/** * deploy-paths.test.ts — the CLI survives the installer's flatten. * * The installer (src/lib/installer.ts) deploys `business-analyse/change/` as * `ba-change/`: it drops ONE `../` from the lib imports and re-points every * contiguous `skills/business-analyse/` literal at `skills/ba-`. A path built * from fragments or an import climbing elsewhere than lib/ passes every other * test and 404s once deployed — invisible until launch. Same idiom as * lib/__tests__/remediation.test.ts. */ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { CLI_PATHS } from '../impact.js' const cliDir = join(dirname(fileURLToPath(import.meta.url)), '..') const skillsRoot = join(cliDir, '..', '..', '..', '..') /** Resolve against the source tree OR the flattened one (both may exist). */ function resolveCliPath(cliPath: string): string | null { const rel = cliPath.replace(/^skills\//, '') const source = join(skillsRoot, rel) if (existsSync(source)) return source const flattened = join(skillsRoot, rel.replace(/^business-analyse\//, 'ba-')) return existsSync(flattened) ? flattened : null } /** The two rewrites of installer.ts (L275 / L281), re-implemented inline — importing src/ would drag the root Zod v3 world in. */ const rewriteDeployedSkillPaths = (s: string): string => s.replace(/(? s.replace(/(?:\.\.\/){2,}(?:lib|development)\//g, (m) => m.slice(3)) const sourceFiles = readdirSync(cliDir).filter((f) => f.endsWith('.ts')) describe('deploy-paths — literals', () => { it('every CLI_PATHS entry resolves in the source or flattened tree', () => { for (const p of Object.values(CLI_PATHS)) { expect(p).toMatch(/^skills\/(business-analyse\/[a-z-]+|ba-develop)\/cli\/[a-z0-9-]+\/index\.ts$/) expect(resolveCliPath(p), p).not.toBeNull() } }) it('keeps every `skills/business-analyse/` occurrence of impact.ts contiguous (a full literal path)', () => { const src = readFileSync(join(cliDir, 'impact.ts'), 'utf8') const occurrences = src.match(/skills\/business-analyse\//g)?.length ?? 0 const fullLiterals = src.match(/'skills\/business-analyse\/[a-z-]+\/cli\/[a-z-]+\/index\.ts'/g)?.length ?? 0 // comments may cite the prefix; every OTHER occurrence must be a full literal const inComments = src.match(/(?:\/\/|\*).*skills\/business-analyse\//g)?.length ?? 0 expect(occurrences - inComments).toBe(fullLiterals) }) it('the deployed rewrite of impact.ts still names existing CLIs', () => { const src = readFileSync(join(cliDir, 'impact.ts'), 'utf8') const deployed = rewriteDeployedSkillPaths(src) const paths = deployed.match(/'skills\/[^']+\/index\.ts'/g)?.map((s) => s.slice(1, -1)) ?? [] expect(paths.length).toBeGreaterThan(0) for (const p of paths) { const rel = p.replace(/^skills\//, '') const source = join(skillsRoot, rel.replace(/^ba-(?!develop\/)/, 'business-analyse/')) const flattened = join(skillsRoot, rel) expect(existsSync(source) || existsSync(flattened), p).toBe(true) } }) }) describe('deploy-paths — imports', () => { it('never leaves ../../../../lib/ (the only cross-folder climb the installer re-anchors)', () => { for (const f of sourceFiles) { const src = readFileSync(join(cliDir, f), 'utf8') for (const m of src.matchAll(/from\s+'(\.\.\/[^']+)'/g)) { expect(m[1], `${f}: ${m[1]}`).toMatch(/^\.\.\/\.\.\/\.\.\/\.\.\/lib\/[A-Za-z0-9_-]+\.js$/) } } }) it('after the deploy rewrite every lib import resolves from the flattened depth', () => { for (const f of sourceFiles) { const src = readFileSync(join(cliDir, f), 'utf8') for (const m of dropOneClimb(src).matchAll(/from\s+'(\.\.\/[^']+)'/g)) { // deployed at skills/ba-change/cli/derive-change-impact/ → 3 climbs reach skills/ expect(m[1]).toMatch(/^\.\.\/\.\.\/\.\.\/lib\//) const target = join(skillsRoot, m[1]!.replace(/^\.\.\/\.\.\/\.\.\//, '').replace(/\.js$/, '.ts')) expect(existsSync(target), `${f}: ${m[1]}`).toBe(true) } } }) })