import { describe, it, expect, afterEach, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { ComposeInputDiscoveryService } from '../services/ComposeInputDiscoveryService'; import type { ManifestBounds } from '../types/gitProjectManifest'; const BOUNDS: ManifestBounds = { maxFiles: 10_000, maxBytes: 512 * 1024 * 1024, maxContextBytes: 256 * 1024 * 1024, maxPathDepth: 64, maxFileBytes: 10 * 1024 * 1024, }; let tmpRoots: string[] = []; function makeClone(files: Record): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-disc-')); tmpRoots.push(dir); for (const [rel, content] of Object.entries(files)) { const abs = path.join(dir, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, content); } return dir; } afterEach(() => { for (const dir of tmpRoots) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { // best-effort cleanup } } tmpRoots = []; vi.restoreAllMocks(); }); function discovery() { return ComposeInputDiscoveryService.getInstance(); } describe('discoverFromClone', () => { it('classifies include, env_file and config inputs as managed', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - common/redis.yaml\nservices:\n web:\n image: nginx\n env_file: web.env\n configs: [cfg]\nconfigs:\n cfg:\n file: nginx/nginx.conf\n', // Included files resolve their own relative paths against their // own directory (each included file is its own project). 'common/redis.yaml': 'services:\n redis:\n image: redis\n env_file: redis.env\n', 'web.env': 'FOO=bar\n', 'common/redis.env': 'REDIS=1\n', 'nginx/nginx.conf': 'server {}\n', '.env': 'PROJECT=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const byKind = (k: string) => result.inputs.filter((i) => i.dependencyKind === k); expect(byKind('explicit').map((i) => i.sourcePath)).toEqual(['compose.yaml']); expect(byKind('explicit')[0].materializedPath).toBe('compose.yaml'); expect(byKind('include')[0].sourcePath).toBe('common/redis.yaml'); const envFiles = byKind('env_file'); expect(envFiles.some((i) => i.sourcePath === 'web.env')).toBe(true); // The included file's env_file resolves against its own directory. expect(envFiles.some((i) => i.sourcePath === 'common/redis.env')).toBe(true); expect(byKind('config')[0].sourcePath).toBe('nginx/nginx.conf'); // The included project's default interpolation .env (common/.env) is // absent here, so it is recorded unmanaged and tolerated; every file // actually present in the repository is managed. const missingDefaultEnv = result.inputs.find((i) => i.dependencyKind === 'interpolation-env' && i.sourcePath === 'common/.env'); expect(missingDefaultEnv?.ownership).toBe('unmanaged'); expect(missingDefaultEnv?.note).toContain('No project .env'); expect(result.inputs.filter((i) => i.ownership === 'unmanaged')).toHaveLength(1); expect(result.inputs.filter((i) => i.ownership === 'managed').every((i) => i.state === 'present')).toBe(true); // content hashes are computed for file-backed inputs. expect(envFiles[0].contentSha256).toBeTruthy(); }); it('refuses out-of-bound ../ include targets', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - ../outside.yaml\nservices: {}\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'out-of-bounds' && r.actionable)).toBe(true); }); it('refuses URL includes', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - https://example.com/remote.yaml\nservices: {}\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'url-include')).toBe(true); }); it('refuses symlink inputs as unsafe-symlink', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: linked.env\n' }); // Create a real target and a symlink pointing at it. fs.writeFileSync(path.join(clone, 'target.env'), 'X=1\n'); try { fs.symlinkSync('target.env', path.join(clone, 'linked.env')); } catch { // Symlinks unavailable (Windows perms); skip. return; } const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'unsafe-symlink')).toBe(true); }); it('refuses special files as special-file', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: fifo.env\n' }); // FIFOs cannot be created on Windows; mock the lstat result instead. fs.writeFileSync(path.join(clone, 'fifo.env'), 'X=1\n'); const originalLstat = fs.promises.lstat.bind(fs.promises); vi.spyOn(fs.promises, 'lstat').mockImplementation(async (p) => { if (String(p).endsWith('fifo.env')) { return { isSymbolicLink: () => false, isDirectory: () => false, isFile: () => true, isCharacterDevice: () => true, isBlockDevice: () => false, isSocket: () => false, isFIFO: () => false, size: 4, } as unknown as fs.Stats; } return originalLstat(p); }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'special-file')).toBe(true); }); it('refuses paths inside submodules', async () => { const clone = makeClone({ '.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n', 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: vendor/lib/settings.env\n', 'vendor/lib/settings.env': 'X=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'submodule' && r.sourcePath === 'vendor/lib/settings.env')).toBe(true); }); it('refuses LFS pointer content for file-backed inputs', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: lfs.env\n', 'lfs.env': 'version https://git-lfs.github.com/spec/v1\noid sha256:abcd\ntype file\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'lfs-pointer')).toBe(true); }); it('records host binds and external resources as unmanaged', async () => { const clone = makeClone({ 'compose.yaml': `services: web: image: nginx volumes: - /var/run/docker.sock:/var/run/docker.sock - ./data:/data configs: ext: external: true `, }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); const unmanaged = result.inputs.filter((i) => i.ownership === 'unmanaged'); expect(unmanaged.some((i) => i.dependencyKind === 'bind-mount')).toBe(true); expect(unmanaged.some((i) => i.dependencyKind === 'config' && i.sourcePath === null)).toBe(true); expect(unmanaged.every((i) => i.deletionAuthority === 'none')).toBe(true); }); it('materializes build contexts with dockerignore filtering and stable byte accounting', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build:\n context: web\n', 'web/.dockerignore': 'node_modules\n*.log\n', 'web/Dockerfile': 'FROM node\n', 'web/index.js': 'console.log(1)\n', 'web/node_modules/pkg/index.js': 'big\n', 'web/debug.log': 'trace\n', }); const first = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(first.refusals).toEqual([]); expect(first.buildContexts).toHaveLength(1); const ctx = first.buildContexts[0]; expect(ctx.repoPath).toBe('web'); expect(ctx.dockerignoreApplied).toBe(true); // node_modules + *.log ignored: only Dockerfile + index.js + .dockerignore counted. expect(ctx.ignoredCount).toBeGreaterThanOrEqual(2); expect(ctx.contextBytes).toBeGreaterThan(0); // Context byte accounting is stable across pulls of the same revision. const second = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(second.buildContexts[0].contextBytes).toBe(first.buildContexts[0].contextBytes); }); it('refuses a repo-root build context that exceeds the context cap', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build: .\n', 'a.bin': 'a'.repeat(500), 'b.bin': 'b'.repeat(500), 'c.bin': 'c'.repeat(500), }); const bounds = { ...BOUNDS, maxContextBytes: 1000 }; const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds }); expect(result.refusals.some((r) => r.kind === 'context-unbounded')).toBe(true); }); it('counts a shared-context union once per unique file', async () => { const clone = makeClone({ 'compose.yaml': `services: one: build: context: web dockerfile: Dockerfile.one two: build: context: web dockerfile: Dockerfile.two `, 'web/Dockerfile.one': 'FROM scratch\n', 'web/Dockerfile.two': 'FROM scratch\n', 'web/Dockerfile.one.dockerignore': 'b.bin\n', 'web/Dockerfile.two.dockerignore': 'a.bin\n', 'web/common.bin': 'c'.repeat(100), 'web/a.bin': 'a'.repeat(100), 'web/b.bin': 'b'.repeat(100), }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: { ...BOUNDS, maxContextBytes: 350 }, }); expect(result.refusals.some((r) => r.kind === 'context-unbounded')).toBe(false); expect(result.buildContexts).toHaveLength(1); expect(result.buildContexts[0].contextBytes).toBe(338); expect(result.buildContexts[0].files).toHaveLength(7); const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-context-union-')); tmpRoots.push(dest); const managedFiles = result.inputs .filter((input) => input.ownership === 'managed' && input.materializedPath !== null && input.dependencyKind !== 'build-context' && input.dependencyKind !== 'build-additional-context') .map((input) => ({ srcRel: input.sourcePath!, destRel: input.materializedPath! })); await discovery().walkAndCopy(clone, dest, managedFiles, result.contextCopyPlans, BOUNDS); expect(fs.existsSync(path.join(dest, 'web/a.bin'))).toBe(true); expect(fs.existsSync(path.join(dest, 'web/b.bin'))).toBe(true); }); it('refuses LFS pointers inside a build context', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build: web\n', 'web/Dockerfile': 'FROM node\n', 'web/model.bin': 'version https://git-lfs.github.com/spec/v1\noid sha256:abcd\ntype file\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'lfs-in-context')).toBe(true); }); it('discovers the implicit compose override for single-file stacks only', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n', 'compose.override.yaml': 'services:\n web:\n environment:\n A: b\n', }); const single = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(single.inputs.some((i) => i.dependencyKind === 'implicit-override' && i.sourcePath === 'compose.override.yaml')).toBe(true); const multi = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml', 'compose.override.yaml'], contextDir: null, bounds: BOUNDS }); expect(multi.inputs.some((i) => i.dependencyKind === 'implicit-override')).toBe(false); }); it('scopes implicit override discovery to the primary file directory (monorepo isolation)', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n app:\n image: nginx\n ports: ["8099:80"]\n', 'compose.override.yaml': 'services:\n app:\n environment:\n FROM: root\n', 'monorepo/project-a/compose.yaml': 'services:\n a:\n image: alpine\n', 'monorepo/project-a/compose.override.yaml': 'services:\n a:\n environment:\n FROM: nested\n', 'monorepo/project-b/compose.yaml': 'services:\n b:\n image: busybox\n', }); const nestedWithLocal = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['monorepo/project-a/compose.yaml'], contextDir: null, bounds: BOUNDS, }); const overrideA = nestedWithLocal.inputs.find((i) => i.dependencyKind === 'implicit-override'); expect(overrideA?.sourcePath).toBe('monorepo/project-a/compose.override.yaml'); expect(overrideA?.materializedPath).toBe('compose.override.yaml'); expect(nestedWithLocal.inputs.some((i) => i.sourcePath === 'compose.override.yaml')).toBe(false); const nestedWithoutLocal = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['monorepo/project-b/compose.yaml'], contextDir: null, bounds: BOUNDS, }); expect(nestedWithoutLocal.inputs.some((i) => i.dependencyKind === 'implicit-override')).toBe(false); }); it('refuses case-only materialized path collisions instead of dropping one file', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - case/Config.yml\n - case/config.yml\nservices: {}\n', 'case/Config.yml': 'services:\n upper:\n image: alpine\n', 'case/config.yml': 'services:\n lower:\n image: busybox\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS, }); expect(result.refusals.some((r) => r.kind === 'case-collision' && r.actionable)).toBe(true); const collision = result.refusals.find((r) => r.kind === 'case-collision'); expect(collision?.reason).toMatch(/Config\.yml/i); expect(collision?.reason).toMatch(/config\.yml/i); const caseInputs = result.inputs.filter( (i) => i.materializedPath !== null && /^case\/config\.yml$/i.test(i.materializedPath), ); expect(caseInputs).toHaveLength(1); }); it('materializes env_file paths that contain a literal $ but no Compose variable', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: config$.env\n', 'config$.env': 'A=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS, }); const env = result.inputs.find((i) => i.dependencyKind === 'env_file' && i.sourcePath === 'config$.env'); expect(env?.ownership).toBe('managed'); expect(env?.materializedPath).toBe('config$.env'); expect(result.dynamic.some((d) => d.sourcePath === 'config$.env')).toBe(false); }); it('enforces the aggregate file and byte caps during classification', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - a.env\n - b.env\n - c.env\n', 'a.env': 'A=1\n', 'b.env': 'B=1\n', 'c.env': 'C=1\n', }); const fileBounds = { ...BOUNDS, maxFiles: 3 }; // compose.yaml + a.env + b.env fit; c.env crosses const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: fileBounds }); expect(result.refusals.some((r) => r.kind === 'too-many-files')).toBe(true); }); }); describe('walkAndCopy', () => { it('copies managed files preserving the nested layout, skipping .git', async () => { const clone = makeClone({ 'compose.yaml': 'services: {}\n', 'deploy/prod.yaml': 'services: {}\n', '.git/config': '[remote]\n', }); const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-')); tmpRoots.push(dest); const result = await discovery().walkAndCopy(clone, dest, [ { srcRel: 'compose.yaml', destRel: 'compose.yaml' }, { srcRel: 'deploy/prod.yaml', destRel: 'deploy/prod.yaml' }, ], [], BOUNDS); expect(result.copiedFiles).toBe(2); expect(fs.existsSync(path.join(dest, 'compose.yaml'))).toBe(true); expect(fs.existsSync(path.join(dest, 'deploy/prod.yaml'))).toBe(true); expect(fs.existsSync(path.join(dest, '.git'))).toBe(false); }); it('copies build contexts with dockerignore filtering', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build:\n context: web\n', 'web/.dockerignore': 'node_modules\n', 'web/Dockerfile': 'FROM node\n', 'web/index.js': 'console.log(1)\n', 'web/node_modules/pkg/index.js': 'big\n', }); const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-')); tmpRoots.push(dest); const discovery = ComposeInputDiscoveryService.getInstance(); const inventory = await discovery.discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); await discovery.walkAndCopy(clone, dest, [], inventory.contextCopyPlans, BOUNDS); expect(fs.existsSync(path.join(dest, 'web/Dockerfile'))).toBe(true); expect(fs.existsSync(path.join(dest, 'web/index.js'))).toBe(true); expect(fs.existsSync(path.join(dest, 'web/node_modules'))).toBe(false); }); it('throws with running counts when the byte cap is crossed mid-copy', async () => { const clone = makeClone({ 'a.bin': 'a'.repeat(1024), 'b.bin': 'b'.repeat(1024), }); const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-')); tmpRoots.push(dest); const bounds = { ...BOUNDS, maxBytes: 1500 }; await expect( discovery().walkAndCopy(clone, dest, [ { srcRel: 'a.bin', destRel: 'a.bin' }, { srcRel: 'b.bin', destRel: 'b.bin' }, ], [], bounds), ).rejects.toThrow(/exceeds 1500 bytes/); }); it('rejects case-colliding destination paths', async () => { const clone = makeClone({ 'A.TXT': '1\n', 'a.txt': '2\n' }); const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-')); tmpRoots.push(dest); await expect( discovery().walkAndCopy(clone, dest, [ { srcRel: 'A.TXT', destRel: 'A.TXT' }, { srcRel: 'a.txt', destRel: 'a.txt' }, ], [], BOUNDS), ).rejects.toThrow(/case-insensitive/); }); }); describe('syncEnv ownership (audit C-2)', () => { it('marks the repo-root .env unmanaged and unhashed when syncEnv owns the path', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n', '.env': 'REPO=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, syncEnv: true, bounds: BOUNDS }); const envEntries = result.inputs.filter((i) => i.materializedPath === '.env' || i.dependencyKind === 'interpolation-env'); expect(envEntries).toHaveLength(1); expect(envEntries[0].ownership).toBe('unmanaged'); expect(envEntries[0].contentSha256).toBeNull(); expect(envEntries[0].deletionAuthority).toBe('none'); }); it('keeps the repo .env managed when syncEnv is off', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n', '.env': 'REPO=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, syncEnv: false, bounds: BOUNDS }); const envEntries = result.inputs.filter((i) => i.dependencyKind === 'interpolation-env'); expect(envEntries).toHaveLength(1); expect(envEntries[0].ownership).toBe('managed'); expect(envEntries[0].contentSha256).toBeTruthy(); }); }); describe('explicit dockerfile resolution (audit round 2 C-3)', () => { it('rebases the dockerfile against its build context', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: Dockerfile.dev\n', 'web/Dockerfile.dev': 'FROM nginx\n', 'web/app.js': 'console.log(1)\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); expect(result.buildContexts).toHaveLength(1); expect(result.buildContexts[0].repoPath).toBe('web'); expect(result.buildContexts[0].dockerfile).toBe('web/Dockerfile.dev'); // The dockerfile's files are part of the context inventory. expect(result.buildContexts[0].files.some((f) => f.path === 'Dockerfile.dev')).toBe(true); }); it('allows a ../ dockerfile inside the repository and materializes it separately', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: ../shared/Dockerfile\n', 'web/app.js': 'x\n', 'shared/Dockerfile': 'FROM nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const dockerfileEntry = result.inputs.find((i) => i.dependencyKind === 'dockerfile' && i.materializedPath === 'shared/Dockerfile'); expect(dockerfileEntry).toBeTruthy(); expect(dockerfileEntry?.ownership).toBe('managed'); expect(dockerfileEntry?.contentSha256).toBeTruthy(); }); it('refuses a dockerfile that escapes the repository', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: ../../outside/Dockerfile\n', 'web/app.js': 'x\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'out-of-bounds' && String(r.sourcePath).includes('Dockerfile'))).toBe(true); }); }); describe('dynamic and submodule-backed inputs (audit round 8 B-2)', () => { it('persists dynamic ${VAR} paths as explicit unmanaged entries', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: ${ENV_FILE:-default.env}\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const dyn = result.inputs.filter((i) => i.dependencyKind === 'env_file' && i.sourcePath?.includes('${ENV_FILE')); expect(dyn).toHaveLength(1); expect(dyn[0].ownership).toBe('unmanaged'); expect(dyn[0].materializedPath).toBeNull(); expect(dyn[0].deletionAuthority).toBe('none'); expect(dyn[0].note).toContain('resolved by Compose at deploy time'); // The dynamic path is never searched for as a file in the clone. expect(result.refusals.some((r) => r.kind === 'missing-file')).toBe(false); }); it('refuses a build context rooted inside a submodule', async () => { const clone = makeClone({ '.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n', 'compose.yaml': 'services:\n web:\n build: vendor/lib\n', 'vendor/lib/Dockerfile': 'FROM nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'submodule' && String(r.sourcePath).includes('vendor/lib'))).toBe(true); }); it('refuses a repo-root context containing a submodule directory', async () => { const clone = makeClone({ '.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n', 'compose.yaml': 'services:\n web:\n build: .\n', 'Dockerfile': 'FROM nginx\n', 'vendor/lib/Dockerfile': 'FROM nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'submodule' && String(r.reason).includes('vendor/lib'))).toBe(true); }); it('allows a submodule directory excluded by the context dockerignore', async () => { const clone = makeClone({ '.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n', 'compose.yaml': 'services:\n web:\n build: .\n', 'Dockerfile': 'FROM nginx\n', '.dockerignore': 'vendor\n', 'vendor/lib/Dockerfile': 'FROM nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); }); }); describe('effective invocation path resolution (audit round 8 B-3)', () => { it('resolves merged-file relative paths against the base file directory, never the declaring file', async () => { const clone = makeClone({ 'deploy/base.yaml': 'services:\n web:\n image: nginx\n configs: [cfg]\nconfigs:\n cfg:\n file: nginx.conf\n', // The second -f file lives in a DIFFERENT directory: its relative // paths still resolve against the base file's directory (deploy/), // not its own. 'configs/prod.yaml': 'services:\n web:\n env_file: prod.env\n', 'deploy/nginx.conf': 'server {}\n', 'deploy/prod.env': 'A=1\n', // Same-named files at the repo root must NOT be picked up. 'nginx.conf': 'WRONG server {}\n', 'prod.env': 'WRONG=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml', 'configs/prod.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const prodEnv = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(prodEnv?.sourcePath).toBe('deploy/prod.env'); // Materialized at the stack root (the runtime project dir). expect(prodEnv?.materializedPath).toBe('prod.env'); const cfg = result.inputs.find((i) => i.dependencyKind === 'config'); expect(cfg?.sourcePath).toBe('deploy/nginx.conf'); expect(cfg?.materializedPath).toBe('nginx.conf'); }); it('refuses a base-dir-relative file that is missing instead of falling back to a same-named root file', async () => { const clone = makeClone({ 'deploy/base.yaml': 'services:\n web:\n image: nginx\n env_file: app.env\n', // No deploy/app.env: the old declaring-dir-first + repo-root // fallback would silently materialize the root decoy. 'app.env': 'WRONG=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.kind === 'missing-file' && r.sourcePath === 'deploy/app.env')).toBe(true); expect(result.inputs.some((i) => i.sourcePath === 'app.env' && i.ownership === 'managed')).toBe(false); }); it('resolves the primary\'s includes against the context dir but keeps the included file\'s own paths on its own project directory', async () => { const clone = makeClone({ // With --project-directory deploy, the primary's include path // resolves against deploy/ (the top-level project base). 'compose.yaml': 'include:\n - common/redis.yaml\nservices: {}\n', 'deploy/common/redis.yaml': 'services:\n redis:\n image: redis\n env_file: redis.env\n', // The included file's own env_file resolves against ITS project // directory (deploy/common), not the context dir. 'deploy/common/redis.env': 'REDIS=1\n', 'deploy/redis.env': 'WRONG=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS }); expect(result.refusals).toEqual([]); const include = result.inputs.find((i) => i.dependencyKind === 'include'); expect(include?.sourcePath).toBe('deploy/common/redis.yaml'); expect(include?.materializedPath).toBe('deploy/common/redis.yaml'); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.sourcePath).toBe('deploy/common/redis.env'); expect(env?.materializedPath).toBe('deploy/common/redis.env'); }); it('materializes base-file-relative paths at the stack root, stripping the base directory prefix', async () => { const clone = makeClone({ 'deploy/base.yaml': 'services:\n web:\n image: nginx\n env_file: prod.env\n', 'deploy/prod.env': 'A=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); // The primary file lands at the stack root, so the runtime project dir // is the stack root: prod.env must live at the stack root, not under // deploy/. expect(env?.sourcePath).toBe('deploy/prod.env'); expect(env?.materializedPath).toBe('prod.env'); }); it('resolves project-relative paths against the configured project directory', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: app.env\n', 'deploy/app.env': 'A=1\n', 'app.env': 'WRONG=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS }); expect(result.refusals).toEqual([]); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.sourcePath).toBe('deploy/app.env'); expect(env?.materializedPath).toBe('deploy/app.env'); }); it('does not auto-discover an override when the project directory makes the invocation explicit', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n', 'compose.override.yaml': 'services:\n web:\n environment:\n A: b\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS }); // A context dir forces explicit -f at runtime, which suppresses // auto-discovery; the override must not enter the inventory. expect(result.inputs.some((i) => i.dependencyKind === 'implicit-override')).toBe(false); }); it('rebases a subdir build context to the stack root for the materialized layout', async () => { const clone = makeClone({ 'deploy/base.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: Dockerfile\n', 'deploy/web/Dockerfile': 'FROM nginx\n', 'deploy/web/app.js': 'console.log(1)\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); expect(result.buildContexts).toHaveLength(1); const ctx = result.buildContexts[0]; expect(ctx.repoPath).toBe('web'); expect(ctx.dockerfile).toBe('deploy/web/Dockerfile'); const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-b3-ctx-')); tmpRoots.push(dest); const managed = result.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null && i.dependencyKind !== 'build-context') .map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })); await discovery().walkAndCopy(clone, dest, managed, result.contextCopyPlans, BOUNDS); expect(fs.existsSync(path.join(dest, 'web/Dockerfile'))).toBe(true); expect(fs.existsSync(path.join(dest, 'web/app.js'))).toBe(true); }); }); describe('nested-primary runtime path equivalence (audit round 9 B-3)', () => { it('materializes a nested primary\'s include graph at the stack root', async () => { const clone = makeClone({ 'deploy/base.yaml': 'include:\n - common.yaml\nservices: {}\n', 'deploy/common.yaml': 'services:\n web:\n image: nginx\n env_file: web.env\n', 'deploy/web.env': 'A=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const include = result.inputs.find((i) => i.dependencyKind === 'include'); expect(include?.sourcePath).toBe('deploy/common.yaml'); expect(include?.materializedPath).toBe('common.yaml'); // The included file's own project-relative input also moves to the // stack root: at runtime compose.yaml includes ./common.yaml, whose // env_file resolves against its own (relocated) directory. const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.sourcePath).toBe('deploy/web.env'); expect(env?.materializedPath).toBe('web.env'); // The candidate mirrors the runtime layout. const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-b3-graph-')); tmpRoots.push(dest); const managed = result.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null && i.dependencyKind !== 'build-context') .map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })); await discovery().walkAndCopy(clone, dest, managed, result.contextCopyPlans, BOUNDS); expect(fs.existsSync(path.join(dest, 'common.yaml'))).toBe(true); expect(fs.existsSync(path.join(dest, 'web.env'))).toBe(true); expect(fs.existsSync(path.join(dest, 'deploy/common.yaml'))).toBe(false); }); it('recurses through a nested primary\'s include graph, stripping the prefix at every level', async () => { const clone = makeClone({ 'deploy/base.yaml': 'include:\n - common.yaml\nservices: {}\n', 'deploy/common.yaml': 'include:\n - nested/inner.yaml\nservices: {}\n', 'deploy/nested/inner.yaml': 'services:\n web:\n image: nginx\n env_file: inner.env\n', 'deploy/nested/inner.env': 'A=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const inner = result.inputs.find((i) => i.dependencyKind === 'include' && i.sourcePath === 'deploy/nested/inner.yaml'); expect(inner?.materializedPath).toBe('nested/inner.yaml'); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.sourcePath).toBe('deploy/nested/inner.env'); expect(env?.materializedPath).toBe('nested/inner.env'); }); it('resolves an additional -f file\'s includes against the base file\'s directory', async () => { const clone = makeClone({ 'deploy/base.yaml': 'services: {}\n', // The merged project's base is deploy/ (the first -f file); an // include declared in the second file resolves against it. 'infra/prod.yaml': 'include:\n - prod-common.yaml\nservices: {}\n', 'deploy/prod-common.yaml': 'services:\n web:\n image: nginx\n', 'infra/prod-common.yaml': 'services:\n WRONG:\n image: nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml', 'infra/prod.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const include = result.inputs.find((i) => i.dependencyKind === 'include'); expect(include?.sourcePath).toBe('deploy/prod-common.yaml'); // At runtime the project dir is the stack root, so the included file // moves there too. expect(include?.materializedPath).toBe('prod-common.yaml'); }); it('applies a divergent include project_directory to the subtree project base', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - path: app/compose.yaml\n project_directory: other\nservices: {}\n', 'app/compose.yaml': 'services:\n web:\n image: nginx\n env_file: x.env\n', 'other/x.env': 'A=1\n', 'app/x.env': 'WRONG=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); // project_directory re-bases the subtree: the env file lives under // other/, not next to the included file. expect(env?.sourcePath).toBe('other/x.env'); expect(env?.materializedPath).toBe('other/x.env'); }); it('materializes a nested primary\'s extends.file target at the stack root with its own inputs', async () => { const clone = makeClone({ 'deploy/base.yaml': 'services:\n web:\n extends:\n file: web-base.yaml\n service: web-base\n', 'deploy/web-base.yaml': 'services:\n web-base:\n image: nginx\n label_file: labels.txt\n', 'deploy/labels.txt': 'a=b\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const ext = result.inputs.find((i) => i.dependencyKind === 'extends'); expect(ext?.sourcePath).toBe('deploy/web-base.yaml'); expect(ext?.materializedPath).toBe('web-base.yaml'); const label = result.inputs.find((i) => i.dependencyKind === 'label_file'); expect(label?.sourcePath).toBe('deploy/labels.txt'); expect(label?.materializedPath).toBe('labels.txt'); }); it('refuses a nested-primary include that escapes the stack root', async () => { const clone = makeClone({ 'deploy/base.yaml': 'include:\n - ../shared.yaml\nservices: {}\n', 'shared.yaml': 'services: {}\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); // ../shared.yaml stays inside the repository (deploy/../shared.yaml) // but escapes the stack root at runtime; it must be refused, never // silently dropped or adopted from the repo root. expect(result.refusals.some((r) => r.actionable && r.kind === 'out-of-bounds' && String(r.sourcePath).includes('../shared.yaml'))).toBe(true); expect(result.inputs.some((i) => i.sourcePath === 'shared.yaml' && i.ownership === 'managed')).toBe(false); }); }); describe('included-project default env and project-base includes (audit round 10 B-1/B-2)', () => { it('materializes an included project\'s default .env as a managed sensitive input', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - modules/app/compose.yaml\nservices: {}\n', 'modules/app/compose.yaml': 'services:\n app:\n image: app:${TAG:-latest}\n', 'modules/app/.env': 'TAG=production\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const defaultEnv = result.inputs.find((i) => i.dependencyKind === 'interpolation-env' && i.sourcePath === 'modules/app/.env'); expect(defaultEnv?.ownership).toBe('managed'); expect(defaultEnv?.materializedPath).toBe('modules/app/.env'); expect(defaultEnv?.contentSha256).toBeTruthy(); expect(defaultEnv?.sensitivity).toBe('high'); }); it('tolerates a missing included-project default .env without refusing', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - modules/app/compose.yaml\nservices: {}\n', 'modules/app/compose.yaml': 'services:\n app:\n image: app:${TAG:-latest}\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const defaultEnv = result.inputs.find((i) => i.dependencyKind === 'interpolation-env' && i.sourcePath === 'modules/app/.env'); expect(defaultEnv?.ownership).toBe('unmanaged'); expect(defaultEnv?.note).toContain('No project .env'); }); it('resolves extends.file in an additional -f file against the base file\'s directory', async () => { const clone = makeClone({ 'deploy/base.yaml': 'services: {}\n', 'infra/prod.yaml': 'services:\n web:\n extends:\n file: web-base.yaml\n service: web-base\n', 'deploy/web-base.yaml': 'services:\n web-base:\n image: nginx\n', 'infra/web-base.yaml': 'services:\n WRONG:\n image: nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml', 'infra/prod.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const ext = result.inputs.find((i) => i.dependencyKind === 'extends'); expect(ext?.sourcePath).toBe('deploy/web-base.yaml'); expect(ext?.materializedPath).toBe('web-base.yaml'); }); it('resolves extends.file against the context dir with the subtree on its own project directory', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n extends:\n file: web-base.yaml\n service: web-base\n', 'deploy/web-base.yaml': 'services:\n web-base:\n image: nginx\n label_file: labels.txt\n', 'deploy/labels.txt': 'a=b\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS }); expect(result.refusals).toEqual([]); const ext = result.inputs.find((i) => i.dependencyKind === 'extends'); expect(ext?.sourcePath).toBe('deploy/web-base.yaml'); const label = result.inputs.find((i) => i.dependencyKind === 'label_file'); expect(label?.sourcePath).toBe('deploy/labels.txt'); }); it('uses the first path as the included project\'s main file for a multi-directory path list', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - path:\n - modules/base/compose.yaml\n - overrides/compose.yaml\nservices: {}\n', 'modules/base/compose.yaml': 'services:\n base:\n image: nginx\n', 'overrides/compose.yaml': 'services:\n base:\n env_file: shared.env\n', // The included project's base is modules/base (the first path); // the override's relative inputs resolve against it, never the // override's own directory. 'modules/base/shared.env': 'A=1\n', 'overrides/shared.env': 'WRONG=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.sourcePath).toBe('modules/base/shared.env'); expect(env?.materializedPath).toBe('modules/base/shared.env'); }); }); describe('optional env_file and external: false semantics (audit round 10 S-1)', () => { it('does not refuse a missing optional env_file', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - path: optional.env\n required: false\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.ownership).toBe('unmanaged'); expect(env?.note).toContain('Optional env file'); }); it('materializes a present optional env_file as managed', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - path: optional.env\n required: false\n', 'optional.env': 'A=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.ownership).toBe('managed'); expect(env?.contentSha256).toBeTruthy(); }); it('treats only external: true as external for file-backed configs', async () => { const clone = makeClone({ 'compose.yaml': 'configs:\n app:\n file: configs/app.conf\n external: false\nservices:\n web:\n image: nginx\n configs: [app]\n', 'configs/app.conf': 'server {}\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const cfg = result.inputs.find((i) => i.dependencyKind === 'config'); expect(cfg?.sourcePath).toBe('configs/app.conf'); expect(cfg?.ownership).toBe('managed'); expect(cfg?.contentSha256).toBeTruthy(); }); it('treats only external: true as external for file-backed secrets', async () => { const clone = makeClone({ 'compose.yaml': 'secrets:\n app-key:\n file: secrets/app.key\n external: false\nservices:\n web:\n image: nginx\n secrets: [app-key]\n', 'secrets/app.key': 'secret\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const sec = result.inputs.find((i) => i.dependencyKind === 'secret'); expect(sec?.sourcePath).toBe('secrets/app.key'); expect(sec?.ownership).toBe('managed'); expect(sec?.sensitivity).toBe('high'); }); }); describe('absolute and home-relative path classification (audit round 9 B-4)', () => { it('records absolute env/config paths as unmanaged host entries, never adopting repo decoys', async () => { const clone = makeClone({ 'compose.yaml': `services: web: image: nginx env_file: /etc/secrets/web.env configs: [cfg] configs: cfg: file: /etc/nginx/app.conf `, // Repo decoys with the same basenames must never be adopted. 'etc/secrets/web.env': 'WRONG=1\n', 'etc/nginx/app.conf': 'WRONG server {}\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const env = result.inputs.find((i) => i.dependencyKind === 'env_file'); expect(env?.ownership).toBe('unmanaged'); expect(env?.materializedPath).toBeNull(); expect(env?.note).toContain('Host path'); const cfg = result.inputs.find((i) => i.dependencyKind === 'config'); expect(cfg?.ownership).toBe('unmanaged'); expect(result.inputs.some((i) => i.ownership === 'managed' && i.materializedPath?.includes('etc/'))).toBe(false); }); it('refuses absolute includes without adopting a repo decoy', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - /etc/compose/extra.yaml\nservices: {}\n', 'etc/compose/extra.yaml': 'services:\n web:\n image: nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals.some((r) => r.actionable && r.kind === 'out-of-bounds' && String(r.sourcePath).includes('/etc/compose/extra.yaml'))).toBe(true); expect(result.inputs.some((i) => i.materializedPath === 'etc/compose/extra.yaml')).toBe(false); }); it('records absolute build contexts and dockerfiles as unmanaged host entries', async () => { const clone = makeClone({ 'compose.yaml': `services: web: build: context: /opt/build/web dockerfile: /opt/build/Dockerfile `, 'opt/build/web/Dockerfile': 'FROM nginx\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); expect(result.buildContexts).toHaveLength(0); const ctx = result.inputs.find((i) => i.dependencyKind === 'build-context'); expect(ctx?.ownership).toBe('unmanaged'); expect(ctx?.note).toContain('Host path'); const df = result.inputs.find((i) => i.dependencyKind === 'dockerfile'); expect(df?.ownership).toBe('unmanaged'); }); it('records Windows drive, UNC, and home-relative paths as host inputs', async () => { const clone = makeClone({ 'compose.yaml': `services: web: image: nginx env_file: - C:\\\\config\\\\web.env - \\\\\\\\server\\\\share\\\\x.env - ~/web.env `, 'config/web.env': 'WRONG=1\n', 'web.env': 'WRONG=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const envFiles = result.inputs.filter((i) => i.dependencyKind === 'env_file'); expect(envFiles).toHaveLength(3); expect(envFiles.every((e) => e.ownership === 'unmanaged' && e.materializedPath === null)).toBe(true); }); }); describe('default build context and build-secret grammar (audit round 8 B-1)', () => { it('materializes an omitted build context at the repo root', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build:\n dockerfile: Dockerfile\n', 'Dockerfile': 'FROM nginx\n', 'src/app.js': 'console.log(1)\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); expect(result.buildContexts).toHaveLength(1); const ctx = result.buildContexts[0]; expect(ctx.repoPath).toBe(''); expect(ctx.dockerfile).toBe('Dockerfile'); expect(ctx.files.some((f) => f.path === 'Dockerfile')).toBe(true); expect(ctx.files.some((f) => f.path === 'src/app.js')).toBe(true); }); it('resolves an omitted build context against the configured project directory', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build:\n dockerfile: Dockerfile\n', 'deploy/Dockerfile': 'FROM nginx\n', 'deploy/app.js': 'console.log(1)\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS }); expect(result.refusals).toEqual([]); expect(result.buildContexts).toHaveLength(1); const ctx = result.buildContexts[0]; expect(ctx.repoPath).toBe('deploy'); expect(ctx.dockerfile).toBe('deploy/Dockerfile'); expect(ctx.files.some((f) => f.path === 'app.js')).toBe(true); }); it('resolves an omitted build context in an included file against the included file\'s directory', async () => { const clone = makeClone({ 'compose.yaml': 'include:\n - services/app/compose.yaml\nservices: {}\n', 'services/app/compose.yaml': 'services:\n app:\n build:\n dockerfile: Dockerfile\n', 'services/app/Dockerfile': 'FROM nginx\n', 'services/app/app.js': 'console.log(1)\n', 'root-decoy.txt': 'must not be in the context\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); expect(result.buildContexts).toHaveLength(1); const ctx = result.buildContexts[0]; expect(ctx.repoPath).toBe('services/app'); expect(ctx.dockerfile).toBe('services/app/Dockerfile'); expect(ctx.files.some((f) => f.path === 'app.js')).toBe(true); expect(ctx.files.some((f) => f.path === 'root-decoy.txt')).toBe(false); }); it('does not double-prefix an include map-form env_file from a subdirectory base file', async () => { const clone = makeClone({ 'deploy/base.yaml': 'include:\n - path: web.yaml\n env_file: e.env\nservices: {}\n', 'deploy/web.yaml': 'services:\n web:\n image: nginx\n', 'deploy/e.env': 'A=1\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const includeEnv = result.inputs.find((i) => i.dependencyKind === 'include-env'); // Repo-side the env file sits next to the base file; at runtime the // primary lands at the stack root, so the env file lives there too. expect(includeEnv?.sourcePath).toBe('deploy/e.env'); expect(includeEnv?.materializedPath).toBe('e.env'); }); it('does not search for a file named after a build-secret source', async () => { const clone = makeClone({ 'compose.yaml': `secrets: build-key: file: keys/build.env services: web: build: context: web secrets: - id: build-key source: build-key `, 'web/Dockerfile': 'FROM nginx\n', 'keys/build.env': 'TOKEN=x\n', }); const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); expect(result.refusals).toEqual([]); const secretEntry = result.inputs.find((i) => i.dependencyKind === 'secret' && i.sourcePath === 'keys/build.env'); expect(secretEntry).toBeTruthy(); expect(secretEntry?.ownership).toBe('managed'); const buildSecret = result.inputs.find((i) => i.dependencyKind === 'build-secret'); expect(buildSecret?.sourcePath).toBeNull(); expect(buildSecret?.ownership).toBe('unmanaged'); }); }); describe('repo-root build context overlap (audit round 2)', () => { it('copies a repo-root context without duplicating managed files', async () => { const clone = makeClone({ 'compose.yaml': 'services:\n web:\n build: .\n', 'src/main.go': 'package main\n', }); const inv = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS }); const managed = inv.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null && i.dependencyKind !== 'build-context'); const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-rootctx-')); tmpRoots.push(dest); const result = await discovery().walkAndCopy(clone, dest, managed.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv.contextCopyPlans, BOUNDS); // compose.yaml + src/main.go: no duplicate-path failure, no double copy. expect(result.copiedFiles).toBe(2); expect(fs.readFileSync(path.join(dest, 'src/main.go'), 'utf8')).toBe('package main\n'); }); });