@ecoma-io/archkeep 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/cli.mjs +156 -66
  2. package/package.json +1 -1
  3. package/src/analysis/contract.md +32 -5
  4. package/src/analysis/source-util.mjs +107 -0
  5. package/src/analysis/typescript.mjs +86 -5
  6. package/src/commands/change.mjs +59 -28
  7. package/src/commands/check.mjs +65 -26
  8. package/src/commands/completeness.mjs +126 -19
  9. package/src/commands/context-command.mjs +13 -5
  10. package/src/commands/context.mjs +31 -4
  11. package/src/commands/coverage-verdict.mjs +184 -0
  12. package/src/commands/debt.mjs +18 -15
  13. package/src/commands/delta-classify.mjs +13 -18
  14. package/src/commands/delta.mjs +95 -33
  15. package/src/commands/diff.mjs +31 -24
  16. package/src/commands/discover.mjs +30 -10
  17. package/src/commands/drift.mjs +21 -21
  18. package/src/commands/edge-constraints.mjs +47 -1
  19. package/src/commands/evaluation-primitives.mjs +194 -2
  20. package/src/commands/evolution.mjs +27 -10
  21. package/src/commands/explain.mjs +14 -13
  22. package/src/commands/fitness.mjs +20 -19
  23. package/src/commands/graph.mjs +14 -5
  24. package/src/commands/health.mjs +12 -5
  25. package/src/commands/history.mjs +29 -15
  26. package/src/commands/impact.mjs +17 -18
  27. package/src/commands/plan-context-command.mjs +10 -5
  28. package/src/commands/reconcile.mjs +14 -17
  29. package/src/commands/scenario-evaluation.mjs +93 -16
  30. package/src/commands/scenario.mjs +28 -18
  31. package/src/commands/waivers.mjs +36 -28
  32. package/src/governance/evolution-event.mjs +62 -9
  33. package/src/intent/intent-manifest.json +83 -39
  34. package/src/report/json.mjs +32 -5
  35. package/src/report/text.mjs +82 -12
  36. package/src/verdict.mjs +78 -36
  37. package/src/workspace.mjs +126 -2
package/cli.mjs CHANGED
@@ -923,13 +923,20 @@ async function runDiff(options, { cwd, env }) {
923
923
  // the mechanism and the threat it closes.
924
924
  const reportText = report.endsWith("\n") ? report : `${report}\n`;
925
925
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
926
- env.err(`archkeep: diff complete ${options.output}`);
926
+ // A refused run still writes the report — but "diff complete" would deny
927
+ // the refusal that report carries (#608).
928
+ if (result.status === "no-verdict") {
929
+ env.err(`archkeep: no verdict — the coverage refusal is in the report → ${options.output}`);
930
+ } else {
931
+ env.err(`archkeep: diff complete → ${options.output}`);
932
+ }
927
933
  } else {
928
934
  env.out(report);
929
935
  }
930
936
 
931
- // Diff is descriptive: 0 when it completes, never 1.
932
- return EXIT.ok;
937
+ // Diff is descriptive: 0 when it completes, never 1 — and an incomplete
938
+ // head is the structured no-verdict envelope's exit 3, not a clean 0 (#608).
939
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
933
940
  }
934
941
 
