@sabaiway/agent-workflow-kit 4.2.0 → 4.4.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/CHANGELOG.md +104 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +58 -7
- package/references/modes/coverage-check.md +1 -0
- package/references/modes/velocity.md +3 -2
- package/references/shared/command-shapes.md +25 -24
- package/tools/coverage-check.mjs +113 -5
- package/tools/path-inventory.mjs +516 -0
- package/tools/repo-search.mjs +217 -29
- package/tools/run-gates.mjs +91 -6
- package/tools/velocity-profile.mjs +6 -2
package/tools/repo-search.mjs
CHANGED
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
//
|
|
10
10
|
// lane 1 --pattern <literal> for a pattern with no shell-significant byte
|
|
11
11
|
// lane 2 --pattern-file <path> the pattern's bytes NEVER enter the command string
|
|
12
|
+
// lane 3 --paths-file <path> the TARGETS' bytes never enter it either
|
|
12
13
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
14
|
+
// Lane 3 exists because lanes 1-2 answered only half the arguments: a search could name a pattern it
|
|
15
|
+
// could not spell in a shell, but not a PATH it could not spell, and this kit ships shell-byte
|
|
16
|
+
// fixtures as a genre. Both lane files are excluded from the search itself, by REAL path.
|
|
17
|
+
//
|
|
18
|
+
// The hook covers this tool's invocation: choosing lane 1 for a byte-carrying pattern raises an ASK
|
|
19
|
+
// whose reason names lane 2. Stated exactly: that ask goes to the HUMAN, and the reason is context for
|
|
20
|
+
// their decision — it is not delivered to the caller that composed the command. So the ask costs one
|
|
21
|
+
// human decision and never costs silence, but it is not by itself a correction mechanism.
|
|
16
22
|
//
|
|
17
23
|
// CONTRACT
|
|
18
24
|
// LITERAL only — no regex dialect, and none is planned for this slice: a bounded walk cannot
|
|
@@ -58,6 +64,15 @@ export const DEFAULT_WALK_BUDGET = 20000;
|
|
|
58
64
|
// bounds are advisory, which is the same as absent.
|
|
59
65
|
export const HARD_MAX_RESULTS = 100000;
|
|
60
66
|
export const HARD_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
67
|
+
// The `--paths-file` lane's own bounds. A target list arrives as a FILE, so neither its size nor its
|
|
68
|
+
// entry count is visible in the command string the caller composed — without ceilings here the lane
|
|
69
|
+
// would be the one unbounded input on a tool whose every other input is bounded.
|
|
70
|
+
export const HARD_MAX_TARGETS = 5000;
|
|
71
|
+
export const HARD_MAX_PATHS_FILE_BYTES = 4 * 1024 * 1024;
|
|
72
|
+
// The AGGREGATE read budget. The walk budget counts ENTRIES; without a byte ceiling a run may read up
|
|
73
|
+
// to the per-file limit for every one of them, which is unbounded work no bound was consulted about.
|
|
74
|
+
export const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024 * 1024;
|
|
75
|
+
export const HARD_MAX_TOTAL_BYTES = 4 * 1024 * 1024 * 1024;
|
|
61
76
|
const BINARY_SNIFF_BYTES = 8192;
|
|
62
77
|
// Characters of context kept on EACH side of a match, and a HARD ceiling on the whole snippet.
|
|
63
78
|
// The ceiling is the load-bearing one: bounding only the context still lets a huge --pattern-file
|
|
@@ -73,8 +88,12 @@ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
|
73
88
|
const NONBLOCK = constants.O_NONBLOCK ?? 0;
|
|
74
89
|
const OPEN_FLAGS = constants.O_RDONLY | NOFOLLOW | NONBLOCK;
|
|
75
90
|
|
|
76
|
-
|
|
77
|
-
class
|
|
91
|
+
// Exported so a sibling kit tool reusing `parsePathsFile` maps the SAME failure to the SAME exit
|
|
92
|
+
// code by construction — an `instanceof` against a private class would silently degrade a usage
|
|
93
|
+
// error into a generic one, and exit-code parity between the two file lanes would be a promise
|
|
94
|
+
// instead of a mechanism.
|
|
95
|
+
export class UsageError extends Error {}
|
|
96
|
+
export class IoError extends Error {}
|
|
78
97
|
|
|
79
98
|
// The pattern is echoed as a DIGEST plus a byte length — never as a first content line. A first
|
|
80
99
|
// line cannot separate two multiline patterns that share it, and it is unsafe for NUL/control
|
|
@@ -104,7 +123,7 @@ export const parseCount = (raw, flag, ceiling) => {
|
|
|
104
123
|
};
|
|
105
124
|
|
|
106
125
|
const parseArgs = (argv) => {
|
|
107
|
-
const opts = { pattern: null, patternFile: null, paths: [], max: DEFAULT_MAX_RESULTS, maxBytes: DEFAULT_MAX_FILE_BYTES, json: false };
|
|
126
|
+
const opts = { pattern: null, patternFile: null, paths: [], pathsFile: null, max: DEFAULT_MAX_RESULTS, maxBytes: DEFAULT_MAX_FILE_BYTES, maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES, json: false };
|
|
108
127
|
for (let i = 0; i < argv.length; i += 1) {
|
|
109
128
|
const arg = argv[i];
|
|
110
129
|
const next = () => {
|
|
@@ -115,8 +134,10 @@ const parseArgs = (argv) => {
|
|
|
115
134
|
if (arg === '--pattern') opts.pattern = next();
|
|
116
135
|
else if (arg === '--pattern-file') opts.patternFile = next();
|
|
117
136
|
else if (arg === '--path') opts.paths.push(next());
|
|
137
|
+
else if (arg === '--paths-file') opts.pathsFile = next();
|
|
118
138
|
else if (arg === '--max') opts.max = parseCount(next(), '--max', HARD_MAX_RESULTS);
|
|
119
139
|
else if (arg === '--max-bytes') opts.maxBytes = parseCount(next(), '--max-bytes', HARD_MAX_FILE_BYTES);
|
|
140
|
+
else if (arg === '--max-total-bytes') opts.maxTotalBytes = parseCount(next(), '--max-total-bytes', HARD_MAX_TOTAL_BYTES);
|
|
120
141
|
else if (arg === '--json') opts.json = true;
|
|
121
142
|
else throw new UsageError(`unknown argument: ${arg} (see --help)`);
|
|
122
143
|
}
|
|
@@ -124,14 +145,107 @@ const parseArgs = (argv) => {
|
|
|
124
145
|
throw new UsageError('--pattern and --pattern-file are mutually exclusive — the lane must be unambiguous');
|
|
125
146
|
}
|
|
126
147
|
if (opts.pattern === null && opts.patternFile === null) throw new UsageError('one of --pattern or --pattern-file is required');
|
|
127
|
-
|
|
148
|
+
// The default target is applied only when the caller named NEITHER lane: `--paths-file` supplies
|
|
149
|
+
// targets after argv is parsed, so defaulting here on an empty `--path` list would silently union
|
|
150
|
+
// the whole root into an explicitly named list.
|
|
151
|
+
if (opts.paths.length === 0 && opts.pathsFile === null) opts.paths.push('.');
|
|
128
152
|
return opts;
|
|
129
153
|
};
|
|
130
154
|
|
|
155
|
+
// The `--paths-file` format, pinned rather than discovered: one target per line, UTF-8, LF or CRLF, a
|
|
156
|
+
// trailing delimiter is not an extra entry, EMPTY lines are ignored, duplicates collapse. There is no
|
|
157
|
+
// comment syntax and no escaping, so a filename containing a newline CANNOT be expressed by this lane
|
|
158
|
+
// — stated here because an unstated gap in a lane that exists to carry awkward names is the defect,
|
|
159
|
+
// not the gap.
|
|
160
|
+
//
|
|
161
|
+
// A line is NOT trimmed. This lane exists to carry names a command string cannot, and a leading or
|
|
162
|
+
// trailing space is exactly such a name: trimming would silently rewrite the caller's target, which is
|
|
163
|
+
// the failure mode the lane was built to remove. A whitespace-only line is therefore a real target and
|
|
164
|
+
// either resolves or fails loudly — never a silent stand-in for the root.
|
|
165
|
+
//
|
|
166
|
+
// THREE name classes this lane CANNOT express, stated rather than discovered: one containing a
|
|
167
|
+
// newline (there is no escaping); one ENDING in a carriage return (a trailing CR is stripped as the
|
|
168
|
+
// CRLF delimiter it almost always is, and no line-oriented format can tell those apart without an
|
|
169
|
+
// encoding this lane deliberately does not have); and one whose bytes are not valid UTF-8. The first
|
|
170
|
+
// two are refused by silence — the target simply will not be found, loudly. The third is refused
|
|
171
|
+
// EXPLICITLY, because `Buffer.toString('utf8')` would replace the offending bytes with U+FFFD and the
|
|
172
|
+
// lane would then search a DIFFERENT path that happens to exist, which is worse than any refusal.
|
|
173
|
+
// `ignoreBOM: true` means "do not TREAT a leading U+FEFF as a byte-order mark", i.e. keep it as a
|
|
174
|
+
// character. The default strips it, which would rewrite a legitimate name beginning with U+FEFF into
|
|
175
|
+
// a different one — the same silent-substitution class as a lossy decode, one layer down.
|
|
176
|
+
const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
177
|
+
|
|
178
|
+
export const decodeLaneFile = (buf, flag) => {
|
|
179
|
+
try {
|
|
180
|
+
return strictUtf8.decode(buf);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new UsageError(`${flag} is not valid UTF-8 — a lossy decode would silently name a different path`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
export const parsePathsFile = (raw, flag = '--paths-file') => {
|
|
186
|
+
const entries = raw.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)).filter((line) => line !== '');
|
|
187
|
+
// The ceiling counts TARGETS, so it is applied after dedupe: a file repeating one path 5001 times
|
|
188
|
+
// names one target and refusing it would be a bound on the file's shape rather than on the work.
|
|
189
|
+
const unique = [...new Set(entries)];
|
|
190
|
+
if (unique.length > HARD_MAX_TARGETS) {
|
|
191
|
+
throw new UsageError(`${flag} lists ${unique.length} distinct targets, above the ceiling of ${HARD_MAX_TARGETS}`);
|
|
192
|
+
}
|
|
193
|
+
if (unique.length === 0) throw new UsageError(`${flag} names no target — a blank list is never "search everything"`);
|
|
194
|
+
return unique;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ── the acceptance predicate for a caller-supplied TARGET ─────────────────────────────
|
|
198
|
+
//
|
|
199
|
+
// ONE closed rule set, applied before any target reaches `resolve()`. It replaces what were seven
|
|
200
|
+
// checks discovered one review round at a time, all of which turned out to be the same defect: a
|
|
201
|
+
// caller string is handed to `path.resolve()` / `realpathSync`, whose semantics differ from the
|
|
202
|
+
// operating system's, so the tool can end up answering about a DIFFERENT object than the string
|
|
203
|
+
// denotes. That is the one outcome worse than any refusal.
|
|
204
|
+
//
|
|
205
|
+
// The rule the predicate enforces: **a target is accepted only if it names exactly one filesystem
|
|
206
|
+
// object unambiguously.** Awkward-but-unambiguous names are supported on purpose — edge whitespace,
|
|
207
|
+
// backticks, control bytes — because carrying those is what the out-of-band lane exists for.
|
|
208
|
+
//
|
|
209
|
+
// Separators are PLATFORM-CORRECT, and that is load-bearing: on POSIX a backslash is an ordinary
|
|
210
|
+
// byte in a filename, so splitting on it would refuse `\..` — a legal file the OS answers about
|
|
211
|
+
// normally. An over-refusal is a smaller defect than a wrong answer, but it is still a defect.
|
|
212
|
+
const TARGET_SEPARATORS = Object.freeze(sep === '\\' ? ['/', '\\'] : ['/']);
|
|
213
|
+
const TARGET_SEPARATOR_SPLIT = sep === '\\' ? /[\\/]/u : /\//u;
|
|
214
|
+
|
|
215
|
+
// A trailing separator, or a trailing `.` component, is an ASSERTION by the caller that the target is
|
|
216
|
+
// a DIRECTORY — that is what it means to the operating system, which answers ENOTDIR when it is not.
|
|
217
|
+
// `resolve()` erases both forms before the filesystem sees them, so the assertion has to be carried
|
|
218
|
+
// separately and checked after resolution. Refusing these outright was wrong in both directions: it
|
|
219
|
+
// rejected `existing-directory/`, which names one object unambiguously, while still accepting
|
|
220
|
+
// `regular-file/.`, which the OS refuses.
|
|
221
|
+
//
|
|
222
|
+
// Stated divergence, and it differs per tool. `path-inventory` reports a symlink BY TYPE and never
|
|
223
|
+
// follows it, so `symlink-to-a-directory/` is not a directory there, while the OS would dereference
|
|
224
|
+
// it — following it would contradict that tool's louder promise. `repo-search` DOES resolve an
|
|
225
|
+
// explicitly named target (its threat model says so at the top of this file), so the assertion is
|
|
226
|
+
// checked against the resolved object and it agrees with the OS.
|
|
227
|
+
export const requiresDirectory = (target) => {
|
|
228
|
+
if (TARGET_SEPARATORS.some((separator) => target.endsWith(separator))) return true;
|
|
229
|
+
const components = target.split(TARGET_SEPARATOR_SPLIT);
|
|
230
|
+
return components[components.length - 1] === '.';
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const assertNameableTarget = (target, flag = '--path') => {
|
|
234
|
+
const refuse = (why) => {
|
|
235
|
+
throw new UsageError(`${flag} ${why} — got ${JSON.stringify(target)}`);
|
|
236
|
+
};
|
|
237
|
+
if (target === '') refuse('must not be empty; an empty target resolves to the whole root');
|
|
238
|
+
if (target.includes('\0')) refuse('must not contain a NUL byte; no filesystem path can hold one');
|
|
239
|
+
if (target.split(TARGET_SEPARATOR_SPLIT).includes('..')) {
|
|
240
|
+
refuse('must not contain a ".." component; resolve() collapses it lexically, so the answer could be about a different object than the OS would reach');
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
131
244
|
// Containment on the REAL path. A lexical check passes `link/secret.txt` whenever `link` resolves
|
|
132
245
|
// outside, and on Windows a cross-drive `relative()` returns an absolute path carrying no `..` —
|
|
133
246
|
// both were live review findings, not hypotheticals.
|
|
134
247
|
export const resolveTarget = (realRoot, target) => {
|
|
248
|
+
assertNameableTarget(target);
|
|
135
249
|
const lexical = resolve(realRoot, target);
|
|
136
250
|
let real;
|
|
137
251
|
try {
|
|
@@ -144,12 +258,15 @@ export const resolveTarget = (realRoot, target) => {
|
|
|
144
258
|
if (rel !== '' && (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))) {
|
|
145
259
|
throw new IoError(`target resolves outside the search root: ${target}`);
|
|
146
260
|
}
|
|
261
|
+
if (requiresDirectory(target) && !lstatSync(real).isDirectory()) {
|
|
262
|
+
throw new IoError(`no such path: ${target} — a trailing separator or "." asserts a directory, and this is not one`);
|
|
263
|
+
}
|
|
147
264
|
return real;
|
|
148
265
|
};
|
|
149
266
|
|
|
150
267
|
// Open → fstat the DESCRIPTOR → read bounded. The descriptor is what was actually opened, so a swap
|
|
151
268
|
// after the check cannot substitute a different node; O_NOFOLLOW refuses a symlinked leaf outright.
|
|
152
|
-
const readRegularFile = (abs, maxBytes, state, io = {}) => {
|
|
269
|
+
const readRegularFile = (abs, maxBytes, state, io = {}, boundName = 'max-file-bytes') => {
|
|
153
270
|
const open = io.open ?? openSync;
|
|
154
271
|
const fstat = io.fstat ?? fstatSync;
|
|
155
272
|
const read = io.read ?? readSync;
|
|
@@ -174,7 +291,7 @@ const readRegularFile = (abs, maxBytes, state, io = {}) => {
|
|
|
174
291
|
// A skipped file is NOT a silent omission: the search is incomplete and says which bound did
|
|
175
292
|
// it. Reporting it only as a counter would let a partial search read as "no matches".
|
|
176
293
|
if (state.incomplete === null) {
|
|
177
|
-
state.incomplete = { bound:
|
|
294
|
+
state.incomplete = { bound: boundName, detail: `at least one file exceeds ${maxBytes} byte(s) and was not searched` };
|
|
178
295
|
}
|
|
179
296
|
return null;
|
|
180
297
|
}
|
|
@@ -184,6 +301,10 @@ const readRegularFile = (abs, maxBytes, state, io = {}) => {
|
|
|
184
301
|
const n = read(fd, buf, got, stat.size - got, got);
|
|
185
302
|
if (n <= 0) break;
|
|
186
303
|
got += n;
|
|
304
|
+
// Charged INSIDE the loop, per successful chunk. After the loop is too late: a `read` that
|
|
305
|
+
// throws mid-file skips the charge entirely, so a series of partial reads that end in a fault
|
|
306
|
+
// would move real bytes the aggregate budget never learns about.
|
|
307
|
+
state.bytesRead += n;
|
|
187
308
|
}
|
|
188
309
|
// A short read means the file changed under us. Returning the partial buffer would let a
|
|
189
310
|
// truncated file come back as a confident "no matches" — the file is classified unreadable
|
|
@@ -293,11 +414,29 @@ const walk = (root, abs, pattern, state, isExplicitTarget = false) => {
|
|
|
293
414
|
}
|
|
294
415
|
return;
|
|
295
416
|
}
|
|
296
|
-
|
|
297
|
-
|
|
417
|
+
const excludedAs = state.excludePaths.get(abs);
|
|
418
|
+
if (excludedAs !== undefined) {
|
|
419
|
+
state.skipped[excludedAs] += 1;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// The walk budget counts ENTRIES, not bytes, so without this a run may read up to the per-file
|
|
423
|
+
// ceiling for every one of them — twenty thousand files at the hard per-file ceiling is work no
|
|
424
|
+
// bound was ever consulted about. The sibling inventory tool already carries an aggregate byte
|
|
425
|
+
// budget for exactly this reason; this closes the asymmetry rather than stating it.
|
|
426
|
+
const remaining = state.maxTotalBytes - state.bytesRead;
|
|
427
|
+
if (remaining <= 0) {
|
|
428
|
+
state.incomplete = state.incomplete ?? {
|
|
429
|
+
bound: '--max-total-bytes',
|
|
430
|
+
detail: `stopped after reading ${state.bytesRead} byte(s); the tree was not fully searched`,
|
|
431
|
+
};
|
|
298
432
|
return;
|
|
299
433
|
}
|
|
300
|
-
|
|
434
|
+
// The read is bounded by whichever ceiling binds FIRST. Checking the aggregate only BEFORE the file
|
|
435
|
+
// and then handing the reader the full per-file limit lets a single file larger than the remaining
|
|
436
|
+
// budget be read whole — the budget would bound the accounting, not the work.
|
|
437
|
+
const limit = Math.min(state.maxBytes, remaining);
|
|
438
|
+
const boundName = limit < state.maxBytes ? '--max-total-bytes' : 'max-file-bytes';
|
|
439
|
+
const buf = readRegularFile(abs, limit, state, state.io, boundName);
|
|
301
440
|
if (buf === null) return;
|
|
302
441
|
if (isBinary(buf)) {
|
|
303
442
|
state.skipped.binary += 1;
|
|
@@ -310,15 +449,20 @@ const walk = (root, abs, pattern, state, isExplicitTarget = false) => {
|
|
|
310
449
|
// directory, a symlink refused by O_NOFOLLOW, a file truncated mid-read — are reachable from tests.
|
|
311
450
|
// Every one of them is a COUNTED skip in production, and a counted skip that no test ever exercises
|
|
312
451
|
// is indistinguishable from a silent one.
|
|
313
|
-
|
|
452
|
+
// `excludePaths` maps a REAL path to the skip counter it belongs to: BOTH lane files must be kept out
|
|
453
|
+
// of their own search (a `--paths-file` under a searched target would otherwise match on its own
|
|
454
|
+
// contents), and a skip that is not attributed to its lane is indistinguishable from a silent one.
|
|
455
|
+
export const search = ({ root, pattern, paths, max, maxBytes, excludePaths = new Map(), walkBudget = DEFAULT_WALK_BUDGET, maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES, io = {} }) => {
|
|
314
456
|
const state = {
|
|
315
457
|
matches: [],
|
|
316
458
|
incomplete: null,
|
|
317
|
-
skipped: { symlinks: 0, binary: 0, special: 0, unreadable: 0, large: 0, patternFile: 0 },
|
|
318
|
-
|
|
459
|
+
skipped: { symlinks: 0, binary: 0, special: 0, unreadable: 0, large: 0, patternFile: 0, pathsFile: 0 },
|
|
460
|
+
excludePaths,
|
|
319
461
|
io,
|
|
320
462
|
walked: 0,
|
|
321
463
|
walkBudget,
|
|
464
|
+
bytesRead: 0,
|
|
465
|
+
maxTotalBytes,
|
|
322
466
|
max,
|
|
323
467
|
maxBytes,
|
|
324
468
|
};
|
|
@@ -334,6 +478,9 @@ export const search = ({ root, pattern, paths, max, maxBytes, excludePath = null
|
|
|
334
478
|
incomplete: state.incomplete,
|
|
335
479
|
skipped: state.skipped,
|
|
336
480
|
scanned: state.walked,
|
|
481
|
+
// Reported so the aggregate budget is observable rather than internal: a bound that nobody can
|
|
482
|
+
// read is a bound nobody can test.
|
|
483
|
+
bytesRead: state.bytesRead,
|
|
337
484
|
};
|
|
338
485
|
};
|
|
339
486
|
|
|
@@ -351,14 +498,27 @@ const formatResult = (result) => {
|
|
|
351
498
|
|
|
352
499
|
const HELP = `repo-search — literal repository search that never has to ride a shell metacharacter.
|
|
353
500
|
|
|
354
|
-
Usage:
|
|
355
|
-
|
|
356
|
-
|
|
501
|
+
Usage — pick ONE pattern lane and ANY combination of target lanes:
|
|
502
|
+
pattern lane --pattern <literal> | --pattern-file <path> (mutually exclusive)
|
|
503
|
+
target lanes [--path <p>]... and/or --paths-file <path> (union; default: .)
|
|
504
|
+
shared flags [--max <n>] [--max-bytes <n>] [--max-total-bytes <n>] [--json]
|
|
505
|
+
|
|
506
|
+
A target must NAME EXACTLY ONE filesystem object: no empty value, no NUL byte, and no ".."
|
|
507
|
+
component (resolve() collapses it before the filesystem sees it). A trailing "/" or "/." is NOT
|
|
508
|
+
rejected — it ASSERTS the target is a directory, exactly as it does to the OS: it holds for a real
|
|
509
|
+
directory and fails for anything else. Awkward-but-unambiguous names — edge whitespace, backticks,
|
|
510
|
+
control bytes — are supported, and --paths-file is the lane for the ones a command string cannot
|
|
511
|
+
carry.
|
|
512
|
+
|
|
513
|
+
Two out-of-band lanes, one per argument half — their bytes never enter the command string, so the
|
|
514
|
+
residual guard has nothing to scan:
|
|
515
|
+
--pattern-file <path> a PATTERN carrying shell-significant bytes (\`>\`, \`$(\`, a backtick)
|
|
516
|
+
--paths-file <path> TARGET paths carrying the same, one per line
|
|
357
517
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this
|
|
518
|
+
Write either file with your host's file-write tool, pass the plain path here, and delete it when you
|
|
519
|
+
are done — this tool never writes. --paths-file format: one target per line, UTF-8, LF or CRLF; blank
|
|
520
|
+
lines ignored; duplicates collapse; no comment syntax and no escaping, so a filename containing a
|
|
521
|
+
newline cannot be expressed by this lane. Both lane files are excluded from the search itself.
|
|
362
522
|
|
|
363
523
|
LITERAL only, multiline patterns supported. Reads regular files only, opened no-follow. Skipped
|
|
364
524
|
entries (symlinks, non-regular, binary, oversized, unreadable) are counted and reported, never
|
|
@@ -372,21 +532,49 @@ export const main = (argv, ctx = {}) => {
|
|
|
372
532
|
if (argv.includes('--help') || argv.includes('-h')) return { code: EXIT_OK, stdout: HELP, stderr: '', result: null };
|
|
373
533
|
const root = realpathSync(resolve(ctx.cwd ?? process.cwd()));
|
|
374
534
|
const opts = parseArgs(argv);
|
|
535
|
+
const excludePaths = new Map();
|
|
536
|
+
// ONE reader for both out-of-band lanes, so their failure classification cannot drift: a missing,
|
|
537
|
+
// unreadable or non-regular lane file is an IoError (exit 1) for the pattern lane and for the
|
|
538
|
+
// target lane alike.
|
|
539
|
+
const readLaneFile = (rel, flag, maxBytes, counter) => {
|
|
540
|
+
const abs = resolveTarget(root, rel);
|
|
541
|
+
// `bytesRead` is present but SEPARATE from the search's budget: reading a lane file is the
|
|
542
|
+
// caller's own instruction, not tree traversal, so it is accounted and then discarded.
|
|
543
|
+
const state = { skipped: { symlinks: 0, special: 0, unreadable: 0, large: 0 }, incomplete: null, bytesRead: 0 };
|
|
544
|
+
const buf = readRegularFile(abs, maxBytes, state);
|
|
545
|
+
if (buf === null) throw new IoError(`cannot read ${flag} ${rel} as a regular file`);
|
|
546
|
+
excludePaths.set(abs, counter);
|
|
547
|
+
return decodeLaneFile(buf, flag);
|
|
548
|
+
};
|
|
375
549
|
let raw;
|
|
376
|
-
let excludePath = null;
|
|
377
550
|
if (opts.patternFile !== null) {
|
|
378
|
-
|
|
379
|
-
const state = { skipped: { symlinks: 0, special: 0, unreadable: 0, large: 0 }, incomplete: null };
|
|
380
|
-
const buf = readRegularFile(excludePath, HARD_MAX_FILE_BYTES, state);
|
|
381
|
-
if (buf === null) throw new IoError(`cannot read --pattern-file ${opts.patternFile} as a regular file`);
|
|
382
|
-
raw = buf.toString('utf8');
|
|
551
|
+
raw = readLaneFile(opts.patternFile, '--pattern-file', HARD_MAX_FILE_BYTES, 'patternFile');
|
|
383
552
|
} else {
|
|
384
553
|
raw = opts.pattern;
|
|
385
554
|
}
|
|
386
555
|
const pattern = opts.patternFile !== null ? resolvePattern(raw) : raw;
|
|
387
556
|
if (pattern === '') throw new UsageError('the pattern is empty — it would match every line of every file');
|
|
388
557
|
|
|
389
|
-
const
|
|
558
|
+
const named = [...opts.paths];
|
|
559
|
+
if (opts.pathsFile !== null) {
|
|
560
|
+
const listed = readLaneFile(opts.pathsFile, '--paths-file', HARD_MAX_PATHS_FILE_BYTES, 'pathsFile');
|
|
561
|
+
named.push(...parsePathsFile(listed));
|
|
562
|
+
}
|
|
563
|
+
// Dedupe across the UNION, not only within each lane: the same target named by `--path` and by
|
|
564
|
+
// the file would otherwise be walked twice.
|
|
565
|
+
const paths = [...new Set(named)];
|
|
566
|
+
// EVERY target is validated BEFORE any of them is walked. Validating lazily per target means an
|
|
567
|
+
// invalid one late in the list is only refused if the walk gets that far — and a bound that fires
|
|
568
|
+
// on an earlier target ends the loop first, so the same invocation would be accepted or refused
|
|
569
|
+
// depending on how much work happened to be done. A refusal must not depend on scheduling.
|
|
570
|
+
for (const target of paths) assertNameableTarget(target);
|
|
571
|
+
// The ceiling holds over the UNION. `parsePathsFile` bounds the file, but thousands of `--path`
|
|
572
|
+
// values would otherwise walk straight past it.
|
|
573
|
+
if (paths.length > HARD_MAX_TARGETS) {
|
|
574
|
+
throw new UsageError(`more than the ceiling of ${HARD_MAX_TARGETS} targets`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const result = search({ root, pattern, paths, max: opts.max, maxBytes: opts.maxBytes, maxTotalBytes: opts.maxTotalBytes, excludePaths });
|
|
390
578
|
const stdout = opts.json ? JSON.stringify(result, null, 2) : formatResult(result);
|
|
391
579
|
return { code: result.incomplete ? EXIT_INCOMPLETE : EXIT_OK, stdout, stderr: '', result };
|
|
392
580
|
} catch (err) {
|
package/tools/run-gates.mjs
CHANGED
|
@@ -34,8 +34,14 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
34
34
|
import { computeTreeFingerprint } from './review-state.mjs';
|
|
35
35
|
// The D3(a) final receipt rides the core-evidence SOLE WRITER (the sole-writer boundary — this
|
|
36
36
|
// runner never opens the store itself) + the canonical per-kind serialization its hashes bind.
|
|
37
|
-
import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION } from './core-evidence.mjs';
|
|
38
|
-
import {
|
|
37
|
+
import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION, resolveBase } from './core-evidence.mjs';
|
|
38
|
+
import {
|
|
39
|
+
LCOV_BASENAME,
|
|
40
|
+
commitmentFor,
|
|
41
|
+
ATTEST_NONCE_ENV,
|
|
42
|
+
ATTEST_FINGERPRINT_ENV,
|
|
43
|
+
ATTEST_BASE_ENV,
|
|
44
|
+
} from './coverage-check.mjs';
|
|
39
45
|
|
|
40
46
|
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
41
47
|
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
@@ -196,10 +202,19 @@ export const selectGates = (gates, onlyIds) => {
|
|
|
196
202
|
// silently attest against the wrong git dir or lcov instead of the computed one.
|
|
197
203
|
export const RESERVED_PRODUCER_ENV = Object.freeze(['AW_GIT_DIR', 'AW_LCOV_FILE']);
|
|
198
204
|
|
|
205
|
+
// The attestation variables ride the same STRIP but are NOT producer variables: a producer variable
|
|
206
|
+
// is something a gate cmd may legitimately reference, and a missing one refuses the run up front.
|
|
207
|
+
// A capability is the opposite — no gate may reference it, exactly one gate is handed it, and every
|
|
208
|
+
// other child (and any descendant it spawns) must see it absent, host-set copies included, or that
|
|
209
|
+
// descendant could certify a foreign lcov later. Conflating the two lists made a gate that merely
|
|
210
|
+
// MENTIONS the name refuse the whole run.
|
|
211
|
+
export const RESERVED_CAPABILITY_ENV = Object.freeze([ATTEST_NONCE_ENV, ATTEST_FINGERPRINT_ENV, ATTEST_BASE_ENV]);
|
|
212
|
+
|
|
199
213
|
export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
|
|
200
214
|
const env = { ...process.env };
|
|
201
215
|
delete env.NODE_TEST_CONTEXT;
|
|
202
216
|
for (const name of RESERVED_PRODUCER_ENV) delete env[name];
|
|
217
|
+
for (const name of RESERVED_CAPABILITY_ENV) delete env[name];
|
|
203
218
|
return spawnSync('bash', ['-c', cmd], { cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
|
|
204
219
|
};
|
|
205
220
|
|
|
@@ -327,13 +342,22 @@ const matchesCanonicalCheck = (check, cmd, projectDir) => {
|
|
|
327
342
|
}
|
|
328
343
|
};
|
|
329
344
|
|
|
345
|
+
// canonicalCheckerGates(gates, projectDir) → every gate that IS the canonical coverage-check. The
|
|
346
|
+
// count is load-bearing twice over: --final refuses more than one (the attestation capability would
|
|
347
|
+
// reach more than one process) and this predicate must refuse the same declaration, or a consumer
|
|
348
|
+
// would advertise final-capability for a declaration --final then rejects.
|
|
349
|
+
export const canonicalCheckerGates = (gates, projectDir) =>
|
|
350
|
+
gates.filter((g) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], g.cmd, projectDir));
|
|
351
|
+
|
|
330
352
|
// isFinalCapableDeclaration(gates, projectDir) → whether --final would accept this declaration
|
|
331
|
-
// (every canonical core check present +
|
|
332
|
-
// recommendations guard-install probe) read instead
|
|
353
|
+
// (every canonical core check present + EXACTLY ONE canonical checker + that checker LAST) — the
|
|
354
|
+
// ONE home consumers (the recommendations guard-install probe, the worktrees report) read instead
|
|
355
|
+
// of re-deriving the rule.
|
|
333
356
|
export const isFinalCapableDeclaration = (gates, projectDir) => {
|
|
334
357
|
if (!Array.isArray(gates) || gates.length === 0) return false;
|
|
335
358
|
const missing = FINAL_CORE_CHECKS.filter((c) => !gates.some((g) => matchesCanonicalCheck(c, g.cmd, projectDir)));
|
|
336
359
|
if (missing.length > 0) return false;
|
|
360
|
+
if (canonicalCheckerGates(gates, projectDir).length !== 1) return false;
|
|
337
361
|
return matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gates[gates.length - 1].cmd, projectDir);
|
|
338
362
|
};
|
|
339
363
|
const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
|
|
@@ -389,6 +413,13 @@ export const runCli = (argv, deps = {}) => {
|
|
|
389
413
|
if (missing.length > 0) {
|
|
390
414
|
throw fail(EXIT.malformed, `--final refuses a weakened declaration — missing the canonical core check(s): ${missing.map((c) => c.name).join(', ')} (each must be ONE plain --check invocation of the kit's OWN tool in ${GATES_REL} — a masked form, a compound, or a lookalike path never counts)`);
|
|
391
415
|
}
|
|
416
|
+
// EXACTLY ONE canonical checker. Two gates may carry the same canonical cmd under different
|
|
417
|
+
// ids, and the capability is handed to every match — so without this the "exactly one gate
|
|
418
|
+
// holds it" guarantee is prose, and an extra copy would run with a live attestation context.
|
|
419
|
+
const canonicalCheckers = canonicalCheckerGates(declaration.gates, projectDir);
|
|
420
|
+
if (canonicalCheckers.length > 1) {
|
|
421
|
+
throw fail(EXIT.malformed, `--final refuses the declaration — ${canonicalCheckers.length} gates are the canonical coverage-check (${canonicalCheckers.map((g) => JSON.stringify(g.id)).join(', ')}); exactly ONE may be, or the attestation context would be handed to more than one process`);
|
|
422
|
+
}
|
|
392
423
|
const lastGate = declaration.gates[declaration.gates.length - 1];
|
|
393
424
|
if (!matchesCanonicalCheck(FINAL_CORE_CHECKS[1], lastGate.cmd, projectDir)) {
|
|
394
425
|
throw fail(EXIT.malformed, `--final refuses the declaration — the CANONICAL coverage-check gate must be the LAST declared gate (nothing may run after the checker consumed the lcov; "${lastGate.id}" is declared last)`);
|
|
@@ -430,7 +461,29 @@ export const runCli = (argv, deps = {}) => {
|
|
|
430
461
|
}
|
|
431
462
|
// --final needs the pre-run fingerprint (the receipt binds before == after == current).
|
|
432
463
|
const finalFingerprintBefore = opts.final ? fingerprint(projectDir) : null;
|
|
433
|
-
const
|
|
464
|
+
const finalBase = opts.final ? resolveBase(projectDir) ?? '' : null;
|
|
465
|
+
// The attestation handshake (see coverage-check.mjs): a fresh random nonce rides the child
|
|
466
|
+
// environment, and the attempt id this run records is the one-way COMMITMENT over it plus the
|
|
467
|
+
// identity. Persisting only the commitment is what makes the context unreproducible from the
|
|
468
|
+
// repository afterwards — a plain recorded id is public, and attesting from one let an ordinary
|
|
469
|
+
// interrupted run certify a later run's lcov. The commitment is also the only place the BASE is
|
|
470
|
+
// bound, since no record stores it.
|
|
471
|
+
const finalNonce = opts.final ? randomUUID() : null;
|
|
472
|
+
const finalAttempt = opts.final ? commitmentFor(finalNonce, finalFingerprintBefore ?? '', finalBase) : null;
|
|
473
|
+
if (opts.final) {
|
|
474
|
+
// ONLY the canonical checker receives the capability — the same predicate the --final preflight
|
|
475
|
+
// uses to recognise it. Every other gate gets the producer variables and nothing else.
|
|
476
|
+
gateSpawn = (cmd, cwd2) => {
|
|
477
|
+
const producers = { AW_GIT_DIR: gitDir, AW_LCOV_FILE: join(gitDir, LCOV_BASENAME) };
|
|
478
|
+
if (!matchesCanonicalCheck(FINAL_CORE_CHECKS[1], cmd, projectDir)) return spawn(cmd, cwd2, producers);
|
|
479
|
+
return spawn(cmd, cwd2, {
|
|
480
|
+
...producers,
|
|
481
|
+
[ATTEST_NONCE_ENV]: finalNonce,
|
|
482
|
+
[ATTEST_FINGERPRINT_ENV]: finalFingerprintBefore ?? '',
|
|
483
|
+
[ATTEST_BASE_ENV]: finalBase,
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
}
|
|
434
487
|
let finalError = null;
|
|
435
488
|
let startEvidenceHashes = null;
|
|
436
489
|
if (opts.final) {
|
|
@@ -458,6 +511,19 @@ export const runCli = (argv, deps = {}) => {
|
|
|
458
511
|
const results = runGates(selected, { cwd: projectDir, spawn: gateSpawn, log, now });
|
|
459
512
|
for (const line of formatTable(results)) log(line);
|
|
460
513
|
const allGreen = results.every((result) => result.ok);
|
|
514
|
+
// A green gate's stdout is deliberately not echoed — the table IS the report. But the checker
|
|
515
|
+
// exits 0 both when it certifies and when it WITHHOLDS a verdict, so on a plain run the table
|
|
516
|
+
// would read PASS over a coverage claim that was never made: the same false reassurance one
|
|
517
|
+
// layer up from the defect this whole mechanism exists to close. Surface it, and only it.
|
|
518
|
+
if (!opts.final) {
|
|
519
|
+
const checkerAt = selected.findIndex((gate) => matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gate.cmd, projectDir));
|
|
520
|
+
const checkerRow = checkerAt === -1 ? null : results[checkerAt];
|
|
521
|
+
if (checkerRow?.ok && /^coverage-check: attested=no$/m.test(String(checkerRow.stdout ?? ''))) {
|
|
522
|
+
log(`── ${checkerRow.id} — NO COVERAGE VERDICT (the gate passed; it did not certify)`);
|
|
523
|
+
for (const line of String(checkerRow.stdout).split(/\r?\n/).filter((l) => /^coverage-check: (NO VERDICT|skipped-no-lcov)/.test(l))) log(line);
|
|
524
|
+
log(' A coverage verdict is issued only by run-gates.mjs --final, which owns the lcov for the whole run.');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
461
527
|
if (opts.final) {
|
|
462
528
|
// The checker's verbatim diagnostics surface even on green — skipped-no-lcov and the
|
|
463
529
|
// out-of-domain/unsupported lists must never vanish into a suppressed green stdout.
|
|
@@ -493,6 +559,22 @@ export const runCli = (argv, deps = {}) => {
|
|
|
493
559
|
const shaLines = String(checkerRow?.stdout ?? '').split(/\r?\n/).filter((l) => shaLineRe.test(l));
|
|
494
560
|
const shaValue = shaLines.length === 1 ? shaLineRe.exec(shaLines[0])[1] : null;
|
|
495
561
|
const lcovSha256 = shaValue !== null && shaValue !== 'none' ? shaValue : null;
|
|
562
|
+
// The attestation line, on the SAME exactly-one-anchored-line contract as the sha: a green
|
|
563
|
+
// exit status alone never proves the checker certified anything — it exits 0 both when it
|
|
564
|
+
// attests and when it withholds a verdict. Without this arm a gate that removed the start
|
|
565
|
+
// record mid-run would yield a green receipt carrying no coverage claim at all.
|
|
566
|
+
const attestLineRe = /^coverage-check: attested=(yes|no)$/;
|
|
567
|
+
const attestLines = String(checkerRow?.stdout ?? '').split(/\r?\n/).filter((l) => attestLineRe.test(l));
|
|
568
|
+
const attested = attestLines.length === 1 ? attestLineRe.exec(attestLines[0])[1] : null;
|
|
569
|
+
if (allGreen && integrityFailure === null && lcovSha256 !== null) {
|
|
570
|
+
if (attestLines.length !== 1) {
|
|
571
|
+
integrityFailure = attestLines.length === 0
|
|
572
|
+
? 'the coverage-check gate printed no attested= line — whether coverage was certified is unknowable (fail closed)'
|
|
573
|
+
: `the coverage-check gate printed ${attestLines.length} attested= lines — exactly ONE full machine line binds the receipt`;
|
|
574
|
+
} else if (attested !== 'yes') {
|
|
575
|
+
integrityFailure = 'the coverage-check gate consumed an lcov but did NOT certify it — the final run reached the checker without a valid attestation context';
|
|
576
|
+
}
|
|
577
|
+
}
|
|
496
578
|
if (allGreen && integrityFailure === null) {
|
|
497
579
|
if (shaLines.length !== 1) {
|
|
498
580
|
integrityFailure = shaLines.length === 0
|
|
@@ -543,7 +625,10 @@ export const runCli = (argv, deps = {}) => {
|
|
|
543
625
|
logError(`[run-gates] --final could not write its receipt: ${err.message}`);
|
|
544
626
|
}
|
|
545
627
|
}
|
|
546
|
-
|
|
628
|
+
// The summary line is the MACHINE report, so it must agree with the exit code: an integrity
|
|
629
|
+
// failure mints a RED receipt and exits finalFailed, and a line still saying status=ok there
|
|
630
|
+
// would be a silent green in the one place a reader parses instead of reads.
|
|
631
|
+
log(composeSummaryLine({ status: allGreen && finalError === null ? 'ok' : 'fail', results }));
|
|
547
632
|
if (finalError) return EXIT.finalFailed;
|
|
548
633
|
return allGreen ? EXIT.ok : EXIT.fail;
|
|
549
634
|
} catch (err) {
|
|
@@ -149,6 +149,10 @@ export const KIT_READONLY_TOOLS = Object.freeze([
|
|
|
149
149
|
// NO decision from the hook, which is not the same as an allow, so without this rule the lane
|
|
150
150
|
// falls through to whatever the host policy happens to be.
|
|
151
151
|
'tools/repo-search.mjs',
|
|
152
|
+
// The inventory lane, in the tier for the same reason and with the same dependency: the corpus of
|
|
153
|
+
// useless approvals is mostly small path questions batched into a composed shell because no single
|
|
154
|
+
// call answered them, and a lane the agent must still ask about is not a lane.
|
|
155
|
+
'tools/path-inventory.mjs',
|
|
152
156
|
]);
|
|
153
157
|
// Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
|
|
154
158
|
// --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps
|
|
@@ -342,7 +346,7 @@ const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--br
|
|
|
342
346
|
|
|
343
347
|
Allowlist mode (default): seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
|
|
344
348
|
Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
|
|
345
|
-
--kit-tools additionally seeds the audited kit-tool tier:
|
|
349
|
+
--kit-tools additionally seeds the audited kit-tool tier: ${KIT_WILDCARD_TOOLS.length} read-only kit tools by resolved
|
|
346
350
|
absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned byte-string
|
|
347
351
|
(project-exec - it runs YOUR declared gates.json), and the writers' exact arg-free dry-run
|
|
348
352
|
preview byte-strings. Never touches settings.local.json.
|
|
@@ -644,7 +648,7 @@ const formatAllowlist = (result) => [
|
|
|
644
648
|
// The tier's honest posture, printed on every --kit-tools run: run-gates is project-exec (never
|
|
645
649
|
// "read-only"), previews stay dry-run-only, and the tier gets none of the hook's residual ask-net.
|
|
646
650
|
const KIT_TIER_NOTICE =
|
|
647
|
-
'kit-tools tier: paths are resolved absolute at seed time (fail-safe - a moved skill or stale path simply prompts again); run-gates.mjs is seeded as ONE exact byte-string pinned to this project root and is project-exec - it runs YOUR declared gates.json commands, never "read-only"; writer previews are exact dry-run byte-strings - every --apply/--write/--yes still prompts; tier entries get NO PreToolUse-hook residual coverage (settings-level posture only - see the velocity mode notes).';
|
|
651
|
+
'kit-tools tier: paths are resolved absolute at seed time (fail-safe - a moved skill or stale path simply prompts again); run-gates.mjs is seeded as ONE exact byte-string pinned to this project root and is project-exec - it runs YOUR declared gates.json commands, never "read-only"; writer previews are exact dry-run byte-strings - every --apply/--write/--yes still prompts; tier entries get NO PreToolUse-hook residual coverage EXCEPT repo-search.mjs and path-inventory.mjs, whose invocations the hook scans because they take caller-supplied argument bytes (settings-level posture only for the rest - see the velocity mode notes).';
|
|
648
652
|
|
|
649
653
|
const formatKitTier = (result) =>
|
|
650
654
|
result.kitTools
|