@webjsdev/server 0.8.62 → 0.8.64

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.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. package/src/vendor.js +29 -2371
package/index.d.ts CHANGED
@@ -336,8 +336,13 @@ export declare function setVendorEntries(
336
336
  // vendor.js (the `webjs vendor` CLI surface)
337
337
  // ---------------------------------------------------------------------------
338
338
 
339
- /** Scan a directory's source for bare-specifier npm imports. */
340
- export declare function scanBareImports(dir: string, skipFiles?: Set<string>): Promise<Set<string>>;
339
+ /**
340
+ * Bare-specifier npm imports an app could load in the browser, found by walking
341
+ * the module graph from every browser-bound entry rather than by scanning the
342
+ * directory. Applies no elision pruning, so the result is a superset of what the
343
+ * running server serves.
344
+ */
345
+ export declare function scanBareImports(dir: string): Promise<Set<string>>;
341
346
  /** Extract the package name from an import specifier (`dayjs/plugin/utc` -> `dayjs`). */
342
347
  export declare function extractPackageName(spec: string): string | null;
343
348
  /** Resolve bare imports to CDN importmap entries. */
@@ -635,6 +640,21 @@ export declare function rateLimit(opts?: {
635
640
  * that STRIPS inbound `X-Forwarded-For` before adding its own.
636
641
  */
637
642
  trustProxy?: boolean;
643
+ /**
644
+ * Name the ONE forwarded header that carries the visitor, e.g.
645
+ * `'cf-connecting-ip'` behind Cloudflare. Requires `trustProxy: true`, and
646
+ * when set it is the only wire header consulted (falling back to the stamped
647
+ * peer, then `_anon_`).
648
+ *
649
+ * Behind a CDN this is usually required for the limiter to work at all. The
650
+ * default chain takes the leftmost `X-Forwarded-For` entry, which behind
651
+ * Cloudflare is Cloudflare's EGRESS address, not the visitor, and since that
652
+ * is pinned per connection the limiter hands out one bucket per connection.
653
+ * The framework will not prefer `CF-Connecting-IP` on its own: Cloudflare
654
+ * overwrites it, so it is unforgeable behind Cloudflare and forgeable
655
+ * anywhere else, which makes the right choice a property of your topology.
656
+ */
657
+ clientIpHeader?: string;
638
658
  }): Middleware;
639
659
  /** Parse a window string (`'1m'`, `'30s'`) to milliseconds. */
640
660
  export declare function parseWindow(w: number | string): number;
@@ -645,8 +665,17 @@ export declare function parseWindow(w: number | string): number;
645
665
  * `trustProxy: true` reads the forwarded-IP headers instead, UNLESS
646
666
  * `WEBJS_NO_TRUST_PROXY=1` is set, which overrides the option back to the
647
667
  * stamped peer and logs once per process.
668
+ *
669
+ * `header` names the one forwarded header to trust (`'cf-connecting-ip'`
670
+ * behind Cloudflare) and, when given, is the only wire header read. Use it
671
+ * whenever a CDN sits in front: the default chain's leftmost
672
+ * `X-Forwarded-For` entry is then the CDN's egress address rather than the
673
+ * visitor.
648
674
  */
649
- export declare function clientIp(req: Request, opts?: { trustProxy?: boolean }): string;
675
+ export declare function clientIp(
676
+ req: Request,
677
+ opts?: { trustProxy?: boolean; header?: string },
678
+ ): string;
650
679
  /** Stamp the socket remote address onto a request for `clientIp` to read. */
651
680
  export declare function stampRemoteIp(req: Request, remoteAddress: string): void;
652
681
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.62",
3
+ "version": "0.8.64",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@webjsdev/core": "^0.7.50",
31
+ "@webjsdev/core": "^0.7.51",
32
32
  "ws": "^8.20.0"
33
33
  },
