@flowrail/init 0.0.9 → 0.0.11
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/dist/app-instance.js +287 -0
- package/dist/claude-settings.js +39 -10
- package/dist/doctor-script.js +1292 -7
- package/dist/index.js +0 -0
- package/dist/init.js +21 -0
- package/package.json +2 -2
package/dist/doctor-script.js
CHANGED
|
@@ -10,15 +10,19 @@ const path_1 = __importDefault(require("path"));
|
|
|
10
10
|
exports.DOCTOR_SCRIPT_FILENAME = 'scripts/check-flowrail.mjs';
|
|
11
11
|
/**
|
|
12
12
|
* The content of the doctor script dropped into the target repo.
|
|
13
|
-
* It is a self-contained ES module — no deps outside node:
|
|
13
|
+
* It is a self-contained ES module — no deps outside node: builtins.
|
|
14
14
|
*/
|
|
15
|
-
const DOCTOR_SCRIPT_CONTENT = `#!/usr/bin/env node
|
|
15
|
+
const DOCTOR_SCRIPT_CONTENT = String.raw `#!/usr/bin/env node
|
|
16
16
|
/**
|
|
17
|
-
* FlowRail doctor script — checks that .mcp.json is present and wired
|
|
17
|
+
* FlowRail doctor script — checks that .mcp.json is present and wired,
|
|
18
|
+
* and that runtime source imports are declared in package.json.
|
|
18
19
|
* Generated by @flowrail/init. Safe to commit to the repo.
|
|
19
20
|
* Re-run the installer if this script fails: npx @flowrail/init@latest
|
|
20
21
|
*/
|
|
21
|
-
import {
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { builtinModules } from 'node:module';
|
|
25
|
+
import path from 'node:path';
|
|
22
26
|
|
|
23
27
|
const mcpPath = '.mcp.json';
|
|
24
28
|
let ok = true;
|
|
@@ -26,7 +30,7 @@ let ok = true;
|
|
|
26
30
|
if (!existsSync(mcpPath)) {
|
|
27
31
|
console.error(
|
|
28
32
|
'FlowRail: .mcp.json is missing — Claude Code MCP tools ' +
|
|
29
|
-
'(mcp__flowrail__*) will not be available
|
|
33
|
+
'(mcp__flowrail__*) will not be available.\n' +
|
|
30
34
|
'Fix: npx @flowrail/init@latest',
|
|
31
35
|
);
|
|
32
36
|
ok = false;
|
|
@@ -36,20 +40,1301 @@ if (!existsSync(mcpPath)) {
|
|
|
36
40
|
if (!cfg?.mcpServers?.flowrail) {
|
|
37
41
|
console.error(
|
|
38
42
|
'FlowRail: .mcp.json has no mcpServers.flowrail entry — ' +
|
|
39
|
-
'Claude Code MCP tools (mcp__flowrail__*) will not be available
|
|
43
|
+
'Claude Code MCP tools (mcp__flowrail__*) will not be available.\n' +
|
|
40
44
|
'Fix: npx @flowrail/init@latest',
|
|
41
45
|
);
|
|
42
46
|
ok = false;
|
|
43
47
|
}
|
|
44
48
|
} catch {
|
|
45
49
|
console.error(
|
|
46
|
-
'FlowRail: .mcp.json is malformed JSON
|
|
50
|
+
'FlowRail: .mcp.json is malformed JSON.\n' +
|
|
47
51
|
'Fix: delete it and re-run: npx @flowrail/init@latest',
|
|
48
52
|
);
|
|
49
53
|
ok = false;
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.cts']);
|
|
58
|
+
const DECLARATION_EXTENSIONS = ['.d.ts', '.d.mts', '.d.cts'];
|
|
59
|
+
const DEPENDENCY_SECTIONS = [
|
|
60
|
+
'dependencies',
|
|
61
|
+
'devDependencies',
|
|
62
|
+
'optionalDependencies',
|
|
63
|
+
'peerDependencies',
|
|
64
|
+
];
|
|
65
|
+
const EXCLUDED_SEGMENTS = new Set([
|
|
66
|
+
'node_modules',
|
|
67
|
+
'dist',
|
|
68
|
+
'build',
|
|
69
|
+
'.git',
|
|
70
|
+
'coverage',
|
|
71
|
+
]);
|
|
72
|
+
const TSCONFIG_EXTENDS_DEPTH_LIMIT = 10;
|
|
73
|
+
const BUILTIN_MODULES = new Set(
|
|
74
|
+
builtinModules.map((moduleName) => moduleName.replace(/^node:/, '')),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
function isPlainObject(value) {
|
|
78
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stripJsonComments(text) {
|
|
82
|
+
let output = '';
|
|
83
|
+
let index = 0;
|
|
84
|
+
let inString = false;
|
|
85
|
+
let quote = '';
|
|
86
|
+
|
|
87
|
+
while (index < text.length) {
|
|
88
|
+
const char = text[index];
|
|
89
|
+
const next = text[index + 1];
|
|
90
|
+
|
|
91
|
+
if (inString) {
|
|
92
|
+
output += char;
|
|
93
|
+
if (char === '\\' && index + 1 < text.length) {
|
|
94
|
+
output += text[index + 1];
|
|
95
|
+
index += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (char === quote) {
|
|
99
|
+
inString = false;
|
|
100
|
+
quote = '';
|
|
101
|
+
}
|
|
102
|
+
index += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (char === '"' || char === "'") {
|
|
107
|
+
inString = true;
|
|
108
|
+
quote = char;
|
|
109
|
+
output += char;
|
|
110
|
+
index += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (char === '/' && next === '/') {
|
|
115
|
+
index += 2;
|
|
116
|
+
while (index < text.length && text[index] !== '\n' && text[index] !== '\r') {
|
|
117
|
+
index += 1;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (char === '/' && next === '*') {
|
|
123
|
+
index += 2;
|
|
124
|
+
while (index + 1 < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
|
|
125
|
+
index += 1;
|
|
126
|
+
}
|
|
127
|
+
index += index + 1 < text.length ? 2 : 0;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
output += char;
|
|
132
|
+
index += 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return output;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stripJsonTrailingCommas(text) {
|
|
139
|
+
let output = '';
|
|
140
|
+
let index = 0;
|
|
141
|
+
let inString = false;
|
|
142
|
+
let quote = '';
|
|
143
|
+
|
|
144
|
+
while (index < text.length) {
|
|
145
|
+
const char = text[index];
|
|
146
|
+
|
|
147
|
+
if (inString) {
|
|
148
|
+
output += char;
|
|
149
|
+
if (char === '\\' && index + 1 < text.length) {
|
|
150
|
+
output += text[index + 1];
|
|
151
|
+
index += 2;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (char === quote) {
|
|
155
|
+
inString = false;
|
|
156
|
+
quote = '';
|
|
157
|
+
}
|
|
158
|
+
index += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (char === '"' || char === "'") {
|
|
163
|
+
inString = true;
|
|
164
|
+
quote = char;
|
|
165
|
+
output += char;
|
|
166
|
+
index += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (char === ',') {
|
|
171
|
+
let lookahead = index + 1;
|
|
172
|
+
while (lookahead < text.length && /\s/.test(text[lookahead])) lookahead += 1;
|
|
173
|
+
if (text[lookahead] === '}' || text[lookahead] === ']') {
|
|
174
|
+
index += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
output += char;
|
|
180
|
+
index += 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return output;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseJsoncFile(filePath) {
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(stripJsonTrailingCommas(stripJsonComments(readFileSync(filePath, 'utf8'))));
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isFile(filePath) {
|
|
195
|
+
try {
|
|
196
|
+
return statSync(filePath).isFile();
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function tsconfigKeyToRegExp(key) {
|
|
203
|
+
// A tsconfig "paths" key may carry a single '*' wildcard ANYWHERE (not only
|
|
204
|
+
// a trailing '/*'): '@env/*/config' is legal and matches '@env/prod/config'.
|
|
205
|
+
// Escape EVERY regex metacharacter (incl. '?', which appears in real keys
|
|
206
|
+
// like '@assets/*.svg?react'), then turn each '*' into '.*' (TS '*' matches
|
|
207
|
+
// any substring, slashes included). Anchored full-match. Returns null if the
|
|
208
|
+
// key still cannot compile to a RegExp — the caller skips it rather than
|
|
209
|
+
// letting the doctor crash the build on its own limitation (class-3 spirit).
|
|
210
|
+
const escaped = key.replace(/[$.+?^(){}|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
211
|
+
try {
|
|
212
|
+
return new RegExp('^' + escaped + '$');
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function addTsconfigPathAlias(aliases, key) {
|
|
219
|
+
if (key.includes('*')) {
|
|
220
|
+
const pattern = tsconfigKeyToRegExp(key);
|
|
221
|
+
if (pattern !== null) aliases.patterns.push(pattern);
|
|
222
|
+
} else {
|
|
223
|
+
aliases.exact.add(key);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function collectTsconfigFiles(dir, files = []) {
|
|
228
|
+
let entries;
|
|
229
|
+
try {
|
|
230
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
231
|
+
} catch {
|
|
232
|
+
return files;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (EXCLUDED_SEGMENTS.has(entry.name)) continue;
|
|
237
|
+
|
|
238
|
+
const fullPath = path.join(dir, entry.name);
|
|
239
|
+
if (entry.isDirectory()) {
|
|
240
|
+
collectTsconfigFiles(fullPath, files);
|
|
241
|
+
} else if (
|
|
242
|
+
entry.isFile() &&
|
|
243
|
+
entry.name.startsWith('tsconfig') &&
|
|
244
|
+
entry.name.endsWith('.json')
|
|
245
|
+
) {
|
|
246
|
+
files.push(fullPath);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return files;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isInsideDirectory(rootDir, candidatePath) {
|
|
253
|
+
const relative = path.relative(rootDir, candidatePath);
|
|
254
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveTsconfigExtendsCandidate(basePath, mustBeInsideRoot) {
|
|
258
|
+
const rootDir = path.resolve(process.cwd());
|
|
259
|
+
const candidates = [basePath];
|
|
260
|
+
if (!basePath.endsWith('.json')) candidates.push(basePath + '.json');
|
|
261
|
+
candidates.push(path.join(basePath, 'tsconfig.json'));
|
|
262
|
+
|
|
263
|
+
for (const candidate of candidates) {
|
|
264
|
+
const resolved = path.resolve(candidate);
|
|
265
|
+
if (mustBeInsideRoot && !isInsideDirectory(rootDir, resolved)) continue;
|
|
266
|
+
if (isFile(resolved)) return resolved;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function resolveRelativeTsconfigExtendsEntry(configPath, extendsValue) {
|
|
272
|
+
const rootDir = path.resolve(process.cwd());
|
|
273
|
+
const basePath = path.resolve(path.dirname(configPath), extendsValue);
|
|
274
|
+
return resolveTsconfigExtendsCandidate(basePath, isInsideDirectory(rootDir, path.resolve(configPath)));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parsePackageTsconfigExtendsSpecifier(extendsValue) {
|
|
278
|
+
if (
|
|
279
|
+
extendsValue.startsWith('.') ||
|
|
280
|
+
extendsValue.startsWith('/') ||
|
|
281
|
+
path.isAbsolute(extendsValue)
|
|
282
|
+
) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const parts = extendsValue.split('/');
|
|
287
|
+
let packageName;
|
|
288
|
+
let subpathParts;
|
|
289
|
+
|
|
290
|
+
if (extendsValue.startsWith('@')) {
|
|
291
|
+
if (parts.length < 2 || parts[0] === '' || parts[1] === '') return null;
|
|
292
|
+
packageName = parts[0] + '/' + parts[1];
|
|
293
|
+
subpathParts = parts.slice(2);
|
|
294
|
+
} else {
|
|
295
|
+
if (parts[0] === '') return null;
|
|
296
|
+
packageName = parts[0];
|
|
297
|
+
subpathParts = parts.slice(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (subpathParts.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
packageName,
|
|
306
|
+
subpath: subpathParts.length > 0 ? subpathParts.join('/') : 'tsconfig.json',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resolvePackageTsconfigExtendsEntry(configPath, extendsValue) {
|
|
311
|
+
const parsed = parsePackageTsconfigExtendsSpecifier(extendsValue);
|
|
312
|
+
if (parsed === null) return null;
|
|
313
|
+
|
|
314
|
+
let currentDir = path.resolve(path.dirname(configPath));
|
|
315
|
+
while (true) {
|
|
316
|
+
const basePath = path.join(
|
|
317
|
+
currentDir,
|
|
318
|
+
'node_modules',
|
|
319
|
+
parsed.packageName,
|
|
320
|
+
parsed.subpath,
|
|
321
|
+
);
|
|
322
|
+
const resolved = resolveTsconfigExtendsCandidate(basePath, false);
|
|
323
|
+
if (resolved !== null) return resolved;
|
|
324
|
+
|
|
325
|
+
const parentDir = path.dirname(currentDir);
|
|
326
|
+
if (parentDir === currentDir) return null;
|
|
327
|
+
currentDir = parentDir;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function resolveTsconfigExtendsEntry(configPath, extendsValue) {
|
|
332
|
+
if (typeof extendsValue !== 'string') return null;
|
|
333
|
+
|
|
334
|
+
const trimmed = extendsValue.trim();
|
|
335
|
+
if (trimmed === '') return null;
|
|
336
|
+
if (trimmed.startsWith('.')) return resolveRelativeTsconfigExtendsEntry(configPath, trimmed);
|
|
337
|
+
return resolvePackageTsconfigExtendsEntry(configPath, trimmed);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function resolveTsconfigExtends(configPath, extendsValue) {
|
|
341
|
+
const values = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
|
|
342
|
+
const resolvedPaths = [];
|
|
343
|
+
|
|
344
|
+
for (const value of values) {
|
|
345
|
+
const resolvedPath = resolveTsconfigExtendsEntry(configPath, value);
|
|
346
|
+
if (resolvedPath !== null) resolvedPaths.push(resolvedPath);
|
|
347
|
+
}
|
|
348
|
+
return resolvedPaths;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function addTsconfigPathAliasesFromFile(configPath, aliases, visited, depth) {
|
|
352
|
+
const resolvedPath = path.resolve(configPath);
|
|
353
|
+
if (depth >= TSCONFIG_EXTENDS_DEPTH_LIMIT || visited.has(resolvedPath)) return;
|
|
354
|
+
visited.add(resolvedPath);
|
|
355
|
+
|
|
356
|
+
const cfg = parseJsoncFile(resolvedPath);
|
|
357
|
+
if (!isPlainObject(cfg)) return;
|
|
358
|
+
|
|
359
|
+
for (const extendedPath of resolveTsconfigExtends(resolvedPath, cfg.extends)) {
|
|
360
|
+
addTsconfigPathAliasesFromFile(extendedPath, aliases, visited, depth + 1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const paths = cfg?.compilerOptions?.paths;
|
|
364
|
+
if (!isPlainObject(paths)) return;
|
|
365
|
+
|
|
366
|
+
for (const key of Object.keys(paths)) {
|
|
367
|
+
addTsconfigPathAlias(aliases, key);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readTsconfigPathAliases() {
|
|
372
|
+
const aliases = { exact: new Set(), patterns: [] };
|
|
373
|
+
|
|
374
|
+
for (const configPath of collectTsconfigFiles(process.cwd())) {
|
|
375
|
+
addTsconfigPathAliasesFromFile(configPath, aliases, new Set(), 0);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return aliases;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const TSCONFIG_PATH_ALIASES = readTsconfigPathAliases();
|
|
382
|
+
|
|
383
|
+
function isTsconfigPathAlias(specifier) {
|
|
384
|
+
if (TSCONFIG_PATH_ALIASES.exact.has(specifier)) return true;
|
|
385
|
+
return TSCONFIG_PATH_ALIASES.patterns.some((pattern) => pattern.test(specifier));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function addDeclaredModulesFromPackageJson(packageJsonPath, declared) {
|
|
389
|
+
let pkg;
|
|
390
|
+
try {
|
|
391
|
+
pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const section of DEPENDENCY_SECTIONS) {
|
|
397
|
+
const deps = pkg?.[section];
|
|
398
|
+
if (!isPlainObject(deps)) continue;
|
|
399
|
+
for (const name of Object.keys(deps)) declared.add(name);
|
|
400
|
+
}
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function collectPackageJsonFiles(dir, files = []) {
|
|
405
|
+
let entries;
|
|
406
|
+
try {
|
|
407
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
408
|
+
} catch {
|
|
409
|
+
return files;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const entry of entries) {
|
|
413
|
+
if (EXCLUDED_SEGMENTS.has(entry.name)) continue;
|
|
414
|
+
|
|
415
|
+
const fullPath = path.join(dir, entry.name);
|
|
416
|
+
if (entry.isDirectory()) {
|
|
417
|
+
collectPackageJsonFiles(fullPath, files);
|
|
418
|
+
} else if (entry.isFile() && entry.name === 'package.json') {
|
|
419
|
+
files.push(fullPath);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return files;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function readDeclaredModules() {
|
|
426
|
+
if (!existsSync('package.json')) return null;
|
|
427
|
+
|
|
428
|
+
const declared = new Set();
|
|
429
|
+
const rootPackageJsonPath = path.resolve('package.json');
|
|
430
|
+
if (!addDeclaredModulesFromPackageJson(rootPackageJsonPath, declared)) return null;
|
|
431
|
+
|
|
432
|
+
for (const packageJsonPath of collectPackageJsonFiles(process.cwd())) {
|
|
433
|
+
if (path.resolve(packageJsonPath) === rootPackageJsonPath) continue;
|
|
434
|
+
addDeclaredModulesFromPackageJson(packageJsonPath, declared);
|
|
435
|
+
}
|
|
436
|
+
return declared;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isDeclarationFileName(fileName) {
|
|
440
|
+
const lower = fileName.toLowerCase();
|
|
441
|
+
return DECLARATION_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function collectSourceFiles(dir, files = []) {
|
|
445
|
+
let entries;
|
|
446
|
+
try {
|
|
447
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
448
|
+
} catch {
|
|
449
|
+
return files;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
for (const entry of entries) {
|
|
453
|
+
if (EXCLUDED_SEGMENTS.has(entry.name)) continue;
|
|
454
|
+
|
|
455
|
+
const fullPath = path.join(dir, entry.name);
|
|
456
|
+
if (entry.isDirectory()) {
|
|
457
|
+
collectSourceFiles(fullPath, files);
|
|
458
|
+
} else if (
|
|
459
|
+
entry.isFile() &&
|
|
460
|
+
!isDeclarationFileName(entry.name) &&
|
|
461
|
+
SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())
|
|
462
|
+
) {
|
|
463
|
+
files.push(fullPath);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return files;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function packageNameForSpecifier(specifier) {
|
|
470
|
+
const clean = specifier.trim();
|
|
471
|
+
if (
|
|
472
|
+
clean === '' ||
|
|
473
|
+
clean.startsWith('.') ||
|
|
474
|
+
clean.startsWith('/') ||
|
|
475
|
+
clean.includes('://') ||
|
|
476
|
+
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(clean) ||
|
|
477
|
+
clean.startsWith('@/') ||
|
|
478
|
+
clean.startsWith('#') ||
|
|
479
|
+
clean.startsWith('~/') ||
|
|
480
|
+
clean === '~' ||
|
|
481
|
+
isTsconfigPathAlias(clean)
|
|
482
|
+
) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const bare = clean.startsWith('node:') ? clean.slice('node:'.length) : clean;
|
|
487
|
+
const builtinRoot = bare.split('/')[0];
|
|
488
|
+
if (BUILTIN_MODULES.has(bare) || BUILTIN_MODULES.has(builtinRoot)) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (bare.startsWith('@')) {
|
|
493
|
+
const parts = bare.split('/');
|
|
494
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
495
|
+
return parts[0] + '/' + parts[1];
|
|
496
|
+
}
|
|
497
|
+
return bare;
|
|
498
|
+
}
|
|
499
|
+
return bare.split('/')[0];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const STATIC_SENTINEL = String.fromCharCode(0);
|
|
503
|
+
const DYNAMIC_SENTINEL = String.fromCharCode(1);
|
|
504
|
+
const BACKTICK_CODE = 96;
|
|
505
|
+
const DOLLAR_CODE = 36;
|
|
506
|
+
|
|
507
|
+
function staticSentinel(index) {
|
|
508
|
+
return STATIC_SENTINEL + String(index) + STATIC_SENTINEL;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function dynamicSentinel(index) {
|
|
512
|
+
return DYNAMIC_SENTINEL + String(index) + DYNAMIC_SENTINEL;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function readQuotedString(text, start, quote) {
|
|
516
|
+
let value = '';
|
|
517
|
+
let index = start + 1;
|
|
518
|
+
|
|
519
|
+
while (index < text.length) {
|
|
520
|
+
const char = text[index];
|
|
521
|
+
if (char === '\\') {
|
|
522
|
+
if (index + 1 < text.length) {
|
|
523
|
+
if (text[index + 1] === '\r' && text[index + 2] === '\n') {
|
|
524
|
+
index += 3;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (text[index + 1] === '\n' || text[index + 1] === '\r') {
|
|
528
|
+
index += 2;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
value += text[index + 1];
|
|
532
|
+
index += 2;
|
|
533
|
+
} else {
|
|
534
|
+
index += 1;
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (char === '\n' || char === '\r') {
|
|
539
|
+
return { end: index, value };
|
|
540
|
+
}
|
|
541
|
+
if (char === quote) {
|
|
542
|
+
return { end: index + 1, value };
|
|
543
|
+
}
|
|
544
|
+
value += char;
|
|
545
|
+
index += 1;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return { end: text.length, value };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function skipLineComment(text, start) {
|
|
552
|
+
let index = start + 2;
|
|
553
|
+
while (index < text.length && text[index] !== '\n' && text[index] !== '\r') {
|
|
554
|
+
index += 1;
|
|
555
|
+
}
|
|
556
|
+
return index;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function skipBlockComment(text, start) {
|
|
560
|
+
let index = start + 2;
|
|
561
|
+
while (index + 1 < text.length) {
|
|
562
|
+
if (text[index] === '*' && text[index + 1] === '/') {
|
|
563
|
+
return index + 2;
|
|
564
|
+
}
|
|
565
|
+
index += 1;
|
|
566
|
+
}
|
|
567
|
+
return text.length;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function commentWhitespace(text, start, end) {
|
|
571
|
+
let value = ' ';
|
|
572
|
+
for (let index = start; index < end; index += 1) {
|
|
573
|
+
const char = text[index];
|
|
574
|
+
if (char === '\n' || char === '\r') value += char;
|
|
575
|
+
}
|
|
576
|
+
return value;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const REGEX_PREFIX_PUNCTUATORS = new Set([
|
|
580
|
+
'(',
|
|
581
|
+
',',
|
|
582
|
+
'[',
|
|
583
|
+
'{',
|
|
584
|
+
';',
|
|
585
|
+
':',
|
|
586
|
+
'=',
|
|
587
|
+
'!',
|
|
588
|
+
'&',
|
|
589
|
+
'|',
|
|
590
|
+
'?',
|
|
591
|
+
'+',
|
|
592
|
+
'-',
|
|
593
|
+
'*',
|
|
594
|
+
'/',
|
|
595
|
+
'%',
|
|
596
|
+
'^',
|
|
597
|
+
'~',
|
|
598
|
+
'<',
|
|
599
|
+
'>',
|
|
600
|
+
]);
|
|
601
|
+
const REGEX_PREFIX_KEYWORDS = new Set([
|
|
602
|
+
'return',
|
|
603
|
+
'throw',
|
|
604
|
+
'typeof',
|
|
605
|
+
'instanceof',
|
|
606
|
+
'in',
|
|
607
|
+
'of',
|
|
608
|
+
'do',
|
|
609
|
+
'else',
|
|
610
|
+
'yield',
|
|
611
|
+
'await',
|
|
612
|
+
'case',
|
|
613
|
+
'void',
|
|
614
|
+
'delete',
|
|
615
|
+
'new',
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
function isIdentifierStart(char) {
|
|
619
|
+
return /[A-Za-z_$]/.test(char);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function isIdentifierPart(char) {
|
|
623
|
+
return /[A-Za-z0-9_$]/.test(char);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function readIdentifier(text, start) {
|
|
627
|
+
let index = start + 1;
|
|
628
|
+
while (index < text.length && isIdentifierPart(text[index])) index += 1;
|
|
629
|
+
return index;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function tokenAllowsRegex(previousToken) {
|
|
633
|
+
if (previousToken === null) return true;
|
|
634
|
+
if (previousToken.type === 'keyword') return REGEX_PREFIX_KEYWORDS.has(previousToken.value);
|
|
635
|
+
if (previousToken.type === 'punctuator') {
|
|
636
|
+
return REGEX_PREFIX_PUNCTUATORS.has(previousToken.value);
|
|
637
|
+
}
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function tokenForPunctuator(char) {
|
|
642
|
+
if (char === ')' || char === ']' || char === '}') return { type: 'expression', value: char };
|
|
643
|
+
return { type: 'punctuator', value: char };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function readRegexLiteral(text, start) {
|
|
647
|
+
let inClass = false;
|
|
648
|
+
let index = start + 1;
|
|
649
|
+
|
|
650
|
+
while (index < text.length) {
|
|
651
|
+
const char = text[index];
|
|
652
|
+
if (char === '\n' || char === '\r') return index;
|
|
653
|
+
if (char === '\\') {
|
|
654
|
+
if (index + 1 < text.length && (text[index + 1] === '\n' || text[index + 1] === '\r')) {
|
|
655
|
+
return index + 1;
|
|
656
|
+
}
|
|
657
|
+
index += index + 1 < text.length ? 2 : 1;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (char === '[') {
|
|
661
|
+
inClass = true;
|
|
662
|
+
index += 1;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (char === ']' && inClass) {
|
|
666
|
+
inClass = false;
|
|
667
|
+
index += 1;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (char === '/' && !inClass) {
|
|
671
|
+
index += 1;
|
|
672
|
+
while (index < text.length && isIdentifierPart(text[index])) index += 1;
|
|
673
|
+
return index;
|
|
674
|
+
}
|
|
675
|
+
index += 1;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return index;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function skipTemplateExpression(text, start) {
|
|
682
|
+
let depth = 1;
|
|
683
|
+
let index = start;
|
|
684
|
+
let previousToken = null;
|
|
685
|
+
|
|
686
|
+
while (index < text.length) {
|
|
687
|
+
const char = text[index];
|
|
688
|
+
const next = text[index + 1];
|
|
689
|
+
const code = text.charCodeAt(index);
|
|
690
|
+
|
|
691
|
+
if (/\s/.test(char)) {
|
|
692
|
+
index += 1;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (isIdentifierStart(char)) {
|
|
696
|
+
const end = readIdentifier(text, index);
|
|
697
|
+
const value = text.slice(index, end);
|
|
698
|
+
previousToken = {
|
|
699
|
+
type:
|
|
700
|
+
REGEX_PREFIX_KEYWORDS.has(value) && previousToken?.value !== '.'
|
|
701
|
+
? 'keyword'
|
|
702
|
+
: 'identifier',
|
|
703
|
+
value,
|
|
704
|
+
};
|
|
705
|
+
index = end;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (char === "'" || char === '"') {
|
|
709
|
+
index = readQuotedString(text, index, char).end;
|
|
710
|
+
previousToken = { type: 'expression', value: char };
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (code === BACKTICK_CODE) {
|
|
714
|
+
index = readTemplateLiteral(text, index).end;
|
|
715
|
+
previousToken = { type: 'expression', value: 'template' };
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (char === '/' && next === '/') {
|
|
719
|
+
index = skipLineComment(text, index);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (char === '/' && next === '*') {
|
|
723
|
+
index = skipBlockComment(text, index);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (char === '/' && tokenAllowsRegex(previousToken)) {
|
|
727
|
+
index = readRegexLiteral(text, index);
|
|
728
|
+
previousToken = { type: 'expression', value: 'regex' };
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (char === '{') {
|
|
732
|
+
depth += 1;
|
|
733
|
+
previousToken = { type: 'punctuator', value: char };
|
|
734
|
+
index += 1;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (char === '}') {
|
|
738
|
+
depth -= 1;
|
|
739
|
+
index += 1;
|
|
740
|
+
if (depth === 0) return index;
|
|
741
|
+
previousToken = { type: 'expression', value: char };
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
previousToken = tokenForPunctuator(char);
|
|
745
|
+
index += 1;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return index;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function readTemplateLiteral(text, start) {
|
|
752
|
+
let value = '';
|
|
753
|
+
let dynamic = false;
|
|
754
|
+
const expressions = [];
|
|
755
|
+
let index = start + 1;
|
|
756
|
+
|
|
757
|
+
while (index < text.length) {
|
|
758
|
+
const char = text[index];
|
|
759
|
+
const code = text.charCodeAt(index);
|
|
760
|
+
|
|
761
|
+
if (char === '\\') {
|
|
762
|
+
if (!dynamic && index + 1 < text.length) value += text[index + 1];
|
|
763
|
+
index += index + 1 < text.length ? 2 : 1;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (code === BACKTICK_CODE) {
|
|
767
|
+
return {
|
|
768
|
+
dynamic,
|
|
769
|
+
end: index + 1,
|
|
770
|
+
expressions,
|
|
771
|
+
source: text.slice(start, index + 1),
|
|
772
|
+
value,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
if (code === DOLLAR_CODE && text[index + 1] === '{') {
|
|
776
|
+
dynamic = true;
|
|
777
|
+
const expressionStart = index + 2;
|
|
778
|
+
const expressionEnd = skipTemplateExpression(text, expressionStart);
|
|
779
|
+
const expressionClose =
|
|
780
|
+
expressionEnd > expressionStart && text[expressionEnd - 1] === '}'
|
|
781
|
+
? expressionEnd - 1
|
|
782
|
+
: expressionEnd;
|
|
783
|
+
expressions.push(text.slice(expressionStart, expressionClose));
|
|
784
|
+
index = expressionEnd;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (!dynamic) value += char;
|
|
788
|
+
index += 1;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return { dynamic, end: text.length, expressions, source: text.slice(start), value };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function maskStringsAndComments(text, state) {
|
|
795
|
+
const strings = state?.strings ?? [];
|
|
796
|
+
const dynamicStrings = state?.dynamicStrings ?? [];
|
|
797
|
+
const templateExpressions = [];
|
|
798
|
+
let masked = '';
|
|
799
|
+
let index = 0;
|
|
800
|
+
let previousToken = null;
|
|
801
|
+
|
|
802
|
+
while (index < text.length) {
|
|
803
|
+
const char = text[index];
|
|
804
|
+
const next = text[index + 1];
|
|
805
|
+
const code = text.charCodeAt(index);
|
|
806
|
+
|
|
807
|
+
if (/\s/.test(char)) {
|
|
808
|
+
masked += char;
|
|
809
|
+
index += 1;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (isIdentifierStart(char)) {
|
|
813
|
+
const end = readIdentifier(text, index);
|
|
814
|
+
const value = text.slice(index, end);
|
|
815
|
+
masked += value;
|
|
816
|
+
previousToken = {
|
|
817
|
+
type:
|
|
818
|
+
REGEX_PREFIX_KEYWORDS.has(value) && previousToken?.value !== '.'
|
|
819
|
+
? 'keyword'
|
|
820
|
+
: 'identifier',
|
|
821
|
+
value,
|
|
822
|
+
};
|
|
823
|
+
index = end;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (char === '/' && next === '/') {
|
|
827
|
+
const end = skipLineComment(text, index);
|
|
828
|
+
masked += commentWhitespace(text, index, end);
|
|
829
|
+
index = end;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (char === '/' && next === '*') {
|
|
833
|
+
const end = skipBlockComment(text, index);
|
|
834
|
+
masked += commentWhitespace(text, index, end);
|
|
835
|
+
index = end;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (char === '/' && tokenAllowsRegex(previousToken)) {
|
|
839
|
+
const end = readRegexLiteral(text, index);
|
|
840
|
+
masked += commentWhitespace(text, index, end);
|
|
841
|
+
previousToken = { type: 'expression', value: 'regex' };
|
|
842
|
+
index = end;
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (char === "'" || char === '"') {
|
|
846
|
+
const literal = readQuotedString(text, index, char);
|
|
847
|
+
const literalIndex = strings.push(literal.value) - 1;
|
|
848
|
+
masked += staticSentinel(literalIndex);
|
|
849
|
+
previousToken = { type: 'expression', value: char };
|
|
850
|
+
index = literal.end;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (code === BACKTICK_CODE) {
|
|
854
|
+
const literal = readTemplateLiteral(text, index);
|
|
855
|
+
if (literal.dynamic) {
|
|
856
|
+
const literalIndex = dynamicStrings.push(literal.source) - 1;
|
|
857
|
+
masked += dynamicSentinel(literalIndex);
|
|
858
|
+
for (const expression of literal.expressions) {
|
|
859
|
+
const expressionScan = maskStringsAndComments(expression, {
|
|
860
|
+
dynamicStrings,
|
|
861
|
+
strings,
|
|
862
|
+
});
|
|
863
|
+
if (expressionScan.masked.trim()) templateExpressions.push(expressionScan.masked);
|
|
864
|
+
}
|
|
865
|
+
} else {
|
|
866
|
+
const literalIndex = strings.push(literal.value) - 1;
|
|
867
|
+
masked += staticSentinel(literalIndex);
|
|
868
|
+
}
|
|
869
|
+
previousToken = { type: 'expression', value: 'template' };
|
|
870
|
+
index = literal.end;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
masked += char;
|
|
875
|
+
previousToken = tokenForPunctuator(char);
|
|
876
|
+
index += 1;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (templateExpressions.length > 0) {
|
|
880
|
+
masked += '\n' + templateExpressions.join('\n');
|
|
881
|
+
}
|
|
882
|
+
return { dynamicStrings, masked, strings };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function staticSpecifierFromMatch(strings, indexText) {
|
|
886
|
+
const index = Number(indexText);
|
|
887
|
+
return Number.isInteger(index) ? strings[index] : undefined;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function isAllTypeOnlyNamedClause(clause) {
|
|
891
|
+
const trimmed = clause.trim();
|
|
892
|
+
const open = trimmed.indexOf('{');
|
|
893
|
+
const close = trimmed.lastIndexOf('}');
|
|
894
|
+
if (open === -1 || close === -1 || close < open) return false;
|
|
895
|
+
if (trimmed.slice(0, open).trim() !== '') return false;
|
|
896
|
+
if (trimmed.slice(close + 1).trim() !== '') return false;
|
|
897
|
+
|
|
898
|
+
const bindings = trimmed
|
|
899
|
+
.slice(open + 1, close)
|
|
900
|
+
.split(',')
|
|
901
|
+
.map((binding) => binding.trim())
|
|
902
|
+
.filter(Boolean);
|
|
903
|
+
return bindings.length > 0 && bindings.every((binding) => /^type\s+(?!as\b)/.test(binding));
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function addSpecifier(specifiers, strings, indexText) {
|
|
907
|
+
const specifier = staticSpecifierFromMatch(strings, indexText);
|
|
908
|
+
if (specifier !== undefined) specifiers.add(specifier);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function expressionStartsWithStaticSentinelArgument(expression) {
|
|
912
|
+
const source = STATIC_SENTINEL + '(\\d+)' + STATIC_SENTINEL;
|
|
913
|
+
return new RegExp('^' + source + '\\s*(?:$|,)').test(expression.trim());
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function expressionForWarning(expression, strings, dynamicStrings) {
|
|
917
|
+
const staticSource = STATIC_SENTINEL + '(\\d+)' + STATIC_SENTINEL;
|
|
918
|
+
const dynamicSource = DYNAMIC_SENTINEL + '(\\d+)' + DYNAMIC_SENTINEL;
|
|
919
|
+
return expression.trim().replace(new RegExp(staticSource, 'g'), (_match, indexText) => {
|
|
920
|
+
const value = strings[Number(indexText)];
|
|
921
|
+
return value === undefined ? '<static string>' : value;
|
|
922
|
+
}).replace(new RegExp(dynamicSource, 'g'), (_match, indexText) => {
|
|
923
|
+
const value = dynamicStrings[Number(indexText)];
|
|
924
|
+
return value === undefined ? '<dynamic template>' : value;
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function skipWsBackward(text, index) {
|
|
929
|
+
while (index > 0 && /\s/.test(text[index - 1])) index -= 1;
|
|
930
|
+
return index;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function matchKeywordBackward(text, end, keyword) {
|
|
934
|
+
// If a whole-word 'keyword' ends exactly at 'end' (left boundary is not an
|
|
935
|
+
// identifier char), return its start index; else -1. Shared by the backward
|
|
936
|
+
// type-erasure scans below. (Note: does NOT treat a leading '.' as a
|
|
937
|
+
// boundary stop — isPrecededByTypeofKeyword needs that extra rule and keeps
|
|
938
|
+
// its own check.)
|
|
939
|
+
const start = end - keyword.length;
|
|
940
|
+
if (start < 0 || text.slice(start, end) !== keyword) return -1;
|
|
941
|
+
if (start > 0 && isIdentifierPart(text[start - 1])) return -1;
|
|
942
|
+
return start;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function isPrecededByTypeofKeyword(text, index) {
|
|
946
|
+
const wordEnd = skipWsBackward(text, index);
|
|
947
|
+
const wordStart = wordEnd - 'typeof'.length;
|
|
948
|
+
if (wordStart < 0 || text.slice(wordStart, wordEnd) !== 'typeof') return false;
|
|
949
|
+
if (wordStart > 0 && (isIdentifierPart(text[wordStart - 1]) || text[wordStart - 1] === '.')) {
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function isTypeOnlyImportAssignmentRequire(text, requireStart) {
|
|
956
|
+
let index = skipWsBackward(text, requireStart);
|
|
957
|
+
if (index === 0 || text[index - 1] !== '=') return false;
|
|
958
|
+
|
|
959
|
+
index = skipWsBackward(text, index - 1);
|
|
960
|
+
|
|
961
|
+
const identifierEnd = index;
|
|
962
|
+
while (index > 0 && isIdentifierPart(text[index - 1])) index -= 1;
|
|
963
|
+
if (index === identifierEnd) return false;
|
|
964
|
+
|
|
965
|
+
const typeStart = matchKeywordBackward(text, skipWsBackward(text, index), 'type');
|
|
966
|
+
if (typeStart === -1) return false;
|
|
967
|
+
|
|
968
|
+
const importStart = matchKeywordBackward(text, skipWsBackward(text, typeStart), 'import');
|
|
969
|
+
return importStart !== -1;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function findCallCloseParen(text, callStart) {
|
|
973
|
+
const open = text.indexOf('(', callStart);
|
|
974
|
+
if (open === -1) return -1;
|
|
975
|
+
let depth = 0;
|
|
976
|
+
for (let index = open; index < text.length; index += 1) {
|
|
977
|
+
const char = text[index];
|
|
978
|
+
if (char === '(') {
|
|
979
|
+
depth += 1;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (char === ')') {
|
|
983
|
+
depth -= 1;
|
|
984
|
+
if (depth === 0) return index;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return -1;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function skipBackwardOverGenericParams(text, end) {
|
|
991
|
+
// text[end-1] is '>': return the index of the matching '<' (the position just
|
|
992
|
+
// before it), or -1 if unbalanced. Used to step over a type name's generic
|
|
993
|
+
// parameter list ('type Foo<T> = ...') when scanning backwards.
|
|
994
|
+
if (text[end - 1] !== '>') return end;
|
|
995
|
+
let depth = 0;
|
|
996
|
+
for (let index = end - 1; index >= 0; index -= 1) {
|
|
997
|
+
const char = text[index];
|
|
998
|
+
if (char === '>') depth += 1;
|
|
999
|
+
else if (char === '<') {
|
|
1000
|
+
depth -= 1;
|
|
1001
|
+
if (depth === 0) return index;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return -1;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function isTypeAliasImportCall(text, importStart) {
|
|
1008
|
+
// "type X = import('pkg')" / "type X<T> = import('pkg')" — a TS type alias
|
|
1009
|
+
// whose whole RHS is a module-type query (no ".Member" / "typeof", so the
|
|
1010
|
+
// existing isTypeQueryImportCall misses it). Erased at runtime, not a dep.
|
|
1011
|
+
// Scan backward: ws, '=', ws, [generic '<...>'], type-name ident, ws, 'type'.
|
|
1012
|
+
// Only ever skips (false negative); a runtime "const X = import()" lacks the
|
|
1013
|
+
// leading "type" keyword and is still flagged.
|
|
1014
|
+
let index = skipWsBackward(text, importStart);
|
|
1015
|
+
if (index === 0 || text[index - 1] !== '=') return false;
|
|
1016
|
+
index = skipWsBackward(text, index - 1);
|
|
1017
|
+
|
|
1018
|
+
if (text[index - 1] === '>') {
|
|
1019
|
+
const open = skipBackwardOverGenericParams(text, index);
|
|
1020
|
+
if (open < 0) return false;
|
|
1021
|
+
index = skipWsBackward(text, open);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const identifierEnd = index;
|
|
1025
|
+
while (index > 0 && isIdentifierPart(text[index - 1])) index -= 1;
|
|
1026
|
+
if (index === identifierEnd) return false;
|
|
1027
|
+
|
|
1028
|
+
return matchKeywordBackward(text, skipWsBackward(text, index), 'type') !== -1;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function isTypeQueryImportCall(text, importStart) {
|
|
1032
|
+
if (isPrecededByTypeofKeyword(text, importStart)) return true;
|
|
1033
|
+
|
|
1034
|
+
const close = findCallCloseParen(text, importStart);
|
|
1035
|
+
if (close === -1) return false;
|
|
1036
|
+
|
|
1037
|
+
let index = close + 1;
|
|
1038
|
+
while (index < text.length && /\s/.test(text[index])) index += 1;
|
|
1039
|
+
if (text[index] !== '.') return false;
|
|
1040
|
+
|
|
1041
|
+
index += 1;
|
|
1042
|
+
while (index < text.length && /\s/.test(text[index])) index += 1;
|
|
1043
|
+
if (index >= text.length || !isIdentifierStart(text[index])) return false;
|
|
1044
|
+
|
|
1045
|
+
const memberEnd = readIdentifier(text, index);
|
|
1046
|
+
const member = text.slice(index, memberEnd);
|
|
1047
|
+
return member !== 'then' && member !== 'catch' && member !== 'finally';
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function computePrefixBracketDepth(masked) {
|
|
1051
|
+
// prefix[i] = net ()/{}/[] nesting depth of masked[0..i). Strings, comments,
|
|
1052
|
+
// regex and template literals are already sentinel-masked, so brackets inside
|
|
1053
|
+
// them don't count. A real ESM statement-form import/export is only valid at
|
|
1054
|
+
// module top level (depth 0); an import-like literal nested in JSX text or any
|
|
1055
|
+
// expression sits at depth > 0. Gating on depth 0 can only ADD a false
|
|
1056
|
+
// negative (never a false positive that breaks a build) — the load-bearing
|
|
1057
|
+
// ADR-018 invariant.
|
|
1058
|
+
const prefix = new Int32Array(masked.length + 1);
|
|
1059
|
+
let depth = 0;
|
|
1060
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
1061
|
+
const char = masked[index];
|
|
1062
|
+
if (char === '(' || char === '{' || char === '[') depth += 1;
|
|
1063
|
+
else if (char === ')' || char === '}' || char === ']') depth -= 1;
|
|
1064
|
+
prefix[index + 1] = depth;
|
|
1065
|
+
}
|
|
1066
|
+
return prefix;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function extractModuleSpecifiers(text, filePath) {
|
|
1070
|
+
const scan = maskStringsAndComments(text);
|
|
1071
|
+
const prefixDepth = computePrefixBracketDepth(scan.masked);
|
|
1072
|
+
const isStatementTopLevel = (matchIndex) => prefixDepth[matchIndex] === 0;
|
|
1073
|
+
const specifiers = new Set();
|
|
1074
|
+
const sentinelSource = STATIC_SENTINEL + '(\\d+)' + STATIC_SENTINEL;
|
|
1075
|
+
const statementStartSource = '(?:^|[\\r\\n])[ \\t]*';
|
|
1076
|
+
const importFromPattern = new RegExp(
|
|
1077
|
+
statementStartSource + '\\bimport(?!\\s+type\\b)\\s+([^;]*?)\\bfrom\\s*' + sentinelSource,
|
|
1078
|
+
'g',
|
|
1079
|
+
);
|
|
1080
|
+
const sideEffectImportPattern = new RegExp(
|
|
1081
|
+
statementStartSource + '\\bimport\\s+' + sentinelSource,
|
|
1082
|
+
'g',
|
|
1083
|
+
);
|
|
1084
|
+
const exportFromPattern = new RegExp(
|
|
1085
|
+
statementStartSource +
|
|
1086
|
+
'\\bexport(?!\\s+type\\b)\\s+((?:\\*\\s+as\\s+[A-Za-z_$][A-Za-z0-9_$]*|\\*|\\{[^}]*\\})\\s*)\\bfrom\\s*' +
|
|
1087
|
+
sentinelSource,
|
|
1088
|
+
'g',
|
|
1089
|
+
);
|
|
1090
|
+
const requirePattern = new RegExp(
|
|
1091
|
+
'\\brequire\\s*\\(\\s*' + sentinelSource + '\\s*(?:,|\\))',
|
|
1092
|
+
'g',
|
|
1093
|
+
);
|
|
1094
|
+
const importCallPattern = new RegExp(
|
|
1095
|
+
'\\bimport\\s*\\(\\s*' + sentinelSource + '\\s*(?:,|\\))',
|
|
1096
|
+
'g',
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
let match;
|
|
1100
|
+
while ((match = importFromPattern.exec(scan.masked)) !== null) {
|
|
1101
|
+
if (!isStatementTopLevel(match.index)) continue;
|
|
1102
|
+
if (!isAllTypeOnlyNamedClause(match[1])) addSpecifier(specifiers, scan.strings, match[2]);
|
|
1103
|
+
}
|
|
1104
|
+
while ((match = sideEffectImportPattern.exec(scan.masked)) !== null) {
|
|
1105
|
+
if (!isStatementTopLevel(match.index)) continue;
|
|
1106
|
+
addSpecifier(specifiers, scan.strings, match[1]);
|
|
1107
|
+
}
|
|
1108
|
+
while ((match = exportFromPattern.exec(scan.masked)) !== null) {
|
|
1109
|
+
if (!isStatementTopLevel(match.index)) continue;
|
|
1110
|
+
if (!isAllTypeOnlyNamedClause(match[1])) addSpecifier(specifiers, scan.strings, match[2]);
|
|
1111
|
+
}
|
|
1112
|
+
while ((match = requirePattern.exec(scan.masked)) !== null) {
|
|
1113
|
+
if (isTypeOnlyImportAssignmentRequire(scan.masked, match.index)) continue;
|
|
1114
|
+
addSpecifier(specifiers, scan.strings, match[1]);
|
|
1115
|
+
}
|
|
1116
|
+
while ((match = importCallPattern.exec(scan.masked)) !== null) {
|
|
1117
|
+
if (isTypeQueryImportCall(scan.masked, match.index)) continue;
|
|
1118
|
+
if (isTypeAliasImportCall(scan.masked, match.index)) continue;
|
|
1119
|
+
addSpecifier(specifiers, scan.strings, match[1]);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const dynamicPattern = /\b(?:require|import)\s*\(\s*([^)]*?)\)/g;
|
|
1123
|
+
let dynamicMatch;
|
|
1124
|
+
while ((dynamicMatch = dynamicPattern.exec(scan.masked)) !== null) {
|
|
1125
|
+
const expression = dynamicMatch[1].trim();
|
|
1126
|
+
if (!expression || expressionStartsWithStaticSentinelArgument(expression)) continue;
|
|
1127
|
+
const relativePath = path.relative(process.cwd(), filePath) || filePath;
|
|
1128
|
+
console.warn(
|
|
1129
|
+
'FlowRail: skipped dynamic module specifier in ' +
|
|
1130
|
+
relativePath +
|
|
1131
|
+
': ' +
|
|
1132
|
+
expressionForWarning(expression, scan.strings, scan.dynamicStrings),
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return specifiers;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function checkUndeclaredRuntimeImports() {
|
|
1140
|
+
const declared = readDeclaredModules();
|
|
1141
|
+
if (declared === null) return true;
|
|
1142
|
+
|
|
1143
|
+
const undeclared = new Set();
|
|
1144
|
+
for (const filePath of collectSourceFiles(process.cwd())) {
|
|
1145
|
+
let text;
|
|
1146
|
+
try {
|
|
1147
|
+
text = readFileSync(filePath, 'utf8');
|
|
1148
|
+
} catch {
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
for (const specifier of extractModuleSpecifiers(text, filePath)) {
|
|
1153
|
+
const packageName = packageNameForSpecifier(specifier);
|
|
1154
|
+
if (packageName !== null && !declared.has(packageName)) {
|
|
1155
|
+
undeclared.add(packageName);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (undeclared.size === 0) return true;
|
|
1161
|
+
|
|
1162
|
+
const names = Array.from(undeclared).sort();
|
|
1163
|
+
console.error(
|
|
1164
|
+
'FlowRail: source imports runtime modules missing from package.json ' +
|
|
1165
|
+
'dependencies/devDependencies/optionalDependencies/peerDependencies:\n' +
|
|
1166
|
+
names.map((name) => ' - ' + name).join('\n') +
|
|
1167
|
+
'\nFix: add the missing module(s) to package.json and re-run install.',
|
|
1168
|
+
);
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (!checkUndeclaredRuntimeImports()) ok = false;
|
|
1173
|
+
|
|
1174
|
+
// ---------------------------------------------------------------------------
|
|
1175
|
+
// ADR-019 Decision 6 — version-skew checks.
|
|
1176
|
+
//
|
|
1177
|
+
// Both checks DEGRADE GRACEFULLY: an inability to PROBE (offline npx,
|
|
1178
|
+
// unreachable server, missing API key) warns and skips — the doctor must
|
|
1179
|
+
// never convert its own probe failure into a hard build failure. Only a
|
|
1180
|
+
// SUCCESSFUL probe that reveals real skew fails the doctor.
|
|
1181
|
+
// ---------------------------------------------------------------------------
|
|
1182
|
+
|
|
1183
|
+
function readClaudeSettings() {
|
|
1184
|
+
try {
|
|
1185
|
+
return JSON.parse(readFileSync(path.join('.claude', 'settings.json'), 'utf8'));
|
|
1186
|
+
} catch {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function flowrailHookCommands(settings) {
|
|
1192
|
+
const commands = [];
|
|
1193
|
+
const hooks = settings && typeof settings === 'object' ? settings.hooks : null;
|
|
1194
|
+
if (!hooks || typeof hooks !== 'object') return commands;
|
|
1195
|
+
for (const groups of Object.values(hooks)) {
|
|
1196
|
+
if (!Array.isArray(groups)) continue;
|
|
1197
|
+
for (const group of groups) {
|
|
1198
|
+
const entries = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
1199
|
+
for (const hook of entries) {
|
|
1200
|
+
if (hook && typeof hook.command === 'string' && hook.command.includes('flowrail-hook')) {
|
|
1201
|
+
commands.push(hook.command);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return commands;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function referencedHookSubcommands(commands) {
|
|
1210
|
+
const subcommands = new Set();
|
|
1211
|
+
for (const command of commands) {
|
|
1212
|
+
const match = command.match(/flowrail-hook\s+([a-z][a-z-]*)/);
|
|
1213
|
+
if (match) subcommands.add(match[1]);
|
|
1214
|
+
}
|
|
1215
|
+
return subcommands;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* (a) Every settings-referenced flowrail-hook subcommand must exist in the
|
|
1220
|
+
* RESOLVED hook version. A stale npx cache running an old hook against newer
|
|
1221
|
+
* wiring mis-fires silently at runtime (the hook's own unknown-subcommand
|
|
1222
|
+
* handling fails open outside PreToolUse) — the doctor is where that skew
|
|
1223
|
+
* surfaces loudly. Probes the hook's "capabilities" subcommand.
|
|
1224
|
+
*/
|
|
1225
|
+
function checkHookSubcommandCapabilities(settings) {
|
|
1226
|
+
const referenced = referencedHookSubcommands(flowrailHookCommands(settings));
|
|
1227
|
+
if (referenced.size === 0) return true;
|
|
1228
|
+
let parsed;
|
|
1229
|
+
try {
|
|
1230
|
+
const out = execFileSync(
|
|
1231
|
+
'npx',
|
|
1232
|
+
['--yes', '-p', '@flowrail/hook', 'flowrail-hook', 'capabilities'],
|
|
1233
|
+
{ encoding: 'utf8', timeout: 20000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
1234
|
+
);
|
|
1235
|
+
parsed = JSON.parse(out);
|
|
1236
|
+
} catch {
|
|
1237
|
+
console.warn(
|
|
1238
|
+
'FlowRail: could not probe flowrail-hook capabilities (offline, npx ' +
|
|
1239
|
+
'unavailable, or a pre-capabilities hook version); skipping the ' +
|
|
1240
|
+
'subcommand version-skew check.',
|
|
1241
|
+
);
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
const advertised = new Set(
|
|
1245
|
+
parsed && Array.isArray(parsed.subcommands) ? parsed.subcommands : [],
|
|
1246
|
+
);
|
|
1247
|
+
const missing = Array.from(referenced).filter((sub) => !advertised.has(sub));
|
|
1248
|
+
if (missing.length === 0) return true;
|
|
1249
|
+
console.error(
|
|
1250
|
+
'FlowRail: .claude/settings.json references flowrail-hook subcommand(s) ' +
|
|
1251
|
+
'missing from the resolved @flowrail/hook version: ' +
|
|
1252
|
+
missing.join(', ') +
|
|
1253
|
+
'\nA stale hook cannot serve these hooks. Fix: refresh the hook — ' +
|
|
1254
|
+
'npx --yes @flowrail/init@latest',
|
|
1255
|
+
);
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function settingsHasFlowrailStopWiring(settings) {
|
|
1260
|
+
const hooks = settings && typeof settings === 'object' ? settings.hooks : null;
|
|
1261
|
+
const groups = hooks && Array.isArray(hooks.Stop) ? hooks.Stop : [];
|
|
1262
|
+
for (const group of groups) {
|
|
1263
|
+
const entries = group && Array.isArray(group.hooks) ? group.hooks : [];
|
|
1264
|
+
for (const hook of entries) {
|
|
1265
|
+
if (hook && typeof hook.command === 'string' && hook.command.includes('flowrail-hook')) {
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* (b) When the server advertises flowrail_review_app (tools/list) but
|
|
1275
|
+
* .claude/settings.json has no FlowRail Stop wiring, whole-app reviews
|
|
1276
|
+
* never trigger — settings written by a pre-ADR-019 init against a newer
|
|
1277
|
+
* server. Needs the base URL (from the hook command's FLOWRAIL_MCP_URL
|
|
1278
|
+
* prefix) and FLOWRAIL_API_KEY; missing either skips silently.
|
|
1279
|
+
*/
|
|
1280
|
+
async function checkStopWiringAgainstServer(settings) {
|
|
1281
|
+
let baseUrl = null;
|
|
1282
|
+
for (const command of flowrailHookCommands(settings)) {
|
|
1283
|
+
const match = command.match(/FLOWRAIL_MCP_URL=(\S+)/);
|
|
1284
|
+
if (match) {
|
|
1285
|
+
baseUrl = match[1];
|
|
1286
|
+
break;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const apiKey = process.env.FLOWRAIL_API_KEY;
|
|
1290
|
+
if (!baseUrl || !apiKey) return true;
|
|
1291
|
+
|
|
1292
|
+
let tools;
|
|
1293
|
+
try {
|
|
1294
|
+
const controller = new AbortController();
|
|
1295
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
1296
|
+
const response = await fetch(baseUrl.replace(/\/+$/, '') + '/mcp', {
|
|
1297
|
+
method: 'POST',
|
|
1298
|
+
headers: {
|
|
1299
|
+
'content-type': 'application/json',
|
|
1300
|
+
authorization: 'Bearer ' + apiKey,
|
|
1301
|
+
},
|
|
1302
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
1303
|
+
signal: controller.signal,
|
|
1304
|
+
});
|
|
1305
|
+
clearTimeout(timer);
|
|
1306
|
+
const body = await response.json();
|
|
1307
|
+
tools = body && body.result && Array.isArray(body.result.tools)
|
|
1308
|
+
? body.result.tools
|
|
1309
|
+
: null;
|
|
1310
|
+
} catch {
|
|
1311
|
+
console.warn(
|
|
1312
|
+
'FlowRail: could not reach the server for tools/list; skipping the ' +
|
|
1313
|
+
'Stop-wiring check.',
|
|
1314
|
+
);
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
if (!tools) return true;
|
|
1318
|
+
const advertisesReviewApp = tools.some(
|
|
1319
|
+
(tool) => tool && tool.name === 'flowrail_review_app',
|
|
1320
|
+
);
|
|
1321
|
+
if (!advertisesReviewApp) return true;
|
|
1322
|
+
if (settingsHasFlowrailStopWiring(settings)) return true;
|
|
1323
|
+
console.error(
|
|
1324
|
+
'FlowRail: the server advertises flowrail_review_app but ' +
|
|
1325
|
+
'.claude/settings.json has no FlowRail Stop hook wiring — whole-app ' +
|
|
1326
|
+
'composition reviews will never trigger.\n' +
|
|
1327
|
+
'Fix: re-run the installer — npx @flowrail/init@latest',
|
|
1328
|
+
);
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const claudeSettings = readClaudeSettings();
|
|
1333
|
+
if (claudeSettings !== null) {
|
|
1334
|
+
if (!checkHookSubcommandCapabilities(claudeSettings)) ok = false;
|
|
1335
|
+
if (!(await checkStopWiringAgainstServer(claudeSettings))) ok = false;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
53
1338
|
if (!ok) process.exit(1);
|
|
54
1339
|
`;
|
|
55
1340
|
/**
|