@cplieger/deadset-ts 3.4.0 → 3.6.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 (60) hide show
  1. package/README.md +1 -1
  2. package/dist/src/analysis.js +70 -14
  3. package/dist/src/components.js +66 -107
  4. package/dist/src/config.js +6 -0
  5. package/dist/src/dependencies.js +493 -0
  6. package/dist/src/deprecation.js +83 -0
  7. package/dist/src/discover.js +1 -1
  8. package/dist/src/enum-group.js +217 -0
  9. package/dist/src/file-facts.js +101 -0
  10. package/dist/src/findings/boundary.js +1 -0
  11. package/dist/src/findings/completion.js +99 -0
  12. package/dist/src/findings/dependencies-and-module-machinery.js +65 -1
  13. package/dist/src/findings/emitters.js +8 -0
  14. package/dist/src/findings/interfaces.js +95 -2
  15. package/dist/src/findings/non-code-artifacts.js +97 -1
  16. package/dist/src/findings/reads-and-writes.js +199 -2
  17. package/dist/src/findings/unused-declarations.js +182 -1
  18. package/dist/src/findings/visibility-narrowing.js +239 -2
  19. package/dist/src/implementations.js +210 -0
  20. package/dist/src/inventory.js +2 -2
  21. package/dist/src/kinds.js +40 -0
  22. package/dist/src/print.js +10 -1
  23. package/dist/src/resolve.js +91 -10
  24. package/dist/src/run.js +9 -3
  25. package/dist/src/schema.js +37 -1
  26. package/dist/src/scope.js +55 -21
  27. package/dist/src/serialization-contract.js +109 -19
  28. package/dist/src/stores.js +32 -0
  29. package/dist/src/version.js +1 -1
  30. package/jsr.json +1 -1
  31. package/package.json +1 -1
  32. package/src/analysis.ts +113 -16
  33. package/src/components.ts +73 -131
  34. package/src/config.ts +37 -2
  35. package/src/dependencies.ts +617 -0
  36. package/src/deprecation.ts +101 -0
  37. package/src/discover.ts +1 -1
  38. package/src/enum-group.ts +265 -0
  39. package/src/file-facts.ts +143 -0
  40. package/src/findings/boundary.ts +39 -0
  41. package/src/findings/completion.ts +151 -0
  42. package/src/findings/dependencies-and-module-machinery.ts +79 -1
  43. package/src/findings/emitter.ts +21 -1
  44. package/src/findings/emitters.ts +13 -1
  45. package/src/findings/interfaces.ts +149 -3
  46. package/src/findings/non-code-artifacts.ts +124 -2
  47. package/src/findings/reads-and-writes.ts +310 -3
  48. package/src/findings/unused-declarations.ts +223 -2
  49. package/src/findings/visibility-narrowing.ts +324 -3
  50. package/src/implementations.ts +286 -0
  51. package/src/inventory.ts +2 -2
  52. package/src/kinds.ts +41 -0
  53. package/src/print.ts +17 -1
  54. package/src/resolve.ts +124 -10
  55. package/src/run.ts +10 -3
  56. package/src/schema.ts +47 -2
  57. package/src/scope.ts +63 -22
  58. package/src/serialization-contract.ts +138 -20
  59. package/src/stores.ts +77 -0
  60. package/src/version.ts +1 -1
@@ -1,13 +1,19 @@
1
1
  import { ConfigError, defaultConfig, } from "./config.js";
2
- import { FIXED_SEVERITY_CODES } from "./kinds.js";
2
+ import { FIXED_SEVERITY_CODES, LIVE_CODES } from "./kinds.js";
3
3
  import { checkDocument, malformed, nestFlags, SCHEMA_ROOT, SEVERITY_SECTION, } from "./schema.js";
4
4
  /** Names the command-line flags in a refusal, where a document is named by its path. */
5
5
  const FLAGS_LABEL = "the command-line flags";
6
6
  /** The length of a severity key naming a whole family: the prefix and two digits. */
7
- const FAMILY_KEY_LENGTH = 4;
7
+ export const FAMILY_KEY_LENGTH = 4;
8
8
  const SEVERITY_KEY = /^DS[0-9]{2}([0-9]{2})?$/u;
9
9
  const EXEMPTION_CLASS = /^[a-z][a-z0-9-]*$/u;
10
10
  const CONTRACT_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/u;