34
34
  "publishConfig": {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The browser-bound ENTRY files of an app: every module the client boot can
3
+ * import directly.
4
+ *
5
+ * Feeding these to `reachableFromEntries` produces webjs's equivalent of
6
+ * Next.js's bundler-produced page manifest, derived lazily on the first
7
+ * request (and re-derived on every rebuild) instead of at compile time. The
8
+ * dev server uses that closure as an authorization gate on its source-file
9
+ * branch: in-set is served (subject to the `.server.{js,ts}` stub guardrail),
10
+ * out-of-set 404s. Feeding them to `reachableBareSpecifiers` produces the
11
+ * vendor importmap, which is why this half is its own module: the CLI-side
12
+ * vendor paths (`pinAll`, `webjs doctor`) root at exactly the entries the gate
13
+ * does, without booting a server.
14
+ *
15
+ * Entries:
16
+ * - page / layout (both re-run on the client for hydration)
17
+ * - the error / loading / not-found / forbidden / unauthorized boundaries
18
+ * - the two root-only boundaries (global-error, global-not-found)
19
+ * - instrumentation-client (imported first in the boot)
20
+ * - every discovered component (a `static lazy` one is fetched by the lazy
21
+ * loader rather than imported by a page, so the component scan is an entry
22
+ * source in its own right)
23
+ *
24
+ * NOT entries, because the browser never fetches them as a module:
25
+ * - route.{js,ts} (API handlers) and middleware.{js,ts}
26
+ * - metadata routes (sitemap.js, robots.js, manifest.js, …)
27
+ * - .server.{js,ts} files (the browser gets a stub, not the source)
28
+ */
29
+
30
+ /**
31
+ * Components are passed in (rather than rescanned) so the caller can share one
32
+ * scan with `primeComponentRegistry`, saving a full appDir walk per analysis.
33
+ *
34
+ * @param {Awaited<ReturnType<typeof import('./router.js').buildRouteTable>>} routeTable
35
+ * @param {Awaited<ReturnType<typeof import('./component-scanner.js').scanComponents>>} components
36
+ * @returns {Set<string>}
37
+ */
38
+ export function browserEntryFiles(routeTable, components) {
39
+ /** @type {Set<string>} */
40
+ const entries = new Set();
41
+ for (const page of routeTable.pages) {
42
+ if (page.file) entries.add(page.file);
43
+ for (const f of page.layouts || []) entries.add(f);
44
+ for (const f of page.errors || []) entries.add(f);
45
+ for (const f of page.loadings || []) entries.add(f);
46
+ for (const f of page.forbiddens || []) entries.add(f);
47
+ for (const f of page.unauthorizeds || []) entries.add(f);
48
+ }
49
+ if (routeTable.notFound) entries.add(routeTable.notFound);
50
+ if (routeTable.notFounds) {
51
+ for (const f of routeTable.notFounds.values()) entries.add(f);
52
+ }
53
+ if (routeTable.globalError) entries.add(routeTable.globalError);
54
+ if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
55
+ // instrumentation-client is browser-bound (imported first in the boot), so it
56
+ // must be servable through the gate.
57
+ if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
58
+ // Lazy components live in the registry but no page imports their
59
+ // class directly; the lazy-loader fetches their module URLs on
60
+ // viewport entry. Add every discovered component file as an entry so
61
+ // the graph walk covers both eager and lazy paths.
62
+ for (const c of components) entries.add(c.file);
63
+ return entries;
64
+ }
@@ -0,0 +1,365 @@
1
+ import { sep } from 'node:path';
2
+ import { matchClosingBrace } from '../js-scan.js';
3
+
4
+ /**
5
+ * Check whether a file has the `'use server'` directive in its first
6
+ * five lines. Used by the `use-server-needs-extension` rule, and by
7
+ * `isServerActionFile` below.
8
+ * @param {string} content file content (already read)
9
+ * @returns {boolean}
10
+ */
11
+ export function hasUseServerDirective(content) {
12
+ const head = content.split('\n').slice(0, 5).join('\n');
13
+ return /^\s*(['"])use server\1\s*;?\s*$/m.test(head);
14
+ }
15
+
16
+ /**
17
+ * Check whether a file is a server action. A server action requires
18
+ * BOTH the `.server.{js,ts,mts,mjs}` extension AND the `'use server'`
19
+ * directive in the file head. Either alone is not enough: bare `.server.ts`
20
+ * is a server-only utility (no RPC), and bare `'use server'` is a lint
21
+ * violation (use-server-needs-extension).
22
+ * @param {string} filePath absolute path
23
+ * @param {string} content file content (already read)
24
+ * @returns {boolean}
25
+ */
26
+ export function isServerActionFile(filePath, content) {
27
+ if (!/\.server\.m?[jt]s$/.test(filePath)) return false;
28
+ return hasUseServerDirective(content);
29
+ }
30
+
31
+ /**
32
+ * Check whether a file resides under a components/ directory (shared or
33
+ * module-scoped).
34
+ * @param {string} relPath Path relative to appDir
35
+ * @returns {boolean}
36
+ */
37
+ export function isComponentFile(relPath) {
38
+ const segments = relPath.split(sep);
39
+ return segments.includes('components');
40
+ }
41
+
42
+ /**
43
+ * Scan a class body for class-field initializers naming any of `props`.
44
+ * "Class-field" means: at the top of the class body (brace depth 0
45
+ * relative to the body), at the start of a line, NOT prefixed with
46
+ * `declare`, `static`, or `this.`.
47
+ *
48
+ * Returns the offending property names. The caller maps these to
49
+ * Violation objects.
50
+ *
51
+ * @param {string} classBody
52
+ * @param {Set<string>} props
53
+ * @returns {string[]}
54
+ */
55
+ /**
56
+ * True when a factory prop value is an array-typed `prop<…>(…)` whose
57
+ * runtime constructor argument is `Object`. The generic and the
58
+ * constructor sit in the same call, so it is decidable from the value
59
+ * text alone. The match is greedy on the generic so a nested generic
60
+ * (`prop<Array<X>>(Object)`) closes at the outer `>` that precedes the
61
+ * `(Object` call. A bare constructor (`Object`, with no generic) or a
62
+ * non-array generic (`prop<Foo>(Object)`) returns false.
63
+ *
64
+ * @param {string} value the raw prop value text, e.g. `prop<Tag[]>(Object)`
65
+ * @returns {boolean}
66
+ */
67
+ export function arrayPropUsesObject(value) {
68
+ const m = /^prop\s*<([\s\S]*)>\s*\(\s*Object\s*[,)]/.exec(value.trim());
69
+ if (!m) return false;
70
+ return isArrayTypeText(m[1]);
71
+ }
72
+
73
+ /**
74
+ * True when a TypeScript type expression denotes an array: `T[]`,
75
+ * `readonly T[]`, `T[][]`, `Array<T>`, or `ReadonlyArray<T>`.
76
+ *
77
+ * @param {string} type
78
+ * @returns {boolean}
79
+ */
80
+ export function isArrayTypeText(type) {
81
+ const bare = type.trim().replace(/^readonly\s+/, '');
82
+ if (/\[\s*\]$/.test(bare)) return true;
83
+ if (/^(?:Readonly)?Array\s*<[\s\S]*>$/.test(bare)) return true;
84
+ return false;
85
+ }
86
+
87
+ export function findFieldInitializers(classBody, props) {
88
+ /** @type {string[]} */
89
+ const out = [];
90
+ const n = classBody.length;
91
+ // Match class-field declarations. Two shapes:
92
+ // 1. With initializer: `count = 0`, `count: number = 0`, `public count = 0`
93
+ // 2. Type-only (no initializer): `count!: number`, `count?: number`, `count: number`
94
+ // Both compile to Object.defineProperty after super() under modern class-field
95
+ // semantics, clobbering the reactive accessor.
96
+ // The initializer regex: optional modifier, identifier, optional type, then `=`.
97
+ const initRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
98
+ // The type-only regex: optional modifier, identifier, then `!:` or `?:` or `:` with a type.
99
+ const typeOnlyRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*[!?]?\s*:\s*\S/;
100
+ const examineLine = (lineStart) => {
101
+ let j = lineStart;
102
+ while (j < n && classBody[j] !== '\n') j++;
103
+ const line = classBody.slice(lineStart, j);
104
+ const initM = initRe.exec(line);
105
+ const typeM = typeOnlyRe.exec(line);
106
+ // Prefer the initializer match; if neither, skip.
107
+ const name = initM ? initM[2] : (typeM ? typeM[2] : null);
108
+ // `declare`, `static`, and `this.` patterns shouldn't reach here
109
+ // (declare/static start with their keyword, this.x has the dot in
110
+ // the regex group), but guard against matching keywords as names:
111
+ if (name && name !== 'declare' && name !== 'static' && props.has(name)) out.push(name);
112
+ };
113
+ // Walk the body char by char, tracking brace depth. A class field lives at
114
+ // the class-body top level (depth 0). We examine a line ONCE, at its first
115
+ // non-whitespace char, only while depth is 0, and WITHOUT skipping the braces
116
+ // on that line: an opening brace on the line (`method() {`, `field = {`) must
117
+ // still be counted so the lines inside its block are seen at depth > 0. The
118
+ // earlier version jumped `i` past the whole examined line, which dropped that
119
+ // line's braces and let object-literal keys inside a method body (`{ game:
120
+ // ..., scoreboard: ... }`) be misread as depth-0 class fields (#934).
121
+ let depth = 0;
122
+ let i = 0;
123
+ let lineStart = 0;
124
+ let examined = false;
125
+ let str = '';
126
+ while (i < n) {
127
+ const c = classBody[i];
128
+ if (str) {
129
+ if (c === '\\') { i += 2; continue; }
130
+ if (c === str) str = '';
131
+ i++;
132
+ continue;
133
+ }
134
+ if (c === '\n') { lineStart = i + 1; examined = false; i++; continue; }
135
+ if (c === '/' && classBody[i + 1] === '/') {
136
+ while (i < n && classBody[i] !== '\n') i++;
137
+ continue;
138
+ }
139
+ if (c === '/' && classBody[i + 1] === '*') {
140
+ i += 2;
141
+ while (i < n && !(classBody[i] === '*' && classBody[i + 1] === '/')) i++;
142
+ i += 2;
143
+ continue;
144
+ }
145
+ if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
146
+ // First non-whitespace char of a class-body top-level line: examine it for
147
+ // a field declaration BEFORE consuming any brace it opens.
148
+ if (depth === 0 && !examined && !/\s/.test(c)) {
149
+ examined = true;
150
+ examineLine(lineStart);
151
+ }
152
+ if (c === '{') { depth++; i++; continue; }
153
+ if (c === '}') { depth--; i++; continue; }
154
+ i++;
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // Browser-only globals that are undefined during SSR (the server-side
160
+ // WebComponent base is a bare class with no DOM). High-confidence names only
161
+ // (unlikely to be ordinary local variables), so the rule stays low-noise.
162
+ const BROWSER_GLOBALS = [
163
+ 'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
164
+ 'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
165
+ 'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
166
+ ];
167
+
168
+ // HTMLElement instance members that do not exist on the server element shim,
169
+ // so `this.<member>` throws (a method call) or is `undefined` (a property) at
170
+ // SSR. The attribute methods (get/set/has/remove/toggleAttribute), the event
171
+ // methods (add/removeEventListener, dispatchEvent), and attachInternals are
172
+ // backed by the shim and run server-side, so they are intentionally NOT
173
+ // flagged: a component may read attributes in render and reflect properties
174
+ // during the SSR update cycle. What stays is the genuinely browser-only
175
+ // surface (DOM querying, layout reads, shadow construction, focus).
176
+ const HTMLELEMENT_MEMBERS = [
177
+ 'attachShadow', 'shadowRoot', 'classList',
178
+ 'querySelector', 'querySelectorAll', 'getBoundingClientRect',
179
+ 'focus', 'blur', 'scrollIntoView',
180
+ ];
181
+
182
+ /**
183
+ * Extract the body text of a named method from a (redacted) class body, or
184
+ * '' if absent. Handles `async`, a TS return-type annotation, and params.
185
+ * @param {string} classBody
186
+ * @param {string} name
187
+ */
188
+ export function methodBodyOf(classBody, name) {
189
+ const re = new RegExp(`(?:^|[\\s;}])(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`, 'g');
190
+ const m = re.exec(classBody);
191
+ if (!m) return '';
192
+ const open = classBody.indexOf('{', m.index + m[0].length - 1);
193
+ if (open === -1) return '';
194
+ const close = matchClosingBrace(classBody, open + 1);
195
+ return close === -1 ? '' : classBody.slice(open + 1, close);
196
+ }
197
+
198
+ /**
199
+ * Find browser-only globals and HTMLElement `this.<member>` accesses in a
200
+ * (redacted) method body. Returns one entry per distinct member.
201
+ * @param {string} code
202
+ * @returns {{ member: string, kind: string }[]}
203
+ */
204
+ export function findBrowserMemberUses(code) {
205
+ // The class body arrives template-redacted, but `redactStringsAndTemplates`
206
+ // keeps single/double-quoted string CONTENT (real specifiers ride strings).
207
+ // Blank that too so a browser word inside a string literal (e.g. a label
208
+ // `'open the document'`) is not mistaken for a real global access.
209
+ code = code
210
+ .replace(/'(?:[^'\\]|\\.)*'/g, (s) => `'${' '.repeat(Math.max(0, s.length - 2))}'`)
211
+ .replace(/"(?:[^"\\]|\\.)*"/g, (s) => `"${' '.repeat(Math.max(0, s.length - 2))}"`);
212
+ const out = [];
213
+ const seen = new Set();
214
+ const gRe = new RegExp(`(?<![.\\w$])(${BROWSER_GLOBALS.join('|')})\\b`, 'g');
215
+ let m;
216
+ while ((m = gRe.exec(code)) !== null) {
217
+ if (seen.has(m[1])) continue;
218
+ seen.add(m[1]);
219
+ out.push({ member: m[1], kind: 'a browser global' });
220
+ }
221
+ const hRe = new RegExp(`\\bthis\\.(${HTMLELEMENT_MEMBERS.join('|')})\\b`, 'g');
222
+ while ((m = hRe.exec(code)) !== null) {
223
+ const key = `this.${m[1]}`;
224
+ if (seen.has(key)) continue;
225
+ seen.add(key);
226
+ out.push({ member: key, kind: 'an HTMLElement member' });
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * True if any `export const/let/var` declares more than one binding
233
+ * (`export const a = 1, b = 2`), which the single-name collector would
234
+ * under-count. Depth-aware: a comma inside an initializer (`f(a, b)`, `[a, b]`,
235
+ * `{ a, b }`) does not count, only a top-level comma before the statement end.
236
+ * Bailing the whole module on this errs toward a false negative (safe), never a
237
+ * false positive. Runs on the string/comment-redacted `scan`.
238
+ * @param {string} scan
239
+ * @returns {boolean}
240
+ */
241
+ export function hasMultiDeclaratorExport(scan) {
242
+ const re = /\bexport\s+(?:const|let|var)\b/g;
243
+ let m;
244
+ while ((m = re.exec(scan))) {
245
+ let depth = 0; // () [] {} nesting
246
+ let angle = 0; // <> generics, tracked ONLY inside a type annotation
247
+ let seenEq = false; // passed this declarator's `=` (now in the initializer)
248
+ let inType = false; // inside a `: Type` annotation (before the `=`)
249
+ for (let i = m.index + m[0].length; i < scan.length; i++) {
250
+ const ch = scan[i];
251
+ if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
252
+ if (ch === ')' || ch === ']' || ch === '}') { if (depth === 0) break; depth--; continue; }
253
+ if (depth !== 0) continue;
254
+ if (ch === ';') break;
255
+ // A plain `=` ends the type annotation and enters the initializer. `<=`,
256
+ // `>=`, `=>`, `==` are harmless here (seenEq only latches true).
257
+ if (ch === '=') { seenEq = true; inType = false; angle = 0; continue; }
258
+ // A `:` BEFORE the `=` opens the type annotation. A `:` after `=` (a
259
+ // ternary in the initializer) is NOT a type and must not suppress a real
260
+ // declarator comma.
261
+ if (ch === ':' && !seenEq) { inType = true; continue; }
262
+ // Inside a type annotation `<` / `>` are generic delimiters (a comparison
263
+ // only appears in the initializer, after `=`). Track their depth so a
264
+ // generic's comma (`Map<string, number>`) is skipped while a real
265
+ // declarator comma (`a: number, b: number`) still counts. A `>` that is
266
+ // part of a `=>` function-type arrow is not a generic close.
267
+ if (inType && ch === '<') { angle++; continue; }
268
+ if (inType && ch === '>' && scan[i - 1] !== '=') { if (angle > 0) angle--; continue; }
269
+ // A top-level comma starts a second declarator UNLESS it is a generic's
270
+ // comma inside a type annotation.
271
+ if (ch === ',' && !(inType && angle > 0)) return true;
272
+ }
273
+ }
274
+ return false;
275
+ }
276
+
277
+ /**
278
+ * Enumerate a module's provable named exports from its redacted `scan`, for the
279
+ * `no-missing-local-import` rule. Returns `null` when the export list is NOT
280
+ * fully enumerable (a `export *` star re-export, a destructuring export, or a
281
+ * multi-declarator `export const a = .., b = ..`), which tells the rule to treat
282
+ * the module as unknowable and flag nothing from it. Otherwise returns the set
283
+ * of exported names (the alias after `as` for a clause, and type/interface/enum
284
+ * names too, so a value import of a type is not falsely flagged; tsc owns that).
285
+ * @param {string} scan string/template/comment-redacted source
286
+ * @returns {Set<string>|null}
287
+ */
288
+ export function enumerableExports(scan) {
289
+ // Unknowable export shapes: bail so the rule never false-positives.
290
+ if (/\bexport\s*\*/.test(scan)) return null; // export * from
291
+ if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) return null; // destructuring export
292
+ if (hasMultiDeclaratorExport(scan)) return null; // export const a=1, b=2
293
+ const names = new Set();
294
+ let m;
295
+ const collect = (re) => { while ((m = re.exec(scan))) names.add(m[1]); };
296
+ collect(/\bexport\s+(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)/g);
297
+ collect(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g);
298
+ collect(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g);
299
+ collect(/\bexport\s+(?:type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)/g);
300
+ // A named-default import (`import { default as Foo }`) is legal against an
301
+ // `export default` module, so record the `default` name when present.
302
+ if (/\bexport\s+default\b/.test(scan)) names.add('default');
303
+ // `export { a, b as c }` / `export type { ... }` / `export { x } from '...'`:
304
+ // the EXPORTED name is the alias after `as`, else the bare name.
305
+ const reClause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
306
+ while ((m = reClause.exec(scan))) {
307
+ for (let part of m[1].split(',')) {
308
+ part = part.trim().replace(/^type\s+/, '');
309
+ if (!part) continue;
310
+ const name = part.split(/\s+as\s+/).pop().trim();
311
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
312
+ }
313
+ }
314
+ return names;
315
+ }
316
+
317
+ /**
318
+ * The NAMED VALUE names a single `import { ... } from` clause pulls in, for the
319
+ * `no-missing-local-import` rule. Returns `null` for an `import type { ... }`
320
+ * (all type, skip), and `[]` for a default / namespace / side-effect import
321
+ * (nothing named to verify). A per-specifier inline `type` marker is dropped,
322
+ * and for `a as b` the IMPORTED name is `a` (what the target must export).
323
+ * @param {string} clause the text between `import` and `from`
324
+ * @returns {string[]|null}
325
+ */
326
+ /**
327
+ * The `{ local, imported }` pairs a named import clause binds, for a rule that
328
+ * has to match the LOCAL name against code (`action=${reader}`) while reporting
329
+ * the IMPORTED one (`readIt`, which is what the target module calls it).
330
+ *
331
+ * `importedValueNames` cannot serve here: it returns only the imported side,
332
+ * which is the wrong name to match against a template hole under `as`.
333
+ *
334
+ * @param {string} clause the text between `import` and `from`
335
+ * @returns {{ local: string, imported: string }[]}
336
+ */
337
+ export function importedLocalNames(clause) {
338
+ if (/^\s*type\b/.test(clause)) return [];
339
+ const brace = clause.match(/\{([^}]*)\}/);
340
+ if (!brace) return [];
341
+ /** @type {{ local: string, imported: string }[]} */
342
+ const out = [];
343
+ for (const part of brace[1].split(',')) {
344
+ const seg = part.trim().replace(/^type\s+/, '');
345
+ if (!seg) continue;
346
+ const as = seg.split(/\s+as\s+/);
347
+ const imported = as[0].trim();
348
+ const local = (as[1] || as[0]).trim();
349
+ if (/^[A-Za-z_$][\w$]*$/.test(local) && /^[A-Za-z_$][\w$]*$/.test(imported)) {
350
+ out.push({ local, imported });
351
+ }
352
+ }
353
+ return out;
354
+ }
355
+
356
+ export function importedValueNames(clause) {
357
+ if (/^\s*type\b/.test(clause)) return null; // import type { ... }
358
+ const brace = clause.match(/\{([^}]*)\}/);
359
+ if (!brace) return []; // default / namespace only
360
+ return brace[1].split(',')
361
+ .map((s) => s.trim())
362
+ .filter((s) => s && !/^type\s/.test(s))
363
+ .map((s) => s.split(/\s+as\s+/)[0].trim())
364
+ .filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
365
+ }