@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.
@@ -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: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 { 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.\\n' +
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.\\n' +
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.\\n' +
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
  /**