ts-axi 0.1.2 → 0.1.3

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.
@@ -0,0 +1,297 @@
1
+ // Workspace detection (no configuration): which kind of repo this is, its projects,
2
+ // path aliases and workspace packages. Reads files only; never writes, never caches.
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import { matchesGlob } from "./glob.js";
6
+ import { listFiles, toRel } from "./walk.js";
7
+ /** Most specific first. */
8
+ const KIND_ORDER = ["nx", "pnpm-workspaces", "npm-workspaces", "tsconfig-references", "single", "implicit"];
9
+ const MULTI = new Set(["nx", "pnpm-workspaces", "npm-workspaces", "tsconfig-references"]);
10
+ export const WORKSPACE_SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
11
+ const LIST_CAP = 200_000;
12
+ export function isMultiProject(kind) {
13
+ return MULTI.has(kind);
14
+ }
15
+ const KIND_LABELS = {
16
+ nx: "nx workspace",
17
+ "pnpm-workspaces": "pnpm workspace",
18
+ "npm-workspaces": "npm workspace",
19
+ "tsconfig-references": "tsconfig references",
20
+ single: "single project",
21
+ implicit: "implicit project",
22
+ };
23
+ export function kindLabel(kind) {
24
+ return KIND_LABELS[kind];
25
+ }
26
+ /** JSON with comments and trailing commas (tsconfig style); undefined when unreadable. */
27
+ export function readJsonc(path) {
28
+ let text;
29
+ try {
30
+ text = readFileSync(path, "utf8");
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ let outText = "";
36
+ for (let i = 0; i < text.length; i++) {
37
+ const ch = text[i];
38
+ if (ch === '"') {
39
+ const start = i;
40
+ for (i++; i < text.length && text[i] !== '"'; i++)
41
+ if (text[i] === "\\")
42
+ i++;
43
+ outText += text.slice(start, i + 1);
44
+ }
45
+ else if (ch === "/" && text[i + 1] === "/") {
46
+ while (i < text.length && text[i] !== "\n")
47
+ i++;
48
+ outText += "\n";
49
+ }
50
+ else if (ch === "/" && text[i + 1] === "*") {
51
+ const end = text.indexOf("*/", i + 2);
52
+ i = end < 0 ? text.length : end + 1;
53
+ }
54
+ else
55
+ outText += ch;
56
+ }
57
+ try {
58
+ const value = JSON.parse(outText.replace(/,(\s*[}\]])/g, "$1"));
59
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ function isSourceName(name) {
66
+ return WORKSPACE_SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext)) && !/\.d\.[mc]?ts$/.test(name);
67
+ }
68
+ /** Repo-root-relative files of the workspace (shared listing, `src/walk.ts`). */
69
+ export function listWorkspaceFiles(root) {
70
+ const listing = listFiles(root, () => true, { maxFiles: LIST_CAP });
71
+ return { files: listing.files.map((f) => toRel(root, f)), listedBy: listing.listedBy };
72
+ }
73
+ /** Role of a tsconfig by its name, then by its `include` patterns. */
74
+ export function classifyTsconfig(path) {
75
+ const name = basename(path);
76
+ if (name === "tsconfig.json")
77
+ return "root";
78
+ if (/^tsconfig\.base\./.test(name))
79
+ return "base";
80
+ if (/(?:^|\.)lib(?:\.|$)/.test(name.replace(/^tsconfig\./, "").replace(/\.json$/, "")))
81
+ return "lib";
82
+ if (/\.app\.json$/.test(name))
83
+ return "app";
84
+ if (/\.(?:spec|test|tests|vitest|jest)\.json$/.test(name))
85
+ return "spec";
86
+ if (/\.(?:e2e|cy|cypress|playwright)\.json$/.test(name))
87
+ return "e2e";
88
+ const include = readJsonc(path)?.["include"];
89
+ const patterns = Array.isArray(include) ? include.filter((p) => typeof p === "string") : [];
90
+ if (patterns.some((p) => /e2e|\.cy\./.test(p)))
91
+ return "e2e";
92
+ if (patterns.some((p) => /\.(?:spec|test)\.|__tests__/.test(p)))
93
+ return "spec";
94
+ return "other";
95
+ }
96
+ function tsconfigsIn(dir) {
97
+ try {
98
+ return readdirSync(dir)
99
+ .filter((n) => /^tsconfig.*\.json$/.test(n))
100
+ .map((n) => join(dir, n))
101
+ .sort();
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ }
107
+ function stringArray(value) {
108
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
109
+ }
110
+ /** `packages:` list of a pnpm-workspace.yaml (block or flow style). */
111
+ export function pnpmPackageGlobs(text) {
112
+ const globs = [];
113
+ const lines = text.split(/\r?\n/);
114
+ const unquote = (s) => s.trim().replace(/^['"]|['"]$/g, "");
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const m = /^packages\s*:\s*(.*)$/.exec(lines[i]);
117
+ if (!m)
118
+ continue;
119
+ const inline = m[1].replace(/#.*$/, "").trim();
120
+ if (inline.startsWith("["))
121
+ return inline.replace(/^\[|\]$/g, "").split(",").map(unquote).filter((g) => g !== "");
122
+ for (let j = i + 1; j < lines.length; j++) {
123
+ const line = lines[j].replace(/#.*$/, "");
124
+ if (line.trim() === "")
125
+ continue;
126
+ const item = /^\s+-\s*(.+)$/.exec(line);
127
+ if (!item)
128
+ break;
129
+ globs.push(unquote(item[1]));
130
+ }
131
+ }
132
+ return globs;
133
+ }
134
+ /** Directories (repo-relative) holding a `package.json` that match workspace globs (`!` negates). */
135
+ function matchPackageDirs(globs, packageDirs) {
136
+ const positive = globs.filter((g) => !g.startsWith("!")).map((g) => g.replace(/^\.\//, "").replace(/\/$/, ""));
137
+ const negative = globs.filter((g) => g.startsWith("!")).map((g) => g.slice(1).replace(/^\.\//, "").replace(/\/$/, ""));
138
+ return packageDirs.filter((d) => d !== "" && positive.some((g) => matchesGlob(d, g)) && !negative.some((g) => matchesGlob(d, g)));
139
+ }
140
+ /** The first string target of an `exports` condition object, preferring source-ish conditions. */
141
+ function exportTargets(value) {
142
+ if (typeof value === "string")
143
+ return [value];
144
+ if (Array.isArray(value))
145
+ return value.flatMap(exportTargets);
146
+ if (value && typeof value === "object") {
147
+ const record = value;
148
+ const order = ["source", "development", "types", "import", "module", "default", "require", "node"];
149
+ const keys = [...order.filter((k) => k in record), ...Object.keys(record).filter((k) => !order.includes(k))];
150
+ return keys.flatMap((k) => exportTargets(record[k]));
151
+ }
152
+ return [];
153
+ }
154
+ function readPackage(dir) {
155
+ const pkg = readJsonc(join(dir, "package.json"));
156
+ const name = pkg?.["name"];
157
+ if (!pkg || typeof name !== "string" || name === "")
158
+ return undefined;
159
+ const exportsMap = {};
160
+ const add = (sub, targets) => {
161
+ exportsMap[sub] = [...(exportsMap[sub] ?? []), ...targets.map((t) => resolve(dir, t))];
162
+ };
163
+ const raw = pkg["exports"];
164
+ if (typeof raw === "string" || Array.isArray(raw))
165
+ add(".", exportTargets(raw));
166
+ else if (raw && typeof raw === "object") {
167
+ const record = raw;
168
+ if (Object.keys(record).some((k) => k.startsWith("."))) {
169
+ for (const [sub, target] of Object.entries(record))
170
+ if (sub.startsWith("."))
171
+ add(sub, exportTargets(target));
172
+ }
173
+ else
174
+ add(".", exportTargets(record));
175
+ }
176
+ const fields = ["source", "types", "typings", "module", "main"].map((k) => pkg[k]).filter((v) => typeof v === "string");
177
+ add(".", [...fields, "src/index", "index"]);
178
+ return { name, dir, exports: exportsMap };
179
+ }
180
+ /** `compilerOptions.paths` of tsconfig.base.json (else tsconfig.json), following relative `extends`. */
181
+ export function readPathAliases(root) {
182
+ const start = [join(root, "tsconfig.base.json"), join(root, "tsconfig.json")].find((f) => existsSync(f));
183
+ let file = start;
184
+ for (let hops = 0; file && hops < 5; hops++) {
185
+ const config = readJsonc(file);
186
+ if (!config)
187
+ return undefined;
188
+ const options = (config["compilerOptions"] ?? {});
189
+ const paths = options["paths"];
190
+ if (paths && typeof paths === "object" && !Array.isArray(paths)) {
191
+ const clean = {};
192
+ for (const [k, v] of Object.entries(paths))
193
+ clean[k] = stringArray(v);
194
+ const baseUrl = typeof options["baseUrl"] === "string" ? options["baseUrl"] : undefined;
195
+ return { file, base: resolve(dirname(file), baseUrl ?? "."), paths: clean };
196
+ }
197
+ const ext = config["extends"];
198
+ const next = typeof ext === "string" && ext.startsWith(".") ? resolve(dirname(file), ext) : undefined;
199
+ file = next && !next.endsWith(".json") && !existsSync(next) ? `${next}.json` : next;
200
+ }
201
+ return undefined;
202
+ }
203
+ /** Root tsconfig with `references` and no files/include of its own. */
204
+ function referencesOnly(config) {
205
+ if (!config)
206
+ return false;
207
+ const refs = config["references"];
208
+ const own = (key) => Array.isArray(config[key]) && config[key].length > 0;
209
+ return Array.isArray(refs) && refs.length > 0 && !own("files") && !own("include");
210
+ }
211
+ function project(root, relDir, name) {
212
+ const dir = relDir === "" ? root : join(root, relDir);
213
+ return { name: name ?? (relDir || basename(root)), dir, tsconfigs: tsconfigsIn(dir) };
214
+ }
215
+ /** Cheap pre-check (no file listing): could `root` be a multi-project workspace? */
216
+ export function hasMultiProjectSignal(root) {
217
+ if (existsSync(join(root, "nx.json")) || existsSync(join(root, "pnpm-workspace.yaml")))
218
+ return true;
219
+ if (readJsonc(join(root, "package.json"))?.["workspaces"] !== undefined)
220
+ return true;
221
+ return referencesOnly(readJsonc(join(root, "tsconfig.json")));
222
+ }
223
+ export function detectWorkspace(root) {
224
+ const { files, listedBy } = listWorkspaceFiles(root);
225
+ const dirsWith = (fileName) => files.filter((f) => f === fileName || f.endsWith(`/${fileName}`)).map((f) => (f === fileName ? "" : f.slice(0, -fileName.length - 1)));
226
+ const packageDirs = dirsWith("package.json");
227
+ const signals = [];
228
+ const byKind = new Map();
229
+ if (existsSync(join(root, "nx.json"))) {
230
+ signals.push("nx");
231
+ const projects = dirsWith("project.json").map((d) => {
232
+ const name = readJsonc(join(root, d, "project.json"))?.["name"];
233
+ return project(root, d, typeof name === "string" ? name : undefined);
234
+ });
235
+ const legacy = readJsonc(join(root, "workspace.json"))?.["projects"];
236
+ if (legacy && typeof legacy === "object") {
237
+ for (const [name, value] of Object.entries(legacy)) {
238
+ const dir = typeof value === "string" ? value : value?.root;
239
+ if (typeof dir === "string" && !projects.some((p) => p.dir === join(root, dir)))
240
+ projects.push(project(root, dir.replace(/\/$/, ""), name));
241
+ }
242
+ }
243
+ byKind.set("nx", projects);
244
+ }
245
+ const pnpmFile = join(root, "pnpm-workspace.yaml");
246
+ if (existsSync(pnpmFile)) {
247
+ const globs = pnpmPackageGlobs(readFileSync(pnpmFile, "utf8"));
248
+ if (globs.length > 0) {
249
+ signals.push("pnpm-workspaces");
250
+ byKind.set("pnpm-workspaces", matchPackageDirs(globs, packageDirs).map((d) => project(root, d, readPackage(join(root, d))?.name)));
251
+ }
252
+ }
253
+ const rootPkg = readJsonc(join(root, "package.json"));
254
+ const ws = rootPkg?.["workspaces"];
255
+ const npmGlobs = Array.isArray(ws) ? stringArray(ws) : stringArray(ws?.packages);
256
+ if (npmGlobs.length > 0) {
257
+ signals.push("npm-workspaces");
258
+ byKind.set("npm-workspaces", matchPackageDirs(npmGlobs, packageDirs).map((d) => project(root, d, readPackage(join(root, d))?.name)));
259
+ }
260
+ const rootTsconfig = join(root, "tsconfig.json");
261
+ const rootConfig = existsSync(rootTsconfig) ? readJsonc(rootTsconfig) : undefined;
262
+ if (referencesOnly(rootConfig)) {
263
+ signals.push("tsconfig-references");
264
+ const refs = rootConfig["references"].map((r) => r.path).filter((p) => typeof p === "string");
265
+ const dirs = refs.map((p) => {
266
+ const abs = resolve(root, p);
267
+ return existsSync(abs) && statSync(abs).isFile() ? dirname(abs) : abs;
268
+ });
269
+ byKind.set("tsconfig-references", [...new Set(dirs)].map((d) => project(root, relative(root, d).split(sep).join("/"))));
270
+ }
271
+ else if (existsSync(rootTsconfig)) {
272
+ signals.push("single");
273
+ byKind.set("single", [project(root, "")]);
274
+ }
275
+ if (signals.length === 0)
276
+ signals.push("implicit");
277
+ signals.sort((a, b) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b));
278
+ const kind = signals[0];
279
+ // Workspace packages: package.json with a name in any project dir or matched workspace dir.
280
+ const pkgDirs = new Set();
281
+ for (const list of byKind.values())
282
+ for (const p of list)
283
+ if (p.dir !== root && existsSync(join(p.dir, "package.json")))
284
+ pkgDirs.add(p.dir);
285
+ const packages = [...pkgDirs].map(readPackage).filter((p) => p !== undefined);
286
+ const paths = readPathAliases(root);
287
+ return {
288
+ root,
289
+ kind,
290
+ signals,
291
+ projects: (byKind.get(kind) ?? []).sort((a, b) => a.dir.localeCompare(b.dir)),
292
+ ...(paths ? { paths } : {}),
293
+ packages,
294
+ sourceFiles: files.filter((f) => isSourceName(f)).map((f) => join(root, f)),
295
+ listedBy,
296
+ };
297
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "AXI-compliant TypeScript symbol CLI for agents - definitions, references, callers, file outlines and change impact (blast radius) with token-efficient TOON output",
5
5
  "type": "module",
6
6
  "keywords": [