* 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>
| M | deno.json | 2 | +- |
| A | package/ | 157 | ++++++++++++++++++++++++++++++ |
| M | package/ | 1 | + |
| M | package/ | 8 | +- |
| M | package/ | 2 | +- |
| M | package/ | 2 | +- |
@@ -1,6 +1,6 @@
11 {22 "name": "@nzip/lofi",3- "version": "0.8.0",3+ "version": "0.8.1",44 "license": "MIT",55 "description": "A local-first Deno PWA framework with durable local data, recoverable accounts, optional Jazz sync, mutations with sync-boundary effects, and private, direct-share, or group access templates.",66 "exports": {@@ -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+});@@ -212,6 +212,7 @@ export default defineConfig({
212212 { find: /^npm:preact@[^/]+\\/jsx-runtime$/, replacement: "preact/jsx-runtime" },213213 { find: /^npm:preact@[^/]+$/, replacement: "preact" },214214 { find: /^npm:@noble\\/ciphers@[^/]+\\/chacha$/, replacement: "@noble/ciphers/chacha" },215+ { find: /^npm:@noble\\/curves@[^/]+\\/ed25519$/, replacement: "@noble/curves/ed25519" },215216 { find: /^npm:@noble\\/hashes@[^/]+\\/hkdf$/, replacement: "@noble/hashes/hkdf" },216217 { find: /^npm:@noble\\/hashes@[^/]+\\/sha2$/, replacement: "@noble/hashes/sha2" },217218 { find: /^npm:jazz-tools@[^/]+\\/passkey-backup$/, replacement: "jazz-tools/passkey-backup" },@@ -99,9 +99,9 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
9999 );100100 }101101 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));102- assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.8.0");103- assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.8.0/");104- assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.8.0/testing");102+ assertEquals(config.imports["@nzip/lofi"], "jsr:@nzip/lofi@0.8.1");103+ assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.8.1/");104+ assertEquals(config.imports["@nzip/lofi/testing"], "jsr:@nzip/lofi@0.8.1/testing");105105 const lofiSpecifiers = Object.entries(config.imports)106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));@@ -110,7 +110,7 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
110110 `expected one package prefix, access, schema, two integrations, six commands, seven recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113- lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.8.0/")),113+ lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.8.1/")),114114 "generated lofi commands do not resolve through one exact package version",115115 );116116 const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "0bc0d0487bd2da6645ae2d43960e04fe3eb80d597b4e29fc259166c66673d8fa",4+ "deno.json": "bdba720d3409da4a00e34fa550eb5597381419a5fcaff42d9d5660f5b7a65767",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -1,5 +1,5 @@
11 /** The single source version for generated @nzip/lofi references. */2-export const LOFI_VERSION = "0.8.0";2+export const LOFI_VERSION = "0.8.1";33 44 /** The exact JSR package prefix emitted into generated projects. */55 export const LOFI_PACKAGE_PREFIX = `jsr:@nzip/lofi@${LOFI_VERSION}/`;