@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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
@@ -44,6 +44,40 @@
44
44
  * plugin but whose tracked files include polyglot manifests under project
45
45
  * roots, `explain` refuses loudly rather than explaining a judgment from a
46
46
  * graph whose edges silently under-represent the real architecture.
47
+ *
48
+ * ## The "why does this constraint exist" chain
49
+ *
50
+ * A constraint row that carries a `decisionRef` claims a governing decision —
51
+ * an ADR record, or a rule/fitness id the law declares. `explain` resolves
52
+ * that claim against the workspace's ADR registry and walks it through the
53
+ * governance graph (`../governance/decision-graph.mjs`), surfacing the
54
+ * decision's status and authority, its rationale/context prose, and its
55
+ * supersession lineage — not just the pointer. It is read-only, exactly like
56
+ * the rest of `explain`: an agent is shown WHY the row exists, never handed
57
+ * the authority to change the decision or the verdict.
58
+ *
59
+ * Resolution never changes the exit code and never invents a fact. A
60
+ * `decisionRef` that resolves to nothing — or a registry that cannot be read
61
+ * at all — renders as UNRESOLVED, the same loud wording `check`/`report`
62
+ * use; a `rule:`/`fitness:`-shaped ref that the law declares renders as
63
+ * exactly that, a fitness rule this law declares. Only an unresolved site or
64
+ * incomplete analysis moves `status`, so an unchanged tree pays no changed
65
+ * exit code. The registry is read lazily, only when a matched row actually
66
+ * carries a `decisionRef` — the common case (no `docs/adr/` adopted yet)
67
+ * pays no extra read.
68
+ *
69
+ * ## The lineage comparison (optional, additive)
70
+ *
71
+ * `explainCommand` accepts an optional `options.baseRegistry` — a base
72
+ * ADR registry state to compare the workspace's current one against. When it
73
+ * is supplied, the resolved-site explanation gains a `decisionChange` field:
74
+ * whether the decision lineage moved between the two states
75
+ * (`detectDecisionChange`, `../governance/decision-lineage.mjs` —
76
+ * DECISION_CHANGE, never DRIFT, and never asserted without both registry
77
+ * states: a one-sided base renders as a "not comparable" note). Without the
78
+ * option — every current caller — the explanation is byte-for-byte what it
79
+ * was: the field, and its rendered lines, exist only when the comparison was
80
+ * requested.
47
81
  */
48
82
  import { isWholeFileFailure } from "../analysis/source-util.mjs";
49
83
  import { UsageError } from "../errors.mjs";
@@ -53,6 +87,16 @@ import { findProjectForPath, createProjectRootMappings } from "../rules/specifie
53
87
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
54
88
  import { formatExplainReport } from "../report/explain-text.mjs";
55
89
  import { resolveProvenance } from "./provenance.mjs";
90
+ import { readAdrContext } from "./adr.mjs";
91
+ import { lineage } from "../governance/decision-graph.mjs";
92
+ import { unresolvedDecisionRefNote } from "./provenance-command.mjs";
93
+ import { detectDecisionChange } from "../governance/decision-lineage.mjs";
94
+ import {
95
+ declaredFitnessNames,
96
+ hasAuthority,
97
+ resolveDecisionRef,
98
+ stripAdrPrefix,
99
+ } from "../governance/adr-registry.mjs";
56
100
 
