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/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/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 b945bc81f..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'; @@ -541,7 +599,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, { @@ -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/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 cec86883f..494fe23b3 100644 Binary files a/scripts/guard-protocol.ts and b/scripts/guard-protocol.ts differ diff --git a/scripts/guards.ts b/scripts/guards.ts index df4fb68c3..22c7317f7 100644 --- a/scripts/guards.ts +++ b/scripts/guards.ts @@ -170,6 +170,14 @@ export const GUARDS: GuardSpec[] = [ json: false, proves: 'site SVG diagrams stay within their geometric bounds', }, + { + id: 'source-encoding', + script: 'scripts/check-source-encoding.ts', + npmScript: 'check:encoding', + scope: 'repo', + json: true, + proves: 'no text source carries a literal control character, so grep and git diff stay honest', + }, ]; export const CORPUS_GUARDS = GUARDS.filter((g) => g.scope === 'corpus'); 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.

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', () => {