diff --git a/.agents/pm/chores/pm-github-1wka.toon b/.agents/pm/chores/pm-github-1wka.toon new file mode 100644 index 0000000..755074b --- /dev/null +++ b/.agents/pm/chores/pm-github-1wka.toon @@ -0,0 +1,19 @@ +id: pm-github-1wka +title: Eliminate all 37 any usages from pm-github source with real GitHub Projects V2 GraphQL types and typed handler contexts +description: "index.ts carried 37 any usages in two groups. Group A was untyped GitHub Projects V2 GraphQL responses: githubGraphQL at every call site plus untyped map callbacks over nodes, fields and options. Replaced with 18 precise interfaces derived from the ACTUAL query strings next to each call site rather than guessed: project metadata, item connections with pageInfo and nodes, field lists with their option sets, the draft-issue and add-item mutation payloads, the owner-projects listing and the issue node-id resolution. GraphQL nullability is modelled honestly, so nodes is typed Array because connections may include nulls for redacted or inaccessible items and the callers already filter them. Verified one type field-for-field against its query: fetchProjectItems selects pageInfo hasNextPage endCursor and nodes id fieldValueByName content, and the interfaces declare exactly those and nothing unselected. Group B was untyped extension handler and hook contexts across runSync, runExport, runValidate, the project commands, the seven registered run handlers, registerPreflight, registerImporter, registerExporter, hooks.afterCommand and the search query path; all now use the real SDK types. Source any count is 0, down from 37, and tests are 0 down from 52 (the two remaining grep hits are comments that mention as any, not usages)." +type: Chore +status: closed +priority: 1 +tags: [] +created_at: "2026-07-27T16:28:44.867Z" +updated_at: "2026-07-27T16:43:43.814Z" +closed_at: "2026-07-27T16:28:47.026Z" +author: claude-code +acceptance_criteria: "grep for any patterns over index.ts and test/*.ts returns no real usages (was 37 source / 52 test); typecheck, check and 168 tests pass; behaviour unchanged; every GraphQL interface is derived from the query string at its call site with honest nullability; production proof shows install ok, all five subcommands registered, no flag collision and pm health extensions ok." +notes[4]{created_at,author,text}: + "2026-07-27T16:28:45.882Z",claude-code,"Production proof with the built extension installed into a throwaway workspace holding two seeded items: install ok with no warnings, host commands unaffected (pm list renders items, pm list --json valid), all five registered subcommands export import project sync validate resolve, no host-owned global flag is declared, pm health reports extensions ok with no warnings, and github validate --help works. 168 tests pass." + "2026-07-27T16:28:46.285Z",claude-code,"Audited the camelCase multi-word flag contract that caused three defects in pm-ops, and pm-github was already correct: index.ts line 432 documents that flags may arrive kebab-case or camelCase, and every one of the eleven multi-word flags (comments-mode, dry-run, include-comments, include-prs, label-map, link-deps, no-add-missing, skip-drafts, status-map, with-comments) is read with BOTH forms via optionEnabled. No change needed." + "2026-07-27T16:43:43.086Z",claude-code,"Greptile review on PR 18 found a REAL crash the typing refactor introduced, reproduced with T-Rex: the SDK declares SearchProviderQueryContext.documents as ItemDocument[] with a REQUIRED metadata, so the refactor replaced the pre-existing guard d?.metadata ? d.metadata : d with a bare d.metadata. But SearchProviderQueryContext carries an index signature and the runtime hands RAW pm items through on some paths, so trusting the declared type produced undefined entries and crashed indexByProvenance with a TypeError. This is the classic typing-refactor hazard: the type says the field always exists, the runtime disagrees, and the guard that proved otherwise gets deleted in the name of the type. Guard restored as a typed narrowing helper searchDocumentToItem handling wrapped, raw and neither, with no any." + "2026-07-27T16:43:43.812Z",claude-code,"My first regression tests for this were NOT falsifiable: reverting the guard still passed 172/172, because they tested the helper in isolation and a hand-mirrored copy of the mapping rather than the code the provider runs. That is exactly the flaw this session kept finding elsewhere. Fixed by extracting the provider's real mapping into resolveSearchCorpus and testing THAT: the surrounding query handler does network I/O first so an end-to-end test cannot reach the mapping without stubbing internals, and an inline expression is untestable in practice. Falsification now verified: reverting the guard inside resolveSearchCorpus fails the test (171 pass 1 fail), restoring passes 172." +close_reason: "Shipped: 37 source any eliminated with real Projects V2 GraphQL types and SDK handler contexts, verified by production proof rather than tests alone." +body: "" diff --git a/.agents/pm/chores/pm-github-iai5.toon b/.agents/pm/chores/pm-github-iai5.toon new file mode 100644 index 0000000..99ecc42 --- /dev/null +++ b/.agents/pm/chores/pm-github-iai5.toon @@ -0,0 +1,14 @@ +id: pm-github-iai5 +title: Adopt pm-cli 2026.7.27 in pm-github +description: "Raises the pm-cli peerDependency to >=2026.7.27 and the devDependency to ^2026.7.27, refreshing the lockfile. The package version and manifest version were already 2026.7.27 from the daily release. An activation proof is mandatory rather than optional for this bump because 2026.7.27 hardened host-owned global flags and a collision aborts command registration at the offending command, dropping it and every later sibling. Proven: all five registered subcommands resolve, no host-owned global is declared anywhere in index.ts, and pm health reports extensions ok with no warnings." +type: Chore +status: closed +priority: 2 +tags: [] +created_at: "2026-07-27T16:29:02.835Z" +updated_at: "2026-07-27T16:29:04.381Z" +closed_at: "2026-07-27T16:29:04.380Z" +author: claude-code +acceptance_criteria: peerDependencies >=2026.7.27 and devDependencies ^2026.7.27 with an updated lockfile; node_modules resolves 2026.7.27; all five pm-github subcommands register and resolve on the real CLI; flag-collision audit clean; pm health extensions ok. +close_reason: Shipped with the activation proof the hardened 2026.7.27 host requires. +body: "" diff --git a/.agents/pm/history/pm-github-1wka.jsonl b/.agents/pm/history/pm-github-1wka.jsonl new file mode 100644 index 0000000..dc1507c --- /dev/null +++ b/.agents/pm/history/pm-github-1wka.jsonl @@ -0,0 +1,8 @@ +{"ts":"2026-07-27T16:28:44.867Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-1wka"},{"op":"add","path":"/metadata/title","value":"Eliminate all 37 any usages from pm-github source with real GitHub Projects V2 GraphQL types and typed handler contexts"},{"op":"add","path":"/metadata/description","value":"index.ts carried 37 any usages in two groups. Group A was untyped GitHub Projects V2 GraphQL responses: githubGraphQL at every call site plus untyped map callbacks over nodes, fields and options. Replaced with 18 precise interfaces derived from the ACTUAL query strings next to each call site rather than guessed: project metadata, item connections with pageInfo and nodes, field lists with their option sets, the draft-issue and add-item mutation payloads, the owner-projects listing and the issue node-id resolution. GraphQL nullability is modelled honestly, so nodes is typed Array because connections may include nulls for redacted or inaccessible items and the callers already filter them. Verified one type field-for-field against its query: fetchProjectItems selects pageInfo hasNextPage endCursor and nodes id fieldValueByName content, and the interfaces declare exactly those and nothing unselected. Group B was untyped extension handler and hook contexts across runSync, runExport, runValidate, the project commands, the seven registered run handlers, registerPreflight, registerImporter, registerExporter, hooks.afterCommand and the search query path; all now use the real SDK types. Source any count is 0, down from 37, and tests are 0 down from 52 (the two remaining grep hits are comments that mention as any, not usages)."},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-07-27T16:28:44.867Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-27T16:28:44.867Z"},{"op":"add","path":"/metadata/author","value":"claude-code"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"92891c55c0b900971ee9f6f70dc9eda8b07351fc0604dc1e3f7863e9867090a2","message":""} +{"ts":"2026-07-27T16:28:45.452Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:28:45.452Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"92891c55c0b900971ee9f6f70dc9eda8b07351fc0604dc1e3f7863e9867090a2","after_hash":"785eacc78293c21e730927c3a96b95755ecc15c92acbf9eabeac00bfee8e47b5"} +{"ts":"2026-07-27T16:28:45.883Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:28:45.883Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-07-27T16:28:45.882Z","author":"claude-code","text":"Production proof with the built extension installed into a throwaway workspace holding two seeded items: install ok with no warnings, host commands unaffected (pm list renders items, pm list --json valid), all five registered subcommands export import project sync validate resolve, no host-owned global flag is declared, pm health reports extensions ok with no warnings, and github validate --help works. 168 tests pass."}]}],"before_hash":"785eacc78293c21e730927c3a96b95755ecc15c92acbf9eabeac00bfee8e47b5","after_hash":"91ddacd8b025bec9408efeec22cab941cf77281393a61e0bdeb878e78501b60c"} +{"ts":"2026-07-27T16:28:46.286Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-07-27T16:28:46.285Z","author":"claude-code","text":"Audited the camelCase multi-word flag contract that caused three defects in pm-ops, and pm-github was already correct: index.ts line 432 documents that flags may arrive kebab-case or camelCase, and every one of the eleven multi-word flags (comments-mode, dry-run, include-comments, include-prs, label-map, link-deps, no-add-missing, skip-drafts, status-map, with-comments) is read with BOTH forms via optionEnabled. No change needed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:28:46.286Z"}],"before_hash":"91ddacd8b025bec9408efeec22cab941cf77281393a61e0bdeb878e78501b60c","after_hash":"a67fa7e5c06f1df3254f1fe8024232bb1c859a6d55ec79e7384a10af8d4a8cc4"} +{"ts":"2026-07-27T16:28:46.697Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:28:46.697Z"},{"op":"add","path":"/metadata/acceptance_criteria","value":"grep for any patterns over index.ts and test/*.ts returns no real usages (was 37 source / 52 test); typecheck, check and 168 tests pass; behaviour unchanged; every GraphQL interface is derived from the query string at its call site with honest nullability; production proof shows install ok, all five subcommands registered, no flag collision and pm health extensions ok."}],"before_hash":"a67fa7e5c06f1df3254f1fe8024232bb1c859a6d55ec79e7384a10af8d4a8cc4","after_hash":"afe8d90457aeda9cea5801c0fb975af8eff2065958c5fceda7cb8c54a762c30c"} +{"ts":"2026-07-27T16:28:47.027Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:28:47.027Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-27T16:28:47.026Z"},{"op":"add","path":"/metadata/close_reason","value":"Shipped: 37 source any eliminated with real Projects V2 GraphQL types and SDK handler contexts, verified by production proof rather than tests alone."}],"before_hash":"afe8d90457aeda9cea5801c0fb975af8eff2065958c5fceda7cb8c54a762c30c","after_hash":"f03ce54264da4d987e3417a61bba8b1c22c821abb576bdc2815b1eb40edb3f3a"} +{"ts":"2026-07-27T16:43:43.087Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-07-27T16:43:43.086Z","author":"claude-code","text":"Greptile review on PR 18 found a REAL crash the typing refactor introduced, reproduced with T-Rex: the SDK declares SearchProviderQueryContext.documents as ItemDocument[] with a REQUIRED metadata, so the refactor replaced the pre-existing guard d?.metadata ? d.metadata : d with a bare d.metadata. But SearchProviderQueryContext carries an index signature and the runtime hands RAW pm items through on some paths, so trusting the declared type produced undefined entries and crashed indexByProvenance with a TypeError. This is the classic typing-refactor hazard: the type says the field always exists, the runtime disagrees, and the guard that proved otherwise gets deleted in the name of the type. Guard restored as a typed narrowing helper searchDocumentToItem handling wrapped, raw and neither, with no any."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:43:43.087Z"}],"before_hash":"f03ce54264da4d987e3417a61bba8b1c22c821abb576bdc2815b1eb40edb3f3a","after_hash":"066fa3fbae3de7e1b41c4cb89df92a1507c13f6289186ba2f31deccdeeb4fc93"} +{"ts":"2026-07-27T16:43:43.814Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-07-27T16:43:43.812Z","author":"claude-code","text":"My first regression tests for this were NOT falsifiable: reverting the guard still passed 172/172, because they tested the helper in isolation and a hand-mirrored copy of the mapping rather than the code the provider runs. That is exactly the flaw this session kept finding elsewhere. Fixed by extracting the provider's real mapping into resolveSearchCorpus and testing THAT: the surrounding query handler does network I/O first so an end-to-end test cannot reach the mapping without stubbing internals, and an inline expression is untestable in practice. Falsification now verified: reverting the guard inside resolveSearchCorpus fails the test (171 pass 1 fail), restoring passes 172."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:43:43.814Z"}],"before_hash":"066fa3fbae3de7e1b41c4cb89df92a1507c13f6289186ba2f31deccdeeb4fc93","after_hash":"c5c5eb066efbeade0950da4e8f6a08dc1e748bf8da7dbfe679b2cbaa53b84224"} diff --git a/.agents/pm/history/pm-github-iai5.jsonl b/.agents/pm/history/pm-github-iai5.jsonl new file mode 100644 index 0000000..db18771 --- /dev/null +++ b/.agents/pm/history/pm-github-iai5.jsonl @@ -0,0 +1,4 @@ +{"ts":"2026-07-27T16:29:02.835Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-iai5"},{"op":"add","path":"/metadata/title","value":"Adopt pm-cli 2026.7.27 in pm-github"},{"op":"add","path":"/metadata/description","value":"Raises the pm-cli peerDependency to >=2026.7.27 and the devDependency to ^2026.7.27, refreshing the lockfile. The package version and manifest version were already 2026.7.27 from the daily release. An activation proof is mandatory rather than optional for this bump because 2026.7.27 hardened host-owned global flags and a collision aborts command registration at the offending command, dropping it and every later sibling. Proven: all five registered subcommands resolve, no host-owned global is declared anywhere in index.ts, and pm health reports extensions ok with no warnings."},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-07-27T16:29:02.835Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-27T16:29:02.835Z"},{"op":"add","path":"/metadata/author","value":"claude-code"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"f13087d6b5712219966d22468c0e8d74787c81c4039caff9f7f79d7afeb13f5f","message":""} +{"ts":"2026-07-27T16:29:03.334Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:29:03.334Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"f13087d6b5712219966d22468c0e8d74787c81c4039caff9f7f79d7afeb13f5f","after_hash":"82b44c9201fb0e04ad42c4bf99ed34e7be12501e737669c00d99c3d9faf1d2a9"} +{"ts":"2026-07-27T16:29:03.827Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:29:03.827Z"},{"op":"add","path":"/metadata/acceptance_criteria","value":"peerDependencies >=2026.7.27 and devDependencies ^2026.7.27 with an updated lockfile; node_modules resolves 2026.7.27; all five pm-github subcommands register and resolve on the real CLI; flag-collision audit clean; pm health extensions ok."}],"before_hash":"82b44c9201fb0e04ad42c4bf99ed34e7be12501e737669c00d99c3d9faf1d2a9","after_hash":"4344ae2fbe075c46152086d0573e376636822ca0d12dd5eb12ce0b0d355e4980"} +{"ts":"2026-07-27T16:29:04.381Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6ff7a19f0fde28814757fa94","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-27T16:29:04.381Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-27T16:29:04.380Z"},{"op":"add","path":"/metadata/close_reason","value":"Shipped with the activation proof the hardened 2026.7.27 host requires."}],"before_hash":"4344ae2fbe075c46152086d0573e376636822ca0d12dd5eb12ce0b0d355e4980","after_hash":"0349103eec3601bc181ad207f82ed5a90a34ab263df6a078ff023196f8ec8d43"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b61965..e44f419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Other + +- Adopt pm-cli 2026.7.27 in pm-github ([pm-github-iai5](https://github.com/unbraind/pm-github/blob/main/.agents/pm/chores/pm-github-iai5.toon)) +- Eliminate all 37 any usages from pm-github source with real GitHub Projects V2 GraphQL types and typed handler contexts ([pm-github-1wka](https://github.com/unbraind/pm-github/blob/main/.agents/pm/chores/pm-github-1wka.toon)) + ## 2026.7.27 - 2026-07-27 ### Removed diff --git a/index.ts b/index.ts index d615835..f4b76f8 100644 --- a/index.ts +++ b/index.ts @@ -13,40 +13,37 @@ // Issues use the REST API; Projects v2 is GraphQL-only (see the Projects v2 // section below and the pure plan/mapping logic in ./projects.ts). -import type { ExtensionApi, ExtensionModule } from "@unbrained/pm-cli/sdk/authoring"; +import type { + AfterCommandHookContext, + CommandHandlerContext, + ExtensionApi, + ExtensionModule, + ImportExportContext, + PreflightOverrideContext, + SearchProviderQueryContext, +} from "@unbrained/pm-cli/sdk/authoring"; import https from "node:https"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import type { - BulkItemMutation, - CommitItemMutationsOptions, - CommitItemMutationsResult, +import { + comments as pmCommentsFn, + commitItemMutations as sdkCommitItemMutations, + listAllItemMetadata as sdkListAllItemMetadata, + normalizeItemId as sdkNormalizeItemId, + readSettings as sdkReadSettings, + type BulkItemMutation, + type CommentsCommandOptions, + type CommentsResult, + type CommitItemMutationsOptions, + type CommitItemMutationsResult, + type ItemDocument, + type ItemMetadata, + type PmClientOptions, } from "@unbrained/pm-cli/sdk"; - -// The `comments()` annotation primitive is only exported from the public SDK -// since pm CLI 2026.7.14. Load it lazily so hosts on older CLI versions still -// load the extension fine (this is ESM — a missing named export in a static -// import kills the whole module at load time); only `--comments-mode -// annotations|both` needs it, and it degrades with a clear error instead. -type PmCommentsFn = ( - itemId: string, - options: Record, - ctx: { pmRoot: string }, -) => Promise<{ comments?: unknown[] } | undefined>; - -async function loadPmComments(): Promise { - const sdk = (await import("@unbrained/pm-cli/sdk")) as Record; - const fn = sdk.comments; - if (typeof fn !== "function") { - throw new Error( - "the installed pm CLI does not export the SDK comments() primitive (requires pm CLI >= 2026.7.14)", - ); - } - return fn as PmCommentsFn; -} +import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph"; import { type ProjectItem, @@ -687,6 +684,43 @@ function readPmItems(pmRoot: string): PmItem[] { // Index existing pm items by their GitHub provenance tag for O(1) idempotent // matching on re-import. +/** + * Narrow one runtime search document to a {@link PmItem}. + * + * The SDK declares `SearchProviderQueryContext.documents` as `ItemDocument[]` + * with a REQUIRED `metadata`, but the runtime also hands raw pm items straight + * through on some paths — `SearchProviderQueryContext` carries an + * `[key: string]: unknown` index signature, so its shape is looser than the + * declared type. Trusting `d.metadata` unconditionally therefore yields + * `undefined` entries and crashes `indexByProvenance` with a TypeError; the + * pre-typing code guarded this with `d?.metadata ? d.metadata : d`, and that + * guard is preserved here rather than dropped in the name of the declared type. + * Anything matching neither shape is skipped instead of poisoning the index. + */ +export function searchDocumentToItem(document: ItemDocument | PmItem | null | undefined): PmItem | undefined { + if (!document || typeof document !== "object") return undefined; + const wrapped = (document as ItemDocument).metadata; + if (wrapped && typeof wrapped === "object") return wrapped as PmItem; + return "id" in document ? (document as PmItem) : undefined; +} + +/** + * Resolve the corpus the search provider matches remote hits against. + * + * Prefers the runtime-provided documents (already the current corpus) and falls + * back to a fresh workspace read when absent. This is the provider's REAL mapping, + * extracted so it is directly testable: the surrounding `query` handler performs + * network I/O first, so an end-to-end test cannot reach the mapping without + * stubbing internals, and an inline expression would be untestable in practice. + */ +export function resolveSearchCorpus(documents: unknown, pmRootValue: unknown): PmItem[] { + const pmRoot = typeof pmRootValue === "string" && pmRootValue ? pmRootValue : ".agents/pm"; + if (!Array.isArray(documents)) return readPmItems(pmRoot); + return documents + .map((document) => searchDocumentToItem(document as ItemDocument | PmItem | null | undefined)) + .filter((item): item is PmItem => item !== undefined); +} + export function indexByProvenance(items: PmItem[]): Map { const index = new Map(); for (const item of items) { @@ -749,33 +783,25 @@ function assertSdkFunction(fn: unknown, exportName: string): F { return fn as F; } -async function loadAtomicSdk( - importSdk: () => Promise> = - () => import("@unbrained/pm-cli/sdk"), -): Promise> { - try { - return await importSdk(); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - throw new CommandError( - `--atomic requires @unbrained/pm-cli>=2026.7.20, but the SDK could not be imported: ${msg}. Install or upgrade @unbrained/pm-cli.`, - EXIT_CODE.USAGE, - ); - } +/** Minimal shape of the SDK module the atomic import path reads. */ +interface AtomicSdkModule { + commitItemMutations?: CommitItemMutations; } -/** Resolve the atomic bulk-mutation helper lazily so normal imports stay compatible. */ +/** Resolve the atomic bulk-mutation helper. Accepts an optional SDK override for + * tests that simulate a missing export; the default path uses the top-level + * imported `commitItemMutations` so normal imports never touch the dynamic + * loader. */ export async function resolveCommitItemMutations( - importSdk?: () => Promise>, + importSdk?: () => Promise, ): Promise { if (importSdk) { - const mod = await loadAtomicSdk(importSdk); + const mod = await importSdk(); return assertSdkFunction(mod.commitItemMutations, "commitItemMutations"); } if (cachedCommitItemMutations) return cachedCommitItemMutations; - const mod = await loadAtomicSdk(); cachedCommitItemMutations = assertSdkFunction( - mod.commitItemMutations, + sdkCommitItemMutations, "commitItemMutations", ); return cachedCommitItemMutations; @@ -787,18 +813,17 @@ async function resolveAtomicSdkFunctions(opts: AtomicImportOptions): Promise<{ readSettings: ReadSettings; }> { const needsSdk = !opts.commitItemMutations || !opts.normalizeItemId || !opts.readSettings; - const mod = needsSdk ? await loadAtomicSdk() : undefined; return { commitItemMutations: opts.commitItemMutations ?? assertSdkFunction( - mod?.commitItemMutations, + needsSdk ? sdkCommitItemMutations : undefined, "commitItemMutations", ), normalizeItemId: opts.normalizeItemId ?? assertSdkFunction( - mod?.normalizeItemId, + needsSdk ? sdkNormalizeItemId : undefined, "normalizeItemId", ), readSettings: opts.readSettings ?? assertSdkFunction( - mod?.readSettings, + needsSdk ? sdkReadSettings : undefined, "readSettings", ), }; @@ -1526,12 +1551,10 @@ export async function syncGithubCommentsToAnnotations( } const release = acquisition.status === "acquired" ? () => acquisition.lock.release() : () => {}; try { - let pmComments: PmCommentsFn; let existing: { text?: string }[] = []; try { - pmComments = await loadPmComments(); - const list = await pmComments(itemId, {}, { pmRoot }); - existing = (list?.comments ?? []) as { text?: string }[]; + const list: CommentsResult = await pmCommentsFn(itemId, {}, { pmRoot }); + existing = list.comments; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.error(`#${issueNumber}: could not read existing comments for ${itemId} — ${msg}`); @@ -1547,7 +1570,7 @@ export async function syncGithubCommentsToAnnotations( } const author = c.user?.login ?? "github"; try { - await pmComments(itemId, { add: buildCommentText(c), author }, { pmRoot }); + await pmCommentsFn(itemId, { add: buildCommentText(c), author }, { pmRoot }); added++; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -1852,43 +1875,24 @@ interface DepLinkSdk { ) => string[]; } -// Lazy-load the two SDK primitives --link-deps needs. Mirrors loadPmComments / -// loadAtomicSdk: a missing export degrades to a clear upgrade error instead of -// killing extension load on older hosts. -async function loadDepLinkSdk(): Promise { - let mod: Record; - try { - mod = (await import("@unbrained/pm-cli/sdk")) as Record; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - throw new CommandError( - `--link-deps requires @unbrained/pm-cli>=2026.7.20, but the SDK could not be imported: ${msg}.`, - EXIT_CODE.USAGE, - ); - } - const listFn = mod.listAllItemMetadata; - const collectFn = mod.collectNewOrderingCycleWarnings; - if (typeof listFn !== "function" || typeof collectFn !== "function") { - throw new CommandError( - "--link-deps requires @unbrained/pm-cli>=2026.7.20 with the SDK exports " + - "listAllItemMetadata + collectNewOrderingCycleWarnings. Upgrade @unbrained/pm-cli.", - EXIT_CODE.USAGE, - ); - } +// Build the default `DepLinkSdk` from the top-level SDK imports so the +// --link-deps second pass never needs a dynamic import. `listAllItemMetadata` +// returns full `ItemMetadata` objects; we project to the `DepLinkSnapshotItem` +// structural subset the link pass actually reads. The SDK's cycle detector +// reads only id/tags/dependencies off each item; `DepLinkSnapshotItem` is that +// structural subset, so the cast on `collectNewOrderingCycleWarnings` is sound. +function defaultDepLinkSdk(): DepLinkSdk { return { listAllItemMetadata: async (pmRoot: string) => { - const raw = (await (listFn as (r: string) => Promise>>)(pmRoot)); + const raw = await sdkListAllItemMetadata(pmRoot); return raw.map((i) => ({ - id: String(i.id), - tags: Array.isArray(i.tags) ? (i.tags as string[]) : [], - dependencies: Array.isArray(i.dependencies) - ? (i.dependencies as Array<{ id: string; kind: string }>) - : undefined, + id: i.id, + tags: i.tags, + dependencies: i.dependencies, })); }, - // The SDK's cycle detector reads only id/tags/dependencies off each item; - // DepLinkSnapshotItem is that structural subset, so the cast is sound. - collectNewOrderingCycleWarnings: collectFn as DepLinkSdk["collectNewOrderingCycleWarnings"], + collectNewOrderingCycleWarnings: + sdkCollectNewOrderingCycleWarnings as DepLinkSdk["collectNewOrderingCycleWarnings"], }; } @@ -1932,7 +1936,7 @@ export async function linkImportedDependencies( let before: DepLinkSnapshotItem[]; try { const needsSdk = !deps.listItemMetadata || !deps.collectOrderingCycleWarnings; - const sdk = needsSdk ? await loadDepLinkSdk() : undefined; + const sdk = needsSdk ? defaultDepLinkSdk() : undefined; listMeta = deps.listItemMetadata ?? ((r: string) => sdk!.listAllItemMetadata(r)); collect = deps.collectOrderingCycleWarnings ?? sdk!.collectNewOrderingCycleWarnings; before = await listMeta(pmRoot); @@ -2407,7 +2411,7 @@ export function planSync(items: PmItem[], repo: string): SyncPlanEntry[] { // Command handler for `pm github sync`: preview or apply the pm → GitHub issue // sync plan, scoped by --ids and honoring --dry-run / --apply. -async function runSync(ctx: any) { +async function runSync(ctx: CommandHandlerContext) { const options = ctx.options || {}; const repo = optionString(options, "repo") || (ctx.args?.[0] as string | undefined); const dryRun = optionEnabled(options, "dry-run", "dryRun"); @@ -2728,7 +2732,7 @@ export function applyOutcomeError( // stdout in JSON mode (pm renders the return value). Used by both the // `registerExporter("github", ...)` entry point and the `pm github export` // command so the surface stays consistent. -async function runExport(ctx: any) { +async function runExport(ctx: CommandHandlerContext) { const options = ctx.options || {}; const jsonMode = ctx.global?.json === true; const format = optionString(options, "format") || "json"; @@ -2954,7 +2958,7 @@ function detectTokenSource(): "env" | "gh" | "none" { // Command handler for `pm github validate`: checks token source, gh CLI, rate // limit, and the reachable issue counts for the configured repo. -async function runValidate(ctx: any): Promise { +async function runValidate(ctx: CommandHandlerContext): Promise { const options = ctx.options || {}; const repo = optionString(options, "repo") || (ctx.args?.[0] as string | undefined); const gh_cli = detectGhCli(); @@ -3041,6 +3045,136 @@ interface GraphQLResponse { errors?: Array<{ message: string; type?: string }>; } +// --- GraphQL response shapes (derived from the actual query strings below) --- + +/** The Status single-select field on a Project v2 board, as selected by + * `STATUS_FIELD_GQL`. The fragment spreads onto `ProjectV2SingleSelectField`, + * so every field is optional in the response (GitHub omits the fragment for + * projects whose Status field is a different type). */ +interface GraphqlStatusField { + id?: string; + name: string; + options?: Array<{ id: string; name: string }>; +} + +/** A Project v2 node as returned by the `resolveProject` query (user or org). + * `title`/`url` are requested but may be null for redacted projects. */ +interface GraphqlProjectNode { + id: string; + title?: string; + url?: string; + statusField?: GraphqlStatusField | null; +} + +/** Response shape of the `resolveProject` query — one of user/organization + * resolves, the other is null. */ +interface GraphqlResolveProjectData { + user?: { projectV2?: GraphqlProjectNode | null } | null; + organization?: { projectV2?: GraphqlProjectNode | null } | null; +} + +/** The content polymorph of one project-item node: `DraftIssue`, `Issue`, + * `PullRequest`, or a redacted/unknown type. Only the fields the query selects + * are typed; everything is optional because GitHub may return null for + * redacted items. */ +interface GraphqlProjectItemContent { + __typename: "DraftIssue" | "Issue" | "PullRequest" | string; + title?: string; + body?: string; + number?: number; + url?: string; + state?: string; + stateReason?: string | null; + repository?: { nameWithOwner?: string } | null; +} + +/** The `fieldValueByName("Status")` single-select value on a project item + * node. Both fields are optional (an item may have no Status set). */ +interface GraphqlFieldValueByName { + name?: string; + optionId?: string; +} + +/** One node in the project-items connection. The id is the stable idempotency + * key; `content` and `fieldValueByName` are null when the item is redacted. */ +interface GraphqlProjectItemNode { + id?: string; + fieldValueByName?: GraphqlFieldValueByName | null; + content?: GraphqlProjectItemContent | null; +} + +/** The items connection on a Project v2, as returned by `fetchProjectItems`. + * `nodes` is `(T | null)[]` because GraphQL connections may include nulls for + * redacted/inaccessible items; the caller `.filter(Boolean)`s them. */ +interface GraphqlProjectItemsConnection { + pageInfo?: { hasNextPage?: boolean; endCursor?: string }; + nodes?: Array; +} + +/** Response shape of the `fetchProjectItems` query. The node may be null when + * the project id is invalid or inaccessible. */ +interface GraphqlFetchProjectItemsData { + node?: { items?: GraphqlProjectItemsConnection } | null; +} + +/** Response shape of the `addProjectV2DraftIssue` mutation. */ +interface GraphqlAddDraftData { + addProjectV2DraftIssue?: { projectItem?: { id?: string } } | null; +} + +/** Response shape of the `addProjectV2ItemById` mutation. */ +interface GraphqlAddIssueData { + addProjectV2ItemById?: { item?: { id?: string } } | null; +} + +/** Response shape of the `updateProjectV2ItemFieldValue` mutation. */ +interface GraphqlSetStatusData { + updateProjectV2ItemFieldValue?: { projectV2Item?: { id?: string } } | null; +} + +/** Response shape of the `gqlResolveIssueNodeId` query. */ +interface GraphqlResolveIssueNodeIdData { + repository?: { issueOrPullRequest?: { id?: string } | null } | null; +} + +/** One project-summary node in a `projectsV2` connection. */ +interface GraphqlProjectsV2Node { + number: number; + title?: string; + url?: string; + closed?: boolean; + shortDescription?: string | null; +} + +/** A `projectsV2` connection (user or organization), as returned by the listing + * query. */ +interface GraphqlProjectsV2Connection { + pageInfo?: { hasNextPage?: boolean; endCursor?: string }; + nodes?: Array; +} + +/** Response shape of the `listOwnerProjectsV2Nodes` query — one of + * user/organization resolves, the other is null. */ +interface GraphqlListOwnerProjectsData { + user?: { projectsV2?: GraphqlProjectsV2Connection | null } | null; + organization?: { projectsV2?: GraphqlProjectsV2Connection | null } | null; +} + +/** One field node in the project-fields query. `__typename` distinguishes + * `ProjectV2FieldCommon` from `ProjectV2SingleSelectField`; `options` is only + * present on single-select fields. */ +interface GraphqlFieldNode { + __typename: string; + name?: string; + dataType?: string; + options?: Array<{ name: string }> | null; +} + +/** Response shape of the `runProjectFields` query. */ +interface GraphqlProjectFieldsData { + node?: { fields?: { nodes?: Array } | null } | null; +} + // One GraphQL round-trip. GraphQL reports business errors as HTTP 200 with an // `errors` array, so we surface those explicitly. The combined user+org queries // below intentionally return a partial error for the wrong owner type while the @@ -3087,7 +3221,7 @@ async function resolveProject(ref: ProjectRef, token: string | undefined): Promi user(login:$owner){ projectV2(number:$number){ id title url ${STATUS_FIELD_GQL} } } organization(login:$owner){ projectV2(number:$number){ id title url ${STATUS_FIELD_GQL} } } }`; - const data = await githubGraphQL(token, query, { owner: ref.owner, number: ref.number }); + const data = await githubGraphQL(token, query, { owner: ref.owner, number: ref.number }); const userNode = data.user?.projectV2; const orgNode = data.organization?.projectV2; const node = userNode ?? orgNode; @@ -3108,8 +3242,8 @@ async function resolveProject(ref: ProjectRef, token: string | undefined): Promi // Normalize a raw GraphQL project-item node into the ProjectItem model, // tolerating null/undefined nodes and missing content (defensive against // partial GraphQL errors / inaccessible items). -function normalizeProjectItemNode(n: any): ProjectItem { - const c = n?.content ?? {}; +function normalizeProjectItemNode(n: GraphqlProjectItemNode): ProjectItem { + const c: GraphqlProjectItemContent = n?.content ?? { __typename: "Unknown" }; const tn = c.__typename; let content: ProjectItemContent; if (tn === "DraftIssue") { @@ -3170,7 +3304,7 @@ async function fetchProjectItems(projectId: string, token: string | undefined): const items: ProjectItem[] = []; let cursor: string | undefined; for (;;) { - const data = await githubGraphQL(token, query, { id: projectId, cursor: cursor ?? null }); + const data = await githubGraphQL(token, query, { id: projectId, cursor: cursor ?? null }); const conn = data.node?.items; if (!conn) break; for (const n of conn.nodes ?? []) { @@ -3193,7 +3327,7 @@ async function gqlAddDraft( token: string | undefined, ): Promise { const q = `mutation($p:ID!,$t:String!,$b:String){ addProjectV2DraftIssue(input:{projectId:$p,title:$t,body:$b}){ projectItem{ id } } }`; - const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body ?? "" }); + const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body ?? "" }); const id = d.addProjectV2DraftIssue?.projectItem?.id; if (!id) throw new CommandError("addProjectV2DraftIssue returned no item id."); return id; @@ -3203,7 +3337,7 @@ async function gqlAddDraft( // new project item id. async function gqlAddIssue(projectId: string, contentId: string, token: string | undefined): Promise { const q = `mutation($p:ID!,$c:ID!){ addProjectV2ItemById(input:{projectId:$p,contentId:$c}){ item{ id } } }`; - const d = await githubGraphQL(token, q, { p: projectId, c: contentId }); + const d = await githubGraphQL(token, q, { p: projectId, c: contentId }); const id = d.addProjectV2ItemById?.item?.id; if (!id) throw new CommandError("addProjectV2ItemById returned no item id."); return id; @@ -3218,7 +3352,7 @@ async function gqlSetStatus( token: string | undefined, ): Promise { const q = `mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){ updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:$f,value:{singleSelectOptionId:$o}}){ projectV2Item{ id } } }`; - await githubGraphQL(token, q, { p: projectId, i: itemId, f: fieldId, o: optionId }); + await githubGraphQL(token, q, { p: projectId, i: itemId, f: fieldId, o: optionId }); } // Resolve the GraphQL node id of a repo's issue or PR by number, returning @@ -3231,7 +3365,7 @@ async function gqlResolveIssueNodeId( const [owner, name] = repo.split("/"); if (!owner || !name) return undefined; const q = `query($o:String!,$n:String!,$num:Int!){ repository(owner:$o,name:$n){ issueOrPullRequest(number:$num){ ... on Issue { id } ... on PullRequest { id } } } }`; - const d = await githubGraphQL(token, q, { o: owner, n: name, num: number }); + const d = await githubGraphQL(token, q, { o: owner, n: name, num: number }); return d.repository?.issueOrPullRequest?.id ?? undefined; } @@ -3250,7 +3384,7 @@ function projectItemDescription(ref: ProjectRef, c: ProjectItemContent): string // A page of a projectsV2 connection as returned by GitHub GraphQL. export interface ProjectsV2Page { - nodes?: any[]; + nodes?: Array; pageInfo?: { hasNextPage?: boolean; endCursor?: string }; } @@ -3261,8 +3395,8 @@ export interface ProjectsV2Page { // is unit-testable without network I/O. Mirrors the fetchProjectItems loop. export async function collectProjectsV2Pages( fetchPage: (cursor: string | undefined) => Promise, -): Promise { - const out: any[] = []; +): Promise { + const out: GraphqlProjectsV2Node[] = []; let cursor: string | undefined; for (;;) { const conn = await fetchPage(cursor); @@ -3286,7 +3420,7 @@ export async function collectProjectsV2Pages( export type GraphQLTransport = ( query: string, variables: Record, -) => Promise; +) => Promise; // Resolve + paginate the projectsV2 connection for a user-or-org owner using an // injected GraphQL transport. A login is either a user or an organization, @@ -3297,7 +3431,7 @@ export type GraphQLTransport = ( export async function listOwnerProjectsV2Nodes( owner: string, graphQL: GraphQLTransport, -): Promise { +): Promise { let ownerType: "user" | "organization" | null = null; return collectProjectsV2Pages(async (cursor) => { const q = ` @@ -3314,7 +3448,7 @@ export async function listOwnerProjectsV2Nodes( }); } -async function runProjectList(ctx: any) { +async function runProjectList(ctx: CommandHandlerContext) { const options = ctx.options || {}; const owner = optionString(options, "owner") || (ctx.args?.[0] as string | undefined); if (!owner) { @@ -3324,8 +3458,8 @@ async function runProjectList(ctx: any) { ); } const token = resolveGitHubToken(); - const nodes = await listOwnerProjectsV2Nodes(owner, (q, vars) => githubGraphQL(token, q, vars)); - const projects = nodes.map((n: any) => ({ + const nodes = await listOwnerProjectsV2Nodes(owner, (q, vars) => githubGraphQL(token, q, vars)); + const projects = nodes.map((n) => ({ number: n.number, title: n.title ?? "", url: n.url ?? "", @@ -3347,7 +3481,7 @@ async function runProjectList(ctx: any) { // --- project fields -------------------------------------------------------- -async function runProjectFields(ctx: any) { +async function runProjectFields(ctx: CommandHandlerContext) { const options = ctx.options || {}; const ref = parseProjectRef(optionString(options, "project") || (ctx.args?.[0] as string | undefined)); if (!ref) { @@ -3366,11 +3500,11 @@ async function runProjectFields(ctx: any) { ... on ProjectV2SingleSelectField { name options{ name } } } } }}}`; - const d = await githubGraphQL(token, q, { id: meta.id }); - const fields = (d.node?.fields?.nodes ?? []).filter(Boolean).map((f: any) => ({ + const d = await githubGraphQL(token, q, { id: meta.id }); + const fields = (d.node?.fields?.nodes ?? []).filter((f): f is GraphqlFieldNode => f !== null).map((f) => ({ name: f.name, type: f.dataType ?? f.__typename, - options: Array.isArray(f.options) ? f.options.map((o: any) => o.name) : undefined, + options: Array.isArray(f.options) ? f.options.map((o) => o.name) : undefined, })); if (ctx.global?.json !== true) { console.error(`Project ${ref.owner}/${ref.number} — ${meta.title} (${meta.ownerType})`); @@ -3386,7 +3520,7 @@ async function runProjectFields(ctx: any) { // --- project import -------------------------------------------------------- -async function runProjectImport(ctx: any) { +async function runProjectImport(ctx: CommandHandlerContext) { const options = ctx.options || {}; const ref = parseProjectRef(optionString(options, "project") || (ctx.args?.[0] as string | undefined)); if (!ref) { @@ -3574,7 +3708,7 @@ interface PullPlanEntryLike { // Command handler for `pm github project sync`: preview (default) or apply the // bidirectional Projects v2 sync plan, honoring --push/--pull/--apply/--ids. -async function runProjectSync(ctx: any) { +async function runProjectSync(ctx: CommandHandlerContext) { const options = ctx.options || {}; const ref = parseProjectRef(optionString(options, "project") || (ctx.args?.[0] as string | undefined)); if (!ref) { @@ -3831,7 +3965,7 @@ export default defineExtension({ // warning when a github mutation is requested without a resolvable token; // the authoritative validation + non-zero exit lives in the handlers. // ----------------------------------------------------------------------- - api.registerPreflight((ctx: any) => { + api.registerPreflight((ctx: PreflightOverrideContext) => { if (isMutatingGithubCommand(ctx.command, ctx.options || {})) { if (!resolveGitHubToken()) { console.error( @@ -3846,7 +3980,7 @@ export default defineExtension({ // ----------------------------------------------------------------------- // importer — `pm github import ` (idempotent native pipeline) // ----------------------------------------------------------------------- - api.registerImporter("github", async (ctx: any) => { + api.registerImporter("github", async (ctx: ImportExportContext) => { return runImport(ctx.args?.[0], ctx.pm_root, parseImportOptions(ctx.options || {})); }, { description: @@ -3884,7 +4018,7 @@ export default defineExtension({ // (upsert) rather than duplicated. --json returns the plan object; we never // write our own stdout in JSON mode (pm renders the return value). // ----------------------------------------------------------------------- - api.registerExporter("github", async (ctx: any) => runExport(ctx), { + api.registerExporter("github", async (ctx: ImportExportContext) => runExport(ctx), { description: "Export pm items as GitHub issues. SAFE BY DEFAULT: prints a create/update " + "plan and writes NOTHING. Use --apply --repo to write to " + @@ -3917,7 +4051,7 @@ export default defineExtension({ if (typeof api.registerSearchProvider === "function") { api.registerSearchProvider({ name: "github", - async query(qctx: any) { + async query(qctx: SearchProviderQueryContext) { const repo = resolveSearchRepo(qctx.options || {}); if (!repo) return []; const token = resolveGitHubToken(); @@ -3935,9 +4069,7 @@ export default defineExtension({ // Map remote matches back to local items via provenance tags. Prefer // the runtime-provided documents (already the current corpus); fall // back to a fresh read if absent. - const docs: PmItem[] = Array.isArray(qctx.documents) - ? qctx.documents.map((d: any) => (d?.metadata ? d.metadata : d)) - : readPmItems(qctx.pm_root || ".agents/pm"); + const docs = resolveSearchCorpus(qctx.documents, qctx.pm_root); const index = indexByProvenance(docs); return mapSearchHits(matchedNumbers, repo, index); }, @@ -3950,7 +4082,7 @@ export default defineExtension({ // the exact command to run. Triggers only when a github-linked item (one // carrying a `gh:owner/repo#N` provenance tag) is closed/reopened. // ----------------------------------------------------------------------- - api.hooks.afterCommand((ctx: any) => { + api.hooks.afterCommand((ctx: AfterCommandHookContext) => { if (!process.env.PM_GITHUB_SYNC) return; if (!ctx.ok) return; if (ctx.command !== "close" && ctx.command !== "update") return; @@ -4004,7 +4136,7 @@ export default defineExtension({ "Items must carry a `gh:owner/repo#N` tag — import with `pm github import` first.", "Use --dry-run to preview the close/reopen plan before pushing.", ], - async run(ctx: any) { + async run(ctx: CommandHandlerContext) { return runSync(ctx); }, }); @@ -4042,7 +4174,7 @@ export default defineExtension({ "Re-running is safe: existing items are updated, not duplicated.", "Use --atomic for a durable resumable journal with reverse compensation on ordinary failures.", ], - async run(ctx: any) { + async run(ctx: CommandHandlerContext) { return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options)); }, }); @@ -4068,7 +4200,7 @@ export default defineExtension({ "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise the rate limit and reach private repos.", "Pass --repo to verify a specific repo is reachable.", ], - async run(ctx: any) { + async run(ctx: CommandHandlerContext) { const report = await runValidate(ctx); const jsonMode = ctx.global?.json === true; if (!jsonMode) { @@ -4103,7 +4235,7 @@ export default defineExtension({ "Pass an owner login, e.g. `pm github project list unbraind`.", "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` (private projects need `project` scope).", ], - async run(ctx: any) { + async run(ctx: CommandHandlerContext) { return runProjectList(ctx); }, }); @@ -4125,7 +4257,7 @@ export default defineExtension({ "Pass , e.g. `pm github project fields unbraind/5`.", "A project without a Status field cannot receive pushed statuses (items are still added).", ], - async run(ctx: any) { + async run(ctx: CommandHandlerContext) { return runProjectFields(ctx); }, }); @@ -4156,7 +4288,7 @@ export default defineExtension({ "Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` (needs `project`/`read:project`).", "Re-running is safe: linked items are updated, not duplicated.", ], - async run(ctx: any) { + async run(ctx: CommandHandlerContext) { return runProjectImport(ctx); }, }); @@ -4192,7 +4324,7 @@ export default defineExtension({ "Design a --status-map with `pm github project fields ` first.", "Use --ids to scope; unknown IDs fail fast.", ], - async run(ctx: any) { + async run(ctx: CommandHandlerContext) { return runProjectSync(ctx); }, }); diff --git a/package-lock.json b/package-lock.json index c12a878..aa56a22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.7.26", + "@unbrained/pm-cli": "^2026.7.27", "pm-changelog": "^2026.7.25", "typescript": "^7.0.2" }, @@ -18,7 +18,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.26" + "@unbrained/pm-cli": ">=2026.7.27" } }, "node_modules/@apm-js-collab/code-transformer": { @@ -717,9 +717,9 @@ } }, "node_modules/@unbrained/pm-cli": { - "version": "2026.7.26", - "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.7.26.tgz", - "integrity": "sha512-Esbt0SE0PgYlZzdgCCu5MlGt6zxkAvy5V/Pj8MOC3E2zfe5mCuazwi3mN/z71C0Xyp2TzSivpratUTia2IlOdQ==", + "version": "2026.7.27", + "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.7.27.tgz", + "integrity": "sha512-18OEQkGXVAd71yJ6iuZKojl29ypqqlN0jJYFAcs+VBsM7EH45wrOP/7C3jrPW4pZWF4fJ5M6VGYOC0a358nh9w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a808ae3..4601ff1 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,11 @@ "merge:install": "pm merge install" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.26" + "@unbrained/pm-cli": ">=2026.7.27" }, "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "^2026.7.26", + "@unbrained/pm-cli": "^2026.7.27", "pm-changelog": "^2026.7.25", "typescript": "^7.0.2" }, diff --git a/test/comments-sync.test.ts b/test/comments-sync.test.ts index 63d002d..5f4862d 100644 --- a/test/comments-sync.test.ts +++ b/test/comments-sync.test.ts @@ -23,10 +23,16 @@ import { parseImportOptions, syncGithubCommentsToAnnotations, } from "../dist/index.js"; +import type { GhComment, GhIssue } from "../dist/index.js"; // Minimal factories ----------------------------------------------------------- +// +// Override-typed against the real exported shape so a typo on an override key +// fails the test compile instead of silently being dropped — these factories +// drive `composeBody`, `buildCommentText`, and the annotations-mode sync path +// which all expect a `GhComment`/`GhIssue`. -function ghComment(overrides: Record = {}): any { +function ghComment(overrides: Partial = {}): GhComment { return { id: 1001, user: { login: "alice" }, @@ -36,7 +42,7 @@ function ghComment(overrides: Record = {}): any { }; } -function ghIssue(overrides: Record = {}): any { +function ghIssue(overrides: Partial = {}): GhIssue { return { number: 1, title: "t", @@ -161,7 +167,10 @@ test("extractSyncedCommentIds collects ids from markers and ignores plain commen const ids = extractSyncedCommentIds(stored); assert.deepEqual([...ids].sort((a, b) => a - b), [7, 99]); assert.strictEqual(extractSyncedCommentIds([]).size, 0); - assert.strictEqual(extractSyncedCommentIds([{ text: undefined } as any]).size, 0); + // `text?: string` accepts `undefined`, so no cast is needed — the test still + // exercises the empty-text branch the live consumer hits when a comment body + // is missing/null. + assert.strictEqual(extractSyncedCommentIds([{ text: undefined }]).size, 0); }); test("parseCreatedItemId reads the id from `pm create --json` stdout", () => { diff --git a/test/import-lock.test.ts b/test/import-lock.test.ts index 70be705..2e28d7a 100644 --- a/test/import-lock.test.ts +++ b/test/import-lock.test.ts @@ -32,11 +32,12 @@ import { resolvePmDataDir, syncGithubCommentsToAnnotations, } from "../dist/index.js"; +import type { GhComment } from "../dist/index.js"; import { waitForBarrier } from "./helpers/barrier.js"; // Minimal factories + workspace helpers -------------------------------------- -function ghComment(overrides: Record = {}): any { +function ghComment(overrides: Partial = {}): GhComment { return { id: 1001, user: { login: "alice" }, diff --git a/test/smoke.test.ts b/test/smoke.test.ts index e9362d4..0846a75 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -6,6 +6,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { createExtensionTestHarness, type ExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; + import extension, { CommandError, EXIT_CODE, @@ -22,6 +24,9 @@ import extension, { formatRateLimit, isDraftPr, listOwnerProjectsV2Nodes, + indexByProvenance, + searchDocumentToItem, + resolveSearchCorpus, mapSearchHits, mapState, sameOrigin, @@ -37,9 +42,49 @@ import extension, { resolveSearchRepo, scopeItemsByIds, } from "../dist/index.js"; +import type { + ExportPlanEntry, + GhIssue, + ImportOptions, + ProjectsV2Page, +} from "../dist/index.js"; + +// The extension registers against six surfaces (commands, importers, schema, +// hooks, preflight, search) — exactly the `capabilities` block in +// manifest.json. `createExtensionTestHarness` runs pm's real activation engine +// over that set, so every registration is validated (and a missing-capability +// drop fails fast) instead of being silently swallowed by an `activate(api as +// any)` test double that asserts against itself. The in-memory module export +// carries no `.capabilities` field, so we mirror manifest.json here. +const MANIFEST_CAPABILITIES = ["commands", "importers", "schema", "hooks", "preflight", "search"] as const; +const harnessPromise: Promise = + createExtensionTestHarness(extension, { capabilities: [...MANIFEST_CAPABILITIES] }); + +// Shape of the `runExport` dry-run return value — narrowed so the assertion +// sites don't have to cast the `CommandHandlerResult.result` (typed `unknown`). +interface RunExportDryRunResult { + dry_run: boolean; + plan: unknown[]; + would_create: number; + would_update: number; + repo?: string; + label_map?: Record; + scoped_ids?: string[]; +} + +// Structural stand-in for the unexported `PullPlanEntryLike` the buildPullEntry +// tests construct. Fields mirror index.ts verbatim so structural typing lines up. +interface TestPullEntry { + itemId: string; + pmId: string; + title: string; + fromStatus: string; + toStatus: string; +} -// Minimal GhIssue factory for filter/field tests. -function issue(overrides: Record = {}): any { +// Minimal GhIssue factory for filter/field tests — typed so a typo on an +// override key fails the test compile instead of silently being dropped. +function issue(overrides: Partial = {}): GhIssue { return { number: 1, title: "t", @@ -55,13 +100,19 @@ function issue(overrides: Record = {}): any { }; } -const baseOpts: any = { +// Base ImportOptions literal — applyClientFilters wants full ImportOptions, so +// the literal supplies every field (commentsMode, atomic, linkDeps default to +// their safe/no-op values, matching the import command's real defaults). +const baseOpts: ImportOptions = { state: "all", includePrs: false, skipDrafts: false, withComments: false, + commentsMode: "body", itemType: "Issue", dryRun: false, + atomic: false, + linkDeps: false, }; test("extension has required shape", () => { @@ -72,39 +123,20 @@ test("extension has required shape", () => { assert.strictEqual(typeof extension.activate, "function", "activate should be a function"); }); -test("extension registers at least one capability", () => { - const registered: string[] = []; - // Mirror the real ExtensionApi surface so activate() can register every - // capability the extension uses (commands, importers, exporters, schema - // fields, hooks). - const api = { - registerCommand: () => { registered.push("command"); }, - registerParser: () => { registered.push("parser"); }, - registerPreflight: () => { registered.push("preflight"); }, - registerService: () => { registered.push("service"); }, - registerFlags: () => { registered.push("flags"); }, - registerItemFields: () => { registered.push("itemFields"); }, - registerItemTypes: () => { registered.push("itemTypes"); }, - registerMigration: () => { registered.push("migration"); }, - registerRenderer: () => { registered.push("renderer"); }, - registerImporter: () => { registered.push("importer"); }, - registerExporter: () => { registered.push("exporter"); }, - registerSearchProvider: () => { registered.push("search"); }, - registerVectorStoreAdapter: () => { registered.push("vectorStore"); }, - hooks: { - beforeCommand: () => { registered.push("hook:before"); }, - afterCommand: () => { registered.push("hook:after"); }, - onWrite: () => { registered.push("hook:onWrite"); }, - onRead: () => { registered.push("hook:onRead"); }, - onIndex: () => { registered.push("hook:onIndex"); }, - }, - }; - extension.activate(api as any); - assert.ok(registered.includes("importer"), "should register the github importer"); - assert.ok(registered.includes("exporter"), "should register the github exporter"); - assert.ok(registered.includes("itemFields"), "should register github schema fields"); - assert.ok(registered.includes("hook:after"), "should register an afterCommand hook"); - assert.ok(registered.length > 0, `extension should register at least one capability, got: ${JSON.stringify(registered)}`); +test("extension registers at least one capability", async () => { + // Running the real activation engine proves the manifest grants every + // capability the activate() registers against; the harness promotes any + // dropped registration (from a missing capability) into a hard failure. + const ext = await harnessPromise; + ext.assertImporter({ name: "github" }); + ext.assertExporter({ name: "github" }); + ext.assertItemField({ name: "github_url" }); + ext.assertHook({ kind: "after_command" }); + ext.assertSearchProvider({ name: "github" }); + assert.ok( + ext.activation.registrations.commands.length > 0, + "extension should register commands", + ); }); test("parseNextLink extracts the rel=\"next\" page URL", () => { @@ -247,38 +279,27 @@ test("resolveSearchRepo prefers --repo, then PM_GITHUB_REPO env", () => { } }); -test("extension registers the github search provider when supported", () => { - let searchProvider: any; - const noop = () => {}; - const api: any = { - registerCommand: noop, registerParser: noop, registerPreflight: noop, - registerService: noop, registerFlags: noop, registerItemFields: noop, - registerItemTypes: noop, registerMigration: noop, registerRenderer: noop, - registerImporter: noop, registerExporter: noop, - registerSearchProvider: (def: any) => { searchProvider = def; }, - registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - assert.ok(searchProvider, "search provider should be registered"); - assert.strictEqual(searchProvider.name, "github"); - assert.strictEqual(typeof searchProvider.query, "function"); -}); - -test("github validate command is registered", () => { - let captured: any; - const noop = () => {}; - const api: any = { - registerCommand: (def: any) => { if (def?.name === "github validate") captured = def; }, - registerParser: noop, registerPreflight: noop, registerService: noop, - registerFlags: noop, registerItemFields: noop, registerItemTypes: noop, - registerMigration: noop, registerRenderer: noop, registerImporter: noop, - registerExporter: noop, registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - assert.ok(captured, "github validate should be registered"); - assert.strictEqual(typeof captured.run, "function"); +test("extension registers the github search provider when supported", async () => { + const ext = await harnessPromise; + ext.assertSearchProvider({ name: "github" }); + const provider = ext.activation.registrations.search_providers + .find((p) => p.definition.name === "github"); + assert.ok(provider, "search provider should be registered"); + assert.strictEqual( + typeof provider!.runtime_definition.query, + "function", + "search provider query must be a function", + ); +}); + +test("github validate command is registered", async () => { + const ext = await harnessPromise; + // `registrations.commands` carries the public metadata; `commands.handlers` + // is the dispatch registry that owns the live `run` function. + ext.assertCommandContract({ name: "github validate" }); + const handler = ext.activation.commands.handlers.find((h) => h.command === "github validate"); + assert.ok(handler, "github validate should be registered"); + assert.strictEqual(typeof handler!.run, "function"); }); test("isDraftPr only flags draft pull requests, never plain issues", () => { @@ -346,71 +367,45 @@ test("formatRateLimit renders a quota line, undefined when no quota present", () assert.strictEqual(formatRateLimit({ low: false }), undefined, "no remaining → no line"); }); -test("schema registers github_author / created_at / updated_at fields", () => { - let fields: any[] = []; - const noop = () => {}; - const api: any = { - registerCommand: noop, registerParser: noop, registerPreflight: noop, registerService: noop, - registerFlags: noop, registerItemFields: (f: any[]) => { fields = f; }, registerItemTypes: noop, - registerMigration: noop, registerRenderer: noop, registerImporter: noop, - registerExporter: noop, registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - const names = fields.map((f) => f.name); - for (const expected of ["github_url", "github_number", "github_state", "github_author", "github_created_at", "github_updated_at"]) { - assert.ok(names.includes(expected), `schema should declare ${expected}`); +test("schema registers github_author / created_at / updated_at fields", async () => { + const ext = await harnessPromise; + // Assert each GitHub-provenance field is registered via the real schema + // surface; the assertion fails if a field is missing from the manifest's + // `schema` capability. + for (const expected of [ + "github_url", + "github_number", + "github_state", + "github_author", + "github_created_at", + "github_updated_at", + ]) { + ext.assertItemField({ name: expected }); } }); -test("import command advertises the --skip-drafts flag", () => { - let captured: any; - const noop = () => {}; - const api: any = { - registerCommand: (def: any) => { if (def?.name === "gh-issues import") captured = def; }, - registerParser: noop, registerPreflight: noop, registerService: noop, registerFlags: noop, - registerItemFields: noop, registerItemTypes: noop, registerMigration: noop, registerRenderer: noop, - registerImporter: noop, registerExporter: noop, registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - assert.ok(captured?.flags?.some((f: any) => f.long === "--skip-drafts"), "import should expose --skip-drafts"); -}); - -test("sync command advertises the --ids flag", () => { - let captured: any; - const noop = () => {}; - const api: any = { - registerCommand: (def: any) => { if (def?.name === "github sync") captured = def; }, - registerParser: noop, registerPreflight: noop, registerService: noop, registerFlags: noop, - registerItemFields: noop, registerItemTypes: noop, registerMigration: noop, registerRenderer: noop, - registerImporter: noop, registerExporter: noop, registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - assert.ok(captured?.flags?.some((f: any) => f.long === "--ids"), "sync should expose --ids"); +test("import command advertises the --skip-drafts flag", async () => { + const ext = await harnessPromise; + ext.assertCommandContract({ name: "gh-issues import", flags: ["--skip-drafts"] }); +}); + +test("sync command advertises the --ids flag", async () => { + const ext = await harnessPromise; + ext.assertCommandContract({ name: "github sync", flags: ["--ids"] }); }); test("gh-issues import rejects a missing owner/repo argument", async () => { - let captured: { run: (ctx: any) => unknown } | undefined; - const noop = () => {}; - const api = { - registerCommand: (def: any) => { if (def?.name === "gh-issues import") captured = def; }, - registerParser: noop, registerPreflight: noop, registerService: noop, - registerFlags: noop, registerItemFields: noop, registerItemTypes: noop, - registerMigration: noop, registerRenderer: noop, registerImporter: noop, - registerExporter: noop, registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api as any); - assert.ok(captured, "import command should be registered"); + const ext = await harnessPromise; + // runRegisteredCommandForTest propagates any thrown error carrying a numeric + // `exitCode` — exactly how the runtime surfaces a `CommandError` — so the + // host dispatch path is identical to `pm gh-issues import` with no argv. await assert.rejects( - async () => captured!.run({ args: [], options: {}, pm_root: ".agents/pm" }), + () => ext.runCommand({ command: "gh-issues import", args: [] }), (err: unknown) => { - // The runtime only treats a thrown error as a cleanly handled non-zero - // exit (no second handler invocation) when it carries a numeric exitCode. - assert.match((err as Error).message, /owner\/repo/); - assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.USAGE); + assert.match((err as Error).message, /owner\/repo/, + "missing argv should surface the actionable usage message"); + assert.strictEqual((err as CommandError).exitCode, EXIT_CODE.USAGE, + "the handler must carry a numeric exitCode so pm treats it as a non-zero exit"); return true; }, "missing argument should throw a CommandError carrying a USAGE exit code", @@ -426,12 +421,12 @@ test("gh-issues import rejects a missing owner/repo argument", async () => { // a summary instead of aborting mid-batch. // --------------------------------------------------------------------------- -function exportEntry(overrides: Partial<{ - id: string; - action: "create" | "update"; - number: number; - title: string; -}> = {}): any { +function exportEntry(overrides: { + id?: string; + action?: "create" | "update"; + number?: number; + title?: string; +} = {}): ExportPlanEntry { const action = overrides.action ?? "create"; return { id: overrides.id ?? "github-1", @@ -623,12 +618,15 @@ test("parseSince rejects out-of-range relative durations without throwing", () = test("parseImportOptions parses relative --since into an ISO timestamp", () => { const now = Date.UTC(2026, 0, 10, 0, 0, 0); const orig = Date.now; - (Date as any).now = () => now; + // `Date.now` is a static-side readonly; we monkey-patch it via a cast on a + // writeable structural view (no `any`) so callers cannot see the swap as a + // type error and the cleanup in `finally` restores the original binding. + (Date as { now: () => number }).now = () => now; try { const opts = parseImportOptions({ since: "7d" }); assert.strictEqual(opts.since, "2026-01-03T00:00:00.000Z"); } finally { - (Date as any).now = orig; + (Date as { now: () => number }).now = orig; } }); @@ -660,7 +658,7 @@ test("parseImportOptions surfaces --dry-run", () => { test("parseImportOptions rejects malformed --since instead of silently removing the filter", () => { assert.throws( () => parseImportOptions({ since: "nonsense" }), - (err: any) => err?.name === "CommandError" && err?.exitCode === 2, + (err: unknown) => err instanceof CommandError && err.exitCode === EXIT_CODE.USAGE, ); }); @@ -781,55 +779,27 @@ test("buildExportPlan strips both gh: and gh-project: provenance tags, keeps use // pm github export command registration (new --export mode surface) // --------------------------------------------------------------------------- -test("native github exporter declares --label-map and --dry-run metadata", () => { - let captured: any; - let handler: unknown; - const noop = () => {}; - const api: any = { - registerCommand: noop, - registerParser: noop, registerPreflight: noop, registerService: noop, registerFlags: noop, - registerItemFields: noop, registerItemTypes: noop, registerMigration: noop, registerRenderer: noop, - registerImporter: noop, - registerExporter: (name: string, fn: unknown, options: unknown) => { - if (name === "github") { handler = fn; captured = options; } - }, - registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - assert.ok(captured, "github exporter should declare command metadata"); - assert.strictEqual(typeof handler, "function"); - const longs = captured.flags.map((f: any) => f.long); - assert.ok(longs.includes("--label-map"), "export should advertise --label-map"); - assert.ok(longs.includes("--dry-run"), "export should advertise --dry-run"); - assert.ok(longs.includes("--apply"), "export should advertise --apply"); - assert.ok(longs.includes("--repo"), "export should advertise --repo"); -}); - -test("native github importer advertises --include-comments as an alias for --with-comments", () => { - let captured: any; - let handler: unknown; - const noop = () => {}; - const api: any = { - registerCommand: noop, - registerParser: noop, registerPreflight: noop, registerService: noop, registerFlags: noop, - registerItemFields: noop, registerItemTypes: noop, registerMigration: noop, registerRenderer: noop, - registerImporter: (name: string, fn: unknown, options: unknown) => { - if (name === "github") { handler = fn; captured = options; } - }, - registerExporter: noop, registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - assert.ok( - captured?.flags?.some((f: any) => f.long === "--include-comments"), - "import should expose --include-comments as an alias", - ); - assert.ok( - captured?.flags?.some((f: any) => f.long === "--since"), - "installed github import command should expose --since", - ); - assert.strictEqual(typeof handler, "function"); +test("native github exporter declares --label-map and --dry-run metadata", async () => { + const ext = await harnessPromise; + ext.assertExporter({ name: "github" }); + // Importer/exporter-declared flags land in `registrations.flags` against the + // generated `"github export"` command path — assert them via that surface so + // the metadata is verified by the real host registration pipeline. + ext.assertFlags({ targetCommand: "github export", flags: ["--label-map", "--dry-run", "--apply", "--repo"] }); + // The dispatch handler is the live function injected into commands.handlers. + const handler = ext.activation.commands.handlers.find((h) => h.command === "github export"); + assert.ok(handler, "github exporter should register a command handler"); + assert.strictEqual(typeof handler!.run, "function"); +}); + +test("native github importer advertises --include-comments as an alias for --with-comments", async () => { + const ext = await harnessPromise; + ext.assertImporter({ name: "github" }); + ext.assertFlags({ targetCommand: "github import", flags: ["--include-comments", "--since"] }); + // The dispatch handler is the live function injected into commands.handlers. + const handler = ext.activation.commands.handlers.find((h) => h.command === "github import"); + assert.ok(handler, "github importer should register a command handler"); + assert.strictEqual(typeof handler!.run, "function"); }); test("manifest uses only runtime-supported capability names", async () => { @@ -848,7 +818,8 @@ test("sameOrigin only treats identical hosts as same-origin (token forwarding gu }); test("buildPullEntryArgs routes terminal statuses through the pm close lifecycle", () => { - const entry = (toStatus: string): any => ({ itemId: "PVTI_1", pmId: "pm-1", title: "t", fromStatus: "open", toStatus }); + const entry = (toStatus: string): TestPullEntry => + ({ itemId: "PVTI_1", pmId: "pm-1", title: "t", fromStatus: "open", toStatus }); // `closed` uses `pm close`, which records closed_at + close_reason. assert.deepEqual( buildPullEntryArgs(entry("closed"), "/root"), @@ -876,7 +847,12 @@ test("buildPullEntryArgs routes terminal statuses through the pm close lifecycle // (GitHub caps connections at 100/page) for both user and organization owners, // threading the endCursor through pageInfo and never silently truncating. -function projNode(n: number): any { +// Build a minimal ProjectsV2 node literal the pagination tests inject. We +// derive the element type from `ProjectsV2Page.nodes` so the factory tracks +// the (unexported) `GraphqlProjectsV2Node` the runtime contract expects. +type ProjectsV2Node = NonNullable[number]>; + +function projNode(n: number): ProjectsV2Node { return { number: n, title: `P${n}`, url: `https://github.com/orgs/o/projects/${n}`, closed: false, shortDescription: null }; } @@ -916,7 +892,7 @@ test("collectProjectsV2Pages stops when pageInfo is missing (defensive, no infin let calls = 0; const out = await collectProjectsV2Pages(async () => { calls++; - return { nodes: [projNode(1)] } as any; // no pageInfo + return { nodes: [projNode(1)] }; // no pageInfo, no infinite loop }); assert.equal(calls, 1); assert.equal(out.length, 1); @@ -926,14 +902,15 @@ test("collectProjectsV2Pages stops when hasNextPage=true but endCursor is absent let calls = 0; const out = await collectProjectsV2Pages(async () => { calls++; - return { nodes: [projNode(1)], pageInfo: { hasNextPage: true } as any }; + return { nodes: [projNode(1)], pageInfo: { hasNextPage: true } }; }); assert.equal(calls, 1, "must not loop forever paging with the same (absent) cursor"); assert.equal(out.length, 1); }); test("collectProjectsV2Pages tolerates null nodes and a null/early-stop fetcher", async () => { - const out = await collectProjectsV2Pages(async () => ({ nodes: [null, projNode(1), undefined], pageInfo: { hasNextPage: false } } as any)); + // The runtime contract accepts null nodes; the page filter drops them. + const out = await collectProjectsV2Pages(async () => ({ nodes: [null, projNode(1), null], pageInfo: { hasNextPage: false } })); assert.equal(out.length, 1, "null/undefined nodes are filtered out"); const empty = await collectProjectsV2Pages(async () => undefined); assert.equal(empty.length, 0); @@ -1015,7 +992,11 @@ test("listOwnerProjectsV2Nodes resolves the owner type on the first page and doe if (page === 2) { return { user: { projectsV2: { nodes: [projNode(2)], pageInfo: { hasNextPage: false, endCursor: "c2" } } }, organization: { projectsV2: { nodes: [projNode(999)] } } }; } - return null as any; + // Page 3 is unreachable: `collectProjectsV2Pages` stops after page 2 + // (hasNextPage=false). Return a graphQL-valid empty page so the transport's + // declared `Promise` return type is satisfied + // without an `as any` cast. + return { user: null, organization: null }; }); assert.equal(nodes.length, 2, "organization node from page 2 must be ignored once user is pinned"); assert.equal(nodes[1].number, 2); @@ -1075,46 +1056,33 @@ function makeExportTestWorkspace(): string { } } -function captureExporterHandler(): (ctx: any) => Promise { - let handler: ((ctx: any) => Promise) | undefined; - const noop = () => {}; - const api: any = { - registerCommand: noop, registerParser: noop, registerPreflight: noop, registerService: noop, - registerFlags: noop, registerItemFields: noop, registerItemTypes: noop, registerMigration: noop, - registerRenderer: noop, registerImporter: noop, - registerExporter: (name: string, fn: any) => { if (name === "github") handler = fn; }, - registerSearchProvider: noop, registerVectorStoreAdapter: noop, - hooks: { beforeCommand: noop, afterCommand: noop, onWrite: noop, onRead: noop, onIndex: noop }, - }; - extension.activate(api); - assert.ok(handler, "github exporter handler should be registered"); - return handler!; -} - // Captures every console.log / console.error argument during `fn`. Returns the -// flat list of joined string arguments written to each stream. +// flat list of joined string arguments written to each stream. The override +// functions are explicitly typed and assigned via a `typeof console.log` cast — +// no `as any` — so the swap is type-checked (and the `unknown[]` arg signature +// prevents an implicit `any` sneaking past `strict: true`). async function captureConsole(fn: () => Promise): Promise<{ stdout: string[]; stderr: string[]; result: T }> { const stdout: string[] = []; const stderr: string[] = []; const origLog = console.log; const origErr = console.error; - (console as any).log = (...a: any[]) => { stdout.push(a.map(String).join(" ")); }; - (console as any).error = (...a: any[]) => { stderr.push(a.map(String).join(" ")); }; + console.log = ((...args: unknown[]) => { stdout.push(args.map(String).join(" ")); }) as typeof console.log; + console.error = ((...args: unknown[]) => { stderr.push(args.map(String).join(" ")); }) as typeof console.error; try { const result = await fn(); return { stdout, stderr, result }; } finally { - (console as any).log = origLog; - (console as any).error = origErr; + console.log = origLog; + console.error = origErr; } } test("runExport dry-run routes the md preview to STDERR, never STDOUT", async () => { - const handler = captureExporterHandler(); + const ext = await harnessPromise; const root = makeExportTestWorkspace(); try { const { stdout, stderr, result } = await captureConsole(() => - handler({ pm_root: root, options: { format: "md" }, global: { json: false } }), + ext.runExporter({ exporter: "github", pmRoot: root, options: { format: "md" }, global: { json: false } }), ); // The whole point of the fix: the human preview must NOT touch stdout. assert.strictEqual( @@ -1132,21 +1100,23 @@ test("runExport dry-run routes the md preview to STDERR, never STDOUT", async () "the existing [dry-run] note should remain on stderr", ); // The machine-readable return object is still intact. - assert.strictEqual(result.dry_run, true); - assert.ok(Array.isArray(result.plan), "return object should carry the plan array"); - assert.strictEqual(result.would_create, 2, "both unlinked items are creates"); - assert.strictEqual(result.would_update, 0); + assert.strictEqual(result.handled, true, "the exporter handler should run clean"); + const exported = result.result as RunExportDryRunResult; + assert.strictEqual(exported.dry_run, true); + assert.ok(Array.isArray(exported.plan), "return object should carry the plan array"); + assert.strictEqual(exported.would_create, 2, "both unlinked items are creates"); + assert.strictEqual(exported.would_update, 0); } finally { rmSync(root, { recursive: true, force: true }); } }); test("runExport dry-run routes the JSON preview to STDERR, never STDOUT", async () => { - const handler = captureExporterHandler(); + const ext = await harnessPromise; const root = makeExportTestWorkspace(); try { const { stdout, stderr, result } = await captureConsole(() => - handler({ pm_root: root, options: {}, global: { json: false } }), + ext.runExporter({ exporter: "github", pmRoot: root, global: { json: false } }), ); // Default format is JSON; the JSON.stringify(plan) preview must go to // stderr so stdout stays only the host render (valid JSON under --json). @@ -1167,10 +1137,12 @@ test("runExport dry-run routes the JSON preview to STDERR, never STDOUT", async "the [dry-run] note should still be on stderr", ); // Return object still carries the plan for the host to render. - assert.strictEqual(result.dry_run, true); - assert.ok(Array.isArray(result.plan)); - assert.strictEqual(result.plan.length, 2); - assert.strictEqual(result.would_create, 2); + assert.strictEqual(result.handled, true, "the exporter handler should run clean"); + const exported = result.result as RunExportDryRunResult; + assert.strictEqual(exported.dry_run, true); + assert.ok(Array.isArray(exported.plan)); + assert.strictEqual(exported.plan.length, 2); + assert.strictEqual(exported.would_create, 2); } finally { rmSync(root, { recursive: true, force: true }); } @@ -1180,11 +1152,11 @@ test("runExport dry-run with global --json writes NO preview to either stream", // In JSON mode the host renders the return object to stdout; the extension // must stay completely silent (no preview on either stream) so stdout is a // single valid JSON object. - const handler = captureExporterHandler(); + const ext = await harnessPromise; const root = makeExportTestWorkspace(); try { const { stdout, stderr, result } = await captureConsole(() => - handler({ pm_root: root, options: { format: "md" }, global: { json: true } }), + ext.runExporter({ exporter: "github", pmRoot: root, options: { format: "md" }, global: { json: true } }), ); assert.strictEqual(stdout.length, 0, "nothing should be written to stdout in JSON mode"); // No human preview lines (only the host renders). The [dry-run] note is @@ -1197,8 +1169,58 @@ test("runExport dry-run with global --json writes NO preview to either stream", !stderr.some((l) => l.includes("[dry-run]")), "no human dry-run note should be emitted in JSON mode", ); - assert.ok(Array.isArray(result.plan), "return object should carry the plan array"); + assert.strictEqual(result.handled, true, "the exporter handler should run clean"); + const exported = result.result as RunExportDryRunResult; + assert.ok(Array.isArray(exported.plan), "return object should carry the plan array"); } finally { rmSync(root, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// Regression: the search provider must survive RAW pm items in +// `SearchProviderQueryContext.documents`. +// +// The SDK declares `documents: ItemDocument[]` with a REQUIRED `metadata`, so the +// typing refactor replaced the pre-existing guard (`d?.metadata ? d.metadata : d`) +// with a bare `d.metadata`. But `SearchProviderQueryContext` carries an +// `[key: string]: unknown` index signature and the runtime hands raw pm items +// straight through on some paths, so trusting the declared type yielded +// `undefined` entries and crashed `indexByProvenance` with a TypeError. +// +// Greptile's T-Rex run reproduced exactly that: a wrapped document produced a +// local hit while a raw document threw. These cases pin both shapes. +// --------------------------------------------------------------------------- + +test("searchDocumentToItem unwraps a metadata-wrapped document", () => { + const item = { id: "pm-1", title: "Wrapped", tags: ["gh:acme/repo#7"] }; + assert.deepEqual(searchDocumentToItem({ metadata: item, body: "" } as never), item); +}); + +test("searchDocumentToItem passes a RAW pm item through unchanged", () => { + const raw = { id: "pm-2", title: "Raw", tags: ["gh:acme/repo#8"] }; + assert.deepEqual(searchDocumentToItem(raw as never), raw, "a raw item must be used as-is, not read through .metadata"); +}); + +test("searchDocumentToItem skips values matching neither shape", () => { + for (const bad of [undefined, null, {}, "nope", 42]) { + assert.equal(searchDocumentToItem(bad as never), undefined, `${JSON.stringify(bad)} must be skipped, not indexed`); + } +}); + +test("resolveSearchCorpus (the provider's REAL mapping) handles wrapped, raw and junk documents", () => { + const wrapped = { id: "pm-1", title: "Wrapped", tags: ["gh:acme/repo#7"] }; + const raw = { id: "pm-2", title: "Raw", tags: ["gh:acme/repo#8"] }; + const documents = [{ metadata: wrapped, body: "" }, raw, undefined, null, {}]; + + // This is the exact function the search provider calls, so reverting the guard + // inside it fails this test — an inline expression at the call site could not be + // reached without stubbing the provider's network I/O. + const docs = resolveSearchCorpus(documents, ".agents/pm"); + assert.equal(docs.length, 2, "junk entries must be skipped, not indexed as undefined"); + + const index = indexByProvenance(docs); + assert.equal(index.get("acme/repo#7")?.id, "pm-1", "the wrapped document must be indexed"); + assert.equal(index.get("acme/repo#8")?.id, "pm-2", "the RAW document must be indexed, not dropped or thrown on"); + assert.equal(index.size, 2); +});