57
101
  /**
58
102
  * Parses a `file:line:column` site string into its components.
@@ -138,6 +182,133 @@ export function findSite(parsed, imports) {
138
182
  function findMatchingConstraints(depConstraints, sourceProjectNode) {
139
183
  return findConstraintsFor(depConstraints, sourceProjectNode);
140
184
  }
185
+ /**
186
+ * Resolves the "why does this constraint exist" chain for every distinct
187
+ * `decisionRef` the matched constraint rows carry, in first-sight order.
188
+ *
189
+ * A `decisionRef` names the ADR (or rule/fitness id) that authorizes the row.
190
+ * Resolution is the registry's own (`resolveDecisionRef`); the walk that
191
+ * surfaces status/authority/rationale/context and lineage is the governance
192
+ * graph's (`lineage`). Failures are named, never silent:
193
+ *
194
+ * - a registry that cannot be read resolves nothing — every ref is `unknown`
195
+ * with the read failure as its reason, the same posture `report` takes for
196
+ * the same condition;
197
+ * - a ref that resolves to no ADR, rule, or fitness record the registry knows
198
+ * is `unknown`, with `unresolvedDecisionRefNote`'s shared wording.
199
+ *
200
+ * Deterministic: matched-row order, distinct refs deduplicated on first
201
+ * sight, and the walk's own registry (byte-sorted filename) order. Empty when
202
+ * no matched row carries a `decisionRef` — the caller then changes no byte of
203
+ * its explanation.
204
+ *
205
+ * @param {object[]} matchedConstraints The constraint rows that matched the
206
+ * explained site.
207
+ * @param {string} root The workspace root, for the registry read.
208
+ * @param {string[]} tracked Tracked files, for the registry read.
209
+ * @param {object} config The loaded boundary config, for the declared-name
210
+ * half of `resolveDecisionRef`.
211
+ * @returns {object[]} One entry per distinct ref: `resolution` is the
212
+ * registry's own `"adr" | "fitness" | "unknown"`; an `"adr"` entry carries
213
+ * the record's authority and prose facts plus the lineage walk.
214
+ */
215
+ function resolveDecisionChains(matchedConstraints, root, tracked, config) {
216
+ const refs = [];
217
+ for (const row of matchedConstraints) {
218
+ if (typeof row?.decisionRef !== "string" || row.decisionRef.trim() === "") continue;
219
+ if (!refs.includes(row.decisionRef)) refs.push(row.decisionRef);
220
+ }
221
+ if (refs.length === 0) return [];
222
+
223
+ let registry = null;
224
+ let registryReason = null;
225
+ try {
226
+ registry = readAdrContext(root, { tracked });
227
+ } catch (error) {
228
+ registryReason = String(error?.message ?? error);
229
+ }
230
+
231
+ const knownFitness = declaredFitnessNames(config);
232
+ return refs.map((ref) => {
233
+ if (registry === null) {
234
+ return {
235
+ ref,
236
+ resolution: "unknown",
237
+ reason: `the decision registry could not be read: ${registryReason}`,
238
+ };
239
+ }
240
+ const resolution = resolveDecisionRef(registry.byId, knownFitness, ref);
241
+ if (resolution === "adr") {
242
+ const record = registry.byId.get(stripAdrPrefix(ref));
243
+ const recordFacts = { id: record.id, status: record.status };
244
+ for (const key of [
245
+ "created",
246
+ "updated",
247
+ "context",
248
+ "decision",
249
+ "rationale",
250
+ "alternatives",
251
+ "consequences",
252
+ "assumptions",
253
+ ]) {
254
+ if (record[key] !== undefined) recordFacts[key] = record[key];
255
+ }
256
+ recordFacts.supersedes = record.supersedes;
257
+ recordFacts.supersededBy = record.supersededBy;
258
+ recordFacts.bindings = record.bindings;
259
+ return {
260
+ ref,
261
+ resolution: "adr",
262
+ authority: hasAuthority(record.status),
263
+ record: recordFacts,
264
+ lineage: lineage(record.id, {
265
+ records: registry.records,
266
+ byId: registry.byId,
267
+ // The lineage walk reads only `records`/`byId`; the graph walk's
268
+ // full shape is supplied so the call satisfies the type rather than
269
+ // leaning on an unchecked subset.
270
+ knownFitness,
271
+ rows: [],
272
+ }),
273
+ };
274
+ }
275
+ if (resolution === "fitness") {
276
+ return { ref, resolution: "fitness" };
277
+ }
278
+ return { ref, resolution: "unknown", reason: unresolvedDecisionRefNote(ref) };
279
+ });
280
+ }
281
+
282
+ /**
283
+ * The lineage-comparison seam: `detectDecisionChange` fed the caller-supplied
284
+ * base registry and the workspace's current registry — the same
285
+ * `readAdrContext` read the decision-chain walk makes, made only when the
286
+ * caller asked for the comparison. A registry that cannot be read resolves
287
+ * nothing and says so (the same fail-closed posture the chain walk takes for
288
+ * the same condition), so a supersession is never asserted from a registry
289
+ * that was never read.
290
+ *
291
+ * @param {{records: object[], byId: Map<string, object>}|null|undefined} baseRegistry
292
+ * The base state's registry (a `loadAdrRegistry` result), or `null` for a
293
+ * one-sided base — the comparison then discloses "not comparable" instead
294
+ * of asserting anything.
295
+ * @param {string} root The workspace root, for the head registry read.
296
+ * @param {string[]} tracked Tracked files, for the head registry read.
297
+ * @returns {{superseded: boolean, comparable: boolean, notes: string[]}}
298
+ */
299
+ function computeDecisionChange(baseRegistry, root, tracked) {
300
+ let headRegistry;
301
+ try {
302
+ headRegistry = readAdrContext(root, { tracked });
303
+ } catch (error) {
304
+ return {
305
+ superseded: false,
306
+ comparable: false,
307
+ notes: [`the decision registry could not be read: ${String(error?.message ?? error)}`],
308
+ };
309
+ }
310
+ return detectDecisionChange(baseRegistry, headRegistry);
311
+ }
141
312
 
