@ecoma-io/archkeep 0.17.0 → 0.18.1
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/cli.mjs +161 -22
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/diff.mjs +15 -7
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +82 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/rules.mjs +3 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -0
- package/src/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +45 -0
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture scaffolding for the Wave 3 W8 evolution-lifecycle conformance suite
|
|
3
|
+
* (`../evolution-lifecycle.integration.test.mjs`). This module is the ONE home
|
|
4
|
+
* for the real-git workspace builders that suite uses — a throwaway native Go
|
|
5
|
+
* workspace per case, materialized through real `git`, driven through the real
|
|
6
|
+
* `archkeep evolution` entry point.
|
|
7
|
+
*
|
|
8
|
+
* It deliberately reuses the native-workspace recipe already proven by
|
|
9
|
+
* `../commands/evolution.cli.integration.test.mjs` (an `archkeep.json` model,
|
|
10
|
+
* a `module-boundaries.config.mjs` law, and Go sources) rather than inventing
|
|
11
|
+
* a second convention, and threads the same environment guard (`../process.mjs`).
|
|
12
|
+
*
|
|
13
|
+
* Nothing here decides a verdict. It builds trees and drives the CLI; the
|
|
14
|
+
* assertions live in the suite. Keeping the builders here (and only here) is
|
|
15
|
+
* what the W8 task boundary requires: fixture scaffolding lives in
|
|
16
|
+
* `./fixtures/evolution-lifecycle/`, nowhere else.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { EXIT, runCli } from "../../../cli.mjs";
|
|
25
|
+
import { SPAWN_BUDGET_MS, SPAWN_TEST_BUDGET_MS } from "../../../spawn-budget.mjs";
|
|
26
|
+
import { environmentForTree } from "../../workspace.mjs";
|
|
27
|
+
|
|
28
|
+
export { SPAWN_TEST_BUDGET_MS, EXIT };
|
|
29
|
+
|
|
30
|
+
/** Identity flags keeping every fixture commit independent of the machine. */
|
|
31
|
+
const IDENTITY = ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runs git in `cwd` through the same environment guard production uses, with
|
|
35
|
+
* the single-spawn budget on every child so a wedged git fails the test rather
|
|
36
|
+
* than blocking the worker thread forever.
|
|
37
|
+
*/
|
|
38
|
+
export function git(cwd, ...args) {
|
|
39
|
+
return execFileSync("git", args, {
|
|
40
|
+
cwd,
|
|
41
|
+
env: environmentForTree(),
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
timeout: SPAWN_BUDGET_MS,
|
|
44
|
+
killSignal: "SIGKILL",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Writes `text` to `root/relativePath`, creating parent directories. */
|
|
49
|
+
export function writeIn(root, relativePath, text) {
|
|
50
|
+
mkdirSync(join(root, relativePath, ".."), { recursive: true });
|
|
51
|
+
writeFileSync(join(root, relativePath), text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Stages every change and commits with the fixture identity; returns the SHA. */
|
|
55
|
+
export function commit(root, message) {
|
|
56
|
+
git(root, ...IDENTITY, "add", "-A");
|
|
57
|
+
git(root, ...IDENTITY, "commit", "-q", "-m", message);
|
|
58
|
+
return git(root, "rev-parse", "HEAD").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolves the current HEAD of the fixture. */
|
|
62
|
+
export function headOf(root) {
|
|
63
|
+
return git(root, "rev-parse", "HEAD").trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Opens a brand-new throwaway native git workspace (never the repository's own
|
|
68
|
+
* tree). `archkeep.json` declares two Go projects on two layers, exactly the
|
|
69
|
+
* MODEL `../commands/evolution.cli.integration.test.mjs` uses, so a case can
|
|
70
|
+
* lay an edge between them and the native provider draws it.
|
|
71
|
+
*
|
|
72
|
+
* @returns {{root: string}}
|
|
73
|
+
*/
|
|
74
|
+
export function createWorkspace() {
|
|
75
|
+
const root = mkdtempSync(join(tmpdir(), "archkeep-lifecycle-"));
|
|
76
|
+
git(root, "init", "-q", "-b", "main");
|
|
77
|
+
writeIn(root, "archkeep.json", `${MODEL()}\n`);
|
|
78
|
+
writeIn(root, "libs/alpha/go.mod", "module example.com/alpha\n\ngo 1.22\n");
|
|
79
|
+
writeIn(root, "libs/beta/go.mod", "module example.com/beta\n\ngo 1.22\n");
|
|
80
|
+
return { root };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The native workspace model: two Go projects on two layers (alpha is
|
|
85
|
+
* `layer:a`, beta is `layer:b`), the law file exempted from coverage.
|
|
86
|
+
*/
|
|
87
|
+
const MODEL = () =>
|
|
88
|
+
JSON.stringify(
|
|
89
|
+
{
|
|
90
|
+
projects: {
|
|
91
|
+
declared: [
|
|
92
|
+
{ root: "libs/alpha", name: "alpha", tags: ["layer:a"] },
|
|
93
|
+
{ root: "libs/beta", name: "beta", tags: ["layer:b"] },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
coverage: {
|
|
97
|
+
exempt: [{ path: "module-boundaries.config.mjs", reason: "the workspace's own law" }],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
null,
|
|
101
|
+
2,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/** The eight options a valid boundary law must carry, per `policyFrom`. */
|
|
105
|
+
const OPTIONS = `export const moduleBoundaryOptions = {
|
|
106
|
+
allow: [],
|
|
107
|
+
buildTargets: ["build"],
|
|
108
|
+
enforceBuildableLibDependency: false,
|
|
109
|
+
allowCircularSelfDependency: false,
|
|
110
|
+
checkDynamicDependenciesExceptions: [],
|
|
111
|
+
ignoredCircularDependencies: [],
|
|
112
|
+
banTransitiveDependencies: false,
|
|
113
|
+
checkNestedExternalImports: false,
|
|
114
|
+
};
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
export const ALPHA_CLEAN = `package alpha
|
|
118
|
+
|
|
119
|
+
func Name() string { return "alpha" }
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
export const ALPHA_REACHING = `package alpha
|
|
123
|
+
|
|
124
|
+
import (
|
|
125
|
+
"example.com/beta"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
func Name() string { return "alpha" + beta.Suffix() }
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
export const BETA = `package beta
|
|
132
|
+
|
|
133
|
+
func Suffix() string { return "-beta" }
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Writes a `module-boundaries.config.mjs` law at `root` with the given
|
|
138
|
+
* `depConstraints` rows and optional `fitness` array.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} root
|
|
141
|
+
* @param {{rows?: string, fitness?: string}} [law]
|
|
142
|
+
*/
|
|
143
|
+
export function writeLaw(root, { rows = "", fitness } = {}) {
|
|
144
|
+
writeIn(
|
|
145
|
+
root,
|
|
146
|
+
"module-boundaries.config.mjs",
|
|
147
|
+
`export const depConstraints = [\n${rows}\n];\n${OPTIONS}` +
|
|
148
|
+
(fitness === undefined ? "" : `\nexport const fitness = ${fitness};\n`),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A single permitted layer rule (a may reach b). The same ONE_ROW the
|
|
154
|
+
* evolution CLI integration fixtures use, so an allowed alpha→beta edge never
|
|
155
|
+
* trips a boundary rule.
|
|
156
|
+
*/
|
|
157
|
+
export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Writes `architecture-intent.json` at `root`. `sections` carries the top-level
|
|
161
|
+
* keys directly (`version`, `boundaries`, `allowed`, `forbidden`,
|
|
162
|
+
* `dependencies`, …); `version` defaults to "1".
|
|
163
|
+
*/
|
|
164
|
+
export function writeIntent(root, sections) {
|
|
165
|
+
writeIn(root, "architecture-intent.json", `${JSON.stringify(sections, null, 2)}\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Writes one ADR record under `docs/adr/`, the shape `adr-registry.mjs` reads.
|
|
170
|
+
* `record` is the frontmatter map (`{id, status, supersedes?, bindings?}`).
|
|
171
|
+
*/
|
|
172
|
+
export function writeAdr(root, filename, record) {
|
|
173
|
+
const lines = ["---", `id: ${record.id}`, `status: ${record.status}`];
|
|
174
|
+
if (record.supersedes?.length) {
|
|
175
|
+
lines.push("supersedes:");
|
|
176
|
+
for (const target of record.supersedes) lines.push(` - ${target}`);
|
|
177
|
+
}
|
|
178
|
+
if (record.bindings?.length) {
|
|
179
|
+
lines.push("bindings:");
|
|
180
|
+
for (const binding of record.bindings) lines.push(` - ${binding}`);
|
|
181
|
+
}
|
|
182
|
+
lines.push("---", "", `# ${record.id}`, "");
|
|
183
|
+
writeIn(root, join("docs/adr", filename), `${lines.join("\n")}\n`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Drives the CLI in-process over `cwd`, capturing streams. Returns the exit
|
|
188
|
+
* code and joined `out`/`err`. `runCli` is the real entry point
|
|
189
|
+
* (`../cli.mjs`), never a shell-out to a binary named `archkeep`.
|
|
190
|
+
*/
|
|
191
|
+
export async function runEvolution(cwd, argv) {
|
|
192
|
+
const out = [];
|
|
193
|
+
const err = [];
|
|
194
|
+
const exitCode = await runCli(argv, {
|
|
195
|
+
out: (text) => out.push(text),
|
|
196
|
+
err: (text) => err.push(text),
|
|
197
|
+
cwd,
|
|
198
|
+
});
|
|
199
|
+
return { exitCode, out: out.join("\n"), err: err.join("\n") };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Invokes `evolution --base <base> [--head <head>] [--event-out <dir>] [--format json]`.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} base The base revision (full SHA).
|
|
206
|
+
* @param {{head?: string, eventOut?: string, format?: string}} [options]
|
|
207
|
+
*/
|
|
208
|
+
export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
|
|
209
|
+
const args = ["evolution", "--base", base];
|
|
210
|
+
if (head) args.push("--head", head);
|
|
211
|
+
if (eventOut) args.push("--event-out", eventOut);
|
|
212
|
+
if (format) args.push("--format", format);
|
|
213
|
+
return args;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parses the `--format json` envelope out of a successful evolution run.
|
|
218
|
+
*/
|
|
219
|
+
export function parseEnvelope(run) {
|
|
220
|
+
if (run.exitCode !== EXIT.ok) throw new Error(`evolution exited ${run.exitCode}: ${run.err}`);
|
|
221
|
+
return JSON.parse(run.out);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The parsed event files in `dir`, in filename order. */
|
|
225
|
+
export function readEvents(dir) {
|
|
226
|
+
return readdirSync(dir)
|
|
227
|
+
.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
|
|
228
|
+
.sort()
|
|
229
|
+
.map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The event store's file names in `dir`, in filename order. */
|
|
233
|
+
export function eventFiles(dir) {
|
|
234
|
+
return readdirSync(dir)
|
|
235
|
+
.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
|
|
236
|
+
.sort();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Removes a throwaway workspace. */
|
|
240
|
+
export function dispose(root) {
|
|
241
|
+
rmSync(root, { recursive: true, force: true });
|
|
242
|
+
}
|
|
@@ -77,6 +77,10 @@
|
|
|
77
77
|
* never as an empty ledger.
|
|
78
78
|
*/
|
|
79
79
|
|
|
80
|
+
import { createHash } from "node:crypto";
|
|
81
|
+
|
|
82
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
83
|
+
|
|
80
84
|
import { referenceTime as clockReferenceTime } from "./clock.mjs";
|
|
81
85
|
import { EXPIRED_WAIVER_EVIDENCE, suppressionFate } from "./waiver.mjs";
|
|
82
86
|
|
|
@@ -118,9 +122,122 @@ function owningProjectForPath(path, byName) {
|
|
|
118
122
|
bestRoot = root;
|
|
119
123
|
}
|
|
120
124
|
}
|
|
125
|
+
|
|
121
126
|
return best;
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The stable identity of a debt entry: `sha256` of its canonical `{kind,
|
|
131
|
+
* source}` — the same mechanism `eventId` uses for evolution events (one
|
|
132
|
+
* pattern, one canonicalizer), never the wall clock, a sequence or a random.
|
|
133
|
+
* The same fact must always hash to the same id; any caller that emits these
|
|
134
|
+
* ids into an evolution event's `debt.introduced`/`debt.resolved` MUST use
|
|
135
|
+
* this exact identity, or the event-linked lifecycle will never match the
|
|
136
|
+
* ledger.
|
|
137
|
+
*
|
|
138
|
+
* `expired-waiver` maps back to `waiver`: it is the SAME accepted violation,
|
|
139
|
+
* and its id must not change when its `expiresAt` passes — a fact's identity
|
|
140
|
+
* cannot depend on a transient state.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} kind The entry's `kind`.
|
|
143
|
+
* @param {string} source The entry's `source` (its keying field).
|
|
144
|
+
* @returns {string} The stable hex id.
|
|
145
|
+
*/
|
|
146
|
+
export function entryId(kind, source) {
|
|
147
|
+
const semanticKind = kind === "expired-waiver" ? "waiver" : kind;
|
|
148
|
+
return createHash("sha256")
|
|
149
|
+
.update(canonicalizeJson({ kind: semanticKind, source }))
|
|
150
|
+
.digest("hex");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The stable identity of a structured debt fact: `entryId` over the fact's
|
|
155
|
+
* canonical JSON. Where `entryId(kind, source)` keys on a single string (a
|
|
156
|
+
* waiver path, an unresolved boundary), `debtFactId` keys on the full semantic
|
|
157
|
+
* fact so two distinct facts can never collide on one id.
|
|
158
|
+
*
|
|
159
|
+
* This is the ONE identity the event-linked lifecycle links against: a
|
|
160
|
+
* producer (`change`, `delta`) that emits `debt.introduced`/`debt.resolved`
|
|
161
|
+
* MUST call this exact function with the same structured fact the ledger
|
|
162
|
+
* derives its entry from, or the ids will never match (the broken lifecycle
|
|
163
|
+
* F-DEB-1 exists to close). The fact excludes prose — a reworded message must
|
|
164
|
+
* not re-key a fact (F-DEB-5 drift, F-DEB-8 aspirational gap).
|
|
165
|
+
*
|
|
166
|
+
* @param {string} kind The entry's `kind`.
|
|
167
|
+
* @param {object} fact The structured semantic fact, e.g. a drift finding
|
|
168
|
+
* `{source, target, rule}` or an aspirational gap `{from, to}`.
|
|
169
|
+
* @returns {string} The stable hex id.
|
|
170
|
+
*/
|
|
171
|
+
export function debtFactId(kind, fact) {
|
|
172
|
+
return entryId(kind, canonicalizeJson(fact));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The structured drift fact a judge finding keys on: `{source, target, rule}`
|
|
177
|
+
* over exactly the fields that were judged. Presence findings (projectMissing,
|
|
178
|
+
* projectPresent, projectTagMissing) carry no `target`; the fact spans only
|
|
179
|
+
* the present fields — shared by the ledger's drift entry and every producer
|
|
180
|
+
* that emits `debt.introduced`/`debt.resolved`, so they can never disagree
|
|
181
|
+
* about which id a finding owns.
|
|
182
|
+
*
|
|
183
|
+
* @param {{source?: string, target?: string, rule?: string}} finding A judge
|
|
184
|
+
* finding (`judgeIntent`'s `{source, target, rule, …}`).
|
|
185
|
+
* @returns {{source: string, target?: string, rule?: string}} The drift fact.
|
|
186
|
+
*/
|
|
187
|
+
export function driftFactOf(finding) {
|
|
188
|
+
return {
|
|
189
|
+
source: finding.source,
|
|
190
|
+
...(finding.target === undefined ? {} : { target: finding.target }),
|
|
191
|
+
...(finding.rule === undefined ? {} : { rule: finding.rule }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The debt a base→head transition opened and closed, expressed as ledger ids
|
|
197
|
+
* (design §8: `debt.introduced` names debt created, `debt.resolved` names debt
|
|
198
|
+
* resolved). Two `judgeIntent` verdicts — one over the base graph, one over
|
|
199
|
+
* the head graph, both judged under ONE current intent — diff on the STABLE
|
|
200
|
+
* ids: a finding present at base but gone at head is resolved; present at head
|
|
201
|
+
* but not base is introduced; a fact that never moved is neither. Identity
|
|
202
|
+
* never depends on prose (F-DEB-5 drift, F-DEB-8 aspirational gap), so the
|
|
203
|
+
* ids emitted here are byte-identical to what `computeDebtLedger` derives for
|
|
204
|
+
* the same fact — the ONE home that makes the event-linked lifecycle fire.
|
|
205
|
+
*
|
|
206
|
+
* Aspirational gaps count as debt too: an optional row not built at base but
|
|
207
|
+
* built at head is resolved debt; one that stops being built is introduced.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} baseVerdict A `judgeIntent` result over the base graph.
|
|
210
|
+
* @param {object} headVerdict A `judgeIntent` result over the head graph.
|
|
211
|
+
* @returns {{introduced: string[], resolved: string[]}} Stable debt ids.
|
|
212
|
+
*/
|
|
213
|
+
export function debtChangeDiff(baseVerdict, headVerdict) {
|
|
214
|
+
const driftOf = (v) => (v.findings ?? []).map((f) => debtFactId("drift", driftFactOf(f)));
|
|
215
|
+
const gapOf = (v) =>
|
|
216
|
+
(v.gaps ?? []).map((g) => debtFactId("aspirational-gap", { from: g.from, to: g.to }));
|
|
217
|
+
const baseIds = new Set([...driftOf(baseVerdict), ...gapOf(baseVerdict)]);
|
|
218
|
+
const headIds = new Set([...driftOf(headVerdict), ...gapOf(headVerdict)]);
|
|
219
|
+
const introduced = [...headIds].filter((id) => !baseIds.has(id)).sort();
|
|
220
|
+
const resolved = [...baseIds].filter((id) => !headIds.has(id)).sort();
|
|
221
|
+
return { introduced, resolved };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Reduces an `opts.events` value to a loaded event array, or `null` when no
|
|
225
|
+
* event store is linked. Accepts an already-loaded array or a
|
|
226
|
+
* `{ getEvents(dir) }`-shaped reader (design §4). `null` means "not linked":
|
|
227
|
+
* no `introducedBy`/`resolvedBy` is ever guessed.
|
|
228
|
+
*
|
|
229
|
+
* @param {{events?: object[]|{getEvents?: (dir?: string) => object[]},
|
|
230
|
+
* eventsDir?: string}} opts
|
|
231
|
+
* @returns {object[]|null}
|
|
232
|
+
*/
|
|
233
|
+
function loadEvents(opts) {
|
|
234
|
+
if (!opts.events) return null;
|
|
235
|
+
if (Array.isArray(opts.events)) return opts.events;
|
|
236
|
+
if (typeof opts.events.getEvents === "function")
|
|
237
|
+
return opts.events.getEvents(opts.eventsDir) ?? [];
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
124
241
|
/**
|
|
125
242
|
* The complete ledger over one ordered snapshot set. Deterministic: the same
|
|
126
243
|
* files, the same current facts and the same `referenceTime` produce the same
|
|
@@ -131,17 +248,27 @@ function owningProjectForPath(path, byName) {
|
|
|
131
248
|
* test runs could share. (The ledger's own determinism is about a fixed
|
|
132
249
|
* clock.)
|
|
133
250
|
*
|
|
134
|
-
* @param {{suppressions?: object[], intentNotes?: string[],
|
|
251
|
+
* @param {{suppressions?: object[], intentNotes?: string[], gaps?: {from: string,
|
|
252
|
+
* to: string, note?: string}[], findings?: object[],
|
|
135
253
|
* unresolved?: object[]}} current The current run's candid facts: the loaded
|
|
136
|
-
* boundary config's `suppressions`, `judgeIntent`'s
|
|
137
|
-
* gaps
|
|
254
|
+
* boundary config's `suppressions`, `judgeIntent`'s aspirational-gap facts
|
|
255
|
+
* (`gaps`, structured `{from, to}` — the identity source) with `intentNotes`
|
|
256
|
+
* as their prose display (deprecated dance when `gaps` is absent),
|
|
257
|
+
* `findings` (drift), and `unresolved`.
|
|
138
258
|
* @param {{files: {name: string, envelope: object, id: string}[]}} snapshots
|
|
139
259
|
* From `readSnapshots(dir)`, in history order.
|
|
140
|
-
* @param {{referenceTime?: number|string
|
|
260
|
+
* @param {{referenceTime?: number|string, events?: object[]|{getEvents?: (dir?: string) => object[]},
|
|
261
|
+
* eventsDir?: string}} [opts] `events` links an event store (design §4): an
|
|
262
|
+
* already-loaded array of evolution events, or a `{ getEvents(dir) }`-shaped
|
|
263
|
+
* reader. Absent ⇒ no `introducedBy`/`resolvedBy` is ever set and a
|
|
264
|
+
* `lifecycle.note` states the refs are unavailable — refs are never guessed.
|
|
141
265
|
* @returns {{entries: {source: string, kind: string, severity: string,
|
|
142
|
-
* age: number, count: number, remediationHint: string
|
|
266
|
+
* age: number, count: number, remediationHint: string, id: string,
|
|
267
|
+
* status: "active", introducedBy?: string}[],
|
|
268
|
+
* resolved: {id: string, status: "resolved", resolvedBy: string}[],
|
|
143
269
|
* total: number, byKind: object, bySeverity: object, agings: boolean,
|
|
144
|
-
* sampleTime: string
|
|
270
|
+
* sampleTime: string,
|
|
271
|
+
* lifecycle: {linked: boolean, note: string|null}}}
|
|
145
272
|
*/
|
|
146
273
|
export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
147
274
|
const referenceTime = opts.referenceTime ?? clockReferenceTime();
|
|
@@ -167,7 +294,7 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
167
294
|
|
|
168
295
|
const byName = headProjects(files);
|
|
169
296
|
|
|
170
|
-
/** @type {{source: string, kind: string, severity: string, age: number, count: number, remediationHint: string}[]} */
|
|
297
|
+
/** @type {{source: string, kind: string, severity: string, age: number, count: number, remediationHint: string, id: string, status: "active", introducedBy?: string}[]} */
|
|
171
298
|
const entries = [];
|
|
172
299
|
|
|
173
300
|
for (const suppression of current.suppressions ?? []) {
|
|
@@ -179,28 +306,58 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
179
306
|
// suppression (no `expiresAt`) is `suppress`: still low and permanent.
|
|
180
307
|
const fate = suppressionFate(suppression, sampleTime);
|
|
181
308
|
const expired = fate === "reassert";
|
|
309
|
+
const kind = expired ? "expired-waiver" : "waiver";
|
|
182
310
|
entries.push({
|
|
183
311
|
source: suppression.path,
|
|
184
|
-
kind
|
|
312
|
+
kind,
|
|
185
313
|
severity: expired ? "medium" : "low",
|
|
186
314
|
age: project ? ageOf(project) : 0,
|
|
187
315
|
count: 1,
|
|
316
|
+
id: entryId(kind, suppression.path),
|
|
317
|
+
status: "active",
|
|
188
318
|
remediationHint: expired
|
|
189
319
|
? `the waiver at '${suppression.path}' expired — the boundary it accepted is live again (${EXPIRED_WAIVER_EVIDENCE}); renew it or retire it`
|
|
190
320
|
: `the accepted violation at '${suppression.path}' is still suppressed — ` +
|
|
191
321
|
(project ? `owning project '${project}'` : "retire it or confirm the reason"),
|
|
192
322
|
});
|
|
193
323
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
324
|
+
// Aspirational-gap entries: an `optional: true` `allowed` row not yet built.
|
|
325
|
+
// Identity comes from the STRUCTURED `{from, to}` (F-DEB-8) — never from the
|
|
326
|
+
// prose note, which re-keys every gap when the wording changes. `gaps` is
|
|
327
|
+
// the structured source when a caller threads it (the producer emits the
|
|
328
|
+
// same `{from, to}` ids the ledger derives here); the prose `intentNotes`
|
|
329
|
+
// fallback keys on the note itself only for callers that pass notes with no
|
|
330
|
+
// structured gaps — a deprecated shape, retained so the identity home stays
|
|
331
|
+
// single (the `debtFactId` here is the one the producers must match).
|
|
332
|
+
const gaps = current.gaps ?? [];
|
|
333
|
+
if (gaps.length > 0) {
|
|
334
|
+
for (const gap of gaps) {
|
|
335
|
+
entries.push({
|
|
336
|
+
source: gap.note ?? `${gap.from} → ${gap.to}`,
|
|
337
|
+
kind: "aspirational-gap",
|
|
338
|
+
severity: "low",
|
|
339
|
+
age: 0,
|
|
340
|
+
count: 1,
|
|
341
|
+
id: debtFactId("aspirational-gap", { from: gap.from, to: gap.to }),
|
|
342
|
+
status: "active",
|
|
343
|
+
remediationHint:
|
|
344
|
+
"an optional allowed row is not yet built — either build it or remove the row",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
for (const note of current.intentNotes ?? []) {
|
|
349
|
+
entries.push({
|
|
350
|
+
source: note,
|
|
351
|
+
kind: "aspirational-gap",
|
|
352
|
+
severity: "low",
|
|
353
|
+
age: 0,
|
|
354
|
+
count: 1,
|
|
355
|
+
id: entryId("aspirational-gap", note),
|
|
356
|
+
status: "active",
|
|
357
|
+
remediationHint:
|
|
358
|
+
"an optional allowed row is not yet built — either build it or remove the row",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
204
361
|
}
|
|
205
362
|
|
|
206
363
|
// Which projects hold an accepted waiver — so a drift finding in the same
|
|
@@ -217,12 +374,20 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
217
374
|
for (const finding of current.findings ?? []) {
|
|
218
375
|
const project = typeof finding.source === "string" ? finding.source : null;
|
|
219
376
|
const waiverFailed = project !== null && waiverProjects.has(project);
|
|
377
|
+
// The stable fact keys on the full semantic tuple (source, target, rule)
|
|
378
|
+
// — never on `finding.source` alone, which collides every distinct
|
|
379
|
+
// same-source finding onto one id (F-DEB-5), and never on the prose
|
|
380
|
+
// `finding.message`, which re-keys a fact when the wording changes.
|
|
381
|
+
// `driftFactOf` is the ONE builder both the ledger and the producers use,
|
|
382
|
+
// so an introduced id can never disagree with the ledger's active id.
|
|
220
383
|
entries.push({
|
|
221
384
|
source: finding.source ?? finding.message,
|
|
222
385
|
kind: "drift",
|
|
223
386
|
severity: waiverFailed ? "high" : "medium",
|
|
224
387
|
age: project ? ageOf(project) : 0,
|
|
225
388
|
count: 1,
|
|
389
|
+
id: debtFactId("drift", driftFactOf(finding)),
|
|
390
|
+
status: "active",
|
|
226
391
|
remediationHint: waiverFailed
|
|
227
392
|
? `this drift finding is in a project with an accepted waiver — the accepted violation is failing again, resolve it or remove the waiver`
|
|
228
393
|
: "a dependency the intent forbids (or allows but is not built) — resolve the contradiction",
|
|
@@ -235,6 +400,8 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
235
400
|
severity: "unknown",
|
|
236
401
|
age: 0,
|
|
237
402
|
count: 1,
|
|
403
|
+
id: entryId("unresolved", unresolved.boundary),
|
|
404
|
+
status: "active",
|
|
238
405
|
remediationHint:
|
|
239
406
|
"an intent boundary matched no observed project — the intent cannot be verified",
|
|
240
407
|
});
|
|
@@ -270,5 +437,80 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
|
|
|
270
437
|
if (entry.severity !== "unknown") bySeverity[entry.severity] += 1;
|
|
271
438
|
}
|
|
272
439
|
|
|
273
|
-
|
|
440
|
+
// The lifecycle surface (design §6): every active entry carries a stable id
|
|
441
|
+
// and `status: "active"`. When an event store is linked, REPAIR events name
|
|
442
|
+
// the debt they closed (`debt.resolved`) and introduction events name what
|
|
443
|
+
// they opened (`debt.introduced`); the closure is only accepted when the
|
|
444
|
+
// candidate fact is NOT still active at head (an id that came back is not
|
|
445
|
+
// resolved). Without a linked store, `resolved` stays empty and no ref is
|
|
446
|
+
// ever fabricated — the note states the refs are unavailable instead.
|
|
447
|
+
const events = loadEvents(opts);
|
|
448
|
+
const activeIds = new Set(entries.map((entry) => entry.id));
|
|
449
|
+
/** @type {{id: string, status: "resolved", resolvedBy: string}[]} */
|
|
450
|
+
const resolved = [];
|
|
451
|
+
const lifecycle = { linked: events !== null, note: null };
|
|
452
|
+
|
|
453
|
+
if (events === null) {
|
|
454
|
+
lifecycle.note = "no event store linked — lifecycle refs unavailable";
|
|
455
|
+
} else {
|
|
456
|
+
/** @type {Map<string, string>} id → the first event that introduced it. */
|
|
457
|
+
const introducedByForId = new Map();
|
|
458
|
+
/** @type {Set<string>} debt ids already placed on the resolved list. */
|
|
459
|
+
const resolvedSeen = new Set();
|
|
460
|
+
for (const event of events) {
|
|
461
|
+
// The store validates that every event carries a string id, but the
|
|
462
|
+
// reader shape is loosely typed — coerce so the ref string is always a
|
|
463
|
+
// real string, never a fabricated one.
|
|
464
|
+
const eventId = typeof event?.id === "string" ? event.id : "";
|
|
465
|
+
for (const debtId of event?.debt?.introduced ?? []) {
|
|
466
|
+
if (typeof debtId === "string" && !introducedByForId.has(debtId)) {
|
|
467
|
+
introducedByForId.set(debtId, eventId);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const repairs = Array.isArray(event?.classifications)
|
|
471
|
+
? event.classifications.includes("REPAIR")
|
|
472
|
+
: false;
|
|
473
|
+
if (!repairs) continue;
|
|
474
|
+
for (const debtId of event?.debt?.resolved ?? []) {
|
|
475
|
+
// Closure is only real when the candidate fact is gone at head; a
|
|
476
|
+
// debt id still active is not resolved (closed then re-opened), and
|
|
477
|
+
// an id NO event ever introduced was never debt — resolving it would
|
|
478
|
+
// invent a foreign fact out of nothing (inv. 2/7, F-DEB-2).
|
|
479
|
+
if (
|
|
480
|
+
typeof debtId !== "string" ||
|
|
481
|
+
activeIds.has(debtId) ||
|
|
482
|
+
resolvedSeen.has(debtId) ||
|
|
483
|
+
!introducedByForId.has(debtId)
|
|
484
|
+
)
|
|
485
|
+
continue;
|
|
486
|
+
resolvedSeen.add(debtId);
|
|
487
|
+
// The resolved surface carries ONLY evidence-backed refs. The full
|
|
488
|
+
// original entry (kind, severity, age, count) is not reconstructed
|
|
489
|
+
// here — that record lives on in the history snapshots behind the
|
|
490
|
+
// ledger; `kind:"debt"`, `age:0`, `count:1` would be fabricated
|
|
491
|
+
// numerics (design §6 retains the record, never a stand-in).
|
|
492
|
+
resolved.push({ id: debtId, status: "resolved", resolvedBy: eventId });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (resolved.length > 0 && lifecycle.note === null) {
|
|
496
|
+
lifecycle.note =
|
|
497
|
+
"resolved rows retain only evidence-backed refs — each closed debt's full entry lives in the history snapshots";
|
|
498
|
+
}
|
|
499
|
+
for (const entry of entries) {
|
|
500
|
+
const introducedBy = introducedByForId.get(entry.id);
|
|
501
|
+
if (introducedBy !== undefined) entry.introducedBy = introducedBy;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
resolved.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
entries,
|
|
508
|
+
resolved,
|
|
509
|
+
total: entries.length,
|
|
510
|
+
byKind,
|
|
511
|
+
bySeverity,
|
|
512
|
+
agings,
|
|
513
|
+
sampleTime,
|
|
514
|
+
lifecycle,
|
|
515
|
+
};
|
|
274
516
|
}
|