@marcomigozzi/trailhead 0.10.0 → 0.11.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.it.md +2 -2
- package/README.md +2 -2
- package/bin/lib/install-verify.js +257 -0
- package/bin/lib/install-verify.test.js +379 -0
- package/bin/trailhead.js +86 -5
- package/bin/trailhead.test.js +500 -4
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +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/lib/model-defaults-review.js +399 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +437 -0
- package/plugins/trailhead/hooks/lib/model-defaults.json +12 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
- 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/configuration-reference.md +9 -6
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- 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 +4 -4
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
- package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for model-defaults-review.js. Run: node model-defaults-review.test.js
|
|
3
|
+
// No framework: plain asserts, mirrors the style of gh-subcommand.test.js.
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
// Isola ogni caso dal vero CLAUDE_CONFIG_DIR dell'utente: senza questo, una
|
|
11
|
+
// review "global" potrebbe leggere davvero ~/.claude/trailhead/config.json.
|
|
12
|
+
// Ripristinato in fondo al file; i casi che puntano altrove salvano e
|
|
13
|
+
// ripristinano il valore corrente attorno a se stessi.
|
|
14
|
+
const PRIOR_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
|
|
15
|
+
const BASE_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-globalcfg-'));
|
|
16
|
+
process.env.CLAUDE_CONFIG_DIR = BASE_CONFIG_DIR;
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
loadData,
|
|
20
|
+
currentEntry,
|
|
21
|
+
stalePins,
|
|
22
|
+
applyUpdate,
|
|
23
|
+
semverLt,
|
|
24
|
+
reviewOffer,
|
|
25
|
+
reviewNotice,
|
|
26
|
+
findProjectRoot,
|
|
27
|
+
readAck,
|
|
28
|
+
writeAck,
|
|
29
|
+
check,
|
|
30
|
+
ack,
|
|
31
|
+
effectiveModels,
|
|
32
|
+
globalConfigDir,
|
|
33
|
+
globalConfigFile,
|
|
34
|
+
globalAckFile,
|
|
35
|
+
readAckFile,
|
|
36
|
+
writeAckFile,
|
|
37
|
+
} = require('./model-defaults-review.js');
|
|
38
|
+
|
|
39
|
+
const LIB_PATH = path.join(__dirname, 'model-defaults-review.js');
|
|
40
|
+
|
|
41
|
+
let passed = 0;
|
|
42
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
43
|
+
|
|
44
|
+
const data = loadData();
|
|
45
|
+
const cur = currentEntry(data);
|
|
46
|
+
ok('loadData reads the shipped JSON', data.schema === 1 && Array.isArray(data.history));
|
|
47
|
+
ok('currentEntry is the last history entry', cur.since === data.history[data.history.length - 1].since);
|
|
48
|
+
|
|
49
|
+
// --- stalePins -------------------------------------------------------------
|
|
50
|
+
ok('no models -> stalePins empty', stalePins({}, data).length === 0);
|
|
51
|
+
ok('pins already equal to the new defaults -> stalePins empty',
|
|
52
|
+
stalePins({ models: { plan: 'claude-opus-5-5', execute: 'claude-sonnet-5-5' } }, data).length === 0);
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
const stale = stalePins({ models: { plan: 'claude-opus-4-8' } }, data);
|
|
56
|
+
ok('stale strong pin detected', stale.length === 1 &&
|
|
57
|
+
stale[0].key === 'models.plan' && stale[0].from === 'claude-opus-4-8' && stale[0].to === 'claude-opus-5-5');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
const stale = stalePins({ models: { execute: 'claude-sonnet-5' } }, data);
|
|
62
|
+
ok('stale standard pin detected', stale.length === 1 &&
|
|
63
|
+
stale[0].key === 'models.execute' && stale[0].from === 'claude-sonnet-5' && stale[0].to === 'claude-sonnet-5-5');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
const stale = stalePins({ models: { plan: 'claude-opus-5' } }, data);
|
|
68
|
+
ok('a superseded id (not a former plain default) is flagged too', stale.length === 1 &&
|
|
69
|
+
stale[0].key === 'models.plan' && stale[0].from === 'claude-opus-5' && stale[0].to === 'claude-opus-5-5');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
const stale = stalePins({ models: { plan: 'claude-fable-5', review: 'inherit session' } }, data);
|
|
74
|
+
ok('unknown ids and "inherit session" are never flagged', stale.length === 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
const stale = stalePins({ models: { codex: { plan: 'gpt-5.6-sol' } } }, data);
|
|
79
|
+
ok('codex pins already on current ids are not stale', stale.length === 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
// Fixture sintetico: un tier codex il cui default e' cambiato, per esercitare
|
|
84
|
+
// il ramo {model, effort} che il dataset reale (codex invariato) non copre.
|
|
85
|
+
const synthetic = {
|
|
86
|
+
schema: 1,
|
|
87
|
+
history: [
|
|
88
|
+
{ since: '0.1.0', claude: { strong: 'a', standard: 'b', fast: 'c' }, codex: { strong: 'old-strong', standard: 's', fast: 'f' } },
|
|
89
|
+
{ since: '0.2.0', claude: { strong: 'a', standard: 'b', fast: 'c' }, codex: { strong: 'new-strong', standard: 's', fast: 'f' } },
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
const stale = stalePins({ models: { codex: { plan: { model: 'old-strong', effort: 'high' } } } }, synthetic);
|
|
93
|
+
ok('a stale codex {model, effort} pin is detected on the model field', stale.length === 1 &&
|
|
94
|
+
stale[0].key === 'models.codex.plan' && stale[0].from === 'old-strong' && stale[0].to === 'new-strong');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- applyUpdate -------------------------------------------------------------
|
|
98
|
+
{
|
|
99
|
+
const config = { other: 1, models: { plan: 'claude-opus-4-8', codex: { plan: { model: 'gpt-old', effort: 'high' } } } };
|
|
100
|
+
const stale = [
|
|
101
|
+
{ key: 'models.plan', from: 'claude-opus-4-8', to: 'claude-opus-5-5' },
|
|
102
|
+
{ key: 'models.codex.plan', from: 'gpt-old', to: 'gpt-new' },
|
|
103
|
+
];
|
|
104
|
+
const next = applyUpdate(config, stale);
|
|
105
|
+
ok('applyUpdate replaces a claude string pin', next.models.plan === 'claude-opus-5-5');
|
|
106
|
+
ok('applyUpdate replaces only model in a codex {model, effort} pin, keeping effort',
|
|
107
|
+
next.models.codex.plan.model === 'gpt-new' && next.models.codex.plan.effort === 'high');
|
|
108
|
+
ok('applyUpdate does not mutate the input config',
|
|
109
|
+
config.models.plan === 'claude-opus-4-8' && config.models.codex.plan.model === 'gpt-old');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- reviewOffer / reviewNotice ----------------------------------------------
|
|
113
|
+
ok('reviewOffer: ack equal to current since -> null',
|
|
114
|
+
reviewOffer({ config: { models: { plan: 'claude-opus-4-8' } }, data, ack: cur.since }) === null);
|
|
115
|
+
ok('reviewOffer: older ack -> offered',
|
|
116
|
+
reviewOffer({ config: { models: { plan: 'claude-opus-4-8' } }, data, ack: '0.10.0' }) !== null);
|
|
117
|
+
ok('reviewOffer: no ack, no stale pins -> null',
|
|
118
|
+
reviewOffer({ config: {}, data, ack: null }) === null);
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
const offer = reviewOffer({ config: { models: { plan: 'claude-opus-4-8' } }, data, ack: null });
|
|
122
|
+
ok('reviewOffer notice mentions the since version', offer.notice.includes(cur.since));
|
|
123
|
+
ok('reviewOffer notice lists the stale key with -> arrow', offer.notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
|
|
124
|
+
ok('reviewNotice never contains an em-dash', !reviewNotice(offer.stale, cur.since).includes('—'));
|
|
125
|
+
ok('reviewNotice no longer claims the offer will not reappear once acknowledged',
|
|
126
|
+
!reviewNotice(offer.stale, cur.since).includes('This offer will not reappear once acknowledged.'));
|
|
127
|
+
ok('reviewNotice says the offer reappears until acknowledged',
|
|
128
|
+
reviewNotice(offer.stale, cur.since).includes('reappears') || reviewNotice(offer.stale, cur.since).toLowerCase().includes('until'));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- fs round trip: check() is read-only --------------------------------------
|
|
132
|
+
{
|
|
133
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-'));
|
|
134
|
+
fs.mkdirSync(path.join(tmp, '.trailhead'));
|
|
135
|
+
fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
136
|
+
|
|
137
|
+
ok('findProjectRoot finds the nearest .trailhead/config.json', findProjectRoot(tmp) === tmp);
|
|
138
|
+
ok('findProjectRoot walking from a subdir still finds it', (() => {
|
|
139
|
+
const sub = path.join(tmp, 'a', 'b');
|
|
140
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
141
|
+
return findProjectRoot(sub) === tmp;
|
|
142
|
+
})());
|
|
143
|
+
ok('findProjectRoot returns null with no project above', findProjectRoot(fs.mkdtempSync(path.join(os.tmpdir(), 'th-noproj-'))) === null);
|
|
144
|
+
|
|
145
|
+
ok('readAck is null before any ack is written', readAck(tmp) === null);
|
|
146
|
+
|
|
147
|
+
const first = check(tmp);
|
|
148
|
+
ok('check() returns a notice on first run', typeof first === 'string' && first.length > 0);
|
|
149
|
+
ok('check() is read-only: writes no ack file', readAck(tmp) === null);
|
|
150
|
+
ok('check() is read-only: writes no ack file on disk either', !fs.existsSync(path.join(tmp, '.trailhead', 'model-defaults-ack')));
|
|
151
|
+
|
|
152
|
+
const second = check(tmp);
|
|
153
|
+
ok('check() returns the same notice on a second run (still not acknowledged)', second === first);
|
|
154
|
+
|
|
155
|
+
ok('the notice carries a ready-to-run ack instruction naming this lib and the project root',
|
|
156
|
+
first.includes(`node "${LIB_PATH}" ack "${tmp}"`));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- ack(cwd) ------------------------------------------------------------------
|
|
160
|
+
{
|
|
161
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-ack-'));
|
|
162
|
+
fs.mkdirSync(path.join(tmp, '.trailhead'));
|
|
163
|
+
fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
164
|
+
|
|
165
|
+
ok('check() returns a notice before ack()', typeof check(tmp) === 'string');
|
|
166
|
+
|
|
167
|
+
const result = ack(tmp);
|
|
168
|
+
ok('ack() returns true when it finds a project root', result === true);
|
|
169
|
+
ok('ack() writes the current since as the ack', readAck(tmp) === cur.since);
|
|
170
|
+
ok('check() returns null after ack()', check(tmp) === null);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
const noProj = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-ack-noproj-'));
|
|
175
|
+
ok('ack() returns false with no project above cwd', ack(noProj) === false);
|
|
176
|
+
ok('ack() never throws and writes nothing with no project above cwd',
|
|
177
|
+
!fs.existsSync(path.join(noProj, '.trailhead')));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-nopins-'));
|
|
182
|
+
fs.mkdirSync(path.join(tmp, '.trailhead'));
|
|
183
|
+
fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({}));
|
|
184
|
+
const res = check(tmp);
|
|
185
|
+
ok('check() with no pins returns null', res === null);
|
|
186
|
+
ok('check() with no pins writes no ack file', readAck(tmp) === null);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-'));
|
|
191
|
+
fs.mkdirSync(path.join(tmp, '.trailhead'));
|
|
192
|
+
fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
193
|
+
|
|
194
|
+
const out = execFileSync('node', [LIB_PATH, 'check', tmp], { encoding: 'utf8' });
|
|
195
|
+
ok('CLI check prints the notice to stdout', out.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
|
|
196
|
+
ok('CLI check writes no ack file', !fs.existsSync(path.join(tmp, '.trailhead', 'model-defaults-ack')));
|
|
197
|
+
|
|
198
|
+
const ackOut = execFileSync('node', [LIB_PATH, 'ack', tmp], { encoding: 'utf8' });
|
|
199
|
+
ok('CLI ack prints nothing', ackOut === '');
|
|
200
|
+
ok('CLI ack writes the ack file', readAck(tmp) === cur.since);
|
|
201
|
+
|
|
202
|
+
const outAfterAck = execFileSync('node', [LIB_PATH, 'check', tmp], { encoding: 'utf8' });
|
|
203
|
+
ok('CLI check prints nothing after CLI ack', outAfterAck === '');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-unknown-'));
|
|
208
|
+
let status = null;
|
|
209
|
+
try {
|
|
210
|
+
execFileSync('node', [LIB_PATH, 'bogus', tmp], { encoding: 'utf8' });
|
|
211
|
+
status = 0;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
status = e.status;
|
|
214
|
+
}
|
|
215
|
+
ok('CLI with an unknown subcommand still exits 0', status === 0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- shipped data invariants ---------------------------------------------
|
|
219
|
+
{
|
|
220
|
+
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', '..', 'package.json'), 'utf8'));
|
|
221
|
+
ok('shipped data: schema is 1', data.schema === 1);
|
|
222
|
+
let ascending = true;
|
|
223
|
+
for (let i = 1; i < data.history.length; i++) {
|
|
224
|
+
if (!semverLt(data.history[i - 1].since, data.history[i].since)) ascending = false;
|
|
225
|
+
}
|
|
226
|
+
ok('shipped data: history is ascending by since', ascending);
|
|
227
|
+
const complete = data.history.every((e) => ['claude', 'codex'].every((h) =>
|
|
228
|
+
['strong', 'standard', 'fast'].every((t) => typeof e[h][t] === 'string' && e[h][t].length > 0)));
|
|
229
|
+
ok('shipped data: every entry is a complete snapshot (3 tiers x 2 hosts)', complete);
|
|
230
|
+
ok('shipped data: last since <= package.json version',
|
|
231
|
+
cur.since === pkg.version || semverLt(cur.since, pkg.version));
|
|
232
|
+
const plugin = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
233
|
+
ok('shipped data: last since <= plugin.json version',
|
|
234
|
+
cur.since === plugin.version || semverLt(cur.since, plugin.version));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
{
|
|
238
|
+
const refDoc = fs.readFileSync(
|
|
239
|
+
path.resolve(__dirname, '..', '..', 'skills', '_shared', 'configuration-reference.md'), 'utf8');
|
|
240
|
+
ok('drift guard: current claude strong id appears in the Tier-class -> id section',
|
|
241
|
+
refDoc.includes(cur.claude.strong));
|
|
242
|
+
ok('drift guard: current claude standard id appears in the Tier-class -> id section',
|
|
243
|
+
refDoc.includes(cur.claude.standard));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- effectiveModels (pure merge, no mutation) --------------------------------
|
|
247
|
+
{
|
|
248
|
+
const globalModels = { plan: 'g-plan', execute: 'g-exec', codex: { plan: { model: 'g-gpt', effort: 'high' } } };
|
|
249
|
+
const projectModels = { plan: 'p-plan', codex: { plan: { model: 'p-gpt' } } };
|
|
250
|
+
const layers = [
|
|
251
|
+
{ layer: 'global', file: '/g/trailhead/config.json', config: { models: globalModels } },
|
|
252
|
+
{ layer: 'project', file: '/p/.trailhead/config.json', config: { models: projectModels } },
|
|
253
|
+
];
|
|
254
|
+
const { config, sources } = effectiveModels(layers);
|
|
255
|
+
ok('effectiveModels: a project key wins over the global one', config.models.plan === 'p-plan');
|
|
256
|
+
ok('effectiveModels: a global-only key survives when the project does not set it', config.models.execute === 'g-exec');
|
|
257
|
+
ok('effectiveModels: a project codex key replaces the global one atomically (no inherited effort)',
|
|
258
|
+
config.models.codex.plan.model === 'p-gpt' && config.models.codex.plan.effort === undefined);
|
|
259
|
+
ok('effectiveModels: sources tags models.plan as the project layer',
|
|
260
|
+
sources['models.plan'].layer === 'project' && sources['models.plan'].file === '/p/.trailhead/config.json');
|
|
261
|
+
ok('effectiveModels: sources tags models.execute as the global layer',
|
|
262
|
+
sources['models.execute'].layer === 'global' && sources['models.execute'].file === '/g/trailhead/config.json');
|
|
263
|
+
ok('effectiveModels: sources tags models.codex.plan as the project layer',
|
|
264
|
+
sources['models.codex.plan'].layer === 'project' && sources['models.codex.plan'].file === '/p/.trailhead/config.json');
|
|
265
|
+
ok('effectiveModels does not mutate the input layer configs',
|
|
266
|
+
globalModels.plan === 'g-plan' && projectModels.codex.plan.model === 'p-gpt' && !('effort' in projectModels.codex.plan));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
{
|
|
270
|
+
const layers = [
|
|
271
|
+
{ layer: 'global', file: '/g/config.json', config: { models: { plan: 'claude-opus-4-8' } } },
|
|
272
|
+
{ layer: 'project', file: '/p/config.json', config: { models: { plan: null } } },
|
|
273
|
+
];
|
|
274
|
+
const { config, sources } = effectiveModels(layers);
|
|
275
|
+
ok('effectiveModels: a project null value wins over global by own-key presence, not truthiness', config.models.plan === null);
|
|
276
|
+
ok('effectiveModels: source for plan is the project even though its value is null', sources['models.plan'].layer === 'project');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
{
|
|
280
|
+
const layers = [
|
|
281
|
+
{ layer: 'global', file: '/g/config.json', config: { models: { plan: 'claude-opus-4-8' } } },
|
|
282
|
+
{ layer: 'project', file: '/p/config.json', config: { models: { plan: '' } } },
|
|
283
|
+
];
|
|
284
|
+
const { config } = effectiveModels(layers);
|
|
285
|
+
ok('effectiveModels: a project empty-string value wins over global by own-key presence', config.models.plan === '');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
{
|
|
289
|
+
const layers = [
|
|
290
|
+
{ layer: 'global', file: '/g/config.json', config: { models: { codex: { plan: { model: 'g-gpt' } } } } },
|
|
291
|
+
{ layer: 'project', file: '/p/config.json', config: { models: { codex: 'not-an-object' } } },
|
|
292
|
+
];
|
|
293
|
+
const { config, sources } = effectiveModels(layers);
|
|
294
|
+
ok('effectiveModels: a malformed project codex wins wholesale, yielding no codex pins', config.models.codex === 'not-an-object');
|
|
295
|
+
ok('effectiveModels: a malformed project codex clears prior codex sources', !sources['models.codex.plan']);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
{
|
|
299
|
+
const layers = [{ layer: 'global', file: '/g/config.json', config: { models: null } }];
|
|
300
|
+
const { config } = effectiveModels(layers);
|
|
301
|
+
ok('effectiveModels: a layer whose models is not a plain object contributes nothing', Object.keys(config.models).length === 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- stalePins with a malformed codex value -------------------------------
|
|
305
|
+
ok('stalePins: a non-object models.codex yields no codex pins (not an exception)',
|
|
306
|
+
stalePins({ models: { codex: 'oops' } }, data).length === 0);
|
|
307
|
+
|
|
308
|
+
// --- check()/ack(): global config merged under the project ---------------
|
|
309
|
+
{
|
|
310
|
+
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
311
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-'));
|
|
312
|
+
process.env.CLAUDE_CONFIG_DIR = globalDir;
|
|
313
|
+
fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
|
|
314
|
+
fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
315
|
+
|
|
316
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-empty-'));
|
|
317
|
+
fs.mkdirSync(path.join(projectDir, '.trailhead'));
|
|
318
|
+
fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({}));
|
|
319
|
+
|
|
320
|
+
const notice = check(projectDir);
|
|
321
|
+
ok('check(): a project with an empty config still flags a stale global pin',
|
|
322
|
+
typeof notice === 'string' && notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
|
|
323
|
+
ok('check(): the stale global pin line is tagged with the global config path',
|
|
324
|
+
notice.includes(path.join(globalDir, 'trailhead', 'config.json')));
|
|
325
|
+
|
|
326
|
+
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
331
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global2-'));
|
|
332
|
+
process.env.CLAUDE_CONFIG_DIR = globalDir;
|
|
333
|
+
fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
|
|
334
|
+
fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
335
|
+
|
|
336
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-override-'));
|
|
337
|
+
fs.mkdirSync(path.join(projectDir, '.trailhead'));
|
|
338
|
+
fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-5-5' } }));
|
|
339
|
+
|
|
340
|
+
ok('check(): a project pin overriding a stale global pin is not flagged', check(projectDir) === null);
|
|
341
|
+
|
|
342
|
+
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
{
|
|
346
|
+
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
347
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-only-'));
|
|
348
|
+
process.env.CLAUDE_CONFIG_DIR = globalDir;
|
|
349
|
+
fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
|
|
350
|
+
fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
351
|
+
|
|
352
|
+
const noProjDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-noproj2-'));
|
|
353
|
+
|
|
354
|
+
const notice = check(noProjDir);
|
|
355
|
+
ok('check(): no project above cwd but a global config exists -> a notice',
|
|
356
|
+
typeof notice === 'string' && notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
|
|
357
|
+
ok('check(): the global-only case writes nothing', !fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
|
|
358
|
+
|
|
359
|
+
const ackResult = ack(noProjDir);
|
|
360
|
+
ok('ack(): the global-only case writes the global ack and returns true',
|
|
361
|
+
ackResult === true && fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
|
|
362
|
+
|
|
363
|
+
ok('check(): the global-only case is silenced after the global ack', check(noProjDir) === null);
|
|
364
|
+
|
|
365
|
+
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
{
|
|
369
|
+
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
370
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-bad-'));
|
|
371
|
+
process.env.CLAUDE_CONFIG_DIR = globalDir;
|
|
372
|
+
fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
|
|
373
|
+
fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), '{ not valid json');
|
|
374
|
+
|
|
375
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-malformed-global-'));
|
|
376
|
+
fs.mkdirSync(path.join(projectDir, '.trailhead'));
|
|
377
|
+
fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
378
|
+
|
|
379
|
+
const notice = check(projectDir);
|
|
380
|
+
ok('check(): a malformed global config does not block the project review',
|
|
381
|
+
typeof notice === 'string' && notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
|
|
382
|
+
|
|
383
|
+
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
{
|
|
387
|
+
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
388
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-mixed-'));
|
|
389
|
+
process.env.CLAUDE_CONFIG_DIR = globalDir;
|
|
390
|
+
fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
|
|
391
|
+
fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
392
|
+
|
|
393
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-mixed-'));
|
|
394
|
+
fs.mkdirSync(path.join(projectDir, '.trailhead'));
|
|
395
|
+
fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({ models: { execute: 'claude-sonnet-5' } }));
|
|
396
|
+
|
|
397
|
+
const notice = check(projectDir);
|
|
398
|
+
const globalConfigPath = path.join(globalDir, 'trailhead', 'config.json');
|
|
399
|
+
const projectConfigPath = path.join(projectDir, '.trailhead', 'config.json');
|
|
400
|
+
ok('check(): mixed sources - the global stale key is tagged with the global config path',
|
|
401
|
+
notice.includes(`models.plan: claude-opus-4-8 -> claude-opus-5-5 (new default; from the global config, ${globalConfigPath})`));
|
|
402
|
+
ok('check(): mixed sources - the project stale key is tagged with the project config path',
|
|
403
|
+
notice.includes(`models.execute: claude-sonnet-5 -> claude-sonnet-5-5 (new default; from the project config, ${projectConfigPath})`));
|
|
404
|
+
|
|
405
|
+
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- CLI check/ack honouring CLAUDE_CONFIG_DIR ----------------------------
|
|
409
|
+
{
|
|
410
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-global-'));
|
|
411
|
+
fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
|
|
412
|
+
fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
|
|
413
|
+
|
|
414
|
+
const noProjDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-noproj-'));
|
|
415
|
+
|
|
416
|
+
const out = execFileSync('node', [LIB_PATH, 'check', noProjDir], {
|
|
417
|
+
encoding: 'utf8',
|
|
418
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: globalDir },
|
|
419
|
+
});
|
|
420
|
+
ok('CLI check with CLAUDE_CONFIG_DIR set flags a global-only stale pin',
|
|
421
|
+
out.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
|
|
422
|
+
|
|
423
|
+
const ackOut = execFileSync('node', [LIB_PATH, 'ack', noProjDir], {
|
|
424
|
+
encoding: 'utf8',
|
|
425
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: globalDir },
|
|
426
|
+
});
|
|
427
|
+
ok('CLI ack with CLAUDE_CONFIG_DIR set writes the global ack',
|
|
428
|
+
ackOut === '' && fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
console.log(`model-defaults-review.test.js: ${passed} assertions passed`);
|
|
432
|
+
|
|
433
|
+
if (PRIOR_CLAUDE_CONFIG_DIR === undefined) {
|
|
434
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
435
|
+
} else {
|
|
436
|
+
process.env.CLAUDE_CONFIG_DIR = PRIOR_CLAUDE_CONFIG_DIR;
|
|
437
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"history": [
|
|
4
|
+
{ "since": "0.5.1",
|
|
5
|
+
"claude": { "strong": "claude-opus-4-8", "standard": "claude-sonnet-5", "fast": "claude-haiku-4-5-20251001" },
|
|
6
|
+
"codex": { "strong": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "fast": "gpt-5.6-luna" } },
|
|
7
|
+
{ "since": "0.11.0",
|
|
8
|
+
"claude": { "strong": "claude-opus-5-5", "standard": "claude-sonnet-5-5", "fast": "claude-haiku-4-5-20251001" },
|
|
9
|
+
"codex": { "strong": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "fast": "gpt-5.6-luna" },
|
|
10
|
+
"supersedes": { "claude": { "strong": ["claude-opus-5"], "standard": ["claude-sonnet-4-8"] } } }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -105,9 +105,11 @@ function latestGitHub() {
|
|
|
105
105
|
return '';
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
// Calcola la notice dell'update (solo Codex, comportamento invariato).
|
|
109
|
+
// Ritorna null se non c'è nulla da segnalare.
|
|
110
|
+
function updateNotice() {
|
|
109
111
|
const info = detect();
|
|
110
|
-
if (!SEMVER.test(clean(info.version))) return; // versione installata sconosciuta: non azzardare
|
|
112
|
+
if (!SEMVER.test(clean(info.version))) return null; // versione installata sconosciuta: non azzardare
|
|
111
113
|
const out = cacheFile(info.host); // cache per-host: Codex non eredita il verdetto di Claude
|
|
112
114
|
|
|
113
115
|
// throttle: se la cache è fresca (<6h) riusa quel verdetto senza richiamare la rete
|
|
@@ -119,7 +121,7 @@ function main() {
|
|
|
119
121
|
verdict = prev;
|
|
120
122
|
} else {
|
|
121
123
|
const latest = (info.channel === 'npm' || info.channel === 'codex') ? latestNpm() : latestGitHub();
|
|
122
|
-
if (!SEMVER.test(clean(latest))) return; // rete/fonte non disponibile: lascia la cache com'è
|
|
124
|
+
if (!SEMVER.test(clean(latest))) return null; // rete/fonte non disponibile: lascia la cache com'è
|
|
123
125
|
|
|
124
126
|
verdict = {
|
|
125
127
|
channel: info.channel,
|
|
@@ -138,18 +140,71 @@ function main() {
|
|
|
138
140
|
|
|
139
141
|
// Codex non ha una statusline: l'hook stesso deve segnalare l'update
|
|
140
142
|
// disponibile via additionalContext, su OGNI sessione (non solo quando la
|
|
141
|
-
// finestra di throttle si riapre). Su Claude (info.host è undefined)
|
|
142
|
-
//
|
|
143
|
+
// finestra di throttle si riapre). Su Claude (info.host è undefined) resta
|
|
144
|
+
// sempre null: comportamento invariato.
|
|
143
145
|
if (info.host === 'codex' && verdict && verdict.updateAvailable === true && SEMVER.test(clean(verdict.latest))) {
|
|
144
|
-
|
|
145
|
-
hookSpecificOutput: {
|
|
146
|
-
hookEventName: 'SessionStart',
|
|
147
|
-
additionalContext: `Heads up: trailhead ${verdict.latest} is available (you have ${verdict.installed}). Run $trailhead update to install it.`,
|
|
148
|
-
},
|
|
149
|
-
}));
|
|
146
|
+
return `Heads up: trailhead ${verdict.latest} is available (you have ${verdict.installed}). Run $trailhead update to install it.`;
|
|
150
147
|
}
|
|
148
|
+
return null;
|
|
151
149
|
}
|
|
152
150
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
151
|
+
// Notice di revisione dei pin dei modelli (#186): calcolata PRIMA e a
|
|
152
|
+
// prescindere dall'update check, cosi' un early-return di quest'ultimo
|
|
153
|
+
// (versione ignota, rete assente) non la fa mai cadere. Il require è dentro
|
|
154
|
+
// un try: una lib mancante non deve mai rompere SessionStart. check() è di
|
|
155
|
+
// sola lettura (#187): non scrive l'ack, quindi ripropone la notice finche'
|
|
156
|
+
// l'agente non chiama la subcommand "ack" della stessa lib, dopo che
|
|
157
|
+
// l'utente ha risposto a una delle tre scelte della notice.
|
|
158
|
+
function reviewNotice(cwd) {
|
|
159
|
+
try {
|
|
160
|
+
return require('./lib/model-defaults-review.js').check(cwd) || null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Legge il JSON di SessionStart da stdin in modo asincrono, con timeout, come
|
|
167
|
+
// gli altri hook: una lettura sincrona di fd 0 si bloccherebbe per sempre su
|
|
168
|
+
// un TTY o su una pipe mai chiusa. Su TTY o input assente si prosegue con {}.
|
|
169
|
+
const STDIN_TIMEOUT_MS = 2000;
|
|
170
|
+
function readInput(done) {
|
|
171
|
+
if (process.stdin.isTTY) return done({});
|
|
172
|
+
let data = '';
|
|
173
|
+
let finished = false;
|
|
174
|
+
const finish = () => {
|
|
175
|
+
if (finished) return;
|
|
176
|
+
finished = true;
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
try { process.stdin.destroy(); } catch { /* ignore */ }
|
|
179
|
+
let input = {};
|
|
180
|
+
try { input = JSON.parse(data || '{}'); } catch { input = {}; }
|
|
181
|
+
done(input && typeof input === 'object' ? input : {});
|
|
182
|
+
};
|
|
183
|
+
const timer = setTimeout(finish, STDIN_TIMEOUT_MS);
|
|
184
|
+
process.stdin.on('data', (c) => (data += c));
|
|
185
|
+
process.stdin.on('end', finish);
|
|
186
|
+
process.stdin.on('error', finish);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function main(input) {
|
|
190
|
+
const cwd = (input && typeof input.cwd === 'string' && input.cwd) || process.cwd();
|
|
191
|
+
|
|
192
|
+
const review = reviewNotice(cwd);
|
|
193
|
+
const update = updateNotice();
|
|
194
|
+
|
|
195
|
+
const parts = [review, update].filter(Boolean);
|
|
196
|
+
if (!parts.length) return;
|
|
197
|
+
|
|
198
|
+
process.stdout.write(JSON.stringify({
|
|
199
|
+
hookSpecificOutput: {
|
|
200
|
+
hookEventName: 'SessionStart',
|
|
201
|
+
additionalContext: parts.join('\n\n'),
|
|
202
|
+
},
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// L'hook non deve MAI far fallire SessionStart: ogni errore degrada in silenzio.
|
|
207
|
+
// Il lavoro (rete) è dentro main() con timeout brevi.
|
|
208
|
+
try {
|
|
209
|
+
readInput((input) => { try { main(input); } catch { /* mai far fallire SessionStart */ } });
|
|
210
|
+
} catch { /* mai far fallire SessionStart */ }
|
|
@@ -44,6 +44,11 @@
|
|
|
44
44
|
// are shared with trailhead-search-guard.js via lib/shell-scan.js, so the two
|
|
45
45
|
// guards can never drift into two divergent tokenizers again.
|
|
46
46
|
//
|
|
47
|
+
// #180 fix: `docker`/`podman` `--env-file .env` (or `--env-file=.env`) was a
|
|
48
|
+
// false positive: the container RUNTIME reads that file, not the agent. Only
|
|
49
|
+
// that flag's value is exempted, and only for docker/podman verbs; see
|
|
50
|
+
// ENV_FILE_EXEMPT_VERBS below.
|
|
51
|
+
//
|
|
47
52
|
// Deliberately best-effort on the Bash leg, like the sibling guards: not a
|
|
48
53
|
// full shell parser. It does not care about `cd` shape at all (that is
|
|
49
54
|
// trailhead-search-guard.js's job) — it only asks "does any token here
|
|
@@ -94,6 +99,18 @@ function isSecretPathArg(t) {
|
|
|
94
99
|
// every tool's flag surface, just the common file-value shapes.
|
|
95
100
|
const VALUE_FLAGS = new Set(['-f', '--file']);
|
|
96
101
|
|
|
102
|
+
// #180: `docker run --env-file .env img` / `docker compose --env-file
|
|
103
|
+
// .env.prod up` / `podman run --env-file=.env img` are false positives. The
|
|
104
|
+
// CONTAINER RUNTIME reads that file directly to populate the container's
|
|
105
|
+
// environment; its contents never pass through the agent, so it is not a
|
|
106
|
+
// secret READ by the agent (unlike a bare `cat .env`, which is). Scoped
|
|
107
|
+
// narrowly: only the value of `--env-file` is exempt, and only for these
|
|
108
|
+
// verbs; every other flag/positional in the same statement (image name,
|
|
109
|
+
// `-v`, trailing `cat .env`, etc.) is still scanned exactly as before.
|
|
110
|
+
const ENV_FILE_EXEMPT_VERBS = new Set(['docker', 'podman', 'docker-compose', 'podman-compose']);
|
|
111
|
+
const ENV_FILE_EXEMPT_FLAGS = new Set(['--env-file']);
|
|
112
|
+
const NO_EXEMPT_FLAGS = new Set();
|
|
113
|
+
|
|
97
114
|
// The pattern-first search verbs (grep/rg/ag/sed/awk), shared with
|
|
98
115
|
// trailhead-search-guard.js. Their first positional is a PATTERN, not a file
|
|
99
116
|
// operand, so it must be skipped before scanning for a secret FILE argument
|
|
@@ -119,8 +136,11 @@ function stripTrailingPunct(t) {
|
|
|
119
136
|
// Scan one already-tokenized statement's argument tokens (verb NOT stripped)
|
|
120
137
|
// for a secret path used as a plain positional, an option value
|
|
121
138
|
// (`--file=.env`, `-f .env`, glued `-f.env`), or a `<` (or glued `<file`)
|
|
122
|
-
// redirect target. Returns the offending token or null.
|
|
123
|
-
|
|
139
|
+
// redirect target. Returns the offending token or null. `exemptFlags` (#180)
|
|
140
|
+
// names flags whose value must be skipped entirely rather than scanned, in
|
|
141
|
+
// both the `--flag=value` and spaced `--flag value` forms.
|
|
142
|
+
function scanTokensForSecretPath(tokens, exemptFlags) {
|
|
143
|
+
const exempt = exemptFlags || NO_EXEMPT_FLAGS;
|
|
124
144
|
for (let i = 0; i < tokens.length; i++) {
|
|
125
145
|
const t = stripTrailingPunct(tokens[i]);
|
|
126
146
|
if (!t) continue;
|
|
@@ -143,10 +163,15 @@ function scanTokensForSecretPath(tokens) {
|
|
|
143
163
|
// `--flag=value` form.
|
|
144
164
|
const eq = t.indexOf('=');
|
|
145
165
|
if (eq > 0) {
|
|
166
|
+
const flagName = t.slice(0, eq);
|
|
167
|
+
if (exempt.has(flagName)) continue; // #180: value never scanned
|
|
146
168
|
const val = t.slice(eq + 1);
|
|
147
169
|
if (isSecretPathArg(val)) return val;
|
|
148
170
|
continue;
|
|
149
171
|
}
|
|
172
|
+
// #180: exempt flag's separate-token value is skipped entirely, not
|
|
173
|
+
// scanned as a plain positional on the next loop iteration.
|
|
174
|
+
if (exempt.has(t)) { i++; continue; }
|
|
150
175
|
// `-f value` / `--file value` (separate-token value).
|
|
151
176
|
if (VALUE_FLAGS.has(t)) {
|
|
152
177
|
const next = i + 1 < tokens.length ? stripTrailingPunct(tokens[i + 1]) : null;
|
|
@@ -214,7 +239,11 @@ function scanStatementForSecretPath(stmt) {
|
|
|
214
239
|
if (SEARCH_VERBS.has(bin)) {
|
|
215
240
|
return scanArgsSkippingPattern(tokens.slice(1), SEARCH_VERB_CONFIG[bin], isSecretPathArg);
|
|
216
241
|
}
|
|
217
|
-
|
|
242
|
+
// #180: docker/podman (incl. `docker compose` as a subcommand, and the
|
|
243
|
+
// standalone docker-compose/podman-compose binaries) get --env-file's value
|
|
244
|
+
// exempted; every other verb keeps today's behaviour unchanged.
|
|
245
|
+
const exempt = ENV_FILE_EXEMPT_VERBS.has(bin) ? ENV_FILE_EXEMPT_FLAGS : NO_EXEMPT_FLAGS;
|
|
246
|
+
return scanTokensForSecretPath(tokens, exempt);
|
|
218
247
|
}
|
|
219
248
|
|
|
220
249
|
// Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
|