@cplieger/deadset-ts 1.0.0 → 1.2.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.
- package/NOTICE +3 -0
- package/README.md +5 -6
- package/bin/deadset-ts.mjs +24 -0
- package/bin/deadset-ts.ts +2 -1
- package/bin/node-host.ts +28 -0
- package/jsr.json +4 -2
- package/package.json +11 -6
- package/src/config.ts +218 -0
- package/src/discover.ts +198 -0
- package/src/host.ts +29 -0
- package/src/index.ts +29 -1
- package/src/inventory.ts +941 -0
- package/src/kinds.ts +12 -0
- package/src/paths.ts +93 -0
- package/src/position.ts +75 -0
- package/src/print.ts +59 -0
- package/src/ref.ts +179 -0
- package/src/resolve.ts +732 -0
- package/src/run.ts +272 -17
- package/src/schema.ts +401 -0
- package/src/scope.ts +153 -0
- package/src/session.ts +221 -0
- package/src/version.ts +22 -0
package/NOTICE
ADDED
package/README.md
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# deadset-ts
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@cplieger/deadset-ts)
|
|
4
|
-
[](https://jsr.io/@cplieger/deadset-ts)
|
|
5
|
-
[](https://www.bestpractices.dev/projects/PROJECT_ID)
|
|
6
|
-
[](https://scorecard.dev/viewer/?uri=github.com/cplieger/deadset-ts)
|
|
3
|
+
[](https://www.npmjs.com/package/@cplieger/deadset-ts) [](https://jsr.io/@cplieger/deadset-ts)
|
|
7
4
|
|
|
8
5
|
> Deterministic dead-code analysis for TypeScript, class and type members included.
|
|
9
6
|
|
|
@@ -11,7 +8,7 @@ deadset-ts is a command-line analyzer for TypeScript and JavaScript projects. It
|
|
|
11
8
|
|
|
12
9
|
deadset-ts is report-only. It never edits source, and it refuses any `--fix` flag.
|
|
13
10
|
|
|
14
|
-
**Status: pre-release.** This version implements
|
|
11
|
+
**Status: pre-release.** This version implements contract 1.5.0 and ships the analyzer's foundation: one compiler session per run over every `tsconfig` project discovered under the target (the explicit scope, every `tsconfig*.json`, each project's `references`), a fail-closed read of every project's diagnostics, configuration decoding against the contract's closed key list with `print-config` and its provenance, `print-projects`, and the report-only guard. `analyze` and `explain` are not implemented yet and exit with a usage message. The one runtime dependency is the `typescript` package at exactly 7.0.2, installed under the `@typescript/native` name until TypeScript 7.1 ships a compiler API the tooling can share.
|
|
15
12
|
|
|
16
13
|
## Install
|
|
17
14
|
|
|
@@ -19,9 +16,11 @@ deadset-ts is report-only. It never edits source, and it refuses any `--fix` fla
|
|
|
19
16
|
npm i -D @cplieger/deadset-ts
|
|
20
17
|
# or
|
|
21
18
|
npx jsr add -D @cplieger/deadset-ts
|
|
19
|
+
# or run the command without installing it
|
|
20
|
+
npx @cplieger/deadset-ts version
|
|
22
21
|
```
|
|
23
22
|
|
|
24
|
-
The package ships TypeScript source, not compiled JavaScript
|
|
23
|
+
The package ships TypeScript source, not compiled JavaScript, and there is no build step on either side. The `deadset-ts` command runs on plain Node.js 24 or later; its entry strips the types of the package's own source itself, because Node refuses to do that for a file under `node_modules`. The exported API is source too, so a consumer compiles it with its own TypeScript, or imports it under Deno from the JSR package; plain Node cannot import it.
|
|
25
24
|
|
|
26
25
|
## Usage
|
|
27
26
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Node refuses to strip types from a .ts file under node_modules, so the
|
|
3
|
+
// TypeScript entry cannot start from an install. A load hook scoped to this
|
|
4
|
+
// package's own files supplies the stripped source instead.
|
|
5
|
+
// https://nodejs.org/api/typescript.html#type-stripping-in-dependencies
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { registerHooks, stripTypeScriptTypes } from "node:module";
|
|
8
|
+
|
|
9
|
+
const packageRoot = new URL("../", import.meta.url).href;
|
|
10
|
+
|
|
11
|
+
registerHooks({
|
|
12
|
+
load(url, context, nextLoad) {
|
|
13
|
+
if (!url.startsWith(packageRoot) || !url.endsWith(".ts")) {
|
|
14
|
+
return nextLoad(url, context);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
format: "module",
|
|
18
|
+
source: stripTypeScriptTypes(readFileSync(new URL(url), "utf8")),
|
|
19
|
+
shortCircuit: true,
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await import("./deadset-ts.ts");
|
package/bin/deadset-ts.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run } from "../src/run.ts";
|
|
3
|
+
import { nodeHost } from "./node-host.ts";
|
|
3
4
|
|
|
4
|
-
process.exitCode = run(process.argv.slice(2), process.stdout, process.stderr);
|
|
5
|
+
process.exitCode = run(process.argv.slice(2), process.stdout, process.stderr, nodeHost());
|
package/bin/node-host.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import type { DirectoryEntry, Host, PathKind } from "../src/host.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The filesystem of the platform the command runs on.
|
|
6
|
+
*
|
|
7
|
+
* It is the one module that names the platform: everything the analysis is made of
|
|
8
|
+
* takes the filesystem as a parameter, so a consumer compiling the published
|
|
9
|
+
* sources needs the compiler's types and nothing else.
|
|
10
|
+
*/
|
|
11
|
+
export function nodeHost(): Host {
|
|
12
|
+
return {
|
|
13
|
+
workingDirectory: () => process.cwd(),
|
|
14
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
15
|
+
readDirectory: (path): readonly DirectoryEntry[] =>
|
|
16
|
+
readdirSync(path, { withFileTypes: true }).map((entry) => ({
|
|
17
|
+
name: entry.name,
|
|
18
|
+
directory: entry.isDirectory(),
|
|
19
|
+
})),
|
|
20
|
+
kindOf: (path): PathKind => {
|
|
21
|
+
const stat = statSync(path, { throwIfNoEntry: false });
|
|
22
|
+
if (stat === undefined) {
|
|
23
|
+
return "absent";
|
|
24
|
+
}
|
|
25
|
+
return stat.isDirectory() ? "directory" : "file";
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
package/jsr.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/deadset-ts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "GPL-3.0-or-later",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts"
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
"publish": {
|
|
9
9
|
"include": [
|
|
10
10
|
"LICENSE",
|
|
11
|
+
"NOTICE",
|
|
11
12
|
"README.md",
|
|
12
13
|
"bin/**",
|
|
13
14
|
"src/**",
|
|
14
|
-
"jsr.json"
|
|
15
|
+
"jsr.json",
|
|
16
|
+
"package.json"
|
|
15
17
|
],
|
|
16
18
|
"exclude": [
|
|
17
19
|
"src/**/*.test.ts"
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/deadset-ts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Deterministic dead-code analysis for TypeScript, class and type members included",
|
|
7
7
|
"license": "GPL-3.0-or-later",
|
|
8
8
|
"bin": {
|
|
9
|
-
"deadset-ts": "./bin/deadset-ts.
|
|
9
|
+
"deadset-ts": "./bin/deadset-ts.mjs"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./src/index.ts"
|
|
@@ -22,15 +22,18 @@
|
|
|
22
22
|
"lint:prettier": "prettier --check .",
|
|
23
23
|
"lint:fix": "eslint . --fix && prettier --write ."
|
|
24
24
|
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@typescript/native": "npm:typescript@7.0.2"
|
|
27
|
+
},
|
|
25
28
|
"devDependencies": {
|
|
26
29
|
"@eslint/js": "10.0.1",
|
|
27
|
-
"@types/node": "24.13.
|
|
28
|
-
"@typescript/native": "npm:typescript@7.0.2",
|
|
30
|
+
"@types/node": "24.13.5",
|
|
29
31
|
"eslint": "10.10.0",
|
|
30
|
-
"
|
|
32
|
+
"fast-check": "4.10.1",
|
|
33
|
+
"prettier": "3.9.7",
|
|
31
34
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
32
35
|
"typescript-eslint": "8.70.0",
|
|
33
|
-
"vitest": "5.0.
|
|
36
|
+
"vitest": "5.0.1"
|
|
34
37
|
},
|
|
35
38
|
"publishConfig": {
|
|
36
39
|
"access": "public"
|
|
@@ -53,6 +56,8 @@
|
|
|
53
56
|
"cli"
|
|
54
57
|
],
|
|
55
58
|
"files": [
|
|
59
|
+
"LICENSE",
|
|
60
|
+
"NOTICE",
|
|
56
61
|
"bin/",
|
|
57
62
|
"src/",
|
|
58
63
|
"!src/**/*.test.ts",
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { CONTRACT_VERSION } from "./version.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the target is an application, whose every caller is in the analyzed
|
|
5
|
+
* graph, or a library, whose published API has callers outside it.
|
|
6
|
+
*/
|
|
7
|
+
export type TargetKind = "application" | "library";
|
|
8
|
+
|
|
9
|
+
/** The lowest reachability class a finding must reach to be reported. */
|
|
10
|
+
export type Confidence = "certain" | "probable" | "possible";
|
|
11
|
+
|
|
12
|
+
/** Whether declarations in generated files are analyzed. */
|
|
13
|
+
export type GeneratedFiles = "exclude" | "include";
|
|
14
|
+
|
|
15
|
+
/** How a reference from a consumer's test file counts. */
|
|
16
|
+
export type ConsumerTests = "test" | "production";
|
|
17
|
+
|
|
18
|
+
/** One language an analysis covers. */
|
|
19
|
+
export type Language = "go" | "ts";
|
|
20
|
+
|
|
21
|
+
/** What a finding of one issue kind does to a run. */
|
|
22
|
+
export type Severity = "allow" | "warn" | "deny";
|
|
23
|
+
|
|
24
|
+
/** One rendering of the finding list. */
|
|
25
|
+
export type Format = "text" | "json" | "github" | "sarif" | "template";
|
|
26
|
+
|
|
27
|
+
/** The order findings are rendered in. */
|
|
28
|
+
export type Sort = "position" | "size";
|
|
29
|
+
|
|
30
|
+
/** How much of a dead component a rendering names. */
|
|
31
|
+
export type Cascade = "roots" | "full";
|
|
32
|
+
|
|
33
|
+
/** One entry of the build matrix: a configuration the target builds under. */
|
|
34
|
+
export interface BuildConfiguration {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly os: string;
|
|
37
|
+
readonly arch: string;
|
|
38
|
+
readonly tags: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The pair of action delimiters a template is parsed with. Both members are
|
|
43
|
+
* required once a document names the object, so the pair is one setting rather
|
|
44
|
+
* than two.
|
|
45
|
+
*/
|
|
46
|
+
export interface TemplateDelimiters {
|
|
47
|
+
readonly left: string;
|
|
48
|
+
readonly right: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How the analysis is scoped and filtered. */
|
|
52
|
+
export interface Analysis {
|
|
53
|
+
readonly languages: readonly Language[];
|
|
54
|
+
readonly minConfidence: Confidence;
|
|
55
|
+
readonly generatedFiles: GeneratedFiles;
|
|
56
|
+
readonly consumerTests: ConsumerTests;
|
|
57
|
+
readonly configurations: readonly BuildConfiguration[];
|
|
58
|
+
readonly matrixComplete: boolean;
|
|
59
|
+
readonly templateDirs: readonly string[];
|
|
60
|
+
readonly templateDelimiters: TemplateDelimiters;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How findings are rendered. */
|
|
64
|
+
export interface Reporters {
|
|
65
|
+
readonly formats: readonly Format[];
|
|
66
|
+
readonly sort: Sort;
|
|
67
|
+
readonly cascade: Cascade;
|
|
68
|
+
readonly maxFindings: number;
|
|
69
|
+
readonly failOn: Severity;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The section the TypeScript analyzer owns. */
|
|
73
|
+
export interface TSSection {
|
|
74
|
+
readonly testFiles: readonly string[];
|
|
75
|
+
readonly entryFiles: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The resolved configuration: the closed key list of the Contract's
|
|
80
|
+
* configuration schema, decoded, with every default applied.
|
|
81
|
+
*
|
|
82
|
+
* `targetKind` is empty exactly in the configuration every default states, which
|
|
83
|
+
* is not a configuration a run may use: the field has no default and resolution
|
|
84
|
+
* refuses a result that carries none.
|
|
85
|
+
*
|
|
86
|
+
* `severity` is a map rather than an object because its keys come from a
|
|
87
|
+
* document, and an object keyed by document text answers for names it never
|
|
88
|
+
* held. The order it is written in is the emitter's, not the map's.
|
|
89
|
+
*/
|
|
90
|
+
export interface Config {
|
|
91
|
+
readonly contractVersion: string;
|
|
92
|
+
readonly targetKind: TargetKind | "";
|
|
93
|
+
readonly analysis: Analysis;
|
|
94
|
+
readonly consumersComplete: boolean;
|
|
95
|
+
readonly rootPatterns: readonly string[];
|
|
96
|
+
readonly severity: ReadonlyMap<string, Severity>;
|
|
97
|
+
readonly exemptionsDisabled: readonly string[];
|
|
98
|
+
readonly reporters: Reporters;
|
|
99
|
+
readonly ts: TSSection;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The configuration every documented default states. `targetKind` is empty: the
|
|
104
|
+
* field has no default and is never inferred.
|
|
105
|
+
*/
|
|
106
|
+
export function defaultConfig(): Config {
|
|
107
|
+
return {
|
|
108
|
+
contractVersion: CONTRACT_VERSION,
|
|
109
|
+
targetKind: "",
|
|
110
|
+
analysis: {
|
|
111
|
+
languages: [],
|
|
112
|
+
minConfidence: "possible",
|
|
113
|
+
generatedFiles: "exclude",
|
|
114
|
+
consumerTests: "test",
|
|
115
|
+
configurations: [],
|
|
116
|
+
matrixComplete: false,
|
|
117
|
+
templateDirs: [],
|
|
118
|
+
templateDelimiters: { left: "{{", right: "}}" },
|
|
119
|
+
},
|
|
120
|
+
consumersComplete: false,
|
|
121
|
+
rootPatterns: [],
|
|
122
|
+
severity: new Map(),
|
|
123
|
+
exemptionsDisabled: [],
|
|
124
|
+
reporters: {
|
|
125
|
+
formats: ["text"],
|
|
126
|
+
sort: "position",
|
|
127
|
+
cascade: "roots",
|
|
128
|
+
maxFindings: 0,
|
|
129
|
+
failOn: "deny",
|
|
130
|
+
},
|
|
131
|
+
ts: {
|
|
132
|
+
testFiles: ["**/*.test.{ts,tsx,mts,cts}"],
|
|
133
|
+
entryFiles: [],
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Where a resolved setting came from, in the order the sources outrank each other. */
|
|
139
|
+
export type Source = "flag" | "repository" | "central" | "default";
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One setting's source together with the file or flag that supplied it. `label`
|
|
143
|
+
* is empty exactly when `source` is `"default"`.
|
|
144
|
+
*/
|
|
145
|
+
export interface Origin {
|
|
146
|
+
readonly source: Source;
|
|
147
|
+
readonly label: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Renders the origin as the provenance value the Contract declares: `default`,
|
|
152
|
+
* or the source, a colon, a space and the file or flag.
|
|
153
|
+
*/
|
|
154
|
+
export function renderOrigin(origin: Origin): string {
|
|
155
|
+
return origin.source === "default" ? "default" : `${origin.source}: ${origin.label}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The origin of each resolved setting, keyed by the setting's dotted path. A
|
|
160
|
+
* section holds no value of its own and has no entry; the severity object
|
|
161
|
+
* carries one entry per code it names, or the entry `severity` alone when it is
|
|
162
|
+
* empty.
|
|
163
|
+
*/
|
|
164
|
+
export type Provenance = ReadonlyMap<string, Origin>;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The configuration documents one invocation reads, each the document's text or
|
|
168
|
+
* absent. `repositoryLabel` names the document `repository` holds and
|
|
169
|
+
* `centralLabel` the one `central` holds; `flagLabels` names the flag that
|
|
170
|
+
* supplied each setting `flags` names, keyed by dotted setting path.
|
|
171
|
+
*/
|
|
172
|
+
export interface Inputs {
|
|
173
|
+
readonly flags?: string;
|
|
174
|
+
readonly repository?: string;
|
|
175
|
+
readonly central?: string;
|
|
176
|
+
readonly repositoryLabel?: string;
|
|
177
|
+
readonly centralLabel?: string;
|
|
178
|
+
readonly flagLabels?: ReadonlyMap<string, string>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Which refusal a {@link ConfigError} carries. */
|
|
182
|
+
export type ErrorKind =
|
|
183
|
+
/**
|
|
184
|
+
* A document that is not one JSON instance of the closed key list: a syntax
|
|
185
|
+
* error, a value of the wrong type, a value outside the closed set a key
|
|
186
|
+
* declares, or a member written twice at one level.
|
|
187
|
+
*/
|
|
188
|
+
| "malformed"
|
|
189
|
+
/**
|
|
190
|
+
* A key the closed key list does not declare, or a severity key naming a kind
|
|
191
|
+
* whose severity the Contract fixes.
|
|
192
|
+
*/
|
|
193
|
+
| "unimplemented-key"
|
|
194
|
+
/** A resolved configuration no source supplied a target kind for. */
|
|
195
|
+
| "missing-target-kind";
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Every refusal configuration resolution makes. Each maps to the usage exit
|
|
199
|
+
* code, and each is thrown rather than returned: a refusal ends the run, and the
|
|
200
|
+
* one caller that turns it into an exit code is the command line.
|
|
201
|
+
*/
|
|
202
|
+
export class ConfigError extends Error {
|
|
203
|
+
/** Which refusal this is. */
|
|
204
|
+
readonly kind: ErrorKind;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The key or field the message names, spelled as the document spells it. It is
|
|
208
|
+
* empty when the refusal names none.
|
|
209
|
+
*/
|
|
210
|
+
readonly key: string;
|
|
211
|
+
|
|
212
|
+
constructor(kind: ErrorKind, key: string, message: string) {
|
|
213
|
+
super(message);
|
|
214
|
+
this.name = "ConfigError";
|
|
215
|
+
this.kind = kind;
|
|
216
|
+
this.key = key;
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { DiagnosticCategory } from "@typescript/native/unstable/sync";
|
|
2
|
+
import type { Diagnostic } from "@typescript/native/unstable/sync";
|
|
3
|
+
import type { Host } from "./host.ts";
|
|
4
|
+
import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.ts";
|
|
5
|
+
import type { Scope } from "./scope.ts";
|
|
6
|
+
import type { Engine } from "./session.ts";
|
|
7
|
+
|
|
8
|
+
/** Directory names discovery does not descend into. */
|
|
9
|
+
const IGNORED_DIRECTORIES: ReadonlySet<string> = new Set(["node_modules", ".git"]);
|
|
10
|
+
|
|
11
|
+
/** A compiler configuration file discovery recognises under a root. */
|
|
12
|
+
const CONFIG_FILE = /^tsconfig.*\.json$/u;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A run that cannot read what it was asked to analyze. It ends the run with the
|
|
16
|
+
* failure code and no finding list: a configuration file the compiler cannot read,
|
|
17
|
+
* or a project carrying an error, leaves the analysis without the type information
|
|
18
|
+
* every answer rests on, and a partial answer is never printed as a complete one.
|
|
19
|
+
*/
|
|
20
|
+
export class DiscoveryError extends Error {
|
|
21
|
+
/** The diagnostics the compiler reported, in the order it reported them. */
|
|
22
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
23
|
+
|
|
24
|
+
constructor(message: string, diagnostics: readonly Diagnostic[]) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "DiscoveryError";
|
|
27
|
+
this.diagnostics = diagnostics;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether one diagnostic is an error, which is what fails a run closed. */
|
|
32
|
+
export function isError(diagnostic: Diagnostic): boolean {
|
|
33
|
+
return diagnostic.category === DiagnosticCategory.Error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One diagnostic as a line naming its position, its code and its message. */
|
|
37
|
+
export function renderDiagnostic(diagnostic: Diagnostic): string {
|
|
38
|
+
const where = diagnostic.fileName ?? "<no file>";
|
|
39
|
+
return `${where}:${String(diagnostic.pos)}: TS${String(diagnostic.code)}: ${diagnostic.text}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every error a project's three diagnostic sets carry, in the order the compiler
|
|
44
|
+
* reported them: a configuration whose options do not parse, a file whose syntax
|
|
45
|
+
* does not, and a program that does not type-check. A resolved configuration
|
|
46
|
+
* carries no diagnostics of its own, so a configuration written with an unreadable
|
|
47
|
+
* option resolves and is refused here, once its project is open.
|
|
48
|
+
*/
|
|
49
|
+
export function diagnosticErrors(sets: {
|
|
50
|
+
readonly syntactic: readonly Diagnostic[];
|
|
51
|
+
readonly semantic: readonly Diagnostic[];
|
|
52
|
+
readonly configParsing: readonly Diagnostic[];
|
|
53
|
+
}): readonly Diagnostic[] {
|
|
54
|
+
return [...sets.configParsing, ...sets.syntactic, ...sets.semantic].filter(isError);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every compiler configuration file discovery found, and where each came from. */
|
|
58
|
+
export interface Discovery {
|
|
59
|
+
/** The configuration files, in discovery order, each once. */
|
|
60
|
+
readonly configFiles: readonly string[];
|
|
61
|
+
/** The files the scope named, which are the ones discovery read first. */
|
|
62
|
+
readonly fromScope: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The configuration files below `root`, in ascending path order, skipping an
|
|
71
|
+
* ignored directory. A directory that cannot be read is skipped: a tree the
|
|
72
|
+
* analysis may not enter names no project.
|
|
73
|
+
*/
|
|
74
|
+
function configFilesUnder(host: Host, root: string): string[] {
|
|
75
|
+
const found: string[] = [];
|
|
76
|
+
const pending = [root];
|
|
77
|
+
while (pending.length > 0) {
|
|
78
|
+
const dir = pending.pop();
|
|
79
|
+
if (dir === undefined) {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
let entries: readonly { readonly name: string; readonly directory: boolean }[];
|
|
83
|
+
try {
|
|
84
|
+
entries = host.readDirectory(dir);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
89
|
+
const path = joinPath(dir, entry.name);
|
|
90
|
+
if (entry.directory) {
|
|
91
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
92
|
+
pending.push(path);
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (CONFIG_FILE.test(entry.name)) {
|
|
97
|
+
found.push(path);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return found.sort();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The configuration files one configuration's `references` name, resolved against
|
|
106
|
+
* that configuration's own directory. A reference naming a directory names the
|
|
107
|
+
* `tsconfig.json` in it, which is the compiler's own rule.
|
|
108
|
+
*/
|
|
109
|
+
function referencesOf(host: Host, configFile: string): string[] {
|
|
110
|
+
let text: string;
|
|
111
|
+
try {
|
|
112
|
+
text = host.readFile(configFile);
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
let value: unknown;
|
|
117
|
+
try {
|
|
118
|
+
value = JSON.parse(text);
|
|
119
|
+
} catch {
|
|
120
|
+
// What a configuration's contents say is refused by the compiler's own
|
|
121
|
+
// diagnostics, which name the position; nothing is inferred from it here.
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
if (!isRecord(value) || !Array.isArray(value["references"])) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const dir = dirnamePath(configFile);
|
|
128
|
+
const found: string[] = [];
|
|
129
|
+
for (const entry of value["references"] as unknown[]) {
|
|
130
|
+
if (!isRecord(entry)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const path = entry["path"];
|
|
134
|
+
if (typeof path !== "string" || path === "") {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const resolved = isAbsolutePath(path) ? path : joinPath(dir, path);
|
|
138
|
+
found.push(resolved.endsWith(".json") ? resolved : joinPath(resolved, "tsconfig.json"));
|
|
139
|
+
}
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The configuration files the scope names directly: one per module the scope
|
|
145
|
+
* declares whose path names a configuration file rather than a directory. A module
|
|
146
|
+
* naming a directory is reached by the walk over that directory instead.
|
|
147
|
+
*/
|
|
148
|
+
function scopeConfigFiles(host: Host, scope: Scope): string[] {
|
|
149
|
+
return [scope.target, ...scope.consumers]
|
|
150
|
+
.map((module) => module.path)
|
|
151
|
+
.filter((path) => path.endsWith(".json"))
|
|
152
|
+
.map((path) => resolvePath(host.workingDirectory(), path));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Every project one run analyzes, in the order discovery reads them: the paths the
|
|
157
|
+
* scope names, then every compiler configuration under the target root that is not
|
|
158
|
+
* inside an ignored directory, then the configurations each of those references,
|
|
159
|
+
* transitively. Each configuration appears once.
|
|
160
|
+
*
|
|
161
|
+
* A configuration file the compiler cannot read ends the run with the failure code,
|
|
162
|
+
* so a project that would have been analyzed with no type information is never
|
|
163
|
+
* analyzed at all. What a configuration's own contents say is refused once its
|
|
164
|
+
* project is open, by {@link diagnosticErrors} over the three sets a program
|
|
165
|
+
* reports, because a resolution carries no diagnostics.
|
|
166
|
+
*
|
|
167
|
+
* Nothing reads a build output: a project is the files its configuration names, so a
|
|
168
|
+
* package whose sources ship as TypeScript is analyzed as it stands.
|
|
169
|
+
*/
|
|
170
|
+
export function discoverProjects(engine: Engine, host: Host, scope: Scope): Discovery {
|
|
171
|
+
const fromScope = scopeConfigFiles(host, scope);
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
const configFiles: string[] = [];
|
|
174
|
+
const pending = [...fromScope, ...configFilesUnder(host, scope.target.path)];
|
|
175
|
+
while (pending.length > 0) {
|
|
176
|
+
const configFile = pending.shift();
|
|
177
|
+
if (configFile === undefined) {
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
const path = resolvePath(host.workingDirectory(), configFile);
|
|
181
|
+
if (seen.has(path)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
seen.add(path);
|
|
185
|
+
try {
|
|
186
|
+
engine.parseConfigFile(path);
|
|
187
|
+
} catch (error: unknown) {
|
|
188
|
+
throw new DiscoveryError(
|
|
189
|
+
`${path} cannot be read as a compiler configuration: ` +
|
|
190
|
+
(error instanceof Error ? error.message : String(error)),
|
|
191
|
+
[],
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
configFiles.push(path);
|
|
195
|
+
pending.push(...referencesOf(host, path));
|
|
196
|
+
}
|
|
197
|
+
return { configFiles, fromScope };
|
|
198
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** What one path names. */
|
|
2
|
+
export type PathKind = "file" | "directory" | "absent";
|
|
3
|
+
|
|
4
|
+
/** One entry of a directory. */
|
|
5
|
+
export interface DirectoryEntry {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly directory: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The filesystem the analysis reads, and the directory it reads relative paths
|
|
12
|
+
* against.
|
|
13
|
+
*
|
|
14
|
+
* It is a parameter rather than an import because a published module that reads
|
|
15
|
+
* the filesystem directly carries the whole platform's type surface into every
|
|
16
|
+
* consumer's compile, and because the run's own reads are then the one thing a
|
|
17
|
+
* caller can watch. The command line binds it to the platform it runs on; nothing
|
|
18
|
+
* under this module does.
|
|
19
|
+
*/
|
|
20
|
+
export interface Host {
|
|
21
|
+
/** The directory a relative path resolves against. */
|
|
22
|
+
workingDirectory(): string;
|
|
23
|
+
/** The text of one file, or a throw naming why it cannot be read. */
|
|
24
|
+
readFile(path: string): string;
|
|
25
|
+
/** The entries of one directory, or a throw naming why it cannot be read. */
|
|
26
|
+
readDirectory(path: string): readonly DirectoryEntry[];
|
|
27
|
+
/** What one path names, `"absent"` where nothing is there. */
|
|
28
|
+
kindOf(path: string): PathKind;
|
|
29
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1 +1,29 @@
|
|
|
1
|
-
export { run, type Writer } from "./run.ts";
|
|
1
|
+
export { run, SETTING_OPTIONS, type Writer } from "./run.ts";
|
|
2
|
+
export type { DirectoryEntry, Host, PathKind } from "./host.ts";
|
|
3
|
+
export {
|
|
4
|
+
inventory,
|
|
5
|
+
type Inventory,
|
|
6
|
+
type InventoryCost,
|
|
7
|
+
type InventorySymbol,
|
|
8
|
+
type SymbolKind,
|
|
9
|
+
type Visibility,
|
|
10
|
+
} from "./inventory.ts";
|
|
11
|
+
export {
|
|
12
|
+
byPosition,
|
|
13
|
+
positionKey,
|
|
14
|
+
PositionError,
|
|
15
|
+
renderPosition,
|
|
16
|
+
type Position,
|
|
17
|
+
} from "./position.ts";
|
|
18
|
+
export {
|
|
19
|
+
computedComponent,
|
|
20
|
+
isRef,
|
|
21
|
+
nameComponent,
|
|
22
|
+
REF_EXPRESSIONS,
|
|
23
|
+
renderRef,
|
|
24
|
+
type Component,
|
|
25
|
+
type DependencySection,
|
|
26
|
+
type Fragment,
|
|
27
|
+
type Module,
|
|
28
|
+
} from "./ref.ts";
|
|
29
|
+
export { CONTRACT_VERSION, version } from "./version.ts";
|