@weft-js/cli 0.0.0-stage → 0.1.1

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 (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
package/src/mcp.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // `weft mcp`: an MCP server over stdio (newline-delimited JSON-RPC 2.0) with the tools `docs`, `check`, `routes_meta`, `tasks`,
2
+ // `style_find`, `style_explain`, `test_gate` and the `browser_*` tools.
3
+ // Every tool returns the same JSON as the CLI's `--json`, as text and as structuredContent.
4
+
5
+ import { audit } from './audit.mjs'
6
+ import { agentMap } from './map.mjs'
7
+ import { once } from 'node:events'
8
+ import { createInterface } from 'node:readline'
9
+ import { check } from './check.mjs'
10
+ import { docs, topics } from './docs.mjs'
11
+ import { listSurfaces } from './surface-checks.mjs'
12
+ import { routesCommand } from './start.mjs'
13
+ import { tasks } from './tasks.mjs'
14
+ import { browser, expectations, actions as browserActions } from './browser/commands.mjs'
15
+ import { gate } from './gate.mjs'
16
+ import { explainTarget, findGroups, styleMatrix } from './style.mjs'
17
+
18
+ const versions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
19
+
20
+ const target = {
21
+ description: 'What the user sees: { role, name } (name exact), { label }, { text }, or a ref from the latest snapshot ("@e12")',
22
+ anyOf: [
23
+ { type: 'string', pattern: '^@?e\\d+$' },
24
+ { type: 'object', properties: { role: { type: 'string' }, name: { type: 'string' }, exact: { type: 'boolean' } }, required: ['role'] },
25
+ { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
26
+ { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
27
+ ],
28
+ }
29
+
30
+ /** The `weft browser` commands as tools. The page is the app open in any browser; the dev server relays to it. */
31
+ function browserTools() {
32
+ const tab = { url: { type: 'string', description: 'Dev server URL (default: .weft/dev.json, then environments.dev.url)' }, tab: { type: 'string', description: 'Tab id from browser_tabs (default: the focused or latest tab)' } }
33
+ const t = (name, description, properties = {}, required) => ({ name, description, inputSchema: { type: 'object', properties: { ...properties, ...tab }, ...(required ? { required } : {}) } })
34
+ return [
35
+ t('browser_tabs', 'Tabs of the app connected to the dev server. Any browser works (yours, the person\'s); with none, call browser_launch. If your own browser can run JS, `await __weft__.agent.run(command, args)` in the page does the same as these tools.'),
36
+ t('browser_launch', 'Start a headless Chrome on the app (only when you have no browser of your own)', { route: { type: 'string' }, headed: { type: 'boolean' } }),
37
+ t('browser_open', 'Navigate the tab to a route', { route: { type: 'string' } }, ['route']),
38
+ t('browser_snapshot', "The page as roles, names and refs (`- button \"Add Banana\" [ref=e15]`), plus weft's live reactive graph with real names (`Cart.total = 42.5 ← Cart.subtotal → 1 binding in <Cart>`), and errors since the last call. Big payloads come back as a file path.", {
39
+ interactive: { type: 'boolean', description: 'Only interactive elements' }, delta: { type: 'boolean', description: 'Only what changed since the last snapshot' },
40
+ scope: target, graph: { description: "false: no graph; 'only': only the graph; a string filters it by name", anyOf: [{ type: 'boolean' }, { type: 'string' }] },
41
+ }),
42
+ t('browser_act', `Act like a user (user-event, as in @weft-js/core/test) and get back what changed on the page. Actions: ${browserActions.join(', ')}`, { target, action: { type: 'string', enum: browserActions }, value: { type: 'string', description: 'fill/type text, press key (`{Enter}`), select option' } }, ['target', 'action']),
43
+ t('browser_assert', `Check what the user sees (retries up to timeout ms). Expectations: ${expectations.join(', ')}. Recorded for browser_record.`, { target, expect: { type: 'object', description: '{ text: "Total $4" } | { value: "SAVE10" } | { disabled: true } | { count: 3 } | { absent: true } | …' }, timeout: { type: 'number' } }, ['target', 'expect']),
44
+ t('browser_state', "Read the named reactive graph, or write a box by its graph name to reach a state fast (dev only)", { filter: { type: 'string', description: 'Name prefix (`Cart`, `cart.`)' }, set: { type: 'string', description: 'Graph name to write, e.g. Cart.coupon' }, value: { description: 'JSON value to write' } }),
45
+ t('browser_time', "The page's clock: pause (reload: from page load, so startup timers are faked too), advance ms (runs due timers in order), resume, status", { op: { type: 'string', enum: ['pause', 'advance', 'resume', 'status'] }, ms: { type: 'number' }, reload: { type: 'boolean' } }, ['op']),
46
+ t('browser_net', 'The network as the app sees it: offline (navigator.onLine false, offline event, fetch fails, sockets dropped), online, latency ms (fetch), drop (close open WebSockets/EventSources: a server restart), reset, status', { op: { type: 'string', enum: ['offline', 'online', 'latency', 'drop', 'reset', 'status'] }, ms: { type: 'number' } }, ['op']),
47
+ t('browser_record', 'Record what happens in the tab (your acts and asserts, and clicks/typing from any browser tool) and turn it into a @weft-js/core/test test, which then has to pass the gate', {
48
+ op: { type: 'string', enum: ['start', 'stop', 'steps', 'note', 'code'] }, text: { type: 'string' }, component: { type: 'string' }, from: { type: 'string' }, props: { type: 'string' }, name: { type: 'string' }, out: { type: 'string', description: 'Write the test to this file (relative to the project)' },
49
+ }, ['op']),
50
+ t('browser_logs', 'Console errors and warnings, and uncaught errors with the weft binding they came from, since the last call'),
51
+ t('browser_screenshot', 'PNG of the launched Chrome (browser_launch); in your own browser, use its screenshot tool', { out: { type: 'string' } }),
52
+ ]
53
+ }
54
+
55
+ /** What `weft mcp` offers an agent: each tool's name, its description and the arguments it takes. */
56
+ export const tools = [
57
+ {
58
+ name: 'docs',
59
+ description: `weft docs for agents. Topics: ${Object.keys(topics).join(', ')}; or a diagnostic code such as W014. No topic: the index.`,
60
+ inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Topic name or diagnostic code' } } },
61
+ },
62
+ {
63
+ name: 'check',
64
+ description: 'Run `weft check`: every W- and P-diagnostic with code, file, span, why and fix (edits when an autofix exists), plus slot bindings.',
65
+ inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'Files relative to the project root; default: all' } } },
66
+ },
67
+ {
68
+ name: 'routes_meta',
69
+ description: "Every route's metadata as a crawler gets it (`weft routes --meta --json`): title, description, image, every tag, where each came from (`route`, `layout`, `config`, `auto`, `card`), real samples of param routes, and H-diagnostics with text to apply. Every page sets `meta` from its own context as part of building it: call this after adding or changing a route.",
70
+ inputSchema: { type: 'object', properties: { preset: { type: 'string', description: "`static`: no server at runtime, so a param route's card needs `prerender`" } } },
71
+ },
72
+ {
73
+ name: 'audit',
74
+ description: "The pages as a browser renders them (`weft audit`): a clean Chrome at a phone's and a desktop's width, axe-core's WCAG rules (what Lighthouse grades), console errors, deprecations, the head a crawler reads, sideways scroll and layout shift, as L-diagnostics with fixes. Static checks read source; this catches what CSS and a breakpoint do to it. Default: the last build's pages.",
75
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'A running site to audit instead (a deploy, `weft start`, the dev server)' }, routes: { type: 'array', items: { type: 'string' }, description: 'Paths; default one page of each kind' } } },
76
+ },
77
+ {
78
+ name: 'map',
79
+ description: 'What this app has and what to call for each part: routes, server functions, data, styles, plugins',
80
+ inputSchema: { type: 'object', properties: {} },
81
+ },
82
+ {
83
+ name: 'tasks',
84
+ description: "List the project's commands and tasks: what each does, its args schema, side effects and allowed environments.",
85
+ inputSchema: { type: 'object', properties: {} },
86
+ },
87
+ {
88
+ name: 'style_find',
89
+ description: "Before styling anything, find the style groups that already fit (`weft style find`): name, doc, axes and values, parts, states, how often it's used, and similar groups. Reuse or add an axis value; don't write a new group or class list for the same thing.",
90
+ inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'What the thing is: `card`, `status badge`, `nav`, or a token such as `critical`' } }, required: ['query'] },
91
+ },
92
+ {
93
+ name: 'style_explain',
94
+ description: 'Explain one variation of a style group (`weft style explain`): every declaration on its root and parts in cascade order with the token, axis, state, compound or accent it came from, the attributes it renders, the CSS it emits, and the call sites that produce it.',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ target: { type: 'string', description: 'A group (`order-card`), a part (`order-card.title`), `order-card[status=late][selected]`, or a DOM selector (`[data-g="order-card"][data-status="late"]`)' },
99
+ axes: { type: 'object', additionalProperties: { type: 'string' }, description: 'Axis values, e.g. { "status": "late" } (defaults fill the rest)' },
100
+ states: { type: 'array', items: { type: 'string' }, description: 'States to apply: selected, hover…' },
101
+ accent: { type: 'object', additionalProperties: { type: 'string' }, description: 'A call-site accent, intent → token: { "radius": "round" }' },
102
+ part: { type: 'string', description: 'Only this part (and the root)' },
103
+ },
104
+ required: ['target'],
105
+ },
106
+ },
107
+ {
108
+ name: 'surfaces',
109
+ description: "List the project's canvas and WebGL surfaces (`weft surfaces`): each `defineSurface` with its name, kind, props and file, ready to drop in as `<Surface use={X} label=\"…\" />`.",
110
+ inputSchema: { type: 'object', properties: {} },
111
+ },
112
+ {
113
+ name: 'test_gate',
114
+ description: 'Run the test gate on the diff (`weft test --gate`): each new or changed test must fail on the old code (T010), kill a mutant of the changed lines (T011), and no existing test may get weaker (T020). Returns per-test results, surviving mutants with what to assert, and diagnostics.',
115
+ inputSchema: { type: 'object', properties: { base: { type: 'string', description: 'Git ref to diff against (default HEAD: the uncommitted change)' }, budgetSec: { type: 'number' }, maxMutants: { type: 'number' }, refactor: { type: 'boolean' } } },
116
+ },
117
+ ...browserTools(),
118
+ ]
119
+
120
+ async function callBrowser(root, name, args) {
121
+ const o = { url: args.url, tab: args.tab }
122
+ const cmd = name.slice('browser_'.length)
123
+ if (cmd === 'state') return args.set ? browser(root, 'set', { name: args.set, value: args.value }, o) : browser(root, 'graph', { filter: args.filter }, o)
124
+ return browser(root, cmd, args, o)
125
+ }
126
+
127
+ /** Handle one JSON-RPC message. Returns the response, or undefined for notifications. */
128
+ export async function handle(root, msg) {
129
+ const reply = result => ({ jsonrpc: '2.0', id: msg.id, result })
130
+ const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } })
131
+ if (msg?.jsonrpc !== '2.0' || typeof msg.method !== 'string') return fail(-32600, 'Invalid Request')
132
+ const notification = msg.id === undefined
133
+ switch (msg.method) {
134
+ case 'initialize': {
135
+ const asked = msg.params?.protocolVersion
136
+ return reply({
137
+ protocolVersion: versions.includes(asked) ? asked : versions[0],
138
+ capabilities: { tools: { listChanged: false } },
139
+ serverInfo: { name: 'weft', version: '0.1.1' },
140
+ instructions: 'weft is a fine-grained reactive TSX framework, not React. Read `docs` topic `rules` first, and run `check` after every edit. Every page sets `meta` from its own context as part of building it (`docs` topic `meta`). Style with groups from @weft-js/core/style, not class lists: `style_find` before you write one. Verify behavior in the running app with the browser_* tools (or `__weft__.agent` in your own browser), and run `test_gate` before you call a change done.',
141
+ })
142
+ }
143
+ case 'ping':
144
+ return reply({})
145
+ case 'tools/list':
146
+ return reply({ tools })
147
+ case 'tools/call': {
148
+ const { name, arguments: args = {} } = msg.params ?? {}
149
+ try {
150
+ let data
151
+ if (name === 'docs') {
152
+ const page = await docs(root, args.topic)
153
+ return reply({ content: [{ type: 'text', text: page.text }] })
154
+ }
155
+ if (name === 'check') data = await check(root, { files: args.files })
156
+ else if (name === 'tasks') data = await tasks(root)
157
+ else if (name === 'routes_meta') data = await routesCommand(root, { meta: true, preset: args.preset })
158
+ else if (name === 'audit') data = await audit(root, { url: args.url, routes: args.routes })
159
+ else if (name === 'map') data = await agentMap(root)
160
+ else if (name === 'style_find') data = findGroups((await styleMatrix(root)).matrix, args.query)
161
+ else if (name === 'style_explain') data = explainTarget(await styleMatrix(root), args.target, { axes: args.axes, states: args.states, accent: args.accent, part: args.part })
162
+ else if (name === 'surfaces') data = { surfaces: listSurfaces(root) }
163
+ else if (name === 'test_gate') data = await gate(root, { base: args.base, budgetSec: args.budgetSec, maxMutants: args.maxMutants, refactor: args.refactor })
164
+ else if (name.startsWith('browser_')) data = await callBrowser(root, name, args)
165
+ else return fail(-32602, `Unknown tool: ${name}`)
166
+ return reply({ content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data, isError: false })
167
+ } catch (error) {
168
+ return reply({ content: [{ type: 'text', text: `${error.code ?? 'E_INTERNAL'}: ${error.message}` }], isError: true })
169
+ }
170
+ }
171
+ default:
172
+ return notification ? undefined : fail(-32601, `Method not found: ${msg.method}`)
173
+ }
174
+ }
175
+
176
+ /** Answer newline-delimited JSON-RPC from `input`, one message at a time; resolves once `input` has closed and the last answer is out. */
177
+ export function serve(root, input = process.stdin, output = process.stdout) {
178
+ const rl = createInterface({ input, crlfDelay: Infinity })
179
+ const write = obj => output.write(`${JSON.stringify(obj)}\n`)
180
+ let queue = Promise.resolve()
181
+ rl.on('line', line => {
182
+ if (!line.trim()) return
183
+ queue = queue.then(async () => {
184
+ let msg
185
+ try {
186
+ msg = JSON.parse(line)
187
+ } catch {
188
+ return write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })
189
+ }
190
+ const res = await handle(root, msg)
191
+ if (res) write(res)
192
+ })
193
+ })
194
+ return once(rl, 'close').then(() => queue)
195
+ }
@@ -0,0 +1,28 @@
1
+ // The human form of `weft routes --meta`: one line per page, and one per sampled page of a param route, with where each
2
+ // value came from. `--json` has everything else (every tag, the merged object).
3
+
4
+ const short = (s, n) => {
5
+ const text = s ?? ''
6
+ return text.length > n ? text.slice(0, n - 1) + '…' : text
7
+ }
8
+
9
+ const cell = (s, n) => short(s, n).padEnd(n)
10
+
11
+ /** `/_weft/og/users/42.png?v=abc` → `card: users/42.png`; an explicit image as it is. */
12
+ const imageOf = url => (!url ? 'none' : url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/_weft\/og\//, '/_weft/og/').replace(/\?v=.*/, ''))
13
+
14
+ export function metaTable(result) {
15
+ const rows = []
16
+ for (const r of result.routes) {
17
+ if (r.kind !== 'page' || r.redirect !== undefined) continue
18
+ const add = (path, title, description, image, source) => rows.push({ path, title, description, image: imageOf(image), source: source ? `${source.title}/${source.description}/${source.image}` : 'source only' })
19
+ if (r.samples?.length) {
20
+ r.samples.slice(0, 3).forEach(s => add(s.url, s.title, s.description, s.image, s.source))
21
+ if (r.samples.length > 3) rows.push({ path: ` … ${r.samples.length - 3} more of ${r.path}`, title: '', description: '', image: '', source: '' })
22
+ } else add(r.url ?? r.path, r.meta?.title, r.meta?.description, r.meta?.og?.image?.url, r.source)
23
+ }
24
+ const width = { path: Math.min(30, Math.max(5, ...rows.map(r => r.path.length))), image: Math.min(34, Math.max(5, ...rows.map(r => r.image.length))) }
25
+ const line = r => `${cell(r.path, width.path)} ${cell(r.title, 36)} ${cell(r.description, 48)} ${cell(r.image, width.image)} ${r.source}`
26
+ const url = result.site?.url ? `site.url ${result.site.url}` : 'site.url not set'
27
+ return [`${url}. source: title/description/image, each route, layout, config, auto or card.`, line({ path: 'route', title: 'title', description: 'description', image: 'image', source: 'source' }), ...rows.map(line)].join('\n')
28
+ }
package/src/mutate.mjs ADDED
@@ -0,0 +1,140 @@
1
+ // Mutants on the changed lines of a source file (the gate's T011). weft mutates source text, not compiled output, so
2
+ // each mutant goes through the weft compiler like any edit and its W-codes still apply. Operators follow Stryker's
3
+ // common set: equality, boundaries, logic, arithmetic, negation, literals, conditions, removed calls, JSX text.
4
+
5
+ import ts from 'typescript'
6
+ import { positions } from './diag.mjs'
7
+ import { parse } from './tests.mjs'
8
+
9
+ const K = ts.SyntaxKind
10
+ const swaps = new Map([
11
+ [K.EqualsEqualsEqualsToken, ['!==']], [K.ExclamationEqualsEqualsToken, ['===']],
12
+ [K.EqualsEqualsToken, ['!=']], [K.ExclamationEqualsToken, ['==']],
13
+ [K.LessThanToken, ['<=', '>=']], [K.LessThanEqualsToken, ['<', '>']],
14
+ [K.GreaterThanToken, ['>=', '<=']], [K.GreaterThanEqualsToken, ['>', '<']],
15
+ [K.AmpersandAmpersandToken, ['||']], [K.BarBarToken, ['&&']], [K.QuestionQuestionToken, ['&&']],
16
+ [K.PlusToken, ['-']], [K.MinusToken, ['+']], [K.AsteriskToken, ['/']], [K.SlashToken, ['*']], [K.PercentToken, ['*']],
17
+ ])
18
+
19
+ /** Lines (1-based) of `after` that are new or changed since `before` (null: every line). LCS over lines. */
20
+ export function changedLines(before, after) {
21
+ const b = after.split('\n')
22
+ if (before == null) return new Set(b.map((_, i) => i + 1))
23
+ const a = before.split('\n')
24
+ // Trim the common head and tail; the middle is usually small.
25
+ let start = 0
26
+ while (start < a.length && start < b.length && a[start] === b[start]) start++
27
+ let ea = a.length, eb = b.length
28
+ while (ea > start && eb > start && a[ea - 1] === b[eb - 1]) { ea--; eb-- }
29
+ const A = a.slice(start, ea), B = b.slice(start, eb)
30
+ const out = new Set()
31
+ if (A.length * B.length > 4e6) { for (let i = start; i < eb; i++) out.add(i + 1); return out }
32
+ const dp = Array.from({ length: A.length + 1 }, () => new Uint32Array(B.length + 1))
33
+ for (let i = A.length - 1; i >= 0; i--) for (let j = B.length - 1; j >= 0; j--) dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
34
+ let i = 0, j = 0
35
+ while (j < B.length) {
36
+ if (i < A.length && A[i] === B[j]) { i++; j++ }
37
+ else if (i < A.length && dp[i + 1][j] >= dp[i][j + 1]) i++
38
+ else out.add(start + ++j)
39
+ }
40
+ return out
41
+ }
42
+
43
+ /**
44
+ * Mutants whose code starts on one of `lines`.
45
+ * @returns {{ id: string, file: string, line: number, column: number, op: string, start: number, end: number, original: string, replacement: string }[]}
46
+ */
47
+ export function mutants(text, rel, lines) {
48
+ const sf = parse(text, rel)
49
+ const pos = positions(text)
50
+ const out = []
51
+ const add = (node, op, replacement, start = node.getStart(sf), end = node.getEnd()) => {
52
+ const [line, column] = pos.lineCol(start)
53
+ if (!lines.has(line)) return
54
+ const original = text.slice(start, end)
55
+ if (original === replacement) return
56
+ out.push({ id: `${rel}:${line}:${column}:${op}:${out.length}`, file: rel, line, column, op, start, end, original, replacement })
57
+ }
58
+ const skip = n => ts.isImportDeclaration(n) || ts.isExportDeclaration(n) || ts.isTypeNode(n) || ts.isInterfaceDeclaration(n) || ts.isTypeAliasDeclaration(n) || ts.isEnumDeclaration(n)
59
+ const visit = n => {
60
+ if (skip(n)) return
61
+ if (ts.isBinaryExpression(n)) {
62
+ for (const r of swaps.get(n.operatorToken.kind) ?? []) add(n.operatorToken, 'operator', r)
63
+ } else if (ts.isPrefixUnaryExpression(n) && n.operator === K.ExclamationToken) {
64
+ add(n, 'negation', n.operand.getText(sf))
65
+ } else if (n.kind === K.TrueKeyword || n.kind === K.FalseKeyword) {
66
+ add(n, 'boolean', n.kind === K.TrueKeyword ? 'false' : 'true')
67
+ } else if (ts.isNumericLiteral(n)) {
68
+ const v = Number(n.text)
69
+ if (Number.isFinite(v)) add(n, 'number', String(v === 0 ? 1 : v + 1))
70
+ } else if ((ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) && n.text && !isKey(n)) {
71
+ add(n, 'string', '""')
72
+ } else if (ts.isConditionalExpression(n)) {
73
+ add(n.condition, 'condition', 'true')
74
+ add(n.condition, 'condition', 'false')
75
+ } else if (ts.isIfStatement(n)) {
76
+ add(n.expression, 'condition', 'true')
77
+ add(n.expression, 'condition', 'false')
78
+ } else if (ts.isExpressionStatement(n) && ts.isCallExpression(unparen(n.expression)) && !isDirective(n)) {
79
+ add(n, 'call', ';')
80
+ } else if (ts.isJsxText(n) && n.text.trim()) {
81
+ const start = n.pos + n.text.indexOf(n.text.trim()) // JSX text: `text` starts at `pos`, before any trivia
82
+ add(n, 'jsx-text', '', start, start + n.text.trim().length)
83
+ } else if (ts.isReturnStatement(n) && n.expression && isFn(n.parent?.parent) && !ts.isJsxElement(unparen(n.expression)) && !ts.isJsxFragment(unparen(n.expression))) {
84
+ add(n.expression, 'return', 'undefined')
85
+ }
86
+ ts.forEachChild(n, visit)
87
+ }
88
+ visit(sf)
89
+ return out
90
+ }
91
+
92
+ const unparen = n => { while (ts.isParenthesizedExpression(n)) n = n.expression; return n }
93
+ const isFn = n => n && (ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) || ts.isMethodDeclaration(n))
94
+ const isDirective = n => ts.isStringLiteral(n.expression)
95
+ /** Strings that name things rather than being data: property keys, element-access keys, `import()` specifiers. */
96
+ const isKey = n => {
97
+ const p = n.parent
98
+ return (ts.isPropertyAssignment(p) && p.name === n) || ts.isElementAccessExpression(p) || ts.isLiteralTypeNode(p) || ts.isExternalModuleReference(p) ||
99
+ (ts.isCallExpression(p) && p.expression.kind === K.ImportKeyword) || ts.isJsxAttribute(p) && /^(key|class|className|style|id|type|role|for|htmlFor|name)$/.test(p.name.getText())
100
+ }
101
+
102
+ export const apply = (text, m) => text.slice(0, m.start) + m.replacement + text.slice(m.end)
103
+
104
+ /** Spread a sample over lines: one mutant per line in turn, deterministic. */
105
+ export function sample(list, max) {
106
+ if (list.length <= max) return list
107
+ const byLine = new Map()
108
+ for (const m of list) {
109
+ const k = `${m.file}:${m.line}`
110
+ if (!byLine.has(k)) byLine.set(k, [])
111
+ byLine.get(k).push(m)
112
+ }
113
+ const queues = [...byLine.values()]
114
+ const out = []
115
+ for (let round = 0; out.length < max; round++) {
116
+ let any = false
117
+ for (const q of queues) if (q[round]) { out.push(q[round]); any = true; if (out.length >= max) break }
118
+ if (!any) break
119
+ }
120
+ return out
121
+ }
122
+
123
+ /** What a test that kills this mutant would check: the hint surviving mutants come back with (MUTGEN/ACH loop). */
124
+ export function suggest(m) {
125
+ const at = `${m.file}:${m.line}`
126
+ switch (m.op) {
127
+ case 'operator':
128
+ if (/^[<>]=?$/.test(m.original)) return `${at}: \`${m.original}\` → \`${m.replacement}\` survives. Assert the boundary: the value exactly at the limit.`
129
+ if (/^(&&|\|\||\?\?)$/.test(m.original)) return `${at}: \`${m.original}\` → \`${m.replacement}\` survives. Assert a case where only one side of the condition holds.`
130
+ if (/^[!=]=/.test(m.original)) return `${at}: \`${m.original}\` → \`${m.replacement}\` survives. Assert both the matching and the non-matching case.`
131
+ return `${at}: \`${m.original}\` → \`${m.replacement}\` survives. Assert the computed value, not only that something rendered.`
132
+ case 'boolean': case 'condition': case 'negation':
133
+ return `${at}: \`${m.original}\` → \`${m.replacement}\` survives. Assert what the user sees on the branch this condition guards.`
134
+ case 'number': return `${at}: ${m.original} → ${m.replacement} survives. Assert the exact value this number produces.`
135
+ case 'string': case 'jsx-text': return `${at}: the text ${JSON.stringify(m.original.slice(0, 40))} can be blanked. Assert the text the user sees.`
136
+ case 'call': return `${at}: removing \`${m.original.slice(0, 60)}\` survives. Assert the effect of that call.`
137
+ case 'return': return `${at}: returning undefined survives. Assert what this function's result shows.`
138
+ default: return `${at}: ${m.op} survives`
139
+ }
140
+ }
@@ -0,0 +1,89 @@
1
+ // Plugin `check` hooks (research 13 §2.3 `CheckHook`, research 26 §2.5): a plugin's manifest may carry
2
+ // `check: { include: string[], run(ctx) }`. `weft check` runs it once over the matching files, never on HMR. The
3
+ // hook reads through `ctx` (the files, a JSX view, other project files) and returns diagnostics in its namespace.
4
+
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { entry } from './catalog.mjs'
8
+ import { diagnostic, positions } from './diag.mjs'
9
+ import { discover, matcher } from './files.mjs'
10
+ import { importOf, scanJsx } from './jsx-scan.mjs'
11
+
12
+ /**
13
+ * @param {string} root
14
+ * @param {{ contributions: { name: string, namespace: string, manifest: any }[] }} loaded
15
+ * @param {{ files: { rel: string, text: string }[], exclude?: string[] }} options the files `weft check` compiled
16
+ */
17
+ export async function pluginChecks(root, loaded, { files, exclude = [] }) {
18
+ const out = []
19
+ const texts = new Map(files.map(f => [f.rel, f.text]))
20
+ const read = rel => {
21
+ if (!texts.has(rel)) texts.set(rel, existsSync(join(root, rel)) ? readFileSync(join(root, rel), 'utf8') : '')
22
+ return texts.get(rel)
23
+ }
24
+ const scans = new Map()
25
+ const jsx = rel => {
26
+ if (!scans.has(rel)) {
27
+ try {
28
+ scans.set(rel, scanJsx(read(rel), rel))
29
+ } catch {
30
+ scans.set(rel, { imports: [], elements: [], functions: new Map() })
31
+ }
32
+ }
33
+ return scans.get(rel)
34
+ }
35
+
36
+ for (const c of loaded.contributions) {
37
+ const hook = c.manifest.check
38
+ if (!hook || typeof hook.run !== 'function') continue
39
+ const include = hook.include ?? ['**/*.{tsx,jsx}']
40
+ const wanted = matcher(include)
41
+ /** @type {Set<string>} */
42
+ const chosen = new Set([...files.map(f => f.rel).filter(wanted), ...discover(root, { include, exclude })])
43
+ const rels = [...chosen].sort()
44
+ const declared = new Map((c.manifest.diagnostics ?? []).map(d => [d.code, d]))
45
+ const ctx = Object.freeze({
46
+ root,
47
+ files: rels,
48
+ read,
49
+ jsx,
50
+ importOf,
51
+ slots: loaded.slots ?? {},
52
+ exists: rel => existsSync(join(root, rel)),
53
+ bytes: rel => (existsSync(join(root, rel)) ? new Uint8Array(readFileSync(join(root, rel))) : undefined),
54
+ })
55
+ let found
56
+ try {
57
+ found = (await hook.run(ctx)) ?? []
58
+ } catch (error) {
59
+ out.push(diagnostic({ code: 'P119', owner: c.name, file: 'weft.config.ts', why: `\`${c.name}\`'s check hook threw: ${String(error?.message ?? error).split('\n')[0]}` }))
60
+ continue
61
+ }
62
+ for (const d of found) {
63
+ if (!d.code.startsWith(c.namespace)) {
64
+ out.push(diagnostic({ code: 'P111', file: 'weft.config.ts', why: `\`${c.name}\`'s check reported \`${d.code}\`, outside its namespace \`${c.namespace}\`.` }))
65
+ continue
66
+ }
67
+ const text = read(d.file)
68
+ const pos = positions(text)
69
+ const [start, end] = [d.start ?? 0, d.end ?? d.start ?? 0]
70
+ const meta = declared.get(d.code)
71
+ out.push(diagnostic({
72
+ code: d.code,
73
+ owner: c.name,
74
+ severity: d.severity ?? meta?.severity ?? 'error',
75
+ file: d.file,
76
+ span: pos.span(start, end),
77
+ offset: [start, end],
78
+ why: d.why,
79
+ help: d.help ?? (entry(d.code) ? undefined : meta?.fix), // a catalog code keeps the catalog's fix text
80
+ fix: d.fix && {
81
+ description: d.fix.description,
82
+ safe: d.fix.safe === true,
83
+ edits: d.fix.edits.map(e => ({ file: e.file ?? d.file, span: positions(read(e.file ?? d.file)).span(e.start, e.end), offset: [e.start, e.end], text: e.text })),
84
+ },
85
+ }))
86
+ }
87
+ }
88
+ return out
89
+ }
@@ -0,0 +1,172 @@
1
+ // Plugins (PLAN §7c, research 13 §2.6–2.8): run each plugin's pure `contribute()`, check what it declares, generate
2
+ // `.weft/slots/<id>.ts` + `.weft/slots.d.ts` + `.weft/tokens/<ns>.css`, and serve `weft plugins list|explain|sync`.
3
+
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { isAbsolute, join, relative, resolve } from 'node:path'
6
+ import { loadConfig } from './load-config.mjs'
7
+ import { slotDef } from './slots.mjs'
8
+
9
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
10
+ const header = '// Generated from weft.config.ts. Do not edit: change weft.config.ts and run `weft plugins sync`.'
11
+
12
+ const posix = p => p.split('\\').join('/')
13
+ const slotFile = id => `.weft/slots/${id}.ts`
14
+
15
+ /** The ProviderRef a bound plugin declared for `slot` (and `key`). */
16
+ function providerRef(loaded, plugin, slot, key = '') {
17
+ const c = loaded.contributions.find(x => x.name === plugin)
18
+ return c?.manifest.provides?.find(p => p.slot.id === slot && (p.key ?? '') === key) ?? null
19
+ }
20
+
21
+ /** `module` relative to `.weft/slots/` when the plugin gave a relative path from the project root. */
22
+ function specifier(root, module) {
23
+ if (!module.startsWith('.') && !isAbsolute(module)) return module
24
+ const abs = isAbsolute(module) ? module : resolve(root, module)
25
+ const rel = posix(relative(join(root, '.weft', 'slots'), abs))
26
+ return rel.startsWith('.') ? rel : `./${rel}`
27
+ }
28
+
29
+ function loader(root, ref) {
30
+ const side = ref.sides.client ?? ref.sides[Object.keys(ref.sides).sort()[0]]
31
+ if (!side) return { comment: 'no sides', code: `() => Promise.reject(new Error('provider declares no module'))` }
32
+ const call = side.options === undefined ? `m[${JSON.stringify(side.export)}]` : `m[${JSON.stringify(side.export)}](${JSON.stringify(side.options)})`
33
+ return { comment: Object.keys(ref.sides).sort().join(', '), code: `() => import(${JSON.stringify(specifier(root, side.module))}).then(m => ${call})` }
34
+ }
35
+
36
+ const unbound = id =>
37
+ `export const provider = null\nexport const load = (): never => {\n throw Object.assign(new Error(${JSON.stringify(`P101 ${id}: nothing provides this slot. Add a plugin that provides it to \`plugins\` in weft.config.ts.`)}), { code: 'P101' })\n}\n`
38
+ const builtin = (id, name) =>
39
+ `export const provider = ${JSON.stringify(name)}\nexport const load = (): never => {\n throw new Error(${JSON.stringify(`${id}: built in, provided by weft (${name}).`)})\n}\n`
40
+
41
+ /** File contents for every slot module, the slots.d.ts and token CSS, keyed by path relative to the root. */
42
+ export function generated(root, loaded) {
43
+ const files = {}
44
+ const typed = []
45
+ for (const [id, binding] of Object.entries(loaded.slots).sort(([a], [b]) => (a < b ? -1 : 1))) {
46
+ const def = slotDef(id) ?? { version: 1, cardinality: typeof binding === 'object' && binding ? 'keyed' : 'one' }
47
+ let body
48
+ if (def.cardinality === 'one') {
49
+ const ref = binding && !binding.startsWith('weft:default') ? providerRef(loaded, binding, id) : null
50
+ if (ref) {
51
+ const l = loader(root, ref)
52
+ const plugin = loaded.contributions.find(x => x.name === binding)
53
+ body = `// provider: ${binding}@${plugin.version} · implements ${id}@${ref.versions.join(',')} · sides: ${l.comment}\nexport const provider = ${JSON.stringify({ plugin: binding, version: plugin.version, interface: Math.max(...ref.versions) })} as const\nexport const load = ${l.code}\n`
54
+ typed.push(id)
55
+ } else if (binding?.startsWith('weft:default')) body = builtin(id, binding)
56
+ else body = unbound(id)
57
+ } else {
58
+ const lines = []
59
+ const providers = {}
60
+ for (const [key, name] of Object.entries(binding ?? {})) {
61
+ const ref = name && !name.startsWith('weft:default') ? providerRef(loaded, name, id, key) : null
62
+ providers[key] = ref ? name : name ?? null
63
+ if (ref) lines.push(` ${JSON.stringify(key)}: ${loader(root, ref).code},`)
64
+ }
65
+ body = `export const providers = ${JSON.stringify(providers)} as const\nconst loaders: Record<string, () => Promise<unknown>> = {\n${lines.join('\n')}${lines.length ? '\n' : ''}}\nexport const load = (key: string): Promise<unknown> => {\n const l = loaders[key]\n if (l) return l()\n throw Object.assign(new Error(\`P101 ${id} key \${key}: \${providers[key as keyof typeof providers] ? 'built in, provided by weft' : 'nothing provides it'}.\`), { code: 'P101' })\n}\n`
66
+ if (lines.length) typed.push(id)
67
+ }
68
+ files[slotFile(id)] = `${header}\n${body}`
69
+ }
70
+ // Build-side providers, for @weft-js/core/vite to import in Node (it can't load the generated .ts): research 26 §3.1.
71
+ for (const [id, binding] of Object.entries(loaded.slots)) {
72
+ if (binding && typeof binding === 'object') {
73
+ const keys = {}
74
+ for (const [key, name] of Object.entries(binding)) {
75
+ const side = name && !name.startsWith('weft:default') ? providerRef(loaded, name, id, key)?.sides.build : null
76
+ if (side) keys[key] = { plugin: name, module: side.module, export: side.export, options: side.options }
77
+ }
78
+ if (Object.keys(keys).length) files[`.weft/slots/${id}.build.json`] = `${JSON.stringify({ keys }, null, 2)}\n`
79
+ continue
80
+ }
81
+ if (typeof binding !== 'string' || binding.startsWith('weft:default')) continue
82
+ const side = providerRef(loaded, binding, id)?.sides.build
83
+ if (!side) continue
84
+ const plugin = loaded.contributions.find(x => x.name === binding)
85
+ const module = side.module.startsWith('.') ? posix(resolve(root, side.module)) : side.module
86
+ files[`.weft/slots/${id}.build.json`] = `${JSON.stringify({ plugin: binding, version: plugin.version, module, export: side.export, ...(side.options === undefined ? {} : { options: side.options }) }, null, 2)}\n`
87
+ }
88
+ files['.weft/slots.d.ts'] = `${header}\n// Only bound slots are declared: importing an unbound one is a type error (P101).\n${typed
89
+ .map(id => `declare module 'weft:slot/${id}' {\n export const provider: unknown\n export const providers: Record<string, unknown>\n export const load: (key?: string) => Promise<any>\n}\n`)
90
+ .join('')}`
91
+ for (const c of loaded.contributions) {
92
+ const t = c.manifest.tokens
93
+ if (t?.css) files[`.weft/tokens/${t.namespace}.css`] = `/* Generated from weft.config.ts (${c.name}). Do not edit. */\n${t.css}`
94
+ if (t?.alias) files[`.weft/tokens/${t.namespace}.alias.json`] = `${JSON.stringify(t.alias, null, 2)}\n`
95
+ }
96
+ return files
97
+ }
98
+
99
+ /** Write generated files whose content changed; remove stale slot/token files. Returns written paths. */
100
+ export function sync(root, loaded) {
101
+ const files = generated(root, loaded)
102
+ const written = []
103
+ for (const [rel, text] of Object.entries(files)) {
104
+ const abs = join(root, rel)
105
+ if (existsSync(abs) && readFileSync(abs, 'utf8') === text) continue
106
+ mkdirSync(join(abs, '..'), { recursive: true })
107
+ writeFileSync(abs, text)
108
+ written.push(rel)
109
+ }
110
+ for (const dir of ['.weft/slots', '.weft/tokens']) {
111
+ if (!existsSync(join(root, dir))) continue
112
+ for (const f of readdirSync(join(root, dir))) {
113
+ if (!(`${dir}/${f}` in files)) rmSync(join(root, dir, f))
114
+ }
115
+ }
116
+ return written
117
+ }
118
+
119
+ /** `weft plugins list --json`. */
120
+ export async function list(root) {
121
+ root = resolve(root)
122
+ const loaded = await loadConfig(root)
123
+ const unique = (loaded.config.plugins ?? []).filter((p, i, all) => all.findIndex(q => q.name === p.name) === i)
124
+ const plugins = [...unique].sort(byName).map(p => {
125
+ const c = loaded.contributions.find(x => x.name === p.name)
126
+ const provides = (p.provides ?? []).map(item => {
127
+ const { slot, key } = typeof item === 'string' ? { slot: item } : item
128
+ const binding = loaded.slots[slot]
129
+ const bound = key ? binding?.[key] === p.name : binding === p.name
130
+ return { slot, ...(key ? { key } : {}), interface: providerRef(loaded, p.name, slot, key)?.versions ?? null, bound }
131
+ })
132
+ return {
133
+ name: p.name,
134
+ version: p.version ?? null,
135
+ provides,
136
+ permissions: c?.permissions ?? {},
137
+ namespace: c?.namespace ?? null,
138
+ diagnostics: (c?.manifest.diagnostics ?? []).map(d => d.code),
139
+ tokens: c?.manifest.tokens?.namespace ?? null,
140
+ catalog: (c?.manifest.catalog ?? []).map(e => e.name),
141
+ agents: c?.manifest.agents?.title ?? null,
142
+ }
143
+ })
144
+ return { ok: !loaded.diagnostics.some(d => d.severity === 'error'), plugins, slots: loaded.slots, diagnostics: loaded.diagnostics }
145
+ }
146
+
147
+ /** `weft plugins explain <slot> --json`: config entry → plugin → module → generated file. */
148
+ export async function explain(root, id) {
149
+ root = resolve(root)
150
+ const loaded = await loadConfig(root)
151
+ if (!id || !(id in loaded.slots)) throw Object.assign(new Error(`unknown slot ${id ?? '(none)'}; known: ${Object.keys(loaded.slots).join(', ')}`), { code: 'E_USAGE' })
152
+ const binding = loaded.slots[id]
153
+ const def = slotDef(id) ?? null
154
+ const chain = key => {
155
+ const name = key === undefined ? binding : binding?.[key]
156
+ if (!name) return { provider: null, why: 'nothing provides it (P101 for consumers)' }
157
+ if (name.startsWith('weft:default')) return { provider: name, why: 'built in, provided by weft' }
158
+ const ref = providerRef(loaded, name, id, key ?? '')
159
+ const side = ref && (ref.sides.client ?? ref.sides[Object.keys(ref.sides).sort()[0]])
160
+ return {
161
+ config: loaded.file ? posix(relative(root, loaded.file)) : null,
162
+ chosenBy: loaded.config.slots?.[id] ? 'slots' : 'only provider',
163
+ plugin: name,
164
+ version: loaded.contributions.find(c => c.name === name)?.version ?? null,
165
+ interface: ref?.versions ?? null,
166
+ sides: ref ? Object.keys(ref.sides).sort() : [],
167
+ module: side ? { module: side.module, export: side.export, ...(side.options === undefined ? {} : { options: side.options }) } : null,
168
+ }
169
+ }
170
+ const bindings = def?.cardinality === 'keyed' || (binding && typeof binding === 'object') ? Object.fromEntries(Object.keys(binding ?? {}).sort().map(k => [k, chain(k)])) : chain()
171
+ return { slot: id, version: def?.version ?? null, cardinality: def?.cardinality ?? 'one', sides: def?.sides ?? [], binding: bindings, file: slotFile(id), import: `weft:slot/${id}` }
172
+ }