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.
- package/README.md +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- 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
|
+
}
|