@cplieger/deadset-ts 5.0.0 → 5.1.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 (55) hide show
  1. package/dist/src/analysis.js +77 -10
  2. package/dist/src/configuration-files.js +107 -3
  3. package/dist/src/conformance-record.js +3 -9
  4. package/dist/src/contextual-uses.js +240 -0
  5. package/dist/src/convention-rows.js +6 -0
  6. package/dist/src/conventions.js +123 -14
  7. package/dist/src/dependencies.js +8 -2
  8. package/dist/src/destructuring.js +6 -6
  9. package/dist/src/entry-points.js +8 -5
  10. package/dist/src/exempt-classes.js +6 -1
  11. package/dist/src/exempt.js +21 -17
  12. package/dist/src/findings/completion.js +27 -3
  13. package/dist/src/findings/interfaces.js +6 -2
  14. package/dist/src/findings/reads-and-writes.js +53 -9
  15. package/dist/src/findings/unused-declarations.js +40 -1
  16. package/dist/src/generated-file.js +24 -0
  17. package/dist/src/interface-satisfaction.js +2 -68
  18. package/dist/src/inventory.js +4 -1
  19. package/dist/src/kinds.js +4 -3
  20. package/dist/src/query.js +15 -0
  21. package/dist/src/references.js +199 -10
  22. package/dist/src/resolve.js +12 -11
  23. package/dist/src/roots.js +1 -1
  24. package/dist/src/stores.js +3 -0
  25. package/dist/src/value-flow.js +74 -0
  26. package/dist/src/version.js +1 -1
  27. package/jsr.json +1 -1
  28. package/package.json +2 -2
  29. package/src/analysis.ts +92 -13
  30. package/src/configuration-files.ts +136 -1
  31. package/src/conformance-record.ts +3 -10
  32. package/src/contextual-uses.ts +354 -0
  33. package/src/convention-rows.ts +8 -0
  34. package/src/conventions.ts +161 -13
  35. package/src/dependencies.ts +14 -1
  36. package/src/destructuring.ts +16 -6
  37. package/src/entry-points.ts +16 -3
  38. package/src/exempt-classes.ts +6 -1
  39. package/src/exempt.ts +48 -17
  40. package/src/file-facts.ts +2 -0
  41. package/src/findings/completion.ts +31 -3
  42. package/src/findings/interfaces.ts +9 -2
  43. package/src/findings/reads-and-writes.ts +70 -9
  44. package/src/findings/unused-declarations.ts +51 -1
  45. package/src/generated-file.ts +27 -0
  46. package/src/interface-satisfaction.ts +1 -92
  47. package/src/inventory.ts +4 -1
  48. package/src/kinds.ts +5 -3
  49. package/src/query.ts +26 -0
  50. package/src/references.ts +254 -21
  51. package/src/resolve.ts +14 -13
  52. package/src/roots.ts +11 -1
  53. package/src/stores.ts +9 -0
  54. package/src/value-flow.ts +104 -0
  55. package/src/version.ts +1 -1
@@ -27,6 +27,7 @@ import {
27
27
  declaredIn as declaredBeside,
28
28
  dependenciesNamed,
29
29
  moduleStrings,
30
+ type ParseFile,
30
31
  } from "./configuration-files.ts";
31
32
  import type { FindingPosition } from "./finding.ts";
32
33
  import type { Host } from "./host.ts";
