diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d13c62e0..812c3eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,16 +54,24 @@ 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: 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 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/.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/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..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,6 +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)** — 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. @@ -38,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/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/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..2c27cd43 --- /dev/null +++ b/.pair/adoption/decision-log/2026-07-12-website-coverage-baseline.md @@ -0,0 +1,88 @@ +# Decision: Website coverage threshold is a realistic baseline, not 80% + +## Date + +2026-07-12 + +## Status + +Active (amended 2026-07-12 — see Update below: thresholds now gate CI) + +## 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. + +## 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. 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/.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 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..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,7 +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). + +**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. + +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/.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/apps/pair-cli/src/cli.e2e.test.ts b/apps/pair-cli/src/cli.e2e.test.ts index 9fa43c34..5f4b4b6f 100644 --- a/apps/pair-cli/src/cli.e2e.test.ts +++ b/apps/pair-cli/src/cli.e2e.test.ts @@ -1,1507 +1,100 @@ 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', +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', + }, }, - 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, + }), + [`${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, - 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 - }) - }) -}) + // 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') -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 - }) -}) + // 4. Test disjoint update + // Add new file to source + await fs.writeFile(`${kbSourceDir}/knowledge/new.md`, 'New content') -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', + // 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, ) - }) - }) - - 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() - }) + expect(fs.existsSync(`${disjointTarget}/knowledge/new.md`)).toBe(true) - it('--ignore-config skips structure validation', async () => { - const cwd = '/e2e-validate-ignore' - const fs = new InMemoryFileSystemService( + // 5. Test disjoint update-link + // pair update-link /opt/pair/kb + await handleUpdateLinkCommand( { - [`${cwd}/.pair/knowledge/index.md`]: '# KB', + command: 'update-link', + target: disjointTarget, + dryRun: false, + logLevel: 'debug', }, - 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', + fs, ) - }) - - 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', + // 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) }) - - 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/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/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/package.json b/package.json index 9779da29..b215bc1d 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": "catalog:", "turbo": "catalog:" }, "packageManager": "pnpm@10.15.0", 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}

