@ecoma-io/archkeep 0.22.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gate-attestation.mjs +23 -0
- package/package.json +3 -1
- package/src/analysis/analyze.mjs +6 -0
- package/src/analysis/csharp.mjs +18 -0
- package/src/analysis/go.mjs +18 -0
- package/src/analysis/java.mjs +15 -0
- package/src/analysis/kotlin.mjs +15 -0
- package/src/analysis/python.mjs +25 -3
- package/src/analysis/rust.mjs +18 -0
- package/src/analysis/source-util.mjs +11 -5
- package/src/canonical.mjs +43 -25
- package/src/commands/README.md +63 -12
- package/src/commands/change-intent.mjs +25 -1
- package/src/commands/change.mjs +31 -12
- package/src/commands/coverage-verdict.mjs +11 -4
- package/src/commands/delta-snapshot.mjs +13 -5
- package/src/commands/discover.mjs +59 -38
- package/src/commands/graph.mjs +29 -20
- package/src/commands/history.mjs +12 -11
- package/src/governance/evolution-event.mjs +36 -2
- package/src/intent/intent-manifest.json +5 -5
- package/src/lsp/diagnose.mjs +12 -3
- package/src/report/discover-text.mjs +31 -9
- package/src/report/graph-text.mjs +25 -5
- package/src/verify-gate-attestation.mjs +323 -0
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
*/
|
|
48
48
|
import { readFileSync } from "node:fs";
|
|
49
49
|
|
|
50
|
+
import { canonicalJsonReplacer } from "../canonical.mjs";
|
|
50
51
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
51
52
|
|
|
52
53
|
/** The only snapshot schemaVersion this module writes and reads. */
|
|
@@ -262,16 +263,23 @@ export function buildEvidenceSnapshot({
|
|
|
262
263
|
/**
|
|
263
264
|
* Renders the snapshot as deterministic JSON text.
|
|
264
265
|
*
|
|
265
|
-
* Deterministic
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
266
|
+
* Deterministic by mechanism, not by constructor discipline: the text is
|
|
267
|
+
* produced through `../canonical.mjs`'s `canonicalJsonReplacer`, which sorts
|
|
268
|
+
* plain-object keys at every depth — the same canonicalizer the graph-snapshot
|
|
269
|
+
* family's `snapshotIdentity` hashes with. That is load-bearing because two of
|
|
270
|
+
* the stored inputs (`records`, `graph.workspaceLayout`) arrive verbatim from
|
|
271
|
+
* upstream code that owns their nested key order; sorting at serialize time is
|
|
272
|
+
* what makes the bytes a function of what the snapshot MEANS. Array element
|
|
273
|
+
* order is the only order the format keeps, and `buildEvidenceSnapshot` sorts
|
|
274
|
+
* every array whose source does not guarantee it. Two captures over one
|
|
275
|
+
* unchanged tree produce byte-identical files, which is what makes a plain
|
|
276
|
+
* `diff` of two baselines meaningful.
|
|
269
277
|
*
|
|
270
278
|
* @param {object} snapshot From `buildEvidenceSnapshot`.
|
|
271
279
|
* @returns {string} The JSON text, newline-terminated.
|
|
272
280
|
*/
|
|
273
281
|
export function serializeEvidenceSnapshot(snapshot) {
|
|
274
|
-
return `${JSON.stringify(snapshot,
|
|
282
|
+
return `${JSON.stringify(snapshot, canonicalJsonReplacer, 2)}\n`;
|
|
275
283
|
}
|
|
276
284
|
|
|
277
285
|
/**
|
|
@@ -20,18 +20,23 @@
|
|
|
20
20
|
*
|
|
21
21
|
* ## The empty-result invariant
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* under-represent the real architecture, and a candidate derived from it
|
|
29
|
-
* would be a fabrication wearing a proposal's name.
|
|
23
|
+
* Completeness comes from `./coverage-verdict.mjs`'s shared constructor —
|
|
24
|
+
* the same three-axis law (no whole-file failure, no unjudged blind spot,
|
|
25
|
+
* at least one file analyzed) that `graph` and `check` judge completeness
|
|
26
|
+
* over. The graph-family restatement this replaces is how the zero-analysis
|
|
27
|
+
* axis went missing here (#619).
|
|
30
28
|
*
|
|
31
29
|
* A workspace with zero projects is NOT a refusal: it is the empty proposal
|
|
32
30
|
* with `unknown: true` (`evaluateDiscovery`'s contract), because zero observed
|
|
33
31
|
* projects is a complete observation — the honest answer is "nothing to
|
|
34
|
-
* propose", not a fabricated candidate set.
|
|
32
|
+
* propose", not a fabricated candidate set. The constructor's zero-analysis
|
|
33
|
+
* clause is overridden for this case: if there is nothing to observe, the
|
|
34
|
+
* observation is complete.
|
|
35
|
+
*
|
|
36
|
+
* An Nx workspace with polyglot manifests and no plugin registration is
|
|
37
|
+
* refused the same way `graph` refuses it — the graph would silently
|
|
38
|
+
* under-represent the real architecture, and a candidate derived from it
|
|
39
|
+
* would be a fabrication wearing a proposal's name.
|
|
35
40
|
*
|
|
36
41
|
* ## Determinism
|
|
37
42
|
*
|
|
@@ -40,14 +45,11 @@
|
|
|
40
45
|
* byte-identical text and JSON — the same promise `graph`'s snapshots make,
|
|
41
46
|
* which is what lets a consumer `diff` two proposals meaningfully.
|
|
42
47
|
*/
|
|
43
|
-
import {
|
|
44
|
-
blindSpotRows,
|
|
45
|
-
isWholeFileFailure,
|
|
46
|
-
unresolvableLiteralCount,
|
|
47
|
-
} from "../analysis/source-util.mjs";
|
|
48
48
|
import { evaluateDiscovery } from "../governance/discovery-proposal.mjs";
|
|
49
49
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
50
50
|
import { formatDiscoverReport } from "../report/discover-text.mjs";
|
|
51
|
+
import { coverageIncompleteReasons } from "../verdict.mjs";
|
|
52
|
+
import { coverageVerdict } from "./coverage-verdict.mjs";
|
|
51
53
|
import { buildDependencies, buildProjects } from "./graph.mjs";
|
|
52
54
|
import { resolveProvenance } from "./provenance.mjs";
|
|
53
55
|
import { refuseIncompleteGraph } from "./drift.mjs";
|
|
@@ -131,32 +133,49 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
|
|
|
131
133
|
|
|
132
134
|
refuseIncompleteGraph(commandContext);
|
|
133
135
|
|
|
134
|
-
const notAnalyzed = analysis.failures
|
|
135
|
-
.filter(isWholeFileFailure)
|
|
136
|
-
.map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
|
|
137
|
-
|
|
138
|
-
const blindSpotCount = unresolvableLiteralCount(analysis.failures);
|
|
139
|
-
|
|
140
136
|
const observed = buildObserved(commandContext);
|
|
141
137
|
|
|
142
138
|
const proposal = propose ? evaluateDiscovery(observed) : null;
|
|
143
139
|
|
|
140
|
+
// The completeness verdict is the shared constructor's, not this file's:
|
|
141
|
+
// restating the axes here is how the `analyzed > 0` term went missing from
|
|
142
|
+
// this face (#619 — a run that judged no file at all used to report `ok` /
|
|
143
|
+
// `complete: true` / exit 0, byte-for-byte the envelope a clean workspace
|
|
144
|
+
// gets). `coverageVerdict` owns the one law — no whole-file failure, no
|
|
145
|
+
// unjudged site, at least one file analyzed — and the same return shape
|
|
146
|
+
// the envelope and the text face both read.
|
|
147
|
+
//
|
|
148
|
+
// One override: a workspace with zero projects is a complete observation
|
|
149
|
+
// (there is nothing to observe), so the zero-analysis clause does not
|
|
150
|
+
// withhold from it. `evaluateDiscovery`'s contract returns `unknown: true`
|
|
151
|
+
// over an empty project list, and that answer is honest — it does not
|
|
152
|
+
// claim completeness over nothing.
|
|
153
|
+
const verdict = coverageVerdict(commandContext);
|
|
154
|
+
const hasProjects = observed.projects.length > 0;
|
|
155
|
+
const { complete, status, exitCode } = verdict;
|
|
156
|
+
// When there are no projects, override the zero-analysis withdrawal: an
|
|
157
|
+
// empty observation is a complete observation, and the `unknown` proposal
|
|
158
|
+
// is the honest answer.
|
|
159
|
+
const effectiveComplete = hasProjects ? complete : true;
|
|
160
|
+
const effectiveStatus = hasProjects ? status : "ok";
|
|
161
|
+
const effectiveExitCode = hasProjects ? exitCode : 0;
|
|
162
|
+
|
|
144
163
|
// A proposal over an unread tree would be a fabrication wearing a
|
|
145
164
|
// proposal's name: every candidate edge would be ambiguous between "gone"
|
|
146
165
|
// and "never seen". Refuse loudly — the same reasoning `drift`'s refusal
|
|
147
166
|
// gives — rather than print a proposal and a warning that it may be lying.
|
|
148
167
|
// An unresolvable site is the same fabrication at site granularity (#595):
|
|
149
168
|
// the edge out of it may be missing, and a candidate built over a gap is
|
|
150
|
-
// still a guess.
|
|
151
|
-
if (propose && (notAnalyzed.length > 0 || blindSpotCount > 0)) {
|
|
169
|
+
// still a guess. Use the shared verdict's counts rather than re-deriving.
|
|
170
|
+
if (propose && (verdict.notAnalyzed.length > 0 || verdict.blindSpotCount > 0)) {
|
|
152
171
|
throw new Error(
|
|
153
172
|
`archkeep: discover --propose has incomplete coverage — ` +
|
|
154
173
|
[
|
|
155
|
-
notAnalyzed.length > 0
|
|
156
|
-
? `${notAnalyzed.length} file${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
|
|
174
|
+
verdict.notAnalyzed.length > 0
|
|
175
|
+
? `${verdict.notAnalyzed.length} file${verdict.notAnalyzed.length === 1 ? "" : "s"} could not be analyzed`
|
|
157
176
|
: null,
|
|
158
|
-
blindSpotCount > 0
|
|
159
|
-
? `${blindSpotCount} import site${blindSpotCount === 1 ? "" : "s"} could not be resolved`
|
|
177
|
+
verdict.blindSpotCount > 0
|
|
178
|
+
? `${verdict.blindSpotCount} import site${verdict.blindSpotCount === 1 ? "" : "s"} could not be resolved`
|
|
160
179
|
: null,
|
|
161
180
|
]
|
|
162
181
|
.filter(Boolean)
|
|
@@ -166,21 +185,22 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
|
|
|
166
185
|
);
|
|
167
186
|
}
|
|
168
187
|
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
// The clauses the text face renders over an incomplete run, worded by the
|
|
189
|
+
// same function `verdictFor` joins into `decision.reason` — one wording,
|
|
190
|
+
// two renderings, and neither can drift from the other.
|
|
191
|
+
const coverageIncomplete = coverageIncompleteReasons({
|
|
192
|
+
unchecked: verdict.notAnalyzed.length,
|
|
193
|
+
blindSpots: verdict.blindSpotCount,
|
|
194
|
+
analyzed: analysis.analyzed,
|
|
195
|
+
});
|
|
176
196
|
|
|
177
197
|
const coverage = {
|
|
178
|
-
complete,
|
|
198
|
+
complete: effectiveComplete,
|
|
179
199
|
projects: observed.projects.length,
|
|
180
200
|
analyzedFiles: analysis.analyzed,
|
|
181
201
|
imports: analysis.imports.length,
|
|
182
|
-
notAnalyzed,
|
|
183
|
-
blindSpots:
|
|
202
|
+
notAnalyzed: verdict.notAnalyzed,
|
|
203
|
+
blindSpots: verdict.blindSpots,
|
|
184
204
|
notes: [],
|
|
185
205
|
};
|
|
186
206
|
|
|
@@ -196,14 +216,14 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
|
|
|
196
216
|
const envelope = jsonEnvelope({
|
|
197
217
|
command: "discover",
|
|
198
218
|
context,
|
|
199
|
-
status,
|
|
200
|
-
exitCode,
|
|
219
|
+
status: effectiveStatus,
|
|
220
|
+
exitCode: effectiveExitCode,
|
|
201
221
|
coverage,
|
|
202
222
|
result: { discovery, ...(proposal ? { proposal } : {}) },
|
|
203
223
|
});
|
|
204
224
|
|
|
205
225
|
return {
|
|
206
|
-
status,
|
|
226
|
+
status: effectiveStatus,
|
|
207
227
|
discovery,
|
|
208
228
|
proposal,
|
|
209
229
|
coverage,
|
|
@@ -212,6 +232,7 @@ export function discoverCommand(commandContext, { propose = false } = {}) {
|
|
|
212
232
|
discovery,
|
|
213
233
|
proposal,
|
|
214
234
|
coverage,
|
|
235
|
+
coverageIncomplete: hasProjects ? coverageIncomplete : undefined,
|
|
215
236
|
}),
|
|
216
237
|
json: renderJson(envelope),
|
|
217
238
|
},
|
package/src/commands/graph.mjs
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
* `entryPoints`, or `declaredPackages`). It is descriptive: it never exits 1,
|
|
11
11
|
* because a snapshot of what is is never a finding.
|
|
12
12
|
*
|
|
13
|
+
* Its completeness verdict is not computed here: `graphCommand` composes
|
|
14
|
+
* `./coverage-verdict.mjs`'s `coverageVerdict`, the one constructor every
|
|
15
|
+
* refusal-contract face reads, so this snapshot's `status`/`exitCode` cannot
|
|
16
|
+
* drift from the axes `check` judges completeness over. The graph-family
|
|
17
|
+
* restatement this replaces is how the zero-analysis axis went missing here
|
|
18
|
+
* while every other face carried it (#612).
|
|
19
|
+
*
|
|
13
20
|
* What it needs from its caller is a `CommandContext` — the preamble every
|
|
14
21
|
* command shares (`./context.mjs`). What it gives back is a `status`, the
|
|
15
22
|
* payload for both the text and the JSON renderers, and enough coverage
|
|
@@ -29,15 +36,12 @@
|
|
|
29
36
|
*/
|
|
30
37
|
import { createHash } from "node:crypto";
|
|
31
38
|
|
|
32
|
-
import {
|
|
33
|
-
blindSpotRows,
|
|
34
|
-
isWholeFileFailure,
|
|
35
|
-
unresolvableLiteralCount,
|
|
36
|
-
} from "../analysis/source-util.mjs";
|
|
37
39
|
import { canonicalizeJson } from "../canonical.mjs";
|
|
38
40
|
import { DEFAULT_WORKSPACE_LAYOUT } from "../rules/specifiers.mjs";
|
|
39
41
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
40
42
|
import { formatGraphReport } from "../report/graph-text.mjs";
|
|
43
|
+
import { coverageIncompleteReasons } from "../verdict.mjs";
|
|
44
|
+
import { coverageVerdict } from "./coverage-verdict.mjs";
|
|
41
45
|
import { resolveProvenance } from "./provenance.mjs";
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -222,20 +226,24 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
222
226
|
);
|
|
223
227
|
}
|
|
224
228
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
|
|
229
|
+
// The completeness verdict is the shared constructor's, not this file's:
|
|
230
|
+
// restating the axes here is how the `analyzed > 0` term went missing from
|
|
231
|
+
// this face while `check` carried it (#612 — a run that judged no file at
|
|
232
|
+
// all used to report `ok` / `complete: true` / exit 0, byte-for-byte the
|
|
233
|
+
// envelope a clean workspace gets). `coverageVerdict` owns the one law —
|
|
234
|
+
// no whole-file failure, no unjudged site, at least one file analyzed —
|
|
235
|
+
// and the same return shape the envelope and the text face both read.
|
|
236
|
+
const verdict = coverageVerdict(commandContext);
|
|
237
|
+
const { complete, status, exitCode } = verdict;
|
|
235
238
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
// The clauses the text face renders over an incomplete run, worded by the
|
|
240
|
+
// same function `verdictFor` joins into `decision.reason` — one wording,
|
|
241
|
+
// two renderings, and neither can drift from the other.
|
|
242
|
+
const coverageIncomplete = coverageIncompleteReasons({
|
|
243
|
+
unchecked: verdict.notAnalyzed.length,
|
|
244
|
+
blindSpots: verdict.blindSpotCount,
|
|
245
|
+
analyzed: commandContext.analysis.analyzed,
|
|
246
|
+
});
|
|
239
247
|
|
|
240
248
|
const projects = buildProjects(graph.nodes);
|
|
241
249
|
const dependencies = buildDependencies(graph.dependencies);
|
|
@@ -255,8 +263,8 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
255
263
|
projects: projects.length,
|
|
256
264
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
257
265
|
imports: commandContext.analysis.imports.length,
|
|
258
|
-
notAnalyzed,
|
|
259
|
-
blindSpots,
|
|
266
|
+
notAnalyzed: verdict.notAnalyzed,
|
|
267
|
+
blindSpots: verdict.blindSpots,
|
|
260
268
|
notes: [],
|
|
261
269
|
};
|
|
262
270
|
|
|
@@ -299,6 +307,7 @@ export function graphCommand(commandContext, { config = null } = {}) {
|
|
|
299
307
|
workspaceLayout,
|
|
300
308
|
workspaceLayoutSource,
|
|
301
309
|
coverage,
|
|
310
|
+
coverageIncomplete,
|
|
302
311
|
}),
|
|
303
312
|
json: renderJson(envelope),
|
|
304
313
|
},
|
package/src/commands/history.mjs
CHANGED
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
isWholeFileFailure,
|
|
80
80
|
unresolvableLiteralCount,
|
|
81
81
|
} from "../analysis/source-util.mjs";
|
|
82
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
82
83
|
import { containmentViolation } from "../containment.mjs";
|
|
83
84
|
import { classifyEvolution } from "../governance/evolution-event.mjs";
|
|
84
85
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
@@ -121,17 +122,17 @@ export function snapshotIdentity({ projects, dependencies, policy }) {
|
|
|
121
122
|
type,
|
|
122
123
|
tags,
|
|
123
124
|
}));
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
);
|
|
125
|
+
// The canonical string comes from `../canonical.mjs` — the one canonicalizer
|
|
126
|
+
// ("one canonicalizer, in one place, so two serializations cannot drift").
|
|
127
|
+
// This digest's private inline replacer was the last serialization site
|
|
128
|
+
// beside it (#613); `history.test.mjs` pins the digest byte-identical to the
|
|
129
|
+
// retired spelling on every snapshot shape, so composing it moved no byte an
|
|
130
|
+
// unchanged workspace ever saw.
|
|
131
|
+
const canonical = canonicalizeJson({
|
|
132
|
+
projects: identityProjects,
|
|
133
|
+
dependencies,
|
|
134
|
+
policy: policy?.fingerprint ?? null,
|
|
135
|
+
});
|
|
135
136
|
return createHash("sha256").update(canonical).digest("hex");
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -107,19 +107,53 @@ export function declarationDigest(intent) {
|
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The escape character every identity spelling below uses, and the one
|
|
112
|
+
* function that applies it. A field that carries none of `\`, `>` or `:` and
|
|
113
|
+
* is not exactly `-` is returned byte-identical — the overwhelmingly common
|
|
114
|
+
* case, which is what keeps this escaping from rewriting the stored events of
|
|
115
|
+
* workspaces whose names never carried a delimiter (#627's fix is
|
|
116
|
+
* conditional by design; a wholesale re-encode on the `boundaryKey`
|
|
117
|
+
* pattern would change the persisted bytes of every workspace). A field that
|
|
118
|
+
* does carry one is escaped, so the delimiters that remain unescaped in an
|
|
119
|
+
* identity string are exactly the separators, and distinct field tuples can
|
|
120
|
+
* no longer join to the same string (#627). The sentinel `-` (`#628` writes
|
|
121
|
+
* it for an absent source project) escapes to `\-`, so a field that literally
|
|
122
|
+
* is `-` can no longer read as "absent" — `\-` in an identity string can only
|
|
123
|
+
* ever have come from field data.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} value One field of an identity string.
|
|
126
|
+
* @returns {string} The field, escaped iff escaping is needed.
|
|
127
|
+
*/
|
|
128
|
+
export function escapeIdentityField(value) {
|
|
129
|
+
if (!value.includes("\\") && !value.includes(">") && !value.includes(":") && value !== "-") {
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
// Backslash first, so it never escapes an escape this pass itself wrote.
|
|
133
|
+
return value
|
|
134
|
+
.replaceAll("\\", "\\\\")
|
|
135
|
+
.replaceAll(">", "\\>")
|
|
136
|
+
.replaceAll(":", "\\:")
|
|
137
|
+
.replace(/^-$/u, "\\-");
|
|
138
|
+
}
|
|
139
|
+
|
|
110
140
|
/**
|
|
111
141
|
* The identity string of one graph edge, in the canonical spelling
|
|
112
142
|
* `source>target:type` — the `(source, target, type)` identity design §1
|
|
113
143
|
* names. The ONE spelling the evolution events' `observed.edges` and
|
|
114
144
|
* `affected.boundaries` use: this module owns it, and `classifyEvolution`
|
|
115
145
|
* maps every edge it is handed through this function, so there is exactly
|
|
116
|
-
* one definition of "same edge" and no second spelling to drift.
|
|
146
|
+
* one definition of "same edge" and no second spelling to drift. Fields are
|
|
147
|
+
* escaped through `escapeIdentityField`, so the unescaped `>` and `:` in the
|
|
148
|
+
* result are the separators and two distinct triples never join to one
|
|
149
|
+
* string (#627) — while a triple with no delimiter in any field spells
|
|
150
|
+
* exactly what earlier versions spelled, byte for byte.
|
|
117
151
|
*
|
|
118
152
|
* @param {{source: string, target: string, type: string}} edge
|
|
119
153
|
* @returns {string}
|
|
120
154
|
*/
|
|
121
155
|
export function edgeEvolutionIdentity({ source, target, type }) {
|
|
122
|
-
return `${source}>${target}:${type}`;
|
|
156
|
+
return `${escapeIdentityField(source)}>${escapeIdentityField(target)}:${escapeIdentityField(type)}`;
|
|
123
157
|
}
|
|
124
158
|
|
|
125
159
|
/**
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
{
|
|
46
46
|
"type": "source-evidence",
|
|
47
47
|
"path": "src/lsp/diagnose.mjs",
|
|
48
|
-
"assertion": "analyzed:false on every non-verdict path; empty diagnostic list only from two named places",
|
|
49
|
-
"sha256": "
|
|
48
|
+
"assertion": "analyzed:false on every non-verdict path; empty diagnostic list only from two named places — plus a document whose only positioned failure is an external disclosure, which was judged and so is not published (#603)",
|
|
49
|
+
"sha256": "72196def14055475ccb23284a3c5d34167535a506985b26fb95af097545cc740"
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"type": "source-evidence",
|
|
@@ -104,13 +104,13 @@
|
|
|
104
104
|
"type": "source-evidence",
|
|
105
105
|
"path": "src/commands/graph.mjs",
|
|
106
106
|
"assertion": "Plain string comparison, never localeCompare; INTERNAL_DATA_FIELDS stripped; SCHEMA_VERSION = 2",
|
|
107
|
-
"sha256": "
|
|
107
|
+
"sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
"type": "source-evidence",
|
|
111
111
|
"path": "src/commands/graph.mjs",
|
|
112
112
|
"assertion": "computePolicyFingerprint produces SHA-256 of canonicalized policy",
|
|
113
|
-
"sha256": "
|
|
113
|
+
"sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
|
|
114
114
|
}
|
|
115
115
|
],
|
|
116
116
|
"status": "proven"
|
|
@@ -286,7 +286,7 @@
|
|
|
286
286
|
"type": "source-evidence",
|
|
287
287
|
"path": "src/commands/graph.mjs",
|
|
288
288
|
"assertion": "Plain string comparison throughout; never localeCompare",
|
|
289
|
-
"sha256": "
|
|
289
|
+
"sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
|
|
290
290
|
}
|
|
291
291
|
],
|
|
292
292
|
"status": "proven"
|
package/src/lsp/diagnose.mjs
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* places outside the boundary system entirely.
|
|
43
43
|
*/
|
|
44
44
|
import { analyzeFile } from "../analysis/analyze.mjs";
|
|
45
|
-
import { projectOwning } from "../analysis/source-util.mjs";
|
|
45
|
+
import { isExternalSiteFailure, projectOwning } from "../analysis/source-util.mjs";
|
|
46
46
|
import { declaredEdgeViolationsForCheck } from "../commands/edge-constraints.mjs";
|
|
47
47
|
import { evaluate } from "../rules/index.mjs";
|
|
48
48
|
|
|
@@ -97,10 +97,19 @@ export function diagnoseDocument({ sourceFile, text, index, config }) {
|
|
|
97
97
|
|
|
98
98
|
// Recorded failures come next, and they are published whether or not the
|
|
99
99
|
// rule pass below succeeds: they are the part of the file that was NOT
|
|
100
|
-
// judged, and a reader needs that before they read what was.
|
|
100
|
+
// judged, and a reader needs that before they read what was. The external
|
|
101
|
+
// class is not that part (`isExternalSiteFailure`): a bare coordinate that
|
|
102
|
+
// resolves to the dependency universe was judged — resolved external,
|
|
103
|
+
// disclosed in the run's blind-spot rows, excluded from the withholding
|
|
104
|
+
// count — so a warning on it would say "not checked" about a site the
|
|
105
|
+
// verdict below covers, and one per third-party import would be a wall of
|
|
106
|
+
// warnings a reader rightly learns to ignore (#603). The workspace-surface
|
|
107
|
+
// and whole-file classes keep publishing.
|
|
101
108
|
const diagnostics = [
|
|
102
109
|
...prelude,
|
|
103
|
-
...analysis.failures
|
|
110
|
+
...analysis.failures
|
|
111
|
+
.filter((failure) => !isExternalSiteFailure(failure))
|
|
112
|
+
.map((failure) => failureDiagnostic(failure, lines)),
|
|
104
113
|
];
|
|
105
114
|
|
|
106
115
|
// The engine derives its evidence index from exactly the records it is
|
|
@@ -4,15 +4,27 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The coverage claim sits ABOVE everything — the reader knows whether the
|
|
6
6
|
* observations are complete before reading any entry, exactly like
|
|
7
|
-
* `./graph-text.mjs`'s report.
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `./graph-text.mjs`'s report. Under an incomplete claim sit the reason
|
|
8
|
+
* clauses the run withheld the verdict over, worded by
|
|
9
|
+
* `../verdict.mjs`'s `coverageIncompleteReasons` and rendered through
|
|
10
|
+
* `./text.mjs`'s `formatCoverageIncomplete` — the same clauses, in the
|
|
11
|
+
* same `⚠` rendering, `check`'s text report prints, so a terminal reader is
|
|
12
|
+
* told why the verdict is withheld whichever face ran. A zero-analysis run
|
|
13
|
+
* is the case that needs this: its `notAnalyzed` list is empty, so a
|
|
14
|
+
* count-bearing headline would blame zero failures for an incomplete
|
|
15
|
+
* discovery (#619).
|
|
16
|
+
*
|
|
17
|
+
* The proposal, when present, is rendered below the observations with the
|
|
18
|
+
* proposal-only banner (`proposed`, `not authoritative`) repeated on every
|
|
19
|
+
* line of every candidate, so a reader who scans the report cannot mistake a
|
|
20
|
+
* candidate for a decision.
|
|
11
21
|
*
|
|
12
22
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
13
23
|
* wearing a formatter's name (`../README.md`).
|
|
14
24
|
*/
|
|
15
25
|
|
|
26
|
+
import { formatCoverageIncomplete } from "./text.mjs";
|
|
27
|
+
|
|
16
28
|
/** The three confidence markers, in the order the legend prints them. */
|
|
17
29
|
const CONFIDENCE_ORDER = ["high", "medium", "low"];
|
|
18
30
|
|
|
@@ -96,10 +108,16 @@ function formatRule(item) {
|
|
|
96
108
|
*
|
|
97
109
|
* @param {{discovery: {projects: object[], edges: object[], tags: string[]},
|
|
98
110
|
* proposal: object|null,
|
|
99
|
-
* coverage: object
|
|
111
|
+
* coverage: object,
|
|
112
|
+
* coverageIncomplete?: string[]}} input
|
|
113
|
+
* `coverageIncomplete` is the withheld-verdict clause list
|
|
114
|
+
* (`../verdict.mjs`'s `coverageIncompleteReasons`, handed through
|
|
115
|
+
* `../commands/discover.mjs`) — rendered below the incomplete headline,
|
|
116
|
+
* empty exactly when the discovery is complete, and optional because a
|
|
117
|
+
* complete discovery carries no clauses to render.
|
|
100
118
|
* @returns {string}
|
|
101
119
|
*/
|
|
102
|
-
export function formatDiscoverReport({ discovery, proposal, coverage }) {
|
|
120
|
+
export function formatDiscoverReport({ discovery, proposal, coverage, coverageIncomplete }) {
|
|
103
121
|
const sections = [];
|
|
104
122
|
|
|
105
123
|
const inspected =
|
|
@@ -110,11 +128,15 @@ export function formatDiscoverReport({ discovery, proposal, coverage }) {
|
|
|
110
128
|
if (coverage.complete) {
|
|
111
129
|
sections.push(`✔ discovery complete (${inspected})`);
|
|
112
130
|
} else {
|
|
113
|
-
|
|
131
|
+
// The headline states the incompleteness and its consequence; the clauses
|
|
132
|
+
// below state WHY, one per failed coverage axis. Blaming the whole-file
|
|
133
|
+
// count in the headline alone would read "0 files could not be analyzed"
|
|
134
|
+
// over a zero-analysis run (#619) — incomplete, with a reason of nothing.
|
|
114
135
|
sections.push(
|
|
115
|
-
`✖ discovery incomplete —
|
|
116
|
-
`could not be analyzed, so these observations may under-represent the workspace (${inspected})`,
|
|
136
|
+
`✖ discovery incomplete — these observations may under-represent the workspace (${inspected})`,
|
|
117
137
|
);
|
|
138
|
+
const clauses = formatCoverageIncomplete(coverageIncomplete ?? []);
|
|
139
|
+
if (clauses !== "") sections.push(clauses);
|
|
118
140
|
}
|
|
119
141
|
|
|
120
142
|
const projectWord = discovery.projects.length === 1 ? "project" : "projects";
|
|
@@ -12,12 +12,21 @@
|
|
|
12
12
|
* The coverage claim sits ABOVE the listing, not below it, so the reader knows
|
|
13
13
|
* whether the snapshot is complete before reading any entry — an incomplete
|
|
14
14
|
* snapshot printed in full would have the "this may under-represent" warning
|
|
15
|
-
* buried at the bottom.
|
|
15
|
+
* buried at the bottom. Under an incomplete claim sit the reason clauses the
|
|
16
|
+
* run withheld the verdict over, worded by `../verdict.mjs`'s
|
|
17
|
+
* `coverageIncompleteReasons` and rendered through `./text.mjs`'s
|
|
18
|
+
* `formatCoverageIncomplete` — the same clauses, in the same `⚠` rendering,
|
|
19
|
+
* `check`'s text report prints, so a terminal reader is told why the verdict
|
|
20
|
+
* is withheld whichever face ran. A zero-analysis run is the case that needs
|
|
21
|
+
* this: its `notAnalyzed` list is empty, so a count-bearing headline would
|
|
22
|
+
* blame zero failures for an incomplete snapshot.
|
|
16
23
|
*
|
|
17
24
|
* This module decides nothing. A formatter that filtered would be a rule
|
|
18
25
|
* wearing a formatter's name (`../README.md`).
|
|
19
26
|
*/
|
|
20
27
|
|
|
28
|
+
import { formatCoverageIncomplete } from "./text.mjs";
|
|
29
|
+
|
|
21
30
|
/**
|
|
22
31
|
* One project as a line: name, root, type, and tags.
|
|
23
32
|
*
|
|
@@ -44,7 +53,13 @@ function formatEdge(edge) {
|
|
|
44
53
|
* The whole graph report.
|
|
45
54
|
*
|
|
46
55
|
* @param {{projects: object[], dependencies: object[], workspaceLayout: object,
|
|
47
|
-
* workspaceLayoutSource: string, coverage: object
|
|
56
|
+
* workspaceLayoutSource: string, coverage: object,
|
|
57
|
+
* coverageIncomplete?: string[]}} input
|
|
58
|
+
* `coverageIncomplete` is the withheld-verdict clause list
|
|
59
|
+
* (`../verdict.mjs`'s `coverageIncompleteReasons`, handed through
|
|
60
|
+
* `../../commands/graph.mjs`) — rendered below the incomplete headline,
|
|
61
|
+
* empty exactly when the snapshot is complete, and optional because a
|
|
62
|
+
* complete snapshot carries no clauses to render.
|
|
48
63
|
* @returns {string}
|
|
49
64
|
*/
|
|
50
65
|
export function formatGraphReport({
|
|
@@ -53,6 +68,7 @@ export function formatGraphReport({
|
|
|
53
68
|
workspaceLayout,
|
|
54
69
|
workspaceLayoutSource,
|
|
55
70
|
coverage,
|
|
71
|
+
coverageIncomplete,
|
|
56
72
|
}) {
|
|
57
73
|
const sections = [];
|
|
58
74
|
|
|
@@ -66,11 +82,15 @@ export function formatGraphReport({
|
|
|
66
82
|
if (coverage.complete) {
|
|
67
83
|
sections.push(`✔ graph snapshot complete (${inspected})`);
|
|
68
84
|
} else {
|
|
69
|
-
|
|
85
|
+
// The headline states the incompleteness and its consequence; the clauses
|
|
86
|
+
// below state WHY, one per failed coverage axis. Blaming the whole-file
|
|
87
|
+
// count in the headline alone would read "0 files could not be analyzed"
|
|
88
|
+
// over a zero-analysis run (#612) — incomplete, with a reason of nothing.
|
|
70
89
|
sections.push(
|
|
71
|
-
`✖ graph snapshot incomplete —
|
|
72
|
-
`could not be analyzed, so this snapshot may under-represent the architecture (${inspected})`,
|
|
90
|
+
`✖ graph snapshot incomplete — this snapshot may under-represent the architecture (${inspected})`,
|
|
73
91
|
);
|
|
92
|
+
const clauses = formatCoverageIncomplete(coverageIncomplete ?? []);
|
|
93
|
+
if (clauses !== "") sections.push(clauses);
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
// Layout line
|