@cplieger/deadset-ts 3.2.0 → 3.3.1

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/README.md +2 -2
  2. package/dist/src/analysis.js +14 -2
  3. package/dist/src/decorator.js +64 -0
  4. package/dist/src/exempt-classes.js +57 -10
  5. package/dist/src/exempt.js +23 -3
  6. package/dist/src/findings/dependencies-and-module-machinery.js +2 -0
  7. package/dist/src/findings/emitter.js +1 -0
  8. package/dist/src/findings/emitters.js +19 -0
  9. package/dist/src/findings/interfaces.js +2 -0
  10. package/dist/src/findings/intra-function.js +2 -0
  11. package/dist/src/findings/non-code-artifacts.js +2 -0
  12. package/dist/src/findings/reads-and-writes.js +2 -0
  13. package/dist/src/findings/self-check.js +2 -0
  14. package/dist/src/findings/unused-declarations.js +2 -0
  15. package/dist/src/findings/visibility-narrowing.js +2 -0
  16. package/dist/src/interface-satisfaction.js +310 -0
  17. package/dist/src/member-names.js +76 -0
  18. package/dist/src/references.js +22 -3
  19. package/dist/src/reflective-lookup.js +96 -0
  20. package/dist/src/run.js +60 -176
  21. package/dist/src/template-field.js +173 -0
  22. package/dist/src/verbs/print-config.js +9 -0
  23. package/dist/src/verbs/print-projects.js +34 -0
  24. package/dist/src/verbs/print-retained.js +23 -0
  25. package/dist/src/verbs/print-roots.js +36 -0
  26. package/dist/src/verbs/verb.js +5 -0
  27. package/dist/src/verbs/version.js +7 -0
  28. package/jsr.json +1 -1
  29. package/package.json +1 -1
  30. package/src/analysis.ts +24 -6
  31. package/src/decorator.ts +74 -0
  32. package/src/exempt-classes.ts +75 -12
  33. package/src/exempt.ts +29 -3
  34. package/src/findings/dependencies-and-module-machinery.ts +4 -0
  35. package/src/findings/emitter.ts +19 -0
  36. package/src/findings/emitters.ts +21 -0
  37. package/src/findings/interfaces.ts +4 -0
  38. package/src/findings/intra-function.ts +4 -0
  39. package/src/findings/non-code-artifacts.ts +4 -0
  40. package/src/findings/reads-and-writes.ts +4 -0
  41. package/src/findings/self-check.ts +4 -0
  42. package/src/findings/unused-declarations.ts +4 -0
  43. package/src/findings/visibility-narrowing.ts +4 -0
  44. package/src/interface-satisfaction.ts +417 -0
  45. package/src/member-names.ts +89 -0
  46. package/src/references.ts +41 -6
  47. package/src/reflective-lookup.ts +127 -0
  48. package/src/run.ts +64 -200
  49. package/src/template-field.ts +214 -0
  50. package/src/verbs/print-config.ts +10 -0
  51. package/src/verbs/print-projects.ts +35 -0
  52. package/src/verbs/print-retained.ts +24 -0
  53. package/src/verbs/print-roots.ts +38 -0
  54. package/src/verbs/verb.ts +38 -0
  55. package/src/verbs/version.ts +8 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The template-field class: a member is held back, at the lowest confidence, when a
