pi-aia-asf 0.17.9 → 0.17.10

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/index.ts +4 -0
  3. package/lib/code-health/index.mjs +11 -6
  4. package/lib/decisions/consult.d.mts +46 -0
  5. package/lib/decisions/consult.mjs +184 -0
  6. package/lib/decisions/glob.d.mts +2 -0
  7. package/lib/decisions/glob.mjs +57 -0
  8. package/lib/decisions/parse.d.mts +11 -0
  9. package/lib/decisions/parse.mjs +138 -0
  10. package/lib/decisions/schema.d.mts +36 -0
  11. package/lib/decisions/schema.mjs +252 -0
  12. package/lib/decisions/store.d.mts +28 -0
  13. package/lib/decisions/store.mjs +195 -0
  14. package/lib/extension/browser-record.ts +70 -0
  15. package/lib/extension/commands/session.ts +5 -3
  16. package/lib/extension/gate-state.ts +8 -1
  17. package/lib/extension/handlers/observe-decisions.ts +78 -0
  18. package/lib/extension/handlers/observe-work.ts +111 -0
  19. package/lib/extension/handlers/observed.ts +8 -0
  20. package/lib/extension/handlers/settle.ts +35 -0
  21. package/lib/extension/handlers/tool-call.ts +47 -76
  22. package/lib/extension/handlers/tool-result.ts +17 -1
  23. package/lib/extension/paths.ts +18 -0
  24. package/lib/extension/run.ts +39 -1
  25. package/lib/extension/runtime.ts +24 -0
  26. package/lib/extension/state.ts +42 -18
  27. package/lib/extension/tools/browser.ts +65 -0
  28. package/lib/extension/tools/decisions.ts +251 -0
  29. package/lib/extension/types.ts +1 -1
  30. package/lib/gates/browser-evidence.d.mts +28 -0
  31. package/lib/gates/browser-evidence.mjs +101 -0
  32. package/lib/gates/config.d.mts +1 -1
  33. package/lib/gates/config.mjs +11 -4
  34. package/lib/gates/decisions.d.mts +36 -0
  35. package/lib/gates/decisions.mjs +75 -0
  36. package/lib/gates/ledger.mjs +0 -4
  37. package/lib/gates/settle.d.mts +3 -2
  38. package/lib/gates/settle.mjs +8 -1
  39. package/lib/home.d.mts +18 -0
  40. package/lib/home.mjs +126 -0
  41. package/package.json +1 -1
  42. package/skills/aia-asf/references/06b-appendix.md +18 -0
  43. package/skills/aia-asf/references/06b-testing-qa.md +3 -1
  44. package/skills/aia-asf/references/06c-code-quality.md +5 -5
  45. package/skills/aia-asf/references/06i-completeness.md +6 -8
