tspl-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 (75) hide show
  1. package/README.md +127 -2
  2. package/bin/tspl.js +10 -0
  3. package/package.json +41 -4
  4. package/src/active-org.js +49 -0
  5. package/src/analysis/analyzer.js +61 -0
  6. package/src/analysis/extract.js +37 -0
  7. package/src/analysis/file-filter.js +69 -0
  8. package/src/analysis/git-reader.js +108 -0
  9. package/src/analysis/html-parser.js +46 -0
  10. package/src/analysis/js-parser.js +473 -0
  11. package/src/analysis/py-parser.js +66 -0
  12. package/src/analysis/repo-index.js +107 -0
  13. package/src/api-client.js +108 -0
  14. package/src/browser-tests.js +209 -0
  15. package/src/commands/analyze.command.js +81 -0
  16. package/src/commands/auth.command.js +250 -0
  17. package/src/commands/build.command.js +267 -0
  18. package/src/commands/connect.command.js +270 -0
  19. package/src/commands/engines.command.js +136 -0
  20. package/src/commands/find.command.js +107 -0
  21. package/src/commands/init.command.js +79 -0
  22. package/src/commands/link.command.js +65 -0
  23. package/src/commands/mcp.command.js +12 -0
  24. package/src/commands/member.command.js +82 -0
  25. package/src/commands/plan.command.js +161 -0
  26. package/src/commands/project.command.js +191 -0
  27. package/src/commands/setup.command.js +63 -0
  28. package/src/commands/sync.command.js +59 -0
  29. package/src/commands/task.command.js +475 -0
  30. package/src/commands/usage.command.js +105 -0
  31. package/src/config-store.js +255 -0
  32. package/src/connect/claude-skill.md +30 -0
  33. package/src/engines.js +218 -0
  34. package/src/find/graph.js +272 -0
  35. package/src/find/search.js +473 -0
  36. package/src/find/text.js +86 -0
  37. package/src/git-helper.js +102 -0
  38. package/src/index.js +91 -0
  39. package/src/mcp/server.js +708 -0
  40. package/src/project-config.js +197 -0
  41. package/src/project-init.js +447 -0
  42. package/src/providers/github.js +249 -0
  43. package/src/providers/index.js +83 -0
  44. package/src/runtime-check.js +18 -0
  45. package/src/setup.js +180 -0
  46. package/src/shared/approaches.js +44 -0
  47. package/src/shared/constants.js +89 -0
  48. package/src/shared/index.js +8 -0
  49. package/src/shared/workflow-progress.js +67 -0
  50. package/src/shared/workflow.js +381 -0
  51. package/src/stack-detector.js +47 -0
  52. package/src/task/approaches.js +53 -0
  53. package/src/task/branch-choice.js +69 -0
  54. package/src/task/brief.js +115 -0
  55. package/src/task/build.js +549 -0
  56. package/src/task/check.js +415 -0
  57. package/src/task/conventions.js +113 -0
  58. package/src/task/gate.js +88 -0
  59. package/src/task/hooks.js +82 -0
  60. package/src/task/plan-draft.js +43 -0
  61. package/src/task/plan-page.js +123 -0
  62. package/src/task/plan.js +293 -0
  63. package/src/task/planning-context.js +165 -0
  64. package/src/task/refine.js +73 -0
  65. package/src/workflow/agents.js +336 -0
  66. package/src/workflow/checkout-lock.js +92 -0
  67. package/src/workflow/client.js +82 -0
  68. package/src/workflow/controller.js +585 -0
  69. package/src/workflow/evidence.js +276 -0
  70. package/src/workflow/pr-body.js +52 -0
  71. package/src/workflow/process.js +82 -0
  72. package/src/workflow/review.js +128 -0
  73. package/src/workflow/runtime.js +148 -0
  74. package/src/workflow/wait.js +58 -0
  75. package/src/workspace.js +250 -0
