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,82 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
const { execFileSync } = require('child_process')
|
|
6
|
+
|
|
7
|
+
const BIN = Path.join(__dirname, '..', '..', 'bin', 'tspl.js')
|
|
8
|
+
const HOOKS = ['pre-commit', 'pre-push']
|
|
9
|
+
const MARK = '# tspl edit lock'
|
|
10
|
+
|
|
11
|
+
const hooksDir = repoPath => {
|
|
12
|
+
const dir = execFileSync('git', ['-C', repoPath, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
13
|
+
return Path.isAbsolute(dir) ? dir : Path.join(repoPath, dir)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const script = hook =>
|
|
17
|
+
[
|
|
18
|
+
'#!/bin/sh',
|
|
19
|
+
MARK,
|
|
20
|
+
`export TSPL_HOME="${process.env.TSPL_HOME || Path.join(require('os').homedir(), '.tspl')}"`,
|
|
21
|
+
'prev="$0.tspl-prev"',
|
|
22
|
+
'if [ -x "$prev" ]; then "$prev" "$@" || exit $?; fi',
|
|
23
|
+
`NODE="${process.execPath}"`,
|
|
24
|
+
'[ -x "$NODE" ] || NODE=node',
|
|
25
|
+
`exec "$NODE" "${BIN}" guard ${hook} "$@"`,
|
|
26
|
+
'',
|
|
27
|
+
].join('\n')
|
|
28
|
+
|
|
29
|
+
const ours = file => {
|
|
30
|
+
try {
|
|
31
|
+
return Fs.readFileSync(file, 'utf8').includes(MARK)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const installHooks = repoPath => {
|
|
38
|
+
const dir = hooksDir(repoPath)
|
|
39
|
+
Fs.mkdirSync(dir, { recursive: true })
|
|
40
|
+
HOOKS.forEach(hook => {
|
|
41
|
+
const file = Path.join(dir, hook)
|
|
42
|
+
if (Fs.existsSync(file) && !ours(file)) Fs.renameSync(file, `${file}.tspl-prev`)
|
|
43
|
+
Fs.writeFileSync(file, script(hook), { mode: 0o755 })
|
|
44
|
+
Fs.chmodSync(file, 0o755)
|
|
45
|
+
})
|
|
46
|
+
return dir
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const removeHooks = repoPath => {
|
|
50
|
+
let dir
|
|
51
|
+
try {
|
|
52
|
+
dir = hooksDir(repoPath)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
let removed = false
|
|
57
|
+
HOOKS.forEach(hook => {
|
|
58
|
+
const file = Path.join(dir, hook)
|
|
59
|
+
if (ours(file)) {
|
|
60
|
+
Fs.rmSync(file, { force: true })
|
|
61
|
+
removed = true
|
|
62
|
+
}
|
|
63
|
+
if (Fs.existsSync(`${file}.tspl-prev`) && !Fs.existsSync(file)) Fs.renameSync(`${file}.tspl-prev`, file)
|
|
64
|
+
})
|
|
65
|
+
return removed
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const hooksInstalled = repoPath => {
|
|
69
|
+
try {
|
|
70
|
+
const dir = hooksDir(repoPath)
|
|
71
|
+
return HOOKS.every(hook => ours(Path.join(dir, hook)))
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
HOOKS,
|
|
79
|
+
installHooks,
|
|
80
|
+
removeHooks,
|
|
81
|
+
hooksInstalled,
|
|
82
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Os = require('os')
|
|
5
|
+
const Path = require('path')
|
|
6
|
+
const { hashValue, approachBasis } = require('../shared')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The plan drafted alongside the approaches, kept on this computer until the user chooses. It is only valid for the
|
|
10
|
+
* same requirements, the same offered options and the same code context; anything else plans afresh.
|
|
11
|
+
*/
|
|
12
|
+
const dirOf = () => Path.join(process.env.TSPL_HOME || Path.join(Os.homedir(), '.tspl'), 'plan-drafts')
|
|
13
|
+
const fileOf = (projectId, task) => Path.join(dirOf(), `${hashValue({ project: String(projectId), task: task.key || task.id })}.json`)
|
|
14
|
+
const fingerprint = (card, approaches, brief) => hashValue({
|
|
15
|
+
requirements: approachBasis(card),
|
|
16
|
+
options: approaches.options.map(option => [option.id, option.title, option.summary]),
|
|
17
|
+
recommendedId: approaches.recommendedId,
|
|
18
|
+
brief: brief || '',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const saveDraft = (projectId, task, proposal, plan, brief) => {
|
|
22
|
+
Fs.mkdirSync(dirOf(), { recursive: true, mode: 0o700 })
|
|
23
|
+
const file = fileOf(projectId, task)
|
|
24
|
+
Fs.writeFileSync(file, JSON.stringify({ fingerprint: fingerprint(task.card, proposal, brief), optionId: proposal.recommendedId, plan }), { mode: 0o600 })
|
|
25
|
+
Fs.chmodSync(file, 0o600)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns { optionId, plan } once, or null; a used or stale draft is removed so a re-plan never repeats it. */
|
|
29
|
+
const takeDraft = (projectId, task, brief) => {
|
|
30
|
+
const file = fileOf(projectId, task)
|
|
31
|
+
let draft
|
|
32
|
+
try {
|
|
33
|
+
draft = JSON.parse(Fs.readFileSync(file, 'utf8'))
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err.code === 'ENOENT' || err instanceof SyntaxError) return null
|
|
36
|
+
throw err
|
|
37
|
+
}
|
|
38
|
+
Fs.rmSync(file, { force: true })
|
|
39
|
+
if (!task.approaches || draft.fingerprint !== fingerprint(task.card, task.approaches, brief)) return null
|
|
40
|
+
return { optionId: draft.optionId, plan: draft.plan }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { saveDraft, takeDraft }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { byRepo, countLine, changesSince } = require('./plan')
|
|
4
|
+
|
|
5
|
+
const escape = value =>
|
|
6
|
+
String(value === undefined || value === null ? '' : value)
|
|
7
|
+
.replace(/&/g, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"')
|
|
11
|
+
|
|
12
|
+
const STATUS_LABELS = {
|
|
13
|
+
draft: 'No plan yet',
|
|
14
|
+
'plan-review': 'Waiting for the developer',
|
|
15
|
+
'pm-review': 'Waiting for the PM',
|
|
16
|
+
approved: 'Approved — ready to build',
|
|
17
|
+
'changes-requested': 'Changes requested',
|
|
18
|
+
building: 'Building',
|
|
19
|
+
done: 'Done',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const KIND_LABELS = { edit: 'edit', create: 'new', remove: 'remove' }
|
|
23
|
+
|
|
24
|
+
const approvalsHtml = task => {
|
|
25
|
+
const decision = stage => [...(task.decisions || [])].reverse().find(item => item.stage === stage && item.planVersion === (task.plan && task.plan.version))
|
|
26
|
+
const row = (stage, label) => {
|
|
27
|
+
const found = decision(stage)
|
|
28
|
+
if (!found) return `<li class="pending">${label}: waiting</li>`
|
|
29
|
+
const when = found.at ? new Date(found.at).toLocaleString() : ''
|
|
30
|
+
return `<li class="${found.approved ? 'yes' : 'no'}">${label}: ${found.approved ? 'approved' : 'asked for changes'} by ${escape(found.userName || 'someone')} ${escape(when)}${found.note ? ` — “${escape(found.note)}”` : ''}</li>`
|
|
31
|
+
}
|
|
32
|
+
return `<ul class="approvals">${row('developer', 'Developer')}${task.requirePmApproval === false ? '<li class="skip">PM: not required for this project</li>' : row('pm', 'PM')}</ul>`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const renderPlanPage = (task, projectName) => {
|
|
36
|
+
const plan = task.plan
|
|
37
|
+
const card = task.card || {}
|
|
38
|
+
const review = {
|
|
39
|
+
edit: (plan && plan.edit) || [],
|
|
40
|
+
create: (plan && plan.create) || [],
|
|
41
|
+
remove: (plan && plan.remove) || [],
|
|
42
|
+
tests: (plan && plan.tests) || [],
|
|
43
|
+
}
|
|
44
|
+
const testsFor = n => review.tests.filter(test => (test.checks || []).includes(n)).map(test => test.name)
|
|
45
|
+
const repos = []
|
|
46
|
+
byRepo(review).forEach((items, repo) => {
|
|
47
|
+
repos.push(`<section class="repo"><h3>${escape(repo)}</h3><ul class="files">${items
|
|
48
|
+
.map(item => `<li class="${item.kind}"><span class="badge ${item.kind}">${KIND_LABELS[item.kind]}</span><code>${escape(item.path)}</code>${item.why ? `<span class="why">${escape(item.why)}</span>` : ''}</li>`)
|
|
49
|
+
.join('')}</ul></section>`)
|
|
50
|
+
})
|
|
51
|
+
const versions = (task.plans || [])
|
|
52
|
+
.map((previous, i) => {
|
|
53
|
+
const next = i + 1 < (task.plans || []).length ? task.plans[i + 1] : plan
|
|
54
|
+
const changes = changesSince(previous, { edit: next.edit || [], create: next.create || [], remove: next.remove || [], tests: next.tests || [] })
|
|
55
|
+
return `<li>v${escape(previous.version)} → v${escape(next.version)}: ${changes.length ? changes.map(escape).join(', ') : 'no file changes'}</li>`
|
|
56
|
+
})
|
|
57
|
+
.join('')
|
|
58
|
+
const fields = [
|
|
59
|
+
['Who', card.who],
|
|
60
|
+
['Where', (card.apps || []).join(', ')],
|
|
61
|
+
['How it works', card.how],
|
|
62
|
+
['Steps', card.steps],
|
|
63
|
+
['Now', card.now],
|
|
64
|
+
['Expected', card.expected],
|
|
65
|
+
].filter(([, value]) => value)
|
|
66
|
+
|
|
67
|
+
return `<!doctype html>
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
+
<title>Plan ${escape(task.key)}</title>
|
|
73
|
+
<style>
|
|
74
|
+
:root { --bg: #f7f7f5; --card: #ffffff; --text: #1d1d1b; --muted: #6b6b66; --line: #e3e3de;
|
|
75
|
+
--edit: #b26b00; --edit-bg: #fff4e0; --new: #1f7a3a; --new-bg: #e6f5ea; --remove: #b3261e; --remove-bg: #fde8e6; --accent: #2f5bd3; }
|
|
76
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #151514; --card: #1f1f1d; --text: #ecece8; --muted: #a2a29b; --line: #34342f;
|
|
77
|
+
--edit: #f0b35a; --edit-bg: #3a2a10; --new: #6fd08c; --new-bg: #13301c; --remove: #ff8a80; --remove-bg: #3b1714; --accent: #8fb0ff; } }
|
|
78
|
+
* { box-sizing: border-box; }
|
|
79
|
+
body { margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
80
|
+
main { max-width: 960px; margin: 0 auto; }
|
|
81
|
+
h1 { font-size: 22px; margin: 4px 0 8px; } h2 { font-size: 16px; margin: 28px 0 10px; } h3 { font-size: 14px; margin: 0 0 8px; color: var(--muted); }
|
|
82
|
+
.meta { color: var(--muted); font-size: 13px; } .status { display: inline-block; padding: 2px 10px; border-radius: 999px; background: var(--card); border: 1px solid var(--line); font-weight: 600; }
|
|
83
|
+
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin: 12px 0; }
|
|
84
|
+
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; } dt { color: var(--muted); } dd { margin: 0; }
|
|
85
|
+
.count { font-weight: 600; margin-bottom: 8px; } .repo { margin-top: 14px; }
|
|
86
|
+
.files { list-style: none; padding: 0; margin: 0; } .files li { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; padding: 6px 0; border-top: 1px solid var(--line); }
|
|
87
|
+
code { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; } .why { color: var(--muted); flex-basis: 100%; padding-left: 64px; font-size: 13px; }
|
|
88
|
+
.badge { font-size: 11px; font-weight: 700; text-transform: uppercase; padding: 1px 8px; border-radius: 6px; min-width: 56px; text-align: center; }
|
|
89
|
+
.badge.edit { color: var(--edit); background: var(--edit-bg); } .badge.create { color: var(--new); background: var(--new-bg); } .badge.remove { color: var(--remove); background: var(--remove-bg); }
|
|
90
|
+
table { width: 100%; border-collapse: collapse; } td, th { text-align: left; padding: 6px 8px; border-top: 1px solid var(--line); vertical-align: top; } th { color: var(--muted); font-weight: 600; }
|
|
91
|
+
.missing { color: var(--remove); } ul.approvals { list-style: none; padding: 0; margin: 0; } .approvals li { padding: 4px 0; }
|
|
92
|
+
.approvals .yes::before { content: "✓ "; color: var(--new); } .approvals .no::before { content: "✗ "; color: var(--remove); } .approvals .pending::before { content: "○ "; color: var(--muted); } .approvals .skip { color: var(--muted); }
|
|
93
|
+
.warn li { color: var(--edit); } pre { white-space: pre-wrap; background: var(--bg); padding: 10px; border-radius: 8px; }
|
|
94
|
+
@media (max-width: 600px) { .why { padding-left: 0; } dl { grid-template-columns: 1fr; } }
|
|
95
|
+
</style>
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
<main>
|
|
99
|
+
<div class="meta">${escape(projectName)} · ${escape(task.key)}${task.ticket ? ` · ${escape(task.ticket)}` : ''} · ${escape(task.type || '')}${plan ? ` · plan v${escape(plan.version)}` : ''}</div>
|
|
100
|
+
<h1>${escape(task.title || card.what || 'Task')}</h1>
|
|
101
|
+
<span class="status">${escape(STATUS_LABELS[task.status] || task.status)}</span>
|
|
102
|
+
<div class="card"><h2 style="margin-top:0">Approvals</h2>${approvalsHtml(task)}</div>
|
|
103
|
+
${fields.length ? `<div class="card"><dl>${fields.map(([label, value]) => `<dt>${label}</dt><dd>${escape(value)}</dd>`).join('')}</dl></div>` : ''}
|
|
104
|
+
${plan ? `<h2>Files</h2><div class="card"><div class="count">${escape(countLine({ ...review, edit: review.edit, create: review.create, remove: review.remove }))}</div>${repos.join('') || '<p>No files.</p>'}</div>` : '<p>No plan submitted yet.</p>'}
|
|
105
|
+
${(card.doneWhen || []).length ? `<h2>Done when</h2><div class="card"><table><tr><th>#</th><th>Check</th><th>Tested by</th></tr>${card.doneWhen
|
|
106
|
+
.map((check, i) => `<tr><td>${i + 1}</td><td>${escape(check)}</td><td>${testsFor(i + 1).length ? testsFor(i + 1).map(escape).join('<br>') : '<span class="missing">no test yet</span>'}</td></tr>`)
|
|
107
|
+
.join('')}</table></div>` : ''}
|
|
108
|
+
${plan && (plan.screens || []).length ? `<h2>Screens</h2><div class="card"><ul>${plan.screens.map(screen => `<li>${escape(screen)}</li>`).join('')}</ul></div>` : ''}
|
|
109
|
+
${plan && plan.notes ? `<h2>Notes</h2><div class="card">${escape(plan.notes)}</div>` : ''}
|
|
110
|
+
${plan && (plan.warnings || []).length ? `<h2>tspl checks</h2><div class="card"><ul class="warn">${plan.warnings.map(warning => `<li>${escape(warning)}</li>`).join('')}</ul></div>` : ''}
|
|
111
|
+
${versions ? `<h2>Earlier versions</h2><div class="card"><ul>${versions}</ul></div>` : ''}
|
|
112
|
+
<h2>Decide</h2>
|
|
113
|
+
<div class="card"><pre>tspl plan approve ${escape(task.key)}
|
|
114
|
+
tspl plan reject ${escape(task.key)} --note "what should change"</pre></div>
|
|
115
|
+
</main>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
118
|
+
`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
renderPlanPage,
|
|
123
|
+
}
|
package/src/task/plan.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Path = require('path').posix
|
|
4
|
+
const { editDistance } = require('../find/text')
|
|
5
|
+
const { TEST_FILE } = require('../shared')
|
|
6
|
+
const { styleOf, majority } = require('./conventions')
|
|
7
|
+
const { findProjectConfig, snapshotProject } = require('../project-config')
|
|
8
|
+
|
|
9
|
+
const KIND_FILE = /^(.+?)\.([a-z][a-z0-9-]*)\.([a-z]+)$/
|
|
10
|
+
const USED_BY = new Set(['imports', 'uses', 'renders', 'calls-api', 'template'])
|
|
11
|
+
|
|
12
|
+
const keyOf = file => String(file || '').trim().replace(/^([^:/\s]+):\s*/, '$1/')
|
|
13
|
+
|
|
14
|
+
const items = (plan, kind) =>
|
|
15
|
+
(Array.isArray(plan[kind]) ? plan[kind] : [])
|
|
16
|
+
.map(item => (typeof item === 'string' ? { file: item } : item || {}))
|
|
17
|
+
.map(item => ({ file: keyOf(item.file), why: String(item.why || '').trim() }))
|
|
18
|
+
.filter(item => item.file)
|
|
19
|
+
|
|
20
|
+
const closestPath = (graph, key) => {
|
|
21
|
+
const repo = key.split('/')[0]
|
|
22
|
+
const base = Path.basename(key)
|
|
23
|
+
const candidates = graph.files
|
|
24
|
+
.map(file => ({ file, distance: editDistance(Path.basename(file.path), base, 2) + (file.repo === repo ? 0 : 1) }))
|
|
25
|
+
.filter(item => item.distance <= 2)
|
|
26
|
+
.sort((a, b) => a.distance - b.distance)
|
|
27
|
+
return candidates.length ? ` Did you mean ${candidates[0].file.repo}/${candidates[0].file.path}?` : ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const placementNote = (graph, key) => {
|
|
31
|
+
const repo = key.split('/')[0]
|
|
32
|
+
const path = key.slice(repo.length + 1)
|
|
33
|
+
const match = Path.basename(path).match(KIND_FILE)
|
|
34
|
+
if (!match) return null
|
|
35
|
+
const suffix = `.${match[2]}.${match[3]}`
|
|
36
|
+
const sameKind = graph.files.filter(file => file.repo === repo && file.path.endsWith(suffix))
|
|
37
|
+
if (!sameKind.length) return null
|
|
38
|
+
const parts = Path.dirname(path).split('/')
|
|
39
|
+
const area = parts.slice(0, Math.max(1, parts.length - 2)).join('/')
|
|
40
|
+
const nearby = sameKind.some(file => file.path.startsWith(`${area}/`))
|
|
41
|
+
if (nearby) return null
|
|
42
|
+
const folders = new Map()
|
|
43
|
+
sameKind.forEach(file => {
|
|
44
|
+
const folder = Path.basename(Path.dirname(file.path)) === file.path.split('/').pop().split('.')[0]
|
|
45
|
+
? Path.dirname(Path.dirname(file.path))
|
|
46
|
+
: Path.dirname(file.path)
|
|
47
|
+
folders.set(folder, (folders.get(folder) || 0) + 1)
|
|
48
|
+
})
|
|
49
|
+
const [folder, count] = [...folders.entries()].sort((a, b) => b[1] - a[1])[0]
|
|
50
|
+
return `Create ${key}: no *${suffix} files live near there; most (${count}) are in ${repo}/${folder}.`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const namingNote = (graph, key) => {
|
|
54
|
+
const repo = key.split('/')[0]
|
|
55
|
+
const match = Path.basename(key).match(KIND_FILE)
|
|
56
|
+
if (!match) return null
|
|
57
|
+
const suffix = `.${match[2]}.${match[3]}`
|
|
58
|
+
const sameKind = graph.files.filter(file => file.repo === repo && file.path.endsWith(suffix))
|
|
59
|
+
if (sameKind.length < 3) return null
|
|
60
|
+
const styles = sameKind.map(file => styleOf(Path.basename(file.path).match(KIND_FILE)[1])).filter(value => value && value !== 'lowercase')
|
|
61
|
+
const [style, count] = majority(styles)
|
|
62
|
+
const mine = styleOf(match[1])
|
|
63
|
+
if (!style || !mine || mine === 'lowercase' || mine === style || count < styles.length * 0.7) return null
|
|
64
|
+
const example = sameKind.map(file => file.path).sort((a, b) => a.length - b.length)[0]
|
|
65
|
+
return `Create ${key}: files like it are named in ${style} (for example ${example}); "${match[1]}" is ${mine}.`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** briefFiles: the files tspl's search found for the task; an edit outside them may mean the search, or the plan, is off. */
|
|
69
|
+
const reviewPlan = (plan, card, graph, { briefFiles = null } = {}) => {
|
|
70
|
+
const byKey = new Map(graph.files.map(file => [`${file.repo}/${file.path}`, file]))
|
|
71
|
+
const edit = items(plan, 'edit')
|
|
72
|
+
const create = items(plan, 'create')
|
|
73
|
+
const remove = items(plan, 'remove')
|
|
74
|
+
const planned = new Set([...edit, ...create, ...remove].map(item => item.file))
|
|
75
|
+
const removed = new Set(remove.map(item => item.file))
|
|
76
|
+
const warnings = []
|
|
77
|
+
const companions = []
|
|
78
|
+
|
|
79
|
+
const found = briefFiles && briefFiles.length ? new Set(briefFiles) : null
|
|
80
|
+
edit.forEach(item => {
|
|
81
|
+
if (!byKey.has(item.file)) warnings.push(`Edit ${item.file}: not in tspl's copy of the code (new, uncommitted, or a typo?).${closestPath(graph, item.file)}`)
|
|
82
|
+
else if (found && !found.has(item.file) && !TEST_FILE.test(item.file)) warnings.push(`Edit ${item.file}: tspl's search did not find this file for the task; check that the change belongs here, or reword the task so the search finds it.`)
|
|
83
|
+
if (!item.why) warnings.push(`Edit ${item.file}: say what changes in it.`)
|
|
84
|
+
})
|
|
85
|
+
create.forEach(item => {
|
|
86
|
+
if (byKey.has(item.file)) warnings.push(`Create ${item.file}: this file already exists; edit it instead?`)
|
|
87
|
+
else {
|
|
88
|
+
const note = placementNote(graph, item.file)
|
|
89
|
+
if (note) warnings.push(note)
|
|
90
|
+
const naming = namingNote(graph, item.file)
|
|
91
|
+
if (naming) warnings.push(naming)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
remove.forEach(item => {
|
|
95
|
+
const file = byKey.get(item.file)
|
|
96
|
+
if (!file) {
|
|
97
|
+
warnings.push(`Remove ${item.file}: not in tspl's copy of the code.${closestPath(graph, item.file)}`)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const users = graph.into[file.id]
|
|
101
|
+
.filter(edge => USED_BY.has(edge.type))
|
|
102
|
+
.map(edge => graph.files[edge.from])
|
|
103
|
+
.filter(user => !removed.has(`${user.repo}/${user.path}`))
|
|
104
|
+
if (users.length) {
|
|
105
|
+
warnings.push(`Remove ${item.file}: still used by ${users.slice(0, 3).map(user => `${user.repo}/${user.path}`).join(', ')}${users.length > 3 ? ` and ${users.length - 3} more` : ''}.`)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
edit.forEach(item => {
|
|
110
|
+
const file = byKey.get(item.file)
|
|
111
|
+
if (!file) return
|
|
112
|
+
const stem = Path.basename(file.path).split('.')[0]
|
|
113
|
+
const linked = [
|
|
114
|
+
...graph.out[file.id].filter(edge => edge.type === 'template' || edge.type === 'imports').map(edge => graph.files[edge.to]),
|
|
115
|
+
...graph.into[file.id].filter(edge => edge.type === 'template').map(edge => graph.files[edge.from]),
|
|
116
|
+
]
|
|
117
|
+
linked
|
|
118
|
+
.filter(other => other.repo === file.repo && !planned.has(`${other.repo}/${other.path}`))
|
|
119
|
+
.filter(other => Path.basename(other.path).split('.')[0] === stem)
|
|
120
|
+
.forEach(other => {
|
|
121
|
+
const key = `${other.repo}/${other.path}`
|
|
122
|
+
if (!companions.some(entry => entry.file === key)) companions.push({ file: key, with: item.file })
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const tests = (Array.isArray(plan.tests) ? plan.tests : [])
|
|
127
|
+
.map(test => (typeof test === 'string' ? { name: test } : test || {}))
|
|
128
|
+
.map(test => ({
|
|
129
|
+
name: String(test.name || '').trim(),
|
|
130
|
+
file: test.file ? keyOf(test.file) : '',
|
|
131
|
+
checks: (test.checks || []).map(Number).filter(Boolean),
|
|
132
|
+
...(test.command ? { command: String(test.command).trim() } : {}),
|
|
133
|
+
}))
|
|
134
|
+
.filter(test => test.name)
|
|
135
|
+
const covered = new Set(tests.flatMap(test => test.checks))
|
|
136
|
+
const testCases = (Array.isArray(plan.testCases) ? plan.testCases : [])
|
|
137
|
+
.map(item => (typeof item === 'string' ? { title: item } : item || {}))
|
|
138
|
+
.map(item => ({
|
|
139
|
+
title: String(item.title || '').trim(),
|
|
140
|
+
steps: (Array.isArray(item.steps) ? item.steps : []).map(step => String(step).trim()).filter(Boolean),
|
|
141
|
+
expected: String(item.expected || '').trim(),
|
|
142
|
+
}))
|
|
143
|
+
.filter(item => item.title)
|
|
144
|
+
.slice(0, 30)
|
|
145
|
+
const doneWhen = (card.doneWhen || []).length ? card.doneWhen : testCases.map(item => item.title)
|
|
146
|
+
if (!(card.doneWhen || []).length && !testCases.length) warnings.push('The task has no test cases: propose them (testCases) so each can be proven.')
|
|
147
|
+
const uncovered = doneWhen.map((check, i) => ({ check, n: i + 1 })).filter(item => !covered.has(item.n))
|
|
148
|
+
uncovered.forEach(item => warnings.push(`Done-when ${item.n} "${item.check}" has no test.`))
|
|
149
|
+
if (!edit.length && !create.length && !remove.length) warnings.push('The plan changes no files.')
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
edit,
|
|
153
|
+
create,
|
|
154
|
+
remove,
|
|
155
|
+
screens: (Array.isArray(plan.screens) ? plan.screens : []).map(String).filter(Boolean),
|
|
156
|
+
tests,
|
|
157
|
+
notes: String(plan.notes || '').trim(),
|
|
158
|
+
...(testCases.length && !(card.doneWhen || []).length ? { testCases } : {}),
|
|
159
|
+
warnings,
|
|
160
|
+
companions,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const MARKS = { edit: '✎', create: '➕', remove: '➖' }
|
|
165
|
+
|
|
166
|
+
const byRepo = review => {
|
|
167
|
+
const repos = new Map()
|
|
168
|
+
;['edit', 'create', 'remove'].forEach(kind => {
|
|
169
|
+
review[kind].forEach(item => {
|
|
170
|
+
const repo = item.file.split('/')[0]
|
|
171
|
+
if (!repos.has(repo)) repos.set(repo, [])
|
|
172
|
+
repos.get(repo).push({ ...item, kind, path: item.file.slice(repo.length + 1) })
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
return repos
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const countLine = review => {
|
|
179
|
+
const total = review.edit.length + review.create.length + review.remove.length
|
|
180
|
+
return `${total} file${total === 1 ? '' : 's'} in ${byRepo(review).size} repo${byRepo(review).size === 1 ? '' : 's'}: ${MARKS.edit} ${review.edit.length} edit ${MARKS.create} ${review.create.length} new ${MARKS.remove} ${review.remove.length} remove`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const renderChecklist = (review, card, taskId) => {
|
|
184
|
+
const testsFor = n => review.tests.filter(test => test.checks.includes(n)).map(test => test.name)
|
|
185
|
+
const files = []
|
|
186
|
+
byRepo(review).forEach((items, repo) => {
|
|
187
|
+
files.push(repo)
|
|
188
|
+
items.forEach(item => files.push(` ${MARKS[item.kind]} ${item.path}${item.why ? ` — ${item.why}` : ''}`))
|
|
189
|
+
})
|
|
190
|
+
return [
|
|
191
|
+
`PLAN ${taskId}${card.ticket ? ` · ${card.ticket}` : ''} · ${card.type} · ${card.what}`,
|
|
192
|
+
review.approach ? `Selected approach: ${review.approach.option ? review.approach.option.title : review.approach.optionId} [${review.approach.optionId}] (v${review.approach.version})` : null,
|
|
193
|
+
card.apps.length ? `Where: ${card.apps.join(', ')}${card.screen ? ` · screen: ${card.screen}` : ''}` : null,
|
|
194
|
+
'',
|
|
195
|
+
countLine(review),
|
|
196
|
+
...(files.length ? files : [' (no files)']),
|
|
197
|
+
...(review.screens.length ? ['', 'SCREENS', ...review.screens.map(screen => ` • ${screen}`)] : []),
|
|
198
|
+
'',
|
|
199
|
+
`TESTS (${review.tests.length})`,
|
|
200
|
+
...(review.tests.length
|
|
201
|
+
? review.tests.map(test => ` ☐ ${test.name}${test.file ? ` (${test.file})` : ''}${test.command ? ` $ ${test.command}` : ''}${test.checks.length ? ` → done-when ${test.checks.join(', ')}` : ''}`)
|
|
202
|
+
: [' —']),
|
|
203
|
+
...(card.doneWhen.length
|
|
204
|
+
? ['', 'DONE WHEN', ...card.doneWhen.map((check, i) => ` ${i + 1}. ${check}${testsFor(i + 1).length ? '' : ' ⚠ no test yet'}`)]
|
|
205
|
+
: []),
|
|
206
|
+
...(review.notes ? ['', 'NOTES', ` ${review.notes}`] : []),
|
|
207
|
+
...(review.companions.length
|
|
208
|
+
? ['', 'OFTEN CHANGED TOGETHER (check if needed)', ...review.companions.map(entry => ` ? ${entry.file} (with ${entry.with.split('/').pop()})`)]
|
|
209
|
+
: []),
|
|
210
|
+
'',
|
|
211
|
+
...(review.warnings.length
|
|
212
|
+
? ['TSPL CHECKS', ...review.warnings.map(warning => ` ⚠ ${warning}`)]
|
|
213
|
+
: ['TSPL CHECKS', ' ✓ files exist, new files sit with similar files, nothing removed is still used, every done-when has a test']),
|
|
214
|
+
]
|
|
215
|
+
.filter(line => line !== null)
|
|
216
|
+
.join('\n')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const changesSince = (previous, review) => {
|
|
220
|
+
if (!previous) return []
|
|
221
|
+
const lines = []
|
|
222
|
+
;['edit', 'create', 'remove'].forEach(kind => {
|
|
223
|
+
const before = new Set((previous[kind] || []).map(item => item.file))
|
|
224
|
+
const after = new Set(review[kind].map(item => item.file))
|
|
225
|
+
after.forEach(file => !before.has(file) && lines.push(`+ ${kind} ${file}`))
|
|
226
|
+
before.forEach(file => !after.has(file) && lines.push(`- ${kind} ${file}`))
|
|
227
|
+
})
|
|
228
|
+
const testsBefore = new Set((previous.tests || []).map(test => test.name))
|
|
229
|
+
review.tests.filter(test => !testsBefore.has(test.name)).forEach(test => lines.push(`+ test ${test.name}`))
|
|
230
|
+
;(previous.tests || []).filter(test => !review.tests.some(item => item.name === test.name)).forEach(test => lines.push(`- test ${test.name}`))
|
|
231
|
+
return lines
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const testParser = command => {
|
|
235
|
+
if (/\bplaywright\s+test\b/.test(command)) return 'playwright-json'
|
|
236
|
+
if (/node\s+(--[\w-]+\s+)*--test\b/.test(command)) return 'node-tap'
|
|
237
|
+
if (/\bjest\b/.test(command)) return 'jest-json'
|
|
238
|
+
// Vitest's JSON reporter writes the same summary as Jest's
|
|
239
|
+
if (/\bvitest\b/.test(command)) return 'jest-json'
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const withReporter = (command, parser) => {
|
|
244
|
+
if (parser === 'playwright-json' && !/--reporter[ =]json/.test(command)) return `${command} --reporter=json`
|
|
245
|
+
if (parser === 'jest-json' && /\bvitest\b/.test(command) && !/--reporter[ =]json/.test(command)) return `${command} --reporter=json`
|
|
246
|
+
return command
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Freezes the project's checks, the plan's own runnable tests and the rules into the plan. A test that tspl cannot
|
|
251
|
+
* parse (or one with no command) is not executable evidence, so the done-when items it covers need a human check.
|
|
252
|
+
* Folders without a project file keep the legacy plan unchanged.
|
|
253
|
+
*/
|
|
254
|
+
const withSnapshot = (review, card, cwd) => {
|
|
255
|
+
const found = findProjectConfig(cwd)
|
|
256
|
+
if (!found) return review
|
|
257
|
+
const { root, config } = found
|
|
258
|
+
const names = config.repos.map(repo => repo.name)
|
|
259
|
+
const checks = []
|
|
260
|
+
review.tests.forEach((test, i) => {
|
|
261
|
+
const repo = test.file ? test.file.split('/')[0] : null
|
|
262
|
+
const parser = test.command ? testParser(test.command) : null
|
|
263
|
+
if (!parser || !names.includes(repo)) return
|
|
264
|
+
const browser = parser === 'playwright-json'
|
|
265
|
+
checks.push({
|
|
266
|
+
id: `task-test-${i + 1}`,
|
|
267
|
+
repo,
|
|
268
|
+
cwd: '.',
|
|
269
|
+
category: browser ? 'e2e' : 'unit',
|
|
270
|
+
command: withReporter(test.command, parser),
|
|
271
|
+
...(browser ? { artifacts: 'test-results' } : {}),
|
|
272
|
+
timeoutMs: config.workflow.checkTimeoutMs,
|
|
273
|
+
required: true,
|
|
274
|
+
parser,
|
|
275
|
+
attested: false,
|
|
276
|
+
criteria: test.checks,
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
const acceptance = (card.doneWhen || []).length || (review.testCases || []).length
|
|
280
|
+
const covered = new Set([...config.checks, ...checks].filter(check => check.required).flatMap(check => check.criteria || []))
|
|
281
|
+
const manualCriteria = Array.from({ length: acceptance }, (_, i) => i + 1).filter(n => !covered.has(n))
|
|
282
|
+
return { ...review, workflow: snapshotProject(root, config, { checks, manualCriteria, acceptance }) }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
withSnapshot,
|
|
287
|
+
reviewPlan,
|
|
288
|
+
renderChecklist,
|
|
289
|
+
changesSince,
|
|
290
|
+
byRepo,
|
|
291
|
+
countLine,
|
|
292
|
+
MARKS,
|
|
293
|
+
}
|