@descent-vtt/spec-harness 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/configure.js CHANGED
@@ -11,8 +11,9 @@ import { isAbsolute, join } from 'node:path';
11
11
  import { runInNewContext } from 'node:vm';
12
12
  import { CALLABLE_TOOLS } from './hooks.js';
13
13
  import { literal } from './literal.js';
14
+ import { namedTree } from './usage.js';
14
15
  import { parseGlobList } from './vendor/spec-core/pattern/index.js';
15
- import { CLAUDE_CODE_MINIMUM } from './versions.js';
16
+ import { CLAUDE_CODE_MINIMUM, meets, parseVersion } from './versions.js';
16
17
  function isObject(value) {
17
18
  return typeof value === 'object' && value !== null;
18
19
  }
@@ -86,19 +87,72 @@ function upgraded(group) {
86
87
  * `_`, `-`, spaces, `,` and `|` alone, the tools it names, one between each
87
88
  * `|` or `,`; with any other character in it, a JavaScript regular
88
89
  * expression that may match anywhere in a tool's name. `null` where the
89
- * reference does not say, or it cannot be told: a matcher that is not text,
90
- * is no pattern, or is a pattern whose reading runs out its budget.
90
+ * reference does not say, or it cannot be told: a matcher that is neither
91
+ * text nor an array, is no pattern, or is a pattern whose reading runs out
92
+ * its budget.
93
+ *
94
+ * Two things its page on debugging a configuration adds
95
+ * (https://code.claude.com/docs/en/debug-your-config, Check hooks and Check
96
+ * common causes, read 2026-10-14). A matcher that is an array is an entry
97
+ * Claude Code lists as an invalid setting, among the reasons a hook is not
98
+ * loaded and never fires: no tool. And a comma separates names only from
99
+ * {@link COMMA_SINCE} on, so a matcher that lists names with one is read as
100
+ * `commas` has the Claude Code here read it.
91
101
  */
92
- function matchedTools(matcher, pattern) {
102
+ function matchedTools(matcher, pattern, commas) {
93
103
  if (matcher === undefined || matcher === '' || matcher === '*')
94
104
  return CALLABLE_TOOLS;
105
+ if (Array.isArray(matcher))
106
+ return [];
95
107
  if (typeof matcher !== 'string')
96
108
  return null;
97
- if (/^[A-Za-z0-9_\- ,|]*$/.test(matcher)) {
98
- const names = matcher.split(/[|,]/).map((name) => name.trim());
99
- return CALLABLE_TOOLS.filter((tool) => names.includes(tool));
100
- }
101
- return pattern(matcher);
109
+ if (!NAMES.test(matcher))
110
+ return pattern(matcher);
111
+ const comma = commas.as === 'separator' ? null : commaReading(matcher, pattern);
112
+ if (comma === null)
113
+ return listedTools(matcher);
114
+ if (commas.as === 'pattern')
115
+ return comma.earlier;
116
+ // Which of the two readings holds cannot be told, and where both are no
117
+ // tool that writes it need not be.
118
+ return comma.earlier !== null && comma.listed.length === 0 ? comma.listed : null;
119
+ }
120
+ /** A matcher that is a name, or names between `|` and `,`: what the reference compares with a tool's name exactly. */
121
+ const NAMES = /^[A-Za-z0-9_\- ,|]*$/;
122
+ /** The writing tools a matcher of names lists, one between each `|` or `,`. */
123
+ function listedTools(matcher) {
124
+ const names = matcher.split(/[|,]/).map((name) => name.trim());
125
+ return CALLABLE_TOOLS.filter((tool) => names.includes(tool));
126
+ }
127
+ /**
128
+ * The release of Claude Code from which a comma in a matcher separates
129
+ * names as a `|` does. Its page on debugging a configuration (Check hooks,
130
+ * read 2026-10-14) has a comma before it fall through to the reading of a
131
+ * pattern, under which the matcher never matched, and its changelog has the
132
+ * fix under this release: hooks with comma-separated matchers no longer
133
+ * silently never firing.
134
+ */
135
+ export const COMMA_SINCE = '2.1.191';
136
+ /** The reading every release from {@link COMMA_SINCE} on has, and what a caller that asks no release is given. */
137
+ const SEPARATOR = { as: 'separator' };
138
+ /**
139
+ * How the Claude Code doctor asked its release reads a comma. A release that
140
+ * was not asked, or could not be told, leaves the reading untold: asked or
141
+ * not, nothing says which it is.
142
+ */
143
+ export function commasUnder(release) {
144
+ if (release === null || release.state === 'unknown')
145
+ return { as: 'untold' };
146
+ // A release that was told is one whose version was read as one, and the
147
+ // constant is a version, so both parse.
148
+ return meets(parseVersion(release.version), parseVersion(COMMA_SINCE)) ? SEPARATOR : { as: 'pattern', release: release.version };
149
+ }
150
+ /** The two readings of a matcher of names that holds a comma, or `null` for any other matcher, which every release reads one way. */
151
+ function commaReading(matcher, pattern) {
152
+ if (typeof matcher !== 'string' || !NAMES.test(matcher) || !matcher.includes(','))
153
+ return null;
154
+ const read = pattern(matcher);
155
+ return { listed: listedTools(matcher), earlier: read !== null && read.length === 0 ? read : null };
102
156
  }
103
157
  /**
104
158
  * How long the reading of one pattern may take, in milliseconds of the
@@ -169,12 +223,12 @@ function inBackground(hook) {
169
223
  * entry started only for the calls its rule matches, in the syntax of a
170
224
  * permission rule, which is not read here.
171
225
  */
172
- function answered(event, matcher, hook, pattern) {
226
+ function answered(event, matcher, hook, pattern, commas) {
173
227
  if (event === BEFORE && inBackground(hook))
174
228
  return [];
175
229
  if (hook['if'] !== undefined)
176
230
  return null;
177
- return matchedTools(matcher, pattern);
231
+ return matchedTools(matcher, pattern, commas);
178
232
  }
179
233
  /**
180
234
  * `.claude/settings.json`, or the plugin's hooks, with the guard hooks merged
@@ -191,10 +245,14 @@ function answered(event, matcher, hook, pattern) {
191
245
  *
192
246
  * `pattern` reads a matcher that is a pattern: the run's own reader, where
193
247
  * the run has read the file's matchers already ({@link patternReader}).
248
+ * `commas` is how the Claude Code here reads a comma in a matcher of names,
249
+ * where the run asked its release ({@link commasUnder}): a line that
250
+ * release starts for no write is no guard, and init's own is written beside
251
+ * it.
194
252
  */
195
- export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader()) {
253
+ export function mergeClaudeSettings(current, isHook = () => false, pattern = patternReader(), commas = SEPARATOR) {
196
254
  const hooks = isObject(current['hooks']) ? current['hooks'] : {};
197
- const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern)));
255
+ const theirs = harnessEntries(current).some((entry) => !isGuard(entry.hook) && !isLegacyGuard(entry.hook) && isHook(entry.words) && isStarted(toolsOf(entry, pattern, commas)));
198
256
  if (theirs)
199
257
  return null;
200
258
  let changed = false;
@@ -204,7 +262,7 @@ export function mergeClaudeSettings(current, isHook = () => false, pattern = pat
204
262
  const before = Array.isArray(listed) ? listed : [];
205
263
  const groups = before.map(upgraded);
206
264
  const replaced = groups.some((group, index) => group !== before[index]);
207
- const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern)));
265
+ const present = harnessEntries({ hooks: { [event]: groups } }).some((entry) => isGuard(entry.hook) && isStarted(toolsOf(entry, pattern, commas)));
208
266
  if (!present)
