Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
472 lines
22 KiB
JavaScript
472 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
/**
|
||
* Anti-divergence drift guard for the PROMPT-LAYER plan/summary-COUNTING seam
|
||
* (epic #3180, ADR-3180 "Planning Semantic Model Single Owner", Decision 4(e)).
|
||
*
|
||
* `scripts/lint-plan-count-drift.cjs` and `scripts/lint-milestone-window-drift.cjs`
|
||
* scan `src/` only — but the `.planning/` semantic derivations they own are ALSO
|
||
* re-derived a second time, in the PROMPT layer: the workflow markdown that
|
||
* ships to every runtime, authored as raw shell rather than TypeScript. Issue
|
||
* #1762's second reproduction traced a wrong `30 plans, 24 summaries` figure to
|
||
* a `ls -1 ... *-PLAN.md | wc -l` snippet in `msd-core/workflows/progress.md` —
|
||
* a re-derivation no `.cts`-scoped guard can see, because it is markdown, not
|
||
* source. ADR-3180 Decision 4(a) requires whole-repo discovery; this guard
|
||
* extends that requirement from "the whole `src/` tree" to "every authored
|
||
* surface that can carry a derivation", covering the prompt layer the two
|
||
* sibling guards structurally cannot reach.
|
||
*
|
||
* Detection is intentionally NARROW, mirroring the sibling guards' precedent:
|
||
* a line is a re-derivation when it carries BOTH, in ONE source line:
|
||
* (a) a plan/summary SET GLOB — a `*` followed by a run of
|
||
* `[-A-Za-z0-9_.{}$]` characters and then the literal `PLAN.md` or
|
||
* `SUMMARY.md`. The leading `*` is load-bearing: it is what makes the
|
||
* line enumerate a SET of files rather than name one specific plan.
|
||
* `msd-core/workflows/execute-plan.md`'s
|
||
* `grep -cE '^\s*<task[[:space:]>]' .../{phase}-{plan}-PLAN.md` counts
|
||
* TASKS *inside* one already-named plan file — it has no glob token
|
||
* (no `*` anywhere near `PLAN.md`), so it is not a plan-count
|
||
* re-derivation and correctly never matches (a).
|
||
* (b) a COUNTING operation on that same line — `wc -l`, or `grep -c`
|
||
* (optionally with bundled short flags, e.g. `grep -cE`). Reading,
|
||
* globbing, or merely LISTING plan/summary files (`ls *-PLAN.md`,
|
||
* `cat *-PLAN.md`, `--files ".../*-PLAN.md"`) without counting them is
|
||
* not this derivation and must not be flagged — every non-counting
|
||
* `*-PLAN.md`/`*-SUMMARY.md` glob in `msd-core/workflows/plan-phase.md`
|
||
* (backup, `--files`, `cat`, cross-reference prose) is exactly this
|
||
* shape and is deliberately left alone.
|
||
* `*-UAT.md` never matches (a) — UAT artifacts are a different derivation
|
||
* this guard does not own — so `msd-core/workflows/progress.md`'s
|
||
* `... *-UAT.md ... | wc -l` line correctly never fires even though it sits
|
||
* one line below two lines that DO.
|
||
*
|
||
* Both regexes are small, bounded, and non-backtracking by construction (a
|
||
* single fixed character class with no nested quantifiers) — `npm run
|
||
* lint:ci` runs CodeQL js/redos over this repo, the same discipline the
|
||
* sibling guards document in their own headers.
|
||
*
|
||
* Surfaces scanned (SCAN_DIRS): `msd-core/workflows`, `commands`, `agents`,
|
||
* `skills` — the prompt-layer markdown that ships to runtimes. SCAN_EXT:
|
||
* `.md` only. The tree-walk / root-confinement / symlink / sanitizer
|
||
* machinery is SHARED with the two sibling guards via `scripts/lib/drift-scan.cjs`
|
||
* (ADR-3180 Decision 4's own "Rejected: let the new drift guard copy Phase 1's
|
||
* tree-walk / root-confinement / sanitizer") — see that module for the
|
||
* `isInsideRoot` case-sensitivity note, the `walk` symlink-confinement
|
||
* rationale, and the ReDoS-avoidance rationale for its regex-literal reader
|
||
* (unused by this guard's own regexes, which need no literal tokenizer, but
|
||
* shared for the tree walk and report sanitization).
|
||
*
|
||
* RATCHET, not an allowlist. Per ADR-3180 Decision 4(e) this guard's baseline
|
||
* (`scripts/baselines/planning-prompt-drift-baseline.json`) mirrors
|
||
* `scripts/qa-smell-ratchet.cjs`'s precedent exactly: a violation whose
|
||
* `(file, text)` pair is already RECORDED in the baseline is KNOWN and never
|
||
* fails; a violation whose pair is NOT recorded is NEW and fails, telling the
|
||
* author to route the count through the `msd-core` CLI instead of re-deriving
|
||
* it in shell; a recorded pair that no longer fires in this run is STALE and
|
||
* ALSO fails, forcing `--update` (run by a maintainer after a migration) to
|
||
* prune it — this is what makes the baseline SHRINK-ONLY as call sites
|
||
* migrate off the shell re-derivation, rather than a list that only ever
|
||
* grows. Matching is keyed on the pair (`file`, TRIMMED source `text`), never
|
||
* the line number: a workflow markdown file's line numbers churn on every
|
||
* unrelated edit (a new paragraph, a reworded step) and a number-keyed
|
||
* baseline would need hand-maintenance on changes that have nothing to do
|
||
* with this derivation at all.
|
||
*
|
||
* COUNT, not duplicate rows. Two DIFFERENT source lines can carry the exact
|
||
* same (file, TRIMMED text) pair — `msd-core/workflows/plan-phase.md` has two
|
||
* byte-identical `DISK_PLANS=$(ls "${PHASE_DIR}"/*-PLAN.md 2>/dev/null | wc -l
|
||
* | tr -d ' ')` sites. Keying on (file, text) alone with one baseline row per
|
||
* OCCURRENCE made a partial migration invisible: migrating ONE of the two
|
||
* sites still leaves a violation matching the row, so nothing goes fresh and
|
||
* nothing goes stale — the remaining, unmigrated copy is silently covered by
|
||
* the row meant to acknowledge the pair NO LONGER MIGRATING. Each baseline
|
||
* entry therefore carries a `count` — the number of byte-identical
|
||
* occurrences of that (file, text) pair acknowledged at this site, not a
|
||
* duplicated row per occurrence:
|
||
* - actual occurrences this run < entry.count -> STALE as a PARTIAL
|
||
* migration: some but not all acknowledged copies are gone, so the entry
|
||
* no longer describes reality and must be re-recorded via `--update`;
|
||
* - actual occurrences this run > entry.count -> the occurrences beyond
|
||
* the acknowledged count are FRESH: a new copy landed next to one that
|
||
* was already acknowledged;
|
||
* - actual occurrences this run === 0 -> fully STALE, the
|
||
* existing "site was migrated, delete the row" case;
|
||
* - actual occurrences this run === entry.count -> fully acknowledged, no
|
||
* failure.
|
||
* Line numbers stay OUT of the key even with counting — that is still what
|
||
* keeps the baseline immune to unrelated churn; `count` answers "how many",
|
||
* never "which lines".
|
||
*
|
||
* KNOWN, ACCEPTED limits of a per-line textual scan (same tradeoff the
|
||
* sibling guards document): a re-derivation whose glob and counting operator
|
||
* are split across two DIFFERENT lines (e.g. a variable holding the glob,
|
||
* counted via `wc -l` on the next line) is not caught by this narrow shape.
|
||
* That is left to code review, not this regex.
|
||
*/
|
||
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const driftScan = require('./lib/drift-scan.cjs');
|
||
const { sanitizeForReport, scanTree } = driftScan;
|
||
|
||
// (a) A plan/summary SET GLOB: a `*` followed by a bounded run of path/brace/
|
||
// var-interpolation characters and then the literal `PLAN.md` or
|
||
// `SUMMARY.md`. The character class is fixed and the quantifier is a single
|
||
// `*` (regex "zero or more", not the shell glob character being matched) over
|
||
// that one class — no nesting, no alternation inside a repeated group, so
|
||
// there is nothing here for a backtracking engine to explore more than once.
|
||
const PLAN_SUMMARY_GLOB_RE = /\*[-A-Za-z0-9_.{}$]*(?:PLAN|SUMMARY)\.md/;
|
||
|
||
// `scanTree` (scripts/lib/drift-scan.cjs) builds its repo-relative path via
|
||
// `path.relative()`, which uses NATIVE separators: on Windows that is
|
||
// `msd-core\workflows\execute-plan.md`, while the committed baseline
|
||
// (`scripts/baselines/planning-prompt-drift-baseline.json`) stores POSIX
|
||
// paths (`msd-core/workflows/execute-plan.md`). Every baseline lookup in this
|
||
// guard is keyed on that path, so an un-normalized Windows path silently
|
||
// fails to match ANY baseline entry — every real violation reports as FRESH
|
||
// and every baseline entry reports as STALE (100% failure rate on Windows,
|
||
// caught by GitHub Actions' Windows CI lane on PR #3223; the remote runner
|
||
// this repo otherwise gates on is Linux-only and cannot see this class).
|
||
// Normalized UNCONDITIONALLY — never gated on `process.platform` — because a
|
||
// platform-conditional normalizer is itself the bug: it makes the POSIX path
|
||
// the tested case and leaves the Windows branch exercised only on Windows.
|
||
// Applied at the single seam `findPromptDrift` owns (the only place a
|
||
// repo-relative path enters this guard's violation objects), so ONE
|
||
// normalized value flows into all four consumers: the baseline key
|
||
// (`diffAgainstBaseline`), the `--update` writer (`writeBaseline` via
|
||
// `dedupeViolationsForBaseline`), the violation report (`main`), and the
|
||
// tests.
|
||
function toPosixRel(relPath) {
|
||
return relPath.replace(/\\/g, '/');
|
||
}
|
||
|
||
// (b) A counting operation: `wc -l`, or `grep -c` optionally followed by
|
||
// bundled short flags before the next space (e.g. `grep -cE`, `grep -cE`).
|
||
// `[A-Za-z]{0,4}` bounds the bundled-flag run so the alternative branch is
|
||
// exactly as fixed-width-bounded as `wc -l` — no unbounded quantifier chained
|
||
// to another, so nothing to backtrack.
|
||
const COUNTING_OP_RE = /wc -l|grep -c[A-Za-z]{0,4}\b/;
|
||
|
||
// Prompt-layer markdown that ships to every runtime.
|
||
const SCAN_DIRS = ['msd-core/workflows', 'commands', 'agents', 'skills'];
|
||
const SCAN_EXT = new Set(['.md']);
|
||
|
||
const BASELINE_REL_PATH = path.join('scripts', 'baselines', 'planning-prompt-drift-baseline.json');
|
||
|
||
// ADR-3180 Decision 4(e): a baseline entry is "acknowledged, in writing, with
|
||
// the issue that owns its removal" — that is Phase 8 (#3218, "the prompt
|
||
// layer": give the workflow layer a CLI surface to ask for plan and phase
|
||
// counts, and burn this ratchet baseline to zero), NOT the epic (#3180)
|
||
// itself. #3180 is the scope authority for the whole consolidation; #3218 is
|
||
// the phase that actually deletes these shell re-derivations.
|
||
const RATCHET_OWNER_ISSUE = '#3218';
|
||
|
||
/**
|
||
* Pure: find every plan/summary-count re-derivation line in `text`.
|
||
* `relPath` is the repo-relative path (native separators or POSIX, either
|
||
* is accepted) — normalized via `toPosixRel` and attached as `file` on every
|
||
* result; this function applies no per-file exemption, so `relPath` is not
|
||
* otherwise consulted for detection.
|
||
* Returns [{ file, line, found, text }] — `file` is always POSIX-separated,
|
||
* `text` is the TRIMMED source line, the same value the baseline keys on.
|
||
*/
|
||
function findPromptDrift(text, relPath) {
|
||
const file = toPosixRel(relPath);
|
||
const out = [];
|
||
const lines = text.split('\n');
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const globMatch = PLAN_SUMMARY_GLOB_RE.exec(line);
|
||
if (!globMatch) continue;
|
||
if (!COUNTING_OP_RE.test(line)) continue;
|
||
out.push({ file, line: i + 1, found: globMatch[0], text: line.trim() });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Scan the prompt-layer markdown tree and return every re-derivation, each
|
||
* annotated with the repo-relative file path (POSIX-normalized — see
|
||
* `toPosixRel`).
|
||
*/
|
||
function scanRepo(root) {
|
||
return scanTree({
|
||
root,
|
||
scanDirs: SCAN_DIRS,
|
||
scanExt: SCAN_EXT,
|
||
onFile(rel, text) {
|
||
return findPromptDrift(text, rel);
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Read and parse the ratchet baseline. Returns `{ entries, errors }` —
|
||
* `entries` is `[]` and `errors` names the problem when the file is missing,
|
||
* empty, invalid JSON, or malformed; callers in check mode treat a non-empty
|
||
* `errors` as a hard failure (mirrors `qa-smell-ratchet.cjs`'s `readBaseline`).
|
||
*/
|
||
function loadBaseline(root) {
|
||
const baselinePath = path.join(root, BASELINE_REL_PATH);
|
||
if (!fs.existsSync(baselinePath)) {
|
||
return { entries: [], errors: [`${BASELINE_REL_PATH} is missing — run \`node scripts/lint-planning-prompt-drift.cjs --update\` to generate it`] };
|
||
}
|
||
const raw = fs.readFileSync(baselinePath, 'utf8');
|
||
if (raw.trim() === '') {
|
||
return { entries: [], errors: [`${BASELINE_REL_PATH} is present but empty`] };
|
||
}
|
||
let doc;
|
||
try {
|
||
doc = JSON.parse(raw);
|
||
} catch (err) {
|
||
return { entries: [], errors: [`${BASELINE_REL_PATH} is not valid JSON: ${err.message}`] };
|
||
}
|
||
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
||
return { entries: [], errors: [`${BASELINE_REL_PATH} must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`] };
|
||
}
|
||
if (!Array.isArray(doc.entries)) {
|
||
return { entries: [], errors: [`${BASELINE_REL_PATH}: "entries" must be an array, got ${JSON.stringify(doc.entries)}`] };
|
||
}
|
||
const errors = [];
|
||
const entries = [];
|
||
doc.entries.forEach((entry, i) => {
|
||
const where = `${BASELINE_REL_PATH}.entries[${i}]`;
|
||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
||
errors.push(`${where} must be an object, got ${JSON.stringify(entry)}`);
|
||
return;
|
||
}
|
||
if (typeof entry.file !== 'string' || entry.file === '') {
|
||
errors.push(`${where}.file must be a non-empty string, got ${JSON.stringify(entry.file)}`);
|
||
return;
|
||
}
|
||
if (typeof entry.text !== 'string' || entry.text === '') {
|
||
errors.push(`${where}.text must be a non-empty string, got ${JSON.stringify(entry.text)}`);
|
||
return;
|
||
}
|
||
// `count` is optional on read (diffAgainstBaseline defaults an absent
|
||
// count to 1) but when present must be a positive integer — the number
|
||
// of byte-identical (file, text) occurrences this entry acknowledges.
|
||
if (entry.count !== undefined && !(Number.isInteger(entry.count) && entry.count >= 1)) {
|
||
errors.push(`${where}.count must be a positive integer when present, got ${JSON.stringify(entry.count)}`);
|
||
return;
|
||
}
|
||
entries.push(entry);
|
||
});
|
||
return { entries, errors };
|
||
}
|
||
|
||
/**
|
||
* Diff scanned `violations` (from `scanRepo`) against baseline `entries`,
|
||
* matched by the pair (`file`, TRIMMED `text`) — never the line
|
||
* number — and COUNT-aware: an entry acknowledges `entry.count`
|
||
* (default 1 when absent) byte-identical occurrences of that pair, not
|
||
* merely its presence. Returns `{ fresh, stale }`:
|
||
* - `fresh`: violations whose (file, text) pair is NOT in the baseline at
|
||
* all (a brand new site), PLUS any occurrences of a KNOWN pair beyond
|
||
* its acknowledged `count` (a new copy landed next to an
|
||
* already-acknowledged one) — both fail the build as NEW.
|
||
* - `stale`: baseline entries whose actual occurrence count this run is
|
||
* LESS than their acknowledged `count` — zero actual
|
||
* occurrences is the fully-migrated case ("site was migrated, delete
|
||
* the row"); a positive but short count is a PARTIAL migration (some
|
||
* but not all acknowledged copies are gone). Both fail the build,
|
||
* forcing `--update` to re-record the pair (this is what keeps the
|
||
* baseline shrink-only and what makes a partial migration visible
|
||
* instead of silently covered by the still-present sibling
|
||
* occurrence).
|
||
*/
|
||
function diffAgainstBaseline(violations, baseline) {
|
||
const key = (file, text) => `${file} |