@specverse/engines 6.32.11 → 6.33.0

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 (46) hide show
  1. package/dist/ai/analyse-runner.js +1 -1
  2. package/dist/ai/analyse-runner.js.map +1 -1
  3. package/dist/ai/behaviours-runner.js +1 -1
  4. package/dist/ai/behaviours-runner.js.map +1 -1
  5. package/dist/ai/deployment-emitter.d.ts +3 -0
  6. package/dist/ai/deployment-emitter.d.ts.map +1 -1
  7. package/dist/ai/deployment-emitter.js +145 -0
  8. package/dist/ai/deployment-emitter.js.map +1 -1
  9. package/dist/ai/skeleton-emitter.d.ts +1 -1
  10. package/dist/ai/skeleton-emitter.d.ts.map +1 -1
  11. package/dist/ai/skeleton-emitter.js +73 -26
  12. package/dist/ai/skeleton-emitter.js.map +1 -1
  13. package/dist/analyse-prepass/imports-graph.d.ts +274 -0
  14. package/dist/analyse-prepass/imports-graph.d.ts.map +1 -1
  15. package/dist/analyse-prepass/imports-graph.js +770 -0
  16. package/dist/analyse-prepass/imports-graph.js.map +1 -1
  17. package/dist/analyse-prepass/index.d.ts +20 -0
  18. package/dist/analyse-prepass/index.d.ts.map +1 -1
  19. package/dist/analyse-prepass/index.js +17 -0
  20. package/dist/analyse-prepass/index.js.map +1 -1
  21. package/dist/libs/instance-factories/orms/templates/prisma/schema-generator.js +66 -4
  22. package/dist/parser/unified-parser.d.ts.map +1 -1
  23. package/dist/parser/unified-parser.js +103 -0
  24. package/dist/parser/unified-parser.js.map +1 -1
  25. package/dist/realize/index.d.ts.map +1 -1
  26. package/dist/realize/index.js +73 -148
  27. package/dist/realize/index.js.map +1 -1
  28. package/dist/realize/per-action-emitter.d.ts +235 -0
  29. package/dist/realize/per-action-emitter.d.ts.map +1 -0
  30. package/dist/realize/per-action-emitter.js +229 -0
  31. package/dist/realize/per-action-emitter.js.map +1 -0
  32. package/dist/realize/per-action-llm-emit.d.ts +87 -0
  33. package/dist/realize/per-action-llm-emit.d.ts.map +1 -0
  34. package/dist/realize/per-action-llm-emit.js +427 -0
  35. package/dist/realize/per-action-llm-emit.js.map +1 -0
  36. package/dist/realize/per-action-runner.d.ts +127 -0
  37. package/dist/realize/per-action-runner.d.ts.map +1 -0
  38. package/dist/realize/per-action-runner.js +269 -0
  39. package/dist/realize/per-action-runner.js.map +1 -0
  40. package/dist/realize/structural-validator.d.ts +71 -0
  41. package/dist/realize/structural-validator.d.ts.map +1 -0
  42. package/dist/realize/structural-validator.js +167 -0
  43. package/dist/realize/structural-validator.js.map +1 -0
  44. package/libs/instance-factories/orms/templates/prisma/__tests__/schema-generator.test.ts +416 -0
  45. package/libs/instance-factories/orms/templates/prisma/schema-generator.ts +182 -5
  46. package/package.json +3 -3
@@ -427,4 +427,774 @@ export async function buildImportsByComponentFromBackend(facts, backend) {
427
427
  readFile: (path) => backend.fileSourceText(path),
428
428
  });
429
429
  }
