@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/src/check.js CHANGED
@@ -1,20 +1,3 @@
1
- import { readFile, stat } from 'node:fs/promises';
2
- import { join, relative, sep, basename, dirname } from 'node:path';
3
- import { walk } from './fs-walk.js';
4
- import {
5
- redactStringsAndTemplates,
6
- redactToPlaceholders,
7
- extractWebComponentClassBodies,
8
- matchClosingBrace,
9
- parsePropEntries,
10
- classifyActionHole,
11
- } from './js-scan.js';
12
- import { buildModuleGraph, transitiveDeps, resolveImport } from './module-graph.js';
13
- import { scanComponents } from './component-scanner.js';
14
- import { buildRouteTable } from './router.js';
15
- import { analyzeElision } from './component-elision.js';
16
- import { RESERVED_CONFIG } from './action-config.js';
17
-
18
1
  /**
19
2
  * Convention validator for WebJs apps.
20
3
  *
@@ -31,1764 +14,11 @@ import { RESERVED_CONFIG } from './action-config.js';
31
14
  * `fix`. Agents should iterate the array and apply (or propose) the fixes.
32
15
  *
33
16
  * @module check
34
- */
35
-
36
- /**
37
- * @typedef {{
38
- * rule: string,
39
- * file: string,
40
- * message: string,
41
- * fix: string,
42
- * }} Violation
43
- */
44
-
45
- /**
46
- * @typedef {{
47
- * name: string,
48
- * description: string,
49
- * }} RuleDescriptor
50
- */
51
-
52
- /**
53
- * All available rule names with descriptions. Useful for help text and
54
- * documentation generators.
55
- *
56
- * @type {RuleDescriptor[]}
57
- */
58
- export const RULES = [
59
- {
60
- name: 'components-have-register',
61
- description:
62
- 'Component files that define a class extending WebComponent must register the class with ClassName.register(\'tag\') (or customElements.define). The server-side scanner derives the module URL from the file path.',
63
- },
64
- {
65
- name: 'no-server-env-in-components',
66
- description:
67
- 'Component files (under components/ or modules/*/components/) must not read non-public environment variables. process.env.X is allowed when X starts with WEBJS_PUBLIC_ (exposed to the browser via the SSR shim) or equals NODE_ENV (also defined in the browser). Any other process.env read in a component would leak the server-side value into the SSR\'d HTML, then read as undefined after hydration. Read server-only env vars in a page function, server action, or middleware (which never reach the browser as source) and pass derived values to the component as attributes.',
68
- },
69
- {
70
- name: 'tag-name-has-hyphen',
71
- description:
72
- 'Static tag = \'...\' in component files must contain a hyphen (HTML custom element spec).',
73
- },
74
- {
75
- name: 'no-duplicate-tag',
76
- description:
77
- 'A custom-element tag name must be registered exactly once across the app. Two `Class.register(\'tag\')` / `customElements.define(\'tag\', …)` calls for the SAME tag resolve INCONSISTENTLY at runtime: SSR overwrites the registry (last registration wins) while the browser keeps the first native upgrade, so the rendered element and the webjs registry disagree. Rename one tag.',
78
- },
79
- {
80
- name: 'no-static-properties',
81
- description:
82
- 'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults in the constructor after super().',
83
- },
84
- {
85
- name: 'reactive-props-no-class-field',
86
- description:
87
- 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field declaration (`count = 0`, `count: number = 0`, `count!: number`, or `count?: number`) in the class body. Under modern class-field semantics (including `erasableSyntaxOnly: true`) every class-field declaration compiles to Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
88
- },
89
- {
90
- name: 'array-prop-uses-array-type',
91
- description:
92
- 'An array-typed reactive property declared via the `extends WebComponent({ … })` factory must pass the `Array` runtime constructor, not `Object`: `count: prop<Tag[]>(Array)`, never `prop<Tag[]>(Object)`. The two share one converter (both JSON-encode the value), so the wrong one does not crash, but `Object` misstates the prop contract to the next reader and diverges from the documented built-in set (String/Number/Boolean/Object/Array). Fires only when the factory generic is itself an array type (`T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>`) AND the constructor argument is `Object`; a bare `foo: Object` with no generic is never flagged. Fix: change the constructor to `Array`.',
93
- },
94
- {
95
- name: 'shell-in-non-root-layout',
96
- description:
97
- 'Only the root layout (app/layout.{js,ts}) may write a <!doctype>/<html>/<head>/<body> shell to override default <html lang>, <body class>, etc. Non-root layouts (app/<segment>/layout.{js,ts}) and pages (app/**/page.{js,ts}) must not: the framework auto-emits the wrapper around the whole composition, so a nested shell ends up nested inside <body> where browsers drop it. Triggers on any of <!doctype>, <html, <head, <body in a non-root layout or page.',
98
- },
99
- {
100
- name: 'erasable-typescript-only',
101
- description:
102
- 'Apps must opt into TypeScript\'s `erasableSyntaxOnly: true` so the compiler rejects non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators with emitDecoratorMetadata, import = require) at edit time. webjs strips types via Node\'s built-in `module.stripTypeScriptTypes`, which only supports erasable TypeScript and produces byte-exact position preservation (no sourcemap overhead). Files using non-erasable syntax fail at strip time and the dev server returns a 500 pointing at the no-non-erasable-typescript rule; webjs is buildless end-to-end and has no bundler fallback. The rule checks the project\'s tsconfig.json and warns when `erasableSyntaxOnly` is missing or set to false. Set `compilerOptions.erasableSyntaxOnly: true` in tsconfig.json to comply.',
103
- },
104
- {
105
- name: 'use-server-needs-extension',
106
- description:
107
- 'Files that declare the `\'use server\'` directive at the top must also have the `.server.{js,ts,mts,mjs}` extension. The two markers are complementary, not interchangeable: `.server.ts` is the path-level boundary that triggers source protection by the file router; `\'use server\'` is the semantic opt-in that registers exports as RPC-callable from client code. A `\'use server\'` directive without the extension is silently ignored: the file is served to the browser as plain source, exports are NOT registered as RPC, and code the developer expects to run on the server actually runs in the browser. Rename the file to add the `.server.` infix.',
108
- },
109
- {
110
- name: 'use-server-exports-callable',
111
- description:
112
- 'A `.server.{js,ts}` file that declares the `\'use server\'` directive registers its function exports as RPC-callable server actions, but only its FUNCTION exports: `buildActionIndex` / the stub generator register an export only when `typeof export === \'function\'`, so a `\'use server\'` file that exports zero functions (or only a non-function `const` / a type / only verb config like `method` / `cache`) registers NOTHING and gives no signal. The developer believes they exposed an action; nothing did, and the failure only surfaces as a 404 / undefined at the first call site. This is the complement of `use-server-needs-extension` (the directive without the extension) and of `one-action-per-configured-file` (more than one action in a configured file); this rule catches the directive-present-but-nothing-callable case. The rule asserts only that the file exports at least one callable, NOT that the action returns a value (a server action may be a void side-effect or throw `redirect()` / `notFound()` and never return). Conservative: a re-export (`export ... from`, `export *`) or an `export const NAME = <identifier-or-call>` (a possible factory-produced function such as `export const get = cache(fetch)`) is treated as a possible callable and NOT flagged, so the rule fires only when every export is provably non-callable. Fix: export an `async function` action, or drop the `\'use server\'` directive if the file is a plain server-only utility.',
113
- },
114
- {
115
- name: 'no-non-erasable-typescript',
116
- description:
117
- 'Scans .ts / .mts source for the four non-erasable TypeScript constructs (enum declarations, namespace blocks with value statements, constructor parameter properties, and `import = require`) that the framework\'s type-stripper rejects at request time. Companion to `erasable-typescript-only`: that rule checks the tsconfig flag, this rule checks the actual source. Both run by default so the flag check catches violations early in the editor while the source scan catches violations even if the tsconfig flag is missing or the rule is bypassed. Skips node_modules, dist, build, .git, .next, and _private folders.',
118
- },
119
- {
120
- name: 'no-browser-globals-in-render',
121
- description:
122
- 'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
123
- },
124
- {
125
- name: 'no-shadowed-native-member',
126
- description:
127
- 'Flags a WebComponent class method whose name collides with a native DOM mutation method that WebJs relies on and INSTRUMENTS on every light-DOM host for the slot API (#1021): append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, replaceChild. A method of the same name is SHADOWED at runtime (the instrumented native method wins), so the component method silently never runs, while TypeScript stays green because a zero/one-arg override is assignable to the native signature. Found dogfooding the stream demo (#248): a component named its button handler `append()`, so clicking Append called the slot-append no-op instead of the handler. Rename the handler to a non-native name (appendRow / prependRow / removeItem). The `render` / lifecycle hooks are MEANT to be overridden and are not flagged; only the native DOM mutation members are.',
128
- },
129
- {
130
- name: 'no-server-import-in-browser-module',
131
- description:
132
- 'A page / layout / component module that SHIPS to the browser (the build does NOT elide it) must not transitively import a server-only `.server.{ts,js}` module. The server module is replaced by a stub in the browser, so the import is fine while the module never loads client-side: a display-only page is elided, and a page whose only client relevance is importing shipping components is import-only (#605/#963), dropped from the boot in favour of its components. But the moment the page does its OWN client work (the client router, a reactive primitive, a module-scope browser-global access, a client-effecting non-component util) it ships whole, dragging the server import with it. The stub then throws (or a server-only export like `auth` is missing) the instant the module loads, a runtime browser crash that `webjs typecheck` and the rest of `webjs check` miss. The rule reuses the build\'s own elision verdict, so it ONLY fires on modules that genuinely ship; an elided, inert, or import-only page is never flagged. The fix is to keep the server call off the browser-shipped module: gate the route in `middleware.ts`, call the server through a `\'use server\'` action, or move the module\'s own client work into a component so the page is dropped again. Server-to-server imports (`.server.ts` importing `.server.ts`) and `middleware.ts` / `route.ts` (never shipped) are never flagged.',
133
- },
134
- {
135
- name: 'one-action-per-configured-file',
136
- description:
137
- 'A `\'use server\'` action file that declares HTTP-verb config (any of `method` / `cache` / `tags` / `invalidates` / `validate`, #488) must export exactly ONE callable action function. The config is file-level (it applies to the action in the file), so a second exported function would silently inherit the same verb / cache, which is almost never intended and makes the contract ambiguous. Move the extra function to its own `.server.ts` file (the one-function-per-file convention), or, if it is a private helper, do not export it. Files with no verb config are unaffected.',
138
- },
139
- {
140
- name: 'form-action-not-a-get-action',
141
- description:
142
- 'Flags `<form action=${someAction}>` bound to a `\'use server\'` action whose file declares `export const method = \'GET\'` (#488). A GET action is a READ: it is CSRF-exempt and rides its arguments in the url, while a form submission is a CSRF-checked POST carrying a body, so the two contracts contradict each other and the submission is answered with a 405 at runtime. The rule reads the imported action\'s own file, so it only fires when the binding really does resolve to a GET-declared action. Fix by dropping the `method` export (an action with no `method` is a POST, which is what a form wants) or by binding a different action; if the form really is a read, use a plain `<form method="get" action="/search">` with no bound action.',
143
- },
144
- {
145
- name: 'no-redirect-in-api-route',
146
- description:
147
- 'API route handlers (`route.{js,ts}`) must NOT call `redirect()` from `@webjsdev/core`. That function throws a control-flow signal designed for the SSR page renderer; in a route handler it goes uncaught and produces a 500. Use `Response.redirect(url, 303)` for external redirects or return a 3xx Response directly. Page functions, layouts, and server actions may still use `redirect()` (caught by the SSR pipeline).',
148
- },
149
- {
150
- name: 'no-interpolation-in-raw-text-element',
151
- description:
152
- 'Flags a template interpolation (`${...}`) placed as a child of a `<style>` or `<script>` element inside a COMPONENT `html` template. Raw-text elements are an SSR/client asymmetry trap: the server renderer emits the interpolated content, but the client renderer drops it (a raw-text hole is a `noop`, since the compile cache is keyed on the static strings), so the element renders correctly on the server and then wipes to empty on hydration. Scoped to components (files with a `WebComponent` class), which hydrate; pages and layouts render server-only and never hydrate, so a page interpolating a `css` result into a `<style>` is a legitimate pattern and is not flagged. In a component, author scoped CSS with `static styles` (shadow DOM) or a `css` template. Found dogfooding a tic-tac-toe app (#845): a `<style>${STYLE}</style>` painted at SSR then vanished on hydrate.',
153
- },
154
- {
155
- name: 'no-missing-local-import',
156
- description:
157
- 'Flags a NAMED value import of a symbol that a resolvable app-internal module does not export (`import { todos } from \'#db/schema.server.ts\'` when the schema no longer exports `todos`). The binding is `undefined` at runtime and crashes on first use, yet the rest of `webjs check` (elision-based, it does not compile types) misses it, so a schema swap that orphans a gallery module can pass `check` while `typecheck` is red. This is the runtime-crash class `check` exists to catch, filling that gap so an agent running only `check` cannot ship a broken import. Deliberately conservative to never false-positive on a valid app: it only inspects app-internal specifiers (a relative `./` path or a `#` alias) that resolve to a file in the app, only NAMED value imports (a `type` import, a default import, a namespace import, and any bare / `node:` / npm-package specifier are skipped), and it treats a module as UNKNOWABLE (never flags imports from it) when its exports are not fully enumerable: a `export * from ...` star re-export, a destructuring `export const { a } = ...`, or a multi-declarator `export const a = .., b = ..`. It reads names from `export function/class/const/let/var/type/interface/enum` and from `export { a, b as c }` / `export { x } from ...` clauses (the alias after `as` is the exported name). A re-export barrel therefore resolves correctly, and a `\'use server\'` action file exports its function names normally. Found dogfooding a tic-tac-toe app: dropping the example `todos` table left a gallery module importing it, green under `check` but red under `typecheck`.',
158
- },
159
- ];
160
-
161
- /** Set of all known rule names for fast lookup. */
162
- const RULE_NAMES = new Set(RULES.map((r) => r.name));
163
-
164
- /**
165
- * Check whether a file has the `'use server'` directive in its first
166
- * five lines. Used by the `use-server-needs-extension` rule, and by
167
- * `isServerActionFile` below.
168
- * @param {string} content file content (already read)
169
- * @returns {boolean}
170
- */
171
- function hasUseServerDirective(content) {
172
- const head = content.split('\n').slice(0, 5).join('\n');
173
- return /^\s*(['"])use server\1\s*;?\s*$/m.test(head);
174
- }
175
-
176
- /**
177
- * Check whether a file is a server action. A server action requires
178
- * BOTH the `.server.{js,ts,mts,mjs}` extension AND the `'use server'`
179
- * directive in the file head. Either alone is not enough: bare `.server.ts`
180
- * is a server-only utility (no RPC), and bare `'use server'` is a lint
181
- * violation (use-server-needs-extension).
182
- * @param {string} filePath absolute path
183
- * @param {string} content file content (already read)
184
- * @returns {boolean}
185
- */
186
- function isServerActionFile(filePath, content) {
187
- if (!/\.server\.m?[jt]s$/.test(filePath)) return false;
188
- return hasUseServerDirective(content);
189
- }
190
-
191
- /**
192
- * Check whether a file resides under a components/ directory (shared or
193
- * module-scoped).
194
- * @param {string} relPath Path relative to appDir
195
- * @returns {boolean}
196
- */
197
- function isComponentFile(relPath) {
198
- const segments = relPath.split(sep);
199
- return segments.includes('components');
200
- }
201
-
202
- /**
203
- * Scan a class body for class-field initializers naming any of `props`.
204
- * "Class-field" means: at the top of the class body (brace depth 0
205
- * relative to the body), at the start of a line, NOT prefixed with
206
- * `declare`, `static`, or `this.`.
207
- *
208
- * Returns the offending property names. The caller maps these to
209
- * Violation objects.
210
- *
211
- * @param {string} classBody
212
- * @param {Set<string>} props
213
- * @returns {string[]}
214
- */
215
- /**
216
- * True when a factory prop value is an array-typed `prop<…>(…)` whose
217
- * runtime constructor argument is `Object`. The generic and the
218
- * constructor sit in the same call, so it is decidable from the value
219
- * text alone. The match is greedy on the generic so a nested generic
220
- * (`prop<Array<X>>(Object)`) closes at the outer `>` that precedes the
221
- * `(Object` call. A bare constructor (`Object`, with no generic) or a
222
- * non-array generic (`prop<Foo>(Object)`) returns false.
223
- *
224
- * @param {string} value the raw prop value text, e.g. `prop<Tag[]>(Object)`
225
- * @returns {boolean}
226
- */
227
- function arrayPropUsesObject(value) {
228
- const m = /^prop\s*<([\s\S]*)>\s*\(\s*Object\s*[,)]/.exec(value.trim());
229
- if (!m) return false;
230
- return isArrayTypeText(m[1]);
231
- }
232
-
233
- /**
234
- * True when a TypeScript type expression denotes an array: `T[]`,
235
- * `readonly T[]`, `T[][]`, `Array<T>`, or `ReadonlyArray<T>`.
236
- *
237
- * @param {string} type
238
- * @returns {boolean}
239
- */
240
- function isArrayTypeText(type) {
241
- const bare = type.trim().replace(/^readonly\s+/, '');
242
- if (/\[\s*\]$/.test(bare)) return true;
243
- if (/^(?:Readonly)?Array\s*<[\s\S]*>$/.test(bare)) return true;
244
- return false;
245
- }
246
-
247
- function findFieldInitializers(classBody, props) {
248
- /** @type {string[]} */
249
- const out = [];
250
- const n = classBody.length;
251
- // Match class-field declarations. Two shapes:
252
- // 1. With initializer: `count = 0`, `count: number = 0`, `public count = 0`
253
- // 2. Type-only (no initializer): `count!: number`, `count?: number`, `count: number`
254
- // Both compile to Object.defineProperty after super() under modern class-field
255
- // semantics, clobbering the reactive accessor.
256
- // The initializer regex: optional modifier, identifier, optional type, then `=`.
257
- const initRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
258
- // The type-only regex: optional modifier, identifier, then `!:` or `?:` or `:` with a type.
259
- const typeOnlyRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*[!?]?\s*:\s*\S/;
260
- const examineLine = (lineStart) => {
261
- let j = lineStart;
262
- while (j < n && classBody[j] !== '\n') j++;
263
- const line = classBody.slice(lineStart, j);
264
- const initM = initRe.exec(line);
265
- const typeM = typeOnlyRe.exec(line);
266
- // Prefer the initializer match; if neither, skip.
267
- const name = initM ? initM[2] : (typeM ? typeM[2] : null);
268
- // `declare`, `static`, and `this.` patterns shouldn't reach here
269
- // (declare/static start with their keyword, this.x has the dot in
270
- // the regex group), but guard against matching keywords as names:
271
- if (name && name !== 'declare' && name !== 'static' && props.has(name)) out.push(name);
272
- };
273
- // Walk the body char by char, tracking brace depth. A class field lives at
274
- // the class-body top level (depth 0). We examine a line ONCE, at its first
275
- // non-whitespace char, only while depth is 0, and WITHOUT skipping the braces
276
- // on that line: an opening brace on the line (`method() {`, `field = {`) must
277
- // still be counted so the lines inside its block are seen at depth > 0. The
278
- // earlier version jumped `i` past the whole examined line, which dropped that
279
- // line's braces and let object-literal keys inside a method body (`{ game:
280
- // ..., scoreboard: ... }`) be misread as depth-0 class fields (#934).
281
- let depth = 0;
282
- let i = 0;
283
- let lineStart = 0;
284
- let examined = false;
285
- let str = '';
286
- while (i < n) {
287
- const c = classBody[i];
288
- if (str) {
289
- if (c === '\\') { i += 2; continue; }
290
- if (c === str) str = '';
291
- i++;
292
- continue;
293
- }
294
- if (c === '\n') { lineStart = i + 1; examined = false; i++; continue; }
295
- if (c === '/' && classBody[i + 1] === '/') {
296
- while (i < n && classBody[i] !== '\n') i++;
297
- continue;
298
- }
299
- if (c === '/' && classBody[i + 1] === '*') {
300
- i += 2;
301
- while (i < n && !(classBody[i] === '*' && classBody[i + 1] === '/')) i++;
302
- i += 2;
303
- continue;
304
- }
305
- if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
306
- // First non-whitespace char of a class-body top-level line: examine it for
307
- // a field declaration BEFORE consuming any brace it opens.
308
- if (depth === 0 && !examined && !/\s/.test(c)) {
309
- examined = true;
310
- examineLine(lineStart);
311
- }
312
- if (c === '{') { depth++; i++; continue; }
313
- if (c === '}') { depth--; i++; continue; }
314
- i++;
315
- }
316
- return out;
317
- }
318
-
319
- // Browser-only globals that are undefined during SSR (the server-side
320
- // WebComponent base is a bare class with no DOM). High-confidence names only
321
- // (unlikely to be ordinary local variables), so the rule stays low-noise.
322
- const BROWSER_GLOBALS = [
323
- 'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
324
- 'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
325
- 'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
326
- ];
327
- // HTMLElement instance members that do not exist on the server element shim,
328
- // so `this.<member>` throws (a method call) or is `undefined` (a property) at
329
- // SSR. The attribute methods (get/set/has/remove/toggleAttribute), the event
330
- // methods (add/removeEventListener, dispatchEvent), and attachInternals are
331
- // backed by the shim and run server-side, so they are intentionally NOT
332
- // flagged: a component may read attributes in render and reflect properties
333
- // during the SSR update cycle. What stays is the genuinely browser-only
334
- // surface (DOM querying, layout reads, shadow construction, focus).
335
- const HTMLELEMENT_MEMBERS = [
336
- 'attachShadow', 'shadowRoot', 'classList',
337
- 'querySelector', 'querySelectorAll', 'getBoundingClientRect',
338
- 'focus', 'blur', 'scrollIntoView',
339
- ];
340
-
341
- /**
342
- * Extract the body text of a named method from a (redacted) class body, or
343
- * '' if absent. Handles `async`, a TS return-type annotation, and params.
344
- * @param {string} classBody
345
- * @param {string} name
346
- */
347
- function methodBodyOf(classBody, name) {
348
- const re = new RegExp(`(?:^|[\\s;}])(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`, 'g');
349
- const m = re.exec(classBody);
350
- if (!m) return '';
351
- const open = classBody.indexOf('{', m.index + m[0].length - 1);
352
- if (open === -1) return '';
353
- const close = matchClosingBrace(classBody, open + 1);
354
- return close === -1 ? '' : classBody.slice(open + 1, close);
355
- }
356
-
357
- /**
358
- * Find browser-only globals and HTMLElement `this.<member>` accesses in a
359
- * (redacted) method body. Returns one entry per distinct member.
360
- * @param {string} code
361
- * @returns {{ member: string, kind: string }[]}
362
- */
363
- function findBrowserMemberUses(code) {
364
- // The class body arrives template-redacted, but `redactStringsAndTemplates`
365
- // keeps single/double-quoted string CONTENT (real specifiers ride strings).
366
- // Blank that too so a browser word inside a string literal (e.g. a label
367
- // `'open the document'`) is not mistaken for a real global access.
368
- code = code
369
- .replace(/'(?:[^'\\]|\\.)*'/g, (s) => `'${' '.repeat(Math.max(0, s.length - 2))}'`)
370
- .replace(/"(?:[^"\\]|\\.)*"/g, (s) => `"${' '.repeat(Math.max(0, s.length - 2))}"`);
371
- const out = [];
372
- const seen = new Set();
373
- const gRe = new RegExp(`(?<![.\\w$])(${BROWSER_GLOBALS.join('|')})\\b`, 'g');
374
- let m;
375
- while ((m = gRe.exec(code)) !== null) {
376
- if (seen.has(m[1])) continue;
377
- seen.add(m[1]);
378
- out.push({ member: m[1], kind: 'a browser global' });
379
- }
380
- const hRe = new RegExp(`\\bthis\\.(${HTMLELEMENT_MEMBERS.join('|')})\\b`, 'g');
381
- while ((m = hRe.exec(code)) !== null) {
382
- const key = `this.${m[1]}`;
383
- if (seen.has(key)) continue;
384
- seen.add(key);
385
- out.push({ member: key, kind: 'an HTMLElement member' });
386
- }
387
- return out;
388
- }
389
-
390
- /**
391
- * True if any `export const/let/var` declares more than one binding
392
- * (`export const a = 1, b = 2`), which the single-name collector would
393
- * under-count. Depth-aware: a comma inside an initializer (`f(a, b)`, `[a, b]`,
394
- * `{ a, b }`) does not count, only a top-level comma before the statement end.
395
- * Bailing the whole module on this errs toward a false negative (safe), never a
396
- * false positive. Runs on the string/comment-redacted `scan`.
397
- * @param {string} scan
398
- * @returns {boolean}
399
- */
400
- function hasMultiDeclaratorExport(scan) {
401
- const re = /\bexport\s+(?:const|let|var)\b/g;
402
- let m;
403
- while ((m = re.exec(scan))) {
404
- let depth = 0; // () [] {} nesting
405
- let angle = 0; // <> generics, tracked ONLY inside a type annotation
406
- let seenEq = false; // passed this declarator's `=` (now in the initializer)
407
- let inType = false; // inside a `: Type` annotation (before the `=`)
408
- for (let i = m.index + m[0].length; i < scan.length; i++) {
409
- const ch = scan[i];
410
- if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
411
- if (ch === ')' || ch === ']' || ch === '}') { if (depth === 0) break; depth--; continue; }
412
- if (depth !== 0) continue;
413
- if (ch === ';') break;
414
- // A plain `=` ends the type annotation and enters the initializer. `<=`,
415
- // `>=`, `=>`, `==` are harmless here (seenEq only latches true).
416
- if (ch === '=') { seenEq = true; inType = false; angle = 0; continue; }
417
- // A `:` BEFORE the `=` opens the type annotation. A `:` after `=` (a
418
- // ternary in the initializer) is NOT a type and must not suppress a real
419
- // declarator comma.
420
- if (ch === ':' && !seenEq) { inType = true; continue; }
421
- // Inside a type annotation `<` / `>` are generic delimiters (a comparison
422
- // only appears in the initializer, after `=`). Track their depth so a
423
- // generic's comma (`Map<string, number>`) is skipped while a real
424
- // declarator comma (`a: number, b: number`) still counts. A `>` that is
425
- // part of a `=>` function-type arrow is not a generic close.
426
- if (inType && ch === '<') { angle++; continue; }
427
- if (inType && ch === '>' && scan[i - 1] !== '=') { if (angle > 0) angle--; continue; }
428
- // A top-level comma starts a second declarator UNLESS it is a generic's
429
- // comma inside a type annotation.
430
- if (ch === ',' && !(inType && angle > 0)) return true;
431
- }
432
- }
433
- return false;
434
- }
435
-
436
- /**
437
- * Enumerate a module's provable named exports from its redacted `scan`, for the
438
- * `no-missing-local-import` rule. Returns `null` when the export list is NOT
439
- * fully enumerable (a `export *` star re-export, a destructuring export, or a
440
- * multi-declarator `export const a = .., b = ..`), which tells the rule to treat
441
- * the module as unknowable and flag nothing from it. Otherwise returns the set
442
- * of exported names (the alias after `as` for a clause, and type/interface/enum
443
- * names too, so a value import of a type is not falsely flagged; tsc owns that).
444
- * @param {string} scan string/template/comment-redacted source
445
- * @returns {Set<string>|null}
446
- */
447
- function enumerableExports(scan) {
448
- // Unknowable export shapes: bail so the rule never false-positives.
449
- if (/\bexport\s*\*/.test(scan)) return null; // export * from
450
- if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) return null; // destructuring export
451
- if (hasMultiDeclaratorExport(scan)) return null; // export const a=1, b=2
452
- const names = new Set();
453
- let m;
454
- const collect = (re) => { while ((m = re.exec(scan))) names.add(m[1]); };
455
- collect(/\bexport\s+(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)/g);
456
- collect(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g);
457
- collect(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g);
458
- collect(/\bexport\s+(?:type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)/g);
459
- // A named-default import (`import { default as Foo }`) is legal against an
460
- // `export default` module, so record the `default` name when present.
461
- if (/\bexport\s+default\b/.test(scan)) names.add('default');
462
- // `export { a, b as c }` / `export type { ... }` / `export { x } from '...'`:
463
- // the EXPORTED name is the alias after `as`, else the bare name.
464
- const reClause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
465
- while ((m = reClause.exec(scan))) {
466
- for (let part of m[1].split(',')) {
467
- part = part.trim().replace(/^type\s+/, '');
468
- if (!part) continue;
469
- const name = part.split(/\s+as\s+/).pop().trim();
470
- if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
471
- }
472
- }
473
- return names;
474
- }
475
-
476
- /**
477
- * The NAMED VALUE names a single `import { ... } from` clause pulls in, for the
478
- * `no-missing-local-import` rule. Returns `null` for an `import type { ... }`
479
- * (all type, skip), and `[]` for a default / namespace / side-effect import
480
- * (nothing named to verify). A per-specifier inline `type` marker is dropped,
481
- * and for `a as b` the IMPORTED name is `a` (what the target must export).
482
- * @param {string} clause the text between `import` and `from`
483
- * @returns {string[]|null}
484
- */
485
- /**
486
- * The `{ local, imported }` pairs a named import clause binds, for a rule that
487
- * has to match the LOCAL name against code (`action=${reader}`) while reporting
488
- * the IMPORTED one (`readIt`, which is what the target module calls it).
489
- *
490
- * `importedValueNames` cannot serve here: it returns only the imported side,
491
- * which is the wrong name to match against a template hole under `as`.
492
- *
493
- * @param {string} clause the text between `import` and `from`
494
- * @returns {{ local: string, imported: string }[]}
495
- */
496
- function importedLocalNames(clause) {
497
- if (/^\s*type\b/.test(clause)) return [];
498
- const brace = clause.match(/\{([^}]*)\}/);
499
- if (!brace) return [];
500
- /** @type {{ local: string, imported: string }[]} */
501
- const out = [];
502
- for (const part of brace[1].split(',')) {
503
- const seg = part.trim().replace(/^type\s+/, '');
504
- if (!seg) continue;
505
- const as = seg.split(/\s+as\s+/);
506
- const imported = as[0].trim();
507
- const local = (as[1] || as[0]).trim();
508
- if (/^[A-Za-z_$][\w$]*$/.test(local) && /^[A-Za-z_$][\w$]*$/.test(imported)) {
509
- out.push({ local, imported });
510
- }
511
- }
512
- return out;
513
- }
514
-
515
- function importedValueNames(clause) {
516
- if (/^\s*type\b/.test(clause)) return null; // import type { ... }
517
- const brace = clause.match(/\{([^}]*)\}/);
518
- if (!brace) return []; // default / namespace only
519
- return brace[1].split(',')
520
- .map((s) => s.trim())
521
- .filter((s) => s && !/^type\s/.test(s))
522
- .map((s) => s.split(/\s+as\s+/)[0].trim())
523
- .filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
524
- }
525
-
526
- /**
527
- * Scan a WebJs app directory and report convention violations.
528
- *
529
- * Every rule is a correctness check (a crash, a security leak, a reactive
530
- * prop that silently stops re-rendering, or a build/type-strip failure), so
531
- * they all run unconditionally. There is no
532
- * per-project disabling: project conventions (layout, style, process) live in
533
- * CONVENTIONS.md as guidance, not in this tool.
534
- *
535
- * @param {string} appDir absolute path to the app root (the directory
536
- * containing `app/`, `modules/`, `components/`, etc.)
537
- * @returns {Promise<Violation[]>}
538
17
  *
539
- * @example
540
- * ```js
541
- * import { checkConventions } from '@webjsdev/server';
542
- * const violations = await checkConventions('/path/to/myapp');
543
- * for (const v of violations) {
544
- * console.warn(`[${v.rule}] ${v.file}: ${v.message}`);
545
- * }
546
- * ```
547
- */
548
- export async function checkConventions(appDir) {
549
- /** @type {Violation[]} */
550
- const violations = [];
551
-
552
- // Collect all JS/TS files in the app directory. Each entry carries
553
- // both the raw `content` (for rules that need verbatim source: the
554
- // `'use server'` directive detector, the `.gitignore` reader, etc.)
555
- // and a `scan` view with comments, string contents, and
556
- // template-literal bodies redacted to whitespace. Rules that
557
- // pattern-match across raw source should consume `scan` so docs-
558
- // page code-block examples and JSDoc samples don't trigger false
559
- // positives.
560
- /** @type {{ abs: string, rel: string, content: string, scan: string }[]} */
561
- const files = [];
562
- for await (const abs of walk(appDir, (p) => /\.m?[jt]sx?$/.test(p))) {
563
- const rel = relative(appDir, abs);
564
- let content;
565
- try {
566
- content = await readFile(abs, 'utf8');
567
- } catch {
568
- continue;
569
- }
570
- files.push({ abs, rel, content, scan: redactStringsAndTemplates(content) });
571
- }
572
-
573
- // --- Rule: components-have-register ---
574
- {
575
- for (const { rel, scan } of files) {
576
- if (!isComponentFile(rel)) continue;
577
- // Use redacted source so a code-example string like
578
- // `Foo.register('bar')` inside a tagged template literal does
579
- // not falsely satisfy the rule for a sibling unregistered
580
- // class. Real register() calls live at top level where the
581
- // redactor leaves them alone.
582
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
583
- // Accept either registration pattern:
584
- // Counter.register('tag') (WebJs idiom)
585
- // customElements.define('tag', Counter) (native)
586
- if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"`]/.test(scan)) continue;
587
- if (/\bcustomElements\.define\s*\(/.test(scan)) continue;
588
- violations.push({
589
- rule: 'components-have-register',
590
- file: rel,
591
- message: "Component extends WebComponent but is never registered. Call ClassName.register('tag-name') at the bottom of the file.",
592
- fix: "Add `ClassName.register('tag-name')` after the class definition",
593
- });
594
- }
595
- }
596
-
597
- // --- Rule: no-static-properties ---
598
- // A hand-written `static properties = { … }` in a WebComponent class body is
599
- // no longer supported: reactive properties are declared via the
600
- // `extends WebComponent({ … })` factory (the runtime throws on a direct
601
- // `static properties`). Flag it statically so the editor catches it before
602
- // the page 500s.
603
- {
604
- for (const { rel, scan } of files) {
605
- // Use redacted source so fixture-style strings like
606
- // `class X extends WebComponent { static properties = {…} }` inside
607
- // template literals don't trip the rule. Real declarations live at
608
- // top-level code where the redactor leaves them alone.
609
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
610
- for (const { body } of extractWebComponentClassBodies(scan)) {
611
- if (!/static\s+properties\s*=\s*\{/.test(body)) continue;
612
- violations.push({
613
- rule: 'no-static-properties',
614
- file: rel,
615
- message:
616
- '`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
617
- fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults in the constructor after super(). Delete the `static properties` block and any `declare` fields for those props.',
618
- });
619
- }
620
- }
621
- }
622
-
623
- // --- Rule: reactive-props-no-class-field ---
624
- // A reactive property declared via the factory must not also carry a plain
625
- // class-field declaration (initializer OR type-only): under modern class-field
626
- // semantics (including `erasableSyntaxOnly: true`) every class-field declaration
627
- // compiles to Object.defineProperty after super() and clobbers the framework's
628
- // reactive accessor (silent broken re-renders). Catches `count = 0`,
629
- // `count: number = 0`, `count!: number`, and `count?: number`.
630
- {
631
- for (const { rel, scan } of files) {
632
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
633
- for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
634
- if (factoryProps.size === 0) continue;
635
- for (const bad of findFieldInitializers(body, factoryProps)) {
636
- violations.push({
637
- rule: 'reactive-props-no-class-field',
638
- file: rel,
639
- message: `Reactive prop \`${bad}\` uses a class-field declaration (initializer or type-only); this clobbers the framework's reactive accessor under modern class-field semantics.`,
640
- fix: `Delete the class-field declaration and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
641
- });
642
- }
643
- }
644
- }
645
- }
646
-
647
- // --- Rule: array-prop-uses-array-type ---
648
- // An array-typed reactive prop declared via the factory should pass the
649
- // `Array` runtime constructor, not `Object`. Both share one converter
650
- // (JSON encode/decode), so `Object` does not crash, but it misstates the
651
- // prop contract and diverges from the documented built-in set. Fires only
652
- // when the factory generic is itself an array type AND the constructor is
653
- // `Object`; a bare `foo: Object` (no generic to prove array-ness) is left
654
- // alone to avoid false positives. Uses the redacted `scan`, so a
655
- // `prop<X[]>(Object)` shown inside an html`` example string never fires.
656
- {
657
- for (const { rel, scan } of files) {
658
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
659
- for (const { factoryArg } of extractWebComponentClassBodies(scan)) {
660
- const objStart = factoryArg.indexOf('{');
661
- if (objStart === -1) continue;
662
- const objEnd = matchClosingBrace(factoryArg, objStart + 1);
663
- if (objEnd === -1) continue;
664
- const objContent = factoryArg.slice(objStart + 1, objEnd);
665
- for (const { key, value } of parsePropEntries(objContent)) {
666
- if (!arrayPropUsesObject(value)) continue;
667
- violations.push({
668
- rule: 'array-prop-uses-array-type',
669
- file: rel,
670
- message: `Array-typed reactive prop \`${key}\` is declared with the \`Object\` constructor (\`prop<…[]>(Object)\`); use \`Array\` so the runtime converter matches the declared shape.`,
671
- fix: `Change the constructor to \`Array\`: \`${key}: prop<…[]>(Array)\`. Object and Array share one converter so behaviour is unchanged, but \`Array\` states the prop's shape correctly.`,
672
- });
673
- }
674
- }
675
- }
676
- }
677
-
678
- // --- Rule: no-browser-globals-in-render ---
679
- // The SSR pipeline runs the constructor (`new Cls()`), willUpdate, and
680
- // render() on the server element shim (attribute methods backed, but no real
681
- // DOM). A genuinely browser-only global or an unshimmed HTMLElement member on
682
- // `this` touched in any of those throws at SSR time. Those belong in
683
- // connectedCallback / post-render hooks, which SSR never calls. willUpdate is
684
- // scanned because it now runs at SSR (issue #217).
685
- {
686
- for (const { rel, scan } of files) {
687
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
688
- for (const { body } of extractWebComponentClassBodies(scan)) {
689
- for (const method of ['constructor', 'willUpdate', 'render']) {
690
- const code = methodBodyOf(body, method);
691
- if (!code) continue;
692
- for (const { member, kind } of findBrowserMemberUses(code)) {
693
- violations.push({
694
- rule: 'no-browser-globals-in-render',
695
- file: rel,
696
- message: `\`${member}\` (${kind}) is used in ${method}(), which runs during SSR where it is not available, so it throws and the component fails to server-render.`,
697
- fix: `Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls. Seed first-paint defaults in the constructor only from server-known inputs (attributes / props), then refine in connectedCallback by writing to a signal.`,
698
- });
699
- }
700
- }
701
- }
702
- }
703
- }
704
-
705
- // --- Rule: no-shadowed-native-member ---
706
- // A WebComponent method named after a native DOM mutation method WebJs
707
- // instruments on every light-DOM host for the slot API (#1021) is SHADOWED at
708
- // runtime (the native/instrumented method wins), so the component method never
709
- // runs, while TypeScript stays green (a shorter override is assignable). Rename
710
- // the handler. The lifecycle hooks are meant to be overridden, so only the DOM
711
- // mutation members are reserved.
712
- {
713
- const NATIVE_MEMBERS = [
714
- 'append', 'prepend', 'before', 'after', 'replaceWith', 'replaceChildren', 'remove',
715
- 'appendChild', 'insertBefore', 'removeChild', 'replaceChild',
716
- ];
717
- for (const { rel, scan } of files) {
718
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
719
- for (const { body } of extractWebComponentClassBodies(scan)) {
720
- // The slot interception installs only on LIGHT-DOM hosts (the shadow
721
- // branch keeps the true native methods), so a shadow component's method
722
- // is never shadowed. Skip the whole class.
723
- if (/static\s+shadow\s*=\s*true/.test(body)) continue;
724
- // Brace-depth map over the (already string/comment-masked) class body,
725
- // so only TOP-LEVEL class members flag: a same-named object-literal
726
- // shorthand or function expression nested inside a method body is a
727
- // different object's property and shadows nothing.
728
- const depth = new Int32Array(body.length);
729
- {
730
- let d = 0;
731
- for (let i = 0; i < body.length; i++) {
732
- if (body[i] === '{') { depth[i] = d; d++; }
733
- else if (body[i] === '}') { d--; depth[i] = d; }
734
- else depth[i] = d;
735
- }
736
- }
737
- for (const name of NATIVE_MEMBERS) {
738
- // Two shadowed-definition shapes, both instance-level: the method /
739
- // accessor form (`remove() {`, `get remove() {`), and the class-field
740
- // FUNCTION form (`remove = () => {}` / `= function () {}`), which the
741
- // connect-time interception install overwrites just the same. A
742
- // `static` member lives on the constructor and shadows nothing.
743
- const methodRe = new RegExp(
744
- `(?:^|[\\s;}])((?:static\\s+)?)(?:async\\s+)?(?:get\\s+|set\\s+)?(${name})\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`,
745
- 'g',
746
- );
747
- const fieldRe = new RegExp(
748
- `(?:^|[\\s;}])((?:static\\s+)?)(${name})\\s*=\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]*)?=>|[\\w$]+\\s*=>)`,
749
- 'g',
750
- );
751
- let flagged = false;
752
- for (const re of [methodRe, fieldRe]) {
753
- let m;
754
- while (!flagged && (m = re.exec(body)) !== null) {
755
- if (m[1]) continue; // static: not shadowed
756
- const nameIdx = m.index + m[0].indexOf(m[2]);
757
- if (depth[nameIdx] !== 0) continue; // nested: another object's property
758
- flagged = true;
759
- violations.push({
760
- rule: 'no-shadowed-native-member',
761
- file: rel,
762
- message: `Component member \`${name}\` shadows the native DOM method WebJs instruments for the slot API, so it silently never runs (the native method wins) and TypeScript does not catch it.`,
763
- fix: `Rename the member to a non-native name (e.g. \`${name}Row()\` / \`${name}Item()\`) and update its call sites.`,
764
- });
765
- }
766
- if (flagged) break;
767
- }
768
- }
769
- }
770
- }
771
- }
772
-
773
- // --- Rule: no-redirect-in-api-route ---
774
- // `redirect()` from `@webjsdev/core` throws a control-flow signal designed
775
- // for the SSR page renderer. In a `route.ts` API handler it goes uncaught
776
- // and produces a 500. API handlers must use `Response.redirect(url, 303)`
777
- // instead. Page functions, layouts, and server actions may still use
778
- // `redirect()` (caught by the SSR / action pipeline).
779
- {
780
- const ROUTE_FILE = /(?:^|\/)route\.m?[jt]s$/;
781
- for (const { rel, scan } of files) {
782
- if (!ROUTE_FILE.test(rel)) continue;
783
- // `redirect` reaches the route file in one of two statically-visible ways:
784
- // 1. A NAMED import from `@webjsdev/core` (possibly aliased):
785
- // `import { redirect }`, `import { redirect as r }`, `import { …, redirect, … }`.
786
- // 2. A NAMESPACE import then a member call:
787
- // `import * as core from '@webjsdev/core'` then `core.redirect(...)`.
788
- // The named case flags a bare `redirect(` call; the namespace case flags
789
- // `<ns>.redirect(`. `Response.redirect(` and any other `obj.redirect(` are
790
- // the standard API and stay fine. A `redirect()` thrown inside a
791
- // '`use server`' action the route calls DIRECTLY (an uncaught 500) needs
792
- // cross-file analysis and is left to the AST rework (#753).
793
- const namedM = /\bimport\s+\{[^}]*\bredirect\b(?:\s+as\s+(\w+))?\s*[^}]*\}\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
794
- const nsM = /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
795
- // A file can carry BOTH a named `redirect` import AND a namespace import,
796
- // so check every matcher independently (not mutually exclusive): a named
797
- // import means a bare `<localName>(`, a namespace import means
798
- // `<ns>.redirect(`. The `member` flag distinguishes the two so a bare
799
- // named call can screen out `Response.redirect(` / `obj.redirect(`.
800
- /** @type {Array<{ re: RegExp, member: boolean }>} */
801
- const matchers = [];
802
- if (namedM) {
803
- const localName = namedM[1] || 'redirect';
804
- matchers.push({ re: new RegExp(`(?<!\\.)\\b${localName}\\s*\\(`, 'g'), member: false });
805
- }
806
- if (nsM) {
807
- matchers.push({ re: new RegExp(`\\b${nsM[1]}\\.redirect\\s*\\(`, 'g'), member: true });
808
- }
809
- let flagged = false;
810
- for (const { re, member } of matchers) {
811
- if (flagged) break;
812
- let m;
813
- while ((m = re.exec(scan)) !== null) {
814
- if (!member) {
815
- // Screen out `Response.redirect(` / `someObj.redirect(` sharing the
816
- // local name: a preceding member-access dot means it is not the import.
817
- const before = scan.slice(Math.max(0, m.index - 20), m.index);
818
- if (/\w\.$/.test(before)) continue;
819
- }
820
- violations.push({
821
- rule: 'no-redirect-in-api-route',
822
- file: rel,
823
- message:
824
- `\`redirect()\` from \`@webjsdev/core\` throws a control-flow signal for the SSR page renderer; in a \`route.ts\` handler it goes uncaught and returns a 500.`,
825
- fix: `Use \`Response.redirect(url, 303)\` for external redirects, or return a 3xx Response directly. The \`redirect()\` sentinel is only valid in page functions, layouts, and server actions (where the SSR pipeline catches it).`,
826
- });
827
- flagged = true; // one violation per file is enough
828
- break;
829
- }
830
- }
831
- }
832
- }
833
-
834
- // --- Rule: no-interpolation-in-raw-text-element ---
835
- // A `${...}` hole inside a `<style>` or `<script>` element in an `html`
836
- // template is an SSR/client asymmetry: `renderToString` emits it, but the
837
- // client parser drops a raw-text hole as a `noop` (the compile cache is keyed
838
- // on the static strings, so a per-render value cannot be baked in), so the
839
- // element paints at SSR then wipes to empty on hydration.
840
- //
841
- // Scoped to COMPONENTS. The drop only happens on the CLIENT renderer, which
842
- // runs for components (hydration + re-render). Pages and layouts render
843
- // server-only (never hydrate), so a page's `<style>${STYLES.text}</style>` is
844
- // a legitimate, taught pattern and must NOT be flagged. Scan raw source with
845
- // comments stripped (the tag text lives in a template string, which the
846
- // redacted `scan` view blanks). One violation per file.
847
- {
848
- for (const { rel, content } of files) {
849
- // Only files that define a hydrating custom element. A page/layout that
850
- // interpolates a `css` result into a `<style>` is server-only and fine.
851
- if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
852
- const stripped = content
853
- .replace(/\/\/.*$/gm, '')
854
- .replace(/\/\*[\s\S]*?\*\//g, '');
855
- for (const tag of ['style', 'script']) {
856
- // `<tag ...> ... ${ ... </tag>`, where the hole sits before the close
857
- // tag. The negative lookahead keeps the match from crossing `</tag>`.
858
- const re = new RegExp(
859
- `<${tag}\\b[^>]*>(?:(?!<\\/${tag}>)[\\s\\S])*?\\$\\{(?:(?!<\\/${tag}>)[\\s\\S])*?<\\/${tag}>`,
860
- 'i',
861
- );
862
- if (re.test(stripped)) {
863
- violations.push({
864
- rule: 'no-interpolation-in-raw-text-element',
865
- file: rel,
866
- message: `An interpolation (\`\${...}\`) sits inside a <${tag}> element in an html template. The server renderer emits it but the client renderer drops it, so it paints at SSR then wipes to empty on hydration.`,
867
- fix:
868
- tag === 'style'
869
- ? `Move the CSS out of the raw-text hole: use \`static styles\` (shadow DOM) or a \`css\` template for a component, or put page CSS in the layout. Static \`<style>...</style>\` with no \`\${}\` is fine.`
870
- : `Build the script body outside the raw-text element (set attributes/properties via bindings, or compute the value before the template). Static \`<script>...</script>\` with no \`\${}\` is fine.`,
871
- });
872
- break; // one violation per file is enough
873
- }
874
- }
875
- }
876
- }
877
-
878
- // --- Rule: no-server-env-in-components ---
879
- // Catches `process.env.X` reads in component files where X is not a
880
- // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
881
- // two categories to the browser; any other read either leaks a secret
882
- // into the SSR'd HTML or reads as undefined after hydration.
883
- {
884
- for (const { abs, rel, content } of files) {
885
- if (!isComponentFile(rel)) continue;
886
- if (isServerActionFile(abs, content)) continue;
887
-
888
- const re = /\bprocess\.env\.([A-Z][A-Z0-9_]*)\b/g;
889
- const seen = new Set();
890
- let m;
891
- while ((m = re.exec(content)) !== null) {
892
- const name = m[1];
893
- if (name.startsWith('WEBJS_PUBLIC_')) continue;
894
- if (name === 'NODE_ENV') continue;
895
- if (seen.has(name)) continue;
896
- seen.add(name);
897
- violations.push({
898
- rule: 'no-server-env-in-components',
899
- file: rel,
900
- message: `Component reads process.env.${name}; server-only env vars must not be read in components (would leak into SSR'd HTML and read as undefined after hydration)`,
901
- fix: `Either rename to WEBJS_PUBLIC_${name} if the value is intended for the browser, or read process.env.${name} in a page function / server action / middleware and pass a derived value to the component as an attribute.`,
902
- });
903
- }
904
- }
905
- }
906
-
907
- // --- Rule: shell-in-non-root-layout ---
908
- // Only app/layout.{js,ts} may write <!doctype>/<html>/<head>/<body>. The
909
- // framework auto-emits the shell around the whole composition; a nested
910
- // shell ends up duplicated and silently dropped by the HTML parser.
911
- {
912
- // Root layout = exactly "app/layout.js" or "app/layout.ts".
913
- const ROOT_LAYOUT = /^app\/layout\.(?:js|mjs|ts|mts)$/;
914
- // Any other layout or page under app/ (including pages, nested layouts).
915
- const LAYOUT_OR_PAGE = /^app\/(?:.+\/)?(?:layout|page)\.(?:js|mjs|ts|mts)$/;
916
- // Shell tags. Case-insensitive, allow whitespace, allow attributes for <html>/<body>.
917
- const SHELL_RE = /<!doctype\b|<html\b|<head\b|<body\b/i;
918
- for (const { rel, content } of files) {
919
- if (ROOT_LAYOUT.test(rel)) continue;
920
- if (!LAYOUT_OR_PAGE.test(rel)) continue;
921
- // Strip line comments + /* … */ block comments + ` ` string-template
922
- // tag content is fine: we're looking at the literal HTML in the
923
- // returned `html` template, which won't be inside a code comment.
924
- // A naive substring scan is good enough; false positives only when
925
- // someone genuinely embeds `<html>` inside a string literal that
926
- // isn't a layout shell (rare and probably an honest code smell).
927
- const stripped = content
928
- .replace(/\/\/.*$/gm, '')
929
- .replace(/\/\*[\s\S]*?\*\//g, '');
930
- const m = stripped.match(SHELL_RE);
931
- if (m) {
932
- violations.push({
933
- rule: 'shell-in-non-root-layout',
934
- file: rel,
935
- message:
936
- `Non-root layout/page contains ${m[0]}: only the root layout (app/layout.{js,ts}) may write the shell. The framework auto-emits <!doctype>/<html>/<head>/<body> around the whole composition; a nested shell ends up duplicated and dropped by the HTML parser.`,
937
- fix:
938
- 'Remove the <!doctype>/<html>/<head>/<body> wrapper from this file. Use the `metadata` export for <title>/<meta>/og/twitter, return inline <link>/<style>/<script> for head-bound resources (they auto-hoist), and put any `<html lang>` / `<body class>` overrides in app/layout.{js,ts} instead.',
939
- });
940
- }
941
- }
942
- }
943
-
944
- // --- Rule: erasable-typescript-only ---
945
- // The dev server's type-stripper is Node's built-in
946
- // module.stripTypeScriptTypes, which rejects non-erasable TS (enum,
947
- // namespace with values, constructor parameter properties, legacy
948
- // decorators, `import = require`). There is no fallback: non-erasable
949
- // syntax is rejected at request time with a 500. Enforce TS-side
950
- // rejection of those patterns via `compilerOptions.erasableSyntaxOnly:
951
- // true` in tsconfig.json so violations surface as red squiggles in
952
- // the editor before they ever hit the dev server. The companion
953
- // no-non-erasable-typescript rule (below) catches violations even if
954
- // the tsconfig flag is unset.
955
- {
956
- let tsconfigContent = null;
957
- try {
958
- tsconfigContent = await readFile(join(appDir, 'tsconfig.json'), 'utf8');
959
- } catch {
960
- // No tsconfig.json (pure JS app). Skip the rule.
961
- }
962
- if (tsconfigContent != null) {
963
- let parsed = null;
964
- try {
965
- const stripped = tsconfigContent
966
- .replace(/\/\/.*$/gm, '')
967
- .replace(/\/\*[\s\S]*?\*\//g, '')
968
- .replace(/,(\s*[}\]])/g, '$1');
969
- parsed = JSON.parse(stripped);
970
- } catch {
971
- parsed = null;
972
- }
973
- const compilerOptions = parsed && typeof parsed === 'object' ? parsed.compilerOptions : null;
974
- const flag = compilerOptions && typeof compilerOptions === 'object' ? compilerOptions.erasableSyntaxOnly : undefined;
975
- if (flag !== true) {
976
- violations.push({
977
- rule: 'erasable-typescript-only',
978
- file: 'tsconfig.json',
979
- message:
980
- flag === false
981
- ? '`compilerOptions.erasableSyntaxOnly` is `false`. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Non-erasable syntax (enum, namespace with values, constructor parameter properties, legacy decorators) fails at strip time and the dev server returns a 500. webjs is buildless end-to-end and has no bundler fallback; turn the flag on so the TypeScript compiler catches non-erasable constructs as red squiggles at edit time.'
982
- : '`compilerOptions.erasableSyntaxOnly` is not set. The framework strips TypeScript via Node\'s built-in stripper, which only supports erasable TS. Setting this flag makes the TypeScript compiler flag non-erasable syntax as a red squiggle in the editor instead of letting it silently slip through to a 500 at runtime.',
983
- fix:
984
- 'Set `"erasableSyntaxOnly": true` under `compilerOptions` in tsconfig.json. Replace any existing `enum` declarations with `const X = { ... } as const` plus a `type X = typeof X[keyof typeof X]` union. Replace constructor parameter properties with explicit field declarations + assignments.',
985
- });
986
- }
987
- }
988
- }
989
-
990
- // --- Rule: no-non-erasable-typescript ---
991
- // Scans .ts source for the four non-erasable TypeScript constructs
992
- // that the runtime stripper rejects. Complement to
993
- // erasable-typescript-only: the flag check catches the case where
994
- // the user opts into the tsconfig flag; this scan catches the
995
- // case where the flag is missing OR the user has bypassed it and
996
- // written offending syntax anyway. Both rules ship enabled by
997
- // default so violators get the strongest signal possible.
998
- {
999
- /** @type {Array<{ name: string, regex: RegExp, fix: string }>} */
1000
- const NON_ERASABLE_PATTERNS = [
1001
- {
1002
- name: 'enum',
1003
- // Matches `enum X {`, `export enum X {`, `const enum X {`,
1004
- // `declare enum X {`. Requires uppercase first letter on the
1005
- // identifier to avoid matching variables literally named "enum"
1006
- // in user code (rare but possible).
1007
- regex: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?(?:const[ \t]+)?enum[ \t]+[A-Z]\w*[ \t]*\{/m,
1008
- fix: 'Replace `enum Foo { A, B }` with `const Foo = { A: "A", B: "B" } as const; type Foo = typeof Foo[keyof typeof Foo];`.',
1009
- },
1010
- {
1011
- name: 'namespace with values',
1012
- // Matches `namespace Foo { ... <value statement> ... }` at top
1013
- // level. Type-only namespaces (which ARE erasable) won't contain
1014
- // `let|const|var|function|class` as statements, so this catches
1015
- // only the value-carrying form. False positives possible for
1016
- // type-only namespaces that contain those words in type aliases;
1017
- // accept this as a soft warning.
1018
- regex: /^[ \t]*(?:export[ \t]+)?namespace[ \t]+\w+[ \t]*\{[\s\S]*?\b(?:let|const|var|function|class)\b/m,
1019
- fix: 'Replace `namespace Foo { export const x = 1 }` with `export const Foo = { x: 1 } as const;` or split the contents into separate modules.',
1020
- },
1021
- {
1022
- name: 'constructor parameter property',
1023
- // Matches `constructor(public x: T)`, `constructor(private foo, ...)`,
1024
- // `constructor(readonly bar)`. Looks for one of the four access
1025
- // modifiers immediately followed by an identifier inside the
1026
- // constructor's parameter list.
1027
- regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/,
1028
- fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`.',
1029
- },
1030
- {
1031
- name: 'import = require',
1032
- // TypeScript-style CommonJS import. Catches `import foo =
1033
- // require("bar")` and `export import foo = require("bar")`.
1034
- regex: /^[ \t]*(?:export[ \t]+)?import[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/m,
1035
- fix: 'Replace `import foo = require("bar")` with `import * as foo from "bar"` or `import { something } from "bar"`.',
1036
- },
1037
- ];
1038
-
1039
- // Walk every .ts / .mts file under appDir, skipping node_modules,
1040
- // build outputs, version control, and the framework's own private
1041
- // folders. Match the conventional excludes that fs-walk.js's caller
1042
- // contract expects.
1043
- for await (const abs of walk(appDir, (p) => /\.m?ts$/.test(p))) {
1044
- // Skip anything inside node_modules or common build / cache dirs.
1045
- const relPath = relative(appDir, abs);
1046
- if (
1047
- relPath.includes('node_modules' + sep) ||
1048
- relPath.startsWith('dist' + sep) ||
1049
- relPath.startsWith('build' + sep) ||
1050
- relPath.startsWith('.next' + sep) ||
1051
- relPath.startsWith('.git' + sep) ||
1052
- relPath.split(sep).some((s) => s.startsWith('_'))
1053
- ) {
1054
- continue;
1055
- }
1056
- let content;
1057
- try {
1058
- content = await readFile(abs, 'utf8');
1059
- } catch {
1060
- continue;
1061
- }
1062
- // Redact comments, string contents, and template-literal bodies
1063
- // so docs-page code examples like `<pre>enum Direction { ... }</pre>`
1064
- // inside `html\`...\`` template literals don't trip the rule.
1065
- // The redactor preserves line + column so the reported line
1066
- // number still maps to the right place in the original.
1067
- const scan = redactStringsAndTemplates(content);
1068
- for (const { name, regex, fix } of NON_ERASABLE_PATTERNS) {
1069
- const m = scan.match(regex);
1070
- if (m && typeof m.index === 'number') {
1071
- const line = content.slice(0, m.index).split('\n').length;
1072
- violations.push({
1073
- rule: 'no-non-erasable-typescript',
1074
- file: relPath,
1075
- message: `Non-erasable TypeScript construct (${name}) detected at line ${line}. The framework's type-stripper rejects this at request time with a 500.`,
1076
- fix,
1077
- });
1078
- }
1079
- }
1080
- }
1081
- }
1082
-
1083
- // --- Rule: use-server-needs-extension ---
1084
- // Catch files that declare `'use server'` at the top but lack the
1085
- // `.server.{js,ts}` extension. Under the two-marker convention the
1086
- // directive alone does nothing (the file is served to the browser as
1087
- // plain source and exports are not registered as RPC), which is a
1088
- // silent footgun. The fix is mechanical: rename the file.
1089
- {
1090
- for (const { rel, content } of files) {
1091
- if (!hasUseServerDirective(content)) continue;
1092
- if (/\.server\.m?[jt]s$/.test(rel)) continue; // OK: has both markers
1093
- const fileBase = basename(rel);
1094
- const renamedBase = fileBase.replace(/\.(m?[jt]sx?)$/, '.server.$1');
1095
- violations.push({
1096
- rule: 'use-server-needs-extension',
1097
- file: rel,
1098
- message:
1099
- "File declares `'use server'` but its name does not match `.server.{js,ts,mts,mjs}`. The directive is silently ignored: the file is served to the browser as plain source and its exports are not RPC-callable. Code the developer expects to run on the server actually runs in the browser.",
1100
- fix: `Rename to ${renamedBase} (add the .server. infix before the extension)`,
1101
- });
1102
- }
1103
- }
1104
-
1105
- // --- Rule: use-server-exports-callable (#464) ---
1106
- // A `.server.{js,ts}` file with the `'use server'` directive registers only
1107
- // its FUNCTION exports as RPC actions (the registrar checks `typeof === 'function'`).
1108
- // A file that declares the directive but exports no callable registers nothing,
1109
- // silently: the developer thinks they exposed an action, and the only signal is
1110
- // a 404 / undefined at the first call site. Flag it. The complement of
1111
- // use-server-needs-extension (the directive without the extension) and of
1112
- // one-action-per-configured-file (more than one action).
1113
- {
1114
- for (const { rel, content, scan } of files) {
1115
- // Only properly-marked action files (the extension boundary). A `'use
1116
- // server'` file WITHOUT the .server. extension is the use-server-needs-
1117
- // extension rule's job; do not double-flag it here.
1118
- if (!/\.server\.m?[jt]s$/.test(rel)) continue;
1119
- if (!hasUseServerDirective(content)) continue;
1120
- // Count function-shaped EXPORTED callables, the SAME way the action
1121
- // registrar sees them: function declarations + arrow / function-expression
1122
- // consts (with an optional `: Type` annotation, #495). Reserved verb-config
1123
- // names (method/cache/...) are config, never a callable action.
1124
- const names = new Set();
1125
- let m;
1126
- const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
1127
- while ((m = reFn.exec(scan))) names.add(m[1]);
1128
- const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
1129
- while ((m = reArrow.exec(scan))) names.add(m[1]);
1130
- const callables = [...names].filter((n) => !RESERVED_CONFIG.has(n));
1131
- if (callables.length > 0) continue; // exports at least one callable -> fine
1132
- // A default export is assumed callable (an action commonly default-exports).
1133
- if (/\bexport\s+default\b/.test(scan)) continue;
1134
- // Conservative, avoid a FALSE POSITIVE: the runtime registrar
1135
- // (`actionFunctionNames`) keeps EVERY export whose value is a function at
1136
- // load time, regardless of the export syntax, so any export shape these
1137
- // patterns cannot prove is non-callable must NOT be flagged. Skip when the
1138
- // file has:
1139
- // - a named-export clause `export { a, b as c }` (with or WITHOUT `from`):
1140
- // it can surface a local function (`function getX(){}; export { getX }`)
1141
- // or a re-exported / imported function, neither matched above;
1142
- // - a star re-export `export * from ...`;
1143
- // - a destructuring export `export const { x } = obj` / `export const [x] = arr`,
1144
- // which may bind a function;
1145
- // - an `export const NAME = <identifier-or-call>`, a factory-produced
1146
- // function (`export const get = cache(fetch)`).
1147
- // A sole `export { aConst }` of a non-function is then a tolerated FALSE
1148
- // NEGATIVE, which is the right bias for a non-overridable correctness rule.
1149
- if (/\bexport\s*\{/.test(scan)) continue;
1150
- if (/\bexport\s*\*/.test(scan)) continue;
1151
- if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) continue;
1152
- // A factory / identifier-valued const could be a callable ACTION, so skip,
1153
- // BUT only when its name is NOT a reserved config key: the registrar
1154
- // excludes reserved names from actions (`actionFunctionNames`), so a file
1155
- // whose only ambiguous const is `validate` / `tags` / ... (config produced
1156
- // by an arrow or a factory) still has zero actions and must flag. Skip only
1157
- // for a NON-reserved ambiguous const (a real possible action).
1158
- const reFactory = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=\s*[A-Za-z_$(]/g;
1159
- let ambiguousAction = false;
1160
- while ((m = reFactory.exec(scan))) { if (!RESERVED_CONFIG.has(m[1])) { ambiguousAction = true; break; } }
1161
- if (ambiguousAction) continue;
1162
- // Every export is provably non-callable (a literal const, a type) or there
1163
- // are none: the directive exposes nothing.
1164
- violations.push({
1165
- rule: 'use-server-exports-callable',
1166
- file: rel,
1167
- message:
1168
- "File declares `'use server'` but exports no callable action. The `'use server'` directive registers FUNCTION exports as RPC-callable; a file exporting only a non-function (a `const` / a type / only verb config) registers nothing, so a client import resolves to nothing and the call 404s.",
1169
- fix: "Export an `async function` action from this file, or remove the `'use server'` directive if it is a plain server-only utility.",
1170
- });
1171
- }
1172
- }
1173
-
1174
- // --- Rule: one-action-per-configured-file (#488) ---
1175
- // A `'use server'` file that declares HTTP-verb config (method/cache/tags/
1176
- // invalidates/validate) must export exactly one callable action; the config
1177
- // is file-level, so a second exported function would silently inherit it.
1178
- {
1179
- for (const { rel, content, scan } of files) {
1180
- if (!/\.server\.m?[jt]s$/.test(rel)) continue;
1181
- if (!hasUseServerDirective(content)) continue;
1182
- if (!/\bexport\s+const\s+(?:method|cache|tags|invalidates|validate|middleware)\b/.test(scan)) continue;
1183
- const names = new Set();
1184
- let m;
1185
- const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
1186
- while ((m = reFn.exec(scan))) names.add(m[1]);
1187
- // An arrow-const action: `export const x = (...) => ...`, the paren-less
1188
- // `export const x = id => ...`, or a function expression. The `=>` /
1189
- // `function` anchor keeps a plain `export const N = 5` from counting.
1190
- // An OPTIONAL `: Type` annotation may sit between the name and the `=`
1191
- // (#495); the type itself can contain a function-type `=>`, so the
1192
- // annotation matcher consumes any non-`=` char OR a literal `=>`, and the
1193
- // assignment is the first `=` NOT followed by `>` (`=(?!>)`). The
1194
- // alternation is unambiguous at each position (a `=` can only start `=>`),
1195
- // so there is no catastrophic backtracking.
1196
- const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
1197
- while ((m = reArrow.exec(scan))) names.add(m[1]);
1198
- if (/\bexport\s+default\b/.test(scan)) names.add('default');
1199
- const actions = [...names].filter((n) => !RESERVED_CONFIG.has(n));
1200
- if (actions.length > 1) {
1201
- violations.push({
1202
- rule: 'one-action-per-configured-file',
1203
- file: rel,
1204
- message: `Configured action file exports ${actions.length} callable functions (${actions.join(', ')}); the verb/cache config is file-level, so only one action per file is allowed.`,
1205
- fix: 'Move the extra function to its own .server.{js,ts} file, or keep it private (do not export it).',
1206
- });
1207
- }
1208
- }
1209
- }
1210
-
1211
- // --- Rule: tag-name-has-hyphen ---
1212
- {
1213
- for (const { rel, scan } of files) {
1214
- if (!isComponentFile(rel)) continue;
1215
- // Use redacted source. A `register('tag')` call inside a
1216
- // TAGGED template literal (docs-page code example) is blanked.
1217
- // Calls at top level keep their structure AND their string
1218
- // argument. Quote style can be ', ", or ` (untagged backtick
1219
- // literals survive the redactor, like single/double-quote
1220
- // strings).
1221
- const patterns = [
1222
- // Class.register('tag') / register("tag") / register(`tag`)
1223
- /\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"`])([^'"`]+)\1/g,
1224
- // customElements.define('tag', Class) and quote variants
1225
- /\bcustomElements\.define\s*\(\s*(['"`])([^'"`]+)\1/g,
1226
- ];
1227
- for (const re of patterns) {
1228
- let match;
1229
- while ((match = re.exec(scan)) !== null) {
1230
- const tagName = match[2];
1231
- if (!tagName.includes('-')) {
1232
- violations.push({
1233
- rule: 'tag-name-has-hyphen',
1234
- file: rel,
1235
- message: `Custom element tag "${tagName}" must contain a hyphen`,
1236
- fix: `Rename to a hyphenated tag name, e.g. "app-${tagName}" or "${tagName}-element"`,
1237
- });
1238
- }
1239
- }
1240
- }
1241
- }
1242
- }
1243
-
1244
- // --- Rule: no-duplicate-tag ---
1245
- // Two registrations of the SAME tag string anywhere in the app resolve
1246
- // inconsistently at runtime (SSR last-wins, browser first-wins), so flag
1247
- // every colliding site naming the others. Scans EVERY source file, not just
1248
- // components/, because a duplicate is a runtime hazard regardless of where
1249
- // the register/define call lives (a page, a lib, a module can register a
1250
- // tag too); this keeps the rule in lockstep with the editor's 9004
1251
- // diagnostic, which is likewise project-wide. Reuses the same
1252
- // register/define extraction as tag-name-has-hyphen, over the redacted
1253
- // source so a tag in a docs-page tagged-template example does not count.
1254
- // Only hyphenated tags are considered (a non-hyphenated tag is already
1255
- // flagged by tag-name-has-hyphen / invariant 3), matching the 9004 filter.
1256
- {
1257
- // Generated / gitignored files (e.g. a `webjs ui add`-regenerated
1258
- // `components/` dir) are not committed source the rule should police;
1259
- // counting them would flag a collision between a hand-written component
1260
- // and its generated copy. Skip anything git reports as ignored.
1261
- // Best-effort: a non-git project (or absent git) scans everything.
1262
- const ignored = await gitIgnoredSet(appDir, files.map((f) => f.rel));
1263
- /** @type {Map<string, string[]>} tag -> rel files that register it (with repeats) */
1264
- const tagSites = new Map();
1265
- const patterns = [
1266
- /\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*(['"`])([^'"`]+)\1/g,
1267
- /\bcustomElements\.define\s*\(\s*(['"`])([^'"`]+)\1/g,
1268
- ];
1269
- for (const { scan, rel } of files) {
1270
- if (ignored.has(rel)) continue;
1271
- for (const re of patterns) {
1272
- let match;
1273
- while ((match = re.exec(scan)) !== null) {
1274
- const tagName = match[2];
1275
- if (!tagName.includes('-')) continue;
1276
- const arr = tagSites.get(tagName) || [];
1277
- arr.push(rel);
1278
- tagSites.set(tagName, arr);
1279
- }
1280
- }
1281
- }
1282
- for (const [tagName, sites] of tagSites) {
1283
- if (sites.length < 2) continue;
1284
- // Report once per DISTINCT file, naming the others.
1285
- for (const file of new Set(sites)) {
1286
- const others = [...new Set(sites)].filter((f) => f !== file);
1287
- const where = others.length
1288
- ? `also registered in ${others.join(', ')}`
1289
- : 'registered more than once in this file';
1290
- violations.push({
1291
- rule: 'no-duplicate-tag',
1292
- file,
1293
- message: `Custom element tag "${tagName}" is registered more than once (${where}). A tag must be registered exactly once; the runtime resolves a duplicate inconsistently (SSR keeps the last registration, the browser keeps the first).`,
1294
- fix: `Rename one registration so each "${tagName}" is unique, e.g. "${tagName}-2".`,
1295
- });
1296
- }
1297
- }
1298
- }
1299
-
1300
- // --- Rule: no-server-import-in-browser-module ---
1301
- // A page / layout / component module that SHIPS to the browser must not
1302
- // transitively import a server-only `.server.{ts,js}` module. The browser
1303
- // gets a stub for the server file, so the import is harmless while the
1304
- // module never loads client-side: a display-only page is elided, and an
1305
- // import-only page (#605/#963) is dropped from the boot in favour of its
1306
- // components. But the moment the page does its OWN client work (the client
1307
- // router, a reactive primitive, module-scope code, a client-effecting util
1308
- // on a component-free path) it ships whole, must load in the browser,
1309
- // and drags the server import with it: the stub throws (or a server-only
1310
- // export like `auth` is missing) the instant the module loads. That crash
1311
- // only surfaces at runtime; typecheck and every other check pass.
1312
- //
1313
- // The rule reuses the BUILD'S elision verdict (analyzeElision) instead of
1314
- // re-deriving it, so it fires ONLY on modules that genuinely ship: a
1315
- // display-only page the framework elides is never flagged (that is the
1316
- // legitimate pattern). The motivating case (crisp dogfood): a page that does
1317
- // `await auth()` (import from `lib/auth.server.ts`) AND imports a component
1318
- // directly, so it is not elided and ships the server import.
1319
- await checkServerImportInBrowserModule(appDir, violations);
1320
-
1321
- // --- Rule: no-missing-local-import ---
1322
- // A named value import of a symbol an app-internal module does not export is a
1323
- // runtime crash (the binding is undefined) that the elision-based checks miss,
1324
- // so `check` can stay green while `typecheck` is red (a dropped schema table
1325
- // orphaning a gallery module is the motivating dogfood case). Conservative by
1326
- // construction: only app-internal specifiers resolving to a known app file,
1327
- // only named value imports, and only when the target's exports are fully
1328
- // enumerable (see enumerableExports / importedValueNames).
1329
- {
1330
- // Fully-blanked view (blankStrings=true): string AND template AND comment
1331
- // bodies blank to spaces, position-preserving. The default `scan` keeps
1332
- // plain-string bodies VERBATIM (so callers can read `register('tag')`),
1333
- // which would let an `import`/`export` inside a string be matched, so use
1334
- // the fully-blanked view for both the export map and the import scan. The
1335
- // real specifier is read back from `content` at the same (length-preserved)
1336
- // offset.
1337
- const maskedByAbs = new Map();
1338
- for (const f of files) maskedByAbs.set(f.abs, redactStringsAndTemplates(f.content, true));
1339
- const exportsByAbs = new Map();
1340
- for (const f of files) exportsByAbs.set(f.abs, enumerableExports(maskedByAbs.get(f.abs)));
1341
- // `import\s+` excludes `import.meta` and a dynamic `import(`. The clause is
1342
- // `[^'";]*?` so it cannot swallow a side-effect `import '...'` (its quote) or
1343
- // bridge across a `;` into the next statement's `from`.
1344
- const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
1345
- for (const { abs, rel, content } of files) {
1346
- const masked = maskedByAbs.get(abs);
1347
- reImport.lastIndex = 0;
1348
- let m;
1349
- while ((m = reImport.exec(masked))) {
1350
- const quote = m[2];
1351
- const specStart = reImport.lastIndex; // just past the opening quote
1352
- const specEnd = content.indexOf(quote, specStart);
1353
- if (specEnd < 0) continue;
1354
- const spec = content.slice(specStart, specEnd);
1355
- if (!/^(?:\.|#)/.test(spec)) continue; // app-internal only (relative or #alias)
1356
- const names = importedValueNames(m[1]);
1357
- if (!names || names.length === 0) continue;
1358
- const target = resolveImport(spec, abs, appDir);
1359
- const exp = exportsByAbs.get(target);
1360
- if (exp == null) continue; // not an app file, or exports not enumerable
1361
- for (const name of names) {
1362
- if (exp.has(name)) continue;
1363
- violations.push({
1364
- rule: 'no-missing-local-import',
1365
- file: rel,
1366
- message: `Imports \`${name}\` from \`${spec}\`, but that module does not export \`${name}\`. The binding is undefined at runtime and crashes on first use (often a renamed or removed export, e.g. a dropped schema table).`,
1367
- fix: `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
1368
- });
1369
- }
1370
- }
1371
- }
1372
- }
1373
-
1374
- // --- Rule: form-action-not-a-get-action (#1155) ---
1375
- // `<form action=${fn}>` binds a server action to a POST submission. An action
1376
- // declaring `method = 'GET'` cannot serve one: it is CSRF-exempt and takes its
1377
- // args in the url, so the dispatcher answers 405. Catch it at edit time.
1378
- {
1379
- // A `'use server'` file's declared method, or absent when it declares none.
1380
- const methodByAbs = new Map();
1381
- for (const f of files) {
1382
- if (!/\.server\.m?[jt]s$/.test(f.rel)) continue;
1383
- if (!hasUseServerDirective(f.content)) continue;
1384
- const m = /\bexport\s+const\s+method\s*(?::[^=]*)?=\s*['"`]([A-Za-z]+)['"`]/.exec(f.scan);
1385
- if (m) methodByAbs.set(f.abs, m[1].toUpperCase());
1386
- }
1387
- if (methodByAbs.size) {
1388
- const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
1389
- for (const { abs, rel, content } of files) {
1390
- // PLACEHOLDER redaction, not either mask, and the choice is forced.
1391
- // The default mask blanks a tagged template outright, so a real
1392
- // `html`<form action=${fn}>`` disappears and the rule could never fire;
1393
- // the fully-blanked mask does the same. Placeholder mode keeps `${...}`
1394
- // holes as REAL code while turning each literal BODY into one opaque
1395
- // `__STR_n__` token, so a live binding is readable and a `<form
1396
- // action=${x}>` shown as text inside a docs sample is a single token
1397
- // with no hole in it. That distinction is the whole carve-out: the
1398
- // framework's own website renders this exact shape as a code sample.
1399
- const { redacted, literals } = redactToPlaceholders(content);
1400
- // Both binding shapes count the same here: `action=` on a `<form>`
1401
- // (#1155) and `formaction=` on a `<button>` / `<input>` submitter
1402
- // (#1207) each put the named action behind a form submission, and a
1403
- // GET-declared action cannot serve either. `classifyActionHole` owns
1404
- // the tag-and-attribute pairing (js-scan.js).
1405
- const bound = new Set();
1406
- for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
1407
- if (!classifyActionHole(literals[Number(m[1])] || '')) continue;
1408
- bound.add(m[2]);
1409
- }
1410
- if (!bound.size) continue;
1411
- reImport.lastIndex = 0;
1412
- let im;
1413
- while ((im = reImport.exec(redacted))) {
1414
- // The specifier is a placeholder token in this view, so read the real
1415
- // one back out of `literals`.
1416
- const tok = /__STR_(\d+)__/.exec(redacted.slice(reImport.lastIndex, reImport.lastIndex + 24));
1417
- if (!tok) continue;
1418
- const spec = literals[Number(tok[1])] || '';
1419
- if (!/^(?:\.|#)/.test(spec)) continue;
1420
- const names = importedLocalNames(im[1]);
1421
- if (!names.length) continue;
1422
- const target = resolveImport(spec, abs, appDir);
1423
- // ONLY a GET is contradictory, and the rule must say exactly what the
1424
- // dispatcher does or it is a false positive. A browser form always
1425
- // submits as a POST, and the dispatcher runs a PUT / PATCH / DELETE
1426
- // action on that POST: the declared verb governs the RPC transport,
1427
- // not whether the function can serve a form. A GET is different in
1428
- // kind (CSRF-exempt, args in the url, no body), which is why it is
1429
- // the one the dispatcher answers with a 405.
1430
- const method = target ? methodByAbs.get(target) : undefined;
1431
- if (method !== 'GET') continue;
1432
- for (const { local, imported } of names) {
1433
- if (!bound.has(local)) continue;
1434
- violations.push({
1435
- rule: 'form-action-not-a-get-action',
1436
- file: rel,
1437
- message: `Binds \`${imported}\` to a form, but \`${spec}\` declares \`method = 'GET'\`. A GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST; the submission is a 405.`,
1438
- fix: `Drop the \`method\` export from \`${spec}\` (an action with no method is a POST, which is what a form submits), or bind an action that takes a POST.`,
1439
- });
1440
- }
1441
- }
1442
- }
1443
- }
1444
- }
1445
-
1446
- return violations;
1447
- }
1448
-
1449
- /**
1450
- * BFS the module graph for the shortest import path from `from` to `to`,
1451
- * returning every hop `[from, ..., to]` so the `no-server-import-in-browser-module`
1452
- * message can print the FULL chain instead of an opaque `… ->` truncation (#804).
1453
- * Falls back to `[from, to]` if no path is found (defensive; the caller only
1454
- * calls this once `to` is known reachable).
1455
- *
1456
- * @param {Map<string, Set<string>>} graph
1457
- * @param {string} from
1458
- * @param {string} to
1459
- * @returns {string[]}
1460
- */
1461
- function findImportChain(graph, from, to) {
1462
- const prev = new Map();
1463
- const seen = new Set([from]);
1464
- const queue = [from];
1465
- while (queue.length) {
1466
- const cur = queue.shift();
1467
- const deps = graph.get(cur);
1468
- if (!deps) continue;
1469
- for (const dep of deps) {
1470
- if (seen.has(dep)) continue;
1471
- seen.add(dep);
1472
- prev.set(dep, cur);
1473
- if (dep === to) {
1474
- const path = [];
1475
- for (let n = to; n !== undefined; n = prev.get(n)) path.unshift(n);
1476
- return path;
1477
- }
1478
- queue.push(dep);
1479
- }
1480
- }
1481
- return [from, to];
1482
- }
1483
-
1484
- /**
1485
- * Implements `no-server-import-in-browser-module`. Factored into its own
1486
- * function (rather than an inline block) because it does the heavier
1487
- * whole-app analysis the other rules avoid: it builds the module graph,
1488
- * scans components, builds the route table, and runs the framework's own
1489
- * elision analysis so the rule's notion of "ships to the browser" is
1490
- * byte-for-byte the build's.
1491
- *
1492
- * A module is flagged when BOTH hold:
1493
- * 1. It SHIPS to the browser. For a component that means it is NOT in the
1494
- * elidable set; for a page / layout that means it is NOT in the inert
1495
- * route-module set. (Pages and layouts that do real client work are not
1496
- * inert and therefore ship.)
1497
- * 2. Its transitive import closure reaches a `.server.{ts,js}` module.
1498
- * `transitiveDeps` stops AT a server file (it is included but not walked
1499
- * into), so a server file pulled in only through another server file is
1500
- * not attributed to a browser module that never reaches it directly.
1501
- *
1502
- * Also flagged: error / loading / not-found modules. These ship to the browser
1503
- * too (the dev server's `computeBrowserBoundFiles` adds them unconditionally)
1504
- * and are never elided, so a server import reaching one of them is the same
1505
- * throw-at-load crash.
1506
- *
1507
- * Never flagged: a `.server.ts` importing another `.server.ts` (server-to-
1508
- * server, and `.server.*` modules are not components nor route modules), and
1509
- * `middleware.ts` / `route.ts` (server-only, never page/layout/component
1510
- * entries, so they are not in the candidate set to begin with).
1511
- *
1512
- * Scope note for dynamic imports: a string-literal `import('./widget.ts')` IS
1513
- * tracked by the module graph now (#751), but only as a GATE edge (so the
1514
- * lazily-imported module is servable); this rule's server-import detection
1515
- * still runs over STATIC edges only (`transitiveDeps`), so a dynamic
1516
- * `import('./x.server.ts')` of a no-`'use server'` utility is not flagged here.
1517
- * That is deliberate: the throw-at-load crash is deferred to call time (when
1518
- * the module is actually fetched), not module load, and a dynamic import is
1519
- * also not elided framework-wide. A computed `import(expr)` cannot be resolved
1520
- * statically at all; rather than a false-positive-prone check rule (a computed
1521
- * import of an npm specifier or an otherwise-reachable app module is perfectly
1522
- * valid, so it fails the check-is-correctness-only dividing line), the dev
1523
- * server surfaces it with a 404 hint when the target 404s (see dev.js #751).
1524
- *
1525
- * @param {string} appDir
1526
- * @param {Violation[]} violations appended to in place
1527
- */
1528
- async function checkServerImportInBrowserModule(appDir, violations) {
1529
- // No `app/` directory means this is not a routable WebJs app (e.g. a bare
1530
- // component library, or a fixture with only `lib/`); nothing ships, so the
1531
- // rule has nothing to police. Skip rather than do the heavy analysis.
1532
- if (!(await pathExists(join(appDir, 'app')))) return;
1533
-
1534
- let moduleGraph, components, routeTable;
1535
- try {
1536
- moduleGraph = await buildModuleGraph(appDir);
1537
- components = await scanComponents(appDir);
1538
- routeTable = await buildRouteTable(appDir);
1539
- } catch {
1540
- // A malformed app the analysis can't process is left to the other rules
1541
- // (and the dev server) to surface; this rule degrades to a no-op.
1542
- return;
1543
- }
1544
-
1545
- // Page + layout modules that the router treats as route modules, exactly the
1546
- // set the dev server feeds to analyzeElision (so the inert verdict matches).
1547
- /** @type {Set<string>} */
1548
- const routeModuleSet = new Set();
1549
- for (const page of routeTable.pages || []) {
1550
- if (page.file) routeModuleSet.add(page.file);
1551
- for (const f of page.layouts || []) routeModuleSet.add(f);
1552
- }
1553
- const routeModules = [...routeModuleSet];
1554
-
1555
- // error / loading / not-found modules ALSO ship to the browser, but unlike
1556
- // pages + layouts they are never elided: the dev server's
1557
- // `computeBrowserBoundFiles` adds them to the browser-bound entry set
1558
- // unconditionally (only ELIDABLE-COMPONENT imports are ever stripped, and
1559
- // these modules have no component to strip). So a personalized 404 that does
1560
- // `await auth()` is a real throw-at-load crash the page+layout-only candidate
1561
- // set would miss. Collect them here and add them to the candidate set as
1562
- // always-shipping (no elision verdict to consult).
1563
- /** @type {Map<string, string>} abs file -> kind */
1564
- const alwaysShipRouteModules = new Map();
1565
- for (const page of routeTable.pages || []) {
1566
- for (const f of page.errors || []) alwaysShipRouteModules.set(f, 'error boundary');
1567
- for (const f of page.loadings || []) alwaysShipRouteModules.set(f, 'loading boundary');
1568
- }
1569
- if (routeTable.notFound) alwaysShipRouteModules.set(routeTable.notFound, 'not-found page');
1570
- if (routeTable.notFounds) {
1571
- for (const f of routeTable.notFounds.values()) {
1572
- alwaysShipRouteModules.set(f, 'not-found page');
1573
- }
1574
- }
1575
-
1576
- // The elision flag mirrors `dev.js`: respect `webjs.elide === false` and the
1577
- // WEBJS_ELIDE override. When elision is OFF, the build ships EVERY component
1578
- // and route module, so the verdict is "nothing is elidable / inert" and the
1579
- // rule treats every candidate as shipping (which is correct: with elision
1580
- // off, a display-only page really does ship its server import too).
1581
- const elideEnabled = await readElideEnabledForCheck(appDir);
1582
- const { elidableComponents, inertRouteModules, importOnlyRouteModules } = elideEnabled
1583
- ? await analyzeElision(components, routeModules, moduleGraph, (f) => readFile(f, 'utf8'), appDir)
1584
- : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
1585
-
1586
- // Candidate browser-shipped modules: components that are NOT elided, plus
1587
- // route modules that are NOT inert and NOT import-only (an import-only
1588
- // module is dropped from the boot in favour of its component frontier,
1589
- // #605/#963, so its own imports never load in a browser and a bare
1590
- // server-only import in it is harmless). A `.server.*` file is never a
1591
- // component (the scanner skips it) nor a route module the browser loads, so
1592
- // it cannot enter this set; server-to-server imports are excluded by
1593
- // construction.
1594
- /** @type {Map<string, { kind: string }>} relFile is keyed by ABS path */
1595
- const candidates = new Map();
1596
- for (const c of components) {
1597
- if (!elidableComponents.has(c.file)) candidates.set(c.file, { kind: 'component' });
1598
- }
1599
- for (const file of routeModules) {
1600
- if (inertRouteModules.has(file) || importOnlyRouteModules.has(file)) continue;
1601
- const base = basename(file);
1602
- const kind = /^layout\./.test(base) ? 'layout' : 'page';
1603
- candidates.set(file, { kind });
1604
- }
1605
- // error / loading / not-found modules always ship (never elided), so they are
1606
- // candidates unconditionally. A page/layout entry already in `candidates`
1607
- // wins (it is the more specific kind); these only add files not already seen.
1608
- for (const [file, kind] of alwaysShipRouteModules) {
1609
- if (!candidates.has(file)) candidates.set(file, { kind });
1610
- }
1611
-
1612
- // Report at most once per module (a page importing two server modules is one
1613
- // finding, naming the first reached). Sorted for deterministic output.
1614
- for (const file of [...candidates.keys()].sort()) {
1615
- // `transitiveDeps` skips nothing here, so it includes (but does not walk
1616
- // into) any `.server.*` file reachable from this module. The module itself
1617
- // is not in the result. A direct OR indirect server import both surface,
1618
- // because the closure walks every non-server edge until it hits one.
1619
- const closure = transitiveDeps(moduleGraph, [file], appDir);
1620
- // Of the reachable server files, find one that is a genuine throw-at-load
1621
- // crash in the browser. TWO kinds of `.server.*` import are NOT crashes and
1622
- // must be skipped, or the rule false-positives on legitimate code:
1623
- // - A `'use server'` ACTION. The browser receives a working RPC stub
1624
- // whose exports POST to the server, so calling it from a shipping
1625
- // module is the intended pattern (the issue even lists it as a fix).
1626
- // Only a bare `.server.*` utility (no directive) gets the
1627
- // throw-at-module-load stub that crashes the page.
1628
- // - A PHANTOM edge to a file that does not exist on disk. The module
1629
- // graph keeps quoted-string CONTENT verbatim, so an `import` written
1630
- // inside a code-example string (the docs / website `<pre>` samples)
1631
- // resolves to a non-existent path. That import never runs, so it is
1632
- // not a crash; require the server file to actually exist.
1633
- let serverDep = null;
1634
- for (const d of closure) {
1635
- if (!/\.server\.m?[jt]s$/.test(d)) continue;
1636
- if (await isUseServerActionFile(d)) continue; // working RPC stub, not a crash
1637
- if (!(await pathExists(d))) continue; // phantom edge from a string sample
1638
- serverDep = d;
1639
- break;
1640
- }
1641
- if (!serverDep) continue;
1642
-
1643
- const { kind } = candidates.get(file);
1644
- const relFile = relative(appDir, file);
1645
- const relServer = relative(appDir, serverDep);
1646
- // Name the import chain: if the server file is a DIRECT import of this
1647
- // module, the chain is just the two; otherwise show one intermediate hop
1648
- // so the diagnostic points at where the edge enters (the full path is
1649
- // recoverable from the graph, but one hop is enough to locate it).
1650
- const chainFiles = findImportChain(moduleGraph, file, serverDep);
1651
- const chain = chainFiles.map((f) => relative(appDir, f)).join(' -> ');
1652
- // If the edge into the server file comes from a types-shaped module, the
1653
- // idiomatic fix is to relocate that type to a browser-safe typedef (#804).
1654
- const importer = chainFiles.length >= 2 ? chainFiles[chainFiles.length - 2] : null;
1655
- const viaTypesModule = importer && /(^|\/)types(\.m?[jt]s$|\/)/.test(relative(appDir, importer));
1656
-
1657
- // The "elides again" remedy only applies to a page / layout: since the
1658
- // path-aware import-only verdict (#963), a page importing a component to
1659
- // register is dropped from the boot, so a page/layout on this rule ships
1660
- // because of its OWN client work (or a client-effecting non-component in
1661
- // its closure); moving that work into a component makes it a dropped
1662
- // carrier again and the server import never loads. The error / loading /
1663
- // not-found boundaries always ship and are never elided, so offering
1664
- // them an "elides again" fix is wrong. Branch the fix text on kind.
1665
- const canElide = kind === 'page' || kind === 'layout';
1666
- const typesHint = viaTypesModule
1667
- ? `The edge enters via a types-shaped module (${relative(appDir, importer)}); if it re-exports a runtime VALUE from a \`.server.{ts,js}\` file, relocate that to a browser-safe typedef (a plain \`interface\` / JSDoc, or an \`import type\` which the stripper erases) so the type is shared without pinning the module. `
1668
- : '';
1669
- const fixText = canElide
1670
- ? `${typesHint}Keep the server call off this browser-shipped ${kind}. Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC; or (3) move this ${kind}'s own client work (the module-scope call, browser-global access, or client-effecting util import that pins it) into a component, so the ${kind} elides again as a dropped carrier and its server import never loads.`
1671
- : `${typesHint}Keep the server call off this browser-shipped ${kind} (it always ships and is never elided). Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); or (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC.`;
1672
-
1673
- violations.push({
1674
- rule: 'no-server-import-in-browser-module',
1675
- file: relFile,
1676
- message:
1677
- `This ${kind} ships to the browser (the build does not elide it) but transitively imports the server-only module ${relServer} (${chain}). In the browser that import resolves to a stub, so the module crashes at load (the stub throws, or a server-only export such as \`auth\` is missing). \`webjs typecheck\` and the rest of \`webjs check\` pass; only the running ${kind} fails.`,
1678
- fix: fixText,
1679
- });
1680
- }
1681
- }
1682
-
1683
- /**
1684
- * Read whether component elision is enabled for `appDir`, mirroring
1685
- * `dev.js`'s `readElideEnabled` so the check's notion of "ships" matches the
1686
- * dev server's. Elision is ON unless `webjs.elide === false` in package.json or
1687
- * the `WEBJS_ELIDE` env var forces it off (`0` / `false` / `off` / `no`). A
1688
- * missing or malformed package.json keeps the default (on). Inlined rather
1689
- * than imported from `dev.js` so the check tool does not pull the whole dev
1690
- * server module just for this flag.
1691
- *
1692
- * @param {string} appDir
1693
- * @returns {Promise<boolean>}
1694
- */
1695
- /**
1696
- * True if `file` is a `'use server'` action: a `.server.{ts,js}` module that
1697
- * declares the `'use server'` directive. The dev server rewrites its browser
1698
- * import into a working RPC stub (exports POST to the server), so importing it
1699
- * from a shipping module is legitimate, NOT the throw-at-load crash the
1700
- * no-server-import-in-browser-module rule catches. A bare `.server.*` utility
1701
- * (no directive) instead gets a stub that throws when the module loads, which
1702
- * IS the crash. Returns false on any read failure (treat an unreadable server
1703
- * file as a potential crash, the conservative direction for this rule).
18
+ * Public API barrel re-exporting sub-modules under `./check/*`.
1704
19
  *
1705
- * @param {string} file absolute path to a `.server.*` file
1706
- * @returns {Promise<boolean>}
1707
- */
1708
- async function isUseServerActionFile(file) {
1709
- try {
1710
- const content = await readFile(file, 'utf8');
1711
- return hasUseServerDirective(content);
1712
- } catch {
1713
- return false;
1714
- }
1715
- }
1716
-
1717
- async function readElideEnabledForCheck(appDir) {
1718
- const raw = process.env.WEBJS_ELIDE;
1719
- if (raw != null) {
1720
- const v = raw.trim().toLowerCase();
1721
- if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
1722
- if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
1723
- }
1724
- try {
1725
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
1726
- if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
1727
- } catch {
1728
- // No package.json, malformed JSON, or unreadable: keep the default.
1729
- }
1730
- return true;
1731
- }
1732
-
1733
- /**
1734
- * Async fs.exists shim. Returns true if the path exists at all (file
1735
- * or directory), false on ENOENT or any other stat failure.
1736
- *
1737
- * @param {string} p absolute path
1738
- * @returns {Promise<boolean>}
20
+ * @module check
1739
21
  */
1740
- async function pathExists(p) {
1741
- try {
1742
- await stat(p);
1743
- return true;
1744
- } catch {
1745
- return false;
1746
- }
1747
- }
1748
22
 
1749
- /**
1750
- * The subset of `rels` (appDir-relative paths) that git reports as ignored,
1751
- * via a single batched `git check-ignore --stdin`. Best-effort: returns an
1752
- * empty Set when the directory is not a git repo, git is absent, or the
1753
- * spawn fails, so a non-git project scans every file as before. Runs with
1754
- * `cwd: appDir` and the inherited GIT_* env stripped so cwd is the sole
1755
- * authority on which repo + .gitignore stack is consulted (a pre-commit
1756
- * hook from a linked worktree exports GIT_WORK_TREE, which would otherwise
1757
- * override cwd-based discovery; same reason the doctor vendor-gitignore check
1758
- * strips them).
1759
- * Works for an in-repo sub-package with no nested `.git` too: git walks up
1760
- * to the monorepo root and resolves the relative paths against cwd.
1761
- *
1762
- * @param {string} appDir absolute app directory
1763
- * @param {string[]} rels appDir-relative file paths
1764
- * @returns {Promise<Set<string>>}
1765
- */
1766
- async function gitIgnoredSet(appDir, rels) {
1767
- /** @type {Set<string>} */
1768
- const out = new Set();
1769
- if (!rels.length) return out;
1770
- try {
1771
- const { spawnSync } = await import('node:child_process');
1772
- const {
1773
- GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
1774
- ...gitEnv
1775
- } = process.env;
1776
- // `git check-ignore --stdin` exits 0 when ≥1 path is ignored (those
1777
- // paths are echoed on stdout), 1 when none are ignored, >1 on error.
1778
- const res = spawnSync('git', ['check-ignore', '--stdin'], {
1779
- cwd: appDir,
1780
- input: rels.join('\n'),
1781
- encoding: 'utf8',
1782
- env: gitEnv,
1783
- });
1784
- if (res.status === 0 && typeof res.stdout === 'string') {
1785
- for (const line of res.stdout.split('\n')) {
1786
- const p = line.trim();
1787
- if (p) out.add(p);
1788
- }
1789
- }
1790
- } catch {
1791
- // git missing or spawn failure: scan everything (no filter).
1792
- }
1793
- return out;
1794
- }
23
+ export { RULES } from './check/rules.js';
24
+ export { checkConventions } from './check/runner.js';