docs(#3025): MCP tool schema as a context-budget concern (#3032)

* docs(#3025): MCP tool schema as a context-budget concern

Adds documentation covering the largest GSD cost lever that GSD
itself does not own: MCP tool schema injection. Every enabled MCP
server adds its schema to every turn (often 20k+ tokens for
heavyweight servers like browser/playwright, mac-tools, etc.),
which can dwarf whatever `model_profile` tuning saves.

Two doc surfaces (per the bundle-docs-with-code skill depth gradient):

1. get-shit-done/references/context-budget.md
   - New "MCP Tool Schema Cost (Harness Concern)" section.
   - Explains schemas-per-turn cost framing.
   - Names enabledMcpjsonServers / disabledMcpjsonServers and
     .claude/settings.json explicitly.
   - Pre-phase audit checklist: browser/playwright, platform-specific,
     cross-project/stale, duplicate/shadow.
   - Explicit "GSD does not manage MCP enablement — harness concern"
     statement so users don't hunt for a GSD setting.
   - Links to Anthropic Claude Code MCP docs as canonical reference.
   - Notes compounding interaction with model_profile (additive levers).

2. docs/USER-GUIDE.md
   - New task-oriented "Trim MCP servers to reduce per-turn cost"
     section above "Using Non-Claude Runtimes".
   - Same checklist condensed.
   - Cross-link to context-budget.md for the full reference.

Tests:
- tests/feat-3025-mcp-token-budget-docs.test.cjs (12 cases) parses
  both docs into typed semantic-flag records and asserts behavioral
  invariants (mentions key, includes audit, names harness, etc.)
  rather than substring-matching prose. Adheres to CONTRIBUTING.md
  no-source-grep — section can be reworded freely as long as the
  required semantics survive.
- Markdownlint pre-flight tests (MD040 fence language, MD056 table
  column count) per the bundle-docs-with-code skill so CR can't
  ratchet on prose nitpicks across multiple review rounds.

Verification:
- 12/12 pass on regression test
- 6857/6857 full suite (12 net new)
- lint-no-source-grep clean (377 test files)

Companion to #3023 (per-phase-type model map) and #3024 (dynamic
routing). Together they cover the three biggest cost levers users
ask about; this issue covers the one GSD does not own.

Closes #3025

* docs(#3025): batch 3 CR fixes — pr id, relative link, named flag

CodeRabbit on PR #3032 (3 minor — 2 inline + 1 nitpick), all in one
push per the bundle-docs-with-code skill (avoid per-round nitpick
ratchet):

1. Inline (Minor) — .changeset/mcp-token-budget-docs.md:3
   `pr: TBD` → `pr: 3032` so changeset tooling can link the entry.

2. Inline (Minor) — docs/USER-GUIDE.md:1101
   Used a hardcoded `https://github.com/.../blob/main/...` URL for the
   cross-link to `context-budget.md`. Rest of USER-GUIDE.md uses
   relative links. Switched to `../get-shit-done/references/context-
   budget.md#mcp-tool-schema-cost-harness-concern` so feature-branch
   work shows the right content and rename-resilience is preserved.

3. Nitpick — tests/feat-3025-mcp-token-budget-docs.test.cjs:234
   The cross-link assertion used an inline `/context-budget/i.test(...)`
   while every other invariant in the file lived as a named flag in
   `parseMcpBudgetSection`. Per CONTRIBUTING.md no-source-grep, added
   `crossLinksContextBudget` to the parser and asserted on
   `parsed.crossLinksContextBudget` so the cross-link rule sits next
   to its siblings.

Verification:
- 12/12 pass on regression test (no count change; refactor only)
- No source code changes, only docs + tests

* test(#3025): strip inline markdown before phrase-match (CR nitpick)

CodeRabbit caught that the `explainsHarnessNotGsd` primary regex
branch couldn't match "GSD does **not** manage" in
context-budget.md because the markdown bold markers (`**`) sit
between contiguous words. The test passed today only via the
fallback `harness (concern|setting|controlled)` branch — the
primary branch was effectively dead code.

Fix: strip inline markdown emphasis (`**`, `*`, `~~`) and inline-
code backticks before any phrase-matching in `parseMcpBudgetSection`.
All seven flag computations now run against the stripped text so
markdown formatting can't silently invalidate any invariant.

Underscores are intentionally NOT stripped — `model_profile` and
other snake_case identifiers must survive intact for the
mentionsModelProfileInteraction check to find them.

Verification: 12/12 still pass; primary branches now fire on
real markdown content rather than relying on fallbacks.

* test(#3025): guard markdownlint tests against null section (CR nitpick)

CodeRabbit caught that the MD040 and MD056 markdownlint pre-flight
tests called `section.match(...)` and `section.split('\n')`
directly on the value returned by `extractSection`, which returns
null when no matching header is found. If the MCP section is ever
removed (regression), both tests would throw `TypeError: Cannot
read properties of null` instead of producing a clean assertion
failure naming the actual problem.

The semantic tests above are protected because parseMcpBudgetSection
short-circuits to a typed-falsy record on null input. The
markdownlint tests bypassed that guard since they need raw section
text, not parsed flags. Added `assert.ok(section, ...)` preconditions
to both so a missing section produces a meaningful failure message.

No content changes; defensive programming only.

Verification: 12/12 still pass.
This commit is contained in:
Tom Boucher
2026-05-02 15:24:26 -04:00
committed by GitHub
parent e1d661ece0
commit 8c43ba7301
4 changed files with 365 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3032
---
**Documentation: MCP tool schema as a context-budget concern (#3025).** Adds new sections to `get-shit-done/references/context-budget.md` and `docs/USER-GUIDE.md` explaining that every enabled MCP server injects its tool schema into every turn — heavyweight servers (browser/playwright, Mac-tools, Windows-tools) can cost 20k+ tokens each, often dwarfing what `model_profile` tuning saves. The toggle lives in `.claude/settings.json` (`enabledMcpjsonServers` / `disabledMcpjsonServers`) and is a Claude Code harness concern, not a GSD concern. Includes a pre-phase audit checklist (browser, platform-specific, cross-project, duplicates) and notes the multiplier interaction with `model_profile`. Companion to #3023 (per-phase-type model map) and #3024 (dynamic routing); together they cover the three biggest cost levers.

View File

@@ -1077,6 +1077,29 @@ To turn it off, set `dynamic_routing.enabled: false` (the default) — behavior
For the full agent → tier mapping and resolution-precedence rules, see [Dynamic Routing](CONFIGURATION.md#dynamic-routing-with-failure-tier-escalation-dynamic_routing--added-in-v140) in the configuration reference.
### Trim MCP servers to reduce per-turn cost (the biggest lever GSD doesn't own)
Before tuning `model_profile` or `models.<phase_type>`, audit which **MCP servers** your harness has enabled. Every enabled MCP server injects its tool schema into every turn — heavyweight servers like browser/playwright tools or platform-specific helpers can cost 20k+ tokens each, often dwarfing whatever GSD's resolver can save.
This is a **harness setting**, not a GSD setting. The toggle lives in `.claude/settings.json`:
```json
{
"enabledMcpjsonServers": ["context7"],
"disabledMcpjsonServers": ["playwright", "mac-tools"]
}
```
Quick audit before a long phase:
- Are any browser / playwright tools enabled when this phase has no UI work?
- Are any platform-specific tools (Mac-tools, Windows-tools, OS-specific) enabled when not needed?
- Are any project-specific MCPs from a different project still enabled here?
Each disabled server removes its schema from every subsequent turn for the rest of the session. Trimming MCPs **compounds** with `model_profile` tuning — both levers are additive, and MCP savings show up immediately across every subagent the orchestrator spawns.
For the full audit, harness reference, and the composition note with `model_profile`, see [MCP Tool Schema Cost](../get-shit-done/references/context-budget.md#mcp-tool-schema-cost-harness-concern) in the bundled `context-budget.md` reference.
### Using Non-Claude Runtimes (Codex, OpenCode, Gemini CLI, Kilo)
If you installed GSD for a non-Claude runtime, the installer already configured model resolution so all agents use the runtime's default model. No manual setup is needed. Specifically, the installer sets `resolve_model_ids: "omit"` in your config, which tells GSD to skip Anthropic model ID resolution and let the runtime choose its own default model.

View File

@@ -47,3 +47,39 @@ Quality degrades gradually before panic thresholds fire. Watch for these early s
- **Skipped steps** -- agent omits protocol steps it would normally follow. If an agent's success criteria has 8 items but it only reports 5, suspect context pressure.
When delegating to agents, the orchestrator cannot verify semantic correctness of agent output -- only structural completeness. This is a fundamental limitation. Mitigate with must_haves.truths and spot-check verification.
## MCP Tool Schema Cost (Harness Concern)
Every enabled MCP server injects its tool schema into **every turn**, regardless of whether you call any of its tools. Heavyweight servers can cost 20k+ tokens per turn each — often dwarfing whatever GSD itself can save through `model_profile` tuning. This is a Claude Code harness concern, not a GSD concern: GSD does **not** manage MCP enablement. The toggle lives in `.claude/settings.json` under `enabledMcpjsonServers` and `disabledMcpjsonServers`.
### Why this is the biggest cost lever you don't own
Tool schemas count against the same context budget as model context, prompts, and conversation history. If a project has 5 unused MCP servers averaging 5k tokens of schema each, every turn pays a 25k-token tax before the assistant reads a single project file. Trimming MCPs has a **multiplier effect** that compounds with whichever `model_profile` you've chosen — every-turn overhead drops regardless of which model is in use.
### Pre-Phase MCP Audit
Before starting a long phase (especially `/gsd-execute-phase`, `/gsd-plan-phase`, or anything that fans out across many subagents), run this audit:
- [ ] **Browser / playwright tools enabled?** If this phase has no UI work, disable them. They're among the heaviest per-turn schemas.
- [ ] **Platform-specific tools enabled?** Mac-tools / Windows-tools / OS-specific helpers should be disabled when not actively needed for the phase at hand.
- [ ] **Cross-project / stale MCPs?** Servers added for a different project that are still enabled here. These are often forgotten and pay a per-turn tax for zero benefit.
- [ ] **Duplicate or shadow servers?** Two MCPs offering similar tools (e.g. two different filesystem helpers). Keep one.
Each item disabled removes its schema from every subsequent turn for the rest of the session.
### How to toggle
The keys live in `.claude/settings.json` (project) or `~/.claude/settings.json` (global) — **not** in `.planning/config.json`:
```json
{
"enabledMcpjsonServers": ["context7"],
"disabledMcpjsonServers": ["playwright", "mac-tools"]
}
```
Either list works — `enabledMcpjsonServers` is an explicit allow-list, `disabledMcpjsonServers` is a block-list against the default. See the [Claude Code MCP documentation](https://docs.anthropic.com/en/docs/claude-code/mcp) for the canonical reference; this section just flags it as a context-budget lever GSD users routinely overlook.
### Composition with model_profile
Trimming MCPs and tuning `model_profile` are independent levers that **compound**. Disabling a 25k-token MCP saves 25k per turn whether you're running `quality` (opus everywhere) or `budget` (sonnet/haiku); the savings are additive, not in lieu of model tuning. Don't pick one — do both, and audit MCPs first because the per-turn savings show up immediately and stack across every subagent the orchestrator spawns.

View File

@@ -0,0 +1,301 @@
/**
* Documentation regression test for issue #3025 — MCP token-budget guidance.
*
* Verifies that get-shit-done/references/context-budget.md contains the
* structural elements the issue requires:
*
* 1. A section explaining MCP/tool schemas as a context-budget concern
* 2. References to the harness-side toggles (enabledMcpjsonServers,
* disabledMcpjsonServers in .claude/settings.json)
* 3. A pre-phase audit checklist (browser/playwright, platform-specific,
* project-specific)
* 4. An explicit note that GSD does NOT manage MCP enablement — this is
* a Claude Code harness concern (with a cross-link)
* 5. Note the interaction with model_profile (compounding levers)
*
* Tests parse the doc into a typed section record (parseMcpSection) and
* assert on flag booleans, not raw text matches. Adheres to
* CONTRIBUTING.md "no-source-grep" — describes invariants, not wording,
* so the prose can be reworded freely as long as the semantics survive.
*
* Companion to docs/USER-GUIDE.md task section, which is exercised by the
* same parser shape (separate test below).
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const CONTEXT_BUDGET_MD = path.join(ROOT, 'get-shit-done', 'references', 'context-budget.md');
const USER_GUIDE_MD = path.join(ROOT, 'docs', 'USER-GUIDE.md');
/**
* Extract the MCP-budget section from a markdown file by header text.
* Returns null if the section is missing. Section runs from the matching
* `## ` header up to the next `## ` header (or EOF).
*/
function extractSection(filePath, headerSubstring) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
let inSection = false;
let startDepth = 0;
const collected = [];
for (const line of lines) {
const headerMatch = /^(#+)\s/.exec(line);
if (headerMatch) {
const depth = headerMatch[1].length;
if (inSection) {
// Section ends at a header at the same or shallower depth.
// Subsections at deeper depth are part of the section.
if (depth <= startDepth) break;
} else if (line.toLowerCase().includes(headerSubstring.toLowerCase())) {
inSection = true;
startDepth = depth;
}
}
if (inSection) collected.push(line);
}
return collected.length > 0 ? collected.join('\n') : null;
}
/**
* Parse the MCP-budget section into a typed semantic-flag record.
* Each flag answers a single behavioral question that #3025 requires
* the prose to encode.
*/
function parseMcpBudgetSection(section) {
if (!section || typeof section !== 'string') {
return {
ok: false,
sectionLength: 0,
explainsMcpAsBudgetConcern: false,
namesEnabledMcpjsonServers: false,
namesDisabledMcpjsonServers: false,
namesClaudeSettingsJson: false,
includesPrePhaseAudit: false,
auditMentionsBrowserOrPlaywright: false,
auditMentionsPlatformSpecific: false,
auditMentionsCrossProject: false,
explainsHarnessNotGsd: false,
mentionsModelProfileInteraction: false,
crossLinksContextBudget: false,
};
}
// CR follow-up: strip inline markdown emphasis (`**`, `*`, `~~`) and
// backticks before phrase-matching so e.g. "GSD does **not** manage"
// is caught by the primary `gsd does not manage` alternative below.
// WITHOUT this, the markdown-bold breaks the contiguous match and the
// test only passes via the fallback branch (silent dead code).
// Underscores are intentionally NOT stripped — `model_profile` and
// other snake_case identifiers must survive intact so the
// model_profile interaction check still finds them.
const stripped = section.replace(/\*{1,3}|~{2}|`/g, '');
// (1) Explains MCP as budget concern — must mention BOTH "MCP" / "tool
// schema" AND a token/cost framing.
const explainsMcpAsBudgetConcern =
/\bmcp\b|tool schema|tool schemas/i.test(stripped) &&
/\btoken|context budget|per[- ]turn|cost\b/i.test(stripped);
// (2) Names the harness keys verbatim
const namesEnabledMcpjsonServers = /enabledMcpjsonServers/.test(stripped);
const namesDisabledMcpjsonServers = /disabledMcpjsonServers/.test(stripped);
// (3) Names the settings file location
const namesClaudeSettingsJson = /\.claude\/settings\.json/.test(stripped);
// (4) Audit checklist — must mention all three classes the issue
// calls out, plus a "before this phase / pre-phase" framing
const includesPrePhaseAudit =
/audit|checklist|review (your )?mcp|before (starting|beginning) (a |the )?phase/i.test(stripped);
const auditMentionsBrowserOrPlaywright = /\bbrowser\b|playwright/i.test(stripped);
const auditMentionsPlatformSpecific = /platform[- ]specific|mac[- ]?tools|windows[- ]?tools|os[- ]specific/i.test(stripped);
const auditMentionsCrossProject = /(other|different|cross[- ])\s*project|stale (project )?mcp/i.test(stripped);
// (5) Harness vs GSD distinction — must explicitly state GSD doesn't
// own this knob and point at the harness
const explainsHarnessNotGsd =
/(gsd does(?:n[''’]t| not) (own|manage|control)|harness (concern|setting|controlled)|not a gsd (setting|knob))/i.test(stripped);
// (6) Compounding with model_profile
const mentionsModelProfileInteraction =
/model[_ ]profile/i.test(stripped) &&
/compound|multiplier|stack|every[- ]turn|regardless of (which )?model|in addition/i.test(stripped);
// (7) Cross-link to the canonical reference doc — task-guide section
// must point readers at context-budget.md for the full audit. Encoded
// as a named flag (CR follow-up) so the assertion sits alongside the
// other parsed invariants rather than as a one-off inline regex.
const crossLinksContextBudget = /context-budget/i.test(stripped);
return {
ok: true,
sectionLength: section.length,
explainsMcpAsBudgetConcern,
namesEnabledMcpjsonServers,
namesDisabledMcpjsonServers,
namesClaudeSettingsJson,
includesPrePhaseAudit,
auditMentionsBrowserOrPlaywright,
auditMentionsPlatformSpecific,
auditMentionsCrossProject,
explainsHarnessNotGsd,
mentionsModelProfileInteraction,
crossLinksContextBudget,
};
}
// ─── context-budget.md ──────────────────────────────────────────────────────
describe('#3025 context-budget.md: MCP token-budget section exists with required content', () => {
test('the file exists', () => {
assert.ok(fs.existsSync(CONTEXT_BUDGET_MD), `expected file at ${CONTEXT_BUDGET_MD}`);
});
test('has a section header that mentions MCP', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
assert.ok(section, 'must have a `## ...MCP...` heading; section was not found');
});
test('explains MCP/tool schemas as a context-budget concern (#3025 requirement 1)', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
const parsed = parseMcpBudgetSection(section);
assert.equal(parsed.explainsMcpAsBudgetConcern, true,
`must explain MCP/tool schemas as a token/context-budget concern; section was:\n${section}`);
});
test('names enabledMcpjsonServers and disabledMcpjsonServers (#3025 requirement 2)', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
const parsed = parseMcpBudgetSection(section);
assert.equal(parsed.namesEnabledMcpjsonServers, true,
'section must reference `enabledMcpjsonServers` so users know the exact key');
assert.equal(parsed.namesDisabledMcpjsonServers, true,
'section must reference `disabledMcpjsonServers` for parity');
assert.equal(parsed.namesClaudeSettingsJson, true,
'section must name `.claude/settings.json` as the location of the toggle');
});
test('includes a pre-phase audit checklist with all three classes (#3025 requirement 3)', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
const parsed = parseMcpBudgetSection(section);
assert.equal(parsed.includesPrePhaseAudit, true,
'section must include audit/checklist framing for pre-phase MCP review');
assert.equal(parsed.auditMentionsBrowserOrPlaywright, true,
'audit must mention browser/playwright tools as a candidate for disabling');
assert.equal(parsed.auditMentionsPlatformSpecific, true,
'audit must mention platform-specific tools (Mac/Windows/OS-specific)');
assert.equal(parsed.auditMentionsCrossProject, true,
'audit must mention stale/cross-project MCPs from other projects');
});
test('explains GSD does not own MCP enablement — harness concern (#3025 requirement 4)', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
const parsed = parseMcpBudgetSection(section);
assert.equal(parsed.explainsHarnessNotGsd, true,
'section must explicitly state GSD does not manage MCP enablement (harness concern)');
});
test('notes interaction with model_profile (compounding levers) (#3025 requirement 5)', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
const parsed = parseMcpBudgetSection(section);
assert.equal(parsed.mentionsModelProfileInteraction, true,
'section must note that trimming MCPs compounds with model_profile choice');
});
test('full semantic record matches the #3025 contract — typed snapshot', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
const parsed = parseMcpBudgetSection(section);
const contract = {
ok: parsed.ok,
explainsMcpAsBudgetConcern: parsed.explainsMcpAsBudgetConcern,
namesEnabledMcpjsonServers: parsed.namesEnabledMcpjsonServers,
namesDisabledMcpjsonServers: parsed.namesDisabledMcpjsonServers,
namesClaudeSettingsJson: parsed.namesClaudeSettingsJson,
includesPrePhaseAudit: parsed.includesPrePhaseAudit,
auditMentionsBrowserOrPlaywright: parsed.auditMentionsBrowserOrPlaywright,
auditMentionsPlatformSpecific: parsed.auditMentionsPlatformSpecific,
auditMentionsCrossProject: parsed.auditMentionsCrossProject,
explainsHarnessNotGsd: parsed.explainsHarnessNotGsd,
mentionsModelProfileInteraction: parsed.mentionsModelProfileInteraction,
};
assert.deepStrictEqual(contract, {
ok: true,
explainsMcpAsBudgetConcern: true,
namesEnabledMcpjsonServers: true,
namesDisabledMcpjsonServers: true,
namesClaudeSettingsJson: true,
includesPrePhaseAudit: true,
auditMentionsBrowserOrPlaywright: true,
auditMentionsPlatformSpecific: true,
auditMentionsCrossProject: true,
explainsHarnessNotGsd: true,
mentionsModelProfileInteraction: true,
}, 'context-budget.md MCP section contract violated');
});
});
// ─── docs/USER-GUIDE.md task section ────────────────────────────────────────
describe('#3025 docs/USER-GUIDE.md: companion task section exists', () => {
test('USER-GUIDE.md has an MCP-trimming task section', () => {
const section = extractSection(USER_GUIDE_MD, 'mcp');
assert.ok(section,
'USER-GUIDE.md must have a `### ...MCP...` task section so users find it via the guide');
});
test('USER-GUIDE.md task section names the harness key and cross-links the reference', () => {
const section = extractSection(USER_GUIDE_MD, 'mcp');
const parsed = parseMcpBudgetSection(section);
assert.equal(parsed.namesEnabledMcpjsonServers, true,
'task section must mention the harness key by name');
// Cross-link to the reference doc — assert on the parsed flag so
// the invariant lives alongside the other named flags (CR follow-up
// on the no-source-grep standard).
assert.equal(parsed.crossLinksContextBudget, true,
'task section must cross-link to context-budget.md');
});
});
// ─── markdownlint pre-flight (per bundle-docs-with-code skill) ──────────────
describe('#3025 markdownlint pre-flight: MD040 + MD056', () => {
test('every fenced code block in the new MCP section has a language tag (MD040)', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
// Guard: extractSection returns null when the section is missing.
// Without this, `section.match(...)` would throw a TypeError instead
// of producing a meaningful assertion failure (CR follow-up).
assert.ok(section, 'MCP section not found in context-budget.md — cannot check MD040');
const fences = (section.match(/^```([a-zA-Z0-9_+-]*)?\s*$/gm) || []);
// Pairs of fences open/close; odd-indexed ones close blocks. Every
// OPENING fence must have a language tag. Closing fences are bare ```.
// Walk pairs: even index = opener, odd = closer.
const openers = fences.filter((_, i) => i % 2 === 0);
const missing = openers.filter((line) => /^```\s*$/.test(line));
assert.deepStrictEqual(missing, [],
`every fenced code block opener must have a language tag (MD040). Missing: ${JSON.stringify(missing)}`);
});
test('every markdown table row in the new MCP section has the same column count as its header (MD056)', () => {
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
// Guard: same null-section concern as MD040 above (CR follow-up).
assert.ok(section, 'MCP section not found in context-budget.md — cannot check MD056');
const lines = section.split('\n');
// Walk through and detect tables: header row followed by a separator
// (--- pattern) followed by data rows. Count `|` per line.
const issues = [];
for (let i = 0; i < lines.length - 1; i += 1) {
const header = lines[i];
const sep = lines[i + 1];
if (!/^\s*\|.*\|\s*$/.test(header)) continue;
if (!/^\s*\|[\s\-:|]+\|\s*$/.test(sep)) continue;
const headerCols = (header.match(/\|/g) || []).length;
// Walk data rows
for (let j = i + 2; j < lines.length; j += 1) {
const row = lines[j];
if (!/^\s*\|.*\|\s*$/.test(row)) break;
const rowCols = (row.match(/\|/g) || []).length;
if (rowCols !== headerCols) {
issues.push({ line: j, expected: headerCols, actual: rowCols, row });
}
}
}
assert.deepStrictEqual(issues, [],
`table rows must match header column count (MD056). Issues: ${JSON.stringify(issues, null, 2)}`);
});
});