@ecoma-io/archkeep 0.20.1 → 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.
- package/cli.mjs +156 -66
- package/package.json +1 -1
- package/src/analysis/contract.md +32 -5
- package/src/analysis/source-util.mjs +107 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/commands/change.mjs +59 -28
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +708 -0
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +184 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +30 -10
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +691 -0
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +14 -5
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +29 -15
- package/src/commands/impact-statement.mjs +31 -409
- package/src/commands/impact.mjs +18 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/provenance-command.mjs +33 -2
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +363 -198
- package/src/commands/scenario.mjs +32 -21
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +62 -9
- package/src/governance/provenance-graph.mjs +479 -0
- package/src/intent/intent-manifest.json +83 -39
- package/src/report/json.mjs +32 -5
- package/src/report/provenance-text.mjs +30 -7
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1019
|
+
result.status === "no-verdict"
|
|
1008
1020
|
? result.report.json
|
|
1009
|
-
: options.format === "
|
|
1010
|
-
? result.report.
|
|
1011
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
2803
|
-
"
|
|
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.
|
|
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",
|
package/src/analysis/contract.md
CHANGED
|
@@ -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"
|
|
164
|
-
|
|
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)
|
|
171
|
-
reader can see this one site in the report's
|
|
172
|
-
does not
|
|
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`).
|
|
1027
|
-
//
|
|
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
|
-
: {
|
|
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
|
}
|