@marcomigozzi/trailhead 0.9.0 → 0.11.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 +11 -11
- package/README.md +11 -11
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +45 -3
- package/bin/lib/install-verify.js +257 -0
- package/bin/lib/install-verify.test.js +379 -0
- package/bin/trailhead.js +91 -5
- package/bin/trailhead.test.js +588 -4
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/quick.md +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +27 -20
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +8 -6
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +13 -4
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -10
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +12 -9
- package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// install-verify.js: post-install filesystem verification for both hosts.
|
|
3
|
+
//
|
|
4
|
+
// installClaude/installCodex place files and register hooks, but never
|
|
5
|
+
// checked any of it landed. This module adds that check: given a spec
|
|
6
|
+
// describing what an install is supposed to look like, it walks the
|
|
7
|
+
// filesystem (and the host's hook registry file) and reports what's missing
|
|
8
|
+
// or merely unverifiable (a registered hook target whose path depends on an
|
|
9
|
+
// env var this process doesn't have set).
|
|
10
|
+
//
|
|
11
|
+
// Reads the filesystem only, never writes, and never throws: a stat/parse
|
|
12
|
+
// error just counts as "missing" or an empty registry. It never reads
|
|
13
|
+
// process.env or os.homedir() itself; the caller always passes both in
|
|
14
|
+
// (spec.env, spec.home), so the tests stay hermetic.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { codexLayout } = require('./codex-projection.js');
|
|
19
|
+
|
|
20
|
+
// --- fs probes, never throw --------------------------------------------------
|
|
21
|
+
function isDir(p) {
|
|
22
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
function isFile(p) {
|
|
25
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- sharedCoreFiles ----------------------------------------------------------
|
|
29
|
+
// The shared-core file list, single-sourced from _shared/load-first.md's own
|
|
30
|
+
// numbered "Load first, in order" list: load-first.md itself, plus every
|
|
31
|
+
// `../_shared/<name>.md` named on a numbered-list line, de-duplicated, in
|
|
32
|
+
// order. Empty/unreadable text still yields ['load-first.md'].
|
|
33
|
+
function sharedCoreFiles(loadFirstText) {
|
|
34
|
+
const names = ['load-first.md'];
|
|
35
|
+
const re = /^\d+\.\s+`\.\.\/_shared\/([\w.-]+\.md)`/gm;
|
|
36
|
+
let m;
|
|
37
|
+
while ((m = re.exec(loadFirstText || '')) !== null) {
|
|
38
|
+
if (!names.includes(m[1])) names.push(m[1]);
|
|
39
|
+
}
|
|
40
|
+
return names;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- hookScriptTokens ---------------------------------------------------------
|
|
44
|
+
// Split a shell-like command into tokens (quotes stripped), then return every
|
|
45
|
+
// token whose basename matches trailhead-<name>.js. A1: a quoted token that
|
|
46
|
+
// itself contains whitespace (e.g. bash -c's payload) is re-tokenized
|
|
47
|
+
// recursively, so an inner script path nested inside `bash -c '...'` is still
|
|
48
|
+
// found as its own token.
|
|
49
|
+
function tokenize(command) {
|
|
50
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
51
|
+
const tokens = [];
|
|
52
|
+
let m;
|
|
53
|
+
while ((m = re.exec(command)) !== null) {
|
|
54
|
+
tokens.push(m[1] !== undefined ? m[1] : (m[2] !== undefined ? m[2] : m[3]));
|
|
55
|
+
}
|
|
56
|
+
return tokens;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hookScriptTokens(command) {
|
|
60
|
+
const found = [];
|
|
61
|
+
const visit = (cmd) => {
|
|
62
|
+
for (const token of tokenize(cmd)) {
|
|
63
|
+
if (/\s/.test(token)) {
|
|
64
|
+
visit(token);
|
|
65
|
+
} else if (/^trailhead-[\w-]+\.js$/.test(path.basename(token))) {
|
|
66
|
+
found.push(token);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
visit(command || '');
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- expandTarget --------------------------------------------------------------
|
|
75
|
+
// Expand a `~`/`~/...` prefix (to `home`) and any `$VAR`/`${VAR}` references
|
|
76
|
+
// (from `env`). The first undefined variable short-circuits to
|
|
77
|
+
// { unresolved: varName }; an empty-string value counts as defined and is
|
|
78
|
+
// substituted as-is. `~user/...` is intentionally NOT expanded (it stays
|
|
79
|
+
// relative and later fails as a relative target).
|
|
80
|
+
function expandTarget(token, { env, home }) {
|
|
81
|
+
let result = token;
|
|
82
|
+
if (result === '~' || result.startsWith('~/')) {
|
|
83
|
+
result = path.join(home, result.slice(1));
|
|
84
|
+
}
|
|
85
|
+
const re = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
86
|
+
let out = '';
|
|
87
|
+
let lastIndex = 0;
|
|
88
|
+
let match;
|
|
89
|
+
while ((match = re.exec(result)) !== null) {
|
|
90
|
+
const name = match[1] || match[2];
|
|
91
|
+
const val = env[name];
|
|
92
|
+
if (val === undefined) return { unresolved: name };
|
|
93
|
+
out += result.slice(lastIndex, match.index) + val;
|
|
94
|
+
lastIndex = match.index + match[0].length;
|
|
95
|
+
}
|
|
96
|
+
out += result.slice(lastIndex);
|
|
97
|
+
return { path: out };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- verifyInstall: individual checks -----------------------------------------
|
|
101
|
+
function checkSkills(spec, missing) {
|
|
102
|
+
for (const name of spec.skillDirs) {
|
|
103
|
+
if (name === '_shared') continue;
|
|
104
|
+
const skillMd = path.join(spec.skillsRoot, name, 'SKILL.md');
|
|
105
|
+
if (!isDir(path.join(spec.skillsRoot, name)) || !isFile(skillMd)) {
|
|
106
|
+
missing.push(`skill ${name}/SKILL.md`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function checkSharedCore(spec, missing) {
|
|
112
|
+
const sharedDir = path.join(spec.skillsRoot, '_shared');
|
|
113
|
+
for (const f of spec.sharedFiles) {
|
|
114
|
+
if (!isDir(sharedDir) || !isFile(path.join(sharedDir, f))) missing.push(`shared core _shared/${f}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkHookScripts(spec, missing) {
|
|
119
|
+
for (const f of spec.hookFiles) {
|
|
120
|
+
if (!isFile(path.join(spec.hooksDir, f))) missing.push(`hook script ${f}`);
|
|
121
|
+
}
|
|
122
|
+
for (const f of spec.hookLibFiles) {
|
|
123
|
+
if (!isFile(path.join(spec.hooksDir, 'lib', f))) missing.push(`hook lib lib/${f}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readRegistry(registryFile) {
|
|
128
|
+
try { return JSON.parse(fs.readFileSync(registryFile, 'utf8')); } catch { return {}; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Registered hook targets + expected registration. Walks hooks.<event>[].hooks[]
|
|
132
|
+
// .command in the registry file, expands each trailhead-* token, and sorts it
|
|
133
|
+
// into `missing` (relative or non-existent) or `unverified` (undefined var).
|
|
134
|
+
// Then, per A2, an expected hookFiles entry counts as registered when some
|
|
135
|
+
// token expands to exactly hooksDir/<f>, OR an unresolved token's basename is
|
|
136
|
+
// <f> (stays warning-only, never also missing).
|
|
137
|
+
function checkRegisteredTargets(spec, missing, unverified) {
|
|
138
|
+
const registry = readRegistry(spec.registryFile);
|
|
139
|
+
const registryName = path.basename(spec.registryFile);
|
|
140
|
+
const events = (registry && typeof registry.hooks === 'object' && registry.hooks) ? registry.hooks : {};
|
|
141
|
+
const registeredExact = new Set();
|
|
142
|
+
const registeredViaUnresolved = new Set();
|
|
143
|
+
|
|
144
|
+
for (const [event, groups] of Object.entries(events)) {
|
|
145
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
146
|
+
for (const hook of Array.isArray(group.hooks) ? group.hooks : []) {
|
|
147
|
+
const command = typeof hook.command === 'string' ? hook.command : '';
|
|
148
|
+
for (const token of hookScriptTokens(command)) {
|
|
149
|
+
const expanded = expandTarget(token, { env: spec.env, home: spec.home });
|
|
150
|
+
if (expanded.unresolved) {
|
|
151
|
+
unverified.push(`${token} (${event}, ${registryName}: $${expanded.unresolved} undefined)`);
|
|
152
|
+
registeredViaUnresolved.add(path.basename(token));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!path.isAbsolute(expanded.path)) {
|
|
156
|
+
missing.push(`registered hook target ${token} is relative (${event}, ${registryName})`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!isFile(expanded.path)) {
|
|
160
|
+
missing.push(`registered hook target ${expanded.path} (${event}, ${registryName})`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const f of spec.hookFiles) {
|
|
164
|
+
if (expanded.path === path.join(spec.hooksDir, f)) registeredExact.add(f);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const f of spec.hookFiles) {
|
|
172
|
+
if (!registeredExact.has(f) && !registeredViaUnresolved.has(f)) {
|
|
173
|
+
missing.push(`hook registration ${f} (${registryName})`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function checkAgents(spec, missing) {
|
|
179
|
+
let files = [];
|
|
180
|
+
if (isDir(spec.agentsDir)) {
|
|
181
|
+
try { files = fs.readdirSync(spec.agentsDir); } catch { files = []; }
|
|
182
|
+
}
|
|
183
|
+
const hasMatch = files.some((f) => spec.agentPattern.test(f) && isFile(path.join(spec.agentsDir, f)));
|
|
184
|
+
if (!hasMatch) missing.push(`agents: no trailhead-* agents in ${spec.agentsDir}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- verifyInstall --------------------------------------------------------------
|
|
188
|
+
// spec = { skillsRoot, skillDirs, sharedFiles, hooksDir, hookFiles,
|
|
189
|
+
// hookLibFiles, registryFile, agentsDir, agentPattern, env, home }
|
|
190
|
+
function verifyInstall(spec) {
|
|
191
|
+
const missing = [];
|
|
192
|
+
const unverified = [];
|
|
193
|
+
checkSkills(spec, missing);
|
|
194
|
+
checkSharedCore(spec, missing);
|
|
195
|
+
checkHookScripts(spec, missing);
|
|
196
|
+
checkRegisteredTargets(spec, missing, unverified);
|
|
197
|
+
checkAgents(spec, missing);
|
|
198
|
+
return { missing, unverified };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- spec builders ---------------------------------------------------------
|
|
202
|
+
function claudeVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env, home }) {
|
|
203
|
+
return {
|
|
204
|
+
skillsRoot: path.join(configDir, 'skills'),
|
|
205
|
+
skillDirs,
|
|
206
|
+
sharedFiles,
|
|
207
|
+
hooksDir: path.join(configDir, 'hooks'),
|
|
208
|
+
hookFiles,
|
|
209
|
+
hookLibFiles,
|
|
210
|
+
registryFile: path.join(configDir, 'settings.json'),
|
|
211
|
+
agentsDir: path.join(configDir, 'agents'),
|
|
212
|
+
agentPattern: /^trailhead-.*\.md$/,
|
|
213
|
+
env,
|
|
214
|
+
home,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function codexVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env, home }) {
|
|
219
|
+
const L = codexLayout(configDir);
|
|
220
|
+
return {
|
|
221
|
+
skillsRoot: L.skillsRoot,
|
|
222
|
+
skillDirs,
|
|
223
|
+
sharedFiles,
|
|
224
|
+
hooksDir: L.hooksScriptsDir,
|
|
225
|
+
hookFiles,
|
|
226
|
+
hookLibFiles,
|
|
227
|
+
registryFile: L.hooksJson,
|
|
228
|
+
agentsDir: L.codexAgentsDir,
|
|
229
|
+
agentPattern: /^trailhead-.*\.toml$/,
|
|
230
|
+
env,
|
|
231
|
+
home,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- formatters --------------------------------------------------------------
|
|
236
|
+
function formatVerifyFailure(hostLabel, configDir, { missing, unverified }) {
|
|
237
|
+
const lines = [`✗ trailhead install incomplete for ${hostLabel} → ${configDir}`];
|
|
238
|
+
for (const m of missing) lines.push(` missing: ${m}`);
|
|
239
|
+
for (const u of unverified) lines.push(` unverified: ${u}`);
|
|
240
|
+
lines.push('Re-run the installer to restore a missing file. For a stale or relative hook target, remove that entry from the registry file named above (or run --uninstall), then reinstall.');
|
|
241
|
+
return lines.join('\n');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatVerifyWarnings(unverified) {
|
|
245
|
+
return unverified.map((u) => ` ⚠ unverified hook target ${u}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
sharedCoreFiles,
|
|
250
|
+
hookScriptTokens,
|
|
251
|
+
expandTarget,
|
|
252
|
+
verifyInstall,
|
|
253
|
+
claudeVerifySpec,
|
|
254
|
+
codexVerifySpec,
|
|
255
|
+
formatVerifyFailure,
|
|
256
|
+
formatVerifyWarnings,
|
|
257
|
+
};
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for install-verify.js. Run: node install-verify.test.js
|
|
3
|
+
// No framework: plain asserts, mirrors the style of codex-projection.test.js.
|
|
4
|
+
// Fixtures live under os.tmpdir(); every spec passes an explicit env/home,
|
|
5
|
+
// never the real process.env/os.homedir(), so the tests stay hermetic.
|
|
6
|
+
const assert = require('assert');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const {
|
|
11
|
+
sharedCoreFiles,
|
|
12
|
+
hookScriptTokens,
|
|
13
|
+
expandTarget,
|
|
14
|
+
verifyInstall,
|
|
15
|
+
claudeVerifySpec,
|
|
16
|
+
codexVerifySpec,
|
|
17
|
+
formatVerifyFailure,
|
|
18
|
+
formatVerifyWarnings,
|
|
19
|
+
} = require('./install-verify.js');
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
23
|
+
|
|
24
|
+
const tmpDirs = [];
|
|
25
|
+
function mktmp() {
|
|
26
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'th-iv-'));
|
|
27
|
+
tmpDirs.push(d);
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
function write(p, content = '') {
|
|
31
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
32
|
+
fs.writeFileSync(p, content);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- sharedCoreFiles -----------------------------------------------------------
|
|
36
|
+
const loadFirstPath = path.join(__dirname, '../../plugins/trailhead/skills/_shared/load-first.md');
|
|
37
|
+
const loadFirstSrc = fs.readFileSync(loadFirstPath, 'utf8');
|
|
38
|
+
ok('sharedCoreFiles on the real load-first.md returns the 7 core names',
|
|
39
|
+
JSON.stringify(sharedCoreFiles(loadFirstSrc)) === JSON.stringify([
|
|
40
|
+
'load-first.md', 'principles.md', 'ticket-language.md', 'substrate.md',
|
|
41
|
+
'session-handoff.md', 'configuration.md', 'techniques.md',
|
|
42
|
+
]));
|
|
43
|
+
ok("sharedCoreFiles('') returns just load-first.md", JSON.stringify(sharedCoreFiles('')) === JSON.stringify(['load-first.md']));
|
|
44
|
+
ok('sharedCoreFiles de-duplicates repeated entries', JSON.stringify(sharedCoreFiles(
|
|
45
|
+
'1. `../_shared/a.md`: x\n2. `../_shared/a.md`: x again\n3. `../_shared/b.md`: y\n'
|
|
46
|
+
)) === JSON.stringify(['load-first.md', 'a.md', 'b.md']));
|
|
47
|
+
|
|
48
|
+
// --- hookScriptTokens ----------------------------------------------------------
|
|
49
|
+
ok('hookScriptTokens: node "<p>"', JSON.stringify(hookScriptTokens('node "/x/trailhead-a.js"')) === JSON.stringify(['/x/trailhead-a.js']));
|
|
50
|
+
ok('hookScriptTokens: node <p>', JSON.stringify(hookScriptTokens('node /x/trailhead-a.js')) === JSON.stringify(['/x/trailhead-a.js']));
|
|
51
|
+
ok('hookScriptTokens: node <p> --flag', JSON.stringify(hookScriptTokens('node /x/trailhead-a.js --flag')) === JSON.stringify(['/x/trailhead-a.js']));
|
|
52
|
+
ok("hookScriptTokens: bash -c 'node <p>'", JSON.stringify(hookScriptTokens("bash -c 'node /x/trailhead-a.js'")) === JSON.stringify(['/x/trailhead-a.js']));
|
|
53
|
+
ok("hookScriptTokens: /usr/bin/env node '<p>'", JSON.stringify(hookScriptTokens("/usr/bin/env node '/x/trailhead-a.js'")) === JSON.stringify(['/x/trailhead-a.js']));
|
|
54
|
+
ok('hookScriptTokens: no trailhead script gives []', JSON.stringify(hookScriptTokens('node /x/other.js')) === JSON.stringify([]));
|
|
55
|
+
ok('hookScriptTokens: trailhead-x.json does not match', JSON.stringify(hookScriptTokens('node /x/trailhead-x.json')) === JSON.stringify([]));
|
|
56
|
+
ok('hookScriptTokens: my-trailhead-x.js does not match', JSON.stringify(hookScriptTokens('node /x/my-trailhead-x.js')) === JSON.stringify([]));
|
|
57
|
+
|
|
58
|
+
// --- expandTarget ----------------------------------------------------------------
|
|
59
|
+
const H = '/home/fixture-user';
|
|
60
|
+
ok('expandTarget ~/a becomes home', expandTarget('~/a', { env: {}, home: H }).path === path.join(H, 'a'));
|
|
61
|
+
ok('expandTarget ~ alone becomes home', expandTarget('~', { env: {}, home: H }).path === H);
|
|
62
|
+
ok('expandTarget $A/x expands with A defined', expandTarget('$A/x', { env: { A: '/v' }, home: H }).path === '/v/x');
|
|
63
|
+
ok('expandTarget ${A}/x expands with A defined', expandTarget('${A}/x', { env: { A: '/v' }, home: H }).path === '/v/x');
|
|
64
|
+
ok('expandTarget undefined ${CLAUDE_PLUGIN_ROOT} is unresolved',
|
|
65
|
+
expandTarget('${CLAUDE_PLUGIN_ROOT}/x', { env: {}, home: H }).unresolved === 'CLAUDE_PLUGIN_ROOT');
|
|
66
|
+
ok('expandTarget substitutes an empty-string variable', expandTarget('$A/x', { env: { A: '' }, home: H }).path === '/x');
|
|
67
|
+
|
|
68
|
+
// --- verifyInstall: clean fixtures ----------------------------------------------
|
|
69
|
+
function buildClaudeFixture() {
|
|
70
|
+
const configDir = mktmp();
|
|
71
|
+
const skillDirs = ['trailhead', '_shared', 'trailhead-work'];
|
|
72
|
+
const sharedFiles = ['load-first.md', 'substrate.md'];
|
|
73
|
+
const hookFiles = ['trailhead-commit-guard.js', 'trailhead-body-guard.js'];
|
|
74
|
+
const hookLibFiles = ['commit-message-check.js'];
|
|
75
|
+
write(path.join(configDir, 'skills', 'trailhead', 'SKILL.md'), 'x');
|
|
76
|
+
write(path.join(configDir, 'skills', 'trailhead-work', 'SKILL.md'), 'x');
|
|
77
|
+
write(path.join(configDir, 'skills', '_shared', 'load-first.md'), 'x');
|
|
78
|
+
write(path.join(configDir, 'skills', '_shared', 'substrate.md'), 'x');
|
|
79
|
+
write(path.join(configDir, 'hooks', 'trailhead-commit-guard.js'), 'x');
|
|
80
|
+
write(path.join(configDir, 'hooks', 'trailhead-body-guard.js'), 'x');
|
|
81
|
+
write(path.join(configDir, 'hooks', 'lib', 'commit-message-check.js'), 'x');
|
|
82
|
+
write(path.join(configDir, 'agents', 'trailhead-execute.md'), 'x');
|
|
83
|
+
const settings = {
|
|
84
|
+
hooks: {
|
|
85
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
86
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
87
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
88
|
+
] }],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify(settings, null, 2));
|
|
92
|
+
const spec = claudeVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env: {}, home: H });
|
|
93
|
+
return { configDir, spec, skillDirs, sharedFiles, hookFiles, hookLibFiles };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildCodexFixture() {
|
|
97
|
+
const configDir = mktmp();
|
|
98
|
+
const skillDirs = ['trailhead', '_shared', 'trailhead-work'];
|
|
99
|
+
const sharedFiles = ['load-first.md', 'substrate.md'];
|
|
100
|
+
const hookFiles = ['trailhead-commit-guard.js', 'trailhead-body-guard.js'];
|
|
101
|
+
const hookLibFiles = ['commit-message-check.js'];
|
|
102
|
+
const hooksScriptsDir = path.join(configDir, 'skills', 'trailhead', 'hooks');
|
|
103
|
+
write(path.join(configDir, 'skills', 'trailhead', 'SKILL.md'), 'x');
|
|
104
|
+
write(path.join(configDir, 'skills', 'trailhead-work', 'SKILL.md'), 'x');
|
|
105
|
+
write(path.join(configDir, 'skills', '_shared', 'load-first.md'), 'x');
|
|
106
|
+
write(path.join(configDir, 'skills', '_shared', 'substrate.md'), 'x');
|
|
107
|
+
write(path.join(hooksScriptsDir, 'trailhead-commit-guard.js'), 'x');
|
|
108
|
+
write(path.join(hooksScriptsDir, 'trailhead-body-guard.js'), 'x');
|
|
109
|
+
write(path.join(hooksScriptsDir, 'lib', 'commit-message-check.js'), 'x');
|
|
110
|
+
write(path.join(configDir, 'agents', 'trailhead-execute.toml'), 'x');
|
|
111
|
+
const hooksJson = {
|
|
112
|
+
hooks: {
|
|
113
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
114
|
+
{ type: 'command', command: `node "${path.join(hooksScriptsDir, 'trailhead-commit-guard.js')}"` },
|
|
115
|
+
{ type: 'command', command: `node "${path.join(hooksScriptsDir, 'trailhead-body-guard.js')}"` },
|
|
116
|
+
] }],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
write(path.join(configDir, 'hooks.json'), JSON.stringify(hooksJson, null, 2));
|
|
120
|
+
const spec = codexVerifySpec(configDir, { skillDirs, sharedFiles, hookFiles, hookLibFiles, env: {}, home: H });
|
|
121
|
+
return { configDir, spec, skillDirs, sharedFiles, hookFiles, hookLibFiles, hooksScriptsDir };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
const { spec } = buildClaudeFixture();
|
|
126
|
+
const r = verifyInstall(spec);
|
|
127
|
+
ok('verifyInstall claude clean fixture: no missing/unverified', r.missing.length === 0 && r.unverified.length === 0);
|
|
128
|
+
}
|
|
129
|
+
{
|
|
130
|
+
const { spec } = buildCodexFixture();
|
|
131
|
+
const r = verifyInstall(spec);
|
|
132
|
+
ok('verifyInstall codex clean fixture: no missing/unverified', r.missing.length === 0 && r.unverified.length === 0);
|
|
133
|
+
}
|
|
134
|
+
// The Codex spec never flags search-guard (it is never in hookFiles for Codex).
|
|
135
|
+
{
|
|
136
|
+
const { spec } = buildCodexFixture();
|
|
137
|
+
const r = verifyInstall(spec);
|
|
138
|
+
ok('verifyInstall codex spec never names search-guard',
|
|
139
|
+
!r.missing.some((m) => m.includes('search-guard')) && !r.unverified.some((u) => u.includes('search-guard')));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- verifyInstall: one named `missing` entry each ------------------------------
|
|
143
|
+
{
|
|
144
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
145
|
+
fs.rmSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'));
|
|
146
|
+
const r = verifyInstall(spec);
|
|
147
|
+
ok('missing: a missing hook file', r.missing.includes('hook script trailhead-body-guard.js'));
|
|
148
|
+
}
|
|
149
|
+
{
|
|
150
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
151
|
+
fs.rmSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'));
|
|
152
|
+
fs.mkdirSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'), { recursive: true });
|
|
153
|
+
const r = verifyInstall(spec);
|
|
154
|
+
ok('missing: a hook path that is a directory', r.missing.includes('hook script trailhead-body-guard.js'));
|
|
155
|
+
}
|
|
156
|
+
{
|
|
157
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
158
|
+
fs.rmSync(path.join(configDir, 'hooks', 'lib', 'commit-message-check.js'));
|
|
159
|
+
const r = verifyInstall(spec);
|
|
160
|
+
ok('missing: a missing lib file', r.missing.includes('hook lib lib/commit-message-check.js'));
|
|
161
|
+
}
|
|
162
|
+
{
|
|
163
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
164
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
165
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
166
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
167
|
+
] }] },
|
|
168
|
+
}));
|
|
169
|
+
const r = verifyInstall(spec);
|
|
170
|
+
ok('missing: an unregistered hook', r.missing.includes('hook registration trailhead-body-guard.js (settings.json)'));
|
|
171
|
+
}
|
|
172
|
+
{
|
|
173
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
174
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
175
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
176
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
177
|
+
{ type: 'command', command: 'node "/other/dir/trailhead-body-guard.js"' },
|
|
178
|
+
] }] },
|
|
179
|
+
}));
|
|
180
|
+
const r = verifyInstall(spec);
|
|
181
|
+
ok('missing: a lookalike registration at /other/dir/trailhead-commit-guard.js is still unregistered',
|
|
182
|
+
r.missing.includes('hook registration trailhead-body-guard.js (settings.json)') &&
|
|
183
|
+
r.missing.includes('registered hook target /other/dir/trailhead-body-guard.js (PreToolUse, settings.json)'));
|
|
184
|
+
}
|
|
185
|
+
{
|
|
186
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
187
|
+
fs.rmSync(path.join(configDir, 'agents', 'trailhead-execute.md'));
|
|
188
|
+
write(path.join(configDir, 'agents', 'my-custom-agent.md'), 'x');
|
|
189
|
+
const r = verifyInstall(spec);
|
|
190
|
+
ok('missing: zero agents, with a non-trailhead agent present', r.missing.some((m) => m.startsWith('agents: no trailhead-* agents')));
|
|
191
|
+
}
|
|
192
|
+
{
|
|
193
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
194
|
+
fs.rmSync(path.join(configDir, 'skills', 'trailhead-work', 'SKILL.md'));
|
|
195
|
+
const r = verifyInstall(spec);
|
|
196
|
+
ok('missing: a cluster with no SKILL.md', r.missing.includes('skill trailhead-work/SKILL.md'));
|
|
197
|
+
}
|
|
198
|
+
{
|
|
199
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
200
|
+
fs.rmSync(path.join(configDir, 'skills', '_shared'), { recursive: true, force: true });
|
|
201
|
+
const r = verifyInstall(spec);
|
|
202
|
+
ok('missing: _shared missing', r.missing.includes('shared core _shared/load-first.md') && r.missing.includes('shared core _shared/substrate.md'));
|
|
203
|
+
}
|
|
204
|
+
{
|
|
205
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
206
|
+
fs.rmSync(path.join(configDir, 'skills', '_shared', 'substrate.md'));
|
|
207
|
+
const r = verifyInstall(spec);
|
|
208
|
+
ok('missing: _shared present without substrate.md', r.missing.includes('shared core _shared/substrate.md') && !r.missing.includes('shared core _shared/load-first.md'));
|
|
209
|
+
}
|
|
210
|
+
{
|
|
211
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
212
|
+
const dangling = path.join(configDir, 'hooks', 'trailhead-body-guard.js');
|
|
213
|
+
fs.rmSync(dangling);
|
|
214
|
+
fs.symlinkSync(path.join(configDir, 'nonexistent-target.js'), dangling);
|
|
215
|
+
const r = verifyInstall(spec);
|
|
216
|
+
ok('missing: a dangling symlink', r.missing.includes('hook script trailhead-body-guard.js'));
|
|
217
|
+
}
|
|
218
|
+
{
|
|
219
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
220
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
221
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
222
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
223
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
224
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-old-guard.js')}"` },
|
|
225
|
+
] }] },
|
|
226
|
+
}));
|
|
227
|
+
const r = verifyInstall(spec);
|
|
228
|
+
ok('missing: a stale trailhead-old-guard.js registration',
|
|
229
|
+
r.missing.includes(`registered hook target ${path.join(configDir, 'hooks', 'trailhead-old-guard.js')} (PreToolUse, settings.json)`));
|
|
230
|
+
}
|
|
231
|
+
{
|
|
232
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
233
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
234
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
235
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
236
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
237
|
+
{ type: 'command', command: 'node hooks/trailhead-x-guard.js' },
|
|
238
|
+
] }] },
|
|
239
|
+
}));
|
|
240
|
+
const r = verifyInstall(spec);
|
|
241
|
+
ok('missing: a relative target', r.missing.includes('registered hook target hooks/trailhead-x-guard.js is relative (PreToolUse, settings.json)'));
|
|
242
|
+
}
|
|
243
|
+
{
|
|
244
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
245
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
246
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
247
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
248
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
249
|
+
{ type: 'command', command: 'node "~/gone/trailhead-x-guard.js"' },
|
|
250
|
+
] }] },
|
|
251
|
+
}));
|
|
252
|
+
const r = verifyInstall(spec);
|
|
253
|
+
ok('missing: a ~/gone/trailhead-x-guard.js target (expands under the fixture home, not found)',
|
|
254
|
+
r.missing.includes(`registered hook target ${path.join(H, 'gone', 'trailhead-x-guard.js')} (PreToolUse, settings.json)`));
|
|
255
|
+
}
|
|
256
|
+
{
|
|
257
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
258
|
+
spec.env = { A: configDir };
|
|
259
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
260
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
261
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
262
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
263
|
+
{ type: 'command', command: 'node "$A/trailhead-x-guard.js"' },
|
|
264
|
+
] }] },
|
|
265
|
+
}));
|
|
266
|
+
const r = verifyInstall(spec);
|
|
267
|
+
ok('missing: a $A/trailhead-x-guard.js target with A defined but the file absent',
|
|
268
|
+
r.missing.includes(`registered hook target ${path.join(configDir, 'trailhead-x-guard.js')} (PreToolUse, settings.json)`));
|
|
269
|
+
}
|
|
270
|
+
{
|
|
271
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
272
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
273
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
274
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
275
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
276
|
+
{ type: 'command', command: `bash -c 'node "${path.join(configDir, 'hooks', 'trailhead-gone.js')}"'` },
|
|
277
|
+
] }] },
|
|
278
|
+
}));
|
|
279
|
+
const r = verifyInstall(spec);
|
|
280
|
+
ok("missing: a script inside bash -c '...' whose file is gone",
|
|
281
|
+
r.missing.includes(`registered hook target ${path.join(configDir, 'hooks', 'trailhead-gone.js')} (PreToolUse, settings.json)`));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- verifyInstall: resolution cases ---------------------------------------------
|
|
285
|
+
{
|
|
286
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
287
|
+
spec.env = {};
|
|
288
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
289
|
+
hooks: { SessionStart: [{ matcher: '', hooks: [
|
|
290
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
291
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
292
|
+
{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-x.js"' },
|
|
293
|
+
] }] },
|
|
294
|
+
}));
|
|
295
|
+
const r = verifyInstall(spec);
|
|
296
|
+
ok('resolution: undefined ${CLAUDE_PLUGIN_ROOT} gives missing: [] plus one unverified entry naming the variable',
|
|
297
|
+
r.missing.length === 0 && r.unverified.some((u) => u.includes('CLAUDE_PLUGIN_ROOT undefined')));
|
|
298
|
+
}
|
|
299
|
+
{
|
|
300
|
+
// ~/... and $A/... pointing at EXISTING fixture files both pass.
|
|
301
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
302
|
+
const homeDir = mktmp();
|
|
303
|
+
spec.home = homeDir;
|
|
304
|
+
spec.env = { A: configDir };
|
|
305
|
+
write(path.join(homeDir, 'sub', 'trailhead-tilde.js'), 'x');
|
|
306
|
+
write(path.join(configDir, 'trailhead-dollar.js'), 'x');
|
|
307
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
308
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
309
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
310
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
311
|
+
{ type: 'command', command: 'node "~/sub/trailhead-tilde.js"' },
|
|
312
|
+
{ type: 'command', command: 'node "$A/trailhead-dollar.js"' },
|
|
313
|
+
] }] },
|
|
314
|
+
}));
|
|
315
|
+
const r = verifyInstall(spec);
|
|
316
|
+
ok('resolution: ~/... and $A/... pointing at existing fixture files pass (no new missing/unverified)',
|
|
317
|
+
r.missing.length === 0 && r.unverified.length === 0);
|
|
318
|
+
}
|
|
319
|
+
{
|
|
320
|
+
// A non-trailhead hook with a missing target is ignored entirely.
|
|
321
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
322
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
323
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
324
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
325
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-body-guard.js')}"` },
|
|
326
|
+
{ type: 'command', command: 'node "/gone/other.js"' },
|
|
327
|
+
] }] },
|
|
328
|
+
}));
|
|
329
|
+
const r = verifyInstall(spec);
|
|
330
|
+
ok('resolution: a non-trailhead hook with a missing target is ignored', r.missing.length === 0 && r.unverified.length === 0);
|
|
331
|
+
}
|
|
332
|
+
{
|
|
333
|
+
// Missing/malformed registry: no crash, every expected hook unregistered.
|
|
334
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
335
|
+
fs.rmSync(path.join(configDir, 'settings.json'));
|
|
336
|
+
const r1 = verifyInstall(spec);
|
|
337
|
+
ok('resolution: a missing registry file does not crash and flags every hook unregistered',
|
|
338
|
+
r1.missing.includes('hook registration trailhead-commit-guard.js (settings.json)') &&
|
|
339
|
+
r1.missing.includes('hook registration trailhead-body-guard.js (settings.json)'));
|
|
340
|
+
write(path.join(configDir, 'settings.json'), '{not json');
|
|
341
|
+
const r2 = verifyInstall(spec);
|
|
342
|
+
ok('resolution: a malformed registry file does not crash and flags every hook unregistered',
|
|
343
|
+
r2.missing.includes('hook registration trailhead-commit-guard.js (settings.json)'));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- A2: unresolved expected registration stays warning-only ---------------------
|
|
347
|
+
{
|
|
348
|
+
const { configDir, spec } = buildClaudeFixture();
|
|
349
|
+
fs.rmSync(path.join(configDir, 'hooks', 'trailhead-body-guard.js'));
|
|
350
|
+
write(path.join(configDir, 'settings.json'), JSON.stringify({
|
|
351
|
+
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [
|
|
352
|
+
{ type: 'command', command: `node "${path.join(configDir, 'hooks', 'trailhead-commit-guard.js')}"` },
|
|
353
|
+
{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-body-guard.js"' },
|
|
354
|
+
] }] },
|
|
355
|
+
}));
|
|
356
|
+
const r = verifyInstall(spec);
|
|
357
|
+
ok('A2: an unresolved token whose basename matches the expected file stays unverified, not also missing',
|
|
358
|
+
!r.missing.includes('hook registration trailhead-body-guard.js (settings.json)') &&
|
|
359
|
+
r.unverified.some((u) => u.includes('CLAUDE_PLUGIN_ROOT undefined')));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- formatters --------------------------------------------------------------
|
|
363
|
+
{
|
|
364
|
+
const out = formatVerifyFailure('Claude Code', '/c/dir', { missing: ['skill trailhead-work/SKILL.md'], unverified: ['/x/trailhead-y.js (PreToolUse, settings.json: $Y undefined)'] });
|
|
365
|
+
ok('formatVerifyFailure contains the host', out.includes('Claude Code'));
|
|
366
|
+
ok('formatVerifyFailure contains the dir', out.includes('/c/dir'));
|
|
367
|
+
ok('formatVerifyFailure contains the missing entry', out.includes('skill trailhead-work/SKILL.md'));
|
|
368
|
+
ok('formatVerifyFailure contains the unverified entry', out.includes('$Y undefined'));
|
|
369
|
+
}
|
|
370
|
+
ok('formatVerifyWarnings([]) is []', JSON.stringify(formatVerifyWarnings([])) === '[]');
|
|
371
|
+
ok('formatVerifyWarnings has one line per entry', (() => {
|
|
372
|
+
const lines = formatVerifyWarnings(['a', 'b']);
|
|
373
|
+
return lines.length === 2 && lines[0].includes('⚠') && lines[0].includes('a') && lines[1].includes('b');
|
|
374
|
+
})());
|
|
375
|
+
|
|
376
|
+
// --- cleanup ---------------------------------------------------------------
|
|
377
|
+
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
|
|
378
|
+
|
|
379
|
+
console.log(`✓ install-verify: ${passed} assertions passed`);
|