@@ -284,6 +285,7 @@ export function projectNeeds<Brand>(
284
285
  held: Inventory,
285
286
  host: Host,
286
287
  targetRoot: string,
288
+ parse?: ParseFile,
287
289
  ): ProjectNeeds {
288
290
  const programPackages = new Set<string>();
289
291
  for (const name of project.program.getSourceFileNames()) {
@@ -329,7 +331,12 @@ export function projectNeeds<Brand>(
329
331
  }
330
332
  return names;
331
333
  };
332
- const configuration = configurationFiles(host, targetRoot, sourceFilesOf(project, targetRoot));
334
+ const configuration = configurationFiles(
335
+ host,
336
+ targetRoot,
337
+ sourceFilesOf(project, targetRoot),
338
+ parse,
339
+ );
333
340
  for (const { file } of configuration.modules) {
334
341
  const names = declaredAt(file.fileName);
335
342
  for (const { node, text } of moduleStrings(file)) {
@@ -342,6 +349,12 @@ export function projectNeeds<Brand>(
342
349
  use([PROJECT_OWNER], dependenciesNamed(text, names));
343
350
  }
344
351
  }
352
+ for (const { file, manifestDir } of configuration.outside) {
353
+ const names = declaredAt(joinPath(manifestDir, "package.json"));
354
+ for (const { text } of moduleStrings(file)) {
355
+ use([PROJECT_OWNER], dependenciesNamed(text, names));
356
+ }
357
+ }
345
358
  return { packages, uses, unanswered };
346
359
  }
347
360
 
@@ -25,7 +25,13 @@ import {
25
25
  type Node,
26
26
  } from "@typescript/native/unstable/ast";
27
27
  import { TypeFlags, type Symbol as TSSymbol, type Type } from "@typescript/native/unstable/sync";
28
- import { isAnswered, UNANSWERED, type Answer } from "./query.ts";
28
+ import {
29
+ isAnswered,
30
+ propertyTables,
31
+ UNANSWERED,
32
+ type Answer,
33
+ type PropertyTables,
34
+ } from "./query.ts";
29
35
  import type { ProjectView } from "./session.ts";
30
36
 
31
37
  /** One property one object pattern reads. */
@@ -228,7 +234,11 @@ type Found = Answer<Type | undefined>;
228
234
  * read once per type for the whole project, since one type is destructured in many
229
235
  * places and a table answers every name of it.
230
236
  */
231
- export function destructuring<Brand>(project: ProjectView<Brand>, cap: number): Destructuring {
237
+ export function destructuring<Brand>(
238
+ project: ProjectView<Brand>,
239
+ cap: number,
240
+ properties: PropertyTables = propertyTables(project.queries),
241
+ ): Destructuring {
232
242
  const queries = project.queries;
233
243
  const tables = new Map<number, Answer<ReadonlyMap<string, TSSymbol>>>();
234
244
  let patternBatches = 0;
@@ -241,10 +251,10 @@ export function destructuring<Brand>(project: ProjectView<Brand>, cap: number):
241
251
  }
242
252
  let table: Answer<ReadonlyMap<string, TSSymbol>> = new Map<string, TSSymbol>();
243
253
  if ((type.flags & TypeFlags.AnyOrUnknown) === 0) {
244
- patternLookups += 1;
245
- const properties = queries.propertiesOf(type);
246
- table = isAnswered(properties)
247
- ? new Map(properties.map((property) => [property.name, property]))
254
+ const read = properties.of(type);
255
+ patternLookups += read.asked ? 1 : 0;
256
+ table = isAnswered(read.properties)
257
+ ? new Map(read.properties.map((property) => [property.name, property]))
248
258
  : UNANSWERED;
249
259
  }
250
260
  tables.set(type.id, table);
@@ -30,7 +30,12 @@ import {
30
30
  type SourceFile,
31
31
  } from "@typescript/native/unstable/ast";
32
32
  import { SymbolFlags } from "@typescript/native/unstable/sync";
33
- import { configurationFiles, filesNamedBy, moduleStrings } from "./configuration-files.ts";
33
+ import {
34
+ configurationFiles,
35
+ filesNamedBy,
36
+ moduleStrings,
37
+ type ParseFile,
38
+ } from "./configuration-files.ts";
34
39
  import { globExpression } from "./glob.ts";
35
40
  import type { Host } from "./host.ts";
36
41
  import { dirnamePath, relativePath, resolvePath } from "./paths.ts";
@@ -260,8 +265,9 @@ function configurationEntries(
260
265
  host: Host,
261
266
  targetRoot: string,
262
267
  files: SourceFiles,
268
+ parse: ParseFile | undefined,
263
269
  ): readonly EntryPoint[] {
264
- const { modules, documents } = configurationFiles(host, targetRoot, files);
270
+ const { modules, documents, outside } = configurationFiles(host, targetRoot, files, parse);
265
271
  const found: EntryPoint[] = [];
266
272
  const strings = (dir: string, texts: readonly string[]): void => {
267
273
  for (const text of texts) {
@@ -280,6 +286,12 @@ function configurationEntries(
280
286
  for (const document of documents) {
281
287
  strings(dirnamePath(document.path), document.strings);
282
288
  }
289
+ for (const { file } of outside) {
290
+ strings(
291
+ dirnamePath(file.fileName),
292
+ moduleStrings(file).map((one) => one.text),
293
+ );
294
+ }
283
295
  return found;
284
296
  }
285
297
 
@@ -507,8 +519,9 @@ export function entryPoints<Brand>(
507
519
  testFiles: readonly string[],
508
520
  host: Host,
509
521
  targetRoot: string,
522
+ parse?: ParseFile,
510
523
  ): readonly EntryPoint[] {
511
- const found: EntryPoint[] = [...configurationEntries(host, targetRoot, files)];
524
+ const found: EntryPoint[] = [...configurationEntries(host, targetRoot, files, parse)];
512
525
  const tests = testFiles.map((pattern) => ({ pattern, expression: globExpression(pattern) }));
513
526
  for (const [path, file] of files.byPath) {
514
527
  const name = basename(path);
@@ -49,7 +49,12 @@ export const EXEMPTION_CLASSES = [
49
49
  confidence: "certain",
50
50
  typescriptVisibility: { private: false, privateName: false },
51
51
  },
52
- { class: "generated-file", languages: ["go"], confidence: "certain" },
52
+ {
53
+ class: "generated-file",
54
+ languages: ["go", "ts"],
55
+ confidence: "certain",
56
+ typescriptVisibility: { private: true, privateName: false },
57
+ },
53
58
  { class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
54
59
  {
55
60
  class: "template-field",
package/src/exempt.ts CHANGED
@@ -48,6 +48,11 @@ export interface DetectorInput<Brand> {
48
48
  readonly ts: TSSection;
49
49
  /** The absolute path of each declared consumer, whose modules are part of the analysis. */
50
50
  readonly consumers: readonly string[];
51
+ /**
52
+ * The project's generated files, by path below the target root, each with the
53
+ * convention row that names its directory as generated.
54
+ */
55
+ readonly generated?: ReadonlyMap<string, string>;
51
56
  }
52
57
 
53
58
  /**
@@ -162,8 +167,29 @@ export function computeExemptions<Brand>(
162
167
  detectors: Detectors,
163
168
  holding: Holding,
164
169
  ): readonly Exemption[] {
170
+ return detectExemptions(input, detectors, holding).records;
171
+ }
172
+
173
+ /** One project's records, and the evidence a production mode found in a test file. */
174
+ export interface Detected {
175
+ readonly records: readonly Exemption[];
176
+ /**
177
+ * Under a production mode, each record whose evidence is written in a test file. It
178
+ * holds nothing, and it is a test reference to its declaration, as a reference the
179
+ * test file wrote would be.
180
+ */
181
+ readonly inTestFiles: readonly Exemption[];
182
+ }
183
+
184
+ /** The records of {@link computeExemptions}, beside the test-file evidence it set aside. */
185
+ export function detectExemptions<Brand>(
186
+ input: DetectorInput<Brand>,
187
+ detectors: Detectors,
188
+ holding: Holding,
189
+ ): Detected {
165
190
  const visibility = new Map(input.held.symbols.map((symbol) => [symbol.id, symbol.visibility]));
166
191
  const found: Exemption[] = [];
192
+ const inTestFiles: Exemption[] = [];
167
193
  for (const exemptionClass of TS_EXEMPTION_CLASSES) {
168
194
  const detect = detectors.get(exemptionClass);
169
195
  if (detect === undefined || holding.disabled.has(exemptionClass)) {
@@ -174,19 +200,21 @@ export function computeExemptions<Brand>(
174
200
  if (!retainable(allowed, visibility.get(evidence.id))) {
175
201
  continue;
176
202
  }
177
- if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
178
- continue;
179
- }
180
- found.push({
203
+ const record = {
181
204
  id: evidence.id,
182
205
  class: exemptionClass,
183
206
  detail: evidence.detail,
184
207
  site: evidence.site,
185
208
  ...(evidence.whileLive === undefined ? {} : { whileLive: evidence.whileLive }),
186
- });
209
+ };
210
+ if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
211
+ inTestFiles.push(record);
212
+ continue;
213
+ }
214
+ found.push(record);
187
215
  }
188
216
  }
189
- return exemptionsOf(found);
217
+ return { records: exemptionsOf(found), inTestFiles };
190
218
  }
191
219
 
192
220
  /** Two strings ordered bytewise. */
@@ -247,17 +275,18 @@ function byDeclaration(records: readonly Exemption[]): ReadonlyMap<string, reado
247
275
  }
248
276
 
249
277
  /**
250
- * The records that held back a declaration some configuration of the run would
251
- * otherwise judge dead: each configuration's graph is swept once more with no
252
- * exemption and the marks it was given, and a record is retained when one of those
253
- * sweeps judged its declaration a candidate. A record that holds a declaration back in
254
- * one configuration is retained whatever another configuration answers about it, as a
255
- * run of that configuration alone would report the declaration without it. A record
256
- * on a declaration that a relation or a mark holds live in every configuration held
257
- * nothing back. The order is the declarations' site order, and for one declaration
258
- * the input's.
278
+ * The records that held back a declaration: one some configuration's graph, swept
279
+ * again with no exemption and its marks, judges a candidate, whatever another
280
+ * configuration answers, or one in `writeOnly`, which the run would report as written
281
+ * and never read without the record's read. A declaration a relation or a mark holds
282
+ * live in every configuration is held back by no record. The order is the
283
+ * declarations' site order, and for one declaration the input's.
259
284
  */
260
- export function retainedIn(matrix: Matrix, input: SweepInput): readonly Exemption[] {
285
+ export function retainedIn(
286
+ matrix: Matrix,
287
+ input: SweepInput,
288
+ writeOnly: ReadonlySet<string> = new Set(),
289
+ ): readonly Exemption[] {
261
290
  const exempt = input.exempt ?? [];
262
291
  if (exempt.length === 0) {
263
292
  return [];
@@ -268,7 +297,9 @@ export function retainedIn(matrix: Matrix, input: SweepInput): readonly Exemptio
268
297
  sweep(graph, bare).candidates.map((candidate) => candidate.id),
269
298
  ),
270
299
  );
271
- const held = byDeclaration(exempt.filter((record) => dead.has(record.id)));
300
+ const held = byDeclaration(
301
+ exempt.filter((record) => dead.has(record.id) || writeOnly.has(record.id)),
302
+ );
272
303
  return matrix.union.symbols.flatMap((symbol) => held.get(symbol.id) ?? []);
273
304
  }
274
305
 
package/src/file-facts.ts CHANGED
@@ -47,6 +47,8 @@ export interface FileFacts {
47
47
  * reference names is included by the file that names it.
48
48
  */
49
49
  readonly heldByInclusion: ReadonlySet<string>;
50
+ /** The paths below the target root of the files below a directory an applied row names as generated. */
51
+ readonly generated?: ReadonlySet<string>;
50
52
  }
51
53
 
52
54
  /** The line of the last character of one text, counted from one; an empty text is one line. */
@@ -146,8 +146,9 @@ export function completed(
146
146
  const classOf = reachabilityClasses(input);
147
147
  const componentOf = findingComponents(swept, input.config.reporters.cascade);
148
148
  let minted = swept.sweep.components.length;
149
+ const dead = new Set(swept.sweep.components.map((component) => component.id));
149
150
 
150
- return findings.map((finding): CompletedFinding => {
151
+ const done = findings.map((finding): CompletedFinding => {
151
152
  const row = kinds.get(finding.code);
152
153
  if (row === undefined) {
153
154
  throw new Error(`${finding.code} names no live row of the issue-kind vocabulary`);
@@ -183,6 +184,7 @@ export function completed(
183
184
  ? union.made[at]
184
185
  : { production: candidate.productionRefs, test: candidate.testRefs };
185
186
  const reachabilityClass = judged === undefined ? "certain" : classOf(judged.id);
187
+ const generated = input.files.generated?.has(finding.position.path) === true;
186
188
  return {
187
189
  code: finding.code,
188
190
  kind: row.name,
@@ -193,7 +195,7 @@ export function completed(
193
195
  confidence: capped(reachabilityClass, row.maxClass),
194
196
  ...(candidate === undefined ? {} : { livenessRelation: candidate.relation }),
195
197
  testOnly: judged !== undefined && counts?.production === 0 && counts.test > 0,
196
- generated: false,
198
+ generated,
197
199
  component,
198
200
  retainedBy: [],
199
201
  configurations:
@@ -202,12 +204,38 @@ export function completed(
202
204
  ? swept.matrix.configurations
203
205
  : (swept.matrix.heldIn[at] ?? swept.matrix.configurations)),
204
206
  consumersLoaded: input.boundary.consumers.loaded,
205
- fixability: row.fixability,
207
+ fixability: generated ? "none" : row.fixability,
206
208
  severity: severityOf(input, finding.code, row.defaultSeverity),
207
209
  message: finding.message,
208
210
  details: finding.details ?? {},
209
211
  };
210
212
  });
213
+ return componentCapped(done, dead);
214
+ }
215
+
216
+ /**
217
+ * The findings with every finding of a dead component capped by the lowest confidence
218
+ * among the component's root members, so the minimum confidence reports or withholds a
219
+ * component whole.
220
+ */
221
+ function componentCapped(
222
+ findings: readonly CompletedFinding[],
223
+ dead: ReadonlySet<string>,
224
+ ): readonly CompletedFinding[] {
225
+ const lowest = new Map<string, Confidence>();
226
+ for (const finding of findings) {
227
+ const { id, root } = finding.component;
228
+ if (root && dead.has(id)) {
229
+ const held = lowest.get(id);
230
+ lowest.set(id, held === undefined ? finding.confidence : capped(held, finding.confidence));
231
+ }
232
+ }
233
+ return findings.map((finding) => {
234
+ const cap = lowest.get(finding.component.id);
235
+ return cap === undefined || RANK[cap] >= RANK[finding.confidence]
236
+ ? finding
237
+ : { ...finding, confidence: cap };
238
+ });
211
239
  }
212
240
 
213
241
  /** Whether the dials withhold a finding by its own severity or confidence. */
@@ -31,6 +31,7 @@ function compare(a: string, b: string): number {
31
31
  function indexed(input: EmitterInput): {
32
32
  readonly symbolOf: (id: string) => InventorySymbol | undefined;
33
33
  readonly inTest: (id: string) => boolean;
34
+ readonly inSupport: (id: string) => boolean;
34
35
  readonly candidateOf: (id: string) => Candidate | undefined;
35
36
  readonly implementationsOf: (id: string) => readonly PositionedSymbol[];
36
37
  } {
@@ -43,6 +44,7 @@ function indexed(input: EmitterInput): {
43
44
  return {
44
45
  symbolOf,
45
46
  inTest: (id) => union.test[union.at(id)] === true,
47
+ inSupport: (id) => union.support[union.at(id)] === true,
46
48
  candidateOf: (id) => candidates.get(id),
47
49
  implementationsOf: (id) =>
48
50
  [...(input.implementations.classes.get(id) ?? [])]
@@ -80,14 +82,19 @@ function findingOf(
80
82
  * interface, whose members fall with it unreported; and `DS1203`, a method of a live
81
83
  * interface no reference names, unless it is a marker, a method whose every
82
84
  * implementation has an empty body. A test file's declaration is neither kind's
83
- * subject, because a production sweep counts none of the references it can have.
85
+ * subject, because a production sweep counts none of the references it can have, and
86
+ * neither is a test-support declaration test code references, which that code keeps live.
84
87
  */
85
88
  export const interfaces: Emitter = (input) => {
86
89
  const run = indexed(input);
87
90
  const found: Finding[] = [];
88
91
  for (const candidate of input.swept.sweep.candidates) {
89
92
  const symbol = run.symbolOf(candidate.id);
90
- if (symbol === undefined || run.inTest(candidate.id)) {
93
+ if (
94
+ symbol === undefined ||
95
+ run.inTest(candidate.id) ||
96
+ (run.inSupport(candidate.id) && candidate.testRefs > 0)
97
+ ) {
91
98
  continue;
92
99
  }
93
100
  if (symbol.kind === "interface") {
@@ -62,23 +62,76 @@ function findingAbout(
62
62
  interface Uses {
63
63
  readonly writes: FindingPosition[];
64
64
  reads: number;
65
+ /** The import bindings only the writes use. */
66
+ readonly alone: Set<string>;
65
67
  }
66
68
 
67
69
  /**
68
70
  * Whether one declaration is a subject of the write-only kind: a module-level variable,
69
- * or a class member that is state rather than code. A getter or a setter is code, so a
70
- * store into one runs it.
71
+ * a member of an object type, or a class member that is state rather than code. A getter
72
+ * or a setter is code, so a store into one runs it.
71
73
  */
72
74
  function writable(run: Run, symbol: InventorySymbol): boolean {
73
- if (symbol.kind === "variable") {
75
+ if (symbol.kind === "variable" || symbol.kind === "type-member") {
74
76
  return true;
75
77
  }
76
78
  return symbol.kind === "class-member" && !run.input.stores.accessors.has(symbol.id);
77
79
  }
78
80
 
81
+ /** The reader's word for each kind of the write-only kind's subject. */
82
+ const SUBJECT_WORDS: Readonly<Partial<Record<InventorySymbol["kind"], string>>> = {
83
+ variable: "variable",
84
+ "class-member": "member",
85
+ "type-member": "type member",
86
+ };
87
+
88
+ /**
89
+ * The type parameters of one member's declaring type that only the member names, which
90
+ * deleting the member leaves unused.
91
+ */
92
+ function parametersOnlyNamedBy(
93
+ run: Run,
94
+ namedBy: ReadonlyMap<string, ReadonlySet<string>>,
95
+ member: InventorySymbol,
96
+ ): readonly string[] {
97
+ return [...run.symbols.values()]
98
+ .filter((symbol) => {
99
+ const from = namedBy.get(symbol.id);
100
+ return (
101
+ symbol.kind === "type-parameter" &&
102
+ symbol.parent === member.parent &&
103
+ from?.size === 1 &&
104
+ from.has(member.id)
105
+ );
106
+ })
107
+ .map((symbol) => symbol.name);
108
+ }
109
+
110
+ /** What one write-only finding says, the declarations its deletion takes with it included. */
111
+ function writeOnlyMessage(
112
+ run: Run,
113
+ namedBy: ReadonlyMap<string, ReadonlySet<string>>,
114
+ symbol: InventorySymbol,
115
+ held: Uses,
116
+ ): string {
117
+ const writes = held.writes.length === 1 ? "once" : `at ${String(held.writes.length)} positions`;
118
+ const read = run.input.stores.readInTests.has(symbol.id)
119
+ ? "read only from test files"
120
+ : "never read";
121
+ const cascade = [
122
+ ...parametersOnlyNamedBy(run, namedBy, symbol).map((name) => `type parameter ${name}`),
123
+ ...[...held.alone].sort().map((name) => `import ${name}`),
124
+ ];
125
+ const word = SUBJECT_WORDS[symbol.kind] ?? "member";
126
+ const message = `${word} ${symbol.name} is written ${writes} and ${read}`;
127
+ return cascade.length === 0
128
+ ? message
129
+ : `${message}, and deleting it with its writes deletes ${cascade.join(" and ")} too`;
130
+ }
131
+
79
132
  /**
80
- * `DS1301`: a module-level variable or a class member the counted references store into
81
- * and never read, named with every position written.
133
+ * `DS1301`: a module-level variable, a type member or a class member the counted
134
+ * references store into and never read, named with every position written.
82
135
  *
83
136
  * The subject is a live declaration: a store is a reference, so the sweep holds a
84
137
  * written one live and its candidates are never this kind's. Every reference that is
@@ -91,7 +144,7 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
91
144
  for (const reference of stores.references) {
92
145
  let held = uses.get(reference.to);
93
146
  if (held === undefined) {
94
- held = { writes: [], reads: 0 };
147
+ held = { writes: [], reads: 0, alone: new Set() };
95
148
  uses.set(reference.to, held);
96
149
  }
97
150
  // A consumer's store is written at a position no report of the target can name, so
@@ -100,6 +153,9 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
100
153
  held.reads += 1;
101
154
  continue;
102
155
  }
156
+ for (const name of reference.alone ?? []) {
157
+ held.alone.add(name);
158
+ }
103
159
  const at = reference.position;
104
160
  const key = positionKey(at);
105
161
  if (!held.writes.some((written) => positionKey(written) === key)) {
@@ -107,6 +163,13 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
107
163
  }
108
164
  }
109
165
 
166
+ const namedBy = new Map<string, Set<string>>();
167
+ for (const reference of stores.references) {
168
+ if (run.symbols.get(reference.to)?.kind === "type-parameter") {
169
+ namedBy.set(reference.to, (namedBy.get(reference.to) ?? new Set()).add(reference.from));
170
+ }
171
+ }
172
+
110
173
  const found: Finding[] = [];
111
174
  for (const symbol of matrix.union.symbols) {
112
175
  const held = uses.get(symbol.id);
@@ -120,10 +183,8 @@ function writeOnlySymbols(run: Run): readonly Finding[] {
120
183
  ) {
121
184
  continue;
122
185
  }
123
- const writes = held.writes.length === 1 ? "once" : `at ${String(held.writes.length)} positions`;
124
- const word = symbol.kind === "variable" ? "variable" : "member";
125
186
  found.push(
126
- findingAbout(symbol, "DS1301", `${word} ${symbol.name} is written ${writes} and never read`, {
187
+ findingAbout(symbol, "DS1301", writeOnlyMessage(run, namedBy, symbol, held), {
127
188
  writePositions: held.writes,
128
189
  }),
129
190
  );
@@ -142,6 +142,52 @@ function messageOf(code: string, judged: Judged): string {
142
142
  return `${subject} has no reference in the target`;
143
143
  }
144
144
 
145
+ /**
146
+ * The members nothing writes or reads that are brands: each names a type parameter of
147
+ * its declaring type that no other part of the declaration names, other than another
148
+ * such member. They are what make two instantiations of the type distinct types, so
149
+ * deleting them would let one instantiation stand for another.
150
+ */
151
+ function brandMembers(input: EmitterInput): ReadonlySet<string> {
152
+ const union = input.swept.matrix.union;
153
+ const unreferenced = new Map(
154
+ input.swept.sweep.candidates
155
+ .filter((candidate) => candidate.productionRefs + candidate.testRefs === 0)
156
+ .map((candidate) => [candidate.id, candidate]),
157
+ );
158
+ const namedBy = new Map<string, Set<string>>();
159
+ for (const reference of input.stores.references) {
160
+ const to = union.symbols[union.at(reference.to)];
161
+ if (to?.kind === "type-parameter") {
162
+ namedBy.set(to.id, (namedBy.get(to.id) ?? new Set()).add(reference.from));
163
+ }
164
+ }
165
+ const brands = new Set<string>();
166
+ for (const [parameter, from] of namedBy) {
167
+ const owner = union.symbols[union.at(parameter)]?.parent;
168
+ const members = [...from].map((id) => union.symbols[union.at(id)]);
169
+ if (
170
+ members.every(
171
+ (member) =>
172
+ member !== undefined &&
173
+ member.parent === owner &&
174
+ BRANDS.has(member.kind) &&
175
+ unreferenced.has(member.id),
176
+ )
177
+ ) {
178
+ for (const member of members) {
179
+ if (member !== undefined) {
180
+ brands.add(member.id);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ return brands;
186
+ }
187
+
188
+ /** The kinds of member a brand is: a data member of a type or of a class. */
189
+ const BRANDS: ReadonlySet<SymbolKind> = new Set(["type-member", "class-member"]);
190
+
145
191
  /** The findings of the unused-declarations family, `DS1000` to `DS1099`. */
146
192
  export const unusedDeclarations: Emitter = (input) => familyFindings(input, false);
147
193
 
@@ -157,6 +203,7 @@ function familyFindings(input: EmitterInput, fallen: boolean): Finding[] {
157
203
  const union = input.swept.matrix.union;
158
204
  const dead = new Set(input.swept.sweep.candidates.map((candidate) => candidate.id));
159
205
  const unreachable = unreachableExports(input);
206
+ let brands: ReadonlySet<string> | undefined;
160
207
 
161
208
  const found: Finding[] = [];
162
209
  for (const candidate of input.swept.sweep.candidates) {
@@ -179,7 +226,10 @@ function familyFindings(input: EmitterInput, fallen: boolean): Finding[] {
179
226
  unreachable: unreachable.has(symbol.id),
180
227
  };
181
228
  const code = codeOf(judged);
182
- if (code === undefined) {
229
+ if (
230
+ code === undefined ||
231
+ (code === "DS1003" && (brands ??= brandMembers(input)).has(symbol.id))
232
+ ) {
183
233
  continue;
184
234
  }
185
235
  const endLine = Math.max(symbol.position.line, symbol.endLine);
@@ -0,0 +1,27 @@
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.
5
+ */
6
+
7
+ import type { DetectorInput, Evidence } from "./exempt.ts";
8
+
9
+ /** The generated-file detector, recording each file's first line. */
10
+ export function generatedFile<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
11
+ const generated = input.generated;
12
+ if (generated === undefined || generated.size === 0) {
13
+ return [];
14
+ }
15
+ return input.held.symbols.flatMap((symbol): Evidence[] => {
16
+ const row = generated.get(symbol.position.path);
17
+ return symbol.kind === "file" || row === undefined
18
+ ? []
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
+ ];
26
+ });
27
+ }