11
+ const ANALYZER_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/u;
12
+ const PROVIDER_SOURCE = /^(go|npm):[^ \t\r\n]+$/u;
13
+ const PROVIDER_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/u;
14
+ const PROVIDER_DIGEST = /^sha256:[0-9a-f]{64}$/u;
15
+ /** The members only an acquirable provider entry carries. */
16
+ const ACQUIRABLE_MEMBERS = ["source", "version", "digest"];
11
17
  /** The members only a platform build configuration carries. */
12
18
  const PLATFORM_MEMBERS = ["os", "arch", "tags"];
13
19
  /** The member only a project build configuration carries. */
@@ -223,8 +229,8 @@ function readDelimiters(analysis, label) {
223
229
  };
224
230
  }
225
231
  /**
226
- * A severity key that names no setting, because the Contract fixes the severity of
227
- * the kind or kinds it names.
232
+ * A severity key that names no setting: it names no live kind, or the Contract
233
+ * fixes the severity of the kind or kinds it names.
228
234
  */
229
235
  function unimplementedSeverityKey(label, path, reason) {
230
236
  return new ConfigError("unimplemented-key", path, `${label}: key ${JSON.stringify(path)} is not implemented: ${reason}`);
@@ -245,16 +251,19 @@ function spellCodes(codes) {
245
251
  }
246
252
  return `${codes.slice(0, -1).join(", ")} and ${String(codes[codes.length - 1])}`;
247
253
  }
254
+ /** Whether one severity key names a live kind: the code itself, or a family holding one. */
255
+ function namesLiveKind(code) {
256
+ return LIVE_CODES.some((candidate) => code === candidate || (code.length === FAMILY_KEY_LENGTH && candidate.startsWith(code)));
257
+ }
248
258
  /**
249
259
  * The severity object, one code at a time.
250
260
  *
251
- * A key is one issue-kind code or one two-digit family prefix, and a key naming a
252
- * kind whose severity the Contract fixes, or a family prefix whose range holds
253
- * one, is an unimplemented key rather than a setting.
261
+ * A key is one issue-kind code or one two-digit family prefix. A key naming no
262
+ * live kind, and a key naming a kind whose severity the Contract fixes or a family
263
+ * prefix whose range holds one, is an unimplemented key rather than a setting.
254
264
  *
255
- * Whether a key names a kind this analyzer reports is not decided here. This
256
- * analyzer reports no issue kind, so the question has one answer for every key
257
- * and asking it would refuse the whole object.
265
+ * Whether a key names a kind this analyzer reports is not decided here: a live
266
+ * kind of another language is a setting the analyzer of that language reads.
258
267
  */
