@gjsify/rolldown-plugin-gjsify 0.43.0 → 0.44.0

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,239 @@
1
+ // Which NAMES a module imports from one package — the input the ADR 0032 § 8
2
+ // build-time gate needs and no bundler hook hands over.
3
+ //
4
+ // WHY A PARSE HERE RATHER THAN A HOOK. The gate has to name the IDENTIFIER, not
5
+ // the module: § 8's whole advantage over the runtime backstop is failing on
6
+ // `import { FlatList } from 'react-native'` with the importing file and position.
7
+ // Of the hooks BOTH engines honour — `@gjsify/rolldown-native`'s `NativePlugin`
8
+ // lists load, transform, resolveId, renderChunk, the four addons and the
9
+ // lifecycle pair, and that list is the ceiling — `resolveId` sees the specifier
10
+ // and never the bindings, and there is no `moduleParsed`, no `ModuleInfo.ast` and
11
+ // no `this.parse`. `transform` hands over the source and nothing else does, so
12
+ // the import list is ours to read.
13
+ //
14
+ // WHY NOT A TEXT SCAN. A regex over `import {…} from '<pkg>'` cannot tell a live
15
+ // import from one inside a block comment or a template literal, and it cannot
16
+ // see `importKind` — which is the difference between a build error and five
17
+ // type-only imports that ADR 0032 measured as costing nothing. Both of those are
18
+ // false POSITIVES, the failure mode that teaches people to switch a gate off.
19
+ //
20
+ // WHY ACORN AND NOT THE BUNDLER'S PARSER. Same measured reason as
21
+ // `inline-static-reads.ts`: under GJS the engine is `@gjsify/rolldown-native`,
22
+ // npm `rolldown` is a Node-only N-API crate, and a top-level
23
+ // `import { parseAst } from 'rolldown/parseAst'` puts it in a module that has to
24
+ // load under GJS — the CLI's own bundle died at startup with `createRequire:
25
+ // Cannot require builtin module "fs" synchronously in GJS`. `acorn-typescript`
26
+ // is pure JS and reports `importKind` on both the declaration and each specifier
27
+ // (verified against `.tsx` with JSX, a namespace import and inline `type`
28
+ // specifiers), which is exactly the discrimination the gate turns on.
29
+ //
30
+ // TWO BLIND SPOTS, BOTH MEASURED, AND NEITHER IS SILENT.
31
+ //
32
+ // 1. FORMS THAT CARRY NO NAME — the point of `opaque`. A named import is the
33
+ // only form that carries a name at build time. `import * as RN`, a default
34
+ // import, `export * from`, `await import('…')` and `require('…')` all reach
35
+ // the surface through a value whose members are resolved at RUNTIME, and no
36
+ // parser recovers them without type information this layer does not have.
37
+ // They come back as {@link OpaqueReference}s so the caller can say out loud
38
+ // that the gate cannot answer for this module and that the runtime refusal is
39
+ // what covers it.
40
+ //
41
+ // 2. SYNTAX acorn-typescript 1.4.13 DOES NOT HAVE. Measured against the pinned
42
+ // version (acorn 8.17.0): `const x = 1 satisfies number` fails with
43
+ // `Unexpected token (1:12)`, and a `const` type parameter
44
+ // (`<const T,>(x: T) => x`) fails with `Unexpected token (1:18)`. Both are
45
+ // ordinary TypeScript, and this repository pins `typescript: "^6.0.3"`, so
46
+ // both WILL appear in consumer source. This function throws
47
+ // {@link ImportScanParseError} for them, and the plugin turns that into a
48
+ // warning rather than a build failure — see `react-native-gate.ts` for why a
49
+ // false violation is the worse outcome. `react-native-gate.spec.ts` pins the
50
+ // gap as a fixture that must NOT parse, beside the modern syntax that must, so
51
+ // the day acorn-typescript gains it that vector goes red and someone moves it.
52
+ //
53
+ // A gate whose limits are undocumented is worse than no gate; a gate that fails
54
+ // a valid program is how a gate gets switched off.
55
+ import * as acorn from 'acorn';
56
+ import { tsPlugin } from 'acorn-typescript';
57
+ /**
58
+ * The TypeScript/TSX-capable parser, built once.
59
+ *
60
+ * `acorn-typescript` refuses to parse without `locations: true` (measured in
61
+ * `inline-static-reads.ts`), and the gate needs the positions anyway.
62
+ */
63
+ const TS_PARSER = acorn.Parser.extend(tsPlugin());
64
+ const PARSE_OPTIONS = {
65
+ ecmaVersion: 'latest',
66
+ sourceType: 'module',
67
+ locations: true,
68
+ allowAwaitOutsideFunction: true,
69
+ allowReturnOutsideFunction: true,
70
+ allowImportExportEverywhere: true,
71
+ };
72
+ /** Raised when a module that mentions a watched package cannot be parsed. */
73
+ export class ImportScanParseError extends Error {
74
+ id;
75
+ name = 'ImportScanParseError';
76
+ constructor(id, cause) {
77
+ const detail = cause instanceof Error ? cause.message : String(cause);
78
+ super(`gjsify import scan: cannot read the import list of ${id} — ${detail}. This module mentions a ` +
79
+ `package the build-time support gate watches, so for THIS file the gate is downgraded to ` +
80
+ `the runtime backstop. Known causes, both measured on acorn-typescript 1.4.13: a ` +
81
+ `\`satisfies\` expression, or a \`const\` type parameter. Otherwise the syntax is newer ` +
82
+ `than the bundled parser, or the file is not the module type its extension claims.`, { cause });
83
+ this.id = id;
84
+ }
85
+ }
86
+ function positionOf(node) {
87
+ return { line: node.loc?.start?.line ?? 0, column: node.loc?.start?.column ?? 0 };
88
+ }
89
+ /** The string value of a node when it is a plain string literal, else `undefined`. */
90
+ function literalString(node) {
91
+ const value = node?.value;
92
+ return node?.type === 'Literal' && typeof value === 'string' ? value : undefined;
93
+ }
94
+ /**
95
+ * Visit every node in the tree, generically.
96
+ *
97
+ * A bare descent rather than `acorn-walk`, and not as a preference: `walk.simple`
98
+ * looks its visitor up in a table keyed by node type and throws `No walker
99
+ * function defined for node type TSInterfaceDeclaration` on the first interface
100
+ * in a first-party source. A walker with no node table cannot fall behind the
101
+ * parser — which matters precisely because the parser emits TS node types this
102
+ * file has never heard of.
103
+ */
104
+ function forEachNode(root, visit) {
105
+ const stack = [root];
106
+ while (stack.length > 0) {
107
+ const node = stack.pop();
108
+ if (node === null || typeof node !== 'object')
109
+ continue;
110
+ if (Array.isArray(node)) {
111
+ for (const child of node)
112
+ stack.push(child);
113
+ continue;
114
+ }
115
+ if (typeof node.type === 'string')
116
+ visit(node);
117
+ for (const value of Object.values(node)) {
118
+ if (value !== null && typeof value === 'object')
119
+ stack.push(value);
120
+ }
121
+ }
122
+ }
123
+ /**
124
+ * Every named import and every opaque reference to one of `specifiers`.
125
+ *
126
+ * `specifiers` is matched EXACTLY. `react-native-web` and
127
+ * `react-native/Libraries/…` are different modules with different contracts, and
128
+ * a prefix match would fail a build over a package this layer never claimed —
129
+ * the deep-subpath case has its own named error in the alias plugin, where the
130
+ * importer is known and the message can say what to do.
131
+ *
132
+ * Type-only imports are absent from the result by construction. ADR 0032 counted
133
+ * five of them (`ViewProps`, `TextProps`, `PressableProps`, `LayoutChangeEvent`,
134
+ * `ColorValue`) in the measured application and recorded that they cost nothing:
135
+ * a type erases at build time and reaches no runtime, so a gate that failed on
136
+ * `import type { ViewProps }` would refuse a program that cannot fail.
137
+ *
138
+ * @throws {ImportScanParseError} when the source cannot be parsed.
139
+ */
140
+ export function scanNamedImports(code, id, specifiers) {
141
+ const named = [];
142
+ const opaque = [];
143
+ const watched = new Set(specifiers);
144
+ let ast;
145
+ try {
146
+ ast = TS_PARSER.parse(code, PARSE_OPTIONS);
147
+ }
148
+ catch (cause) {
149
+ throw new ImportScanParseError(id, cause);
150
+ }
151
+ // Import and re-export declarations are top level by grammar, so `body` is
152
+ // the whole population — no descent needed and none wanted, because a
153
+ // descent would also visit a `TSModuleDeclaration`'s ambient imports, which
154
+ // declare types and import nothing.
155
+ for (const node of ast.body) {
156
+ if (node.type === 'ImportDeclaration') {
157
+ const specifier = literalString(node.source);
158
+ if (specifier === undefined || !watched.has(specifier))
159
+ continue;
160
+ // `import type { X } from …` — the whole declaration erases.
161
+ if (node.importKind === 'type')
162
+ continue;
163
+ for (const spec of node.specifiers) {
164
+ const at = positionOf(spec);
165
+ if (spec.type === 'ImportSpecifier') {
166
+ // `import { type X }` — the one specifier erases while its
167
+ // value-importing siblings in the same declaration do not.
168
+ if (spec.importKind === 'type')
169
+ continue;
170
+ const imported = spec.imported;
171
+ const name = imported.name ?? (typeof imported.value === 'string' ? imported.value : undefined);
172
+ if (name !== undefined)
173
+ named.push({ name, specifier, ...at });
174
+ }
175
+ else {
176
+ opaque.push({
177
+ form: spec.type === 'ImportNamespaceSpecifier' ? 'namespace' : 'default',
178
+ specifier,
179
+ ...at,
180
+ });
181
+ }
182
+ }
183
+ continue;
184
+ }
185
+ if (node.type === 'ExportNamedDeclaration') {
186
+ const specifier = literalString(node.source);
187
+ if (specifier === undefined || !watched.has(specifier))
188
+ continue;
189
+ if (node.exportKind === 'type')
190
+ continue;
191
+ for (const spec of node.specifiers) {
192
+ if (spec.exportKind === 'type')
193
+ continue;
194
+ // On an `ExportSpecifier` it is `local` that names the binding in
195
+ // the SOURCE module; `exported` is the name this module publishes.
196
+ const local = spec.local;
197
+ const name = local.name ?? (typeof local.value === 'string' ? local.value : undefined);
198
+ if (name !== undefined)
199
+ named.push({ name, specifier, ...positionOf(spec) });
200
+ }
201
+ continue;
202
+ }
203
+ if (node.type === 'ExportAllDeclaration') {
204
+ const specifier = literalString(node.source);
205
+ if (specifier === undefined || !watched.has(specifier))
206
+ continue;
207
+ if (node.exportKind === 'type')
208
+ continue;
209
+ opaque.push({ form: 'export-all', specifier, ...positionOf(node) });
210
+ }
211
+ }
212
+ // `import('…')` and `require('…')` can sit anywhere, so these need the
213
+ // descent. Only the specifier is recoverable — which member of the namespace
214
+ // the caller reaches for is a runtime question by construction.
215
+ forEachNode(ast, (node) => {
216
+ if (node.type === 'ImportExpression') {
217
+ const specifier = literalString(node.source);
218
+ if (specifier !== undefined && watched.has(specifier)) {
219
+ opaque.push({ form: 'dynamic-import', specifier, ...positionOf(node) });
220
+ }
221
+ return;
222
+ }
223
+ if (node.type !== 'CallExpression')
224
+ return;
225
+ const call = node;
226
+ // `import(…)` also arrives as a CallExpression with an `Import` callee on
227
+ // some acorn versions; both spellings are accepted so the form is not
228
+ // silently missed by a parser upgrade.
229
+ const isImport = call.callee?.type === 'Import';
230
+ const isRequire = call.callee?.type === 'Identifier' && call.callee.name === 'require';
231
+ if (!isImport && !isRequire)
232
+ return;
233
+ const specifier = literalString(call.arguments?.[0]);
234
+ if (specifier === undefined || !watched.has(specifier))
235
+ return;
236
+ opaque.push({ form: isImport ? 'dynamic-import' : 'require', specifier, ...positionOf(node) });
237
+ });
238
+ return { named, opaque };
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Rolldown / Rollup / Vite plugin orchestrator for GJS, Node, and Browser targets",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -33,9 +33,13 @@
33
33
  "lib"
34
34
  ],
