@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,252 @@
1
+ // What one file says about its components, from syntax alone (research 44 §4). No checker here: this runs on every save
2
+ // and is cached by content, so the common file, with nothing to infer, costs a parse. CommonJS because the tsserver
3
+ // plugin loads it synchronously; `ts` is passed in because a plugin must use the TypeScript that hosts it.
4
+
5
+ const { createHash } = require('node:crypto')
6
+
7
+ const hashOf = text => createHash('sha1').update(text).digest('base64url').slice(0, 16)
8
+ const isCap = name => /^[A-Z]/.test(name)
9
+ /** The comment a generated annotation starts with. It keeps the type regenerable; deleting it makes the type yours. */
10
+ const MARK = '/*inferred*/'
11
+
12
+ function isExported(ts, node) {
13
+ return !!ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some(m => m.kind === ts.SyntaxKind.ExportKeyword)
14
+ }
15
+
16
+ const walk = (ts, node, visit) => {
17
+ const go = n => { if (visit(n) !== false) ts.forEachChild(n, go) }
18
+ ts.forEachChild(node, go)
19
+ }
20
+
21
+ /** A function a component could be: the node, its name, and the parameter it would type. */
22
+ function componentAt(ts, node) {
23
+ if (ts.isFunctionDeclaration(node) && node.name && isCap(node.name.text)) return { fn: node, name: node.name.text, nameNode: node.name, holder: node }
24
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isCap(node.name.text) && !node.type && node.initializer) {
25
+ const init = node.initializer
26
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) return { fn: init, name: node.name.text, nameNode: node.name, holder: node.parent.parent }
27
+ }
28
+ return null
29
+ }
30
+
31
+ const hasJsx = (ts, node) => {
32
+ let found = false
33
+ walk(ts, node, n => {
34
+ if (found) return false
35
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) found = true
36
+ return !found
37
+ })
38
+ return found
39
+ }
40
+
41
+ /** A parameter pattern's keys: `{ a, b = 1, c: d, ...rest }`. */
42
+ function patternKeys(ts, sf, pattern, out) {
43
+ for (const el of pattern.elements) {
44
+ if (el.dotDotDotToken) { if (ts.isIdentifier(el.name)) out.rest = el.name.text; continue }
45
+ const key = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : ts.isIdentifier(el.name) ? el.name.text : null
46
+ if (!key) { out.opaque = true; continue }
47
+ if (ts.isIdentifier(el.name)) out.aliases.set(el.name.text, key)
48
+ const entry = out.keys.get(key) ?? { uses: [] }
49
+ if (el.initializer) entry.fallback = [el.initializer.getStart(sf), el.initializer.end]
50
+ out.keys.set(key, entry)
51
+ }
52
+ }
53
+
54
+ /** Reads and writes of the prop `key` at `node`, the expression that names it. */
55
+ function classify(ts, sf, node) {
56
+ const up = node.parent
57
+ const span = n => [n.getStart(sf), n.end]
58
+ if ((ts.isPostfixUnaryExpression(up) || ts.isPrefixUnaryExpression(up)) && (up.operator === ts.SyntaxKind.PlusPlusToken || up.operator === ts.SyntaxKind.MinusMinusToken)) return { as: 'number', span: span(node) }
59
+ if (ts.isBinaryExpression(up) && up.left === node) {
60
+ const op = up.operatorToken.kind
61
+ if (op === ts.SyntaxKind.EqualsToken || op === ts.SyntaxKind.PlusEqualsToken) return { as: 'write', span: span(node), value: span(up.right) }
62
+ if (op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment) return { as: 'number', span: span(node) }
63
+ }
64
+ if (ts.isCallExpression(up) && up.expression === node) return { as: 'call', span: span(node), args: up.arguments.map(span) }
65
+ if ((ts.isPropertyAccessExpression(up) && up.expression === node) || (ts.isNonNullExpression(up))) {
66
+ const member = ts.isPropertyAccessExpression(up) ? up.name.text : null
67
+ if (member) return { as: 'member', span: span(up), member }
68
+ }
69
+ if (ts.isJsxExpression(up) && ts.isJsxAttribute(up.parent)) return { as: 'context', span: span(node) }
70
+ return { as: 'read', span: span(node) }
71
+ }
72
+
73
+ /** Every use of the props in a component's body. */
74
+ function bodyUses(ts, sf, comp) {
75
+ const { fn, param, info } = comp
76
+ const names = new Set(ts.isIdentifier(param.name) ? [param.name.text] : [])
77
+ const use = (key, node) => {
78
+ const entry = info.keys.get(key) ?? { uses: [] }
79
+ entry.uses.push(classify(ts, sf, node))
80
+ info.keys.set(key, entry)
81
+ }
82
+ const shadows = (node, name) => node.parameters?.some(p => ts.isIdentifier(p.name) && p.name.text === name)
83
+ const visit = (node, scope) => {
84
+ // `const { a } = props`, `let n = props.n`: another name for the prop (research 43). Only the first level.
85
+ if (ts.isVariableDeclaration(node) && node.initializer) {
86
+ const init = node.initializer
87
+ if (ts.isIdentifier(init) && scope.has(init.text) && ts.isObjectBindingPattern(node.name)) patternKeys(ts, sf, node.name, info)
88
+ else if (ts.isPropertyAccessExpression(init) && ts.isIdentifier(init.expression) && scope.has(init.expression.text) && ts.isIdentifier(node.name)) {
89
+ info.aliases.set(node.name.text, init.name.text)
90
+ if (!info.keys.has(init.name.text)) info.keys.set(init.name.text, { uses: [] })
91
+ return
92
+ }
93
+ }
94
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && scope.has(node.expression.text)) return use(node.name.text, node)
95
+ if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression) && scope.has(node.expression.text)) {
96
+ if (ts.isStringLiteralLike(node.argumentExpression)) return use(node.argumentExpression.text, node)
97
+ info.opaque = true
98
+ }
99
+ if (ts.isIdentifier(node) && info.aliases.has(node.text) && ts.isExpressionNode?.(node) !== false) {
100
+ const up = node.parent
101
+ const named = (ts.isPropertyAccessExpression(up) && up.name === node) || (ts.isPropertyAssignment(up) && up.name === node) || ts.isBindingElement(up) || (ts.isVariableDeclaration(up) && up.name === node) || ts.isJsxAttribute(up)
102
+ if (!named) use(info.aliases.get(node.text), node)
103
+ }
104
+ if (ts.isFunctionLike(node)) {
105
+ let inner = scope
106
+ for (const n of scope) if (shadows(node, n)) { inner = new Set(scope); inner.delete(n) }
107
+ ts.forEachChild(node, c => visit(c, inner))
108
+ return
109
+ }
110
+ ts.forEachChild(node, c => visit(c, scope))
111
+ }
112
+ if (fn.body) visit(fn.body, names)
113
+ }
114
+
115
+ function jsxSite(ts, sf, node, enclosing) {
116
+ const tag = node.tagName
117
+ if (!ts.isIdentifier(tag) && !ts.isPropertyAccessExpression(tag)) return null
118
+ if (ts.isIdentifier(tag) && !isCap(tag.text)) return null
119
+ const span = n => [n.getStart(sf), n.end]
120
+ const site = { tag: tag.getText(sf), tagSpan: span(tag), span: span(node), attrs: [], spreads: [], children: null, in: enclosing ?? null }
121
+ for (const a of node.attributes.properties) {
122
+ if (ts.isJsxSpreadAttribute(a)) { site.spreads.push({ span: span(a.expression), text: a.expression.getText(sf) }); continue }
123
+ const raw = a.name.getText(sf)
124
+ const bind = raw.startsWith('bind:')
125
+ const name = bind ? raw.slice(5) : raw
126
+ if (name === 'key') continue
127
+ const value = a.initializer
128
+ if (!value) { site.attrs.push({ name, literal: 'true', bind }); continue }
129
+ if (ts.isStringLiteral(value)) { site.attrs.push({ name, literal: 'string', exact: JSON.stringify(value.text), whole: span(a), bind }); continue }
130
+ const expr = value.expression
131
+ if (!expr) continue
132
+ const lambda = ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)
133
+ site.attrs.push({
134
+ name, span: span(expr), whole: span(a), bind, raw: bind ? span(a.name) : undefined,
135
+ untypedFn: lambda && expr.parameters.some(p => !p.type) || undefined,
136
+ value: ts.isIdentifier(expr) && isCap(expr.text) ? expr.text : undefined,
137
+ })
138
+ }
139
+ if (ts.isJsxOpeningElement(node)) {
140
+ const kids = node.parent.children.filter(c => !(ts.isJsxText(c) && !c.text.trim()))
141
+ if (kids.length) {
142
+ const only = kids.length === 1 ? kids[0] : null
143
+ const expr = only && ts.isJsxExpression(only) ? only.expression : null
144
+ const lambda = expr && (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr))
145
+ site.children = lambda ? { fn: true, span: span(expr), untypedFn: expr.parameters.some(p => !p.type) || undefined } : { fn: false }
146
+ }
147
+ }
148
+ return site
149
+ }
150
+
151
+ /** `const views = { a: A }`, `pick(A, B)`, `<X icon={A} />`: an identifier that leaves JSX tag position. */
152
+ function escapes(ts, node) {
153
+ const up = node.parent
154
+ if (ts.isJsxOpeningLikeElement?.(up) && up.tagName === node) return false
155
+ if (ts.isJsxClosingElement(up)) return false
156
+ if (ts.isFunctionDeclaration(up) || ts.isVariableDeclaration(up) && up.name === node) return false
157
+ if (ts.isPropertyAccessExpression(up) && up.name === node) return false
158
+ if (ts.isExportSpecifier(up) || ts.isExportAssignment(up) || ts.isImportSpecifier(up) || ts.isImportClause(up)) return false
159
+ if (ts.isTypeQueryNode(up)) return false
160
+ return true
161
+ }
162
+
163
+ /** The names an initializer can hand back: `pick(A, B)`, `{ a: A, b: B }`, `views[k]`, `views.a`, `hoc(A)`, `x ? A : B`. */
164
+ function holds(ts, init) {
165
+ const name = e => (ts.isIdentifier(e) ? [e.text] : [])
166
+ if (ts.isCallExpression(init)) return init.arguments.flatMap(a => name(a)).filter(isCap)
167
+ if (ts.isConditionalExpression(init)) return [...name(init.whenTrue), ...name(init.whenFalse)].filter(isCap)
168
+ if (ts.isObjectLiteralExpression(init)) return init.properties.flatMap(p => (ts.isPropertyAssignment(p) ? name(p.initializer) : ts.isShorthandPropertyAssignment(p) ? [p.name.text] : [])).filter(isCap)
169
+ if (ts.isElementAccessExpression(init) || ts.isPropertyAccessExpression(init)) return name(init.expression)
170
+ return []
171
+ }
172
+
173
+ const isOpening = (ts, n) => ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)
174
+
175
+ /**
176
+ * @returns the file's facts: `comps` (untyped or generated), `sites` (capitalised JSX tags), `imports`, `wraps`
177
+ * (`const W = hoc(Inner)`), `escaped` (component names used as values), `exports` (local names).
178
+ */
179
+ function fileFacts(ts, file, text, options = {}) {
180
+ const hash = hashOf(text)
181
+ if (!file.endsWith('.tsx')) return { file, hash, comps: [], sites: [], imports: new Map(), wraps: new Map(), escaped: new Set(), exported: new Set(), parse: null }
182
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
183
+ const comps = [], sites = []
184
+ const imports = new Map(), wraps = new Map(), escaped = new Set(), exported = new Set()
185
+ let exportsLoad = false
186
+
187
+ for (const st of sf.statements) {
188
+ if (ts.isImportDeclaration(st) && st.importClause && ts.isStringLiteral(st.moduleSpecifier)) {
189
+ const from = st.moduleSpecifier.text
190
+ const { name, namedBindings } = st.importClause
191
+ if (name) imports.set(name.text, { from, name: 'default' })
192
+ if (namedBindings && ts.isNamedImports(namedBindings)) for (const el of namedBindings.elements) imports.set(el.name.text, { from, name: (el.propertyName ?? el.name).text })
193
+ }
194
+ if (ts.isExportAssignment(st) && ts.isIdentifier(st.expression)) exported.add(st.expression.text)
195
+ if (ts.isExportDeclaration(st) && st.exportClause && ts.isNamedExports(st.exportClause) && !st.moduleSpecifier) for (const el of st.exportClause.elements) exported.add((el.propertyName ?? el.name).text)
196
+ if (isExported(ts, st) && ts.isVariableStatement(st)) for (const d of st.declarationList.declarations) if (ts.isIdentifier(d.name) && d.name.text === 'load') exportsLoad = true
197
+ if (isExported(ts, st) && ts.isFunctionDeclaration(st) && st.name?.text === 'load') exportsLoad = true
198
+ }
199
+
200
+ const enclosing = []
201
+ const visit = node => {
202
+ const found = componentAt(ts, node)
203
+ if (found) {
204
+ const { fn, name } = found
205
+ const param = fn.parameters?.[0]
206
+ const typed = param?.type
207
+ const generated = typed && text.slice(param.name.end, typed.pos + 64).includes(MARK)
208
+ if (param && fn.parameters.length === 1 && (!typed || generated) && !param.questionToken && !param.dotDotDotToken && !fn.typeParameters && hasJsx(ts, fn)) {
209
+ const info = { keys: new Map(), aliases: new Map(), rest: null, opaque: false }
210
+ if (ts.isObjectBindingPattern(param.name)) patternKeys(ts, sf, param.name, info)
211
+ else if (!ts.isIdentifier(param.name)) info.opaque = true
212
+ const comp = {
213
+ id: `${file}#${name}`, file, name, param, fn, info, nameSpan: [found.nameNode.getStart(sf), found.nameNode.end],
214
+ bodySpan: [fn.getStart(sf), fn.end], exported: isExported(ts, found.holder), isDefault: (ts.canHaveModifiers(found.holder) ? ts.getModifiers(found.holder) ?? [] : []).some(m => m.kind === ts.SyntaxKind.DefaultKeyword),
215
+ paramSpan: [param.getStart(sf), param.end], nameEnd: param.name.end, parens: !!fn.getChildren?.(sf).some(c => c.kind === ts.SyntaxKind.OpenParenToken),
216
+ written: generated ? [param.name.end, typed.end] : null, writtenType: generated ? text.slice(typed.getStart(sf), typed.end).trim() : null,
217
+ forwards: [], local: enclosing.length > 0, async: !!(ts.getModifiers?.(fn) ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword),
218
+ }
219
+ bodyUses(ts, sf, comp)
220
+ comps.push(comp)
221
+ }
222
+ }
223
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
224
+ const held = holds(ts, node.initializer)
225
+ if (held.length) wraps.set(node.name.text, held)
226
+ }
227
+ if (isOpening(ts, node)) {
228
+ const site = jsxSite(ts, sf, node, enclosing[enclosing.length - 1])
229
+ if (site) sites.push(site)
230
+ }
231
+ if (ts.isIdentifier(node) && isCap(node.text) && node.parent && !ts.isTypeReferenceNode(node.parent) && escapes(ts, node)) escaped.add(node.text)
232
+ const push = found && found.fn
233
+ if (push) enclosing.push(found.name)
234
+ ts.forEachChild(node, visit)
235
+ if (push) enclosing.pop()
236
+ }
237
+ ts.forEachChild(sf, visit)
238
+
239
+ for (const c of comps) if (exported.has(c.name) || c.isDefault) { c.exported = true; c.isDefault ||= false }
240
+ // Spreads and attributes forward from a component to whatever it renders.
241
+ for (const c of comps) {
242
+ const own = new Set([...(ts.isIdentifier(c.param.name) ? [c.param.name.text] : []), ...(c.info.rest ? [c.info.rest] : [])])
243
+ for (const s of sites) {
244
+ if (s.span[0] < c.fn.getStart(sf) || s.span[1] > c.fn.end) continue
245
+ for (const sp of s.spreads) if (own.has(sp.text)) c.forwards.push({ tag: s.tag, tagSpan: s.tagSpan, given: s.attrs.map(a => a.name) })
246
+ }
247
+ }
248
+ for (const c of comps) { delete c.fn; delete c.param }
249
+ return { file, hash, comps, sites, imports, wraps, escaped, exported, exportsLoad, parse: options.keep ? sf : null }
250
+ }
251
+
252
+ module.exports = { fileFacts, hashOf, MARK }
@@ -0,0 +1,114 @@
1
+ // The two ways to ask a checker about a shadow. Both answer the same four questions about a span (`query`), so the
2
+ // engine doesn't know which compiler it is on. `tsgoHost` drives TypeScript 7's process through its unstable API with a
3
+ // file overlay: `tsc --noEmit` of the source tree, with no files on disk and no plugin. `languageHost` reuses a language
4
+ // service, which is what a tsserver plugin has, and what a project on TypeScript 6 gets.
5
+
6
+ const NO_TRUNCATE = 1
7
+
8
+ /** The node exactly on [start, end]: the deepest one, because `x` and `x;` can share an end but not a start. */
9
+ function nodeAt(each, root, start, end) {
10
+ let best = null
11
+ const go = n => {
12
+ if (n.pos <= start && n.end >= end) {
13
+ if (n.end === end) best = n
14
+ each(n, go)
15
+ }
16
+ }
17
+ each(root, go)
18
+ return best
19
+ }
20
+
21
+ /** The flags the engine reads. `lit`: the widened text differs from the exact one. */
22
+ function describe(checker, type, flagsAny, node) {
23
+ if (!type) return { text: 'any', any: true }
24
+ const widened = checker.getWidenedType?.(checker.getBaseTypeOfLiteralType(type)) ?? checker.getBaseTypeOfLiteralType(type)
25
+ const text = checker.typeToString(widened, node, NO_TRUNCATE)
26
+ const exact = checker.typeToString(type, node, NO_TRUNCATE)
27
+ return { text, exact: exact === text ? undefined : exact, any: !!(type.flags & flagsAny) }
28
+ }
29
+
30
+ function answer(checker, node, req, env) {
31
+ if (!node) return { text: 'any', any: true, missing: true }
32
+ switch (req.kind) {
33
+ case 'type': return describe(checker, checker.getTypeAtLocation(node), env.Any, node)
34
+ case 'context': return describe(checker, checker.getContextualType(node), env.Any, node)
35
+ case 'spread': {
36
+ const type = checker.getTypeAtLocation(node)
37
+ if (!type || type.flags & env.Any) return { any: true, props: [] }
38
+ return { props: checker.getPropertiesOfType(type).map(s => ({ name: s.name, optional: !!(s.flags & env.Optional), ...describe(checker, checker.getTypeOfSymbol(s), env.Any, node) })) }
39
+ }
40
+ case 'decls': {
41
+ const type = checker.getTypeAtLocation(node)
42
+ if (!type) return { decls: [] }
43
+ const members = env.union(type) ?? [type]
44
+ return { decls: members.flatMap(t => checker.getSignaturesOfType(t, env.Call).map(s => env.place(s))).filter(Boolean) }
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Over TypeScript 7's API. `typescript/unstable/*` is a moving name, so this is the one file that touches it. */
50
+ async function tsgoHost({ root, config, typescriptDir, overlay }) {
51
+ const load = name => import(require('node:url').pathToFileURL(require('node:path').join(typescriptDir, name)).href)
52
+ const { API, SignatureKind, SymbolFlags, TypeFlags } = await load('dist/api/sync/api.js')
53
+ const each = (n, f) => n.forEachChild(f)
54
+ const api = new API({ cwd: root, fs: { readFile: f => overlay.get(f) } })
55
+ let snap = api.updateSnapshot({ openProject: config })
56
+ let project = snap.getProject(config) ?? snap.getProjects()[0]
57
+ const env = {
58
+ Any: TypeFlags.Any, Optional: SymbolFlags.Optional, Call: SignatureKind.Call,
59
+ union: t => (t.isUnionType() ? t.getTypes() : null),
60
+ place(s) { const n = s.declaration?.resolve(project); return n && { file: s.declaration.path, pos: n.pos, end: n.end } },
61
+ }
62
+ return {
63
+ kind: 'tsgo',
64
+ update(files, touched = []) {
65
+ for (const [f, text] of files) overlay.set(f, text)
66
+ const next = api.updateSnapshot({ fileChanges: { changed: [...files.keys(), ...touched] } })
67
+ snap.dispose?.()
68
+ snap = next
69
+ project = snap.getProject(config) ?? snap.getProjects()[0]
70
+ },
71
+ query(file, reqs) {
72
+ const sf = project.program.getSourceFile(file)
73
+ if (!sf) return reqs.map(() => ({ text: 'any', any: true, missing: true }))
74
+ return reqs.map(r => answer(project.checker, nodeAt(each, sf, r.start, r.end), r, env))
75
+ },
76
+ diagnostics(file) {
77
+ return [...project.program.getSyntacticDiagnostics(file), ...project.program.getSemanticDiagnostics(file)].map(d => ({ start: d.pos, length: d.end - d.pos, code: d.code, text: flatten(d) }))
78
+ },
79
+ allDiagnostics() {
80
+ return project.program.getSourceFileNames().filter(f => !f.includes('/node_modules/')).flatMap(f => this.diagnostics(f).map(d => ({ ...d, file: f })))
81
+ },
82
+ close() { snap.dispose?.(); api.close() },
83
+ }
84
+ }
85
+
86
+ const flatten = d => (typeof d.text === 'string' ? d.text : d.message ?? d.messageText ?? String(d.text)) + (d.messageChain?.length ? `\n ${d.messageChain.map(flatten).join('\n ')}` : '')
87
+
88
+ /** Over a TypeScript language service: `service()` returns it, `sync()` makes it see what `update` was told. */
89
+ function languageHost(ts, service, sync = () => {}) {
90
+ const env = {
91
+ Any: ts.TypeFlags.Any, Optional: ts.SymbolFlags.Optional, Call: ts.SignatureKind.Call,
92
+ union: t => (t.isUnion() ? t.types : null),
93
+ place(s) { const n = s.declaration; return n && { file: n.getSourceFile().fileName, pos: n.pos, end: n.end } },
94
+ }
95
+ const each = (n, f) => ts.forEachChild(n, f)
96
+ return {
97
+ kind: 'language-service',
98
+ update(files, touched = []) { sync(files, touched) },
99
+ query(file, reqs) {
100
+ const program = service().getProgram()
101
+ const sf = program.getSourceFile(file)
102
+ if (!sf) return reqs.map(() => ({ text: 'any', any: true, missing: true }))
103
+ const checker = program.getTypeChecker()
104
+ return reqs.map(r => answer(checker, nodeAt(each, sf, r.start, r.end), r, env))
105
+ },
106
+ diagnostics(file) {
107
+ const s = service()
108
+ return [...s.getSyntacticDiagnostics(file), ...s.getSemanticDiagnostics(file)].map(d => ({ start: d.start ?? 0, length: d.length ?? 0, code: d.code, text: ts.flattenDiagnosticMessageText(d.messageText, '\n') }))
109
+ },
110
+ close() {},
111
+ }
112
+ }
113
+
114
+ module.exports = { tsgoHost, languageHost, nodeAt }
@@ -0,0 +1,131 @@
1
+ // `weft lsp`: TypeScript 7 loads no plugins and its content mappers refuse `.tsx`, so the one place left to stand is
2
+ // between the editor and `tsc --lsp`. Buffers go in as their shadows; positions are mapped both ways (research 44 §6).
3
+
4
+ import { spawn } from 'node:child_process'
5
+ import { realpathSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { createRequire } from 'node:module'
8
+ import { fileURLToPath } from 'node:url'
9
+ import ts from 'typescript'
10
+
11
+ const require = createRequire(import.meta.url)
12
+ const { Session, projectTypescript } = require('./session.cjs')
13
+ const { inGenerated, toShadow, toSource } = require('./shadow.cjs')
14
+
15
+ const RANGES = new Set(['range', 'selectionRange', 'targetRange', 'targetSelectionRange', 'originSelectionRange', 'insert', 'replace'])
16
+
17
+ function framed(stream, on) {
18
+ let buffer = Buffer.alloc(0)
19
+ stream.on('data', d => {
20
+ buffer = Buffer.concat([buffer, d])
21
+ for (;;) {
22
+ const head = buffer.indexOf('\r\n\r\n')
23
+ if (head < 0) return
24
+ const len = Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0, head).toString())[1])
25
+ if (buffer.length < head + 4 + len) return
26
+ const msg = JSON.parse(buffer.subarray(head + 4, head + 4 + len).toString())
27
+ buffer = buffer.subarray(head + 4 + len)
28
+ on(msg)
29
+ }
30
+ })
31
+ }
32
+ const frame = msg => { const body = Buffer.from(JSON.stringify(msg)); return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]) }
33
+
34
+ const offsetAt = (text, { line, character }) => {
35
+ let at = 0
36
+ for (let i = 0; i < line; i++) { const n = text.indexOf('\n', at); if (n < 0) return text.length; at = n + 1 }
37
+ return at + character
38
+ }
39
+ const positionAt = (text, offset) => {
40
+ const head = text.slice(0, offset)
41
+ const line = head.split('\n').length - 1
42
+ return { line, character: offset - (head.lastIndexOf('\n') + 1) }
43
+ }
44
+
45
+ export function lsp(root, { input = process.stdin, output = process.stdout } = {}) {
46
+ const dir = projectTypescript(root)
47
+ if (!dir) throw Object.assign(new Error('`weft lsp` needs TypeScript 7 in the project; older versions use the tsserver plugin'), { code: 'E_USAGE' })
48
+ const exe = join(dirname(createRequire(join(realpathSync(dir), 'package.json')).resolve(`@typescript/typescript-${process.platform}-${process.arch}/package.json`)), 'lib', process.platform === 'win32' ? 'tsc.exe' : 'tsc')
49
+ const server = spawn(exe, ['--lsp', '--stdio'], { cwd: root, stdio: ['pipe', 'pipe', 'inherit'] })
50
+ const session = new Session(ts, root)
51
+ const docs = new Map() // uri → { file, text, sent, version }
52
+ const pending = new Map() // request id → uri
53
+ const toServer = m => server.stdin.write(frame(m))
54
+ const toClient = m => output.write(frame(m))
55
+ const fileOf = uri => fileURLToPath(uri)
56
+ const mapOf = doc => doc && session.engine.maps.get(doc.file)
57
+
58
+ const pos = (doc, p, to) => {
59
+ const map = mapOf(doc)
60
+ if (!map) return p
61
+ return to ? positionAt(map.text, toShadow(map, offsetAt(doc.text, p))) : positionAt(doc.text, toSource(map, offsetAt(map.text, p)))
62
+ }
63
+ const back = (value, doc) => {
64
+ if (Array.isArray(value)) return value.map(v => back(v, doc))
65
+ if (!value || typeof value !== 'object') return value
66
+ const uri = value.uri ?? value.targetUri
67
+ const here = uri ? docs.get(uri) ?? doc : doc
68
+ const out = {}
69
+ for (const [k, v] of Object.entries(value)) {
70
+ if (RANGES.has(k) && v?.start && mapOf(here)) out[k] = { start: pos(here, v.start), end: pos(here, v.end) }
71
+ else out[k] = back(v, here)
72
+ }
73
+ return out
74
+ }
75
+ const generated = (doc, d) => { const map = mapOf(doc); if (!map) return false; const a = offsetAt(map.text, d.range.start); return inGenerated(map, a, offsetAt(map.text, d.range.end) - a) }
76
+ const ours = doc => (session.engine.findings.get(doc.file) ?? []).map(f => ({
77
+ range: { start: positionAt(doc.text, f.start), end: positionAt(doc.text, f.end) }, severity: f.severity === 'warning' ? 2 : 1, code: f.code, source: 'weft', message: `${f.why} Fix: ${f.help}`,
78
+ }))
79
+ const items = (doc, list) => [...back(list.filter(d => !generated(doc, d)), doc).filter(d => !(session.engine.findings.get(doc.file) ?? []).some(f => f.code === 'W084' && offsetAt(doc.text, d.range.start) < f.end && f.start <= offsetAt(doc.text, d.range.start))), ...ours(doc)]
80
+
81
+ /** Inference over the buffers, then every open file whose shadow moved. */
82
+ async function refresh(changed) {
83
+ if (session.host) session.host.update(new Map(changed.flatMap(f => (session.engine.maps.has(f) ? [] : [[f, session.engine.texts.get(f)]]))))
84
+ await session.analyze(session.host ? changed : undefined)
85
+ for (const [uri, doc] of docs) {
86
+ const text = session.engine.maps.get(doc.file)?.text ?? doc.text
87
+ if (text === doc.sent) continue
88
+ doc.sent = text
89
+ toServer({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version: ++doc.version }, contentChanges: [{ text }] } })
90
+ }
91
+ }
92
+
93
+ // One at a time: a request has to see the shadow its position is mapped for.
94
+ let line = Promise.resolve()
95
+ framed(input, msg => { line = line.then(() => client(msg)).catch(e => process.stderr.write(`weft lsp: ${e.stack}\n`)) })
96
+ async function client(msg) {
97
+ const p = msg.params
98
+ if (msg.method === 'textDocument/didOpen') {
99
+ const doc = { file: fileOf(p.textDocument.uri), text: p.textDocument.text, version: p.textDocument.version }
100
+ docs.set(p.textDocument.uri, doc)
101
+ session.engine.set(doc.file, doc.text)
102
+ await refresh([doc.file])
103
+ doc.sent = session.engine.maps.get(doc.file)?.text ?? doc.text
104
+ return toServer({ ...msg, params: { textDocument: { ...p.textDocument, text: doc.sent } } })
105
+ }
106
+ if (msg.method === 'textDocument/didChange') {
107
+ const doc = docs.get(p.textDocument.uri)
108
+ for (const c of p.contentChanges) doc.text = c.range ? doc.text.slice(0, offsetAt(doc.text, c.range.start)) + c.text + doc.text.slice(offsetAt(doc.text, c.range.end)) : c.text
109
+ session.engine.set(doc.file, doc.text)
110
+ return refresh([doc.file])
111
+ }
112
+ const doc = p?.textDocument && docs.get(p.textDocument.uri)
113
+ if (msg.id !== undefined && doc) pending.set(msg.id, { doc, method: msg.method })
114
+ if (doc && p.position) return toServer({ ...msg, params: { ...p, position: pos(doc, p.position, true) } })
115
+ toServer(msg)
116
+ }
117
+
118
+ framed(server.stdout, msg => {
119
+ if (msg.method === 'textDocument/publishDiagnostics') {
120
+ const doc = docs.get(msg.params.uri)
121
+ return toClient(doc ? { ...msg, params: { ...msg.params, diagnostics: items(doc, msg.params.diagnostics) } } : msg)
122
+ }
123
+ const hold = msg.id !== undefined && !msg.method ? pending.get(msg.id) : null
124
+ if (!hold) return toClient(msg)
125
+ pending.delete(msg.id)
126
+ if (hold.method === 'textDocument/diagnostic' && msg.result?.items) return toClient({ ...msg, result: { ...msg.result, items: items(hold.doc, msg.result.items) } })
127
+ toClient({ ...msg, result: back(msg.result, hold.doc) })
128
+ })
129
+ server.on('exit', code => { session.close(); process.exitCode = code ?? 0 })
130
+ return { server, close: () => { server.kill(); session.close() } }
131
+ }
@@ -0,0 +1,105 @@
1
+ // One project's inference: a tsconfig's files, an engine, and a checker started only if something needs one. `weft
2
+ // check`, `weft tsc` and the dev worker all use this. The tsserver plugin has a language service of its own (plugin).
3
+
4
+ const { existsSync, readFileSync } = require('node:fs')
5
+ const { createRequire } = require('node:module')
6
+ const { dirname, join } = require('node:path')
7
+ const { Engine } = require('./engine.cjs')
8
+ const { languageHost, tsgoHost } = require('./hosts.cjs')
9
+
10
+ /** The project's TypeScript when it can be driven without a plugin (7's API), else ours. */
11
+ function projectTypescript(root) {
12
+ try {
13
+ const pkg = createRequire(join(root, 'package.json')).resolve('typescript/package.json')
14
+ const dir = dirname(pkg)
15
+ if (existsSync(join(dir, 'dist/api/sync/api.js'))) return dir
16
+ } catch {}
17
+ return null
18
+ }
19
+
20
+ /** A language service over the project and an overlay: TypeScript before 7. */
21
+ function memoryHost(ts, root, config, parsed, overlay) {
22
+ const versions = new Map()
23
+ const host = {
24
+ getCompilationSettings: () => parsed.options,
25
+ getScriptFileNames: () => parsed.fileNames,
26
+ getScriptVersion: f => String(versions.get(f) ?? 0),
27
+ getScriptSnapshot(f) { const t = overlay.get(f) ?? (existsSync(f) ? readFileSync(f, 'utf8') : undefined); return t === undefined ? undefined : ts.ScriptSnapshot.fromString(t) },
28
+ getCurrentDirectory: () => root,
29
+ getDefaultLibFileName: o => ts.getDefaultLibFilePath(o),
30
+ fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, readDirectory: ts.sys.readDirectory, directoryExists: ts.sys.directoryExists, getDirectories: ts.sys.getDirectories,
31
+ }
32
+ const service = ts.createLanguageService(host)
33
+ const bump = f => versions.set(f, (versions.get(f) ?? 0) + 1)
34
+ const out = languageHost(ts, () => service, (files, touched) => { for (const [f, t] of files) { overlay.set(f, t); bump(f) } touched.forEach(bump) })
35
+ out.allDiagnostics = () => parsed.fileNames.flatMap(f => out.diagnostics(f).map(d => ({ ...d, file: f })))
36
+ return out
37
+ }
38
+
39
+ class Session {
40
+ constructor(ts, root, options = {}) {
41
+ this.ts = ts
42
+ this.root = root
43
+ this.config = options.config ?? join(root, 'tsconfig.json')
44
+ const raw = ts.readConfigFile(this.config, ts.sys.readFile)
45
+ this.parsed = ts.parseJsonConfigFileContent(raw.config ?? {}, ts.sys, root)
46
+ this.engine = new Engine({ ts, read: f => (existsSync(f) ? readFileSync(f, 'utf8') : undefined), routes: options.routes })
47
+ this.host = null
48
+ this.overlay = new Map()
49
+ this.errors = new Map()
50
+ for (const f of this.parsed.fileNames) if (f.endsWith('.tsx')) this.engine.set(f)
51
+ }
52
+
53
+ async start() {
54
+ if (this.host) return this.host
55
+ const dir = projectTypescript(this.root)
56
+ this.host = dir ? await tsgoHost({ root: this.root, config: this.config, typescriptDir: dir, overlay: this.overlay }) : memoryHost(this.ts, this.root, this.config, this.parsed, this.overlay)
57
+ return this.host
58
+ }
59
+
60
+ /** Saved files: the checker reads them again, except where a shadow still stands in for them. */
61
+ touch(files) {
62
+ const plain = files.filter(f => !this.engine.maps.has(f))
63
+ for (const f of plain) this.overlay.delete(f)
64
+ this.host.update(new Map(), plain)
65
+ }
66
+
67
+ /** Findings and the checker's errors in the files inference touches, in source positions. */
68
+ async analyze(changed) {
69
+ if (!this.engine.needed()) return { findings: new Map(), errors: new Map(), started: false }
70
+ const fresh = !this.host
71
+ const host = await this.start()
72
+ const findings = this.engine.run(host, fresh ? undefined : changed)
73
+ const redo = fresh || !changed ? this.engine.affected() : this.engine.touched
74
+ for (const file of redo) {
75
+ if (!this.engine.facts.has(file)) { this.errors.delete(file); continue }
76
+ const list = this.engine.errors(host, file)
77
+ if (list.length) this.errors.set(file, list)
78
+ else this.errors.delete(file)
79
+ }
80
+ return { findings, errors: this.errors, started: true }
81
+ }
82
+
83
+ /** Every file's errors, the way `tsc --noEmit` would report them with inference applied. */
84
+ async all() {
85
+ const host = await this.start()
86
+ const findings = this.engine.needed() ? this.engine.run(host) : new Map()
87
+ const diags = host.allDiagnostics()
88
+ const errors = new Map()
89
+ const files = new Set(diags.map(d => d.file))
90
+ for (const file of files) {
91
+ const real = [...this.engine.facts.keys()].find(f => f.toLowerCase() === file.toLowerCase()) ?? file
92
+ if (!this.engine.facts.has(real)) {
93
+ errors.set(real, diags.filter(d => d.file === file).map(d => ({ code: `TS${d.code}`, start: d.start, end: d.start + d.length, why: d.text, help: 'Fix the type error' })))
94
+ continue
95
+ }
96
+ const list = this.engine.errors(host, real)
97
+ if (list.length) errors.set(real, list)
98
+ }
99
+ return { findings, errors }
100
+ }
101
+
102
+ close() { this.host?.close(); this.host = null }
103
+ }
104
+
105
+ module.exports = { Session, projectTypescript }
@@ -0,0 +1,46 @@
1
+ // A shadow is the source with a few spans replaced (annotations in, `bind:` out). Nothing else moves, so mapping a
2
+ // position is a binary search over the edits, in both directions, and it never needs a source map. Research 44 §5.
3
+
4
+ /** @param {string} text @param {{ start: number, end: number, text: string }[]} edits sorted, not overlapping */
5
+ function shadow(text, edits) {
6
+ const sorted = [...edits].sort((a, b) => a.start - b.start)
7
+ const parts = []
8
+ const segments = [] // { src, out, len }: runs that are unchanged
9
+ let at = 0, out = 0
10
+ for (const e of sorted) {
11
+ const len = e.start - at
12
+ segments.push({ src: at, out, len })
13
+ parts.push(text.slice(at, e.start), e.text)
14
+ out += len + e.text.length
15
+ at = e.end
16
+ }
17
+ segments.push({ src: at, out, len: text.length - at })
18
+ parts.push(text.slice(at))
19
+ return { text: parts.join(''), segments, edits: sorted }
20
+ }
21
+
22
+ function find(segments, pos, key) {
23
+ let lo = 0, hi = segments.length - 1
24
+ while (lo < hi) {
25
+ const mid = (lo + hi + 1) >> 1
26
+ if (segments[mid][key] <= pos) lo = mid
27
+ else hi = mid - 1
28
+ }
29
+ return segments[lo]
30
+ }
31
+
32
+ /** Source position to shadow position. */
33
+ const toShadow = (map, pos) => { const s = find(map.segments, pos, 'src'); return s.out + Math.min(pos - s.src, s.len) }
34
+
35
+ /** Shadow position to source position. A position inside generated text lands where that text was inserted. */
36
+ const toSource = (map, pos) => { const s = find(map.segments, pos, 'out'); return s.src + Math.min(pos - s.out, s.len) }
37
+
38
+ /** Whether a shadow span is inside text the source doesn't have. */
39
+ const inGenerated = (map, start, length) => {
40
+ const s = find(map.segments, start, 'out')
41
+ return start >= s.out + s.len && length >= 0
42
+ }
43
+
44
+ const identity = text => ({ text, segments: [{ src: 0, out: 0, len: text.length }], edits: [] })
45
+
46
+ module.exports = { shadow, toShadow, toSource, inGenerated, identity }