@ecoma-io/archkeep 0.17.0 → 0.18.1
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 +161 -22
- 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/diff.mjs +15 -7
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +82 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/rules.mjs +3 -1
- 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
|
@@ -69,8 +69,24 @@
|
|
|
69
69
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
70
70
|
import { tmpdir } from "node:os";
|
|
71
71
|
import { join } from "node:path";
|
|
72
|
+
import { createRequire } from "node:module";
|
|
72
73
|
|
|
73
74
|
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
75
|
+
import { loadIntent, INTENT_FILE } from "../architecture-intent/model.mjs";
|
|
76
|
+
import { judgeIntent } from "../architecture-intent/judge.mjs";
|
|
77
|
+
import { readAdrContext } from "./adr.mjs";
|
|
78
|
+
import { referenceTime } from "../governance/clock.mjs";
|
|
79
|
+
import { debtChangeDiff, debtFactId, driftFactOf } from "../governance/debt-ledger.mjs";
|
|
80
|
+
import { computeAffectedDecisions } from "../governance/decision-lineage.mjs";
|
|
81
|
+
import {
|
|
82
|
+
classifyEvolution,
|
|
83
|
+
eventDedupeKey,
|
|
84
|
+
eventId,
|
|
85
|
+
EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
86
|
+
} from "../governance/evolution-event.mjs";
|
|
87
|
+
import { writeEvent } from "../governance/evolution-store.mjs";
|
|
88
|
+
import { recordOrigin } from "../governance/provenance-record.mjs";
|
|
89
|
+
import { fitnessForCheck } from "./fitness.mjs";
|
|
74
90
|
import { runProcess } from "../process.mjs";
|
|
75
91
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
76
92
|
import { formatEvolutionReport } from "../report/evolution-text.mjs";
|
|
@@ -80,6 +96,11 @@ import { resolveProvenance } from "./provenance.mjs";
|
|
|
80
96
|
import { resolveDescribedPolicy } from "./policy.mjs";
|
|
81
97
|
import { resolveCommandContext, describeWorkspaceRoot } from "./context.mjs";
|
|
82
98
|
|
|
99
|
+
const require = createRequire(import.meta.url);
|
|
100
|
+
|
|
101
|
+
/** The tool identity stamped into every emitted event's provenance (delta.mjs's pattern). */
|
|
102
|
+
const { version: TOOL_VERSION } = require("../../package.json");
|
|
103
|
+
|
|
83
104
|
/** A full SHA-1 object name, as `git rev-parse` answers it. */
|
|
84
105
|
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
85
106
|
|
|
@@ -227,7 +248,8 @@ export function selectLinearRange(root, { base, head }, { run = runProcess } = {
|
|
|
227
248
|
* @param {{resolveContext?: Function, resolveProvenance?: Function}} [io]
|
|
228
249
|
* @returns {Promise<{sha: string, id: string, provider: string, provenance: object|null,
|
|
229
250
|
* projects: object[], dependencies: object[], fingerprint: string|null,
|
|
230
|
-
* coverage: {projects: number, analyzedFiles: number, imports: number}
|
|
251
|
+
* coverage: {projects: number, analyzedFiles: number, imports: number},
|
|
252
|
+
* evidence?: object|null}>}
|
|
231
253
|
* @throws {Error} when the revision is not a readable workspace, when whole-file
|
|
232
254
|
* analysis failures leave the record under-represented, or when the law the
|
|
233
255
|
* revision names will not load.
|
|
@@ -272,6 +294,16 @@ async function analyzeRevision(input, io = {}) {
|
|
|
272
294
|
const projects = buildProjects(context.graph.nodes);
|
|
273
295
|
const dependencies = buildDependencies(context.graph.dependencies);
|
|
274
296
|
|
|
297
|
+
// Wave 3 W7 (design §7): the per-revision comparable evidence for the
|
|
298
|
+
// fitness, debt and decision axes — the only comparable evidence those axes
|
|
299
|
+
// have, because each analyzed revision carries its own graph, law and
|
|
300
|
+
// decision registry. Computed ADDITIVELY and FAIL-SOFT: an axis that cannot
|
|
301
|
+
// be judged (an absent intent, an undeclared fitness block, an unjudgeable
|
|
302
|
+
// law) produces `null` with an in-band reason rather than a fabricated
|
|
303
|
+
// verdict, and never changes the existing `evolution` behavior (a revision
|
|
304
|
+
// that lacks an intent today still analyzes; it must keep doing so).
|
|
305
|
+
const evidence = await revisionEvidence(context, config, dir);
|
|
306
|
+
|
|
275
307
|
return {
|
|
276
308
|
sha,
|
|
277
309
|
id: snapshotIdentity({
|
|
@@ -289,6 +321,678 @@ async function analyzeRevision(input, io = {}) {
|
|
|
289
321
|
analyzedFiles: context.analysis.analyzed,
|
|
290
322
|
imports: context.analysis.imports.length,
|
|
291
323
|
},
|
|
324
|
+
...(evidence === null ? {} : { evidence }),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The per-revision comparable evidence for the fitness, debt and decision
|
|
330
|
+
* axes (design §7): the observed architecture facts each revision's FULL
|
|
331
|
+
* analysis carries — the intent verdict over its own graph, the fitness
|
|
332
|
+
* verdicts its own law declares, and the ADR registry its own tree records.
|
|
333
|
+
* The `evolution` command judges every transition against exactly this
|
|
334
|
+
* evidence, never a second opinion: an axis that cannot be compared is `n/a`
|
|
335
|
+
* with a reason, never zero and never folded into a clean "none" (the wave's
|
|
336
|
+
* invariant, `../governance/evolution-event.mjs`).
|
|
337
|
+
*
|
|
338
|
+
* Fail-soft by contract: every destination here is evidence worth disclosing,
|
|
339
|
+
* not a gate. An absent intent, an undeclared fitness block, or an
|
|
340
|
+
* unjudgeable law returns `null` (with the caller's sense of "could not be
|
|
341
|
+
* compared") instead of throwing, so a revision that lacks the evidence still
|
|
342
|
+
* analyses exactly as it always did.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} context The `resolveCommandContext` record for one revision.
|
|
345
|
+
* @param {object|null} config The revision's resolved boundary law.
|
|
346
|
+
* @param {string} dir The revision's materialized worktree.
|
|
347
|
+
* @returns {Promise<object|null>} The evidence record, or `null` when the
|
|
348
|
+
* revision supplies nothing to compare (no intent, no fitness, no decision
|
|
349
|
+
* registry) — the axes then read as incomparable with a reason.
|
|
350
|
+
*/
|
|
351
|
+
async function revisionEvidence(context, config, dir) {
|
|
352
|
+
/** The intent judgement over this revision's own graph, or null. */
|
|
353
|
+
let intentVerdict = null;
|
|
354
|
+
let intentNote = null;
|
|
355
|
+
try {
|
|
356
|
+
if (context.tracked?.includes(INTENT_FILE)) {
|
|
357
|
+
const intent = await loadIntent(dir, { tracked: context.tracked });
|
|
358
|
+
if (intent !== undefined && intent !== null) {
|
|
359
|
+
intentVerdict = judgeIntent(intent, {
|
|
360
|
+
nodes: context.graph.nodes,
|
|
361
|
+
dependencies: context.graph.dependencies,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} catch (cause) {
|
|
366
|
+
intentVerdict = null;
|
|
367
|
+
intentNote = `architecture intent could not be judged — ${cause?.message ?? cause}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The per-constraint fitness verdicts this revision's law declares, or null. */
|
|
371
|
+
let fitness = null;
|
|
372
|
+
let fitnessNote = null;
|
|
373
|
+
if (config?.fitness !== undefined) {
|
|
374
|
+
try {
|
|
375
|
+
const judged = fitnessForCheck(context, {
|
|
376
|
+
rows: config.fitness,
|
|
377
|
+
intent:
|
|
378
|
+
intentVerdict === null
|
|
379
|
+
? null
|
|
380
|
+
: {
|
|
381
|
+
verdict:
|
|
382
|
+
intentVerdict.findings.length > 0
|
|
383
|
+
? "findings"
|
|
384
|
+
: intentVerdict.unresolved.length > 0
|
|
385
|
+
? "no-verdict"
|
|
386
|
+
: "ok",
|
|
387
|
+
boundaries: intentVerdict.boundaries,
|
|
388
|
+
findings: intentVerdict.findings,
|
|
389
|
+
unresolved: intentVerdict.unresolved,
|
|
390
|
+
notes: intentVerdict.notes,
|
|
391
|
+
},
|
|
392
|
+
suppressions: config.suppressions ?? [],
|
|
393
|
+
scoped: false,
|
|
394
|
+
});
|
|
395
|
+
fitness = {
|
|
396
|
+
decisions: judged.decisions,
|
|
397
|
+
overall: judged.overall,
|
|
398
|
+
};
|
|
399
|
+
} catch (cause) {
|
|
400
|
+
fitness = null;
|
|
401
|
+
fitnessNote = `fitness could not be judged — ${cause?.message ?? cause}`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** The ADR registry this revision's own tree records, or null when unreadable. */
|
|
405
|
+
let adrContext;
|
|
406
|
+
let adrNote = null;
|
|
407
|
+
try {
|
|
408
|
+
adrContext = readAdrContext(dir, { tracked: context.tracked });
|
|
409
|
+
} catch (cause) {
|
|
410
|
+
// A malformed registry is a disclosure, not a gate: the decision axis
|
|
411
|
+
// reads as "could not be compared" with the reason, and the revision
|
|
412
|
+
// still analyzes exactly as it always did (the existing `evolution`
|
|
413
|
+
// never read ADR, so this catch is strictly additive).
|
|
414
|
+
adrContext = null;
|
|
415
|
+
adrNote = `ADR registry could not be read — ${cause?.message ?? cause}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const hasAny =
|
|
419
|
+
intentVerdict !== null ||
|
|
420
|
+
fitness !== null ||
|
|
421
|
+
(adrContext !== null && adrContext.records.length > 0);
|
|
422
|
+
if (!hasAny) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...(intentVerdict === null
|
|
427
|
+
? { intent: { verdict: "no-verdict", note: intentNote ?? "no intent compared" } }
|
|
428
|
+
: { intent: intentVerdict }),
|
|
429
|
+
fitness:
|
|
430
|
+
fitness === null
|
|
431
|
+
? config?.fitness !== undefined
|
|
432
|
+
? { verdict: "unknown", note: fitnessNote ?? "fitness could not be judged" }
|
|
433
|
+
: { verdict: "not_applicable", note: "no fitness block declared" }
|
|
434
|
+
: {
|
|
435
|
+
verdict: fitness.overall.verdict,
|
|
436
|
+
decisions: fitness.decisions,
|
|
437
|
+
overall: fitness.overall,
|
|
438
|
+
},
|
|
439
|
+
adr:
|
|
440
|
+
adrContext === null
|
|
441
|
+
? {
|
|
442
|
+
records: [],
|
|
443
|
+
byId: new Map(),
|
|
444
|
+
knownFitness: new Set(),
|
|
445
|
+
note: adrNote ?? "ADR registry could not be read",
|
|
446
|
+
}
|
|
447
|
+
: adrContext,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The per-revision comparable evidence two adjacent snapshots both carry, as
|
|
453
|
+
* the facts `classifyEvolution` and the 8-question report read.
|
|
454
|
+
*
|
|
455
|
+
* @typedef {object} TransitionEvidence
|
|
456
|
+
* @property {object} from
|
|
457
|
+
* @property {object} to
|
|
458
|
+
*/
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Whether a per-revision `evidence.intent` is a REAL `judgeIntent` result (and
|
|
462
|
+
* so comparable) rather than the `{verdict: "no-verdict", note}` shape used to
|
|
463
|
+
* disclose an intent that could not be judged. `debtChangeDiff` reads
|
|
464
|
+
* `findings`/`gaps` from it, and the refusal shape carries neither — diffing
|
|
465
|
+
* one would fabricate a clean empty-diff, so it is never treated as verdict
|
|
466
|
+
* evidence.
|
|
467
|
+
*
|
|
468
|
+
* @param {object|undefined} intent The `evidence.intent` value.
|
|
469
|
+
* @returns {boolean}
|
|
470
|
+
*/
|
|
471
|
+
function isRealIntentVerdict(intent) {
|
|
472
|
+
return (
|
|
473
|
+
intent !== undefined &&
|
|
474
|
+
intent !== null &&
|
|
475
|
+
typeof intent === "object" &&
|
|
476
|
+
Array.isArray(intent.findings)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The observed architecture-change struct `classifyEvolution` and the report
|
|
482
|
+
* read: the graph diff between two snapshots, plus the carrier-change flags the
|
|
483
|
+
* transition already classified. A transition whose graph did not change
|
|
484
|
+
* (`changes === null`) carries an empty structure — a pure policy or provider
|
|
485
|
+
* carrier change is still disclosed by the flags, never by fabricated diff
|
|
486
|
+
* rows.
|
|
487
|
+
*
|
|
488
|
+
* @param {object} transition A `computeEvolution` transition record.
|
|
489
|
+
* @returns {{architectureChanged: boolean, projects: {added: object[], removed: object[], changed: object[]},
|
|
490
|
+
* edges: {added: object[], removed: object[]}, policyChanged: boolean|null,
|
|
491
|
+
* policyOneSided: boolean, providerChanged: boolean, provenanceChanged: boolean|null}}
|
|
492
|
+
*/
|
|
493
|
+
function transitionObserved(transition) {
|
|
494
|
+
const diff = transition.changes ?? {
|
|
495
|
+
addedProjects: [],
|
|
496
|
+
removedProjects: [],
|
|
497
|
+
changedProjects: [],
|
|
498
|
+
addedEdges: [],
|
|
499
|
+
removedEdges: [],
|
|
500
|
+
};
|
|
501
|
+
return {
|
|
502
|
+
architectureChanged: transition.architectureChanged,
|
|
503
|
+
projects: {
|
|
504
|
+
added: diff.addedProjects ?? [],
|
|
505
|
+
removed: diff.removedProjects ?? [],
|
|
506
|
+
changed: diff.changedProjects ?? [],
|
|
507
|
+
},
|
|
508
|
+
edges: { added: diff.addedEdges ?? [], removed: diff.removedEdges ?? [] },
|
|
509
|
+
policyChanged: transition.policyChanged,
|
|
510
|
+
policyOneSided: transition.policyOneSided,
|
|
511
|
+
providerChanged: transition.providerChanged,
|
|
512
|
+
provenanceChanged: transition.provenanceChanged,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The stable drift-finding id one intent finding owns — the SAME id the debt
|
|
518
|
+
* ledger derives for the same fact (`debtFactId` over `driftFactOf`), so an
|
|
519
|
+
* event's `findings.introduced`/`findings.resolved` link the W5 ledger.
|
|
520
|
+
*
|
|
521
|
+
* @param {object} finding A `judgeIntent` finding (`{source, target, rule, …}`).
|
|
522
|
+
* @returns {string}
|
|
523
|
+
*/
|
|
524
|
+
function driftFindingId(finding) {
|
|
525
|
+
return debtFactId("drift", driftFactOf(finding));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The comparable axis answer when a side's intent could not be judged.
|
|
530
|
+
*
|
|
531
|
+
* @param {object|undefined} baseIntent
|
|
532
|
+
* @param {object|undefined} headIntent
|
|
533
|
+
* @returns {string}
|
|
534
|
+
*/
|
|
535
|
+
function intentIncomparableReason(baseIntent, headIntent) {
|
|
536
|
+
const sideReason = (side, intent) =>
|
|
537
|
+
side === "base"
|
|
538
|
+
? `base intent unjudgeable${typeof intent?.note === "string" ? ` — ${intent.note}` : ""}`
|
|
539
|
+
: `head intent unjudgeable${typeof intent?.note === "string" ? ` — ${intent.note}` : ""}`;
|
|
540
|
+
if (!isRealIntentVerdict(baseIntent)) return sideReason("base", baseIntent);
|
|
541
|
+
if (!isRealIntentVerdict(headIntent)) return sideReason("head", headIntent);
|
|
542
|
+
return "intent could not be compared";
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Builds the per-transition 8-question comparison — the comparable evidence
|
|
547
|
+
* for one base→head revision pair (design §7/§8). Every question field EXISTS
|
|
548
|
+
* with either real facts or the `{available: false, reason}` marker; an
|
|
549
|
+
* incomparable axis is NEVER folded into a clean empty result (the invariant:
|
|
550
|
+
* "an empty result is a claim, not a shrug").
|
|
551
|
+
*
|
|
552
|
+
* @param {object} from The base snapshot (`snapshots[i]`, with its `evidence`).
|
|
553
|
+
* @param {object} to The head snapshot (`snapshots[i+1]`, with its `evidence`).
|
|
554
|
+
* @param {object} transition The `evolution.transitions[i]` record.
|
|
555
|
+
* @returns {object} The comparison object for the envelope and report.
|
|
556
|
+
*/
|
|
557
|
+
function buildTransitionComparison(from, to, transition) {
|
|
558
|
+
const observed = transitionObserved(transition);
|
|
559
|
+
|
|
560
|
+
const fromEvidence = from.evidence ?? {};
|
|
561
|
+
const toEvidence = to.evidence ?? {};
|
|
562
|
+
const baseIntent = fromEvidence.intent;
|
|
563
|
+
const headIntent = toEvidence.intent;
|
|
564
|
+
const intentComparable = isRealIntentVerdict(baseIntent) && isRealIntentVerdict(headIntent);
|
|
565
|
+
|
|
566
|
+
/** @type {{introduced: string[], resolved: string[], unknown: string[], note?: string}|{available: false, reason: string}} */
|
|
567
|
+
let findings;
|
|
568
|
+
if (intentComparable) {
|
|
569
|
+
const baseDrift = new Set((baseIntent.findings ?? []).map(driftFindingId));
|
|
570
|
+
const headDrift = new Set((headIntent.findings ?? []).map(driftFindingId));
|
|
571
|
+
findings = {
|
|
572
|
+
introduced: [...headDrift].filter((id) => !baseDrift.has(id)).sort(),
|
|
573
|
+
resolved: [...baseDrift].filter((id) => !headDrift.has(id)).sort(),
|
|
574
|
+
unknown: [],
|
|
575
|
+
};
|
|
576
|
+
} else {
|
|
577
|
+
findings = { available: false, reason: intentIncomparableReason(baseIntent, headIntent) };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @type {{introduced: string[], resolved: string[]}|{available: false, reason: string}} */
|
|
581
|
+
let debt;
|
|
582
|
+
if (intentComparable) {
|
|
583
|
+
debt = debtChangeDiff(baseIntent, headIntent);
|
|
584
|
+
} else {
|
|
585
|
+
debt = { available: false, reason: intentIncomparableReason(baseIntent, headIntent) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The drift-finding feed for `classifyEvolution` — supplied ONLY when both
|
|
589
|
+
// intents were actually judged, so a could-not-look is never absorbed into a
|
|
590
|
+
// clean class set. Resolved drift findings feed REPAIR through
|
|
591
|
+
// `driftFindingsResolved`; `debtResolved` carries the same closed-debt fact
|
|
592
|
+
// in the aggregate (drift ids plus gap ids), so a gap closure the per-finding
|
|
593
|
+
// diff cannot see still classifies as REPAIR rather than folding into a clean
|
|
594
|
+
// class set. `violations.resolved` stays out: the debt answer already carries
|
|
595
|
+
// the closed-debt fact.
|
|
596
|
+
const driftComparable = intentComparable;
|
|
597
|
+
const headDriftIds = new Set((headIntent?.findings ?? []).map(driftFindingId));
|
|
598
|
+
const baseDriftIds = new Set((baseIntent?.findings ?? []).map(driftFindingId));
|
|
599
|
+
const violationsIntroduced = driftComparable
|
|
600
|
+
? [...headDriftIds]
|
|
601
|
+
.filter((id) => !baseDriftIds.has(id))
|
|
602
|
+
.sort()
|
|
603
|
+
.map((id) => ({ id, waived: false }))
|
|
604
|
+
: [];
|
|
605
|
+
const driftFindingsResolved = driftComparable
|
|
606
|
+
? [...baseDriftIds].filter((id) => !headDriftIds.has(id)).sort()
|
|
607
|
+
: [];
|
|
608
|
+
|
|
609
|
+
// Declared constraints: the HEAD-side fitness decisions that carry a
|
|
610
|
+
// verdict classifyEvolution can act on (`pass`/`fail` classify; `unknown`
|
|
611
|
+
// discloses a could-not-determine → no-verdict). `not_applicable` rows are
|
|
612
|
+
// skipped here — a declared-but-matching-nothing function is a report row,
|
|
613
|
+
// not a violation and not an unknown, so it must not force a fabricated
|
|
614
|
+
// no-verdict; it rides only the fitness verdictDeltas.
|
|
615
|
+
const headDecisions = Array.isArray(toEvidence.fitness?.decisions)
|
|
616
|
+
? toEvidence.fitness.decisions
|
|
617
|
+
: [];
|
|
618
|
+
const declaredConstraints = headDecisions
|
|
619
|
+
.filter((d) => d.verdict === "pass" || d.verdict === "fail" || d.verdict === "unknown")
|
|
620
|
+
.map((d) => ({ id: d.name, verdict: d.verdict }));
|
|
621
|
+
|
|
622
|
+
// The ADR registry each side records: `{records}` when the side's registry
|
|
623
|
+
// is readable AND non-empty; `null` when the side records no registry or its
|
|
624
|
+
// registry is unreadable (the note is surfaced into the comparison's notes).
|
|
625
|
+
const adrSide = (side) => {
|
|
626
|
+
const evidence = side === "base" ? fromEvidence : toEvidence;
|
|
627
|
+
const adr = evidence.adr;
|
|
628
|
+
if (
|
|
629
|
+
adr === undefined ||
|
|
630
|
+
adr === null ||
|
|
631
|
+
typeof adr.note === "string" ||
|
|
632
|
+
!Array.isArray(adr.records) ||
|
|
633
|
+
adr.records.length === 0
|
|
634
|
+
) {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
return { records: adr.records };
|
|
638
|
+
};
|
|
639
|
+
const adrBase = adrSide("base");
|
|
640
|
+
const adrHead = adrSide("head");
|
|
641
|
+
|
|
642
|
+
const directionNotes = [];
|
|
643
|
+
const adrNote = (side) => {
|
|
644
|
+
const adr = (side === "base" ? fromEvidence : toEvidence).adr;
|
|
645
|
+
return typeof adr?.note === "string" ? adr.note : null;
|
|
646
|
+
};
|
|
647
|
+
const baseAdrNote = adrNote("base");
|
|
648
|
+
const headAdrNote = adrNote("head");
|
|
649
|
+
if (baseAdrNote !== null) {
|
|
650
|
+
directionNotes.push(`base decision registry unreadable — ${baseAdrNote}`);
|
|
651
|
+
}
|
|
652
|
+
if (headAdrNote !== null) {
|
|
653
|
+
directionNotes.push(`head decision registry unreadable — ${headAdrNote}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const fitnessComparable =
|
|
657
|
+
Array.isArray(fromEvidence.fitness?.decisions) && Array.isArray(toEvidence.fitness?.decisions);
|
|
658
|
+
|
|
659
|
+
/** @type {{verdictDeltas: object[]}|{available: false, reason: string}} */
|
|
660
|
+
let fitness;
|
|
661
|
+
if (fitnessComparable) {
|
|
662
|
+
const names = new Set([
|
|
663
|
+
...fromEvidence.fitness.decisions.map((d) => d.name),
|
|
664
|
+
...toEvidence.fitness.decisions.map((d) => d.name),
|
|
665
|
+
]);
|
|
666
|
+
const verdictDelta = (side, name) => {
|
|
667
|
+
const decisions = (side === "base" ? fromEvidence : toEvidence).fitness.decisions;
|
|
668
|
+
const found = decisions.find((d) => d.name === name);
|
|
669
|
+
if (found !== undefined) return found.verdict;
|
|
670
|
+
return {
|
|
671
|
+
available: false,
|
|
672
|
+
reason: `not declared/judged at ${side}`,
|
|
673
|
+
};
|
|
674
|
+
};
|
|
675
|
+
fitness = {
|
|
676
|
+
verdictDeltas: [...names].sort().map((name) => ({
|
|
677
|
+
id: name,
|
|
678
|
+
base: verdictDelta("base", name),
|
|
679
|
+
head: verdictDelta("head", name),
|
|
680
|
+
})),
|
|
681
|
+
};
|
|
682
|
+
} else {
|
|
683
|
+
const baseFitness = fromEvidence.fitness;
|
|
684
|
+
const headFitness = toEvidence.fitness;
|
|
685
|
+
let reason;
|
|
686
|
+
if (baseFitness?.verdict === "not_applicable") {
|
|
687
|
+
reason = "no fitness block declared at base";
|
|
688
|
+
} else if (headFitness?.verdict === "not_applicable") {
|
|
689
|
+
reason = "no fitness block declared at head";
|
|
690
|
+
} else if (!Array.isArray(baseFitness?.decisions)) {
|
|
691
|
+
reason = `base fitness unjudgeable${typeof baseFitness?.note === "string" ? ` — ${baseFitness.note}` : ""}`;
|
|
692
|
+
} else {
|
|
693
|
+
reason = `head fitness unjudgeable${typeof headFitness?.note === "string" ? ` — ${headFitness.note}` : ""}`;
|
|
694
|
+
}
|
|
695
|
+
fitness = { available: false, reason };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const coverageAnswer = {
|
|
699
|
+
base: {
|
|
700
|
+
projects: from.coverage?.projects ?? 0,
|
|
701
|
+
analyzedFiles: from.coverage?.analyzedFiles ?? 0,
|
|
702
|
+
imports: from.coverage?.imports ?? 0,
|
|
703
|
+
},
|
|
704
|
+
head: {
|
|
705
|
+
projects: to.coverage?.projects ?? 0,
|
|
706
|
+
analyzedFiles: to.coverage?.analyzedFiles ?? 0,
|
|
707
|
+
imports: to.coverage?.imports ?? 0,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
/* F-EVO-4: when intent exists on only one side (or a side is a refusal
|
|
712
|
+
* object rather than a real verdict), the pair is NOT fully comparable and
|
|
713
|
+
* its disposition must never read `accepted`. `classifyEvolution` only
|
|
714
|
+
* reaches that conclusion through `verdictRelevantUnknown`, so we feed it
|
|
715
|
+
* an unknown entry — unless BOTH sides carry no intent evidence at all
|
|
716
|
+
* (base and head both `no-verdict` refusals), which is the status-quo
|
|
717
|
+
* baseline W8 pins as `DRIFT`/`accepted`. */
|
|
718
|
+
const bothAbsent = !isRealIntentVerdict(baseIntent) && !isRealIntentVerdict(headIntent);
|
|
719
|
+
const classification = classifyEvolution({
|
|
720
|
+
observed,
|
|
721
|
+
codeDrift: transition.codeDrift === true,
|
|
722
|
+
...(driftComparable
|
|
723
|
+
? {
|
|
724
|
+
violations: { introduced: violationsIntroduced },
|
|
725
|
+
driftFindingsResolved,
|
|
726
|
+
debtResolved: "resolved" in debt ? debt.resolved : [],
|
|
727
|
+
}
|
|
728
|
+
: {}),
|
|
729
|
+
...(!driftComparable && !bothAbsent
|
|
730
|
+
? {
|
|
731
|
+
violations: {
|
|
732
|
+
unknown: [{ id: "intent", reason: intentIncomparableReason(baseIntent, headIntent) }],
|
|
733
|
+
},
|
|
734
|
+
}
|
|
735
|
+
: {}),
|
|
736
|
+
...(declaredConstraints.length > 0 ? { declaredConstraints } : {}),
|
|
737
|
+
adrBase,
|
|
738
|
+
adrHead,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// The richer affected-decisions lineage: which ADR each head-side fitness
|
|
742
|
+
// id binds to, resolved against the head registry. `classifyEvolution`
|
|
743
|
+
// owns the DECISION_CHANGE predicate (`classification.affected.decisions`);
|
|
744
|
+
// this reuses the shared resolver for the per-id binding detail.
|
|
745
|
+
let affectedLineage;
|
|
746
|
+
const headAdr = toEvidence.adr;
|
|
747
|
+
const headRegistry =
|
|
748
|
+
headAdr !== undefined &&
|
|
749
|
+
headAdr !== null &&
|
|
750
|
+
typeof headAdr.note !== "string" &&
|
|
751
|
+
headAdr.byId instanceof Map
|
|
752
|
+
? { records: headAdr.records, byId: headAdr.byId }
|
|
753
|
+
: null;
|
|
754
|
+
if (headRegistry !== null) {
|
|
755
|
+
affectedLineage = computeAffectedDecisions(
|
|
756
|
+
headRegistry,
|
|
757
|
+
headDecisions.map((d) => ({ id: d.name, verdict: d.verdict })),
|
|
758
|
+
[],
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const notes = [...classification.notes, ...transition.notes, ...directionNotes];
|
|
763
|
+
const affected = {
|
|
764
|
+
...classification.affected,
|
|
765
|
+
...(affectedLineage !== undefined ? { lineage: affectedLineage.lineage } : {}),
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
observed,
|
|
770
|
+
findings,
|
|
771
|
+
debt,
|
|
772
|
+
fitness,
|
|
773
|
+
coverage: coverageAnswer,
|
|
774
|
+
...(classification.classifications.length > 0
|
|
775
|
+
? { classifications: classification.classifications }
|
|
776
|
+
: { classifications: [] }),
|
|
777
|
+
disposition: classification.disposition,
|
|
778
|
+
affected,
|
|
779
|
+
notes: [...new Set(notes)],
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Assembles the transition EvolutionEvent for one revision pair (design §3/§4)
|
|
785
|
+
* — the record `writeEvent` persists when `--event-out` is set. The event's
|
|
786
|
+
* identity (`id`/`dedupeKey`) derives from `{base, head}` alone, so only the
|
|
787
|
+
* full SHA + snapshot id ride in those two fields: a re-run over the same
|
|
788
|
+
* pair is byte-identical and the store proves idempotency. Wall-clock-bound
|
|
789
|
+
* fields (`recordedAt`) are carried but never key the event, mirroring
|
|
790
|
+
* delta.mjs's assembly.
|
|
791
|
+
*
|
|
792
|
+
* @param {object} from The base snapshot.
|
|
793
|
+
* @param {object} to The head snapshot.
|
|
794
|
+
* @param {object} transition The `evolution.transitions[i]` record.
|
|
795
|
+
* @param {object} comparison The comparison object for this pair.
|
|
796
|
+
* @returns {object} The event, minus `dedupeKey`/`id` (the caller sets those
|
|
797
|
+
* before `writeEvent`).
|
|
798
|
+
*/
|
|
799
|
+
function buildTransitionEvent(from, to, transition, comparison) {
|
|
800
|
+
const provenance = [];
|
|
801
|
+
if (typeof from.provenance?.commit === "string") {
|
|
802
|
+
provenance.push({ kind: "git-commit", ref: from.provenance.commit });
|
|
803
|
+
}
|
|
804
|
+
if (typeof to.provenance?.commit === "string") {
|
|
805
|
+
provenance.push({ kind: "git-commit", ref: to.provenance.commit });
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
|
|
809
|
+
kind: "transition",
|
|
810
|
+
source: "evolution",
|
|
811
|
+
base: { revision: from.sha, snapshot: from.id },
|
|
812
|
+
head: { revision: to.sha, snapshot: to.id },
|
|
813
|
+
recordedAt: recordOrigin({
|
|
814
|
+
by: "evolution",
|
|
815
|
+
tool: `archkeep:v${TOOL_VERSION}`,
|
|
816
|
+
clock: { now: referenceTime },
|
|
817
|
+
}),
|
|
818
|
+
observed: comparison.observed,
|
|
819
|
+
affected: comparison.affected,
|
|
820
|
+
findings: comparison.findings,
|
|
821
|
+
fitness: comparison.fitness,
|
|
822
|
+
debt: comparison.debt,
|
|
823
|
+
classifications: comparison.classifications,
|
|
824
|
+
disposition: comparison.disposition,
|
|
825
|
+
notes: comparison.notes,
|
|
826
|
+
provenance,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Aggregates every transition comparison into one `result.summary` (design
|
|
832
|
+
* §8). The disposition is the worst across transitions; classifications and
|
|
833
|
+
* affected identities are unique sorted unions. An axis is unioned across
|
|
834
|
+
* transitions ONLY when every transition is comparable — an incomparable
|
|
835
|
+
* axis at any transition is surfaced as `{available: false, reason}` naming
|
|
836
|
+
* the transition index, never folded into a fabricated clean aggregate.
|
|
837
|
+
*
|
|
838
|
+
* @param {object[]} comparisons The per-transition comparison objects.
|
|
839
|
+
* @returns {object} The summary.
|
|
840
|
+
*/
|
|
841
|
+
function buildEvolutionSummary(comparisons) {
|
|
842
|
+
const dispositionRank = { accepted: 1, rejected: 2, "no-verdict": 3 };
|
|
843
|
+
let disposition = "accepted";
|
|
844
|
+
for (const comparison of comparisons) {
|
|
845
|
+
if ((dispositionRank[comparison.disposition] ?? 1) > dispositionRank[disposition]) {
|
|
846
|
+
disposition = comparison.disposition;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const unique = (items) => [...new Set(items)].sort();
|
|
851
|
+
const unionAxis = (axis) => {
|
|
852
|
+
// `{available: false}` markers propagate: the summary of an incomparable
|
|
853
|
+
// axis is itself incomparable, never a union that hides the gap.
|
|
854
|
+
const markerIndex = comparisons.findIndex(
|
|
855
|
+
(c) => c[axis] !== undefined && c[axis].available === false,
|
|
856
|
+
);
|
|
857
|
+
if (markerIndex !== -1) {
|
|
858
|
+
return {
|
|
859
|
+
available: false,
|
|
860
|
+
reason: `transition ${markerIndex} not comparable: ${comparisons[markerIndex][axis].reason}`,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
const collect = (field) =>
|
|
864
|
+
unique(comparisons.flatMap((c) => (c[axis] !== undefined ? (c[axis][field] ?? []) : [])));
|
|
865
|
+
if (axis === "fitness") {
|
|
866
|
+
// Fitness aggregates on verdictDeltas: one delta per fitness id across
|
|
867
|
+
// the whole range. The id's summary base/head are the range's first base
|
|
868
|
+
// and last head ONLY when the id carries a real verdict on both sides of
|
|
869
|
+
// EVERY transition that declares it — a transition that never judges the
|
|
870
|
+
// id (or carries an incomparable marker) must surface as a marker naming
|
|
871
|
+
// the first broken transition, never as a fabricated pass→pass.
|
|
872
|
+
const ids = unique(
|
|
873
|
+
comparisons.flatMap((c) => (c.fitness.verdictDeltas ?? []).map((d) => d.id)),
|
|
874
|
+
);
|
|
875
|
+
const verdictDeltas = ids.map((id) => {
|
|
876
|
+
const deltas = comparisons.flatMap((c) =>
|
|
877
|
+
(c.fitness.verdictDeltas ?? [])
|
|
878
|
+
.filter((d) => d.id === id)
|
|
879
|
+
.map((d) => ({ delta: d, index: comparisons.indexOf(c) })),
|
|
880
|
+
);
|
|
881
|
+
// The first transition that declares the id without a real verdict on
|
|
882
|
+
// both sides names the break; an id never declared is its own marker.
|
|
883
|
+
const broken = deltas.find(
|
|
884
|
+
({ delta }) =>
|
|
885
|
+
delta.base === undefined ||
|
|
886
|
+
delta.base === null ||
|
|
887
|
+
typeof delta.base !== "string" ||
|
|
888
|
+
delta.head === undefined ||
|
|
889
|
+
delta.head === null ||
|
|
890
|
+
typeof delta.head !== "string",
|
|
891
|
+
);
|
|
892
|
+
if (broken !== undefined) {
|
|
893
|
+
return {
|
|
894
|
+
id,
|
|
895
|
+
base: {
|
|
896
|
+
available: false,
|
|
897
|
+
reason: `not declared/judged at transition ${broken.index}`,
|
|
898
|
+
},
|
|
899
|
+
head: {
|
|
900
|
+
available: false,
|
|
901
|
+
reason: `not declared/judged at transition ${broken.index}`,
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
if (deltas.length === 0) {
|
|
906
|
+
return {
|
|
907
|
+
id,
|
|
908
|
+
base: { available: false, reason: "never comparable at base" },
|
|
909
|
+
head: { available: false, reason: "never comparable at head" },
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
id,
|
|
914
|
+
base: deltas[0].delta.base,
|
|
915
|
+
head: deltas[deltas.length - 1].delta.head,
|
|
916
|
+
};
|
|
917
|
+
});
|
|
918
|
+
return { verdictDeltas };
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
introduced: collect("introduced"),
|
|
922
|
+
resolved: collect("resolved"),
|
|
923
|
+
...(axis === "findings" ? { unknown: collect("unknown") } : {}),
|
|
924
|
+
};
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const findings = unionAxis("findings");
|
|
928
|
+
const debt = unionAxis("debt");
|
|
929
|
+
const fitness = unionAxis("fitness");
|
|
930
|
+
|
|
931
|
+
const observed = {
|
|
932
|
+
architectureChanged: comparisons.filter((c) => c.observed.architectureChanged === true).length,
|
|
933
|
+
projects: {
|
|
934
|
+
added: unique(comparisons.flatMap((c) => c.observed.projects.added.map((p) => p.name ?? p))),
|
|
935
|
+
removed: unique(
|
|
936
|
+
comparisons.flatMap((c) => c.observed.projects.removed.map((p) => p.name ?? p)),
|
|
937
|
+
),
|
|
938
|
+
changed: unique(
|
|
939
|
+
comparisons.flatMap((c) => c.observed.projects.changed.map((p) => p.name ?? p)),
|
|
940
|
+
),
|
|
941
|
+
},
|
|
942
|
+
edges: {
|
|
943
|
+
added: unique(
|
|
944
|
+
comparisons.flatMap((c) =>
|
|
945
|
+
c.observed.edges.added.map((e) =>
|
|
946
|
+
e.source && e.target && e.type
|
|
947
|
+
? `${e.source}→${e.target}:${e.type}`
|
|
948
|
+
: JSON.stringify(e),
|
|
949
|
+
),
|
|
950
|
+
),
|
|
951
|
+
),
|
|
952
|
+
removed: unique(
|
|
953
|
+
comparisons.flatMap((c) =>
|
|
954
|
+
c.observed.edges.removed.map((e) =>
|
|
955
|
+
e.source && e.target && e.type
|
|
956
|
+
? `${e.source}→${e.target}:${e.type}`
|
|
957
|
+
: JSON.stringify(e),
|
|
958
|
+
),
|
|
959
|
+
),
|
|
960
|
+
),
|
|
961
|
+
},
|
|
962
|
+
// F-EVO-3: a transition whose policy change could not be compared reads
|
|
963
|
+
// as a count of how many changed, never a number that hides the axis was
|
|
964
|
+
// unjudgeable at one point — surface it as a marker naming the transition.
|
|
965
|
+
policyChanged: (() => {
|
|
966
|
+
const unjudgeable = comparisons.findIndex((c) => c.observed.policyChanged === null);
|
|
967
|
+
if (unjudgeable !== -1) {
|
|
968
|
+
return {
|
|
969
|
+
available: false,
|
|
970
|
+
reason: `policy could not be compared at transition ${unjudgeable}`,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
return comparisons.filter((c) => c.observed.policyChanged === true).length;
|
|
974
|
+
})(),
|
|
975
|
+
providerChanged: comparisons.filter((c) => c.observed.providerChanged === true).length,
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const affected = {
|
|
979
|
+
projects: unique(comparisons.flatMap((c) => c.affected?.projects ?? [])),
|
|
980
|
+
boundaries: unique(comparisons.flatMap((c) => c.affected?.boundaries ?? [])),
|
|
981
|
+
constraints: unique(comparisons.flatMap((c) => c.affected?.constraints ?? [])),
|
|
982
|
+
decisions: unique(comparisons.flatMap((c) => c.affected?.decisions ?? [])),
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
const notes = unique(comparisons.flatMap((c) => c.notes ?? []));
|
|
986
|
+
return {
|
|
987
|
+
transitions: comparisons.length,
|
|
988
|
+
disposition,
|
|
989
|
+
classifications: unique(comparisons.flatMap((c) => c.classifications ?? [])),
|
|
990
|
+
observed,
|
|
991
|
+
affected,
|
|
992
|
+
findings,
|
|
993
|
+
debt,
|
|
994
|
+
fitness,
|
|
995
|
+
notes,
|
|
292
996
|
};
|
|
293
997
|
}
|
|
294
998
|
|
|
@@ -326,19 +1030,22 @@ function releaseWorktree(root, dir, { run = runProcess } = {}) {
|
|
|
326
1030
|
* invoked from — the envelope header describes THIS tree, and every git
|
|
327
1031
|
* question is answered in it; the analyzed revisions are materialized
|
|
328
1032
|
* elsewhere.
|
|
329
|
-
* @param {{base: string, head?: string|null}} range The raw
|
|
1033
|
+
* @param {{base: string, head?: string|null, eventOut?: string|null}} range The raw
|
|
1034
|
+
* revisions. `eventOut` an optional directory; when set, one EvolutionEvent is
|
|
1035
|
+
* written per revision pair (idempotent, design §3/§4).
|
|
330
1036
|
* @param {{run?: Function, makeTempRoot?: Function, resolveContext?: Function,
|
|
331
1037
|
* resolveProvenance?: Function, readGraph?: Function, listFiles?: Function}} [io]
|
|
332
1038
|
* Injectable seams. `makeTempRoot` defaults to a fresh `mkdtemp` directory
|
|
333
1039
|
* under the OS temp dir; `readGraph`/`listFiles` thread into every analyzed
|
|
334
1040
|
* revision's context the way `../../cli.mjs` threads them into one.
|
|
335
|
-
* @returns {Promise<{status: "ok", result:
|
|
1041
|
+
* @returns {Promise<{status: "ok", result: {base: string, head: string,
|
|
1042
|
+
* revisions: object[], transitions: object[], summary: object}, coverage: object,
|
|
336
1043
|
* report: {text: string, json: string}}>}
|
|
337
1044
|
* @throws {Error} on every condition listed in this module's header — an
|
|
338
1045
|
* unusable selection, an unanalyzable revision, a failed worktree, a git
|
|
339
1046
|
* failure — never a shorter record for any of them.
|
|
340
1047
|
*/
|
|
341
|
-
export async function evolutionCommand(root, { base, head = null }, io = {}) {
|
|
1048
|
+
export async function evolutionCommand(root, { base, head = null, eventOut = null }, io = {}) {
|
|
342
1049
|
const run = io.run ?? runProcess;
|
|
343
1050
|
const identity = describeWorkspaceRoot(root);
|
|
344
1051
|
const provenanceResolver = io.resolveProvenance ?? resolveProvenance;
|
|
@@ -415,6 +1122,41 @@ export async function evolutionCommand(root, { base, head = null }, io = {}) {
|
|
|
415
1122
|
})),
|
|
416
1123
|
);
|
|
417
1124
|
|
|
1125
|
+
// Wave 3 W7 (design §7/§8): per-transition comparable evidence (the
|
|
1126
|
+
// 8-question comparison) plus, when `--event-out` is set, one transition
|
|
1127
|
+
// EvolutionEvent per revision pair — idempotent, keyed on byte-stable
|
|
1128
|
+
// base/head revisions.
|
|
1129
|
+
const comparisons = [];
|
|
1130
|
+
for (let index = 0; index < evolution.transitions.length; index++) {
|
|
1131
|
+
const transition = /** @type {object} */ (evolution.transitions[index]);
|
|
1132
|
+
const comparison = buildTransitionComparison(
|
|
1133
|
+
snapshots[index],
|
|
1134
|
+
snapshots[index + 1],
|
|
1135
|
+
transition,
|
|
1136
|
+
);
|
|
1137
|
+
transition.comparison = comparison;
|
|
1138
|
+
comparisons.push(comparison);
|
|
1139
|
+
|
|
1140
|
+
if (eventOut) {
|
|
1141
|
+
const event = buildTransitionEvent(
|
|
1142
|
+
snapshots[index],
|
|
1143
|
+
snapshots[index + 1],
|
|
1144
|
+
transition,
|
|
1145
|
+
comparison,
|
|
1146
|
+
);
|
|
1147
|
+
event.dedupeKey = eventDedupeKey(event);
|
|
1148
|
+
event.id = eventId(event);
|
|
1149
|
+
const write = writeEvent(eventOut, event, { root });
|
|
1150
|
+
transition.eventWrite = {
|
|
1151
|
+
dir: eventOut,
|
|
1152
|
+
id: write.id,
|
|
1153
|
+
duplicate: write.duplicate,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const summary = buildEvolutionSummary(comparisons);
|
|
1159
|
+
|
|
418
1160
|
const headSnapshot = snapshots[snapshots.length - 1];
|
|
419
1161
|
const userProvenance = provenanceResolver(root);
|
|
420
1162
|
const notes = [
|
|
@@ -443,8 +1185,19 @@ export async function evolutionCommand(root, { base, head = null }, io = {}) {
|
|
|
443
1185
|
const result = {
|
|
444
1186
|
base: selection.base,
|
|
445
1187
|
head: selection.head,
|
|
446
|
-
|
|
1188
|
+
// Wave 3 W7 (design §7): each revision also carries its comparable
|
|
1189
|
+
// evidence — the intent verdict over its own graph, the fitness verdicts
|
|
1190
|
+
// its own law judges, and the ADR registry its own tree records — so a
|
|
1191
|
+
// trend report can name its basis per revision. Additive: `{commit, id}`
|
|
1192
|
+
// are unchanged, and a revision that supplied nothing to compare omits
|
|
1193
|
+
// the key entirely (an absent `evidence` reads as incomparable).
|
|
1194
|
+
revisions: snapshots.map(({ sha, id, evidence }) => ({
|
|
1195
|
+
commit: sha,
|
|
1196
|
+
id,
|
|
1197
|
+
...(evidence == null ? {} : { evidence }),
|
|
1198
|
+
})),
|
|
447
1199
|
transitions: evolution.transitions,
|
|
1200
|
+
summary,
|
|
448
1201
|
};
|
|
449
1202
|
|
|
450
1203
|
const envelope = jsonEnvelope({
|