142
313
  /**
143
314
  * Runs the `explain` command: resolves the command context, finds the import
@@ -146,12 +317,22 @@ function findMatchingConstraints(depConstraints, sourceProjectNode) {
146
317
  * @param {string} site A `file:line:column` string.
147
318
  * @param {object} commandContext From `resolveCommandContext`.
148
319
  * @param {object} config The loaded boundary config (from `loadBoundaryConfig`).
320
+ * @param {object} [options]
321
+ * @param {{records: object[], byId: Map<string, object>}|null|undefined} [options.baseRegistry]
322
+ * The ADR registry at the comparison base (a `loadAdrRegistry` result), or
323
+ * `null` for a one-sided base. Supplied, the resolved-site explanation
324
+ * also carries a `decisionChange` field — whether the decision lineage
325
+ * moved between base and head (the workspace's current registry):
326
+ * DECISION_CHANGE, never DRIFT, and never asserted without both registry
327
+ * states (one-sided ⇒ a "not comparable" note, `detectDecisionChange`'s
328
+ * own contract). Absent — every current caller — the explanation changes
329
+ * no byte.
149
330
  * @returns {{status: "ok"|"no-verdict", explanation: object, coverage: object,
150
331
  * report: {text: string, json: string}}}
151
332
  * @throws {Error} when the plugin is unregistered on a polyglot Nx workspace,
152
333
  * when the site string is malformed, or when the site cannot be found.
153
334
  */