3
+ * template under a configured template directory writes its name inside an action,
4
+ * because a template engine reads the member by that name at run time.
5
+ *
6
+ * Every file under a configured directory is a template. An action runs from an
7
+ * opening delimiter to the first closing one after it, at the pair
8
+ * `analysis.template_delimiters` sets, `{{` and `}}` by default, and each identifier
9
+ * it writes outside a quoted string is a name it reads: `{{ user.name }}` names `user`
10
+ * and `name`. A file holding an unclosed action names nothing. A name reaches the
11
+ * instance members of classes and the members of interfaces and object types, on any
12
+ * type, because nothing ties the name to a type.
13
+ */
14
+ import { ConfigError } from "./config.js";
15
+ import { memberNames } from "./member-names.js";
16
+ import { isAbsolutePath, joinPath, normalizePath } from "./paths.js";
17
+ /** The kinds of declaration an action can name. */
18
+ const READ_BY_A_TEMPLATE = new Set([
19
+ "method",
20
+ "class-member",
21
+ "interface-method",
22
+ "type-member",
23
+ ]);
24
+ /** One identifier, as the grammar of the analyzed language spells one. */
25
+ const IDENTIFIER = /[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*/uy;
26
+ /** Two strings ordered bytewise. */
27
+ function compare(a, b) {
28
+ if (a === b) {
29
+ return 0;
30
+ }
31
+ return a < b ? -1 : 1;
32
+ }
33
+ /**
34
+ * Every template under the configured directories, read once for the run, in path
35
+ * order and each file once. A directory is named relative to the target root, and
36
+ * one the target does not hold refuses the run as a usage error: the class would
37
+ * scan nothing, so a name that no longer matches the tree would silently switch the
38
+ * exemption it was configured for off. A symbolic link to a directory is not
39
+ * followed.
40
+ */
41
+ export function readTemplates(host, targetRoot, analysis) {
42
+ const files = new Map();
43
+ const read = (dir) => {
44
+ const entries = [...host.readDirectory(joinPath(targetRoot, dir))].sort((a, b) => compare(a.name, b.name));
45
+ for (const entry of entries) {
46
+ const path = joinPath(dir, entry.name);
47
+ if (entry.directory) {
48
+ read(path);
49
+ continue;
50
+ }
51
+ const absolute = joinPath(targetRoot, path);
52
+ if (!files.has(path) && host.kindOf(absolute) === "file") {
53
+ files.set(path, host.readFile(absolute));
54
+ }
55
+ }
56
+ };
57
+ for (const configured of analysis.templateDirs) {
58
+ const dir = normalizePath(configured);
59
+ if (isAbsolutePath(dir) ||
60
+ dir === ".." ||
61
+ dir.startsWith("../") ||
62
+ host.kindOf(joinPath(targetRoot, dir)) !== "directory") {
63
+ throw new ConfigError("malformed", "analysis.template_dirs", `analysis.template_dirs: ${JSON.stringify(configured)} is not a directory below the target root`);
64
+ }
65
+ read(dir);
66
+ }
67
+ return {
68
+ delimiters: analysis.templateDelimiters,
69
+ files: [...files]
70
+ .map(([path, text]) => ({ path, text }))
71
+ .sort((a, b) => compare(a.path, b.path)),
72
+ };
73
+ }
74
+ /** The end of the quoted string that opens at `from`, past its closing quote. */
75
+ function stringEnd(text, from) {
76
+ const quote = text[from];
77
+ let at = from + 1;
78
+ while (at < text.length && text[at] !== quote) {
79
+ at += text[at] === "\\" ? 2 : 1;
80
+ }
81
+ return at + 1;
82
+ }
83
+ /** The identifiers one action's inner text writes outside a quoted string, with their offsets in it. */
84
+ function identifiersOf(inner) {
85
+ const found = [];
86
+ let at = 0;
87
+ while (at < inner.length) {
88
+ const char = inner[at] ?? "";
89
+ if (char === '"' || char === "'" || char === "`") {
90
+ at = stringEnd(inner, at);
91
+ continue;
92
+ }
93
+ if (/[0-9]/u.test(char)) {
94
+ // A number's letters are part of the number: `1e3` names nothing.
95
+ while (at < inner.length && /[0-9A-Za-z_.]/u.test(inner[at] ?? "")) {
96
+ at += 1;
97
+ }
98
+ continue;
99
+ }
100
+ IDENTIFIER.lastIndex = at;
101
+ const match = IDENTIFIER.exec(inner);
102
+ if (match !== null) {
103
+ found.push({ name: match[0], offset: at });
104
+ at += match[0].length;
105
+ continue;
106
+ }
107
+ at += 1;
108
+ }
109
+ return found;
110
+ }
111
+ /**
112
+ * Every identifier one template's actions write, in the order the file writes them,
113
+ * or none where an action is not closed.
114
+ */
115
+ function namesOf(text, delimiters) {
116
+ const found = [];
117
+ let from = 0;
118
+ for (;;) {
119
+ const open = text.indexOf(delimiters.left, from);
120
+ if (open < 0) {
121
+ return found;
122
+ }
123
+ const start = open + delimiters.left.length;
124
+ const close = text.indexOf(delimiters.right, start);
125
+ if (close < 0) {
126
+ return [];
127
+ }
128
+ const inner = text.slice(start, close);
129
+ const action = `${delimiters.left}${inner.trim().replace(/\s+/gu, " ")}${delimiters.right}`;
130
+ for (const { name, offset } of identifiersOf(inner)) {
131
+ found.push({ name, offset: start + offset, action });
132
+ }
133
+ from = close + delimiters.right.length;
134
+ }
135
+ }
136
+ /** Where one offset of a template is, its column counted in UTF-16 code units. */
137
+ function positionIn(file, offset) {
138
+ const before = file.text.slice(0, offset);
139
+ const lineStart = before.lastIndexOf("\n") + 1;
140
+ return {
141
+ path: file.path,
142
+ line: before.split("\n").length,
143
+ column: offset - lineStart + 1,
144
+ };
145
+ }
146
+ /**
147
+ * The template-field detector: every instance member of a class and every member of
148
+ * an interface or an object type whose name an action of a configured template
149
+ * writes, recorded at the identifier.
150
+ */
151
+ export function templateField(input) {
152
+ const { templates } = input;
153
+ if (templates.files.length === 0) {
154
+ return [];
155
+ }
156
+ const members = memberNames(input.project.ownSourceFiles(), input.held);
157
+ const found = [];
158
+ for (const file of templates.files) {
159
+ for (const named of namesOf(file.text, templates.delimiters)) {
160
+ for (const member of members.get(named.name) ?? []) {
161
+ if (!READ_BY_A_TEMPLATE.has(member.kind) || member.static) {
162
+ continue;
163
+ }
164
+ found.push({
165
+ id: member.id,
166
+ detail: `named by ${named.action}`,
167
+ site: positionIn(file, named.offset),
168
+ });
169
+ }
170
+ }
171
+ }
172
+ return found;
173
+ }
@@ -0,0 +1,9 @@
1
+ import { printConfig } from "../print.js";
2
+ import { resolve } from "../resolve.js";
3
+ import { EXIT_CLEAN } from "./verb.js";
4
+ /** Writes the resolved configuration with the source of every setting. */
5
+ export const printConfigVerb = ({ out, inputs }) => {
6
+ const { config, provenance } = resolve(inputs());
7
+ out.write(printConfig(config, provenance));
8
+ return EXIT_CLEAN;
9
+ };
@@ -0,0 +1,34 @@
1
+ import { diagnosticErrors, discoverProjects, renderDiagnostic } from "../discover.js";
2
+ import { resolveMatrix } from "../resolve.js";
3
+ import { diagnosticsOf, runSession } from "../session.js";
4
+ import { EXIT_CLEAN, EXIT_FAILURE } from "./verb.js";
5
+ /**
6
+ * Writes the name of every project one run analyzes, one per line, having read each
7
+ * project's diagnostics first: the identifier the build matrix declares for it, or,
8
+ * where discovery derived the project, its configuration file's path below the
9
+ * target root.
10
+ */
11
+ export const printProjectsVerb = ({ out, err, host, inputs, scope, openClient }) => {
12
+ const matrix = resolveMatrix(inputs());
13
+ const scoped = scope();
14
+ const engine = openClient(false);
15
+ const discovered = discoverProjects(engine, host, scoped, matrix);
16
+ const ids = new Map(discovered.projects.map((project) => [project.configFile, project.id]));
17
+ const failures = [];
18
+ const { projects } = runSession(engine, discovered.configFiles, (project) => {
19
+ const errors = diagnosticErrors(diagnosticsOf(project));
20
+ failures.push(...errors.map(renderDiagnostic));
21
+ return ids.get(project.configFile) ?? project.configFile;
22
+ });
23
+ if (failures.length > 0) {
24
+ for (const line of failures) {
25
+ err.write(`${line}\n`);
26
+ }
27
+ err.write(`deadset-ts: ${String(failures.length)} error(s); no answer was produced\n`);
28
+ return EXIT_FAILURE;
29
+ }
30
+ for (const id of projects) {
31
+ out.write(`${id}\n`);
32
+ }
33
+ return EXIT_CLEAN;
34
+ };
@@ -0,0 +1,23 @@
1
+ import { runSweep } from "../analysis.js";
2
+ import { retainedLines } from "../exempt.js";
3
+ import { resolve } from "../resolve.js";
4
+ import { EXIT_CLEAN } from "./verb.js";
5
+ /**
6
+ * Writes every declaration an exemption held back in the production sweep a report is
7
+ * built from, one per line, and nothing where no exemption held one back. One fact
8
+ * several configurations found is one record, so the line names no configuration. The
9
+ * line shape is this command's own rather than a Contract format, so that sort and cut
10
+ * read it.
11
+ */
12
+ export const printRetainedVerb = ({ out, host, inputs, scope, openClient }) => {
13
+ const { config } = resolve(inputs());
14
+ const scoped = scope();
15
+ const swept = runSweep(openClient(false), host, scoped, config, {
16
+ marked: [],
17
+ mode: { production: true },
18
+ });
19
+ for (const line of retainedLines(swept.matrix.union.symbols, swept.retained)) {
20
+ out.write(`${line}\n`);
21
+ }
22
+ return EXIT_CLEAN;
23
+ };
@@ -0,0 +1,36 @@
1
+ import { runRoots } from "../analysis.js";
2
+ import { resolve } from "../resolve.js";
3
+ import { EXIT_CLEAN, EXIT_FINDINGS } from "./verb.js";
4
+ /**
5
+ * One root as a line: the declaration's reference, why it is a root, and the string
6
+ * that named it, separated by tabs. A run of several configurations adds the ones
7
+ * whose project holds the root as a fourth field, and writes the third present and
8
+ * empty where no string named the root, so every line of one run holds the same
9
+ * fields.
10
+ */
11
+ function rootLine(root, several) {
12
+ const line = `${root.ref}\t${root.kind}`;
13
+ if (several) {
14
+ return `${line}\t${root.source}\t${root.configurations.join(" ")}`;
15
+ }
16
+ return root.source === "" ? line : `${line}\t${root.source}`;
17
+ }
18
+ /**
19
+ * Writes the root set of the run, one root per line, and then names on the error
20
+ * stream, by its issue kind, every configured root or pattern that named nothing;
21
+ * one such string fails the run. The line shape is this command's own rather than a
22
+ * Contract format, so that sort and cut read it.
23
+ */
24
+ export const printRootsVerb = ({ out, err, host, inputs, scope, openClient }) => {
25
+ const { config, provenance } = resolve(inputs());
26
+ const scoped = scope();
27
+ const answer = runRoots(openClient(false), host, scoped, config, provenance);
28
+ const several = answer.configurations.length > 1;
29
+ for (const root of answer.roots) {
30
+ out.write(`${rootLine(root, several)}\n`);
31
+ }
32
+ for (const finding of answer.findings) {
33
+ err.write(`${finding.code}: roots.patterns names nothing: ${finding.symbol.ref}\n`);
34
+ }
35
+ return answer.findings.length > 0 ? EXIT_FINDINGS : EXIT_CLEAN;
36
+ };
@@ -0,0 +1,5 @@
1
+ /** The exit codes the Contract's exit-code table names. */
2
+ export const EXIT_CLEAN = 0;
3
+ export const EXIT_FINDINGS = 1;
4
+ export const EXIT_USAGE = 2;
5
+ export const EXIT_FAILURE = 3;
@@ -0,0 +1,7 @@
1
+ import { CONTRACT_VERSION } from "../version.js";
2
+ import { EXIT_CLEAN } from "./verb.js";
3
+ /** Writes the version the host reports and the Contract version this analyzer implements. */
4
+ export const versionVerb = ({ out, host }) => {
5
+ out.write(`deadset-ts ${host.analyzerVersion()}\ncontract ${CONTRACT_VERSION}\n`);
6
+ return EXIT_CLEAN;
7
+ };
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",
package/src/analysis.ts CHANGED
@@ -10,26 +10,32 @@
10
10
 
11
11
  import type { Diagnostic } from "@typescript/native/unstable/sync";
12
12
  import type { Config, Provenance } from "./config.ts";
13
+ import { decorator } from "./decorator.ts";
13
14
  import { diagnosticErrors, discoverProjects, DiscoveryError } from "./discover.ts";
14
15
  import {
15
16
  computeExemptions,
16
17
  disabledClasses,
17
18
  exemptionsOf,
18
19
  retainedIn,
20
+ type Detector,
19
21
  type Detectors,
20
22
  } from "./exempt.ts";
23
+ import type { TSExemptionClass } from "./exempt-classes.ts";
21
24
  import type { Finding } from "./finding.ts";
22
25
  import type { Host } from "./host.ts";
26
+ import { interfaceSatisfaction } from "./interface-satisfaction.ts";
23
27
  import { inventory, type Inventory } from "./inventory.ts";
24
28
  import { readManifest } from "./manifest.ts";
25
29
  import { matrixOf, sweepMatrix, type Configured, type Matrix, type SweepResult } from "./matrix.ts";
26
30
  import { relativePath, resolvePath } from "./paths.ts";
27
31
  import { byPosition, type Position } from "./position.ts";
32
+ import { reflectiveLookup } from "./reflective-lookup.ts";
28
33
  import { references } from "./references.ts";
29
34
  import { roots, unmatchedEverywhere, unmatchedRoots, type RootKind, type Roots } from "./roots.ts";
30
35
  import type { Scope } from "./scope.ts";
31
36
  import { diagnosticsOf, runSession, type Engine, type ProjectView } from "./session.ts";
32
37
  import type { Exemption, SweepInput } from "./sweep.ts";
38
+ import { readTemplates, templateField } from "./template-field.ts";
33
39
 
34
40
  /** The setting whose source decides where a finding about a configured root sits. */
35
41
  const ROOTS_SETTING = "roots.patterns";
@@ -211,7 +217,12 @@ export interface RunSweep {
211
217
  }
212
218
 
213
219
  /** The exemption classes this analyzer detects, each by its detector; any other retains nothing. */
214
- const DETECTORS: Detectors = new Map();
220
+ const DETECTORS: Detectors = new Map<TSExemptionClass, Detector>([
221
+ ["interface-satisfaction", interfaceSatisfaction],
222
+ ["template-field", templateField],
223
+ ["reflective-lookup", reflectiveLookup],
224
+ ["decorator", decorator],
225
+ ]);
215
226
 
216
227
  /**
217
228
  * Sweeps the run the scope and the configuration describe. Each project's references
@@ -233,6 +244,9 @@ export function runSweep(
233
244
  ): RunSweep {
234
245
  const targetRoot = scope.target.path;
235
246
  const disabled = disabledClasses(config);
247
+ const templates = disabled.has("template-field")
248
+ ? { delimiters: config.analysis.templateDelimiters, files: [] }
249
+ : readTemplates(host, targetRoot, config.analysis);
236
250
  const read = readProjects(engine, host, scope, config, (project, projectRead) => {
237
251
  const resolved = references(project, projectRead.held, targetRoot, {
238
252
  testFiles: config.ts.testFiles,
@@ -244,11 +258,15 @@ export function runSweep(
244
258
  roots: projectRead.rooted.liveUnderReachability,
245
259
  testFiles: resolved.testFilePaths,
246
260
  };
247
- const exempt = computeExemptions({ project, held: projectRead.held, targetRoot }, detectors, {
248
- disabled,
249
- mode: input.mode,
250
- testFiles: new Set(resolved.testFilePaths),
251
- });
261
+ const exempt = computeExemptions(
262
+ { project, held: projectRead.held, targetRoot, templates },
263
+ detectors,
264
+ {
265
+ disabled,
266
+ mode: input.mode,
267
+ testFiles: new Set(resolved.testFilePaths),
268
+ },
269
+ );
252
270
  return { configured, exempt };
253
271
  });
254
272
  const matrix = matrixOf(read.map((one) => one.configured));
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The decorator class: a member a decorator expression is attached to, and every
3
+ * member of a class a decorator expression is attached to, is held back, because the
4
+ * decorator receives the member or the class when the class is defined and may reach
5
+ * the member by its name.
6
+ *
7
+ * The rule is read from the tree alone: a decorator is attached where it is written.
8
+ * The use the decorator itself makes of what it decorates is the reference pass's,
9
+ * which records it as a `decorator` reference; this class records only that the
10
+ * declaration is held back, and the member set of a decorated class is every member
11
+ * the inventory holds for the class, static members included.
12
+ */
13
+
14
+ import {
15
+ isCallExpression,
16
+ isDecorator,
17
+ type Decorator,
18
+ type Node,
19
+ } from "@typescript/native/unstable/ast";
20
+ import type { DetectorInput, Evidence } from "./exempt.ts";
21
+ import { nodeKey, type InventorySymbol, type SymbolKind } from "./inventory.ts";
22
+ import { renderPosition } from "./position.ts";
23
+
24
+ /** The kinds of declaration a decorator is attached to and holds back. */
25
+ const DECORATED: ReadonlySet<SymbolKind> = new Set(["class", "method", "class-member"]);
26
+
27
+ /** How one decorator is spelled in a record: its expression, without a call's arguments. */
28
+ function spelled(decorator: Decorator): string {
29
+ const expression = decorator.expression;
30
+ const named = isCallExpression(expression) ? expression.expression : expression;
31
+ return `@${named.getText().replace(/\s+/gu, "")}`;
32
+ }
33
+
34
+ /**
35
+ * The decorator detector: each decorated member, and each member of a decorated
36
+ * class, recorded at the decorator.
37
+ */
38
+ export function decorator<Brand>(input: DetectorInput<Brand>): readonly Evidence[] {
39
+ const { project, held, targetRoot } = input;
40
+ const byId = new Map(held.symbols.map((symbol) => [symbol.id, symbol]));
41
+ const membersOf = new Map<string, InventorySymbol[]>();
42
+ for (const symbol of held.symbols) {
43
+ if (symbol.kind === "method" || symbol.kind === "class-member") {
44
+ const members = membersOf.get(symbol.parent);
45
+ if (members === undefined) {
46
+ membersOf.set(symbol.parent, [symbol]);
47
+ } else {
48
+ members.push(symbol);
49
+ }
50
+ }
51
+ }
52
+
53
+ const found: Evidence[] = [];
54
+ for (const file of project.ownSourceFiles()) {
55
+ const visit = (node: Node): void => {
56
+ const id = held.declarations.get(nodeKey(file, node));
57
+ const symbol = id === undefined ? undefined : byId.get(id);
58
+ if (symbol !== undefined && DECORATED.has(symbol.kind)) {
59
+ const modifiers = (node as { readonly modifiers?: readonly Node[] }).modifiers ?? [];
60
+ for (const attached of modifiers.filter(isDecorator)) {
61
+ const detail = `decorated by ${spelled(attached)}`;
62
+ const site = renderPosition(file, targetRoot, attached.getStart());
63
+ const reached = symbol.kind === "class" ? (membersOf.get(symbol.id) ?? []) : [symbol];
64
+ for (const member of reached) {
65
+ found.push({ id: member.id, detail, site });
66
+ }
67
+ }
68
+ }
69
+ node.forEachChild(visit);
70
+ };
71
+ file.forEachChild(visit);
72
+ }
73
+ return found;
74
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The exemption classes of the Contract's vocabulary, in its order: each class's name,
3
- * the languages it runs on and the confidence it is recorded at.
3
+ * the languages it runs on, the confidence it is recorded at and, for a class that runs
4
+ * on TypeScript, which private members it may retain.
4
5
  *
5
6
  * The rows are written out rather than read from the Contract at run time, for the
6
7
  * reason the fixed severity codes are, and a test holds them equal to the Contract
@@ -11,28 +12,81 @@
11
12
 
12
13
  import type { Confidence, Language } from "./config.ts";
13
14
 
15
+ /**
16
+ * Whether a class may retain a member declared with the `private` modifier, and one
17
+ * declared with a `#private` name. The modifier is a compile-time constraint only, so
18
+ * such a member stays reachable by its name at run time; a private name cannot be
19
+ * named from outside its class body at all.
20
+ */
21
+ export interface TypeScriptVisibility {
22
+ readonly private: boolean;
23
+ readonly privateName: boolean;
24
+ }
25
+
14
26
  /** One class of the vocabulary. */
15
27
  interface ExemptionClassRow {
16
28
  readonly class: string;
17
29
  readonly languages: readonly Language[];
18
30
  readonly confidence: Confidence;
31
+ /** Present exactly on a class that runs on TypeScript. */
32
+ readonly typescriptVisibility?: TypeScriptVisibility;
19
33
  }
20
34
 
21
35
  /** Every class of the vocabulary, in the order the Contract lists them. */
22
36
  export const EXEMPTION_CLASSES = [
23
- { class: "interface-satisfaction", languages: ["go", "ts"], confidence: "certain" },
37
+ {
38
+ class: "interface-satisfaction",
39
+ languages: ["go", "ts"],
40
+ confidence: "certain",
41
+ typescriptVisibility: { private: false, privateName: false },
42
+ },
24
43
  { class: "encoding-reflection", languages: ["go"], confidence: "certain" },
25
44
  { class: "format-verb-contract", languages: ["go"], confidence: "certain" },
26
45
  { class: "errors-duck-typing", languages: ["go"], confidence: "certain" },
27
- { class: "enum-group", languages: ["go", "ts"], confidence: "certain" },
46
+ {
47
+ class: "enum-group",
48
+ languages: ["go", "ts"],
49
+ confidence: "certain",
50
+ typescriptVisibility: { private: false, privateName: false },
51
+ },
28
52
  { class: "generated-file", languages: ["go"], confidence: "certain" },
29
53
  { class: "linkname-cgo-asm-plugin", languages: ["go"], confidence: "certain" },
30
- { class: "template-field", languages: ["go", "ts"], confidence: "possible" },
31
- { class: "reflective-lookup", languages: ["go", "ts"], confidence: "possible" },
32
- { class: "decorator", languages: ["ts"], confidence: "certain" },
33
- { class: "injection-container", languages: ["ts"], confidence: "certain" },
34
- { class: "framework-lifecycle", languages: ["ts"], confidence: "certain" },
35
- { class: "serialization-contract", languages: ["ts"], confidence: "certain" },
54
+ {
55
+ class: "template-field",
56
+ languages: ["go", "ts"],
57
+ confidence: "possible",
58
+ typescriptVisibility: { private: true, privateName: false },
59
+ },
60
+ {
61
+ class: "reflective-lookup",
62
+ languages: ["go", "ts"],
63
+ confidence: "possible",
64
+ typescriptVisibility: { private: true, privateName: false },
65
+ },
66
+ {
67
+ class: "decorator",
68
+ languages: ["ts"],
69
+ confidence: "certain",
70
+ typescriptVisibility: { private: true, privateName: false },
71
+ },
72
+ {
73
+ class: "injection-container",
74
+ languages: ["ts"],
75
+ confidence: "certain",
76
+ typescriptVisibility: { private: true, privateName: false },
77
+ },
78
+ {
79
+ class: "framework-lifecycle",
80
+ languages: ["ts"],
81
+ confidence: "certain",
82
+ typescriptVisibility: { private: true, privateName: false },
83
+ },
84
+ {
85
+ class: "serialization-contract",
86
+ languages: ["ts"],
87
+ confidence: "certain",
88
+ typescriptVisibility: { private: true, privateName: false },
89
+ },
36
90
  ] as const satisfies readonly ExemptionClassRow[];
37
91
 
38
92
  type Row = (typeof EXEMPTION_CLASSES)[number];
@@ -58,10 +112,19 @@ function runsOnTS(row: Row): row is Extract<Row, { readonly class: TSExemptionCl
58
112
  return languages.includes("ts");
59
113
  }
60
114
 
115
+ const TS_ROWS = EXEMPTION_CLASSES.filter(runsOnTS);
116
+
61
117
  /** The classes that run on TypeScript, in the vocabulary's order. */
62
- export const TS_EXEMPTION_CLASSES: readonly TSExemptionClass[] = EXEMPTION_CLASSES.filter(
63
- runsOnTS,
64
- ).map((row) => row.class);
118
+ export const TS_EXEMPTION_CLASSES: readonly TSExemptionClass[] = TS_ROWS.map((row) => row.class);
119
+
120
+ const VISIBILITY: ReadonlyMap<TSExemptionClass, TypeScriptVisibility> = new Map(
121
+ TS_ROWS.map((row) => [row.class, row.typescriptVisibility]),
122
+ );
123
+
124
+ /** Which private members one class that runs on TypeScript may retain. */
125
+ export function typescriptVisibilityOf(exemptionClass: TSExemptionClass): TypeScriptVisibility {
126
+ return VISIBILITY.get(exemptionClass) ?? { private: false, privateName: false };
127
+ }
65
128
 
66
129
  /** Whether one name is a class of the vocabulary, whichever language it runs on. */
67
130
  export function isExemptionClass(name: string): name is ExemptionClass {
package/src/exempt.ts CHANGED
@@ -13,13 +13,16 @@ import {
13
13
  EXEMPTION_CLASSES,
14
14
  isExemptionClass,
15
15
  TS_EXEMPTION_CLASSES,
16
+ typescriptVisibilityOf,
16
17
  type ExemptionClass,
17
18
  type TSExemptionClass,
19
+ type TypeScriptVisibility,
18
20
  } from "./exempt-classes.ts";
19
- import type { Inventory, InventorySymbol } from "./inventory.ts";
21
+ import type { Inventory, InventorySymbol, Visibility } from "./inventory.ts";
20
22
  import type { Matrix } from "./matrix.ts";
21
23
  import { byPosition, positionKey, type Position } from "./position.ts";
22
24
  import type { ProjectView } from "./session.ts";
25
+ import type { Templates } from "./template-field.ts";
23
26
  import { sweep, type Exemption, type Mode, type SweepInput } from "./sweep.ts";
24
27
 
25
28
  /** One declaration a detector holds back, and where and why. */
@@ -36,6 +39,8 @@ export interface DetectorInput<Brand> {
36
39
  readonly project: ProjectView<Brand>;
37
40
  readonly held: Inventory;
38
41
  readonly targetRoot: string;
42
+ /** The configured templates, read once for the run. */
43
+ readonly templates: Templates;
39
44
  }
40
45
 
41
46
  /**
@@ -77,23 +82,44 @@ export function disabledClasses(config: Config): ReadonlySet<ExemptionClass> {
77
82
  return disabled;
78
83
  }
79
84
 
85
+ /**
86
+ * Whether a class whose row states `allowed` may retain a member of one visibility. A
87
+ * declaration the inventory does not hold has no visibility to refuse it by.
88
+ */
89
+ function retainable(allowed: TypeScriptVisibility, visibility: Visibility | undefined): boolean {
90
+ if (visibility === "private") {
91
+ return allowed.private;
92
+ }
93
+ if (visibility === "private-name") {
94
+ return allowed.privateName;
95
+ }
96
+ return true;
97
+ }
98
+
80
99
  /**
81
100
  * The records one project's detectors found, each class in the vocabulary's order and
82
- * none of a switched-off class. Under a production mode a record whose evidence is
83
- * written in a test file holds nothing, as a reference a test file makes is none there.
101
+ * none of a switched-off class. Evidence on a private member the class's row says it
102
+ * cannot retain is dropped first, whatever the detector found. Under a production mode
103
+ * a record whose evidence is written in a test file holds nothing, as a reference a
104
+ * test file makes is none there.
84
105
  */
85
106
  export function computeExemptions<Brand>(
86
107
  input: DetectorInput<Brand>,
87
108
  detectors: Detectors,
88
109
  holding: Holding,
89
110
  ): readonly Exemption[] {
111
+ const visibility = new Map(input.held.symbols.map((symbol) => [symbol.id, symbol.visibility]));
90
112
  const found: Exemption[] = [];
91
113
  for (const exemptionClass of TS_EXEMPTION_CLASSES) {
92
114
  const detect = detectors.get(exemptionClass);
93
115
  if (detect === undefined || holding.disabled.has(exemptionClass)) {
94
116
  continue;
95
117
  }
118
+ const allowed = typescriptVisibilityOf(exemptionClass);
96
119
  for (const evidence of detect(input)) {
120
+ if (!retainable(allowed, visibility.get(evidence.id))) {
121
+ continue;
122
+ }
97
123
  if (holding.mode.production && holding.testFiles.has(evidence.site.path)) {
98
124
  continue;
99
125
  }
@@ -0,0 +1,4 @@
1
+ import type { Emitter } from "./emitter.ts";
2
+
3
+ /** The findings of the dependencies-and-module-machinery family, `DS1600` to `DS1699`. */
4
+ export const dependenciesAndModuleMachinery: Emitter = () => [];