@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/docs.mjs ADDED
@@ -0,0 +1,132 @@
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
+ deploy: 'Deploy: which hosts can run the app, weft deploy hosts/init, the push workflow, Netlify, Vercel, Cloudflare, Fly, Render, CDN and Cloudinary; E001–E014',
26
+ plugins: 'Build a plugin: slots, server extensions, check hooks, docs, `weft add`, conformance. Walks through how @weft-js/pay is built',
27
+ testing: '@weft-js/core/test: render, screen, user, flush, clock, touches and toTouchAtMost(n, { runs }); T001–T009',
28
+ browser: '@weft-js/core/browser: online, visible, lifecycle, media, on, interval; the net/page fakes; W050, W051, W054, W056, T012',
29
+ 'ui-state': 'Hover, press and focus as state: interaction(), the ui prop, sharing across tabs and clients, realtime({ share }); U001–U005',
30
+ 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',
31
+ 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',
32
+ icons: '@weft-js/warp-icons: per-icon exports, iconSet, the three tiers, icons({ tier }), sizes',
33
+ fonts: 'Fonts with @weft-js/text: text({ fonts }), what the build makes, using a face in CSS, budgets; X020, X021, X022, X023',
34
+ meta: 'Page metadata and share cards: route `meta`, what is automatic, `site.url`, `.og.tsx`, `weft routes --meta`; H001–H008, H011–H016',
35
+ config: 'weft.config.ts: scope, tasks, environments, reserved plugins/slots; P-codes',
36
+ design: 'The design language: Stack, Row, Heading, Text, Link, the closed vocabulary, `weft fix`, `weft tokens`; D001–D007, D011, D030–D039',
37
+ style: 'Style groups (@weft-js/core/style): group(), axes, parts, accents, weft style find/explain, .weft/styles.json; D020–D025, W060',
38
+ diagnostics: 'Every W-, T-, P-, H- and D-code, and which ones the installed weft reports',
39
+ commands: "This project's commands and tasks (from `weft tasks`)",
40
+ agents: 'The generated AGENTS.md',
41
+ }
42
+
43
+ const probes = new Map()
44
+
45
+ /** What the installed weft offers: version, exports, and which catalog codes its compiler reports. */
46
+ export function installed(root) {
47
+ const weft = resolveWeft(root)
48
+ if (!probes.has(weft.compilerPath)) probes.set(weft.compilerPath, { ...probe(loadCompiler(weft)), ...probeTests(catalog) })
49
+ return { weft, exports: exportsOf(weft), status: probes.get(weft.compilerPath) }
50
+ }
51
+
52
+ function status(code, info) {
53
+ const e = entry(code)
54
+ const st = info.status[e.code]
55
+ if (e.by === 'compiler') return st?.implemented ? `reported${st.autofix ? ', safe autofix' : st.suggestion ? ', suggested edit' : ''}` : 'not reported by this weft yet'
56
+ if (e.by === 'test') return 'reported by @weft-js/core/test after each test'
57
+ if (e.by === 'gate') return 'reported by `weft test --gate` on a diff'
58
+ 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'
59
+ 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`'
60
+ 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'
61
+ if (e.by === 'cli') return e.proposed ? 'reported by weft check (proposed code, research 17)' : 'reported by weft check'
62
+ if (e.by === 'runtime') return 'reported in dev by the runtime (a console warning)'
63
+ if (e.by === 'plugin') return `reported by weft check when \`${e.owner}\` is in \`plugins\``
64
+ return 'reserved'
65
+ }
66
+
67
+ function codePage(code, info) {
68
+ const e = entry(code)
69
+ const lines = [`# ${code} (${e.severity})`, '', e.catches + '.', '', `**Fix:** ${e.fix}.`, '', `**Status in weft@${info.weft.version}:** ${status(code, info)}.`]
70
+ if (e.example) lines.push('', 'Triggers it:', '', '```tsx', e.example, '```')
71
+ 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.')
72
+ const topic = Object.entries(topics).find(([, d]) => d.includes(code)) ?? (code.startsWith('T') ? ['testing'] : undefined)
73
+ if (topic) lines.push('', `More: \`weft docs ${topic[0]}\`.`)
74
+ return lines.join('\n')
75
+ }
76
+
77
+ const contributed = async root => (await loadConfig(root)).contributions
78
+
79
+ /** What this app's plugins add to the docs (`docs` and `diagnostics` in their manifests). */
80
+ const pluginDocs = async root => (await contributed(root)).flatMap(c => (c.manifest.docs ? [c.manifest.docs] : []))
81
+ const pluginCodes = async root => (await contributed(root)).flatMap(c => (c.manifest.diagnostics ?? []).map(d => ({ ...d, plugin: c.name })))
82
+
83
+ function diagnosticsPage(info, own = []) {
84
+ 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}\` |`)]
85
+ return [`# Diagnostics (weft@${info.weft.version})`, '', '| Code | Severity | Catches | Fix | Status |', '|---|---|---|---|---|', ...rows].join('\n')
86
+ }
87
+
88
+ export function commandsPage(list, { planned: showPlanned = true } = {}) {
89
+ const rows = list.tasks
90
+ .filter(t => t.available)
91
+ .map(t => `| \`${t.command}\` | ${t.description ?? '(no description: P004)'} | ${t.sideEffects?.length ? t.sideEffects.join(', ') : 'none'} |`)
92
+ const planned = list.tasks.filter(t => !t.available).map(t => `\`${t.name}\``)
93
+ return ['| Command | Does | Side effects |', '|---|---|---|', ...rows, '', showPlanned && planned.length ? `Planned, not shipped yet: ${planned.join(', ')}.` : ''].join('\n').trim()
94
+ }
95
+
96
+ /**
97
+ * @param {string} root
98
+ * @param {string} [topic] a topic name or a diagnostic code; omitted: the index
99
+ * @returns {Promise<{ topic: string, text: string, topics?: Record<string, string> }>}
100
+ */
101
+ export async function docs(root, topic) {
102
+ root = resolve(root)
103
+ const key = (topic ?? 'index').trim()
104
+ if (key === 'index') {
105
+ const all = { ...topics, ...Object.fromEntries((await pluginDocs(root)).map(d => [d.topic, d.summary])) }
106
+ const text = ['# weft docs', '', ...Object.entries(all).map(([t, d]) => `- \`${t}\`: ${d}`), '', 'Any diagnostic code works too: `W014`, `T003`, `P004`, `D025`, `Q001`.'].join('\n')
107
+ return { topic: 'index', text, topics: all }
108
+ }
109
+ const info = installed(root)
110
+ if (/^[A-Z]{1,5}\d{3}$/i.test(key)) {
111
+ const code = key.toUpperCase()
112
+ const own = !entry(code) && (await pluginCodes(root)).find(d => d.code === code)
113
+ 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}\`.` }
114
+ if (!entry(code)) throw Object.assign(new Error(`unknown diagnostic code ${code}`), { code: 'E_UNKNOWN_TOPIC' })
115
+ return { topic: code, text: codePage(code, info) }
116
+ }
117
+ if (key === 'diagnostics') return { topic: key, text: diagnosticsPage(info, await pluginCodes(root)) }
118
+ if (key === 'commands') return { topic: key, text: `# Commands\n\n${commandsPage(await tasks(root))}` }
119
+ if (key === 'agents') {
120
+ const { agentsMd } = await import('./agents-md.mjs')
121
+ return { topic: key, text: (await agentsMd(root)).text }
122
+ }
123
+ const page = (await pluginDocs(root)).find(d => d.topic === key)
124
+ if (page) return { topic: key, text: readFileSync(page.file, 'utf8').trim() }
125
+ if (fileTopics[key]) {
126
+ const text = readFileSync(resolve(docsDir, fileTopics[key]), 'utf8').trim()
127
+ return { topic: key, text: key === 'rules' ? `# The rules\n\n${text}` : text }
128
+ }
129
+ throw Object.assign(new Error(`unknown docs topic "${key}"; topics: ${Object.keys(topics).join(', ')}, or a code like W014`), { code: 'E_UNKNOWN_TOPIC' })
130
+ }
131
+
132
+ 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'