@marcomigozzi/trailhead 0.17.0 → 0.18.0
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.it.md +28 -18
- package/README.md +28 -18
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/engine-route-docs.test.js +7 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +8 -8
- package/bin/lib/codex-projection.test.js +19 -3
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +9 -4
- package/bin/trailhead.test.js +18 -6
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-grill.md +2 -2
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +30 -14
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
- package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
- package/plugins/trailhead/skills/_shared/substrate.md +3 -2
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +2 -1
- package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
- package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
|
@@ -5,6 +5,8 @@ import { atom, read, update } from 'claude-code'
|
|
|
5
5
|
import type { EngineInterface } from 'claude-code'
|
|
6
6
|
|
|
7
7
|
import type { RepoPin } from '../../types'
|
|
8
|
+
import { gitArgv } from './git-env'
|
|
9
|
+
import { parseWorktreeBranches } from './worktree-porcelain'
|
|
8
10
|
import { sameValue } from './work-state-view'
|
|
9
11
|
|
|
10
12
|
type RepoRead = { root: string; remote: string | null }
|
|
@@ -71,7 +73,7 @@ let remembered: RepoPin | null = null
|
|
|
71
73
|
// the caller falls back.
|
|
72
74
|
export async function toplevelOf($: EngineInterface): Promise<string | null> {
|
|
73
75
|
try {
|
|
74
|
-
const out = await $.process.run(
|
|
76
|
+
const out = await $.process.run(gitArgv('rev-parse', '--show-toplevel'), { cwd: await $.session.cwd(), timeoutMs: 5_000 })
|
|
75
77
|
const top = out.stdout.trim()
|
|
76
78
|
return out.exitCode === 0 && top.startsWith('/') ? top : null
|
|
77
79
|
} catch {
|
|
@@ -95,9 +97,9 @@ async function gitRepo($: EngineInterface): Promise<{ repo: RepoRead; toplevel:
|
|
|
95
97
|
try {
|
|
96
98
|
const cwd = await $.session.cwd()
|
|
97
99
|
const run = (argv: string[]) => $.process.run(argv, { cwd, timeoutMs: 5_000 })
|
|
98
|
-
const top = await run(
|
|
100
|
+
const top = await run(gitArgv('rev-parse', '--show-toplevel'))
|
|
99
101
|
if (repoFromGit(top, null) === null) return null
|
|
100
|
-
const repo = repoFromGit(top, await run(
|
|
102
|
+
const repo = repoFromGit(top, await run(gitArgv('remote', 'get-url', 'origin')))
|
|
101
103
|
return repo === null ? null : { repo, toplevel: repo.root }
|
|
102
104
|
} catch {
|
|
103
105
|
return null
|
|
@@ -139,10 +141,107 @@ async function isServed($: EngineInterface, host: string, cwd: string): Promise<
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
// The pinned checkout deleted under the session (a finished worktree, a removed clone): the pin moves to a live
|
|
145
|
+
// checkout of the same repo (#347). Preferred in order: the main working tree, another worktree, the sibling
|
|
146
|
+
// clone itself. Candidates are the repo's root as the host knows it and the folders beside the dead root; every
|
|
147
|
+
// probe is `git -C <path>` from `/`, never a spawn in the dead directory. null when nothing is left.
|
|
148
|
+
const MAX_SIBLINGS = 100
|
|
149
|
+
const parentOf = (path: string): string => path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
|
|
150
|
+
|
|
151
|
+
async function checkoutOf($: EngineInterface, path: string, key: string): Promise<string | null> {
|
|
152
|
+
try {
|
|
153
|
+
const run = (args: string[]) => $.process.run(gitArgv('-C', path, ...args), { cwd: '/', timeoutMs: 5_000 })
|
|
154
|
+
const top = await run(['rev-parse', '--show-toplevel'])
|
|
155
|
+
if (top.exitCode !== 0 || !top.stdout.trim().startsWith('/')) return null
|
|
156
|
+
const origin = await run(['remote', 'get-url', 'origin'])
|
|
157
|
+
const id = remoteId(origin.exitCode === 0 ? origin.stdout : null)
|
|
158
|
+
return id !== null && `${id.host}/${id.slug}` === key ? top.stdout.trim() : null
|
|
159
|
+
} catch {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function exists($: EngineInterface, path: string): Promise<boolean> {
|
|
165
|
+
try {
|
|
166
|
+
return await $.fs.exists(path)
|
|
167
|
+
} catch {
|
|
168
|
+
return true // unreadable: do not move the pin on a guess
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function siblingsOf($: EngineInterface, dir: string): Promise<string[]> {
|
|
173
|
+
try {
|
|
174
|
+
const entries = await $.fs.list(dir)
|
|
175
|
+
return entries.filter(e => e.kind === 'dir' && !e.name.startsWith('.')).slice(0, MAX_SIBLINGS).map(e => `${dir.replace(/\/+$/, '')}/${e.name}`)
|
|
176
|
+
} catch {
|
|
177
|
+
return []
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function liveCheckout($: EngineInterface, pin: RepoPin): Promise<string | null> {
|
|
182
|
+
const key = pinKey(pin)
|
|
183
|
+
const seeds: string[] = []
|
|
184
|
+
try {
|
|
185
|
+
const host = await $.session.repo()
|
|
186
|
+
if (host !== null && host.root !== pin.root) seeds.push(host.root)
|
|
187
|
+
} catch {
|
|
188
|
+
// The host read failed: the neighbours still answer.
|
|
189
|
+
}
|
|
190
|
+
seeds.push(...(await siblingsOf($, parentOf(pin.root))))
|
|
191
|
+
for (const seed of seeds) {
|
|
192
|
+
if (seed === pin.root || !(await exists($, seed))) continue
|
|
193
|
+
const top = await checkoutOf($, seed, key)
|
|
194
|
+
if (top === null) continue
|
|
195
|
+
// The seed's own worktree list names the main tree first, then the others.
|
|
196
|
+
const listed = await $.process.run(gitArgv('-C', top, 'worktree', 'list', '--porcelain'), { cwd: '/', timeoutMs: 5_000 }).catch(() => null)
|
|
197
|
+
const paths = listed !== null && listed.exitCode === 0 ? parseWorktreeBranches(listed.stdout).map(w => w.path) : []
|
|
198
|
+
for (const path of [...paths, top]) {
|
|
199
|
+
if (path === pin.root || !(await exists($, path))) continue
|
|
200
|
+
const checkout = path === top ? top : await checkoutOf($, path, key)
|
|
201
|
+
if (checkout !== null) return checkout
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The pin as it stands, or the live checkout of its repo when its root is gone. Both outcomes are cached per
|
|
208
|
+
// repo and dead root, since the host's memoized repo keeps naming the dead root and every refresh asks again: a hit
|
|
209
|
+
// is revalidated by one exists check, a miss is not searched again within RELOCATE_RETRY_MS (a search spawns git
|
|
210
|
+
// per sibling). Reset at a session start.
|
|
211
|
+
const RELOCATE_RETRY_MS = 60_000
|
|
212
|
+
const missed = new Map<string, number>()
|
|
213
|
+
const relocated = new Map<string, string>()
|
|
214
|
+
const relocationKey = (pin: RepoPin): string => `${pinKey(pin)}|${pin.root}`
|
|
215
|
+
|
|
216
|
+
export async function livePin($: EngineInterface, pin: RepoPin): Promise<RepoPin> {
|
|
217
|
+
if (await exists($, pin.root)) return pin
|
|
218
|
+
const key = relocationKey(pin)
|
|
219
|
+
const known = relocated.get(key)
|
|
220
|
+
if (known !== undefined) {
|
|
221
|
+
if (await exists($, known)) return { ...pin, root: known }
|
|
222
|
+
relocated.delete(key)
|
|
223
|
+
}
|
|
224
|
+
const now = await $.clock.now().catch(() => null)
|
|
225
|
+
const last = missed.get(key)
|
|
226
|
+
if (now !== null && last !== undefined && now - last < RELOCATE_RETRY_MS) return pin
|
|
227
|
+
const root = await liveCheckout($, pin).catch(() => null)
|
|
228
|
+
if (root === null) {
|
|
229
|
+
if (now !== null) missed.set(key, now)
|
|
230
|
+
return pin
|
|
231
|
+
}
|
|
232
|
+
missed.delete(key)
|
|
233
|
+
relocated.set(key, root)
|
|
234
|
+
return { ...pin, root }
|
|
235
|
+
}
|
|
236
|
+
|
|
142
237
|
// Checks the working copy now and pins what it finds; a failed read keeps the last pin. The host's
|
|
143
238
|
// `session.repo()` can stay null for a repo created mid-session, so git in the cwd answers then (#299). A session
|
|
144
239
|
// start begins from nothing: the pin of an earlier session is not this one's.
|
|
145
240
|
export async function refreshPin($: EngineInterface, init: { isStart?: boolean; onMove?: (pin: RepoPin | null) => Promise<void> } = {}): Promise<RepoPin | null> {
|
|
241
|
+
if (init.isStart === true) {
|
|
242
|
+
missed.clear()
|
|
243
|
+
relocated.clear()
|
|
244
|
+
}
|
|
146
245
|
const stored = await read($, repoPin)
|
|
147
246
|
const current = init.isStart === true ? null : (stored ?? remembered)
|
|
148
247
|
let repo: RepoRead | null = null
|
|
@@ -159,7 +258,9 @@ export async function refreshPin($: EngineInterface, init: { isStart?: boolean;
|
|
|
159
258
|
} catch {
|
|
160
259
|
// Unreadable repo: keep the last pin.
|
|
161
260
|
}
|
|
162
|
-
const
|
|
261
|
+
const next = nextPin(current, repo, toplevel)
|
|
262
|
+
// The pin, kept or freshly read (the host's memoized repo can keep naming a deleted root), may be gone.
|
|
263
|
+
const pin = next === null ? null : await livePin($, next)
|
|
163
264
|
remembered = pin
|
|
164
265
|
if (!sameValue(stored, pin)) {
|
|
165
266
|
// Right before the atom changes, whoever moves it, so what was read for the old pin goes no later than the change.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// may sit in the main checkout. Pure parts first, then the engine part that looks the state up.
|
|
4
4
|
import type { EngineInterface } from 'claude-code'
|
|
5
5
|
|
|
6
|
+
import { gitArgv } from './git-env'
|
|
6
7
|
import { isMissing, parseWorkState } from './work-state-view'
|
|
7
8
|
import { MAX_FAILURES, parseWorktreeBranches } from './worktree-porcelain'
|
|
8
9
|
|
|
@@ -91,7 +92,7 @@ async function nowOf($: EngineInterface): Promise<number | null> {
|
|
|
91
92
|
// null: the listing itself failed (not the same as an empty one). Path and branch of each checkout.
|
|
92
93
|
export async function listWorktreeBranches($: EngineInterface, root: string): Promise<{ path: string; branch: string | null }[] | null> {
|
|
93
94
|
try {
|
|
94
|
-
const out = await $.process.run(
|
|
95
|
+
const out = await $.process.run(gitArgv('worktree', 'list', '--porcelain'), { cwd: root, timeoutMs: LOOKUP_TIMEOUT_MS })
|
|
95
96
|
return out.exitCode === 0 ? parseWorktreeBranches(out.stdout) : null
|
|
96
97
|
} catch {
|
|
97
98
|
return null
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// trailhead-bash-guards.js: PreToolUse(Bash) hook.
|
|
3
|
-
// One process for the
|
|
4
|
-
// instead of
|
|
3
|
+
// One process for the seven Bash guards (commit, secret, install, search, body, labels, test)
|
|
4
|
+
// instead of seven: each Bash call pays a single node start-up. The guards keep
|
|
5
5
|
// their own modules and verdicts; this runner only calls each one's pure
|
|
6
|
-
// evaluate(cmd) in a fixed order (commit, secret, install, search, body, labels).
|
|
6
|
+
// evaluate(cmd) in a fixed order (commit, secret, install, search, body, labels, test).
|
|
7
7
|
// - First block wins: its payload goes to stdout and the hook exits 2; later
|
|
8
8
|
// guards do not run. An earlier advisory keeps its stderr line.
|
|
9
9
|
// - No block: advisories (secret-guard's "could not scan") surface on stderr
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// - Failure posture is unchanged per guard: a throwing guard counts as allow
|
|
12
12
|
// and the others still run; a guard module that fails to load is skipped
|
|
13
13
|
// with a one-line stderr warning.
|
|
14
|
-
// - --guards=<name,...> picks the guards (default: all
|
|
15
|
-
// ships no search-guard and registers --guards=commit,secret,install,body,labels.
|
|
16
|
-
// A missing, empty, malformed or unknown-name list falls back to all
|
|
14
|
+
// - --guards=<name,...> picks the guards (default: all seven). The Codex install
|
|
15
|
+
// ships no search-guard and registers --guards=commit,secret,install,body,labels,test.
|
|
16
|
+
// A missing, empty, malformed or unknown-name list falls back to all seven, so
|
|
17
17
|
// a config typo never disables a guard.
|
|
18
18
|
// - TRAILHEAD_MOD_GUARDS=<name,...>@<epoch-ms>: the mod (which has ported those
|
|
19
19
|
// guards) stamps the guards it already ran; a stamp no older than
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// Crash-safe: unparseable input or any internal error -> exit 0.
|
|
23
23
|
// Self-contained, no dependencies. Pure functions are exported for tests.
|
|
24
24
|
|
|
25
|
-
const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body', 'labels'];
|
|
25
|
+
const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body', 'labels', 'test'];
|
|
26
26
|
const MOD_GUARDS_FRESH_MS = 5000;
|
|
27
27
|
const MOD_GUARDS_RE = /^([a-z][a-z0-9-]*(?:,[a-z][a-z0-9-]*)*)@(\d+)$/;
|
|
28
28
|
|
|
@@ -37,7 +37,7 @@ function parseModGuards(value, now = Date.now()) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Guards selected by --guards=<name,...>, in canonical order. Anything but a
|
|
40
|
-
// well-formed list of known names means all
|
|
40
|
+
// well-formed list of known names means all seven.
|
|
41
41
|
function parseGuardsArg(argv) {
|
|
42
42
|
const arg = argv.find((a) => a.startsWith('--guards='));
|
|
43
43
|
if (!arg) return GUARD_ORDER;
|
|
@@ -19,7 +19,7 @@ const eq = (name, actual, expected) => { assert.deepStrictEqual(actual, expected
|
|
|
19
19
|
// Run the merged hook end-to-end: tool_input JSON on stdin, capture {code, out, err}.
|
|
20
20
|
function runHook(command, { env = {}, args = [], hook = HOOK, cwd } = {}) {
|
|
21
21
|
const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
|
|
22
|
-
const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, ...env } });
|
|
22
|
+
const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, CLAUDE_CONFIG_DIR: NO_GLOBAL, ...env } });
|
|
23
23
|
return { code: r.status, out: r.stdout.toString(), err: r.stderr.toString() };
|
|
24
24
|
}
|
|
25
25
|
const codeOf = (r) => (r.out ? JSON.parse(r.out).code : undefined);
|
|
@@ -31,9 +31,20 @@ const BAD_INSTALL = 'npm install left-pad';
|
|
|
31
31
|
const BAD_SEARCH = 'cd x && grep y z';
|
|
32
32
|
const BAD_BODY = 'gh issue edit 5 --body ""';
|
|
33
33
|
const BAD_LABELS = 'gh issue create --title x --body y --label trailhead:bug';
|
|
34
|
+
const BAD_TEST = 'npm test 2>&1 | tail -20';
|
|
35
|
+
|
|
36
|
+
// A scratch trailhead repo whose config sets testing.full, and an empty global config dir so
|
|
37
|
+
// the developer's own config never leaks into the probes.
|
|
38
|
+
const SCRATCH = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-test-guard-'));
|
|
39
|
+
const TEST_REPO = path.join(SCRATCH, 'repo');
|
|
40
|
+
fs.mkdirSync(path.join(TEST_REPO, '.git'), { recursive: true });
|
|
41
|
+
fs.mkdirSync(path.join(TEST_REPO, '.trailhead'));
|
|
42
|
+
fs.writeFileSync(path.join(TEST_REPO, '.trailhead', 'config.json'), JSON.stringify({ testing: { full: 'npm test' } }));
|
|
43
|
+
const NO_GLOBAL = path.join(SCRATCH, 'no-global');
|
|
44
|
+
fs.mkdirSync(NO_GLOBAL);
|
|
34
45
|
|
|
35
46
|
// --- unit: constants ---
|
|
36
|
-
eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body', 'labels']);
|
|
47
|
+
eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body', 'labels', 'test']);
|
|
37
48
|
ok('freshness window is 5s', MOD_GUARDS_FRESH_MS === 5000);
|
|
38
49
|
|
|
39
50
|
// --- unit: parseModGuards ---
|
|
@@ -49,10 +60,10 @@ for (const bad of ['commit', '@123', 'commit@abc', ',@1', '', undefined, 'commit
|
|
|
49
60
|
eq('unknown names are ignored', [...parseModGuards(`commit,bogus@${NOW}`, NOW)], ['commit']);
|
|
50
61
|
|
|
51
62
|
// --- unit: parseGuardsArg ---
|
|
52
|
-
eq('no --guards -> all
|
|
63
|
+
eq('no --guards -> all seven', parseGuardsArg([]), GUARD_ORDER);
|
|
53
64
|
eq('valid subset keeps canonical order', parseGuardsArg(['--guards=body,commit']), ['commit', 'body']);
|
|
54
|
-
eq('empty --guards -> all
|
|
55
|
-
eq('malformed --guards -> all
|
|
65
|
+
eq('empty --guards -> all seven', parseGuardsArg(['--guards=']), GUARD_ORDER);
|
|
66
|
+
eq('malformed --guards -> all seven', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
|
|
56
67
|
eq('any unknown name invalidates the whole list', parseGuardsArg(['--guards=commit,secert']), GUARD_ORDER);
|
|
57
68
|
|
|
58
69
|
// --- unit: runGuards with fake evaluators ---
|
|
@@ -66,7 +77,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
66
77
|
evaluators: {
|
|
67
78
|
commit: spy('commit', () => { throw new Error('boom'); }),
|
|
68
79
|
secret: spy('secret', allow), install: spy('install', blocker('I')),
|
|
69
|
-
search: spy('search', blocker('S')), body: spy('body', allow), labels: spy('labels', allow),
|
|
80
|
+
search: spy('search', blocker('S')), body: spy('body', allow), labels: spy('labels', allow), test: spy('test', allow),
|
|
70
81
|
},
|
|
71
82
|
});
|
|
72
83
|
eq('a throwing guard counts as allow and the next one still blocks', r.code, 2);
|
|
@@ -76,7 +87,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
76
87
|
{
|
|
77
88
|
const r = runGuards('x', {
|
|
78
89
|
now: NOW, env: { TRAILHEAD_MOD_GUARDS: `commit@${NOW}` },
|
|
79
|
-
evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow, labels: allow },
|
|
90
|
+
evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow, labels: allow, test: allow },
|
|
80
91
|
});
|
|
81
92
|
eq('a fresh stamp skips the listed guard', r.code, 0);
|
|
82
93
|
}
|
|
@@ -84,7 +95,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
|
|
|
84
95
|
const adv = (t) => () => ({ kind: 'advise', stderr: `${t}\n`, payload: { hookSpecificOutput: { additionalContext: t } } });
|
|
85
96
|
const r = runGuards('x', {
|
|
86
97
|
now: NOW, env: {},
|
|
87
|
-
evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow, labels: allow },
|
|
98
|
+
evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow, labels: allow, test: allow },
|
|
88
99
|
});
|
|
89
100
|
eq('advisories exit 0', r.code, 0);
|
|
90
101
|
eq('advisory stderr lines are all written', r.stderr, 'a\nb\n');
|
|
@@ -98,11 +109,13 @@ const { evaluate: evInstall } = require('./trailhead-install-guard.js');
|
|
|
98
109
|
const { evaluate: evSearch } = require('./trailhead-search-guard.js');
|
|
99
110
|
const { evaluate: evBody } = require('./trailhead-body-guard.js');
|
|
100
111
|
const { evaluate: evLabels } = require('./trailhead-labels-guard.js');
|
|
112
|
+
const { evaluate: evTest } = require('./trailhead-test-guard.js');
|
|
101
113
|
for (const [name, cmd, ev] of [
|
|
102
114
|
['commit', BAD_COMMIT, evCommit], ['secret', BAD_SECRET, evSecret], ['install', BAD_INSTALL, evInstall],
|
|
103
115
|
['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody], ['labels', BAD_LABELS, evLabels],
|
|
116
|
+
['test', BAD_TEST, (c) => evTest(c, { cwd: TEST_REPO, env: { CLAUDE_CONFIG_DIR: NO_GLOBAL } })],
|
|
104
117
|
]) {
|
|
105
|
-
const r = runHook(cmd);
|
|
118
|
+
const r = runHook(cmd, name === 'test' ? { cwd: TEST_REPO } : {});
|
|
106
119
|
const want = ev(cmd);
|
|
107
120
|
ok(`${name}: evaluator blocks the probe`, want.kind === 'block');
|
|
108
121
|
eq(`${name}: exit 2`, r.code, 2);
|
|
@@ -171,19 +184,19 @@ for (const [name, cmd, ev] of [
|
|
|
171
184
|
const hook = path.join(tmp, 'trailhead-bash-guards.js');
|
|
172
185
|
fs.rmSync(path.join(tmp, 'trailhead-search-guard.js'));
|
|
173
186
|
|
|
174
|
-
const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body,labels'] });
|
|
187
|
+
const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body,labels,test'] });
|
|
175
188
|
eq('--guards without search never loads search-guard', codex.code, 0);
|
|
176
189
|
eq('--guards without search: no warning', codex.err, '');
|
|
177
|
-
const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body,labels'] });
|
|
190
|
+
const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body,labels,test'] });
|
|
178
191
|
eq('--guards subset still blocks with its guards', still.code, 2);
|
|
179
192
|
const only = runHook(BAD_INSTALL, { hook, args: ['--guards=commit'] });
|
|
180
193
|
eq('unselected guards do not run', only.code, 0);
|
|
181
194
|
|
|
182
195
|
for (const bad of ['--guards=', '--guards=commit,secert', '--guards=commit,,body']) {
|
|
183
196
|
const r = runHook(BAD_INSTALL, { hook, args: [bad] });
|
|
184
|
-
eq(`${bad}: falls back to all
|
|
197
|
+
eq(`${bad}: falls back to all seven (install blocks)`, r.code, 2);
|
|
185
198
|
const clean = runHook('git status', { hook, args: [bad] });
|
|
186
|
-
ok(`${bad}: all
|
|
199
|
+
ok(`${bad}: all seven are selected (the missing search module is reported)`, /search/.test(clean.err));
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
const missing = runHook(`${BAD_BODY} && ${BAD_SEARCH}`, { hook });
|
|
@@ -217,13 +230,14 @@ for (const [name, cmd, ev] of [
|
|
|
217
230
|
// Run from a scratch dir outside any trailhead repo: those suites open unlabelled issues, which
|
|
218
231
|
// the labels guard (rightly) blocks inside a trailhead repo.
|
|
219
232
|
const SUITE_CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-suites-'));
|
|
220
|
-
for (const name of ['commit', 'secret', 'install', 'search', 'body', 'labels']) {
|
|
233
|
+
for (const name of ['commit', 'secret', 'install', 'search', 'body', 'labels', 'test']) {
|
|
221
234
|
const file = path.join(__dirname, `trailhead-${name}-guard.test.js`);
|
|
222
235
|
const r = spawnSync('node', [file], { cwd: SUITE_CWD, env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
|
|
223
236
|
if (r.status !== 0) process.stderr.write(r.stdout.toString() + r.stderr.toString());
|
|
224
237
|
eq(`${name}-guard suite passes through the merged hook`, r.status, 0);
|
|
225
238
|
}
|
|
226
239
|
fs.rmSync(SUITE_CWD, { recursive: true, force: true });
|
|
240
|
+
fs.rmSync(SCRATCH, { recursive: true, force: true });
|
|
227
241
|
|
|
228
242
|
// --- registration: one Bash entry, one command (the merged hook) ---
|
|
229
243
|
{
|
|
@@ -11,6 +11,8 @@ const fs = require('fs');
|
|
|
11
11
|
const os = require('os');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { execFileSync } = require('child_process');
|
|
14
|
+
// Un GIT_DIR ereditato batterebbe -C: le chiamate git non lo seguono (#349).
|
|
15
|
+
const { gitEnv } = require('./lib/trailhead-git-env.js');
|
|
14
16
|
|
|
15
17
|
const REPO = 'ToRvaLDz/trailhead';
|
|
16
18
|
const NPM_PKG = '@marcomigozzi/trailhead';
|
|
@@ -33,7 +35,7 @@ function readJSON(p) {
|
|
|
33
35
|
}
|
|
34
36
|
function run(cmd, args) {
|
|
35
37
|
try {
|
|
36
|
-
return execFileSync(cmd, args, { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
38
|
+
return execFileSync(cmd, args, { encoding: 'utf8', env: gitEnv(), timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
37
39
|
} catch { return ''; }
|
|
38
40
|
}
|
|
39
41
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// prototype | build | bug | task, and exactly one of `trailhead:map-<n>` |
|
|
11
11
|
// `trailhead:whiteboard` (never both, never two maps). Non-ticket trailhead issues
|
|
12
12
|
// (`trailhead:map`, `trailhead:dashboard`, `trailhead:codebase`,
|
|
13
|
-
// `trailhead:conventions`, and `trailhead:fog` / `trailhead:unverified`) are exempt.
|
|
13
|
+
// `trailhead:conventions`, and `trailhead:fog` / `trailhead:needs-info` / `trailhead:unverified`) are exempt.
|
|
14
14
|
// State labels (blocked, seed, paused, ...) are allowed extras and never count
|
|
15
15
|
// toward the shape. This rule applies in any repo.
|
|
16
16
|
// - A create with NO `trailhead:*` label is blocked too, but only inside a trailhead
|
|
@@ -36,7 +36,7 @@ const { words, parseRedirects } = require('./lib/body-sources.js');
|
|
|
36
36
|
|
|
37
37
|
const PLAIN_SENTINEL = /(?:^|\s)TRAILHEAD_PLAIN_ISSUE=1(?:\s|$)/;
|
|
38
38
|
const TYPES = ['decision', 'research', 'prototype', 'build', 'bug', 'task'];
|
|
39
|
-
const EXEMPT = ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified'];
|
|
39
|
+
const EXEMPT = ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:needs-info', 'trailhead:unverified'];
|
|
40
40
|
const MAP_RE = /^trailhead:map-\d+$/;
|
|
41
41
|
const WHITEBOARD = 'trailhead:whiteboard';
|
|
42
42
|
const TYPE_LIST = TYPES.map((t) => `trailhead:${t}`).join(' | ');
|
|
@@ -104,7 +104,7 @@ ok('a seed without type is blocked', blocked(create(['trailhead:ticket', 'trailh
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// --- non-ticket trailhead issues are exempt ---
|
|
107
|
-
for (const l of ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified']) {
|
|
107
|
+
for (const l of ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:needs-info', 'trailhead:unverified']) {
|
|
108
108
|
ok(`${l} create passes`, !blocked(create([l]), TH));
|
|
109
109
|
}
|
|
110
110
|
ok('map issue with extra labels passes', !blocked(create(['trailhead:map', 'trailhead:paused']), TH));
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trailhead-test-guard.js: PreToolUse(Bash) hook.
|
|
3
|
+
// Several agent sessions share one machine, and each of them running the whole test
|
|
4
|
+
// suite at once starves it. `testing.full` (project `.trailhead/config.json`, or the
|
|
5
|
+
// global `<config dir>/trailhead/config.json`) names the full-suite command; the queue
|
|
6
|
+
// helper `trailhead-test-run.js` serialises those runs. This guard blocks a Bash call
|
|
7
|
+
// that runs the bare `testing.full` command outside the helper.
|
|
8
|
+
// - Off unless `testing.full` is a non-empty string (nested `{"testing":{"full":..}}`
|
|
9
|
+
// or flat `"testing.full"`, nested wins; the project value replaces the global one,
|
|
10
|
+
// the same rule as the helper). Unset, unreadable or invalid config -> allow.
|
|
11
|
+
// - `testing.full` and the command are both split into statements; each statement
|
|
12
|
+
// drops NAME=v prefixes, redirections and the wrappers an agent writes by mistake
|
|
13
|
+
// (`env`, `time`, `command`, `nice`, `timeout`). The command is blocked when it
|
|
14
|
+
// holds the statements of `testing.full` consecutively, every statement word-equal
|
|
15
|
+
// except the last, which may add only flag arguments (a token starting with `-`).
|
|
16
|
+
// A non-flag extra argument is a focused run (`npm test -- hooks/x.test.js`) and passes.
|
|
17
|
+
// - Compound matching compares statements, not the operators between them. A `cd` /
|
|
18
|
+
// `pushd` statement before the match means the working directory is unknown: allow.
|
|
19
|
+
// - The helper's own invocation (`node .../trailhead-test-run.js ...`) never matches.
|
|
20
|
+
// - Opaque forms (`bash -c`, `eval`) are not unwrapped: guards target accidental mistakes.
|
|
21
|
+
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
22
|
+
// Crash-safe: any error -> allow. Self-contained except for lib/shell-scan.js. Pure
|
|
23
|
+
// functions are exported for tests and the merged trailhead-bash-guards.js; runs as a
|
|
24
|
+
// hook when executed directly.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { splitStatements, tokenize, stripQuotes, stripRedirections, skipEnvPrefix } = require('./lib/shell-scan.js');
|
|
30
|
+
|
|
31
|
+
const HELPER = 'trailhead-test-run.js';
|
|
32
|
+
const ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
33
|
+
const DURATION_RE = /^\d+(?:\.\d+)?[smhd]?$/;
|
|
34
|
+
// timeout options that consume a value as the next token (when not glued).
|
|
35
|
+
const TIMEOUT_VALUE_OPTS = new Set(['-s', '--signal', '-k', '--kill-after']);
|
|
36
|
+
|
|
37
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
38
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
39
|
+
|
|
40
|
+
// --- config ----------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function readJson(file) {
|
|
43
|
+
try {
|
|
44
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
45
|
+
return isObject(cfg) ? cfg : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The testing.full entry of one parsed config: { found, value }. Nested wins over flat.
|
|
52
|
+
function pickFull(cfg) {
|
|
53
|
+
if (!cfg) return { found: false };
|
|
54
|
+
if (isObject(cfg.testing) && has(cfg.testing, 'full')) return { found: true, value: cfg.testing.full };
|
|
55
|
+
if (has(cfg, 'testing.full')) return { found: true, value: cfg['testing.full'] };
|
|
56
|
+
return { found: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The git top level above `cwd` (first dir holding `.git`, dir or file), or null.
|
|
60
|
+
function gitTop(cwd) {
|
|
61
|
+
try {
|
|
62
|
+
let dir = path.resolve(cwd || process.cwd());
|
|
63
|
+
for (;;) {
|
|
64
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
65
|
+
const up = path.dirname(dir);
|
|
66
|
+
if (up === dir) return null;
|
|
67
|
+
dir = up;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
|
|
75
|
+
const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
|
|
76
|
+
const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
|
|
77
|
+
|
|
78
|
+
function configDir(env) {
|
|
79
|
+
return codexHome() || (env && env.CLAUDE_CONFIG_DIR) || path.join((env && env.HOME) || os.homedir(), '.claude');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The effective testing.full string, or null when the guard is off. The project value
|
|
83
|
+
// replaces the global one even when it is invalid (then the guard is off).
|
|
84
|
+
function readFull(cwd, env) {
|
|
85
|
+
const top = gitTop(cwd);
|
|
86
|
+
const layers = [
|
|
87
|
+
top ? readJson(path.join(top, '.trailhead', 'config.json')) : null,
|
|
88
|
+
readJson(path.join(configDir(env), 'trailhead', 'config.json')),
|
|
89
|
+
];
|
|
90
|
+
for (const cfg of layers) {
|
|
91
|
+
const p = pickFull(cfg);
|
|
92
|
+
if (!p.found) continue;
|
|
93
|
+
return typeof p.value === 'string' && p.value.trim() !== '' ? p.value : null;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- statement normalisation -----------------------------------------------
|
|
99
|
+
|
|
100
|
+
// One statement as a word list without env prefixes, redirections and wrappers, or [].
|
|
101
|
+
function normalise(stmt) {
|
|
102
|
+
let toks = stripRedirections(tokenize(stmt)).map(stripQuotes);
|
|
103
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
104
|
+
toks = toks.slice(skipEnvPrefix(toks));
|
|
105
|
+
if (toks.length === 0) return [];
|
|
106
|
+
const head = toks[0];
|
|
107
|
+
if (head === 'env') {
|
|
108
|
+
let i = 1;
|
|
109
|
+
while (i < toks.length) {
|
|
110
|
+
if (ASSIGN_RE.test(toks[i])) i++;
|
|
111
|
+
else if (toks[i] === '-u' || toks[i] === '-C') i += 2;
|
|
112
|
+
else if (toks[i].startsWith('-') && toks[i] !== '-') i++;
|
|
113
|
+
else break;
|
|
114
|
+
}
|
|
115
|
+
toks = toks.slice(i);
|
|
116
|
+
} else if (head === 'time') {
|
|
117
|
+
let i = 1;
|
|
118
|
+
while (i < toks.length && toks[i].startsWith('-')) i++;
|
|
119
|
+
toks = toks.slice(i);
|
|
120
|
+
} else if (head === 'command') {
|
|
121
|
+
let i = 1;
|
|
122
|
+
while (i < toks.length && toks[i].startsWith('-') && toks[i] !== '--') i++;
|
|
123
|
+
if (toks[i] === '--') i++;
|
|
124
|
+
toks = toks.slice(i);
|
|
125
|
+
} else if (head === 'nice') {
|
|
126
|
+
let i = 1;
|
|
127
|
+
if (toks[i] === '-n') i += 2;
|
|
128
|
+
else if (/^-n\d+$/.test(toks[i] || '') || /^-\d+$/.test(toks[i] || '')) i++;
|
|
129
|
+
toks = toks.slice(i);
|
|
130
|
+
} else if (head === 'timeout') {
|
|
131
|
+
let i = 1;
|
|
132
|
+
while (i < toks.length && toks[i].startsWith('-')) {
|
|
133
|
+
i += TIMEOUT_VALUE_OPTS.has(toks[i]) ? 2 : 1;
|
|
134
|
+
}
|
|
135
|
+
if (i < toks.length && DURATION_RE.test(toks[i])) i++;
|
|
136
|
+
toks = toks.slice(i);
|
|
137
|
+
} else {
|
|
138
|
+
return toks;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return toks;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const statementsOf = (text) => splitStatements(String(text)).map(normalise);
|
|
145
|
+
|
|
146
|
+
const isCd = (words) => words[0] === 'cd' || words[0] === 'pushd';
|
|
147
|
+
const isHelper = (words) => words.some((w) => path.basename(w) === HELPER);
|
|
148
|
+
|
|
149
|
+
// Does `words` run the `want` statement; the last one may add only flag arguments?
|
|
150
|
+
function matchesStatement(words, want, last) {
|
|
151
|
+
if (words.length < want.length) return false;
|
|
152
|
+
for (let i = 0; i < want.length; i++) if (words[i] !== want[i]) return false;
|
|
153
|
+
const extra = words.slice(want.length);
|
|
154
|
+
if (!last) return extra.length === 0;
|
|
155
|
+
return extra.every((w) => w.startsWith('-'));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Does the command run the `full` statements, with a known working directory?
|
|
159
|
+
function runsFull(cmdStmts, fullStmts) {
|
|
160
|
+
for (let start = 0; start + fullStmts.length <= cmdStmts.length; start++) {
|
|
161
|
+
if (cmdStmts.slice(0, start).some(isCd)) return false; // unknown cwd from here on
|
|
162
|
+
const hit = fullStmts.every((want, k) => {
|
|
163
|
+
const words = cmdStmts[start + k];
|
|
164
|
+
return !isHelper(words) && matchesStatement(words, want, k === fullStmts.length - 1);
|
|
165
|
+
});
|
|
166
|
+
if (hit) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- reason ----------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function helperPath(env) {
|
|
174
|
+
const candidates = [path.join(__dirname, '..', 'templates'), path.join(configDir(env), 'trailhead', 'templates')];
|
|
175
|
+
const dir = candidates.find((d) => fs.existsSync(path.join(d, HELPER)));
|
|
176
|
+
return dir ? path.join(dir, HELPER) : HELPER;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function block(full, env, root) {
|
|
180
|
+
const reason =
|
|
181
|
+
`trailhead: this Bash call runs the full test suite (\`${full}\`) directly. Several sessions doing that at once starve the machine. ` +
|
|
182
|
+
'Run focused tests instead (pass only the test files you touched, or the `testing.targeted` command when it is set), ' +
|
|
183
|
+
`or queue the full run with the helper: \`node ${helperPath(env)} run --repo ${root || '<repo root>'}\`.`;
|
|
184
|
+
return { kind: 'block', payload: { decision: 'block', code: 'UNQUEUED_FULL_TEST_RUN', reason } };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Pure verdict: null (allow), or { kind:'block', payload }. Never exits or writes; any
|
|
188
|
+
// internal error allows.
|
|
189
|
+
function evaluate(cmd, opts) {
|
|
190
|
+
try {
|
|
191
|
+
const cwd = opts && opts.cwd;
|
|
192
|
+
const env = (opts && opts.env) || process.env;
|
|
193
|
+
const full = readFull(cwd, env);
|
|
194
|
+
if (!full) return null;
|
|
195
|
+
const fullStmts = statementsOf(full);
|
|
196
|
+
if (fullStmts.length === 0 || fullStmts.some((s) => s.length === 0)) return null;
|
|
197
|
+
if (!runsFull(statementsOf(cmd), fullStmts)) return null;
|
|
198
|
+
return block(full, env, gitTop(cwd));
|
|
199
|
+
} catch {
|
|
200
|
+
return null; // a guard must never wedge the workflow
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function run(data) {
|
|
205
|
+
let cmd = '';
|
|
206
|
+
let cwd;
|
|
207
|
+
try {
|
|
208
|
+
const input = JSON.parse(data);
|
|
209
|
+
cmd = (input.tool_input || {}).command || '';
|
|
210
|
+
cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
|
|
211
|
+
} catch {
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
const v = evaluate(cmd, { cwd, env: process.env });
|
|
215
|
+
if (v && v.kind === 'block') {
|
|
216
|
+
process.stdout.write(JSON.stringify(v.payload));
|
|
217
|
+
process.exit(2);
|
|
218
|
+
}
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (require.main === module) {
|
|
223
|
+
let data = '';
|
|
224
|
+
const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
|
|
225
|
+
process.stdin.on('data', (c) => (data += c));
|
|
226
|
+
process.stdin.on('end', () => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
run(data);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { configDir, evaluate, readFull, normalise, runsFull };
|