ts-axi 0.1.2 → 0.1.4

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.
@@ -1,13 +1,16 @@
1
1
  // Project discovery and the two engine paths (ADR 0001): a syntactic index
2
2
  // over `ts.createSourceFile`, and a LanguageService for semantic queries.
3
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import ts from "typescript";
6
6
  import { usageError } from "./errors.js";
7
7
  import { classifyReferenceGroups } from "./references.js";
8
8
  import { collectDeclarations } from "./symbols.js";
9
+ import { byDepthThenPath, listFiles } from "./walk.js";
10
+ import { detectWorkspace, hasMultiProjectSignal, isMultiProject } from "./workspace.js";
9
11
  export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
10
- const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git"]);
12
+ /** An implicit project above this many files is analysed by importer subgraph, not as a whole. */
13
+ export const IMPLICIT_SCOPE_THRESHOLD = 3_000;
11
14
  export const IMPLICIT_WARNING = "no tsconfig.json found - implicit project";
12
15
  function real(path) {
13
16
  try {
@@ -65,29 +68,9 @@ export function discoverTsconfig(startDir, options = {}) {
65
68
  function isSourceFile(name) {
66
69
  return SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !/\.d\.[mc]?ts$/.test(name);
67
70
  }
68
- /** Every source file below `root`, minus node_modules/dist/build/.git and `.d.ts`. */
71
+ /** Every source file below `root` from the shared listing (`src/walk.ts`: git, else fs walk), minus `.d.ts`. */
69
72
  export function walkSourceFiles(root) {
70
- const files = [];
71
- const visit = (dir) => {
72
- let entries;
73
- try {
74
- entries = readdirSync(dir, { withFileTypes: true });
75
- }
76
- catch {
77
- return;
78
- }
79
- for (const entry of entries) {
80
- if (entry.isDirectory()) {
81
- if (!SKIP_DIRS.has(entry.name))
82
- visit(join(dir, entry.name));
83
- }
84
- else if (entry.isFile() && isSourceFile(entry.name)) {
85
- files.push(join(dir, entry.name));
86
- }
87
- }
88
- };
89
- visit(root);
90
- return files.sort();
73
+ return listFiles(root, isSourceFile).files;
91
74
  }
92
75
  export const IMPLICIT_COMPILER_OPTIONS = {
93
76
  allowJs: true,
@@ -101,6 +84,52 @@ export const IMPLICIT_COMPILER_OPTIONS = {
101
84
  jsx: ts.JsxEmit.Preserve,
102
85
  resolveJsonModule: true,
103
86
  };
87
+ function readTsconfig(path) {
88
+ const read = ts.readConfigFile(path, ts.sys.readFile);
89
+ if (read.error)
90
+ return undefined;
91
+ return ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(path), undefined, path);
92
+ }
93
+ function sourceNames(parsed) {
94
+ return parsed.fileNames
95
+ .map((f) => resolve(f))
96
+ .filter((f) => !f.split(sep).includes("node_modules") && SOURCE_EXTENSIONS.some((ext) => f.endsWith(ext)));
97
+ }
98
+ /**
99
+ * The configs a tsconfig stands for: itself, or - when it has `references` and no files of its
100
+ * own (an Nx project shell) - its referenced configs, recursively and deduplicated.
101
+ */
102
+ export function expandTsconfig(path) {
103
+ const result = [];
104
+ const seen = new Set();
105
+ const visit = (file) => {
106
+ const abs = real(file);
107
+ if (seen.has(abs))
108
+ return;
109
+ seen.add(abs);
110
+ const parsed = readTsconfig(abs);
111
+ if (!parsed)
112
+ return;
113
+ const fileNames = sourceNames(parsed);
114
+ const refs = parsed.projectReferences ?? [];
115
+ if (fileNames.length === 0 && refs.length > 0) {
116
+ for (const ref of refs) {
117
+ const target = ts.resolveProjectReferencePath(ref);
118
+ if (existsSync(target))
119
+ visit(target);
120
+ }
121
+ return;
122
+ }
123
+ result.push({ path: abs, fileNames, options: parsed.options });
124
+ };
125
+ visit(path);
126
+ return result;
127
+ }
128
+ /** One config's options for a union: the first config's, with every config's `types` merged. */
129
+ function unionOptions(configs) {
130
+ const types = configs.some((c) => c.options.types === undefined) ? undefined : [...new Set(configs.flatMap((c) => c.options.types ?? []))];
131
+ return { ...configs[0].options, ...(types ? { types } : {}) };
132
+ }
104
133
  /** Resolve the Project's file list and compiler options. Cheap: reads directories, parses no source. */
105
134
  export function loadProject(root, location) {
106
135
  if (!location.path) {
@@ -110,11 +139,14 @@ export function loadProject(root, location) {
110
139
  if (read.error) {
111
140
  throw usageError(`cannot read ${location.path} - ${ts.flattenDiagnosticMessageText(read.error.messageText, " ")}`);
112
141
  }
113
- const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(location.path), undefined, location.path);
114
- const fileNames = parsed.fileNames
115
- .map((f) => resolve(f))
116
- .filter((f) => !f.split(sep).includes("node_modules") && SOURCE_EXTENSIONS.some((ext) => f.endsWith(ext)));
117
- return { root, tsconfig: location.path, implicit: false, fileNames, options: { ...parsed.options, noEmit: true } };
142
+ const configs = expandTsconfig(location.path);
143
+ const self = configs.length === 1 && configs[0].path === real(location.path);
144
+ if (self || configs.length === 0) {
145
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(location.path), undefined, location.path);
146
+ return { root, tsconfig: location.path, implicit: false, fileNames: sourceNames(parsed), options: { ...parsed.options, noEmit: true } };
147
+ }
148
+ const fileNames = [...new Set(configs.flatMap((c) => c.fileNames))].sort();
149
+ return { root, tsconfig: location.path, implicit: false, fileNames, options: { ...unionOptions(configs), noEmit: true }, referenced: configs.length };
118
150
  }
119
151
  function scriptKind(file) {
120
152
  if (file.endsWith(".tsx"))
@@ -148,47 +180,25 @@ const OUTSIDE_WALK_CAP = 20_000;
148
180
  const OUTSIDE_WALK_MS = 600;
149
181
  /**
150
182
  * Declarations matching `query` in source files below the repo root that are not in the Project
151
- * (syntactic, bounded by file count and time; stops after `max` hits).
183
+ * (syntactic; the fs fallback is bounded by file count and time; at most `max` hits, shallowest paths first).
152
184
  */
153
185
  export function findOutsideProject(index, query, max = 3) {
154
186
  const inProject = new Set(index.files);
155
- const deadline = Date.now() + OUTSIDE_WALK_MS;
187
+ const { files } = listFiles(index.root, isSourceFile, { maxFiles: OUTSIDE_WALK_CAP, deadline: Date.now() + OUTSIDE_WALK_MS });
156
188
  const hits = [];
157
- const stack = [index.root];
158
- let visited = 0;
159
- while (stack.length > 0 && hits.length < max && visited < OUTSIDE_WALK_CAP && Date.now() <= deadline) {
160
- const dir = stack.pop();
161
- let entries;
162
- try {
163
- entries = readdirSync(dir, { withFileTypes: true });
164
- }
165
- catch {
189
+ for (const path of files.sort((a, b) => byDepthThenPath(index.rel(a), index.rel(b)))) {
190
+ if (inProject.has(path))
166
191
  continue;
167
- }
168
- for (const entry of entries.sort((a, b) => b.name.localeCompare(a.name))) {
169
- const path = join(dir, entry.name);
170
- if (entry.isDirectory()) {
171
- if (!SKIP_DIRS.has(entry.name))
172
- stack.push(path);
173
- continue;
174
- }
175
- if (!entry.isFile() || !isSourceFile(entry.name) || inProject.has(path))
176
- continue;
177
- visited++;
178
- const text = index.text(path);
179
- if (text === undefined || !text.includes(query.name))
180
- continue;
181
- for (const decl of index.declarations(path)) {
182
- if (declMatches(decl, query))
183
- hits.push(decl);
184
- if (hits.length >= max)
185
- break;
186
- }
187
- if (hits.length >= max)
188
- break;
189
- }
192
+ const text = index.text(path);
193
+ if (text === undefined || !text.includes(query.name))
194
+ continue;
195
+ for (const decl of index.declarations(path))
196
+ if (declMatches(decl, query))
197
+ hits.push(decl);
198
+ if (hits.length >= max)
199
+ break;
190
200
  }
191
- return hits.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
201
+ return hits.slice(0, max).sort((a, b) => byDepthThenPath(a.file, b.file) || a.line - b.line);
192
202
  }
193
203
  /** Nearest `tsconfig.json` from the directory of `abs` up to `root` (inclusive). */
194
204
  export function nearestTsconfig(abs, root) {
@@ -268,19 +278,21 @@ export function createSyntacticIndex(root, project) {
268
278
  return index;
269
279
  }
270
280
  const FULL_DIAGNOSTICS_LIMIT = 300;
271
- export function createSemanticProject(index) {
281
+ export function createSemanticProject(index, scope) {
272
282
  const started = performance.now();
273
283
  const { project } = index;
274
- const fileNames = project.fileNames;
284
+ const fileNames = scope?.rootNames ?? project.fileNames;
285
+ const options = scope?.options ?? project.options;
275
286
  const snapshots = new Map();
276
- const currentDirectory = project.tsconfig ? dirname(project.tsconfig) : index.root;
287
+ const currentDirectory = scope?.currentDirectory ?? (project.tsconfig ? dirname(project.tsconfig) : index.root);
288
+ const indexed = new Set(index.files);
277
289
  const host = {
278
- getCompilationSettings: () => project.options,
290
+ getCompilationSettings: () => options,
279
291
  getScriptFileNames: () => fileNames,
280
292
  getScriptVersion: () => "1",
281
293
  getScriptSnapshot(file) {
282
294
  if (!snapshots.has(file)) {
283
- const text = index.files.includes(file) ? index.text(file) : ts.sys.readFile(file);
295
+ const text = indexed.has(file) ? index.text(file) : ts.sys.readFile(file);
284
296
  snapshots.set(file, text === undefined ? undefined : ts.ScriptSnapshot.fromString(text));
285
297
  }
286
298
  return snapshots.get(file);
@@ -513,7 +525,8 @@ export function createSemanticProject(index) {
513
525
  return service.getQuickInfoAtPosition(absFile, pos);
514
526
  },
515
527
  diagnosticsCount(touched = []) {
516
- const files = projectFiles.length <= FULL_DIAGNOSTICS_LIMIT ? projectFiles : touched.map((f) => index.abs(f));
528
+ // A subgraph program mixes projects under one set of options: count the touched files only.
529
+ const files = !scope && projectFiles.length <= FULL_DIAGNOSTICS_LIMIT ? projectFiles : touched.map((f) => index.abs(f));
517
530
  let count = 0;
518
531
  for (const file of files) {
519
532
  const sf = program.getSourceFile(file);
@@ -526,7 +539,21 @@ export function createSemanticProject(index) {
526
539
  elapsedMs: () => Math.round(performance.now() - started),
527
540
  };
528
541
  }
529
- /** Repo root + tsconfig discovery + syntactic index in one call. */
542
+ /** The workspace as one file set; each semantic command scopes its own program from it. */
543
+ function scopedProject(root, workspace, implicit) {
544
+ return {
545
+ root,
546
+ tsconfig: null,
547
+ implicit,
548
+ fileNames: workspace.sourceFiles,
549
+ options: IMPLICIT_COMPILER_OPTIONS,
550
+ scope: { workspace, kind: implicit ? "implicit" : workspace.kind },
551
+ };
552
+ }
553
+ /**
554
+ * Repo root + tsconfig discovery + syntactic index in one call. Without `-P` / `.ts-axi.json`
555
+ * `project`, a multi-project workspace (or a large implicit project) becomes one scoped file set.
556
+ */
530
557
  export function openProject(cwd, options = {}) {
531
558
  const root = findRepoRoot(cwd);
532
559
  const location = discoverTsconfig(options.startDir ?? cwd, {
@@ -536,6 +563,16 @@ export function openProject(cwd, options = {}) {
536
563
  settingsRoot: options.settingsRoot ?? root,
537
564
  ...(existsSync(join(root, ".git")) ? { stopAt: root } : {}),
538
565
  });
539
- const index = createSyntacticIndex(root, loadProject(root, location));
540
- return { cwd, root, location, index, ...(location.implicit ? { warning: IMPLICIT_WARNING } : {}) };
566
+ const explicit = options.override !== undefined || options.settingsProject !== undefined;
567
+ let project;
568
+ if (!explicit && (location.implicit || hasMultiProjectSignal(root))) {
569
+ const workspace = detectWorkspace(root);
570
+ if (isMultiProject(workspace.kind))
571
+ project = scopedProject(root, workspace, false);
572
+ else if (location.implicit && workspace.sourceFiles.length > IMPLICIT_SCOPE_THRESHOLD)
573
+ project = scopedProject(root, workspace, true);
574
+ }
575
+ project ??= loadProject(root, location);
576
+ const index = createSyntacticIndex(root, project);
577
+ return { cwd, root, location, index, ...(project.implicit ? { warning: IMPLICIT_WARNING } : {}) };
541
578
  }
@@ -0,0 +1,119 @@
1
+ // Scoped program for semantic commands in a workspace: seed files + their importers (syntactic
2
+ // importer index), compiled with the seed's own tsconfig plus the workspace `paths`.
3
+ import { dirname, join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import ts from "typescript";
6
+ import { buildImporterIndex, createResolver, importerClosure } from "./importers.js";
7
+ import { createSemanticProject, expandTsconfig, IMPLICIT_COMPILER_OPTIONS, nearestTsconfig, } from "./project.js";
8
+ import { IMPORTER_MAX_FILES, IMPORTER_MAX_MS } from "./importers.js";
9
+ import { kindLabel, readJsonc } from "./workspace.js";
10
+ /** Importer hops: refs/callers need the declaring file's importers (barrels free), impact two levels. */
11
+ export const REFS_HOPS = 1;
12
+ export const IMPACT_HOPS = 2;
13
+ const indexes = new WeakMap();
14
+ /** The workspace importer index (in memory for this process only); `extra` = paths that resolve although absent (deleted files). */
15
+ export function importerIndexFor(index, workspace, extra = []) {
16
+ const key = [...extra].sort().join("\n");
17
+ let byKey = indexes.get(workspace);
18
+ if (!byKey)
19
+ indexes.set(workspace, (byKey = new Map()));
20
+ let built = byKey.get(key);
21
+ if (!built) {
22
+ const resolveSpec = createResolver(workspace, new Set([...workspace.sourceFiles, ...extra]));
23
+ built = buildImporterIndex(workspace.sourceFiles, (f) => index.text(f), resolveSpec, { maxFiles: IMPORTER_MAX_FILES, maxMs: IMPORTER_MAX_MS });
24
+ byKey.set(key, built);
25
+ }
26
+ return built;
27
+ }
28
+ /** Options that would make a subgraph program error on files outside one project's layout. */
29
+ const DROPPED_OPTIONS = ["composite", "incremental", "tsBuildInfoFile", "rootDir", "rootDirs", "outDir", "outFile", "declaration", "declarationMap", "emitDeclarationOnly", "sourceMap"];
30
+ /** The seed's nearest tsconfig (its referenced config that includes the seed, for a references-only shell) + workspace `paths`. */
31
+ export function compilerOptionsFor(seed, workspace) {
32
+ let options;
33
+ const tsconfig = nearestTsconfig(seed, workspace.root);
34
+ if (tsconfig) {
35
+ const configs = expandTsconfig(tsconfig);
36
+ const picked = (configs.find((c) => c.fileNames.includes(seed)) ?? configs[0])?.options;
37
+ // Importers include spec files: give them the spec config's globals too.
38
+ const types = configs.some((c) => c.options.types === undefined) ? undefined : [...new Set(configs.flatMap((c) => c.options.types ?? []))];
39
+ options = picked && types ? { ...picked, types } : picked;
40
+ }
41
+ if (!options) {
42
+ const base = join(workspace.root, "tsconfig.base.json");
43
+ const raw = existsSync(base) ? readJsonc(base)?.["compilerOptions"] : undefined;
44
+ options = raw ? { ...IMPLICIT_COMPILER_OPTIONS, ...ts.convertCompilerOptionsFromJson(raw, dirname(base), base).options } : { ...IMPLICIT_COMPILER_OPTIONS };
45
+ }
46
+ const merged = { ...options, noEmit: true, skipLibCheck: true };
47
+ for (const key of DROPPED_OPTIONS)
48
+ delete merged[key];
49
+ if (!merged.paths && workspace.paths) {
50
+ merged.paths = workspace.paths.paths;
51
+ // Internal option TypeScript sets from the tsconfig that declares `paths`; avoids the deprecated baseUrl.
52
+ merged["pathsBasePath"] = workspace.paths.base;
53
+ }
54
+ // Workspace packages resolve to their sources, not to a built `dist` behind a node_modules link.
55
+ const resolveSpec = createResolver({ packages: workspace.packages }, new Set(workspace.sourceFiles));
56
+ const packagePaths = {};
57
+ for (const pkg of workspace.packages) {
58
+ for (const sub of Object.keys(pkg.exports)) {
59
+ if (sub.includes("*"))
60
+ continue;
61
+ const spec = sub === "." ? pkg.name : `${pkg.name}/${sub.slice(2)}`;
62
+ if (merged.paths?.[spec])
63
+ continue;
64
+ const target = resolveSpec(spec, workspace.root);
65
+ if (target)
66
+ packagePaths[spec] = [target];
67
+ }
68
+ }
69
+ if (Object.keys(packagePaths).length > 0) {
70
+ if (!merged.paths)
71
+ merged["pathsBasePath"] = workspace.root;
72
+ merged.paths = { ...packagePaths, ...merged.paths };
73
+ }
74
+ return merged;
75
+ }
76
+ /** Root files and options of the subgraph program around `seeds`. */
77
+ export function planScope(index, seeds, hops, extra = []) {
78
+ const scope = index.project.scope;
79
+ const { workspace } = scope;
80
+ const importers = hops > 0 ? importerIndexFor(index, workspace, extra) : undefined;
81
+ const closure = importers ? importerClosure(importers, [...seeds, ...extra], hops) : { importers: [], barrels: [] };
82
+ const seedFiles = seeds.filter((s) => existsSync(s));
83
+ const rootNames = [...new Set([...seedFiles, ...closure.importers])];
84
+ const options = compilerOptionsFor(seedFiles[0] ?? seeds[0] ?? join(workspace.root, "index.ts"), workspace);
85
+ if (rootNames.some((f) => /\.[mc]?jsx?$/.test(f))) {
86
+ options.allowJs = true;
87
+ options.checkJs = false;
88
+ }
89
+ return {
90
+ program: { rootNames, options, currentDirectory: workspace.root },
91
+ info: {
92
+ line: `${kindLabel(scope.kind)} - ${rootNames.length} of ${workspace.sourceFiles.length} files (seeds ${seedFiles.length}; importers ${closure.importers.length})`,
93
+ partial: importers?.partial ?? false,
94
+ scanned: importers?.scanned ?? 0,
95
+ total: workspace.sourceFiles.length,
96
+ },
97
+ };
98
+ }
99
+ /** A LanguageService over the Project, or over the importer subgraph of `seeds` when the Project is scoped. */
100
+ export function semanticFor(ctx, seeds, hops, extra = []) {
101
+ if (!ctx.index.project.scope)
102
+ return { semantic: createSemanticProject(ctx.index) };
103
+ const { program, info } = planScope(ctx.index, seeds, hops, extra);
104
+ return { semantic: createSemanticProject(ctx.index, program), scope: info };
105
+ }
106
+ /** `scope` / `scope_partial` fields for a semantic command's output. */
107
+ export function scopeFields(scope) {
108
+ if (!scope)
109
+ return {};
110
+ return { scope: scope.line, ...(scope.partial ? { scope_partial: true } : {}) };
111
+ }
112
+ /** Help line when the importer scan stopped early. */
113
+ export function scopeHelp(scope) {
114
+ if (!scope?.partial)
115
+ return [];
116
+ return [
117
+ `Importer scan stopped after ${scope.scanned} of ${scope.total} files (cap ${IMPORTER_MAX_FILES} files or ${IMPORTER_MAX_MS / 1000} s) - importers in the rest are missing; run with -P <tsconfig> for one whole project`,
118
+ ];
119
+ }