diff --git a/.agents/pm/features/pm-github-hdai.toon b/.agents/pm/features/pm-github-hdai.toon new file mode 100644 index 0000000..206ec13 --- /dev/null +++ b/.agents/pm/features/pm-github-hdai.toon @@ -0,0 +1,14 @@ +id: pm-github-hdai +title: "Import --link-deps: map GitHub issue-body dependency references (Blocked by/Depends on/Blocks #N) into pm dependency edges + emit ordering-cycle advisory via collectNewOrderingCycleWarnings" +description: "Opt-in second pass over imported issues that parses dependency references from issue bodies and materializes them as pm dependency edges between the corresponding pm items (resolved via the existing gh:owner/repo#N provenance index). Preserves the declared blocker graph so agents see real dependencies instead of flat items (project management = context management). Rejects self-references. Idempotent (pm update --dep dedupes). Works with and without --atomic. Non-atomic path surfaces the native pm update ordering_cycle_created warning; atomic path calls the SDK export collectNewOrderingCycleWarnings(before,after,changedId) to restore the advisory that commitItemMutations drops (its BulkItemMutationOutcome carries only {id,op}). Substantively resolves companion 7pnx part-2 (adopt collectNewOrderingCycleWarnings)." +type: Feature +status: open +priority: 2 +tags[4]: dependencies,feature,import,sdk-adoption +created_at: "2026-07-22T21:41:46.678Z" +updated_at: "2026-07-22T21:56:09.789Z" +author: codex +acceptance_criteria: "Ref parser unit-tested (Blocked by/Depends on/Blocks, cross-repo owner/repo#N, case-insensitive, dedupe, self-ref rejected, dangling refs reported not errored); edges resolved via provenance index; both atomic + non-atomic paths apply edges; ordering-cycle warnings surfaced in summary+JSON; re-import creates no duplicate edges; README + CHANGELOG updated; CI green; full bot loop clean" +notes[1]{created_at,author,text}: + "2026-07-22T21:56:09.788Z",codex,"IMPLEMENTED (branch pending). Added opt-in --link-deps second pass to pm github import: parseDependencyReferences (pure; Blocked by/Depends on→blocked_by, Blocks→blocks; bare #N + owner/repo#N; case/hyphen/colon tolerant; multi-ref; code-span stripping; dedupe), buildProvenanceIndexFromMetadata, planDependencyLinks (self-ref + dangling + missing-source skips, dedupe), linkImportedDependencies (best-effort apply via pm update --dep, path-agnostic, idempotent). Ordering-cycle advisory via SDK collectNewOrderingCycleWarnings over before/after listAllItemMetadata snapshots (restores the advisory commitItemMutations drops → resolves companion 7pnx part-2). Result gains linkedDependencies/unresolvedDependencyRefs/orderingCycleWarnings/dependencyLinkFailures; dry-run reports wouldLinkDependencyCandidates. peerDep floor unchanged (>=2026.7.20 already covers both SDK exports). 25 new tests (parser/planner/provenance units + hermetic DI orchestration + 3 real end-to-end runImport tests) — full suite 164 green, typecheck clean. REAL-DATA VERIFIED: created private throwaway repo with 3 dependency-phrased issues, imported via live REST → 3 edges (#2 blocked_by #1; #3 blocked_by #2 + #1), code-block #999 correctly ignored (0 unresolved), pm next correctly gated #2/#3 behind #1; repo deleted after." +body: "" diff --git a/.agents/pm/history/pm-github-hdai.jsonl b/.agents/pm/history/pm-github-hdai.jsonl new file mode 100644 index 0000000..3dfd047 --- /dev/null +++ b/.agents/pm/history/pm-github-hdai.jsonl @@ -0,0 +1,2 @@ +{"ts":"2026-07-22T21:41:46.678Z","author":"codex","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-hdai"},{"op":"add","path":"/metadata/title","value":"Import --link-deps: map GitHub issue-body dependency references (Blocked by/Depends on/Blocks #N) into pm dependency edges + emit ordering-cycle advisory via collectNewOrderingCycleWarnings"},{"op":"add","path":"/metadata/description","value":"Opt-in second pass over imported issues that parses dependency references from issue bodies and materializes them as pm dependency edges between the corresponding pm items (resolved via the existing gh:owner/repo#N provenance index). Preserves the declared blocker graph so agents see real dependencies instead of flat items (project management = context management). Rejects self-references. Idempotent (pm update --dep dedupes). Works with and without --atomic. Non-atomic path surfaces the native pm update ordering_cycle_created warning; atomic path calls the SDK export collectNewOrderingCycleWarnings(before,after,changedId) to restore the advisory that commitItemMutations drops (its BulkItemMutationOutcome carries only {id,op}). Substantively resolves companion 7pnx part-2 (adopt collectNewOrderingCycleWarnings)."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["dependencies","feature","import","sdk-adoption"]},{"op":"add","path":"/metadata/created_at","value":"2026-07-22T21:41:46.678Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-22T21:41:46.678Z"},{"op":"add","path":"/metadata/author","value":"codex"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Ref parser unit-tested (Blocked by/Depends on/Blocks, cross-repo owner/repo#N, case-insensitive, dedupe, self-ref rejected, dangling refs reported not errored); edges resolved via provenance index; both atomic + non-atomic paths apply edges; ordering-cycle warnings surfaced in summary+JSON; re-import creates no duplicate edges; README + CHANGELOG updated; CI green; full bot loop clean"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"7a56d6b54c6742ccf7f2d5245e704bee24121830bc57b64905f2caea0b34e4a4","message":""} +{"ts":"2026-07-22T21:56:09.789Z","author":"codex","op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-22T21:56:09.789Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-07-22T21:56:09.788Z","author":"codex","text":"IMPLEMENTED (branch pending). Added opt-in --link-deps second pass to pm github import: parseDependencyReferences (pure; Blocked by/Depends on→blocked_by, Blocks→blocks; bare #N + owner/repo#N; case/hyphen/colon tolerant; multi-ref; code-span stripping; dedupe), buildProvenanceIndexFromMetadata, planDependencyLinks (self-ref + dangling + missing-source skips, dedupe), linkImportedDependencies (best-effort apply via pm update --dep, path-agnostic, idempotent). Ordering-cycle advisory via SDK collectNewOrderingCycleWarnings over before/after listAllItemMetadata snapshots (restores the advisory commitItemMutations drops → resolves companion 7pnx part-2). Result gains linkedDependencies/unresolvedDependencyRefs/orderingCycleWarnings/dependencyLinkFailures; dry-run reports wouldLinkDependencyCandidates. peerDep floor unchanged (>=2026.7.20 already covers both SDK exports). 25 new tests (parser/planner/provenance units + hermetic DI orchestration + 3 real end-to-end runImport tests) — full suite 164 green, typecheck clean. REAL-DATA VERIFIED: created private throwaway repo with 3 dependency-phrased issues, imported via live REST → 3 edges (#2 blocked_by #1; #3 blocked_by #2 + #1), code-block #999 correctly ignored (0 unresolved), pm next correctly gated #2/#3 behind #1; repo deleted after."}]}],"before_hash":"7a56d6b54c6742ccf7f2d5245e704bee24121830bc57b64905f2caea0b34e4a4","after_hash":"b75738d7177a92b035569a7efcbacf08a4c7a113c3cc443248fc5bc13668e3f4"} diff --git a/README.md b/README.md index 9d774b3..76b56db 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ pm github import owner/repo --assignee octocat pm github import owner/repo --milestone "v1.0" pm github import owner/repo --include-prs pm github import owner/repo --atomic +pm github import owner/repo --link-deps # map issue-body dependencies to pm edges pm github import owner/repo --dry-run ``` @@ -55,6 +56,7 @@ pm github import owner/repo --dry-run | `--with-comments` | boolean | Fetch issue comments and append them to the item body | | `--comments-mode ` | `body`\|`annotations`\|`both` | How fetched GitHub comments are persisted (default `body`). `annotations` syncs comments into the pm item's native comments collection via the SDK; `both` writes the body section AND native comments. `annotations`/`both` are idempotent on re-import (dedupe by GitHub comment id) | | `--atomic` | boolean | Commit every create, update, close, and reopen in one workspace-writer-locked, crash-resumable transaction (requires pm CLI/SDK >=2026.7.20). Normal failure compensation restores updated/closed items and deletes newly created items; an incomplete compensation is reported explicitly for retry or repair. | +| `--link-deps` | boolean | After import, map dependency references in issue **bodies** (`Blocked by #N`, `Depends on owner/repo#N`, `Blocks #N`) to pm dependency edges between the linked items. Idempotent, best-effort, and re-runnable; see [Dependency linking](#dependency-linking---link-deps) below. | | `--dry-run` | boolean | Preview without writing | | `--type ` | string | Override pm item type (default: Issue) | @@ -77,6 +79,37 @@ pm github import owner/repo --with-comments # legacy body embedd pm github import owner/repo --with-comments --comments-mode annotations # same as --comments-mode both ``` +### Dependency linking (`--link-deps`) + +GitHub issue authors declare cross-issue dependencies in prose — `Blocked by #12`, `Depends on owner/repo#5`, `Blocks #9`. A flat import throws that structure away, leaving pm items with no blocker graph. `--link-deps` runs an opt-in second pass that parses those references from each issue's **body** and materializes them as pm dependency edges between the corresponding pm items, resolved through the same `gh:owner/repo#N` provenance tags the import writes. + +The effect is context you can act on: `pm next` and `pm deps` surface the real ready/blocked ordering instead of an undifferentiated list. + +```bash +pm github import owner/repo --link-deps +pm github import owner/repo --atomic --link-deps +pm github import owner/repo --link-deps --dry-run # reports candidate reference count only +``` + +**Reference grammar** (case-insensitive; `-`/`:` glue tolerated; multiple refs like `#1, #2 and #3`): + +| Phrase | pm edge on the source item | +|---|---| +| `Blocked by #N` | `blocked_by` → the referenced item | +| `Depends on #N` | `blocked_by` → the referenced item | +| `Blocks #N` | `blocks` → the referenced item | + +Both bare `#N` (resolved against the imported repo) and explicit `owner/repo#N` cross-repo references are supported, the latter only when that issue is also present in the workspace. + +**Guarantees** + +- **Idempotent** — edges dedupe by id + kind, so re-running import never duplicates them. +- **Path-agnostic** — runs identically after the normal and `--atomic` import. +- **Safe** — references inside fenced/inline code spans are ignored; self-references and references to issues not in the workspace are skipped (the latter counted as `unresolvedDependencyRefs`); a failed individual edge never fails the import (it is reported in `dependencyLinkFailures` and the pass is re-runnable). +- **Cycle-aware, not cycle-blocking** — ordering cycles the mapped edges introduce are reported in `orderingCycleWarnings` (computed via the pm SDK `collectNewOrderingCycleWarnings` advisory over the workspace before/after the pass) rather than rejected, mirroring the SDK's warn-don't-reject contract for legacy graph debt. + +The import result gains `linkedDependencies`, `unresolvedDependencyRefs`, `orderingCycleWarnings` (and `dependencyLinkFailures` when non-empty); a `--dry-run --link-deps` result reports `wouldLinkDependencyCandidates`. + ## Export (pm → GitHub) ### `pm github export` diff --git a/index.ts b/index.ts index bf259b7..8e1e43b 100644 --- a/index.ts +++ b/index.ts @@ -125,6 +125,7 @@ export interface ImportOptions { itemType: string; dryRun: boolean; atomic: boolean; + linkDeps: boolean; } type CommitItemMutations = ( @@ -155,6 +156,18 @@ export interface ImportRunDependencies { ) => Promise; readItems?: (pmRoot: string) => PmItem[]; commitAtomic?: typeof importGithubAtomic; + // --link-deps second-pass hooks (all optional; defaults wire the real SDK / + // `pm` CLI). Injecting all three keeps the pass fully hermetic for tests. + /** Snapshot workspace item metadata for the ordering-cycle advisory. Defaults to the SDK `listAllItemMetadata`. */ + listItemMetadata?: (pmRoot: string) => Promise; + /** Warnings for ordering cycles a mutation newly introduced. Defaults to the SDK `collectNewOrderingCycleWarnings`. */ + collectOrderingCycleWarnings?: ( + before: readonly DepLinkSnapshotItem[], + after: readonly DepLinkSnapshotItem[], + changedItemId: string, + ) => string[]; + /** Apply one resolved dependency edge. Defaults to spawning `pm update --dep`. */ + applyDependencyLink?: (edge: ResolvedDepEdge, pmRoot: string) => { ok: boolean; stderr: string }; } // --------------------------------------------------------------------------- @@ -1588,6 +1601,7 @@ export function parseImportOptions(options: Record): ImportOpti itemType: optionString(options, "type") || "Issue", dryRun: optionEnabled(options, "dry-run", "dryRun"), atomic: optionEnabled(options, "atomic"), + linkDeps: optionEnabled(options, "link-deps", "linkDeps"), }; } @@ -1599,6 +1613,341 @@ function pmRun(args: string[]): { ok: boolean; stderr: string; stdout: string } return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" }; } +// --------------------------------------------------------------------------- +// --link-deps — map GitHub issue-body dependency references to pm edges +// +// GitHub issue authors declare cross-issue dependencies in prose: +// "Blocked by #12", "Depends on owner/repo#5", "Blocks #9". The flat import +// drops these, leaving pm items with no blocker graph. This opt-in second pass +// parses those references from the RAW issue body and materializes them as pm +// dependency edges between the corresponding pm items (resolved through the +// same `gh:owner/repo#N` provenance tags the import writes). Effect: agents see +// the real ready/blocked ordering (`pm next`, `pm deps`) instead of a flat list +// — project management = context management. +// +// The pass is idempotent (`pm update --dep` dedupes by id+kind), path-agnostic +// (works after both the atomic and non-atomic import), best-effort (a failed +// edge never fails the import; the pass is re-runnable), and it never invents +// self-edges or edges to issues absent from the workspace. Ordering cycles the +// mapped edges introduce are surfaced (not rejected) via the SDK export +// `collectNewOrderingCycleWarnings`, whose whole-workspace before/after diff is +// the authoritative advisory regardless of how the edges were written — the +// `commitItemMutations` atomic path drops the per-item advisory the plain +// `pm update` path prints, so recomputing it here restores parity. +// --------------------------------------------------------------------------- + +/** pm dependency kinds a GitHub body reference can map to. */ +export type DepRefKind = "blocked_by" | "blocks"; + +/** One dependency reference parsed out of an issue body. `repo` is lowercased. */ +export interface ParsedDepRef { + repo: string; + number: number; + kind: DepRefKind; + /** Normalized human phrase that produced this ref ("blocked by" | "depends on" | "blocks"). */ + phrase: string; +} + +/** A resolved, workspace-concrete edge ready to apply to the source pm item. */ +export interface ResolvedDepEdge { + sourceId: string; + targetId: string; + kind: DepRefKind; + /** GitHub issue number of the source item (for the audit message). */ + sourceIssue: number; + phrase: string; +} + +/** Minimal item-metadata shape the link pass reads (subset of the SDK `ItemMetadata`). */ +export interface DepLinkSnapshotItem { + id: string; + tags: string[]; + dependencies?: Array<{ id: string; kind: string }>; +} + +/** Structured outcome of the `--link-deps` pass, merged into the import result. */ +export interface DepLinkResult { + linked: number; + unresolved: number; + orderingCycleWarnings: string[]; + failures: string[]; +} + +// Strip fenced (```…```) and inline (`…`) code so `#123` mentions inside code +// samples never masquerade as declared dependencies. +function stripCodeSpans(body: string): string { + return body + .replace(/```[\s\S]*?```/g, " ") + .replace(/`[^`]*`/g, " "); +} + +// A dependency phrase at a word boundary, plus trailing separator. Global + +// case-insensitive; each match anchors where the reference run begins. +const DEP_PHRASE_RE = /\b(blocked[\s-]?by|depends[\s-]?on|blocks)\b[\s:]*/gi; +// One `owner/repo#N` or bare `#N` reference, matched STICKILY (`y`) so it only +// succeeds at the exact scan position — no scanning ahead, no backtracking. +const DEP_REF_STICKY = /(?:([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+))?#(\d+)/y; +// Comma/whitespace glue with an optional "and" between consecutive refs +// ("#1, #2 and #3"). Sticky and can match empty, so the ref loop always +// terminates: the next sticky ref match is what decides whether to continue. +const DEP_GLUE_STICKY = /[\s,]*(?:and\b[\s,]*)?/y; + +function phraseToKind(raw: string): { kind: DepRefKind; phrase: string } { + const compact = raw.toLowerCase().replace(/[\s-]/g, ""); + if (compact === "blocks") return { kind: "blocks", phrase: "blocks" }; + if (compact === "dependson") return { kind: "blocked_by", phrase: "depends on" }; + return { kind: "blocked_by", phrase: "blocked by" }; +} + +/** + * Parse dependency references from one issue body. Pure. Bare `#N` refs resolve + * against `sourceRepo` (lowercased); `owner/repo#N` refs keep their explicit + * repo (lowercased). References are de-duplicated within the body by + * repo+number+kind so a body repeating a link yields one ref. + * + * Scanning is linear: each dependency phrase is found once, then references are + * consumed one at a time with sticky (`y`) regexes anchored at the exact scan + * position. There is no nested quantifier or look-ahead over unbounded input, + * so the parser cannot backtrack catastrophically on adversarial bodies. + */ +export function parseDependencyReferences(body: string, sourceRepo: string): ParsedDepRef[] { + if (!body) return []; + const text = stripCodeSpans(body); + const repoLc = sourceRepo.toLowerCase(); + const out: ParsedDepRef[] = []; + const seen = new Set(); + for (const m of text.matchAll(DEP_PHRASE_RE)) { + const { kind, phrase } = phraseToKind(m[1]); + let pos = m.index + m[0].length; + // Consume the run of references immediately following the phrase. + for (;;) { + DEP_REF_STICKY.lastIndex = pos; + const ref = DEP_REF_STICKY.exec(text); + if (!ref) break; + const repo = (ref[1] ? ref[1] : repoLc).toLowerCase(); + const number = Number.parseInt(ref[2], 10); + if (Number.isSafeInteger(number) && number > 0) { + const key = `${repo}#${number}|${kind}`; + if (!seen.has(key)) { + seen.add(key); + out.push({ repo, number, kind, phrase }); + } + } + pos = DEP_REF_STICKY.lastIndex; + // Skip separator glue; if no further ref follows, the next iteration's + // sticky match fails at `pos` and the run ends. + DEP_GLUE_STICKY.lastIndex = pos; + DEP_GLUE_STICKY.exec(text); + pos = DEP_GLUE_STICKY.lastIndex; + } + } + return out; +} + +/** Build a `repo#number` → pm item id index from an item-metadata snapshot. */ +export function buildProvenanceIndexFromMetadata( + items: readonly DepLinkSnapshotItem[], +): Map { + const index = new Map(); + for (const item of items) { + if (!item.id) continue; + for (const tag of item.tags ?? []) { + const p = parseProvenanceTag(tag); + // First writer wins: a provenance tag is a 1:1 issue↔item link, and the + // import guarantees uniqueness; guarding keeps a hand-edited duplicate + // deterministic rather than order-dependent. + if (p && !index.has(`${p.repo}#${p.number}`)) { + index.set(`${p.repo}#${p.number}`, item.id); + } + } + } + return index; +} + +/** + * Resolve parsed references into concrete workspace edges. Skips references + * whose source or target issue is not present in the workspace (counted as + * `unresolved`), self-references, and duplicates. Pure. + */ +export function planDependencyLinks( + repo: string, + issues: readonly GhIssue[], + provenance: ReadonlyMap, +): { edges: ResolvedDepEdge[]; unresolved: number } { + const repoLc = repo.toLowerCase(); + const edges: ResolvedDepEdge[] = []; + const seen = new Set(); + let unresolved = 0; + for (const issue of issues) { + const sourceKey = `${repoLc}#${issue.number}`; + const sourceId = provenance.get(sourceKey); + // Source not in the workspace (e.g. its own import was skipped): its edges + // have nowhere to attach; leave them for a later re-run rather than count + // them as unresolved targets. + if (!sourceId) continue; + for (const ref of parseDependencyReferences(issue.body ?? "", repoLc)) { + const targetKey = `${ref.repo}#${ref.number}`; + if (targetKey === sourceKey) continue; // self-reference by provenance + const targetId = provenance.get(targetKey); + if (!targetId) { + unresolved++; + continue; + } + if (targetId === sourceId) continue; // self-reference by resolved id + const dedupeKey = `${sourceId}|${targetId}|${ref.kind}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + edges.push({ sourceId, targetId, kind: ref.kind, sourceIssue: issue.number, phrase: ref.phrase }); + } + } + return { edges, unresolved }; +} + +/** Count candidate references across issues without resolving them (dry-run preview). */ +export function countDependencyRefCandidates(repo: string, issues: readonly GhIssue[]): number { + const repoLc = repo.toLowerCase(); + let n = 0; + for (const issue of issues) n += parseDependencyReferences(issue.body ?? "", repoLc).length; + return n; +} + +interface DepLinkSdk { + listAllItemMetadata: (pmRoot: string) => Promise; + collectNewOrderingCycleWarnings: ( + before: readonly DepLinkSnapshotItem[], + after: readonly DepLinkSnapshotItem[], + changedItemId: string, + ) => 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, + ); + } + return { + listAllItemMetadata: async (pmRoot: string) => { + const raw = (await (listFn as (r: string) => Promise>>)(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, + })); + }, + // 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"], + }; +} + +function defaultApplyDependencyLink(edge: ResolvedDepEdge, pmRoot: string): { ok: boolean; stderr: string } { + const res = pmRun([ + "--path", pmRoot, "update", edge.sourceId, + "--dep", `id=${edge.targetId},kind=${edge.kind}`, + "--message", `Linked from GitHub #${edge.sourceIssue} body ("${edge.phrase}" reference)`, + ]); + return { ok: res.ok, stderr: res.stderr }; +} + +/** + * The `--link-deps` second pass. Snapshots the workspace, resolves body + * references to edges, applies them idempotently, then re-snapshots and asks the + * SDK which ordering cycles the batch newly introduced. Never throws for an + * individual edge; a resolution/SDK failure surfaces through the returned + * `failures`/warnings so the import result stays truthful. + */ +export async function linkImportedDependencies( + repo: string, + issues: readonly GhIssue[], + pmRoot: string, + deps: ImportRunDependencies = {}, +): Promise { + const applyEdge = deps.applyDependencyLink ?? defaultApplyDependencyLink; + const failures: string[] = []; + + // Resolve the SDK-backed helpers and take the pre-edge snapshot. This runs + // AFTER the import has already committed, so a failure here (a host whose CLI + // predates the required SDK exports, or an unreadable workspace) must never + // reject and discard the successful import result — degrade to a reported + // failure and skip linking, honoring the "a failure never fails the import" + // contract for infra errors, not just per-edge errors. + let listMeta: (pmRoot: string) => Promise; + let collect: ( + before: readonly DepLinkSnapshotItem[], + after: readonly DepLinkSnapshotItem[], + changedItemId: string, + ) => string[]; + let before: DepLinkSnapshotItem[]; + try { + const needsSdk = !deps.listItemMetadata || !deps.collectOrderingCycleWarnings; + const sdk = needsSdk ? await loadDepLinkSdk() : undefined; + listMeta = deps.listItemMetadata ?? ((r: string) => sdk!.listAllItemMetadata(r)); + collect = deps.collectOrderingCycleWarnings ?? sdk!.collectNewOrderingCycleWarnings; + before = await listMeta(pmRoot); + } catch (err: unknown) { + return { + linked: 0, + unresolved: 0, + orderingCycleWarnings: [], + failures: [`dependency linking skipped: ${err instanceof Error ? err.message : String(err)}`], + }; + } + + const provenance = buildProvenanceIndexFromMetadata(before); + const { edges, unresolved } = planDependencyLinks(repo, issues, provenance); + if (edges.length === 0) { + return { linked: 0, unresolved, orderingCycleWarnings: [], failures }; + } + + const changedSources = new Set(); + let linked = 0; + for (const edge of edges) { + const res = applyEdge(edge, pmRoot); + if (res.ok) { + linked++; + changedSources.add(edge.sourceId); + } else { + failures.push(`#${edge.sourceIssue} ${edge.sourceId} —(${edge.kind})→ ${edge.targetId}: ${res.stderr.trim()}`); + } + } + + // The ordering-cycle advisory is likewise best-effort: a re-snapshot or + // detector failure must not discard the edges already written, so it degrades + // to a reported note rather than throwing. + const warnings = new Set(); + if (changedSources.size > 0) { + try { + const after = await listMeta(pmRoot); + for (const id of changedSources) { + for (const w of collect(before, after, id)) warnings.add(w); + } + } catch (err: unknown) { + failures.push(`ordering-cycle advisory skipped: ${err instanceof Error ? err.message : String(err)}`); + } + } + return { linked, unresolved, orderingCycleWarnings: [...warnings], failures }; +} + async function prepareGithubImport( issue: GhIssue, repo: string, @@ -1759,12 +2108,19 @@ export async function runImport( console.error( `[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`, ); + if (opts.linkDeps) { + console.error( + `[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` + + `resolution + edge writes run only on a real import.`, + ); + } return { dryRun: true, wouldImport: imported, wouldUpdate: updated, wouldSkip: skipped, atomic: true, + ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}), }; } @@ -1790,6 +2146,10 @@ export async function runImport( `Atomically imported ${result.imported} new, updated ${result.updated} existing, skipped ${skipped}.`, ); } + const atomicDepLink = opts.linkDeps + ? await linkImportedDependencies(repo, filtered, pmRoot, dependencies) + : undefined; + reportDepLink(atomicDepLink); // itemIds is an internal post-commit routing map for native comments. Maps // serialize as `{}` in JSON, so keep it out of the public command result. return { @@ -1800,6 +2160,7 @@ export async function runImport( ...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}), skipped, atomic: true, + ...depLinkResultFields(atomicDepLink), }; } @@ -1913,11 +2274,18 @@ export async function runImport( if (opts.dryRun) { console.error(`[dry-run] Would import ${imported}, skip ${skipped}.`); + if (opts.linkDeps) { + console.error( + `[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` + + `resolution + edge writes run only on a real import.`, + ); + } return { dryRun: true, wouldImport: imported, wouldSkip: skipped, ...(opts.atomic ? { atomic: true } : {}), + ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}), }; } @@ -1925,7 +2293,36 @@ export async function runImport( if (imported === 0 && updated === 0 && skipped > 0) { throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`); } - return { imported, updated, skipped }; + const depLink = opts.linkDeps + ? await linkImportedDependencies(repo, filtered, pmRoot, dependencies) + : undefined; + reportDepLink(depLink); + return { imported, updated, skipped, ...depLinkResultFields(depLink) }; +} + +// Emit the human-readable --link-deps summary to stderr (import writes its +// progress there; stdout is reserved for the structured result). +function reportDepLink(result: DepLinkResult | undefined): void { + if (!result) return; + const parts = [`linked ${result.linked} dependency edge(s)`]; + if (result.unresolved > 0) parts.push(`${result.unresolved} unresolved ref(s)`); + console.error(`--link-deps: ${parts.join(", ")}.`); + for (const warning of result.orderingCycleWarnings) console.error(` ⚠ ${warning}`); + for (const failure of result.failures) console.error(` ✗ link failed: ${failure}`); +} + +// The public, JSON-serializable slice of a --link-deps result. Kept flat and +// omitted entirely when the pass did not run so the default result is unchanged. +function depLinkResultFields( + result: DepLinkResult | undefined, +): Record { + if (!result) return {}; + return { + linkedDependencies: result.linked, + unresolvedDependencyRefs: result.unresolved, + orderingCycleWarnings: result.orderingCycleWarnings, + ...(result.failures.length > 0 ? { dependencyLinkFailures: result.failures } : {}), + }; } // --------------------------------------------------------------------------- @@ -3310,6 +3707,7 @@ const IMPORT_FLAGS = [ { long: "--include-comments", description: "Alias for --with-comments" }, { long: "--comments-mode", value_name: "body|annotations|both", description: "How to persist fetched GitHub comments: `body` (default, embed in item body), `annotations` (sync to the pm item's native comments collection), or `both`. `annotations`/`both` are idempotent on re-import (dedupe by GitHub comment id)" }, { long: "--atomic", description: "Commit the complete import as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" }, + { long: "--link-deps", description: "After import, map dependency references in issue bodies (`Blocked by #N`, `Depends on owner/repo#N`, `Blocks #N`) to pm dependency edges between the linked items. Idempotent; skips self- and unresolved references; ordering cycles are reported (via the SDK ordering-cycle advisory), not rejected" }, { long: "--dry-run", description: "Preview without writing" }, { long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" }, ]; @@ -3419,6 +3817,7 @@ export default defineExtension({ "pm github import owner/repo --include-comments", "pm github import owner/repo --comments-mode annotations", "pm github import owner/repo --atomic", + "pm github import owner/repo --link-deps", "pm github import owner/repo --dry-run", ], flags: IMPORT_FLAGS, @@ -3587,6 +3986,7 @@ export default defineExtension({ "pm github import owner/repo --with-comments", "pm github import owner/repo --comments-mode annotations", "pm github import owner/repo --atomic", + "pm github import owner/repo --link-deps", "pm github import owner/repo --dry-run", ], flags: IMPORT_FLAGS, diff --git a/test/link-deps.test.ts b/test/link-deps.test.ts new file mode 100644 index 0000000..8163b9d --- /dev/null +++ b/test/link-deps.test.ts @@ -0,0 +1,449 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + buildProvenanceIndexFromMetadata, + countDependencyRefCandidates, + linkImportedDependencies, + parseDependencyReferences, + planDependencyLinks, + runImport, +} from "../dist/index.js"; +import type { + DepLinkSnapshotItem, + GhIssue, + ImportOptions, + ResolvedDepEdge, +} from "../dist/index.js"; + +const PM_BIN = process.platform === "win32" ? "pm.cmd" : "pm"; +const PM_SPAWN_OPTS = { + encoding: "utf-8" as const, + shell: process.platform === "win32", +}; + +function issue(number: number, body: string | null, overrides: Partial = {}): GhIssue { + return { + number, + title: `Issue ${number}`, + body, + state: "open", + labels: [], + assignee: null, + milestone: null, + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:00Z", + html_url: `https://github.com/acme/widgets/issues/${number}`, + ...overrides, + }; +} + +function importOpts(overrides: Partial = {}): ImportOptions { + return { + state: "all", + includePrs: false, + skipDrafts: false, + withComments: false, + commentsMode: "body", + itemType: "Issue", + dryRun: false, + atomic: false, + linkDeps: true, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// parseDependencyReferences — pure +// --------------------------------------------------------------------------- + +test("parses 'Blocked by #N' as a blocked_by ref against the source repo", () => { + const refs = parseDependencyReferences("Blocked by #12", "acme/widgets"); + assert.deepStrictEqual(refs, [ + { repo: "acme/widgets", number: 12, kind: "blocked_by", phrase: "blocked by" }, + ]); +}); + +test("maps 'Depends on' to blocked_by and 'Blocks' to blocks", () => { + assert.strictEqual(parseDependencyReferences("Depends on #3", "a/b")[0].kind, "blocked_by"); + assert.strictEqual(parseDependencyReferences("Blocks #4", "a/b")[0].kind, "blocks"); +}); + +test("is case-insensitive and tolerates hyphen/colon glue", () => { + const refs = parseDependencyReferences("BLOCKED-BY: #7", "a/b"); + assert.strictEqual(refs.length, 1); + assert.strictEqual(refs[0].number, 7); + assert.strictEqual(refs[0].kind, "blocked_by"); +}); + +test("collects multiple refs in one clause ('#1, #2 and #3')", () => { + const refs = parseDependencyReferences("Blocked by #1, #2 and #3", "a/b"); + assert.deepStrictEqual(refs.map((r) => r.number), [1, 2, 3]); +}); + +test("resolves explicit owner/repo#N cross-repo refs (lowercased)", () => { + const refs = parseDependencyReferences("Depends on Other/Repo#42", "a/b"); + assert.deepStrictEqual(refs, [ + { repo: "other/repo", number: 42, kind: "blocked_by", phrase: "depends on" }, + ]); +}); + +test("de-duplicates repeated refs within one body", () => { + const refs = parseDependencyReferences("Blocked by #5. Also blocked by #5.", "a/b"); + assert.strictEqual(refs.length, 1); +}); + +test("ignores refs inside fenced and inline code spans", () => { + const body = "See `Blocked by #99` and\n```\nBlocked by #100\n```\nBlocked by #1"; + const refs = parseDependencyReferences(body, "a/b"); + assert.deepStrictEqual(refs.map((r) => r.number), [1]); +}); + +test("does not match 'blocks' inside a larger word (roadblocks)", () => { + assert.deepStrictEqual(parseDependencyReferences("roadblocks #1", "a/b"), []); +}); + +test("prefers 'blocked by' over 'blocks' at the same position", () => { + const refs = parseDependencyReferences("Blocked by #2", "a/b"); + assert.strictEqual(refs[0].kind, "blocked_by"); +}); + +test("returns [] for empty/undefined body and bodies with no phrase", () => { + assert.deepStrictEqual(parseDependencyReferences("", "a/b"), []); + assert.deepStrictEqual(parseDependencyReferences("mentions #5 without a phrase", "a/b"), []); +}); + +// --------------------------------------------------------------------------- +// buildProvenanceIndexFromMetadata — pure +// --------------------------------------------------------------------------- + +test("indexes items by their gh provenance tag", () => { + const items: DepLinkSnapshotItem[] = [ + { id: "pm-1", tags: ["bug", "gh:acme/widgets#1"] }, + { id: "pm-2", tags: ["gh:acme/widgets#2"] }, + { id: "pm-3", tags: ["no-provenance"] }, + ]; + const index = buildProvenanceIndexFromMetadata(items); + assert.strictEqual(index.get("acme/widgets#1"), "pm-1"); + assert.strictEqual(index.get("acme/widgets#2"), "pm-2"); + assert.strictEqual(index.size, 2); +}); + +test("first writer wins on a duplicated provenance tag", () => { + const items: DepLinkSnapshotItem[] = [ + { id: "pm-1", tags: ["gh:acme/widgets#1"] }, + { id: "pm-dup", tags: ["gh:acme/widgets#1"] }, + ]; + assert.strictEqual(buildProvenanceIndexFromMetadata(items).get("acme/widgets#1"), "pm-1"); +}); + +// --------------------------------------------------------------------------- +// planDependencyLinks — pure +// --------------------------------------------------------------------------- + +const PROVENANCE = new Map([ + ["acme/widgets#1", "pm-1"], + ["acme/widgets#2", "pm-2"], + ["acme/widgets#3", "pm-3"], +]); + +test("resolves a body reference into a concrete edge", () => { + const { edges, unresolved } = planDependencyLinks( + "acme/widgets", + [issue(2, "Blocked by #1")], + PROVENANCE, + ); + assert.strictEqual(unresolved, 0); + assert.deepStrictEqual(edges, [ + { sourceId: "pm-2", targetId: "pm-1", kind: "blocked_by", sourceIssue: 2, phrase: "blocked by" }, + ]); +}); + +test("counts references to issues absent from the workspace as unresolved", () => { + const { edges, unresolved } = planDependencyLinks( + "acme/widgets", + [issue(2, "Blocked by #999")], + PROVENANCE, + ); + assert.strictEqual(edges.length, 0); + assert.strictEqual(unresolved, 1); +}); + +test("skips self-references (issue referencing its own number)", () => { + const { edges, unresolved } = planDependencyLinks( + "acme/widgets", + [issue(1, "Blocked by #1")], + PROVENANCE, + ); + assert.deepStrictEqual(edges, []); + assert.strictEqual(unresolved, 0); +}); + +test("skips issues whose source item is not in the workspace (no unresolved inflation)", () => { + const { edges, unresolved } = planDependencyLinks( + "acme/widgets", + [issue(77, "Blocked by #1")], // #77 has no provenance entry + PROVENANCE, + ); + assert.deepStrictEqual(edges, []); + assert.strictEqual(unresolved, 0); +}); + +test("de-duplicates identical edges across issues", () => { + const { edges } = planDependencyLinks( + "acme/widgets", + [issue(2, "Blocked by #1 and again blocked by #1")], + PROVENANCE, + ); + assert.strictEqual(edges.length, 1); +}); + +// --------------------------------------------------------------------------- +// linkImportedDependencies — hermetic (all hooks injected) +// --------------------------------------------------------------------------- + +function snapshot(deps: Record> = {}): DepLinkSnapshotItem[] { + return [ + { id: "pm-1", tags: ["gh:acme/widgets#1"], dependencies: deps["pm-1"] }, + { id: "pm-2", tags: ["gh:acme/widgets#2"], dependencies: deps["pm-2"] }, + ]; +} + +test("applies edges, records changed sources, and surfaces injected cycle warnings", async () => { + const applied: ResolvedDepEdge[] = []; + const result = await linkImportedDependencies( + "acme/widgets", + [issue(2, "Blocked by #1")], + "/pm", + { + listItemMetadata: async () => snapshot(), + applyDependencyLink: (edge) => { + applied.push(edge); + return { ok: true, stderr: "" }; + }, + collectOrderingCycleWarnings: (_b, _a, changed) => + changed === "pm-2" ? ["ordering_cycle_created:pm-2 -> pm-1 -> pm-2"] : [], + }, + ); + assert.strictEqual(applied.length, 1); + assert.strictEqual(result.linked, 1); + assert.strictEqual(result.unresolved, 0); + assert.deepStrictEqual(result.orderingCycleWarnings, ["ordering_cycle_created:pm-2 -> pm-1 -> pm-2"]); + assert.deepStrictEqual(result.failures, []); +}); + +test("captures apply failures without throwing and still snapshots for warnings", async () => { + let afterCalls = 0; + const result = await linkImportedDependencies( + "acme/widgets", + [issue(2, "Blocked by #1")], + "/pm", + { + listItemMetadata: async () => { + afterCalls++; + return snapshot(); + }, + applyDependencyLink: () => ({ ok: false, stderr: "boom" }), + collectOrderingCycleWarnings: () => [], + }, + ); + assert.strictEqual(result.linked, 0); + assert.strictEqual(result.failures.length, 1); + assert.match(result.failures[0], /boom/); + // A failed edge does not join changedSources, so no "after" snapshot is taken. + assert.strictEqual(afterCalls, 1); +}); + +test("no candidate edges → no apply, no after-snapshot, empty result", async () => { + let applyCalls = 0; + let listCalls = 0; + const result = await linkImportedDependencies( + "acme/widgets", + [issue(1, "no dependency phrases here")], + "/pm", + { + listItemMetadata: async () => { + listCalls++; + return snapshot(); + }, + applyDependencyLink: () => { + applyCalls++; + return { ok: true, stderr: "" }; + }, + collectOrderingCycleWarnings: () => ["should-not-appear"], + }, + ); + assert.strictEqual(applyCalls, 0); + assert.strictEqual(listCalls, 1); // only the "before" snapshot + assert.deepStrictEqual(result, { linked: 0, unresolved: 0, orderingCycleWarnings: [], failures: [] }); +}); + +test("de-duplicates cycle warnings emitted for multiple changed sources", async () => { + const result = await linkImportedDependencies( + "acme/widgets", + [issue(1, "Blocks #2"), issue(2, "Blocked by #1")], + "/pm", + { + listItemMetadata: async () => snapshot(), + applyDependencyLink: () => ({ ok: true, stderr: "" }), + collectOrderingCycleWarnings: () => ["same-cycle-warning"], + }, + ); + assert.strictEqual(result.linked, 2); + assert.deepStrictEqual(result.orderingCycleWarnings, ["same-cycle-warning"]); +}); + +test("a pre-edge snapshot/SDK failure is reported, never thrown (import stays successful)", async () => { + const result = await linkImportedDependencies( + "acme/widgets", + [issue(2, "Blocked by #1")], + "/pm", + { + listItemMetadata: async () => { + throw new Error("SDK listAllItemMetadata missing"); + }, + applyDependencyLink: () => ({ ok: true, stderr: "" }), + collectOrderingCycleWarnings: () => [], + }, + ); + assert.strictEqual(result.linked, 0); + assert.strictEqual(result.orderingCycleWarnings.length, 0); + assert.strictEqual(result.failures.length, 1); + assert.match(result.failures[0], /dependency linking skipped: SDK listAllItemMetadata missing/); +}); + +test("an advisory (after-snapshot) failure preserves the edges already written", async () => { + let call = 0; + const applied: ResolvedDepEdge[] = []; + const result = await linkImportedDependencies( + "acme/widgets", + [issue(2, "Blocked by #1")], + "/pm", + { + listItemMetadata: async () => { + call++; + if (call === 1) return snapshot(); // before → ok + throw new Error("workspace read error"); // after → fails + }, + applyDependencyLink: (edge) => { + applied.push(edge); + return { ok: true, stderr: "" }; + }, + collectOrderingCycleWarnings: () => ["unreached"], + }, + ); + assert.strictEqual(applied.length, 1); + assert.strictEqual(result.linked, 1); // edge NOT discarded + assert.deepStrictEqual(result.orderingCycleWarnings, []); + assert.strictEqual(result.failures.length, 1); + assert.match(result.failures[0], /ordering-cycle advisory skipped: workspace read error/); +}); + +test("countDependencyRefCandidates totals parsed refs across issues", () => { + const n = countDependencyRefCandidates("acme/widgets", [ + issue(1, "Blocked by #2, #3"), + issue(4, "no refs"), + issue(5, "Depends on #1"), + ]); + assert.strictEqual(n, 3); +}); + +// --------------------------------------------------------------------------- +// runImport --link-deps — real end-to-end against a live pm tracker + SDK +// --------------------------------------------------------------------------- + +function freshTracker(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pm-github-linkdeps-")); + const init = spawnSync(PM_BIN, ["--path", root, "init", "test"], PM_SPAWN_OPTS); + assert.strictEqual(init.status, 0, `pm init failed: ${init.error?.message ?? init.stderr}`); + return root; +} + +function showItem(root: string, id: string): { dependencies?: Array<{ id: string; kind: string }> } { + const r = spawnSync(PM_BIN, ["--path", root, "show", id, "--json"], PM_SPAWN_OPTS); + assert.strictEqual(r.status, 0, `pm show failed: ${r.stderr}`); + const parsed = JSON.parse(r.stdout); + return parsed.item ?? parsed; +} + +function idForProvenance(root: string, tag: string): string { + const r = spawnSync(PM_BIN, ["--path", root, "list", "--json", "--limit", "100"], PM_SPAWN_OPTS); + assert.strictEqual(r.status, 0, `pm list failed: ${r.stderr}`); + const items = (JSON.parse(r.stdout).items ?? []) as Array<{ id: string; tags?: string[] }>; + for (const it of items) { + const full = showItemTags(root, it.id); + if (full.includes(tag)) return it.id; + } + throw new Error(`no item carries provenance ${tag}`); +} + +function showItemTags(root: string, id: string): string[] { + const r = spawnSync(PM_BIN, ["--path", root, "show", id, "--json"], PM_SPAWN_OPTS); + const parsed = JSON.parse(r.stdout); + return (parsed.item ?? parsed).tags ?? []; +} + +test("runImport --link-deps writes a blocked_by edge between imported items (idempotent)", async () => { + const root = freshTracker(); + try { + const issues = [issue(1, "Root work."), issue(2, "Blocked by #1")]; + const deps = { resolveToken: () => "x", fetchIssues: async () => issues }; + + const first = (await runImport("acme/widgets", root, importOpts(), deps)) as Record; + assert.strictEqual(first.imported, 2); + assert.strictEqual(first.linkedDependencies, 1); + assert.strictEqual(first.unresolvedDependencyRefs, 0); + + const id1 = idForProvenance(root, "gh:acme/widgets#1"); + const id2 = idForProvenance(root, "gh:acme/widgets#2"); + const item2 = showItem(root, id2); + assert.deepStrictEqual( + (item2.dependencies ?? []).map((d) => ({ id: d.id, kind: d.kind })), + [{ id: id1, kind: "blocked_by" }], + ); + + // Re-import must not duplicate the edge. + const second = (await runImport("acme/widgets", root, importOpts(), deps)) as Record; + assert.strictEqual(second.linkedDependencies, 1); + const item2Again = showItem(root, id2); + assert.strictEqual((item2Again.dependencies ?? []).length, 1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runImport --link-deps skips self-references and counts unresolved refs", async () => { + const root = freshTracker(); + try { + const issues = [issue(1, "Blocked by #1. Also blocked by #404.")]; + const deps = { resolveToken: () => "x", fetchIssues: async () => issues }; + const result = (await runImport("acme/widgets", root, importOpts(), deps)) as Record; + assert.strictEqual(result.imported, 1); + assert.strictEqual(result.linkedDependencies, 0); // self skipped + assert.strictEqual(result.unresolvedDependencyRefs, 1); // #404 absent + + const id1 = idForProvenance(root, "gh:acme/widgets#1"); + assert.strictEqual((showItem(root, id1).dependencies ?? []).length, 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("runImport without --link-deps leaves items free of dependency edges", async () => { + const root = freshTracker(); + try { + const issues = [issue(1, "Root."), issue(2, "Blocked by #1")]; + const deps = { resolveToken: () => "x", fetchIssues: async () => issues }; + const result = (await runImport("acme/widgets", root, importOpts({ linkDeps: false }), deps)) as Record; + assert.strictEqual(result.imported, 2); + assert.strictEqual(result.linkedDependencies, undefined); + const id2 = idForProvenance(root, "gh:acme/widgets#2"); + assert.strictEqual((showItem(root, id2).dependencies ?? []).length, 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});