diff --git a/bin/axiomcode b/bin/axiomcode index 71ae8b8cf..b5225e774 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -412,7 +412,7 @@ case "$cmd" in case "$which" in java|typescript|python|javascript|csharp) run_suite "$which" "$@";; web) echo "══ web: cli smoke + violation checks + include checks"; bash "$ROOT/graph/test/web/cli-smoke.sh" || rc=1; bash "$ROOT/graph/test/web/violations.sh" || rc=1; bash "$ROOT/graph/test/web/includes.sh" || rc=1; run_suite web "$@";; - parser) for t in java-extractor-tests typescript-tests python-tests javascript-tests csharp-tests gradle-tests services-tests web-tests web-torture web-plumbing-tests discovery-tests; do + parser) for t in java-extractor-tests typescript-tests deno-tests python-tests javascript-tests csharp-tests gradle-tests services-tests web-tests web-torture web-plumbing-tests discovery-tests; do [ -f "$ROOT/parser/src/test/$t.ts" ] || continue; echo "══ parser: $t"; (cd "$ROOT/parser" && npx tsx "src/test/$t.ts") || rc=1; done;; all) for l in java typescript python javascript csharp web; do [ -f "$ROOT/graph/test/$l/run-tests.sh" ] && run_suite "$l" "$@"; done; bash "$ROOT/graph/test/web/cli-smoke.sh" || rc=1; "$0" test parser || rc=1;; *) die "test: unknown suite $which (java | typescript | python | javascript | csharp | web | parser | all)";; diff --git a/parser/src/deno-libraries.ts b/parser/src/deno-libraries.ts new file mode 100644 index 000000000..15fdec185 --- /dev/null +++ b/parser/src/deno-libraries.ts @@ -0,0 +1,122 @@ +/** + * The library roots a Deno project's imports reach, for `--library auto`: + * + * node dist/deno-libraries.js + * + * prints one JSON object, `{"mirrors": ["", …], "packages": ["", …]}`: + * + * mirrors each Deno project's mirror, filled by resolving every import: each + * `jsr:` and URL module the project reaches, and what those import, + * copied from Deno's cache as `vendor/` lays them out + * packages every npm package directory an `npm:` import resolved into, in + * Deno's npm cache or `node_modules` (real paths, because Deno's + * `node_modules` entries are symlinks into `node_modules/.deno`) + * + * Library discovery runs BEFORE the project is parsed, so the mirror has to be + * filled here rather than as a side effect of the parse. Resolution is the same + * code the parser uses, so the files staged are the files the imports name. + * A repository with no Deno config prints `{"mirrors": [], "packages": []}`. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +import { configIn, denoContextForDir, isDenoVendorDirectory } from '@/parsers/typescript/deno/deno-config'; +import { mirrorRoot } from '@/parsers/typescript/deno/deno-cache'; +import { moduleSpecifiersOf, resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver'; + +const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.axiomcode']); +const SOURCE = /\.(tsx?|mts|cts|jsx?|mjs|cjs)$/; + +function sourceFiles(root: string): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP.has(entry.name) && !entry.name.startsWith('.') && !isDenoVendorDirectory(dir, entry.name)) { + walk(full); + } + } else if (SOURCE.test(entry.name) && !entry.name.endsWith('.d.ts')) { + out.push(full); + } + } + }; + walk(root); + return out; +} + +/** + * `<…>/zod/3.25.76` for a file inside it: the nearest ancestor whose package.json + * names `packageName`. Nearest alone is not enough, because a package can ship a + * stub package.json per subpath (`zod/v4/package.json`). + */ +function packageDirOf(file: string, packageName: string): string | undefined { + let dir = path.dirname(file); + for (;;) { + const manifest = path.join(dir, 'package.json'); + if (fs.existsSync(manifest)) { + let name: unknown; + try { + name = (JSON.parse(fs.readFileSync(manifest, 'utf-8')) as { name?: unknown }).name; + } catch { + name = undefined; + } + if (name === packageName) { + try { + return fs.realpathSync(dir); + } catch { + return dir; + } + } + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +function main(): number { + const repo = path.resolve(process.argv[2] ?? '.'); + const mirrors = new Set(); + const packages = new Set(); + for (const file of sourceFiles(repo)) { + const context = denoContextForDir(path.dirname(file)); + if (context === undefined) { + continue; + } + let text: string; + try { + text = fs.readFileSync(file, 'utf-8'); + } catch { + continue; + } + for (const specifier of moduleSpecifiersOf(text, file)) { + const resolved = resolveDenoSpecifier(specifier, file); + if (resolved === undefined) { + continue; + } + if (resolved.kind === 'JSR' || resolved.kind === 'REMOTE') { + mirrors.add(mirrorRoot(context.root)); + } else if (resolved.kind === 'NPM_CACHE' || resolved.kind === 'NODE_MODULES') { + const dir = packageDirOf(resolved.resolvedFileName, resolved.packageName); + if (dir !== undefined) { + packages.add(dir); + } + } + } + } + // A mirror exists once something was copied into it; one that stayed empty stages nothing. + const filled = [...mirrors].filter((m) => configIn(m) !== undefined).sort(); + process.stdout.write(`${JSON.stringify({ mirrors: filled, packages: [...packages].sort() })}\n`); + return 0; +} + +process.exit(main()); diff --git a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts index bc36e385b..a82c0f3a0 100644 --- a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts +++ b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts @@ -33,7 +33,7 @@ export enum TsImportResolutionKind { /** A relative specifier that resolved to a file. */ RELATIVE_FILE = 'RELATIVE_FILE', - /** Resolved through a tsconfig `paths` mapping. */ + /** Resolved through a tsconfig `paths` mapping, or a Deno import-map entry onto a project file. */ PATHS_ALIAS = 'PATHS_ALIAS', /** Resolved to a `.d.ts` inside `node_modules`. */ NODE_MODULES_TYPES = 'NODE_MODULES_TYPES', @@ -46,6 +46,16 @@ export enum TsImportResolutionKind { * source its `main` / `types` / `exports` entry is built from. */ WORKSPACE_PACKAGE = 'WORKSPACE_PACKAGE', + /** + * A `jsr:` package (directly, or through a Deno import map), resolved to the + * version the lockfile pins and the file its `exports` names. The path is the + * module's copy in the project's Deno mirror, laid out as `vendor/` is. + */ + JSR_PACKAGE = 'JSR_PACKAGE', + /** An `npm:` package resolved inside Deno's own npm cache rather than `node_modules`. */ + DENO_NPM_CACHE = 'DENO_NPM_CACHE', + /** An `https:` module a Deno project imports by URL, as downloaded (redirects and `x-typescript-types` followed). */ + REMOTE_MODULE = 'REMOTE_MODULE', /** Matched a `declare module "x"` in this analysis. No file, and none needed. */ AMBIENT_MODULE = 'AMBIENT_MODULE', /** A Node builtin, with or without the `node:` prefix. */ diff --git a/parser/src/language-detectors/typescript-detector.ts b/parser/src/language-detectors/typescript-detector.ts index 753d5d23f..6ad6da2d8 100644 --- a/parser/src/language-detectors/typescript-detector.ts +++ b/parser/src/language-detectors/typescript-detector.ts @@ -37,6 +37,10 @@ export class TypeScriptDetector implements LanguageDetector { 'tsconfig.json', 'tsconfig.base.json', 'jsconfig.json', + // A Deno project's config defines its module graph the way a tsconfig does, and a + // workspace root's lists members that import one another: one project, not one per member. + 'deno.json', + 'deno.jsonc', ]; private static readonly SKIP = new Set(TS_SKIP_DIRECTORIES); @@ -62,6 +66,7 @@ export class TypeScriptDetector implements LanguageDetector { async detectBuildSystem(projectPath: string): Promise { try { const files = await fs.readdir(projectPath); + if (files.includes('deno.json') || files.includes('deno.jsonc')) return 'Deno'; if (files.includes('pnpm-lock.yaml')) return 'pnpm'; if (files.includes('yarn.lock')) return 'Yarn'; if (files.includes('bun.lockb') || files.includes('bun.lock')) return 'Bun'; diff --git a/parser/src/parsers/typescript/deno/deno-cache.ts b/parser/src/parsers/typescript/deno/deno-cache.ts new file mode 100644 index 000000000..10a3a17cc --- /dev/null +++ b/parser/src/parsers/typescript/deno/deno-cache.ts @@ -0,0 +1,278 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { DenoConfig, readJsonc } from '@/parsers/typescript/deno/deno-config'; + +/** + * Where Deno keeps what a project downloaded, read without running Deno. + * + * ## The two stores + * + * - **Remote modules** (`jsr:` packages and `https:` imports) live in + * `$DENO_DIR/remote///`: a file with no + * extension whose body is the module and whose LAST line is a + * `// denoCacheMetadata={…}` comment carrying the response headers. With + * `"vendor": true` the same modules sit under `/vendor//`. + * - **npm packages** live unpacked in `$DENO_DIR/npm////` + * (or in `node_modules` when the project has one). + * + * ## The mirror + * + * A hashed, extensionless cache file is not something a parser walk can stage as + * a library, and its path names no package. So a remote module a project imports + * is copied to a MIRROR laid out exactly as `vendor/` is (`jsr.io/@std/path/1.1.6/ + * mod.ts`), one mirror per Deno project. An import then resolves to a real `.ts` + * file, and the mirror is one library root whose module paths carry the package + * and version, which is what keeps `@std/path`'s `mod.ts` apart from `@std/fs`'s + * when the engine matches a resolved path against library modules. + */ + +/** `$DENO_DIR`, or the platform default Deno itself uses. */ +export function denoDir(): string { + if (process.env.DENO_DIR) { + return path.resolve(process.env.DENO_DIR); + } + const home = os.homedir(); + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Caches', 'deno'); + } + if (process.platform === 'win32') { + return path.join(process.env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'), 'deno'); + } + return path.join(process.env.XDG_CACHE_HOME ?? path.join(home, '.cache'), 'deno'); +} + +export interface RemoteModule { + readonly url: string; + readonly text: string; + /** Response headers, lower-cased. Empty for a vendored file. */ + readonly headers: Readonly>; +} + +const METADATA_MARKER = '\n// denoCacheMetadata='; + +/** + * A remote module as the project's Deno would load it: from `vendor/` when the + * project vendors, else from `$DENO_DIR`. `undefined` when it was never downloaded. + */ +export function readRemote(url: string, root: DenoConfig): RemoteModule | undefined { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return undefined; + } + if (root.vendor) { + const vendored = path.join(root.dir, 'vendor', hostDir(parsed), ...decodedSegments(parsed)); + const text = readText(vendored); + if (text !== undefined) { + return { url, text, headers: {} }; + } + } + const cached = path.join(denoDir(), 'remote', parsed.protocol.replace(/:$/, ''), hostDir(parsed), + crypto.createHash('sha256').update(parsed.pathname + parsed.search).digest('hex')); + const raw = readText(cached); + if (raw === undefined) { + return undefined; + } + const at = raw.lastIndexOf(METADATA_MARKER); + if (at >= 0) { + return { url, text: raw.slice(0, at), headers: headersOf(raw.slice(at + METADATA_MARKER.length)) }; + } + // Deno before the inline trailer kept the headers in a sidecar file. + const sidecar = readText(`${cached}.metadata.json`); + return { url, text: raw, headers: sidecar === undefined ? {} : headersOf(sidecar) }; +} + +function headersOf(json: string): Record { + try { + const meta = JSON.parse(json.trim()) as { headers?: Record }; + const out: Record = {}; + for (const [k, v] of Object.entries(meta.headers ?? {})) { + if (typeof v === 'string') { + out[k.toLowerCase()] = v; + } + } + return out; + } catch { + return {}; + } +} + +/** `deno.land`, or `localhost_PORT8000` as Deno spells a host with a port. */ +function hostDir(url: URL): string { + return url.port === '' ? url.hostname : `${url.hostname}_PORT${url.port}`; +} + +function decodedSegments(url: URL): string[] { + return url.pathname.split('/').filter((s) => s !== '').map((s) => decodeURIComponent(s)); +} + +function readText(file: string): string | undefined { + try { + return fs.readFileSync(file, 'utf-8'); + } catch { + return undefined; + } +} + +/** The versions a lockfile pins, per package name, for each registry. */ +export interface DenoLock { + readonly jsr: ReadonlyMap; + readonly npm: ReadonlyMap; + readonly text: string; +} + +const locks = new Map(); + +/** The lockfile beside `root`, in any of the v3 to v5 layouts, or `undefined`. */ +export function readLock(root: DenoConfig): DenoLock | undefined { + if (root.lockPath === false) { + return undefined; + } + const cached = locks.get(root.lockPath); + if (cached !== undefined) { + return cached ?? undefined; + } + const text = readText(root.lockPath); + let result: DenoLock | undefined; + if (text !== undefined) { + const json = readJsonc(root.lockPath) as Record | undefined; + if (json !== undefined && json !== null && typeof json === 'object') { + // v4 and v5 keep `jsr` and `npm` at the top; v3 nests them under `packages`. + const packages = (json.packages ?? {}) as Record; + result = { + jsr: versionsByName(json.jsr ?? packages.jsr), + npm: versionsByName(json.npm ?? packages.npm), + text, + }; + } + } + locks.set(root.lockPath, result ?? null); + return result; +} + +/** `{"@std/path@1.1.6": …, "zod@3.25.76_peer@1.0.0": …}` to name → versions. */ +function versionsByName(section: unknown): Map { + const out = new Map(); + if (section === null || typeof section !== 'object') { + return out; + } + for (const key of Object.keys(section as Record)) { + const at = key.indexOf('@', 1); + if (at < 0) { + continue; + } + const name = key.slice(0, at); + // An npm key carries its peer resolution after `_`; the version is before it. + const version = key.slice(at + 1).split('_')[0]!; + out.set(name, [...(out.get(name) ?? []), version]); + } + return out; +} + +/** Every version of an npm package unpacked in `$DENO_DIR/npm`, with its directory. */ +export function cachedNpmVersions(name: string): Map { + const out = new Map(); + const npmRoot = path.join(denoDir(), 'npm'); + let registries: string[] = []; + try { + registries = fs.readdirSync(npmRoot); + } catch { + return out; + } + // The public registry first, so a mirror registry holding the same version does not win. + registries.sort((a, b) => (b === 'registry.npmjs.org' ? 1 : 0) - (a === 'registry.npmjs.org' ? 1 : 0)); + for (const registry of registries) { + const dir = path.join(npmRoot, registry, ...name.split('/')); + let versions: string[] = []; + try { + versions = fs.readdirSync(dir); + } catch { + continue; + } + for (const version of versions) { + const pkgDir = path.join(dir, version); + if (!out.has(version) && fs.existsSync(path.join(pkgDir, 'package.json'))) { + out.set(version, pkgDir); + } + } + } + return out; +} + +/** + * The mirror directory for a Deno project: `$AXIOMCODE_DENO_MIRROR` when set, + * else one directory per project under the axiomcode cache. + */ +export function mirrorRoot(root: DenoConfig): string { + // A library parse OF a mirror resolves inside it rather than minting a mirror of the mirror. + if (fs.existsSync(path.join(root.dir, MIRROR_MARKER))) { + return root.dir; + } + if (process.env.AXIOMCODE_DENO_MIRROR) { + return path.resolve(process.env.AXIOMCODE_DENO_MIRROR); + } + const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache'); + let real = root.dir; + try { + real = fs.realpathSync(root.dir); + } catch { + // an unreadable root keeps its written path + } + const key = crypto.createHash('sha1').update(real).digest('hex').slice(0, 12); + return path.join(base, 'axiomcode', 'deno', `${path.basename(real) || 'root'}-${key}`); +} + +/** The file that marks a directory as a mirror this module wrote. */ +export const MIRROR_MARKER = '.axiomcode-deno-mirror'; + +const preparedMirrors = new Set(); + +/** + * Writes `text` to `/` unless it is already there, and returns + * the absolute path. The write is a rename, so two parse workers writing the same + * module never leave a torn file. + * + * The first write to a mirror also gives it a `deno.json` and the project's + * lockfile: a library parse of the mirror then resolves the `jsr:` imports INSIDE + * the mirrored packages to the same pinned versions the project uses. + */ +export function materialize(root: DenoConfig, relative: string, text: string): string | undefined { + const mirror = mirrorRoot(root); + const target = path.join(mirror, relative); + try { + if (!preparedMirrors.has(mirror)) { + fs.mkdirSync(mirror, { recursive: true }); + writeIfChanged(path.join(mirror, MIRROR_MARKER), ''); + writeIfChanged(path.join(mirror, 'deno.json'), '{}\n'); + const lock = readLock(root); + if (lock !== undefined) { + writeIfChanged(path.join(mirror, 'deno.lock'), lock.text); + } + preparedMirrors.add(mirror); + } + writeIfChanged(target, text); + return target; + } catch { + return undefined; + } +} + +function writeIfChanged(file: string, text: string): void { + if (readText(file) === text) { + return; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; + fs.writeFileSync(temporary, text); + fs.renameSync(temporary, file); +} + +/** Forgets cached lockfiles and prepared mirrors; for a test that rewrites them. */ +export function clearDenoCacheState(): void { + locks.clear(); + preparedMirrors.clear(); +} diff --git a/parser/src/parsers/typescript/deno/deno-config.ts b/parser/src/parsers/typescript/deno/deno-config.ts new file mode 100644 index 000000000..838e1e0cc --- /dev/null +++ b/parser/src/parsers/typescript/deno/deno-config.ts @@ -0,0 +1,327 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as ts from 'typescript'; + +/** + * Which `deno.json` governs a file, and what it says. + * + * ## Why Deno needs its own reading + * + * A Deno program has no `tsconfig.json` and no `node_modules` to speak of. Its + * module graph is written in `deno.json`: an import map (`imports`, `scopes`, or + * an `importMap` file) turns a bare specifier into a file, a `jsr:` / `npm:` + * package or a URL, and a `workspace` lists the member packages that import one + * another by NAME. `ts.resolveModuleName` knows none of it, so before this file + * every `import { Hono } from "hono"` in a Deno service resolved to nothing and + * every call across a workspace member was matched by name only. + * + * ## Member and root + * + * The NEAREST config is the member that owns the file. A workspace root lists its + * members; its import map applies to every member, and the member's own entries + * win. A config that no workspace lists is its own root. Both are pure functions + * of the file system, so a parse worker reaches the same answer as the analyzer. + */ +export const DENO_CONFIG_NAMES = ['deno.json', 'deno.jsonc'] as const; + +/** One import map: bare or URL keys to targets, and the scopes that override it. */ +export interface DenoImportMap { + /** + * Key to target. A target written relative to the map (`./src/`) is stored + * ABSOLUTE; `jsr:`, `npm:`, `node:` and URL targets are stored as written. + */ + readonly imports: ReadonlyMap; + /** Scope prefix (an absolute directory with a trailing separator, or a URL) to its map. */ + readonly scopes: ReadonlyArray<{ readonly prefix: string; readonly imports: ReadonlyMap }>; +} + +export interface DenoConfig { + /** Absolute path of the `deno.json` / `deno.jsonc`. */ + readonly path: string; + readonly dir: string; + /** `name` as published (`@scope/pkg`), or `''`. */ + readonly name: string; + readonly version: string; + /** Export subpath (`.`, `./ids`) to the absolute file it names. */ + readonly exports: ReadonlyMap; + readonly importMap: DenoImportMap; + /** Absolute member directories, for a workspace root; empty otherwise. */ + readonly workspaceMembers: readonly string[]; + /** `"vendor": true`: remote modules are kept under `/vendor`. */ + readonly vendor: boolean; + /** `false` when the config turns the lockfile off; the path otherwise (it may not exist). */ + readonly lockPath: string | false; + readonly compilerOptions: Readonly>; +} + +export interface DenoContext { + /** The config nearest the file. */ + readonly member: DenoConfig; + /** The workspace root that lists `member`, or `member` itself. */ + readonly root: DenoConfig; + /** Every member of `root`'s workspace, `root` included when it has a name. */ + readonly members: readonly DenoConfig[]; +} + +const parsedConfigs = new Map(); +const contextByDir = new Map(); + +/** The Deno config governing `absoluteFilePath`, or `undefined` outside any Deno project. */ +export function denoContextFor(absoluteFilePath: string): DenoContext | undefined { + return denoContextForDir(path.dirname(path.resolve(absoluteFilePath))); +} + +export function denoContextForDir(directory: string): DenoContext | undefined { + const dir = path.resolve(directory); + const cached = contextByDir.get(dir); + if (cached !== undefined) { + return cached ?? undefined; + } + let result: DenoContext | undefined; + const memberPath = findConfigUpward(dir); + const member = memberPath === undefined ? undefined : readDenoConfig(memberPath); + if (member !== undefined) { + let root: DenoConfig = member; + // The workspace root is an ANCESTOR config that lists this member's directory. + let up = path.dirname(member.dir); + for (;;) { + const candidatePath = findConfigUpward(up); + if (candidatePath === undefined) { + break; + } + const candidate = readDenoConfig(candidatePath); + if (candidate !== undefined && candidate.workspaceMembers.some((m) => samePath(m, member.dir))) { + root = candidate; + break; + } + const parent = path.dirname(path.dirname(candidatePath)); + if (parent === path.dirname(candidatePath)) { + break; + } + up = parent; + } + const members = root.workspaceMembers + .map((m) => { + const p = configIn(m); + return p === undefined ? undefined : readDenoConfig(p); + }) + .filter((c): c is DenoConfig => c !== undefined); + if (root.name !== '' && !members.some((m) => samePath(m.path, root.path))) { + members.push(root); + } + if (member.name !== '' && !members.some((m) => samePath(m.path, member.path))) { + members.push(member); + } + result = { member, root, members }; + } + contextByDir.set(dir, result ?? null); + return result; +} + +/** The Deno config file directly in `dir`, if any. */ +export function configIn(dir: string): string | undefined { + for (const name of DENO_CONFIG_NAMES) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +function findConfigUpward(start: string): string | undefined { + let dir = start; + for (;;) { + const found = configIn(dir); + if (found !== undefined) { + return found; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +function samePath(a: string, b: string): boolean { + return path.resolve(a) === path.resolve(b); +} + +/** JSON with comments and trailing commas, which is what `deno.jsonc` (and often `deno.json`) holds. */ +export function readJsonc(file: string): unknown { + let text: string; + try { + text = fs.readFileSync(file, 'utf-8'); + } catch { + return undefined; + } + const parsed = ts.parseConfigFileTextToJson(file, text); + return parsed.error ? undefined : parsed.config; +} + +export function readDenoConfig(configPath: string): DenoConfig | undefined { + const absolute = path.resolve(configPath); + const cached = parsedConfigs.get(absolute); + if (cached !== undefined) { + return cached ?? undefined; + } + const raw = readJsonc(absolute); + let result: DenoConfig | undefined; + if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { + const json = raw as Record; + const dir = path.dirname(absolute); + const inline = importMapOf(json, dir); + const external = typeof json.importMap === 'string' + ? importMapFile(path.resolve(dir, json.importMap)) + : undefined; + result = { + path: absolute, + dir, + name: typeof json.name === 'string' ? json.name : '', + version: typeof json.version === 'string' ? json.version : '', + exports: exportsOf(json.exports, dir), + importMap: { + imports: new Map([...(external?.imports ?? []), ...inline.imports]), + scopes: [...inline.scopes, ...(external?.scopes ?? [])], + }, + workspaceMembers: workspaceMembersOf(json.workspace, dir), + vendor: json.vendor === true, + lockPath: lockPathOf(json.lock, dir), + compilerOptions: json.compilerOptions !== null && typeof json.compilerOptions === 'object' + ? json.compilerOptions as Record + : {}, + }; + } + parsedConfigs.set(absolute, result ?? null); + return result; +} + +function importMapFile(file: string): DenoImportMap | undefined { + const raw = readJsonc(file); + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + return importMapOf(raw as Record, path.dirname(file)); +} + +function importMapOf(json: Record, baseDir: string): DenoImportMap { + const scopes: Array<{ prefix: string; imports: ReadonlyMap }> = []; + if (json.scopes !== null && typeof json.scopes === 'object') { + for (const [prefix, map] of Object.entries(json.scopes as Record)) { + if (map !== null && typeof map === 'object') { + scopes.push({ prefix: absoluteTarget(prefix, baseDir), imports: entriesOf(map, baseDir) }); + } + } + // The most specific scope is consulted first, as the import-map spec orders them. + scopes.sort((a, b) => b.prefix.length - a.prefix.length); + } + return { imports: entriesOf(json.imports, baseDir), scopes }; +} + +function entriesOf(value: unknown, baseDir: string): Map { + const out = new Map(); + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + for (const [key, target] of Object.entries(value as Record)) { + if (typeof target === 'string') { + out.set(key, absoluteTarget(target, baseDir)); + } + } + } + return out; +} + +/** `./src/` beside the map becomes an absolute directory, its trailing separator kept. */ +function absoluteTarget(target: string, baseDir: string): string { + if (target.startsWith('file://')) { + return withTrailing(target, decodeURIComponent(new URL(target).pathname)); + } + if (target.startsWith('./') || target.startsWith('../') || target === '.' || target === '..' + || target.startsWith('/')) { + return withTrailing(target, path.resolve(baseDir, target)); + } + return target; +} + +function withTrailing(original: string, resolved: string): string { + return original.endsWith('/') && !resolved.endsWith(path.sep) ? resolved + path.sep : resolved; +} + +function exportsOf(value: unknown, dir: string): Map { + const out = new Map(); + if (typeof value === 'string') { + out.set('.', path.resolve(dir, value)); + } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + for (const [subpath, target] of Object.entries(value as Record)) { + if (typeof target === 'string') { + out.set(subpath, path.resolve(dir, target)); + } + } + } + return out; +} + +function workspaceMembersOf(value: unknown, dir: string): string[] { + const listed = Array.isArray(value) + ? value + : value !== null && typeof value === 'object' && Array.isArray((value as { members?: unknown }).members) + ? (value as { members: unknown[] }).members + : []; + const out: string[] = []; + for (const entry of listed) { + if (typeof entry !== 'string') { + continue; + } + if (entry.endsWith('/*') || entry.endsWith('/*/')) { + // `./packages/*`: every directory under it that holds a Deno config. + const parent = path.resolve(dir, entry.replace(/\/\*\/?$/, '')); + let children: fs.Dirent[] = []; + try { + children = fs.readdirSync(parent, { withFileTypes: true }); + } catch { + // a glob over a missing directory names no member + } + for (const child of children) { + if (child.isDirectory() && configIn(path.join(parent, child.name)) !== undefined) { + out.push(path.join(parent, child.name)); + } + } + } else { + out.push(path.resolve(dir, entry)); + } + } + return out; +} + +function lockPathOf(value: unknown, dir: string): string | false { + if (value === false) { + return false; + } + if (typeof value === 'string') { + return path.resolve(dir, value); + } + if (value !== null && typeof value === 'object' && typeof (value as { path?: unknown }).path === 'string') { + return path.resolve(dir, (value as { path: string }).path); + } + return path.join(dir, 'deno.lock'); +} + +/** Forgets every parsed config; for a test that rewrites one in place. */ +export function clearDenoConfigCache(): void { + parsedConfigs.clear(); + contextByDir.clear(); +} + +/** + * Whether `parent/name` is the `vendor/` directory a Deno config beside it keeps + * its downloaded modules in. Those are third-party sources: walking them as + * project files would put every vendored package in the project's own graph. + */ +export function isDenoVendorDirectory(parent: string, name: string): boolean { + if (name !== 'vendor') { + return false; + } + const config = configIn(parent); + return config !== undefined && readDenoConfig(config)?.vendor === true; +} diff --git a/parser/src/parsers/typescript/deno/deno-resolver.ts b/parser/src/parsers/typescript/deno/deno-resolver.ts new file mode 100644 index 000000000..c5cf5aff6 --- /dev/null +++ b/parser/src/parsers/typescript/deno/deno-resolver.ts @@ -0,0 +1,545 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as ts from 'typescript'; + +import { DenoConfig, DenoContext, DenoImportMap, denoContextFor } from '@/parsers/typescript/deno/deno-config'; +import { + cachedNpmVersions, materialize, readLock, readRemote, RemoteModule, +} from '@/parsers/typescript/deno/deno-cache'; +import { maxSatisfying } from '@/parsers/typescript/deno/deno-semver'; + +/** + * Resolves a module specifier the way Deno does, for a file inside a Deno project. + * + * Order, as Deno applies it: + * + * 1. the import map: the member's `scopes` and `imports`, then the workspace + * root's (a key ending in `/` maps a prefix; a `jsr:` / `npm:` target also + * maps the key's subpaths, `"hono": "jsr:@hono/hono"` serving `hono/cors`); + * 2. a workspace member by name (`@acme/shared`, `@acme/shared/ids`), through + * its `exports`; + * 3. the target's scheme: `jsr:` to the version the lockfile pins (or the + * highest cached one in range) and the file its `exports` names; `npm:` to + * `node_modules` or Deno's npm cache; `https:` to the downloaded module, + * following redirects and an `x-typescript-types` header as Deno does. + * + * Relative specifiers and bare ones nothing above claims are NOT answered here: + * `ts.resolveModuleName` already gets those right (`./a.ts` included) and a Deno + * project with a `package.json` resolves its bare npm imports through + * `node_modules` exactly as Node does. + * + * Every answer is a file that exists. A `jsr:` or URL module that was never + * downloaded resolves to nothing, which is the honest answer for this machine. + */ +export type DenoResolutionKind = + /** An import-map entry onto a project file. */ + | 'IMPORT_MAP' + /** A workspace member imported by its name. */ + | 'WORKSPACE_MEMBER' + | 'JSR' + /** An npm package in Deno's own cache. */ + | 'NPM_CACHE' + /** An npm package in the project's `node_modules`. */ + | 'NODE_MODULES' + | 'REMOTE' + | 'BUILTIN'; + +export interface DenoResolution { + readonly resolvedFileName: string; + /** The `ts.Extension` the file carries (`.ts`, `.d.ts`, …); `''` for a builtin. */ + readonly extension: string; + readonly kind: DenoResolutionKind; + /** `@std/path`, `zod`, `@acme/shared`; `''` for a project file or a URL. */ + readonly packageName: string; +} + +const resolutions = new Map(); + +export function resolveDenoSpecifier(specifier: string, containingFile: string): DenoResolution | undefined { + if (specifier === '' || isPathSpecifier(specifier)) { + return undefined; + } + const context = denoContextFor(containingFile); + if (context === undefined) { + return undefined; + } + const key = `${path.dirname(path.resolve(containingFile))}\0${specifier}`; + const cached = resolutions.get(key); + if (cached !== undefined) { + return cached ?? undefined; + } + let result: DenoResolution | undefined; + try { + result = resolveIn(specifier, path.resolve(containingFile), context); + } catch { + result = undefined; + } + // Scopes and member maps are keyed by directory, so the answer is too. + resolutions.set(key, result ?? null); + return result; +} + +function resolveIn(specifier: string, containingFile: string, context: DenoContext): DenoResolution | undefined { + const mapped = mapSpecifier(specifier, containingFile, context); + if (mapped !== undefined && path.isAbsolute(mapped)) { + const file = existingFile(mapped); + return file === undefined ? undefined : { ...fileResolution(file), kind: 'IMPORT_MAP', packageName: '' }; + } + const target = mapped ?? specifier; + if (target.startsWith('node:')) { + return { resolvedFileName: '', extension: '', kind: 'BUILTIN', packageName: '' }; + } + if (target.startsWith('jsr:')) { + return resolveJsr(target, context); + } + if (target.startsWith('npm:')) { + return resolveNpm(target, containingFile, context); + } + if (target.startsWith('https:') || target.startsWith('http:')) { + return resolveRemoteUrl(target, context.root); + } + return memberExport(target, context); +} + +// ── 1. the import map ─────────────────────────────────────────────────────── + +function mapSpecifier(specifier: string, containingFile: string, context: DenoContext): string | undefined { + const maps = context.member === context.root + ? [context.member.importMap] + : [context.member.importMap, context.root.importMap]; + for (const map of maps) { + const hit = applyImportMap(specifier, containingFile, map); + if (hit !== undefined) { + return hit; + } + } + return undefined; +} + +export function applyImportMap(specifier: string, referrer: string, map: DenoImportMap): string | undefined { + for (const scope of map.scopes) { + if (referrer.startsWith(scope.prefix)) { + const hit = matchImports(specifier, scope.imports); + if (hit !== undefined) { + return hit; + } + } + } + return matchImports(specifier, map.imports); +} + +function matchImports(specifier: string, imports: ReadonlyMap): string | undefined { + const exact = imports.get(specifier); + if (exact !== undefined) { + return exact; + } + // The longest `prefix/` key, as the import-map spec chooses among several. + let best: string | undefined; + for (const key of imports.keys()) { + if (key.endsWith('/') && specifier.startsWith(key) && (best === undefined || key.length > best.length)) { + best = key; + } + } + if (best !== undefined) { + return joinTarget(imports.get(best)!, specifier.slice(best.length)); + } + // Deno's own extension: a key that maps to a PACKAGE also serves its subpaths. + let packageKey: string | undefined; + for (const [key, target] of imports) { + if (!key.endsWith('/') && specifier.startsWith(`${key}/`) + && (target.startsWith('jsr:') || target.startsWith('npm:')) + && (packageKey === undefined || key.length > packageKey.length)) { + packageKey = key; + } + } + return packageKey === undefined + ? undefined + : `${imports.get(packageKey)!}${specifier.slice(packageKey.length)}`; +} + +function joinTarget(target: string, rest: string): string { + if (path.isAbsolute(target)) { + return path.join(target, ...rest.split('/')); + } + return target + rest; +} + +// ── 2. workspace members ──────────────────────────────────────────────────── + +function memberExport(specifier: string, context: DenoContext): DenoResolution | undefined { + for (const member of context.members) { + if (member.name === '' || (specifier !== member.name && !specifier.startsWith(`${member.name}/`))) { + continue; + } + const subpath = specifier === member.name ? '.' : `.${specifier.slice(member.name.length)}`; + const target = member.exports.get(subpath); + const file = target === undefined ? undefined : existingFile(target); + if (file !== undefined) { + return { ...fileResolution(file), kind: 'WORKSPACE_MEMBER', packageName: member.name }; + } + } + return undefined; +} + +// ── 3a. jsr: ──────────────────────────────────────────────────────────────── + +const JSR = /^jsr:\/?(@[^/@]+\/[^/@]+)(?:@([^/]*))?(\/.*)?$/; + +interface JsrMeta { + readonly exports?: Record; +} + +function resolveJsr(target: string, context: DenoContext): DenoResolution | undefined { + const m = JSR.exec(target); + if (!m) { + return undefined; + } + const name = m[1]!; + const range = m[2] ?? ''; + const subpath = m[3] ?? ''; + // `jsr:@acme/shared` inside the workspace that defines it is the member, not a download. + const member = memberExport(name + subpath, context); + if (member !== undefined) { + return member; + } + const root = context.root; + const version = jsrVersion(name, range, root); + if (version === undefined) { + return undefined; + } + const meta = readJson(`https://jsr.io/${name}/${version}_meta.json`, root); + const exported = meta?.exports?.[subpath === '' ? '.' : `.${subpath}`]; + if (exported === undefined) { + return undefined; + } + const file = exported.replace(/^\.\//, ''); + const module = readRemote(`https://jsr.io/${name}/${version}/${file}`, root); + const written = module === undefined ? undefined : materializeWithImports(module, root); + return written === undefined + ? undefined + : { ...fileResolution(written), kind: 'JSR', packageName: name }; +} + +function jsrVersion(name: string, range: string, root: DenoConfig): string | undefined { + const locked = readLock(root)?.jsr.get(name); + if (locked !== undefined && locked.length > 0) { + const pinned = maxSatisfying(locked, range); + if (pinned !== undefined) { + return pinned; + } + } + // No lockfile, or it pins no version in range: the highest version the cache holds. + const meta = readJson<{ versions?: Record }>(`https://jsr.io/${name}/meta.json`, root); + const available = Object.entries(meta?.versions ?? {}) + .filter(([version, info]) => info?.yanked !== true + && readRemote(`https://jsr.io/${name}/${version}_meta.json`, root) !== undefined) + .map(([version]) => version); + return maxSatisfying(available, range); +} + +function readJson(url: string, root: DenoConfig): T | undefined { + const module = readRemote(url, root); + if (module === undefined) { + return undefined; + } + try { + return JSON.parse(module.text) as T; + } catch { + return undefined; + } +} + +// ── 3b. npm: ──────────────────────────────────────────────────────────────── + +const NPM = /^npm:\/?((?:@[^/@]+\/)?[^/@]+)(?:@([^/]*))?(\/.*)?$/; + +/** What a Deno program resolves an npm package's entry with: ESM, `types` first. */ +const NPM_OPTIONS: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + customConditions: ['deno'], + allowImportingTsExtensions: true, + noEmit: true, +}; + +function resolveNpm(target: string, containingFile: string, context: DenoContext): DenoResolution | undefined { + const m = NPM.exec(target); + if (!m) { + return undefined; + } + const name = m[1]!; + const range = m[2] ?? ''; + const bare = name + (m[3] ?? ''); + // A project with node_modules (`nodeModulesDir`, or a package.json) installs there. + const installed = ts.resolveModuleName(bare, containingFile, NPM_OPTIONS, ts.sys).resolvedModule; + if (installed !== undefined && /[\\/]node_modules[\\/]/.test(installed.resolvedFileName)) { + return { + resolvedFileName: path.normalize(installed.resolvedFileName), + extension: installed.extension, + kind: 'NODE_MODULES', + packageName: installed.packageId?.name ?? name, + }; + } + const cached = cachedNpmVersions(name); + if (cached.size === 0) { + return undefined; + } + const locked = readLock(context.root)?.npm.get(name)?.filter((v) => cached.has(v)) ?? []; + const version = maxSatisfying(locked, range) ?? maxSatisfying(cached.keys(), range); + const packageDir = version === undefined ? undefined : cached.get(version); + if (packageDir === undefined) { + return undefined; + } + const file = resolveInPackage(bare, name, packageDir); + return file === undefined + ? undefined + : { resolvedFileName: file.resolvedFileName, extension: file.extension, kind: 'NPM_CACHE', packageName: name }; +} + +/** + * `bare` resolved inside one unpacked package directory, honouring its + * `exports` / `types` the way the compiler does for `node_modules`. + * + * Deno's cache is not a `node_modules` tree, so the compiler is shown one: a host + * that answers for `/node_modules//…` from `packageDir` and for + * nothing else under the virtual root. + */ +function resolveInPackage(bare: string, name: string, packageDir: string): + { resolvedFileName: string; extension: string } | undefined { + const virtualRoot = path.join(path.parse(packageDir).root, '__axiomcode_deno_npm__'); + const virtualPackage = path.join(virtualRoot, 'node_modules', ...name.split('/')); + const toReal = (p: string): string | undefined => { + const normal = path.normalize(p); + if (normal === virtualPackage || normal.startsWith(virtualPackage + path.sep)) { + return packageDir + normal.slice(virtualPackage.length); + } + return undefined; + }; + const host: ts.ModuleResolutionHost = { + fileExists: (p) => { + const real = toReal(p); + return real !== undefined && fs.existsSync(real) && fs.statSync(real).isFile(); + }, + readFile: (p) => { + const real = toReal(p); + return real === undefined ? undefined : ts.sys.readFile(real); + }, + directoryExists: (p) => { + const normal = path.normalize(p); + if (virtualPackage.startsWith(normal + path.sep) || normal === virtualPackage) { + return true; + } + const real = toReal(p); + return real !== undefined && fs.existsSync(real) && fs.statSync(real).isDirectory(); + }, + realpath: (p) => p, + getCurrentDirectory: () => virtualRoot, + }; + const resolved = ts.resolveModuleName(bare, path.join(virtualRoot, 'index.ts'), NPM_OPTIONS, host).resolvedModule; + const real = resolved === undefined ? undefined : toReal(resolved.resolvedFileName); + return real === undefined ? undefined : { resolvedFileName: real, extension: resolved!.extension }; +} + +// ── 3c. https: ────────────────────────────────────────────────────────────── + +function resolveRemoteUrl(url: string, root: DenoConfig): DenoResolution | undefined { + let module = followRedirects(url, root); + if (module === undefined) { + return undefined; + } + // A CDN that serves JavaScript points at its declarations, and Deno type-checks against those. + const types = module.headers['x-typescript-types']; + if (types !== undefined) { + const declared = followRedirects(new URL(types, module.url).toString(), root); + if (declared !== undefined) { + module = declared; + } + } + const written = materializeWithImports(module, root); + return written === undefined ? undefined : { ...fileResolution(written), kind: 'REMOTE', packageName: '' }; +} + +/** URLs already copied to a mirror this process, so a cycle between modules ends. */ +const materializedUrls = new Map(); + +/** + * Copies `module` to the mirror and, transitively, every module it imports that + * Deno downloaded: a relative import by its URL, a `jsr:` / `npm:` / URL import + * through this resolver from the mirrored file. A library parse of the mirror + * then finds a package's whole reachable source, not only the entry the project + * imported. + */ +function materializeWithImports(module: RemoteModule, root: DenoConfig): string | undefined { + const known = materializedUrls.get(module.url); + if (known !== undefined) { + return known ?? undefined; + } + const relative = mirrorPathOf(module); + const written = relative === undefined ? undefined : materialize(root, relative, module.text); + materializedUrls.set(module.url, written ?? null); + if (written === undefined) { + return undefined; + } + for (const specifier of moduleSpecifiersOf(module.text, written)) { + if (specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')) { + let target: string; + try { + target = new URL(specifier, module.url).toString(); + } catch { + continue; + } + if (!materializedUrls.has(target)) { + const imported = followRedirects(target, root); + if (imported !== undefined) { + materializeWithImports(imported, root); + } + } + } else if (/^(jsr|npm|https?):/.test(specifier)) { + resolveDenoSpecifier(specifier, written); + } + } + return written; +} + +function followRedirects(url: string, root: DenoConfig): RemoteModule | undefined { + let current = url; + for (let hop = 0; hop < 10; hop++) { + const module = readRemote(current, root); + if (module === undefined) { + return undefined; + } + const location = module.headers.location; + if (location === undefined) { + return module; + } + current = new URL(location, current).toString(); + } + return undefined; +} + +const TS_LIKE = /\.(d\.ts|d\.mts|d\.cts|tsx?|mts|cts|jsx?|mjs|cjs)$/; + +/** `deno.land/std@0.224.0/fmt/colors.ts`; an extensionless URL takes one from its content type. */ +function mirrorPathOf(module: RemoteModule): string | undefined { + let parsed: URL; + try { + parsed = new URL(module.url); + } catch { + return undefined; + } + const host = parsed.port === '' ? parsed.hostname : `${parsed.hostname}_PORT${parsed.port}`; + const segments = parsed.pathname.split('/').filter((s) => s !== '').map((s) => decodeURIComponent(s)); + if (segments.length === 0) { + segments.push('index'); + } + let last = segments.pop()!; + if (parsed.search !== '') { + last += `_${Buffer.from(parsed.search).toString('base64url')}`; + } + if (!TS_LIKE.test(last) || parsed.search !== '') { + const type = module.headers['content-type'] ?? ''; + last += /typescript|\/tsx?\b/.test(type) ? (/tsx/.test(type) ? '.tsx' : '.ts') + : /javascript|ecmascript|\/jsx\b/.test(type) ? '.js' + : TS_LIKE.exec(parsed.pathname)?.[0] ?? '.ts'; + } + return path.join(host, ...segments, last); +} + +/** + * Every module specifier a file names, as the parser's extraction sees them: + * imports and re-exports (`export * as ns from` included, which the compiler's + * light pre-processor misses), `import x = require()`, `import()` / `require()` + * with a literal, `import("x")` types, and triple-slash references. Missing one + * here leaves a module out of the mirror until a later library parse copies it, + * which changes the mirror and makes every rebuild recompile it. + */ +export function moduleSpecifiersOf(text: string, fileName: string): string[] { + const out: string[] = []; + const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false, + /\.tsx$|\.jsx$/.test(fileName) ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const literal = (node: ts.Node | undefined): void => { + if (node !== undefined && ts.isStringLiteralLike(node)) { + out.push(node.text); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + literal(node.moduleSpecifier); + } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { + literal(node.moduleReference.expression); + } else if (ts.isCallExpression(node) + && (node.expression.kind === ts.SyntaxKind.ImportKeyword + || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) { + literal(node.arguments[0]); + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + literal(node.argument.literal); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + for (const reference of [...sf.referencedFiles, ...sf.typeReferenceDirectives]) { + out.push(reference.fileName); + } + return out; +} + +// ── files ─────────────────────────────────────────────────────────────────── + +function isPathSpecifier(specifier: string): boolean { + return specifier.startsWith('./') || specifier.startsWith('../') || specifier === '.' || specifier === '..' + || specifier.startsWith('/') || specifier.startsWith('file:') || /^[A-Za-z]:[\\/]/.test(specifier); +} + +/** `target` itself, as Deno requires an explicit extension, else what the compiler's probing finds. */ +function existingFile(target: string): string | undefined { + try { + if (fs.statSync(target).isFile()) { + return path.normalize(target); + } + } catch { + // not a file as written; probe below + } + const probed = ts.resolveModuleName(target, path.join(path.dirname(target), '__probe__.ts'), + NPM_OPTIONS, ts.sys).resolvedModule; + return probed === undefined ? undefined : path.normalize(probed.resolvedFileName); +} + +const EXTENSIONS: readonly string[] = [ + ts.Extension.Dts, ts.Extension.Dmts, ts.Extension.Dcts, + ts.Extension.Tsx, ts.Extension.Ts, ts.Extension.Mts, ts.Extension.Cts, + ts.Extension.Jsx, ts.Extension.Js, ts.Extension.Mjs, ts.Extension.Cjs, ts.Extension.Json, +]; + +function fileResolution(file: string): { resolvedFileName: string; extension: string } { + const extension = EXTENSIONS.find((e) => file.endsWith(e)) ?? ''; + return { resolvedFileName: path.normalize(file), extension }; +} + +/** + * The package a specifier names in a Deno project, from the config and syntax + * alone, for an import that did not resolve: `hono` mapped to `jsr:@hono/hono@^4` + * names `@hono/hono`. `undefined` outside a Deno project or for a specifier the + * import map and the schemes say nothing about; `''` for a URL or a project file. + */ +export function denoPackageNameOf(specifier: string, containingFile: string): string | undefined { + if (isPathSpecifier(specifier)) { + return undefined; + } + const context = denoContextFor(containingFile); + if (context === undefined) { + return undefined; + } + const target = mapSpecifier(specifier, path.resolve(containingFile), context) ?? specifier; + if (path.isAbsolute(target) || /^https?:/.test(target) || target.startsWith('node:')) { + return ''; + } + const scheme = target.startsWith('jsr:') ? JSR.exec(target) : target.startsWith('npm:') ? NPM.exec(target) : null; + return scheme ? scheme[1] : undefined; +} + +/** Forgets memoised resolutions; for a test that rewrites a project in place. */ +export function clearDenoResolutionCache(): void { + resolutions.clear(); + materializedUrls.clear(); +} diff --git a/parser/src/parsers/typescript/deno/deno-semver.ts b/parser/src/parsers/typescript/deno/deno-semver.ts new file mode 100644 index 000000000..aeb3a8ff5 --- /dev/null +++ b/parser/src/parsers/typescript/deno/deno-semver.ts @@ -0,0 +1,214 @@ +/** + * The slice of semver a Deno import needs: does a locked or cached version satisfy + * the range a specifier wrote, and which of several is the highest. + * + * `jsr:@std/path@^1.0.0` and `npm:zod@~3.23` name RANGES. The version actually + * used is in `deno.lock`, or, without a lockfile, is the highest one the cache + * holds. The lockfile's own specifier keys cannot be looked up by the written + * range (Deno normalises `^1.0.0` to `1` there), so the range is matched against + * the locked VERSIONS instead, which is the same answer without depending on how + * a given Deno release spells its keys. + * + * Supported: exact, `^`, `~`, `x`/`*` wildcards, partial versions (`1`, `1.2`), + * comparators (`>=1.2 <2`), hyphen ranges and `||`. Pre-release versions satisfy + * only a range that names the same pre-release base, as in npm. + */ + +interface Version { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly pre: readonly string[]; +} + +const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +export function parseVersion(text: string): Version | undefined { + const m = VERSION.exec(text.trim()); + if (!m) { + return undefined; + } + return { + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + pre: m[4] ? m[4].split('.') : [], + }; +} + +export function compareVersions(a: string, b: string): number { + const va = parseVersion(a); + const vb = parseVersion(b); + if (!va || !vb) { + return va ? 1 : vb ? -1 : a.localeCompare(b); + } + return compare(va, vb); +} + +function compare(a: Version, b: Version): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + if (a.pre.length === 0 || b.pre.length === 0) { + return (a.pre.length === 0 ? 1 : 0) - (b.pre.length === 0 ? 1 : 0); + } + for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) { + const x = a.pre[i]; + const y = b.pre[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const nx = /^\d+$/.test(x) ? Number(x) : undefined; + const ny = /^\d+$/.test(y) ? Number(y) : undefined; + if (nx !== undefined && ny !== undefined) { + if (nx !== ny) return nx - ny; + } else if (nx !== undefined) { + return -1; + } else if (ny !== undefined) { + return 1; + } else if (x !== y) { + return x < y ? -1 : 1; + } + } + return 0; +} + +/** The highest of `versions` that satisfies `range`, or `undefined`. */ +export function maxSatisfying(versions: Iterable, range: string): string | undefined { + let best: string | undefined; + for (const v of versions) { + if (satisfies(v, range) && (best === undefined || compareVersions(v, best) > 0)) { + best = v; + } + } + return best; +} + +type Comparator = { readonly op: '<' | '<=' | '>' | '>=' | '='; readonly v: Version }; + +export function satisfies(versionText: string, rangeText: string): boolean { + const version = parseVersion(versionText); + if (!version) { + return false; + } + const range = rangeText.trim(); + if (range === '' || range === '*' || range === 'latest' || range === 'x') { + return version.pre.length === 0; + } + return range.split('||').some((alternative) => { + const comparators = parseAlternative(alternative.trim()); + if (comparators === undefined) { + return false; + } + if (!comparators.every((c) => test(version, c))) { + return false; + } + // A pre-release only satisfies a range one of whose comparators names a + // pre-release on the same major.minor.patch. + return version.pre.length === 0 || comparators.some((c) => c.v.pre.length > 0 + && c.v.major === version.major && c.v.minor === version.minor && c.v.patch === version.patch); + }); +} + +function test(v: Version, c: Comparator): boolean { + const d = compare(v, c.v); + switch (c.op) { + case '<': return d < 0; + case '<=': return d <= 0; + case '>': return d > 0; + case '>=': return d >= 0; + default: return d === 0; + } +} + +/** A partial version: `1`, `1.2`, `1.2.3-pre`, with `x`/`*` for a missing part. */ +interface Partial { + readonly major?: number; + readonly minor?: number; + readonly patch?: number; + readonly pre: readonly string[]; +} + +function parsePartial(text: string): Partial | undefined { + const m = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text); + if (!m) { + return undefined; + } + const part = (s: string | undefined): number | undefined => + s === undefined || /^[xX*]$/.test(s) ? undefined : Number(s); + const major = part(m[1]); + const minor = major === undefined ? undefined : part(m[2]); + const patch = minor === undefined ? undefined : part(m[3]); + return { major, minor, patch, pre: m[4] ? m[4].split('.') : [] }; +} + +const v = (major: number, minor: number, patch: number, pre: readonly string[] = []): Version => + ({ major, minor, patch, pre }); + +function parseAlternative(text: string): Comparator[] | undefined { + const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(text); + if (hyphen) { + const lo = parsePartial(hyphen[1]!); + const hi = parsePartial(hyphen[2]!); + if (!lo || !hi) return undefined; + const out: Comparator[] = [{ op: '>=', v: floor(lo) }]; + if (hi.major === undefined) return out; + if (hi.minor === undefined) return [...out, { op: '<', v: v(hi.major + 1, 0, 0) }]; + if (hi.patch === undefined) return [...out, { op: '<', v: v(hi.major, hi.minor + 1, 0) }]; + return [...out, { op: '<=', v: v(hi.major, hi.minor, hi.patch, hi.pre) }]; + } + const out: Comparator[] = []; + // `>= 1.2` is legal spelling; glue an operator to the version that follows it. + const tokens = text.replace(/(>=|<=|>|<|=)\s+/g, '$1').split(/\s+/).filter((t) => t !== ''); + for (const token of tokens) { + const parsed = comparatorsOf(token); + if (parsed === undefined) return undefined; + out.push(...parsed); + } + return out; +} + +function floor(p: Partial): Version { + return v(p.major ?? 0, p.minor ?? 0, p.patch ?? 0, p.pre); +} + +function comparatorsOf(token: string): Comparator[] | undefined { + const m = /^(\^|~>?|>=|<=|>|<|=)?(.*)$/.exec(token); + if (!m) return undefined; + const op = m[1] ?? ''; + const p = parsePartial(m[2]!); + if (!p) return undefined; + if (p.major === undefined) { + return op === '<' || op === '>' ? [{ op: '<', v: v(0, 0, 0) }] : []; + } + const lo = floor(p); + switch (op) { + case '^': { + if (p.major > 0 || p.minor === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major + 1, 0, 0) }]; + if (p.minor > 0 || p.patch === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(0, p.minor + 1, 0) }]; + return [{ op: '>=', v: lo }, { op: '<', v: v(0, 0, p.patch + 1) }]; + } + case '~': + case '~>': { + if (p.minor === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major + 1, 0, 0) }]; + return [{ op: '>=', v: lo }, { op: '<', v: v(p.major, p.minor + 1, 0) }]; + } + case '>': { + if (p.minor === undefined) return [{ op: '>=', v: v(p.major + 1, 0, 0) }]; + if (p.patch === undefined) return [{ op: '>=', v: v(p.major, p.minor + 1, 0) }]; + return [{ op: '>', v: lo }]; + } + case '>=': return [{ op: '>=', v: lo }]; + case '<': return [{ op: '<', v: lo }]; + case '<=': { + if (p.minor === undefined) return [{ op: '<', v: v(p.major + 1, 0, 0) }]; + if (p.patch === undefined) return [{ op: '<', v: v(p.major, p.minor + 1, 0) }]; + return [{ op: '<=', v: lo }]; + } + default: { + // A bare partial is an x-range: `1` is >=1.0.0 <2.0.0, `1.2` is >=1.2.0 <1.3.0. + if (p.minor === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major + 1, 0, 0) }]; + if (p.patch === undefined) return [{ op: '>=', v: lo }, { op: '<', v: v(p.major, p.minor + 1, 0) }]; + return [{ op: '=', v: lo }]; + } + } +} diff --git a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts index d35d7582d..62845254f 100644 --- a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts @@ -27,6 +27,7 @@ import { TsDecoratorSystem } from '@/enums/typescript/decorators'; import { TsExportedEntityKind } from '@/enums/typescript/exports'; import { TsParseGapKind } from '@/enums/typescript/parse-gaps'; import { TsModuleResolutionMode } from '@/enums/typescript/modules'; +import { resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver'; import { bindSourceFile, BinderResult, nodeId } from '@/parsers/typescript/extractors/ts-binder'; import { TsDeclarationExtractor } from '@/parsers/typescript/extractors/ts-declaration-extractor'; @@ -175,13 +176,16 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF // table. Without this, `Request` in `augmented-base.ts` and `Request` inside // `declare module "./augmented-base"` are two symbols instead of one. resolveModuleHash: (specifier) => { - const resolved = ts.resolveModuleName( + // A Deno import map or workspace member first, exactly as the import rows resolve it. + const deno = resolveDenoSpecifier(specifier, options.absoluteFilePath); + const resolved = deno !== undefined ? undefined : ts.resolveModuleName( specifier, options.absoluteFilePath, options.compilerOptions, ts.sys ); - const fileName = resolved.resolvedModule?.resolvedFileName + const fileName = (deno?.resolvedFileName || undefined) + ?? resolved?.resolvedModule?.resolvedFileName ?? resolveVueSpecifier(specifier, options.absoluteFilePath); const absolute = fileName ? path.normalize(fileName) : ''; const moduleHash = options.projectModuleHashes.get(absolute); diff --git a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts index 5536a84fa..d78882095 100644 --- a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts @@ -4,6 +4,7 @@ import * as ts from 'typescript'; import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; import { TsImportKind, TsImportResolutionKind } from '@/enums/typescript/imports'; +import { denoPackageNameOf, DenoResolutionKind, resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver'; import { nodeId } from '@/parsers/typescript/extractors/ts-binder'; import { resolveVueSpecifier, VUE_EXTENSION } from '@/utils/vue-sfc'; @@ -436,6 +437,30 @@ export class TsImportExtractor { return result; } + /** + * A Deno project's own resolution, tried before the compiler's: an import map, + * a workspace member, a `jsr:` / `npm:` package or a URL. `undefined` for a file + * outside any Deno project and for a specifier Deno resolves as Node would. + */ + private resolveDeno(specifier: string): ResolvedSpecifier | undefined { + const deno = resolveDenoSpecifier(specifier, this.options.absoluteFilePath); + if (deno === undefined) { + return undefined; + } + if (deno.kind === 'BUILTIN') { + return { ...UNRESOLVED, kind: TsImportResolutionKind.BUILTIN_NODE }; + } + const absolute = path.normalize(deno.resolvedFileName); + return { + absolutePath: absolute, + relativePath: this.options.toProjectRelative(absolute), + moduleHash: this.options.projectModuleHashes.get(absolute) ?? '', + kind: DENO_RESOLUTION_KINDS[deno.kind](deno.extension), + extension: deno.extension, + packageName: deno.packageName, + }; + } + private doResolve(specifier: string): ResolvedSpecifier { // A Node builtin, with or without the `node:` prefix. Both forms must be // classified, and the unprefixed form is the common one: `import * as path @@ -447,6 +472,10 @@ export class TsImportExtractor { if (specifier.startsWith('node:') || NODE_BUILTIN_SPECIFIERS.has(specifier)) { return { ...UNRESOLVED, kind: TsImportResolutionKind.BUILTIN_NODE }; } + const deno = this.resolveDeno(specifier); + if (deno !== undefined) { + return deno; + } const resolved = ts.resolveModuleName( specifier, this.options.absoluteFilePath, @@ -498,7 +527,10 @@ export class TsImportExtractor { // import, and a consumer had to re-derive it from the specifier to answer // "which package would close these hops". The name is pure syntax, so it // is filled here whether resolution succeeded or not. - return { ...UNRESOLVED, packageName: packageNameOf(specifier) }; + return { + ...UNRESOLVED, + packageName: denoPackageNameOf(specifier, this.options.absoluteFilePath) ?? packageNameOf(specifier), + }; } return { absolutePath: absolute, @@ -558,6 +590,19 @@ function isDeclarationExtension(extension: string): boolean { || extension === ts.Extension.Dcts; } +/** Each Deno answer as the `resolutionKind` the engine reads. */ +const DENO_RESOLUTION_KINDS: Record TsImportResolutionKind> = { + IMPORT_MAP: () => TsImportResolutionKind.PATHS_ALIAS, + WORKSPACE_MEMBER: () => TsImportResolutionKind.WORKSPACE_PACKAGE, + JSR: () => TsImportResolutionKind.JSR_PACKAGE, + NPM_CACHE: () => TsImportResolutionKind.DENO_NPM_CACHE, + NODE_MODULES: (extension) => isDeclarationExtension(extension) + ? TsImportResolutionKind.NODE_MODULES_TYPES + : TsImportResolutionKind.NODE_MODULES_SOURCE, + REMOTE: () => TsImportResolutionKind.REMOTE_MODULE, + BUILTIN: () => TsImportResolutionKind.BUILTIN_NODE, +}; + interface ResolvedSpecifier { readonly absolutePath: string; readonly relativePath: string; @@ -596,16 +641,25 @@ const NODE_BUILTIN_SPECIFIERS = new Set([ * Node builtins never reach this: they are classified BUILTIN_NODE before * resolution is attempted, and they are not packages to stage. */ -function packageNameOf(specifier: string): string { +function packageNameOf(specifierIn: string): string { + // `npm:zod@^3/v4` and `jsr:@std/path@1/join` name a package after the scheme, + // with a version range glued to it; any other scheme (`https:`, `data:`) names none. + const scheme = /^(?:npm|jsr):\/?/.exec(specifierIn); + if (scheme === null && /^[A-Za-z][A-Za-z0-9+.-]*:/.test(specifierIn)) { + return ''; + } + const specifier = scheme === null ? specifierIn : specifierIn.slice(scheme[0].length); if (specifier === '' || specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes('*')) { return ''; } const segments = specifier.split('/'); + const unversioned = (segment: string | undefined): string => + scheme === null || segment === undefined ? segment ?? '' : segment.replace(/(.)@.*$/, '$1'); if (specifier.startsWith('@')) { - return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : ''; + return segments.length >= 2 ? `${segments[0]}/${unversioned(segments[1])}` : ''; } - return segments[0] ?? ''; + return unversioned(segments[0]); } const UNRESOLVED: ResolvedSpecifier = { diff --git a/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts index de5be2f9d..bf37ec53e 100644 --- a/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts +++ b/parser/src/parsers/typescript/extractors/ts-ir-completeness.ts @@ -179,7 +179,15 @@ export function linkAmbientModuleImports(files: readonly ModuleGraphFacts[]): nu * parser's own column (§4.13 c8), and this stops one hop short of the call * graph — at the module, which is where `type-resolution.dl` takes over. */ -export function linkReExportSources(files: readonly ModuleGraphFacts[]): number { +export function linkReExportSources( + files: readonly ModuleGraphFacts[], + /** + * The project-relative file a NON-relative specifier names from `fromFilePath`, + * for a resolution the specifier alone cannot give: a Deno import map or + * workspace member re-exported by a barrel that never imports it. + */ + resolveBare?: (fromFilePath: string, specifier: string) => string | undefined +): number { const modulesByPath = new Map(); for (const facts of files) { for (const module of facts.modules) { @@ -206,9 +214,14 @@ export function linkReExportSources(files: readonly ModuleGraphFacts[]): number continue; } const viaImport = resolvedBySpecifier.get(exportRow.sourceSpecifier); + const bare = viaImport === undefined && !exportRow.sourceSpecifier.startsWith('.') + ? resolveBare?.(facts.filePath, exportRow.sourceSpecifier) + : undefined; const target = viaImport !== undefined ? modulesByPath.get(viaImport) - : modulesByPath.get(resolveRelative(facts.filePath, exportRow.sourceSpecifier)); + : bare !== undefined + ? modulesByPath.get(stripKnownExtension(bare)) + : modulesByPath.get(resolveRelative(facts.filePath, exportRow.sourceSpecifier)); if (target !== undefined) { exportRow.setResolvedSourceModuleLinkHash(target); linked += 1; diff --git a/parser/src/parsers/typescript/tsconfig-resolver.ts b/parser/src/parsers/typescript/tsconfig-resolver.ts index f36937469..d84fa4479 100644 --- a/parser/src/parsers/typescript/tsconfig-resolver.ts +++ b/parser/src/parsers/typescript/tsconfig-resolver.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import * as ts from 'typescript'; import { TsDecoratorSystem } from '@/enums/typescript/decorators'; +import { denoContextFor } from '@/parsers/typescript/deno/deno-config'; import { TsModuleResolutionMode } from '@/enums/typescript/modules'; /** @@ -151,7 +152,7 @@ export class TsConfigResolver { dir = parent; } - const result: GoverningTsConfig = found ?? { + const result: GoverningTsConfig = found ?? denoGoverned(normalised) ?? { configPath: '', options: DEFAULT_OPTIONS, moduleResolutionMode: TsModuleResolutionMode.NODE10, @@ -269,6 +270,42 @@ export class TsConfigResolver { } } +/** + * A file no tsconfig claims but a `deno.json` governs compiles under Deno's + * defaults, not Node's: ES modules, explicit `.ts` extensions, strict mode, and + * whatever `compilerOptions` the config sets (`jsx`, `jsxImportSource`, + * `experimentalDecorators`, `strict`). `configPath` stays `""`, because no + * tsconfig governs the file and that fact stays visible in `ts_module`. + */ +function denoGoverned(file: string): GoverningTsConfig | undefined { + const context = denoContextFor(file); + if (context === undefined) { + return undefined; + } + const written = { ...context.root.compilerOptions, ...context.member.compilerOptions }; + const converted = ts.convertCompilerOptionsFromJson(written, context.member.dir).options; + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowImportingTsExtensions: true, + noEmit: true, + strict: true, + jsx: ts.JsxEmit.React, + ...converted, + }; + return { + configPath: '', + options, + moduleResolutionMode: TsModuleResolutionMode.BUNDLER, + decoratorSystem: options.experimentalDecorators === true + ? TsDecoratorSystem.LEGACY_EXPERIMENTAL + : TsDecoratorSystem.STANDARD_TC39, + fileNames: new Set(), + references: [], + }; +} + /** * `ts.ModuleResolutionKind` to the schema's token. * diff --git a/parser/src/test/deno-tests.ts b/parser/src/test/deno-tests.ts new file mode 100644 index 000000000..baf89a974 --- /dev/null +++ b/parser/src/test/deno-tests.ts @@ -0,0 +1,452 @@ +/** + * DENO TESTS: module resolution for TypeScript in a Deno project. + * + * npx tsx src/test/deno-tests.ts # everything + * npx tsx src/test/deno-tests.ts --list # what runs, and what it proves + * + * NO DENO, NO NETWORK. Each check builds a throwaway project, and a fake + * `$DENO_DIR` laid out exactly as Deno 2 writes one (remote modules under + * `remote/https//` with the `// denoCacheMetadata=` trailer, + * npm packages unpacked under `npm/registry.npmjs.org//`), then + * runs the real resolver or the real extraction over it. + * + * The expected answers are what `deno info --json` reported for the same layouts + * on Deno 2.9: an import-map key that maps a package also serves its subpaths, a + * member's map wins over the root's, the lockfile's pinned version wins over a + * newer cached one, and a workspace member is reached by its `name` through its + * `exports`. + */ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { extractProject } from '@/extract'; +import { clearDenoCacheState } from '@/parsers/typescript/deno/deno-cache'; +import { clearDenoConfigCache } from '@/parsers/typescript/deno/deno-config'; +import { clearDenoResolutionCache, resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver'; +import { compareVersions, maxSatisfying, satisfies } from '@/parsers/typescript/deno/deno-semver'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { ProjectScanner } from '@/utils/project-scanner'; + +interface Check { + name: string; + proves: string; + rulesOut: string; + run: (tmp: string) => Promise; +} + +function write(root: string, files: Record): string { + for (const [relative, contents] of Object.entries(files)) { + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } + return root; +} + +/** A module in the fake `$DENO_DIR` remote cache, keyed as Deno keys it. */ +function cacheRemote(denoDir: string, url: string, body: string, headers: Record = {}): void { + const u = new URL(url); + const file = path.join(denoDir, 'remote', u.protocol.replace(':', ''), u.hostname, + crypto.createHash('sha256').update(u.pathname + u.search).digest('hex')); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${body}\n// denoCacheMetadata=${JSON.stringify({ headers, url, time: 0 })}`); +} + +/** A jsr package version in the fake cache: its `_meta.json` and every file given. */ +function cacheJsr(denoDir: string, name: string, version: string, exports: Record, + files: Record): void { + const manifest: Record = {}; + for (const [file, body] of Object.entries(files)) { + cacheRemote(denoDir, `https://jsr.io/${name}/${version}/${file}`, body, + { 'content-type': 'text/typescript' }); + manifest[`/${file}`] = { size: body.length, checksum: '' }; + } + cacheRemote(denoDir, `https://jsr.io/${name}/${version}_meta.json`, JSON.stringify({ manifest, exports })); +} + +function cacheJsrIndex(denoDir: string, name: string, versions: Record): void { + cacheRemote(denoDir, `https://jsr.io/${name}/meta.json`, JSON.stringify({ versions })); +} + +/** Points the resolver at a fresh fake `$DENO_DIR` and mirror for one check. */ +function freshEnvironment(tmp: string, name: string): { denoDir: string; mirror: string } { + const denoDir = path.join(tmp, `${name}-deno-dir`); + const mirror = path.join(tmp, `${name}-mirror`); + fs.mkdirSync(denoDir, { recursive: true }); + process.env.DENO_DIR = denoDir; + process.env.AXIOMCODE_DENO_MIRROR = mirror; + clearDenoConfigCache(); + clearDenoCacheState(); + clearDenoResolutionCache(); + return { denoDir, mirror }; +} + +function relativeTo(root: string, file: string | undefined): string { + return file === undefined ? '' : path.relative(root, file).split(path.sep).join('/'); +} + +function expectResolved(root: string, specifier: string, from: string, want: string, kind?: string): string | null { + const got = resolveDenoSpecifier(specifier, path.join(root, from)); + const rel = relativeTo(root, got?.resolvedFileName); + if (rel !== want) { + return `${specifier} from ${from}: resolved to ${rel}, expected ${want}`; + } + if (kind !== undefined && got?.kind !== kind) { + return `${specifier} from ${from}: kind ${got?.kind}, expected ${kind}`; + } + return null; +} + +function firstFailure(results: Array): string | null { + return results.find((r) => r !== null) ?? null; +} + +function readRows(file: string): Record[] { + const lines = fs.readFileSync(file, 'utf-8').split('\n').filter((l) => l !== ''); + const header = lines[0]!.split('\t'); + return lines.slice(1).map((line) => { + const cells = line.split('\t'); + return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ''])); + }); +} + +const CHECKS: Check[] = [ + { + name: 'semver ranges pick what Deno picks', + proves: 'caret, tilde, partial, comparator and || ranges match the npm semantics Deno uses', + rulesOut: 'a resolver that takes the first or newest version regardless of the written range', + run: async () => { + const cases: Array<[string, string, boolean]> = [ + ['1.1.6', '^1.0.0', true], ['2.0.0', '^1.0.0', false], ['0.2.5', '^0.2.1', true], + ['0.3.0', '^0.2.1', false], ['1.2.9', '~1.2.3', true], ['1.3.0', '~1.2.3', false], + ['4.13.13', '4', true], ['4.13.13', '4.12', false], ['3.25.76', '>=3.23 <4', true], + ['1.0.0-rc.1', '^1.0.0', false], ['1.0.0-rc.2', '^1.0.0-rc.1', true], ['5.0.0', '^4 || ^5', true], + ['1.2.3', '1.2.3', true], ['1.2.4', '', true], ['1.5.0', '1.2 - 1.4', false], + ]; + for (const [version, range, want] of cases) { + if (satisfies(version, range) !== want) { + return `satisfies(${version}, "${range}") should be ${want}`; + } + } + if (maxSatisfying(['1.0.0', '1.1.6', '2.0.0'], '^1.0.0') !== '1.1.6') { + return 'maxSatisfying did not pick the highest in-range version'; + } + if (compareVersions('1.0.0-rc.1', '1.0.0') >= 0) { + return 'a pre-release must sort before its release'; + } + return null; + }, + }, + { + name: 'import map: keys, prefixes, package subpaths and scopes', + proves: 'exact keys, the longest `prefix/` key, Deno\'s package-subpath extension, scopes and an external importMap file', + rulesOut: 'a map applied as an exact lookup only, so `@/x.ts` and `hono/cors` stay unresolved', + run: async (tmp) => { + freshEnvironment(tmp, 'map'); + const root = write(path.join(tmp, 'map'), { + 'deno.json': JSON.stringify({ + imports: { '@/': './src/', 'util': './src/util.ts', 'lib/': './lib/', 'lib/deep/': './deeper/' }, + scopes: { './legacy/': { 'util': './legacy/old-util.ts' } }, + importMap: './extra-map.json', + }), + 'extra-map.json': JSON.stringify({ imports: { 'extra': './src/extra.ts' } }), + 'src/main.ts': '', 'src/util.ts': '', 'src/extra.ts': '', 'src/a.ts': '', + 'lib/x.ts': '', 'deeper/y.ts': '', 'legacy/caller.ts': '', 'legacy/old-util.ts': '', + }); + return firstFailure([ + expectResolved(root, '@/a.ts', 'src/main.ts', 'src/a.ts', 'IMPORT_MAP'), + expectResolved(root, 'util', 'src/main.ts', 'src/util.ts'), + expectResolved(root, 'lib/x.ts', 'src/main.ts', 'lib/x.ts'), + expectResolved(root, 'lib/deep/y.ts', 'src/main.ts', 'deeper/y.ts'), + expectResolved(root, 'util', 'legacy/caller.ts', 'legacy/old-util.ts'), + expectResolved(root, 'extra', 'src/main.ts', 'src/extra.ts'), + // Relative specifiers are the compiler's, never the map's. + expectResolved(root, './a.ts', 'src/main.ts', ''), + ]); + }, + }, + { + name: 'workspace: members by name, member map over root map', + proves: 'a member is reached by its name and its export subpaths, and a member\'s own import map wins over the root\'s', + rulesOut: 'a workspace analysed as unrelated programs, or a root map that shadows a member\'s entry', + run: async (tmp) => { + freshEnvironment(tmp, 'ws'); + const root = write(path.join(tmp, 'ws'), { + 'deno.json': JSON.stringify({ workspace: ['./api', './packages/*'], imports: { 'cfg': './root-cfg.ts' } }), + 'root-cfg.ts': '', + 'api/deno.json': JSON.stringify({ name: '@acme/api', imports: { 'cfg': './src/cfg.ts' } }), + 'api/src/main.ts': '', 'api/src/cfg.ts': '', + 'packages/shared/deno.json': JSON.stringify({ name: '@acme/shared', exports: { '.': './mod.ts', './ids': './src/ids.ts' } }), + 'packages/shared/mod.ts': '', 'packages/shared/src/ids.ts': '', + 'packages/other/deno.json': JSON.stringify({ name: '@acme/other', exports: './index.ts' }), + 'packages/other/index.ts': '', + }); + const scanned = await new ProjectScanner().scanForProjects(root); + const tsProjects = scanned.filter((p) => p.language === ProjectLanguage.TYPESCRIPT); + if (tsProjects.length !== 1) { + return `the workspace was discovered as ${tsProjects.length} TypeScript projects, expected 1`; + } + return firstFailure([ + expectResolved(root, '@acme/shared', 'api/src/main.ts', 'packages/shared/mod.ts', 'WORKSPACE_MEMBER'), + expectResolved(root, '@acme/shared/ids', 'api/src/main.ts', 'packages/shared/src/ids.ts'), + expectResolved(root, '@acme/other', 'api/src/main.ts', 'packages/other/index.ts'), + expectResolved(root, 'jsr:@acme/shared@^0.1.0', 'api/src/main.ts', 'packages/shared/mod.ts'), + expectResolved(root, 'cfg', 'api/src/main.ts', 'api/src/cfg.ts'), + expectResolved(root, 'cfg', 'packages/shared/mod.ts', 'root-cfg.ts'), + expectResolved(root, '@acme/shared/missing', 'api/src/main.ts', ''), + ]); + }, + }, + { + name: 'jsr: the locked version, its exports, and what it imports', + proves: 'the lockfile pins the version over a newer cached one, exports name the file, and the mirror receives the module and its transitive imports', + rulesOut: 'resolving to the newest cached version, ignoring exports, or a mirror with only the entry file', + run: async (tmp) => { + const { denoDir, mirror } = freshEnvironment(tmp, 'jsr'); + cacheJsr(denoDir, '@std/path', '1.1.6', { '.': './mod.ts', './join': './join.ts' }, { + 'mod.ts': 'export * from "./join.ts";\nimport { os } from "jsr:@std/internal@^1.0.0/os";\n', + 'join.ts': 'import { assertPath } from "./_common/assert_path.ts";\nexport function join() {}\n', + '_common/assert_path.ts': 'export function assertPath() {}\n', + }); + cacheJsr(denoDir, '@std/path', '1.2.0', { '.': './mod.ts' }, { 'mod.ts': 'export const newer = 1;\n' }); + cacheJsr(denoDir, '@std/internal', '1.0.14', { './os': './os.ts' }, { 'os.ts': 'export const os = "x";\n' }); + const root = write(path.join(tmp, 'jsr'), { + 'deno.json': JSON.stringify({ imports: { '@std/path': 'jsr:@std/path@^1.0.0' } }), + // v5 layout: `jsr` at the top level. + 'deno.lock': JSON.stringify({ version: '5', jsr: { '@std/path@1.1.6': {}, '@std/internal@1.0.14': {} } }), + 'main.ts': '', + }); + const failure = firstFailure([ + expectResolved(root, '@std/path', 'main.ts', relativeTo(root, path.join(mirror, 'jsr.io/@std/path/1.1.6/mod.ts')), 'JSR'), + expectResolved(root, '@std/path/join', 'main.ts', relativeTo(root, path.join(mirror, 'jsr.io/@std/path/1.1.6/join.ts'))), + expectResolved(root, 'jsr:@std/path@^1.0.0/join', 'main.ts', relativeTo(root, path.join(mirror, 'jsr.io/@std/path/1.1.6/join.ts'))), + expectResolved(root, 'jsr:@std/path@^1.0.0/missing', 'main.ts', ''), + expectResolved(root, 'jsr:@std/absent@1', 'main.ts', ''), + ]); + if (failure) { + return failure; + } + for (const copied of ['jsr.io/@std/path/1.1.6/_common/assert_path.ts', 'jsr.io/@std/internal/1.0.14/os.ts', 'deno.lock']) { + if (!fs.existsSync(path.join(mirror, copied))) { + return `the mirror is missing ${copied}`; + } + } + if (fs.existsSync(path.join(mirror, 'jsr.io/@std/path/1.2.0'))) { + return 'a version the lockfile does not pin was copied to the mirror'; + } + return null; + }, + }, + { + name: 'jsr: without a lockfile, the highest cached version in range', + proves: 'with no lockfile the range picks among cached versions, skipping yanked ones and ones never downloaded', + rulesOut: 'refusing every jsr import in a project with "lock": false', + run: async (tmp) => { + const { denoDir, mirror } = freshEnvironment(tmp, 'nolock'); + cacheJsrIndex(denoDir, '@hono/hono', { '4.6.0': {}, '4.7.0': {}, '4.8.0': { yanked: true }, '4.9.0': {}, '5.0.0': {} }); + cacheJsr(denoDir, '@hono/hono', '4.6.0', { '.': './src/index.ts' }, { 'src/index.ts': 'export class Hono {}\n' }); + cacheJsr(denoDir, '@hono/hono', '4.7.0', { '.': './src/index.ts', './cors': './src/cors.ts' }, + { 'src/index.ts': 'export class Hono {}\n', 'src/cors.ts': 'export function cors() {}\n' }); + cacheJsr(denoDir, '@hono/hono', '4.8.0', { '.': './src/index.ts' }, { 'src/index.ts': '' }); + cacheJsr(denoDir, '@hono/hono', '5.0.0', { '.': './src/index.ts' }, { 'src/index.ts': '' }); + const root = write(path.join(tmp, 'nolock'), { + 'deno.jsonc': '{\n // comments and trailing commas, as deno.jsonc allows\n "lock": false,\n "imports": { "hono": "jsr:@hono/hono@^4.6.0", },\n}\n', + 'main.ts': '', + }); + return firstFailure([ + expectResolved(root, 'hono', 'main.ts', relativeTo(root, path.join(mirror, 'jsr.io/@hono/hono/4.7.0/src/index.ts'))), + expectResolved(root, 'hono/cors', 'main.ts', relativeTo(root, path.join(mirror, 'jsr.io/@hono/hono/4.7.0/src/cors.ts'))), + ]); + }, + }, + { + name: 'npm: node_modules first, then Deno\'s npm cache through exports', + proves: 'an npm: import resolves into node_modules when installed there, else into $DENO_DIR/npm through the package\'s exports and types', + rulesOut: 'npm: specifiers left unresolved, or a cache lookup that ignores the package\'s own exports map', + run: async (tmp) => { + const { denoDir } = freshEnvironment(tmp, 'npm'); + const pkg = path.join(denoDir, 'npm', 'registry.npmjs.org', 'zod', '3.25.76'); + write(pkg, { + 'package.json': JSON.stringify({ name: 'zod', version: '3.25.76', exports: { '.': { types: './index.d.ts', import: './index.js' }, './v4': { types: './v4/index.d.ts' } } }), + 'index.d.ts': 'export declare const z: unknown;\n', 'index.js': '', 'v4/index.d.ts': '', + 'v4/package.json': JSON.stringify({ name: 'zod-v4-stub' }), + }); + write(path.join(denoDir, 'npm', 'registry.npmjs.org', 'zod', '4.0.0'), { + 'package.json': JSON.stringify({ name: 'zod', version: '4.0.0', types: './index.d.ts' }), 'index.d.ts': '', + }); + const root = write(path.join(tmp, 'npm'), { + 'deno.json': JSON.stringify({ imports: { 'zod': 'npm:zod@^3.23.0' } }), + 'deno.lock': JSON.stringify({ version: '4', npm: { 'zod@3.25.76': {} } }), + 'main.ts': '', + 'installed/deno.json': '{}', + 'installed/node_modules/left-pad/package.json': JSON.stringify({ name: 'left-pad', types: './index.d.ts' }), + 'installed/node_modules/left-pad/index.d.ts': '', + 'installed/main.ts': '', + }); + return firstFailure([ + expectResolved(root, 'zod', 'main.ts', relativeTo(root, path.join(pkg, 'index.d.ts')), 'NPM_CACHE'), + expectResolved(root, 'zod/v4', 'main.ts', relativeTo(root, path.join(pkg, 'v4/index.d.ts'))), + expectResolved(root, 'npm:zod@4', 'main.ts', + relativeTo(root, path.join(denoDir, 'npm', 'registry.npmjs.org', 'zod', '4.0.0', 'index.d.ts'))), + expectResolved(root, 'npm:left-pad@1', 'installed/main.ts', 'installed/node_modules/left-pad/index.d.ts', 'NODE_MODULES'), + expectResolved(root, 'npm:absent@1', 'main.ts', ''), + ]); + }, + }, + { + name: 'https: redirects, x-typescript-types and extensionless URLs', + proves: 'a URL import follows cached redirects, prefers the declarations a CDN points at, and gets an extension from its content type', + rulesOut: 'URL imports left unresolved, or mirrored under a name the parser walk would skip', + run: async (tmp) => { + const { denoDir, mirror } = freshEnvironment(tmp, 'remote'); + cacheRemote(denoDir, 'https://deno.land/x/oak/mod.ts', '', { location: '/x/oak@v12.6.1/mod.ts' }); + // `export * as` is the re-export form the compiler's light pre-processor does not report. + cacheRemote(denoDir, 'https://deno.land/x/oak@v12.6.1/mod.ts', 'export * as app from "./app.ts";\n', + { 'content-type': 'application/typescript' }); + cacheRemote(denoDir, 'https://deno.land/x/oak@v12.6.1/app.ts', 'export class Application {}\n', + { 'content-type': 'application/typescript' }); + cacheRemote(denoDir, 'https://esm.sh/preact@10.19.0', 'export {}\n', + { 'content-type': 'application/javascript', 'x-typescript-types': 'https://esm.sh/preact@10.19.0/src/index.d.ts' }); + cacheRemote(denoDir, 'https://esm.sh/preact@10.19.0/src/index.d.ts', 'export declare function h(): void;\n', + { 'content-type': 'application/typescript' }); + const root = write(path.join(tmp, 'remote'), { 'deno.json': '{}', 'main.ts': '' }); + const failure = firstFailure([ + expectResolved(root, 'https://deno.land/x/oak/mod.ts', 'main.ts', + relativeTo(root, path.join(mirror, 'deno.land/x/oak@v12.6.1/mod.ts')), 'REMOTE'), + expectResolved(root, 'https://esm.sh/preact@10.19.0', 'main.ts', + relativeTo(root, path.join(mirror, 'esm.sh/preact@10.19.0/src/index.d.ts'))), + expectResolved(root, 'https://deno.land/x/never-downloaded/mod.ts', 'main.ts', ''), + ]); + if (failure) { + return failure; + } + return fs.existsSync(path.join(mirror, 'deno.land/x/oak@v12.6.1/app.ts')) + ? null + : 'a relative import of a mirrored URL module was not copied'; + }, + }, + { + name: 'outside a Deno project nothing changes', + proves: 'with no deno.json above a file the resolver answers nothing, so every other TypeScript project resolves as before', + rulesOut: 'Deno rules leaking into Node projects, such as an unrelated import map or a stray mirror write', + run: async (tmp) => { + const { mirror } = freshEnvironment(tmp, 'node'); + const root = write(path.join(tmp, 'node'), { + 'package.json': JSON.stringify({ name: 'node-app' }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { module: 'NodeNext' } }), + 'src/main.ts': '', + }); + for (const specifier of ['hono', 'npm:zod@3', 'jsr:@std/path', 'https://deno.land/x/oak/mod.ts', '@/a.ts']) { + if (resolveDenoSpecifier(specifier, path.join(root, 'src/main.ts')) !== undefined) { + return `${specifier} was answered in a project with no deno.json`; + } + } + return fs.existsSync(mirror) ? 'a mirror was written for a project with no deno.json' : null; + }, + }, + { + name: 'extraction: import rows, links and the vendor directory', + proves: 'ts_import carries the Deno resolution kinds and module links, unresolved jsr: rows still name their package, and vendor/ is not walked as project source', + rulesOut: 'a resolver that is right in isolation but never reaches the rows the engine reads', + run: async (tmp) => { + const { denoDir } = freshEnvironment(tmp, 'extract'); + cacheJsr(denoDir, '@std/path', '1.1.6', { '.': './mod.ts' }, { 'mod.ts': 'export function join() {}\n' }); + const root = write(path.join(tmp, 'extract'), { + 'deno.json': JSON.stringify({ workspace: ['./api', './shared'], vendor: true, imports: { '@std/path': 'jsr:@std/path@^1.0.0' } }), + 'deno.lock': JSON.stringify({ version: '5', jsr: { '@std/path@1.1.6': {} } }), + 'api/deno.json': JSON.stringify({ name: '@acme/api', imports: { '@/': './src/', 'gone': 'jsr:@std/gone@^1.0.0' } }), + 'api/src/main.ts': [ + 'import { join } from "@std/path";', + 'import { formatUser } from "@acme/shared";', + 'import { audit } from "@/audit.ts";', + 'import { lost } from "gone";', + 'export function main() { audit(formatUser(join())); lost(); }', + ].join('\n'), + 'api/src/audit.ts': 'export function audit(m: string): void {}\n', + 'shared/deno.json': JSON.stringify({ name: '@acme/shared', exports: './mod.ts' }), + 'shared/mod.ts': 'export { formatUser } from "@acme/shared/format";\n', + 'shared/format.ts': 'export function formatUser(n: string): string { return n; }\n', + 'vendor/jsr.io/@std/path/1.1.6/mod.ts': 'export function join() {}\n', + }); + // The member exports its own subpath, which the barrel above re-exports by name. + write(root, { 'shared/deno.json': JSON.stringify({ name: '@acme/shared', exports: { '.': './mod.ts', './format': './format.ts' } }) }); + clearDenoConfigCache(); + const out = path.join(tmp, 'extract-ir'); + await extractProject({ projectPath: root, versionLink: 'v1', outputDir: out, layout: 'per-language' }); + const imports = readRows(path.join(out, 'typescript', 'all-typescript-imports.csv')); + const byPath = new Map(imports.map((r) => [r.importedPath, r])); + const want: Array<[string, string, boolean, string]> = [ + ['@std/path', 'JSR_PACKAGE', false, '@std/path'], + ['@acme/shared', 'WORKSPACE_PACKAGE', true, '@acme/shared'], + ['@/audit.ts', 'PATHS_ALIAS', true, ''], + ['gone', 'UNRESOLVED', false, '@std/gone'], + ]; + for (const [specifier, kind, linked, packageName] of want) { + const row = byPath.get(specifier); + if (row === undefined) { + return `no ts_import row for ${specifier}`; + } + if (row.resolutionKind !== kind) { + return `${specifier}: resolutionKind ${row.resolutionKind}, expected ${kind}`; + } + if ((row.resolvedModuleLinkHash !== '') !== linked) { + return `${specifier}: module link ${row.resolvedModuleLinkHash === '' ? 'missing' : 'present'}, expected ${linked ? 'present' : 'missing'}`; + } + if (row.packageName !== packageName) { + return `${specifier}: packageName "${row.packageName}", expected "${packageName}"`; + } + } + const modules = readRows(path.join(out, 'typescript', 'all-typescript-modules.csv')); + if (modules.some((m) => (m.filePath ?? '').startsWith('vendor/'))) { + return 'vendor/ was walked as project source'; + } + const exports = readRows(path.join(out, 'typescript', 'all-typescript-exports.csv')); + const reExport = exports.find((e) => e.sourceSpecifier === '@acme/shared/format'); + if (reExport === undefined || reExport.resolvedSourceModuleLinkHash === '') { + return 'a barrel re-exporting a workspace member by name was not linked to the member\'s module'; + } + const strictRow = modules.find((m) => m.filePath === 'api/src/main.ts'); + if (strictRow?.moduleResolutionMode !== 'BUNDLER') { + return `a Deno file compiled under ${strictRow?.moduleResolutionMode}, expected BUNDLER`; + } + return null; + }, + }, +]; + +async function main(): Promise { + if (process.argv.includes('--list')) { + for (const c of CHECKS) console.log(`${c.name}\n proves: ${c.proves}\n rules out: ${c.rulesOut}`); + return 0; + } + // Real path: the compiler resolves through symlinks, and macOS's temp dir is one. + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'deno-tests-'))); + const saved = { DENO_DIR: process.env.DENO_DIR, AXIOMCODE_DENO_MIRROR: process.env.AXIOMCODE_DENO_MIRROR }; + let failed = 0; + try { + for (const check of CHECKS) { + let result: string | null; + try { + result = await check.run(tmp); + } catch (e) { + result = `threw: ${(e as Error).stack ?? String(e)}`; + } + console.log(`${result === null ? 'PASS' : 'FAIL'} ${check.name}`); + if (result !== null) { + console.log(` ${result}`); + failed += 1; + } + } + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } + console.log(`${CHECKS.length - failed}/${CHECKS.length} Deno checks passed`); + return failed ? 1 : 0; +} + +void main().then((code) => process.exit(code)); diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index 3c7642595..223e0c1d6 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -25,6 +25,8 @@ import { ModuleGraphFacts, newCompletenessAccumulator, } from '@/parsers/typescript/extractors/ts-ir-completeness'; +import { isDenoVendorDirectory } from '@/parsers/typescript/deno/deno-config'; +import { resolveDenoSpecifier } from '@/parsers/typescript/deno/deno-resolver'; import { moduleHashFor } from '@/parsers/typescript/extractors/ts-module-extractor'; import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; import { extractTsPackageEntries } from '@/parsers/typescript/ts-package-entry-extractor'; @@ -83,6 +85,13 @@ import { export interface SharedWriteContext { readonly writers: Map; readonly writerFor: (filename: string) => TsRelationWriter; + /** + * Every file a program in this run has already taken, normalised. A file no + * config claims can join two programs (one it imports, one that resolves an + * import into it), and a module row is a pure function of its path, so a second + * extraction would write the same primary key twice. The first program keeps it. + */ + readonly analyzedFiles: Set; } export interface TypeScriptAnalysisOptions { @@ -206,6 +215,7 @@ export class TypeScriptProjectAnalyzer { const writers = new Map(); return { writers, + analyzedFiles: new Set(), writerFor: (filename: string): TsRelationWriter => { const existing = writers.get(filename); if (existing) { @@ -268,7 +278,11 @@ export class TypeScriptProjectAnalyzer { // Every emitted path hangs off this, not off rootDir — see pathAnchorFor. const pathAnchor = pathAnchorFor(rootDir, options.baseMservPath); const rootProgram = filesOfRootProgram(rootDir, configResolver); - const files = (rootProgram?.files ?? collectTypeScriptFiles(rootDir, excludes)).sort(); + const files = (rootProgram?.files ?? collectTypeScriptFiles(rootDir, excludes)).sort() + .filter((file) => !shared?.analyzedFiles.has(path.normalize(file))); + for (const file of files) { + shared?.analyzedFiles.add(path.normalize(file)); + } const filesInOtherPrograms = rootProgram?.others.length ?? 0; const nestedProgramRoots = programRootsOf(rootProgram?.others ?? [], configResolver); @@ -505,7 +519,12 @@ export class TypeScriptProjectAnalyzer { // A re-export's source module may be parsed after the file that re-exports // from it, so the link is made here. Still the MODULE graph, and // ts_export.resolvedSourceModuleLinkHash is the parser's own column. - linkReExportSources(moduleGraph); + linkReExportSources(moduleGraph, (fromFilePath, specifier) => { + const deno = resolveDenoSpecifier(specifier, path.join(pathAnchor, fromFilePath)); + return deno === undefined || deno.resolvedFileName === '' + ? undefined + : toRelative(pathAnchor, deno.resolvedFileName); + }); // Reads the accumulated rows and mutates nothing. Cross-file CALL resolution // used to happen here and has been retracted: following an import to a // declaring file is `type-resolution.dl` rewritten in TypeScript. What runs @@ -790,8 +809,10 @@ function filesOfRootProgram( const sf = ts.createSourceFile(file, script.text, ts.ScriptTarget.Latest, false, script.scriptKind ?? (file.endsWith('.tsx') ? ts.ScriptKind.TSX : JS_JOINER.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS)); for (const specifier of importSpecifiersOf(sf)) { - const resolved = ts.resolveModuleName(specifier, file, rootOptions, ts.sys, resolutionCache) - .resolvedModule?.resolvedFileName ?? resolveVueSpecifier(specifier, file); + const resolved = (resolveDenoSpecifier(specifier, file)?.resolvedFileName || undefined) + ?? ts.resolveModuleName(specifier, file, rootOptions, ts.sys, resolutionCache) + .resolvedModule?.resolvedFileName + ?? resolveVueSpecifier(specifier, file); if (resolved !== undefined) { keys.push(path.normalize(resolved)); } @@ -960,7 +981,7 @@ function collectTypeScriptFiles(dir: string, excludes: ReadonlySet, only const full = path.join(current, entry.name); if (entry.isDirectory()) { if (!excludes.has(entry.name) && !entry.name.startsWith('.') && !isGitIgnoredDir(full) - && !isGeneratedOutputDirectory(current, entry.name)) { + && !isGeneratedOutputDirectory(current, entry.name) && !isDenoVendorDirectory(current, entry.name)) { walk(full); } continue; diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index a4f480f51..e01093337 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -102,7 +102,8 @@ once on `index` and kept by every rebuild after it: axiomcode index --library .venv/lib/python3.12/site-packages/requests,/deps/ir/jdk # or name them `auto` finds what the project depends on, in every language: a Python package its source imports, in its virtual -environment (`.venv`, `venv`, `$VIRTUAL_ENV`); a JavaScript / TypeScript package it imports, under `node_modules`; a +environment (`.venv`, `venv`, `$VIRTUAL_ENV`); a JavaScript / TypeScript package it imports, under `node_modules`; in a +Deno project, every `jsr:`, `npm:` and URL module it imports, from Deno's cache (`deno install` fetches them); a Java dependency `pom.xml` or `build.gradle` declares, through its `-sources.jar` in the Maven repository or Gradle's cache, or its class jar decompiled when it ships no sources (needs Vineflower: `mvn dependency:get -Dartifact=org.vineflower:vineflower:1.10.1`); a NuGet package a `.csproj` references, decompiled from its assembly diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index e1fc01047..b58517ab0 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -71,7 +71,9 @@ 'web': WEB_EXT} NAMES = { 'java': ('lombok.config',), - 'typescript': ('package.json',), + # a Deno project's config and lockfile decide what its imports resolve to (its import map, workspace members and + # pinned jsr: / npm: versions), so an edit to either changes the graph as an edit to a source file does + 'typescript': ('package.json', 'deno.json', 'deno.jsonc', 'deno.lock'), 'javascript': ('package.json',), 'python': ('pyproject.toml', 'setup.cfg', 'setup.py'), 'csharp': ('global.json', 'Directory.Build.props'), @@ -258,7 +260,7 @@ def snapshot(repo, lang, src): DEP_FILES = {'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt', 'pyproject.toml', 'setup.cfg', 'setup.py', 'Pipfile', 'Pipfile.lock', 'poetry.lock', 'uv.lock', 'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'gradle.lockfile', 'Directory.Packages.props', - 'packages.lock.json', 'project.assets.json', '.package-lock.json'} + 'packages.lock.json', 'project.assets.json', '.package-lock.json', 'deno.json', 'deno.jsonc', 'deno.lock'} DEP_PRUNE = {'.git', '.axiomcode', '__pycache__', '.venv', 'venv', 'env', '.env', 'dist', 'build', 'target', 'bin', '.gradle'} def dep_snapshot(repo): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py index 622c61c88..00082afdb 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py @@ -70,7 +70,7 @@ def python_sites(repo): return [] -def discover(repo): +def discover(repo, engine=None): found, own = [], {d for d in os.listdir(repo) if os.path.isdir(os.path.join(repo, d))} src = os.path.join(repo, 'src') if os.path.isdir(src): @@ -105,6 +105,7 @@ def discover(repo): if os.path.isdir(d) and not os.path.islink(d): found.append(d) note(f'auto: {len(found) - before} JavaScript / TypeScript package(s) the project imports, from node_modules') + found += deno_libraries(repo, engine) found += java_sources(repo) found += csharp_sources(repo) if not found: @@ -113,6 +114,37 @@ def discover(repo): return found +def has_deno_config(repo): + for root, dirs, files in os.walk(repo): + dirs[:] = [x for x in dirs if x not in SKIP and not x.startswith('.') and x != 'vendor'] + if 'deno.json' in files or 'deno.jsonc' in files: + return True + return False + + +def deno_libraries(repo, engine): + """A Deno project's dependencies: its mirror of the jsr: and URL modules it imports (copied from Deno's cache, + laid out as vendor/ is) and every npm: package directory, as the parser's own resolver finds them.""" + if not engine or not has_deno_config(repo): + return [] + cli = os.path.join(engine, 'parser', 'dist', 'deno-libraries.js') + if not os.path.isfile(cli): + note(f'auto: a Deno project, but {cli} is missing (parser not built); its jsr:, npm: and URL imports are not staged') + return [] + try: + out = subprocess.run(['node', cli, repo], capture_output=True, text=True, timeout=600) + roots = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else None + except (OSError, ValueError, subprocess.TimeoutExpired): + roots = None + if roots is None: + note('auto: Deno dependency discovery failed; its jsr:, npm: and URL imports are not staged') + return [] + found = [d for d in roots.get('mirrors', []) + roots.get('packages', []) if os.path.isdir(d)] + note(f"auto: {len(roots.get('mirrors', []))} Deno mirror(s) of jsr: and URL modules, " + f"{len(roots.get('packages', []))} npm package(s) the Deno project imports") + return found + + def maven_repo(repo): cfg = os.path.join(repo, '.mvn', 'maven.config') if os.path.isfile(cfg): @@ -318,7 +350,7 @@ def main(): cache = CACHE out, seen = [], set() for e in [x for x in spec.split(',') if x]: - entries = discover(repo) if e == 'auto' else [e if os.path.isabs(e) else os.path.join(repo, e)] + entries = discover(repo, engine) if e == 'auto' else [e if os.path.isabs(e) else os.path.join(repo, e)] for d in entries: if not os.path.isdir(d): note(f'not a directory: {d}; not staged'); continue diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ae84f0056..65d012940 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,7 +1,7 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual; ask the graph for what grep cannot know — which declaration a call reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach, context(task, source=True) for how something works as a step-by-step call flow with each step's code. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual; ask the graph for what grep cannot know — which declaration a call reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach, context(task, source=True) for how something works as a step-by-step call flow with each step's code. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Java, TypeScript, Python, JavaScript, C#, and HTML + CSS (impact .class, #id, a custom property as var(…), page.html, sheet.css, page.html:LINE; each language its own graph). --- # axiomcode @@ -102,7 +102,8 @@ once on `index` and kept by every rebuild after it: axiomcode index --library .venv/lib/python3.12/site-packages/requests,/deps/ir/jdk # or name them `auto` finds what the project depends on, in every language: a Python package its source imports, in its virtual -environment (`.venv`, `venv`, `$VIRTUAL_ENV`); a JavaScript / TypeScript package it imports, under `node_modules`; a +environment (`.venv`, `venv`, `$VIRTUAL_ENV`); a JavaScript / TypeScript package it imports, under `node_modules`; in a +Deno project, every `jsr:`, `npm:` and URL module it imports, from Deno's cache (`deno install` fetches them); a Java dependency `pom.xml` or `build.gradle` declares, through its `-sources.jar` in the Maven repository or Gradle's cache, or its class jar decompiled when it ships no sources (needs Vineflower: `mvn dependency:get -Dartifact=org.vineflower:vineflower:1.10.1`); a NuGet package a `.csproj` references, decompiled from its assembly