@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.
- package/README.md +11 -5
- package/cli.mjs +571 -61
- package/commands.mjs +57 -0
- package/lsp.mjs +15 -2
- package/package.json +8 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +11 -3
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +124 -16
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/path-util.mjs +40 -0
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/tsconfig-paths.mjs +3 -2
- 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
|
+
}
|