@ecoma-io/archkeep 0.22.0 → 0.22.2

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.
@@ -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 because `buildEvidenceSnapshot` constructs every key in a
266
- * fixed order and sorts every array whose source does not guarantee order;
267
- * two captures over one unchanged tree produce byte-identical files, which is
268
- * what makes a plain `diff` of two baselines meaningful.
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, null, 2)}\n`;
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
- * A workspace the run could not fully read (`notAnalyzed` non-empty) returns
24
- * `status: "no-verdict"` exit 3, the same refusal `graph`/`drift` make:
25
- * every missing edge would be ambiguous between "gone" and "never seen".
26
- * An Nx workspace with polyglot manifests and no plugin registration is
27
- * refused the same way `graph` refuses it — the graph would silently
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
- // An unresolvable site was seen but never judged (#595): the snapshot's
170
- // edge list under-represents the tree wherever that site would have drawn
171
- // one, so `complete` cannot be claimed over it. It still reports — status
172
- // no-verdict, exit 3 — naming the site in `coverage.blindSpots`.
173
- const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
174
- const status = complete ? "ok" : "no-verdict";
175
- const exitCode = complete ? 0 : 3;
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: blindSpotRows(analysis.failures),
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
  },
@@ -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
- const notAnalyzed = commandContext.analysis.failures
226
- .filter(isWholeFileFailure)
227
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
228
-
229
- // An unresolvable import site was seen but never judged (#595): the edges
230
- // out of it may be missing from this snapshot, so the snapshot must not
231
- // claim `complete` over it. It still reports status no-verdict, exit 3
232
- // naming the site in `coverage.blindSpots`, the same contract `check` runs.
233
- const blindSpots = blindSpotRows(commandContext.analysis.failures);
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
- const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
237
- const status = complete ? "ok" : "no-verdict";
238
- const exitCode = complete ? 0 : 3;
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
  },
@@ -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
- const canonical = JSON.stringify(
125
- { projects: identityProjects, dependencies, policy: policy?.fingerprint ?? null },
126
- (_, value) =>
127
- value !== null && typeof value === "object" && !Array.isArray(value)
128
- ? Object.fromEntries(
129
- Object.keys(value)
130
- .sort()
131
- .map((key) => [key, value[key]]),
132
- )
133
- : value,
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": "4eea19bf245c33963cd292e02f676eb9c5238a5783a3a960976f53afc7cf8b27"
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": "cce9730e33e83e0a183c0139afcc9a0845f2f95f67b07875f9c583f3ca95d046"
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": "cce9730e33e83e0a183c0139afcc9a0845f2f95f67b07875f9c583f3ca95d046"
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": "cce9730e33e83e0a183c0139afcc9a0845f2f95f67b07875f9c583f3ca95d046"
289
+ "sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
290
290
  }
291
291
  ],
292
292
  "status": "proven"
@@ -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.map((failure) => failureDiagnostic(failure, lines)),
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. The proposal, when present, is rendered below
8
- * the observations with the proposal-only banner (`proposed`, `not
9
- * authoritative`) repeated on every line of every candidate, so a reader who
10
- * scans the report cannot mistake a candidate for a decision.
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}} input
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
- const notAnalyzedCount = coverage.notAnalyzed.length;
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 — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
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}} input
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
- const notAnalyzedCount = coverage.notAnalyzed.length;
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 — ${notAnalyzedCount} file${notAnalyzedCount === 1 ? "" : "s"} ` +
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