@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
package/src/commands/explain.mjs
CHANGED
|
@@ -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({
|
package/src/commands/history.mjs
CHANGED
|
@@ -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,
|
|
275
|
-
*
|
|
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,
|
|
399
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|