209
267
  groups.push({ matcher: HOOK_MATCHER, hooks: [{ ...GUARD_HOOK, args: [...GUARD_ARGS] }] });
210
268
  if (replaced || !present) {
@@ -309,8 +367,8 @@ function sameLine(one, other) {
309
367
  * `null` where that cannot be told. Read where it is asked for and no
310
368
  * sooner: a matcher that is a pattern can cost its whole budget.
311
369
  */
312
- function toolsOf(entry, pattern) {
313
- return answered(entry.event, entry.matcher, entry.hook, pattern);
370
+ function toolsOf(entry, pattern, commas) {
371
+ return answered(entry.event, entry.matcher, entry.hook, pattern, commas);
314
372
  }
315
373
  /** Every entry that runs the harness under the two events the guard answers, in the order the settings hold them. */
316
374
  function harnessEntries(settings) {
@@ -355,18 +413,39 @@ function named(names) {
355
413
  return names.length === 1 ? last : `${names.slice(0, -1).join(', ')} and ${last}`;
356
414
  }
357
415
  /** The writing tools a line's entries under `event` are waited on for between them, or `null` where an entry that cannot be told might add one. */
358
- function toolsUnder(event, entries, pattern) {
359
- const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, pattern));
416
+ function toolsUnder(event, entries, reading) {
417
+ const read = entries.filter((entry) => entry.event === event).map((entry) => toolsOf(entry, reading.pattern, reading.commas));
360
418
  const sure = CALLABLE_TOOLS.filter((tool) => read.some((tools) => tools !== null && tools.includes(tool)));
361
419
  return sure.length < CALLABLE_TOOLS.length && read.some((tools) => tools === null) ? null : sure;
362
420
  }
421
+ /**
422
+ * What is said after a matcher that Claude Code does not read as its text
423
+ * suggests: an array, which it loads no hook under, and names with a comma
424
+ * where the release here reads the comma another way than the newest does,
425
+ * or cannot be told. Nothing for any other matcher, and nothing for a comma
426
+ * where both readings come to no tool that writes.
427
+ */
428
+ function saidOfMatcher(matcher, { pattern, commas }) {
429
+ if (Array.isArray(matcher))
430
+ return ', an array, which Claude Code lists as an invalid setting and loads no hook under';
431
+ if (commas.as === 'separator')
432
+ return '';
433
+ const comma = commaReading(matcher, pattern);
434
+ if (comma === null || (comma.earlier !== null && comma.listed.length === 0))
435
+ return '';
436
+ const listed = comma.listed.length === 0 ? 'no tool that writes' : named(comma.listed);
437
+ const earlier = comma.earlier === null ? 'a pattern whose matches its documentation does not give' : 'a pattern that matches no tool';
438
+ if (commas.as === 'untold')
439
+ return `, which is ${listed} from Claude Code ${COMMA_SINCE} on, where a comma separates names, and ${earlier} before it: the release here cannot be told`;
440
+ return `, which Claude Code ${commas.release} reads as ${earlier}: a comma separates names from ${COMMA_SINCE} on, where the matcher is ${listed}`;
441
+ }
363
442
  /** What a line's entries under `event` hold that decides when Claude Code starts them, as written. */
364
- function entriesUnder(event, entries) {
443
+ function entriesUnder(event, entries, reading) {
365
444
  const under = entries.filter((entry) => entry.event === event);
366
445
  if (under.length === 0)
367
446
  return `it stands under no ${event}`;
368
447
  const said = under.map(({ matcher, hook }) => {
369
- const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}`;
448
+ const pattern = matcher === undefined ? 'no matcher' : `the matcher ${JSON.stringify(matcher)}${saidOfMatcher(matcher, reading)}`;
370
449
  const background = inBackground(hook) ? ' in the background' : '';
371
450
  const rule = hook['if'] === undefined ? '' : ` with "if" ${JSON.stringify(hook['if'])}`;
372
451
  return `${pattern}${background}${rule}`;
@@ -381,24 +460,58 @@ function entriesUnder(event, entries) {
381
460
  * hook still reads a path from, is named as unchecked for no line, since no
382
461
  * such Claude Code has it to call (hooks.ts).
383
462
  */
384
- function describeCover(where, before, after, entries) {
463
+ function describeCover(where, before, after, entries, reading) {
385
464
  const parts = [];
386
465
  const left = (tools) => CALLABLE_TOOLS.filter((tool) => !tools.includes(tool));
387
466
  if (before === null)
388
- parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries)})`);
467
+ parts.push(`which writes Claude Code waits on it before cannot be told here (${entriesUnder(BEFORE, entries, reading)})`);
389
468
  else if (before.length === 0)
390
- parts.push(`Claude Code waits on it before no write, so it is not the guard and refuses none (${entriesUnder(BEFORE, entries)})`);
469
+ parts.push(`Claude Code waits on it before no write, so it is not the guard and refuses none (${entriesUnder(BEFORE, entries, reading)})`);
391
470
  else if (left(before).length > 0) {
392
- parts.push(`Claude Code waits on it before a write by ${named(before)}, and before none by ${named(left(before))}, which goes unchecked (${entriesUnder(BEFORE, entries)})`);
471
+ parts.push(`Claude Code waits on it before a write by ${named(before)}, and before none by ${named(left(before))}, which goes unchecked (${entriesUnder(BEFORE, entries, reading)})`);
393
472
  }
394
473
  if (after === null)
395
- parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries)})`);
474
+ parts.push(`which writes it is started after cannot be told here (${entriesUnder(AFTER, entries, reading)})`);
396
475
  else if (after.length === 0)
397
- parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries)})`);
476
+ parts.push(`it is started after no write, so it warns of none (${entriesUnder(AFTER, entries, reading)})`);
398
477
  else if (left(after).length > 0)
399
- parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries)})`);
478
+ parts.push(`it is started after a write by ${named(after)} alone (${entriesUnder(AFTER, entries, reading)})`);
400
479
  return parts.length === 0 ? null : `${where}: ${parts.join('; ')}`;
401
480
  }
481
+ /** The events under which an array for a matcher keeps Claude Code from loading the rest of a file's hooks. */
482
+ const UNLOADING = [BEFORE, 'PermissionRequest'];
483
+ /**
484
+ * The first array that stands for a matcher under `PreToolUse` or
485
+ * `PermissionRequest` in a settings file, or `null`.
486
+ *
487
+ * Claude Code's page on debugging a configuration
488
+ * (https://code.claude.com/docs/en/debug-your-config, Check hooks, read
489
+ * 2026-10-14) has a matcher that is an array, where it must be one string,
490
+ * as an entry Claude Code lists as an invalid setting, at the start of an
491
+ * interactive session and in `claude doctor`; and where the array is under
492
+ * one of these two events, none of that file's other hooks load either. It
493
+ * names no release. So a file that holds one runs no hook at all, whoever
494
+ * wrote the entry and whatever it runs: the array's own is not loaded, and
495
+ * neither is any other.
496
+ */
497
+ export function arrayMatcher(settings) {
498
+ const hooks = field(settings, 'hooks');
499
+ for (const event of UNLOADING) {
500
+ const groups = field(hooks, event);
501
+ if (!Array.isArray(groups))
502
+ continue;
503
+ const group = groups.find((candidate) => Array.isArray(field(candidate, 'matcher')));
504
+ if (group !== undefined)
505
+ return { event, matcher: JSON.stringify(field(group, 'matcher')) };
506
+ }
507
+ return null;
508
+ }
509
+ /** What is said of a settings file that an array for a matcher keeps Claude Code from loading the hooks of. */
510
+ export function describeArrayMatcher(file, { event, matcher }) {
511
+ return `${file} has an array for a matcher under ${event}, ${matcher}, which Claude Code lists as an invalid setting, and beside one under PreToolUse or PermissionRequest it loads none of the file's other hooks`;
512
+ }
513
+ /** How a matcher that is an array is mended. */
514
+ export const ONE_STRING = 'write the matcher as one string, its names between "|"';
402
515
  /** The words Claude Code hands the hook for a line: what follows the harness's command, each `${CLAUDE_PROJECT_DIR}` filled with `project`. */
