@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
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Server-action rules: where `'use server'` is honoured, what its exports must
3
+ * look like, and what a configured action file may contain.
4
+ *
5
+ * Lifted verbatim out of checkConventions, which held all twenty rules inline
6
+ * in one 900-line function. Each rule keeps its original comment header, its
7
+ * logic, and its position in the run order.
8
+ */
9
+ import { join, basename } from 'node:path';
10
+ import { RESERVED_CONFIG } from '../action-config.js';
11
+ import {
12
+ hasUseServerDirective,
13
+ } from './helpers.js';
14
+
15
+ /**
16
+ * @typedef {import('./rules.js').Violation} Violation
17
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
18
+ */
19
+
20
+
21
+ /**
22
+ * Rule: `use-server-needs-extension`.
23
+ *
24
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
25
+ * @param {Violation[]} violations accumulator the rule pushes onto
26
+ * @returns {void}
27
+ */
28
+ export function checkUseServerNeedsExtension(files, violations) {
29
+ // --- Rule: use-server-needs-extension ---
30
+ // Catch files that declare `'use server'` at the top but lack the
31
+ // `.server.{js,ts}` extension. Under the two-marker convention the
32
+ // directive alone does nothing (the file is served to the browser as
33
+ // plain source and exports are not registered as RPC), which is a
34
+ // silent footgun. The fix is mechanical: rename the file.
35
+ {
36
+ for (const { rel, content } of files) {
37
+ if (!hasUseServerDirective(content)) continue;
38
+ if (/\.server\.m?[jt]s$/.test(rel)) continue; // OK: has both markers
39
+ const fileBase = basename(rel);
40
+ const renamedBase = fileBase.replace(/\.(m?[jt]sx?)$/, '.server.$1');
41
+ violations.push({
42
+ rule: 'use-server-needs-extension',
43
+ file: rel,
44
+ message:
45
+ "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.",
46
+ fix: `Rename to ${renamedBase} (add the .server. infix before the extension)`,
47
+ });
48
+ }
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Rule: `use-server-exports-callable`.
54
+ *
55
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
56
+ * @param {Violation[]} violations accumulator the rule pushes onto
57
+ * @returns {void}
58
+ */
59
+ export function checkUseServerExportsCallable(files, violations) {
60
+ // --- Rule: use-server-exports-callable (#464) ---
61
+ // A `.server.{js,ts}` file with the `'use server'` directive registers only
62
+ // its FUNCTION exports as RPC actions (the registrar checks `typeof === 'function'`).
63
+ // A file that declares the directive but exports no callable registers nothing,
64
+ // silently: the developer thinks they exposed an action, and the only signal is
65
+ // a 404 / undefined at the first call site. Flag it. The complement of
66
+ // use-server-needs-extension (the directive without the extension) and of
67
+ // one-action-per-configured-file (more than one action).
68
+ {
69
+ for (const { rel, content, scan } of files) {
70
+ // Only properly-marked action files (the extension boundary). A `'use
71
+ // server'` file WITHOUT the .server. extension is the use-server-needs-
72
+ // extension rule's job; do not double-flag it here.
73
+ if (!/\.server\.m?[jt]s$/.test(rel)) continue;
74
+ if (!hasUseServerDirective(content)) continue;
75
+ // Count function-shaped EXPORTED callables, the SAME way the action
76
+ // registrar sees them: function declarations + arrow / function-expression
77
+ // consts (with an optional `: Type` annotation, #495). Reserved verb-config
78
+ // names (method/cache/...) are config, never a callable action.
79
+ const names = new Set();
80
+ let m;
81
+ const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
82
+ while ((m = reFn.exec(scan))) names.add(m[1]);
83
+ const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
84
+ while ((m = reArrow.exec(scan))) names.add(m[1]);
85
+ const callables = [...names].filter((n) => !RESERVED_CONFIG.has(n));
86
+ if (callables.length > 0) continue; // exports at least one callable -> fine
87
+ // A default export is assumed callable (an action commonly default-exports).
88
+ if (/\bexport\s+default\b/.test(scan)) continue;
89
+ // Conservative, avoid a FALSE POSITIVE: the runtime registrar
90
+ // (`actionFunctionNames`) keeps EVERY export whose value is a function at
91
+ // load time, regardless of the export syntax, so any export shape these
92
+ // patterns cannot prove is non-callable must NOT be flagged. Skip when the
93
+ // file has:
94
+ // - a named-export clause `export { a, b as c }` (with or WITHOUT `from`):
95
+ // it can surface a local function (`function getX(){}; export { getX }`)
96
+ // or a re-exported / imported function, neither matched above;
97
+ // - a star re-export `export * from ...`;
98
+ // - a destructuring export `export const { x } = obj` / `export const [x] = arr`,
99
+ // which may bind a function;
100
+ // - an `export const NAME = <identifier-or-call>`, a factory-produced
101
+ // function (`export const get = cache(fetch)`).
102
+ // A sole `export { aConst }` of a non-function is then a tolerated FALSE
103
+ // NEGATIVE, which is the right bias for a non-overridable correctness rule.
104
+ if (/\bexport\s*\{/.test(scan)) continue;
105
+ if (/\bexport\s*\*/.test(scan)) continue;
106
+ if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) continue;
107
+ // A factory / identifier-valued const could be a callable ACTION, so skip,
108
+ // BUT only when its name is NOT a reserved config key: the registrar
109
+ // excludes reserved names from actions (`actionFunctionNames`), so a file
110
+ // whose only ambiguous const is `validate` / `tags` / ... (config produced
111
+ // by an arrow or a factory) still has zero actions and must flag. Skip only
112
+ // for a NON-reserved ambiguous const (a real possible action).
113
+ const reFactory = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=\s*[A-Za-z_$(]/g;
114
+ let ambiguousAction = false;
115
+ while ((m = reFactory.exec(scan))) { if (!RESERVED_CONFIG.has(m[1])) { ambiguousAction = true; break; } }
116
+ if (ambiguousAction) continue;
117
+ // Every export is provably non-callable (a literal const, a type) or there
118
+ // are none: the directive exposes nothing.
119
+ violations.push({
120
+ rule: 'use-server-exports-callable',
121
+ file: rel,
122
+ message:
123
+ "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.",
124
+ fix: "Export an `async function` action from this file, or remove the `'use server'` directive if it is a plain server-only utility.",
125
+ });
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Rule: `one-action-per-configured-file`.
132
+ *
133
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
134
+ * @param {Violation[]} violations accumulator the rule pushes onto
135
+ * @returns {void}
136
+ */
137
+ export function checkOneActionPerConfiguredFile(files, violations) {
138
+ // --- Rule: one-action-per-configured-file (#488) ---
139
+ // A `'use server'` file that declares HTTP-verb config (method/cache/tags/
140
+ // invalidates/validate) must export exactly one callable action; the config
141
+ // is file-level, so a second exported function would silently inherit it.
142
+ {
143
+ for (const { rel, content, scan } of files) {
144
+ if (!/\.server\.m?[jt]s$/.test(rel)) continue;
145
+ if (!hasUseServerDirective(content)) continue;
146
+ if (!/\bexport\s+const\s+(?:method|cache|tags|invalidates|validate|middleware)\b/.test(scan)) continue;
147
+ const names = new Set();
148
+ let m;
149
+ const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
150
+ while ((m = reFn.exec(scan))) names.add(m[1]);
151
+ // An arrow-const action: `export const x = (...) => ...`, the paren-less
152
+ // `export const x = id => ...`, or a function expression. The `=>` /
153
+ // `function` anchor keeps a plain `export const N = 5` from counting.
154
+ // An OPTIONAL `: Type` annotation may sit between the name and the `=`
155
+ // (#495); the type itself can contain a function-type `=>`, so the
156
+ // annotation matcher consumes any non-`=` char OR a literal `=>`, and the
157
+ // assignment is the first `=` NOT followed by `>` (`=(?!>)`). The
158
+ // alternation is unambiguous at each position (a `=` can only start `=>`),
159
+ // so there is no catastrophic backtracking.
160
+ const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
161
+ while ((m = reArrow.exec(scan))) names.add(m[1]);
162
+ if (/\bexport\s+default\b/.test(scan)) names.add('default');
163
+ const actions = [...names].filter((n) => !RESERVED_CONFIG.has(n));
164
+ if (actions.length > 1) {
165
+ violations.push({
166
+ rule: 'one-action-per-configured-file',
167
+ file: rel,
168
+ 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.`,
169
+ fix: 'Move the extra function to its own .server.{js,ts} file, or keep it private (do not export it).',
170
+ });
171
+ }
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Component-authoring rules: registration, reactive-property declaration, and
3
+ * the SSR-safety of a component body.
4
+ *
5
+ * Lifted verbatim out of checkConventions, which held all twenty rules inline
6
+ * in one 900-line function. Each rule keeps its original comment header, its
7
+ * logic, and its position in the run order.
8
+ */
9
+ import {
10
+ extractWebComponentClassBodies, matchClosingBrace, parsePropEntries,
11
+ } from '../js-scan.js';
12
+ import {
13
+ isComponentFile, arrayPropUsesObject, findFieldInitializers, methodBodyOf,
14
+ findBrowserMemberUses,
15
+ } from './helpers.js';
16
+
17
+ /**
18
+ * @typedef {import('./rules.js').Violation} Violation
19
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
20
+ */
21
+
22
+
23
+ /**
24
+ * Rule: `components-have-register`.
25
+ *
26
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
27
+ * @param {Violation[]} violations accumulator the rule pushes onto
28
+ * @returns {void}
29
+ */
30
+ export function checkComponentsHaveRegister(files, violations) {
31
+ // --- Rule: components-have-register ---
32
+ {
33
+ for (const { rel, scan } of files) {
34
+ if (!isComponentFile(rel)) continue;
35
+ // Use redacted source so a code-example string like
36
+ // `Foo.register('bar')` inside a tagged template literal does
37
+ // not falsely satisfy the rule for a sibling unregistered
38
+ // class. Real register() calls live at top level where the
39
+ // redactor leaves them alone.
40
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
41
+ // Accept either registration pattern:
42
+ // Counter.register('tag') (WebJs idiom)
43
+ // customElements.define('tag', Counter) (native)
44
+ if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"`]/.test(scan)) continue;
45
+ if (/\bcustomElements\.define\s*\(/.test(scan)) continue;
46
+ violations.push({
47
+ rule: 'components-have-register',
48
+ file: rel,
49
+ message: "Component extends WebComponent but is never registered. Call ClassName.register('tag-name') at the bottom of the file.",
50
+ fix: "Add `ClassName.register('tag-name')` after the class definition",
51
+ });
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Rule: `no-static-properties`.
58
+ *
59
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
60
+ * @param {Violation[]} violations accumulator the rule pushes onto
61
+ * @returns {void}
62
+ */
63
+ export function checkNoStaticProperties(files, violations) {
64
+ // --- Rule: no-static-properties ---
65
+ // A hand-written `static properties = { … }` in a WebComponent class body is
66
+ // no longer supported: reactive properties are declared via the
67
+ // `extends WebComponent({ … })` factory (the runtime throws on a direct
68
+ // `static properties`). Flag it statically so the editor catches it before
69
+ // the page 500s.
70
+ {
71
+ for (const { rel, scan } of files) {
72
+ // Use redacted source so fixture-style strings like
73
+ // `class X extends WebComponent { static properties = {…} }` inside
74
+ // template literals don't trip the rule. Real declarations live at
75
+ // top-level code where the redactor leaves them alone.
76
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
77
+ for (const { body } of extractWebComponentClassBodies(scan)) {
78
+ if (!/static\s+properties\s*=\s*\{/.test(body)) continue;
79
+ violations.push({
80
+ rule: 'no-static-properties',
81
+ file: rel,
82
+ message:
83
+ '`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
84
+ 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.',
85
+ });
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Rule: `reactive-props-no-class-field`.
93
+ *
94
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
95
+ * @param {Violation[]} violations accumulator the rule pushes onto
96
+ * @returns {void}
97
+ */
98
+ export function checkReactivePropsNoClassField(files, violations) {
99
+ // --- Rule: reactive-props-no-class-field ---
100
+ // A reactive property declared via the factory must not also carry a plain
101
+ // class-field declaration (initializer OR type-only): under modern class-field
102
+ // semantics (including `erasableSyntaxOnly: true`) every class-field declaration
103
+ // compiles to Object.defineProperty after super() and clobbers the framework's
104
+ // reactive accessor (silent broken re-renders). Catches `count = 0`,
105
+ // `count: number = 0`, `count!: number`, and `count?: number`.
106
+ {
107
+ for (const { rel, scan } of files) {
108
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
109
+ for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
110
+ if (factoryProps.size === 0) continue;
111
+ for (const bad of findFieldInitializers(body, factoryProps)) {
112
+ violations.push({
113
+ rule: 'reactive-props-no-class-field',
114
+ file: rel,
115
+ 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.`,
116
+ fix: `Delete the class-field declaration and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
117
+ });
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Rule: `array-prop-uses-array-type`.
126
+ *
127
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
128
+ * @param {Violation[]} violations accumulator the rule pushes onto
129
+ * @returns {void}
130
+ */
131
+ export function checkArrayPropUsesArrayType(files, violations) {
132
+ // --- Rule: array-prop-uses-array-type ---
133
+ // An array-typed reactive prop declared via the factory should pass the
134
+ // `Array` runtime constructor, not `Object`. Both share one converter
135
+ // (JSON encode/decode), so `Object` does not crash, but it misstates the
136
+ // prop contract and diverges from the documented built-in set. Fires only
137
+ // when the factory generic is itself an array type AND the constructor is
138
+ // `Object`; a bare `foo: Object` (no generic to prove array-ness) is left
139
+ // alone to avoid false positives. Uses the redacted `scan`, so a
140
+ // `prop<X[]>(Object)` shown inside an html`` example string never fires.
141
+ {
142
+ for (const { rel, scan } of files) {
143
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
144
+ for (const { factoryArg } of extractWebComponentClassBodies(scan)) {
145
+ const objStart = factoryArg.indexOf('{');
146
+ if (objStart === -1) continue;
147
+ const objEnd = matchClosingBrace(factoryArg, objStart + 1);
148
+ if (objEnd === -1) continue;
149
+ const objContent = factoryArg.slice(objStart + 1, objEnd);
150
+ for (const { key, value } of parsePropEntries(objContent)) {
151
+ if (!arrayPropUsesObject(value)) continue;
152
+ violations.push({
153
+ rule: 'array-prop-uses-array-type',
154
+ file: rel,
155
+ message: `Array-typed reactive prop \`${key}\` is declared with the \`Object\` constructor (\`prop<…[]>(Object)\`); use \`Array\` so the runtime converter matches the declared shape.`,
156
+ 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.`,
157
+ });
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Rule: `no-browser-globals-in-render`.
166
+ *
167
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
168
+ * @param {Violation[]} violations accumulator the rule pushes onto
169
+ * @returns {void}
170
+ */
171
+ export function checkNoBrowserGlobalsInRender(files, violations) {
172
+ // --- Rule: no-browser-globals-in-render ---
173
+ // The SSR pipeline runs the constructor (`new Cls()`), willUpdate, and
174
+ // render() on the server element shim (attribute methods backed, but no real
175
+ // DOM). A genuinely browser-only global or an unshimmed HTMLElement member on
176
+ // `this` touched in any of those throws at SSR time. Those belong in
177
+ // connectedCallback / post-render hooks, which SSR never calls. willUpdate is
178
+ // scanned because it now runs at SSR (issue #217).
179
+ {
180
+ for (const { rel, scan } of files) {
181
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
182
+ for (const { body } of extractWebComponentClassBodies(scan)) {
183
+ for (const method of ['constructor', 'willUpdate', 'render']) {
184
+ const code = methodBodyOf(body, method);
185
+ if (!code) continue;
186
+ for (const { member, kind } of findBrowserMemberUses(code)) {
187
+ violations.push({
188
+ rule: 'no-browser-globals-in-render',
189
+ file: rel,
190
+ 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.`,
191
+ 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.`,
192
+ });
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Rule: `no-shadowed-native-member`.
202
+ *
203
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
204
+ * @param {Violation[]} violations accumulator the rule pushes onto
205
+ * @returns {void}
206
+ */
207
+ export function checkNoShadowedNativeMember(files, violations) {
208
+ // --- Rule: no-shadowed-native-member ---
209
+ // A WebComponent method named after a native DOM mutation method WebJs
210
+ // instruments on every light-DOM host for the slot API (#1021) is SHADOWED at
211
+ // runtime (the native/instrumented method wins), so the component method never
212
+ // runs, while TypeScript stays green (a shorter override is assignable). Rename
213
+ // the handler. The lifecycle hooks are meant to be overridden, so only the DOM
214
+ // mutation members are reserved.
215
+ {
216
+ const NATIVE_MEMBERS = [
217
+ 'append', 'prepend', 'before', 'after', 'replaceWith', 'replaceChildren', 'remove',
218
+ 'appendChild', 'insertBefore', 'removeChild', 'replaceChild',
219
+ ];
220
+ for (const { rel, scan } of files) {
221
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
222
+ for (const { body } of extractWebComponentClassBodies(scan)) {
223
+ // The slot interception installs only on LIGHT-DOM hosts (the shadow
224
+ // branch keeps the true native methods), so a shadow component's method
225
+ // is never shadowed. Skip the whole class.
226
+ if (/static\s+shadow\s*=\s*true/.test(body)) continue;
227
+ // Brace-depth map over the (already string/comment-masked) class body,
228
+ // so only TOP-LEVEL class members flag: a same-named object-literal
229
+ // shorthand or function expression nested inside a method body is a
230
+ // different object's property and shadows nothing.
231
+ const depth = new Int32Array(body.length);
232
+ {
233
+ let d = 0;
234
+ for (let i = 0; i < body.length; i++) {
235
+ if (body[i] === '{') { depth[i] = d; d++; }
236
+ else if (body[i] === '}') { d--; depth[i] = d; }
237
+ else depth[i] = d;
238
+ }
239
+ }
240
+ for (const name of NATIVE_MEMBERS) {
241
+ // Two shadowed-definition shapes, both instance-level: the method /
242
+ // accessor form (`remove() {`, `get remove() {`), and the class-field
243
+ // FUNCTION form (`remove = () => {}` / `= function () {}`), which the
244
+ // connect-time interception install overwrites just the same. A
245
+ // `static` member lives on the constructor and shadows nothing.
246
+ const methodRe = new RegExp(
247
+ `(?:^|[\\s;}])((?:static\\s+)?)(?:async\\s+)?(?:get\\s+|set\\s+)?(${name})\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`,
248
+ 'g',
249
+ );
250
+ const fieldRe = new RegExp(
251
+ `(?:^|[\\s;}])((?:static\\s+)?)(${name})\\s*=\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]*)?=>|[\\w$]+\\s*=>)`,
252
+ 'g',
253
+ );
254
+ let flagged = false;
255
+ for (const re of [methodRe, fieldRe]) {
256
+ let m;
257
+ while (!flagged && (m = re.exec(body)) !== null) {
258
+ if (m[1]) continue; // static: not shadowed
259
+ const nameIdx = m.index + m[0].indexOf(m[2]);
260
+ if (depth[nameIdx] !== 0) continue; // nested: another object's property
261
+ flagged = true;
262
+ violations.push({
263
+ rule: 'no-shadowed-native-member',
264
+ file: rel,
265
+ 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.`,
266
+ fix: `Rename the member to a non-native name (e.g. \`${name}Row()\` / \`${name}Item()\`) and update its call sites.`,
267
+ });
268
+ }
269
+ if (flagged) break;
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Import-graph rules: what a browser-bound module may reach, whether a local
3
+ * specifier resolves, and whether a bound form action can answer a POST.
4
+ *
5
+ * Lifted verbatim out of checkConventions, which held all twenty rules inline
6
+ * in one 900-line function. Each rule keeps its original comment header, its
7
+ * logic, and its position in the run order.
8
+ */
9
+ import { relative } from 'node:path';
10
+ import {
11
+ redactStringsAndTemplates, redactToPlaceholders, classifyActionHole,
12
+ } from '../js-scan.js';
13
+ import { resolveImport } from '../module-graph.js';
14
+ import {
15
+ hasUseServerDirective, enumerableExports, importedLocalNames, importedValueNames,
16
+ } from './helpers.js';
17
+
18
+ /**
19
+ * @typedef {import('./rules.js').Violation} Violation
20
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
21
+ */
22
+
23
+ /**
24
+ * Rule: `no-missing-local-import`.
25
+ *
26
+ * @param {string} appDir absolute path to the app root
27
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
28
+ * @param {Violation[]} violations accumulator the rule pushes onto
29
+ * @returns {void}
30
+ */
31
+ export function checkNoMissingLocalImport(appDir, files, violations) {
32
+ // --- Rule: no-missing-local-import ---
33
+ // A named value import of a symbol an app-internal module does not export is a
34
+ // runtime crash (the binding is undefined) that the elision-based checks miss,
35
+ // so `check` can stay green while `typecheck` is red (a dropped schema table
36
+ // orphaning a gallery module is the motivating dogfood case). Conservative by
37
+ // construction: only app-internal specifiers resolving to a known app file,
38
+ // only named value imports, and only when the target's exports are fully
39
+ // enumerable (see enumerableExports / importedValueNames).
40
+ {
41
+ // Fully-blanked view (blankStrings=true): string AND template AND comment
42
+ // bodies blank to spaces, position-preserving. The default `scan` keeps
43
+ // plain-string bodies VERBATIM (so callers can read `register('tag')`),
44
+ // which would let an `import`/`export` inside a string be matched, so use
45
+ // the fully-blanked view for both the export map and the import scan. The
46
+ // real specifier is read back from `content` at the same (length-preserved)
47
+ // offset.
48
+ const maskedByAbs = new Map();
49
+ for (const f of files) maskedByAbs.set(f.abs, redactStringsAndTemplates(f.content, true));
50
+ const exportsByAbs = new Map();
51
+ for (const f of files) exportsByAbs.set(f.abs, enumerableExports(maskedByAbs.get(f.abs)));
52
+ // `import\s+` excludes `import.meta` and a dynamic `import(`. The clause is
53
+ // `[^'";]*?` so it cannot swallow a side-effect `import '...'` (its quote) or
54
+ // bridge across a `;` into the next statement's `from`.
55
+ const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
56
+ for (const { abs, rel, content } of files) {
57
+ const masked = maskedByAbs.get(abs);
58
+ reImport.lastIndex = 0;
59
+ let m;
60
+ while ((m = reImport.exec(masked))) {
61
+ const quote = m[2];
62
+ const specStart = reImport.lastIndex; // just past the opening quote
63
+ const specEnd = content.indexOf(quote, specStart);
64
+ if (specEnd < 0) continue;
65
+ const spec = content.slice(specStart, specEnd);
66
+ if (!/^(?:\.|#)/.test(spec)) continue; // app-internal only (relative or #alias)
67
+ const names = importedValueNames(m[1]);
68
+ if (!names || names.length === 0) continue;
69
+ const target = resolveImport(spec, abs, appDir);
70
+ const exp = exportsByAbs.get(target);
71
+ if (exp == null) continue; // not an app file, or exports not enumerable
72
+ for (const name of names) {
73
+ if (exp.has(name)) continue;
74
+ violations.push({
75
+ rule: 'no-missing-local-import',
76
+ file: rel,
77
+ 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).`,
78
+ fix: `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Rule: `form-action-not-a-get-action`.
88
+ *
89
+ * @param {string} appDir absolute path to the app root
90
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
91
+ * @param {Violation[]} violations accumulator the rule pushes onto
92
+ * @returns {void}
93
+ */
94
+ export function checkFormActionNotAGetAction(appDir, files, violations) {
95
+ // --- Rule: form-action-not-a-get-action (#1155) ---
96
+ // `<form action=${fn}>` binds a server action to a POST submission. An action
97
+ // declaring `method = 'GET'` cannot serve one: it is CSRF-exempt and takes its
98
+ // args in the url, so the dispatcher answers 405. Catch it at edit time.
99
+ {
100
+ // A `'use server'` file's declared method, or absent when it declares none.
101
+ const methodByAbs = new Map();
102
+ for (const f of files) {
103
+ if (!/\.server\.m?[jt]s$/.test(f.rel)) continue;
104
+ if (!hasUseServerDirective(f.content)) continue;
105
+ const m = /\bexport\s+const\s+method\s*(?::[^=]*)?=\s*['"`]([A-Za-z]+)['"`]/.exec(f.scan);
106
+ if (m) methodByAbs.set(f.abs, m[1].toUpperCase());
107
+ }
108
+ if (methodByAbs.size) {
109
+ const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
110
+ for (const { abs, rel, content } of files) {
111
+ // PLACEHOLDER redaction, not either mask, and the choice is forced.
112
+ // The default mask blanks a tagged template outright, so a real
113
+ // `html`<form action=${fn}>`` disappears and the rule could never fire;
114
+ // the fully-blanked mask does the same. Placeholder mode keeps `${...}`
115
+ // holes as REAL code while turning each literal BODY into one opaque
116
+ // `__STR_n__` token, so a live binding is readable and a `<form
117
+ // action=${x}>` shown as text inside a docs sample is a single token
118
+ // with no hole in it. That distinction is the whole carve-out: the
119
+ // framework's own website renders this exact shape as a code sample.
120
+ const { redacted, literals } = redactToPlaceholders(content);
121
+ // Both binding shapes count the same here: `action=` on a `<form>`
122
+ // (#1155) and `formaction=` on a `<button>` / `<input>` submitter
123
+ // (#1207) each put the named action behind a form submission, and a
124
+ // GET-declared action cannot serve either. `classifyActionHole` owns
125
+ // the tag-and-attribute pairing (js-scan.js).
126
+ const bound = new Set();
127
+ for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
128
+ if (!classifyActionHole(literals[Number(m[1])] || '')) continue;
129
+ bound.add(m[2]);
130
+ }
131
+ if (!bound.size) continue;
132
+ reImport.lastIndex = 0;
133
+ let im;
134
+ while ((im = reImport.exec(redacted))) {
135
+ // The specifier is a placeholder token in this view, so read the real
136
+ // one back out of `literals`.
137
+ const tok = /__STR_(\d+)__/.exec(redacted.slice(reImport.lastIndex, reImport.lastIndex + 24));
138
+ if (!tok) continue;
139
+ const spec = literals[Number(tok[1])] || '';
140
+ if (!/^(?:\.|#)/.test(spec)) continue;
141
+ const names = importedLocalNames(im[1]);
142
+ if (!names.length) continue;
143
+ const target = resolveImport(spec, abs, appDir);
144
+ // ONLY a GET is contradictory, and the rule must say exactly what the
145
+ // dispatcher does or it is a false positive. A browser form always
146
+ // submits as a POST, and the dispatcher runs a PUT / PATCH / DELETE
147
+ // action on that POST: the declared verb governs the RPC transport,
148
+ // not whether the function can serve a form. A GET is different in
149
+ // kind (CSRF-exempt, args in the url, no body), which is why it is
150
+ // the one the dispatcher answers with a 405.
151
+ const method = target ? methodByAbs.get(target) : undefined;
152
+ if (method !== 'GET') continue;
153
+ for (const { local, imported } of names) {
154
+ if (!bound.has(local)) continue;
155
+ violations.push({
156
+ rule: 'form-action-not-a-get-action',
157
+ file: rel,
158
+ 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.`,
159
+ 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.`,
160
+ });
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }