@weft-js/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +534 -54
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -3,140 +3,53 @@
3
3
  // These need to know which imports are queries, so they read the server modules; the compiler sees one file at a time. TypeScript's parser only, no type checking, and a name's taint lasts for the
4
4
  // function it was declared in, so a same-named primitive elsewhere isn't flagged.
5
5
 
6
- import { existsSync, readFileSync } from 'node:fs'
7
- import { join, relative } from 'node:path'
6
+ import { readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
8
  import ts from 'typescript'
9
- import { local } from './alias.mjs'
10
9
  import { diagnostic, positions } from './diag.mjs'
11
10
  import { discover } from './files.mjs'
12
-
13
- const isServerModule = rel => rel.startsWith('src/server/') || /\.server\.[cm]?[jt]sx?$/.test(rel)
14
- const KEEPS_OBJECTS = new Set(['map', 'filter', 'flatMap', 'slice', 'sort', 'toSorted', 'reverse', 'concat', 'find', 'at', 'structuredClone'])
15
- const ESCAPES = new Set(['untrack', 'optimistic'])
11
+ import { scan } from './state-checks/scan.mjs'
12
+ import { isServerModule, moduleFile } from './state-checks/modules.mjs'
16
13
 
17
14
  /** `export const name = query(` in a module: the names of its queries. */
18
- const queriesOf = text => new Set([...text.matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*query\s*\(/g)].map(m => m[1]))
19
-
20
- function moduleFile(root, from, spec) {
21
- const abs = local(join(root, from), spec)
22
- if (!abs) return undefined
23
- const base = relative(root, abs).split('\\').join('/').replace(/\.js$/, '')
24
- return ['', '.ts', '.tsx', '.mts', '.js', '/index.ts'].map(x => base + x).find(f => /\.[cm]?[jt]sx?$/.test(f) && existsSync(join(root, f)))
25
- }
15
+ const queriesOf = (text) =>
16
+ new Set(
17
+ [...text.matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*query\s*\(/g)].map((m) => m[1]),
18
+ )
26
19
 
27
20
  /** Local names of the queries `sf` imports from server modules. */
28
21
  function importedQueries(root, rel, sf, cache) {
29
22
  const out = new Set()
30
23
  for (const n of sf.statements) {
31
- if (!ts.isImportDeclaration(n) || !n.importClause?.namedBindings || !ts.isNamedImports(n.importClause.namedBindings)) continue
24
+ if (
25
+ !ts.isImportDeclaration(n) ||
26
+ !n.importClause?.namedBindings ||
27
+ !ts.isNamedImports(n.importClause.namedBindings)
28
+ )
29
+ continue
32
30
  const file = moduleFile(root, rel, n.moduleSpecifier.text)
33
31
  if (!file || !isServerModule(file)) continue
34
32
  if (!cache.has(file)) cache.set(file, queriesOf(readFileSync(join(root, file), 'utf8')))
35
- for (const s of n.importClause.namedBindings.elements) if (cache.get(file).has((s.propertyName ?? s.name).text)) out.add(s.name.text)
33
+ for (const s of n.importClause.namedBindings.elements)
34
+ if (cache.get(file).has((s.propertyName ?? s.name).text)) out.add(s.name.text)
36
35
  }
37
36
  return out
38
37
  }
39
38
 
40
- function scan(rel, text, queries, report) {
41
- const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
42
- const frames = [{ handles: new Set(), values: new Set() }]
43
- const has = (kind, name) => frames.some(f => f[kind].has(name))
44
- const peel = e => { while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e)) e = e.expression; return e }
45
-
46
- const isQueryCall = e => ts.isCallExpression(e) && (ts.isIdentifier(e.expression) ? queries.has(e.expression.text) : ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === 'live' && ts.isIdentifier(e.expression.expression) && queries.has(e.expression.expression.text))
47
- const isFetch = e => ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === 'fetch' && ts.isIdentifier(e.expression.expression) && queries.has(e.expression.expression.text)
48
- const isHandle = e => { e = peel(e); return isQueryCall(e) || (ts.isIdentifier(e) && has('handles', e.text)) }
49
-
50
- /** The expression holds a whole server object or array, or a derived copy of one. A picked field isn't one. */
51
- function serverValue(e) {
52
- e = peel(e)
53
- if (ts.isIdentifier(e)) return has('values', e.text)
54
- if (ts.isAwaitExpression(e)) return isFetch(peel(e.expression)) || isQueryCall(peel(e.expression))
55
- if (ts.isPropertyAccessExpression(e)) return e.name.text === 'value' && isHandle(e.expression)
56
- if (ts.isElementAccessExpression(e)) return serverValue(e.expression)
57
- if (ts.isBinaryExpression(e) && [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes(e.operatorToken.kind)) return serverValue(e.left) || serverValue(e.right)
58
- if (ts.isConditionalExpression(e)) return serverValue(e.whenTrue) || serverValue(e.whenFalse)
59
- if (ts.isCallExpression(e)) {
60
- const c = e.expression
61
- return ts.isPropertyAccessExpression(c) && KEEPS_OBJECTS.has(c.name.text) && serverValue(c.expression)
62
- }
63
- if (ts.isArrayLiteralExpression(e)) return e.elements.some(x => ts.isSpreadElement(x) && serverValue(x.expression))
64
- if (ts.isObjectLiteralExpression(e)) return e.properties.some(p => ts.isSpreadAssignment(p) && serverValue(p.expression))
65
- return false
66
- }
67
-
68
- const returned = fn => {
69
- if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return undefined
70
- if (!ts.isBlock(fn.body)) return fn.body
71
- const r = fn.body.statements.find(ts.isReturnStatement)
72
- return r?.expression
73
- }
74
- /** The first handle a function's body reads: the source `state.linked` follows. */
75
- function handleIn(node) {
76
- /** @type {string | undefined} */
77
- let found
78
- const walk = n => {
79
- if (found) return
80
- if (ts.isPropertyAccessExpression(n) && n.name.text === 'value' && isHandle(n.expression)) found = n.expression.getText(sf)
81
- else ts.forEachChild(n, walk)
82
- }
83
- walk(node)
84
- return found
85
- }
86
- const flag = (node, why) => report('S001', node.getStart(sf), node.end, why)
87
-
88
- function visit(node) {
89
- if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && ESCAPES.has(node.name.text)) return
90
- if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name) && ESCAPES.has(node.name.text)) return
91
- const fn = ts.isFunctionLike(node)
92
- if (fn) frames.push({ handles: new Set(), values: new Set() })
93
- try {
94
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
95
- const init = peel(node.initializer)
96
- if (isQueryCall(init)) frames.at(-1).handles.add(node.name.text)
97
- else if (serverValue(init)) frames.at(-1).values.add(node.name.text)
98
- }
99
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left) && node.left.name.text === 'value' && serverValue(node.right))
100
- flag(node, `A server value is copied into \`${node.left.expression.getText(sf)}\`: the copy goes stale, and its rows lose their identity.`)
101
- if (ts.isCallExpression(node)) {
102
- const c = node.expression
103
- if (ts.isIdentifier(c) && ESCAPES.has(c.text)) return
104
- if (ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression) && c.expression.text === 'state' && c.name.text === 'linked') {
105
- const source = handleIn(node.arguments[0] ?? node)
106
- if (source && node.arguments.length < 2) {
107
- const at = node.arguments[0].end
108
- report('S012', node.getStart(sf), node.end, `\`state.linked\` over \`${source}\` has no \`key\`: a refetch resets what someone is typing.`,
109
- { description: `Key it by the row: \`{ key: () => ${source}.value?.id }\``, safe: false, edits: [[at, at, `, { key: () => ${source}.value?.id }`]] })
110
- }
111
- return // the declared editing path: not S001
112
- }
113
- if (ts.isIdentifier(c) && c.text === 'state' && node.arguments[0] && serverValue(node.arguments[0]))
114
- flag(node, 'A server value is copied into `state`: the copy goes stale, and its rows lose their identity.')
115
- if (ts.isPropertyAccessExpression(c) && c.name.text === 'set' && node.arguments.length === 1) {
116
- const r = returned(node.arguments[0])
117
- if (r && serverValue(r)) flag(node, `A server value is copied into \`${c.expression.getText(sf)}\`: the copy goes stale, and its rows lose their identity.`)
118
- }
119
- // The value a callback gets from a query: `watch(() => q.value, v => …)`, `q.fetch().then(v => …)`.
120
- const fed = ts.isIdentifier(c) && c.text === 'watch' && returned(node.arguments[0]) && serverValue(returned(node.arguments[0])) ? node.arguments[1]
121
- : ts.isPropertyAccessExpression(c) && c.name.text === 'then' && isFetch(peel(c.expression)) ? node.arguments[0] : undefined
122
- if (fed && (ts.isArrowFunction(fed) || ts.isFunctionExpression(fed)) && ts.isIdentifier(fed.parameters[0]?.name)) {
123
- frames.push({ handles: new Set(), values: new Set([fed.parameters[0].name.text]) })
124
- ts.forEachChild(node, visit)
125
- frames.pop()
126
- return
127
- }
128
- }
129
- ts.forEachChild(node, visit)
130
- } finally {
131
- if (fn) frames.pop()
132
- }
133
- }
134
- visit(sf)
135
- }
136
-
137
39
  /** The local names `resource` is imported as from @weft-js/core. */
