@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.
@@ -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({
@@ -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
@@ -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
- const incomparable = meta.policyOneSided || meta.provenanceOneSided;
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