* chore(#2896): convert CONTEXT.md prose defect registry into enforced gates Squashes the prior 4-commit sequence and fixes defects found while resuming this branch: 5 orphaned/corrupted DEFECT fragment lines left by an earlier botched edit, 17 "Source of truth: Memtrace `find_symbol`" placeholders that had destroyed real file-path citations, and 3 DEFECT.GENERATIVE-* entries merged into one RULESET.GENERATIVE-FIX predicate (policy, not an unenforced defect) to satisfy the zero DEFECT.<NAME>.<field>= acceptance criterion. Six mechanizable defects get real gates: DEFECT.UNBOUNDED-SUBPROCESS (eslint-rules/require-subprocess-timeout.cjs), DEFECT.CANARY-VERSION-LEAK (scripts/lint-canary-version-leak.cjs + version-gate.yml), DEFECT.CHANGESET-PR-FIELD-DRIFT (findPrFieldDrift in changeset/lint.cjs), DEFECT.FRONTMATTER-SCALAR-BROAD-GREP, DEFECT.REMOVED-BUT-NEEDED, and DEFECT.DEFAULT-FLIP-DOCUMENTATION (new lint scripts, wired into lint:ci). Already-enforced and unenforceable prose entries are deleted; the gate is the record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#2896): route the new lint tests' subprocess calls through the bounded process-seam helper The 4 new test files for this PR's lint checks called cp.spawnSync/ execFileSync directly with no timeout, tripping this repo's own existing local/no-unbounded-spawn ESLint rule. Route every one through runNode/gitOrThrow (tests/helpers/process-seam.cjs, tests/helpers/git-fixture.cjs) instead, matching the pattern already used elsewhere in the suite (e.g. tests/changeset-lint.test.cjs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: register claude-orchestration.cjs and regenerate stale generated indexes Pre-existing drift on next, unrelated to this PR's own change, surfaced by running lint:ci as part of verifying #2896: two cli_modules (claude-orchestration.cjs, write-set.cjs) landed without a manifest regen, and CONTEXT.md's own edits in this PR staled its two generated indexes. Adds the missing docs/INVENTORY.md row for claude-orchestration.cjs (write-set.cjs already had one — only its manifest entry was stale) and regenerates docs/INVENTORY-MANIFEST.json, docs/CONTEXT-INDEX.json, and examples/dynamic-context-management/CONTEXT-INDEX.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): default-flip-documentation lint's local fallback base was main, not next Found in review: every other base-ref fallback in this repo (see scripts/changeset/lint.cjs's DEFAULT_BASE, #2988) defaults to `next`, the integration branch every PR actually targets — `main` is the release branch. This script's local fallback (used only when GITHUB_BASE_REF is unset, i.e. never in CI, but potentially on a local or direct invocation) diffed against the wrong ref. No test exercised the unset-env-var path, so it shipped unnoticed; every e2e test sets GITHUB_BASE_REF explicitly and is unaffected by this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): stale eslint comment, overclaiming CONTEXT.md wording, and an incompletely-regenerated manifest Found by the isolated Standards code-review pass: - eslint.config.mjs's require-subprocess-timeout comment said "'warn' for now... flip to 'error' once migrated" while the rule already shipped as 'error' with all 8 sites migrated in the same commit — described a state that never existed. - The CONTEXT.md pointer block claimed the rule's bounded call sites "never throw", but roadmap-upgrade.cts's pre-mutation clean-tree check correctly still throws on failure (it gates a destructive real-run migration; degrading to "assume clean" would risk clobbering uncommitted work) — softened the claim to describe both shapes accurately instead of overclaiming one. - docs/INVENTORY-MANIFEST.json's claude-orchestration.cjs/write-set.cjs entries from the prior "fix: register claude-orchestration.cjs..." commit didn't actually land — re-running the generator now includes them; lint:generated-sync is green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#2896): backfill changeset pr field with the real PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): normalize buildCorpus file paths to POSIX in lint-removed-but-needed Windows CI caught it: path.relative(root, abs) returns backslash- separated paths on Windows, but findSurvivingReferences's package-lock special case does file.startsWith('.github/workflows') — a forward- slash literal. On Windows the check silently never matched, so tests/removed-but-needed-lint.test.cjs's real-defect-shape fixture got exit 0 instead of the expected exit 1. Normalize at the production source (RULESET.CONTENT-PATH-NORMALIZATION) rather than the test side. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
182 lines
7.3 KiB
JavaScript
182 lines
7.3 KiB
JavaScript
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
/**
|
|
* Frontmatter-scalar-broad-grep lint (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP,
|
|
* CONTEXT.md).
|
|
*
|
|
* scripts/lint-frontmatter-scalar-broad-grep.cjs flags a `grep "^key:"` over
|
|
* a whole markdown report (not scoped to the frontmatter block, no -m1/
|
|
* `head -1` single-match guard) whose result feeds an exact-token comparison
|
|
* — the #586/#651 bug class where a body line beginning `key:` concatenates
|
|
* onto the intended frontmatter value and misroutes a valid state.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-frontmatter-scalar-broad-grep.cjs');
|
|
const { findBroadGrepsInBlock, extractBashBlocks, scan } = require(LINT_SCRIPT);
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
|
|
describe('frontmatter-scalar-broad-grep lint: findBroadGrepsInBlock (pure)', () => {
|
|
test('the real #586/#651 defect shape IS flagged: whole-file grep, no scope, no -m1, piped to cut|tr', () => {
|
|
const lines = [
|
|
'grep "^status:" "${QUICK_DIR}/${quick_id}-VERIFICATION.md" | cut -d: -f2 | tr -d \' \'',
|
|
];
|
|
const findings = findBroadGrepsInBlock(lines);
|
|
assert.equal(findings.length, 1);
|
|
assert.equal(findings[0].key, 'status');
|
|
});
|
|
|
|
test('a variable captured from a broad grep and later compared with == is also flagged', () => {
|
|
const lines = [
|
|
'STATUS=$(grep "^status:" "$FILE")',
|
|
'if [ "$STATUS" == "passed" ]; then echo ok; fi',
|
|
];
|
|
const findings = findBroadGrepsInBlock(lines);
|
|
assert.equal(findings.length, 1);
|
|
});
|
|
|
|
test('LOOKALIKE: sed-scoped to the frontmatter block is NOT flagged', () => {
|
|
const lines = [
|
|
'sed -n \'/^---$/,/^---$/p\' "$f" | grep -m1 "^status:" | cut -d: -f2 | tr -d \' \'',
|
|
];
|
|
assert.deepEqual(findBroadGrepsInBlock(lines), []);
|
|
});
|
|
|
|
test('LOOKALIKE: -m1 on the grep itself is NOT flagged even without a preceding scope', () => {
|
|
const lines = [
|
|
'grep -m1 "^status:" "$FILE" | cut -d: -f2 | tr -d \' \'',
|
|
];
|
|
assert.deepEqual(findBroadGrepsInBlock(lines), []);
|
|
});
|
|
|
|
test('LOOKALIKE: piped to `head -1` immediately after grep is NOT flagged (frontmatter is always first)', () => {
|
|
const lines = [
|
|
'AUDIT_STATUS=$(grep "^status:" "${AUDIT_FILE}" 2>/dev/null | head -1 | cut -d: -f2 | tr -d \' \')',
|
|
];
|
|
assert.deepEqual(findBroadGrepsInBlock(lines), []);
|
|
});
|
|
|
|
test('LOOKALIKE: a frontmatter block already extracted into a variable (JS regex idiom), then multiple keys parsed from it', () => {
|
|
const lines = [
|
|
'FRONTMATTER=$(node -e "',
|
|
' const m = content.match(/^---\\n([\\s\\S]*?)\\n---/);',
|
|
' if (m) process.stdout.write(m[1]);',
|
|
'")',
|
|
'STATUS=$(echo "$FRONTMATTER" | grep "^status:" | cut -d: -f2 | xargs)',
|
|
'FILES_REVIEWED=$(echo "$FRONTMATTER" | grep "^files_reviewed:" | cut -d: -f2 | xargs)',
|
|
];
|
|
assert.deepEqual(findBroadGrepsInBlock(lines), []);
|
|
});
|
|
|
|
test('LOOKALIKE: an explicit `# lint-allow:` suppression comment silences the finding', () => {
|
|
const lines = [
|
|
'# lint-allow: frontmatter-scalar-broad-grep — intentional multi-file scan, not a single report',
|
|
'grep "^status:" reports/*.md | cut -d: -f2 | tr -d \' \'',
|
|
];
|
|
assert.deepEqual(findBroadGrepsInBlock(lines), []);
|
|
});
|
|
|
|
test('a grep not piped to cut/tr and never compared is NOT flagged (not a token-comparison use)', () => {
|
|
const lines = ['grep -c "^status:" "$FILE"'];
|
|
assert.deepEqual(findBroadGrepsInBlock(lines), []);
|
|
});
|
|
});
|
|
|
|
describe('frontmatter-scalar-broad-grep lint: extractBashBlocks (pure)', () => {
|
|
test('extracts a fenced ```bash block and reports its 1-indexed start line', () => {
|
|
const text = [
|
|
'intro',
|
|
'```bash',
|
|
'echo hi',
|
|
'```',
|
|
'outro',
|
|
].join('\n');
|
|
const blocks = extractBashBlocks(text);
|
|
assert.equal(blocks.length, 1);
|
|
assert.equal(blocks[0].startLine, 3);
|
|
assert.deepEqual(blocks[0].lines, ['echo hi']);
|
|
});
|
|
|
|
test('a non-bash fenced block (e.g. ```json) is ignored', () => {
|
|
const text = ['```json', '{"a":1}', '```'].join('\n');
|
|
assert.deepEqual(extractBashBlocks(text), []);
|
|
});
|
|
});
|
|
|
|
describe('frontmatter-scalar-broad-grep lint: the live repo is clean', () => {
|
|
test('scan() finds zero offenders in the real workflow/agent/command markdown', () => {
|
|
const offenders = scan();
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'un-scoped frontmatter-scalar grep(s) found:\n' + offenders.map((o) => ` ${o.file}:${o.line} ${o.snippet}`).join('\n'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('frontmatter-scalar-broad-grep lint: main() end-to-end wiring', () => {
|
|
test('exit 0 on the real repo tree', () => {
|
|
const result = runNode([LINT_SCRIPT], { cwd: ROOT });
|
|
assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`);
|
|
});
|
|
|
|
test('exit 1 on a fixture reproducing the real defect shape', (t) => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-frontmatter-grep-lint-e2e-'));
|
|
t.after(() => cleanup(tmpDir));
|
|
const workflowsDir = path.join(tmpDir, 'gsd-core', 'workflows');
|
|
fs.mkdirSync(workflowsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(workflowsDir, 'quick.md'),
|
|
[
|
|
'# Quick',
|
|
'```bash',
|
|
'grep "^status:" "${QUICK_DIR}/${quick_id}-VERIFICATION.md" | cut -d: -f2 | tr -d \' \'',
|
|
'```',
|
|
].join('\n'),
|
|
);
|
|
const scriptCopyDir = path.join(tmpDir, 'scripts');
|
|
fs.mkdirSync(scriptCopyDir, { recursive: true });
|
|
const scriptCopy = path.join(scriptCopyDir, 'lint-frontmatter-scalar-broad-grep.cjs');
|
|
fs.copyFileSync(LINT_SCRIPT, scriptCopy);
|
|
fs.mkdirSync(path.join(scriptCopyDir, 'lib'), { recursive: true });
|
|
fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(scriptCopyDir, 'lib', 'cli-exit.cjs'));
|
|
|
|
const result = runNode([scriptCopy]);
|
|
assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}`);
|
|
assert.match(result.stderr, /FRONTMATTER-SCALAR-BROAD-GREP/);
|
|
});
|
|
|
|
test('exit 0 on a fixture that is properly scoped (no false positive)', (t) => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-frontmatter-grep-lint-e2e-clean-'));
|
|
t.after(() => cleanup(tmpDir));
|
|
const workflowsDir = path.join(tmpDir, 'gsd-core', 'workflows');
|
|
fs.mkdirSync(workflowsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(workflowsDir, 'quick.md'),
|
|
[
|
|
'# Quick',
|
|
'```bash',
|
|
'sed -n \'/^---$/,/^---$/p\' "$f" | grep -m1 "^status:" | cut -d: -f2 | tr -d \' \'',
|
|
'```',
|
|
].join('\n'),
|
|
);
|
|
const scriptCopyDir = path.join(tmpDir, 'scripts');
|
|
fs.mkdirSync(scriptCopyDir, { recursive: true });
|
|
const scriptCopy = path.join(scriptCopyDir, 'lint-frontmatter-scalar-broad-grep.cjs');
|
|
fs.copyFileSync(LINT_SCRIPT, scriptCopy);
|
|
fs.mkdirSync(path.join(scriptCopyDir, 'lib'), { recursive: true });
|
|
fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(scriptCopyDir, 'lib', 'cli-exit.cjs'));
|
|
|
|
const result = runNode([scriptCopy]);
|
|
assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`);
|
|
});
|
|
});
|