pi-aia-asf 0.17.9 → 0.17.11
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/CHANGELOG.md +109 -0
- package/index.ts +6 -0
- package/lib/code-health/config.mjs +9 -4
- package/lib/code-health/index.mjs +11 -6
- package/lib/code-health/tools.d.mts +21 -0
- package/lib/decisions/consult.d.mts +46 -0
- package/lib/decisions/consult.mjs +184 -0
- package/lib/decisions/glob.d.mts +2 -0
- package/lib/decisions/glob.mjs +57 -0
- package/lib/decisions/parse.d.mts +11 -0
- package/lib/decisions/parse.mjs +138 -0
- package/lib/decisions/schema.d.mts +36 -0
- package/lib/decisions/schema.mjs +252 -0
- package/lib/decisions/store.d.mts +28 -0
- package/lib/decisions/store.mjs +195 -0
- package/lib/extension/browser-record.ts +70 -0
- package/lib/extension/commands/session.ts +5 -3
- package/lib/extension/gate-state.ts +8 -1
- package/lib/extension/handlers/observe-decisions.ts +78 -0
- package/lib/extension/handlers/observe-work.ts +111 -0
- package/lib/extension/handlers/observed.ts +8 -0
- package/lib/extension/handlers/settle.ts +35 -0
- package/lib/extension/handlers/tool-call.ts +47 -76
- package/lib/extension/handlers/tool-result.ts +17 -1
- package/lib/extension/paths.ts +18 -0
- package/lib/extension/run.ts +39 -1
- package/lib/extension/runtime.ts +24 -0
- package/lib/extension/state.ts +42 -18
- package/lib/extension/tools/browser.ts +65 -0
- package/lib/extension/tools/decisions.ts +251 -0
- package/lib/extension/tools/evidence-check.ts +134 -0
- package/lib/extension/types.ts +1 -1
- package/lib/gates/browser-evidence.d.mts +28 -0
- package/lib/gates/browser-evidence.mjs +101 -0
- package/lib/gates/config.d.mts +1 -1
- package/lib/gates/config.mjs +11 -4
- package/lib/gates/decisions.d.mts +36 -0
- package/lib/gates/decisions.mjs +75 -0
- package/lib/gates/evidence-coverage.d.mts +19 -0
- package/lib/gates/evidence-coverage.mjs +124 -0
- package/lib/gates/ledger.mjs +0 -4
- package/lib/gates/settle.d.mts +3 -2
- package/lib/gates/settle.mjs +8 -1
- package/lib/home.d.mts +18 -0
- package/lib/home.mjs +127 -0
- package/package.json +1 -1
- package/skills/aia-asf/references/06b-appendix.md +18 -0
- package/skills/aia-asf/references/06b-testing-qa.md +3 -1
- package/skills/aia-asf/references/06c-code-quality.md +5 -5
- package/skills/aia-asf/references/06e-code-health.md +31 -2
- package/skills/aia-asf/references/06i-completeness.md +6 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.11] - 2026-11-10
|
|
11
|
+
|
|
12
|
+
### Added — the coverage invariant is checkable from the installed package
|
|
13
|
+
|
|
14
|
+
`test/test-evidence-kinds.mjs` asserts that every evidence kind a gate requires has a
|
|
15
|
+
producer, but `test/` is not shipped — so a broken or half-updated install reproduced
|
|
16
|
+
the one bug that suite exists to catch, and the session inside it had no way to find
|
|
17
|
+
out. It could only be steered, at the same gap, forever. Three callers now ask the one
|
|
18
|
+
question, against one definition in `lib/gates/evidence-coverage.mjs`:
|
|
19
|
+
|
|
20
|
+
- `asf_evidence_check {kind?}` — the agent's own check. An unproducible **required**
|
|
21
|
+
kind is reported as a broken install rather than as work to keep attempting, because
|
|
22
|
+
no amount of effort records evidence nothing in the package can write.
|
|
23
|
+
- `scripts/verify-published.mjs` — a fourth release section, run against the copy npm
|
|
24
|
+
just installed. A tarball missing a producer now fails the release instead of
|
|
25
|
+
shipping the loop to every user while the repository stayed green. A section that
|
|
26
|
+
throws is a failed check, never a silent pass.
|
|
27
|
+
- `test/test-evidence-kinds.mjs` — asserts the answer, and asserts the shipped module
|
|
28
|
+
derives the same required set an independent derivation in the test does.
|
|
29
|
+
|
|
30
|
+
### Fixed — the coverage checker could not say "no"
|
|
31
|
+
|
|
32
|
+
Writing the negatives proved it: five mutations of the new module initially passed the
|
|
33
|
+
suite, because every assertion asked only whether a producer was **found**. A checker
|
|
34
|
+
whose regex matches everything, or that counts the ledger as the producer of its own
|
|
35
|
+
kinds, is invisible to a suite like that — and `requiredOrphans: []` was invisible too.
|
|
36
|
+
It is now tested against fixture packages where the answer is known to be no: a kind
|
|
37
|
+
nothing writes, and a kind only the ledger mentions. The tool's two paths no session
|
|
38
|
+
hits on a good day (broken install, check that threw) were untested for the same reason
|
|
39
|
+
and are now driven directly.
|
|
40
|
+
|
|
41
|
+
### Changed — the code-health config lives under `.asf/`
|
|
42
|
+
|
|
43
|
+
`.asf/code-health.json` is now read first, with the root `.asf-code-health.json` still
|
|
44
|
+
honoured so a project configured before the move does not wake up on defaults after an
|
|
45
|
+
upgrade. This was the last ASF-authored file still written to the project root, and
|
|
46
|
+
"everything ASF authors lives under `.asf/`" is one decision with one table of names:
|
|
47
|
+
the path now comes from `FILES` in `lib/home.mjs` rather than a literal in the reader,
|
|
48
|
+
which is how a file ends up written in one place and read from another.
|
|
49
|
+
|
|
50
|
+
### Fixed — the shipped CHANGELOG said nothing
|
|
51
|
+
|
|
52
|
+
The 0.17.10 section was the release script's untouched placeholder, twice over: an
|
|
53
|
+
empty `### Added` above a second heading for the same version. `CHANGELOG.md` ships in
|
|
54
|
+
the tarball, so that is what a user reads. `test/test-antipatterns.mjs` now asserts the
|
|
55
|
+
shipped version has a section, that it is not the placeholder, that it has real prose,
|
|
56
|
+
and that no version is listed twice — making "Vague commits or CHANGELOG placeholders"
|
|
57
|
+
an enforced anti-pattern rather than a listed one.
|
|
58
|
+
|
|
59
|
+
Also: `test/harness.mjs` passes a jiti loader to suites, since a `.mjs` suite cannot
|
|
60
|
+
import ASF's own `.ts` modules and each suite spinning up its own loader is how two
|
|
61
|
+
different loaders start disagreeing; `lib/code-health/tools.d.mts` added (an imported
|
|
62
|
+
`.mjs` used from `.ts` needs a declaration or typecheck fails).
|
|
63
|
+
|
|
64
|
+
## [0.17.10] - 2026-11-10
|
|
65
|
+
|
|
66
|
+
### Added — the browser evidence the completion gate demands can now be produced
|
|
67
|
+
|
|
68
|
+
The gate required browser verification whenever a UI file was touched (in two places:
|
|
69
|
+
the ledger's completeness check and the delivery report), but the only registered
|
|
70
|
+
tools were `asf_health` and `asf_decisions`. Any session that edited a tsx/html/css
|
|
71
|
+
file was steered at a gap it had no hands for — the health loop, one layer down. Two
|
|
72
|
+
agent-callable tools now close it, mirroring `asf_health`:
|
|
73
|
+
|
|
74
|
+
- `asf_browser_verify {page, action, assertion}` — **refused** unless the session
|
|
75
|
+
actually drove a browser. ASF sees every `tool_call`, so a verification with no
|
|
76
|
+
browser in the room is a claim about a page nobody opened; `browser_diagnose` and
|
|
77
|
+
`browser_logs` do not count, because they report on the daemon, not on the surface.
|
|
78
|
+
All three fields are required: that is the minimum a reviewer can go and repeat.
|
|
79
|
+
- `asf_browser_na {reason}` — **refused** when the reason is a placeholder (`n/a`,
|
|
80
|
+
`none`, `not applicable`, one word). A structured n/a is worth having only if a
|
|
81
|
+
reviewer can disagree with it; otherwise it is an exit around the gate.
|
|
82
|
+
|
|
83
|
+
Recorded, not claimed: a tool that accepted "I verified it" would make the gate
|
|
84
|
+
decorative, and a decorative gate reads as coverage. The rules live in
|
|
85
|
+
`lib/gates/browser-evidence.mjs` (pure, testable without a browser) with a thin
|
|
86
|
+
extension adapter, mirroring the `measure.mjs` / `health-record.ts` split.
|
|
87
|
+
|
|
88
|
+
### Fixed — five ledger kinds had no producer
|
|
89
|
+
|
|
90
|
+
`browser-verify`, `browser-na`, `clean-room`, `spec-transition` and `perf-scenario`
|
|
91
|
+
could be validated and rendered but never written. Now:
|
|
92
|
+
|
|
93
|
+
- `spec-transition` is **observed** from the real `update_spec_status` call, so the
|
|
94
|
+
delivery report renders actual transitions without anyone remembering to record them;
|
|
95
|
+
- `clean-room` is observed from the release pipeline (`npm run release` /
|
|
96
|
+
`verify-published.mjs`) together with its exit code — the definition-of-done line
|
|
97
|
+
about the shipped artifact no longer promises evidence nothing can produce;
|
|
98
|
+
- `perf-scenario` is **dropped**: no producer, no requirement, five mandatory fields
|
|
99
|
+
nobody could fill.
|
|
100
|
+
|
|
101
|
+
### Added — the runner can no longer read a suite as green when it never checked
|
|
102
|
+
|
|
103
|
+
`test/harness.mjs` honoured only `export async function run()` returning false. A
|
|
104
|
+
suite exposing its verdict as the module's default export was ignored entirely —
|
|
105
|
+
which is how a new suite reported "51 passed" while enforcing nothing. The runner now
|
|
106
|
+
reads `run()`, a boolean default, or a boolean `ok`; a suite claiming `selfReports`
|
|
107
|
+
must contain a real failure channel (`process.exit(1)` or `node:test`) or it warns;
|
|
108
|
+
and a suite with none of these is named instead of passing silently. The whole class
|
|
109
|
+
is pinned by `test/test-evidence-kinds.mjs`: every ledger kind must have a producer in
|
|
110
|
+
the shipped package, and no kind a gate *requires* may be an orphan.
|
|
111
|
+
|
|
112
|
+
Also: `tool-call.ts` passed the 300-line ceiling and its work observers moved to
|
|
113
|
+
`lib/extension/handlers/observe-work.ts`, with pi's `Input`/`Event` shapes declared
|
|
114
|
+
once in `observed.ts` instead of three times; `String(undefined)` → `?? ""` in the
|
|
115
|
+
evidence builders (the literal "undefined" reads like a value and passes a presence
|
|
116
|
+
check); and a redundant guard in `placeholderReason` was deleted because no mutation
|
|
117
|
+
of it failed a test — a guard that cannot be failed is decoration, not protection.
|
|
118
|
+
|
|
10
119
|
## [0.17.9] - 2026-11-10
|
|
11
120
|
|
|
12
121
|
### Fixed — the health verdict now reports what it measured, not what it planned
|
package/index.ts
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { setPiRef } from "./lib/extension/pi-ref.ts";
|
|
19
19
|
import { registerHealthTool } from "./lib/extension/tools/health.ts";
|
|
20
|
+
import { registerDecisionsTool } from "./lib/extension/tools/decisions.ts";
|
|
21
|
+
import { registerBrowserTools } from "./lib/extension/tools/browser.ts";
|
|
22
|
+
import { registerEvidenceCheckTool } from "./lib/extension/tools/evidence-check.ts";
|
|
20
23
|
import { registerCommands } from "./lib/extension/commands/index.ts";
|
|
21
24
|
import { registerToolCallHandler } from "./lib/extension/handlers/tool-call.ts";
|
|
22
25
|
import { registerToolResultHandler } from "./lib/extension/handlers/tool-result.ts";
|
|
@@ -28,6 +31,9 @@ export { shouldPersist } from "./lib/extension/evidence.ts";
|
|
|
28
31
|
export default function register(pi: ExtensionAPI): void {
|
|
29
32
|
setPiRef(pi);
|
|
30
33
|
registerHealthTool(pi);
|
|
34
|
+
registerDecisionsTool(pi);
|
|
35
|
+
registerBrowserTools(pi);
|
|
36
|
+
registerEvidenceCheckTool(pi);
|
|
31
37
|
registerCommands(pi);
|
|
32
38
|
registerToolCallHandler(pi);
|
|
33
39
|
registerToolResultHandler(pi);
|
|
@@ -203,15 +203,20 @@ export function normalizeConfig(raw) {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
/**
|
|
206
|
-
* Load the project config:
|
|
207
|
-
* `
|
|
206
|
+
* Load the project config: `.asf/code-health.json`, falling back to the root
|
|
207
|
+
* `.asf-code-health.json` that older releases wrote.
|
|
208
|
+
*
|
|
209
|
+
* The path comes from the home table rather than a literal here, because "everything
|
|
210
|
+
* ASF authors lives under `.asf/`" is one decision with one table of names — a second
|
|
211
|
+
* place that hardcodes a location is how a file ends up written in one place and read
|
|
212
|
+
* from another. `readFile` is injectable for tests.
|
|
208
213
|
*/
|
|
209
214
|
export async function loadConfig(projectRoot, readFile = null) {
|
|
210
215
|
const fs = readFile || (await import("node:fs/promises")).readFile;
|
|
216
|
+
const { readPath } = await import("../home.mjs");
|
|
211
217
|
let raw = null;
|
|
212
218
|
try {
|
|
213
|
-
|
|
214
|
-
raw = JSON.parse(text);
|
|
219
|
+
raw = JSON.parse(await fs(readPath(projectRoot, "healthConfig"), "utf-8"));
|
|
215
220
|
} catch {
|
|
216
221
|
raw = null; // no file, or unreadable/invalid → defaults
|
|
217
222
|
}
|
|
@@ -10,10 +10,12 @@ import { loadConfig } from "./config.mjs";
|
|
|
10
10
|
import { detectLanguages, planMetrics, resolvePlans } from "./detect.mjs";
|
|
11
11
|
import { listSourceFiles, detectTools, runAll } from "./run.mjs";
|
|
12
12
|
import { evaluateRow, verdict, formatReport, jsonReport, diffRows, formatDiff, baselinePayload } from "./report.mjs";
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
-
import {
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
import { readPath, writePath } from "../home.mjs";
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
/** The path ASF reports and tests assert; derived from the home, never a second copy. */
|
|
18
|
+
export const BASELINE_FILE = writePath("", "baseline");
|
|
17
19
|
|
|
18
20
|
/** Parse CLI args: --diff, --json, --update-baseline, --verbose. */
|
|
19
21
|
export function parseArgs(args) {
|
|
@@ -66,11 +68,15 @@ export async function runHealth(projectRoot, args = [], deps = {}) {
|
|
|
66
68
|
function applyBaselineFlags({ text, rows, root, flags }) {
|
|
67
69
|
if (flags.updateBaseline) {
|
|
68
70
|
const payload = baselinePayload(rows);
|
|
69
|
-
|
|
71
|
+
const file = writePath(root, "baseline");
|
|
72
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
73
|
+
writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
70
74
|
return `${text}\n\nBaseline updated: ${BASELINE_FILE}`;
|
|
71
75
|
}
|
|
72
76
|
if (!flags.diff) return text;
|
|
73
|
-
|
|
77
|
+
// Home first, then the pre-home root file — a project that set a baseline
|
|
78
|
+
// before the home existed must still diff against its own ratchet.
|
|
79
|
+
const baselinePath = readPath(root, "baseline");
|
|
74
80
|
let baselineRows = [];
|
|
75
81
|
if (existsSync(baselinePath)) {
|
|
76
82
|
try {
|
|
@@ -83,4 +89,3 @@ function applyBaselineFlags({ text, rows, root, flags }) {
|
|
|
83
89
|
return `${text}\n\n${formatDiff(diffRows(rows, baselineRows))}`;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
export { BASELINE_FILE };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Where ASF is installed — the base for resolving its own bundled toolchain. */
|
|
2
|
+
export const ASF_ROOT: string;
|
|
3
|
+
|
|
4
|
+
export interface ResolvedTools {
|
|
5
|
+
eslint?: string;
|
|
6
|
+
sonarjs?: string;
|
|
7
|
+
tsParser?: string;
|
|
8
|
+
jscpd?: string;
|
|
9
|
+
madge?: string;
|
|
10
|
+
depcruise?: string;
|
|
11
|
+
/** Where each tool was found: "asf" | "project" | "path" | undefined when missing. */
|
|
12
|
+
sources: Record<string, string | undefined>;
|
|
13
|
+
missing: string[];
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolve the health toolchain ASF-first: ASF's install, then the project, then PATH. */
|
|
18
|
+
export function resolveTools(projectRoot: string, opts?: { asfRoot?: string }): ResolvedTools;
|
|
19
|
+
|
|
20
|
+
/** The required tools that could not be found; empty means the gate can measure. */
|
|
21
|
+
export function missingTools(tools: ResolvedTools): string[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DecisionRecord, DecisionProblem } from "./schema.d.mts";
|
|
2
|
+
|
|
3
|
+
export interface Register {
|
|
4
|
+
dir: string;
|
|
5
|
+
records: DecisionRecord[];
|
|
6
|
+
problems: DecisionProblem[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DecisionResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
file?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
superseded?: string;
|
|
14
|
+
by?: string;
|
|
15
|
+
problems?: DecisionProblem[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AddArgs {
|
|
19
|
+
record: DecisionRecord;
|
|
20
|
+
body?: string;
|
|
21
|
+
config?: Record<string, unknown> | null;
|
|
22
|
+
planApproved?: boolean;
|
|
23
|
+
read?: DecisionRecord[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SupersedeArgs extends AddArgs {
|
|
27
|
+
id: string;
|
|
28
|
+
by?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerAt(root: string, config?: Record<string, unknown> | null): Register;
|
|
32
|
+
/**
|
|
33
|
+
* Project-relative paths: `applies_to` is written relative to the repo while a
|
|
34
|
+
* run records absolute ones, and the two must be compared on the same footing.
|
|
35
|
+
*/
|
|
36
|
+
export function relativeTo(root: string, files: string[] | undefined): string[];
|
|
37
|
+
export function cardIndex(records: DecisionRecord[] | undefined, opts?: { max?: number; bytes?: number }): string[];
|
|
38
|
+
export function unreadGoverning(
|
|
39
|
+
records: DecisionRecord[] | undefined,
|
|
40
|
+
filePath: string,
|
|
41
|
+
read: string[] | undefined,
|
|
42
|
+
): DecisionRecord[];
|
|
43
|
+
export function citedIds(text: string | undefined, records: DecisionRecord[] | undefined): string[];
|
|
44
|
+
export function addDecision(root: string, args: AddArgs): DecisionResult;
|
|
45
|
+
export function supersedeDecision(root: string, args: SupersedeArgs): DecisionResult;
|
|
46
|
+
export function refreshIndex(root: string, config?: Record<string, unknown> | null): { file: string; count: number };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consult surface: what the handlers and the tool actually call.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from `store.mjs` because the store is about files and this is
|
|
5
|
+
* about a *run*: what is in context, what has been read, what has been cited,
|
|
6
|
+
* and what a new decision has to satisfy before it is allowed to exist. The
|
|
7
|
+
* rule it enforces is the cheap one that makes the whole register worth
|
|
8
|
+
* building — a decision only governs work if it was in front of the agent when
|
|
9
|
+
* that work was written.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { decisionsDir, ensureHome } from "../home.mjs";
|
|
15
|
+
import { loadRegister, governing, indexForCard, writeRecord, updateRecord, nextNumber, writeIndex, fileNameFor } from "./store.mjs";
|
|
16
|
+
import { validateRegister } from "./schema.mjs";
|
|
17
|
+
|
|
18
|
+
const ID_RE = /ADR-\d{4}/g;
|
|
19
|
+
|
|
20
|
+
/** Whether a named enforcement check exists — relative to the measured project. */
|
|
21
|
+
function existsIn(root, p) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.existsSync(path.join(root, String(p)));
|
|
24
|
+
} catch {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read the register for a project. Reading never creates anything. */
|
|
30
|
+
export function registerAt(root, config) {
|
|
31
|
+
const dir = decisionsDir(root, config || {});
|
|
32
|
+
const { records, problems } = loadRegister(dir);
|
|
33
|
+
const exists = (p) => existsIn(root, p);
|
|
34
|
+
return { dir, records, problems: problems.concat(validateRegister(records, { exists })) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The budgeted index for the run card. */
|
|
38
|
+
export function cardIndex(records, opts) {
|
|
39
|
+
return indexForCard(records, opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which accepted decisions govern a file the agent is about to change and has
|
|
44
|
+
* not read this run. That set is the whole read-before-edit floor.
|
|
45
|
+
*/
|
|
46
|
+
export function unreadGoverning(records, filePath, read) {
|
|
47
|
+
return governing(records, filePath).filter((r) => !(read || []).includes(String(r.id)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Project-relative paths, because `applies_to` is written relative to the repo
|
|
52
|
+
* and the run records absolute ones. A path outside the project is left alone —
|
|
53
|
+
* it governs nothing, and silently dropping it would hide that fact.
|
|
54
|
+
*/
|
|
55
|
+
export function relativeTo(root, files) {
|
|
56
|
+
return (files || [])
|
|
57
|
+
.filter((f) => typeof f === "string" && f.trim())
|
|
58
|
+
.map((f) => {
|
|
59
|
+
const s = String(f).replace(/\\/g, "/");
|
|
60
|
+
if (!path.isAbsolute(s)) return s;
|
|
61
|
+
const rel = path.relative(root, s);
|
|
62
|
+
return rel && !rel.startsWith("..") ? rel : s;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Which accepted decisions a piece of text cites. */
|
|
67
|
+
export function citedIds(text, records) {
|
|
68
|
+
const found = String(text || "").match(ID_RE) || [];
|
|
69
|
+
const known = new Set((records || []).filter((r) => r.status === "accepted").map((r) => String(r.id)));
|
|
70
|
+
return found.filter((id) => known.has(id));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What makes a record law rather than a suggestion. */
|
|
74
|
+
function hasAcceptanceEvidence(d) {
|
|
75
|
+
return Boolean(String(d.from_spec || "").trim() || String(d.user_instructed || "").trim() || String(d.plan || "").trim());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Default the status rather than refuse the record.
|
|
80
|
+
*
|
|
81
|
+
* A record with nothing behind it is a *proposal* — visible, never enforced —
|
|
82
|
+
* which is the honest default and keeps an unattended run from having to ask.
|
|
83
|
+
* Evidence (a spec id, the user's own words, an approved plan) is what promotes
|
|
84
|
+
* it to law, and the validator still refuses `accepted` without it, so a
|
|
85
|
+
* hand-written or imported record cannot skip the same bar.
|
|
86
|
+
*/
|
|
87
|
+
function defaultStatus(d) {
|
|
88
|
+
if (d.status) return d.status;
|
|
89
|
+
if (String(d.origin || "") === "backfill" && Array.isArray(d.source) && d.source.length) return "accepted";
|
|
90
|
+
return hasAcceptanceEvidence(d) ? "accepted" : "proposed";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record a new decision.
|
|
95
|
+
*
|
|
96
|
+
* An invalid record is **not written**: a register full of half-records is worse
|
|
97
|
+
* than a smaller one, because the half-records are the ones that get followed
|
|
98
|
+
* into the code. The caller gets the fix list instead.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The set a write is validated against: what is on disk, plus whatever the caller
|
|
103
|
+
* already loaded, minus the file this write replaces (it is being rewritten, not
|
|
104
|
+
* duplicated).
|
|
105
|
+
*
|
|
106
|
+
* Deduplicated by FILE, never by id — two files carrying the same id is exactly
|
|
107
|
+
* the corruption the validator has to be able to see, and a caller that passes the
|
|
108
|
+
* records it just loaded must not turn every record into a duplicate of itself.
|
|
109
|
+
*/
|
|
110
|
+
function validationSet(dir, probe, read) {
|
|
111
|
+
const byFile = new Map();
|
|
112
|
+
for (const r of [...loadRegister(dir).records, ...(read || [])]) {
|
|
113
|
+
const key = String(r.__file || "");
|
|
114
|
+
if (key && key === String(probe.__file)) continue;
|
|
115
|
+
byFile.set(key || `#${byFile.size}`, r);
|
|
116
|
+
}
|
|
117
|
+
return [probe, ...byFile.values()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function addDecision(root, { record, body, config, planApproved, read }) {
|
|
121
|
+
ensureHome(root);
|
|
122
|
+
const dir = decisionsDir(root, config || {});
|
|
123
|
+
const data = { ...record };
|
|
124
|
+
data.status = defaultStatus(data);
|
|
125
|
+
if (!data.id) data.id = `ADR-${String(nextNumber(dir)).padStart(4, "0")}`;
|
|
126
|
+
if (!data.decided) data.decided = new Date().toISOString().slice(0, 10);
|
|
127
|
+
const probe = { ...data, body: body ?? data.body, __file: fileNameFor(data.id, data.title) };
|
|
128
|
+
const problems = validateRegister(validationSet(dir, probe, read), { planApproved: planApproved === true, exists: (p) => existsIn(root, p) });
|
|
129
|
+
// Only this record's own faults may refuse it. The register's existing
|
|
130
|
+
// records are passed in so collisions (duplicate ids, overlapping scopes,
|
|
131
|
+
// one-sided supersession) are caught — but a neighbour's formatting is not
|
|
132
|
+
// this write's problem, and refusing on it would make a register unwritable.
|
|
133
|
+
const blocking = problems.filter((p) => p.severity === "error" && (p.id === data.id || p.file === probe.__file || !p.id));
|
|
134
|
+
if (blocking.length) return { ok: false, problems: blocking, file: undefined };
|
|
135
|
+
const written = writeRecord(dir, data, body ?? data.body);
|
|
136
|
+
if (!written.created) return { ok: false, problems: [{ severity: "error", field: "id", message: `${written.file} already exists — a decision is superseded, never rewritten` }], file: written.file };
|
|
137
|
+
const refreshed = loadRegister(dir);
|
|
138
|
+
writeIndex(dir, refreshed.records);
|
|
139
|
+
return { ok: true, file: written.file, id: data.id, problems: problems.filter((p) => p.severity === "notice") };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Replace a decision. The old record keeps its history and gains
|
|
144
|
+
* `superseded_by`; the new one names it in `supersedes`. Both halves are written
|
|
145
|
+
* together, because a one-way link leaves two records looking current — which is
|
|
146
|
+
* exactly how an agent ends up following a decision that was reversed.
|
|
147
|
+
*/
|
|
148
|
+
export function supersedeDecision(root, { by, id, record, body, config, planApproved, read }) {
|
|
149
|
+
const dir = decisionsDir(root, config || {});
|
|
150
|
+
const loaded = loadRegister(dir).records;
|
|
151
|
+
const older = loaded.find((r) => String(r.id) === String(id));
|
|
152
|
+
if (!older) return { ok: false, problems: [{ severity: "error", field: "id", message: `${id} is not in the register` }] };
|
|
153
|
+
// The id is decided BEFORE validation: the bidirectional check asks what the
|
|
154
|
+
// old record will say about the new one, and that cannot be answered after the
|
|
155
|
+
// new record has already been written. Validating the pair as it will exist is
|
|
156
|
+
// the only order in which a legitimate supersession can be written at all.
|
|
157
|
+
const newId = String(record.id || `ADR-${String(nextNumber(dir)).padStart(4, "0")}`);
|
|
158
|
+
// The register this function already opened is the authority for what exists —
|
|
159
|
+
// a caller that forgot to pass `read` (or passed a stale one) must not make a
|
|
160
|
+
// legitimate supersession look like a link to nothing.
|
|
161
|
+
const known = [...new Map([...loaded, ...(read || [])].map((r) => [String(r.id), r])).values()].map((r) =>
|
|
162
|
+
String(r.id) === String(id) ? { ...r, status: "superseded", superseded_by: newId } : r,
|
|
163
|
+
);
|
|
164
|
+
const created = addDecision(root, {
|
|
165
|
+
record: { ...record, id: newId, supersedes: [String(id)] },
|
|
166
|
+
body,
|
|
167
|
+
config,
|
|
168
|
+
planApproved,
|
|
169
|
+
read: known,
|
|
170
|
+
});
|
|
171
|
+
if (!created.ok) return created;
|
|
172
|
+
const back = updateRecord(dir, String(id), { status: "superseded", superseded_by: created.id });
|
|
173
|
+
if (!back.created) return { ok: false, problems: [{ severity: "error", field: "supersedes", message: `could not mark ${id} superseded (${back.reason})` }] };
|
|
174
|
+
const refreshed = loadRegister(dir);
|
|
175
|
+
writeIndex(dir, refreshed.records);
|
|
176
|
+
return { ok: true, file: back.file, id: created.id, superseded: String(id), by: String(by || "agent") };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Re-generate the index from the records (the records are the source). */
|
|
180
|
+
export function refreshIndex(root, config) {
|
|
181
|
+
const dir = decisionsDir(root, config || {});
|
|
182
|
+
const { records } = loadRegister(dir);
|
|
183
|
+
return { file: writeIndex(dir, records), count: records.length };
|
|
184
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tiny glob matcher a decision uses to name the code it governs.
|
|
3
|
+
*
|
|
4
|
+
* `applies_to: [lib/gates/**, test/test-gates.mjs]` is the field that turns a
|
|
5
|
+
* paragraph into something that can be *routed*: it is how ASF knows, at the
|
|
6
|
+
* moment a file is about to be edited, which decisions apply to that file.
|
|
7
|
+
*
|
|
8
|
+
* Written here rather than pulled from a package for the same reason as the
|
|
9
|
+
* front-matter parser — a gate's behaviour must not vary with what the measured
|
|
10
|
+
* project has installed. Supported: `*` (not across `/`), `**` (across `/`),
|
|
11
|
+
* `?`, and a trailing `/` meaning "everything under here".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function escapeRe(s) {
|
|
15
|
+
return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Emit the pattern for a run of stars starting at `i`, and report how much of the
|
|
20
|
+
* glob it consumed. Pulled out of `globToRe` because the three star cases are where
|
|
21
|
+
* all the nesting lived; with them here, the compiler is a flat switch.
|
|
22
|
+
*/
|
|
23
|
+
function starPattern(g, i) {
|
|
24
|
+
if (g[i + 1] !== "*") return { pattern: "[^/]*", skip: 1 };
|
|
25
|
+
// `**/` may match zero directories, so swallow the separator with it.
|
|
26
|
+
if (g[i + 2] === "/") return { pattern: "(?:.*/)?", skip: 3 };
|
|
27
|
+
return { pattern: ".*", skip: 2 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Compile one glob into an anchored RegExp. */
|
|
31
|
+
export function globToRe(glob) {
|
|
32
|
+
let g = String(glob || "");
|
|
33
|
+
if (!g) return /^$/;
|
|
34
|
+
if (g.endsWith("/")) g += "**";
|
|
35
|
+
let out = "";
|
|
36
|
+
for (let i = 0; i < g.length; i++) {
|
|
37
|
+
const c = g[i];
|
|
38
|
+
if (c === "*") {
|
|
39
|
+
const star = starPattern(g, i);
|
|
40
|
+
out += star.pattern;
|
|
41
|
+
i += star.skip - 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === "?") {
|
|
45
|
+
out += "[^/]";
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out += escapeRe(c);
|
|
49
|
+
}
|
|
50
|
+
return new RegExp(`^${out}$`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Does a project-relative path fall under any of these globs? */
|
|
54
|
+
export function matchesAny(path, globs) {
|
|
55
|
+
const p = String(path || "").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
56
|
+
return (globs || []).some((g) => globToRe(g).test(p));
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ParsedFrontMatter {
|
|
2
|
+
data: Record<string, unknown>;
|
|
3
|
+
body: string;
|
|
4
|
+
errors: string[];
|
|
5
|
+
}
|
|
6
|
+
export function parseFrontMatter(text: string): ParsedFrontMatter;
|
|
7
|
+
export function serializeFrontMatter(
|
|
8
|
+
data: Record<string, unknown>,
|
|
9
|
+
body: string,
|
|
10
|
+
order?: string[],
|
|
11
|
+
): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The front matter of a decision record.
|
|
3
|
+
*
|
|
4
|
+
* A documented subset of YAML — flat `key: value`, inline arrays, block lists,
|
|
5
|
+
* comments — parsed by ASF rather than by a YAML dependency. The reason is the
|
|
6
|
+
* same one that makes the health gate write its own eslint config and run
|
|
7
|
+
* `--no-config-lookup`: what a gate reads must not depend on a library the
|
|
8
|
+
* measured project happens to have installed.
|
|
9
|
+
*
|
|
10
|
+
* The second reason is the user. These files are edited by hand, verbatim, in
|
|
11
|
+
* whatever editor is open. So the parser is strict about what it understands and
|
|
12
|
+
* *loud* about what it does not: unsupported syntax is returned as an error,
|
|
13
|
+
* never skipped, because a record silently parsed as empty reads as "no
|
|
14
|
+
* decisions" — and an empty register is a gate that passes for the wrong reason.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const FM_DELIM = /^---\s*$/;
|
|
18
|
+
|
|
19
|
+
function stripComment(value) {
|
|
20
|
+
let out = "";
|
|
21
|
+
let quote = null;
|
|
22
|
+
for (let i = 0; i < value.length; i++) {
|
|
23
|
+
const c = value[i];
|
|
24
|
+
if (quote) {
|
|
25
|
+
out += c;
|
|
26
|
+
if (c === quote) quote = null;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (c === '"' || c === "'") {
|
|
30
|
+
quote = c;
|
|
31
|
+
out += c;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (c === "#" && (i === 0 || /\s/.test(value[i - 1]))) break;
|
|
35
|
+
out += c;
|
|
36
|
+
}
|
|
37
|
+
return out.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function unquote(v) {
|
|
41
|
+
const t = v.trim();
|
|
42
|
+
if (t.length > 1 && (t[0] === '"' || t[0] === "'") && t.endsWith(t[0])) return t.slice(1, -1);
|
|
43
|
+
return t;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseScalar(v) {
|
|
47
|
+
const t = unquote(v);
|
|
48
|
+
if (t === "") return "";
|
|
49
|
+
if (t === "true") return true;
|
|
50
|
+
if (t === "false") return false;
|
|
51
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
52
|
+
return t;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseInlineArray(v) {
|
|
56
|
+
const inner = v.trim().replace(/^\[/, "").replace(/\]$/, "").trim();
|
|
57
|
+
if (!inner) return [];
|
|
58
|
+
return inner.split(",").map((s) => parseScalar(s.trim())).filter((x) => x !== "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Is this line something the subset cannot express (nested map, tabs, anchors)? */
|
|
62
|
+
function unsupported(line) {
|
|
63
|
+
if (/\t/.test(line)) return "tabs are not valid indentation";
|
|
64
|
+
if (/^\s+\S+:\s*$/.test(line) || /^\s+\S+:\s+\S/.test(line)) return "nested maps are not supported";
|
|
65
|
+
if (/^\s*[-*]\s+\S+:/.test(line)) return "maps inside lists are not supported";
|
|
66
|
+
if (/^\s*[&*]|:\s*[|>]\s*$/.test(line)) return "anchors and block scalars are not supported";
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply one front-matter line to `data`. Returns an error string when the line is
|
|
72
|
+
* not something this parser understands, `null` when it was consumed happily, and
|
|
73
|
+
* the new current key when a `key:` opened a list. Kept separate from the loop so
|
|
74
|
+
* the four shapes a line can have (blank/comment, unsupported, bullet, pair) read
|
|
75
|
+
* as four branches instead of one nested pile.
|
|
76
|
+
*/
|
|
77
|
+
function applyLine(line, data, key) {
|
|
78
|
+
const bad = unsupported(line);
|
|
79
|
+
if (bad) return { error: bad };
|
|
80
|
+
const bullet = /^\s*-\s+(.*)$/.exec(line);
|
|
81
|
+
if (bullet && key) {
|
|
82
|
+
if (!Array.isArray(data[key])) data[key] = [];
|
|
83
|
+
data[key].push(parseScalar(stripComment(bullet[1])));
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
const kv = /^([A-Za-z_][\w.-]*):\s*(.*)$/.exec(line);
|
|
87
|
+
if (!kv) return { error: `not a "key: value" pair — "${line.trim()}"`, key: null };
|
|
88
|
+
const next = kv[1];
|
|
89
|
+
const value = stripComment(kv[2]);
|
|
90
|
+
if (value === "") data[next] = [];
|
|
91
|
+
else if (value.startsWith("[")) data[next] = parseInlineArray(value);
|
|
92
|
+
else data[next] = parseScalar(value);
|
|
93
|
+
return { key: next };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse `---` front matter out of a markdown document.
|
|
98
|
+
* @returns {{data: Record<string, unknown>, body: string, errors: string[]}}
|
|
99
|
+
*/
|
|
100
|
+
export function parseFrontMatter(text) {
|
|
101
|
+
const lines = String(text ?? "").replace(/\r\n/g, "\n").split("\n");
|
|
102
|
+
if (lines[0].trim() !== "---") {
|
|
103
|
+
return { data: {}, body: lines.join("\n"), errors: ["missing front matter (the file must start with ---)"] };
|
|
104
|
+
}
|
|
105
|
+
const end = lines.findIndex((l, i) => i > 0 && FM_DELIM.test(l));
|
|
106
|
+
if (end < 0) {
|
|
107
|
+
return { data: {}, body: "", errors: ["front matter is never closed (no second ---)"] };
|
|
108
|
+
}
|
|
109
|
+
const data = {};
|
|
110
|
+
const errors = [];
|
|
111
|
+
let key = null;
|
|
112
|
+
for (let i = 1; i < end; i++) {
|
|
113
|
+
const line = lines[i].replace(/\s+$/, "");
|
|
114
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
115
|
+
const out = applyLine(line, data, key);
|
|
116
|
+
if (out.error) {
|
|
117
|
+
errors.push(`front matter line ${i + 1}: ${out.error}`);
|
|
118
|
+
key = out.key === null ? null : key;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (out.key) key = out.key;
|
|
122
|
+
}
|
|
123
|
+
return { data, body: lines.slice(end + 1).join("\n"), errors };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function emitValue(v) {
|
|
127
|
+
if (Array.isArray(v)) return `[${v.map(emitValue).join(", ")}]`;
|
|
128
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
129
|
+
const s = String(v ?? "");
|
|
130
|
+
return /[:#\[\]{}"']|^\s|\s$/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Serialize a record back to front matter + body. Keys keep the declared order. */
|
|
134
|
+
export function serializeFrontMatter(data, body, order = []) {
|
|
135
|
+
const keys = order.length ? [...order.filter((k) => k in data), ...Object.keys(data).filter((k) => !order.includes(k))] : Object.keys(data);
|
|
136
|
+
const fm = keys.map((k) => `${k}: ${emitValue(data[k])}`);
|
|
137
|
+
return `---\n${fm.join("\n")}\n---\n\n${String(body ?? "").trim()}\n`;
|
|
138
|
+
}
|