@event4u/agent-config 14.6.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 +255 -145
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +264 -10
- package/dist/agent-src/commands/analyze.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 +272 -32
- 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 +95 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
- package/dist/agent-src/rules/fix-what-you-see.md +108 -0
- package/dist/agent-src/rules/no-roadmap-references.md +0 -5
- package/dist/agent-src/rules/recurring-criticism.md +110 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
- package/dist/agent-src/rules/rule-type-governance.md +0 -2
- package/dist/agent-src/rules/self-repair-loop.md +17 -0
- package/dist/agent-src/rules/skill-quality.md +0 -2
- package/dist/agent-src/rules/source-confidentiality.md +37 -7
- package/dist/agent-src/scripts/archival_sweep.ts +117 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/gate_budget.ts +282 -0
- package/dist/agent-src/scripts/gate_execute.ts +72 -31
- package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
- package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
- package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
- package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
- package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
- package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
- package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
- 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/quality-tools/SKILL.md +34 -326
- package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
- package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
- package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
- package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
- package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
- package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -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/skills/skill-writing/SKILL.md +34 -570
- package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
- package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
- package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +65 -4
- package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/dist/cli/registry.js +2 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
- package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
- package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
- package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
- package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
- package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
- package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
- package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
- package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
- package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
- package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +466 -716
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +10 -10
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +10 -9
- package/dist/cli-delegate/cmd_handoff.js +10 -9
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +1 -1
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +11 -10
- 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 +840 -30
- package/dist/cli-delegate/cmd_settings_get.js +3 -3
- package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +11 -10
- package/dist/cli-delegate/cmd_uninstall.js +13 -11
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +11 -10
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +111 -37
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +15 -12
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +11 -8
- package/dist/hooks/dispatch.js +3165 -2192
- package/dist/install/claudePathsPlan.js +187 -0
- package/dist/install/claudePathsPlan.js.map +1 -0
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/hostLayerFingerprint.js +136 -0
- package/dist/install/hostLayerFingerprint.js.map +1 -0
- package/dist/install/install.mjs +553 -425
- package/dist/install/partitionEligibility.js +432 -0
- package/dist/install/partitionEligibility.js.map +1 -0
- 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 +1863 -1234
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_src.js +41 -0
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/scripts/_lib/install_layout.js +83 -0
- package/dist/scripts/_lib/install_layout.js.map +1 -0
- package/dist/scripts/_lib/installed_lock.js +444 -0
- package/dist/scripts/_lib/installed_lock.js.map +1 -0
- 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 +63 -251
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +12 -6
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
- package/docs/guidelines/agent-infra/false-green.md +47 -1
- package/docs/guidelines/agent-infra/memory-access.md +17 -0
- package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archival_sweep.ts +117 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/gate_budget.ts +282 -0
- package/src/agent-src/scripts/gate_execute.ts +72 -31
- package/src/agent-src/scripts/roadmap_gates.ts +74 -3
- package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
- package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/src/config/agent-settings.template.yml +27 -2
- package/src/config/ci-local-parity.yml +39 -0
- package/src/config/estate-count-budget.json +612 -3
- 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-activation-census.json +14 -6
- package/src/config/rule-stub-ceilings.json +34 -7
- package/src/install/claudePathsPlan.ts +196 -0
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/hostLayerFingerprint.ts +145 -0
- package/src/install/partitionEligibility.ts +502 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
- package/src/scripts/_dispatch.bash +17 -1
- package/src/scripts/_lib/ac_heading.ts +30 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/agent_src.ts +42 -0
- package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
- package/src/scripts/_lib/envelope_grounding.ts +49 -9
- package/src/scripts/_lib/installed_lock.ts +50 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/md_section.ts +129 -0
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/_lib/scope_guard.sh +26 -3
- package/src/scripts/_lib/self_repair.ts +19 -0
- package/src/scripts/_lib/self_repair_class_b.ts +171 -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/ai_council/cli_help.ts +55 -0
- package/src/scripts/audit_adr_coverage.ts +103 -17
- package/src/scripts/before_complete_hook.ts +153 -28
- package/src/scripts/bench_ab_v2_complexity.ts +27 -7
- 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 +78 -0
- package/src/scripts/check_release_published.ts +32 -11
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_single_delivery.ts +194 -17
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/ci_settle.ts +170 -0
- package/src/scripts/condense.ts +79 -259
- package/src/scripts/council_cli.ts +3 -3
- package/src/scripts/dispatch_r2_reviewer.ts +6 -14
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +77 -14
- package/src/scripts/hooks/block_no_verify.ts +61 -10
- package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
- package/src/scripts/hooks/concern_registry.ts +4 -0
- 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/rtk_wrap_hook.ts +8 -3
- package/src/scripts/hooks/run_continuation_hook.ts +8 -5
- package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
- package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
- package/src/scripts/hooks/state_io.ts +870 -21
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
- package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
- package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
- package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
- package/src/scripts/injection_scan_hook.ts +137 -5
- package/src/scripts/install.ts +16 -11
- package/src/scripts/language_mirror_hook.ts +561 -25
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_plan_risk_register.ts +4 -3
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/lint_skill_router_head.ts +4 -4
- package/src/scripts/mcp_server/tools.ts +2 -2
- package/src/scripts/new_roadmap.ts +136 -0
- package/src/scripts/pack_mcp_content.ts +20 -5
- package/src/scripts/release.ts +37 -38
- package/src/scripts/report_layer_overlap.ts +6 -0
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/self_repair_hook.ts +37 -5
- package/src/scripts/session_register_hook.ts +11 -0
- package/src/scripts/skill_usage_collect.ts +11 -1
- package/src/scripts/skill_usage_report.ts +278 -8
- package/src/scripts/sync_pr_branch.ts +170 -20
- package/src/server/schemas/settings.ts +8 -0
- package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
- package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
- package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
|
@@ -37,6 +37,18 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
37
37
|
|
|
38
38
|
import { GateLedger } from './_lib/gate_ledger.js';
|
|
39
39
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
40
|
+
import {
|
|
41
|
+
type AdrFrontmatter,
|
|
42
|
+
AUTHORITY_BASES,
|
|
43
|
+
AGENTIC_MODES,
|
|
44
|
+
DISCOVERY_STATES,
|
|
45
|
+
EVIDENCE_STRENGTHS,
|
|
46
|
+
PROVENANCE_KINDS,
|
|
47
|
+
evidenceOf,
|
|
48
|
+
provenanceOf,
|
|
49
|
+
readAdrFrontmatter,
|
|
50
|
+
readAdrFrontmatterScalars,
|
|
51
|
+
} from './_lib/adr_frontmatter.js';
|
|
40
52
|
|
|
41
53
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
54
|
const REPO_ROOT = path.resolve(_HERE, '..', '..');
|
|
@@ -150,36 +162,53 @@ export interface AdrFinding {
|
|
|
150
162
|
file: string;
|
|
151
163
|
level: 'error' | 'warn';
|
|
152
164
|
message: string;
|
|
165
|
+
/**
|
|
166
|
+
* Optional finding class, set only by the supersession-link check.
|
|
167
|
+
*
|
|
168
|
+
* It exists because `main()`'s summary line labels its warn count
|
|
169
|
+
* "grandfathered without a revisit condition" — accurate while that was the
|
|
170
|
+
* only warn class in the file, and a lie the moment a second one lands. A
|
|
171
|
+
* new warn class must not silently inflate a count that names a different
|
|
172
|
+
* cause, and a warn nobody prints is a warn nobody acts on, so the tagged
|
|
173
|
+
* ones are counted and printed separately. Absent = the pre-existing
|
|
174
|
+
* grandfathering class; nothing else reads this field.
|
|
175
|
+
*/
|
|
176
|
+
kind?: 'supersession_link' | 'supersession_qualifier';
|
|
153
177
|
}
|
|
154
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Read the leading `---` block.
|
|
181
|
+
*
|
|
182
|
+
* Delegates to the shared reader (`_lib/adr_frontmatter.ts`) rather than
|
|
183
|
+
* carrying a fourth copy: the nested `provenance` / `evidence` axes cannot be
|
|
184
|
+
* read by the scalar-only parser this function used to be, and a corpus
|
|
185
|
+
* equivalence test holds the shared reader to this one's exact scalar output
|
|
186
|
+
* for every ADR in the tree. Re-exported because the test suite imports it.
|
|
187
|
+
*/
|
|
155
188
|
export function parse_frontmatter(text: string): Record<string, string> | null {
|
|
156
|
-
|
|
157
|
-
const end = text.indexOf('\n---\n', 4);
|
|
158
|
-
if (end === -1) return null;
|
|
159
|
-
const out: Record<string, string> = {};
|
|
160
|
-
let key: string | null = null;
|
|
161
|
-
for (const raw of text.slice(4, end).split('\n')) {
|
|
162
|
-
const line = raw.replace(/\s+$/, '');
|
|
163
|
-
if (!line || line.trimStart().startsWith('#')) continue;
|
|
164
|
-
// Continuation of a folded/indented value.
|
|
165
|
-
if (/^\s/.test(line) && key) {
|
|
166
|
-
out[key] = `${out[key]} ${line.trim()}`.trim();
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
const idx = line.indexOf(':');
|
|
170
|
-
if (idx === -1) continue;
|
|
171
|
-
key = line.slice(0, idx).trim();
|
|
172
|
-
out[key] = line
|
|
173
|
-
.slice(idx + 1)
|
|
174
|
-
.trim()
|
|
175
|
-
.replace(/^["'](.*)["']$/, '$1');
|
|
176
|
-
}
|
|
177
|
-
return out;
|
|
189
|
+
return readAdrFrontmatterScalars(text);
|
|
178
190
|
}
|
|
179
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The transitional migration value, legal on an existing accepted record only.
|
|
194
|
+
*
|
|
195
|
+
* It has to be named here as well as in `check_descriptive_axes`, and the reason
|
|
196
|
+
* is a contract-versus-validator contradiction this change nearly shipped:
|
|
197
|
+
* `adr-layout` blesses `review_trigger: unclassified` for an existing record,
|
|
198
|
+
* while `trigger_is_meaningful` rejects anything under 20 characters and
|
|
199
|
+
* `unclassified` is 12. The contradiction was latent — no flat ADR carries the
|
|
200
|
+
* value yet and this gate scans `docs/decisions/` only — and would have fired
|
|
201
|
+
* on the first backfill onto a post-`REVIEW_TRIGGER_SINCE` record, i.e. exactly
|
|
202
|
+
* when someone followed the contract. A contract and its validator disagreeing
|
|
203
|
+
* is worse than either rule alone, because the tree then teaches the opposite
|
|
204
|
+
* of what the document says.
|
|
205
|
+
*/
|
|
206
|
+
const TRANSITIONAL_TRIGGER = 'unclassified';
|
|
207
|
+
|
|
180
208
|
/** A trigger must name a condition. A bare cadence is the failure mode. */
|
|
181
209
|
export function trigger_is_meaningful(value: string): boolean {
|
|
182
210
|
const v = value.trim();
|
|
211
|
+
if (v.toLowerCase() === TRANSITIONAL_TRIGGER) return true;
|
|
183
212
|
if (v.length < 20) return false;
|
|
184
213
|
// "annually" / "every 6 months" / a bare date is a calendar, not a condition.
|
|
185
214
|
if (/^(annually|yearly|quarterly|monthly|every\s+\d+\s+\w+|\d{4}-\d{2}-\d{2})\.?$/i.test(v)) {
|
|
@@ -229,15 +258,268 @@ export function check_one(rel: string, text: string): AdrFinding[] {
|
|
|
229
258
|
}
|
|
230
259
|
check_reopen_authority(rel, fm, findings);
|
|
231
260
|
check_amendment_shape(rel, fm, findings);
|
|
261
|
+
const parsed = readAdrFrontmatter(text);
|
|
262
|
+
if (parsed !== null) check_descriptive_axes(rel, parsed, findings);
|
|
263
|
+
return findings;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The permanence vocabulary, checked on the `decision` slug and on the value of
|
|
268
|
+
* any frontmatter key.
|
|
269
|
+
*
|
|
270
|
+
* ADR-208 is why this is a frontmatter check and not only a prose one: its
|
|
271
|
+
* `decision:` slug is literally `dist-agent-src-keep-forever`, so the
|
|
272
|
+
* permanence claim is in the metadata a tool reads, not only in the body a
|
|
273
|
+
* human reads.
|
|
274
|
+
*/
|
|
275
|
+
const PERMANENCE_RE = /\b(forever|permanently|permanent|never revisit|never reconsider|settled forever)\b/i;
|
|
276
|
+
|
|
277
|
+
/** `terminal`, `none` and their kin are permanence wearing a field name. */
|
|
278
|
+
const INVALID_TRIGGER_VALUES = new Set(['terminal', 'none', 'n/a', 'na', '-', '—', 'never']);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Validate `provenance`, `evidence`, `authority_basis` and the trigger's
|
|
282
|
+
* transitional vocabulary.
|
|
283
|
+
*
|
|
284
|
+
* **This function validates the SHAPE of an axis that is present. It does not
|
|
285
|
+
* require the axes at all**, and an earlier version of this docstring said "a
|
|
286
|
+
* NEW record must carry the axes", which nothing here enforces. Completion
|
|
287
|
+
* review disproved it directly: a fresh record with no `provenance`, no
|
|
288
|
+
* `evidence` and no `## Evidence` section passed every gate in this tree.
|
|
289
|
+
*
|
|
290
|
+
* The gap is structural rather than an omission, and it is why it was not closed
|
|
291
|
+
* HERE. Requiring the axes needs a notion of NEW, and a single-file linter has
|
|
292
|
+
* none — it sees a `date:` the author typed, not whether the record is new to the
|
|
293
|
+
* repository. `date:` is the proxy used for the `review_trigger` staging below,
|
|
294
|
+
* and it is a weak one: a backdated record slips through it.
|
|
295
|
+
*
|
|
296
|
+
* **The gap is now closed, in `check_new_adr_evidence.ts`** — the two-ref diff
|
|
297
|
+
* this paragraph used to call "buildable and not built" (review finding 4). It
|
|
298
|
+
* enumerates records ADDED against a base ref and requires all three of
|
|
299
|
+
* `provenance.kind`, `evidence.strength` and an `## Evidence` section of any
|
|
300
|
+
* added record whose status is `accepted`. Nothing moved out of this file: the
|
|
301
|
+
* split is one axis, one owner — that gate decides WHETHER a new record must
|
|
302
|
+
* disclose, this function decides whether what it wrote is well-formed, and the
|
|
303
|
+
* pre-existing corpus stays out of scope on both sides.
|
|
304
|
+
*
|
|
305
|
+
* The `review_trigger` staging IS enforced, on that same date proxy. 88 of the
|
|
306
|
+
* 147 accepted records carry no trigger, so a same-day hard requirement would
|
|
307
|
+
* have made the tree invalid on the day this landed and forced the schema and
|
|
308
|
+
* the backfill into one unreviewable change. What is rejected at every stage is
|
|
309
|
+
* a value asserting permanence — `terminal` is not a migration state, it is the
|
|
310
|
+
* thing the staging exists to stop becoming permanent.
|
|
311
|
+
*
|
|
312
|
+
* Everything here is shape validation. Nothing in this function grants an
|
|
313
|
+
* agent authority over anything, and `authority_basis` is checked precisely so
|
|
314
|
+
* it cannot be used to route around the owner.
|
|
315
|
+
*/
|
|
316
|
+
export function check_descriptive_axes(
|
|
317
|
+
rel: string,
|
|
318
|
+
fm: AdrFrontmatter,
|
|
319
|
+
findings: AdrFinding[],
|
|
320
|
+
): AdrFinding[] {
|
|
321
|
+
const status = fm.scalars['status'] ?? '';
|
|
322
|
+
const historical = status === 'superseded' || status === 'rejected' || status === 'deprecated';
|
|
323
|
+
|
|
324
|
+
// --- review_trigger vocabulary: invalid values are invalid at every stage.
|
|
325
|
+
const trigger = (fm.scalars['review_trigger'] ?? '').trim();
|
|
326
|
+
if (trigger !== '' && !historical) {
|
|
327
|
+
// The staged half. `unclassified` is the migration value for a record
|
|
328
|
+
// that already existed; a record authored now has no migration to be
|
|
329
|
+
// in, and letting it through would turn a transitional state into the
|
|
330
|
+
// permanent default — which is the failure the staging exists to avoid.
|
|
331
|
+
// "Existing" is read from the same `REVIEW_TRIGGER_SINCE` date this
|
|
332
|
+
// file already uses for grandfathering, so there is one notion of
|
|
333
|
+
// old-versus-new rather than two that can drift apart.
|
|
334
|
+
if (trigger.toLowerCase() === TRANSITIONAL_TRIGGER) {
|
|
335
|
+
const recordDate = fm.scalars['date'] ?? '';
|
|
336
|
+
const preexisting = recordDate !== '' && recordDate < REVIEW_TRIGGER_SINCE;
|
|
337
|
+
if (!preexisting) {
|
|
338
|
+
findings.push({
|
|
339
|
+
file: rel,
|
|
340
|
+
level: 'error',
|
|
341
|
+
message: `\`review_trigger: unclassified\` is the migration value for a record that already existed (dated before ${REVIEW_TRIGGER_SINCE}). This record is dated ${recordDate || 'undated'} — name the condition that would reopen it.`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (INVALID_TRIGGER_VALUES.has(trigger.toLowerCase())) {
|
|
346
|
+
findings.push({
|
|
347
|
+
file: rel,
|
|
348
|
+
level: 'error',
|
|
349
|
+
message: `\`review_trigger: ${trigger}\` is permanence under a field name. Every accepted decision has a conceivable reopening condition — write a narrow one, or \`unclassified\` while the migration runs.`,
|
|
350
|
+
});
|
|
351
|
+
} else if (trigger.toLowerCase() !== 'unclassified' && PERMANENCE_RE.test(trigger)) {
|
|
352
|
+
findings.push({
|
|
353
|
+
file: rel,
|
|
354
|
+
level: 'error',
|
|
355
|
+
message: `\`review_trigger\` asserts permanence ("${trigger.slice(0, 60)}…"). A trigger names the condition under which the decision stops holding.`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// --- provenance
|
|
361
|
+
const provenance = provenanceOf(fm);
|
|
362
|
+
// Key-presence is read from BOTH maps, not from `scalars` alone. The shared
|
|
363
|
+
// reader routes an inline list (`provenance: [human]`) into `nested`, so a
|
|
364
|
+
// scalars-only guard left the whole malformed-list class silently accepted:
|
|
365
|
+
// `provenanceOf` returns null via its `Array.isArray` branch, no branch
|
|
366
|
+
// fired, and every downstream consumer then read the axis as ABSENT. Found
|
|
367
|
+
// in completion review with a direct probe — `provenance: [human]` +
|
|
368
|
+
// `evidence: [E9]` on an otherwise valid accepted record produced zero
|
|
369
|
+
// findings. The list shape is a plausible slip precisely because
|
|
370
|
+
// `protected_dimensions: [purpose]` in the same block is a list.
|
|
371
|
+
if (axisPresent(fm, 'provenance') && provenance === null) {
|
|
372
|
+
findings.push({
|
|
373
|
+
file: rel,
|
|
374
|
+
level: 'error',
|
|
375
|
+
message: '`provenance` must be a map with a `kind:` key — a scalar or a list is not a map',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (provenance !== null) {
|
|
379
|
+
if (provenance.kind === null) {
|
|
380
|
+
findings.push({ file: rel, level: 'error', message: '`provenance` is present but carries no `kind`' });
|
|
381
|
+
} else if (!(PROVENANCE_KINDS as readonly string[]).includes(provenance.kind)) {
|
|
382
|
+
findings.push({
|
|
383
|
+
file: rel,
|
|
384
|
+
level: 'error',
|
|
385
|
+
message: `provenance.kind \`${provenance.kind}\` is not one of: ${PROVENANCE_KINDS.join(', ')}. A council is \`agentic\` with \`agentic_mode: council\` — it is not its own kind.`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (
|
|
389
|
+
provenance.agenticMode !== null &&
|
|
390
|
+
!(AGENTIC_MODES as readonly string[]).includes(provenance.agenticMode)
|
|
391
|
+
) {
|
|
392
|
+
findings.push({
|
|
393
|
+
file: rel,
|
|
394
|
+
level: 'error',
|
|
395
|
+
message: `provenance.agentic_mode \`${provenance.agenticMode}\` is not one of: ${AGENTIC_MODES.join(', ')}`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- evidence
|
|
401
|
+
const evidence = evidenceOf(fm);
|
|
402
|
+
if (axisPresent(fm, 'evidence') && evidence === null) {
|
|
403
|
+
findings.push({
|
|
404
|
+
file: rel,
|
|
405
|
+
level: 'error',
|
|
406
|
+
message: '`evidence` must be a map with a `strength:` key — a scalar or a list is not a map',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (evidence !== null) {
|
|
410
|
+
if (evidence.strength === null) {
|
|
411
|
+
findings.push({ file: rel, level: 'error', message: '`evidence` is present but carries no `strength`' });
|
|
412
|
+
} else if (!(EVIDENCE_STRENGTHS as readonly string[]).includes(evidence.strength)) {
|
|
413
|
+
findings.push({
|
|
414
|
+
file: rel,
|
|
415
|
+
level: 'error',
|
|
416
|
+
message: `evidence.strength \`${evidence.strength}\` is not one of: ${EVIDENCE_STRENGTHS.join(', ')}`,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
// `discovery` is required on E0 and only on E0. A bare E0 cannot
|
|
420
|
+
// distinguish "no evidence exists" from "nobody looked", and the
|
|
421
|
+
// second reading is the cheap way to manufacture a reopenable lock.
|
|
422
|
+
if (evidence.strength === 'E0' && evidence.discovery === null) {
|
|
423
|
+
findings.push({
|
|
424
|
+
file: rel,
|
|
425
|
+
level: 'error',
|
|
426
|
+
message: '`evidence.strength: E0` requires `discovery: complete | incomplete` — an unsearched absence is not an established one',
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (
|
|
430
|
+
evidence.discovery !== null &&
|
|
431
|
+
!(DISCOVERY_STATES as readonly string[]).includes(evidence.discovery)
|
|
432
|
+
) {
|
|
433
|
+
findings.push({
|
|
434
|
+
file: rel,
|
|
435
|
+
level: 'error',
|
|
436
|
+
message: `evidence.discovery \`${evidence.discovery}\` is not one of: ${DISCOVERY_STATES.join(', ')}`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
// A grade above E1 asserts a source. Saying so without naming one is
|
|
440
|
+
// the evidence-theater failure the contract names.
|
|
441
|
+
if (
|
|
442
|
+
evidence.strength !== null &&
|
|
443
|
+
['E2', 'E3', 'E4'].includes(evidence.strength) &&
|
|
444
|
+
evidence.basis.length === 0
|
|
445
|
+
) {
|
|
446
|
+
findings.push({
|
|
447
|
+
file: rel,
|
|
448
|
+
level: 'error',
|
|
449
|
+
message: `evidence.strength \`${evidence.strength}\` cites no \`basis\`. A grade above E1 asserts a source; name it or grade it lower.`,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- authority_basis
|
|
455
|
+
const basis = fm.scalars['authority_basis'];
|
|
456
|
+
if (basis !== undefined && !(AUTHORITY_BASES as readonly string[]).includes(basis)) {
|
|
457
|
+
findings.push({
|
|
458
|
+
file: rel,
|
|
459
|
+
level: 'error',
|
|
460
|
+
message: `authority_basis \`${basis}\` is not one of: ${AUTHORITY_BASES.join(', ')}`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
// `owner_intent` is the honest form for a purpose decision, and it pairs
|
|
464
|
+
// with E0 by design — the authority comes from owning the purpose, not
|
|
465
|
+
// from pretending the preference is empirical.
|
|
466
|
+
if (basis === 'owner_intent' && evidence !== null && evidence.strength !== null) {
|
|
467
|
+
if (['E2', 'E3', 'E4'].includes(evidence.strength) && evidence.basis.length === 0) {
|
|
468
|
+
findings.push({
|
|
469
|
+
file: rel,
|
|
470
|
+
level: 'error',
|
|
471
|
+
message: 'an `owner_intent` record claims an empirical grade with no basis — record it as `E0` + `owner_intent` instead of dressing intent as measurement',
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
232
476
|
return findings;
|
|
233
477
|
}
|
|
234
478
|
|
|
235
479
|
/** `ADR-035`, `035`, `35` → `35`. Returns null when nothing resolves. */
|
|
480
|
+
/**
|
|
481
|
+
* Is a descriptive axis PRESENT in the frontmatter, in any shape?
|
|
482
|
+
*
|
|
483
|
+
* The typed accessors (`provenanceOf` / `evidenceOf`) return null for three
|
|
484
|
+
* different situations — absent, scalar, list — and only the first is legal.
|
|
485
|
+
* Distinguishing them needs the raw key, and the raw key can live in either
|
|
486
|
+
* map: the reader keeps a scalar in `scalars` and BOTH a map and an inline
|
|
487
|
+
* list in `nested`. Asking one map is how the malformed-list class escaped.
|
|
488
|
+
*/
|
|
489
|
+
export function axisPresent(fm: AdrFrontmatter, key: 'provenance' | 'evidence'): boolean {
|
|
490
|
+
return fm.scalars[key] !== undefined || fm.nested[key] !== undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
236
493
|
export function adr_number(raw: string): string | null {
|
|
237
494
|
const m = /(\d{1,4})/.exec(raw.trim());
|
|
238
495
|
return m?.[1] === undefined ? null : String(Number(m[1]));
|
|
239
496
|
}
|
|
240
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Index a corpus by ADR number for the reciprocal-link checks — FLAT records
|
|
500
|
+
* only.
|
|
501
|
+
*
|
|
502
|
+
* A bare `ADR-NNN` reference denotes a flat record: that is the only surface
|
|
503
|
+
* whose numbers are globally unique. Per-area numbers are unique only within
|
|
504
|
+
* their area (`docs/adrs/<area>/0001`), so admitting them here would make one
|
|
505
|
+
* Map key mean several files — six per-area records carry `adr: 0001` today.
|
|
506
|
+
* Cross-surface links are therefore reported as unresolvable rather than
|
|
507
|
+
* resolved to an arbitrary sibling; a per-area record still gets every
|
|
508
|
+
* single-file check, and a real cross-surface reference needs a qualified
|
|
509
|
+
* reference form that `parse_adr_refs` does not yet have.
|
|
510
|
+
*/
|
|
511
|
+
function indexByNumber(
|
|
512
|
+
files: { rel: string; fm: Record<string, string>; perArea?: boolean }[],
|
|
513
|
+
): Map<string, { rel: string; fm: Record<string, string> }> {
|
|
514
|
+
const byNumber = new Map<string, { rel: string; fm: Record<string, string> }>();
|
|
515
|
+
for (const f of files) {
|
|
516
|
+
if (f.perArea === true) continue;
|
|
517
|
+
const n = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
|
|
518
|
+
if (n !== null) byNumber.set(n, f);
|
|
519
|
+
}
|
|
520
|
+
return byNumber;
|
|
521
|
+
}
|
|
522
|
+
|
|
241
523
|
/**
|
|
242
524
|
* Validate `amends:` / `amended_by:` shape. The reciprocal half is checked in
|
|
243
525
|
* `check_amendment_links` (it needs the whole corpus); this one only rejects a
|
|
@@ -266,20 +548,20 @@ export function check_amendment_shape(
|
|
|
266
548
|
|
|
267
549
|
/**
|
|
268
550
|
* Corpus-level check: every `amends:` has its reciprocal `amended_by:` and vice
|
|
269
|
-
* versa.
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
551
|
+
* versa. Its absence for the `supersedes:` / `superseded_by:` pair is exactly
|
|
552
|
+
* how ADR-035 kept asserting a rejection ADR-232 had reopened. A one-sided link
|
|
553
|
+
* is invisible from the side that is stale, which is the side a reader lands on
|
|
554
|
+
* first.
|
|
555
|
+
*
|
|
556
|
+
* That pair now has the same half, in `check_supersession_links` below — this
|
|
557
|
+
* docstring used to say it "never had" one, which was true when it was written
|
|
558
|
+
* and is the gap it documented rather than closed.
|
|
273
559
|
*/
|
|
274
560
|
export function check_amendment_links(
|
|
275
|
-
files: { rel: string; fm: Record<string, string
|
|
561
|
+
files: { rel: string; fm: Record<string, string>; perArea?: boolean }[],
|
|
276
562
|
): AdrFinding[] {
|
|
277
563
|
const findings: AdrFinding[] = [];
|
|
278
|
-
const byNumber =
|
|
279
|
-
for (const f of files) {
|
|
280
|
-
const n = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
|
|
281
|
-
if (n !== null) byNumber.set(n, f);
|
|
282
|
-
}
|
|
564
|
+
const byNumber = indexByNumber(files);
|
|
283
565
|
|
|
284
566
|
const reciprocal = { amends: 'amended_by', amended_by: 'amends' } as const;
|
|
285
567
|
for (const f of files) {
|
|
@@ -322,6 +604,176 @@ export function check_amendment_links(
|
|
|
322
604
|
return findings;
|
|
323
605
|
}
|
|
324
606
|
|
|
607
|
+
/** The none-tokens a link field uses to say "no link". `—` is the corpus form. */
|
|
608
|
+
const LINK_PLACEHOLDERS = new Set(['—', '–', '-', 'none', 'n/a']);
|
|
609
|
+
|
|
610
|
+
/** One parsed member of a `supersedes:` / `superseded_by:` list. */
|
|
611
|
+
export interface AdrRef {
|
|
612
|
+
/** The segment as written, trimmed — what an error message should quote. */
|
|
613
|
+
raw: string;
|
|
614
|
+
/** The resolved ADR number, or null when the segment names none. */
|
|
615
|
+
number: string | null;
|
|
616
|
+
/** Free text found inside `(…)`, or null. A partial-supersession marker. */
|
|
617
|
+
qualifier: string | null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Parse an ADR link field into its members. List-aware and qualifier-aware,
|
|
622
|
+
* because both shapes are already in the corpus and a first-number parser reads
|
|
623
|
+
* them wrong in opposite directions.
|
|
624
|
+
*
|
|
625
|
+
* ADR-206 carries sixteen targets in one `supersedes:` field, so anything that
|
|
626
|
+
* takes the first number and stops reports the other fifteen back-links as
|
|
627
|
+
* one-sided — 15 of the 19 findings a naive parser produces on this corpus are
|
|
628
|
+
* that artifact, not a defect in the tree. ADR-124 carries
|
|
629
|
+
* `ADR-088 (engine-adoption interpretation only), ADR-094 (…)`, so the split
|
|
630
|
+
* has to respect parentheses (a qualifier may itself contain a comma) and the
|
|
631
|
+
* number has to be read with the qualifier removed rather than from the
|
|
632
|
+
* concatenated text — `ADR-030 (Decision 2 — …)` is one reference with a note,
|
|
633
|
+
* not two.
|
|
634
|
+
*
|
|
635
|
+
* `check_amendment_shape` / `check_amendment_links` keep their naive
|
|
636
|
+
* `split(',')`: no `amends:` value in the corpus carries a comma or a list, so
|
|
637
|
+
* rewriting them here would be an unrequested behaviour change to code under
|
|
638
|
+
* test. The shape is identical if that ever stops being true.
|
|
639
|
+
*/
|
|
640
|
+
export function parse_adr_refs(raw: string | undefined): AdrRef[] {
|
|
641
|
+
if (raw === undefined) return [];
|
|
642
|
+
const whole = raw.trim();
|
|
643
|
+
if (whole === '' || LINK_PLACEHOLDERS.has(whole.toLowerCase())) return [];
|
|
644
|
+
|
|
645
|
+
// Split on top-level commas only — a comma inside `(…)` belongs to the note.
|
|
646
|
+
const segments: string[] = [];
|
|
647
|
+
let depth = 0;
|
|
648
|
+
let cur = '';
|
|
649
|
+
for (const ch of whole) {
|
|
650
|
+
if (ch === '(') depth += 1;
|
|
651
|
+
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
652
|
+
else if (ch === ',' && depth === 0) {
|
|
653
|
+
segments.push(cur);
|
|
654
|
+
cur = '';
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
cur += ch;
|
|
658
|
+
}
|
|
659
|
+
segments.push(cur);
|
|
660
|
+
|
|
661
|
+
const refs: AdrRef[] = [];
|
|
662
|
+
for (const segment of segments) {
|
|
663
|
+
const seg = segment.trim();
|
|
664
|
+
if (seg === '' || LINK_PLACEHOLDERS.has(seg.toLowerCase())) continue;
|
|
665
|
+
const qm = /\(([^)]*)\)/.exec(seg);
|
|
666
|
+
// Read the number from the reference only. A qualifier can contain
|
|
667
|
+
// digits ("Decision 2"), so stripping first is what keeps the match
|
|
668
|
+
// order-independent instead of relying on the note trailing the ref.
|
|
669
|
+
const bare = seg.replace(/\([^)]*\)/g, ' ');
|
|
670
|
+
refs.push({
|
|
671
|
+
raw: seg,
|
|
672
|
+
number: adr_number(bare),
|
|
673
|
+
qualifier: qm?.[1] === undefined ? null : qm[1].trim(),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return refs;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Corpus-level check: every `supersedes:` has its reciprocal `superseded_by:`
|
|
681
|
+
* and vice versa — the half this pair never had, which is what
|
|
682
|
+
* `check_amendment_links` was built for `amends:` / `amended_by:` and this one
|
|
683
|
+
* closes for the pair whose absence produced the ADR-035 case in the first
|
|
684
|
+
* place. A one-sided link is invisible from the stale side, and the stale side
|
|
685
|
+
* is the one a reader lands on: ADR-030 reads as live while ADR-209 has
|
|
686
|
+
* superseded part of it.
|
|
687
|
+
*
|
|
688
|
+
* Levels are split by whether a human has to decide anything.
|
|
689
|
+
*
|
|
690
|
+
* A dangling or unparseable reference is an **error**: it cannot be a content
|
|
691
|
+
* decision, only a typo, and it points at nothing a reader can follow. Zero
|
|
692
|
+
* occurrences today, so it reds nothing on landing and reds the right thing
|
|
693
|
+
* later.
|
|
694
|
+
*
|
|
695
|
+
* A one-sided link is a **warn**, deliberately, and this is the choice worth
|
|
696
|
+
* stating. There are 4 in the tree, and each one is a content decision rather
|
|
697
|
+
* than a typo: someone has to choose the direction. ADR-209 supersedes ADR-030
|
|
698
|
+
* only in its "Decision 2" carve-out, so marking ADR-030 wholly
|
|
699
|
+
* `superseded_by` would assert more than happened, while leaving it blank
|
|
700
|
+
* asserts less — the fix is a judgement about the record, not a field to fill
|
|
701
|
+
* in mechanically. Erroring would red the build on landing and force those four
|
|
702
|
+
* judgements into an unrelated change; warning surfaces them so the next change
|
|
703
|
+
* can decide each one and ratchet the count. The repo's baseline-ratchet
|
|
704
|
+
* mechanism is the usual home for exactly this, and belongs here once someone
|
|
705
|
+
* owns those four decisions.
|
|
706
|
+
*
|
|
707
|
+
* The free-text qualifier is its own warn class. `supersedes: ADR-088
|
|
708
|
+
* (engine-adoption interpretation only)` encodes a PARTIAL supersession in
|
|
709
|
+
* prose inside a field that nothing parses as partial: every consumer of the
|
|
710
|
+
* frontmatter reads it as total. That is a different problem from a broken
|
|
711
|
+
* link — the link is correct and reciprocal — so it is reported separately
|
|
712
|
+
* rather than folded into the same count.
|
|
713
|
+
*/
|
|
714
|
+
export function check_supersession_links(
|
|
715
|
+
files: { rel: string; fm: Record<string, string>; perArea?: boolean }[],
|
|
716
|
+
): AdrFinding[] {
|
|
717
|
+
const findings: AdrFinding[] = [];
|
|
718
|
+
const byNumber = indexByNumber(files);
|
|
719
|
+
|
|
720
|
+
const reciprocal = { supersedes: 'superseded_by', superseded_by: 'supersedes' } as const;
|
|
721
|
+
for (const f of files) {
|
|
722
|
+
const self = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
|
|
723
|
+
for (const key of ['supersedes', 'superseded_by'] as const) {
|
|
724
|
+
for (const ref of parse_adr_refs(f.fm[key])) {
|
|
725
|
+
if (ref.qualifier !== null) {
|
|
726
|
+
findings.push({
|
|
727
|
+
file: f.rel,
|
|
728
|
+
level: 'warn',
|
|
729
|
+
kind: 'supersession_qualifier',
|
|
730
|
+
message:
|
|
731
|
+
`\`${key}: ${ref.raw}\` records a PARTIAL supersession as free text. ` +
|
|
732
|
+
`Every reader of this field parses it as total — the scope lives only ` +
|
|
733
|
+
`in the prose.`,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
if (ref.number === null) {
|
|
737
|
+
findings.push({
|
|
738
|
+
file: f.rel,
|
|
739
|
+
level: 'error',
|
|
740
|
+
kind: 'supersession_link',
|
|
741
|
+
message: `\`${key}\` value \`${ref.raw}\` names no ADR number`,
|
|
742
|
+
});
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const other = byNumber.get(ref.number);
|
|
746
|
+
if (other === undefined) {
|
|
747
|
+
findings.push({
|
|
748
|
+
file: f.rel,
|
|
749
|
+
level: 'error',
|
|
750
|
+
kind: 'supersession_link',
|
|
751
|
+
message: `\`${key}: ${ref.raw}\` points at an ADR that does not exist`,
|
|
752
|
+
});
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
const names_self =
|
|
756
|
+
self !== null &&
|
|
757
|
+
parse_adr_refs(other.fm[reciprocal[key]])
|
|
758
|
+
.map((r) => r.number)
|
|
759
|
+
.includes(self);
|
|
760
|
+
if (!names_self) {
|
|
761
|
+
findings.push({
|
|
762
|
+
file: f.rel,
|
|
763
|
+
level: 'warn',
|
|
764
|
+
kind: 'supersession_link',
|
|
765
|
+
message:
|
|
766
|
+
`\`${key}: ${ref.raw}\` is one-sided — ${other.rel} carries no ` +
|
|
767
|
+
`reciprocal \`${reciprocal[key]}\`. A one-sided supersession link is ` +
|
|
768
|
+
`invisible from the stale side, which is the side a reader lands on.`,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return findings;
|
|
775
|
+
}
|
|
776
|
+
|
|
325
777
|
/**
|
|
326
778
|
* Check every ADR in `dir`, optionally recording per-file completeness.
|
|
327
779
|
*
|
|
@@ -334,16 +786,70 @@ export function check_amendment_links(
|
|
|
334
786
|
* A `warn`-level finding resolves as `complete`: warnings are allowed by this
|
|
335
787
|
* gate's exit-code contract, so `fail` is reserved for what actually reds it.
|
|
336
788
|
*/
|
|
789
|
+
/** A per-area record under `docs/adrs/<area>/NNNN-*.md`, excluding READMEs. */
|
|
790
|
+
export function listPerAreaRecords(root: string = REPO_ROOT): { abs: string; rel: string }[] {
|
|
791
|
+
const base = path.join(root, 'docs', 'adrs');
|
|
792
|
+
if (!fs.existsSync(base)) return [];
|
|
793
|
+
const out: { abs: string; rel: string }[] = [];
|
|
794
|
+
for (const area of fs.readdirSync(base).sort()) {
|
|
795
|
+
const dir = path.join(base, area);
|
|
796
|
+
if (!fs.statSync(dir).isDirectory()) continue;
|
|
797
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
798
|
+
if (!/^\d{4}-.*\.md$/.test(name)) continue;
|
|
799
|
+
const abs = path.join(dir, name);
|
|
800
|
+
out.push({ abs, rel: path.relative(root, abs) });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
337
806
|
export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[] {
|
|
338
807
|
const findings: AdrFinding[] = [];
|
|
339
808
|
if (!fs.existsSync(dir)) return findings;
|
|
340
809
|
|
|
810
|
+
// Flat records, plus the per-area records under `docs/adrs/<area>/`.
|
|
811
|
+
//
|
|
812
|
+
// The per-area half was MISSING and that was this gate's largest hole, found
|
|
813
|
+
// in completion review: three sibling tools in the same change read both
|
|
814
|
+
// roots (`adr_cite_check.ADR_DIRS`, `audit_adr_coverage`, `evidence_census`)
|
|
815
|
+
// and this one read only the flat tree — so the seven per-area records that
|
|
816
|
+
// had just been given frontmatter were validated by nothing. The consequence
|
|
817
|
+
// was already in the tree rather than hypothetical:
|
|
818
|
+
// `docs/adrs/telegraph/0002` carried `review_trigger: unclassified` on a
|
|
819
|
+
// post-cutoff record, which this file's own rule classifies as an error.
|
|
820
|
+
//
|
|
821
|
+
// A gate that does not read a surface cannot be cited as covering it, and
|
|
822
|
+
// step 1.5's stated rationale — "check_adr_frontmatter needs
|
|
823
|
+
// machine-readable fields" — was unrealised until this call site changed.
|
|
341
824
|
const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
825
|
+
// The per-area root is derived from `dir` so the whole check is hermetic.
|
|
826
|
+
// It used to call `listPerAreaRecords()` with no argument, which always read
|
|
827
|
+
// the REAL `REPO_ROOT/docs/adrs` — so a call with a temp dir silently mixed
|
|
828
|
+
// the live corpus into `findings`, into `corpus` (both reciprocal-link
|
|
829
|
+
// checks) and into the `assertScanned` denominator. That last one is the
|
|
830
|
+
// damaging half: with the live corpus always contributing, an emptied or
|
|
831
|
+
// moved `docs/decisions` could never trip `DeadScopeError`, which is the one
|
|
832
|
+
// thing that assertion exists for. Found in completion review; the `root`
|
|
833
|
+
// parameter existed and had no caller.
|
|
834
|
+
const perArea = listPerAreaRecords(path.resolve(dir, '..', '..'));
|
|
835
|
+
ledger?.plan([...candidates, ...perArea.map((r) => r.rel)]);
|
|
836
|
+
|
|
837
|
+
// Collected for the two corpus-level reciprocal-link checks below
|
|
838
|
+
// (amendment, supersession), neither of which can be decided from one file.
|
|
839
|
+
//
|
|
840
|
+
// `perArea: true` keeps per-area records out of the number index those
|
|
841
|
+
// checks build. Without it their `adr: 0001` collapses onto flat ADR-001 —
|
|
842
|
+
// and, worse, onto EACH OTHER: six of the seven per-area records carry
|
|
843
|
+
// `adr: 0001`, so a single Map keyed on the number kept exactly one of
|
|
844
|
+
// seven. Completion review found the flat half with a probe (`supersedes:
|
|
845
|
+
// ADR-001` blamed `docs/adrs/telegraph/0001` for a missing reciprocal —
|
|
846
|
+
// the wrong file); the six-way per-area collision is the same defect,
|
|
847
|
+
// wider, and it turned up only because the fix asked how many records
|
|
848
|
+
// shared a key. The false-negative direction is the dangerous one: a
|
|
849
|
+
// per-area record's reciprocal would mask a genuinely broken flat link.
|
|
850
|
+
// Latent today because nothing cites ADR-001/002, and introduced by folding
|
|
851
|
+
// per-area records into the shared corpus in this same change.
|
|
852
|
+
const corpus: { rel: string; fm: Record<string, string>; perArea?: boolean }[] = [];
|
|
347
853
|
|
|
348
854
|
for (const name of candidates) {
|
|
349
855
|
if (!/^ADR-.*\.md$/.test(name)) {
|
|
@@ -364,7 +870,23 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
|
|
|
364
870
|
ledger?.complete(name);
|
|
365
871
|
}
|
|
366
872
|
}
|
|
873
|
+
// Per-area records: same per-file checks, same corpus for the reciprocal
|
|
874
|
+
// link checks. A supersession crosses surfaces (adr-layout § Frontmatter),
|
|
875
|
+
// so keeping them out of `corpus` would make a cross-surface link look
|
|
876
|
+
// one-sided.
|
|
877
|
+
for (const rec of perArea) {
|
|
878
|
+
const text = fs.readFileSync(rec.abs, 'utf-8');
|
|
879
|
+
const parsed = parse_frontmatter(text);
|
|
880
|
+
if (parsed !== null) corpus.push({ rel: rec.rel, fm: parsed, perArea: true });
|
|
881
|
+
const own = check_one(rec.rel, text);
|
|
882
|
+
findings.push(...own);
|
|
883
|
+
const errs = own.filter((f) => f.level === 'error').length;
|
|
884
|
+
if (errs > 0) ledger?.fail(rec.rel, `${String(errs)} frontmatter error(s)`);
|
|
885
|
+
else ledger?.complete(rec.rel);
|
|
886
|
+
}
|
|
887
|
+
|
|
367
888
|
findings.push(...check_amendment_links(corpus));
|
|
889
|
+
findings.push(...check_supersession_links(corpus));
|
|
368
890
|
return findings;
|
|
369
891
|
}
|
|
370
892
|
|
|
@@ -390,7 +912,7 @@ function main(argv: string[]): number {
|
|
|
390
912
|
gate: 'check_adr_frontmatter',
|
|
391
913
|
scanned: total,
|
|
392
914
|
units: 'ADR file(s)',
|
|
393
|
-
roots: ['docs/decisions'],
|
|
915
|
+
roots: ['docs/decisions', 'docs/adrs'],
|
|
394
916
|
});
|
|
395
917
|
} catch (exc) {
|
|
396
918
|
if (exc instanceof DeadScopeError) {
|
|
@@ -409,13 +931,26 @@ function main(argv: string[]): number {
|
|
|
409
931
|
|
|
410
932
|
const errors = findings.filter((f) => f.level === 'error');
|
|
411
933
|
const warns = findings.filter((f) => f.level === 'warn');
|
|
934
|
+
// The untagged warns are the grandfathering class the summary label names;
|
|
935
|
+
// there are ~114 of them, so they stay a count and are not printed. The
|
|
936
|
+
// link classes are few and each needs a decision, so they are printed by
|
|
937
|
+
// name — folding them into the grandfathered count would both mislabel them
|
|
938
|
+
// and bury them.
|
|
939
|
+
const grandfathered = warns.filter((f) => f.kind === undefined);
|
|
940
|
+
const linkWarns = warns.filter((f) => f.kind === 'supersession_link');
|
|
941
|
+
const partialWarns = warns.filter((f) => f.kind === 'supersession_qualifier');
|
|
412
942
|
|
|
413
943
|
process.stdout.write(
|
|
414
944
|
`ADR frontmatter: ${total} ADR(s) · ${errors.length} error(s) · ` +
|
|
415
|
-
`${
|
|
945
|
+
`${grandfathered.length} grandfathered without a revisit condition · ` +
|
|
946
|
+
`${linkWarns.length} one-sided supersession link(s) · ` +
|
|
947
|
+
`${partialWarns.length} free-text partial supersession(s)\n`,
|
|
416
948
|
);
|
|
417
949
|
ledger.report();
|
|
418
950
|
for (const e of errors) process.stderr.write(` ❌ ${e.file}: ${e.message}\n`);
|
|
951
|
+
for (const w of [...linkWarns, ...partialWarns]) {
|
|
952
|
+
process.stdout.write(` ⚠️ ${w.file}: ${w.message}\n`);
|
|
953
|
+
}
|
|
419
954
|
|
|
420
955
|
if (errors.length > 0) {
|
|
421
956
|
process.stderr.write(
|