diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index eab0c2ee..215c4c82 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -10,6 +10,13 @@ - **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. +## Manual Testing + +- Manual test suites live in `qa/` at the repository root. +- `qa/release-validation/` contains critical path test cases (CP1–CP8) for release validation. +- Test cases follow the template in `.pair/knowledge/guidelines/collaboration/templates/manual-test-case-template.md`. +- When a bug fix or feature changes behavior covered by an existing CP, the corresponding test case MUST be updated. + ## Quality Gates - `pnpm quality-gate` is the adopted project-level quality gate command. diff --git a/CLAUDE.md b/CLAUDE.md index 0cb8ec0d..e0ec5a55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,7 @@ pnpm lint --filter - **Testing strategy**: `.pair/tech/knowledge-base/07-testing-strategy.md` - **Code guidelines**: `.pair/tech/knowledge-base/02-code-design-guidelines.md` - **Security rules**: `.pair/tech/knowledge-base/10-security-guidelines.md` +- **Manual test suites**: `qa/release-validation/` (critical path test cases CP1–CP8) ## ⚡ Quick Rules diff --git a/apps/pair-cli/src/commands/package/zip-creator.test.ts b/apps/pair-cli/src/commands/package/zip-creator.test.ts index 96f5ee6a..4cbbdf1d 100644 --- a/apps/pair-cli/src/commands/package/zip-creator.test.ts +++ b/apps/pair-cli/src/commands/package/zip-creator.test.ts @@ -261,6 +261,142 @@ describe('createPackageZip - target layout', () => { expect(fsService.existsSync(outputPath)).toBe(true) }) + + it('rewrites relative links to match source-layout depth', async () => { + const projectRoot = '/test-project' + const registry: RegistryConfig = { + source: '.skills', + behavior: 'mirror', + description: 'Skills registry', + include: [], + flatten: false, + targets: [{ path: '.claude/skills', mode: 'canonical' }], + } + + // Target layout: .claude/skills/pair-test/SKILL.md (3 levels deep) + // Link uses 3 levels of ../ (correct for target depth) + await fsService.writeFile( + `${projectRoot}/.claude/skills/pair-test/SKILL.md`, + '# Test Skill\n\n[Tech Stack](../../../.pair/adoption/tech/tech-stack.md)\n', + ) + + const manifest = testManifest({ name: 'test-kb', registries: ['skills'] }) + + await createPackageZip( + { projectRoot, registries: [registry], manifest, outputPath, layout: 'target' }, + fsService, + ) + + // Source layout: .skills/pair-test/SKILL.md (2 levels deep) + // Link should be rewritten to 2 levels of ../ + const zipContent = JSON.parse(fsService.readFileSync(outputPath)) + const skillContent = zipContent['.skills/pair-test/SKILL.md'] + expect(skillContent).toBeDefined() + expect(skillContent).toContain('[Tech Stack](../../.pair/adoption/tech/tech-stack.md)') + expect(skillContent).not.toContain('../../../.pair/adoption/tech/tech-stack.md') + }) + + it('does NOT rewrite relative links when layout is source (regression)', async () => { + const projectRoot = '/test-project' + const registry: RegistryConfig = { + source: '.skills', + behavior: 'mirror', + description: 'Skills registry', + include: [], + flatten: false, + targets: [{ path: '.claude/skills', mode: 'canonical' }], + } + + // Source layout: file is at source depth, links already correct + await fsService.writeFile( + `${projectRoot}/.skills/pair-test/SKILL.md`, + '# Test\n\n[Tech Stack](../../.pair/adoption/tech/tech-stack.md)\n', + ) + + const manifest = testManifest({ name: 'test-kb', registries: ['skills'] }) + + await createPackageZip( + { projectRoot, registries: [registry], manifest, outputPath, layout: 'source' }, + fsService, + ) + + const zipContent = JSON.parse(fsService.readFileSync(outputPath)) + const skillContent = zipContent['.skills/pair-test/SKILL.md'] + expect(skillContent).toContain('[Tech Stack](../../.pair/adoption/tech/tech-stack.md)') + }) + + it('preserves external links and anchors in target-layout packaging', async () => { + const projectRoot = '/test-project' + const registry: RegistryConfig = { + source: '.skills', + behavior: 'mirror', + description: 'Skills registry', + include: [], + flatten: false, + targets: [{ path: '.claude/skills', mode: 'canonical' }], + } + + const content = [ + '# Test Skill', + '', + '[External](https://example.com/docs)', + '[Anchor](#section-one)', + '[Relative](../../../.pair/adoption/tech/tech-stack.md)', + '[Another](../../../.pair/knowledge/guidelines/code.md)', + '', + ].join('\n') + + await fsService.writeFile(`${projectRoot}/.claude/skills/pair-test/SKILL.md`, content) + + const manifest = testManifest({ name: 'test-kb', registries: ['skills'] }) + + await createPackageZip( + { projectRoot, registries: [registry], manifest, outputPath, layout: 'target' }, + fsService, + ) + + const zipContent = JSON.parse(fsService.readFileSync(outputPath)) + const skillContent = zipContent['.skills/pair-test/SKILL.md'] + + // External and anchor links unchanged + expect(skillContent).toContain('[External](https://example.com/docs)') + expect(skillContent).toContain('[Anchor](#section-one)') + // Relative links adjusted + expect(skillContent).toContain('../../.pair/adoption/tech/tech-stack.md') + expect(skillContent).toContain('../../.pair/knowledge/guidelines/code.md') + // No 3-level links remain + expect(skillContent).not.toContain('../../../') + }) + + it('handles same-depth source and target (no-op rewriting)', async () => { + const projectRoot = '/test-project' + const registry: RegistryConfig = { + source: 'docs', + behavior: 'mirror', + description: 'Docs', + include: [], + flatten: false, + targets: [{ path: 'docs-target', mode: 'canonical' }], + } + + // Both source (docs/) and target (docs-target/) are 1 level deep — no depth change + await fsService.writeFile( + `${projectRoot}/docs-target/guide.md`, + '# Guide\n\n[Ref](../README.md)\n', + ) + + const manifest = testManifest({ name: 'test-kb', registries: ['docs'] }) + + await createPackageZip( + { projectRoot, registries: [registry], manifest, outputPath, layout: 'target' }, + fsService, + ) + + const zipContent = JSON.parse(fsService.readFileSync(outputPath)) + const docContent = zipContent['docs/guide.md'] + // Link stays at 1 level since source and target are same depth + expect(docContent).toContain('[Ref](../README.md)') + }) }) describe('createPackageZip - error handling', () => { @@ -502,6 +638,86 @@ describe('createPackageZip - content checksum', () => { } }) + it('target-layout package with rewritten links passes verification (round-trip)', async () => { + const testDir = path.join(os.tmpdir(), `test-target-roundtrip-${Date.now()}`) + const projectRoot = path.join(testDir, 'project') + const outputPath = path.join(testDir, 'target-package.zip') + + try { + // Setup target-layout structure with skills registry + const skillsTargetDir = path.join(projectRoot, '.claude/skills/pair-test') + fs.mkdirSync(skillsTargetDir, { recursive: true }) + + // Create .pair structure for link targets to exist + const adoptionDir = path.join(projectRoot, '.pair/adoption/tech') + fs.mkdirSync(adoptionDir, { recursive: true }) + fs.writeFileSync(path.join(adoptionDir, 'tech-stack.md'), '# Tech Stack') + + // Skill file with target-depth links (3 levels: .claude/skills/pair-test) + fs.writeFileSync( + path.join(skillsTargetDir, 'SKILL.md'), + [ + '# Test Skill', + '', + '[Tech Stack](../../../.pair/adoption/tech/tech-stack.md)', + '[External](https://example.com)', + '[Anchor](#overview)', + '', + ].join('\n'), + ) + + const { fileSystemService } = await import('@pair/content-ops') + const realFs = fileSystemService + + const registries: RegistryConfig[] = [ + { + source: '.skills', + behavior: 'mirror', + description: 'Skills', + include: [], + flatten: false, + targets: [{ path: '.claude/skills', mode: 'canonical' }], + }, + ] + const manifest = testManifest({ + name: 'target-roundtrip-kb', + registries: ['.skills'], + }) + + // Package from target layout + await createPackageZip( + { projectRoot, registries, manifest, outputPath, layout: 'target' }, + realFs, + ) + + expect(fs.existsSync(outputPath)).toBe(true) + + // Extract and verify link depth was adjusted + const AdmZip = (await import('adm-zip')).default + const zip = new AdmZip(outputPath) + const skillEntry = zip.getEntry('.skills/pair-test/SKILL.md') + expect(skillEntry).toBeDefined() + + const skillContent = skillEntry!.getData().toString('utf-8') + // Source layout depth: .skills/pair-test/ = 2 levels, so links use ../../ + expect(skillContent).toContain('../../.pair/adoption/tech/tech-stack.md') + expect(skillContent).not.toContain('../../../.pair/adoption/tech/tech-stack.md') + // External and anchor links preserved + expect(skillContent).toContain('https://example.com') + expect(skillContent).toContain('#overview') + + // Verify package passes checksum verification + const { verifyPackage } = await import('../kb-verify/verify-package.js') + const result = await verifyPackage(outputPath, realFs) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + } finally { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }) + } + } + }) + it('corrupted package fails verification (smoke test)', async () => { const testDir = path.join(os.tmpdir(), `test-corrupt-${Date.now()}`) const projectRoot = path.join(testDir, 'project') diff --git a/apps/pair-cli/src/commands/package/zip-creator.ts b/apps/pair-cli/src/commands/package/zip-creator.ts index a529d0cd..1194f644 100644 --- a/apps/pair-cli/src/commands/package/zip-creator.ts +++ b/apps/pair-cli/src/commands/package/zip-creator.ts @@ -1,4 +1,5 @@ import type { FileSystemService } from '@pair/content-ops' +import { rewriteLinksInFile } from '@pair/content-ops' import type { ManifestMetadata } from './metadata' import type { RegistryConfig } from '#registry' import { rewriteFileLinks } from './link-rewriter' @@ -155,6 +156,7 @@ interface CopyFileOptions { async function copyFileToTemp(opts: CopyFileOptions): Promise { const { filePath, layoutPaths, registry, tempDir, options, fsService } = opts + const layout: LayoutMode = options.layout || 'source' const basePath = layoutPaths.find(p => filePath.startsWith(p + '/') || filePath === p) const baseForRelative = basePath || path.join(options.projectRoot, registry.source) @@ -170,6 +172,21 @@ async function copyFileToTemp(opts: CopyFileOptions): Promise { } await fsService.writeFile(targetPath, content) + + // Adjust relative link depth when packaging from target layout to source layout + if (layout === 'target' && filePath.endsWith('.md')) { + const originalDir = path.relative(options.projectRoot, path.dirname(filePath)) + const newDir = path.dirname(path.join(registry.source, relativePath)) + if (originalDir !== newDir) { + await rewriteLinksInFile({ + fileService: fsService, + filePath: targetPath, + originalDir, + newDir, + datasetRoot: options.projectRoot, + }) + } + } } async function cleanupOnError(outputPath: string, fsService: FileSystemService): Promise { diff --git a/qa/release-validation/CP8-packaging.md b/qa/release-validation/CP8-packaging.md index 1a43707b..79c8bfbf 100644 --- a/qa/release-validation/CP8-packaging.md +++ b/qa/release-validation/CP8-packaging.md @@ -103,7 +103,37 @@ --- -## MT-CP806: Source and target packages differ +## MT-CP806: Target-layout package rewrites relative link depth + +**Priority**: P1 +**Preconditions**: MT-CP301 passes (KB installed in `$WORKDIR/project-auto`) +**Category**: Packaging +**Regression**: #187 + +### Steps + +1. `cd $WORKDIR/project-auto` +2. Find a skill file with relative links: `grep -r '\.\./\.\./\.\.' .claude/skills/ --include='*.md' -l | head -1` → `$SKILL_FILE` +3. Note the link depth: `grep -oP '\(\K[^)]+' $SKILL_FILE | grep '^\.\.' | head -3` +4. `$CLI package --layout target -o $WORKDIR/pkg-link-test.zip` +5. `mkdir -p $WORKDIR/pkg-link-extract && cd $WORKDIR/pkg-link-extract && unzip $WORKDIR/pkg-link-test.zip` +6. Find the same skill in extracted package: `find .skills/ -name "$(basename $SKILL_FILE)"` → `$PKG_SKILL` +7. Compare link depth: `grep -oP '\(\K[^)]+' $PKG_SKILL | grep '^\.\.' | head -3` + +### Expected Result + +- Links in `$PKG_SKILL` have one fewer `../` level than `$SKILL_FILE` (target `.claude/skills/x/` = 3 deep, source `.skills/x/` = 2 deep) +- External links (https://...) and anchors (#...) are unchanged +- `$CLI kb-validate --layout source` passes in `$WORKDIR/pkg-link-extract` + +### Notes + +- This validates the fix for #187 (off-by-one link depth in target-layout packaging) +- The depth delta depends on the specific registry: `.claude/skills/` → `.skills/` = -1 level + +--- + +## MT-CP807: Source and target packages differ **Priority**: P2 **Preconditions**: MT-CP801 and MT-CP802 pass