* enh(#3565): sentinel/contract registry + check:contract-drift lint * fix(#3565): report artifact-row markers once and dedupe per marker * docs(#3565): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
297
scripts/check-contract-drift.cjs
Normal file
297
scripts/check-contract-drift.cjs
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-contract-drift.cjs
|
||||
*
|
||||
* Enforces that gsd-core/references/agent-contracts.md's `## Agent Registry`
|
||||
* table stays in sync with reality:
|
||||
*
|
||||
* 1. The table itself must parse cleanly (no malformed rows).
|
||||
* 2. Every agents/*.md file must have its fenced code blocks properly
|
||||
* closed (an unclosed fence makes in-fence marker detection unreliable).
|
||||
* 3. Every marker an agent actually emits in-fence, and every marker the
|
||||
* registry declares for it, must agree (contractViolations' declared/
|
||||
* emitted checks) -- unless the row opts out via `kind:
|
||||
* artifact+query`/`structured-return`, in which case any emitted
|
||||
* marker is itself a violation (vestigial_marker).
|
||||
* 4. Every `sentinel-match` row's declared markers must have at least one
|
||||
* exact-case consumer somewhere under gsd-core/workflows/, commands/,
|
||||
* or agents/ (excluding the producing agent's own file).
|
||||
* 5. Registry roster coverage: every agents/*.md file has exactly one
|
||||
* row, every row names an agent file that exists
|
||||
* (agent_without_contract / duplicate_registry_row / unknown_producer),
|
||||
* and every file-shaped `Consumed by` entry resolves (unknown_consumer).
|
||||
* 6. Read-tag arm: no `<files_to_read>` survives anywhere in the consumer
|
||||
* corpus (legacy_read_tag), and whenever a declared consumer emits
|
||||
* `<required_reading>` the producing agent's file must reference the
|
||||
* gate (read_tag_gate_missing).
|
||||
* 7. Reverse direction: a workflow/command matching a quoted `## TOKEN`
|
||||
* no agent declares or emits is dispatch-on-phantom
|
||||
* (unmatched_consumer_token) — F9's shape from the consumer side.
|
||||
*
|
||||
* Exit 0 = clean. Exit 1 = violations (with diagnostics on stderr).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function resolveRoot(argv) {
|
||||
const idx = argv.indexOf('--root');
|
||||
if (idx === -1) return path.join(__dirname, '..');
|
||||
const value = argv[idx + 1];
|
||||
if (!value) {
|
||||
throw new Error('check-contract-drift: --root requires a directory argument');
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
const ROOT = resolveRoot(process.argv.slice(2));
|
||||
const CONTRACTS_FILE = path.join(ROOT, 'gsd-core', 'references', 'agent-contracts.md');
|
||||
const AGENTS_DIR = path.join(ROOT, 'agents');
|
||||
const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands');
|
||||
|
||||
const {
|
||||
extractMarkers,
|
||||
parseAgentContracts,
|
||||
contractViolations,
|
||||
readTagViolations,
|
||||
parseConsumedByCell,
|
||||
unmatchedConsumerTokens,
|
||||
sanitizeEcho,
|
||||
REMEDIES,
|
||||
} = require('./command-contract-helpers.cjs');
|
||||
|
||||
const { runMain } = require('./lib/cli-exit.cjs');
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function walkMarkdownFiles(dir, acc) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkMarkdownFiles(full, acc);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function toRepoRelative(absPath) {
|
||||
return path.relative(ROOT, absPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* referenceIncludes(content)
|
||||
*
|
||||
* Plain scan for `@~/.claude/gsd-core/references/*.md` tokens anywhere in an
|
||||
* agent file's content -- inside an `<execution_context>` block (already
|
||||
* covered structurally by `executionContextRefs` in command-contract-helpers,
|
||||
* but a raw regex over the whole string picks those up too) and, just as
|
||||
* importantly, OUTSIDE one: agents frequently point at a reference doc from
|
||||
* plain prose (e.g. "See @~/.claude/gsd-core/references/planner-guidance.md
|
||||
* for ...") rather than from the eager `<execution_context>` include list.
|
||||
* An agent's completion-marker contract can be authored in such a reference
|
||||
* file rather than the agent file itself -- gsd-planner declares
|
||||
* `PLANNING COMPLETE` in its registry row, but the example heading itself
|
||||
* lives in `gsd-core/references/planner-guidance.md`, which the agent only
|
||||
* `@`-includes -- so the producer scan below must follow these includes to
|
||||
* see markers an agent's contract legitimately delegates to a reference doc.
|
||||
* Returns ROOT-relative paths (`gsd-core/references/foo.md`), de-duplicated.
|
||||
*/
|
||||
function referenceIncludes(content) {
|
||||
const seen = new Set();
|
||||
const re = /@~\/\.claude\/gsd-core\/references\/[A-Za-z0-9._-]+\.md/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const relPath = 'gsd-core/references/' + m[0].slice('@~/.claude/gsd-core/references/'.length);
|
||||
seen.add(relPath);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function remedyFor(kind) {
|
||||
return REMEDIES[kind] || 'review the registry row and agent file for drift';
|
||||
}
|
||||
|
||||
// ─── run ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(CONTRACTS_FILE)) {
|
||||
process.stderr.write(`\nERROR check-contract-drift: contracts file not found at ${toRepoRelative(CONTRACTS_FILE)}\n\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const contractsMd = fs.readFileSync(CONTRACTS_FILE, 'utf-8');
|
||||
const { rows: registry, errors: parseErrors } = parseAgentContracts(contractsMd);
|
||||
|
||||
// knownMarkers: every marker string declared anywhere in the registry --
|
||||
// extractMarkers only ever resolves a heading against this vocabulary, it
|
||||
// never falls back to guessing a shape. Includes `(unconsumed: …)` entries
|
||||
// so their declared↔emitted agreement is checked too.
|
||||
const knownMarkers = new Set();
|
||||
for (const row of registry) {
|
||||
for (const m of row.completion_markers || []) knownMarkers.add(m);
|
||||
for (const m of row.unconsumed_markers || []) knownMarkers.add(m);
|
||||
}
|
||||
|
||||
// producerMarkers: agent -> [in-fence marker strings that matched knownMarkers]
|
||||
// candidateMarkers: agent -> [in-fence marker-shaped headings NOT in knownMarkers,
|
||||
// deduped per marker — "emitted but undeclared" is a fact about the
|
||||
// marker, not about each line or file it appears in]
|
||||
// agentTexts: agent -> file content CONCATENATED with every
|
||||
// references/*.md file the agent @-includes, for the read-tag arm's gate
|
||||
// check. The fold is load-bearing: planner/executor/phase-researcher
|
||||
// deliver the MUST-Read gate via the shared mandatory-initial-read.md
|
||||
// include, so the agent file alone would report a gate that actually
|
||||
// arrives (the same producer-scope gap referenceIncludes() fixes for
|
||||
// markers, one layer up).
|
||||
const producerMarkers = new Map();
|
||||
const candidateMarkers = new Map();
|
||||
const agentTexts = new Map();
|
||||
const unclosedFenceViolations = [];
|
||||
|
||||
const agentFiles = fs.existsSync(AGENTS_DIR)
|
||||
? fs.readdirSync(AGENTS_DIR).filter(f => f.endsWith('.md'))
|
||||
: [];
|
||||
|
||||
for (const file of agentFiles) {
|
||||
const agent = file.replace(/\.md$/, '');
|
||||
const abs = path.join(AGENTS_DIR, file);
|
||||
const content = fs.readFileSync(abs, 'utf-8');
|
||||
// Single pass over the agent's @-included references: each file is read
|
||||
// once and feeds BOTH the read-tag fold (agentTexts) and marker
|
||||
// extraction (producer/candidate attribution).
|
||||
const includeTexts = [];
|
||||
for (const refRelPath of referenceIncludes(content)) {
|
||||
try {
|
||||
includeTexts.push(fs.readFileSync(path.join(ROOT, refRelPath), 'utf-8'));
|
||||
} catch {
|
||||
// include miss — lint-command-contract rule 4 owns @-ref existence
|
||||
}
|
||||
}
|
||||
agentTexts.set(agent, [content, ...includeTexts].join('\n'));
|
||||
const { markers, candidates, unclosedFence } = extractMarkers(agentTexts.get(agent), knownMarkers);
|
||||
const inFenceMarkers = markers.filter(m => m.inFence).map(m => m.marker);
|
||||
const inFenceCandidates = candidates.filter(m => m.inFence).map(m => m.marker);
|
||||
|
||||
producerMarkers.set(agent, inFenceMarkers);
|
||||
candidateMarkers.set(agent, [...new Set(inFenceCandidates)]);
|
||||
|
||||
if (unclosedFence) {
|
||||
unclosedFenceViolations.push({
|
||||
kind: 'unclosed_fence',
|
||||
agent,
|
||||
marker: null,
|
||||
detail: `${toRepoRelative(abs)} has an unterminated code fence`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// consumerTexts: every *.md under gsd-core/workflows/, commands/, agents/
|
||||
// — plus every file-shaped `Consumed by` entry that resolves on disk, so
|
||||
// a row citing a reference doc or an ADR (e.g. intel-updater's
|
||||
// docs/adr/22-plan-drift-guard.md) is validated against the real file and
|
||||
// its text participates in consumer matching, not just workflows.
|
||||
const consumerTexts = new Map();
|
||||
const consumerDirs = [WORKFLOWS_DIR, COMMANDS_DIR, AGENTS_DIR];
|
||||
for (const dir of consumerDirs) {
|
||||
for (const abs of walkMarkdownFiles(dir, [])) {
|
||||
consumerTexts.set(toRepoRelative(abs), fs.readFileSync(abs, 'utf-8'));
|
||||
}
|
||||
}
|
||||
for (const row of registry) {
|
||||
for (const rel of parseConsumedByCell(row.consumed_by)) {
|
||||
if (consumerTexts.has(rel)) continue;
|
||||
const abs = path.join(ROOT, rel);
|
||||
try {
|
||||
consumerTexts.set(rel, fs.readFileSync(abs, 'utf-8'));
|
||||
} catch {
|
||||
// absent — contractViolations reports it as unknown_consumer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contractViolationsList = contractViolations({ registry, producerMarkers, candidateMarkers, consumerTexts });
|
||||
const readTagViolationsList = readTagViolations({ registry, agentTexts, consumerTexts });
|
||||
const reverseViolationsList = unmatchedConsumerTokens({ consumerTexts, vocabulary: knownMarkers });
|
||||
|
||||
const parseViolations = parseErrors.map(e => ({
|
||||
kind: 'parse_error',
|
||||
agent: null,
|
||||
marker: null,
|
||||
detail: `agent-contracts.md:${e.line} — ${e.reason}`,
|
||||
}));
|
||||
|
||||
const allViolations = [
|
||||
...parseViolations,
|
||||
...unclosedFenceViolations,
|
||||
...contractViolationsList,
|
||||
...readTagViolationsList,
|
||||
...reverseViolationsList,
|
||||
];
|
||||
|
||||
const agentCount = registry.length;
|
||||
const markerCount = registry.reduce((n, r) => n + (r.completion_markers || []).length, 0);
|
||||
|
||||
// --json: the typed surface tests consume (CONTRIBUTING "Raw Text
|
||||
// Matching" rule — the human formatter below is for operators only).
|
||||
if (process.argv.includes('--json')) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
check: 'check-contract-drift',
|
||||
status: allViolations.length === 0 ? 'ok' : 'violations',
|
||||
agents: agentCount,
|
||||
markers: markerCount,
|
||||
violations: allViolations.map((v) => ({
|
||||
kind: v.kind,
|
||||
agent: v.agent ?? null,
|
||||
marker: v.marker ?? null,
|
||||
detail: sanitizeEcho(v.detail),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
return allViolations.length === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
if (allViolations.length === 0) {
|
||||
console.log(`ok check-contract-drift: ${agentCount} agents, ${markerCount} markers, 0 violations`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// group by violation kind
|
||||
const byKind = new Map();
|
||||
for (const v of allViolations) {
|
||||
if (!byKind.has(v.kind)) byKind.set(v.kind, []);
|
||||
byKind.get(v.kind).push(v);
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`\nERROR check-contract-drift: ${allViolations.length} violation(s) across ${byKind.size} kind(s)\n\n`,
|
||||
);
|
||||
|
||||
for (const [kind, violations] of byKind) {
|
||||
process.stderr.write(` ${kind} (${violations.length}):\n`);
|
||||
for (const v of violations) {
|
||||
const agentLabel = v.agent ? sanitizeEcho(v.agent) : '(registry)';
|
||||
const markerLabel = v.marker ? ` marker "${sanitizeEcho(v.marker)}"` : '';
|
||||
process.stderr.write(` - ${agentLabel}${markerLabel}: ${sanitizeEcho(v.detail)}\n`);
|
||||
process.stderr.write(` remedy: ${remedyFor(kind)}\n`);
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
|
||||
process.stderr.write('See gsd-core/references/agent-contracts.md for the registry contract spec.\n\n');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
runMain(main);
|
||||
@@ -172,10 +172,795 @@ function unreachableWorkflows(loaderContents, gsdFiles, workflowPaths) {
|
||||
return workflowPaths.filter(p => !visited.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* splitOutsideDelimiter(str, delimiter)
|
||||
*
|
||||
* Splits `str` on `delimiter` characters that fall outside any backtick
|
||||
* span AND outside any parenthetical span. A backtick toggles an "inside a
|
||||
* code span" flag; `(` / `)` track nesting depth. Delimiters seen while
|
||||
* inside either construct are treated as literal content rather than split
|
||||
* points. Used both for comma-splitting a completion-markers cell and for
|
||||
* pipe-splitting a table row, so a marker annotation
|
||||
* (`(unconsumed: draft presentation, approved interactively)`) or a path
|
||||
* that happens to contain the delimiter inside backticks or parens is never
|
||||
* torn in half.
|
||||
*/
|
||||
function splitOutsideDelimiter(str, delimiter) {
|
||||
const parts = [];
|
||||
let cur = '';
|
||||
let inBacktick = false;
|
||||
let parenDepth = 0;
|
||||
for (const ch of str) {
|
||||
if (ch === '`') inBacktick = !inBacktick;
|
||||
if (!inBacktick) {
|
||||
if (ch === '(') parenDepth += 1;
|
||||
else if (ch === ')' && parenDepth > 0) parenDepth -= 1;
|
||||
}
|
||||
if (ch === delimiter && !inBacktick && parenDepth === 0) {
|
||||
parts.push(cur);
|
||||
cur = '';
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
parts.push(cur);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
let s = line.trim();
|
||||
if (s.startsWith('|')) s = s.slice(1);
|
||||
if (s.endsWith('|')) s = s.slice(0, -1);
|
||||
return splitOutsideDelimiter(s, '|').map(c => c.trim());
|
||||
}
|
||||
|
||||
function isSeparatorRow(line) {
|
||||
const t = line.trim();
|
||||
return t.length > 0 && /^[|:\- ]+$/.test(t) && t.includes('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* parseCompletionMarkersCell(value)
|
||||
*
|
||||
* Parses one `Completion Markers` table cell into `{markers, notes}`.
|
||||
* Entries are comma-separated OUTSIDE backticks (so a marker's own text
|
||||
* never leaks a split point). Each entry is stripped of a trailing
|
||||
* parenthetical annotation (`(title case)`, `(non-standard)`, ...) — that
|
||||
* annotation is captured into `notes` rather than the marker text — then
|
||||
* stripped of surrounding backticks and a leading `## ` heading prefix. A
|
||||
* bare `No marker` entry (post-annotation-strip) contributes nothing to
|
||||
* `markers`; its parenthetical still lands in `notes`.
|
||||
*/
|
||||
function parseCompletionMarkersCell(value) {
|
||||
const markers = [];
|
||||
const notes = [];
|
||||
const unconsumedMarkers = [];
|
||||
if (!value) return { markers, notes, unconsumedMarkers };
|
||||
const entries = splitOutsideDelimiter(value, ',');
|
||||
for (const rawEntry of entries) {
|
||||
const entry = rawEntry.trim();
|
||||
if (entry === '') continue;
|
||||
let markerPortion = entry;
|
||||
let annotation = null;
|
||||
const noteMatch = entry.match(/^(.*?)\s*\(([^)]*)\)\s*$/);
|
||||
if (noteMatch) {
|
||||
markerPortion = noteMatch[1].trim();
|
||||
annotation = noteMatch[2].trim();
|
||||
notes.push(annotation);
|
||||
}
|
||||
markerPortion = markerPortion.replace(/`/g, '').trim();
|
||||
markerPortion = markerPortion.replace(/^#{1,6}\s+/, '').trim();
|
||||
if (markerPortion === '' || markerPortion === 'No marker') continue;
|
||||
// An `(unconsumed: <reason>)` annotation declares a marker that is
|
||||
// emitted deliberately but matched by no consumer (e.g. a user-facing
|
||||
// presentation format). It stays in the declared+emitted contract and in
|
||||
// case-collision scope; only the consumer requirement is waived.
|
||||
if (annotation !== null && /^unconsumed\b/i.test(annotation)) {
|
||||
unconsumedMarkers.push(markerPortion);
|
||||
continue;
|
||||
}
|
||||
markers.push(markerPortion);
|
||||
}
|
||||
return { markers, notes, unconsumedMarkers };
|
||||
}
|
||||
|
||||
/**
|
||||
* extractMarkers(content, knownMarkers)
|
||||
*
|
||||
* Scans a markdown string line by line for H1-H6 headings, and reports
|
||||
* whether each heading sits inside a fenced code block. A heading counts as
|
||||
* a marker only when its captured text EXACTLY matches (case-sensitively)
|
||||
* one of `knownMarkers` — the declared vocabulary from the agent-contracts
|
||||
* registry, supplied by the caller. There is deliberately no shape
|
||||
* heuristic (e.g. "ends in COMPLETE") here: the registry already declares
|
||||
* every marker string, several of which do NOT end in COMPLETE (`##
|
||||
* ISSUES FOUND`, `## ROADMAP CREATED`, `## CHECKPOINT REACHED`, `##
|
||||
* ESCALATE`, ...), so guessing the shape both over-matches ordinary prose
|
||||
* headings that happen to look marker-shaped and under-matches every real
|
||||
* marker that doesn't end in the word COMPLETE. When `knownMarkers` is
|
||||
* omitted or empty, `markers` is always `[]` — this function never falls
|
||||
* back to guessing.
|
||||
*
|
||||
* `candidates` separately reports in-fence headings that are NOT in
|
||||
* `knownMarkers` but still look marker-shaped (`/^[A-Z][A-Z0-9 _:'\/-]*$/`,
|
||||
* no backticks, <= 60 chars) — these feed the `emitted_marker_not_declared`
|
||||
* check. They are reported as candidates, never silently treated as
|
||||
* markers.
|
||||
*
|
||||
* Fence tracking is a single boolean, not a stack: a line whose trimmed
|
||||
* form starts with a run of 3+ backticks or tildes toggles it, but only a
|
||||
* closing run of the SAME character and AT LEAST the same length actually
|
||||
* closes an open fence — a shorter or differently-charred run nested inside
|
||||
* (e.g. a 3-backtick span shown inside a 4-backtick outer fence) is inert
|
||||
* and leaves the outer fence open. `unclosedFence` reports when the file
|
||||
* ends without ever closing the last-opened fence, so callers can tell a
|
||||
* truncated/malformed file from one that's actually well-formed.
|
||||
*/
|
||||
function extractMarkers(content, knownMarkers) {
|
||||
const markers = [];
|
||||
const candidates = [];
|
||||
if (content == null || typeof content !== 'string' || content.trim() === '') {
|
||||
return { markers, candidates, unclosedFence: false };
|
||||
}
|
||||
|
||||
const known = new Set(knownMarkers || []);
|
||||
const lines = content.split(/\r?\n/);
|
||||
const headingRe = /^(#{1,6})\s+(\S.*?)\s*$/;
|
||||
const candidateShapeRe = /^[A-Z][A-Z0-9 _:'/-]*$/;
|
||||
|
||||
let inFence = false;
|
||||
let fenceChar = null;
|
||||
let fenceLen = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
const stateForThisLine = inFence;
|
||||
|
||||
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
const runChar = fenceMatch[1][0];
|
||||
const runLen = fenceMatch[1].length;
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceChar = runChar;
|
||||
fenceLen = runLen;
|
||||
} else if (runChar === fenceChar && runLen >= fenceLen) {
|
||||
inFence = false;
|
||||
fenceChar = null;
|
||||
fenceLen = 0;
|
||||
}
|
||||
// else: nested/mismatched run — does not close the outer fence.
|
||||
}
|
||||
|
||||
const hm = line.match(headingRe);
|
||||
if (hm) {
|
||||
const text = hm[2].trim();
|
||||
if (known.has(text)) {
|
||||
markers.push({ marker: text, inFence: stateForThisLine, line: i + 1 });
|
||||
} else if (stateForThisLine && text.length <= 60 && candidateShapeRe.test(text)) {
|
||||
candidates.push({ marker: text, inFence: stateForThisLine, line: i + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { markers, candidates, unclosedFence: inFence };
|
||||
}
|
||||
|
||||
/**
|
||||
* parseAgentContracts(markdown)
|
||||
*
|
||||
* Parses the `## Agent Registry` pipe table in
|
||||
* gsd-core/references/agent-contracts.md into structured rows, keyed by
|
||||
* lowercased, underscore-joined column headers (`Completion Markers` ->
|
||||
* `completion_markers`). Column set is read from the header row itself, so
|
||||
* the table can grow a `Consumed By` / `Kind` column later without this
|
||||
* parser needing an update — rows simply gain the corresponding key.
|
||||
*
|
||||
* The `completion_markers` column gets special handling via
|
||||
* parseCompletionMarkersCell: it is never a plain string on the returned
|
||||
* row, always the parsed `{markers}` array (with any parenthetical
|
||||
* annotation split out into a row-level `notes` array instead).
|
||||
*
|
||||
* Malformed rows (wrong cell count for the header, or an empty agent name)
|
||||
* are reported in `errors` as `{line, reason}` and excluded from `rows`
|
||||
* rather than thrown.
|
||||
*/
|
||||
function parseAgentContracts(markdown) {
|
||||
const rows = [];
|
||||
const errors = [];
|
||||
if (markdown == null || typeof markdown !== 'string') return { rows, errors };
|
||||
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const registryIdx = lines.findIndex(l => /^##\s+Agent Registry\s*$/.test(l.trim()));
|
||||
if (registryIdx === -1) return { rows, errors };
|
||||
|
||||
let i = registryIdx + 1;
|
||||
while (i < lines.length && lines[i].trim() === '') i++;
|
||||
if (i >= lines.length || !lines[i].trim().startsWith('|')) return { rows, errors };
|
||||
|
||||
const columnKeys = splitTableRow(lines[i]).map(h =>
|
||||
h.trim().toLowerCase().replace(/\s+/g, '_')
|
||||
);
|
||||
i++;
|
||||
if (i < lines.length && isSeparatorRow(lines[i])) i++;
|
||||
|
||||
for (; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
if (!raw.trim().startsWith('|')) break;
|
||||
|
||||
const lineNum = i + 1;
|
||||
const cells = splitTableRow(raw);
|
||||
if (cells.length !== columnKeys.length) {
|
||||
errors.push({
|
||||
line: lineNum,
|
||||
reason: `column count mismatch: expected ${columnKeys.length}, got ${cells.length}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = {};
|
||||
for (let j = 0; j < columnKeys.length; j++) {
|
||||
const key = columnKeys[j];
|
||||
const value = cells[j];
|
||||
if (key === 'completion_markers') {
|
||||
const parsed = parseCompletionMarkersCell(value);
|
||||
row.completion_markers = parsed.markers;
|
||||
row.notes = parsed.notes;
|
||||
row.unconsumed_markers = parsed.unconsumedMarkers;
|
||||
} else {
|
||||
row[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!row.agent) {
|
||||
errors.push({ line: lineNum, reason: 'empty agent name' });
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
const CONTRACT_KINDS = new Set(['sentinel-match', 'artifact+query', 'structured-return']);
|
||||
|
||||
/**
|
||||
* contractViolations({registry, producerMarkers, candidateMarkers, consumerTexts})
|
||||
*
|
||||
* Cross-checks the agent-contracts registry (from parseAgentContracts)
|
||||
* against what agent files actually emit in-fence (from extractMarkers) and
|
||||
* what every workflow/command/agent file actually consumes, exact-case.
|
||||
* Rows are evaluated independently; `registry`, `producerMarkers`,
|
||||
* `candidateMarkers`, and `consumerTexts` default to empty when omitted so a
|
||||
* partial call never throws.
|
||||
*
|
||||
* `emitted_marker_not_declared` is driven by `candidateMarkers` (agent ->
|
||||
* [in-fence heading text]) rather than `producerMarkers` -- a heading only
|
||||
* lands in `producerMarkers` once it already matches a KNOWN registry
|
||||
* marker (see extractMarkers), so it can never be "emitted but undeclared";
|
||||
* `candidateMarkers` is exactly the set of marker-shaped in-fence headings
|
||||
* extractMarkers could NOT resolve against the known vocabulary.
|
||||
*
|
||||
* A row declaring `kind: 'artifact+query'` or `'structured-return'` opts
|
||||
* out of the sentinel-marker contract entirely — its only possible
|
||||
* violation is `vestigial_marker` (it still emits an in-fence marker
|
||||
* despite the row saying completion isn't detected that way). Every other
|
||||
* row (kind `sentinel-match`, or the column simply absent/empty) is held to
|
||||
* the full contract: declared markers must be emitted, emitted markers must
|
||||
* be declared, and — for sentinel-match rows specifically — every declared
|
||||
* marker must have at least one EXACT-CASE consumer somewhere in
|
||||
* `consumerTexts`, excluding the producing agent's own file (an agent can
|
||||
* never satisfy its own marker, even when the same map entry also serves as
|
||||
* a consumer of some OTHER agent's marker). A case-insensitive-only match
|
||||
* is reported (`case_only_match`), never treated as satisfying the
|
||||
* contract. `case_collision` is computed once across the whole registry,
|
||||
* independent of kind, since two markers differing only by case is a
|
||||
* registry-wide authoring hazard.
|
||||
*/
|
||||
function contractViolations({ registry, producerMarkers, candidateMarkers, consumerTexts } = {}) {
|
||||
const violations = [];
|
||||
try {
|
||||
const rows = Array.isArray(registry) ? registry : [];
|
||||
const producers = producerMarkers instanceof Map ? producerMarkers : new Map();
|
||||
const candidatesByAgent = candidateMarkers instanceof Map ? candidateMarkers : new Map();
|
||||
const consumers = consumerTexts instanceof Map ? consumerTexts : new Map();
|
||||
|
||||
// case_collision — global, across every declared marker in the registry,
|
||||
// including `(unconsumed: …)` entries: a case-variant of an unconsumed
|
||||
// marker is still an authoring hazard even though no consumer matches it.
|
||||
const byLower = new Map();
|
||||
for (const row of rows) {
|
||||
for (const m of [...(row.completion_markers || []), ...(row.unconsumed_markers || [])]) {
|
||||
const lower = m.toLowerCase();
|
||||
if (!byLower.has(lower)) byLower.set(lower, new Set());
|
||||
byLower.get(lower).add(m);
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
for (const m of [...(row.completion_markers || []), ...(row.unconsumed_markers || [])]) {
|
||||
const variants = byLower.get(m.toLowerCase());
|
||||
if (variants && variants.size > 1) {
|
||||
const others = [...variants].filter(v => v !== m);
|
||||
violations.push({
|
||||
kind: 'case_collision',
|
||||
agent: row.agent,
|
||||
marker: m,
|
||||
detail: `case-insensitive collision with: ${others.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// roster coverage — the registry is the declaration of record for every
|
||||
// agents/*.md file: an agent with no row, two rows for one agent, or a
|
||||
// row naming an agent that does not exist are all contract drift. The
|
||||
// roster is the producer map's key set; guards on map size keep empty
|
||||
// inputs inert rather than spuriously "missing".
|
||||
const seenAgents = new Set();
|
||||
for (const row of rows) {
|
||||
const agent = row.agent;
|
||||
if (seenAgents.has(agent)) {
|
||||
violations.push({
|
||||
kind: 'duplicate_registry_row',
|
||||
agent,
|
||||
marker: null,
|
||||
detail: `agent "${agent}" has more than one registry row`,
|
||||
});
|
||||
}
|
||||
seenAgents.add(agent);
|
||||
if (producers.size > 0 && !producers.has(agent)) {
|
||||
violations.push({
|
||||
kind: 'unknown_producer',
|
||||
agent,
|
||||
marker: null,
|
||||
detail: `registry row for "${agent}" but no agents/${agent}.md exists`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const agent of producers.keys()) {
|
||||
if (!seenAgents.has(agent)) {
|
||||
violations.push({
|
||||
kind: 'agent_without_contract',
|
||||
agent,
|
||||
marker: null,
|
||||
detail: `agents/${agent}.md exists but has no registry row in agent-contracts.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const agent = row.agent;
|
||||
const declared = row.completion_markers || [];
|
||||
const unconsumed = row.unconsumed_markers || [];
|
||||
const declaredAll = [...declared, ...unconsumed];
|
||||
// Deduped: the same marker emitted on several lines/files is one
|
||||
// emission fact, never several violations.
|
||||
const produced = [...new Set(producers.get(agent) || [])];
|
||||
const kind = row.kind;
|
||||
const kindProvided = kind !== undefined && kind !== null && kind !== '';
|
||||
|
||||
// A row naming a nonexistent agent file: unknown_producer (above) is
|
||||
// the violation; per-marker noise on top of it hides nothing.
|
||||
if (producers.size > 0 && !producers.has(agent)) continue;
|
||||
|
||||
// unknown_consumer — the `Consumed by` column feeds readTagViolations,
|
||||
// so a file-shaped entry that resolves to nothing would make that arm a
|
||||
// lint over fiction. Only checked when a consumer corpus was supplied.
|
||||
for (const p of parseConsumedByCell(row.consumed_by)) {
|
||||
if (consumers.size > 0 && !consumers.has(p)) {
|
||||
violations.push({
|
||||
kind: 'unknown_consumer',
|
||||
agent,
|
||||
marker: null,
|
||||
detail: `Consumed by names "${p}" but no such file exists`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (kindProvided && !CONTRACT_KINDS.has(kind)) {
|
||||
violations.push({
|
||||
kind: 'unknown_kind',
|
||||
agent,
|
||||
marker: null,
|
||||
detail: `row declares unknown kind "${kind}"`,
|
||||
});
|
||||
}
|
||||
|
||||
const isArtifactOrStructured =
|
||||
kindProvided && (kind === 'artifact+query' || kind === 'structured-return');
|
||||
|
||||
// declaredAll includes unconsumed entries: an `(unconsumed: …)` marker
|
||||
// must still be emitted — the exemption waives the consumer
|
||||
// requirement only, never the declared↔emitted agreement (Marker
|
||||
// Rule 7). This runs for EVERY row kind.
|
||||
for (const m of declaredAll) {
|
||||
if (!produced.includes(m)) {
|
||||
violations.push({
|
||||
kind: 'declared_marker_not_emitted',
|
||||
agent,
|
||||
marker: m,
|
||||
detail: `declared in registry but not emitted in-fence by ${agent}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isArtifactOrStructured) {
|
||||
// An `(unconsumed: …)` annotation on an artifact/structured row is a
|
||||
// recorded decision to keep emitting a marker the completion route
|
||||
// doesn't need (Marker Rule 2's intentional title-case markers) —
|
||||
// exempt from vestigial_marker. Every OTHER emitted marker on such a
|
||||
// row is reported ONCE, as vestigial_marker: the candidate check
|
||||
// below is skipped for these rows because emitted_marker_not_declared
|
||||
// on top of vestigial_marker double-reports the same fact, and the
|
||||
// consumer contract cannot apply to a row whose completion is not
|
||||
// detected by marker matching at all.
|
||||
const unconsumedSet = new Set(unconsumed);
|
||||
for (const m of produced) {
|
||||
if (unconsumedSet.has(m)) continue;
|
||||
violations.push({
|
||||
kind: 'vestigial_marker',
|
||||
agent,
|
||||
marker: m,
|
||||
detail: `row kind "${kind}" expects no marker, but agent still emits "${m}" in-fence`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deduped per marker: "emitted but undeclared" is a fact about the
|
||||
// marker, and a caller-supplied list carrying the same marker twice
|
||||
// (two template lines, or agent file + @-included reference) is one
|
||||
// violation, not two.
|
||||
const candidates = [...new Set(candidatesByAgent.get(agent) || [])];
|
||||
for (const m of candidates) {
|
||||
if (!declaredAll.includes(m)) {
|
||||
violations.push({
|
||||
kind: 'emitted_marker_not_declared',
|
||||
agent,
|
||||
marker: m,
|
||||
detail: `emitted in-fence by ${agent} but absent from its registry row`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isSentinelMatchKind = !kindProvided || kind === 'sentinel-match';
|
||||
if (isSentinelMatchKind) {
|
||||
// Only markers the agent actually emits participate in the consumer
|
||||
// contract — a declared_marker_not_emitted finding already covers a
|
||||
// phantom declaration, and stacking no_consumer on top of it is
|
||||
// double-reporting the same dead row.
|
||||
const declaredConsumerPaths = parseConsumedByCell(row.consumed_by);
|
||||
for (const m of declared) {
|
||||
if (!produced.includes(m)) continue;
|
||||
let exactMatch = false;
|
||||
let caseInsensitiveMatch = false;
|
||||
let declaredConsumerMatch = false;
|
||||
for (const [consumerId, text] of consumers.entries()) {
|
||||
// consumerTexts is keyed by repo-relative path — an agent's own
|
||||
// file is 'agents/<name>.md'. The producer's own template can
|
||||
// never satisfy its marker.
|
||||
if (consumerId === `agents/${agent}.md`) continue;
|
||||
if (typeof text !== 'string') continue;
|
||||
if (text.includes(m)) {
|
||||
exactMatch = true;
|
||||
if (declaredConsumerPaths.includes(consumerId)) declaredConsumerMatch = true;
|
||||
} else if (text.toLowerCase().includes(m.toLowerCase())) {
|
||||
caseInsensitiveMatch = true;
|
||||
}
|
||||
}
|
||||
if (!exactMatch) {
|
||||
if (caseInsensitiveMatch) {
|
||||
violations.push({
|
||||
kind: 'case_only_match',
|
||||
agent,
|
||||
marker: m,
|
||||
detail: `no exact-case consumer for "${m}"; case-insensitive match found only`,
|
||||
});
|
||||
} else {
|
||||
violations.push({
|
||||
kind: 'no_consumer',
|
||||
agent,
|
||||
marker: m,
|
||||
detail: `no consumer text contains "${m}" (exact case)`,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
declaredConsumerPaths.length > 0 &&
|
||||
!declaredConsumerMatch
|
||||
) {
|
||||
// The marker IS consumed somewhere, but by none of the consumers
|
||||
// the row cites — the `Consumed by` cell is documentation the
|
||||
// read-tag arm and humans both navigate by, so a cell naming
|
||||
// files that never match is registry drift even when the corpus
|
||||
// at large happens to contain the token.
|
||||
violations.push({
|
||||
kind: 'declared_consumer_no_match',
|
||||
agent,
|
||||
marker: m,
|
||||
detail: `consumed somewhere, but none of the row's Consumed by entries ( ${declaredConsumerPaths.join(', ')} ) contain "${m}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// A drift gate must fail RED, never green: an internal error here must
|
||||
// surface, not be swallowed into '0 violations'.
|
||||
e.message = `contractViolations (internal failure — failing red): ${e.message}`;
|
||||
throw e;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* parseConsumedByCell(value)
|
||||
*
|
||||
* Extracts every file-shaped path from a `Consumed by` table cell. Cell
|
||||
* entries are comma-separated outside backticks, and an entry may carry
|
||||
* several backtick-wrapped tokens (a path, a command, a marker string) in
|
||||
* prose — so every backtick span is inspected and only tokens that look like
|
||||
* repo-relative markdown paths (`^[\w][\w./-]*\.md$` — no spaces, no glob
|
||||
* stars) are returned. A glob (`*-VERIFICATION.md`) or a command
|
||||
* (`gsd_run query verification.status`) describes the consumption mechanism
|
||||
* in prose; it is not a file the check can open, and is ignored.
|
||||
*/
|
||||
function parseConsumedByCell(value) {
|
||||
if (!value || typeof value !== 'string') return [];
|
||||
const paths = [];
|
||||
const re = /`([^`]+)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(value)) !== null) {
|
||||
const token = m[1].trim();
|
||||
if (!/^[\w][\w./-]*\.md$/.test(token) || token.includes('*')) continue;
|
||||
// Traversal segments are dropped, never surfaced — the same rule
|
||||
// workflowPathRefs applies to its refs: this resolver only ever reports
|
||||
// paths inside the repo, never something a `..` could walk outside of
|
||||
// (the driver reads these paths under --root, so an interior `..` would
|
||||
// be an out-of-root file read).
|
||||
if (token.split('/').includes('..')) continue;
|
||||
paths.push(token);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* readTagViolations({registry, agentTexts, consumerTexts})
|
||||
*
|
||||
* The read-tag arm — F8 one layer up. Two invariants:
|
||||
*
|
||||
* legacy_read_tag — no `<files_to_read>` may survive anywhere in the
|
||||
* consumer corpus (workflows/, commands/, agents/). #3423 standardized on
|
||||
* `<required_reading>` and retired the old vocabulary via a test; this
|
||||
* folds that assertion into the gate itself.
|
||||
*
|
||||
* read_tag_gate_missing — for every registry row, every file-shaped
|
||||
* `Consumed by` consumer that itself emits `<required_reading>` requires
|
||||
* the producing agent's file to reference `<required_reading>`. A spawner
|
||||
* that sends a required-reading block to an agent whose instructions never
|
||||
* mention the gate is exactly the F8 shape: the emit side is correct, the
|
||||
* gate side never fires — and #3423's per-agent test cannot see it,
|
||||
* because an agent carrying no reading instruction at all is skipped by
|
||||
* it. The registry supplies the producer↔consumer pair, so this check
|
||||
* needs no spawn-site parsing heuristics.
|
||||
*/
|
||||
function readTagViolations({ registry, agentTexts, consumerTexts } = {}) {
|
||||
const violations = [];
|
||||
try {
|
||||
const rows = Array.isArray(registry) ? registry : [];
|
||||
const agents = agentTexts instanceof Map ? agentTexts : new Map();
|
||||
const consumers = consumerTexts instanceof Map ? consumerTexts : new Map();
|
||||
|
||||
for (const [file, text] of consumers.entries()) {
|
||||
if (typeof text === 'string' && text.includes('<files_to_read>')) {
|
||||
violations.push({
|
||||
kind: 'legacy_read_tag',
|
||||
agent: null,
|
||||
marker: null,
|
||||
detail: `${file} still contains the retired <files_to_read> tag`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const agent = row.agent;
|
||||
const agentText = agents.get(agent);
|
||||
if (typeof agentText !== 'string') continue; // unknown_producer reports this
|
||||
if (agentText.includes('<required_reading>')) continue;
|
||||
for (const p of parseConsumedByCell(row.consumed_by)) {
|
||||
const text = consumers.get(p);
|
||||
if (typeof text === 'string' && text.includes('<required_reading>')) {
|
||||
violations.push({
|
||||
kind: 'read_tag_gate_missing',
|
||||
agent,
|
||||
marker: null,
|
||||
detail: `consumer ${p} emits <required_reading> but agents/${agent}.md never references the gate`,
|
||||
});
|
||||
break; // one violation per agent is the fact; more is noise
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// A drift gate must fail RED, never green: an internal error here must
|
||||
// surface, not be swallowed into '0 violations'.
|
||||
e.message = `readTagViolations (internal failure — failing red): ${e.message}`;
|
||||
throw e;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens that look like completion markers but are NOT agent contracts, and
|
||||
* therefore exempt from the reverse-direction check. Each entry carries its
|
||||
* justification inline; a test locks the set's membership so it cannot grow
|
||||
* silently.
|
||||
*/
|
||||
const NON_AGENT_TOKENS = new Set([
|
||||
// Display headings the /gsd:graphify command itself renders from
|
||||
// `gsd_run graphify build` CLI output — tool output shown to the user,
|
||||
// not an agent return any spawner dispatches on.
|
||||
'GRAPHIFY BUILD COMPLETE',
|
||||
'GRAPHIFY BUILD FAILED',
|
||||
]);
|
||||
|
||||
/**
|
||||
* unmatchedConsumerTokens({consumerTexts, vocabulary})
|
||||
*
|
||||
* The reverse direction of the registry contract: a consumer that matches a
|
||||
* `## TOKEN` string no producer emits is dispatch-on-phantom — F9's shape
|
||||
* (a sanctioned return that no dispatch branch ever keyed on), seen from
|
||||
* the consumer side. Scans ONLY workflow/command files (the dispatch
|
||||
* surfaces); `agents/` is excluded because an agent mentioning a token in
|
||||
* its own file is a self-description, not a spawner's match instruction —
|
||||
* the forward direction already validates agent-to-agent consumption.
|
||||
*
|
||||
* A token is extracted when it appears quoted (`## X`, "## X", '## X') —
|
||||
* the shape a match/dispatch instruction actually writes — is ALL-CAPS
|
||||
* marker-shaped (the same candidate shape extractMarkers uses, ≤60 chars),
|
||||
* is absent from the declared vocabulary, and is not exempt via
|
||||
* NON_AGENT_TOKENS.
|
||||
*/
|
||||
function unmatchedConsumerTokens({ consumerTexts, vocabulary } = {}) {
|
||||
const violations = [];
|
||||
try {
|
||||
const consumers = consumerTexts instanceof Map ? consumerTexts : new Map();
|
||||
const known = vocabulary instanceof Set ? vocabulary : new Set(knownMarkersArray(vocabulary));
|
||||
const shapeRe = /^[A-Z][A-Z0-9 _:'/-]*$/;
|
||||
const tokenRe = /[`'"]## ([^`'"\n]+)[`'"]/g;
|
||||
|
||||
const reported = new Set();
|
||||
for (const [file, text] of consumers.entries()) {
|
||||
if (!file.startsWith('gsd-core/workflows/') && !file.startsWith('commands/')) continue;
|
||||
if (typeof text !== 'string') continue;
|
||||
tokenRe.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = tokenRe.exec(text)) !== null) {
|
||||
const token = m[1].trim();
|
||||
if (known.has(token)) continue;
|
||||
if (NON_AGENT_TOKENS.has(token)) continue;
|
||||
if (token.length > 60 || !shapeRe.test(token)) continue;
|
||||
const key = `${file}\0${token}`;
|
||||
if (reported.has(key)) continue;
|
||||
reported.add(key);
|
||||
violations.push({
|
||||
kind: 'unmatched_consumer_token',
|
||||
agent: null,
|
||||
marker: token,
|
||||
detail: `${file} matches \`## ${token}\` but no agent declares or emits it`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// A drift gate must fail RED, never green: an internal error here must
|
||||
// surface, not be swallowed into '0 violations'.
|
||||
e.message = `unmatchedConsumerTokens (internal failure — failing red): ${e.message}`;
|
||||
throw e;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function knownMarkersArray(vocabulary) {
|
||||
// vocabulary may arrive as an array (tests) — normalize; the Set branch
|
||||
// above handles the driver's Set.
|
||||
return Array.isArray(vocabulary) ? vocabulary : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen violation-kind vocabulary — one entry per finding the check
|
||||
* can emit. Tests lock this set and the driver's REMEDIES parity, so a new
|
||||
* kind is three coordinated changes: enum + remedy + test, the same
|
||||
* discipline verify-reapply-patches' REASON enum follows.
|
||||
*/
|
||||
const VIOLATION_KINDS = Object.freeze({
|
||||
PARSE_ERROR: 'parse_error',
|
||||
UNCLOSED_FENCE: 'unclosed_fence',
|
||||
DECLARED_MARKER_NOT_EMITTED: 'declared_marker_not_emitted',
|
||||
EMITTED_MARKER_NOT_DECLARED: 'emitted_marker_not_declared',
|
||||
CASE_COLLISION: 'case_collision',
|
||||
CASE_ONLY_MATCH: 'case_only_match',
|
||||
NO_CONSUMER: 'no_consumer',
|
||||
DECLARED_CONSUMER_NO_MATCH: 'declared_consumer_no_match',
|
||||
UNKNOWN_KIND: 'unknown_kind',
|
||||
VESTIGIAL_MARKER: 'vestigial_marker',
|
||||
AGENT_WITHOUT_CONTRACT: 'agent_without_contract',
|
||||
DUPLICATE_REGISTRY_ROW: 'duplicate_registry_row',
|
||||
UNKNOWN_PRODUCER: 'unknown_producer',
|
||||
UNKNOWN_CONSUMER: 'unknown_consumer',
|
||||
LEGACY_READ_TAG: 'legacy_read_tag',
|
||||
READ_TAG_GATE_MISSING: 'read_tag_gate_missing',
|
||||
UNMATCHED_CONSUMER_TOKEN: 'unmatched_consumer_token',
|
||||
});
|
||||
|
||||
/**
|
||||
* The remedy shown per violation kind — one entry per VIOLATION_KINDS value.
|
||||
* The parity test locks REMEDIES keys against VIOLATION_KINDS values, so a
|
||||
* new kind cannot ship without its remedy.
|
||||
*/
|
||||
const REMEDIES = Object.freeze({
|
||||
parse_error: 'fix the malformed row in gsd-core/references/agent-contracts.md',
|
||||
unclosed_fence: 'close the unterminated code fence in this agent file',
|
||||
declared_marker_not_emitted:
|
||||
'remove the marker from the registry row, or emit it as an in-fence example in the agent file',
|
||||
emitted_marker_not_declared:
|
||||
"add this marker to the agent's Completion Markers cell in the registry table",
|
||||
case_collision: 'rename one of the colliding markers so they no longer differ only by case',
|
||||
case_only_match:
|
||||
'fix the consumer file to match this marker exact-case, or update the marker/registry to match the consumer',
|
||||
no_consumer:
|
||||
'add an exact-case consumer for this marker, or reclassify the row Kind to artifact+query/structured-return',
|
||||
declared_consumer_no_match:
|
||||
"the marker is consumed, but not by any file the row's Consumed by cell names — fix the cell to name a real consumer",
|
||||
unknown_kind: 'set the Kind column to one of sentinel-match, artifact+query, structured-return',
|
||||
vestigial_marker:
|
||||
"remove this marker heading from the agent file, since the row's Kind says no marker is expected",
|
||||
agent_without_contract: 'add a registry row for this agent in agent-contracts.md',
|
||||
duplicate_registry_row: 'merge the two rows for this agent into one',
|
||||
unknown_producer: 'remove the row, or create the agents/<name>.md file it names',
|
||||
unknown_consumer:
|
||||
'fix the path in the Consumed by cell, or drop it — only real files the check can open belong there',
|
||||
legacy_read_tag:
|
||||
'replace <files_to_read> with <required_reading> (#3423 standardized on the gate tag)',
|
||||
read_tag_gate_missing:
|
||||
"add the <required_reading> MUST-Read gate clause to this agent's instructions (see docs/AGENTS.md)",
|
||||
unmatched_consumer_token:
|
||||
'declare the producing agent and marker in the registry, or remove the dispatch — nothing emits this token',
|
||||
});
|
||||
|
||||
/**
|
||||
* sanitizeEcho(text)
|
||||
*
|
||||
* File-derived strings (registry cells, marker tokens, workflow text) are
|
||||
* echoed into lint output that AI agents read as trusted instructions —
|
||||
* every interpolated field is control-char-stripped and length-capped
|
||||
* before it is written, so a malicious repo file cannot make the lint emit
|
||||
* instruction-shaped text. Implemented as a code-point filter rather than a
|
||||
* regex so no control-character regex literal exists to lint.
|
||||
*/
|
||||
function sanitizeEcho(text) {
|
||||
let out = '';
|
||||
for (const ch of String(text)) {
|
||||
const code = ch.codePointAt(0);
|
||||
if (code <= 0x1f || code === 0x7f) continue;
|
||||
out += ch;
|
||||
}
|
||||
return out.length > 200 ? out.slice(0, 200) : out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CANONICAL_TOOLS,
|
||||
parseFrontmatter,
|
||||
executionContextRefs,
|
||||
workflowPathRefs,
|
||||
unreachableWorkflows,
|
||||
extractMarkers,
|
||||
parseAgentContracts,
|
||||
contractViolations,
|
||||
parseConsumedByCell,
|
||||
readTagViolations,
|
||||
unmatchedConsumerTokens,
|
||||
NON_AGENT_TOKENS,
|
||||
VIOLATION_KINDS,
|
||||
REMEDIES,
|
||||
sanitizeEcho,
|
||||
};
|
||||
|
||||
@@ -7,19 +7,38 @@
|
||||
* ## Why
|
||||
*
|
||||
* A file/key gets removed because "no longer used" without verifying every
|
||||
* consumer (workflows, docs, manifests, npm scripts). #3316: root
|
||||
* consumer (workflows, docs, manifests, npm scripts, tests). #3316: root
|
||||
* `package-lock.json` was deleted while `package.json` still declares deps
|
||||
* and workflows still use `cache: 'npm'` + `npm ci` (which require a
|
||||
* lockfile). e3b52c70: docs referenced a removed `/gsd-new-workspace`
|
||||
* workflow after it was deleted.
|
||||
* workflow after it was deleted. #3560: a deleted workflow was pinned by an
|
||||
* existence assertion in `tests/phase.test.cjs` — the lint passed clean and
|
||||
* the breakage surfaced only as four red tests on the remote runner,
|
||||
* because `tests/` was not scanned at all.
|
||||
*
|
||||
* ## What this checks
|
||||
*
|
||||
* For every file deleted (`git diff --name-status <base>...HEAD`, status
|
||||
* `D`), grep the post-diff tree (`.github/workflows/`, `gsd-core/`, `docs/`,
|
||||
* `package.json`) for the deleted file's basename. Fails if any reference
|
||||
* survives. `package-lock.json` deletions additionally fail if any workflow
|
||||
* still uses `npm ci` or `cache: 'npm'`/`cache: "npm"` — those depend on a
|
||||
* `D`), grep the post-diff tree for the deleted file's basename:
|
||||
*
|
||||
* - `.github/workflows/`, `gsd-core/`, `docs/`, `package.json` — ANY
|
||||
* surviving reference fails (the original rule).
|
||||
* - `tests/` — scanned with a discriminator (#3565): a reference that PINS
|
||||
* existence (`fs.existsSync(path)`, `readFileSync`, `require`, or the
|
||||
* basename as a quoted object key) fails; a reference that ASSERTS
|
||||
* ABSENCE (`assert.ok(!content.includes('x.md'))`, `!fs.existsSync(...)`)
|
||||
* is the correct post-deletion state and passes. The naive widening —
|
||||
* flagging every mention — was tried in #3560 and reverted: an absence
|
||||
* assertion must contain the basename to assert the file is gone, so
|
||||
* without the two kinds separated the guard fires on correct code and
|
||||
* gets disabled. A mention that is neither a pin nor an absence
|
||||
* assertion (prose, a path assembled on another line) is not flagged —
|
||||
* a documented known limit, chosen because a false violation reds a
|
||||
* correct tree while a missed prose mention only loses one detection
|
||||
* channel.
|
||||
*
|
||||
* `package-lock.json` deletions additionally fail if any workflow still
|
||||
* uses `npm ci` or `cache: 'npm'`/`cache: "npm"` — those depend on a
|
||||
* lockfile even though they never spell out its filename.
|
||||
*
|
||||
* ## False-positive risk (moderate, per audit)
|
||||
@@ -34,10 +53,12 @@ const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
||||
const { sanitizeEcho } = require('./command-contract-helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCAN_ROOTS = ['.github/workflows', 'gsd-core', 'docs'];
|
||||
const EXTRA_FILES = ['package.json'];
|
||||
const TESTS_ROOT = 'tests';
|
||||
|
||||
// Skip these when walking SCAN_ROOTS — binary/generated content that can
|
||||
// never carry a meaningful basename reference, and is often large.
|
||||
@@ -115,6 +136,81 @@ function findSurvivingReferences(deletedFiles, corpus) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: classify ONE line of a test file that references `basename`.
|
||||
* Returns 'asserts-absence' | 'pins-existence' | null.
|
||||
*
|
||||
* - asserts-absence: a NEGATED check carrying the basename —
|
||||
* `assert.ok(!content.includes('x.md'))`, `!fs.existsSync(p)`,
|
||||
* `!/x\.md/.test(s)`. The basename must appear for the assertion to
|
||||
* work, so this is the CORRECT post-deletion state (#3560's reverted
|
||||
* naive widening fired exactly here).
|
||||
* - pins-existence: a direct dependency on the file existing —
|
||||
* `fs.existsSync/readFileSync/statSync/readdirSync/require(...)` on the
|
||||
* same line, or the basename as a quoted object key (`'x.md': ...`,
|
||||
* the allowlist trap — a key means the test resolves against the file).
|
||||
* - null: neither (prose mention, path assembled on another line). Not a
|
||||
* violation — see the header's known-limit note.
|
||||
*
|
||||
* @param {string} line
|
||||
* @param {string} basename
|
||||
* @returns {'asserts-absence'|'pins-existence'|null}
|
||||
*/
|
||||
function classifyTestReference(line, basename) {
|
||||
const negatedCheck =
|
||||
/!\s*[A-Za-z_$][\w.$]*\.(includes|indexOf|search|startsWith|endsWith|match)\s*\(/.test(line) ||
|
||||
/!\s*(fs\.)?(existsSync|statSync)\s*\(/.test(line) ||
|
||||
// [^/\n]* (not .*) so an adversarial line cannot backtrack the regex
|
||||
/!\s*\/[^/\n]*\/\s*\.(test|match)\s*\(/.test(line);
|
||||
if (negatedCheck) return 'asserts-absence';
|
||||
const pinsByCall =
|
||||
/\b(existsSync|readFileSync|statSync|readdirSync|require|accessSync)\s*\(/.test(line) ||
|
||||
/\brequire\s*\(?['"]/.test(line);
|
||||
if (pinsByCall) return 'pins-existence';
|
||||
// Template literal needs \\s so the RegExp constructor receives \s — a
|
||||
// bare \s in a template literal collapses to 's' and silently matches a
|
||||
// literal 's' instead of whitespace.
|
||||
if (new RegExp(`['"\`]${escapeRegex(basename)}['"\`]\\s*:`).test(line)) return 'pins-existence';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: for every deleted basename, scan the tests corpus line by line and
|
||||
* report each `pins-existence` reference as a violation. `asserts-absence`
|
||||
* references are explicitly NOT violations; unclassifiable mentions are
|
||||
* skipped (known limit). Violations are per-reference — one test file
|
||||
* carrying both an absence assertion and an existence pin reports the pin.
|
||||
*
|
||||
* @param {string[]} deletedFiles - repo-relative deleted paths
|
||||
* @param {{ file: string, content: string }[]} testsCorpus
|
||||
* @returns {{ deletedFile: string, referencedIn: string, reason: string }[]}
|
||||
*/
|
||||
function findSurvivingTestReferences(deletedFiles, testsCorpus) {
|
||||
const violations = [];
|
||||
for (const deletedFile of deletedFiles) {
|
||||
const basename = path.basename(deletedFile);
|
||||
const refRe = new RegExp(`(^|[^\\w.-])${escapeRegex(basename)}($|[^\\w.-])`);
|
||||
for (const { file, content } of testsCorpus) {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
if (!refRe.test(line)) continue;
|
||||
const kind = classifyTestReference(line, basename);
|
||||
if (kind === 'pins-existence') {
|
||||
violations.push({
|
||||
deletedFile,
|
||||
referencedIn: file,
|
||||
// control-char-stripped + capped: this text is echoed into lint
|
||||
// output that AI agents read as trusted instructions
|
||||
reason: `pins-existence: test depends on deleted basename '${basename}' — ${sanitizeEcho(line.trim())}`,
|
||||
});
|
||||
}
|
||||
// 'asserts-absence' is correct post-deletion state; null is a
|
||||
// documented known limit — neither is a violation.
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function getDeletedFiles(root, baseRef) {
|
||||
// Deliberately let a git failure (unresolvable ref, no merge base, etc.)
|
||||
// propagate as a plain Error — main() treats ANY scan() failure as "cannot
|
||||
@@ -161,7 +257,23 @@ function scan(root, baseRef) {
|
||||
const deletedFiles = getDeletedFiles(root, baseRef);
|
||||
if (deletedFiles.length === 0) return [];
|
||||
const corpus = buildCorpus(root);
|
||||
return findSurvivingReferences(deletedFiles, corpus);
|
||||
const violations = findSurvivingReferences(deletedFiles, corpus);
|
||||
// tests/ arm (#3565): same deletions, discriminated references. A bare
|
||||
// mention that pins nothing is skipped; an absence assertion is the
|
||||
// correct post-deletion state; only a pin on a deleted file fails.
|
||||
const testsCorpus = [];
|
||||
for (const abs of walk(path.join(root, TESTS_ROOT))) {
|
||||
try {
|
||||
testsCorpus.push({
|
||||
file: path.relative(root, abs).replace(/\\/g, '/'),
|
||||
content: fs.readFileSync(abs, 'utf8'),
|
||||
});
|
||||
} catch {
|
||||
// unreadable (broken symlink, binary that slipped past SKIP_EXT) — skip
|
||||
}
|
||||
}
|
||||
violations.push(...findSurvivingTestReferences(deletedFiles, testsCorpus));
|
||||
return violations;
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -195,11 +307,14 @@ module.exports = {
|
||||
referencesBasename,
|
||||
referencesNpmLockfileDependency,
|
||||
findSurvivingReferences,
|
||||
classifyTestReference,
|
||||
findSurvivingTestReferences,
|
||||
getDeletedFiles,
|
||||
buildCorpus,
|
||||
scan,
|
||||
SCAN_ROOTS,
|
||||
EXTRA_FILES,
|
||||
TESTS_ROOT,
|
||||
};
|
||||
|
||||
if (require.main === module) runMain(main);
|
||||
|
||||
Reference in New Issue
Block a user