@cplieger/deadset-ts 7.2.0-dev.4 → 8.0.0-dev.2

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 (122) hide show
  1. package/dist/src/analysis.js +108 -10
  2. package/dist/src/augmentations.js +62 -18
  3. package/dist/src/automatic-types.js +189 -0
  4. package/dist/src/base-url.js +18 -0
  5. package/dist/src/components.js +7 -6
  6. package/dist/src/config.js +1 -0
  7. package/dist/src/configuration-files.js +50 -12
  8. package/dist/src/conformance-record.js +2 -2
  9. package/dist/src/convention-rows.js +98 -0
  10. package/dist/src/conventions.js +101 -5
  11. package/dist/src/decorator.js +21 -18
  12. package/dist/src/dependencies.js +183 -28
  13. package/dist/src/discover.js +132 -11
  14. package/dist/src/entry-point-gaps.js +7 -10
  15. package/dist/src/entry-points.js +112 -11
  16. package/dist/src/findings/completion.js +172 -40
  17. package/dist/src/findings/dependencies-and-module-machinery.js +71 -25
  18. package/dist/src/findings/emitters.js +1 -1
  19. package/dist/src/findings/intra-function.js +44 -13
  20. package/dist/src/findings/non-code-artifacts.js +66 -33
  21. package/dist/src/findings/unused-declarations.js +30 -5
  22. package/dist/src/findings/visibility-narrowing.js +29 -5
  23. package/dist/src/generated-file.js +11 -0
  24. package/dist/src/git-ignore.js +86 -0
  25. package/dist/src/graph.js +4 -1
  26. package/dist/src/import-bindings.js +97 -0
  27. package/dist/src/indexed-access.js +130 -28
  28. package/dist/src/interface-satisfaction.js +30 -12
  29. package/dist/src/intra-function-parts.js +297 -45
  30. package/dist/src/inventory.js +52 -8
  31. package/dist/src/kinds.js +13 -13
  32. package/dist/src/matrix.js +57 -10
  33. package/dist/src/mdx-entries.js +197 -0
  34. package/dist/src/module-declarations.js +9 -13
  35. package/dist/src/overrides.js +41 -10
  36. package/dist/src/print.js +1 -0
  37. package/dist/src/ref.js +10 -0
  38. package/dist/src/references.js +38 -4
  39. package/dist/src/report.js +4 -2
  40. package/dist/src/resolve.js +2 -0
  41. package/dist/src/roots.js +110 -38
  42. package/dist/src/schema.js +1 -0
  43. package/dist/src/serialization-contract.js +54 -8
  44. package/dist/src/session.js +7 -0
  45. package/dist/src/source-files.js +39 -14
  46. package/dist/src/suppress-file.js +10 -2
  47. package/dist/src/suppress-inline.js +18 -2
  48. package/dist/src/suppress.js +8 -5
  49. package/dist/src/sweep.js +27 -10
  50. package/dist/src/symbol-keys.js +103 -2
  51. package/dist/src/test-support.js +119 -18
  52. package/dist/src/type-errors.js +34 -7
  53. package/dist/src/type-parameter-reads.js +172 -0
  54. package/dist/src/verbs/analyze.js +16 -0
  55. package/dist/src/verbs/explain.js +4 -2
  56. package/dist/src/version.js +1 -1
  57. package/dist/src/workflow-steps.js +119 -22
  58. package/dist/src/workspace-programs.js +7 -3
  59. package/dist/src/workspace-resolution.js +53 -10
  60. package/jsr.json +1 -1
  61. package/package.json +2 -2
  62. package/src/analysis.ts +146 -11
  63. package/src/augmentations.ts +76 -22
  64. package/src/automatic-types.ts +246 -0
  65. package/src/base-url.ts +24 -0
  66. package/src/components.ts +7 -6
  67. package/src/config.ts +3 -0
  68. package/src/configuration-files.ts +66 -12
  69. package/src/conformance-record.ts +2 -2
  70. package/src/convention-rows.ts +128 -0
  71. package/src/conventions.ts +121 -7
  72. package/src/decorator.ts +28 -18
  73. package/src/dependencies.ts +247 -41
  74. package/src/discover.ts +156 -13
  75. package/src/entry-point-gaps.ts +8 -11
  76. package/src/entry-points.ts +133 -11
  77. package/src/finding.ts +15 -0
  78. package/src/findings/completion.ts +222 -46
  79. package/src/findings/dependencies-and-module-machinery.ts +90 -28
  80. package/src/findings/emitters.ts +1 -1
  81. package/src/findings/intra-function.ts +56 -14
  82. package/src/findings/non-code-artifacts.ts +81 -41
  83. package/src/findings/unused-declarations.ts +36 -5
  84. package/src/findings/visibility-narrowing.ts +40 -5
  85. package/src/generated-file.ts +16 -0
  86. package/src/git-ignore.ts +98 -0
  87. package/src/graph.ts +6 -0
  88. package/src/host.ts +5 -9
  89. package/src/import-bindings.ts +128 -0
  90. package/src/indexed-access.ts +152 -30
  91. package/src/interface-satisfaction.ts +33 -13
  92. package/src/intra-function-parts.ts +394 -59
  93. package/src/inventory.ts +71 -7
  94. package/src/kinds.ts +13 -13
  95. package/src/matrix.ts +104 -15
  96. package/src/mdx-entries.ts +241 -0
  97. package/src/module-declarations.ts +11 -13
  98. package/src/overrides.ts +51 -12
  99. package/src/print.ts +1 -0
  100. package/src/ref.ts +12 -0
  101. package/src/references.ts +48 -3
  102. package/src/report.ts +16 -3
  103. package/src/resolve.ts +3 -0
  104. package/src/roots.ts +163 -45
  105. package/src/schema.ts +1 -0
  106. package/src/serialization-contract.ts +55 -4
  107. package/src/session.ts +7 -0
  108. package/src/source-files.ts +66 -16
  109. package/src/suppress-file.ts +12 -1
  110. package/src/suppress-inline.ts +18 -1
  111. package/src/suppress.ts +10 -5
  112. package/src/sweep.ts +32 -11
  113. package/src/symbol-keys.ts +129 -2
  114. package/src/test-support.ts +155 -18
  115. package/src/type-errors.ts +38 -6
  116. package/src/type-parameter-reads.ts +227 -0
  117. package/src/verbs/analyze.ts +22 -0
  118. package/src/verbs/explain.ts +5 -2
  119. package/src/version.ts +1 -1
  120. package/src/workflow-steps.ts +127 -22
  121. package/src/workspace-programs.ts +7 -3
  122. package/src/workspace-resolution.ts +62 -10
