diff --git a/plan/Plan-001-plugin-discovery/index.md b/plan/Plan-001-plugin-discovery/index.md new file mode 100644 index 0000000..d10f5bc --- /dev/null +++ b/plan/Plan-001-plugin-discovery/index.md @@ -0,0 +1,20 @@ +--- +type: index +title: "Plan-001 — Plugin discovery" +description: "Contents of the Plan-001 bundle." +okf_version: "0.1" +--- + +# Plan-001 — Plugin discovery + +## Contents + +- [Plan-001: Plugin discovery](./plan.md) - Plan overview, DAG, tracks, gates, test plan. +- [Task-001: FR-008 candidate search core](./tasks/Task-001-candidate-search-core.md) +- [Task-002: FR-008 dedupe, rank, signal, token redaction](./tasks/Task-002-dedupe-rank-signal.md) +- [Task-003: FR-009 compatibility verification](./tasks/Task-003-compatibility-verification.md) +- [Task-004: FR-010 TTL cache + createPluginSearch factory](./tasks/Task-004-cache-and-factory.md) +- [Task-005: FR-011 GitHub rate-limit surfacing + short-circuit](./tasks/Task-005-rate-limit.md) +- [Task-006: FR-012 sourceToInstallInput](./tasks/Task-006-source-to-install-input.md) +- [Task-007: re-exports + quality gates](./tasks/Task-007-exports-and-gates.md) +- [Task-008: publish hand-off gate](./tasks/Task-008-publish-handoff.md) diff --git a/plan/Plan-001-plugin-discovery/log.md b/plan/Plan-001-plugin-discovery/log.md new file mode 100644 index 0000000..7ef110d --- /dev/null +++ b/plan/Plan-001-plugin-discovery/log.md @@ -0,0 +1,11 @@ +--- +type: log +title: "Plan-001 — Update Log" +description: "Chronological log of changes to the Plan-001 bundle." +--- + +# Plan-001 — Update Log + +## History + +- **2026-06-27** — Plan created from the reviewed discovery spec (US-003, FR-008…FR-012, NFR-005); scaffolded Task-001…Task-008 across tracks A (search), B (cache/rate), C (helper), S (integration/release). Test plan traces TC-022…TC-054 in `spec/tests.md`. Publish (Task-008) flagged as the hand-off gate before filament-ide consumes the API. diff --git a/plan/Plan-001-plugin-discovery/plan.md b/plan/Plan-001-plugin-discovery/plan.md new file mode 100644 index 0000000..f4d1e45 --- /dev/null +++ b/plan/Plan-001-plugin-discovery/plan.md @@ -0,0 +1,94 @@ +--- +id: Plan-001 +title: "Plugin discovery (search.ts) — candidate search, verification, cache, rate-limit" +type: Plan +status: active +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/usecase/US-003 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-008 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-009 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-010 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-011 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-012 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-005 + type: references +--- + +# Implementation Plan: Plugin discovery (`search.ts`) + +## Requirements Summary + +- [ ] **US-003**: A host consumes a discovery API to find and verify publishable plugins by tag. +- [ ] **FR-008**: Candidate search across npm + GitHub via an injectable `HttpFetcher`; per-backend `Promise.allSettled`; normalize to `Source`; encode/headers/auth; malformed-body tolerance; all-fail shape; `limit` clamp; dedupe + total-order ranking; `signal`; token redaction (CON-2). +- [ ] **FR-009**: Host-driven compatibility verification — manifest fetch (unpkg / raw.githubusercontent), `404`→incompatible vs transient→error, `verify` null/object/throw, concurrency cap ≤ 6. +- [ ] **FR-010**: `createTtlCache` (injectable `Clock`) + `createPluginSearch` factory; cache key incl. verifier-presence + token-id; no caching of errored responses; `invalidate`; late-bound token. +- [ ] **FR-011**: GitHub rate-limit surfacing (`resetAt` epoch-seconds) + short-circuit while exhausted + first-call passthrough; never sleeps. +- [ ] **FR-012**: `sourceToInstallInput` renders a `Source` to its canonical source string. +- [ ] **NFR-005**: All discovery network access through one injectable `HttpFetcher`; suite runs offline; zero new deps; 100% coverage (NFR-002). + +Spec-side reconciliation of NFR-001/NFR-003/`spec.md` (resolution surface = sync; discovery = the one async surface) is already landed; Task-007 verifies the code matches. + +## Dependency Graph + +- `Task-001 (FR-008 core search) -> Task-002 (FR-008 dedupe/rank/signal/redaction)` + Reason: merge/rank operate on the normalized candidate results Task-001 produces. +- `Task-001 -> Task-003 (FR-009 verification)` + Reason: verification filters the candidates Task-001 returns. +- `Task-001, Task-003 -> Task-004 (FR-010 cache + factory)` + Reason: `createPluginSearch` wraps the full search+verify pipeline and caches its `SearchResponse`. +- `Task-001, Task-004 -> Task-005 (FR-011 rate-limit)` + Reason: rate state is read from GitHub responses (Task-001) and retained by the factory (Task-004). +- `Task-006 (FR-012 sourceToInstallInput)` is independent — only depends on the `Source` type — and runs in parallel. +- `Task-002, Task-003, Task-004, Task-005, Task-006 -> Task-007 (exports + gates)` +- `Task-007 -> Task-008 (publish hand-off gate)` + +### Execution Tracks + +- **Track A — search pipeline (serial):** Task-001 → Task-002, and Task-001 → Task-003. +- **Track B — cache & rate (serial, after A):** Task-004 → Task-005. +- **Track C — helper (parallel):** Task-006. +- **Track S — integration/release (serial, last):** Task-007 → Task-008. + +### Hand-off contract (to filament-ide) + +This kit must be **specced + implemented + published** before filament-ide's +discovery work (its US-016/FR-030…032) consumes the published `search` API. +**Task-008 (publish) is the gate**: filament-ide bumps to the version that exports +`searchPlugins`/`createPluginSearch`/`sourceToInstallInput` only after it lands on +npm. + +## Test Plan + +All discovery tests live in a new `tests/search.test.ts` (vitest), driven by an +injected fake `HttpFetcher` and `Clock` — no real npm/GitHub/unpkg/raw access +(NFR-005). TC IDs trace to `spec/tests.md` (TC-022…TC-054). + +### Unit (fake fetcher + clock) + +- [ ] **candidate search** (FR-008 AC-1..4,6,7,8 / CON-1): TC-022, TC-023, TC-024, TC-025, TC-043, TC-044, TC-045. +- [ ] **dedupe / rank / signal / redaction** (FR-008 AC-5,9,10 / CON-2): TC-026, TC-046, TC-047, TC-054. +- [ ] **verification** (FR-009 AC-1..8): TC-027, TC-028, TC-029, TC-030, TC-031, TC-048, TC-049, TC-050. +- [ ] **cache + factory** (FR-010 AC-1..7): TC-032, TC-033, TC-034, TC-035, TC-036, TC-051, TC-052. +- [ ] **rate-limit** (FR-011 AC-1..5): TC-037, TC-038, TC-039, TC-040, TC-053. +- [ ] **sourceToInstallInput** (FR-012 AC-1,2): TC-041, TC-042. +- [ ] **defaultHttpFetcher** (NFR-005): `vi.stubGlobal('fetch', spy)` delegation. + +## Quality Gates + +- `make test` — vitest at the **100%** branches/functions/lines/statements gate (NFR-002) over `src/**` including `src/search.ts`. +- `make lint` — eslint + prettier clean. +- `quire validate --scope . "spec/**/*.md" "plan/**/*.md"` — spec + plan validate. +- Zero runtime dependencies preserved (`package.json` `dependencies` stays empty; default fetcher uses global `fetch`). + +## Notes + +`search.ts` is the kit's first asynchronous, networked surface; everything is +bounded behind `HttpFetcher`/`Clock` injection so the suite stays offline and +deterministic. The kit performs **no** manifest parsing — the host `CandidateVerifier` +owns that — keeping the toolkit framework-agnostic. diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-001-candidate-search-core.md b/plan/Plan-001-plugin-discovery/tasks/Task-001-candidate-search-core.md new file mode 100644 index 0000000..b737018 --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-001-candidate-search-core.md @@ -0,0 +1,52 @@ +--- +id: Task-001 +title: "FR-008 candidate search core (search.ts scaffold + HttpFetcher + backends)" +type: Task +status: done +track: A +priority: P0 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-008 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/usecase/US-003 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-005 + type: references +--- + +# Task-001: FR-008 candidate search core + +## Scope + +Stand up `src/search.ts` with the discovery type surface and the `HttpFetcher` +seam, and implement candidate search across npm and GitHub with independent +backends, normalization to `Source`, defensive parsing, the all-fail shape, and +`limit` clamping. (Dedupe/rank/signal/redaction are Task-002.) + +## Subtasks + +- [ ] Write Vitest specs FIRST in `tests/search.test.ts` with an injected fake `HttpFetcher`: + TC-022 (merge both backends), TC-023 (encoded `keywords:`/`topic:` + `size`/`per_page`), + TC-024 (one backend rejects → other returns + `SearchBackendError`), TC-025 (`Authorization` + iff token; `sources:["npm"]` skips GitHub), TC-043 (all backends fail → `results:[]` + per-backend + errors), TC-044 (malformed body / missing optionals degrade, no throw), TC-045 (`limit` clamp 250/100). +- [ ] Define types: `HttpResponse`, `HttpFetcher`, `defaultHttpFetcher` (global `fetch`), `SearchBackend`, + `PluginSearchResult`, `RateLimit`, `SearchBackendError`, `SearchResponse`, `SearchOptions`, + `CandidateVerifier`, `SearchError`. +- [ ] Implement `searchPlugins(opts)`: per-backend `Promise.allSettled`; npm + `GET {npmRegistry}/-/v1/search?text=keywords:{tag}[ {query}]&size={limit}`; GitHub + `GET {githubApi}/search/repositories?q=topic:{tag}[ {query}]&per_page={limit}` with + `Accept`/`X-GitHub-Api-Version` and conditional `Authorization`; URL-encode the query. +- [ ] Normalize npm→`{type:"npm",package}` and github→`{type:"github",repo:full_name}`; tolerate missing + `objects[]`/`items[]` (→ `SearchBackendError`) and missing optional item fields (→ `undefined`). +- [ ] Clamp `limit` to npm `size` ≤ 250 and GitHub `per_page` ≤ 100. + +## Deliverables + +- `src/search.ts` with the type surface, `defaultHttpFetcher`, and `searchPlugins` candidate path. +- `tests/search.test.ts` covering TC-022, TC-023, TC-024, TC-025, TC-043, TC-044, TC-045 (all via fake fetcher). + +## Notes + +CON-1: every request goes through the injected `HttpFetcher`; tests assert no real +network. Feeds Task-002 (dedupe/rank) and Task-003 (verification). diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-002-dedupe-rank-signal.md b/plan/Plan-001-plugin-discovery/tasks/Task-002-dedupe-rank-signal.md new file mode 100644 index 0000000..9fc280f --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-002-dedupe-rank-signal.md @@ -0,0 +1,42 @@ +--- +id: Task-002 +title: "FR-008 dedupe, total-order rank, signal propagation, token redaction" +type: Task +status: done +track: A +priority: P0 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-008 + type: references + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001 + type: depends_on +--- + +# Task-002: FR-008 dedupe, rank, signal, token redaction + +## Scope + +Complete `searchPlugins` with cross-backend dedupe, a deterministic total-order +ranking, `AbortSignal` propagation, and the token-redaction guarantee. + +## Subtasks + +- [ ] Write Vitest specs FIRST: TC-026 (npm vs github of same project collapse, npm preferred, carries + repo stars/updatedAt), TC-046 (total-order rank — absent stars/updatedAt sort last, `fullName` + tie-break), TC-047 (`signal` passed to every fetch; abort → backend error, resolved results kept), + TC-054 (token never in cache key, error, or result). +- [ ] Implement URL normalization for dedupe (lowercase host/owner/repo; strip `git+`, scheme, trailing + `/`, `.git`); prefer the npm entry and copy the matched repo's `stars`/`updatedAt` onto it. +- [ ] Implement ranking: `stars` desc → `updatedAt` desc → `fullName` asc (absent values lowest). +- [ ] Thread `opts.signal` into every `HttpFetcher` call; map an abort to a `SearchBackendError`. +- [ ] Ensure `githubToken` never appears in cache keys, `SearchBackendError` messages, or results (CON-2). + +## Deliverables + +- Dedupe + ranking + signal + redaction in `src/search.ts`. +- Tests TC-026, TC-046, TC-047, TC-054. + +## Notes + +Ranking must be a total order so the 100% gate has a deterministic result order to +assert. Depends on Task-001's normalized candidate results. diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-003-compatibility-verification.md b/plan/Plan-001-plugin-discovery/tasks/Task-003-compatibility-verification.md new file mode 100644 index 0000000..74575b3 --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-003-compatibility-verification.md @@ -0,0 +1,46 @@ +--- +id: Task-003 +title: "FR-009 host-driven compatibility verification" +type: Task +status: done +track: A +priority: P0 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-009 + type: references + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001 + type: depends_on +--- + +# Task-003: FR-009 compatibility verification + +## Scope + +Add the optional verification pass to `searchPlugins`: fetch each candidate's +manifest from a rate-limit-friendly CDN, hand the raw text to the host +`CandidateVerifier`, and keep/drop accordingly — distinguishing an absent manifest +(`404` → incompatible) from a transient failure (→ unverified + error). The kit +parses nothing. + +## Subtasks + +- [ ] Write Vitest specs FIRST: TC-027 (verify returns object → kept, `verified:true` + capabilities), + TC-028 (verify null → dropped), TC-029 (`404` → dropped as incompatible, no error, verify not called), + TC-030 (npm via `unpkg.com/{pkg}/{manifestPath}`, github via `raw.githubusercontent.com/{owner}/{repo}/HEAD/{manifestPath}`), + TC-031 (no verifier → unfiltered, no fetch), TC-048 (non-404 fail/reject → dropped + transient + `SearchBackendError`), TC-049 (verify throws → only that candidate dropped, no escape), + TC-050 (≤ 6 concurrent manifest fetches for > 6 candidates). +- [ ] Implement manifest-fetch URL construction (unpkg / raw.githubusercontent HEAD) through the same `HttpFetcher`. +- [ ] Branch on status: `404`→incompatible-drop (no error); other non-OK/reject→unverified-drop + transient error. +- [ ] Branch on `verify` result: `null`→drop; object→`verified:true` + `capabilities`; throw→drop that candidate only. +- [ ] Cap manifest-fetch concurrency at ≤ 6 simultaneous calls. + +## Deliverables + +- Verification pass in `src/search.ts`. +- Tests TC-027..TC-031, TC-048, TC-049, TC-050. + +## Notes + +The kit performs NO YAML/JSON parsing — interpretation is entirely the host +callback (keeps the toolkit framework-agnostic). Depends on Task-001's candidates. diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-004-cache-and-factory.md b/plan/Plan-001-plugin-discovery/tasks/Task-004-cache-and-factory.md new file mode 100644 index 0000000..748c3a1 --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-004-cache-and-factory.md @@ -0,0 +1,45 @@ +--- +id: Task-004 +title: "FR-010 TTL cache + createPluginSearch factory" +type: Task +status: done +track: B +priority: P0 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-010 + type: references + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001 + type: depends_on + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-003 + type: depends_on +--- + +# Task-004: FR-010 TTL cache + createPluginSearch factory + +## Scope + +Add the generic injectable-clock TTL cache and the `createPluginSearch` factory +that wraps the full search+verify pipeline, holding cache + rate state across calls. + +## Subtasks + +- [ ] Write Vitest specs FIRST (injected `Clock`): TC-032 (return before expiry, evict after clock advances), + TC-033 (`max` evicts oldest), TC-034 (cache hit → no `HttpFetcher` call), TC-035 (`invalidate(opts)` + re-fetches; `invalidate()` clears all), TC-036 (late-bound `githubToken` resolved per call, no stale + hit under previous token-id), TC-051 (errored/rate-limited response NOT cached), TC-052 + (verifier-presence + token-id discriminate entries). +- [ ] Implement `Clock`, `systemClock`, `createTtlCache({ttlMs, clock?, max?})` with `get/set/delete/clear/size`. +- [ ] Implement `createPluginSearch(deps)` → `{ search, invalidate, lastRate }`; cache key + `tag|query|sources|limit|verifier-present|token-id` (token-id is a non-secret discriminator, never the raw token). +- [ ] Cache only responses whose `errors` is empty; resolve a function-valued `githubToken` per call. + +## Deliverables + +- `createTtlCache`, `Clock`/`systemClock`, `createPluginSearch` in `src/search.ts`. +- Tests TC-032..TC-036, TC-051, TC-052. + +## Notes + +Not caching errored responses is what preserves the FR-011 resume-after-`resetAt` +behavior (Task-005). Depends on the search (Task-001) + verify (Task-003) pipeline +it wraps. diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-005-rate-limit.md b/plan/Plan-001-plugin-discovery/tasks/Task-005-rate-limit.md new file mode 100644 index 0000000..84b123e --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-005-rate-limit.md @@ -0,0 +1,44 @@ +--- +id: Task-005 +title: "FR-011 GitHub rate-limit surfacing + short-circuit" +type: Task +status: done +track: B +priority: P0 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-011 + type: references + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-001 + type: depends_on + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-004 + type: depends_on +--- + +# Task-005: FR-011 GitHub rate-limit surfacing + short-circuit + +## Scope + +Read GitHub rate-limit headers into `rate.github`, surface an exhausted window as a +`SearchBackendError` (never throw, never sleep), and short-circuit further GitHub +requests through `createPluginSearch` while the window is exhausted. + +## Subtasks + +- [ ] Write Vitest specs FIRST (injected `Clock` + fake fetcher): TC-037 (200 headers populate `rate.github`), + TC-038 (`403`/`429` + `remaining:0` → `SearchBackendError{rateLimited:true}` + reset, no throw), + TC-039 (exhausted window → next `search` skips GitHub while clock < `resetAt`), TC-040 (clock past + `resetAt` → GitHub re-issued), TC-053 (first call, no prior snapshot → GitHub issued; `lastRate()` empty). +- [ ] Parse `x-ratelimit-limit|remaining|reset` (present on 200 and 403); store `resetAt` as epoch-seconds. +- [ ] Compare `clock.now()/1000 < resetAt` for the short-circuit; emit the rate-limited error in `errors`. +- [ ] Guard the first-call (undefined `lastRate().github`) path so it does not short-circuit. +- [ ] No sleep/delay primitive — behavior is a pure function of inputs + the injected clock. + +## Deliverables + +- Rate-limit parsing + short-circuit in `src/search.ts`. +- Tests TC-037..TC-040, TC-053. + +## Notes + +Cooperates with Task-004's retained rate state. `resetAt` is epoch-seconds while +`clock.now()` is ms — the `/1000` conversion is the unit bridge. diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-006-source-to-install-input.md b/plan/Plan-001-plugin-discovery/tasks/Task-006-source-to-install-input.md new file mode 100644 index 0000000..901bbeb --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-006-source-to-install-input.md @@ -0,0 +1,35 @@ +--- +id: Task-006 +title: "FR-012 sourceToInstallInput (canonical source string)" +type: Task +status: done +track: C +priority: P1 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-012 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/functional/FR-001 + type: references +--- + +# Task-006: FR-012 sourceToInstallInput + +## Scope + +Add `sourceToInstallInput(source)` rendering a typed `Source` to its canonical +source string (the token a host's string-based install entry point accepts). + +## Subtasks + +- [ ] Write Vitest specs FIRST: TC-041 (npm→`package`, github→`owner/repo`), TC-042 (git/url→`url`, path→`path`). +- [ ] Implement the per-variant mapping; `git-subdir`→`url` (documented lossy — subdir not expressible as one token). + +## Deliverables + +- `sourceToInstallInput` in `src/search.ts`. +- Tests TC-041, TC-042. + +## Notes + +Independent of the search pipeline — only needs the `Source` union (FR-001), so it +runs in parallel (Track C). diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-007-exports-and-gates.md b/plan/Plan-001-plugin-discovery/tasks/Task-007-exports-and-gates.md new file mode 100644 index 0000000..4cc5a16 --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-007-exports-and-gates.md @@ -0,0 +1,54 @@ +--- +id: Task-007 +title: "Re-export discovery surface + quality gates" +type: Task +status: done +track: S +priority: P0 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-001 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-002 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-003 + type: references + - target: ix://agent-ix/ts-plugin-kit/spec/non-functional/NFR-005 + type: references + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-002 + type: depends_on + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-003 + type: depends_on + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-004 + type: depends_on + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-005 + type: depends_on + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-006 + type: depends_on +--- + +# Task-007: Re-export discovery surface + quality gates + +## Scope + +Wire the discovery surface into the public barrel and pass every quality gate. + +## Subtasks + +- [ ] Re-export from `src/index.ts`: `HttpFetcher`, `HttpResponse`, `defaultHttpFetcher`, `searchPlugins`, + all discovery result/option/error types, `SearchError`, `Clock`, `systemClock`, `TtlCache`, + `TtlCacheOptions`, `createTtlCache`, `PluginSearch`, `PluginSearchDeps`, `createPluginSearch`, + `sourceToInstallInput`. +- [ ] Verify `vite.config.ts` `external` needs no change (global `fetch`, no new import) and `package.json` + `dependencies` stays empty (NFR-001). +- [ ] Confirm the resolution surface stays synchronous (NFR-003) and only the discovery exports are async. +- [ ] `make test` at the 100% coverage gate over `src/**` incl. `src/search.ts` (NFR-002). +- [ ] `make lint` clean; `quire validate --scope . "spec/**/*.md" "plan/**/*.md"` clean. + +## Deliverables + +- Updated `src/index.ts`; green `make test` (100%), `make lint`, and quire validation. + +## Notes + +The spec-side NFR-001/NFR-003/`spec.md` reconciliation already landed during review; +this task verifies the code matches those claims. Gates Task-008. diff --git a/plan/Plan-001-plugin-discovery/tasks/Task-008-publish-handoff.md b/plan/Plan-001-plugin-discovery/tasks/Task-008-publish-handoff.md new file mode 100644 index 0000000..a6a3b41 --- /dev/null +++ b/plan/Plan-001-plugin-discovery/tasks/Task-008-publish-handoff.md @@ -0,0 +1,38 @@ +--- +id: Task-008 +title: "Publish hand-off gate (npm) for filament-ide consumption" +type: Task +status: todo +track: S +priority: P0 +relationships: + - target: ix://agent-ix/ts-plugin-kit/spec/usecase/US-003 + type: references + - target: ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/tasks/Task-007 + type: depends_on +--- + +# Task-008: Publish hand-off gate + +## Scope + +Cut and publish the kit version that exports the discovery surface, so filament-ide +can bump to it. This is the **hand-off gate** between this plan and filament-ide's +discovery work (its US-016 / FR-030…032). + +## Subtasks + +- [ ] Bump `@agent-ix/ts-plugin-kit` version (minor — additive public surface). +- [ ] `make build` (vite lib + rolled-up `.d.ts`) and verify `searchPlugins`, + `createPluginSearch`, `createTtlCache`, `sourceToInstallInput`, and all discovery types appear in `dist/`. +- [ ] Publish to the public npm registry (`prepublishOnly` runs the build; `publish:dry-run` first). +- [ ] Record the published version so filament-ide's plan pins to it. + +## Deliverables + +- Published `@agent-ix/ts-plugin-kit` with the discovery API; version recorded for the downstream bump. + +## Notes + +filament-ide MUST NOT start consuming `searchPlugins` until this lands on npm — +this gate is the cross-repo sequencing point called out in `plan.md`. diff --git a/reviews/26-06-27-plugin-discovery-gap-analysis.md b/reviews/26-06-27-plugin-discovery-gap-analysis.md new file mode 100644 index 0000000..836606c --- /dev/null +++ b/reviews/26-06-27-plugin-discovery-gap-analysis.md @@ -0,0 +1,59 @@ +--- +id: SR-005 +title: "gap-analysis of Plan-001 plugin discovery (US-003, FR-008..012, NFR-005) — 2026-06-27" +type: SpecReview +analysis: gap-analysis +scope: "plan/Plan-001-plugin-discovery/; spec/usecase/US-003; spec/functional/FR-008..FR-012; spec/non-functional/NFR-005; src/search.ts; tests/search.test.ts; spec/tests.md (TC-022..054)" +review_set: subset +relationships: + - target: "ix://agent-ix/ts-plugin-kit/plan/Plan-001-plugin-discovery/plan" + type: "reviews" + - target: "ix://agent-ix/ts-plugin-kit/spec/tests" + type: "references" +--- + +## Summary + +Post-implementation gap analysis of the plugin-discovery plan bundle (Plan-001) +against its spec (US-003, FR-008…FR-012, NFR-005), the implementation +(`src/search.ts`), the tests (`tests/search.test.ts`), and the Test Matrix +(`spec/tests.md`, TC-022…TC-054). The build is functionally complete, fully +covered (100% on `src/search.ts`), and was smoke-tested against live npm + GitHub. +The matrix is real: every discovery Test Case (TC-022…TC-054) is backed by a test +carrying a matching `(TC-xxx)` tracking tag. The gaps are (a) plan-status hygiene — +the seven build tasks were never flipped from `todo` to `done`; (b) one genuine +code↔spec divergence in the cache key; and (c) a cache-bound default that does not +match the bounded-cache intent. Semantic intent↔test↔code agreement was assessed in +the parallel `/code-review` pass (folded into the findings below). + +## Verdict + +**CONDITIONAL** — implementation complete and matrix-backed; no `high` findings. +Clears to **PASS** once the seven build tasks are marked `done`, the cache-key +token-identity divergence (FND-002) is fixed, and the cache-bound default (FND-003) +is set. Task-008 (publish) is a legitimate downstream gate, not a defect. + +## Findings + +| ID | Severity | Summary | Refs | +| ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| FND-001 | medium | Plan-001 Tasks 001–007 are implemented, tested, and smoke-verified but still carry `status: todo`; only Task-008 (publish) is genuinely pending. Flip the seven build tasks to `done`. | plan/Plan-001-plugin-discovery/tasks/Task-001..007 | +| FND-002 | medium | **Code↔spec divergence.** FR-010 specifies the cache key uses "a stable non-secret discriminator of the resolved token" (token _identity_), but `cacheKey` uses token _presence_ (`token ? "auth" : "anon"`). Two distinct tokens collide on `"auth"`; with the late-bound-token feature a shared searcher could serve one user's authenticated (incl. private-repo) results to another. | FR-010, src/search.ts:568 | +| FND-003 | medium | Cache is unbounded by default: `createPluginSearch` leaves `cacheMax` undefined, so entries evict only lazily on expired-key read — memory grows with distinct queries over the TTL. FR-010/NFR-011 intend a bounded cache; set a default `cacheMax`. | FR-010, NFR-011, src/search.ts:546 | +| FND-004 | low | `manifestUrl` interpolates the registry-supplied `name`/`fullName` unencoded; cross-host SSRF is not reachable (fixed authority), but a name containing `..` can traverse within the trusted CDN to a different file. Reject/encode `..`/control chars. | FR-009, src/search.ts:421 | +| FND-005 | low | `Number(x-ratelimit-*)` has no non-finite guard (surfaces `NaN` rate fields, benign); a cache hit returns the shared `SearchResponse` reference (host mutation poisons later hits). | FR-010, FR-011, src/search.ts:331,576 | +| FND-006 | low | Untracked tests: several `search.test.ts` cases (dedup-order, npm-link fallback, github-first dedupe, equal-rank) carry no `(TC-xxx)` tag — extra coverage beyond the matrix, not a gap; optionally tag or note. | tests/search.test.ts | + +## Coverage + +- **Plan completion (Step 1):** 0/8 tasks `status: done`; 7 are built-but-unflagged + (FND-001), Task-008 (publish) genuinely pending. +- **Matrix verification (Step 2):** PASS for the Plan-001 scope — all 33 discovery + Test Cases (TC-022…TC-054) are backed by a test carrying the matching `(TC-xxx)` + tag in `tests/search.test.ts`. (Matrix rows TC-001…021 are the pre-existing backsync + suite, outside this plan's scope, backed by `describe › test` case-strings.) +- **Underspecified code (Step 3):** No code lacking an owning requirement; all + `search.ts` exports trace to FR-008…012 / NFR-005. One divergence (FND-002) and one + unbounded default (FND-003) recorded. +- **Semantic review (Step 4):** Performed via the parallel `/code-review` pass + (intent↔test↔code); its medium findings are reflected in FND-002…005. diff --git a/spec/functional/FR-008-candidate-search.md b/spec/functional/FR-008-candidate-search.md new file mode 100644 index 0000000..12cc1ab --- /dev/null +++ b/spec/functional/FR-008-candidate-search.md @@ -0,0 +1,107 @@ +--- +id: FR-008 +title: "Candidate Plugin Search Across npm and GitHub" +type: FR +relationships: + - target: "ix://agent-ix/ts-plugin-kit/spec/usecase/US-003" + type: "implements" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-001" + type: "requires" + cardinality: "1:1" +--- + +# [FR-008] Candidate Plugin Search Across npm and GitHub + +## Description + +The library SHALL export `searchPlugins(opts)` which, given a discriminator `tag`, +queries the selected backends (`npm` and/or `github`) through an injectable +`HttpFetcher` and returns a `SearchResponse` whose every `PluginSearchResult` +carries a normalized [`Source`](./FR-001-typed-source-union.md). The default +`HttpFetcher` SHALL delegate to the Node global `fetch`, adding no runtime +dependency. + +## Inputs + +- `SearchOptions`: `tag` (required), optional `query`, `sources` + (default `["npm","github"]`), `limit` (per backend, default 20), `http`, + `githubToken`, `npmRegistry`, `githubApi`, `signal`. + +## Outputs + +- `SearchResponse`: `results` (normalized, deduped, ranked), `rate` + (per-backend rate-limit snapshots, see [FR-011](./FR-011-github-rate-limit.md)), + and `errors` (one `SearchBackendError` per failed backend). + +- The library SHALL run each selected backend independently via + `Promise.allSettled`, so a rejecting backend does not prevent the other backend's + results from being returned. +- When a selected backend rejects or returns a non-OK status, the library SHALL + record one `SearchBackendError` for it in `errors` and SHALL NOT throw. +- When every selected backend fails, the library SHALL resolve to a `SearchResponse` + with `results: []` and one `SearchBackendError` per backend, so a host + distinguishes a total failure from a successful empty search (empty `results`, + empty `errors`). +- When `npm` is selected, the library SHALL issue + `GET {npmRegistry}/-/v1/search?text=keywords:{tag}[ {query}]&size={limit}` with + default `npmRegistry` `https://registry.npmjs.org`. +- For each npm `objects[].package`, the library SHALL emit a result whose `source` + is `{type:"npm", package:}`. +- When `github` is selected, the library SHALL issue + `GET {githubApi}/search/repositories?q=topic:{tag}[ {query}]&per_page={limit}` + (default `githubApi` `https://api.github.com`) with headers + `Accept: application/vnd.github+json`, `X-GitHub-Api-Version: 2022-11-28`, and + `Authorization: Bearer {githubToken}` only when a token is supplied. +- For each GitHub `items[]` entry, the library SHALL emit a result whose `source` + is `{type:"github", repo:}`. +- The library SHALL URL-encode the composed query string so a `tag` or `query` + containing reserved characters does not corrupt the request. +- The library SHALL clamp `limit` to each backend's maximum (npm `size` ≤ 250, + GitHub `per_page` ≤ 100) so a large `limit` cannot provoke a backend `422`. +- When a backend body is structurally invalid (missing or non-array + `objects[]`/`items[]`), the library SHALL record a `SearchBackendError` for that + backend rather than throwing; a per-item entry missing an optional field + (`links.repository`, `author`, `stargazers_count`, `updatedAt`) SHALL degrade to + an `undefined` result field, never an exception. +- The library SHALL merge results across backends and dedupe an npm package against + a GitHub repository when their repository URLs are equal after normalization + (lowercased host/owner/repo, stripped `git+`, scheme, trailing `/`, and `.git`), + preferring the npm entry while carrying the matched repository's `stars` and + `updatedAt` onto it. +- The library SHALL rank the merged set by `stars` descending, then `updatedAt` + descending, then `fullName` ascending, so ordering is a total order even when + `stars`/`updatedAt` are absent (treated as the lowest value). +- When `opts.signal` is supplied, the library SHALL pass it to every `HttpFetcher` + call; an abort SHALL surface as a `SearchBackendError` for the in-flight backend, + leaving any already-resolved backend's results intact. + +## Constraints + +| ID | Constraint | Type | Validation | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------------- | +| FR-008-CON-1 | The library SHALL route all discovery network access through the injectable `HttpFetcher`, with no direct transport call that bypasses it | Maintainability | Test (TC-032) | +| FR-008-CON-2 | The library SHALL NOT include the `githubToken` value in any cache key, `SearchBackendError`, or `PluginSearchResult` | Security | Test (TC-064) | + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | +| FR-008-AC-1 | A search over both backends returns npm results sourced as `{type:"npm",…}` and GitHub results sourced as `{type:"github",…}`, merged into one ranked list. | Test (TC-032) | +| FR-008-AC-2 | The composed npm URL contains `keywords:{tag}` and the GitHub URL contains `topic:{tag}`, both URL-encoded; `limit` maps to npm `size` and GitHub `per_page`. | Test (TC-033) | +| FR-008-AC-3 | When one backend's fetch rejects, the other backend's results are returned and a `SearchBackendError` for the failed backend appears in `errors`. | Test (TC-034) | +| FR-008-AC-4 | A GitHub request carries `Authorization: Bearer …` when a token is supplied and omits it when none is; `sources:["npm"]` issues no GitHub request. | Test (TC-035) | +| FR-008-AC-5 | An npm package whose normalized `links.repository` matches a returned GitHub repository collapses to one result, preferring the npm entry and carrying the repo's stars/updatedAt onto it. | Test (TC-036) | +| FR-008-AC-6 | When every selected backend fails, the response is `results: []` with one `SearchBackendError` per backend and no thrown error. | Test (TC-053) | +| FR-008-AC-7 | A structurally-invalid backend body yields a `SearchBackendError` for that backend (no throw); items missing optional fields degrade to `undefined`, not an exception. | Test (TC-054) | +| FR-008-AC-8 | A `limit` above a backend maximum is clamped (npm `size` ≤ 250, GitHub `per_page` ≤ 100). | Test (TC-055) | +| FR-008-AC-9 | Ranking is a total order — results with absent `stars`/`updatedAt` sort last and ties break on `fullName`, giving a stable deterministic order. | Test (TC-056) | +| FR-008-AC-10 | A supplied `opts.signal` is passed to every `HttpFetcher` call; an abort surfaces as a `SearchBackendError` for the in-flight backend and preserves resolved results. | Test (TC-057) | + +## Dependencies + +- Implements [US-003](../usecase/US-003-discover-plugins-by-tag.md). +- Requires [FR-001](./FR-001-typed-source-union.md) (results normalize to the typed + `Source` union). +- Constrained by [NFR-001](../non-functional/NFR-001-zero-runtime-dependencies.md) + and [NFR-005](../non-functional/NFR-005-injectable-discovery-transport.md). diff --git a/spec/functional/FR-009-compatibility-verification.md b/spec/functional/FR-009-compatibility-verification.md new file mode 100644 index 0000000..526b829 --- /dev/null +++ b/spec/functional/FR-009-compatibility-verification.md @@ -0,0 +1,91 @@ +--- +id: FR-009 +title: "Host-Driven Compatibility Verification of Candidates" +type: FR +relationships: + - target: "ix://agent-ix/ts-plugin-kit/spec/usecase/US-003" + type: "implements" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-008" + type: "requires" + cardinality: "1:1" +--- + +# [FR-009] Host-Driven Compatibility Verification of Candidates + +## Description + +Where the caller supplies a `CandidateVerifier`, the library SHALL include a +candidate only when the host's `verify` callback accepts that candidate's +CDN-fetched manifest, dropping every other candidate. The library SHALL NOT itself +parse manifest text in any format (YAML, JSON, or otherwise); interpretation +belongs entirely to the host callback. The detailed fetch, drop, and +capability-attachment steps are specified under Behavior below. + +## Inputs + +- `CandidateVerifier`: `manifestPath` (e.g. `"manifest.yaml"`) and + `verify(rawManifest) => { capabilities?: unknown } | null`. +- The candidate results produced by [FR-008](./FR-008-candidate-search.md). + +## Outputs + +- The result set after verification: incompatible candidates removed; each surviving + result marked `verified: true` and carrying the host-supplied `capabilities`. A + candidate dropped because its manifest was _unreachable_ (as opposed to absent) + contributes a `SearchBackendError` so the host can tell a real failure from an + incompatible package. + +## Behavior + +- Where a `verifier` is given, the library SHALL fetch each npm candidate's + manifest via `https://unpkg.com/{package}/{manifestPath}` and each GitHub + candidate's manifest via + `https://raw.githubusercontent.com/{owner}/{repo}/HEAD/{manifestPath}`, through + the same injectable `HttpFetcher`. +- Before building a manifest URL, the library SHALL reject a registry-supplied + `name`/`fullName` whose path segments contain `..` or any control character, + dropping that candidate without issuing a fetch, so a hostile registry entry + cannot traverse within the trusted CDN authority. (`manifestPath` is host-supplied + and therefore trusted.) +- When a manifest fetch returns `404`, the library SHALL drop the candidate as + incompatible (no manifest at the declared path) without recording an error. +- If a manifest fetch rejects or returns a non-OK status other than `404`, then the + library SHALL drop the candidate as _unverified_ and record one + `SearchBackendError` marked transient, so a CDN/registry blip is not silently + conflated with incompatibility. +- If `verify(rawManifest)` returns `null`, then the library SHALL drop the candidate. +- If `verify(rawManifest)` returns an object, then the library SHALL mark the + candidate `verified: true` and attach `capabilities` from that object. +- If `verify(rawManifest)` throws, then the library SHALL drop only that candidate + (treated as unverified) and SHALL NOT let the error escape `searchPlugins`. +- Where no `verifier` is given, the library SHALL skip verification and return + candidates with `verified` left unset. +- The library SHALL cap manifest-fetch concurrency at no more than six simultaneous + `HttpFetcher` calls so a large candidate set does not issue unbounded requests. + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| FR-009-AC-1 | With a verifier supplied, a candidate whose `verify` returns an object survives with `verified:true` and the returned `capabilities` attached. | Test (TC-037) | +| FR-009-AC-2 | A candidate whose `verify` returns `null` is removed from the results. | Test (TC-038) | +| FR-009-AC-3 | A candidate whose manifest fetch returns `404` is dropped as incompatible with no error recorded, and `verify` is not called. | Test (TC-039) | +| FR-009-AC-4 | The npm manifest is fetched from an `unpkg.com/{package}/{manifestPath}` URL and the GitHub manifest from a `raw.githubusercontent.com/{owner}/{repo}/HEAD/{manifestPath}` URL. | Test (TC-040) | +| FR-009-AC-5 | With no verifier supplied, results are returned unfiltered with `verified` unset and no manifest fetch occurs. | Test (TC-041) | +| FR-009-AC-6 | A manifest fetch that rejects or returns a non-`404` non-OK status drops the candidate as unverified and records a transient `SearchBackendError`. | Test (TC-058) | +| FR-009-AC-7 | A `verify` callback that throws drops only that candidate; no error escapes `searchPlugins` and other candidates are unaffected. | Test (TC-059) | +| FR-009-AC-8 | No more than six manifest fetches are in flight simultaneously for a candidate set larger than six. | Test (TC-060) | +| FR-009-AC-9 | A candidate whose registry-supplied name contains a `..` path segment or a control character is dropped before any manifest fetch is issued. | Test (TC-067) | + +## Constraints + +| ID | Constraint | Type | Validation | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------- | +| FR-009-CON-1 | The library SHALL NOT interpolate a registry-supplied name containing a `..` path segment or a control character into a manifest URL; such a candidate is dropped. | Security | Test (TC-067) | + +## Dependencies + +- Implements [US-003](../usecase/US-003-discover-plugins-by-tag.md). +- Requires [FR-008](./FR-008-candidate-search.md) (the candidates it verifies). +- Constrained by [NFR-005](../non-functional/NFR-005-injectable-discovery-transport.md). diff --git a/spec/functional/FR-010-discovery-cache.md b/spec/functional/FR-010-discovery-cache.md new file mode 100644 index 0000000..ec95e69 --- /dev/null +++ b/spec/functional/FR-010-discovery-cache.md @@ -0,0 +1,89 @@ +--- +id: FR-010 +title: "TTL-Cached Discovery with an Injectable Clock" +type: FR +relationships: + - target: "ix://agent-ix/ts-plugin-kit/spec/usecase/US-003" + type: "implements" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-008" + type: "requires" + cardinality: "1:1" +--- + +# [FR-010] TTL-Cached Discovery with an Injectable Clock + +## Description + +The library SHALL export a generic `createTtlCache(opts)` whose expiry is driven by +an injectable `Clock`, and a `createPluginSearch(deps)` factory that holds one such +cache plus rate state across calls so a host can reuse a single searcher. Repeated +searches with identical parameters within the TTL SHALL be served from cache +without re-issuing backend requests. + +## Inputs + +- `createTtlCache`: `{ ttlMs, clock?, max? }`; default `clock` is `systemClock`. +- `createPluginSearch`: `{ http?, clock?, ttlMs?, cacheMax?, githubToken?, +npmRegistry?, githubApi?, verifier? }`; `githubToken` MAY be a value or a late-bound + `() => string | undefined`; `cacheMax` defaults to 256 when omitted. + +## Outputs + +- `TtlCache` with `get`/`set`/`delete`/`clear`/`size`. +- `PluginSearch` with `search(opts)`, `invalidate(opts?)`, and `lastRate()`. + +## Behavior + +- `createTtlCache` SHALL store each value with an expiry of `clock.now() + ttlMs`. +- When `clock.now()` reaches an entry's expiry, `createTtlCache.get` SHALL return + `undefined` and evict that entry. +- Where `max` is set, `createTtlCache` SHALL evict entries in insertion order beyond + `max`. +- `createPluginSearch().search` SHALL key the cache on + `tag|query|sources|limit|verifier-present|token-id`, where `token-id` is a stable + non-secret discriminator of the resolved token (never the raw token), so a + late-bound token or a verifier toggle does not return a stale or wrong-shape entry. +- `createPluginSearch().search` SHALL derive `token-id` from the resolved token so + that two distinct non-empty tokens map to distinct cache entries (no cross-token + hit) — computed as the first 8 hex of the token's SHA-256 digest — while an absent + token maps to a fixed `anon` id; the raw token value SHALL NOT appear in the key + (see [FR-008-CON-2](./FR-008-candidate-search.md)). +- `createPluginSearch` SHALL bound its cache by `cacheMax`, defaulting to 256 when + none is supplied, so memory does not grow without limit across distinct searches + within the TTL. +- When a search hits a live cache entry, `createPluginSearch().search` SHALL return a + distinct (cloned) `SearchResponse`, not the shared cached reference, so a caller + mutating the result cannot corrupt the cached entry. +- When a search hits a live cache entry, `createPluginSearch().search` SHALL serve + it from the cache and issue no `HttpFetcher` call. +- `createPluginSearch().search` SHALL cache only a `SearchResponse` whose `errors` + is empty; a response carrying any `SearchBackendError` (including a rate-limited + backend) SHALL NOT be cached, so a transient failure never poisons the TTL nor + defeats the [FR-011](./FR-011-github-rate-limit.md) resume-after-`resetAt` rule. +- `invalidate(opts?)` SHALL drop the matching cache entry, or the whole cache when + no `opts` are given. +- The factory SHALL resolve a function-valued `githubToken` at call time, so a host + can supply a token that becomes available after the searcher is constructed. + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- | ------- | -------------------------------------------------------------- | ------------- | +| FR-010-AC-1 | `createTtlCache` returns a stored value before expiry and `undefined` after the injected clock advances past `ttlMs`. | Test (TC-042) | +| FR-010-AC-2 | A `max`-bounded cache evicts the oldest entry once `size` would exceed `max`. | Test (TC-043) | +| FR-010-AC-3 | A second `search` with identical parameters within the TTL returns the cached response and issues no further `HttpFetcher` call. | Test (TC-044) | +| FR-010-AC-4 | `invalidate(opts)` forces the next identical `search` to re-issue backend requests; `invalidate()` clears all entries. | Test (TC-045) | +| FR-010-AC-5 | A function-valued `githubToken` is resolved per call, so a token supplied after construction is applied to the next search and does not return a stale cached entry keyed under the previous token-id. | Test (TC-046) | +| FR-010-AC-6 | A `SearchResponse` carrying any `SearchBackendError` is not cached; the next identical `search` re-issues backend requests. | Test (TC-061) | +| FR-010-AC-7 | Toggling verifier presence (or token identity) for the same `tag | query | sources | limit` produces a distinct cache entry, not a cross-shape hit. | Test (TC-062) | +| FR-010-AC-8 | Two distinct non-empty resolved tokens produce distinct cache entries (no cross-token hit), and the raw token value never appears in the key. | Test (TC-065) | +| FR-010-AC-9 | The cache is bounded by `cacheMax` (default 256), evicting the oldest entry once `size` would exceed the bound; an explicit `cacheMax` is honored. | Test (TC-066, TC-070) | +| FR-010-AC-10 | A cache hit returns a distinct (cloned) `SearchResponse`, not the shared cached reference. | Test (TC-069) | + +## Dependencies + +- Implements [US-003](../usecase/US-003-discover-plugins-by-tag.md). +- Requires [FR-008](./FR-008-candidate-search.md) (the responses it caches). +- Cooperates with [FR-011](./FR-011-github-rate-limit.md) (rate state held alongside + the cache). diff --git a/spec/functional/FR-011-github-rate-limit.md b/spec/functional/FR-011-github-rate-limit.md new file mode 100644 index 0000000..8e0d37e --- /dev/null +++ b/spec/functional/FR-011-github-rate-limit.md @@ -0,0 +1,81 @@ +--- +id: FR-011 +title: "GitHub Rate-Limit Surfacing and Short-Circuit" +type: FR +relationships: + - target: "ix://agent-ix/ts-plugin-kit/spec/usecase/US-003" + type: "implements" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-008" + type: "requires" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-010" + type: "requires" + cardinality: "1:1" +--- + +# [FR-011] GitHub Rate-Limit Surfacing and Short-Circuit + +## Description + +The library SHALL surface GitHub rate-limit state — reading the rate-limit response +headers into a structured `RateLimit`, reporting an exhausted window as a +`SearchBackendError` rather than a thrown error, and short-circuiting further GitHub +requests through `createPluginSearch` while the window stays exhausted. The library +SHALL leave all retry timing to the host, never sleeping or auto-retrying on its +own. The individual rules are specified under Behavior below. + +## Inputs + +- The GitHub response headers `x-ratelimit-limit`, `x-ratelimit-remaining`, and + `x-ratelimit-reset`. +- The rate state retained by `createPluginSearch` (see + [FR-010](./FR-010-discovery-cache.md)). + +## Outputs + +- `SearchResponse.rate.github`: `{ limit, remaining, resetAt }`, where `resetAt` is + the epoch-**seconds** value from `x-ratelimit-reset` (compared against + `clock.now()/1000`, which is milliseconds). +- A `SearchBackendError` with `rateLimited: true` and the `resetAt` seconds value + when the window is exhausted. +- `PluginSearch.lastRate()`: the most recent per-backend `RateLimit` snapshot, or an + empty object before any GitHub response has been seen. + +## Behavior + +- The library SHALL parse the three rate-limit headers from a GitHub response + (present on both 200 and 403) into `rate.github`, storing `resetAt` as the raw + epoch-seconds integer. +- Where any of the three rate-limit headers is absent or parses to a non-finite + number, the library SHALL treat the response as carrying no rate info and leave + `rate.github` undefined rather than surfacing a `NaN` field. +- On the first search, when no prior `lastRate().github` snapshot exists, the + library SHALL issue the GitHub request normally (no short-circuit). +- When a GitHub response is `403` or `429` with `remaining === 0`, the library + SHALL emit a `SearchBackendError` carrying `rateLimited: true` and the reset + time rather than throwing. +- While `lastRate().github.remaining === 0` and `clock.now()/1000 < resetAt`, the + library SHALL skip the GitHub request from `createPluginSearch().search` and + return the rate-limited error for that backend. +- When the recorded reset time has passed, the library SHALL issue the GitHub + request again on the next search. +- The library SHALL NOT call any sleep or delay primitive, remaining a pure + function of its inputs and the injected clock. + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| FR-011-AC-1 | A GitHub `200` carrying rate-limit headers populates `rate.github` with `limit`, `remaining`, and `resetAt`. | Test (TC-047) | +| FR-011-AC-2 | A GitHub `403` with `remaining:0` yields a `SearchBackendError{rateLimited:true}` carrying the reset time, and no exception propagates. | Test (TC-048) | +| FR-011-AC-3 | After a window is recorded exhausted, the next `search` skips the GitHub request while the injected clock is before `resetAt`. | Test (TC-049) | +| FR-011-AC-4 | Once the injected clock advances past `resetAt` (seconds), the next `search` issues the GitHub request again. | Test (TC-050) | +| FR-011-AC-5 | On the first search with no prior rate snapshot, the GitHub request is issued (no short-circuit), and `lastRate()` is an empty object until a response is seen. | Test (TC-063) | +| FR-011-AC-6 | A rate-limit header that is absent or parses to a non-finite number yields no rate info (`rate.github` undefined), not a `NaN`-valued `RateLimit`. | Test (TC-068) | + +## Dependencies + +- Implements [US-003](../usecase/US-003-discover-plugins-by-tag.md). +- Requires [FR-008](./FR-008-candidate-search.md) (the GitHub backend it guards) and + [FR-010](./FR-010-discovery-cache.md) (the retained rate state). diff --git a/spec/functional/FR-012-source-to-install-input.md b/spec/functional/FR-012-source-to-install-input.md new file mode 100644 index 0000000..3cbb648 --- /dev/null +++ b/spec/functional/FR-012-source-to-install-input.md @@ -0,0 +1,57 @@ +--- +id: FR-012 +title: "Render a Source as a Host Install-Input String" +type: FR +relationships: + - target: "ix://agent-ix/ts-plugin-kit/spec/usecase/US-003" + type: "implements" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-001" + type: "requires" + cardinality: "1:1" +--- + +# [FR-012] Render a Source as a Host Install-Input String + +## Description + +The library SHALL export `sourceToInstallInput(source)` which renders a typed +[`Source`](./FR-001-typed-source-union.md) as its single **canonical source +string** — the same token form a host's string-based install entry point accepts — +so a discovery result can flow into an existing install path without a parallel +install API. The kit makes no assumption about the host's UI; the name reflects the +common host shape (a string install field), not a required one. + +**Known limitation.** A `git-subdir` source is **lossy** under this rendering: the +returned `url` omits the `path` subdir, because a single token cannot express both. +Hosts that must preserve a subdir SHOULD pass the structured `Source` rather than +the rendered string. + +## Inputs + +- A normalized `Source` (any variant of the union). + +## Outputs + +- A string: the install token for that source. + +## Behavior + +- For an `npm` source the library SHALL return the `package`. +- For a `github` source the library SHALL return the `owner/repo` value. +- For a `git` or `url` source the library SHALL return the `url`. +- For a `path` source the library SHALL return the `path`. +- For a `git-subdir` source the library SHALL return the `url` (the subdir is not + expressible as a single install token). + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ----------- | -------------------------------------------------------------------------------------------- | ------------- | +| FR-012-AC-1 | An `npm` source renders to its `package`, and a `github` source renders to its `owner/repo`. | Test (TC-051) | +| FR-012-AC-2 | `git`/`url` sources render to their `url`, and a `path` source renders to its `path`. | Test (TC-052) | + +## Dependencies + +- Implements [US-003](../usecase/US-003-discover-plugins-by-tag.md). +- Requires [FR-001](./FR-001-typed-source-union.md) (the `Source` union it renders). diff --git a/spec/functional/index.md b/spec/functional/index.md index 8dd36a5..0214ec4 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -15,3 +15,8 @@ description: "Index of artifacts in this directory." - [FR-005: Install Registry: Read, Atomic Write, and Upsert](./FR-005-install-registry.md) - [FR-006: Single-Entry Install and Materialization](./FR-006-single-entry-install.md) - [FR-007: Default-Set Reconciliation (Lazy and Sync)](./FR-007-reconcile.md) +- [FR-008: Candidate Plugin Search Across npm and GitHub](./FR-008-candidate-search.md) +- [FR-009: Host-Driven Compatibility Verification of Candidates](./FR-009-compatibility-verification.md) +- [FR-010: TTL-Cached Discovery with an Injectable Clock](./FR-010-discovery-cache.md) +- [FR-011: GitHub Rate-Limit Surfacing and Short-Circuit](./FR-011-github-rate-limit.md) +- [FR-012: Render a Source as a Host Install-Input String](./FR-012-source-to-install-input.md) diff --git a/spec/non-functional/NFR-001-zero-runtime-dependencies.md b/spec/non-functional/NFR-001-zero-runtime-dependencies.md index fb51dfd..1acf780 100644 --- a/spec/non-functional/NFR-001-zero-runtime-dependencies.md +++ b/spec/non-functional/NFR-001-zero-runtime-dependencies.md @@ -10,6 +10,9 @@ relationships: - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-003" type: "constrains" cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-008" + type: "constrains" + cardinality: "1:1" --- ## Statement @@ -35,3 +38,7 @@ is delegated to the host ([FR-003](../functional/FR-003-manifest-validation.md)) built-in or a sibling `./*.js` module — no third-party package. - The build (`vite.config.ts`) externalizes only `node:` built-ins and optional peer packages that the library does not actually import at runtime. +- The discovery surface ([FR-008](../functional/FR-008-candidate-search.md)) reaches + the network through the Node global `fetch` referenced by `defaultHttpFetcher`, not + an imported HTTP client, so it adds no runtime dependency; the built-ins list above + is not exhaustive of the network surface. diff --git a/spec/non-functional/NFR-003-synchronous-zero-git-hot-path.md b/spec/non-functional/NFR-003-synchronous-zero-git-hot-path.md index 0ebb57b..4f9a9f1 100644 --- a/spec/non-functional/NFR-003-synchronous-zero-git-hot-path.md +++ b/spec/non-functional/NFR-003-synchronous-zero-git-hot-path.md @@ -14,27 +14,35 @@ relationships: ## Statement -All operations SHALL be **synchronous**, with a per-source package-manager -subprocess — `git` (via `GitRunner`) and, for npm sources, `npm pack` + `tar` (via -`NpmFetcher`) — as the only external side effect; the library SHALL NOT introduce -async/Promise APIs or any other network calls. A `lazy` reconcile of an +The **resolution surface** — `resolveSource`, `installEntry`, and `reconcile` +(`resolve.ts` / `install.ts` / `reconcile.ts`) — SHALL be **synchronous**, with a +per-source package-manager subprocess — `git` (via `GitRunner`) and, for npm +sources, `npm pack` + `tar` (via `NpmFetcher`) — as its only external side effect, +and SHALL NOT introduce async/Promise APIs or any other network calls. A `lazy` +reconcile of an already-settled manifest SHALL perform **zero** git invocations so it is safe on a -per-CLI-invocation hot path. +per-CLI-invocation hot path. The separately-specified **discovery surface** +(`search.ts`) is the only asynchronous, networked part of the library and is bounded +behind the injectable `HttpFetcher` of +[NFR-005](./NFR-005-injectable-discovery-transport.md); it is excluded from this +constraint. ## Measurement and Evaluation | Metric | Target | Threshold | Method | | ---------------------------------------------------------------------- | ------ | --------- | ---------- | | Git invocations on a 2nd lazy reconcile of a settled manifest | 0 | 0 | Test | -| Promise-returning functions in the public API | 0 | 0 | Inspection | +| Promise-returning functions on the resolution surface | 0 | 0 | Inspection | | External side effects in `resolveSource` beyond `git`/`npm pack`+`tar` | 0 | 0 | Analysis | ## Verification - The reconcile test counts `GitRunner` calls; the second lazy reconcile of a settled manifest asserts the count is exactly `0` ([FR-007-AC-2](../functional/FR-007-reconcile.md)). -- Inspect the public API in `src/index.ts`: every exported function is synchronous - (no `async`, no `Promise<...>` return types). +- Inspect the resolution surface in `src/index.ts`: every exported resolve/install/ + reconcile function is synchronous (no `async`, no `Promise<...>` return types). The + discovery exports (`searchPlugins`, `createPluginSearch`) are intentionally async + and out of scope here (NFR-005). - `resolveSource` performs only filesystem reads/dir creation and the per-source package-manager subprocess (`git`; or `npm pack` + `tar` for npm sources); with an injected fake `GitRunner` it resolves git sources with no real git diff --git a/spec/non-functional/NFR-005-injectable-discovery-transport.md b/spec/non-functional/NFR-005-injectable-discovery-transport.md new file mode 100644 index 0000000..2462ffe --- /dev/null +++ b/spec/non-functional/NFR-005-injectable-discovery-transport.md @@ -0,0 +1,69 @@ +--- +id: NFR-005 +title: "Injectable, Dependency-Free Discovery Transport" +type: NFR +quality_attribute: maintainability +relationships: + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-008" + type: "constrains" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-009" + type: "constrains" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-011" + type: "constrains" + cardinality: "1:1" +--- + +# [NFR-005] Injectable, Dependency-Free Discovery Transport + +## Statement + +The discovery surface SHALL perform all network access through a single injectable +`HttpFetcher` seam whose default delegates to the Node global `fetch`, so that the +toolkit adds no HTTP-client runtime dependency and every discovery test runs fully +offline against a supplied fake fetcher. Discovery is the only asynchronous, +networked surface in the library; it SHALL NOT introduce any other ambient side +effect. + +## Scope + +- Applies to: `searchPlugins`, `createPluginSearch`, and the manifest-fetching + verification path ([FR-008](../functional/FR-008-candidate-search.md), + [FR-009](../functional/FR-009-compatibility-verification.md), + [FR-011](../functional/FR-011-github-rate-limit.md)). +- Distinct from the synchronous, git-only resolution surface governed by + [NFR-003](./NFR-003-synchronous-zero-git-hot-path.md). + +## Rationale + +The pre-existing toolkit was synchronous with git as its sole side effect. Adding +discovery introduces async network I/O; bounding that I/O behind one injectable +seam keeps the zero-dependency guarantee +([NFR-001](./NFR-001-zero-runtime-dependencies.md)) intact, keeps the 100% coverage +gate ([NFR-002](./NFR-002-full-test-coverage.md)) achievable without live network, +and keeps results deterministic for tests. + +## Measurement and Evaluation + +| Metric | Target | Threshold | Method | +| ------------------------------------------------------------------- | ------ | --------- | ---------- | +| Direct transport calls bypassing the `HttpFetcher` seam in `src/**` | 0 | 0 | Inspection | +| Discovery tests requiring live network access | 0 | 0 | Test | +| New HTTP-client runtime dependencies in `package.json` | 0 | 0 | Inspection | + +## Verification + +- `tests/search.test.ts` injects a fake `HttpFetcher` (and `Clock`) for every + discovery case, so the suite hits no real npm, GitHub, unpkg, or + raw.githubusercontent endpoint. +- `package.json` `dependencies` stays empty; the default fetcher references the + global `fetch` rather than importing a client. + +## Dependencies + +- Upstream: [NFR-001](./NFR-001-zero-runtime-dependencies.md) and + [NFR-002](./NFR-002-full-test-coverage.md) (the guarantees this preserves). +- Constrains the discovery FRs [FR-008](../functional/FR-008-candidate-search.md), + [FR-009](../functional/FR-009-compatibility-verification.md), and + [FR-011](../functional/FR-011-github-rate-limit.md). diff --git a/spec/non-functional/index.md b/spec/non-functional/index.md index 4165d6d..b3c8616 100644 --- a/spec/non-functional/index.md +++ b/spec/non-functional/index.md @@ -12,3 +12,4 @@ description: "Index of artifacts in this directory." - [NFR-002: One-Hundred-Percent Enforced Test Coverage](./NFR-002-full-test-coverage.md) - [NFR-003: Synchronous Resolution with a Zero-Git Settled Hot Path](./NFR-003-synchronous-zero-git-hot-path.md) - [NFR-004: Cache and Target Directory Isolation](./NFR-004-cache-target-isolation.md) +- [NFR-005: Injectable, Dependency-Free Discovery Transport](./NFR-005-injectable-discovery-transport.md) diff --git a/spec/reviews/base.md b/spec/reviews/base.md new file mode 100644 index 0000000..c165ac2 --- /dev/null +++ b/spec/reviews/base.md @@ -0,0 +1,27 @@ +--- +id: SR-001 +title: "base checklist review of plugin-discovery (US-003, FR-008..012, NFR-005) — 2026-06-27" +type: SpecReview +analysis: base +scope: "spec/usecase/US-003; spec/functional/FR-008..FR-012; spec/non-functional/NFR-005; spec/spec.md; spec/tests.md" +review_set: subset +--- + +## Summary + +Base checklist review of the plugin-discovery requirements: ID formats, US/FR/AC +quality and testability, traceability, and the six coverage rules. IDs, links, and +AC→TC mapping are sound; the substantive items are a few non-testable phrasings and +a US-versus-AC consistency nit. Deeper findings are recorded in the failure-domain, +ears-conformance, and scope-boundary companion reviews. + +## Findings + +| ID | Severity | Summary | Refs | +| ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | +| FND-001 | low | ID formats (US/FR/NFR/AC/CON/TC) all conform; relationship links resolve; quire validate exits 0. | US-003, FR-008..012, NFR-005 | +| FND-002 | medium | "SHALL bound manifest-fetch concurrency" names no concrete limit, so it is not testable as written. | FR-009 | +| FND-003 | medium | `SearchOptions.signal` (cancellation) is an input with no behavioral AC; an unexercised field risks the 100% gate. | FR-008 | +| FND-004 | low | US-003 records illustrative `US-003-EX-*` examples while US-002 uses `US-002-AC-*`; matrix maps EX→TC, which is consistent but mixes the two conventions across user stories. | US-003, spec/tests.md | +| FND-005 | low | Discovery TCs (TC-022..042) are correctly marked 🚧 Planned (forward-spec); AC→TC mapping is 100% so the matrix is complete as a plan, with execution coverage gated by NFR-002 once `tests/search.test.ts` lands. | spec/tests.md | +| FND-006 | low | All six coverage rules are represented for discovery (option permutations, constraint boundary FR-008-CON-1, error paths, the rate-limit state transition, and edge cases EC-006..009). | spec/tests.md | diff --git a/spec/reviews/ears-conformance.md b/spec/reviews/ears-conformance.md new file mode 100644 index 0000000..aaafa16 --- /dev/null +++ b/spec/reviews/ears-conformance.md @@ -0,0 +1,31 @@ +--- +id: SR-003 +title: "ears-conformance review of plugin-discovery (FR-008..012, NFR-005) — 2026-06-27" +type: SpecReview +analysis: ears-conformance +scope: "spec/functional/FR-008..FR-012; spec/non-functional/NFR-005" +review_set: subset +--- + +## Summary + +EARS requirement-grammar review of the discovery FRs. The two warnings `quire +validate` emits (FR-011 lines 24 and 46) are tokenizer noise from an em-dash +parenthetical and inline-code operator spans (`403`/`remaining === 0`) over +statements that are actually atomic — no rewrite required. The material items are +several **validator-missed** multi-`shall` bullets that should be split for +atomicity so each maps cleanly to one AC. No vague-verb violations were found. + +## Findings + +| ID | Severity | Summary | Refs | +| ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| FND-001 | low | FR-011 line 24 (`SHALL surface … —` em-dash roll-up) is flagged unclassifiable but is an acceptable Description summary deferring atomic rules to Behavior; tokenizer noise. | FR-011 | +| FND-002 | low | FR-011 line 46 (`When … 403/429 … remaining === 0, the library SHALL emit …`) is a well-formed event statement; the flag is backtick/operator noise. | FR-011 | +| FND-003 | medium | FR-008 npm-backend bullet packs two `shall` (issue request + map results); split into a request statement and a mapping statement. | FR-008 | +| FND-004 | medium | FR-008 github-backend bullet packs two `shall` (issue request + map results); split likewise. | FR-008 | +| FND-005 | medium | FR-009 verify-result bullet packs two conditional rules and two `shall` joined by `;`; split into the `null`→drop and object→mark statements. | FR-009 | +| FND-006 | medium | FR-010 `createTtlCache` storage bullet packs three `shall` over three subjects (store / get-evict / max-evict); split into three bullets. | FR-010 | +| FND-007 | low | FR-010 cache-key bullet packs two `shall` (key + return-on-hit); split for atomicity. | FR-010 | +| FND-008 | low | FR-008 merge/dedupe/rank bullet is one `shall` with three responses (complex); optional split into merge/dedupe and ranking statements. | FR-008 | +| FND-009 | low | No vague-verb (support/handle/manage/process/provide/enable) violations found; FR-012 and NFR-005 statements are atomic and concrete. | FR-012, NFR-005 | diff --git a/spec/reviews/failure-domain.md b/spec/reviews/failure-domain.md new file mode 100644 index 0000000..1ebfc56 --- /dev/null +++ b/spec/reviews/failure-domain.md @@ -0,0 +1,34 @@ +--- +id: SR-002 +title: "failure-domain review of plugin-discovery (US-003, FR-008..012, NFR-005) — 2026-06-27" +type: SpecReview +analysis: failure-domain +scope: "spec/usecase/US-003; spec/functional/FR-008..FR-012; spec/non-functional/NFR-005; spec/tests.md" +review_set: subset +--- + +## Summary + +Failure-domain analysis of the discovery surface surfaced several unstated or +under-specified failure modes around manifest verification, error propagation, cache +poisoning, dedup determinism, and rate-limit state. The high-severity items +(absent-vs-unreachable manifest, a throwing host `verify`, and caching of error +responses) should be resolved before the feature is converted to a plan because each +would otherwise become a latent defect with no owning AC. + +## Findings + +| ID | Severity | Summary | Refs | +| ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | +| FND-001 | high | Manifest 404 (absent → incompatible) is conflated with a transient/unreachable fetch (unknown); both silently drop the candidate, discarding real plugins during a CDN blip with no error surfaced. | FR-009-AC-3 | +| FND-002 | high | Behavior when the host `verify(rawText)` callback **throws** (rather than returning `null`) is unspecified — it could sink the whole backend arm or escape `searchPlugins`. | FR-009-AC-1, FR-009-AC-2 | +| FND-003 | high | Whether a `SearchResponse` carrying `errors` or a rate-limited backend is cached is unspecified; caching it poisons the TTL and defeats the FR-011 "resume after `resetAt`" guarantee. | FR-010-AC-3, FR-011-AC-4 | +| FND-004 | medium | Malformed/partial search JSON (missing `objects[]`/`items[]`, absent `links.repository`/`author`/`stargazers_count`) has no defensive contract — undefined whether it throws or degrades. | FR-008-AC-1 | +| FND-005 | medium | Dedup match omits URL normalization (`.git`, trailing slash, `git+https`, case) and ranking is not a total order when `stars`/`updatedAt` are absent (the npm-preferred entry has no intrinsic stars), so ordering is non-deterministic. | FR-008-AC-5 | +| FND-006 | medium | "Bounded" verification concurrency names no value or mechanism and has no AC/TC, so it is neither verifiable nor enforced by the coverage gate. | FR-009 | +| FND-007 | medium | `SearchOptions.signal` cancellation has no behavioral FR/AC (propagation, partial-vs-throw, AbortError). | FR-008 | +| FND-008 | medium | The "all backends fail/rate-limited" outcome shape is undefined; a host cannot distinguish it from a legitimate empty-but-successful search. | FR-008, US-003-EX-3 | +| FND-009 | medium | Cache key `tag | query | sources | limit` omits verifier-presence and token identity, so a late-bound token (FR-010-AC-5) or a verifier toggle can return a stale/cross-shape cached set. | FR-010-AC-3, FR-010-AC-5 | +| FND-010 | low | Rate-limit first-call (no prior `lastRate()`), `resetAt` units (epoch seconds vs `clock.now()` ms), and stale `resetAt` in a long-lived process are under-pinned. | FR-011-AC-3 | +| FND-011 | low | No constraint forbids `githubToken` from appearing in `SearchBackendError` messages, cached values, or results. | FR-008, FR-011 | +| FND-012 | low | `limit` (default 20) maps to npm `size` (max 250) and GitHub `per_page` (max 100); large values risk a backend 422 with no clamping specified. | FR-008-AC-2 | diff --git a/spec/reviews/index.md b/spec/reviews/index.md new file mode 100644 index 0000000..eebdbe5 --- /dev/null +++ b/spec/reviews/index.md @@ -0,0 +1,14 @@ +--- +type: index +title: "Reviews" +description: "Index of artifacts in this directory." +--- + +# Reviews + +## Contents + +- [SR-001: base checklist review of plugin-discovery (2026-06-27)](./base.md) +- [SR-002: failure-domain review of plugin-discovery (2026-06-27)](./failure-domain.md) +- [SR-003: ears-conformance review of plugin-discovery (2026-06-27)](./ears-conformance.md) +- [SR-004: scope-boundary review of plugin-discovery (2026-06-27)](./scope-boundary.md) diff --git a/spec/reviews/scope-boundary.md b/spec/reviews/scope-boundary.md new file mode 100644 index 0000000..a882945 --- /dev/null +++ b/spec/reviews/scope-boundary.md @@ -0,0 +1,33 @@ +--- +id: SR-004 +title: "scope-boundary review of plugin-discovery (US-003, FR-008..012, NFR-003/005) — 2026-06-27" +type: SpecReview +analysis: scope-boundary +scope: "spec/spec.md; spec/usecase/US-003; spec/functional/FR-008..FR-012; spec/non-functional/NFR-001, NFR-003, NFR-005" +review_set: subset +--- + +## Summary + +Scope-boundary analysis. The discovery FRs draw the _plugin-type_ boundary well — +all manifest parsing stays in the host `CandidateVerifier`, `capabilities` is opaque +`unknown`, and the `tag` discriminator carries host semantics with no YAML/JSON +parser added. The defects are concentrated in the **top-level `spec.md` and NFR-003 +not being reconciled to the now-async discovery surface** (a genuine internal +contradiction), plus the npm/GitHub/unpkg endpoint knowledge narrowing the kit's +genericity without being acknowledged in Scope. The high-severity items are factual +contradictions and must be fixed before planning. + +## Findings + +| ID | Severity | Summary | Refs | +| ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| FND-001 | high | NFR-003 globally forbids what discovery does ("All operations SHALL be synchronous … SHALL NOT introduce async/Promise APIs or non-git network calls"; "Promise-returning functions: 0"); re-scope its subject to the resolution surface. | NFR-003, FR-008 | +| FND-002 | high | spec.md §3.1 still asserts "every operation is synchronous and `git` … is the only side effect" — now factually false. | spec.md §3.1 | +| FND-003 | high | spec.md §2.2 excludes "Asynchronous or networked transport beyond the synchronous `git` subprocess" — directly excludes the in-scope discovery feature. | spec.md §2.2 | +| FND-004 | medium | spec.md §2.1 headline still describes a purely "synchronous" toolkit and omits discovery. | spec.md §2.1 | +| FND-005 | medium | The two CDN manifest hosts (`unpkg.com`, `raw.githubusercontent.com`) are hardcoded and non-injectable while the search endpoints (`npmRegistry`, `githubApi`) are injectable — an inconsistent seam; and the kit now bakes in npm/GitHub/unpkg wire knowledge without Scope acknowledging the narrowed genericity. | FR-008, FR-009 | +| FND-006 | medium | §2.2 lacks the new host-concern exclusions the feature creates: token persistence, retry/backoff/sleep/debounce timing, and plugin publishing/topic-tagging. | spec.md §2.2 | +| FND-007 | medium | spec.md §5.3/§5.4 class narratives and §12 verification strategy omit the discovery FRs, NFR-005, and the `tests/search.test.ts` / fake `HttpFetcher`+`Clock` approach, leaving the prose inconsistent with the (updated) index/module tables. | spec.md §5, §12 | +| FND-008 | medium | FR-012 couples the public symbol/title to a host UI concept ("install field"/"install-input"); the agnostic framing is "render `Source` → canonical source string", and the `git-subdir` lossiness should be a documented limitation. | FR-012 | +| FND-009 | low | NFR-001 verification narrative enumerates allowed `node:*` built-ins but is silent on the discovery `fetch` global; add a line clarifying the default fetcher uses global `fetch` and adds no import. | NFR-001 | diff --git a/spec/spec.md b/spec/spec.md index 3611f65..fe6991f 100644 --- a/spec/spec.md +++ b/spec/spec.md @@ -55,9 +55,12 @@ This document is the **top-level requirements artifact** for the repository. ### 2.1 In Scope -Scope: **A zero-dependency, synchronous, framework-agnostic toolkit for resolving -typed plugin sources, pinning them by ref/sha, recording installs in a registry, -and reconciling a manifest's default set into a host-owned target directory.** +Scope: **A zero-dependency, framework-agnostic toolkit for resolving typed plugin +sources, pinning them by ref/sha, recording installs in a registry, reconciling a +manifest's default set into a host-owned target directory, and discovering and +verifying candidate plugins published under a host-chosen tag.** The resolution and +reconcile surface is synchronous (git as its sole side effect); the discovery +surface is asynchronous network I/O confined behind a single injectable transport. This specification governs: @@ -85,6 +88,12 @@ pack`s + extracts the tarball under `/npm/` and pins the resolve only what is missing or repinned, **zero git when settled**) and a `sync` path (re-resolve all, detect drift), returning `{installed, unchanged, updated, skipped}`; `defaultEnabled:false` entries are skipped ([FR-007](./functional/FR-007-reconcile.md)). +- **Plugin discovery** — `searchPlugins` / `createPluginSearch`: candidate search + across npm `keywords:` and GitHub `topic:` through an injectable + `HttpFetcher`, host-driven compatibility verification against a CDN-fetched + manifest, a TTL cache with an injectable clock, GitHub rate-limit surfacing with + a short-circuit, and `sourceToInstallInput` to feed a host install field + ([FR-008](./functional/FR-008-candidate-search.md)–[FR-012](./functional/FR-012-source-to-install-input.md)). ### 2.2 Out of Scope @@ -104,7 +113,18 @@ This specification does not govern: - **The oclif / ix-cli-core adapter.** Cache-root derivation, the oclif `plugins:install` bridge, and runtime wiring live in `ix://agent-ix/ix-cli-core` (its FR-019), not here. -- **Asynchronous or networked transport** beyond the synchronous `git` subprocess. +- **Asynchronous or networked transport on the resolution surface.** Resolution, + install, and reconcile remain synchronous with `git` as the sole side effect; the + one networked surface is discovery (`search.ts`), confined behind the injectable + `HttpFetcher` ([NFR-005](./non-functional/NFR-005-injectable-discovery-transport.md)). +- **Manifest parsing and compatibility semantics during discovery.** The host + supplies the discriminator `tag` and a `CandidateVerifier` whose callback parses + and judges each manifest; the library fetches raw manifest text but never parses it + ([FR-009](./functional/FR-009-compatibility-verification.md)). +- **Credential storage, retry/backoff/sleep/debounce timing, and publishing or + topic-tagging of plugin repositories.** Discovery accepts a `githubToken` but never + persists it, surfaces rate limits without sleeping or retrying, and does not publish + packages or tag repos — those are host concerns ([FR-011](./functional/FR-011-github-rate-limit.md)). --- @@ -114,9 +134,12 @@ This specification does not govern: `@agent-ix/ts-plugin-kit` is a single publishable TypeScript library (npm package `@agent-ix/ts-plugin-kit`) with **zero runtime dependencies**. It exposes the -building blocks below as pure ES-module functions; every operation is synchronous -and the only side effect is a per-source package-manager subprocess (via -`execFileSync`): `git` for git sources, and `npm pack` + `tar` for `npm` sources. +building blocks below as ES-module functions. The resolution/install/reconcile +surface is synchronous with a per-source package-manager subprocess (via +`execFileSync`) as its only side effect — `git` for git sources, and `npm pack` + +`tar` for `npm` sources; the discovery surface (`search.ts`) is asynchronous and +performs all network I/O through the injectable `HttpFetcher` (default: the Node +global `fetch`, so no dependency is added). | Module | Responsibility | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | @@ -126,6 +149,7 @@ and the only side effect is a per-source package-manager subprocess (via | `registry.ts` | `InstalledPlugin`, `PluginRegistry`, `readRegistry`, `writeRegistry`, `upsertPlugin` | | `install.ts` | `InstallOptions`, `installEntry` | | `reconcile.ts` | `ReconcileOptions`, `ReconcileResult`, `reconcile` | +| `search.ts` | `HttpFetcher`, `searchPlugins`, `createTtlCache`, `createPluginSearch`, `sourceToInstallInput`, discovery result/option types | | `index.ts` | The public barrel re-exporting the above | ### 3.2 Intended Users @@ -171,13 +195,17 @@ installing an ad-hoc, unnamed source. Testable behavioral contracts for source typing/validation, URL shorthand expansion, manifest validation, source resolution, the install registry, -single-entry install, and default-set reconciliation. +single-entry install, default-set reconciliation, and plugin discovery (candidate +search, host-driven compatibility verification, TTL caching, GitHub rate-limit +surfacing, and source-to-install-string rendering). ### 5.4 Non-Functional Requirements (`NFR-XXX`) Quality constraints: zero runtime dependencies, 100% enforced test coverage, -synchronous git-as-sole-side-effect with a zero-git settled hot path, and -cache/target directory isolation. +synchronous git-as-sole-side-effect on the resolution surface with a zero-git +settled hot path, cache/target directory isolation, and an injectable, +dependency-free discovery transport (NFR-005) that keeps the one async/networked +surface offline-testable. --- @@ -204,6 +232,7 @@ sequence (no classifier prefix). | [StR-003](./stakeholder/StR-003-fast-reconciliation.md) | Fast Per-Invocation Reconciliation | | [US-001](./usecase/US-001-reconcile-default-set.md) | Reconcile a Default Plugin Set | | [US-002](./usecase/US-002-install-ad-hoc-source.md) | Install an Ad-Hoc Source and Derive Its Name | +| [US-003](./usecase/US-003-discover-plugins-by-tag.md) | Discover and Verify Publishable Plugins by Tag | | [FR-001](./functional/FR-001-typed-source-union.md) | Typed Source Union and Structural Validation | | [FR-002](./functional/FR-002-git-url-shorthand.md) | Git URL Shorthand Expansion | | [FR-003](./functional/FR-003-manifest-validation.md) | Marketplace Manifest Validation | @@ -211,10 +240,16 @@ sequence (no classifier prefix). | [FR-005](./functional/FR-005-install-registry.md) | Install Registry: Read, Atomic Write, and Upsert | | [FR-006](./functional/FR-006-single-entry-install.md) | Single-Entry Install and Materialization | | [FR-007](./functional/FR-007-reconcile.md) | Default-Set Reconciliation (Lazy and Sync) | +| [FR-008](./functional/FR-008-candidate-search.md) | Candidate Plugin Search Across npm and GitHub | +| [FR-009](./functional/FR-009-compatibility-verification.md) | Host-Driven Compatibility Verification of Candidates | +| [FR-010](./functional/FR-010-discovery-cache.md) | TTL-Cached Discovery with an Injectable Clock | +| [FR-011](./functional/FR-011-github-rate-limit.md) | GitHub Rate-Limit Surfacing and Short-Circuit | +| [FR-012](./functional/FR-012-source-to-install-input.md) | Render a Source as a Host Install-Input String | | [NFR-001](./non-functional/NFR-001-zero-runtime-dependencies.md) | Zero Runtime Dependencies | | [NFR-002](./non-functional/NFR-002-full-test-coverage.md) | One-Hundred-Percent Enforced Test Coverage | | [NFR-003](./non-functional/NFR-003-synchronous-zero-git-hot-path.md) | Synchronous Resolution with a Zero-Git Settled Hot Path | | [NFR-004](./non-functional/NFR-004-cache-target-isolation.md) | Cache and Target Directory Isolation | +| [NFR-005](./non-functional/NFR-005-injectable-discovery-transport.md) | Injectable, Dependency-Free Discovery Transport | --- @@ -342,6 +377,11 @@ Bidirectional traceability SHALL be maintained between: reconcile including the zero-git settled path. - An injectable `GitRunner` lets resolution tests run without a real network and assert the exact git argv. +- Discovery is verified in `tests/search.test.ts` with an injected fake + `HttpFetcher` and `Clock`, so the suite exercises candidate search, compatibility + verification, TTL caching, and GitHub rate-limit handling fully offline and + deterministically — no real npm, GitHub, unpkg, or raw.githubusercontent access + ([NFR-005](./non-functional/NFR-005-injectable-discovery-transport.md)). - Coverage is gated at 100% (branches, functions, lines, statements) in `vite.config.ts` ([NFR-002](./non-functional/NFR-002-full-test-coverage.md)). diff --git a/spec/tests.md b/spec/tests.md index 41d1ec2..51866f8 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -14,15 +14,21 @@ This matrix covers `@agent-ix/ts-plugin-kit`, the framework-agnostic plugin / marketplace install toolkit (typed sources, ref/sha pinning, install registry, and lazy/sync default-set reconciliation). -This is a **backsync** matrix: the implementation and its tests already exist. -Every Test Case (TC) below references a real test in `tests/index.test.ts` by its -`describe` / `test` string. No TC is aspirational — each maps to code that runs -today under `make test` (vitest) at the 100% coverage gate (NFR-002). - -> **Concurrent-PR note.** TC-022…TC-054 are a shared TC-ID block also claimed by -> the concurrent `feat/plugin-discovery` PR. This (`feat/npm-source-resolution`) -> PR lands first and uses TC-022…TC-028 for npm source resolution; the two -> matrices will be **reconciled at the second merge** so the IDs do not collide. +Most of this matrix is a **backsync**: TC-001…TC-031 reference a real test in +`tests/index.test.ts` by its `describe` / `test` string — each maps to code that +runs today under `make test` (vitest) at the 100% coverage gate (NFR-002). TC-001… +TC-021 cover the original source/registry/install/reconcile surface; TC-022…TC-028 +cover npm source resolution; and TC-029…TC-031 are the git argv-injection guards. + +The **plugin-discovery** rows (US-003, FR-008…FR-012, NFR-005; TC-032…TC-070) +describe `tests/search.test.ts`, the discovery suite driven entirely by an injected +fake `HttpFetcher` + `Clock` (no real network). The 100% coverage gate (NFR-002) +forces every branch of `src/search.ts` to be exercised. + +> **Merge note.** The discovery TCs were renumbered from their original TC-022… +> TC-060 block to TC-032…TC-070 (a uniform +10 shift) so they sit above main's +> npm/git-security block (TC-022…TC-031) without colliding, reconciled at the +> discovery merge. Tests fall into the following types: @@ -42,9 +48,10 @@ is reserved and verified only via its `UnsupportedSourceError` (TC-007). ## Test Files -| File | Primary requirements | -| --------------------- | ------------------------ | -| `tests/index.test.ts` | FR-001 … FR-007, NFR-003 | +| File | Primary requirements | +| ---------------------- | ---------------------------------- | +| `tests/index.test.ts` | FR-001 … FR-007, NFR-003 | +| `tests/search.test.ts` | FR-008 … FR-012, NFR-005 (planned) | > `tests/scripts.test.ts` covers the repo's `scripts/` build tooling, not the > library surface, and is out of scope for this requirements matrix. @@ -66,79 +73,122 @@ partial clones work offline. ## User Story Coverage -| User Story | Acceptance Criteria | Test Cases | Coverage Status | -| ---------- | -------------------------------------------- | ---------- | --------------- | -| US-001 | US-001-AC-1 (install enabled, skip disabled) | TC-018 | ✅ Unit | -| US-001 | US-001-AC-2 (2nd reconcile zero git) | TC-018 | ✅ Unit | -| US-001 | US-001-AC-3 (sync detects a moved pin) | TC-019 | ✅ Unit | -| US-002 | US-002-AC-1 (derive name via readName) | TC-015 | ✅ Unit | -| US-002 | US-002-AC-2 (explicit name wins) | TC-014 | ✅ Unit | -| US-002 | US-002-AC-3 (symlink materialization) | TC-017 | ✅ Unit | +| User Story | Acceptance Criteria | Test Cases | Coverage Status | +| ---------- | ---------------------------------------------- | -------------- | --------------- | +| US-001 | US-001-AC-1 (install enabled, skip disabled) | TC-018 | ✅ Unit | +| US-001 | US-001-AC-2 (2nd reconcile zero git) | TC-018 | ✅ Unit | +| US-001 | US-001-AC-3 (sync detects a moved pin) | TC-019 | ✅ Unit | +| US-002 | US-002-AC-1 (derive name via readName) | TC-015 | ✅ Unit | +| US-002 | US-002-AC-2 (explicit name wins) | TC-014 | ✅ Unit | +| US-002 | US-002-AC-3 (symlink materialization) | TC-017 | ✅ Unit | +| US-003 | US-003-EX-1 (find candidates by tag) | TC-032 | 🚧 Planned | +| US-003 | US-003-EX-2 (keep only compatible) | TC-037, TC-038 | 🚧 Planned | +| US-003 | US-003-EX-3 (one backend fails, other returns) | TC-034 | 🚧 Planned | --- ## Functional Requirement Coverage -| Functional Req | Acceptance Criteria | Test Case · Case String | Coverage Status | -| -------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| FR-001 | AC-1: all six valid source shapes accepted | TC-001 — `normalizeSource › "accepts every valid shape"` | ✅ Unit | -| FR-001 | AC-2: null / no-`type` → SourceError | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | -| FR-001 | AC-3: missing required field → SourceError naming the field | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | -| FR-001 | AC-4: unknown type → SourceError "unknown source type" | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | -| FR-002 | AC-1: `owner/repo` → GitHub https URL | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-2: `owner/repo.git` strips trailing `.git` | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-3: full `https://` URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-4: `git@…` scp-style URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | -| FR-002 | AC-5: surrounding whitespace is trimmed | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` (padded-input assertion) | ✅ Unit | -| FR-003 | AC-1: valid manifest with name round-trips | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | -| FR-003 | AC-2: missing name → `name === undefined` | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | -| FR-003 | AC-3: null / non-object manifest → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-003 | AC-4: bad schemaVersion / non-array entries → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-003 | AC-5: null entry / missing entry name → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-003 | AC-6: invalid entry source → SourceError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | -| FR-004 | AC-1: path source returns the dir | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | -| FR-004 | AC-2: missing path → SourceError | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | -| FR-004 | AC-3: url → UnsupportedSourceError | TC-007 — `resolveSource › "url sources are not yet supported"` | ✅ Unit | -| FR-004 | AC-4: git-subdir sparse-checkout at tag → dir/sha/ref | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | -| FR-004 | AC-5: whole-repo HEAD when unpinned; re-fetch existing cache | TC-009 — `resolveSource › "whole-repo git resolves to HEAD when unpinned, and re-fetches an existing cache"` | ✅ Unit (git) | -| FR-004 | AC-6: sha pin checks out the exact commit | TC-010 — `resolveSource › "sha pin checks out the exact commit"` | ✅ Unit (git) | -| FR-004 | AC-7: github + injected runner needs no real git | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | -| FR-004 | CON-1: git is the sole side effect | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | -| FR-004 | CON-2: blobless + sparse (subdir only) | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | -| FR-004 | AC-8: npm resolves+extracts+pins resolved version (fake fetcher) | TC-022 — `resolveSource › "npm source downloads, extracts, and pins the resolved version"` + `"… resolves via the default fetcher when none is injected"` | ✅ Unit (fake/npm) | -| FR-004 | AC-9: exact-version pin cached; unpinned re-fetches | TC-023 — `resolveSource › "exact-version npm pins are cached; unpinned specs re-fetch"` | ✅ Unit (fake) | -| FR-004 | AC-10: defaultNpmFetcher local-pack offline (npm pack + tar) | TC-024 — `resolveSource › "defaultNpmFetcher packs and extracts a local package offline"` | ✅ Unit (npm) | -| FR-004 | AC-11: npmPackArgs builds pinned/unpinned + registry argv | TC-025 — `resolveSource › "npmPackArgs builds pinned, unpinned, and registry argv"` | ✅ Unit | -| FR-004 | CON-3: rejects option-like npm package (injection guard) | TC-026 — `normalizeSource › "rejects malformed input"` (option-like `-x` package assertion) | ✅ Unit | -| FR-004 | AC-12: robust npm-pack-json parse (skip noise; metadata object) | TC-027 — `resolveSource › "parseNpmPackJson skips prepack noise and parses the trailing array"` (+ scans-past-empty / scans-past-numeric tests) | ✅ Unit | -| FR-004 | CON-4: descriptive SourceError on no-array/empty/invalid output | TC-027 — `resolveSource › "parseNpmPackJson throws SourceError on no-bracket output"` (+ empty-array / numeric / no-string-filename throw tests) | ✅ Unit | -| FR-004 | AC-13: unpinned re-fetch clears stale tarballs (cache hygiene) | TC-028 — `resolveSource › "unpinned re-fetch does not accumulate stale tarballs"` | ✅ Unit (fake) | -| FR-004 | AC-14 / CON-5: option-like git argv field rejected (injection guard) | TC-029 — `normalizeSource › "rejects option-like git argv fields (injection guard)"` | ✅ Unit | -| FR-004 | AC-15 / CON-5: leading-whitespace trim-bypass repo/url rejected | TC-030 — `normalizeSource › "rejects leading-whitespace option-like repo/url (trim bypass)"` | ✅ Unit | -| FR-004 | AC-16 / CON-5: option-like `git-subdir.path` rejected | TC-031 — `normalizeSource › "rejects option-like git-subdir path"` | ✅ Unit | -| FR-005 | AC-1: missing / shape-invalid (`{}`) registry read as empty | TC-012 — `registry › "missing and malformed files read as empty"` | ✅ Unit | -| FR-005 | AC-2: atomic write + nested-dir creation round-trips | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | -| FR-005 | AC-3: upsert replaces by name (count stays 1) | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | -| FR-006 | AC-1: named git-subdir entry materializes + records | TC-014 — `installEntry › "materializes a named git-subdir entry and records it"` | ✅ Unit (git) | -| FR-006 | AC-2: name derived via readName when absent | TC-015 — `installEntry › "derives the name via readName when the entry has none"` | ✅ Unit (git) | -| FR-006 | AC-3: entry.path against a whole-repo source | TC-016 — `installEntry › "honors entry.path against a whole-repo source"` | ✅ Unit (git) | -| FR-006 | AC-4: symlink mode; re-install replaces | TC-017 — `installEntry › "symlink mode links instead of copying, and re-install replaces"` | ✅ Unit (git) | -| FR-007 | AC-1: lazy installs enabled, skips disabled | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | -| FR-007 | AC-2: 2nd lazy reconcile → unchanged, zero git | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | -| FR-007 | AC-3: sync unchanged on stable ref; updated on moved pin | TC-019 — `reconcile › "sync re-resolves: unchanged on a stable ref, updated on a moved pin"` | ✅ Unit (git) | -| FR-007 | AC-4: lazy re-materializes when target dir is gone | TC-020 — `reconcile › "lazy re-materializes when the target dir is gone"` | ✅ Unit (git) | -| FR-007 | AC-5: lazy sha pin → unchanged when matches, updated when differs | TC-021 — `reconcile › "lazy honors a sha pin: unchanged when it matches, updated when it differs"` | ✅ Unit (git) | +| Functional Req | Acceptance Criteria | Test Case · Case String | Coverage Status | +| -------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| FR-001 | AC-1: all six valid source shapes accepted | TC-001 — `normalizeSource › "accepts every valid shape"` | ✅ Unit | +| FR-001 | AC-2: null / no-`type` → SourceError | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | +| FR-001 | AC-3: missing required field → SourceError naming the field | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | +| FR-001 | AC-4: unknown type → SourceError "unknown source type" | TC-002 — `normalizeSource › "rejects malformed input"` | ✅ Unit | +| FR-002 | AC-1: `owner/repo` → GitHub https URL | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-2: `owner/repo.git` strips trailing `.git` | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-3: full `https://` URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-4: `git@…` scp-style URL passes through | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` | ✅ Unit | +| FR-002 | AC-5: surrounding whitespace is trimmed | TC-003 — `"toGitUrl expands shorthand and passes through URLs"` (padded-input assertion) | ✅ Unit | +| FR-003 | AC-1: valid manifest with name round-trips | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | +| FR-003 | AC-2: missing name → `name === undefined` | TC-004 — `validateMarketplaceManifest › "accepts a valid manifest (with and without a name)"` | ✅ Unit | +| FR-003 | AC-3: null / non-object manifest → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-003 | AC-4: bad schemaVersion / non-array entries → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-003 | AC-5: null entry / missing entry name → ManifestError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-003 | AC-6: invalid entry source → SourceError | TC-005 — `validateMarketplaceManifest › "rejects malformed manifests and entries"` | ✅ Unit | +| FR-004 | AC-1: path source returns the dir | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | +| FR-004 | AC-2: missing path → SourceError | TC-006 — `resolveSource › "path source returns the dir; missing path throws"` | ✅ Unit | +| FR-004 | AC-3: url → UnsupportedSourceError | TC-007 — `resolveSource › "url sources are not yet supported"` | ✅ Unit | +| FR-004 | AC-4: git-subdir sparse-checkout at tag → dir/sha/ref | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | +| FR-004 | AC-5: whole-repo HEAD when unpinned; re-fetch existing cache | TC-009 — `resolveSource › "whole-repo git resolves to HEAD when unpinned, and re-fetches an existing cache"` | ✅ Unit (git) | +| FR-004 | AC-6: sha pin checks out the exact commit | TC-010 — `resolveSource › "sha pin checks out the exact commit"` | ✅ Unit (git) | +| FR-004 | AC-7: github + injected runner needs no real git | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | +| FR-004 | CON-1: git is the sole side effect | TC-011 — `resolveSource › "github source + injected runner needs no real git"` | ✅ Unit (fake) | +| FR-004 | CON-2: blobless + sparse (subdir only) | TC-008 — `resolveSource › "git-subdir sparse-checks out only the subdir at a tag"` | ✅ Unit (git) | +| FR-004 | AC-8: npm resolves+extracts+pins resolved version (fake fetcher) | TC-022 — `resolveSource › "npm source downloads, extracts, and pins the resolved version"` + `"… resolves via the default fetcher when none is injected"` | ✅ Unit (fake/npm) | +| FR-004 | AC-9: exact-version pin cached; unpinned re-fetches | TC-023 — `resolveSource › "exact-version npm pins are cached; unpinned specs re-fetch"` | ✅ Unit (fake) | +| FR-004 | AC-10: defaultNpmFetcher local-pack offline (npm pack + tar) | TC-024 — `resolveSource › "defaultNpmFetcher packs and extracts a local package offline"` | ✅ Unit (npm) | +| FR-004 | AC-11: npmPackArgs builds pinned/unpinned + registry argv | TC-025 — `resolveSource › "npmPackArgs builds pinned, unpinned, and registry argv"` | ✅ Unit | +| FR-004 | CON-3: rejects option-like npm package (injection guard) | TC-026 — `normalizeSource › "rejects malformed input"` (option-like `-x` package assertion) | ✅ Unit | +| FR-004 | AC-12: robust npm-pack-json parse (skip noise; metadata object) | TC-027 — `resolveSource › "parseNpmPackJson skips prepack noise and parses the trailing array"` (+ scans-past-empty / scans-past-numeric tests) | ✅ Unit | +| FR-004 | CON-4: descriptive SourceError on no-array/empty/invalid output | TC-027 — `resolveSource › "parseNpmPackJson throws SourceError on no-bracket output"` (+ empty-array / numeric / no-string-filename throw tests) | ✅ Unit | +| FR-004 | AC-13: unpinned re-fetch clears stale tarballs (cache hygiene) | TC-028 — `resolveSource › "unpinned re-fetch does not accumulate stale tarballs"` | ✅ Unit (fake) | +| FR-004 | AC-14 / CON-5: option-like git argv field rejected (injection guard) | TC-029 — `normalizeSource › "rejects option-like git argv fields (injection guard)"` | ✅ Unit | +| FR-004 | AC-15 / CON-5: leading-whitespace trim-bypass repo/url rejected | TC-030 — `normalizeSource › "rejects leading-whitespace option-like repo/url (trim bypass)"` | ✅ Unit | +| FR-004 | AC-16 / CON-5: option-like `git-subdir.path` rejected | TC-031 — `normalizeSource › "rejects option-like git-subdir path"` | ✅ Unit | +| FR-005 | AC-1: missing / shape-invalid (`{}`) registry read as empty | TC-012 — `registry › "missing and malformed files read as empty"` | ✅ Unit | +| FR-005 | AC-2: atomic write + nested-dir creation round-trips | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | +| FR-005 | AC-3: upsert replaces by name (count stays 1) | TC-013 — `registry › "write is atomic and round-trips; upsert replaces by name"` | ✅ Unit | +| FR-006 | AC-1: named git-subdir entry materializes + records | TC-014 — `installEntry › "materializes a named git-subdir entry and records it"` | ✅ Unit (git) | +| FR-006 | AC-2: name derived via readName when absent | TC-015 — `installEntry › "derives the name via readName when the entry has none"` | ✅ Unit (git) | +| FR-006 | AC-3: entry.path against a whole-repo source | TC-016 — `installEntry › "honors entry.path against a whole-repo source"` | ✅ Unit (git) | +| FR-006 | AC-4: symlink mode; re-install replaces | TC-017 — `installEntry › "symlink mode links instead of copying, and re-install replaces"` | ✅ Unit (git) | +| FR-007 | AC-1: lazy installs enabled, skips disabled | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | +| FR-007 | AC-2: 2nd lazy reconcile → unchanged, zero git | TC-018 — `reconcile › "lazy installs the enabled set, skips disabled, and is idempotent with zero git on the 2nd run"` | ✅ Unit (git) | +| FR-007 | AC-3: sync unchanged on stable ref; updated on moved pin | TC-019 — `reconcile › "sync re-resolves: unchanged on a stable ref, updated on a moved pin"` | ✅ Unit (git) | +| FR-007 | AC-4: lazy re-materializes when target dir is gone | TC-020 — `reconcile › "lazy re-materializes when the target dir is gone"` | ✅ Unit (git) | +| FR-007 | AC-5: lazy sha pin → unchanged when matches, updated when differs | TC-021 — `reconcile › "lazy honors a sha pin: unchanged when it matches, updated when it differs"` | ✅ Unit (git) | +| FR-008 | AC-1: both backends normalize to typed sources, merged + ranked | TC-032 — `searchPlugins › "merges npm + github candidates into one ranked list"` | 🚧 Planned (fake) | +| FR-008 | AC-2: URL-encoded `keywords:`/`topic:` queries; `limit` plumbing | TC-033 — `searchPlugins › "composes encoded npm size + github per_page queries"` | 🚧 Planned (fake) | +| FR-008 | AC-3: one backend rejects → other returns + SearchBackendError | TC-034 — `searchPlugins › "one backend failing still returns the other plus an error"` | 🚧 Planned (fake) | +| FR-008 | AC-4: Authorization header present iff token; `sources` filter | TC-035 — `searchPlugins › "adds Authorization only with a token; honors sources filter"` | 🚧 Planned (fake) | +| FR-008 | AC-5: npm/github of same project dedupe (npm preferred) | TC-036 — `searchPlugins › "dedupes an npm package against its github repo, preferring npm"` | 🚧 Planned (fake) | +| FR-008 | AC-6: all backends fail → results:[] + one error each | TC-053 — `searchPlugins › "returns empty results with one error per failed backend"` | 🚧 Planned (fake) | +| FR-008 | AC-7: malformed body → SearchBackendError; missing optionals undefined | TC-054 — `searchPlugins › "tolerates malformed payloads and missing optional fields"` | 🚧 Planned (fake) | +| FR-008 | AC-8: limit clamped (npm size ≤250, github per_page ≤100) | TC-055 — `searchPlugins › "clamps limit to each backend maximum"` | 🚧 Planned (fake) | +| FR-008 | AC-9: ranking is a total order (absent stars/updatedAt last) | TC-056 — `searchPlugins › "ranks deterministically with a fullName tie-break"` | 🚧 Planned (fake) | +| FR-008 | AC-10: signal passed to every fetch; abort → backend error | TC-057 — `searchPlugins › "propagates signal and surfaces an abort as a backend error"` | 🚧 Planned (fake) | +| FR-008 | CON-1: all network flows through the injectable HttpFetcher | TC-032 — `searchPlugins › "merges npm + github candidates into one ranked list"` (no real fetch) | 🚧 Planned (fake) | +| FR-008 | CON-2: githubToken never in cache key / error / result | TC-064 — `searchPlugins › "never leaks the github token into errors, keys, or results"` | 🚧 Planned (fake) | +| FR-009 | AC-1: verify-accepted candidate kept, verified + capabilities | TC-037 — `searchPlugins(verify) › "keeps a candidate whose verify returns capabilities"` | 🚧 Planned (fake) | +| FR-009 | AC-2: verify returns null → candidate dropped | TC-038 — `searchPlugins(verify) › "drops a candidate whose verify returns null"` | 🚧 Planned (fake) | +| FR-009 | AC-3: manifest fetch non-OK/reject → dropped, no verify call | TC-039 — `searchPlugins(verify) › "drops a candidate whose manifest fetch fails without calling verify"` | 🚧 Planned (fake) | +| FR-009 | AC-4: npm via unpkg, github via raw.githubusercontent HEAD | TC-040 — `searchPlugins(verify) › "fetches manifests from unpkg and raw.githubusercontent"` | 🚧 Planned (fake) | +| FR-009 | AC-5: no verifier → unfiltered, no manifest fetch | TC-041 — `searchPlugins › "skips verification entirely when no verifier is given"` | 🚧 Planned (fake) | +| FR-009 | AC-6: transient (non-404) fetch fail → drop + transient error | TC-058 — `searchPlugins(verify) › "drops on a transient fetch failure and records a transient error"` | 🚧 Planned (fake) | +| FR-009 | AC-7: verify throws → drop only that candidate, no escape | TC-059 — `searchPlugins(verify) › "isolates a throwing verify to its candidate"` | 🚧 Planned (fake) | +| FR-009 | AC-8: ≤6 manifest fetches in flight at once | TC-060 — `searchPlugins(verify) › "caps manifest-fetch concurrency at six"` | 🚧 Planned (fake) | +| FR-009 | AC-9 / CON-1: traversal/control-char name dropped, no fetch | TC-067 — `searchPlugins(verify) › "rejects path-traversal / control-char candidate names before fetching a manifest"` | ✅ Unit (fake) | +| FR-010 | AC-1: TTL cache returns value before expiry, undefined after | TC-042 — `createTtlCache › "returns before expiry, evicts after the clock advances"` | 🚧 Planned (fake) | +| FR-010 | AC-2: `max`-bounded cache evicts oldest | TC-043 — `createTtlCache › "evicts the oldest entry past max"` | 🚧 Planned (fake) | +| FR-010 | AC-3: cache hit within TTL issues no further fetch | TC-044 — `createPluginSearch › "serves an identical search from cache with no fetch"` | 🚧 Planned (fake) | +| FR-010 | AC-4: invalidate(opts) re-fetches; invalidate() clears all | TC-045 — `createPluginSearch › "invalidate forces a re-fetch"` | 🚧 Planned (fake) | +| FR-010 | AC-5: function-valued githubToken resolved per call | TC-046 — `createPluginSearch › "resolves a late-bound github token per call"` | 🚧 Planned (fake) | +| FR-010 | AC-6: error/rate-limited response is not cached | TC-061 — `createPluginSearch › "does not cache a response carrying errors"` | 🚧 Planned (fake) | +| FR-010 | AC-7: verifier/token-id discriminate cache entries | TC-062 — `createPluginSearch › "keys verifier-presence and token-id distinctly"` | 🚧 Planned (fake) | +| FR-010 | AC-8: distinct non-empty tokens → distinct cache entries | TC-065 — `createPluginSearch › "keys distinct non-empty tokens to distinct cache entries"` | ✅ Unit (fake) | +| FR-010 | AC-9: cache bounded by default cacheMax (256) + explicit override | TC-066, TC-070 — `createPluginSearch › "bounds the cache by a default max…" / "honors an explicit cacheMax override"` | ✅ Unit (fake) | +| FR-010 | AC-10: cache hit returns a distinct (cloned) response | TC-069 — `createPluginSearch › "returns a distinct response object on a cache hit"` | ✅ Unit (fake) | +| FR-011 | AC-1: 200 headers populate rate.github | TC-047 — `searchPlugins › "reads github rate-limit headers into rate.github"` | 🚧 Planned (fake) | +| FR-011 | AC-2: 403 remaining:0 → rateLimited error, no throw | TC-048 — `searchPlugins › "surfaces an exhausted github window as a rateLimited error"` | 🚧 Planned (fake) | +| FR-011 | AC-3: exhausted window short-circuits next github request | TC-049 — `createPluginSearch › "skips github while the window is exhausted"` | 🚧 Planned (fake) | +| FR-011 | AC-4: after resetAt passes, github request resumes | TC-050 — `createPluginSearch › "resumes github once the clock passes resetAt"` | 🚧 Planned (fake) | +| FR-011 | AC-5: first call (no prior snapshot) issues github; lastRate empty | TC-063 — `createPluginSearch › "does not short-circuit on the first call"` | 🚧 Planned (fake) | +| FR-011 | AC-6: non-finite rate header → no rate info (undefined) | TC-068 — `searchPlugins › "treats non-finite rate-limit headers as no rate info"` | ✅ Unit (fake) | +| FR-012 | AC-1: npm→package, github→owner/repo | TC-051 — `sourceToInstallInput › "renders npm and github sources"` | 🚧 Planned | +| FR-012 | AC-2: git/url→url, path→path | TC-052 — `sourceToInstallInput › "renders git/url and path sources"` | 🚧 Planned | --- ## Non-Functional Requirement Coverage -| Non-Functional Req | Verification Method | Evidence / Test Cases | Status | -| ------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------- | -| NFR-001 | Inspection + static grep: no `dependencies`, no non-`node:` import | `package.json` (no `dependencies` key); grep of `src/**` imports | ✅ Inspection | -| NFR-002 | Test: 100% coverage gate fails the build below threshold | `vite.config.ts` `test.coverage.thresholds = 100/100/100/100`; `make test` | ✅ Test | -| NFR-003 | Test: 2nd lazy reconcile issues zero git; API is synchronous | TC-018 (zero-git assertion) + TC-011 (no real git via fake runner); `src/index.ts` has no async export | ✅ Test | -| NFR-004 | Inspection + Analysis: distinct cache/target/registry paths | `InstallOptions` fields; per-case temp roots in the `opts()` helper (used by TC-006…TC-021) | ✅ Analysis | +| Non-Functional Req | Verification Method | Evidence / Test Cases | Status | +| ------------------ | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------- | +| NFR-001 | Inspection + static grep: no `dependencies`, no non-`node:` import | `package.json` (no `dependencies` key); grep of `src/**` imports | ✅ Inspection | +| NFR-002 | Test: 100% coverage gate fails the build below threshold | `vite.config.ts` `test.coverage.thresholds = 100/100/100/100`; `make test` | ✅ Test | +| NFR-003 | Test: 2nd lazy reconcile issues zero git; API is synchronous | TC-018 (zero-git assertion) + TC-011 (no real git via fake runner); `src/index.ts` has no async export | ✅ Test | +| NFR-004 | Inspection + Analysis: distinct cache/target/registry paths | `InstallOptions` fields; per-case temp roots in the `opts()` helper (used by TC-006…TC-021) | ✅ Analysis | +| NFR-005 | Inspection + Test: discovery network flows through one injectable `HttpFetcher`; suite runs offline | `package.json` (no `dependencies`); `tests/search.test.ts` injects a fake `HttpFetcher` + `Clock` (TC-032…TC-070) | 🚧 Planned | --- @@ -177,53 +227,109 @@ partial clones work offline. | TC-029 | normalizeSource rejects option-like git argv fields (`-x`) | Unit | P0 | FR-004-AC-14, -CON-5 | ✅ | | TC-030 | normalizeSource rejects leading-ws trim-bypass repo/url | Unit | P0 | FR-004-AC-15, -CON-5 | ✅ | | TC-031 | normalizeSource rejects option-like `git-subdir.path` | Unit | P0 | FR-004-AC-16, -CON-5 | ✅ | - -> TC-022…TC-028 belong to the shared TC-022…TC-054 block also used by the -> concurrent `feat/plugin-discovery` PR; IDs are reconciled at the second merge -> (this PR lands first). TC-029…TC-031 are the git argv-injection guard tests -> added by this branch, allocated after the npm block. +| TC-032 | searchPlugins merges npm + github into one ranked list | Unit (fake) | P0 | FR-008-AC-1, -CON-1, US-003-EX-1 | 🚧 | +| TC-033 | searchPlugins composes encoded queries + limit plumbing | Unit (fake) | P0 | FR-008-AC-2 | 🚧 | +| TC-034 | searchPlugins one backend fails, other returns + error | Unit (fake) | P0 | FR-008-AC-3, US-003-EX-3 | 🚧 | +| TC-035 | searchPlugins Authorization header + sources filter | Unit (fake) | P1 | FR-008-AC-4 | 🚧 | +| TC-036 | searchPlugins dedupes npm vs github (npm preferred) | Unit (fake) | P1 | FR-008-AC-5 | 🚧 | +| TC-037 | verify keeps candidate returning capabilities | Unit (fake) | P0 | FR-009-AC-1, US-003-EX-2 | 🚧 | +| TC-038 | verify drops candidate returning null | Unit (fake) | P0 | FR-009-AC-2, US-003-EX-2 | 🚧 | +| TC-039 | verify drops candidate when manifest fetch fails | Unit (fake) | P0 | FR-009-AC-3 | 🚧 | +| TC-040 | verify fetches manifests from unpkg / raw.githubusercontent | Unit (fake) | P1 | FR-009-AC-4 | 🚧 | +| TC-041 | no verifier → unfiltered, no manifest fetch | Unit (fake) | P1 | FR-009-AC-5 | 🚧 | +| TC-042 | createTtlCache returns before expiry, evicts after clock | Unit (fake) | P0 | FR-010-AC-1 | 🚧 | +| TC-043 | createTtlCache evicts oldest past max | Unit (fake) | P1 | FR-010-AC-2 | 🚧 | +| TC-044 | createPluginSearch cache hit issues no fetch | Unit (fake) | P0 | FR-010-AC-3 | 🚧 | +| TC-045 | createPluginSearch invalidate forces re-fetch | Unit (fake) | P1 | FR-010-AC-4 | 🚧 | +| TC-046 | createPluginSearch resolves late-bound token per call | Unit (fake) | P1 | FR-010-AC-5 | 🚧 | +| TC-047 | searchPlugins reads github rate-limit headers | Unit (fake) | P0 | FR-011-AC-1 | 🚧 | +| TC-048 | searchPlugins surfaces exhausted window as rateLimited error | Unit (fake) | P0 | FR-011-AC-2 | 🚧 | +| TC-049 | createPluginSearch short-circuits github while exhausted | Unit (fake) | P0 | FR-011-AC-3 | 🚧 | +| TC-050 | createPluginSearch resumes github past resetAt | Unit (fake) | P1 | FR-011-AC-4 | 🚧 | +| TC-051 | sourceToInstallInput renders npm + github sources | Unit | P1 | FR-012-AC-1 | 🚧 | +| TC-052 | sourceToInstallInput renders git/url + path sources | Unit | P1 | FR-012-AC-2 | 🚧 | +| TC-053 | searchPlugins all backends fail → empty + per-backend errors | Unit (fake) | P0 | FR-008-AC-6 | 🚧 | +| TC-054 | searchPlugins tolerates malformed payloads / missing optionals | Unit (fake) | P0 | FR-008-AC-7 | 🚧 | +| TC-055 | searchPlugins clamps limit to backend maxima | Unit (fake) | P1 | FR-008-AC-8 | 🚧 | +| TC-056 | searchPlugins deterministic total-order ranking | Unit (fake) | P1 | FR-008-AC-9 | 🚧 | +| TC-057 | searchPlugins propagates signal; abort → backend error | Unit (fake) | P1 | FR-008-AC-10 | 🚧 | +| TC-058 | verify drops on transient fetch fail + transient error | Unit (fake) | P0 | FR-009-AC-6 | 🚧 | +| TC-059 | verify throw isolated to its candidate | Unit (fake) | P0 | FR-009-AC-7 | 🚧 | +| TC-060 | verify caps manifest-fetch concurrency at six | Unit (fake) | P1 | FR-009-AC-8 | 🚧 | +| TC-061 | createPluginSearch does not cache an errored response | Unit (fake) | P0 | FR-010-AC-6 | 🚧 | +| TC-062 | createPluginSearch keys verifier-presence + token-id | Unit (fake) | P1 | FR-010-AC-7 | 🚧 | +| TC-063 | createPluginSearch first call issues github (no short-circuit) | Unit (fake) | P1 | FR-011-AC-5 | 🚧 | +| TC-064 | searchPlugins never leaks github token | Unit (fake) | P0 | FR-008-CON-2 | 🚧 | +| TC-065 | createPluginSearch keys distinct tokens to distinct entries | Unit (fake) | P0 | FR-010-AC-8, FR-008-CON-2 | ✅ | +| TC-066 | createPluginSearch bounds cache by default max (256) | Unit (fake) | P1 | FR-010-AC-9 | ✅ | +| TC-067 | verify drops traversal/control-char name before fetch | Unit (fake) | P0 | FR-009-AC-9, -CON-1 | ✅ | +| TC-068 | searchPlugins treats non-finite rate header as no rate info | Unit (fake) | P1 | FR-011-AC-6 | ✅ | +| TC-069 | createPluginSearch cache hit returns a distinct clone | Unit (fake) | P1 | FR-010-AC-10 | ✅ | +| TC-070 | createPluginSearch honors an explicit cacheMax override | Unit (fake) | P1 | FR-010-AC-9 | ✅ | + +> TC-022…TC-028 are npm source-resolution tests and TC-029…TC-031 are +> the git argv-injection guard tests; both blocks are 1:1 with tests in +> `tests/index.test.ts`. TC-032…TC-070 are the plugin-discovery tests in +> `tests/search.test.ts`, renumbered above main's block when the two PRs +> were reconciled at the discovery merge. --- ## Constraint Boundary Tests -| Constraint | Boundary / Case | Test Value | Test Case | Expected | -| ------------ | -------------------------------------------------- | --------------------------------------------------------------- | -------------- | -------------------------------------------------------------- | -| FR-004-CON-1 | package-manager subprocess is the sole side effect | injected fake `GitRunner` / `NpmFetcher` | TC-011, TC-022 | resolves with no real git/npm; argv[0]=`clone` | -| FR-004-CON-2 | blobless + sparse | `git-subdir` at `v0.2.0` | TC-008 | only the subdir present; tag sha resolved | -| FR-004-CON-3 | option-like npm package rejected | `{type:"npm", package:"-x"}` | TC-026 | `SourceError` "must not begin with -"; no `npm pack` | -| FR-004-CON-4 | no metadata array in `npm pack --json` output | `""`, `"[]"`, `"[1,2,3]"`, `'[{"name":"p"}]'` | TC-027 | `SourceError` "could not parse npm pack --json output" | -| FR-004-CON-5 | option-like git argv field | `repo`/`url`/`ref`/`sha` = `-x` (e.g. `ref: "--upload-pack=…"`) | TC-029 | `SourceError` "must not begin with `-`"; no `git` invocation | -| FR-004-CON-5 | trim-bypass (leading ws) | `repo`/`url` = `" --upload-pack=… ext://x"` | TC-030 | `SourceError` "must not begin with `-`"; trimmed value guarded | -| FR-004-CON-5 | option-like subdir path | `git-subdir.path` = `"--stdin"` / `"-X"` | TC-031 | `SourceError` "must not begin with `-`"; no `git` invocation | +| Constraint | Boundary / Case | Test Value | Test Case | Expected | +| ------------------ | -------------------------------------------------- | --------------------------------------------------------------- | -------------- | -------------------------------------------------------------- | +| FR-004-CON-1 | package-manager subprocess is the sole side effect | injected fake `GitRunner` / `NpmFetcher` | TC-011, TC-022 | resolves with no real git/npm; argv[0]=`clone` | +| FR-004-CON-2 | blobless + sparse | `git-subdir` at `v0.2.0` | TC-008 | only the subdir present; tag sha resolved | +| FR-004-CON-3 | option-like npm package rejected | `{type:"npm", package:"-x"}` | TC-026 | `SourceError` "must not begin with -"; no `npm pack` | +| FR-004-CON-4 | no metadata array in `npm pack --json` output | `""`, `"[]"`, `"[1,2,3]"`, `'[{"name":"p"}]'` | TC-027 | `SourceError` "could not parse npm pack --json output" | +| FR-004-CON-5 | option-like git argv field | `repo`/`url`/`ref`/`sha` = `-x` (e.g. `ref: "--upload-pack=…"`) | TC-029 | `SourceError` "must not begin with `-`"; no `git` invocation | +| FR-004-CON-5 | trim-bypass (leading ws) | `repo`/`url` = `" --upload-pack=… ext://x"` | TC-030 | `SourceError` "must not begin with `-`"; trimmed value guarded | +| FR-004-CON-5 | option-like subdir path | `git-subdir.path` = `"--stdin"` / `"-X"` | TC-031 | `SourceError` "must not begin with `-`"; no `git` invocation | +| FR-008-CON-1 | network via injectable seam | injected fake `HttpFetcher` | TC-032 | results returned with no real network call | +| FR-008-CON-2 | token never leaks | `githubToken` set + error/cache path | TC-064 | token absent from key, error, and result | +| FR-009-CON-1 | name path-traversal / ctrl | `name: "../evil"`, ctrl char | TC-067 | candidate dropped; no manifest URL fetched | +| FR-009 concurrency | verification fan-out | 12 candidates, verifier set | TC-060 | ≤ 6 concurrent manifest fetches | +| FR-008 limit | clamp above max | `limit: 9999` | TC-055 | npm `size`=250, github `per_page`=100 | --- ## Error-Path Coverage -| Error | Trigger | Test Case | Status | -| ---------------------------- | ------------------------------------------------------------------ | --------- | ------ | -| `SourceError` | null / no-`type` / missing field / unknown type | TC-002 | ✅ | -| `SourceError` | `path` source dir does not exist | TC-006 | ✅ | -| `SourceError` | option-like (`-`) `repo`/`url`/`ref`/`sha` (argv injection guard) | TC-029 | ✅ | -| `SourceError` | leading-whitespace trim-bypass `repo`/`url` (argv injection guard) | TC-030 | ✅ | -| `SourceError` | option-like (`-`) `git-subdir.path` (argv injection guard) | TC-031 | ✅ | -| `UnsupportedSourceError` | `url` passed to `resolveSource` (npm now resolves) | TC-007 | ✅ | -| `ManifestError` | non-object / bad schemaVersion / non-array entries | TC-005 | ✅ | -| `ManifestError` | null entry / entry missing a non-empty `name` | TC-005 | ✅ | -| `SourceError` (via manifest) | entry with an invalid `source.type` | TC-005 | ✅ | +| Error | Trigger | Test Case | Status | +| -------------------------------- | ------------------------------------------------------------------ | --------- | ------ | +| `SourceError` | null / no-`type` / missing field / unknown type | TC-002 | ✅ | +| `SourceError` | `path` source dir does not exist | TC-006 | ✅ | +| `SourceError` | option-like (`-`) `repo`/`url`/`ref`/`sha` (argv injection guard) | TC-029 | ✅ | +| `SourceError` | leading-whitespace trim-bypass `repo`/`url` (argv injection guard) | TC-030 | ✅ | +| `SourceError` | option-like (`-`) `git-subdir.path` (argv injection guard) | TC-031 | ✅ | +| `UnsupportedSourceError` | `url` passed to `resolveSource` (npm now resolves) | TC-007 | ✅ | +| `ManifestError` | non-object / bad schemaVersion / non-array entries | TC-005 | ✅ | +| `ManifestError` | null entry / entry missing a non-empty `name` | TC-005 | ✅ | +| `SourceError` (via manifest) | entry with an invalid `source.type` | TC-005 | ✅ | +| `SearchBackendError` | one search backend rejects (network/parse failure) | TC-034 | 🚧 | +| `SearchBackendError` | all backends fail → empty results + per-backend errors | TC-053 | 🚧 | +| `SearchBackendError` | structurally-invalid backend body (no `objects[]`/`items[]`) | TC-054 | 🚧 | +| `SearchBackendError` (rate) | github `403`/`429` with `remaining:0` | TC-048 | 🚧 | +| `SearchBackendError` (transient) | manifest fetch non-404 non-OK / rejects | TC-058 | 🚧 | +| candidate dropped (incompatible) | manifest fetch returns `404` | TC-039 | 🚧 | +| candidate dropped (verify threw) | host `verify` callback throws | TC-059 | 🚧 | --- ## Edge Cases -| ID | Description | Related Req | Test Case | Risk if Untested | -| ------ | --------------------------------------------------------------------- | ----------- | --------- | ------------------------------------------ | -| EC-001 | Second resolve of a cached URL takes the `fetch` (not `clone`) branch | FR-004-AC-5 | TC-009 | Re-fetch path silently broken | -| EC-002 | Settled lazy reconcile issues **zero** git calls | FR-007-AC-2 | TC-018 | Per-invocation git cost regresses | -| EC-003 | Registry file is valid JSON but `plugins` is absent (`{}`) | FR-005-AC-1 | TC-012 | Read throws instead of degrading to empty | -| EC-004 | Target dir deleted out from under an installed entry | FR-007-AC-4 | TC-020 | Reconcile reports unchanged for a gone dir | -| EC-005 | Re-install over an existing symlink target | FR-006-AC-4 | TC-017 | Stale symlink or copy-over-symlink error | +| ID | Description | Related Req | Test Case | Risk if Untested | +| ------ | --------------------------------------------------------------------- | ------------------- | -------------- | ----------------------------------------------- | +| EC-001 | Second resolve of a cached URL takes the `fetch` (not `clone`) branch | FR-004-AC-5 | TC-009 | Re-fetch path silently broken | +| EC-002 | Settled lazy reconcile issues **zero** git calls | FR-007-AC-2 | TC-018 | Per-invocation git cost regresses | +| EC-003 | Registry file is valid JSON but `plugins` is absent (`{}`) | FR-005-AC-1 | TC-012 | Read throws instead of degrading to empty | +| EC-004 | Target dir deleted out from under an installed entry | FR-007-AC-4 | TC-020 | Reconcile reports unchanged for a gone dir | +| EC-005 | Re-install over an existing symlink target | FR-006-AC-4 | TC-017 | Stale symlink or copy-over-symlink error | +| EC-006 | npm package and its github repo both match the same tag | FR-008-AC-5 | TC-036 | Same project shown twice in results | +| EC-007 | Candidate carries the tag but has no valid manifest | FR-009-AC-2 | TC-038 | Incompatible package offered as installable | +| EC-008 | Exhausted github window before reset; then clock passes reset | FR-011-AC-3 · -AC-4 | TC-049, TC-050 | Hammering a rate-limited API / never recovering | +| EC-009 | Identical query within TTL must not re-hit the network | FR-010-AC-3 | TC-044 | Debounced UI still floods the registries | --- @@ -248,8 +354,16 @@ partial clones work offline. five FR-004 constraints are boundary-tested (Rule 3); every documented error is triggered (Rule 4); reconcile's installed/unchanged/updated/skipped outcomes are all reached (Rule 5); and the edge cases above are explicit (Rule 6). +- **Discovery (US-003, FR-008…FR-012, NFR-005; TC-032…TC-070): every AC is + mapped to a TC.** These rows describe `tests/search.test.ts`, the discovery + suite driven by an injected fake `HttpFetcher` + `Clock` (no real network). + AC→TC mapping is 100% (Rule 1); the 100% coverage gate (NFR-002) keeps every + branch of `src/search.ts` exercised. Backend-reject, rate-limit, and + manifest-fetch failure paths are all triggered (Rule 4), the rate-limit + exhausted→reset transition is reached (Rule 5, TC-049/TC-050), and the + discovery edge cases above are explicit (Rule 6). -## Status: ✅ Complete — 100% AC→TC coverage +## Status: ⚠️ Partial — backsync ✅ Complete (executed); discovery 🚧 Planned (100% AC→TC mapped, awaiting `tests/search.test.ts`) ## Backsync Findings (code behavior vs. test coverage) @@ -281,3 +395,7 @@ Recorded during /spec-review. None block the spec; each is a candidate test/hard therefore no happy-path TC for it, only the `UnsupportedSourceError` assertion (TC-007). This is correct, not a coverage gap. The `npm` variant **is** now resolved (TC-022…TC-028). +- TC-032…TC-070 cover `tests/search.test.ts`: the `Case String` values are the + `describe › test` names of the discovery suite, all driven by an injected + fake `HttpFetcher` + `Clock` (no real network), so the suite stays offline + and deterministic (NFR-005). diff --git a/spec/usecase/US-003-discover-plugins-by-tag.md b/spec/usecase/US-003-discover-plugins-by-tag.md new file mode 100644 index 0000000..29bf0d3 --- /dev/null +++ b/spec/usecase/US-003-discover-plugins-by-tag.md @@ -0,0 +1,83 @@ +--- +id: US-003 +title: "Discover and Verify Publishable Plugins by Tag" +type: US +relationships: + - target: "ix://agent-ix/ts-plugin-kit/spec/stakeholder/StR-001" + type: "traces_to" + cardinality: "1:1" + - target: "ix://agent-ix/ts-plugin-kit/spec/functional/FR-008" + type: "satisfied_by" + cardinality: "1:1" +--- + +## Story + +**As a** host application author embedding the toolkit +**I want** to search npm and GitHub for published plugins that carry my plugin +type's discriminator tag, and confirm each candidate is genuinely compatible +**So that** I can offer my users a curated set of installable plugins without +making them browse all of npm or GitHub or hand-type package names. + +This story expresses the host's intent in informal language; it does not prescribe +how the toolkit issues queries, parses manifests, or caches results. + +## Context + +The toolkit already turns a known `Source` into a pinned, materialized install +([US-002](./US-002-install-ad-hoc-source.md)). What a host cannot yet do is _find_ +candidate sources in the first place. Each plugin type (Filament modules, oclif +data plugins, a future kind) is published with a conventional marker — an npm +`keywords` entry and/or a GitHub repository `topic` — and a host wants to turn that +marker into a list of install candidates. Because anyone can attach a topic, the +host also needs to _verify_ a candidate really declares the expected plugin +manifest before presenting it, and the toolkit must do this without learning any +plugin type's manifest format (it stays framework-agnostic and dependency-free, +per [StR-001](../stakeholder/StR-001-shared-zero-dep-install-mechanism.md)). + +## Acceptance Examples (Illustrative) + +These examples clarify the host's expectations; they are illustrative only, not +test cases or verification criteria. + +### [US-003-EX-1] Find candidates by tag + +- **Given** a discriminator tag the host's plugin type publishes under +- **When** the host searches with that tag +- **Then** it receives candidate results from npm and GitHub, each already shaped + as a `Source` it could hand to the existing install path + +### [US-003-EX-2] Keep only compatible candidates + +- **Given** a candidate repository that carries the tag but has no valid plugin + manifest +- **When** the host runs discovery with its compatibility check supplied +- **Then** that candidate is dropped and only verified, installable plugins remain + +### [US-003-EX-3] One slow backend does not sink the search + +- **Given** GitHub is temporarily rate-limited +- **When** the host searches +- **Then** npm results still come back, alongside a clearly-marked GitHub error + +## Options (Exploratory) + +Approaches weighed during discovery, none binding: querying a central marketplace +manifest instead of the public registries; verifying compatibility by downloading +the full package tarball versus fetching only the manifest from a CDN; embedding a +YAML parser in the toolkit versus delegating parsing to the host. + +## Dependencies (Contextual) + +- Upstream: the host owns the discriminator tag and the compatibility predicate. +- Downstream: candidate `Source` values feed the existing `installEntry` / + `resolveSource` path; a host install field that accepts a source string is + served by [FR-012](../functional/FR-012-source-to-install-input.md). + +## Traceability (Informative) + +This story traces to +[StR-001](../stakeholder/StR-001-shared-zero-dep-install-mechanism.md) (a shared, +framework-agnostic, dependency-free mechanism) and is satisfied by the discovery +functional requirements [FR-008](../functional/FR-008-candidate-search.md) through +[FR-012](../functional/FR-012-source-to-install-input.md). diff --git a/spec/usecase/index.md b/spec/usecase/index.md index 2c5229f..d065771 100644 --- a/spec/usecase/index.md +++ b/spec/usecase/index.md @@ -10,3 +10,4 @@ description: "Index of artifacts in this directory." - [US-001: Reconcile a Default Plugin Set](./US-001-reconcile-default-set.md) - [US-002: Install an Ad-Hoc Source and Derive Its Name](./US-002-install-ad-hoc-source.md) +- [US-003: Discover and Verify Publishable Plugins by Tag](./US-003-discover-plugins-by-tag.md) diff --git a/src/index.ts b/src/index.ts index 46ae2af..76c382e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,27 @@ export { type ReconcileResult, reconcile, } from "./reconcile.js"; + +export { + type HttpRequestInit, + type HttpResponse, + type HttpFetcher, + type SearchBackend, + type PluginSearchResult, + type RateLimit, + type SearchBackendError, + type SearchResponse, + type CandidateVerifier, + type SearchOptions, + type Clock, + type TtlCacheOptions, + type TtlCache, + type PluginSearchDeps, + type PluginSearch, + defaultHttpFetcher, + searchPlugins, + systemClock, + createTtlCache, + createPluginSearch, + sourceToInstallInput, +} from "./search.js"; diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..9ca1c9a --- /dev/null +++ b/src/search.ts @@ -0,0 +1,680 @@ +/** + * Plugin discovery: search npm and GitHub for publishable plugins carrying a + * host-chosen discriminator `tag`, optionally verify each candidate against a + * host-supplied manifest predicate, cache results with an injectable-clock TTL, + * and surface GitHub rate limits. This is the library's only asynchronous, + * networked surface; all network access flows through the injectable + * {@link HttpFetcher} (default: the Node global `fetch`), so it adds no runtime + * dependency and is fully offline-testable (NFR-005). + */ +import { createHash } from "node:crypto"; + +import { Source } from "./sources.js"; + +// ── Injectable HTTP seam (a structural subset of the DOM `Response`) ───────── + +export interface HttpRequestInit { + headers?: Record; + signal?: AbortSignal; +} + +export interface HttpResponse { + status: number; + ok: boolean; + headers: { get(name: string): string | null }; + json(): Promise; + text(): Promise; +} + +export interface HttpFetcher { + (url: string, init?: HttpRequestInit): Promise; +} + +/** Default fetcher: delegates to the Node 18+ global `fetch`. Keeps zero-dep. */ +export const defaultHttpFetcher: HttpFetcher = (url, init) => fetch(url, init); + +// ── Result / option / response types ──────────────────────────────────────── + +export type SearchBackend = "npm" | "github"; + +export interface PluginSearchResult { + /** Stable id: `${origin}:${fullName}`. */ + id: string; + origin: SearchBackend; + /** Package or repository name. */ + name: string; + /** `scope/name` (npm) or `owner/repo` (github). */ + fullName: string; + description?: string; + author?: string; + version?: string; + stars?: number; + updatedAt?: string; + /** A human-facing link for the result. */ + url?: string; + /** The discriminator tag that matched. */ + matchedTag: string; + /** Ready for the host install/resolve path. */ + source: Source; + /** True once a {@link CandidateVerifier} has accepted it. */ + verified?: boolean; + /** Host-supplied capabilities from {@link CandidateVerifier.verify}. */ + capabilities?: unknown; +} + +export interface RateLimit { + limit: number; + remaining: number; + /** Epoch **seconds** (from `x-ratelimit-reset`). */ + resetAt: number; +} + +export interface SearchBackendError { + backend: SearchBackend; + message: string; + status?: number; + /** True when the backend's request window is exhausted. */ + rateLimited?: boolean; + /** True when the failure is transient (network/5xx), not a definitive answer. */ + transient?: boolean; + resetAt?: number; +} + +export interface SearchResponse { + results: PluginSearchResult[]; + rate: Partial>; + /** One entry per failed backend; a search never throws on a backend failure. */ + errors: SearchBackendError[]; +} + +/** + * Host plug-in for compatibility verification. The kit fetches the raw manifest + * text and hands it to {@link verify}; it never parses the manifest itself. + */ +export interface CandidateVerifier { + manifestPath: string; + /** Return `null` to reject a candidate, or capabilities to accept it. */ + verify(rawManifest: string): { capabilities?: unknown } | null; +} + +export interface SearchOptions { + /** Required discriminator (npm keyword / GitHub topic). */ + tag: string; + query?: string; + sources?: SearchBackend[]; + /** Per-backend result cap (default 20; clamped to each backend's max). */ + limit?: number; + http?: HttpFetcher; + githubToken?: string; + npmRegistry?: string; + githubApi?: string; + verifier?: CandidateVerifier; + signal?: AbortSignal; +} + +const NPM_REGISTRY = "https://registry.npmjs.org"; +const GITHUB_API = "https://api.github.com"; +const NPM_SIZE_MAX = 250; +const GITHUB_PER_PAGE_MAX = 100; +const VERIFY_CONCURRENCY = 6; +const DEFAULT_SOURCES: SearchBackend[] = ["npm", "github"]; +/** Default upper bound on distinct cached searches held by a `PluginSearch`. */ +const DEFAULT_CACHE_MAX = 256; + +interface BackendOutcome { + results: PluginSearchResult[]; + rate?: RateLimit; + error?: SearchBackendError; +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function composeText( + prefix: string, + tag: string, + query: string | undefined, +): string { + return prefix + tag + (query ? ` ${query}` : ""); +} + +// ── Candidate search (FR-008) ─────────────────────────────────────────────── + +/** + * Search the selected backends for candidates matching `tag` and return a + * normalized, deduped, ranked {@link SearchResponse}. Backends run independently + * (a rejecting backend still returns the other's results plus an error). When a + * {@link CandidateVerifier} is supplied, incompatible candidates are dropped. + */ +export async function searchPlugins( + opts: SearchOptions, +): Promise { + const http = opts.http ?? defaultHttpFetcher; + const sources = opts.sources ?? DEFAULT_SOURCES; + const limit = opts.limit ?? 20; + + const settled = await Promise.allSettled( + sources.map((backend) => + backend === "npm" + ? searchNpm(opts, http, limit) + : searchGithub(opts, http, limit), + ), + ); + + const rate: Partial> = {}; + const errors: SearchBackendError[] = []; + let candidates: PluginSearchResult[] = []; + + settled.forEach((res, i) => { + const backend = sources[i]; + if (res.status === "fulfilled") { + candidates.push(...res.value.results); + if (res.value.rate) rate[backend] = res.value.rate; + if (res.value.error) errors.push(res.value.error); + } else { + errors.push({ backend, message: errMsg(res.reason) }); + } + }); + + if (opts.verifier) { + candidates = await verifyCandidates( + candidates, + opts.verifier, + opts, + http, + errors, + ); + } + + return { results: rank(dedupe(candidates)), rate, errors }; +} + +async function searchNpm( + opts: SearchOptions, + http: HttpFetcher, + limit: number, +): Promise { + const registry = opts.npmRegistry ?? NPM_REGISTRY; + const size = Math.min(limit, NPM_SIZE_MAX); + const text = composeText("keywords:", opts.tag, opts.query); + const url = `${registry}/-/v1/search?text=${encodeURIComponent(text)}&size=${size}`; + const res = await http(url, { signal: opts.signal }); + if (!res.ok) { + return { + results: [], + error: { + backend: "npm", + message: `npm search failed (${res.status})`, + status: res.status, + }, + }; + } + const body = (await res.json()) as { objects?: unknown }; + if (!Array.isArray(body.objects)) { + return { + results: [], + error: { + backend: "npm", + message: "npm search returned a malformed body", + }, + }; + } + const results: PluginSearchResult[] = []; + for (const obj of body.objects) { + const r = npmResult((obj as { package?: unknown }).package, opts.tag); + if (r) results.push(r); + } + return { results }; +} + +function npmResult(pkg: unknown, tag: string): PluginSearchResult | null { + const p = pkg as { + name?: unknown; + version?: unknown; + description?: unknown; + date?: unknown; + links?: { repository?: string; npm?: string; homepage?: string }; + author?: { name?: string }; + } | null; + if (!p || typeof p.name !== "string") return null; + return { + id: `npm:${p.name}`, + origin: "npm", + name: p.name, + fullName: p.name, + description: typeof p.description === "string" ? p.description : undefined, + author: p.author?.name, + version: typeof p.version === "string" ? p.version : undefined, + updatedAt: typeof p.date === "string" ? p.date : undefined, + url: p.links?.repository ?? p.links?.homepage ?? p.links?.npm, + matchedTag: tag, + source: { type: "npm", package: p.name }, + }; +} + +async function searchGithub( + opts: SearchOptions, + http: HttpFetcher, + limit: number, +): Promise { + const api = opts.githubApi ?? GITHUB_API; + const perPage = Math.min(limit, GITHUB_PER_PAGE_MAX); + const q = composeText("topic:", opts.tag, opts.query); + const url = `${api}/search/repositories?q=${encodeURIComponent(q)}&per_page=${perPage}`; + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (opts.githubToken) headers.Authorization = `Bearer ${opts.githubToken}`; + const res = await http(url, { headers, signal: opts.signal }); + const rate = parseRate(res); + if (!res.ok) { + const exhausted = + (res.status === 403 || res.status === 429) && rate?.remaining === 0; + return { + results: [], + rate, + error: { + backend: "github", + message: exhausted + ? "github rate limit exhausted" + : `github search failed (${res.status})`, + status: res.status, + rateLimited: exhausted ? true : undefined, + resetAt: exhausted ? rate?.resetAt : undefined, + }, + }; + } + const body = (await res.json()) as { items?: unknown }; + if (!Array.isArray(body.items)) { + return { + results: [], + rate, + error: { + backend: "github", + message: "github search returned a malformed body", + }, + }; + } + const results: PluginSearchResult[] = []; + for (const item of body.items) { + const r = githubResult(item, opts.tag); + if (r) results.push(r); + } + return { results, rate }; +} + +function githubResult(item: unknown, tag: string): PluginSearchResult | null { + const r = item as { + full_name?: unknown; + name?: unknown; + description?: unknown; + stargazers_count?: unknown; + owner?: { login?: string }; + html_url?: unknown; + pushed_at?: unknown; + } | null; + if (!r || typeof r.full_name !== "string") return null; + return { + id: `github:${r.full_name}`, + origin: "github", + name: typeof r.name === "string" ? r.name : r.full_name, + fullName: r.full_name, + description: typeof r.description === "string" ? r.description : undefined, + author: r.owner?.login, + stars: + typeof r.stargazers_count === "number" ? r.stargazers_count : undefined, + updatedAt: typeof r.pushed_at === "string" ? r.pushed_at : undefined, + url: typeof r.html_url === "string" ? r.html_url : undefined, + matchedTag: tag, + source: { type: "github", repo: r.full_name }, + }; +} + +function parseRate(res: HttpResponse): RateLimit | undefined { + const limit = res.headers.get("x-ratelimit-limit"); + const remaining = res.headers.get("x-ratelimit-remaining"); + const reset = res.headers.get("x-ratelimit-reset"); + if (limit === null || remaining === null || reset === null) return undefined; + const nums = [Number(limit), Number(remaining), Number(reset)]; + // A non-finite parse (e.g. a malformed header) means "no rate info". + if (nums.some((n) => !Number.isFinite(n))) return undefined; + return { limit: nums[0], remaining: nums[1], resetAt: nums[2] }; +} + +// ── Dedupe + rank (FR-008) ────────────────────────────────────────────────── + +/** Normalize a repository reference to a `owner/repo` key, or null if not GitHub. */ +function repoKey(result: PluginSearchResult): string | null { + const raw = + result.origin === "github" ? `github.com/${result.fullName}` : result.url; + if (!raw) return null; + let s = raw.trim().toLowerCase(); + s = s.replace(/^git\+/, "").replace(/^git@github\.com:/, "github.com/"); + s = s.replace(/^[a-z]+:\/\//, ""); + s = s.replace(/\.git$/, "").replace(/\/$/, ""); + const m = s.match(/github\.com\/([^/]+\/[^/]+)/); + return m ? m[1] : null; +} + +function mergePreferNpm( + a: PluginSearchResult, + b: PluginSearchResult, +): PluginSearchResult { + const npm = a.origin === "npm" ? a : b; + const other = npm === a ? b : a; + npm.stars = npm.stars ?? other.stars; + npm.updatedAt = npm.updatedAt ?? other.updatedAt; + return npm; +} + +function dedupe(results: PluginSearchResult[]): PluginSearchResult[] { + const byKey = new Map(); + const unkeyed: PluginSearchResult[] = []; + for (const r of results) { + const key = repoKey(r); + if (key === null) { + unkeyed.push(r); + continue; + } + const existing = byKey.get(key); + byKey.set(key, existing ? mergePreferNpm(existing, r) : r); + } + return [...byKey.values(), ...unkeyed]; +} + +/** Total order: stars desc, then updatedAt desc, then fullName asc. */ +function rank(results: PluginSearchResult[]): PluginSearchResult[] { + return [...results].sort((a, b) => { + const byStars = (b.stars ?? -1) - (a.stars ?? -1); + if (byStars !== 0) return byStars; + const byDate = (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); + if (byDate !== 0) return byDate; + return a.fullName.localeCompare(b.fullName); + }); +} + +// ── Compatibility verification (FR-009) ───────────────────────────────────── + +async function verifyCandidates( + candidates: PluginSearchResult[], + verifier: CandidateVerifier, + opts: SearchOptions, + http: HttpFetcher, + errors: SearchBackendError[], +): Promise { + const kept: PluginSearchResult[] = []; + let next = 0; + async function worker(): Promise { + while (next < candidates.length) { + const candidate = candidates[next++]; + const result = await verifyOne(candidate, verifier, opts, http, errors); + if (result) kept.push(result); + } + } + const pool = Array.from( + { length: Math.min(VERIFY_CONCURRENCY, candidates.length) }, + () => worker(), + ); + await Promise.all(pool); + return kept; +} + +/** + * A registry-supplied name is unsafe to interpolate into a manifest URL when it + * carries a control character or a path segment containing `..`: even though the + * authority is fixed (no cross-host SSRF), `..` can traverse within the trusted + * CDN. (`manifestPath` is host-supplied and therefore trusted.) + */ +function unsafeManifestName(name: string): boolean { + return ( + /[\u0000-\u001f\u007f]/.test(name) || + name.split("/").some((seg) => seg.includes("..")) + ); +} + +function manifestUrl( + candidate: PluginSearchResult, + manifestPath: string, +): string | null { + const name = candidate.origin === "npm" ? candidate.name : candidate.fullName; + if (unsafeManifestName(name)) return null; + return candidate.origin === "npm" + ? `https://unpkg.com/${name}/${manifestPath}` + : `https://raw.githubusercontent.com/${name}/HEAD/${manifestPath}`; +} + +async function verifyOne( + candidate: PluginSearchResult, + verifier: CandidateVerifier, + opts: SearchOptions, + http: HttpFetcher, + errors: SearchBackendError[], +): Promise { + const url = manifestUrl(candidate, verifier.manifestPath); + if (url === null) return null; // unsafe registry-supplied name → drop candidate + let res: HttpResponse; + try { + res = await http(url, { signal: opts.signal }); + } catch (e) { + errors.push({ + backend: candidate.origin, + message: errMsg(e), + transient: true, + }); + return null; + } + if (res.status === 404) return null; // absent manifest → incompatible, not an error + if (!res.ok) { + errors.push({ + backend: candidate.origin, + message: `manifest fetch failed (${res.status})`, + status: res.status, + transient: true, + }); + return null; + } + const text = await res.text(); + let verdict: { capabilities?: unknown } | null; + try { + verdict = verifier.verify(text); + } catch { + return null; // a throwing predicate drops only this candidate + } + if (verdict === null) return null; + return { ...candidate, verified: true, capabilities: verdict.capabilities }; +} + +// ── TTL cache with injectable clock (FR-010) ──────────────────────────────── + +export interface Clock { + now(): number; +} + +export const systemClock: Clock = { now: () => Date.now() }; + +export interface TtlCacheOptions { + ttlMs: number; + clock?: Clock; + max?: number; +} + +export interface TtlCache { + get(key: string): V | undefined; + set(key: string, value: V): void; + delete(key: string): void; + clear(): void; + size(): number; +} + +export function createTtlCache(opts: TtlCacheOptions): TtlCache { + const clock = opts.clock ?? systemClock; + const store = new Map(); + return { + get(key) { + const entry = store.get(key); + if (!entry) return undefined; + if (clock.now() >= entry.expiresAt) { + store.delete(key); + return undefined; + } + return entry.value; + }, + set(key, value) { + store.set(key, { value, expiresAt: clock.now() + opts.ttlMs }); + if (opts.max !== undefined && store.size > opts.max) { + store.delete(store.keys().next().value as string); + } + }, + delete(key) { + store.delete(key); + }, + clear() { + store.clear(); + }, + size() { + return store.size; + }, + }; +} + +// ── Composed factory: cache + rate state across calls (FR-010, FR-011) ────── + +export interface PluginSearchDeps { + http?: HttpFetcher; + clock?: Clock; + ttlMs?: number; + cacheMax?: number; + /** A value, or a late-bound resolver invoked per call. */ + githubToken?: string | (() => string | undefined); + npmRegistry?: string; + githubApi?: string; + verifier?: CandidateVerifier; +} + +export interface PluginSearch { + search(opts: SearchOptions): Promise; + invalidate(): void; + lastRate(): Partial>; +} + +/** + * A stable, non-secret discriminator of a resolved token for the cache key. Two + * distinct tokens map to distinct ids (no cross-token cache hit), while the raw + * token value never enters the key (FR-008-CON-2): an absent token is `"anon"`, + * any present token is the first 8 hex of its SHA-256 digest. + */ +function tokenId(token: string | undefined): string { + if (!token) return "anon"; + return createHash("sha256").update(token).digest("hex").slice(0, 8); +} + +/** A one-level clone so a cache hit never shares mutable state with the caller. */ +function cloneResponse(r: SearchResponse): SearchResponse { + return { + results: [...r.results], + rate: { ...r.rate }, + errors: [...r.errors], + }; +} + +export function createPluginSearch(deps: PluginSearchDeps = {}): PluginSearch { + const clock = deps.clock ?? systemClock; + const http = deps.http ?? defaultHttpFetcher; + const ttlMs = deps.ttlMs ?? 600_000; + const cache = createTtlCache({ + ttlMs, + clock, + max: deps.cacheMax ?? DEFAULT_CACHE_MAX, + }); + let rate: Partial> = {}; + + function resolveToken(): string | undefined { + return typeof deps.githubToken === "function" + ? deps.githubToken() + : deps.githubToken; + } + + function cacheKey(opts: SearchOptions, token: string | undefined): string { + const sources = (opts.sources ?? DEFAULT_SOURCES).join(","); + return [ + opts.tag, + opts.query ?? "", + sources, + opts.limit ?? 20, + (opts.verifier ?? deps.verifier) ? "v" : "", + tokenId(token), + ].join("|"); + } + + return { + async search(opts) { + const token = resolveToken(); + const key = cacheKey(opts, token); + const hit = cache.get(key); + if (hit) return cloneResponse(hit); + + const requested = opts.sources ?? DEFAULT_SOURCES; + const gh = rate.github; + const exhausted = + gh !== undefined && + gh.remaining === 0 && + clock.now() / 1000 < gh.resetAt; + const skipGithub = exhausted && requested.includes("github"); + const runSources = skipGithub + ? requested.filter((s) => s !== "github") + : requested; + + const response = await searchPlugins({ + ...opts, + sources: runSources, + http, + githubToken: token, + npmRegistry: opts.npmRegistry ?? deps.npmRegistry, + githubApi: opts.githubApi ?? deps.githubApi, + verifier: opts.verifier ?? deps.verifier, + }); + + if (skipGithub) { + response.errors.push({ + backend: "github", + message: "github rate limit exhausted", + rateLimited: true, + resetAt: gh.resetAt, + }); + response.rate.github = gh; + } + + rate = { ...rate, ...response.rate }; + // Cache a clone so a caller mutating the returned response cannot poison + // the cached entry, and hand back the fresh response on this call. + if (response.errors.length === 0) cache.set(key, cloneResponse(response)); + return response; + }, + invalidate() { + cache.clear(); + }, + lastRate() { + return rate; + }, + }; +} + +// ── Source → install-input string (FR-012) ────────────────────────────────── + +/** Render a {@link Source} as the canonical install token a host field accepts. */ +export function sourceToInstallInput(source: Source): string { + switch (source.type) { + case "npm": + return source.package; + case "github": + return source.repo; + case "git": + case "url": + case "git-subdir": + return source.url; + case "path": + return source.path; + } +} diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 0000000..59a3fe7 --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,1054 @@ +import { + createPluginSearch, + createTtlCache, + defaultHttpFetcher, + searchPlugins, + sourceToInstallInput, + systemClock, + type CandidateVerifier, + type HttpFetcher, + type HttpResponse, +} from "../src"; + +// ── Offline fakes: an HttpResponse builder + per-test routed fetchers ───────── + +function res( + body: unknown, + opts: { status?: number; headers?: Record } = {}, +): HttpResponse { + const status = opts.status ?? 200; + const headers = opts.headers ?? {}; + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (name) => headers[name.toLowerCase()] ?? null }, + json: async () => body, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + }; +} + +const npmPkg = (over: Record = {}) => ({ + name: "a-mod", + version: "1.0.0", + description: "d", + date: "2024-01-01", + links: { repository: "https://github.com/o/a-mod" }, + author: { name: "auth" }, + ...over, +}); +const npmBody = (...pkgs: unknown[]) => ({ + objects: pkgs.map((p) => ({ package: p })), +}); +const ghItem = (over: Record = {}) => ({ + full_name: "o/r", + name: "r", + description: "gd", + stargazers_count: 5, + owner: { login: "o" }, + html_url: "https://github.com/o/r", + pushed_at: "2024-02-01", + ...over, +}); +const ghBody = (...items: unknown[]) => ({ items }); + +const RL = (limit: string, remaining: string, reset: string) => ({ + "x-ratelimit-limit": limit, + "x-ratelimit-remaining": remaining, + "x-ratelimit-reset": reset, +}); + +const okVerifier = (caps: unknown = { x: 1 }): CandidateVerifier => ({ + manifestPath: "manifest.yaml", + verify: () => ({ capabilities: caps }), +}); + +// ── FR-008: candidate search ───────────────────────────────────────────────── + +describe("searchPlugins — candidate search (FR-008)", () => { + it("merges npm + github candidates into one ranked list (TC-032)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/-/v1/search") + ? res(npmBody(npmPkg({ links: {} }))) + : res(ghBody(ghItem())); + const r = await searchPlugins({ tag: "filament-module", http }); + expect(r.results.map((x) => x.origin).sort()).toEqual(["github", "npm"]); + expect(r.results.find((x) => x.origin === "npm")!.source).toEqual({ + type: "npm", + package: "a-mod", + }); + expect(r.results.find((x) => x.origin === "github")!.source).toEqual({ + type: "github", + repo: "o/r", + }); + expect(r.errors).toEqual([]); + }); + + it("composes encoded npm size + github per_page queries (TC-033)", async () => { + const urls: string[] = []; + const http: HttpFetcher = async (url) => { + urls.push(url); + return url.includes("/-/v1/search") ? res(npmBody()) : res(ghBody()); + }; + await searchPlugins({ tag: "fil mod", query: "a/b", limit: 7, http }); + const npmUrl = urls.find((u) => u.includes("/-/v1/search"))!; + const ghUrl = urls.find((u) => u.includes("/search/repositories"))!; + expect(npmUrl).toContain(encodeURIComponent("keywords:fil mod a/b")); + expect(npmUrl).toContain("size=7"); + expect(ghUrl).toContain(encodeURIComponent("topic:fil mod a/b")); + expect(ghUrl).toContain("per_page=7"); + }); + + it("one backend failing still returns the other plus an error (TC-034)", async () => { + const http: HttpFetcher = async (url) => { + if (url.includes("/-/v1/search")) + return res(npmBody(npmPkg({ links: {} }))); + throw new Error("gh down"); + }; + const r = await searchPlugins({ tag: "t", http }); + expect(r.results).toHaveLength(1); + expect(r.results[0].origin).toBe("npm"); + expect(r.errors).toEqual([{ backend: "github", message: "gh down" }]); + }); + + it("adds Authorization only with a token and honors the sources filter (TC-035)", async () => { + let ghHeaders: Record | undefined; + let npmCalled = false; + const http: HttpFetcher = async (url, init) => { + if (url.includes("/search/repositories")) { + ghHeaders = init?.headers; + return res(ghBody()); + } + npmCalled = true; + return res(npmBody()); + }; + await searchPlugins({ + tag: "t", + http, + githubToken: "secret", + sources: ["github"], + }); + expect(ghHeaders?.Authorization).toBe("Bearer secret"); + expect(npmCalled).toBe(false); + ghHeaders = undefined; + await searchPlugins({ tag: "t", http, sources: ["github"] }); + expect(ghHeaders?.Authorization).toBeUndefined(); + }); + + it("dedupes an npm package against its github repo, preferring npm (TC-036)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/-/v1/search") + ? res( + npmBody( + npmPkg({ + name: "a-mod", + date: undefined, + links: { repository: "git+https://github.com/o/a-mod.git" }, + }), + ), + ) + : res( + ghBody( + ghItem({ + full_name: "o/a-mod", + stargazers_count: 42, + pushed_at: "2024-05-05", + }), + ), + ); + const r = await searchPlugins({ tag: "t", http }); + expect(r.results).toHaveLength(1); + expect(r.results[0].origin).toBe("npm"); + expect(r.results[0].stars).toBe(42); + expect(r.results[0].updatedAt).toBe("2024-05-05"); + }); + + it("dedupes regardless of backend order, preferring npm (github-first)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res( + ghBody( + ghItem({ + full_name: "o/m", + stargazers_count: 9, + pushed_at: "2024-07-07", + }), + ), + ) + : res( + npmBody( + npmPkg({ + name: "m", + date: undefined, + links: { repository: "https://github.com/o/m" }, + }), + ), + ); + const r = await searchPlugins({ + tag: "t", + http, + sources: ["github", "npm"], + }); + expect(r.results).toHaveLength(1); + expect(r.results[0].origin).toBe("npm"); + expect(r.results[0].stars).toBe(9); + }); + + it("collapses duplicate github entries with the same full_name", async () => { + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res( + ghBody( + ghItem({ full_name: "o/d", stargazers_count: 1 }), + ghItem({ full_name: "o/d", stargazers_count: 2 }), + ), + ) + : res(npmBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["github"] }); + expect(r.results).toHaveLength(1); + }); + + it("returns empty results with one error per failed backend (TC-053)", async () => { + const http: HttpFetcher = async () => { + throw new Error("net"); + }; + const r = await searchPlugins({ tag: "t", http }); + expect(r.results).toEqual([]); + expect(r.errors.map((e) => e.backend).sort()).toEqual(["github", "npm"]); + }); + + it("tolerates missing optional fields and skips invalid items (TC-054)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/-/v1/search") + ? res({ + objects: [ + { package: null }, + { package: { name: "min", links: {} } }, + ], + }) + : res(ghBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["npm"] }); + expect(r.results).toHaveLength(1); + const only = r.results[0]; + expect(only.name).toBe("min"); + expect(only.description).toBeUndefined(); + expect(only.version).toBeUndefined(); + expect(only.author).toBeUndefined(); + expect(only.updatedAt).toBeUndefined(); + expect(only.url).toBeUndefined(); + }); + + it("reports an npm malformed body and a github malformed body", async () => { + const npmBad = await searchPlugins({ + tag: "t", + sources: ["npm"], + http: async () => res({ nope: 1 }), + }); + expect(npmBad.errors[0]).toMatchObject({ backend: "npm" }); + expect(npmBad.errors[0].message).toMatch(/malformed/); + const ghBad = await searchPlugins({ + tag: "t", + sources: ["github"], + http: async () => res({ nope: 1 }), + }); + expect(ghBad.errors[0]).toMatchObject({ backend: "github" }); + expect(ghBad.errors[0].message).toMatch(/malformed/); + }); + + it("skips invalid github items", async () => { + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res(ghBody(null, { name: "x" }, ghItem({ full_name: "o/r" }))) + : res(npmBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["github"] }); + expect(r.results.map((x) => x.fullName)).toEqual(["o/r"]); + }); + + it("reports non-rate npm/github failures on other non-OK statuses", async () => { + const npm = await searchPlugins({ + tag: "t", + sources: ["npm"], + http: async () => res({}, { status: 500 }), + }); + expect(npm.errors[0]).toMatchObject({ backend: "npm", status: 500 }); + const gh = await searchPlugins({ + tag: "t", + sources: ["github"], + http: async () => res({}, { status: 500 }), + }); + expect(gh.errors[0]).toMatchObject({ backend: "github", status: 500 }); + expect(gh.errors[0].rateLimited).toBeUndefined(); + }); + + it("clamps limit to each backend maximum (TC-055)", async () => { + const urls: string[] = []; + const http: HttpFetcher = async (url) => { + urls.push(url); + return url.includes("/-/v1/search") ? res(npmBody()) : res(ghBody()); + }; + await searchPlugins({ tag: "t", http, limit: 9999 }); + expect(urls.find((u) => u.includes("/-/v1/search"))).toContain("size=250"); + expect(urls.find((u) => u.includes("/search/repositories"))).toContain( + "per_page=100", + ); + }); + + it("ranks deterministically with a fullName tie-break (TC-056)", async () => { + const items = [ + ghItem({ + full_name: "o/b", + stargazers_count: undefined, + pushed_at: undefined, + }), + ghItem({ + full_name: "o/a", + stargazers_count: undefined, + pushed_at: undefined, + }), + ghItem({ full_name: "o/c", stargazers_count: 10 }), + ghItem({ + full_name: "o/d", + stargazers_count: 10, + pushed_at: "2024-09-09", + }), + ]; + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res(ghBody(...items)) + : res(npmBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["github"] }); + expect(r.results.map((x) => x.fullName)).toEqual([ + "o/d", + "o/c", + "o/a", + "o/b", + ]); + }); + + it("keeps repo-less results and treats fully-equal results as equal rank", async () => { + const http: HttpFetcher = async (url) => + url.includes("/-/v1/search") + ? res( + npmBody( + npmPkg({ name: "dup", date: undefined, links: {} }), + npmPkg({ name: "dup", date: undefined, links: {} }), + ), + ) + : res(ghBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["npm"] }); + expect(r.results).toHaveLength(2); + expect(r.results.every((x) => x.fullName === "dup")).toBe(true); + }); + + it("falls back through npm link fields for the result url", async () => { + const http: HttpFetcher = async (url) => + url.includes("/-/v1/search") + ? res( + npmBody( + npmPkg({ name: "a", links: { homepage: "https://hp" } }), + npmPkg({ name: "b", links: { npm: "https://npmlink" } }), + ), + ) + : res(ghBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["npm"] }); + expect(r.results.find((x) => x.name === "a")!.url).toBe("https://hp"); + expect(r.results.find((x) => x.name === "b")!.url).toBe("https://npmlink"); + }); + + it("propagates signal and surfaces an abort as a backend error (TC-057)", async () => { + const seen: (AbortSignal | undefined)[] = []; + const ctrl = new AbortController(); + const http: HttpFetcher = async (url, init) => { + seen.push(init?.signal); + if (url.includes("/search/repositories")) throw new Error("aborted"); + return res(npmBody(npmPkg({ links: {} }))); + }; + const r = await searchPlugins({ tag: "t", http, signal: ctrl.signal }); + expect(seen.every((s) => s === ctrl.signal)).toBe(true); + expect(r.results).toHaveLength(1); + expect(r.errors[0].backend).toBe("github"); + }); + + it("never leaks the github token into errors, keys, or results (TC-064)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res({}, { status: 403, headers: RL("60", "0", "999") }) + : res(npmBody(npmPkg({ links: {} }))); + const r = await searchPlugins({ tag: "t", http, githubToken: "SECRET" }); + expect(JSON.stringify(r)).not.toContain("SECRET"); + }); + + it("stringifies a non-Error backend rejection", async () => { + const r = await searchPlugins({ + tag: "t", + sources: ["npm"], + http: async () => { + throw "kaboom"; + }, + }); + expect(r.errors[0]).toEqual({ backend: "npm", message: "kaboom" }); + }); + + it("falls back github fields and orders equal-star results by date", async () => { + const items = [ + { full_name: "o/nofields", stargazers_count: 3 }, + ghItem({ + full_name: "o/a1", + stargazers_count: undefined, + pushed_at: "2024-01-01", + }), + ghItem({ + full_name: "o/a2", + stargazers_count: undefined, + pushed_at: "2024-02-02", + }), + ghItem({ + full_name: "o/a3", + stargazers_count: undefined, + pushed_at: "2024-03-03", + }), + ]; + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res(ghBody(...items)) + : res(npmBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["github"] }); + const nofields = r.results.find((x) => x.fullName === "o/nofields")!; + expect(nofields.name).toBe("o/nofields"); + expect(nofields.description).toBeUndefined(); + expect(nofields.url).toBeUndefined(); + expect(nofields.author).toBeUndefined(); + expect(r.results.map((x) => x.fullName)).toEqual([ + "o/nofields", + "o/a3", + "o/a2", + "o/a1", + ]); + }); + + it("honors custom npmRegistry and githubApi endpoints", async () => { + const urls: string[] = []; + const http: HttpFetcher = async (url) => { + urls.push(url); + return url.includes("/-/v1/search") ? res(npmBody()) : res(ghBody()); + }; + await searchPlugins({ + tag: "t", + http, + npmRegistry: "https://custom-npm", + githubApi: "https://custom-gh", + }); + expect( + urls.some((u) => u.startsWith("https://custom-npm/-/v1/search")), + ).toBe(true); + expect( + urls.some((u) => u.startsWith("https://custom-gh/search/repositories")), + ).toBe(true); + }); +}); + +// ── FR-009: compatibility verification ─────────────────────────────────────── + +describe("searchPlugins — verification (FR-009)", () => { + const npmOnly = + (manifest: HttpResponse | (() => Promise)): HttpFetcher => + async (url) => { + if (new URL(url).host === "unpkg.com") + return typeof manifest === "function" ? manifest() : manifest; + if (url.includes("/-/v1/search")) + return res(npmBody(npmPkg({ name: "n", links: {} }))); + return res(ghBody()); + }; + + it("keeps a candidate whose verify returns capabilities (TC-037)", async () => { + const r = await searchPlugins({ + tag: "t", + sources: ["npm"], + http: npmOnly(res("name: x")), + verifier: okVerifier({ ok: true }), + }); + expect(r.results).toHaveLength(1); + expect(r.results[0].verified).toBe(true); + expect(r.results[0].capabilities).toEqual({ ok: true }); + }); + + it("drops a candidate whose verify returns null (TC-038)", async () => { + const r = await searchPlugins({ + tag: "t", + sources: ["npm"], + http: npmOnly(res("manifest")), + verifier: { manifestPath: "manifest.yaml", verify: () => null }, + }); + expect(r.results).toEqual([]); + }); + + it("drops a 404 candidate as incompatible without calling verify (TC-039)", async () => { + let verifyCalls = 0; + const r = await searchPlugins({ + tag: "t", + sources: ["npm"], + http: npmOnly(res("", { status: 404 })), + verifier: { + manifestPath: "manifest.yaml", + verify: () => { + verifyCalls++; + return { capabilities: {} }; + }, + }, + }); + expect(r.results).toEqual([]); + expect(r.errors).toEqual([]); + expect(verifyCalls).toBe(0); + }); + + it("fetches manifests from unpkg and raw.githubusercontent (TC-040)", async () => { + const urls: string[] = []; + const http: HttpFetcher = async (url) => { + const host = new URL(url).host; + if (host === "unpkg.com" || host === "raw.githubusercontent.com") { + urls.push(url); + return res("m"); + } + if (url.includes("/-/v1/search")) + return res(npmBody(npmPkg({ name: "n", links: {} }))); + return res(ghBody(ghItem({ full_name: "o/r" }))); + }; + await searchPlugins({ tag: "t", http, verifier: okVerifier() }); + expect(urls).toContain("https://unpkg.com/n/manifest.yaml"); + expect(urls).toContain( + "https://raw.githubusercontent.com/o/r/HEAD/manifest.yaml", + ); + }); + + it("skips verification entirely when no verifier is given (TC-041)", async () => { + let manifestFetched = false; + const http: HttpFetcher = async (url) => { + if (url.includes("unpkg") || url.includes("raw.github")) { + manifestFetched = true; + return res("m"); + } + return url.includes("/-/v1/search") + ? res(npmBody(npmPkg({ links: {} }))) + : res(ghBody()); + }; + const r = await searchPlugins({ tag: "t", http, sources: ["npm"] }); + expect(r.results[0].verified).toBeUndefined(); + expect(manifestFetched).toBe(false); + }); + + it("drops on a transient fetch failure and records a transient error (TC-058)", async () => { + const r = await searchPlugins({ + tag: "t", + sources: ["npm"], + http: npmOnly(res("", { status: 500 })), + verifier: okVerifier(), + }); + expect(r.results).toEqual([]); + expect(r.errors[0]).toMatchObject({ + backend: "npm", + transient: true, + status: 500, + }); + }); + + it("drops when the manifest fetch rejects, as a transient error", async () => { + const http: HttpFetcher = async (url) => { + if (url.includes("unpkg")) throw new Error("dns"); + return url.includes("/-/v1/search") + ? res(npmBody(npmPkg({ name: "n", links: {} }))) + : res(ghBody()); + }; + const r = await searchPlugins({ + tag: "t", + http, + sources: ["npm"], + verifier: okVerifier(), + }); + expect(r.results).toEqual([]); + expect(r.errors[0]).toMatchObject({ backend: "npm", transient: true }); + }); + + it("isolates a throwing verify to its candidate (TC-059)", async () => { + const http: HttpFetcher = async (url) => { + if (url.includes("unpkg.com/bad")) return res("THROW"); + if (url.includes("unpkg.com/good")) return res("OK"); + return url.includes("/-/v1/search") + ? res( + npmBody( + npmPkg({ name: "bad", links: {} }), + npmPkg({ name: "good", links: {} }), + ), + ) + : res(ghBody()); + }; + const verifier: CandidateVerifier = { + manifestPath: "manifest.yaml", + verify: (t) => { + if (t === "THROW") throw new Error("boom"); + return { capabilities: {} }; + }, + }; + const r = await searchPlugins({ + tag: "t", + http, + sources: ["npm"], + verifier, + }); + expect(r.results.map((x) => x.name)).toEqual(["good"]); + }); + + it("rejects path-traversal / control-char candidate names before fetching a manifest (TC-067)", async () => { + const fetched: string[] = []; + const http: HttpFetcher = async (url) => { + if (new URL(url).host === "unpkg.com") { + fetched.push(url); + return res("m"); + } + return url.includes("/-/v1/search") + ? res( + npmBody( + npmPkg({ name: "../evil", links: {} }), + npmPkg({ name: `ev${String.fromCharCode(1)}il`, links: {} }), + npmPkg({ name: "good", links: {} }), + ), + ) + : res(ghBody()); + }; + const r = await searchPlugins({ + tag: "t", + http, + sources: ["npm"], + verifier: okVerifier(), + }); + expect(r.results.map((x) => x.name)).toEqual(["good"]); + expect(fetched).toEqual(["https://unpkg.com/good/manifest.yaml"]); + }); + + it("caps manifest-fetch concurrency at six (TC-060)", async () => { + let inFlight = 0; + let maxInFlight = 0; + const http: HttpFetcher = async (url) => { + if (url.includes("unpkg")) { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 0)); + inFlight--; + return res("m"); + } + return url.includes("/-/v1/search") + ? res( + npmBody( + ...Array.from({ length: 12 }, (_, i) => + npmPkg({ name: `p${i}`, links: {} }), + ), + ), + ) + : res(ghBody()); + }; + await searchPlugins({ + tag: "t", + http, + sources: ["npm"], + verifier: okVerifier(), + }); + expect(maxInFlight).toBe(6); + }); +}); + +// ── FR-010: TTL cache + factory ────────────────────────────────────────────── + +describe("createTtlCache (FR-010)", () => { + it("returns before expiry and evicts after the clock advances (TC-042)", () => { + let t = 1000; + const c = createTtlCache({ ttlMs: 100, clock: { now: () => t } }); + c.set("k", "v"); + expect(c.get("k")).toBe("v"); + t = 1100; + expect(c.get("k")).toBeUndefined(); + expect(c.get("k")).toBeUndefined(); + expect(c.size()).toBe(0); + }); + + it("evicts the oldest entry past max (TC-043)", () => { + const c = createTtlCache({ + ttlMs: 1000, + clock: { now: () => 0 }, + max: 2, + }); + c.set("a", 1); + c.set("b", 2); + c.set("c", 3); + expect(c.get("a")).toBeUndefined(); + expect(c.get("b")).toBe(2); + expect(c.get("c")).toBe(3); + expect(c.size()).toBe(2); + }); + + it("supports delete, clear, and size without a max bound", () => { + const c = createTtlCache({ ttlMs: 1000, clock: { now: () => 0 } }); + c.set("a", 1); + c.set("b", 2); + expect(c.size()).toBe(2); + c.delete("a"); + expect(c.get("a")).toBeUndefined(); + c.clear(); + expect(c.size()).toBe(0); + }); + + it("defaults to the system clock when none is injected", () => { + const c = createTtlCache({ ttlMs: 10_000 }); + c.set("a", 1); + expect(c.get("a")).toBe(1); + }); + + it("systemClock.now returns a number", () => { + expect(typeof systemClock.now()).toBe("number"); + }); +}); + +describe("createPluginSearch (FR-010)", () => { + const okHttp: HttpFetcher = async (url) => + url.includes("/-/v1/search") + ? res(npmBody(npmPkg({ links: {} }))) + : res(ghBody()); + + it("serves an identical search from cache with no fetch (TC-044)", async () => { + let calls = 0; + const http: HttpFetcher = async (url, init) => { + calls++; + return okHttp(url, init); + }; + const ps = createPluginSearch({ http, clock: { now: () => 0 } }); + await ps.search({ tag: "t" }); + const before = calls; + await ps.search({ tag: "t" }); + expect(calls).toBe(before); + }); + + it("invalidate forces a re-fetch (TC-045)", async () => { + let calls = 0; + const http: HttpFetcher = async (url, init) => { + calls++; + return okHttp(url, init); + }; + const ps = createPluginSearch({ http, clock: { now: () => 0 } }); + await ps.search({ tag: "t" }); + const after1 = calls; + ps.invalidate(); + await ps.search({ tag: "t" }); + expect(calls).toBeGreaterThan(after1); + }); + + it("resolves a late-bound github token per call (TC-046)", async () => { + let token: string | undefined; + const seen: (string | undefined)[] = []; + const http: HttpFetcher = async (url, init) => { + if (url.includes("/search/repositories")) { + seen.push(init?.headers?.Authorization); + return res(ghBody()); + } + return res(npmBody()); + }; + const ps = createPluginSearch({ + http, + clock: { now: () => 0 }, + githubToken: () => token, + }); + await ps.search({ tag: "t" }); + token = "later"; + await ps.search({ tag: "t" }); + expect(seen[0]).toBeUndefined(); + expect(seen[1]).toBe("Bearer later"); + }); + + it("does not cache a response carrying errors (TC-061)", async () => { + let calls = 0; + const http: HttpFetcher = async (url) => { + calls++; + if (url.includes("/-/v1/search")) + return res(npmBody(npmPkg({ links: {} }))); + throw new Error("gh down"); + }; + const ps = createPluginSearch({ http, clock: { now: () => 0 } }); + await ps.search({ tag: "t" }); + const after1 = calls; + await ps.search({ tag: "t" }); + expect(calls).toBeGreaterThan(after1); + }); + + it("keys verifier-presence and token-id distinctly (TC-062)", async () => { + let calls = 0; + const http: HttpFetcher = async (url) => { + calls++; + if (url.includes("unpkg")) return res("m"); + return url.includes("/-/v1/search") + ? res(npmBody(npmPkg({ links: {} }))) + : res(ghBody()); + }; + const ps = createPluginSearch({ http, clock: { now: () => 0 } }); + await ps.search({ tag: "t", sources: ["npm"] }); + const noVerifier = calls; + await ps.search({ tag: "t", sources: ["npm"], verifier: okVerifier() }); + expect(calls).toBeGreaterThan(noVerifier); + }); + + it("uses a default verifier from deps when none is passed per call", async () => { + let manifestFetched = false; + const http: HttpFetcher = async (url) => { + if (url.includes("unpkg")) { + manifestFetched = true; + return res("m"); + } + return url.includes("/-/v1/search") + ? res(npmBody(npmPkg({ links: {} }))) + : res(ghBody()); + }; + const ps = createPluginSearch({ + http, + clock: { now: () => 0 }, + verifier: okVerifier(), + }); + const r = await ps.search({ tag: "t", sources: ["npm"] }); + expect(manifestFetched).toBe(true); + expect(r.results[0].verified).toBe(true); + }); + + it("prefers per-call endpoints, falling back to deps endpoints", async () => { + const urls: string[] = []; + const http: HttpFetcher = async (url) => { + urls.push(url); + return url.includes("/-/v1/search") ? res(npmBody()) : res(ghBody()); + }; + const ps = createPluginSearch({ + http, + clock: { now: () => 0 }, + npmRegistry: "https://deps-npm", + githubApi: "https://deps-gh", + }); + await ps.search({ + tag: "t", + npmRegistry: "https://call-npm", + githubApi: "https://call-gh", + }); + await ps.search({ tag: "t2", query: "q", limit: 5 }); + expect(urls.some((u) => u.startsWith("https://call-npm"))).toBe(true); + expect(urls.some((u) => u.startsWith("https://call-gh"))).toBe(true); + expect(urls.some((u) => u.startsWith("https://deps-npm"))).toBe(true); + expect(urls.some((u) => u.startsWith("https://deps-gh"))).toBe(true); + }); + + it("keys distinct non-empty tokens to distinct cache entries (TC-065)", async () => { + let token: string | undefined = "tokA"; + let calls = 0; + const http: HttpFetcher = async (url, init) => { + calls++; + return okHttp(url, init); + }; + const ps = createPluginSearch({ + http, + clock: { now: () => 0 }, + githubToken: () => token, + }); + await ps.search({ tag: "t" }); // caches under tokA's token-id + const afterA = calls; + token = "tokB"; + await ps.search({ tag: "t" }); // distinct token-id → must NOT cross-hit tokA + expect(calls).toBeGreaterThan(afterA); + const afterB = calls; + await ps.search({ tag: "t" }); // tokB now cached → no fetch + expect(calls).toBe(afterB); + token = "tokA"; + await ps.search({ tag: "t" }); // tokA's entry still distinct + live → no fetch + expect(calls).toBe(afterB); + }); + + it("bounds the cache by a default max, evicting the oldest entry (TC-066)", async () => { + let calls = 0; + const http: HttpFetcher = async (url, init) => { + calls++; + return okHttp(url, init); + }; + const ps = createPluginSearch({ http, clock: { now: () => 0 } }); + for (let i = 0; i < 257; i++) await ps.search({ tag: `t${i}` }); + const afterFill = calls; + await ps.search({ tag: "t0" }); // oldest was evicted past the default bound + expect(calls).toBeGreaterThan(afterFill); + const afterEvicted = calls; + await ps.search({ tag: "t256" }); // a recent entry is still cached + expect(calls).toBe(afterEvicted); + }); + + it("honors an explicit cacheMax override (TC-070)", async () => { + let calls = 0; + const http: HttpFetcher = async (url, init) => { + calls++; + return okHttp(url, init); + }; + const ps = createPluginSearch({ + http, + clock: { now: () => 0 }, + cacheMax: 1, + }); + await ps.search({ tag: "a" }); + await ps.search({ tag: "b" }); // evicts "a" past the bound of 1 + const before = calls; + await ps.search({ tag: "a" }); // "a" was evicted → re-fetch + expect(calls).toBeGreaterThan(before); + }); + + it("returns a distinct response object on a cache hit (TC-069)", async () => { + const ps = createPluginSearch({ http: okHttp, clock: { now: () => 0 } }); + const first = await ps.search({ tag: "t" }); + const hit = await ps.search({ tag: "t" }); + expect(hit).not.toBe(first); + expect(hit.results).not.toBe(first.results); + expect(hit).toEqual(first); + }); +}); + +// ── FR-011: GitHub rate limit ──────────────────────────────────────────────── + +describe("rate-limit surfacing + short-circuit (FR-011)", () => { + it("reads github rate-limit headers into rate.github (TC-047)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res(ghBody(), { headers: RL("30", "29", "12345") }) + : res(npmBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["github"] }); + expect(r.rate.github).toEqual({ limit: 30, remaining: 29, resetAt: 12345 }); + }); + + it("treats non-finite rate-limit headers as no rate info (TC-068)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res(ghBody(), { headers: RL("oops", "29", "12345") }) + : res(npmBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["github"] }); + expect(r.rate.github).toBeUndefined(); + }); + + it("surfaces an exhausted github window as a rateLimited error (TC-048)", async () => { + const http: HttpFetcher = async (url) => + url.includes("/search/repositories") + ? res({}, { status: 403, headers: RL("60", "0", "777") }) + : res(npmBody()); + const r = await searchPlugins({ tag: "t", http, sources: ["github"] }); + expect(r.errors[0]).toMatchObject({ + backend: "github", + rateLimited: true, + status: 403, + resetAt: 777, + }); + expect(r.rate.github?.remaining).toBe(0); + }); + + it("skips github while the window is exhausted (TC-049)", async () => { + let t = 0; + let ghCalls = 0; + const http: HttpFetcher = async (url) => { + if (url.includes("/search/repositories")) { + ghCalls++; + return res({}, { status: 403, headers: RL("60", "0", "100") }); + } + return res(npmBody(npmPkg({ links: {} }))); + }; + const ps = createPluginSearch({ http, clock: { now: () => t } }); + await ps.search({ tag: "t" }); + expect(ghCalls).toBe(1); + t = 50_000; + const r = await ps.search({ tag: "x" }); + expect(ghCalls).toBe(1); + expect(r.errors.some((e) => e.backend === "github" && e.rateLimited)).toBe( + true, + ); + expect(ps.lastRate().github?.remaining).toBe(0); + }); + + it("resumes github once the clock passes resetAt (TC-050)", async () => { + let t = 0; + let ghCalls = 0; + const http: HttpFetcher = async (url) => { + if (url.includes("/search/repositories")) { + ghCalls++; + return ghCalls === 1 + ? res({}, { status: 403, headers: RL("60", "0", "100") }) + : res(ghBody(), { headers: RL("60", "59", "100") }); + } + return res(npmBody(npmPkg({ links: {} }))); + }; + const ps = createPluginSearch({ http, clock: { now: () => t } }); + await ps.search({ tag: "t" }); + t = 200_000; + await ps.search({ tag: "y" }); + expect(ghCalls).toBe(2); + }); + + it("does not short-circuit on the first call (TC-063)", async () => { + let ghCalls = 0; + const http: HttpFetcher = async (url) => { + if (url.includes("/search/repositories")) { + ghCalls++; + return res(ghBody()); + } + return res(npmBody()); + }; + const ps = createPluginSearch({ http, clock: { now: () => 0 } }); + expect(ps.lastRate()).toEqual({}); + await ps.search({ tag: "t" }); + expect(ghCalls).toBe(1); + }); +}); + +// ── FR-012 + defaults ──────────────────────────────────────────────────────── + +describe("sourceToInstallInput (FR-012)", () => { + it("renders npm and github sources (TC-051)", () => { + expect(sourceToInstallInput({ type: "npm", package: "@s/p" })).toBe("@s/p"); + expect(sourceToInstallInput({ type: "github", repo: "o/r" })).toBe("o/r"); + }); + + it("renders git, url, git-subdir and path sources (TC-052)", () => { + expect(sourceToInstallInput({ type: "git", url: "https://g/x.git" })).toBe( + "https://g/x.git", + ); + expect(sourceToInstallInput({ type: "url", url: "https://u" })).toBe( + "https://u", + ); + expect( + sourceToInstallInput({ + type: "git-subdir", + url: "https://g/x.git", + path: "sub", + }), + ).toBe("https://g/x.git"); + expect(sourceToInstallInput({ type: "path", path: "/abs" })).toBe("/abs"); + }); +}); + +describe("default global fetch (NFR-005)", () => { + it("uses the global fetch when no HttpFetcher is injected", async () => { + const orig = globalThis.fetch; + const calls: unknown[][] = []; + globalThis.fetch = (async (url: string, init: unknown) => { + calls.push([url, init]); + return res( + url.includes("/-/v1/search") + ? npmBody(npmPkg({ links: {} })) + : ghBody(), + ); + }) as unknown as typeof fetch; + try { + const direct = await defaultHttpFetcher("http://x", { + headers: { A: "b" }, + }); + expect(await direct.json()).toBeDefined(); + const r = await searchPlugins({ tag: "t" }); + expect(r.results.length).toBeGreaterThan(0); + const ps = createPluginSearch(); + const r2 = await ps.search({ tag: "z" }); + expect(r2.errors).toEqual([]); + expect(calls.length).toBeGreaterThan(0); + } finally { + globalThis.fetch = orig; + } + }); +});