@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|