@mosetta/build 0.0.0-stage → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ import { path } from './nodeApi.mjs';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { depPathToFilename } from '@pnpm/dependency-path';
4
+
5
+ /**
6
+ * Package catalog scanner: LOCKFILE scan + CODE scan, portable.
7
+ *
8
+ * The input is exactly what a scanner isolate would have: a file port under which the
9
+ * distributive sources and the lockfile live. No node_modules traversal, no `createRequire`,
10
+ * no direct disk access: the "(importer, name) → package dir" edges are read from the lock,
11
+ * the narrowing seeds come from bare specifiers in the sources and from `mosetta.json`, and the
12
+ * closure is completed over the lock edges without reading third-party code. In dev the CLI
13
+ * calls this same scanner before every build iteration.
14
+ *
15
+ * The package-manager extension point is the LOCK ADAPTER (contract: "(port, lock dir) →
16
+ * edges + devNames"); narrowing, seeds and the devDependencies policy on top of it are
17
+ * manager-agnostic. Supported dialects are those whose lock carries the layout itself:
18
+ * - pnpm-lock v9 — YAML parsed by the `yaml` package, `.pnpm` directory names produced by
19
+ * the native `@pnpm/dependency-path` (the same code pnpm itself uses);
20
+ * - package-lock v2/v3 (npm) — the layout is written directly into the `packages` keys
21
+ * (`node_modules/foo/node_modules/react`), walk-up follows those same keys.
22
+ * yarn/bun — deferred: their lock does NOT carry the layout (the hoister computes it at
23
+ * install time); dev support will come through indexer tables rather than a hoister
24
+ * reimplementation.
25
+ */
26
+
27
+ const MAX_WALK_UP = 50;
28
+ // pnpm collapses a long peer suffix into a hash starting at this length — pnpm's own default
29
+ const PEER_DIR_MAX_LENGTH = 120;
30
+
31
+ // Dialects, in order of preference when both exist in one directory: pnpm first — it is our
32
+ // native manager and its lock is the most precise (versioned paths instead of hoisting).
33
+ const LOCK_DIALECTS = [
34
+ { file: 'pnpm-lock.yaml', read: readPnpmLock },
35
+ { file: 'package-lock.json', read: readNpmLock },
36
+ ];
37
+ // recognized but unsupported locks — so the error names the manager instead of "nothing found"
38
+ const KNOWN_UNSUPPORTED_LOCKS = { 'yarn.lock': 'yarn', 'bun.lock': 'bun', 'bun.lockb': 'bun' };
39
+
40
+ // ALL locks up the chain (nearest to farthest): a nested project carries its own lock, but its
41
+ // resolution still sees the parent one — same directionality as node_modules walk-up.
42
+ function findLocks(fileSystem, startDir) {
43
+ const locks = [];
44
+ let dir = startDir;
45
+ for (let i = 0; i < MAX_WALK_UP; i++) {
46
+ const dialect = LOCK_DIALECTS.find((d) => fileSystem.exists(path.join(dir, d.file)));
47
+ if (dialect) locks.push({ dir, read: dialect.read });
48
+ const parent = path.dirname(dir);
49
+ if (parent === dir) break;
50
+ dir = parent;
51
+ }
52
+ return locks;
53
+ }
54
+
55
+ // snapshot depPath → package dir in .pnpm ('react-dom@19.2.0(react@19.2.0)' → …/react-dom)
56
+ function snapshotDirOf(lockfileDir, depPath) {
57
+ const cut = depPath.indexOf('@', 1); // for scoped packages the first @ is part of the name
58
+ return path.join(
59
+ lockfileDir, 'node_modules', '.pnpm',
60
+ depPathToFilename(depPath, PEER_DIR_MAX_LENGTH),
61
+ 'node_modules', depPath.slice(0, cut),
62
+ );
63
+ }
64
+
65
+ // 'react-dom@19.2.0(react@19.2.0)' → dir in .pnpm; 'link:../../packages/x' → workspace dir
66
+ function targetDirOf(lockfileDir, importerDir, name, version) {
67
+ if (version.startsWith('link:')) return path.resolve(importerDir, version.slice('link:'.length));
68
+ return path.join(
69
+ lockfileDir, 'node_modules', '.pnpm',
70
+ depPathToFilename(`${name}@${version}`, PEER_DIR_MAX_LENGTH),
71
+ 'node_modules', name,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * ALL edges of all locks up the chain (dialect adapters). devDependencies of importers do
77
+ * enter the edges but are flagged in `devNames` — the policy (dev sees them, prod rejects
78
+ * with an explanation) is applied by scanCatalog.
79
+ */
80
+ export function loadLockEdges(fileSystem, startDir) {
81
+ const locks = findLocks(fileSystem, startDir);
82
+ if (!locks.length) {
83
+ // before saying "no lock at all", look for a lock of an unsupported manager — an error
84
+ // that names it is more useful than a generic one
85
+ let dir = startDir;
86
+ for (let i = 0; i < MAX_WALK_UP; i++) {
87
+ const found = Object.keys(KNOWN_UNSUPPORTED_LOCKS).find((f) => fileSystem.exists(path.join(dir, f)));
88
+ if (found) {
89
+ throw new Error(`found ${found} (${dir}), but ${KNOWN_UNSUPPORTED_LOCKS[found]} is not supported: `
90
+ + `its lock does not carry the package layout. Supported: pnpm (pnpm-lock.yaml v9) and npm `
91
+ + `(package-lock.json v2/v3); other managers will come through the indexer`);
92
+ }
93
+ const parent = path.dirname(dir);
94
+ if (parent === dir) break;
95
+ dir = parent;
96
+ }
97
+ throw new Error(`lockfile not found upwards from ${startDir} `
98
+ + `(supported: ${LOCK_DIALECTS.map((d) => d.file).join(', ')})`);
99
+ }
100
+ const edges = {}; // importerPkgDir -> { name -> pkgDir }
101
+ const devNames = {}; // importerPkgDir -> { name: true } — declared ONLY in devDependencies
102
+ for (const { dir, read } of locks) read(fileSystem, dir, edges, devNames);
103
+ return { lockfileDirs: locks.map((l) => l.dir), edges, devNames };
104
+ }
105
+
106
+ // --- pnpm-lock v9 adapter ------------------------------------------------------------------
107
+
108
+ function readPnpmLock(fileSystem, lockfileDir, edges, devNames) {
109
+ const lock = parseYaml(fileSystem.readFile(path.join(lockfileDir, 'pnpm-lock.yaml'), 'utf8'));
110
+ if (!String(lock.lockfileVersion ?? '').startsWith('9')) {
111
+ throw new Error(`unsupported pnpm-lock version ${lock.lockfileVersion} (MVP: v9)`);
112
+ }
113
+ for (const [rel, entry] of Object.entries(lock.importers ?? {})) {
114
+ const dir = path.resolve(lockfileDir, rel);
115
+ const bucket = (edges[dir] ??= {});
116
+ for (const section of ['dependencies', 'optionalDependencies']) {
117
+ for (const [name, spec] of Object.entries(entry?.[section] ?? {})) {
118
+ bucket[name] ??= targetDirOf(lockfileDir, dir, name, spec.version);
119
+ }
120
+ }
121
+ for (const [name, spec] of Object.entries(entry?.devDependencies ?? {})) {
122
+ if (name in bucket) continue;
123
+ bucket[name] = targetDirOf(lockfileDir, dir, name, spec.version);
124
+ (devNames[dir] ??= {})[name] = true;
125
+ }
126
+ }
127
+ // bundledDependencies: the package SHIPS the dependency inside itself
128
+ // (`<pkg>/node_modules/<name>`) — it has no snapshot edges, but the lock does declare it
129
+ // and the layout is fixed by convention. lightningcss-wasm (napi-wasm inside) lives this way.
130
+ for (const [depPath, meta] of Object.entries(lock.packages ?? {})) {
131
+ const bundled = meta?.bundledDependencies;
132
+ if (!Array.isArray(bundled) || !bundled.length) continue;
133
+ const dir = snapshotDirOf(lockfileDir, depPath);
134
+ const bucket = (edges[dir] ??= {});
135
+ for (const bundledName of bundled) {
136
+ bucket[bundledName] ??= path.join(dir, 'node_modules', bundledName);
137
+ }
138
+ }
139
+ for (const [depPath, snap] of Object.entries(lock.snapshots ?? {})) {
140
+ const dir = snapshotDirOf(lockfileDir, depPath);
141
+ const bucket = (edges[dir] ??= {});
142
+ for (const section of ['dependencies', 'optionalDependencies']) {
143
+ for (const [depName, depVersion] of Object.entries(snap?.[section] ?? {})) {
144
+ bucket[depName] ??= targetDirOf(lockfileDir, dir, depName, depVersion);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // --- package-lock v2/v3 (npm) adapter ------------------------------------------------------
151
+
152
+ // npm writes the layout DIRECTLY into the `packages` keys (`node_modules/foo/node_modules/react`),
153
+ // so resolution walk-up replays over those same keys: the nearest `<prefix>/node_modules/<name>`
154
+ // upwards from the importer's location. Workspaces come as a key pair: the alias
155
+ // `node_modules/<name>` with `link: true` plus the real package path.
156
+ function readNpmLock(fileSystem, lockfileDir, edges, devNames) {
157
+ const lock = fileSystem.readJson(path.join(lockfileDir, 'package-lock.json'));
158
+ const packages = lock.packages;
159
+ if (!packages) {
160
+ throw new Error(`unsupported package-lock version ${lock.lockfileVersion} (need v2/v3 with a "packages" map)`);
161
+ }
162
+ const dirOf = (location) => path.resolve(lockfileDir, location);
163
+ const targetOf = (location, name) => {
164
+ let prefix = location;
165
+ for (;;) {
166
+ const key = `${prefix ? `${prefix}/` : ''}node_modules/${name}`;
167
+ const entry = packages[key];
168
+ if (entry) return dirOf(entry.link && entry.resolved ? entry.resolved : key);
169
+ if (!prefix) return null; // not installed (e.g. an optional peer)
170
+ const cut = prefix.lastIndexOf('/node_modules/');
171
+ prefix = cut >= 0 ? prefix.slice(0, cut) : '';
172
+ }
173
+ };
174
+ for (const [location, entry] of Object.entries(packages)) {
175
+ if (entry?.link) continue; // workspace alias — the real path acts as the importer
176
+ const dir = dirOf(location);
177
+ const bucket = (edges[dir] ??= {});
178
+ // peerDependencies participate in the edges: npm installs peers (v7+) but declares them
179
+ // in a separate section; an uninstalled optional peer drops out by having no target
180
+ for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) {
181
+ for (const name of Object.keys(entry?.[section] ?? {})) {
182
+ if (name in bucket) continue;
183
+ const target = targetOf(location, name);
184
+ if (target) bucket[name] = target;
185
+ }
186
+ }
187
+ // devDependencies are installed only for the root and workspaces — nested node_modules have none
188
+ if (!location.includes('node_modules')) {
189
+ for (const name of Object.keys(entry?.devDependencies ?? {})) {
190
+ if (name in bucket) continue;
191
+ const target = targetOf(location, name);
192
+ if (target) {
193
+ bucket[name] = target;
194
+ (devNames[dir] ??= {})[name] = true;
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ // --- code scan: greedy specifier collection ------------------------------------------------
202
+
203
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
204
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
205
+ // Greedy lexer walk: precision only affects the SIZE of the closure, not correctness —
206
+ // rebuilding extra packages is cheap, and a miss surfaces as a resolve error at build time.
207
+ const SPECIFIER_RE = /\bfrom\s*['"]([^'"\n]+)['"]|\bimport\s*\(?\s*['"]([^'"\n]+)['"]|\brequire\s*\(\s*['"]([^'"\n]+)['"]/g;
208
+
209
+ function packageNameOf(specifier) {
210
+ const parts = specifier.split('/');
211
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
212
+ }
213
+
214
+ /** Build config file name: its imports are TOOLING and get their own closure (see below). */
215
+ export const BUILD_CONFIG_FILE = 'mosetta.build.mjs';
216
+
217
+ /** Bare specifiers of a single file. */
218
+ function specifiersOfFile(fileSystem, file) {
219
+ const found = new Set();
220
+ let text;
221
+ try {
222
+ text = fileSystem.readFile(file, 'utf8');
223
+ } catch {
224
+ return found;
225
+ }
226
+ for (const m of text.matchAll(SPECIFIER_RE)) {
227
+ const spec = m[1] ?? m[2] ?? m[3];
228
+ if (spec && !spec.startsWith('.') && !path.isAbsolute(spec) && !spec.startsWith('node:')) found.add(spec);
229
+ }
230
+ return found;
231
+ }
232
+
233
+ /**
234
+ * Bare specifiers of all sources under rootDir (recursive, skipping node_modules/dist).
235
+ * `skipFiles` — file names whose imports do NOT enter this closure (the build config).
236
+ */
237
+ export function scanSourceSpecifiers(fileSystem, rootDir, { skipFiles = new Set() } = {}) {
238
+ const found = new Set();
239
+ const walk = (dir) => {
240
+ let entries;
241
+ try {
242
+ entries = fileSystem.readDir(dir);
243
+ } catch {
244
+ return;
245
+ }
246
+ for (const e of entries) {
247
+ if (e.isDirectory) {
248
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name));
249
+ continue;
250
+ }
251
+ if (!SOURCE_EXTENSIONS.has(path.extname(e.name))) continue;
252
+ if (skipFiles.has(e.name)) continue;
253
+ for (const spec of specifiersOfFile(fileSystem, path.join(dir, e.name))) found.add(spec);
254
+ }
255
+ };
256
+ walk(rootDir);
257
+ return found;
258
+ }
259
+
260
+ // --- composition: seeds → closure over the lock --------------------------------------------
261
+
262
+ /**
263
+ * PRODUCT closure of the distributive: lock edges narrowed to the seeds — imports of the
264
+ * sources (EXCEPT the build config) and the mosetta.json libraries — plus the framework layer
265
+ * as a whole. Packages outside the closure do not enter the catalog: importing an undeclared
266
+ * one is a resolve error.
267
+ *
268
+ * devDependencies policy is a parity policy:
269
+ * - prod (`includeDev: false`): devDeps edges are CUT OUT and the names go to
270
+ * `catalog.devOnly` — the resolver throws on them with a "move to dependencies" explanation;
271
+ * - dev (`includeDev: true`): the edges are available (devLibraries live exactly there), but
272
+ * a source seed resolved through devDependencies immediately gets a warning in `log`.
273
+ *
274
+ * Imports of `mosetta.build.mjs` do NOT enter this closure: the build config is a TOOL, its
275
+ * dependencies legitimately live in devDependencies, and demanding `dependencies` for them
276
+ * would be harmful advice. They get their own closure — `scanToolingCatalog`.
277
+ */
278
+ export function scanCatalog({ fileSystem, root, frameworkDir, includeDev = true, log = () => {} }) {
279
+ const { edges: full, devNames } = loadLockEdges(fileSystem, root);
280
+ if (!includeDev) {
281
+ for (const [dir, names] of Object.entries(devNames)) {
282
+ for (const name of Object.keys(names)) delete full[dir]?.[name];
283
+ }
284
+ }
285
+
286
+ // walk-up contexts: lock importers through which the seeds are visible (root and ancestors)
287
+ const contexts = Object.keys(full)
288
+ .filter((d) => root === d || root.startsWith(d + path.sep))
289
+ .sort((a, b) => b.length - a.length); // nearest first — node ordering
290
+
291
+ const names = new Set(
292
+ [...scanSourceSpecifiers(fileSystem, root, { skipFiles: new Set([BUILD_CONFIG_FILE]) })].map(packageNameOf),
293
+ );
294
+ let cfg = null;
295
+ try {
296
+ cfg = fileSystem.readJson(path.join(root, 'mosetta.json'));
297
+ } catch { /* not a distributive — imports only */ }
298
+ for (const lib of [...(cfg?.libraries ?? []), ...(cfg?.devLibraries ?? [])]) {
299
+ names.add(packageNameOf(lib));
300
+ }
301
+
302
+ const keep = new Set(contexts);
303
+ const queue = [];
304
+ const enqueue = (dir) => {
305
+ if (!keep.has(dir)) {
306
+ keep.add(dir);
307
+ queue.push(dir);
308
+ }
309
+ };
310
+ for (const name of names) {
311
+ const ctx = contexts.find((c) => full[c]?.[name]);
312
+ if (!ctx) continue; // greedy-scan noise or an honest phantom (fails at resolve)
313
+ if (devNames[ctx]?.[name]) {
314
+ log(` ! ${name} is declared in devDependencies (${ctx}) — a prod build will not resolve it; move it to dependencies`);
315
+ }
316
+ enqueue(full[ctx][name]);
317
+ }
318
+ // framework layer as a whole: generated facets import from the tool's installation
319
+ keep.add(frameworkDir);
320
+ for (const target of Object.values(full[frameworkDir] ?? {})) enqueue(target);
321
+ while (queue.length) {
322
+ const dir = queue.shift();
323
+ for (const target of Object.values(full[dir] ?? {})) enqueue(target);
324
+ }
325
+
326
+ const edges = {};
327
+ for (const dir of keep) {
328
+ const bucket = full[dir];
329
+ if (!bucket) continue;
330
+ const filtered = {};
331
+ for (const [name, target] of Object.entries(bucket)) {
332
+ if (keep.has(target)) filtered[name] = target;
333
+ }
334
+ edges[dir] = filtered;
335
+ }
336
+ const devOnly = {};
337
+ if (!includeDev) {
338
+ for (const dir of keep) {
339
+ if (devNames[dir]) devOnly[dir] = devNames[dir];
340
+ }
341
+ }
342
+ return { edges, devOnly };
343
+ }
344
+
345
+ /**
346
+ * BUILD CONFIG closure: seeds are the imports of `mosetta.build.mjs` and the specifiers of its
347
+ * `buildConfigWasm` knob. Here devDependencies are ALWAYS allowed, with no warnings: a build
348
+ * tool (an scss compiler, lightningcss-wasm, …) is exactly what should live in devDependencies
349
+ * rather than in product dependencies.
350
+ *
351
+ * The closure is SEPARATE, not an extension of the product one: otherwise tooling would leak
352
+ * into the product-code catalog — a client module could import a build package that is not in
353
+ * its dependencies.
354
+ */
355
+ export function scanToolingCatalog({ fileSystem, root, frameworkDir }) {
356
+ const { edges: full } = loadLockEdges(fileSystem, root);
357
+ const contexts = Object.keys(full)
358
+ .filter((d) => root === d || root.startsWith(d + path.sep))
359
+ .sort((a, b) => b.length - a.length);
360
+
361
+ const names = new Set(
362
+ [...specifiersOfFile(fileSystem, path.join(root, BUILD_CONFIG_FILE))].map(packageNameOf),
363
+ );
364
+ try {
365
+ const cfg = fileSystem.readJson(path.join(root, 'mosetta.json'));
366
+ for (const spec of Object.values(cfg?.buildConfigWasm ?? {})) names.add(packageNameOf(spec));
367
+ } catch { /* no mosetta.json — config imports only */ }
368
+
369
+ const keep = new Set([...contexts, frameworkDir]);
370
+ const queue = [];
371
+ const enqueue = (dir) => {
372
+ if (!keep.has(dir)) {
373
+ keep.add(dir);
374
+ queue.push(dir);
375
+ }
376
+ };
377
+ for (const name of names) {
378
+ const ctx = contexts.find((c) => full[c]?.[name]);
379
+ if (ctx) enqueue(full[ctx][name]);
380
+ }
381
+ while (queue.length) {
382
+ const dir = queue.shift();
383
+ for (const target of Object.values(full[dir] ?? {})) enqueue(target);
384
+ }
385
+
386
+ const edges = {};
387
+ for (const dir of keep) {
388
+ const bucket = full[dir];
389
+ if (!bucket) continue;
390
+ const filtered = {};
391
+ for (const [name, target] of Object.entries(bucket)) {
392
+ if (keep.has(target)) filtered[name] = target;
393
+ }
394
+ edges[dir] = filtered;
395
+ }
396
+ return { edges };
397
+ }
@@ -0,0 +1,39 @@
1
+ import { path } from './nodeApi.mjs';
2
+ import { portResolveEsbuild } from './plugins/portResolveEsbuild.mjs';
3
+ import { monolithCompat, monolithCompatOptions } from './plugins/monolithCompat.mjs';
4
+ import { MONOLITH_CONDITIONS } from './conditions.mjs';
5
+
6
+ // A BLOCK'S SETTINGS SCHEMA in its catalog entry. A block may describe what its node keeps in
7
+ // `settings` — `<name>/<name>.settings.ts` beside its module, exporting `settings` (a schema of
8
+ // @mosetta/settings). The build bundles that file with what turns it into JSON Schema and has the
9
+ // HOST run it: the build itself executes nothing (`evaluate` is a capability the environment
10
+ // supplies — the CLI runs it in node). Without one, the catalog goes without schemas: a block
11
+ // still works, only nothing can tell what it takes.
12
+
13
+ export const SETTINGS_SUFFIX = '.settings.ts';
14
+
15
+ const ENTRY = (file) => `export { settings } from ${JSON.stringify(`./${path.basename(file)}`)};
16
+ export { settingsJsonSchema } from '@mosetta/settings';
17
+ `;
18
+
19
+ /** the JSON Schema of a block's settings and the files it came from */
20
+ export async function describeSettings(fileSystem, file, { resolver, engine, evaluate, frameworkDir, sourceBase }) {
21
+ const watched = new Set([file]);
22
+ const result = await engine.bundle({
23
+ stdin: { contents: ENTRY(file), resolveDir: path.dirname(file), sourcefile: 'settings.entry.ts', loader: 'ts' },
24
+ bundle: true,
25
+ format: 'esm',
26
+ platform: 'neutral',
27
+ target: 'es2022',
28
+ write: false,
29
+ logLevel: 'silent',
30
+ plugins: [monolithCompat(fileSystem, resolver), portResolveEsbuild(fileSystem, resolver, frameworkDir, MONOLITH_CONDITIONS, watched)],
31
+ ...monolithCompatOptions(),
32
+ ...(sourceBase ? { absWorkingDir: sourceBase } : {}),
33
+ });
34
+ const described = await evaluate(result.outputFiles[0].text);
35
+ if (!described?.settings || typeof described.settingsJsonSchema !== 'function') {
36
+ throw new Error(`${file}: exports no \`settings\` schema`);
37
+ }
38
+ return { schema: described.settingsJsonSchema(described.settings), watchFiles: [...watched] };
39
+ }
@@ -0,0 +1,26 @@
1
+ import { path } from './nodeApi.mjs';
2
+
3
+ /**
4
+ * Unified `sources` form in sourcemaps.
5
+ *
6
+ * The engines relativize paths differently and both badly: native rollup — against
7
+ * `process.cwd()` (the form depends on which directory the build runs from), `@rollup/browser` —
8
+ * against `/`, because it has no `cwd` at all. We normalize to a path RELATIVE TO THE ROOT of
9
+ * the distributive: the engines start to agree, and the form stops depending on the machine and
10
+ * the launch directory.
11
+ *
12
+ * The engines can be told apart by what they put in `sourcemapPath`: the native one gives an
13
+ * absolute path (and `rel` relative to it), the wasm one — a bare file name (and `rel` = an
14
+ * absolute path without the leading slash). This contract is pinned by `sourcemapForm`.
15
+ *
16
+ * Changing the form is safe: `sourcesContent` is inlined into the maps, so `sources` acts as a
17
+ * label in devtools, not as a load address.
18
+ */
19
+ export function sourcemapPathTransformFor(sourceBase) {
20
+ return (relativeSourcePath, sourcemapPath) => {
21
+ const absolute = sourcemapPath && path.isAbsolute(sourcemapPath)
22
+ ? path.resolve(path.dirname(sourcemapPath), relativeSourcePath)
23
+ : path.resolve('/', relativeSourcePath);
24
+ return path.relative(sourceBase, absolute).split(path.sep).join('/');
25
+ };
26
+ }
@@ -0,0 +1,76 @@
1
+ // External-dependency resolution + version-qualified identity.
2
+ // Single source of truth for "given a bare specifier, what artifact (full-id, file, version)
3
+ // does it build into" — shared by the closure walker (build.mjs) and the per-build external
4
+ // rewriter (output.paths in buildModules/buildDependencies).
5
+ import { builtinModules } from './nodeApi.mjs';
6
+ import { dependencyFullId } from '@mosetta/runtime/naming';
7
+ import { CLIENT_CONDITIONS } from './conditions.mjs';
8
+ import { applyBrowserField, applyBrowserEntry } from './browserField.mjs';
9
+ import { packageVersionOf } from './packageMeta.mjs';
10
+
11
+ // `builtinModules` is pure DATA (a list of names), not I/O: target-environment policy
12
+ // (the browser has no node builtins), not a build-host capability — hence not in the port.
13
+ export const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]);
14
+
15
+ // node builtins → browser shims. EMPTY on purpose: the last shim (crypto for object-hash) died
16
+ // with the legacy hashing (cache keys switched to stableStringify). The mechanism stays —
17
+ // an unshimmed builtin is warn-skipped below, and a future need slots a file back in here.
18
+ export const SHIMS = {};
19
+
20
+ // Framework runtime modules resolved specially into the closure: a component's bare
21
+ // import of '@mosetta/runtime' gets the package's ROOT (asset) surface as a dependency artifact.
22
+ // Resolved from the TOOL'S OWN installation (resolver.resolveFramework), not from the distributive.
23
+ export const FRAMEWORK_SPECIFIERS = new Set(['@mosetta/runtime']);
24
+
25
+ const SKIP_PREFIXES = ['@types/'];
26
+
27
+ // Version-qualified id of an external dependency artifact — re-exported from the
28
+ // shared naming module so the build and the runtime resolver form ids identically.
29
+ export { dependencyFullId };
30
+
31
+ /**
32
+ * Resolve a bare specifier to its built-dependency identity, as seen FROM `fromDir`
33
+ * (importer-relative resolution). Returns { specifier, full, resolvedFile, version, kind }
34
+ * or null when it must be skipped (@types, unresolvable, or an unshimmed node builtin).
35
+ */
36
+ export function resolveSpecifier(fileSystem, resolver, specifier, fromDir, log = () => {}) {
37
+ if (SKIP_PREFIXES.some((p) => specifier.startsWith(p))) return null;
38
+
39
+ let resolvedFile;
40
+ let version;
41
+ let kind;
42
+ if (FRAMEWORK_SPECIFIERS.has(specifier)) {
43
+ resolvedFile = resolver.resolveFramework(specifier);
44
+ // the version of the runtime the tool brings — a new runtime is a new artifact, never new bytes
45
+ // under an old name
46
+ version = packageVersionOf(fileSystem, resolvedFile);
47
+ kind = 'framework';
48
+ } else if (BUILTINS.has(specifier)) {
49
+ const shim = SHIMS[specifier];
50
+ if (!shim) {
51
+ log(` ! node builtin ${specifier} has no browser shim — left unresolved`);
52
+ return null;
53
+ }
54
+ resolvedFile = shim;
55
+ version = '0.0.0';
56
+ kind = 'builtin';
57
+ } else {
58
+ try {
59
+ const hit = resolver.resolve(specifier, fromDir, CLIENT_CONDITIONS);
60
+ const entry = applyBrowserEntry(fileSystem, specifier, hit, CLIENT_CONDITIONS);
61
+ const mapped = applyBrowserField(fileSystem, entry, CLIENT_CONDITIONS);
62
+ // a stub as a dependency ENTRY makes no sense — such a specifier is simply skipped
63
+ if (typeof mapped !== 'string' || mapped.startsWith('\0')) return null;
64
+ resolvedFile = mapped;
65
+ } catch (e) {
66
+ // a devDep in product code is not a "reason to skip" but a configuration error with a
67
+ // ready recipe: fail the build with an explanation instead of leaving a hole in the closure
68
+ if (e.code === 'DEV_DEPENDENCY') throw e;
69
+ log(` ! unresolved ${specifier} (${e.code || e.message}) — skipped`);
70
+ return null;
71
+ }
72
+ version = packageVersionOf(fileSystem, resolvedFile);
73
+ kind = 'npm';
74
+ }
75
+ return { specifier, full: dependencyFullId(specifier, version), resolvedFile, version, kind };
76
+ }