@ecoma-io/archkeep 0.13.0 → 0.14.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/README.md +1 -1
- package/cli.mjs +158 -2
- package/package.json +1 -1
- package/src/commands/README.md +19 -1
- package/src/commands/check.mjs +82 -16
- package/src/commands/context.mjs +52 -14
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/delta-classify.mjs +502 -0
- package/src/commands/delta-snapshot.mjs +517 -0
- package/src/commands/delta.mjs +481 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/policy.mjs +36 -1
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/providers/native/model.mjs +17 -0
- package/src/report/delta-text.mjs +183 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/sarif.mjs +25 -0
- package/src/report/text.mjs +36 -0
- package/src/report/waivers-text.mjs +35 -2
package/README.md
CHANGED
|
@@ -220,7 +220,7 @@ Exit codes: 0 clean — and every selected file was analyzed; 1 findings;
|
|
|
220
220
|
Ten minutes end to end, most of it spent deciding what your tags mean:
|
|
221
221
|
[**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
|
|
222
222
|
`history`, `drift`, `impact`, `explain`, `context` and the rest of the
|
|
223
|
-
|
|
223
|
+
eighteen-command surface are in the [CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
|
|
224
224
|
|
|
225
225
|
## Documentation map
|
|
226
226
|
|
package/cli.mjs
CHANGED
|
@@ -104,6 +104,7 @@ import { contextCommand } from "./src/commands/context-command.mjs";
|
|
|
104
104
|
import { planContextCommand } from "./src/commands/plan-context-command.mjs";
|
|
105
105
|
import { adrCommand } from "./src/commands/adr.mjs";
|
|
106
106
|
import { diffCommand } from "./src/commands/diff.mjs";
|
|
107
|
+
import { captureDelta, deltaCommand } from "./src/commands/delta.mjs";
|
|
107
108
|
import { discoverCommand } from "./src/commands/discover.mjs";
|
|
108
109
|
import { driftCommand } from "./src/commands/drift.mjs";
|
|
109
110
|
import { fitnessCommand } from "./src/commands/fitness.mjs";
|
|
@@ -941,6 +942,93 @@ async function runDiff(options, { cwd, env }) {
|
|
|
941
942
|
return EXIT.ok;
|
|
942
943
|
}
|
|
943
944
|
|
|
945
|
+
/**
|
|
946
|
+
* `delta`'s `run`: two modes behind one verb.
|
|
947
|
+
*
|
|
948
|
+
* `--capture` writes the evidence snapshot a later run compares against;
|
|
949
|
+
* `delta <baseline>` loads one, re-judges both sides under the current law,
|
|
950
|
+
* and folds the classification into the exit code — the one descriptive-family
|
|
951
|
+
* verb beside `check` and `fitness` whose verdict carries exit 1
|
|
952
|
+
* (`./src/commands/delta.mjs` owns the fold).
|
|
953
|
+
*
|
|
954
|
+
* @param {{format: string, output: string|null, config: string|null, capture: boolean,
|
|
955
|
+
* paths: string[]}} options
|
|
956
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
957
|
+
* @returns {Promise<number>}
|
|
958
|
+
*/
|
|
959
|
+
async function runDelta(options, { cwd, env }) {
|
|
960
|
+
if (options.capture) {
|
|
961
|
+
if (options.paths.length !== 0) {
|
|
962
|
+
env.err(
|
|
963
|
+
`archkeep: delta --capture takes no positional arguments; got ${options.paths.join(", ")}`,
|
|
964
|
+
);
|
|
965
|
+
return EXIT.usage;
|
|
966
|
+
}
|
|
967
|
+
} else if (options.paths.length !== 1) {
|
|
968
|
+
env.err(
|
|
969
|
+
`archkeep: delta takes exactly one positional argument (the baseline evidence snapshot), ` +
|
|
970
|
+
`or --capture to write one; got ${options.paths.length}`,
|
|
971
|
+
);
|
|
972
|
+
return EXIT.usage;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
let result;
|
|
976
|
+
try {
|
|
977
|
+
const commandContext = resolveCommandContext(
|
|
978
|
+
{ cwd },
|
|
979
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
// Both modes need the boundary law: capture fingerprints it, compare
|
|
983
|
+
// re-judges both sides under it — the same ladder every judging command
|
|
984
|
+
// resolves through (`resolvePolicy`).
|
|
985
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
986
|
+
|
|
987
|
+
if (options.capture) {
|
|
988
|
+
const { text } = captureDelta(commandContext, { config });
|
|
989
|
+
if (options.output) {
|
|
990
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring
|
|
991
|
+
// owns the mechanism and the threat it closes.
|
|
992
|
+
if (!writeOutputReport(options.output, text, env, cwd, options.config)) return EXIT.error;
|
|
993
|
+
env.err(`archkeep: delta baseline captured → ${options.output}`);
|
|
994
|
+
} else {
|
|
995
|
+
env.out(text);
|
|
996
|
+
}
|
|
997
|
+
// Capture is descriptive: 0 on success, 3 on any failure, never 1.
|
|
998
|
+
return EXIT.ok;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const baselinePath = isAbsolute(options.paths[0])
|
|
1002
|
+
? resolve(options.paths[0])
|
|
1003
|
+
: resolve(cwd, options.paths[0]);
|
|
1004
|
+
result = deltaCommand(baselinePath, commandContext, { config });
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
const usageError = error instanceof UsageError;
|
|
1007
|
+
env.err(String(error?.message ?? error));
|
|
1008
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1012
|
+
|
|
1013
|
+
if (options.output) {
|
|
1014
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1015
|
+
// the mechanism and the threat it closes.
|
|
1016
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1017
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1018
|
+
env.err(`archkeep: delta complete → ${options.output}`);
|
|
1019
|
+
} else {
|
|
1020
|
+
env.out(report);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// The exit fold `deltaCommand` computed: a non-waived introduced violation
|
|
1024
|
+
// is a finding, an unclassifiable item is a no-verdict, anything else is
|
|
1025
|
+
// clean — mapped here the same way `fitness`'s status is.
|
|
1026
|
+
return (
|
|
1027
|
+
{ ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
|
|
1028
|
+
EXIT.error
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
944
1032
|
/**
|
|
945
1033
|
* `drift`'s `run`: resolves the command context, drives `driftCommand`, writes
|
|
946
1034
|
* the report where it belongs, and returns the process's exit code.
|
|
@@ -1156,9 +1244,14 @@ async function runWaivers(options, { cwd, env }) {
|
|
|
1156
1244
|
// against this tool's own location, and a `profiles` registry resolves
|
|
1157
1245
|
// `--config`/`boundaryConfig` as a profile NAME the same way `check`
|
|
1158
1246
|
// does. A malformed law throws here, exit 3, exactly as in `check`.
|
|
1159
|
-
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1247
|
+
const { config, source } = await resolvePolicy(options, commandContext, cwd);
|
|
1160
1248
|
|
|
1161
|
-
|
|
1249
|
+
// `source` rides along for one job: `waiversCommand` subtracts the law's
|
|
1250
|
+
// own file from the unowned-file set the `coverage.unowned` acceptances
|
|
1251
|
+
// are matched against, exactly as `check` does — the law is not source
|
|
1252
|
+
// judged by the law (`../src/commands/context.mjs`'s
|
|
1253
|
+
// `unownedGapWithoutRunConfiguration`).
|
|
1254
|
+
result = await waiversCommand(commandContext, config, { policySource: source });
|
|
1162
1255
|
} catch (error) {
|
|
1163
1256
|
const usageError = error instanceof UsageError;
|
|
1164
1257
|
env.err(String(error?.message ?? error));
|
|
@@ -2011,6 +2104,58 @@ const DIFF_FLAG_HELP = Object.freeze([
|
|
|
2011
2104
|
}),
|
|
2012
2105
|
]);
|
|
2013
2106
|
|
|
2107
|
+
/**
|
|
2108
|
+
* `delta`'s flags: `--capture` writes the evidence snapshot a later run
|
|
2109
|
+
* compares against; without it the baseline file is the single positional
|
|
2110
|
+
* argument. `--config` overrides the boundary law the same way `check`'s
|
|
2111
|
+
* does, because both sides are re-judged under whichever law this run
|
|
2112
|
+
* resolves.
|
|
2113
|
+
*
|
|
2114
|
+
* @type {readonly FlagHelp[]}
|
|
2115
|
+
*/
|
|
2116
|
+
const DELTA_FLAG_HELP = Object.freeze([
|
|
2117
|
+
Object.freeze({
|
|
2118
|
+
flag: "--capture",
|
|
2119
|
+
key: "capture",
|
|
2120
|
+
arg: "",
|
|
2121
|
+
describe: Object.freeze([
|
|
2122
|
+
"Write an evidence snapshot of the current tree",
|
|
2123
|
+
"(raw import records, graph, coverage, policy",
|
|
2124
|
+
"fingerprint) for a later delta run to compare against",
|
|
2125
|
+
]),
|
|
2126
|
+
}),
|
|
2127
|
+
Object.freeze({
|
|
2128
|
+
flag: "--format",
|
|
2129
|
+
key: "format",
|
|
2130
|
+
arg: "text|json",
|
|
2131
|
+
describe: Object.freeze([
|
|
2132
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2133
|
+
"docs/reference/json-output.md documents",
|
|
2134
|
+
]),
|
|
2135
|
+
}),
|
|
2136
|
+
Object.freeze({
|
|
2137
|
+
flag: "--output",
|
|
2138
|
+
key: "output",
|
|
2139
|
+
arg: "<file>",
|
|
2140
|
+
describe: Object.freeze([
|
|
2141
|
+
"Write the report — or, with --capture, the snapshot —",
|
|
2142
|
+
"to a file instead of stdout",
|
|
2143
|
+
]),
|
|
2144
|
+
}),
|
|
2145
|
+
Object.freeze({
|
|
2146
|
+
flag: "--config",
|
|
2147
|
+
key: "config",
|
|
2148
|
+
arg: "<file>",
|
|
2149
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2150
|
+
Object.freeze([
|
|
2151
|
+
"Read the boundary law from here instead of",
|
|
2152
|
+
inline
|
|
2153
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2154
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2155
|
+
]),
|
|
2156
|
+
}),
|
|
2157
|
+
]);
|
|
2158
|
+
|
|
2014
2159
|
/**
|
|
2015
2160
|
* `drift`'s flags: text or JSON envelope, optional file output. The intent is
|
|
2016
2161
|
* always read from the tracked root `architecture-intent.json` — the same one
|
|
@@ -2527,6 +2672,17 @@ const COMMANDS = Object.freeze({
|
|
|
2527
2672
|
formats: DESCRIBABLE_FORMATS,
|
|
2528
2673
|
run: runDiff,
|
|
2529
2674
|
}),
|
|
2675
|
+
delta: Object.freeze({
|
|
2676
|
+
name: "delta",
|
|
2677
|
+
args: "<baseline> | --capture",
|
|
2678
|
+
summary: "Classify how boundary violations moved between a captured baseline and head",
|
|
2679
|
+
flagHelp: DELTA_FLAG_HELP,
|
|
2680
|
+
flags: Object.freeze(Object.fromEntries(DELTA_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2681
|
+
defaults: Object.freeze({ format: "text", output: null, config: null, capture: false }),
|
|
2682
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2683
|
+
booleans: Object.freeze(["capture"]),
|
|
2684
|
+
run: runDelta,
|
|
2685
|
+
}),
|
|
2530
2686
|
discover: Object.freeze({
|
|
2531
2687
|
name: "discover",
|
|
2532
2688
|
args: "[--propose]",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript and Vue",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
package/src/commands/README.md
CHANGED
|
@@ -23,7 +23,7 @@ the resolution order.
|
|
|
23
23
|
(`./custom-rules.mjs`). Exits 1 on any of them, and it is the only
|
|
24
24
|
command holding all four exit codes
|
|
25
25
|
([which verbs carry exit 1 is settled in `docs/concepts/architecture.md`](../../../../docs/concepts/architecture.md)
|
|
26
|
-
— `fitness`
|
|
26
|
+
— `fitness` and `delta` are the other two).
|
|
27
27
|
|
|
28
28
|
- **`graph`** (`./graph.mjs`'s `graphCommand`) — the project graph as a
|
|
29
29
|
deterministic, serialisable snapshot: projects (with `targets` and `tags`) and
|
|
@@ -42,6 +42,24 @@ the resolution order.
|
|
|
42
42
|
Refuses an Nx workspace with polyglot manifests but no plugin registration.
|
|
43
43
|
Descriptive: never exits 1.
|
|
44
44
|
|
|
45
|
+
- **`delta`** (`./delta.mjs`'s `captureDelta` and `deltaCommand`) — two modes
|
|
46
|
+
behind one verb. `--capture` writes the evidence snapshot
|
|
47
|
+
`./delta-snapshot.mjs` defines — raw import-site records, the graph, coverage,
|
|
48
|
+
provenance, the policy fingerprint; evidence, never verdicts. Compare loads a
|
|
49
|
+
baseline, re-judges BOTH sides through `../rules/index.mjs` under the CURRENT
|
|
50
|
+
config and one shared instant, and classifies each violation
|
|
51
|
+
introduced/resolved/unchanged/unknown (`./delta-classify.mjs`), with
|
|
52
|
+
unresolvable import sites carried as their own category, never counted as
|
|
53
|
+
violations. Refuses an unreadable, malformed, foreign-schema, or
|
|
54
|
+
incomplete-coverage baseline, a provider mismatch (stricter than `diff`'s
|
|
55
|
+
note — violation identity across two project models is not evidence),
|
|
56
|
+
incomplete head coverage, and an Nx workspace with polyglot manifests but no
|
|
57
|
+
plugin registration; a policy-fingerprint change is a loud coverage note, not
|
|
58
|
+
a refusal. A verdict, not a description: a non-waived introduced violation is
|
|
59
|
+
a finding (exit 1 — the third verb beside `check` and `fitness`), an
|
|
60
|
+
unclassifiable item is a no-verdict (exit 3), and a waived-introduced entry
|
|
61
|
+
is reported without gating. Capture stays descriptive: never exits 1.
|
|
62
|
+
|
|
45
63
|
- **`impact`** (`./impact.mjs`'s `impactCommand`) — reverse reachability from
|
|
46
64
|
the project graph: given a project name, lists every project that transitively
|
|
47
65
|
depends on it. Separates direct from transitive dependents. When a boundary
|
package/src/commands/check.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { suppressionCovers } from "../config.mjs";
|
|
|
18
18
|
import { referenceTime } from "../governance/clock.mjs";
|
|
19
19
|
import { suppressionFate } from "../governance/waiver.mjs";
|
|
20
20
|
import { resolveCommandContext, unownedGapWithoutRunConfiguration } from "./context.mjs";
|
|
21
|
+
import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
|
|
21
22
|
import { readAdrContext } from "./adr.mjs";
|
|
22
23
|
import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
|
|
23
24
|
import { declaredEdgeViolationsForCheck } from "./edge-constraints.mjs";
|
|
@@ -248,6 +249,51 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
248
249
|
}
|
|
249
250
|
: null;
|
|
250
251
|
|
|
252
|
+
// The unowned-file question, answered once and BEFORE any verdict below
|
|
253
|
+
// reads `failures`: the tolerated TS/JS/Vue gap (`./context.mjs`'s
|
|
254
|
+
// `unownedGap`), minus the files this run read as its own configuration —
|
|
255
|
+
// the law that actually governed THIS run, which `policySource` names
|
|
256
|
+
// (`--config` override and resolved profile included), never the declared
|
|
257
|
+
// name alone. Subtracting here rather than inside `resolveCommandContext`
|
|
258
|
+
// is forced: `resolvePolicy` takes the context as an argument, so it cannot
|
|
259
|
+
// run before it, and until it has run nothing knows which law governed.
|
|
260
|
+
// `tsConfig` joins it because it is configuration by the same test, though
|
|
261
|
+
// every spelling of it is `.json` today and so never reaches the list.
|
|
262
|
+
const unownedGap = unownedGapWithoutRunConfiguration(commandContext.unownedGap, [
|
|
263
|
+
policySource,
|
|
264
|
+
commandContext.options.tsConfig,
|
|
265
|
+
]);
|
|
266
|
+
// Then the acceptance channel: the policy's `coverage.unowned` rows,
|
|
267
|
+
// matched against BOTH unowned sets — this gap and the Go/Rust/Python
|
|
268
|
+
// unclaimed list — and nothing else (`./coverage-acceptance.mjs` owns the
|
|
269
|
+
// guarantee that an owned file is unreachable). Unowned-ness is decided
|
|
270
|
+
// exactly as before this channel existed; the rows only partition the
|
|
271
|
+
// result into accepted and uncovered.
|
|
272
|
+
const coverageRows = config?.coverage?.unowned ?? [];
|
|
273
|
+
const unownedCoverage = partitionUnownedCoverage({
|
|
274
|
+
rows: coverageRows,
|
|
275
|
+
unownedGap,
|
|
276
|
+
unclaimedFiles: commandContext.unclaimedGap.files,
|
|
277
|
+
tracked,
|
|
278
|
+
});
|
|
279
|
+
// A covered unclaimed file's whole-file failure is withdrawn here: the file
|
|
280
|
+
// is still unowned and still unanalyzed, but its state is a RECORDED
|
|
281
|
+
// acceptance now — stated below as the `"accepted-unowned-files"` coverage
|
|
282
|
+
// gap, never silently — rather than the exit-3 refusal an unanswered
|
|
283
|
+
// orphan earns. Uncovered unclaimed files keep their failures, and with
|
|
284
|
+
// them the exit code, byte-identical to before the channel existed. An
|
|
285
|
+
// unclaimed file carries exactly one failure (it is unowned, so no
|
|
286
|
+
// analyzer ever read it), so filtering by file cannot drop an unrelated
|
|
287
|
+
// read failure.
|
|
288
|
+
const acceptedUnclaimed = new Set(
|
|
289
|
+
commandContext.unclaimedGap.files.filter((file) => unownedCoverage.acceptedFiles.has(file)),
|
|
290
|
+
);
|
|
291
|
+
if (acceptedUnclaimed.size > 0) {
|
|
292
|
+
for (let at = failures.length - 1; at >= 0; at -= 1) {
|
|
293
|
+
if (acceptedUnclaimed.has(failures[at].sourceFile)) failures.splice(at, 1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
251
297
|
// The go.work drift check, keyed off the manifest's presence the way every
|
|
252
298
|
// resolver keys off its language's manifest: no tracked root go.work, no
|
|
253
299
|
// check and no mention. It ignores `options.paths` on purpose — two
|
|
@@ -622,9 +668,22 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
622
668
|
config !== null &&
|
|
623
669
|
options.paths.length === 0 &&
|
|
624
670
|
failures.length === 0 &&
|
|
625
|
-
(config.suppressions.length > 0 || config.depConstraints.length > 0)
|
|
671
|
+
(config.suppressions.length > 0 || config.depConstraints.length > 0 || coverageRows.length > 0)
|
|
626
672
|
) {
|
|
627
673
|
const deadRows = [];
|
|
674
|
+
// The third dead table: a `coverage.unowned` row matching no unowned file
|
|
675
|
+
// across BOTH sets (`./coverage-acceptance.mjs`) accepts nothing — the
|
|
676
|
+
// files it covered are owned now, or the path was never right — and it is
|
|
677
|
+
// refused in the same sentence shape the native provider's stale
|
|
678
|
+
// `coverage.exempt` row has always gotten
|
|
679
|
+
// (`../providers/native/index.mjs`), under the same gate as its two
|
|
680
|
+
// siblings above this comment's block.
|
|
681
|
+
for (const { path, index } of unownedCoverage.dead) {
|
|
682
|
+
deadRows.push(
|
|
683
|
+
`coverage.unowned[${index}]: '${path}' matches no unowned file this run judged — either ` +
|
|
684
|
+
`the files it accepted are owned by a project now, or the path was never right`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
628
687
|
config.suppressions.forEach((row, index) => {
|
|
629
688
|
const fate = suppressionFate(row, now);
|
|
630
689
|
if (fate === "waive") return;
|
|
@@ -739,25 +798,16 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
739
798
|
// `coverage.complete` untouched — those belong to `unchecked`, and moving
|
|
740
799
|
// this state into them would turn `check` red on trees whose only sin is a
|
|
741
800
|
// root-level tooling script.
|
|
742
|
-
// The
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
// inside `resolveCommandContext` is forced — `resolvePolicy` takes the
|
|
746
|
-
// context as an argument, so it cannot run before it. `tsConfig` joins it
|
|
747
|
-
// because it is configuration by the same test, though every spelling of it
|
|
748
|
-
// is `.json` today and so never reaches the list.
|
|
749
|
-
const unownedGap = unownedGapWithoutRunConfiguration(commandContext.unownedGap, [
|
|
750
|
-
policySource,
|
|
751
|
-
commandContext.options.tsConfig,
|
|
752
|
-
]);
|
|
753
|
-
|
|
801
|
+
// The subtraction of the run's own configuration, and the acceptance
|
|
802
|
+
// partition, both happened beside the policy above — `unownedCoverage` is
|
|
803
|
+
// that one computation's result, read here rather than recomputed.
|
|
754
804
|
const coverageGaps = [
|
|
755
805
|
...(commandContext.provider === "nx" &&
|
|
756
806
|
!commandContext.pluginGap.registered &&
|
|
757
807
|
commandContext.pluginGap.manifests.length > 0
|
|
758
808
|
? [{ kind: "unregistered-plugin", manifests: commandContext.pluginGap.manifests }]
|
|
759
809
|
: []),
|
|
760
|
-
...(
|
|
810
|
+
...(unownedCoverage.uncoveredUnowned.files.length > 0
|
|
761
811
|
? [
|
|
762
812
|
{
|
|
763
813
|
kind: "unowned-files",
|
|
@@ -766,8 +816,24 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
|
|
|
766
816
|
// `moon.yml`), and the faces that render this carry no other way
|
|
767
817
|
// to know which tree they are describing.
|
|
768
818
|
provider: commandContext.provider,
|
|
769
|
-
languages:
|
|
770
|
-
files:
|
|
819
|
+
languages: unownedCoverage.uncoveredUnowned.languages,
|
|
820
|
+
files: unownedCoverage.uncoveredUnowned.files,
|
|
821
|
+
},
|
|
822
|
+
]
|
|
823
|
+
: []),
|
|
824
|
+
// The accepted half of both unowned sets, stated every run the
|
|
825
|
+
// acceptance is in force: an accepted hole is loud, never invisible —
|
|
826
|
+
// the report keeps naming the files, and `archkeep waivers` names each
|
|
827
|
+
// accepting row with its reason. Only the permanent unanswerable
|
|
828
|
+
// question (the warning above) and the unclaimed exit 3 are gone for
|
|
829
|
+
// these files.
|
|
830
|
+
...(unownedCoverage.accepted.files.length > 0
|
|
831
|
+
? [
|
|
832
|
+
{
|
|
833
|
+
kind: "accepted-unowned-files",
|
|
834
|
+
provider: commandContext.provider,
|
|
835
|
+
languages: unownedCoverage.accepted.languages,
|
|
836
|
+
files: unownedCoverage.accepted.files,
|
|
771
837
|
},
|
|
772
838
|
]
|
|
773
839
|
: []),
|
package/src/commands/context.mjs
CHANGED
|
@@ -228,6 +228,15 @@ export const WORKSPACE_MARKERS = [NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE, MOON_DIR,
|
|
|
228
228
|
* failure (`../providers/native/coverage.mjs`'s `judgeCoverage`) and so
|
|
229
229
|
* already refuses the run with exit 3 — a gap beside it would be a second,
|
|
230
230
|
* quieter voice for a state that is answered loudly.
|
|
231
|
+
* @property {{files: string[]}} unclaimedGap The tracked Go, Rust or Python
|
|
232
|
+
* files no project owns — the SAME files whose whole-file failures
|
|
233
|
+
* `unclaimedFileFailures` already put in `analysis.failures`, listed a
|
|
234
|
+
* second time as data so `./check.mjs` and `./waivers.mjs` can match the
|
|
235
|
+
* policy's `coverage.unowned` acceptance rows against them
|
|
236
|
+
* (`./coverage-acceptance.mjs`) without parsing a failure's sentence back
|
|
237
|
+
* into a file list. Always `{files: []}` on a native workspace, whose own
|
|
238
|
+
* `coverage.exempt` channel makes the policy key unreachable there
|
|
239
|
+
* (`./policy.mjs`'s `resolvePolicy`).
|
|
231
240
|
* @property {{file: string, project: string}[]} owned Every tracked file that
|
|
232
241
|
* belongs to a project, paired with its owning project — the ownership map
|
|
233
242
|
* `createWorkspace` already built. A command that needs to know WHICH project
|
|
@@ -305,20 +314,37 @@ const UNCLAIMED_CHECK_LANGUAGES = new Set(["go", "rust", "python"]);
|
|
|
305
314
|
* tool reads — inventing one is out of scope here; this only detects and
|
|
306
315
|
* reports.
|
|
307
316
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
317
|
+
* The file list and the failures it becomes are two exports on purpose:
|
|
318
|
+
* `./check.mjs` and `./waivers.mjs` need the LIST a second time — the
|
|
319
|
+
* `coverage.unowned` acceptance channel (`./coverage-acceptance.mjs`) matches
|
|
320
|
+
* its rows against exactly this set, and deriving the set from the failures'
|
|
321
|
+
* wording would bind an acceptance decision to a sentence.
|
|
322
|
+
*
|
|
323
|
+
* @param {{tracked: string[], owned: {file: string, project: string}[]}} args
|
|
324
|
+
* @returns {string[]}
|
|
310
325
|
*/
|
|
311
|
-
function
|
|
326
|
+
function unclaimedAnalyzableFiles({ tracked, owned }) {
|
|
312
327
|
const ownedFiles = new Set(owned.map(({ file }) => file));
|
|
313
|
-
return tracked
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
328
|
+
return tracked.filter(
|
|
329
|
+
(file) => UNCLAIMED_CHECK_LANGUAGES.has(languageOf(file)) && !ownedFiles.has(file),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The whole-file failures for `unclaimedAnalyzableFiles`' list — the shape
|
|
335
|
+
* argued in the comment above the two functions.
|
|
336
|
+
*
|
|
337
|
+
* @param {{files: string[], providerLabel: string}} args
|
|
338
|
+
* @returns {object[]}
|
|
339
|
+
*/
|
|
340
|
+
function unclaimedFileFailures({ files, providerLabel }) {
|
|
341
|
+
return files.map((file) =>
|
|
342
|
+
fileFailure(
|
|
343
|
+
file,
|
|
344
|
+
`is not owned by any project in ${providerLabel} — every tracked Go, Rust or Python file ` +
|
|
345
|
+
`must belong to exactly one declared project, so its cross-project imports can be checked`,
|
|
346
|
+
),
|
|
347
|
+
);
|
|
322
348
|
}
|
|
323
349
|
|
|
324
350
|
/**
|
|
@@ -498,6 +524,7 @@ export function resolveCommandContext(
|
|
|
498
524
|
let analyzedFiles;
|
|
499
525
|
let pluginGap;
|
|
500
526
|
let unownedGap;
|
|
527
|
+
let unclaimedGap;
|
|
501
528
|
let exemptedFiles;
|
|
502
529
|
|
|
503
530
|
if (hasNative) {
|
|
@@ -607,6 +634,12 @@ export function resolveCommandContext(
|
|
|
607
634
|
// the reason `pluginGap` is: a reader must not have to tell "false" from
|
|
608
635
|
// "this branch forgot".
|
|
609
636
|
unownedGap = { files: [], languages: [] };
|
|
637
|
+
// Same statement one list over: native's unclaimed files are already
|
|
638
|
+
// whole-file failures in `discovered.failures` above, and the policy's
|
|
639
|
+
// `coverage.unowned` channel is refused outright on this provider
|
|
640
|
+
// (`./policy.mjs`'s `resolvePolicy`), so there is nothing here for that
|
|
641
|
+
// channel to match against.
|
|
642
|
+
unclaimedGap = { files: [] };
|
|
610
643
|
} else if (hasMoon) {
|
|
611
644
|
// Moon provider — reads graph from `moon project-graph --json`, the same
|
|
612
645
|
// one-call contract as the Nx path: Moon already resolved projects, tags
|
|
@@ -683,11 +716,13 @@ export function resolveCommandContext(
|
|
|
683
716
|
// native's own `discovered.failures` has (this branch's header already
|
|
684
717
|
// analyzes the whole tree before `paths` narrows anything, for the same
|
|
685
718
|
// reason).
|
|
719
|
+
const unclaimedFiles = unclaimedAnalyzableFiles({ tracked, owned });
|
|
686
720
|
failures = [
|
|
687
721
|
...wholeTreeAnalysis.failures.filter((failure) => selectedFiles.has(failure.sourceFile)),
|
|
688
|
-
...unclaimedFileFailures({
|
|
722
|
+
...unclaimedFileFailures({ files: unclaimedFiles, providerLabel: "the Moon project graph" }),
|
|
689
723
|
...pythonUnmodelledFailures(workspace),
|
|
690
724
|
];
|
|
725
|
+
unclaimedGap = { files: unclaimedFiles };
|
|
691
726
|
analyzedFiles = wholeTreeAnalysis.analyzedFiles.filter((file) => selectedFiles.has(file));
|
|
692
727
|
analyzed = analyzedFiles.length;
|
|
693
728
|
// `coverage.exempt` is a native-only key (`../providers/native/coverage.mjs`'s
|
|
@@ -742,11 +777,13 @@ export function resolveCommandContext(
|
|
|
742
777
|
// unconditionally, the same workspace-wide posture native's own
|
|
743
778
|
// `discovered.failures` has, so a scoped `check <path>` cannot hide an
|
|
744
779
|
// orphan file elsewhere in the tree by naming a path that excludes it.
|
|
780
|
+
const unclaimedFiles = unclaimedAnalyzableFiles({ tracked, owned });
|
|
745
781
|
failures = [
|
|
746
782
|
...failures,
|
|
747
|
-
...unclaimedFileFailures({
|
|
783
|
+
...unclaimedFileFailures({ files: unclaimedFiles, providerLabel: "the Nx project graph" }),
|
|
748
784
|
...pythonUnmodelledFailures(workspace),
|
|
749
785
|
];
|
|
786
|
+
unclaimedGap = { files: unclaimedFiles };
|
|
750
787
|
// Same reason as the Moon branch above: `coverage.exempt` is a native-only
|
|
751
788
|
// concept, so an Nx workspace has nothing to report here.
|
|
752
789
|
exemptedFiles = [];
|
|
@@ -774,6 +811,7 @@ export function resolveCommandContext(
|
|
|
774
811
|
options,
|
|
775
812
|
pluginGap,
|
|
776
813
|
unownedGap,
|
|
814
|
+
unclaimedGap,
|
|
777
815
|
// Every tracked file that belongs to a project, paired with its project —
|
|
778
816
|
// the ownership map `createWorkspace` already built (`own ./workspace.mjs`).
|
|
779
817
|
// A command that needs to know WHICH project owns a file (the planning
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy's `coverage.unowned` acceptance channel, matched in ONE place
|
|
3
|
+
* against the two unowned-file sets a run establishes — so `check` and
|
|
4
|
+
* `waivers` cannot disagree about which files a row accepts.
|
|
5
|
+
*
|
|
6
|
+
* The channel exists for the Nx and Moon providers, whose project model has
|
|
7
|
+
* no home of its own for "this file is owned by no project, and we accept
|
|
8
|
+
* that, for this reason" — the decision `archkeep.json`'s `coverage.exempt`
|
|
9
|
+
* records on a native tree (`../providers/native/coverage.mjs`). Without it,
|
|
10
|
+
* the two unowned states those providers report are both permanent: the
|
|
11
|
+
* TS/JS/Vue gap (`./context.mjs`'s `unownedAnalyzableFiles`) warns on every
|
|
12
|
+
* run with no way to answer it, and a Go/Rust/Python unclaimed file
|
|
13
|
+
* (`unclaimedFileFailures` there) is a hard exit 3 with no accepted middle
|
|
14
|
+
* ground.
|
|
15
|
+
*
|
|
16
|
+
* A covered file is a RECORDED acceptance, never an invisible one: `check`
|
|
17
|
+
* still states every accepted file, as the `"accepted-unowned-files"`
|
|
18
|
+
* coverage-gap entry (`./check.mjs`), and `archkeep waivers` names each row
|
|
19
|
+
* with its reason and current coverage. What changes is only the permanent
|
|
20
|
+
* half — the warning stops re-asking a question someone answered, and the
|
|
21
|
+
* unclaimed exit 3 stops firing for a file whose acceptance is on record.
|
|
22
|
+
*
|
|
23
|
+
* ## Matching starts AFTER unowned-ness is decided
|
|
24
|
+
*
|
|
25
|
+
* The inputs here are lists something else already judged: `unownedGap` is
|
|
26
|
+
* the tolerated TS/JS/Vue list with the run's own configuration files
|
|
27
|
+
* subtracted (`./context.mjs`'s `unownedGapWithoutRunConfiguration`, applied
|
|
28
|
+
* by the caller), and `unclaimedFiles` is the Go/Rust/Python unclaimed list
|
|
29
|
+
* the same module computes. A row is matched against those lists ONLY —
|
|
30
|
+
* never against the tracked tree — so even a `**` row can never accept, or
|
|
31
|
+
* silence anything about, a file a project owns. That is the same guarantee
|
|
32
|
+
* the native provider's `coverage.exempt` states, kept by the same
|
|
33
|
+
* construction (`../providers/native/coverage.mjs`'s `judgeCoverage` filters
|
|
34
|
+
* `unowned` first and matches second).
|
|
35
|
+
*
|
|
36
|
+
* A row matching NOTHING across both sets is dead — the files it accepted
|
|
37
|
+
* are owned now, or the path was never right — and dead is a verdict the
|
|
38
|
+
* caller must refuse loudly, not a state to skip: `./check.mjs`'s dead-row
|
|
39
|
+
* block does, in the same sentence shape as the native stale-row refusal
|
|
40
|
+
* (`../providers/native/index.mjs`).
|
|
41
|
+
*/
|
|
42
|
+
import { languageOf } from "../analysis/registry.mjs";
|
|
43
|
+
import { safeMatchesGlob } from "../rules/match.mjs";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The languages a file list spans — sorted and distinct, derived beside the
|
|
47
|
+
* list it describes so no face can name a language the list does not contain
|
|
48
|
+
* (the same rule `./context.mjs`'s `unownedAnalyzableFiles` states).
|
|
49
|
+
*
|
|
50
|
+
* @param {string[]} files
|
|
51
|
+
* @returns {string[]}
|
|
52
|
+
*/
|
|
53
|
+
function languagesOf(files) {
|
|
54
|
+
return [...new Set(files.map((file) => languageOf(file)))].sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Partitions a run's unowned files into accepted and uncovered under the
|
|
59
|
+
* policy's `coverage.unowned` rows.
|
|
60
|
+
*
|
|
61
|
+
* Everything is derived from the arguments — no filesystem, no policy load —
|
|
62
|
+
* so a test drives it directly and the two callers (`./check.mjs`,
|
|
63
|
+
* `./waivers.mjs`) provably run the identical judgment.
|
|
64
|
+
*
|
|
65
|
+
* @param {{
|
|
66
|
+
* rows: {path: string, reason: string}[],
|
|
67
|
+
* unownedGap: {files: string[], languages: string[]},
|
|
68
|
+
* unclaimedFiles: string[],
|
|
69
|
+
* tracked: string[],
|
|
70
|
+
* }} args `rows` is `config.coverage?.unowned ?? []` — the validated table
|
|
71
|
+
* (`../config.mjs`'s `findCoverageViolations`). `tracked` fixes the order
|
|
72
|
+
* accepted files are reported in: `git ls-files` order, the same order
|
|
73
|
+
* every other file list in a report keeps.
|
|
74
|
+
* @returns {{
|
|
75
|
+
* rows: {path: string, reason: string, index: number, files: string[]}[],
|
|
76
|
+
* dead: {path: string, reason: string, index: number}[],
|
|
77
|
+
* accepted: {files: string[], languages: string[]},
|
|
78
|
+
* acceptedFiles: Set<string>,
|
|
79
|
+
* uncoveredUnowned: {files: string[], languages: string[]},
|
|
80
|
+
* }} `rows` is every declared row with the unowned files it currently
|
|
81
|
+
* accepts; `dead` the subset accepting none. `accepted` is the union of
|
|
82
|
+
* both sets' covered files in tracked order; `uncoveredUnowned` is
|
|
83
|
+
* `unownedGap` minus the accepted files — the warning that survives.
|
|
84
|
+
* Uncovered UNCLAIMED files need no list of their own: their whole-file
|
|
85
|
+
* failures are already in the caller's hands, untouched.
|
|
86
|
+
*/
|
|
87
|
+
export function partitionUnownedCoverage({ rows, unownedGap, unclaimedFiles, tracked }) {
|
|
88
|
+
const candidates = [...unownedGap.files, ...unclaimedFiles];
|
|
89
|
+
const matched = rows.map((row, index) => ({
|
|
90
|
+
path: row.path,
|
|
91
|
+
reason: row.reason,
|
|
92
|
+
index,
|
|
93
|
+
files: candidates.filter((file) => safeMatchesGlob(file, row.path)),
|
|
94
|
+
}));
|
|
95
|
+
const acceptedFiles = new Set(matched.flatMap((entry) => entry.files));
|
|
96
|
+
const acceptedInTrackedOrder = tracked.filter((file) => acceptedFiles.has(file));
|
|
97
|
+
const uncoveredFiles = unownedGap.files.filter((file) => !acceptedFiles.has(file));
|
|
98
|
+
return {
|
|
99
|
+
rows: matched,
|
|
100
|
+
dead: matched
|
|
101
|
+
.filter((entry) => entry.files.length === 0)
|
|
102
|
+
.map(({ path, reason, index }) => ({ path, reason, index })),
|
|
103
|
+
accepted: {
|
|
104
|
+
files: acceptedInTrackedOrder,
|
|
105
|
+
languages: languagesOf(acceptedInTrackedOrder),
|
|
106
|
+
},
|
|
107
|
+
acceptedFiles,
|
|
108
|
+
uncoveredUnowned: {
|
|
109
|
+
files: uncoveredFiles,
|
|
110
|
+
languages: languagesOf(uncoveredFiles),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|