@cplieger/deadset-ts 5.1.1 → 5.2.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 (58) hide show
  1. package/dist/src/analysis.js +7 -4
  2. package/dist/src/config.js +3 -0
  3. package/dist/src/configuration-files.js +23 -24
  4. package/dist/src/conformance-record.js +2 -2
  5. package/dist/src/contextual-uses.js +25 -4
  6. package/dist/src/convention-rows.js +7 -0
  7. package/dist/src/conventions.js +97 -33
  8. package/dist/src/dependencies.js +8 -0
  9. package/dist/src/entry-point-gaps.js +10 -26
  10. package/dist/src/entry-points.js +4 -0
  11. package/dist/src/findings/completion.js +27 -11
  12. package/dist/src/findings/interfaces.js +2 -6
  13. package/dist/src/findings/intra-function.js +15 -3
  14. package/dist/src/findings/unused-declarations.js +5 -6
  15. package/dist/src/findings/visibility-narrowing.js +6 -1
  16. package/dist/src/generated-file.js +72 -12
  17. package/dist/src/graph.js +5 -4
  18. package/dist/src/html-entries.js +137 -0
  19. package/dist/src/kinds.js +2 -2
  20. package/dist/src/manifest.js +5 -1
  21. package/dist/src/matrix.js +9 -3
  22. package/dist/src/namespace-objects.js +158 -0
  23. package/dist/src/references.js +120 -3
  24. package/dist/src/roots.js +52 -3
  25. package/dist/src/sweep.js +17 -0
  26. package/dist/src/test-support.js +35 -23
  27. package/dist/src/version.js +1 -1
  28. package/jsr.json +1 -1
  29. package/package.json +1 -1
  30. package/src/analysis.ts +12 -5
  31. package/src/config.ts +3 -0
  32. package/src/configuration-files.ts +28 -24
  33. package/src/conformance-record.ts +2 -2
  34. package/src/contextual-uses.ts +31 -4
  35. package/src/convention-rows.ts +15 -0
  36. package/src/conventions.ts +119 -30
  37. package/src/dependencies.ts +9 -0
  38. package/src/entry-point-gaps.ts +11 -28
  39. package/src/entry-points.ts +7 -1
  40. package/src/exempt.ts +1 -4
  41. package/src/file-facts.ts +1 -1
  42. package/src/findings/completion.ts +31 -11
  43. package/src/findings/interfaces.ts +2 -9
  44. package/src/findings/intra-function.ts +17 -3
  45. package/src/findings/unused-declarations.ts +5 -8
  46. package/src/findings/visibility-narrowing.ts +7 -1
  47. package/src/generated-file.ts +80 -12
  48. package/src/graph.ts +5 -4
  49. package/src/html-entries.ts +170 -0
  50. package/src/kinds.ts +2 -2
  51. package/src/manifest.ts +9 -3
  52. package/src/matrix.ts +21 -3
  53. package/src/namespace-objects.ts +216 -0
  54. package/src/references.ts +161 -3
  55. package/src/roots.ts +69 -10
  56. package/src/sweep.ts +19 -0
  57. package/src/test-support.ts +34 -22
  58. package/src/version.ts +1 -1
@@ -1,6 +1,7 @@
1
1
  import type { Finding } from "../finding.ts";
2
2
  import { KINDS } from "../kinds.ts";
3
3
  import { UNUSED_PARAMETER, UNUSED_RESULT, type PartFact } from "../intra-function-parts.ts";
4
+ import { reachedWithTheTests } from "../sweep.ts";
4
5
  import type { Emitter, EmitterInput } from "./emitter.ts";
5
6
 
6
7
  /** A list of rules as a sentence names them. */
