From 3422b7d6e48268ee52fd36eddadff1316319825a Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 30 Jul 2026 15:06:10 +0100 Subject: [PATCH 1/3] fix(scripts): spell control-character delimiters as escapes, and guard the class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-binding-fidelity and guard-protocol each joined a composite key on a literal NUL byte. The delimiter choice is sound — no field content can collide with it — but written as a raw byte it makes the file binary to every text tool, and each tool fails silently and differently. grep prints nothing and exits 1, so searching a file for a symbol it does contain reports absence. git sniffs the first 8000 bytes: guard-protocol's byte sits at 2439, so every diff of it rendered as "Binary files differ" and the file could not be reviewed line by line, while check-binding-fidelity's at 28832 fell outside the window and diffed normally. Same defect, opposite visibility, neither announced. Written as the \u0000 escape the runtime value is identical — the binding-fidelity report is byte-for-byte unchanged across the edit — and the sources are text again. check-source-encoding registers the class so it cannot return. It found a literal NUL in its own header on first run, which is the shape of mistake it exists to catch. CLAUDE.md and AGENTS.md carry the refreshed index stats: the graph was three days and eighteen merges stale, which no tool reported. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- package.json | 1 + scripts/check-binding-fidelity.ts | 2 +- scripts/check-source-encoding.ts | 79 ++++++++++++++++++++++++++++++ scripts/guard-protocol.ts | Bin 4572 -> 4582 bytes scripts/guards.ts | 8 +++ 7 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 scripts/check-source-encoding.ts diff --git a/AGENTS.md b/AGENTS.md index 8d0b2ff44..c11708955 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ Host auth is normally **keyring + SSH** (`gh` logged in as the active user; `ori # GitNexus — Code Intelligence -This project is indexed by GitNexus as **workflow-server** (10790 symbols, 14448 relationships, 271 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **workflow-server** (13503 symbols, 18236 relationships, 294 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 49e603be3..a78efad2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ Host auth is normally **keyring + SSH** (`gh` logged in as the active user; `ori # GitNexus — Code Intelligence -This project is indexed by GitNexus as **workflow-server** (10790 symbols, 14448 relationships, 271 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **workflow-server** (13503 symbols, 18236 relationships, 294 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/package.json b/package.json index 56f9e5c64..25613a57a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:site": "tsx scripts/generate-site-data.ts", "check:site": "tsx scripts/check-site-links.ts", "check:svg": "tsx scripts/check-svg-layout.ts", + "check:encoding": "tsx scripts/check-source-encoding.ts", "start": "node dist/index.js", "start:http": "node dist/index.js --transport=http", "dev": "tsx src/index.ts", diff --git a/scripts/check-binding-fidelity.ts b/scripts/check-binding-fidelity.ts index b945bc81f..0420838c8 100644 --- a/scripts/check-binding-fidelity.ts +++ b/scripts/check-binding-fidelity.ts @@ -541,7 +541,7 @@ export function collectViolations(): Violation[] { for (const [inputId, meta] of r.entry.own.inputs) { if (meta.hasDefault || meta.optional) continue; const opId = r.homeWf === s.wf ? r.key : `${r.homeWf}::${r.key}`; - const seam = `${s.wf}${opId}${inputId}`; + const seam = `${s.wf}\u0000${opId}\u0000${inputId}`; if (inputId in s.inputsMap) { callerSupplied.add(seam); continue; } if (producersOf(s.wf).has(inputId)) continue; orphans.set(seam, { diff --git a/scripts/check-source-encoding.ts b/scripts/check-source-encoding.ts new file mode 100644 index 000000000..7d2f848e0 --- /dev/null +++ b/scripts/check-source-encoding.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env npx tsx +/** + * Control-character guard over the repo's own text sources. + * + * A C0 control character written literally into a source file makes that file binary to every text + * tool, and each tool fails differently and quietly: + * + * - `grep` prints nothing and exits 1, so a search for a symbol that IS present reports absence. + * - `git` sniffs the first 8000 bytes; a control character inside that window makes every diff of + * the file render as "Binary files differ", so the file cannot be reviewed line by line. + * + * Neither failure announces itself, and whether the second one bites depends on the byte's offset — + * so the same defect is invisible in one file and blocks review in the next. + * + * The remedy is spelling, not avoidance: a delimiter or sentinel written as an escape (`\u0000`, + * `\x1f`) has the identical runtime value and leaves the source readable. + * + * Scope is this repo's own sources. The `workflows` corpus is a separate submodule read by the + * corpus-scope guards, and is not walked here. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { report, type Finding } from './guard-protocol.js'; + +const ROOT = resolve(import.meta.dirname, '..'); + +const TEXT_EXTENSIONS = new Set([ + '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.yaml', '.yml', + '.css', '.html', '.svg', '.sh', '.txt', +]); + +/** Directories that hold generated output, dependencies, or another git tree. */ +const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', '.worktrees', 'workflows']); + +/** Tab, line feed and carriage return are the control characters text files legitimately carry. */ +const ALLOWED = new Set([0x09, 0x0a, 0x0d]); + +/** `0x00` -> `NUL (U+0000)`, so a finding names the byte rather than rendering it. */ +function describe(byte: number): string { + return `U+${byte.toString(16).toUpperCase().padStart(4, '0')}`; +} + +function collect(): Finding[] { + const findings: Finding[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const p = join(dir, entry); + if (statSync(p).isDirectory()) { walk(p); continue; } + const dot = entry.lastIndexOf('.'); + if (dot < 0 || !TEXT_EXTENSIONS.has(entry.slice(dot))) continue; + const buf = readFileSync(p); + // One finding per (file, control character kind) — a delimiter appears once per field, and N + // findings for one bad spelling would just repeat the same remedy. + const seen = new Map(); + let line = 1; + for (const byte of buf) { + if (byte === 0x0a) { line += 1; continue; } + if (byte < 0x20 && !ALLOWED.has(byte) && !seen.has(byte)) seen.set(byte, line); + else if (byte === 0x7f && !seen.has(byte)) seen.set(byte, line); + } + for (const [byte, at] of seen) { + findings.push({ + check: 'control-character', + site: `${relative(ROOT, p)}:${at}`, + detail: `literal ${describe(byte)} in a text source makes the file binary to grep and git — write it as an escape instead`, + }); + } + } + }; + walk(ROOT); + return findings; +} + +report('source-encoding', collect(), { + okMessage: 'no text source carries a literal control character', + root: ROOT, + remedy: 'replace each literal control character with its escape sequence', +}); diff --git a/scripts/guard-protocol.ts b/scripts/guard-protocol.ts index cec86883fba3cca9a8f2371791c9f9e9d229ffa7..494fe23b3b2273fdd061920ec86e20fbc2fd7f4d 100644 GIT binary patch delta 30 fcmcbk{7iX6J11LAsR0m7?%hH01yQqRI(b delta 20 bcmaE+d`EdhJ0~N< g.scope === 'corpus'); From ba453789c17925866e3a552e23fe1dfc8b5b89f6 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 30 Jul 2026 15:22:42 +0100 Subject: [PATCH 2/3] feat(guards): resolve gate expressions, scope dead-output to its workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blind spots in check-binding-fidelity, each a check that went one way only. A gate expression counted as consumption and was never resolved (#341). #327 taught the guard that a step `when` and a `validate` target consume the names they read, which stopped a gated value reading as dead. Nothing checked the other direction, so a gate naming a value nothing produces was accepted, evaluated against an absent bag name, and never fired — the #324 A2 class in the one construct the guard read but did not validate. Resolution makes the extractor load-bearing, so it now parses clauses rather than harvesting identifiers. It takes the left operand of each comparison and a bare clause read for truthiness, never the right side: an unquoted right operand is shaped exactly like an identifier, and `analysis_type == completion` would otherwise read `completion` as a bag name nothing can ever produce. Environment namespaces are named, because dotted-ness cannot separate them — `gh.auth.status` asks the GitHub CLI while `planning_folder_path.writable` is a real bag name carrying a probed field. Sites report at their enclosing step, since the walk reads parsed YAML and a validate target sits inside actions[] where the step id is out of scope. dead-output resolved a consumer by bare name across the whole corpus (#342), so an output in one workflow read as consumed when an unrelated workflow happened to read a name of the same spelling. Consumer resolution is now scoped to the declaring workflow, with meta's library ops reachable from anywhere and cross-workflow reach taken from the bind sites themselves, so a borrowed op's outputs still close. And a stale entry names what now satisfies it: "no longer occurs" had two causes a reader could not tell apart, and deleting the entry on the wrong one is how b41aaacc forced real debt out of the ledger. The first run surfaced 31 findings, triaged: 24 dead-output seams the masking had hidden — including both entries b41aaacc pruned, so #342 R2 falls out of R1 as its issue predicted — and 7 gate expressions reading values nothing declares. All are the two classes #336 already owns, undeclared-seed and terminal-product-unconsumed, and none is newly broken: each runs today because the executing agent improvises the value. Two are worth taking first there, being a precondition and a safety gate rather than routing flags: work-packages' agents_md_read, and work-package's safety_floor, whose correctly-shaped twin ponytail already models as safety_floor_cleared with a checkpoint producing it. deploy-docs drops its check:site and check:svg steps. Both are registry entries check:all runs in verify.yml, which has no path filter, so every PR touching src or site ran them twice. The generated-page drift check stays — that one is not a registry guard. corpusSha now names the corpus this triage was taken against; it had drifted behind the gitlink, and check-delta materialises the base corpus from it. --- .github/workflows/deploy-docs.yml | 4 - scripts/binding-fidelity-triage.json | 219 ++++++++++++++++++++++++++- scripts/check-binding-fidelity.ts | 113 +++++++++++--- tests/binding-fidelity.test.ts | 61 +++++++- tests/guard-registry.test.ts | 6 +- 5 files changed, 378 insertions(+), 25 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index ffd675575..888227839 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -48,10 +48,6 @@ jobs: run: npm run build:site - name: Fail if committed pages drifted from the source run: git diff --exit-code -- site - - name: Check site links and anchors - run: npm run check:site - - name: Check SVG diagram layout - run: npm run check:svg deploy: name: Deploy to GitHub Pages diff --git a/scripts/binding-fidelity-triage.json b/scripts/binding-fidelity-triage.json index 8ec943740..620ff2b2a 100644 --- a/scripts/binding-fidelity-triage.json +++ b/scripts/binding-fidelity-triage.json @@ -1,6 +1,6 @@ { "note": "One-time triage of the binding-fidelity findings the retired baseline had suppressed (issue #327 R3). Every finding carries a verdict and a named rationale, so \"harmless\" and \"live bug\" are no longer the same silence. harmless = correct by design, suppressed. fix-later = real debt, suppressed but counted. live-bug = REPORTED, so the guard stays red until it is fixed. A finding absent from this file is untriaged and reported; an entry that matches nothing is stale and reported. There is no regenerate flag \u2014 an entry is a human judgement, which is exactly what the baseline never required. The triage also found and FIXED three live defects, so they appear here as history rather than as entries: work-package auto-created GitHub/Jira issues in a headless review run (two review-reachable checkpoints with a create default); substrate-node-security-audit required user_request without declaring it; and the stealth push path computed push_remote_verified and commits_signed without gating on either.", - "corpusSha": "7a57d3c1b33cfabd3cb5e205d2f7d80ea853c20f", + "corpusSha": "6bc46faf346b9ac18ac9922a96ad7feff6201d4c", "rationales": { "shared-op-return-contract": "A shared meta operation declares what it returns. Library ops are bound ad hoc by any workflow, so having no consumer inside the corpus is the expected state of a library, not a broken seam; the caller that binds the op consumes the value in its own context.", "shared-op-caller-argument": "A shared meta operation input that the binding step supplies as a deviation when it needs a non-default value. No workflow-level producer is expected: callers that need it bind it, and callers that do not take the op default path.", @@ -1169,6 +1169,223 @@ "detail": "own input 'issue_details' has no producer in workflow 'work-package' (no step-binding entry, workflow variable, step output, or default)", "verdict": "fix-later", "rationale": "test-fixture-pins-the-defect" + }, + { + "check": "read-resolution", + "site": "meta/activities/03-dispatch-client-workflow.yaml[present-yielded-checkpoint]", + "detail": "gate expression reads 'worker_yielded_checkpoint', which has no producer (declared id / workflow var / set-target)", + "verdict": "fix-later", + "rationale": "undeclared-seed" + }, + { + "check": "read-resolution", + "site": "meta/activities/03-dispatch-client-workflow.yaml[respond-yielded-checkpoint]", + "detail": "gate expression reads 'worker_yielded_checkpoint', which has no producer (declared id / workflow var / set-target)", + "verdict": "fix-later", + "rationale": "undeclared-seed" + }, + { + "check": "read-resolution", + "site": "meta/activities/03-dispatch-client-workflow.yaml[commit-activity-artifacts]", + "detail": "gate expression reads 'worker_yielded_checkpoint', which has no producer (declared id / workflow var / set-target)", + "verdict": "fix-later", + "rationale": "undeclared-seed" + }, + { + "check": "read-resolution", + "site": "meta/activities/03-dispatch-client-workflow.yaml[advance-activity]", + "detail": "gate expression reads 'worker_yielded_checkpoint', which has no producer (declared id / workflow var / set-target)", + "verdict": "fix-later", + "rationale": "undeclared-seed" + }, + { + "check": "read-resolution", + "site": "work-package/activities/01-start-work-package.yaml[verify-jira-issue]", + "detail": "gate expression reads 'jira_cloud_id', which has no producer (declared id / workflow var / set-target)", + "verdict": "fix-later", + "rationale": "undeclared-seed" + }, + { + "check": "read-resolution", + "site": "work-package/activities/09-lean-coding-audit.yaml[validate-safety-floor]", + "detail": "gate expression reads 'safety_floor', which has no producer (declared id / workflow var / set-target)", + "verdict": "fix-later", + "rationale": "undeclared-seed" + }, + { + "check": "read-resolution", + "site": "work-packages/activities/01-scope-assessment.yaml[verify-preconditions]", + "detail": "gate expression reads 'agents_md_read', which has no producer (declared id / workflow var / set-target)", + "verdict": "fix-later", + "rationale": "undeclared-seed" + }, + { + "check": "dead-output", + "site": "cicd-pipeline-security-audit/techniques/execute-sub-agent.md", + "detail": "output 'sub_agent_output' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "midnight-system-review/techniques/verdict-and-report/render-review.md", + "detail": "output 'review_summary' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "prism-audit/techniques/execute-analysis/compose-trigger-context.md", + "detail": "output 'target' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "prism-audit/techniques/execute-analysis/compose-trigger-context.md", + "detail": "output 'target_description' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "prism-audit/techniques/execute-analysis/compose-trigger-context.md", + "detail": "output 'analysis_focus' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "prism-audit/techniques/scope-definition/summarize-scope.md", + "detail": "output 'scope_summary' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "prism-evaluate/techniques/plan-evaluation/summarize-scope.md", + "detail": "output 'scope_summary' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "prism-update/techniques/verify-prism-consistency.md", + "detail": "output 'verification_report' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "substrate-node-security-audit/techniques/execute-sub-agent.md", + "detail": "output 'sub_agent_output' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "substrate-node-security-audit/techniques/score-severity.md", + "detail": "output 'scored_findings' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "work-package/techniques/repo-root-resolution.md", + "detail": "output 'component_path' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-authoring/techniques/workflow-definition/compose-publication.md", + "detail": "output 'paths' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-authoring/techniques/workflow-definition/compose-publication.md", + "detail": "output 'commit_message' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-authoring/techniques/workflow-definition/compose-publication.md", + "detail": "output 'title' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-authoring/techniques/workflow-definition/compose-publication.md", + "detail": "output 'body' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-authoring/techniques/workflow-definition/derive-workflows-target-path.md", + "detail": "output 'host_repo_path' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-authoring/techniques/workflow-definition/intake-classification.md", + "detail": "output 'headless_mode' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-design/techniques/TECHNIQUE.md", + "detail": "output 'workflow_files' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-design/techniques/apply-audit-fixes.md", + "detail": "output 'fixes_applied' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-design/techniques/publish-workflow-pr.md", + "detail": "output 'title' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-design/techniques/publish-workflow-pr.md", + "detail": "output 'body' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-design/techniques/reconcile-design-assumptions.md", + "detail": "output 'open_assumptions' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-design/techniques/verify-artifact-conforms.md", + "detail": "output 'artifact_conformance' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" + }, + { + "check": "dead-output", + "site": "workflow-design/techniques/yaml-authoring.md", + "detail": "output 'yaml_file' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)", + "verdict": "fix-later", + "rationale": "terminal-product-unconsumed" } ] } diff --git a/scripts/check-binding-fidelity.ts b/scripts/check-binding-fidelity.ts index 0420838c8..d3c651fb7 100644 --- a/scripts/check-binding-fidelity.ts +++ b/scripts/check-binding-fidelity.ts @@ -272,25 +272,47 @@ const steps: Step[] = []; * visible to the read scan, so an output whose only consumer was a `when` gate or a validate gate * read as dead — the opposite of dead (#327 R3). */ -const expressionConsumes: Array<{ rel: string; name: string }> = []; +const expressionConsumes: Array<{ rel: string; stepId: string; name: string }> = []; -/** Operators and literals in a condition expression are not bag names. */ -const EXPRESSION_LITERALS = new Set(['true', 'false', 'null', 'undefined', 'length', 'and', 'or', 'not']); +/** + * Namespaces naming the ENVIRONMENT rather than the variable bag: `gh.auth.status == 0` asks the + * GitHub CLI, not the session. A probe head has no producer by construction, so resolution has to + * know them by name — dotted-ness cannot discriminate, since `planning_folder_path.writable` is a + * real bag name carrying a probed field. + */ +const ENV_PROBES = new Set(['gh', 'gpg', 'git', 'signing', 'workflows']); -/** The head bag names an expression reads: `missing_prerequisites.length == 0` -> `missing_prerequisites`. */ -function expressionNames(expr: string): string[] { +/** + * The bag names an expression READS — the left operand of each comparison, plus a bare clause read + * for truthiness. + * + * Only the left side. A right operand is a value, and an unquoted one is indistinguishable from an + * identifier by shape: `analysis_type == completion` would otherwise read `completion` as a bag name + * that nothing can ever produce. #327 collected every identifier in the string, which was safe while + * these names only MARKED consumption — a false name there costs nothing. Feeding them to + * resolution makes the extractor load-bearing, so it parses clauses instead of harvesting words. + */ +export function expressionReads(expr: string): string[] { const out: string[] = []; - for (const m of expr.matchAll(/[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)*/g)) { - const head = m[0]!.split('.')[0]!; - if (!EXPRESSION_LITERALS.has(head)) out.push(head); + for (const clause of expr.split(/&&|\|\|/)) { + const compared = clause.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)*)\s*(?:==|!=|>=|<=|>|<)/); + const bare = clause.match(/^\s*!?\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)*)\s*$/); + const ref = compared?.[1] ?? bare?.[1]; + if (!ref) continue; + const head = ref.split('.')[0]!; + if (!ENV_PROBES.has(head)) out.push(head); } return out; } -function walkSteps(wf: string, rel: string, node: unknown, activityId: string): void { +function walkSteps(wf: string, rel: string, node: unknown, activityId: string, stepId = '?'): void { if (!node || typeof node !== 'object') return; - if (Array.isArray(node)) { node.forEach((n) => walkSteps(wf, rel, n, activityId)); return; } + if (Array.isArray(node)) { node.forEach((n) => walkSteps(wf, rel, n, activityId, stepId)); return; } const o = node as Record; + // A gate expression is reported at its enclosing step, not by line: the walk reads parsed YAML, + // which carries no line numbers, and a `validate` target sits inside `actions[]` where the step id + // is already out of scope. + const here = typeof o.id === 'string' ? o.id : stepId; // A step's technique binding is either a bare string (no deviations) or a structured object // `{ name, inputs?, outputs? }` — inputs are op-input deviations, outputs are op-output remaps. const t = o.technique; @@ -306,16 +328,16 @@ function walkSteps(wf: string, rel: string, node: unknown, activityId: string): // (`fragment_references_issue != false`, `has_debt_markers == true`) — the one place the value is // enforced or the gate that consumes it. if (o.action === 'validate' && typeof o.target === 'string') { - for (const name of expressionNames(o.target)) expressionConsumes.push({ rel, name }); + for (const name of expressionReads(o.target)) expressionConsumes.push({ rel, stepId: here, name }); } if (typeof o.when === 'string') { - for (const name of expressionNames(o.when)) expressionConsumes.push({ rel, name }); + for (const name of expressionReads(o.when)) expressionConsumes.push({ rel, stepId: here, name }); } if (o.setVariable && typeof o.setVariable === 'object') Object.keys(o.setVariable).forEach((k) => produced(wf).add(k)); const eff = o.effect as { setVariable?: object } | undefined; if (eff?.setVariable) Object.keys(eff.setVariable).forEach((k) => produced(wf).add(k)); if (typeof o.variable === 'string') produced(wf).add(o.variable); - for (const v of Object.values(o)) walkSteps(wf, rel, v, activityId); + for (const v of Object.values(o)) walkSteps(wf, rel, v, activityId, here); } type Read = { rel: string; line: number; full: string; head: string; kind: 'technique' | 'activity' }; @@ -497,6 +519,42 @@ function collectConsumedSites(): Map> { return consumed; } +/** + * Which workflows genuinely reach into a home workflow's operations, from the bind sites themselves: + * `remediate-vuln` borrowing a `work-package` op can consume that op's outputs, and `meta` is the + * universal library every workflow binds ad hoc. + */ +const crossWorkflowConsumers = ((): Map> => { + const reach = new Map>(); + for (const s of steps) { + const r = resolve(s.technique, s.wf, s.activityId); + if (!r || r.homeWf === s.wf) continue; + let into = reach.get(r.homeWf); + if (!into) { into = new Set(); reach.set(r.homeWf, into); } + into.add(s.wf); + } + return reach; +})(); + +/** + * Whether a consumer file can close a dead-output finding on a declaring file. + * + * Resolution used to be by bare name across the whole corpus, so an output in workflow A read as + * consumed when an unrelated workflow B happened to read a name of the same spelling — and B has no + * address for A's op, so it cannot bind it (#342). The masking presented as a STALE triage entry, + * which reads like progress, and forced real debt out of the ledger. + */ +function consumerReaches(consumerRel: string, declaringRel: string): boolean { + const consumerWf = consumerRel.split('/')[0]!; + const declaringWf = declaringRel.split('/')[0]!; + if (consumerWf === declaringWf) return true; + if (declaringWf === META) return true; + return crossWorkflowConsumers.get(declaringWf)?.has(consumerWf) ?? false; +} + +/** Dead-output findings that a consumer closed: ` ` -> satisfying file. */ +const deadOutputSatisfier = new Map(); + /* --------------------------------- checks --------------------------------- */ export interface Violation { check: 'arg-conformance' | 'read-resolution' | 'binding-resolution' | 'dead-output' | 'orphan-input'; @@ -561,12 +619,25 @@ export function collectViolations(): Violation[] { if (scopeOf(wf).has(r.head)) continue; v.push({ check: 'read-resolution', site: `${r.rel}:${r.line}`, detail: `{${r.full}} has no producer (declared id / $-local / workflow var / set-target)` }); } - // (3) dead-output + // (2b) read-resolution over gate expressions — the same scope a `{token}` resolves against. + // #327 taught the guard to count a `when` and a `validate` target as CONSUMPTION, which stopped a + // gated value reading as dead. It never checked the other direction, so a gate naming a value + // nothing produces was accepted and could never fire (#341 R1, the #324 A2 class). + for (const e of expressionConsumes) { + if (PLACEHOLDER.has(e.name)) continue; + const wf = e.rel.split('/')[0]!; + if (scopeOf(wf).has(e.name)) continue; + v.push({ + check: 'read-resolution', site: `${e.rel}[${e.stepId}]`, + detail: `gate expression reads '${e.name}', which has no producer (declared id / workflow var / set-target)`, + }); + } + // (3) dead-output — consumer resolution scoped to the declaring workflow const consumed = collectConsumedSites(); for (const site of declaredOutputSites) { if (site.hasArtifact) continue; - const consumers = consumed.get(site.id); - if (consumers && [...consumers].some((rel) => rel !== site.rel)) continue; + const satisfier = [...(consumed.get(site.id) ?? [])].find((rel) => rel !== site.rel && consumerReaches(rel, site.rel)); + if (satisfier) { deadOutputSatisfier.set(`${site.rel}\u0000${site.id}`, satisfier); continue; } v.push({ check: 'dead-output', site: site.rel, detail: `output '${site.id}' is declared but nothing outside its own file consumes it (no read, condition, binding value, remap, or same-named input)`, @@ -640,10 +711,18 @@ export function applyTriage(violations: Violation[] = collectViolations()): Tria for (const [key, entry] of byKey) { if (seen.has(key)) continue; counts.stale++; + // "No longer occurs" has two causes a reader must be able to tell apart: the seam was CLOSED, or + // the guard stopped SEEING it. Naming what now satisfies the finding makes the second visible — + // a satisfier in another workflow is the #342 masking shape, and deleting the entry there would + // drop real debt out of the ledger. + const outputId = entry.check === 'dead-output' ? /output '([^']+)'/.exec(entry.detail)?.[1] : undefined; + const satisfier = outputId ? deadOutputSatisfier.get(`${entry.site}\u0000${outputId}`) : undefined; findings.push({ check: 'stale-triage', site: entry.site, - detail: `triaged '${entry.check}' finding no longer occurs — delete the entry from scripts/binding-fidelity-triage.json`, + detail: satisfier + ? `triaged '${entry.check}' finding no longer occurs — now satisfied by ${satisfier}; delete the entry only if that is a real closure` + : `triaged '${entry.check}' finding no longer occurs — delete the entry from scripts/binding-fidelity-triage.json`, }); } return { findings, counts, total: violations.length }; diff --git a/tests/binding-fidelity.test.ts b/tests/binding-fidelity.test.ts index 56bc0f806..796d86f2b 100644 --- a/tests/binding-fidelity.test.ts +++ b/tests/binding-fidelity.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { applyTriage, loadTriage } from '../scripts/check-binding-fidelity.js'; +import { applyTriage, loadTriage, expressionReads } from '../scripts/check-binding-fidelity.js'; /** * Binding-fidelity gate. The corpus carries triaged debt, recorded per finding with a verdict and a @@ -30,3 +30,62 @@ describe('binding-fidelity gate', () => { expect(undefinedRationales).toEqual([]); }); }); + +/** + * Gate expressions became a resolution surface in #341, which makes this extractor load-bearing: a + * name it invents is a finding nobody can fix, and a name it drops is a gate that can never fire. + * Every case here is a shape the corpus actually carries. + */ +describe('gate-expression reads', () => { + it('takes the left operand and never the right', () => { + // An unquoted right operand is shaped exactly like an identifier, so harvesting every word in + // the string would read `completion` as a bag name nothing can produce. + expect(expressionReads('analysis_type == completion')).toEqual(['analysis_type']); + expect(expressionReads("operation_type != 'review'")).toEqual(['operation_type']); + }); + + it('splits a conjunction into one read per clause', () => { + expect(expressionReads("issue_platform == 'jira' && issue_skipped != true")) + .toEqual(['issue_platform', 'issue_skipped']); + }); + + it('reduces a dotted path to its bag head', () => { + expect(expressionReads('missing_prerequisites.length == 0')).toEqual(['missing_prerequisites']); + expect(expressionReads('planning_folder_path.writable == true')).toEqual(['planning_folder_path']); + }); + + it('reads a bare clause for truthiness', () => { + expect(expressionReads('agents_md_read')).toEqual(['agents_md_read']); + }); + + it('exempts namespaces that probe the environment rather than the bag', () => { + // `gh.auth.status` asks the GitHub CLI. Dotted-ness cannot discriminate these, since + // `planning_folder_path.writable` above is a real bag name carrying a probed field. + expect(expressionReads('gh.auth.status == 0')).toEqual([]); + expect(expressionReads('gpg.agent.reachable == true')).toEqual([]); + expect(expressionReads('signing.configured == true')).toEqual([]); + }); + + it('reads nothing from an empty-collection comparison', () => { + expect(expressionReads('broken_artifact_links == []')).toEqual(['broken_artifact_links']); + }); +}); + +/** + * #342: a consumer in an unrelated workflow used to close a dead-output finding by bare name, and + * the masking presented as a STALE entry — which reads like progress and forced real debt out of the + * ledger. These two are the entries commit b41aaacc pruned for exactly that reason. + */ +describe('dead-output scoping', () => { + it('keeps the seams that a same-named read in another workflow was masking', () => { + const declared = loadTriage().entries.filter((e) => e.check === 'dead-output'); + const masked = [ + ['workflow-design/techniques/apply-audit-fixes.md', 'fixes_applied'], + ['workflow-design/techniques/yaml-authoring.md', 'yaml_file'], + ]; + for (const [site, output] of masked) { + expect(declared.some((e) => e.site === site && e.detail.includes(`'${output}'`)), + `${site} :: ${output} must stay in the ledger — workflow-authoring's same-named read cannot consume it`).toBe(true); + } + }); +}); diff --git a/tests/guard-registry.test.ts b/tests/guard-registry.test.ts index 518490589..3c717214e 100644 --- a/tests/guard-registry.test.ts +++ b/tests/guard-registry.test.ts @@ -41,8 +41,10 @@ describe('guard registry', () => { it('separates corpus-scoped guards from repo-scoped ones', () => { expect(CORPUS_GUARDS.length).toBeGreaterThan(0); expect(CORPUS_GUARDS.every((g) => g.scope === 'corpus')).toBe(true); - // The site guards read `site/`, not the corpus, so a delta run must not aim them at a corpus root. - expect(GUARDS.filter((g) => g.scope === 'repo').map((g) => g.id).sort()).toEqual(['site-links', 'svg-layout']); + // The site guards read `site/` and the encoding guard reads this repo's own sources — neither + // reads the corpus, so a delta run must not aim them at a corpus root. + expect(GUARDS.filter((g) => g.scope === 'repo').map((g) => g.id).sort()) + .toEqual(['site-links', 'source-encoding', 'svg-layout']); }); it('covers every check:* script in package.json', () => { From eb4cba35e75e47364ae5967570f4c8b74cf099a8 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Thu, 30 Jul 2026 16:03:54 +0100 Subject: [PATCH 3/3] docs(site): describe where guards run, and the triage model that replaced baselines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims on the quality-system page were false. Dropping the duplicated site checks from deploy-docs made one of them false in this same branch: that workflow no longer runs the site guards, it rebuilds the generated regions and fails on drift. The guards run in verify, which has no path filter, so the page now says that — the property that made the deploy-docs copies redundant is the one worth stating. The other predates this branch. #327 retired the committed baselines and the --update-baseline flag, and the page still described both, so a reader would reach for a flag no guard has. It now describes the triage model that replaced them: a verdict and a named rationale per finding, no re-snapshot because classification is a judgement, and a stale entry reported so a seam cannot leave the ledger by going quiet. --- site/design/quality-system.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/design/quality-system.html b/site/design/quality-system.html index cc477d029..d070bcb84 100644 --- a/site/design/quality-system.html +++ b/site/design/quality-system.html @@ -153,10 +153,10 @@

The guard suite

-
Authors run guards locally while editing the workflows branch; Vitest mirrors key corpus checks so regressions fail npm test; the deploy-docs workflow runs the site pipeline on documentation PRs.
+
Authors run guards locally while editing the workflows branch; Vitest mirrors key corpus checks so regressions fail npm test; the verify workflow walks the whole registry on every pull request, and deploy-docs rebuilds the generated regions and checks them for drift.
-

Most guards are hard-zero — any finding fails the run. A few compare against a committed baseline of reviewed, pre-existing findings and fail only when the corpus grows beyond that snapshot; re-snapshot with --update-baseline after an intentional change. The full command list is in package.json.

+

Most guards are hard-zero — any finding fails the run. Where a guard measures debt the corpus already carries, every finding is triaged once by hand in a committed file that records a verdict and a named rationale against each one: harmless by design, a real seam accepted for now, or a live bug that keeps the guard red until it is fixed. There is no re-snapshot flag, because classifying a finding is a judgement rather than a regeneration — and an entry that stops matching anything is reported too, so a seam cannot leave the ledger by going quiet. The full command list is in package.json.

The test architecture

The Vitest suite (thirty-plus files) is organized around what could break:

@@ -218,7 +218,7 @@

Generated, then guarded

The two generation pipelines. The JSON schemas are compiled from the Zod sources; the site's tool and schema pages contain regions regenerated by replaying the real tool registrations against a recording stub. In both cases the generated artifact is committed, and a test fails when it no longer matches a fresh generation — documentation cannot silently lie about the code.
-

The same discipline extends to deployment: the GitHub Pages workflow re-runs the site guards before publishing, so the deployed site is exactly the checked, committed one.

+

The same discipline extends to deployment: the GitHub Pages workflow rebuilds the generated regions and fails if the committed pages have drifted, so the deployed site is exactly the checked, committed one. The guards themselves run in the verify workflow, which has no path filter — every pull request gets the whole suite, so a site check cannot be skipped by a change that looks unrelated.

Next

Where this machinery lives in the server is server anatomy; how runs are kept on-script at runtime is workflow fidelity.