+ + ) +} 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/packages/content-ops/package.json b/packages/content-ops/package.json index 334d8f90..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": { @@ -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/content-ops/src/index.ts b/packages/content-ops/src/index.ts index f4f00923..95e9f057 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, @@ -43,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/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/copyPathOps.test.ts b/packages/content-ops/src/ops/copy/copy-directory-transforms.test.ts similarity index 57% rename from packages/content-ops/src/ops/copyPathOps.test.ts rename to packages/content-ops/src/ops/copy/copy-directory-transforms.test.ts index 5e889383..fda7c5fd 100644 --- a/packages/content-ops/src/ops/copyPathOps.test.ts +++ b/packages/content-ops/src/ops/copy/copy-directory-transforms.test.ts @@ -1,142 +1,13 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect } 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)', - ) - }) +import { TEST_ASSERTIONS, createTestFileService } from '../../test-utils' - 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', () => { +describe('copyDirectoryWithTransforms (via copyPathOps, flatten/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', - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + '/dataset/source/process/implement/SKILL.md': '# Implement Skill', + }) await copyPathOps({ fileService, @@ -159,13 +30,9 @@ describe('copyPathOps - flatten and prefix', () => { }) it('should apply prefix to top-level directory names', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/SKILL.md': '# Catalog Skill', - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/catalog/SKILL.md': '# Catalog Skill', + }) await copyPathOps({ fileService, @@ -183,13 +50,9 @@ describe('copyPathOps - flatten and prefix', () => { }) it('should apply both flatten and prefix', async () => { - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/catalog/next/SKILL.md': '# Next Skill', - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + }) await copyPathOps({ fileService, @@ -207,13 +70,9 @@ describe('copyPathOps - flatten and prefix', () => { }) 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', - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': '# Next Skill', + }) await copyPathOps({ fileService, @@ -232,14 +91,10 @@ describe('copyPathOps - flatten and prefix', () => { 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)', - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/catalog/next/SKILL.md': + '# Next\n[guide](../../../.pair/knowledge/testing/README.md)', + }) await copyPathOps({ fileService, @@ -260,14 +115,10 @@ describe('copyPathOps - flatten and prefix', () => { // 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)', - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/project/packages/kb/dataset/.skills/next/SKILL.md': + '# Next\n[PRD](../../.pair/adoption/PRD.md)', + }) await copyPathOps({ fileService, @@ -295,13 +146,9 @@ describe('copyPathOps - flatten and prefix', () => { '# /record-decision', ].join('\n') - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/capability/record-decision/SKILL.md': skillContent, - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/capability/record-decision/SKILL.md': skillContent, + }) await copyPathOps({ fileService, @@ -333,13 +180,9 @@ describe('copyPathOps - flatten and prefix', () => { '# Body', ].join('\n') - const fileService = new InMemoryFileSystemService( - { - '/dataset/source/category/my-skill/SKILL.md': skillContent, - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/category/my-skill/SKILL.md': skillContent, + }) await copyPathOps({ fileService, @@ -380,18 +223,14 @@ describe('copyPathOps - flatten and prefix', () => { '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', - }, - '/', - '/', - ) + 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, @@ -418,14 +257,10 @@ describe('copyPathOps - flatten and prefix', () => { }) 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 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, @@ -450,13 +285,9 @@ describe('copyPathOps - flatten and prefix', () => { 'Then `/implement` your task.', ].join('\n') - const fileService = new InMemoryFileSystemService( - { - '/project/src/AGENTS.md': agentsContent, - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/project/src/AGENTS.md': agentsContent, + }) const skillNameMap = new Map([ ['next', 'pair-next'], @@ -480,14 +311,10 @@ describe('copyPathOps - flatten and prefix', () => { }) 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', - }, - '/', - '/', - ) + const fileService = createTestFileService({ + '/dataset/source/a/b/SKILL.md': '# Skill 1', + '/dataset/source/a-b/SKILL.md': '# Skill 2', + }) await expect( copyPathOps({ @@ -502,15 +329,11 @@ describe('copyPathOps - flatten and prefix', () => { 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---', - }, - '/', - '/', - ) + 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, @@ -529,15 +352,11 @@ describe('copyPathOps - flatten and prefix', () => { }) 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---', - }, - '/', - '/', - ) + 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, @@ -560,14 +379,10 @@ describe('copyPathOps - flatten and prefix', () => { // 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 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({ @@ -590,14 +405,10 @@ describe('copyPathOps - flatten and prefix', () => { }) 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---', - }, - '/', - '/', - ) + 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, @@ -613,41 +424,3 @@ describe('copyPathOps - flatten and prefix', () => { }) }) }) - -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/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.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 new file mode 100644 index 00000000..7e178aec --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-directory.ts @@ -0,0 +1,216 @@ +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)}`) + 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.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 new file mode 100644 index 00000000..8cf20520 --- /dev/null +++ b/packages/content-ops/src/ops/copy/copy-file.ts @@ -0,0 +1,68 @@ +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)}`) + 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..91f361a6 --- /dev/null +++ b/packages/content-ops/src/ops/copy/copyPathOps.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { copyPathOps } from './copyPathOps' +import { TEST_SETUP, InMemoryFileSystemService } from '../../test-utils' + +// 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 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 throw error for nonexistent source', async () => { + await expect( + copyPathOps({ + fileService, + source: 'nonexistent.md', + target: 'target.md', + datasetRoot: '/dataset', + }), + ).rejects.toThrow() + }) +}) 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/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.ts b/packages/content-ops/src/ops/copyPathOps.ts deleted file mode 100644 index d419b264..00000000 --- a/packages/content-ops/src/ops/copyPathOps.ts +++ /dev/null @@ -1,857 +0,0 @@ -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 { 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' - -export type CopyPathOpsResult = { - skillNameMap?: SkillNameMap -} - -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 -} - -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 - */ -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') -} - -/** - * Handles directory copy operations - */ -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, - }) - } -} - -/** - * 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 }) - } -} 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 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 } /** 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 f97094aa..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['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') - }) - }) -}) - -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['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') - - // Test absolute paths - expect(customFs['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 d9831809..00000000 --- a/packages/content-ops/src/test-utils/in-memory-fs.ts +++ /dev/null @@ -1,429 +0,0 @@ -import type { Dirent, Stats } from 'fs' -import { dirname, resolve, isAbsolute } from 'path' -import type { FileSystemService } from '../file-system' - -export class InMemoryFileSystemService implements FileSystemService { - private files = new Map() - private dirs = new Set() - private moduleDirectory: string - private workingDirectory: string - - constructor( - initial: Record = {}, - moduleDirectory: string, - workingDirectory: string, - ) { - // Set directories first so resolvePath works - this.moduleDirectory = moduleDirectory - this.workingDirectory = workingDirectory - - this.dirs.add('/') - this.addParentDirectories(moduleDirectory) - this.addParentDirectories(workingDirectory) - this.addInitialFiles(initial) - - // Ensure moduleDirectory and workingDirectory exist - this.dirs.add(moduleDirectory) - this.dirs.add(workingDirectory) - } - - 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 - } - } - - 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) - } - } - - private resolvePath(path: string): string { - return isAbsolute(path) ? path : resolve(this.workingDirectory, path) - } - - accessSync() {} - - async readFile(path: string): Promise { - return this.readFileSync(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 - } - - existsSync(path: string): boolean { - const resolvedPath = this.resolvePath(path) - return this.files.has(resolvedPath) || this.dirs.has(resolvedPath) - } - - 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 - } - } - - async writeFileBinary(path: string, content: Buffer): Promise { - // Store binary data using latin1 encoding to preserve byte values - await this.writeFile(path, content.toString('latin1')) - } - - 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) - } - - 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 - } - } - } - - 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 - } - } - } - - 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) - }) - } - - 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}`) - } - } - - 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}`) - } - } - - 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() - } - - 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) - } - - // 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) - } - - 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 - } - - 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) - } - - getSymlinks(): Map { - return new Map(this.symlinks) - } - - 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 - } - } - - 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) - } - - 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) - } -} - -export default InMemoryFileSystemService 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-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.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-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.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-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.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-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..d1131a59 --- /dev/null +++ b/packages/content-ops/src/test-utils/in-memory-fs/in-memory-fs.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { InMemoryFileSystemService } from './in-memory-fs' +import { createFs, DEFAULT_DIR } from './test-fixtures' + +// 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 - accessSync', () => { + it('should not throw for existing files', () => { + const fs = createFs() + fs.writeFile('/test.txt', 'content') + expect(() => fs.accessSync()).not.toThrow() + }) + + it('should not throw for non-existent files', () => { + const fs = createFs() + expect(() => fs.accessSync()).not.toThrow() + }) +}) + +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"}', + '/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']) + }) +}) 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/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' 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) +} 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..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,7 +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). + +**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. + +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/design-principles/design-rules.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/code-design/design-principles/design-rules.md index 7f8f6cf5..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**: #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. - ## 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. @@ -41,6 +39,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,8 +55,6 @@ 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. - ## 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. @@ -93,22 +90,30 @@ 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. +## DR-4 — Split Files Without a Barrel -## Migration Plan (existing violations) +**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. -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. +**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 | 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 | +```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**: 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). +**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 @@ -116,3 +121,4 @@ Current, concrete instances found while extracting these rules from #199 — not - [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') ``` 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 | 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..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 @@ -182,6 +185,9 @@ importers: husky: specifier: 'catalog:' version: 8.0.0 + jscpd: + specifier: 'catalog:' + version: 5.0.12 turbo: specifier: 'catalog:' version: 2.5.6 @@ -416,7 +422,7 @@ importers: packages/content-ops: dependencies: adm-zip: - specifier: ^0.5.9 + specifier: 'catalog:' version: 0.5.16 markdown-it: specifier: 'catalog:' @@ -444,7 +450,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:' @@ -2326,6 +2332,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 +3538,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 +4748,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 @@ -8347,6 +8390,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 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 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 } + } + } +}