@@ -0,0 +1,272 @@
1
+ 'use strict'
2
+
3
+ const Path = require('path').posix
4
+
5
+ const RESOLVE_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/__init__.py']
6
+ const ROUTE_NOISE = /^(api|v\d+)$/
7
+ const KIND_SUFFIX = /\.(route|routes|router|controller|api|handler|handlers)$/
8
+
9
+ const CONSTANT_NAME = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/
10
+ const SAME_VALUE_MAX = 12
11
+
12
+ const EDGE_WEIGHTS = {
13
+ 'calls-api': 0.9,
14
+ 'same-value': 0.7,
15
+ template: 0.9,
16
+ renders: 0.6,
17
+ imports: 0.6,
18
+ uses: 0.4,
19
+ }
20
+
21
+ const segments = url => {
22
+ const path = String(url)
23
+ .replace(/^[a-z]+:\/\/[^/]+/i, '')
24
+ .replace(/[?#].*$/, '')
25
+ return path
26
+ .split('/')
27
+ .filter(Boolean)
28
+ .map(part => (/[{}:*]/.test(part) || /^\d+$/.test(part) ? '*' : part.toLowerCase()))
29
+ }
30
+
31
+ const fileStem = path => Path.basename(path).replace(/\.[^.]+$/, '').replace(KIND_SUFFIX, '').toLowerCase()
32
+
33
+ const repoKind = files => {
34
+ const ui = files.filter(file => {
35
+ const facts = file.facts
36
+ return facts.language === 'html' || facts.screens || facts.uiRoutes || facts.labels ||
37
+ (facts.components || []).some(component => component.kind === 'component')
38
+ }).length
39
+ const api = files.filter(file => file.facts.routes).length
40
+ if (ui >= 10 && ui >= api) return 'app'
41
+ if (api >= 5) return 'api'
42
+ return 'shared'
43
+ }
44
+
45
+ const createGraph = (index, feedback = {}) => {
46
+ const files = []
47
+ const byKey = new Map()
48
+ const repos = Object.entries(index.repos || {}).map(([id, repo]) => ({ id, ...repo }))
49
+
50
+ repos.forEach(repo => {
51
+ repo.paths = new Set()
52
+ repo.files
53
+ .filter(file => file.status === 'parsed' && repo.facts[file.blobHash])
54
+ .forEach(file => {
55
+ const node = {
56
+ id: files.length,
57
+ repo: repo.name,
58
+ repoId: repo.id,
59
+ path: file.path,
60
+ facts: repo.facts[file.blobHash],
61
+ history: [
62
+ ...((repo.history && repo.history[file.path]) || []),
63
+ ...(feedback[`${repo.name}/${file.path}`] || []),
64
+ ],
65
+ }
66
+ files.push(node)
67
+ byKey.set(`${repo.id}\0${file.path}`, node)
68
+ repo.paths.add(file.path)
69
+ })
70
+ })
71
+
72
+ const out = files.map(() => [])
73
+ const into = files.map(() => [])
74
+ const seen = new Set()
75
+ const addEdge = (from, to, type, detail) => {
76
+ if (!to || from.id === to.id) return
77
+ const key = `${from.id}>${to.id}`
78
+ if (seen.has(key)) return
79
+ seen.add(key)
80
+ const edge = { from: from.id, to: to.id, type, detail: detail || null, weight: EDGE_WEIGHTS[type] }
81
+ out[from.id].push(edge)
82
+ into[to.id].push(edge)
83
+ }
84
+
85
+ const byPackage = new Map(repos.filter(repo => repo.packageName).map(repo => [repo.packageName, repo]))
86
+
87
+ const findIn = (repo, path) => {
88
+ const clean = Path.normalize(path).replace(/^\.\//, '').replace(/\/$/, '')
89
+ for (const ext of RESOLVE_EXTENSIONS) {
90
+ if (repo.paths.has(clean + ext)) return byKey.get(`${repo.id}\0${clean + ext}`)
91
+ }
92
+ return null
93
+ }
94
+
95
+ const fromPackage = spec => {
96
+ const parts = spec.split('/')
97
+ const name = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
98
+ const repo = byPackage.get(name)
99
+ if (!repo) return null
100
+ const rest = spec.slice(name.length).replace(/^\//, '')
101
+ return findIn(repo, rest || repo.packageMain || 'index')
102
+ }
103
+
104
+ const inRepo = (repo, target) => {
105
+ const vendored = target.match(/^node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/(.*))?$/)
106
+ if (vendored) return fromPackage(vendored[2] ? `${vendored[1]}/${vendored[2]}` : vendored[1])
107
+ return findIn(repo, target)
108
+ }
109
+
110
+ const resolveImport = (repo, file, spec) => {
111
+ if (spec.startsWith('.')) {
112
+ return findIn(repo, Path.join(Path.dirname(file.path), spec))
113
+ }
114
+ for (const alias of repo.aliases || []) {
115
+ if (alias.wildcard ? spec.startsWith(alias.from) : spec === alias.from || spec.startsWith(`${alias.from}/`)) {
116
+ return inRepo(repo, Path.join(alias.to, spec.slice(alias.from.length)))
117
+ }
118
+ }
119
+ if (file.facts.language === 'python' && /^[\w.]+$/.test(spec)) {
120
+ const asPath = spec.replace(/\./g, '/')
121
+ return findIn(repo, asPath) || findIn(repo, Path.join(Path.dirname(file.path), asPath))
122
+ }
123
+ return fromPackage(spec)
124
+ }
125
+
126
+ const repoById = new Map(repos.map(repo => [repo.id, repo]))
127
+ files.forEach(file => {
128
+ const repo = repoById.get(file.repoId)
129
+ ;(file.facts.imports || []).forEach(spec => addEdge(file, resolveImport(repo, file, spec), 'imports', spec))
130
+ })
131
+
132
+ const dependsOn = new Map(repos.map(repo => [repo.id, new Set([repo.id])]))
133
+ out.flat().forEach(edge => dependsOn.get(files[edge.from].repoId).add(files[edge.to].repoId))
134
+ const nearest = (file, candidates) => {
135
+ if (!candidates) return []
136
+ const list = [...candidates]
137
+ const local = list.filter(target => target.repoId === file.repoId)
138
+ return local.length ? local : list.filter(target => dependsOn.get(file.repoId).has(target.repoId))
139
+ }
140
+
141
+ const selectors = new Map()
142
+ const definitions = new Map()
143
+ const remember = (map, key, file) => {
144
+ if (!map.has(key)) map.set(key, new Set())
145
+ map.get(key).add(file)
146
+ }
147
+ const define = (name, file) => {
148
+ if (name && name.length >= 4 && name !== 'default') remember(definitions, name.toLowerCase(), file)
149
+ }
150
+ files.forEach(file => {
151
+ const facts = file.facts
152
+ ;(facts.symbols || []).forEach(symbol => define(symbol.name, file))
153
+ ;(facts.models || []).forEach(model => define(model.name, file))
154
+ ;(facts.values || []).filter(value => CONSTANT_NAME.test(value.name)).forEach(value => define(value.name, file))
155
+ ;(facts.components || []).forEach(component => {
156
+ define(component.name, file)
157
+ if (component.selector) {
158
+ component.selector.split(',').forEach(selector => remember(selectors, selector.trim(), file))
159
+ }
160
+ if (component.template) {
161
+ const repo = repoById.get(file.repoId)
162
+ addEdge(file, findIn(repo, Path.join(Path.dirname(file.path), component.template)), 'template', component.template)
163
+ }
164
+ })
165
+ })
166
+
167
+ files.forEach(file => {
168
+ ;(file.facts.tags || []).forEach(tag => {
169
+ const targets = nearest(file, selectors.get(tag))
170
+ if (targets.length === 1) addEdge(file, targets[0], 'renders', `<${tag}>`)
171
+ })
172
+ ;(file.facts.refs || []).forEach(name => {
173
+ const targets = nearest(file, definitions.get(name.toLowerCase()))
174
+ if (targets.length && targets.length <= 2) targets.forEach(target => addEdge(file, target, 'uses', name))
175
+ })
176
+ })
177
+
178
+ const setters = new Map()
179
+ files.forEach(file => {
180
+ new Set((file.facts.values || []).filter(value => CONSTANT_NAME.test(value.name)).map(value => value.name))
181
+ .forEach(name => remember(setters, `${file.repoId}\0${name}`, file))
182
+ })
183
+ setters.forEach((group, key) => {
184
+ if (group.size < 2 || group.size > SAME_VALUE_MAX) return
185
+ const name = key.split('\0')[1]
186
+ const list = [...group]
187
+ list.forEach(a => list.forEach(b => a.id < b.id && addEdge(a, b, 'same-value', name)))
188
+ })
189
+
190
+ const routes = []
191
+ files.forEach(file => {
192
+ ;(file.facts.routes || []).forEach(route => {
193
+ const parts = segments(route.path)
194
+ if (!parts.length) return
195
+ routes.push({ file, route, parts, methods: route.method.split(','), stem: fileStem(file.path) })
196
+ })
197
+ })
198
+
199
+ const matchCall = (file, call) => {
200
+ const parts = segments(call.url)
201
+ if (!parts.some(part => part !== '*')) return []
202
+ let best = []
203
+ let bestScore = 0
204
+ routes.forEach(candidate => {
205
+ if (!candidate.methods.includes(call.method) && !candidate.methods.includes('*')) return
206
+ const tail = candidate.parts
207
+ if (tail.length > parts.length) return
208
+ const offset = parts.length - tail.length
209
+ let literal = 0
210
+ for (let i = 0; i < tail.length; i++) {
211
+ const want = parts[offset + i]
212
+ if (tail[i] === '*') continue
213
+ if (want !== tail[i]) return
214
+ literal += 1
215
+ }
216
+ const head = parts.slice(0, offset).filter(part => part !== '*' && !ROUTE_NOISE.test(part))
217
+ let score = literal * 2 + (tail.length === parts.length ? 1 : 0)
218
+ if (head.length) {
219
+ const joined = head.join('-')
220
+ if (joined === candidate.stem) {
221
+ score += 3
222
+ } else if (candidate.stem.endsWith(joined) || joined.endsWith(candidate.stem)) {
223
+ score += 2
224
+ } else if (literal < 2) {
225
+ return
226
+ } else {
227
+ score -= 1
228
+ }
229
+ }
230
+ if (score > bestScore) {
231
+ bestScore = score
232
+ best = [candidate]
233
+ } else if (score === bestScore) {
234
+ best.push(candidate)
235
+ }
236
+ })
237
+ return best
238
+ }
239
+
240
+ const pending = []
241
+ const affinity = new Map()
242
+ files.forEach(file => {
243
+ ;(file.facts.httpCalls || []).forEach(call => {
244
+ const matches = matchCall(file, call)
245
+ if (!matches.length) return
246
+ pending.push({ file, call, matches })
247
+ new Set(matches.map(match => match.file.repoId)).forEach(repoId => {
248
+ const key = `${file.repoId}>${repoId}`
249
+ affinity.set(key, (affinity.get(key) || 0) + 1 / matches.length)
250
+ })
251
+ })
252
+ })
253
+ pending.forEach(({ file, call, matches }) => {
254
+ const strength = match => affinity.get(`${file.repoId}>${match.file.repoId}`) || 0
255
+ const top = Math.max(...matches.map(strength))
256
+ matches
257
+ .filter(match => strength(match) === top)
258
+ .slice(0, 3)
259
+ .forEach(match => addEdge(file, match.file, 'calls-api', `${call.method} ${call.url}`))
260
+ })
261
+
262
+ repos.forEach(repo => {
263
+ repo.kind = repoKind(files.filter(file => file.repoId === repo.id))
264
+ })
265
+
266
+ return { files, out, into, repos }
267
+ }
268
+
269
+ module.exports = {
270
+ createGraph,
271
+ segments,
272
+ }