@@ -51,12 +52,25 @@ function reported(
51
52
  /**
52
53
  * The findings of the intra-function family, `DS1800` to `DS1899`, each about a part of
53
54
  * the declaration its reference names, at the part's own position, whatever the sweep
54
- * decided about that declaration. Each message names the other rules that report the
55
- * kind, as the vocabulary lists them, and so does the finding's details.
55
+ * decided about a production declaration. A part of test code is reported only where the
56
+ * run that counts test references reaches its declaration and that is no test of dead
57
+ * code. Each message and the finding's details name the other rules that report the kind.
56
58
  */
57
59
  export const intraFunction: Emitter = (input) => {
58
60
  const union = input.swept.matrix.union;
59
61
  const byId = new Map(union.symbols.map((symbol) => [symbol.id, symbol]));
62
+ const reached = reachedWithTheTests(union, [
63
+ ...input.swept.exempt.map((record) => record.id),
64
+ ...input.swept.heldByUnanswered,
65
+ ]);
66
+ const testsOfDeadCode = new Set(
67
+ input.swept.sweep.candidates.filter((one) => one.testOfDeadCode).map((one) => one.id),
68
+ );
69
+ const judged = (id: string): boolean => {
70
+ const at = union.at(id);
71
+ const testCode = union.test[at] === true || union.support[at] === true;
72
+ return !testCode || (reached[at] === true && !testsOfDeadCode.has(id));
73
+ };
60
74
  const unknown = new Set(
61
75
  union.rooted.flatMap((root) => {
62
76
  const symbol = union.symbols[root.at];
@@ -84,7 +98,7 @@ export const intraFunction: Emitter = (input) => {
84
98
  for (const part of input.intraFunction.parts) {
85
99
  const declaration = byId.get(part.declaration);
86
100
  const overlap = KINDS.get(part.code)?.overlap ?? [];
87
- if (declaration === undefined || !reported(input, part, unknown)) {
101
+ if (declaration === undefined || !judged(declaration.id) || !reported(input, part, unknown)) {
88
102
  continue;
89
103
  }
90
104
  found.push({
@@ -52,8 +52,6 @@ interface Judged {
52
52
  readonly symbol: InventorySymbol;
53
53
  /** Whether a test file declares it. */
54
54
  readonly test: boolean;
55
- /** Whether a test-support file declares it, which test code keeps live. */
56
- readonly support: boolean;
57
55
  /** Whether its container is itself dead, so it falls with the container. */
58
56
  readonly containerDead: boolean;
59
57
  readonly deprecated: boolean;
@@ -64,9 +62,9 @@ interface Judged {
64
62
  /**
65
63
  * The code one candidate is reported under, or none, each symbol once under the most
66
64
  * specific code: a test of dead code first; no other test-file declaration, member of a
67
- * dead container or enum member nothing names, nor a test-support declaration test code
68
- * references; then deprecation, a test reference, and the unreferenced codes, an
69
- * unimportable unused export being the unreachable kind's.
65
+ * dead container or enum member nothing names; a test-support declaration test code
66
+ * references as test-only use; then deprecation, a test reference, and the unreferenced
67
+ * codes, an unimportable unused export being the unreachable kind's.
70
68
  */
71
69
  function codeOf(judged: Judged): string | undefined {
72
70
  const { candidate, symbol } = judged;
@@ -80,8 +78,8 @@ function codeOf(judged: Judged): string | undefined {
80
78
  if (symbol.kind === "enum-member" && !referenced) {
81
79
  return undefined;
82
80
  }
83
- if (judged.support && candidate.testRefs > 0) {
84
- return undefined;
81
+ if (candidate.heldByTests === candidate.id) {
82
+ return "DS1004";
85
83
  }
86
84
  if (candidate.productionRefs === 0 && judged.deprecated) {
87
85
  return "DS1006";
@@ -220,7 +218,6 @@ function familyFindings(input: EmitterInput, fallen: boolean): Finding[] {
220
218
  candidate,
221
219
  symbol,
222
220
  test: union.test[at] === true,
223
- support: union.support[at] === true,
224
221
  containerDead: containerDead && !fallen,
225
222
  deprecated: input.deprecated.has(symbol.id),
226
223
  unreachable: unreachable.has(symbol.id),
@@ -232,7 +232,8 @@ function unreachable(
232
232
  * The visibility-narrowing family over one run: `DS1101`, `DS1103` and `DS1104`. A finding
233
233
  * about a symbol a declared cross-language edge names is reported here like any other, and
234
234
  * the edge's evaluation then holds it pending, because the edge stands for a reference from
235
- * outside the file and the package whose use is decided in the other language.
235
+ * outside the file and the package whose use is decided in the other language. A
236
+ * declaration of a generated file is reported only where generated files are included.
236
237
  */
237
238
  export function narrowings(input: EmitterInput): readonly Finding[] {
238
239
  const facts = factsOf(input.swept.matrix.union);
@@ -242,9 +243,14 @@ export function narrowings(input: EmitterInput): readonly Finding[] {
242
243
  const candidates = new Map(
243
244
  input.swept.sweep.candidates.map((candidate) => [candidate.id, candidate]),
244
245
  );
246
+ const generated =
247
+ input.config.analysis.generatedFiles === "include" ? undefined : input.files.generated;
245
248
 
246
249
  const findings: Finding[] = [];
247
250
  facts.graph.symbols.forEach((symbol, at) => {
251
+ if (generated?.has(symbol.position.path) === true) {
252
+ return;
253
+ }
248
254
  const candidate = candidates.get(symbol.id);
249
255
  const found =
250
256
  candidate === undefined
@@ -1,10 +1,84 @@
1
1
  /**
2
- * The generated-file class on TypeScript: every declaration in a file below a directory
3
- * an applied convention row names as generated is retained, because the framework owns
4
- * the file and a deletion there is undone at the next generation.
2
+ * The generated-file class on TypeScript: every declaration in a file that marks itself
3
+ * generated, or below a directory an applied convention row names as generated, is
4
+ * retained, because a generator owns the file and a deletion there is undone at the next
5
+ * generation.
5
6
  */
6
7
 
7
8
  import type { DetectorInput, Evidence } from "./exempt.ts";
9
+ import type { SourceFiles } from "./source-files.ts";
10
+
11
+ /** The generated-code header, matched against each line of a leading comment. */
12
+ const HEADER_LINE = /^\/\/ Code generated .* DO NOT EDIT\.$/u;
13
+
14
+ /** The documentation tag `@generated` as a word of a comment. */
15
+ const GENERATED_TAG = /(?<![\w@$])@generated(?![\w$-])/u;
16
+
17
+ /** The comments before a text's first token, a leading hashbang line skipped. */
18
+ function leadingComments(text: string): readonly string[] {
19
+ const comments: string[] = [];
20
+ let at = text.startsWith("\uFEFF") ? 1 : 0;
21
+ if (text.startsWith("#!", at)) {
22
+ const end = text.indexOf("\n", at);
23
+ at = end === -1 ? text.length : end + 1;
24
+ }
25
+ for (;;) {
26
+ while (at < text.length && /\s/u.test(text.charAt(at))) {
27
+ at++;
28
+ }
29
+ if (text.startsWith("//", at)) {
30
+ const end = text.slice(at).search(/[\r\n\u2028\u2029]/u);
31
+ const stop = end === -1 ? text.length : at + end;
32
+ comments.push(text.slice(at, stop));
33
+ at = stop;
34
+ } else if (text.startsWith("/*", at)) {
35
+ const end = text.indexOf("*/", at + 2);
36
+ if (end === -1) {
37
+ return comments;
38
+ }
39
+ comments.push(text.slice(at, end + 2));
40
+ at = end + 2;
41
+ } else {
42
+ return comments;
43
+ }
44
+ }
45
+ }
46
+
47
+ /** The detail a file's leading comments mark it generated by, or undefined where they mark nothing. */
48
+ export function generatedByComment(text: string): string | undefined {
49
+ const comments = leadingComments(text);
50
+ if (comments.some((one) => one.split(/\r?\n/u).some((line) => HEADER_LINE.test(line)))) {
51
+ return "generated by the header line its leading comments hold";
52
+ }
53
+ if (comments.some((one) => GENERATED_TAG.test(one))) {
54
+ return "generated by the @generated tag its leading comments hold";
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * Every own file of one project that is generated, by its path below the target root,
61
+ * with the detail a retention in it names: its leading comments' mark, else the row
62
+ * naming its directory as generated.
63
+ */
64
+ export function generatedFilesOf(
65
+ files: SourceFiles,
66
+ byRow: ReadonlyMap<string, string>,
67
+ ): ReadonlyMap<string, string> {
68
+ const found = new Map<string, string>();
69
+ for (const [path, file] of files.byPath) {
70
+ const row = byRow.get(path);
71
+ const detail =
72
+ generatedByComment(file.text) ??
73
+ (row === undefined
74
+ ? undefined
75
+ : `generated below a directory the convention row ${row} names`);
76
+ if (detail !== undefined) {
77
+ found.set(path, detail);
78
+ }
79
+ }
80
+ return found;
81
+ }
8
82
 
9
83
  /** The generated-file detector, recording each file's first line. */
10
84
  export function generatedFile<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
@@ -13,15 +87,9 @@ export function generatedFile<Brand>(input: DetectorInput<Brand>): readonly Evid
13
87
  return [];
14
88
  }
15
89
  return input.held.symbols.flatMap((symbol): Evidence[] => {
16
- const row = generated.get(symbol.position.path);
17
- return symbol.kind === "file" || row === undefined
90
+ const detail = generated.get(symbol.position.path);
91
+ return symbol.kind === "file" || detail === undefined
18
92
  ? []
19
- : [
20
- {
21
- id: symbol.id,
22
- detail: `generated below a directory the convention row ${row} names`,
23
- site: { path: symbol.position.path, line: 1, column: 1 },
24
- },
25
- ];
93
+ : [{ id: symbol.id, detail, site: { path: symbol.position.path, line: 1, column: 1 } }];
26
94
  });
27
95
  }
package/src/graph.ts CHANGED
@@ -40,10 +40,10 @@ const SWEPT: Readonly<Record<SymbolKind, boolean>> = {
40
40
  /**
41
41
  * Whether a root of each kind names a caller, which holds it live under both relations: a
42
42
  * tool reads the configuration it loads and the files its strings name, a runtime runs a
43
- * test file, a setup file, a worker, a command and a script, the manifest names what
44
- * enters the package, an alias global stands for the export it names, and an entry file,
45
- * a configured root and a pattern assert a caller. A library's published API only supposes
46
- * a consumer the run may not see, so it is live under reachability alone.
43
+ * test file, a setup file, a worker, a page's module script, a command and a script, the
44
+ * manifest names what enters the package, an alias global stands for the export it names,
45
+ * and an entry file, a configured root and a pattern assert a caller. A library's
46
+ * published API only supposes an unseen consumer, so it is live under reachability alone.
47
47
  */
48
48
  const NAMES_A_CALLER: Readonly<Record<RootKind, boolean>> = {
49
49
  "entry-file": true,
@@ -62,6 +62,7 @@ const NAMES_A_CALLER: Readonly<Record<RootKind, boolean>> = {
62
62
  "mutation-testing": true,
63
63
  "browser-tests": true,
64
64
  worker: true,
65
+ "html-entry": true,
65
66
  };
66
67
 
67
68
  /** Whether the sweep judges a declaration of one kind. */
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The files the module scripts of the target's HTML files load. An HTML file is read as
3
+ * text and no markup is parsed, so a `<script>` inside a comment is read as well.
4
+ */
5
+
6
+ import {
7
+ isExportDeclaration,
8
+ isImportDeclaration,
9
+ isStringLiteral,
10
+ type SourceFile,
11
+ } from "@typescript/native/unstable/ast";
12
+ import { resolveRelativeSpecifier, type ParseFile } from "./configuration-files.ts";
13
+ import type { Host } from "./host.ts";
14
+ import { dirnamePath, joinPath, relativePath, resolvePath } from "./paths.ts";
15
+ import type { SourceFiles } from "./source-files.ts";
16
+
17
+ /** One file a module script loads, and the value or specifier that named it. */
18
+ export interface HTMLEntry {
19
+ readonly file: SourceFile;
20
+ readonly source: string;
21
+ }
22
+
23
+ /** The directory no HTML file is read below. */
24
+ const SKIPPED = "node_modules";
25
+
26
+ /** A `<script` start tag, its attribute text captured. */
27
+ const SCRIPT_TAG = /<script(?=[\s>/])([^>]*)>/giu;
28
+
29
+ /** The end tag an inline module script's content runs to. */
30
+ const SCRIPT_END = /<\/script\s*>/iu;
31
+
32
+ /** One attribute: a name, then a double-quoted, single-quoted or unquoted value. */
33
+ const ATTRIBUTE = /([^\s"'=<>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/gu;
34
+
35
+ /** A value that names a resource on another origin, or by another scheme. */
36
+ const ELSEWHERE = /^(?:\/\/|[A-Za-z][A-Za-z\d+.-]*:)/u;
37
+
38
+ /** The prefixes an inline script's specifier names a file of the target with. */
39
+ const FILE_PREFIXES = ["/", "./", "../"] as const;
40
+
41
+ /** Every HTML file below `dir`, outside every `node_modules` directory, by absolute path. */
42
+ function htmlFiles(host: Host, dir: string): readonly string[] {
43
+ let entries;
44
+ try {
45
+ entries = host.readDirectory(dir);
46
+ } catch {
47
+ return [];
48
+ }
49
+ const found: string[] = [];
50
+ for (const entry of entries) {
51
+ const path = joinPath(dir, entry.name);
52
+ if (entry.directory) {
53
+ if (entry.name !== SKIPPED) {
54
+ found.push(...htmlFiles(host, path));
55
+ }
56
+ } else if (entry.name.endsWith(".html")) {
57
+ found.push(path);
58
+ }
59
+ }
60
+ return found.sort();
61
+ }
62
+
63
+ /** Each attribute of one start tag's attribute text, by its lowercase name. */
64
+ function attributes(text: string): ReadonlyMap<string, string> {
65
+ const found = new Map<string, string>();
66
+ for (const match of text.matchAll(ATTRIBUTE)) {
67
+ const [, name = "", double, single, bare] = match;
68
+ const key = name.toLowerCase();
69
+ if (!found.has(key)) {
70
+ found.set(key, double ?? single ?? bare ?? "");
71
+ }
72
+ }
73
+ return found;
74
+ }
75
+
76
+ /** The specifiers of the import and re-export declarations one inline script holds. */
77
+ function specifiersOf(parse: ParseFile, fileName: string, content: string): readonly string[] {
78
+ return parse(fileName, content).statements.flatMap((statement) => {
79
+ const specifier =
80
+ isImportDeclaration(statement) || isExportDeclaration(statement)
81
+ ? statement.moduleSpecifier
82
+ : undefined;
83
+ return specifier !== undefined && isStringLiteral(specifier) ? [specifier.text] : [];
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Every value a module script of one HTML file names a file by: a `src` attribute's
89
+ * value, or each specifier an inline script imports or re-exports that starts with `/`,
90
+ * `./` or `../`.
91
+ */
92
+ function scriptValues(parse: ParseFile | undefined, path: string, text: string): readonly string[] {
93
+ const values: string[] = [];
94
+ for (const match of text.matchAll(SCRIPT_TAG)) {
95
+ const held = attributes(match[1] ?? "");
96
+ if (held.get("type") !== "module") {
97
+ continue;
98
+ }
99
+ const src = held.get("src");
100
+ if (src !== undefined) {
101
+ values.push(src);
102
+ continue;
103
+ }
104
+ if (parse === undefined) {
105
+ continue;
106
+ }
107
+ const start = match.index + match[0].length;
108
+ const rest = text.slice(start);
109
+ const end = rest.search(SCRIPT_END);
110
+ const content = end === -1 ? rest : rest.slice(0, end);
111
+ values.push(
112
+ ...specifiersOf(parse, `${path}.${String(start)}.ts`, content).filter((specifier) =>
113
+ FILE_PREFIXES.some((prefix) => specifier.startsWith(prefix)),
114
+ ),
115
+ );
116
+ }
117
+ return values;
118
+ }
119
+
120
+ /** The directory of the nearest `package.json` at or above `dir`, inside `root`, else `root`. */
121
+ function manifestDirectory(host: Host, root: string, dir: string): string {
122
+ for (let at = dir; relativePath(root, at) !== undefined; at = dirnamePath(at)) {
123
+ if (host.kindOf(joinPath(at, "package.json")) === "file") {
124
+ return at;
125
+ }
126
+ if (at === root) {
127
+ break;
128
+ }
129
+ }
130
+ return root;
131
+ }
132
+
133
+ /**
134
+ * The own files the module scripts of every HTML file below the target root load. A value
135
+ * starting with a single `/` is read against the nearest manifest's directory, any other
136
+ * against the HTML file's own, and either resolves as a configuration file's relative
137
+ * specifier does. A value naming another origin or scheme, or no source file of the
138
+ * program, loads nothing here.
139
+ */
140
+ export function htmlEntries(
141
+ host: Host,
142
+ targetRoot: string,
143
+ files: SourceFiles,
144
+ parse: ParseFile | undefined,
145
+ ): readonly HTMLEntry[] {
146
+ const root = resolvePath(host.workingDirectory(), targetRoot);
147
+ const found: HTMLEntry[] = [];
148
+ for (const path of htmlFiles(host, root)) {
149
+ let text: string;
150
+ try {
151
+ text = host.readFile(path);
152
+ } catch {
153
+ continue;
154
+ }
155
+ const dir = dirnamePath(path);
156
+ for (const value of scriptValues(parse, path, text)) {
157
+ if (ELSEWHERE.test(value)) {
158
+ continue;
159
+ }
160
+ const reached = value.startsWith("/")
161
+ ? resolveRelativeSpecifier(host, manifestDirectory(host, root, dir), `.${value}`)
162
+ : resolveRelativeSpecifier(host, dir, value);
163
+ const file = reached === undefined ? undefined : files.byName.get(reached);
164
+ if (file !== undefined) {
165
+ found.push({ file, source: value });
166
+ }
167
+ }
168
+ }
169
+ return found;
170
+ }
package/src/kinds.ts CHANGED
@@ -71,7 +71,7 @@ const ROWS: readonly KindRow[] = [
71
71
  code: "DS1004",
72
72
  name: "test-only-use",
73
73
  languages: ["go", "ts"],
74
- rule: "A symbol with zero production references and at least one test reference: an unused-exported, unused-unexported or unused-member candidate whose test reference count is not zero, reported once under this code. A reference from a consumer's test files is a test reference unless the configuration counts consumer tests as production. A reference from test-support code, a package or file that only test code imports, is a test reference, and a declaration of test-support code is never reported under this code.",
74
+ rule: "A symbol with zero production references and at least one test reference: an unused-exported, unused-unexported or unused-member candidate whose test reference count is not zero, reported once under this code. A reference from a consumer's test files is a test reference unless the configuration counts consumer tests as production. A reference from test-support code, a package or file that only tests reach, is a test reference. A declaration of test-support code that test code references is reported under this code at the reachability class possible, as grammar/analysis.md states. Such a declaration that is an interface is reported under DS1201 instead.",
75
75
  defaultSeverity: "deny",
76
76
  maxClass: "certain",
77
77
  fixability: "deletable",
@@ -179,7 +179,7 @@ const ROWS: readonly KindRow[] = [
179
179
  code: "DS1301",
180
180
  name: "write-only-symbol",
181
181
  languages: ["go", "ts"],
182
- rule: "A package-level or module-level variable, struct field, class member, type member or collection that production code writes and never reads. In production mode a read from a test file counts as no read. A comparison of struct values reads every field of the type: in Go, a value of a struct type used as an operand of `==` or `!=`, as a map key, or as a `switch` tag or `case` expression is a read of every field of that type, transitively through the fields of every struct type it holds, recorded at the comparison, because equality reads every field and a field that decides equality carries information. In TypeScript a comparison of two object references reads no member, because `===` compares identity and never a member. In TypeScript a property of an object literal writes the member of the same name that the literal's contextual type declares, whether or not the compiler checks the literal for excess properties. Where that contextual type is a union or an intersection, the property writes the member of that name on each constituent that declares one. So a literal that a declaration annotates, a function returns, a call passes or a `satisfies` expression checks writes the members of the type it is given. A store through the value a field or a member holds reads that field or member rather than writing it, because the store lands in storage every other holder of the value reads. In Go that store is an assignment, an increment, a compound assignment or a `delete` whose target is reached through a struct field holding a map, a slice or a pointer. Examples are `s.f[k] = v`, `s.f.x = v` and `delete(s.f, k)`. The same store through a field holding an array or a struct is a store into the field itself. TypeScript holds every object by reference, so an element or property store through a member's value reads the member. The finding names each write position, so the deletion set is visible. Deleting the subject and its writes also deletes every declaration whose only uses they hold. Such a declaration is a type parameter of the subject's declaring type that the subject's type alone names, or an import binding the writes alone use. The finding's message names each such declaration.",
182
+ rule: "A package-level or module-level variable, struct field, class member, type member or collection that production code writes and never reads. In production mode a read from a test file counts as no read. A write from a loaded consumer's code counts as a read, because the deletion the finding names would break a consumer the target does not own. A write in a consumer's test file counts as a read in the run that counts test references. It counts in a production sweep only where the configuration counts consumer tests as production. A comparison of struct values reads every field of the type: in Go, a value of a struct type used as an operand of `==` or `!=`, as a map key, or as a `switch` tag or `case` expression is a read of every field of that type, transitively through the fields of every struct type it holds, recorded at the comparison, because equality reads every field and a field that decides equality carries information. In Go a type argument the program instantiates a type parameter with, where the parameter's constraint is `comparable` or embeds it, is compared as an operand of `==` of that type is, recorded at the instantiation, so the struct values `slices.Equal`, `slices.Index` or `maps.Equal` compares have every field read. `reflect.DeepEqual` is not a comparison under this rule. In TypeScript a comparison of two object references reads no member, because `===` compares identity and never a member. In TypeScript a property of an object literal writes the member of the same name that the literal's contextual type declares, whether or not the compiler checks the literal for excess properties. Where that contextual type is a union or an intersection, the property writes the member of that name on each constituent that declares one. So a literal that a declaration annotates, a function returns, a call passes or a `satisfies` expression checks writes the members of the type it is given. A store through the value a field or a member holds reads that field or member rather than writing it, because the store lands in storage every other holder of the value reads. In Go that store is an assignment, an increment, a compound assignment or a `delete` whose target is reached through a struct field holding a map, a slice or a pointer. Examples are `s.f[k] = v`, `s.f.x = v` and `delete(s.f, k)`. The same store through a field holding an array or a struct is a store into the field itself. TypeScript holds every object by reference, so an element or property store through a member's value reads the member. The finding names each write position, so the deletion set is visible. Deleting the subject and its writes also deletes every declaration whose only uses they hold. Such a declaration is a type parameter of the subject's declaring type that the subject's type alone names, an import binding the writes alone use, or a declaration whose every use is in the type annotation of the subject or of another subject this rule reports, or in the writes those findings name. The finding's message names each such declaration, and no finding is reported about the declaration itself.",
183
183
  defaultSeverity: "deny",
184
184
  maxClass: "certain",
185
185
  fixability: "deletable",
package/src/manifest.ts CHANGED
@@ -40,6 +40,8 @@ export interface ManifestEntry {
40
40
  * `exports` is the surface a library's published API is read from.
41
41
  */
42
42
  readonly published: boolean;
43
+ /** The directory of the manifest that names it, which `path` was resolved against. */
44
+ readonly dir: string;
43
45
  }
44
46
 
45
47
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -88,7 +90,7 @@ function below(member: string, key: string): string {
88
90
  * any depth; a `null` blocks a subpath. Every level is walked, because each
89
91
  * condition names its own entry file, and a key's meaning is never read.
90
92
  */
91
- function exportTargets(value: unknown, member: string, found: ManifestEntry[]): void {
93
+ function exportTargets(value: unknown, member: string, found: Omit<ManifestEntry, "dir">[]): void {
92
94
  if (typeof value === "string") {
93
95
  if (isTarget(value, true)) {
94
96
  found.push({ member, path: value, role: "import", published: true });
@@ -161,7 +163,7 @@ export function readManifest(host: Host, targetRoot: string): Manifest {
161
163
  return { entries: [], declaresExports: false };
162
164
  }
163
165
 
164
- const found: ManifestEntry[] = [];
166
+ const found: Omit<ManifestEntry, "dir">[] = [];
165
167
  for (const member of LEGACY_MEMBERS) {
166
168
  const value = manifest[member];
167
169
  if (typeof value === "string" && isTarget(value, false)) {
@@ -200,7 +202,11 @@ export function readManifest(host: Host, targetRoot: string): Manifest {
200
202
  }
201
203
 
202
204
  return {
203
- entries: found.map((entry) => ({ ...entry, path: resolvePath(targetRoot, entry.path) })),
205
+ entries: found.map((entry) => ({
206
+ ...entry,
207
+ path: resolvePath(targetRoot, entry.path),
208
+ dir: targetRoot,
209
+ })),
204
210
  declaresExports: manifest["exports"] !== undefined,
205
211
  };
206
212
  }
package/src/matrix.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { componentsOf, type Component } from "./components.ts";
8
- import { graphOf, type Graph } from "./graph.ts";
8
+ import { graphOf, OUTSIDE, type Graph } from "./graph.ts";
9
9
  import type { InventorySymbol } from "./inventory.ts";
10
10
  import { byPosition } from "./position.ts";
11
11
  import type { Reference } from "./references.ts";
@@ -55,6 +55,13 @@ export interface Matrix {
55
55
  export interface MatrixCandidate extends Candidate {
56
56
  /** The configurations the declaration is dead in, which are every one that holds it. */
57
57
  readonly configurations: readonly string[];
58
+ /**
59
+ * The declaration of test-support code that test code references, outside the
60
+ * test-of-dead-code rule, that is or contains this one, the outermost where two do.
61
+ * Such a declaration is not dead while test code references it, so neither it nor a
62
+ * declaration it contains is a member of a dead component.
63
+ */
64
+ readonly heldByTests: string | undefined;
58
65
  }
59
66
 
60
67
  /** One run's sweep. */
@@ -178,6 +185,7 @@ export function sweepMatrix(matrix: Matrix, input: SweepInput): SweepResult {
178
185
  const union = matrix.union;
179
186
  const dead = union.symbols.map(() => false);
180
187
  const testOfDeadCode = union.symbols.map(() => false);
188
+ const heldBy: (string | undefined)[] = union.symbols.map(() => undefined);
181
189
  const candidates: MatrixCandidate[] = [];
182
190
  union.symbols.forEach((symbol, at) => {
183
191
  const held = matrix.heldIn[at] ?? [];
@@ -187,24 +195,34 @@ export function sweepMatrix(matrix: Matrix, input: SweepInput): SweepResult {
187
195
  }
188
196
  const all = found.filter((candidate): candidate is Candidate => candidate !== undefined);
189
197
  const admitted = all.every((candidate) => candidate.testOfDeadCode);
198
+ const testRefs = union.made[at]?.test ?? 0;
199
+ const heldByTests =
200
+ heldBy[union.parent[at] ?? OUTSIDE] ??
201
+ (union.support[at] === true && testRefs > 0 && !admitted ? symbol.id : undefined);
190
202
  dead[at] = true;
191
203
  testOfDeadCode[at] = admitted;
204
+ heldBy[at] = heldByTests;
192
205
  candidates.push({
193
206
  id: symbol.id,
194
207
  productionRefs: union.made[at]?.production ?? 0,
195
- testRefs: union.made[at]?.test ?? 0,
208
+ testRefs,
196
209
  relation: all.every((candidate) => candidate.relation === "reference-counting")
197
210
  ? "reference-counting"
198
211
  : "reachability",
199
212
  testOfDeadCode: admitted,
200
213
  configurations: held,
214
+ heldByTests,
201
215
  });
202
216
  });
203
217
 
204
218
  return {
205
219
  liveUnder: liveAnywhere(union.symbols, per),
206
220
  candidates,
207
- components: componentsOf(union, dead, testOfDeadCode),
221
+ components: componentsOf(
222
+ union,
223
+ dead.map((one, at) => one && heldBy[at] === undefined),
224
+ testOfDeadCode,
225
+ ),
208
226
  };
209
227
  }
210
228