Files
msd-core/scripts/lint-default-flip-documentation.cjs
Tom Boucher bcf7b04864 chore(#2896): convert CONTEXT.md prose defect registry into enforced gates (#3325)
* 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>
2026-08-10 12:55:52 -04:00

194 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* lint-default-flip-documentation.cjs — DEFECT.DEFAULT-FLIP-DOCUMENTATION
* (CONTEXT.md).
*
* ## Why
*
* A PR flips a config default but doesn't call out the migration semantics
* (when the new default takes effect; existing configs vs new configs; what
* the opt-back-in looks like — #3309, the v2 default flip from mid-flight to
* end-of-phase).
*
* ## Scope (deliberately narrower than the full DEFECT.detect clause)
*
* The DEFECT text names two surfaces: `CONFIG_DEFAULTS` and
* `buildNewProjectConfig`. This check covers ONLY the single-source-of-truth
* defaults manifest, `gsd-core/bin/shared/config-defaults.manifest.json`
* (what `CONFIG_DEFAULTS` in `src/configuration.cts` / `src/config.cts`
* actually loads at runtime) — because it is pure JSON, a resolved
* key→value-map diff between base and head is trivially reliable: no line
* movement, reordering, or refactor can ever produce a false "value changed"
* verdict, only an actual value change can.
*
* `buildNewProjectConfig`'s `hardcoded` object literal in `src/config.cts`
* is DELIBERATELY OUT OF SCOPE here. It mixes literal values with
* environment-derived branches (`hasBraveSearch`, etc.) and spreads of
* `CONFIG_DEFAULTS.*` — there is no reliable way to compute its *resolved*
* value map from source text alone without executing the compiled module at
* both refs, and a line/AST-level diff of that literal would inherit exactly
* the false-positive risk (a harmless refactor that moves or restructures
* the literal reads as a "flip") this check exists to avoid. Per the audit's
* own risk callout, a noisy check here is worse than no check — the
* `buildNewProjectConfig` half of the DEFECT stays prose-only.
*
* ## What this checks
*
* If any *value* differs between the base and head resolved manifest
* key→value maps (additions/removals alone don't count as a "flip" — the
* symptom is specifically about an EXISTING default changing), fail unless
* the PR body contains a `## Breaking Changes` (or `# Breaking Changes`)
* heading.
*
* Needs a PR event payload (`GITHUB_EVENT_PATH`) to read the PR body — this
* is a dedicated-workflow check (like `lint-canary-version-leak.cjs`), not a
* `lint:ci` member, since a local/push run has no PR body to check against.
*/
const fs = require('node:fs');
const path = require('node:path');
const cp = require('node:child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.join(__dirname, '..');
const MANIFEST_PATH = path.join('gsd-core', 'bin', 'shared', 'config-defaults.manifest.json');
const BREAKING_CHANGES_RE = /^#{1,6}\s*Breaking Changes\b/im;
/**
* Pure: flatten a nested plain-object JSON value into dot-path
* `{ "a.b.c": value }` leaves. Arrays and primitives are leaves (compared by
* JSON.stringify equality, never recursed into) so array reordering reads as
* one value change, not N.
* @param {unknown} value
* @param {string} prefix
* @param {Record<string, unknown>} out
* @returns {Record<string, unknown>}
*/
function flatten(value, prefix = '', out = {}) {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, v] of Object.entries(value)) {
flatten(v, prefix ? `${prefix}.${key}` : key, out);
}
} else {
out[prefix] = value;
}
return out;
}
/**
* Pure: given two resolved (already-flattened) key→value maps, return the
* keys present in BOTH whose value differs. Additions/removals are NOT
* "flips" — a brand-new default has no prior behavior to contradict.
* @param {Record<string, unknown>} baseMap
* @param {Record<string, unknown>} headMap
* @returns {{ key: string, from: unknown, to: unknown }[]}
*/
function findDefaultValueChanges(baseMap, headMap) {
const changes = [];
for (const key of Object.keys(baseMap)) {
if (!Object.prototype.hasOwnProperty.call(headMap, key)) continue;
if (JSON.stringify(baseMap[key]) !== JSON.stringify(headMap[key])) {
changes.push({ key, from: baseMap[key], to: headMap[key] });
}
}
return changes;
}
/**
* Pure verdict: given the detected default-value changes and the PR body,
* decide pass/fail.
* @param {{ key: string, from: unknown, to: unknown }[]} changes
* @param {string} prBody
* @returns {{ ok: boolean, changes: object[] }}
*/
function evaluateDefaultFlipDoc(changes, prBody) {
if (changes.length === 0) return { ok: true, changes: [] };
if (BREAKING_CHANGES_RE.test(prBody || '')) return { ok: true, changes };
return { ok: false, changes };
}
/**
* Read and JSON.parse the manifest at a given git ref. Returns `{}` when the
* file doesn't exist at that ref (new file, or ref predates it) — that is
* not a "flip", it's an addition, and is silently excluded by
* findDefaultValueChanges's both-sides-present requirement anyway.
* @param {string} root
* @param {string} ref
* @returns {Record<string, unknown>}
*/
function readManifestAtRef(root, ref) {
let raw;
try {
raw = cp.execFileSync('git', ['show', `${ref}:${MANIFEST_PATH.split(path.sep).join('/')}`], {
cwd: root,
encoding: 'utf8',
timeout: 15000,
});
} catch {
return {};
}
try {
return JSON.parse(raw);
} catch (e) {
throw new ExitError(2, `lint-default-flip-documentation: ${ref}:${MANIFEST_PATH} is not valid JSON: ${e.message}`);
}
}
function readPrBody() {
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!eventPath || !fs.existsSync(eventPath)) return null;
try {
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
return typeof event.pull_request?.body === 'string' ? event.pull_request.body : '';
} catch {
return null;
}
}
function main() {
const prBody = readPrBody();
if (prBody === null) {
console.log('lint-default-flip-documentation: no PR event payload (not a pull_request run), skipping');
return;
}
const baseRef = `origin/${process.env.GITHUB_BASE_REF || 'next'}`; // #2988
const baseMap = flatten(readManifestAtRef(ROOT, baseRef));
const headMap = flatten(readManifestAtRef(ROOT, 'HEAD'));
const changes = findDefaultValueChanges(baseMap, headMap);
const verdict = evaluateDefaultFlipDoc(changes, prBody);
if (!verdict.ok) {
const detail = verdict.changes
.map((c) => ` ${c.key}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`)
.join('\n');
throw new ExitError(
1,
'lint-default-flip-documentation: this PR changes an existing default value in\n'
+ 'config-defaults.manifest.json (DEFECT.DEFAULT-FLIP-DOCUMENTATION) but the PR body has no\n'
+ '`## Breaking Changes` section. Add one covering: (a) when the new default takes effect\n'
+ '(config-set, fresh project, regenerated config), (b) the opt-back-in command\n'
+ '(`gsd config-set <key> <old-value>`), (c) effect on in-flight artifacts. Changed default(s):\n'
+ detail,
);
}
console.log(
changes.length === 0
? 'ok lint-default-flip-documentation: no default value changed'
: `ok lint-default-flip-documentation: ${changes.length} default value change(s), PR body documents Breaking Changes`,
);
}
module.exports = {
flatten,
findDefaultValueChanges,
evaluateDefaultFlipDoc,
readManifestAtRef,
MANIFEST_PATH,
BREAKING_CHANGES_RE,
};
if (require.main === module) runMain(main);