@event4u/agent-config 14.7.0 → 14.9.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 +175 -390
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +34 -4
- 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 +383 -0
- package/dist/agent-src/commands/review/changes.md +95 -14
- 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/host-capability-manifest.md +77 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
- 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/rules/untrusted-input-defense.md +9 -3
- 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/code-review/evals/evals.json +87 -46
- 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/humanizer/SKILL.md +39 -0
- package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
- package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
- package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
- 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 +28 -2
- package/dist/cli/registry.js +3 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
- package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
- package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
- 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-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +101 -25
- 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 +4 -4
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- 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 +83 -30
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -9
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +9 -7
- package/dist/hooks/dispatch.js +1295 -812
- 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 +1358 -805
- package/dist/scripts/_lib/claude_plugin.js +162 -0
- package/dist/scripts/_lib/claude_plugin.js.map +1 -0
- package/dist/scripts/_lib/environment_detector.js +397 -0
- package/dist/scripts/_lib/environment_detector.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/_lib/spawn_env.js +129 -0
- package/dist/scripts/_lib/spawn_env.js.map +1 -0
- package/dist/scripts/ai_council/modes.js +199 -0
- package/dist/scripts/ai_council/modes.js.map +1 -0
- package/dist/scripts/ai_council/transport_resolver.js +418 -0
- package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
- package/dist/scripts/condense.js +52 -89
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/wizard.js +3 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/ui/assets/index-Cxgy63kz.js +26 -0
- package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
- package/dist/ui/index.html +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/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
- package/package.json +7 -3
- 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 +13 -471
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +458 -12
- package/src/config/gate-violation-baselines.json +18 -8
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/publish-surface.json +60 -0
- package/src/config/requirements-trace-budget.json +32 -0
- package/src/config/rule-stub-ceilings.json +25 -4
- 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 +18 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/base_tree.ts +175 -0
- package/src/scripts/_lib/billing_grant.ts +165 -0
- package/src/scripts/_lib/billing_grant_cli.ts +123 -0
- package/src/scripts/_lib/council_fallback_posture.ts +53 -6
- package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
- package/src/scripts/_lib/envelope_grounding.ts +11 -1
- package/src/scripts/_lib/host_capability.ts +59 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/_lib/quota_parked.ts +133 -0
- package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
- package/src/scripts/_lib/review_telemetry.ts +196 -0
- package/src/scripts/_lib/subagent_response.ts +92 -0
- package/src/scripts/adr/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +25 -38
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/ai_council/clients.ts +12 -1
- package/src/scripts/ai_council/config.ts +72 -115
- package/src/scripts/ai_council/fallback_config.ts +19 -7
- package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
- package/src/scripts/ai_council/orchestrator.ts +2 -2
- package/src/scripts/ai_council/py_format.ts +124 -0
- package/src/scripts/ai_council/transport_resolver.ts +65 -2
- package/src/scripts/audit_adr_coverage.ts +134 -21
- 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_council_pin_staleness.ts +300 -0
- package/src/scripts/check_estate_count.ts +420 -293
- package/src/scripts/check_gate_coverage.ts +91 -0
- package/src/scripts/check_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_no_stub_inventory_table.ts +292 -0
- package/src/scripts/check_pack_size.ts +234 -1
- package/src/scripts/check_publish_surface.ts +297 -0
- package/src/scripts/check_references.ts +18 -0
- package/src/scripts/check_requirements_trace.ts +378 -0
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_secret_leak.ts +166 -12
- package/src/scripts/check_single_delivery.ts +137 -4
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/code_graph/cli.ts +44 -5
- package/src/scripts/code_graph/extract.ts +87 -0
- package/src/scripts/condense.ts +61 -91
- package/src/scripts/council_cli.ts +12 -16
- package/src/scripts/detect_ai_tells.ts +87 -0
- package/src/scripts/dispatch_r2_reviewer.ts +42 -0
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/generate_capabilities_index.ts +41 -0
- package/src/scripts/git_authorization_hook.ts +54 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
- 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/run_continuation_hook.ts +3 -1
- package/src/scripts/hooks/state_io.ts +88 -1
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
- package/src/scripts/injection_scan_hook.ts +257 -15
- package/src/scripts/lint_adr_sweep_routing.ts +365 -0
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/lint_workflow_security.ts +53 -10
- package/src/scripts/lint_workflow_security_allowlist.json +2 -18
- package/src/scripts/report_envelope_rate.ts +166 -0
- package/src/scripts/review_axis_report.ts +218 -0
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/run_supervise.ts +46 -1
- package/src/scripts/schemas/evals.schema.json +5 -0
- package/src/scripts/sync_pr_branch.ts +170 -20
- package/dist/cli/commands/commands.candidates.test.js +0 -257
- package/dist/cli/commands/commands.candidates.test.js.map +0 -1
- package/dist/cli/commands/profiles.test.js +0 -88
- package/dist/cli/commands/profiles.test.js.map +0 -1
- package/dist/cli/commands/recordTriggerEval.test.js +0 -113
- package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
- package/dist/cli/configRoot.test.js +0 -97
- package/dist/cli/configRoot.test.js.map +0 -1
- package/dist/cli/mcp/content.test.js +0 -54
- package/dist/cli/mcp/content.test.js.map +0 -1
- package/dist/cli/mcp/dispatch.test.js +0 -108
- package/dist/cli/mcp/dispatch.test.js.map +0 -1
- package/dist/server/writeRoot.test.js +0 -91
- package/dist/server/writeRoot.test.js.map +0 -1
- package/dist/shared/capabilities.test.js +0 -39
- package/dist/shared/capabilities.test.js.map +0 -1
- package/dist/ui/assets/index-C1pBRm1f.js +0 -26
- package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
- 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-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
|
@@ -135,6 +135,7 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
|
|
|
135
135
|
return new Set();
|
|
136
136
|
}
|
|
137
137
|
const names = new Set<string>();
|
|
138
|
+
const nudgeRanks = new Map<number, string[]>();
|
|
138
139
|
for (const [name, spec] of Object.entries(concernsRaw)) {
|
|
139
140
|
if (!isObject(spec)) {
|
|
140
141
|
errors.push(
|
|
@@ -213,8 +214,56 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
}
|
|
217
|
+
// `nudge_rank:` — the nudge-exclusivity ordering `injection_budget`
|
|
218
|
+
// arbitrates on. Validated here because `injection_budget._selectNudge`
|
|
219
|
+
// says so in as many words: "a tie is a manifest defect rather than a
|
|
220
|
+
// runtime one, and `lint_hook_manifest` is where it should eventually be
|
|
221
|
+
// caught — that check does not exist yet and this comment is the honest
|
|
222
|
+
// statement of the gap". This is that check.
|
|
223
|
+
//
|
|
224
|
+
// A tie is not a crash. `_selectNudge` keeps the lowest rank and breaks
|
|
225
|
+
// ties on concern name, so a collision resolves stably and arbitrarily:
|
|
226
|
+
// one nudge silently wins forever, and the author who added the second one
|
|
227
|
+
// gets no signal that their concern can never emit. That is the failure
|
|
228
|
+
// mode — silent, not loud.
|
|
229
|
+
if ("nudge_rank" in spec) {
|
|
230
|
+
const rank = spec["nudge_rank"];
|
|
231
|
+
if (typeof rank !== "number" || !Number.isInteger(rank) || rank < 1) {
|
|
232
|
+
errors.push(
|
|
233
|
+
`concerns.${name}: 'nudge_rank' must be a positive integer ` +
|
|
234
|
+
`(got ${pyTypeName(rank)}) — the dispatcher keeps the LOWEST rank, ` +
|
|
235
|
+
`so a malformed value reads as "never wins" rather than as an error`,
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
const seen = nudgeRanks.get(rank);
|
|
239
|
+
if (seen) {
|
|
240
|
+
seen.push(name);
|
|
241
|
+
} else {
|
|
242
|
+
nudgeRanks.set(rank, [name]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
216
246
|
names.add(name);
|
|
217
247
|
}
|
|
248
|
+
// Uniqueness is enforced GLOBALLY, not per event, and that is a deliberate
|
|
249
|
+
// choice rather than an accident of what is easy to compute. `_selectNudge`
|
|
250
|
+
// compares candidates within one dispatch, so two concerns bound to disjoint
|
|
251
|
+
// events could share a rank harmlessly and the accurate invariant is
|
|
252
|
+
// per-event. Global uniqueness is the strictly-safe superset: it costs
|
|
253
|
+
// nothing at today's two declared ranks, and an author reusing a rank across
|
|
254
|
+
// events is exactly the confusion worth refusing. If a legitimate design
|
|
255
|
+
// ever needs the same rank on two events, NARROW this to concerns sharing at
|
|
256
|
+
// least one bound event — do not delete it.
|
|
257
|
+
for (const [rank, holders] of nudgeRanks) {
|
|
258
|
+
if (holders.length > 1) {
|
|
259
|
+
errors.push(
|
|
260
|
+
`concerns: nudge_rank ${rank} is declared by ${holders.length} concerns ` +
|
|
261
|
+
`(${holders.slice().sort().join(", ")}) — ranks must be unique, because ` +
|
|
262
|
+
`the dispatcher breaks a tie on concern name and the loser can then ` +
|
|
263
|
+
`never emit`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
218
267
|
return names;
|
|
219
268
|
}
|
|
220
269
|
|
|
@@ -26,18 +26,42 @@
|
|
|
26
26
|
* 3. Number cross-check — every N/D figure inside a scope box MUST also
|
|
27
27
|
* appear, verbatim, somewhere in `docs/CLAIMS.md` — a box number that
|
|
28
28
|
* drifts from the ledger is a build failure, not a doc nit.
|
|
29
|
+
* 4. Permanence language — an ADR record MUST NOT assert that its decision
|
|
30
|
+
* is permanent in a load-bearing position (its `# ` title, its
|
|
31
|
+
* `decision:` slug, its `## Decision` section, an `Addendum`). ADR-239
|
|
32
|
+
* § 6 is the doctrine and ADR-208 the standing demonstration: its title
|
|
33
|
+
* says the tree is kept forever, its Decision says KEEP permanently, and
|
|
34
|
+
* its own frontmatter carries the conditions under which it is reopened.
|
|
35
|
+
* A record cannot be both. Two escapes, both enforced here: permanence
|
|
36
|
+
* scoped to an EXTERNAL invariant with the condition under which that
|
|
37
|
+
* invariant stops applying stated in the same section, or an owner
|
|
38
|
+
* purpose statement recorded as `authority_basis: owner_intent`.
|
|
39
|
+
* Describing positions — `## Alternatives considered`, `## Consequences`,
|
|
40
|
+
* `## Context`, an `## Amendment`, a quoted open question, a table
|
|
41
|
+
* rationale cell — are NOT violations.
|
|
29
42
|
*
|
|
30
43
|
* Scan surfaces: README.md + every `docs/**\/*.md` file (recursive). No
|
|
31
44
|
* marketing/install-output text file carries this vocabulary today — this is
|
|
32
|
-
* the single place to widen SCAN_ROOTS when one does.
|
|
45
|
+
* the single place to widen SCAN_ROOTS when one does. Rule 4 adds no root: it
|
|
46
|
+
* filters the already-walked `docs/**` set down to ADR records
|
|
47
|
+
* ({@link isAdrRecord}) rather than widening the walk, which is why it lives
|
|
48
|
+
* here instead of in a new gate script (a new gate costs seven wiring
|
|
49
|
+
* surfaces for the same coverage).
|
|
33
50
|
*
|
|
34
|
-
*
|
|
51
|
+
* Two verdicts. Rules 1-3 hard-fail. Rule 4 is a RATCHET against
|
|
52
|
+
* `lint_provenance_vocabulary:permanence-language` in
|
|
53
|
+
* `src/config/gate-violation-baselines.json`, because the corpus carries real
|
|
54
|
+
* violations today and a gate that lands as five instant blockers is a shape
|
|
55
|
+
* this repository has already refused. A new record still hard-fails.
|
|
56
|
+
*
|
|
57
|
+
* Exit codes: 0 clean · 1 violations found (either verdict).
|
|
35
58
|
*/
|
|
36
59
|
|
|
37
60
|
import * as fs from 'node:fs';
|
|
38
61
|
import * as path from 'node:path';
|
|
39
62
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
40
63
|
|
|
64
|
+
import { checkRatchet } from './_lib/gate_baseline.js';
|
|
41
65
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
42
66
|
|
|
43
67
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -362,6 +386,387 @@ export function lintFile(text: string, file: string, claimsText: string): Violat
|
|
|
362
386
|
];
|
|
363
387
|
}
|
|
364
388
|
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// 4. Permanence language in ADR records
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* ADR record roots.
|
|
395
|
+
*
|
|
396
|
+
* Both already sit under `docs`, which {@link SCAN_ROOTS} walks — these roots
|
|
397
|
+
* do NOT widen the walk, they IDENTIFY which of the already-read files is an
|
|
398
|
+
* ADR record. Every other `docs/**` file is untouched by this rule.
|
|
399
|
+
*/
|
|
400
|
+
export const ADR_RECORD_ROOTS: readonly string[] = ['docs/decisions', 'docs/adrs'];
|
|
401
|
+
|
|
402
|
+
/** Ratchet key in `src/config/gate-violation-baselines.json`. */
|
|
403
|
+
export const PERMANENCE_GATE = 'lint_provenance_vocabulary:permanence-language';
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* True for a flat `docs/decisions/ADR-*.md` record or a per-area
|
|
407
|
+
* `docs/adrs/<area>/<n>-*.md` record. A per-area `README.md` is an index, not
|
|
408
|
+
* a record, and is excluded on both surfaces — so are the non-ADR documents
|
|
409
|
+
* that share `docs/decisions/` (sweep artifacts, reclassification logs).
|
|
410
|
+
*/
|
|
411
|
+
export function isAdrRecord(rel: string): boolean {
|
|
412
|
+
const posix = rel.split(path.sep).join('/');
|
|
413
|
+
const base = posix.slice(posix.lastIndexOf('/') + 1);
|
|
414
|
+
if (!base.endsWith('.md') || base === 'README.md') return false;
|
|
415
|
+
if (posix.startsWith('docs/decisions/')) return /^ADR-/.test(base);
|
|
416
|
+
if (posix.startsWith('docs/adrs/')) return /^\d/.test(base);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The four positions where a permanence claim is LOAD-BEARING — the record is
|
|
422
|
+
* asserting permanence rather than describing, historicising, or rejecting it.
|
|
423
|
+
*
|
|
424
|
+
* - `title` — the `# ` heading; it is the record's name in every citation.
|
|
425
|
+
* - `slug` — the `decision:` frontmatter value; machine-visible, so a
|
|
426
|
+
* permanence claim there is carried by every tool that reads the field.
|
|
427
|
+
* - `decision` — the `## Decision` section's own prose.
|
|
428
|
+
* - `addendum` — an `Addendum` heading at any level. Both corpus instances
|
|
429
|
+
* (ADR-122, ADR-124) assert a permanently-off default, which is the same
|
|
430
|
+
* claim shape as ADR-208's Decision, so they are in scope. `Amendment` is
|
|
431
|
+
* deliberately NOT: an amendment revises or historicises an earlier claim.
|
|
432
|
+
*
|
|
433
|
+
* Everything else is out of scope by construction: `## Alternatives
|
|
434
|
+
* considered` rejects a permanence option, `## Consequences` reports one,
|
|
435
|
+
* `## Context` recounts one, and an `## Open question` quotes one. Scoping is
|
|
436
|
+
* the whole gate — the same word list applied to whole files matches 41 lines
|
|
437
|
+
* across 30 records, and the large majority of them are legitimate.
|
|
438
|
+
*/
|
|
439
|
+
export type PermanenceRegion = 'title' | 'slug' | 'decision' | 'addendum';
|
|
440
|
+
|
|
441
|
+
interface Region {
|
|
442
|
+
kind: PermanenceRegion;
|
|
443
|
+
/** First line index of the region (0-based, inclusive). */
|
|
444
|
+
start: number;
|
|
445
|
+
/** Last line index of the region (0-based, inclusive). */
|
|
446
|
+
end: number;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const FM_DELIM_RE = /^---[ \t]*$/;
|
|
450
|
+
const ANY_HEADING_RE = /^#{1,6}[ \t]+/;
|
|
451
|
+
const H1_RE = /^#[ \t]+/;
|
|
452
|
+
const DECISION_KEY_RE = /^decision:/;
|
|
453
|
+
// `## Decision`, `## Decisions`, `## Decision — GO`, `## Decision (Reading B)`,
|
|
454
|
+
// `### Decision`. Deliberately NOT `### Decision matrix` / `### Decision owner`
|
|
455
|
+
// / `### The owner's decision`: the word has to BE the heading, not open it.
|
|
456
|
+
const DECISION_HEADING_RE = /^#{2,6}[ \t]+Decisions?[ \t]*(?:[—–(:\-]|$)/i;
|
|
457
|
+
const ADDENDUM_HEADING_RE = /^#{2,6}[ \t]+Addendum\b/i;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Escape 2 — an owner purpose statement, keyed on the SCHEMA field.
|
|
461
|
+
*
|
|
462
|
+
* This originally matched a bare `owner_intent: currently binding` key, which
|
|
463
|
+
* the roadmap's own draft prose named and which no schema declares anywhere in
|
|
464
|
+
* the tree (`grep -rn owner_intent docs/ src/` found it only in that prose). It
|
|
465
|
+
* was usable — nothing rejects an unknown frontmatter key — and that is exactly
|
|
466
|
+
* what made it wrong: a lint reading a field the validator does not know is a
|
|
467
|
+
* second vocabulary for one concept, and the tree would have taught two.
|
|
468
|
+
*
|
|
469
|
+
* `authority_basis: owner_intent` is the declared field
|
|
470
|
+
* (`adr-layout § Provenance and evidence`, validated by
|
|
471
|
+
* `check_adr_frontmatter.check_descriptive_axes`), and it already carries the
|
|
472
|
+
* temporal meaning the phrase was reaching for: the burden table reads it as
|
|
473
|
+
* "binding until the owner changes it". So one field, already validated, rather
|
|
474
|
+
* than a phrase that only this lint could see.
|
|
475
|
+
*/
|
|
476
|
+
const OWNER_INTENT_ESCAPE_RE = /^[ \t]*authority_basis:[ \t]*owner_intent[ \t]*$/m;
|
|
477
|
+
|
|
478
|
+
/** The literal an owner purpose statement writes instead of "forever". */
|
|
479
|
+
export const OWNER_INTENT_ESCAPE = 'authority_basis: owner_intent';
|
|
480
|
+
|
|
481
|
+
/** The YAML frontmatter block, or '' when the record carries none. */
|
|
482
|
+
function _frontmatterText(lines: readonly string[]): string {
|
|
483
|
+
if (lines.length === 0 || !FM_DELIM_RE.test(lines[0] ?? '')) return '';
|
|
484
|
+
for (let i = 1; i < lines.length; i++) {
|
|
485
|
+
if (FM_DELIM_RE.test(lines[i] ?? '')) return lines.slice(1, i).join('\n');
|
|
486
|
+
}
|
|
487
|
+
return '';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Single permanence words. `permanent` is matched only in a **predicative**
|
|
492
|
+
* position (see {@link PROSE_WORD_RES}) because attributively it names a
|
|
493
|
+
* thing, not the decision: "two permanent regression fixtures" (ADR-126) and
|
|
494
|
+
* "permanent migration stubs" (ADR-057) are objects that happen to persist.
|
|
495
|
+
*/
|
|
496
|
+
export const PERMANENCE_WORDS: readonly string[] = ['forever', 'permanently', 'permanent'];
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Multi-word equivalents. The first three are named by ADR-239 § 6;
|
|
500
|
+
* `now or in the future` is the corpus instance that made the list necessary
|
|
501
|
+
* (ADR-108's Decision statement says it instead of "forever"). `in perpetuity`
|
|
502
|
+
* and `for all time` have zero corpus hits today and are listed because they
|
|
503
|
+
* are the standard idioms for the same claim.
|
|
504
|
+
*/
|
|
505
|
+
export const PERMANENCE_PHRASES: readonly string[] = [
|
|
506
|
+
'never revisit',
|
|
507
|
+
'never reconsider',
|
|
508
|
+
'settled forever',
|
|
509
|
+
'now or in the future',
|
|
510
|
+
'in perpetuity',
|
|
511
|
+
'for all time',
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
/** Prose matchers. `forever` / `permanently` reject a trailing hyphen so a
|
|
515
|
+
* compound modifier ("forever-incomplete", "permanently-parked") describing
|
|
516
|
+
* something else is not read as a permanence claim about the decision. */
|
|
517
|
+
const PROSE_WORD_RES: readonly RegExp[] = [
|
|
518
|
+
/(?<![A-Za-z0-9])forever(?![A-Za-z0-9-])/gi,
|
|
519
|
+
/(?<![A-Za-z0-9])permanently(?![A-Za-z0-9-])/gi,
|
|
520
|
+
/(?<![A-Za-z0-9])permanent(?=[ \t]*(?:[.,;:!?)*\]]|$))/gi,
|
|
521
|
+
];
|
|
522
|
+
|
|
523
|
+
const PHRASE_RES: readonly RegExp[] = PERMANENCE_PHRASES.map(
|
|
524
|
+
(p) =>
|
|
525
|
+
new RegExp(
|
|
526
|
+
`(?<![A-Za-z0-9])${p.split(' ').map(_escapeRegExp).join('[ \\t]+')}(?![A-Za-z0-9])`,
|
|
527
|
+
'gi',
|
|
528
|
+
),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
/** A hyphen-joined slug carries its words without spaces, so the slug region
|
|
532
|
+
* matches on hyphen boundaries too (`dist-agent-src-keep-forever`). */
|
|
533
|
+
const SLUG_RE = /(?<![A-Za-z0-9])(?:forever|permanent(?:ly)?)(?![A-Za-z0-9])/gi;
|
|
534
|
+
|
|
535
|
+
const TABLE_ROW_RE = /^[ \t]*\|/;
|
|
536
|
+
const LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]/;
|
|
537
|
+
// A sentence break, tolerating a bold/paren/backtick close after the stop.
|
|
538
|
+
const SENTENCE_SPLIT_RE = /(?<=[.!?][)*"'\]`]{0,3})[ \t]+(?=[A-Z*`[("'])/g;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Negation and conditional markers that make a permanence word DESCRIPTIVE.
|
|
542
|
+
*
|
|
543
|
+
* Deliberately short. A wider list was tried and rejected against the corpus:
|
|
544
|
+
* `would` kills ADR-107's live claim ("commercial ship **would** require …"
|
|
545
|
+
* sits two sentences from "the suite is open-source forever"), and a bare `no`
|
|
546
|
+
* kills ADR-108's ("There is **no** commercial tier … now or in the future").
|
|
547
|
+
* Both are real violations, so a marker that silences them is worse than the
|
|
548
|
+
* noise it removes.
|
|
549
|
+
*/
|
|
550
|
+
const NEGATION_RE =
|
|
551
|
+
/\b(?:cannot|can't|could not|couldn't|does not|doesn't|do not|don't|did not|is not|isn't|are not|aren't|was not|were not|no longer|rather than|instead of|not a|not an|not the|neither)\b/i;
|
|
552
|
+
const CONDITIONAL_RE = /(?:^|[\s(*_"'`])(?:if|unless)\b/i;
|
|
553
|
+
|
|
554
|
+
/** Escape 1, half one: the permanence is scoped to an EXTERNAL invariant. */
|
|
555
|
+
const EXTERNAL_INVARIANT_RE =
|
|
556
|
+
/\b(?:protocol|api compatibility|wire format|statutory|regulatory|legal obligation|licen[sc]e|licensing|contractual|upstream|external constraint|standards? body|rfc[ \t]*\d+)\b/i;
|
|
557
|
+
/** Escape 1, half two: the condition under which that invariant stops. */
|
|
558
|
+
const STOP_CONDITION_RE =
|
|
559
|
+
/\b(?:stops? applying|no longer applies|ceases? to apply|until|reopen(?:s|ed)?[ \t]+when|revisit(?:ed)?[ \t]+(?:if|when)|is[ \t]+retired|is[ \t]+withdrawn|expires?)\b/i;
|
|
560
|
+
|
|
561
|
+
const PERMANENCE_CONSEQUENCE_BOUND =
|
|
562
|
+
'a record cannot both assert permanence and carry the conditions under which it ' +
|
|
563
|
+
'is reopened (ADR-239 § 6; ADR-208 is the standing demonstration). Two escapes: ' +
|
|
564
|
+
'scope the permanence to an external invariant AND state in the same section when ' +
|
|
565
|
+
`that invariant stops applying, or record an owner purpose statement as \`${OWNER_INTENT_ESCAPE}\``;
|
|
566
|
+
|
|
567
|
+
/** Split a record into its load-bearing regions. */
|
|
568
|
+
export function permanenceRegions(lines: readonly string[]): Region[] {
|
|
569
|
+
const regions: Region[] = [];
|
|
570
|
+
let bodyStart = 0;
|
|
571
|
+
|
|
572
|
+
if (lines.length > 0 && FM_DELIM_RE.test(lines[0] ?? '')) {
|
|
573
|
+
let close = -1;
|
|
574
|
+
for (let i = 1; i < lines.length; i++) {
|
|
575
|
+
if (FM_DELIM_RE.test(lines[i] ?? '')) {
|
|
576
|
+
close = i;
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (close > 0) {
|
|
581
|
+
bodyStart = close + 1;
|
|
582
|
+
for (let i = 1; i < close; i++) {
|
|
583
|
+
if (!DECISION_KEY_RE.test(lines[i] ?? '')) continue;
|
|
584
|
+
// A folded value (`decision: >-`) continues on indented lines.
|
|
585
|
+
let end = i;
|
|
586
|
+
for (let j = i + 1; j < close; j++) {
|
|
587
|
+
if (!/^[ \t]+\S/.test(lines[j] ?? '')) break;
|
|
588
|
+
end = j;
|
|
589
|
+
}
|
|
590
|
+
regions.push({ kind: 'slug', start: i, end });
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let open: Region | null = null;
|
|
597
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
598
|
+
const line = lines[i] ?? '';
|
|
599
|
+
if (!ANY_HEADING_RE.test(line)) continue;
|
|
600
|
+
if (open !== null) {
|
|
601
|
+
open.end = i - 1;
|
|
602
|
+
regions.push(open);
|
|
603
|
+
open = null;
|
|
604
|
+
}
|
|
605
|
+
if (H1_RE.test(line)) {
|
|
606
|
+
regions.push({ kind: 'title', start: i, end: i });
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (DECISION_HEADING_RE.test(line)) {
|
|
610
|
+
open = { kind: 'decision', start: i, end: lines.length - 1 };
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (ADDENDUM_HEADING_RE.test(line)) {
|
|
614
|
+
open = { kind: 'addendum', start: i, end: lines.length - 1 };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (open !== null) regions.push(open);
|
|
618
|
+
return regions;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The sentence containing the hit.
|
|
623
|
+
*
|
|
624
|
+
* Hard-wrapped prose puts a sentence's start on a previous line (ADR-055's
|
|
625
|
+
* "If Step 8 slips," is one line above its "permanent"), so the paragraph is
|
|
626
|
+
* rejoined first. The walk stops at a list-item start in both directions: the
|
|
627
|
+
* corpus writes numbered Decision clauses one item per line, and merging them
|
|
628
|
+
* imports a neighbour's markers into this item's verdict.
|
|
629
|
+
*/
|
|
630
|
+
function _sentenceAround(
|
|
631
|
+
lines: readonly string[],
|
|
632
|
+
region: Region,
|
|
633
|
+
lineIdx: number,
|
|
634
|
+
col: number,
|
|
635
|
+
): string {
|
|
636
|
+
let top = lineIdx;
|
|
637
|
+
while (
|
|
638
|
+
top > region.start &&
|
|
639
|
+
!LIST_ITEM_RE.test(lines[top] ?? '') &&
|
|
640
|
+
(lines[top - 1] ?? '').trim() !== '' &&
|
|
641
|
+
!ANY_HEADING_RE.test(lines[top - 1] ?? '') &&
|
|
642
|
+
!TABLE_ROW_RE.test(lines[top - 1] ?? '')
|
|
643
|
+
) {
|
|
644
|
+
top -= 1;
|
|
645
|
+
}
|
|
646
|
+
let bottom = lineIdx;
|
|
647
|
+
while (
|
|
648
|
+
bottom < region.end &&
|
|
649
|
+
(lines[bottom + 1] ?? '').trim() !== '' &&
|
|
650
|
+
!ANY_HEADING_RE.test(lines[bottom + 1] ?? '') &&
|
|
651
|
+
!LIST_ITEM_RE.test(lines[bottom + 1] ?? '') &&
|
|
652
|
+
!TABLE_ROW_RE.test(lines[bottom + 1] ?? '')
|
|
653
|
+
) {
|
|
654
|
+
bottom += 1;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
let joined = '';
|
|
658
|
+
let hitOffset = 0;
|
|
659
|
+
for (let i = top; i <= bottom; i++) {
|
|
660
|
+
const raw = lines[i] ?? '';
|
|
661
|
+
const sep = joined === '' ? '' : ' ';
|
|
662
|
+
if (i === lineIdx) {
|
|
663
|
+
const lead = raw.length - raw.trimStart().length;
|
|
664
|
+
hitOffset = joined.length + sep.length + Math.max(0, col - lead);
|
|
665
|
+
}
|
|
666
|
+
joined += sep + raw.trim();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const parts: Array<{ text: string; start: number }> = [];
|
|
670
|
+
let cursor = 0;
|
|
671
|
+
let m: RegExpExecArray | null;
|
|
672
|
+
SENTENCE_SPLIT_RE.lastIndex = 0;
|
|
673
|
+
while ((m = SENTENCE_SPLIT_RE.exec(joined)) !== null) {
|
|
674
|
+
parts.push({ text: joined.slice(cursor, m.index), start: cursor });
|
|
675
|
+
cursor = m.index + m[0].length;
|
|
676
|
+
}
|
|
677
|
+
parts.push({ text: joined.slice(cursor), start: cursor });
|
|
678
|
+
for (const p of parts) {
|
|
679
|
+
if (hitOffset >= p.start && hitOffset < p.start + p.text.length) return p.text;
|
|
680
|
+
}
|
|
681
|
+
return joined;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
interface RawHit {
|
|
685
|
+
term: string;
|
|
686
|
+
index: number;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function _hits(line: string, res: readonly RegExp[]): RawHit[] {
|
|
690
|
+
const out: RawHit[] = [];
|
|
691
|
+
for (const re of res) {
|
|
692
|
+
re.lastIndex = 0;
|
|
693
|
+
let m: RegExpExecArray | null;
|
|
694
|
+
while ((m = re.exec(line)) !== null) {
|
|
695
|
+
out.push({ term: m[0], index: m.index });
|
|
696
|
+
if (m.index === re.lastIndex) re.lastIndex += 1;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Rule 4: permanence language in an ADR record's load-bearing positions.
|
|
704
|
+
*
|
|
705
|
+
* Returns [] for a file {@link isAdrRecord} rejects, so a caller may hand it
|
|
706
|
+
* any prose file.
|
|
707
|
+
*/
|
|
708
|
+
export function permanenceViolations(text: string, file: string): Violation[] {
|
|
709
|
+
if (!isAdrRecord(file)) return [];
|
|
710
|
+
|
|
711
|
+
const rawLines = text.split('\n');
|
|
712
|
+
// Fence-stripped, index-preserving: a `## Decision` inside a code fence is
|
|
713
|
+
// an example, not a section, and must not open a region.
|
|
714
|
+
const prose = _stripFences(rawLines);
|
|
715
|
+
const regions = permanenceRegions(prose);
|
|
716
|
+
const ownerIntentEscape = OWNER_INTENT_ESCAPE_RE.test(_frontmatterText(rawLines));
|
|
717
|
+
|
|
718
|
+
const violations: Violation[] = [];
|
|
719
|
+
for (const region of regions) {
|
|
720
|
+
// The slug is machine-visible in every citation and hyphen-joined, so
|
|
721
|
+
// it takes the unconditional match and neither escape: a field cannot
|
|
722
|
+
// qualify a name that tools print without reading the field.
|
|
723
|
+
if (region.kind === 'slug') {
|
|
724
|
+
for (let i = region.start; i <= region.end; i++) {
|
|
725
|
+
for (const hit of _hits(prose[i] ?? '', [SLUG_RE])) {
|
|
726
|
+
violations.push({
|
|
727
|
+
file,
|
|
728
|
+
rule: 'permanence-language',
|
|
729
|
+
msg:
|
|
730
|
+
`line ${i + 1}: permanence language "${hit.term}" in the \`decision:\` slug — ` +
|
|
731
|
+
`${PERMANENCE_CONSEQUENCE_BOUND} (the slug takes neither escape)`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const regionText = prose.slice(region.start, region.end + 1).join('\n');
|
|
739
|
+
const hasStopCondition = STOP_CONDITION_RE.test(regionText);
|
|
740
|
+
|
|
741
|
+
for (let i = region.start; i <= region.end; i++) {
|
|
742
|
+
const line = prose[i] ?? '';
|
|
743
|
+
const isProseRegion = region.kind === 'decision' || region.kind === 'addendum';
|
|
744
|
+
// A table cell is a per-row detail with its own rationale column,
|
|
745
|
+
// not the decision statement (ADR-074's "probing forever" is a
|
|
746
|
+
// failure mode being bounded, in the rationale column).
|
|
747
|
+
if (isProseRegion && TABLE_ROW_RE.test(line)) continue;
|
|
748
|
+
|
|
749
|
+
for (const hit of _hits(line, [...PROSE_WORD_RES, ...PHRASE_RES])) {
|
|
750
|
+
if (_isQuotedSpan(line, hit.index)) continue;
|
|
751
|
+
if (isProseRegion) {
|
|
752
|
+
if (ownerIntentEscape) continue;
|
|
753
|
+
const sentence = _sentenceAround(prose, region, i, hit.index);
|
|
754
|
+
if (NEGATION_RE.test(sentence) || CONDITIONAL_RE.test(sentence)) continue;
|
|
755
|
+
if (hasStopCondition && EXTERNAL_INVARIANT_RE.test(sentence)) continue;
|
|
756
|
+
}
|
|
757
|
+
violations.push({
|
|
758
|
+
file,
|
|
759
|
+
rule: 'permanence-language',
|
|
760
|
+
msg:
|
|
761
|
+
`line ${i + 1}: permanence language "${hit.term}" in the record's ${region.kind} — ` +
|
|
762
|
+
`${PERMANENCE_CONSEQUENCE_BOUND} (context: ${line.trim().slice(0, 90)})`,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return violations;
|
|
768
|
+
}
|
|
769
|
+
|
|
365
770
|
// ---------------------------------------------------------------------------
|
|
366
771
|
// Filesystem walk + CLI
|
|
367
772
|
// ---------------------------------------------------------------------------
|
|
@@ -404,6 +809,42 @@ export function lintProvenanceVocabulary(repoRoot: string = REPO_ROOT): Violatio
|
|
|
404
809
|
return violations;
|
|
405
810
|
}
|
|
406
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Rule 4 over the ADR corpus.
|
|
814
|
+
*
|
|
815
|
+
* Separate from {@link lintProvenanceVocabulary} because the two carry
|
|
816
|
+
* different verdicts: rules 1-3 hard-fail, rule 4 is ratcheted against
|
|
817
|
+
* `${PERMANENCE_GATE}` in `src/config/gate-violation-baselines.json` because
|
|
818
|
+
* the corpus contains real violations today (ADR-107, ADR-108, ADR-122,
|
|
819
|
+
* ADR-124, ADR-208) and a gate that lands as five instant blockers is a shape
|
|
820
|
+
* this repository has refused before. A NEW record still hard-fails: the
|
|
821
|
+
* ratchet only turns one way.
|
|
822
|
+
*/
|
|
823
|
+
export function lintAdrPermanence(repoRoot: string = REPO_ROOT): Violation[] {
|
|
824
|
+
const records: string[] = [];
|
|
825
|
+
for (const root of ADR_RECORD_ROOTS) {
|
|
826
|
+
for (const abs of _collectMd(path.join(repoRoot, root))) {
|
|
827
|
+
const rel = path.relative(repoRoot, abs);
|
|
828
|
+
if (isAdrRecord(rel)) records.push(abs);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// A `## Decision`-scoped rule that read zero ADR records would print a
|
|
832
|
+
// green line while enforcing nothing — the exact failure the shared
|
|
833
|
+
// scope-assert exists for.
|
|
834
|
+
assertScanned({
|
|
835
|
+
gate: PERMANENCE_GATE,
|
|
836
|
+
scanned: records.length,
|
|
837
|
+
units: 'ADR record(s)',
|
|
838
|
+
roots: ADR_RECORD_ROOTS,
|
|
839
|
+
});
|
|
840
|
+
const violations: Violation[] = [];
|
|
841
|
+
for (const abs of records.sort()) {
|
|
842
|
+
const rel = path.relative(repoRoot, abs);
|
|
843
|
+
violations.push(...permanenceViolations(fs.readFileSync(abs, 'utf-8'), rel));
|
|
844
|
+
}
|
|
845
|
+
return violations;
|
|
846
|
+
}
|
|
847
|
+
|
|
407
848
|
function _print(quiet: boolean, msg: string): void {
|
|
408
849
|
if (!quiet) process.stdout.write(`${msg}\n`);
|
|
409
850
|
}
|
|
@@ -411,8 +852,10 @@ function _print(quiet: boolean, msg: string): void {
|
|
|
411
852
|
export function main(): number {
|
|
412
853
|
const quiet = process.argv.slice(2).includes('--quiet');
|
|
413
854
|
let violations: Violation[];
|
|
855
|
+
let permanence: Violation[];
|
|
414
856
|
try {
|
|
415
857
|
violations = lintProvenanceVocabulary();
|
|
858
|
+
permanence = lintAdrPermanence();
|
|
416
859
|
} catch (e) {
|
|
417
860
|
// 1 is the only failure code this gate defines, so a dead scan root and
|
|
418
861
|
// a real violation share it — the message distinguishes them.
|
|
@@ -422,14 +865,39 @@ export function main(): number {
|
|
|
422
865
|
}
|
|
423
866
|
throw e;
|
|
424
867
|
}
|
|
868
|
+
|
|
869
|
+
let failed = false;
|
|
425
870
|
if (violations.length > 0) {
|
|
426
871
|
for (const v of violations) {
|
|
427
872
|
process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
|
|
428
873
|
}
|
|
429
874
|
process.stderr.write(`\n❌ provenance-vocabulary — ${violations.length} violation(s)\n`);
|
|
430
|
-
|
|
875
|
+
failed = true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Rule 4 reports every finding either way: at baseline they are the known
|
|
879
|
+
// debt this ratchet is walking down, and printing them is what makes the
|
|
880
|
+
// next lowering possible.
|
|
881
|
+
const verdict = checkRatchet({
|
|
882
|
+
gate: PERMANENCE_GATE,
|
|
883
|
+
actual: permanence.length,
|
|
884
|
+
repoRoot: REPO_ROOT,
|
|
885
|
+
});
|
|
886
|
+
if (!verdict.ok) {
|
|
887
|
+
for (const v of permanence) {
|
|
888
|
+
process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
|
|
889
|
+
}
|
|
890
|
+
process.stderr.write(`\n❌ ${verdict.message}\n`);
|
|
891
|
+
failed = true;
|
|
892
|
+
} else if (!quiet) {
|
|
893
|
+
for (const v of permanence) {
|
|
894
|
+
process.stdout.write(` [${v.rule}] ${v.file}: ${v.msg}\n`);
|
|
895
|
+
}
|
|
896
|
+
_print(quiet, `✅ ${verdict.message}`);
|
|
431
897
|
}
|
|
432
|
-
|
|
898
|
+
|
|
899
|
+
if (failed) return 1;
|
|
900
|
+
_print(quiet, '✅ provenance-vocabulary — no banned phrases, every approved-term use is co-located with a valid scope box, every box figure matches docs/CLAIMS.md, no new permanence language in an ADR record');
|
|
433
901
|
return 0;
|
|
434
902
|
}
|
|
435
903
|
|
|
@@ -17,8 +17,17 @@
|
|
|
17
17
|
* permissions: write-all;
|
|
18
18
|
* npm install / npm ci without --ignore-scripts in a
|
|
19
19
|
* pull_request_target workflow.
|
|
20
|
-
* MEDIUM —
|
|
21
|
-
*
|
|
20
|
+
* MEDIUM — actions pinned by mutable tag instead of full SHA;
|
|
21
|
+
* an actions/checkout step that keeps its credential.
|
|
22
|
+
*
|
|
23
|
+
* The MEDIUM tier's original wording read "third-party actions … (first-party
|
|
24
|
+
* actions/* are skipped)". Corrected 2026-08-22: the first-party exemption is
|
|
25
|
+
* GONE (see the removal note below), so the parenthetical documented an
|
|
26
|
+
* exemption that no longer exists — a false claim in a tracked artefact, which
|
|
27
|
+
* is the defect class the change that removed it was written to repair. The
|
|
28
|
+
* TIERS are untouched; only the description of what MEDIUM covers now matches
|
|
29
|
+
* the code. Re-tiering the locked model is a separate decision, recorded as
|
|
30
|
+
* unresolved in agents/evidence/analysis/workflow-security-net-degraded-decision.md.
|
|
22
31
|
*
|
|
23
32
|
* Script-injection detection (regex-based) is intentionally deferred — it
|
|
24
33
|
* requires an AST-aware pass to avoid false positives on quoted / escaped
|
|
@@ -53,8 +62,23 @@ function _setAllowlistPathForTest(p: string): void {
|
|
|
53
62
|
// Triggers that expose the repository token to untrusted pull-request context
|
|
54
63
|
const DANGEROUS_TRIGGERS: ReadonlySet<string> = new Set(['pull_request_target', 'workflow_run']);
|
|
55
64
|
|
|
56
|
-
//
|
|
57
|
-
|
|
65
|
+
// REMOVED 2026-08-22 (road-to-ci-supply-chain-integrity 1.4). This was
|
|
66
|
+
// `FIRST_PARTY_OWNERS = {'actions', 'github'}` and it exempted first-party
|
|
67
|
+
// owners from the mutable-tag rule. The premise — "mutable tags on these are
|
|
68
|
+
// acceptable" — is the one a supply-chain rule cannot hold: a first-party tag
|
|
69
|
+
// is still a mutable pointer, and `actions/checkout@v7` moving under the repo
|
|
70
|
+
// is the same class of event as any other owner's tag moving.
|
|
71
|
+
//
|
|
72
|
+
// What it cost, measured before removal: 100 of the 112 unpinned references in
|
|
73
|
+
// this tree were first-party, so the gate saw 12 of 112 defects and reported
|
|
74
|
+
// green on the rest. An exemption sized like that is not an exemption, it is
|
|
75
|
+
// the rule being off.
|
|
76
|
+
//
|
|
77
|
+
// The removal ships in the SAME change as the 112 pins, and in that order:
|
|
78
|
+
// pin first, then lift, so the transition is red-then-green inside one
|
|
79
|
+
// reviewable diff. Reverting the pins locally must make this gate exit
|
|
80
|
+
// non-zero — that is the assertion that the gate can still see the defect,
|
|
81
|
+
// which a green-only check cannot make.
|
|
58
82
|
|
|
59
83
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
60
84
|
type JsonObject = { [k: string]: JsonValue };
|
|
@@ -262,13 +286,33 @@ function _mutable_third_party_actions(doc: JsonObject, _workflow_name: string):
|
|
|
262
286
|
if (!uses || !uses.includes('@')) {
|
|
263
287
|
continue;
|
|
264
288
|
}
|
|
289
|
+
// `actions/checkout` leaves the repository token in the runner's git
|
|
290
|
+
// config for the rest of the job unless told not to. Measured before
|
|
291
|
+
// this rule existed: 0 of 50 checkouts in this tree set the flag, so
|
|
292
|
+
// every job carried a usable credential it did not need.
|
|
293
|
+
//
|
|
294
|
+
// `true` is a legitimate answer — two jobs here push — so the rule
|
|
295
|
+
// asks for the key to be PRESENT, not for a particular value. An
|
|
296
|
+
// explicit `true` is a reviewable decision; a missing key is an
|
|
297
|
+
// ambient one, and only the second is a finding.
|
|
298
|
+
if (uses.slice(0, uses.indexOf('@')).toLowerCase() === 'actions/checkout') {
|
|
299
|
+
const withObj = _asObject(stepObj['with'] as JsonValue);
|
|
300
|
+
const hasKey =
|
|
301
|
+
withObj !== null &&
|
|
302
|
+
Object.prototype.hasOwnProperty.call(withObj, 'persist-credentials');
|
|
303
|
+
if (!hasKey) {
|
|
304
|
+
findings.push({
|
|
305
|
+
severity: 'MEDIUM',
|
|
306
|
+
rule: 'persist-credentials',
|
|
307
|
+
detail:
|
|
308
|
+
'actions/checkout without an explicit persist-credentials — ' +
|
|
309
|
+
'the repository token stays in the runner git config for the rest of the job',
|
|
310
|
+
location: `job:${job_name}/step:${i + 1}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
265
314
|
const at = uses.indexOf('@');
|
|
266
|
-
const action_ref = uses.slice(0, at);
|
|
267
315
|
const pin = uses.slice(at + 1);
|
|
268
|
-
const owner = action_ref.includes('/') ? (action_ref.split('/')[0] as string) : action_ref;
|
|
269
|
-
if (FIRST_PARTY_OWNERS.has(owner.toLowerCase())) {
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
316
|
// A full SHA pin is 40 hex chars; anything else is mutable
|
|
273
317
|
if (pin.length === 40 && [...pin.toLowerCase()].every((c) => '0123456789abcdef'.includes(c))) {
|
|
274
318
|
continue;
|
|
@@ -650,7 +694,6 @@ export {
|
|
|
650
694
|
ALLOWLIST_PATH,
|
|
651
695
|
ALLOWLIST_CAP,
|
|
652
696
|
DANGEROUS_TRIGGERS,
|
|
653
|
-
FIRST_PARTY_OWNERS,
|
|
654
697
|
_setWorkflowsDirForTest,
|
|
655
698
|
_setAllowlistPathForTest,
|
|
656
699
|
load_allowlist,
|