@ecoma-io/archkeep 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cli.mjs +258 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/adr.mjs +45 -4
- 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/decisions.mjs +291 -0
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +207 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- 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/governance/provenance-record.mjs +150 -0
- package/src/providers/native/model.mjs +18 -4
- package/src/report/adr-text.mjs +109 -4
- package/src/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +122 -1
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -313,6 +313,43 @@ export function findChangeIntentViolations(raw) {
|
|
|
313
313
|
|
|
314
314
|
return violations;
|
|
315
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* The breadth guard (wave 3, design §5): everything wrong with a NORMALIZED
|
|
318
|
+
* intent whose declaration is empty while its own prose asserts a change.
|
|
319
|
+
*
|
|
320
|
+
* An intent with zero declared rows — no project rows, no edge rows, no
|
|
321
|
+
* declared constraints — declares that NOTHING material will change. Its
|
|
322
|
+
* `summary`, if present, is the author's own statement of what the change
|
|
323
|
+
* does; when the rows are empty that statement is a claim nothing in the
|
|
324
|
+
* declaration can be verified against, and the reconciliation would read
|
|
325
|
+
* `matched` over an unchanged tree while the author's prose says the tree
|
|
326
|
+
* moved. Prose cannot assert what rows must state: this is the one bypass
|
|
327
|
+
* around the grammar's reject-by-name discipline, and it is refused loudly
|
|
328
|
+
* (`parseChangeIntent` throws, exit 3 upstream) instead of reconciling.
|
|
329
|
+
*
|
|
330
|
+
* The rule is deliberately stricter than "empty projects/edges": it also
|
|
331
|
+
* requires the constraints section empty. A declared constraint IS a row —
|
|
332
|
+
* `noNewViolations: true` states a verifiable promise, and an intent that
|
|
333
|
+
* declares one is not a catch-all no matter what its summary says.
|
|
334
|
+
*
|
|
335
|
+
* @param {object} intent A `parseChangeIntent` result — the normalized shape,
|
|
336
|
+
* with absent raw sections already normalized to empty arrays.
|
|
337
|
+
* @returns {string[]} Messages; empty when the intent is not a catch-all.
|
|
338
|
+
*/
|
|
339
|
+
export function findChangeIntentBreadthViolations(intent) {
|
|
340
|
+
const declaredRows =
|
|
341
|
+
intent.projects.add.length +
|
|
342
|
+
intent.projects.remove.length +
|
|
343
|
+
intent.edges.add.length +
|
|
344
|
+
intent.edges.remove.length +
|
|
345
|
+
Object.keys(intent.constraints).length;
|
|
346
|
+
if (declaredRows > 0 || intent.summary === undefined) return [];
|
|
347
|
+
return [
|
|
348
|
+
"summary: the intent declares no rows (no projects, no edges, no constraints) while its " +
|
|
349
|
+
"summary asserts a material change — prose cannot assert what rows must state; declare " +
|
|
350
|
+
"the material consequences in the rows, or drop the summary",
|
|
351
|
+
];
|
|
352
|
+
}
|
|
316
353
|
|
|
317
354
|
/**
|
|
318
355
|
* Parses and validates change-intent text into the normalized shape the
|
|
@@ -346,16 +383,18 @@ export function parseChangeIntent(text, path) {
|
|
|
346
383
|
{ cause },
|
|
347
384
|
);
|
|
348
385
|
}
|
|
386
|
+
// Shape validation first, then the breadth guard over the NORMALIZED
|
|
387
|
+
// intent — the guard's subject is the intent as the command will consume
|
|
388
|
+
// it, with absent sections already normalized to empty expectations. Both
|
|
389
|
+
// name every violation at once in the one throw below, so a malformed
|
|
390
|
+
// catch-all is reported whole rather than piecemeal.
|
|
349
391
|
const violations = findChangeIntentViolations(parsed);
|
|
350
|
-
|
|
351
|
-
throw new Error(
|
|
352
|
-
`archkeep: the change intent '${path}' is not a usable contract:\n ` +
|
|
353
|
-
violations.join("\n "),
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
return {
|
|
392
|
+
const intent = {
|
|
357
393
|
version: parsed.version,
|
|
358
|
-
|
|
394
|
+
// Defensive access: an invalid `base` is caught by the shape violations
|
|
395
|
+
// below and thrown before this object is ever returned, so a missing
|
|
396
|
+
// section must not crash the normalization itself.
|
|
397
|
+
base: { commit: parsed.base?.commit },
|
|
359
398
|
...(parsed.summary === undefined ? {} : { summary: parsed.summary }),
|
|
360
399
|
projects: {
|
|
361
400
|
add: parsed.projects?.add ?? [],
|
|
@@ -367,6 +406,14 @@ export function parseChangeIntent(text, path) {
|
|
|
367
406
|
},
|
|
368
407
|
constraints: { ...(parsed.constraints ?? {}) },
|
|
369
408
|
};
|
|
409
|
+
violations.push(...findChangeIntentBreadthViolations(intent));
|
|
410
|
+
if (violations.length > 0) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`archkeep: the change intent '${path}' is not a usable contract:\n ` +
|
|
413
|
+
violations.join("\n "),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return intent;
|
|
370
417
|
}
|
|
371
418
|
|
|
372
419
|
/**
|
package/src/commands/change.mjs
CHANGED
|
@@ -53,6 +53,24 @@
|
|
|
53
53
|
* passing is ok (exit 0). The workspace-law axis rides the envelope as
|
|
54
54
|
* evidence only.
|
|
55
55
|
*
|
|
56
|
+
* ## Wave 3: classification and events (additive)
|
|
57
|
+
*
|
|
58
|
+
* Every result carries the evolution classification (`classifications`,
|
|
59
|
+
* `affected`, `debt` — added fields; nothing existing moved) computed by the
|
|
60
|
+
* one home the predicates live in, `../governance/evolution-event.mjs`'s
|
|
61
|
+
* `classifyEvolution`, from the reconciliation output this command already
|
|
62
|
+
* produced. With `--event-out <dir>` (owned by `../../cli.mjs`), the same
|
|
63
|
+
* facts are persisted as the canonical reconcile EvolutionEvent
|
|
64
|
+
* (`kind: "reconcile"`, `source: "change"`, declaration digest over the
|
|
65
|
+
* intent's declarative rows, disposition by §5's mapping: matched with every
|
|
66
|
+
* constraint passing ⇒ accepted, undeclared/unfulfilled or a failed
|
|
67
|
+
* constraint ⇒ rejected, unproven or an undeterminable constraint ⇒
|
|
68
|
+
* no-verdict). A write failure throws → exit 3; the flag absent means no
|
|
69
|
+
* file is written and the run is byte-identical to a pre-wave-3 one. The
|
|
70
|
+
* breadth guard that refuses an empty-rows intent whose summary asserts a
|
|
71
|
+
* change lives in `./change-intent.mjs`'s validation (`parseChangeIntent`),
|
|
72
|
+
* the same loud lane as every other malformed declaration.
|
|
73
|
+
*
|
|
56
74
|
* Refusals (each a throw → exit 3 upstream): a manifest that fails shape or
|
|
57
75
|
* reference validation, an unreadable/malformed/foreign-schema baseline,
|
|
58
76
|
* incomplete baseline coverage, a provider mismatch, incomplete head
|
|
@@ -85,6 +103,18 @@ import { formatChangeReport } from "../report/change-text.mjs";
|
|
|
85
103
|
import { evaluateRun } from "../rules/index.mjs";
|
|
86
104
|
import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
87
105
|
import { resolveProvenance } from "./provenance.mjs";
|
|
106
|
+
import { referenceTime } from "../governance/clock.mjs";
|
|
107
|
+
import {
|
|
108
|
+
classifyEvolution,
|
|
109
|
+
declarationDigest,
|
|
110
|
+
eventDedupeKey,
|
|
111
|
+
eventId,
|
|
112
|
+
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
113
|
+
} from "../governance/evolution-event.mjs";
|
|
114
|
+
import { writeEvent } from "../governance/evolution-store.mjs";
|
|
115
|
+
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
116
|
+
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
117
|
+
import { debtChangeDiff } from "../governance/debt-ledger.mjs";
|
|
88
118
|
|
|
89
119
|
/**
|
|
90
120
|
* One expected-fact row as the report and JSON carry it. Kept in one builder
|
|
@@ -111,6 +141,81 @@ function cmpFacts(a, b) {
|
|
|
111
141
|
);
|
|
112
142
|
}
|
|
113
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The identity string a delta-classified violation entry carries into the
|
|
146
|
+
* event's `findings` — the same identity fields the delta classification
|
|
147
|
+
* emits (`messageId`, `sourceProject`, `target`), serialized deterministically
|
|
148
|
+
* so the ref is stable across runs over the same transition.
|
|
149
|
+
*
|
|
150
|
+
* @param {{messageId: string, sourceProject: string|null, target: string}} entry
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
function violationFindingId(entry) {
|
|
154
|
+
return `${entry.messageId}:${entry.sourceProject ?? "-"}:${entry.target}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The identity string an observed edge carries into the event's `observed`
|
|
159
|
+
* and `affected` — `(source, target, type)`, the triple `./diff.mjs`'s
|
|
160
|
+
* `edgeIdentityKey` owns, spelled for a human reader (`>` separator, optional
|
|
161
|
+
* type suffix). Two spellings of one triple never diverge because the triple
|
|
162
|
+
* itself is the input.
|
|
163
|
+
*
|
|
164
|
+
* @param {{source: string, target: string, type?: string}} edge
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
function edgeIdentityString(edge) {
|
|
168
|
+
return `${edge.source}>${edge.target}${edge.type === undefined || edge.type === "" ? "" : `:${edge.type}`}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The structural-diff facts in the event's `observed` shape (design §1),
|
|
173
|
+
* mapped from `computeDiff`'s output and the metadata comparison — the same
|
|
174
|
+
* lists the reconciliation already consumed, never recomputed.
|
|
175
|
+
*
|
|
176
|
+
* @param {{addedProjects: object[], removedProjects: object[],
|
|
177
|
+
* changedProjects: object[], addedEdges: object[], removedEdges: object[]}} structural
|
|
178
|
+
* @param {{policyChanged: boolean|null, policyOneSided: boolean,
|
|
179
|
+
* provenanceChanged: boolean|null}} meta From `compareSnapshotMetadata`.
|
|
180
|
+
* @returns {{architectureChanged: boolean, projects: {added: string[],
|
|
181
|
+
* removed: string[], changed: string[]}, edges: {added: string[],
|
|
182
|
+
* removed: string[]}, policyChanged: boolean|null, policyOneSided: boolean,
|
|
183
|
+
* provenanceChanged: boolean|null, providerChanged: boolean}}
|
|
184
|
+
*/
|
|
185
|
+
function observedFrom(structural, meta) {
|
|
186
|
+
const projects = {
|
|
187
|
+
added: structural.addedProjects.map((project) => project.name),
|
|
188
|
+
removed: structural.removedProjects.map((project) => project.name),
|
|
189
|
+
changed: structural.changedProjects.map((project) => project.name),
|
|
190
|
+
};
|
|
191
|
+
const edges = {
|
|
192
|
+
added: structural.addedEdges.map(edgeIdentityString),
|
|
193
|
+
removed: structural.removedEdges.map(edgeIdentityString),
|
|
194
|
+
};
|
|
195
|
+
return {
|
|
196
|
+
architectureChanged:
|
|
197
|
+
projects.added.length +
|
|
198
|
+
projects.removed.length +
|
|
199
|
+
projects.changed.length +
|
|
200
|
+
edges.added.length +
|
|
201
|
+
edges.removed.length >
|
|
202
|
+
0,
|
|
203
|
+
projects,
|
|
204
|
+
edges,
|
|
205
|
+
// `null` is "could not be compared": exactly one side records the law
|
|
206
|
+
// (`policyOneSided`) or neither does — passed through so classifyEvolution
|
|
207
|
+
// discloses rather than reading it as "the same", and the advanced
|
|
208
|
+
// both-absent pair never classifies as unchanged
|
|
209
|
+
// (`../governance/evolution-event.mjs`).
|
|
210
|
+
policyChanged: meta.policyChanged,
|
|
211
|
+
policyOneSided: meta.policyOneSided,
|
|
212
|
+
provenanceChanged: meta.provenanceChanged,
|
|
213
|
+
// The provider mismatch refuses before reconciliation ever runs, so a
|
|
214
|
+
// reconciliable pair is by construction same-provider.
|
|
215
|
+
providerChanged: false,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
114
219
|
/**
|
|
115
220
|
* Reconciles a normalized change-intent contract against the structural diff
|
|
116
221
|
* of the two graphs. Pure — the heart of the command, and the function a
|
|
@@ -244,6 +349,37 @@ export function reconciliationVerdict(lists, unprovenReasons) {
|
|
|
244
349
|
return "matched";
|
|
245
350
|
}
|
|
246
351
|
|
|
352
|
+
/**
|
|
353
|
+
* The reconcile DISPOSITION mapping (wave 3, design §5) — the verdict axis
|
|
354
|
+
* first, then the declared constraints' verdicts. Pure and exported so the
|
|
355
|
+
* mapping is a fact a test can pin:
|
|
356
|
+
*
|
|
357
|
+
* | verdict | disposition |
|
|
358
|
+
* |---|---|
|
|
359
|
+
* | `matched`, every declared constraint passing | `accepted` |
|
|
360
|
+
* | `matched`, a declared constraint failing | `rejected` — the change's own
|
|
361
|
+
* promise was broken, the same lane design §2 gives a constraint `fail` |
|
|
362
|
+
* | `undeclared` / `unfulfilled` | `rejected` |
|
|
363
|
+
* | `unproven`, or any declared constraint `unknown` | `no-verdict` — an
|
|
364
|
+
* undetermined axis never reads as an acceptance |
|
|
365
|
+
*
|
|
366
|
+
* `no-verdict` is never a degraded `accepted`: the mapping refuses to
|
|
367
|
+
* fabricate either direction when the run could not determine it.
|
|
368
|
+
*
|
|
369
|
+
* @param {"matched"|"undeclared"|"unfulfilled"|"unproven"} verdict
|
|
370
|
+
* @param {{verdict: string}[]} [constraints] The judged constraint rows from
|
|
371
|
+
* this run — empty when none were declared or when the base identity was
|
|
372
|
+
* unproven (constraints are left unevaluated then).
|
|
373
|
+
* @returns {"accepted"|"rejected"|"no-verdict"}
|
|
374
|
+
*/
|
|
375
|
+
export function reconcileDisposition(verdict, constraints = []) {
|
|
376
|
+
if (verdict === "unproven") return "no-verdict";
|
|
377
|
+
if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
|
|
378
|
+
if (constraints.some((row) => row.verdict === "unknown")) return "no-verdict";
|
|
379
|
+
if (constraints.some((row) => row.verdict === "fail")) return "rejected";
|
|
380
|
+
return "accepted";
|
|
381
|
+
}
|
|
382
|
+
|
|
247
383
|
/**
|
|
248
384
|
* Judges the constraints the contract declares, through the shared engine —
|
|
249
385
|
* both sides re-judged under the CURRENT law and one shared instant, exactly
|
|
@@ -335,14 +471,24 @@ function judgeDeclaredConstraints(intent, io) {
|
|
|
335
471
|
* @param {string} intentPath Absolute path to the change-intent manifest.
|
|
336
472
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
337
473
|
* @param {{config?: object|null, readBaseline?: (path: string) => object,
|
|
338
|
-
* readIntent?: (path: string) => Promise<object>, now?: string
|
|
474
|
+
* readIntent?: (path: string) => Promise<object>, now?: string,
|
|
475
|
+
* loadIntentOverride?: (root: string, opts?: object) => Promise<object|undefined>,
|
|
476
|
+
* eventOut?: string, writeEvent?: (dir: string, event: object,
|
|
477
|
+
* io?: object) => {id: string, duplicate: boolean}}} [io]
|
|
339
478
|
* The resolved boundary config (required — constraints are judged under it
|
|
340
479
|
* and the fingerprint identifies the law), injectable readers so tests drive
|
|
341
|
-
* the command without disk,
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
480
|
+
* the command without disk, the shared reference instant, and the wave-3
|
|
481
|
+
* event surface: `eventOut` names the directory the reconcile EvolutionEvent
|
|
482
|
+
* is written to (absent ⇒ no event file, byte-identical run), and
|
|
483
|
+
* `writeEvent` is the store seam defaulting to the canonical append-only
|
|
484
|
+
* store. Both readers return VALIDATED domain objects —
|
|
485
|
+
* `readEvidenceSnapshot`'s parse and `parseChangeIntent`'s normalized
|
|
486
|
+
* contract respectively — the same seam contract `deltaCommand`'s
|
|
487
|
+
* `readBaseline` states; an injector handing back raw file contents skips
|
|
488
|
+
* the grammar this command trusts. `loadIntentOverride` is the same
|
|
489
|
+
* architecture-intent seam `drift` uses (defaults to `loadIntent`); the
|
|
490
|
+
* change event's `debt` diff judges the intent over this run's base and
|
|
491
|
+
* head graphs and would be untestable without it.
|
|
346
492
|
* @returns {Promise<{status: "ok"|"findings"|"no-verdict",
|
|
347
493
|
* changeIntent: object, coverage: object, report: {text: string, json: string}}>}
|
|
348
494
|
* @throws {Error} on every refusal the module header lists.
|
|
@@ -351,9 +497,27 @@ export async function changeCommand(
|
|
|
351
497
|
baselinePath,
|
|
352
498
|
intentPath,
|
|
353
499
|
commandContext,
|
|
354
|
-
{
|
|
500
|
+
{
|
|
501
|
+
config,
|
|
502
|
+
readBaseline = readEvidenceSnapshot,
|
|
503
|
+
readIntent,
|
|
504
|
+
now,
|
|
505
|
+
// Wave 3 (design §4): the directory the reconcile EvolutionEvent is
|
|
506
|
+
// written to. Absent ⇒ no event file is written and the run is
|
|
507
|
+
// byte-identical to a pre-wave-3 run; the classification still rides the
|
|
508
|
+
// envelope result either way.
|
|
509
|
+
eventOut,
|
|
510
|
+
// The architecture-intent reader seam (design §8): the change event's
|
|
511
|
+
// `debt` diff judges the SAME intent file `drift`/`debt` judge over this
|
|
512
|
+
// run's base and head graphs. Injectable so tests drive it without disk;
|
|
513
|
+
// defaults to the canonical loader. Absent intent ⇒ no ids are emitted.
|
|
514
|
+
loadIntentOverride,
|
|
515
|
+
// The store seam, injectable so tests drive the write without disk and
|
|
516
|
+
// embedders can route it; defaults to the canonical append-only store.
|
|
517
|
+
writeEvent: writeEventSeam = writeEvent,
|
|
518
|
+
} = {},
|
|
355
519
|
) {
|
|
356
|
-
const { root, provider, marker, graph, analysis } = commandContext;
|
|
520
|
+
const { root, provider, marker, graph, analysis, tracked } = commandContext;
|
|
357
521
|
|
|
358
522
|
// The one required member of `io`: constraints are judged under the current
|
|
359
523
|
// law and the envelope records which law that was, so an undefined config
|
|
@@ -452,12 +616,23 @@ export async function changeCommand(
|
|
|
452
616
|
/** @type {object[]} */
|
|
453
617
|
let constraints = [];
|
|
454
618
|
let liveViolations = null;
|
|
619
|
+
// The classification is a wave-3 event signal too, so it is held at
|
|
620
|
+
// function scope: `null` when the base identity is unproven (nothing was
|
|
621
|
+
// evaluated, and the event must not fabricate violation facts).
|
|
622
|
+
/** @type {ReturnType<typeof classifyDelta>|null} */
|
|
623
|
+
let classification = null;
|
|
624
|
+
// The base engine graph is hoisted (not block-scoped) because the change
|
|
625
|
+
// event's `debt` diff below re-judges the architecture intent over BOTH
|
|
626
|
+
// sides. It exists only when the base identity is provable; `null` here is
|
|
627
|
+
// the F-CHG-1 signal that no debt diff is trustworthy and none will be
|
|
628
|
+
// emitted (an unproven base must never fabricate ledger ids).
|
|
629
|
+
const baseEngineGraph =
|
|
630
|
+
unprovenReasons.length === 0 ? evidenceGraphToProjectGraph(baseline.graph) : null;
|
|
455
631
|
if (unprovenReasons.length === 0) {
|
|
456
632
|
const configWithNow = now === undefined ? config : { ...config, now };
|
|
457
|
-
const baseEngineGraph = evidenceGraphToProjectGraph(baseline.graph);
|
|
458
633
|
const baseRaw = evaluateRun(baseline.records, baseEngineGraph, configWithNow).rawViolations;
|
|
459
634
|
const headEval = evaluateRun(analysis.imports, graph, configWithNow);
|
|
460
|
-
|
|
635
|
+
classification = classifyDelta({
|
|
461
636
|
baseViolations: baseRaw,
|
|
462
637
|
headViolations: headEval.rawViolations,
|
|
463
638
|
baseRecords: baseline.records,
|
|
@@ -547,6 +722,140 @@ export async function changeCommand(
|
|
|
547
722
|
notes,
|
|
548
723
|
};
|
|
549
724
|
|
|
725
|
+
// Wave 3 (design §1, §2, §5): the evolution classification and the reconcile
|
|
726
|
+
// event. The classification is ALWAYS computed — it rides the envelope
|
|
727
|
+
// result in memory even when no event is written (design §4) — and the
|
|
728
|
+
// event is persisted only when `eventOut` names a directory. The signals
|
|
729
|
+
// are mapped from the reconciliation output already computed above; nothing
|
|
730
|
+
// here re-derives what `diff`, `classifyDelta` or `classifyEvolution`
|
|
731
|
+
// answered (`../governance/evolution-event.mjs` is the predicates' one
|
|
732
|
+
// home). When the base identity is unproven the constraints were left
|
|
733
|
+
// unevaluated, so no violation signal exists — the classification then
|
|
734
|
+
// answers `no-verdict` from the declared-intent row alone, never a
|
|
735
|
+
// fabricated acceptance.
|
|
736
|
+
const observed = observedFrom(structural, meta);
|
|
737
|
+
/** @type {{id: string, waived: boolean}[]} */
|
|
738
|
+
const introducedViolations = (classification?.violations.introduced ?? []).map((entry) => ({
|
|
739
|
+
id: violationFindingId(entry),
|
|
740
|
+
waived: entry.waived === true,
|
|
741
|
+
}));
|
|
742
|
+
const resolvedViolations = (classification?.violations.resolved ?? []).map(violationFindingId);
|
|
743
|
+
const unknownViolations = (classification?.violations.unknown ?? []).map((entry) => ({
|
|
744
|
+
id: `unidentified:${entry.reason}`,
|
|
745
|
+
reason: entry.reason,
|
|
746
|
+
}));
|
|
747
|
+
const evolution = classifyEvolution({
|
|
748
|
+
observed,
|
|
749
|
+
...(classification === null
|
|
750
|
+
? {}
|
|
751
|
+
: {
|
|
752
|
+
violations: {
|
|
753
|
+
introduced: introducedViolations,
|
|
754
|
+
resolved: resolvedViolations,
|
|
755
|
+
unknown: unknownViolations,
|
|
756
|
+
},
|
|
757
|
+
}),
|
|
758
|
+
declaredConstraints: constraints.map((row) => ({ id: row.name, verdict: row.verdict })),
|
|
759
|
+
declaredIntentRows: [{ id: "intent", verdict }],
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Debt impact: the architecture-lifecycle debt THIS change opened or closed,
|
|
763
|
+
// as stable ids the W5 ledger derives for the same intent facts (design §8)
|
|
764
|
+
// — the SAME fact → the SAME id, so an event's `debt.introduced` links the
|
|
765
|
+
// ledger's `introducedBy`. The ids come from a `judgeIntent` diff over the
|
|
766
|
+
// run's base and head graphs under ONE current intent: a drift finding (or
|
|
767
|
+
// unbuilt aspirational gap) present at head but not base is introduced; one
|
|
768
|
+
// gone at head is resolved. Constraint-fail rows carry no source/target/rule
|
|
769
|
+
// — because they are not drift, they stay in `affected.constraints` and
|
|
770
|
+
// `findings`, never in `debt`. An unproven base (F-CHG-1) or an unjudgeable
|
|
771
|
+
// intent is a no-verdict: no ids are emitted, an in-band note says so, and a
|
|
772
|
+
// change run never fabricates ledger ids over evidence it cannot vouch for.
|
|
773
|
+
/** @type {{introduced: string[], resolved: string[], note?: string}} */
|
|
774
|
+
let debt;
|
|
775
|
+
if (baseEngineGraph === null) {
|
|
776
|
+
debt = {
|
|
777
|
+
introduced: [],
|
|
778
|
+
resolved: [],
|
|
779
|
+
note: "base identity unproven — no architecture debt diff can be trusted",
|
|
780
|
+
};
|
|
781
|
+
} else {
|
|
782
|
+
try {
|
|
783
|
+
const archIntent = await (loadIntentOverride ?? loadIntent)(root, { tracked });
|
|
784
|
+
if (archIntent === undefined || archIntent === null) {
|
|
785
|
+
debt = {
|
|
786
|
+
introduced: [],
|
|
787
|
+
resolved: [],
|
|
788
|
+
note: `no '${INTENT_FILE}' tracked — the change event carries no architecture debt ids`,
|
|
789
|
+
};
|
|
790
|
+
} else {
|
|
791
|
+
const baseVerdict = judgeIntent(archIntent, baseEngineGraph);
|
|
792
|
+
const headVerdict = judgeIntent(archIntent, graph);
|
|
793
|
+
debt = debtChangeDiff(baseVerdict, headVerdict);
|
|
794
|
+
}
|
|
795
|
+
} catch (error) {
|
|
796
|
+
debt = {
|
|
797
|
+
introduced: [],
|
|
798
|
+
resolved: [],
|
|
799
|
+
note: `architecture intent could not be judged — no debt ids emitted (${error.message})`,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const baseCommit = baseline.provenance?.commit;
|
|
805
|
+
const headCommit = headProvenance?.commit;
|
|
806
|
+
/** @type {object} */
|
|
807
|
+
const event = {
|
|
808
|
+
schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
809
|
+
kind: "reconcile",
|
|
810
|
+
source: "change",
|
|
811
|
+
base: {
|
|
812
|
+
...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
|
|
813
|
+
// The caller's own evidence ref — the baseline file this run consumed,
|
|
814
|
+
// spelled as the run received it (the same convention `declaration.file`
|
|
815
|
+
// uses for the intent path).
|
|
816
|
+
evidence: baselinePath,
|
|
817
|
+
},
|
|
818
|
+
head: {
|
|
819
|
+
...(typeof headCommit === "string" ? { revision: headCommit } : {}),
|
|
820
|
+
},
|
|
821
|
+
declaration: { file: intentPath, digest: declarationDigest(intent) },
|
|
822
|
+
observed,
|
|
823
|
+
affected: evolution.affected,
|
|
824
|
+
findings: {
|
|
825
|
+
introduced: introducedViolations.map((entry) => entry.id),
|
|
826
|
+
resolved: resolvedViolations,
|
|
827
|
+
unknown: unknownViolations.map((entry) => entry.id),
|
|
828
|
+
},
|
|
829
|
+
// The verdictDeltas are the DECLARED constraints judged over the delta —
|
|
830
|
+
// a change run has no separate base-verdict pass, so each row states the
|
|
831
|
+
// delta verdict itself rather than a pair that does not exist.
|
|
832
|
+
fitness: {
|
|
833
|
+
verdictDeltas: constraints.map((row) => ({ id: row.name, verdict: row.verdict })),
|
|
834
|
+
},
|
|
835
|
+
debt,
|
|
836
|
+
classifications: evolution.classifications,
|
|
837
|
+
disposition: evolution.disposition,
|
|
838
|
+
notes: evolution.notes,
|
|
839
|
+
provenance: typeof headCommit === "string" ? [{ kind: "git-commit", ref: headCommit }] : [],
|
|
840
|
+
// The governance clock — the same injected `now` seam the rest of the run
|
|
841
|
+
// uses, the wall clock only when nothing was injected. It is EXCLUDED
|
|
842
|
+
// from the identity by `evolution-event.mjs` by construction.
|
|
843
|
+
recordedAt: { by: "change", tool: "archkeep:v1", on: now ?? referenceTime() },
|
|
844
|
+
};
|
|
845
|
+
event.dedupeKey = eventDedupeKey(event);
|
|
846
|
+
event.id = eventId(event);
|
|
847
|
+
|
|
848
|
+
/** @type {{dir: string, id: string, duplicate: boolean}|null} */
|
|
849
|
+
let eventWritten = null;
|
|
850
|
+
if (eventOut !== undefined && eventOut !== null && eventOut !== "") {
|
|
851
|
+
// The store's io.root is the workspace root: the write must be provable
|
|
852
|
+
// to stay inside the workspace (the same containment `--output` obeys).
|
|
853
|
+
// A write failure throws — exit 3 upstream, the could-not-look lane —
|
|
854
|
+
// never a silent "event recorded" that wrote nothing.
|
|
855
|
+
const write = writeEventSeam(eventOut, event, { root });
|
|
856
|
+
eventWritten = { dir: eventOut, ...write };
|
|
857
|
+
}
|
|
858
|
+
|
|
550
859
|
const result = {
|
|
551
860
|
intent: {
|
|
552
861
|
file: intentPath,
|
|
@@ -588,6 +897,12 @@ export async function changeCommand(
|
|
|
588
897
|
changedSinceBase: meta.policyChanged === true,
|
|
589
898
|
liveViolations,
|
|
590
899
|
},
|
|
900
|
+
// Wave 3 additive result fields (design §5): the evolution classification
|
|
901
|
+
// and the debt impact. Present on every run, `--event-out` or not — the
|
|
902
|
+
// event's classification rides the envelope in memory (design §4).
|
|
903
|
+
classifications: evolution.classifications,
|
|
904
|
+
affected: evolution.affected,
|
|
905
|
+
debt,
|
|
591
906
|
};
|
|
592
907
|
|
|
593
908
|
const envelope = jsonEnvelope({
|
|
@@ -605,7 +920,13 @@ export async function changeCommand(
|
|
|
605
920
|
changeIntent: result,
|
|
606
921
|
coverage,
|
|
607
922
|
report: {
|
|
608
|
-
text: formatChangeReport({
|
|
923
|
+
text: formatChangeReport({
|
|
924
|
+
change: result,
|
|
925
|
+
coverage,
|
|
926
|
+
// Present only when an event was written — the text report stays
|
|
927
|
+
// byte-identical when `--event-out` is absent.
|
|
928
|
+
...(eventWritten === null ? {} : { eventWritten }),
|
|
929
|
+
}),
|
|
609
930
|
json: renderJson(envelope),
|
|
610
931
|
},
|
|
611
932
|
};
|
package/src/commands/debt.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
|
49
49
|
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
50
50
|
import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
|
|
51
51
|
import { computeDebtLedger } from "../governance/debt-ledger.mjs";
|
|
52
|
+
import { readEvents } from "../governance/evolution-store.mjs";
|
|
52
53
|
import { formatDebtReport } from "../report/debt-text.mjs";
|
|
53
54
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
54
55
|
import { resolveProvenance } from "./provenance.mjs";
|
|
@@ -68,9 +69,12 @@ import { readSnapshots } from "./history.mjs";
|
|
|
68
69
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
69
70
|
* @param {{config?: {depConstraints: object[], options: object,
|
|
70
71
|
* suppressions: object[]}|null, referenceTime?: number|string,
|
|
71
|
-
* io?: {readSnapshots?: Function,
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* events?: string|null, io?: {readSnapshots?: Function,
|
|
73
|
+
* loadIntentOverride?: Function, resolveProvenance?: Function,
|
|
74
|
+
* readEvents?: Function}}} [options] Injectable seams for tests, mirroring
|
|
75
|
+
* the `history`/`drift` pattern. `events` is the event-store directory the
|
|
76
|
+
* `--events <dir>` CLI flag resolves; absent ⇒ no lifecycle refs (the
|
|
77
|
+
* ledger says so in `lifecycle.note`), never guessed.
|
|
74
78
|
* @returns {Promise<{status: "ok", ledger: object, coverage: object,
|
|
75
79
|
* report: {text: string, json: string}}>}
|
|
76
80
|
* @throws {Error} on every condition the header lists, all exit-3 class.
|
|
@@ -140,14 +144,26 @@ export async function debtCommand(dir, commandContext, options = {}) {
|
|
|
140
144
|
);
|
|
141
145
|
}
|
|
142
146
|
|
|
147
|
+
// The optional event linkage (design §4): `--events <dir>` reads the
|
|
148
|
+
// evolution store so active debt can carry `introducedBy` and closed debt
|
|
149
|
+
// can appear on the `resolved` list. Absent ⇒ nothing is loaded and the
|
|
150
|
+
// ledger records no refs ("no event store linked" note); refs are never
|
|
151
|
+
// guessed.
|
|
152
|
+
const loadStoreEvents = io.readEvents ?? readEvents;
|
|
153
|
+
const events = options.events ? loadStoreEvents(options.events) : null;
|
|
154
|
+
|
|
143
155
|
const ledger = computeDebtLedger(
|
|
144
156
|
{
|
|
145
157
|
suppressions: config.suppressions,
|
|
146
158
|
intentNotes: verdict.notes,
|
|
159
|
+
gaps: verdict.gaps,
|
|
147
160
|
findings: verdict.findings,
|
|
148
161
|
},
|
|
149
162
|
read,
|
|
150
|
-
{
|
|
163
|
+
{
|
|
164
|
+
referenceTime: options.referenceTime,
|
|
165
|
+
events,
|
|
166
|
+
},
|
|
151
167
|
);
|
|
152
168
|
|
|
153
169
|
const observed = buildObserved(commandContext);
|
|
@@ -168,7 +184,9 @@ export async function debtCommand(dir, commandContext, options = {}) {
|
|
|
168
184
|
// so a consumer diffing or hashing two envelopes to detect real drift
|
|
169
185
|
// knows to exclude it rather than read clock drift as architectural
|
|
170
186
|
// change; every other field is deterministic given the same law, history
|
|
171
|
-
// directory and tree.
|
|
187
|
+
// directory and tree. The lifecycle note (when the store is not linked)
|
|
188
|
+
// states the same posture for refs: they are absent, disclosed, never
|
|
189
|
+
// fabricated.
|
|
172
190
|
notes: [
|
|
173
191
|
`ledger ages are snapshot-relative; ${ledger.entries.length} entr` +
|
|
174
192
|
`${ledger.entries.length === 1 ? "y" : "ies"} derived across ${read.files.length} snapshot` +
|
|
@@ -177,6 +195,7 @@ export async function debtCommand(dir, commandContext, options = {}) {
|
|
|
177
195
|
"workspace — it is expected to differ between two runs of an unchanged tree and " +
|
|
178
196
|
"should be excluded from any diff or hash meant to detect real change. Every other " +
|
|
179
197
|
"field here is deterministic given the same law, history directory and tree.",
|
|
198
|
+
...(ledger.lifecycle.note ? [ledger.lifecycle.note] : []),
|
|
180
199
|
],
|
|
181
200
|
};
|
|
182
201
|
|
|
@@ -192,9 +211,11 @@ export async function debtCommand(dir, commandContext, options = {}) {
|
|
|
192
211
|
agings: ledger.agings,
|
|
193
212
|
sampleTime: ledger.sampleTime,
|
|
194
213
|
entries: ledger.entries,
|
|
214
|
+
resolved: ledger.resolved,
|
|
195
215
|
total: ledger.total,
|
|
196
216
|
byKind: ledger.byKind,
|
|
197
217
|
bySeverity: ledger.bySeverity,
|
|
218
|
+
lifecycle: ledger.lifecycle,
|
|
198
219
|
};
|
|
199
220
|
|
|
200
221
|
const envelope = jsonEnvelope({
|