430
+ /**
431
+ * V2 (Component Dependencies V2 — 2026-05-08) — extract dotted operation
432
+ * references from a consumer source file. Walks property-access chains
433
+ * rooted at the import-bound symbol names supplied in `bindings` and
434
+ * returns each chain's tail (`charges.create`, `Calculator.calculate`,
435
+ * etc.). The leading symbol is stripped because it names a JS-side
436
+ * binding, not a SpecVerse-side `select:` entry — the dotted op is what
437
+ * matters for V2's `select: [Charge.create]` grammar extension.
438
+ *
439
+ * Heuristic: regex over `<symbol>(.<segment>)+` where each segment is a
440
+ * camelCase or PascalCase identifier. Skips comments via the same naive
441
+ * stripper as `parseImports`. Two-segment chains (`s.charges`) are
442
+ * dropped — those are property accesses, not operation calls. Three-
443
+ * segment-or-more chains (`stripe.charges.create`) keep the trailing two
444
+ * segments (`charges.create`) as the dotted op. Single bound-symbol
445
+ * call sites (`calculator(...)`) emit nothing — there's no operation
446
+ * to name.
447
+ *
448
+ * The recogniser runs per-file and returns a Set so the caller can
449
+ * dedup across files. Empty input → empty Set.
450
+ */
451
+ export function detectDottedOps(source, bindings) {
452
+ const out = new Set();
453
+ if (!source)
454
+ return out;
455
+ const cleaned = source
456
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
457
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
458
+ for (const sym of bindings) {
459
+ if (!sym || !/^[A-Za-z_$][\w$]*$/.test(sym))
460
+ continue;
461
+ // Match sym followed by ≥1 dotted segment. Anchor on a non-word char
462
+ // (or start-of-string) to avoid spuriously matching tail of a longer
463
+ // identifier (`barCharges` should not match a binding `Charges`).
464
+ const re = new RegExp(`(?:^|[^\\w$.])${escapeRegex(sym)}((?:\\.[A-Za-z_$][\\w$]*)+)`, 'g');
465
+ let m;
466
+ while ((m = re.exec(cleaned)) !== null) {
467
+ const tail = m[1].replace(/^\./, ''); // strip leading dot
468
+ const segments = tail.split('.');
469
+ if (segments.length === 0)
470
+ continue;
471
+ if (segments.length === 1) {
472
+ // Single tail segment — `stripe.create` becomes `create` which
473
+ // is too coarse to be useful. Drop unless it looks like a method
474
+ // call (followed by `(` after optional whitespace).
475
+ const after = cleaned.slice(m.index + m[0].length).match(/^\s*\(/);
476
+ if (!after)
477
+ continue;
478
+ // Even with `(`, drop bare tails — they could be local methods.
479
+ // V2 callers want `Service.op` granularity; bare `op` is the
480
+ // entity-level visibility lane handled by `select: [Service]`.
481
+ continue;
482
+ }
483
+ // Two-or-more tail segments: keep the FINAL two as the dotted op.
484
+ // For `stripe.charges.create` → `charges.create`. For
485
+ // `calculator.calculate` (sym=calculator, tail=calculate) → already
486
+ // handled above. For `service.module.method.deep` → `method.deep`
487
+ // captures the immediate enclosing service + operation.
488
+ const trimmed = segments.slice(-2).join('.');
489
+ // Filter out clearly-non-op tails (numeric, all-caps constants).
490
+ if (!/^[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*$/.test(trimmed))
491
+ continue;
492
+ out.add(trimmed);
493
+ }
494
+ }
495
+ return out;
496
+ }
497
+ function escapeRegex(s) {
498
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
499
+ }
500
+ /**
501
+ * V2 — build both the bare-names imports graph (existing) AND the V2
502
+ * per-target metadata index (version + dotted ops). The bare-names half
503
+ * is identical to `buildImportsByComponent`; the metadata half walks
504
+ * each target component's package.json (for `version`) and re-scans the
505
+ * consumer's source files for dotted-op patterns rooted at symbols
506
+ * bound by imports from that target.
507
+ *
508
+ * Backward-compat: callers that don't need V2 metadata keep using
509
+ * `buildImportsByComponent` directly. The V2-aware skeleton emitter
510
+ * can switch to this two-output variant when emitting `version:` and
511
+ * dotted-form `select:` entries.
512
+ */
513
+ export async function buildImportsByComponentWithMetadata(opts) {
514
+ const components = opts.facts.suggestedComponents ?? [];
515
+ const result = {
516
+ imports: {},
517
+ metadata: {},
518
+ };
519
+ if (components.length === 0)
520
+ return result;
521
+ // Per-target version: read each component's package.json (if any) and
522
+ // record its `version` field. Same fileReader path as the package-name
523
+ // alias loader uses below.
524
+ const versionByTarget = {};
525
+ for (const comp of components) {
526
+ const sd = comp.structural?.sourceDir;
527
+ if (!sd)
528
+ continue;
529
+ try {
530
+ const pkgJson = await opts.readFile(`${sd}/package.json`);
531
+ if (pkgJson) {
532
+ const parsed = JSON.parse(pkgJson);
533
+ if (parsed?.version && typeof parsed.version === 'string') {
534
+ versionByTarget[comp.suggestedName] = parsed.version;
535
+ }
536
+ }
537
+ }
538
+ catch { /* missing or malformed package.json — skip */ }
539
+ }
540
+ // Reuse the existing bare-names build for symbol aggregation.
541
+ result.imports = await buildImportsByComponent(opts);
542
+ // For each consumer × target, scan the consumer's source files for
543
+ // dotted-op patterns rooted at the imported symbols and record them.
544
+ const fileExt = opts.extensionFilter ?? defaultExtFilter;
545
+ const filesByComponent = new Map();
546
+ for (const comp of components)
547
+ filesByComponent.set(comp.suggestedName, []);
548
+ const allFiles = new Set();
549
+ for (const cm of opts.facts.candidateMethods ?? [])
550
+ allFiles.add(cm.filePath);
551
+ for (const v of opts.facts.views ?? [])
552
+ allFiles.add(v.filePath);
553
+ for (const e of opts.facts.entities ?? []) {
554
+ if (e.filePath)
555
+ allFiles.add(e.filePath);
556
+ }
557
+ for (const r of opts.facts.routes ?? []) {
558
+ if (r.filePath)
559
+ allFiles.add(r.filePath);
560
+ }
561
+ for (const file of allFiles) {
562
+ if (!fileExt(file))
563
+ continue;
564
+ const owner = findOwningComponent(file, components);
565
+ if (owner)
566
+ filesByComponent.get(owner).push(file);
567
+ }
568
+ for (const [consumerName, targetMap] of Object.entries(result.imports)) {
569
+ const targetMetaMap = {};
570
+ const consumerFiles = filesByComponent.get(consumerName) ?? [];
571
+ for (const [targetName, names] of Object.entries(targetMap)) {
572
+ const meta = {};
573
+ if (versionByTarget[targetName]) {
574
+ meta.version = versionByTarget[targetName];
575
+ }
576
+ // Scan consumer files for dotted-op references rooted at any of
577
+ // the bound names from this target. Collect across all files.
578
+ const ops = new Set();
579
+ for (const file of consumerFiles) {
580
+ let source = '';
581
+ try {
582
+ source = await opts.readFile(file);
583
+ }
584
+ catch {
585
+ continue;
586
+ }
587
+ const fileOps = detectDottedOps(source, names);
588
+ for (const op of fileOps)
589
+ ops.add(op);
590
+ }
591
+ if (ops.size > 0) {
592
+ meta.dottedOps = [...ops].sort();
593
+ }
594
+ if (meta.version || meta.dottedOps) {
595
+ targetMetaMap[targetName] = meta;
596
+ }
597
+ }
598
+ if (Object.keys(targetMetaMap).length > 0) {
599
+ result.metadata[consumerName] = targetMetaMap;
600
+ }
601
+ }
602
+ return result;
603
+ }
604
+ /**
605
+ * Convert an arbitrary identifier-shaped string to a deployment-instance
606
+ * name (lowerCamelCase). `gitnexus` → `gitnexus`; `@stripe/stripe-js` →
607
+ * `stripeStripeJs`; `Bun.spawn` → `bunSpawn`.
608
+ */
609
+ function toInstanceKey(raw) {
610
+ if (!raw)
611
+ return 'instance';
612
+ const cleaned = raw
613
+ .replace(/^@/, '')
614
+ .replace(/[^A-Za-z0-9]+/g, ' ')
615
+ .trim();
616
+ if (!cleaned)
617
+ return 'instance';
618
+ const parts = cleaned.split(/\s+/);
619
+ const first = parts[0].charAt(0).toLowerCase() + parts[0].slice(1);
620
+ const rest = parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1));
621
+ return [first, ...rest].join('');
622
+ }
623
+ /** Convert a shell-friendly binary name (kebab-case) into a capability suffix. */
624
+ function binaryToCapabilitySuffix(binary) {
625
+ return binary.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
626
+ }
627
+ /**
628
+ * Stripped + cleaned version of a consumer source for detection passes.
629
+ * Same comment-stripping rule as `parseImports` so `// fetch('foo')` in
630
+ * a comment doesn't trigger a false positive.
631
+ */
632
+ function stripCommentsForDetection(source) {
633
+ return source
634
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
635
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
636
+ }
637
+ /**
638
+ * Detect subprocess invocations:
639
+ * - `spawnSync('git', [...])`
640
+ * - `execSync('gitnexus analyze', ...)`
641
+ * - `child_process.spawn('ffmpeg', ...)`
642
+ * - `Bun.spawn(['<binary>', ...])`
643
+ *
644
+ * Heuristics:
645
+ * 1. First positional argument is a string literal — treat as the
646
+ * binary name (`spawnSync('git', [...])` → binary `git`).
647
+ * 2. `Bun.spawn([...])` — first array element string literal is the
648
+ * binary (`Bun.spawn(['ffmpeg', ...])` → `ffmpeg`).
649
+ * 3. `execSync('foo bar baz')` — command string is shell-style; we
650
+ * take the first whitespace-delimited token as the binary
651
+ * (`execSync('git status')` → `git`).
652
+ * 4. Variable arguments (`spawnSync(cmd, args)`) — skipped, can't
653
+ * reliably name the binary.
654
+ *
655
+ * Multiple invocations of the same binary in the same source produce
656
+ * one hint (deduped by binary name).
657
+ *
658
+ * Each hint emits:
659
+ * - `import:` entry with `from: <binary>` (kebab-cased)
660
+ * - deployment instance under `infrastructure:`, `advertises:
661
+ * ["executable.<binary>"]`, `config: { binary: <binary> }`
662
+ */
663
+ export function detectSubprocessCalls(source, sourceFile) {
664
+ if (!source)
665
+ return [];
666
+ const cleaned = stripCommentsForDetection(source);
667
+ const seen = new Map();
668
+ // Pattern A: spawnSync / execSync / spawn / exec — first arg is a
669
+ // string literal binary name OR a shell command string.
670
+ // We accept the function called as a bare identifier OR as a method
671
+ // on `child_process` / `cp` / similar (`childProcess.spawn(...)`).
672
+ const fnRe = /(?:^|[^.\w$])(?:[a-zA-Z_$][\w$]*\.)?(spawn(?:Sync)?|exec(?:Sync|File|FileSync)?)\s*\(\s*['"]([^'"]+)['"]/g;
673
+ let m;
674
+ while ((m = fnRe.exec(cleaned)) !== null) {
675
+ const fn = m[1];
676
+ const argRaw = m[2];
677
+ let binary;
678
+ if (fn.startsWith('exec') && !fn.startsWith('execFile')) {
679
+ // `exec('git status')` — shell command string. Take the first token.
680
+ const firstToken = argRaw.trim().split(/\s+/)[0];
681
+ // Strip path prefix (`/usr/bin/git` → `git`).
682
+ binary = firstToken.split('/').pop();
683
+ }
684
+ else {
685
+ // `spawn('git', [...])` / `execFile('git', [...])` — first arg is
686
+ // the binary directly.
687
+ binary = argRaw.split('/').pop();
688
+ }
689
+ if (!binary || !/^[a-zA-Z][\w.\-]*$/.test(binary))
690
+ continue;
691
+ if (seen.has(binary))
692
+ continue;
693
+ seen.set(binary, makeSubprocessHint(binary, sourceFile));
694
+ }
695
+ // Pattern B: Bun.spawn(['ffmpeg', ...]) — bun-specific, array form.
696
+ const bunRe = /Bun\.spawn\s*\(\s*\[\s*['"]([^'"]+)['"]/g;
697
+ while ((m = bunRe.exec(cleaned)) !== null) {
698
+ const binary = m[1].split('/').pop();
699
+ if (!binary || !/^[a-zA-Z][\w.\-]*$/.test(binary))
700
+ continue;
701
+ if (seen.has(binary))
702
+ continue;
703
+ seen.set(binary, makeSubprocessHint(binary, sourceFile));
704
+ }
705
+ return [...seen.values()];
706
+ }
707
+ function makeSubprocessHint(binary, sourceFile) {
708
+ const capability = `executable.${binaryToCapabilitySuffix(binary)}`;
709
+ return {
710
+ from: binary,
711
+ pattern: 'subprocess',
712
+ ...(sourceFile ? { sourceFile } : {}),
713
+ deploymentHint: {
714
+ category: 'infrastructure',
715
+ instanceName: toInstanceKey(binary),
716
+ advertises: [capability],
717
+ config: { binary },
718
+ },
719
+ };
720
+ }
721
+ /**
722
+ * Detect HTTP/RPC service-client construction:
723
+ * - `axios.create({ baseURL: '...' })` — REST client
724
+ * - `axios.create({ baseURL: process.env.X })` — env-driven REST client
725
+ * - `fetch(BASE_URL + ...)` / `fetch(\`${BASE}/foo\`)` — bare fetch
726
+ * - `new GreeterClient('host:port', ...)` — gRPC stub instantiation
727
+ * - `new Client({ url: ... })` — generic MCP / RPC client
728
+ *
729
+ * Heuristics:
730
+ * 1. axios.create({ baseURL }) — extracts the baseURL string when literal,
731
+ * or the env-var name when the value is `process.env.<X>`.
732
+ * 2. Bare `fetch(<expr>)` — only when called against an env-var-shaped
733
+ * identifier (`process.env.X` or an UPPER_CASE constant the consumer
734
+ * owns); otherwise too noisy. The env-var becomes the hint's
735
+ * `endpoint.env`.
736
+ * 3. gRPC stubs are tagged by the `*Client` suffix on a constructor
737
+ * whose first arg is a hostname-shaped string ('host:port').
738
+ *
739
+ * Returns an empty array when no patterns match. Multiple distinct
740
+ * baseURLs in one file produce multiple hints.
741
+ *
742
+ * Each hint emits:
743
+ * - `import:` entry with `from: <baseURL-host or env-var>`
744
+ * - deployment instance under `services:`, `advertises:
745
+ * ["api.rest"]` (or `api.grpc` / `api.rpc`), `config:
746
+ * { endpoint: { env: <ENV_NAME> | url: <URL> } }`
747
+ */
748
+ export function detectServiceClients(source, sourceFile) {
749
+ if (!source)
750
+ return [];
751
+ const cleaned = stripCommentsForDetection(source);
752
+ const out = [];
753
+ const seen = new Set();
754
+ // axios.create({ baseURL: ... }) — both literal and env-var.
755
+ // Match the call up to the closing brace of the options object; we
756
+ // don't need a full parser, just a non-greedy scan.
757
+ const axiosRe = /(?:axios|httpClient|http)\s*\.\s*create\s*\(\s*\{([^}]*)\}/g;
758
+ let m;
759
+ while ((m = axiosRe.exec(cleaned)) !== null) {
760
+ const optsBody = m[1];
761
+ const literalMatch = optsBody.match(/baseURL\s*:\s*['"`]([^'"`]+)['"`]/);
762
+ const envMatch = optsBody.match(/baseURL\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/);
763
+ if (literalMatch) {
764
+ const url = literalMatch[1];
765
+ const host = extractHost(url);
766
+ if (!host)
767
+ continue;
768
+ const key = `axios:${host}`;
769
+ if (seen.has(key))
770
+ continue;
771
+ seen.add(key);
772
+ out.push(makeServiceClientHint({
773
+ from: host,
774
+ protocol: 'rest',
775
+ endpointUrl: url,
776
+ sourceFile,
777
+ }));
778
+ }
779
+ else if (envMatch) {
780
+ const env = envMatch[1];
781
+ const key = `axios-env:${env}`;
782
+ if (seen.has(key))
783
+ continue;
784
+ seen.add(key);
785
+ out.push(makeServiceClientHint({
786
+ from: envToProviderName(env),
787
+ protocol: 'rest',
788
+ endpointEnv: env,
789
+ sourceFile,
790
+ }));
791
+ }
792
+ }
793
+ // Bare fetch(process.env.X + ...) or fetch(`${process.env.X}/...`).
794
+ // Restricted form to avoid false positives against in-tree fetch calls.
795
+ const fetchEnvRe = /\bfetch\s*\(\s*(?:`[^`]*\$\{)?process\.env\.([A-Z][A-Z0-9_]*)/g;
796
+ while ((m = fetchEnvRe.exec(cleaned)) !== null) {
797
+ const env = m[1];
798
+ const key = `fetch-env:${env}`;
799
+ if (seen.has(key))
800
+ continue;
801
+ seen.add(key);
802
+ out.push(makeServiceClientHint({
803
+ from: envToProviderName(env),
804
+ protocol: 'rest',
805
+ endpointEnv: env,
806
+ sourceFile,
807
+ }));
808
+ }
809
+ // gRPC stub: `new <Pascal>Client('host:port', ...)`. Looks for the
810
+ // canonical grpc-js / grpc-web convention.
811
+ const grpcRe = /new\s+([A-Z][\w$]*Client)\s*\(\s*['"]([\w.\-]+:\d+)['"]/g;
812
+ while ((m = grpcRe.exec(cleaned)) !== null) {
813
+ const stub = m[1];
814
+ const target = m[2];
815
+ const key = `grpc:${stub}`;
816
+ if (seen.has(key))
817
+ continue;
818
+ seen.add(key);
819
+ const providerName = stub.replace(/Client$/, '');
820
+ out.push(makeServiceClientHint({
821
+ from: providerName,
822
+ protocol: 'grpc',
823
+ endpointUrl: target,
824
+ sourceFile,
825
+ }));
826
+ }
827
+ return out;
828
+ }
829
+ function makeServiceClientHint(opts) {
830
+ const advertise = `api.${opts.protocol}`;
831
+ const endpoint = {};
832
+ if (opts.endpointEnv)
833
+ endpoint.env = opts.endpointEnv;
834
+ if (opts.endpointUrl)
835
+ endpoint.url = opts.endpointUrl;
836
+ return {
837
+ from: opts.from,
838
+ pattern: 'service-client',
839
+ ...(opts.sourceFile ? { sourceFile: opts.sourceFile } : {}),
840
+ deploymentHint: {
841
+ category: 'services',
842
+ instanceName: toInstanceKey(opts.from),
843
+ advertises: [advertise],
844
+ config: { endpoint },
845
+ },
846
+ };
847
+ }
848
+ /**
849
+ * Best-effort hostname extraction. Drops protocol + path; returns the
850
+ * host portion (which may include a port). Returns null for clearly-
851
+ * malformed URLs. The host doubles as a `from:` value for the import.
852
+ */
853
+ function extractHost(url) {
854
+ // Drop protocol.
855
+ const protoStrip = url.replace(/^[a-z][a-zA-Z0-9+.-]*:\/\//, '');
856
+ // First path/query segment.
857
+ const host = protoStrip.split(/[/?#]/)[0];
858
+ if (!host || /\$\{|\$/.test(host))
859
+ return null; // unresolved interpolation
860
+ return host;
861
+ }
862
+ /**
863
+ * Map `XXX_API_URL` / `STRIPE_KEY` style env-var names to a provider
864
+ * name suitable for `from:`. Drops the trailing _URL / _KEY / _BASE_URL
865
+ * suffix and lowercases. Falls back to lowercased env-var when no
866
+ * suffix matches.
867
+ */
868
+ function envToProviderName(env) {
869
+ const stripped = env.replace(/_(API_URL|BASE_URL|URL|KEY|TOKEN|SECRET|HOST|ENDPOINT)$/i, '');
870
+ return stripped.toLowerCase().replace(/_/g, '-');
871
+ }
872
+ /**
873
+ * Detect async messaging crossings:
874
+ * - `rabbit.subscribe('queue', handler)` / `rabbitMq.publish(...)`
875
+ * - `kafka.consume(...)` / `kafka.subscribe(...)` / `kafkaProducer.send(...)`
876
+ * - `eventBus.publish(...)` / `eventBus.subscribe(...)` (in-memory or
877
+ * cross-component bus crossings)
878
+ * - `nats.subscribe(...)` / `nats.publish(...)`
879
+ * - `redis.xadd(...)` (streams) / `redis.publish(...)` (pub-sub)
880
+ *
881
+ * Heuristic:
882
+ * - Match `<broker-handle>.<verb>(...)` where verb ∈ {subscribe,
883
+ * consume, publish, send, xadd}.
884
+ * - Broker name comes from the receiver identifier (`rabbitMq`,
885
+ * `kafka`, `eventBus`).
886
+ * - First positional arg, if a string literal, is recorded as a
887
+ * `queues[]` / `topics[]` entry on the deployment hint.
888
+ *
889
+ * Each hint emits:
890
+ * - `import:` entry with `from: <broker>` (lowercased)
891
+ * - deployment instance under `communications:`, `advertises:
892
+ * ["messaging.<broker>"]`, `config: { broker, queues: [...] }`
893
+ */
894
+ export function detectMessagingCrossings(source, sourceFile) {
895
+ if (!source)
896
+ return [];
897
+ const cleaned = stripCommentsForDetection(source);
898
+ const byBroker = new Map();
899
+ // Receiver names we recognise as brokers. Conservative list to keep
900
+ // false-positive rate low; arbitrary `*.publish()` would over-match.
901
+ const BROKER_RE = /\b(rabbit|rabbitmq|rabbitMq|kafka|kafkaProducer|kafkaConsumer|nats|redis|eventBus|amqp|sns|sqs|sqsClient|snsClient)\b/;
902
+ const verbsRe = /\b(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(/;
903
+ // Combined: `<broker>.<verb>('queueOrTopic', ...)` — first arg literal.
904
+ const callRe = /\b([a-zA-Z_$][\w$]*)\s*\.\s*(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(\s*['"]([^'"]+)['"]/g;
905
+ let m;
906
+ while ((m = callRe.exec(cleaned)) !== null) {
907
+ const receiver = m[1];
908
+ const queue = m[3];
909
+ if (!BROKER_RE.test(receiver))
910
+ continue;
911
+ const broker = receiverToBrokerName(receiver);
912
+ if (!byBroker.has(broker)) {
913
+ byBroker.set(broker, { queues: new Set(), broker });
914
+ }
915
+ byBroker.get(broker).queues.add(queue);
916
+ }
917
+ // Also catch `<broker>.<verb>(<non-string-arg>)` so the broker still
918
+ // shows up as a hint, just with no queues recorded.
919
+ const argLessRe = /\b([a-zA-Z_$][\w$]*)\s*\.\s*(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(/g;
920
+ while ((m = argLessRe.exec(cleaned)) !== null) {
921
+ const receiver = m[1];
922
+ if (!BROKER_RE.test(receiver))
923
+ continue;
924
+ if (!verbsRe.test(m[0]))
925
+ continue;
926
+ const broker = receiverToBrokerName(receiver);
927
+ if (!byBroker.has(broker)) {
928
+ byBroker.set(broker, { queues: new Set(), broker });
929
+ }
930
+ // No queue to add.
931
+ }
932
+ const out = [];
933
+ for (const { broker, queues } of byBroker.values()) {
934
+ out.push(makeMessagingHint(broker, [...queues].sort(), sourceFile));
935
+ }
936
+ return out;
937
+ }
938
+ /** Normalise a JS-side broker handle to a canonical broker name. */
939
+ function receiverToBrokerName(receiver) {
940
+ const lower = receiver.toLowerCase();
941
+ if (lower.startsWith('rabbit'))
942
+ return 'rabbitmq';
943
+ if (lower.startsWith('kafka'))
944
+ return 'kafka';
945
+ if (lower.startsWith('nats'))
946
+ return 'nats';
947
+ if (lower.startsWith('redis'))
948
+ return 'redis';
949
+ if (lower.startsWith('amqp'))
950
+ return 'amqp';
951
+ if (lower.includes('sqs'))
952
+ return 'sqs';
953
+ if (lower.includes('sns'))
954
+ return 'sns';
955
+ if (lower === 'eventbus')
956
+ return 'eventbus';
957
+ return lower;
958
+ }
959
+ function makeMessagingHint(broker, queues, sourceFile) {
960
+ const config = { broker };
961
+ if (queues.length > 0)
962
+ config.queues = queues;
963
+ // Capability tag: messaging.<broker>. The `<topic-domain>` form in the
964
+ // proposal would require domain inference from queue names — not done
965
+ // here; spec author tunes if needed.
966
+ const advertise = `messaging.${broker}`;
967
+ return {
968
+ from: broker,
969
+ pattern: 'messaging',
970
+ ...(sourceFile ? { sourceFile } : {}),
971
+ deploymentHint: {
972
+ category: 'communications',
973
+ instanceName: toInstanceKey(broker),
974
+ advertises: [advertise],
975
+ config,
976
+ },
977
+ };
978
+ }
979
+ /**
980
+ * Detect known managed-SaaS SDK initialisations:
981
+ * - `new Stripe(apiKey, ...)` → from: `@stripe/stripe-js`, advertises:
982
+ * `managed.payments`
983
+ * - `new OpenAI({ apiKey })` → from: `openai`, advertises: `managed.ai`
984
+ * - `new Anthropic({ apiKey })` → from: `@anthropic-ai/sdk`, advertises:
985
+ * `managed.ai`
986
+ * - `new SESClient({ region })` / AWS SDK init → from: `@aws-sdk/<x>`
987
+ * - `Twilio(sid, token)` → from: `twilio`, advertises: `managed.sms`
988
+ * - `new SendGridClient({ apiKey })` / `sgMail.setApiKey(...)` →
989
+ * from: `@sendgrid/mail`, advertises: `managed.email`
990
+ *
991
+ * The lookup table is INTENTIONALLY conservative — only well-known SDKs
992
+ * with stable name signatures. For unknown providers, the spec author
993
+ * adds the `import:` block manually.
994
+ *
995
+ * Authentication env-var inference:
996
+ * - When the constructor is called with `process.env.X`, that's the
997
+ * authEnv.
998
+ * - Otherwise we leave `authEnv` unset; the deployment-instance config
999
+ * just carries `provider:` and the spec author fills the env wiring.
1000
+ *
1001
+ * Each hint emits:
1002
+ * - `import:` entry with `from: <scoped-package-name>` and version
1003
+ * when the consumer's package.json names it as a dependency.
1004
+ * - deployment instance under `infrastructure:`, `advertises:
1005
+ * ["managed.<domain>"]`, `config: { provider: <name>, authEnv: <ENV> }`
1006
+ */
1007
+ export function detectManagedSdkInit(source, sourceFile) {
1008
+ if (!source)
1009
+ return [];
1010
+ const cleaned = stripCommentsForDetection(source);
1011
+ const out = [];
1012
+ const seen = new Set();
1013
+ // Known-SDK lookup. Keys are constructor identifiers (`Stripe`,
1014
+ // `OpenAI`, etc.); values describe the canonical scoped package name
1015
+ // and capability tag.
1016
+ const SDK_TABLE = {
1017
+ Stripe: { pkg: '@stripe/stripe-js', provider: 'stripe', advertise: 'managed.payments' },
1018
+ OpenAI: { pkg: 'openai', provider: 'openai', advertise: 'managed.ai' },
1019
+ Anthropic: { pkg: '@anthropic-ai/sdk', provider: 'anthropic', advertise: 'managed.ai' },
1020
+ Twilio: { pkg: 'twilio', provider: 'twilio', advertise: 'managed.sms' },
1021
+ SendGridClient: { pkg: '@sendgrid/client', provider: 'sendgrid', advertise: 'managed.email' },
1022
+ SESClient: { pkg: '@aws-sdk/client-ses', provider: 'aws-ses', advertise: 'managed.email' },
1023
+ SNSClient: { pkg: '@aws-sdk/client-sns', provider: 'aws-sns', advertise: 'managed.sns' },
1024
+ SQSClient: { pkg: '@aws-sdk/client-sqs', provider: 'aws-sqs', advertise: 'managed.queue' },
1025
+ S3Client: { pkg: '@aws-sdk/client-s3', provider: 'aws-s3', advertise: 'managed.storage' },
1026
+ DynamoDBClient: { pkg: '@aws-sdk/client-dynamodb', provider: 'aws-dynamodb', advertise: 'managed.database' },
1027
+ PrismaClient: { pkg: '@prisma/client', provider: 'prisma', advertise: 'managed.database' },
1028
+ };
1029
+ // Match `new <Ctor>(...)` for each known constructor. Capture the
1030
+ // first-arg form so we can pull out `process.env.X` if present.
1031
+ for (const [ctor, info] of Object.entries(SDK_TABLE)) {
1032
+ const re = new RegExp(`new\\s+${escapeRegex(ctor)}\\s*\\(([^)]*)\\)`, 'g');
1033
+ let m;
1034
+ while ((m = re.exec(cleaned)) !== null) {
1035
+ const args = m[1] ?? '';
1036
+ const envMatch = args.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
1037
+ const key = `${info.pkg}:${envMatch ? envMatch[1] : 'noenv'}`;
1038
+ if (seen.has(key))
1039
+ continue;
1040
+ seen.add(key);
1041
+ const config = { provider: info.provider };
1042
+ if (envMatch)
1043
+ config.authEnv = envMatch[1];
1044
+ out.push({
1045
+ from: info.pkg,
1046
+ pattern: 'managed-sdk',
1047
+ ...(sourceFile ? { sourceFile } : {}),
1048
+ deploymentHint: {
1049
+ category: 'infrastructure',
1050
+ instanceName: toInstanceKey(info.provider),
1051
+ advertises: [info.advertise],
1052
+ config,
1053
+ },
1054
+ });
1055
+ }
1056
+ }
1057
+ // Also catch the bare-call shape Twilio uses (`Twilio(sid, token)`).
1058
+ const twilioRe = /(?:^|[^.\w$])Twilio\s*\(\s*([^)]*)\)/g;
1059
+ let tm;
1060
+ while ((tm = twilioRe.exec(cleaned)) !== null) {
1061
+ const args = tm[1] ?? '';
1062
+ const envMatch = args.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
1063
+ const key = `twilio:${envMatch ? envMatch[1] : 'noenv'}`;
1064
+ if (seen.has(key))
1065
+ continue;
1066
+ seen.add(key);
1067
+ const config = { provider: 'twilio' };
1068
+ if (envMatch)
1069
+ config.authEnv = envMatch[1];
1070
+ out.push({
1071
+ from: 'twilio',
1072
+ pattern: 'managed-sdk',
1073
+ ...(sourceFile ? { sourceFile } : {}),
1074
+ deploymentHint: {
1075
+ category: 'infrastructure',
1076
+ instanceName: toInstanceKey('twilio'),
1077
+ advertises: ['managed.sms'],
1078
+ config,
1079
+ },
1080
+ });
1081
+ }
1082
+ return out;
1083
+ }
1084
+ /**
1085
+ * Run all four V2 Phase 2 detections against a single source file and
1086
+ * return a merged hint list. Convenience wrapper used by the prepass
1087
+ * orchestrator.
1088
+ */
1089
+ export function detectNonLibraryDeps(source, sourceFile) {
1090
+ return [
1091
+ ...detectSubprocessCalls(source, sourceFile),
1092
+ ...detectServiceClients(source, sourceFile),
1093
+ ...detectMessagingCrossings(source, sourceFile),
1094
+ ...detectManagedSdkInit(source, sourceFile),
1095
+ ];
1096
+ }
1097
+ /**
1098
+ * Build the per-component non-library imports index. Walks every
1099
+ * consumer component's source files, runs the four detection passes,
1100
+ * deduplicates by (from, pattern) within a component, and unions the
1101
+ * select / queues fields. File walking reuses the same file-discovery
1102
+ * logic as `buildImportsByComponent` so detections are scoped to the
1103
+ * same surface as in-tree imports.
1104
+ */
1105
+ export async function buildNonLibraryImportsByComponent(opts) {
1106
+ const result = {};
1107
+ const components = opts.facts.suggestedComponents ?? [];
1108
+ if (components.length === 0)
1109
+ return result;
1110
+ const fileExt = opts.extensionFilter ?? defaultExtFilter;
1111
+ const filesByComponent = new Map();
1112
+ for (const comp of components)
1113
+ filesByComponent.set(comp.suggestedName, []);
1114
+ const allFiles = new Set();
1115
+ for (const cm of opts.facts.candidateMethods ?? [])
1116
+ allFiles.add(cm.filePath);
1117
+ for (const v of opts.facts.views ?? [])
1118
+ allFiles.add(v.filePath);
1119
+ for (const e of opts.facts.entities ?? []) {
1120
+ if (e.filePath)
1121
+ allFiles.add(e.filePath);
1122
+ }
1123
+ for (const r of opts.facts.routes ?? []) {
1124
+ if (r.filePath)
1125
+ allFiles.add(r.filePath);
1126
+ }
1127
+ for (const file of allFiles) {
1128
+ if (!fileExt(file))
1129
+ continue;
1130
+ const owner = findOwningComponent(file, components);
1131
+ if (owner)
1132
+ filesByComponent.get(owner).push(file);
1133
+ }
1134
+ for (const consumer of components) {
1135
+ const files = filesByComponent.get(consumer.suggestedName) ?? [];
1136
+ if (files.length === 0)
1137
+ continue;
1138
+ // Merge per (from, pattern) tuple so multiple subprocess calls or
1139
+ // multiple managed-SDK constructors from the same provider collapse.
1140
+ const merged = new Map();
1141
+ for (const file of files) {
1142
+ let source = '';
1143
+ try {
1144
+ source = await opts.readFile(file);
1145
+ }
1146
+ catch {
1147
+ continue;
1148
+ }
1149
+ const hints = detectNonLibraryDeps(source, file);
1150
+ for (const hint of hints) {
1151
+ const key = `${hint.pattern}:${hint.from}`;
1152
+ const existing = merged.get(key);
1153
+ if (!existing) {
1154
+ merged.set(key, hint);
1155
+ continue;
1156
+ }
1157
+ // Union select arrays.
1158
+ if (hint.select) {
1159
+ const sel = new Set([...(existing.select ?? []), ...hint.select]);
1160
+ existing.select = [...sel].sort();
1161
+ }
1162
+ // Union queues.
1163
+ const exQ = existing.deploymentHint.config.queues;
1164
+ const newQ = hint.deploymentHint.config.queues;
1165
+ if (newQ && newQ.length > 0) {
1166
+ const unioned = new Set([...(exQ ?? []), ...newQ]);
1167
+ existing.deploymentHint.config.queues = [...unioned].sort();
1168
+ }
1169
+ // Prefer an existing authEnv; otherwise inherit a newly-detected one.
1170
+ const exAuth = existing.deploymentHint.config.authEnv;
1171
+ const newAuth = hint.deploymentHint.config.authEnv;
1172
+ if (!exAuth && newAuth) {
1173
+ existing.deploymentHint.config.authEnv = newAuth;
1174
+ }
1175
+ }
1176
+ }
1177
+ if (merged.size > 0) {
1178
+ // Stable order: sort by pattern then `from:`.
1179
+ const arr = [...merged.values()].sort((a, b) => {
1180
+ if (a.pattern !== b.pattern)
1181
+ return a.pattern.localeCompare(b.pattern);
1182
+ return a.from.localeCompare(b.from);
1183
+ });
1184
+ result[consumer.suggestedName] = arr;
1185
+ }
1186
+ }
1187
+ return result;
1188
+ }
1189
+ /**
1190
+ * Convenience: run the V2 Phase 2 non-library walker against a
1191
+ * StructuralPrepass backend (analogous to
1192
+ * `buildImportsByComponentFromBackend`).
1193
+ */
1194
+ export async function buildNonLibraryImportsByComponentFromBackend(facts, backend) {
1195
+ return buildNonLibraryImportsByComponent({
1196
+ facts,
1197
+ readFile: (path) => backend.fileSourceText(path),
1198
+ });
1199
+ }
430
1200
  //# sourceMappingURL=imports-graph.js.map