From 3c714b0e3c6955af5bb037118beb7e66b2c5ac0d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 21:14:58 +0900 Subject: [PATCH 01/10] chore: bump vendor/opensrc submodule to upstream v0.7.3 (f96078a) Pulls in 9 upstream commits (2026-04-14 .. 2026-06-23): - opensrc fetch subcommand + shared core::fetcher module (#53, #56) - Bitbucket Cloud support via BITBUCKET_TOKEN + auth docs (#52) - Lockfile parsers rewritten with transitive pnpm resolution (#51) - Structured error handling via thiserror across the CLI (#56) - Authenticated clone host validation fix (#66) - Trusted npm publishing, v0.7.2/v0.7.3 releases, docs favicon --- vendor/opensrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/opensrc b/vendor/opensrc index a3b9d21..f96078a 160000 --- a/vendor/opensrc +++ b/vendor/opensrc @@ -1 +1 @@ -Subproject commit a3b9d21bcc2d5017e74a4cfd5c836ff934036ebc +Subproject commit f96078ac0a7ce3fb7d058d73ce65ff4b6606d765 From 9fe7edb19ff47dfa149d6faaf88440731eaed6e9 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 21:52:36 +0900 Subject: [PATCH 02/10] fix(cli): rewrite pnpm/yarn lockfile parsers with transitive pnpm resolution Port of vercel-labs/opensrc#51. The previous regex-based parsers misidentified versions in common real-world cases: yarn multi-specifier headers only matched the first specifier, pnpm peer-dep suffixes like 18.2.0(react@17.0.0) leaked false matches for the inner package, and pnpm monorepos returned whichever version the regex engine hit first. - pnpm: indent-aware stack parser covering v5 through v9 (importers, devDependencies, optionalDependencies, nested peer-dep suffixes) with BFS transitive resolution over the snapshots/packages dep graph - yarn: block-based parser handling classic v1 and Berry v2+ in one path - package.json fallback now skips protocol versions (workspace:*, link:, file:, git+..., npm: aliases) via isRegistryVersion - fixtures (yarn v1, yarn Berry, pnpm v9 monorepo) and 58 unit tests ported from upstream --- packages/cli/src/lockfiles/package-json.ts | 8 +- packages/cli/src/lockfiles/parse-helpers.ts | 79 +++ packages/cli/src/lockfiles/pnpm.ts | 266 +++++++- packages/cli/src/lockfiles/yarn.ts | 117 +++- .../lockfiles/fixtures/pnpm-v9-workspace.yaml | 135 ++++ .../test/lockfiles/fixtures/yarn-berry.lock | 98 +++ .../cli/test/lockfiles/fixtures/yarn-v1.lock | 83 +++ packages/cli/test/lockfiles/parsers.test.ts | 630 ++++++++++++++++++ 8 files changed, 1379 insertions(+), 37 deletions(-) create mode 100644 packages/cli/src/lockfiles/parse-helpers.ts create mode 100644 packages/cli/test/lockfiles/fixtures/pnpm-v9-workspace.yaml create mode 100644 packages/cli/test/lockfiles/fixtures/yarn-berry.lock create mode 100644 packages/cli/test/lockfiles/fixtures/yarn-v1.lock create mode 100644 packages/cli/test/lockfiles/parsers.test.ts diff --git a/packages/cli/src/lockfiles/package-json.ts b/packages/cli/src/lockfiles/package-json.ts index 69be171..deeaab5 100644 --- a/packages/cli/src/lockfiles/package-json.ts +++ b/packages/cli/src/lockfiles/package-json.ts @@ -1,12 +1,17 @@ import type { LockfileReader } from './index.js' import fs from 'node:fs' import path from 'node:path' +import { isRegistryVersion } from './parse-helpers.js' /** * Parse `package.json` for a dependency range. The returned version is * NOT exact — callers receive `exact: false` so the install orchestrator * can decide whether to ask the resolver to normalize the range or to * skip with a warning. + * + * Entries that aren't real registry ranges (e.g. `workspace:*`, + * `link:../pkg`, `file:./tarball.tgz`, `git+https://...`) are skipped so + * the caller isn't handed a string that can't be resolved against npm. */ function parse(content: string, name: string): string | null { try { @@ -16,13 +21,14 @@ function parse(content: string, name: string): string | null { peerDependencies?: Record optionalDependencies?: Record } - return ( + const value = ( json.dependencies?.[name] ?? json.devDependencies?.[name] ?? json.peerDependencies?.[name] ?? json.optionalDependencies?.[name] ?? null ) + return value !== null && isRegistryVersion(value) ? value : null } catch { return null diff --git a/packages/cli/src/lockfiles/parse-helpers.ts b/packages/cli/src/lockfiles/parse-helpers.ts new file mode 100644 index 0000000..c705e4f --- /dev/null +++ b/packages/cli/src/lockfiles/parse-helpers.ts @@ -0,0 +1,79 @@ +/** + * Shared text-level helpers for the hand-rolled pnpm / yarn lockfile + * parsers. Ported from opensrc's `core/version.rs` (vercel-labs/opensrc#51). + */ + +const RE_QUOTES = /^['"]+|['"]+$/g + +/** + * Strip a pnpm peer-dependency suffix like `(react@18.0.0)` from a version + * string, so `18.2.0(react@17.0.0)` becomes `18.2.0`. Cuts at the first `(` + * so nested peer suffixes like `18.2.0(a@1)(b@2(c@3))` also collapse + * cleanly. + */ +export function stripPeerSuffix(v: string): string { + const i = v.indexOf('(') + return (i >= 0 ? v.slice(0, i) : v).trimEnd() +} + +/** + * Strip a YAML-style inline comment. Only strips when `#` is preceded by + * whitespace, so URL fragments like `github:foo/bar#branch` pass through + * intact. + */ +export function stripInlineComment(s: string): string { + const i = s.indexOf(' #') + return i >= 0 ? s.slice(0, i).trimEnd() : s +} + +/** Strip any mix of surrounding single/double quotes from a trimmed string. */ +export function trimQuotes(s: string): string { + return s.replace(RE_QUOTES, '') +} + +/** + * Normalise a raw YAML value: trim whitespace, strip an inline comment, and + * strip surrounding quotes. Does NOT strip peer-dep suffixes — callers do + * that when appropriate. + */ +export function cleanValue(s: string): string { + return trimQuotes(stripInlineComment(s.trim())) +} + +/** + * Split a `@` spec into `[name, rest]`, treating scoped names + * (`@scope/pkg`) correctly. Returns `null` if there's no `@` separator. + */ +export function splitPkgSpec(spec: string): [string, string] | null { + let atPos: number + if (spec.startsWith('@')) { + const i = spec.indexOf('@', 1) + if (i < 0) + return null + atPos = i + } + else { + atPos = spec.indexOf('@') + if (atPos < 0) + return null + } + return [spec.slice(0, atPos), spec.slice(atPos + 1)] +} + +/** + * Return `true` if `v` looks like a version we can resolve against a public + * registry. Lockfiles (and package.json) can legitimately contain + * workspace/link/file/git/URL protocol strings — for example a pnpm importer + * may pin a sibling workspace package with `version: link:../pkg`, and a + * yarn Berry workspace root has `version: 0.0.0-use.local`. Returning any + * of those would make the caller try to fetch `@link:../pkg` from npm, + * which fails with a confusing error. + * + * Real npm versions never contain `:`, so treating a colon as disqualifying + * catches every known protocol prefix (`link:`, `file:`, `workspace:`, + * `portal:`, `git:`, `git+ssh://`, `github:`, `http:`, `https:`, `npm:`, + * etc.) without having to enumerate them. + */ +export function isRegistryVersion(v: string): boolean { + return v.length > 0 && v !== '0.0.0-use.local' && !v.includes(':') +} diff --git a/packages/cli/src/lockfiles/pnpm.ts b/packages/cli/src/lockfiles/pnpm.ts index 26c1cc9..4cb3f35 100644 --- a/packages/cli/src/lockfiles/pnpm.ts +++ b/packages/cli/src/lockfiles/pnpm.ts @@ -1,27 +1,261 @@ import type { LockfileReader } from './index.js' import fs from 'node:fs' import path from 'node:path' +import { + cleanValue, + isRegistryVersion, + splitPkgSpec, + stripPeerSuffix, + trimQuotes, +} from './parse-helpers.js' -const RE_REGEX_META = /[.*+?^${}()|[\]\\]/g -function escapeRegex(input: string): string { - return input.replace(RE_REGEX_META, '\\$&') +/** + * Format-aware `pnpm-lock.yaml` parser, ported from opensrc's + * `core/version.rs` (vercel-labs/opensrc#51). Replaces the previous + * regex-based parser, which misidentified versions in common real-world + * cases: pnpm peer-dep suffixes like `18.2.0(react@17.0.0)` leaked false + * matches for the inner package, and monorepos returned whichever version + * the regex engine encountered first. + * + * The parser is an indent-aware stack machine covering pnpm v5 through v9, + * including importers, devDependencies, optionalDependencies, and nested + * peer-dep suffixes. While parsing it builds a dependency graph from + * `snapshots:` (v9) or `packages:` (v6–v8); when a direct lookup misses, + * a BFS from root-importer deps picks the root-reachable version instead + * of a lexicographic first match. + */ + +/** + * Dependency-graph node built up during parsing. Keys in the containing + * map are the full snapshot id (`@[]`). + */ +interface PnpmNode { + name: string + /** Version with peer suffix stripped — what we'd return to the caller. */ + version: string + /** Snapshot ids of this node's direct dependencies. */ + deps: string[] +} + +interface PnpmGraph { + nodes: Map + /** + * Snapshot ids that are direct deps of any importer (or top-level + * `dependencies:` in v5/v6 non-workspace lockfiles). + */ + roots: string[] } +/** Where a direct dependency entry was found. */ +type Origin = 'root' | 'importer' + /** - * Parse a `pnpm-lock.yaml` file (best-effort, regex-based). - * - * pnpm lockfiles are real YAML, but the shape we care about is regular: - * the `packages:` map is keyed as `'/@'` (or - * `'/@_peerhash'`), which is the most stable form across - * pnpm versions. + * A frame on the indent-aware parse stack. `base` is the indent of the + * line that opened the frame; children must be at indent strictly greater + * than that value. The `root` frame has no header line and is never popped. + */ +type Frame + = | { kind: 'root' } + | { kind: 'importers', base: number } + | { kind: 'importer', base: number } + | { kind: 'depGroup', base: number, origin: Origin } + /** Block-form dep entry awaiting a nested `version:` line. */ + | { kind: 'depBlock', base: number, origin: Origin, pkgName: string } + | { kind: 'packages', base: number } + | { kind: 'snapshots', base: number } + /** Inside a `packages:`/`snapshots:` entry, collecting its subkeys. */ + | { kind: 'pkgEntry', base: number, key: string } + /** + * Inside a pkg entry's `dependencies:`/`optionalDependencies:` block, + * collecting dep edges for `owner`. + */ + | { kind: 'pkgDeps', base: number, owner: string } + +const DEP_GROUP_KEYS = new Set(['dependencies', 'devDependencies', 'optionalDependencies']) + +/** + * Parse a `pnpm-lock.yaml` text and return the installed version of + * `pkg`, if found. * - * LIMITATION: monorepo importers other than `.` are ignored. + * Search priority: + * 1. Direct match in `importers..{dependencies,devDependencies,optionalDependencies}` + * 2. Direct match in top-level `{dependencies,devDependencies,optionalDependencies}` (v5/v6) + * 3. Transitive resolution via BFS from root-importer deps through the + * `snapshots:` (v9) or `packages:` (v6–v8) dep graph + * 4. Fallback: first matching `packages:` or `snapshots:` key */ -function parse(content: string, name: string): string | null { - const escaped = escapeRegex(name) - const re = new RegExp(`^\\s*'?/${escaped}@([^():\\s_]+)`, 'm') - const match = content.match(re) - return match ? match[1]! : null +export function parsePnpmLock(text: string, pkg: string): string | null { + const stack: Frame[] = [{ kind: 'root' }] + const graph: PnpmGraph = { nodes: new Map(), roots: [] } + + let importerMatch: string | null = null + let topMatch: string | null = null + let packagesFallback: string | null = null + + const captureDirect = (depName: string, rawValue: string, origin: Origin): void => { + const cleaned = cleanValue(rawValue) + const stripped = stripPeerSuffix(cleaned) + // Add to graph roots using the raw (peer-including) value so the key + // matches `snapshots:` entries. + graph.roots.push(`${depName}@${cleaned}`) + + // Filter at capture so workspace/link/file versions in one importer + // don't block a real version in a later importer. + if (depName === pkg && isRegistryVersion(stripped)) { + if (origin === 'importer' && importerMatch === null) + importerMatch = stripped + else if (origin === 'root' && topMatch === null) + topMatch = stripped + } + } + + for (const raw of text.split('\n')) { + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw + if (line.trim().length === 0) + continue + if (line.trimStart().startsWith('#')) + continue + const indent = line.length - line.trimStart().length + const content = line.slice(indent) + + // Pop frames whose scope has ended. Root (no base) never pops, so + // the stack is never empty and the `at(-1)!` assertions are safe. + while (true) { + const frame = stack.at(-1)! + if (frame.kind !== 'root' && indent <= frame.base) { + stack.pop() + continue + } + break + } + + const top = stack.at(-1)! + switch (top.kind) { + case 'root': { + if (indent === 0 && content.endsWith(':')) { + const key = content.slice(0, -1).trim() + if (key === 'importers') + stack.push({ kind: 'importers', base: indent }) + else if (DEP_GROUP_KEYS.has(key)) + stack.push({ kind: 'depGroup', base: indent, origin: 'root' }) + else if (key === 'packages') + stack.push({ kind: 'packages', base: indent }) + else if (key === 'snapshots') + stack.push({ kind: 'snapshots', base: indent }) + } + break + } + case 'importers': { + if (content.endsWith(':')) + stack.push({ kind: 'importer', base: indent }) + break + } + case 'importer': { + if (content.endsWith(':') && DEP_GROUP_KEYS.has(content.slice(0, -1).trim())) + stack.push({ kind: 'depGroup', base: indent, origin: 'importer' }) + break + } + case 'depGroup': { + const sep = content.indexOf(':') + if (sep >= 0) { + const depName = trimQuotes(content.slice(0, sep).trim()) + const rawValue = content.slice(sep + 1).trim() + if (rawValue.length === 0) { + // Block form: version comes on a nested line. + stack.push({ kind: 'depBlock', base: indent, origin: top.origin, pkgName: depName }) + } + else { + captureDirect(depName, rawValue, top.origin) + } + } + break + } + case 'depBlock': { + if (content.startsWith('version:')) { + captureDirect(top.pkgName, content.slice('version:'.length), top.origin) + stack.pop() + } + break + } + case 'packages': + case 'snapshots': { + const sep = content.indexOf(':') + if (sep >= 0) { + const rawKey = trimQuotes(content.slice(0, sep).trim()) + const key = rawKey.startsWith('/') ? rawKey.slice(1) : rawKey + const valuePart = content.slice(sep + 1) + + const split = splitPkgSpec(key) + if (split) { + const [name, versionWithPeer] = split + const version = stripPeerSuffix(versionWithPeer) + + if (!graph.nodes.has(key)) + graph.nodes.set(key, { name, version, deps: [] }) + + if (name === pkg && packagesFallback === null && isRegistryVersion(version)) + packagesFallback = version + + if (valuePart.trim().length === 0) + stack.push({ kind: 'pkgEntry', base: indent, key }) + // Else: inline value like `{}` — no children to parse. + } + } + break + } + case 'pkgEntry': { + if (content.endsWith(':')) { + const subKey = content.slice(0, -1).trim() + if (subKey === 'dependencies' || subKey === 'optionalDependencies') + stack.push({ kind: 'pkgDeps', base: indent, owner: top.key }) + // Ignore resolution/engines/peerDependencies/transitivePeerDependencies/etc. + } + break + } + case 'pkgDeps': { + const sep = content.indexOf(':') + if (sep >= 0) { + const depName = trimQuotes(content.slice(0, sep).trim()) + const depValue = cleanValue(content.slice(sep + 1)) + if (depValue.length > 0) + graph.nodes.get(top.owner)?.deps.push(`${depName}@${depValue}`) + } + break + } + } + } + + return importerMatch ?? topMatch ?? resolveTransitive(graph, pkg) ?? packagesFallback +} + +/** + * Breadth-first search from `graph.roots` through the snapshot dep graph, + * returning the version of the first reached node whose name matches + * `pkg`. Depth-first would pick a less-predictable version; BFS picks the + * version at the shallowest transitive depth, which is closer to what's + * actually hoisted in `node_modules`. + */ +function resolveTransitive(graph: PnpmGraph, pkg: string): string | null { + if (graph.nodes.size === 0 || graph.roots.length === 0) + return null + const visited = new Set() + const queue = [...graph.roots] + while (queue.length > 0) { + const key = queue.shift()! + if (visited.has(key)) + continue + visited.add(key) + const node = graph.nodes.get(key) + if (!node) + continue + if (node.name === pkg && isRegistryVersion(node.version)) + return node.version + for (const dep of node.deps) { + if (!visited.has(dep)) + queue.push(dep) + } + } + return null } export const pnpmLockReader: LockfileReader = { @@ -36,7 +270,7 @@ export const pnpmLockReader: LockfileReader = { catch { return null } - const version = parse(content, name) + const version = parsePnpmLock(content, name) return version ? { version, source: 'pnpm-lock.yaml', exact: true } : null }, } diff --git a/packages/cli/src/lockfiles/yarn.ts b/packages/cli/src/lockfiles/yarn.ts index 6806f39..9ee423a 100644 --- a/packages/cli/src/lockfiles/yarn.ts +++ b/packages/cli/src/lockfiles/yarn.ts @@ -1,31 +1,108 @@ import type { LockfileReader } from './index.js' import fs from 'node:fs' import path from 'node:path' +import { + cleanValue, + isRegistryVersion, + splitPkgSpec, + stripPeerSuffix, + trimQuotes, +} from './parse-helpers.js' -const RE_REGEX_META = /[.*+?^${}()|[\]\\]/g -function escapeRegex(input: string): string { - return input.replace(RE_REGEX_META, '\\$&') -} +const RE_WS = /\s/ /** - * Parse a `yarn.lock` file (Yarn classic v1 format). - * - * Entries look like: + * Block-based `yarn.lock` parser handling both classic v1 and Berry v2+ + * in one code path, ported from opensrc's `core/version.rs` + * (vercel-labs/opensrc#51). Replaces the previous regex-based parser, + * whose multi-specifier headers only matched the first specifier: * - * "next@^15.0.0", next@15.0.3: - * version "15.0.3" + * v1: "foo@^1.0.0", "foo@~1.2.0": + * Berry: "foo@npm:^1.0.0, foo@workspace:*": * - * Locate an entry header that mentions this package, then capture the - * nearest following `version ""` within the block. + * Blocks are separated by blank lines. For each block whose header + * mentions `pkg` in ANY specifier, the nearest `version` line in the + * body wins — unless it is a workspace sentinel (`0.0.0-use.local`) or + * protocol string (`file:...`), in which case later blocks may still + * provide a real version. */ -function parse(content: string, name: string): string | null { - const escaped = escapeRegex(name) - const re = new RegExp( - `(?:^|[",\\s])${escaped}@[^\\n]*:\\s*\\n(?:\\s+[^\\n]*\\n)*?\\s+version\\s+"([^"]+)"`, - 'm', - ) - const match = content.match(re) - return match ? match[1]! : null +export function parseYarnLock(text: string, pkg: string): string | null { + const blocks: string[][] = [] + let current: string[] = [] + for (const raw of text.split('\n')) { + const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw + if (line.trim().length === 0) { + if (current.length > 0) { + blocks.push(current) + current = [] + } + } + else { + current.push(line) + } + } + if (current.length > 0) + blocks.push(current) + + for (const block of blocks) { + let header: string | null = null + const body: string[] = [] + + for (const line of block) { + if (line.trimStart().startsWith('#')) + continue + if (header === null && !RE_WS.test(line[0])) + header = line + else + body.push(line) + } + + if (header === null) + continue + if (header.startsWith('__metadata:')) + continue + if (!header.endsWith(':')) + continue + const headerBody = header.slice(0, -1) + + // Splitting on `, ` covers both: + // v1: "foo@^1.0.0", "foo@~1.2.0": + // Berry: "foo@npm:^1.0.0, foo@workspace:*": + // In the Berry case, the first split part keeps a leading `"` and the + // last keeps a trailing `"`; `trimQuotes` strips either form. + const matched = headerBody.split(', ').some((s) => { + const spec = trimQuotes(s.trim()) + const split = splitPkgSpec(spec) + return split !== null && split[0] === pkg + }) + + if (!matched) + continue + + for (const line of body) { + const trimmed = line.trimStart() + if (!trimmed.startsWith('version')) + continue + let rest = trimmed.slice('version'.length) + // Must be followed by `:` (Berry) or whitespace (v1) to be the + // version key — not e.g. `versions:`. + const next = rest[0] + if (next !== ':' && next !== ' ' && next !== '\t') + continue + rest = rest.trimStart() + if (rest.startsWith(':')) + rest = rest.slice(1) + const stripped = stripPeerSuffix(cleanValue(rest)) + // Skip workspace sentinels (`0.0.0-use.local`) and protocol + // strings (`workspace:.`, `portal:.`, etc.) so the caller gets + // `null` rather than an unfetchable "version". If another block + // later in the file has a real version, we'll find it there. + if (isRegistryVersion(stripped)) + return stripped + } + } + + return null } export const yarnLockReader: LockfileReader = { @@ -40,7 +117,7 @@ export const yarnLockReader: LockfileReader = { catch { return null } - const version = parse(content, name) + const version = parseYarnLock(content, name) return version ? { version, source: 'yarn.lock', exact: true } : null }, } diff --git a/packages/cli/test/lockfiles/fixtures/pnpm-v9-workspace.yaml b/packages/cli/test/lockfiles/fixtures/pnpm-v9-workspace.yaml new file mode 100644 index 0000000..3ca2009 --- /dev/null +++ b/packages/cli/test/lockfiles/fixtures/pnpm-v9-workspace.yaml @@ -0,0 +1,135 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.0.0 + version: 5.3.3 + + apps/web: + dependencies: + next: + specifier: ^14.0.0 + version: 14.0.0(react@18.2.0)(react-dom@18.2.0(react@18.2.0)) + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + devDependencies: + '@types/react': + specifier: ^18.2.0 + version: 18.2.45 + + apps/legacy: + dependencies: + react: + specifier: ^17.0.0 + version: 17.0.2 + react-dom: + specifier: ^17.0.0 + version: 17.0.2(react@17.0.2) + +packages: + + '@types/react@18.2.45': + resolution: {integrity: sha512-FAKE} + + js-tokens@4.0.0: + resolution: {integrity: sha512-FAKE} + + loose-envify@1.4.0: + resolution: {integrity: sha512-FAKE} + hasBin: true + + next@14.0.0: + resolution: {integrity: sha512-FAKE} + peerDependencies: + react: ^18.2.0 + react-dom: ^18.2.0 + + object-assign@4.1.1: + resolution: {integrity: sha512-FAKE} + + react-dom@17.0.2: + resolution: {integrity: sha512-FAKE} + peerDependencies: + react: 17.0.2 + + react-dom@18.2.0: + resolution: {integrity: sha512-FAKE} + peerDependencies: + react: ^18.2.0 + + react@17.0.2: + resolution: {integrity: sha512-FAKE} + + react@18.2.0: + resolution: {integrity: sha512-FAKE} + + scheduler@0.20.2: + resolution: {integrity: sha512-FAKE} + + scheduler@0.23.0: + resolution: {integrity: sha512-FAKE} + + typescript@5.3.3: + resolution: {integrity: sha512-FAKE} + hasBin: true + +snapshots: + + '@types/react@18.2.45': {} + + js-tokens@4.0.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + next@14.0.0(react@18.2.0)(react-dom@18.2.0(react@18.2.0)): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + object-assign@4.1.1: {} + + react-dom@17.0.2(react@17.0.2): + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react: 17.0.2 + scheduler: 0.20.2 + + react-dom@18.2.0(react@18.2.0): + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.0 + + react@17.0.2: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + + react@18.2.0: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.20.2: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + + scheduler@0.23.0: + dependencies: + loose-envify: 1.4.0 + + typescript@5.3.3: {} diff --git a/packages/cli/test/lockfiles/fixtures/yarn-berry.lock b/packages/cli/test/lockfiles/fixtures/yarn-berry.lock new file mode 100644 index 0000000..dbda87b --- /dev/null +++ b/packages/cli/test/lockfiles/fixtures/yarn-berry.lock @@ -0,0 +1,98 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 10c0 + +"@babel/code-frame@npm:^7.22.13": + version: 7.23.5 + resolution: "@babel/code-frame@npm:7.23.5" + dependencies: + "@babel/highlight": "npm:^7.23.4" + chalk: "npm:^2.4.2" + checksum: FAKE + languageName: node + linkType: hard + +"@babel/core@npm:^7.22.0": + version: 7.23.0 + resolution: "@babel/core@npm:7.23.0" + dependencies: + "@babel/code-frame": "npm:^7.22.13" + checksum: FAKE + languageName: node + linkType: hard + +"@babel/highlight@npm:^7.23.4": + version: 7.23.4 + resolution: "@babel/highlight@npm:7.23.4" + checksum: FAKE + languageName: node + linkType: hard + +"@types/node@npm:^20.0.0": + version: 20.10.0 + resolution: "@types/node@npm:20.10.0" + checksum: FAKE + languageName: node + linkType: hard + +"@types/react@npm:^18.2.0": + version: 18.2.45 + resolution: "@types/react@npm:18.2.45" + checksum: FAKE + languageName: node + linkType: hard + +"chokidar@npm:^3.5.0": + version: 3.5.3 + resolution: "chokidar@npm:3.5.3" + dependencies: + anymatch: "npm:~3.1.2" + normalize-path: "npm:^3.0.0" + checksum: FAKE + languageName: node + linkType: hard + +"lodash@npm:^4.17.21, lodash@npm:~4.17.10": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: FAKE + languageName: node + linkType: hard + +"myproject@workspace:.": + version: 0.0.0-use.local + resolution: "myproject@workspace:." + dependencies: + "@babel/core": "npm:^7.22.0" + "@types/node": "npm:^20.0.0" + "@types/react": "npm:^18.2.0" + chokidar: "npm:^3.5.0" + lodash: "npm:^4.17.21" + react: "npm:^18.2.0" + typescript: "npm:^5.0.0" + languageName: unknown + linkType: soft + +"normalize-path@npm:^3.0.0": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: FAKE + languageName: node + linkType: hard + +"react@npm:^18.2.0": + version: 18.2.0 + resolution: "react@npm:18.2.0" + checksum: FAKE + languageName: node + linkType: hard + +"typescript@npm:^5.0.0": + version: 5.3.3 + resolution: "typescript@npm:5.3.3" + checksum: FAKE + languageName: node + linkType: hard diff --git a/packages/cli/test/lockfiles/fixtures/yarn-v1.lock b/packages/cli/test/lockfiles/fixtures/yarn-v1.lock new file mode 100644 index 0000000..922b2bc --- /dev/null +++ b/packages/cli/test/lockfiles/fixtures/yarn-v1.lock @@ -0,0 +1,83 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.22.13": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz" + integrity sha512-FAKE + dependencies: + "@babel/highlight" "^7.23.4" + chalk "^2.4.2" + +"@babel/core@^7.22.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.0.tgz" + integrity sha512-FAKE + dependencies: + "@babel/code-frame" "^7.22.13" + +"@babel/highlight@^7.23.4": + version "7.23.4" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz" + integrity sha512-FAKE + +"@types/node@^20.0.0": + version "20.10.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.0.tgz" + integrity sha512-FAKE + +"@types/react@^18.2.0": + version "18.2.45" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.45.tgz" + integrity sha512-FAKE + +"anymatch@~3.1.2": + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz" + integrity sha512-FAKE + +"chalk@^2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz" + integrity sha512-FAKE + +"chokidar@^3.5.0": + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz" + integrity sha512-FAKE + dependencies: + anymatch "~3.1.2" + normalize-path "^3.0.0" + +"lodash@^4.17.21", "lodash@~4.17.10": + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz" + integrity sha512-FAKE + +"lodash@^3.0.0": + version "3.10.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz" + integrity sha512-FAKE + +"normalize-path@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz" + integrity sha512-FAKE + +"react@^18.2.0": + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz" + integrity sha512-FAKE + dependencies: + loose-envify "^1.1.0" + +"typescript@^5.0.0": + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz" + integrity sha512-FAKE + +"zod@^3.22.0": + version "3.22.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz" + integrity sha512-FAKE diff --git a/packages/cli/test/lockfiles/parsers.test.ts b/packages/cli/test/lockfiles/parsers.test.ts new file mode 100644 index 0000000..6e5d8d0 --- /dev/null +++ b/packages/cli/test/lockfiles/parsers.test.ts @@ -0,0 +1,630 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'bun:test' +import { packageJsonReader } from '../../src/lockfiles/package-json.js' +import { + cleanValue, + isRegistryVersion, + splitPkgSpec, + stripInlineComment, + stripPeerSuffix, +} from '../../src/lockfiles/parse-helpers.js' +import { parsePnpmLock } from '../../src/lockfiles/pnpm.js' +import { parseYarnLock } from '../../src/lockfiles/yarn.js' + +// Ported from opensrc's core/version.rs test suite (vercel-labs/opensrc#51). + +const FIXTURES = path.join(import.meta.dir, 'fixtures') +const YARN_V1_FIXTURE = fs.readFileSync(path.join(FIXTURES, 'yarn-v1.lock'), 'utf8') +const YARN_BERRY_FIXTURE = fs.readFileSync(path.join(FIXTURES, 'yarn-berry.lock'), 'utf8') +const PNPM_V9_FIXTURE = fs.readFileSync(path.join(FIXTURES, 'pnpm-v9-workspace.yaml'), 'utf8') + +describe('parse helpers', () => { + it('splitPkgSpec handles plain names', () => { + expect(splitPkgSpec('zod@3.22.0')).toEqual(['zod', '3.22.0']) + }) + + it('splitPkgSpec handles scoped names', () => { + expect(splitPkgSpec('@scope/pkg@1.2.3')).toEqual(['@scope/pkg', '1.2.3']) + }) + + it('splitPkgSpec keeps npm protocol in the rest', () => { + expect(splitPkgSpec('zod@npm:^3.22.0')).toEqual(['zod', 'npm:^3.22.0']) + }) + + it('splitPkgSpec returns null without a separator', () => { + expect(splitPkgSpec('zod')).toBeNull() + expect(splitPkgSpec('@scope/pkg')).toBeNull() + }) + + it('stripPeerSuffix strips single and nested peer suffixes', () => { + expect(stripPeerSuffix('18.0.0')).toBe('18.0.0') + expect(stripPeerSuffix('18.0.0(react@18.0.0)')).toBe('18.0.0') + expect(stripPeerSuffix('15.5.15(a@1)(b@2(c@3))')).toBe('15.5.15') + expect(stripPeerSuffix('3.22.0 ')).toBe('3.22.0') + }) + + it('stripInlineComment strips only whitespace-preceded #', () => { + expect(stripInlineComment('1.2.3')).toBe('1.2.3') + expect(stripInlineComment('1.2.3 # comment')).toBe('1.2.3') + expect(stripInlineComment('1.2.3 # trailing')).toBe('1.2.3') + // URL fragment (no space before #) must pass through. + expect(stripInlineComment('github:foo/bar#branch')).toBe('github:foo/bar#branch') + }) + + it('cleanValue trims, strips comments and quotes', () => { + expect(cleanValue(' "1.2.3" ')).toBe('1.2.3') + expect(cleanValue(' 1.2.3 # comment')).toBe('1.2.3') + expect(cleanValue(' \'1.2.3\' # comment')).toBe('1.2.3') + }) + + it('isRegistryVersion rejects protocol strings and sentinels', () => { + expect(isRegistryVersion('1.2.3')).toBe(true) + expect(isRegistryVersion('1.0.0-beta.1')).toBe(true) + expect(isRegistryVersion('1.0.0-rc.1+build.5114f85')).toBe(true) + expect(isRegistryVersion('')).toBe(false) + expect(isRegistryVersion('0.0.0-use.local')).toBe(false) + expect(isRegistryVersion('link:../pkg')).toBe(false) + expect(isRegistryVersion('file:./tarball.tgz')).toBe(false) + expect(isRegistryVersion('workspace:*')).toBe(false) + expect(isRegistryVersion('workspace:^1.0.0')).toBe(false) + expect(isRegistryVersion('portal:../pkg')).toBe(false) + expect(isRegistryVersion('github:owner/repo')).toBe(false) + expect(isRegistryVersion('git+https://example.com/repo.git')).toBe(false) + expect(isRegistryVersion('npm:other-pkg@^1')).toBe(false) + }) +}) + +describe('parsePnpmLock (direct lookup)', () => { + it('parses v5 top-level string form', () => { + const text = `lockfileVersion: '5.4' + +specifiers: + zod: ^3.22.0 + +dependencies: + zod: 3.22.0 + +packages: + /zod/3.22.0: + resolution: {} +` + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) + + it('parses v6 top-level object form', () => { + const text = `lockfileVersion: '6.0' + +dependencies: + zod: + specifier: ^3.22.0 + version: 3.22.0 + +packages: + /zod@3.22.0: + resolution: {} +` + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) + + it('parses v9 importer with peer suffix', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + react-dom: + specifier: ^18.0.0 + version: 18.2.0(react@18.0.0) + +packages: + react-dom@18.2.0: + resolution: {} +` + expect(parsePnpmLock(text, 'react-dom')).toBe('18.2.0') + }) + + it('parses scoped package in importer', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + '@scope/pkg': + specifier: ^1.0.0 + version: 1.2.3 +` + expect(parsePnpmLock(text, '@scope/pkg')).toBe('1.2.3') + }) + + it('falls back to packages key', () => { + const text = `lockfileVersion: '9.0' + +packages: + zod@3.22.0: + resolution: {} +` + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) + + it('falls back to scoped packages key', () => { + const text = `lockfileVersion: '9.0' + +packages: + '@scope/pkg@1.2.3': + resolution: {} +` + expect(parsePnpmLock(text, '@scope/pkg')).toBe('1.2.3') + }) + + it('returns null when absent', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + react: + specifier: ^18.0.0 + version: 18.0.0 +` + expect(parsePnpmLock(text, 'zod')).toBeNull() + }) + + it('does not false-match inside a peer suffix', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + react-dom: + specifier: ^18.0.0 + version: 18.2.0(react@17.0.0) +` + expect(parsePnpmLock(text, 'react')).toBeNull() + }) + + it('first importer wins across multiple importers', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + zod: + specifier: ^3.22.0 + version: 3.22.0 + apps/docs: + dependencies: + zod: + specifier: ^3.23.0 + version: 3.23.0 +` + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) + + it('reads devDependencies in importers', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + devDependencies: + typescript: + specifier: ^5.0.0 + version: 5.4.5 +` + expect(parsePnpmLock(text, 'typescript')).toBe('5.4.5') + }) + + it('reads optionalDependencies in importers', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + optionalDependencies: + fsevents: + specifier: ^2.3.0 + version: 2.3.3 +` + expect(parsePnpmLock(text, 'fsevents')).toBe('2.3.3') + }) + + it('handles CRLF line endings', () => { + const text = 'lockfileVersion: \'9.0\'\r\n\r\nimporters:\r\n .:\r\n dependencies:\r\n zod:\r\n specifier: ^3.22.0\r\n version: 3.22.0\r\n' + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) + + it('handles empty files', () => { + expect(parsePnpmLock('', 'zod')).toBeNull() + }) + + it('handles comment-only files', () => { + expect(parsePnpmLock('# just a comment\n# another one\n', 'zod')).toBeNull() + }) + + it('strips inline comments', () => { + const text = `lockfileVersion: '9.0' + +dependencies: + zod: 3.22.0 # pinned +` + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) + + it('skips link: versions in importers', () => { + const text = `lockfileVersion: '9.0' + +importers: + apps/web: + dependencies: + my-ui-lib: + specifier: workspace:^ + version: link:../../packages/ui +` + expect(parsePnpmLock(text, 'my-ui-lib')).toBeNull() + }) + + it('workspace link in first importer does not block later real version', () => { + const text = `lockfileVersion: '9.0' + +importers: + apps/web: + dependencies: + shared: + specifier: workspace:^ + version: link:../../packages/shared + apps/docs: + dependencies: + shared: + specifier: ^1.2.3 + version: 1.2.3 +` + expect(parsePnpmLock(text, 'shared')).toBe('1.2.3') + }) + + it('skips link: versions in top-level deps', () => { + const text = `lockfileVersion: '6.0' + +dependencies: + my-lib: link:../my-lib +` + expect(parsePnpmLock(text, 'my-lib')).toBeNull() + }) + + it('skips file: protocol in importers', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + tarball-pkg: + specifier: file:./pkg.tgz + version: file:pkg.tgz +` + expect(parsePnpmLock(text, 'tarball-pkg')).toBeNull() + }) + + it('parses indent-relative 4-space files', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + zod: + specifier: ^3.22.0 + version: 3.22.0 +` + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) +}) + +describe('parsePnpmLock (transitive resolution)', () => { + it('resolves transitively via snapshots', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + next: + specifier: ^14 + version: 14.0.0(react@18.2.0) + +packages: + + foo@1.0.0: + resolution: {} + + next@14.0.0: + resolution: {} + + react@18.2.0: + resolution: {} + +snapshots: + + foo@1.0.0: {} + + next@14.0.0(react@18.2.0): + dependencies: + foo: 1.0.0 + react: 18.2.0 + + react@18.2.0: {} +` + expect(parsePnpmLock(text, 'foo')).toBe('1.0.0') + }) + + it('picks the root-reachable version among multiple candidates', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + next: + specifier: ^14 + version: 14.0.0(react@18.2.0) + +snapshots: + + next@14.0.0(react@18.2.0): + dependencies: + react: 18.2.0 + + react@17.0.0: {} + + react@18.2.0: {} +` + expect(parsePnpmLock(text, 'react')).toBe('18.2.0') + }) + + it('falls back to packages key when unreachable', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + zod: + specifier: ^3 + version: 3.22.0 + +packages: + + unused@9.9.9: + resolution: {} + + zod@3.22.0: + resolution: {} + +snapshots: + + zod@3.22.0: {} +` + expect(parsePnpmLock(text, 'unused')).toBe('9.9.9') + }) + + it('handles dependency cycles', () => { + const text = `lockfileVersion: '9.0' + +importers: + .: + dependencies: + a: + specifier: ^1 + version: 1.0.0 + +snapshots: + + a@1.0.0: + dependencies: + b: 1.0.0 + + b@1.0.0: + dependencies: + a: 1.0.0 + target: 2.0.0 + + target@2.0.0: {} +` + expect(parsePnpmLock(text, 'target')).toBe('2.0.0') + }) +}) + +describe('parseYarnLock', () => { + it('parses v1 single specifier', () => { + const text = '# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT DIRECTLY.\n' + + '# yarn lockfile v1\n\n\n' + + '"zod@^3.22.0":\n version "3.22.0"\n' + + ' resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.0.tgz"\n' + expect(parseYarnLock(text, 'zod')).toBe('3.22.0') + }) + + it('parses v1 multi-specifier where match is not first', () => { + const text = '# yarn lockfile v1\n\n\n' + + '"foo@^1.0.0":\n version "1.0.0"\n\n' + + '"bar@^1.0.0", "bar@~1.2.0":\n version "1.2.3"\n' + expect(parseYarnLock(text, 'bar')).toBe('1.2.3') + }) + + it('parses v1 scoped packages', () => { + const text = '# yarn lockfile v1\n\n\n' + + '"@scope/pkg@^1.0.0":\n version "1.0.0"\n' + expect(parseYarnLock(text, '@scope/pkg')).toBe('1.0.0') + }) + + it('parses Berry npm protocol', () => { + const text = '# This file is generated by running "yarn install".\n\n' + + '__metadata:\n version: 6\n cacheKey: 8\n\n' + + '"zod@npm:^3.22.0":\n version: 3.22.0\n resolution: "zod@npm:3.22.0"\n' + expect(parseYarnLock(text, 'zod')).toBe('3.22.0') + }) + + it('parses Berry comma-separated specifiers', () => { + const text = '__metadata:\n version: 6\n\n' + + '"foo@npm:^1.0.0, foo@workspace:*":\n version: 1.2.3\n resolution: "foo@npm:1.2.3"\n' + expect(parseYarnLock(text, 'foo')).toBe('1.2.3') + }) + + it('parses Berry scoped packages', () => { + const text = '__metadata:\n version: 6\n\n' + + '"@scope/pkg@npm:^1.0.0":\n version: 1.2.3\n' + expect(parseYarnLock(text, '@scope/pkg')).toBe('1.2.3') + }) + + it('returns null when absent', () => { + const text = '# yarn lockfile v1\n\n\n' + + '"foo@^1.0.0":\n version "1.0.0"\n' + expect(parseYarnLock(text, 'zod')).toBeNull() + }) + + it('skips the __metadata block', () => { + const text = '__metadata:\n version: 6\n' + expect(parseYarnLock(text, '__metadata')).toBeNull() + }) + + it('handles CRLF line endings', () => { + const text = '# yarn lockfile v1\r\n\r\n\r\n"zod@^3.22.0":\r\n version "3.22.0"\r\n' + expect(parseYarnLock(text, 'zod')).toBe('3.22.0') + }) + + it('handles empty files', () => { + expect(parseYarnLock('', 'zod')).toBeNull() + }) + + it('strips v1 inline comments', () => { + const text = '# yarn lockfile v1\n\n\n' + + '"zod@^3.22.0":\n version "3.22.0" # pinned\n' + expect(parseYarnLock(text, 'zod')).toBe('3.22.0') + }) + + it('strips Berry inline comments', () => { + const text = '__metadata:\n version: 6\n\n' + + '"zod@npm:^3.22.0":\n version: 3.22.0 # pinned\n' + expect(parseYarnLock(text, 'zod')).toBe('3.22.0') + }) + + it('skips the Berry workspace-root sentinel', () => { + const text = '__metadata:\n version: 6\n\n' + + '"myproject@workspace:.":\n version: 0.0.0-use.local\n resolution: "myproject@workspace:."\n' + expect(parseYarnLock(text, 'myproject')).toBeNull() + }) + + it('workspace block does not block a later real block', () => { + const text = '__metadata:\n version: 6\n\n' + + '"foo@workspace:packages/foo":\n version: 0.0.0-use.local\n resolution: "foo@workspace:packages/foo"\n\n' + + '"foo@npm:^1.0.0":\n version: 1.2.3\n resolution: "foo@npm:1.2.3"\n' + expect(parseYarnLock(text, 'foo')).toBe('1.2.3') + }) + + it('skips v1 link/file protocol versions', () => { + const text = '# yarn lockfile v1\n\n\n' + + '"my-lib@file:../my-lib":\n version "file:../my-lib"\n' + expect(parseYarnLock(text, 'my-lib')).toBeNull() + }) +}) + +describe('fixture-backed tests', () => { + it('yarn v1 fixture: scoped packages', () => { + expect(parseYarnLock(YARN_V1_FIXTURE, '@babel/core')).toBe('7.23.0') + expect(parseYarnLock(YARN_V1_FIXTURE, '@types/react')).toBe('18.2.45') + }) + + it('yarn v1 fixture: multi-specifier header', () => { + expect(parseYarnLock(YARN_V1_FIXTURE, 'lodash')).toBe('4.17.21') + }) + + it('yarn v1 fixture: direct deps', () => { + expect(parseYarnLock(YARN_V1_FIXTURE, 'react')).toBe('18.2.0') + expect(parseYarnLock(YARN_V1_FIXTURE, 'typescript')).toBe('5.3.3') + expect(parseYarnLock(YARN_V1_FIXTURE, 'zod')).toBe('3.22.4') + }) + + it('yarn v1 fixture: absent package', () => { + expect(parseYarnLock(YARN_V1_FIXTURE, 'not-installed-anywhere')).toBeNull() + }) + + it('yarn Berry fixture: scoped with npm protocol', () => { + expect(parseYarnLock(YARN_BERRY_FIXTURE, '@types/react')).toBe('18.2.45') + }) + + it('yarn Berry fixture: comma specifier', () => { + expect(parseYarnLock(YARN_BERRY_FIXTURE, 'lodash')).toBe('4.17.21') + }) + + it('yarn Berry fixture: direct deps', () => { + expect(parseYarnLock(YARN_BERRY_FIXTURE, 'react')).toBe('18.2.0') + expect(parseYarnLock(YARN_BERRY_FIXTURE, 'typescript')).toBe('5.3.3') + }) + + it('yarn Berry fixture: absent package', () => { + expect(parseYarnLock(YARN_BERRY_FIXTURE, 'not-installed-anywhere')).toBeNull() + }) + + it('pnpm v9 fixture: direct importer dep', () => { + expect(parsePnpmLock(PNPM_V9_FIXTURE, 'next')).toBe('14.0.0') + }) + + it('pnpm v9 fixture: scoped direct dep', () => { + expect(parsePnpmLock(PNPM_V9_FIXTURE, '@types/react')).toBe('18.2.45') + }) + + it('pnpm v9 fixture: first importer wins', () => { + // apps/web (react@18.2.0) is listed before apps/legacy (react@17.0.2). + expect(parsePnpmLock(PNPM_V9_FIXTURE, 'react')).toBe('18.2.0') + }) + + it('pnpm v9 fixture: transitive via BFS', () => { + // js-tokens is only reachable via loose-envify; not a direct dep. + expect(parsePnpmLock(PNPM_V9_FIXTURE, 'js-tokens')).toBe('4.0.0') + }) + + it('pnpm v9 fixture: BFS prefers version reachable from first importer', () => { + // Both scheduler@0.20.2 (via legacy) and scheduler@0.23.0 (via web) + // exist. BFS from roots in file order reaches 0.23.0 first. + expect(parsePnpmLock(PNPM_V9_FIXTURE, 'scheduler')).toBe('0.23.0') + }) + + it('pnpm v9 fixture: absent package', () => { + expect(parsePnpmLock(PNPM_V9_FIXTURE, 'definitely-not-here')).toBeNull() + }) + + it('pnpm v9 fixture: top-level typescript dev dep', () => { + expect(parsePnpmLock(PNPM_V9_FIXTURE, 'typescript')).toBe('5.3.3') + }) +}) + +describe('packageJsonReader protocol filtering', () => { + function withPackageJson(json: object, fn: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ask-pkgjson-')) + try { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(json)) + fn(dir) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + } + + it('still returns plain ranges', () => { + withPackageJson({ dependencies: { zod: '^3.22.0' } }, (dir) => { + expect(packageJsonReader.read('zod', dir)).toEqual({ + version: '^3.22.0', + source: 'package.json', + exact: false, + }) + }) + }) + + it('skips workspace protocol', () => { + withPackageJson({ dependencies: { 'my-lib': 'workspace:*' } }, (dir) => { + expect(packageJsonReader.read('my-lib', dir)).toBeNull() + }) + }) + + it('skips link and file protocols', () => { + withPackageJson({ + dependencies: { linked: 'link:../linked', tarball: 'file:./pkg.tgz' }, + }, (dir) => { + expect(packageJsonReader.read('linked', dir)).toBeNull() + expect(packageJsonReader.read('tarball', dir)).toBeNull() + }) + }) +}) From 053e1a3d28b95b2b6538e8bb7a2a6a27558effa0 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 21:52:52 +0900 Subject: [PATCH 03/10] feat(cli): add ask fetch command to warm the source cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of opensrc fetch (vercel-labs/opensrc#53/#56). ask fetch resolves each spec through the shared ensureCheckout helper without printing paths — the prefetch counterpart to ask src / ask docs. - supports multiple specs, -q/--quiet, per-spec ✓ fetched / already cached lines and a summary; per-spec failures print to stderr and the command exits non-zero at the end - EnsureCheckoutResult gains a fromCache flag so callers can tell a store hit from a fresh fetch - warms the cache that ask add's offline-first docs-path prompt reads, removing the need to abuse ask docs for prefetching --- packages/cli/src/commands/ensure-checkout.ts | 10 +- packages/cli/src/commands/fetch.ts | 109 +++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/test/commands/fetch.test.ts | 86 +++++++++++++++ 4 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/fetch.ts create mode 100644 packages/cli/test/commands/fetch.test.ts diff --git a/packages/cli/src/commands/ensure-checkout.ts b/packages/cli/src/commands/ensure-checkout.ts index 07a7124..6eccb3e 100644 --- a/packages/cli/src/commands/ensure-checkout.ts +++ b/packages/cli/src/commands/ensure-checkout.ts @@ -33,6 +33,12 @@ export interface EnsureCheckoutResult { * Undefined for github/pypi/pub/etc. specs. */ npmPackageName?: string + /** + * True when the checkout was already in the store (no network fetch + * happened). `ask fetch` uses this to report "fetched" vs "already + * cached" per spec. + */ + fromCache: boolean } /** @@ -205,7 +211,7 @@ export async function ensureCheckout( // 5. Cache hit short-circuit if (fs.existsSync(checkoutDir)) { - return { parsed, owner, repo, ref, resolvedVersion, checkoutDir, npmPackageName } + return { parsed, owner, repo, ref, resolvedVersion, checkoutDir, npmPackageName, fromCache: true } } // 6. Cache miss + noFetch → throw @@ -253,5 +259,5 @@ export async function ensureCheckout( // the path downstream tools actually index. const actualRef = resolvedCheckoutDir === checkoutDir ? ref : path.basename(resolvedCheckoutDir) - return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName } + return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName, fromCache: false } } diff --git a/packages/cli/src/commands/fetch.ts b/packages/cli/src/commands/fetch.ts new file mode 100644 index 0000000..37fb7d9 --- /dev/null +++ b/packages/cli/src/commands/fetch.ts @@ -0,0 +1,109 @@ +import process from 'node:process' +import { defineCommand } from 'citty' +import { ensureCheckout as defaultEnsureCheckout } from './ensure-checkout.js' + +export interface RunFetchOptions { + specs: string[] + projectDir: string + quiet?: boolean +} + +export interface RunFetchDeps { + ensureCheckout?: typeof defaultEnsureCheckout + log?: (msg: string) => void + error?: (msg: string) => void + exit?: (code: number) => void +} + +/** + * Implementation of `ask fetch ` (ported from opensrc's + * `opensrc fetch`, vercel-labs/opensrc#53). Warms the checkout cache for + * each spec via the shared `ensureCheckout` helper WITHOUT printing + * paths — the counterpart to `ask src`/`ask docs`, which print where the + * source lives. Useful for prefetching in setup scripts and for warming + * the cache that `ask add`'s offline-first docs-path prompt reads from. + * + * Per-spec failures are reported and the remaining specs still run; + * the process exits non-zero at the end if any spec failed. + */ +export async function runFetch(options: RunFetchOptions, deps: RunFetchDeps = {}): Promise { + const ensureCheckout = deps.ensureCheckout ?? defaultEnsureCheckout + const log = deps.log ?? ((msg: string) => process.stdout.write(`${msg}\n`)) + const error = deps.error ?? ((msg: string) => process.stderr.write(`${msg}\n`)) + const exit = deps.exit ?? ((code: number) => process.exit(code)) + + let fetched = 0 + let cached = 0 + let hadErrors = false + + for (const spec of options.specs) { + try { + const result = await ensureCheckout({ spec, projectDir: options.projectDir }) + // Avoid `spec@ref` duplication when the user already pinned the + // ref in the spec itself (e.g. `github:owner/repo@v1.2.3`). + const display = spec.endsWith(`@${result.ref}`) ? spec : `${spec}@${result.ref}` + if (result.fromCache) { + cached += 1 + if (!options.quiet) + log(` ✓ ${display} already cached (${result.checkoutDir})`) + } + else { + fetched += 1 + if (!options.quiet) + log(` ✓ Fetched ${display} (${result.checkoutDir})`) + } + } + catch (err) { + hadErrors = true + error(` ✗ ${spec}: ${err instanceof Error ? err.message : String(err)}`) + } + } + + if (!options.quiet) { + const parts: string[] = [] + if (fetched > 0) + parts.push(`${fetched} fetched`) + if (cached > 0) + parts.push(`${cached} already cached`) + if (parts.length > 0) + log(`\n${parts.join(', ')}`) + } + + if (hadErrors) + exit(1) +} + +/** + * Citty command surface for `ask fetch`. Wires the citty argument parser + * to `runFetch` and uses real stdout/stderr/process.exit. Tests should + * call `runFetch` directly with mocked deps. + */ +export const fetchCmd = defineCommand({ + meta: { + name: 'fetch', + description: 'Warm the source cache for one or more specs without printing paths', + }, + args: { + spec: { + type: 'positional', + description: 'Library spec(s) (e.g. react, npm:react@18.2.0, github:facebook/react@v18.2.0)', + required: true, + }, + quiet: { + type: 'boolean', + alias: 'q', + description: 'Suppress progress output (errors still print to stderr)', + }, + }, + async run({ args }) { + // citty binds the first positional to `args.spec` and keeps ALL + // positionals (including the first) in `args._`. + const rest = (args._ ?? []).filter(s => s.length > 0) + const specs = rest.length > 0 ? rest : [args.spec] + await runFetch({ + specs, + projectDir: process.cwd(), + quiet: Boolean(args.quiet), + }) + }, +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ceed229..99c2921 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,6 +8,7 @@ import { consola } from 'consola' import { addCmd } from './commands/add.js' import { docsCmd } from './commands/docs.js' import { splitExplicitVersion } from './commands/ensure-checkout.js' +import { fetchCmd } from './commands/fetch.js' import { searchCmd } from './commands/search.js' import { skillsCmd } from './commands/skills/index.js' import { srcCmd } from './commands/src.js' @@ -303,6 +304,7 @@ export const main = defineCommand({ list: listCmd, src: srcCmd, docs: docsCmd, + fetch: fetchCmd, search: searchCmd, skills: skillsCmd, cache: cacheCmd, diff --git a/packages/cli/test/commands/fetch.test.ts b/packages/cli/test/commands/fetch.test.ts new file mode 100644 index 0000000..106028e --- /dev/null +++ b/packages/cli/test/commands/fetch.test.ts @@ -0,0 +1,86 @@ +import type { EnsureCheckoutResult } from '../../src/commands/ensure-checkout.js' +import { describe, expect, it } from 'bun:test' +import { runFetch } from '../../src/commands/fetch.js' + +function resultFor(spec: string, fromCache: boolean): EnsureCheckoutResult { + return { + parsed: { kind: 'github', owner: 'o', repo: 'r', name: 'r' } as EnsureCheckoutResult['parsed'], + owner: 'o', + repo: 'r', + ref: 'v1.0.0', + resolvedVersion: '1.0.0', + checkoutDir: `/store/github/github.com/o/${spec}/v1.0.0`, + fromCache, + } +} + +interface Capture { + logs: string[] + errors: string[] + exitCodes: number[] +} + +function deps(capture: Capture, impl: (spec: string) => Promise) { + return { + ensureCheckout: (opts: { spec: string }) => impl(opts.spec), + log: (msg: string) => capture.logs.push(msg), + error: (msg: string) => capture.errors.push(msg), + exit: (code: number) => capture.exitCodes.push(code), + } +} + +describe('runFetch', () => { + it('reports fetched and cached specs with a summary', async () => { + const capture: Capture = { logs: [], errors: [], exitCodes: [] } + await runFetch( + { specs: ['a', 'b'], projectDir: '/proj' }, + deps(capture, async spec => resultFor(spec, spec === 'b')), + ) + expect(capture.logs[0]).toContain('Fetched a@v1.0.0') + expect(capture.logs[1]).toContain('b@v1.0.0 already cached') + expect(capture.logs[2]).toBe('\n1 fetched, 1 already cached') + expect(capture.errors).toEqual([]) + expect(capture.exitCodes).toEqual([]) + }) + + it('suppresses progress output with quiet but keeps errors', async () => { + const capture: Capture = { logs: [], errors: [], exitCodes: [] } + await runFetch( + { specs: ['a', 'bad'], projectDir: '/proj', quiet: true }, + deps(capture, async (spec) => { + if (spec === 'bad') + throw new Error('resolver blew up') + return resultFor(spec, false) + }), + ) + expect(capture.logs).toEqual([]) + expect(capture.errors).toEqual([' ✗ bad: resolver blew up']) + expect(capture.exitCodes).toEqual([1]) + }) + + it('continues after a failing spec and exits non-zero at the end', async () => { + const capture: Capture = { logs: [], errors: [], exitCodes: [] } + await runFetch( + { specs: ['bad', 'a'], projectDir: '/proj' }, + deps(capture, async (spec) => { + if (spec === 'bad') + throw new Error('boom') + return resultFor(spec, false) + }), + ) + expect(capture.errors).toEqual([' ✗ bad: boom']) + // The good spec after the failure still ran and is summarized. + expect(capture.logs.at(-1)).toBe('\n1 fetched') + expect(capture.exitCodes).toEqual([1]) + }) + + it('prints no summary when nothing was processed', async () => { + const capture: Capture = { logs: [], errors: [], exitCodes: [] } + await runFetch( + { specs: [], projectDir: '/proj' }, + deps(capture, async spec => resultFor(spec, false)), + ) + expect(capture.logs).toEqual([]) + expect(capture.exitCodes).toEqual([]) + }) +}) From 4fedd109827dace2e3b486e81e086a6789816fc8 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 21:52:52 +0900 Subject: [PATCH 04/10] feat(cli): support GITHUB_TOKEN for private repos with strict host validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of vercel-labs/opensrc#52 + #66 (scoped to GitHub — ASK's store layout is github.com-only for MVP, so the GitLab/Bitbucket token paths do not apply). - authenticatedCloneUrl parses the clone URL and injects x-access-token: ONLY on an exact https://github.com host match; prefix-confusable hosts (github.com.evil.com, evilgithub.com, subdomains), non-https schemes, and ssh remotes pass through untouched (#66 host-confusion fix) - auth is applied at the exec boundary (clone, ls-remote); error messages keep the token-free URL and redactToken scrubs git/ execFileSync output so the secret never reaches logs or thrown errors - tar.gz fallback switches to the api.github.com tarball endpoint when a token is set: undici drops the Authorization header on the cross-origin redirect to codeload, but GitHub embeds a temporary token in the redirect Location, so private downloads still work --- packages/cli/src/sources/github.ts | 97 ++++++++++++++++--- packages/cli/test/sources/github-auth.test.ts | 57 +++++++++++ 2 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 packages/cli/test/sources/github-auth.test.ts diff --git a/packages/cli/src/sources/github.ts b/packages/cli/src/sources/github.ts index a2f14b7..ba75260 100644 --- a/packages/cli/src/sources/github.ts +++ b/packages/cli/src/sources/github.ts @@ -10,6 +10,7 @@ import { execFileSync, spawnSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import process from 'node:process' import { consola } from 'consola' import { acquireEntryLock, @@ -41,6 +42,56 @@ const RE_SAFE_REF = /^[\w./@-]+$/ const DEFAULT_GITHUB_HOST = 'github.com' +/** + * Read the GitHub auth token from the environment, treating an empty + * string as absent. + */ +function githubToken(): string | undefined { + const token = process.env.GITHUB_TOKEN + return token || undefined +} + +/** + * Rewrite an HTTPS GitHub clone URL to embed auth credentials when a + * token is available. Ported from opensrc's `authenticated_clone_url` + * including the vercel-labs/opensrc#66 host-validation fix: the URL is + * fully parsed and the token is only injected on an EXACT `github.com` + * host match — prefix-confusable hosts like `github.com.evil.com` (or + * `evilgithub.com`, subdomains, non-https schemes, ssh remotes) never + * receive credentials and pass through unchanged. + * + * The returned URL contains the secret — use it ONLY as a git/network + * argument, never in log or error output. + */ +export function authenticatedCloneUrl(url: string, token: string | undefined = githubToken()): string { + if (!token) + return url + let parsed: URL + try { + parsed = new URL(url) + } + catch { + return url + } + if (parsed.protocol !== 'https:') + return url + if (parsed.hostname.toLowerCase() !== DEFAULT_GITHUB_HOST) + return url + parsed.username = 'x-access-token' + parsed.password = token + return parsed.toString() +} + +/** + * Scrub the GitHub token from a message before it reaches logs or + * thrown errors. `execFileSync` failures embed the full command line + * (including the authenticated URL) in `err.message`, and git itself + * echoes the URL it was given in fatal output. + */ +function redactToken(msg: string, token: string | undefined = githubToken()): string { + return token ? msg.split(token).join('***') : msg +} + /** * Check whether `git` is available on the system PATH. */ @@ -98,7 +149,7 @@ function probeRemoteTag( const output = execFileSync('git', [ 'ls-remote', '--tags', - remoteUrl, + authenticatedCloneUrl(remoteUrl), ], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }) // Parse refs/tags/ lines, filter by version @@ -116,7 +167,7 @@ function probeRemoteTag( return { tag: exact ?? matching[0], allMatching: matching } } catch (err) { - consola.debug(`probeRemoteTag: ls-remote failed for ${remoteUrl}: ${err instanceof Error ? err.message : err}`) + consola.debug(`probeRemoteTag: ls-remote failed for ${remoteUrl}: ${redactToken(err instanceof Error ? err.message : String(err))}`) return null } } @@ -143,16 +194,23 @@ function shallowCloneRef( tmpDir: string, ): string { clearDirContents(tmpDir) - execFileSync('git', [ - 'clone', - '--depth', - '1', - '--branch', - candidate, - '--single-branch', - remoteUrl, - tmpDir, - ], { stdio: ['ignore', 'ignore', 'pipe'] }) + // Auth is injected at the exec boundary only — `remoteUrl` stays + // token-free so every error message built from it is safe to print. + try { + execFileSync('git', [ + 'clone', + '--depth', + '1', + '--branch', + candidate, + '--single-branch', + authenticatedCloneUrl(remoteUrl), + tmpDir, + ], { stdio: ['ignore', 'ignore', 'pipe'] }) + } + catch (err) { + throw new Error(redactToken(err instanceof Error ? err.message : String(err))) + } // Capture the commit SHA before we strip `.git/`. const commit = execFileSync( @@ -400,11 +458,22 @@ export class GithubSource implements DocSource { // the default-branch case (e.g. `master`), those are always // branches even though the original ref was the default `main`. const isTagCandidate = opts.tag !== undefined && !tailCandidates?.includes(candidate) - const archiveUrl = `https://github.com/${repo}/archive/refs/${isTagCandidate ? 'tags' : 'heads'}/${candidate}.tar.gz` + // With a token, go through the API tarball endpoint: undici + // strips the Authorization header on the cross-origin redirect + // to codeload, but GitHub embeds a temporary token in the + // redirect Location itself, so private repos still work. The + // hosts are hard-coded here, so no URL-host validation (as in + // `authenticatedCloneUrl`) is needed. + const token = githubToken() + const archiveUrl = token + ? `https://api.github.com/repos/${repo}/tarball/${candidate}` + : `https://github.com/${repo}/archive/refs/${isTagCandidate ? 'tags' : 'heads'}/${candidate}.tar.gz` let response: Response try { - response = await fetch(archiveUrl) + response = await fetch(archiveUrl, token + ? { headers: { authorization: `Bearer ${token}` } } + : undefined) } catch (err) { lastErr = err diff --git a/packages/cli/test/sources/github-auth.test.ts b/packages/cli/test/sources/github-auth.test.ts new file mode 100644 index 0000000..dec4587 --- /dev/null +++ b/packages/cli/test/sources/github-auth.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test' +import { authenticatedCloneUrl } from '../../src/sources/github.js' + +// Ported from opensrc's authenticated-clone host-validation tests +// (vercel-labs/opensrc#52 + #66). + +const TOKEN = 'ghp_test_token' + +describe('authenticatedCloneUrl', () => { + it('injects credentials on an exact github.com host match', () => { + expect(authenticatedCloneUrl('https://github.com/owner/repo.git', TOKEN)) + .toBe(`https://x-access-token:${TOKEN}@github.com/owner/repo.git`) + }) + + it('matches the host case-insensitively', () => { + expect(authenticatedCloneUrl('https://GitHub.com/owner/repo.git', TOKEN)) + .toContain(`x-access-token:${TOKEN}@`) + }) + + it('returns the URL unchanged when no token is set', () => { + expect(authenticatedCloneUrl('https://github.com/owner/repo.git', undefined)) + .toBe('https://github.com/owner/repo.git') + expect(authenticatedCloneUrl('https://github.com/owner/repo.git', '')) + .toBe('https://github.com/owner/repo.git') + }) + + it('rejects host-prefix confusion (github.com.evil.com)', () => { + expect(authenticatedCloneUrl('https://github.com.evil.com/owner/repo.git', TOKEN)) + .toBe('https://github.com.evil.com/owner/repo.git') + }) + + it('rejects host-suffix confusion (evilgithub.com)', () => { + expect(authenticatedCloneUrl('https://evilgithub.com/owner/repo.git', TOKEN)) + .toBe('https://evilgithub.com/owner/repo.git') + }) + + it('rejects subdomains (gist.github.com)', () => { + expect(authenticatedCloneUrl('https://gist.github.com/owner/repo.git', TOKEN)) + .toBe('https://gist.github.com/owner/repo.git') + }) + + it('rejects non-https schemes', () => { + expect(authenticatedCloneUrl('http://github.com/owner/repo.git', TOKEN)) + .toBe('http://github.com/owner/repo.git') + expect(authenticatedCloneUrl('git://github.com/owner/repo.git', TOKEN)) + .toBe('git://github.com/owner/repo.git') + }) + + it('leaves ssh remotes untouched', () => { + expect(authenticatedCloneUrl('git@github.com:owner/repo.git', TOKEN)) + .toBe('git@github.com:owner/repo.git') + }) + + it('leaves unparseable URLs untouched', () => { + expect(authenticatedCloneUrl('not a url', TOKEN)).toBe('not a url') + }) +}) From b743defdcf744d780950b69b29073d3ae91b8874 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 21:53:03 +0900 Subject: [PATCH 05/10] test(cli): disable gpg signing in git test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Machines with global tag.gpgsign=true failed createLocalRemote setup — git tag becomes a signed annotated tag that requires a message. Pass -c tag.gpgsign=false / -c commit.gpgsign=false so the fixtures are hermetic regardless of the developer's global git config. --- .../cli/test/sources/github-monorepo.test.ts | 8 +++---- packages/cli/test/sources/github.test.ts | 24 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/cli/test/sources/github-monorepo.test.ts b/packages/cli/test/sources/github-monorepo.test.ts index d995f49..29de3a6 100644 --- a/packages/cli/test/sources/github-monorepo.test.ts +++ b/packages/cli/test/sources/github-monorepo.test.ts @@ -54,9 +54,9 @@ function createMonorepoRemote(tagName: string): string { fs.writeFileSync(path.join(workDir, 'docs', 'intro.md'), '# AI Docs\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], { stdio: 'ignore' }) // Only monorepo-style tag — no `v1.0.0` - execFileSync('git', ['-C', workDir, 'tag', tagName], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'tag.gpgsign=false', 'tag', tagName], { stdio: 'ignore' }) execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' }) return repoDir @@ -80,8 +80,8 @@ function createStandardRemote(version: string): string { fs.writeFileSync(path.join(workDir, 'docs', 'api.md'), '# API Docs\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'tag', `v${version}`], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'tag.gpgsign=false', 'tag', `v${version}`], { stdio: 'ignore' }) execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' }) return repoDir diff --git a/packages/cli/test/sources/github.test.ts b/packages/cli/test/sources/github.test.ts index 87efc10..ed086fd 100644 --- a/packages/cli/test/sources/github.test.ts +++ b/packages/cli/test/sources/github.test.ts @@ -45,19 +45,19 @@ function createLocalRemote(): string { fs.writeFileSync(path.join(workDir, 'docs', 'guide.md'), '# Guide v1\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'tag', 'v1.0.0'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'tag.gpgsign=false', 'tag', 'v1.0.0'], { stdio: 'ignore' }) fs.writeFileSync(path.join(workDir, 'docs', 'guide.md'), '# Guide v2\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'update'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'tag', 'v2.0.0'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'update'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'tag.gpgsign=false', 'tag', 'v2.0.0'], { stdio: 'ignore' }) // Add a bare tag (no `v` prefix) so we can test the fallback chain fs.writeFileSync(path.join(workDir, 'docs', 'guide.md'), '# Guide v3\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'bare-tag'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'tag', '3.0.0'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'bare-tag'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'tag.gpgsign=false', 'tag', '3.0.0'], { stdio: 'ignore' }) execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' }) return repoDir @@ -363,7 +363,7 @@ describe('default-branch fallback — main → master', () => { fs.writeFileSync(path.join(workDir, 'docs', 'guide.md'), '# Master Guide\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], { stdio: 'ignore' }) execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' }) return repoDir @@ -480,7 +480,7 @@ describe('skipDocExtraction — callers that only need the checkout path', () => fs.writeFileSync(path.join(workDir, 'src.ts'), 'export {}\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], { stdio: 'ignore' }) execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' }) return repoDir @@ -575,9 +575,9 @@ describe('refCandidates via fetch behavior — fallbackRefs', () => { fs.writeFileSync(path.join(workDir, 'docs', 'intro.md'), '# AI Docs\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], { stdio: 'ignore' }) // monorepo-style tag with `@` separator - execFileSync('git', ['-C', workDir, 'tag', 'ai@6.0.158'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'tag.gpgsign=false', 'tag', 'ai@6.0.158'], { stdio: 'ignore' }) execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' }) return repoDir @@ -696,9 +696,9 @@ describe('git ls-remote fallback probe (T005)', () => { fs.writeFileSync(path.join(workDir, 'docs', 'api.md'), '# API Docs\n') execFileSync('git', ['-C', workDir, 'add', '-A'], { stdio: 'ignore' }) - execFileSync('git', ['-C', workDir, 'commit', '-m', 'initial'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], { stdio: 'ignore' }) // Only monorepo-style tag: no `v1.0.0` exists - execFileSync('git', ['-C', workDir, 'tag', 'ai@1.0.0'], { stdio: 'ignore' }) + execFileSync('git', ['-C', workDir, '-c', 'tag.gpgsign=false', 'tag', 'ai@1.0.0'], { stdio: 'ignore' }) execFileSync('git', ['clone', '--bare', workDir, repoDir], { stdio: 'ignore' }) return repoDir From bab97dd7ed69a811416150ac83326c6af0027a61 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 21:53:03 +0900 Subject: [PATCH 06/10] docs: document ask fetch and GITHUB_TOKEN support - README / SKILL.md: add ask fetch to the command tables, note the cache-warming use case and private-repo token support - CLAUDE.md: update the top-level command list, point cache-warming guidance at ask fetch, and add gotchas for the format-aware lockfile parsers and the exact-host token injection rule --- CLAUDE.md | 7 ++++--- README.md | 17 ++++++++++++----- skills/ask/SKILL.md | 9 ++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8b1e457..6d15814 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,10 +61,10 @@ node packages/cli/dist/index.js docs add -s [options] - `getSource(type)` (`packages/cli/src/sources/index.ts:54`) returns a `DocSource`; pass config to `DocSource.fetch(options)`, not to `getSource`. It is a single-arg factory. - `parseSpec` lives in `packages/cli/src/spec.ts` and returns a discriminated union (`npm` / `github` / `unknown`). It is the single source of truth for translating an `ask.json` spec string into a library slug — do not reintroduce the legacy private `parseSpec(spec) -> { name, version }` from the old `index.ts`. - In Nuxt Content v3 server routes, `queryCollection` is auto-imported — do NOT use `import { queryCollection } from '#content/server'` (that alias does not exist and breaks Cloudflare Pages build). Use the bare function or `import { queryCollection } from '@nuxt/content/server'`. -- Top-level commands: `ask install | add | remove | list | docs | search | skills | src | cache` (registered in `packages/cli/src/index.ts:299`). `ask search ` resolves the pinned checkout via `ensureCheckout` then delegates to `csp` (code-search) for semantic search; csp is optional (probe `$CSP_BIN`→PATH, else print path + recipe and exit 0). The **legacy** `ask docs` namespace that had `add | sync | list | remove` sub-commands is gone — there is no deprecated wrapper. The **current** `ask docs ` is a NEW single-shot command (`packages/cli/src/commands/docs.ts`) that prints candidate doc paths: for npm specs it walks `node_modules//`, then always walks the cached checkout at `/github/////` and emits the root plus any `/doc/i` subdirs. It shares `ensureCheckout` with `ask src` and does NOT go through `runInstall`, so strict ref validation does not apply — unqualified github specs default `ref = 'main'` and clone the branch directly. `ask install` is `postinstall`-friendly: per-entry failures emit a warning and exit code is always 0 (FR-10). +- Top-level commands: `ask install | add | remove | list | docs | fetch | search | skills | src | cache` (registered in `packages/cli/src/index.ts`). `ask fetch ` (ported from opensrc's `opensrc fetch`) warms the checkout cache via `ensureCheckout` without printing paths — supports multiple specs, `-q/--quiet`, per-spec error reporting with non-zero exit at the end. `ask search ` resolves the pinned checkout via `ensureCheckout` then delegates to `csp` (code-search) for semantic search; csp is optional (probe `$CSP_BIN`→PATH, else print path + recipe and exit 0). The **legacy** `ask docs` namespace that had `add | sync | list | remove` sub-commands is gone — there is no deprecated wrapper. The **current** `ask docs ` is a NEW single-shot command (`packages/cli/src/commands/docs.ts`) that prints candidate doc paths: for npm specs it walks `node_modules//`, then always walks the cached checkout at `/github/////` and emits the root plus any `/doc/i` subdirs. It shares `ensureCheckout` with `ask src` and does NOT go through `runInstall`, so strict ref validation does not apply — unqualified github specs default `ref = 'main'` and clone the branch directly. `ask install` is `postinstall`-friendly: per-entry failures emit a warning and exit code is always 0 (FR-10). - `ensureCheckout` (shared by `ask src` and `ask docs`) always sets `skipDocExtraction: true` on its `GithubSourceOptions`. The source's `extractDocsFromDir` scan is a correctness precondition for `runInstall` (materializing `.ask/docs/@/` requires a real file list), but for `ask src`/`ask docs` it is actively wrong — those commands walk the checkout themselves (`findDocLikePaths`) and must not crash on repos without a conventional `docs/` folder (regression: `gitbutlerapp/gitbutler`). Keep the flag opt-in; never set it inside `runInstall`. - `cpDirAtomic` (`packages/cli/src/store/index.ts`) passes `verbatimSymlinks: true` to `fs.cpSync`. Without it, Node resolves relative symlinks against the SOURCE directory at copy time, baking an absolute path pointing at the soon-deleted `ask-gh-clone-*` tmp dir into the store entry — every later `verifyEntry` call then hits ENOENT reading the now-broken link (observed on gitbutler's `claude.md`/`CLAUDE.md` → `AGENTS.md` links). `hashDir` walks every file via `collectFiles` + `readFileSync`, so it has no tolerance for a broken symlink. Keep the flag. -- PM-driven entries (`{ "spec": "npm:" }`) get their version from the project's lockfile in priority order: `bun.lock → package-lock.json → pnpm-lock.yaml → yarn.lock → package.json` (range fallback). The chain lives in `packages/cli/src/lockfiles/index.ts:npmEcosystemReader`. Standalone github entries (`{ "spec": "github:owner/repo", "ref": "v1.2.3" }`) require an explicit `ref` enforced at the schema layer — there is no implicit default branch. +- PM-driven entries (`{ "spec": "npm:" }`) get their version from the project's lockfile in priority order: `bun.lock → package-lock.json → pnpm-lock.yaml → yarn.lock → package.json` (range fallback). The chain lives in `packages/cli/src/lockfiles/index.ts:npmEcosystemReader`. The pnpm/yarn readers are format-aware parsers ported from opensrc (indent-aware stack parser with BFS transitive resolution for pnpm v5–v9; block-based parser for yarn v1 + Berry) — do not reintroduce the old regex parsers, they false-match pnpm peer-dep suffixes and yarn multi-specifier headers. All readers (incl. the `package.json` fallback) skip protocol versions (`workspace:*`, `link:`, `file:`, ...) via `isRegistryVersion`. Standalone github entries (`{ "spec": "github:owner/repo", "ref": "v1.2.3" }`) require an explicit `ref` enforced at the schema layer — there is no implicit default branch. - A PreToolUse Bash hook blocks shell commands when the current commit has no recorded review. Run `/review:code-review` (or `/review:run-cubic` / `/review:run-gemini`) and let it call `save-review-state.sh` before further bash. - `apps/registry/` uses `vercel.ts` (programmatic config via `@vercel/config` devDep), not `vercel.json`. Use `git.deploymentEnabled` (not deprecated `github.enabled`) to control auto-deploy. - Track artifacts live under `.please/docs/tracks/active/{slug}-{YYYYMMDD}/` with `spec.md`, `plan.md`, `metadata.json`. Append a JSON line to `.please/docs/tracks.jsonl` when creating a track. @@ -90,13 +90,14 @@ node packages/cli/dist/index.js docs add -s [options] - `github`-kind store entries live at `/github/////` (PM-style nested layout, host hard-coded to `github.com` for MVP). Do NOT reintroduce `github/db/` or `github/checkouts/` — those are LEGACY and collapse five correctness defects structurally when removed. The shared bare-clone subsystem (`packages/cli/src/store/github-bare.ts`) is gone; each entry is an independent shallow clone via `git clone --depth 1 --branch --single-branch` with `.git/` stripped and commit SHA captured into `ResolvedEntry.commit` before the strip. Fallback chain in `cloneAtTag` (`packages/cli/src/sources/github.ts`): try ref as-is; if it does NOT start with `v`, also try `v`. Never emit `vv1.2.3`. See track `github-store-pm-unified-20260411`. - `ask.json` ref validation is **strict by default**: `main`/`master`/`develop`/`trunk`/`HEAD`/`latest` and any single-word ref without `.` or digit are rejected at the CLI boundary (in `runInstall` via `validateAskJsonStrict`). Schema package exports both `AskJsonSchema` (strict) and `LaxAskJsonSchema` (no refinement). `readAskJson`/`writeAskJson` ALWAYS use lax so internal readers (`listDocs`, `generateAgentsMd`, `manageIgnoreFiles`, etc.) never need to know about `--allow-mutable-ref`. CI/test paths pass `--allow-mutable-ref` to skip the strict validation call; the schema parser still reads the lax variant underneath. - `FetchResult.storeSubpath` (`packages/cli/src/sources/index.ts`) is populated by the github source from the entry's `docsPath`. Link/ref mode in `storage.ts:saveDocs` joins `path.join(storePath, storeSubpath ?? '')` so symlinks and AGENTS.md-rendered paths point at the docs subdirectory, not the repo root. npm/web/llms-txt leave `storeSubpath` undefined and the behaviour is unchanged. +- Private GitHub repos: `GITHUB_TOKEN` is injected by `authenticatedCloneUrl` (`packages/cli/src/sources/github.ts`) ONLY on exact `https://github.com` host matches (opensrc#66 host-confusion fix — `github.com.evil.com` etc. never get the token). Auth happens at the exec boundary; error messages keep the token-free URL and `redactToken` scrubs git/execFileSync output. Never pass the authenticated URL into a log or thrown Error. - Store-hit short-circuits in both `packages/cli/src/install.ts:274` (npm) and `packages/cli/src/sources/github.ts` (github) run `verifyEntry(storeDir)` before trusting a cached entry. Failures move the entry to `/.quarantine/-/` via `quarantineEntry` (in `store/index.ts`) and fall through to a fresh fetch. Never reintroduce a bare `fs.existsSync(storeDir)` check without the guard. - `bun pm pack` resolves `workspace:*` from `bun.lock`, not `package.json` (oven-sh/bun#20477). release-please bumps `package.json` versions but `bun install` won't propagate them to the lockfile (oven-sh/bun#18906). The release workflow regenerates the lockfile (`rm bun.lock && bun install`) before packing to work around this. If you ever change the publish pipeline, ensure the lockfile is fresh. - `/STORE_VERSION` is written on every `ask install` start (`writeStoreVersion` in `store/index.ts`). Current value is `"2"`. A legacy `github/db` or `github/checkouts` directory triggers a one-line warning and points at `ask cache clean --legacy`. `cacheLs` walks BOTH layouts and tags legacy entries with `legacy: true` + a `(legacy) ` key prefix. - `consola.prompt()` supports `multiselect`, `text`, `confirm`, `select` types (v3.4.2+). The multiselect return type needs `as unknown as string[]` cast because TS infers the options object type, not the value type. - `ask.json` `libraries` is a union `string | { spec, docsPaths }` (schema in `packages/schema/src/ask-json.ts`). The object form is emitted ONLY when the user selects a docs-path subset; the canonical "no override" form is the bare string (enforced in `entryFromSpec` and by `docsPaths.nonempty()`). Every consumer that iterates `askJson.libraries` MUST wrap entries with `specFromEntry` — the exported helpers `specFromEntry` / `docsPathsFromEntry` / `entryFromSpec` live in the same schema file and are re-exported from `packages/cli/src/schemas.ts`. `io.ts:findEntry` is the standard lookup helper (matches by exact spec → library slug → npm package name, same rules as `ask remove`). -- `ask add`'s docs-path prompt (`packages/cli/src/commands/add.ts:runAdd`) is **offline-first**: `gatherDocsCandidates` calls `ensureCheckout({ noFetch: true })` and silently skips the cached-checkout group on `NoCacheError`. It never triggers a fresh clone. First-time `ask add ` on an uncached spec gets no prompt — users must run `ask docs ` first to warm the cache, then re-run `ask add`. Flags: `--docs-paths ` (non-interactive override) and `--clear-docs-paths` (downgrade object entry → bare string). `CandidateGatheringError` is caught by `runAdd` → warn + persist the spec without an override. +- `ask add`'s docs-path prompt (`packages/cli/src/commands/add.ts:runAdd`) is **offline-first**: `gatherDocsCandidates` calls `ensureCheckout({ noFetch: true })` and silently skips the cached-checkout group on `NoCacheError`. It never triggers a fresh clone. First-time `ask add ` on an uncached spec gets no prompt — users must run `ask fetch ` (or `ask docs `) first to warm the cache, then re-run `ask add`. Flags: `--docs-paths ` (non-interactive override) and `--clear-docs-paths` (downgrade object entry → bare string). `CandidateGatheringError` is caught by `runAdd` → warn + persist the spec without an override. - `ask docs` (`packages/cli/src/commands/docs.ts:runDocs`) consults `readAskJson` + `findEntry` for a stored `docsPaths` override. Stored paths are resolved against both the npm root (`node_modules/`) and `result.checkoutDir`, existing-file wins, zero-survivor case prints a stderr warning and falls through to the unfiltered walk. The prompt and the consumer both store / read paths **relative to the group root** so the entry stays portable across machines and cache wipes. - Monorepo packages (changesets convention) use `@` git tags, not `v`. The npm resolver detects monorepos via `repository.directory` in npm registry metadata and generates `@` as `fallbackRefs`. `GithubSource` tries these before the standard `v` chain. As a last resort, `cloneAtTag()` probes `git ls-remote --tags` to discover matching tags dynamically. On total failure, the error message lists discovered tags so users/LLM agents can retry with `--ref `. - When disambiguating bare names (no `:` prefix): check `input.startsWith('@')` before `OWNER_REPO_RE` — scoped npm packages like `@vercel/ai` match the `owner/repo` regex pattern and get misrouted to `github:`. diff --git a/README.md b/README.md index 5db7e68..481feda 100644 --- a/README.md +++ b/README.md @@ -178,13 +178,20 @@ documentation**. They print absolute paths to cached source trees, fetching on cache miss: ```bash -ask src # Print the absolute path to a cached library source tree -ask docs # Print all candidate documentation paths from node_modules + the cached source +ask src # Print the absolute path to a cached library source tree +ask docs # Print all candidate documentation paths from node_modules + the cached source +ask fetch # Warm the cache for one or more specs without printing paths (-q/--quiet) ``` -Both commands fetch on cache miss (first run) and short-circuit on cache -hit. They share the same `~/.ask/github/github.com////` -store. +All three commands fetch on cache miss (first run) and short-circuit on +cache hit. They share the same +`~/.ask/github/github.com////` store. `ask fetch` is +the prefetch counterpart for setup scripts — it also warms the cache +that `ask add`'s offline-first docs-path prompt reads from. + +Private GitHub repositories are supported by setting `GITHUB_TOKEN` in +the environment; the token is attached only to exact `https://github.com` +remotes. ```bash # Print the absolute path to React's source tree (fetches on first run) diff --git a/skills/ask/SKILL.md b/skills/ask/SKILL.md index 818d189..b4b2792 100644 --- a/skills/ask/SKILL.md +++ b/skills/ask/SKILL.md @@ -75,10 +75,13 @@ github:owner/repo@main # pinned branch | `ask src [--no-fetch]` | Checkout root, single line | You need to read real source, search all files, follow implementations | | `ask search [--content …] [--top-k n]` | Ranked snippets (via csp), or path + recipe if csp absent | "How does X work internally" — semantic search beats reading whole files; csp optional | | `ask skills ` (= `ask skills list`) | `/skills/` dirs, one per line | The library ships its own Claude / Cursor / OpenCode skills | +| `ask fetch [-q]` | Fetch status per spec, no paths | You only want to warm the cache (prefetch, setup scripts, before `ask add`'s docs-path prompt) | -`ask docs`, `ask src`, and `ask search` share `ensureCheckout`, so the -cached path is reused across commands — resolving a spec with any of them -(then `ask skills list`) fetches once. +`ask docs`, `ask src`, `ask search`, and `ask fetch` share +`ensureCheckout`, so the cached path is reused across commands — +resolving a spec with any of them (then `ask skills list`) fetches once. +Private GitHub repos work when `GITHUB_TOKEN` is set (token attached +only to exact `https://github.com` remotes). ## When You Need More From 54844a1d68bb6a9a0dfa432cc75df3eee9afc785 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 22:40:00 +0900 Subject: [PATCH 07/10] chore: apply AI code review suggestions - pnpm parser: support v5 /name/version keys in the packages:/snapshots: pass with a last-slash split (scoped names intact, _peerhash stripped) and canonical name@version node keys so v5 fallback and transitive resolution connect (cubic review) - ensure-checkout: propagate GithubSource's store-hit via a new FetchResult.fromStoreCache flag so fromCache is not misreported as a fresh fetch when the source found a ref-candidate cache entry with zero network I/O (cubic review) --- packages/cli/src/commands/ensure-checkout.ts | 5 +- packages/cli/src/lockfiles/pnpm.ts | 40 ++++++++++++-- packages/cli/src/sources/github.ts | 1 + packages/cli/src/sources/index.ts | 7 +++ .../cli/test/commands/ensure-checkout.test.ts | 55 +++++++++++++++++++ packages/cli/test/lockfiles/parsers.test.ts | 51 +++++++++++++++++ 6 files changed, 153 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/ensure-checkout.ts b/packages/cli/src/commands/ensure-checkout.ts index 6eccb3e..4f10305 100644 --- a/packages/cli/src/commands/ensure-checkout.ts +++ b/packages/cli/src/commands/ensure-checkout.ts @@ -259,5 +259,8 @@ export async function ensureCheckout( // the path downstream tools actually index. const actualRef = resolvedCheckoutDir === checkoutDir ? ref : path.basename(resolvedCheckoutDir) - return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName, fromCache: false } + // `GithubSource.fetch` can satisfy the request from its own store-hit + // path (a ref-candidate variant like `v` or `master`) with zero + // network I/O — trust its `fromStoreCache` over our primary-key miss. + return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName, fromCache: fetchResult?.fromStoreCache ?? false } } diff --git a/packages/cli/src/lockfiles/pnpm.ts b/packages/cli/src/lockfiles/pnpm.ts index 4cb3f35..4a0c5a6 100644 --- a/packages/cli/src/lockfiles/pnpm.ts +++ b/packages/cli/src/lockfiles/pnpm.ts @@ -73,6 +73,36 @@ type Frame const DEP_GROUP_KEYS = new Set(['dependencies', 'devDependencies', 'optionalDependencies']) +/** + * Split a `packages:`/`snapshots:` entry key (leading `/` already + * stripped) into name + version + the node key used in the dep graph. + * + * v6–v9 keys use `@[]` — the node key is + * the raw key so it matches dep values that include peer suffixes. + * + * v5 keys use `//[_peerhash]` (slash separator, no `@` + * between name and version): split at the LAST slash so scoped names + * (`@scope/pkg/1.2.3`) stay intact, and strip the `_peerhash` suffix. + * The node key is canonicalized to `@` because v5 dep + * edges and top-level roots reference deps as `: `. + */ +function splitPackagesKey(key: string): { name: string, versionWithPeer: string, nodeKey: string } | null { + const split = splitPkgSpec(key) + if (split) { + const [name, versionWithPeer] = split + return { name, versionWithPeer, nodeKey: key } + } + const i = key.lastIndexOf('/') + if (i <= 0 || i === key.length - 1) + return null + const name = key.slice(0, i) + const underscore = key.indexOf('_', i) + const version = underscore >= 0 ? key.slice(i + 1, underscore) : key.slice(i + 1) + if (version.length === 0) + return null + return { name, versionWithPeer: version, nodeKey: `${name}@${version}` } +} + /** * Parse a `pnpm-lock.yaml` text and return the installed version of * `pkg`, if found. @@ -185,19 +215,19 @@ export function parsePnpmLock(text: string, pkg: string): string | null { const key = rawKey.startsWith('/') ? rawKey.slice(1) : rawKey const valuePart = content.slice(sep + 1) - const split = splitPkgSpec(key) + const split = splitPackagesKey(key) if (split) { - const [name, versionWithPeer] = split + const { name, versionWithPeer, nodeKey } = split const version = stripPeerSuffix(versionWithPeer) - if (!graph.nodes.has(key)) - graph.nodes.set(key, { name, version, deps: [] }) + if (!graph.nodes.has(nodeKey)) + graph.nodes.set(nodeKey, { name, version, deps: [] }) if (name === pkg && packagesFallback === null && isRegistryVersion(version)) packagesFallback = version if (valuePart.trim().length === 0) - stack.push({ kind: 'pkgEntry', base: indent, key }) + stack.push({ kind: 'pkgEntry', base: indent, key: nodeKey }) // Else: inline value like `{}` — no children to parse. } } diff --git a/packages/cli/src/sources/github.ts b/packages/cli/src/sources/github.ts index ba75260..7a320b3 100644 --- a/packages/cli/src/sources/github.ts +++ b/packages/cli/src/sources/github.ts @@ -352,6 +352,7 @@ export class GithubSource implements DocSource { resolvedVersion, storePath: storeDir, storeSubpath: docsPath, + fromStoreCache: true, meta: { ref }, } } diff --git a/packages/cli/src/sources/index.ts b/packages/cli/src/sources/index.ts index 444e1cf..25a509f 100644 --- a/packages/cli/src/sources/index.ts +++ b/packages/cli/src/sources/index.ts @@ -89,6 +89,13 @@ export interface FetchResult { * points at the docs directory, not the repo root. */ storeSubpath?: string + /** + * True when the result was served from an existing verified store + * entry without any network fetch (e.g. GithubSource's store-hit + * short-circuit on a ref candidate). Lets callers report cache hits + * accurately even when their own cache-key check missed. + */ + fromStoreCache?: boolean /** Source-specific metadata propagated to ask.lock */ meta?: { /** GitHub commit sha (40 hex chars) */ diff --git a/packages/cli/test/commands/ensure-checkout.test.ts b/packages/cli/test/commands/ensure-checkout.test.ts index eddb698..84b7e82 100644 --- a/packages/cli/test/commands/ensure-checkout.test.ts +++ b/packages/cli/test/commands/ensure-checkout.test.ts @@ -164,6 +164,61 @@ describe('ensureCheckout', () => { expect(calls[0].opts.tag).toBe('v18.2.0') }) + it('reports fromCache=true on primary-key cache hit and false on fresh fetch', async () => { + const checkoutDir = path.join(askHome, 'github', 'github.com', 'facebook', 'react', 'v18.2.0') + const deps = { + askHome, + resolverFor: () => makeResolver({ + react: { repo: 'facebook/react', ref: 'v18.2.0', resolvedVersion: '18.2.0' }, + }), + lockfileReader: { read: () => null }, + } + + const { fetcher } = makeFetcher(() => { + fs.mkdirSync(checkoutDir, { recursive: true }) + }) + const fresh = await ensureCheckout({ spec: 'react@18.2.0', projectDir }, { ...deps, fetcher }) + expect(fresh.fromCache).toBe(false) + + const { fetcher: unusedFetcher } = makeFetcher(() => { + throw new Error('fetcher must not be called on cache hit') + }) + const hit = await ensureCheckout({ spec: 'react@18.2.0', projectDir }, { ...deps, fetcher: unusedFetcher }) + expect(hit.fromCache).toBe(true) + }) + + it('reports fromCache=true when the fetcher hit its own store on a ref-candidate variant', async () => { + // Primary key `.../react/v18.2.0` is absent, but GithubSource finds a + // verified entry under a candidate ref and returns fromStoreCache + // without any network I/O — ensureCheckout must not report "fetched". + const variantDir = path.join(askHome, 'github', 'github.com', 'facebook', 'react', '18.2.0') + fs.mkdirSync(variantDir, { recursive: true }) + + const fetcher = { + fetch: mock(async (opts: SourceConfig) => ({ + files: [], + resolvedVersion: (opts as GithubSourceOptions).version, + storePath: variantDir, + fromStoreCache: true, + })), + } + + const result = await ensureCheckout( + { spec: 'react@18.2.0', projectDir }, + { + askHome, + fetcher, + resolverFor: () => makeResolver({ + react: { repo: 'facebook/react', ref: 'v18.2.0', resolvedVersion: '18.2.0' }, + }), + lockfileReader: { read: () => null }, + }, + ) + + expect(result.fromCache).toBe(true) + expect(result.checkoutDir).toBe(variantDir) + }) + it('uses explicit @version over lockfile version', async () => { const expectedDir = path.join(askHome, 'github', 'github.com', 'facebook', 'react', 'v18.2.0') fs.mkdirSync(expectedDir, { recursive: true }) diff --git a/packages/cli/test/lockfiles/parsers.test.ts b/packages/cli/test/lockfiles/parsers.test.ts index 6e5d8d0..382564b 100644 --- a/packages/cli/test/lockfiles/parsers.test.ts +++ b/packages/cli/test/lockfiles/parsers.test.ts @@ -401,6 +401,57 @@ snapshots: expect(parsePnpmLock(text, 'unused')).toBe('9.9.9') }) + it('v5 slash-form packages key works as fallback', () => { + const text = `lockfileVersion: '5.4' + +packages: + /zod/3.22.0: + resolution: {} +` + expect(parsePnpmLock(text, 'zod')).toBe('3.22.0') + }) + + it('v5 slash-form scoped packages key works as fallback', () => { + const text = `lockfileVersion: '5.4' + +packages: + /@scope/pkg/1.2.3: + resolution: {} +` + expect(parsePnpmLock(text, '@scope/pkg')).toBe('1.2.3') + }) + + it('v5 slash-form key strips the peer-hash suffix', () => { + const text = `lockfileVersion: '5.4' + +packages: + /foo/1.0.0_abcd1234: + resolution: {} +` + expect(parsePnpmLock(text, 'foo')).toBe('1.0.0') + }) + + it('v5 slash-form keys participate in transitive resolution', () => { + // target is only reachable through a's packages-level dep edge; the + // v5 `/name/version` node keys must canonicalize to `name@version` + // so root and dep references (`a: 1.0.0`, `target: 2.0.0`) connect. + const text = `lockfileVersion: '5.4' + +dependencies: + a: 1.0.0 + +packages: + + /a/1.0.0: + dependencies: + target: 2.0.0 + + /target/2.0.0: + resolution: {} +` + expect(parsePnpmLock(text, 'target')).toBe('2.0.0') + }) + it('handles dependency cycles', () => { const text = `lockfileVersion: '9.0' From 61cbbda2d94df3432ee2aa5efd6c545ac505f3de Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 23:03:05 +0900 Subject: [PATCH 08/10] refactor(cli): split parsePnpmLock state machine into per-frame handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the SonarCloud cognitive-complexity finding (85 > 15) raised via CodeRabbit review. The line loop now delegates to one small handler per frame kind (root/importers/importer/depGroup/depBlock/packages/ pkgEntry/pkgDeps) over a shared ParseState, with the root scope represented by an empty stack instead of a sentinel frame. Also clears the Codacy warnings on the same function: no while(true), no non-null assertions (currentFrame returns Frame | undefined; resolveTransitive uses index-pointer BFS instead of queue.shift()!). Behavior unchanged — covered by the 84 lockfile parser tests. --- packages/cli/src/lockfiles/pnpm.ts | 336 +++++++++++++++++------------ 1 file changed, 198 insertions(+), 138 deletions(-) diff --git a/packages/cli/src/lockfiles/pnpm.ts b/packages/cli/src/lockfiles/pnpm.ts index 4a0c5a6..ffdbdbc 100644 --- a/packages/cli/src/lockfiles/pnpm.ts +++ b/packages/cli/src/lockfiles/pnpm.ts @@ -52,11 +52,11 @@ type Origin = 'root' | 'importer' /** * A frame on the indent-aware parse stack. `base` is the indent of the * line that opened the frame; children must be at indent strictly greater - * than that value. The `root` frame has no header line and is never popped. + * than that value. The root scope has no frame — an empty stack means + * the current line is at document level. */ type Frame - = | { kind: 'root' } - | { kind: 'importers', base: number } + = | { kind: 'importers', base: number } | { kind: 'importer', base: number } | { kind: 'depGroup', base: number, origin: Origin } /** Block-form dep entry awaiting a nested `version:` line. */ @@ -71,6 +71,16 @@ type Frame */ | { kind: 'pkgDeps', base: number, owner: string } +/** Mutable state threaded through the per-frame line handlers. */ +interface ParseState { + pkg: string + stack: Frame[] + graph: PnpmGraph + importerMatch: string | null + topMatch: string | null + packagesFallback: string | null +} + const DEP_GROUP_KEYS = new Set(['dependencies', 'devDependencies', 'optionalDependencies']) /** @@ -103,6 +113,174 @@ function splitPackagesKey(key: string): { name: string, versionWithPeer: string, return { name, versionWithPeer: version, nodeKey: `${name}@${version}` } } +/** + * Pop frames whose scope ended at this indent and return the frame the + * line belongs to — `undefined` means document (root) level. + */ +function currentFrame(stack: Frame[], indent: number): Frame | undefined { + let top = stack.at(-1) + while (top && indent <= top.base) { + stack.pop() + top = stack.at(-1) + } + return top +} + +/** + * Record a direct dependency entry: seed the graph roots and capture the + * version when the entry names the package we're looking for. + */ +function captureDirect(state: ParseState, depName: string, rawValue: string, origin: Origin): void { + const cleaned = cleanValue(rawValue) + const stripped = stripPeerSuffix(cleaned) + // Add to graph roots using the raw (peer-including) value so the key + // matches `snapshots:` entries. + state.graph.roots.push(`${depName}@${cleaned}`) + + // Filter at capture so workspace/link/file versions in one importer + // don't block a real version in a later importer. + if (depName !== state.pkg || !isRegistryVersion(stripped)) + return + if (origin === 'importer' && state.importerMatch === null) + state.importerMatch = stripped + else if (origin === 'root' && state.topMatch === null) + state.topMatch = stripped +} + +/** Document-level line: open the top-level sections we care about. */ +function handleRootLine(state: ParseState, indent: number, content: string): void { + if (indent !== 0 || !content.endsWith(':')) + return + const key = content.slice(0, -1).trim() + if (key === 'importers') + state.stack.push({ kind: 'importers', base: indent }) + else if (DEP_GROUP_KEYS.has(key)) + state.stack.push({ kind: 'depGroup', base: indent, origin: 'root' }) + else if (key === 'packages') + state.stack.push({ kind: 'packages', base: indent }) + else if (key === 'snapshots') + state.stack.push({ kind: 'snapshots', base: indent }) +} + +/** Inside `importers:` — every child key is an importer (workspace dir). */ +function handleImportersLine(state: ParseState, indent: number, content: string): void { + if (content.endsWith(':')) + state.stack.push({ kind: 'importer', base: indent }) +} + +/** Inside one importer — open its dependency groups. */ +function handleImporterLine(state: ParseState, indent: number, content: string): void { + if (content.endsWith(':') && DEP_GROUP_KEYS.has(content.slice(0, -1).trim())) + state.stack.push({ kind: 'depGroup', base: indent, origin: 'importer' }) +} + +/** + * Inside a dependency group — either an inline `name: version` entry + * (v5) or a block entry whose `version:` arrives on a nested line (v6+). + */ +function handleDepGroupLine(state: ParseState, frame: Extract, indent: number, content: string): void { + const sep = content.indexOf(':') + if (sep < 0) + return + const depName = trimQuotes(content.slice(0, sep).trim()) + const rawValue = content.slice(sep + 1).trim() + if (rawValue.length === 0) + state.stack.push({ kind: 'depBlock', base: indent, origin: frame.origin, pkgName: depName }) + else + captureDirect(state, depName, rawValue, frame.origin) +} + +/** Block-form dep entry — capture its nested `version:` line. */ +function handleDepBlockLine(state: ParseState, frame: Extract, content: string): void { + if (!content.startsWith('version:')) + return + captureDirect(state, frame.pkgName, content.slice('version:'.length), frame.origin) + state.stack.pop() +} + +/** + * Inside `packages:`/`snapshots:` — register a graph node per entry and + * remember the first key matching the package as a last-resort fallback. + */ +function handlePackagesLine(state: ParseState, indent: number, content: string): void { + const sep = content.indexOf(':') + if (sep < 0) + return + const rawKey = trimQuotes(content.slice(0, sep).trim()) + const key = rawKey.startsWith('/') ? rawKey.slice(1) : rawKey + const valuePart = content.slice(sep + 1) + + const split = splitPackagesKey(key) + if (!split) + return + const { name, versionWithPeer, nodeKey } = split + const version = stripPeerSuffix(versionWithPeer) + + if (!state.graph.nodes.has(nodeKey)) + state.graph.nodes.set(nodeKey, { name, version, deps: [] }) + + if (name === state.pkg && state.packagesFallback === null && isRegistryVersion(version)) + state.packagesFallback = version + + if (valuePart.trim().length === 0) + state.stack.push({ kind: 'pkgEntry', base: indent, key: nodeKey }) + // Else: inline value like `{}` — no children to parse. +} + +/** Inside one pkg entry — open its dependency blocks, ignore the rest. */ +function handlePkgEntryLine(state: ParseState, frame: Extract, indent: number, content: string): void { + if (!content.endsWith(':')) + return + const subKey = content.slice(0, -1).trim() + if (subKey === 'dependencies' || subKey === 'optionalDependencies') + state.stack.push({ kind: 'pkgDeps', base: indent, owner: frame.key }) + // Ignore resolution/engines/peerDependencies/transitivePeerDependencies/etc. +} + +/** Inside a pkg entry's dependency block — record a graph edge. */ +function handlePkgDepsLine(state: ParseState, frame: Extract, content: string): void { + const sep = content.indexOf(':') + if (sep < 0) + return + const depName = trimQuotes(content.slice(0, sep).trim()) + const depValue = cleanValue(content.slice(sep + 1)) + if (depValue.length > 0) + state.graph.nodes.get(frame.owner)?.deps.push(`${depName}@${depValue}`) +} + +/** Route one content line to the handler for the frame it belongs to. */ +function dispatchLine(state: ParseState, indent: number, content: string): void { + const top = currentFrame(state.stack, indent) + if (!top) { + handleRootLine(state, indent, content) + return + } + switch (top.kind) { + case 'importers': + handleImportersLine(state, indent, content) + break + case 'importer': + handleImporterLine(state, indent, content) + break + case 'depGroup': + handleDepGroupLine(state, top, indent, content) + break + case 'depBlock': + handleDepBlockLine(state, top, content) + break + case 'packages': + case 'snapshots': + handlePackagesLine(state, indent, content) + break + case 'pkgEntry': + handlePkgEntryLine(state, top, indent, content) + break + case 'pkgDeps': + handlePkgDepsLine(state, top, content) + break + } +} + /** * Parse a `pnpm-lock.yaml` text and return the installed version of * `pkg`, if found. @@ -115,147 +293,27 @@ function splitPackagesKey(key: string): { name: string, versionWithPeer: string, * 4. Fallback: first matching `packages:` or `snapshots:` key */ export function parsePnpmLock(text: string, pkg: string): string | null { - const stack: Frame[] = [{ kind: 'root' }] - const graph: PnpmGraph = { nodes: new Map(), roots: [] } - - let importerMatch: string | null = null - let topMatch: string | null = null - let packagesFallback: string | null = null - - const captureDirect = (depName: string, rawValue: string, origin: Origin): void => { - const cleaned = cleanValue(rawValue) - const stripped = stripPeerSuffix(cleaned) - // Add to graph roots using the raw (peer-including) value so the key - // matches `snapshots:` entries. - graph.roots.push(`${depName}@${cleaned}`) - - // Filter at capture so workspace/link/file versions in one importer - // don't block a real version in a later importer. - if (depName === pkg && isRegistryVersion(stripped)) { - if (origin === 'importer' && importerMatch === null) - importerMatch = stripped - else if (origin === 'root' && topMatch === null) - topMatch = stripped - } + const state: ParseState = { + pkg, + stack: [], + graph: { nodes: new Map(), roots: [] }, + importerMatch: null, + topMatch: null, + packagesFallback: null, } for (const raw of text.split('\n')) { const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw - if (line.trim().length === 0) - continue - if (line.trimStart().startsWith('#')) + if (line.trim().length === 0 || line.trimStart().startsWith('#')) continue const indent = line.length - line.trimStart().length - const content = line.slice(indent) - - // Pop frames whose scope has ended. Root (no base) never pops, so - // the stack is never empty and the `at(-1)!` assertions are safe. - while (true) { - const frame = stack.at(-1)! - if (frame.kind !== 'root' && indent <= frame.base) { - stack.pop() - continue - } - break - } - - const top = stack.at(-1)! - switch (top.kind) { - case 'root': { - if (indent === 0 && content.endsWith(':')) { - const key = content.slice(0, -1).trim() - if (key === 'importers') - stack.push({ kind: 'importers', base: indent }) - else if (DEP_GROUP_KEYS.has(key)) - stack.push({ kind: 'depGroup', base: indent, origin: 'root' }) - else if (key === 'packages') - stack.push({ kind: 'packages', base: indent }) - else if (key === 'snapshots') - stack.push({ kind: 'snapshots', base: indent }) - } - break - } - case 'importers': { - if (content.endsWith(':')) - stack.push({ kind: 'importer', base: indent }) - break - } - case 'importer': { - if (content.endsWith(':') && DEP_GROUP_KEYS.has(content.slice(0, -1).trim())) - stack.push({ kind: 'depGroup', base: indent, origin: 'importer' }) - break - } - case 'depGroup': { - const sep = content.indexOf(':') - if (sep >= 0) { - const depName = trimQuotes(content.slice(0, sep).trim()) - const rawValue = content.slice(sep + 1).trim() - if (rawValue.length === 0) { - // Block form: version comes on a nested line. - stack.push({ kind: 'depBlock', base: indent, origin: top.origin, pkgName: depName }) - } - else { - captureDirect(depName, rawValue, top.origin) - } - } - break - } - case 'depBlock': { - if (content.startsWith('version:')) { - captureDirect(top.pkgName, content.slice('version:'.length), top.origin) - stack.pop() - } - break - } - case 'packages': - case 'snapshots': { - const sep = content.indexOf(':') - if (sep >= 0) { - const rawKey = trimQuotes(content.slice(0, sep).trim()) - const key = rawKey.startsWith('/') ? rawKey.slice(1) : rawKey - const valuePart = content.slice(sep + 1) - - const split = splitPackagesKey(key) - if (split) { - const { name, versionWithPeer, nodeKey } = split - const version = stripPeerSuffix(versionWithPeer) - - if (!graph.nodes.has(nodeKey)) - graph.nodes.set(nodeKey, { name, version, deps: [] }) - - if (name === pkg && packagesFallback === null && isRegistryVersion(version)) - packagesFallback = version - - if (valuePart.trim().length === 0) - stack.push({ kind: 'pkgEntry', base: indent, key: nodeKey }) - // Else: inline value like `{}` — no children to parse. - } - } - break - } - case 'pkgEntry': { - if (content.endsWith(':')) { - const subKey = content.slice(0, -1).trim() - if (subKey === 'dependencies' || subKey === 'optionalDependencies') - stack.push({ kind: 'pkgDeps', base: indent, owner: top.key }) - // Ignore resolution/engines/peerDependencies/transitivePeerDependencies/etc. - } - break - } - case 'pkgDeps': { - const sep = content.indexOf(':') - if (sep >= 0) { - const depName = trimQuotes(content.slice(0, sep).trim()) - const depValue = cleanValue(content.slice(sep + 1)) - if (depValue.length > 0) - graph.nodes.get(top.owner)?.deps.push(`${depName}@${depValue}`) - } - break - } - } + dispatchLine(state, indent, line.slice(indent)) } - return importerMatch ?? topMatch ?? resolveTransitive(graph, pkg) ?? packagesFallback + return state.importerMatch + ?? state.topMatch + ?? resolveTransitive(state.graph, pkg) + ?? state.packagesFallback } /** @@ -270,8 +328,10 @@ function resolveTransitive(graph: PnpmGraph, pkg: string): string | null { return null const visited = new Set() const queue = [...graph.roots] - while (queue.length > 0) { - const key = queue.shift()! + // Index-pointer BFS: `queue` only grows, so iterating by index visits + // entries in insertion order without shift()'s O(n) reindexing. + for (let i = 0; i < queue.length; i++) { + const key = queue[i] if (visited.has(key)) continue visited.add(key) From bec57a0657b17360f5dae03ba61144aba8002fbe Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 23:10:12 +0900 Subject: [PATCH 09/10] chore: drop unnecessary optional chain on fetch result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetcher.fetch() returns a non-nullable FetchResult, so the ?. on fromStoreCache access was dead — flagged by Codacy. --- packages/cli/src/commands/ensure-checkout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/ensure-checkout.ts b/packages/cli/src/commands/ensure-checkout.ts index 4f10305..7de5d5f 100644 --- a/packages/cli/src/commands/ensure-checkout.ts +++ b/packages/cli/src/commands/ensure-checkout.ts @@ -262,5 +262,5 @@ export async function ensureCheckout( // `GithubSource.fetch` can satisfy the request from its own store-hit // path (a ref-candidate variant like `v` or `master`) with zero // network I/O — trust its `fromStoreCache` over our primary-key miss. - return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName, fromCache: fetchResult?.fromStoreCache ?? false } + return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName, fromCache: fetchResult.fromStoreCache ?? false } } From 14e55847234ae3017da6205d15204f5c511016cc Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 23:18:30 +0900 Subject: [PATCH 10/10] fix: restore null-safe access to the injected fetcher result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bec57a0 dropped the optional chain because FetchResult was declared non-nullable, but the ensureCheckout test seam legitimately simulates a successful fetch by materializing the checkout dir without returning a FetchResult — cache-sharing.test.ts crashed in CI. Widen the seam type to Promise so the type states the real contract and the ?. is justified rather than dead. --- packages/cli/src/commands/ensure-checkout.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/ensure-checkout.ts b/packages/cli/src/commands/ensure-checkout.ts index 7de5d5f..fbf4bc1 100644 --- a/packages/cli/src/commands/ensure-checkout.ts +++ b/packages/cli/src/commands/ensure-checkout.ts @@ -47,7 +47,12 @@ export interface EnsureCheckoutResult { */ export interface EnsureCheckoutDeps { askHome?: string - fetcher?: { fetch: (opts: SourceConfig) => Promise } + /** + * The result may be undefined: test seams simulate a successful fetch + * by materializing the checkout dir without building a `FetchResult`. + * Downstream reads must stay null-safe (`fetchResult?.…`). + */ + fetcher?: { fetch: (opts: SourceConfig) => Promise } lockfileReader?: { read: (name: string, projectDir: string) => { version: string } | null } resolverFor?: (ecosystem: string) => { resolve: (name: string, version: string) => Promise<{ @@ -262,5 +267,5 @@ export async function ensureCheckout( // `GithubSource.fetch` can satisfy the request from its own store-hit // path (a ref-candidate variant like `v` or `master`) with zero // network I/O — trust its `fromStoreCache` over our primary-key miss. - return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName, fromCache: fetchResult.fromStoreCache ?? false } + return { parsed, owner, repo, ref: actualRef, resolvedVersion, checkoutDir: resolvedCheckoutDir, npmPackageName, fromCache: fetchResult?.fromStoreCache ?? false } }