diff --git a/app/src/test/capabilities.test.ts b/app/src/test/capabilities.test.ts index 64f5fc2..1290497 100644 --- a/app/src/test/capabilities.test.ts +++ b/app/src/test/capabilities.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { existsSync, readdirSync, readFileSync, statSync } from "fs"; import { dirname, join, relative, resolve } from "path"; import { builtinModules } from "module"; +import ts from "typescript"; /** * The capability files are the IPC ACL. Since the AppManifest lockdown, a window can only @@ -14,11 +15,11 @@ import { builtinModules } from "module"; * everything goes through `lib/tauri-commands.ts`, which is the only file allowed to import * `@tauri-apps/api/core` (that rule is what makes this test complete). * - * This is a static, regex-based approximation of a module graph, not a real parser — the - * boundary cases below (namespace imports, re-exports, dynamic imports, path aliases) are each - * handled explicitly rather than left to fall through silently, because a silent fall-through - * here is exactly the shape of gap this test exists to close. Anything this file's heuristics - * cannot make sense of is a thrown error (fail-closed), never a quiet `null`/skip. + * Every file is parsed with the TypeScript compiler (`ts.createSourceFile`), not scanned with + * regexes, so comments, strings, template substitutions and regex literals are the parser's + * problem rather than ours. Anything the walk below cannot account for — a computed `import()`, + * a path alias, a namespace of the wrappers handed around as a value — throws (fail-closed); + * nothing is ever skipped quietly. */ const srcDir = resolve(__dirname, ".."); const capDir = resolve(srcDir, "../src-tauri/capabilities"); @@ -54,283 +55,243 @@ function sourceFiles(dir: string, out: string[] = []): string[] { return out; } -/** `export const NAME = … invoke("command"` → NAME → command. Exactly one literal per wrapper. */ +const rel = (file: string) => relative(srcDir, file); + +function fail(file: string, node: ts.Node | undefined, message: string): never { + const where = node + ? `:${node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line + 1}` + : ""; + throw new Error(`${rel(file)}${where}: ${message}`); +} + +function parse(file: string): ts.SourceFile { + const kind = /\.[jt]sx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + return ts.createSourceFile(file, readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true, kind); +} + +/** Depth-first visit of every node (JSDoc is not a child, so comments never show up). */ +function walk(node: ts.Node, visit: (n: ts.Node) => void) { + visit(node); + ts.forEachChild(node, (child) => walk(child, visit)); +} + +/** Specifiers that reach Tauri's raw `invoke`: `core` itself, and the package root, which + * re-exports it as `core`. Only `lib/tauri-commands.ts` may use either. */ +const INVOKE_SPECIFIER = /^@tauri-apps\/api(\/(core|index)(\.[cm]?js)?)?\/?$/; + +/** Only these are walked as source; any other extension (css, svg, png, json, …) is an asset. */ +const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; +const HAS_EXTENSION = /\.[^./]+$/; +/** A bare npm package name (optionally scoped) followed by an optional subpath. */ +const PACKAGE_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(\/.*)?$/i; + +/** + * Resolves a module specifier to the source file it names, or `null` for something that is not + * part of `src/`'s module graph (a real package, a node builtin, an asset). Everything else + * throws: a path alias, a Vite query suffix (`?worker`, `?raw`), a relative path that leaves + * `src/` or names nothing. + */ +function resolveSpecifier(from: string, spec: string, node: ts.Node): string | null { + if (spec.includes("?") || spec.includes("#")) { + fail(from, node, `import "${spec}" carries a query/fragment suffix this test cannot audit`); + } + if (spec.startsWith("./") || spec.startsWith("../")) { + if (HAS_EXTENSION.test(spec) && !SOURCE_EXTENSION.test(spec)) return null; // a non-source asset + const base = resolve(dirname(from), spec); + const candidates = [base, ...["ts", "tsx", "js", "jsx"].flatMap((e) => [`${base}.${e}`, join(base, `index.${e}`)])]; + if (/\.jsx?$/.test(base)) candidates.push(base.replace(/\.js(x?)$/, ".ts$1"), base.replace(/\.jsx?$/, ".tsx")); + const found = candidates.find((c) => existsSync(c) && statSync(c).isFile()); + if (!found) fail(from, node, `cannot resolve import "${spec}"`); + if (rel(found).startsWith("..")) fail(from, node, `import "${spec}" resolves outside src/ (${found})`); + return found; + } + if (spec.startsWith("node:") || builtinModules.includes(spec)) return null; + const pkg = PACKAGE_NAME.exec(spec)?.[1]; + if (pkg && existsSync(join(nodeModulesDir, pkg, "package.json"))) return null; + fail( + from, + node, + `import "${spec}" is neither relative nor an installed package — likely a path alias. This test ` + + `only understands relative imports and real dependencies; teach it the alias rather than letting ` + + `the file drop out of the closure.`, + ); +} + +const stringLiteralText = (node: ts.Node | undefined) => + node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined; + +interface ModuleFacts { + /** Every module specifier the file names: static imports, `export … from`, literal `import()`. */ + specifiers: string[]; + /** The source files those specifiers resolve to (packages and assets excluded). */ + targets: string[]; + /** Wrapper names the file reaches from `lib/tauri-commands.ts`. */ + wrapperNames: string[]; +} + +/** + * Parses one file and returns its module edges and the wrappers it reaches. Wrapper usage is: + * named imports and named re-exports (by their exported name), and `X.name` / `X?.name` (or + * `typeof X.name` in a type) where `X` is a namespace import of the wrappers. Fails closed on + * everything else that could carry a wrapper: a default import, `export *` / `export * as` of the wrappers, `import()` of them (it + * resolves to the namespace object), a computed `import()`, `require`, `import X = require`, + * `import.meta.glob`, and any reference to a namespace alias other than `X.name`. + * + * Namespace references are matched by identifier text, not symbol: every Identifier spelled `X` + * anywhere in the file must be the object of a property access (or the name *of* one, `o.X`, + * which is not a reference). A local that shadows `X` needs a declaration spelled `X`, and that + * declaration is itself such an Identifier, so shadowing fails closed rather than confusing it. + */ +function analyzeModule(file: string): ModuleFacts { + const sf = parse(file); + const facts: ModuleFacts = { specifiers: [], targets: [], wrapperNames: [] }; + const namespaceAliases = new Map(); + + const edge = (spec: string, node: ts.Node) => { + facts.specifiers.push(spec); + const target = resolveSpecifier(file, spec, node); + if (target) facts.targets.push(target); + return target; + }; + + for (const stmt of sf.statements) { + if (ts.isImportDeclaration(stmt)) { + const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt); + const clause = stmt.importClause; + if (target !== WRAPPERS || !clause) continue; + if (clause.name) fail(file, stmt, "default-imports tauri-commands.ts, which has no default export"); + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) namespaceAliases.set(bindings.name.text, bindings.name); + if (bindings && ts.isNamedImports(bindings)) { + for (const el of bindings.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text); + } + } else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) { + const target = edge(stringLiteralText(stmt.moduleSpecifier)!, stmt); + if (target !== WRAPPERS) continue; + const clause = stmt.exportClause; + if (!clause || ts.isNamespaceExport(clause)) { + fail( + file, + stmt, + `re-exports tauri-commands.ts with "export *${clause ? " as …" : ""}", which cannot be audited — ` + + `re-export wrappers by name (export { wrapperName } from "…/tauri-commands")`, + ); + } + for (const el of clause.elements) facts.wrapperNames.push((el.propertyName ?? el.name).text); + } else if (ts.isImportEqualsDeclaration(stmt) && ts.isExternalModuleReference(stmt.moduleReference)) { + fail(file, stmt, `"import … = require(…)" is not followed by this test; use an ES import`); + } + } + + walk(sf, (node) => { + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const spec = stringLiteralText(node.arguments[0]); + if (spec === undefined || node.arguments.length === 0) { + fail(file, node, "import() with a computed specifier cannot be followed; use a string literal"); + } + if (edge(spec, node) === WRAPPERS) { + fail(file, node, "import() of tauri-commands.ts yields the whole namespace object; import wrappers by name"); + } + } else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") { + fail(file, node, "require() is not followed by this test; use an ES import"); + } else if ( + ts.isPropertyAccessExpression(node) && + ts.isMetaProperty(node.expression) && + node.expression.keywordToken === ts.SyntaxKind.ImportKeyword && + node.name.text.startsWith("glob") + ) { + fail(file, node, "import.meta.glob pulls in modules this test cannot enumerate"); + } else if (ts.isIdentifier(node) && namespaceAliases.has(node.text)) { + if (node === namespaceAliases.get(node.text)) return; // the `import * as X` binding itself + const parent = node.parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) return; // `o.X` — not a reference + if (ts.isPropertyAccessExpression(parent) && parent.expression === node && ts.isIdentifier(parent.name)) { + facts.wrapperNames.push(parent.name.text); + return; + } + // `typeof X.name` in a type: a QualifiedName, type-only, counted anyway (the safe direction). + if (ts.isQualifiedName(parent) && parent.left === node && ts.isTypeQueryNode(parent.parent)) { + facts.wrapperNames.push(parent.right.text); + return; + } + fail( + file, + node, + `"${node.text}" (a namespace import of tauri-commands.ts) is used in \`${parent.getText().slice(0, 60)}\` ` + + `rather than as "${node.text}.wrapperName" — only direct member access can be audited; import ` + + `the wrappers by name instead`, + ); + } + }); + return facts; +} + +/** + * `export const NAME = … invoke("command", …)` → NAME → command, read from the AST: each exported + * const calls `invoke` exactly once, with a string literal. `invoke` must be imported by name from + * `@tauri-apps/api/core` and appear nowhere except as the callee of such a call. + */ function wrapperCommands(): Map { - const text = readFileSync(WRAPPERS, "utf-8"); - const [head, ...chunks] = text.split(/^export const /m); - expect(head.match(/\binvoke(?:<[^>]*>)?\(/g) ?? [], "invoke calls outside an export const wrapper").toHaveLength(0); + const sf = parse(WRAPPERS); const map = new Map(); - for (const chunk of chunks) { - const name = /^(\w+)/.exec(chunk)?.[1] ?? ""; - const calls = chunk.match(/\binvoke(?:<[^>]*>)?\(/g) ?? []; - const literals = [...chunk.matchAll(/\binvoke(?:<[^>]*>)?\(\s*"([a-z_]+)"/g)].map((m) => m[1]); + const callsByWrapper = new Map(); + for (const stmt of sf.statements) { + if (ts.isImportDeclaration(stmt) && INVOKE_SPECIFIER.test(stringLiteralText(stmt.moduleSpecifier)!)) { + const b = stmt.importClause?.namedBindings; + const onlyInvoke = + !stmt.importClause?.name && + b !== undefined && + ts.isNamedImports(b) && + b.elements.every((el) => !el.propertyName && el.name.text === "invoke"); + if (!onlyInvoke) fail(WRAPPERS, stmt, `must import exactly { invoke } from ${stringLiteralText(stmt.moduleSpecifier)}`); + } + if ( + ts.isVariableStatement(stmt) && + stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) && + stmt.declarationList.flags & ts.NodeFlags.Const + ) { + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) fail(WRAPPERS, decl, "an exported wrapper must be a plain `export const NAME`"); + callsByWrapper.set(decl.name.text, []); + } + } + } + walk(sf, (node) => { + if (!ts.isIdentifier(node) || node.text !== "invoke" || ts.isImportSpecifier(node.parent)) return; + const call = node.parent; + if (!ts.isCallExpression(call) || call.expression !== node) { + fail(WRAPPERS, node, "invoke is referenced other than as a direct call"); + } + let decl: ts.Node = call; + while (!(ts.isVariableDeclaration(decl) && decl.parent.parent.parent === sf)) { + decl = decl.parent; + if (decl === sf) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper"); + } + const calls = callsByWrapper.get((decl as ts.VariableDeclaration).name.getText()); + if (!calls) fail(WRAPPERS, call, "invoke is called outside an `export const` wrapper"); + calls.push(call); + }); + for (const [name, calls] of callsByWrapper) { expect(calls, `${name} must call invoke exactly once`).toHaveLength(1); - expect(literals, `${name} must invoke a string literal (a computed name cannot be audited)`).toHaveLength(1); - map.set(name, literals[0]); + const command = stringLiteralText(calls[0].arguments[0]) ?? ""; + expect(command, `${name} must invoke a string literal (a computed name cannot be audited)`).toMatch(/^[a-z_]+$/); + map.set(name, command); } expect(map.size).toBeGreaterThan(100); return map; } -/** A non-relative specifier resolves to a real dependency: a node builtin or a node_modules package. */ -function isRealPackage(spec: string): boolean { - if (builtinModules.includes(spec) || spec.startsWith("node:")) return true; - const parts = spec.split("/"); - const pkgName = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; - return existsSync(resolve(nodeModulesDir, pkgName)); +const factsCache = new Map(); +function factsOf(file: string): ModuleFacts { + let facts = factsCache.get(file); + if (!facts) { + facts = analyzeModule(file); + factsCache.set(file, facts); + } + return facts; } -/** Only .ts/.tsx/.js/.jsx are source; anything else with an extension (css, svg, png, woff, …) is an asset. */ -const SOURCE_EXTENSION = /\.(ts|tsx|js|jsx)$/; -const HAS_EXTENSION = /\.[^./]+$/; - -function resolveRelativeImport(from: string, spec: string): string | null { - if (!spec.startsWith(".")) { - if (isRealPackage(spec)) return null; // a real package — not walked - throw new Error( - `${relative(srcDir, from)}: import "${spec}" is neither relative nor a resolvable node_modules ` + - `package — likely an unhandled path alias. This test only understands relative imports and real ` + - `dependencies; add alias support here rather than letting it drop out of the closure silently.`, - ); - } - if (HAS_EXTENSION.test(spec) && !SOURCE_EXTENSION.test(spec)) return null; // a non-source asset - const base = resolve(dirname(from), spec); - for (const candidate of [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.jsx`, join(base, "index.ts"), join(base, "index.tsx")]) { - if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; - } - throw new Error(`${relative(srcDir, from)}: cannot resolve import "${spec}"`); -} - -const IMPORT_SPEC = /^import\b[^;]*?\bfrom\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm; -/** `export { x } from "…"`, `export * from "…"` and `export * as ns from "…"` — a re-export is a - * module-graph edge too. */ -const EXPORT_FROM_SPEC = /^export\s*(?:\*(?:\s*as\s+\w+)?|(?:type\s*)?\{[^}]*\})\s*from\s*["']([^"']+)["']/gm; -/** A dynamic `import("…")` with a plain string literal argument. Template/computed specs aren't walked. */ -const DYNAMIC_IMPORT_SPEC = /\bimport\(\s*["']([^"']+)["']\s*\)/g; -/** Named or re-exported wrapper identifiers, e.g. `import { x } from ".../tauri-commands"` or - * `export { x } from ".../tauri-commands"` (including a re-export of every wrapper via `export *`, - * handled separately since it names no identifiers). */ -const WRAPPER_IMPORT = /(?:import|export)\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["'][^"']*\/lib\/tauri-commands["']/g; -/** `export * from ".../tauri-commands"` re-exports every wrapper by name. No `g` flag: this is - * used with `.test()`, and a global regex's `.test()` mutates `lastIndex` across calls, which - * would make later files silently skip a real match. */ -const STAR_REEXPORT_WRAPPERS = /^export\s*\*\s*from\s*["'][^"']*\/lib\/tauri-commands["']/m; -/** `export * as ns from ".../tauri-commands"` — a namespace re-export. Resolving `ns`'s downstream - * uses (by whoever imports it from *this* file) is out of reach for a per-file regex scan, so this - * form is fail-closed rather than resolved, same as an unhandled path alias. No `g` flag, same - * `.test()`-reuse reason as STAR_REEXPORT_WRAPPERS above. */ -const NAMESPACE_REEXPORT = /^export\s*\*\s*as\s+\w+\s*from\s*["'][^"']*\/lib\/tauri-commands["']/m; -/** `import * as NS from ".../tauri-commands"` — NS's member accesses must be resolved, not dropped. */ -const NAMESPACE_IMPORT = /^import\s*\*\s*as\s+(\w+)\s*from\s*["']([^"']+)["']/gm; -/** Presence of the `@tauri-apps/api/core` specifier, static or dynamic — this file is the only - * place allowed to reach it. */ -const CORE_IMPORT = /from\s*["']@tauri-apps\/api\/core["']|import\(\s*["']@tauri-apps\/api\/core["']\s*\)/; - -function importSpecs(file: string): string[] { - const text = readFileSync(file, "utf-8"); - return [ - ...[...text.matchAll(IMPORT_SPEC)].map((m) => m[1] ?? m[2]), - ...[...text.matchAll(EXPORT_FROM_SPEC)].map((m) => m[1]), - ...[...text.matchAll(DYNAMIC_IMPORT_SPEC)].map((m) => m[1]), - ]; -} - -/** Scans a double- or single-quoted string starting at `text[start]` (the opening quote) and - * returns the index just past its closing quote, honoring backslash escapes. Throws (fail-closed) - * on an unterminated string rather than silently running to end-of-file. */ -function skipQuoted(text: string, start: number, file: string): number { - const quote = text[start]; - let i = start + 1; - while (i < text.length) { - if (text[i] === "\\") { - i += 2; - continue; - } - if (text[i] === quote) return i + 1; - i++; - } - throw new Error(`${relative(srcDir, file)}: unterminated ${quote === '"' ? "double" : "single"}-quoted string`); -} - -/** Scans a `${...}` substitution's contents starting right after the `${`, tracking nesting of - * `{`/`(`/`[` (and skipping over any string/template/comment inside) so an unrelated `}` doesn't - * end the substitution early. Returns the raw source text of the substitution (not including the - * closing `}`) and the index just past that `}`. Throws (fail-closed) if it runs off the end - * without finding a balancing `}` — an unparseable substitution is not silently ignored. */ -function scanSubstitution(text: string, start: number, file: string): { content: string; endIndex: number } { - let i = start; - let depth = 0; - while (i < text.length) { - const c = text[i]; - if (c === "\\") { - i += 2; - continue; - } - if (c === '"' || c === "'") { - i = skipQuoted(text, i, file); - continue; - } - if (c === "`") { - i = maskTemplate(text, i, file).endIndex; - continue; - } - if (c === "/" && text[i + 1] === "*") { - const end = text.indexOf("*/", i + 2); - if (end === -1) throw new Error(`${relative(srcDir, file)}: unterminated block comment`); - i = end + 2; - continue; - } - if (c === "/" && text[i + 1] === "/") { - const end = text.indexOf("\n", i + 2); - i = end === -1 ? text.length : end; - continue; - } - if (c === "{" || c === "(" || c === "[") { - depth++; - i++; - continue; - } - if (c === ")" || c === "]") { - depth--; - i++; - continue; - } - if (c === "}") { - if (depth === 0) return { content: text.slice(start, i), endIndex: i + 1 }; - depth--; - i++; - continue; - } - i++; - } - throw new Error(`${relative(srcDir, file)}: unterminated template substitution ("\${...}" never closes)`); -} - -/** Masks a backtick template literal starting at `text[start]` (the opening backtick): literal - * template *text* is dropped, but every `${...}` substitution's source is kept — recursively - * stripped of its own comments/strings/nested templates via `stripCommentsAndStrings` — since a - * namespace alias referenced only inside a substitution is passed by reference to whatever - * consumes it (a tagged template hands each substitution to the tag function unstringified, - * exactly like `fn(alias)`) and must stay visible to the occurrence scan. Throws (fail-closed) on - * an unterminated template. */ -function maskTemplate(text: string, start: number, file: string): { result: string; endIndex: number } { - let i = start + 1; - let result = ""; - while (i < text.length) { - const c = text[i]; - if (c === "\\") { - i += 2; - continue; - } - if (c === "`") return { result, endIndex: i + 1 }; - if (c === "$" && text[i + 1] === "{") { - const { content, endIndex } = scanSubstitution(text, i + 2, file); - result += ` ${stripCommentsAndStrings(content, file)} `; - i = endIndex; - continue; - } - i++; - } - throw new Error(`${relative(srcDir, file)}: unterminated template literal`); -} - -/** Best-effort removal of block/line comments and string/template literal *text*, so the - * namespace-usage scan below doesn't trip on the alias appearing in prose or as string data — while - * keeping the source of any `${...}` template substitution intact, recursively, since that's live - * code that can reference the alias. A small hand-rolled scanner, not a full parser: good enough - * for this test's purpose. Throws (fail-closed) on anything it can't make sense of — an - * unterminated string, comment or template — rather than silently truncating. */ -function stripCommentsAndStrings(text: string, file: string): string { - let out = ""; - let i = 0; - while (i < text.length) { - const c = text[i]; - if (c === "/" && text[i + 1] === "*") { - const end = text.indexOf("*/", i + 2); - if (end === -1) throw new Error(`${relative(srcDir, file)}: unterminated block comment`); - i = end + 2; - continue; - } - if (c === "/" && text[i + 1] === "/") { - const end = text.indexOf("\n", i + 2); - i = end === -1 ? text.length : end; - continue; - } - if (c === '"' || c === "'") { - i = skipQuoted(text, i, file); - continue; - } - if (c === "`") { - const masked = maskTemplate(text, i, file); - out += masked.result; - i = masked.endIndex; - continue; - } - out += c; - i++; - } - return out; -} - -/** Every occurrence of a namespace-imported alias, after its import line, must be a plain - * `alias.identifier` member access — `fn(alias)`, `const y = alias`, `alias["name"]`, - * `alias[expr]`, and a tagged-template substitution like `` tag`${alias}` `` all hand the whole - * namespace object somewhere this scan can't follow, which is exactly the silent-smuggling shape - * this test exists to close. Any such occurrence is fail-closed rather than silently accepted. */ -function assertNamespaceUsedAsMemberAccessOnly(file: string, text: string, alias: string, importEnd: number) { - const rest = stripCommentsAndStrings(text.slice(importEnd), file); - const occurrence = new RegExp(`\\b${alias}\\b(\\.[A-Za-z_$][\\w$]*)?`, "g"); - for (const m of rest.matchAll(occurrence)) { - if (!m[1]) { - throw new Error( - `${relative(srcDir, file)}: "${alias}" (a namespace import of tauri-commands.ts) is used as a ` + - `bare value rather than always as "${alias}.wrapperName" — this test can only audit direct ` + - `member access; rewrite as named imports (import { wrapperName } from "…/tauri-commands") so ` + - `wrapper usage stays auditable.`, - ); - } - } -} - -/** Wrapper names a file's code actually reaches: named imports/re-exports, a `export *` of all of - * them, and member accesses through a `import * as NS from ".../tauri-commands"` namespace. - * `strict` additionally fails closed on forms this scan cannot safely resolve — the viewer side of - * the ACL boundary, where a silent miss would be a real escape, uses it; the main-window side (used - * only to build an inclusive usage count, not to bound anything) does not. */ -function wrapperNamesImportedBy(file: string, wrappers: Map, strict: boolean): string[] { - const text = readFileSync(file, "utf-8"); - const named = [...text.matchAll(WRAPPER_IMPORT)].flatMap((m) => - m[1] - .split(",") - .map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()) - .filter((s) => s.length > 0), - ); - const starReexported = STAR_REEXPORT_WRAPPERS.test(text) ? [...wrappers.keys()] : []; - if (strict && NAMESPACE_REEXPORT.test(text)) { - throw new Error( - `${relative(srcDir, file)}: re-exports tauri-commands.ts via "export * as ... from" — this test ` + - `cannot audit a namespace re-export; use named re-exports (export { wrapperName } from ` + - `"…/tauri-commands") instead.`, - ); - } - const namespaceImports = [...text.matchAll(NAMESPACE_IMPORT)].filter( - (m) => resolveRelativeImport(file, m[2]) === WRAPPERS, - ); - if (strict) { - for (const m of namespaceImports) { - assertNamespaceUsedAsMemberAccessOnly(file, text, m[1], m.index + m[0].length); - } - } - const namespaced = namespaceImports.flatMap((m) => { - const alias = m[1]; - const memberAccess = new RegExp(`\\b${alias}\\.(\\w+)\\b`, "g"); - return [...text.matchAll(memberAccess)].map((mm) => mm[1]); - }); - return [...named, ...starReexported, ...namespaced]; -} - -/** Transitive relative-import closure from the viewer entry, following static imports, dynamic - * `import()`, and `export … from` re-exports alike. */ +/** Transitive closure from the viewer entry over static imports, `export … from` and `import()`. */ function viewerClosure(): Set { const seen = new Set(); const queue = [VIEWER_ENTRY]; @@ -338,10 +299,7 @@ function viewerClosure(): Set { const file = queue.pop()!; if (seen.has(file)) continue; seen.add(file); - for (const spec of importSpecs(file)) { - const target = resolveRelativeImport(file, spec); - if (target && !seen.has(target)) queue.push(target); - } + for (const target of factsOf(file).targets) if (!seen.has(target)) queue.push(target); } return seen; } @@ -354,8 +312,8 @@ describe("capability files match the code each window runs", () => { it("only lib/tauri-commands.ts imports @tauri-apps/api/core", () => { const offenders = files .filter((f) => f !== WRAPPERS) - .filter((f) => CORE_IMPORT.test(readFileSync(f, "utf-8"))) - .map((f) => relative(srcDir, f)); + .filter((f) => factsOf(f).specifiers.some((s) => INVOKE_SPECIFIER.test(s))) + .map(rel); expect(offenders).toEqual([]); }); @@ -386,9 +344,9 @@ describe("capability files match the code each window runs", () => { expect(closure.has(WRAPPERS), "the viewer reaches tauri-commands.ts").toBe(true); const viewerCommands = new Set(); for (const file of closure) { - for (const name of wrapperNamesImportedBy(file, wrappers, true)) { + for (const name of factsOf(file).wrapperNames) { const command = wrappers.get(name); - expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined(); + expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined(); viewerCommands.add(command!); } } @@ -402,9 +360,9 @@ describe("capability files match the code each window runs", () => { const mainCommands = new Set(); for (const file of files) { if (closure.has(file)) continue; - for (const name of wrapperNamesImportedBy(file, wrappers, false)) { + for (const name of factsOf(file).wrapperNames) { const command = wrappers.get(name); - expect(command, `${relative(srcDir, file)} imports unknown wrapper ${name}`).toBeDefined(); + expect(command, `${rel(file)} imports unknown wrapper ${name}`).toBeDefined(); mainCommands.add(command!); } }