@ecoma-io/archkeep 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. package/src/workspace.mjs +115 -23
@@ -0,0 +1,473 @@
1
+ /**
2
+ * The `evolution` command: the architecture's evolution across a bounded,
3
+ * explicit range of Git revisions, read with everything `history` reads it
4
+ * from — the same engine, the same snapshot identity, the same transition
5
+ * classification — with Git answering one question and only one question:
6
+ * which trees to read.
7
+ *
8
+ * `archkeep evolution --base <rev> [--head <rev>]` resolves both revisions in
9
+ * the workspace's own repository, materializes each selected commit into a
10
+ * temporary detached worktree (`git worktree add --detach` — the caller's
11
+ * working tree is never touched), analyzes every materialized tree through
12
+ * `./context.mjs`'s ordinary pipeline, and classifies the transition between
13
+ * each consecutive pair with `./history.mjs`'s `computeEvolution`. Git names
14
+ * WHERE a change is first observed between two analyzed revisions; Archkeep
15
+ * owns every architecture judgment over those revisions. Nothing here parses
16
+ * diffs, blames lines, or infers intent — a commit message carries no
17
+ * evidence this command can verify, so none is reported.
18
+ *
19
+ * ## What Git is asked, and what it is never asked
20
+ *
21
+ * Asked: which commit a revision names (`rev-parse --verify`), whether base
22
+ * precedes head (`merge-base --is-ancestor`), which single-parent commits lie
23
+ * between them oldest-first (`rev-list --reverse --parents`), and where a
24
+ * disposable copy of each tree can live (`worktree add` / `worktree remove`).
25
+ * Never asked: what a change MEANS. Classification is `computeEvolution`'s —
26
+ * architecture, policy, provider, code drift — decided exactly as
27
+ * `history` decides it, from evidence each revision's own analysis produced.
28
+ *
29
+ * ## The MVP's deliberate narrowness, stated rather than hidden
30
+ *
31
+ * - **Linear ranges only.** Every selected commit must have exactly one
32
+ * parent; a merge commit inside `base..head` refuses the run loudly, because
33
+ * flattening a merge would attribute a whole branch's architectural changes
34
+ * to one commit the reader cannot see behind. There is no `--first-parent`
35
+ * mode yet: a range ending at a merge, or spanning one, fails rather than
36
+ * pretending merges are modeled.
37
+ * - **Committed state only.** Every analyzed revision is materialized from a
38
+ * commit object, so the working tree's uncommitted changes belong to no
39
+ * analyzed revision — by construction, not by neglect. When the working
40
+ * tree is dirty, `coverage.notes` says so rather than letting a reader
41
+ * assume the tip analysis saw their desk.
42
+ * - **Bounded selection.** The command analyzes exactly the commits named by
43
+ * the range — never a whole repository. Cost is O(selected revisions)
44
+ * worktrees × one full analysis each; a wide range is slow by construction,
45
+ * and the merge refusal keeps most branched histories from being selected
46
+ * accidentally wide.
47
+ *
48
+ * ## What refuses, and why that is the quiet-direction answer
49
+ *
50
+ * Every condition below throws (exit 3 through `../../cli.mjs`) instead of
51
+ * degrading into a shorter or emptier record, because each one would
52
+ * otherwise read as "fewer changes happened":
53
+ *
54
+ * - an unresolved revision, a base that is not head's ancestor, a range whose
55
+ * ends coincide, a merge inside the range — the selection itself is
56
+ * unusable, and guessing a smaller one would fabricate history;
57
+ * - a revision that is not a readable workspace, or whose analysis leaves
58
+ * whole-file failures — the same bar `history --capture` holds (`./history.mjs`),
59
+ * because an under-represented revision would manufacture architecture
60
+ * changes out of unread files;
61
+ * - a boundary law a revision NAMES but that will not load — an absent law
62
+ * and a broken one must not report alike (`./policy.mjs`);
63
+ * - a failed worktree add/remove or any git failure.
64
+ *
65
+ * It is descriptive: it never exits 1. Where the architecture changed is a
66
+ * fact about history, not a finding about the tree.
67
+ */
68
+
69
+ import { mkdtempSync, rmSync } from "node:fs";
70
+ import { tmpdir } from "node:os";
71
+ import { join } from "node:path";
72
+
73
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
74
+ import { runProcess } from "../process.mjs";
75
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
76
+ import { formatEvolutionReport } from "../report/evolution-text.mjs";
77
+ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
78
+ import { computeEvolution, snapshotIdentity } from "./history.mjs";
79
+ import { resolveProvenance } from "./provenance.mjs";
80
+ import { resolveDescribedPolicy } from "./policy.mjs";
81
+ import { resolveCommandContext, describeWorkspaceRoot } from "./context.mjs";
82
+
83
+ /** A full SHA-1 object name, as `git rev-parse` answers it. */
84
+ const FULL_SHA = /^[0-9a-f]{40}$/;
85
+
86
+ /**
87
+ * The short form used inside error messages — long enough to stay unambiguous
88
+ * in every repository a human will type into, never part of the JSON output
89
+ * (which carries full SHAs).
90
+ *
91
+ * @param {string} sha
92
+ * @returns {string}
93
+ */
94
+ function shortSha(sha) {
95
+ return sha.slice(0, 12);
96
+ }
97
+
98
+ /**
99
+ * Resolves one revision to the full SHA of the commit it names.
100
+ *
101
+ * `^{commit}` peels tags and rejects trees and blobs, so `v1.2.0`, `main`,
102
+ * `HEAD~3`, and a raw SHA all land on the same answer: a commit object. An
103
+ * input beginning with `-` is refused before git sees it — git would read it
104
+ * as its own option, and an option spelled by a caller is an injection seam,
105
+ * not a revision.
106
+ *
107
+ * @param {string} root Absolute path inside the repository (the workspace
108
+ * root; git resolves the repository upward from there).
109
+ * @param {string} rev The revision the caller asked for.
110
+ * @param {"--base"|"--head"} flag Which flag named it, so the error points at
111
+ * the spelling that was wrong.
112
+ * @param {{run?: Function}} [io] Injectable spawner (`../process.mjs`).
113
+ * @returns {string} Full hex SHA-1 of the named commit.
114
+ * @throws {Error} when the revision does not name a commit reachable in this
115
+ * repository — including a repository that is not a git repository at all,
116
+ * and a shallow clone whose cut-off sits below the requested revision.
117
+ */
118
+ export function resolveRevision(root, rev, flag, { run = runProcess } = {}) {
119
+ if (typeof rev !== "string" || rev.length === 0) {
120
+ throw new Error(`archkeep: ${flag} needs a revision — a commit, branch, tag, or HEAD~n.`);
121
+ }
122
+ if (rev.startsWith("-")) {
123
+ throw new Error(
124
+ `archkeep: ${flag} '${rev}' starts with '-' and would be read by git as an option, ` +
125
+ `not a revision. Give a commit, branch, or tag name.`,
126
+ );
127
+ }
128
+ let out;
129
+ try {
130
+ // Everything after `--end-of-options` is a revision, whatever it starts
131
+ // with — belt to the braces above.
132
+ out = run("git", ["rev-parse", "--verify", "--end-of-options", `${rev}^{commit}`], root);
133
+ } catch (cause) {
134
+ throw new Error(
135
+ `archkeep: ${flag} '${rev}' does not name a commit reachable in this repository ` +
136
+ `(resolved from '${root}'). Give a branch, tag, or commit SHA that exists — ` +
137
+ `in a shallow clone, one fetched deep enough to reach it.`,
138
+ { cause },
139
+ );
140
+ }
141
+ const sha = out.trim();
142
+ if (!FULL_SHA.test(sha)) {
143
+ throw new Error(
144
+ `archkeep: ${flag} '${rev}' resolved to '${sha}', which is not a commit SHA — ` +
145
+ `refusing rather than analyze something this tool cannot name.`,
146
+ );
147
+ }
148
+ return sha;
149
+ }
150
+
151
+ /**
152
+ * Resolves and validates the selected range: both endpoints as commits, base
153
+ * an ancestor of head, and every commit between them single-parent.
154
+ *
155
+ * The ordering is `git rev-list --reverse`'s — oldest first, parents before
156
+ * children — with `base` prepended as the first ANALYZED revision, so the
157
+ * first transition is base → the commit that follows it. `--parents` is what
158
+ * makes the merge refusal cheap: the same walk that selects the commits
159
+ * already carries each one's parent count, so no second traversal is needed
160
+ * to refuse a merge.
161
+ *
162
+ * @param {string} root Absolute path inside the repository.
163
+ * @param {{base: string, head: string}} range The raw revisions from the CLI.
164
+ * @param {{run?: Function}} [io] Injectable spawner.
165
+ * @returns {{base: string, head: string, commits: string[]}} Resolved full
166
+ * SHAs, and every analyzed revision oldest-first — `base` first, `head` last.
167
+ * @throws {Error} on an unresolved revision, coincident endpoints, a base off
168
+ * head's ancestry, or a merge commit inside the range.
169
+ */
170
+ export function selectLinearRange(root, { base, head }, { run = runProcess } = {}) {
171
+ const baseSha = resolveRevision(root, base, "--base", { run });
172
+ const headSha = resolveRevision(root, head ?? "HEAD", "--head", { run });
173
+
174
+ if (baseSha === headSha) {
175
+ throw new Error(
176
+ `archkeep: --base and --head both resolve to ${baseSha} — the range selects nothing to ` +
177
+ `compare. Give a --base earlier in history than --head.`,
178
+ );
179
+ }
180
+
181
+ try {
182
+ run("git", ["merge-base", "--is-ancestor", baseSha, headSha], root);
183
+ } catch {
184
+ throw new Error(
185
+ `archkeep: --base ${shortSha(baseSha)} is not an ancestor of --head ${shortSha(headSha)} — ` +
186
+ `'evolution' describes one linear descent, so the base must lie on head's own history. ` +
187
+ `Choose a --base that --head descends from.`,
188
+ );
189
+ }
190
+
191
+ const lines = run("git", ["rev-list", "--reverse", "--parents", `${baseSha}..${headSha}`], root)
192
+ .split("\n")
193
+ .filter((line) => line !== "");
194
+ const commits = [baseSha];
195
+ for (const line of lines) {
196
+ const [sha, ...parents] = line.split(" ");
197
+ if (parents.length > 1) {
198
+ throw new Error(
199
+ `archkeep: ${sha} is a merge commit inside the selected range — this view describes ` +
200
+ `linear history only, and flattening a merge would pin a whole branch's ` +
201
+ `architectural changes on one commit. Select a range whose commits each have ` +
202
+ `one parent.`,
203
+ );
204
+ }
205
+ commits.push(sha);
206
+ }
207
+ return { base: baseSha, head: headSha, commits };
208
+ }
209
+
210
+ /**
211
+ * Analyzes ONE materialized revision through the ordinary pipeline — the same
212
+ * `resolveCommandContext`, the same policy ladder, the same builders, the
213
+ * same identity every captured snapshot carries — and returns the record
214
+ * `computeEvolution` reads.
215
+ *
216
+ * Nothing here knows the record came from Git. Given a directory, it answers
217
+ * exactly what `graph` would answer about that directory; the caller owns
218
+ * which directories exist and what they are named.
219
+ *
220
+ * @param {object} input
221
+ * @param {string} input.sha The commit the worktree was materialized from —
222
+ * carried into error messages and the returned record, never derived here.
223
+ * @param {string} input.dir Absolute path of the temporary worktree.
224
+ * @param {object} input.seams The `readGraph`/`listFiles` seams threaded from
225
+ * the CLI env, honored for EVERY analyzed tree a run reads — in production
226
+ * they are undefined and every tree is read for real.
227
+ * @param {{resolveContext?: Function, resolveProvenance?: Function}} [io]
228
+ * @returns {Promise<{sha: string, id: string, provider: string, provenance: object|null,
229
+ * projects: object[], dependencies: object[], fingerprint: string|null,
230
+ * coverage: {projects: number, analyzedFiles: number, imports: number}}>}
231
+ * @throws {Error} when the revision is not a readable workspace, when whole-file
232
+ * analysis failures leave the record under-represented, or when the law the
233
+ * revision names will not load.
234
+ */
235
+ async function analyzeRevision(input, io = {}) {
236
+ const { sha, dir, seams } = input;
237
+ const resolveContext = io.resolveContext ?? ((cwd) => resolveCommandContext({ cwd }, seams));
238
+ const provenanceResolver = io.resolveProvenance ?? resolveProvenance;
239
+
240
+ let context;
241
+ try {
242
+ context = await resolveContext(dir);
243
+ } catch (cause) {
244
+ // The raw refusal names the directory it probed — a temporary worktree
245
+ // path that exists only for this run. Wrap it so the error names the
246
+ // REVISION instead: "some revision was skipped" is the silent direction;
247
+ // "revision X could not be read" is an actionable fact.
248
+ throw new Error(
249
+ `archkeep: revision ${shortSha(sha)} could not be read as a workspace — ` +
250
+ `${cause?.message ?? cause}`,
251
+ { cause },
252
+ );
253
+ }
254
+ const notAnalyzed = context.analysis.failures.filter(isWholeFileFailure);
255
+ if (notAnalyzed.length > 0) {
256
+ const sample = notAnalyzed
257
+ .slice(0, 3)
258
+ .map(({ sourceFile }) => sourceFile)
259
+ .join(", ");
260
+ throw new Error(
261
+ `archkeep: revision ${shortSha(sha)} cannot be analyzed completely — ` +
262
+ `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} produced no verdict ` +
263
+ `(${sample}${notAnalyzed.length > 3 ? ", …" : ""}). Its architecture record would ` +
264
+ `under-represent the real graph, and a transition classified against a partial ` +
265
+ `picture would report changes the unread files may explain. Fix the unanalyzed ` +
266
+ `files at that revision, or select a range that excludes it.`,
267
+ );
268
+ }
269
+
270
+ const { config } = await resolveDescribedPolicy({ config: null }, context, dir);
271
+ const fingerprint = config ? computePolicyFingerprint(config) : null;
272
+ const projects = buildProjects(context.graph.nodes);
273
+ const dependencies = buildDependencies(context.graph.dependencies);
274
+
275
+ return {
276
+ sha,
277
+ id: snapshotIdentity({
278
+ projects,
279
+ dependencies,
280
+ policy: fingerprint === null ? null : { fingerprint },
281
+ }),
282
+ provider: context.provider,
283
+ provenance: provenanceResolver(dir),
284
+ projects,
285
+ dependencies,
286
+ fingerprint,
287
+ coverage: {
288
+ projects: projects.length,
289
+ analyzedFiles: context.analysis.analyzed,
290
+ imports: context.analysis.imports.length,
291
+ },
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Removes one worktree: git's own unregister first (so the repository's
297
+ * worktree metadata stays truthful), then the bytes. Both are needed — git's
298
+ * remove alone leaves nothing behind but fails if the directory already went,
299
+ * and deleting the directory alone would strand `.git/worktrees` entries.
300
+ *
301
+ * @param {string} root Repository-scoped directory the git calls run in.
302
+ * @param {string} dir The worktree to release.
303
+ * @param {{run?: Function}} [io]
304
+ */
305
+ function releaseWorktree(root, dir, { run = runProcess } = {}) {
306
+ let removeError = null;
307
+ try {
308
+ run("git", ["worktree", "remove", "--force", dir], root);
309
+ } catch (cause) {
310
+ removeError = cause;
311
+ }
312
+ rmSync(dir, { recursive: true, force: true });
313
+ if (removeError !== null) {
314
+ throw new Error(
315
+ `archkeep: releasing the temporary worktree '${dir}' failed — ` +
316
+ `${removeError?.message ?? removeError}`,
317
+ { cause: removeError },
318
+ );
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Runs the `evolution` command.
324
+ *
325
+ * @param {string} root Absolute path to the workspace root the command was
326
+ * invoked from — the envelope header describes THIS tree, and every git
327
+ * question is answered in it; the analyzed revisions are materialized
328
+ * elsewhere.
329
+ * @param {{base: string, head?: string|null}} range The raw revisions.
330
+ * @param {{run?: Function, makeTempRoot?: Function, resolveContext?: Function,
331
+ * resolveProvenance?: Function, readGraph?: Function, listFiles?: Function}} [io]
332
+ * Injectable seams. `makeTempRoot` defaults to a fresh `mkdtemp` directory
333
+ * under the OS temp dir; `readGraph`/`listFiles` thread into every analyzed
334
+ * revision's context the way `../../cli.mjs` threads them into one.
335
+ * @returns {Promise<{status: "ok", result: object, coverage: object,
336
+ * report: {text: string, json: string}}>}
337
+ * @throws {Error} on every condition listed in this module's header — an
338
+ * unusable selection, an unanalyzable revision, a failed worktree, a git
339
+ * failure — never a shorter record for any of them.
340
+ */
341
+ export async function evolutionCommand(root, { base, head = null }, io = {}) {
342
+ const run = io.run ?? runProcess;
343
+ const identity = describeWorkspaceRoot(root);
344
+ const provenanceResolver = io.resolveProvenance ?? resolveProvenance;
345
+
346
+ const selection = selectLinearRange(root, { base, head }, { run });
347
+
348
+ const makeTempRoot =
349
+ io.makeTempRoot ?? (() => mkdtempSync(join(tmpdir(), "archkeep-evolution-")));
350
+ const parent = makeTempRoot();
351
+ const seams = {};
352
+ if (io.readGraph !== undefined) seams.readGraph = io.readGraph;
353
+ if (io.listFiles !== undefined) seams.listFiles = io.listFiles;
354
+
355
+ const snapshots = [];
356
+ /** Worktrees still standing — emptied as each is released successfully. */
357
+ const standing = [];
358
+ try {
359
+ for (const [index, sha] of selection.commits.entries()) {
360
+ const dir = join(parent, `${index}-${sha.slice(0, 12)}`);
361
+ run("git", ["worktree", "add", "--quiet", "--detach", dir, sha], root);
362
+ standing.push(dir);
363
+ const snapshot = await analyzeRevision({ sha, dir, seams }, io);
364
+ releaseWorktree(root, dir, { run });
365
+ standing.pop();
366
+ snapshots.push(snapshot);
367
+ }
368
+ } finally {
369
+ // A revision whose analysis threw leaves its worktree here; release it
370
+ // WITHOUT letting a cleanup failure mask the original error — the bytes go
371
+ // either way when the parent directory goes, and the original failure is
372
+ // the one that explains the exit. On the success path this list is empty.
373
+ for (const dir of standing) {
374
+ try {
375
+ releaseWorktree(root, dir, { run });
376
+ } catch {
377
+ // The parent-directory removal below still removes the bytes; a stale
378
+ // worktree-admin entry is pruned next line, and masking the error that
379
+ // is already propagating would hide why the run failed.
380
+ }
381
+ }
382
+ rmSync(parent, { recursive: true, force: true });
383
+ try {
384
+ run("git", ["worktree", "prune"], root);
385
+ } catch {
386
+ // Cosmetic-only after the bytes are gone: prune clears leftover admin
387
+ // entries. Failing here must not overwrite a real verdict.
388
+ }
389
+ }
390
+
391
+ const evolution = computeEvolution(
392
+ snapshots.map((snapshot) => ({
393
+ // The record's "name" is the full commit SHA: the transition's from/to
394
+ // identity IS the revision, and a full SHA never abbreviates differently
395
+ // as the repository grows.
396
+ name: snapshot.sha,
397
+ path: null,
398
+ envelope: {
399
+ coverage: {
400
+ complete: true,
401
+ projects: snapshot.coverage.projects,
402
+ analyzedFiles: snapshot.coverage.analyzedFiles,
403
+ imports: snapshot.coverage.imports,
404
+ },
405
+ result: {
406
+ projects: snapshot.projects,
407
+ dependencies: snapshot.dependencies,
408
+ ...(snapshot.fingerprint === null
409
+ ? {}
410
+ : { policy: { fingerprint: snapshot.fingerprint } }),
411
+ },
412
+ workspace: { provider: snapshot.provider, provenance: snapshot.provenance },
413
+ },
414
+ id: snapshot.id,
415
+ })),
416
+ );
417
+
418
+ const headSnapshot = snapshots[snapshots.length - 1];
419
+ const userProvenance = provenanceResolver(root);
420
+ const notes = [
421
+ "each change is attributed to the first analyzed revision where it is observed — a fact " +
422
+ "about where history shows it, not about why it was made",
423
+ "rule-impact cannot be recomputed across revisions — each analyzed revision carries its " +
424
+ "graph and policy fingerprint, not import sites judged under a law. Run 'check' at a " +
425
+ "revision for its boundary verdict.",
426
+ ];
427
+ if (userProvenance?.dirty === true) {
428
+ notes.push(
429
+ "the working tree has uncommitted changes; they belong to no analyzed revision — every " +
430
+ "analyzed revision was materialized from committed state",
431
+ );
432
+ }
433
+ const coverage = {
434
+ complete: true,
435
+ projects: headSnapshot.coverage.projects,
436
+ analyzedFiles: headSnapshot.coverage.analyzedFiles,
437
+ imports: headSnapshot.coverage.imports,
438
+ notAnalyzed: [],
439
+ blindSpots: [],
440
+ notes,
441
+ };
442
+
443
+ const result = {
444
+ base: selection.base,
445
+ head: selection.head,
446
+ revisions: snapshots.map(({ sha, id }) => ({ commit: sha, id })),
447
+ transitions: evolution.transitions,
448
+ };
449
+
450
+ const envelope = jsonEnvelope({
451
+ command: "evolution",
452
+ context: {
453
+ root,
454
+ provider: identity.provider,
455
+ marker: identity.marker,
456
+ provenance: userProvenance,
457
+ },
458
+ status: "ok",
459
+ exitCode: 0,
460
+ coverage,
461
+ result,
462
+ });
463
+
464
+ return {
465
+ status: "ok",
466
+ result,
467
+ coverage,
468
+ report: {
469
+ text: formatEvolutionReport({ result, coverage }),
470
+ json: renderJson(envelope),
471
+ },
472
+ };
473
+ }