138
- const resourceNames = sf => new Set(sf.statements.flatMap(n => ts.isImportDeclaration(n) && n.moduleSpecifier.text === '@weft-js/core' && n.importClause?.namedBindings && ts.isNamedImports(n.importClause.namedBindings)
139
- ? n.importClause.namedBindings.elements.filter(s => (s.propertyName ?? s.name).text === 'resource').map(s => s.name.text) : []))
40
+ const resourceNames = (sf) =>
41
+ new Set(
42
+ sf.statements.flatMap((n) =>
43
+ ts.isImportDeclaration(n) &&
44
+ n.moduleSpecifier.text === '@weft-js/core' &&
45
+ n.importClause?.namedBindings &&
46
+ ts.isNamedImports(n.importClause.namedBindings)
47
+ ? n.importClause.namedBindings.elements
48
+ .filter((s) => (s.propertyName ?? s.name).text === 'resource')
49
+ .map((s) => s.name.text)
50
+ : [],
51
+ ),
52
+ )
140
53
 
141
54
  /**
142
55
  * X037: a `resource` or query made in a component's own body and read inside a `<Boundary>` that component renders. A
@@ -145,15 +58,32 @@ const resourceNames = sf => new Set(sf.statements.flatMap(n => ts.isImportDeclar
145
58
  * @param {ts.SourceFile} sf @param {Set<string>} makers names whose call makes one (`resource` and the queries)
146
59
  */
