@cplieger/deadset-ts 1.2.0 → 2.0.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/README.md CHANGED
@@ -8,7 +8,7 @@ deadset-ts is a command-line analyzer for TypeScript and JavaScript projects. It
8
8
 
9
9
  deadset-ts is report-only. It never edits source, and it refuses any `--fix` flag.
10
10
 
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.
11
+ **Status: pre-release.** This version implements contract 1.7.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`, the report-only guard, the symbol inventory of every declaration a project's own files hold down to its class and type members, and the one-pass reference resolution over that inventory. The inventory and the reference pass are library functions the package exports; no verb reports findings yet, so `analyze`, `explain`, `print-roots`, `print-retained` and `describe` 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.
12
12
 
13
13
  ## Install
14
14
 
@@ -20,7 +20,7 @@ npx jsr add -D @cplieger/deadset-ts
20
20
  npx @cplieger/deadset-ts version
21
21
  ```
22
22
 
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.
23
+ The npm package carries the command and the library as JavaScript that the compiler emits at publish, so `npx deadset-ts` and `import("@cplieger/deadset-ts")` both run on plain Node.js 24 or later, with no loader and no flag. It carries the TypeScript source as well, which is what a TypeScript consumer's own compiler resolves. That consumer sets `allowImportingTsExtensions`, because the source names each import with its `.ts` extension. The JSR package ships source only, and Deno compiles it.
24
24
 
25
25
  ## Usage
26
26
 
@@ -42,17 +42,56 @@ npx deadset-ts version
42
42
 
43
43
  ## Requirements
44
44
 
45
- - Node.js 24 or later, for the type stripping that runs the shipped source.
45
+ - Node.js 24 or later. The npm command is JavaScript the compiler emits at publish, so it runs with no loader and no flag; a clone of this repository runs the same command from its TypeScript sources through Node's own type stripping, which is what needs the version.
46
46
  - TypeScript 7. The analyzer is written against the `typescript` package at exactly version 7.0.2 and its `unstable/*` API. It does not run on TypeScript 6, whose compiler API TypeScript 7 removed, and it does not fall back to it.
47
47
  - Linux. Other platforms are untested.
48
48
 
49
49
  ## API
50
50
 
51
- The package also exports the command line as a function, for callers that embed it:
51
+ The package exports the command line and the analyzer's own stages, for callers that embed them. Every one of them reads the platform through a `Host`: the filesystem, the directory relative paths resolve against, and the version of the package the analyzer was installed from. `bin/node-host.ts` is the Node one the command line binds; a caller embedding the analyzer, or running it on another platform, supplies its own and supplies its own version with it.
52
52
 
53
- - `run(args, out, err)`: runs the command line over `args` (the arguments after the program name), writing to the two `Writer` streams, and returns the exit code. It never exits the process.
53
+ The command line:
54
+
55
+ - `run(args, out, err, host, openClient?)`: runs the command line over `args` (the arguments after the program name), writing to the two `Writer` streams, and returns the exit code. It never exits the process.
56
+ - `SETTING_OPTIONS`: the configuration setting each command-line option supplies, by option name.
54
57
  - `Writer`: `{ write(text: string): void }`. `process.stdout` and `process.stderr` satisfy it.
55
58
 
59
+ The platform:
60
+
61
+ - `Host`: the filesystem a run reads, the directory it reads relative paths against, and the analyzer's own version.
62
+ - `DirectoryEntry`, `PathKind`: one entry of a directory read, and what one path names.
63
+
64
+ The symbol inventory:
65
+
66
+ - `inventory(project, host, targetRoot)`: every declaration one project's own files hold, in position order, down to class and type members.
67
+ - `nodeKey(file, node)`: the key one declaration is recorded under.
68
+ - `Inventory`, `InventorySymbol`, `InventoryCost`, `SymbolKind`, `Visibility`: one project's inventory, one declaration of it, what reading it cost, and the two vocabularies a declaration is described by.
69
+
70
+ The reference pass:
71
+
72
+ - `references(project, held, targetRoot, options)`: every reference from one project's own files to a declaration of its inventory, in position order, with the rules that classified files as test files.
73
+ - `DEFAULT_BATCH_CAP`: the batch size a run takes when the configuration names none.
74
+ - `Reference`, `References`, `ReferenceOptions`, `ReferenceCost`, `TestFileRule`, `Resolution`, `Use`: one reference, one project's set of them, how a run resolves them, what that cost, one test-file rule, which accessor answered a reference, and whether it reads or writes.
75
+
76
+ Positions:
77
+
78
+ - `renderPosition(file, root, offset)`: one compiler offset as a path, a line and a column, the column counting UTF-16 code units.
79
+ - `positionKey(position)`, `byPosition(a, b)`: the string one position is keyed by, and the order positions are reported in.
80
+ - `PositionError`: the refusal for a position that cannot be rendered against the target root.
81
+ - `Position`: one rendered position.
82
+
83
+ Stable symbol references:
84
+
85
+ - `renderRef(module, fragment)`: one symbol's stable reference, in the canonical spelling the contract's grammar fixes.
86
+ - `nameComponent(name)`, `computedComponent(text)`: the canonical spelling of one component of that reference, from a declared name or from a computed key.
87
+ - `isRef(value)`: whether one string is a reference of this language's grammar.
88
+ - `REF_EXPRESSIONS`: one regular expression per form the grammar defines.
89
+ - `Module`, `Component`, `Fragment`, `DependencySection`: the module half of a reference, one component of its fragment, what the fragment names, and the manifest section a dependency was declared in.
90
+
91
+ Versions:
92
+
93
+ - `CONTRACT_VERSION`: the contract version this analyzer implements.
94
+
56
95
  ## Contributing
