* 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>
967 lines
39 KiB
JavaScript
967 lines
39 KiB
JavaScript
'use strict';
|
|
/**
|
|
* command-contract-helpers.cjs (ADR-0002)
|
|
*
|
|
* Single source of truth for the commands/gsd/*.md contract constants and
|
|
* parsers shared by scripts/lint-command-contract.cjs and
|
|
* tests/command-contract.test.cjs.
|
|
*
|
|
* Keeping these in one place ensures the lint script and the test suite
|
|
* always agree on what constitutes a valid tool, a valid @-ref, and a valid
|
|
* frontmatter structure. A new canonical tool added here is automatically
|
|
* enforced by both consumers.
|
|
*/
|
|
|
|
const CANONICAL_TOOLS = new Set([
|
|
'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
|
|
'Task', 'Agent', 'Skill', 'SlashCommand',
|
|
'AskUserQuestion', 'WebFetch', 'WebSearch', 'TodoWrite',
|
|
'mcp__context7__resolve-library-id',
|
|
'mcp__context7__query-docs',
|
|
'mcp__context7__*',
|
|
]);
|
|
|
|
function parseFrontmatter(content) {
|
|
// CRLF-tolerant split: Windows checkouts (autocrlf=true) leave a trailing
|
|
// \r on every line, making lines.indexOf('---', 1) return -1 (the value
|
|
// would be '---\r', not '---') → returns {} → every field appears missing.
|
|
const lines = content.split(/\r?\n/);
|
|
if (lines[0].trim() !== '---') return {};
|
|
const end = lines.indexOf('---', 1);
|
|
if (end === -1) return {};
|
|
const fm = {};
|
|
let key = null;
|
|
for (const line of lines.slice(1, end)) {
|
|
const kv = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
|
|
if (kv) { key = kv[1]; fm[key] = kv[2].trim(); }
|
|
else if (key && line.match(/^\s+-\s+/)) {
|
|
const val = line.replace(/^\s+-\s+/, '').trim();
|
|
fm[key] = fm[key] ? fm[key] + '\n' + val : val;
|
|
}
|
|
}
|
|
return fm;
|
|
}
|
|
|
|
function executionContextRefs(content) {
|
|
const refs = [];
|
|
const re = /<execution_context(?:_extended)?>([\s\S]*?)<\/execution_context(?:_extended)?>/g;
|
|
let m;
|
|
while ((m = re.exec(content)) !== null) {
|
|
for (const rawLine of m[1].split('\n')) {
|
|
const line = rawLine.trim();
|
|
if (!line.startsWith('@')) continue;
|
|
const token = line.split(/\s+/)[0];
|
|
const trailingProse = line.length > token.length;
|
|
const normalized = token
|
|
.replace(/^@(?:~|\$HOME)\//, '')
|
|
.replace(/^(?:\.claude\/)?(?:gsd-core\/)?/, '');
|
|
refs.push({ token, normalized, trailingProse });
|
|
}
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
/**
|
|
* workflowPathRefs(content)
|
|
*
|
|
* Locates every gsd-core-relative workflow path referenced in a markdown
|
|
* string, whether the reference is an eager @-include (already covered by
|
|
* executionContextRefs) or a *lazy* path mentioned only in prose/code — a
|
|
* path a command reads on demand via Read/Bash rather than an @-inclusion
|
|
* the harness inlines automatically. Both kinds are load-bearing: the
|
|
* progressive-disclosure split (#717) deliberately keeps most workflow
|
|
* content out of the eager path so the common case stays cheap, but that
|
|
* means a command naming a workflow only in prose is invisible to
|
|
* executionContextRefs even though the runtime still needs the file to
|
|
* exist. Recognizes three reference shapes:
|
|
*
|
|
* A. Any path whose segments include `workflows/`, optionally preceded by
|
|
* an eager `@`, a home-dir prefix (`~/` or `$HOME/`), `.claude/`, and/or
|
|
* `gsd-core/` — e.g. `@~/.claude/gsd-core/workflows/scan.md`,
|
|
* `gsd-core/workflows/x.md`, or a bare `workflows/x.md`.
|
|
* B. Same as A but without the eager `@` — a lazy reference read on
|
|
* demand rather than inlined at load time.
|
|
* C. Parent-relative sub-file paths with no `workflows/` prefix at all —
|
|
* `execute-phase/steps/post-merge-gate.md` — implicitly rooted under
|
|
* `workflows/` because that's the only place `steps/`, `modes/`, and
|
|
* `templates/` subdirectories live.
|
|
*
|
|
* Traversal segments (`..`) are dropped rather than surfaced: this resolver
|
|
* only ever reports paths under `workflows/`, never something a `..` could
|
|
* walk outside of it. Results are de-duplicated, first-seen order preserved.
|
|
*
|
|
* Both regexes anchor `\.md` with a trailing `(?![A-Za-z0-9_])` negative
|
|
* lookahead so a longer extension (`.mdx`, `.md5`) is rejected outright
|
|
* rather than silently truncated into a plausible-looking `.md` path.
|
|
*/
|
|
function workflowPathRefs(content) {
|
|
const refs = [];
|
|
const seen = new Set();
|
|
|
|
function addRef(normalized) {
|
|
if (normalized.split('/').includes('..')) return;
|
|
if (seen.has(normalized)) return;
|
|
seen.add(normalized);
|
|
refs.push(normalized);
|
|
}
|
|
|
|
const shapeARe = /@?(?:(?:~|\$HOME)\/)?(?:\.claude\/)?(?:gsd-core\/)?workflows\/[A-Za-z0-9._/-]+\.md(?![A-Za-z0-9_])/g;
|
|
let m;
|
|
while ((m = shapeARe.exec(content)) !== null) {
|
|
const normalized = m[0]
|
|
.replace(/^@/, '')
|
|
.replace(/^(?:~|\$HOME)\//, '')
|
|
.replace(/^\.claude\//, '')
|
|
.replace(/^gsd-core\//, '');
|
|
addRef(normalized);
|
|
}
|
|
|
|
const shapeCRe = /(?:^|[\s`("'>])([A-Za-z0-9._-]+\/(?:steps|modes|templates)\/[A-Za-z0-9._-]+\.md(?![A-Za-z0-9_]))/gm;
|
|
while ((m = shapeCRe.exec(content)) !== null) {
|
|
addRef('workflows/' + m[1]);
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
/**
|
|
* unreachableWorkflows(loaderContents, gsdFiles, workflowPaths)
|
|
*
|
|
* Computes reachability over the gsd-core file graph and reports which
|
|
* `workflowPaths` are never reached, starting only from `loaderContents`
|
|
* (commands/agents/skills — the files a runtime actually loads) and walking
|
|
* `workflowPathRefs` edges transitively through `gsdFiles`.
|
|
*
|
|
* The seed set is deliberately restricted to loaders and never includes a
|
|
* workflow's own content. Seeding from workflows too would let two failure
|
|
* modes hide: a workflow that only references itself would satisfy its own
|
|
* reachability, and a pair of workflows that reference only each other would
|
|
* form an island that looks connected from the inside but that no command,
|
|
* agent, or skill ever actually opens. Both are orphans in every sense that
|
|
* matters — nothing external can reach them — and both must be reported.
|
|
* Requiring every path to originate at a loader is what makes "reachable"
|
|
* mean "a runtime can actually get here," not merely "something points to
|
|
* it."
|
|
*
|
|
* `gsdFiles` covers all of `gsd-core/**`, not just `workflows/`, because a
|
|
* `references/` or `templates/` file can itself name a workflow path and
|
|
* needs to be walked through to propagate reachability — restricting the map
|
|
* to `workflows/` would silently break any chain that passes through a
|
|
* non-workflow file.
|
|
*
|
|
* `visited` guards the walk against reference cycles (including the
|
|
* mutual/self cases above) so traversal always terminates.
|
|
*/
|
|
function unreachableWorkflows(loaderContents, gsdFiles, workflowPaths) {
|
|
const visited = new Set();
|
|
const queue = [];
|
|
|
|
for (const content of loaderContents) {
|
|
for (const ref of workflowPathRefs(content)) queue.push(ref);
|
|
}
|
|
|
|
while (queue.length > 0) {
|
|
const p = queue.pop();
|
|
if (visited.has(p)) continue;
|
|
visited.add(p);
|
|
if (gsdFiles.has(p)) {
|
|
for (const ref of workflowPathRefs(gsdFiles.get(p))) queue.push(ref);
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|