Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .pair/adoption/tech/way-of-working.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pnpm lint --filter <package_name>
- **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

Expand Down
216 changes: 216 additions & 0 deletions apps/pair-cli/src/commands/package/zip-creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
Expand Down
17 changes: 17 additions & 0 deletions apps/pair-cli/src/commands/package/zip-creator.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -155,6 +156,7 @@ interface CopyFileOptions {

async function copyFileToTemp(opts: CopyFileOptions): Promise<void> {
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)

Expand All @@ -170,6 +172,21 @@ async function copyFileToTemp(opts: CopyFileOptions): Promise<void> {
}

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<void> {
Expand Down
32 changes: 31 additions & 1 deletion qa/release-validation/CP8-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading