@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.
Files changed (84) 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/design.md +97 -0
  8. package/docs/fonts.md +70 -0
  9. package/docs/icons.md +102 -0
  10. package/docs/lists.md +23 -0
  11. package/docs/meta.md +82 -0
  12. package/docs/plugins.md +143 -0
  13. package/docs/props.md +77 -0
  14. package/docs/rules.md +32 -0
  15. package/docs/rust.md +223 -0
  16. package/docs/start-short.md +8 -0
  17. package/docs/start.md +422 -0
  18. package/docs/state.md +96 -0
  19. package/docs/style.md +59 -0
  20. package/docs/surface.md +111 -0
  21. package/docs/testing.md +71 -0
  22. package/docs/ui-state.md +92 -0
  23. package/docs/verify.md +48 -0
  24. package/package.json +39 -4
  25. package/src/add-rust.mjs +123 -0
  26. package/src/add.mjs +358 -0
  27. package/src/agents-md.mjs +134 -0
  28. package/src/alias.mjs +59 -0
  29. package/src/audit.mjs +333 -0
  30. package/src/browser/cli.mjs +34 -0
  31. package/src/browser/client.mjs +132 -0
  32. package/src/browser/commands.mjs +143 -0
  33. package/src/catalog.mjs +479 -0
  34. package/src/check.mjs +328 -0
  35. package/src/cli.mjs +332 -0
  36. package/src/config.d.ts +1 -0
  37. package/src/config.mjs +5 -0
  38. package/src/contributions.mjs +69 -0
  39. package/src/db-layer.mjs +33 -0
  40. package/src/design-checks.mjs +439 -0
  41. package/src/diag.mjs +74 -0
  42. package/src/docs.mjs +131 -0
  43. package/src/doctor.mjs +30 -0
  44. package/src/files.mjs +47 -0
  45. package/src/fix.mjs +40 -0
  46. package/src/gate.mjs +276 -0
  47. package/src/index.mjs +9 -0
  48. package/src/infer/engine.cjs +525 -0
  49. package/src/infer/facts.cjs +252 -0
  50. package/src/infer/hosts.cjs +114 -0
  51. package/src/infer/lsp.mjs +131 -0
  52. package/src/infer/session.cjs +105 -0
  53. package/src/infer/shadow.cjs +46 -0
  54. package/src/infer/worker.cjs +97 -0
  55. package/src/jsx-scan.mjs +86 -0
  56. package/src/load-config.mjs +274 -0
  57. package/src/map.mjs +110 -0
  58. package/src/mcp.mjs +195 -0
  59. package/src/meta-table.mjs +28 -0
  60. package/src/mutate.mjs +140 -0
  61. package/src/plugin-checks.mjs +89 -0
  62. package/src/plugins.mjs +172 -0
  63. package/src/project.mjs +219 -0
  64. package/src/props-checks.mjs +46 -0
  65. package/src/props-listener.mjs +38 -0
  66. package/src/react-checks.mjs +36 -0
  67. package/src/share-checks.mjs +74 -0
  68. package/src/site.mjs +19 -0
  69. package/src/slots.mjs +96 -0
  70. package/src/start.mjs +292 -0
  71. package/src/state-checks.mjs +213 -0
  72. package/src/style.mjs +320 -0
  73. package/src/surface-checks.mjs +218 -0
  74. package/src/tailwind.mjs +22 -0
  75. package/src/tasks.mjs +99 -0
  76. package/src/testdiff.mjs +157 -0
  77. package/src/tests.mjs +572 -0
  78. package/src/text-checks.mjs +172 -0
  79. package/src/tokens.mjs +24 -0
  80. package/src/trust.mjs +282 -0
  81. package/src/weft.mjs +66 -0
  82. package/src/workspace.mjs +105 -0
  83. package/ts-plugin/index.js +137 -0
  84. package/ts-plugin/package.json +1 -0