935
942
  /**
@@ -1003,19 +1010,31 @@ async function runDelta(options, { cwd, env }) {
1003
1010
  return usageError ? EXIT.usage : EXIT.error;
1004
1011
  }
1005
1012
 
1013
+ // A coverage-refused compare (#608) answers every `--format` with the
1014
+ // envelope: a withheld verdict has no findings, so there is no SARIF log
1015
+ // and no text table to render — and `result.report.sarif` does not even
1016
+ // exist on the refusal, which would crash the ternary below into a
1017
+ // TypeError where the contract says exit 3.
1006
1018
  const report =
1007
- options.format === "json"
1019
+ result.status === "no-verdict"
1008
1020
  ? result.report.json
1009
- : options.format === "sarif"
1010
- ? result.report.sarif
1011
- : result.report.text;
1021
+ : options.format === "json"
1022
+ ? result.report.json
1023
+ : options.format === "sarif"
1024
+ ? result.report.sarif
1025
+ : result.report.text;
1012
1026
 
1013
1027
  if (options.output) {
1014
1028
  // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1015
- // the mechanism and the threat it closes.
1029
+ // the mechanism and the threat it closes. A refused run still writes the
1030
+ // report — but "delta complete" would deny the refusal it carries (#608).
1016
1031
  const reportText = report.endsWith("\n") ? report : `${report}\n`;
1017
1032
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1018
- env.err(`archkeep: delta complete → ${options.output}`);
1033
+ if (result.status === "no-verdict") {
1034
+ env.err(`archkeep: no verdict — the coverage refusal is in the report → ${options.output}`);
1035
+ } else {
1036
+ env.err(`archkeep: delta complete → ${options.output}`);
1037
+ }
1019
1038
  } else {
1020
1039
  env.out(report);
1021
1040
  }
@@ -1023,8 +1042,10 @@ async function runDelta(options, { cwd, env }) {
1023
1042
  // The event the run recorded, when `--event-out` was given — the store's
1024
1043
  // own `duplicate` answer, so a rerun over the same transition says so
1025
1044
  // instead of implying a second event was appended. Capture mode refuses the
1026
- // flag upstream; this line runs only for a compare.
1027
- if (result.eventWrite !== null) {
1045
+ // flag upstream; this line runs only for a compare. Truthy, not `!== null`:
1046
+ // a coverage-refused run (#608) returns no `eventWrite` key at all, and a
1047
+ // truthiness test is the one form that reads both shapes safely.
1048
+ if (result.eventWrite) {
1028
1049
  env.err(
1029
1050
  `archkeep: evolution event ${
1030
1051
  result.eventWrite.duplicate ? "duplicate, already recorded" : "recorded"
@@ -1105,16 +1126,24 @@ async function runDrift(options, { cwd, env }) {
1105
1126
  // `drift` has no `--config` flag (`DRIFT_FLAG_HELP`) — there is no
1106
1127
  // override to pass, only the workspace's un-overridden default to guard.
1107
1128
  if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
1108
- env.err(
1109
- `archkeep: ${result.drift.observed.projects} projects, ${result.drift.observed.edges} edges ` +
1110
- `→ ${options.output}`,
1111
- );
1129
+ // A refused run carries no `drift` payload to summarize — the numbers
1130
+ // would be `undefined` so the stderr line names the refusal instead.
1131
+ if (result.status === "no-verdict") {
1132
+ env.err(`archkeep: no verdict — the coverage refusal is in the report → ${options.output}`);
1133
+ } else {
1134
+ env.err(
1135
+ `archkeep: ${result.drift.observed.projects} projects, ${result.drift.observed.edges} edges ` +
1136
+ `→ ${options.output}`,
1137
+ );
1138
+ }
1112
1139
  } else {
1113
1140
  env.out(report);
1114
1141
  }
1115
1142
 
1116
- // Drift is descriptive: 0 when the comparison completes, never 1.
1117
- return EXIT.ok;
1143
+ // Drift is descriptive: 0 when the comparison completes, never 1 — and an
1144
+ // incomplete analysis is the structured no-verdict envelope's exit 3
1145
+ // (#608), not a clean 0.
1146
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
1118
1147
  }
1119
1148
 
1120
1149
  /**
@@ -1212,16 +1241,22 @@ async function runReconcile(options, { cwd, env }) {
1212
1241
  // `reconcile` has no `--config` flag (`RECONCILE_FLAG_HELP`) — there is no
1213
1242
  // override to pass, only the workspace's un-overridden default to guard.
1214
1243
  if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
1215
- env.err(
1216
- `archkeep: ${result.reconcile.observed.projects} projects, ${result.reconcile.observed.edges} edges ` +
1217
- `→ ${options.output}`,
1218
- );
1244
+ // A refused run carries no `reconcile` payload to summarize (#608).
1245
+ if (result.status === "no-verdict") {
1246
+ env.err(`archkeep: no verdict — the coverage refusal is in the report → ${options.output}`);
1247
+ } else {
1248
+ env.err(
1249
+ `archkeep: ${result.reconcile.observed.projects} projects, ${result.reconcile.observed.edges} edges ` +
1250
+ `→ ${options.output}`,
1251
+ );
1252
+ }
1219
1253
  } else {
1220
1254
  env.out(report);
1221
1255
  }
1222
1256
 
1223
- // Reconcile is descriptive: 0 when the comparison completes, never 1.
1224
- return EXIT.ok;
1257
+ // Reconcile is descriptive: 0 when the comparison completes, never 1 — and
1258
+ // an incomplete analysis is the no-verdict envelope's exit 3 (#608).
1259
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
1225
1260
  }
1226
1261
 
1227
1262
  /**
@@ -1320,12 +1355,18 @@ async function runChange(options, { cwd, env }) {
1320
1355
  // the mechanism and the threat it closes.
1321
1356
  const reportText = report.endsWith("\n") ? report : `${report}\n`;
1322
1357
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1323
- env.err(
1324
- `archkeep: change ${result.changeIntent.reconciliation.verdict} ` +
1325
- `(+${result.changeIntent.reconciliation.matched.length} matched, ` +
1326
- `!${result.changeIntent.reconciliation.unexpected.length} undeclared, ` +
1327
- `?${result.changeIntent.reconciliation.missingExpected.length} unfulfilled) → ${options.output}`,
1328
- );
1358
+ // A coverage-refused run carries no `changeIntent` payload to summarize
1359
+ // (#608) the refusal's own clause list is in the written report.
1360
+ if (result.status === "no-verdict") {
1361
+ env.err(`archkeep: no verdict — the coverage refusal is in the report → ${options.output}`);
1362
+ } else {
1363
+ env.err(
1364
+ `archkeep: change ${result.changeIntent.reconciliation.verdict} ` +
1365
+ `(+${result.changeIntent.reconciliation.matched.length} matched, ` +
1366
+ `!${result.changeIntent.reconciliation.unexpected.length} undeclared, ` +
1367
+ `?${result.changeIntent.reconciliation.missingExpected.length} unfulfilled) → ${options.output}`,
1368
+ );
1369
+ }
1329
1370
  } else {
1330
1371
  env.out(report);
1331
1372
  }
@@ -1396,22 +1437,29 @@ async function runWaivers(options, { cwd, env }) {
1396
1437
  // only glances at this confirmation must not see "N waivers on the
1397
1438
  // table" and conclude that is the whole surface when a
1398
1439
  // `boundarySuppressions` row with no `expiresAt` is hiding something the
1399
- // file --output just wrote down.
1400
- const suppressionNote =
1401
- result.waivers.suppressions.length > 0
1402
- ? `, ${result.waivers.suppressions.length} permanent suppression` +
1403
- `${result.waivers.suppressions.length === 1 ? "" : "s"}`
1404
- : "";
1405
- env.err(
1406
- `archkeep: ${result.waivers.waivers.length} waivers${suppressionNote} on the table ` +
1407
- `→ ${options.output}`,
1408
- );
1440
+ // file --output just wrote down. A coverage-refused run carries no
1441
+ // `waivers` payload at all (#608) — the line names the refusal instead.
1442
+ if (result.status === "no-verdict") {
1443
+ env.err(`archkeep: no verdict — the coverage refusal is in the report → ${options.output}`);
1444
+ } else {
1445
+ const suppressionNote =
1446
+ result.waivers.suppressions.length > 0
1447
+ ? `, ${result.waivers.suppressions.length} permanent suppression` +
1448
+ `${result.waivers.suppressions.length === 1 ? "" : "s"}`
1449
+ : "";
1450
+ env.err(
1451
+ `archkeep: ${result.waivers.waivers.length} waivers${suppressionNote} on the table ` +
1452
+ `→ ${options.output}`,
1453
+ );
1454
+ }
1409
1455
  } else {
1410
1456
  env.out(report);
1411
1457
  }
1412
1458
 
1413
- // Waivers is descriptive: 0 when the surface could be read, never 1.
1414
- return EXIT.ok;
1459
+ // Waivers is descriptive: 0 when the surface could be read, never 1 — and
1460
+ // an incomplete analysis is the no-verdict envelope's exit 3 (#608), the
1461
+ // same tree `check` refuses.
1462
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
1415
1463
  }
1416
1464
 
1417
1465
  /**
@@ -1469,11 +1517,16 @@ async function runFitness(options, { cwd, env }) {
1469
1517
  // the mechanism and the threat it closes.
1470
1518
  const reportText = report.endsWith("\n") ? report : `${report}\n`;
1471
1519
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1472
- env.err(
1473
- `archkeep: ${result.fitness.functions.length} fitness function` +
1474
- `${result.fitness.functions.length === 1 ? "" : "s"} judged (${result.fitness.verdict}) ` +
1475
- `→ ${options.output}`,
1476
- );
1520
+ // A coverage-refused run carries no `fitness` payload to summarize (#608).
1521
+ if (result.status === "no-verdict") {
1522
+ env.err(`archkeep: no verdict the coverage refusal is in the report → ${options.output}`);
1523
+ } else {
1524
+ env.err(
1525
+ `archkeep: ${result.fitness.functions.length} fitness function` +
1526
+ `${result.fitness.functions.length === 1 ? "" : "s"} judged (${result.fitness.verdict}) ` +
1527
+ `→ ${options.output}`,
1528
+ );
1529
+ }
1477
1530
  } else {
1478
1531
  env.out(report);
1479
1532
  }
@@ -1535,18 +1588,24 @@ async function runImpact(options, { cwd, env }) {
1535
1588
  // the mechanism and the threat it closes.
1536
1589
  const reportText = report.endsWith("\n") ? report : `${report}\n`;
1537
1590
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1538
- env.err(
1539
- `archkeep: ${result.impact.dependents.length} project` +
1540
- `${result.impact.dependents.length === 1 ? "" : "s"}` +
1541
- `${result.impact.dependents.length === 1 ? " depends" : " depend"} on ${projectName} ` +
1542
- `→ ${options.output}`,
1543
- );
1591
+ // A coverage-refused run carries no `impact` payload to summarize (#608).
1592
+ if (result.status === "no-verdict") {
1593
+ env.err(`archkeep: no verdict the coverage refusal is in the report → ${options.output}`);
1594
+ } else {
1595
+ env.err(
1596
+ `archkeep: ${result.impact.dependents.length} project` +
1597
+ `${result.impact.dependents.length === 1 ? "" : "s"}` +
1598
+ `${result.impact.dependents.length === 1 ? " depends" : " depend"} on ${projectName} ` +
1599
+ `→ ${options.output}`,
1600
+ );
1601
+ }
1544
1602
  } else {
1545
1603
  env.out(report);
1546
1604
  }
1547
1605
 
1548
- // Impact is descriptive: 0 when it completes, never 1.
1549
- return EXIT.ok;
1606
+ // Impact is descriptive: 0 when it completes, never 1 — and an incomplete
1607
+ // analysis is the no-verdict envelope's exit 3 (#608).
1608
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
1550
1609
  }
1551
1610
  /**
1552
1611
  * `scenario`'s `run`: resolves the command context, reads the scenario file,
@@ -1606,13 +1665,20 @@ async function runScenario(options, { cwd, env }) {
1606
1665
 
1607
1666
  if (options.output) {
1608
1667
  if (!writeOutputReport(options.output, report, env, cwd, options.config)) return EXIT.error;
1609
- env.err(`archkeep: scenario for "${projectName}" complete ${options.output}`);
1668
+ // A refused run still writes the report — but "complete" would deny the
1669
+ // refusal that report carries (#608).
1670
+ if (result.status === "no-verdict") {
1671
+ env.err(`archkeep: no verdict — the coverage refusal is in the report → ${options.output}`);
1672
+ } else {
1673
+ env.err(`archkeep: scenario for "${projectName}" complete → ${options.output}`);
1674
+ }
1610
1675
  } else {
1611
1676
  env.out(report);
1612
1677
  }
1613
1678
 
1614
- // Scenario is descriptive: 0 when it completes, never 1.
1615
- return EXIT.ok;
1679
+ // Scenario is descriptive: 0 when it completes, never 1 — and an incomplete
1680
+ // analysis is the no-verdict envelope's exit 3 (#608).
1681
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
1616
1682
  }
1617
1683
 
1618
1684
  /**
@@ -2259,16 +2325,22 @@ async function runDebt(options, { cwd, env }) {
2259
2325
  // the mechanism and the threat it closes.
2260
2326
  const reportText = report.endsWith("\n") ? report : `${report}\n`;
2261
2327
  if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
2262
- env.err(
2263
- `archkeep: ${result.ledger.total} debt entr` +
2264
- `${result.ledger.total === 1 ? "y" : "ies"} → ${options.output}`,
2265
- );
2328
+ // A coverage-refused run carries no `ledger` payload to summarize (#608).
2329
+ if (result.status === "no-verdict") {
2330
+ env.err(`archkeep: no verdict the coverage refusal is in the report → ${options.output}`);
2331
+ } else {
2332
+ env.err(
2333
+ `archkeep: ${result.ledger.total} debt entr` +
2334
+ `${result.ledger.total === 1 ? "y" : "ies"} → ${options.output}`,
2335
+ );
2336
+ }
2266
2337
  } else {
2267
2338
  env.out(report);
2268
2339
  }
2269
2340
 
2270
- // Debt is descriptive: 0 when the ledger completes, never 1.
2271
- return EXIT.ok;
2341
+ // Debt is descriptive: 0 when the ledger completes, never 1 — and an
2342
+ // incomplete analysis is the no-verdict envelope's exit 3 (#608).
2343
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
2272
2344
  }
2273
2345
 
2274
2346
  /**
@@ -2330,10 +2402,27 @@ async function runDiscover(options, { cwd, env }) {
2330
2402
  }
2331
2403
 
2332
2404
  if (options.writeIntent) {
2405
+ // The one write that can turn a proposal into the law `check` gates on,
2406
+ // so it refuses to replace: a file already at the target is a law (or a
2407
+ // candidate someone holds), and silently overwriting it with a proposal
2408
+ // is the adoption this command must never perform by itself. Move or
2409
+ // delete the file first — a step a human reviews.
2410
+ if (existsSync(options.writeIntent)) {
2411
+ env.err(
2412
+ `archkeep: ${options.writeIntent} already exists, and a proposal must never ` +
2413
+ `silently replace what is there. Move or delete the file first, then run this again.`,
2414
+ );
2415
+ return EXIT.error;
2416
+ }
2333
2417
  try {
2334
2418
  const intentJson = JSON.stringify(proposalToIntent(result.proposal), null, 2) + "\n";
2335
- writeFileSync(options.writeIntent, intentJson, "utf-8");
2419
+ // `wx` refuses the file materializing between the check above and this
2420
+ // write, so the refusal above cannot be raced past.
2421
+ writeFileSync(options.writeIntent, intentJson, { encoding: "utf-8", flag: "wx" });
2336
2422
  env.err(`archkeep: proposed architecture written to ${options.writeIntent}`);
2423
+ env.err(
2424
+ "archkeep: this file is a proposal, not the law — review it as a diff before it governs anything.",
2425
+ );
2337
2426
  } catch (error) {
2338
2427
  env.err(`archkeep: failed to write intent file: ${error.message}`);
2339
2428
  return EXIT.error;
@@ -2799,8 +2888,9 @@ const DISCOVER_FLAG_HELP = Object.freeze([
2799
2888
  describe: Object.freeze([
2800
2889
  "Write the proposed components and rules to a valid",
2801
2890
  "architecture-intent.json file. Only valid with --propose;",
2802
- "the file is a candidate for drift/reconcile and must be",
2803
- "reviewed before use",
2891
+ "refuses to overwrite a file that already exists the file",
2892
+ "is a candidate for drift/reconcile and must be reviewed",
2893
+ "before use",
2804
2894
  ]),
2805
2895
  }),
2806
2896
  Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
5
5
  "keywords": [
6
6
  "architecture",
@@ -160,16 +160,36 @@ which one it was:
160
160
  - A **literal package import that names no declared project** — an uninstalled
161
161
  third-party package, a dependency of some other workspace — is a normal,
162
162
  permanent state: a workspace with packages is not a missing workspace edge.
163
- It stays a POSITIONED failure (`line`/`column` set), the "blind spot" that
164
- does not fail the run.
163
+ It stays a POSITIONED failure (`line`/`column` set), the "blind spot", and
164
+ it carries `external: true`: the specifier asked the external dependency
165
+ universe, not the governed graph, and its resolvability is a property of
166
+ the installed dependency tree a workspace legitimately may not have (a
167
+ fresh clone, a trimmed install, the native self-check's `git archive`
168
+ copy). Disclosed, verdict-neutral — withholding over it would make a tree
169
+ permanently un-green over dependencies nobody crossed.
170
+ - A **literal that references the workspace's own surface and still fails to
171
+ resolve** — a path-like specifier (`./`, `../`, `/`), a `#` subpath, a
172
+ `paths` alias — is the other positioned class, and the one that withholds
173
+ the run's verdict since the loud-coverage contract (#595): the resolver was
174
+ asked a concrete question about the governed graph and could not answer,
175
+ and #595's own reported shape (`#canary-review/index.mjs`, a subpath naming
176
+ a declared project) is exactly this class — `packageNameOf` keeps the `#`,
177
+ so the whole-file name test cannot catch it. `check` exits 3 over it
178
+ rather than reading as a pass.
165
179
  - A **dynamic import with a non-literal argument** — `import(somePath)`, or an
166
180
  `import()` whose argument is a template literal interpolating a variable — is
167
181
  the recurring permanent case. The site is real, the target is not knowable
168
182
  statically, and the honest answer is one record with `kind: "dynamic"`, the
169
183
  source text of the argument as `specifier`, and a POSITIONED failure
170
- (`line`/`column` set). The rest of the file's imports were still judged; a
171
- reader can see this one site in the report's blind-spot section and the run
172
- does not fail on it.
184
+ (`line`/`column` set) carrying `dynamic: true`. The rest of the file's
185
+ imports were still judged; a reader can see this one site in the report's
186
+ blind-spot section. It does not withhold the verdict: a non-literal argument
187
+ is the language itself declaring the target computed at runtime, a limit
188
+ static analysis cannot close, so the run's pass claim covers the statically
189
+ judgeable surface. The `dynamic` field is what keeps the envelope builder
190
+ from reading this row as unjudged work (`report/json.mjs`'s completeness
191
+ law) — the permanent classes are disclosed alike and judged differently,
192
+ and the fields are the only things that tell them apart.
173
193
 
174
194
  Silently dropping any of them is how a boundary gets bypassed.
175
195
 
@@ -207,6 +227,13 @@ fails loudly rather than reporting an empty result that reads as "clean".
207
227
 
208
228
  `failures` carries `{ sourceFile, line, column, reason }`; `line`/`column` are
209
229
  `null` when the failure is about the file as a whole rather than one position.
230
+ A positioned failure produced from a non-literal `import()`/`require()`
231
+ argument additionally carries `dynamic: true`, and one produced from an
232
+ unresolvable bare-package specifier carries `external: true` — the fields the
233
+ verdict and envelope layers read to tell the declared dynamic limit and the
234
+ external dependency universe apart from an unresolvable specifier that
235
+ references the workspace's own surface, which is the class that withholds the
236
+ verdict.
210
237
  Both arrays are always present and always arrays — a consumer never has to
211
238
  check for `undefined` before iterating.
212
239
 
@@ -351,6 +351,113 @@ export const refuseUnreadTree = (reader, failures) => {
351
351
  */
