* chore(#604): rename get-shit-done/ runtime directory to gsd-core/ Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary (`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers are unaffected. Mechanical (bulk, ~90% of the diff): - `git mv get-shit-done gsd-core` - Swept path/identifier references across the repo via `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead preserves the five legitimate slug variants that are NOT the directory: get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names). - Build/manifest wiring: package.json (bin, files, coverage globs), tsconfig.build.json (outDir), ~86 .gitignore build-output entries, stryker.config.mjs, scan-ignore files, install.js path strings. - Frozen (not rewritten): CHANGELOG.md history; translated docs (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/). New logic (review here): - src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper ADR-0008 installer migration. On upgrade it walks the legacy `~/.claude/get-shit-done/` tree, classifies each file via the prior install manifest, and emits remove-managed / backup-and-remove for managed files while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked root and symlinked entries; bounds-checks every path under configDir). The framework rolls back on install failure. Emptied dirs may remain (framework has no recursive dir-removal primitive) — documented. - scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare `get-shit-done` directory token (split token to avoid self-match; case- insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines). Wired into the lint-tests CI job. - Restored scripts/lint-package-identity-drift.cjs detection regexes (the mechanical sweep had wrongly rewritten the old-name patterns it exists to detect) and marked them as intentional legacy references. - TDD tests for the migration and the guard; do.md slash-command guard regex tightened so a `/gsd-core/bin` path segment is not mistaken for a command; changeset + docs/installer-migrations.md row added. Breaking: the installed runtime path moves `~/.claude/get-shit-done/` -> `~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed files (preserving user files) on upgrade. Users with custom hooks/configs hardcoding the old path must update them. Closes #604 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unsweep pending changesets + allowlist injection-example docs CI fixes for the rename PR: - Do not sweep pending .changeset/*.md (ephemeral release-note fragments, like CHANGELOG); reverted those body edits so 5 pre-existing malformed fragments (missing type/pr) no longer enter the PR diff and trip docs-lint. Allowlisted .changeset/ in the legacy-name guard accordingly. - Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in prompt-injection-scan.sh: they contain intentional injection examples / security-model prose; the path-reference rewrites are kept. CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR; none in the new migration/guard) and are out of scope for the rename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): resolve CodeQL alerts surfaced on this PR The rename diff touched files carrying pre-existing CodeQL findings; per the no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving them off. All behavior-preserving: - scripts/ci-test-scope.cjs: build the config-path match from string .includes() instead of a RegExp over an arg-derived value (js/regex-injection). - src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName so the table-cell escape is complete (js/incomplete-sanitization). - tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization). - tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace, keep the meaningful POSIX-class conversion (js/identity-replacement). Verified: build:lib green; the touched test files + ci-test-scope + profile-output suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization) The prior commit's fixes for two alerts were ineffective: - ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file` reaching static regex `.test(file)` calls (not the config rule). Removed ALL regex over file/t — startsWith/includes/=== string checks + an isWindowsHint helper — so there is no regex sink for the tainted value. - js/incomplete-multi-character-sanitization (3 test files): a single `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint loop (replace until stable) plus a final bare-opener strip. Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL CodeQL flags the regex PATTERNS syntactically (regex-injection on the --files arg split; incomplete-multi-character-sanitization on the <!--...--> replace), so loop fixes do not satisfy it. Made these paths regex-free: - ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/). - 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)). Behavior preserved; ci-test-scope + the 3 suites pass; guard clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unblock security base64 scan on the large rename diff The security job hit its 10m timeout: base64-scan.sh choked on the binary test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/ non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings), and the ~800-file rename diff is slow to scan regardless. - scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they can't carry base64-obfuscated *text* and feeding NUL bytes through the per-line scanner is pathologically slow. collect_files already filtered binary *extensions*; this catches binary *content* in text extensions. - .github/workflows/security-scan.yml: raise the security job timeout 10m->30m to accommodate very large diffs (the scan itself is unchanged). Verified locally: scan skips the fixture, 0 "ignored null byte" warnings, 0 findings, exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): sweep get-shit-done refs introduced by merging next The branch was updated with next (#614/#384/#618 etc.), which reference the get-shit-done/ dir (still named that on next). Swept the stale references in the merged files to gsd-core so the rename stays consistent and lint:legacy-name passes: - commands/gsd/discuss-phase.md (runtime-launcher shim paths) - src/core.cts (getAgentsDir layout comments) - tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib) Verified: guard 0 violations; build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant The #614 runtime-launcher shim added to discuss-phase.md references `${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it mis-read the directory path as a dangling `/gsd-core` command ref (same class as the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path segments are not treated as slash-command references. Verified locally on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22 image) full suite: 0 failures - bug-3683 + bug-2954 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI) CI intermittently failed state.test's gsd-tools subprocess with "findProjectRoot is not a function" (flip-flopping across legs; not reproducible on mac full suite, gsd-test linux full suite, test:unit, or state.test x8). findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs); binding it via destructure at module-load can be undefined under a load-ordering edge. Resolve it lazily at call time via a small wrapper so the lookup happens after core.cjs is fully initialized. Verified green on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22) full suite: 0 failures - state.test.cjs: 106/106; gsd-tools loads cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): allowlist verification-patterns.md placeholder examples in secret scan The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var examples (illustrative Stripe test-key / database-URL / API-key placeholders) — not real credentials. Added it to .secretscanignore with the strict annotation, mirroring the existing gsd-core/workflows/plan-phase.md exception. Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next exits 0 with 0 findings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
14 KiB
JavaScript
302 lines
14 KiB
JavaScript
/**
|
||
* Documentation regression test for issue #3025 — MCP token-budget guidance.
|
||
*
|
||
* Verifies that gsd-core/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, 'gsd-core', '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)}`);
|
||
});
|
||
});
|