@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.
Files changed (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. 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, relative, resolve } from 'node:path'
17
- import { diagnostic, sortDiagnostics, summarize } from './diag.mjs'
18
- import { discover } from './files.mjs'
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 { apply, changedLines, mutants as makeMutants, sample, suggest } from './mutate.mjs'
21
- import { changedCases, testCases, weakened } from './testdiff.mjs'
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 => /(^|\/)(vite|vitest|weft|tsup|eslint|playwright)\.config\.[cm]?[jt]s$/.test(rel)
27
- const posix = p => p.split('\\').join('/')
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) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'ignore'] })
32
- const list = [...git('diff', '--name-only', '--relative', '--no-renames', base).split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')]
33
- const rels = [...new Set(list.filter(r => r && !r.startsWith('.weft/')))]
34
- return rels.map(rel => {
35
- let before = null
36
- try { before = git('show', `${base}:./${rel}`) } catch {}
37
- const abs = join(root, rel)
38
- return { rel, before, after: existsSync(abs) ? readFileSync(abs, 'utf8') : null }
39
- }).filter(c => c.before !== c.after)
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(1, options.concurrency ?? Math.min(4, Math.floor(availableParallelism() / 3) || 1))
71
- const changes = (options.changes ?? gitChanges(root, options.base ?? 'HEAD')).map(c => ({ ...c, rel: posix(c.rel) }))
72
-
73
- // Tests, test support (helpers only tests import) and source.
74
- const tests = changes.filter(c => isTestFile(c.rel))
75
- const others = changes.filter(c => !isTestFile(c.rel) && code(c.rel) && !configFile(c.rel))
76
- const testAbs = new Set(discover(root, { include: ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], exclude: ['.weft/**'] }).map(r => join(root, r)))
77
- const importedBySource = new Set()
78
- for (const rel of discover(root, { include: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], exclude: ['.weft/**'] })) {
79
- const abs = join(root, rel)
80
- if (testAbs.has(abs)) continue
81
- for (const p of importsOf(abs, readFileSync(abs, 'utf8'))) importedBySource.add(p)
82
- }
83
- const importedByTests = new Set()
84
- for (const abs of testAbs) for (const p of importsOf(abs, readFileSync(abs, 'utf8'))) importedByTests.add(p)
85
- const isSupport = rel => importedByTests.has(join(root, rel)) && !importedBySource.has(join(root, rel)) && !rel.match(/(^|\/)src\//)
86
- const support = others.filter(c => isSupport(c.rel))
87
- const source = others.filter(c => !isSupport(c.rel))
88
-
89
- // Static: T020 on every changed test file, T001–T009 on the ones that still exist (T005 blocks here).
90
- const diagnostics = []
91
- for (const c of tests) diagnostics.push(...weakened(c.before, c.after, c.rel))
92
- for (const c of tests) {
93
- if (c.after == null) continue
94
- for (const d of checkTestFile(c.after, c.rel, { abs: join(root, c.rel), root })) {
95
- if (d.code === 'T005' && d.severity === 'warning') { d.severity = 'error'; d.why += ' The gate blocks implementation queries in new and changed tests.' }
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) for (const c of cases) diagnostics.push(diagnostic({ code: 'T020', file: c.file, span: [c.line, 1, c.line, 1], why: `--refactor promises behavior didn't change, but test "${c.name}" is new or edited`, help: 'Drop --refactor, or leave the tests alone' }))
128
- if (!cases.length) { result.notes.push('refactor: no test changed; run the whole suite to confirm behavior'); return finish() }
129
- }
130
- if (!cases.length) { result.notes.push('no new or changed tests'); return finish() }
131
-
132
- const bin = vitestBin(root)
133
- const run = (dir, files) => runVitest(dir, files, { bin, config: options.config, timeoutMs: 180_000 })
134
-
135
- // After: the change as it stands. Every changed test must pass.
136
- log(`gate: ${cases.length} new or changed tests in ${testFiles.length} files; running them on the change`)
137
- const afterDir = copyProject(root, { exclude: options.exclude, id: 'after' })
138
- let after
139
- try { after = await run(afterDir, testFiles) } finally { removeCopy(afterDir) }
140
- const resolveCases = res => {
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
- const afterStatus = resolveCases(after)
152
- const byKey = new Map()
153
- for (const [key, st] of afterStatus) {
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
- // Mutation: the changed source lines (or, with no source change, the source files the tests import).
183
- if (options.mutation !== false) {
184
- // Only code the changed tests can reach (runtime imports): a mutant nothing runs survives by definition.
185
- const reach = (limit) => {
186
- const seen = new Set()
187
- const walk = (abs, depth) => {
188
- if (seen.has(abs) || depth > limit || !existsSync(abs)) return
189
- seen.add(abs)
190
- for (const p of importsOf(abs, readFileSync(abs, 'utf8'))) walk(p, depth + 1)
191
- }
192
- for (const f of testFiles) walk(join(root, f), 0)
193
- return seen
194
- }
195
- const reachable = reach(12)
196
- const unreached = source.filter(c => c.after != null && code(c.rel) && !reachable.has(join(root, c.rel))).map(c => c.rel)
197
- if (unreached.length) result.notes.push(`not mutated (no changed test imports them): ${unreached.join(', ')}`)
198
- let targets = source.filter(c => c.after != null && reachable.has(join(root, c.rel))).map(c => ({ rel: c.rel, text: c.after, lines: changedLines(c.before, c.after) }))
199
- if (!source.length) {
200
- targets = [...reach(3)].map(abs => posix(relative(root, abs))).filter(rel => !isTestFile(rel) && code(rel) && !isSupport(rel) && !rel.startsWith('..') && !rel.includes('node_modules'))
201
- .map(rel => { const text = readFileSync(join(root, rel), 'utf8'); return { rel, text, lines: changedLines(null, text) } })
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 => /\bW\d{3}\b|SyntaxError|Transform failed|Parse error|Unexpected token|Expected/.test(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(`${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}` : ''}`)
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) lines.push(`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}` : ''}`)
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 }