@@ -1,6 +1,5 @@
1
1
  import type { Config } from "../config.ts";
2
2
  import type { Finding } from "../finding.ts";
3
- import type { Graph } from "../graph.ts";
4
3
  import type { Emitter, EmitterInput } from "./emitter.ts";
5
4
 
6
5
  const FILE_NEVER_BUILT = "DS1501";
@@ -61,43 +60,34 @@ function fileNeverBuilt({ config, swept, files }: EmitterInput): readonly Findin
61
60
  .map((file) => fileFinding(FILE_NEVER_BUILT, file, message));
62
61
  }
63
62
 
64
- /**
65
- * The paths of every file a reference written in another file reaches, naming the
66
- * file or a declaration in it, a loaded consumer's file included. An import is such a
67
- * reference, so an import from any file reaches what it names, whatever reaches the
68
- * importing file.
69
- */
70
- function reachedFromOutside(union: Graph): ReadonlySet<string> {
71
- const reached = new Set<string>();
72
- for (const edge of union.consumed) {
73
- const to = union.symbols[edge.to];
74
- if (to !== undefined) {
75
- reached.add(to.position.path);
76
- }
77
- }
78
- union.out.forEach((edges, from) => {
79
- const fromPath = union.symbols[from]?.position.path;
80
- for (const edge of edges) {
81
- const to = union.symbols[edge.to];
82
- if (to !== undefined && to.position.path !== fromPath) {
83
- reached.add(to.position.path);
84
- }
85
- }
86
- });
87
- return reached;
63
+ /** The files of the run, each with whether a reference written in another file reaches it. */
64
+ interface KeptFiles {
65
+ /** The files kept: reached from a live file, or named by a root, an edge or a program. */
66
+ readonly kept: ReadonlySet<string>;
67
+ /** The files a reference written in another file reaches, whatever holds that file. */
68
+ readonly referenced: ReadonlySet<string>;
88
69
  }
89
70
 
90
71
  /**
91
- * The paths of the files an import reaches, a root names, or a declared edge names a
92
- * declaration in. A file holding a declaration the sweep judged live is kept whatever
93
- * held it live, and so is a file a program holds by inclusion, which acts on every
94
- * file of the program without an import naming it.
72
+ * The paths of the files a reference from a live declaration of another file reaches, a
73
+ * loaded consumer's file included, a root names, or a declared edge names a declaration in,
74
+ * and of every file a reference written anywhere reaches. A file holding a declaration the
75
+ * sweep judged live is kept whatever held it live, and so is a file a program holds by
76
+ * inclusion, which acts on every file of the program without an import naming it. A
77
+ * reference from a file's top level counts once the file is kept.
95
78
  */
96
- function keptFiles({ swept, files, boundary }: EmitterInput): ReadonlySet<string> {
79
+ function keptFiles({ swept, files, boundary }: EmitterInput): KeptFiles {
97
80
  const union = swept.matrix.union;
98
81
  const edgeNamed = new Set(boundary.edges.map((side) => side.symbol));
99
82
  const dead = new Set(swept.sweep.candidates.map((candidate) => candidate.id));
100
- const kept = new Set([...reachedFromOutside(union), ...files.heldByInclusion]);
83
+ const kept = new Set(files.heldByInclusion);
84
+ const referenced = new Set<string>();
85
+ for (const edge of union.consumed) {
86
+ const to = union.symbols[edge.to];
87
+ if (to !== undefined) {
88
+ kept.add(to.position.path);
89
+ }
90
+ }
101
91
  for (const root of union.rooted) {
102
92
  const named = union.symbols[root.at];
103
93
  if (named !== undefined) {
@@ -109,21 +99,71 @@ function keptFiles({ swept, files, boundary }: EmitterInput): ReadonlySet<string
109
99
  kept.add(symbol.position.path);
110
100
  }
111
101
  }
112
- return kept;
102
+ const fromTopLevel = new Map<string, string[]>();
103
+ const pending: string[] = [];
104
+ const keep = (path: string): void => {
105
+ if (!kept.has(path)) {
106
+ kept.add(path);
107
+ pending.push(path);
108
+ }
109
+ };
110
+ union.out.forEach((edges, from) => {
111
+ const source = union.symbols[from];
112
+ if (source === undefined) {
113
+ return;
114
+ }
115
+ for (const edge of edges) {
116
+ const to = union.symbols[edge.to]?.position.path;
117
+ if (to === undefined || to === source.position.path) {
118
+ continue;
119
+ }
120
+ referenced.add(to);
121
+ if (source.kind === FILE_SUBJECT) {
122
+ fromTopLevel.set(source.position.path, [
123
+ ...(fromTopLevel.get(source.position.path) ?? []),
124
+ to,
125
+ ]);
126
+ } else if (!dead.has(source.id)) {
127
+ pending.push(to);
128
+ }
129
+ }
130
+ });
131
+ pending.push(...kept);
132
+ for (let path = pending.pop(); path !== undefined; path = pending.pop()) {
133
+ kept.add(path);
134
+ for (const to of fromTopLevel.get(path) ?? []) {
135
+ keep(to);
136
+ }
137
+ fromTopLevel.delete(path);
138
+ }
139
+ return { kept, referenced };
113
140
  }
114
141
 
115
- /** Every file of the run that no import reaches and no root or edge names. */
142
+ /**
143
+ * Every file of the run that no reference from live code reaches and no root or edge
144
+ * names. A file only dead code imports is found dead under reachability alone.
145
+ */
116
146
  function fileNeverImported(input: EmitterInput): readonly Finding[] {
117
- const kept = keptFiles(input);
147
+ const { kept, referenced } = keptFiles(input);
118
148
  return input.swept.matrix.union.symbols
119
149
  .filter((symbol) => symbol.kind === FILE_SUBJECT && !kept.has(symbol.position.path))
120
- .map((symbol) =>
121
- fileFinding(
122
- FILE_NEVER_IMPORTED,
123
- { path: symbol.position.path, ref: symbol.ref, endLine: symbol.endLine },
124
- "no import reaches this file and no root names it or a declaration in it",
125
- ),
126
- );
150
+ .map((symbol) => {
151
+ const file = { path: symbol.position.path, ref: symbol.ref, endLine: symbol.endLine };
152
+ return referenced.has(symbol.position.path)
153
+ ? {
154
+ ...fileFinding(
155
+ FILE_NEVER_IMPORTED,
156
+ file,
157
+ "only code that is itself dead imports this file, and no root names it or a declaration in it",
158
+ ),
159
+ unreachableOnly: true as const,
160
+ }
161
+ : fileFinding(
162
+ FILE_NEVER_IMPORTED,
163
+ file,
164
+ "no import reaches this file and no root names it or a declaration in it",
165
+ );
166
+ });
127
167
  }
128
168
 
129
169
  /** The findings of the non-code-artifacts family, `DS1500` to `DS1599`. */
@@ -61,8 +61,9 @@ interface Judged {
61
61
  * The code one candidate is reported under, or none, each symbol once under the most
62
62
  * specific code: a test of dead code first; no other test-file declaration or enum
63
63
  * member nothing names; a test-support declaration test code references as test-only
64
- * use; then deprecation, a test reference, and the unreferenced codes, an unimportable
65
- * unused export being the unreachable kind's.
64
+ * use; then deprecation, a test reference, or only dead production references beside a
65
+ * test that stays, and the unreferenced codes, an unimportable unused export being the
66
+ * unreachable kind's.
66
67
  */
67
68
  function codeOf(judged: Judged): string | undefined {
68
69
  const { candidate, symbol } = judged;
@@ -85,7 +86,11 @@ function codeOf(judged: Judged): string | undefined {
85
86
  if (candidate.productionRefs === 0 && judged.deprecated) {
86
87
  return "DS1006";
87
88
  }
88
- if (candidate.productionRefs === 0 && candidate.testRefs > 0) {
89
+ if (
90
+ candidate.testRefs > 0 &&
91
+ (candidate.productionRefs === 0 ||
92
+ (candidate.relation === "reachability" && candidate.keptByTests))
93
+ ) {
89
94
  return "DS1004";
90
95
  }
91
96
  if (MEMBERS.has(symbol.kind)) {
@@ -132,7 +137,9 @@ function messageOf(code: string, judged: Judged): string {
132
137
  case "DS1006":
133
138
  return `deprecated ${word} has no production reference`;
134
139
  case "DS1004":
135
- return `${word} is referenced only from test files and never from production code`;
140
+ return candidate.productionRefs === 0
141
+ ? `${word} is referenced only from test files and never from production code`
142
+ : `${word} is referenced only from test files and from declarations that are themselves dead`;
136
143
  }
137
144
  const subject =
138
145
  code === "DS1003"
@@ -196,7 +203,31 @@ function brandMembers(input: EmitterInput): ReadonlySet<string> {
196
203
  const BRANDS: ReadonlySet<SymbolKind> = new Set(["type-member", "class-member"]);
197
204
 
198
205
  /** The findings of the unused-declarations family, `DS1000` to `DS1099`. */
199
- export const unusedDeclarations: Emitter = (input) => familyFindings(input, false);
206
+ export const unusedDeclarations: Emitter = (input) => [
207
+ ...familyFindings(input, false),
208
+ ...unusedImports(input),
209
+ ];
210
+
211
+ /**
212
+ * The import bindings their file never reads, under the unexported code. A generated
213
+ * file's are the generator's to remove unless the run includes generated files.
214
+ */
215
+ function unusedImports(input: EmitterInput): Finding[] {
216
+ const generated =
217
+ input.config.analysis.generatedFiles === "include" ? undefined : input.files.generated;
218
+ return input.intraFunction.imports.flatMap((binding) =>
219
+ binding.used || generated?.has(binding.position.path) === true
220
+ ? []
221
+ : [
222
+ {
223
+ code: "DS1002",
224
+ position: binding.position,
225
+ symbol: { ref: binding.ref, kind: "import", name: binding.name, sizeLines: 1 },
226
+ message: `import binding ${binding.name} is never read in its file`,
227
+ },
228
+ ],
229
+ );
230
+ }
200
231
 
201
232
  /**
202
233
  * The finding each member of a dead container would carry were its container live: what
@@ -4,6 +4,7 @@ import type { InventorySymbol, SymbolKind } from "../inventory.ts";
4
4
  import type { MatrixCandidate } from "../matrix.ts";
5
5
  import { declarationKey } from "../ref.ts";
6
6
  import type { RootKind } from "../roots.ts";
7
+ import { runnerCalls } from "../sweep.ts";
7
8
  import type { Emitter, EmitterInput } from "./emitter.ts";
8
9
  import { writeOnlyDeclarations } from "./reads-and-writes.ts";
9
10
 
@@ -51,8 +52,37 @@ function publishes(kinds: ReadonlySet<RootKind> | undefined): boolean {
51
52
  return [...(kinds ?? [])].some((kind) => PUBLISHING.has(kind));
52
53
  }
53
54
 
55
+ /**
56
+ * The root kinds that name a file without entering it by its exports: a configuration
57
+ * string or a script token runs the file, and no tool reads a name it exports.
58
+ */
59
+ const RUN_ONLY: ReadonlySet<RootKind> = new Set<RootKind>(["configuration-string", "script"]);
60
+
61
+ /** Whether a root makes its file an entry file, or its declaration an entry, which a tool may read by name. */
54
62
  function entersByName(kinds: ReadonlySet<RootKind> | undefined): boolean {
55
- return [...(kinds ?? [])].some((kind) => !PUBLISHING.has(kind));
63
+ return [...(kinds ?? [])].some((kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind));
64
+ }
65
+
66
+ /**
67
+ * The root kinds whose root of a file enters no export by name: a document's import roots
68
+ * each export it imports, so an export it does not import is judged as in any file.
69
+ */
70
+ const PER_NAME: ReadonlySet<RootKind> = new Set<RootKind>(["document-import"]);
71
+
72
+ /**
73
+ * Whether the roots of a declaration's file enter it by name: a test runner's root
74
+ * enters only an export the runner calls, so another export of a test file is judged.
75
+ */
76
+ function fileEntersByName(
77
+ kinds: ReadonlySet<RootKind> | undefined,
78
+ symbol: InventorySymbol,
79
+ ): boolean {
80
+ const entering = [...(kinds ?? [])].filter(
81
+ (kind) => !PUBLISHING.has(kind) && !RUN_ONLY.has(kind) && !PER_NAME.has(kind),
82
+ );
83
+ return (
84
+ entering.some((kind) => kind !== "test-runner") || (entering.length > 0 && runnerCalls(symbol))
85
+ );
56
86
  }
57
87
 
58
88
  /**
@@ -163,7 +193,7 @@ function narrowing(
163
193
  world.writeOnly.has(declarationKey(symbol.ref, symbol.position.path)) ||
164
194
  symbol.kind === "export-alias" ||
165
195
  entersByName(facts.rootedBy[at]) ||
166
- entersByName(facts.rootedBy[file]) ||
196
+ fileEntersByName(facts.rootedBy[file], symbol) ||
167
197
  (facts.named[at] ?? 0) === 0
168
198
  ) {
169
199
  return undefined;
@@ -198,7 +228,7 @@ function narrowing(
198
228
  * export no code outside the target can import, because the manifest declares
199
229
  * `exports` and no root reaches it. Each arm yields to a more specific kind, so one
200
230
  * declaration is reported once: a test whose every subject is dead, an interface, a
201
- * declaration only a test file references, and a deprecated one.
231
+ * declaration only a test file references or a test that stays keeps, and a deprecated one.
202
232
  */
203
233
  function unreachable(
204
234
  facts: Facts,
@@ -213,7 +243,7 @@ function unreachable(
213
243
  (facts.rootedBy[at]?.size ?? 0) > 0 ||
214
244
  candidate.testOfDeadCode ||
215
245
  symbol.kind === "interface" ||
216
- (candidate.productionRefs === 0 && candidate.testRefs > 0) ||
246
+ (candidate.testRefs > 0 && (candidate.productionRefs === 0 || candidate.keptByTests)) ||
217
247
  (candidate.productionRefs === 0 && input.deprecated.has(symbol.id))
218
248
  ) {
219
249
  return undefined;
@@ -253,8 +283,13 @@ export function narrowings(input: EmitterInput): readonly Finding[] {
253
283
  return;
254
284
  }
255
285
  const candidate = candidates.get(symbol.id);
286
+ // A declaration of test code, a test file's or test-support code's, that only test code
287
+ // references is a candidate, live in the run that counts test references.
288
+ const live =
289
+ candidate === undefined ||
290
+ (facts.graph.test[at] === true && !candidate.unreferencedTest && !candidate.testOfDeadCode);
256
291
  const found =
257
- candidate === undefined
292
+ candidate === undefined || live
258
293
  ? narrowing(facts, at, symbol, world)
259
294
  : unreachable(facts, at, symbol, candidate, input);
260
295
  if (found !== undefined) {
@@ -14,6 +14,15 @@ const HEADER_LINE = /^\/\/ Code generated .* DO NOT EDIT\.$/u;
14
14
  /** The documentation tag `@generated` as a word of a comment. */
15
15
  const GENERATED_TAG = /(?<![\w@$])@generated(?![\w$-])/u;
16
16
 
17
+ /** A first comment naming the tool that wrote the file. */
18
+ const GENERATED_BY = /^\/\/ Generated by \S/u;
19
+
20
+ /** The word a generator's notice holds, beside one of {@link KEEP_OUT}. */
21
+ const GENERATED_WORD = /\bgenerated\b/iu;
22
+
23
+ /** The phrases a generator's notice asks a reader not to edit the file by. */
24
+ const KEEP_OUT = /\b(?:do not edit|will be overwritten|should not make any changes)\b/iu;
25
+
17
26
  /** The comments before a text's first token, a leading hashbang line skipped. */
18
27
  function leadingComments(text: string): readonly string[] {
19
28
  const comments: string[] = [];
@@ -53,6 +62,13 @@ export function generatedByComment(text: string): string | undefined {
53
62
  if (comments.some((one) => GENERATED_TAG.test(one))) {
54
63
  return "generated by the @generated tag its leading comments hold";
55
64
  }
65
+ const notice = comments.join("\n");
66
+ if (
67
+ GENERATED_BY.test(comments[0] ?? "") ||
68
+ (GENERATED_WORD.test(notice) && KEEP_OUT.test(notice))
69
+ ) {
70
+ return "generated by the notice its leading comments hold";
71
+ }
56
72
  return undefined;
57
73
  }
58
74
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Which files the `.gitignore` files of a tree ignore, read as git reads them:
3
+ * https://git-scm.com/docs/gitignore#_pattern_format
4
+ */
5
+
6
+ import { globExpression } from "./glob.ts";
7
+ import type { Host } from "./host.ts";
8
+ import { dirnamePath, joinPath, relativePath } from "./paths.ts";
9
+
10
+ /** One pattern line of a `.gitignore` file. */
11
+ interface Rule {
12
+ readonly negated: boolean;
13
+ /** Whether the pattern ends with `/`, so it matches a directory alone. */
14
+ readonly directoryOnly: boolean;
15
+ /** The expression over a path below the file's own directory. */
16
+ readonly expression: RegExp;
17
+ }
18
+
19
+ /** The rules one `.gitignore` text writes, in order. */
20
+ function rulesOf(text: string): readonly Rule[] {
21
+ const rules: Rule[] = [];
22
+ for (const raw of text.split(/\r?\n/u)) {
23
+ let line = raw.replace(/(?<!\\)\s+$/u, "");
24
+ if (line === "" || line.startsWith("#")) {
25
+ continue;
26
+ }
27
+ const negated = line.startsWith("!");
28
+ line = negated || line.startsWith("\\") ? line.slice(1) : line;
29
+ const directoryOnly = line.endsWith("/");
30
+ line = line.replace(/\/+$/u, "");
31
+ if (line === "") {
32
+ continue;
33
+ }
34
+ // A separator at the start or in the middle anchors the pattern to the file's
35
+ // directory; with none, the pattern matches at any depth below it.
36
+ const anchored = line.includes("/");
37
+ const pattern = line.replace(/^\//u, "");
38
+ rules.push({
39
+ negated,
40
+ directoryOnly,
41
+ expression: globExpression(anchored ? pattern : `**/${pattern}`),
42
+ });
43
+ }
44
+ return rules;
45
+ }
46
+
47
+ /**
48
+ * Whether the `.gitignore` files at and below `root` ignore one absolute path. A path
49
+ * outside `root` is not ignored, and a file below an ignored directory stays ignored
50
+ * whatever a later pattern says, as git cannot re-include it.
51
+ */
52
+ export function gitIgnored(host: Host, root: string): (path: string) => boolean {
53
+ const read = new Map<string, readonly Rule[]>();
54
+ const rulesAt = (dir: string): readonly Rule[] => {
55
+ let rules = read.get(dir);
56
+ if (rules === undefined) {
57
+ try {
58
+ rules = rulesOf(host.readFile(joinPath(dir, ".gitignore")));
59
+ } catch {
60
+ rules = [];
61
+ }
62
+ read.set(dir, rules);
63
+ }
64
+ return rules;
65
+ };
66
+ /** Whether the rules of `root` and of each directory down to one path's own decide it ignored. */
67
+ const decided = (path: string, directory: boolean): boolean => {
68
+ const dirs: string[] = [];
69
+ for (let at = dirnamePath(path); relativePath(root, at) !== undefined; at = dirnamePath(at)) {
70
+ dirs.unshift(at);
71
+ }
72
+ dirs.unshift(root);
73
+ let ignored = false;
74
+ for (const dir of dirs) {
75
+ const below = relativePath(dir, path);
76
+ for (const rule of below === undefined ? [] : rulesAt(dir)) {
77
+ if ((directory || !rule.directoryOnly) && rule.expression.test(below ?? "")) {
78
+ ignored = !rule.negated;
79
+ }
80
+ }
81
+ }
82
+ return ignored;
83
+ };
84
+ return (path) => {
85
+ const below = relativePath(root, path);
86
+ if (below === undefined) {
87
+ return false;
88
+ }
89
+ const parts = below.split("/");
90
+ for (let count = 1; count <= parts.length; count += 1) {
91
+ const at = joinPath(root, parts.slice(0, count).join("/"));
92
+ if (decided(at, count < parts.length)) {
93
+ return true;
94
+ }
95
+ }
96
+ return false;
97
+ };
98
+ }
package/src/graph.ts CHANGED
@@ -63,6 +63,8 @@ const NAMES_A_CALLER: Readonly<Record<RootKind, boolean>> = {
63
63
  worker: true,
64
64
  "html-entry": true,
65
65
  "workflow-step": true,
66
+ "computed-import": true,
67
+ "document-import": true,
66
68
  };
67
69
 
68
70
  /** Whether the sweep judges a declaration of one kind. */
@@ -112,6 +114,8 @@ export interface Graph {
112
114
  readonly test: readonly boolean[];
113
115
  /** Per declaration, whether a test-support file holds it. */
114
116
  readonly support: readonly boolean[];
117
+ /** Per declaration, whether it is ambient, describing code the target does not implement. */
118
+ readonly ambient: readonly boolean[];
115
119
  /** Per declaration, whether the sweep judges its liveness. */
116
120
  readonly subject: readonly boolean[];
117
121
  /**
@@ -145,6 +149,7 @@ export function graphOf(
145
149
  roots: readonly Root[],
146
150
  testFiles: readonly string[],
147
151
  supportFiles: readonly string[] = [],
152
+ ambient: ReadonlySet<string> = new Set(),
148
153
  ): Graph {
149
154
  const supports = new Set(supportFiles);
150
155
  const index = new Map(symbols.map((symbol, at) => [symbol.id, at]));
@@ -199,6 +204,7 @@ export function graphOf(
199
204
  parent,
200
205
  test: symbols.map((symbol) => tests.has(symbol.position.path)),
201
206
  support: symbols.map((symbol) => supports.has(symbol.position.path)),
207
+ ambient: symbols.map((symbol) => ambient.has(symbol.id)),
202
208
  subject: symbols.map((symbol) => swept(symbol.kind)),
203
209
  exportsOf,
204
210
  rooted,
package/src/host.ts CHANGED
@@ -8,15 +8,11 @@ export interface DirectoryEntry {
8
8
  }
9
9
 
10
10
  /**
11
- * The platform one run reads: the filesystem, the directory relative paths
12
- * resolve against, and the version of the package this analyzer was installed
13
- * from.
14
- *
15
- * It is a parameter rather than an import because a published module that reads
16
- * the filesystem directly carries the whole platform's type surface into every
17
- * consumer's compile, and because the run's own reads are then the one thing a
18
- * caller can watch. The command line binds it to the platform it runs on; nothing
19
- * under this module does.
11
+ * The platform one run reads: the filesystem, the working directory and the analyzer's
12
+ * installed version. A run may keep what it reads for the host's life, so a caller whose
13
+ * tree changes passes a new host. It is a parameter, not an import, so a published module
14
+ * carries no platform type surface into a consumer's compile and a caller can watch the
15
+ * run's reads. The command line binds it to the platform; nothing under this module does.
20
16
  */
21
17
  export interface Host {
22
18
  /** The directory a relative path resolves against. */
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The import bindings of one file: the names its import declarations bind, which only
3
+ * the file itself can read.
4
+ */
5
+
6
+ import {
7
+ isExportSpecifier,
8
+ isExternalModuleReference,
9
+ isIdentifier,
10
+ isImportDeclaration,
11
+ isImportEqualsDeclaration,
12
+ isJsxElement,
13
+ isJsxFragment,
14
+ isJsxSelfClosingElement,
15
+ isNamespaceImport,
16
+ SyntaxKind,
17
+ type Identifier,
18
+ type Node,
19
+ type SourceFile,
20
+ } from "@typescript/native/ast";
21
+
22
+ /** The extensions of the files whose import bindings are judged: no markup can read them. */
23
+ const JUDGED = /\.(?:[mc]?ts|tsx|[mc]?js|jsx)$/u;
24
+
25
+ /** The compiler's `jsx` settings whose emit calls no factory the file must hold. */
26
+ const AUTOMATIC_RUNTIME: ReadonlySet<unknown> = new Set([4, 5, "react-jsx", "react-jsxdev"]);
27
+
28
+ /** A doc comment, where a JavaScript file names the types it imports. */
29
+ const DOC_COMMENT = /\/\*\*[\s\S]*?\*\//gu;
30
+
31
+ /** The parts of a doc comment the compiler resolves names in: a `{…}` type or link, a `@see` target. */
32
+ const DOC_REFERENCE = /\{[^{}]*\}|@see\s+[A-Za-z_$][\w$.]*/gu;
33
+
34
+ /** The factory a pragma comment names: `@jsx h` or `@jsxFrag Fragment`. */
35
+ const PRAGMA = /@jsx(?:Frag)?\s+([A-Za-z_$][\w$]*)/gu;
36
+
37
+ /** The compiler options the JSX rule reads. */
38
+ export interface JsxOptions {
39
+ readonly jsx?: unknown;
40
+ readonly jsxFactory?: unknown;
41
+ readonly jsxFragmentFactory?: unknown;
42
+ }
43
+
44
+ /**
45
+ * The names one file's import declarations bind: the default, named and namespace names of
46
+ * an `import` declaration, and the name of an `import x = require()` declaration that is
47
+ * not exported. None is judged in a declaration file or a component file.
48
+ */
49
+ export function importBindings(file: SourceFile): readonly Identifier[] {
50
+ if (file.isDeclarationFile || !JUDGED.test(file.fileName)) {
51
+ return [];
52
+ }
53
+ const found: Identifier[] = [];
54
+ for (const statement of file.statements) {
55
+ if (isImportDeclaration(statement)) {
56
+ const clause = statement.importClause;
57
+ if (clause?.name !== undefined) {
58
+ found.push(clause.name);
59
+ }
60
+ const named = clause?.namedBindings;
61
+ if (named !== undefined && isNamespaceImport(named)) {
62
+ found.push(named.name);
63
+ } else if (named !== undefined) {
64
+ for (const element of named.elements) {
65
+ found.push(element.name);
66
+ }
67
+ }
68
+ } else if (
69
+ isImportEqualsDeclaration(statement) &&
70
+ isExternalModuleReference(statement.moduleReference) &&
71
+ isIdentifier(statement.name) &&
72
+ !(statement.modifiers ?? []).some((modifier) => modifier.kind === SyntaxKind.ExportKeyword)
73
+ ) {
74
+ found.push(statement.name);
75
+ }
76
+ }
77
+ return found;
78
+ }
79
+
80
+ /** Whether one identifier names a binding in a local export, which reads it whatever it resolves to. */
81
+ export function exportsLocally(use: Identifier): boolean {
82
+ return isExportSpecifier(use.parent);
83
+ }
84
+
85
+ /**
86
+ * The names a file reads without an identifier the program resolves: each name a type, a
87
+ * link or a `@see` tag of its doc comments spells, and, where it writes JSX the compiler
88
+ * turns into factory calls, the factories the configuration or a pragma names.
89
+ */
90
+ export function implicitReads(file: SourceFile, options: JsxOptions): ReadonlySet<string> {
91
+ const names = new Set<string>();
92
+ for (const comment of file.text.match(DOC_COMMENT) ?? []) {
93
+ for (const reference of comment.match(DOC_REFERENCE) ?? []) {
94
+ for (const word of reference.match(/[A-Za-z_$][\w$]*/gu) ?? []) {
95
+ names.add(word);
96
+ }
97
+ }
98
+ }
99
+ if (AUTOMATIC_RUNTIME.has(options.jsx) || !writesJsx(file)) {
100
+ return names;
101
+ }
102
+ for (const factory of [options.jsxFactory, options.jsxFragmentFactory]) {
103
+ names.add(typeof factory === "string" ? (factory.split(".")[0] ?? factory) : "React");
104
+ }
105
+ for (const match of file.text.matchAll(PRAGMA)) {
106
+ if (match[1] !== undefined) {
107
+ names.add(match[1]);
108
+ }
109
+ }
110
+ return names;
111
+ }
112
+
113
+ /** Whether a file holds a JSX element or fragment. */
114
+ function writesJsx(file: SourceFile): boolean {
115
+ let found = false;
116
+ const visit = (node: Node): void => {
117
+ if (found) {
118
+ return;
119
+ }
120
+ if (isJsxElement(node) || isJsxSelfClosingElement(node) || isJsxFragment(node)) {
121
+ found = true;
122
+ return;
123
+ }
124
+ node.forEachChild(visit);
125
+ };
126
+ file.forEachChild(visit);
127
+ return found;
128
+ }