@ecoma-io/archkeep 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +9 -3
  2. package/cli.mjs +599 -55
  3. package/commands.mjs +51 -0
  4. package/package.json +3 -1
  5. package/src/analysis/typescript.mjs +2 -1
  6. package/src/commands/README.md +70 -1
  7. package/src/commands/change-intent.mjs +461 -0
  8. package/src/commands/change.mjs +612 -0
  9. package/src/commands/check.mjs +84 -17
  10. package/src/commands/context.mjs +92 -16
  11. package/src/commands/coverage-acceptance.mjs +113 -0
  12. package/src/commands/custom-rules.mjs +286 -2
  13. package/src/commands/delta-classify.mjs +664 -0
  14. package/src/commands/delta-snapshot.mjs +672 -0
  15. package/src/commands/delta.mjs +606 -0
  16. package/src/commands/diff.mjs +41 -13
  17. package/src/commands/evolution.mjs +473 -0
  18. package/src/commands/explain.mjs +39 -0
  19. package/src/commands/history.mjs +130 -103
  20. package/src/commands/policy.mjs +93 -1
  21. package/src/commands/trajectory.mjs +437 -0
  22. package/src/commands/waivers.mjs +53 -3
  23. package/src/config.mjs +129 -11
  24. package/src/lsp/boundary-config.mjs +9 -4
  25. package/src/path-util.mjs +40 -0
  26. package/src/providers/native/model.mjs +17 -0
  27. package/src/report/change-text.mjs +148 -0
  28. package/src/report/delta-text.mjs +264 -0
  29. package/src/report/evolution-text.mjs +83 -0
  30. package/src/report/explain-text.mjs +27 -0
  31. package/src/report/history-text.mjs +4 -114
  32. package/src/report/sarif.mjs +280 -0
  33. package/src/report/snapshot-text.mjs +123 -0
  34. package/src/report/text.mjs +36 -0
  35. package/src/report/trajectory-text.mjs +143 -0
  36. package/src/report/waivers-text.mjs +35 -2
  37. package/src/tsconfig-paths.mjs +3 -2
@@ -14,10 +14,12 @@ import { join } from "node:path";
14
14
 
15
15
  import { fileFailure, isWholeFileFailure } from "../analysis/source-util.mjs";
16
16
  import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
17
+ import { stripTrailingSlashes } from "../path-util.mjs";
17
18
  import { suppressionCovers } from "../config.mjs";
18
19
  import { referenceTime } from "../governance/clock.mjs";
19
20
  import { suppressionFate } from "../governance/waiver.mjs";
20
21
  import { resolveCommandContext, unownedGapWithoutRunConfiguration } from "./context.mjs";
22
+ import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
21
23
  import { readAdrContext } from "./adr.mjs";
22
24
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
23
25
  import { declaredEdgeViolationsForCheck } from "./edge-constraints.mjs";
@@ -154,7 +156,7 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
154
156
  // spelling) — and `./project.json` is a different string from
155
157
  // `project.json` to every consumer that compares paths, this file's own
156
158
  // SARIF `uri` included.
157
- const scoped = typeof root === "string" ? root.replace(/\/+$/u, "") : "";
159
+ const scoped = typeof root === "string" ? stripTrailingSlashes(root) : "";
158
160
  return scoped === "" || scoped === "." ? site.file : `${scoped}/${site.file}`;
159
161
  }
