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,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
+ }
@@ -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
+ }