@cat-factory/integrations 0.77.3 → 0.77.5

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.
@@ -1,3 +1,4 @@
1
+ import { BudgetedRepoScanner, joinRepoPath } from '@cat-factory/kernel';
1
2
  import { parse as parseYaml, parseAllDocuments } from 'yaml';
2
3
  import { extractComposeProfiles, extractExternalNetworks, hasBuildDirective, } from '../compose/compose-environment.logic.js';
3
4
  import { RepoReadError } from './repo-read-error.js';
@@ -20,7 +21,7 @@ const COMPOSE_FILES = [
20
21
  'docker-compose.prod.yml',
21
22
  'docker-compose.dev.yaml',
22
23
  'docker-compose.dev.yml',
23
- // A bare `dev.yml` base (the acme-main `docker/dev.yml` shape) — lowest priority so a
24
+ // A bare `dev.yml` base (the acme-monolith `docker/dev.yml` shape) — lowest priority so a
24
25
  // canonical name still wins, but recognized so a complex multi-file compose repo is detected
25
26
  // (its OS overrides `dev.<os>.override.yml` become recipe compose-file candidates).
26
27
  'dev.yaml',
@@ -30,6 +31,10 @@ const COMPOSE_FILES = [
30
31
  // it — so unlike the canonical `compose.*`/`docker-compose.*` names it is only accepted as a compose
31
32
  // file when it actually declares a `services:` map (an empty/absent one ⇒ it isn't a compose file).
32
33
  const AMBIGUOUS_COMPOSE_FILES = new Set(['dev.yaml', 'dev.yml']);
34
+ // The built-in (canonical) compose names, as a Set for a cheap membership test. A convention-added
35
+ // EXTRA name (not in here) is non-canonical, so — like the bare `dev.*` names — it is trusted as a
36
+ // compose file only when it actually declares `services:` (see `findCompose`).
37
+ const COMPOSE_FILE_SET = new Set(COMPOSE_FILES);
33
38
  // Directories (relative to the service root) a compose file commonly nests under, in addition to
34
39
  // the root itself. One `listDir` per entry (cheap membership test against COMPOSE_FILES).
35
40
  const COMPOSE_DIR_CANDIDATES = ['', 'deploy', 'docker', '.docker', 'compose'];
@@ -115,7 +120,7 @@ const ENV_EXAMPLE_FILES = ['.env.example', '.env.sample', '.env.template', '.env
115
120
  // intentionally SEQUENTIAL (not batched/parallel): the budget short-circuit and the "first present
116
121
  // name/dir wins" ordering both depend on deterministic, in-order accounting. In practice a real
117
122
  // repo resolves in a handful of reads well before the cap; the cap only bites on decoy-heavy repos,
118
- // where truncation is surfaced as a note (see `Scanner.exhausted`).
123
+ // where truncation is surfaced as a note (see `BudgetedRepoScanner.exhausted`).
119
124
  const READ_BUDGET = 200;
120
125
  const MAX_IMAGES = 8;
121
126
  // ---- Slice 2: stack-recipe detection (compose repos) -----------------------------------------
@@ -165,22 +170,42 @@ const OS_OVERRIDE_RE = /^(.+?)\.(wsl|mac|macos|osx|linux|windows|win)(?:\.overri
165
170
  const MAKEFILE_NAMES = ['Makefile', 'makefile', 'GNUmakefile'];
166
171
  const JUSTFILE_NAMES = ['justfile', 'Justfile', '.justfile'];
167
172
  const TASKFILE_NAMES = ['Taskfile.yml', 'Taskfile.yaml', 'taskfile.yml', 'taskfile.yaml'];
168
- /** Join + normalize repo-relative path segments, collapsing `.`/`..` (resolves `../base` refs). */
169
- function joinPath(...parts) {
170
- const segs = [];
171
- for (const part of parts) {
172
- if (!part)
173
- continue;
174
- for (const seg of part.split('/')) {
175
- if (!seg || seg === '.')
176
- continue;
177
- if (seg === '..')
178
- segs.pop();
179
- else
180
- segs.push(seg);
173
+ // Monorepo "service container" dirs an env/config template commonly lives ONE LEVEL DOWN in
174
+ // (`services/app/.env.dev.local-dist`, `apps/web/.env.example`). Scanned a single level deep by
175
+ // `collectEnvFileTemplates` in addition to the root-level dirs, so a per-service template outside
176
+ // the compose dir is still surfaced (the pilot's documented `services/app/` gap).
177
+ const ENV_TEMPLATE_CONTAINER_DIRS = ['services', 'apps', 'packages'];
178
+ /** Append `extras` after `base`, dropping any already present in `base` (base wins / stays first). */
179
+ function withExtras(base, extras) {
180
+ if (!extras || extras.length === 0)
181
+ return [...base];
182
+ const seen = new Set(base);
183
+ const out = [...base];
184
+ for (const raw of extras) {
185
+ const value = raw.trim();
186
+ if (value && !seen.has(value)) {
187
+ seen.add(value);
188
+ out.push(value);
181
189
  }
182
190
  }
183
- return segs.join('/');
191
+ return out;
192
+ }
193
+ /**
194
+ * The compose file names to try, canonical-first: the built-in {@link COMPOSE_FILES} then any
195
+ * deployment-supplied extras (lowest priority, so a canonical name still wins). The
196
+ * {@link AMBIGUOUS_COMPOSE_FILES} `services:`-required guard still applies to the bare `dev.*` names.
197
+ */
198
+ function resolveComposeFileNames(conventions) {
199
+ return withExtras(COMPOSE_FILES, conventions?.composeFiles);
200
+ }
201
+ function resolveComposeDirs(conventions) {
202
+ return withExtras(COMPOSE_DIR_CANDIDATES, conventions?.composeDirs);
203
+ }
204
+ function resolveSeedDirs(conventions) {
205
+ return withExtras(SEED_DIRS, conventions?.seedDirs);
206
+ }
207
+ function resolveEnvTemplateDirs(conventions) {
208
+ return withExtras(ENV_TEMPLATE_DIR_CANDIDATES, conventions?.envTemplateDirs);
184
209
  }
185
210
  function isYamlFile(name) {
186
211
  return name.endsWith('.yaml') || name.endsWith('.yml');
@@ -215,92 +240,6 @@ function parseOne(content) {
215
240
  return null;
216
241
  }
217
242
  }
218
- /**
219
- * Stateful repo reader with a hard read budget so detection can't fan out without bound. Reads are
220
- * MEMOIZED per path: the compose + recipe passes list several dirs in common (the repo root is a
221
- * candidate for k8s roots, compose dirs, env-template dirs, and the repo-CLI scan), so caching keeps
222
- * each unique path to a single real round-trip and stops those overlaps from burning the budget. A
223
- * cache hit is free (no budget spend) and deterministic, so the "first present name/dir wins"
224
- * ordering and the budget short-circuit are unaffected.
225
- */
226
- class Scanner {
227
- reader;
228
- gitRef;
229
- reads = 0;
230
- firstFault;
231
- fileCache = new Map();
232
- dirCache = new Map();
233
- constructor(reader, gitRef) {
234
- this.reader = reader;
235
- this.gitRef = gitRef;
236
- }
237
- /** True once the read budget was hit — the scan may have stopped short of the full repo. */
238
- get exhausted() {
239
- return this.reads >= READ_BUDGET;
240
- }
241
- /**
242
- * The message of the FIRST genuine read fault the reader threw (auth/permission revoked, rate
243
- * limit, transport/token-mint error), else `undefined`. A miss (absent path) is NOT a fault. The
244
- * caller raises {@link RepoReadError} when it detected nothing AND this is set — so a truly
245
- * unreadable repo surfaces an actionable error instead of a misleading "nothing found".
246
- */
247
- get readFault() {
248
- return this.firstFault;
249
- }
250
- recordFault(err) {
251
- if (this.firstFault === undefined) {
252
- this.firstFault = err instanceof Error ? err.message : String(err);
253
- }
254
- }
255
- async getFile(path) {
256
- const cached = this.fileCache.get(path);
257
- if (cached !== undefined)
258
- return cached;
259
- if (this.reads >= READ_BUDGET)
260
- return null;
261
- this.reads++;
262
- let content = null;
263
- try {
264
- const file = await this.reader.getFile(path, this.gitRef);
265
- content = file?.content ?? null;
266
- }
267
- catch (err) {
268
- // A genuine read fault (non-404 — the reader turns 404 into null itself). Keep scanning
269
- // best-effort (a transient fault mustn't lose a good result) but record it so an all-miss
270
- // outcome can be reported as "couldn't read" rather than "nothing found".
271
- this.recordFault(err);
272
- }
273
- this.fileCache.set(path, content);
274
- return content;
275
- }
276
- /** Read the first present file among `names` in `dir`; returns its content + matched name. */
277
- async getFirstFile(dir, names) {
278
- for (const name of names) {
279
- const content = await this.getFile(joinPath(dir, name));
280
- if (content !== null)
281
- return { name, content };
282
- }
283
- return null;
284
- }
285
- async listDir(path) {
286
- const cached = this.dirCache.get(path);
287
- if (cached !== undefined)
288
- return cached;
289
- if (this.reads >= READ_BUDGET)
290
- return [];
291
- this.reads++;
292
- try {
293
- const entries = await this.reader.listDirectory(path, this.gitRef);
294
- this.dirCache.set(path, entries);
295
- return entries;
296
- }
297
- catch (err) {
298
- this.recordFault(err);
299
- this.dirCache.set(path, []);
300
- return [];
301
- }
302
- }
303
- }
304
243
  function emptyScan() {
305
244
  return {
306
245
  kinds: new Set(),
@@ -364,7 +303,7 @@ async function scanRawDir(scanner, dir, scan) {
364
303
  const entries = await scanner.listDir(dir);
365
304
  for (const entry of entries) {
366
305
  if (entry.type !== 'dir' && isYamlFile(entry.name)) {
367
- const content = await scanner.getFile(joinPath(dir, entry.name));
306
+ const content = await scanner.getFile(joinRepoPath(dir, entry.name));
368
307
  if (content)
369
308
  for (const doc of parseDocs(content))
370
309
  scanManifestDoc(doc, scan);
@@ -417,7 +356,7 @@ async function walkKustomize(scanner, dir, scan, depth) {
417
356
  // Skip remote bases (URLs / git refs) — only local paths are checkout-free readable.
418
357
  if (ref.includes('://') || ref.startsWith('git@'))
419
358
  continue;
420
- const refPath = joinPath(dir, ref);
359
+ const refPath = joinRepoPath(dir, ref);
421
360
  if (isYamlFile(ref)) {
422
361
  const content = await scanner.getFile(refPath);
423
362
  if (content)
@@ -462,7 +401,7 @@ async function evaluateK8sDir(scanner, dir, entries) {
462
401
  for (const entry of entries) {
463
402
  if (entry.type === 'dir' || !isYamlFile(entry.name))
464
403
  continue;
465
- const content = await scanner.getFile(joinPath(dir, entry.name));
404
+ const content = await scanner.getFile(joinRepoPath(dir, entry.name));
466
405
  const looksLikeManifest = content !== null && parseDocs(content).some((d) => asString(d.kind) && asString(d.apiVersion));
467
406
  if (looksLikeManifest)
468
407
  return { dir, hasOverlays: false, hasKustomization: false };
@@ -487,7 +426,7 @@ async function collectKubernetesRoots(scanner, root) {
487
426
  for (const candidate of K8S_DIR_CANDIDATES) {
488
427
  if (found.length >= MAX_MANIFEST_ROOTS)
489
428
  break;
490
- const dir = joinPath(root, candidate);
429
+ const dir = joinRepoPath(root, candidate);
491
430
  const entries = await scanner.listDir(dir);
492
431
  if (entries.length === 0)
493
432
  continue;
@@ -503,7 +442,7 @@ async function collectKubernetesRoots(scanner, root) {
503
442
  break;
504
443
  if (entry.type !== 'dir' || !K8S_NESTED_SUBDIRS.includes(entry.name))
505
444
  continue;
506
- const nestedDir = joinPath(dir, entry.name);
445
+ const nestedDir = joinRepoPath(dir, entry.name);
507
446
  const nested = await evaluateK8sDir(scanner, nestedDir, await scanner.listDir(nestedDir));
508
447
  if (nested)
509
448
  add(nested);
@@ -524,7 +463,7 @@ async function findServiceDeployCandidates(scanner, serviceBasename) {
524
463
  for (const entry of await scanner.listDir(deployRoot)) {
525
464
  if (entry.type !== 'dir')
526
465
  continue;
527
- const path = joinPath(deployRoot, entry.name);
466
+ const path = joinRepoPath(deployRoot, entry.name);
528
467
  if (seen.has(path))
529
468
  continue;
530
469
  // Skip a child that is itself a shared-deploy root (e.g. `manifests/services`): it's a
@@ -551,24 +490,28 @@ async function findServiceDeployCandidates(scanner, serviceBasename) {
551
490
  * picker), external networks + profiles (for the recipe), and the containing dir's listing (for the
552
491
  * `-f` override family).
553
492
  */
554
- async function findCompose(scanner, root) {
555
- for (const dir of COMPOSE_DIR_CANDIDATES) {
556
- const dirPath = joinPath(root, dir);
493
+ async function findCompose(scanner, root, conventions) {
494
+ const composeFileNames = resolveComposeFileNames(conventions);
495
+ for (const dir of resolveComposeDirs(conventions)) {
496
+ const dirPath = joinRepoPath(root, dir);
557
497
  const entries = await scanner.listDir(dirPath);
558
498
  if (entries.length === 0)
559
499
  continue;
560
500
  const names = new Set(entries.filter((e) => e.type !== 'dir').map((e) => e.name));
561
- for (const candidate of COMPOSE_FILES) {
501
+ for (const candidate of composeFileNames) {
562
502
  if (!names.has(candidate))
563
503
  continue;
564
- const path = joinPath(dirPath, candidate);
504
+ const path = joinRepoPath(dirPath, candidate);
565
505
  const content = await scanner.getFile(path);
566
506
  const doc = content ? parseOne(content) : null;
567
507
  const servicesRecord = asRecord(doc?.services) ?? {};
568
508
  const services = Object.keys(servicesRecord);
569
- // An ambiguous bare `dev.ya?ml` is only a compose file when it declares services; otherwise
570
- // it's some other `dev.yml` (CLI/CI/Ansible config) and must not be detected as compose.
571
- if (AMBIGUOUS_COMPOSE_FILES.has(candidate) && services.length === 0)
509
+ // An ambiguous bare `dev.ya?ml` or ANY convention-added extra name, which is non-canonical
510
+ // by definition is only a compose file when it declares services; otherwise it's some other
511
+ // YAML (CLI/CI/Ansible/app config) that merely matches the name and must not be detected as
512
+ // compose. Canonical `compose.*`/`docker-compose.*` names are trusted without this guard.
513
+ const requiresServices = AMBIGUOUS_COMPOSE_FILES.has(candidate) || !COMPOSE_FILE_SET.has(candidate);
514
+ if (requiresServices && services.length === 0)
572
515
  continue;
573
516
  // Single source of truth with the provider's build-mode rejection: any service with a
574
517
  // `build:` means the stack builds from source, so build mode is required to provision it.
@@ -594,17 +537,17 @@ function rankOverlay(name) {
594
537
  /** Resolve the manifest source path + renderer + (when several) the overlay candidates. */
595
538
  async function resolveManifestSource(scanner, k8s) {
596
539
  if (k8s.hasOverlays) {
597
- const overlaysDir = joinPath(k8s.dir, 'overlays');
540
+ const overlaysDir = joinRepoPath(k8s.dir, 'overlays');
598
541
  const overlays = (await scanner.listDir(overlaysDir)).filter((e) => e.type === 'dir');
599
542
  if (overlays.length > 0) {
600
543
  const ranked = [...overlays].sort((a, b) => rankOverlay(a.name) - rankOverlay(b.name));
601
544
  const chosen = ranked[0];
602
545
  const candidates = ranked.map((o) => ({
603
- path: joinPath(overlaysDir, o.name),
546
+ path: joinRepoPath(overlaysDir, o.name),
604
547
  name: o.name,
605
548
  recommended: o.name === chosen.name,
606
549
  }));
607
- const chosenPath = joinPath(overlaysDir, chosen.name);
550
+ const chosenPath = joinRepoPath(overlaysDir, chosen.name);
608
551
  const hasK = (await scanner.getFirstFile(chosenPath, KUSTOMIZATION_FILES)) !== null;
609
552
  return {
610
553
  path: chosenPath,
@@ -685,8 +628,8 @@ async function inferHelmReleases(scanner, root, k8sDir) {
685
628
  field: 'helmReleases',
686
629
  confidence: 'low',
687
630
  message: releases.length > 0
688
- ? `Proposed ${releases.length} helm release(s) from ${joinPath(dir, helmfile.name)}; review charts/versions before applying.${unpinned > 0 ? ` ${unpinned} release(s) had an unpinned version and were skipped.` : ''}`
689
- : `Found ${joinPath(dir, helmfile.name)} but its release versions aren't pinned — pin them to a semver to enable.`,
631
+ ? `Proposed ${releases.length} helm release(s) from ${joinRepoPath(dir, helmfile.name)}; review charts/versions before applying.${unpinned > 0 ? ` ${unpinned} release(s) had an unpinned version and were skipped.` : ''}`
632
+ : `Found ${joinRepoPath(dir, helmfile.name)} but its release versions aren't pinned — pin them to a semver to enable.`,
690
633
  },
691
634
  };
692
635
  }
@@ -706,8 +649,8 @@ async function inferHelmReleases(scanner, root, k8sDir) {
706
649
  field: 'helmReleases',
707
650
  confidence: 'low',
708
651
  message: releases.length > 0
709
- ? `Proposed ${releases.length} helm release(s) from ${joinPath(dir, chart.name)} dependencies; review before applying.`
710
- : `Found ${joinPath(dir, chart.name)} dependencies but their versions aren't pinned to a semver.`,
652
+ ? `Proposed ${releases.length} helm release(s) from ${joinRepoPath(dir, chart.name)} dependencies; review before applying.`
653
+ : `Found ${joinRepoPath(dir, chart.name)} dependencies but their versions aren't pinned to a semver.`,
711
654
  },
712
655
  };
713
656
  }
@@ -778,7 +721,7 @@ function collectComposeFiles(compose) {
778
721
  continue;
779
722
  const os = overrideOsFor(entry.name, stem);
780
723
  if (os)
781
- osOverrides.push({ path: joinPath(compose.dir, entry.name), name: entry.name, os });
724
+ osOverrides.push({ path: joinRepoPath(compose.dir, entry.name), name: entry.name, os });
782
725
  else if (isBaseOverride(entry.name, stem))
783
726
  baseOverrideNames.push(entry.name);
784
727
  }
@@ -786,7 +729,7 @@ function collectComposeFiles(compose) {
786
729
  if (osOverrides.length === 0 && baseOverrideNames.length === 0)
787
730
  return {};
788
731
  for (const name of baseOverrideNames.sort())
789
- baseFiles.push(joinPath(compose.dir, name));
732
+ baseFiles.push(joinRepoPath(compose.dir, name));
790
733
  osOverrides.sort((a, b) => a.name.localeCompare(b.name));
791
734
  const composeFileCandidates = [
792
735
  ...baseFiles.map((path) => ({ path, name: path.split('/').pop() ?? path, recommended: true })),
@@ -823,14 +766,20 @@ function isConfigLikeName(target) {
823
766
  * Find committed env/config TEMPLATE files (`*-dist` / `*.example` / …) beside the compose file and
824
767
  * in the service root's common config dirs, and pair each with its gitignored target. Deduped by
825
768
  * target; bounded by `MAX_ENV_FILES`. These become `recipe.envFiles` — materialized before `up`.
769
+ *
770
+ * Scans, in order: the compose dir, the root-level config dirs (`ENV_TEMPLATE_DIR_CANDIDATES` +
771
+ * any deployment `conventions.envTemplateDirs`), then ONE LEVEL DOWN into the monorepo
772
+ * service-container dirs (`ENV_TEMPLATE_CONTAINER_DIRS` — `services/<svc>/`, `apps/<svc>/`), so a
773
+ * per-service template that lives outside the compose dir (the pilot's `services/app/.env.dev.local-dist`
774
+ * gap) is still surfaced. First template seen for a given target wins; the root-level dirs are scanned
775
+ * before the deeper container dirs so a root/compose-dir template takes precedence.
826
776
  */
827
- async function collectEnvFileTemplates(scanner, root, composeDir) {
828
- const dirs = [
829
- ...new Set([composeDir, ...ENV_TEMPLATE_DIR_CANDIDATES.map((d) => joinPath(root, d))]),
830
- ];
777
+ async function collectEnvFileTemplates(scanner, root, composeDir, conventions) {
831
778
  const pairs = [];
832
779
  const seenTargets = new Set();
833
- for (const dir of dirs) {
780
+ const sorted = () => pairs.sort((a, b) => a.template.localeCompare(b.template));
781
+ // Scan one flat directory; returns true once MAX_ENV_FILES is reached (caller stops).
782
+ const scanDir = async (dir) => {
834
783
  // Sort by name so the dedup-by-target choice (first template seen wins) is deterministic
835
784
  // regardless of the reader's directory-listing order.
836
785
  const entries = [...(await scanner.listDir(dir))].sort((a, b) => a.name.localeCompare(b.name));
@@ -840,16 +789,38 @@ async function collectEnvFileTemplates(scanner, root, composeDir) {
840
789
  const target = deriveEnvTemplateTarget(entry.name);
841
790
  if (!target)
842
791
  continue;
843
- const targetPath = joinPath(dir, target);
792
+ const targetPath = joinRepoPath(dir, target);
844
793
  if (seenTargets.has(targetPath))
845
794
  continue;
846
795
  seenTargets.add(targetPath);
847
- pairs.push({ template: joinPath(dir, entry.name), target: targetPath });
796
+ pairs.push({ template: joinRepoPath(dir, entry.name), target: targetPath });
848
797
  if (pairs.length >= MAX_ENV_FILES)
849
- return pairs.sort((a, b) => a.template.localeCompare(b.template));
798
+ return true;
799
+ }
800
+ return false;
801
+ };
802
+ const rootDirs = [
803
+ ...new Set([
804
+ composeDir,
805
+ ...resolveEnvTemplateDirs(conventions).map((d) => joinRepoPath(root, d)),
806
+ ]),
807
+ ];
808
+ for (const dir of rootDirs) {
809
+ if (await scanDir(dir))
810
+ return sorted();
811
+ }
812
+ // One level into monorepo service containers (`services/app/…`), children sorted for determinism.
813
+ for (const container of ENV_TEMPLATE_CONTAINER_DIRS) {
814
+ const containerDir = joinRepoPath(root, container);
815
+ const children = [...(await scanner.listDir(containerDir))]
816
+ .filter((e) => e.type === 'dir')
817
+ .sort((a, b) => a.name.localeCompare(b.name));
818
+ for (const child of children) {
819
+ if (await scanDir(joinRepoPath(containerDir, child.name)))
820
+ return sorted();
850
821
  }
851
822
  }
852
- return pairs.sort((a, b) => a.template.localeCompare(b.template));
823
+ return sorted();
853
824
  }
854
825
  // Whole-token matches (bounded by `^`/`$` or a non-letter — `-`, `_`, `.`, digits — so `pre` does
855
826
  // NOT match inside `compressed` and `data` DOES match inside `add_data`) for the seed-dump ranking.
@@ -872,22 +843,22 @@ function rankSeedDump(name) {
872
843
  * one into a `compose-exec` seed-import step (never auto-applied). The heuristically-fullest dump is
873
844
  * pre-selected.
874
845
  */
875
- async function collectSeedDumps(scanner, root) {
846
+ async function collectSeedDumps(scanner, root, conventions) {
876
847
  const found = [];
877
848
  const seen = new Set();
878
849
  const addSql = (dir, name) => {
879
850
  if (!name.toLowerCase().endsWith('.sql'))
880
851
  return;
881
- const path = joinPath(dir, name);
852
+ const path = joinRepoPath(dir, name);
882
853
  if (seen.has(path))
883
854
  return;
884
855
  seen.add(path);
885
856
  found.push({ path, name });
886
857
  };
887
- for (const rel of SEED_DIRS) {
858
+ for (const rel of resolveSeedDirs(conventions)) {
888
859
  if (found.length >= MAX_SEED_DUMPS)
889
860
  break;
890
- const dir = joinPath(root, rel);
861
+ const dir = joinRepoPath(root, rel);
891
862
  const entries = await scanner.listDir(dir);
892
863
  for (const entry of entries) {
893
864
  if (found.length >= MAX_SEED_DUMPS)
@@ -896,7 +867,7 @@ async function collectSeedDumps(scanner, root) {
896
867
  // A `migrations`/`migration` child holds schema DDL, not seed data — never a seed dump.
897
868
  if (/^migrations?$/i.test(entry.name))
898
869
  continue;
899
- const childDir = joinPath(dir, entry.name);
870
+ const childDir = joinRepoPath(dir, entry.name);
900
871
  for (const child of await scanner.listDir(childDir)) {
901
872
  if (child.type !== 'dir')
902
873
  addSql(childDir, child.name);
@@ -935,7 +906,7 @@ async function detectRepoCliHint(scanner, root, rootEntries) {
935
906
  const fileNames = new Set(rootEntries.filter((e) => e.type !== 'dir').map((e) => e.name));
936
907
  const hasBin = rootEntries.some((e) => e.type === 'dir' && e.name === 'bin');
937
908
  if (hasBin) {
938
- for (const entry of await scanner.listDir(joinPath(root, 'bin'))) {
909
+ for (const entry of await scanner.listDir(joinRepoPath(root, 'bin'))) {
939
910
  if (entry.type === 'dir')
940
911
  continue;
941
912
  const lower = entry.name.toLowerCase();
@@ -943,32 +914,32 @@ async function detectRepoCliHint(scanner, root, rootEntries) {
943
914
  lower.includes('cli') ||
944
915
  lower === 'dev' ||
945
916
  lower === 'setup') {
946
- return { path: joinPath(root, 'bin', entry.name), kind: 'repo-cli' };
917
+ return { path: joinRepoPath(root, 'bin', entry.name), kind: 'repo-cli' };
947
918
  }
948
919
  }
949
920
  }
950
921
  for (const name of MAKEFILE_NAMES) {
951
922
  if (fileNames.has(name))
952
- return { path: joinPath(root, name), kind: 'makefile' };
923
+ return { path: joinRepoPath(root, name), kind: 'makefile' };
953
924
  }
954
925
  for (const name of JUSTFILE_NAMES) {
955
926
  if (fileNames.has(name))
956
- return { path: joinPath(root, name), kind: 'justfile' };
927
+ return { path: joinRepoPath(root, name), kind: 'justfile' };
957
928
  }
958
929
  for (const name of TASKFILE_NAMES) {
959
930
  if (fileNames.has(name))
960
- return { path: joinPath(root, name), kind: 'taskfile' };
931
+ return { path: joinRepoPath(root, name), kind: 'taskfile' };
961
932
  }
962
933
  return undefined;
963
934
  }
964
935
  /**
965
936
  * Build the `docker-compose` recommendation. Beyond the base `composePath` + build-mode detection,
966
- * this reads the STACK RECIPE a complex compose repo implies (the acme-main pilot): multi-`-f`
937
+ * this reads the STACK RECIPE a complex compose repo implies (the acme-monolith pilot): multi-`-f`
967
938
  * layering, external networks, env-file materialization → `recipe`; profiles + seed dumps →
968
939
  * candidate arrays the wizard confirms; a repo-CLI hint → the analyst nudge. When NONE of those are
969
940
  * present the output is exactly the simple single-file recommendation (no `recipe`, no extra notes).
970
941
  */
971
- async function buildComposeRecommendation(scanner, root, compose, serviceBasename, kubernetesAlsoExists = false) {
942
+ async function buildComposeRecommendation(scanner, root, compose, serviceBasename, kubernetesAlsoExists = false, conventions) {
972
943
  const notes = [
973
944
  {
974
945
  field: 'provisionType',
@@ -1030,7 +1001,7 @@ async function buildComposeRecommendation(scanner, root, compose, serviceBasenam
1030
1001
  message: `Bind the external network(s) (${compose.externalNetworks.join(', ')}) to a shared stack so it is brought up first, or create them on the host manually.`,
1031
1002
  });
1032
1003
  }
1033
- const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir);
1004
+ const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir, conventions);
1034
1005
  if (envFiles.length > 0) {
1035
1006
  recipe.envFiles = envFiles;
1036
1007
  notes.push({
@@ -1049,7 +1020,7 @@ async function buildComposeRecommendation(scanner, root, compose, serviceBasenam
1049
1020
  message: `The compose file declares ${profileCandidates.length} profile(s): ${compose.profiles.join(', ')}. All surfaced default-off — enable the optional service groups you need.`,
1050
1021
  });
1051
1022
  }
1052
- const seedDumpCandidates = await collectSeedDumps(scanner, root);
1023
+ const seedDumpCandidates = await collectSeedDumps(scanner, root, conventions);
1053
1024
  if (seedDumpCandidates.length > 0) {
1054
1025
  const pick = seedDumpCandidates.find((s) => s.recommended);
1055
1026
  notes.push({
@@ -1201,7 +1172,7 @@ async function buildKubernetesRecommendation(scanner, roots, lookupRoot, opts) {
1201
1172
  }
1202
1173
  const secretInjections = [];
1203
1174
  if (scan.secretGenerator) {
1204
- const envFilePath = joinPath(scan.secretGenerator.baseDir, scan.secretGenerator.envFile);
1175
+ const envFilePath = joinRepoPath(scan.secretGenerator.baseDir, scan.secretGenerator.envFile);
1205
1176
  const exampleDirs = [...new Set([scan.secretGenerator.baseDir, path, lookupRoot])];
1206
1177
  let keys = [];
1207
1178
  for (const dir of exampleDirs) {
@@ -1276,18 +1247,18 @@ async function buildKubernetesRecommendation(scanner, roots, lookupRoot, opts) {
1276
1247
  * candidates for the user to pick, never silently auto-applied beyond the pre-selected one.
1277
1248
  */
1278
1249
  export async function detectKubernetesProvisioning(reader, options = {}) {
1279
- const root = joinPath(options.directory ?? '');
1250
+ const root = joinRepoPath(options.directory ?? '');
1280
1251
  const repoScanEnabled = root !== '';
1281
1252
  const serviceBasename = root.split('/').pop() ?? '';
1282
- const scanner = new Scanner(reader, options.gitRef);
1253
+ const scanner = new BudgetedRepoScanner(reader, READ_BUDGET, options.gitRef);
1283
1254
  const roots = await collectKubernetesRoots(scanner, root);
1284
- const compose = await findCompose(scanner, root);
1255
+ const compose = await findCompose(scanner, root, options.conventions);
1285
1256
  // Honor the selected tab: on docker-compose, recommend the compose file first (noting any
1286
1257
  // co-existing k8s manifests). Falls through to kubernetes when the user is on compose but no
1287
1258
  // compose file exists. With no preference (or any non-compose tab) we keep the historical
1288
1259
  // kubernetes-first order.
1289
1260
  if (options.prefer === 'docker-compose' && compose) {
1290
- return buildComposeRecommendation(scanner, root, compose, serviceBasename, roots.length > 0);
1261
+ return buildComposeRecommendation(scanner, root, compose, serviceBasename, roots.length > 0, options.conventions);
1291
1262
  }
1292
1263
  // Colocated k8s manifests win (highest confidence). In a monorepo, ALSO surface a root-shared
1293
1264
  // per-service slice as a low-confidence "this might be the deploy target instead" hint — but ONLY
@@ -1347,7 +1318,7 @@ export async function detectKubernetesProvisioning(reader, options = {}) {
1347
1318
  }
1348
1319
  }
1349
1320
  if (compose)
1350
- return buildComposeRecommendation(scanner, root, compose, serviceBasename);
1321
+ return buildComposeRecommendation(scanner, root, compose, serviceBasename, false, options.conventions);
1351
1322
  // Nothing detected. If that "nothing" is really "the repo couldn't be read" (the scan hit a
1352
1323
  // genuine read fault), raise it rather than falsely reporting an empty repo.
1353
1324
  if (scanner.readFault)
@@ -1375,9 +1346,9 @@ export async function detectKubernetesProvisioning(reader, options = {}) {
1375
1346
  * {@link RepoReadError}; a clean "no compose file here" returns `detected: false`.
1376
1347
  */
1377
1348
  export async function detectSharedStack(reader, options = {}) {
1378
- const root = joinPath(options.directory ?? '');
1379
- const scanner = new Scanner(reader, options.gitRef);
1380
- const compose = await findCompose(scanner, root);
1349
+ const root = joinRepoPath(options.directory ?? '');
1350
+ const scanner = new BudgetedRepoScanner(reader, READ_BUDGET, options.gitRef);
1351
+ const compose = await findCompose(scanner, root, options.conventions);
1381
1352
  if (!compose) {
1382
1353
  // Nothing compose-shaped. Distinguish "couldn't read the repo" from "read it, no compose".
1383
1354
  if (scanner.readFault)
@@ -1437,7 +1408,7 @@ export async function detectSharedStack(reader, options = {}) {
1437
1408
  message: `The compose file declares ${compose.profiles.length} profile(s): ${compose.profiles.join(', ')}. Enable the optional service groups this stack should run.`,
1438
1409
  });
1439
1410
  }
1440
- const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir);
1411
+ const envFiles = await collectEnvFileTemplates(scanner, root, compose.dir, options.conventions);
1441
1412
  if (envFiles.length > 0) {
1442
1413
  notes.push({
1443
1414
  field: 'envFiles',
@@ -1479,8 +1450,8 @@ export async function detectSharedStack(reader, options = {}) {
1479
1450
  * Never throws / never persists; the SPA confirms the prefilled `manifestPath`.
1480
1451
  */
1481
1452
  export async function detectCustomManifest(reader, options = {}) {
1482
- const root = joinPath(options.directory ?? '');
1483
- const scanner = new Scanner(reader, options.gitRef);
1453
+ const root = joinRepoPath(options.directory ?? '');
1454
+ const scanner = new BudgetedRepoScanner(reader, READ_BUDGET, options.gitRef);
1484
1455
  const manifestIdPart = options.manifestId ? { manifestId: options.manifestId } : {};
1485
1456
  const rec = (detected, manifestPath, note) => ({
1486
1457
  detected,
@@ -1509,7 +1480,7 @@ export async function detectCustomManifest(reader, options = {}) {
1509
1480
  });
1510
1481
  }
1511
1482
  // 2a. Exact: the complete relative path (with filename) under the service subtree / repo root.
1512
- const exact = joinPath(root, defaultPath);
1483
+ const exact = joinRepoPath(root, defaultPath);
1513
1484
  if ((await scanner.getFile(exact)) !== null) {
1514
1485
  return rec(true, exact, {
1515
1486
  field: 'manifestPath',
@@ -1522,7 +1493,7 @@ export async function detectCustomManifest(reader, options = {}) {
1522
1493
  for (const entry of await scanner.listDir(root)) {
1523
1494
  if (entry.type !== 'dir')
1524
1495
  continue;
1525
- const nested = joinPath(entry.path, defaultPath);
1496
+ const nested = joinRepoPath(entry.path, defaultPath);
1526
1497
  if ((await scanner.getFile(nested)) !== null) {
1527
1498
  return rec(true, nested, {
1528
1499
  field: 'manifestPath',