@event4u/agent-config 14.7.0 → 14.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -388
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
- package/dist/agent-src/commands/evals/roadmap.json +17 -1
- package/dist/agent-src/commands/pr/create.md +14 -2
- package/dist/agent-src/commands/pr/merge.md +379 -0
- package/dist/agent-src/commands/roadmap/process-full.md +157 -3
- package/dist/agent-src/commands/roadmap.md +1 -1
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +64 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
- package/dist/agent-src/rules/recurring-criticism.md +1 -1
- package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
- package/dist/agent-src/rules/source-confidentiality.md +1 -1
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/security/SKILL.md +8 -0
- package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +20 -2
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
- package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-EEJUMCDZ.js} +14 -14
- package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +14 -14
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +7 -7
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_handoff.js +7 -6
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_route_audit.js +6 -5
- package/dist/cli-delegate/cmd_route_explain.js +3 -3
- package/dist/cli-delegate/cmd_session_recycle.js +3 -3
- package/dist/cli-delegate/cmd_settings_get.js +3 -3
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +3 -3
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +53 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +806 -681
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/partitionEligibility.js +156 -0
- package/dist/install/partitionEligibility.js.map +1 -1
- package/dist/install/ruleLayerPartition.js +157 -0
- package/dist/install/ruleLayerPartition.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +654 -578
- package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
- package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
- package/dist/scripts/condense.js +52 -89
- package/dist/scripts/condense.js.map +1 -1
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/false-green.md +47 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/src/config/ci-local-parity.yml +21 -0
- package/src/config/estate-count-budget.json +275 -2
- package/src/config/evaluator-budgets.json +3 -3
- package/src/config/gate-coverage.yml +220 -12
- package/src/config/gate-violation-baselines.json +73 -63
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/rule-stub-ceilings.json +17 -3
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/partitionEligibility.ts +177 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/envelope_grounding.ts +11 -1
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/adr/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +12 -32
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/audit_adr_coverage.ts +103 -17
- package/src/scripts/bench_hook_latency.ts +204 -0
- package/src/scripts/build_archive_index.ts +34 -0
- package/src/scripts/check_adr_frontmatter.ts +574 -39
- package/src/scripts/check_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_references.ts +18 -0
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_single_delivery.ts +137 -4
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/condense.ts +61 -91
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +22 -2
- package/src/scripts/hooks/dispatch_hook.ts +154 -187
- package/src/scripts/hooks/dispatch_issues.ts +38 -19
- package/src/scripts/hooks/fallback_yaml.ts +66 -0
- package/src/scripts/hooks/host_semantics.ts +21 -0
- package/src/scripts/hooks/injection_budget.ts +13 -3
- package/src/scripts/hooks/py_json_dumps.ts +103 -0
- package/src/scripts/hooks/state_io.ts +88 -1
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/injection_scan_hook.ts +137 -5
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/sync_pr_branch.ts +163 -20
- package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
- package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
|
@@ -33,20 +33,72 @@
|
|
|
33
33
|
* trigger grammar as a prerequisite for exactly this reason. An `indeterminate`
|
|
34
34
|
* result means: this may not be presented as an unqualified lock — route it.
|
|
35
35
|
*
|
|
36
|
+
* It also prints the two descriptive axes (`provenance`, `evidence`) and
|
|
37
|
+
* `authority_basis`, and for an accepted E0/E1 record that does NOT carry
|
|
38
|
+
* `authority_basis: owner_intent` it prints
|
|
39
|
+
* `authority_effect: disabled-shadow-mode` — see LOW_EVIDENCE_NOTICE.
|
|
40
|
+
* Provenance is deliberately NOT part of that condition: the notice is about
|
|
41
|
+
* evidence strength, and a human snapshot has exactly as little of it.
|
|
42
|
+
*
|
|
43
|
+
* It prints three more record fields, so that a revisit read can be performed
|
|
44
|
+
* from this tool instead of from the file by hand: `evidence.basis` verbatim
|
|
45
|
+
* (with a `[found]` / `[MISSING]` marker on the refs that are repo paths — a
|
|
46
|
+
* URL and a `claim:` id are printed and never checked), the RESOLVED
|
|
47
|
+
* `reopen_policy` marked declared-or-defaulted, and `protected_dimensions`.
|
|
48
|
+
*
|
|
49
|
+
* What it deliberately does not print: whether the proposed transition is
|
|
50
|
+
* reversible. That is a property of the PROPOSAL, not of the record, so no
|
|
51
|
+
* reader of an ADR file can supply it — the citer does.
|
|
52
|
+
* Shadow mode means exactly what the word says: the axes are surfaced, and no
|
|
53
|
+
* grade changes who may act (`adr-layout § Provenance and evidence`).
|
|
54
|
+
*
|
|
36
55
|
* Usage:
|
|
37
56
|
* ./scripts-run src/scripts/adr_cite_check ADR-211
|
|
38
57
|
* ./scripts-run src/scripts/adr_cite_check ADR-001 ADR-035 --json
|
|
58
|
+
* ./scripts-run src/scripts/adr_cite_check --cited # the CI gate
|
|
59
|
+
*
|
|
60
|
+
* `--cited` is the CI invocation: it resolves every ADR citation found in
|
|
61
|
+
* `src/rules/`, `src/skills/`, `src/domains/` and `docs/` against the corpus
|
|
62
|
+
* and reds when one points nowhere. Until it existed no workflow ran this tool
|
|
63
|
+
* at all — only a test imported it — so every "enforced at cite time" claim
|
|
64
|
+
* about this file was model-carried.
|
|
39
65
|
*
|
|
40
66
|
* Exit codes: 0 every reference resolved · 1 at least one did not · 2 usage.
|
|
41
67
|
*/
|
|
42
68
|
|
|
43
69
|
import * as fs from 'node:fs';
|
|
70
|
+
import * as os from 'node:os';
|
|
44
71
|
import * as path from 'node:path';
|
|
45
72
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
73
|
+
import {
|
|
74
|
+
type AdrFrontmatter,
|
|
75
|
+
authorityBasisOf,
|
|
76
|
+
evidenceOf,
|
|
77
|
+
isLowEvidenceAccepted,
|
|
78
|
+
provenanceOf,
|
|
79
|
+
readAdrFrontmatter,
|
|
80
|
+
readAdrFrontmatterScalars,
|
|
81
|
+
} from './_lib/adr_frontmatter.js';
|
|
82
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
83
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
84
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
46
85
|
|
|
47
86
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
48
87
|
const REPO_ROOT = path.resolve(_HERE, '..', '..');
|
|
49
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The tree the corpus mode reads, overridable for `--self-test`.
|
|
91
|
+
*
|
|
92
|
+
* The self-test drives the REAL binary — argv parsing, entry guard and all —
|
|
93
|
+
* over a synthetic tree, which is the whole point of the harness
|
|
94
|
+
* (`_lib/gate_self_test.ts`): an in-process call would skip exactly the layers
|
|
95
|
+
* that have silently no-opped in this repository before. Driving it needs one
|
|
96
|
+
* seam, and this is it.
|
|
97
|
+
*/
|
|
98
|
+
function scan_root(): string {
|
|
99
|
+
return process.env['ADR_CITE_CHECK_ROOT'] ?? REPO_ROOT;
|
|
100
|
+
}
|
|
101
|
+
|
|
50
102
|
/**
|
|
51
103
|
* The surfaces a decision can live on. Six exist in this tree; this tool reads
|
|
52
104
|
* the two that carry numbered ADRs and can therefore be cited as `ADR-NNN`.
|
|
@@ -67,7 +119,9 @@ const REPO_ROOT = path.resolve(_HERE, '..', '..');
|
|
|
67
119
|
* about, committed by this very file. It resolves by path and by the
|
|
68
120
|
* `ADR-<area>-NNNN` citation form now; a BARE number still cannot address one,
|
|
69
121
|
* because per-area numbering restarts per area and five files here are `0001`.
|
|
70
|
-
*
|
|
122
|
+
* A per-area hit now carries the same metadata as a flat one: all seven
|
|
123
|
+
* records gained YAML frontmatter on 2026-08-21, so `PARTIAL_COVERAGE` is
|
|
124
|
+
* empty and no residual limit is claimed for this surface.
|
|
71
125
|
*/
|
|
72
126
|
export const ADR_DIRS = ['docs/decisions', 'docs/adrs'] as const;
|
|
73
127
|
|
|
@@ -78,17 +132,22 @@ export const SURFACES_NOT_SCANNED = [
|
|
|
78
132
|
] as const;
|
|
79
133
|
|
|
80
134
|
/**
|
|
81
|
-
* Partial coverage
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
135
|
+
* Partial coverage — empty, and the KEY is kept deliberately.
|
|
136
|
+
*
|
|
137
|
+
* The single entry this held is gone. It said `docs/adrs/<area>/` resolves by
|
|
138
|
+
* PATH and by the `ADR-<area>-NNNN` citation form while those files carry a
|
|
139
|
+
* quote-block header rather than YAML, so `status` / `review_trigger` / the
|
|
140
|
+
* link fields read as absent. All seven per-area records carry real
|
|
141
|
+
* frontmatter as of 2026-08-21, so that gap closed — and its replacement text
|
|
142
|
+
* was prose asserting the absence of partial coverage inside a field named for
|
|
143
|
+
* its presence, plus a "remove on the next pass" TODO, shipped into `--json`.
|
|
144
|
+
*
|
|
145
|
+
* The key stays. `partial_coverage` is a published output shape (see the
|
|
146
|
+
* `--json` payload), so removing it breaks any consumer that reads it, while
|
|
147
|
+
* an empty list states exactly what is true: no partial coverage is known
|
|
148
|
+
* today. This is the place to record the next one.
|
|
88
149
|
*/
|
|
89
|
-
export const PARTIAL_COVERAGE = [
|
|
90
|
-
'docs/adrs/<area>/ — resolves, but the header is a quote block, not YAML: metadata reads empty',
|
|
91
|
-
] as const;
|
|
150
|
+
export const PARTIAL_COVERAGE: readonly string[] = [];
|
|
92
151
|
|
|
93
152
|
/** A trigger state. `none` means the ADR never recorded a reopen condition. */
|
|
94
153
|
export type TriggerState = 'none' | 'indeterminate' | 'fired' | 'not-fired';
|
|
@@ -110,10 +169,91 @@ export interface CiteResult {
|
|
|
110
169
|
amendment_blocks: string[];
|
|
111
170
|
/** Other ADR files whose text names this one. */
|
|
112
171
|
referenced_by: string[];
|
|
172
|
+
/** `provenance.kind` — who decided. Absent when the record carries no axis. */
|
|
173
|
+
provenance_kind?: string;
|
|
174
|
+
/** `provenance.agentic_mode` — descriptive shape of an agentic decision. */
|
|
175
|
+
provenance_agentic_mode?: string;
|
|
176
|
+
/** `evidence.strength` — E0–E4, claim-relative. */
|
|
177
|
+
evidence_strength?: string;
|
|
178
|
+
/** `evidence.discovery` — `complete` | `incomplete`; required on E0. */
|
|
179
|
+
evidence_discovery?: string;
|
|
180
|
+
/**
|
|
181
|
+
* `evidence.basis` — the refs the grade rests on, VERBATIM.
|
|
182
|
+
*
|
|
183
|
+
* Present only when the record declares at least one. The tool prints them;
|
|
184
|
+
* whether each one still supports the claim is the citer's read, not a
|
|
185
|
+
* verdict this file may issue.
|
|
186
|
+
*/
|
|
187
|
+
evidence_basis?: string[];
|
|
188
|
+
/**
|
|
189
|
+
* The subset of `evidence_basis` that looks like a repo path and does NOT
|
|
190
|
+
* exist in this checkout — the one existence question that is cheap and
|
|
191
|
+
* decidable here. A URL or a `claim:` id is never listed: liveness of a URL
|
|
192
|
+
* needs the network, and a claim id resolves against the claim registry,
|
|
193
|
+
* neither of which this tool touches. Set (possibly empty) whenever
|
|
194
|
+
* `evidence_basis` is set, so an empty list means "checked, all present"
|
|
195
|
+
* rather than "not checked".
|
|
196
|
+
*/
|
|
197
|
+
evidence_basis_unresolved?: string[];
|
|
198
|
+
/** `authority_basis` — `evidence` (default) or `owner_intent`. */
|
|
199
|
+
authority_basis?: string;
|
|
200
|
+
/**
|
|
201
|
+
* `reopen_policy`, RESOLVED — `directional` | `owner` | `unclassified`.
|
|
202
|
+
*
|
|
203
|
+
* Never blank on a resolved record: `adr-layout § Reopen authority` states
|
|
204
|
+
* that an absent field resolves to `unclassified`, and printing a blank
|
|
205
|
+
* would make an agent re-derive a default the contract already fixed.
|
|
206
|
+
* `reopen_policy_defaulted` says which of the two it was, because
|
|
207
|
+
* "nobody classified this" and "someone classified it as unclassified" are
|
|
208
|
+
* the same value and not the same fact.
|
|
209
|
+
*/
|
|
210
|
+
reopen_policy?: string;
|
|
211
|
+
/** True when `reopen_policy` was absent and resolved to `unclassified`. */
|
|
212
|
+
reopen_policy_defaulted?: boolean;
|
|
213
|
+
/**
|
|
214
|
+
* `protected_dimensions` — the reserved interests this record touches.
|
|
215
|
+
* Empty list when the record declares none; that is not the same as
|
|
216
|
+
* "no dimension is reserved for the transition being proposed", which is a
|
|
217
|
+
* property of the proposal and outside anything a record can say.
|
|
218
|
+
*/
|
|
219
|
+
protected_dimensions?: string[];
|
|
220
|
+
/**
|
|
221
|
+
* What the axes say a cite-time reader may draw from this record.
|
|
222
|
+
*
|
|
223
|
+
* Present only as `disabled-shadow-mode`, and only for an accepted E0/E1
|
|
224
|
+
* record that does NOT carry `authority_basis: owner_intent`.
|
|
225
|
+
*
|
|
226
|
+
* Provenance is deliberately NOT part of that condition, and this docstring
|
|
227
|
+
* said "accepted + agentic + E0/E1" after the predicate had stopped
|
|
228
|
+
* checking provenance — caught in completion review. The notice is about
|
|
229
|
+
* evidence strength, which a human snapshot has exactly as little of; the
|
|
230
|
+
* real exemption is an owner purpose statement, whose alternatives are
|
|
231
|
+
* foreclosed by ownership rather than by evidence.
|
|
232
|
+
*
|
|
233
|
+
* The literal is deliberate: no grade authorizes anything here, so the
|
|
234
|
+
* field reports a *disabled* effect rather than asserting a permission
|
|
235
|
+
* that does not exist. Whether one ever may is Phase 7's open question.
|
|
236
|
+
*/
|
|
237
|
+
authority_effect?: string;
|
|
113
238
|
/** Why this may or may not be cited as a live lock. */
|
|
114
239
|
verdict: string;
|
|
115
240
|
}
|
|
116
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The block printed for a record whose own metadata says it may not establish
|
|
244
|
+
* that the alternatives remain invalid.
|
|
245
|
+
*
|
|
246
|
+
* Emitted verbatim and unindented so the whole block is greppable as one
|
|
247
|
+
* literal — the string `disabled-shadow-mode` is the load-bearing token and a
|
|
248
|
+
* reader (or a downstream check) must be able to match it without knowing this
|
|
249
|
+
* renderer's indentation.
|
|
250
|
+
*/
|
|
251
|
+
export const LOW_EVIDENCE_NOTICE = [
|
|
252
|
+
'authority_effect: disabled-shadow-mode',
|
|
253
|
+
'This record documents the prior choice. It does not by itself',
|
|
254
|
+
'establish that alternatives remain invalid.',
|
|
255
|
+
].join('\n');
|
|
256
|
+
|
|
117
257
|
/**
|
|
118
258
|
* A parsed citation. `area` is null for the flat surface.
|
|
119
259
|
*
|
|
@@ -175,33 +315,141 @@ export function adr_files(repo_root: string = REPO_ROOT): string[] {
|
|
|
175
315
|
return ADR_DIRS.flatMap((d) => walk_md(path.join(repo_root, d)));
|
|
176
316
|
}
|
|
177
317
|
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// The CI corpus: every ADR the tree cites at someone.
|
|
320
|
+
//
|
|
321
|
+
// WHAT THE GATE FAILS ON, and why it is this and not something else.
|
|
322
|
+
//
|
|
323
|
+
// A malformed axis (`evidence.strength: E9`, an E0 with no `discovery`) is
|
|
324
|
+
// already `check_adr_frontmatter`'s job, and duplicating it here would buy a
|
|
325
|
+
// second red for one defect while leaving this gate's real subject unguarded.
|
|
326
|
+
// The condition only THIS tool computes is an **unresolvable citation**: a
|
|
327
|
+
// rule, skill, domain or doc that names `ADR-NNN` where no decision record
|
|
328
|
+
// carries that number on a scanned surface. That is a live defect class in this
|
|
329
|
+
// tree — a lock cited by number that a reader cannot open is the same
|
|
330
|
+
// false-absence failure `verdict_for` was written for, seen from the citing
|
|
331
|
+
// side — and `cite_check` already decides it (`resolved: false` → exit 1).
|
|
332
|
+
//
|
|
333
|
+
// So the CI invocation resolves every citation in `src/rules/`, `src/skills/`,
|
|
334
|
+
// `src/domains/` and `docs/` against the corpus, and reds when any one of them
|
|
335
|
+
// points nowhere.
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/** The trees whose ADR citations the CI invocation resolves. */
|
|
339
|
+
export const CITATION_ROOTS = ['src/rules', 'src/skills', 'src/domains', 'docs'] as const;
|
|
340
|
+
|
|
341
|
+
const CITATION_FILE_EXTS = new Set(['.md', '.ts', '.yml', '.yaml']);
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* A citation, in the two forms `normalise_ref` can resolve.
|
|
345
|
+
*
|
|
346
|
+
* The trailing `(?![0-9A-Za-z])` rejects the template placeholders the corpus
|
|
347
|
+
* really contains — `ADR-0N` in `producing-the-review.md` and `ADR-206`, which
|
|
348
|
+
* would otherwise be discovered as a citation to `ADR-0`.
|
|
349
|
+
*
|
|
350
|
+
* The per-area alternative requires the area to START with a letter, which is
|
|
351
|
+
* what `normalise_ref`'s own `byArea` pattern requires and is not cosmetic:
|
|
352
|
+
* without it, the filename reference `ADR-082-410-one-click-relaunch.md` is
|
|
353
|
+
* discovered as `ADR-082-410` (area `082`), and prose like a dated sweep name
|
|
354
|
+
* would be discovered as a citation to an area that does not exist — a
|
|
355
|
+
* manufactured red. A per-area citation whose area contains a hyphen is
|
|
356
|
+
* therefore NOT discovered by this scan; all six live areas are single words,
|
|
357
|
+
* and `normalise_ref` still resolves such a citation when it is passed
|
|
358
|
+
* explicitly. Stated rather than left as a silent narrowing.
|
|
359
|
+
*/
|
|
360
|
+
const CITATION_RE = /ADR-(?:[a-z][a-z0-9]*-\d{1,4}|\d{1,4})(?![0-9A-Za-z])/g;
|
|
361
|
+
|
|
362
|
+
function walk_citable(dir: string): string[] {
|
|
363
|
+
if (!fs.existsSync(dir)) return [];
|
|
364
|
+
const out: string[] = [];
|
|
365
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
366
|
+
const full = path.join(dir, entry.name);
|
|
367
|
+
if (entry.isDirectory()) out.push(...walk_citable(full));
|
|
368
|
+
else if (CITATION_FILE_EXTS.has(path.extname(entry.name))) out.push(full);
|
|
369
|
+
}
|
|
370
|
+
return out.sort();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Every distinct ADR reference the citation roots name, sorted. */
|
|
374
|
+
export function cited_refs(repo_root: string = REPO_ROOT): string[] {
|
|
375
|
+
const found = new Set<string>();
|
|
376
|
+
for (const root of CITATION_ROOTS) {
|
|
377
|
+
for (const file of walk_citable(path.join(repo_root, root))) {
|
|
378
|
+
for (const m of fs.readFileSync(file, 'utf-8').matchAll(CITATION_RE)) {
|
|
379
|
+
found.add(m[0]);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return [...found].sort();
|
|
384
|
+
}
|
|
385
|
+
|
|
178
386
|
/**
|
|
179
387
|
* Minimal frontmatter reader — folded (`>-`) values are joined, which the
|
|
180
388
|
* corpus needs: every `review_trigger` in it is a folded multi-line string.
|
|
181
389
|
*/
|
|
390
|
+
/**
|
|
391
|
+
* Read the leading `---` block.
|
|
392
|
+
*
|
|
393
|
+
* Delegates to the shared reader (`_lib/adr_frontmatter.ts`); this function was
|
|
394
|
+
* one of the three divergent copies that reader replaces. Kept as a named
|
|
395
|
+
* export because the test suite imports it.
|
|
396
|
+
*/
|
|
182
397
|
export function parse_frontmatter(text: string): Record<string, string> | null {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
398
|
+
return readAdrFrontmatterScalars(text);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `protected_dimensions` off the shared reader's node, narrowed — not parsed.
|
|
403
|
+
*
|
|
404
|
+
* The field is written inline (`protected_dimensions: [governance]`), so the
|
|
405
|
+
* shared reader hands it back under `nested` as a string list. This only
|
|
406
|
+
* narrows that node; adding a second parser for a field one reader already
|
|
407
|
+
* reads is how the tree ended up with three divergent ADR parsers.
|
|
408
|
+
*/
|
|
409
|
+
export function protected_dimensions_of(fm: AdrFrontmatter): string[] {
|
|
410
|
+
const node = fm.nested['protected_dimensions'];
|
|
411
|
+
if (Array.isArray(node)) return node;
|
|
412
|
+
if (typeof node === 'string' && node !== '') return [node];
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Which `evidence.basis` refs are repo paths that are not there any more.
|
|
418
|
+
*
|
|
419
|
+
* Deliberately narrow. A ref is checked ONLY when it looks like a repo-relative
|
|
420
|
+
* path — no scheme, no `claim:` prefix. A trailing `(YYYY-MM-DD)` access note
|
|
421
|
+
* and a `:LINE` / `:LINE-LINE` anchor are stripped before the check, because
|
|
422
|
+
* both appear in the corpus and neither is part of the filename.
|
|
423
|
+
*
|
|
424
|
+
* What this does NOT do, stated because the difference is the whole point: it
|
|
425
|
+
* does not fetch a URL, does not resolve a `claim:` id against the claim
|
|
426
|
+
* registry, and does not judge whether a ref that EXISTS still supports the
|
|
427
|
+
* grade. "The file is present" is the only claim made.
|
|
428
|
+
*/
|
|
429
|
+
export function unresolved_basis_refs(basis: readonly string[], repo_root: string): string[] {
|
|
430
|
+
const missing: string[] = [];
|
|
431
|
+
for (const ref of basis) {
|
|
432
|
+
if (basis_ref_kind(ref) !== 'path') continue;
|
|
433
|
+
if (!fs.existsSync(path.join(repo_root, basis_ref_path(ref)))) missing.push(ref);
|
|
203
434
|
}
|
|
204
|
-
return
|
|
435
|
+
return missing;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** `url` and `claim` refs are printed and never checked; `path` refs are. */
|
|
439
|
+
export function basis_ref_kind(ref: string): 'url' | 'claim' | 'path' {
|
|
440
|
+
const t = ref.trim();
|
|
441
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) return 'url';
|
|
442
|
+
if (/^claim:/i.test(t)) return 'claim';
|
|
443
|
+
return 'path';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Strip a trailing `(note)` and a `:LINE[-LINE]` anchor off a path-shaped ref. */
|
|
447
|
+
function basis_ref_path(ref: string): string {
|
|
448
|
+
return ref
|
|
449
|
+
.trim()
|
|
450
|
+
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
451
|
+
.replace(/:\d+(-\d+)?$/, '')
|
|
452
|
+
.trim();
|
|
205
453
|
}
|
|
206
454
|
|
|
207
455
|
/**
|
|
@@ -229,6 +477,14 @@ export function amendment_blocks(body: string): string[] {
|
|
|
229
477
|
export function trigger_state(fm: Record<string, string>): TriggerState {
|
|
230
478
|
const t = (fm['review_trigger'] ?? '').trim();
|
|
231
479
|
if (t === '') return 'none';
|
|
480
|
+
// The transitional migration value records that no condition has been
|
|
481
|
+
// written yet, so it is `none` rather than `indeterminate`. Mapping it to
|
|
482
|
+
// `indeterminate` would report "a condition exists, its state is unknown"
|
|
483
|
+
// for a record that has no condition at all — the opposite of what the
|
|
484
|
+
// staging is for, and indistinguishable at a glance from a real trigger
|
|
485
|
+
// nobody has evaluated. Found in completion review; the field did not
|
|
486
|
+
// exist in this file's corpus when the mapping was written.
|
|
487
|
+
if (t.toLowerCase() === 'unclassified') return 'none';
|
|
232
488
|
return 'indeterminate';
|
|
233
489
|
}
|
|
234
490
|
|
|
@@ -305,6 +561,26 @@ export function cite_check(refs: string[], repo_root: string = REPO_ROOT): CiteR
|
|
|
305
561
|
|
|
306
562
|
const text = contents.get(match) ?? '';
|
|
307
563
|
const fm = parse_frontmatter(text) ?? {};
|
|
564
|
+
// The two nested axes need the structured reader; `parse_frontmatter`
|
|
565
|
+
// folds them back to a string for scalar-only callers. `null` here is
|
|
566
|
+
// the honest answer for a record predating the axes — no longer for a
|
|
567
|
+
// per-area record: all seven carry real frontmatter as of 2026-08-21
|
|
568
|
+
// and read through exactly the same path as a flat one.
|
|
569
|
+
const structured = readAdrFrontmatter(text);
|
|
570
|
+
const provenance = structured === null ? null : provenanceOf(structured);
|
|
571
|
+
const evidence = structured === null ? null : evidenceOf(structured);
|
|
572
|
+
const basis = structured === null ? null : authorityBasisOf(structured);
|
|
573
|
+
const evidence_basis = evidence?.basis ?? [];
|
|
574
|
+
// `adr-layout § Reopen authority`: absent resolves to `unclassified`,
|
|
575
|
+
// and deliberately NOT to `owner` — fail-closed there would encode the
|
|
576
|
+
// existing blockage into the schema. A record with no frontmatter at
|
|
577
|
+
// all resolves the same way, for the same reason.
|
|
578
|
+
const declared_policy = fm['reopen_policy'];
|
|
579
|
+
const reopen_policy =
|
|
580
|
+
declared_policy !== undefined && declared_policy !== '' ? declared_policy : 'unclassified';
|
|
581
|
+
const reopen_policy_defaulted = declared_policy === undefined || declared_policy === '';
|
|
582
|
+
const protected_dimensions = structured === null ? [] : protected_dimensions_of(structured);
|
|
583
|
+
const provisional = structured !== null && isLowEvidenceAccepted(structured);
|
|
308
584
|
const bodyStart = text.indexOf('\n---\n', 4);
|
|
309
585
|
const body = bodyStart === -1 ? text : text.slice(bodyStart + 5);
|
|
310
586
|
|
|
@@ -337,6 +613,29 @@ export function cite_check(refs: string[], repo_root: string = REPO_ROOT): CiteR
|
|
|
337
613
|
...(fm['amended_by'] !== undefined ? { amended_by: fm['amended_by'] } : {}),
|
|
338
614
|
amendment_blocks: amendment_blocks(body),
|
|
339
615
|
referenced_by,
|
|
616
|
+
...(provenance?.kind !== undefined && provenance.kind !== null
|
|
617
|
+
? { provenance_kind: provenance.kind }
|
|
618
|
+
: {}),
|
|
619
|
+
...(provenance?.agenticMode !== undefined && provenance.agenticMode !== null
|
|
620
|
+
? { provenance_agentic_mode: provenance.agenticMode }
|
|
621
|
+
: {}),
|
|
622
|
+
...(evidence?.strength !== undefined && evidence.strength !== null
|
|
623
|
+
? { evidence_strength: evidence.strength }
|
|
624
|
+
: {}),
|
|
625
|
+
...(evidence?.discovery !== undefined && evidence.discovery !== null
|
|
626
|
+
? { evidence_discovery: evidence.discovery }
|
|
627
|
+
: {}),
|
|
628
|
+
...(evidence_basis.length > 0
|
|
629
|
+
? {
|
|
630
|
+
evidence_basis,
|
|
631
|
+
evidence_basis_unresolved: unresolved_basis_refs(evidence_basis, repo_root),
|
|
632
|
+
}
|
|
633
|
+
: {}),
|
|
634
|
+
...(basis !== null ? { authority_basis: basis } : {}),
|
|
635
|
+
reopen_policy,
|
|
636
|
+
reopen_policy_defaulted,
|
|
637
|
+
protected_dimensions,
|
|
638
|
+
...(provisional ? { authority_effect: 'disabled-shadow-mode' } : {}),
|
|
340
639
|
};
|
|
341
640
|
return { ...partial, verdict: verdict_for(partial) };
|
|
342
641
|
});
|
|
@@ -363,37 +662,230 @@ function render(results: CiteResult[]): string {
|
|
|
363
662
|
lines.push(` amendments ${String(r.amendment_blocks.length)}: ${r.amendment_blocks.join(' · ')}`);
|
|
364
663
|
if (r.referenced_by.length > 0)
|
|
365
664
|
lines.push(` referenced by ${String(r.referenced_by.length)} other ADR(s): ${r.referenced_by.join(', ')}`);
|
|
665
|
+
|
|
666
|
+
// The two descriptive axes. Printed for every resolved record, absent
|
|
667
|
+
// or not: "the axis is missing" is itself the thing a citer needs to
|
|
668
|
+
// see — a record with no grade has not been assessed, which is not the
|
|
669
|
+
// same as a record assessed as weak.
|
|
670
|
+
const provenanceLine = r.provenance_kind ?? '— (no provenance axis)';
|
|
671
|
+
const mode = r.provenance_agentic_mode !== undefined ? ` · mode ${r.provenance_agentic_mode}` : '';
|
|
672
|
+
lines.push(` provenance ${provenanceLine}${mode}`);
|
|
673
|
+
lines.push(
|
|
674
|
+
` evidence ${r.evidence_strength ?? '— (ungraded)'}` +
|
|
675
|
+
` · discovery ${r.evidence_discovery ?? '—'}`,
|
|
676
|
+
);
|
|
677
|
+
lines.push(` authority_basis ${r.authority_basis ?? '— (absent → evidence)'}`);
|
|
678
|
+
|
|
679
|
+
// The basis refs, verbatim, one per line with an existence marker. A
|
|
680
|
+
// grade with no readable basis is the thing a citer has to see, so the
|
|
681
|
+
// absent case prints too rather than dropping the line.
|
|
682
|
+
if (r.evidence_basis === undefined || r.evidence_basis.length === 0) {
|
|
683
|
+
lines.push(' evidence_basis — (none recorded)');
|
|
684
|
+
} else {
|
|
685
|
+
const unresolved = new Set(r.evidence_basis_unresolved ?? []);
|
|
686
|
+
lines.push(
|
|
687
|
+
` evidence_basis ${String(r.evidence_basis.length)} ref(s), ` +
|
|
688
|
+
`${String(unresolved.size)} unresolved:`,
|
|
689
|
+
);
|
|
690
|
+
for (const ref of r.evidence_basis) {
|
|
691
|
+
const kind = basis_ref_kind(ref);
|
|
692
|
+
const marker =
|
|
693
|
+
kind !== 'path'
|
|
694
|
+
? `[not checked: ${kind}]`
|
|
695
|
+
: unresolved.has(ref)
|
|
696
|
+
? '[MISSING]'
|
|
697
|
+
: '[found]';
|
|
698
|
+
lines.push(` ${marker} ${ref}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// The two reserved-authority fields, printed together because the
|
|
703
|
+
// routing question reads them together. `protected_dimensions` names
|
|
704
|
+
// what the RECORD reserves; whether a given transition weakens one is a
|
|
705
|
+
// property of the proposal and is nothing this tool can print.
|
|
706
|
+
const policyNote = r.reopen_policy_defaulted === true ? ' (absent → default)' : ' (declared)';
|
|
707
|
+
const dims =
|
|
708
|
+
r.protected_dimensions !== undefined && r.protected_dimensions.length > 0
|
|
709
|
+
? r.protected_dimensions.join(', ')
|
|
710
|
+
: '— (none declared)';
|
|
711
|
+
lines.push(` reopen_policy ${r.reopen_policy ?? 'unclassified'}${policyNote}`);
|
|
712
|
+
lines.push(` protected dims ${dims}`);
|
|
366
713
|
lines.push(` → ${r.verdict}`);
|
|
714
|
+
// Verbatim + unindented, on purpose: see LOW_EVIDENCE_NOTICE.
|
|
715
|
+
if (r.authority_effect === 'disabled-shadow-mode') lines.push(LOW_EVIDENCE_NOTICE);
|
|
367
716
|
}
|
|
368
717
|
return lines.join('\n');
|
|
369
718
|
}
|
|
370
719
|
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* `--self-test` — drives the REAL binary over synthetic trees.
|
|
723
|
+
*
|
|
724
|
+
* Not optional for a new gate: `gate-self-test:registered-non-adopters`
|
|
725
|
+
* (`check_gate_coverage.list_self_test_non_adopters`) is a shrink-only count
|
|
726
|
+
* over the enforced manifest, so registering this gate without a self-test
|
|
727
|
+
* would raise it and red CI. That ratchet is working as designed — the cheapest
|
|
728
|
+
* moment to prove a gate discriminates is when it is written, and an enforced
|
|
729
|
+
* `scanned:` floor only proves the gate READ something.
|
|
730
|
+
*
|
|
731
|
+
* Both reject cases were run by hand before they were written down, and both
|
|
732
|
+
* were seen red: a planted `ADR-993` citation exited 1, and a citation root
|
|
733
|
+
* with no ADR reference in it exited 1 through `DeadScopeError`. A case never
|
|
734
|
+
* seen red has unknown sensitivity.
|
|
735
|
+
*/
|
|
736
|
+
export function selfTest(): number {
|
|
737
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'acc-'));
|
|
738
|
+
|
|
739
|
+
/** Build a tree with one flat ADR and one citing rule; return its root. */
|
|
740
|
+
const mk = (adr: string | null, citation: string | null): string => {
|
|
741
|
+
const root = fs.mkdtempSync(path.join(tmp, 'root-'));
|
|
742
|
+
fs.mkdirSync(path.join(root, 'docs', 'decisions'), { recursive: true });
|
|
743
|
+
fs.mkdirSync(path.join(root, 'src', 'rules'), { recursive: true });
|
|
744
|
+
if (adr !== null) {
|
|
745
|
+
fs.writeFileSync(
|
|
746
|
+
path.join(root, 'docs', 'decisions', `ADR-${adr}-probe.md`),
|
|
747
|
+
`---\nstatus: accepted\ndate: 2026-08-21\ndecision: probe\n---\n\n# Probe\n`,
|
|
748
|
+
'utf-8',
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
fs.writeFileSync(
|
|
752
|
+
path.join(root, 'src', 'rules', 'probe.md'),
|
|
753
|
+
citation === null ? '# Probe\n\nNo decision is named here.\n' : `# Probe\n\nSee ${citation}.\n`,
|
|
754
|
+
'utf-8',
|
|
755
|
+
);
|
|
756
|
+
return root;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const run = (root: string): number => {
|
|
760
|
+
process.env['ADR_CITE_CHECK_ROOT'] = root;
|
|
761
|
+
try {
|
|
762
|
+
return runGateCli(REPO_ROOT, 'src/scripts/adr_cite_check.ts', ['--cited'], REPO_ROOT);
|
|
763
|
+
} finally {
|
|
764
|
+
delete process.env['ADR_CITE_CHECK_ROOT'];
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
return runSelfTest({
|
|
770
|
+
gate: 'adr_cite_check',
|
|
771
|
+
minCases: 3,
|
|
772
|
+
minRejectCases: 2,
|
|
773
|
+
cases: [
|
|
774
|
+
{
|
|
775
|
+
name: 'a citation whose ADR exists passes',
|
|
776
|
+
expect: 'accept',
|
|
777
|
+
run: () => run(mk('001', 'ADR-001')),
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
name: 'a citation to an ADR that does not exist is rejected — the gate\'s whole subject',
|
|
781
|
+
expect: 'reject',
|
|
782
|
+
run: () => run(mk('001', 'ADR-993')),
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
name: 'citation roots that name no ADR at all are rejected as a dead scope, never green',
|
|
786
|
+
expect: 'reject',
|
|
787
|
+
run: () => run(mk('001', null)),
|
|
788
|
+
},
|
|
789
|
+
],
|
|
790
|
+
});
|
|
791
|
+
} finally {
|
|
792
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
371
796
|
function main(argv: string[]): number {
|
|
372
797
|
const as_json = argv.includes('--json');
|
|
373
|
-
|
|
374
|
-
|
|
798
|
+
if (argv.includes('--self-test')) return selfTest();
|
|
799
|
+
const corpus_mode = argv.includes('--cited');
|
|
800
|
+
const explicit = argv.filter((a) => !a.startsWith('--'));
|
|
801
|
+
if (!corpus_mode && explicit.length === 0) {
|
|
375
802
|
process.stderr.write(
|
|
376
803
|
'usage: adr_cite_check <ADR-NNN> [ADR-NNN …] [--json]\n' +
|
|
377
|
-
'
|
|
804
|
+
' adr_cite_check --cited [--json]\n' +
|
|
805
|
+
' evaluate a decision before citing it as a reason not to act\n' +
|
|
806
|
+
' --cited: resolve every ADR citation in ' +
|
|
807
|
+
`${CITATION_ROOTS.join(', ')} (the CI gate)\n`,
|
|
378
808
|
);
|
|
379
809
|
return 2;
|
|
380
810
|
}
|
|
381
811
|
|
|
382
|
-
const
|
|
812
|
+
const root = scan_root();
|
|
813
|
+
const refs = corpus_mode ? cited_refs(root) : explicit;
|
|
814
|
+
const results = cite_check(refs, root);
|
|
815
|
+
|
|
816
|
+
// The ledger and the `scanned:` line exist only in corpus mode, and the
|
|
817
|
+
// asymmetry is deliberate. In explicit-ref mode the refs come from argv, so
|
|
818
|
+
// there is no scan scope to assert and a published count would be a floor
|
|
819
|
+
// under a constant — the false-count shape `gate-coverage.yml` rejects. In
|
|
820
|
+
// corpus mode the denominator is real: it collapses to 0 if either the
|
|
821
|
+
// citation roots or the decision surfaces move, which is exactly the
|
|
822
|
+
// dead-scan-root failure the assertion is for.
|
|
823
|
+
let ledger: GateLedger | null = null;
|
|
824
|
+
if (corpus_mode) {
|
|
825
|
+
ledger = new GateLedger('adr_cite_check');
|
|
826
|
+
ledger.plan(refs);
|
|
827
|
+
for (const r of results) {
|
|
828
|
+
if (r.resolved) ledger.complete(r.ref);
|
|
829
|
+
else ledger.fail(r.ref, 'citation resolves to no decision record');
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
383
833
|
if (as_json) {
|
|
834
|
+
// The tally rides in the payload rather than on stdout: printing the
|
|
835
|
+
// ledger line beside the JSON would break every parser of this mode.
|
|
384
836
|
process.stdout.write(
|
|
385
837
|
JSON.stringify(
|
|
386
838
|
{
|
|
387
839
|
results,
|
|
388
840
|
surfaces_not_scanned: SURFACES_NOT_SCANNED,
|
|
389
841
|
partial_coverage: PARTIAL_COVERAGE,
|
|
842
|
+
...(ledger !== null ? { ledger: ledger.finalize() } : {}),
|
|
390
843
|
},
|
|
391
844
|
null,
|
|
392
845
|
2,
|
|
393
846
|
) + '\n',
|
|
394
847
|
);
|
|
395
848
|
} else {
|
|
396
|
-
|
|
849
|
+
// Corpus mode renders only the unresolved records. The full per-record
|
|
850
|
+
// surfacing is 178 records × ~10 lines today — a CI step that prints
|
|
851
|
+
// 1,900 lines of green hides the one red line that matters, and the
|
|
852
|
+
// surfacing exists for a human citing ONE decision, not for the gate.
|
|
853
|
+
// `--json` still carries every record for a machine consumer.
|
|
854
|
+
const shown = corpus_mode ? results.filter((r) => !r.resolved) : results;
|
|
855
|
+
if (corpus_mode) {
|
|
856
|
+
process.stdout.write(
|
|
857
|
+
`ADR citations: ${String(results.length)} distinct reference(s) across ` +
|
|
858
|
+
`${CITATION_ROOTS.join(', ')} · ` +
|
|
859
|
+
`${String(results.filter((r) => !r.resolved).length)} unresolved\n`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
if (shown.length > 0) process.stdout.write(render(shown) + '\n');
|
|
863
|
+
if (ledger !== null) {
|
|
864
|
+
try {
|
|
865
|
+
// The count is references RESOLVED AGAINST the corpus, not the
|
|
866
|
+
// subset that resolved successfully — deliberately the same
|
|
867
|
+
// number the ledger's own `scanned=` reports, because two
|
|
868
|
+
// different values printed under the same word is the drift
|
|
869
|
+
// `reportScanned` was written to prevent. It still guards both
|
|
870
|
+
// roots: a moved citation root drops this to 0 and throws
|
|
871
|
+
// here, and a moved decision surface makes every reference
|
|
872
|
+
// unresolved, which reds on exit 1 below with 178 failures in
|
|
873
|
+
// the ledger. Neither failure can present as green.
|
|
874
|
+
reportScanned({
|
|
875
|
+
gate: 'adr_cite_check',
|
|
876
|
+
scanned: results.length,
|
|
877
|
+
units: 'cited ADR reference(s)',
|
|
878
|
+
roots: [...CITATION_ROOTS, ...ADR_DIRS],
|
|
879
|
+
});
|
|
880
|
+
} catch (exc) {
|
|
881
|
+
if (exc instanceof DeadScopeError) {
|
|
882
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
883
|
+
return 1;
|
|
884
|
+
}
|
|
885
|
+
throw exc;
|
|
886
|
+
}
|
|
887
|
+
ledger.report();
|
|
888
|
+
}
|
|
397
889
|
}
|
|
398
890
|
|
|
399
891
|
const unresolved = results.filter((r) => !r.resolved);
|
|
@@ -408,17 +900,26 @@ function main(argv: string[]): number {
|
|
|
408
900
|
}
|
|
409
901
|
|
|
410
902
|
// Main-guard (realpath-compared, mirrors the repo convention).
|
|
903
|
+
//
|
|
904
|
+
// `process.exitCode`, never `process.exit()` — and the difference is a real
|
|
905
|
+
// defect this change would otherwise have shipped. `--cited --json` writes a
|
|
906
|
+
// ~167 KB payload; `process.exit()` tears the process down before Node has
|
|
907
|
+
// flushed an async stdout, so piping that mode into a parser delivered exactly
|
|
908
|
+
// 65,536 bytes and a JSONDecodeError, while redirecting to a file delivered all
|
|
909
|
+
// of it. Measured before the fix, on this branch. Setting the code and letting
|
|
910
|
+
// the process end naturally flushes first. Twenty-odd sibling gates already use
|
|
911
|
+
// this shape (`audit_skill_overlap`, `apply_modules_config`, …).
|
|
411
912
|
if (process.argv[1] !== undefined) {
|
|
412
913
|
try {
|
|
413
914
|
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
414
915
|
const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
|
|
415
916
|
if (here === argv1) {
|
|
416
|
-
process.
|
|
917
|
+
process.exitCode = main(process.argv.slice(2));
|
|
417
918
|
}
|
|
418
919
|
} catch {
|
|
419
920
|
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
420
921
|
if (import.meta.url === argvUrl) {
|
|
421
|
-
process.
|
|
922
|
+
process.exitCode = main(process.argv.slice(2));
|
|
422
923
|
}
|
|
423
924
|
}
|
|
424
925
|
}
|