From 650921d91d4c8dc0e980133e0eeb99f20c5a5305 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 19:55:20 +0200 Subject: [PATCH 1/5] [#187] test: add failing test for target-layout link depth off-by-one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reproduce bug: target-layout packaging doesn't adjust relative link depth - .claude/skills/ (3 deep) → .skills/ (2 deep) links not rewritten - Task: T-1 Refs: #187 --- .../src/commands/package/zip-creator.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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..72e33457 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,40 @@ 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') + }) }) describe('createPackageZip - error handling', () => { From 679f82989e44e06a72fbb74a167f4b82134478f9 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 19:57:02 +0200 Subject: [PATCH 2/5] [#187] fix: depth-aware link rewriting in target-layout packaging - Rewrite relative links when packaging from target to source layout - Compute depth delta using originalDir (target) vs newDir (source) - Use content-ops rewriteLinksInFile for consistent link adjustment - Depth rewrite applied before --root absolute rewriting - Task: T-2 Refs: #187 --- .../src/commands/package/zip-creator.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 { From 971847a68fc43429642894fb3090b2178a656045 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 19:58:36 +0200 Subject: [PATCH 3/5] [#187] test: regression tests for source-layout and edge cases - Source layout packaging does NOT rewrite links (AC-4) - External links and anchors preserved in target-layout (AC-5) - Same-depth source/target is a no-op - Task: T-3 Refs: #187 --- .../src/commands/package/zip-creator.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) 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 72e33457..9f92bf5c 100644 --- a/apps/pair-cli/src/commands/package/zip-creator.test.ts +++ b/apps/pair-cli/src/commands/package/zip-creator.test.ts @@ -295,6 +295,108 @@ describe('createPackageZip - target layout', () => { 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', () => { From 07cd321d74ddc7c845acb6850d597fe0836a5ab1 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:02:27 +0200 Subject: [PATCH 4/5] [#187] test: integration test for target-layout package round-trip - Package from target layout with depth-adjusted links - Verify rewritten content in extracted ZIP - Verify package passes checksum verification - Task: T-4 Refs: #187 --- .../src/commands/package/zip-creator.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) 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 9f92bf5c..4cbbdf1d 100644 --- a/apps/pair-cli/src/commands/package/zip-creator.test.ts +++ b/apps/pair-cli/src/commands/package/zip-creator.test.ts @@ -638,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') From 4e0b720a0d2e937c84931211f92d9903c0336a56 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Sat, 11 Apr 2026 20:08:00 +0200 Subject: [PATCH 5/5] [#187] docs: add manual test case + reference qa/ in adoptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MT-CP806: manual test for target-layout link depth rewriting - Renumber old CP806→CP807 - way-of-working: document qa/ as manual test location - CLAUDE.md: add qa/release-validation/ to Key References Refs: #187 --- .pair/adoption/tech/way-of-working.md | 7 ++++++ CLAUDE.md | 1 + qa/release-validation/CP8-packaging.md | 32 +++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) 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/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