@@ -0,0 +1,157 @@
1
+ // Test cases in a test file, and what changed between two versions of one: which tests are new or edited (the ones
2
+ // the gate must prove), and T020, an existing test deleted, skipped or loosened without a written reason.
3
+ //
4
+ // // weft-allow T020: the old assertion pinned the bug this change fixes
5
+
6
+ import ts from 'typescript'
7
+ import { diagnostic, positions } from './diag.mjs'
8
+ import { allowances, isSubject, matcherOf, norm, parse, testCall } from './tests.mjs'
9
+
10
+ const skipMods = new Set(['skip', 'todo'])
11
+ const skipRoots = new Set(['xit', 'xtest', 'xdescribe'])
12
+
13
+ /** A case's name as vitest reports it: suite titles and the test title joined by ' > '. */
14
+ const join = names => names.join(' > ')
15
+
16
+ function nameOf(arg) {
17
+ if (!arg) return { name: '<anonymous>', dynamic: true }
18
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) return { name: arg.text, dynamic: false }
19
+ return { name: arg.getText(), dynamic: true }
20
+ }
21
+
22
+ /**
23
+ * Every test case in one file, in order.
24
+ * @returns {{ name: string, line: number, end: number, body: string, skipped: boolean, fails: boolean, dynamic: boolean, asserts: { key: string, subject: string, matcher: string, not: boolean, args: string, line: number }[], expected: number | null }[]}
25
+ */
26
+ export function testCases(text, rel) {
27
+ const sf = parse(text, rel)
28
+ const pos = positions(text)
29
+ const out = []
30
+ const visit = (node, suites, skipped, dynamic) => {
31
+ if (ts.isCallExpression(node)) {
32
+ const t = testCall(node)
33
+ if (t) {
34
+ const each = t.mods.some(m => m.name === 'each' || m.name === 'for')
35
+ const { name, dynamic: dyn } = nameOf(node.arguments[0])
36
+ const skip = skipped || skipRoots.has(t.root) || t.mods.some(m => skipMods.has(m.name))
37
+ const isDynamic = dynamic || dyn || each
38
+ if (t.suite) {
39
+ if (t.fn?.body) ts.forEachChild(t.fn.body, c => visit(c, [...suites, name], skip, isDynamic))
40
+ return
41
+ }
42
+ const asserts = []
43
+ let expected = null
44
+ if (t.fn?.body)
45
+ walk(t.fn.body, n => {
46
+ if (!ts.isCallExpression(n)) return
47
+ if (isSubject(n)) {
48
+ const m = matcherOf(n)
49
+ if (!m) return
50
+ const subject = n.arguments[0] ? norm(n.arguments[0]) : ''
51
+ const args = m.call.arguments.map(norm).join(', ')
52
+ asserts.push({ key: `${subject}|${m.not ? 'not.' : ''}${m.name}|${args}`, subject, matcher: m.name, not: m.not, args, line: pos.lineCol(n.getStart(sf))[0] })
53
+ } else if (ts.isPropertyAccessExpression(n.expression) && ts.isIdentifier(n.expression.expression) && n.expression.expression.text === 'expect' && n.expression.name.text === 'assertions') {
54
+ expected = Number(n.arguments[0]?.getText()) || null
55
+ }
56
+ })
57
+ out.push({
58
+ name: join([...suites, name]), line: pos.lineCol(node.getStart(sf))[0], end: pos.lineCol(node.getEnd())[0], body: t.fn?.body ? norm(t.fn.body) : '',
59
+ skipped: skip, fails: t.mods.some(m => m.name === 'fails'), dynamic: isDynamic, asserts, expected,
60
+ })
61
+ return
62
+ }
63
+ }
64
+ ts.forEachChild(node, c => visit(c, suites, skipped, dynamic))
65
+ }
66
+ visit(sf, [], false, false)
67
+ return out
68
+ }
69
+
70
+ function walk(node, fn) {
71
+ const go = n => { fn(n); ts.forEachChild(n, go) }
72
+ go(node)
73
+ }
74
+
75
+ /** The test names in `after` that are new or whose body changed since `before` (null: the file is new). */
76
+ export function changedCases(before, after, rel) {
77
+ const old = new Map((before == null ? [] : testCases(before, rel)).map(c => [c.name, c]))
78
+ return testCases(after, rel).filter(c => !c.skipped && old.get(c.name)?.body !== c.body)
79
+ }
80
+
81
+ /**
82
+ * Loosening a matcher: the new one accepts everything the old one did, and more. Keyed by the old matcher.
83
+ * Changing an expected value (`toBe(3)` → `toBe(4)`) is not loosening: a fix often has to.
84
+ */
85
+ const looser = {
86
+ toBe: ['toBeTruthy', 'toBeFalsy', 'toBeDefined', 'toEqual', 'toBeTypeOf', 'toBeInstanceOf'],
87
+ toEqual: ['toBeTruthy', 'toBeDefined', 'toMatchObject', 'toBeTypeOf', 'toBeInstanceOf'],
88
+ toStrictEqual: ['toEqual', 'toBeTruthy', 'toBeDefined', 'toMatchObject'],
89
+ toHaveLength: ['toBeTruthy', 'toBeDefined'],
90
+ toHaveBeenCalledWith: ['toHaveBeenCalled'],
91
+ toHaveBeenCalledTimes: ['toHaveBeenCalled'],
92
+ toHaveBeenLastCalledWith: ['toHaveBeenCalled'],
93
+ toContain: ['toBeTruthy', 'toBeDefined'],
94
+ toMatch: ['toBeTruthy', 'toBeDefined'],
95
+ toThrow: ['toThrow'], // with an argument → without one (checked below)
96
+ toBeNull: ['toBeFalsy'],
97
+ toBeUndefined: ['toBeFalsy'],
98
+ toBeGreaterThan: ['toBeGreaterThanOrEqual'],
99
+ toBeLessThan: ['toBeLessThanOrEqual'],
100
+ }
101
+
102
+ function loosened(a, b) {
103
+ if (a.subject !== b.subject || a.not !== b.not) return false
104
+ if (a.matcher === 'toThrow' || a.matcher === 'toThrowError') return b.matcher.startsWith('toThrow') && a.args && !b.args
105
+ return looser[a.matcher]?.includes(b.matcher) ?? false
106
+ }
107
+
108
+ /**
109
+ * T020 for one test file: an existing test deleted, skipped, marked `.fails`, or with an assertion removed or loosened.
110
+ * `after` null: the whole file was deleted (reported on `before`'s path, line 1).
111
+ */
112
+ export function weakened(before, after, rel) {
113
+ if (before == null) return []
114
+ const old = testCases(before, rel)
115
+ if (after == null) {
116
+ const live = old.filter(c => !c.skipped)
117
+ return live.length ? [t020(rel, null, 1, `${rel} was deleted with ${live.length} test${live.length > 1 ? 's' : ''} in it (${live.slice(0, 3).map(c => `"${c.name}"`).join(', ')}${live.length > 3 ? ', …' : ''})`)] : []
118
+ }
119
+ const sf = parse(after, rel)
120
+ const allowed = allowances(after, sf)
121
+ const now = testCases(after, rel)
122
+ const byName = new Map(now.map(c => [c.name, c]))
123
+ const byBody = new Set(now.map(c => c.body))
124
+ const out = []
125
+ const report = (line, why) => { if (!allowed('T020', line)) out.push(t020(rel, after, line, why)) }
126
+ for (const o of old) {
127
+ if (o.skipped || o.dynamic) continue
128
+ const n = byName.get(o.name)
129
+ if (!n) {
130
+ if (!byBody.has(o.body)) report(1, `test "${o.name}" was deleted`)
131
+ continue
132
+ }
133
+ if (n.skipped) { report(n.line, `test "${o.name}" is now skipped`); continue }
134
+ if (n.fails && !o.fails) { report(n.line, `test "${o.name}" is now marked \`.fails\``); continue }
135
+ if (o.expected && (n.expected ?? 0) < o.expected) report(n.line, `test "${o.name}" now expects ${n.expected ?? 'no'} assertions instead of ${o.expected}`)
136
+ const nowKeys = new Set(n.asserts.map(a => a.key))
137
+ const oldKeys = new Set(o.asserts.map(a => a.key))
138
+ const gone = o.asserts.filter(a => !nowKeys.has(a.key))
139
+ const added = n.asserts.filter(a => !oldKeys.has(a.key))
140
+ for (const a of gone) {
141
+ const loose = added.find(b => loosened(a, b))
142
+ if (loose) report(loose.line, `in "${o.name}", \`expect(${a.subject}).${a.not ? 'not.' : ''}${a.matcher}(${a.args})\` became \`${loose.not ? 'not.' : ''}${loose.matcher}(${loose.args})\`, which accepts more`)
143
+ }
144
+ const replaced = gone.filter(a => !added.some(b => loosened(a, b)))
145
+ if (n.asserts.length < o.asserts.length && replaced.length)
146
+ report(n.line, `"${o.name}" lost ${o.asserts.length - n.asserts.length} assertion${o.asserts.length - n.asserts.length > 1 ? 's' : ''}: ${replaced.slice(0, 2).map(a => `\`expect(${a.subject}).${a.matcher}(…)\``).join(', ')}`)
147
+ }
148
+ return out
149
+ }
150
+
151
+ function t020(rel, _text, line, why) {
152
+ const span = [line, 1, line, 1]
153
+ return diagnostic({
154
+ code: 'T020', file: rel, span, why: `${why}. An existing test got weaker, so it may no longer catch what it used to.`,
155
+ help: 'Restore it; or, when the change really is intended (the old test pinned a bug), keep it with `// weft-allow T020: <reason>` on that test, where the reviewer sees it',
156
+ })
157
+ }