@cplieger/deadset-ts 8.0.0-dev.3 → 8.0.0-dev.5
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.
- package/README.md +1 -1
- package/dist/src/analysis.js +34 -5
- package/dist/src/augmentations.js +1 -1
- package/dist/src/conformance-record.js +1 -1
- package/dist/src/contextual-uses.js +56 -9
- package/dist/src/convention-rows.js +1 -1
- package/dist/src/dependencies.js +22 -14
- package/dist/src/discover.js +14 -82
- package/dist/src/exempt.js +8 -0
- package/dist/src/findings/completion.js +4 -2
- package/dist/src/kinds.js +1 -1
- package/dist/src/query.js +1 -0
- package/dist/src/reporters/annotations.js +15 -8
- package/dist/src/roots.js +69 -41
- package/dist/src/serialization-contract.js +337 -9
- package/dist/src/verbs/analyze.js +0 -1
- package/dist/src/workflow-steps.js +32 -15
- package/jsr.json +1 -1
- package/package.json +3 -3
- package/src/analysis.ts +64 -5
- package/src/augmentations.ts +1 -1
- package/src/conformance-record.ts +1 -1
- package/src/contextual-uses.ts +64 -12
- package/src/convention-rows.ts +1 -1
- package/src/dependencies.ts +27 -14
- package/src/discover.ts +14 -94
- package/src/exempt.ts +16 -0
- package/src/findings/boundary.ts +2 -0
- package/src/findings/completion.ts +4 -2
- package/src/kinds.ts +1 -1
- package/src/query.ts +11 -0
- package/src/reporters/annotations.ts +21 -10
- package/src/reporters/reporter.ts +1 -3
- package/src/roots.ts +73 -43
- package/src/serialization-contract.ts +455 -11
- package/src/sweep.ts +2 -0
- package/src/verbs/analyze.ts +0 -1
- package/src/workflow-steps.ts +35 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/deadset-ts",
|
|
3
|
-
"version": "8.0.0-dev.
|
|
3
|
+
"version": "8.0.0-dev.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Deterministic dead-code analysis for TypeScript, class and type members included",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"lint:fix": "eslint . --fix && prettier --write ."
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@typescript/native": "npm:typescript@7.1.0-dev.
|
|
33
|
+
"@typescript/native": "npm:typescript@7.1.0-dev.20261014.1"
|
|
34
34
|
},
|
|
35
35
|
"overrides": {
|
|
36
36
|
"qs": "^6.15.2"
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"fast-check": "4.10.2",
|
|
46
46
|
"prettier": "3.9.10",
|
|
47
47
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
48
|
-
"typescript-eslint": "8.71.
|
|
48
|
+
"typescript-eslint": "8.71.2",
|
|
49
49
|
"vitest": "5.0.4"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
package/src/analysis.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* another project's checker, which holds only for views the session hands out.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { isStringLiteralLikeNode } from "@typescript/native/ast";
|
|
9
|
+
import { isStringLiteralLikeNode, type SourceFile } from "@typescript/native/ast";
|
|
10
10
|
import { lineOf, readComponent } from "./component-files.ts";
|
|
11
11
|
import { kindOf, type Config, type Provenance } from "./config.ts";
|
|
12
12
|
import {
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
holdingWhile,
|
|
57
57
|
isUnansweredRecord,
|
|
58
58
|
retainedIn,
|
|
59
|
+
writtenRecords,
|
|
59
60
|
type Detector,
|
|
60
61
|
type Detectors,
|
|
61
62
|
} from "./exempt.ts";
|
|
@@ -113,7 +114,7 @@ import {
|
|
|
113
114
|
type Roots,
|
|
114
115
|
} from "./roots.ts";
|
|
115
116
|
import type { Scope } from "./scope.ts";
|
|
116
|
-
import { serializationContract } from "./serialization-contract.ts";
|
|
117
|
+
import { decodedMembers, serializationContract, type Decoded } from "./serialization-contract.ts";
|
|
117
118
|
import { outsideTestReferences, testFilesAt, type OutsideTest } from "./outside-tests.ts";
|
|
118
119
|
import { mergedGlobalInterfaces } from "./augmentations.ts";
|
|
119
120
|
import { unseenLoadsOf, type UnseenLoad } from "./unseen-loads.ts";
|
|
@@ -794,6 +795,8 @@ interface SweptProject<Extra> {
|
|
|
794
795
|
readonly exempt: readonly Exemption[];
|
|
795
796
|
/** The declarations of the project an unanswered question could have kept live. */
|
|
796
797
|
readonly unanswered: readonly string[];
|
|
798
|
+
/** The data members a generic declared outside the analysis fills, which it may read. */
|
|
799
|
+
readonly filledOutside: readonly string[];
|
|
797
800
|
readonly typeErrorSkips: readonly TypeErrorSkip[];
|
|
798
801
|
readonly skipped: readonly SkippedUnit[];
|
|
799
802
|
/** The component files the project holds of its own, by path below the target root. */
|
|
@@ -816,6 +819,8 @@ interface ReadProject<Extra> {
|
|
|
816
819
|
readonly imports: ReadonlyMap<string, readonly string[]>;
|
|
817
820
|
/** The global augmentations a declaration outside the project names. */
|
|
818
821
|
readonly augmented: readonly Root[];
|
|
822
|
+
/** The data members its production code decodes into. */
|
|
823
|
+
readonly decoded: Decoded;
|
|
819
824
|
readonly extra: Extra;
|
|
820
825
|
}
|
|
821
826
|
|
|
@@ -993,6 +998,12 @@ function readRun<Extra>(
|
|
|
993
998
|
reason: warning.reason,
|
|
994
999
|
})),
|
|
995
1000
|
),
|
|
1001
|
+
decoded: decodedMembers(
|
|
1002
|
+
project,
|
|
1003
|
+
projectRead.held,
|
|
1004
|
+
productionFiles(project, targetRoot, resolved.testFilePaths),
|
|
1005
|
+
consumers,
|
|
1006
|
+
),
|
|
996
1007
|
extra: extra(project, projectRead, resolved.references, resolved.testFilePaths),
|
|
997
1008
|
};
|
|
998
1009
|
});
|
|
@@ -1014,14 +1025,31 @@ function readRun<Extra>(
|
|
|
1014
1025
|
withOutside.flatMap((one) => one.testFiles),
|
|
1015
1026
|
testEvidence(host, targetRoot, withOutside),
|
|
1016
1027
|
);
|
|
1028
|
+
// A member a decode fills or a copy carries is written: a value decoded and sent again carries it on.
|
|
1029
|
+
const written = new Set([
|
|
1030
|
+
...countedWrites(
|
|
1031
|
+
[...withOutside.flatMap((one) => one.references), ...read.consumers.references],
|
|
1032
|
+
support,
|
|
1033
|
+
mode,
|
|
1034
|
+
),
|
|
1035
|
+
...withOutside.flatMap((one) => [
|
|
1036
|
+
...one.decoded.filled,
|
|
1037
|
+
...one.decoded.parsed,
|
|
1038
|
+
...one.decoded.copied,
|
|
1039
|
+
]),
|
|
1040
|
+
]);
|
|
1041
|
+
const evidence = withOutside.map((one) => writtenRecords(one.evidence, written));
|
|
1017
1042
|
const settled = settledSatisfaction(
|
|
1018
|
-
withOutside.map((one) => ({
|
|
1043
|
+
withOutside.map((one, index) => ({
|
|
1044
|
+
evidence: evidence[index] ?? [],
|
|
1045
|
+
held: one.projectRead.held,
|
|
1046
|
+
})),
|
|
1019
1047
|
[...withOutside.flatMap((one) => one.references), ...read.consumers.references],
|
|
1020
1048
|
);
|
|
1021
1049
|
const answers = withOutside.map((one, index): SweptProject<Extra> => {
|
|
1022
1050
|
const { projectRead } = one;
|
|
1023
1051
|
const satisfied = settled[index];
|
|
1024
|
-
const detected = heldRecords(satisfied?.evidence ??
|
|
1052
|
+
const detected = heldRecords(satisfied?.evidence ?? evidence[index] ?? [], {
|
|
1025
1053
|
mode,
|
|
1026
1054
|
testFiles: new Set([...one.testFiles, ...support]),
|
|
1027
1055
|
});
|
|
@@ -1059,7 +1087,9 @@ function readRun<Extra>(
|
|
|
1059
1087
|
.filter((reference) => reference.resolution === "by-name")
|
|
1060
1088
|
.map((reference) => reference.to),
|
|
1061
1089
|
...exempt.filter(isUnansweredRecord).map((record) => record.id),
|
|
1090
|
+
...one.decoded.unanswered,
|
|
1062
1091
|
],
|
|
1092
|
+
filledOutside: one.decoded.filled,
|
|
1063
1093
|
typeErrorSkips: projectRead.typeErrorSkips,
|
|
1064
1094
|
unmappedEntries: projectRead.rooted.unmappedEntries,
|
|
1065
1095
|
skipped: projectRead.skipped,
|
|
@@ -1162,6 +1192,22 @@ function evidenceReferences(
|
|
|
1162
1192
|
});
|
|
1163
1193
|
}
|
|
1164
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* The declarations a write the mode counts stores into, a write a test-support file
|
|
1197
|
+
* makes being a test's.
|
|
1198
|
+
*/
|
|
1199
|
+
function countedWrites(
|
|
1200
|
+
references: readonly Reference[],
|
|
1201
|
+
support: ReadonlySet<string>,
|
|
1202
|
+
mode: Mode,
|
|
1203
|
+
): ReadonlySet<string> {
|
|
1204
|
+
return new Set(
|
|
1205
|
+
supportReferences(references, support).flatMap((reference) =>
|
|
1206
|
+
reference.use === "write" && !(mode.production && reference.test) ? [reference.to] : [],
|
|
1207
|
+
),
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1165
1211
|
/**
|
|
1166
1212
|
* The declarations every reference the mode counts stores into, a loaded consumer's
|
|
1167
1213
|
* reference counting as a read: what the write-only kind would report but for a record.
|
|
@@ -1281,6 +1327,18 @@ interface EmitterExtra {
|
|
|
1281
1327
|
readonly unseenLoads: readonly UnseenLoad[];
|
|
1282
1328
|
}
|
|
1283
1329
|
|
|
1330
|
+
/** The project's own files below the target root that are no test file. */
|
|
1331
|
+
function productionFiles<Brand>(
|
|
1332
|
+
project: ProjectView<Brand>,
|
|
1333
|
+
targetRoot: string,
|
|
1334
|
+
testFiles: readonly string[],
|
|
1335
|
+
): readonly SourceFile[] {
|
|
1336
|
+
const tests = new Set(testFiles);
|
|
1337
|
+
return [...sourceFilesOf(project, targetRoot).byPath].flatMap(([path, file]) =>
|
|
1338
|
+
tests.has(path) ? [] : [file],
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1284
1342
|
/** Reads a run's projects with every per-project fact an emitter reads. */
|
|
1285
1343
|
export function readEmitterRun(
|
|
1286
1344
|
engine: Engine,
|
|
@@ -1317,7 +1375,7 @@ export function readEmitterRun(
|
|
|
1317
1375
|
unseenLoads: unseenLoadsOf(project, read.configuration, targetRoot, testFiles),
|
|
1318
1376
|
}),
|
|
1319
1377
|
);
|
|
1320
|
-
// Whether a declaration carries the marker decides its code, so one whose
|
|
1378
|
+
// Whether a declaration carries the marker decides its code, so one whose answer went
|
|
1321
1379
|
// unread is reported by nothing.
|
|
1322
1380
|
const unread = run.projects.flatMap((one) => one.extra.deprecation.unanswered);
|
|
1323
1381
|
return unread.length === 0
|
|
@@ -1429,6 +1487,7 @@ export function emitterInputOver(
|
|
|
1429
1487
|
},
|
|
1430
1488
|
encapsulated: readManifest(host, targetRoot).declaresExports,
|
|
1431
1489
|
edges: readEdgeSides(host, targetRoot),
|
|
1490
|
+
filledOutside: new Set(projects.flatMap((one) => one.filledOutside)),
|
|
1432
1491
|
},
|
|
1433
1492
|
selfCheck,
|
|
1434
1493
|
intraFunction: intraFunctionFacts(
|
package/src/augmentations.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { SourceFiles } from "./source-files.ts";
|
|
|
21
21
|
import type { NodeHandle } from "@typescript/native/sync";
|
|
22
22
|
|
|
23
23
|
/** Whether one declaration is a `declare global` block: a module-level namespace named `global`. */
|
|
24
|
-
function isGlobalBlock(
|
|
24
|
+
export function isGlobalBlock(
|
|
25
25
|
symbol: InventorySymbol,
|
|
26
26
|
held: Inventory,
|
|
27
27
|
files: ReadonlySet<string>,
|
|
@@ -4,7 +4,7 @@ export const RECORD = {
|
|
|
4
4
|
conformance: {
|
|
5
5
|
corpusVersion: "6.0.0",
|
|
6
6
|
result: "pass",
|
|
7
|
-
digest: "sha256:
|
|
7
|
+
digest: "sha256:e2a3ba92a5a6fe81174439a944333daf8d17e52fdacb8599fc50e3adb1569773",
|
|
8
8
|
},
|
|
9
9
|
gaps: [],
|
|
10
10
|
} as const;
|
package/src/contextual-uses.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The uses contextual types make of the target's members, which no name node resolves to.
|
|
3
3
|
* An object-literal property writes the member of its name on each constituent of the
|
|
4
|
-
* literal's contextual type that declares one
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* literal's contextual type that declares one, and so does a property of a literal whose
|
|
5
|
+
* type is inferred, where a value of that type reaches such a position. A value reaching a
|
|
6
|
+
* position typed by another object type reads each of its members that type declares,
|
|
7
|
+
* through array elements and the parameters and results of functions and constructors
|
|
8
|
+
* too; a class instance there is the interface-satisfaction class's evidence instead.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import {
|
|
@@ -109,10 +110,12 @@ interface Checked {
|
|
|
109
110
|
readonly source: Type;
|
|
110
111
|
readonly targets: readonly Type[];
|
|
111
112
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
113
|
+
* What the source is: a type whose members the target declares, which the position
|
|
114
|
+
* reads; one declared outside the target, so code the analysis does not hold reads the
|
|
115
|
+
* members of the target's types it declares; or an object literal's own type, whose
|
|
116
|
+
* properties write the position's members of their names.
|
|
114
117
|
*/
|
|
115
|
-
readonly
|
|
118
|
+
readonly sourceKind: "members" | "outside" | "literal";
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
/** One member of an object type, by the inventory's declarations and the checker's declaration handles. */
|
|
@@ -287,6 +290,37 @@ export function contextualUses<Brand>(
|
|
|
287
290
|
return members.length > 0 && members.every((member) => !member.own);
|
|
288
291
|
};
|
|
289
292
|
|
|
293
|
+
const literalTypes = new Map<number, boolean>();
|
|
294
|
+
/**
|
|
295
|
+
* Whether an object type is an object literal's own, inferred where no contextual type
|
|
296
|
+
* names it: every member a property an object literal of the target writes.
|
|
297
|
+
*/
|
|
298
|
+
const literalTyped = (part: Type): boolean => {
|
|
299
|
+
let known = literalTypes.get(part.id);
|
|
300
|
+
if (known === undefined) {
|
|
301
|
+
const members = [...membersOf(part).values()];
|
|
302
|
+
known =
|
|
303
|
+
members.length > 0 &&
|
|
304
|
+
members.every(
|
|
305
|
+
(member) =>
|
|
306
|
+
member.ids.length === 0 &&
|
|
307
|
+
member.declarations.length > 0 &&
|
|
308
|
+
member.declarations.every((handle) => literalProperty(handle) !== undefined),
|
|
309
|
+
);
|
|
310
|
+
literalTypes.set(part.id, known);
|
|
311
|
+
}
|
|
312
|
+
return known;
|
|
313
|
+
};
|
|
314
|
+
/** The name node of the object literal property one declaration handle names, if it is one. */
|
|
315
|
+
const literalProperty = (handle: NodeHandle): Node | undefined => {
|
|
316
|
+
const node = project.declarationAt(handle)?.node;
|
|
317
|
+
return node !== undefined &&
|
|
318
|
+
(isPropertyAssignment(node) || isShorthandPropertyAssignment(node)) &&
|
|
319
|
+
isObjectLiteralExpression(node.parent)
|
|
320
|
+
? node.name
|
|
321
|
+
: undefined;
|
|
322
|
+
};
|
|
323
|
+
|
|
290
324
|
const elements = new Map<number, Type | undefined>();
|
|
291
325
|
/** The element type of an array type, read once per type. */
|
|
292
326
|
const elementOf = (type: Type): Type | undefined => {
|
|
@@ -379,9 +413,11 @@ export function contextualUses<Brand>(
|
|
|
379
413
|
const sources = partsOf(source).filter((one) => !targets.some((part) => part.id === one.id));
|
|
380
414
|
for (const one of sources) {
|
|
381
415
|
if (carries(one)) {
|
|
382
|
-
into.push({ source: one, targets,
|
|
416
|
+
into.push({ source: one, targets, sourceKind: "members" });
|
|
383
417
|
} else if (outside(one)) {
|
|
384
|
-
into.push({ source: one, targets,
|
|
418
|
+
into.push({ source: one, targets, sourceKind: "outside" });
|
|
419
|
+
} else if (literalTyped(one)) {
|
|
420
|
+
into.push({ source: one, targets, sourceKind: "literal" });
|
|
385
421
|
}
|
|
386
422
|
const element = elementOf(one);
|
|
387
423
|
const called = element === undefined ? callsOf(one) : [];
|
|
@@ -423,7 +459,7 @@ export function contextualUses<Brand>(
|
|
|
423
459
|
}
|
|
424
460
|
seen.add(type.id);
|
|
425
461
|
return partsOf(type).some((part) => {
|
|
426
|
-
if (carries(part) || outside(part)) {
|
|
462
|
+
if (carries(part) || outside(part) || literalTyped(part)) {
|
|
427
463
|
return true;
|
|
428
464
|
}
|
|
429
465
|
const element = elementOf(part);
|
|
@@ -444,6 +480,8 @@ export function contextualUses<Brand>(
|
|
|
444
480
|
cap,
|
|
445
481
|
);
|
|
446
482
|
const reads: ContextualUse[] = [];
|
|
483
|
+
// A literal property writes a position's members once, however many values carry it there.
|
|
484
|
+
const carriedWrites = new Set<string>();
|
|
447
485
|
sourced.forEach(({ flow, type }, index) => {
|
|
448
486
|
const position = positions[index];
|
|
449
487
|
if (position === undefined || !isAnswered(position)) {
|
|
@@ -452,10 +490,24 @@ export function contextualUses<Brand>(
|
|
|
452
490
|
const checked: Checked[] = [];
|
|
453
491
|
compared(type, position, checked, new Set());
|
|
454
492
|
const ids = new Set<string>();
|
|
455
|
-
for (const { source, targets,
|
|
493
|
+
for (const { source, targets, sourceKind } of checked) {
|
|
456
494
|
const tables = targets.map(membersOf);
|
|
457
495
|
const sourceMembers = membersOf(source);
|
|
458
|
-
if (
|
|
496
|
+
if (sourceKind === "literal") {
|
|
497
|
+
for (const [name, member] of sourceMembers) {
|
|
498
|
+
const written = [...new Set(tables.flatMap((table) => table.get(name)?.ids ?? []))];
|
|
499
|
+
for (const at of written.length === 0 ? [] : member.declarations) {
|
|
500
|
+
const node = literalProperty(at);
|
|
501
|
+
const key = `${at.path}#${String(at.index)}>${written.join(",")}`;
|
|
502
|
+
if (node !== undefined && !carriedWrites.has(key)) {
|
|
503
|
+
carriedWrites.add(key);
|
|
504
|
+
writes.push({ at: node, from: flow.from, ids: written });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (sourceKind === "outside") {
|
|
459
511
|
for (const table of tables) {
|
|
460
512
|
for (const [name, member] of table) {
|
|
461
513
|
if (sourceMembers.has(name)) {
|
package/src/convention-rows.ts
CHANGED
package/src/dependencies.ts
CHANGED
|
@@ -48,7 +48,7 @@ import type { DependencySection, Module } from "./ref.ts";
|
|
|
48
48
|
import { UNANSWERED } from "./query.ts";
|
|
49
49
|
import type { Handle, ProjectView } from "./session.ts";
|
|
50
50
|
import { sourceFilesOf, type SourceFiles } from "./source-files.ts";
|
|
51
|
-
import { workflowTokens } from "./workflow-steps.ts";
|
|
51
|
+
import { workflowCommands, workflowTokens } from "./workflow-steps.ts";
|
|
52
52
|
|
|
53
53
|
const MANIFEST = "package.json";
|
|
54
54
|
const MODULES_DIR = "node_modules";
|
|
@@ -908,20 +908,32 @@ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/u;
|
|
|
908
908
|
const COMMAND_SEPARATORS = /&&|\|\||;|\||&/u;
|
|
909
909
|
|
|
910
910
|
/**
|
|
911
|
-
* The
|
|
912
|
-
*
|
|
913
|
-
*
|
|
911
|
+
* The command strings one manifest runs: each of its `scripts`, and the `exec` command of
|
|
912
|
+
* nodemon's configuration, in the manifest's `nodemonConfig` or the `nodemon.json` beside
|
|
913
|
+
* it, which nodemon runs the watched script with:
|
|
914
|
+
* https://github.com/remy/nodemon#config-files
|
|
914
915
|
*/
|
|
915
|
-
function
|
|
916
|
+
function manifestCommands(
|
|
917
|
+
host: Host,
|
|
918
|
+
dir: string,
|
|
919
|
+
manifest: Record<string, unknown>,
|
|
920
|
+
): readonly string[] {
|
|
916
921
|
const scripts = manifest["scripts"];
|
|
922
|
+
const nodemon = [manifest["nodemonConfig"], readJSON(host, joinPath(dir, "nodemon.json"))?.value];
|
|
923
|
+
return [
|
|
924
|
+
...(isRecord(scripts) ? Object.values(scripts) : []),
|
|
925
|
+
...nodemon.map((config) => (isRecord(config) ? config["exec"] : undefined)),
|
|
926
|
+
].filter((command): command is string => typeof command === "string");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* The packages `scripts` preload by a runtime flag's bare module specifier, a flag
|
|
931
|
+
* counting only in a command whose program passes it to the runtime, and for Node only
|
|
932
|
+
* before the script it runs, whose own arguments come after it.
|
|
933
|
+
*/
|
|
934
|
+
function preloadsOf(scripts: readonly string[]): ReadonlySet<string> {
|
|
917
935
|
const found = new Set<string>();
|
|
918
|
-
|
|
919
|
-
return found;
|
|
920
|
-
}
|
|
921
|
-
for (const script of Object.values(scripts)) {
|
|
922
|
-
if (typeof script !== "string") {
|
|
923
|
-
continue;
|
|
924
|
-
}
|
|
936
|
+
for (const script of scripts) {
|
|
925
937
|
for (const command of script.split(COMMAND_SEPARATORS)) {
|
|
926
938
|
const tokens = scriptTokens(command);
|
|
927
939
|
let at = 0;
|
|
@@ -1058,6 +1070,7 @@ export function dependenciesOf(
|
|
|
1058
1070
|
}
|
|
1059
1071
|
}
|
|
1060
1072
|
const workflow = new Set(workflowTokens(host, targetRoot));
|
|
1073
|
+
const workflowPreloads = preloadsOf(workflowCommands(host, targetRoot));
|
|
1061
1074
|
const declared: DeclaredDependency[] = [];
|
|
1062
1075
|
const ran = new Set<string>();
|
|
1063
1076
|
const peers = new Map<string, readonly string[]>();
|
|
@@ -1068,14 +1081,14 @@ export function dependenciesOf(
|
|
|
1068
1081
|
continue;
|
|
1069
1082
|
}
|
|
1070
1083
|
const tokens = scriptTokensOf(read.value);
|
|
1071
|
-
const preloads =
|
|
1084
|
+
const preloads = preloadsOf(manifestCommands(host, dir, read.value));
|
|
1072
1085
|
for (const dependency of declaredIn(read.text, read.value, path, scopeOf(path))) {
|
|
1073
1086
|
const found = installedOf(host, dir, dependency.name);
|
|
1074
1087
|
if (found === undefined && uninstalledHere.has(dir)) {
|
|
1075
1088
|
continue;
|
|
1076
1089
|
}
|
|
1077
1090
|
declared.push(dependency);
|
|
1078
|
-
if (preloads.has(dependency.name)) {
|
|
1091
|
+
if (preloads.has(dependency.name) || workflowPreloads.has(dependency.name)) {
|
|
1079
1092
|
ran.add(declarationKey(dependency));
|
|
1080
1093
|
}
|
|
1081
1094
|
if (found === undefined) {
|
package/src/discover.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Diagnostic } from "@typescript/native/sync";
|
|
|
3
3
|
import { baseUrlPaths } from "./base-url.ts";
|
|
4
4
|
import type { BuildConfiguration, ProjectConfiguration } from "./config.ts";
|
|
5
5
|
import type { Host } from "./host.ts";
|
|
6
|
-
import { dirnamePath,
|
|
6
|
+
import { dirnamePath, joinPath, relativePath, resolvePath } from "./paths.ts";
|
|
7
7
|
import type { Scope } from "./scope.ts";
|
|
8
8
|
import { SetupError } from "./setup-failure.ts";
|
|
9
9
|
import type { Engine, ProjectDiagnostics } from "./session.ts";
|
|
@@ -347,55 +347,8 @@ function derivedId(targetRoot: string, configFile: string): string {
|
|
|
347
347
|
return relativePath(targetRoot, configFile) ?? configFile;
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
-
/**
|
|
351
|
-
* The code of a configuration whose `extends` chain returns to a configuration it already
|
|
352
|
-
* passed. The parse reports it and returns, and a snapshot opened from it never answers:
|
|
353
|
-
* https://github.com/microsoft/TypeScript/issues/64754
|
|
354
|
-
*/
|
|
355
|
-
const CIRCULAR_EXTENDS = 18000;
|
|
356
|
-
|
|
357
350
|
type Parsed = ReturnType<Engine["parseConfigFile"]>;
|
|
358
351
|
|
|
359
|
-
/** The configuration files one parsed configuration's own `extends` names by path. */
|
|
360
|
-
function extendedPaths(parsed: Parsed, configFile: string): readonly string[] {
|
|
361
|
-
const { raw } = parsed as { readonly raw?: unknown };
|
|
362
|
-
const named: unknown = typeof raw === "object" && raw !== null ? Reflect.get(raw, "extends") : [];
|
|
363
|
-
return (Array.isArray(named) ? named : [named])
|
|
364
|
-
.filter((one): one is string => typeof one === "string")
|
|
365
|
-
.filter((one) => one.startsWith("./") || one.startsWith("../") || isAbsolutePath(one))
|
|
366
|
-
.map((one) => resolvePath(dirnamePath(configFile), one));
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* The `extends` chain from `configFile` to the first configuration it reaches twice, each
|
|
371
|
-
* link by path, or undefined where no chain written by path returns.
|
|
372
|
-
*/
|
|
373
|
-
function extendsCycle(
|
|
374
|
-
engine: Engine,
|
|
375
|
-
configFile: string,
|
|
376
|
-
parsed: Parsed,
|
|
377
|
-
): readonly string[] | undefined {
|
|
378
|
-
const walk = (chain: readonly string[], at: Parsed): readonly string[] | undefined => {
|
|
379
|
-
for (const next of extendedPaths(at, chain.at(-1) ?? configFile)) {
|
|
380
|
-
if (chain.includes(next)) {
|
|
381
|
-
return [...chain, next];
|
|
382
|
-
}
|
|
383
|
-
let read: Parsed;
|
|
384
|
-
try {
|
|
385
|
-
read = engine.parseConfigFile(next);
|
|
386
|
-
} catch {
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
const cycle = walk([...chain, next], read);
|
|
390
|
-
if (cycle !== undefined) {
|
|
391
|
-
return cycle;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
return undefined;
|
|
395
|
-
};
|
|
396
|
-
return walk([configFile], parsed);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
352
|
/** The configuration files one parsed configuration's `references` name. */
|
|
400
353
|
function referencesOf(parsed: Parsed): readonly string[] {
|
|
401
354
|
// The parse answers a directory reference as the directory; the compiler reads a
|
|
@@ -406,51 +359,17 @@ function referencesOf(parsed: Parsed): readonly string[] {
|
|
|
406
359
|
);
|
|
407
360
|
}
|
|
408
361
|
|
|
409
|
-
/** Why one parsed configuration's own `extends` chain cannot be opened, where it is circular. */
|
|
410
|
-
function circularity(engine: Engine, configFile: string, parsed: Parsed): string | undefined {
|
|
411
|
-
const chain = extendsCycle(engine, configFile, parsed);
|
|
412
|
-
if (chain === undefined && !parsed.errors.some(({ code }) => code === CIRCULAR_EXTENDS)) {
|
|
413
|
-
return undefined;
|
|
414
|
-
}
|
|
415
|
-
return `TS${String(CIRCULAR_EXTENDS)}: Circularity detected while resolving configuration: ${(chain ?? [configFile]).join(" -> ")}`;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
362
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
363
|
+
* The code of a configuration whose `extends` chain returns to a configuration it already
|
|
364
|
+
* passed. A snapshot that opens two configurations whose `extends` chains reach one such
|
|
365
|
+
* cycle panics (`OwnerCache.AddOwner: entry not found`, typescript 7.1.0-dev.20261014.1),
|
|
366
|
+
* so the analysis refuses one before any snapshot is asked to open it.
|
|
422
367
|
*/
|
|
423
|
-
|
|
424
|
-
engine: Engine,
|
|
425
|
-
references: readonly string[],
|
|
426
|
-
visited: Set<string>,
|
|
427
|
-
): string | undefined {
|
|
428
|
-
for (const reference of references) {
|
|
429
|
-
if (visited.has(reference)) {
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
visited.add(reference);
|
|
433
|
-
let parsed: Parsed;
|
|
434
|
-
try {
|
|
435
|
-
parsed = engine.parseConfigFile(reference);
|
|
436
|
-
} catch {
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
const reason =
|
|
440
|
-
circularity(engine, reference, parsed) ??
|
|
441
|
-
referencedCircularity(engine, referencesOf(parsed), visited);
|
|
442
|
-
if (reason !== undefined) {
|
|
443
|
-
return reason;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
return undefined;
|
|
447
|
-
}
|
|
368
|
+
const CIRCULAR_EXTENDS = 18000;
|
|
448
369
|
|
|
449
370
|
/**
|
|
450
|
-
* The
|
|
451
|
-
*
|
|
452
|
-
* configuration it references is circular, which the analysis refuses before any
|
|
453
|
-
* snapshot is asked to open it.
|
|
371
|
+
* The configurations one configuration file's `references` name, or why it cannot be read:
|
|
372
|
+
* the compiler throws, or its parse reports the `extends` chain circular.
|
|
454
373
|
*/
|
|
455
374
|
function readConfiguration(
|
|
456
375
|
engine: Engine,
|
|
@@ -458,11 +377,12 @@ function readConfiguration(
|
|
|
458
377
|
): { readonly references: readonly string[] } | { readonly reason: string } {
|
|
459
378
|
try {
|
|
460
379
|
const parsed = engine.parseConfigFile(configFile);
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
380
|
+
const circular = parsed.errors.find(({ code }) => code === CIRCULAR_EXTENDS);
|
|
381
|
+
// The compiler leaves the message's `{0}` unfilled:
|
|
382
|
+
// https://github.com/microsoft/TypeScript/pull/64755
|
|
383
|
+
return circular === undefined
|
|
384
|
+
? { references: referencesOf(parsed) }
|
|
385
|
+
: { reason: `TS${String(circular.code)}: ${circular.text.replace("{0}", () => configFile)}` };
|
|
466
386
|
} catch (error: unknown) {
|
|
467
387
|
return { reason: error instanceof Error ? error.message : String(error) };
|
|
468
388
|
}
|
package/src/exempt.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface Evidence {
|
|
|
37
37
|
readonly whileLive?: string;
|
|
38
38
|
/** Where the evidence is an implementation of interface methods, which and how. */
|
|
39
39
|
readonly implementing?: Implementing;
|
|
40
|
+
/** Whether the evidence holds only where a write the mode counts stores into the declaration. */
|
|
41
|
+
readonly whileWritten?: true;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** What a detector reads of one project, while the project's view is open. */
|
|
@@ -193,12 +195,26 @@ export function evidenceRecords<Brand>(
|
|
|
193
195
|
site: evidence.site,
|
|
194
196
|
...(evidence.whileLive === undefined ? {} : { whileLive: evidence.whileLive }),
|
|
195
197
|
...(evidence.implementing === undefined ? {} : { implementing: evidence.implementing }),
|
|
198
|
+
...(evidence.whileWritten === undefined ? {} : { whileWritten: evidence.whileWritten }),
|
|
196
199
|
});
|
|
197
200
|
}
|
|
198
201
|
}
|
|
199
202
|
return found;
|
|
200
203
|
}
|
|
201
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The records that hold given the declarations a write the mode counts stores into: one
|
|
207
|
+
* that holds only while written holds where its declaration is among them.
|
|
208
|
+
*/
|
|
209
|
+
export function writtenRecords(
|
|
210
|
+
records: readonly Exemption[],
|
|
211
|
+
written: ReadonlySet<string>,
|
|
212
|
+
): readonly Exemption[] {
|
|
213
|
+
return records.flatMap(({ whileWritten, ...record }) =>
|
|
214
|
+
whileWritten === undefined || written.has(record.id) ? [record] : [],
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
202
218
|
/** The records that hold under one mode, beside the evidence a production mode found in a test file. */
|
|
203
219
|
export function heldRecords(records: readonly Exemption[], holding: Holding): Detected {
|
|
204
220
|
const found: Exemption[] = [];
|
package/src/findings/boundary.ts
CHANGED
|
@@ -25,4 +25,6 @@ export interface Boundary {
|
|
|
25
25
|
readonly encapsulated: boolean;
|
|
26
26
|
/** Every side of a declared cross-language edge that names a symbol of this language. */
|
|
27
27
|
readonly edges: readonly EdgeSide[];
|
|
28
|
+
/** The members a function outside the analysis fills, which it may read unseen. */
|
|
29
|
+
readonly filledOutside?: ReadonlySet<string>;
|
|
28
30
|
}
|
|
@@ -112,10 +112,12 @@ function testedByIn(input: EmitterInput): ReadonlySet<string> {
|
|
|
112
112
|
* published API, a public member of a published type included, is `certain` where the
|
|
113
113
|
* run loaded every consumer the scope declared and declared one, `probable` where one did
|
|
114
114
|
* not load, and `possible` where it declared none, whatever the configuration says of the
|
|
115
|
-
* consumer set. A member an `in` expression tests by name
|
|
115
|
+
* consumer set. A member an `in` expression tests by name, and one a function outside the
|
|
116
|
+
* analysis fills, is `possible`.
|
|
116
117
|
*/
|
|
117
118
|
function reachabilityClasses(input: EmitterInput): (id: string) => Confidence {
|
|
118
119
|
const tested = testedByIn(input);
|
|
120
|
+
const filled = input.boundary.filledOutside ?? new Set<string>();
|
|
119
121
|
const published = publishedIds(input.swept.matrix.union);
|
|
120
122
|
const { declared } = input.boundary.consumers;
|
|
121
123
|
const outside: Confidence =
|
|
@@ -125,7 +127,7 @@ function reachabilityClasses(input: EmitterInput): (id: string) => Confidence {
|
|
|
125
127
|
? "certain"
|
|
126
128
|
: "probable";
|
|
127
129
|
return (id) => {
|
|
128
|
-
if (tested.has(id)) {
|
|
130
|
+
if (tested.has(id) || filled.has(id)) {
|
|
129
131
|
return "possible";
|
|
130
132
|
}
|
|
131
133
|
return published.has(id) ? outside : "certain";
|
package/src/kinds.ts
CHANGED
|
@@ -191,7 +191,7 @@ const ROWS: readonly KindRow[] = [
|
|
|
191
191
|
code: "DS1301",
|
|
192
192
|
name: "write-only-symbol",
|
|
193
193
|
languages: ["go", "ts"],
|
|
194
|
-
rule: "A package-level or module-level variable, struct field, class member, type member or collection that production code writes and never reads. In production mode a read from a test file counts as no read. A write from a loaded consumer's code counts as a read, because the deletion the finding names would break a consumer the target does not own. A write in a consumer's test file counts as a read in the run that counts test references. It counts in a production sweep only where the configuration counts consumer tests as production. In Go a read counts only from a declaration that is live under the finding's liveness relation, which is reference counting: a read from a declaration nothing references, whose own finding has the reachability class `certain`, is no read, so a declaration that only such declarations read is reported where a declaration something references writes it. A comparison of struct values reads every field of the type: in Go, a value of a struct type used as an operand of `==` or `!=`, as a map key, or as a `switch` tag or `case` expression is a read of every field of that type, transitively through the fields of every struct type it holds, recorded at the comparison, because equality reads every field and a field that decides equality carries information. In Go a type argument the program instantiates a type parameter with, where the parameter's constraint is `comparable` or embeds it, is compared as an operand of `==` of that type is, recorded at the instantiation, so the struct values `slices.Equal`, `slices.Index` or `maps.Equal` compares have every field read. `reflect.DeepEqual` is not a comparison under this rule. In Go, a conversion the compiler accepts between two struct types, or between two pointer types that are not named types and whose base types are struct types, reads every field of the two struct types where their struct type literals are distinct, recorded at the conversion, because the conversion is legal only while their fields are identical, ignoring tags, and so no field of either can be deleted alone. It reads in the same way every field of each pair of distinct struct type literals the identity of the two types compares in turn: through each field's type, and from there through the element, key, base, parameter and result types of the type literals it reaches, through the method signatures of an interface type literal, and through the type arguments of an instantiated generic type. A field reached there has no reference of its own, as grammar/symbol-ref.md states, so it is never a subject and those reads hold no finding back. It stops at a defined type, whose fields the conversion does not read, because a defined type is identical to itself whatever fields it declares. A pair whose two sides are one struct type literal, the two struct types themselves included, as where one type is declared from the other or the conversion is to the operand's own type, reads nothing, because a field deleted from that literal leaves both sides identical. Where the operand's type or the conversion's type is a type parameter, the struct type its constraint's core type is takes that side's place, and a type parameter operand reads in the same way the fields of each struct type argument an instantiation the program holds gives it, an instantiation made through another generic declaration's own type parameters included. A conversion to any other type, an interface type among them, reads no field under this rule. In TypeScript a comparison of two object references reads no member, because `===` compares identity and never a member. A TypeScript parameter property, a constructor parameter that carries a modifier, declares a member of its class that the constructor writes with its argument, so the member is judged under this rule, and under every rule that reads a class member, as a member the class body declares is. In TypeScript a property of an object literal writes the member of the same name that the literal's contextual type declares, whether or not the compiler checks the literal for excess properties. Where that contextual type is a union or an intersection, the property writes the member of that name on each constituent that declares one. So a literal that a declaration annotates, a function returns, a call passes or a `satisfies` expression checks writes the members of the type it is given. An object literal handed to a callee the target does not implement has its members read, as grammar/analysis.md states. A store through the value a field or a member holds reads that field or member rather than writing it, because the store lands in storage every other holder of the value reads. In Go that store is an assignment, an increment, a compound assignment or a `delete` whose target is reached through a struct field holding a map, a slice or a pointer. Examples are `s.f[k] = v`, `s.f.x = v` and `delete(s.f, k)`. The same store through a field holding an array or a struct is a store into the field itself. TypeScript holds every object by reference, so an element or property store through a member's value reads the member. The finding names each write position, so the deletion set is visible. Deleting the subject and its writes also deletes every declaration whose only uses they hold. Such a declaration is a type parameter of the subject's declaring type that the subject's type alone names, an import binding the writes alone use, or a declaration whose every use is in the type annotation of the subject or of another subject this rule reports, or in the writes those findings name. The finding's message names each such declaration, and no finding is reported about the declaration itself.",
|
|
194
|
+
rule: "A package-level or module-level variable, struct field, class member, type member or collection that production code writes and never reads. In production mode a read from a test file counts as no read. A write from a loaded consumer's code counts as a read, because the deletion the finding names would break a consumer the target does not own. A write in a consumer's test file counts as a read in the run that counts test references. It counts in a production sweep only where the configuration counts consumer tests as production. In Go a read counts only from a declaration that is live under the finding's liveness relation, which is reference counting: a read from a declaration nothing references, whose own finding has the reachability class `certain`, is no read, so a declaration that only such declarations read is reported where a declaration something references writes it. A comparison of struct values reads every field of the type: in Go, a value of a struct type used as an operand of `==` or `!=`, as a map key, or as a `switch` tag or `case` expression is a read of every field of that type, transitively through the fields of every struct type it holds, recorded at the comparison, because equality reads every field and a field that decides equality carries information. In Go a type argument the program instantiates a type parameter with, where the parameter's constraint is `comparable` or embeds it, is compared as an operand of `==` of that type is, recorded at the instantiation, so the struct values `slices.Equal`, `slices.Index` or `maps.Equal` compares have every field read. `reflect.DeepEqual` is not a comparison under this rule. In Go, a conversion the compiler accepts between two struct types, or between two pointer types that are not named types and whose base types are struct types, reads every field of the two struct types where their struct type literals are distinct, recorded at the conversion, because the conversion is legal only while their fields are identical, ignoring tags, and so no field of either can be deleted alone. It reads in the same way every field of each pair of distinct struct type literals the identity of the two types compares in turn: through each field's type, and from there through the element, key, base, parameter and result types of the type literals it reaches, through the method signatures of an interface type literal, and through the type arguments of an instantiated generic type. A field reached there has no reference of its own, as grammar/symbol-ref.md states, so it is never a subject and those reads hold no finding back. It stops at a defined type, whose fields the conversion does not read, because a defined type is identical to itself whatever fields it declares. A pair whose two sides are one struct type literal, the two struct types themselves included, as where one type is declared from the other or the conversion is to the operand's own type, reads nothing, because a field deleted from that literal leaves both sides identical. Where the operand's type or the conversion's type is a type parameter, the struct type its constraint's core type is takes that side's place, and a type parameter operand reads in the same way the fields of each struct type argument an instantiation the program holds gives it, an instantiation made through another generic declaration's own type parameters included. A conversion to any other type, an interface type among them, reads no field under this rule. In TypeScript a comparison of two object references reads no member, because `===` compares identity and never a member. A TypeScript parameter property, a constructor parameter that carries a modifier, declares a member of its class that the constructor writes with its argument, so the member is judged under this rule, and under every rule that reads a class member, as a member the class body declares is. In TypeScript a property of an object literal writes the member of the same name that the literal's contextual type declares, whether or not the compiler checks the literal for excess properties. Where that contextual type is a union or an intersection, the property writes the member of that name on each constituent that declares one. So a literal that a declaration annotates, a function returns, a call passes or a `satisfies` expression checks writes the members of the type it is given. A literal whose type the compiler infers, as one a callback returns to a generic call, writes in the same way the member of each property's name that each object type a value of the literal's type reaches declares, through array elements, type arguments and the parameters and results of functions, so `ids.map((id) => ({ id }))` returned as `Row[]` writes `Row.id`. An object literal handed to a callee the target does not implement has its members read, as grammar/analysis.md states. A store through the value a field or a member holds reads that field or member rather than writing it, because the store lands in storage every other holder of the value reads. In Go that store is an assignment, an increment, a compound assignment or a `delete` whose target is reached through a struct field holding a map, a slice or a pointer. Examples are `s.f[k] = v`, `s.f.x = v` and `delete(s.f, k)`. The same store through a field holding an array or a struct is a store into the field itself. TypeScript holds every object by reference, so an element or property store through a member's value reads the member. The finding names each write position, so the deletion set is visible. Deleting the subject and its writes also deletes every declaration whose only uses they hold. Such a declaration is a type parameter of the subject's declaring type that the subject's type alone names, an import binding the writes alone use, or a declaration whose every use is in the type annotation of the subject or of another subject this rule reports, or in the writes those findings name. The finding's message names each such declaration, and no finding is reported about the declaration itself.",
|
|
195
195
|
defaultSeverity: "deny",
|
|
196
196
|
maxClass: "certain",
|
|
197
197
|
fixability: "deletable",
|