@weft-js/cli 0.1.1 → 0.1.3
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.
- package/README.md +534 -54
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/project.mjs
CHANGED
|
@@ -9,15 +9,30 @@ import { relative } from 'node:path'
|
|
|
9
9
|
import ts from 'typescript'
|
|
10
10
|
import { local } from './alias.mjs'
|
|
11
11
|
|
|
12
|
-
const exts = [
|
|
13
|
-
|
|
12
|
+
const exts = [
|
|
13
|
+
'',
|
|
14
|
+
'.tsx',
|
|
15
|
+
'.ts',
|
|
16
|
+
'.jsx',
|
|
17
|
+
'.js',
|
|
18
|
+
'/index.tsx',
|
|
19
|
+
'/index.ts',
|
|
20
|
+
'/index.jsx',
|
|
21
|
+
'/index.js',
|
|
22
|
+
]
|
|
23
|
+
const isComponentName = (name) => /^[A-Z]/.test(name)
|
|
14
24
|
|
|
15
25
|
/** `a.b.c(…).d` → ['a', 'b']: the root identifier and the first member after it. */
|
|
16
26
|
function chainRoot(expr) {
|
|
17
27
|
let node = expr
|
|
18
28
|
let member
|
|
19
29
|
for (;;) {
|
|
20
|
-
if (
|
|
30
|
+
if (
|
|
31
|
+
ts.isParenthesizedExpression(node) ||
|
|
32
|
+
ts.isNonNullExpression(node) ||
|
|
33
|
+
ts.isAsExpression(node)
|
|
34
|
+
)
|
|
35
|
+
node = node.expression
|
|
21
36
|
else if (ts.isPropertyAccessExpression(node)) {
|
|
22
37
|
member = node.name.text
|
|
23
38
|
node = node.expression
|
|
@@ -37,9 +52,11 @@ function isInJsx(node, stop) {
|
|
|
37
52
|
/** Identifiers an expression reads (not property names); `props.x` is kept as 'props.x'. */
|
|
38
53
|
function identifiersIn(node) {
|
|
39
54
|
const names = new Set()
|
|
40
|
-
const visit = n => {
|
|
41
|
-
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression))
|
|
42
|
-
|
|
55
|
+
const visit = (n) => {
|
|
56
|
+
if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression))
|
|
57
|
+
names.add(`${n.expression.text}.${n.name.text}`)
|
|
58
|
+
if (ts.isIdentifier(n) && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n))
|
|
59
|
+
names.add(n.text)
|
|
43
60
|
ts.forEachChild(n, visit)
|
|
44
61
|
}
|
|
45
62
|
visit(node)
|
|
@@ -55,44 +72,87 @@ function componentsOf(sf) {
|
|
|
55
72
|
if (param && ts.isObjectBindingPattern(param.name)) {
|
|
56
73
|
for (const el of param.name.elements) {
|
|
57
74
|
if (el.dotDotDotToken || !ts.isIdentifier(el.name)) continue
|
|
58
|
-
const key =
|
|
75
|
+
const key =
|
|
76
|
+
el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text
|
|
59
77
|
props.set(el.name.text, key)
|
|
60
78
|
}
|
|
61
79
|
} else if (param && ts.isIdentifier(param.name)) propsIdent = param.name.text
|
|
62
80
|
comps.push({ name, fn, props, propsIdent, derives: new Map(), lists: [], edges: [] })
|
|
63
81
|
}
|
|
64
82
|
for (const st of sf.statements) {
|
|
65
|
-
if (ts.isFunctionDeclaration(st) && st.name && isComponentName(st.name.text))
|
|
66
|
-
|
|
83
|
+
if (ts.isFunctionDeclaration(st) && st.name && isComponentName(st.name.text))
|
|
84
|
+
add(st.name.text, st)
|
|
85
|
+
if (
|
|
86
|
+
ts.isFunctionDeclaration(st) &&
|
|
87
|
+
!st.name &&
|
|
88
|
+
st.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)
|
|
89
|
+
)
|
|
90
|
+
add('default', st)
|
|
67
91
|
if (ts.isVariableStatement(st)) {
|
|
68
92
|
for (const d of st.declarationList.declarations) {
|
|
69
|
-
if (
|
|
93
|
+
if (
|
|
94
|
+
ts.isIdentifier(d.name) &&
|
|
95
|
+
isComponentName(d.name.text) &&
|
|
96
|
+
d.initializer &&
|
|
97
|
+
(ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
|
|
98
|
+
)
|
|
99
|
+
add(d.name.text, d.initializer)
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
102
|
}
|
|
73
|
-
for (const c of comps)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
for (const c of comps) analyzeComponent(c)
|
|
104
|
+
return comps
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function analyzeComponent(c) {
|
|
108
|
+
const visit = (n) => {
|
|
109
|
+
if (
|
|
110
|
+
ts.isVariableDeclaration(n) &&
|
|
111
|
+
ts.isIdentifier(n.name) &&
|
|
112
|
+
n.initializer &&
|
|
113
|
+
ts.isCallExpression(n.initializer)
|
|
114
|
+
) {
|
|
115
|
+
const callee = n.initializer.expression.getText()
|
|
116
|
+
if (callee === 'derived' || callee === 'state.linked')
|
|
117
|
+
c.derives.set(n.name.text, identifiersIn(n.initializer))
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
ts.isCallExpression(n) &&
|
|
121
|
+
ts.isPropertyAccessExpression(n.expression) &&
|
|
122
|
+
n.expression.name.text === 'map' &&
|
|
123
|
+
isInJsx(n, c.fn)
|
|
124
|
+
) {
|
|
125
|
+
const cb = n.arguments[0]
|
|
126
|
+
const root = chainRoot(n.expression.expression)
|
|
127
|
+
if (root && cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb)))
|
|
128
|
+
c.lists.push({ root, at: n.getStart() })
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
(ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) &&
|
|
132
|
+
ts.isIdentifier(n.tagName) &&
|
|
133
|
+
isComponentName(n.tagName.text)
|
|
134
|
+
) {
|
|
135
|
+
for (const attr of n.attributes.properties) {
|
|
136
|
+
if (
|
|
137
|
+
!ts.isJsxAttribute(attr) ||
|
|
138
|
+
!attr.initializer ||
|
|
139
|
+
!ts.isJsxExpression(attr.initializer) ||
|
|
140
|
+
!attr.initializer.expression
|
|
141
|
+
)
|
|
142
|
+
continue
|
|
143
|
+
const root = chainRoot(attr.initializer.expression)
|
|
144
|
+
if (root)
|
|
145
|
+
c.edges.push({
|
|
146
|
+
tag: n.tagName.text,
|
|
147
|
+
prop: attr.name.getText(),
|
|
148
|
+
root,
|
|
149
|
+
at: attr.getStart(),
|
|
150
|
+
})
|
|
90
151
|
}
|
|
91
|
-
ts.forEachChild(n, visit)
|
|
92
152
|
}
|
|
93
|
-
|
|
153
|
+
ts.forEachChild(n, visit)
|
|
94
154
|
}
|
|
95
|
-
|
|
155
|
+
visit(c.fn.body ?? c.fn)
|
|
96
156
|
}
|
|
97
157
|
|
|
98
158
|
/**
|
|
@@ -101,31 +161,17 @@ function componentsOf(sf) {
|
|
|
101
161
|
*/
|
|
102
162
|
export function projectView(files, pos) {
|
|
103
163
|
// Only a W014 warning needs the project view, so most runs parse nothing here.
|
|
104
|
-
if (!files.some(f => f.diagnostics.some(d => d.code === 'W014' && d.severity === 'warning')))
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
f.comps = componentsOf(f.sf)
|
|
109
|
-
f.imports = new Map()
|
|
110
|
-
for (const st of f.sf.statements) {
|
|
111
|
-
if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier)) continue
|
|
112
|
-
const spec = st.moduleSpecifier.text
|
|
113
|
-
const base = local(f.abs, spec)
|
|
114
|
-
if (!base) continue
|
|
115
|
-
const target = exts.map(e => base + e).find(p => byAbs.has(p))
|
|
116
|
-
if (!target) continue
|
|
117
|
-
if (st.importClause.name) f.imports.set(st.importClause.name.text, { file: target, name: 'default' })
|
|
118
|
-
const named = st.importClause.namedBindings
|
|
119
|
-
if (named && ts.isNamedImports(named)) for (const el of named.elements) f.imports.set(el.name.text, { file: target, name: (el.propertyName ?? el.name).text })
|
|
120
|
-
}
|
|
121
|
-
}
|
|
164
|
+
if (!files.some((f) => f.diagnostics.some((d) => d.code === 'W014' && d.severity === 'warning')))
|
|
165
|
+
return
|
|
166
|
+
const byAbs = new Map(files.map((f) => [f.abs, f]))
|
|
167
|
+
parseProject(files, byAbs)
|
|
122
168
|
|
|
123
169
|
const lookup = (file, tag) => {
|
|
124
|
-
const own = file.comps.find(c => c.name === tag)
|
|
170
|
+
const own = file.comps.find((c) => c.name === tag)
|
|
125
171
|
if (own) return { file, comp: own }
|
|
126
172
|
const imp = file.imports.get(tag)
|
|
127
173
|
const target = imp && byAbs.get(imp.file)
|
|
128
|
-
const comp = target?.comps.find(c => c.name === imp.name)
|
|
174
|
+
const comp = target?.comps.find((c) => c.name === imp.name)
|
|
129
175
|
return comp ? { file: target, comp } : null
|
|
130
176
|
}
|
|
131
177
|
|
|
@@ -136,7 +182,8 @@ export function projectView(files, pos) {
|
|
|
136
182
|
if (comp.props.has(name)) return new Set([comp.props.get(name)])
|
|
137
183
|
if (name === comp.propsIdent && member) return new Set([member])
|
|
138
184
|
const out = new Set()
|
|
139
|
-
for (const dep of comp.derives.get(name) ?? [])
|
|
185
|
+
for (const dep of comp.derives.get(name) ?? [])
|
|
186
|
+
for (const p of propsRead(comp, dep.split('.'), seen)) out.add(p)
|
|
140
187
|
return out
|
|
141
188
|
}
|
|
142
189
|
|
|
@@ -145,12 +192,14 @@ export function projectView(files, pos) {
|
|
|
145
192
|
const id = `${file.abs}#${comp.name}#${prop}`
|
|
146
193
|
if (seen.has(id)) return null
|
|
147
194
|
seen.add(id)
|
|
148
|
-
for (const list of comp.lists)
|
|
195
|
+
for (const list of comp.lists)
|
|
196
|
+
if (propsRead(comp, list.root).has(prop)) return { file, at: list.at, via: [] }
|
|
149
197
|
for (const edge of comp.edges) {
|
|
150
198
|
if (!propsRead(comp, edge.root).has(prop)) continue
|
|
151
199
|
const next = lookup(file, edge.tag)
|
|
152
200
|
const hit = next && reachesList(next.file, next.comp, edge.prop, seen)
|
|
153
|
-
if (hit)
|
|
201
|
+
if (hit)
|
|
202
|
+
return { ...hit, via: [{ file, at: edge.at, tag: edge.tag, prop: edge.prop }, ...hit.via] }
|
|
154
203
|
}
|
|
155
204
|
return null
|
|
156
205
|
}
|
|
@@ -160,15 +209,75 @@ export function projectView(files, pos) {
|
|
|
160
209
|
if (name === box) return true
|
|
161
210
|
if (seen.has(name)) return false
|
|
162
211
|
seen.add(name)
|
|
163
|
-
return [...(comp?.derives.get(name) ?? [])].some(
|
|
212
|
+
return [...(comp?.derives.get(name) ?? [])].some((dep) =>
|
|
213
|
+
readsBox(comp, dep.split('.'), box, seen),
|
|
214
|
+
)
|
|
164
215
|
}
|
|
165
216
|
|
|
217
|
+
promoteFindings(files, pos, readsBox, lookup, reachesList)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The box a flagged `.map` call writes: `box.value = …map(…)` or `const box = …map(…)`. */
|
|
221
|
+
function boxOf(sf, offset) {
|
|
222
|
+
let found
|
|
223
|
+
const visit = (n) => {
|
|
224
|
+
if (found) return
|
|
225
|
+
if (ts.isCallExpression(n) && n.getStart() === offset) found = n
|
|
226
|
+
else if (n.pos <= offset && offset < n.end) ts.forEachChild(n, visit)
|
|
227
|
+
}
|
|
228
|
+
visit(sf)
|
|
229
|
+
for (let n = found; n; n = n.parent) {
|
|
230
|
+
if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
231
|
+
const left = n.left
|
|
232
|
+
if (
|
|
233
|
+
ts.isPropertyAccessExpression(left) &&
|
|
234
|
+
left.name.text === 'value' &&
|
|
235
|
+
ts.isIdentifier(left.expression)
|
|
236
|
+
)
|
|
237
|
+
return left.expression.text
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
if (ts.isVariableDeclaration(n)) return ts.isIdentifier(n.name) ? n.name.text : null
|
|
241
|
+
}
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const relPath = (root, abs) => relative(root, abs).split('\\').join('/')
|
|
246
|
+
|
|
247
|
+
function parseProject(files, byAbs) {
|
|
248
|
+
for (const f of files) {
|
|
249
|
+
f.sf = ts.createSourceFile(f.abs, f.text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
250
|
+
f.comps = componentsOf(f.sf)
|
|
251
|
+
f.imports = new Map()
|
|
252
|
+
for (const st of f.sf.statements) {
|
|
253
|
+
if (
|
|
254
|
+
!ts.isImportDeclaration(st) ||
|
|
255
|
+
!st.importClause ||
|
|
256
|
+
!ts.isStringLiteral(st.moduleSpecifier)
|
|
257
|
+
)
|
|
258
|
+
continue
|
|
259
|
+
const spec = st.moduleSpecifier.text
|
|
260
|
+
const base = local(f.abs, spec)
|
|
261
|
+
if (!base) continue
|
|
262
|
+
const target = exts.map((e) => base + e).find((p) => byAbs.has(p))
|
|
263
|
+
if (!target) continue
|
|
264
|
+
if (st.importClause.name)
|
|
265
|
+
f.imports.set(st.importClause.name.text, { file: target, name: 'default' })
|
|
266
|
+
const named = st.importClause.namedBindings
|
|
267
|
+
if (named && ts.isNamedImports(named))
|
|
268
|
+
for (const el of named.elements)
|
|
269
|
+
f.imports.set(el.name.text, { file: target, name: (el.propertyName ?? el.name).text })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function promoteFindings(files, pos, readsBox, lookup, reachesList) {
|
|
166
275
|
for (const f of files) {
|
|
167
276
|
for (const d of f.diagnostics) {
|
|
168
277
|
if (d.code !== 'W014' || d.severity !== 'warning' || !d.offset) continue
|
|
169
278
|
const box = boxOf(f.sf, d.offset[0])
|
|
170
279
|
if (!box) continue
|
|
171
|
-
const owners = f.comps.filter(c => c.fn.pos <= d.offset[0] && d.offset[0] < c.fn.end)
|
|
280
|
+
const owners = f.comps.filter((c) => c.fn.pos <= d.offset[0] && d.offset[0] < c.fn.end)
|
|
172
281
|
for (const comp of owners.length ? owners : f.comps) {
|
|
173
282
|
let hit = null
|
|
174
283
|
let edge
|
|
@@ -185,9 +294,13 @@ export function projectView(files, pos) {
|
|
|
185
294
|
d.project = true
|
|
186
295
|
d.why =
|
|
187
296
|
`\`.map(p => ({ ...p, … }))\` gives every item a new identity on each update, and \`${box}\` reaches a keyed list in ` +
|
|
188
|
-
`${hit.file.rel}:${listPos[0]} through ${path.map(p => `<${p.tag} ${p.prop}>`).join(' → ')}: every row re-runs on every change.`
|
|
297
|
+
`${hit.file.rel}:${listPos[0]} through ${path.map((p) => `<${p.tag} ${p.prop}>`).join(' → ')}: every row re-runs on every change.`
|
|
189
298
|
d.related = [
|
|
190
|
-
...path.map(p => ({
|
|
299
|
+
...path.map((p) => ({
|
|
300
|
+
file: p.file.rel,
|
|
301
|
+
span: pos(p.file).span(p.at, p.at),
|
|
302
|
+
why: `passed as \`<${p.tag} ${p.prop}>\``,
|
|
303
|
+
})),
|
|
191
304
|
{ file: hit.file.rel, span: listPos, why: 'rendered here as a keyed list' },
|
|
192
305
|
]
|
|
193
306
|
break
|
|
@@ -195,25 +308,3 @@ export function projectView(files, pos) {
|
|
|
195
308
|
}
|
|
196
309
|
}
|
|
197
310
|
}
|
|
198
|
-
|
|
199
|
-
/** The box a flagged `.map` call writes: `box.value = …map(…)` or `const box = …map(…)`. */
|
|
200
|
-
function boxOf(sf, offset) {
|
|
201
|
-
let found
|
|
202
|
-
const visit = n => {
|
|
203
|
-
if (found) return
|
|
204
|
-
if (ts.isCallExpression(n) && n.getStart() === offset) found = n
|
|
205
|
-
else if (n.pos <= offset && offset < n.end) ts.forEachChild(n, visit)
|
|
206
|
-
}
|
|
207
|
-
visit(sf)
|
|
208
|
-
for (let n = found; n; n = n.parent) {
|
|
209
|
-
if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
210
|
-
const left = n.left
|
|
211
|
-
if (ts.isPropertyAccessExpression(left) && left.name.text === 'value' && ts.isIdentifier(left.expression)) return left.expression.text
|
|
212
|
-
return null
|
|
213
|
-
}
|
|
214
|
-
if (ts.isVariableDeclaration(n)) return ts.isIdentifier(n.name) ? n.name.text : null
|
|
215
|
-
}
|
|
216
|
-
return null
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export const relPath = (root, abs) => relative(root, abs).split('\\').join('/')
|
package/src/props-checks.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Prop inference in `weft check` and `weft tsc` (research 44). `W080`–`W085` come from the engine; a `TS` code is the
|
|
2
2
|
// compiler's own error, reported at the source position with the inferred types applied.
|
|
3
|
+
// W007/W008 map proven core box errors.
|
|
3
4
|
|
|
4
5
|
import { existsSync, readFileSync } from 'node:fs'
|
|
5
6
|
import { createRequire } from 'node:module'
|
|
@@ -10,19 +11,36 @@ import { diagnostic, positions } from './diag.mjs'
|
|
|
10
11
|
const require = createRequire(import.meta.url)
|
|
11
12
|
const { Session } = require('./infer/session.cjs')
|
|
12
13
|
|
|
13
|
-
const posix = p => p.split('\\').join('/')
|
|
14
|
+
const posix = (p) => p.split('\\').join('/')
|
|
14
15
|
|
|
15
16
|
export function toDiagnostics(root, list, file) {
|
|
16
17
|
const rel = posix(relative(root, file))
|
|
17
18
|
const text = readFileSync(file, 'utf8')
|
|
18
19
|
const pos = positions(text)
|
|
19
|
-
return list.map(f =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
return list.map((f) =>
|
|
21
|
+
diagnostic({
|
|
22
|
+
code: f.code,
|
|
23
|
+
severity: f.severity,
|
|
24
|
+
file: rel,
|
|
25
|
+
span: pos.span(f.start, f.end),
|
|
26
|
+
offset: [f.start, f.end],
|
|
27
|
+
why: f.why,
|
|
28
|
+
help: f.help,
|
|
29
|
+
fix: f.edits && {
|
|
30
|
+
description: f.help,
|
|
31
|
+
safe: f.safe,
|
|
32
|
+
edits: f.edits.map((e) => ({
|
|
33
|
+
file: rel,
|
|
34
|
+
span: pos.span(e.start, e.end),
|
|
35
|
+
offset: [e.start, e.end],
|
|
36
|
+
text: e.text,
|
|
37
|
+
})),
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
/**
|
|
43
|
+
/** Prop inference and core box diagnostics; a project with no relevant source starts no checker. */
|
|
26
44
|
export async function propChecks(root, config) {
|
|
27
45
|
if (config.check?.props === false || !existsSync(join(root, 'tsconfig.json'))) return []
|
|
28
46
|
const session = new Session(ts, root)
|
package/src/props-listener.mjs
CHANGED
|
@@ -10,29 +10,47 @@ const SKIP = /[\\/](node_modules|\.weft|\.output|dist)[\\/]/
|
|
|
10
10
|
export function attachProps(server, root, config) {
|
|
11
11
|
const setting = config?.check?.props
|
|
12
12
|
if (setting === false || process.env.WEFT_PROPS === 'off') return null
|
|
13
|
-
const worker = new Worker(new URL('./infer/worker.cjs', import.meta.url), {
|
|
13
|
+
const worker = new Worker(new URL('./infer/worker.cjs', import.meta.url), {
|
|
14
|
+
workerData: { root, write: setting !== 'manual' },
|
|
15
|
+
stdout: false,
|
|
16
|
+
})
|
|
14
17
|
worker.unref()
|
|
15
18
|
const stats = { saves: 0, ms: [] }
|
|
16
|
-
const post = file => {
|
|
19
|
+
const post = (file) => {
|
|
20
|
+
if (SOURCE.test(file) && !SKIP.test(file)) worker.postMessage(file)
|
|
21
|
+
}
|
|
17
22
|
for (const event of ['change', 'add', 'unlink']) server.watcher.on(event, post)
|
|
18
23
|
|
|
19
24
|
const logger = server.config?.logger ?? console
|
|
20
|
-
worker.on('message', msg => {
|
|
25
|
+
worker.on('message', (msg) => {
|
|
21
26
|
if (msg.type === 'failed') return logger.warn?.(`weft props: ${msg.message}`)
|
|
22
|
-
if (msg.type === 'wrote')
|
|
27
|
+
if (msg.type === 'wrote')
|
|
28
|
+
return logger.info?.(`weft props: wrote the inferred type in ${msg.file}`)
|
|
23
29
|
if (msg.type !== 'done') return
|
|
24
30
|
stats.saves++
|
|
25
31
|
stats.ms.push(msg.ms)
|
|
26
32
|
if (!msg.changed) return
|
|
27
|
-
const errors = msg.diagnostics.filter(d => d.severity === 'error')
|
|
28
|
-
for (const d of msg.diagnostics)
|
|
33
|
+
const errors = msg.diagnostics.filter((d) => d.severity === 'error')
|
|
34
|
+
for (const d of msg.diagnostics)
|
|
35
|
+
logger.warn?.(`${d.file}:${d.line} ${d.code} ${d.severity} ${d.why}\n fix: ${d.help}`)
|
|
29
36
|
server.ws.send({ type: 'custom', event: 'weft:props', data: msg.diagnostics })
|
|
30
37
|
// A save's HMR update has cleared any earlier overlay already, so this only ever shows the current errors.
|
|
31
|
-
if (errors.length)
|
|
38
|
+
if (errors.length)
|
|
39
|
+
server.ws.send({
|
|
40
|
+
type: 'error',
|
|
41
|
+
err: {
|
|
42
|
+
message: errors.map((d) => `${d.file}:${d.line} ${d.code}: ${d.why}`).join('\n'),
|
|
43
|
+
stack: '',
|
|
44
|
+
plugin: 'weft:props',
|
|
45
|
+
},
|
|
46
|
+
})
|
|
32
47
|
})
|
|
33
|
-
worker.on('error', e => logger.warn?.(`weft props stopped: ${e.message}`))
|
|
48
|
+
worker.on('error', (e) => logger.warn?.(`weft props stopped: ${e.message}`))
|
|
34
49
|
|
|
35
50
|
const close = server.close.bind(server)
|
|
36
|
-
server.close = async () => {
|
|
51
|
+
server.close = async () => {
|
|
52
|
+
worker.postMessage('close')
|
|
53
|
+
return close()
|
|
54
|
+
}
|
|
37
55
|
return { worker, stats }
|
|
38
56
|
}
|
package/src/react-checks.mjs
CHANGED
|
@@ -16,19 +16,50 @@ export function reactChecks({ files }) {
|
|
|
16
16
|
const pos = positions(text)
|
|
17
17
|
for (const el of scanJsx(text, rel).elements) {
|
|
18
18
|
for (const a of el.attrs) {
|
|
19
|
-
if (a.name === 'key' && a.kind === 'expr' && RANDOM.test(a.expr))
|
|
19
|
+
if (a.name === 'key' && a.kind === 'expr' && RANDOM.test(a.expr))
|
|
20
|
+
out.push(
|
|
21
|
+
diagnostic({
|
|
22
|
+
code: 'W044',
|
|
23
|
+
file: rel,
|
|
24
|
+
span: pos.span(a.start, a.end),
|
|
25
|
+
offset: [a.start, a.end],
|
|
26
|
+
why: `\`key={${a.expr}}\` differs between the server and the client, so this row can't hydrate and is built again, and it differs on every render.`,
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
20
29
|
}
|
|
21
30
|
if (!NATIVE.test(el.name)) continue
|
|
22
31
|
for (const a of el.attrs) {
|
|
23
|
-
if (
|
|
32
|
+
if (
|
|
33
|
+
!a.name.startsWith('data-') ||
|
|
34
|
+
a.kind !== 'expr' ||
|
|
35
|
+
(a.expr !== 'true' && a.expr !== 'false')
|
|
36
|
+
)
|
|
37
|
+
continue
|
|
24
38
|
const on = a.expr === 'true'
|
|
25
39
|
// Removing the attribute takes the space before it too.
|
|
26
40
|
const [from, to] = on ? [a.start, a.end] : [text.slice(0, a.start).trimEnd().length, a.end]
|
|
27
41
|
const why = on
|
|
28
42
|
? `\`${a.name}={true}\` writes \`${a.name}=""\`, not React's \`"true"\`, so \`[${a.name}="true"]\` won't match. Write \`${a.name}\` for a presence flag, or \`${a.name}="true"\` for the word.`
|
|
29
43
|
: `\`${a.name}={false}\` writes no attribute, not React's \`"false"\`. Drop it, or write \`${a.name}="false"\` for the word.`
|
|
30
|
-
const fix = {
|
|
31
|
-
|
|
44
|
+
const fix = {
|
|
45
|
+
description: on
|
|
46
|
+
? `Write \`${a.name}\`, which renders the same`
|
|
47
|
+
: `Remove \`${a.name}\`, which renders the same`,
|
|
48
|
+
safe: true,
|
|
49
|
+
edits: [
|
|
50
|
+
{ file: rel, span: pos.span(from, to), offset: [from, to], text: on ? a.name : '' },
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
out.push(
|
|
54
|
+
diagnostic({
|
|
55
|
+
code: 'W043',
|
|
56
|
+
file: rel,
|
|
57
|
+
span: pos.span(a.start, a.end),
|
|
58
|
+
offset: [a.start, a.end],
|
|
59
|
+
why,
|
|
60
|
+
fix,
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
32
63
|
}
|
|
33
64
|
}
|
|
34
65
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// W030 joins server reachability with proven core state calls at module evaluation.
|
|
2
|
+
import { diagnostic, positions } from './diag.mjs'
|
|
3
|
+
import { serverGraph } from './state-checks/server-graph.mjs'
|
|
4
|
+
import { coreState, moduleCalls } from './state-checks/core-state.mjs'
|
|
5
|
+
|
|
6
|
+
/** State created by a server-reachable module is shared across requests unless explicitly global. */
|
|
7
|
+
export function serverStateChecks(root, options) {
|
|
8
|
+
const graph = serverGraph(root, options)
|
|
9
|
+
if (!graph.size) return []
|
|
10
|
+
const resolve = coreState(root, graph)
|
|
11
|
+
const out = []
|
|
12
|
+
for (const [file, { sf, entry }] of graph) {
|
|
13
|
+
const pos = positions(sf.text)
|
|
14
|
+
moduleCalls(sf, (call) => {
|
|
15
|
+
if (!['state', 'linked'].includes(resolve(call.expression))) return
|
|
16
|
+
const start = call.getStart(sf),
|
|
17
|
+
end = call.end
|
|
18
|
+
out.push(
|
|
19
|
+
diagnostic({
|
|
20
|
+
code: 'W030',
|
|
21
|
+
severity: 'error',
|
|
22
|
+
file,
|
|
23
|
+
span: pos.span(start, end),
|
|
24
|
+
offset: [start, end],
|
|
25
|
+
why: `This module creates state reachable from server entry \`${entry}\`; requests share it.`,
|
|
26
|
+
help:
|
|
27
|
+
'Create state inside a request/component factory or a context owned by the request. ' +
|
|
28
|
+
'Use `state.global` only when sharing across requests is intended.',
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
return out.sort((a, b) => a.file.localeCompare(b.file) || a.offset[0] - b.offset[0])
|
|
34
|
+
}
|