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,473 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Path = require('path').posix
|
|
4
|
+
const { words, terms, splitTerms, identifier, editDistance } = require('./text')
|
|
5
|
+
|
|
6
|
+
const K1 = 1.2
|
|
7
|
+
const B = 0.75
|
|
8
|
+
const SPREAD = 0.35
|
|
9
|
+
const DETAIL_WEIGHT = 0.6
|
|
10
|
+
const SEEDS = 40
|
|
11
|
+
const CHAIN_FILES = 12
|
|
12
|
+
const TEST_FILE = /(^|[./_-])(spec|test|tests)([./_-]|$)/
|
|
13
|
+
|
|
14
|
+
const addField = (doc, value, weight, label) => {
|
|
15
|
+
terms(value).forEach(term => {
|
|
16
|
+
doc.tf.set(term, (doc.tf.get(term) || 0) + weight)
|
|
17
|
+
doc.length += weight
|
|
18
|
+
if (!doc.why.has(term)) doc.why.set(term, `${label}: ${value}`)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const documentFor = (file, repoName) => {
|
|
23
|
+
const facts = file.facts
|
|
24
|
+
const doc = { tf: new Map(), why: new Map(), length: 0 }
|
|
25
|
+
const name = Path.basename(file.path).replace(/\.[^.]+$/, '')
|
|
26
|
+
addField(doc, name, 3, 'file')
|
|
27
|
+
addField(doc, Path.dirname(file.path), 1.5, 'folder')
|
|
28
|
+
doc.place = new Set([...terms(name), ...terms(Path.basename(Path.dirname(file.path)))])
|
|
29
|
+
addField(doc, repoName, 0.5, 'repo')
|
|
30
|
+
;(facts.symbols || []).forEach(symbol => {
|
|
31
|
+
addField(doc, symbol.name, 2.5, symbol.kind)
|
|
32
|
+
;(symbol.methods || []).forEach(method => addField(doc, method, 1.5, 'method'))
|
|
33
|
+
})
|
|
34
|
+
;(facts.exports || []).forEach(value => addField(doc, value, 1.5, 'export'))
|
|
35
|
+
;(facts.routes || []).forEach(route => {
|
|
36
|
+
addField(doc, route.path, 2, `route ${route.method}`)
|
|
37
|
+
if (route.handler) addField(doc, route.handler, 1.5, 'handler')
|
|
38
|
+
})
|
|
39
|
+
;(facts.refs || []).forEach(name => {
|
|
40
|
+
const term = identifier(name)
|
|
41
|
+
if (!term) return
|
|
42
|
+
doc.tf.set(term, (doc.tf.get(term) || 0) + 0.5)
|
|
43
|
+
doc.length += 0.5
|
|
44
|
+
if (!doc.why.has(term)) doc.why.set(term, `mentions: ${name}`)
|
|
45
|
+
})
|
|
46
|
+
;(facts.models || []).forEach(model => {
|
|
47
|
+
addField(doc, model.name, 3, 'model')
|
|
48
|
+
addField(doc, model.variable, 2, 'schema')
|
|
49
|
+
addField(doc, model.collection, 2, 'collection')
|
|
50
|
+
;(model.fields || []).forEach(field => addField(doc, field, 0.7, 'field'))
|
|
51
|
+
})
|
|
52
|
+
;(facts.components || []).forEach(component => {
|
|
53
|
+
addField(doc, component.name, 2.5, component.kind)
|
|
54
|
+
addField(doc, component.selector, 2, 'selector')
|
|
55
|
+
})
|
|
56
|
+
;(facts.uiRoutes || []).forEach(route => addField(doc, route.path, 2, 'page route'))
|
|
57
|
+
;(facts.screens || []).forEach(screen => addField(doc, screen.name, 2.5, 'screen'))
|
|
58
|
+
;(facts.httpCalls || []).forEach(call => addField(doc, call.url, 1, `calls ${call.method}`))
|
|
59
|
+
;(facts.labels || []).forEach(label => addField(doc, label, 1.5, 'text'))
|
|
60
|
+
;(facts.tags || []).forEach(tag => addField(doc, tag, 0.8, 'uses'))
|
|
61
|
+
;(facts.values || []).forEach(value => {
|
|
62
|
+
addField(doc, value.name, 2, `value ${value.name} = ${value.value}`)
|
|
63
|
+
if (/[a-z]{3}/i.test(value.value)) addField(doc, value.value, 1, `value ${value.name} = ${value.value}`)
|
|
64
|
+
})
|
|
65
|
+
;(facts.texts || []).forEach(value => addField(doc, value, 1, 'message'))
|
|
66
|
+
;(facts.comments || []).forEach(value => addField(doc, value, 0.8, 'comment'))
|
|
67
|
+
;(file.history || []).forEach(message => addField(doc, message, 1, 'past change'))
|
|
68
|
+
return doc
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const createSearch = graph => {
|
|
72
|
+
const docs = graph.files.map(file => documentFor(file, file.repo))
|
|
73
|
+
const df = new Map()
|
|
74
|
+
docs.forEach(doc => doc.tf.forEach((_, term) => df.set(term, (df.get(term) || 0) + 1)))
|
|
75
|
+
const averageLength = docs.reduce((sum, doc) => sum + doc.length, 0) / (docs.length || 1)
|
|
76
|
+
const idf = term => {
|
|
77
|
+
const n = df.get(term) || 0
|
|
78
|
+
return Math.log(1 + (docs.length - n + 0.5) / (n + 0.5))
|
|
79
|
+
}
|
|
80
|
+
const vocabulary = [...df.keys()]
|
|
81
|
+
const suffix = path => path.split('/').slice(-2).join('/')
|
|
82
|
+
const bySuffix = new Map()
|
|
83
|
+
graph.files.forEach((file, id) => {
|
|
84
|
+
const key = suffix(file.path)
|
|
85
|
+
if (!bySuffix.has(key)) bySuffix.set(key, [])
|
|
86
|
+
bySuffix.get(key).push(id)
|
|
87
|
+
})
|
|
88
|
+
const copiesOf = id => (bySuffix.get(suffix(graph.files[id].path)) || [])
|
|
89
|
+
.filter(other => graph.files[other].repo !== graph.files[id].repo)
|
|
90
|
+
.map(other => `${graph.files[other].repo}/${graph.files[other].path}`)
|
|
91
|
+
|
|
92
|
+
const expandQuery = text => {
|
|
93
|
+
const { single, joined, original } = splitTerms(text)
|
|
94
|
+
const known = []
|
|
95
|
+
const unknown = []
|
|
96
|
+
const add = (term, weight, typo) => {
|
|
97
|
+
if (!known.some(q => q.term === term)) known.push({ term, weight, typo })
|
|
98
|
+
}
|
|
99
|
+
;[...new Set(single)].forEach(term => {
|
|
100
|
+
if (df.has(term)) {
|
|
101
|
+
add(term, 1)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const max = term.length >= 8 ? 2 : term.length >= 4 ? 1 : 0
|
|
105
|
+
const close = max
|
|
106
|
+
? vocabulary.filter(word => word[0] === term[0] && editDistance(word, term, max) <= max).slice(0, 3)
|
|
107
|
+
: []
|
|
108
|
+
if (close.length) close.forEach(word => add(word, 0.7, original.get(term)))
|
|
109
|
+
else unknown.push(original.get(term))
|
|
110
|
+
})
|
|
111
|
+
joined.filter(term => df.has(term)).forEach(term => add(term, 1))
|
|
112
|
+
const fixed = term => (known.find(q => q.typo === original.get(term)) || { term }).term
|
|
113
|
+
for (let i = 0; i < single.length - 1; i++) {
|
|
114
|
+
const pair = fixed(single[i]) + fixed(single[i + 1])
|
|
115
|
+
if (pair !== single[i] + single[i + 1] && df.has(pair)) add(pair, 0.7)
|
|
116
|
+
}
|
|
117
|
+
const used = [...new Set(single)]
|
|
118
|
+
.filter(term => df.has(term))
|
|
119
|
+
.map(term => original.get(term))
|
|
120
|
+
const corrected = known.filter(q => q.typo).map(q => `${q.typo} → ${q.term}`)
|
|
121
|
+
return { known, unknown, used: [...used, ...corrected] }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const lexical = known => {
|
|
125
|
+
const scores = new Map()
|
|
126
|
+
const total = known.reduce((sum, q) => sum + idf(q.term) * q.weight, 0) || 1
|
|
127
|
+
docs.forEach((doc, id) => {
|
|
128
|
+
let score = 0
|
|
129
|
+
let covered = 0
|
|
130
|
+
known.forEach(q => {
|
|
131
|
+
const tf = doc.tf.get(q.term)
|
|
132
|
+
if (!tf) return
|
|
133
|
+
const weight = idf(q.term) * q.weight
|
|
134
|
+
score += weight * ((tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * doc.length) / averageLength)))
|
|
135
|
+
covered += weight
|
|
136
|
+
})
|
|
137
|
+
if (score > 0) scores.set(id, { score: score * (0.3 + covered / total) ** 2, covered: covered / total })
|
|
138
|
+
})
|
|
139
|
+
return scores
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const degree = id => graph.out[id].length + graph.into[id].length
|
|
143
|
+
|
|
144
|
+
const find = (text, options = {}) => {
|
|
145
|
+
const limit = options.limit || 15
|
|
146
|
+
const { known, unknown, used } = expandQuery(text)
|
|
147
|
+
if (options.title) {
|
|
148
|
+
const title = expandQuery(options.title).known.map(q => q.term)
|
|
149
|
+
if (title.length) {
|
|
150
|
+
const inTitle = new Set(title)
|
|
151
|
+
known.forEach(q => {
|
|
152
|
+
if (!inTitle.has(q.term)) q.weight *= DETAIL_WEIGHT
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const lex = lexical(known)
|
|
157
|
+
const top = Math.max(0, ...[...lex.values()].map(item => item.score)) || 1
|
|
158
|
+
const final = new Map()
|
|
159
|
+
const via = new Map()
|
|
160
|
+
lex.forEach((item, id) => final.set(id, item.score / top))
|
|
161
|
+
|
|
162
|
+
const seeds = [...lex.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, SEEDS)
|
|
163
|
+
seeds.forEach(([id, item]) => {
|
|
164
|
+
const strength = item.score / top
|
|
165
|
+
const spread = (edge, other, direction) => {
|
|
166
|
+
const own = lex.has(other) ? lex.get(other).score / top : 0
|
|
167
|
+
const bonus = strength * edge.weight * SPREAD * (1 + own) / Math.log2(2 + degree(other) / 4)
|
|
168
|
+
if (bonus < 0.02) return
|
|
169
|
+
final.set(other, (final.get(other) || 0) + bonus)
|
|
170
|
+
const current = via.get(other)
|
|
171
|
+
if (!current || current.bonus < bonus) via.set(other, { bonus, from: id, edge, direction })
|
|
172
|
+
}
|
|
173
|
+
graph.out[id].forEach(edge => spread(edge, edge.to, 'out'))
|
|
174
|
+
graph.into[id].forEach(edge => spread(edge, edge.from, 'in'))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
let sorted = [...final.entries()].sort((a, b) => b[1] - a[1])
|
|
178
|
+
if (options.repos && options.repos.length) {
|
|
179
|
+
const chosen = new Set(options.repos)
|
|
180
|
+
const anchors = new Set(sorted.filter(([id]) => chosen.has(graph.files[id].repo)).slice(0, 20).map(([id]) => id))
|
|
181
|
+
const linked = id => graph.out[id].some(edge => anchors.has(edge.to)) || graph.into[id].some(edge => anchors.has(edge.from))
|
|
182
|
+
sorted = sorted.filter(([id]) => chosen.has(graph.files[id].repo) || linked(id))
|
|
183
|
+
}
|
|
184
|
+
const ranked = sorted.slice(0, limit).map(([id, score]) => ({ id, score }))
|
|
185
|
+
|
|
186
|
+
const titleTerms = options.title ? expandQuery(options.title).known.map(q => q.term) : []
|
|
187
|
+
const choices = choicesFor(graph, docs, final, titleTerms, options.repos)
|
|
188
|
+
|
|
189
|
+
const queryTerms = new Set(known.map(q => q.term))
|
|
190
|
+
const results = ranked.map(({ id, score }) => {
|
|
191
|
+
const file = graph.files[id]
|
|
192
|
+
const doc = docs[id]
|
|
193
|
+
const matched = known.filter(q => doc.tf.has(q.term)).sort((a, b) => idf(b.term) - idf(a.term))
|
|
194
|
+
const why = [...new Set(matched.map(q => doc.why.get(q.term)))].slice(0, 3)
|
|
195
|
+
const link = via.get(id)
|
|
196
|
+
if (link) {
|
|
197
|
+
const other = graph.files[link.from]
|
|
198
|
+
const verb = {
|
|
199
|
+
'calls-api': link.direction === 'out' ? 'is called by' : 'calls',
|
|
200
|
+
imports: link.direction === 'out' ? 'is imported by' : 'imports',
|
|
201
|
+
uses: link.direction === 'out' ? 'is used by' : 'uses',
|
|
202
|
+
template: 'is the template/code of',
|
|
203
|
+
renders: link.direction === 'out' ? 'is shown inside' : 'shows',
|
|
204
|
+
'same-value': `sets the same ${link.edge.detail} as`,
|
|
205
|
+
}[link.edge.type]
|
|
206
|
+
const where = other.repo === file.repo ? Path.basename(other.path) : `${other.repo}/${other.path}`
|
|
207
|
+
why.push(`${verb} ${where}${link.edge.detail && link.edge.type === 'calls-api' ? ` (${link.edge.detail})` : ''}`)
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
id,
|
|
211
|
+
repo: file.repo,
|
|
212
|
+
path: file.path,
|
|
213
|
+
score: Number(score.toFixed(3)),
|
|
214
|
+
why,
|
|
215
|
+
symbols: symbolsFor(file, queryTerms),
|
|
216
|
+
similar: similarFiles(graph, file, ranked),
|
|
217
|
+
copies: copiesOf(id),
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
query: text,
|
|
223
|
+
terms: used,
|
|
224
|
+
unknown,
|
|
225
|
+
files: results,
|
|
226
|
+
chains: chainsFor(graph, results),
|
|
227
|
+
areas: areasFor(graph, results),
|
|
228
|
+
choices,
|
|
229
|
+
searchSteps: searchStepsFor(graph, results, new Set(known.map(q => q.term))),
|
|
230
|
+
confidence: confidenceFor(known, unknown, lex, results, graph, top),
|
|
231
|
+
placement: placementFor(graph, results),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { find }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const symbolsFor = (file, queryTerms) => {
|
|
239
|
+
const hits = []
|
|
240
|
+
const check = (name, kind, line) => {
|
|
241
|
+
const matched = terms(name).filter(term => queryTerms.has(term)).length
|
|
242
|
+
if (matched) hits.push({ name, kind, line: line || null, matched })
|
|
243
|
+
}
|
|
244
|
+
;(file.facts.symbols || []).forEach(symbol => {
|
|
245
|
+
check(symbol.name, symbol.kind, symbol.line)
|
|
246
|
+
;(symbol.methods || []).forEach(method => check(method, `method of ${symbol.name}`))
|
|
247
|
+
})
|
|
248
|
+
;(file.facts.routes || []).forEach(route => check(route.path, `route ${route.method}`, route.line))
|
|
249
|
+
;(file.facts.models || []).forEach(model => check(model.name || 'model', 'model', model.line))
|
|
250
|
+
return hits
|
|
251
|
+
.sort((a, b) => b.matched - a.matched)
|
|
252
|
+
.slice(0, 4)
|
|
253
|
+
.map(({ name, kind, line }) => ({ name, kind, line }))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const similarFiles = (graph, file, ranked) => {
|
|
257
|
+
const shown = new Set(ranked.map(item => item.id))
|
|
258
|
+
const name = Path.basename(file.path).replace(/\..*$/, '')
|
|
259
|
+
const mine = new Set(terms(name))
|
|
260
|
+
if (mine.size < 2 || TEST_FILE.test(file.path)) return []
|
|
261
|
+
const folder = Path.dirname(file.path)
|
|
262
|
+
return graph.files
|
|
263
|
+
.filter(other =>
|
|
264
|
+
other.repoId === file.repoId && !shown.has(other.id) && Path.dirname(other.path) === folder &&
|
|
265
|
+
Path.basename(other.path).replace(/\..*$/, '') !== name && !TEST_FILE.test(other.path))
|
|
266
|
+
.map(other => {
|
|
267
|
+
const theirs = terms(Path.basename(other.path).replace(/\..*$/, ''))
|
|
268
|
+
return { path: other.path, shared: theirs.filter(term => mine.has(term)).length / Math.max(mine.size, theirs.length) }
|
|
269
|
+
})
|
|
270
|
+
.filter(other => other.shared >= 0.5)
|
|
271
|
+
.sort((a, b) => b.shared - a.shared)
|
|
272
|
+
.slice(0, 3)
|
|
273
|
+
.map(other => other.path)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const searchStepsFor = (graph, results, queryTerms) => {
|
|
277
|
+
const steps = new Map()
|
|
278
|
+
const add = (pattern, why) => {
|
|
279
|
+
if (pattern && pattern.length >= 3 && !steps.has(pattern)) steps.set(pattern, why)
|
|
280
|
+
}
|
|
281
|
+
results.slice(0, 8).forEach(result => {
|
|
282
|
+
const facts = graph.files[result.id].facts
|
|
283
|
+
;(facts.values || []).forEach(value => {
|
|
284
|
+
if (terms(value.name).filter(term => queryTerms.has(term)).length < 2) return
|
|
285
|
+
add(value.name, `constant in ${Path.basename(result.path)}`)
|
|
286
|
+
if (value.value.length >= 3 && !/^\d{1,3}$/.test(value.value)) add(`'${value.value}'`, `value of ${value.name}`)
|
|
287
|
+
})
|
|
288
|
+
;(facts.symbols || []).forEach(symbol => {
|
|
289
|
+
;[symbol.name, ...(symbol.methods || [])].forEach(name => {
|
|
290
|
+
if (terms(name).filter(term => queryTerms.has(term)).length >= 2) add(name, `defined in ${Path.basename(result.path)}`)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
return [...steps.entries()].slice(0, 6).map(([pattern, why]) => ({ pattern, why }))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const AREA_FILES = 12
|
|
298
|
+
const AREA_LINKS = new Set(['calls-api', 'template', 'renders', 'same-value', 'imports', 'uses'])
|
|
299
|
+
const HUB = 15
|
|
300
|
+
|
|
301
|
+
const areaName = path => {
|
|
302
|
+
const base = Path.basename(path).split('.')[0]
|
|
303
|
+
const folder = Path.basename(Path.dirname(path))
|
|
304
|
+
return words(base === 'index' ? folder : base).join(' ')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const areasFor = (graph, results) => {
|
|
308
|
+
const top = results.slice(0, AREA_FILES).filter(result => !TEST_FILE.test(result.path))
|
|
309
|
+
const ids = new Set(top.map(result => result.id))
|
|
310
|
+
const parent = new Map(top.map(result => [result.id, result.id]))
|
|
311
|
+
const root = id => (parent.get(id) === id ? id : root(parent.get(id)))
|
|
312
|
+
const hub = id => graph.into[id].length > HUB
|
|
313
|
+
top.forEach(result => {
|
|
314
|
+
graph.out[result.id].forEach(edge => {
|
|
315
|
+
if (!ids.has(edge.to) || !AREA_LINKS.has(edge.type)) return
|
|
316
|
+
if ((edge.type === 'imports' || edge.type === 'uses') && (hub(edge.to) || hub(edge.from))) return
|
|
317
|
+
parent.set(root(edge.from), root(edge.to))
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
const groups = new Map()
|
|
321
|
+
top.forEach(result => {
|
|
322
|
+
const key = root(result.id)
|
|
323
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
324
|
+
groups.get(key).push(result)
|
|
325
|
+
})
|
|
326
|
+
return [...groups.values()]
|
|
327
|
+
.map(files => ({
|
|
328
|
+
name: areaName(files[0].path),
|
|
329
|
+
repo: files[0].repo,
|
|
330
|
+
score: files[0].score,
|
|
331
|
+
files: files.map(file => `${file.repo}/${file.path}`),
|
|
332
|
+
}))
|
|
333
|
+
.sort((a, b) => b.score - a.score)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const CHOICES = 4
|
|
337
|
+
|
|
338
|
+
const choicesFor = (graph, docs, scores, titleTerms, repos) => {
|
|
339
|
+
const chosen = repos && repos.length ? new Set(repos) : null
|
|
340
|
+
const choices = []
|
|
341
|
+
titleTerms.forEach(term => {
|
|
342
|
+
const folders = new Map()
|
|
343
|
+
docs.forEach((doc, id) => {
|
|
344
|
+
const file = graph.files[id]
|
|
345
|
+
if (!doc.place.has(term) || TEST_FILE.test(file.path) || (chosen && !chosen.has(file.repo))) return
|
|
346
|
+
const key = `${file.repo}/${Path.dirname(file.path)}`
|
|
347
|
+
const entry = folders.get(key) || { repo: file.repo, folder: Path.dirname(file.path), score: 0, files: [] }
|
|
348
|
+
const score = scores.get(id) || 0
|
|
349
|
+
entry.score += score
|
|
350
|
+
entry.files.push({ path: `${file.repo}/${file.path}`, score })
|
|
351
|
+
folders.set(key, entry)
|
|
352
|
+
})
|
|
353
|
+
const best = [...folders.values()].sort((a, b) => b.score - a.score)[0]
|
|
354
|
+
if (!best || !best.score) return
|
|
355
|
+
choices.push({
|
|
356
|
+
term,
|
|
357
|
+
name: words(Path.basename(best.folder)).join(' ') || best.repo,
|
|
358
|
+
repo: best.repo,
|
|
359
|
+
score: best.score,
|
|
360
|
+
files: best.files.sort((a, b) => b.score - a.score).slice(0, 3).map(file => file.path),
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
const seen = new Set()
|
|
364
|
+
return choices
|
|
365
|
+
.sort((a, b) => b.score - a.score)
|
|
366
|
+
.filter(choice => !seen.has(choice.files[0]) && seen.add(choice.files[0]))
|
|
367
|
+
.slice(0, CHOICES)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const chainsFor = (graph, results) => {
|
|
371
|
+
const inSet = new Set(results.slice(0, CHAIN_FILES).filter(result => !TEST_FILE.test(result.path)).map(result => result.id))
|
|
372
|
+
const next = id =>
|
|
373
|
+
[...graph.out[id].filter(edge => edge.type !== 'same-value'), ...graph.into[id].filter(edge => edge.type === 'template')]
|
|
374
|
+
.map(edge => ({ edge, to: edge.from === id ? edge.to : edge.from }))
|
|
375
|
+
.filter(step => inSet.has(step.to))
|
|
376
|
+
const hasIncoming = id =>
|
|
377
|
+
graph.into[id].some(edge => inSet.has(edge.from) && edge.type !== 'template' && edge.type !== 'same-value')
|
|
378
|
+
const chains = []
|
|
379
|
+
const walk = (id, path, visited) => {
|
|
380
|
+
const steps = next(id).filter(step => !visited.has(step.to))
|
|
381
|
+
if (!steps.length) {
|
|
382
|
+
if (path.length >= 3 || path.some(step => step.edge && step.edge.type === 'calls-api')) chains.push(path)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
steps.forEach(step => walk(step.to, [...path, step], new Set([...visited, step.to])))
|
|
386
|
+
}
|
|
387
|
+
results
|
|
388
|
+
.slice(0, CHAIN_FILES)
|
|
389
|
+
.filter(result => !hasIncoming(result.id) && !TEST_FILE.test(result.path))
|
|
390
|
+
.forEach(result => walk(result.id, [{ to: result.id, edge: null }], new Set([result.id])))
|
|
391
|
+
|
|
392
|
+
const covered = new Set()
|
|
393
|
+
return chains
|
|
394
|
+
.sort((a, b) => b.length - a.length)
|
|
395
|
+
.filter(chain => {
|
|
396
|
+
const fresh = chain.some(step => !covered.has(step.to))
|
|
397
|
+
chain.forEach(step => covered.add(step.to))
|
|
398
|
+
return fresh
|
|
399
|
+
})
|
|
400
|
+
.slice(0, 3)
|
|
401
|
+
.map(chain =>
|
|
402
|
+
chain.map(step => ({
|
|
403
|
+
repo: graph.files[step.to].repo,
|
|
404
|
+
path: graph.files[step.to].path,
|
|
405
|
+
via: step.edge ? step.edge.type : null,
|
|
406
|
+
detail: step.edge && step.edge.type === 'calls-api' ? step.edge.detail : null,
|
|
407
|
+
})),
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const confidenceFor = (known, unknown, lex, results, graph, top) => {
|
|
412
|
+
const reasons = []
|
|
413
|
+
if (!known.length) {
|
|
414
|
+
return { level: 'low', reasons: ['none of the task words appear in the code'] }
|
|
415
|
+
}
|
|
416
|
+
const best = results.length ? lex.get(results[0].id) : null
|
|
417
|
+
const coverage = best ? best.covered : 0
|
|
418
|
+
const topFive = new Set(results.slice(0, 5).map(result => result.id))
|
|
419
|
+
const connected = results
|
|
420
|
+
.slice(0, 5)
|
|
421
|
+
.some(result => graph.out[result.id].some(edge => topFive.has(edge.to)))
|
|
422
|
+
const scores = results.map(result => result.score)
|
|
423
|
+
const clear = scores.length > 5 ? scores[0] > scores[5] * 1.3 : true
|
|
424
|
+
|
|
425
|
+
if (unknown.length) reasons.push(`words not found in the code: ${unknown.join(', ')}`)
|
|
426
|
+
reasons.push(`best file matches ${Math.round(coverage * 100)}% of the task words`)
|
|
427
|
+
reasons.push(connected ? 'top files are connected to each other' : 'top files are not connected to each other')
|
|
428
|
+
if (!clear) reasons.push('many files score about the same')
|
|
429
|
+
|
|
430
|
+
let level = 'low'
|
|
431
|
+
if (coverage >= 0.7 && connected && !unknown.length && clear) level = 'high'
|
|
432
|
+
else if (coverage >= 0.5 && (connected || clear)) level = 'medium'
|
|
433
|
+
return { level, reasons }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const KIND_FILE = /^(.+?)\.([a-z][a-z0-9-]*)\.([a-z]+)$/
|
|
437
|
+
|
|
438
|
+
const placementFor = (graph, results) => {
|
|
439
|
+
const byRepo = new Map()
|
|
440
|
+
graph.files.forEach(file => {
|
|
441
|
+
if (!byRepo.has(file.repo)) byRepo.set(file.repo, [])
|
|
442
|
+
byRepo.get(file.repo).push(file.path)
|
|
443
|
+
})
|
|
444
|
+
const patterns = new Map()
|
|
445
|
+
results.slice(0, 8).forEach(result => {
|
|
446
|
+
const dir = Path.dirname(result.path)
|
|
447
|
+
const base = Path.basename(result.path)
|
|
448
|
+
const match = base.match(KIND_FILE)
|
|
449
|
+
const paths = byRepo.get(result.repo)
|
|
450
|
+
let pattern = null
|
|
451
|
+
let count = 0
|
|
452
|
+
if (match && Path.basename(dir) === match[1]) {
|
|
453
|
+
const parent = Path.dirname(dir)
|
|
454
|
+
pattern = `${parent}/<name>/<name>.${match[2]}.${match[3]}`
|
|
455
|
+
const re = new RegExp(`^${escape(parent)}/([^/]+)/\\1\\.${escape(match[2])}\\.${match[3]}$`)
|
|
456
|
+
count = paths.filter(path => re.test(path)).length
|
|
457
|
+
} else if (match) {
|
|
458
|
+
pattern = `${dir}/<name>.${match[2]}.${match[3]}`
|
|
459
|
+
const re = new RegExp(`^${escape(dir)}/[^/]+\\.${escape(match[2])}\\.${match[3]}$`)
|
|
460
|
+
count = paths.filter(path => re.test(path)).length
|
|
461
|
+
}
|
|
462
|
+
if (pattern && count >= 3 && !patterns.has(`${result.repo}\0${pattern}`)) {
|
|
463
|
+
patterns.set(`${result.repo}\0${pattern}`, { repo: result.repo, pattern, count })
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
return [...patterns.values()].slice(0, 5)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
470
|
+
|
|
471
|
+
module.exports = {
|
|
472
|
+
createSearch,
|
|
473
|
+
}
|
package/src/find/text.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const STOPWORDS = new Set(`
|
|
4
|
+
a an the and or but if then else when while of to in on at by for from with without into onto over under
|
|
5
|
+
is are was were be been being am do does did done has have had having can could should would will shall may might must
|
|
6
|
+
it its this that these those there here what which who whom whose where why how all any each every some such no not only
|
|
7
|
+
own same so than too very just also as i me my we our us you your he she they them their his her
|
|
8
|
+
please need needs want wants like get gets let lets use using used via per etc e g ie eg
|
|
9
|
+
`.split(/\s+/).filter(Boolean))
|
|
10
|
+
|
|
11
|
+
const stem = word => {
|
|
12
|
+
let w = word
|
|
13
|
+
if (w.length > 4 && w.endsWith('ies')) w = `${w.slice(0, -3)}y`
|
|
14
|
+
else if (w.length > 4 && /(ss|x|z|ch|sh)es$/.test(w)) w = w.slice(0, -2)
|
|
15
|
+
else if (w.length > 3 && w.endsWith('s') && !/(ss|us|is)$/.test(w)) w = w.slice(0, -1)
|
|
16
|
+
if ((w.length > 5 && w.endsWith('ing')) || (w.length > 4 && w.endsWith('ed'))) {
|
|
17
|
+
w = w.slice(0, w.endsWith('ing') ? -3 : -2)
|
|
18
|
+
if (w.length > 4 && /([^aeiouls])\1$/.test(w)) w = w.slice(0, -1)
|
|
19
|
+
}
|
|
20
|
+
if (w.length > 3 && w.endsWith('e')) w = w.slice(0, -1)
|
|
21
|
+
return w
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const words = value =>
|
|
25
|
+
String(value || '')
|
|
26
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
27
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.split(/[^a-z0-9]+/)
|
|
30
|
+
.filter(word => word && !/^\d+$/.test(word))
|
|
31
|
+
|
|
32
|
+
const splitTerms = value => {
|
|
33
|
+
const parts = words(value).filter(word => !STOPWORDS.has(word))
|
|
34
|
+
const single = parts.filter(word => word.length > 1).map(stem)
|
|
35
|
+
const original = new Map(parts.filter(word => word.length > 1).map(word => [stem(word), word]))
|
|
36
|
+
const joined = []
|
|
37
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
38
|
+
if (parts[i].length > 1 && parts[i + 1].length > 1) joined.push(stem(parts[i] + parts[i + 1]))
|
|
39
|
+
}
|
|
40
|
+
String(value || '')
|
|
41
|
+
.split(/[^A-Za-z0-9_]+/)
|
|
42
|
+
.forEach(token => {
|
|
43
|
+
const pieces = words(token)
|
|
44
|
+
if (pieces.length > 2) joined.push(stem(pieces.join('')))
|
|
45
|
+
})
|
|
46
|
+
return { single, joined, original }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const identifier = value => {
|
|
50
|
+
const pieces = words(value)
|
|
51
|
+
return pieces.length > 1 ? stem(pieces.join('')) : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const terms = value => {
|
|
55
|
+
const { single, joined } = splitTerms(value)
|
|
56
|
+
return [...single, ...joined]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const editDistance = (a, b, max) => {
|
|
60
|
+
if (Math.abs(a.length - b.length) > max) return max + 1
|
|
61
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
62
|
+
for (let i = 1; i <= a.length; i++) {
|
|
63
|
+
const current = [i]
|
|
64
|
+
let best = i
|
|
65
|
+
for (let j = 1; j <= b.length; j++) {
|
|
66
|
+
current[j] = Math.min(
|
|
67
|
+
previous[j] + 1,
|
|
68
|
+
current[j - 1] + 1,
|
|
69
|
+
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
70
|
+
)
|
|
71
|
+
best = Math.min(best, current[j])
|
|
72
|
+
}
|
|
73
|
+
if (best > max) return max + 1
|
|
74
|
+
previous = current
|
|
75
|
+
}
|
|
76
|
+
return previous[b.length]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
stem,
|
|
81
|
+
words,
|
|
82
|
+
terms,
|
|
83
|
+
splitTerms,
|
|
84
|
+
identifier,
|
|
85
|
+
editDistance,
|
|
86
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Crypto = require('crypto')
|
|
4
|
+
const Fs = require('fs')
|
|
5
|
+
const Path = require('path')
|
|
6
|
+
const { execFileSync } = require('child_process')
|
|
7
|
+
|
|
8
|
+
const git = (dir, args) => {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync('git', ['-C', dir, ...args], {
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
13
|
+
}).trim()
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isRepo = dir => Fs.existsSync(Path.join(dir, '.git'))
|
|
20
|
+
|
|
21
|
+
const findRepos = folder => {
|
|
22
|
+
if (isRepo(folder)) {
|
|
23
|
+
return [folder]
|
|
24
|
+
}
|
|
25
|
+
return Fs.readdirSync(folder, { withFileTypes: true })
|
|
26
|
+
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
27
|
+
.map(entry => Path.join(folder, entry.name))
|
|
28
|
+
.filter(isRepo)
|
|
29
|
+
.sort()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const remoteUrl = dir => git(dir, ['remote', 'get-url', 'origin'])
|
|
33
|
+
|
|
34
|
+
const defaultBranch = dir => {
|
|
35
|
+
const head = git(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
|
|
36
|
+
if (head) {
|
|
37
|
+
return head.replace(/^origin\//, '')
|
|
38
|
+
}
|
|
39
|
+
const main = ['master', 'main'].find(name => git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${name}`]))
|
|
40
|
+
if (main) {
|
|
41
|
+
return main
|
|
42
|
+
}
|
|
43
|
+
const current = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
44
|
+
return current && current !== 'HEAD' ? current : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FETCH_TIMEOUT = 60 * 1000
|
|
48
|
+
|
|
49
|
+
const fetchBranch = (dir, branch) => {
|
|
50
|
+
if (!branch) return false
|
|
51
|
+
try {
|
|
52
|
+
execFileSync('git', ['-C', dir, 'fetch', '--quiet', '--no-tags', 'origin', branch], {
|
|
53
|
+
stdio: 'ignore',
|
|
54
|
+
timeout: FETCH_TIMEOUT,
|
|
55
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' },
|
|
56
|
+
})
|
|
57
|
+
return true
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const normalizeGitUrl = url =>
|
|
64
|
+
String(url)
|
|
65
|
+
.trim()
|
|
66
|
+
.replace(/^[a-z+]+:\/\//i, '')
|
|
67
|
+
.replace(/^[^@/]+@/, '')
|
|
68
|
+
.replace(/:(?!\d+\/)/, '/')
|
|
69
|
+
.replace(/:\d+\//, '/')
|
|
70
|
+
.replace(/\/+$/, '')
|
|
71
|
+
.replace(/\.git$/i, '')
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
|
|
74
|
+
const firstCommit = dir => {
|
|
75
|
+
if (git(dir, ['rev-parse', '--is-shallow-repository']) !== 'false') {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
const roots = git(dir, ['rev-list', '--max-parents=0', 'HEAD'])
|
|
79
|
+
return roots ? roots.split('\n').sort()[0] : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const identify = dir => {
|
|
83
|
+
const url = remoteUrl(dir)
|
|
84
|
+
return {
|
|
85
|
+
fingerprint: firstCommit(dir),
|
|
86
|
+
remoteHash: url
|
|
87
|
+
? Crypto.createHash('sha256').update(normalizeGitUrl(url)).digest('hex')
|
|
88
|
+
: null,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hideCredentials = url => String(url).replace(/(https?:\/\/)[^@/]+@/i, '$1')
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
findRepos,
|
|
96
|
+
remoteUrl,
|
|
97
|
+
defaultBranch,
|
|
98
|
+
fetchBranch,
|
|
99
|
+
normalizeGitUrl,
|
|
100
|
+
identify,
|
|
101
|
+
hideCredentials,
|
|
102
|
+
}
|