352
352
  export const isWholeFileFailure = (failure) => failure.line === null;
353
353
 
354
+ /**
355
+ * Whether a positioned failure is the declared dynamic limit rather than an
356
+ * unresolvable specifier.
357
+ *
358
+ * Both are permanent blind spots — a site the run saw and could not judge —
359
+ * and both are disclosed identically (`coverage.blindSpots`, the report's
360
+ * blind-spot section). They part ways at the verdict: an unresolvable LITERAL
361
+ * specifier is a concrete question the resolver was asked and could not answer
362
+ * (#595 — a missing workspace edge at site granularity, an uninstalled
363
+ * dependency), so it withholds the run's verdict; a non-literal
364
+ * `import()`/`require()` argument is the language itself declaring the target
365
+ * computed at runtime, which static analysis cannot answer in principle —
366
+ * every config loader that opens a consumer-named file contains one — so it
367
+ * is a declared limit the run states and moves past. Measured on this
368
+ * repository's own tree: ten such sites in config loaders, unfixable without
369
+ * giving up the feature that makes them config loaders.
370
+ *
371
+ * The field is set only by the TypeScript/Vue analyzer, the one analyzer whose
372
+ * language has the construct; every other analyzer's positioned failures are
373
+ * literal specifiers by construction and never set it.
374
+ *
375
+ * @param {{ line: number|null, dynamic?: true }} failure
376
+ * @returns {boolean}
377
+ */
378
+ export const isDynamicSiteFailure = (failure) => failure.dynamic === true;
379
+
380
+ /**
381
+ * Whether a positioned failure is an unresolvable literal pointing at the
382
+ * EXTERNAL dependency universe — the bare-package class that neither withholds
383
+ * the verdict nor may.
384
+ *
385
+ * A bare specifier (`vitest`, `zod`, a scoped package) resolves against an
386
+ * installed dependency tree, and a workspace without that tree — a fresh
387
+ * clone, a trimmed install, the native self-check's `git archive` copy, which
388
+ * by design carries no `node_modules` — is a normal state. Withholding the
389
+ * verdict over it would make the tree permanently un-green over dependencies
390
+ * nobody crossed: measured, that is exactly what this repository's own
391
+ * required boundary gate did under the unqualified flip, 284 such rows on the
392
+ * native face alone. The marker rides the row (the TypeScript/Vue analyzer
393
+ * sets it; `isExternalUnresolvable` holds the class line there — path-like,
394
+ * `#` subpath and `paths`-alias specifiers never get it, so a workspace-edge
395
+ * question can never masquerade as external), and every verdict lane counts
396
+ * by its absence, the same mechanism the `dynamic` marker uses.
397
+ *
398
+ * Only the TypeScript/Vue analyzer sets the field today. The other analyzers'
399
+ * unresolvable literals keep withholding — including legitimately external
400
+ * coordinates (a JVM package coordinate naming an uninstalled library) —
401
+ * which is the over-loud direction and is tracked as its own issue rather
402
+ * than silently tolerated.
403
+ *
404
+ * @param {{ line: number|null, external?: true }} failure
405
+ * @returns {boolean}
406
+ */
407
+ export const isExternalSiteFailure = (failure) => failure.external === true;
408
+
409
+ /**
410
+ * The count of positioned failures that WITHHOLD the run's verdict —
411
+ * unresolvable literal specifiers that reference the workspace's own surface,
412
+ * dynamic declared limits and external bare-package sites excluded.
413
+ *
414
+ * The one number every verdict lane and refusal guard reads (#595, narrowed):
415
+ * `check`'s `coverage.complete` and no-verdict lane, the refusal every
416
+ * descriptive and refuse-class command raises over an unjudged site, and the
417
+ * envelope builder's completeness law all read this class and nothing else.
418
+ * Centralized here so the class line cannot drift between the fifteen call
419
+ * sites the way a repeated inline filter would.
420
+ *
421
+ * @param {{ line: number|null, dynamic?: true, external?: true }[]} failures
422
+ * @returns {number}
423
+ */
424
+ export const unresolvableLiteralCount = (failures) =>
425
+ failures.filter(
426
+ (failure) =>
427
+ !isWholeFileFailure(failure) &&
428
+ !isDynamicSiteFailure(failure) &&
429
+ !isExternalSiteFailure(failure),
430
+ ).length;
431
+
432
+ /**
433
+ * The `coverage.blindSpots` rows every command's coverage block carries: one
434
+ * row per positioned failure, ALL permanent classes, the run's disclosure of
435
+ * every site it saw and did not judge.
436
+ *
437
+ * Disclosure is deliberately wider than the verdict's withholding: a dynamic
438
+ * or external site never flips an exit, but it is still named here — the
439
+ * fields that separate the classes ride the row, which is what lets the
440
+ * envelope builder's completeness law (`report/json.mjs`) tell a declared
441
+ * limit and an external bare package from unjudged work without a second
442
+ * classification.
443
+ *
444
+ * @param {{ sourceFile: string, line: number|null, column: number|null,
445
+ * reason: string, dynamic?: true, external?: true }[]} failures
446
+ * @returns {{ file: string, line: number, column: number, reason: string,
447
+ * dynamic?: true, external?: true }[]}
448
+ */
449
+ export const blindSpotRows = (failures) =>
450
+ failures
451
+ .filter((failure) => !isWholeFileFailure(failure))
452
+ .map(({ sourceFile, line, column, reason, dynamic, external }) => ({
453
+ file: sourceFile,
454
+ line,
455
+ column,
456
+ reason,
457
+ ...(dynamic ? { dynamic: true } : {}),
458
+ ...(external ? { external: true } : {}),
459
+ }));
460
+
354
461
  /**
355
462
  * One whole-file failure per source file, first reason kept.
356
463
  *
@@ -373,6 +373,57 @@ function namesDeclaredProject(specifier, workspace) {
373
373
  return workspace.projects.some((project) => project.name === name || project.name === specifier);
374
374
  }
375
375
 
376
+ /**
377
+ * Whether an unresolvable LITERAL specifier points at the external dependency
378
+ * universe rather than at the workspace's own governed surface — the row class
379
+ * that the loud-coverage contract (#595, narrowed) discloses without
380
+ * withholding the verdict, marked `external: true` on the failure.
381
+ *
382
+ * Three shapes always reference the workspace surface and never get the
383
+ * marker, so a run cannot go quiet over an edge the graph should carry:
384
+ *
385
+ * - a path-like specifier (`./`, `../`, `/`): it names a file in the
386
+ * workspace's own namespace, and failing to resolve it means that path
387
+ * cannot be proven to exist — a broken import, which is loud on purpose;
388
+ * - a `#` subpath import: package-internal by definition, never an external
389
+ * dependency. This is #595's own reported shape
390
+ * (`#canary-review/index.mjs`): `packageNameOf` keeps the `#` prefix, so
391
+ * the whole-file lane's name test cannot catch it, and without this branch
392
+ * the subpath would silently read as external;
393
+ * - a `paths` alias: the mapping declares the prefix workspace-internal, so
394
+ * a specifier under a failed alias is an unproven workspace edge. A `paths`
395
+ * key with `*` matches by its prefix (`@app/*` catches `@app/x/y`); an
396
+ * exact key matches exactly. (`baseUrl` alone is not consulted — its bare
397
+ * results are npm-shaped and read as external.)
398
+ *
399
+ * Everything else — `vitest`, `zod`, a scoped package no `paths` row claims —
400
+ * resolves against an installed dependency tree, and a workspace without that
401
+ * tree installed (a fresh clone, a trimmed install, the native self-check's
402
+ * `git archive` copy, which by design carries no `node_modules`) is a normal
403
+ * state. Those are the rows the native self-check's 284 blind spots are made
404
+ * of, and treating them as verdict-withholding would make that required CI
405
+ * step permanently exit 3 over dependencies nobody crossed.
406
+ *
407
+ * @param {string} specifier The raw specifier as written.
408
+ * @param {ts.CompilerOptions} options The resolution options in force.
409
+ * @returns {boolean}
410
+ */
411
+ function isExternalUnresolvable(specifier, options) {
412
+ if (
413
+ specifier.startsWith("./") ||
414
+ specifier.startsWith("../") ||
415
+ specifier.startsWith("/") ||
416
+ specifier.startsWith("#")
417
+ ) {
418
+ return false;
419
+ }
420
+ for (const key of Object.keys(options.paths ?? {})) {
421
+ const star = key.indexOf("*");
422
+ if (star === -1 ? key === specifier : specifier.startsWith(key.slice(0, star))) return false;
423
+ }
424
+ return true;
425
+ }
426
+
376
427
  /** The dialect to parse `sourceFile` as; `lang` wins when the caller knows it. */