57
96
 
58
97
  See [CONTRIBUTING.md](https://github.com/cplieger/.github/blob/main/CONTRIBUTING.md).
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { run } from "../src/run.ts";
3
- import { nodeHost } from "./node-host.ts";
4
-
2
+ import { run } from "../src/run.js";
3
+ import { nodeHost } from "./node-host.js";
5
4
  process.exitCode = run(process.argv.slice(2), process.stdout, process.stderr, nodeHost());
@@ -0,0 +1,74 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** The name the manifest of this package declares. */
5
+ const PACKAGE_NAME = "@cplieger/deadset-ts";
6
+ /** The version the walk found, read once per process. */
7
+ let found;
8
+ /**
9
+ * One manifest, or undefined where nothing readable is at that path: a file that
10
+ * cannot be read or parsed is not one that declares this package, so the walk
11
+ * passes it and the failure is the search's.
12
+ */
13
+ function manifestAt(path) {
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(readFileSync(path, "utf8"));
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ return typeof parsed === "object" && parsed !== null ? parsed : undefined;
22
+ }
23
+ /**
24
+ * The version of the package this file is part of, from the nearest manifest at
25
+ * or above it that declares this package: a checkout resolves this file to the
26
+ * manifest beside it, the emitted tree resolves the emitted copy to the same
27
+ * file two directories up, and neither depth is written down. A manifest
28
+ * declaring something else is not this package's, so the walk continues past it
29
+ * rather than reporting the version of whatever the command was installed into.
30
+ */
31
+ function readVersion() {
32
+ const from = dirname(fileURLToPath(import.meta.url));
33
+ let at = from;
34
+ for (;;) {
35
+ const manifest = manifestAt(join(at, "package.json"));
36
+ if (manifest?.name === PACKAGE_NAME && typeof manifest.version === "string") {
37
+ return manifest.version;
38
+ }
39
+ const up = dirname(at);
40
+ if (up === at) {
41
+ throw new Error(`no package.json declaring ${PACKAGE_NAME} with a version, from ${from} up to ${at}`);
42
+ }
43
+ at = up;
44
+ }
45
+ }
46
+ /**
47
+ * The filesystem of the platform the command runs on, and the version of the
48
+ * package it was installed from.
49
+ *
50
+ * It is the one module that names the platform: everything the analysis is made of
51
+ * takes the filesystem as a parameter, so a consumer compiling the published
52
+ * sources needs the compiler's types and nothing else.
53
+ */
54
+ export function nodeHost() {
55
+ return {
56
+ workingDirectory: () => process.cwd(),
57
+ readFile: (path) => readFileSync(path, "utf8"),
58
+ readDirectory: (path) => readdirSync(path, { withFileTypes: true }).map((entry) => ({
59
+ name: entry.name,
60
+ directory: entry.isDirectory(),
61
+ })),
62
+ kindOf: (path) => {
63
+ const stat = statSync(path, { throwIfNoEntry: false });
64
+ if (stat === undefined) {
65
+ return "absent";
66
+ }
67
+ return stat.isDirectory() ? "directory" : "file";
68
+ },
69
+ analyzerVersion: () => {
70
+ found ??= readVersion();
71
+ return found;
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,63 @@
1
+ import { CONTRACT_VERSION } from "./version.js";
2
+ /**
3
+ * The configuration every documented default states. `targetKind` is empty: the
4
+ * field has no default and is never inferred.
5
+ */
6
+ export function defaultConfig() {
7
+ return {
8
+ contractVersion: CONTRACT_VERSION,
9
+ targetKind: "",
10
+ analysis: {
11
+ languages: [],
12
+ minConfidence: "possible",
13
+ generatedFiles: "exclude",
14
+ consumerTests: "test",
15
+ configurations: [],
16
+ matrixComplete: false,
17
+ templateDirs: [],
18
+ templateDelimiters: { left: "{{", right: "}}" },
19
+ },
20
+ consumersComplete: false,
21
+ rootPatterns: [],
22
+ severity: new Map(),
23
+ exemptionsDisabled: [],
24
+ reporters: {
25
+ formats: ["text"],
26
+ sort: "position",
27
+ cascade: "roots",
28
+ maxFindings: 0,
29
+ failOn: "deny",
30
+ },
31
+ ts: {
32
+ testFiles: ["**/*.test.{ts,tsx,mts,cts}"],
33
+ entryFiles: [],
34
+ },
35
+ };
36
+ }
37
+ /**
38
+ * Renders the origin as the provenance value the Contract declares: `default`,
39
+ * or the source, a colon, a space and the file or flag.
40
+ */
41
+ export function renderOrigin(origin) {
42
+ return origin.source === "default" ? "default" : `${origin.source}: ${origin.label}`;
43
+ }
44
+ /**
45
+ * Every refusal configuration resolution makes. Each maps to the usage exit
46
+ * code, and each is thrown rather than returned: a refusal ends the run, and the
47
+ * one caller that turns it into an exit code is the command line.
48
+ */
49
+ export class ConfigError extends Error {
50
+ /** Which refusal this is. */
51
+ kind;
52
+ /**
53
+ * The key or field the message names, spelled as the document spells it. It is
54
+ * empty when the refusal names none.
55
+ */
56
+ key;
57
+ constructor(kind, key, message) {
58
+ super(message);
59
+ this.name = "ConfigError";
60
+ this.kind = kind;
61
+ this.key = key;
62
+ }
63
+ }
@@ -0,0 +1,171 @@
1
+ import { DiagnosticCategory } from "@typescript/native/unstable/sync";
2
+ import { dirnamePath, isAbsolutePath, joinPath, resolvePath } from "./paths.js";
3
+ /** Directory names discovery does not descend into. */
4
+ const IGNORED_DIRECTORIES = new Set(["node_modules", ".git"]);
5
+ /** A compiler configuration file discovery recognises under a root. */
6
+ const CONFIG_FILE = /^tsconfig.*\.json$/u;
7
+ /**
8
+ * A run that cannot read what it was asked to analyze. It ends the run with the
9
+ * failure code and no finding list: a configuration file the compiler cannot read,
10
+ * or a project carrying an error, leaves the analysis without the type information
11
+ * every answer rests on, and a partial answer is never printed as a complete one.
12
+ */
13
+ export class DiscoveryError extends Error {
14
+ /** The diagnostics the compiler reported, in the order it reported them. */
15
+ diagnostics;
16
+ constructor(message, diagnostics) {
17
+ super(message);
18
+ this.name = "DiscoveryError";
19
+ this.diagnostics = diagnostics;
20
+ }
21
+ }
22
+ /** Whether one diagnostic is an error, which is what fails a run closed. */
23
+ export function isError(diagnostic) {
24
+ return diagnostic.category === DiagnosticCategory.Error;
25
+ }
26
+ /** One diagnostic as a line naming its position, its code and its message. */
27
+ export function renderDiagnostic(diagnostic) {
28
+ const where = diagnostic.fileName ?? "<no file>";
29
+ return `${where}:${String(diagnostic.pos)}: TS${String(diagnostic.code)}: ${diagnostic.text}`;
30
+ }
31
+ /**
32
+ * Every error a project's three diagnostic sets carry, in the order the compiler
33
+ * reported them: a configuration whose options do not parse, a file whose syntax
34
+ * does not, and a program that does not type-check. A resolved configuration
35
+ * carries no diagnostics of its own, so a configuration written with an unreadable
36
+ * option resolves and is refused here, once its project is open.
37
+ */
38
+ export function diagnosticErrors(sets) {
39
+ return [...sets.configParsing, ...sets.syntactic, ...sets.semantic].filter(isError);
40
+ }
41
+ function isRecord(value) {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+ /**
45
+ * The configuration files below `root`, in ascending path order, skipping an
46
+ * ignored directory. A directory that cannot be read is skipped: a tree the
47
+ * analysis may not enter names no project.
48
+ */
49
+ function configFilesUnder(host, root) {
50
+ const found = [];
51
+ const pending = [root];
52
+ while (pending.length > 0) {
53
+ const dir = pending.pop();
54
+ if (dir === undefined) {
55
+ break;
56
+ }
57
+ let entries;
58
+ try {
59
+ entries = host.readDirectory(dir);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : 1))) {
65
+ const path = joinPath(dir, entry.name);
66
+ if (entry.directory) {
67
+ if (!IGNORED_DIRECTORIES.has(entry.name)) {
68
+ pending.push(path);
69
+ }
70
+ continue;
71
+ }
72
+ if (CONFIG_FILE.test(entry.name)) {
73
+ found.push(path);
74
+ }
75
+ }
76
+ }
77
+ return found.sort();
78
+ }
79
+ /**
80
+ * The configuration files one configuration's `references` name, resolved against
81
+ * that configuration's own directory. A reference naming a directory names the
82
+ * `tsconfig.json` in it, which is the compiler's own rule.
83
+ */
84
+ function referencesOf(host, configFile) {
85
+ let text;
86
+ try {
87
+ text = host.readFile(configFile);
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ let value;
93
+ try {
94
+ value = JSON.parse(text);
95
+ }
96
+ catch {
97
+ // What a configuration's contents say is refused by the compiler's own
98
+ // diagnostics, which name the position; nothing is inferred from it here.
99
+ return [];
100
+ }
101
+ if (!isRecord(value) || !Array.isArray(value["references"])) {
102
+ return [];
103
+ }
104
+ const dir = dirnamePath(configFile);
105
+ const found = [];
106
+ for (const entry of value["references"]) {
107
+ if (!isRecord(entry)) {
108
+ continue;
109
+ }
110
+ const path = entry["path"];
111
+ if (typeof path !== "string" || path === "") {
112
+ continue;
113
+ }
114
+ const resolved = isAbsolutePath(path) ? path : joinPath(dir, path);
115
+ found.push(resolved.endsWith(".json") ? resolved : joinPath(resolved, "tsconfig.json"));
116
+ }
117
+ return found;
118
+ }
119
+ /**
120
+ * The configuration files the scope names directly: one per module the scope
121
+ * declares whose path names a configuration file rather than a directory. A module
122
+ * naming a directory is reached by the walk over that directory instead.
123
+ */
124
+ function scopeConfigFiles(host, scope) {
125
+ return [scope.target, ...scope.consumers]
126
+ .map((module) => module.path)
127
+ .filter((path) => path.endsWith(".json"))
128
+ .map((path) => resolvePath(host.workingDirectory(), path));
129
+ }
130
+ /**
131
+ * Every project one run analyzes, in the order discovery reads them: the paths the
132
+ * scope names, then every compiler configuration under the target root that is not
133
+ * inside an ignored directory, then the configurations each of those references,
134
+ * transitively. Each configuration appears once.
135
+ *
136
+ * A configuration file the compiler cannot read ends the run with the failure code,
137
+ * so a project that would have been analyzed with no type information is never
138
+ * analyzed at all. What a configuration's own contents say is refused once its
139
+ * project is open, by {@link diagnosticErrors} over the three sets a program
140
+ * reports, because a resolution carries no diagnostics.
141
+ *
142
+ * Nothing reads a build output: a project is the files its configuration names, so a
143
+ * package whose sources ship as TypeScript is analyzed as it stands.
144
+ */
145
+ export function discoverProjects(engine, host, scope) {
146
+ const fromScope = scopeConfigFiles(host, scope);
147
+ const seen = new Set();
148
+ const configFiles = [];
149
+ const pending = [...fromScope, ...configFilesUnder(host, scope.target.path)];
150
+ while (pending.length > 0) {
151
+ const configFile = pending.shift();
152
+ if (configFile === undefined) {
153
+ break;
154
+ }
155
+ const path = resolvePath(host.workingDirectory(), configFile);
156
+ if (seen.has(path)) {
157
+ continue;
158
+ }
159
+ seen.add(path);
160
+ try {
161
+ engine.parseConfigFile(path);
162
+ }
163
+ catch (error) {
164
+ throw new DiscoveryError(`${path} cannot be read as a compiler configuration: ` +
165
+ (error instanceof Error ? error.message : String(error)), []);
166
+ }
167
+ configFiles.push(path);
168
+ pending.push(...referencesOf(host, path));
169
+ }
170
+ return { configFiles, fromScope };
171
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ export { run, SETTING_OPTIONS } from "./run.js";
2
+ export { inventory, nodeKey, } from "./inventory.js";
3
+ export { DEFAULT_BATCH_CAP, references, } from "./references.js";
4
+ export { byPosition, positionKey, PositionError, renderPosition, } from "./position.js";
5
+ export { computedComponent, isRef, nameComponent, REF_EXPRESSIONS, renderRef, } from "./ref.js";
6
+ export { CONTRACT_VERSION } from "./version.js";