160
162
  /**
@@ -248,6 +250,51 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
248
250
  }
249
251
  : null;
250
252
 
253
+ // The unowned-file question, answered once and BEFORE any verdict below
254
+ // reads `failures`: the tolerated TS/JS/Vue gap (`./context.mjs`'s
255
+ // `unownedGap`), minus the files this run read as its own configuration —
256
+ // the law that actually governed THIS run, which `policySource` names
257
+ // (`--config` override and resolved profile included), never the declared
258
+ // name alone. Subtracting here rather than inside `resolveCommandContext`
259
+ // is forced: `resolvePolicy` takes the context as an argument, so it cannot
260
+ // run before it, and until it has run nothing knows which law governed.
261
+ // `tsConfig` joins it because it is configuration by the same test, though
262
+ // every spelling of it is `.json` today and so never reaches the list.
263
+ const unownedGap = unownedGapWithoutRunConfiguration(commandContext.unownedGap, [
264
+ policySource,
265
+ commandContext.options.tsConfig,
266
+ ]);
267
+ // Then the acceptance channel: the policy's `coverage.unowned` rows,
268
+ // matched against BOTH unowned sets — this gap and the Go/Rust/Python
269
+ // unclaimed list — and nothing else (`./coverage-acceptance.mjs` owns the
270
+ // guarantee that an owned file is unreachable). Unowned-ness is decided
271
+ // exactly as before this channel existed; the rows only partition the
272
+ // result into accepted and uncovered.
273
+ const coverageRows = config?.coverage?.unowned ?? [];
274
+ const unownedCoverage = partitionUnownedCoverage({
275
+ rows: coverageRows,
276
+ unownedGap,
277
+ unclaimedFiles: commandContext.unclaimedGap.files,
278
+ tracked,
279
+ });
280
+ // A covered unclaimed file's whole-file failure is withdrawn here: the file
281
+ // is still unowned and still unanalyzed, but its state is a RECORDED
282
+ // acceptance now — stated below as the `"accepted-unowned-files"` coverage
283
+ // gap, never silently — rather than the exit-3 refusal an unanswered
284
+ // orphan earns. Uncovered unclaimed files keep their failures, and with
285
+ // them the exit code, byte-identical to before the channel existed. An
286
+ // unclaimed file carries exactly one failure (it is unowned, so no
287
+ // analyzer ever read it), so filtering by file cannot drop an unrelated
288
+ // read failure.
289
+ const acceptedUnclaimed = new Set(
290
+ commandContext.unclaimedGap.files.filter((file) => unownedCoverage.acceptedFiles.has(file)),
291
+ );
292
+ if (acceptedUnclaimed.size > 0) {
293
+ for (let at = failures.length - 1; at >= 0; at -= 1) {
294
+ if (acceptedUnclaimed.has(failures[at].sourceFile)) failures.splice(at, 1);
295
+ }
296
+ }
297
+
251
298
  // The go.work drift check, keyed off the manifest's presence the way every
252
299
  // resolver keys off its language's manifest: no tracked root go.work, no
253
300
  // check and no mention. It ignores `options.paths` on purpose — two
@@ -622,9 +669,22 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
622
669
  config !== null &&
623
670
  options.paths.length === 0 &&
624
671
  failures.length === 0 &&
625
- (config.suppressions.length > 0 || config.depConstraints.length > 0)
672
+ (config.suppressions.length > 0 || config.depConstraints.length > 0 || coverageRows.length > 0)
626
673
  ) {
627
674
  const deadRows = [];
675
+ // The third dead table: a `coverage.unowned` row matching no unowned file
676
+ // across BOTH sets (`./coverage-acceptance.mjs`) accepts nothing — the
677
+ // files it covered are owned now, or the path was never right — and it is
678
+ // refused in the same sentence shape the native provider's stale
679
+ // `coverage.exempt` row has always gotten
680
+ // (`../providers/native/index.mjs`), under the same gate as its two
681
+ // siblings above this comment's block.
682
+ for (const { path, index } of unownedCoverage.dead) {
683
+ deadRows.push(
684
+ `coverage.unowned[${index}]: '${path}' matches no unowned file this run judged — either ` +
685
+ `the files it accepted are owned by a project now, or the path was never right`,
686
+ );
687
+ }
628
688
  config.suppressions.forEach((row, index) => {
629
689
  const fate = suppressionFate(row, now);
630
690
  if (fate === "waive") return;
@@ -739,25 +799,16 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
739
799
  // `coverage.complete` untouched — those belong to `unchecked`, and moving
740
800
  // this state into them would turn `check` red on trees whose only sin is a
741
801
  // 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
-
802
+ // The subtraction of the run's own configuration, and the acceptance
803
+ // partition, both happened beside the policy above `unownedCoverage` is
804
+ // that one computation's result, read here rather than recomputed.
754
805
  const coverageGaps = [
755
806
  ...(commandContext.provider === "nx" &&
756
807
  !commandContext.pluginGap.registered &&
757
808
  commandContext.pluginGap.manifests.length > 0
758
809
  ? [{ kind: "unregistered-plugin", manifests: commandContext.pluginGap.manifests }]
759
810
  : []),
760
- ...(unownedGap.files.length > 0
811
+ ...(unownedCoverage.uncoveredUnowned.files.length > 0
761
812
  ? [
762
813
  {
763
814
  kind: "unowned-files",
@@ -766,8 +817,24 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
766
817
  // `moon.yml`), and the faces that render this carry no other way
767
818
  // to know which tree they are describing.
768
819
  provider: commandContext.provider,
769
- languages: unownedGap.languages,
770
- files: unownedGap.files,
820
+ languages: unownedCoverage.uncoveredUnowned.languages,
821
+ files: unownedCoverage.uncoveredUnowned.files,
822
+ },
823
+ ]
824
+ : []),
825
+ // The accepted half of both unowned sets, stated every run the
826
+ // acceptance is in force: an accepted hole is loud, never invisible —
827
+ // the report keeps naming the files, and `archkeep waivers` names each
828
+ // accepting row with its reason. Only the permanent unanswerable
829
+ // question (the warning above) and the unclaimed exit 3 are gone for
830
+ // these files.
831
+ ...(unownedCoverage.accepted.files.length > 0
832
+ ? [
833
+ {
834
+ kind: "accepted-unowned-files",
835
+ provider: commandContext.provider,
836
+ languages: unownedCoverage.accepted.languages,
837
+ files: unownedCoverage.accepted.files,
771
838
  },
772
839
  ]
773
840
  : []),
@@ -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 = [];
@@ -765,8 +802,7 @@ export function resolveCommandContext(
765
802
  // can name it correctly.
766
803
  return {
767
804
  root,
768
- provider: hasMoon ? "moon" : hasNative ? "native" : "nx",
769
- marker: hasMoon ? moonMarker : hasNative ? ARCHKEEP_MODEL_FILE : NX_CONFIG_FILE,
805
+ ...workspaceNames(hasMoon, hasNative, moonMarker),
770
806
  graph,
771
807
  workspace,
772
808
  tracked,
@@ -774,6 +810,7 @@ export function resolveCommandContext(
774
810
  options,
775
811
  pluginGap,
776
812
  unownedGap,
813
+ unclaimedGap,
777
814
  // Every tracked file that belongs to a project, paired with its project —
778
815
  // the ownership map `createWorkspace` already built (`own ./workspace.mjs`).
779
816
  // A command that needs to know WHICH project owns a file (the planning
@@ -783,6 +820,45 @@ export function resolveCommandContext(
783
820
  };
784
821
  }
785
822
 
823
+ /**
824
+ * The provider/marker pair a workspace root carries, derived from marker
825
+ * presence through the one mapping every envelope header must agree on.
826
+ * `resolveCommandContext` reads it for its own context; a caller that needs
827
+ * the identity WITHOUT judging the tree — an envelope header over a run whose
828
+ * analyzed revisions each carry their own contexts (`./evolution.mjs`) —
829
+ * calls `describeWorkspaceRoot`, so the vocabulary ("nx"/"native"/"moon" and
830
+ * the marker that decided it) has exactly one home.
831
+ *
832
+ * @param {boolean} hasMoon Whether a Moon directory marks the root.
833
+ * @param {boolean} hasNative Whether `archkeep.json` marks the root.
834
+ * @param {string|null} moonMarker Which Moon directory is present.
835
+ * @returns {{provider: "nx" | "moon" | "native", marker: string}}
836
+ */
837
+ function workspaceNames(hasMoon, hasNative, moonMarker) {
838
+ return {
839
+ provider: hasMoon ? "moon" : hasNative ? "native" : "nx",
840
+ marker: hasMoon ? moonMarker : hasNative ? ARCHKEEP_MODEL_FILE : NX_CONFIG_FILE,
841
+ };
842
+ }
843
+
844
+ /**
845
+ * The workspace identity of `root` — which project model governs it and which
846
+ * marker decided that — without reading one source file or building one graph.
847
+ * The single-project-model gate runs here exactly as it does in
848
+ * `resolveCommandContext`: both markers present is refused here for the same
849
+ * reason it is refused there, because a caller about to describe this
850
+ * workspace must not name a model the full read would have refused.
851
+ *
852
+ * @param {string} root Absolute path to the workspace root.
853
+ * @returns {{provider: "nx" | "moon" | "native", marker: string}}
854
+ * @throws {Error} when more than one project-model marker is present
855
+ * (`requireSingleProjectModel`).
856
+ */
857
+ export function describeWorkspaceRoot(root) {
858
+ const { hasNative, moonMarker } = requireSingleProjectModel(root);
859
+ return workspaceNames(moonMarker !== null, hasNative, moonMarker);
860
+ }
861
+
786
862
  // Re-exported so a caller that only needs "does this tree look like a
787
863
  // workspace at all" (`../../cli.mjs`'s `optionsForUsage`) is not forced to
788
864
  // duplicate the marker check a second time; `DEFAULT_OPTIONS` rides along for
@@ -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
+ }