@weft-js/cli 0.0.0-stage → 0.1.0
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/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +333 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +479 -0
- package/src/check.mjs +328 -0
- package/src/cli.mjs +332 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +131 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +274 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +292 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
package/src/docs.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Docs for agents: topic files in ../docs, plus pages generated from the installed weft (diagnostics, commands,
|
|
2
|
+
// AGENTS.md). One source for `weft docs`, the MCP `docs` tool and AGENTS.md, so they can't disagree.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { catalog, entry, probe } from './catalog.mjs'
|
|
8
|
+
import { loadConfig } from './load-config.mjs'
|
|
9
|
+
import { tasks } from './tasks.mjs'
|
|
10
|
+
import { probeTests } from './tests.mjs'
|
|
11
|
+
import { exportsOf, loadCompiler, resolveWeft } from './weft.mjs'
|
|
12
|
+
|
|
13
|
+
const docsDir = fileURLToPath(new URL('../docs/', import.meta.url))
|
|
14
|
+
const fileTopics = Object.fromEntries(readdirSync(docsDir).filter(f => f.endsWith('.md')).map(f => [f.slice(0, -3), f]))
|
|
15
|
+
|
|
16
|
+
/** What `weft docs` can show, with the line that says what each topic covers. */
|
|
17
|
+
export const topics = {
|
|
18
|
+
rules: 'The nine rules: boxes, derived, live JSX and props, run-once bodies (start here)',
|
|
19
|
+
state: 'Every reactive function (state, derived, watch, untrack, flush, batch, context) and defineState with persist, sync and history; S001, S002, S008, S009, S012, S013',
|
|
20
|
+
props: 'How props stay live; W001, W005, W006',
|
|
21
|
+
lists: 'Keyed lists, conditionals and stable items; W010, W011, W013, W014',
|
|
22
|
+
async: 'resource, <Boundary pending error>, @weft-js/core/ssr and hydrate; W012',
|
|
23
|
+
start: 'full stack: routes, load, query, mutation, the database, auth, realtime, runtimes; Q001–Q016, A001–A004, C001–C002, F001–F003, R001–R023',
|
|
24
|
+
rust: 'Rust bodies: native() addons with a WASM fallback, remote() sidecars, weft add rust, impl, the weft crate, build.rs, Ctx, touch, weft dev reload, deployment; R101–R109',
|
|
25
|
+
plugins: 'Build a plugin: slots, server extensions, check hooks, docs, `weft add`, conformance. Walks through how @weft-js/pay is built',
|
|
26
|
+
testing: '@weft-js/core/test: render, screen, user, flush, clock, touches and toTouchAtMost(n, { runs }); T001–T009',
|
|
27
|
+
browser: '@weft-js/core/browser: online, visible, lifecycle, media, on, interval; the net/page fakes; W050, W051, W054, W056, T012',
|
|
28
|
+
'ui-state': 'Hover, press and focus as state: interaction(), the ui prop, sharing across tabs and clients, realtime({ share }); U001–U005',
|
|
29
|
+
surface: '@weft-js/core/surface: canvas and WebGL as layout children: defineSurface, <Surface>, s.draw, s.frame, s.text, the clock; X001–X004, X009–X012',
|
|
30
|
+
verify: 'Check your work: drive the running app (weft browser / __weft__.agent: snapshot with the live graph, act, assert, clock, network, record) and the test gate; T010, T011, T020',
|
|
31
|
+
icons: '@weft-js/warp-icons: per-icon exports, iconSet, the three tiers, icons({ tier }), sizes',
|
|
32
|
+
fonts: 'Fonts with @weft-js/text: text({ fonts }), what the build makes, using a face in CSS, budgets; X020, X021, X022, X023',
|
|
33
|
+
meta: 'Page metadata and share cards: route `meta`, what is automatic, `site.url`, `.og.tsx`, `weft routes --meta`; H001–H008, H011–H016',
|
|
34
|
+
config: 'weft.config.ts: scope, tasks, environments, reserved plugins/slots; P-codes',
|
|
35
|
+
design: 'The design language: Stack, Row, Heading, Text, Link, the closed vocabulary, `weft fix`, `weft tokens`; D001–D007, D011, D030–D039',
|
|
36
|
+
style: 'Style groups (@weft-js/core/style): group(), axes, parts, accents, weft style find/explain, .weft/styles.json; D020–D025, W060',
|
|
37
|
+
diagnostics: 'Every W-, T-, P-, H- and D-code, and which ones the installed weft reports',
|
|
38
|
+
commands: "This project's commands and tasks (from `weft tasks`)",
|
|
39
|
+
agents: 'The generated AGENTS.md',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const probes = new Map()
|
|
43
|
+
|
|
44
|
+
/** What the installed weft offers: version, exports, and which catalog codes its compiler reports. */
|
|
45
|
+
export function installed(root) {
|
|
46
|
+
const weft = resolveWeft(root)
|
|
47
|
+
if (!probes.has(weft.compilerPath)) probes.set(weft.compilerPath, { ...probe(loadCompiler(weft)), ...probeTests(catalog) })
|
|
48
|
+
return { weft, exports: exportsOf(weft), status: probes.get(weft.compilerPath) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function status(code, info) {
|
|
52
|
+
const e = entry(code)
|
|
53
|
+
const st = info.status[e.code]
|
|
54
|
+
if (e.by === 'compiler') return st?.implemented ? `reported${st.autofix ? ', safe autofix' : st.suggestion ? ', suggested edit' : ''}` : 'not reported by this weft yet'
|
|
55
|
+
if (e.by === 'test') return 'reported by @weft-js/core/test after each test'
|
|
56
|
+
if (e.by === 'gate') return 'reported by `weft test --gate` on a diff'
|
|
57
|
+
if (e.code.startsWith('T')) return st?.implemented ? `reported by weft check on test files${st.autofix ? ', safe autofix' : st.suggestion ? ', suggested edit' : ''}` : 'not reported by this weft yet'
|
|
58
|
+
if (e.by === 'start') return 'reported by @weft-js/start when it serves or renders the route, and by weft check and `weft routes --meta`'
|
|
59
|
+
if (e.by === 'cli' && e.code.startsWith('H')) return 'reported by weft check and by `weft routes --meta`, per route, on what the routes produce'
|
|
60
|
+
if (e.by === 'cli') return e.proposed ? 'reported by weft check (proposed code, research 17)' : 'reported by weft check'
|
|
61
|
+
if (e.by === 'runtime') return 'reported in dev by the runtime (a console warning)'
|
|
62
|
+
if (e.by === 'plugin') return `reported by weft check when \`${e.owner}\` is in \`plugins\``
|
|
63
|
+
return 'reserved'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function codePage(code, info) {
|
|
67
|
+
const e = entry(code)
|
|
68
|
+
const lines = [`# ${code} (${e.severity})`, '', e.catches + '.', '', `**Fix:** ${e.fix}.`, '', `**Status in weft@${info.weft.version}:** ${status(code, info)}.`]
|
|
69
|
+
if (e.example) lines.push('', 'Triggers it:', '', '```tsx', e.example, '```')
|
|
70
|
+
if (code.startsWith('T')) lines.push('', 'A test that needs it on purpose keeps it with a reason: `// weft-allow ' + code + ': <why>` in the file header (whole file) or on the line above.')
|
|
71
|
+
const topic = Object.entries(topics).find(([, d]) => d.includes(code)) ?? (code.startsWith('T') ? ['testing'] : undefined)
|
|
72
|
+
if (topic) lines.push('', `More: \`weft docs ${topic[0]}\`.`)
|
|
73
|
+
return lines.join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const contributed = async root => (await loadConfig(root)).contributions
|
|
77
|
+
|
|
78
|
+
/** What this app's plugins add to the docs (`docs` and `diagnostics` in their manifests). */
|
|
79
|
+
const pluginDocs = async root => (await contributed(root)).flatMap(c => (c.manifest.docs ? [c.manifest.docs] : []))
|
|
80
|
+
const pluginCodes = async root => (await contributed(root)).flatMap(c => (c.manifest.diagnostics ?? []).map(d => ({ ...d, plugin: c.name })))
|
|
81
|
+
|
|
82
|
+
function diagnosticsPage(info, own = []) {
|
|
83
|
+
const rows = [...catalog.map(e => `| ${e.code} | ${e.severity} | ${e.catches} | ${e.fix} | ${status(e.code, info)} |`), ...own.map(d => `| ${d.code} | ${d.severity ?? 'error'} | ${d.catches} | ${d.fix} | from \`${d.plugin}\` |`)]
|
|
84
|
+
return [`# Diagnostics (weft@${info.weft.version})`, '', '| Code | Severity | Catches | Fix | Status |', '|---|---|---|---|---|', ...rows].join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function commandsPage(list, { planned: showPlanned = true } = {}) {
|
|
88
|
+
const rows = list.tasks
|
|
89
|
+
.filter(t => t.available)
|
|
90
|
+
.map(t => `| \`${t.command}\` | ${t.description ?? '(no description: P004)'} | ${t.sideEffects?.length ? t.sideEffects.join(', ') : 'none'} |`)
|
|
91
|
+
const planned = list.tasks.filter(t => !t.available).map(t => `\`${t.name}\``)
|
|
92
|
+
return ['| Command | Does | Side effects |', '|---|---|---|', ...rows, '', showPlanned && planned.length ? `Planned, not shipped yet: ${planned.join(', ')}.` : ''].join('\n').trim()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} root
|
|
97
|
+
* @param {string} [topic] a topic name or a diagnostic code; omitted: the index
|
|
98
|
+
* @returns {Promise<{ topic: string, text: string, topics?: Record<string, string> }>}
|
|
99
|
+
*/
|
|
100
|
+
export async function docs(root, topic) {
|
|
101
|
+
root = resolve(root)
|
|
102
|
+
const key = (topic ?? 'index').trim()
|
|
103
|
+
if (key === 'index') {
|
|
104
|
+
const all = { ...topics, ...Object.fromEntries((await pluginDocs(root)).map(d => [d.topic, d.summary])) }
|
|
105
|
+
const text = ['# weft docs', '', ...Object.entries(all).map(([t, d]) => `- \`${t}\`: ${d}`), '', 'Any diagnostic code works too: `W014`, `T003`, `P004`, `D025`, `Q001`.'].join('\n')
|
|
106
|
+
return { topic: 'index', text, topics: all }
|
|
107
|
+
}
|
|
108
|
+
const info = installed(root)
|
|
109
|
+
if (/^[A-Z]{1,5}\d{3}$/i.test(key)) {
|
|
110
|
+
const code = key.toUpperCase()
|
|
111
|
+
const own = !entry(code) && (await pluginCodes(root)).find(d => d.code === code)
|
|
112
|
+
if (own) return { topic: code, text: `# ${code} (${own.severity ?? 'error'})\n\n${own.catches}.\n\n**Fix:** ${own.fix}.\n\nReported by weft check, from \`${own.plugin}\`.` }
|
|
113
|
+
if (!entry(code)) throw Object.assign(new Error(`unknown diagnostic code ${code}`), { code: 'E_UNKNOWN_TOPIC' })
|
|
114
|
+
return { topic: code, text: codePage(code, info) }
|
|
115
|
+
}
|
|
116
|
+
if (key === 'diagnostics') return { topic: key, text: diagnosticsPage(info, await pluginCodes(root)) }
|
|
117
|
+
if (key === 'commands') return { topic: key, text: `# Commands\n\n${commandsPage(await tasks(root))}` }
|
|
118
|
+
if (key === 'agents') {
|
|
119
|
+
const { agentsMd } = await import('./agents-md.mjs')
|
|
120
|
+
return { topic: key, text: (await agentsMd(root)).text }
|
|
121
|
+
}
|
|
122
|
+
const page = (await pluginDocs(root)).find(d => d.topic === key)
|
|
123
|
+
if (page) return { topic: key, text: readFileSync(page.file, 'utf8').trim() }
|
|
124
|
+
if (fileTopics[key]) {
|
|
125
|
+
const text = readFileSync(resolve(docsDir, fileTopics[key]), 'utf8').trim()
|
|
126
|
+
return { topic: key, text: key === 'rules' ? `# The rules\n\n${text}` : text }
|
|
127
|
+
}
|
|
128
|
+
throw Object.assign(new Error(`unknown docs topic "${key}"; topics: ${Object.keys(topics).join(', ')}, or a code like W014`), { code: 'E_UNKNOWN_TOPIC' })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const readDoc = name => readFileSync(resolve(docsDir, `${name}.md`), 'utf8').trim()
|
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// `weft doctor`: what this install compiles with, and whether Rust is around. Everything here is information: Rust is
|
|
2
|
+
// optional, the WASM compiler is a full compiler, and the addon only adds speed. Nothing it reports fails the command.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { loadCompiler, resolveWeft } from './weft.mjs'
|
|
6
|
+
|
|
7
|
+
/** One line for `weft check --verbose` and `weft doctor`. */
|
|
8
|
+
export function compilerLine(backend) {
|
|
9
|
+
if (!backend) return 'compiler: this weft does not say which build it uses'
|
|
10
|
+
if (backend.kind === 'native') return `compiler: native addon${backend.package ? ` (${backend.package})` : ` (${backend.file})`}`
|
|
11
|
+
const offer = backend.wanted?.length
|
|
12
|
+
? `. Optional, and faster: ${backend.wanted.join(' or ')}, which a normal install adds by itself`
|
|
13
|
+
: '. No prebuilt addon for this platform, and none is needed'
|
|
14
|
+
return `compiler: WASM, same output as the addon${offer}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function doctor(root) {
|
|
18
|
+
const weft = resolveWeft(root)
|
|
19
|
+
const { backend } = loadCompiler(weft)
|
|
20
|
+
const cargo = spawnSync('cargo', ['--version'], { encoding: 'utf8' })
|
|
21
|
+
const version = cargo.status === 0 ? cargo.stdout.trim() : null
|
|
22
|
+
const runtime = globalThis.Deno ? `Deno ${Deno.version.deno}` : globalThis.Bun ? `Bun ${Bun.version}` : `Node ${process.version}`
|
|
23
|
+
return { ok: true, weft: weft.version, runtime, compiler: backend ?? null, rust: { cargo: version, required: false } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const formatDoctor = r => [
|
|
27
|
+
`weft ${r.weft} on ${r.runtime}`,
|
|
28
|
+
compilerLine(r.compiler),
|
|
29
|
+
`rust: ${r.rust.cargo ?? 'no cargo on PATH'}. Not needed for weft; only \`weft add rust\` sidecars and native() use it`,
|
|
30
|
+
].join('\n')
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Project file discovery with a small glob (`**`, `*`, `?`, `{a,b}`), relative to the project root.
|
|
2
|
+
|
|
3
|
+
import { readdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const skipDirs = new Set(['public', 'node_modules', '.git', '.weft', '.output', 'dist', 'coverage', 'target'])
|
|
7
|
+
|
|
8
|
+
export function globToRegExp(glob) {
|
|
9
|
+
let re = ''
|
|
10
|
+
for (let i = 0; i < glob.length; i++) {
|
|
11
|
+
const c = glob[i]
|
|
12
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
13
|
+
re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*'
|
|
14
|
+
i += glob[i + 2] === '/' ? 2 : 1
|
|
15
|
+
} else if (c === '*') re += '[^/]*'
|
|
16
|
+
else if (c === '?') re += '[^/]'
|
|
17
|
+
else if (c === '{') {
|
|
18
|
+
const end = glob.indexOf('}', i)
|
|
19
|
+
re += `(?:${glob.slice(i + 1, end).split(',').map(s => s.replace(/[.+^$()|[\]\\]/g, '\\$&')).join('|')})`
|
|
20
|
+
i = end
|
|
21
|
+
} else re += c.replace(/[.+^$()|[\]\\]/g, '\\$&')
|
|
22
|
+
}
|
|
23
|
+
return new RegExp(`^${re}$`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const matcher = globs => {
|
|
27
|
+
const res = (globs ?? []).map(globToRegExp)
|
|
28
|
+
return path => res.some(re => re.test(path))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Relative posix paths under `root` matching `include` and none of `exclude`. Sorted, so output is stable. */
|
|
32
|
+
export function discover(root, { include = ['**/*.{tsx,jsx}'], exclude = [] } = {}) {
|
|
33
|
+
const inc = matcher(include)
|
|
34
|
+
const exc = matcher(exclude)
|
|
35
|
+
/** @type {string[]} */
|
|
36
|
+
const out = []
|
|
37
|
+
const walk = rel => {
|
|
38
|
+
for (const ent of readdirSync(join(root, rel), { withFileTypes: true })) {
|
|
39
|
+
const path = rel ? `${rel}/${ent.name}` : ent.name
|
|
40
|
+
if (ent.isDirectory()) {
|
|
41
|
+
if (!skipDirs.has(ent.name) && !exc(path) && !exc(`${path}/`)) walk(path)
|
|
42
|
+
} else if (inc(path) && !exc(path)) out.push(path)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
walk('')
|
|
46
|
+
return out.sort()
|
|
47
|
+
}
|
package/src/fix.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// `weft fix`: apply every autofix `weft check` marks safe, the same edits an agent reads in `--json`. Unsafe fixes are
|
|
2
|
+
// suggestions and stay with the agent. Edits are applied last first; one that overlaps an earlier one waits for the
|
|
3
|
+
// next pass, and passes repeat until nothing is left (a fix can only shrink what the next check reports).
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import { check } from './check.mjs'
|
|
8
|
+
|
|
9
|
+
const PASSES = 5
|
|
10
|
+
|
|
11
|
+
export async function fix(root, options = {}) {
|
|
12
|
+
root = resolve(root)
|
|
13
|
+
const applied = []
|
|
14
|
+
for (let pass = 0; pass < PASSES; pass++) {
|
|
15
|
+
const { diagnostics } = await check(root, options)
|
|
16
|
+
const byFile = new Map()
|
|
17
|
+
for (const d of diagnostics) {
|
|
18
|
+
if (!d.fix?.safe || !d.fix.edits?.length) continue
|
|
19
|
+
const file = d.fix.edits[0].file
|
|
20
|
+
byFile.set(file, [...(byFile.get(file) ?? []), d])
|
|
21
|
+
}
|
|
22
|
+
if (!byFile.size) break
|
|
23
|
+
let changed = 0
|
|
24
|
+
for (const [file, list] of byFile) {
|
|
25
|
+
const edits = []
|
|
26
|
+
for (const d of list) {
|
|
27
|
+
if (d.fix.edits.some(e => e.file !== file || edits.some(x => e.offset[0] < x.offset[1] && x.offset[0] < e.offset[1]))) continue
|
|
28
|
+
edits.push(...d.fix.edits)
|
|
29
|
+
applied.push({ code: d.code, file, span: d.span })
|
|
30
|
+
changed++
|
|
31
|
+
}
|
|
32
|
+
const abs = join(root, file)
|
|
33
|
+
let text = readFileSync(abs, 'utf8')
|
|
34
|
+
for (const e of edits.sort((a, b) => b.offset[0] - a.offset[0])) text = text.slice(0, e.offset[0]) + e.text + text.slice(e.offset[1])
|
|
35
|
+
writeFileSync(abs, text)
|
|
36
|
+
}
|
|
37
|
+
if (!changed) break
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, applied }
|
|
40
|
+
}
|
package/src/gate.mjs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// `weft test --gate`: a new or changed test has to prove it tests something (PLAN §7b, research 05 §5.1, research 22).
|
|
2
|
+
//
|
|
3
|
+
// 1. Fail-before (T010): each new or changed test fails against the code before the change, and passes after it.
|
|
4
|
+
// 2. Mutation on changed lines (T011): each new test kills at least one mutant of the changed source lines.
|
|
5
|
+
// Surviving mutants come back with what an assertion that kills them would check.
|
|
6
|
+
// 3. No silent weakening (T020): an existing test deleted, skipped or loosened needs `// weft-allow T020: <reason>`.
|
|
7
|
+
// Plus the static T-codes on the changed test files, with T005 (implementation queries) blocking for new tests.
|
|
8
|
+
//
|
|
9
|
+
// Tests run in scratch copies of the project (`.weft/gate/`), never in the working tree. With no source change
|
|
10
|
+
// (tests added for existing code) there is no "before" to fail on: fail-before is skipped and the mutants come from
|
|
11
|
+
// the source files the new tests import.
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from 'node:child_process'
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
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'
|
|
19
|
+
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'
|
|
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('/')
|
|
28
|
+
|
|
29
|
+
/** Changed files between `base` and the working tree, from git (paths relative to `root`). */
|
|
30
|
+
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)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const importRe = /(?:from\s*|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g
|
|
43
|
+
const typeOnly = /^\s*(?:import|export)\s+type\b[^;'"]*from\s*['"][^'"]+['"];?/gm
|
|
44
|
+
/** Runtime imports only: `import type` brings no code a test could run. */
|
|
45
|
+
function importsOf(abs, text) {
|
|
46
|
+
const out = []
|
|
47
|
+
for (const m of text.replace(typeOnly, '').matchAll(importRe)) {
|
|
48
|
+
const p = resolveImport(abs, m[1])
|
|
49
|
+
if (p) out.push(p)
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} root project root (the "after" state)
|
|
56
|
+
* @param {{
|
|
57
|
+
* base?: string, changes?: { rel: string, before: string | null, after: string | null }[],
|
|
58
|
+
* budgetSec?: number, maxMutants?: number, concurrency?: number, refactor?: boolean, mutation?: boolean,
|
|
59
|
+
* exclude?: (rel: string) => boolean, config?: string, log?: (line: string) => void,
|
|
60
|
+
* }} [options] `changes` replaces the git diff (the eval harness passes its own); `exclude` leaves paths out of the copies
|
|
61
|
+
*/
|
|
62
|
+
export async function gate(root, options = {}) {
|
|
63
|
+
const started = performance.now()
|
|
64
|
+
root = resolve(root)
|
|
65
|
+
const log = options.log ?? (() => {})
|
|
66
|
+
const { config } = await loadConfig(root).catch(() => ({ config: {} }))
|
|
67
|
+
const gateCfg = config.test?.gate ?? {}
|
|
68
|
+
const budgetSec = options.budgetSec ?? gateCfg.mutationBudgetSec ?? 60
|
|
69
|
+
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
|
+
}
|
|
125
|
+
|
|
126
|
+
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))
|
|
148
|
+
}
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
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)
|
|
159
|
+
}
|
|
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
|
+
|
|
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
|
+
}
|
|
256
|
+
return finish()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 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)
|
|
261
|
+
|
|
262
|
+
/** Human summary for stderr. */
|
|
263
|
+
export function formatGate(r) {
|
|
264
|
+
const lines = []
|
|
265
|
+
for (const t of r.tests) {
|
|
266
|
+
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}` : ''}`)
|
|
268
|
+
}
|
|
269
|
+
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}` : ''}`)
|
|
271
|
+
for (const s of r.survivors.slice(0, 8)) lines.push(` survived: ${s.suggestion}`)
|
|
272
|
+
for (const n of r.notes) lines.push(`note: ${n}`)
|
|
273
|
+
return lines.join('\n')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export { testCases }
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { agentsMd, writeAgentsMd } from './agents-md.mjs'
|
|
2
|
+
export { catalog, entry, probe } from './catalog.mjs'
|
|
3
|
+
export { check } from './check.mjs'
|
|
4
|
+
export { defineConfig } from './config.mjs'
|
|
5
|
+
export { docs, topics } from './docs.mjs'
|
|
6
|
+
export { loadConfig } from './load-config.mjs'
|
|
7
|
+
export { handle, serve, tools } from './mcp.mjs'
|
|
8
|
+
export { bindSlots, slotTable } from './slots.mjs'
|
|
9
|
+
export { tasks } from './tasks.mjs'
|