@flowrail/init 0.0.8 → 0.0.10

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