403
516
  function handedWords(words, project) {
404
517
  return words.map((word) => word.split(PROJECT_DIR).join(project));
@@ -417,18 +530,29 @@ export function handedLines(sources, project) {
417
530
  * A line that guards and holds no more than a release wrote is reported
418
531
  * with nothing to say, as it always was. One with more on it is said as it
419
532
  * is, and fails nothing. One the hook cannot read, or whose root is no
420
- * directory, is a guard that is off, and one that names a brief which is not
533
+ * directory, or is in no work tree that holds the project, is a guard that
534
+ * is off, and one that names a brief which is not
421
535
  * one holds every write: each a problem, and doctor exits 1 for it. A line
422
536
  * Claude Code waits on before no write is not the guard, whatever the hook
423
537
  * would do with it.
424
538
  *
425
539
  * `pattern` reads a matcher that is a pattern, once for every entry under
426
540
  * it in every file: the run's own reader, where something else in the run
427
- * reads the same files ({@link patternReader}).
541
+ * reads the same files ({@link patternReader}). `commas` is how the Claude
542
+ * Code here reads a comma in a matcher of names, where its release was
543
+ * asked ({@link commasUnder}); a caller that asks none reads a comma as
544
+ * every release from {@link COMMA_SINCE} on does.
545
+ *
546
+ * A file that holds an array for a matcher under `PreToolUse` or
547
+ * `PermissionRequest` has none of its hooks loaded ({@link arrayMatcher}):
548
+ * each line of the hook there is started for no write, and one that stood
549
+ * to be started for a write is a guard that is off.
428
550
  */
429
- export function reportHooks(sources, project, read, pattern = patternReader()) {
551
+ export function reportHooks(sources, project, read, pattern = patternReader(), commas = SEPARATOR) {
552
+ const reading = { pattern, commas };
430
553
  return sources.flatMap(({ file, settings }) => {
431
554
  const entries = harnessEntries(settings);
555
+ const array = arrayMatcher(settings);
432
556
  return writtenHooks(settings).flatMap((hook) => {
433
557
  const handed = read(handedWords(hook.words, project));
434
558
  if (handed === null)
@@ -438,9 +562,23 @@ export function reportHooks(sources, project, read, pattern = patternReader()) {
438
562
  // A line that is not under both events is under one.
439
563
  const where = `"${hook.words.map((word) => (word === '' ? "''" : word)).join(' ')}" in ${file}${both ? '' : ` (under ${hook.events[0]} alone)`}`;
440
564
  const mine = entries.filter((entry) => sameLine(entry, hook));
441
- const before = toolsUnder(BEFORE, mine, pattern);
442
- const after = toolsUnder(AFTER, mine, pattern);
443
- const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine) };
565
+ const before = toolsUnder(BEFORE, mine, reading);
566
+ const after = toolsUnder(AFTER, mine, reading);
567
+ if (array !== null) {
568
+ // Nothing of the file is loaded, whatever the line holds and
569
+ // whatever the hook would do with it. A line that stood to be
570
+ // started for a write, or stands under an array itself, where
571
+ // someone meant a matcher, is a guard set up that checks nothing;
572
+ // one that stood to be started for none leaves no write unchecked
573
+ // that it was ever asked about.
574
+ const unloaded = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before: [], after: [], cover: null, note: null };
575
+ const said = `${where}: ${describeArrayMatcher(file, array)}`;
576
+ if (isStarted(before) || isStarted(after) || mine.some((entry) => Array.isArray(entry.matcher))) {
577
+ return [{ ...unloaded, state: 'off', detail: `${said}, so the guard is off and no write is checked; ${ONE_STRING}` }];
578
+ }
579
+ return [{ ...unloaded, state: 'unstarted', detail: `${said}: the line is not started, and stood to be started for no write as it is, so no guard is switched off` }];
580
+ }
581
+ const written = { file, events: hook.events, line: hook.words, onFailure: hook.blocks ? 'block' : null, before, after, cover: describeCover(where, before, after, mine, reading) };
444
582
  const note = describeOnFailure(where, hook.blocks, false);
445
583
  if (hook.untyped !== null) {
446
584
  const detail = `${where} stands on an entry with ${hook.untyped}, where Claude Code's hooks reference has "type": "command" on an entry it runs as a command, so whether Claude Code runs it cannot be told here: give the entry "type": "command"`;
@@ -454,15 +592,15 @@ export function reportHooks(sources, project, read, pattern = patternReader()) {
454
592
  const unstarted = { ...written, state: 'unstarted', detail: null, note };
455
593
  const waited = before === null || before.length > 0;
456
594
  if (handed.state === 'off') {
457
- const detail = `${where}: ${handed.fault.wrong}, so the guard is off and no write is checked; ${handed.fault.mend}`;
595
+ const detail = `${where}: ${handed.fault.wrong}, so the guard is off and ${handed.unchecked ?? 'no write is checked'}; ${handed.fault.mend}`;
458
596
  // A fault the hook says, it says with exit 1 after every write it is
459
597
  // started for as well, so it is said wherever the line stands.
460
598
  if (handed.silent !== true)
461
599
  return [{ ...written, state: 'off', detail, note: describeOnFailure(where, hook.blocks, true) }];
462
- // A root outside any work tree, of which the hook says nothing: no
463
- // run of it fails there, so onFailure holds no write for it, and
464
- // where the line is waited on before no write there is none it
465
- // leaves unchecked.
600
+ // A root outside any work tree, or in one that does not hold the
601
+ // project, of which the hook says nothing: no run of it fails
602
+ // there, so onFailure holds no write for it, and where the line is
603
+ // waited on before no write there is none it leaves unchecked.
466
604
  return [waited ? { ...written, state: 'off', detail, note } : unstarted];
467
605
  }
468
606
  if (!waited)
@@ -477,7 +615,9 @@ export function reportHooks(sources, project, read, pattern = patternReader()) {
477
615
  if (sameStrings(hook.words, ['hook', 'claude']))
478
616
  return [{ ...written, state: 'guards', detail: null, note }];
479
617
  const root = handed.root === undefined ? '' : `, and its root, ${handed.root}, is a directory`;
480
- return [{ ...written, state: 'guards', detail: `${where} is more than init writes, and guards: the hook reads the line${root}`, note }];
618
+ // Whose briefs a write is checked by, where they are not the project's own.
619
+ const holder = handed.holder === undefined ? '' : ` in ${namedTree(handed.holder)}, which holds this project: the hook checks a write here by that work tree's briefs and its branch, as a file under ${handed.holder.path}/ there`;
620
+ return [{ ...written, state: 'guards', detail: `${where} is more than init writes, and guards: the hook reads the line${root}${holder}`, note }];
481
621
  });
482
622
  });
483
623
  }
@@ -983,6 +1123,10 @@ export function describeGitHook(hook) {
983
1123
  return `${hook.file} runs spec-harness, but is not executable, so git skips it: chmod +x ${hook.file}`;
984
1124
  case 'other':
985
1125
  return `${hook.file} does not run spec-harness: add the line "${GIT_HOOK_LINE}" to it`;
1126
+ case 'stops':
1127
+ return `${hook.file} is ${hook.what}, not a hook, and git stops every commit at it, since it cannot run it ("cannot spawn" on Windows, "cannot exec" elsewhere): nothing is checked there, and nothing is committed. Take it away, then run spec-harness init --git-hook --write`;
1128
+ case 'ignored':
1129
+ return `${hook.file} is ${hook.what}, not a hook, and is not executable, so git skips it and runs no hook: take it away, then run spec-harness init --git-hook --write`;
986
1130
  case 'absent':
987
1131
  return missingGitHook(hook.file);
988
1132
  case 'unknown':