35
35
  "scripts": {
36
- "clear": "gjsify clear lib tsconfig.tsbuildinfo",
36
+ "clear": "gjsify clear lib tsconfig.tsbuildinfo test.node.mjs",
37
37
  "check": "gjsify tsc --noEmit",
38
- "build": "gjsify tsc"
38
+ "build": "gjsify tsc -p tsconfig.build.json",
39
+ "build:test": "gjsify run build:test:node",
40
+ "build:test:node": "gjsify build src/test.mts --app node --outfile test.node.mjs",
41
+ "test": "gjsify run build:test && gjsify run test:node",
42
+ "test:node": "node test.node.mjs"
39
43
  },
40
44
  "repository": {
41
45
  "type": "git",
@@ -56,11 +60,11 @@
56
60
  ],
57
61
  "license": "MIT",
58
62
  "dependencies": {
59
- "@gjsify/console": "^0.43.0",
60
- "@gjsify/resolve-npm": "^0.43.0",
61
- "@gjsify/rolldown-plugin-deepkit": "^0.43.0",
62
- "@gjsify/rolldown-plugin-pnp": "^0.43.0",
63
- "@gjsify/vite-plugin-blueprint": "^0.43.0",
63
+ "@gjsify/console": "^0.44.0",
64
+ "@gjsify/resolve-npm": "^0.44.0",
65
+ "@gjsify/rolldown-plugin-deepkit": "^0.44.0",
66
+ "@gjsify/rolldown-plugin-pnp": "^0.44.0",
67
+ "@gjsify/vite-plugin-blueprint": "^0.44.0",
64
68
  "@rollup/pluginutils": "^5.4.0",
65
69
  "acorn": "^8.17.0",
66
70
  "acorn-typescript": "^1.4.13",
@@ -70,7 +74,7 @@
70
74
  "sass": "^1.101.0"
71
75
  },
72
76
  "peerDependencies": {
73
- "@gjsify/lightningcss-native": "^0.43.0",
77
+ "@gjsify/lightningcss-native": "^0.44.0",
74
78
  "rolldown": "^1.1.4"
75
79
  },
76
80
  "peerDependenciesMeta": {
@@ -82,6 +86,8 @@
82
86
  }
83
87
  },
84
88
  "devDependencies": {
89
+ "@gjsify/cli": "^0.44.0",
90
+ "@gjsify/unit": "^0.44.0",
85
91
  "@types/node": "^25.9.2",
86
92
  "rolldown": "^1.1.4",
87
93
  "typescript": "^6.0.3"