@cplieger/deadset-ts 5.2.2 → 6.0.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 (75) hide show
  1. package/README.md +1 -1
  2. package/dist/src/analysis.js +28 -5
  3. package/dist/src/augmentations.js +50 -0
  4. package/dist/src/components.js +15 -15
  5. package/dist/src/conformance-record.js +2 -2
  6. package/dist/src/convention-rows.js +17 -2
  7. package/dist/src/conventions.js +82 -1
  8. package/dist/src/dependencies.js +42 -26
  9. package/dist/src/entry-points.js +28 -14
  10. package/dist/src/exempt.js +1 -0
  11. package/dist/src/findings/dependencies-and-module-machinery.js +2 -2
  12. package/dist/src/findings/emitters.js +47 -12
  13. package/dist/src/findings/interfaces.js +4 -3
  14. package/dist/src/findings/intra-function.js +15 -2
  15. package/dist/src/findings/unused-declarations.js +13 -1
  16. package/dist/src/findings-pass.js +1 -0
  17. package/dist/src/graph.js +6 -6
  18. package/dist/src/implementations.js +1 -1
  19. package/dist/src/indexed-access.js +215 -0
  20. package/dist/src/interface-satisfaction.js +85 -23
  21. package/dist/src/intra-function-parts.js +139 -30
  22. package/dist/src/inventory.js +6 -1
  23. package/dist/src/kinds.js +13 -12
  24. package/dist/src/matrix.js +34 -9
  25. package/dist/src/module-declarations.js +180 -0
  26. package/dist/src/outside-tests.js +17 -15
  27. package/dist/src/overrides.js +70 -9
  28. package/dist/src/query.js +7 -0
  29. package/dist/src/ref.js +1 -0
  30. package/dist/src/references.js +6 -0
  31. package/dist/src/report.js +6 -1
  32. package/dist/src/reporters/sarif.js +3 -1
  33. package/dist/src/reporters/text.js +23 -3
  34. package/dist/src/roots.js +50 -10
  35. package/dist/src/version.js +1 -1
  36. package/dist/src/workflow-steps.js +82 -0
  37. package/jsr.json +1 -1
  38. package/package.json +8 -2
  39. package/src/analysis.ts +40 -4
  40. package/src/augmentations.ts +72 -0
  41. package/src/components.ts +15 -13
  42. package/src/conformance-record.ts +2 -2
  43. package/src/convention-rows.ts +25 -3
  44. package/src/conventions.ts +106 -2
  45. package/src/dependencies.ts +51 -31
  46. package/src/entry-points.ts +32 -19
  47. package/src/exempt.ts +4 -1
  48. package/src/findings/dependencies-and-module-machinery.ts +2 -2
  49. package/src/findings/emitters.ts +71 -12
  50. package/src/findings/interfaces.ts +7 -6
  51. package/src/findings/intra-function.ts +20 -2
  52. package/src/findings/unused-declarations.ts +16 -1
  53. package/src/findings-pass.ts +4 -1
  54. package/src/graph.ts +6 -6
  55. package/src/implementations.ts +1 -1
  56. package/src/indexed-access.ts +295 -0
  57. package/src/interface-satisfaction.ts +118 -27
  58. package/src/intra-function-parts.ts +196 -35
  59. package/src/inventory.ts +8 -0
  60. package/src/kinds.ts +14 -12
  61. package/src/matrix.ts +51 -8
  62. package/src/module-declarations.ts +241 -0
  63. package/src/outside-tests.ts +18 -16
  64. package/src/overrides.ts +80 -14
  65. package/src/query.ts +28 -0
  66. package/src/ref.ts +1 -0
  67. package/src/references.ts +6 -0
  68. package/src/report.ts +11 -1
  69. package/src/reporters/sarif.ts +4 -1
  70. package/src/reporters/text.ts +25 -3
  71. package/src/roots.ts +56 -7
  72. package/src/sweep.ts +12 -0
  73. package/src/version.ts +1 -1
  74. package/src/workflow-steps.ts +104 -0
  75. package/src/workspace-run.ts +3 -4
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The source files a CI workflow's `run:` steps name by their path from the target root.
3
+ * A workflow file is read as text, line by line, with no YAML parsed.
4
+ */
5
+ import { scriptTokens } from "./manifest.js";
6
+ import { joinPath, resolvePath } from "./paths.js";
7
+ const WORKFLOWS = ".github/workflows";
8
+ /** A line holding a run command: indentation and one sequence marker before `run:`. */
9
+ const RUN_LINE = /^(\s*(?:- )?)run:(.*)$/u;
10
+ const BLOCK_SCALAR = /^[|>][+-]?$/u;
11
+ const QUOTED = /^(["'])(.*)\1$/su;
12
+ /** A token holding one of these is computed or a pattern, so it names no file. */
13
+ const COMPUTED = /[${*?[]/u;
14
+ const INDENT = /^\s*/u;
15
+ function workflowFiles(host, dir) {
16
+ let entries;
17
+ try {
18
+ entries = host.readDirectory(dir);
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ return entries
24
+ .filter((entry) => !entry.directory && /\.ya?ml$/u.test(entry.name))
25
+ .map((entry) => joinPath(dir, entry.name))
26
+ .sort();
27
+ }
28
+ /** Every command line one workflow file's `run:` steps hold. */
29
+ export function runCommands(text) {
30
+ const lines = text.split(/\r?\n/u);
31
+ const found = [];
32
+ for (let at = 0; at < lines.length; at += 1) {
33
+ const match = RUN_LINE.exec(lines[at] ?? "");
34
+ if (match === null) {
35
+ continue;
36
+ }
37
+ const column = (match[1] ?? "").length;
38
+ const value = (match[2] ?? "").trim().replace(QUOTED, "$2");
39
+ if (!BLOCK_SCALAR.test(value)) {
40
+ found.push(value);
41
+ continue;
42
+ }
43
+ for (let next = at + 1; next < lines.length; next += 1) {
44
+ const line = lines[next] ?? "";
45
+ if (line.trim() !== "" && (INDENT.exec(line)?.[0].length ?? 0) <= column) {
46
+ break;
47
+ }
48
+ found.push(line);
49
+ }
50
+ }
51
+ return found;
52
+ }
53
+ /**
54
+ * The own files the `run:` steps of every workflow file in the target root's
55
+ * `.github/workflows` name: a token equal to a file's path below the target root, with or
56
+ * without a leading `./`, whatever directory the step runs in.
57
+ */
58
+ export function workflowEntries(host, targetRoot, files) {
59
+ const dir = joinPath(resolvePath(host.workingDirectory(), targetRoot), WORKFLOWS);
60
+ const found = [];
61
+ for (const path of workflowFiles(host, dir)) {
62
+ let text;
63
+ try {
64
+ text = host.readFile(path);
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ for (const command of runCommands(text)) {
70
+ for (const token of scriptTokens(command)) {
71
+ if (COMPUTED.test(token)) {
72
+ continue;
73
+ }
74
+ const file = files.byPath.get(token.startsWith("./") ? token.slice(2) : token);
75
+ if (file !== undefined) {
76
+ found.push({ file, source: token });
77
+ }
78
+ }
79
+ }
80
+ }
81
+ return found;
82
+ }
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "5.2.2",
3
+ "version": "6.0.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "5.2.2",
3
+ "version": "6.0.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
@@ -29,10 +29,16 @@
29
29
  "lint:fix": "eslint . --fix && prettier --write ."
30
30
  },
31
31
  "dependencies": {
32
- "@typescript/native": "npm:typescript@7.1.0-dev.20261003.1"
32
+ "@typescript/native": "npm:typescript@7.1.0-dev.20261006.1"
33
+ },
34
+ "overrides": {
35
+ "qs": "^6.15.2"
33
36
  },
34
37
  "devDependencies": {
35
38
  "@eslint/js": "10.0.1",
39
+ "@stryker-mutator/core": "10.0.0",
40
+ "@stryker-mutator/typescript-checker": "10.0.0",
41
+ "@stryker-mutator/vitest-runner": "10.0.0",
36
42
  "@types/node": "24.19.1",
37
43
  "eslint": "10.12.0",
38
44
  "fast-check": "4.10.2",
package/src/analysis.ts CHANGED
@@ -71,7 +71,7 @@ import { frameworkLifecycle } from "./framework-lifecycle.ts";
71
71
  import type { Host } from "./host.ts";
72
72
  import { implementations, mergeImplementations } from "./implementations.ts";
73
73
  import { injectionContainer } from "./injection-container.ts";
74
- import { interfaceSatisfaction } from "./interface-satisfaction.ts";
74
+ import { interfaceSatisfaction, settledSatisfaction } from "./interface-satisfaction.ts";
75
75
  import { intraFunctionFacts, projectParts, type ProjectParts } from "./intra-function-parts.ts";
76
76
  import { inventory, type Inventory, type InventorySymbol } from "./inventory.ts";
77
77
  import { packageEntries, readManifest } from "./manifest.ts";
@@ -224,6 +224,8 @@ interface ReadProjects<Answer> {
224
224
  readonly conventionsApplied: readonly AppliedConvention[];
225
225
  /** The dependencies the applied rows' short names use. */
226
226
  readonly conventionUses: readonly string[];
227
+ /** The directory of every workspace member, absolute. */
228
+ readonly memberDirs: readonly string[];
227
229
  /** The test files below the target root no project the run reads holds, parsed. */
228
230
  readonly outsideTests: readonly OutsideTest[];
229
231
  }
@@ -508,6 +510,7 @@ function readProjects<Answer>(
508
510
  componentsUnread: workspace?.componentsUnread ?? [],
509
511
  conventionsApplied: conventions.applied,
510
512
  conventionUses: conventions.uses,
513
+ memberDirs: workspace?.memberDirs ?? [],
511
514
  outsideTests,
512
515
  };
513
516
  }
@@ -694,6 +697,8 @@ interface ReadRun<Extra> {
694
697
  readonly conventionsApplied: readonly AppliedConvention[];
695
698
  /** The dependencies the applied rows' short names use. */
696
699
  readonly conventionUses: readonly string[];
700
+ /** The directory of every workspace member, absolute. */
701
+ readonly memberDirs: readonly string[];
697
702
  }
698
703
 
699
704
  /**
@@ -777,15 +782,20 @@ function readRun<Extra>(
777
782
  withOutside.flatMap((one) => one.projectRead.rooted.liveUnderReachability),
778
783
  withOutside.flatMap((one) => one.testFiles),
779
784
  );
780
- const answers = withOutside.map((one): SweptProject<Extra> => {
785
+ const settled = settledSatisfaction(
786
+ withOutside.map((one) => ({ evidence: one.evidence, held: one.projectRead.held })),
787
+ [...withOutside.flatMap((one) => one.references), ...read.consumers.references],
788
+ );
789
+ const answers = withOutside.map((one, index): SweptProject<Extra> => {
781
790
  const { projectRead } = one;
782
- const detected = heldRecords(one.evidence, {
791
+ const satisfied = settled[index];
792
+ const detected = heldRecords(satisfied?.evidence ?? one.evidence, {
783
793
  disabled,
784
794
  mode,
785
795
  testFiles: new Set([...one.testFiles, ...support]),
786
796
  });
787
797
  const exempt = detected.records;
788
- const made = supportReferences(one.references, support);
798
+ const made = supportReferences([...one.references, ...(satisfied?.references ?? [])], support);
789
799
  const held = new Set(projectRead.held.symbols.map((symbol) => symbol.position.path));
790
800
  const supportFiles = [...support].filter((path) => held.has(path)).sort();
791
801
  const configured: Configured = {
@@ -846,6 +856,7 @@ function readRun<Extra>(
846
856
  componentsUnread: read.componentsUnread,
847
857
  conventionsApplied: read.conventionsApplied,
848
858
  conventionUses: read.conventionUses,
859
+ memberDirs: read.memberDirs,
849
860
  };
850
861
  }
851
862
 
@@ -1070,6 +1081,30 @@ function readEmitterRun(
1070
1081
  : { ...run, unansweredHeld: [...new Set([...run.unansweredHeld, ...unread])] };
1071
1082
  }
1072
1083
 
1084
+ /**
1085
+ * The workspace members whose manifest the run reads whole enough to judge its
1086
+ * dependencies: a built project holds a file below the member's directory, and no derived
1087
+ * configuration at or below it was dropped. A file no built project holds may import any
1088
+ * dependency the member declares.
1089
+ */
1090
+ function membersWhollyRead(
1091
+ host: Host,
1092
+ targetRoot: string,
1093
+ read: ReadRun<EmitterExtra>,
1094
+ ): readonly string[] {
1095
+ const root = resolvePath(host.workingDirectory(), targetRoot);
1096
+ const held = new Set(read.matrix.union.symbols.map((symbol) => symbol.position.path));
1097
+ const dropped = read.notBuilt.map((one) => relativePath(root, one.configFile) ?? "");
1098
+ return read.memberDirs.filter((dir) => {
1099
+ const below = relativePath(root, dir);
1100
+ if (below === undefined) {
1101
+ return false;
1102
+ }
1103
+ const inside = (path: string): boolean => path.startsWith(`${below}/`);
1104
+ return [...held].some(inside) && !dropped.some(inside);
1105
+ });
1106
+ }
1107
+
1073
1108
  /**
1074
1109
  * The input every emitter reads over one sweep of a read run: the facts beside the sweep
1075
1110
  * each kind family reads, every per-project one read in the same session as the sweep.
@@ -1102,6 +1137,7 @@ function emitterInputOver(
1102
1137
  targetRoot,
1103
1138
  [...projects.map((one) => one.extra.needs), configurationNeeds(read.conventionUses)],
1104
1139
  swept.sweep.candidates.map((candidate) => candidate.id),
1140
+ membersWhollyRead(host, targetRoot, read),
1105
1141
  ),
1106
1142
  implementations: mergeImplementations(projects.map((one) => one.extra.implementations)),
1107
1143
  files: {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The references a use of an augmentation's declaration makes to what holds it. No name
3
+ * spells a `declare global` block, so a use of a declaration it holds is a use of the
4
+ * block. An interface or namespace an augmentation block holds merges with the one of its
5
+ * name, which the language or a library may type a value by where the target never names
6
+ * it, as it types `import.meta` or `process.env`, so a use of its member is a use of it.
7
+ */
8
+
9
+ import type { Inventory, InventorySymbol } from "./inventory.ts";
10
+ import type { Reference } from "./references.ts";
11
+
12
+ /** Whether one declaration is a `declare global` block: a module-level namespace named `global`. */
13
+ function isGlobalBlock(
14
+ symbol: InventorySymbol,
15
+ held: Inventory,
16
+ files: ReadonlySet<string>,
17
+ ): boolean {
18
+ return (
19
+ symbol.kind === "namespace" &&
20
+ files.has(symbol.parent) &&
21
+ symbol.ref.endsWith("#global") &&
22
+ !held.modules.has(symbol.id)
23
+ );
24
+ }
25
+
26
+ /**
27
+ * For each reference to a declaration an augmentation block holds, a read at the same place
28
+ * of every interface and namespace between it and the block, and of a `declare global` block
29
+ * itself.
30
+ */
31
+ export function augmentationReferences(
32
+ held: Inventory,
33
+ found: readonly Reference[],
34
+ ): readonly Reference[] {
35
+ const files = new Set(held.symbols.filter((one) => one.kind === "file").map((one) => one.id));
36
+ const globals = new Set(
37
+ held.symbols.filter((one) => isGlobalBlock(one, held, files)).map((one) => one.id),
38
+ );
39
+ if (globals.size === 0 && held.modules.size === 0) {
40
+ return [];
41
+ }
42
+ const byId = new Map(held.symbols.map((one) => [one.id, one]));
43
+ const holders = (id: string): string[] => {
44
+ const ids: string[] = [];
45
+ for (
46
+ let at = byId.get(byId.get(id)?.parent ?? "");
47
+ at !== undefined;
48
+ at = byId.get(at.parent)
49
+ ) {
50
+ if (globals.has(at.id)) {
51
+ return [...ids, at.id];
52
+ }
53
+ if (held.modules.has(at.id)) {
54
+ return ids;
55
+ }
56
+ if (at.kind === "interface" || at.kind === "namespace") {
57
+ ids.push(at.id);
58
+ }
59
+ }
60
+ return [];
61
+ };
62
+ return found.flatMap((reference) =>
63
+ holders(reference.to).map((to) => ({
64
+ from: reference.from,
65
+ to,
66
+ position: reference.position,
67
+ use: "read" as const,
68
+ resolution: reference.resolution,
69
+ test: reference.test,
70
+ })),
71
+ );
72
+ }
package/src/components.ts CHANGED
@@ -62,17 +62,18 @@ export function listing(component: Component, cascade: Cascade): Listing {
62
62
 
63
63
  /**
64
64
  * Groups the declarations `dead` marks into components, ordered by their first member's
65
- * site; `dead` and `testOfDeadCode` hold one flag per declaration of the graph. Root
66
- * members sit on the cycles no other cycle references. A cycle two roots both reach is
67
- * dead only through both, so a component is a weakly connected part of the dead
68
- * subgraph: no dead declaration of one references a declaration of another.
65
+ * site; `dead`, `testOfDeadCode` and `unreferencedTest` hold one flag per declaration of
66
+ * the graph. Root members sit on the cycles no other cycle references. A cycle two roots
67
+ * both reach is dead only through both, so a component is a weakly connected part of the
68
+ * dead subgraph: no dead declaration of one references a declaration of another.
69
69
  */
70
70
  export function componentsOf(
71
71
  graph: Graph,
72
72
  dead: readonly boolean[],
73
73
  testOfDeadCode: readonly boolean[],
74
+ unreferencedTest: readonly boolean[] = [],
74
75
  ): readonly Component[] {
75
- const { at, adjacent } = deadSubgraph(graph, dead, testOfDeadCode);
76
+ const { at, adjacent } = deadSubgraph(graph, dead, testOfDeadCode, unreferencedTest);
76
77
  if (at.length === 0) {
77
78
  return [];
78
79
  }
@@ -133,21 +134,22 @@ function linesSpanned(symbols: readonly (InventorySymbol | undefined)[]): number
133
134
 
134
135
  /**
135
136
  * The dead declarations by their place in the subgraph, a test file's declaration only
136
- * where it is a test of dead code, and the edges between them: each
137
- * reference one makes to another, an edge each way between a dead member and its dead
138
- * container (one direction alone leaves the member a component the container reaches),
139
- * and an edge back from each production target of an admitted test, which puts the test in
140
- * the component of the code it exercises because a subject never references its test.
137
+ * where it is a test of dead code or nothing the tests reach, and the edges between them:
138
+ * each reference one makes to another, except an unreferenced test-file declaration's
139
+ * reference to production code, which decides nothing of its target, an edge each way
140
+ * between a dead member and its dead container, and an edge back from each production
141
+ * target of an admitted test, which puts the test in its subject's component.
141
142
  */
142
143
  function deadSubgraph(
143
144
  graph: Graph,
144
145
  dead: readonly boolean[],
145
146
  testOfDeadCode: readonly boolean[],
147
+ unreferencedTest: readonly boolean[],
146
148
  ): { readonly at: readonly number[]; readonly adjacent: readonly (readonly number[])[] } {
147
149
  const at: number[] = [];
148
150
  const position = graph.symbols.map((_symbol, index) => {
149
- // A test file's declaration belongs to a component only as a test of dead code.
150
- if (dead[index] !== true || (graph.test[index] === true && testOfDeadCode[index] !== true)) {
151
+ const judged = testOfDeadCode[index] === true || unreferencedTest[index] === true;
152
+ if (dead[index] !== true || (graph.test[index] === true && !judged)) {
151
153
  return UNVISITED;
152
154
  }
153
155
  at.push(index);
@@ -157,7 +159,7 @@ function deadSubgraph(
157
159
  at.forEach((index, from) => {
158
160
  for (const edge of graph.out[index] ?? []) {
159
161
  const to = position[edge.to] ?? UNVISITED;
160
- if (to === UNVISITED) {
162
+ if (to === UNVISITED || (unreferencedTest[index] === true && graph.test[edge.to] !== true)) {
161
163
  continue;
162
164
  }
163
165
  adjacent[from]?.push(to);
@@ -2,9 +2,9 @@
2
2
  // Regenerate it with `UPDATE_GOLDEN=1 npx vitest --run src/conformance.corpus.test.ts`.
3
3
  export const RECORD = {
4
4
  conformance: {
5
- corpusVersion: "3.0.0",
5
+ corpusVersion: "4.0.0",
6
6
  result: "pass",
7
- digest: "sha256:db52e3164347cab70f45f438b4ac9ce89573be9303503daeb237a3c40361b001",
7
+ digest: "sha256:8250b61deb71467139bf4cbcaf10802cdabb1a2c2261a11a571fc1ff55bfc3dc",
8
8
  },
9
9
  gaps: [],
10
10
  } as const;
@@ -16,7 +16,7 @@ export interface OptionsCall {
16
16
  }
17
17
 
18
18
  /** One configuration property, and the configuration files it is read from. */
19
- interface MoveReading {
19
+ export interface MoveReading {
20
20
  /** The property's path through nested object literals, its keys joined by `.`. */
21
21
  readonly property: string;
22
22
  /** Globs naming the configuration files, matched in the manifest's directory alone. */
@@ -64,6 +64,13 @@ export interface ConventionRow {
64
64
  readonly moves: readonly DirectoryMove[];
65
65
  /** The properties that name a package the configuration uses by a short name. */
66
66
  readonly shortNames?: readonly ShortNameReading[];
67
+ /**
68
+ * The properties whose value only selects files to test, mutate, cover or ignore, so a
69
+ * string there names no file the tool runs or loads.
70
+ */
71
+ readonly selections?: readonly MoveReading[];
72
+ /** The members of a `package.json` the tool reads its configuration from. */
73
+ readonly manifestKeys?: readonly string[];
67
74
  }
68
75
 
69
76
  const SCRIPT = "{js,jsx,ts,tsx}";
@@ -99,6 +106,8 @@ const SOLIDSTART_PLUGIN = { module: "@solidjs/start/config", export: "solidStart
99
106
  const NUXT_CONFIG = [`nuxt.config.${ANY_MODULE}`];
100
107
 
101
108
  const STRYKER_CONFIG = "{stryker,.stryker}.{conf,config}.{json,js,mjs,cjs}";
109
+ const STRYKER_MODULE = "{stryker,.stryker}.{conf,config}.{js,mjs,cjs}";
110
+ const VITEST_CONFIG = `{vitest,vite}.config.${ANY_MODULE}`;
102
111
 
103
112
  /** Every row, ordered by name and then by range. */
104
113
  export const CONVENTION_ROWS: readonly ConventionRow[] = [
@@ -214,6 +223,14 @@ export const CONVENTION_ROWS: readonly ConventionRow[] = [
214
223
  },
215
224
  ],
216
225
  },
226
+ {
227
+ name: "prettier",
228
+ package: "prettier",
229
+ range: ">=3.0.0 <4.0.0",
230
+ entries: [`{prettier.config,.prettierrc}.${ANY_MODULE}`],
231
+ moves: [],
232
+ manifestKeys: ["prettier"],
233
+ },
217
234
  {
218
235
  name: "qwik-city",
219
236
  package: "@builder.io/qwik-city",
@@ -331,8 +348,9 @@ export const CONVENTION_ROWS: readonly ConventionRow[] = [
331
348
  name: "stryker",
332
349
  package: "@stryker-mutator/core",
333
350
  range: ">=7.0.0 <11.0.0",
334
- entries: [],
351
+ entries: [STRYKER_MODULE],
335
352
  moves: [],
353
+ selections: [{ property: "mutate", files: [STRYKER_CONFIG] }],
336
354
  shortNames: [
337
355
  {
338
356
  property: "testRunner",
@@ -476,9 +494,13 @@ export const CONVENTION_ROWS: readonly ConventionRow[] = [
476
494
  shortNames: [
477
495
  {
478
496
  property: "test.coverage.provider",
479
- files: [`{vitest,vite}.config.${ANY_MODULE}`],
497
+ files: [VITEST_CONFIG],
480
498
  package: "@vitest/coverage-{}",
481
499
  },
482
500
  ],
501
+ selections: [
502
+ { property: "test.coverage.include", files: [VITEST_CONFIG] },
503
+ { property: "test.coverage.exclude", files: [VITEST_CONFIG] },
504
+ ],
483
505
  },
484
506
  ];
@@ -50,9 +50,11 @@ import {
50
50
  CONVENTION_ROWS,
51
51
  type ConventionRow,
52
52
  type DirectoryMove,
53
+ type MoveReading,
53
54
  type OptionsCall,
54
55
  type ShortNameReading,
55
56
  } from "./convention-rows.ts";
57
+ import { documentStrings } from "./configuration-files.ts";
56
58
  import { globExpression } from "./glob.ts";
57
59
  import type { Host } from "./host.ts";
58
60
  import { dirnamePath, joinPath, normalizePath, relativePath, resolvePath } from "./paths.ts";
@@ -108,8 +110,18 @@ export interface Conventions {
108
110
  readonly globs: readonly ConventionGlob[];
109
111
  readonly generated: readonly GeneratedDirectory[];
110
112
  readonly failures: readonly ConventionFailure[];
111
- /** The dependencies the applied rows' short names use, by name, each once. */
113
+ /** The dependencies the applied rows' short names and manifest keys use, by name, each once. */
112
114
  readonly uses: readonly string[];
115
+ /** Per configuration file, absolute, the strings its applied rows' selection keys hold. */
116
+ readonly selected: ReadonlyMap<string, Selected>;
117
+ }
118
+
119
+ /** The strings one configuration file holds at selection keys, which root no file. */
120
+ export interface Selected {
121
+ /** Where each string starts in the file's text. */
122
+ readonly starts: ReadonlySet<number>;
123
+ /** Each string's text, once per occurrence. */
124
+ readonly texts: readonly string[];
113
125
  }
114
126
 
115
127
  /** One file a row roots. */
@@ -520,6 +532,86 @@ function readPath(
520
532
  return undefined;
521
533
  }
522
534
 
535
+ /**
536
+ * Every string literal one file writes at one property's path inside the objects it is
537
+ * read from, as {@link readProperty} reads them: the value itself, or each element of an
538
+ * array value, whatever else the path holds.
539
+ */
540
+ function stringsAt(file: SourceFile, property: string, call?: OptionsCall): readonly Node[] {
541
+ const declared = declaredValues(file);
542
+ const objects =
543
+ call === undefined
544
+ ? defaultExports(file).map((one) => objectOf(one, declared, true))
545
+ : optionsArguments(file, call).map((one) =>
546
+ one === undefined ? undefined : objectOf(one, declared, false),
547
+ );
548
+ const found: Node[] = [];
549
+ const visit = (object: Node, keys: readonly string[]): void => {
550
+ if (!isObjectLiteralExpression(object)) {
551
+ return;
552
+ }
553
+ const [key, ...rest] = keys;
554
+ for (const member of object.properties) {
555
+ if (!isPropertyAssignment(member) || keyOf(member) !== key) {
556
+ continue;
557
+ }
558
+ const held = unwrapped(member.initializer);
559
+ if (rest.length > 0) {
560
+ visit(held, rest);
561
+ } else if (literalText(held) !== undefined) {
562
+ found.push(held);
563
+ } else if (isArrayLiteralExpression(held)) {
564
+ found.push(...held.elements.map(unwrapped).filter((one) => literalText(one) !== undefined));
565
+ }
566
+ }
567
+ };
568
+ for (const object of objects) {
569
+ if (object !== undefined) {
570
+ visit(object, property.split("."));
571
+ }
572
+ }
573
+ return found;
574
+ }
575
+
576
+ /** The strings an applied row's selection keys hold, added per configuration file to `into`. */
577
+ function selectedStrings(
578
+ host: Host,
579
+ parse: ParseSource,
580
+ dir: string,
581
+ readings: readonly MoveReading[],
582
+ into: Map<string, { starts: Set<number>; texts: string[] }>,
583
+ ): void {
584
+ for (const reading of readings) {
585
+ for (const { name, file } of readingFiles(host, parse, dir, reading.files)) {
586
+ const path = joinPath(dir, name);
587
+ const held = into.get(path) ?? { starts: new Set<number>(), texts: [] };
588
+ for (const node of stringsAt(file, reading.property, reading.call)) {
589
+ held.starts.add(node.getStart());
590
+ held.texts.push(literalText(node) ?? "");
591
+ }
592
+ into.set(path, held);
593
+ }
594
+ }
595
+ }
596
+
597
+ /**
598
+ * The dependencies one manifest declares that a string at any depth of one of its members
599
+ * names, alone or followed by `/` and a subpath, a member's name aside.
600
+ */
601
+ function manifestKeyUses(manifest: unknown, keys: readonly string[]): readonly string[] {
602
+ if (!isRecord(manifest)) {
603
+ return [];
604
+ }
605
+ const declared = DEPENDENCY_SECTIONS.flatMap((section) => {
606
+ const held = manifest[section];
607
+ return isRecord(held) ? Object.keys(held) : [];
608
+ });
609
+ const strings = keys.flatMap((key) => documentStrings(manifest[key]));
610
+ return declared.filter((name) =>
611
+ strings.some((text) => text === name || text.startsWith(`${name}/`)),
612
+ );
613
+ }
614
+
523
615
  /** What one configuration file states about one property. */
524
616
  interface PropertyReading {
525
617
  /** The literal values the file sets the property to. */
@@ -740,6 +832,7 @@ export function readConventions(
740
832
  const generated: GeneratedDirectory[] = [];
741
833
  const failures: ConventionFailure[] = [];
742
834
  const uses = new Set<string>();
835
+ const selected = new Map<string, { starts: Set<number>; texts: string[] }>();
743
836
  for (const manifest of manifests) {
744
837
  const declared = readJSON(host, joinPath(manifest.dir, "package.json"));
745
838
  const versions = new Map<string, string | undefined>();
@@ -801,6 +894,10 @@ export function readConventions(
801
894
  for (const name of shortNameUses(host, parse, manifest.dir, declared, row.shortNames ?? [])) {
802
895
  uses.add(name);
803
896
  }
897
+ for (const name of manifestKeyUses(declared, row.manifestKeys ?? [])) {
898
+ uses.add(name);
899
+ }
900
+ selectedStrings(host, parse, manifest.dir, row.selections ?? [], selected);
804
901
  const excludes = [
805
902
  ...new Set((row.excludes ?? []).flatMap((one) => expand(one, resolved))),
806
903
  ].map(globExpression);
@@ -824,7 +921,14 @@ export function readConventions(
824
921
  compare(a.manifest, b.manifest) ||
825
922
  compare(JSON.stringify(a), JSON.stringify(b)),
826
923
  );
827
- return { applied: unique, globs, generated, failures, uses: [...uses].sort(compare) };
924
+ return {
925
+ applied: unique,
926
+ globs,
927
+ generated,
928
+ failures,
929
+ uses: [...uses].sort(compare),
930
+ selected,
931
+ };
828
932
  }
829
933
 
830
934
  /**