259
268
  function readSeverity(document, label) {
260
269
  const object = section(document, SEVERITY_SECTION, SEVERITY_SECTION, label);
@@ -267,6 +276,11 @@ function readSeverity(document, label) {
267
276
  if (!SEVERITY_KEY.test(code)) {
268
277
  throw unimplementedSeverityKey(label, path, "a severity key is one issue-kind code or one two-digit family prefix");
269
278
  }
279
+ if (!namesLiveKind(code)) {
280
+ throw unimplementedSeverityKey(label, path, code.length === FAMILY_KEY_LENGTH
281
+ ? "the family this prefix names holds no live issue kind"
282
+ : "the code names no live issue kind");
283
+ }
270
284
  const fixed = fixedByContract(code);
271
285
  if (fixed.length > 0) {
272
286
  throw unimplementedSeverityKey(label, path, `the Contract fixes the severity of ${spellCodes(fixed)}, which this key names`);
@@ -384,6 +398,68 @@ function readLifecycleContracts(ts, label) {
384
398
  }
385
399
  return value.map((entry, index) => readLifecycleContract(entry, `${path}[${String(index)}]`, label));
386
400
  }
401
+ /** One string member a provider entry requires, refused where it fails `pattern`. */
402
+ function readMatching(entry, name, at, label, pattern) {
403
+ const path = `${at}.${name}`;
404
+ const value = requireMember(readString(entry, name, path, label), path, label);
405
+ if (!pattern.test(value)) {
406
+ throw malformed(label, path, `${JSON.stringify(value)} does not match ${pattern.source}`);
407
+ }
408
+ return value;
409
+ }
410
+ /**
411
+ * One provider entry, read as the one shape its members name: a member only an
412
+ * acquirable analyzer carries makes it acquirable, and then it carries all three.
413
+ */
414
+ function readProvider(entry, at, label) {
415
+ if (!isRecord(entry)) {
416
+ throw malformed(label, at, "is not an object");
417
+ }
418
+ const name = readMatching(entry, "name", at, label, ANALYZER_NAME);
419
+ const languages = readStrings(entry, "languages", `${at}.languages`, label, 1, LANGUAGES);
420
+ if (languages === undefined) {
421
+ throw malformed(label, `${at}.languages`, "is required and names nothing");
422
+ }
423
+ const commandPath = `${at}.command`;
424
+ const command = requireMember(readString(entry, "command", commandPath, label), commandPath, label);
425
+ if (!ACQUIRABLE_MEMBERS.some((member) => Object.hasOwn(entry, member))) {
426
+ return { shape: "installed", name, languages, command };
427
+ }
428
+ return {
429
+ shape: "acquirable",
430
+ name,
431
+ languages,
432
+ command,
433
+ source: readMatching(entry, "source", at, label, PROVIDER_SOURCE),
434
+ version: readMatching(entry, "version", at, label, PROVIDER_VERSION),
435
+ digest: readMatching(entry, "digest", at, label, PROVIDER_DIGEST),
436
+ };
437
+ }
438
+ /**
439
+ * The provider list. Every file a run writes for an analyzer is keyed by the
440
+ * entry's name, so a list naming one name twice is refused at the later entry.
441
+ */
442
+ function readProviders(providers, label) {
443
+ const path = "providers.analyzers";
444
+ const value = member(providers, "analyzers");
445
+ if (value === undefined) {
446
+ return undefined;
447
+ }
448
+ if (!Array.isArray(value)) {
449
+ throw malformed(label, path, "is not an array");
450
+ }
451
+ const read = value.map((entry, index) => readProvider(entry, `${path}[${String(index)}]`, label));
452
+ const first = new Map();
453
+ read.forEach((entry, index) => {
454
+ const earlier = first.get(entry.name);
455
+ if (earlier !== undefined) {
456
+ throw malformed(label, `${path}[${String(index)}].name`, `${JSON.stringify(entry.name)} is already the name of ${path}[${String(earlier)}], ` +
457
+ "and every entry's name keys the files a run writes for it");
458
+ }
459
+ first.set(entry.name, index);
460
+ });
461
+ return read;
462
+ }
387
463
  /**
388
464
  * Reads one already-parsed configuration document as the settings it supplies,
389
465
  * refusing every constraint the closed key list declares that a parse cannot
@@ -400,6 +476,7 @@ function readDoc(value, label) {
400
476
  const consumers = section(value, "consumers", "consumers", label);
401
477
  const roots = section(value, "roots", "roots", label);
402
478
  const reporters = section(value, "reporters", "reporters", label);
479
+ const providers = section(value, "providers", "providers", label);
403
480
  const ts = section(value, "ts", "ts", label);
404
481
  section(value, "go", "go", label);
405
482
  section(value, "provenance", "provenance", label);
@@ -427,6 +504,7 @@ function readDoc(value, label) {
427
504
  cascade: readEnum(reporters, "cascade", "reporters.cascade", label, CASCADES),
428
505
  maxFindings: readCount(reporters, "max_findings", "reporters.max_findings", label),
429
506
  failOn: readEnum(reporters, "fail_on", "reporters.fail_on", label, SEVERITIES),
507
+ providers: readProviders(providers, label),
430
508
  testFiles: readStrings(ts, "test_files", "ts.test_files", label, 1),
431
509
  entryFiles: readStrings(ts, "entry_files", "ts.entry_files", label, 0),
432
510
  injectionRegistrations: readDeclarations(ts, "injection_registrations", "ts.injection_registrations", label),
@@ -630,6 +708,9 @@ function settleAll(inputs) {
630
708
  maxFindings: at("reporters.max_findings", defaults.reporters.maxFindings, (doc) => doc.maxFindings),
631
709
  failOn: at("reporters.fail_on", defaults.reporters.failOn, (doc) => doc.failOn),
632
710
  },
711
+ providers: {
712
+ analyzers: at("providers.analyzers", defaults.providers.analyzers, (doc) => doc.providers),
713
+ },
633
714
  ts: {
634
715
  testFiles: at("ts.test_files", defaults.ts.testFiles, (doc) => doc.testFiles),
635
716
  entryFiles: at("ts.entry_files", defaults.ts.entryFiles, (doc) => doc.entryFiles),
package/dist/src/run.js CHANGED
@@ -47,8 +47,14 @@ export const SETTING_OPTIONS = new Map([
47
47
  ]);
48
48
  /** The options that take a value and are not settings. */
49
49
  const PLAIN_OPTIONS = new Set(["target", "config", "central", "scope"]);
50
- /** The setting options whose value is a number rather than a string. */
51
- const NUMBER_OPTIONS = new Set(["max-findings"]);
50
+ /**
51
+ * The setting options whose value is an integer rather than a string. A value
52
+ * written other than as digits is passed on as the string it is, so resolution
53
+ * refuses it by its type rather than reading `1e2` as 100.
54
+ */
55
+ const INTEGER_OPTIONS = new Set(["max-findings"]);
56
+ /** An integer as an option writes it: an optional minus sign and digits alone. */
57
+ const INTEGER_TEXT = /^-?[0-9]+$/u;
52
58
  /**
53
59
  * The first option whose name carries a source-edit token, because every verb
54
60
  * reports and never edits. The match is on the name alone, so an option whose
@@ -116,7 +122,7 @@ function inputsOf(host, options) {
116
122
  if (value === undefined) {
117
123
  continue;
118
124
  }
119
- flat[path] = NUMBER_OPTIONS.has(name) ? Number(value) : value;
125
+ flat[path] = INTEGER_OPTIONS.has(name) && INTEGER_TEXT.test(value) ? Number(value) : value;
120
126
  flagLabels.set(path, `--${name}`);
121
127
  }
122
128
  const flags = Object.keys(flat).length === 0 ? undefined : JSON.stringify(flat);
@@ -1,5 +1,6 @@
1
1
  import { ConfigError } from "./config.js";
2
2
  const leaf = { kind: "leaf" };
3
+ const integer = { kind: "leaf", integer: true };
3
4
  /** A list of declarations, each entry in one of the three shapes the `ts` section states. */
4
5
  const declarations = {
5
6
  kind: "list",
@@ -44,10 +45,26 @@ export const SCHEMA_ROOT = {
44
45
  formats: leaf,
45
46
  sort: leaf,
46
47
  cascade: leaf,
47
- max_findings: leaf,
48
+ max_findings: integer,
48
49
  fail_on: leaf,
49
50
  },
50
51
  },
52
+ providers: {
53
+ kind: "section",
54
+ members: {
55
+ analyzers: {
56
+ kind: "list",
57
+ members: {
58
+ name: leaf,
59
+ languages: leaf,
60
+ command: leaf,
61
+ source: leaf,
62
+ version: leaf,
63
+ digest: leaf,
64
+ },
65
+ },
66
+ },
67
+ },
51
68
  go: { kind: "section", members: {} },
52
69
  ts: {
53
70
  kind: "section",
@@ -244,6 +261,21 @@ function skipScalar(cursor) {
244
261
  cursor.at += 1;
245
262
  }
246
263
  }
264
+ /** A number the document writes as an optional minus sign and digits alone. */
265
+ const INTEGER_TEXT = /^-?[0-9]+$/u;
266
+ /** The start of a JSON number: a minus sign or a digit. */
267
+ const NUMBER_START = /^[-0-9]/u;
268
+ /**
269
+ * Refuses a number written with a fraction or an exponent where the key list
270
+ * declares an integer. A parse reads `100.0` and `1e2` as the integer 100, so only
271
+ * the text can tell them apart. A scalar that is no number is the decoder's to
272
+ * refuse by its type.
273
+ */
274
+ function checkInteger(text, at, label) {
275
+ if (NUMBER_START.test(text) && !INTEGER_TEXT.test(text)) {
276
+ throw malformed(label, at, `${text} is written with a fraction or an exponent, and an integer is digits alone`);
277
+ }
278
+ }
247
279
  function walkValue(cursor, at, node, label) {
248
280
  skipSpace(cursor);
249
281
  const ch = cursor.text.charCodeAt(cursor.at);
@@ -261,7 +293,11 @@ function walkValue(cursor, at, node, label) {
261
293
  readName(cursor, at, label);
262
294
  return;
263
295
  }
296
+ const start = cursor.at;
264
297
  skipScalar(cursor);
298
+ if (node.integer === true) {
299
+ checkInteger(cursor.text.slice(start, cursor.at), at, label);
300
+ }
265
301
  }
266
302
  function walkObject(cursor, at, node, label) {
267
303
  const seen = new Set();
package/dist/src/scope.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { ConfigError } from "./config.js";
1
2
  import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.js";
3
+ import { checkDocument } from "./schema.js";
2
4
  /** The roles a scope document declares for a module. */
3
5
  export const ROLE_TARGET = "target";
4
6
  export const ROLE_CONSUMER = "consumer";
@@ -17,18 +19,37 @@ export class ScopeError extends Error {
17
19
  this.name = "ScopeError";
18
20
  }
19
21
  }
20
- const DECLARED_KEYS = new Set(["target", "workspace", "consumers"]);
21
- const MODULE_KEYS = new Set(["id", "role", "path"]);
22
+ const MODULE = {
23
+ kind: "section",
24
+ members: { id: { kind: "leaf" }, role: { kind: "leaf" }, path: { kind: "leaf" } },
25
+ };
26
+ /** The closed key list of the scope document, compared as bytes. */
27
+ const SCOPE_ROOT = {
28
+ kind: "section",
29
+ members: {
30
+ target: MODULE,
31
+ workspace: { kind: "leaf" },
32
+ consumers: { kind: "list", members: MODULE.members ?? {} },
33
+ },
34
+ };
22
35
  function isRecord(value) {
23
36
  return typeof value === "object" && value !== null && !Array.isArray(value);
24
37
  }
38
+ /**
39
+ * One string member, or undefined where the document omits it. A member the
40
+ * document writes is a string holding at least one character, so `null` and `""`
41
+ * are refused rather than read as absent.
42
+ */
25
43
  function stringMember(parent, name, at) {
26
44
  if (!Object.hasOwn(parent, name)) {
27
- return "";
45
+ return undefined;
28
46
  }
29
47
  const value = parent[name];
30
48
  if (typeof value !== "string") {
31
- throw new ScopeError(`scope: ${at}.${name} is not a string`);
49
+ throw new ScopeError(`scope: ${at === "" ? name : `${at}.${name}`} is not a string`);
50
+ }
51
+ if (value === "") {
52
+ throw new ScopeError(`scope: ${at === "" ? name : `${at}.${name}`} names nothing`);
32
53
  }
33
54
  return value;
34
55
  }
@@ -36,30 +57,46 @@ function readModule(value, at, role, documentDir) {
36
57
  if (!isRecord(value)) {
37
58
  throw new ScopeError(`scope: ${at} is not an object`);
38
59
  }
39
- for (const name of Object.keys(value)) {
40
- if (!MODULE_KEYS.has(name)) {
41
- throw new ScopeError(`scope: ${at}.${name} is not a declared key`);
42
- }
43
- }
44
60
  const declared = stringMember(value, "role", at);
45
- if (declared !== "" && declared !== role) {
61
+ if (declared !== undefined && declared !== role) {
46
62
  throw new ScopeError(`scope: ${at}.role is ${JSON.stringify(declared)}, want ${JSON.stringify(role)}`);
47
63
  }
48
64
  const path = stringMember(value, "path", at);
49
- if (path === "") {
65
+ if (path === undefined) {
50
66
  throw new ScopeError(`scope: ${at}.path names nothing`);
51
67
  }
52
68
  return {
53
- id: stringMember(value, "id", at),
69
+ id: stringMember(value, "id", at) ?? "",
54
70
  path: isAbsolutePath(path) ? path : joinPath(documentDir, path),
55
71
  };
56
72
  }
57
73
  /**
58
- * Decodes the scope document at `file`. Decoding is strict: an undeclared key is an
59
- * error, and a relative path inside the document resolves against the document's
60
- * own directory.
74
+ * Walks the document's text against the closed key list, refusing a key the list
75
+ * does not declare, compared as bytes, and a key one object writes twice, which a
76
+ * parse cannot report because it keeps the last value.
77
+ */
78
+ function checkKeys(text, file) {
79
+ try {
80
+ checkDocument(text, SCOPE_ROOT, `scope: ${file}`);
81
+ }
82
+ catch (error) {
83
+ if (error instanceof ConfigError) {
84
+ // The configuration's refusal names the nearest configuration key, which a
85
+ // scope document does not have.
86
+ throw new ScopeError(error.kind === "unimplemented-key"
87
+ ? `scope: ${file}: ${error.key} is not a declared key`
88
+ : error.message);
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+ /**
94
+ * Decodes the scope document at `file`. Decoding is strict: an undeclared key, a
95
+ * key written twice in one object, and a value of a type the document does not
96
+ * admit, `null` included, are errors, and a relative path inside the document
97
+ * resolves against the document's own directory.
61
98
  *
62
- * The `workspace` key the format declares is read and carries nothing here: it
99
+ * The `workspace` key the format declares is checked and carries nothing here: it
63
100
  * names the file a Go consumer resolves the target through, and a TypeScript
64
101
  * project resolves one through its own compiler configuration.
65
102
  */
@@ -85,11 +122,8 @@ export function readScope(host, file) {
85
122
  if (!isRecord(value)) {
86
123
  throw new ScopeError(`scope: ${file} is not one JSON object`);
87
124
  }
88
- for (const name of Object.keys(value)) {
89
- if (!DECLARED_KEYS.has(name)) {
90
- throw new ScopeError(`scope: ${file}: ${name} is not a declared key`);
91
- }
92
- }
125
+ checkKeys(text, file);
126
+ stringMember(value, "workspace", "");
93
127
  const documentDir = dirnamePath(absolute);
94
128
  if (!Object.hasOwn(value, "target")) {
95
129
  throw new ScopeError(`scope: ${file} names no target`);
@@ -3,10 +3,12 @@
3
3
  * `JSON.stringify` or a call `ts.serializers` names, and those and `toJSON` and
4
4
  * `toString` where a value is passed to an `unknown` or `any` parameter of a function
5
5
  * outside the analysis. A function of the project handing such a parameter of its own
6
- * to a destination is one, holding back what that destination does, to a fixpoint.
6
+ * to a destination is one, holding back what that destination does, to a fixpoint. A
7
+ * function invoked through `Function.prototype.call`, `apply` or `bind` is the callee,
8
+ * given the arguments those methods pass it.
7
9
  */
8
- import { isArrowFunction, isClassDeclaration, isClassExpression, isClassStaticBlockDeclaration, isFunctionDeclaration, isFunctionExpression, isGetAccessorDeclaration, isIdentifier, isMethodDeclaration, isNoSubstitutionTemplateLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isSetAccessorDeclaration, isSpreadElement, isStringLiteral, isVariableDeclaration, ModifierFlags, SyntaxKind, } from "@typescript/native/unstable/ast";
9
- import { SymbolFlags, TypeFlags, } from "@typescript/native/unstable/sync";
10
+ import { isArrayLiteralExpression, isArrowFunction, isCallExpression, isClassDeclaration, isClassExpression, isClassStaticBlockDeclaration, isFunctionDeclaration, isFunctionExpression, isGetAccessorDeclaration, isIdentifier, isMethodDeclaration, isNoSubstitutionTemplateLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isSetAccessorDeclaration, isSpreadElement, isStringLiteral, isVariableDeclaration, ModifierFlags, SyntaxKind, } from "@typescript/native/unstable/ast";
11
+ import { SignatureKind, SymbolFlags, TypeFlags, } from "@typescript/native/unstable/sync";
10
12
  import { aliasChains } from "./alias-chain.js";
11
13
  import { calleeName, resolvedTargets, valuesPassed } from "./calls.js";
12
14
  import { classMembers, memberComponent, typesAt } from "./class-members.js";
@@ -71,6 +73,55 @@ function evaluated(argument) {
71
73
  const value = isSpreadElement(argument) ? argument.expression : argument;
72
74
  return isParenthesizedExpression(value) ? evaluated(value.expression) : value;
73
75
  }
76
+ /** The methods of `Function.prototype` that invoke a function with arguments of their own. */
77
+ const FUNCTION_METHODS = new Set(["call", "apply", "bind"]);
78
+ /** The interfaces of the standard library that declare those methods for a function value. */
79
+ const FUNCTION_INTERFACES = new Set([
80
+ "Function",
81
+ "CallableFunction",
82
+ "NewableFunction",
83
+ ]);
84
+ function unparenthesized(expression) {
85
+ return isParenthesizedExpression(expression)
86
+ ? unparenthesized(expression.expression)
87
+ : expression;
88
+ }
89
+ /**
90
+ * The invocation a call writes through a method of `Function.prototype`, read from
91
+ * the syntax alone: `f.call(self, a)`, `f.apply(self, [a])`, `f.bind(self, a)`, which
92
+ * binds `a`, and `f.bind(self, a)(b)`, which passes `b` after it. An `apply` argument
93
+ * that is no array literal is the value of the first parameter, whose elements it
94
+ * carries. Whether the method is the prototype's is resolution's to say.
95
+ */
96
+ function throughMethod(call) {
97
+ const expression = unparenthesized(call.expression);
98
+ if (isPropertyAccessExpression(expression) && FUNCTION_METHODS.has(expression.name.text)) {
99
+ const [, ...rest] = call.arguments;
100
+ let passed = rest;
101
+ if (expression.name.text === "apply") {
102
+ const list = rest[0] === undefined ? undefined : unparenthesized(rest[0]);
103
+ passed = list === undefined ? [] : isArrayLiteralExpression(list) ? list.elements : [list];
104
+ }
105
+ return {
106
+ method: expression.name,
107
+ invocation: { callee: expression.expression, arguments: passed, indirect: true },
108
+ };
109
+ }
110
+ if (isCallExpression(expression)) {
111
+ const bound = unparenthesized(expression.expression);
112
+ if (isPropertyAccessExpression(bound) && bound.name.text === "bind") {
113
+ return {
114
+ method: bound.name,
115
+ invocation: {
116
+ callee: bound.expression,
117
+ arguments: [...expression.arguments.slice(1), ...call.arguments],
118
+ indirect: true,
119
+ },
120
+ };
121
+ }
122
+ }
123
+ return undefined;
124
+ }
74
125
  /**
75
126
  * Every call of the project's own files with the class `this` stands for there, and
76
127
  * every identifier-named parameter of every function, method and function-valued
@@ -146,9 +197,9 @@ function erasedParameters(project, parameters) {
146
197
  return bySymbol;
147
198
  }
148
199
  /** Each place a call's argument passes one of the erased parameters it can see. */
149
- function handedOn(project, sites, erased) {
200
+ function handedOn(project, sites, erased, invocationOf) {
150
201
  const spellings = new Set([...erased.values()].map((parameter) => parameter.name.getText()));
151
- const candidates = sites.flatMap((site) => site.call.arguments.flatMap((argument, index) => valuesPassed(argument)
202
+ const candidates = sites.flatMap((site) => invocationOf(site.call).arguments.flatMap((argument, index) => valuesPassed(argument)
152
203
  .filter((value) => spellings.has(value.name.getText()))
153
204
  .map((value) => ({ site, argument: index, value }))));
154
205
  const plain = candidates.filter((one) => !one.value.shorthand);
@@ -175,11 +226,20 @@ function destinations(input, chains, walked) {
175
226
  const checker = project.checker;
176
227
  const own = new Set(project.ownSourceFiles().map((file) => file.fileName));
177
228
  const named = resolveEntries(project, held, [JSON_STRINGIFY, ...input.ts.serializers]).filter(namesADeclaration);
178
- const callees = resolvedTargets(project, walked.sites.flatMap((site) => calleeName(site.call.expression) ?? []));
179
- const calleeOf = (call) => {
180
- const name = calleeName(call.expression);
181
- return name === undefined ? undefined : callees.get(name);
182
- };
229
+ const forms = new Map();
230
+ for (const site of walked.sites) {
231
+ const form = throughMethod(site.call);
232
+ if (form !== undefined) {
233
+ forms.set(site.call, form);
234
+ }
235
+ }
236
+ const callees = resolvedTargets(project, [
237
+ ...walked.sites.flatMap((site) => calleeName(site.call.expression) ?? []),
238
+ ...[...forms.values()].flatMap((form) => {
239
+ const name = calleeName(form.invocation.callee);
240
+ return name === undefined ? [form.method] : [form.method, name];
241
+ }),
242
+ ]);
183
243
  const erased = erasedParameters(project, walked.parameters);
184
244
  const byFunction = new Map();
185
245
  for (const parameter of erased.values()) {
@@ -188,15 +248,42 @@ function destinations(input, chains, walked) {
188
248
  const outside = (target) => target.declarations.length > 0 &&
189
249
  target.declarations.every((handle) => !own.has(handle.path) &&
190
250
  !input.consumers.some((root) => handle.path === root || handle.path.startsWith(`${root}/`)));
251
+ const invocation = (call) => {
252
+ const form = forms.get(call);
253
+ const method = form === undefined ? undefined : callees.get(form.method);
254
+ // Only the standard library's function methods pass their arguments on: the
255
+ // program's own `call` and `Reflect.apply` invoke nothing here.
256
+ if (form !== undefined &&
257
+ method !== undefined &&
258
+ outside(method) &&
259
+ FUNCTION_INTERFACES.has(method.getParent()?.name ?? "")) {
260
+ return form.invocation;
261
+ }
262
+ return { callee: call.expression, arguments: call.arguments, indirect: false };
263
+ };
264
+ const calleeOf = (call) => {
265
+ const name = calleeName(invocation(call).callee);
266
+ return name === undefined ? undefined : callees.get(name);
267
+ };
191
268
  const erasedArguments = new Map();
192
269
  const erasedAt = (call, argument) => {
193
270
  let indexes = erasedArguments.get(call);
194
271
  if (indexes === undefined) {
195
- const signature = checker.getResolvedSignature(call);
196
- indexes = new Set(call.arguments.flatMap((_unused, index) => {
197
- const type = signature === undefined ? undefined : checker.getParameterType(signature, index);
198
- return type !== undefined && (type.flags & ERASED) !== 0 ? [index] : [];
199
- }));
272
+ const invoked = invocation(call);
273
+ // Through a method of the prototype the resolved signature is the method's, so
274
+ // the parameters are read off the callee's own signatures, any one erasing.
275
+ const type = invoked.indirect ? checker.getTypeAtLocation(invoked.callee) : undefined;
276
+ const signatures = invoked.indirect
277
+ ? type === undefined
278
+ ? []
279
+ : checker.getSignaturesOfType(type, SignatureKind.Call)
280
+ : [checker.getResolvedSignature(call)].filter((one) => one !== undefined);
281
+ indexes = new Set(invoked.arguments.flatMap((_unused, index) => signatures.some((signature) => {
282
+ const parameter = checker.getParameterType(signature, index);
283
+ return parameter !== undefined && (parameter.flags & ERASED) !== 0;
284
+ })
285
+ ? [index]
286
+ : []));
200
287
  erasedArguments.set(call, indexes);
201
288
  }
202
289
  return indexes.has(argument);
@@ -215,7 +302,10 @@ function destinations(input, chains, walked) {
215
302
  const declared = chains.declarationsOf(target);
216
303
  if (declared.length === 0) {
217
304
  return outside(target) && erasedAt(call, argument)
218
- ? { reach: "conversions", callee: call.expression.getText().replace(/\s+/gu, "") }
305
+ ? {
306
+ reach: "conversions",
307
+ callee: invocation(call).callee.getText().replace(/\s+/gu, ""),
308
+ }
219
309
  : undefined;
220
310
  }
221
311
  for (const fn of declared) {
@@ -227,7 +317,7 @@ function destinations(input, chains, walked) {
227
317
  }
228
318
  return undefined;
229
319
  };
230
- const forwarded = handedOn(project, walked.sites, erased);
320
+ const forwarded = handedOn(project, walked.sites, erased, invocation);
231
321
  for (let moved = true; moved;) {
232
322
  moved = false;
233
323
  for (const one of forwarded) {
@@ -254,13 +344,13 @@ function destinations(input, chains, walked) {
254
344
  }
255
345
  return declared.some((fn) => (byFunction.get(fn) ?? []).some((parameter) => forwards.has(keyOf(parameter))));
256
346
  };
257
- return { at, mayReceive };
347
+ return { invocation, at, mayReceive };
258
348
  }
259
349
  /** Each argument of a call that can receive one whose value carries a class of the target. */
260
350
  function flowsOf(project, held, sites, to) {
261
351
  const reach = valueReach(project, held);
262
352
  const arguments_ = sites.flatMap((site) => to.mayReceive(site.call)
263
- ? site.call.arguments.flatMap((argument, index) => {
353
+ ? to.invocation(site.call).arguments.flatMap((argument, index) => {
264
354
  const value = evaluated(argument);
265
355
  return holdsNoInstance(value) ? [] : [{ site, argument: index, value }];
266
356
  })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What a run knows about the stores into its declarations and the reads of them, which
3
+ * a sweep does not keep: a sweep asks whether anything names a declaration, and a store
4
+ * names it as surely as a read does.
5
+ */
6
+ import { isGetAccessorDeclaration, isSetAccessorDeclaration, } from "@typescript/native/unstable/ast";
7
+ import { nodeKey } from "./inventory.js";
8
+ /** The class members one project's own files declare by a getter or a setter. */
9
+ export function accessorsOf(project, held) {
10
+ const found = [];
11
+ for (const file of project.ownSourceFiles()) {
12
+ const visit = (node) => {
13
+ if (isGetAccessorDeclaration(node) || isSetAccessorDeclaration(node)) {
14
+ const id = held.declarations.get(nodeKey(file, node));
15
+ if (id !== undefined) {
16
+ found.push(id);
17
+ }
18
+ }
19
+ node.forEachChild(visit);
20
+ };
21
+ file.forEachChild(visit);
22
+ }
23
+ return found;
24
+ }
25
+ /** The run's stores, from what each configuration contributed, the records and the mode. */
26
+ export function storesOf(per, exempt, mode) {
27
+ return {
28
+ references: per.flatMap((one) => one.references.filter((reference) => !mode.production || !reference.test)),
29
+ accessors: new Set(per.flatMap((one) => one.accessors)),
30
+ exempt: new Set(exempt.map((record) => record.id)),
31
+ };
32
+ }
@@ -4,4 +4,4 @@
4
4
  * independently of the analyzer's own version, which is the one the host answers
5
5
  * from the package the analyzer was installed from.
6
6
  */
7
- export const CONTRACT_VERSION = "3.1.0";
7
+ export const CONTRACT_VERSION = "3.2.0";
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/deadset-ts",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Deterministic dead-code analysis for TypeScript, class and type members included",