@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 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
- seventeen-command surface are in the [CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
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
- result = await waiversCommand(commandContext, config);
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.13.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",
@@ -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` is the other one).
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
@@ -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 law that actually governed THIS run, not the one the workspace
743
- // declared: `policySource` already carries the `--config` override and the
744
- // resolved profile, workspace-relative. Subtracting it here rather than
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
- ...(unownedGap.files.length > 0
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: unownedGap.languages,
770
- files: unownedGap.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
  : []),
@@ -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
- * @param {{tracked: string[], owned: {file: string, project: string}[], providerLabel: string}} args
309
- * @returns {object[]}
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 unclaimedFileFailures({ tracked, owned, providerLabel }) {
326
+ function unclaimedAnalyzableFiles({ tracked, owned }) {
312
327
  const ownedFiles = new Set(owned.map(({ file }) => file));
313
- return tracked
314
- .filter((file) => UNCLAIMED_CHECK_LANGUAGES.has(languageOf(file)) && !ownedFiles.has(file))
315
- .map((file) =>
316
- fileFailure(
317
- file,
318
- `is not owned by any project in ${providerLabel} — every tracked Go, Rust or Python file ` +
319
- `must belong to exactly one declared project, so its cross-project imports can be checked`,
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({ tracked, owned, providerLabel: "the Moon project graph" }),
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({ tracked, owned, providerLabel: "the Nx project graph" }),
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
+ }