377
428
  function scriptKindFor(sourceFile, lang) {
378
429
  if (lang) return SCRIPT_KIND_BY_LANG[lang] ?? ts.ScriptKind.TS;
@@ -784,7 +835,7 @@ function parseFailures(sourceFile, workspaceRelativePath) {
784
835
  * nested `node_modules/`, which every package that declares its own
785
836
  * `dependencies` has; the branch below says why, with the measurement.
786
837
  *
787
- * @returns {{ resolved: object|null, reason: string|null }}
838
+ * @returns {{ resolved: object|null, reason: string|null, external?: boolean }}
788
839
  */
789
840
  function resolveSpecifier(specifier, sourceFile, workspace) {
790
841
  const context = contextFor(workspace);
@@ -905,6 +956,12 @@ function resolveSpecifier(specifier, sourceFile, workspace) {
905
956
  if (sibling === null) {
906
957
  return {
907
958
  resolved: null,
959
+ // A failed literal is classified here, where the resolution options
960
+ // are in scope: `external: true` marks the bare-package class the
961
+ // contract discloses without withholding (`isExternalUnresolvable`
962
+ // holds the class line). The failure-push site forwards the flag to
963
+ // the row, and every verdict lane counts withholds from its absence.
964
+ external: isExternalUnresolvable(specifier, context.options),
908
965
  reason: `TypeScript cannot resolve '${specifier}' from '${sourceFile}'`,
909
966
  };
910
967
  }
@@ -992,7 +1049,7 @@ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
992
1049
  // a grammar error there — and `import x = require(y)` is the form that
993
1050
  // makes `require` the right word for it.
994
1051
  const callee = site.callee ?? (site.kind === "dynamic" ? "import" : "require");
995
- const { resolved, reason } = site.literal
1052
+ const { resolved, reason, external } = site.literal
996
1053
  ? resolveSpecifier(site.specifier, sourceFile, workspace)
997
1054
  : {
998
1055
  resolved: null,
@@ -1023,13 +1080,37 @@ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
1023
1080
  // site failures — the "blind spot" the contract documents as legitimately
1024
1081
  // permanent (`report/text.mjs`'s `formatFailures` is where the report
1025
1082
  // explains the two, and `cli.mjs` counts `unchecked` by
1026
- // `failure.line === null`). A NON-LITERAL argument keeps its line/column
1027
- // for the same reason: it is genuinely not statically knowable.
1083
+ // `failure.line === null`).
1084
+ //
1085
+ // The positioned rows then part ways by class, and the markers on the
1086
+ // row are what every verdict lane counts by:
1087
+ //
1088
+ // - a positioned literal that still references the workspace's own
1089
+ // surface — path-like, `#` subpath, or a `paths` alias — carries NO
1090
+ // marker and withholds the run's verdict (#595, narrowed): a `#`
1091
+ // subpath naming a declared project is exactly the shape #595
1092
+ // reported, and `packageNameOf` keeps the `#`, so the whole-file
1093
+ // lane's name test above cannot catch it;
1094
+ // - a positioned literal pointing at the bare-package universe carries
1095
+ // `external: true` (`isExternalUnresolvable` holds the class line)
1096
+ // and discloses without withholding — the native self-check's 284
1097
+ // bare rows are this class, and withholding them would make that
1098
+ // required CI step permanently exit 3;
1099
+ // - a NON-LITERAL argument keeps its line/column for the same reason:
1100
+ // it is genuinely not statically knowable, and carries
1101
+ // `dynamic: true` (contract.md).
1028
1102
  if (reason) {
1029
1103
  result.failures.push(
1030
1104
  site.literal && namesDeclaredProject(site.specifier, workspace)
1031
1105
  ? fileFailure(sourceFile, reason)
1032
- : { sourceFile, line: line + 1, column: character + 1, reason },
1106
+ : {
1107
+ sourceFile,
1108
+ line: line + 1,
1109
+ column: character + 1,
1110
+ reason,
1111
+ ...(site.literal ? {} : { dynamic: true }),
1112
+ ...(site.literal && external ? { external: true } : {}),
1113
+ },
1033
1114
  );
1034
1115
  }
1035
1116
  }