@ecoma-io/archkeep 0.21.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.
Files changed (53) hide show
  1. package/cli.mjs +156 -66
  2. package/gate-attestation.mjs +23 -0
  3. package/package.json +3 -1
  4. package/src/analysis/analyze.mjs +6 -0
  5. package/src/analysis/contract.md +32 -5
  6. package/src/analysis/csharp.mjs +18 -0
  7. package/src/analysis/go.mjs +18 -0
  8. package/src/analysis/java.mjs +15 -0
  9. package/src/analysis/kotlin.mjs +15 -0
  10. package/src/analysis/python.mjs +25 -3
  11. package/src/analysis/rust.mjs +18 -0
  12. package/src/analysis/source-util.mjs +113 -0
  13. package/src/analysis/typescript.mjs +86 -5
  14. package/src/canonical.mjs +43 -25
  15. package/src/commands/README.md +63 -12
  16. package/src/commands/change-intent.mjs +25 -1
  17. package/src/commands/change.mjs +90 -40
  18. package/src/commands/check.mjs +65 -26
  19. package/src/commands/completeness.mjs +126 -19
  20. package/src/commands/context-command.mjs +13 -5
  21. package/src/commands/context.mjs +31 -4
  22. package/src/commands/coverage-verdict.mjs +191 -0
  23. package/src/commands/debt.mjs +18 -15
  24. package/src/commands/delta-classify.mjs +13 -18
  25. package/src/commands/delta-snapshot.mjs +13 -5
  26. package/src/commands/delta.mjs +95 -33
  27. package/src/commands/diff.mjs +31 -24
  28. package/src/commands/discover.mjs +70 -29
  29. package/src/commands/drift.mjs +21 -21
  30. package/src/commands/edge-constraints.mjs +47 -1
  31. package/src/commands/evaluation-primitives.mjs +194 -2
  32. package/src/commands/evolution.mjs +27 -10
  33. package/src/commands/explain.mjs +14 -13
  34. package/src/commands/fitness.mjs +20 -19
  35. package/src/commands/graph.mjs +29 -11
  36. package/src/commands/health.mjs +12 -5
  37. package/src/commands/history.mjs +41 -26
  38. package/src/commands/impact.mjs +17 -18
  39. package/src/commands/plan-context-command.mjs +10 -5
  40. package/src/commands/reconcile.mjs +14 -17
  41. package/src/commands/scenario-evaluation.mjs +93 -16
  42. package/src/commands/scenario.mjs +28 -18
  43. package/src/commands/waivers.mjs +36 -28
  44. package/src/governance/evolution-event.mjs +96 -9
  45. package/src/intent/intent-manifest.json +83 -39
  46. package/src/lsp/diagnose.mjs +12 -3
  47. package/src/report/discover-text.mjs +31 -9
  48. package/src/report/graph-text.mjs +25 -5
  49. package/src/report/json.mjs +32 -5
  50. package/src/report/text.mjs +82 -12
  51. package/src/verdict.mjs +78 -36
  52. package/src/verify-gate-attestation.mjs +323 -0
  53. 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({
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The gate-attestation verifier's public face — what a consumer imports from
3
+ * this package's `./gate-attestation` subpath to validate an attestation with
4
+ * the version it actually installed.
5
+ *
6
+ * It holds no logic on purpose, the same bargain `./commands` (`commands.mjs`)
7
+ * and `./nx` (`nx.mjs`) make: a named entry that is a re-export, so the
8
+ * verifier can grow under `src/` without a second copy of any decision
9
+ * appearing beside it.
10
+ *
11
+ * ```js
12
+ * import { validateGateAttestation, readGateAttestation }
13
+ * from "@ecoma-io/archkeep/gate-attestation";
14
+ * ```
15
+ */
16
+ export {
17
+ ATTESTED_PACKAGE,
18
+ GATE_ATTESTATION_SCHEMA_VERSION,
19
+ readGateAttestation,
20
+ reachesCheckVerdict,
21
+ validateGateAttestation,
22
+ verifiedAdopters,
23
+ } from "./src/verify-gate-attestation.mjs";
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.1",
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",
@@ -29,6 +29,7 @@
29
29
  ".": "./index.mjs",
30
30
  "./nx": "./nx.mjs",
31
31
  "./commands": "./commands.mjs",
32
+ "./gate-attestation": "./gate-attestation.mjs",
32
33
  "./presets/*.json": "./presets/*.json",
33
34
  "./package.json": "./package.json"
34
35
  },
@@ -36,6 +37,7 @@
36
37
  "index.mjs",
37
38
  "nx.mjs",
38
39
  "commands.mjs",
40
+ "gate-attestation.mjs",
39
41
  "cli.mjs",
40
42
  "lsp.mjs",
41
43
  "src/",
@@ -121,6 +121,12 @@ export { LANGUAGE_BY_EXTENSION, languageOf };
121
121
  * the file as a whole rather than one position.
122
122
  * @property {number|null} column 1-based, or `null`.
123
123
  * @property {string} reason Human-readable; written to be read in a report.
124
+ * @property {true} [dynamic] The site's target is computed at runtime — the
125
+ * language itself declines a static answer. Disclosed without withholding
126
+ * (`isDynamicSiteFailure`).
127
+ * @property {true} [external] The site names the external dependency universe
128
+ * rather than the governed graph. Disclosed without withholding
129
+ * (`isExternalSiteFailure`); every analyzer sets it (#603).
124
130
  */
125
131
 
126
132
  /**
@@ -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
 
@@ -429,6 +429,24 @@ export function analyzeCSharp({ sourceFile, text, workspace }) {
429
429
  external: true,
430
430
  packageName: site.importableName,
431
431
  };
432
+ // The bare-coordinate class the contract discloses without
433
+ // withholding (#603): the namespace names the external dependency
434
+ // universe, not the governed graph, so the site is DISCLOSED — a
435
+ // positioned row carrying `external: true`
436
+ // (`isExternalSiteFailure`), the run's verdict untouched — rather
437
+ // than swallowed, the same classification the TypeScript analyzer
438
+ // already emits. A name a tracked namespace claims resolved through
439
+ // the index above and never reaches this branch; the split-package
440
+ // branch below keeps withholding. The `importableName === null`
441
+ // arm above is the extern-alias construct, not an unresolvable
442
+ // coordinate, and stays unfailed.
443
+ result.failures.push({
444
+ sourceFile,
445
+ line,
446
+ column,
447
+ reason: `C# cannot resolve '${site.importableName}' from '${sourceFile}'`,
448
+ external: true,
449
+ });
432
450
  } else if (resolved.ambiguous) {
433
451
  resolution = null;
434
452
  result.failures.push({
@@ -704,6 +704,24 @@ export function analyzeGo({ sourceFile, text, workspace }) {
704
704
  packageName: target === null ? site.specifier : null,
705
705
  },
706
706
  });
707
+ // The bare-module class the contract discloses without withholding
708
+ // (#603): the import names the external dependency universe, not the
709
+ // governed graph, so the site is DISCLOSED — a positioned row carrying
710
+ // `external: true` (`isExternalSiteFailure`), the run's verdict
711
+ // untouched — rather than swallowed. An empty blind-spot list must mean
712
+ // "nothing to disclose", and a workspace whose imports reach outside it
713
+ // has things to disclose. An import naming a declared module resolved
714
+ // above and never reaches this branch; the same classification the
715
+ // TypeScript analyzer already emits.
716
+ if (target === null) {
717
+ result.failures.push({
718
+ sourceFile,
719
+ line,
720
+ column,
721
+ reason: `Go cannot resolve '${site.specifier}' from '${sourceFile}'`,
722
+ external: true,
723
+ });
724
+ }
707
725
  }
708
726
  } catch (cause) {
709
727
  result.failures.push(fileFailure(sourceFile, `Go analysis failed: ${cause?.message ?? cause}`));
@@ -235,6 +235,21 @@ export function analyzeJava({ sourceFile, text, workspace }) {
235
235
  external: true,
236
236
  packageName: site.importableName,
237
237
  };
238
+ // The bare-coordinate class the contract discloses without withholding
239
+ // (#603): the dotted name names the external dependency universe, not
240
+ // the governed graph, so the site is DISCLOSED — a positioned row
241
+ // carrying `external: true` (`isExternalSiteFailure`), the run's
242
+ // verdict untouched — rather than swallowed, the same classification
243
+ // the TypeScript analyzer already emits. A name a tracked package
244
+ // prefix claims resolved through the index above and never reaches
245
+ // this branch; the split-package branch below keeps withholding.
246
+ result.failures.push({
247
+ sourceFile,
248
+ line,
249
+ column,
250
+ reason: `Java cannot resolve '${site.importableName}' from '${sourceFile}'`,
251
+ external: true,
252
+ });
238
253
  } else if (resolved.ambiguous) {
239
254
  // Split package: unresolvable by static reading, so `resolved` is
240
255
  // null WITH a positioned failure naming every claimant — the Python
@@ -184,6 +184,21 @@ export function analyzeKotlin({ sourceFile, text, workspace }) {
184
184
  external: true,
185
185
  packageName: site.importableName,
186
186
  };
187
+ // The bare-coordinate class the contract discloses without withholding
188
+ // (#603): the dotted name names the external dependency universe, not
189
+ // the governed graph, so the site is DISCLOSED — a positioned row
190
+ // carrying `external: true` (`isExternalSiteFailure`), the run's
191
+ // verdict untouched — rather than swallowed, the same classification
192
+ // the TypeScript analyzer already emits. A name a tracked package
193
+ // prefix claims resolved through the index above and never reaches
194
+ // this branch; the split-package branch below keeps withholding.
195
+ result.failures.push({
196
+ sourceFile,
197
+ line,
198
+ column,
199
+ reason: `Kotlin cannot resolve '${site.importableName}' from '${sourceFile}'`,
200
+ external: true,
201
+ });
187
202
  } else if (resolved.ambiguous) {
188
203
  resolution = null;
189
204
  result.failures.push({
@@ -1243,10 +1243,15 @@ export function analyzePython({ sourceFile, text, workspace }) {
1243
1243
  continue;
1244
1244
  }
1245
1245
  if (!site.literal) {
1246
- fail(
1247
- `dynamic import of '${site.specifier}' has a non-literal argument, ` +
1246
+ result.failures.push({
1247
+ sourceFile,
1248
+ line,
1249
+ column,
1250
+ reason:
1251
+ `dynamic import of '${site.specifier}' has a non-literal argument, ` +
1248
1252
  `so its target is not knowable statically`,
1249
- );
1253
+ dynamic: true,
1254
+ });
1250
1255
  continue;
1251
1256
  }
1252
1257
 
@@ -1293,6 +1298,23 @@ export function analyzePython({ sourceFile, text, workspace }) {
1293
1298
  external: true,
1294
1299
  packageName: absolute.split(".")[0],
1295
1300
  };
1301
+ // The bare-package class the contract discloses without withholding
1302
+ // (#603): every layout is modelled and the name still reaches no
1303
+ // tracked package, so the external answer is the language's, and the
1304
+ // site is DISCLOSED — a positioned row carrying `external: true`
1305
+ // (`isExternalSiteFailure`), the run's verdict untouched — rather
1306
+ // than swallowed, the same classification the TypeScript analyzer
1307
+ // already emits. The unmodelled branch above keeps withholding,
1308
+ // because there the external answer is NOT the language's; the
1309
+ // relative-import and ambiguous branches keep withholding because
1310
+ // those names the workspace's own surface.
1311
+ result.failures.push({
1312
+ sourceFile,
1313
+ line,
1314
+ column,
1315
+ reason: `Python cannot resolve '${site.specifier}' from '${sourceFile}'`,
1316
+ external: true,
1317
+ });
1296
1318
  } else if (resolution.ambiguous) {
1297
1319
  fail(
1298
1320
  `'${site.specifier}' resolves through the namespace package '${resolution.prefix}', which ` +
@@ -804,6 +804,24 @@ export function analyzeRust({ sourceFile, text, workspace }) {
804
804
  // so a `bannedExternalImports` glob is written against this form.
805
805
  packageName: target === null ? site.root : null,
806
806
  };
807
+ // The bare-crate class the contract discloses without withholding
808
+ // (#603): the `use` names the external dependency universe, not the
809
+ // governed graph, so the site is DISCLOSED — a positioned row carrying
810
+ // `external: true` (`isExternalSiteFailure`), the run's verdict
811
+ // untouched — rather than swallowed, the same classification the
812
+ // TypeScript analyzer already emits. A name a workspace crate declares
813
+ // resolved above and never reaches this branch; the brace-group
814
+ // malformation above keeps withholding, because there the name itself
815
+ // was never read.
816
+ if (target === null) {
817
+ result.failures.push({
818
+ sourceFile,
819
+ line,
820
+ column,
821
+ reason: `Rust cannot resolve '${site.specifier}' from '${sourceFile}'`,
822
+ external: true,
823
+ });
824
+ }
807
825
  }
808
826
  result.imports.push({
809
827
  sourceFile,