package/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.10] - 2026-11-10
11
+
12
+ ### Added
13
+
14
+ - (describe changes for 0.17.10)
15
+
16
+ ## [0.18.0] - 2026-11-10
17
+
18
+ ### Added — the browser evidence the completion gate demands can now be produced
19
+
20
+ The gate required browser verification whenever a UI file was touched (in two places:
21
+ the ledger's completeness check and the delivery report), but the only registered
22
+ tools were `asf_health` and `asf_decisions`. Any session that edited a tsx/html/css
23
+ file was steered at a gap it had no hands for — the health loop, one layer down. Two
24
+ agent-callable tools now close it, mirroring `asf_health`:
25
+
26
+ - `asf_browser_verify {page, action, assertion}` — **refused** unless the session
27
+ actually drove a browser. ASF sees every `tool_call`, so a verification with no
28
+ browser in the room is a claim about a page nobody opened; `browser_diagnose` and
29
+ `browser_logs` do not count, because they report on the daemon, not on the surface.
30
+ All three fields are required: that is the minimum a reviewer can go and repeat.
31
+ - `asf_browser_na {reason}` — **refused** when the reason is a placeholder (`n/a`,
32
+ `none`, `not applicable`, one word). A structured n/a is worth having only if a
33
+ reviewer can disagree with it; otherwise it is an exit around the gate.
34
+
35
+ Recorded, not claimed: a tool that accepted "I verified it" would make the gate
36
+ decorative, and a decorative gate reads as coverage. The rules live in
37
+ `lib/gates/browser-evidence.mjs` (pure, testable without a browser) with a thin
38
+ extension adapter, mirroring the `measure.mjs` / `health-record.ts` split.
39
+
40
+ ### Fixed — five ledger kinds had no producer
41
+
42
+ `browser-verify`, `browser-na`, `clean-room`, `spec-transition` and `perf-scenario`
43
+ could be validated and rendered but never written. Now:
44
+
45
+ - `spec-transition` is **observed** from the real `update_spec_status` call, so the
46
+ delivery report renders actual transitions without anyone remembering to record them;
47
+ - `clean-room` is observed from the release pipeline (`npm run release` /
48
+ `verify-published.mjs`) together with its exit code — the definition-of-done line
49
+ about the shipped artifact no longer promises evidence nothing can produce;
50
+ - `perf-scenario` is **dropped**: no producer, no requirement, five mandatory fields
51
+ nobody could fill.
52
+
53
+ ### Added — the runner can no longer read a suite as green when it never checked
54
+
55
+ `test/harness.mjs` honoured only `export async function run()` returning false. A
56
+ suite exposing its verdict as the module's default export was ignored entirely —
57
+ which is how a new suite reported "51 passed" while enforcing nothing. The runner now
58
+ reads `run()`, a boolean default, or a boolean `ok`; a suite claiming `selfReports`
59
+ must contain a real failure channel (`process.exit(1)` or `node:test`) or it warns;
60
+ and a suite with none of these is named instead of passing silently. The whole class
61
+ is pinned by `test/test-evidence-kinds.mjs`: every ledger kind must have a producer in
62
+ the shipped package, and no kind a gate *requires* may be an orphan.
63
+
64
+ Also: `tool-call.ts` passed the 300-line ceiling and its work observers moved to
65
+ `lib/extension/handlers/observe-work.ts`, with pi's `Input`/`Event` shapes declared
66
+ once in `observed.ts` instead of three times; `String(undefined)` → `?? ""` in the
67
+ evidence builders (the literal "undefined" reads like a value and passes a presence
68
+ check); and a redundant guard in `placeholderReason` was deleted because no mutation
69
+ of it failed a test — a guard that cannot be failed is decoration, not protection.
70
+
10
71
  ## [0.17.9] - 2026-11-10
11
72
 
12
73
  ### Fixed — the health verdict now reports what it measured, not what it planned
package/index.ts CHANGED
@@ -17,6 +17,8 @@
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";
20
22
  import { registerCommands } from "./lib/extension/commands/index.ts";
21
23
  import { registerToolCallHandler } from "./lib/extension/handlers/tool-call.ts";
22
24
  import { registerToolResultHandler } from "./lib/extension/handlers/tool-result.ts";
@@ -28,6 +30,8 @@ export { shouldPersist } from "./lib/extension/evidence.ts";
28
30
  export default function register(pi: ExtensionAPI): void {
29
31
  setPiRef(pi);
30
32
  registerHealthTool(pi);
33
+ registerDecisionsTool(pi);
34
+ registerBrowserTools(pi);
31
35
  registerCommands(pi);
32
36
  registerToolCallHandler(pi);
33
37
  registerToolResultHandler(pi);
@@ -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 { join } from "node:path";
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
- const BASELINE_FILE = ".asf-code-health-baseline.json";
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
- writeFileSync(join(root, BASELINE_FILE), JSON.stringify(payload, null, 2) + "\n", "utf-8");
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
- const baselinePath = join(root, BASELINE_FILE);
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,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,2 @@
1
+ export function globToRe(glob: string): RegExp;
2
+ export function matchesAny(path: string, globs: string[] | undefined): boolean;
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ export interface DecisionRecord extends Record<string, unknown> {
2
+ id?: string;
3
+ status?: string;
4
+ title?: string;
5
+ decision?: string;
6
+ decided?: string | number;
7
+ applies_to?: string[];
8
+ body?: string;
9
+ __file?: string;
10
+ }
11
+
12
+ export interface DecisionProblem {
13
+ severity: "error" | "notice";
14
+ file?: string;
15
+ id?: string;
16
+ field?: string;
17
+ message: string;
18
+ }
19
+
20
+ export interface ValidateContext {
21
+ byId?: Map<string, DecisionRecord>;
22
+ planApproved?: boolean;
23
+ now?: number;
24
+ exists?: (p: string) => boolean;
25
+ }
26
+
27
+ export const STATUSES: string[];
28
+ export const ORIGINS: string[];
29
+ export const FIELD_ORDER: string[];
30
+ export const REQUIRED_FIELDS: string[];
31
+ export const BODY_SECTIONS: string[];
32
+ export const ACCEPTANCE_FIELDS: string[];
33
+
34
+ export function sectionsOf(body: string | undefined): Record<string, string>;
35
+ export function validateRecord(rec: DecisionRecord, ctx?: ValidateContext): DecisionProblem[];
36
+ export function validateRegister(records: DecisionRecord[], ctx?: ValidateContext): DecisionProblem[];