154
- export function explainCommand(site, commandContext, config) {
335
+ export function explainCommand(site, commandContext, config, options = {}) {
155
336
  const { root, provider, marker, graph } = commandContext;
156
337
 
157
338
  // Descriptive commands refuse when the graph is known to be incomplete.
@@ -353,6 +534,29 @@ export function explainCommand(site, commandContext, config) {
353
534
  unresolvable: false,
354
535
  reason: null,
355
536
  };
537
+ // The "why does this constraint exist" chain — the governing decision(s)
538
+ // behind the rows that matched this site, resolved through the ADR registry
539
+ // and walked through the governance graph (`resolveDecisionChains`'s own
540
+ // header argues the fail-closed wording and the determinism). Additive: an
541
+ // explanation whose rows carry no `decisionRef` keeps every byte it had.
542
+ const decisions = resolveDecisionChains(matchedConstraints, root, commandContext.tracked, config);
543
+ if (decisions.length > 0) {
544
+ explanation.decisions = decisions;
545
+ }
546
+
547
+ // The lineage-comparison seam (Wave 3 §9): when the caller supplies a base
548
+ // registry, the resolved-site explanation also states whether the decision
549
+ // lineage moved between base and head — DECISION_CHANGE, never DRIFT, and
550
+ // never asserted without both registry states (a one-sided base ⇒ a
551
+ // "not comparable" note, `detectDecisionChange`'s own contract). Absent
552
+ // the option — every current caller — the explanation changes no byte:
553
+ // the field and its rendered lines exist only when the comparison was
554
+ // requested, and an unresolvable site (above) stays what it was.
555
+ let decisionChange = null;
556
+ if (options.baseRegistry !== undefined) {
557
+ decisionChange = computeDecisionChange(options.baseRegistry, root, commandContext.tracked);
558
+ explanation.decisionChange = decisionChange;
559
+ }
356
560
 
357
561
  const context = { root, provider, marker, provenance: resolveProvenance(root) };
358
562
  const coverage = {
@@ -375,6 +579,8 @@ export function explainCommand(site, commandContext, config) {
375
579
  matchedConstraints,
376
580
  violations,
377
581
  verdict,
582
+ ...(decisions.length > 0 ? { decisions } : {}),
583
+ ...(decisionChange !== null ? { decisionChange } : {}),
378
584
  };
379
585
 
380
586
  const envelope = jsonEnvelope({
@@ -76,9 +76,10 @@ import { basename, join, resolve } from "node:path";
76
76
 
77
77
  import { isWholeFileFailure } from "../analysis/source-util.mjs";
78
78
  import { containmentViolation } from "../containment.mjs";
79
+ import { classifyEvolution } from "../governance/evolution-event.mjs";
79
80
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
80
81
  import { formatHistoryReport } from "../report/history-text.mjs";
81
- import { computeDiff, parseBaseline } from "./diff.mjs";
82
+ import { computeDiff, edgeIdentityKey, parseBaseline } from "./diff.mjs";
82
83
  import { buildDependencies, buildProjects } from "./graph.mjs";
83
84
  import { resolveProvenance } from "./provenance.mjs";
84
85
  import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
@@ -268,11 +269,21 @@ export function nextSequence(read) {
268
269
  * `meta`; parsing `notes` strings would be a second copy of the decision
269
270
  * wearing a parser's name.
270
271
  *
272
+ * The record's `classifications` are the canonical evolution classes
273
+ * (`../governance/evolution-event.mjs`'s `classifyEvolution`) computed from
274
+ * the signals this transition's own record carries — the graph diff rows,
275
+ * the policy comparison, and the code-drift fact. Stored snapshots carry no
276
+ * findings, intent rows, debt ledger, or decision registries, so no class is
277
+ * ever asserted where the input holds no evidence for it (design §2): a
278
+ * violation, repair, or decision-change class would be a fabricated fact.
279
+ *
271
280
  * @param {{name: string, path: string, envelope: object, id: string}} from
272
281
  * @param {{name: string, path: string, envelope: object, id: string}} to
273
282
  * @returns {{record: {from: string, to: string, architectureChanged: boolean,
274
- * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
275
- * codeDrift: boolean, notes: string[]},
283
+ * changes: object|null, policyChanged: boolean|null,
284
+ * policyOneSided: boolean, provenanceChanged: boolean|null,
285
+ * providerChanged: boolean, codeDrift: boolean, notes: string[],
286
+ * classifications: string[]},
276
287
  * meta: object}} `meta` is `compareSnapshotMetadata`'s result.
277
288
  */
278
289
  export function classifyTransition(from, to) {
@@ -313,6 +324,15 @@ export function classifyTransition(from, to) {
313
324
  "records the boundary law and the other does not",
314
325
  );
315
326
  }
327
+ // Mirror of the one-sided rule: neither side records the law while the
328
+ // commit advanced. The pair carried real code motion the tool cannot
329
+ // classify, so it is disclosed — never asserted unchanged (F-HIST-1).
330
+ if (meta.policyChanged === null && meta.provenanceChanged === true) {
331
+ notes.push(
332
+ "policy (the declared architectural intent) could not be compared — neither snapshot " +
333
+ "records the boundary law while the provenance advanced, so code drift cannot be asserted",
334
+ );
335
+ }
316
336
  if (meta.provenanceOneSided) {
317
337
  notes.push(
318
338
  "repository provenance could not be compared — one snapshot records its origin and the other does not",
@@ -363,6 +383,55 @@ export function classifyTransition(from, to) {
363
383
  const codeDrift =
364
384
  !architectureChanged && meta.policyChanged === false && meta.provenanceChanged === true;
365
385
 
386
+ // The canonical classification: one definition (`classifyEvolution`), fed
387
+ // from the signals this transition's own record carries — the graph diff
388
+ // rows, the policy comparison, and the code-drift fact. Stored snapshots
389
+ // carry no findings, intent rows, debt ledger, or decision registries, so
390
+ // those evidence classes stay absent — a class is never asserted where the
391
+ // input holds no evidence for it (design §2).
392
+ const classification = classifyEvolution({
393
+ observed: {
394
+ projects: {
395
+ added: diff.addedProjects.map((project) => project.name),
396
+ removed: diff.removedProjects.map((project) => project.name),
397
+ changed: diff.changedProjects.map((project) => project.name),
398
+ },
399
+ edges: {
400
+ added: diff.addedEdges.map(edgeIdentityKey),
401
+ removed: diff.removedEdges.map(edgeIdentityKey),
402
+ },
403
+ policyChanged: meta.policyChanged,
404
+ policyOneSided: meta.policyOneSided,
405
+ provenanceChanged: meta.provenanceChanged,
406
+ },
407
+ codeDrift,
408
+ });
409
+
410
+ // `classifyEvolution`'s disclosures for snapshot-sourced evidence are the
411
+ // policy facts the notes above already state (its wording mirrors ours, per
412
+ // `../governance/evolution-event.mjs`) plus the empty-classification
413
+ // statement. The statement is appended only where it is true: a pair this
414
+ // record disclosed as policy-only, one-sided (policy or provenance), or
415
+ // provider-only is never read as "unchanged" — the same exclusions
416
+ // `./trajectory.mjs`'s `unchanged` bucket applies. `null` policy with
417
+ // advancing provenance is the same exclusion: a pair that moved and cannot
418
+ // be classified is never read as unchanged.
419
+ if (
420
+ classification.classifications.length === 0 &&
421
+ meta.policyChanged !== true &&
422
+ !meta.policyOneSided &&
423
+ !meta.provenanceOneSided &&
424
+ !meta.providerChanged &&
425
+ !(meta.policyChanged === null && meta.provenanceChanged === true)
426
+ ) {
427
+ const statement = classification.notes.find((note) =>
428
+ note.endsWith("no classification applies"),
429
+ );
430
+ if (statement !== undefined) {
431
+ notes.push(statement);
432
+ }
433
+ }
434
+
366
435
  const record = {
367
436
  from: from.name,
368
437
  to: to.name,
@@ -373,9 +442,14 @@ export function classifyTransition(from, to) {
373
442
  // interpretation changed".
374
443
  changes: architectureChanged || meta.providerChanged ? diff : null,
375
444
  policyChanged: meta.policyChanged,
445
+ policyOneSided: meta.policyOneSided,
446
+ provenanceChanged: meta.provenanceChanged,
376
447
  providerChanged: meta.providerChanged,
377
448
  codeDrift,
378
449
  notes,
450
+ // Appended after every existing key: the classifications are a fact
451
+ // about the transition, never a change to one of the facts above.
452
+ classifications: classification.classifications,
379
453
  };
380
454
  return { record, meta };
381
455
  }
@@ -395,8 +469,10 @@ export function classifyTransition(from, to) {
395
469
  * @param {{name: string, path: string, envelope: object, id: string}[]} files
396
470
  * @returns {{snapshots: {name: string, id: string}[],
397
471
  * transitions: {from: string, to: string, architectureChanged: boolean,
398
- * changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
399
- * codeDrift: boolean, notes: string[]}[]}}
472
+ * changes: object|null, policyChanged: boolean|null,
473
+ * policyOneSided: boolean, provenanceChanged: boolean|null,
474
+ * providerChanged: boolean, codeDrift: boolean, notes: string[],
475
+ * classifications: string[]}[]}}
400
476
  */
401
477
  export function computeEvolution(files) {
402
478
  const snapshots = files.map((file) => ({ name: file.name, id: file.id }));
@@ -33,6 +33,17 @@
33
33
  * - **Intent** — the canonical Architecture Intent verdict (`driftForCheck`,
34
34
  * the object `drift` and `check` share), absent when the workspace declares
35
35
  * no `architecture-intent.json`, no-verdict when one cannot be established.
36
+ * - **Fitness** — the declared fitness functions that govern this workspace,
37
+ * with per-function verdicts (pass/fail/no-verdict) or a note that no
38
+ * fitness gates are declared.
39
+ * - **Waivers** — active boundary waivers, each with its remaining term and
40
+ * how many violations it currently covers, plus permanent suppressions.
41
+ * - **Decisions / ADRs in scope** — the decision lineage: which ADR, rule, or
42
+ * fitness record authorises each matched constraint row, resolved where
43
+ * possible and listed with their context.
44
+ * - **Debt** — the architecture-debt snapshot for the target project (when a
45
+ * history directory is available): current violations, exemptions, and gaps,
46
+ * aged across the snapshot history.
36
47
  * - **Coverage / limitations** — complete vs no-verdict, with the exact files
37
48
  * that could not be analyzed.
38
49
  * - **Verification commands** — the deterministic commands an agent runs after
@@ -66,6 +77,8 @@ import { collectProjectContext } from "./context-command.mjs";
66
77
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
67
78
  import { resolveProvenance } from "./provenance.mjs";
68
79
  import { readAdrContext } from "./adr.mjs";
80
+ import { declaresFitness, fitnessForCheck } from "./fitness.mjs";
81
+ import { computeWaivers } from "./waivers.mjs";
69
82
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
70
83
  import { formatPlanContextReport } from "../report/plan-context-text.mjs";
71
84
 
@@ -229,10 +242,20 @@ export function collectDrift(commandContext) {
229
242
  * @param {string[]} paths Optional workspace-relative paths the change touches.
230
243
  * @param {object} commandContext From `resolveCommandContext`.
231
244
  * @param {object} config The loaded boundary config.
245
+ * @param {string|null} [historyDir=null] Optional absolute path to the workspace's
246
+ * history directory. When provided, the plan includes the architecture-debt
247
+ * snapshot for the target project — current violations, exemptions, and gaps,
248
+ * aged across the snapshot history. When absent, the debt section is omitted.
232
249
  * @returns {Promise<{status: "ok"|"no-verdict", exitCode: number, coverage: object,
233
250
  * result: object, report: {text: string, json: string}}>}
234
251
  */
235
- export async function planContextCommand(projectName, paths, commandContext, config) {
252
+ export async function planContextCommand(
253
+ projectName,
254
+ paths,
255
+ commandContext,
256
+ config,
257
+ historyDir = null,
258
+ ) {
236
259
  const { root, provider, marker, graph, workspace, pluginGap, tracked } = commandContext;
237
260
 
238
261
  // The same refusal every descriptive command carries: on an Nx workspace
@@ -269,9 +292,10 @@ export async function planContextCommand(projectName, paths, commandContext, con
269
292
  const planDecisionRefRows = projectContext.constraints
270
293
  .map((row, index) => ({ kind: `constraints[${index}]`, row }))
271
294
  .filter(({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "");
295
+ let adrContext = null;
272
296
  let unresolvedDecisionRefs = new Set();
273
297
  if (planDecisionRefRows.length > 0) {
274
- const adrContext = readAdrContext(root, { tracked });
298
+ adrContext = readAdrContext(root, { tracked });
275
299
  unresolvedDecisionRefs = new Set(
276
300
  // F04: the fitness half resolves against the ids THIS policy declares
277
301
  // (`declaredFitnessNames(config)`), never the ADRs' own `bindings`.
@@ -333,6 +357,125 @@ export async function planContextCommand(projectName, paths, commandContext, con
333
357
  };
334
358
  }
335
359
  }
360
+
361
+ // Fitness: the declared fitness functions that govern this workspace.
362
+ // Composed from `fitnessForCheck` (not re-implemented) — the same verdict
363
+ // `fitness` and `check` share. When the policy declares no fitness
364
+ // functions, state so honestly rather than omitting the section.
365
+ let fitness = null;
366
+ if (declaresFitness(config)) {
367
+ try {
368
+ const fitnessResult = fitnessForCheck(commandContext, {
369
+ rows: config.fitness,
370
+ intent: intent ?? null,
371
+ suppressions: config.suppressions ?? [],
372
+ scoped: false,
373
+ });
374
+ fitness = {
375
+ verified: true,
376
+ decisions: fitnessResult.decisions,
377
+ overall: fitnessResult.overall,
378
+ };
379
+ } catch (cause) {
380
+ fitness = {
381
+ verified: false,
382
+ decisions: [],
383
+ overall: { verdict: "no-verdict" },
384
+ error: `${cause?.message ?? cause}`,
385
+ };
386
+ }
387
+ }
388
+
389
+ // Waivers: active boundary waivers from the policy's suppressions table.
390
+ // Composed from `computeWaivers` — the same function `waivers` and `check`
391
+ // use. When no suppressions are declared, the section states "none" rather
392
+ // than omitting the key (the plan contract: every dimension present).
393
+ const suppressions = config.suppressions ?? [];
394
+ const waiversResult = suppressions.length > 0 ? computeWaivers(suppressions, wholeVerdict) : null;
395
+ const waivers =
396
+ waiversResult === null
397
+ ? {
398
+ declared: false,
399
+ waivers: [],
400
+ covered: 0,
401
+ expired: 0,
402
+ stale: 0,
403
+ permanentSuppressions: [],
404
+ }
405
+ : {
406
+ declared: true,
407
+ waivers: waiversResult.waivers,
408
+ covered: waiversResult.covered,
409
+ expired: waiversResult.expired,
410
+ stale: waiversResult.stale,
411
+ permanentSuppressions: waiversResult.suppressions,
412
+ };
413
+
414
+ // Decisions / ADRs in scope: the positive lineage — which ADRs, rules, or
415
+ // fitness IDs each matched constraint row cites and successfully resolves
416
+ // against. The negative case (unresolved refs) already exists above as
417
+ // `unresolvedDecisionRefs`; this is the resolved half.
418
+ const decisions = [];
419
+ if (planDecisionRefRows.length > 0 && adrContext !== null) {
420
+ for (const { kind, row } of planDecisionRefRows) {
421
+ const ref = row.decisionRef.trim();
422
+ if (!unresolvedDecisionRefs.has(ref)) {
423
+ const record = adrContext.byId.get(ref);
424
+ decisions.push({
425
+ decisionRef: ref,
426
+ kind,
427
+ resolved: true,
428
+ record: record
429
+ ? {
430
+ id: record.id,
431
+ title: record.title,
432
+ status: record.status,
433
+ ...(record.context ? { context: record.context } : {}),
434
+ }
435
+ : null,
436
+ });
437
+ }
438
+ }
439
+ }
440
+ decisions.sort((a, b) =>
441
+ a.decisionRef < b.decisionRef ? -1 : a.decisionRef > b.decisionRef ? 1 : 0,
442
+ );
443
+
444
+ // Architecture debt: the current debt snapshot for the target project, aged
445
+ // across the snapshot history. Composed from `debtCommand` (not re-implemented)
446
+ // — the same ledger the `debt` command builds. Present only when a history
447
+ // directory is available (matching the `intent` pattern: absent when the
448
+ // workspace has no history, never a claim of zero debt).
449
+ let debt = null;
450
+ if (historyDir !== null) {
451
+ try {
452
+ const { debtCommand } = await import("./debt.mjs");
453
+ const debtResult = await debtCommand(historyDir, commandContext, { config });
454
+ const ledger = debtResult.ledger;
455
+ debt = {
456
+ available: true,
457
+ dir: ledger.dir,
458
+ snapshots: ledger.snapshots,
459
+ entries: ledger.entries,
460
+ resolved: ledger.resolved,
461
+ total: ledger.total,
462
+ byKind: ledger.byKind,
463
+ bySeverity: ledger.bySeverity,
464
+ agings: ledger.agings,
465
+ lifecycle: ledger.lifecycle,
466
+ };
467
+ } catch (cause) {
468
+ debt = {
469
+ available: false,
470
+ reason: `${cause?.message ?? cause}`,
471
+ entries: [],
472
+ total: { open: 0, resolved: 0, total: 0 },
473
+ byKind: {},
474
+ bySeverity: {},
475
+ agings: { meanDays: null, maxDays: null },
476
+ };
477
+ }
478
+ }
336
479
  const failures = [...wholeTree.failures, ...drift.failures];
337
480
  const notAnalyzed = failures
338
481
  .filter(isWholeFileFailure)
@@ -436,6 +579,24 @@ export async function planContextCommand(projectName, paths, commandContext, con
436
579
  goWork: goWorkResult(drift.goWork),
437
580
  tsconfigPaths: tsconfigPathsResult(drift.tsconfigPaths),
438
581
  },
582
+ // Fitness: the declared fitness functions that govern this workspace.
583
+ // Present only when the policy declares fitness functions (matching
584
+ // the pattern: absent when the workspace chose not to declare any).
585
+ ...(fitness === null ? {} : { fitness }),
586
+ // Waivers: active boundary waivers and permanent suppressions.
587
+ // Always present — when no suppressions are declared, `declared: false`
588
+ // distinguishes "no waivers to show" from "waivers exist but none match".
589
+ waivers,
590
+ // Decisions / ADRs in scope: the resolved decision lineage — which
591
+ // ADRs, rules, or fitness IDs authorise each matched constraint row.
592
+ // Present only when at least one decision ref resolves; absent when
593
+ // no constraint row carries a decisionRef or none resolve.
594
+ ...(decisions.length > 0 ? { decisions } : {}),
595
+ // Architecture debt: the current debt snapshot for the target project,
596
+ // aged across the snapshot history. Present only when a history directory
597
+ // is available (matching the `intent` pattern: absent when the workspace
598
+ // has no history, never a claim of zero debt).
599
+ ...(debt === null ? {} : { debt }),
439
600
  // The canonical Architecture Intent verdict — the same fold `check` and
440
601
  // `drift` report. Absent (key omitted) when no intent file is tracked,
441
602
  // matching `check`: intent absence is a workspace decision about