clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

release: 0.8.1 — resolve @noble/curves in generated apps (#174)

FelineStateMachine committed 0add918
* release: 0.8.1 — resolve @noble/curves in generated apps

The shared-field crypto vendored into generated projects imports
npm:@noble/curves@<v>/ed25519, but the generated Astro config carried no
alias for it, so a clean-room app's Vite dev server could not resolve the
module and the 0.8.0 registry golden gate failed. The config already
materializes the package at config time; this adds the missing alias line
beside the other noble subpaths. Verified by quarantining the workspace
node_modules copy and running the full golden journey.

* test(astro): pin vendored-import alias coverage statically

Renders the generated Astro config, inventories every import the vendored
.lofi/ tree can produce, resolves each through the root import map, and
asserts two invariants: the alias table matches every rewritten npm:/jsr:
form (both bare and published shapes for self-imports), and every vendored
npm dependency is materialized by a config-time import. Either half missing
previously surfaced only in a clean-room generated app after publish; both
now fail deno task check at PR time.
commit
0add918b719cb4564ae5781e60d45ba264e51762
parent
f606c9f4005551f9a0093f47e827030a094152f5
author
FelineStateMachine <dami.etoile@gmail.com>
6 files changed, +165 -7
Mdeno.json2+-
Apackage/astro/alias_coverage_test.ts157++++++++++++++++++++++++++++++
Mpackage/astro/mod.ts1+
Mpackage/create_core_test.ts8+-
Mpackage/testdata/starter.snapshot.json2+-
Mpackage/version.ts2+-
M deno.json +1 -1
A package/astro/alias_coverage_test.ts +157 -0
@@ -0,0 +1,157 @@
1+import { prepareLofiAstroConfig } from "./mod.ts";2+3+// The vendored package sources under .lofi/ are served by Vite, not Deno:4+// when a generated project resolves them from the published package, every5+// mapped import has been rewritten to a fully qualified npm:/jsr: specifier,6+// and only the alias table in the generated Astro config maps those back to7+// resolvable packages. A vendored import without an alias therefore works in8+// every workspace-adjacent test and fails only in a clean-room generated9+// app. These tests pin the invariant statically: every specifier the10+// vendored tree can produce must be covered by the alias table, and every11+// aliased npm package must be materialized at config time.12+13+function assert(condition: unknown, message: string): asserts condition {14+ if (!condition) throw new Error(message);15+}16+17+type AliasTable = { regexes: RegExp[]; literals: string[] };18+19+async function walkFiles(directory: string, collected: string[]): Promise<void> {20+ for await (const entry of Deno.readDir(directory)) {21+ const path = `${directory}/${entry.name}`;22+ if (entry.isDirectory) await walkFiles(path, collected);23+ else if (path.endsWith(".ts") || path.endsWith(".tsx")) collected.push(path);24+ }25+}26+27+function importSpecifiers(source: string): string[] {28+ const found = new Set<string>();29+ const statements = [30+ /^\s*import\s+[^"'\n]*?from\s*"([^"\n]+)"/gm,31+ /^\s*export\s+[^"'\n]*?from\s*"([^"\n]+)"/gm,32+ /^\s*import\s*"([^"\n]+)"/gm,33+ /import\(\s*"([^"\n]+)"\s*\)/g,34+ ];35+ for (const pattern of statements) {36+ for (const match of source.matchAll(pattern)) found.add(match[1]);37+ }38+ return [...found];39+}40+41+function aliasTable(configSource: string): AliasTable {42+ const regexes = [...configSource.matchAll(/find:\s*\/(.+?)\/,/g)]43+ .map((match) => new RegExp(match[1]));44+ const literals = [...configSource.matchAll(/find:\s*"([^"\n]+)"/g)]45+ .map((match) => match[1]);46+ return { regexes, literals };47+}48+49+function covered(table: AliasTable, specifier: string): boolean {50+ return table.literals.includes(specifier) ||51+ table.regexes.some((regex) => regex.test(specifier));52+}53+54+/** Resolves a bare specifier the way the root import map (and JSR) would. */55+function mapSpecifier(imports: Record<string, string>, specifier: string): string | undefined {56+ const exact = imports[specifier];57+ if (exact !== undefined) return exact;58+ const scopeEnd = specifier.startsWith("@") ? specifier.indexOf("/") + 1 : 0;59+ const slash = specifier.indexOf("/", scopeEnd);60+ if (slash < 0) return undefined;61+ const base = imports[specifier.slice(0, slash)];62+ if (base === undefined) return undefined;63+ return base + specifier.slice(slash);64+}65+66+async function vendoredInventory(): Promise<67+ { table: AliasTable; configModule: string; bySpecifier: Map<string, string[]> }68+> {69+ const root = await Deno.makeTempDir({ dir: ".", prefix: ".lofi-alias-test-" });70+ try {71+ const configPath = await prepareLofiAstroConfig({ root });72+ const table = aliasTable(await Deno.readTextFile(configPath));73+ const files: string[] = [];74+ await walkFiles(`${root}/.lofi/package`, files);75+ const bySpecifier = new Map<string, string[]>();76+ for (const file of files.sort()) {77+ const relative = file.slice(root.length + 1);78+ for (const specifier of importSpecifiers(await Deno.readTextFile(file))) {79+ if (specifier.startsWith(".")) continue;80+ const users = bySpecifier.get(specifier) ?? [];81+ users.push(relative);82+ bySpecifier.set(specifier, users);83+ }84+ }85+ const configModule = await Deno.readTextFile(86+ new URL("./mod.ts", import.meta.url),87+ );88+ return { table, configModule, bySpecifier };89+ } finally {90+ await Deno.remove(root, { recursive: true });91+ }92+}93+94+const rootImports = (JSON.parse(95+ await Deno.readTextFile(new URL("../../deno.json", import.meta.url)),96+) as { imports: Record<string, string> }).imports;97+98+Deno.test("every vendored import is covered by the generated alias table", async () => {99+ const { table, bySpecifier } = await vendoredInventory();100+ assert(bySpecifier.size > 0, "vendored tree produced no imports; the scan is broken");101+ const misses: string[] = [];102+ for (const [specifier, users] of bySpecifier) {103+ const sample = users.slice(0, 3).join(", ");104+ if (specifier.startsWith("@nzip/lofi")) {105+ // Self-imports stay bare in development and become jsr:@nzip/lofi@<v>106+ // in the published package; the table must cover both forms.107+ const subpath = specifier.slice("@nzip/lofi".length);108+ if (!table.literals.includes(specifier)) {109+ misses.push(`${specifier} has no bare alias (used by ${sample})`);110+ }111+ if (!covered(table, `jsr:@nzip/lofi@9.9.9${subpath}`)) {112+ misses.push(`${specifier} has no jsr:-form alias (used by ${sample})`);113+ }114+ continue;115+ }116+ const mapped = mapSpecifier(rootImports, specifier);117+ if (mapped === undefined || mapped.startsWith("node:")) continue;118+ if (mapped.startsWith("./") || mapped.startsWith("../")) continue;119+ if (!covered(table, mapped)) {120+ misses.push(121+ `${specifier} rewrites to ${mapped} in the published package but no alias matches it ` +122+ `(used by ${sample})`,123+ );124+ }125+ }126+ assert(127+ misses.length === 0,128+ `vendored imports without alias coverage; add matching { find, replacement } entries ` +129+ `(and a config-time import) in package/astro/mod.ts:\n ${misses.join("\n ")}`,130+ );131+});132+133+Deno.test("aliased npm packages used by vendored sources materialize at config time", async () => {134+ const { configModule, bySpecifier } = await vendoredInventory();135+ // preact reaches node_modules through the generated app's own islands and136+ // jazz-tools through the config's jazzPlugin import; everything else must137+ // be imported by the config module itself so npm materializes it.138+ const materializedElsewhere = ["preact", "jazz-tools"];139+ const misses: string[] = [];140+ for (const specifier of bySpecifier.keys()) {141+ if (specifier.startsWith("@nzip/lofi")) continue;142+ const mapped = mapSpecifier(rootImports, specifier);143+ if (mapped === undefined || !mapped.startsWith("npm:")) continue;144+ const packageName = specifier.startsWith("@")145+ ? specifier.split("/").slice(0, 2).join("/")146+ : specifier.split("/")[0];147+ if (materializedElsewhere.includes(packageName)) continue;148+ if (!configModule.includes(`import "${specifier}";`)) {149+ misses.push(specifier);150+ }151+ }152+ assert(153+ misses.length === 0,154+ `vendored npm imports without a config-time materialization import in ` +155+ `package/astro/mod.ts:\n ${misses.join("\n ")}`,156+ );157+});
M package/astro/mod.ts +1 -0
M package/create_core_test.ts +4 -4
M package/testdata/starter.snapshot.json +1 -1
M package/version.ts +1 -1