147
60
  function madeBesideBoundary(sf, makers, report) {
148
- const peel = e => { while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e)) e = e.expression; return e }
149
- const maker = e => {
61
+ const peel = (e) => {
62
+ while (
63
+ ts.isParenthesizedExpression(e) ||
64
+ ts.isAsExpression(e) ||
65
+ ts.isNonNullExpression(e) ||
66
+ ts.isSatisfiesExpression(e)
67
+ )
68
+ e = e.expression
69
+ return e
70
+ }
71
+ const maker = (e) => {
150
72
  e = peel(e)
151
73
  if (!ts.isCallExpression(e)) return undefined
152
74
  const c = e.expression
153
- const name = ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) && c.name.text === 'live' && ts.isIdentifier(c.expression) ? c.expression.text : undefined
75
+ const name = ts.isIdentifier(c)
76
+ ? c.text
77
+ : ts.isPropertyAccessExpression(c) && c.name.text === 'live' && ts.isIdentifier(c.expression)
78
+ ? c.expression.text
79
+ : undefined
154
80
  return name !== undefined && makers.has(name) ? e : undefined
155
81
  }
156
- const nested = n => ts.isFunctionDeclaration(n) || (ts.isVariableDeclaration(n) && !!n.initializer && (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer)))
82
+ const nested = (n) =>
83
+ ts.isFunctionDeclaration(n) ||
84
+ (ts.isVariableDeclaration(n) &&
85
+ !!n.initializer &&
86
+ (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer)))
157
87
  function component(fn) {
158
88
  /** @type {Map<string, ts.CallExpression>} */
159
89
  const made = new Map()
@@ -166,23 +96,39 @@ function madeBesideBoundary(sf, makers, report) {
166
96
  }
167
97
  if (!made.size) return
168
98
  const done = new Set()
169
- const read = n => {
170
- if (ts.isIdentifier(n) && made.has(n.text) && !done.has(n.text) && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)) {
99
+ const read = (n) => {
100
+ if (
101
+ ts.isIdentifier(n) &&
102
+ made.has(n.text) &&
103
+ !done.has(n.text) &&
104
+ !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)
105
+ ) {
171
106
  done.add(n.text)
172
107
  const call = made.get(n.text)
173
- report('X037', call.getStart(sf), call.end, `\`${n.text}\` is made here, beside the <Boundary> that shows it, so that Boundary never waits for it: no fallback shows, and the content renders before the data. Make it in a component rendered inside the Boundary.`)
108
+ report(
109
+ 'X037',
110
+ call.getStart(sf),
111
+ call.end,
112
+ `\`${n.text}\` is made here, beside the <Boundary> that shows it, so that Boundary never waits for it: no fallback shows, and the content renders before the data. Make it in a component rendered inside the Boundary.`,
113
+ )
174
114
  }
175
115
  ts.forEachChild(n, read)
176
116
  }
177
- const find = n => {
117
+ const find = (n) => {
178
118
  if (nested(n)) return // another component, with its own body
179
- if (ts.isJsxElement(n) && n.openingElement.tagName.getText(sf) === 'Boundary') n.children.forEach(read)
119
+ if (ts.isJsxElement(n) && n.openingElement.tagName.getText(sf) === 'Boundary')
120
+ n.children.forEach(read)
180
121
  else ts.forEachChild(n, find)
181
122
  }
182
123
  fn.body.statements.forEach(find)
183
124
  }
184
- const visit = n => {
185
- if ((ts.isFunctionDeclaration(n) || ts.isArrowFunction(n) || ts.isFunctionExpression(n)) && n.body && ts.isBlock(n.body)) component(n)
125
+ const visit = (n) => {
126
+ if (
127
+ (ts.isFunctionDeclaration(n) || ts.isArrowFunction(n) || ts.isFunctionExpression(n)) &&
128
+ n.body &&
129
+ ts.isBlock(n.body)
130
+ )
131
+ component(n)
186
132
  ts.forEachChild(n, visit)
187
133
  }
188
134
  visit(sf)
@@ -192,19 +138,49 @@ function madeBesideBoundary(sf, makers, report) {
192
138
  export function stateChecks(root, { files, exclude = [] }) {
193
139
  const out = []
194
140
  const cache = new Map()
195
- const checked = new Set(files.map(f => f.rel))
141
+ const checked = new Set(files.map((f) => f.rel))
196
142
  // Scopes usually live in plain .ts files, which the compiler pass doesn't read.
197
- const extra = discover(root, { include: ['src/**/*.ts'], exclude }).filter(f => !checked.has(f) && !isServerModule(f) && !/\.(d|test|spec)\.ts$/.test(f))
198
- const all = [...files.map(f => [f.rel, f.text]), ...extra.map(f => [f, readFileSync(join(root, f), 'utf8')])]
143
+ const extra = discover(root, { include: ['src/**/*.ts'], exclude }).filter(
144
+ (f) => !checked.has(f) && !isServerModule(f) && !/\.(d|test|spec)\.ts$/.test(f),
145
+ )
146
+ const all = [
147
+ ...files.map((f) => [f.rel, f.text]),
148
+ ...extra.map((f) => [f, readFileSync(join(root, f), 'utf8')]),
149
+ ]
199
150
  for (const [rel, text] of all) {
200
- const copies = /\b(state|watch|set)\b/.test(text), boundary = /\.[jt]sx$/.test(rel) && text.includes('<Boundary')
151
+ const copies = /\b(state|watch|set)\b/.test(text),
152
+ boundary = /\.[jt]sx$/.test(rel) && text.includes('<Boundary')
201
153
  if (isServerModule(rel) || !(copies || boundary)) continue
202
- const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, boundary, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
154
+ const sf = ts.createSourceFile(
155
+ rel,
156
+ text,
157
+ ts.ScriptTarget.Latest,
158
+ boundary,
159
+ /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
160
+ )
203
161
  const queries = importedQueries(root, rel, sf, cache)
204
162
  const pos = positions(text)
205
163
  const report = (code, start, end, why, fix) => {
206
164
  const offset = [start, end]
207
- out.push(diagnostic({ code, file: rel, span: pos.span(start, end), offset, why, fix: fix && { description: fix.description, safe: fix.safe, edits: fix.edits.map(([a, b, t]) => ({ file: rel, span: pos.span(a, b), offset: [a, b], text: t })) } }))
165
+ out.push(
166
+ diagnostic({
167
+ code,
168
+ file: rel,
169
+ span: pos.span(start, end),
170
+ offset,
171
+ why,
172
+ fix: fix && {
173
+ description: fix.description,
174
+ safe: fix.safe,
175
+ edits: fix.edits.map(([a, b, t]) => ({
176
+ file: rel,
177
+ span: pos.span(a, b),
178
+ offset: [a, b],
179
+ text: t,
180
+ })),
181
+ },
182
+ }),
183
+ )
208
184
  }
209
185
  if (copies && queries.size) scan(rel, text, queries, report)
210
186
  if (boundary) madeBesideBoundary(sf, new Set([...queries, ...resourceNames(sf)]), report)
@@ -0,0 +1,121 @@
1
+ export const usageError = (message) => Object.assign(new Error(message), { code: 'E_USAGE' })
2
+
3
+ const words = (s) =>
4
+ s
5
+ .toLowerCase()
6
+ .split(/[\s\-_.:/=,]+|(?<=[a-z])(?=[A-Z])/)
7
+ .filter(Boolean)
8
+
9
+ /** Edit distance with adjacent swaps as one edit (`ordre` → `order`). */
10
+ function distance(a, b) {
11
+ const d = Array.from({ length: a.length + 1 }, (_, i) =>
12
+ Array.from({ length: b.length + 1 }, (__, j) => (i ? (j ? 0 : i) : j)),
13
+ )
14
+ for (let i = 1; i <= a.length; i++)
15
+ for (let j = 1; j <= b.length; j++) {
16
+ d[i][j] = Math.min(
17
+ d[i - 1][j] + 1,
18
+ d[i][j - 1] + 1,
19
+ d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
20
+ )
21
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
22
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
23
+ }
24
+ return d[a.length][b.length]
25
+ }
26
+
27
+ /** The names closest to `word` (edit distance within a third of its length, or containing it). */
28
+ export function closest(word, names, n = 3) {
29
+ const w = word.toLowerCase()
30
+ const limit = Math.max(1, Math.floor(w.length / 3))
31
+ return names
32
+ .map((x) => [distance(w, x.toLowerCase()), x])
33
+ .filter(([d, x]) => d <= limit || x.toLowerCase().includes(w) || w.includes(x.toLowerCase()))
34
+ .sort((a, b) => a[0] - b[0])
35
+ .slice(0, n)
36
+ .map(([, x]) => x)
37
+ }
38
+
39
+ /**
40
+ * `weft style find <query>`: the groups that fit, best first. Every word of the query must hit the group's name,
41
+ * doc, parts, axes, values, states or tokens (a name word one typo off counts); with no such group, groups that hit
42
+ * any word.
43
+ */
44
+ export function findGroups(matrix, query) {
45
+ const terms = words(query ?? '')
46
+ if (!terms.length)
47
+ throw usageError(
48
+ 'weft style find <query>: say what the thing is (`card`, `status badge`, `critical`)',
49
+ )
50
+ const score = (name, g) => {
51
+ const fields = [
52
+ [words(name), 30],
53
+ [words(g.doc ?? ''), 10],
54
+ [[...g.parts, ...g.states, ...Object.keys(g.axes)].flatMap(words), 8],
55
+ [
56
+ Object.values(g.axes)
57
+ .flatMap((v) => (Array.isArray(v) ? v : []))
58
+ .flatMap(words),
59
+ 6,
60
+ ],
61
+ [g.tokens.flatMap(words), 4],
62
+ ]
63
+ let total = name === query ? 100 : 0
64
+ const matched = []
65
+ let hits = 0
66
+ for (const t of terms) {
67
+ let best = 0
68
+ let why = null
69
+ for (const [ws, weight] of fields) {
70
+ for (const w of ws) {
71
+ const s =
72
+ w === t
73
+ ? weight
74
+ : w.startsWith(t) || (t.length > 3 && w.includes(t))
75
+ ? weight * 0.7
76
+ : weight >= 30 && t.length > 3 && distance(w, t) === 1
77
+ ? weight * 0.5
78
+ : 0
79
+ if (s > best) [best, why] = [s, w]
80
+ }
81
+ }
82
+ if (best) {
83
+ hits++
84
+ matched.push(why)
85
+ }
86
+ total += best
87
+ }
88
+ return { total, hits, matched: [...new Set(matched)] }
89
+ }
90
+ const all = Object.entries(matrix.groups)
91
+ .map(([name, g]) => ({ name, g, ...score(name, g) }))
92
+ .filter((x) => x.total > 0)
93
+ const full = all.filter((x) => x.hits === terms.length)
94
+ const ranked = (full.length ? full : all).sort(
95
+ (a, b) => b.total - a.total || a.name.localeCompare(b.name),
96
+ )
97
+ // A name or doc hit outranks groups that only share a token (`row` → product-row, not every `layout: row`).
98
+ const picked = ranked.filter((x) => x.total >= (ranked[0]?.total ?? 0) / 4)
99
+ return {
100
+ query,
101
+ groups: picked.map(({ name, g, total, matched }) => ({
102
+ name,
103
+ score: Math.round(total),
104
+ matched,
105
+ doc: g.doc,
106
+ file: g.file,
107
+ import:
108
+ g.binding && g.exported ? { name: g.binding, from: g.file.replace(/:\d+$/, '') } : null,
109
+ axes: g.axes,
110
+ defaults: g.defaults,
111
+ parts: g.parts,
112
+ states: g.states,
113
+ uses: g.used.length,
114
+ similar: matrix.similar
115
+ .filter((s) => s[0] === name || s[1] === name)
116
+ .map((s) => (s[0] === name ? s[1] : s[0])),
117
+ })),
118
+ partial: !full.length && all.length > 0,
119
+ total: Object.keys(matrix.groups).length,
120
+ }
121
+ }