@ecoma-io/archkeep 0.17.0 → 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/cli.mjs +143 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +71 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -0
- package/src/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +45 -0
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
package/src/commands/explain.mjs
CHANGED
|
@@ -65,6 +65,19 @@
|
|
|
65
65
|
* exit code. The registry is read lazily, only when a matched row actually
|
|
66
66
|
* carries a `decisionRef` — the common case (no `docs/adr/` adopted yet)
|
|
67
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.
|
|
68
81
|
*/
|
|
69
82
|
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
70
83
|
import { UsageError } from "../errors.mjs";
|
|
@@ -77,6 +90,7 @@ import { resolveProvenance } from "./provenance.mjs";
|
|
|
77
90
|
import { readAdrContext } from "./adr.mjs";
|
|
78
91
|
import { lineage } from "../governance/decision-graph.mjs";
|
|
79
92
|
import { unresolvedDecisionRefNote } from "./provenance-command.mjs";
|
|
93
|
+
import { detectDecisionChange } from "../governance/decision-lineage.mjs";
|
|
80
94
|
import {
|
|
81
95
|
declaredFitnessNames,
|
|
82
96
|
hasAuthority,
|
|
@@ -265,6 +279,37 @@ function resolveDecisionChains(matchedConstraints, root, tracked, config) {
|
|
|
265
279
|
});
|
|
266
280
|
}
|
|
267
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
|
+
}
|
|
312
|
+
|
|
268
313
|
/**
|
|
269
314
|
* Runs the `explain` command: resolves the command context, finds the import
|
|
270
315
|
* site, evaluates the rules, and returns the explanation.
|
|
@@ -272,12 +317,22 @@ function resolveDecisionChains(matchedConstraints, root, tracked, config) {
|
|
|
272
317
|
* @param {string} site A `file:line:column` string.
|
|
273
318
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
274
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.
|
|
275
330
|
* @returns {{status: "ok"|"no-verdict", explanation: object, coverage: object,
|
|
276
331
|
* report: {text: string, json: string}}}
|
|
277
332
|
* @throws {Error} when the plugin is unregistered on a polyglot Nx workspace,
|
|
278
333
|
* when the site string is malformed, or when the site cannot be found.
|
|
279
334
|
*/
|
|
280
|
-
export function explainCommand(site, commandContext, config) {
|
|
335
|
+
export function explainCommand(site, commandContext, config, options = {}) {
|
|
281
336
|
const { root, provider, marker, graph } = commandContext;
|
|
282
337
|
|
|
283
338
|
// Descriptive commands refuse when the graph is known to be incomplete.
|
|
@@ -489,6 +544,20 @@ export function explainCommand(site, commandContext, config) {
|
|
|
489
544
|
explanation.decisions = decisions;
|
|
490
545
|
}
|
|
491
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
|
+
}
|
|
560
|
+
|
|
492
561
|
const context = { root, provider, marker, provenance: resolveProvenance(root) };
|
|
493
562
|
const coverage = {
|
|
494
563
|
complete,
|
|
@@ -511,6 +580,7 @@ export function explainCommand(site, commandContext, config) {
|
|
|
511
580
|
violations,
|
|
512
581
|
verdict,
|
|
513
582
|
...(decisions.length > 0 ? { decisions } : {}),
|
|
583
|
+
...(decisionChange !== null ? { decisionChange } : {}),
|
|
514
584
|
};
|
|
515
585
|
|
|
516
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
|
|
@@ -140,6 +140,35 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
|
|
|
140
140
|
* @property {number|null} persistent
|
|
141
141
|
*/
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The trend-facts block: per-class counts and boundary-movement totals over
|
|
145
|
+
* the SAME comparable transitions the axes count — a pair whose fingerprint
|
|
146
|
+
* or provenance could not be compared is excluded exactly as it is excluded
|
|
147
|
+
* from `transitions.unchanged`, so the basis is one consistent subset. Each
|
|
148
|
+
* class counts once per transition carrying it (a transition may carry
|
|
149
|
+
* several). `violationsIntroduced`/`violationsResolved` count classes, never
|
|
150
|
+
* violation rows — and for snapshot-sourced transitions no finding evidence
|
|
151
|
+
* exists at all, so those totals are `0` with the `note` saying why: the
|
|
152
|
+
* absence of evidence, never a claim that none occurred (the silent
|
|
153
|
+
* direction).
|
|
154
|
+
*
|
|
155
|
+
* `null` when no trend can be derived: fewer than two observations, or a
|
|
156
|
+
* history where every transition was incomparable. Never a zero-filled block.
|
|
157
|
+
*
|
|
158
|
+
* @typedef {object} TrajectoryTrends
|
|
159
|
+
* @property {{CHANGE: number, DRIFT: number, VIOLATION: number, REPAIR: number,
|
|
160
|
+
* DECISION_CHANGE: number}} byClass One count per evolution class, over
|
|
161
|
+
* comparable transitions whose `classifications` carry it.
|
|
162
|
+
* @property {number} violationsIntroduced
|
|
163
|
+
* @property {number} violationsResolved
|
|
164
|
+
* @property {number} comparableTransitions The number of transitions the
|
|
165
|
+
* counts were derived over — `transitions.count − transitions.incomparable`.
|
|
166
|
+
* @property {"comparable transition classifications"} basis What the counts
|
|
167
|
+
* are a claim about, stated as a value.
|
|
168
|
+
* @property {string} [note] A disclosure when the counts cannot speak about
|
|
169
|
+
* evidence the input never carried (violation/repair rows).
|
|
170
|
+
*/
|
|
171
|
+
|
|
143
172
|
/**
|
|
144
173
|
* Aggregates the deterministic trajectory over an ordered snapshot set.
|
|
145
174
|
* Pure: same bytes in, same object out. All keys are always present — shape
|
|
@@ -154,7 +183,8 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
|
|
|
154
183
|
* transitions: {count: number, architecture: number, policy: number,
|
|
155
184
|
* provider: number, codeDrift: number, incomparable: number, unchanged: number},
|
|
156
185
|
* disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
|
|
157
|
-
* projects: TrajectoryAxis, edges: TrajectoryAxis
|
|
186
|
+
* projects: TrajectoryAxis, edges: TrajectoryAxis,
|
|
187
|
+
* trends: TrajectoryTrends|null}}
|
|
158
188
|
*/
|
|
159
189
|
export function computeTrajectory(files) {
|
|
160
190
|
const n = files.length;
|
|
@@ -182,6 +212,10 @@ export function computeTrajectory(files) {
|
|
|
182
212
|
};
|
|
183
213
|
const disclosures = { policyOneSided: 0, provenanceOneSided: 0, crossRepo: 0 };
|
|
184
214
|
|
|
215
|
+
/** @type {{CHANGE: number, DRIFT: number, VIOLATION: number, REPAIR: number,
|
|
216
|
+
DECISION_CHANGE: number}} */
|
|
217
|
+
const classCounts = { CHANGE: 0, DRIFT: 0, VIOLATION: 0, REPAIR: 0, DECISION_CHANGE: 0 };
|
|
218
|
+
|
|
185
219
|
// Cumulative transition events, accumulated while classifying. Kept as
|
|
186
220
|
// scalars rather than deferred to a second pass — one walk over the pairs.
|
|
187
221
|
let addedProjectEvents = 0;
|
|
@@ -231,15 +265,34 @@ export function computeTrajectory(files) {
|
|
|
231
265
|
if (record.policyChanged === true) transitions.policy += 1;
|
|
232
266
|
if (record.providerChanged) transitions.provider += 1;
|
|
233
267
|
if (record.codeDrift) transitions.codeDrift += 1;
|
|
234
|
-
|
|
235
268
|
// The asymmetric-evidence cases, counted from `meta` itself — never
|
|
236
269
|
// parsed back out of the record's prose notes.
|
|
237
270
|
if (meta.policyOneSided) disclosures.policyOneSided += 1;
|
|
238
271
|
if (meta.provenanceOneSided) disclosures.provenanceOneSided += 1;
|
|
239
272
|
if (meta.crossRepo) disclosures.crossRepo += 1;
|
|
240
|
-
|
|
273
|
+
// The advanced-both-absent pair is the same incomparable case as
|
|
274
|
+
// one-sided: neither side records the boundary law while the commit
|
|
275
|
+
// advanced, so the transition carried real code motion the tool cannot
|
|
276
|
+
// classify (F-HIST-1). `provenanceChanged === true` requires both sides
|
|
277
|
+
// to record provenance AND the commits to differ, so neither-side-
|
|
278
|
+
// absent histories (both commits `null`) stay comparable-unchanged.
|
|
279
|
+
const incomparable =
|
|
280
|
+
meta.policyOneSided ||
|
|
281
|
+
meta.provenanceOneSided ||
|
|
282
|
+
(meta.policyChanged === null && meta.provenanceChanged === true);
|
|
241
283
|
if (incomparable) transitions.incomparable += 1;
|
|
242
284
|
|
|
285
|
+
// The trend classes are counted over the SAME comparable subset the
|
|
286
|
+
// axes already disclose: a pair excluded from `unchanged` because its
|
|
287
|
+
// fingerprint or provenance could not be compared is excluded from the
|
|
288
|
+
// trend facts the same way — never silently folded in. Each class is a
|
|
289
|
+
// fact per transition; a transition carrying several counts in each.
|
|
290
|
+
if (!incomparable) {
|
|
291
|
+
for (const cls of record.classifications) {
|
|
292
|
+
classCounts[cls] += 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
243
296
|
// `unchanged` is deliberately STRICTER than the label `history`'s text
|
|
244
297
|
// renderer prints for the same transition: an aggregate has no
|
|
245
298
|
// per-transition note to disclose "one side carried no fingerprint",
|
|
@@ -328,6 +381,38 @@ export function computeTrajectory(files) {
|
|
|
328
381
|
resolved: available ? edgeMovement.resolved : null,
|
|
329
382
|
persistent: available ? persistentCount(edgePresence) : null,
|
|
330
383
|
};
|
|
384
|
+
// The trend-facts block. It exists only when comparable evidence exists:
|
|
385
|
+
// fewer than two observations, or a history whose every transition was
|
|
386
|
+
// incomparable, yields `null` — never a zero-filled block (a zero would
|
|
387
|
+
// claim "no change" over evidence this run could not compare). The counts
|
|
388
|
+
// ride the same classification the axes classify with; both come from
|
|
389
|
+
// `classifyTransition`'s one walk, so the trends are idempotent: same
|
|
390
|
+
// snapshots, same object, every run.
|
|
391
|
+
//
|
|
392
|
+
// The violation/repair totals are computed from the classification inputs,
|
|
393
|
+
// and snapshot-sourced transitions carry none: stored snapshots hold the
|
|
394
|
+
// graph and the policy fingerprint, not findings. Zero there is the absence
|
|
395
|
+
// of evidence, never a claim that none occurred — the note says so, on
|
|
396
|
+
// every non-null trends block.
|
|
397
|
+
/** @type {TrajectoryTrends|null} */
|
|
398
|
+
let trends = null;
|
|
399
|
+
if (available) {
|
|
400
|
+
const comparableTransitions = transitions.count - transitions.incomparable;
|
|
401
|
+
if (comparableTransitions > 0) {
|
|
402
|
+
trends = {
|
|
403
|
+
byClass: classCounts,
|
|
404
|
+
violationsIntroduced: 0,
|
|
405
|
+
violationsResolved: 0,
|
|
406
|
+
comparableTransitions,
|
|
407
|
+
basis: "comparable transition classifications",
|
|
408
|
+
note:
|
|
409
|
+
"transition classifications carry no violation or repair evidence — stored " +
|
|
410
|
+
"snapshots hold the graph and the policy fingerprint, not findings, so " +
|
|
411
|
+
"VIOLATION/REPAIR and the violations-* totals are 0 because no finding could " +
|
|
412
|
+
"be classified, never because none occurred",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
331
416
|
|
|
332
417
|
return {
|
|
333
418
|
observations: {
|
|
@@ -344,6 +429,7 @@ export function computeTrajectory(files) {
|
|
|
344
429
|
disclosures,
|
|
345
430
|
projects,
|
|
346
431
|
edges,
|
|
432
|
+
trends,
|
|
347
433
|
};
|
|
348
434
|
}
|
|
349
435
|
|