@weft-js/cli 0.1.1 → 0.1.2
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 +38 -14
- 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/gate.mjs
CHANGED
|
@@ -13,30 +13,43 @@
|
|
|
13
13
|
import { execFileSync } from 'node:child_process'
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs'
|
|
15
15
|
import { availableParallelism } from 'node:os'
|
|
16
|
-
import { join,
|
|
17
|
-
import { diagnostic
|
|
18
|
-
import {
|
|
16
|
+
import { join, resolve } from 'node:path'
|
|
17
|
+
import { diagnostic } from './diag.mjs'
|
|
18
|
+
import { runGate } from './gate/run.mjs'
|
|
19
|
+
import { classify } from './gate/classify.mjs'
|
|
20
|
+
import { makeResult } from './gate/result.mjs'
|
|
19
21
|
import { loadConfig } from './load-config.mjs'
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { allowances, checkTestFile, isTestFile, parse, resolveImport } from './tests.mjs'
|
|
23
|
-
import { copyProject, removeCopy, runVitest, vitestBin, writeFiles } from './workspace.mjs'
|
|
22
|
+
import { testCases } from './testdiff.mjs'
|
|
23
|
+
import { resolveImport } from './tests.mjs'
|
|
24
24
|
|
|
25
|
-
const code = rel => /\.[cm]?[jt]sx?$/.test(rel) && !/\.d\.[cm]?ts$/.test(rel)
|
|
26
|
-
const configFile = rel =>
|
|
27
|
-
|
|
25
|
+
const code = (rel) => /\.[cm]?[jt]sx?$/.test(rel) && !/\.d\.[cm]?ts$/.test(rel)
|
|
26
|
+
const configFile = (rel) =>
|
|
27
|
+
/(^|\/)(vite|vitest|weft|tsup|eslint|playwright)\.config\.[cm]?[jt]s$/.test(rel)
|
|
28
|
+
const posix = (p) => p.split('\\').join('/')
|
|
28
29
|
|
|
29
30
|
/** Changed files between `base` and the working tree, from git (paths relative to `root`). */
|
|
30
31
|
export function gitChanges(root, base = 'HEAD') {
|
|
31
|
-
const git = (...args) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
32
|
+
const git = (...args) =>
|
|
33
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
maxBuffer: 1 << 28,
|
|
36
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
37
|
+
})
|
|
38
|
+
const list = [
|
|
39
|
+
...git('diff', '--name-only', '--relative', '--no-renames', base).split('\n'),
|
|
40
|
+
...git('ls-files', '--others', '--exclude-standard').split('\n'),
|
|
41
|
+
]
|
|
42
|
+
const rels = [...new Set(list.filter((r) => r && !r.startsWith('.weft/')))]
|
|
43
|
+
return rels
|
|
44
|
+
.map((rel) => {
|
|
45
|
+
let before = null
|
|
46
|
+
try {
|
|
47
|
+
before = git('show', `${base}:./${rel}`)
|
|
48
|
+
} catch {}
|
|
49
|
+
const abs = join(root, rel)
|
|
50
|
+
return { rel, before, after: existsSync(abs) ? readFileSync(abs, 'utf8') : null }
|
|
51
|
+
})
|
|
52
|
+
.filter((c) => c.before !== c.after)
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
const importRe = /(?:from\s*|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g
|
|
@@ -67,207 +80,96 @@ export async function gate(root, options = {}) {
|
|
|
67
80
|
const gateCfg = config.test?.gate ?? {}
|
|
68
81
|
const budgetSec = options.budgetSec ?? gateCfg.mutationBudgetSec ?? 60
|
|
69
82
|
const maxMutants = options.maxMutants ?? gateCfg.maxMutants ?? 40
|
|
70
|
-
const concurrency = Math.max(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
diagnostics.push(d)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// The tests this change has to prove.
|
|
101
|
-
// `// weft-allow T010: <reason>` / T011 on a test keeps it (shown in the result, for the reviewer).
|
|
102
|
-
const allowFns = new Map(tests.filter(c => c.after != null).map(c => [c.rel, allowances(c.after, parse(c.after, c.rel))]))
|
|
103
|
-
const allowed = (rule, file, line) => allowFns.get(file)?.(rule, line) ?? false
|
|
104
|
-
const cases = tests.filter(c => c.after != null).flatMap(c => changedCases(c.before, c.after, c.rel).map(t => ({ file: c.rel, name: t.name, line: t.line, end: t.end, dynamic: t.dynamic })))
|
|
105
|
-
const testFiles = [...new Set(cases.map(c => c.file))]
|
|
106
|
-
const result = {
|
|
107
|
-
version: 1, ok: true, root, base: options.changes ? null : (options.base ?? 'HEAD'),
|
|
108
|
-
files: { tests: tests.map(c => c.rel), support: support.map(c => c.rel), source: source.map(c => c.rel) },
|
|
109
|
-
tests: [], mutants: { generated: 0, run: 0, killed: 0, survived: 0, timedOut: 0, invalid: 0, sampled: false, budgetSec }, survivors: [],
|
|
110
|
-
diagnostics, summary: null, notes: [], ms: 0,
|
|
111
|
-
}
|
|
112
|
-
const finish = () => {
|
|
113
|
-
sortDiagnostics(diagnostics)
|
|
114
|
-
// Each test's verdict: it ran green on the change, failed before it (when there was a before), and no error
|
|
115
|
-
// diagnostic falls inside it.
|
|
116
|
-
for (const t of result.tests) {
|
|
117
|
-
t.codes = diagnostics.filter(d => d.severity === 'error' && d.file === t.file && d.span[0] >= t.line && d.span[0] <= t.end).map(d => d.code)
|
|
118
|
-
t.accepted = t.after === 'passed' && t.before !== 'passed' && !t.codes.length
|
|
119
|
-
}
|
|
120
|
-
result.summary = { changedTests: result.tests.length, ...summarize(diagnostics) }
|
|
121
|
-
result.ok = result.summary.errors === 0 && result.tests.every(t => t.accepted)
|
|
122
|
-
result.ms = Math.round(performance.now() - started)
|
|
123
|
-
return result
|
|
124
|
-
}
|
|
83
|
+
const concurrency = Math.max(
|
|
84
|
+
1,
|
|
85
|
+
options.concurrency ?? Math.min(4, Math.floor(availableParallelism() / 3) || 1),
|
|
86
|
+
)
|
|
87
|
+
const changes = (options.changes ?? gitChanges(root, options.base ?? 'HEAD')).map((c) => ({
|
|
88
|
+
...c,
|
|
89
|
+
rel: posix(c.rel),
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
const { tests, support, source, isSupport, diagnostics, allowed, cases, testFiles } = classify(
|
|
93
|
+
root,
|
|
94
|
+
changes,
|
|
95
|
+
code,
|
|
96
|
+
configFile,
|
|
97
|
+
importsOf,
|
|
98
|
+
)
|
|
99
|
+
const { result, finish } = makeResult({
|
|
100
|
+
root,
|
|
101
|
+
options,
|
|
102
|
+
tests,
|
|
103
|
+
support,
|
|
104
|
+
source,
|
|
105
|
+
diagnostics,
|
|
106
|
+
budgetSec,
|
|
107
|
+
started,
|
|
108
|
+
})
|
|
125
109
|
|
|
126
110
|
if (options.refactor) {
|
|
127
|
-
if (cases.length)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// Static names map 1:1; results nobody claimed belong to the file's dynamic (`.each`) cases.
|
|
142
|
-
const out = new Map()
|
|
143
|
-
for (const f of testFiles) {
|
|
144
|
-
const mine = cases.filter(c => c.file === f)
|
|
145
|
-
const keys = [...res.tests.keys()].filter(k => k.startsWith(`${f}::`))
|
|
146
|
-
for (const c of mine.filter(x => !x.dynamic)) out.set(`${f}::${c.name}`, res.loadErrors.has(f) ? { status: 'failed', message: res.loadErrors.get(f), load: true } : res.tests.get(`${f}::${c.name}`) ?? { status: 'missing' })
|
|
147
|
-
if (mine.some(c => c.dynamic)) for (const k of keys) if (!out.has(k)) out.set(k, res.tests.get(k))
|
|
111
|
+
if (cases.length)
|
|
112
|
+
for (const c of cases)
|
|
113
|
+
diagnostics.push(
|
|
114
|
+
diagnostic({
|
|
115
|
+
code: 'T020',
|
|
116
|
+
file: c.file,
|
|
117
|
+
span: [c.line, 1, c.line, 1],
|
|
118
|
+
why: `--refactor promises behavior didn't change, but test "${c.name}" is new or edited`,
|
|
119
|
+
help: 'Drop --refactor, or leave the tests alone',
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
if (!cases.length) {
|
|
123
|
+
result.notes.push('refactor: no test changed; run the whole suite to confirm behavior')
|
|
124
|
+
return finish()
|
|
148
125
|
}
|
|
149
|
-
return out
|
|
150
126
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const [file, name] = key.split('::')
|
|
155
|
-
const c = cases.find(x => x.file === file && x.name === name) ?? cases.find(x => x.file === file && x.dynamic)
|
|
156
|
-
const t = { file, name, line: c?.line ?? 1, end: c?.end ?? c?.line ?? 1, after: st.status, afterMessage: st.status === 'passed' ? undefined : st.message, before: null, kills: 0 }
|
|
157
|
-
byKey.set(key, t)
|
|
158
|
-
result.tests.push(t)
|
|
127
|
+
if (!cases.length) {
|
|
128
|
+
result.notes.push('no new or changed tests')
|
|
129
|
+
return finish()
|
|
159
130
|
}
|
|
160
|
-
if (after.timedOut) result.notes.push('the run on the change timed out')
|
|
161
|
-
|
|
162
|
-
// Before: source reverted, tests and their helpers as they are now. Every changed test must fail.
|
|
163
|
-
if (source.length && !options.refactor) {
|
|
164
|
-
log(`gate: reverting ${source.length} source files; the changed tests must fail`)
|
|
165
|
-
const beforeDir = copyProject(root, { exclude: options.exclude, id: 'before' })
|
|
166
|
-
let before
|
|
167
|
-
try {
|
|
168
|
-
writeFiles(beforeDir, Object.fromEntries(source.map(c => [c.rel, c.before])))
|
|
169
|
-
before = await run(beforeDir, testFiles)
|
|
170
|
-
} finally { removeCopy(beforeDir) }
|
|
171
|
-
const beforeStatus = resolveCases(before)
|
|
172
|
-
for (const [key, t] of byKey) {
|
|
173
|
-
const st = beforeStatus.get(key) ?? (before.loadErrors.has(t.file) ? { status: 'failed', message: before.loadErrors.get(t.file) } : { status: 'missing' })
|
|
174
|
-
t.before = st.status
|
|
175
|
-
t.beforeMessage = st.message
|
|
176
|
-
if (t.after === 'passed' && st.status === 'passed' && allowed('T010', t.file, t.line)) result.notes.push(`T010 allowed on "${t.name}"`)
|
|
177
|
-
else if (t.after === 'passed' && st.status === 'passed')
|
|
178
|
-
diagnostics.push(diagnostic({ code: 'T010', file: t.file, span: [t.line, 1, t.line, 1], why: `"${t.name}" passes on the code before this change too, so it doesn't test the change`, help: 'Assert what the change makes different (what the user sees now and didn\'t before); or, if this is a test for existing behavior, add it in its own change with no source edits' }))
|
|
179
|
-
}
|
|
180
|
-
} else if (!options.refactor) result.notes.push('no source change: fail-before skipped; mutants come from the source the new tests import')
|
|
181
131
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
const all = targets.flatMap(t => makeMutants(t.text, t.rel, t.lines))
|
|
204
|
-
const list = sample(all, maxMutants)
|
|
205
|
-
result.mutants.generated = all.length
|
|
206
|
-
result.mutants.sampled = list.length < all.length
|
|
207
|
-
const passing = [...byKey.values()].filter(t => t.after === 'passed')
|
|
208
|
-
const files = [...new Set(passing.map(t => t.file))]
|
|
209
|
-
if (list.length && files.length) {
|
|
210
|
-
log(`gate: ${list.length}${result.mutants.sampled ? ` of ${all.length}` : ''} mutants on ${targets.length} files, ${concurrency} at a time, ${budgetSec}s budget`)
|
|
211
|
-
const texts = Object.fromEntries(targets.map(t => [t.rel, t.text]))
|
|
212
|
-
const deadline = started + budgetSec * 1000 + after.ms
|
|
213
|
-
const timeoutMs = Math.max(15_000, after.ms * 3)
|
|
214
|
-
const queue = [...list]
|
|
215
|
-
const outcomes = []
|
|
216
|
-
const worker = async i => {
|
|
217
|
-
const dir = copyProject(root, { exclude: options.exclude, id: `mutant-${i}` })
|
|
218
|
-
try {
|
|
219
|
-
for (let m; (m = queue.shift()); ) {
|
|
220
|
-
if (performance.now() > deadline) { result.mutants.sampled = true; queue.length = 0; break }
|
|
221
|
-
writeFiles(dir, { [m.file]: apply(texts[m.file], m) })
|
|
222
|
-
const res = await runVitest(dir, files, { bin, config: options.config, timeoutMs })
|
|
223
|
-
writeFiles(dir, { [m.file]: texts[m.file] })
|
|
224
|
-
outcomes.push({ m, res })
|
|
225
|
-
}
|
|
226
|
-
} finally { removeCopy(dir) }
|
|
227
|
-
}
|
|
228
|
-
await Promise.all(Array.from({ length: Math.min(concurrency, list.length) }, (_, i) => worker(i)))
|
|
229
|
-
for (const { m, res } of outcomes) {
|
|
230
|
-
result.mutants.run++
|
|
231
|
-
if (res.timedOut) { result.mutants.timedOut++; result.mutants.killed++; continue }
|
|
232
|
-
const loadOnly = res.tests.size === 0 && res.loadErrors.size > 0
|
|
233
|
-
if (loadOnly && [...res.loadErrors.values()].some(compileError)) { result.mutants.invalid++; continue }
|
|
234
|
-
let killed = false
|
|
235
|
-
for (const t of passing) {
|
|
236
|
-
const st = res.loadErrors.has(t.file) ? 'failed' : res.tests.get(`${t.file}::${t.name}`)?.status
|
|
237
|
-
if (st && st !== 'passed') { t.kills++; killed = true }
|
|
238
|
-
}
|
|
239
|
-
if (killed) result.mutants.killed++
|
|
240
|
-
else { result.mutants.survived++; result.survivors.push({ file: m.file, line: m.line, column: m.column, op: m.op, original: m.original.slice(0, 80), replacement: m.replacement, suggestion: suggest(m) }) }
|
|
241
|
-
}
|
|
242
|
-
const valid = result.mutants.run - result.mutants.invalid
|
|
243
|
-
if (valid > 0)
|
|
244
|
-
for (const t of passing) {
|
|
245
|
-
if (t.kills > 0 || t.before === 'passed') continue
|
|
246
|
-
if (allowed('T011', t.file, t.line)) { result.notes.push(`T011 allowed on "${t.name}"`); continue }
|
|
247
|
-
const partial = result.mutants.sampled
|
|
248
|
-
diagnostics.push(diagnostic({
|
|
249
|
-
code: 'T011', severity: partial ? 'warning' : 'error', file: t.file, span: [t.line, 1, t.line, 1],
|
|
250
|
-
why: `"${t.name}" killed none of the ${valid} mutants of the changed lines${partial ? ` (a sample of ${result.mutants.generated})` : ''}: it would still pass if that code were wrong`,
|
|
251
|
-
help: result.survivors[0] ? `Assert what the changed code computes. For example: ${result.survivors[0].suggestion}` : 'Assert what the changed code computes',
|
|
252
|
-
}))
|
|
253
|
-
}
|
|
254
|
-
} else if (!list.length) result.notes.push('no mutants on the changed lines')
|
|
255
|
-
}
|
|
132
|
+
await runGate({
|
|
133
|
+
root,
|
|
134
|
+
options,
|
|
135
|
+
cases,
|
|
136
|
+
testFiles,
|
|
137
|
+
log,
|
|
138
|
+
result,
|
|
139
|
+
source,
|
|
140
|
+
allowed,
|
|
141
|
+
diagnostics,
|
|
142
|
+
importsOf,
|
|
143
|
+
code,
|
|
144
|
+
posix,
|
|
145
|
+
isSupport,
|
|
146
|
+
maxMutants,
|
|
147
|
+
concurrency,
|
|
148
|
+
budgetSec,
|
|
149
|
+
started,
|
|
150
|
+
compileError,
|
|
151
|
+
})
|
|
256
152
|
return finish()
|
|
257
153
|
}
|
|
258
154
|
|
|
259
155
|
/** A mutant the compiler or parser rejects never ran, so it counts as neither killed nor survived. */
|
|
260
|
-
const compileError = msg =>
|
|
156
|
+
const compileError = (msg) =>
|
|
157
|
+
/\bW\d{3}\b|SyntaxError|Transform failed|Parse error|Unexpected token|Expected/.test(msg)
|
|
261
158
|
|
|
262
159
|
/** Human summary for stderr. */
|
|
263
160
|
export function formatGate(r) {
|
|
264
161
|
const lines = []
|
|
265
162
|
for (const t of r.tests) {
|
|
266
163
|
const mark = t.accepted ? '✓' : '✗'
|
|
267
|
-
lines.push(
|
|
164
|
+
lines.push(
|
|
165
|
+
`${mark} ${t.file} › ${t.name} before: ${t.before ?? '—'}, after: ${t.after}, kills: ${t.kills}${t.codes?.length ? `, ${t.codes.join(' ')}` : ''}${t.after !== 'passed' && t.afterMessage ? `\n ${t.afterMessage}` : ''}`,
|
|
166
|
+
)
|
|
268
167
|
}
|
|
269
168
|
const m = r.mutants
|
|
270
|
-
if (m.run)
|
|
169
|
+
if (m.run)
|
|
170
|
+
lines.push(
|
|
171
|
+
`mutants: ${m.killed}/${m.run - m.invalid} killed${m.timedOut ? ` (${m.timedOut} by timeout)` : ''}${m.invalid ? `, ${m.invalid} didn't compile` : ''}${m.sampled ? `, sampled from ${m.generated}` : ''}`,
|
|
172
|
+
)
|
|
271
173
|
for (const s of r.survivors.slice(0, 8)) lines.push(` survived: ${s.suggestion}`)
|
|
272
174
|
for (const n of r.notes) lines.push(`note: ${n}`)
|
|
273
175
|
return lines.join('\n')
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Map proven JSX box/value assignability errors. Type names alone are not evidence:
|
|
2
|
+
// value members must come from the resolved core State/Derived exports.
|
|
3
|
+
const { coreMembers, known } = require('./core-boxes.cjs')
|
|
4
|
+
const assignmentCodes = new Set([2322, 2739, 2740, 2741])
|
|
5
|
+
|
|
6
|
+
function boxKind(ts, checker, type, members) {
|
|
7
|
+
if (!known(ts, type)) return null
|
|
8
|
+
if (type.isUnion()) {
|
|
9
|
+
const nullable = ts.TypeFlags.Null | ts.TypeFlags.Undefined
|
|
10
|
+
const parts = type.types.filter((part) => !(part.flags & nullable))
|
|
11
|
+
const kinds = new Set(parts.map((part) => boxKind(ts, checker, part, members)))
|
|
12
|
+
return kinds.size === 1 ? [...kinds][0] : null
|
|
13
|
+
}
|
|
14
|
+
if (members.roots.has(type.getSymbol()) || members.roots.has(type.aliasSymbol)) return 'box'
|
|
15
|
+
const value = checker.getPropertyOfType(type, 'value')
|
|
16
|
+
const declarations = value?.getDeclarations() ?? []
|
|
17
|
+
const names = new Set(checker.getPropertiesOfType(type).map((property) => property.name))
|
|
18
|
+
// A projection of just .value is not evidence of the complete box contract.
|
|
19
|
+
return members.shapes.some(
|
|
20
|
+
(shape) =>
|
|
21
|
+
shape.names.length > 1 &&
|
|
22
|
+
shape.names.every((name) => names.has(name)) &&
|
|
23
|
+
declarations.some((declaration) => shape.declarations.has(declaration)),
|
|
24
|
+
)
|
|
25
|
+
? 'box'
|
|
26
|
+
: 'value'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function attribute(ts, file, start) {
|
|
30
|
+
let node = ts.getTokenAtPosition(file, start)
|
|
31
|
+
while (node && !ts.isJsxAttribute(node)) node = node.parent
|
|
32
|
+
if (!node || !node.initializer || !ts.isJsxExpression(node.initializer)) return null
|
|
33
|
+
const expression = node.initializer.expression
|
|
34
|
+
const opening = node.parent.parent
|
|
35
|
+
if (!expression || !opening.tagName || start > expression.getStart(file)) return null
|
|
36
|
+
return { expression, tag: opening.tagName }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Scalar aliases and synthesized boxes stay opaque; no complete contract is proven there.
|
|
40
|
+
function originatingBox(ts, expression) {
|
|
41
|
+
while (
|
|
42
|
+
ts.isParenthesizedExpression(expression) ||
|
|
43
|
+
ts.isAsExpression(expression) ||
|
|
44
|
+
ts.isTypeAssertionExpression(expression) ||
|
|
45
|
+
ts.isNonNullExpression(expression) ||
|
|
46
|
+
ts.isSatisfiesExpression(expression)
|
|
47
|
+
)
|
|
48
|
+
expression = expression.expression
|
|
49
|
+
if (ts.isPropertyAccessExpression(expression) && expression.name.text === 'value')
|
|
50
|
+
return expression.expression
|
|
51
|
+
if (
|
|
52
|
+
ts.isElementAccessExpression(expression) &&
|
|
53
|
+
expression.argumentExpression &&
|
|
54
|
+
ts.isStringLiteralLike(expression.argumentExpression) &&
|
|
55
|
+
expression.argumentExpression.text === 'value'
|
|
56
|
+
)
|
|
57
|
+
return expression.expression
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A core diagnostic only when changing box/value direction explains the type error. */
|
|
62
|
+
function boxMismatch(ts, program, diagnostic) {
|
|
63
|
+
if (!assignmentCodes.has(diagnostic.code) || !diagnostic.file || diagnostic.start === undefined)
|
|
64
|
+
return null
|
|
65
|
+
const site = attribute(ts, diagnostic.file, diagnostic.start)
|
|
66
|
+
if (!site) return null
|
|
67
|
+
const checker = program.getTypeChecker()
|
|
68
|
+
const tag = checker.getTypeAtLocation(site.tag)
|
|
69
|
+
if (!tag.getCallSignatures().length && !tag.getConstructSignatures().length) return null
|
|
70
|
+
const actual = checker.getTypeAtLocation(site.expression)
|
|
71
|
+
const expected = checker.getContextualType(site.expression)
|
|
72
|
+
const members = coreMembers(ts, program, checker)
|
|
73
|
+
const given = boxKind(ts, checker, actual, members)
|
|
74
|
+
const wanted = boxKind(ts, checker, expected, members)
|
|
75
|
+
if (!given || !wanted || given === wanted || checker.isTypeAssignableTo(actual, expected))
|
|
76
|
+
return null
|
|
77
|
+
const box = checker.getNonNullableType(given === 'box' ? actual : expected)
|
|
78
|
+
const value = checker.getTypeOfPropertyOfType(box, 'value')
|
|
79
|
+
if (!known(ts, value)) return null
|
|
80
|
+
const from = given === 'box' ? value : actual
|
|
81
|
+
const to = given === 'box' ? expected : value
|
|
82
|
+
if (!checker.isTypeAssignableTo(from, to)) return null
|
|
83
|
+
if (given === 'value') {
|
|
84
|
+
const origin = originatingBox(ts, site.expression)
|
|
85
|
+
const original = origin && checker.getTypeAtLocation(origin)
|
|
86
|
+
if (
|
|
87
|
+
boxKind(ts, checker, original, members) !== 'box' ||
|
|
88
|
+
!checker.isTypeAssignableTo(original, expected)
|
|
89
|
+
)
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
const text = site.expression.getText(diagnostic.file)
|
|
93
|
+
return {
|
|
94
|
+
code: 'W008',
|
|
95
|
+
severity: 'error',
|
|
96
|
+
why:
|
|
97
|
+
given === 'box'
|
|
98
|
+
? `This component prop expects a value, but \`${text}\` is a core box.`
|
|
99
|
+
: `This component prop expects a core box, but \`${text}\` is a value.`,
|
|
100
|
+
help:
|
|
101
|
+
given === 'box'
|
|
102
|
+
? `Read the box's \`.value\` before passing it to this prop; handle null or undefined first if needed.`
|
|
103
|
+
: 'Pass the State/Derived box itself, for example `count` instead of `count.value`.',
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function canMap(file, diagnostic) {
|
|
108
|
+
return diagnostic.code === 2540 || (file.endsWith('.tsx') && assignmentCodes.has(diagnostic.code))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Keep native inference; lazily map core box prop and readonly errors with the existing JS host. */
|
|
112
|
+
function withBoxProps(host, createFallback) {
|
|
113
|
+
let fallback
|
|
114
|
+
return {
|
|
115
|
+
...host,
|
|
116
|
+
update(files, touched = []) {
|
|
117
|
+
host.update(files, touched)
|
|
118
|
+
fallback?.update(files, touched)
|
|
119
|
+
},
|
|
120
|
+
diagnostics(file) {
|
|
121
|
+
const list = host.diagnostics(file)
|
|
122
|
+
if (!list.some((diagnostic) => canMap(file, diagnostic))) return list
|
|
123
|
+
fallback ??= createFallback()
|
|
124
|
+
const mapped = fallback.diagnostics(file).filter((diagnostic) => diagnostic.weft)
|
|
125
|
+
return list.map((diagnostic) => {
|
|
126
|
+
if (!canMap(file, diagnostic)) return diagnostic
|
|
127
|
+
const match = mapped.find(
|
|
128
|
+
(candidate) => candidate.start === diagnostic.start && candidate.code === diagnostic.code,
|
|
129
|
+
)
|
|
130
|
+
return match ? { ...diagnostic, weft: match.weft } : diagnostic
|
|
131
|
+
})
|
|
132
|
+
},
|
|
133
|
+
close() {
|
|
134
|
+
host.close()
|
|
135
|
+
fallback?.close()
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { boxMismatch, withBoxProps }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Core box provenance shared by TypeScript diagnostic mappings.
|
|
2
|
+
const programs = new WeakMap()
|
|
3
|
+
|
|
4
|
+
/** Resolved core exports and member declarations for one checker program. */
|
|
5
|
+
function coreMembers(ts, program, checker) {
|
|
6
|
+
if (programs.has(program)) return programs.get(program)
|
|
7
|
+
const roots = new Set(),
|
|
8
|
+
shapes = [],
|
|
9
|
+
derivedRoots = new Set()
|
|
10
|
+
for (const file of program.getSourceFiles()) {
|
|
11
|
+
for (const node of file.statements) {
|
|
12
|
+
if (
|
|
13
|
+
(!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) ||
|
|
14
|
+
node.moduleSpecifier?.text !== '@weft-js/core'
|
|
15
|
+
)
|
|
16
|
+
continue
|
|
17
|
+
const module = checker.getSymbolAtLocation(node.moduleSpecifier)
|
|
18
|
+
if (!module) continue
|
|
19
|
+
for (let symbol of checker.getExportsOfModule(module)) {
|
|
20
|
+
if (!['State', 'Derived'].includes(symbol.name)) continue
|
|
21
|
+
const derived = symbol.name === 'Derived'
|
|
22
|
+
if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
|
|
23
|
+
const type = checker.getDeclaredTypeOfSymbol(symbol)
|
|
24
|
+
const value = checker.getPropertyOfType(type, 'value')
|
|
25
|
+
roots.add(symbol)
|
|
26
|
+
if (derived) derivedRoots.add(symbol)
|
|
27
|
+
shapes.push({
|
|
28
|
+
names: checker.getPropertiesOfType(type).map((property) => property.name),
|
|
29
|
+
declarations: new Set(value?.getDeclarations() ?? []),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const core = { roots, shapes, derivedRoots }
|
|
35
|
+
programs.set(program, core)
|
|
36
|
+
return core
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether a type avoids any, unknown and unresolved type parameters. */
|
|
40
|
+
function known(ts, type) {
|
|
41
|
+
if (!type || type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter))
|
|
42
|
+
return false
|
|
43
|
+
return !type.isUnion() || type.types.every((part) => known(ts, part))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { coreMembers, known }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// W007 maps only TypeScript's readonly write error on a proven core Derived.value.
|
|
2
|
+
const { coreMembers, known } = require('./core-boxes.cjs')
|
|
3
|
+
|
|
4
|
+
function derivedSymbol(ts, checker, symbol, roots, seen = new Set()) {
|
|
5
|
+
if (!symbol || seen.has(symbol)) return false
|
|
6
|
+
seen.add(symbol)
|
|
7
|
+
if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
|
|
8
|
+
if (roots.has(symbol)) return true
|
|
9
|
+
return (symbol.getDeclarations() ?? []).some((declaration) => {
|
|
10
|
+
if (!ts.isTypeAliasDeclaration(declaration)) return false
|
|
11
|
+
let type = declaration.type
|
|
12
|
+
while (ts.isParenthesizedTypeNode(type)) type = type.type
|
|
13
|
+
return (
|
|
14
|
+
ts.isTypeReferenceNode(type) &&
|
|
15
|
+
derivedSymbol(ts, checker, checker.getSymbolAtLocation(type.typeName), roots, seen)
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function derivedType(ts, checker, type, roots) {
|
|
21
|
+
if (!known(ts, type)) return false
|
|
22
|
+
if (type.isUnion()) return type.types.every((part) => derivedType(ts, checker, part, roots))
|
|
23
|
+
if (type.isIntersection()) return type.types.some((part) => derivedType(ts, checker, part, roots))
|
|
24
|
+
if (
|
|
25
|
+
derivedSymbol(ts, checker, type.aliasSymbol, roots) ||
|
|
26
|
+
derivedSymbol(ts, checker, type.getSymbol(), roots)
|
|
27
|
+
)
|
|
28
|
+
return true
|
|
29
|
+
return (
|
|
30
|
+
!!type.isClassOrInterface?.() &&
|
|
31
|
+
checker.getBaseTypes(type).some((base) => derivedType(ts, checker, base, roots))
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeTarget(ts, node) {
|
|
36
|
+
for (let parent = node.parent; parent; node = parent, parent = parent.parent) {
|
|
37
|
+
if (ts.isBinaryExpression(parent) && parent.left === node) {
|
|
38
|
+
const operator = parent.operatorToken.kind
|
|
39
|
+
return (
|
|
40
|
+
operator === ts.SyntaxKind.EqualsToken ||
|
|
41
|
+
(operator >= ts.SyntaxKind.FirstCompoundAssignment &&
|
|
42
|
+
operator <= ts.SyntaxKind.LastCompoundAssignment)
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent))
|
|
46
|
+
return (
|
|
47
|
+
parent.operand === node &&
|
|
48
|
+
[ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(parent.operator)
|
|
49
|
+
)
|
|
50
|
+
if (ts.isForOfStatement(parent) || ts.isForInStatement(parent))
|
|
51
|
+
return parent.initializer === node
|
|
52
|
+
const wrapped =
|
|
53
|
+
ts.isParenthesizedExpression(parent) ||
|
|
54
|
+
ts.isNonNullExpression(parent) ||
|
|
55
|
+
ts.isAsExpression(parent) ||
|
|
56
|
+
ts.isTypeAssertionExpression(parent)
|
|
57
|
+
const wrapper = wrapped && parent.expression === node
|
|
58
|
+
const pattern =
|
|
59
|
+
ts.isArrayLiteralExpression(parent) ||
|
|
60
|
+
ts.isObjectLiteralExpression(parent) ||
|
|
61
|
+
(ts.isPropertyAssignment(parent) && parent.initializer === node) ||
|
|
62
|
+
(ts.isSpreadElement(parent) && parent.expression === node) ||
|
|
63
|
+
(ts.isSpreadAssignment(parent) && parent.expression === node)
|
|
64
|
+
if (!wrapper && !pattern) return false
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Remap actual readonly assignments; ambiguous receivers and projections keep TS2540. */
|
|
70
|
+
function derivedWrite(ts, program, diagnostic) {
|
|
71
|
+
if (diagnostic.code !== 2540 || !diagnostic.file || diagnostic.start === undefined) return null
|
|
72
|
+
let node = ts.getTokenAtPosition(diagnostic.file, diagnostic.start)
|
|
73
|
+
while (node && !ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node))
|
|
74
|
+
node = node.parent
|
|
75
|
+
if (!node || !writeTarget(ts, node)) return null
|
|
76
|
+
const name = ts.isPropertyAccessExpression(node)
|
|
77
|
+
? node.name
|
|
78
|
+
: node.argumentExpression && ts.isStringLiteralLike(node.argumentExpression)
|
|
79
|
+
? node.argumentExpression
|
|
80
|
+
: null
|
|
81
|
+
if (name?.text !== 'value') return null
|
|
82
|
+
const checker = program.getTypeChecker()
|
|
83
|
+
const roots = coreMembers(ts, program, checker).derivedRoots
|
|
84
|
+
if (!derivedType(ts, checker, checker.getTypeAtLocation(node.expression), roots)) return null
|
|
85
|
+
return {
|
|
86
|
+
code: 'W007',
|
|
87
|
+
severity: 'error',
|
|
88
|
+
why: 'This writes a core Derived.value; derived values are readonly computations.',
|
|
89
|
+
help:
|
|
90
|
+
'Use `state(initial)` or `state.linked(() => source)` when independent writes are intended. ' +
|
|
91
|
+
'Otherwise keep `derived(() => expression)` pure and update its source state instead.',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { derivedWrite }
|