@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The `provenance` command: where this run's facts came from, whether the
3
+ * governance rows it judges carry an origin to attest them, and whether a
4
+ * `decisionRef` any of them cite actually resolves to a recorded decision.
5
+ *
6
+ * Provenance is descriptive, exactly like `graph`/`diff`/`drift`: it never
7
+ * changes a verdict, so it never exits 1. It answers three questions:
8
+ *
9
+ * 1. **Repository provenance** — the git commit, remote, and dirty state of the
10
+ * tree this run judged, through the shared `resolveProvenance`
11
+ * (`./provenance.mjs`). This is the answer the JSON envelope already carries
12
+ * for `graph`/`diff`/`drift`/`history`; this command makes it a first-class
13
+ * report instead of a line inside someone else's envelope.
14
+ * 2. **Decision provenance** — for every governance row in the workspace's
15
+ * declared intent (`architecture-intent.json`) and boundary config
16
+ * (`module-boundaries.config.mjs`, the `depConstraints` table), whether the
17
+ * row carries an `origin` block (the `by`/`tool`/`on?` record
18
+ * `../governance/provenance-record.mjs` owns). A row without an origin is
19
+ * flagged — `no origin recorded — cannot attest` — because a row whose
20
+ * decision nobody recorded is indistinguishable from a rule that appeared
21
+ * by editing the file directly. The report never pretends such a row is
22
+ * attested.
23
+ * 3. **Decision resolution** — the same row walk, checked against the
24
+ * workspace's ADR registry (`../governance/adr-registry.mjs`) through
25
+ * `readAdrContext` (`./adr.mjs`). `origin` says a decision was recorded;
26
+ * `decisionRef` says WHICH one, and until this axis existed nothing ever
27
+ * verified the citation was real — `resolveDecisionRef` had no production
28
+ * caller, and a row bound to a nonexistent ADR id read as legitimately
29
+ * documented everywhere it was rendered. A row with no `decisionRef` is
30
+ * not a finding here; a row whose `decisionRef` names nothing the registry
31
+ * knows is.
32
+ *
33
+ * ## Determinism
34
+ *
35
+ * Every artifact this command reads is a static file — the intent, the config,
36
+ * the ADR registry, git state. Two runs over an unchanged tree produce
37
+ * byte-identical output, and no wall-clock time ever enters the report
38
+ * (`../../../../AGENTS.md`).
39
+ *
40
+ * ## Fail-closed
41
+ *
42
+ * Every path that cannot reach an answer says so, in the imperative case:
43
+ *
44
+ * - git is not available or the tree is not a repository → `provenance: null`
45
+ * in the JSON envelope (the same explicit-null `graph`/`diff` carry), and the
46
+ * text report prints `repo provenance unavailable` rather than pretending a
47
+ * commit; the intent/config row arms still answer, because they read
48
+ * files, not git;
49
+ * - the intent file, boundary config, or ADR registry is malformed → throw →
50
+ * exit 3, exactly the same loud refusal `drift` makes (`./drift.mjs`): a
51
+ * row list — or a resolution verdict — built from a file that could not be
52
+ * read would be a claim about rows, or citations, that do not exist.
53
+ *
54
+ * An empty `unattested` list must mean exactly "every governance row carries
55
+ * an origin", an empty `unresolvedDecisionRefs` list must mean exactly "every
56
+ * decisionRef citation resolves", and neither means the other.
57
+ */
58
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
59
+ import { formatProvenanceReport } from "../report/provenance-text.mjs";
60
+ import { loadIntent } from "../architecture-intent/model.mjs";
61
+ import { loadBoundaryConfig } from "../config.mjs";
62
+ import { resolveProvenance } from "./provenance.mjs";
63
+ import { readAdrContext } from "./adr.mjs";
64
+ import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
65
+
66
+ /**
67
+ * Whether a row declares a governance origin (`origin.by`/`origin.tool`).
68
+ * Absence is the finding — a row without an origin cannot be attested.
69
+ *
70
+ * @param {object} row
71
+ * @returns {boolean}
72
+ */
73
+ export function hasOrigin(row) {
74
+ return (
75
+ row !== null &&
76
+ typeof row === "object" &&
77
+ !Array.isArray(row) &&
78
+ "origin" in row &&
79
+ row.origin !== null &&
80
+ typeof row.origin === "object" &&
81
+ !Array.isArray(row.origin) &&
82
+ "by" in row.origin &&
83
+ "tool" in row.origin &&
84
+ typeof row.origin.by === "string" &&
85
+ row.origin.by.length > 0 &&
86
+ typeof row.origin.tool === "string" &&
87
+ row.origin.tool.length > 0
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Describes one row so a human can find it. `kind` already carries the index
93
+ * and section; this appends the row's identity (the pair a rule reads) when
94
+ * the row has one.
95
+ *
96
+ * @param {string} kind e.g. `allowed[3]` or `depConstraints[0]`.
97
+ * @param {object} row
98
+ * @returns {string} e.g. `allowed[3] packages→extensions`
99
+ */
100
+ export function rowLabel(kind, row) {
101
+ const identity =
102
+ typeof row.source === "string" && typeof row.target === "string"
103
+ ? `${row.source}→${row.target}`
104
+ : typeof row.from === "string" && typeof row.to === "string"
105
+ ? `${row.from}→${row.to}`
106
+ : typeof row.name === "string"
107
+ ? row.name
108
+ : typeof row.sourceTag === "string"
109
+ ? row.sourceTag
110
+ : "";
111
+ return `${kind}${identity ? ` ${identity}` : ""}`;
112
+ }
113
+
114
+ /**
115
+ * Walks every governance row in the normalized intent model — the same rows
116
+ * `drift`'s judge counts, so a row list built here always matches the rows a
117
+ * verdict is a claim about. The intent model's rows carry the origin block
118
+ * additively (`../architecture-intent/model.mjs`), so a legacy row simply has
119
+ * no `origin` key.
120
+ *
121
+ * @param {object} intent The normalized intent model.
122
+ * @returns {{kind: string, row: object}[]}
123
+ */
124
+ export function intentRows(intent) {
125
+ const list = [];
126
+ const sections = [
127
+ ["allowed", intent.allowed ?? []],
128
+ ["forbidden", intent.forbidden ?? []],
129
+ ["projects.required", intent.projects?.required ?? []],
130
+ ["projects.forbidden", intent.projects?.forbidden ?? []],
131
+ ["dependencies.allowed", intent.dependencies?.allowed ?? []],
132
+ ["dependencies.forbidden", intent.dependencies?.forbidden ?? []],
133
+ ["forbiddenTags", intent.forbiddenTags ?? []],
134
+ ];
135
+ for (const [key, rows] of sections) {
136
+ for (const [index, row] of rows.entries()) {
137
+ list.push({ kind: `${key}[${index}]`, row });
138
+ }
139
+ }
140
+ return list;
141
+ }
142
+
143
+ /**
144
+ * Walks the boundary config's constraint rows — the `depConstraints` table,
145
+ * the same rows `check`/`diff`/`impact` judge through
146
+ * `../config.mjs`'s `findBoundaryConfigViolations`.
147
+ *
148
+ * @param {object} config The loaded boundary config module.
149
+ * @returns {{kind: string, row: object}[]}
150
+ */
151
+ export function configRows(config) {
152
+ const rows = config?.depConstraints ?? [];
153
+ return rows.map((row, index) => ({ kind: `depConstraints[${index}]`, row }));
154
+ }
155
+
156
+ /**
157
+ * Why an unresolved `decisionRef` is a finding, in the one wording every
158
+ * surface that reports one uses. Exported because `report`
159
+ * (`./report.mjs`) names the same condition in its own document, and a second
160
+ * hand-written copy of this sentence would drift from this one the first time
161
+ * either changed — the rule against stating a rule twice
162
+ * (`../../../../AGENTS.md`), applied to the sentence that carries it.
163
+ *
164
+ * @param {string} decisionRef The citation that resolved to nothing.
165
+ * @returns {string}
166
+ */
167
+ export function unresolvedDecisionRefNote(decisionRef) {
168
+ return `"${decisionRef}" does not resolve — no matching ADR, rule, or fitness record`;
169
+ }
170
+
171
+ /**
172
+ * The provenance verdict: three answer surfaces, each fail-closed.
173
+ *
174
+ * `repo` is the git provenance, `established` whether git could answer,
175
+ * `rows`/`unattested` the per-row decision provenance, and
176
+ * `unresolvedDecisionRefs` every row whose `decisionRef` cites no ADR, rule,
177
+ * or fitness record this workspace's registry knows. All three are findings
178
+ * about *documentation*, not about the architecture — this command never
179
+ * changes what `check` or `drift` decide, and it exits 0 when it completes.
180
+ *
181
+ * @param {{root: string, tracked: string[], provider: string, marker: string,
182
+ * options: {boundaryConfig: string|object, inline?: boolean}}} commandContext
183
+ * From `resolveCommandContext`.
184
+ * @param {{loadIntentOverride?: (root: string, io: object) => Promise<object>,
185
+ * loadConfigOverride?: (root: string, boundaryConfig: string) => Promise<object>,
186
+ * loadAdrRegistryOverride?: typeof import("../governance/adr-registry.mjs").loadAdrRegistry}} [io]
187
+ * `loadAdrRegistryOverride` is forwarded to `readAdrContext` (`./adr.mjs`)
188
+ * unchanged.
189
+ * @returns {Promise<{status: "ok", repo: {commit: string|null, remote: string|null,
190
+ * dirty: boolean|null, established: boolean},
191
+ * rows: {kind: string, attested: boolean, origin: object|null}[],
192
+ * unattested: {kind: string, label: string, note: string}[],
193
+ * unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[],
194
+ * report: {text: string, json: string}}>}
195
+ * @throws {Error} on a malformed intent, boundary config, or ADR registry —
196
+ * exit 3, the loud refusal every command that reads them makes.
197
+ */
198
+ export async function provenanceCommand(commandContext, io = {}) {
199
+ const { root, tracked, options } = commandContext;
200
+
201
+ const repo = resolveProvenance(root);
202
+ const rowList = [];
203
+ const unattested = [];
204
+
205
+ const intent = await (io.loadIntentOverride ?? loadIntent)(root, { tracked });
206
+ const introws = intent === undefined ? [] : intentRows(intent);
207
+ for (const { kind, row } of introws) {
208
+ const attested = hasOrigin(row);
209
+ rowList.push({ kind, attested, origin: attested ? row.origin : null });
210
+ if (!attested) {
211
+ unattested.push({
212
+ kind,
213
+ label: rowLabel(kind, row),
214
+ note: "no origin recorded — cannot attest",
215
+ });
216
+ }
217
+ }
218
+
219
+ // The boundary law is either a filename (the string form `loadBoundaryConfig`
220
+ // reads) or an inline policy object living directly in `archkeep.json`
221
+ // (`../providers/native/model.mjs`, `normalizeNativeModel`'s
222
+ // `inlineBoundaryConfig`). Both are walked — a policy whose rows the report
223
+ // never inspected would claim "every row attests" over an unread table,
224
+ // which is the silent direction this command exists to end.
225
+ const boundaryConfig = options.boundaryConfig;
226
+ const walked = [];
227
+ let loadedConfig = null;
228
+ if (typeof boundaryConfig === "string") {
229
+ const config = await (io.loadConfigOverride ?? loadBoundaryConfig)(root, boundaryConfig);
230
+ loadedConfig = config;
231
+ walked.push(...configRows(config));
232
+ } else if (
233
+ boundaryConfig !== null &&
234
+ typeof boundaryConfig === "object" &&
235
+ !Array.isArray(boundaryConfig)
236
+ ) {
237
+ loadedConfig = boundaryConfig;
238
+ walked.push(...configRows(boundaryConfig));
239
+ }
240
+ for (const { kind, row } of walked) {
241
+ const attested = hasOrigin(row);
242
+ rowList.push({ kind, attested, origin: attested ? row.origin : null });
243
+ if (!attested) {
244
+ unattested.push({
245
+ kind,
246
+ label: rowLabel(kind, row),
247
+ note: "no origin recorded — cannot attest",
248
+ });
249
+ }
250
+ }
251
+
252
+ // Resolution — `row-schema.mjs`'s `decisionRef` half, gated on an
253
+ // `io.resolve` neither `config.mjs` nor `architecture-intent/model.mjs`
254
+ // ever supplied. Walked over the same two row lists the attestation loops
255
+ // above already visited, against the workspace's own ADR registry.
256
+ const governanceRows = [...introws, ...walked];
257
+ const adrContext = readAdrContext(root, {
258
+ tracked,
259
+ loadAdrRegistryOverride: io.loadAdrRegistryOverride,
260
+ });
261
+ // F04: the fitness half of a decisionRef resolves against the ids the
262
+ // loaded policy DECLARES (`declaredFitnessNames`), never against the ADRs'
263
+ // own `bindings` — a citation cannot resolve itself. `loadedConfig` is the
264
+ // policy object walked above; `null` only when no boundary law was declared,
265
+ // and then no fitness-shaped ref can resolve, which is correct.
266
+ const unresolvedDecisionRefs = unresolvedDecisionRefRows(
267
+ governanceRows,
268
+ adrContext.byId,
269
+ declaredFitnessNames(loadedConfig),
270
+ ).map(({ kind, row, decisionRef }) => ({
271
+ kind,
272
+ label: rowLabel(kind, row),
273
+ decisionRef,
274
+ note: unresolvedDecisionRefNote(decisionRef),
275
+ }));
276
+
277
+ const establishment = repo !== null;
278
+ const repoResult = establishment ? repo : { commit: null, remote: null, dirty: null };
279
+ const rowsTotal = rowList.length;
280
+
281
+ // "No fact, no claim": the resolution section is rendered only when at
282
+ // least one row actually cites a decisionRef — a workspace that never uses
283
+ // the field pays nothing and hears nothing, the same bargain every optional
284
+ // axis in this tool states.
285
+ const decisionRefRows = governanceRows.filter(
286
+ ({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "",
287
+ );
288
+ const reportText = formatProvenanceReport({
289
+ establishment,
290
+ repo,
291
+ rowsTotal,
292
+ unattested,
293
+ decisionRefTotal: decisionRefRows.length,
294
+ unresolvedDecisionRefs,
295
+ });
296
+
297
+ const context = {
298
+ root,
299
+ provider: /** @type {"nx"|"moon"|"native"} */ (commandContext.provider),
300
+ marker: commandContext.marker,
301
+ provenance: establishment ? repo : null,
302
+ };
303
+ const envelope = jsonEnvelope({
304
+ command: "provenance",
305
+ context,
306
+ status: "ok",
307
+ exitCode: 0,
308
+ coverage: {
309
+ complete: true,
310
+ projects: 0,
311
+ analyzedFiles: 0,
312
+ imports: 0,
313
+ notAnalyzed: [],
314
+ blindSpots: [],
315
+ notes: [],
316
+ },
317
+ // The three answer surfaces; `result.rows` preserves the canonical row
318
+ // order. `unresolvedDecisionRefs` is unconditional, like `unattested` —
319
+ // an empty array is itself the claim "every citation resolves", never an
320
+ // omitted key that would leave a reader unable to tell "checked, clean"
321
+ // from "never checked" (`../../../../AGENTS.md`).
322
+ result: {
323
+ repo: repoResult,
324
+ established: establishment,
325
+ rows: rowList.map(({ kind, attested, origin }) => ({
326
+ kind,
327
+ attested,
328
+ origin: origin ?? null,
329
+ })),
330
+ unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
331
+ unresolvedDecisionRefs,
332
+ },
333
+ });
334
+
335
+ return {
336
+ status: "ok",
337
+ repo: { ...repoResult, established: establishment },
338
+ // The three answer surfaces, also available readably (not only inside the
339
+ // envelope) so `cli.mjs` can drive the text report from the same facts.
340
+ rows: rowList.map(({ kind, attested, origin }) => ({
341
+ kind,
342
+ attested,
343
+ origin: origin ?? null,
344
+ })),
345
+ unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
346
+ unresolvedDecisionRefs,
347
+ report: {
348
+ text: reportText,
349
+ json: renderJson(envelope),
350
+ },
351
+ };
352
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Optional repository provenance: git commit, remote, and dirty state.
3
+ *
4
+ * Provenance is captured when git is available (it is a hard dependency for
5
+ * the CLI's `git ls-files` file-list, but the LSP and test paths may not
6
+ * have it). When it cannot be read, provenance is `null` — the envelope
7
+ * carries no origin claim it cannot verify. A `null` provenance is explicit,
8
+ * not implicit: a consumer reading a baseline with `provenance: null` knows
9
+ * the snapshot's origin is unverified, rather than guessing from
10
+ * `workspace.root` (a local path that varies by machine and cannot serve as
11
+ * repository identity).
12
+ *
13
+ * Git is NOT made a new core dependency by this module. The CLI already
14
+ * requires it for `listTrackedFiles` (`../../workspace.mjs`); this module
15
+ * reuses that availability. Test harnesses and the LSP continue to work
16
+ * without git, producing `provenance: null`.
17
+ *
18
+ * ## Why not timestamps
19
+ *
20
+ * No timestamp in the provenance. The envelope's determinism guarantee
21
+ * (`../../../../AGENTS.md`: "two runs over an unchanged tree produce
22
+ * byte-identical JSON") would break if the output varied by wall-clock time.
23
+ * A git commit hash is a stable identity for the same tree state; a
24
+ * timestamp is not.
25
+ */
26
+
27
+ import { execFileSync } from "node:child_process";
28
+
29
+ import { environmentForTree } from "../process.mjs";
30
+
31
+ /**
32
+ * Resolves repository provenance from the workspace root.
33
+ *
34
+ * Returns `null` when git is not available or the directory is not a git
35
+ * repository — the snapshot carries no origin claim rather than a false one.
36
+ *
37
+ * Throws when the directory IS a git repository but has no commits. Git keeps
38
+ * no identity for a tree whose HEAD is unborn: `git ls-files` answers an
39
+ * empty list, so a run over a commitless tree would otherwise report a clean
40
+ * workspace over zero files — the exact silent direction this repository
41
+ * runs on ("an empty result is a claim, not a shrug", `../../../../AGENTS.md`).
42
+ * Read as `null` it would look identical to "no origin claim", which is a
43
+ * factually different statement from "the tree's own git cannot name its
44
+ * state". The throw makes the commitless tree a loud could-not-look at every
45
+ * call site (exit 3), instead of a clean-looking run over nothing.
46
+ *
47
+ * @param {string} root The workspace root directory.
48
+ * @returns {{ commit: string, remote: string | null, dirty: boolean } | null}
49
+ * @throws {Error} when `root` is a git repository with no commits.
50
+ */
51
+ export function resolveProvenance(root) {
52
+ // G-09: every git spawn routes through the shared environment guard, so an
53
+ // ambient GIT_DIR/GIT_WORK_TREE from a wrapping tool (the editor hooks, an
54
+ // outer `git` call) can never make these spawns read a repository other
55
+ // than the tree at `root`.
56
+ const env = environmentForTree();
57
+ // First, the "is this even a git repository at all" question, asked before
58
+ // `rev-parse HEAD` so an unborn HEAD (a commitless repo) is distinguishable
59
+ // from "not a repo" — the two must not share the `null` answer, because
60
+ // only the first is a legitimate "no origin claim".
61
+ try {
62
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
63
+ cwd: root,
64
+ env,
65
+ encoding: "utf-8",
66
+ stdio: ["pipe", "pipe", "pipe"],
67
+ });
68
+ } catch {
69
+ // git not available, or not a git repository. Return null — the envelope
70
+ // carries no origin claim rather than a false one.
71
+ return null;
72
+ }
73
+ let commit;
74
+ try {
75
+ commit = execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
76
+ cwd: root,
77
+ env,
78
+ encoding: "utf-8",
79
+ stdio: ["pipe", "pipe", "pipe"],
80
+ }).trim();
81
+ } catch {
82
+ // `--is-inside-work-tree` passed above, so a repo EXISTS here, and
83
+ // `--verify` (the two-argument form) failed to resolve HEAD. Two states
84
+ // share that failure, and they need different messages:
85
+ // - an UNBORN HEAD (a commitless repo: `git init` with nothing ever
86
+ // committed). Git keeps no identity for it — `git ls-files` answers an
87
+ // empty list, so a run otherwise reports a clean workspace over zero
88
+ // files, the exact silent direction this repository refuses.
89
+ // - a BROKEN HEAD (a repo with commits whose HEAD points at a ref that
90
+ // no longer exists — `git symbolic-ref HEAD refs/heads/nonexistent`).
91
+ // The tree HAS identity; only the ref is corrupt, and telling the
92
+ // user to "commit at least once" would be factually false.
93
+ // The count of commits reachable from any ref (`--all`, includes HEAD via
94
+ // its reflog) distinguishes them: zero means genuinely unborn, nonzero
95
+ // means the HEAD ref is broken.
96
+ let reachable;
97
+ try {
98
+ reachable = execFileSync("git", ["rev-list", "--count", "--all"], {
99
+ cwd: root,
100
+ env,
101
+ encoding: "utf-8",
102
+ stdio: ["pipe", "pipe", "pipe"],
103
+ }).trim();
104
+ } catch {
105
+ // `rev-list --all` failing too is a degenerate repo; treat it as unborn
106
+ // rather than inventing a third class.
107
+ }
108
+ if (reachable === undefined || reachable === "0") {
109
+ throw new Error(
110
+ `archkeep: ${root} is a git repository with no commits — no commit or tracked ` +
111
+ `file exists to establish evidence, so there is nothing this run could look at. ` +
112
+ `Commit at least once before running a Archkeep command.`,
113
+ );
114
+ }
115
+ throw new Error(
116
+ `archkeep: ${root} is a git repository whose HEAD cannot be resolved (the ` +
117
+ `HEAD ref appears to point at a nonexistent branch), even though the repository ` +
118
+ `has commits. Fix the broken HEAD before running a Archkeep command.`,
119
+ );
120
+ }
121
+
122
+ // `git remote` may return empty for a repo with no remotes (e.g. a local
123
+ // test fixture). That is a legitimate state — the commit still identifies
124
+ // the tree.
125
+ let remote = null;
126
+ try {
127
+ const remotes = execFileSync("git", ["remote"], {
128
+ cwd: root,
129
+ env,
130
+ encoding: "utf-8",
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ }).trim();
133
+ if (remotes) {
134
+ // Use the first remote's URL — typically "origin".
135
+ const firstRemote = remotes.split("\n")[0].trim();
136
+ remote = execFileSync("git", ["remote", "get-url", firstRemote], {
137
+ cwd: root,
138
+ env,
139
+ encoding: "utf-8",
140
+ stdio: ["pipe", "pipe", "pipe"],
141
+ }).trim();
142
+ }
143
+ } catch {
144
+ // No remotes configured — `remote` stays null.
145
+ }
146
+
147
+ // Dirty: any uncommitted change to tracked files means the working tree
148
+ // does not match the commit. A baseline from a dirty tree is not a
149
+ // reproducible claim about that commit.
150
+ const status = execFileSync("git", ["status", "--porcelain"], {
151
+ cwd: root,
152
+ env,
153
+ encoding: "utf-8",
154
+ stdio: ["pipe", "pipe", "pipe"],
155
+ }).trim();
156
+ const dirty = status.length > 0;
157
+
158
+ return { commit, remote, dirty };
159
+ }