From 5afead274347780015e00bc900faa53efbdf0f09 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 00:59:02 +0200 Subject: [PATCH 01/21] [#199] fix: remove silent-failure masks + content-ops catalog drift - ci.yml: drop `|| echo` on ts:check/build/lint (fail loudly) - knowledge-hub: `vitest || check:links` -> `&&` (both real gates), drop `--coverage || true` - content-ops: adm-zip + @types/adm-zip -> catalog: - Task: T1 (P0.1 + P0.2 + P1.1) Refs: #199 --- .github/workflows/ci.yml | 6 +++--- packages/content-ops/package.json | 4 ++-- packages/knowledge-hub/package.json | 4 ++-- pnpm-lock.yaml | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d13c62e0..39b3322f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,11 +54,11 @@ jobs: export $(grep -v '^#' .env | xargs) fi - name: Run type check - run: pnpm ts:check || echo "No ts:check script defined" + run: pnpm ts:check - name: Run build - run: pnpm build || echo "No build script defined" + run: pnpm build - name: Run lint - run: pnpm lint || echo "No lint script defined" + run: pnpm lint - name: Install Playwright browsers run: | pnpm --filter @pair/brand exec playwright install --with-deps chromium diff --git a/packages/content-ops/package.json b/packages/content-ops/package.json index 334d8f90..3993260e 100644 --- a/packages/content-ops/package.json +++ b/packages/content-ops/package.json @@ -49,7 +49,7 @@ "@pair/prettier-config": "workspace:*", "@pair/markdownlint-config": "workspace:*", "@pair/ts-config": "workspace:*", - "@types/adm-zip": "^0.5.5", + "@types/adm-zip": "catalog:", "@types/markdown-it": "catalog:", "@types/mdast": "catalog:", "@types/node": "catalog:", @@ -60,7 +60,7 @@ "vitest": "catalog:" }, "dependencies": { - "adm-zip": "^0.5.9", + "adm-zip": "catalog:", "markdown-it": "catalog:", "mdast": "catalog:", "remark-parse": "catalog:", diff --git a/packages/knowledge-hub/package.json b/packages/knowledge-hub/package.json index 01f953e0..51f6aefb 100644 --- a/packages/knowledge-hub/package.json +++ b/packages/knowledge-hub/package.json @@ -10,8 +10,8 @@ "build": "rm -rf dist && mkdir -p dist && cp -R dataset dist/dataset", "prettier:fix": "prettier-fix", "prettier:check": "prettier-check", - "test": "vitest run || pnpm check:links", - "test:coverage": "vitest run --coverage || true", + "test": "vitest run && pnpm check:links", + "test:coverage": "vitest run --coverage", "ts:check": "tsc --noEmit", "lint": "lint", "lint:fix": "lint-fix", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 526cb788..5b85143c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,7 +416,7 @@ importers: packages/content-ops: dependencies: adm-zip: - specifier: ^0.5.9 + specifier: 'catalog:' version: 0.5.16 markdown-it: specifier: 'catalog:' @@ -444,7 +444,7 @@ importers: specifier: workspace:* version: link:../../tools/ts-config '@types/adm-zip': - specifier: ^0.5.5 + specifier: 'catalog:' version: 0.5.7 '@types/markdown-it': specifier: 'catalog:' From 4444fbc7d0d5aa0157eeb941f44a7554eae9dbd0 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:03:10 +0200 Subject: [PATCH 02/21] [#199] refactor: split copyPathOps god-module - copy-file.ts: single-file copy path - copy-directory.ts: non-transform directory copy path - copy-directory-transforms.ts: flatten/prefix transform copy path - copy-types.ts: shared CopyPathOpsResult/TransformOpts - copyPathOps.ts: slim orchestrator, re-exports public API (unchanged) - Public API (copyPathOps, copyDirectoryWithTransforms, CopyPathOpsResult) preserved - Task: T4 (P0.5) Refs: #199 --- .../src/ops/copy-directory-transforms.ts | 356 ++++++++++ .../content-ops/src/ops/copy-directory.ts | 219 ++++++ packages/content-ops/src/ops/copy-file.ts | 72 ++ packages/content-ops/src/ops/copy-types.ts | 12 + packages/content-ops/src/ops/copyPathOps.ts | 647 +----------------- 5 files changed, 668 insertions(+), 638 deletions(-) create mode 100644 packages/content-ops/src/ops/copy-directory-transforms.ts create mode 100644 packages/content-ops/src/ops/copy-directory.ts create mode 100644 packages/content-ops/src/ops/copy-file.ts create mode 100644 packages/content-ops/src/ops/copy-types.ts diff --git a/packages/content-ops/src/ops/copy-directory-transforms.ts b/packages/content-ops/src/ops/copy-directory-transforms.ts new file mode 100644 index 00000000..ffd737b5 --- /dev/null +++ b/packages/content-ops/src/ops/copy-directory-transforms.ts @@ -0,0 +1,356 @@ +import { join, relative, dirname } from 'path/posix' +import { logger, createError } from '../observability' +import { copyFileHelper } from '../file-system' +import { FileSystemService } from '../file-system' +import { SyncOptions } from './SyncOptions' +import { transformPath, detectCollisions } from './naming-transforms' +import { rewriteLinksAfterTransform, PathMappingEntry } from './link-rewriter' +import { syncFrontmatter } from './frontmatter-transform' +import { + buildSkillNameMap, + rewriteSkillReferencesInFiles, + SkillNameMap, +} from './skill-reference-rewriter' +import type { CopyPathOpsResult, TransformOpts } from './copy-types' + +/** + * Recursively collects all files under a directory, returning their paths + * relative to the given root directory. + */ +async function collectFiles( + fileService: FileSystemService, + dirPath: string, + rootPath: string, +): Promise { + const result: string[] = [] + const entries = await fileService.readdir(dirPath) + for (const entry of entries) { + const entryPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + const subFiles = await collectFiles(fileService, entryPath, rootPath) + result.push(...subFiles) + } else { + const relPath = relative(rootPath, entryPath) + result.push(relPath) + } + } + return result +} + +/** + * Collects unique subdirectory names from a file list, validates no + * flatten collisions exist, and throws if any are found. + */ +function validateNoCollisions( + files: string[], + transformOpts: TransformOpts, + srcPath: string, +): void { + const dirSet = new Set() + for (const filePath of files) { + const dir = dirname(filePath) + if (dir !== '.') dirSet.add(dir) + } + const transformedDirs = [...dirSet].map(d => transformPath(d, transformOpts)) + const collisions = detectCollisions(transformedDirs) + if (collisions.length > 0) { + throw createError({ + type: 'IO_ERROR', + message: `Flatten naming collision detected: ${collisions.join(', ')}. Different source paths resolve to the same target name.`, + operation: 'copyDir', + path: srcPath, + }) + } +} + +/** + * Copies a single file to its transformed location and tracks the + * directory mapping for later link rewriting. + */ +async function copyFileWithTransform(ctx: { + fileService: FileSystemService + filePath: string + srcPath: string + destPath: string + transformOpts: TransformOpts + dirMappingFiles: Map + topLevelFiles: Set +}): Promise { + const { + fileService, + filePath, + srcPath, + destPath, + transformOpts, + dirMappingFiles, + topLevelFiles, + } = ctx + const dir = dirname(filePath) + const fileName = filePath.slice(dir === '.' ? 0 : dir.length + 1) + const transformedDir = dir === '.' ? null : transformPath(dir, transformOpts) + const targetDir = transformedDir ? join(destPath, transformedDir) : destPath + const targetFilePath = join(targetDir, fileName) + + await fileService.mkdir(targetDir, { recursive: true }) + await copyFileHelper(fileService, join(srcPath, filePath), targetFilePath, 'overwrite') + + await trackTransformedFile({ + fileService, + dir, + fileName, + transformedDir, + targetFilePath, + dirMappingFiles, + topLevelFiles, + }) +} + +/** + * Post-copy bookkeeping for a transformed file: syncs the frontmatter `name` + * when a subdirectory was renamed, and records the file in either + * `dirMappingFiles` (files under a subdirectory) or `topLevelFiles` (root-level + * source files) so link rewriting and mirror cleanup can see it. + */ +async function trackTransformedFile(ctx: { + fileService: FileSystemService + dir: string + fileName: string + transformedDir: string | null + targetFilePath: string + dirMappingFiles: Map + topLevelFiles: Set +}): Promise { + const { + fileService, + dir, + fileName, + transformedDir, + targetFilePath, + dirMappingFiles, + topLevelFiles, + } = ctx + + if (dir === '.') { + // Root-level source file — has no transformed subdirectory of its own, so it's + // never added to dirMappingFiles. Track it separately so cleanupStaleTransformedEntries + // knows it's a legitimate copy, not a stale leftover (see that function's docstring). + topLevelFiles.add(fileName) + return + } + if (!transformedDir) return + + const leafName = dir.split('/').pop()! + if (leafName !== transformedDir) { + const content = await fileService.readFile(targetFilePath) + const synced = syncFrontmatter(content, { from: leafName, to: transformedDir }) + if (synced !== content) { + await fileService.writeFile(targetFilePath, synced) + } + } + + if (!dirMappingFiles.has(dir)) dirMappingFiles.set(dir, []) + dirMappingFiles.get(dir)!.push(targetFilePath) +} + +/** + * Builds PathMappingEntry[] from the directory-to-files map collected during copy. + */ +function buildPathMapping( + dirMappingFiles: Map, + transformOpts: TransformOpts, + sourceRelative: string, + targetRelative: string, +): PathMappingEntry[] { + const pathMapping: PathMappingEntry[] = [] + for (const [originalSubDir, mappedFiles] of dirMappingFiles) { + const transformedSubDir = transformPath(originalSubDir, transformOpts) + pathMapping.push({ + originalDir: join(sourceRelative, originalSubDir), + newDir: join(targetRelative, transformedSubDir), + files: mappedFiles, + }) + } + return pathMapping +} + +/** + * Copies every file with naming transforms applied, collecting both the + * per-subdirectory mapping (for link rewriting, skill renames, and mirror + * cleanup of transformed directories) and the set of root-level file names + * (for mirror cleanup of files that have no subdirectory of their own). + */ +async function copyAllFilesWithTransform(params: { + fileService: FileSystemService + files: string[] + srcPath: string + destPath: string + transformOpts: TransformOpts +}): Promise<{ dirMappingFiles: Map; topLevelFiles: Set }> { + const { fileService, files, srcPath, destPath, transformOpts } = params + const dirMappingFiles = new Map() + const topLevelFiles = new Set() + for (const filePath of files) { + await copyFileWithTransform({ + fileService, + filePath, + srcPath, + destPath, + transformOpts, + dirMappingFiles, + topLevelFiles, + }) + } + return { dirMappingFiles, topLevelFiles } +} + +/** + * Copies a directory with flatten/prefix naming transforms applied. + * Each file's directory path (relative to source) is transformed, then + * the file is copied to the transformed location under the target. + */ +function buildTransformOpts(options?: SyncOptions): TransformOpts { + const flatten = options?.flatten ?? false + const prefix = options?.prefix + return prefix ? { flatten, prefix } : { flatten } +} + +export async function copyDirectoryWithTransforms(params: { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + datasetRoot: string + options?: SyncOptions +}): Promise { + const { fileService, srcPath, destPath, options } = params + const transformOpts = buildTransformOpts(options) + + const files = await collectFiles(fileService, srcPath, srcPath) + validateNoCollisions(files, transformOpts, srcPath) + + await fileService.mkdir(destPath, { recursive: true }) + + const { dirMappingFiles, topLevelFiles } = await copyAllFilesWithTransform({ + fileService, + files, + srcPath, + destPath, + transformOpts, + }) + + if (options?.defaultBehavior === 'mirror') { + await cleanupStaleTransformedEntries({ + fileService, + destPath, + dirMappingFiles, + topLevelFiles, + transformOpts, + }) + } + + await rewriteLinksForTransformedDirs(params, dirMappingFiles, transformOpts) + const skillNameMap = await applySkillReferenceRewrites( + fileService, + dirMappingFiles, + transformOpts, + ) + + logger.info( + `Copied contents of ${srcPath} -> ${destPath} (flatten=${transformOpts.flatten}, prefix=${transformOpts.prefix ?? 'none'})`, + ) + return skillNameMap.size > 0 ? { skillNameMap } : {} +} + +/** + * Removes stale top-level entries under a flatten/prefix target that no + * longer correspond to a source directory or a root-level source file. This + * is what makes `mirror` behavior idempotent across renames: a removed + * skill's leftover flattened directory is cleaned up, and a prefix change no + * longer leaves the old prefixed directory orphaned alongside the new one. + * + * Only top-level entries are considered — matches the granularity of the + * non-transform `handleMirrorCleanup` and the flatten use case (one source + * subdirectory maps to exactly one top-level target directory). + * + * `topLevelFiles` (file names copied directly from the source root, with no + * subdirectory of their own) must be included in `expected` alongside the + * transformed directory names — they're never in `dirMappingFiles` (which + * only tracks files under a subdirectory), so without this they'd be + * wrongly deleted as stale on the very next mirror run. + */ +async function cleanupStaleTransformedEntries(params: { + fileService: FileSystemService + destPath: string + dirMappingFiles: Map + topLevelFiles: Set + transformOpts: TransformOpts +}): Promise { + const { fileService, destPath, dirMappingFiles, topLevelFiles, transformOpts } = params + + const expected = new Set(topLevelFiles) + for (const originalSubDir of dirMappingFiles.keys()) { + const transformedDir = transformPath(originalSubDir, transformOpts) + expected.add(transformedDir.split('/')[0]!) + } + + const entries = await fileService.readdir(destPath).catch(() => []) + for (const entry of entries) { + if (expected.has(entry.name)) continue + const toRemove = join(destPath, entry.name) + await fileService.rm(toRemove, { recursive: true, force: true }) + logger.info(`Mirror: removed stale transformed entry ${toRemove}`) + } +} + +async function rewriteLinksForTransformedDirs( + params: { + fileService: FileSystemService + datasetRoot: string + srcPath: string + destPath: string + source: string + target: string + }, + dirMappingFiles: Map, + transformOpts: TransformOpts, +): Promise { + const sourceRelative = relative(params.datasetRoot, params.srcPath) || params.source + const targetRelative = relative(params.datasetRoot, params.destPath) || params.target + const pathMapping = buildPathMapping( + dirMappingFiles, + transformOpts, + sourceRelative, + targetRelative, + ) + if (pathMapping.length > 0) { + const sourceContentRoot = dirname(sourceRelative) + await rewriteLinksAfterTransform({ + fileService: params.fileService, + pathMapping, + datasetRoot: params.datasetRoot, + ...(sourceContentRoot !== '.' && { sourceContentRoot }), + }) + } +} + +/** + * Collects .md files from dirMappingFiles and rewrites skill references if any renames occurred. + */ +async function applySkillReferenceRewrites( + fileService: FileSystemService, + dirMappingFiles: Map, + transformOpts: TransformOpts, +): Promise { + const skillNameMap = buildSkillNameMap(dirMappingFiles, transformOpts) + if (skillNameMap.size === 0) return skillNameMap + + const allMdFiles: string[] = [] + for (const mappedFiles of dirMappingFiles.values()) { + for (const f of mappedFiles) { + if (f.endsWith('.md')) allMdFiles.push(f) + } + } + await rewriteSkillReferencesInFiles({ fileService, files: allMdFiles, skillNameMap }) + return skillNameMap +} diff --git a/packages/content-ops/src/ops/copy-directory.ts b/packages/content-ops/src/ops/copy-directory.ts new file mode 100644 index 00000000..23de56d0 --- /dev/null +++ b/packages/content-ops/src/ops/copy-directory.ts @@ -0,0 +1,219 @@ +import { join } from 'path/posix' +import { logger, createError } from '../observability' +import { copyDirHelper } from '../file-system' +import type { CopyDirContext } from '../file-system/file-operations' +import { FileSystemService } from '../file-system' +import { SyncOptions } from './SyncOptions' +import { Behavior, normalizeKey, resolveBehavior } from './behavior' +import { + updateMarkdownLinks, + handleMirrorCleanup, + validateSubfolderOperation, +} from './path-operation-helpers' +import { convertToRelative } from '../path-resolution' + +export type HandleDirectoryCopyParams = { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + normSource: string + normTarget: string + datasetRoot: string + defaultBehavior: Behavior + folderBehavior?: Record + options?: SyncOptions +} + +/** + * Handles directory copy operations + */ +export async function handleDirectoryCopy(params: HandleDirectoryCopyParams) { + const { + fileService, + srcPath, + destPath, + source, + target, + normSource, + normTarget, + datasetRoot, + defaultBehavior, + folderBehavior, + options, + } = params + + // Handle different behaviors for directories + const sourceFolderBehavior = + resolveSourceFolderBehavior( + datasetRoot, + normSource, + folderBehavior, + defaultBehavior ?? 'overwrite', + ) ?? 'overwrite' + + if (sourceFolderBehavior === 'skip') { + logger.info(`Skipping directory ${srcPath} due to 'skip' behavior`) + return + } + + await performDirectoryCopyAndUpdate({ + fileService, + srcPath, + destPath, + normSource, + normTarget, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + sourceFolderBehavior, + defaultBehavior: defaultBehavior ?? 'overwrite', + source, + target, + ...(options && { options }), + }) +} + +async function performDirectoryCopyAndUpdate(params: { + fileService: FileSystemService + srcPath: string + destPath: string + normSource: string + normTarget: string + datasetRoot: string + folderBehavior?: Record + sourceFolderBehavior: Behavior + defaultBehavior: Behavior + source: string + target: string + options?: SyncOptions +}) { + const { fileService, destPath, datasetRoot, folderBehavior, source, target, options, ...rest } = + params + + await performDirectoryCopy({ + ...rest, + fileService, + destPath, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + }) + + await updateLinksAfterDirectoryCopy({ + fileService, + source, + target, + datasetRoot, + finalDest: destPath, + ...(options && { options }), + }) +} + +/** + * Updates markdown links after directory copy operation + */ +async function updateLinksAfterDirectoryCopy(params: { + fileService: FileSystemService + source: string + target: string + datasetRoot: string + finalDest: string + options?: SyncOptions +}) { + await updateMarkdownLinks({ + fileService: params.fileService, + source: params.source, + target: params.target, + datasetRoot: params.datasetRoot, + finalDest: params.finalDest, + isDirectory: true, + options: params.options, + }) +} + +function resolveSourceFolderBehavior( + datasetRoot: string, + normSource: string, + folderBehavior?: Record, + defaultBehavior: Behavior = 'overwrite', +) { + const rel = convertToRelative(datasetRoot, join(datasetRoot, normSource)) + const relSourceKey = normalizeKey(rel) + return resolveBehavior(relSourceKey, folderBehavior, defaultBehavior) +} + +async function performDirectoryCopy(params: { + fileService: FileSystemService + srcPath: string + destPath: string + normSource: string + normTarget: string + datasetRoot: string + folderBehavior?: Record + sourceFolderBehavior: Behavior + defaultBehavior: Behavior +}) { + const { + fileService, + srcPath, + destPath, + normSource, + normTarget, + datasetRoot, + folderBehavior, + sourceFolderBehavior, + defaultBehavior, + } = params + await fileService.mkdir(destPath, { recursive: true }) + validateSubfolderOperation({ srcPath, destPath, normSource, normTarget, operation: 'copy' }) + + if (sourceFolderBehavior === 'mirror') { + await handleMirrorCleanup(fileService, srcPath, destPath) + } + + await copyDirectoryContents({ + fileService, + srcPath, + destPath, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + defaultBehavior, + }) + + logger.info(`Copied contents of ${srcPath} -> ${destPath}`) +} + +async function copyDirectoryContents(params: { + fileService: FileSystemService + srcPath: string + destPath: string + datasetRoot: string + folderBehavior?: Record + defaultBehavior: Behavior +}) { + const { fileService, srcPath, destPath, datasetRoot, folderBehavior, defaultBehavior } = params + + try { + const copyContext: CopyDirContext = { + fileService, + oldDir: srcPath, + newDir: destPath, + defaultBehavior, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + } + await copyDirHelper(copyContext) + } catch (err) { + logger.error(`Failed to copy entries: ${String(err)}`) + if (err instanceof Error && err.message.includes('boom')) { + throw err + } + throw createError({ + type: 'IO_ERROR', + message: `Failed to copy directory contents from ${srcPath} to ${destPath}`, + operation: 'copyDir', + path: srcPath, + originalError: err, + }) + } +} diff --git a/packages/content-ops/src/ops/copy-file.ts b/packages/content-ops/src/ops/copy-file.ts new file mode 100644 index 00000000..d0a38915 --- /dev/null +++ b/packages/content-ops/src/ops/copy-file.ts @@ -0,0 +1,72 @@ +import { logger, createError } from '../observability' +import { copyFileHelper } from '../file-system' +import { FileSystemService } from '../file-system' +import { SyncOptions } from './SyncOptions' +import { Behavior } from './behavior' +import { determineFinalDestination, updateMarkdownLinks } from './path-operation-helpers' +import { rewriteSkillReferencesInFiles, SkillNameMap } from './skill-reference-rewriter' + +export type HandleFileCopyParams = { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + normTarget: string + datasetRoot: string + defaultBehavior: Behavior + options?: SyncOptions + skillNameMap?: SkillNameMap +} + +/** + * Handles file copy operations + */ +export async function handleFileCopy(params: HandleFileCopyParams) { + const { + fileService, + srcPath, + destPath, + source, + target, + normTarget, + datasetRoot, + defaultBehavior, + options, + skillNameMap, + } = params + + const finalDest = await determineFinalDestination(fileService, destPath, source, normTarget) + + try { + await copyFileHelper(fileService, srcPath, finalDest, defaultBehavior) + } catch (err) { + logger.error(`Failed to copy file ${srcPath} -> ${finalDest}: ${String(err)}`) + // If the original error message is specific (like test errors), preserve it + if (err instanceof Error && err.message.includes('boom')) { + throw err + } + throw createError({ + type: 'IO_ERROR', + message: `Failed to copy file ${srcPath} -> ${finalDest}`, + operation: 'copyFile', + path: srcPath, + originalError: err, + }) + } + logger.info(`Copied file ${srcPath} -> ${finalDest}`) + + await updateMarkdownLinks({ + fileService, + source, + target, + datasetRoot, + finalDest, + isDirectory: false, + options, + }) + + if (skillNameMap && skillNameMap.size > 0 && finalDest.endsWith('.md')) { + await rewriteSkillReferencesInFiles({ fileService, files: [finalDest], skillNameMap }) + } +} diff --git a/packages/content-ops/src/ops/copy-types.ts b/packages/content-ops/src/ops/copy-types.ts new file mode 100644 index 00000000..bfc623b6 --- /dev/null +++ b/packages/content-ops/src/ops/copy-types.ts @@ -0,0 +1,12 @@ +import type { SkillNameMap } from './skill-reference-rewriter' + +/** + * Result of a copy operation. Carries the skill name map when a + * transform copy renamed skills, so callers can chain reference rewrites. + */ +export type CopyPathOpsResult = { + skillNameMap?: SkillNameMap +} + +/** Naming transform options (flatten and/or prefix) applied during a copy. */ +export type TransformOpts = { flatten: boolean; prefix?: string } diff --git a/packages/content-ops/src/ops/copyPathOps.ts b/packages/content-ops/src/ops/copyPathOps.ts index d419b264..c50f9b52 100644 --- a/packages/content-ops/src/ops/copyPathOps.ts +++ b/packages/content-ops/src/ops/copyPathOps.ts @@ -1,33 +1,19 @@ -import { join, relative, dirname } from 'path/posix' import { Stats } from 'fs' import { logger, createError } from '../observability' import { validateSourceExists } from '../file-system/file-validations' -import { copyFileHelper, copyDirHelper } from '../file-system' -import type { CopyDirContext } from '../file-system/file-operations' import { SyncOptions } from './SyncOptions' import { FileSystemService } from '../file-system' -import { Behavior, normalizeKey, resolveBehavior } from './behavior' -import { - setupPathOperation, - determineFinalDestination, - updateMarkdownLinks, - handleMirrorCleanup, - validateSubfolderOperation, -} from './path-operation-helpers' -import { convertToRelative } from '../path-resolution' +import { Behavior } from './behavior' +import { setupPathOperation } from './path-operation-helpers' import { isAbsolute } from 'path' -import { transformPath, detectCollisions } from './naming-transforms' -import { rewriteLinksAfterTransform, PathMappingEntry } from './link-rewriter' -import { syncFrontmatter } from './frontmatter-transform' -import { - buildSkillNameMap, - rewriteSkillReferencesInFiles, - SkillNameMap, -} from './skill-reference-rewriter' +import { SkillNameMap } from './skill-reference-rewriter' +import { handleDirectoryCopy, HandleDirectoryCopyParams } from './copy-directory' +import { handleFileCopy, HandleFileCopyParams } from './copy-file' +import { copyDirectoryWithTransforms } from './copy-directory-transforms' +import type { CopyPathOpsResult } from './copy-types' -export type CopyPathOpsResult = { - skillNameMap?: SkillNameMap -} +export type { CopyPathOpsResult } from './copy-types' +export { copyDirectoryWithTransforms } from './copy-directory-transforms' type CopyPathOpsParams = { fileService: FileSystemService @@ -40,33 +26,6 @@ type CopyPathOpsParams = { skillNameMap?: SkillNameMap } -type HandleDirectoryCopyParams = { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - normSource: string - normTarget: string - datasetRoot: string - defaultBehavior: Behavior - folderBehavior?: Record - options?: SyncOptions -} - -type HandleFileCopyParams = { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - normTarget: string - datasetRoot: string - defaultBehavior: Behavior - options?: SyncOptions - skillNameMap?: SkillNameMap -} - /** * Performs copy operation based on source type */ @@ -267,591 +226,3 @@ export async function copyPathOps(params: CopyPathOpsParams): Promise - sourceFolderBehavior: Behavior - defaultBehavior: Behavior - source: string - target: string - options?: SyncOptions -}) { - const { fileService, destPath, datasetRoot, folderBehavior, source, target, options, ...rest } = - params - - await performDirectoryCopy({ - ...rest, - fileService, - destPath, - datasetRoot, - ...(folderBehavior && { folderBehavior }), - }) - - await updateLinksAfterDirectoryCopy({ - fileService, - source, - target, - datasetRoot, - finalDest: destPath, - ...(options && { options }), - }) -} - -/** - * Updates markdown links after directory copy operation - */ -async function updateLinksAfterDirectoryCopy(params: { - fileService: FileSystemService - source: string - target: string - datasetRoot: string - finalDest: string - options?: SyncOptions -}) { - await updateMarkdownLinks({ - fileService: params.fileService, - source: params.source, - target: params.target, - datasetRoot: params.datasetRoot, - finalDest: params.finalDest, - isDirectory: true, - options: params.options, - }) -} - -function resolveSourceFolderBehavior( - datasetRoot: string, - normSource: string, - folderBehavior?: Record, - defaultBehavior: Behavior = 'overwrite', -) { - const rel = convertToRelative(datasetRoot, join(datasetRoot, normSource)) - const relSourceKey = normalizeKey(rel) - return resolveBehavior(relSourceKey, folderBehavior, defaultBehavior) -} - -async function performDirectoryCopy(params: { - fileService: FileSystemService - srcPath: string - destPath: string - normSource: string - normTarget: string - datasetRoot: string - folderBehavior?: Record - sourceFolderBehavior: Behavior - defaultBehavior: Behavior -}) { - const { - fileService, - srcPath, - destPath, - normSource, - normTarget, - datasetRoot, - folderBehavior, - sourceFolderBehavior, - defaultBehavior, - } = params - await fileService.mkdir(destPath, { recursive: true }) - validateSubfolderOperation({ srcPath, destPath, normSource, normTarget, operation: 'copy' }) - - if (sourceFolderBehavior === 'mirror') { - await handleMirrorCleanup(fileService, srcPath, destPath) - } - - await copyDirectoryContents({ - fileService, - srcPath, - destPath, - datasetRoot, - ...(folderBehavior && { folderBehavior }), - defaultBehavior, - }) - - logger.info(`Copied contents of ${srcPath} -> ${destPath}`) -} - -async function copyDirectoryContents(params: { - fileService: FileSystemService - srcPath: string - destPath: string - datasetRoot: string - folderBehavior?: Record - defaultBehavior: Behavior -}) { - const { fileService, srcPath, destPath, datasetRoot, folderBehavior, defaultBehavior } = params - - try { - const copyContext: CopyDirContext = { - fileService, - oldDir: srcPath, - newDir: destPath, - defaultBehavior, - datasetRoot, - ...(folderBehavior && { folderBehavior }), - } - await copyDirHelper(copyContext) - } catch (err) { - logger.error(`Failed to copy entries: ${String(err)}`) - if (err instanceof Error && err.message.includes('boom')) { - throw err - } - throw createError({ - type: 'IO_ERROR', - message: `Failed to copy directory contents from ${srcPath} to ${destPath}`, - operation: 'copyDir', - path: srcPath, - originalError: err, - }) - } -} - -/** - * Recursively collects all files under a directory, returning their paths - * relative to the given root directory. - */ -async function collectFiles( - fileService: FileSystemService, - dirPath: string, - rootPath: string, -): Promise { - const result: string[] = [] - const entries = await fileService.readdir(dirPath) - for (const entry of entries) { - const entryPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - const subFiles = await collectFiles(fileService, entryPath, rootPath) - result.push(...subFiles) - } else { - const relPath = relative(rootPath, entryPath) - result.push(relPath) - } - } - return result -} - -type TransformOpts = { flatten: boolean; prefix?: string } - -/** - * Collects unique subdirectory names from a file list, validates no - * flatten collisions exist, and throws if any are found. - */ -function validateNoCollisions( - files: string[], - transformOpts: TransformOpts, - srcPath: string, -): void { - const dirSet = new Set() - for (const filePath of files) { - const dir = dirname(filePath) - if (dir !== '.') dirSet.add(dir) - } - const transformedDirs = [...dirSet].map(d => transformPath(d, transformOpts)) - const collisions = detectCollisions(transformedDirs) - if (collisions.length > 0) { - throw createError({ - type: 'IO_ERROR', - message: `Flatten naming collision detected: ${collisions.join(', ')}. Different source paths resolve to the same target name.`, - operation: 'copyDir', - path: srcPath, - }) - } -} - -/** - * Copies a single file to its transformed location and tracks the - * directory mapping for later link rewriting. - */ -async function copyFileWithTransform(ctx: { - fileService: FileSystemService - filePath: string - srcPath: string - destPath: string - transformOpts: TransformOpts - dirMappingFiles: Map - topLevelFiles: Set -}): Promise { - const { - fileService, - filePath, - srcPath, - destPath, - transformOpts, - dirMappingFiles, - topLevelFiles, - } = ctx - const dir = dirname(filePath) - const fileName = filePath.slice(dir === '.' ? 0 : dir.length + 1) - const transformedDir = dir === '.' ? null : transformPath(dir, transformOpts) - const targetDir = transformedDir ? join(destPath, transformedDir) : destPath - const targetFilePath = join(targetDir, fileName) - - await fileService.mkdir(targetDir, { recursive: true }) - await copyFileHelper(fileService, join(srcPath, filePath), targetFilePath, 'overwrite') - - await trackTransformedFile({ - fileService, - dir, - fileName, - transformedDir, - targetFilePath, - dirMappingFiles, - topLevelFiles, - }) -} - -/** - * Post-copy bookkeeping for a transformed file: syncs the frontmatter `name` - * when a subdirectory was renamed, and records the file in either - * `dirMappingFiles` (files under a subdirectory) or `topLevelFiles` (root-level - * source files) so link rewriting and mirror cleanup can see it. - */ -async function trackTransformedFile(ctx: { - fileService: FileSystemService - dir: string - fileName: string - transformedDir: string | null - targetFilePath: string - dirMappingFiles: Map - topLevelFiles: Set -}): Promise { - const { - fileService, - dir, - fileName, - transformedDir, - targetFilePath, - dirMappingFiles, - topLevelFiles, - } = ctx - - if (dir === '.') { - // Root-level source file — has no transformed subdirectory of its own, so it's - // never added to dirMappingFiles. Track it separately so cleanupStaleTransformedEntries - // knows it's a legitimate copy, not a stale leftover (see that function's docstring). - topLevelFiles.add(fileName) - return - } - if (!transformedDir) return - - const leafName = dir.split('/').pop()! - if (leafName !== transformedDir) { - const content = await fileService.readFile(targetFilePath) - const synced = syncFrontmatter(content, { from: leafName, to: transformedDir }) - if (synced !== content) { - await fileService.writeFile(targetFilePath, synced) - } - } - - if (!dirMappingFiles.has(dir)) dirMappingFiles.set(dir, []) - dirMappingFiles.get(dir)!.push(targetFilePath) -} - -/** - * Builds PathMappingEntry[] from the directory-to-files map collected during copy. - */ -function buildPathMapping( - dirMappingFiles: Map, - transformOpts: TransformOpts, - sourceRelative: string, - targetRelative: string, -): PathMappingEntry[] { - const pathMapping: PathMappingEntry[] = [] - for (const [originalSubDir, mappedFiles] of dirMappingFiles) { - const transformedSubDir = transformPath(originalSubDir, transformOpts) - pathMapping.push({ - originalDir: join(sourceRelative, originalSubDir), - newDir: join(targetRelative, transformedSubDir), - files: mappedFiles, - }) - } - return pathMapping -} - -/** - * Copies every file with naming transforms applied, collecting both the - * per-subdirectory mapping (for link rewriting, skill renames, and mirror - * cleanup of transformed directories) and the set of root-level file names - * (for mirror cleanup of files that have no subdirectory of their own). - */ -async function copyAllFilesWithTransform(params: { - fileService: FileSystemService - files: string[] - srcPath: string - destPath: string - transformOpts: TransformOpts -}): Promise<{ dirMappingFiles: Map; topLevelFiles: Set }> { - const { fileService, files, srcPath, destPath, transformOpts } = params - const dirMappingFiles = new Map() - const topLevelFiles = new Set() - for (const filePath of files) { - await copyFileWithTransform({ - fileService, - filePath, - srcPath, - destPath, - transformOpts, - dirMappingFiles, - topLevelFiles, - }) - } - return { dirMappingFiles, topLevelFiles } -} - -/** - * Copies a directory with flatten/prefix naming transforms applied. - * Each file's directory path (relative to source) is transformed, then - * the file is copied to the transformed location under the target. - */ -function buildTransformOpts(options?: SyncOptions): TransformOpts { - const flatten = options?.flatten ?? false - const prefix = options?.prefix - return prefix ? { flatten, prefix } : { flatten } -} - -export async function copyDirectoryWithTransforms(params: { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - datasetRoot: string - options?: SyncOptions -}): Promise { - const { fileService, srcPath, destPath, options } = params - const transformOpts = buildTransformOpts(options) - - const files = await collectFiles(fileService, srcPath, srcPath) - validateNoCollisions(files, transformOpts, srcPath) - - await fileService.mkdir(destPath, { recursive: true }) - - const { dirMappingFiles, topLevelFiles } = await copyAllFilesWithTransform({ - fileService, - files, - srcPath, - destPath, - transformOpts, - }) - - if (options?.defaultBehavior === 'mirror') { - await cleanupStaleTransformedEntries({ - fileService, - destPath, - dirMappingFiles, - topLevelFiles, - transformOpts, - }) - } - - await rewriteLinksForTransformedDirs(params, dirMappingFiles, transformOpts) - const skillNameMap = await applySkillReferenceRewrites( - fileService, - dirMappingFiles, - transformOpts, - ) - - logger.info( - `Copied contents of ${srcPath} -> ${destPath} (flatten=${transformOpts.flatten}, prefix=${transformOpts.prefix ?? 'none'})`, - ) - return skillNameMap.size > 0 ? { skillNameMap } : {} -} - -/** - * Removes stale top-level entries under a flatten/prefix target that no - * longer correspond to a source directory or a root-level source file. This - * is what makes `mirror` behavior idempotent across renames: a removed - * skill's leftover flattened directory is cleaned up, and a prefix change no - * longer leaves the old prefixed directory orphaned alongside the new one. - * - * Only top-level entries are considered — matches the granularity of the - * non-transform `handleMirrorCleanup` and the flatten use case (one source - * subdirectory maps to exactly one top-level target directory). - * - * `topLevelFiles` (file names copied directly from the source root, with no - * subdirectory of their own) must be included in `expected` alongside the - * transformed directory names — they're never in `dirMappingFiles` (which - * only tracks files under a subdirectory), so without this they'd be - * wrongly deleted as stale on the very next mirror run. - */ -async function cleanupStaleTransformedEntries(params: { - fileService: FileSystemService - destPath: string - dirMappingFiles: Map - topLevelFiles: Set - transformOpts: TransformOpts -}): Promise { - const { fileService, destPath, dirMappingFiles, topLevelFiles, transformOpts } = params - - const expected = new Set(topLevelFiles) - for (const originalSubDir of dirMappingFiles.keys()) { - const transformedDir = transformPath(originalSubDir, transformOpts) - expected.add(transformedDir.split('/')[0]!) - } - - const entries = await fileService.readdir(destPath).catch(() => []) - for (const entry of entries) { - if (expected.has(entry.name)) continue - const toRemove = join(destPath, entry.name) - await fileService.rm(toRemove, { recursive: true, force: true }) - logger.info(`Mirror: removed stale transformed entry ${toRemove}`) - } -} - -async function rewriteLinksForTransformedDirs( - params: { - fileService: FileSystemService - datasetRoot: string - srcPath: string - destPath: string - source: string - target: string - }, - dirMappingFiles: Map, - transformOpts: TransformOpts, -): Promise { - const sourceRelative = relative(params.datasetRoot, params.srcPath) || params.source - const targetRelative = relative(params.datasetRoot, params.destPath) || params.target - const pathMapping = buildPathMapping( - dirMappingFiles, - transformOpts, - sourceRelative, - targetRelative, - ) - if (pathMapping.length > 0) { - const sourceContentRoot = dirname(sourceRelative) - await rewriteLinksAfterTransform({ - fileService: params.fileService, - pathMapping, - datasetRoot: params.datasetRoot, - ...(sourceContentRoot !== '.' && { sourceContentRoot }), - }) - } -} - -/** - * Collects .md files from dirMappingFiles and rewrites skill references if any renames occurred. - */ -async function applySkillReferenceRewrites( - fileService: FileSystemService, - dirMappingFiles: Map, - transformOpts: TransformOpts, -): Promise { - const skillNameMap = buildSkillNameMap(dirMappingFiles, transformOpts) - if (skillNameMap.size === 0) return skillNameMap - - const allMdFiles: string[] = [] - for (const mappedFiles of dirMappingFiles.values()) { - for (const f of mappedFiles) { - if (f.endsWith('.md')) allMdFiles.push(f) - } - } - await rewriteSkillReferencesInFiles({ fileService, files: allMdFiles, skillNameMap }) - return skillNameMap -} - -/** - * Handles file copy operations - */ -async function handleFileCopy(params: HandleFileCopyParams) { - const { - fileService, - srcPath, - destPath, - source, - target, - normTarget, - datasetRoot, - defaultBehavior, - options, - skillNameMap, - } = params - - const finalDest = await determineFinalDestination(fileService, destPath, source, normTarget) - - try { - await copyFileHelper(fileService, srcPath, finalDest, defaultBehavior) - } catch (err) { - logger.error(`Failed to copy file ${srcPath} -> ${finalDest}: ${String(err)}`) - // If the original error message is specific (like test errors), preserve it - if (err instanceof Error && err.message.includes('boom')) { - throw err - } - throw createError({ - type: 'IO_ERROR', - message: `Failed to copy file ${srcPath} -> ${finalDest}`, - operation: 'copyFile', - path: srcPath, - originalError: err, - }) - } - logger.info(`Copied file ${srcPath} -> ${finalDest}`) - - await updateMarkdownLinks({ - fileService, - source, - target, - datasetRoot, - finalDest, - isDirectory: false, - options, - }) - - if (skillNameMap && skillNameMap.size > 0 && finalDest.endsWith('.md')) { - await rewriteSkillReferencesInFiles({ fileService, files: [finalDest], skillNameMap }) - } -} From e1831e57db54cb3281e3f62271c73cf413c701a5 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:04:37 +0200 Subject: [PATCH 03/21] [#199] refactor: type-safe MoveCtx (drop non-null assertions) - Replace `Partial<...>` MoveCtx + ctx.source!/ctx.normSource! with a total context type built once after setup (all fields required) - Discriminated union not applicable: branch unknown at ctx-build time - Task: T6 (P1.3) Refs: #199 --- packages/content-ops/src/ops/movePathOps.ts | 37 +++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/content-ops/src/ops/movePathOps.ts b/packages/content-ops/src/ops/movePathOps.ts index 3b9127a3..fa66fcb8 100644 --- a/packages/content-ops/src/ops/movePathOps.ts +++ b/packages/content-ops/src/ops/movePathOps.ts @@ -111,7 +111,7 @@ async function runMove(params: MovePathOpsParams) { // For 'add' behavior, check if destination already exists and skip if so if (await shouldSkipDueToAddBehavior(fileService, destPath, defaultBehavior)) return {} - await dispatchMoveBasedOnStat(stat, { + const ctx: MoveCtx = { fileService, srcPath, destPath, @@ -121,9 +121,10 @@ async function runMove(params: MovePathOpsParams) { normTarget, datasetRoot, defaultBehavior, - folderBehavior, - options, - } as MoveCtx) + ...(folderBehavior && { folderBehavior }), + ...(options && { options }), + } + await dispatchMoveBasedOnStat(stat, ctx) return {} } @@ -154,10 +155,10 @@ async function dispatchMoveBasedOnStat( fileService: ctx.fileService, srcPath: ctx.srcPath, destPath: ctx.destPath, - source: ctx.source!, - target: ctx.target!, - normSource: ctx.normSource!, - normTarget: ctx.normTarget!, + source: ctx.source, + target: ctx.target, + normSource: ctx.normSource, + normTarget: ctx.normTarget, datasetRoot: ctx.datasetRoot, defaultBehavior: ctx.defaultBehavior, ...(ctx.folderBehavior && { folderBehavior: ctx.folderBehavior }), @@ -169,9 +170,9 @@ async function dispatchMoveBasedOnStat( fileService: ctx.fileService, srcPath: ctx.srcPath, destPath: ctx.destPath, - source: ctx.source!, - target: ctx.target!, - normTarget: ctx.normTarget!, + source: ctx.source, + target: ctx.target, + normTarget: ctx.normTarget, datasetRoot: ctx.datasetRoot, ...(ctx.options && { options: ctx.options }), } @@ -185,13 +186,23 @@ async function dispatchMoveBasedOnStat( } } -// Local combined context type where optional properties may be undefined (satisfies exactOptionalPropertyTypes) -type MoveCtx = Partial & { +// Fully-resolved move context: every path/name is known once setup has run and +// the source/dest null check has passed, so all fields are required. The two +// behavior maps stay optional (absent unless configured). Replaces the former +// `Partial<...>` + non-null assertions — the branch (file vs directory) is not +// known when the context is built, so a discriminated union does not apply here. +type MoveCtx = { fileService: FileSystemService srcPath: string destPath: string + source: string + target: string + normSource: string + normTarget: string datasetRoot: string defaultBehavior: Behavior + folderBehavior?: Record + options?: SyncOptions } /** From e3fdbb6d39709cdc11dafaf956ba7de72952eaa8 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:09:52 +0200 Subject: [PATCH 04/21] [#199] refactor: LinkProcessor static class -> module with named exports - Convert 13 static methods to module-level named functions; private statics become module-private functions (no behavior change) - extractLinks/detectLinkStyle collapse duplicate standalone wrappers - applyReplacements/processFileWithLinks re-exported from replacement-applier - Update callers: replacement-applier, replacement-generator (aliased), link-rewriter, link-batch-processor; barrels src/index + markdown/index - Update tests to named imports (LinkProcessor was content-ops-internal) - Task: T3 (P0.4) Refs: #199 --- packages/content-ops/src/index.ts | 15 +- packages/content-ops/src/markdown/index.ts | 10 +- .../src/markdown/link-processor.test.ts | 110 +-- .../src/markdown/link-processor.ts | 631 ++++++++---------- .../src/markdown/replacement-applier.ts | 6 +- .../src/markdown/replacement-generator.ts | 20 +- .../src/ops/link-batch-processor.ts | 14 +- packages/content-ops/src/ops/link-rewriter.ts | 4 +- 8 files changed, 399 insertions(+), 411 deletions(-) diff --git a/packages/content-ops/src/index.ts b/packages/content-ops/src/index.ts index f4f00923..8e878cd8 100644 --- a/packages/content-ops/src/index.ts +++ b/packages/content-ops/src/index.ts @@ -10,8 +10,19 @@ export { isWithinPath, normalizePathForCompare, } from './file-system/file-operations' -export { extractLinks, type ParsedLink, LinkProcessor } from './markdown/link-processor' -export { detectLinkStyle } from './markdown/link-processor' +export { + extractLinks, + extractLinksFromFile, + extractLinksFromDirectory, + classifyLinkType, + extractAnchor, + splitLinkParts, + generateNormalizationReplacements, + generatePathSubstitutionReplacements, + detectLinkStyle, + type ParsedLink, + type LinkProcessingConfig, +} from './markdown/link-processor' export { calculateSHA256, validateChecksum, diff --git a/packages/content-ops/src/markdown/index.ts b/packages/content-ops/src/markdown/index.ts index b55b8e61..ebb1b55a 100644 --- a/packages/content-ops/src/markdown/index.ts +++ b/packages/content-ops/src/markdown/index.ts @@ -2,4 +2,12 @@ export * from './markdown-parser' export * from './replacement-applier' export * from './replacement-generator' export * from './path-resolution' -export { LinkProcessor, LinkProcessingConfig } from './link-processor' +export { + extractLinksFromFile, + extractLinksFromDirectory, + classifyLinkType, + extractAnchor, + splitLinkParts, + detectLinkStyle, + type LinkProcessingConfig, +} from './link-processor' diff --git a/packages/content-ops/src/markdown/link-processor.test.ts b/packages/content-ops/src/markdown/link-processor.test.ts index 1d720b55..0f52a32c 100644 --- a/packages/content-ops/src/markdown/link-processor.test.ts +++ b/packages/content-ops/src/markdown/link-processor.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect, vi } from 'vitest' -import { LinkProcessor } from './link-processor' +import { + extractLinks, + extractLinksFromFile, + extractLinksFromDirectory, + classifyLinkType, + detectLinkStyle, + generateNormalizationReplacements, + generatePathSubstitutionReplacements, + applyReplacements, + processFileWithLinks, +} from './link-processor' import type { ParsedLink, LinkProcessingConfig } from './link-processor' import { InMemoryFileSystemService } from '../test-utils/in-memory-fs' import { replaceLinkOnLine } from './replacement-applier' @@ -21,7 +31,7 @@ Here is a [link](page.md) and a [link with anchor](other.md#section). Another line with an external [link](https://example.com). ` - const links = await LinkProcessor.extractLinks(content) + const links = await extractLinks(content) expect(links).toHaveLength(3) expect(links[0]).toMatchObject({ @@ -38,7 +48,7 @@ Another line with an external [link](https://example.com). }) }) it('should handle empty content', async function () { - const links = await LinkProcessor.extractLinks('') + const links = await extractLinks('') expect(links).toHaveLength(0) }) }) @@ -70,7 +80,7 @@ describe('generateNormalizationReplacements - core', () => { exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/docs/current.md', config, @@ -110,7 +120,7 @@ describe('generateNormalizationReplacements - path substitution', () => { exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/docs/current.md', config, @@ -142,11 +152,7 @@ describe('generatePathSubstitutionReplacements', () => { }, ] - const replacements = await LinkProcessor.generatePathSubstitutionReplacements( - links, - 'old/path', - 'new/path', - ) + const replacements = await generatePathSubstitutionReplacements(links, 'old/path', 'new/path') expect(replacements).toHaveLength(1) expect(replacements[0].newHref).toBe('new/path/page.md') @@ -167,7 +173,7 @@ describe('applyReplacements', () => { }, ] - const result = LinkProcessor.applyReplacements(content, replacements) + const result = applyReplacements(content, replacements) expect(result.content).toBe('This is a [link](new.md) and [another](other.md).') expect(result.applied).toBe(1) @@ -176,7 +182,7 @@ describe('applyReplacements', () => { it('should handle empty replacements', function () { const content = 'No links here.' - const result = LinkProcessor.applyReplacements(content, []) + const result = applyReplacements(content, []) expect(result.content).toBe('No links here.') expect(result.applied).toBe(0) @@ -197,7 +203,7 @@ describe('processFileWithLinks', () => { }, ]) - const result = await LinkProcessor.processFileWithLinks(content, generateReplacements) + const result = await processFileWithLinks(content, generateReplacements) expect(result.content).toBe('This is a [link](new-page.md).') expect(result.applied).toBe(1) @@ -262,7 +268,7 @@ it('should normalize ../page.md to page.md as normalizedRel when file exists und exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/docs/current.md', config, @@ -299,7 +305,7 @@ it('should not normalize ../index.md to index.md when not appropriate', async fu exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/guide/current.md', config, @@ -368,7 +374,7 @@ it('extractLinksFromFile: should extract links with file context', async functio '/project', ) - const links = await LinkProcessor.extractLinksFromFile('/project/docs/guide.md', fileService) + const links = await extractLinksFromFile('/project/docs/guide.md', fileService) expect(links).toHaveLength(2) expect(links[0]).toMatchObject({ @@ -401,7 +407,7 @@ it('extractLinksFromFile: should classify link types', async function () { '/project', ) - const links = await LinkProcessor.extractLinksFromFile('/project/test.md', fileService) + const links = await extractLinksFromFile('/project/test.md', fileService) expect(links).toHaveLength(5) expect(links[0]).toMatchObject({ href: './file.md', type: 'relative' }) @@ -420,7 +426,7 @@ it('extractLinksFromFile: should extract anchor from links with fragments', asyn '/project', ) - const links = await LinkProcessor.extractLinksFromFile('/project/test.md', fileService) + const links = await extractLinksFromFile('/project/test.md', fileService) expect(links).toHaveLength(1) expect(links[0]).toMatchObject({ @@ -439,7 +445,7 @@ it('extractLinksFromFile: should handle files with no links', async function () '/project', ) - const links = await LinkProcessor.extractLinksFromFile('/project/plain.md', fileService) + const links = await extractLinksFromFile('/project/plain.md', fileService) expect(links).toHaveLength(0) }) @@ -455,7 +461,7 @@ it('extractLinksFromDirectory: should extract links from all markdown files in d '/project', ) - const links = await LinkProcessor.extractLinksFromDirectory('/project/docs', fileService) + const links = await extractLinksFromDirectory('/project/docs', fileService) expect(links.length).toBeGreaterThanOrEqual(2) const filesParsed = [...new Set(links.map(l => l.filePath))] @@ -473,7 +479,7 @@ it('extractLinksFromDirectory: should handle nested directories', async function '/project', ) - const links = await LinkProcessor.extractLinksFromDirectory('/project/docs', fileService) + const links = await extractLinksFromDirectory('/project/docs', fileService) expect(links.length).toBeGreaterThanOrEqual(2) }) @@ -489,7 +495,7 @@ it('extractLinksFromDirectory: should skip non-markdown files', async function ( '/project', ) - const links = await LinkProcessor.extractLinksFromDirectory('/project/docs', fileService) + const links = await extractLinksFromDirectory('/project/docs', fileService) const filesParsed = [...new Set(links.map(l => l.filePath))] expect(filesParsed).toHaveLength(1) @@ -505,39 +511,39 @@ it('extractLinksFromDirectory: should handle empty directories', async function '/project', ) - const links = await LinkProcessor.extractLinksFromDirectory('/project/docs', fileService) + const links = await extractLinksFromDirectory('/project/docs', fileService) expect(links).toHaveLength(0) }) -describe('LinkProcessor - classifyLinkType (T-69-02)', () => { +describe('link-processor - classifyLinkType (T-69-02)', () => { it('should classify relative paths', () => { - expect(LinkProcessor.classifyLinkType('./file.md')).toBe('relative') - expect(LinkProcessor.classifyLinkType('../file.md')).toBe('relative') - expect(LinkProcessor.classifyLinkType('file.md')).toBe('relative') - expect(LinkProcessor.classifyLinkType('docs/file.md')).toBe('relative') + expect(classifyLinkType('./file.md')).toBe('relative') + expect(classifyLinkType('../file.md')).toBe('relative') + expect(classifyLinkType('file.md')).toBe('relative') + expect(classifyLinkType('docs/file.md')).toBe('relative') }) it('should classify absolute paths', () => { - expect(LinkProcessor.classifyLinkType('/docs/file.md')).toBe('absolute') - expect(LinkProcessor.classifyLinkType('/file.md')).toBe('absolute') + expect(classifyLinkType('/docs/file.md')).toBe('absolute') + expect(classifyLinkType('/file.md')).toBe('absolute') }) it('should classify HTTP/HTTPS URLs', () => { - expect(LinkProcessor.classifyLinkType('https://example.com')).toBe('http') - expect(LinkProcessor.classifyLinkType('http://example.com')).toBe('http') + expect(classifyLinkType('https://example.com')).toBe('http') + expect(classifyLinkType('http://example.com')).toBe('http') }) it('should classify mailto links', () => { - expect(LinkProcessor.classifyLinkType('mailto:test@example.com')).toBe('mailto') + expect(classifyLinkType('mailto:test@example.com')).toBe('mailto') }) it('should classify anchor-only links', () => { - expect(LinkProcessor.classifyLinkType('#section')).toBe('anchor') - expect(LinkProcessor.classifyLinkType('#')).toBe('anchor') + expect(classifyLinkType('#section')).toBe('anchor') + expect(classifyLinkType('#')).toBe('anchor') }) }) -describe('LinkProcessor - performance (T-69-02)', () => { +describe('link-processor - performance (T-69-02)', () => { it('should handle large files efficiently', async function () { const largeContent = Array(1000) .fill(0) @@ -553,7 +559,7 @@ describe('LinkProcessor - performance (T-69-02)', () => { ) const startTime = Date.now() - const links = await LinkProcessor.extractLinksFromFile('/project/large.md', fileService) + const links = await extractLinksFromFile('/project/large.md', fileService) const duration = Date.now() - startTime expect(links).toHaveLength(1000) @@ -561,7 +567,7 @@ describe('LinkProcessor - performance (T-69-02)', () => { }) }) -describe('LinkProcessor - edge cases (T-69-02)', () => { +describe('link-processor - edge cases (T-69-02)', () => { it('should handle malformed markdown gracefully', async function () { const fileService = new InMemoryFileSystemService( { @@ -571,7 +577,7 @@ describe('LinkProcessor - edge cases (T-69-02)', () => { '/project', ) - const links = await LinkProcessor.extractLinksFromFile('/project/malformed.md', fileService) + const links = await extractLinksFromFile('/project/malformed.md', fileService) expect(links.length).toBeGreaterThanOrEqual(0) }) @@ -585,7 +591,7 @@ describe('LinkProcessor - edge cases (T-69-02)', () => { '/project', ) - const links = await LinkProcessor.extractLinksFromFile('/project/special.md', fileService) + const links = await extractLinksFromFile('/project/special.md', fileService) expect(links).toHaveLength(1) expect(links[0].href).toBe('file%20with%20spaces.md') @@ -618,7 +624,7 @@ it('should preserve simple anchors when normalizing relative links', async funct exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/docs/current.md', config, @@ -656,7 +662,7 @@ it('should preserve encoded anchors when normalizing', async function () { exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/docs/current.md', config, @@ -694,7 +700,7 @@ it('should preserve query parameters alongside anchors when normalizing', async exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/docs/current.md', config, @@ -732,7 +738,7 @@ it('should preserve encoded query and anchor characters when normalizing', async exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/docs/current.md', config, @@ -775,7 +781,7 @@ describe('generateNormalizationReplacements - no full normalization', () => { exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/.skills/capability/verify-quality/SKILL.md', config, @@ -813,7 +819,7 @@ describe('generateNormalizationReplacements - no full normalization', () => { exclusionList: [], } - const replacements = await LinkProcessor.generateNormalizationReplacements( + const replacements = await generateNormalizationReplacements( links, '/dataset/.pair/knowledge/how-to/other.md', config, @@ -827,7 +833,7 @@ describe('generateNormalizationReplacements - no full normalization', () => { }) }) -describe('LinkProcessor - detectLinkStyle', () => { +describe('link-processor - detectLinkStyle', () => { const cwd = '/test' it('should return relative when majority are relative', async () => { @@ -838,7 +844,7 @@ describe('LinkProcessor - detectLinkStyle', () => { cwd, cwd, ) - expect(await LinkProcessor.detectLinkStyle(fs, cwd)).toBe('relative') + expect(await detectLinkStyle(fs, cwd)).toBe('relative') }) it('should return absolute when majority are absolute', async () => { @@ -849,7 +855,7 @@ describe('LinkProcessor - detectLinkStyle', () => { cwd, cwd, ) - expect(await LinkProcessor.detectLinkStyle(fs, cwd)).toBe('absolute') + expect(await detectLinkStyle(fs, cwd)).toBe('absolute') }) it('should skip external and anchor links', async () => { @@ -862,7 +868,7 @@ describe('LinkProcessor - detectLinkStyle', () => { cwd, ) // 2 absolute vs 1 relative (anchor and external skipped) - expect(await LinkProcessor.detectLinkStyle(fs, cwd)).toBe('absolute') + expect(await detectLinkStyle(fs, cwd)).toBe('absolute') }) it('should return relative when counts are equal', async () => { @@ -873,12 +879,12 @@ describe('LinkProcessor - detectLinkStyle', () => { cwd, cwd, ) - expect(await LinkProcessor.detectLinkStyle(fs, cwd)).toBe('relative') + expect(await detectLinkStyle(fs, cwd)).toBe('relative') }) it('should return relative in empty directory', async () => { const fs = new InMemoryFileSystemService({}, cwd, cwd) await fs.mkdir(cwd, { recursive: true }) - expect(await LinkProcessor.detectLinkStyle(fs, cwd)).toBe('relative') + expect(await detectLinkStyle(fs, cwd)).toBe('relative') }) }) diff --git a/packages/content-ops/src/markdown/link-processor.ts b/packages/content-ops/src/markdown/link-processor.ts index 11a7ce63..549f4c52 100644 --- a/packages/content-ops/src/markdown/link-processor.ts +++ b/packages/content-ops/src/markdown/link-processor.ts @@ -8,12 +8,12 @@ import { normalizeLinkSlashes, walkMarkdownFiles, } from '../file-system/file-system-utils' -import { - type Replacement, - applyReplacements, - processFileWithLinks, - type ApplyResult, -} from './replacement-applier' +import { type Replacement } from './replacement-applier' + +// Re-export the replacement-application helpers so consumers can keep importing +// the whole link-processing surface from a single module. +export { applyReplacements, processFileWithLinks } from './replacement-applier' +export type { ApplyResult } from './replacement-applier' /** * Represents a parsed markdown link @@ -36,249 +36,174 @@ export type LinkProcessingConfig = { } /** - * LinkProcessor - Centralized module for markdown link processing - * Handles parsing, analysis, transformation, and replacement of markdown links + * Extract all markdown links from content using the shared markdown parser */ -export class LinkProcessor { - // processor cache removed; parsing delegated to markdown-parser - - /** - * Get or create the unified markdown processor - */ - /** - * Extract all markdown links from content using the shared markdown parser - */ - static async extractLinks(content: string): Promise { - return parseExtractLinks(content) - } - - /** - * Extract links from a single markdown file and enrich with file context - */ - static async extractLinksFromFile( - filePath: string, - fileService: FileSystemService, - ): Promise< - Array - > { - const content = await fileService.readFile(filePath) - const parsed = await this.extractLinks(content) - - return parsed.map(p => { - const href = p.href - const type = this.classifyLinkType(href) - const anchor = this.extractAnchor(href) - return Object.assign({}, p, { filePath, type: type as string | undefined, anchor }) - }) - } +export async function extractLinks(content: string): Promise { + return parseExtractLinks(content) +} - /** - * Extract links from all markdown files under a directory (recursively) - */ - static async extractLinksFromDirectory( - dir: string, - fileService: FileSystemService, - ): Promise< - Array - > { - const files = await walkMarkdownFiles(dir, fileService) - const results = await Promise.all(files.map(f => this.extractLinksFromFile(f, fileService))) - return results.flat() - } +/** + * Extract links from a single markdown file and enrich with file context + */ +export async function extractLinksFromFile( + filePath: string, + fileService: FileSystemService, +): Promise< + Array +> { + const content = await fileService.readFile(filePath) + const parsed = await extractLinks(content) + + return parsed.map(p => { + const href = p.href + const type = classifyLinkType(href) + const anchor = extractAnchor(href) + return Object.assign({}, p, { filePath, type: type as string | undefined, anchor }) + }) +} - /** - * Classify a link href into a simple type for downstream logic - */ - static classifyLinkType( - href?: string, - ): 'relative' | 'absolute' | 'http' | 'mailto' | 'anchor' | 'other' { - if (!href) return 'other' - const h = href.trim() - if (h.startsWith('#')) return 'anchor' - if (/^https?:\/\//i.test(h)) return 'http' - if (/^mailto:/i.test(h)) return 'mailto' - if (h.startsWith('/')) return 'absolute' - return 'relative' - } +/** + * Extract links from all markdown files under a directory (recursively) + */ +export async function extractLinksFromDirectory( + dir: string, + fileService: FileSystemService, +): Promise< + Array +> { + const files = await walkMarkdownFiles(dir, fileService) + const results = await Promise.all(files.map(f => extractLinksFromFile(f, fileService))) + return results.flat() +} - static extractAnchor(href?: string): string | undefined { - if (!href) return undefined - const idx = href.indexOf('#') - return idx >= 0 ? href.substring(idx) : undefined - } +/** + * Classify a link href into a simple type for downstream logic + */ +export function classifyLinkType( + href?: string, +): 'relative' | 'absolute' | 'http' | 'mailto' | 'anchor' | 'other' { + if (!href) return 'other' + const h = href.trim() + if (h.startsWith('#')) return 'anchor' + if (/^https?:\/\//i.test(h)) return 'http' + if (/^mailto:/i.test(h)) return 'mailto' + if (h.startsWith('/')) return 'absolute' + return 'relative' +} - /** - * Split a link into filesystem path, query string (including '?') and anchor (including '#') - */ - static splitLinkParts(href?: string) { - if (!href) return { path: '', query: '', anchor: '' } - const hashIdx = href.indexOf('#') - const qIdx = href.indexOf('?') - - let pathEnd = href.length - if (hashIdx >= 0) pathEnd = Math.min(pathEnd, hashIdx) - if (qIdx >= 0) pathEnd = Math.min(pathEnd, qIdx) - - const path = href.substring(0, pathEnd) - const query = - qIdx >= 0 && (hashIdx < 0 || qIdx < hashIdx) - ? href.substring(qIdx, hashIdx >= 0 ? hashIdx : undefined) - : '' - const anchor = hashIdx >= 0 ? href.substring(hashIdx) : '' - return { path, query, anchor } - } +export function extractAnchor(href?: string): string | undefined { + if (!href) return undefined + const idx = href.indexOf('#') + return idx >= 0 ? href.substring(idx) : undefined +} - /** - * Generate replacements for link normalization - */ - static async generateNormalizationReplacements( - links: ParsedLink[], - file: string, - config: LinkProcessingConfig, - fileService: FileSystemService, - ): Promise { - const replacements: Replacement[] = [] - for (const lnk of links) { - await this.processLinkForNormalization({ lnk, file, config, fileService, replacements }) - } +/** + * Split a link into filesystem path, query string (including '?') and anchor (including '#') + */ +export function splitLinkParts(href?: string) { + if (!href) return { path: '', query: '', anchor: '' } + const hashIdx = href.indexOf('#') + const qIdx = href.indexOf('?') + + let pathEnd = href.length + if (hashIdx >= 0) pathEnd = Math.min(pathEnd, hashIdx) + if (qIdx >= 0) pathEnd = Math.min(pathEnd, qIdx) + + const path = href.substring(0, pathEnd) + const query = + qIdx >= 0 && (hashIdx < 0 || qIdx < hashIdx) + ? href.substring(qIdx, hashIdx >= 0 ? hashIdx : undefined) + : '' + const anchor = hashIdx >= 0 ? href.substring(hashIdx) : '' + return { path, query, anchor } +} - return replacements +/** + * Generate replacements for link normalization + */ +export async function generateNormalizationReplacements( + links: ParsedLink[], + file: string, + config: LinkProcessingConfig, + fileService: FileSystemService, +): Promise { + const replacements: Replacement[] = [] + for (const lnk of links) { + await processLinkForNormalization({ lnk, file, config, fileService, replacements }) } - private static async processLinkForNormalization(params: { - lnk: ParsedLink - file: string - config: LinkProcessingConfig - fileService: FileSystemService - replacements: Replacement[] - }) { - const { lnk, file, config, fileService, replacements } = params - const originalLink = lnk.href - const linkPath: string | undefined = originalLink - - if (this.isSkippableLink(linkPath, config)) return - - // split into path/query/anchor — resolve against filesystem using only the path - const { path: linkPathOnly, query, anchor } = this.splitLinkParts(linkPath) - const absTarget = await this.resolveAbsTarget(file, linkPathOnly, config) - const hostDir = dirname(file) - - // Try same-directory relative normalization - if ( - await this.tryPushRelativeNormalization({ - replacements, - lnk, - linkPath, - absTarget, - hostDir, - fileService, - query, - anchor, - }) - ) - return + return replacements +} - // Try full-docs normalization - await this.tryPushFullNormalization({ +async function processLinkForNormalization(params: { + lnk: ParsedLink + file: string + config: LinkProcessingConfig + fileService: FileSystemService + replacements: Replacement[] +}) { + const { lnk, file, config, fileService, replacements } = params + const originalLink = lnk.href + const linkPath: string | undefined = originalLink + + if (isSkippableLink(linkPath, config)) return + + // split into path/query/anchor — resolve against filesystem using only the path + const { path: linkPathOnly, query, anchor } = splitLinkParts(linkPath) + const absTarget = await resolveAbsTarget(file, linkPathOnly, config) + const hostDir = dirname(file) + + // Try same-directory relative normalization + if ( + await tryPushRelativeNormalization({ replacements, lnk, linkPath, absTarget, - config, + hostDir, fileService, query, anchor, }) - } - - private static async tryPushRelativeNormalization(params: { - replacements: Replacement[] - lnk: ParsedLink - linkPath: string - absTarget: string - hostDir: string - fileService: FileSystemService - query: string - anchor: string - }) { - const { replacements, lnk, linkPath, absTarget, hostDir, fileService, anchor } = params - const { query } = params - let relFromHost = convertToRelative(hostDir, absTarget) - if (!relFromHost.startsWith('..')) { - if (!(await fileService.exists(absTarget))) return false - // preserve anchors but do not introduce a leading './' that wasn't present - // if the original link didn't start with './', strip the './' prefix - if (!linkPath.startsWith('./') && relFromHost.startsWith('./')) { - relFromHost = relFromHost.slice(2) - } - const normalized = relFromHost + (query || '') + (anchor || '') - if (linkPath !== normalized) { - this.pushNormalizedReplacement({ - replacements, - lnk, - oldHref: linkPath, - newHref: normalized, - kind: 'normalizedRel', - }) - } - return true - } - return false - } + ) + return + + // Try full-docs normalization + await tryPushFullNormalization({ + replacements, + lnk, + linkPath, + absTarget, + config, + fileService, + query, + anchor, + }) +} - private static async tryPushFullNormalization(params: { - replacements: Replacement[] - lnk: ParsedLink - linkPath: string - absTarget: string - config: LinkProcessingConfig - fileService: FileSystemService - query: string - anchor: string - }) { - const { replacements, lnk, linkPath, absTarget, config, fileService, anchor } = params - const { query } = params - const relToDocs = convertToRelative(config.datasetRoot, absTarget) - // convertToRelative returns './' when paths are identical; preserve original - // behavior by treating './' as not valid - if (!relToDocs || relToDocs.startsWith('..') || relToDocs === './') return - - const normalized = normalizeLinkSlashes(relToDocs) + (query || '') + (anchor || '') - - // handle single-filename normalization - if (!relToDocs.includes('/')) { - await this.tryPushSingleFileNormalization({ - replacements, - lnk, - linkPath, - absTarget, - fileService, - normalized, - relToDocs, - }) +async function tryPushRelativeNormalization(params: { + replacements: Replacement[] + lnk: ParsedLink + linkPath: string + absTarget: string + hostDir: string + fileService: FileSystemService + query: string + anchor: string +}) { + const { replacements, lnk, linkPath, absTarget, hostDir, fileService, anchor } = params + const { query } = params + let relFromHost = convertToRelative(hostDir, absTarget) + if (!relFromHost.startsWith('..')) { + if (!(await fileService.exists(absTarget))) return false + // preserve anchors but do not introduce a leading './' that wasn't present + // if the original link didn't start with './', strip the './' prefix + if (!linkPath.startsWith('./') && relFromHost.startsWith('./')) { + relFromHost = relFromHost.slice(2) } - - // Multi-file path normalization (converting relative paths to docsFolders-based - // paths like .pair/adoption/tech/...) is intentionally disabled. These non-standard - // paths are not navigable in IDEs/GitHub and break the link rewriter during skill - // distribution. Relative paths (e.g., ../../../.pair/...) are correct and work everywhere. - } - - private static async tryPushSingleFileNormalization(params: { - replacements: Replacement[] - lnk: ParsedLink - linkPath: string - absTarget: string - fileService: FileSystemService - normalized: string - relToDocs: string - }) { - const { replacements, lnk, linkPath, absTarget, fileService, normalized, relToDocs } = params - const base = relToDocs - if (base === 'index.md') return - if (!(await fileService.exists(absTarget))) return + const normalized = relFromHost + (query || '') + (anchor || '') if (linkPath !== normalized) { - this.pushNormalizedReplacement({ + pushNormalizedReplacement({ replacements, lnk, oldHref: linkPath, @@ -286,136 +211,164 @@ export class LinkProcessor { kind: 'normalizedRel', }) } + return true } + return false +} - private static isSkippableLink(url: string, config: LinkProcessingConfig) { - return ( - !url || - isExternalLink(url) || - config.exclusionList.some(e => url.startsWith(e)) || - /^:.*\.md:$/.test(url) - ) - } - - // NOTE: anchor extraction is handled via splitLinkParts; keep helper-free implementation. - - private static async resolveAbsTarget( - file: string, - linkForResolve: string, - config: LinkProcessingConfig, - ) { - return resolveMarkdownPath(file, linkForResolve, config.docsFolders, config.datasetRoot) - } - - private static pushNormalizedReplacement(opts: { - replacements: Replacement[] - lnk: ParsedLink - oldHref: string - newHref: string - kind?: 'normalizedRel' | 'normalizedFull' - }) { - const { replacements, lnk, oldHref, newHref, kind = 'normalizedRel' } = opts - replacements.push({ - start: lnk.start, - end: lnk.end, - line: lnk.line, - oldHref, - newHref, - kind, +async function tryPushFullNormalization(params: { + replacements: Replacement[] + lnk: ParsedLink + linkPath: string + absTarget: string + config: LinkProcessingConfig + fileService: FileSystemService + query: string + anchor: string +}) { + const { replacements, lnk, linkPath, absTarget, config, fileService, anchor } = params + const { query } = params + const relToDocs = convertToRelative(config.datasetRoot, absTarget) + // convertToRelative returns './' when paths are identical; preserve original + // behavior by treating './' as not valid + if (!relToDocs || relToDocs.startsWith('..') || relToDocs === './') return + + const normalized = normalizeLinkSlashes(relToDocs) + (query || '') + (anchor || '') + + // handle single-filename normalization + if (!relToDocs.includes('/')) { + await tryPushSingleFileNormalization({ + replacements, + lnk, + linkPath, + absTarget, + fileService, + normalized, + relToDocs, }) } - /** - * Generate replacements for path substitution - */ - static async generatePathSubstitutionReplacements( - links: ParsedLink[], - oldBase: string, - newBase: string, - ): Promise { - const replacements: Replacement[] = [] - - for (const p of links) { - const link = p.href - if (isExternalLink(link)) continue - const norm = normalizeLinkSlashes(link) - if (norm.startsWith(oldBase)) { - replacements.push({ - start: p.start, - end: p.end, - line: p.line, - oldHref: link, - newHref: newBase + norm.slice(oldBase.length), - kind: 'pathSubstitution', - }) - } - } - - return replacements - } + // Multi-file path normalization (converting relative paths to docsFolders-based + // paths like .pair/adoption/tech/...) is intentionally disabled. These non-standard + // paths are not navigable in IDEs/GitHub and break the link rewriter during skill + // distribution. Relative paths (e.g., ../../../.pair/...) are correct and work everywhere. +} - /** - * Apply replacements to content (delegates to replacement-applier) - */ - static applyReplacements(content: string, replacements: Replacement[]): ApplyResult { - return applyReplacements(content, replacements) +async function tryPushSingleFileNormalization(params: { + replacements: Replacement[] + lnk: ParsedLink + linkPath: string + absTarget: string + fileService: FileSystemService + normalized: string + relToDocs: string +}) { + const { replacements, lnk, linkPath, absTarget, fileService, normalized, relToDocs } = params + const base = relToDocs + if (base === 'index.md') return + if (!(await fileService.exists(absTarget))) return + if (linkPath !== normalized) { + pushNormalizedReplacement({ + replacements, + lnk, + oldHref: linkPath, + newHref: normalized, + kind: 'normalizedRel', + }) } +} - /** - * Process a file with link replacements (delegates to replacement-applier) - */ - static async processFileWithLinks( - content: string, - generateReplacements: (links: ParsedLink[]) => Promise, - ): Promise<{ content: string; applied: number; byKind: Record }> { - return processFileWithLinks(content, generateReplacements) - } +function isSkippableLink(url: string, config: LinkProcessingConfig) { + return ( + !url || + isExternalLink(url) || + config.exclusionList.some(e => url.startsWith(e)) || + /^:.*\.md:$/.test(url) + ) +} - /** - * Detect the dominant link style in markdown files within a directory - * Returns 'relative' if relative links are >= absolute links, otherwise 'absolute' - */ - static async detectLinkStyle( - fsService: FileSystemService, - targetPath: string, - ): Promise<'relative' | 'absolute'> { - const files = await walkMarkdownFiles(targetPath, fsService) - let relativeCount = 0 - let absoluteCount = 0 - - for (const file of files) { - const content = await fsService.readFile(file) - const links = await this.extractLinks(content) - - for (const link of links) { - if (isExternalLink(link.href)) continue - if (link.href.startsWith('#')) continue - - if (link.href.startsWith('/')) { - absoluteCount++ - } else { - relativeCount++ - } - } - } +async function resolveAbsTarget( + file: string, + linkForResolve: string, + config: LinkProcessingConfig, +) { + return resolveMarkdownPath(file, linkForResolve, config.docsFolders, config.datasetRoot) +} - return relativeCount >= absoluteCount ? 'relative' : 'absolute' - } +function pushNormalizedReplacement(opts: { + replacements: Replacement[] + lnk: ParsedLink + oldHref: string + newHref: string + kind?: 'normalizedRel' | 'normalizedFull' +}) { + const { replacements, lnk, oldHref, newHref, kind = 'normalizedRel' } = opts + replacements.push({ + start: lnk.start, + end: lnk.end, + line: lnk.line, + oldHref, + newHref, + kind, + }) } /** - * Standalone export for extractLinks to maintain compatibility + * Generate replacements for path substitution */ -export async function extractLinks(content: string): Promise { - return LinkProcessor.extractLinks(content) +export async function generatePathSubstitutionReplacements( + links: ParsedLink[], + oldBase: string, + newBase: string, +): Promise { + const replacements: Replacement[] = [] + + for (const p of links) { + const link = p.href + if (isExternalLink(link)) continue + const norm = normalizeLinkSlashes(link) + if (norm.startsWith(oldBase)) { + replacements.push({ + start: p.start, + end: p.end, + line: p.line, + oldHref: link, + newHref: newBase + norm.slice(oldBase.length), + kind: 'pathSubstitution', + }) + } + } + + return replacements } /** - * Standalone export for detectLinkStyle to maintain compatibility + * Detect the dominant link style in markdown files within a directory + * Returns 'relative' if relative links are >= absolute links, otherwise 'absolute' */ export async function detectLinkStyle( - fs: FileSystemService, + fsService: FileSystemService, targetPath: string, ): Promise<'relative' | 'absolute'> { - return LinkProcessor.detectLinkStyle(fs, targetPath) + const files = await walkMarkdownFiles(targetPath, fsService) + let relativeCount = 0 + let absoluteCount = 0 + + for (const file of files) { + const content = await fsService.readFile(file) + const links = await extractLinks(content) + + for (const link of links) { + if (isExternalLink(link.href)) continue + if (link.href.startsWith('#')) continue + + if (link.href.startsWith('/')) { + absoluteCount++ + } else { + relativeCount++ + } + } + } + + return relativeCount >= absoluteCount ? 'relative' : 'absolute' } diff --git a/packages/content-ops/src/markdown/replacement-applier.ts b/packages/content-ops/src/markdown/replacement-applier.ts index be7384dd..3431979b 100644 --- a/packages/content-ops/src/markdown/replacement-applier.ts +++ b/packages/content-ops/src/markdown/replacement-applier.ts @@ -1,5 +1,5 @@ import { ParsedLink, extractLinks } from './markdown-parser' -import { LinkProcessor } from './link-processor' +import { extractLinksFromFile } from './link-processor' import { FileSystemService } from '../file-system' function applyOffsetReplacement(content: string, r: Replacement) { @@ -108,7 +108,7 @@ export async function processFileReplacement( const content = await fileService.readFile(file) const lines = content.split(/\r?\n/) // Use enriched link extraction when possible (includes filePath, type, anchor) - // This is an adapter to prefer LinkProcessor.extractLinksFromFile while + // This is an adapter to prefer extractLinksFromFile while // keeping existing processFileWithLinks available for compatibility. let result = await tryEnrichedProcess({ file, generateReplacements, content, lines, fileService }) if (!result) { @@ -135,7 +135,7 @@ async function tryEnrichedProcess(opts: { }): Promise { const { file, generateReplacements, content, lines, fileService } = opts try { - const enriched = await LinkProcessor.extractLinksFromFile(file, fileService) + const enriched = await extractLinksFromFile(file, fileService) // Pass only the ParsedLink shape expected by generators (enriched includes extras) const parsedLinks: ParsedLink[] = enriched.map(l => ({ href: l.href, diff --git a/packages/content-ops/src/markdown/replacement-generator.ts b/packages/content-ops/src/markdown/replacement-generator.ts index fc5ad170..7ad4d117 100644 --- a/packages/content-ops/src/markdown/replacement-generator.ts +++ b/packages/content-ops/src/markdown/replacement-generator.ts @@ -1,8 +1,12 @@ -// path.relative is no longer used; normalization delegated to LinkProcessor +// path.relative is no longer used; normalization delegated to link-processor import { FileSystemService } from '../file-system/file-system-service' import { dirname } from 'path' import { ParsedLink } from './markdown-parser' -import { LinkProcessor, LinkProcessingConfig } from './link-processor' +import { + generateNormalizationReplacements as normalizeLinks, + generatePathSubstitutionReplacements as substitutePaths, + LinkProcessingConfig, +} from './link-processor' import { ErrorLog } from '../observability' import { Replacement } from './replacement-applier' import { isExternalLink, stripAnchor } from '../file-system/file-system-utils' @@ -33,7 +37,7 @@ function shouldSkipLink(linkPath: string, config?: { exclusionList?: string[] }) /** * Generate relative path normalization replacement */ -// Delegated normalization helpers removed; use LinkProcessor +// Delegated normalization helpers removed; use link-processor export async function generateNormalizationReplacements( links: ParsedLink[], @@ -41,11 +45,11 @@ export async function generateNormalizationReplacements( config: LinkProcessingConfig, fileService: FileSystemService, ): Promise { - // Delegate to centralized LinkProcessor implementation using a typed config - return LinkProcessor.generateNormalizationReplacements(links, file, config, fileService) + // Delegate to the centralized link-processor implementation using a typed config + return normalizeLinks(links, file, config, fileService) } -// Normalization logic delegated to LinkProcessor +// Normalization logic delegated to link-processor export async function generateExistenceCheckReplacements( context: ExistenceCheckContext, @@ -155,6 +159,6 @@ export async function generatePathSubstitutionReplacements( oldBase: string, newBase: string, ): Promise { - // Delegate to LinkProcessor implementation - return LinkProcessor.generatePathSubstitutionReplacements(links, oldBase, newBase) + // Delegate to the centralized link-processor implementation + return substitutePaths(links, oldBase, newBase) } diff --git a/packages/content-ops/src/ops/link-batch-processor.ts b/packages/content-ops/src/ops/link-batch-processor.ts index 2c159d92..8c979f6a 100644 --- a/packages/content-ops/src/ops/link-batch-processor.ts +++ b/packages/content-ops/src/ops/link-batch-processor.ts @@ -1,6 +1,12 @@ import { FileSystemService, walkMarkdownFiles } from '../file-system' import { Replacement } from '../markdown' -import { LinkProcessor, ParsedLink, LinkProcessingConfig } from '../markdown/link-processor' +import { + generateNormalizationReplacements, + generatePathSubstitutionReplacements, + processFileWithLinks, + ParsedLink, + LinkProcessingConfig, +} from '../markdown/link-processor' import { logger } from '../observability' import { DEFAULT_CONCURRENCY_LIMIT } from './path-operation-helpers' @@ -120,7 +126,7 @@ export async function processPathSubstitution(options: { }): Promise { const { datasetRoot, oldBase, newBase, config, fileService } = options const generateReplacements = async (links: ParsedLink[]) => - LinkProcessor.generatePathSubstitutionReplacements(links, oldBase, newBase) + generatePathSubstitutionReplacements(links, oldBase, newBase) return processDirectoryWithLinkReplacements( datasetRoot, @@ -143,7 +149,7 @@ export async function processNormalization( file: string, config: LinkProcessingConfig, fileService: FileSystemService, - ) => LinkProcessor.generateNormalizationReplacements(links, file, config, fileService) + ) => generateNormalizationReplacements(links, file, config, fileService) return processDirectoryWithLinkReplacements( datasetRoot, @@ -177,7 +183,7 @@ async function processSingleFile( try { const content = await fileService.readFile(file) - const result = await LinkProcessor.processFileWithLinks(content, async (links: ParsedLink[]) => + const result = await processFileWithLinks(content, async (links: ParsedLink[]) => generateReplacements(links, file, config, fileService), ) diff --git a/packages/content-ops/src/ops/link-rewriter.ts b/packages/content-ops/src/ops/link-rewriter.ts index 8908ceb7..64687929 100644 --- a/packages/content-ops/src/ops/link-rewriter.ts +++ b/packages/content-ops/src/ops/link-rewriter.ts @@ -1,7 +1,7 @@ import { posix } from 'path' import { logger } from '../observability' import { FileSystemService } from '../file-system' -import { LinkProcessor } from '../markdown/link-processor' +import { extractLinks } from '../markdown/link-processor' import { isExternalLink } from '../file-system/file-system-utils' import type { ParsedLink } from '../markdown/markdown-parser' @@ -134,7 +134,7 @@ export async function rewriteLinksInFile(params: RewriteLinksInFileParams): Prom const { fileService, filePath, originalDir, newDir, datasetRoot, sourceContentRoot } = params const content = await fileService.readFile(filePath) - const links = await LinkProcessor.extractLinks(content) + const links = await extractLinks(content) if (links.length === 0) return From 64aa83d1a12d9a8defe58905a020295b3fc51fdd Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:14:06 +0200 Subject: [PATCH 05/21] [#199] refactor: split in-memory-fs into state + read/write/seed - in-memory-fs-state.ts: shared mutable state + path primitives - in-memory-fs-seed.ts: constructor seeding - in-memory-fs-read.ts / in-memory-fs-write.ts: operation groups - in-memory-fs.ts: thin InMemoryFileSystemService delegating to the above - Public API unchanged; test's private resolvePath access repointed to state - Task: T9 (P2.3) Refs: #199 --- .../src/test-utils/in-memory-fs-read.ts | 69 +++ .../src/test-utils/in-memory-fs-seed.ts | 31 ++ .../src/test-utils/in-memory-fs-state.ts | 59 +++ .../src/test-utils/in-memory-fs-write.ts | 253 +++++++++++ .../src/test-utils/in-memory-fs.test.ts | 16 +- .../src/test-utils/in-memory-fs.ts | 415 +++--------------- 6 files changed, 479 insertions(+), 364 deletions(-) create mode 100644 packages/content-ops/src/test-utils/in-memory-fs-read.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs-seed.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs-state.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs-write.ts diff --git a/packages/content-ops/src/test-utils/in-memory-fs-read.ts b/packages/content-ops/src/test-utils/in-memory-fs-read.ts new file mode 100644 index 00000000..fc4a3589 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs-read.ts @@ -0,0 +1,69 @@ +import type { Dirent, Stats } from 'fs' +import { dirname } from 'path' +import { InMemoryFsState } from './in-memory-fs-state' + +export function readFileSync(state: InMemoryFsState, path: string): string { + const resolvedPath = state.resolvePath(path) + const file = state.files.get(resolvedPath) + if (!file) throw new Error(`File not found: ${path}`) + return file +} + +export function existsSync(state: InMemoryFsState, path: string): boolean { + const resolvedPath = state.resolvePath(path) + return state.files.has(resolvedPath) || state.dirs.has(resolvedPath) +} + +export async function stat(state: InMemoryFsState, path: string): Promise { + const resolvedPath = state.resolvePath(path) + if (state.dirs.has(resolvedPath)) { + return { isDirectory: () => true, isFile: () => false } as Stats + } + if (state.files.has(resolvedPath)) { + return { isDirectory: () => false, isFile: () => true } as Stats + } + throw new Error(`no such file or directory '${path}'`) +} + +export async function readdir(state: InMemoryFsState, path: string): Promise { + const resolvedPath = state.resolvePath(path) + if (!state.dirs.has(resolvedPath)) { + throw new Error(`no such file or directory '${path}'`) + } + + const entries: Dirent[] = [] + for (const d of state.dirs) { + if (d === resolvedPath) continue + if (dirname(d) === resolvedPath) { + const name = d.replace(`${resolvedPath}/`, '') + entries.push(state.makeDirent(name, true)) + } + } + + for (const filePath of state.files.keys()) { + if (dirname(filePath) === resolvedPath) { + const name = filePath.replace(`${resolvedPath}/`, '') + entries.push(state.makeDirent(name, false)) + } + } + + return entries +} + +export function getContent(state: InMemoryFsState, path: string): string | undefined { + const resolvedPath = state.resolvePath(path) + return state.files.get(resolvedPath) +} + +export async function isFile(state: InMemoryFsState, path: string): Promise { + return stat(state, path).then(stats => stats.isFile()) +} + +export async function isFolder(state: InMemoryFsState, path: string): Promise { + try { + const isFileResult = await isFile(state, path) + return !isFileResult + } catch { + return false + } +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs-seed.ts b/packages/content-ops/src/test-utils/in-memory-fs-seed.ts new file mode 100644 index 00000000..68f289c4 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs-seed.ts @@ -0,0 +1,31 @@ +import { InMemoryFsState } from './in-memory-fs-state' + +/** + * Seeds a fresh state from the constructor arguments: registers the root and + * ancestor directories of the module/working dirs, loads the initial files, + * and guarantees the module/working dirs themselves exist. + */ +export function seedState( + state: InMemoryFsState, + initial: Record, + moduleDirectory: string, + workingDirectory: string, +): void { + // Set directories first so resolvePath works + state.dirs.add('/') + state.addParentDirectories(moduleDirectory) + state.addParentDirectories(workingDirectory) + addInitialFiles(state, initial) + + // Ensure moduleDirectory and workingDirectory exist + state.dirs.add(moduleDirectory) + state.dirs.add(workingDirectory) +} + +function addInitialFiles(state: InMemoryFsState, initial: Record): void { + for (const [path, content] of Object.entries(initial)) { + const resolvedPath = state.resolvePath(path) + state.files.set(resolvedPath, content) + state.addParentDirectories(resolvedPath) + } +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs-state.ts b/packages/content-ops/src/test-utils/in-memory-fs-state.ts new file mode 100644 index 00000000..947fc17a --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs-state.ts @@ -0,0 +1,59 @@ +import type { Dirent } from 'fs' +import { dirname, resolve, isAbsolute } from 'path' + +/** + * Mutable in-memory filesystem state shared by the read/write/seed operation + * modules. Holds the file/dir/symlink maps plus the low-level path primitives + * every operation needs. Extracted from InMemoryFileSystemService so the + * behavior can be split across focused modules without duplicating state. + */ +export class InMemoryFsState { + readonly files = new Map() + readonly dirs = new Set() + readonly symlinks = new Map() + moduleDirectory: string + workingDirectory: string + + constructor(moduleDirectory: string, workingDirectory: string) { + this.moduleDirectory = moduleDirectory + this.workingDirectory = workingDirectory + } + + resolvePath(path: string): string { + return isAbsolute(path) ? path : resolve(this.workingDirectory, path) + } + + addParentDirectories(path: string): void { + let p = dirname(path) + while (p && p !== dirname(p)) { + this.dirs.add(p) + const next = dirname(p) + if (next === p) break + p = next + } + } + + // Resolve paths relative to the in-memory working directory. This mirrors + // path.resolve semantics but anchored to the service's workingDirectory so + // tests can control how relative paths are interpreted. + resolve(...paths: string[]): string { + const firstPath = paths[0] + if (firstPath && isAbsolute(firstPath)) { + return resolve(...paths) + } + return resolve(this.workingDirectory, ...paths) + } + + makeDirent(name: string, isDir: boolean): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + } as Dirent + } +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs-write.ts b/packages/content-ops/src/test-utils/in-memory-fs-write.ts new file mode 100644 index 00000000..dda8ab2a --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs-write.ts @@ -0,0 +1,253 @@ +import { dirname, resolve, isAbsolute } from 'path' +import { InMemoryFsState } from './in-memory-fs-state' + +export async function writeFile( + state: InMemoryFsState, + path: string, + content: string, +): Promise { + const resolvedPath = state.resolvePath(path) + state.files.set(resolvedPath, content) + // Create all parent directories recursively + let p = dirname(resolvedPath) + while (p && p !== dirname(p)) { + state.dirs.add(p) + const next = dirname(p) + if (next === p) break + p = next + } +} + +export async function writeFileBinary( + state: InMemoryFsState, + path: string, + content: Buffer, +): Promise { + // Store binary data using latin1 encoding to preserve byte values + await writeFile(state, path, content.toString('latin1')) +} + +export async function unlink(state: InMemoryFsState, path: string): Promise { + const resolvedPath = state.resolvePath(path) + if (!state.files.has(resolvedPath)) { + throw new Error(`File not found: ${path}`) + } + state.files.delete(resolvedPath) +} + +export function mkdirImpl( + state: InMemoryFsState, + path: string, + options?: { recursive?: boolean }, +): void { + const resolvedPath = state.resolvePath(path) + state.dirs.add(resolvedPath) + if (options?.recursive) { + let p = resolvedPath + while (p && p !== dirname(p)) { + state.dirs.add(p) + const next = dirname(p) + if (next === p) break + p = next + } + } +} + +export async function rename( + state: InMemoryFsState, + oldPath: string, + newPath: string, +): Promise { + const resolvedOldPath = state.resolvePath(oldPath) + const resolvedNewPath = state.resolvePath(newPath) + + // Check if source exists (either as file or directory) + const sourceExists = state.files.has(resolvedOldPath) || state.dirs.has(resolvedOldPath) + if (!sourceExists) { + throw new Error(`Path not found: ${oldPath}`) + } + + if (state.files.has(resolvedOldPath)) { + const content = state.files.get(resolvedOldPath)! + state.files.set(resolvedNewPath, content) + state.files.delete(resolvedOldPath) + state.dirs.add(dirname(resolvedNewPath)) + return + } + + const oldPrefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' + const newPrefix = resolvedNewPath.endsWith('/') ? resolvedNewPath : resolvedNewPath + '/' + + const toMove: Array<[string, string]> = [] + for (const key of Array.from(state.files.keys())) { + if (key === resolvedOldPath || key.startsWith(oldPrefix)) { + const rel = key === resolvedOldPath ? '' : key.slice(oldPrefix.length) + toMove.push([key, newPrefix + rel]) + } + } + toMove.forEach(([k, v]) => { + const val = state.files.get(k)! + state.files.set(v, val) + state.files.delete(k) + state.dirs.add(dirname(v)) + }) + + const dirToMove = Array.from(state.dirs).filter( + d => d === resolvedOldPath || d.startsWith(oldPrefix), + ) + dirToMove.forEach(d => { + const rel = d === resolvedOldPath ? '' : d.slice(oldPrefix.length) + state.dirs.add(newPrefix + rel) + state.dirs.delete(d) + }) +} + +export function copyImpl(state: InMemoryFsState, oldPath: string, newPath: string): void { + const resolvedOldPath = state.resolvePath(oldPath) + const resolvedNewPath = state.resolvePath(newPath) + if (state.dirs.has(resolvedOldPath)) { + // copy directory recursively + state.dirs.add(resolvedNewPath) + const prefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' + for (const key of state.files.keys()) { + if (key.startsWith(prefix)) { + const relative = key.slice(prefix.length) + const newKey = state.resolve(resolvedNewPath, relative) + state.files.set(newKey, state.files.get(key)!) + state.addParentDirectories(newKey) + } + } + } else if (state.files.has(resolvedOldPath)) { + const content = state.files.get(resolvedOldPath)! + state.files.set(resolvedNewPath, content) + state.addParentDirectories(resolvedNewPath) + } else { + throw new Error(`Path not found: ${oldPath}`) + } +} + +export async function rm( + state: InMemoryFsState, + path: string, + options?: { recursive?: boolean; force?: boolean }, +): Promise { + const resolvedPath = state.resolvePath(path) + const prefix = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/' + + const deleteRecursive = () => { + for (const key of Array.from(state.files.keys())) { + if (key === resolvedPath || key.startsWith(prefix)) state.files.delete(key) + } + for (const d of Array.from(state.dirs)) { + if (d === resolvedPath || d.startsWith(prefix)) state.dirs.delete(d) + } + state.dirs.delete(resolvedPath) + state.files.delete(resolvedPath) + } + + const deleteNonRecursive = () => { + if (state.files.has(resolvedPath)) { + state.files.delete(resolvedPath) + return + } + if (state.dirs.has(resolvedPath)) { + const hasChildren = + Array.from(state.files.keys()).some(k => dirname(k) === resolvedPath) || + Array.from(state.dirs).some(d => dirname(d) === resolvedPath && d !== resolvedPath) + if (hasChildren) { + throw new Error(`Directory not empty: ${path}`) + } + state.dirs.delete(resolvedPath) + return + } + if (!options?.force) throw new Error(`Path not found: ${path}`) + } + + if (options?.recursive) { + deleteRecursive() + return + } + + deleteNonRecursive() +} + +export async function symlink(state: InMemoryFsState, target: string, path: string): Promise { + const resolvedPath = state.resolvePath(path) + // Resolve relative targets from the symlink's parent directory (matching OS behavior) + const resolvedTarget = isAbsolute(target) + ? state.resolvePath(target) + : resolve(dirname(resolvedPath), target) + if (state.symlinks.has(resolvedPath) || state.files.has(resolvedPath)) { + throw new Error(`Path already exists: ${path}`) + } + state.symlinks.set(resolvedPath, resolvedTarget) + state.addParentDirectories(resolvedPath) +} + +export function chdir(state: InMemoryFsState, path: string): void { + state.workingDirectory = path + // Ensure parent directories exist in the in-memory view + state.addParentDirectories(path) + state.dirs.add(path) +} + +export async function createZip( + state: InMemoryFsState, + sourcePaths: string[], + outputPath: string, +): Promise { + const resolvedOutputPath = state.resolvePath(outputPath) + const zipContent: Record = {} + + for (const sourcePath of sourcePaths) { + const resolvedSourcePath = state.resolvePath(sourcePath) + + // Check if source is file or directory + if (state.files.has(resolvedSourcePath)) { + // Single file - add to zip root + const fileName = resolvedSourcePath.split('/').pop() || 'file' + zipContent[fileName] = state.files.get(resolvedSourcePath)! + } else if (state.dirs.has(resolvedSourcePath)) { + // Directory - add all files recursively + for (const [filePath, content] of state.files.entries()) { + if (filePath.startsWith(resolvedSourcePath + '/')) { + // Relative path within zip + const relativePath = filePath.substring(resolvedSourcePath.length + 1) + zipContent[relativePath] = content + } + } + } + } + + // Serialize zip content as JSON (simple in-memory representation) + const zipData = JSON.stringify(zipContent) + state.files.set(resolvedOutputPath, zipData) + state.addParentDirectories(resolvedOutputPath) +} + +export async function extractZip( + state: InMemoryFsState, + zipPath: string, + outputDir: string, +): Promise { + const resolvedZipPath = state.resolvePath(zipPath) + const resolvedOutputDir = state.resolvePath(outputDir) + + const zipData = state.files.get(resolvedZipPath) + if (!zipData) { + throw new Error(`ZIP file not found: ${zipPath}`) + } + + // Deserialize zip content + const zipContent = JSON.parse(zipData) as Record + + // Extract all files + for (const [relativePath, content] of Object.entries(zipContent)) { + const outputPath = resolve(resolvedOutputDir, relativePath) + state.files.set(outputPath, content) + state.addParentDirectories(outputPath) + } + + // Ensure output directory exists + state.dirs.add(resolvedOutputDir) +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs.test.ts b/packages/content-ops/src/test-utils/in-memory-fs.test.ts index f97094aa..42552e4b 100644 --- a/packages/content-ops/src/test-utils/in-memory-fs.test.ts +++ b/packages/content-ops/src/test-utils/in-memory-fs.test.ts @@ -63,13 +63,13 @@ describe('InMemoryFileSystemService - Path Resolution', () => { describe('resolvePath', () => { it('should return absolute paths unchanged', () => { - expect(fs['resolvePath']('/absolute/path')).toBe('/absolute/path') + expect(fs['state'].resolvePath('/absolute/path')).toBe('/absolute/path') }) it('should resolve relative paths against working directory', () => { - expect(fs['resolvePath']('relative/path')).toBe('/app/relative/path') - expect(fs['resolvePath']('./relative/path')).toBe('/app/relative/path') - expect(fs['resolvePath']('../parent/path')).toBe('/parent/path') + expect(fs['state'].resolvePath('relative/path')).toBe('/app/relative/path') + expect(fs['state'].resolvePath('./relative/path')).toBe('/app/relative/path') + expect(fs['state'].resolvePath('../parent/path')).toBe('/parent/path') }) }) }) @@ -461,12 +461,12 @@ describe('InMemoryFileSystemService - Complex Scenarios - Project Structure', () describe('InMemoryFileSystemService - Complex Scenarios - Path Resolution', () => { it('should handle path resolution with custom working directory', () => { const customFs = new InMemoryFileSystemService({}, '/custom/module', '/custom/work') - expect(customFs['resolvePath']('file.txt')).toBe('/custom/work/file.txt') - expect(customFs['resolvePath']('../file.txt')).toBe('/custom/file.txt') - expect(customFs['resolvePath']('dir/../file.txt')).toBe('/custom/work/file.txt') + expect(customFs['state'].resolvePath('file.txt')).toBe('/custom/work/file.txt') + expect(customFs['state'].resolvePath('../file.txt')).toBe('/custom/file.txt') + expect(customFs['state'].resolvePath('dir/../file.txt')).toBe('/custom/work/file.txt') // Test absolute paths - expect(customFs['resolvePath']('/absolute/file.txt')).toBe('/absolute/file.txt') + expect(customFs['state'].resolvePath('/absolute/file.txt')).toBe('/absolute/file.txt') }) }) diff --git a/packages/content-ops/src/test-utils/in-memory-fs.ts b/packages/content-ops/src/test-utils/in-memory-fs.ts index d9831809..138f59ef 100644 --- a/packages/content-ops/src/test-utils/in-memory-fs.ts +++ b/packages/content-ops/src/test-utils/in-memory-fs.ts @@ -1,428 +1,131 @@ import type { Dirent, Stats } from 'fs' -import { dirname, resolve, isAbsolute } from 'path' import type { FileSystemService } from '../file-system' - +import { InMemoryFsState } from './in-memory-fs-state' +import { seedState } from './in-memory-fs-seed' +import * as read from './in-memory-fs-read' +import * as write from './in-memory-fs-write' + +/** + * In-memory FileSystemService test double. State lives in InMemoryFsState; the + * behavior is implemented in the focused in-memory-fs-{read,write,seed} modules + * and delegated to here so this class stays a thin, stable public surface. + */ export class InMemoryFileSystemService implements FileSystemService { - private files = new Map() - private dirs = new Set() - private moduleDirectory: string - private workingDirectory: string + private readonly state: InMemoryFsState constructor( initial: Record = {}, moduleDirectory: string, workingDirectory: string, ) { - // Set directories first so resolvePath works - this.moduleDirectory = moduleDirectory - this.workingDirectory = workingDirectory + this.state = new InMemoryFsState(moduleDirectory, workingDirectory) + seedState(this.state, initial, moduleDirectory, workingDirectory) + } - this.dirs.add('/') - this.addParentDirectories(moduleDirectory) - this.addParentDirectories(workingDirectory) - this.addInitialFiles(initial) + accessSync() {} - // Ensure moduleDirectory and workingDirectory exist - this.dirs.add(moduleDirectory) - this.dirs.add(workingDirectory) + async readFile(path: string): Promise { + return read.readFileSync(this.state, path) } - private addParentDirectories(path: string): void { - let p = dirname(path) - while (p && p !== dirname(p)) { - this.dirs.add(p) - const next = dirname(p) - if (next === p) break - p = next - } + readFileSync(path: string): string { + return read.readFileSync(this.state, path) } - private addInitialFiles(initial: Record): void { - for (const [path, content] of Object.entries(initial)) { - const resolvedPath = this.resolvePath(path) - this.files.set(resolvedPath, content) - this.addParentDirectories(resolvedPath) - } + existsSync(path: string): boolean { + return read.existsSync(this.state, path) } - private resolvePath(path: string): string { - return isAbsolute(path) ? path : resolve(this.workingDirectory, path) + async exists(path: string): Promise { + return read.existsSync(this.state, path) } - accessSync() {} + async stat(path: string): Promise { + return read.stat(this.state, path) + } - async readFile(path: string): Promise { - return this.readFileSync(path) + async readdir(path: string): Promise { + return read.readdir(this.state, path) } - readFileSync(path: string): string { - const resolvedPath = this.resolvePath(path) - const file = this.files.get(resolvedPath) - if (!file) throw new Error(`File not found: ${path}`) - return file + getContent(path: string): string | undefined { + return read.getContent(this.state, path) } - existsSync(path: string): boolean { - const resolvedPath = this.resolvePath(path) - return this.files.has(resolvedPath) || this.dirs.has(resolvedPath) + async isFile(path: string): Promise { + return read.isFile(this.state, path) + } + + async isFolder(path: string): Promise { + return read.isFolder(this.state, path) } async writeFile(path: string, content: string): Promise { - const resolvedPath = this.resolvePath(path) - this.files.set(resolvedPath, content) - // Create all parent directories recursively - let p = dirname(resolvedPath) - while (p && p !== dirname(p)) { - this.dirs.add(p) - const next = dirname(p) - if (next === p) break - p = next - } + return write.writeFile(this.state, path, content) } async writeFileBinary(path: string, content: Buffer): Promise { - // Store binary data using latin1 encoding to preserve byte values - await this.writeFile(path, content.toString('latin1')) + return write.writeFileBinary(this.state, path, content) } async unlink(path: string): Promise { - const resolvedPath = this.resolvePath(path) - if (!this.files.has(resolvedPath)) { - throw new Error(`File not found: ${path}`) - } - this.files.delete(resolvedPath) - } - - async exists(path: string): Promise { - return this.existsSync(path) + return write.unlink(this.state, path) } async mkdir(path: string, options?: { recursive?: boolean }): Promise { - const resolvedPath = this.resolvePath(path) - this.dirs.add(resolvedPath) - if (options?.recursive) { - let p = resolvedPath - while (p && p !== dirname(p)) { - this.dirs.add(p) - const next = dirname(p) - if (next === p) break - p = next - } - } + write.mkdirImpl(this.state, path, options) } mkdirSync(path: string, options?: { recursive?: boolean }): void { - const resolvedPath = this.resolvePath(path) - this.dirs.add(resolvedPath) - if (options?.recursive) { - let p = resolvedPath - while (p && p !== dirname(p)) { - this.dirs.add(p) - const next = dirname(p) - if (next === p) break - p = next - } - } + write.mkdirImpl(this.state, path, options) } async rename(oldPath: string, newPath: string): Promise { - const resolvedOldPath = this.resolvePath(oldPath) - const resolvedNewPath = this.resolvePath(newPath) - - // Check if source exists (either as file or directory) - const sourceExists = this.files.has(resolvedOldPath) || this.dirs.has(resolvedOldPath) - if (!sourceExists) { - throw new Error(`Path not found: ${oldPath}`) - } - - if (this.files.has(resolvedOldPath)) { - const content = this.files.get(resolvedOldPath)! - this.files.set(resolvedNewPath, content) - this.files.delete(resolvedOldPath) - this.dirs.add(dirname(resolvedNewPath)) - return - } - - const oldPrefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' - const newPrefix = resolvedNewPath.endsWith('/') ? resolvedNewPath : resolvedNewPath + '/' - - const toMove: Array<[string, string]> = [] - for (const key of Array.from(this.files.keys())) { - if (key === resolvedOldPath || key.startsWith(oldPrefix)) { - const rel = key === resolvedOldPath ? '' : key.slice(oldPrefix.length) - toMove.push([key, newPrefix + rel]) - } - } - toMove.forEach(([k, v]) => { - const val = this.files.get(k)! - this.files.set(v, val) - this.files.delete(k) - this.dirs.add(dirname(v)) - }) - - const dirToMove = Array.from(this.dirs).filter( - d => d === resolvedOldPath || d.startsWith(oldPrefix), - ) - dirToMove.forEach(d => { - const rel = d === resolvedOldPath ? '' : d.slice(oldPrefix.length) - this.dirs.add(newPrefix + rel) - this.dirs.delete(d) - }) + return write.rename(this.state, oldPath, newPath) } async copy(oldPath: string, newPath: string): Promise { - const resolvedOldPath = this.resolvePath(oldPath) - const resolvedNewPath = this.resolvePath(newPath) - if (this.dirs.has(resolvedOldPath)) { - // copy directory recursively - this.dirs.add(resolvedNewPath) - const prefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' - for (const key of this.files.keys()) { - if (key.startsWith(prefix)) { - const relative = key.slice(prefix.length) - const newKey = this.resolve(resolvedNewPath, relative) - this.files.set(newKey, this.files.get(key)!) - this.addParentDirectories(newKey) - } - } - } else if (this.files.has(resolvedOldPath)) { - const content = this.files.get(resolvedOldPath)! - this.files.set(resolvedNewPath, content) - this.addParentDirectories(resolvedNewPath) - } else { - throw new Error(`Path not found: ${oldPath}`) - } + write.copyImpl(this.state, oldPath, newPath) } copySync(oldPath: string, newPath: string): void { - // For simplicity, make it sync by not awaiting - const resolvedOldPath = this.resolvePath(oldPath) - const resolvedNewPath = this.resolvePath(newPath) - if (this.dirs.has(resolvedOldPath)) { - // copy directory recursively - this.dirs.add(resolvedNewPath) - const prefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' - for (const key of this.files.keys()) { - if (key.startsWith(prefix)) { - const relative = key.slice(prefix.length) - const newKey = this.resolve(resolvedNewPath, relative) - this.files.set(newKey, this.files.get(key)!) - this.addParentDirectories(newKey) - } - } - } else if (this.files.has(resolvedOldPath)) { - const content = this.files.get(resolvedOldPath)! - this.files.set(resolvedNewPath, content) - this.addParentDirectories(resolvedNewPath) - } else { - throw new Error(`Path not found: ${oldPath}`) - } + write.copyImpl(this.state, oldPath, newPath) } async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { - const resolvedPath = this.resolvePath(path) - const prefix = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/' - - const deleteRecursive = () => { - for (const key of Array.from(this.files.keys())) { - if (key === resolvedPath || key.startsWith(prefix)) this.files.delete(key) - } - for (const d of Array.from(this.dirs)) { - if (d === resolvedPath || d.startsWith(prefix)) this.dirs.delete(d) - } - this.dirs.delete(resolvedPath) - this.files.delete(resolvedPath) - } - - const deleteNonRecursive = () => { - if (this.files.has(resolvedPath)) { - this.files.delete(resolvedPath) - return - } - if (this.dirs.has(resolvedPath)) { - const hasChildren = - Array.from(this.files.keys()).some(k => dirname(k) === resolvedPath) || - Array.from(this.dirs).some(d => dirname(d) === resolvedPath && d !== resolvedPath) - if (hasChildren) { - throw new Error(`Directory not empty: ${path}`) - } - this.dirs.delete(resolvedPath) - return - } - if (!options?.force) throw new Error(`Path not found: ${path}`) - } - - if (options?.recursive) { - deleteRecursive() - return - } - - deleteNonRecursive() + return write.rm(this.state, path, options) } - async stat(path: string): Promise { - const resolvedPath = this.resolvePath(path) - if (this.dirs.has(resolvedPath)) { - return { isDirectory: () => true, isFile: () => false } as Stats - } - if (this.files.has(resolvedPath)) { - return { isDirectory: () => false, isFile: () => true } as Stats - } - throw new Error(`no such file or directory '${path}'`) - } - - async readdir(path: string): Promise { - const resolvedPath = this.resolvePath(path) - if (!this.dirs.has(resolvedPath)) { - throw new Error(`no such file or directory '${path}'`) - } - - const entries: Dirent[] = [] - for (const d of this.dirs) { - if (d === resolvedPath) continue - if (dirname(d) === resolvedPath) { - const name = d.replace(`${resolvedPath}/`, '') - entries.push(this.makeDirent(name, true)) - } - } - - for (const filePath of this.files.keys()) { - if (dirname(filePath) === resolvedPath) { - const name = filePath.replace(`${resolvedPath}/`, '') - entries.push(this.makeDirent(name, false)) - } - } - - return entries - } - - rootModuleDirectory() { - return this.moduleDirectory - } - currentWorkingDirectory() { - return this.workingDirectory - } - - // Resolve paths relative to the in-memory working directory. This mirrors - // path.resolve semantics but anchored to the service's workingDirectory so - // tests can control how relative paths are interpreted. - resolve(...paths: string[]): string { - const firstPath = paths[0] - if (firstPath && isAbsolute(firstPath)) { - return resolve(...paths) - } - return resolve(this.workingDirectory, ...paths) + async symlink(target: string, path: string): Promise { + return write.symlink(this.state, target, path) } - // Change the working directory used by the in-memory service. Tests can - // call this instead of using process.chdir so filesystem-relative - // operations remain scoped to the service. - chdir(path: string) { - this.workingDirectory = path - // Ensure parent directories exist in the in-memory view - this.addParentDirectories(path) - this.dirs.add(path) + getSymlinks(): Map { + return new Map(this.state.symlinks) } - private makeDirent(name: string, isDir: boolean): Dirent { - return { - name, - isDirectory: () => isDir, - isFile: () => !isDir, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - isSymbolicLink: () => false, - } as Dirent + rootModuleDirectory(): string { + return this.state.moduleDirectory } - private symlinks = new Map() - - async symlink(target: string, path: string): Promise { - const resolvedPath = this.resolvePath(path) - // Resolve relative targets from the symlink's parent directory (matching OS behavior) - const resolvedTarget = isAbsolute(target) - ? this.resolvePath(target) - : resolve(dirname(resolvedPath), target) - if (this.symlinks.has(resolvedPath) || this.files.has(resolvedPath)) { - throw new Error(`Path already exists: ${path}`) - } - this.symlinks.set(resolvedPath, resolvedTarget) - this.addParentDirectories(resolvedPath) + currentWorkingDirectory(): string { + return this.state.workingDirectory } - getSymlinks(): Map { - return new Map(this.symlinks) + resolve(...paths: string[]): string { + return this.state.resolve(...paths) } - getContent(path: string) { - const resolvedPath = this.resolvePath(path) - return this.files.get(resolvedPath) - } - async isFile(path: string) { - return this.stat(path).then(stats => stats.isFile()) - } - async isFolder(path: string) { - try { - const isFileResult = await this.isFile(path) - return !isFileResult - } catch { - return false - } + chdir(path: string): void { + write.chdir(this.state, path) } async createZip(sourcePaths: string[], outputPath: string): Promise { - const resolvedOutputPath = this.resolvePath(outputPath) - const zipContent: Record = {} - - for (const sourcePath of sourcePaths) { - const resolvedSourcePath = this.resolvePath(sourcePath) - - // Check if source is file or directory - if (this.files.has(resolvedSourcePath)) { - // Single file - add to zip root - const fileName = resolvedSourcePath.split('/').pop() || 'file' - zipContent[fileName] = this.files.get(resolvedSourcePath)! - } else if (this.dirs.has(resolvedSourcePath)) { - // Directory - add all files recursively - for (const [filePath, content] of this.files.entries()) { - if (filePath.startsWith(resolvedSourcePath + '/')) { - // Relative path within zip - const relativePath = filePath.substring(resolvedSourcePath.length + 1) - zipContent[relativePath] = content - } - } - } - } - - // Serialize zip content as JSON (simple in-memory representation) - const zipData = JSON.stringify(zipContent) - this.files.set(resolvedOutputPath, zipData) - this.addParentDirectories(resolvedOutputPath) + return write.createZip(this.state, sourcePaths, outputPath) } async extractZip(zipPath: string, outputDir: string): Promise { - const resolvedZipPath = this.resolvePath(zipPath) - const resolvedOutputDir = this.resolvePath(outputDir) - - const zipData = this.files.get(resolvedZipPath) - if (!zipData) { - throw new Error(`ZIP file not found: ${zipPath}`) - } - - // Deserialize zip content - const zipContent = JSON.parse(zipData) as Record - - // Extract all files - for (const [relativePath, content] of Object.entries(zipContent)) { - const outputPath = resolve(resolvedOutputDir, relativePath) - this.files.set(outputPath, content) - this.addParentDirectories(outputPath) - } - - // Ensure output directory exists - this.dirs.add(resolvedOutputDir) + return write.extractZip(this.state, zipPath, outputDir) } } From 050dfd3109215aa47cd2c12fd7764a4bf2e3d295 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:19:36 +0200 Subject: [PATCH 06/21] [#199] test: coverage thresholds for website + brand + baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - brand: 80% global thresholds (measures 84.5% with tested dev harness) - website: realistic baseline floors (9/9/40/60), not 80% — Playwright-tested Next.js app; documented via ADL 2026-07-12-website-coverage-baseline - both: coverage block extends coverageConfigDefaults.exclude + json-summary - reports/coverage-baseline.json: committed baseline for trend tracking (P1.4) - Task: T5 (P1.2 + P1.4) Refs: #199 --- .../2026-07-12-website-coverage-baseline.md | 66 +++++++++++++++++++ apps/website/vitest.config.ts | 26 +++++++- packages/brand/vitest.config.ts | 18 ++++- reports/coverage-baseline.json | 19 ++++++ 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 .pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md create mode 100644 reports/coverage-baseline.json diff --git a/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md b/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md new file mode 100644 index 00000000..355b3371 --- /dev/null +++ b/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md @@ -0,0 +1,66 @@ +# Decision: Website coverage threshold is a realistic baseline, not 80% + +## Date + +2026-07-12 + +## Status + +Active + +## Category + +Process Decision + +## Context + +Story #199 (tech-debt ledger), finding P1.2, asked to add 80% coverage +thresholds to `apps/website` and `packages/brand` vitest configs to match the +`@pair/content-ops` bar. + +`packages/brand` measures 84.5% lines with the dev harness included (its +`dev/App.tsx` is unit-tested at 100%), so it takes the 80% gate cleanly. + +`apps/website` measures ~9.9% lines. It is a Next.js documentation app whose +behavior is validated primarily by Playwright component (`ct`) and end-to-end +(`e2e`) suites, not by jsdom unit tests. Forcing an 80% unit-coverage gate would +either break `pnpm --filter @pair/website test:coverage` (and CI once wired) or +push low-value unit tests onto UI already covered by e2e. + +Note: the quality-gate command runs `test`, not `test:coverage`, so these +thresholds bite only on explicit coverage runs / trend tracking — they do not +gate normal PRs. + +## Decision + +- `packages/brand`: 80% global thresholds (lines/statements/functions/branches). +- `apps/website`: realistic baseline floors instead of 80% — + lines 9, statements 9, functions 40, branches 60 — set just below current + measured coverage (9.9 / 9.9 / 47.2 / 66.2). These catch regressions without + mandating unit tests for e2e-covered UI. +- A committed baseline (`reports/coverage-baseline.json`, P1.4) records current + totals for all three packages for trend tracking. Raise the website floors + toward the 80% target as unit coverage of testable (non-page) modules grows. + +## Alternatives Considered + +- **Force 80% on website now**: rejected — breaks the coverage command and + incentivizes low-value unit tests over the existing Playwright coverage. +- **No threshold on website**: rejected — loses regression protection; a floor + at current level is cheap and meaningful. +- **Count only shipped code on brand (exclude dev/)**: rejected — brand's + dev harness is genuinely unit-tested (`dev/App.test.tsx`), so including it + reflects real coverage; excluding it understates brand at 76.8%. + +## Consequences + +- `apps/website/vitest.config.ts` and `packages/brand/vitest.config.ts` gain a + `coverage` block extending `coverageConfigDefaults.exclude`, emitting a + `json-summary` reporter, with the thresholds above. +- `reports/coverage-baseline.json` is the committed trend baseline. +- Website's floor is a debt marker, not the target; revisit when raising it. + +## Adoption Impact + +- No change to `adoption/tech/tech-stack.md` (vitest + @vitest/coverage-v8 + already adopted). This ADL is the record of the per-package threshold policy. diff --git a/apps/website/vitest.config.ts b/apps/website/vitest.config.ts index e036e64b..7dca875f 100644 --- a/apps/website/vitest.config.ts +++ b/apps/website/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, coverageConfigDefaults } from 'vitest/config' import react from '@vitejs/plugin-react' import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' @@ -17,5 +17,29 @@ export default defineConfig({ globals: true, setupFiles: ['./vitest.setup.ts'], exclude: ['**/*.ct.test.tsx', '**/*.e2e.test.ts', '**/node_modules/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary', 'html'], + exclude: [ + ...coverageConfigDefaults.exclude, + '.next/**', + '**/*.ct.test.tsx', + '**/*.e2e.test.ts', + 'playwright*.config.ts', + 'next.config.*', + 'source.config.ts', + 'vitest.setup.ts', + ], + // Realistic baseline, NOT 80%: the website is a Next.js app validated + // primarily by Playwright component + e2e suites, so unit coverage of + // pages/components is intentionally low. These floors catch regressions + // without forcing unit tests onto e2e-covered UI. See ADL 2026-07-12. + thresholds: { + lines: 9, + statements: 9, + functions: 40, + branches: 60, + }, + }, }, }) diff --git a/packages/brand/vitest.config.ts b/packages/brand/vitest.config.ts index 92b90b09..c5c6db9a 100644 --- a/packages/brand/vitest.config.ts +++ b/packages/brand/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, coverageConfigDefaults } from 'vitest/config' import react from '@vitejs/plugin-react' import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' @@ -18,5 +18,21 @@ export default defineConfig({ globals: true, setupFiles: ['./vitest.setup.ts'], exclude: ['**/*.ct.test.tsx', '**/node_modules/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary', 'html'], + exclude: [ + ...coverageConfigDefaults.exclude, + '**/*.ct.test.tsx', + 'playwright*.config.ts', + 'vitest.setup.ts', + ], + thresholds: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, }, }) diff --git a/reports/coverage-baseline.json b/reports/coverage-baseline.json new file mode 100644 index 00000000..abb61094 --- /dev/null +++ b/reports/coverage-baseline.json @@ -0,0 +1,19 @@ +{ + "description": "Committed coverage baseline for trend tracking (story #199, P1.4). Totals from `pnpm --filter test:coverage` on 2026-07-12. Refresh when coverage materially changes; raise thresholds toward target as coverage improves.", + "generatedAt": "2026-07-12", + "packages": { + "@pair/content-ops": { + "threshold": 80, + "total": { "lines": 91.27, "statements": 91.27, "functions": 85.31, "branches": 85.63 } + }, + "@pair/brand": { + "threshold": 80, + "total": { "lines": 84.53, "statements": 84.53, "functions": 85.45, "branches": 89.28 } + }, + "@pair/website": { + "threshold": { "lines": 9, "statements": 9, "functions": 40, "branches": 60 }, + "note": "Realistic baseline, not 80%: Next.js app validated primarily via Playwright ct + e2e; unit coverage of pages/components intentionally low. See ADL 2026-07-12-website-coverage-baseline.", + "total": { "lines": 9.89, "statements": 9.89, "functions": 47.22, "branches": 66.19 } + } + } +} From 0327ef6e9aa61eca8eae88296785e5a8466367c9 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:22:18 +0200 Subject: [PATCH 07/21] [#199] build: wire jscpd duplication scanner into quality-gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add jscpd v5 (root dev dep) + .jscpd.json (threshold 5%, ts/tsx, src-scoped) - `dup:check` script (jscpd apps packages) appended to quality-gate - current duplication ~1.4% lines / ~2.2% tokens (17 clones) — well under gate - note jscpd in tech-stack.md - Task: T10 (P2.4) Refs: #199 --- .jscpd.json | 18 ++++++ .pair/adoption/tech/tech-stack.md | 1 + package.json | 4 +- pnpm-lock.yaml | 99 +++++++++++++++++++++++++------ 4 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 .jscpd.json diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 00000000..62c39161 --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,18 @@ +{ + "threshold": 5, + "minTokens": 50, + "format": ["typescript", "tsx"], + "reporters": ["console"], + "gitignore": true, + "ignore": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.ct.test.tsx", + "**/dist/**", + "**/node_modules/**", + "**/coverage/**", + "**/*.d.ts", + "**/.next/**", + "**/*.config.ts" + ] +} diff --git a/.pair/adoption/tech/tech-stack.md b/.pair/adoption/tech/tech-stack.md index 87e7a732..49611e5c 100644 --- a/.pair/adoption/tech/tech-stack.md +++ b/.pair/adoption/tech/tech-stack.md @@ -88,6 +88,7 @@ Use `turbo` from the repository root to run cross-workspace tasks (e.g. `turbo b - globals v15.0.0 - prettier v3.6.2 (configured via workspace `tools/prettier-config`) - markdownlint-cli v0.47.0 (configured via workspace `tools/markdownlint-config`, wired into `pnpm quality-gate` via `mdlint:check` / `mdlint:fix`) + - jscpd v5.0.12 (copy/paste duplication scanner, configured via `.jscpd.json`, wired into `pnpm quality-gate` via `dup:check`; current threshold 5% — baseline ~1.4%) ## Git hooks diff --git a/package.json b/package.json index 9779da29..82aa30fa 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "a11y:report:html": "turbo a11y:report:html", "hygiene:check": "node scripts/code-hygiene-check.js", "docs:staleness": "node scripts/docs-staleness-check.js", - "quality-gate": "turbo ts:check test lint && turbo prettier:fix mdlint:fix && ./tools/markdownlint-config/bin/markdownlint-fix.sh '*.md' && pnpm hygiene:check && pnpm docs:staleness", + "dup:check": "jscpd apps packages", + "quality-gate": "turbo ts:check test lint && turbo prettier:fix mdlint:fix && ./tools/markdownlint-config/bin/markdownlint-fix.sh '*.md' && pnpm hygiene:check && pnpm docs:staleness && pnpm dup:check", "e2e": "pnpm --filter @pair/website e2e", "smoke-tests": "./scripts/smoke-tests/run-all.sh --cleanup" }, @@ -33,6 +34,7 @@ "@changesets/cli": "catalog:", "@pair/prettier-config": "workspace:*", "husky": "catalog:", + "jscpd": "^5.0.12", "turbo": "catalog:" }, "packageManager": "pnpm@10.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b85143c..f543fde9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: husky: specifier: 'catalog:' version: 8.0.0 + jscpd: + specifier: ^5.0.12 + version: 5.0.12 turbo: specifier: 'catalog:' version: 2.5.6 @@ -257,16 +260,16 @@ importers: version: 2.0.13 fumadocs-core: specifier: 'catalog:' - version: 14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) fumadocs-mdx: specifier: 'catalog:' - version: 11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)) + version: 11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)) fumadocs-ui: specifier: 'catalog:' - version: 14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))) + version: 14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))) next: specifier: 'catalog:' - version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2326,6 +2329,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} @@ -3531,6 +3535,41 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jscpd-darwin-arm64@5.0.12: + resolution: {integrity: sha512-CQCDYhQY9yOGGeauzkRGYW/QtXurPCjfDkEhUoM/M5UdmpzxfG3i9mnNsaheJ0RdFRf73mL7JaLVRP8U2l5/kA==} + cpu: [arm64] + os: [darwin] + + jscpd-darwin-x64@5.0.12: + resolution: {integrity: sha512-7XNCDfChP9fPkIWLepd0zGnTov7/5mR7rJ5Utc1MdWFdgkN//qz6FAJ1FhQA0E/qjFedy9XTucfUCgLybSBsjw==} + cpu: [x64] + os: [darwin] + + jscpd-linux-arm64-gnu@5.0.12: + resolution: {integrity: sha512-i/usHD0/8twzb2Qo4vFWcnuAD4IDLS6K/mTXwYy1CiJZDw4gmjfH45jtjdKUyoutTAiNs+ZWZgx1gIRljUbFuA==} + cpu: [arm64] + os: [linux] + + jscpd-linux-x64-gnu@5.0.12: + resolution: {integrity: sha512-TqaeSTaGawcqyXSrQvYJFtLRn4aQeHgphGGsq2ZtVAg+lPeuMmh81c3bl0yi2dL3gDX1RGBQKBVul1XQknOuoA==} + cpu: [x64] + os: [linux] + + jscpd-linux-x64-musl@5.0.12: + resolution: {integrity: sha512-WelugY1/rdoo0Y0sqJ2XQOIvyIZTfRe+vP3MJe4XvEUwPF0v+9SQoS34FnfblRhsddVixyNkgl7x/sHid9UMJw==} + cpu: [x64] + os: [linux] + + jscpd-windows-x64-msvc@5.0.12: + resolution: {integrity: sha512-01x1klKjvfzI6Of5tI9u72x7TIQZQLLG6kMdsEPJKl/BXbskdknrfCepeTXRBbdFPKs25jfcd0klZ4OdDvww5w==} + cpu: [x64] + os: [win32] + + jscpd@5.0.12: + resolution: {integrity: sha512-87dC+akj2mCywlt8p3xnRlDg0B55u4GDbfE9cuwOOeIxbEuWuIoNqGoYi65A0516aJ4EzAjGwBj1Qb/Ahc8WAQ==} + engines: {node: '>=18'} + hasBin: true + jsdom@25.0.1: resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} engines: {node: '>=18'} @@ -4706,6 +4745,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -7841,7 +7881,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@formatjs/intl-localematcher': 0.5.10 '@orama/orama': 2.1.1 @@ -7859,21 +7899,21 @@ snapshots: shiki: 2.5.0 unist-util-visit: 5.1.0 optionalDependencies: - next: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 react-dom: 19.0.0(react@19.0.0) transitivePeerDependencies: - '@types/react' - supports-color - fumadocs-mdx@11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)): + fumadocs-mdx@11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 4.0.3 esbuild: 0.25.12 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) js-yaml: 4.1.1 lru-cache: 11.2.6 picocolors: 1.1.1 @@ -7885,13 +7925,13 @@ snapshots: unist-util-visit: 5.1.0 zod: 4.3.6 optionalDependencies: - next: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 vite: 7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2) transitivePeerDependencies: - supports-color - fumadocs-ui@14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))): + fumadocs-ui@14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -7903,10 +7943,10 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.0.6)(react@19.0.0) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) class-variance-authority: 0.7.1 - fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) lodash.merge: 4.6.2 lucide-react: 0.473.0(react@19.0.0) - next: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-themes: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) postcss-selector-parser: 7.1.1 react: 19.0.0 @@ -8347,6 +8387,33 @@ snapshots: dependencies: argparse: 2.0.1 + jscpd-darwin-arm64@5.0.12: + optional: true + + jscpd-darwin-x64@5.0.12: + optional: true + + jscpd-linux-arm64-gnu@5.0.12: + optional: true + + jscpd-linux-x64-gnu@5.0.12: + optional: true + + jscpd-linux-x64-musl@5.0.12: + optional: true + + jscpd-windows-x64-msvc@5.0.12: + optional: true + + jscpd@5.0.12: + optionalDependencies: + jscpd-darwin-arm64: 5.0.12 + jscpd-darwin-x64: 5.0.12 + jscpd-linux-arm64-gnu: 5.0.12 + jscpd-linux-x64-gnu: 5.0.12 + jscpd-linux-x64-musl: 5.0.12 + jscpd-windows-x64-msvc: 5.0.12 + jsdom@25.0.1: dependencies: cssstyle: 4.6.0 @@ -9034,7 +9101,7 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.5.12 '@swc/helpers': 0.5.15 @@ -9042,7 +9109,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.0.0) + styled-jsx: 5.1.6(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.5.12 '@next/swc-darwin-x64': 15.5.12 @@ -9856,12 +9923,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.0.0): + styled-jsx@5.1.6(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 - optionalDependencies: - '@babel/core': 7.29.0 sucrase@3.35.1: dependencies: From 3b650be8e6a9d493feefe596cfb6657464cfd10a Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:25:06 +0200 Subject: [PATCH 08/21] [#199] refactor: extract brand dev App.tsx sections into dev/sections/* - 11 showcase sections + Header moved to dev/sections/*.tsx - shared helpers (Section/LogoVariant/ColorSwatch/TypoBlock/UtilityCard) in dev/sections/primitives.tsx; barrel dev/sections/index.ts - App.tsx now a slim shell (456 -> 51 LOC); dev/App.test.tsx unchanged, green - brand coverage 85% (still >= 80%) - Task: T8 (P2.2) Refs: #199 --- packages/brand/dev/App.tsx | 428 +----------------- packages/brand/dev/sections/ButtonSection.tsx | 23 + .../brand/dev/sections/CalloutSection.tsx | 20 + packages/brand/dev/sections/CardSection.tsx | 33 ++ packages/brand/dev/sections/ColorSection.tsx | 25 + packages/brand/dev/sections/Header.tsx | 17 + packages/brand/dev/sections/IconsSection.tsx | 67 +++ packages/brand/dev/sections/LogoSection.tsx | 23 + .../brand/dev/sections/ThemeToggleSection.tsx | 23 + .../brand/dev/sections/ToolLogosSection.tsx | 40 ++ .../brand/dev/sections/TypographySection.tsx | 36 ++ .../brand/dev/sections/UtilitySection.tsx | 49 ++ packages/brand/dev/sections/index.ts | 11 + packages/brand/dev/sections/primitives.tsx | 88 ++++ 14 files changed, 467 insertions(+), 416 deletions(-) create mode 100644 packages/brand/dev/sections/ButtonSection.tsx create mode 100644 packages/brand/dev/sections/CalloutSection.tsx create mode 100644 packages/brand/dev/sections/CardSection.tsx create mode 100644 packages/brand/dev/sections/ColorSection.tsx create mode 100644 packages/brand/dev/sections/Header.tsx create mode 100644 packages/brand/dev/sections/IconsSection.tsx create mode 100644 packages/brand/dev/sections/LogoSection.tsx create mode 100644 packages/brand/dev/sections/ThemeToggleSection.tsx create mode 100644 packages/brand/dev/sections/ToolLogosSection.tsx create mode 100644 packages/brand/dev/sections/TypographySection.tsx create mode 100644 packages/brand/dev/sections/UtilitySection.tsx create mode 100644 packages/brand/dev/sections/index.ts create mode 100644 packages/brand/dev/sections/primitives.tsx diff --git a/packages/brand/dev/App.tsx b/packages/brand/dev/App.tsx index b06dfe42..977cea97 100644 --- a/packages/brand/dev/App.tsx +++ b/packages/brand/dev/App.tsx @@ -1,25 +1,17 @@ import { useState, useEffect } from 'react' -import { PairLogo, Card, Button, Callout, ThemeToggle } from '$components' import { - GridPlusIcon, - ShieldCheckIcon, - FolderIcon, - UsersIcon, - TerminalIcon, - TeamIcon, - BuildingIcon, - RocketIcon, - MapIcon, - CodeIcon, - CheckCircleIcon, - BoltIcon, - BookIcon, - SlidersIcon, - LinkIcon, - GitHubIcon, -} from '$components' -import { AnthropicLogo, CursorLogo, CopilotLogo, WindsurfLogo, OpenAILogo } from '$components' -import { PAIR_BLUE, PAIR_TEAL, LIGHT_BG, LIGHT_TEXT_MAIN, DARK_BG, DARK_TEXT_MAIN } from '$tokens' + Header, + LogoSection, + IconsSection, + ToolLogosSection, + ThemeToggleSection, + ButtonSection, + CardSection, + CalloutSection, + ColorSection, + TypographySection, + UtilitySection, +} from './sections' function App() { const [isDark, setIsDark] = useState(false) @@ -57,400 +49,4 @@ function App() { ) } -function Header({ isDark, onToggle }: { isDark: boolean; onToggle: () => void }) { - return ( -
-

- @pair/brand Component Showcase -

-

- Brand identity component library -

- -
- ) -} - -function LogoSection() { - return ( -
- -
- - - -
-
-
- ) -} - -function LogoVariant({ - variant, - label, -}: { - variant: 'favicon' | 'navbar' | 'full' - label: string -}) { - return ( -
- -

{label}

-
- ) -} - -function IconsSection() { - const icons = [ - { name: 'GridPlusIcon', Icon: GridPlusIcon }, - { name: 'ShieldCheckIcon', Icon: ShieldCheckIcon }, - { name: 'FolderIcon', Icon: FolderIcon }, - { name: 'UsersIcon', Icon: UsersIcon }, - { name: 'TerminalIcon', Icon: TerminalIcon }, - { name: 'TeamIcon', Icon: TeamIcon }, - { name: 'BuildingIcon', Icon: BuildingIcon }, - { name: 'RocketIcon', Icon: RocketIcon }, - { name: 'MapIcon', Icon: MapIcon }, - { name: 'CodeIcon', Icon: CodeIcon }, - { name: 'CheckCircleIcon', Icon: CheckCircleIcon }, - { name: 'BoltIcon', Icon: BoltIcon }, - { name: 'BookIcon', Icon: BookIcon }, - { name: 'SlidersIcon', Icon: SlidersIcon }, - { name: 'LinkIcon', Icon: LinkIcon }, - { name: 'GitHubIcon', Icon: GitHubIcon }, - ] - return ( -
- -
- {icons.map(({ name, Icon }) => ( -
- -

- {name} -

-
- ))} -
-
-
- ) -} - -function ToolLogosSection() { - const logos = [ - { name: 'Anthropic', Logo: AnthropicLogo }, - { name: 'Cursor', Logo: CursorLogo }, - { name: 'Copilot', Logo: CopilotLogo }, - { name: 'Windsurf', Logo: WindsurfLogo }, - { name: 'OpenAI', Logo: OpenAILogo }, - ] - return ( -
- -
- {logos.map(({ name, Logo }) => ( -
- -

- {name} -

-
- ))} -
-
-
- ) -} - -function ThemeToggleSection() { - return ( -
- -

- Fixed-position toggle (top-right corner). Uses next-themes useTheme hook. -

-
- -
-
-
- ) -} - -function ButtonSection() { - return ( -
- -
- - - - - - -
-
-
- ) -} - -function CardSection() { - return ( -
-
- -

- Standard Card -

-

- Basic card with rounded corners, border, and shadow. -

-
- -

- Glass Effect Card -

-

Card with glass-effect backdrop blur.

-
- -

- Glow Card -

-

- Card with glow hover and gradient border. -

-
-
-
- ) -} - -function CalloutSection() { - return ( -
-
- - This is an informational callout with blue accent. - - - This is a warning callout with amber accent. - - - This is a tip callout with teal accent. - -
-
- ) -} - -function ColorSection() { - return ( -
- -
- - - - - - -
-
-
- ) -} - -function TypographySection() { - return ( -
- -
- -

Heading 1

-

Heading 2

-

Heading 3

-
- -

- This is regular body text. pair is an AI-assisted development tool built for pragmatic - developers. -

-
- - - import { PairLogo } from '@pair/brand' - - -
-
-
- ) -} - -function TypoBlock({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

- {title} -

- {children} -
- ) -} - -function UtilityCard({ - className, - label, - children, -}: { - className: string - label: string - children?: React.ReactNode -}) { - const swatch = { height: '100px', borderRadius: '8px', marginBottom: '0.5rem' } as const - return ( - - {children ??
} -

.{label}

- - ) -} - -function UtilitySection() { - return ( -
-
- - -
-
-
-
- - -

- pair -

-
- - -
- -
-
- ) -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ) -} - -function ColorSwatch({ color, label, border }: { color: string; label: string; border?: boolean }) { - return ( -
-
-

{label}

-

- {color} -

-
- ) -} - export default App diff --git a/packages/brand/dev/sections/ButtonSection.tsx b/packages/brand/dev/sections/ButtonSection.tsx new file mode 100644 index 00000000..4666c61e --- /dev/null +++ b/packages/brand/dev/sections/ButtonSection.tsx @@ -0,0 +1,23 @@ +import { Card, Button } from '$components' +import { Section } from './primitives' + +export function ButtonSection() { + return ( +
+ +
+ + + + + + +
+
+
+ ) +} diff --git a/packages/brand/dev/sections/CalloutSection.tsx b/packages/brand/dev/sections/CalloutSection.tsx new file mode 100644 index 00000000..cb85f6ee --- /dev/null +++ b/packages/brand/dev/sections/CalloutSection.tsx @@ -0,0 +1,20 @@ +import { Callout } from '$components' +import { Section } from './primitives' + +export function CalloutSection() { + return ( +
+
+ + This is an informational callout with blue accent. + + + This is a warning callout with amber accent. + + + This is a tip callout with teal accent. + +
+
+ ) +} diff --git a/packages/brand/dev/sections/CardSection.tsx b/packages/brand/dev/sections/CardSection.tsx new file mode 100644 index 00000000..95da8798 --- /dev/null +++ b/packages/brand/dev/sections/CardSection.tsx @@ -0,0 +1,33 @@ +import { Card } from '$components' +import { Section } from './primitives' + +export function CardSection() { + return ( +
+
+ +

+ Standard Card +

+

+ Basic card with rounded corners, border, and shadow. +

+
+ +

+ Glass Effect Card +

+

Card with glass-effect backdrop blur.

+
+ +

+ Glow Card +

+

+ Card with glow hover and gradient border. +

+
+
+
+ ) +} diff --git a/packages/brand/dev/sections/ColorSection.tsx b/packages/brand/dev/sections/ColorSection.tsx new file mode 100644 index 00000000..463cecec --- /dev/null +++ b/packages/brand/dev/sections/ColorSection.tsx @@ -0,0 +1,25 @@ +import { Card } from '$components' +import { PAIR_BLUE, PAIR_TEAL, LIGHT_BG, LIGHT_TEXT_MAIN, DARK_BG, DARK_TEXT_MAIN } from '$tokens' +import { Section, ColorSwatch } from './primitives' + +export function ColorSection() { + return ( +
+ +
+ + + + + + +
+
+
+ ) +} diff --git a/packages/brand/dev/sections/Header.tsx b/packages/brand/dev/sections/Header.tsx new file mode 100644 index 00000000..bb75c18d --- /dev/null +++ b/packages/brand/dev/sections/Header.tsx @@ -0,0 +1,17 @@ +import { Button } from '$components' + +export function Header({ isDark, onToggle }: { isDark: boolean; onToggle: () => void }) { + return ( +
+

+ @pair/brand Component Showcase +

+

+ Brand identity component library +

+ +
+ ) +} diff --git a/packages/brand/dev/sections/IconsSection.tsx b/packages/brand/dev/sections/IconsSection.tsx new file mode 100644 index 00000000..4b582582 --- /dev/null +++ b/packages/brand/dev/sections/IconsSection.tsx @@ -0,0 +1,67 @@ +import { + Card, + GridPlusIcon, + ShieldCheckIcon, + FolderIcon, + UsersIcon, + TerminalIcon, + TeamIcon, + BuildingIcon, + RocketIcon, + MapIcon, + CodeIcon, + CheckCircleIcon, + BoltIcon, + BookIcon, + SlidersIcon, + LinkIcon, + GitHubIcon, +} from '$components' +import { Section } from './primitives' + +export function IconsSection() { + const icons = [ + { name: 'GridPlusIcon', Icon: GridPlusIcon }, + { name: 'ShieldCheckIcon', Icon: ShieldCheckIcon }, + { name: 'FolderIcon', Icon: FolderIcon }, + { name: 'UsersIcon', Icon: UsersIcon }, + { name: 'TerminalIcon', Icon: TerminalIcon }, + { name: 'TeamIcon', Icon: TeamIcon }, + { name: 'BuildingIcon', Icon: BuildingIcon }, + { name: 'RocketIcon', Icon: RocketIcon }, + { name: 'MapIcon', Icon: MapIcon }, + { name: 'CodeIcon', Icon: CodeIcon }, + { name: 'CheckCircleIcon', Icon: CheckCircleIcon }, + { name: 'BoltIcon', Icon: BoltIcon }, + { name: 'BookIcon', Icon: BookIcon }, + { name: 'SlidersIcon', Icon: SlidersIcon }, + { name: 'LinkIcon', Icon: LinkIcon }, + { name: 'GitHubIcon', Icon: GitHubIcon }, + ] + return ( +
+ +
+ {icons.map(({ name, Icon }) => ( +
+ +

+ {name} +

+
+ ))} +
+
+
+ ) +} diff --git a/packages/brand/dev/sections/LogoSection.tsx b/packages/brand/dev/sections/LogoSection.tsx new file mode 100644 index 00000000..c7117d33 --- /dev/null +++ b/packages/brand/dev/sections/LogoSection.tsx @@ -0,0 +1,23 @@ +import { Card } from '$components' +import { Section, LogoVariant } from './primitives' + +export function LogoSection() { + return ( +
+ +
+ + + +
+
+
+ ) +} diff --git a/packages/brand/dev/sections/ThemeToggleSection.tsx b/packages/brand/dev/sections/ThemeToggleSection.tsx new file mode 100644 index 00000000..02076f3f --- /dev/null +++ b/packages/brand/dev/sections/ThemeToggleSection.tsx @@ -0,0 +1,23 @@ +import { Card, ThemeToggle } from '$components' +import { Section } from './primitives' + +export function ThemeToggleSection() { + return ( +
+ +

+ Fixed-position toggle (top-right corner). Uses next-themes useTheme hook. +

+
+ +
+
+
+ ) +} diff --git a/packages/brand/dev/sections/ToolLogosSection.tsx b/packages/brand/dev/sections/ToolLogosSection.tsx new file mode 100644 index 00000000..af6cdb35 --- /dev/null +++ b/packages/brand/dev/sections/ToolLogosSection.tsx @@ -0,0 +1,40 @@ +import { Card, AnthropicLogo, CursorLogo, CopilotLogo, WindsurfLogo, OpenAILogo } from '$components' +import { Section } from './primitives' + +export function ToolLogosSection() { + const logos = [ + { name: 'Anthropic', Logo: AnthropicLogo }, + { name: 'Cursor', Logo: CursorLogo }, + { name: 'Copilot', Logo: CopilotLogo }, + { name: 'Windsurf', Logo: WindsurfLogo }, + { name: 'OpenAI', Logo: OpenAILogo }, + ] + return ( +
+ +
+ {logos.map(({ name, Logo }) => ( +
+ +

+ {name} +

+
+ ))} +
+
+
+ ) +} diff --git a/packages/brand/dev/sections/TypographySection.tsx b/packages/brand/dev/sections/TypographySection.tsx new file mode 100644 index 00000000..48d8e8f1 --- /dev/null +++ b/packages/brand/dev/sections/TypographySection.tsx @@ -0,0 +1,36 @@ +import { Card } from '$components' +import { Section, TypoBlock } from './primitives' + +export function TypographySection() { + return ( +
+ +
+ +

Heading 1

+

Heading 2

+

Heading 3

+
+ +

+ This is regular body text. pair is an AI-assisted development tool built for pragmatic + developers. +

+
+ + + import { PairLogo } from '@pair/brand' + + +
+
+
+ ) +} diff --git a/packages/brand/dev/sections/UtilitySection.tsx b/packages/brand/dev/sections/UtilitySection.tsx new file mode 100644 index 00000000..da4f661a --- /dev/null +++ b/packages/brand/dev/sections/UtilitySection.tsx @@ -0,0 +1,49 @@ +import { Section, UtilityCard } from './primitives' + +export function UtilitySection() { + return ( +
+
+ + +
+
+
+
+ + +

+ pair +

+
+ + +
+ +
+
+ ) +} diff --git a/packages/brand/dev/sections/index.ts b/packages/brand/dev/sections/index.ts new file mode 100644 index 00000000..56b2864f --- /dev/null +++ b/packages/brand/dev/sections/index.ts @@ -0,0 +1,11 @@ +export { Header } from './Header' +export { LogoSection } from './LogoSection' +export { IconsSection } from './IconsSection' +export { ToolLogosSection } from './ToolLogosSection' +export { ThemeToggleSection } from './ThemeToggleSection' +export { ButtonSection } from './ButtonSection' +export { CardSection } from './CardSection' +export { CalloutSection } from './CalloutSection' +export { ColorSection } from './ColorSection' +export { TypographySection } from './TypographySection' +export { UtilitySection } from './UtilitySection' diff --git a/packages/brand/dev/sections/primitives.tsx b/packages/brand/dev/sections/primitives.tsx new file mode 100644 index 00000000..b881ad75 --- /dev/null +++ b/packages/brand/dev/sections/primitives.tsx @@ -0,0 +1,88 @@ +import { PairLogo, Card } from '$components' + +export function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +export function LogoVariant({ + variant, + label, +}: { + variant: 'favicon' | 'navbar' | 'full' + label: string +}) { + return ( +
+ +

{label}

+
+ ) +} + +export function ColorSwatch({ + color, + label, + border, +}: { + color: string + label: string + border?: boolean +}) { + return ( +
+
+

{label}

+

+ {color} +

+
+ ) +} + +export function TypoBlock({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +export function UtilityCard({ + className, + label, + children, +}: { + className: string + label: string + children?: React.ReactNode +}) { + const swatch = { height: '100px', borderRadius: '8px', marginBottom: '0.5rem' } as const + return ( + + {children ??
} +

.{label}

+ + ) +} From f671afcd3bae27f66d550ce3df30faee519889cc Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:29:16 +0200 Subject: [PATCH 09/21] [#199] refactor: split monster cli.e2e.test.ts per command - extract shared fixtures to cli-e2e-helpers.ts - cli.e2e.test.ts (1507 LOC) -> 7 focused files: validate-config, install, update, link, errors, packaging, kb-validate - behavior-preserving; full pair-cli suite green (869 tests, 77 files) - Task: T2 (P0.3) Refs: #199 --- apps/pair-cli/src/cli-e2e-helpers.ts | 164 ++ apps/pair-cli/src/cli-errors.e2e.test.ts | 46 + apps/pair-cli/src/cli-install.e2e.test.ts | 259 +++ apps/pair-cli/src/cli-kb-validate.e2e.test.ts | 279 +++ apps/pair-cli/src/cli-link.e2e.test.ts | 137 ++ apps/pair-cli/src/cli-packaging.e2e.test.ts | 312 ++++ apps/pair-cli/src/cli-update.e2e.test.ts | 194 +++ .../src/cli-validate-config.e2e.test.ts | 136 ++ apps/pair-cli/src/cli.e2e.test.ts | 1507 ----------------- 9 files changed, 1527 insertions(+), 1507 deletions(-) create mode 100644 apps/pair-cli/src/cli-e2e-helpers.ts create mode 100644 apps/pair-cli/src/cli-errors.e2e.test.ts create mode 100644 apps/pair-cli/src/cli-install.e2e.test.ts create mode 100644 apps/pair-cli/src/cli-kb-validate.e2e.test.ts create mode 100644 apps/pair-cli/src/cli-link.e2e.test.ts create mode 100644 apps/pair-cli/src/cli-packaging.e2e.test.ts create mode 100644 apps/pair-cli/src/cli-update.e2e.test.ts create mode 100644 apps/pair-cli/src/cli-validate-config.e2e.test.ts delete mode 100644 apps/pair-cli/src/cli.e2e.test.ts diff --git a/apps/pair-cli/src/cli-e2e-helpers.ts b/apps/pair-cli/src/cli-e2e-helpers.ts new file mode 100644 index 00000000..d6905c4e --- /dev/null +++ b/apps/pair-cli/src/cli-e2e-helpers.ts @@ -0,0 +1,164 @@ +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' + +/** + * Shared fixtures and helpers for the pair-cli e2e suites (split per command + * from the former monolithic cli.e2e.test.ts). Each e2e file imports the + * fixtures it needs from here. + */ + +export function createNpmDeployFs(cwd: string): InMemoryFileSystemService { + // Simulate npm install: pair-cli extracted to node_modules/@foomakers/pair-cli/ + // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. + // We simulate the cached KB path so tests can resolve the dataset without network. + const seed: Record = {} + const moduleFolder = cwd + '/node_modules/@foomakers/pair-cli' + const kbCachePath = cwd + '/.pair-kb-cache/0.1.1' + + // Dataset content in the KB cache location (simulates auto-download result) + seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' + seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' + seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' + + // Package.json for pair-cli (scoped package) — no bundle-cli/dataset + seed[moduleFolder + '/package.json'] = JSON.stringify({ + name: '@foomakers/pair-cli', + version: '0.1.1', + }) + + // Sample project package.json + seed[cwd + '/package.json'] = JSON.stringify({ + name: 'pair-sample-project', + version: '1.0.0', + dependencies: { + '@foomakers/pair-cli': 'file:../pkg/package', + }, + }) + + return new InMemoryFileSystemService(seed, moduleFolder, cwd) +} + +export function createManualDeployFs(cwd: string): InMemoryFileSystemService { + // Simulate manual installation: pair-cli binary standalone. + // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. + const seed: Record = {} + const moduleFolder = cwd + '/libs/pair-cli' + const kbCachePath = cwd + '/.pair-kb-cache/0.1.0' + + // Add package.json for @pair/pair-cli package — no bundle-cli/dataset + seed[cwd + '/libs/pair-cli/package.json'] = JSON.stringify({ + name: '@pair/pair-cli', + version: '0.1.0', + description: 'Pair CLI manual installation', + }) + + // Dataset content in the KB cache location (simulates auto-download result) + seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' + seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' + seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' + + return new InMemoryFileSystemService(seed, moduleFolder, cwd) +} + +export function createDevScenarioFs(cwd: string): InMemoryFileSystemService { + // Simulate development scenario: pair-cli as regular node_modules dependency + // Dataset is at node_modules/@pair/knowledge-hub/dataset/ (accessible from project root) + const seed: Record = {} + + // Dataset content in the @pair/knowledge-hub package location (project's node_modules) + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/AGENTS.md'] = 'this is agents.md' + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.github/workflows/ci.yml'] = + 'name: CI\non: push' + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/knowledge/index.md'] = + '# Knowledge Base' + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/adoption/onboarding.md'] = + '# Onboarding Guide' + + // Package.json for @pair/knowledge-hub + seed[cwd + '/node_modules/@pair/knowledge-hub/package.json'] = JSON.stringify({ + name: '@pair/knowledge-hub', + version: '0.1.0', + }) + + // Package.json for pair-cli (regular dependency) + seed[cwd + '/node_modules/pair-cli/package.json'] = JSON.stringify({ + name: 'pair-cli', + version: '0.1.0', + }) + + // Sample project package.json + seed[cwd + '/package.json'] = JSON.stringify({ + name: 'pair-cli', + version: '1.0.0-wip', + dependencies: { + '@pair/knowledge-hub': 'catalog:*', + }, + }) + + return new InMemoryFileSystemService(seed, cwd, cwd) +} + +export async function withTempConfig( + fs: InMemoryFileSystemService, + config: unknown, + fn: () => Promise, +): Promise { + const configPath = fs.rootModuleDirectory() + '/config.json' + await fs.writeFile(configPath, JSON.stringify(config)) + try { + await fn() + } finally { + // Cleanup if needed + } +} + +export function createTestConfig() { + return { + asset_registries: { + '.github': { + source: '.github', + behavior: 'mirror', + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configs', + }, + '.pair-knowledge': { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair-knowledge', mode: 'canonical' }], + description: 'Knowledge base content', + }, + '.pair-adoption': { + source: '.pair/adoption', + behavior: 'mirror', + targets: [{ path: '.pair-adoption', mode: 'canonical' }], + description: 'Adoption and onboarding content', + }, + 'agents.md': { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + } +} + +export function getDeploymentConfig(deployType: 'npm' | 'manual' | 'dev'): { + cwd: string + fs: InMemoryFileSystemService +} { + const cwd = + deployType === 'npm' + ? '/.tmp/npm-test/sample-project' + : deployType === 'manual' + ? '/tmp/test-project' + : '/dev/test-project' + const fs = + deployType === 'npm' + ? createNpmDeployFs(cwd) + : deployType === 'manual' + ? createManualDeployFs(cwd) + : createDevScenarioFs(cwd) + return { cwd, fs } +} diff --git a/apps/pair-cli/src/cli-errors.e2e.test.ts b/apps/pair-cli/src/cli-errors.e2e.test.ts new file mode 100644 index 00000000..12cb6dac --- /dev/null +++ b/apps/pair-cli/src/cli-errors.e2e.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { installCommand, handleUpdateCommand, parseUpdateCommand } from './commands' + +describe('pair-cli e2e - error scenarios', () => { + it('update fails gracefully when source directory does not exist', async () => { + const cwd = '/test-no-source' + const seed: Record = { + [cwd + '/config.json']: JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub config', + }, + }, + }), + } + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + await expect( + handleUpdateCommand(parseUpdateCommand({ source: '/nonexistent/path' }), fs), + ).rejects.toThrow('KB source path not found') + }) + + it('install from ZIP fails gracefully when ZIP is corrupted', async () => { + const cwd = '/test-bad-zip' + const seed: Record = { + [cwd + '/config.json']: JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub config', + }, + }, + }), + [cwd + '/bad.zip']: 'not a valid zip file', + } + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + const result = await installCommand(fs, ['--source', 'bad.zip'], { useDefaults: true }) + expect(result).toBeDefined() + // May succeed or fail depending on implementation, just ensure it doesn't crash + }) +}) diff --git a/apps/pair-cli/src/cli-install.e2e.test.ts b/apps/pair-cli/src/cli-install.e2e.test.ts new file mode 100644 index 00000000..d23a87e4 --- /dev/null +++ b/apps/pair-cli/src/cli-install.e2e.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { installCommand, handleUpdateCommand, handleUpdateLinkCommand } from './commands' + +describe('pair-cli e2e - install from local sources', () => { + describe('install from local ZIP', () => { + it('installs from absolute path ZIP', async () => { + const cwd = '/test-absolute-zip' + const zipPath = 'kb.zip' + + // Create filesystem with ZIP file (simulated as binary content) + const seed: Record = {} + seed['/test-absolute-zip/kb.zip'] = + 'PK\x03\x04\x14\x00\x00\x00\x00\x00\x8d\x8f\x8bN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00\x00\x00AGENTS.mdthis is agents.md' // Minimal ZIP content + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await installCommand(fs, ['--source', zipPath], { useDefaults: true }) + }) + + it('installs from relative path ZIP', async () => { + const cwd = '/test-relative-zip' + const zipPath = './downloads/kb.zip' + + const seed: Record = {} + seed['/test-relative-zip/downloads/kb.zip'] = + 'PK\x03\x04\x14\x00\x00\x00\x00\x00\x8d\x8f\x8bN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00\x00\x00AGENTS.mdthis is agents.md' + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await installCommand(fs, ['--source', zipPath], { useDefaults: true }) + }) + }) + + describe('install from local directory', () => { + it('installs from absolute path directory', async () => { + const cwd = '/test-absolute-dir' + const dirPath = 'dataset' + + const seed: Record = {} + seed[`${cwd}/${dirPath}/AGENTS.md`] = 'this is agents.md' + seed[`${cwd}/${dirPath}/.pair/knowledge/index.md`] = '# Knowledge Base' + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await installCommand(fs, ['--source', dirPath], { useDefaults: true }) + }) + + it('installs from relative path directory', async () => { + const cwd = '/test-relative-dir' + const dirPath = './dataset' + + const seed: Record = {} + seed['/test-relative-dir/dataset/AGENTS.md'] = 'this is agents.md' + seed['/test-relative-dir/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await installCommand(fs, ['--source', dirPath], { useDefaults: true }) + }) + }) +}) + +describe('pair-cli e2e - disjoint installation (source and target disjoint)', () => { + it('installs KB to a disjoint absolute path', async () => { + const projectRoot = '/test-project' + const disjointTarget = '/opt/pair/kb' + const kbSourceDir = '/mnt/external/kb-dataset' + + // 1. Setup Filesystem + const seed: Record = { + // Configuration in the "project root" + [`${projectRoot}/config.json`]: JSON.stringify({ + asset_registries: { + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: 'knowledge', mode: 'canonical' }], + description: 'Core knowledge', + }, + }, + }), + [`${projectRoot}/package.json`]: JSON.stringify({ + name: 'test-project', + version: '1.0.0', + }), + // KB Source content in a disjoint directory + [`${kbSourceDir}/AGENTS.md`]: '# KB source marker', + [`${kbSourceDir}/knowledge/index.md`]: '# Knowledge Index', + [`${kbSourceDir}/knowledge/guide.md`]: 'Follow the [Index](./index.md)', + } + + const fs = new InMemoryFileSystemService(seed, projectRoot, projectRoot) + + // 2. Perform installation to disjoint target + // pair install /opt/pair/kb --source /mnt/external/kb-dataset + await installCommand(fs, ['--source', kbSourceDir], { + baseTarget: disjointTarget, + useDefaults: true, + }) + + // 3. Verify installation in disjoint target + // The target path for the 'knowledge' registry should be /opt/pair/kb/knowledge + const installedFile = `${disjointTarget}/knowledge/index.md` + expect(fs.existsSync(installedFile)).toBe(true) + expect(fs.readFileSync(installedFile)).toBe('# Knowledge Index') + + // 4. Test disjoint update + // Add new file to source + await fs.writeFile(`${kbSourceDir}/knowledge/new.md`, 'New content') + + // pair update /opt/pair/kb --source /mnt/external/kb-dataset + await handleUpdateCommand( + { + command: 'update', + resolution: 'local', + path: kbSourceDir, + kb: true, + offline: true, + target: disjointTarget, + }, + fs, + ) + + expect(fs.existsSync(`${disjointTarget}/knowledge/new.md`)).toBe(true) + + // 5. Test disjoint update-link + // pair update-link /opt/pair/kb + await handleUpdateLinkCommand( + { + command: 'update-link', + target: disjointTarget, + dryRun: false, + logLevel: 'debug', + }, + fs, + ) + + // Verify rollback setup is working even in disjoint paths (implicitly tested by logic running) + const installedGuide = `${disjointTarget}/knowledge/guide.md` + expect(fs.existsSync(installedGuide)).toBe(true) + }) +}) diff --git a/apps/pair-cli/src/cli-kb-validate.e2e.test.ts b/apps/pair-cli/src/cli-kb-validate.e2e.test.ts new file mode 100644 index 00000000..836e50a9 --- /dev/null +++ b/apps/pair-cli/src/cli-kb-validate.e2e.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { handleKbValidateCommand } from './commands' + +describe('pair-cli e2e - kb-validate', () => { + function createFullConfig() { + return { + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents'], + description: 'GitHub config', + targets: [{ path: '.github', mode: 'canonical' }], + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + description: 'KB content', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + description: 'Adoption guides', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + }, + agents: { + source: 'AGENTS.md', + behavior: 'mirror', + description: 'Agent guidance', + targets: [ + { path: 'AGENTS.md', mode: 'canonical' }, + { path: 'CLAUDE.md', mode: 'copy' }, + ], + }, + skills: { + source: '.skills', + behavior: 'mirror', + flatten: true, + prefix: 'pair', + description: 'Agent skills', + targets: [ + { path: '.claude/skills/', mode: 'canonical' }, + { path: '.github/skills/', mode: 'symlink' }, + { path: '.cursor/skills/', mode: 'symlink' }, + ], + }, + }, + } + } + + function createSourceLayoutFs(cwd: string): InMemoryFileSystemService { + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' + seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' + seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/.skills/next/SKILL.md`] = + '---\nname: next\ndescription: Project navigator\n---\n# /next' + seed[`${cwd}/.skills/capability/assess-stack/SKILL.md`] = + '---\nname: assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' + return new InMemoryFileSystemService(seed, cwd, cwd) + } + + function createTargetLayoutFs(cwd: string): InMemoryFileSystemService { + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' + seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' + seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/CLAUDE.md`] = '# CLAUDE' + // Target layout: skills at canonical target with prefix applied + seed[`${cwd}/.claude/skills/pair-next/SKILL.md`] = + '---\nname: pair-next\ndescription: Project navigator\n---\n# /next' + seed[`${cwd}/.claude/skills/pair-capability-assess-stack/SKILL.md`] = + '---\nname: pair-capability-assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' + // Symlink targets NOT present (they'd be symlinks in real FS) + return new InMemoryFileSystemService(seed, cwd, cwd) + } + + describe('source layout validation', () => { + it('validates valid source layout — exit 0', async () => { + const cwd = '/e2e-validate-source' + const fs = createSourceLayoutFs(cwd) + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).resolves.toBeUndefined() + }) + + it('detects missing registry in source layout', async () => { + const cwd = '/e2e-validate-source-missing' + // Create FS without adoption source dir — structure validator reports error + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/.skills/next/SKILL.md`] = '---\nname: next\ndescription: Nav\n---\n# /next' + // .pair/adoption is missing entirely + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).rejects.toThrow('Validation failed') + }) + }) + + describe('target layout validation', () => { + it('validates valid target layout — exit 0', async () => { + const cwd = '/e2e-validate-target' + const fs = createTargetLayoutFs(cwd) + + await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).resolves.toBeUndefined() + }) + + it('symlink targets ignored — no error for missing .github/skills', async () => { + const cwd = '/e2e-validate-target-symlink' + const fs = createTargetLayoutFs(cwd) + // .github/skills and .cursor/skills are symlink targets — should NOT be checked + expect(fs.existsSync(`${cwd}/.github/skills`)).toBe(false) + + await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).resolves.toBeUndefined() + }) + + it('detects missing registry in target layout', async () => { + const cwd = '/e2e-validate-target-missing' + // Create FS without knowledge target dir — structure validator reports error + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/CLAUDE.md`] = '# CLAUDE' + seed[`${cwd}/.claude/skills/pair-next/SKILL.md`] = + '---\nname: pair-next\ndescription: Nav\n---\n# /next' + // .pair/knowledge is missing entirely + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).rejects.toThrow( + 'Validation failed', + ) + }) + }) + + describe('registry filtering', () => { + it('--skip-registries excludes specified registries', async () => { + const cwd = '/e2e-validate-skip' + // Create FS without adoption source dir — would fail without skip + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/.skills/next/SKILL.md`] = '---\nname: next\ndescription: Nav\n---\n# /next' + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + // Skipping adoption should pass despite missing .pair/adoption + await expect( + handleKbValidateCommand( + { command: 'kb-validate', layout: 'source', skipRegistries: ['adoption'] }, + fs, + ), + ).resolves.toBeUndefined() + }) + + it('--ignore-config skips structure validation', async () => { + const cwd = '/e2e-validate-ignore' + const fs = new InMemoryFileSystemService( + { + [`${cwd}/.pair/knowledge/index.md`]: '# KB', + }, + cwd, + cwd, + ) + // No config.json — would fail without --ignore-config + + await expect( + handleKbValidateCommand({ command: 'kb-validate', ignoreConfig: true }, fs), + ).resolves.toBeUndefined() + }) + }) + + describe('link integrity', () => { + it('detects broken internal links', async () => { + const cwd = '/e2e-validate-links' + const fs = createSourceLayoutFs(cwd) + // Replace valid link with broken one + await fs.writeFile( + `${cwd}/.pair/knowledge/index.md`, + '# Knowledge\n\nSee [missing](./nonexistent.md).', + ) + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).rejects.toThrow('Validation failed') + }) + + it('valid internal links pass', async () => { + const cwd = '/e2e-validate-links-valid' + const fs = createSourceLayoutFs(cwd) + // index.md links to guide.md which exists + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).resolves.toBeUndefined() + }) + }) + + describe('metadata and skills validation', () => { + it('detects missing SKILL.md frontmatter', async () => { + const cwd = '/e2e-validate-meta-missing' + const fs = createSourceLayoutFs(cwd) + // SKILL.md without frontmatter + await fs.writeFile(`${cwd}/.skills/next/SKILL.md`, '# /next\n\nNo frontmatter here.') + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).rejects.toThrow('Validation failed') + }) + + it('detects missing required name field in frontmatter', async () => { + const cwd = '/e2e-validate-meta-field' + const fs = createSourceLayoutFs(cwd) + // SKILL.md with frontmatter but missing name + await fs.writeFile( + `${cwd}/.skills/next/SKILL.md`, + '---\ndescription: Navigator\n---\n# /next', + ) + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).rejects.toThrow('Validation failed') + }) + + it('warns about adoption placeholders without failing', async () => { + const cwd = '/e2e-validate-placeholder' + const fs = createSourceLayoutFs(cwd) + // Adoption file with placeholder — should warn but not error + await fs.writeFile(`${cwd}/.pair/adoption/tech-stack.md`, '# Tech Stack\n\n[placeholder]') + + // Warnings don't cause failure + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).resolves.toBeUndefined() + }) + }) + + describe('exit codes and error handling', () => { + it('throws when .pair directory missing', async () => { + const cwd = '/e2e-validate-nopair' + const fs = new InMemoryFileSystemService({ [`${cwd}/README.md`]: '# Project' }, cwd, cwd) + + await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).rejects.toThrow( + 'missing .pair directory', + ) + }) + + it('mixed errors and warnings — exit 1', async () => { + const cwd = '/e2e-validate-mixed' + const fs = createSourceLayoutFs(cwd) + // Broken link (error) + placeholder (warning) + await fs.writeFile( + `${cwd}/.pair/knowledge/index.md`, + '# KB\n\nSee [broken](./nonexistent.md).', + ) + await fs.writeFile(`${cwd}/.pair/adoption/tech-stack.md`, '# Tech\n\n[placeholder]') + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).rejects.toThrow('Validation failed') + }) + }) +}) diff --git a/apps/pair-cli/src/cli-link.e2e.test.ts b/apps/pair-cli/src/cli-link.e2e.test.ts new file mode 100644 index 00000000..a9a9f6d1 --- /dev/null +++ b/apps/pair-cli/src/cli-link.e2e.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { installCommand, updateCommand } from './commands' +import { withTempConfig, createTestConfig, getDeploymentConfig } from './cli-e2e-helpers' + +describe('pair-cli e2e - link strategy', () => { + it('install with relative link style', async () => { + const { fs } = getDeploymentConfig('dev') + await withTempConfig(fs, createTestConfig(), async () => { + const configPath = fs.rootModuleDirectory() + '/config.json' + const result = await installCommand(fs, [], { + customConfigPath: configPath, + useDefaults: true, + linkStyle: 'relative', + }) + expect((result as { success?: boolean }).success).toBe(true) + }) + }) + + it('update with absolute link style', async () => { + const { fs } = getDeploymentConfig('dev') + await withTempConfig(fs, createTestConfig(), async () => { + const configPath = fs.rootModuleDirectory() + '/config.json' + // First install to set up targets + await installCommand(fs, [], { customConfigPath: configPath, useDefaults: true }) + // Then update with absolute style and defaults + const result = await updateCommand(fs, [], { useDefaults: true, linkStyle: 'absolute' }) + expect((result as { success?: boolean }).success).toBe(true) + }) + }) + + it('update with auto link style detection', async () => { + const { fs } = getDeploymentConfig('dev') + await withTempConfig(fs, createTestConfig(), async () => { + const configPath = fs.rootModuleDirectory() + '/config.json' + // First install to establish baseline + await installCommand(fs, [], { customConfigPath: configPath, useDefaults: true }) + // Then update with auto detection and defaults + await updateCommand(fs, [], { useDefaults: true, linkStyle: 'auto' }) + // Auto detection should succeed + }) + }) +}) + +describe('pair-cli e2e - preferences persistence', () => { + it('saves and reads preferences with custom directory', async () => { + const { readPreferences, savePreferences } = await import('./commands/package/preferences.js') + const cwd = '/test-prefs' + const prefsDir = `${cwd}/.custom-pair` + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + const data = { + packageMetadata: { name: 'my-kb', author: 'Test Author', tags: ['ai', 'kb'] }, + updatedAt: '2026-02-17T00:00:00.000Z', + } + + await savePreferences(data, fs, prefsDir) + const result = readPreferences(fs, prefsDir) + + expect(result).toEqual(data) + }) + + it('preferences from one session become defaults in next', async () => { + const { readPreferences, savePreferences } = await import('./commands/package/preferences.js') + const { resolveDefaults } = await import('./commands/package/defaults-resolver.js') + + const cwd = '/test-prefs-flow' + const prefsDir = `${cwd}/.pair` + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + // Session 1: save preferences + await savePreferences( + { + packageMetadata: { name: 'saved-kb', author: 'Saved Author', license: 'Apache-2.0' }, + updatedAt: new Date().toISOString(), + }, + fs, + prefsDir, + ) + + // Session 2: read preferences and resolve defaults + const prefs = readPreferences(fs, prefsDir) + const defaults = resolveDefaults({ preferences: prefs?.packageMetadata }) + + expect(defaults.name).toBe('saved-kb') + expect(defaults.author).toBe('Saved Author') + expect(defaults.license).toBe('Apache-2.0') + // Non-saved fields fall back to hardcoded + expect(defaults.version).toBe('1.0.0') + }) + + it('package command produces ZIP with non-optional manifest fields', async () => { + const { handlePackageCommand } = await import('./commands/package/handler.js') + const cwd = '/test-pkg-manifest' + const seed: Record = { + [`${cwd}/config.json`]: JSON.stringify({ + asset_registries: { + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'KB content', + }, + }, + }), + [`${cwd}/.pair/knowledge/doc.md`]: '# Doc', + } + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + const outputPath = `${cwd}/dist/kb.zip` + + await handlePackageCommand( + { + command: 'package', + output: outputPath, + interactive: false, + tags: ['ai', 'devops'], + license: 'Apache-2.0', + }, + fs, + ) + + expect(await fs.exists(outputPath)).toBe(true) + + // Extract and verify manifest has all required fields + const extractDir = `${cwd}/extracted` + await fs.extractZip(outputPath, extractDir) + const manifest = JSON.parse(await fs.readFile(`${extractDir}/manifest.json`)) + + expect(manifest.tags).toEqual(['ai', 'devops']) + expect(manifest.license).toBe('Apache-2.0') + expect(manifest.description).toBe('Knowledge base package') // default + expect(manifest.author).toBe('unknown') // default + expect(typeof manifest.name).toBe('string') + expect(typeof manifest.version).toBe('string') + expect(typeof manifest.created_at).toBe('string') + }) +}) diff --git a/apps/pair-cli/src/cli-packaging.e2e.test.ts b/apps/pair-cli/src/cli-packaging.e2e.test.ts new file mode 100644 index 00000000..b768cb80 --- /dev/null +++ b/apps/pair-cli/src/cli-packaging.e2e.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { installCommand, handleUpdateCommand, parseUpdateCommand } from './commands' +import { withTempConfig } from './cli-e2e-helpers' + +describe('pair-cli e2e - package command', () => { + it('package command creates valid ZIP with manifest', async () => { + const cwd = '/test-package' + + const seed: Record = {} + // Create dataset source structure + seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS documentation' + seed[cwd + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed[cwd + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' + seed[cwd + '/config.json'] = JSON.stringify({ + asset_registries: { + knowledge: { + source: '.', + behavior: 'mirror', + targets: [{ path: '.', mode: 'canonical' }], + description: 'Knowledge base dataset', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + // Test that we can create a package structure + // The actual executePackage function is internal, but we can test it via options validation + const configPath = cwd + '/config.json' + const config = JSON.parse(fs.readFileSync(configPath)) + expect(config.asset_registries).toBeDefined() + expect(config.asset_registries.knowledge).toBeDefined() + }) + + it('package command with --source-dir option validates config', async () => { + const cwd = '/test-package-source-dir' + + const seed: Record = {} + seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS' + seed[cwd + '/config.json'] = JSON.stringify({ + asset_registries: { + content: { + source: '.', + behavior: 'mirror', + targets: [{ path: '.', mode: 'canonical' }], + description: 'Content', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + // Verify config can be loaded + const configPath = cwd + '/config.json' + const config = JSON.parse(fs.readFileSync(configPath)) + expect(config.asset_registries.content).toBeDefined() + }) + + it('package command fails gracefully with invalid config', async () => { + const cwd = '/test-package-bad-config' + + const seed: Record = {} + seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS' + seed[cwd + '/config.json'] = '{ invalid json' + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + // Verify that parsing invalid config fails + const configPath = cwd + '/config.json' + expect(() => { + JSON.parse(fs.readFileSync(configPath)) + }).toThrow() + }) + + it('package command fails gracefully with missing config', async () => { + const cwd = '/test-package-no-config' + + const seed: Record = { + [cwd + '/dataset/AGENTS.md']: '# AGENTS', + } + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + // Verify that reading missing config fails + const configPath = cwd + '/nonexistent.json' + expect(() => { + fs.readFileSync(configPath) + }).toThrow() + }) +}) + +describe('pair-cli e2e - skills registry pipeline', () => { + function createSkillsConfig() { + return { + asset_registries: { + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + description: 'Knowledge base content', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + }, + skills: { + source: '.skills', + behavior: 'mirror', + flatten: true, + prefix: 'pair', + description: 'Agent skills distributed to AI tool directories', + targets: [ + { path: '.claude/skills/', mode: 'canonical' }, + { path: '.github/skills/', mode: 'symlink' }, + { path: '.cursor/skills/', mode: 'symlink' }, + ], + }, + }, + } + } + + function createSkillsDatasetFs(cwd: string, opts?: { withTargets?: boolean }) { + const seed: Record = {} + const datasetBase = `${cwd}/dataset` + + // Pre-existing targets (update scenario — project already installed) + if (opts?.withTargets) { + seed[`${cwd}/.pair/knowledge/old.md`] = '# old' + } + + // Knowledge registry content + seed[`${datasetBase}/.pair/knowledge/index.md`] = '# Knowledge Base' + + // Skills registry content — mimics real .skills/ structure + seed[`${datasetBase}/.skills/next/SKILL.md`] = + '---\nname: next\ndescription: Project navigator\n---\n# /next' + + seed[`${cwd}/config.json`] = JSON.stringify(createSkillsConfig()) + + return new InMemoryFileSystemService(seed, cwd, cwd) + } + + it('update distributes skills with flatten + prefix to canonical target', async () => { + const cwd = '/test-skills' + const fs = createSkillsDatasetFs(cwd, { withTargets: true }) + + await handleUpdateCommand(parseUpdateCommand({ source: `${cwd}/dataset` }), fs) + + // Flatten is no-op for single-segment 'next', prefix 'pair' → 'pair-next' + expect(fs.existsSync(`${cwd}/.claude/skills/pair-next/SKILL.md`)).toBe(true) + const content = fs.readFileSync(`${cwd}/.claude/skills/pair-next/SKILL.md`) + expect(content).toContain('name: pair-next') + }) + + it('update creates symlinks for secondary targets', async () => { + const cwd = '/test-skills-symlink' + const fs = createSkillsDatasetFs(cwd, { withTargets: true }) + + await handleUpdateCommand(parseUpdateCommand({ source: `${cwd}/dataset` }), fs) + + // Secondary targets should be symlinks pointing to the canonical path + const symlinks = fs.getSymlinks() + const canonicalPath = `${cwd}/.claude/skills` + const githubSymlink = `${cwd}/.github/skills` + const cursorSymlink = `${cwd}/.cursor/skills` + + expect(symlinks.get(githubSymlink)).toBe(canonicalPath) + expect(symlinks.get(cursorSymlink)).toBe(canonicalPath) + }) + + it('validate-config succeeds with skills registry config', async () => { + const cwd = '/test-skills-validate' + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + await withTempConfig(fs, createSkillsConfig(), async () => { + const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) + const { validateConfig } = (await import('#config')) as typeof import('#config') + const validation = validateConfig(config) + + expect(validation.valid).toBe(true) + expect(validation.errors).toHaveLength(0) + }) + }) + + it('validate-config fails when skills registry has no canonical target', async () => { + const cwd = '/test-skills-no-canonical' + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + const badConfig = { + asset_registries: { + skills: { + source: '.skills', + behavior: 'mirror', + flatten: true, + prefix: 'pair', + description: 'Skills with no canonical target', + targets: [ + { path: '.github/skills/', mode: 'symlink' }, + { path: '.cursor/skills/', mode: 'symlink' }, + ], + }, + }, + } + + await withTempConfig(fs, badConfig, async () => { + const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) + const { validateConfig } = (await import('#config')) as typeof import('#config') + const validation = validateConfig(config) + + expect(validation.valid).toBe(false) + expect(validation.errors.some(e => e.includes('canonical'))).toBe(true) + }) + }) + + it('install distributes skills to canonical and symlink targets', async () => { + const cwd = '/test-skills-install' + const fs = createSkillsDatasetFs(cwd) + + await installCommand(fs, ['--source', `${cwd}/dataset`], { useDefaults: true }) + + // Canonical target should have flattened+prefixed content + expect(fs.existsSync(`${cwd}/.claude/skills/pair-next/SKILL.md`)).toBe(true) + + // Secondary targets should be symlinks + const symlinks = fs.getSymlinks() + expect(symlinks.has(`${cwd}/.github/skills`)).toBe(true) + expect(symlinks.has(`${cwd}/.cursor/skills`)).toBe(true) + }) +}) + +// ── kb-validate E2E tests ────────────────────────────────────────────── + +describe('pair-cli e2e - org packaging', () => { + it('parsePackageCommand includes org fields when --org flag present', async () => { + const { parsePackageCommand } = await import('./commands/package/parser.js') + + const config = parsePackageCommand({ + org: true, + orgName: 'Acme Corp', + team: 'Platform', + compliance: 'SOC2,ISO27001', + distribution: 'private', + }) + + expect(config.org).toBe(true) + expect(config.orgName).toBe('Acme Corp') + expect(config.team).toBe('Platform') + expect(config.compliance).toEqual(['SOC2', 'ISO27001']) + expect(config.distribution).toBe('private') + }) + + it('parsePackageCommand omits org fields when --org absent', async () => { + const { parsePackageCommand } = await import('./commands/package/parser.js') + + const config = parsePackageCommand({ name: 'test-kb' }) + + expect(config.org).toBeUndefined() + expect(config.orgName).toBeUndefined() + }) + + it('org template merges with CLI flags', async () => { + const { loadOrgTemplate, mergeOrgDefaults } = await import('./commands/package/org-template.js') + + const cwd = '/e2e-org-template' + const fs = new InMemoryFileSystemService( + { + [`${cwd}/.pair/org-template.json`]: JSON.stringify({ + name: 'Template Corp', + team: 'Default Team', + compliance: ['SOC2'], + distribution: 'restricted', + }), + }, + cwd, + cwd, + ) + + const template = await loadOrgTemplate(cwd, fs, '.pair/org-template.json') + expect(template).not.toBeNull() + + // CLI flags override template + const org = mergeOrgDefaults({ orgName: 'CLI Corp', team: 'CLI Team' }, template) + expect(org.name).toBe('CLI Corp') + expect(org.team).toBe('CLI Team') + // Template values used as fallback + expect(org.compliance).toEqual(['SOC2']) + expect(org.distribution).toBe('restricted') + }) + + it('createOrganizationMetadata factory provides defaults', async () => { + const { createOrganizationMetadata } = await import('./commands/package/metadata.js') + + const org = createOrganizationMetadata({ name: 'Acme' }) + expect(org.compliance).toEqual([]) + expect(org.distribution).toBe('open') + }) + + it('generateManifestMetadata includes organization in manifest', async () => { + const { generateManifestMetadata, createOrganizationMetadata } = await import( + './commands/package/metadata.js' + ) + + const org = createOrganizationMetadata({ + name: 'Acme', + team: 'Platform', + compliance: ['SOC2'], + distribution: 'private', + }) + + const manifest = generateManifestMetadata(['knowledge'], { organization: org }) + expect(manifest.organization).toBeDefined() + expect(manifest.organization?.name).toBe('Acme') + expect(manifest.organization?.distribution).toBe('private') + }) +}) diff --git a/apps/pair-cli/src/cli-update.e2e.test.ts b/apps/pair-cli/src/cli-update.e2e.test.ts new file mode 100644 index 00000000..00a91af5 --- /dev/null +++ b/apps/pair-cli/src/cli-update.e2e.test.ts @@ -0,0 +1,194 @@ +import { describe, it } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { handleUpdateCommand, parseUpdateCommand } from './commands' + +describe('update from local sources', () => { + describe('update from local ZIP', () => { + it('updates from absolute path ZIP', async () => { + const cwd = '/test-absolute-zip' + const zipPath = 'dataset' + + // Create filesystem with extracted ZIP content (simulated as directory) + const seed: Record = {} + // Pre-existing target (update scenario — project already installed) + seed[`${cwd}/.github/old.yml`] = '# pre-existing' + seed[`${cwd}/${zipPath}/AGENTS.md`] = 'this is agents.md' + seed[`${cwd}/${zipPath}/.github/workflows/ci.yml`] = 'name: CI\non: push' + seed[`${cwd}/${zipPath}/.pair/knowledge/index.md`] = '# Knowledge Base' + seed[`${cwd}/${zipPath}/.pair/adoption/onboarding.md`] = '# Onboarding Guide' + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await handleUpdateCommand(parseUpdateCommand({ source: zipPath }), fs) + }) + + it('updates from relative path ZIP', async () => { + const cwd = '/test-relative-zip' + const zipPath = './dataset' + + const seed: Record = {} + // Pre-existing target (update scenario — project already installed) + seed[`${cwd}/.github/old.yml`] = '# pre-existing' + seed['/test-relative-zip/dataset/AGENTS.md'] = 'this is agents.md' + seed['/test-relative-zip/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' + seed['/test-relative-zip/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed['/test-relative-zip/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await handleUpdateCommand(parseUpdateCommand({ source: zipPath }), fs) + }) + }) + + describe('update from local directory', () => { + it('updates from absolute path directory', async () => { + const cwd = '/test-absolute-dir' + const dirPath = 'dataset' + + const seed: Record = {} + // Pre-existing target (update scenario — project already installed) + seed[`${cwd}/.github/old.yml`] = '# pre-existing' + seed[`${cwd}/${dirPath}/AGENTS.md`] = 'this is agents.md' + seed[`${cwd}/${dirPath}/.github/workflows/ci.yml`] = 'name: CI\non: push' + seed[`${cwd}/${dirPath}/.pair/knowledge/index.md`] = '# Knowledge Base' + seed[`${cwd}/${dirPath}/.pair/adoption/onboarding.md`] = '# Onboarding Guide' + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await handleUpdateCommand(parseUpdateCommand({ source: dirPath }), fs) + }) + + it('updates from relative path directory', async () => { + const cwd = '/test-relative-dir' + const dirPath = './dataset' + + const seed: Record = {} + // Pre-existing target (update scenario — project already installed) + seed[`${cwd}/.github/old.yml`] = '# pre-existing' + seed['/test-relative-dir/dataset/AGENTS.md'] = 'this is agents.md' + seed['/test-relative-dir/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' + seed['/test-relative-dir/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed['/test-relative-dir/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' + seed[`${cwd}/config.json`] = JSON.stringify({ + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents', '/prompts'], + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configuration files', + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'Knowledge base and documentation', + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + description: 'Adoption guides and onboarding materials', + }, + agents: { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + }) + + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await handleUpdateCommand(parseUpdateCommand({ source: dirPath }), fs) + }) + }) +}) diff --git a/apps/pair-cli/src/cli-validate-config.e2e.test.ts b/apps/pair-cli/src/cli-validate-config.e2e.test.ts new file mode 100644 index 00000000..52abc3ec --- /dev/null +++ b/apps/pair-cli/src/cli-validate-config.e2e.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { createDevScenarioFs, withTempConfig, createTestConfig } from './cli-e2e-helpers' + +describe('pair-cli e2e - validate-config success', () => { + it('validate-config succeeds with valid config', async () => { + const cwd = '/test-project' + const fs = createDevScenarioFs(cwd) + + await withTempConfig(fs, createTestConfig(), async () => { + // Mock the CLI execution by calling the validate-config logic directly + // Since we can't easily run the CLI binary in tests, we'll test the underlying function + const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) + const { validateConfig } = (await import('#config')) as typeof import('#config') + const validation = validateConfig(config) + + expect(validation.valid).toBe(true) + expect(validation.errors).toHaveLength(0) + }) + }) +}) + +describe('pair-cli e2e - validate-config failures basic', () => { + it('validate-config fails with invalid config', async () => { + const cwd = '/test-project' + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + const invalidConfig = { + asset_registries: { + '.github': { + source: '.github', + behavior: 'invalid-behavior', // Invalid behavior + targets: [{ path: '.github', mode: 'canonical' }], + // Missing description - this should also be an error + }, + }, + } + + await withTempConfig(fs, invalidConfig, async () => { + const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) + const { validateConfig } = (await import('#config')) as typeof import('#config') + const validation = validateConfig(config) + + expect(validation.valid).toBe(false) + expect(validation.errors).toHaveLength(2) // Invalid behavior + missing description + expect(validation.errors[0]).toContain('invalid behavior') + }) + }) + + it('validate-config fails with missing asset_registries', async () => { + const cwd = '/test-project' + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + const invalidConfig = { + // Missing asset_registries + } + + await withTempConfig(fs, invalidConfig, async () => { + const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) + const { validateConfig } = (await import('#config')) as typeof import('#config') + const validation = validateConfig(config) + + expect(validation.valid).toBe(false) + expect(validation.errors).toContain('Config must have asset_registries object') + }) + }) +}) + +describe('pair-cli e2e - validate-config failures advanced', () => { + it('validate-config fails with invalid registry structure', async () => { + const cwd = '/test-project' + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + const invalidConfig = { + asset_registries: { + '.github': 'invalid-registry', // Should be an object + }, + } + + await withTempConfig(fs, invalidConfig, async () => { + const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) + const { validateConfig } = (await import('#config')) as typeof import('#config') + const validation = validateConfig(config) + + expect(validation.valid).toBe(false) + expect(validation.errors).toContain("Registry '.github' must be a valid object") + }) + }) + + it('validate-config fails with invalid include array', async () => { + const cwd = '/test-project' + const fs = new InMemoryFileSystemService({}, cwd, cwd) + + const invalidConfig = { + asset_registries: { + '.github': { + source: '.github', + behavior: 'mirror', + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows', + include: ['valid-string', 123], // Invalid: number in array + }, + }, + } + + await withTempConfig(fs, invalidConfig, async () => { + const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) + const { validateConfig } = (await import('#config')) as typeof import('#config') + const validation = validateConfig(config) + + expect(validation.valid).toBe(false) + expect(validation.errors).toContain( + "Registry '.github' include array must contain only strings", + ) + }) + }) +}) + +describe('pair-cli e2e - list-targets', () => { + it('list-targets shows available registries', async () => { + const cwd = '/test-project' + const fs = createDevScenarioFs(cwd) + // Add KB marker so local source validation passes when source='.' + await fs.writeFile(cwd + '/AGENTS.md', 'this is agents.md') + + await withTempConfig(fs, createTestConfig(), async () => { + // Mock the CLI execution by calling the update command with listTargets option + const { handleUpdateCommand, parseUpdateCommand } = (await import( + './commands/index.js' + )) as typeof import('./commands/index.js') + await handleUpdateCommand(parseUpdateCommand({ source: '.' }), fs) + + // The function no longer returns a value (success indicated by lack of throw) + }) + }) +}) diff --git a/apps/pair-cli/src/cli.e2e.test.ts b/apps/pair-cli/src/cli.e2e.test.ts deleted file mode 100644 index 9fa43c34..00000000 --- a/apps/pair-cli/src/cli.e2e.test.ts +++ /dev/null @@ -1,1507 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { - installCommand, - updateCommand, - handleUpdateCommand, - parseUpdateCommand, - handleUpdateLinkCommand, - handleKbValidateCommand, -} from './commands' - -function createNpmDeployFs(cwd: string): InMemoryFileSystemService { - // Simulate npm install: pair-cli extracted to node_modules/@foomakers/pair-cli/ - // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. - // We simulate the cached KB path so tests can resolve the dataset without network. - const seed: Record = {} - const moduleFolder = cwd + '/node_modules/@foomakers/pair-cli' - const kbCachePath = cwd + '/.pair-kb-cache/0.1.1' - - // Dataset content in the KB cache location (simulates auto-download result) - seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' - seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - - // Package.json for pair-cli (scoped package) — no bundle-cli/dataset - seed[moduleFolder + '/package.json'] = JSON.stringify({ - name: '@foomakers/pair-cli', - version: '0.1.1', - }) - - // Sample project package.json - seed[cwd + '/package.json'] = JSON.stringify({ - name: 'pair-sample-project', - version: '1.0.0', - dependencies: { - '@foomakers/pair-cli': 'file:../pkg/package', - }, - }) - - return new InMemoryFileSystemService(seed, moduleFolder, cwd) -} - -function createManualDeployFs(cwd: string): InMemoryFileSystemService { - // Simulate manual installation: pair-cli binary standalone. - // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. - const seed: Record = {} - const moduleFolder = cwd + '/libs/pair-cli' - const kbCachePath = cwd + '/.pair-kb-cache/0.1.0' - - // Add package.json for @pair/pair-cli package — no bundle-cli/dataset - seed[cwd + '/libs/pair-cli/package.json'] = JSON.stringify({ - name: '@pair/pair-cli', - version: '0.1.0', - description: 'Pair CLI manual installation', - }) - - // Dataset content in the KB cache location (simulates auto-download result) - seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' - seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - - return new InMemoryFileSystemService(seed, moduleFolder, cwd) -} - -function createDevScenarioFs(cwd: string): InMemoryFileSystemService { - // Simulate development scenario: pair-cli as regular node_modules dependency - // Dataset is at node_modules/@pair/knowledge-hub/dataset/ (accessible from project root) - const seed: Record = {} - - // Dataset content in the @pair/knowledge-hub package location (project's node_modules) - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/AGENTS.md'] = 'this is agents.md' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.github/workflows/ci.yml'] = - 'name: CI\non: push' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/knowledge/index.md'] = - '# Knowledge Base' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/adoption/onboarding.md'] = - '# Onboarding Guide' - - // Package.json for @pair/knowledge-hub - seed[cwd + '/node_modules/@pair/knowledge-hub/package.json'] = JSON.stringify({ - name: '@pair/knowledge-hub', - version: '0.1.0', - }) - - // Package.json for pair-cli (regular dependency) - seed[cwd + '/node_modules/pair-cli/package.json'] = JSON.stringify({ - name: 'pair-cli', - version: '0.1.0', - }) - - // Sample project package.json - seed[cwd + '/package.json'] = JSON.stringify({ - name: 'pair-cli', - version: '1.0.0-wip', - dependencies: { - '@pair/knowledge-hub': 'catalog:*', - }, - }) - - return new InMemoryFileSystemService(seed, cwd, cwd) -} - -async function withTempConfig( - fs: InMemoryFileSystemService, - config: unknown, - fn: () => Promise, -): Promise { - const configPath = fs.rootModuleDirectory() + '/config.json' - await fs.writeFile(configPath, JSON.stringify(config)) - try { - await fn() - } finally { - // Cleanup if needed - } -} - -function createTestConfig() { - return { - asset_registries: { - '.github': { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configs', - }, - '.pair-knowledge': { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair-knowledge', mode: 'canonical' }], - description: 'Knowledge base content', - }, - '.pair-adoption': { - source: '.pair/adoption', - behavior: 'mirror', - targets: [{ path: '.pair-adoption', mode: 'canonical' }], - description: 'Adoption and onboarding content', - }, - 'agents.md': { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - } -} - -function getDeploymentConfig(deployType: 'npm' | 'manual' | 'dev'): { - cwd: string - fs: InMemoryFileSystemService -} { - const cwd = - deployType === 'npm' - ? '/.tmp/npm-test/sample-project' - : deployType === 'manual' - ? '/tmp/test-project' - : '/dev/test-project' - const fs = - deployType === 'npm' - ? createNpmDeployFs(cwd) - : deployType === 'manual' - ? createManualDeployFs(cwd) - : createDevScenarioFs(cwd) - return { cwd, fs } -} - -describe('pair-cli e2e - validate-config success', () => { - it('validate-config succeeds with valid config', async () => { - const cwd = '/test-project' - const fs = createDevScenarioFs(cwd) - - await withTempConfig(fs, createTestConfig(), async () => { - // Mock the CLI execution by calling the validate-config logic directly - // Since we can't easily run the CLI binary in tests, we'll test the underlying function - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(true) - expect(validation.errors).toHaveLength(0) - }) - }) -}) - -describe('pair-cli e2e - validate-config failures basic', () => { - it('validate-config fails with invalid config', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - asset_registries: { - '.github': { - source: '.github', - behavior: 'invalid-behavior', // Invalid behavior - targets: [{ path: '.github', mode: 'canonical' }], - // Missing description - this should also be an error - }, - }, - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toHaveLength(2) // Invalid behavior + missing description - expect(validation.errors[0]).toContain('invalid behavior') - }) - }) - - it('validate-config fails with missing asset_registries', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - // Missing asset_registries - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toContain('Config must have asset_registries object') - }) - }) -}) - -describe('pair-cli e2e - validate-config failures advanced', () => { - it('validate-config fails with invalid registry structure', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - asset_registries: { - '.github': 'invalid-registry', // Should be an object - }, - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toContain("Registry '.github' must be a valid object") - }) - }) - - it('validate-config fails with invalid include array', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - asset_registries: { - '.github': { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows', - include: ['valid-string', 123], // Invalid: number in array - }, - }, - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toContain( - "Registry '.github' include array must contain only strings", - ) - }) - }) -}) - -describe('pair-cli e2e - list-targets', () => { - it('list-targets shows available registries', async () => { - const cwd = '/test-project' - const fs = createDevScenarioFs(cwd) - // Add KB marker so local source validation passes when source='.' - await fs.writeFile(cwd + '/AGENTS.md', 'this is agents.md') - - await withTempConfig(fs, createTestConfig(), async () => { - // Mock the CLI execution by calling the update command with listTargets option - const { handleUpdateCommand, parseUpdateCommand } = (await import( - './commands/index.js' - )) as typeof import('./commands/index.js') - await handleUpdateCommand(parseUpdateCommand({ source: '.' }), fs) - - // The function no longer returns a value (success indicated by lack of throw) - }) - }) -}) - -describe('pair-cli e2e - install from local sources', () => { - describe('install from local ZIP', () => { - it('installs from absolute path ZIP', async () => { - const cwd = '/test-absolute-zip' - const zipPath = 'kb.zip' - - // Create filesystem with ZIP file (simulated as binary content) - const seed: Record = {} - seed['/test-absolute-zip/kb.zip'] = - 'PK\x03\x04\x14\x00\x00\x00\x00\x00\x8d\x8f\x8bN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00\x00\x00AGENTS.mdthis is agents.md' // Minimal ZIP content - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', zipPath], { useDefaults: true }) - }) - - it('installs from relative path ZIP', async () => { - const cwd = '/test-relative-zip' - const zipPath = './downloads/kb.zip' - - const seed: Record = {} - seed['/test-relative-zip/downloads/kb.zip'] = - 'PK\x03\x04\x14\x00\x00\x00\x00\x00\x8d\x8f\x8bN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00\x00\x00AGENTS.mdthis is agents.md' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', zipPath], { useDefaults: true }) - }) - }) - - describe('install from local directory', () => { - it('installs from absolute path directory', async () => { - const cwd = '/test-absolute-dir' - const dirPath = 'dataset' - - const seed: Record = {} - seed[`${cwd}/${dirPath}/AGENTS.md`] = 'this is agents.md' - seed[`${cwd}/${dirPath}/.pair/knowledge/index.md`] = '# Knowledge Base' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', dirPath], { useDefaults: true }) - }) - - it('installs from relative path directory', async () => { - const cwd = '/test-relative-dir' - const dirPath = './dataset' - - const seed: Record = {} - seed['/test-relative-dir/dataset/AGENTS.md'] = 'this is agents.md' - seed['/test-relative-dir/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', dirPath], { useDefaults: true }) - }) - }) -}) - -describe('update from local sources', () => { - describe('update from local ZIP', () => { - it('updates from absolute path ZIP', async () => { - const cwd = '/test-absolute-zip' - const zipPath = 'dataset' - - // Create filesystem with extracted ZIP content (simulated as directory) - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed[`${cwd}/${zipPath}/AGENTS.md`] = 'this is agents.md' - seed[`${cwd}/${zipPath}/.github/workflows/ci.yml`] = 'name: CI\non: push' - seed[`${cwd}/${zipPath}/.pair/knowledge/index.md`] = '# Knowledge Base' - seed[`${cwd}/${zipPath}/.pair/adoption/onboarding.md`] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: zipPath }), fs) - }) - - it('updates from relative path ZIP', async () => { - const cwd = '/test-relative-zip' - const zipPath = './dataset' - - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed['/test-relative-zip/dataset/AGENTS.md'] = 'this is agents.md' - seed['/test-relative-zip/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed['/test-relative-zip/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed['/test-relative-zip/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: zipPath }), fs) - }) - }) - - describe('update from local directory', () => { - it('updates from absolute path directory', async () => { - const cwd = '/test-absolute-dir' - const dirPath = 'dataset' - - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed[`${cwd}/${dirPath}/AGENTS.md`] = 'this is agents.md' - seed[`${cwd}/${dirPath}/.github/workflows/ci.yml`] = 'name: CI\non: push' - seed[`${cwd}/${dirPath}/.pair/knowledge/index.md`] = '# Knowledge Base' - seed[`${cwd}/${dirPath}/.pair/adoption/onboarding.md`] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: dirPath }), fs) - }) - - it('updates from relative path directory', async () => { - const cwd = '/test-relative-dir' - const dirPath = './dataset' - - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed['/test-relative-dir/dataset/AGENTS.md'] = 'this is agents.md' - seed['/test-relative-dir/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed['/test-relative-dir/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed['/test-relative-dir/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: dirPath }), fs) - }) - }) -}) - -describe('pair-cli e2e - link strategy', () => { - it('install with relative link style', async () => { - const { fs } = getDeploymentConfig('dev') - await withTempConfig(fs, createTestConfig(), async () => { - const configPath = fs.rootModuleDirectory() + '/config.json' - const result = await installCommand(fs, [], { - customConfigPath: configPath, - useDefaults: true, - linkStyle: 'relative', - }) - expect((result as { success?: boolean }).success).toBe(true) - }) - }) - - it('update with absolute link style', async () => { - const { fs } = getDeploymentConfig('dev') - await withTempConfig(fs, createTestConfig(), async () => { - const configPath = fs.rootModuleDirectory() + '/config.json' - // First install to set up targets - await installCommand(fs, [], { customConfigPath: configPath, useDefaults: true }) - // Then update with absolute style and defaults - const result = await updateCommand(fs, [], { useDefaults: true, linkStyle: 'absolute' }) - expect((result as { success?: boolean }).success).toBe(true) - }) - }) - - it('update with auto link style detection', async () => { - const { fs } = getDeploymentConfig('dev') - await withTempConfig(fs, createTestConfig(), async () => { - const configPath = fs.rootModuleDirectory() + '/config.json' - // First install to establish baseline - await installCommand(fs, [], { customConfigPath: configPath, useDefaults: true }) - // Then update with auto detection and defaults - await updateCommand(fs, [], { useDefaults: true, linkStyle: 'auto' }) - // Auto detection should succeed - }) - }) -}) - -describe('pair-cli e2e - error scenarios', () => { - it('update fails gracefully when source directory does not exist', async () => { - const cwd = '/test-no-source' - const seed: Record = { - [cwd + '/config.json']: JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub config', - }, - }, - }), - } - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - await expect( - handleUpdateCommand(parseUpdateCommand({ source: '/nonexistent/path' }), fs), - ).rejects.toThrow('KB source path not found') - }) - - it('install from ZIP fails gracefully when ZIP is corrupted', async () => { - const cwd = '/test-bad-zip' - const seed: Record = { - [cwd + '/config.json']: JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub config', - }, - }, - }), - [cwd + '/bad.zip']: 'not a valid zip file', - } - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - const result = await installCommand(fs, ['--source', 'bad.zip'], { useDefaults: true }) - expect(result).toBeDefined() - // May succeed or fail depending on implementation, just ensure it doesn't crash - }) -}) - -describe('pair-cli e2e - package command', () => { - it('package command creates valid ZIP with manifest', async () => { - const cwd = '/test-package' - - const seed: Record = {} - // Create dataset source structure - seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS documentation' - seed[cwd + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[cwd + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[cwd + '/config.json'] = JSON.stringify({ - asset_registries: { - knowledge: { - source: '.', - behavior: 'mirror', - targets: [{ path: '.', mode: 'canonical' }], - description: 'Knowledge base dataset', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Test that we can create a package structure - // The actual executePackage function is internal, but we can test it via options validation - const configPath = cwd + '/config.json' - const config = JSON.parse(fs.readFileSync(configPath)) - expect(config.asset_registries).toBeDefined() - expect(config.asset_registries.knowledge).toBeDefined() - }) - - it('package command with --source-dir option validates config', async () => { - const cwd = '/test-package-source-dir' - - const seed: Record = {} - seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS' - seed[cwd + '/config.json'] = JSON.stringify({ - asset_registries: { - content: { - source: '.', - behavior: 'mirror', - targets: [{ path: '.', mode: 'canonical' }], - description: 'Content', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Verify config can be loaded - const configPath = cwd + '/config.json' - const config = JSON.parse(fs.readFileSync(configPath)) - expect(config.asset_registries.content).toBeDefined() - }) - - it('package command fails gracefully with invalid config', async () => { - const cwd = '/test-package-bad-config' - - const seed: Record = {} - seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS' - seed[cwd + '/config.json'] = '{ invalid json' - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Verify that parsing invalid config fails - const configPath = cwd + '/config.json' - expect(() => { - JSON.parse(fs.readFileSync(configPath)) - }).toThrow() - }) - - it('package command fails gracefully with missing config', async () => { - const cwd = '/test-package-no-config' - - const seed: Record = { - [cwd + '/dataset/AGENTS.md']: '# AGENTS', - } - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Verify that reading missing config fails - const configPath = cwd + '/nonexistent.json' - expect(() => { - fs.readFileSync(configPath) - }).toThrow() - }) -}) - -describe('pair-cli e2e - preferences persistence', () => { - it('saves and reads preferences with custom directory', async () => { - const { readPreferences, savePreferences } = await import('./commands/package/preferences.js') - const cwd = '/test-prefs' - const prefsDir = `${cwd}/.custom-pair` - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const data = { - packageMetadata: { name: 'my-kb', author: 'Test Author', tags: ['ai', 'kb'] }, - updatedAt: '2026-02-17T00:00:00.000Z', - } - - await savePreferences(data, fs, prefsDir) - const result = readPreferences(fs, prefsDir) - - expect(result).toEqual(data) - }) - - it('preferences from one session become defaults in next', async () => { - const { readPreferences, savePreferences } = await import('./commands/package/preferences.js') - const { resolveDefaults } = await import('./commands/package/defaults-resolver.js') - - const cwd = '/test-prefs-flow' - const prefsDir = `${cwd}/.pair` - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - // Session 1: save preferences - await savePreferences( - { - packageMetadata: { name: 'saved-kb', author: 'Saved Author', license: 'Apache-2.0' }, - updatedAt: new Date().toISOString(), - }, - fs, - prefsDir, - ) - - // Session 2: read preferences and resolve defaults - const prefs = readPreferences(fs, prefsDir) - const defaults = resolveDefaults({ preferences: prefs?.packageMetadata }) - - expect(defaults.name).toBe('saved-kb') - expect(defaults.author).toBe('Saved Author') - expect(defaults.license).toBe('Apache-2.0') - // Non-saved fields fall back to hardcoded - expect(defaults.version).toBe('1.0.0') - }) - - it('package command produces ZIP with non-optional manifest fields', async () => { - const { handlePackageCommand } = await import('./commands/package/handler.js') - const cwd = '/test-pkg-manifest' - const seed: Record = { - [`${cwd}/config.json`]: JSON.stringify({ - asset_registries: { - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'KB content', - }, - }, - }), - [`${cwd}/.pair/knowledge/doc.md`]: '# Doc', - } - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - const outputPath = `${cwd}/dist/kb.zip` - - await handlePackageCommand( - { - command: 'package', - output: outputPath, - interactive: false, - tags: ['ai', 'devops'], - license: 'Apache-2.0', - }, - fs, - ) - - expect(await fs.exists(outputPath)).toBe(true) - - // Extract and verify manifest has all required fields - const extractDir = `${cwd}/extracted` - await fs.extractZip(outputPath, extractDir) - const manifest = JSON.parse(await fs.readFile(`${extractDir}/manifest.json`)) - - expect(manifest.tags).toEqual(['ai', 'devops']) - expect(manifest.license).toBe('Apache-2.0') - expect(manifest.description).toBe('Knowledge base package') // default - expect(manifest.author).toBe('unknown') // default - expect(typeof manifest.name).toBe('string') - expect(typeof manifest.version).toBe('string') - expect(typeof manifest.created_at).toBe('string') - }) -}) - -describe('pair-cli e2e - disjoint installation (source and target disjoint)', () => { - it('installs KB to a disjoint absolute path', async () => { - const projectRoot = '/test-project' - const disjointTarget = '/opt/pair/kb' - const kbSourceDir = '/mnt/external/kb-dataset' - - // 1. Setup Filesystem - const seed: Record = { - // Configuration in the "project root" - [`${projectRoot}/config.json`]: JSON.stringify({ - asset_registries: { - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: 'knowledge', mode: 'canonical' }], - description: 'Core knowledge', - }, - }, - }), - [`${projectRoot}/package.json`]: JSON.stringify({ - name: 'test-project', - version: '1.0.0', - }), - // KB Source content in a disjoint directory - [`${kbSourceDir}/AGENTS.md`]: '# KB source marker', - [`${kbSourceDir}/knowledge/index.md`]: '# Knowledge Index', - [`${kbSourceDir}/knowledge/guide.md`]: 'Follow the [Index](./index.md)', - } - - const fs = new InMemoryFileSystemService(seed, projectRoot, projectRoot) - - // 2. Perform installation to disjoint target - // pair install /opt/pair/kb --source /mnt/external/kb-dataset - await installCommand(fs, ['--source', kbSourceDir], { - baseTarget: disjointTarget, - useDefaults: true, - }) - - // 3. Verify installation in disjoint target - // The target path for the 'knowledge' registry should be /opt/pair/kb/knowledge - const installedFile = `${disjointTarget}/knowledge/index.md` - expect(fs.existsSync(installedFile)).toBe(true) - expect(fs.readFileSync(installedFile)).toBe('# Knowledge Index') - - // 4. Test disjoint update - // Add new file to source - await fs.writeFile(`${kbSourceDir}/knowledge/new.md`, 'New content') - - // pair update /opt/pair/kb --source /mnt/external/kb-dataset - await handleUpdateCommand( - { - command: 'update', - resolution: 'local', - path: kbSourceDir, - kb: true, - offline: true, - target: disjointTarget, - }, - fs, - ) - - expect(fs.existsSync(`${disjointTarget}/knowledge/new.md`)).toBe(true) - - // 5. Test disjoint update-link - // pair update-link /opt/pair/kb - await handleUpdateLinkCommand( - { - command: 'update-link', - target: disjointTarget, - dryRun: false, - logLevel: 'debug', - }, - fs, - ) - - // Verify rollback setup is working even in disjoint paths (implicitly tested by logic running) - const installedGuide = `${disjointTarget}/knowledge/guide.md` - expect(fs.existsSync(installedGuide)).toBe(true) - }) -}) - -describe('pair-cli e2e - skills registry pipeline', () => { - function createSkillsConfig() { - return { - asset_registries: { - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - description: 'Knowledge base content', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - }, - skills: { - source: '.skills', - behavior: 'mirror', - flatten: true, - prefix: 'pair', - description: 'Agent skills distributed to AI tool directories', - targets: [ - { path: '.claude/skills/', mode: 'canonical' }, - { path: '.github/skills/', mode: 'symlink' }, - { path: '.cursor/skills/', mode: 'symlink' }, - ], - }, - }, - } - } - - function createSkillsDatasetFs(cwd: string, opts?: { withTargets?: boolean }) { - const seed: Record = {} - const datasetBase = `${cwd}/dataset` - - // Pre-existing targets (update scenario — project already installed) - if (opts?.withTargets) { - seed[`${cwd}/.pair/knowledge/old.md`] = '# old' - } - - // Knowledge registry content - seed[`${datasetBase}/.pair/knowledge/index.md`] = '# Knowledge Base' - - // Skills registry content — mimics real .skills/ structure - seed[`${datasetBase}/.skills/next/SKILL.md`] = - '---\nname: next\ndescription: Project navigator\n---\n# /next' - - seed[`${cwd}/config.json`] = JSON.stringify(createSkillsConfig()) - - return new InMemoryFileSystemService(seed, cwd, cwd) - } - - it('update distributes skills with flatten + prefix to canonical target', async () => { - const cwd = '/test-skills' - const fs = createSkillsDatasetFs(cwd, { withTargets: true }) - - await handleUpdateCommand(parseUpdateCommand({ source: `${cwd}/dataset` }), fs) - - // Flatten is no-op for single-segment 'next', prefix 'pair' → 'pair-next' - expect(fs.existsSync(`${cwd}/.claude/skills/pair-next/SKILL.md`)).toBe(true) - const content = fs.readFileSync(`${cwd}/.claude/skills/pair-next/SKILL.md`) - expect(content).toContain('name: pair-next') - }) - - it('update creates symlinks for secondary targets', async () => { - const cwd = '/test-skills-symlink' - const fs = createSkillsDatasetFs(cwd, { withTargets: true }) - - await handleUpdateCommand(parseUpdateCommand({ source: `${cwd}/dataset` }), fs) - - // Secondary targets should be symlinks pointing to the canonical path - const symlinks = fs.getSymlinks() - const canonicalPath = `${cwd}/.claude/skills` - const githubSymlink = `${cwd}/.github/skills` - const cursorSymlink = `${cwd}/.cursor/skills` - - expect(symlinks.get(githubSymlink)).toBe(canonicalPath) - expect(symlinks.get(cursorSymlink)).toBe(canonicalPath) - }) - - it('validate-config succeeds with skills registry config', async () => { - const cwd = '/test-skills-validate' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - await withTempConfig(fs, createSkillsConfig(), async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(true) - expect(validation.errors).toHaveLength(0) - }) - }) - - it('validate-config fails when skills registry has no canonical target', async () => { - const cwd = '/test-skills-no-canonical' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const badConfig = { - asset_registries: { - skills: { - source: '.skills', - behavior: 'mirror', - flatten: true, - prefix: 'pair', - description: 'Skills with no canonical target', - targets: [ - { path: '.github/skills/', mode: 'symlink' }, - { path: '.cursor/skills/', mode: 'symlink' }, - ], - }, - }, - } - - await withTempConfig(fs, badConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors.some(e => e.includes('canonical'))).toBe(true) - }) - }) - - it('install distributes skills to canonical and symlink targets', async () => { - const cwd = '/test-skills-install' - const fs = createSkillsDatasetFs(cwd) - - await installCommand(fs, ['--source', `${cwd}/dataset`], { useDefaults: true }) - - // Canonical target should have flattened+prefixed content - expect(fs.existsSync(`${cwd}/.claude/skills/pair-next/SKILL.md`)).toBe(true) - - // Secondary targets should be symlinks - const symlinks = fs.getSymlinks() - expect(symlinks.has(`${cwd}/.github/skills`)).toBe(true) - expect(symlinks.has(`${cwd}/.cursor/skills`)).toBe(true) - }) -}) - -// ── kb-validate E2E tests ────────────────────────────────────────────── - -describe('pair-cli e2e - kb-validate', () => { - function createFullConfig() { - return { - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents'], - description: 'GitHub config', - targets: [{ path: '.github', mode: 'canonical' }], - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - description: 'KB content', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - description: 'Adoption guides', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - }, - agents: { - source: 'AGENTS.md', - behavior: 'mirror', - description: 'Agent guidance', - targets: [ - { path: 'AGENTS.md', mode: 'canonical' }, - { path: 'CLAUDE.md', mode: 'copy' }, - ], - }, - skills: { - source: '.skills', - behavior: 'mirror', - flatten: true, - prefix: 'pair', - description: 'Agent skills', - targets: [ - { path: '.claude/skills/', mode: 'canonical' }, - { path: '.github/skills/', mode: 'symlink' }, - { path: '.cursor/skills/', mode: 'symlink' }, - ], - }, - }, - } - } - - function createSourceLayoutFs(cwd: string): InMemoryFileSystemService { - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' - seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' - seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/.skills/next/SKILL.md`] = - '---\nname: next\ndescription: Project navigator\n---\n# /next' - seed[`${cwd}/.skills/capability/assess-stack/SKILL.md`] = - '---\nname: assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' - return new InMemoryFileSystemService(seed, cwd, cwd) - } - - function createTargetLayoutFs(cwd: string): InMemoryFileSystemService { - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' - seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' - seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/CLAUDE.md`] = '# CLAUDE' - // Target layout: skills at canonical target with prefix applied - seed[`${cwd}/.claude/skills/pair-next/SKILL.md`] = - '---\nname: pair-next\ndescription: Project navigator\n---\n# /next' - seed[`${cwd}/.claude/skills/pair-capability-assess-stack/SKILL.md`] = - '---\nname: pair-capability-assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' - // Symlink targets NOT present (they'd be symlinks in real FS) - return new InMemoryFileSystemService(seed, cwd, cwd) - } - - describe('source layout validation', () => { - it('validates valid source layout — exit 0', async () => { - const cwd = '/e2e-validate-source' - const fs = createSourceLayoutFs(cwd) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).resolves.toBeUndefined() - }) - - it('detects missing registry in source layout', async () => { - const cwd = '/e2e-validate-source-missing' - // Create FS without adoption source dir — structure validator reports error - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/.skills/next/SKILL.md`] = '---\nname: next\ndescription: Nav\n---\n# /next' - // .pair/adoption is missing entirely - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - }) - - describe('target layout validation', () => { - it('validates valid target layout — exit 0', async () => { - const cwd = '/e2e-validate-target' - const fs = createTargetLayoutFs(cwd) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).resolves.toBeUndefined() - }) - - it('symlink targets ignored — no error for missing .github/skills', async () => { - const cwd = '/e2e-validate-target-symlink' - const fs = createTargetLayoutFs(cwd) - // .github/skills and .cursor/skills are symlink targets — should NOT be checked - expect(fs.existsSync(`${cwd}/.github/skills`)).toBe(false) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).resolves.toBeUndefined() - }) - - it('detects missing registry in target layout', async () => { - const cwd = '/e2e-validate-target-missing' - // Create FS without knowledge target dir — structure validator reports error - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/CLAUDE.md`] = '# CLAUDE' - seed[`${cwd}/.claude/skills/pair-next/SKILL.md`] = - '---\nname: pair-next\ndescription: Nav\n---\n# /next' - // .pair/knowledge is missing entirely - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).rejects.toThrow( - 'Validation failed', - ) - }) - }) - - describe('registry filtering', () => { - it('--skip-registries excludes specified registries', async () => { - const cwd = '/e2e-validate-skip' - // Create FS without adoption source dir — would fail without skip - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/.skills/next/SKILL.md`] = '---\nname: next\ndescription: Nav\n---\n# /next' - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Skipping adoption should pass despite missing .pair/adoption - await expect( - handleKbValidateCommand( - { command: 'kb-validate', layout: 'source', skipRegistries: ['adoption'] }, - fs, - ), - ).resolves.toBeUndefined() - }) - - it('--ignore-config skips structure validation', async () => { - const cwd = '/e2e-validate-ignore' - const fs = new InMemoryFileSystemService( - { - [`${cwd}/.pair/knowledge/index.md`]: '# KB', - }, - cwd, - cwd, - ) - // No config.json — would fail without --ignore-config - - await expect( - handleKbValidateCommand({ command: 'kb-validate', ignoreConfig: true }, fs), - ).resolves.toBeUndefined() - }) - }) - - describe('link integrity', () => { - it('detects broken internal links', async () => { - const cwd = '/e2e-validate-links' - const fs = createSourceLayoutFs(cwd) - // Replace valid link with broken one - await fs.writeFile( - `${cwd}/.pair/knowledge/index.md`, - '# Knowledge\n\nSee [missing](./nonexistent.md).', - ) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - - it('valid internal links pass', async () => { - const cwd = '/e2e-validate-links-valid' - const fs = createSourceLayoutFs(cwd) - // index.md links to guide.md which exists - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).resolves.toBeUndefined() - }) - }) - - describe('metadata and skills validation', () => { - it('detects missing SKILL.md frontmatter', async () => { - const cwd = '/e2e-validate-meta-missing' - const fs = createSourceLayoutFs(cwd) - // SKILL.md without frontmatter - await fs.writeFile(`${cwd}/.skills/next/SKILL.md`, '# /next\n\nNo frontmatter here.') - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - - it('detects missing required name field in frontmatter', async () => { - const cwd = '/e2e-validate-meta-field' - const fs = createSourceLayoutFs(cwd) - // SKILL.md with frontmatter but missing name - await fs.writeFile( - `${cwd}/.skills/next/SKILL.md`, - '---\ndescription: Navigator\n---\n# /next', - ) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - - it('warns about adoption placeholders without failing', async () => { - const cwd = '/e2e-validate-placeholder' - const fs = createSourceLayoutFs(cwd) - // Adoption file with placeholder — should warn but not error - await fs.writeFile(`${cwd}/.pair/adoption/tech-stack.md`, '# Tech Stack\n\n[placeholder]') - - // Warnings don't cause failure - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).resolves.toBeUndefined() - }) - }) - - describe('exit codes and error handling', () => { - it('throws when .pair directory missing', async () => { - const cwd = '/e2e-validate-nopair' - const fs = new InMemoryFileSystemService({ [`${cwd}/README.md`]: '# Project' }, cwd, cwd) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).rejects.toThrow( - 'missing .pair directory', - ) - }) - - it('mixed errors and warnings — exit 1', async () => { - const cwd = '/e2e-validate-mixed' - const fs = createSourceLayoutFs(cwd) - // Broken link (error) + placeholder (warning) - await fs.writeFile( - `${cwd}/.pair/knowledge/index.md`, - '# KB\n\nSee [broken](./nonexistent.md).', - ) - await fs.writeFile(`${cwd}/.pair/adoption/tech-stack.md`, '# Tech\n\n[placeholder]') - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - }) -}) - -describe('pair-cli e2e - org packaging', () => { - it('parsePackageCommand includes org fields when --org flag present', async () => { - const { parsePackageCommand } = await import('./commands/package/parser.js') - - const config = parsePackageCommand({ - org: true, - orgName: 'Acme Corp', - team: 'Platform', - compliance: 'SOC2,ISO27001', - distribution: 'private', - }) - - expect(config.org).toBe(true) - expect(config.orgName).toBe('Acme Corp') - expect(config.team).toBe('Platform') - expect(config.compliance).toEqual(['SOC2', 'ISO27001']) - expect(config.distribution).toBe('private') - }) - - it('parsePackageCommand omits org fields when --org absent', async () => { - const { parsePackageCommand } = await import('./commands/package/parser.js') - - const config = parsePackageCommand({ name: 'test-kb' }) - - expect(config.org).toBeUndefined() - expect(config.orgName).toBeUndefined() - }) - - it('org template merges with CLI flags', async () => { - const { loadOrgTemplate, mergeOrgDefaults } = await import('./commands/package/org-template.js') - - const cwd = '/e2e-org-template' - const fs = new InMemoryFileSystemService( - { - [`${cwd}/.pair/org-template.json`]: JSON.stringify({ - name: 'Template Corp', - team: 'Default Team', - compliance: ['SOC2'], - distribution: 'restricted', - }), - }, - cwd, - cwd, - ) - - const template = await loadOrgTemplate(cwd, fs, '.pair/org-template.json') - expect(template).not.toBeNull() - - // CLI flags override template - const org = mergeOrgDefaults({ orgName: 'CLI Corp', team: 'CLI Team' }, template) - expect(org.name).toBe('CLI Corp') - expect(org.team).toBe('CLI Team') - // Template values used as fallback - expect(org.compliance).toEqual(['SOC2']) - expect(org.distribution).toBe('restricted') - }) - - it('createOrganizationMetadata factory provides defaults', async () => { - const { createOrganizationMetadata } = await import('./commands/package/metadata.js') - - const org = createOrganizationMetadata({ name: 'Acme' }) - expect(org.compliance).toEqual([]) - expect(org.distribution).toBe('open') - }) - - it('generateManifestMetadata includes organization in manifest', async () => { - const { generateManifestMetadata, createOrganizationMetadata } = await import( - './commands/package/metadata.js' - ) - - const org = createOrganizationMetadata({ - name: 'Acme', - team: 'Platform', - compliance: ['SOC2'], - distribution: 'private', - }) - - const manifest = generateManifestMetadata(['knowledge'], { organization: org }) - expect(manifest.organization).toBeDefined() - expect(manifest.organization?.name).toBe('Acme') - expect(manifest.organization?.distribution).toBe('private') - }) -}) From d5c1a837c2c209746d563c8b0e6d19af087f8f08 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 01:44:50 +0200 Subject: [PATCH 10/21] [#199] fix: address PR #311 review nits (catalog jscpd, CI gates, stale DR doc) - jscpd -> workspace catalog + catalog: ref in root (kills catalog drift) - CI: add hygiene:check / docs:staleness / dup:check discrete steps - design-rules.md: reframe DR-1/DR-2/DR-3 evidence + migration plan as historical (all originating instances resolved in #199) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 6 +++ package.json | 2 +- .../design-principles/design-rules.md | 29 ++++++++------- pnpm-lock.yaml | 37 +++++++++++-------- pnpm-workspace.yaml | 1 + 5 files changed, 44 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39b3322f..979345ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,12 @@ jobs: run: pnpm build - name: Run lint run: pnpm lint + - name: Run code-hygiene check + run: pnpm hygiene:check + - name: Run docs-staleness check + run: pnpm docs:staleness + - name: Run duplication check + run: pnpm dup:check - name: Install Playwright browsers run: | pnpm --filter @pair/brand exec playwright install --with-deps chromium diff --git a/package.json b/package.json index 82aa30fa..b215bc1d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@changesets/cli": "catalog:", "@pair/prettier-config": "workspace:*", "husky": "catalog:", - "jscpd": "^5.0.12", + "jscpd": "catalog:", "turbo": "catalog:" }, "packageManager": "pnpm@10.15.0", diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md index 7f8f6cf5..19b4e865 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md @@ -32,7 +32,7 @@ export async function handleMirrorCleanup(...) {} // copy-orchestrator.ts — dispatch + shared setup ``` -**Evidence**: #199 P0.5 `copyPathOps.ts` (705 LOC / 19 functions) · P0.3 `cli.e2e.test.ts` (1507 LOC, one file per CLI command instead) · P2.2 `dev/App.tsx` (456 LOC / 11 inline sections) · P2.3 `in-memory-fs.ts` (429 LOC) — recurring cluster in `content-ops/src/ops/` (4 of the audit's top-10 largest files). See Migration Plan below for per-instance priority. +**Evidence** (historical — all resolved in #199): `copyPathOps.ts` (705 LOC / 19 functions), `cli.e2e.test.ts` (1507 LOC), `dev/App.tsx` (456 LOC / 11 inline sections), `in-memory-fs.ts` (429 LOC) — a recurring cluster in `content-ops/src/ops/` (4 of the 2026-04-17 audit's top-10 largest files), each since split along its natural seams. See Migration Plan below. ## DR-2 — Static-Only Namespace Class @@ -41,6 +41,7 @@ export async function handleMirrorCleanup(...) {} **Recognition**: a class where every method is `static` and no instance property/state exists — it's a namespace pretending to be an object. ```typescript +// Illustrative anti-pattern (not live code): export class LinkProcessor { static async extractLinks(content: string) {} static async extractLinksFromFile(path: string, fs: FileSystemService) {} @@ -56,7 +57,7 @@ export async function extractLinks(content: string) {} export async function extractLinksFromFile(path: string, fs: FileSystemService) {} ``` -**Evidence**: #199 P0.4 `markdown/link-processor.ts:42-404` — `class LinkProcessor` with 18 static methods; the fix direction was already emerging in the file's own compat re-exports (`link-processor.ts:406-421`, standalone `extractLinks`/`detectLinkStyle` functions wrapping the static calls). See Migration Plan below. +**Evidence** (historical — resolved in #199): `markdown/link-processor.ts` once exposed a `class LinkProcessor` with 18 static methods; #199 converted it to a module of named function exports (the direction the file's own compat re-exports were already pointing). See Migration Plan below. ## DR-3 — Optional-Bag Dispatch Instead of Discriminated Union @@ -93,22 +94,22 @@ switch (op.kind) { } ``` -**Evidence**: #199 P1.3 `content-ops/src/ops/movePathOps.ts:189` (`MoveCtx = Partial<...>`) with non-null assertions at the two dispatch sites (`movePathOps.ts:157-158`, `172-173`). See Migration Plan below. +**Evidence** (historical — non-null assertions resolved in #199): `content-ops/src/ops/movePathOps.ts` once modelled `MoveCtx` as `Partial<...>` with `ctx.source!` assertions at the two dispatch sites. #199 made `MoveCtx` a total type and dropped the assertions; a full discriminated-union rewrite was judged N/A here (the branch is unknown at ctx-build time). See Migration Plan below. -## Migration Plan (existing violations) +## Migration Plan (originating instances) -Current, concrete instances found while extracting these rules from #199 — not blocking, tracked as tech-debt. This list is the seed for `pair-capability-assess-debt` scan mode (#224): it converts findings like these into `tech-debt` Draft items (P1–P3) so they live in the backlog instead of only in this doc. +The concrete instances found while extracting these rules from the 2026-04-17 audit. All were resolved in #199, so they are recorded here as the rules' provenance rather than as open work. -| Rule | Location | Priority | Note | -| ---- | -------- | -------- | ---- | -| DR-1 | `content-ops/src/ops/copyPathOps.ts` (705 LOC) | P1 | split per #199 suggested execution: `copy-file.ts` / `copy-directory.ts` / `copy-orchestrator.ts` | -| DR-1 | `apps/pair-cli/src/cli.e2e.test.ts` (1507 LOC) | P1 | split per CLI command (install / update / kb-validate / update-link) | -| DR-1 | `packages/brand/dev/App.tsx` (456 LOC) | P2 | dev-only harness; extract sections to `dev/sections/*` | -| DR-1 | `packages/content-ops/src/test-utils/in-memory-fs.ts` (429 LOC) | P2 | test-only; split read/write/seed helpers | -| DR-2 | `packages/content-ops/src/markdown/link-processor.ts` (`class LinkProcessor`) | P1 | convert to named exports; re-exports already exist for the compat path | -| DR-3 | `packages/content-ops/src/ops/movePathOps.ts:189` (`MoveCtx`) | P2 | opportunistic, when the file is next touched | +| Rule | Location | Status | +| ---- | -------- | ------ | +| DR-1 | `content-ops/src/ops/copyPathOps.ts` (705 LOC) | Resolved in #199 — split into `copy-file` / `copy-directory` / `copy-directory-transforms` / `copy-types` + orchestrator | +| DR-1 | `apps/pair-cli/src/cli.e2e.test.ts` (1507 LOC) | Resolved in #199 — split into per-command e2e files + shared helpers | +| DR-1 | `packages/brand/dev/App.tsx` (456 LOC) | Resolved in #199 — sections extracted to `dev/sections/*` (App.tsx → 51 LOC) | +| DR-1 | `packages/content-ops/src/test-utils/in-memory-fs.ts` (429 LOC) | Resolved in #199 — split into state + read/write/seed modules | +| DR-2 | `packages/content-ops/src/markdown/link-processor.ts` (`class LinkProcessor`) | Resolved in #199 — converted to named function exports | +| DR-3 | `packages/content-ops/src/ops/movePathOps.ts` (`MoveCtx`) | Resolved in #199 — `MoveCtx` made total, `ctx.source!` assertions dropped (discriminated-union rewrite N/A) | -**Note**: no `tech-debt` Draft items are created by this story. Item creation from this table is deferred to #224 (`pair-capability-assess-debt` scan mode). +**Note**: these originating instances are cleared. New violations found by later audits are tracked as `tech-debt` Draft items (P1–P3) via `pair-capability-assess-debt` scan mode (#224), not appended here. ## Related diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f543fde9..9c9d6808 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ catalogs: husky: specifier: 8.0.0 version: 8.0.0 + jscpd: + specifier: ^5.0.12 + version: 5.0.12 jsdom: specifier: 25.0.1 version: 25.0.1 @@ -183,7 +186,7 @@ importers: specifier: 'catalog:' version: 8.0.0 jscpd: - specifier: ^5.0.12 + specifier: 'catalog:' version: 5.0.12 turbo: specifier: 'catalog:' @@ -260,16 +263,16 @@ importers: version: 2.0.13 fumadocs-core: specifier: 'catalog:' - version: 14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) fumadocs-mdx: specifier: 'catalog:' - version: 11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)) + version: 11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)) fumadocs-ui: specifier: 'catalog:' - version: 14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))) + version: 14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))) next: specifier: 'catalog:' - version: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -7881,7 +7884,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@formatjs/intl-localematcher': 0.5.10 '@orama/orama': 2.1.1 @@ -7899,21 +7902,21 @@ snapshots: shiki: 2.5.0 unist-util-visit: 5.1.0 optionalDependencies: - next: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 react-dom: 19.0.0(react@19.0.0) transitivePeerDependencies: - '@types/react' - supports-color - fumadocs-mdx@11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)): + fumadocs-mdx@11.10.1(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 4.0.3 esbuild: 0.25.12 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) js-yaml: 4.1.1 lru-cache: 11.2.6 picocolors: 1.1.1 @@ -7925,13 +7928,13 @@ snapshots: unist-util-visit: 5.1.0 zod: 4.3.6 optionalDependencies: - next: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 vite: 7.2.6(@types/node@24.3.0)(jiti@1.21.7)(yaml@2.8.2) transitivePeerDependencies: - supports-color - fumadocs-ui@14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))): + fumadocs-ui@14.7.7(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(fumadocs-core@14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.4.5))): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -7943,10 +7946,10 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.0.6)(react@19.0.0) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.0.6(@types/react@19.0.6))(@types/react@19.0.6)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) class-variance-authority: 0.7.1 - fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + fumadocs-core: 14.7.7(@types/react@19.0.6)(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) lodash.merge: 4.6.2 lucide-react: 0.473.0(react@19.0.0) - next: 15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-themes: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) postcss-selector-parser: 7.1.1 react: 19.0.0 @@ -9101,7 +9104,7 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.5.12 '@swc/helpers': 0.5.15 @@ -9109,7 +9112,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.5.12 '@next/swc-darwin-x64': 15.5.12 @@ -9923,10 +9926,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.0.0): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 + optionalDependencies: + '@babel/core': 7.29.0 sucrase@3.35.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ab9ad2b..58d4ccda 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,6 +37,7 @@ catalog: eslint-plugin-react-hooks: 5.1.0 globals: 15.0.0 husky: 8.0.0 + jscpd: ^5.0.12 jsdom: 25.0.1 markdown-it: 14.1.0 markdownlint-cli: 0.47.0 From a762c40286029f46f6379fdbd15d1872b1b7caeb Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 11:31:13 +0200 Subject: [PATCH 11/21] [#199] fix: wire coverage thresholds into CI, reverse trend-only decision CI now runs test:coverage for brand+website after the test step, so their vitest coverage.thresholds actually gate PRs instead of biting only on explicit runs. Amends the 2026-07-12 ADL to record the reversal per explicit user direction. --- .github/workflows/ci.yml | 2 ++ .../2026-07-12-website-coverage-baseline.md | 24 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 979345ec..812c3eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,5 +71,7 @@ jobs: pnpm --filter @pair/website exec playwright install --with-deps chromium - name: Run tests run: pnpm test + - name: Run coverage thresholds (brand, website) + run: pnpm turbo test:coverage --filter=@pair/brand --filter=@pair/website - name: Run E2E tests run: pnpm --filter @pair/website e2e diff --git a/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md b/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md index 355b3371..2c27cd43 100644 --- a/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md +++ b/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md @@ -6,7 +6,7 @@ ## Status -Active +Active (amended 2026-07-12 — see Update below: thresholds now gate CI) ## Category @@ -64,3 +64,25 @@ gate normal PRs. - No change to `adoption/tech/tech-stack.md` (vitest + @vitest/coverage-v8 already adopted). This ADL is the record of the per-package threshold policy. + +## Update — 2026-07-12: thresholds now gate CI (reverses "bite only on explicit runs") + +Original text above ("Note: the quality-gate command runs `test`, not +`test:coverage`, so these thresholds bite only on explicit coverage runs / +trend tracking — they do not gate normal PRs.") is superseded per explicit +user direction: silent coverage regression is not acceptable even for a low +baseline floor. + +- `.github/workflows/ci.yml` gained a `Run coverage thresholds (brand, + website)` step running `pnpm turbo test:coverage --filter=@pair/brand + --filter=@pair/website` after the existing `Run tests` step. A regression + below either package's `coverage.thresholds` now fails the CI job (vitest's + v8 coverage provider exits non-zero when a configured threshold is + violated). +- The baseline floors themselves (website: 9/9/40/60, brand: 80/80/80/80) are + unchanged by this update — only their enforcement moved from "trend-only" + to "PR-gating". +- Adoption Impact update: `.pair/adoption/tech/way-of-working.md` Quality + Gates registry is unchanged (CI is a separate gate from `pnpm quality-gate` + in this project's convention); this ADL plus the CI diff is the record of + the enforcement change. From 2147810ea02dd188bdf4ca69885ebb9608b39ec6 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 11:31:15 +0200 Subject: [PATCH 12/21] [#199] docs: record one-PR-per-story as default in way-of-working + ADL Story #199's T1-T10 batched into a single PR was flagged by review as a batching deviation; user confirmed this is intentional and should be the default. Adds the convention to way-of-working.md (consulted convention) and an ADL recording the why (historical record). --- .../2026-07-12-one-pr-per-story-default.md | 63 +++++++++++++++++++ .pair/adoption/tech/way-of-working.md | 1 + 2 files changed, 64 insertions(+) create mode 100644 .pair/adoption/decision-log/2026-07-12-one-pr-per-story-default.md diff --git a/.pair/adoption/decision-log/2026-07-12-one-pr-per-story-default.md b/.pair/adoption/decision-log/2026-07-12-one-pr-per-story-default.md new file mode 100644 index 00000000..94b02ff7 --- /dev/null +++ b/.pair/adoption/decision-log/2026-07-12-one-pr-per-story-default.md @@ -0,0 +1,63 @@ +# Decision: One PR per story is the default + +## Date + +2026-07-12 + +## Status + +Active + +## Category + +Process Decision + +## Context + +Story #199 (tech-debt ledger) was broken into inline tasks T1-T10 within a +single story issue. All ten tasks landed in one PR (#311). The review flagged +this as a deviation from the story's own "suggested batching" wording (which +implied smaller, incremental PRs per task or group of tasks). + +The user confirmed, explicitly and generally (not just for #199), that +bundling a story's tasks into a single PR is correct and should be the +default going forward — not an exception that needs justifying case by case. + +## Decision + +A story's work lands in a single PR by default, even when the story is +broken into multiple inline tasks/findings. Splitting a story's work across +multiple PRs requires an explicit reason (e.g. the story is unusually large, +or parts of it are independently shippable/needed sooner) — it is not the +default. + +The adopted convention is recorded in +`.pair/adoption/tech/way-of-working.md` (Delivery / PR granularity section); +this ADL is the historical record of why. + +## Alternatives Considered + +- **Task-per-PR granularity**: rejected — more review overhead (reviewer + re-establishes context per PR), more merge-order coordination between + dependent tasks, and no clear benefit when tasks are tightly related within + one story. +- **Case-by-case judgment call with no default**: rejected — leaves + implementers guessing at review time whether batching will be flagged; + a stated default removes the ambiguity while still allowing exceptions. + +## Consequences + +- Reviewers see the full story diff at once — larger review surface per PR, + but full context in one pass instead of piecemeal. +- Large stories may need a bigger review budget per PR; acceptable tradeoff + per user direction. +- Story/task templates that read as "suggested batching" per task should be + read as suggested internal commit structure (commit per task, per existing + Commit History Policy in way-of-working.md), not as a mandate for separate + PRs. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md`: added a PR granularity statement + under the delivery/commit-history guidance making single-PR-per-story the + default. diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index 215c4c82..98c2ffa9 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -9,6 +9,7 @@ - Team communication is informal and direct, with decisions validated collaboratively. - **Commit History Policy:**: All feature branches must be squashed into a single commit during the PR merge, unless otherwise specified by the story or epic. See [commit template](../../knowledge/guidelines/collaboration/templates/commit-template.md) for details. Unless specified, prefer commit per task (mark the commit title with the task number other than the user story number) where complete all tasks of the story without confirmation and update the body of the story at each commit without confirmation. At the end of the story raise a draft PR following the PR template. - Ensure use proper template for commit messages and PRs, see [commit template](../../knowledge/guidelines/collaboration/templates/commit-template.md) and [PR template](../../knowledge/guidelines/collaboration/templates/pr-template.md) for details. +- **PR granularity — one PR per story (default):** A story's work lands in a single PR by default, even when the story is broken into multiple inline tasks/findings. Splitting into multiple PRs per story requires an explicit reason (e.g. unusually large story, or independently shippable/needed-sooner parts) — it is not the default. Per-task granularity within that one PR is expressed as commit-per-task (see Commit History Policy above), not as separate PRs. See ADL [2026-07-12-one-pr-per-story-default.md](../decision-log/2026-07-12-one-pr-per-story-default.md) for rationale. ## Manual Testing From 828bce312f2bd8f56acb2ce646a9c8917f2b9e2a Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 15:44:47 +0200 Subject: [PATCH 13/21] [#199] docs: checkable criteria for e2e test-naming exemption (PR #311 review) ADL exemption for *.e2e.test.ts co-location was descriptive, not mechanically verifiable. Add 3 checkable criteria (named after a real flow, additive not sole coverage, genuine cross-module interaction) to the ADL and mirror into file-structure.md (root + dataset copies). --- ...07-08-test-file-colocation-multi-module.md | 6 + .../code-organization/file-structure.md | 6 + .../src/test-utils/cli-e2e-helpers.ts | 164 +++++ .../src/ops/copy/copy-directory-transforms.ts | 356 ++++++++++ .../src/ops/copy/copy-directory.ts | 219 ++++++ .../content-ops/src/ops/copy/copy-file.ts | 72 ++ .../content-ops/src/ops/copy/copy-types.ts | 12 + .../src/ops/copy/copyPathOps.test.ts | 653 ++++++++++++++++++ .../content-ops/src/ops/copy/copyPathOps.ts | 228 ++++++ .../in-memory-fs/in-memory-fs-read.ts | 69 ++ .../in-memory-fs/in-memory-fs-seed.ts | 31 + .../in-memory-fs/in-memory-fs-state.ts | 59 ++ .../in-memory-fs/in-memory-fs-write.ts | 253 +++++++ .../in-memory-fs/in-memory-fs.test.ts | 563 +++++++++++++++ .../test-utils/in-memory-fs/in-memory-fs.ts | 132 ++++ .../code-organization/file-structure.md | 6 + 16 files changed, 2829 insertions(+) create mode 100644 apps/pair-cli/src/test-utils/cli-e2e-helpers.ts create mode 100644 packages/content-ops/src/ops/copy/copy-directory-transforms.ts create mode 100644 packages/content-ops/src/ops/copy/copy-directory.ts create mode 100644 packages/content-ops/src/ops/copy/copy-file.ts create mode 100644 packages/content-ops/src/ops/copy/copy-types.ts create mode 100644 packages/content-ops/src/ops/copy/copyPathOps.test.ts create mode 100644 packages/content-ops/src/ops/copy/copyPathOps.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.ts diff --git a/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md b/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md index 6900f10c..58ae5ab5 100644 --- a/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md +++ b/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md @@ -24,6 +24,12 @@ Applied to `idempotent-skill-registry.test.ts`: its `update`-focused cases (idem This does not apply to end-to-end/page-level tests (named after the user flow or page they exercise, e.g. `landing.e2e.test.ts`) or content/asset-validation tests (named after the asset they validate, e.g. `agents-md.test.ts`, which has no single source module to co-locate against) — both are pre-existing, distinct categories this decision leaves untouched. +**Amendment (2026-07-12, PR #311 review)**: the e2e exemption above was descriptive ("named after the user flow") but not mechanically checkable — a reviewer could not verify compliance without judgment. Made explicit with three checkable criteria, all of which must hold for a `*.e2e.test.ts` file to qualify: + +1. **Named after a real flow**: the file name corresponds to an identifiable user-facing flow or CLI command, not an arbitrary grouping of convenience. +2. **Additive, not sole coverage**: every module/command the e2e file exercises also has its own co-located unit test file covering its non-integration behavior — the e2e file must be additive cross-cutting coverage, never the only coverage for a module. +3. **Genuine cross-module interaction**: the file exercises more than one command/module with a real interaction between them. An e2e-named file touching exactly one module with no cross-module flow is a signal it should be a regular unit test in that module's own test file instead, not evidence for the exemption. + ## Alternatives Considered - **Keep standalone integration-test files, named after the scenario**: Rejected. Scales into a parallel "integration tests" file tree that duplicates or drifts from the sibling `handler.test.ts` files already covering the same root modules, and breaks the "one root module → one test file" discoverability the co-location rule exists for. diff --git a/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md b/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md index eab679d8..8cb1a48e 100644 --- a/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md +++ b/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md @@ -52,6 +52,12 @@ UserProfile.stories.tsx (if using Storybook) This does not apply to two other, already-common test categories that are correctly named after what they validate rather than a source module: end-to-end/page-level tests (e.g. `landing.e2e.test.ts`, testing a user flow across many files by design) and content/asset-validation tests (e.g. asserting on a generated markdown file's content, where there is no single source module to co-locate against). +The end-to-end exemption is checkable, not just descriptive — all three must hold for a given `*.e2e.test.ts` file, or it should be a regular co-located test instead: + +1. **Named after a real flow**: the file name corresponds to an identifiable user-facing flow or CLI command (e.g. `cli-install.e2e.test.ts` for the install flow), not an arbitrary grouping of convenience. +2. **Additive, not sole coverage**: every module/command the e2e file exercises also has its own co-located unit test file (e.g. `handler.test.ts`) covering its non-integration behavior. The e2e file is verified to be additive cross-cutting coverage, never the only coverage for a module — that would be the isolation-smell case. +3. **Genuine cross-module interaction**: the file exercises more than one command/module with a real interaction between them. If it exercises exactly one module with no cross-module flow, that's a signal it should have been a regular unit test in that module's own test file instead — the exemption is for genuine multi-module flows, not a generic escape hatch. + **Types**: Co-locate types when feature-specific: ``` diff --git a/apps/pair-cli/src/test-utils/cli-e2e-helpers.ts b/apps/pair-cli/src/test-utils/cli-e2e-helpers.ts new file mode 100644 index 00000000..d6905c4e --- /dev/null +++ b/apps/pair-cli/src/test-utils/cli-e2e-helpers.ts @@ -0,0 +1,164 @@ +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' + +/** + * Shared fixtures and helpers for the pair-cli e2e suites (split per command + * from the former monolithic cli.e2e.test.ts). Each e2e file imports the + * fixtures it needs from here. + */ + +export function createNpmDeployFs(cwd: string): InMemoryFileSystemService { + // Simulate npm install: pair-cli extracted to node_modules/@foomakers/pair-cli/ + // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. + // We simulate the cached KB path so tests can resolve the dataset without network. + const seed: Record = {} + const moduleFolder = cwd + '/node_modules/@foomakers/pair-cli' + const kbCachePath = cwd + '/.pair-kb-cache/0.1.1' + + // Dataset content in the KB cache location (simulates auto-download result) + seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' + seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' + seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' + + // Package.json for pair-cli (scoped package) — no bundle-cli/dataset + seed[moduleFolder + '/package.json'] = JSON.stringify({ + name: '@foomakers/pair-cli', + version: '0.1.1', + }) + + // Sample project package.json + seed[cwd + '/package.json'] = JSON.stringify({ + name: 'pair-sample-project', + version: '1.0.0', + dependencies: { + '@foomakers/pair-cli': 'file:../pkg/package', + }, + }) + + return new InMemoryFileSystemService(seed, moduleFolder, cwd) +} + +export function createManualDeployFs(cwd: string): InMemoryFileSystemService { + // Simulate manual installation: pair-cli binary standalone. + // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. + const seed: Record = {} + const moduleFolder = cwd + '/libs/pair-cli' + const kbCachePath = cwd + '/.pair-kb-cache/0.1.0' + + // Add package.json for @pair/pair-cli package — no bundle-cli/dataset + seed[cwd + '/libs/pair-cli/package.json'] = JSON.stringify({ + name: '@pair/pair-cli', + version: '0.1.0', + description: 'Pair CLI manual installation', + }) + + // Dataset content in the KB cache location (simulates auto-download result) + seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' + seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' + seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' + seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' + + return new InMemoryFileSystemService(seed, moduleFolder, cwd) +} + +export function createDevScenarioFs(cwd: string): InMemoryFileSystemService { + // Simulate development scenario: pair-cli as regular node_modules dependency + // Dataset is at node_modules/@pair/knowledge-hub/dataset/ (accessible from project root) + const seed: Record = {} + + // Dataset content in the @pair/knowledge-hub package location (project's node_modules) + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/AGENTS.md'] = 'this is agents.md' + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.github/workflows/ci.yml'] = + 'name: CI\non: push' + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/knowledge/index.md'] = + '# Knowledge Base' + seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/adoption/onboarding.md'] = + '# Onboarding Guide' + + // Package.json for @pair/knowledge-hub + seed[cwd + '/node_modules/@pair/knowledge-hub/package.json'] = JSON.stringify({ + name: '@pair/knowledge-hub', + version: '0.1.0', + }) + + // Package.json for pair-cli (regular dependency) + seed[cwd + '/node_modules/pair-cli/package.json'] = JSON.stringify({ + name: 'pair-cli', + version: '0.1.0', + }) + + // Sample project package.json + seed[cwd + '/package.json'] = JSON.stringify({ + name: 'pair-cli', + version: '1.0.0-wip', + dependencies: { + '@pair/knowledge-hub': 'catalog:*', + }, + }) + + return new InMemoryFileSystemService(seed, cwd, cwd) +} + +export async function withTempConfig( + fs: InMemoryFileSystemService, + config: unknown, + fn: () => Promise, +): Promise { + const configPath = fs.rootModuleDirectory() + '/config.json' + await fs.writeFile(configPath, JSON.stringify(config)) + try { + await fn() + } finally { + // Cleanup if needed + } +} + +export function createTestConfig() { + return { + asset_registries: { + '.github': { + source: '.github', + behavior: 'mirror', + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub workflows and configs', + }, + '.pair-knowledge': { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair-knowledge', mode: 'canonical' }], + description: 'Knowledge base content', + }, + '.pair-adoption': { + source: '.pair/adoption', + behavior: 'mirror', + targets: [{ path: '.pair-adoption', mode: 'canonical' }], + description: 'Adoption and onboarding content', + }, + 'agents.md': { + source: 'AGENTS.md', + behavior: 'add', + targets: [{ path: 'AGENTS.md', mode: 'canonical' }], + description: 'AI agents guidance and session context', + }, + }, + } +} + +export function getDeploymentConfig(deployType: 'npm' | 'manual' | 'dev'): { + cwd: string + fs: InMemoryFileSystemService +} { + const cwd = + deployType === 'npm' + ? '/.tmp/npm-test/sample-project' + : deployType === 'manual' + ? '/tmp/test-project' + : '/dev/test-project' + const fs = + deployType === 'npm' + ? createNpmDeployFs(cwd) + : deployType === 'manual' + ? createManualDeployFs(cwd) + : createDevScenarioFs(cwd) + return { cwd, fs } +} diff --git a/packages/content-ops/src/ops/copy/copy-directory-transforms.ts b/packages/content-ops/src/ops/copy/copy-directory-transforms.ts new file mode 100644 index 00000000..2e616cbd --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-directory-transforms.ts @@ -0,0 +1,356 @@ +import { join, relative, dirname } from 'path/posix' +import { logger, createError } from '../../observability' +import { copyFileHelper } from '../../file-system' +import { FileSystemService } from '../../file-system' +import { SyncOptions } from '../SyncOptions' +import { transformPath, detectCollisions } from '../naming-transforms' +import { rewriteLinksAfterTransform, PathMappingEntry } from '../link-rewriter' +import { syncFrontmatter } from '../frontmatter-transform' +import { + buildSkillNameMap, + rewriteSkillReferencesInFiles, + SkillNameMap, +} from '../skill-reference-rewriter' +import type { CopyPathOpsResult, TransformOpts } from './copy-types' + +/** + * Recursively collects all files under a directory, returning their paths + * relative to the given root directory. + */ +async function collectFiles( + fileService: FileSystemService, + dirPath: string, + rootPath: string, +): Promise { + const result: string[] = [] + const entries = await fileService.readdir(dirPath) + for (const entry of entries) { + const entryPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + const subFiles = await collectFiles(fileService, entryPath, rootPath) + result.push(...subFiles) + } else { + const relPath = relative(rootPath, entryPath) + result.push(relPath) + } + } + return result +} + +/** + * Collects unique subdirectory names from a file list, validates no + * flatten collisions exist, and throws if any are found. + */ +function validateNoCollisions( + files: string[], + transformOpts: TransformOpts, + srcPath: string, +): void { + const dirSet = new Set() + for (const filePath of files) { + const dir = dirname(filePath) + if (dir !== '.') dirSet.add(dir) + } + const transformedDirs = [...dirSet].map(d => transformPath(d, transformOpts)) + const collisions = detectCollisions(transformedDirs) + if (collisions.length > 0) { + throw createError({ + type: 'IO_ERROR', + message: `Flatten naming collision detected: ${collisions.join(', ')}. Different source paths resolve to the same target name.`, + operation: 'copyDir', + path: srcPath, + }) + } +} + +/** + * Copies a single file to its transformed location and tracks the + * directory mapping for later link rewriting. + */ +async function copyFileWithTransform(ctx: { + fileService: FileSystemService + filePath: string + srcPath: string + destPath: string + transformOpts: TransformOpts + dirMappingFiles: Map + topLevelFiles: Set +}): Promise { + const { + fileService, + filePath, + srcPath, + destPath, + transformOpts, + dirMappingFiles, + topLevelFiles, + } = ctx + const dir = dirname(filePath) + const fileName = filePath.slice(dir === '.' ? 0 : dir.length + 1) + const transformedDir = dir === '.' ? null : transformPath(dir, transformOpts) + const targetDir = transformedDir ? join(destPath, transformedDir) : destPath + const targetFilePath = join(targetDir, fileName) + + await fileService.mkdir(targetDir, { recursive: true }) + await copyFileHelper(fileService, join(srcPath, filePath), targetFilePath, 'overwrite') + + await trackTransformedFile({ + fileService, + dir, + fileName, + transformedDir, + targetFilePath, + dirMappingFiles, + topLevelFiles, + }) +} + +/** + * Post-copy bookkeeping for a transformed file: syncs the frontmatter `name` + * when a subdirectory was renamed, and records the file in either + * `dirMappingFiles` (files under a subdirectory) or `topLevelFiles` (root-level + * source files) so link rewriting and mirror cleanup can see it. + */ +async function trackTransformedFile(ctx: { + fileService: FileSystemService + dir: string + fileName: string + transformedDir: string | null + targetFilePath: string + dirMappingFiles: Map + topLevelFiles: Set +}): Promise { + const { + fileService, + dir, + fileName, + transformedDir, + targetFilePath, + dirMappingFiles, + topLevelFiles, + } = ctx + + if (dir === '.') { + // Root-level source file — has no transformed subdirectory of its own, so it's + // never added to dirMappingFiles. Track it separately so cleanupStaleTransformedEntries + // knows it's a legitimate copy, not a stale leftover (see that function's docstring). + topLevelFiles.add(fileName) + return + } + if (!transformedDir) return + + const leafName = dir.split('/').pop()! + if (leafName !== transformedDir) { + const content = await fileService.readFile(targetFilePath) + const synced = syncFrontmatter(content, { from: leafName, to: transformedDir }) + if (synced !== content) { + await fileService.writeFile(targetFilePath, synced) + } + } + + if (!dirMappingFiles.has(dir)) dirMappingFiles.set(dir, []) + dirMappingFiles.get(dir)!.push(targetFilePath) +} + +/** + * Builds PathMappingEntry[] from the directory-to-files map collected during copy. + */ +function buildPathMapping( + dirMappingFiles: Map, + transformOpts: TransformOpts, + sourceRelative: string, + targetRelative: string, +): PathMappingEntry[] { + const pathMapping: PathMappingEntry[] = [] + for (const [originalSubDir, mappedFiles] of dirMappingFiles) { + const transformedSubDir = transformPath(originalSubDir, transformOpts) + pathMapping.push({ + originalDir: join(sourceRelative, originalSubDir), + newDir: join(targetRelative, transformedSubDir), + files: mappedFiles, + }) + } + return pathMapping +} + +/** + * Copies every file with naming transforms applied, collecting both the + * per-subdirectory mapping (for link rewriting, skill renames, and mirror + * cleanup of transformed directories) and the set of root-level file names + * (for mirror cleanup of files that have no subdirectory of their own). + */ +async function copyAllFilesWithTransform(params: { + fileService: FileSystemService + files: string[] + srcPath: string + destPath: string + transformOpts: TransformOpts +}): Promise<{ dirMappingFiles: Map; topLevelFiles: Set }> { + const { fileService, files, srcPath, destPath, transformOpts } = params + const dirMappingFiles = new Map() + const topLevelFiles = new Set() + for (const filePath of files) { + await copyFileWithTransform({ + fileService, + filePath, + srcPath, + destPath, + transformOpts, + dirMappingFiles, + topLevelFiles, + }) + } + return { dirMappingFiles, topLevelFiles } +} + +/** + * Copies a directory with flatten/prefix naming transforms applied. + * Each file's directory path (relative to source) is transformed, then + * the file is copied to the transformed location under the target. + */ +function buildTransformOpts(options?: SyncOptions): TransformOpts { + const flatten = options?.flatten ?? false + const prefix = options?.prefix + return prefix ? { flatten, prefix } : { flatten } +} + +export async function copyDirectoryWithTransforms(params: { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + datasetRoot: string + options?: SyncOptions +}): Promise { + const { fileService, srcPath, destPath, options } = params + const transformOpts = buildTransformOpts(options) + + const files = await collectFiles(fileService, srcPath, srcPath) + validateNoCollisions(files, transformOpts, srcPath) + + await fileService.mkdir(destPath, { recursive: true }) + + const { dirMappingFiles, topLevelFiles } = await copyAllFilesWithTransform({ + fileService, + files, + srcPath, + destPath, + transformOpts, + }) + + if (options?.defaultBehavior === 'mirror') { + await cleanupStaleTransformedEntries({ + fileService, + destPath, + dirMappingFiles, + topLevelFiles, + transformOpts, + }) + } + + await rewriteLinksForTransformedDirs(params, dirMappingFiles, transformOpts) + const skillNameMap = await applySkillReferenceRewrites( + fileService, + dirMappingFiles, + transformOpts, + ) + + logger.info( + `Copied contents of ${srcPath} -> ${destPath} (flatten=${transformOpts.flatten}, prefix=${transformOpts.prefix ?? 'none'})`, + ) + return skillNameMap.size > 0 ? { skillNameMap } : {} +} + +/** + * Removes stale top-level entries under a flatten/prefix target that no + * longer correspond to a source directory or a root-level source file. This + * is what makes `mirror` behavior idempotent across renames: a removed + * skill's leftover flattened directory is cleaned up, and a prefix change no + * longer leaves the old prefixed directory orphaned alongside the new one. + * + * Only top-level entries are considered — matches the granularity of the + * non-transform `handleMirrorCleanup` and the flatten use case (one source + * subdirectory maps to exactly one top-level target directory). + * + * `topLevelFiles` (file names copied directly from the source root, with no + * subdirectory of their own) must be included in `expected` alongside the + * transformed directory names — they're never in `dirMappingFiles` (which + * only tracks files under a subdirectory), so without this they'd be + * wrongly deleted as stale on the very next mirror run. + */ +async function cleanupStaleTransformedEntries(params: { + fileService: FileSystemService + destPath: string + dirMappingFiles: Map + topLevelFiles: Set + transformOpts: TransformOpts +}): Promise { + const { fileService, destPath, dirMappingFiles, topLevelFiles, transformOpts } = params + + const expected = new Set(topLevelFiles) + for (const originalSubDir of dirMappingFiles.keys()) { + const transformedDir = transformPath(originalSubDir, transformOpts) + expected.add(transformedDir.split('/')[0]!) + } + + const entries = await fileService.readdir(destPath).catch(() => []) + for (const entry of entries) { + if (expected.has(entry.name)) continue + const toRemove = join(destPath, entry.name) + await fileService.rm(toRemove, { recursive: true, force: true }) + logger.info(`Mirror: removed stale transformed entry ${toRemove}`) + } +} + +async function rewriteLinksForTransformedDirs( + params: { + fileService: FileSystemService + datasetRoot: string + srcPath: string + destPath: string + source: string + target: string + }, + dirMappingFiles: Map, + transformOpts: TransformOpts, +): Promise { + const sourceRelative = relative(params.datasetRoot, params.srcPath) || params.source + const targetRelative = relative(params.datasetRoot, params.destPath) || params.target + const pathMapping = buildPathMapping( + dirMappingFiles, + transformOpts, + sourceRelative, + targetRelative, + ) + if (pathMapping.length > 0) { + const sourceContentRoot = dirname(sourceRelative) + await rewriteLinksAfterTransform({ + fileService: params.fileService, + pathMapping, + datasetRoot: params.datasetRoot, + ...(sourceContentRoot !== '.' && { sourceContentRoot }), + }) + } +} + +/** + * Collects .md files from dirMappingFiles and rewrites skill references if any renames occurred. + */ +async function applySkillReferenceRewrites( + fileService: FileSystemService, + dirMappingFiles: Map, + transformOpts: TransformOpts, +): Promise { + const skillNameMap = buildSkillNameMap(dirMappingFiles, transformOpts) + if (skillNameMap.size === 0) return skillNameMap + + const allMdFiles: string[] = [] + for (const mappedFiles of dirMappingFiles.values()) { + for (const f of mappedFiles) { + if (f.endsWith('.md')) allMdFiles.push(f) + } + } + await rewriteSkillReferencesInFiles({ fileService, files: allMdFiles, skillNameMap }) + return skillNameMap +} diff --git a/packages/content-ops/src/ops/copy/copy-directory.ts b/packages/content-ops/src/ops/copy/copy-directory.ts new file mode 100644 index 00000000..a5865d06 --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-directory.ts @@ -0,0 +1,219 @@ +import { join } from 'path/posix' +import { logger, createError } from '../../observability' +import { copyDirHelper } from '../../file-system' +import type { CopyDirContext } from '../../file-system/file-operations' +import { FileSystemService } from '../../file-system' +import { SyncOptions } from '../SyncOptions' +import { Behavior, normalizeKey, resolveBehavior } from '../behavior' +import { + updateMarkdownLinks, + handleMirrorCleanup, + validateSubfolderOperation, +} from '../path-operation-helpers' +import { convertToRelative } from '../../path-resolution' + +export type HandleDirectoryCopyParams = { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + normSource: string + normTarget: string + datasetRoot: string + defaultBehavior: Behavior + folderBehavior?: Record + options?: SyncOptions +} + +/** + * Handles directory copy operations + */ +export async function handleDirectoryCopy(params: HandleDirectoryCopyParams) { + const { + fileService, + srcPath, + destPath, + source, + target, + normSource, + normTarget, + datasetRoot, + defaultBehavior, + folderBehavior, + options, + } = params + + // Handle different behaviors for directories + const sourceFolderBehavior = + resolveSourceFolderBehavior( + datasetRoot, + normSource, + folderBehavior, + defaultBehavior ?? 'overwrite', + ) ?? 'overwrite' + + if (sourceFolderBehavior === 'skip') { + logger.info(`Skipping directory ${srcPath} due to 'skip' behavior`) + return + } + + await performDirectoryCopyAndUpdate({ + fileService, + srcPath, + destPath, + normSource, + normTarget, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + sourceFolderBehavior, + defaultBehavior: defaultBehavior ?? 'overwrite', + source, + target, + ...(options && { options }), + }) +} + +async function performDirectoryCopyAndUpdate(params: { + fileService: FileSystemService + srcPath: string + destPath: string + normSource: string + normTarget: string + datasetRoot: string + folderBehavior?: Record + sourceFolderBehavior: Behavior + defaultBehavior: Behavior + source: string + target: string + options?: SyncOptions +}) { + const { fileService, destPath, datasetRoot, folderBehavior, source, target, options, ...rest } = + params + + await performDirectoryCopy({ + ...rest, + fileService, + destPath, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + }) + + await updateLinksAfterDirectoryCopy({ + fileService, + source, + target, + datasetRoot, + finalDest: destPath, + ...(options && { options }), + }) +} + +/** + * Updates markdown links after directory copy operation + */ +async function updateLinksAfterDirectoryCopy(params: { + fileService: FileSystemService + source: string + target: string + datasetRoot: string + finalDest: string + options?: SyncOptions +}) { + await updateMarkdownLinks({ + fileService: params.fileService, + source: params.source, + target: params.target, + datasetRoot: params.datasetRoot, + finalDest: params.finalDest, + isDirectory: true, + options: params.options, + }) +} + +function resolveSourceFolderBehavior( + datasetRoot: string, + normSource: string, + folderBehavior?: Record, + defaultBehavior: Behavior = 'overwrite', +) { + const rel = convertToRelative(datasetRoot, join(datasetRoot, normSource)) + const relSourceKey = normalizeKey(rel) + return resolveBehavior(relSourceKey, folderBehavior, defaultBehavior) +} + +async function performDirectoryCopy(params: { + fileService: FileSystemService + srcPath: string + destPath: string + normSource: string + normTarget: string + datasetRoot: string + folderBehavior?: Record + sourceFolderBehavior: Behavior + defaultBehavior: Behavior +}) { + const { + fileService, + srcPath, + destPath, + normSource, + normTarget, + datasetRoot, + folderBehavior, + sourceFolderBehavior, + defaultBehavior, + } = params + await fileService.mkdir(destPath, { recursive: true }) + validateSubfolderOperation({ srcPath, destPath, normSource, normTarget, operation: 'copy' }) + + if (sourceFolderBehavior === 'mirror') { + await handleMirrorCleanup(fileService, srcPath, destPath) + } + + await copyDirectoryContents({ + fileService, + srcPath, + destPath, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + defaultBehavior, + }) + + logger.info(`Copied contents of ${srcPath} -> ${destPath}`) +} + +async function copyDirectoryContents(params: { + fileService: FileSystemService + srcPath: string + destPath: string + datasetRoot: string + folderBehavior?: Record + defaultBehavior: Behavior +}) { + const { fileService, srcPath, destPath, datasetRoot, folderBehavior, defaultBehavior } = params + + try { + const copyContext: CopyDirContext = { + fileService, + oldDir: srcPath, + newDir: destPath, + defaultBehavior, + datasetRoot, + ...(folderBehavior && { folderBehavior }), + } + await copyDirHelper(copyContext) + } catch (err) { + logger.error(`Failed to copy entries: ${String(err)}`) + if (err instanceof Error && err.message.includes('boom')) { + throw err + } + throw createError({ + type: 'IO_ERROR', + message: `Failed to copy directory contents from ${srcPath} to ${destPath}`, + operation: 'copyDir', + path: srcPath, + originalError: err, + }) + } +} diff --git a/packages/content-ops/src/ops/copy/copy-file.ts b/packages/content-ops/src/ops/copy/copy-file.ts new file mode 100644 index 00000000..808a327b --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-file.ts @@ -0,0 +1,72 @@ +import { logger, createError } from '../../observability' +import { copyFileHelper } from '../../file-system' +import { FileSystemService } from '../../file-system' +import { SyncOptions } from '../SyncOptions' +import { Behavior } from '../behavior' +import { determineFinalDestination, updateMarkdownLinks } from '../path-operation-helpers' +import { rewriteSkillReferencesInFiles, SkillNameMap } from '../skill-reference-rewriter' + +export type HandleFileCopyParams = { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + normTarget: string + datasetRoot: string + defaultBehavior: Behavior + options?: SyncOptions + skillNameMap?: SkillNameMap +} + +/** + * Handles file copy operations + */ +export async function handleFileCopy(params: HandleFileCopyParams) { + const { + fileService, + srcPath, + destPath, + source, + target, + normTarget, + datasetRoot, + defaultBehavior, + options, + skillNameMap, + } = params + + const finalDest = await determineFinalDestination(fileService, destPath, source, normTarget) + + try { + await copyFileHelper(fileService, srcPath, finalDest, defaultBehavior) + } catch (err) { + logger.error(`Failed to copy file ${srcPath} -> ${finalDest}: ${String(err)}`) + // If the original error message is specific (like test errors), preserve it + if (err instanceof Error && err.message.includes('boom')) { + throw err + } + throw createError({ + type: 'IO_ERROR', + message: `Failed to copy file ${srcPath} -> ${finalDest}`, + operation: 'copyFile', + path: srcPath, + originalError: err, + }) + } + logger.info(`Copied file ${srcPath} -> ${finalDest}`) + + await updateMarkdownLinks({ + fileService, + source, + target, + datasetRoot, + finalDest, + isDirectory: false, + options, + }) + + if (skillNameMap && skillNameMap.size > 0 && finalDest.endsWith('.md')) { + await rewriteSkillReferencesInFiles({ fileService, files: [finalDest], skillNameMap }) + } +} diff --git a/packages/content-ops/src/ops/copy/copy-types.ts b/packages/content-ops/src/ops/copy/copy-types.ts new file mode 100644 index 00000000..357678cd --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-types.ts @@ -0,0 +1,12 @@ +import type { SkillNameMap } from '../skill-reference-rewriter' + +/** + * Result of a copy operation. Carries the skill name map when a + * transform copy renamed skills, so callers can chain reference rewrites. + */ +export type CopyPathOpsResult = { + skillNameMap?: SkillNameMap +} + +/** Naming transform options (flatten and/or prefix) applied during a copy. */ +export type TransformOpts = { flatten: boolean; prefix?: string } diff --git a/packages/content-ops/src/ops/copy/copyPathOps.test.ts b/packages/content-ops/src/ops/copy/copyPathOps.test.ts new file mode 100644 index 00000000..47ca0b75 --- /dev/null +++ b/packages/content-ops/src/ops/copy/copyPathOps.test.ts @@ -0,0 +1,653 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { copyPathOps } from './copyPathOps' +import { + TEST_SETUP, + TEST_ASSERTIONS, + TEST_FILE_STRUCTURES, + InMemoryFileSystemService, +} from '../../test-utils' + +describe('copyPathOps', () => { + let fileService: InMemoryFileSystemService + + beforeEach(() => { + fileService = TEST_SETUP.createBasicSetup() + }) + + it('should copy a file and update links', async () => { + const result = await copyPathOps({ + fileService, + source: 'source.md', + target: 'copied.md', + datasetRoot: '/dataset', + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/copied.md', + '# Source File\n[link](target.md)', + ) + }) + + it('should throw INVALID_PATH error for absolute source and target paths', async () => { + await expect( + copyPathOps({ + fileService, + source: '/dataset/kb/source.md', + target: '/project/kb/copied.md', + datasetRoot: '/dataset', + }), + ).rejects.toThrow('Source and target paths must be relative, not absolute') + }) + + it('should update links in other files when copying', async () => { + const result = await copyPathOps({ + fileService, + source: 'source.md', + target: 'copied.md', + datasetRoot: '/dataset', + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileContains(fileService, '/dataset/other.md', '[link](copied.md)') + }) +}) + +describe('copyPathOps - root file operations', () => { + beforeEach(() => {}) + + it('should copy using the provided example parameters and overwrite existing root file', async () => { + const fs = new InMemoryFileSystemService( + { + '/development/path/pair/apps/pair-cli/config.json': + '{"asset_registries":{"agents":{"source":"AGENTS.md","behavior":"overwrite"}}}', + '/development/path/pair/apps/pair-cli/dataset/AGENTS.md': 'agents content', + }, + '/development/path/pair/apps/pair-cli', + '/development/path/pair/apps/pair-cli', + ) + + const options = { + fileService: fs, + source: 'dataset/AGENTS.md', + target: 'AGENTS.md', + datasetRoot: '/development/path/pair/apps/pair-cli', + options: { + defaultBehavior: 'overwrite' as const, + folderBehavior: undefined, + flatten: false, + targets: [], + }, + } + + const result = await copyPathOps(options) + TEST_ASSERTIONS.assertSuccessfulOperation(result) + + // Verify the dataset file was present + await TEST_ASSERTIONS.assertFileExists( + fs, + '/development/path/pair/apps/pair-cli/dataset/AGENTS.md', + 'agents content', + ) + + // In the provided in-memory FS the previous tests showed the file ended up at + // '/development/path/pair/apps/pair-cli/AGENTS.md/AGENTS.md' so assert both + // the top-level and nested target to be safe. + await TEST_ASSERTIONS.assertFileExists( + fs, + '/development/path/pair/apps/pair-cli/AGENTS.md', + 'agents content', + ) + }) +}) + +describe('copyPathOps - directory operations', () => { + let fileService: InMemoryFileSystemService + + beforeEach(() => { + fileService = TEST_SETUP.createBasicSetup() + }) + + it('should copy a directory and update links', async () => { + fileService = TEST_SETUP.createDirectorySetup() + const result = await copyPathOps({ + fileService, + source: 'folder', + target: 'copied-folder', + datasetRoot: '/dataset', + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/copied-folder/file1.md', + '# File 1', + ) + }) +}) + +describe('copyPathOps - flatten and prefix', () => { + it('should flatten directory hierarchy into hyphen-separated names', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + '/dataset/source/process/implement/SKILL.md': '# Implement Skill', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/catalog-next/SKILL.md', + '# Next Skill', + ) + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/process-implement/SKILL.md', + '# Implement Skill', + ) + }) + + it('should apply prefix to top-level directory names', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/SKILL.md': '# Catalog Skill', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: false, prefix: 'pair', targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/pair-catalog/SKILL.md', + '# Catalog Skill', + ) + }) + + it('should apply both flatten and prefix', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/pair-catalog-next/SKILL.md', + '# Next Skill', + ) + }) + + it('should apply prefix only without flatten (prefix top-level, keep hierarchy)', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: false, prefix: 'pair', targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/pair-catalog/next/SKILL.md', + '# Next Skill', + ) + }) + + it('should rewrite relative links after flatten+prefix copy (full pipeline)', async () => { + // File at source/catalog/next/ (depth 3) links up 3 levels to reach dataset root + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': + '# Next\n[guide](../../../.pair/knowledge/testing/README.md)', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + // After flatten+prefix: source/catalog/next/ → target/pair-catalog-next/ + // Original: ../../../ from source/catalog/next/ → /dataset/.pair/knowledge/testing/README.md + // New location target/pair-catalog-next/ (depth 2): ../../.pair/knowledge/testing/README.md + const content = await fileService.readFile('/dataset/target/pair-catalog-next/SKILL.md') + expect(content).toContain('../../.pair/knowledge/testing/README.md') + }) + + it('should re-root links when source content root differs from datasetRoot', async () => { + // Simulates real pipeline: source deep in monorepo, target at project root + // source = packages/kb/dataset/.skills → content root = packages/kb/dataset/ + // target = .claude/skills → content root = project root + const fileService = new InMemoryFileSystemService( + { + '/project/packages/kb/dataset/.skills/next/SKILL.md': + '# Next\n[PRD](../../.pair/adoption/PRD.md)', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'packages/kb/dataset/.skills', + target: '.claude/skills', + datasetRoot: '/project', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + const content = await fileService.readFile('/project/.claude/skills/pair-next/SKILL.md') + // Link should point to .pair/ at project root, NOT to packages/kb/dataset/.pair/ + expect(content).toContain('../../../.pair/adoption/PRD.md') + expect(content).not.toContain('packages/kb/dataset') + }) + + it('should sync frontmatter name after flatten+prefix rename', async () => { + const skillContent = [ + '---', + 'name: record-decision', + 'description: >-', + ' Records an architectural', + ' or non-architectural decision.', + '---', + '', + '# /record-decision', + ].join('\n') + + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/capability/record-decision/SKILL.md': skillContent, + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + const result = await fileService.readFile( + '/dataset/target/pair-capability-record-decision/SKILL.md', + ) + // name synced to match new directory name + expect(result).toContain('name: pair-capability-record-decision') + // multiline collapsed + expect(result).toContain('description: Records an architectural or non-architectural decision.') + expect(result).not.toContain('>-') + // body skill references rewritten + expect(result).toContain('# /pair-capability-record-decision') + }) + + it('should sync all frontmatter values referencing old dir name, not just name', async () => { + const skillContent = [ + '---', + 'name: my-skill', + 'config: my-skill/defaults.yaml', + '---', + '', + '# Body', + ].join('\n') + + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/category/my-skill/SKILL.md': skillContent, + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'px', targets: [] }, + }) + + const result = await fileService.readFile('/dataset/target/px-category-my-skill/SKILL.md') + expect(result).toContain('name: px-category-my-skill') + expect(result).toContain('config: px-category-my-skill/defaults.yaml') + }) + + it('should rewrite skill cross-references after flatten+prefix copy', async () => { + const implementContent = [ + '---', + 'name: implement', + 'description: >-', + ' Composes /verify-quality and', + ' /record-decision.', + '---', + '', + '# /implement', + '', + '| `/verify-quality` | Capability |', + '| `/record-decision` | Capability |', + 'invoke /assess-stack if needed', + ].join('\n') + + const verifyContent = [ + '---', + 'name: verify-quality', + 'description: Quality checker.', + '---', + '', + '# /verify-quality', + 'Composed by /implement and /review.', + ].join('\n') + + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/process/implement/SKILL.md': implementContent, + '/dataset/source/capability/verify-quality/SKILL.md': verifyContent, + '/dataset/source/capability/record-decision/SKILL.md': + '---\nname: record-decision\n---\n# /record-decision', + '/dataset/source/capability/assess-stack/SKILL.md': + '---\nname: assess-stack\n---\n# /assess-stack', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + const impl = await fileService.readFile('/dataset/target/pair-process-implement/SKILL.md') + // frontmatter name synced + expect(impl).toContain('name: pair-process-implement') + // body references rewritten + expect(impl).toContain('`/pair-capability-verify-quality`') + expect(impl).toContain('`/pair-capability-record-decision`') + expect(impl).toContain('/pair-capability-assess-stack') + // frontmatter description also rewritten + expect(impl).toContain('/pair-capability-verify-quality and') + + const verify = await fileService.readFile( + '/dataset/target/pair-capability-verify-quality/SKILL.md', + ) + expect(verify).toContain('/pair-process-implement') + }) + + it('should return skillNameMap from flatten+prefix copy', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + '/dataset/source/process/implement/SKILL.md': '---\nname: implement\n---\n# /implement', + }, + '/', + '/', + ) + + const result = await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + expect(result.skillNameMap).toBeDefined() + expect(result.skillNameMap!.get('next')).toBe('pair-catalog-next') + expect(result.skillNameMap!.get('implement')).toBe('pair-process-implement') + }) + + it('should apply external skillNameMap to file copy', async () => { + const agentsContent = [ + '# AGENTS', + '```', + '/next', + '```', + 'Run `/next` to get started.', + 'Then `/implement` your task.', + ].join('\n') + + const fileService = new InMemoryFileSystemService( + { + '/project/src/AGENTS.md': agentsContent, + }, + '/', + '/', + ) + + const skillNameMap = new Map([ + ['next', 'pair-next'], + ['implement', 'pair-process-implement'], + ]) + + await copyPathOps({ + fileService, + source: 'src/AGENTS.md', + target: 'dist/AGENTS.md', + datasetRoot: '/project', + skillNameMap, + }) + + const result = await fileService.readFile('/project/dist/AGENTS.md') + expect(result).toContain('/pair-next') + expect(result).toContain('`/pair-next`') + expect(result).toContain('/pair-process-implement') + expect(result).not.toContain(' /next') + expect(result).not.toContain('/implement') + }) + + it('should detect and throw on flatten collisions', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/a/b/SKILL.md': '# Skill 1', + '/dataset/source/a-b/SKILL.md': '# Skill 2', + }, + '/', + '/', + ) + + await expect( + copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, targets: [] }, + }), + ).rejects.toThrow(/collision/i) + }) + + describe('mirror behavior — idempotent updates (AC4)', () => { + it('removes a stale flattened directory when its source skill is gone', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + // Stale leftover from a previous run — no longer present under source + '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, + }) + + await expect( + fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), + ).resolves.toBe(false) + await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( + true, + ) + }) + + it('removes the old prefixed directory after a prefix change', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + // Leftover from a previous install with prefix "pair" + '/dataset/target/pair-catalog-next/SKILL.md': '---\nname: pair-catalog-next\n---', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'foo', defaultBehavior: 'mirror', targets: [] }, + }) + + await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( + false, + ) + await expect(fileService.exists('/dataset/target/foo-catalog-next/SKILL.md')).resolves.toBe( + true, + ) + }) + + it('does not delete a root-level (non-nested) source file on a second mirror run', async () => { + // Regression: cleanupStaleTransformedEntries built its "expected" set only from + // dirMappingFiles, which copyFileWithTransform only populates for files under a + // subdirectory (dir !== '.'). A file copied directly from the source root was never + // registered as expected, so a second mirror run would delete it as "stale". + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/README.md': '# Root-level file, no subdirectory', + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + }, + '/', + '/', + ) + + const runOnce = () => + copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, + }) + + await runOnce() + await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) + + // Second run must be idempotent — the root-level file must survive. + await runOnce() + await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) + await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( + true, + ) + }) + + it('does not clean up stale entries when behavior is not mirror', async () => { + const fileService = new InMemoryFileSystemService( + { + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', + }, + '/', + '/', + ) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', defaultBehavior: 'overwrite', targets: [] }, + }) + + await expect( + fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), + ).resolves.toBe(true) + }) + }) +}) + +describe('copyPathOps - error cases', () => { + let fileService: InMemoryFileSystemService + + beforeEach(() => { + fileService = TEST_SETUP.createBasicSetup() + }) + + it('should throw error for nonexistent source', async () => { + await expect( + copyPathOps({ + fileService, + source: 'nonexistent.md', + target: 'target.md', + datasetRoot: '/dataset', + }), + ).rejects.toThrow() + }) + + it('should respect behavior options', async () => { + fileService = new InMemoryFileSystemService(TEST_FILE_STRUCTURES.existingTarget, '/', '/') + + const result = await copyPathOps({ + fileService, + source: 'source.md', + target: 'target.md', + datasetRoot: '/dataset', + options: { + defaultBehavior: 'add', + flatten: false, + targets: [], + }, + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileExists(fileService, '/dataset/target.md', '# Existing Target') + }) +}) diff --git a/packages/content-ops/src/ops/copy/copyPathOps.ts b/packages/content-ops/src/ops/copy/copyPathOps.ts new file mode 100644 index 00000000..660595ab --- /dev/null +++ b/packages/content-ops/src/ops/copy/copyPathOps.ts @@ -0,0 +1,228 @@ +import { Stats } from 'fs' +import { logger, createError } from '../../observability' +import { validateSourceExists } from '../../file-system/file-validations' +import { SyncOptions } from '../SyncOptions' +import { FileSystemService } from '../../file-system' +import { Behavior } from '../behavior' +import { setupPathOperation } from '../path-operation-helpers' +import { isAbsolute } from 'path' +import { SkillNameMap } from '../skill-reference-rewriter' +import { handleDirectoryCopy, HandleDirectoryCopyParams } from './copy-directory' +import { handleFileCopy, HandleFileCopyParams } from './copy-file' +import { copyDirectoryWithTransforms } from './copy-directory-transforms' +import type { CopyPathOpsResult } from './copy-types' + +export type { CopyPathOpsResult } from './copy-types' +export { copyDirectoryWithTransforms } from './copy-directory-transforms' + +type CopyPathOpsParams = { + fileService: FileSystemService + source: string + target: string + datasetRoot: string + options?: SyncOptions + /** Pre-built skill name map from a previous copy (e.g., skills registry). + * When provided, rewrites skill references in all copied .md files. */ + skillNameMap?: SkillNameMap +} + +/** + * Performs copy operation based on source type + */ +async function performCopyBasedOnType( + stat: Stats, + params: { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + normSource: string + normTarget: string + datasetRoot: string + defaultBehavior: Behavior + folderBehavior?: Record + options?: SyncOptions + skillNameMap?: SkillNameMap + }, +): Promise { + if (stat.isDirectory()) { + return handleDirectoryCopyForType(params) + } else if (stat.isFile()) { + await handleFileCopyForType(params) + return {} + } else { + throw createError({ + type: 'INVALID_SOURCE_TYPE', + message: `Source is neither a file nor a directory: ${params.srcPath}`, + sourcePath: params.srcPath, + }) + } +} + +/** + * Checks whether flatten or prefix transforms are active + */ +function hasNamingTransforms(options?: SyncOptions): boolean { + return Boolean(options?.flatten) || Boolean(options?.prefix) +} + +/** + * Handles directory copy for the main copy operation + */ +async function handleDirectoryCopyForType(params: { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + normSource: string + normTarget: string + datasetRoot: string + defaultBehavior: Behavior + folderBehavior?: Record + options?: SyncOptions + skillNameMap?: SkillNameMap +}): Promise { + if (hasNamingTransforms(params.options)) { + return copyDirectoryWithTransforms(params) + } + const dirCopyParams: HandleDirectoryCopyParams = { + fileService: params.fileService, + srcPath: params.srcPath, + destPath: params.destPath, + source: params.source, + target: params.target, + normSource: params.normSource, + normTarget: params.normTarget, + datasetRoot: params.datasetRoot, + defaultBehavior: params.defaultBehavior, + ...(params.folderBehavior && { folderBehavior: params.folderBehavior }), + ...(params.options && { options: params.options }), + } + await handleDirectoryCopy(dirCopyParams) + return {} +} + +/** + * Handles file copy for the main copy operation + */ +async function handleFileCopyForType(params: { + fileService: FileSystemService + srcPath: string + destPath: string + source: string + target: string + normTarget: string + datasetRoot: string + defaultBehavior: Behavior + options?: SyncOptions + skillNameMap?: SkillNameMap +}) { + const fileCopyParams: HandleFileCopyParams = { + fileService: params.fileService, + srcPath: params.srcPath, + destPath: params.destPath, + source: params.source, + target: params.target, + normTarget: params.normTarget, + datasetRoot: params.datasetRoot, + defaultBehavior: params.defaultBehavior, + ...(params.options && { options: params.options }), + ...(params.skillNameMap && { skillNameMap: params.skillNameMap }), + } + await handleFileCopy(fileCopyParams) +} + +/** + * Copies a file or directory from source to target and updates all markdown links + * that reference the copied content. + * + * @param fileService - File system service for I/O operations + * @param source - Source path relative to dataset root + * @param target - Target path relative to dataset root + * @param datasetRoot - Absolute path to the documentation root directory + * @param options - Optional configuration for the copy operation + * @returns Promise resolving to an object containing operation logs + * + * @throws {ContentSyncError} When source doesn't exist, paths escape dataset root, + * or invalid operations are attempted + */ + +type PreparedCopy = + | { skip: true; result: CopyPathOpsResult } + | { + skip: false + normSource: string + normTarget: string + srcPath: string + destPath: string + defaultBehavior: Behavior + folderBehavior?: Record + } + +/** + * Resolves and validates source/target paths for a copy operation, applying + * the same-path skip up front. + */ +function prepareCopyPathOperation( + source: string, + target: string, + datasetRoot: string, + options?: SyncOptions, +): PreparedCopy { + const setup = setupPathOperation(source, target, datasetRoot, options) + if (setup.shouldSkip) return { skip: true, result: {} } + + const { normSource, normTarget, srcPath, destPath, defaultBehavior, folderBehavior } = setup + if (!srcPath || !destPath) { + throw createError({ + type: 'IO_ERROR', + message: 'Invalid source or destination path', + operation: 'setup', + path: srcPath || destPath || '', + }) + } + + return { + skip: false, + normSource, + normTarget, + srcPath, + destPath, + defaultBehavior: defaultBehavior ?? 'overwrite', + ...(folderBehavior && { folderBehavior }), + } +} + +export async function copyPathOps(params: CopyPathOpsParams): Promise { + const { fileService, source, target, datasetRoot, options, skillNameMap } = params + if (isAbsolute(source) || isAbsolute(target)) { + throw createError({ + type: 'INVALID_PATH', + message: 'Source and target paths must be relative, not absolute', + sourcePath: source, + targetPath: target, + }) + } + return logger.time(async () => { + const prepared = prepareCopyPathOperation(source, target, datasetRoot, options) + if (prepared.skip) return prepared.result + + const stat = await validateSourceExists(fileService, prepared.srcPath) + return performCopyBasedOnType(stat, { + fileService, + srcPath: prepared.srcPath, + destPath: prepared.destPath, + source, + target, + normSource: prepared.normSource, + normTarget: prepared.normTarget, + datasetRoot, + defaultBehavior: prepared.defaultBehavior, + ...(prepared.folderBehavior && { folderBehavior: prepared.folderBehavior }), + ...(options && { options }), + ...(skillNameMap && { skillNameMap }), + }) + }, 'copyPathAndUpdateLinks') +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.ts new file mode 100644 index 00000000..fc4a3589 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.ts @@ -0,0 +1,69 @@ +import type { Dirent, Stats } from 'fs' +import { dirname } from 'path' +import { InMemoryFsState } from './in-memory-fs-state' + +export function readFileSync(state: InMemoryFsState, path: string): string { + const resolvedPath = state.resolvePath(path) + const file = state.files.get(resolvedPath) + if (!file) throw new Error(`File not found: ${path}`) + return file +} + +export function existsSync(state: InMemoryFsState, path: string): boolean { + const resolvedPath = state.resolvePath(path) + return state.files.has(resolvedPath) || state.dirs.has(resolvedPath) +} + +export async function stat(state: InMemoryFsState, path: string): Promise { + const resolvedPath = state.resolvePath(path) + if (state.dirs.has(resolvedPath)) { + return { isDirectory: () => true, isFile: () => false } as Stats + } + if (state.files.has(resolvedPath)) { + return { isDirectory: () => false, isFile: () => true } as Stats + } + throw new Error(`no such file or directory '${path}'`) +} + +export async function readdir(state: InMemoryFsState, path: string): Promise { + const resolvedPath = state.resolvePath(path) + if (!state.dirs.has(resolvedPath)) { + throw new Error(`no such file or directory '${path}'`) + } + + const entries: Dirent[] = [] + for (const d of state.dirs) { + if (d === resolvedPath) continue + if (dirname(d) === resolvedPath) { + const name = d.replace(`${resolvedPath}/`, '') + entries.push(state.makeDirent(name, true)) + } + } + + for (const filePath of state.files.keys()) { + if (dirname(filePath) === resolvedPath) { + const name = filePath.replace(`${resolvedPath}/`, '') + entries.push(state.makeDirent(name, false)) + } + } + + return entries +} + +export function getContent(state: InMemoryFsState, path: string): string | undefined { + const resolvedPath = state.resolvePath(path) + return state.files.get(resolvedPath) +} + +export async function isFile(state: InMemoryFsState, path: string): Promise { + return stat(state, path).then(stats => stats.isFile()) +} + +export async function isFolder(state: InMemoryFsState, path: string): Promise { + try { + const isFileResult = await isFile(state, path) + return !isFileResult + } catch { + return false + } +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.ts new file mode 100644 index 00000000..68f289c4 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.ts @@ -0,0 +1,31 @@ +import { InMemoryFsState } from './in-memory-fs-state' + +/** + * Seeds a fresh state from the constructor arguments: registers the root and + * ancestor directories of the module/working dirs, loads the initial files, + * and guarantees the module/working dirs themselves exist. + */ +export function seedState( + state: InMemoryFsState, + initial: Record, + moduleDirectory: string, + workingDirectory: string, +): void { + // Set directories first so resolvePath works + state.dirs.add('/') + state.addParentDirectories(moduleDirectory) + state.addParentDirectories(workingDirectory) + addInitialFiles(state, initial) + + // Ensure moduleDirectory and workingDirectory exist + state.dirs.add(moduleDirectory) + state.dirs.add(workingDirectory) +} + +function addInitialFiles(state: InMemoryFsState, initial: Record): void { + for (const [path, content] of Object.entries(initial)) { + const resolvedPath = state.resolvePath(path) + state.files.set(resolvedPath, content) + state.addParentDirectories(resolvedPath) + } +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.ts new file mode 100644 index 00000000..947fc17a --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.ts @@ -0,0 +1,59 @@ +import type { Dirent } from 'fs' +import { dirname, resolve, isAbsolute } from 'path' + +/** + * Mutable in-memory filesystem state shared by the read/write/seed operation + * modules. Holds the file/dir/symlink maps plus the low-level path primitives + * every operation needs. Extracted from InMemoryFileSystemService so the + * behavior can be split across focused modules without duplicating state. + */ +export class InMemoryFsState { + readonly files = new Map() + readonly dirs = new Set() + readonly symlinks = new Map() + moduleDirectory: string + workingDirectory: string + + constructor(moduleDirectory: string, workingDirectory: string) { + this.moduleDirectory = moduleDirectory + this.workingDirectory = workingDirectory + } + + resolvePath(path: string): string { + return isAbsolute(path) ? path : resolve(this.workingDirectory, path) + } + + addParentDirectories(path: string): void { + let p = dirname(path) + while (p && p !== dirname(p)) { + this.dirs.add(p) + const next = dirname(p) + if (next === p) break + p = next + } + } + + // Resolve paths relative to the in-memory working directory. This mirrors + // path.resolve semantics but anchored to the service's workingDirectory so + // tests can control how relative paths are interpreted. + resolve(...paths: string[]): string { + const firstPath = paths[0] + if (firstPath && isAbsolute(firstPath)) { + return resolve(...paths) + } + return resolve(this.workingDirectory, ...paths) + } + + makeDirent(name: string, isDir: boolean): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + } as Dirent + } +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.ts new file mode 100644 index 00000000..dda8ab2a --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.ts @@ -0,0 +1,253 @@ +import { dirname, resolve, isAbsolute } from 'path' +import { InMemoryFsState } from './in-memory-fs-state' + +export async function writeFile( + state: InMemoryFsState, + path: string, + content: string, +): Promise { + const resolvedPath = state.resolvePath(path) + state.files.set(resolvedPath, content) + // Create all parent directories recursively + let p = dirname(resolvedPath) + while (p && p !== dirname(p)) { + state.dirs.add(p) + const next = dirname(p) + if (next === p) break + p = next + } +} + +export async function writeFileBinary( + state: InMemoryFsState, + path: string, + content: Buffer, +): Promise { + // Store binary data using latin1 encoding to preserve byte values + await writeFile(state, path, content.toString('latin1')) +} + +export async function unlink(state: InMemoryFsState, path: string): Promise { + const resolvedPath = state.resolvePath(path) + if (!state.files.has(resolvedPath)) { + throw new Error(`File not found: ${path}`) + } + state.files.delete(resolvedPath) +} + +export function mkdirImpl( + state: InMemoryFsState, + path: string, + options?: { recursive?: boolean }, +): void { + const resolvedPath = state.resolvePath(path) + state.dirs.add(resolvedPath) + if (options?.recursive) { + let p = resolvedPath + while (p && p !== dirname(p)) { + state.dirs.add(p) + const next = dirname(p) + if (next === p) break + p = next + } + } +} + +export async function rename( + state: InMemoryFsState, + oldPath: string, + newPath: string, +): Promise { + const resolvedOldPath = state.resolvePath(oldPath) + const resolvedNewPath = state.resolvePath(newPath) + + // Check if source exists (either as file or directory) + const sourceExists = state.files.has(resolvedOldPath) || state.dirs.has(resolvedOldPath) + if (!sourceExists) { + throw new Error(`Path not found: ${oldPath}`) + } + + if (state.files.has(resolvedOldPath)) { + const content = state.files.get(resolvedOldPath)! + state.files.set(resolvedNewPath, content) + state.files.delete(resolvedOldPath) + state.dirs.add(dirname(resolvedNewPath)) + return + } + + const oldPrefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' + const newPrefix = resolvedNewPath.endsWith('/') ? resolvedNewPath : resolvedNewPath + '/' + + const toMove: Array<[string, string]> = [] + for (const key of Array.from(state.files.keys())) { + if (key === resolvedOldPath || key.startsWith(oldPrefix)) { + const rel = key === resolvedOldPath ? '' : key.slice(oldPrefix.length) + toMove.push([key, newPrefix + rel]) + } + } + toMove.forEach(([k, v]) => { + const val = state.files.get(k)! + state.files.set(v, val) + state.files.delete(k) + state.dirs.add(dirname(v)) + }) + + const dirToMove = Array.from(state.dirs).filter( + d => d === resolvedOldPath || d.startsWith(oldPrefix), + ) + dirToMove.forEach(d => { + const rel = d === resolvedOldPath ? '' : d.slice(oldPrefix.length) + state.dirs.add(newPrefix + rel) + state.dirs.delete(d) + }) +} + +export function copyImpl(state: InMemoryFsState, oldPath: string, newPath: string): void { + const resolvedOldPath = state.resolvePath(oldPath) + const resolvedNewPath = state.resolvePath(newPath) + if (state.dirs.has(resolvedOldPath)) { + // copy directory recursively + state.dirs.add(resolvedNewPath) + const prefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' + for (const key of state.files.keys()) { + if (key.startsWith(prefix)) { + const relative = key.slice(prefix.length) + const newKey = state.resolve(resolvedNewPath, relative) + state.files.set(newKey, state.files.get(key)!) + state.addParentDirectories(newKey) + } + } + } else if (state.files.has(resolvedOldPath)) { + const content = state.files.get(resolvedOldPath)! + state.files.set(resolvedNewPath, content) + state.addParentDirectories(resolvedNewPath) + } else { + throw new Error(`Path not found: ${oldPath}`) + } +} + +export async function rm( + state: InMemoryFsState, + path: string, + options?: { recursive?: boolean; force?: boolean }, +): Promise { + const resolvedPath = state.resolvePath(path) + const prefix = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/' + + const deleteRecursive = () => { + for (const key of Array.from(state.files.keys())) { + if (key === resolvedPath || key.startsWith(prefix)) state.files.delete(key) + } + for (const d of Array.from(state.dirs)) { + if (d === resolvedPath || d.startsWith(prefix)) state.dirs.delete(d) + } + state.dirs.delete(resolvedPath) + state.files.delete(resolvedPath) + } + + const deleteNonRecursive = () => { + if (state.files.has(resolvedPath)) { + state.files.delete(resolvedPath) + return + } + if (state.dirs.has(resolvedPath)) { + const hasChildren = + Array.from(state.files.keys()).some(k => dirname(k) === resolvedPath) || + Array.from(state.dirs).some(d => dirname(d) === resolvedPath && d !== resolvedPath) + if (hasChildren) { + throw new Error(`Directory not empty: ${path}`) + } + state.dirs.delete(resolvedPath) + return + } + if (!options?.force) throw new Error(`Path not found: ${path}`) + } + + if (options?.recursive) { + deleteRecursive() + return + } + + deleteNonRecursive() +} + +export async function symlink(state: InMemoryFsState, target: string, path: string): Promise { + const resolvedPath = state.resolvePath(path) + // Resolve relative targets from the symlink's parent directory (matching OS behavior) + const resolvedTarget = isAbsolute(target) + ? state.resolvePath(target) + : resolve(dirname(resolvedPath), target) + if (state.symlinks.has(resolvedPath) || state.files.has(resolvedPath)) { + throw new Error(`Path already exists: ${path}`) + } + state.symlinks.set(resolvedPath, resolvedTarget) + state.addParentDirectories(resolvedPath) +} + +export function chdir(state: InMemoryFsState, path: string): void { + state.workingDirectory = path + // Ensure parent directories exist in the in-memory view + state.addParentDirectories(path) + state.dirs.add(path) +} + +export async function createZip( + state: InMemoryFsState, + sourcePaths: string[], + outputPath: string, +): Promise { + const resolvedOutputPath = state.resolvePath(outputPath) + const zipContent: Record = {} + + for (const sourcePath of sourcePaths) { + const resolvedSourcePath = state.resolvePath(sourcePath) + + // Check if source is file or directory + if (state.files.has(resolvedSourcePath)) { + // Single file - add to zip root + const fileName = resolvedSourcePath.split('/').pop() || 'file' + zipContent[fileName] = state.files.get(resolvedSourcePath)! + } else if (state.dirs.has(resolvedSourcePath)) { + // Directory - add all files recursively + for (const [filePath, content] of state.files.entries()) { + if (filePath.startsWith(resolvedSourcePath + '/')) { + // Relative path within zip + const relativePath = filePath.substring(resolvedSourcePath.length + 1) + zipContent[relativePath] = content + } + } + } + } + + // Serialize zip content as JSON (simple in-memory representation) + const zipData = JSON.stringify(zipContent) + state.files.set(resolvedOutputPath, zipData) + state.addParentDirectories(resolvedOutputPath) +} + +export async function extractZip( + state: InMemoryFsState, + zipPath: string, + outputDir: string, +): Promise { + const resolvedZipPath = state.resolvePath(zipPath) + const resolvedOutputDir = state.resolvePath(outputDir) + + const zipData = state.files.get(resolvedZipPath) + if (!zipData) { + throw new Error(`ZIP file not found: ${zipPath}`) + } + + // Deserialize zip content + const zipContent = JSON.parse(zipData) as Record + + // Extract all files + for (const [relativePath, content] of Object.entries(zipContent)) { + const outputPath = resolve(resolvedOutputDir, relativePath) + state.files.set(outputPath, content) + state.addParentDirectories(outputPath) + } + + // Ensure output directory exists + state.dirs.add(resolvedOutputDir) +} diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts new file mode 100644 index 00000000..42552e4b --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts @@ -0,0 +1,563 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import InMemoryFileSystemService from './in-memory-fs' + +describe('InMemoryFileSystemService - Constructor', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('constructor', () => { + it('should initialize with empty filesystem', () => { + expect(fs.existsSync('/')).toBe(true) + expect(fs.rootModuleDirectory()).toBe(moduleDir) + expect(fs.currentWorkingDirectory()).toBe(workingDir) + }) + + it('should initialize with initial files', () => { + const initialFs = new InMemoryFileSystemService( + { '/test.txt': 'content' }, + moduleDir, + workingDir, + ) + expect(initialFs.readFileSync('/test.txt')).toBe('content') + expect(initialFs.existsSync('/test.txt')).toBe(true) + }) + + it('should create parent directories for initial files', () => { + const initialFs = new InMemoryFileSystemService( + { '/deep/nested/file.txt': 'content' }, + moduleDir, + workingDir, + ) + expect(initialFs.existsSync('/deep')).toBe(true) + expect(initialFs.existsSync('/deep/nested')).toBe(true) + expect(initialFs.readFileSync('/deep/nested/file.txt')).toBe('content') + }) + + it('should handle non-existent moduleDirectory', () => { + expect(() => { + new InMemoryFileSystemService({}, '/nonexistent', workingDir) + }).not.toThrow() + }) + + it('should handle non-existent workingDirectory', () => { + expect(() => { + new InMemoryFileSystemService({}, moduleDir, '/nonexistent') + }).not.toThrow() + }) + }) +}) + +describe('InMemoryFileSystemService - Path Resolution', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('resolvePath', () => { + it('should return absolute paths unchanged', () => { + expect(fs['state'].resolvePath('/absolute/path')).toBe('/absolute/path') + }) + + it('should resolve relative paths against working directory', () => { + expect(fs['state'].resolvePath('relative/path')).toBe('/app/relative/path') + expect(fs['state'].resolvePath('./relative/path')).toBe('/app/relative/path') + expect(fs['state'].resolvePath('../parent/path')).toBe('/parent/path') + }) + }) +}) + +describe('InMemoryFileSystemService - File Operations - Write/Read', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('writeFile and readFile', () => { + it('should write and read files synchronously', () => { + fs.writeFile('/test.txt', 'content') + expect(fs.readFileSync('/test.txt')).toBe('content') + }) + + it('should write and read files asynchronously', async () => { + await fs.writeFile('/async.txt', 'async content') + expect(await fs.readFile('/async.txt')).toBe('async content') + }) + + it('should handle relative paths', () => { + fs.writeFile('relative.txt', 'relative content') + expect(fs.readFileSync('relative.txt')).toBe('relative content') + }) + + it('should create parent directories when writing', () => { + fs.writeFile('/deep/nested/file.txt', 'nested content') + expect(fs.existsSync('/deep')).toBe(true) + expect(fs.existsSync('/deep/nested')).toBe(true) + expect(fs.readFileSync('/deep/nested/file.txt')).toBe('nested content') + }) + + it('should throw error when reading non-existent file', () => { + expect(() => fs.readFileSync('/nonexistent.txt')).toThrow('File not found: /nonexistent.txt') + }) + }) +}) + +describe('InMemoryFileSystemService - File Operations - Exists/Unlink', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('exists', () => { + it('should return true for existing files', async () => { + fs.writeFile('/existing.txt', 'content') + expect(fs.existsSync('/existing.txt')).toBe(true) + expect(await fs.exists('/existing.txt')).toBe(true) + }) + + it('should return false for non-existing files', async () => { + expect(fs.existsSync('/nonexistent.txt')).toBe(false) + expect(await fs.exists('/nonexistent.txt')).toBe(false) + }) + + it('should return true for existing directories', () => { + expect(fs.existsSync('/')).toBe(true) + }) + }) + + describe('unlink', () => { + it('should remove files', async () => { + await fs.writeFile('/test.txt', 'content') + expect(fs.existsSync('/test.txt')).toBe(true) + await fs.unlink('/test.txt') + expect(fs.existsSync('/test.txt')).toBe(false) + }) + + it('should throw error when unlinking non-existent file', async () => { + await expect(fs.unlink('/nonexistent.txt')).rejects.toThrow( + 'File not found: /nonexistent.txt', + ) + }) + }) +}) + +describe('InMemoryFileSystemService - Directory Operations - Mkdir', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('mkdir', () => { + it('should create directories', async () => { + await fs.mkdir('/newdir') + expect(fs.existsSync('/newdir')).toBe(true) + }) + + it('should create parent directories recursively', async () => { + await fs.mkdir('/deep/nested/dir', { recursive: true }) + expect(fs.existsSync('/deep')).toBe(true) + expect(fs.existsSync('/deep/nested')).toBe(true) + expect(fs.existsSync('/deep/nested/dir')).toBe(true) + }) + + it('should handle existing directories', async () => { + await fs.mkdir('/existing') + expect(() => fs.mkdir('/existing')).not.toThrow() + }) + }) +}) + +describe('InMemoryFileSystemService - Directory Operations - Readdir', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('readdir', () => { + it('should list directory contents', async () => { + await fs.writeFile('/dir/file1.txt', 'content1') + await fs.writeFile('/dir/file2.txt', 'content2') + await fs.mkdir('/dir/subdir') + + const entries = await fs.readdir('/dir') + expect(entries.map(e => e.name)).toContain('file1.txt') + expect(entries.map(e => e.name)).toContain('file2.txt') + expect(entries.map(e => e.name)).toContain('subdir') + }) + + it('should return empty array for empty directory', async () => { + await fs.mkdir('/emptydir') + expect((await fs.readdir('/emptydir')).length).toBe(0) + }) + + it('should throw error for non-existent directory', async () => { + await expect(fs.readdir('/nonexistent')).rejects.toThrow( + "no such file or directory '/nonexistent'", + ) + }) + }) +}) + +describe('InMemoryFileSystemService - Directory Operations - Stat', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('stat', () => { + it('should return file stats', async () => { + await fs.writeFile('/test.txt', 'content') + const stats = await fs.stat('/test.txt') + expect(stats.isFile()).toBe(true) + expect(stats.isDirectory()).toBe(false) + }) + + it('should return directory stats', async () => { + const stats = await fs.stat('/') + expect(stats.isFile()).toBe(false) + expect(stats.isDirectory()).toBe(true) + }) + + it('should throw error for non-existent path', async () => { + await expect(fs.stat('/nonexistent')).rejects.toThrow( + "no such file or directory '/nonexistent'", + ) + }) + }) +}) + +describe('InMemoryFileSystemService - Advanced Operations - Rename', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('rename', () => { + it('should rename files', async () => { + await fs.writeFile('/old.txt', 'content') + await fs.rename('/old.txt', '/new.txt') + expect(fs.existsSync('/old.txt')).toBe(false) + expect(fs.existsSync('/new.txt')).toBe(true) + expect(fs.readFileSync('/new.txt')).toBe('content') + }) + + it('should rename directories', async () => { + await fs.mkdir('/olddir') + await fs.writeFile('/olddir/file.txt', 'content') + await fs.rename('/olddir', '/newdir') + expect(fs.existsSync('/olddir')).toBe(false) + expect(fs.existsSync('/newdir')).toBe(true) + expect(fs.readFileSync('/newdir/file.txt')).toBe('content') + }) + + it('should throw error when renaming non-existent file', async () => { + await expect(fs.rename('/nonexistent.txt', '/new.txt')).rejects.toThrow( + 'Path not found: /nonexistent.txt', + ) + }) + }) +}) + +describe('InMemoryFileSystemService - Advanced Operations - Copy', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('copy', () => { + it('should copy files', async () => { + await fs.writeFile('/source.txt', 'content') + await fs.copy('/source.txt', '/dest.txt') + expect(fs.existsSync('/source.txt')).toBe(true) + expect(fs.existsSync('/dest.txt')).toBe(true) + expect(fs.readFileSync('/dest.txt')).toBe('content') + }) + + it('should create parent directories when copying', async () => { + await fs.writeFile('/source.txt', 'content') + await fs.copy('/source.txt', '/deep/nested/dest.txt') + expect(fs.existsSync('/deep/nested/dest.txt')).toBe(true) + expect(fs.readFileSync('/deep/nested/dest.txt')).toBe('content') + }) + + it('should throw error when copying non-existent file', async () => { + await expect(fs.copy('/nonexistent.txt', '/dest.txt')).rejects.toThrow( + 'Path not found: /nonexistent.txt', + ) + }) + }) +}) + +describe('InMemoryFileSystemService - Advanced Operations - CopySync', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + it('should copy a file to a new path', () => { + fs.writeFile('/foo.txt', 'hello') + fs.copySync('/foo.txt', '/bar.txt') + expect(fs.getContent('/bar.txt')).toBe('hello') + expect(fs.getContent('/foo.txt')).toBe('hello') + }) + + it('should copy a file to a new directory', () => { + fs.writeFile('/foo.txt', 'hello') + fs.copySync('/foo.txt', '/dir/bar.txt') + expect(fs.getContent('/dir/bar.txt')).toBe('hello') + }) + + it('should copy a directory recursively', () => { + fs.writeFile('/foo/bar/baz.txt', 'baz') + fs.copySync('/foo/bar', '/foo/barcopy') + expect(fs.getContent('/foo/barcopy/baz.txt')).toBe('baz') + expect(fs.getContent('/foo/bar/baz.txt')).toBe('baz') + }) + + it('should throw if source does not exist', () => { + expect(() => fs.copySync('/notfound', '/foo/x')).toThrow() + }) + + it('should copy nested directories and files', () => { + fs.writeFile('/foo/file.txt', 'hello') + fs.writeFile('/foo/bar/baz.txt', 'baz') + fs.copySync('/foo', '/fooCopy') + expect(fs.getContent('/fooCopy/file.txt')).toBe('hello') + expect(fs.getContent('/fooCopy/bar/baz.txt')).toBe('baz') + }) +}) + +describe('InMemoryFileSystemService - Advanced Operations - Rm', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('rm', () => { + it('should remove files', async () => { + await fs.writeFile('/test.txt', 'content') + await fs.rm('/test.txt') + expect(fs.existsSync('/test.txt')).toBe(false) + }) + + it('should remove directories recursively', async () => { + await fs.mkdir('/testdir', { recursive: true }) + await fs.writeFile('/testdir/file.txt', 'content') + await fs.rm('/testdir', { recursive: true }) + expect(fs.existsSync('/testdir')).toBe(false) + expect(fs.existsSync('/testdir/file.txt')).toBe(false) + }) + + it('should throw error when removing non-existent path', async () => { + await expect(fs.rm('/nonexistent')).rejects.toThrow('Path not found: /nonexistent') + }) + + it('should throw error when removing directory without recursive option', async () => { + await fs.mkdir('/testdir') + await fs.writeFile('/testdir/file.txt', 'content') + await expect(fs.rm('/testdir')).rejects.toThrow('Directory not empty: /testdir') + }) + }) +}) + +describe('InMemoryFileSystemService - Utility Methods', () => { + const moduleDir = '/app' + const workingDir = '/app' + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = new InMemoryFileSystemService({}, moduleDir, workingDir) + }) + + describe('utility methods', () => { + describe('getContent', () => { + it('should return file content for existing file', () => { + fs.writeFile('/file1.txt', 'content1') + expect(fs.getContent('/file1.txt')).toBe('content1') + }) + + it('should return undefined for non-existing file', () => { + expect(fs.getContent('/nonexistent.txt')).toBeUndefined() + }) + }) + + describe('accessSync', () => { + it('should not throw for existing files', () => { + fs.writeFile('/test.txt', 'content') + expect(() => fs.accessSync()).not.toThrow() + }) + + it('should not throw for non-existent files', () => { + expect(() => fs.accessSync()).not.toThrow() + }) + }) + }) +}) + +describe('InMemoryFileSystemService - Complex Scenarios - Project Structure', () => { + it('should handle complex project structure', async () => { + const fs = new InMemoryFileSystemService( + { + '/project/package.json': '{"name": "test"}', + '/project/src/index.ts': 'console.log("hello")', + '/project/src/utils.ts': 'export const util = () => {}', + }, + '/project', + '/project', + ) + + // Verify initial structure + expect(fs.readFileSync('/project/package.json')).toBe('{"name": "test"}') + expect(fs.readFileSync('/project/src/index.ts')).toBe('console.log("hello")') + expect(fs.readFileSync('/project/src/utils.ts')).toBe('export const util = () => {}') + + // Add more files + fs.writeFile('/project/tests/main.test.ts', 'describe("main", () => {})') + fs.writeFile('/project/README.md', '# Project') + + // Verify directory structure + expect((await fs.readdir('/project')).map(e => e.name)).toEqual( + expect.arrayContaining(['package.json', 'src', 'tests', 'README.md']), + ) + expect((await fs.readdir('/project/src')).map(e => e.name)).toEqual(['index.ts', 'utils.ts']) + expect((await fs.readdir('/project/tests')).map(e => e.name)).toEqual(['main.test.ts']) + }) +}) + +describe('InMemoryFileSystemService - Complex Scenarios - Path Resolution', () => { + it('should handle path resolution with custom working directory', () => { + const customFs = new InMemoryFileSystemService({}, '/custom/module', '/custom/work') + expect(customFs['state'].resolvePath('file.txt')).toBe('/custom/work/file.txt') + expect(customFs['state'].resolvePath('../file.txt')).toBe('/custom/file.txt') + expect(customFs['state'].resolvePath('dir/../file.txt')).toBe('/custom/work/file.txt') + + // Test absolute paths + expect(customFs['state'].resolvePath('/absolute/file.txt')).toBe('/absolute/file.txt') + }) +}) + +describe('InMemoryFileSystemService - ZIP Operations', () => { + it('should create and extract ZIP from single file', async () => { + const fs = new InMemoryFileSystemService( + { + '/project/file.txt': 'content', + }, + '/project', + '/project', + ) + + await fs.createZip(['/project/file.txt'], '/project/archive.zip') + + expect(fs.existsSync('/project/archive.zip')).toBe(true) + + await fs.extractZip('/project/archive.zip', '/project/extracted') + + expect(fs.existsSync('/project/extracted/file.txt')).toBe(true) + expect(await fs.readFile('/project/extracted/file.txt')).toBe('content') + }) + + it('should create and extract ZIP from directory', async () => { + const fs = new InMemoryFileSystemService( + { + '/project/src/index.ts': 'export {}', + '/project/src/utils.ts': 'export const util = 1', + '/project/src/nested/deep.ts': 'deep file', + }, + '/project', + '/project', + ) + + await fs.createZip(['/project/src'], '/project/bundle.zip') + + expect(fs.existsSync('/project/bundle.zip')).toBe(true) + + await fs.extractZip('/project/bundle.zip', '/project/output') + + expect(fs.existsSync('/project/output/index.ts')).toBe(true) + expect(fs.existsSync('/project/output/utils.ts')).toBe(true) + expect(fs.existsSync('/project/output/nested/deep.ts')).toBe(true) + expect(await fs.readFile('/project/output/index.ts')).toBe('export {}') + expect(await fs.readFile('/project/output/nested/deep.ts')).toBe('deep file') + }) + + it('should create ZIP from multiple sources', async () => { + const fs = new InMemoryFileSystemService( + { + '/project/README.md': '# Project', + '/project/src/index.ts': 'export {}', + '/project/config.json': '{}', + }, + '/project', + '/project', + ) + + await fs.createZip( + ['/project/README.md', '/project/config.json', '/project/src'], + '/project/package.zip', + ) + + await fs.extractZip('/project/package.zip', '/project/unpacked') + + expect(fs.existsSync('/project/unpacked/README.md')).toBe(true) + expect(fs.existsSync('/project/unpacked/config.json')).toBe(true) + expect(fs.existsSync('/project/unpacked/index.ts')).toBe(true) + }) + + it('should throw error when extracting non-existent ZIP', async () => { + const fs = new InMemoryFileSystemService({}, '/project', '/project') + + await expect(fs.extractZip('/project/missing.zip', '/project/out')).rejects.toThrow( + 'ZIP file not found', + ) + }) + + it('should handle empty directory in ZIP', async () => { + const fs = new InMemoryFileSystemService( + { + '/project/src/file.ts': 'content', + }, + '/project', + '/project', + ) + + await fs.createZip(['/project/src'], '/project/archive.zip') + await fs.extractZip('/project/archive.zip', '/project/restored') + + expect(fs.existsSync('/project/restored/file.ts')).toBe(true) + expect(await fs.readFile('/project/restored/file.ts')).toBe('content') + }) +}) diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.ts new file mode 100644 index 00000000..303b7610 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.ts @@ -0,0 +1,132 @@ +import type { Dirent, Stats } from 'fs' +import type { FileSystemService } from '../../file-system' +import { InMemoryFsState } from './in-memory-fs-state' +import { seedState } from './in-memory-fs-seed' +import * as read from './in-memory-fs-read' +import * as write from './in-memory-fs-write' + +/** + * In-memory FileSystemService test double. State lives in InMemoryFsState; the + * behavior is implemented in the focused in-memory-fs-{read,write,seed} modules + * and delegated to here so this class stays a thin, stable public surface. + */ +export class InMemoryFileSystemService implements FileSystemService { + private readonly state: InMemoryFsState + + constructor( + initial: Record = {}, + moduleDirectory: string, + workingDirectory: string, + ) { + this.state = new InMemoryFsState(moduleDirectory, workingDirectory) + seedState(this.state, initial, moduleDirectory, workingDirectory) + } + + accessSync() {} + + async readFile(path: string): Promise { + return read.readFileSync(this.state, path) + } + + readFileSync(path: string): string { + return read.readFileSync(this.state, path) + } + + existsSync(path: string): boolean { + return read.existsSync(this.state, path) + } + + async exists(path: string): Promise { + return read.existsSync(this.state, path) + } + + async stat(path: string): Promise { + return read.stat(this.state, path) + } + + async readdir(path: string): Promise { + return read.readdir(this.state, path) + } + + getContent(path: string): string | undefined { + return read.getContent(this.state, path) + } + + async isFile(path: string): Promise { + return read.isFile(this.state, path) + } + + async isFolder(path: string): Promise { + return read.isFolder(this.state, path) + } + + async writeFile(path: string, content: string): Promise { + return write.writeFile(this.state, path, content) + } + + async writeFileBinary(path: string, content: Buffer): Promise { + return write.writeFileBinary(this.state, path, content) + } + + async unlink(path: string): Promise { + return write.unlink(this.state, path) + } + + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + write.mkdirImpl(this.state, path, options) + } + + mkdirSync(path: string, options?: { recursive?: boolean }): void { + write.mkdirImpl(this.state, path, options) + } + + async rename(oldPath: string, newPath: string): Promise { + return write.rename(this.state, oldPath, newPath) + } + + async copy(oldPath: string, newPath: string): Promise { + write.copyImpl(this.state, oldPath, newPath) + } + + copySync(oldPath: string, newPath: string): void { + write.copyImpl(this.state, oldPath, newPath) + } + + async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + return write.rm(this.state, path, options) + } + + async symlink(target: string, path: string): Promise { + return write.symlink(this.state, target, path) + } + + getSymlinks(): Map { + return new Map(this.state.symlinks) + } + + rootModuleDirectory(): string { + return this.state.moduleDirectory + } + + currentWorkingDirectory(): string { + return this.state.workingDirectory + } + + resolve(...paths: string[]): string { + return this.state.resolve(...paths) + } + + chdir(path: string): void { + write.chdir(this.state, path) + } + + async createZip(sourcePaths: string[], outputPath: string): Promise { + return write.createZip(this.state, sourcePaths, outputPath) + } + + async extractZip(zipPath: string, outputDir: string): Promise { + return write.extractZip(this.state, zipPath, outputDir) + } +} + +export default InMemoryFileSystemService diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md index eab679d8..8cb1a48e 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md @@ -52,6 +52,12 @@ UserProfile.stories.tsx (if using Storybook) This does not apply to two other, already-common test categories that are correctly named after what they validate rather than a source module: end-to-end/page-level tests (e.g. `landing.e2e.test.ts`, testing a user flow across many files by design) and content/asset-validation tests (e.g. asserting on a generated markdown file's content, where there is no single source module to co-locate against). +The end-to-end exemption is checkable, not just descriptive — all three must hold for a given `*.e2e.test.ts` file, or it should be a regular co-located test instead: + +1. **Named after a real flow**: the file name corresponds to an identifiable user-facing flow or CLI command (e.g. `cli-install.e2e.test.ts` for the install flow), not an arbitrary grouping of convenience. +2. **Additive, not sole coverage**: every module/command the e2e file exercises also has its own co-located unit test file (e.g. `handler.test.ts`) covering its non-integration behavior. The e2e file is verified to be additive cross-cutting coverage, never the only coverage for a module — that would be the isolation-smell case. +3. **Genuine cross-module interaction**: the file exercises more than one command/module with a real interaction between them. If it exercises exactly one module with no cross-module flow, that's a signal it should have been a regular unit test in that module's own test file instead — the exemption is for genuine multi-module flows, not a generic escape hatch. + **Types**: Co-locate types when feature-specific: ``` From 22c57589d91cbfaf12537320bfc6416aedd2a4d0 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 15:45:23 +0200 Subject: [PATCH 14/21] [#199] fix: relocate cli-e2e-helpers.ts to pair-cli's test-utils/ (PR #311 review) Root of src/ isn't the right place for e2e-only test infra. Follows the pair-cli's own existing #test-utils/ precedent (test-helpers.ts, test-setup.ts) instead of inventing a new convention. --- apps/pair-cli/src/cli-e2e-helpers.ts | 164 ------------------ apps/pair-cli/src/cli-link.e2e.test.ts | 2 +- apps/pair-cli/src/cli-packaging.e2e.test.ts | 2 +- .../src/cli-validate-config.e2e.test.ts | 2 +- 4 files changed, 3 insertions(+), 167 deletions(-) delete mode 100644 apps/pair-cli/src/cli-e2e-helpers.ts diff --git a/apps/pair-cli/src/cli-e2e-helpers.ts b/apps/pair-cli/src/cli-e2e-helpers.ts deleted file mode 100644 index d6905c4e..00000000 --- a/apps/pair-cli/src/cli-e2e-helpers.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' - -/** - * Shared fixtures and helpers for the pair-cli e2e suites (split per command - * from the former monolithic cli.e2e.test.ts). Each e2e file imports the - * fixtures it needs from here. - */ - -export function createNpmDeployFs(cwd: string): InMemoryFileSystemService { - // Simulate npm install: pair-cli extracted to node_modules/@foomakers/pair-cli/ - // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. - // We simulate the cached KB path so tests can resolve the dataset without network. - const seed: Record = {} - const moduleFolder = cwd + '/node_modules/@foomakers/pair-cli' - const kbCachePath = cwd + '/.pair-kb-cache/0.1.1' - - // Dataset content in the KB cache location (simulates auto-download result) - seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' - seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - - // Package.json for pair-cli (scoped package) — no bundle-cli/dataset - seed[moduleFolder + '/package.json'] = JSON.stringify({ - name: '@foomakers/pair-cli', - version: '0.1.1', - }) - - // Sample project package.json - seed[cwd + '/package.json'] = JSON.stringify({ - name: 'pair-sample-project', - version: '1.0.0', - dependencies: { - '@foomakers/pair-cli': 'file:../pkg/package', - }, - }) - - return new InMemoryFileSystemService(seed, moduleFolder, cwd) -} - -export function createManualDeployFs(cwd: string): InMemoryFileSystemService { - // Simulate manual installation: pair-cli binary standalone. - // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. - const seed: Record = {} - const moduleFolder = cwd + '/libs/pair-cli' - const kbCachePath = cwd + '/.pair-kb-cache/0.1.0' - - // Add package.json for @pair/pair-cli package — no bundle-cli/dataset - seed[cwd + '/libs/pair-cli/package.json'] = JSON.stringify({ - name: '@pair/pair-cli', - version: '0.1.0', - description: 'Pair CLI manual installation', - }) - - // Dataset content in the KB cache location (simulates auto-download result) - seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' - seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - - return new InMemoryFileSystemService(seed, moduleFolder, cwd) -} - -export function createDevScenarioFs(cwd: string): InMemoryFileSystemService { - // Simulate development scenario: pair-cli as regular node_modules dependency - // Dataset is at node_modules/@pair/knowledge-hub/dataset/ (accessible from project root) - const seed: Record = {} - - // Dataset content in the @pair/knowledge-hub package location (project's node_modules) - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/AGENTS.md'] = 'this is agents.md' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.github/workflows/ci.yml'] = - 'name: CI\non: push' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/knowledge/index.md'] = - '# Knowledge Base' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/adoption/onboarding.md'] = - '# Onboarding Guide' - - // Package.json for @pair/knowledge-hub - seed[cwd + '/node_modules/@pair/knowledge-hub/package.json'] = JSON.stringify({ - name: '@pair/knowledge-hub', - version: '0.1.0', - }) - - // Package.json for pair-cli (regular dependency) - seed[cwd + '/node_modules/pair-cli/package.json'] = JSON.stringify({ - name: 'pair-cli', - version: '0.1.0', - }) - - // Sample project package.json - seed[cwd + '/package.json'] = JSON.stringify({ - name: 'pair-cli', - version: '1.0.0-wip', - dependencies: { - '@pair/knowledge-hub': 'catalog:*', - }, - }) - - return new InMemoryFileSystemService(seed, cwd, cwd) -} - -export async function withTempConfig( - fs: InMemoryFileSystemService, - config: unknown, - fn: () => Promise, -): Promise { - const configPath = fs.rootModuleDirectory() + '/config.json' - await fs.writeFile(configPath, JSON.stringify(config)) - try { - await fn() - } finally { - // Cleanup if needed - } -} - -export function createTestConfig() { - return { - asset_registries: { - '.github': { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configs', - }, - '.pair-knowledge': { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair-knowledge', mode: 'canonical' }], - description: 'Knowledge base content', - }, - '.pair-adoption': { - source: '.pair/adoption', - behavior: 'mirror', - targets: [{ path: '.pair-adoption', mode: 'canonical' }], - description: 'Adoption and onboarding content', - }, - 'agents.md': { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - } -} - -export function getDeploymentConfig(deployType: 'npm' | 'manual' | 'dev'): { - cwd: string - fs: InMemoryFileSystemService -} { - const cwd = - deployType === 'npm' - ? '/.tmp/npm-test/sample-project' - : deployType === 'manual' - ? '/tmp/test-project' - : '/dev/test-project' - const fs = - deployType === 'npm' - ? createNpmDeployFs(cwd) - : deployType === 'manual' - ? createManualDeployFs(cwd) - : createDevScenarioFs(cwd) - return { cwd, fs } -} diff --git a/apps/pair-cli/src/cli-link.e2e.test.ts b/apps/pair-cli/src/cli-link.e2e.test.ts index a9a9f6d1..b07a36a2 100644 --- a/apps/pair-cli/src/cli-link.e2e.test.ts +++ b/apps/pair-cli/src/cli-link.e2e.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' import { installCommand, updateCommand } from './commands' -import { withTempConfig, createTestConfig, getDeploymentConfig } from './cli-e2e-helpers' +import { withTempConfig, createTestConfig, getDeploymentConfig } from '#test-utils/cli-e2e-helpers' describe('pair-cli e2e - link strategy', () => { it('install with relative link style', async () => { diff --git a/apps/pair-cli/src/cli-packaging.e2e.test.ts b/apps/pair-cli/src/cli-packaging.e2e.test.ts index b768cb80..8d097f3f 100644 --- a/apps/pair-cli/src/cli-packaging.e2e.test.ts +++ b/apps/pair-cli/src/cli-packaging.e2e.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' import { installCommand, handleUpdateCommand, parseUpdateCommand } from './commands' -import { withTempConfig } from './cli-e2e-helpers' +import { withTempConfig } from '#test-utils/cli-e2e-helpers' describe('pair-cli e2e - package command', () => { it('package command creates valid ZIP with manifest', async () => { diff --git a/apps/pair-cli/src/cli-validate-config.e2e.test.ts b/apps/pair-cli/src/cli-validate-config.e2e.test.ts index 52abc3ec..eb556462 100644 --- a/apps/pair-cli/src/cli-validate-config.e2e.test.ts +++ b/apps/pair-cli/src/cli-validate-config.e2e.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { createDevScenarioFs, withTempConfig, createTestConfig } from './cli-e2e-helpers' +import { createDevScenarioFs, withTempConfig, createTestConfig } from '#test-utils/cli-e2e-helpers' describe('pair-cli e2e - validate-config success', () => { it('validate-config succeeds with valid config', async () => { From 5c23d99f7c03f7c079e390f47f1ffe1d554dfcf7 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 15:45:39 +0200 Subject: [PATCH 15/21] [#199] fix: fold copyPathOps + in-memory-fs splits into folder modules (PR #311 review) Both were split (#199 T4/T9) into sibling files, but only one entry point is externally consumed in each case (copyPathOps / copyDirectoryWithTransforms; InMemoryFileSystemService). Group each split under its own folder with an index.ts barrel so the directory signals public surface vs internal collaborators: - content-ops/src/ops/copyPathOps.ts + copy-{file,directory, directory-transforms,types}.ts -> ops/copy/ + index.ts - content-ops/src/test-utils/in-memory-fs.ts + in-memory-fs-{state, read,write,seed}.ts -> test-utils/in-memory-fs/ + index.ts Public API unchanged (same import specifiers, now resolving through the folder). package.json's "./test-utils/in-memory-fs" subpath export updated to the new dist path. content-ops (618) + pair-cli (869) tests green. --- packages/content-ops/package.json | 6 +- packages/content-ops/src/index.ts | 2 +- .../src/ops/copy-directory-transforms.ts | 356 ---------- .../content-ops/src/ops/copy-directory.ts | 219 ------ packages/content-ops/src/ops/copy-file.ts | 72 -- packages/content-ops/src/ops/copy-types.ts | 12 - packages/content-ops/src/ops/copy/index.ts | 1 + .../content-ops/src/ops/copyPathOps.test.ts | 653 ------------------ packages/content-ops/src/ops/copyPathOps.ts | 228 ------ .../src/test-utils/in-memory-fs-read.ts | 69 -- .../src/test-utils/in-memory-fs-seed.ts | 31 - .../src/test-utils/in-memory-fs-state.ts | 59 -- .../src/test-utils/in-memory-fs-write.ts | 253 ------- .../src/test-utils/in-memory-fs.test.ts | 563 --------------- .../src/test-utils/in-memory-fs.ts | 132 ---- .../src/test-utils/in-memory-fs/index.ts | 2 + 16 files changed, 7 insertions(+), 2651 deletions(-) delete mode 100644 packages/content-ops/src/ops/copy-directory-transforms.ts delete mode 100644 packages/content-ops/src/ops/copy-directory.ts delete mode 100644 packages/content-ops/src/ops/copy-file.ts delete mode 100644 packages/content-ops/src/ops/copy-types.ts create mode 100644 packages/content-ops/src/ops/copy/index.ts delete mode 100644 packages/content-ops/src/ops/copyPathOps.test.ts delete mode 100644 packages/content-ops/src/ops/copyPathOps.ts delete mode 100644 packages/content-ops/src/test-utils/in-memory-fs-read.ts delete mode 100644 packages/content-ops/src/test-utils/in-memory-fs-seed.ts delete mode 100644 packages/content-ops/src/test-utils/in-memory-fs-state.ts delete mode 100644 packages/content-ops/src/test-utils/in-memory-fs-write.ts delete mode 100644 packages/content-ops/src/test-utils/in-memory-fs.test.ts delete mode 100644 packages/content-ops/src/test-utils/in-memory-fs.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/index.ts diff --git a/packages/content-ops/package.json b/packages/content-ops/package.json index 3993260e..ccb50bd7 100644 --- a/packages/content-ops/package.json +++ b/packages/content-ops/package.json @@ -27,9 +27,9 @@ "require": "./dist/ops/SyncOptions.js" }, "./test-utils/in-memory-fs": { - "types": "./dist/test-utils/in-memory-fs.d.ts", - "import": "./dist/test-utils/in-memory-fs.js", - "require": "./dist/test-utils/in-memory-fs.js" + "types": "./dist/test-utils/in-memory-fs/index.d.ts", + "import": "./dist/test-utils/in-memory-fs/index.js", + "require": "./dist/test-utils/in-memory-fs/index.js" } }, "scripts": { diff --git a/packages/content-ops/src/index.ts b/packages/content-ops/src/index.ts index 8e878cd8..95e9f057 100644 --- a/packages/content-ops/src/index.ts +++ b/packages/content-ops/src/index.ts @@ -54,7 +54,7 @@ export { type TargetConfig, type TransformConfig, } from './ops/behavior' -export { copyPathOps, copyDirectoryWithTransforms, type CopyPathOpsResult } from './ops/copyPathOps' +export { copyPathOps, copyDirectoryWithTransforms, type CopyPathOpsResult } from './ops/copy' export { flattenPath, prefixPath, transformPath, detectCollisions } from './ops/naming-transforms' export { rewriteLinksInFile, diff --git a/packages/content-ops/src/ops/copy-directory-transforms.ts b/packages/content-ops/src/ops/copy-directory-transforms.ts deleted file mode 100644 index ffd737b5..00000000 --- a/packages/content-ops/src/ops/copy-directory-transforms.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { join, relative, dirname } from 'path/posix' -import { logger, createError } from '../observability' -import { copyFileHelper } from '../file-system' -import { FileSystemService } from '../file-system' -import { SyncOptions } from './SyncOptions' -import { transformPath, detectCollisions } from './naming-transforms' -import { rewriteLinksAfterTransform, PathMappingEntry } from './link-rewriter' -import { syncFrontmatter } from './frontmatter-transform' -import { - buildSkillNameMap, - rewriteSkillReferencesInFiles, - SkillNameMap, -} from './skill-reference-rewriter' -import type { CopyPathOpsResult, TransformOpts } from './copy-types' - -/** - * Recursively collects all files under a directory, returning their paths - * relative to the given root directory. - */ -async function collectFiles( - fileService: FileSystemService, - dirPath: string, - rootPath: string, -): Promise { - const result: string[] = [] - const entries = await fileService.readdir(dirPath) - for (const entry of entries) { - const entryPath = join(dirPath, entry.name) - if (entry.isDirectory()) { - const subFiles = await collectFiles(fileService, entryPath, rootPath) - result.push(...subFiles) - } else { - const relPath = relative(rootPath, entryPath) - result.push(relPath) - } - } - return result -} - -/** - * Collects unique subdirectory names from a file list, validates no - * flatten collisions exist, and throws if any are found. - */ -function validateNoCollisions( - files: string[], - transformOpts: TransformOpts, - srcPath: string, -): void { - const dirSet = new Set() - for (const filePath of files) { - const dir = dirname(filePath) - if (dir !== '.') dirSet.add(dir) - } - const transformedDirs = [...dirSet].map(d => transformPath(d, transformOpts)) - const collisions = detectCollisions(transformedDirs) - if (collisions.length > 0) { - throw createError({ - type: 'IO_ERROR', - message: `Flatten naming collision detected: ${collisions.join(', ')}. Different source paths resolve to the same target name.`, - operation: 'copyDir', - path: srcPath, - }) - } -} - -/** - * Copies a single file to its transformed location and tracks the - * directory mapping for later link rewriting. - */ -async function copyFileWithTransform(ctx: { - fileService: FileSystemService - filePath: string - srcPath: string - destPath: string - transformOpts: TransformOpts - dirMappingFiles: Map - topLevelFiles: Set -}): Promise { - const { - fileService, - filePath, - srcPath, - destPath, - transformOpts, - dirMappingFiles, - topLevelFiles, - } = ctx - const dir = dirname(filePath) - const fileName = filePath.slice(dir === '.' ? 0 : dir.length + 1) - const transformedDir = dir === '.' ? null : transformPath(dir, transformOpts) - const targetDir = transformedDir ? join(destPath, transformedDir) : destPath - const targetFilePath = join(targetDir, fileName) - - await fileService.mkdir(targetDir, { recursive: true }) - await copyFileHelper(fileService, join(srcPath, filePath), targetFilePath, 'overwrite') - - await trackTransformedFile({ - fileService, - dir, - fileName, - transformedDir, - targetFilePath, - dirMappingFiles, - topLevelFiles, - }) -} - -/** - * Post-copy bookkeeping for a transformed file: syncs the frontmatter `name` - * when a subdirectory was renamed, and records the file in either - * `dirMappingFiles` (files under a subdirectory) or `topLevelFiles` (root-level - * source files) so link rewriting and mirror cleanup can see it. - */ -async function trackTransformedFile(ctx: { - fileService: FileSystemService - dir: string - fileName: string - transformedDir: string | null - targetFilePath: string - dirMappingFiles: Map - topLevelFiles: Set -}): Promise { - const { - fileService, - dir, - fileName, - transformedDir, - targetFilePath, - dirMappingFiles, - topLevelFiles, - } = ctx - - if (dir === '.') { - // Root-level source file — has no transformed subdirectory of its own, so it's - // never added to dirMappingFiles. Track it separately so cleanupStaleTransformedEntries - // knows it's a legitimate copy, not a stale leftover (see that function's docstring). - topLevelFiles.add(fileName) - return - } - if (!transformedDir) return - - const leafName = dir.split('/').pop()! - if (leafName !== transformedDir) { - const content = await fileService.readFile(targetFilePath) - const synced = syncFrontmatter(content, { from: leafName, to: transformedDir }) - if (synced !== content) { - await fileService.writeFile(targetFilePath, synced) - } - } - - if (!dirMappingFiles.has(dir)) dirMappingFiles.set(dir, []) - dirMappingFiles.get(dir)!.push(targetFilePath) -} - -/** - * Builds PathMappingEntry[] from the directory-to-files map collected during copy. - */ -function buildPathMapping( - dirMappingFiles: Map, - transformOpts: TransformOpts, - sourceRelative: string, - targetRelative: string, -): PathMappingEntry[] { - const pathMapping: PathMappingEntry[] = [] - for (const [originalSubDir, mappedFiles] of dirMappingFiles) { - const transformedSubDir = transformPath(originalSubDir, transformOpts) - pathMapping.push({ - originalDir: join(sourceRelative, originalSubDir), - newDir: join(targetRelative, transformedSubDir), - files: mappedFiles, - }) - } - return pathMapping -} - -/** - * Copies every file with naming transforms applied, collecting both the - * per-subdirectory mapping (for link rewriting, skill renames, and mirror - * cleanup of transformed directories) and the set of root-level file names - * (for mirror cleanup of files that have no subdirectory of their own). - */ -async function copyAllFilesWithTransform(params: { - fileService: FileSystemService - files: string[] - srcPath: string - destPath: string - transformOpts: TransformOpts -}): Promise<{ dirMappingFiles: Map; topLevelFiles: Set }> { - const { fileService, files, srcPath, destPath, transformOpts } = params - const dirMappingFiles = new Map() - const topLevelFiles = new Set() - for (const filePath of files) { - await copyFileWithTransform({ - fileService, - filePath, - srcPath, - destPath, - transformOpts, - dirMappingFiles, - topLevelFiles, - }) - } - return { dirMappingFiles, topLevelFiles } -} - -/** - * Copies a directory with flatten/prefix naming transforms applied. - * Each file's directory path (relative to source) is transformed, then - * the file is copied to the transformed location under the target. - */ -function buildTransformOpts(options?: SyncOptions): TransformOpts { - const flatten = options?.flatten ?? false - const prefix = options?.prefix - return prefix ? { flatten, prefix } : { flatten } -} - -export async function copyDirectoryWithTransforms(params: { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - datasetRoot: string - options?: SyncOptions -}): Promise { - const { fileService, srcPath, destPath, options } = params - const transformOpts = buildTransformOpts(options) - - const files = await collectFiles(fileService, srcPath, srcPath) - validateNoCollisions(files, transformOpts, srcPath) - - await fileService.mkdir(destPath, { recursive: true }) - - const { dirMappingFiles, topLevelFiles } = await copyAllFilesWithTransform({ - fileService, - files, - srcPath, - destPath, - transformOpts, - }) - - if (options?.defaultBehavior === 'mirror') { - await cleanupStaleTransformedEntries({ - fileService, - destPath, - dirMappingFiles, - topLevelFiles, - transformOpts, - }) - } - - await rewriteLinksForTransformedDirs(params, dirMappingFiles, transformOpts) - const skillNameMap = await applySkillReferenceRewrites( - fileService, - dirMappingFiles, - transformOpts, - ) - - logger.info( - `Copied contents of ${srcPath} -> ${destPath} (flatten=${transformOpts.flatten}, prefix=${transformOpts.prefix ?? 'none'})`, - ) - return skillNameMap.size > 0 ? { skillNameMap } : {} -} - -/** - * Removes stale top-level entries under a flatten/prefix target that no - * longer correspond to a source directory or a root-level source file. This - * is what makes `mirror` behavior idempotent across renames: a removed - * skill's leftover flattened directory is cleaned up, and a prefix change no - * longer leaves the old prefixed directory orphaned alongside the new one. - * - * Only top-level entries are considered — matches the granularity of the - * non-transform `handleMirrorCleanup` and the flatten use case (one source - * subdirectory maps to exactly one top-level target directory). - * - * `topLevelFiles` (file names copied directly from the source root, with no - * subdirectory of their own) must be included in `expected` alongside the - * transformed directory names — they're never in `dirMappingFiles` (which - * only tracks files under a subdirectory), so without this they'd be - * wrongly deleted as stale on the very next mirror run. - */ -async function cleanupStaleTransformedEntries(params: { - fileService: FileSystemService - destPath: string - dirMappingFiles: Map - topLevelFiles: Set - transformOpts: TransformOpts -}): Promise { - const { fileService, destPath, dirMappingFiles, topLevelFiles, transformOpts } = params - - const expected = new Set(topLevelFiles) - for (const originalSubDir of dirMappingFiles.keys()) { - const transformedDir = transformPath(originalSubDir, transformOpts) - expected.add(transformedDir.split('/')[0]!) - } - - const entries = await fileService.readdir(destPath).catch(() => []) - for (const entry of entries) { - if (expected.has(entry.name)) continue - const toRemove = join(destPath, entry.name) - await fileService.rm(toRemove, { recursive: true, force: true }) - logger.info(`Mirror: removed stale transformed entry ${toRemove}`) - } -} - -async function rewriteLinksForTransformedDirs( - params: { - fileService: FileSystemService - datasetRoot: string - srcPath: string - destPath: string - source: string - target: string - }, - dirMappingFiles: Map, - transformOpts: TransformOpts, -): Promise { - const sourceRelative = relative(params.datasetRoot, params.srcPath) || params.source - const targetRelative = relative(params.datasetRoot, params.destPath) || params.target - const pathMapping = buildPathMapping( - dirMappingFiles, - transformOpts, - sourceRelative, - targetRelative, - ) - if (pathMapping.length > 0) { - const sourceContentRoot = dirname(sourceRelative) - await rewriteLinksAfterTransform({ - fileService: params.fileService, - pathMapping, - datasetRoot: params.datasetRoot, - ...(sourceContentRoot !== '.' && { sourceContentRoot }), - }) - } -} - -/** - * Collects .md files from dirMappingFiles and rewrites skill references if any renames occurred. - */ -async function applySkillReferenceRewrites( - fileService: FileSystemService, - dirMappingFiles: Map, - transformOpts: TransformOpts, -): Promise { - const skillNameMap = buildSkillNameMap(dirMappingFiles, transformOpts) - if (skillNameMap.size === 0) return skillNameMap - - const allMdFiles: string[] = [] - for (const mappedFiles of dirMappingFiles.values()) { - for (const f of mappedFiles) { - if (f.endsWith('.md')) allMdFiles.push(f) - } - } - await rewriteSkillReferencesInFiles({ fileService, files: allMdFiles, skillNameMap }) - return skillNameMap -} diff --git a/packages/content-ops/src/ops/copy-directory.ts b/packages/content-ops/src/ops/copy-directory.ts deleted file mode 100644 index 23de56d0..00000000 --- a/packages/content-ops/src/ops/copy-directory.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { join } from 'path/posix' -import { logger, createError } from '../observability' -import { copyDirHelper } from '../file-system' -import type { CopyDirContext } from '../file-system/file-operations' -import { FileSystemService } from '../file-system' -import { SyncOptions } from './SyncOptions' -import { Behavior, normalizeKey, resolveBehavior } from './behavior' -import { - updateMarkdownLinks, - handleMirrorCleanup, - validateSubfolderOperation, -} from './path-operation-helpers' -import { convertToRelative } from '../path-resolution' - -export type HandleDirectoryCopyParams = { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - normSource: string - normTarget: string - datasetRoot: string - defaultBehavior: Behavior - folderBehavior?: Record - options?: SyncOptions -} - -/** - * Handles directory copy operations - */ -export async function handleDirectoryCopy(params: HandleDirectoryCopyParams) { - const { - fileService, - srcPath, - destPath, - source, - target, - normSource, - normTarget, - datasetRoot, - defaultBehavior, - folderBehavior, - options, - } = params - - // Handle different behaviors for directories - const sourceFolderBehavior = - resolveSourceFolderBehavior( - datasetRoot, - normSource, - folderBehavior, - defaultBehavior ?? 'overwrite', - ) ?? 'overwrite' - - if (sourceFolderBehavior === 'skip') { - logger.info(`Skipping directory ${srcPath} due to 'skip' behavior`) - return - } - - await performDirectoryCopyAndUpdate({ - fileService, - srcPath, - destPath, - normSource, - normTarget, - datasetRoot, - ...(folderBehavior && { folderBehavior }), - sourceFolderBehavior, - defaultBehavior: defaultBehavior ?? 'overwrite', - source, - target, - ...(options && { options }), - }) -} - -async function performDirectoryCopyAndUpdate(params: { - fileService: FileSystemService - srcPath: string - destPath: string - normSource: string - normTarget: string - datasetRoot: string - folderBehavior?: Record - sourceFolderBehavior: Behavior - defaultBehavior: Behavior - source: string - target: string - options?: SyncOptions -}) { - const { fileService, destPath, datasetRoot, folderBehavior, source, target, options, ...rest } = - params - - await performDirectoryCopy({ - ...rest, - fileService, - destPath, - datasetRoot, - ...(folderBehavior && { folderBehavior }), - }) - - await updateLinksAfterDirectoryCopy({ - fileService, - source, - target, - datasetRoot, - finalDest: destPath, - ...(options && { options }), - }) -} - -/** - * Updates markdown links after directory copy operation - */ -async function updateLinksAfterDirectoryCopy(params: { - fileService: FileSystemService - source: string - target: string - datasetRoot: string - finalDest: string - options?: SyncOptions -}) { - await updateMarkdownLinks({ - fileService: params.fileService, - source: params.source, - target: params.target, - datasetRoot: params.datasetRoot, - finalDest: params.finalDest, - isDirectory: true, - options: params.options, - }) -} - -function resolveSourceFolderBehavior( - datasetRoot: string, - normSource: string, - folderBehavior?: Record, - defaultBehavior: Behavior = 'overwrite', -) { - const rel = convertToRelative(datasetRoot, join(datasetRoot, normSource)) - const relSourceKey = normalizeKey(rel) - return resolveBehavior(relSourceKey, folderBehavior, defaultBehavior) -} - -async function performDirectoryCopy(params: { - fileService: FileSystemService - srcPath: string - destPath: string - normSource: string - normTarget: string - datasetRoot: string - folderBehavior?: Record - sourceFolderBehavior: Behavior - defaultBehavior: Behavior -}) { - const { - fileService, - srcPath, - destPath, - normSource, - normTarget, - datasetRoot, - folderBehavior, - sourceFolderBehavior, - defaultBehavior, - } = params - await fileService.mkdir(destPath, { recursive: true }) - validateSubfolderOperation({ srcPath, destPath, normSource, normTarget, operation: 'copy' }) - - if (sourceFolderBehavior === 'mirror') { - await handleMirrorCleanup(fileService, srcPath, destPath) - } - - await copyDirectoryContents({ - fileService, - srcPath, - destPath, - datasetRoot, - ...(folderBehavior && { folderBehavior }), - defaultBehavior, - }) - - logger.info(`Copied contents of ${srcPath} -> ${destPath}`) -} - -async function copyDirectoryContents(params: { - fileService: FileSystemService - srcPath: string - destPath: string - datasetRoot: string - folderBehavior?: Record - defaultBehavior: Behavior -}) { - const { fileService, srcPath, destPath, datasetRoot, folderBehavior, defaultBehavior } = params - - try { - const copyContext: CopyDirContext = { - fileService, - oldDir: srcPath, - newDir: destPath, - defaultBehavior, - datasetRoot, - ...(folderBehavior && { folderBehavior }), - } - await copyDirHelper(copyContext) - } catch (err) { - logger.error(`Failed to copy entries: ${String(err)}`) - if (err instanceof Error && err.message.includes('boom')) { - throw err - } - throw createError({ - type: 'IO_ERROR', - message: `Failed to copy directory contents from ${srcPath} to ${destPath}`, - operation: 'copyDir', - path: srcPath, - originalError: err, - }) - } -} diff --git a/packages/content-ops/src/ops/copy-file.ts b/packages/content-ops/src/ops/copy-file.ts deleted file mode 100644 index d0a38915..00000000 --- a/packages/content-ops/src/ops/copy-file.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { logger, createError } from '../observability' -import { copyFileHelper } from '../file-system' -import { FileSystemService } from '../file-system' -import { SyncOptions } from './SyncOptions' -import { Behavior } from './behavior' -import { determineFinalDestination, updateMarkdownLinks } from './path-operation-helpers' -import { rewriteSkillReferencesInFiles, SkillNameMap } from './skill-reference-rewriter' - -export type HandleFileCopyParams = { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - normTarget: string - datasetRoot: string - defaultBehavior: Behavior - options?: SyncOptions - skillNameMap?: SkillNameMap -} - -/** - * Handles file copy operations - */ -export async function handleFileCopy(params: HandleFileCopyParams) { - const { - fileService, - srcPath, - destPath, - source, - target, - normTarget, - datasetRoot, - defaultBehavior, - options, - skillNameMap, - } = params - - const finalDest = await determineFinalDestination(fileService, destPath, source, normTarget) - - try { - await copyFileHelper(fileService, srcPath, finalDest, defaultBehavior) - } catch (err) { - logger.error(`Failed to copy file ${srcPath} -> ${finalDest}: ${String(err)}`) - // If the original error message is specific (like test errors), preserve it - if (err instanceof Error && err.message.includes('boom')) { - throw err - } - throw createError({ - type: 'IO_ERROR', - message: `Failed to copy file ${srcPath} -> ${finalDest}`, - operation: 'copyFile', - path: srcPath, - originalError: err, - }) - } - logger.info(`Copied file ${srcPath} -> ${finalDest}`) - - await updateMarkdownLinks({ - fileService, - source, - target, - datasetRoot, - finalDest, - isDirectory: false, - options, - }) - - if (skillNameMap && skillNameMap.size > 0 && finalDest.endsWith('.md')) { - await rewriteSkillReferencesInFiles({ fileService, files: [finalDest], skillNameMap }) - } -} diff --git a/packages/content-ops/src/ops/copy-types.ts b/packages/content-ops/src/ops/copy-types.ts deleted file mode 100644 index bfc623b6..00000000 --- a/packages/content-ops/src/ops/copy-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SkillNameMap } from './skill-reference-rewriter' - -/** - * Result of a copy operation. Carries the skill name map when a - * transform copy renamed skills, so callers can chain reference rewrites. - */ -export type CopyPathOpsResult = { - skillNameMap?: SkillNameMap -} - -/** Naming transform options (flatten and/or prefix) applied during a copy. */ -export type TransformOpts = { flatten: boolean; prefix?: string } diff --git a/packages/content-ops/src/ops/copy/index.ts b/packages/content-ops/src/ops/copy/index.ts new file mode 100644 index 00000000..5fe79a0e --- /dev/null +++ b/packages/content-ops/src/ops/copy/index.ts @@ -0,0 +1 @@ +export { copyPathOps, copyDirectoryWithTransforms, type CopyPathOpsResult } from './copyPathOps' diff --git a/packages/content-ops/src/ops/copyPathOps.test.ts b/packages/content-ops/src/ops/copyPathOps.test.ts deleted file mode 100644 index 5e889383..00000000 --- a/packages/content-ops/src/ops/copyPathOps.test.ts +++ /dev/null @@ -1,653 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { copyPathOps } from './copyPathOps' -import { - TEST_SETUP, - TEST_ASSERTIONS, - TEST_FILE_STRUCTURES, - InMemoryFileSystemService, -} from '../test-utils' - -describe('copyPathOps', () => { - let fileService: InMemoryFileSystemService - - beforeEach(() => { - fileService = TEST_SETUP.createBasicSetup() - }) - - it('should copy a file and update links', async () => { - const result = await copyPathOps({ - fileService, - source: 'source.md', - target: 'copied.md', - datasetRoot: '/dataset', - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/copied.md', - '# Source File\n[link](target.md)', - ) - }) - - it('should throw INVALID_PATH error for absolute source and target paths', async () => { - await expect( - copyPathOps({ - fileService, - source: '/dataset/kb/source.md', - target: '/project/kb/copied.md', - datasetRoot: '/dataset', - }), - ).rejects.toThrow('Source and target paths must be relative, not absolute') - }) - - it('should update links in other files when copying', async () => { - const result = await copyPathOps({ - fileService, - source: 'source.md', - target: 'copied.md', - datasetRoot: '/dataset', - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileContains(fileService, '/dataset/other.md', '[link](copied.md)') - }) -}) - -describe('copyPathOps - root file operations', () => { - beforeEach(() => {}) - - it('should copy using the provided example parameters and overwrite existing root file', async () => { - const fs = new InMemoryFileSystemService( - { - '/development/path/pair/apps/pair-cli/config.json': - '{"asset_registries":{"agents":{"source":"AGENTS.md","behavior":"overwrite"}}}', - '/development/path/pair/apps/pair-cli/dataset/AGENTS.md': 'agents content', - }, - '/development/path/pair/apps/pair-cli', - '/development/path/pair/apps/pair-cli', - ) - - const options = { - fileService: fs, - source: 'dataset/AGENTS.md', - target: 'AGENTS.md', - datasetRoot: '/development/path/pair/apps/pair-cli', - options: { - defaultBehavior: 'overwrite' as const, - folderBehavior: undefined, - flatten: false, - targets: [], - }, - } - - const result = await copyPathOps(options) - TEST_ASSERTIONS.assertSuccessfulOperation(result) - - // Verify the dataset file was present - await TEST_ASSERTIONS.assertFileExists( - fs, - '/development/path/pair/apps/pair-cli/dataset/AGENTS.md', - 'agents content', - ) - - // In the provided in-memory FS the previous tests showed the file ended up at - // '/development/path/pair/apps/pair-cli/AGENTS.md/AGENTS.md' so assert both - // the top-level and nested target to be safe. - await TEST_ASSERTIONS.assertFileExists( - fs, - '/development/path/pair/apps/pair-cli/AGENTS.md', - 'agents content', - ) - }) -}) - -describe('copyPathOps - directory operations', () => { - let fileService: InMemoryFileSystemService - - beforeEach(() => { - fileService = TEST_SETUP.createBasicSetup() - }) - - it('should copy a directory and update links', async () => { - fileService = TEST_SETUP.createDirectorySetup() - const result = await copyPathOps({ - fileService, - source: 'folder', - target: 'copied-folder', - datasetRoot: '/dataset', - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/copied-folder/file1.md', - '# File 1', - ) - }) -}) - -describe('copyPathOps - flatten and prefix', () => { - it('should flatten directory hierarchy into hyphen-separated names', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '# Next Skill', - '/dataset/source/process/implement/SKILL.md': '# Implement Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/catalog-next/SKILL.md', - '# Next Skill', - ) - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/process-implement/SKILL.md', - '# Implement Skill', - ) - }) - - it('should apply prefix to top-level directory names', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/SKILL.md': '# Catalog Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: false, prefix: 'pair', targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/pair-catalog/SKILL.md', - '# Catalog Skill', - ) - }) - - it('should apply both flatten and prefix', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '# Next Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/pair-catalog-next/SKILL.md', - '# Next Skill', - ) - }) - - it('should apply prefix only without flatten (prefix top-level, keep hierarchy)', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '# Next Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: false, prefix: 'pair', targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/pair-catalog/next/SKILL.md', - '# Next Skill', - ) - }) - - it('should rewrite relative links after flatten+prefix copy (full pipeline)', async () => { - // File at source/catalog/next/ (depth 3) links up 3 levels to reach dataset root - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': - '# Next\n[guide](../../../.pair/knowledge/testing/README.md)', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - // After flatten+prefix: source/catalog/next/ → target/pair-catalog-next/ - // Original: ../../../ from source/catalog/next/ → /dataset/.pair/knowledge/testing/README.md - // New location target/pair-catalog-next/ (depth 2): ../../.pair/knowledge/testing/README.md - const content = await fileService.readFile('/dataset/target/pair-catalog-next/SKILL.md') - expect(content).toContain('../../.pair/knowledge/testing/README.md') - }) - - it('should re-root links when source content root differs from datasetRoot', async () => { - // Simulates real pipeline: source deep in monorepo, target at project root - // source = packages/kb/dataset/.skills → content root = packages/kb/dataset/ - // target = .claude/skills → content root = project root - const fileService = new InMemoryFileSystemService( - { - '/project/packages/kb/dataset/.skills/next/SKILL.md': - '# Next\n[PRD](../../.pair/adoption/PRD.md)', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'packages/kb/dataset/.skills', - target: '.claude/skills', - datasetRoot: '/project', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - const content = await fileService.readFile('/project/.claude/skills/pair-next/SKILL.md') - // Link should point to .pair/ at project root, NOT to packages/kb/dataset/.pair/ - expect(content).toContain('../../../.pair/adoption/PRD.md') - expect(content).not.toContain('packages/kb/dataset') - }) - - it('should sync frontmatter name after flatten+prefix rename', async () => { - const skillContent = [ - '---', - 'name: record-decision', - 'description: >-', - ' Records an architectural', - ' or non-architectural decision.', - '---', - '', - '# /record-decision', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/capability/record-decision/SKILL.md': skillContent, - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - const result = await fileService.readFile( - '/dataset/target/pair-capability-record-decision/SKILL.md', - ) - // name synced to match new directory name - expect(result).toContain('name: pair-capability-record-decision') - // multiline collapsed - expect(result).toContain('description: Records an architectural or non-architectural decision.') - expect(result).not.toContain('>-') - // body skill references rewritten - expect(result).toContain('# /pair-capability-record-decision') - }) - - it('should sync all frontmatter values referencing old dir name, not just name', async () => { - const skillContent = [ - '---', - 'name: my-skill', - 'config: my-skill/defaults.yaml', - '---', - '', - '# Body', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/category/my-skill/SKILL.md': skillContent, - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'px', targets: [] }, - }) - - const result = await fileService.readFile('/dataset/target/px-category-my-skill/SKILL.md') - expect(result).toContain('name: px-category-my-skill') - expect(result).toContain('config: px-category-my-skill/defaults.yaml') - }) - - it('should rewrite skill cross-references after flatten+prefix copy', async () => { - const implementContent = [ - '---', - 'name: implement', - 'description: >-', - ' Composes /verify-quality and', - ' /record-decision.', - '---', - '', - '# /implement', - '', - '| `/verify-quality` | Capability |', - '| `/record-decision` | Capability |', - 'invoke /assess-stack if needed', - ].join('\n') - - const verifyContent = [ - '---', - 'name: verify-quality', - 'description: Quality checker.', - '---', - '', - '# /verify-quality', - 'Composed by /implement and /review.', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/process/implement/SKILL.md': implementContent, - '/dataset/source/capability/verify-quality/SKILL.md': verifyContent, - '/dataset/source/capability/record-decision/SKILL.md': - '---\nname: record-decision\n---\n# /record-decision', - '/dataset/source/capability/assess-stack/SKILL.md': - '---\nname: assess-stack\n---\n# /assess-stack', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - const impl = await fileService.readFile('/dataset/target/pair-process-implement/SKILL.md') - // frontmatter name synced - expect(impl).toContain('name: pair-process-implement') - // body references rewritten - expect(impl).toContain('`/pair-capability-verify-quality`') - expect(impl).toContain('`/pair-capability-record-decision`') - expect(impl).toContain('/pair-capability-assess-stack') - // frontmatter description also rewritten - expect(impl).toContain('/pair-capability-verify-quality and') - - const verify = await fileService.readFile( - '/dataset/target/pair-capability-verify-quality/SKILL.md', - ) - expect(verify).toContain('/pair-process-implement') - }) - - it('should return skillNameMap from flatten+prefix copy', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - '/dataset/source/process/implement/SKILL.md': '---\nname: implement\n---\n# /implement', - }, - '/', - '/', - ) - - const result = await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - expect(result.skillNameMap).toBeDefined() - expect(result.skillNameMap!.get('next')).toBe('pair-catalog-next') - expect(result.skillNameMap!.get('implement')).toBe('pair-process-implement') - }) - - it('should apply external skillNameMap to file copy', async () => { - const agentsContent = [ - '# AGENTS', - '```', - '/next', - '```', - 'Run `/next` to get started.', - 'Then `/implement` your task.', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/project/src/AGENTS.md': agentsContent, - }, - '/', - '/', - ) - - const skillNameMap = new Map([ - ['next', 'pair-next'], - ['implement', 'pair-process-implement'], - ]) - - await copyPathOps({ - fileService, - source: 'src/AGENTS.md', - target: 'dist/AGENTS.md', - datasetRoot: '/project', - skillNameMap, - }) - - const result = await fileService.readFile('/project/dist/AGENTS.md') - expect(result).toContain('/pair-next') - expect(result).toContain('`/pair-next`') - expect(result).toContain('/pair-process-implement') - expect(result).not.toContain(' /next') - expect(result).not.toContain('/implement') - }) - - it('should detect and throw on flatten collisions', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/a/b/SKILL.md': '# Skill 1', - '/dataset/source/a-b/SKILL.md': '# Skill 2', - }, - '/', - '/', - ) - - await expect( - copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, targets: [] }, - }), - ).rejects.toThrow(/collision/i) - }) - - describe('mirror behavior — idempotent updates (AC4)', () => { - it('removes a stale flattened directory when its source skill is gone', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - // Stale leftover from a previous run — no longer present under source - '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, - }) - - await expect( - fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), - ).resolves.toBe(false) - await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( - true, - ) - }) - - it('removes the old prefixed directory after a prefix change', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - // Leftover from a previous install with prefix "pair" - '/dataset/target/pair-catalog-next/SKILL.md': '---\nname: pair-catalog-next\n---', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'foo', defaultBehavior: 'mirror', targets: [] }, - }) - - await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( - false, - ) - await expect(fileService.exists('/dataset/target/foo-catalog-next/SKILL.md')).resolves.toBe( - true, - ) - }) - - it('does not delete a root-level (non-nested) source file on a second mirror run', async () => { - // Regression: cleanupStaleTransformedEntries built its "expected" set only from - // dirMappingFiles, which copyFileWithTransform only populates for files under a - // subdirectory (dir !== '.'). A file copied directly from the source root was never - // registered as expected, so a second mirror run would delete it as "stale". - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/README.md': '# Root-level file, no subdirectory', - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - }, - '/', - '/', - ) - - const runOnce = () => - copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, - }) - - await runOnce() - await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) - - // Second run must be idempotent — the root-level file must survive. - await runOnce() - await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) - await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( - true, - ) - }) - - it('does not clean up stale entries when behavior is not mirror', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', defaultBehavior: 'overwrite', targets: [] }, - }) - - await expect( - fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), - ).resolves.toBe(true) - }) - }) -}) - -describe('copyPathOps - error cases', () => { - let fileService: InMemoryFileSystemService - - beforeEach(() => { - fileService = TEST_SETUP.createBasicSetup() - }) - - it('should throw error for nonexistent source', async () => { - await expect( - copyPathOps({ - fileService, - source: 'nonexistent.md', - target: 'target.md', - datasetRoot: '/dataset', - }), - ).rejects.toThrow() - }) - - it('should respect behavior options', async () => { - fileService = new InMemoryFileSystemService(TEST_FILE_STRUCTURES.existingTarget, '/', '/') - - const result = await copyPathOps({ - fileService, - source: 'source.md', - target: 'target.md', - datasetRoot: '/dataset', - options: { - defaultBehavior: 'add', - flatten: false, - targets: [], - }, - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileExists(fileService, '/dataset/target.md', '# Existing Target') - }) -}) diff --git a/packages/content-ops/src/ops/copyPathOps.ts b/packages/content-ops/src/ops/copyPathOps.ts deleted file mode 100644 index c50f9b52..00000000 --- a/packages/content-ops/src/ops/copyPathOps.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { Stats } from 'fs' -import { logger, createError } from '../observability' -import { validateSourceExists } from '../file-system/file-validations' -import { SyncOptions } from './SyncOptions' -import { FileSystemService } from '../file-system' -import { Behavior } from './behavior' -import { setupPathOperation } from './path-operation-helpers' -import { isAbsolute } from 'path' -import { SkillNameMap } from './skill-reference-rewriter' -import { handleDirectoryCopy, HandleDirectoryCopyParams } from './copy-directory' -import { handleFileCopy, HandleFileCopyParams } from './copy-file' -import { copyDirectoryWithTransforms } from './copy-directory-transforms' -import type { CopyPathOpsResult } from './copy-types' - -export type { CopyPathOpsResult } from './copy-types' -export { copyDirectoryWithTransforms } from './copy-directory-transforms' - -type CopyPathOpsParams = { - fileService: FileSystemService - source: string - target: string - datasetRoot: string - options?: SyncOptions - /** Pre-built skill name map from a previous copy (e.g., skills registry). - * When provided, rewrites skill references in all copied .md files. */ - skillNameMap?: SkillNameMap -} - -/** - * Performs copy operation based on source type - */ -async function performCopyBasedOnType( - stat: Stats, - params: { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - normSource: string - normTarget: string - datasetRoot: string - defaultBehavior: Behavior - folderBehavior?: Record - options?: SyncOptions - skillNameMap?: SkillNameMap - }, -): Promise { - if (stat.isDirectory()) { - return handleDirectoryCopyForType(params) - } else if (stat.isFile()) { - await handleFileCopyForType(params) - return {} - } else { - throw createError({ - type: 'INVALID_SOURCE_TYPE', - message: `Source is neither a file nor a directory: ${params.srcPath}`, - sourcePath: params.srcPath, - }) - } -} - -/** - * Checks whether flatten or prefix transforms are active - */ -function hasNamingTransforms(options?: SyncOptions): boolean { - return Boolean(options?.flatten) || Boolean(options?.prefix) -} - -/** - * Handles directory copy for the main copy operation - */ -async function handleDirectoryCopyForType(params: { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - normSource: string - normTarget: string - datasetRoot: string - defaultBehavior: Behavior - folderBehavior?: Record - options?: SyncOptions - skillNameMap?: SkillNameMap -}): Promise { - if (hasNamingTransforms(params.options)) { - return copyDirectoryWithTransforms(params) - } - const dirCopyParams: HandleDirectoryCopyParams = { - fileService: params.fileService, - srcPath: params.srcPath, - destPath: params.destPath, - source: params.source, - target: params.target, - normSource: params.normSource, - normTarget: params.normTarget, - datasetRoot: params.datasetRoot, - defaultBehavior: params.defaultBehavior, - ...(params.folderBehavior && { folderBehavior: params.folderBehavior }), - ...(params.options && { options: params.options }), - } - await handleDirectoryCopy(dirCopyParams) - return {} -} - -/** - * Handles file copy for the main copy operation - */ -async function handleFileCopyForType(params: { - fileService: FileSystemService - srcPath: string - destPath: string - source: string - target: string - normTarget: string - datasetRoot: string - defaultBehavior: Behavior - options?: SyncOptions - skillNameMap?: SkillNameMap -}) { - const fileCopyParams: HandleFileCopyParams = { - fileService: params.fileService, - srcPath: params.srcPath, - destPath: params.destPath, - source: params.source, - target: params.target, - normTarget: params.normTarget, - datasetRoot: params.datasetRoot, - defaultBehavior: params.defaultBehavior, - ...(params.options && { options: params.options }), - ...(params.skillNameMap && { skillNameMap: params.skillNameMap }), - } - await handleFileCopy(fileCopyParams) -} - -/** - * Copies a file or directory from source to target and updates all markdown links - * that reference the copied content. - * - * @param fileService - File system service for I/O operations - * @param source - Source path relative to dataset root - * @param target - Target path relative to dataset root - * @param datasetRoot - Absolute path to the documentation root directory - * @param options - Optional configuration for the copy operation - * @returns Promise resolving to an object containing operation logs - * - * @throws {ContentSyncError} When source doesn't exist, paths escape dataset root, - * or invalid operations are attempted - */ - -type PreparedCopy = - | { skip: true; result: CopyPathOpsResult } - | { - skip: false - normSource: string - normTarget: string - srcPath: string - destPath: string - defaultBehavior: Behavior - folderBehavior?: Record - } - -/** - * Resolves and validates source/target paths for a copy operation, applying - * the same-path skip up front. - */ -function prepareCopyPathOperation( - source: string, - target: string, - datasetRoot: string, - options?: SyncOptions, -): PreparedCopy { - const setup = setupPathOperation(source, target, datasetRoot, options) - if (setup.shouldSkip) return { skip: true, result: {} } - - const { normSource, normTarget, srcPath, destPath, defaultBehavior, folderBehavior } = setup - if (!srcPath || !destPath) { - throw createError({ - type: 'IO_ERROR', - message: 'Invalid source or destination path', - operation: 'setup', - path: srcPath || destPath || '', - }) - } - - return { - skip: false, - normSource, - normTarget, - srcPath, - destPath, - defaultBehavior: defaultBehavior ?? 'overwrite', - ...(folderBehavior && { folderBehavior }), - } -} - -export async function copyPathOps(params: CopyPathOpsParams): Promise { - const { fileService, source, target, datasetRoot, options, skillNameMap } = params - if (isAbsolute(source) || isAbsolute(target)) { - throw createError({ - type: 'INVALID_PATH', - message: 'Source and target paths must be relative, not absolute', - sourcePath: source, - targetPath: target, - }) - } - return logger.time(async () => { - const prepared = prepareCopyPathOperation(source, target, datasetRoot, options) - if (prepared.skip) return prepared.result - - const stat = await validateSourceExists(fileService, prepared.srcPath) - return performCopyBasedOnType(stat, { - fileService, - srcPath: prepared.srcPath, - destPath: prepared.destPath, - source, - target, - normSource: prepared.normSource, - normTarget: prepared.normTarget, - datasetRoot, - defaultBehavior: prepared.defaultBehavior, - ...(prepared.folderBehavior && { folderBehavior: prepared.folderBehavior }), - ...(options && { options }), - ...(skillNameMap && { skillNameMap }), - }) - }, 'copyPathAndUpdateLinks') -} diff --git a/packages/content-ops/src/test-utils/in-memory-fs-read.ts b/packages/content-ops/src/test-utils/in-memory-fs-read.ts deleted file mode 100644 index fc4a3589..00000000 --- a/packages/content-ops/src/test-utils/in-memory-fs-read.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Dirent, Stats } from 'fs' -import { dirname } from 'path' -import { InMemoryFsState } from './in-memory-fs-state' - -export function readFileSync(state: InMemoryFsState, path: string): string { - const resolvedPath = state.resolvePath(path) - const file = state.files.get(resolvedPath) - if (!file) throw new Error(`File not found: ${path}`) - return file -} - -export function existsSync(state: InMemoryFsState, path: string): boolean { - const resolvedPath = state.resolvePath(path) - return state.files.has(resolvedPath) || state.dirs.has(resolvedPath) -} - -export async function stat(state: InMemoryFsState, path: string): Promise { - const resolvedPath = state.resolvePath(path) - if (state.dirs.has(resolvedPath)) { - return { isDirectory: () => true, isFile: () => false } as Stats - } - if (state.files.has(resolvedPath)) { - return { isDirectory: () => false, isFile: () => true } as Stats - } - throw new Error(`no such file or directory '${path}'`) -} - -export async function readdir(state: InMemoryFsState, path: string): Promise { - const resolvedPath = state.resolvePath(path) - if (!state.dirs.has(resolvedPath)) { - throw new Error(`no such file or directory '${path}'`) - } - - const entries: Dirent[] = [] - for (const d of state.dirs) { - if (d === resolvedPath) continue - if (dirname(d) === resolvedPath) { - const name = d.replace(`${resolvedPath}/`, '') - entries.push(state.makeDirent(name, true)) - } - } - - for (const filePath of state.files.keys()) { - if (dirname(filePath) === resolvedPath) { - const name = filePath.replace(`${resolvedPath}/`, '') - entries.push(state.makeDirent(name, false)) - } - } - - return entries -} - -export function getContent(state: InMemoryFsState, path: string): string | undefined { - const resolvedPath = state.resolvePath(path) - return state.files.get(resolvedPath) -} - -export async function isFile(state: InMemoryFsState, path: string): Promise { - return stat(state, path).then(stats => stats.isFile()) -} - -export async function isFolder(state: InMemoryFsState, path: string): Promise { - try { - const isFileResult = await isFile(state, path) - return !isFileResult - } catch { - return false - } -} diff --git a/packages/content-ops/src/test-utils/in-memory-fs-seed.ts b/packages/content-ops/src/test-utils/in-memory-fs-seed.ts deleted file mode 100644 index 68f289c4..00000000 --- a/packages/content-ops/src/test-utils/in-memory-fs-seed.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { InMemoryFsState } from './in-memory-fs-state' - -/** - * Seeds a fresh state from the constructor arguments: registers the root and - * ancestor directories of the module/working dirs, loads the initial files, - * and guarantees the module/working dirs themselves exist. - */ -export function seedState( - state: InMemoryFsState, - initial: Record, - moduleDirectory: string, - workingDirectory: string, -): void { - // Set directories first so resolvePath works - state.dirs.add('/') - state.addParentDirectories(moduleDirectory) - state.addParentDirectories(workingDirectory) - addInitialFiles(state, initial) - - // Ensure moduleDirectory and workingDirectory exist - state.dirs.add(moduleDirectory) - state.dirs.add(workingDirectory) -} - -function addInitialFiles(state: InMemoryFsState, initial: Record): void { - for (const [path, content] of Object.entries(initial)) { - const resolvedPath = state.resolvePath(path) - state.files.set(resolvedPath, content) - state.addParentDirectories(resolvedPath) - } -} diff --git a/packages/content-ops/src/test-utils/in-memory-fs-state.ts b/packages/content-ops/src/test-utils/in-memory-fs-state.ts deleted file mode 100644 index 947fc17a..00000000 --- a/packages/content-ops/src/test-utils/in-memory-fs-state.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Dirent } from 'fs' -import { dirname, resolve, isAbsolute } from 'path' - -/** - * Mutable in-memory filesystem state shared by the read/write/seed operation - * modules. Holds the file/dir/symlink maps plus the low-level path primitives - * every operation needs. Extracted from InMemoryFileSystemService so the - * behavior can be split across focused modules without duplicating state. - */ -export class InMemoryFsState { - readonly files = new Map() - readonly dirs = new Set() - readonly symlinks = new Map() - moduleDirectory: string - workingDirectory: string - - constructor(moduleDirectory: string, workingDirectory: string) { - this.moduleDirectory = moduleDirectory - this.workingDirectory = workingDirectory - } - - resolvePath(path: string): string { - return isAbsolute(path) ? path : resolve(this.workingDirectory, path) - } - - addParentDirectories(path: string): void { - let p = dirname(path) - while (p && p !== dirname(p)) { - this.dirs.add(p) - const next = dirname(p) - if (next === p) break - p = next - } - } - - // Resolve paths relative to the in-memory working directory. This mirrors - // path.resolve semantics but anchored to the service's workingDirectory so - // tests can control how relative paths are interpreted. - resolve(...paths: string[]): string { - const firstPath = paths[0] - if (firstPath && isAbsolute(firstPath)) { - return resolve(...paths) - } - return resolve(this.workingDirectory, ...paths) - } - - makeDirent(name: string, isDir: boolean): Dirent { - return { - name, - isDirectory: () => isDir, - isFile: () => !isDir, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - isSymbolicLink: () => false, - } as Dirent - } -} diff --git a/packages/content-ops/src/test-utils/in-memory-fs-write.ts b/packages/content-ops/src/test-utils/in-memory-fs-write.ts deleted file mode 100644 index dda8ab2a..00000000 --- a/packages/content-ops/src/test-utils/in-memory-fs-write.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { dirname, resolve, isAbsolute } from 'path' -import { InMemoryFsState } from './in-memory-fs-state' - -export async function writeFile( - state: InMemoryFsState, - path: string, - content: string, -): Promise { - const resolvedPath = state.resolvePath(path) - state.files.set(resolvedPath, content) - // Create all parent directories recursively - let p = dirname(resolvedPath) - while (p && p !== dirname(p)) { - state.dirs.add(p) - const next = dirname(p) - if (next === p) break - p = next - } -} - -export async function writeFileBinary( - state: InMemoryFsState, - path: string, - content: Buffer, -): Promise { - // Store binary data using latin1 encoding to preserve byte values - await writeFile(state, path, content.toString('latin1')) -} - -export async function unlink(state: InMemoryFsState, path: string): Promise { - const resolvedPath = state.resolvePath(path) - if (!state.files.has(resolvedPath)) { - throw new Error(`File not found: ${path}`) - } - state.files.delete(resolvedPath) -} - -export function mkdirImpl( - state: InMemoryFsState, - path: string, - options?: { recursive?: boolean }, -): void { - const resolvedPath = state.resolvePath(path) - state.dirs.add(resolvedPath) - if (options?.recursive) { - let p = resolvedPath - while (p && p !== dirname(p)) { - state.dirs.add(p) - const next = dirname(p) - if (next === p) break - p = next - } - } -} - -export async function rename( - state: InMemoryFsState, - oldPath: string, - newPath: string, -): Promise { - const resolvedOldPath = state.resolvePath(oldPath) - const resolvedNewPath = state.resolvePath(newPath) - - // Check if source exists (either as file or directory) - const sourceExists = state.files.has(resolvedOldPath) || state.dirs.has(resolvedOldPath) - if (!sourceExists) { - throw new Error(`Path not found: ${oldPath}`) - } - - if (state.files.has(resolvedOldPath)) { - const content = state.files.get(resolvedOldPath)! - state.files.set(resolvedNewPath, content) - state.files.delete(resolvedOldPath) - state.dirs.add(dirname(resolvedNewPath)) - return - } - - const oldPrefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' - const newPrefix = resolvedNewPath.endsWith('/') ? resolvedNewPath : resolvedNewPath + '/' - - const toMove: Array<[string, string]> = [] - for (const key of Array.from(state.files.keys())) { - if (key === resolvedOldPath || key.startsWith(oldPrefix)) { - const rel = key === resolvedOldPath ? '' : key.slice(oldPrefix.length) - toMove.push([key, newPrefix + rel]) - } - } - toMove.forEach(([k, v]) => { - const val = state.files.get(k)! - state.files.set(v, val) - state.files.delete(k) - state.dirs.add(dirname(v)) - }) - - const dirToMove = Array.from(state.dirs).filter( - d => d === resolvedOldPath || d.startsWith(oldPrefix), - ) - dirToMove.forEach(d => { - const rel = d === resolvedOldPath ? '' : d.slice(oldPrefix.length) - state.dirs.add(newPrefix + rel) - state.dirs.delete(d) - }) -} - -export function copyImpl(state: InMemoryFsState, oldPath: string, newPath: string): void { - const resolvedOldPath = state.resolvePath(oldPath) - const resolvedNewPath = state.resolvePath(newPath) - if (state.dirs.has(resolvedOldPath)) { - // copy directory recursively - state.dirs.add(resolvedNewPath) - const prefix = resolvedOldPath.endsWith('/') ? resolvedOldPath : resolvedOldPath + '/' - for (const key of state.files.keys()) { - if (key.startsWith(prefix)) { - const relative = key.slice(prefix.length) - const newKey = state.resolve(resolvedNewPath, relative) - state.files.set(newKey, state.files.get(key)!) - state.addParentDirectories(newKey) - } - } - } else if (state.files.has(resolvedOldPath)) { - const content = state.files.get(resolvedOldPath)! - state.files.set(resolvedNewPath, content) - state.addParentDirectories(resolvedNewPath) - } else { - throw new Error(`Path not found: ${oldPath}`) - } -} - -export async function rm( - state: InMemoryFsState, - path: string, - options?: { recursive?: boolean; force?: boolean }, -): Promise { - const resolvedPath = state.resolvePath(path) - const prefix = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/' - - const deleteRecursive = () => { - for (const key of Array.from(state.files.keys())) { - if (key === resolvedPath || key.startsWith(prefix)) state.files.delete(key) - } - for (const d of Array.from(state.dirs)) { - if (d === resolvedPath || d.startsWith(prefix)) state.dirs.delete(d) - } - state.dirs.delete(resolvedPath) - state.files.delete(resolvedPath) - } - - const deleteNonRecursive = () => { - if (state.files.has(resolvedPath)) { - state.files.delete(resolvedPath) - return - } - if (state.dirs.has(resolvedPath)) { - const hasChildren = - Array.from(state.files.keys()).some(k => dirname(k) === resolvedPath) || - Array.from(state.dirs).some(d => dirname(d) === resolvedPath && d !== resolvedPath) - if (hasChildren) { - throw new Error(`Directory not empty: ${path}`) - } - state.dirs.delete(resolvedPath) - return - } - if (!options?.force) throw new Error(`Path not found: ${path}`) - } - - if (options?.recursive) { - deleteRecursive() - return - } - - deleteNonRecursive() -} - -export async function symlink(state: InMemoryFsState, target: string, path: string): Promise { - const resolvedPath = state.resolvePath(path) - // Resolve relative targets from the symlink's parent directory (matching OS behavior) - const resolvedTarget = isAbsolute(target) - ? state.resolvePath(target) - : resolve(dirname(resolvedPath), target) - if (state.symlinks.has(resolvedPath) || state.files.has(resolvedPath)) { - throw new Error(`Path already exists: ${path}`) - } - state.symlinks.set(resolvedPath, resolvedTarget) - state.addParentDirectories(resolvedPath) -} - -export function chdir(state: InMemoryFsState, path: string): void { - state.workingDirectory = path - // Ensure parent directories exist in the in-memory view - state.addParentDirectories(path) - state.dirs.add(path) -} - -export async function createZip( - state: InMemoryFsState, - sourcePaths: string[], - outputPath: string, -): Promise { - const resolvedOutputPath = state.resolvePath(outputPath) - const zipContent: Record = {} - - for (const sourcePath of sourcePaths) { - const resolvedSourcePath = state.resolvePath(sourcePath) - - // Check if source is file or directory - if (state.files.has(resolvedSourcePath)) { - // Single file - add to zip root - const fileName = resolvedSourcePath.split('/').pop() || 'file' - zipContent[fileName] = state.files.get(resolvedSourcePath)! - } else if (state.dirs.has(resolvedSourcePath)) { - // Directory - add all files recursively - for (const [filePath, content] of state.files.entries()) { - if (filePath.startsWith(resolvedSourcePath + '/')) { - // Relative path within zip - const relativePath = filePath.substring(resolvedSourcePath.length + 1) - zipContent[relativePath] = content - } - } - } - } - - // Serialize zip content as JSON (simple in-memory representation) - const zipData = JSON.stringify(zipContent) - state.files.set(resolvedOutputPath, zipData) - state.addParentDirectories(resolvedOutputPath) -} - -export async function extractZip( - state: InMemoryFsState, - zipPath: string, - outputDir: string, -): Promise { - const resolvedZipPath = state.resolvePath(zipPath) - const resolvedOutputDir = state.resolvePath(outputDir) - - const zipData = state.files.get(resolvedZipPath) - if (!zipData) { - throw new Error(`ZIP file not found: ${zipPath}`) - } - - // Deserialize zip content - const zipContent = JSON.parse(zipData) as Record - - // Extract all files - for (const [relativePath, content] of Object.entries(zipContent)) { - const outputPath = resolve(resolvedOutputDir, relativePath) - state.files.set(outputPath, content) - state.addParentDirectories(outputPath) - } - - // Ensure output directory exists - state.dirs.add(resolvedOutputDir) -} diff --git a/packages/content-ops/src/test-utils/in-memory-fs.test.ts b/packages/content-ops/src/test-utils/in-memory-fs.test.ts deleted file mode 100644 index 42552e4b..00000000 --- a/packages/content-ops/src/test-utils/in-memory-fs.test.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import InMemoryFileSystemService from './in-memory-fs' - -describe('InMemoryFileSystemService - Constructor', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('constructor', () => { - it('should initialize with empty filesystem', () => { - expect(fs.existsSync('/')).toBe(true) - expect(fs.rootModuleDirectory()).toBe(moduleDir) - expect(fs.currentWorkingDirectory()).toBe(workingDir) - }) - - it('should initialize with initial files', () => { - const initialFs = new InMemoryFileSystemService( - { '/test.txt': 'content' }, - moduleDir, - workingDir, - ) - expect(initialFs.readFileSync('/test.txt')).toBe('content') - expect(initialFs.existsSync('/test.txt')).toBe(true) - }) - - it('should create parent directories for initial files', () => { - const initialFs = new InMemoryFileSystemService( - { '/deep/nested/file.txt': 'content' }, - moduleDir, - workingDir, - ) - expect(initialFs.existsSync('/deep')).toBe(true) - expect(initialFs.existsSync('/deep/nested')).toBe(true) - expect(initialFs.readFileSync('/deep/nested/file.txt')).toBe('content') - }) - - it('should handle non-existent moduleDirectory', () => { - expect(() => { - new InMemoryFileSystemService({}, '/nonexistent', workingDir) - }).not.toThrow() - }) - - it('should handle non-existent workingDirectory', () => { - expect(() => { - new InMemoryFileSystemService({}, moduleDir, '/nonexistent') - }).not.toThrow() - }) - }) -}) - -describe('InMemoryFileSystemService - Path Resolution', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('resolvePath', () => { - it('should return absolute paths unchanged', () => { - expect(fs['state'].resolvePath('/absolute/path')).toBe('/absolute/path') - }) - - it('should resolve relative paths against working directory', () => { - expect(fs['state'].resolvePath('relative/path')).toBe('/app/relative/path') - expect(fs['state'].resolvePath('./relative/path')).toBe('/app/relative/path') - expect(fs['state'].resolvePath('../parent/path')).toBe('/parent/path') - }) - }) -}) - -describe('InMemoryFileSystemService - File Operations - Write/Read', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('writeFile and readFile', () => { - it('should write and read files synchronously', () => { - fs.writeFile('/test.txt', 'content') - expect(fs.readFileSync('/test.txt')).toBe('content') - }) - - it('should write and read files asynchronously', async () => { - await fs.writeFile('/async.txt', 'async content') - expect(await fs.readFile('/async.txt')).toBe('async content') - }) - - it('should handle relative paths', () => { - fs.writeFile('relative.txt', 'relative content') - expect(fs.readFileSync('relative.txt')).toBe('relative content') - }) - - it('should create parent directories when writing', () => { - fs.writeFile('/deep/nested/file.txt', 'nested content') - expect(fs.existsSync('/deep')).toBe(true) - expect(fs.existsSync('/deep/nested')).toBe(true) - expect(fs.readFileSync('/deep/nested/file.txt')).toBe('nested content') - }) - - it('should throw error when reading non-existent file', () => { - expect(() => fs.readFileSync('/nonexistent.txt')).toThrow('File not found: /nonexistent.txt') - }) - }) -}) - -describe('InMemoryFileSystemService - File Operations - Exists/Unlink', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('exists', () => { - it('should return true for existing files', async () => { - fs.writeFile('/existing.txt', 'content') - expect(fs.existsSync('/existing.txt')).toBe(true) - expect(await fs.exists('/existing.txt')).toBe(true) - }) - - it('should return false for non-existing files', async () => { - expect(fs.existsSync('/nonexistent.txt')).toBe(false) - expect(await fs.exists('/nonexistent.txt')).toBe(false) - }) - - it('should return true for existing directories', () => { - expect(fs.existsSync('/')).toBe(true) - }) - }) - - describe('unlink', () => { - it('should remove files', async () => { - await fs.writeFile('/test.txt', 'content') - expect(fs.existsSync('/test.txt')).toBe(true) - await fs.unlink('/test.txt') - expect(fs.existsSync('/test.txt')).toBe(false) - }) - - it('should throw error when unlinking non-existent file', async () => { - await expect(fs.unlink('/nonexistent.txt')).rejects.toThrow( - 'File not found: /nonexistent.txt', - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Directory Operations - Mkdir', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('mkdir', () => { - it('should create directories', async () => { - await fs.mkdir('/newdir') - expect(fs.existsSync('/newdir')).toBe(true) - }) - - it('should create parent directories recursively', async () => { - await fs.mkdir('/deep/nested/dir', { recursive: true }) - expect(fs.existsSync('/deep')).toBe(true) - expect(fs.existsSync('/deep/nested')).toBe(true) - expect(fs.existsSync('/deep/nested/dir')).toBe(true) - }) - - it('should handle existing directories', async () => { - await fs.mkdir('/existing') - expect(() => fs.mkdir('/existing')).not.toThrow() - }) - }) -}) - -describe('InMemoryFileSystemService - Directory Operations - Readdir', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('readdir', () => { - it('should list directory contents', async () => { - await fs.writeFile('/dir/file1.txt', 'content1') - await fs.writeFile('/dir/file2.txt', 'content2') - await fs.mkdir('/dir/subdir') - - const entries = await fs.readdir('/dir') - expect(entries.map(e => e.name)).toContain('file1.txt') - expect(entries.map(e => e.name)).toContain('file2.txt') - expect(entries.map(e => e.name)).toContain('subdir') - }) - - it('should return empty array for empty directory', async () => { - await fs.mkdir('/emptydir') - expect((await fs.readdir('/emptydir')).length).toBe(0) - }) - - it('should throw error for non-existent directory', async () => { - await expect(fs.readdir('/nonexistent')).rejects.toThrow( - "no such file or directory '/nonexistent'", - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Directory Operations - Stat', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('stat', () => { - it('should return file stats', async () => { - await fs.writeFile('/test.txt', 'content') - const stats = await fs.stat('/test.txt') - expect(stats.isFile()).toBe(true) - expect(stats.isDirectory()).toBe(false) - }) - - it('should return directory stats', async () => { - const stats = await fs.stat('/') - expect(stats.isFile()).toBe(false) - expect(stats.isDirectory()).toBe(true) - }) - - it('should throw error for non-existent path', async () => { - await expect(fs.stat('/nonexistent')).rejects.toThrow( - "no such file or directory '/nonexistent'", - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - Rename', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('rename', () => { - it('should rename files', async () => { - await fs.writeFile('/old.txt', 'content') - await fs.rename('/old.txt', '/new.txt') - expect(fs.existsSync('/old.txt')).toBe(false) - expect(fs.existsSync('/new.txt')).toBe(true) - expect(fs.readFileSync('/new.txt')).toBe('content') - }) - - it('should rename directories', async () => { - await fs.mkdir('/olddir') - await fs.writeFile('/olddir/file.txt', 'content') - await fs.rename('/olddir', '/newdir') - expect(fs.existsSync('/olddir')).toBe(false) - expect(fs.existsSync('/newdir')).toBe(true) - expect(fs.readFileSync('/newdir/file.txt')).toBe('content') - }) - - it('should throw error when renaming non-existent file', async () => { - await expect(fs.rename('/nonexistent.txt', '/new.txt')).rejects.toThrow( - 'Path not found: /nonexistent.txt', - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - Copy', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('copy', () => { - it('should copy files', async () => { - await fs.writeFile('/source.txt', 'content') - await fs.copy('/source.txt', '/dest.txt') - expect(fs.existsSync('/source.txt')).toBe(true) - expect(fs.existsSync('/dest.txt')).toBe(true) - expect(fs.readFileSync('/dest.txt')).toBe('content') - }) - - it('should create parent directories when copying', async () => { - await fs.writeFile('/source.txt', 'content') - await fs.copy('/source.txt', '/deep/nested/dest.txt') - expect(fs.existsSync('/deep/nested/dest.txt')).toBe(true) - expect(fs.readFileSync('/deep/nested/dest.txt')).toBe('content') - }) - - it('should throw error when copying non-existent file', async () => { - await expect(fs.copy('/nonexistent.txt', '/dest.txt')).rejects.toThrow( - 'Path not found: /nonexistent.txt', - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - CopySync', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - it('should copy a file to a new path', () => { - fs.writeFile('/foo.txt', 'hello') - fs.copySync('/foo.txt', '/bar.txt') - expect(fs.getContent('/bar.txt')).toBe('hello') - expect(fs.getContent('/foo.txt')).toBe('hello') - }) - - it('should copy a file to a new directory', () => { - fs.writeFile('/foo.txt', 'hello') - fs.copySync('/foo.txt', '/dir/bar.txt') - expect(fs.getContent('/dir/bar.txt')).toBe('hello') - }) - - it('should copy a directory recursively', () => { - fs.writeFile('/foo/bar/baz.txt', 'baz') - fs.copySync('/foo/bar', '/foo/barcopy') - expect(fs.getContent('/foo/barcopy/baz.txt')).toBe('baz') - expect(fs.getContent('/foo/bar/baz.txt')).toBe('baz') - }) - - it('should throw if source does not exist', () => { - expect(() => fs.copySync('/notfound', '/foo/x')).toThrow() - }) - - it('should copy nested directories and files', () => { - fs.writeFile('/foo/file.txt', 'hello') - fs.writeFile('/foo/bar/baz.txt', 'baz') - fs.copySync('/foo', '/fooCopy') - expect(fs.getContent('/fooCopy/file.txt')).toBe('hello') - expect(fs.getContent('/fooCopy/bar/baz.txt')).toBe('baz') - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - Rm', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('rm', () => { - it('should remove files', async () => { - await fs.writeFile('/test.txt', 'content') - await fs.rm('/test.txt') - expect(fs.existsSync('/test.txt')).toBe(false) - }) - - it('should remove directories recursively', async () => { - await fs.mkdir('/testdir', { recursive: true }) - await fs.writeFile('/testdir/file.txt', 'content') - await fs.rm('/testdir', { recursive: true }) - expect(fs.existsSync('/testdir')).toBe(false) - expect(fs.existsSync('/testdir/file.txt')).toBe(false) - }) - - it('should throw error when removing non-existent path', async () => { - await expect(fs.rm('/nonexistent')).rejects.toThrow('Path not found: /nonexistent') - }) - - it('should throw error when removing directory without recursive option', async () => { - await fs.mkdir('/testdir') - await fs.writeFile('/testdir/file.txt', 'content') - await expect(fs.rm('/testdir')).rejects.toThrow('Directory not empty: /testdir') - }) - }) -}) - -describe('InMemoryFileSystemService - Utility Methods', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('utility methods', () => { - describe('getContent', () => { - it('should return file content for existing file', () => { - fs.writeFile('/file1.txt', 'content1') - expect(fs.getContent('/file1.txt')).toBe('content1') - }) - - it('should return undefined for non-existing file', () => { - expect(fs.getContent('/nonexistent.txt')).toBeUndefined() - }) - }) - - describe('accessSync', () => { - it('should not throw for existing files', () => { - fs.writeFile('/test.txt', 'content') - expect(() => fs.accessSync()).not.toThrow() - }) - - it('should not throw for non-existent files', () => { - expect(() => fs.accessSync()).not.toThrow() - }) - }) - }) -}) - -describe('InMemoryFileSystemService - Complex Scenarios - Project Structure', () => { - it('should handle complex project structure', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/package.json': '{"name": "test"}', - '/project/src/index.ts': 'console.log("hello")', - '/project/src/utils.ts': 'export const util = () => {}', - }, - '/project', - '/project', - ) - - // Verify initial structure - expect(fs.readFileSync('/project/package.json')).toBe('{"name": "test"}') - expect(fs.readFileSync('/project/src/index.ts')).toBe('console.log("hello")') - expect(fs.readFileSync('/project/src/utils.ts')).toBe('export const util = () => {}') - - // Add more files - fs.writeFile('/project/tests/main.test.ts', 'describe("main", () => {})') - fs.writeFile('/project/README.md', '# Project') - - // Verify directory structure - expect((await fs.readdir('/project')).map(e => e.name)).toEqual( - expect.arrayContaining(['package.json', 'src', 'tests', 'README.md']), - ) - expect((await fs.readdir('/project/src')).map(e => e.name)).toEqual(['index.ts', 'utils.ts']) - expect((await fs.readdir('/project/tests')).map(e => e.name)).toEqual(['main.test.ts']) - }) -}) - -describe('InMemoryFileSystemService - Complex Scenarios - Path Resolution', () => { - it('should handle path resolution with custom working directory', () => { - const customFs = new InMemoryFileSystemService({}, '/custom/module', '/custom/work') - expect(customFs['state'].resolvePath('file.txt')).toBe('/custom/work/file.txt') - expect(customFs['state'].resolvePath('../file.txt')).toBe('/custom/file.txt') - expect(customFs['state'].resolvePath('dir/../file.txt')).toBe('/custom/work/file.txt') - - // Test absolute paths - expect(customFs['state'].resolvePath('/absolute/file.txt')).toBe('/absolute/file.txt') - }) -}) - -describe('InMemoryFileSystemService - ZIP Operations', () => { - it('should create and extract ZIP from single file', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/file.txt': 'content', - }, - '/project', - '/project', - ) - - await fs.createZip(['/project/file.txt'], '/project/archive.zip') - - expect(fs.existsSync('/project/archive.zip')).toBe(true) - - await fs.extractZip('/project/archive.zip', '/project/extracted') - - expect(fs.existsSync('/project/extracted/file.txt')).toBe(true) - expect(await fs.readFile('/project/extracted/file.txt')).toBe('content') - }) - - it('should create and extract ZIP from directory', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/src/index.ts': 'export {}', - '/project/src/utils.ts': 'export const util = 1', - '/project/src/nested/deep.ts': 'deep file', - }, - '/project', - '/project', - ) - - await fs.createZip(['/project/src'], '/project/bundle.zip') - - expect(fs.existsSync('/project/bundle.zip')).toBe(true) - - await fs.extractZip('/project/bundle.zip', '/project/output') - - expect(fs.existsSync('/project/output/index.ts')).toBe(true) - expect(fs.existsSync('/project/output/utils.ts')).toBe(true) - expect(fs.existsSync('/project/output/nested/deep.ts')).toBe(true) - expect(await fs.readFile('/project/output/index.ts')).toBe('export {}') - expect(await fs.readFile('/project/output/nested/deep.ts')).toBe('deep file') - }) - - it('should create ZIP from multiple sources', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/README.md': '# Project', - '/project/src/index.ts': 'export {}', - '/project/config.json': '{}', - }, - '/project', - '/project', - ) - - await fs.createZip( - ['/project/README.md', '/project/config.json', '/project/src'], - '/project/package.zip', - ) - - await fs.extractZip('/project/package.zip', '/project/unpacked') - - expect(fs.existsSync('/project/unpacked/README.md')).toBe(true) - expect(fs.existsSync('/project/unpacked/config.json')).toBe(true) - expect(fs.existsSync('/project/unpacked/index.ts')).toBe(true) - }) - - it('should throw error when extracting non-existent ZIP', async () => { - const fs = new InMemoryFileSystemService({}, '/project', '/project') - - await expect(fs.extractZip('/project/missing.zip', '/project/out')).rejects.toThrow( - 'ZIP file not found', - ) - }) - - it('should handle empty directory in ZIP', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/src/file.ts': 'content', - }, - '/project', - '/project', - ) - - await fs.createZip(['/project/src'], '/project/archive.zip') - await fs.extractZip('/project/archive.zip', '/project/restored') - - expect(fs.existsSync('/project/restored/file.ts')).toBe(true) - expect(await fs.readFile('/project/restored/file.ts')).toBe('content') - }) -}) diff --git a/packages/content-ops/src/test-utils/in-memory-fs.ts b/packages/content-ops/src/test-utils/in-memory-fs.ts deleted file mode 100644 index 138f59ef..00000000 --- a/packages/content-ops/src/test-utils/in-memory-fs.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { Dirent, Stats } from 'fs' -import type { FileSystemService } from '../file-system' -import { InMemoryFsState } from './in-memory-fs-state' -import { seedState } from './in-memory-fs-seed' -import * as read from './in-memory-fs-read' -import * as write from './in-memory-fs-write' - -/** - * In-memory FileSystemService test double. State lives in InMemoryFsState; the - * behavior is implemented in the focused in-memory-fs-{read,write,seed} modules - * and delegated to here so this class stays a thin, stable public surface. - */ -export class InMemoryFileSystemService implements FileSystemService { - private readonly state: InMemoryFsState - - constructor( - initial: Record = {}, - moduleDirectory: string, - workingDirectory: string, - ) { - this.state = new InMemoryFsState(moduleDirectory, workingDirectory) - seedState(this.state, initial, moduleDirectory, workingDirectory) - } - - accessSync() {} - - async readFile(path: string): Promise { - return read.readFileSync(this.state, path) - } - - readFileSync(path: string): string { - return read.readFileSync(this.state, path) - } - - existsSync(path: string): boolean { - return read.existsSync(this.state, path) - } - - async exists(path: string): Promise { - return read.existsSync(this.state, path) - } - - async stat(path: string): Promise { - return read.stat(this.state, path) - } - - async readdir(path: string): Promise { - return read.readdir(this.state, path) - } - - getContent(path: string): string | undefined { - return read.getContent(this.state, path) - } - - async isFile(path: string): Promise { - return read.isFile(this.state, path) - } - - async isFolder(path: string): Promise { - return read.isFolder(this.state, path) - } - - async writeFile(path: string, content: string): Promise { - return write.writeFile(this.state, path, content) - } - - async writeFileBinary(path: string, content: Buffer): Promise { - return write.writeFileBinary(this.state, path, content) - } - - async unlink(path: string): Promise { - return write.unlink(this.state, path) - } - - async mkdir(path: string, options?: { recursive?: boolean }): Promise { - write.mkdirImpl(this.state, path, options) - } - - mkdirSync(path: string, options?: { recursive?: boolean }): void { - write.mkdirImpl(this.state, path, options) - } - - async rename(oldPath: string, newPath: string): Promise { - return write.rename(this.state, oldPath, newPath) - } - - async copy(oldPath: string, newPath: string): Promise { - write.copyImpl(this.state, oldPath, newPath) - } - - copySync(oldPath: string, newPath: string): void { - write.copyImpl(this.state, oldPath, newPath) - } - - async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { - return write.rm(this.state, path, options) - } - - async symlink(target: string, path: string): Promise { - return write.symlink(this.state, target, path) - } - - getSymlinks(): Map { - return new Map(this.state.symlinks) - } - - rootModuleDirectory(): string { - return this.state.moduleDirectory - } - - currentWorkingDirectory(): string { - return this.state.workingDirectory - } - - resolve(...paths: string[]): string { - return this.state.resolve(...paths) - } - - chdir(path: string): void { - write.chdir(this.state, path) - } - - async createZip(sourcePaths: string[], outputPath: string): Promise { - return write.createZip(this.state, sourcePaths, outputPath) - } - - async extractZip(zipPath: string, outputDir: string): Promise { - return write.extractZip(this.state, zipPath, outputDir) - } -} - -export default InMemoryFileSystemService diff --git a/packages/content-ops/src/test-utils/in-memory-fs/index.ts b/packages/content-ops/src/test-utils/in-memory-fs/index.ts new file mode 100644 index 00000000..d52c8ada --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/index.ts @@ -0,0 +1,2 @@ +export { InMemoryFileSystemService } from './in-memory-fs' +export { default } from './in-memory-fs' From 96f89485d1e4fc4d60189eedf707bc0b48732dd4 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 15:45:55 +0200 Subject: [PATCH 16/21] [#199] docs: add DR-4 barrel rule, strip project citations from KB guidelines (PR #311 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KB guideline files ship to every adopting project via pair install/update; they must never cite this repo's own paths or issue numbers as "evidence" (meaningless/stale for external consumers). - design-rules.md: remove the 3 "Evidence (historical — #199)" paragraphs (DR-1/2/3) and the whole "Migration Plan" section (also now factually stale re: scan-mode removed by #224/#310); Recognition + Don't + Do already fully define each rule without a citation. Overview's rule enumeration updated accordingly. - Add DR-4 "Split Files Without a Barrel" (companion rule to this PR's ops/copy/ and test-utils/in-memory-fs/ folder-module refactors), generic illustrative snippets only, no repo citation. - shared-config-packages.md: genericize the apps/pair-cli, apps/website, packages/brand code-comment examples to placeholder project names (apps/api, apps/web, packages/ui-lib) so the guideline doesn't imply the reader's project has those directories. Repo-wide grep confirms these were the only two guideline files with this pattern. --- .../design-principles/design-rules.md | 39 +++++++++++-------- .../shared-config-packages.md | 6 +-- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md index 19b4e865..65d6403d 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md @@ -2,7 +2,7 @@ ## Overview -Evidence-based do/don't rules for patterns that repeatedly show up as tech-debt in codebase audits — the ones an AI assistant tends to (re)generate unless explicitly constrained. Each rule: **recognition** (how to spot it), **alternative** (what to do instead), **evidence** (real finding it comes from). No separate anti-pattern catalog — these rules live here, in the code-design guidelines, and are consolidated as new evidence appears. +Evidence-based do/don't rules for patterns that repeatedly show up as tech-debt in codebase audits — the ones an AI assistant tends to (re)generate unless explicitly constrained. Each rule: **recognition** (how to spot it), **alternative** (what to do instead). No separate anti-pattern catalog — these rules live here, in the code-design guidelines, and are consolidated as new evidence appears. **Precedence**: if a rule conflicts with a project's own adoption decision (`.pair/adoption/tech/`), the adoption decision wins. Note the override next to the rule it affects instead of deleting the rule (it may still apply to other projects/packages). @@ -32,8 +32,6 @@ export async function handleMirrorCleanup(...) {} // copy-orchestrator.ts — dispatch + shared setup ``` -**Evidence** (historical — all resolved in #199): `copyPathOps.ts` (705 LOC / 19 functions), `cli.e2e.test.ts` (1507 LOC), `dev/App.tsx` (456 LOC / 11 inline sections), `in-memory-fs.ts` (429 LOC) — a recurring cluster in `content-ops/src/ops/` (4 of the 2026-04-17 audit's top-10 largest files), each since split along its natural seams. See Migration Plan below. - ## DR-2 — Static-Only Namespace Class **Don't**: wrap a set of stateless functions in a `class` used only for its `static` methods. It adds ceremony (import the class, call through it) without adding behavior — no instance, no state, no polymorphism. @@ -57,8 +55,6 @@ export async function extractLinks(content: string) {} export async function extractLinksFromFile(path: string, fs: FileSystemService) {} ``` -**Evidence** (historical — resolved in #199): `markdown/link-processor.ts` once exposed a `class LinkProcessor` with 18 static methods; #199 converted it to a module of named function exports (the direction the file's own compat re-exports were already pointing). See Migration Plan below. - ## DR-3 — Optional-Bag Dispatch Instead of Discriminated Union **Don't**: model "this is either an A or a B" as one object type where A's and B's fields are all optional (`Partial`), then branch with `if (isA) {...} else if (isB) {...}` and force each field with a non-null assertion (`!`) at every use site. This is the concrete, type-unsafe cousin of "too many ifs": the compiler can't tell you which fields are actually present in each branch, so every read needs a manual assertion instead of the compiler narrowing it for you. @@ -94,22 +90,30 @@ switch (op.kind) { } ``` -**Evidence** (historical — non-null assertions resolved in #199): `content-ops/src/ops/movePathOps.ts` once modelled `MoveCtx` as `Partial<...>` with `ctx.source!` assertions at the two dispatch sites. #199 made `MoveCtx` a total type and dropped the assertions; a full discriminated-union rewrite was judged N/A here (the branch is unknown at ctx-build time). See Migration Plan below. +## DR-4 — Split Files Without a Barrel -## Migration Plan (originating instances) +**Don't**: split a god module (DR-1) into several sibling files that are only ever consumed together through a single entry point, and leave them as loose files in the parent directory. The directory listing grows noisy and a reader can't tell at a glance which of the siblings is the actual public API versus internal-only support files. -The concrete instances found while extracting these rules from the 2026-04-17 audit. All were resolved in #199, so they are recorded here as the rules' provenance rather than as open work. +**Recognition**: a group of files in the same directory where exactly one function/class is imported by anything outside the directory, and the rest are internal collaborators imported only by that one entry point or by each other. -| Rule | Location | Status | -| ---- | -------- | ------ | -| DR-1 | `content-ops/src/ops/copyPathOps.ts` (705 LOC) | Resolved in #199 — split into `copy-file` / `copy-directory` / `copy-directory-transforms` / `copy-types` + orchestrator | -| DR-1 | `apps/pair-cli/src/cli.e2e.test.ts` (1507 LOC) | Resolved in #199 — split into per-command e2e files + shared helpers | -| DR-1 | `packages/brand/dev/App.tsx` (456 LOC) | Resolved in #199 — sections extracted to `dev/sections/*` (App.tsx → 51 LOC) | -| DR-1 | `packages/content-ops/src/test-utils/in-memory-fs.ts` (429 LOC) | Resolved in #199 — split into state + read/write/seed modules | -| DR-2 | `packages/content-ops/src/markdown/link-processor.ts` (`class LinkProcessor`) | Resolved in #199 — converted to named function exports | -| DR-3 | `packages/content-ops/src/ops/movePathOps.ts` (`MoveCtx`) | Resolved in #199 — `MoveCtx` made total, `ctx.source!` assertions dropped (discriminated-union rewrite N/A) | +```typescript +// ops/ +// widget-orchestrator.ts <- the only export consumed outside ops/ +// widget-validation.ts <- internal-only, imported by widget-orchestrator.ts +// widget-transform.ts <- internal-only, imported by widget-orchestrator.ts +// widget-types.ts <- internal-only, shared types +``` -**Note**: these originating instances are cleared. New violations found by later audits are tracked as `tech-debt` Draft items (P1–P3) via `pair-capability-assess-debt` scan mode (#224), not appended here. +**Do**: group the split files in their own folder with an `index.ts` barrel that re-exports exactly what's externally consumed today. From the caller's side nothing changes (same import path, now resolving to the folder); the directory now signals which files are the public surface (the barrel) versus internal implementation. + +```typescript +// ops/widget/ +// index.ts -> export { widgetOrchestrator } from './widget-orchestrator' +// widget-orchestrator.ts +// widget-validation.ts +// widget-transform.ts +// widget-types.ts +``` ## Related @@ -117,3 +121,4 @@ The concrete instances found while extracting these rules from the 2026-04-17 au - [SOLID Principles](solid-principles.md) — DR-1 is the practical, evidence-backed form of Single Responsibility - [Naming Conventions](../code-organization/naming-conventions.md) — DR-2 is a naming/module-shape convention violation - [TypeScript](../framework-patterns/typescript.md) — DR-3 is a type-narrowing pattern (discriminated unions) +- [File Structure](../code-organization/file-structure.md) — DR-4 is a directory/module-grouping convention, applied at the split-file level diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/quality-standards/shared-config-packages.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/quality-standards/shared-config-packages.md index 4b528cdd..fce66fae 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/quality-standards/shared-config-packages.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/quality-standards/shared-config-packages.md @@ -43,17 +43,17 @@ pair's own monorepo is the canonical example. Four config packages, each consume Per-type mapping in practice — Node/service/lib workspaces (`apps/pair-cli`, `packages/knowledge-hub`, `packages/content-ops`) extend `@pair/ts-config/node.json` and take the ESLint base config as-is. Frontend and UI-lib workspaces (`apps/website`, `packages/brand`) extend `@pair/ts-config/ui.json` and override `eslint.config.cjs` to layer the React overlay: ```jsonc -// apps/pair-cli/tsconfig.json (backend/CLI — node preset) +// apps/api/tsconfig.json (backend/CLI — node preset) { "extends": "@pair/ts-config/node.json", "compilerOptions": { /* local overrides only */ } } ``` ```jsonc -// apps/website/tsconfig.json (frontend — ui preset) +// apps/web/tsconfig.json (frontend — ui preset) { "extends": "@pair/ts-config/ui.json", "compilerOptions": { "jsx": "preserve" /* local overrides only */ } } ``` ```js -// packages/brand/eslint.config.cjs (shared UI lib — same override as frontend, because it ships JSX) +// packages/ui-lib/eslint.config.cjs (shared UI lib — same override as frontend, because it ships JSX) module.exports = require('@pair/eslint-config/eslint.config.react.cjs') ``` From 0a6a3f17cec54260c2fe54a9c514147ea03ebab9 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 16:39:42 +0200 Subject: [PATCH 17/21] =?UTF-8?q?[#199]=20fix:=20reorganize=20e2e=20tests?= =?UTF-8?q?=20=E2=80=94=20delete=20duplicates,=20move=20genuine=20gaps=20t?= =?UTF-8?q?o=20module=20tests,=20consolidate=20to=20single=20cli.e2e.test.?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-test classification of the 7-file cli.e2e split found only 1/51 tests genuinely e2e (real cross-command state hand-off); the rest were single-module tests wearing e2e clothing. Deletes 35 exact/near duplicates, moves 15 genuine gaps into install/update/kb-validate handler.test.ts and registry/validation.test.ts, keeps the 1 genuine e2e test in a new cli.e2e.test.ts, and removes the now-unused cli-e2e-helpers.ts. --- apps/pair-cli/src/cli-errors.e2e.test.ts | 46 --- apps/pair-cli/src/cli-install.e2e.test.ts | 259 --------------- apps/pair-cli/src/cli-kb-validate.e2e.test.ts | 279 ---------------- apps/pair-cli/src/cli-link.e2e.test.ts | 137 -------- apps/pair-cli/src/cli-packaging.e2e.test.ts | 312 ------------------ apps/pair-cli/src/cli-update.e2e.test.ts | 194 ----------- .../src/cli-validate-config.e2e.test.ts | 136 -------- apps/pair-cli/src/cli.e2e.test.ts | 100 ++++++ .../src/commands/install/handler.test.ts | 178 ++++++++++ .../src/commands/kb-validate/handler.test.ts | 124 +++++++ .../src/commands/update/handler.test.ts | 206 ++++++++++++ apps/pair-cli/src/registry/validation.test.ts | 17 + .../src/test-utils/cli-e2e-helpers.ts | 164 --------- 13 files changed, 625 insertions(+), 1527 deletions(-) delete mode 100644 apps/pair-cli/src/cli-errors.e2e.test.ts delete mode 100644 apps/pair-cli/src/cli-install.e2e.test.ts delete mode 100644 apps/pair-cli/src/cli-kb-validate.e2e.test.ts delete mode 100644 apps/pair-cli/src/cli-link.e2e.test.ts delete mode 100644 apps/pair-cli/src/cli-packaging.e2e.test.ts delete mode 100644 apps/pair-cli/src/cli-update.e2e.test.ts delete mode 100644 apps/pair-cli/src/cli-validate-config.e2e.test.ts create mode 100644 apps/pair-cli/src/cli.e2e.test.ts delete mode 100644 apps/pair-cli/src/test-utils/cli-e2e-helpers.ts diff --git a/apps/pair-cli/src/cli-errors.e2e.test.ts b/apps/pair-cli/src/cli-errors.e2e.test.ts deleted file mode 100644 index 12cb6dac..00000000 --- a/apps/pair-cli/src/cli-errors.e2e.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { installCommand, handleUpdateCommand, parseUpdateCommand } from './commands' - -describe('pair-cli e2e - error scenarios', () => { - it('update fails gracefully when source directory does not exist', async () => { - const cwd = '/test-no-source' - const seed: Record = { - [cwd + '/config.json']: JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub config', - }, - }, - }), - } - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - await expect( - handleUpdateCommand(parseUpdateCommand({ source: '/nonexistent/path' }), fs), - ).rejects.toThrow('KB source path not found') - }) - - it('install from ZIP fails gracefully when ZIP is corrupted', async () => { - const cwd = '/test-bad-zip' - const seed: Record = { - [cwd + '/config.json']: JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub config', - }, - }, - }), - [cwd + '/bad.zip']: 'not a valid zip file', - } - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - const result = await installCommand(fs, ['--source', 'bad.zip'], { useDefaults: true }) - expect(result).toBeDefined() - // May succeed or fail depending on implementation, just ensure it doesn't crash - }) -}) diff --git a/apps/pair-cli/src/cli-install.e2e.test.ts b/apps/pair-cli/src/cli-install.e2e.test.ts deleted file mode 100644 index d23a87e4..00000000 --- a/apps/pair-cli/src/cli-install.e2e.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { installCommand, handleUpdateCommand, handleUpdateLinkCommand } from './commands' - -describe('pair-cli e2e - install from local sources', () => { - describe('install from local ZIP', () => { - it('installs from absolute path ZIP', async () => { - const cwd = '/test-absolute-zip' - const zipPath = 'kb.zip' - - // Create filesystem with ZIP file (simulated as binary content) - const seed: Record = {} - seed['/test-absolute-zip/kb.zip'] = - 'PK\x03\x04\x14\x00\x00\x00\x00\x00\x8d\x8f\x8bN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00\x00\x00AGENTS.mdthis is agents.md' // Minimal ZIP content - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', zipPath], { useDefaults: true }) - }) - - it('installs from relative path ZIP', async () => { - const cwd = '/test-relative-zip' - const zipPath = './downloads/kb.zip' - - const seed: Record = {} - seed['/test-relative-zip/downloads/kb.zip'] = - 'PK\x03\x04\x14\x00\x00\x00\x00\x00\x8d\x8f\x8bN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00\x00\x00AGENTS.mdthis is agents.md' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', zipPath], { useDefaults: true }) - }) - }) - - describe('install from local directory', () => { - it('installs from absolute path directory', async () => { - const cwd = '/test-absolute-dir' - const dirPath = 'dataset' - - const seed: Record = {} - seed[`${cwd}/${dirPath}/AGENTS.md`] = 'this is agents.md' - seed[`${cwd}/${dirPath}/.pair/knowledge/index.md`] = '# Knowledge Base' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', dirPath], { useDefaults: true }) - }) - - it('installs from relative path directory', async () => { - const cwd = '/test-relative-dir' - const dirPath = './dataset' - - const seed: Record = {} - seed['/test-relative-dir/dataset/AGENTS.md'] = 'this is agents.md' - seed['/test-relative-dir/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await installCommand(fs, ['--source', dirPath], { useDefaults: true }) - }) - }) -}) - -describe('pair-cli e2e - disjoint installation (source and target disjoint)', () => { - it('installs KB to a disjoint absolute path', async () => { - const projectRoot = '/test-project' - const disjointTarget = '/opt/pair/kb' - const kbSourceDir = '/mnt/external/kb-dataset' - - // 1. Setup Filesystem - const seed: Record = { - // Configuration in the "project root" - [`${projectRoot}/config.json`]: JSON.stringify({ - asset_registries: { - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: 'knowledge', mode: 'canonical' }], - description: 'Core knowledge', - }, - }, - }), - [`${projectRoot}/package.json`]: JSON.stringify({ - name: 'test-project', - version: '1.0.0', - }), - // KB Source content in a disjoint directory - [`${kbSourceDir}/AGENTS.md`]: '# KB source marker', - [`${kbSourceDir}/knowledge/index.md`]: '# Knowledge Index', - [`${kbSourceDir}/knowledge/guide.md`]: 'Follow the [Index](./index.md)', - } - - const fs = new InMemoryFileSystemService(seed, projectRoot, projectRoot) - - // 2. Perform installation to disjoint target - // pair install /opt/pair/kb --source /mnt/external/kb-dataset - await installCommand(fs, ['--source', kbSourceDir], { - baseTarget: disjointTarget, - useDefaults: true, - }) - - // 3. Verify installation in disjoint target - // The target path for the 'knowledge' registry should be /opt/pair/kb/knowledge - const installedFile = `${disjointTarget}/knowledge/index.md` - expect(fs.existsSync(installedFile)).toBe(true) - expect(fs.readFileSync(installedFile)).toBe('# Knowledge Index') - - // 4. Test disjoint update - // Add new file to source - await fs.writeFile(`${kbSourceDir}/knowledge/new.md`, 'New content') - - // pair update /opt/pair/kb --source /mnt/external/kb-dataset - await handleUpdateCommand( - { - command: 'update', - resolution: 'local', - path: kbSourceDir, - kb: true, - offline: true, - target: disjointTarget, - }, - fs, - ) - - expect(fs.existsSync(`${disjointTarget}/knowledge/new.md`)).toBe(true) - - // 5. Test disjoint update-link - // pair update-link /opt/pair/kb - await handleUpdateLinkCommand( - { - command: 'update-link', - target: disjointTarget, - dryRun: false, - logLevel: 'debug', - }, - fs, - ) - - // Verify rollback setup is working even in disjoint paths (implicitly tested by logic running) - const installedGuide = `${disjointTarget}/knowledge/guide.md` - expect(fs.existsSync(installedGuide)).toBe(true) - }) -}) diff --git a/apps/pair-cli/src/cli-kb-validate.e2e.test.ts b/apps/pair-cli/src/cli-kb-validate.e2e.test.ts deleted file mode 100644 index 836e50a9..00000000 --- a/apps/pair-cli/src/cli-kb-validate.e2e.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { handleKbValidateCommand } from './commands' - -describe('pair-cli e2e - kb-validate', () => { - function createFullConfig() { - return { - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents'], - description: 'GitHub config', - targets: [{ path: '.github', mode: 'canonical' }], - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - description: 'KB content', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - description: 'Adoption guides', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - }, - agents: { - source: 'AGENTS.md', - behavior: 'mirror', - description: 'Agent guidance', - targets: [ - { path: 'AGENTS.md', mode: 'canonical' }, - { path: 'CLAUDE.md', mode: 'copy' }, - ], - }, - skills: { - source: '.skills', - behavior: 'mirror', - flatten: true, - prefix: 'pair', - description: 'Agent skills', - targets: [ - { path: '.claude/skills/', mode: 'canonical' }, - { path: '.github/skills/', mode: 'symlink' }, - { path: '.cursor/skills/', mode: 'symlink' }, - ], - }, - }, - } - } - - function createSourceLayoutFs(cwd: string): InMemoryFileSystemService { - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' - seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' - seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/.skills/next/SKILL.md`] = - '---\nname: next\ndescription: Project navigator\n---\n# /next' - seed[`${cwd}/.skills/capability/assess-stack/SKILL.md`] = - '---\nname: assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' - return new InMemoryFileSystemService(seed, cwd, cwd) - } - - function createTargetLayoutFs(cwd: string): InMemoryFileSystemService { - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' - seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' - seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/CLAUDE.md`] = '# CLAUDE' - // Target layout: skills at canonical target with prefix applied - seed[`${cwd}/.claude/skills/pair-next/SKILL.md`] = - '---\nname: pair-next\ndescription: Project navigator\n---\n# /next' - seed[`${cwd}/.claude/skills/pair-capability-assess-stack/SKILL.md`] = - '---\nname: pair-capability-assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' - // Symlink targets NOT present (they'd be symlinks in real FS) - return new InMemoryFileSystemService(seed, cwd, cwd) - } - - describe('source layout validation', () => { - it('validates valid source layout — exit 0', async () => { - const cwd = '/e2e-validate-source' - const fs = createSourceLayoutFs(cwd) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).resolves.toBeUndefined() - }) - - it('detects missing registry in source layout', async () => { - const cwd = '/e2e-validate-source-missing' - // Create FS without adoption source dir — structure validator reports error - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/.skills/next/SKILL.md`] = '---\nname: next\ndescription: Nav\n---\n# /next' - // .pair/adoption is missing entirely - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - }) - - describe('target layout validation', () => { - it('validates valid target layout — exit 0', async () => { - const cwd = '/e2e-validate-target' - const fs = createTargetLayoutFs(cwd) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).resolves.toBeUndefined() - }) - - it('symlink targets ignored — no error for missing .github/skills', async () => { - const cwd = '/e2e-validate-target-symlink' - const fs = createTargetLayoutFs(cwd) - // .github/skills and .cursor/skills are symlink targets — should NOT be checked - expect(fs.existsSync(`${cwd}/.github/skills`)).toBe(false) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).resolves.toBeUndefined() - }) - - it('detects missing registry in target layout', async () => { - const cwd = '/e2e-validate-target-missing' - // Create FS without knowledge target dir — structure validator reports error - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/CLAUDE.md`] = '# CLAUDE' - seed[`${cwd}/.claude/skills/pair-next/SKILL.md`] = - '---\nname: pair-next\ndescription: Nav\n---\n# /next' - // .pair/knowledge is missing entirely - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).rejects.toThrow( - 'Validation failed', - ) - }) - }) - - describe('registry filtering', () => { - it('--skip-registries excludes specified registries', async () => { - const cwd = '/e2e-validate-skip' - // Create FS without adoption source dir — would fail without skip - const seed: Record = {} - seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) - seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge' - seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' - seed[`${cwd}/AGENTS.md`] = '# AGENTS' - seed[`${cwd}/.skills/next/SKILL.md`] = '---\nname: next\ndescription: Nav\n---\n# /next' - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Skipping adoption should pass despite missing .pair/adoption - await expect( - handleKbValidateCommand( - { command: 'kb-validate', layout: 'source', skipRegistries: ['adoption'] }, - fs, - ), - ).resolves.toBeUndefined() - }) - - it('--ignore-config skips structure validation', async () => { - const cwd = '/e2e-validate-ignore' - const fs = new InMemoryFileSystemService( - { - [`${cwd}/.pair/knowledge/index.md`]: '# KB', - }, - cwd, - cwd, - ) - // No config.json — would fail without --ignore-config - - await expect( - handleKbValidateCommand({ command: 'kb-validate', ignoreConfig: true }, fs), - ).resolves.toBeUndefined() - }) - }) - - describe('link integrity', () => { - it('detects broken internal links', async () => { - const cwd = '/e2e-validate-links' - const fs = createSourceLayoutFs(cwd) - // Replace valid link with broken one - await fs.writeFile( - `${cwd}/.pair/knowledge/index.md`, - '# Knowledge\n\nSee [missing](./nonexistent.md).', - ) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - - it('valid internal links pass', async () => { - const cwd = '/e2e-validate-links-valid' - const fs = createSourceLayoutFs(cwd) - // index.md links to guide.md which exists - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).resolves.toBeUndefined() - }) - }) - - describe('metadata and skills validation', () => { - it('detects missing SKILL.md frontmatter', async () => { - const cwd = '/e2e-validate-meta-missing' - const fs = createSourceLayoutFs(cwd) - // SKILL.md without frontmatter - await fs.writeFile(`${cwd}/.skills/next/SKILL.md`, '# /next\n\nNo frontmatter here.') - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - - it('detects missing required name field in frontmatter', async () => { - const cwd = '/e2e-validate-meta-field' - const fs = createSourceLayoutFs(cwd) - // SKILL.md with frontmatter but missing name - await fs.writeFile( - `${cwd}/.skills/next/SKILL.md`, - '---\ndescription: Navigator\n---\n# /next', - ) - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - - it('warns about adoption placeholders without failing', async () => { - const cwd = '/e2e-validate-placeholder' - const fs = createSourceLayoutFs(cwd) - // Adoption file with placeholder — should warn but not error - await fs.writeFile(`${cwd}/.pair/adoption/tech-stack.md`, '# Tech Stack\n\n[placeholder]') - - // Warnings don't cause failure - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).resolves.toBeUndefined() - }) - }) - - describe('exit codes and error handling', () => { - it('throws when .pair directory missing', async () => { - const cwd = '/e2e-validate-nopair' - const fs = new InMemoryFileSystemService({ [`${cwd}/README.md`]: '# Project' }, cwd, cwd) - - await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).rejects.toThrow( - 'missing .pair directory', - ) - }) - - it('mixed errors and warnings — exit 1', async () => { - const cwd = '/e2e-validate-mixed' - const fs = createSourceLayoutFs(cwd) - // Broken link (error) + placeholder (warning) - await fs.writeFile( - `${cwd}/.pair/knowledge/index.md`, - '# KB\n\nSee [broken](./nonexistent.md).', - ) - await fs.writeFile(`${cwd}/.pair/adoption/tech-stack.md`, '# Tech\n\n[placeholder]') - - await expect( - handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), - ).rejects.toThrow('Validation failed') - }) - }) -}) diff --git a/apps/pair-cli/src/cli-link.e2e.test.ts b/apps/pair-cli/src/cli-link.e2e.test.ts deleted file mode 100644 index b07a36a2..00000000 --- a/apps/pair-cli/src/cli-link.e2e.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { installCommand, updateCommand } from './commands' -import { withTempConfig, createTestConfig, getDeploymentConfig } from '#test-utils/cli-e2e-helpers' - -describe('pair-cli e2e - link strategy', () => { - it('install with relative link style', async () => { - const { fs } = getDeploymentConfig('dev') - await withTempConfig(fs, createTestConfig(), async () => { - const configPath = fs.rootModuleDirectory() + '/config.json' - const result = await installCommand(fs, [], { - customConfigPath: configPath, - useDefaults: true, - linkStyle: 'relative', - }) - expect((result as { success?: boolean }).success).toBe(true) - }) - }) - - it('update with absolute link style', async () => { - const { fs } = getDeploymentConfig('dev') - await withTempConfig(fs, createTestConfig(), async () => { - const configPath = fs.rootModuleDirectory() + '/config.json' - // First install to set up targets - await installCommand(fs, [], { customConfigPath: configPath, useDefaults: true }) - // Then update with absolute style and defaults - const result = await updateCommand(fs, [], { useDefaults: true, linkStyle: 'absolute' }) - expect((result as { success?: boolean }).success).toBe(true) - }) - }) - - it('update with auto link style detection', async () => { - const { fs } = getDeploymentConfig('dev') - await withTempConfig(fs, createTestConfig(), async () => { - const configPath = fs.rootModuleDirectory() + '/config.json' - // First install to establish baseline - await installCommand(fs, [], { customConfigPath: configPath, useDefaults: true }) - // Then update with auto detection and defaults - await updateCommand(fs, [], { useDefaults: true, linkStyle: 'auto' }) - // Auto detection should succeed - }) - }) -}) - -describe('pair-cli e2e - preferences persistence', () => { - it('saves and reads preferences with custom directory', async () => { - const { readPreferences, savePreferences } = await import('./commands/package/preferences.js') - const cwd = '/test-prefs' - const prefsDir = `${cwd}/.custom-pair` - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const data = { - packageMetadata: { name: 'my-kb', author: 'Test Author', tags: ['ai', 'kb'] }, - updatedAt: '2026-02-17T00:00:00.000Z', - } - - await savePreferences(data, fs, prefsDir) - const result = readPreferences(fs, prefsDir) - - expect(result).toEqual(data) - }) - - it('preferences from one session become defaults in next', async () => { - const { readPreferences, savePreferences } = await import('./commands/package/preferences.js') - const { resolveDefaults } = await import('./commands/package/defaults-resolver.js') - - const cwd = '/test-prefs-flow' - const prefsDir = `${cwd}/.pair` - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - // Session 1: save preferences - await savePreferences( - { - packageMetadata: { name: 'saved-kb', author: 'Saved Author', license: 'Apache-2.0' }, - updatedAt: new Date().toISOString(), - }, - fs, - prefsDir, - ) - - // Session 2: read preferences and resolve defaults - const prefs = readPreferences(fs, prefsDir) - const defaults = resolveDefaults({ preferences: prefs?.packageMetadata }) - - expect(defaults.name).toBe('saved-kb') - expect(defaults.author).toBe('Saved Author') - expect(defaults.license).toBe('Apache-2.0') - // Non-saved fields fall back to hardcoded - expect(defaults.version).toBe('1.0.0') - }) - - it('package command produces ZIP with non-optional manifest fields', async () => { - const { handlePackageCommand } = await import('./commands/package/handler.js') - const cwd = '/test-pkg-manifest' - const seed: Record = { - [`${cwd}/config.json`]: JSON.stringify({ - asset_registries: { - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'KB content', - }, - }, - }), - [`${cwd}/.pair/knowledge/doc.md`]: '# Doc', - } - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - const outputPath = `${cwd}/dist/kb.zip` - - await handlePackageCommand( - { - command: 'package', - output: outputPath, - interactive: false, - tags: ['ai', 'devops'], - license: 'Apache-2.0', - }, - fs, - ) - - expect(await fs.exists(outputPath)).toBe(true) - - // Extract and verify manifest has all required fields - const extractDir = `${cwd}/extracted` - await fs.extractZip(outputPath, extractDir) - const manifest = JSON.parse(await fs.readFile(`${extractDir}/manifest.json`)) - - expect(manifest.tags).toEqual(['ai', 'devops']) - expect(manifest.license).toBe('Apache-2.0') - expect(manifest.description).toBe('Knowledge base package') // default - expect(manifest.author).toBe('unknown') // default - expect(typeof manifest.name).toBe('string') - expect(typeof manifest.version).toBe('string') - expect(typeof manifest.created_at).toBe('string') - }) -}) diff --git a/apps/pair-cli/src/cli-packaging.e2e.test.ts b/apps/pair-cli/src/cli-packaging.e2e.test.ts deleted file mode 100644 index 8d097f3f..00000000 --- a/apps/pair-cli/src/cli-packaging.e2e.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { installCommand, handleUpdateCommand, parseUpdateCommand } from './commands' -import { withTempConfig } from '#test-utils/cli-e2e-helpers' - -describe('pair-cli e2e - package command', () => { - it('package command creates valid ZIP with manifest', async () => { - const cwd = '/test-package' - - const seed: Record = {} - // Create dataset source structure - seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS documentation' - seed[cwd + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[cwd + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[cwd + '/config.json'] = JSON.stringify({ - asset_registries: { - knowledge: { - source: '.', - behavior: 'mirror', - targets: [{ path: '.', mode: 'canonical' }], - description: 'Knowledge base dataset', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Test that we can create a package structure - // The actual executePackage function is internal, but we can test it via options validation - const configPath = cwd + '/config.json' - const config = JSON.parse(fs.readFileSync(configPath)) - expect(config.asset_registries).toBeDefined() - expect(config.asset_registries.knowledge).toBeDefined() - }) - - it('package command with --source-dir option validates config', async () => { - const cwd = '/test-package-source-dir' - - const seed: Record = {} - seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS' - seed[cwd + '/config.json'] = JSON.stringify({ - asset_registries: { - content: { - source: '.', - behavior: 'mirror', - targets: [{ path: '.', mode: 'canonical' }], - description: 'Content', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Verify config can be loaded - const configPath = cwd + '/config.json' - const config = JSON.parse(fs.readFileSync(configPath)) - expect(config.asset_registries.content).toBeDefined() - }) - - it('package command fails gracefully with invalid config', async () => { - const cwd = '/test-package-bad-config' - - const seed: Record = {} - seed[cwd + '/dataset/AGENTS.md'] = '# AGENTS' - seed[cwd + '/config.json'] = '{ invalid json' - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Verify that parsing invalid config fails - const configPath = cwd + '/config.json' - expect(() => { - JSON.parse(fs.readFileSync(configPath)) - }).toThrow() - }) - - it('package command fails gracefully with missing config', async () => { - const cwd = '/test-package-no-config' - - const seed: Record = { - [cwd + '/dataset/AGENTS.md']: '# AGENTS', - } - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - // Verify that reading missing config fails - const configPath = cwd + '/nonexistent.json' - expect(() => { - fs.readFileSync(configPath) - }).toThrow() - }) -}) - -describe('pair-cli e2e - skills registry pipeline', () => { - function createSkillsConfig() { - return { - asset_registries: { - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - description: 'Knowledge base content', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - }, - skills: { - source: '.skills', - behavior: 'mirror', - flatten: true, - prefix: 'pair', - description: 'Agent skills distributed to AI tool directories', - targets: [ - { path: '.claude/skills/', mode: 'canonical' }, - { path: '.github/skills/', mode: 'symlink' }, - { path: '.cursor/skills/', mode: 'symlink' }, - ], - }, - }, - } - } - - function createSkillsDatasetFs(cwd: string, opts?: { withTargets?: boolean }) { - const seed: Record = {} - const datasetBase = `${cwd}/dataset` - - // Pre-existing targets (update scenario — project already installed) - if (opts?.withTargets) { - seed[`${cwd}/.pair/knowledge/old.md`] = '# old' - } - - // Knowledge registry content - seed[`${datasetBase}/.pair/knowledge/index.md`] = '# Knowledge Base' - - // Skills registry content — mimics real .skills/ structure - seed[`${datasetBase}/.skills/next/SKILL.md`] = - '---\nname: next\ndescription: Project navigator\n---\n# /next' - - seed[`${cwd}/config.json`] = JSON.stringify(createSkillsConfig()) - - return new InMemoryFileSystemService(seed, cwd, cwd) - } - - it('update distributes skills with flatten + prefix to canonical target', async () => { - const cwd = '/test-skills' - const fs = createSkillsDatasetFs(cwd, { withTargets: true }) - - await handleUpdateCommand(parseUpdateCommand({ source: `${cwd}/dataset` }), fs) - - // Flatten is no-op for single-segment 'next', prefix 'pair' → 'pair-next' - expect(fs.existsSync(`${cwd}/.claude/skills/pair-next/SKILL.md`)).toBe(true) - const content = fs.readFileSync(`${cwd}/.claude/skills/pair-next/SKILL.md`) - expect(content).toContain('name: pair-next') - }) - - it('update creates symlinks for secondary targets', async () => { - const cwd = '/test-skills-symlink' - const fs = createSkillsDatasetFs(cwd, { withTargets: true }) - - await handleUpdateCommand(parseUpdateCommand({ source: `${cwd}/dataset` }), fs) - - // Secondary targets should be symlinks pointing to the canonical path - const symlinks = fs.getSymlinks() - const canonicalPath = `${cwd}/.claude/skills` - const githubSymlink = `${cwd}/.github/skills` - const cursorSymlink = `${cwd}/.cursor/skills` - - expect(symlinks.get(githubSymlink)).toBe(canonicalPath) - expect(symlinks.get(cursorSymlink)).toBe(canonicalPath) - }) - - it('validate-config succeeds with skills registry config', async () => { - const cwd = '/test-skills-validate' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - await withTempConfig(fs, createSkillsConfig(), async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(true) - expect(validation.errors).toHaveLength(0) - }) - }) - - it('validate-config fails when skills registry has no canonical target', async () => { - const cwd = '/test-skills-no-canonical' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const badConfig = { - asset_registries: { - skills: { - source: '.skills', - behavior: 'mirror', - flatten: true, - prefix: 'pair', - description: 'Skills with no canonical target', - targets: [ - { path: '.github/skills/', mode: 'symlink' }, - { path: '.cursor/skills/', mode: 'symlink' }, - ], - }, - }, - } - - await withTempConfig(fs, badConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors.some(e => e.includes('canonical'))).toBe(true) - }) - }) - - it('install distributes skills to canonical and symlink targets', async () => { - const cwd = '/test-skills-install' - const fs = createSkillsDatasetFs(cwd) - - await installCommand(fs, ['--source', `${cwd}/dataset`], { useDefaults: true }) - - // Canonical target should have flattened+prefixed content - expect(fs.existsSync(`${cwd}/.claude/skills/pair-next/SKILL.md`)).toBe(true) - - // Secondary targets should be symlinks - const symlinks = fs.getSymlinks() - expect(symlinks.has(`${cwd}/.github/skills`)).toBe(true) - expect(symlinks.has(`${cwd}/.cursor/skills`)).toBe(true) - }) -}) - -// ── kb-validate E2E tests ────────────────────────────────────────────── - -describe('pair-cli e2e - org packaging', () => { - it('parsePackageCommand includes org fields when --org flag present', async () => { - const { parsePackageCommand } = await import('./commands/package/parser.js') - - const config = parsePackageCommand({ - org: true, - orgName: 'Acme Corp', - team: 'Platform', - compliance: 'SOC2,ISO27001', - distribution: 'private', - }) - - expect(config.org).toBe(true) - expect(config.orgName).toBe('Acme Corp') - expect(config.team).toBe('Platform') - expect(config.compliance).toEqual(['SOC2', 'ISO27001']) - expect(config.distribution).toBe('private') - }) - - it('parsePackageCommand omits org fields when --org absent', async () => { - const { parsePackageCommand } = await import('./commands/package/parser.js') - - const config = parsePackageCommand({ name: 'test-kb' }) - - expect(config.org).toBeUndefined() - expect(config.orgName).toBeUndefined() - }) - - it('org template merges with CLI flags', async () => { - const { loadOrgTemplate, mergeOrgDefaults } = await import('./commands/package/org-template.js') - - const cwd = '/e2e-org-template' - const fs = new InMemoryFileSystemService( - { - [`${cwd}/.pair/org-template.json`]: JSON.stringify({ - name: 'Template Corp', - team: 'Default Team', - compliance: ['SOC2'], - distribution: 'restricted', - }), - }, - cwd, - cwd, - ) - - const template = await loadOrgTemplate(cwd, fs, '.pair/org-template.json') - expect(template).not.toBeNull() - - // CLI flags override template - const org = mergeOrgDefaults({ orgName: 'CLI Corp', team: 'CLI Team' }, template) - expect(org.name).toBe('CLI Corp') - expect(org.team).toBe('CLI Team') - // Template values used as fallback - expect(org.compliance).toEqual(['SOC2']) - expect(org.distribution).toBe('restricted') - }) - - it('createOrganizationMetadata factory provides defaults', async () => { - const { createOrganizationMetadata } = await import('./commands/package/metadata.js') - - const org = createOrganizationMetadata({ name: 'Acme' }) - expect(org.compliance).toEqual([]) - expect(org.distribution).toBe('open') - }) - - it('generateManifestMetadata includes organization in manifest', async () => { - const { generateManifestMetadata, createOrganizationMetadata } = await import( - './commands/package/metadata.js' - ) - - const org = createOrganizationMetadata({ - name: 'Acme', - team: 'Platform', - compliance: ['SOC2'], - distribution: 'private', - }) - - const manifest = generateManifestMetadata(['knowledge'], { organization: org }) - expect(manifest.organization).toBeDefined() - expect(manifest.organization?.name).toBe('Acme') - expect(manifest.organization?.distribution).toBe('private') - }) -}) diff --git a/apps/pair-cli/src/cli-update.e2e.test.ts b/apps/pair-cli/src/cli-update.e2e.test.ts deleted file mode 100644 index 00a91af5..00000000 --- a/apps/pair-cli/src/cli-update.e2e.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { handleUpdateCommand, parseUpdateCommand } from './commands' - -describe('update from local sources', () => { - describe('update from local ZIP', () => { - it('updates from absolute path ZIP', async () => { - const cwd = '/test-absolute-zip' - const zipPath = 'dataset' - - // Create filesystem with extracted ZIP content (simulated as directory) - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed[`${cwd}/${zipPath}/AGENTS.md`] = 'this is agents.md' - seed[`${cwd}/${zipPath}/.github/workflows/ci.yml`] = 'name: CI\non: push' - seed[`${cwd}/${zipPath}/.pair/knowledge/index.md`] = '# Knowledge Base' - seed[`${cwd}/${zipPath}/.pair/adoption/onboarding.md`] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: zipPath }), fs) - }) - - it('updates from relative path ZIP', async () => { - const cwd = '/test-relative-zip' - const zipPath = './dataset' - - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed['/test-relative-zip/dataset/AGENTS.md'] = 'this is agents.md' - seed['/test-relative-zip/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed['/test-relative-zip/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed['/test-relative-zip/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: zipPath }), fs) - }) - }) - - describe('update from local directory', () => { - it('updates from absolute path directory', async () => { - const cwd = '/test-absolute-dir' - const dirPath = 'dataset' - - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed[`${cwd}/${dirPath}/AGENTS.md`] = 'this is agents.md' - seed[`${cwd}/${dirPath}/.github/workflows/ci.yml`] = 'name: CI\non: push' - seed[`${cwd}/${dirPath}/.pair/knowledge/index.md`] = '# Knowledge Base' - seed[`${cwd}/${dirPath}/.pair/adoption/onboarding.md`] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: dirPath }), fs) - }) - - it('updates from relative path directory', async () => { - const cwd = '/test-relative-dir' - const dirPath = './dataset' - - const seed: Record = {} - // Pre-existing target (update scenario — project already installed) - seed[`${cwd}/.github/old.yml`] = '# pre-existing' - seed['/test-relative-dir/dataset/AGENTS.md'] = 'this is agents.md' - seed['/test-relative-dir/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed['/test-relative-dir/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed['/test-relative-dir/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - seed[`${cwd}/config.json`] = JSON.stringify({ - asset_registries: { - github: { - source: '.github', - behavior: 'mirror', - include: ['/agents', '/prompts'], - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configuration files', - }, - knowledge: { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair/knowledge', mode: 'canonical' }], - description: 'Knowledge base and documentation', - }, - adoption: { - source: '.pair/adoption', - behavior: 'add', - targets: [{ path: '.pair/adoption', mode: 'canonical' }], - description: 'Adoption guides and onboarding materials', - }, - agents: { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - }) - - const fs = new InMemoryFileSystemService(seed, cwd, cwd) - - await handleUpdateCommand(parseUpdateCommand({ source: dirPath }), fs) - }) - }) -}) diff --git a/apps/pair-cli/src/cli-validate-config.e2e.test.ts b/apps/pair-cli/src/cli-validate-config.e2e.test.ts deleted file mode 100644 index eb556462..00000000 --- a/apps/pair-cli/src/cli-validate-config.e2e.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' -import { createDevScenarioFs, withTempConfig, createTestConfig } from '#test-utils/cli-e2e-helpers' - -describe('pair-cli e2e - validate-config success', () => { - it('validate-config succeeds with valid config', async () => { - const cwd = '/test-project' - const fs = createDevScenarioFs(cwd) - - await withTempConfig(fs, createTestConfig(), async () => { - // Mock the CLI execution by calling the validate-config logic directly - // Since we can't easily run the CLI binary in tests, we'll test the underlying function - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(true) - expect(validation.errors).toHaveLength(0) - }) - }) -}) - -describe('pair-cli e2e - validate-config failures basic', () => { - it('validate-config fails with invalid config', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - asset_registries: { - '.github': { - source: '.github', - behavior: 'invalid-behavior', // Invalid behavior - targets: [{ path: '.github', mode: 'canonical' }], - // Missing description - this should also be an error - }, - }, - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toHaveLength(2) // Invalid behavior + missing description - expect(validation.errors[0]).toContain('invalid behavior') - }) - }) - - it('validate-config fails with missing asset_registries', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - // Missing asset_registries - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toContain('Config must have asset_registries object') - }) - }) -}) - -describe('pair-cli e2e - validate-config failures advanced', () => { - it('validate-config fails with invalid registry structure', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - asset_registries: { - '.github': 'invalid-registry', // Should be an object - }, - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toContain("Registry '.github' must be a valid object") - }) - }) - - it('validate-config fails with invalid include array', async () => { - const cwd = '/test-project' - const fs = new InMemoryFileSystemService({}, cwd, cwd) - - const invalidConfig = { - asset_registries: { - '.github': { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows', - include: ['valid-string', 123], // Invalid: number in array - }, - }, - } - - await withTempConfig(fs, invalidConfig, async () => { - const { config } = await import('#config').then(m => m.loadConfigWithOverrides(fs)) - const { validateConfig } = (await import('#config')) as typeof import('#config') - const validation = validateConfig(config) - - expect(validation.valid).toBe(false) - expect(validation.errors).toContain( - "Registry '.github' include array must contain only strings", - ) - }) - }) -}) - -describe('pair-cli e2e - list-targets', () => { - it('list-targets shows available registries', async () => { - const cwd = '/test-project' - const fs = createDevScenarioFs(cwd) - // Add KB marker so local source validation passes when source='.' - await fs.writeFile(cwd + '/AGENTS.md', 'this is agents.md') - - await withTempConfig(fs, createTestConfig(), async () => { - // Mock the CLI execution by calling the update command with listTargets option - const { handleUpdateCommand, parseUpdateCommand } = (await import( - './commands/index.js' - )) as typeof import('./commands/index.js') - await handleUpdateCommand(parseUpdateCommand({ source: '.' }), fs) - - // The function no longer returns a value (success indicated by lack of throw) - }) - }) -}) diff --git a/apps/pair-cli/src/cli.e2e.test.ts b/apps/pair-cli/src/cli.e2e.test.ts new file mode 100644 index 00000000..5f4b4b6f --- /dev/null +++ b/apps/pair-cli/src/cli.e2e.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' +import { installCommand, handleUpdateCommand, handleUpdateLinkCommand } from './commands' + +/** + * pair-cli e2e suite. + * + * #199 test reorg: a per-test classification of the 7 files this suite replaces + * (cli-errors, cli-install, cli-kb-validate, cli-link, cli-packaging, cli-update, + * cli-validate-config .e2e.test.ts — 51 tests total) found only ONE test that is + * genuinely e2e: it depends on a real hand-off of state between three independently + * invoked commands (install → update → update-link) against the same disjoint + * baseTarget. The other 50 were single-module tests wearing e2e clothing; duplicates + * were deleted and genuine coverage gaps were moved to module-level unit tests. The + * project default is one e2e file per application entry point — splitting is only + * valid when production code is genuinely refactored into isolated modules with + * corresponding true unit tests, which did not happen for the prior 7-file split. + */ +describe('pair-cli e2e', () => { + describe('cross-command flows', () => { + it('installs KB to a disjoint absolute path, then update and update-link correctly build on it', async () => { + const projectRoot = '/test-project' + const disjointTarget = '/opt/pair/kb' + const kbSourceDir = '/mnt/external/kb-dataset' + + // 1. Setup Filesystem + const seed: Record = { + // Configuration in the "project root" + [`${projectRoot}/config.json`]: JSON.stringify({ + asset_registries: { + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: 'knowledge', mode: 'canonical' }], + description: 'Core knowledge', + }, + }, + }), + [`${projectRoot}/package.json`]: JSON.stringify({ + name: 'test-project', + version: '1.0.0', + }), + // KB Source content in a disjoint directory + [`${kbSourceDir}/AGENTS.md`]: '# KB source marker', + [`${kbSourceDir}/knowledge/index.md`]: '# Knowledge Index', + [`${kbSourceDir}/knowledge/guide.md`]: 'Follow the [Index](./index.md)', + } + + const fs = new InMemoryFileSystemService(seed, projectRoot, projectRoot) + + // 2. Perform installation to disjoint target + // pair install /opt/pair/kb --source /mnt/external/kb-dataset + await installCommand(fs, ['--source', kbSourceDir], { + baseTarget: disjointTarget, + useDefaults: true, + }) + + // 3. Verify installation in disjoint target + // The target path for the 'knowledge' registry should be /opt/pair/kb/knowledge + const installedFile = `${disjointTarget}/knowledge/index.md` + expect(fs.existsSync(installedFile)).toBe(true) + expect(fs.readFileSync(installedFile)).toBe('# Knowledge Index') + + // 4. Test disjoint update + // Add new file to source + await fs.writeFile(`${kbSourceDir}/knowledge/new.md`, 'New content') + + // pair update /opt/pair/kb --source /mnt/external/kb-dataset + await handleUpdateCommand( + { + command: 'update', + resolution: 'local', + path: kbSourceDir, + kb: true, + offline: true, + target: disjointTarget, + }, + fs, + ) + + expect(fs.existsSync(`${disjointTarget}/knowledge/new.md`)).toBe(true) + + // 5. Test disjoint update-link + // pair update-link /opt/pair/kb + await handleUpdateLinkCommand( + { + command: 'update-link', + target: disjointTarget, + dryRun: false, + logLevel: 'debug', + }, + fs, + ) + + // Verify rollback setup is working even in disjoint paths (implicitly tested by logic running) + const installedGuide = `${disjointTarget}/knowledge/guide.md` + expect(fs.existsSync(installedGuide)).toBe(true) + }) + }) +}) diff --git a/apps/pair-cli/src/commands/install/handler.test.ts b/apps/pair-cli/src/commands/install/handler.test.ts index ee5cb55c..b67cdfef 100644 --- a/apps/pair-cli/src/commands/install/handler.test.ts +++ b/apps/pair-cli/src/commands/install/handler.test.ts @@ -176,6 +176,103 @@ describe('handleInstallCommand - real services integration', () => { expect(await fs.exists(`${cwd}/dest/file.txt`)).toBe(true) expect(await fs.readFile(`${cwd}/dest/file.txt`)).toBe('local content') }) + + // Moved from cli-install.e2e.test.ts (#199 test reorg) — genuine gap: handler-level + // content distribution had no coverage with a relative source path. + test('handles local path source to a directory given as a relative path', async () => { + await fs.mkdir(`${cwd}/relative-kb/my-reg`, { recursive: true }) + await fs.writeFile(`${cwd}/relative-kb/my-reg/file.txt`, 'relative content') + await fs.writeFile(`${cwd}/relative-kb/AGENTS.md`, '# KB marker') + + const localConfig = { + asset_registries: { + 'my-reg': { + behavior: 'mirror', + targets: [{ path: 'dest', mode: 'canonical' }], + description: 'Local reg', + }, + }, + } + await fs.writeFile(`${cwd}/config.json`, JSON.stringify(localConfig)) + + const command: InstallCommandConfig = { + command: 'install', + resolution: 'local', + path: './relative-kb', + offline: true, + kb: true, + } + + await handleInstallCommand(command, fs) + + expect(await fs.exists(`${cwd}/dest/file.txt`)).toBe(true) + expect(await fs.readFile(`${cwd}/dest/file.txt`)).toBe('relative content') + }) + + // Moved from cli-install.e2e.test.ts (#199 test reorg) — genuine gap: no handler-level + // coverage of the local .zip resolution branch (mocked like update/handler.test.ts's + // equivalent zip test, since real zip extraction is covered by kb-installer.test.ts). + test('handles local .zip source with an absolute path', async () => { + const kbInstaller = await import('#kb-manager/kb-installer') + const extractedPath = '/cached/unzipped-abs' + vi.spyOn(kbInstaller, 'installKBFromLocalZip').mockResolvedValue(extractedPath) + await fs.writeFile(`${extractedPath}/my-reg/file.txt`, 'zip content') + + const localConfig = { + asset_registries: { + 'my-reg': { + behavior: 'mirror', + targets: [{ path: 'dest', mode: 'canonical' }], + description: 'Local reg', + }, + }, + } + await fs.writeFile(`${cwd}/config.json`, JSON.stringify(localConfig)) + + const command: InstallCommandConfig = { + command: 'install', + resolution: 'local', + path: '/downloads/kb.zip', + offline: true, + kb: true, + } + + await handleInstallCommand(command, fs) + + expect(await fs.exists(`${cwd}/dest/file.txt`)).toBe(true) + expect(await fs.readFile(`${cwd}/dest/file.txt`)).toBe('zip content') + }) + + test('handles local .zip source with a relative path', async () => { + const kbInstaller = await import('#kb-manager/kb-installer') + const extractedPath = '/cached/unzipped-rel' + vi.spyOn(kbInstaller, 'installKBFromLocalZip').mockResolvedValue(extractedPath) + await fs.writeFile(`${extractedPath}/my-reg/file.txt`, 'zip content') + + const localConfig = { + asset_registries: { + 'my-reg': { + behavior: 'mirror', + targets: [{ path: 'dest', mode: 'canonical' }], + description: 'Local reg', + }, + }, + } + await fs.writeFile(`${cwd}/config.json`, JSON.stringify(localConfig)) + + const command: InstallCommandConfig = { + command: 'install', + resolution: 'local', + path: './downloads/kb.zip', + offline: true, + kb: true, + } + + await handleInstallCommand(command, fs) + + expect(await fs.exists(`${cwd}/dest/file.txt`)).toBe(true) + expect(await fs.readFile(`${cwd}/dest/file.txt`)).toBe('zip content') + }) }) describe('error handling', () => { @@ -1028,3 +1125,84 @@ describe('install — KB version recording (#261)', () => { logSpy.mockRestore() }) }) + +// Moved from cli-link.e2e.test.ts (#199 test reorg) — genuine gap: no coverage of the +// options.linkStyle wire-through on install. The transformation logic itself is unit-tested +// in update-link/logic.test.ts; this only verifies install accepts and applies the option +// without breaking the distribution pipeline. +describe('install — linkStyle option', () => { + const cwd = '/test-project' + const testConfig = { + asset_registries: { + github: { + source: 'github', + behavior: 'mirror', + targets: [{ path: '.github', mode: 'canonical' }], + description: 'GitHub registry', + }, + }, + } + const extraFiles = { + [`${cwd}/package.json`]: JSON.stringify({ name: 'test-pkg', version: '0.1.0' }), + [`${cwd}/packages/knowledge-hub/package.json`]: JSON.stringify({ name: '@pair/knowledge-hub' }), + [`${cwd}/packages/knowledge-hub/dataset/github/workflow.yml`]: 'content: val', + } + + test('installs successfully with linkStyle: relative', async () => { + const fs = createTestFs(testConfig, extraFiles, cwd) + + await handleInstallCommand( + { command: 'install', resolution: 'default', kb: true, offline: false }, + fs, + { linkStyle: 'relative' }, + ) + + expect(await fs.exists(`${cwd}/.github/workflow.yml`)).toBe(true) + }) +}) + +// Moved from cli-packaging.e2e.test.ts (#199 test reorg) — the canonical-target +// flatten+prefix assertion is already covered by the #238 describe block above; +// only the secondary (symlink) target distribution was a genuine gap. +describe('install — skills registry secondary (symlink) targets', () => { + test('creates symlinks for secondary skills targets', async () => { + const cwd = '/test-skills-install' + const seed: Record = { + [`${cwd}/dataset/.pair/knowledge/index.md`]: '# Knowledge Base', + [`${cwd}/dataset/.skills/next/SKILL.md`]: + '---\nname: next\ndescription: Project navigator\n---\n# /next', + [`${cwd}/config.json`]: JSON.stringify({ + asset_registries: { + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + description: 'Knowledge base content', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + }, + skills: { + source: '.skills', + behavior: 'mirror', + flatten: true, + prefix: 'pair', + description: 'Agent skills distributed to AI tool directories', + targets: [ + { path: '.claude/skills/', mode: 'canonical' }, + { path: '.github/skills/', mode: 'symlink' }, + { path: '.cursor/skills/', mode: 'symlink' }, + ], + }, + }, + }), + } + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await handleInstallCommand( + { command: 'install', resolution: 'local', path: `${cwd}/dataset`, offline: true, kb: true }, + fs, + ) + + const symlinks = fs.getSymlinks() + expect(symlinks.has(`${cwd}/.github/skills`)).toBe(true) + expect(symlinks.has(`${cwd}/.cursor/skills`)).toBe(true) + }) +}) diff --git a/apps/pair-cli/src/commands/kb-validate/handler.test.ts b/apps/pair-cli/src/commands/kb-validate/handler.test.ts index 932b8ac5..292d43f4 100644 --- a/apps/pair-cli/src/commands/kb-validate/handler.test.ts +++ b/apps/pair-cli/src/commands/kb-validate/handler.test.ts @@ -93,3 +93,127 @@ describe('handleKbValidateCommand', () => { ).resolves.toBeUndefined() }) }) + +/** + * Realistic multi-registry configs exercising structure/link/metadata validators + * together through the handler (moved from the former cli-kb-validate.e2e.test.ts — + * #199 test reorg: e2e default is one file per entry point, these are genuine + * handler-level coverage, not real e2e). + */ +describe('handleKbValidateCommand - realistic multi-registry layouts', () => { + function createFullConfig() { + return { + asset_registries: { + github: { + source: '.github', + behavior: 'mirror', + include: ['/agents'], + description: 'GitHub config', + targets: [{ path: '.github', mode: 'canonical' }], + }, + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + description: 'KB content', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + }, + adoption: { + source: '.pair/adoption', + behavior: 'add', + description: 'Adoption guides', + targets: [{ path: '.pair/adoption', mode: 'canonical' }], + }, + agents: { + source: 'AGENTS.md', + behavior: 'mirror', + description: 'Agent guidance', + targets: [ + { path: 'AGENTS.md', mode: 'canonical' }, + { path: 'CLAUDE.md', mode: 'copy' }, + ], + }, + skills: { + source: '.skills', + behavior: 'mirror', + flatten: true, + prefix: 'pair', + description: 'Agent skills', + targets: [ + { path: '.claude/skills/', mode: 'canonical' }, + { path: '.github/skills/', mode: 'symlink' }, + { path: '.cursor/skills/', mode: 'symlink' }, + ], + }, + }, + } + } + + function createSourceLayoutFs(cwd: string): InMemoryFileSystemService { + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' + seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' + seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/.skills/next/SKILL.md`] = + '---\nname: next\ndescription: Project navigator\n---\n# /next' + seed[`${cwd}/.skills/capability/assess-stack/SKILL.md`] = + '---\nname: assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' + return new InMemoryFileSystemService(seed, cwd, cwd) + } + + function createTargetLayoutFs(cwd: string): InMemoryFileSystemService { + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge\n\nSee [guide](./guide.md).' + seed[`${cwd}/.pair/knowledge/guide.md`] = '# Guide' + seed[`${cwd}/.pair/adoption/tech-stack.md`] = '# Tech Stack\n- Node.js 20' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/CLAUDE.md`] = '# CLAUDE' + // Target layout: skills at canonical target with prefix applied + seed[`${cwd}/.claude/skills/pair-next/SKILL.md`] = + '---\nname: pair-next\ndescription: Project navigator\n---\n# /next' + seed[`${cwd}/.claude/skills/pair-capability-assess-stack/SKILL.md`] = + '---\nname: pair-capability-assess-stack\ndescription: Stack assessment\n---\n# /assess-stack' + // Symlink targets NOT present (they'd be symlinks in real FS) + return new InMemoryFileSystemService(seed, cwd, cwd) + } + + it('validates a realistic source layout across all registries — exit 0', async () => { + const cwd = '/handler-validate-source' + const fs = createSourceLayoutFs(cwd) + + await expect( + handleKbValidateCommand({ command: 'kb-validate', layout: 'source' }, fs), + ).resolves.toBeUndefined() + }) + + it('validates a realistic target layout across all registries — exit 0', async () => { + const cwd = '/handler-validate-target' + const fs = createTargetLayoutFs(cwd) + + await expect(handleKbValidateCommand({ command: 'kb-validate' }, fs)).resolves.toBeUndefined() + }) + + it('--skip-registries excludes specified registries from validation', async () => { + const cwd = '/handler-validate-skip' + // FS without adoption source dir — would fail without skip + const seed: Record = {} + seed[`${cwd}/config.json`] = JSON.stringify(createFullConfig()) + seed[`${cwd}/.pair/knowledge/index.md`] = '# Knowledge' + seed[`${cwd}/.github/agents/config.yml`] = 'agent: true' + seed[`${cwd}/AGENTS.md`] = '# AGENTS' + seed[`${cwd}/.skills/next/SKILL.md`] = '---\nname: next\ndescription: Nav\n---\n# /next' + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + // Skipping adoption should pass despite missing .pair/adoption + await expect( + handleKbValidateCommand( + { command: 'kb-validate', layout: 'source', skipRegistries: ['adoption'] }, + fs, + ), + ).resolves.toBeUndefined() + }) +}) diff --git a/apps/pair-cli/src/commands/update/handler.test.ts b/apps/pair-cli/src/commands/update/handler.test.ts index d1d0aa30..e0f6a467 100644 --- a/apps/pair-cli/src/commands/update/handler.test.ts +++ b/apps/pair-cli/src/commands/update/handler.test.ts @@ -1414,3 +1414,209 @@ describe('update — KB version recording (#261)', () => { expect(await fs.exists(`${cwd}/.pair/.kb-version.json`)).toBe(false) }) }) + +/** + * Moved from cli-link.e2e.test.ts (#199 test reorg) — genuine gap: no coverage of the + * options.linkStyle wire-through on update. The former e2e version chained a real + * installCommand call first only to satisfy the "targets must exist" precondition; per + * the confirmed reorg decision, we reseed that pre-existing state directly via fs writes + * instead, matching this file's own fixture convention (see BUG #04 above). + */ +describe('update — linkStyle option', () => { + const cwd = '/project' + const datasetSrc = `${cwd}/packages/knowledge-hub/dataset` + + function seedFs() { + return new InMemoryFileSystemService( + { + [`${cwd}/package.json`]: JSON.stringify({ name: 'test', version: '0.1.0' }), + [`${cwd}/packages/knowledge-hub/package.json`]: JSON.stringify({ + name: '@pair/knowledge-hub', + }), + [`${cwd}/config.json`]: JSON.stringify({ + asset_registries: { + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + description: 'KB', + }, + }, + }), + [`${datasetSrc}/.pair/knowledge/guide.md`]: '# New Guide', + // Pre-existing target — simulates a project already installed, without + // chaining a real install call. + [`${cwd}/.pair/knowledge/guide.md`]: '# Old Guide', + }, + cwd, + cwd, + ) + } + + test('updates successfully with linkStyle: absolute', async () => { + const fs = seedFs() + const httpClient = new MockHttpClientService() + + await handleUpdateCommand( + { command: 'update', resolution: 'default', kb: true, offline: false }, + fs, + { httpClient, linkStyle: 'absolute' }, + ) + + expect(await fs.readFile(`${cwd}/.pair/knowledge/guide.md`)).toBe('# New Guide') + }) + + test('updates successfully with linkStyle: auto', async () => { + const fs = seedFs() + const httpClient = new MockHttpClientService() + + await handleUpdateCommand( + { command: 'update', resolution: 'default', kb: true, offline: false }, + fs, + { httpClient, linkStyle: 'auto' }, + ) + + expect(await fs.readFile(`${cwd}/.pair/knowledge/guide.md`)).toBe('# New Guide') + }) +}) + +/** + * Moved from cli-packaging.e2e.test.ts (#199 test reorg) — 'update distributes skills + * with flatten + prefix to canonical target' was a redundant duplicate of the #238 AC4 + * test above and was deleted; only the secondary (symlink) target assertion below was a + * genuine gap. + */ +describe('update — skills registry secondary (symlink) targets', () => { + test('creates symlinks for secondary skills targets', async () => { + const cwd = '/test-skills-symlink' + const datasetBase = `${cwd}/dataset` + const seed: Record = { + // Pre-existing target for a different registry — satisfies the "already installed" + // precondition without pre-seeding the skills target itself. + [`${cwd}/.pair/knowledge/old.md`]: '# old', + [`${datasetBase}/.pair/knowledge/index.md`]: '# Knowledge Base', + [`${datasetBase}/.skills/next/SKILL.md`]: + '---\nname: next\ndescription: Project navigator\n---\n# /next', + [`${cwd}/config.json`]: JSON.stringify({ + asset_registries: { + knowledge: { + source: '.pair/knowledge', + behavior: 'mirror', + description: 'Knowledge base content', + targets: [{ path: '.pair/knowledge', mode: 'canonical' }], + }, + skills: { + source: '.skills', + behavior: 'mirror', + flatten: true, + prefix: 'pair', + description: 'Agent skills distributed to AI tool directories', + targets: [ + { path: '.claude/skills/', mode: 'canonical' }, + { path: '.github/skills/', mode: 'symlink' }, + { path: '.cursor/skills/', mode: 'symlink' }, + ], + }, + }, + }), + } + const fs = new InMemoryFileSystemService(seed, cwd, cwd) + + await handleUpdateCommand( + { command: 'update', resolution: 'local', path: datasetBase, offline: true, kb: true }, + fs, + ) + + const symlinks = fs.getSymlinks() + expect(symlinks.has(`${cwd}/.github/skills`)).toBe(true) + expect(symlinks.has(`${cwd}/.cursor/skills`)).toBe(true) + }) +}) + +/** + * Moved from cli-update.e2e.test.ts (#199 test reorg) — the former e2e tests had zero + * real assertions (only checked the call didn't throw). 'absolute path ZIP' is covered + * (with real assertions) by the 'uses installKBFromLocalZip for .zip local resolution' + * test above; 'relative path ZIP' and 'relative path directory' were genuinely uncovered + * anywhere, so they are added here with real assertions. + */ +describe('update — local source path styles (#199 reorg)', () => { + test('updates from a local .zip source given as a relative path', async () => { + const cwd = '/project' + const fs = new InMemoryFileSystemService( + { + [`${cwd}/package.json`]: JSON.stringify({ name: 'test', version: '0.1.0' }), + [`${cwd}/config.json`]: JSON.stringify({ + asset_registries: { + 'test-registry': { + source: 'test-registry', + behavior: 'mirror', + targets: [{ path: '.pair/test-registry', mode: 'canonical' }], + description: 'Test registry', + }, + }, + }), + // Pre-existing target — project already installed + [`${cwd}/.pair/test-registry/file1.md`]: '# Old Content', + }, + cwd, + cwd, + ) + + const kbInstaller = await import('#kb-manager/kb-installer') + const extractedPath = '/cached/unzipped-rel-update' + vi.spyOn(kbInstaller, 'installKBFromLocalZip').mockResolvedValue(extractedPath) + await fs.writeFile(`${extractedPath}/test-registry/file1.md`, '# New Content') + + const config: UpdateCommandConfig = { + command: 'update', + resolution: 'local', + path: './downloads/kb.zip', + kb: true, + offline: true, + } + + await handleUpdateCommand(config, fs) + + expect(await fs.readFile(`${cwd}/.pair/test-registry/file1.md`)).toBe('# New Content') + }) + + test('updates from a local directory source given as a relative path', async () => { + const cwd = '/project' + const dirPath = 'dataset' + const fs = new InMemoryFileSystemService( + { + [`${cwd}/package.json`]: JSON.stringify({ name: 'test', version: '0.1.0' }), + [`${cwd}/config.json`]: JSON.stringify({ + asset_registries: { + 'test-registry': { + source: 'test-registry', + behavior: 'mirror', + targets: [{ path: '.pair/test-registry', mode: 'canonical' }], + description: 'Test registry', + }, + }, + }), + // Dataset source (relative dir, resolved against CWD) — AGENTS.md marks it as a valid KB + [`${cwd}/${dirPath}/AGENTS.md`]: 'this is agents.md', + [`${cwd}/${dirPath}/test-registry/file1.md`]: '# New Content', + // Pre-existing target — project already installed + [`${cwd}/.pair/test-registry/file1.md`]: '# Old Content', + }, + cwd, + cwd, + ) + + const config: UpdateCommandConfig = { + command: 'update', + resolution: 'local', + path: `./${dirPath}`, + kb: true, + offline: true, + } + + await handleUpdateCommand(config, fs) + + expect(await fs.readFile(`${cwd}/.pair/test-registry/file1.md`)).toBe('# New Content') + }) +}) diff --git a/apps/pair-cli/src/registry/validation.test.ts b/apps/pair-cli/src/registry/validation.test.ts index 642961cb..32003d93 100644 --- a/apps/pair-cli/src/registry/validation.test.ts +++ b/apps/pair-cli/src/registry/validation.test.ts @@ -74,6 +74,23 @@ describe('registry validation - validateRegistry', () => { const errors = validateRegistry('test', config) expect(errors[0]).toContain('at least one target') }) + + it('fails when registry config is not an object', () => { + const errors = validateRegistry('test', 'invalid-registry') + expect(errors).toContain("Registry 'test' must be a valid object") + }) + + it('fails when include array contains non-string items', () => { + const config = { + source: '.pair', + behavior: 'mirror', + description: 'Test', + include: ['valid-string', 123], + targets: [{ path: '.pair', mode: 'canonical' }], + } + const errors = validateRegistry('test', config) + expect(errors).toContain("Registry 'test' include array must contain only strings") + }) }) describe('registry validation - detectOverlappingTargets', () => { diff --git a/apps/pair-cli/src/test-utils/cli-e2e-helpers.ts b/apps/pair-cli/src/test-utils/cli-e2e-helpers.ts deleted file mode 100644 index d6905c4e..00000000 --- a/apps/pair-cli/src/test-utils/cli-e2e-helpers.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { InMemoryFileSystemService } from '@pair/content-ops/test-utils/in-memory-fs' - -/** - * Shared fixtures and helpers for the pair-cli e2e suites (split per command - * from the former monolithic cli.e2e.test.ts). Each e2e file imports the - * fixtures it needs from here. - */ - -export function createNpmDeployFs(cwd: string): InMemoryFileSystemService { - // Simulate npm install: pair-cli extracted to node_modules/@foomakers/pair-cli/ - // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. - // We simulate the cached KB path so tests can resolve the dataset without network. - const seed: Record = {} - const moduleFolder = cwd + '/node_modules/@foomakers/pair-cli' - const kbCachePath = cwd + '/.pair-kb-cache/0.1.1' - - // Dataset content in the KB cache location (simulates auto-download result) - seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' - seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - - // Package.json for pair-cli (scoped package) — no bundle-cli/dataset - seed[moduleFolder + '/package.json'] = JSON.stringify({ - name: '@foomakers/pair-cli', - version: '0.1.1', - }) - - // Sample project package.json - seed[cwd + '/package.json'] = JSON.stringify({ - name: 'pair-sample-project', - version: '1.0.0', - dependencies: { - '@foomakers/pair-cli': 'file:../pkg/package', - }, - }) - - return new InMemoryFileSystemService(seed, moduleFolder, cwd) -} - -export function createManualDeployFs(cwd: string): InMemoryFileSystemService { - // Simulate manual installation: pair-cli binary standalone. - // Dataset is NOT bundled — it is auto-downloaded to a KB cache path at runtime. - const seed: Record = {} - const moduleFolder = cwd + '/libs/pair-cli' - const kbCachePath = cwd + '/.pair-kb-cache/0.1.0' - - // Add package.json for @pair/pair-cli package — no bundle-cli/dataset - seed[cwd + '/libs/pair-cli/package.json'] = JSON.stringify({ - name: '@pair/pair-cli', - version: '0.1.0', - description: 'Pair CLI manual installation', - }) - - // Dataset content in the KB cache location (simulates auto-download result) - seed[kbCachePath + '/dataset/AGENTS.md'] = 'this is agents.md' - seed[kbCachePath + '/dataset/.github/workflows/ci.yml'] = 'name: CI\non: push' - seed[kbCachePath + '/dataset/.pair/knowledge/index.md'] = '# Knowledge Base' - seed[kbCachePath + '/dataset/.pair/adoption/onboarding.md'] = '# Onboarding Guide' - - return new InMemoryFileSystemService(seed, moduleFolder, cwd) -} - -export function createDevScenarioFs(cwd: string): InMemoryFileSystemService { - // Simulate development scenario: pair-cli as regular node_modules dependency - // Dataset is at node_modules/@pair/knowledge-hub/dataset/ (accessible from project root) - const seed: Record = {} - - // Dataset content in the @pair/knowledge-hub package location (project's node_modules) - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/AGENTS.md'] = 'this is agents.md' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.github/workflows/ci.yml'] = - 'name: CI\non: push' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/knowledge/index.md'] = - '# Knowledge Base' - seed[cwd + '/node_modules/@pair/knowledge-hub/dataset/.pair/adoption/onboarding.md'] = - '# Onboarding Guide' - - // Package.json for @pair/knowledge-hub - seed[cwd + '/node_modules/@pair/knowledge-hub/package.json'] = JSON.stringify({ - name: '@pair/knowledge-hub', - version: '0.1.0', - }) - - // Package.json for pair-cli (regular dependency) - seed[cwd + '/node_modules/pair-cli/package.json'] = JSON.stringify({ - name: 'pair-cli', - version: '0.1.0', - }) - - // Sample project package.json - seed[cwd + '/package.json'] = JSON.stringify({ - name: 'pair-cli', - version: '1.0.0-wip', - dependencies: { - '@pair/knowledge-hub': 'catalog:*', - }, - }) - - return new InMemoryFileSystemService(seed, cwd, cwd) -} - -export async function withTempConfig( - fs: InMemoryFileSystemService, - config: unknown, - fn: () => Promise, -): Promise { - const configPath = fs.rootModuleDirectory() + '/config.json' - await fs.writeFile(configPath, JSON.stringify(config)) - try { - await fn() - } finally { - // Cleanup if needed - } -} - -export function createTestConfig() { - return { - asset_registries: { - '.github': { - source: '.github', - behavior: 'mirror', - targets: [{ path: '.github', mode: 'canonical' }], - description: 'GitHub workflows and configs', - }, - '.pair-knowledge': { - source: '.pair/knowledge', - behavior: 'mirror', - targets: [{ path: '.pair-knowledge', mode: 'canonical' }], - description: 'Knowledge base content', - }, - '.pair-adoption': { - source: '.pair/adoption', - behavior: 'mirror', - targets: [{ path: '.pair-adoption', mode: 'canonical' }], - description: 'Adoption and onboarding content', - }, - 'agents.md': { - source: 'AGENTS.md', - behavior: 'add', - targets: [{ path: 'AGENTS.md', mode: 'canonical' }], - description: 'AI agents guidance and session context', - }, - }, - } -} - -export function getDeploymentConfig(deployType: 'npm' | 'manual' | 'dev'): { - cwd: string - fs: InMemoryFileSystemService -} { - const cwd = - deployType === 'npm' - ? '/.tmp/npm-test/sample-project' - : deployType === 'manual' - ? '/tmp/test-project' - : '/dev/test-project' - const fs = - deployType === 'npm' - ? createNpmDeployFs(cwd) - : deployType === 'manual' - ? createManualDeployFs(cwd) - : createDevScenarioFs(cwd) - return { cwd, fs } -} From 8c36b867fab3e0b2fe112f1bff729d0abac1f705 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 16:39:58 +0200 Subject: [PATCH 18/21] [#199] docs: correct e2e-exemption checkable criteria to one-file-per-entry-point rule The PR #311 checkable-criteria amendment was satisfiable on paper without genuine cross-module flows, as proven by the pair-cli 7-file split it approved (only 1/51 tests were actually e2e). Replace it with the rule confirmed with the user: e2e defaults to one file per entry point; splitting requires the production code itself to be refactored into isolated modules with true unit tests, not just moved test files. Cites the #199 reorg as precedent. --- ...2026-07-08-test-file-colocation-multi-module.md | 7 ++++++- .../code-organization/file-structure.md | 14 +++++++++----- .../code-organization/file-structure.md | 14 +++++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md b/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md index 58ae5ab5..0123aecf 100644 --- a/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md +++ b/.pair/adoption/decision-log/2026-07-08-test-file-colocation-multi-module.md @@ -24,12 +24,14 @@ Applied to `idempotent-skill-registry.test.ts`: its `update`-focused cases (idem This does not apply to end-to-end/page-level tests (named after the user flow or page they exercise, e.g. `landing.e2e.test.ts`) or content/asset-validation tests (named after the asset they validate, e.g. `agents-md.test.ts`, which has no single source module to co-locate against) — both are pre-existing, distinct categories this decision leaves untouched. -**Amendment (2026-07-12, PR #311 review)**: the e2e exemption above was descriptive ("named after the user flow") but not mechanically checkable — a reviewer could not verify compliance without judgment. Made explicit with three checkable criteria, all of which must hold for a `*.e2e.test.ts` file to qualify: +**Amendment (2026-07-12, PR #311 review)** — SUPERSEDED, see the 2026-07-12 (#199) amendment below: the e2e exemption above was descriptive ("named after the user flow") but not mechanically checkable — a reviewer could not verify compliance without judgment. Made explicit with three checkable criteria, all of which must hold for a `*.e2e.test.ts` file to qualify: 1. **Named after a real flow**: the file name corresponds to an identifiable user-facing flow or CLI command, not an arbitrary grouping of convenience. 2. **Additive, not sole coverage**: every module/command the e2e file exercises also has its own co-located unit test file covering its non-integration behavior — the e2e file must be additive cross-cutting coverage, never the only coverage for a module. 3. **Genuine cross-module interaction**: the file exercises more than one command/module with a real interaction between them. An e2e-named file touching exactly one module with no cross-module flow is a signal it should be a regular unit test in that module's own test file instead, not evidence for the exemption. +**Amendment (2026-07-12, #199 test reorg) — corrects the amendment above**: the three-criteria version was applied to `apps/pair-cli`'s 7-file e2e split (`cli-errors`, `cli-install`, `cli-kb-validate`, `cli-link`, `cli-packaging`, `cli-update`, `cli-validate-config` — all `.e2e.test.ts`) and looked compliant on paper, but a per-test classification of all 51 tests across those 7 files found only 1 was genuinely e2e (a real hand-off of state between independently invoked commands: install → update → update-link against the same disjoint target). The other 50 exercised exactly one command's handler each, with no real cross-command interaction — single-module tests wearing e2e clothing. The criteria were too easy to satisfy without the underlying flows actually being cross-module. Corrected rule: **e2e tests default to one file per application entry point** (e.g. `cli.e2e.test.ts`); splitting is valid only when the production code has genuinely been refactored into isolated modules and the corresponding tests have become true unit tests (not staying e2e-shaped). The 7 files were consolidated back into a single `cli.e2e.test.ts` containing only the one genuinely cross-command test; duplicates of existing unit tests were deleted and the remaining genuine gaps were moved into the relevant `commands/*/handler.test.ts` / `registry/validation.test.ts` files. See `file-structure.md`'s Co-location Rules for the corrected rule text. + ## Alternatives Considered - **Keep standalone integration-test files, named after the scenario**: Rejected. Scales into a parallel "integration tests" file tree that duplicates or drifts from the sibling `handler.test.ts` files already covering the same root modules, and breaks the "one root module → one test file" discoverability the co-location rule exists for. @@ -44,3 +46,6 @@ This does not apply to end-to-end/page-level tests (named after the user flow or - `packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md` (+ root mirror `.pair/knowledge/guidelines/code-design/code-organization/file-structure.md`): Co-location Rules section extended with the multi-module case and its two exceptions. - `apps/pair-cli/src/commands/update/idempotent-skill-registry.test.ts`: deleted; its cases moved into `update/handler.test.ts` and `install/handler.test.ts`. +- **(#199 test reorg)** `packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md` (+ root mirror): e2e exemption section replaced with the corrected one-file-per-entry-point rule and the checkable "genuine cross-module hand-off" test. +- `apps/pair-cli/src/cli-errors.e2e.test.ts`, `cli-install.e2e.test.ts`, `cli-kb-validate.e2e.test.ts`, `cli-link.e2e.test.ts`, `cli-packaging.e2e.test.ts`, `cli-update.e2e.test.ts`, `cli-validate-config.e2e.test.ts`: deleted (51 tests); consolidated into a new `apps/pair-cli/src/cli.e2e.test.ts` (1 genuinely cross-command test kept), 15 genuine gaps moved into `commands/install/handler.test.ts`, `commands/update/handler.test.ts`, `commands/kb-validate/handler.test.ts`, `registry/validation.test.ts`, and 35 exact/near duplicates deleted. +- `apps/pair-cli/src/test-utils/cli-e2e-helpers.ts`: deleted (no remaining consumers after the 7 files above were removed). diff --git a/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md b/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md index 8cb1a48e..77021477 100644 --- a/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md +++ b/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md @@ -50,13 +50,17 @@ UserProfile.stories.tsx (if using Storybook) **Tests spanning multiple modules**: when a test exercises more than one implementation file together (e.g. an integration test driving two collaborating functions end-to-end), it does not get its own standalone file — it goes in the test file already co-located with the *root* module of that call chain, the one whose exported entry point the test is actually verifying. A test that seeds its fixture through module B but asserts on module A's behavior belongs in `A.test.ts`, not a new `A-and-b-integration.test.ts`. This keeps the co-location rule intact (one root module → one test file) instead of a proliferation of ad hoc integration-test files that duplicate coverage or drift out of sync with the modules they actually exercise. -This does not apply to two other, already-common test categories that are correctly named after what they validate rather than a source module: end-to-end/page-level tests (e.g. `landing.e2e.test.ts`, testing a user flow across many files by design) and content/asset-validation tests (e.g. asserting on a generated markdown file's content, where there is no single source module to co-locate against). +This does not apply to two other, already-common test categories that are correctly named after what they validate rather than a source module: end-to-end/page-level tests (e.g. `cli.e2e.test.ts`, testing a real flow across many files by design) and content/asset-validation tests (e.g. asserting on a generated markdown file's content, where there is no single source module to co-locate against). -The end-to-end exemption is checkable, not just descriptive — all three must hold for a given `*.e2e.test.ts` file, or it should be a regular co-located test instead: +**Amendment (2026-07-12, #199 test reorg)**: an earlier version of this rule set out three "checkable criteria" under which an application entry point's e2e coverage could be split into several `*.e2e.test.ts` files (one per command/flow). That split was applied to `apps/pair-cli` and looked compliant against the letter of those criteria, but a subsequent per-test classification of all 51 tests across the resulting 7 files found only 1 was genuinely e2e — depending on a real hand-off of state between independently invoked commands. The other 50 were single-module tests wearing e2e clothing: each exercised exactly one command's handler, with no real cross-command interaction, and would have been (and, per this reorg, now are) equally at home as regular tests in that handler's own co-located test file. The three-criteria version of the rule was too easy to satisfy on paper without the underlying flows actually being cross-module. -1. **Named after a real flow**: the file name corresponds to an identifiable user-facing flow or CLI command (e.g. `cli-install.e2e.test.ts` for the install flow), not an arbitrary grouping of convenience. -2. **Additive, not sole coverage**: every module/command the e2e file exercises also has its own co-located unit test file (e.g. `handler.test.ts`) covering its non-integration behavior. The e2e file is verified to be additive cross-cutting coverage, never the only coverage for a module — that would be the isolation-smell case. -3. **Genuine cross-module interaction**: the file exercises more than one command/module with a real interaction between them. If it exercises exactly one module with no cross-module flow, that's a signal it should have been a regular unit test in that module's own test file instead — the exemption is for genuine multi-module flows, not a generic escape hatch. +The rule is corrected to this, tighter form: + +- **e2e tests default to one file per application entry point** (e.g. `cli.e2e.test.ts` for `apps/pair-cli`, `app.e2e.test.ts` for a web app). Do not split it by module or command as a matter of course. +- **Splitting an entry point's e2e file is valid only when the production code itself has been genuinely refactored into isolated modules, and the corresponding tests for those modules have become true unit tests** (asserting a single module's behavior, not staying e2e-shaped with a full command/flow setup). If the production code was not restructured, the tests should not be either — that's a signal the "split" is just moving tests around, not truly isolating them. +- A test belongs in the one e2e file if it depends on real, observable hand-off of state between independently invoked units (e.g. command A's output becoming command B's input against the same target). If a test only exercises one command/module — even if named `*.e2e.test.ts` — it is a unit test for that module and belongs in its co-located `*.test.ts` file instead. + +Concrete precedent: `apps/pair-cli`'s prior 7-file e2e split (`cli-errors`, `cli-install`, `cli-kb-validate`, `cli-link`, `cli-packaging`, `cli-update`, `cli-validate-config` — all `.e2e.test.ts`) was consolidated back into a single `cli.e2e.test.ts` containing only the one test with a genuine cross-command dependency (install → update → update-link against the same disjoint target). Duplicates of existing unit tests were deleted; the remaining genuine coverage gaps were moved into the relevant handler/module `*.test.ts` files (e.g. `commands/install/handler.test.ts`, `commands/update/handler.test.ts`, `registry/validation.test.ts`). **Types**: Co-locate types when feature-specific: ``` diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md index 8cb1a48e..77021477 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/code-organization/file-structure.md @@ -50,13 +50,17 @@ UserProfile.stories.tsx (if using Storybook) **Tests spanning multiple modules**: when a test exercises more than one implementation file together (e.g. an integration test driving two collaborating functions end-to-end), it does not get its own standalone file — it goes in the test file already co-located with the *root* module of that call chain, the one whose exported entry point the test is actually verifying. A test that seeds its fixture through module B but asserts on module A's behavior belongs in `A.test.ts`, not a new `A-and-b-integration.test.ts`. This keeps the co-location rule intact (one root module → one test file) instead of a proliferation of ad hoc integration-test files that duplicate coverage or drift out of sync with the modules they actually exercise. -This does not apply to two other, already-common test categories that are correctly named after what they validate rather than a source module: end-to-end/page-level tests (e.g. `landing.e2e.test.ts`, testing a user flow across many files by design) and content/asset-validation tests (e.g. asserting on a generated markdown file's content, where there is no single source module to co-locate against). +This does not apply to two other, already-common test categories that are correctly named after what they validate rather than a source module: end-to-end/page-level tests (e.g. `cli.e2e.test.ts`, testing a real flow across many files by design) and content/asset-validation tests (e.g. asserting on a generated markdown file's content, where there is no single source module to co-locate against). -The end-to-end exemption is checkable, not just descriptive — all three must hold for a given `*.e2e.test.ts` file, or it should be a regular co-located test instead: +**Amendment (2026-07-12, #199 test reorg)**: an earlier version of this rule set out three "checkable criteria" under which an application entry point's e2e coverage could be split into several `*.e2e.test.ts` files (one per command/flow). That split was applied to `apps/pair-cli` and looked compliant against the letter of those criteria, but a subsequent per-test classification of all 51 tests across the resulting 7 files found only 1 was genuinely e2e — depending on a real hand-off of state between independently invoked commands. The other 50 were single-module tests wearing e2e clothing: each exercised exactly one command's handler, with no real cross-command interaction, and would have been (and, per this reorg, now are) equally at home as regular tests in that handler's own co-located test file. The three-criteria version of the rule was too easy to satisfy on paper without the underlying flows actually being cross-module. -1. **Named after a real flow**: the file name corresponds to an identifiable user-facing flow or CLI command (e.g. `cli-install.e2e.test.ts` for the install flow), not an arbitrary grouping of convenience. -2. **Additive, not sole coverage**: every module/command the e2e file exercises also has its own co-located unit test file (e.g. `handler.test.ts`) covering its non-integration behavior. The e2e file is verified to be additive cross-cutting coverage, never the only coverage for a module — that would be the isolation-smell case. -3. **Genuine cross-module interaction**: the file exercises more than one command/module with a real interaction between them. If it exercises exactly one module with no cross-module flow, that's a signal it should have been a regular unit test in that module's own test file instead — the exemption is for genuine multi-module flows, not a generic escape hatch. +The rule is corrected to this, tighter form: + +- **e2e tests default to one file per application entry point** (e.g. `cli.e2e.test.ts` for `apps/pair-cli`, `app.e2e.test.ts` for a web app). Do not split it by module or command as a matter of course. +- **Splitting an entry point's e2e file is valid only when the production code itself has been genuinely refactored into isolated modules, and the corresponding tests for those modules have become true unit tests** (asserting a single module's behavior, not staying e2e-shaped with a full command/flow setup). If the production code was not restructured, the tests should not be either — that's a signal the "split" is just moving tests around, not truly isolating them. +- A test belongs in the one e2e file if it depends on real, observable hand-off of state between independently invoked units (e.g. command A's output becoming command B's input against the same target). If a test only exercises one command/module — even if named `*.e2e.test.ts` — it is a unit test for that module and belongs in its co-located `*.test.ts` file instead. + +Concrete precedent: `apps/pair-cli`'s prior 7-file e2e split (`cli-errors`, `cli-install`, `cli-kb-validate`, `cli-link`, `cli-packaging`, `cli-update`, `cli-validate-config` — all `.e2e.test.ts`) was consolidated back into a single `cli.e2e.test.ts` containing only the one test with a genuine cross-command dependency (install → update → update-link against the same disjoint target). Duplicates of existing unit tests were deleted; the remaining genuine coverage gaps were moved into the relevant handler/module `*.test.ts` files (e.g. `commands/install/handler.test.ts`, `commands/update/handler.test.ts`, `registry/validation.test.ts`). **Types**: Co-locate types when feature-specific: ``` From 77576e501f624e549f4702309b39d125f219b53e Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 16:51:57 +0200 Subject: [PATCH 19/21] [#199] docs: cross-link e2e file-organization rule from e2e-testing guideline The strategy-level e2e-testing guideline already said e2e should focus on critical paths, not every scenario, but didn't point to the concrete file-organization consequence (one file per entry point, split only with genuine module isolation) landed by this story's test reorg. Co-Authored-By: Claude Sonnet 5 --- .pair/knowledge/guidelines/testing/e2e-testing/README.md | 2 ++ .../.pair/knowledge/guidelines/testing/e2e-testing/README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.pair/knowledge/guidelines/testing/e2e-testing/README.md b/.pair/knowledge/guidelines/testing/e2e-testing/README.md index b549c257..36b4e696 100644 --- a/.pair/knowledge/guidelines/testing/e2e-testing/README.md +++ b/.pair/knowledge/guidelines/testing/e2e-testing/README.md @@ -30,6 +30,8 @@ E2E tests are the most comprehensive but also the most expensive to maintain. Th Effective E2E testing requires careful test design, robust test infrastructure, and clear understanding of user behavior patterns. These tests serve as living documentation of how the system should behave from a user's perspective. +**File organization**: a large e2e file, or many of them, is a signal — not a target. Genuine e2e tests depend on a real hand-off between independently-invoked components; anything that only exercises one component belongs in that component's own unit tests. See [file-structure.md](../../code-design/code-organization/file-structure.md)'s co-location rules for the file-per-entry-point default and when splitting is actually justified. + ## Framework Comparison | Framework | Best For | Learning Curve | Debugging | Ecosystem | diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/testing/e2e-testing/README.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/testing/e2e-testing/README.md index b549c257..36b4e696 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/testing/e2e-testing/README.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/testing/e2e-testing/README.md @@ -30,6 +30,8 @@ E2E tests are the most comprehensive but also the most expensive to maintain. Th Effective E2E testing requires careful test design, robust test infrastructure, and clear understanding of user behavior patterns. These tests serve as living documentation of how the system should behave from a user's perspective. +**File organization**: a large e2e file, or many of them, is a signal — not a target. Genuine e2e tests depend on a real hand-off between independently-invoked components; anything that only exercises one component belongs in that component's own unit tests. See [file-structure.md](../../code-design/code-organization/file-structure.md)'s co-location rules for the file-per-entry-point default and when splitting is actually justified. + ## Framework Comparison | Framework | Best For | Learning Curve | Debugging | Ecosystem | From a47493efa7e4b87eb53cb1f881749b26c4e79439 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 18:12:47 +0200 Subject: [PATCH 20/21] [#199] test: distribute copy/ tests to per-module files + drop dead boom sentinel (PR #311 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split copyPathOps.test.ts (5 describes, 1 file) into per-production-file tests: copy-file.test.ts, copy-directory.test.ts, copy-directory-transforms.test.ts; copyPathOps.test.ts keeps only its own dispatch-level concerns (absolute-path validation, source-existence check). Reuses existing createTestFileService helper to simplify repeated fs setup in the transforms file. Also removes the dead err.message.includes('boom') test-sentinel from copy-file.ts and copy-directory.ts — no test references it anymore, so the catch blocks now always wrap via createError. --- .../copy/copy-directory-transforms.test.ts | 426 ++++++++++++ .../src/ops/copy/copy-directory.test.ts | 28 + .../src/ops/copy/copy-directory.ts | 3 - .../src/ops/copy/copy-file.test.ts | 107 +++ .../content-ops/src/ops/copy/copy-file.ts | 4 - .../src/ops/copy/copyPathOps.test.ts | 628 +----------------- 6 files changed, 567 insertions(+), 629 deletions(-) create mode 100644 packages/content-ops/src/ops/copy/copy-directory-transforms.test.ts create mode 100644 packages/content-ops/src/ops/copy/copy-directory.test.ts create mode 100644 packages/content-ops/src/ops/copy/copy-file.test.ts diff --git a/packages/content-ops/src/ops/copy/copy-directory-transforms.test.ts b/packages/content-ops/src/ops/copy/copy-directory-transforms.test.ts new file mode 100644 index 00000000..fda7c5fd --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-directory-transforms.test.ts @@ -0,0 +1,426 @@ +import { describe, it, expect } from 'vitest' +import { copyPathOps } from './copyPathOps' +import { TEST_ASSERTIONS, createTestFileService } from '../../test-utils' + +describe('copyDirectoryWithTransforms (via copyPathOps, flatten/prefix)', () => { + it('should flatten directory hierarchy into hyphen-separated names', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + '/dataset/source/process/implement/SKILL.md': '# Implement Skill', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/catalog-next/SKILL.md', + '# Next Skill', + ) + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/process-implement/SKILL.md', + '# Implement Skill', + ) + }) + + it('should apply prefix to top-level directory names', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/SKILL.md': '# Catalog Skill', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: false, prefix: 'pair', targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/pair-catalog/SKILL.md', + '# Catalog Skill', + ) + }) + + it('should apply both flatten and prefix', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/pair-catalog-next/SKILL.md', + '# Next Skill', + ) + }) + + it('should apply prefix only without flatten (prefix top-level, keep hierarchy)', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: false, prefix: 'pair', targets: [] }, + }) + + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/target/pair-catalog/next/SKILL.md', + '# Next Skill', + ) + }) + + it('should rewrite relative links after flatten+prefix copy (full pipeline)', async () => { + // File at source/catalog/next/ (depth 3) links up 3 levels to reach dataset root + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': + '# Next\n[guide](../../../.pair/knowledge/testing/README.md)', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + // After flatten+prefix: source/catalog/next/ → target/pair-catalog-next/ + // Original: ../../../ from source/catalog/next/ → /dataset/.pair/knowledge/testing/README.md + // New location target/pair-catalog-next/ (depth 2): ../../.pair/knowledge/testing/README.md + const content = await fileService.readFile('/dataset/target/pair-catalog-next/SKILL.md') + expect(content).toContain('../../.pair/knowledge/testing/README.md') + }) + + it('should re-root links when source content root differs from datasetRoot', async () => { + // Simulates real pipeline: source deep in monorepo, target at project root + // source = packages/kb/dataset/.skills → content root = packages/kb/dataset/ + // target = .claude/skills → content root = project root + const fileService = createTestFileService({ + '/project/packages/kb/dataset/.skills/next/SKILL.md': + '# Next\n[PRD](../../.pair/adoption/PRD.md)', + }) + + await copyPathOps({ + fileService, + source: 'packages/kb/dataset/.skills', + target: '.claude/skills', + datasetRoot: '/project', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + const content = await fileService.readFile('/project/.claude/skills/pair-next/SKILL.md') + // Link should point to .pair/ at project root, NOT to packages/kb/dataset/.pair/ + expect(content).toContain('../../../.pair/adoption/PRD.md') + expect(content).not.toContain('packages/kb/dataset') + }) + + it('should sync frontmatter name after flatten+prefix rename', async () => { + const skillContent = [ + '---', + 'name: record-decision', + 'description: >-', + ' Records an architectural', + ' or non-architectural decision.', + '---', + '', + '# /record-decision', + ].join('\n') + + const fileService = createTestFileService({ + '/dataset/source/capability/record-decision/SKILL.md': skillContent, + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + const result = await fileService.readFile( + '/dataset/target/pair-capability-record-decision/SKILL.md', + ) + // name synced to match new directory name + expect(result).toContain('name: pair-capability-record-decision') + // multiline collapsed + expect(result).toContain('description: Records an architectural or non-architectural decision.') + expect(result).not.toContain('>-') + // body skill references rewritten + expect(result).toContain('# /pair-capability-record-decision') + }) + + it('should sync all frontmatter values referencing old dir name, not just name', async () => { + const skillContent = [ + '---', + 'name: my-skill', + 'config: my-skill/defaults.yaml', + '---', + '', + '# Body', + ].join('\n') + + const fileService = createTestFileService({ + '/dataset/source/category/my-skill/SKILL.md': skillContent, + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'px', targets: [] }, + }) + + const result = await fileService.readFile('/dataset/target/px-category-my-skill/SKILL.md') + expect(result).toContain('name: px-category-my-skill') + expect(result).toContain('config: px-category-my-skill/defaults.yaml') + }) + + it('should rewrite skill cross-references after flatten+prefix copy', async () => { + const implementContent = [ + '---', + 'name: implement', + 'description: >-', + ' Composes /verify-quality and', + ' /record-decision.', + '---', + '', + '# /implement', + '', + '| `/verify-quality` | Capability |', + '| `/record-decision` | Capability |', + 'invoke /assess-stack if needed', + ].join('\n') + + const verifyContent = [ + '---', + 'name: verify-quality', + 'description: Quality checker.', + '---', + '', + '# /verify-quality', + 'Composed by /implement and /review.', + ].join('\n') + + const fileService = createTestFileService({ + '/dataset/source/process/implement/SKILL.md': implementContent, + '/dataset/source/capability/verify-quality/SKILL.md': verifyContent, + '/dataset/source/capability/record-decision/SKILL.md': + '---\nname: record-decision\n---\n# /record-decision', + '/dataset/source/capability/assess-stack/SKILL.md': + '---\nname: assess-stack\n---\n# /assess-stack', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + const impl = await fileService.readFile('/dataset/target/pair-process-implement/SKILL.md') + // frontmatter name synced + expect(impl).toContain('name: pair-process-implement') + // body references rewritten + expect(impl).toContain('`/pair-capability-verify-quality`') + expect(impl).toContain('`/pair-capability-record-decision`') + expect(impl).toContain('/pair-capability-assess-stack') + // frontmatter description also rewritten + expect(impl).toContain('/pair-capability-verify-quality and') + + const verify = await fileService.readFile( + '/dataset/target/pair-capability-verify-quality/SKILL.md', + ) + expect(verify).toContain('/pair-process-implement') + }) + + it('should return skillNameMap from flatten+prefix copy', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + '/dataset/source/process/implement/SKILL.md': '---\nname: implement\n---\n# /implement', + }) + + const result = await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', targets: [] }, + }) + + expect(result.skillNameMap).toBeDefined() + expect(result.skillNameMap!.get('next')).toBe('pair-catalog-next') + expect(result.skillNameMap!.get('implement')).toBe('pair-process-implement') + }) + + it('should apply external skillNameMap to file copy', async () => { + const agentsContent = [ + '# AGENTS', + '```', + '/next', + '```', + 'Run `/next` to get started.', + 'Then `/implement` your task.', + ].join('\n') + + const fileService = createTestFileService({ + '/project/src/AGENTS.md': agentsContent, + }) + + const skillNameMap = new Map([ + ['next', 'pair-next'], + ['implement', 'pair-process-implement'], + ]) + + await copyPathOps({ + fileService, + source: 'src/AGENTS.md', + target: 'dist/AGENTS.md', + datasetRoot: '/project', + skillNameMap, + }) + + const result = await fileService.readFile('/project/dist/AGENTS.md') + expect(result).toContain('/pair-next') + expect(result).toContain('`/pair-next`') + expect(result).toContain('/pair-process-implement') + expect(result).not.toContain(' /next') + expect(result).not.toContain('/implement') + }) + + it('should detect and throw on flatten collisions', async () => { + const fileService = createTestFileService({ + '/dataset/source/a/b/SKILL.md': '# Skill 1', + '/dataset/source/a-b/SKILL.md': '# Skill 2', + }) + + await expect( + copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, targets: [] }, + }), + ).rejects.toThrow(/collision/i) + }) + + describe('mirror behavior — idempotent updates (AC4)', () => { + it('removes a stale flattened directory when its source skill is gone', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + // Stale leftover from a previous run — no longer present under source + '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, + }) + + await expect( + fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), + ).resolves.toBe(false) + await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( + true, + ) + }) + + it('removes the old prefixed directory after a prefix change', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + // Leftover from a previous install with prefix "pair" + '/dataset/target/pair-catalog-next/SKILL.md': '---\nname: pair-catalog-next\n---', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'foo', defaultBehavior: 'mirror', targets: [] }, + }) + + await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( + false, + ) + await expect(fileService.exists('/dataset/target/foo-catalog-next/SKILL.md')).resolves.toBe( + true, + ) + }) + + it('does not delete a root-level (non-nested) source file on a second mirror run', async () => { + // Regression: cleanupStaleTransformedEntries built its "expected" set only from + // dirMappingFiles, which copyFileWithTransform only populates for files under a + // subdirectory (dir !== '.'). A file copied directly from the source root was never + // registered as expected, so a second mirror run would delete it as "stale". + const fileService = createTestFileService({ + '/dataset/source/README.md': '# Root-level file, no subdirectory', + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + }) + + const runOnce = () => + copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, + }) + + await runOnce() + await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) + + // Second run must be idempotent — the root-level file must survive. + await runOnce() + await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) + await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( + true, + ) + }) + + it('does not clean up stale entries when behavior is not mirror', async () => { + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', + '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', + }) + + await copyPathOps({ + fileService, + source: 'source', + target: 'target', + datasetRoot: '/dataset', + options: { flatten: true, prefix: 'pair', defaultBehavior: 'overwrite', targets: [] }, + }) + + await expect( + fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), + ).resolves.toBe(true) + }) + }) +}) diff --git a/packages/content-ops/src/ops/copy/copy-directory.test.ts b/packages/content-ops/src/ops/copy/copy-directory.test.ts new file mode 100644 index 00000000..ec34703d --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-directory.test.ts @@ -0,0 +1,28 @@ +import { describe, it, beforeEach } from 'vitest' +import { copyPathOps } from './copyPathOps' +import { TEST_SETUP, TEST_ASSERTIONS, InMemoryFileSystemService } from '../../test-utils' + +describe('handleDirectoryCopy (via copyPathOps, no naming transforms)', () => { + let fileService: InMemoryFileSystemService + + beforeEach(() => { + fileService = TEST_SETUP.createBasicSetup() + }) + + it('should copy a directory and update links', async () => { + fileService = TEST_SETUP.createDirectorySetup() + const result = await copyPathOps({ + fileService, + source: 'folder', + target: 'copied-folder', + datasetRoot: '/dataset', + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/copied-folder/file1.md', + '# File 1', + ) + }) +}) diff --git a/packages/content-ops/src/ops/copy/copy-directory.ts b/packages/content-ops/src/ops/copy/copy-directory.ts index a5865d06..7e178aec 100644 --- a/packages/content-ops/src/ops/copy/copy-directory.ts +++ b/packages/content-ops/src/ops/copy/copy-directory.ts @@ -205,9 +205,6 @@ async function copyDirectoryContents(params: { await copyDirHelper(copyContext) } catch (err) { logger.error(`Failed to copy entries: ${String(err)}`) - if (err instanceof Error && err.message.includes('boom')) { - throw err - } throw createError({ type: 'IO_ERROR', message: `Failed to copy directory contents from ${srcPath} to ${destPath}`, diff --git a/packages/content-ops/src/ops/copy/copy-file.test.ts b/packages/content-ops/src/ops/copy/copy-file.test.ts new file mode 100644 index 00000000..6ae8dd3f --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-file.test.ts @@ -0,0 +1,107 @@ +import { describe, it, beforeEach } from 'vitest' +import { copyPathOps } from './copyPathOps' +import { + TEST_SETUP, + TEST_ASSERTIONS, + TEST_FILE_STRUCTURES, + InMemoryFileSystemService, +} from '../../test-utils' + +describe('handleFileCopy (via copyPathOps)', () => { + let fileService: InMemoryFileSystemService + + beforeEach(() => { + fileService = TEST_SETUP.createBasicSetup() + }) + + it('should copy a file and update links', async () => { + const result = await copyPathOps({ + fileService, + source: 'source.md', + target: 'copied.md', + datasetRoot: '/dataset', + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileExists( + fileService, + '/dataset/copied.md', + '# Source File\n[link](target.md)', + ) + }) + + it('should update links in other files when copying', async () => { + const result = await copyPathOps({ + fileService, + source: 'source.md', + target: 'copied.md', + datasetRoot: '/dataset', + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileContains(fileService, '/dataset/other.md', '[link](copied.md)') + }) + + it('should respect behavior options (add — skip existing target)', async () => { + fileService = new InMemoryFileSystemService(TEST_FILE_STRUCTURES.existingTarget, '/', '/') + + const result = await copyPathOps({ + fileService, + source: 'source.md', + target: 'target.md', + datasetRoot: '/dataset', + options: { + defaultBehavior: 'add', + flatten: false, + targets: [], + }, + }) + + TEST_ASSERTIONS.assertSuccessfulOperation(result) + await TEST_ASSERTIONS.assertFileExists(fileService, '/dataset/target.md', '# Existing Target') + }) + + it('should copy using the provided example parameters and overwrite existing root file', async () => { + const fs = new InMemoryFileSystemService( + { + '/development/path/pair/apps/pair-cli/config.json': + '{"asset_registries":{"agents":{"source":"AGENTS.md","behavior":"overwrite"}}}', + '/development/path/pair/apps/pair-cli/dataset/AGENTS.md': 'agents content', + }, + '/development/path/pair/apps/pair-cli', + '/development/path/pair/apps/pair-cli', + ) + + const options = { + fileService: fs, + source: 'dataset/AGENTS.md', + target: 'AGENTS.md', + datasetRoot: '/development/path/pair/apps/pair-cli', + options: { + defaultBehavior: 'overwrite' as const, + folderBehavior: undefined, + flatten: false, + targets: [], + }, + } + + const result = await copyPathOps(options) + TEST_ASSERTIONS.assertSuccessfulOperation(result) + + // Verify the dataset file was present + await TEST_ASSERTIONS.assertFileExists( + fs, + '/development/path/pair/apps/pair-cli/dataset/AGENTS.md', + 'agents content', + ) + + // In the provided in-memory FS the previous tests showed the file ended up at + // '/development/path/pair/apps/pair-cli/AGENTS.md/AGENTS.md' so assert both + // the top-level and nested target to be safe. + await TEST_ASSERTIONS.assertFileExists( + fs, + '/development/path/pair/apps/pair-cli/AGENTS.md', + 'agents content', + ) + }) +}) diff --git a/packages/content-ops/src/ops/copy/copy-file.ts b/packages/content-ops/src/ops/copy/copy-file.ts index 808a327b..8cf20520 100644 --- a/packages/content-ops/src/ops/copy/copy-file.ts +++ b/packages/content-ops/src/ops/copy/copy-file.ts @@ -42,10 +42,6 @@ export async function handleFileCopy(params: HandleFileCopyParams) { await copyFileHelper(fileService, srcPath, finalDest, defaultBehavior) } catch (err) { logger.error(`Failed to copy file ${srcPath} -> ${finalDest}: ${String(err)}`) - // If the original error message is specific (like test errors), preserve it - if (err instanceof Error && err.message.includes('boom')) { - throw err - } throw createError({ type: 'IO_ERROR', message: `Failed to copy file ${srcPath} -> ${finalDest}`, diff --git a/packages/content-ops/src/ops/copy/copyPathOps.test.ts b/packages/content-ops/src/ops/copy/copyPathOps.test.ts index 47ca0b75..91f361a6 100644 --- a/packages/content-ops/src/ops/copy/copyPathOps.test.ts +++ b/packages/content-ops/src/ops/copy/copyPathOps.test.ts @@ -1,35 +1,18 @@ import { describe, it, expect, beforeEach } from 'vitest' import { copyPathOps } from './copyPathOps' -import { - TEST_SETUP, - TEST_ASSERTIONS, - TEST_FILE_STRUCTURES, - InMemoryFileSystemService, -} from '../../test-utils' +import { TEST_SETUP, InMemoryFileSystemService } from '../../test-utils' -describe('copyPathOps', () => { +// copyPathOps' own dispatch-level concerns: top-level path validation and +// source-existence checks that happen before it routes to handleFileCopy / +// handleDirectoryCopy / copyDirectoryWithTransforms. Behavior owned by those +// sub-modules is tested in their own co-located test files. +describe('copyPathOps - dispatch', () => { let fileService: InMemoryFileSystemService beforeEach(() => { fileService = TEST_SETUP.createBasicSetup() }) - it('should copy a file and update links', async () => { - const result = await copyPathOps({ - fileService, - source: 'source.md', - target: 'copied.md', - datasetRoot: '/dataset', - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/copied.md', - '# Source File\n[link](target.md)', - ) - }) - it('should throw INVALID_PATH error for absolute source and target paths', async () => { await expect( copyPathOps({ @@ -41,586 +24,6 @@ describe('copyPathOps', () => { ).rejects.toThrow('Source and target paths must be relative, not absolute') }) - it('should update links in other files when copying', async () => { - const result = await copyPathOps({ - fileService, - source: 'source.md', - target: 'copied.md', - datasetRoot: '/dataset', - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileContains(fileService, '/dataset/other.md', '[link](copied.md)') - }) -}) - -describe('copyPathOps - root file operations', () => { - beforeEach(() => {}) - - it('should copy using the provided example parameters and overwrite existing root file', async () => { - const fs = new InMemoryFileSystemService( - { - '/development/path/pair/apps/pair-cli/config.json': - '{"asset_registries":{"agents":{"source":"AGENTS.md","behavior":"overwrite"}}}', - '/development/path/pair/apps/pair-cli/dataset/AGENTS.md': 'agents content', - }, - '/development/path/pair/apps/pair-cli', - '/development/path/pair/apps/pair-cli', - ) - - const options = { - fileService: fs, - source: 'dataset/AGENTS.md', - target: 'AGENTS.md', - datasetRoot: '/development/path/pair/apps/pair-cli', - options: { - defaultBehavior: 'overwrite' as const, - folderBehavior: undefined, - flatten: false, - targets: [], - }, - } - - const result = await copyPathOps(options) - TEST_ASSERTIONS.assertSuccessfulOperation(result) - - // Verify the dataset file was present - await TEST_ASSERTIONS.assertFileExists( - fs, - '/development/path/pair/apps/pair-cli/dataset/AGENTS.md', - 'agents content', - ) - - // In the provided in-memory FS the previous tests showed the file ended up at - // '/development/path/pair/apps/pair-cli/AGENTS.md/AGENTS.md' so assert both - // the top-level and nested target to be safe. - await TEST_ASSERTIONS.assertFileExists( - fs, - '/development/path/pair/apps/pair-cli/AGENTS.md', - 'agents content', - ) - }) -}) - -describe('copyPathOps - directory operations', () => { - let fileService: InMemoryFileSystemService - - beforeEach(() => { - fileService = TEST_SETUP.createBasicSetup() - }) - - it('should copy a directory and update links', async () => { - fileService = TEST_SETUP.createDirectorySetup() - const result = await copyPathOps({ - fileService, - source: 'folder', - target: 'copied-folder', - datasetRoot: '/dataset', - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/copied-folder/file1.md', - '# File 1', - ) - }) -}) - -describe('copyPathOps - flatten and prefix', () => { - it('should flatten directory hierarchy into hyphen-separated names', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '# Next Skill', - '/dataset/source/process/implement/SKILL.md': '# Implement Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/catalog-next/SKILL.md', - '# Next Skill', - ) - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/process-implement/SKILL.md', - '# Implement Skill', - ) - }) - - it('should apply prefix to top-level directory names', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/SKILL.md': '# Catalog Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: false, prefix: 'pair', targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/pair-catalog/SKILL.md', - '# Catalog Skill', - ) - }) - - it('should apply both flatten and prefix', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '# Next Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/pair-catalog-next/SKILL.md', - '# Next Skill', - ) - }) - - it('should apply prefix only without flatten (prefix top-level, keep hierarchy)', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '# Next Skill', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: false, prefix: 'pair', targets: [] }, - }) - - await TEST_ASSERTIONS.assertFileExists( - fileService, - '/dataset/target/pair-catalog/next/SKILL.md', - '# Next Skill', - ) - }) - - it('should rewrite relative links after flatten+prefix copy (full pipeline)', async () => { - // File at source/catalog/next/ (depth 3) links up 3 levels to reach dataset root - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': - '# Next\n[guide](../../../.pair/knowledge/testing/README.md)', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - // After flatten+prefix: source/catalog/next/ → target/pair-catalog-next/ - // Original: ../../../ from source/catalog/next/ → /dataset/.pair/knowledge/testing/README.md - // New location target/pair-catalog-next/ (depth 2): ../../.pair/knowledge/testing/README.md - const content = await fileService.readFile('/dataset/target/pair-catalog-next/SKILL.md') - expect(content).toContain('../../.pair/knowledge/testing/README.md') - }) - - it('should re-root links when source content root differs from datasetRoot', async () => { - // Simulates real pipeline: source deep in monorepo, target at project root - // source = packages/kb/dataset/.skills → content root = packages/kb/dataset/ - // target = .claude/skills → content root = project root - const fileService = new InMemoryFileSystemService( - { - '/project/packages/kb/dataset/.skills/next/SKILL.md': - '# Next\n[PRD](../../.pair/adoption/PRD.md)', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'packages/kb/dataset/.skills', - target: '.claude/skills', - datasetRoot: '/project', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - const content = await fileService.readFile('/project/.claude/skills/pair-next/SKILL.md') - // Link should point to .pair/ at project root, NOT to packages/kb/dataset/.pair/ - expect(content).toContain('../../../.pair/adoption/PRD.md') - expect(content).not.toContain('packages/kb/dataset') - }) - - it('should sync frontmatter name after flatten+prefix rename', async () => { - const skillContent = [ - '---', - 'name: record-decision', - 'description: >-', - ' Records an architectural', - ' or non-architectural decision.', - '---', - '', - '# /record-decision', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/capability/record-decision/SKILL.md': skillContent, - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - const result = await fileService.readFile( - '/dataset/target/pair-capability-record-decision/SKILL.md', - ) - // name synced to match new directory name - expect(result).toContain('name: pair-capability-record-decision') - // multiline collapsed - expect(result).toContain('description: Records an architectural or non-architectural decision.') - expect(result).not.toContain('>-') - // body skill references rewritten - expect(result).toContain('# /pair-capability-record-decision') - }) - - it('should sync all frontmatter values referencing old dir name, not just name', async () => { - const skillContent = [ - '---', - 'name: my-skill', - 'config: my-skill/defaults.yaml', - '---', - '', - '# Body', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/category/my-skill/SKILL.md': skillContent, - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'px', targets: [] }, - }) - - const result = await fileService.readFile('/dataset/target/px-category-my-skill/SKILL.md') - expect(result).toContain('name: px-category-my-skill') - expect(result).toContain('config: px-category-my-skill/defaults.yaml') - }) - - it('should rewrite skill cross-references after flatten+prefix copy', async () => { - const implementContent = [ - '---', - 'name: implement', - 'description: >-', - ' Composes /verify-quality and', - ' /record-decision.', - '---', - '', - '# /implement', - '', - '| `/verify-quality` | Capability |', - '| `/record-decision` | Capability |', - 'invoke /assess-stack if needed', - ].join('\n') - - const verifyContent = [ - '---', - 'name: verify-quality', - 'description: Quality checker.', - '---', - '', - '# /verify-quality', - 'Composed by /implement and /review.', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/process/implement/SKILL.md': implementContent, - '/dataset/source/capability/verify-quality/SKILL.md': verifyContent, - '/dataset/source/capability/record-decision/SKILL.md': - '---\nname: record-decision\n---\n# /record-decision', - '/dataset/source/capability/assess-stack/SKILL.md': - '---\nname: assess-stack\n---\n# /assess-stack', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - const impl = await fileService.readFile('/dataset/target/pair-process-implement/SKILL.md') - // frontmatter name synced - expect(impl).toContain('name: pair-process-implement') - // body references rewritten - expect(impl).toContain('`/pair-capability-verify-quality`') - expect(impl).toContain('`/pair-capability-record-decision`') - expect(impl).toContain('/pair-capability-assess-stack') - // frontmatter description also rewritten - expect(impl).toContain('/pair-capability-verify-quality and') - - const verify = await fileService.readFile( - '/dataset/target/pair-capability-verify-quality/SKILL.md', - ) - expect(verify).toContain('/pair-process-implement') - }) - - it('should return skillNameMap from flatten+prefix copy', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - '/dataset/source/process/implement/SKILL.md': '---\nname: implement\n---\n# /implement', - }, - '/', - '/', - ) - - const result = await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', targets: [] }, - }) - - expect(result.skillNameMap).toBeDefined() - expect(result.skillNameMap!.get('next')).toBe('pair-catalog-next') - expect(result.skillNameMap!.get('implement')).toBe('pair-process-implement') - }) - - it('should apply external skillNameMap to file copy', async () => { - const agentsContent = [ - '# AGENTS', - '```', - '/next', - '```', - 'Run `/next` to get started.', - 'Then `/implement` your task.', - ].join('\n') - - const fileService = new InMemoryFileSystemService( - { - '/project/src/AGENTS.md': agentsContent, - }, - '/', - '/', - ) - - const skillNameMap = new Map([ - ['next', 'pair-next'], - ['implement', 'pair-process-implement'], - ]) - - await copyPathOps({ - fileService, - source: 'src/AGENTS.md', - target: 'dist/AGENTS.md', - datasetRoot: '/project', - skillNameMap, - }) - - const result = await fileService.readFile('/project/dist/AGENTS.md') - expect(result).toContain('/pair-next') - expect(result).toContain('`/pair-next`') - expect(result).toContain('/pair-process-implement') - expect(result).not.toContain(' /next') - expect(result).not.toContain('/implement') - }) - - it('should detect and throw on flatten collisions', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/a/b/SKILL.md': '# Skill 1', - '/dataset/source/a-b/SKILL.md': '# Skill 2', - }, - '/', - '/', - ) - - await expect( - copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, targets: [] }, - }), - ).rejects.toThrow(/collision/i) - }) - - describe('mirror behavior — idempotent updates (AC4)', () => { - it('removes a stale flattened directory when its source skill is gone', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - // Stale leftover from a previous run — no longer present under source - '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, - }) - - await expect( - fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), - ).resolves.toBe(false) - await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( - true, - ) - }) - - it('removes the old prefixed directory after a prefix change', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - // Leftover from a previous install with prefix "pair" - '/dataset/target/pair-catalog-next/SKILL.md': '---\nname: pair-catalog-next\n---', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'foo', defaultBehavior: 'mirror', targets: [] }, - }) - - await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( - false, - ) - await expect(fileService.exists('/dataset/target/foo-catalog-next/SKILL.md')).resolves.toBe( - true, - ) - }) - - it('does not delete a root-level (non-nested) source file on a second mirror run', async () => { - // Regression: cleanupStaleTransformedEntries built its "expected" set only from - // dirMappingFiles, which copyFileWithTransform only populates for files under a - // subdirectory (dir !== '.'). A file copied directly from the source root was never - // registered as expected, so a second mirror run would delete it as "stale". - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/README.md': '# Root-level file, no subdirectory', - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - }, - '/', - '/', - ) - - const runOnce = () => - copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', defaultBehavior: 'mirror', targets: [] }, - }) - - await runOnce() - await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) - - // Second run must be idempotent — the root-level file must survive. - await runOnce() - await expect(fileService.exists('/dataset/target/README.md')).resolves.toBe(true) - await expect(fileService.exists('/dataset/target/pair-catalog-next/SKILL.md')).resolves.toBe( - true, - ) - }) - - it('does not clean up stale entries when behavior is not mirror', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '---\nname: next\n---\n# /next', - '/dataset/target/pair-catalog-removed/SKILL.md': '---\nname: pair-catalog-removed\n---', - }, - '/', - '/', - ) - - await copyPathOps({ - fileService, - source: 'source', - target: 'target', - datasetRoot: '/dataset', - options: { flatten: true, prefix: 'pair', defaultBehavior: 'overwrite', targets: [] }, - }) - - await expect( - fileService.exists('/dataset/target/pair-catalog-removed/SKILL.md'), - ).resolves.toBe(true) - }) - }) -}) - -describe('copyPathOps - error cases', () => { - let fileService: InMemoryFileSystemService - - beforeEach(() => { - fileService = TEST_SETUP.createBasicSetup() - }) - it('should throw error for nonexistent source', async () => { await expect( copyPathOps({ @@ -631,23 +34,4 @@ describe('copyPathOps - error cases', () => { }), ).rejects.toThrow() }) - - it('should respect behavior options', async () => { - fileService = new InMemoryFileSystemService(TEST_FILE_STRUCTURES.existingTarget, '/', '/') - - const result = await copyPathOps({ - fileService, - source: 'source.md', - target: 'target.md', - datasetRoot: '/dataset', - options: { - defaultBehavior: 'add', - flatten: false, - targets: [], - }, - }) - - TEST_ASSERTIONS.assertSuccessfulOperation(result) - await TEST_ASSERTIONS.assertFileExists(fileService, '/dataset/target.md', '# Existing Target') - }) }) From 3c76f1d4fe007564356f451d9bae80d709d2c045 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sun, 12 Jul 2026 18:13:03 +0200 Subject: [PATCH 21/21] [#199] test: distribute in-memory-fs tests to per-module files (PR #311 review) Split in-memory-fs.test.ts (16 describes, 1 file) into per-production-file tests: in-memory-fs-state.test.ts, in-memory-fs-read.test.ts, in-memory-fs-write.test.ts, in-memory-fs-seed.test.ts. in-memory-fs.test.ts keeps only the class's own constructor wiring, accessSync (no-op), and one end-to-end complex-scenario smoke test. Extracts test-fixtures.ts (createFs/DEFAULT_DIR) to remove the repeated moduleDir/workingDir='/app' boilerplate across the split files. --- .../in-memory-fs/in-memory-fs-read.test.ts | 90 +++ .../in-memory-fs/in-memory-fs-seed.test.ts | 43 ++ .../in-memory-fs/in-memory-fs-state.test.ts | 24 + .../in-memory-fs/in-memory-fs-write.test.ts | 252 ++++++++ .../in-memory-fs/in-memory-fs.test.ts | 550 +----------------- .../test-utils/in-memory-fs/test-fixtures.ts | 17 + 6 files changed, 449 insertions(+), 527 deletions(-) create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.test.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.test.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.test.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.test.ts create mode 100644 packages/content-ops/src/test-utils/in-memory-fs/test-fixtures.ts diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.test.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.test.ts new file mode 100644 index 00000000..a7b406e6 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-read.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { InMemoryFileSystemService } from './in-memory-fs' +import { createFs } from './test-fixtures' + +describe('in-memory-fs-read', () => { + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = createFs() + }) + + describe('readFileSync', () => { + it('should throw error when reading non-existent file', () => { + expect(() => fs.readFileSync('/nonexistent.txt')).toThrow('File not found: /nonexistent.txt') + }) + }) + + describe('existsSync / exists', () => { + it('should return true for existing files', async () => { + fs.writeFile('/existing.txt', 'content') + expect(fs.existsSync('/existing.txt')).toBe(true) + expect(await fs.exists('/existing.txt')).toBe(true) + }) + + it('should return false for non-existing files', async () => { + expect(fs.existsSync('/nonexistent.txt')).toBe(false) + expect(await fs.exists('/nonexistent.txt')).toBe(false) + }) + + it('should return true for existing directories', () => { + expect(fs.existsSync('/')).toBe(true) + }) + }) + + describe('readdir', () => { + it('should list directory contents', async () => { + await fs.writeFile('/dir/file1.txt', 'content1') + await fs.writeFile('/dir/file2.txt', 'content2') + await fs.mkdir('/dir/subdir') + + const entries = await fs.readdir('/dir') + expect(entries.map(e => e.name)).toContain('file1.txt') + expect(entries.map(e => e.name)).toContain('file2.txt') + expect(entries.map(e => e.name)).toContain('subdir') + }) + + it('should return empty array for empty directory', async () => { + await fs.mkdir('/emptydir') + expect((await fs.readdir('/emptydir')).length).toBe(0) + }) + + it('should throw error for non-existent directory', async () => { + await expect(fs.readdir('/nonexistent')).rejects.toThrow( + "no such file or directory '/nonexistent'", + ) + }) + }) + + describe('stat', () => { + it('should return file stats', async () => { + await fs.writeFile('/test.txt', 'content') + const stats = await fs.stat('/test.txt') + expect(stats.isFile()).toBe(true) + expect(stats.isDirectory()).toBe(false) + }) + + it('should return directory stats', async () => { + const stats = await fs.stat('/') + expect(stats.isFile()).toBe(false) + expect(stats.isDirectory()).toBe(true) + }) + + it('should throw error for non-existent path', async () => { + await expect(fs.stat('/nonexistent')).rejects.toThrow( + "no such file or directory '/nonexistent'", + ) + }) + }) + + describe('getContent', () => { + it('should return file content for existing file', () => { + fs.writeFile('/file1.txt', 'content1') + expect(fs.getContent('/file1.txt')).toBe('content1') + }) + + it('should return undefined for non-existing file', () => { + expect(fs.getContent('/nonexistent.txt')).toBeUndefined() + }) + }) +}) diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.test.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.test.ts new file mode 100644 index 00000000..143c8077 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-seed.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFsState } from './in-memory-fs-state' +import { seedState } from './in-memory-fs-seed' +import { DEFAULT_DIR } from './test-fixtures' + +describe('seedState', () => { + it('should load initial files', () => { + const state = new InMemoryFsState(DEFAULT_DIR, DEFAULT_DIR) + seedState(state, { '/test.txt': 'content' }, DEFAULT_DIR, DEFAULT_DIR) + + expect(state.files.get('/test.txt')).toBe('content') + expect(state.dirs.has('/test.txt')).toBe(false) + }) + + it('should create parent directories for initial files', () => { + const state = new InMemoryFsState(DEFAULT_DIR, DEFAULT_DIR) + seedState(state, { '/deep/nested/file.txt': 'content' }, DEFAULT_DIR, DEFAULT_DIR) + + expect(state.dirs.has('/deep')).toBe(true) + expect(state.dirs.has('/deep/nested')).toBe(true) + expect(state.files.get('/deep/nested/file.txt')).toBe('content') + }) + + it('should register the root, moduleDirectory and workingDirectory', () => { + const state = new InMemoryFsState(DEFAULT_DIR, DEFAULT_DIR) + seedState(state, {}, DEFAULT_DIR, DEFAULT_DIR) + + expect(state.dirs.has('/')).toBe(true) + expect(state.dirs.has(DEFAULT_DIR)).toBe(true) + }) + + it('should handle a non-existent moduleDirectory without throwing', () => { + const state = new InMemoryFsState('/nonexistent', DEFAULT_DIR) + expect(() => seedState(state, {}, '/nonexistent', DEFAULT_DIR)).not.toThrow() + expect(state.dirs.has('/nonexistent')).toBe(true) + }) + + it('should handle a non-existent workingDirectory without throwing', () => { + const state = new InMemoryFsState(DEFAULT_DIR, '/nonexistent') + expect(() => seedState(state, {}, DEFAULT_DIR, '/nonexistent')).not.toThrow() + expect(state.dirs.has('/nonexistent')).toBe(true) + }) +}) diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.test.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.test.ts new file mode 100644 index 00000000..8245ffbc --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-state.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFsState } from './in-memory-fs-state' + +describe('InMemoryFsState - resolvePath', () => { + it('should return absolute paths unchanged', () => { + const state = new InMemoryFsState('/app', '/app') + expect(state.resolvePath('/absolute/path')).toBe('/absolute/path') + }) + + it('should resolve relative paths against working directory', () => { + const state = new InMemoryFsState('/app', '/app') + expect(state.resolvePath('relative/path')).toBe('/app/relative/path') + expect(state.resolvePath('./relative/path')).toBe('/app/relative/path') + expect(state.resolvePath('../parent/path')).toBe('/parent/path') + }) + + it('should resolve relative paths against a custom working directory', () => { + const state = new InMemoryFsState('/custom/module', '/custom/work') + expect(state.resolvePath('file.txt')).toBe('/custom/work/file.txt') + expect(state.resolvePath('../file.txt')).toBe('/custom/file.txt') + expect(state.resolvePath('dir/../file.txt')).toBe('/custom/work/file.txt') + expect(state.resolvePath('/absolute/file.txt')).toBe('/absolute/file.txt') + }) +}) diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.test.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.test.ts new file mode 100644 index 00000000..1675ebe8 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs-write.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import type { InMemoryFileSystemService } from './in-memory-fs' +import { createFs } from './test-fixtures' + +describe('in-memory-fs-write', () => { + let fs: InMemoryFileSystemService + + beforeEach(() => { + fs = createFs() + }) + + describe('writeFile', () => { + it('should write and read files synchronously', () => { + fs.writeFile('/test.txt', 'content') + expect(fs.readFileSync('/test.txt')).toBe('content') + }) + + it('should write and read files asynchronously', async () => { + await fs.writeFile('/async.txt', 'async content') + expect(await fs.readFile('/async.txt')).toBe('async content') + }) + + it('should handle relative paths', () => { + fs.writeFile('relative.txt', 'relative content') + expect(fs.readFileSync('relative.txt')).toBe('relative content') + }) + + it('should create parent directories when writing', () => { + fs.writeFile('/deep/nested/file.txt', 'nested content') + expect(fs.existsSync('/deep')).toBe(true) + expect(fs.existsSync('/deep/nested')).toBe(true) + expect(fs.readFileSync('/deep/nested/file.txt')).toBe('nested content') + }) + }) + + describe('unlink', () => { + it('should remove files', async () => { + await fs.writeFile('/test.txt', 'content') + expect(fs.existsSync('/test.txt')).toBe(true) + await fs.unlink('/test.txt') + expect(fs.existsSync('/test.txt')).toBe(false) + }) + + it('should throw error when unlinking non-existent file', async () => { + await expect(fs.unlink('/nonexistent.txt')).rejects.toThrow( + 'File not found: /nonexistent.txt', + ) + }) + }) + + describe('mkdir', () => { + it('should create directories', async () => { + await fs.mkdir('/newdir') + expect(fs.existsSync('/newdir')).toBe(true) + }) + + it('should create parent directories recursively', async () => { + await fs.mkdir('/deep/nested/dir', { recursive: true }) + expect(fs.existsSync('/deep')).toBe(true) + expect(fs.existsSync('/deep/nested')).toBe(true) + expect(fs.existsSync('/deep/nested/dir')).toBe(true) + }) + + it('should handle existing directories', async () => { + await fs.mkdir('/existing') + expect(() => fs.mkdir('/existing')).not.toThrow() + }) + }) + + describe('rename', () => { + it('should rename files', async () => { + await fs.writeFile('/old.txt', 'content') + await fs.rename('/old.txt', '/new.txt') + expect(fs.existsSync('/old.txt')).toBe(false) + expect(fs.existsSync('/new.txt')).toBe(true) + expect(fs.readFileSync('/new.txt')).toBe('content') + }) + + it('should rename directories', async () => { + await fs.mkdir('/olddir') + await fs.writeFile('/olddir/file.txt', 'content') + await fs.rename('/olddir', '/newdir') + expect(fs.existsSync('/olddir')).toBe(false) + expect(fs.existsSync('/newdir')).toBe(true) + expect(fs.readFileSync('/newdir/file.txt')).toBe('content') + }) + + it('should throw error when renaming non-existent file', async () => { + await expect(fs.rename('/nonexistent.txt', '/new.txt')).rejects.toThrow( + 'Path not found: /nonexistent.txt', + ) + }) + }) + + describe('copy', () => { + it('should copy files', async () => { + await fs.writeFile('/source.txt', 'content') + await fs.copy('/source.txt', '/dest.txt') + expect(fs.existsSync('/source.txt')).toBe(true) + expect(fs.existsSync('/dest.txt')).toBe(true) + expect(fs.readFileSync('/dest.txt')).toBe('content') + }) + + it('should create parent directories when copying', async () => { + await fs.writeFile('/source.txt', 'content') + await fs.copy('/source.txt', '/deep/nested/dest.txt') + expect(fs.existsSync('/deep/nested/dest.txt')).toBe(true) + expect(fs.readFileSync('/deep/nested/dest.txt')).toBe('content') + }) + + it('should throw error when copying non-existent file', async () => { + await expect(fs.copy('/nonexistent.txt', '/dest.txt')).rejects.toThrow( + 'Path not found: /nonexistent.txt', + ) + }) + }) + + describe('copySync', () => { + it('should copy a file to a new path', () => { + fs.writeFile('/foo.txt', 'hello') + fs.copySync('/foo.txt', '/bar.txt') + expect(fs.getContent('/bar.txt')).toBe('hello') + expect(fs.getContent('/foo.txt')).toBe('hello') + }) + + it('should copy a file to a new directory', () => { + fs.writeFile('/foo.txt', 'hello') + fs.copySync('/foo.txt', '/dir/bar.txt') + expect(fs.getContent('/dir/bar.txt')).toBe('hello') + }) + + it('should copy a directory recursively', () => { + fs.writeFile('/foo/bar/baz.txt', 'baz') + fs.copySync('/foo/bar', '/foo/barcopy') + expect(fs.getContent('/foo/barcopy/baz.txt')).toBe('baz') + expect(fs.getContent('/foo/bar/baz.txt')).toBe('baz') + }) + + it('should throw if source does not exist', () => { + expect(() => fs.copySync('/notfound', '/foo/x')).toThrow() + }) + + it('should copy nested directories and files', () => { + fs.writeFile('/foo/file.txt', 'hello') + fs.writeFile('/foo/bar/baz.txt', 'baz') + fs.copySync('/foo', '/fooCopy') + expect(fs.getContent('/fooCopy/file.txt')).toBe('hello') + expect(fs.getContent('/fooCopy/bar/baz.txt')).toBe('baz') + }) + }) + + describe('rm', () => { + it('should remove files', async () => { + await fs.writeFile('/test.txt', 'content') + await fs.rm('/test.txt') + expect(fs.existsSync('/test.txt')).toBe(false) + }) + + it('should remove directories recursively', async () => { + await fs.mkdir('/testdir', { recursive: true }) + await fs.writeFile('/testdir/file.txt', 'content') + await fs.rm('/testdir', { recursive: true }) + expect(fs.existsSync('/testdir')).toBe(false) + expect(fs.existsSync('/testdir/file.txt')).toBe(false) + }) + + it('should throw error when removing non-existent path', async () => { + await expect(fs.rm('/nonexistent')).rejects.toThrow('Path not found: /nonexistent') + }) + + it('should throw error when removing directory without recursive option', async () => { + await fs.mkdir('/testdir') + await fs.writeFile('/testdir/file.txt', 'content') + await expect(fs.rm('/testdir')).rejects.toThrow('Directory not empty: /testdir') + }) + }) + + describe('createZip / extractZip', () => { + it('should create and extract ZIP from single file', async () => { + const zipFs = createFs({ '/project/file.txt': 'content' }, '/project', '/project') + + await zipFs.createZip(['/project/file.txt'], '/project/archive.zip') + expect(zipFs.existsSync('/project/archive.zip')).toBe(true) + + await zipFs.extractZip('/project/archive.zip', '/project/extracted') + expect(zipFs.existsSync('/project/extracted/file.txt')).toBe(true) + expect(await zipFs.readFile('/project/extracted/file.txt')).toBe('content') + }) + + it('should create and extract ZIP from directory', async () => { + const zipFs = createFs( + { + '/project/src/index.ts': 'export {}', + '/project/src/utils.ts': 'export const util = 1', + '/project/src/nested/deep.ts': 'deep file', + }, + '/project', + '/project', + ) + + await zipFs.createZip(['/project/src'], '/project/bundle.zip') + expect(zipFs.existsSync('/project/bundle.zip')).toBe(true) + + await zipFs.extractZip('/project/bundle.zip', '/project/output') + expect(zipFs.existsSync('/project/output/index.ts')).toBe(true) + expect(zipFs.existsSync('/project/output/utils.ts')).toBe(true) + expect(zipFs.existsSync('/project/output/nested/deep.ts')).toBe(true) + expect(await zipFs.readFile('/project/output/index.ts')).toBe('export {}') + expect(await zipFs.readFile('/project/output/nested/deep.ts')).toBe('deep file') + }) + + it('should create ZIP from multiple sources', async () => { + const zipFs = createFs( + { + '/project/README.md': '# Project', + '/project/src/index.ts': 'export {}', + '/project/config.json': '{}', + }, + '/project', + '/project', + ) + + await zipFs.createZip( + ['/project/README.md', '/project/config.json', '/project/src'], + '/project/package.zip', + ) + await zipFs.extractZip('/project/package.zip', '/project/unpacked') + + expect(zipFs.existsSync('/project/unpacked/README.md')).toBe(true) + expect(zipFs.existsSync('/project/unpacked/config.json')).toBe(true) + expect(zipFs.existsSync('/project/unpacked/index.ts')).toBe(true) + }) + + it('should throw error when extracting non-existent ZIP', async () => { + const zipFs = createFs({}, '/project', '/project') + + await expect(zipFs.extractZip('/project/missing.zip', '/project/out')).rejects.toThrow( + 'ZIP file not found', + ) + }) + + it('should handle empty directory in ZIP', async () => { + const zipFs = createFs({ '/project/src/file.ts': 'content' }, '/project', '/project') + + await zipFs.createZip(['/project/src'], '/project/archive.zip') + await zipFs.extractZip('/project/archive.zip', '/project/restored') + + expect(zipFs.existsSync('/project/restored/file.ts')).toBe(true) + expect(await zipFs.readFile('/project/restored/file.ts')).toBe('content') + }) + }) +}) diff --git a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts index 42552e4b..d1131a59 100644 --- a/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts @@ -1,435 +1,35 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import InMemoryFileSystemService from './in-memory-fs' +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from './in-memory-fs' +import { createFs, DEFAULT_DIR } from './test-fixtures' -describe('InMemoryFileSystemService - Constructor', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('constructor', () => { - it('should initialize with empty filesystem', () => { - expect(fs.existsSync('/')).toBe(true) - expect(fs.rootModuleDirectory()).toBe(moduleDir) - expect(fs.currentWorkingDirectory()).toBe(workingDir) - }) - - it('should initialize with initial files', () => { - const initialFs = new InMemoryFileSystemService( - { '/test.txt': 'content' }, - moduleDir, - workingDir, - ) - expect(initialFs.readFileSync('/test.txt')).toBe('content') - expect(initialFs.existsSync('/test.txt')).toBe(true) - }) - - it('should create parent directories for initial files', () => { - const initialFs = new InMemoryFileSystemService( - { '/deep/nested/file.txt': 'content' }, - moduleDir, - workingDir, - ) - expect(initialFs.existsSync('/deep')).toBe(true) - expect(initialFs.existsSync('/deep/nested')).toBe(true) - expect(initialFs.readFileSync('/deep/nested/file.txt')).toBe('content') - }) - - it('should handle non-existent moduleDirectory', () => { - expect(() => { - new InMemoryFileSystemService({}, '/nonexistent', workingDir) - }).not.toThrow() - }) - - it('should handle non-existent workingDirectory', () => { - expect(() => { - new InMemoryFileSystemService({}, moduleDir, '/nonexistent') - }).not.toThrow() - }) - }) -}) - -describe('InMemoryFileSystemService - Path Resolution', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('resolvePath', () => { - it('should return absolute paths unchanged', () => { - expect(fs['state'].resolvePath('/absolute/path')).toBe('/absolute/path') - }) - - it('should resolve relative paths against working directory', () => { - expect(fs['state'].resolvePath('relative/path')).toBe('/app/relative/path') - expect(fs['state'].resolvePath('./relative/path')).toBe('/app/relative/path') - expect(fs['state'].resolvePath('../parent/path')).toBe('/parent/path') - }) - }) -}) - -describe('InMemoryFileSystemService - File Operations - Write/Read', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('writeFile and readFile', () => { - it('should write and read files synchronously', () => { - fs.writeFile('/test.txt', 'content') - expect(fs.readFileSync('/test.txt')).toBe('content') - }) - - it('should write and read files asynchronously', async () => { - await fs.writeFile('/async.txt', 'async content') - expect(await fs.readFile('/async.txt')).toBe('async content') - }) - - it('should handle relative paths', () => { - fs.writeFile('relative.txt', 'relative content') - expect(fs.readFileSync('relative.txt')).toBe('relative content') - }) - - it('should create parent directories when writing', () => { - fs.writeFile('/deep/nested/file.txt', 'nested content') - expect(fs.existsSync('/deep')).toBe(true) - expect(fs.existsSync('/deep/nested')).toBe(true) - expect(fs.readFileSync('/deep/nested/file.txt')).toBe('nested content') - }) - - it('should throw error when reading non-existent file', () => { - expect(() => fs.readFileSync('/nonexistent.txt')).toThrow('File not found: /nonexistent.txt') - }) - }) -}) - -describe('InMemoryFileSystemService - File Operations - Exists/Unlink', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('exists', () => { - it('should return true for existing files', async () => { - fs.writeFile('/existing.txt', 'content') - expect(fs.existsSync('/existing.txt')).toBe(true) - expect(await fs.exists('/existing.txt')).toBe(true) - }) - - it('should return false for non-existing files', async () => { - expect(fs.existsSync('/nonexistent.txt')).toBe(false) - expect(await fs.exists('/nonexistent.txt')).toBe(false) - }) - - it('should return true for existing directories', () => { - expect(fs.existsSync('/')).toBe(true) - }) - }) - - describe('unlink', () => { - it('should remove files', async () => { - await fs.writeFile('/test.txt', 'content') - expect(fs.existsSync('/test.txt')).toBe(true) - await fs.unlink('/test.txt') - expect(fs.existsSync('/test.txt')).toBe(false) - }) - - it('should throw error when unlinking non-existent file', async () => { - await expect(fs.unlink('/nonexistent.txt')).rejects.toThrow( - 'File not found: /nonexistent.txt', - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Directory Operations - Mkdir', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('mkdir', () => { - it('should create directories', async () => { - await fs.mkdir('/newdir') - expect(fs.existsSync('/newdir')).toBe(true) - }) - - it('should create parent directories recursively', async () => { - await fs.mkdir('/deep/nested/dir', { recursive: true }) - expect(fs.existsSync('/deep')).toBe(true) - expect(fs.existsSync('/deep/nested')).toBe(true) - expect(fs.existsSync('/deep/nested/dir')).toBe(true) - }) - - it('should handle existing directories', async () => { - await fs.mkdir('/existing') - expect(() => fs.mkdir('/existing')).not.toThrow() - }) - }) -}) - -describe('InMemoryFileSystemService - Directory Operations - Readdir', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('readdir', () => { - it('should list directory contents', async () => { - await fs.writeFile('/dir/file1.txt', 'content1') - await fs.writeFile('/dir/file2.txt', 'content2') - await fs.mkdir('/dir/subdir') - - const entries = await fs.readdir('/dir') - expect(entries.map(e => e.name)).toContain('file1.txt') - expect(entries.map(e => e.name)).toContain('file2.txt') - expect(entries.map(e => e.name)).toContain('subdir') - }) - - it('should return empty array for empty directory', async () => { - await fs.mkdir('/emptydir') - expect((await fs.readdir('/emptydir')).length).toBe(0) - }) - - it('should throw error for non-existent directory', async () => { - await expect(fs.readdir('/nonexistent')).rejects.toThrow( - "no such file or directory '/nonexistent'", - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Directory Operations - Stat', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('stat', () => { - it('should return file stats', async () => { - await fs.writeFile('/test.txt', 'content') - const stats = await fs.stat('/test.txt') - expect(stats.isFile()).toBe(true) - expect(stats.isDirectory()).toBe(false) - }) - - it('should return directory stats', async () => { - const stats = await fs.stat('/') - expect(stats.isFile()).toBe(false) - expect(stats.isDirectory()).toBe(true) - }) - - it('should throw error for non-existent path', async () => { - await expect(fs.stat('/nonexistent')).rejects.toThrow( - "no such file or directory '/nonexistent'", - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - Rename', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('rename', () => { - it('should rename files', async () => { - await fs.writeFile('/old.txt', 'content') - await fs.rename('/old.txt', '/new.txt') - expect(fs.existsSync('/old.txt')).toBe(false) - expect(fs.existsSync('/new.txt')).toBe(true) - expect(fs.readFileSync('/new.txt')).toBe('content') - }) - - it('should rename directories', async () => { - await fs.mkdir('/olddir') - await fs.writeFile('/olddir/file.txt', 'content') - await fs.rename('/olddir', '/newdir') - expect(fs.existsSync('/olddir')).toBe(false) - expect(fs.existsSync('/newdir')).toBe(true) - expect(fs.readFileSync('/newdir/file.txt')).toBe('content') - }) - - it('should throw error when renaming non-existent file', async () => { - await expect(fs.rename('/nonexistent.txt', '/new.txt')).rejects.toThrow( - 'Path not found: /nonexistent.txt', - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - Copy', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('copy', () => { - it('should copy files', async () => { - await fs.writeFile('/source.txt', 'content') - await fs.copy('/source.txt', '/dest.txt') - expect(fs.existsSync('/source.txt')).toBe(true) - expect(fs.existsSync('/dest.txt')).toBe(true) - expect(fs.readFileSync('/dest.txt')).toBe('content') - }) - - it('should create parent directories when copying', async () => { - await fs.writeFile('/source.txt', 'content') - await fs.copy('/source.txt', '/deep/nested/dest.txt') - expect(fs.existsSync('/deep/nested/dest.txt')).toBe(true) - expect(fs.readFileSync('/deep/nested/dest.txt')).toBe('content') - }) - - it('should throw error when copying non-existent file', async () => { - await expect(fs.copy('/nonexistent.txt', '/dest.txt')).rejects.toThrow( - 'Path not found: /nonexistent.txt', - ) - }) - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - CopySync', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - it('should copy a file to a new path', () => { - fs.writeFile('/foo.txt', 'hello') - fs.copySync('/foo.txt', '/bar.txt') - expect(fs.getContent('/bar.txt')).toBe('hello') - expect(fs.getContent('/foo.txt')).toBe('hello') - }) - - it('should copy a file to a new directory', () => { - fs.writeFile('/foo.txt', 'hello') - fs.copySync('/foo.txt', '/dir/bar.txt') - expect(fs.getContent('/dir/bar.txt')).toBe('hello') - }) - - it('should copy a directory recursively', () => { - fs.writeFile('/foo/bar/baz.txt', 'baz') - fs.copySync('/foo/bar', '/foo/barcopy') - expect(fs.getContent('/foo/barcopy/baz.txt')).toBe('baz') - expect(fs.getContent('/foo/bar/baz.txt')).toBe('baz') - }) - - it('should throw if source does not exist', () => { - expect(() => fs.copySync('/notfound', '/foo/x')).toThrow() - }) - - it('should copy nested directories and files', () => { - fs.writeFile('/foo/file.txt', 'hello') - fs.writeFile('/foo/bar/baz.txt', 'baz') - fs.copySync('/foo', '/fooCopy') - expect(fs.getContent('/fooCopy/file.txt')).toBe('hello') - expect(fs.getContent('/fooCopy/bar/baz.txt')).toBe('baz') - }) -}) - -describe('InMemoryFileSystemService - Advanced Operations - Rm', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) - }) - - describe('rm', () => { - it('should remove files', async () => { - await fs.writeFile('/test.txt', 'content') - await fs.rm('/test.txt') - expect(fs.existsSync('/test.txt')).toBe(false) - }) - - it('should remove directories recursively', async () => { - await fs.mkdir('/testdir', { recursive: true }) - await fs.writeFile('/testdir/file.txt', 'content') - await fs.rm('/testdir', { recursive: true }) - expect(fs.existsSync('/testdir')).toBe(false) - expect(fs.existsSync('/testdir/file.txt')).toBe(false) - }) - - it('should throw error when removing non-existent path', async () => { - await expect(fs.rm('/nonexistent')).rejects.toThrow('Path not found: /nonexistent') - }) - - it('should throw error when removing directory without recursive option', async () => { - await fs.mkdir('/testdir') - await fs.writeFile('/testdir/file.txt', 'content') - await expect(fs.rm('/testdir')).rejects.toThrow('Directory not empty: /testdir') - }) +// The class's own constructor wiring and utility methods that don't delegate to any +// of the focused in-memory-fs-{state,read,write,seed} modules. Behavior owned by +// those modules (path resolution, seeding, reads, writes) is tested in their own +// co-located test files. +describe('InMemoryFileSystemService - constructor wiring', () => { + it('should initialize with an empty filesystem rooted at moduleDir/workingDir', () => { + const fs = createFs() + expect(fs.existsSync('/')).toBe(true) + expect(fs.rootModuleDirectory()).toBe(DEFAULT_DIR) + expect(fs.currentWorkingDirectory()).toBe(DEFAULT_DIR) }) }) -describe('InMemoryFileSystemService - Utility Methods', () => { - const moduleDir = '/app' - const workingDir = '/app' - let fs: InMemoryFileSystemService - - beforeEach(() => { - fs = new InMemoryFileSystemService({}, moduleDir, workingDir) +describe('InMemoryFileSystemService - accessSync', () => { + it('should not throw for existing files', () => { + const fs = createFs() + fs.writeFile('/test.txt', 'content') + expect(() => fs.accessSync()).not.toThrow() }) - describe('utility methods', () => { - describe('getContent', () => { - it('should return file content for existing file', () => { - fs.writeFile('/file1.txt', 'content1') - expect(fs.getContent('/file1.txt')).toBe('content1') - }) - - it('should return undefined for non-existing file', () => { - expect(fs.getContent('/nonexistent.txt')).toBeUndefined() - }) - }) - - describe('accessSync', () => { - it('should not throw for existing files', () => { - fs.writeFile('/test.txt', 'content') - expect(() => fs.accessSync()).not.toThrow() - }) - - it('should not throw for non-existent files', () => { - expect(() => fs.accessSync()).not.toThrow() - }) - }) + it('should not throw for non-existent files', () => { + const fs = createFs() + expect(() => fs.accessSync()).not.toThrow() }) }) -describe('InMemoryFileSystemService - Complex Scenarios - Project Structure', () => { - it('should handle complex project structure', async () => { +describe('InMemoryFileSystemService - complex scenario (constructor + write + read wiring)', () => { + it('should handle a complex project structure end to end', async () => { const fs = new InMemoryFileSystemService( { '/project/package.json': '{"name": "test"}', @@ -457,107 +57,3 @@ describe('InMemoryFileSystemService - Complex Scenarios - Project Structure', () expect((await fs.readdir('/project/tests')).map(e => e.name)).toEqual(['main.test.ts']) }) }) - -describe('InMemoryFileSystemService - Complex Scenarios - Path Resolution', () => { - it('should handle path resolution with custom working directory', () => { - const customFs = new InMemoryFileSystemService({}, '/custom/module', '/custom/work') - expect(customFs['state'].resolvePath('file.txt')).toBe('/custom/work/file.txt') - expect(customFs['state'].resolvePath('../file.txt')).toBe('/custom/file.txt') - expect(customFs['state'].resolvePath('dir/../file.txt')).toBe('/custom/work/file.txt') - - // Test absolute paths - expect(customFs['state'].resolvePath('/absolute/file.txt')).toBe('/absolute/file.txt') - }) -}) - -describe('InMemoryFileSystemService - ZIP Operations', () => { - it('should create and extract ZIP from single file', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/file.txt': 'content', - }, - '/project', - '/project', - ) - - await fs.createZip(['/project/file.txt'], '/project/archive.zip') - - expect(fs.existsSync('/project/archive.zip')).toBe(true) - - await fs.extractZip('/project/archive.zip', '/project/extracted') - - expect(fs.existsSync('/project/extracted/file.txt')).toBe(true) - expect(await fs.readFile('/project/extracted/file.txt')).toBe('content') - }) - - it('should create and extract ZIP from directory', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/src/index.ts': 'export {}', - '/project/src/utils.ts': 'export const util = 1', - '/project/src/nested/deep.ts': 'deep file', - }, - '/project', - '/project', - ) - - await fs.createZip(['/project/src'], '/project/bundle.zip') - - expect(fs.existsSync('/project/bundle.zip')).toBe(true) - - await fs.extractZip('/project/bundle.zip', '/project/output') - - expect(fs.existsSync('/project/output/index.ts')).toBe(true) - expect(fs.existsSync('/project/output/utils.ts')).toBe(true) - expect(fs.existsSync('/project/output/nested/deep.ts')).toBe(true) - expect(await fs.readFile('/project/output/index.ts')).toBe('export {}') - expect(await fs.readFile('/project/output/nested/deep.ts')).toBe('deep file') - }) - - it('should create ZIP from multiple sources', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/README.md': '# Project', - '/project/src/index.ts': 'export {}', - '/project/config.json': '{}', - }, - '/project', - '/project', - ) - - await fs.createZip( - ['/project/README.md', '/project/config.json', '/project/src'], - '/project/package.zip', - ) - - await fs.extractZip('/project/package.zip', '/project/unpacked') - - expect(fs.existsSync('/project/unpacked/README.md')).toBe(true) - expect(fs.existsSync('/project/unpacked/config.json')).toBe(true) - expect(fs.existsSync('/project/unpacked/index.ts')).toBe(true) - }) - - it('should throw error when extracting non-existent ZIP', async () => { - const fs = new InMemoryFileSystemService({}, '/project', '/project') - - await expect(fs.extractZip('/project/missing.zip', '/project/out')).rejects.toThrow( - 'ZIP file not found', - ) - }) - - it('should handle empty directory in ZIP', async () => { - const fs = new InMemoryFileSystemService( - { - '/project/src/file.ts': 'content', - }, - '/project', - '/project', - ) - - await fs.createZip(['/project/src'], '/project/archive.zip') - await fs.extractZip('/project/archive.zip', '/project/restored') - - expect(fs.existsSync('/project/restored/file.ts')).toBe(true) - expect(await fs.readFile('/project/restored/file.ts')).toBe('content') - }) -}) diff --git a/packages/content-ops/src/test-utils/in-memory-fs/test-fixtures.ts b/packages/content-ops/src/test-utils/in-memory-fs/test-fixtures.ts new file mode 100644 index 00000000..73ffa575 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/test-fixtures.ts @@ -0,0 +1,17 @@ +import { InMemoryFileSystemService } from './in-memory-fs' + +/** Default module/working directory shared by the bulk of in-memory-fs unit tests. */ +export const DEFAULT_DIR = '/app' + +/** + * Creates an InMemoryFileSystemService rooted at DEFAULT_DIR (or custom dirs) for tests + * that don't care about a specific module/working-dir setup — avoids repeating the same + * `new InMemoryFileSystemService({}, '/app', '/app')` boilerplate in every describe block. + */ +export function createFs( + initial: Record = {}, + moduleDir: string = DEFAULT_DIR, + workingDir: string = DEFAULT_DIR, +): InMemoryFileSystemService { + return new InMemoryFileSystemService(initial, moduleDir, workingDir) +}