From 8c43ba730153dcec992b6204318c1aaf5a58d1c9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 2 May 2026 15:24:26 -0400 Subject: [PATCH] docs(#3025): MCP tool schema as a context-budget concern (#3032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .changeset/mcp-token-budget-docs.md | 5 + docs/USER-GUIDE.md | 23 ++ get-shit-done/references/context-budget.md | 36 +++ .../feat-3025-mcp-token-budget-docs.test.cjs | 301 ++++++++++++++++++ 4 files changed, 365 insertions(+) create mode 100644 .changeset/mcp-token-budget-docs.md create mode 100644 tests/feat-3025-mcp-token-budget-docs.test.cjs diff --git a/.changeset/mcp-token-budget-docs.md b/.changeset/mcp-token-budget-docs.md new file mode 100644 index 000000000..03b35a7c8 --- /dev/null +++ b/.changeset/mcp-token-budget-docs.md @@ -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. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index b37a62799..0f87c4ed8 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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.`, 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. diff --git a/get-shit-done/references/context-budget.md b/get-shit-done/references/context-budget.md index 19df5eb8b..6c40c7bfd 100644 --- a/get-shit-done/references/context-budget.md +++ b/get-shit-done/references/context-budget.md @@ -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. diff --git a/tests/feat-3025-mcp-token-budget-docs.test.cjs b/tests/feat-3025-mcp-token-budget-docs.test.cjs new file mode 100644 index 000000000..8f2ec9330 --- /dev/null +++ b/tests/feat-3025-mcp-token-budget-docs.test.cjs @@ -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)}`); + }); +});