bareloop 0.37.0 → 0.39.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/src/judged.js CHANGED
@@ -63,9 +63,10 @@
63
63
  // persisted raw goes through the ONE `scrubRaw` inventory here, and the CALLER
64
64
  // redacts at its emission boundary, the same split `src/kinds.js` documents.
65
65
 
66
+ import { quoteIn } from 'bareguard';
66
67
  import { createRequire } from 'node:module';
67
68
  import { extractArtifact, priceOf, scrubRaw, rateSourceFields } from './text.js';
68
- import { resolveProvider } from './providers.js';
69
+ import { resolveProvider, judgeCallOptions } from './providers.js';
69
70
 
70
71
  const require = createRequire(import.meta.url);
71
72
 
@@ -257,16 +258,20 @@ export const LOCATE_AXES = Object.freeze({
257
258
  const quoteOf = (/** @type {unknown} */ v) => (typeof v === 'string' && v.trim() !== '' ? v : null);
258
259
 
259
260
  /**
260
- * Every trimmed, non-empty line of `quote` must appear as a trimmed line of the
261
- * artifact. Line-wise and trimmed rather than substring-wise, because a model
262
- * re-indents and a strict byte compare would red honest facts; and every line
263
- * rather than any line, because a half-invented quote is an invented quote.
264
- * @param {string} quote @param {Set<string>|null} lines
261
+ * The quote must be a contiguous run of whole lines of the artifact, in order —
262
+ * bareguard's `quoteIn(.., {wholeLines:true})` (asked by bareloop, F192; shipped in
263
+ * bareguard 0.21.0), the one owner of line matching. Whole lines rather than
264
+ * substring, so a bare `return` cannot pass by hiding inside a longer line; one
265
+ * leading JSDoc/comment decoration is forgiven, because the judge routinely drops
266
+ * the leading star-space of a JSDoc tag line. A quote that is ALL decoration
267
+ * (`/**` alone) proves nothing about location: `has-doc` keeps `docBlockAbove` as
268
+ * the authority on where the block sits.
269
+ * @param {string} quote @param {string|null} text
265
270
  * @returns {boolean} true when the artifact is in hand AND the quote is not in it
266
271
  */
267
- function unquoted(quote, lines) {
268
- if (!lines) return false; // no artifact in hand: decide() says nothing it cannot know
269
- return quote.split('\n').map((l) => l.trim()).filter(Boolean).some((l) => !lines.has(l));
272
+ function unquoted(quote, text) {
273
+ if (text === null) return false; // no artifact in hand: decide() says nothing it cannot know
274
+ return !quoteIn(quote, text, { wholeLines: true }).ok;
270
275
  }
271
276
 
272
277
  /**
@@ -274,54 +279,296 @@ function unquoted(quote, lines) {
274
279
  * Scoped per rule rather than globally so a fabricated `@returns` line reds the
275
280
  * returns item and not the whole card — an itemized red that names the wrong
276
281
  * item is a calibration signal pointing at the wrong card line.
277
- * @param {any} fn @param {string[]} fields @param {Set<string>|null} lines
282
+ * @param {any} fn @param {string[]} fields @param {string|null} text
278
283
  * @returns {RuleRed[]}
279
284
  */
280
- function quoteReds(fn, fields, lines) {
285
+ function quoteReds(fn, fields, text) {
281
286
  /** @type {RuleRed[]} */
282
287
  const reds = [];
283
288
  const name = String(fn?.name ?? '(unnamed)');
284
289
  for (const f of fields) {
285
290
  const q = quoteOf(fn?.[f]);
286
- if (q && unquoted(q, lines)) {
291
+ if (q && unquoted(q, text)) {
287
292
  reds.push({ fn: name, why: `\`${f}\` is not in the artifact — locate quoted a line nobody can find, and unsure is red`, quote: q });
288
293
  }
289
294
  }
290
295
  return reds;
291
296
  }
292
297
 
298
+ /**
299
+ * The trimmed lines of the JSDoc block sitting directly above a declaration, read
300
+ * off the artifact itself. The declaration is its first non-empty quoted line,
301
+ * matched as a trimmed line; found zero or several times it is ambiguous, and
302
+ * unsure is null. "Directly above" is the nearest non-blank line: it must close
303
+ * a comment (`*/`), and the block's opener (the nearest `/*` at or above it) must
304
+ * be a double-star opener — a plain single-star block comment is not a JSDoc block.
305
+ * @param {string} text @param {string|null} declQuote
306
+ * @returns {Set<string>|null} the block's trimmed lines, opener..closer inclusive; null when unsure
307
+ */
308
+ function docBlockAbove(text, declQuote) {
309
+ return locateDocBlock(text, declQuote).block;
310
+ }
311
+
312
+ /**
313
+ * `docBlockAbove` with the reason it came back empty: `declFound` is true when the
314
+ * declaration line matched exactly once (so "no block" is a fact about the artifact),
315
+ * false when it is missing, duplicated or unquoted (so "no block" is only unsure).
316
+ * @param {string} text @param {string|null} declQuote
317
+ * @returns {{declFound: boolean, block: Set<string>|null}}
318
+ */
319
+ function locateDocBlock(text, declQuote) {
320
+ const decl = (declQuote ?? '').split('\n').map((l) => l.trim()).find(Boolean);
321
+ if (!decl) return { declFound: false, block: null };
322
+ const all = text.split('\n').map((l) => l.trim());
323
+ const at = all.flatMap((l, i) => (l === decl ? [i] : []));
324
+ if (at.length !== 1) return { declFound: false, block: null };
325
+ let end = at[0] - 1;
326
+ while (end >= 0 && all[end] === '') end--;
327
+ if (end < 0 || !all[end].endsWith('*/')) return { declFound: true, block: null };
328
+ let start = end;
329
+ while (start >= 0 && !all[start].includes('/*')) {
330
+ if (start < end && all[start].includes('*/')) return { declFound: true, block: null }; // another comment closes first: no opener for this block
331
+ start--;
332
+ }
333
+ if (start < 0 || !all[start].includes('/**')) return { declFound: true, block: null };
334
+ return { declFound: true, block: new Set(all.slice(start, end + 1).filter(Boolean)) };
335
+ }
336
+
337
+ /**
338
+ * THE ONE ACCEPTANCE for "this quote points at the JSDoc block of this declaration":
339
+ * every non-empty line of `docQuote` is a line inside the block directly above the
340
+ * declaration. `has-doc`'s check and the calibration gate's injection facts check
341
+ * both call this, so the two can never disagree about what a good pointer is (F192).
342
+ * @param {string} text the artifact @param {string|null} declQuote @param {string} docQuote
343
+ * @returns {boolean}
344
+ */
345
+ export function docQuoteInBlock(text, declQuote, docQuote) {
346
+ const block = docBlockAbove(text, declQuote);
347
+ return block !== null && docQuote.split('\n').map((l) => l.trim()).filter(Boolean).every((l) => block.has(l));
348
+ }
349
+
350
+ /**
351
+ * Does the artifact mechanically show a JSDoc block directly above function `fn`'s
352
+ * declaration? The declaration line is found by name (function / const-assigned /
353
+ * method shapes); found zero or several times it is ambiguous, and unsure is false
354
+ * (a case is never refused on a guess). The block test is `docBlockAbove` — the
355
+ * one owner `has-doc` itself uses.
356
+ * @param {string} text @param {string} fn @returns {boolean}
357
+ */
358
+ function hasDocBlockDirectlyAbove(text, fn) {
359
+ const n = fn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
360
+ const re = new RegExp(`^(?:export\\s+)?(?:default\\s+)?(?:(?:async\\s+)?function\\s*\\*?\\s*${n}\\b`
361
+ + `|(?:const|let|var)\\s+${n}\\s*=|(?:static\\s+)?(?:async\\s+)?${n}\\s*\\()`);
362
+ const hits = text.split('\n').map((l) => l.trim()).filter((l) => re.test(l));
363
+ if (hits.length !== 1) return false;
364
+ return docBlockAbove(text, hits[0]) !== null;
365
+ }
366
+
367
+ // ── `says-what`: the doc's description is more than the function's own name ──
368
+ //
369
+ // hamr ruling "A" (2026-10-09, F192 addendum): a person's FAIL example is "a doc
370
+ // comment that only restates the function name ... teaches the reader nothing",
371
+ // and `has-doc` cannot express it (a block exists, so it passes). Same shape as
372
+ // every rule here: the judge only POINTS (quotes the description line(s) of the
373
+ // block verbatim); code does the check; unsure is red; the judge never says
374
+ // pass/fail.
375
+ //
376
+ // THE HONEST CEILING: this catches a PURE name echo (the description, minus a
377
+ // small fixed list of function words, adds no word beyond the function name's
378
+ // own words and their light inflections). It does NOT catch a description that
379
+ // adds only generic words: "Returns the total." for `getTotal` passes. A miss
380
+ // of that kind is a new card line and a re-sign, never a smarter judge.
381
+
382
+ /**
383
+ * Function words ONLY: articles, prepositions, conjunctions. No judgement words
384
+ * ("returns", "gets", "function"): a description that adds one of those has added
385
+ * something, and deciding whether it is MEANINGFUL is exactly what this rule
386
+ * refuses to do.
387
+ */
388
+ export const SAYS_WHAT_STOPWORDS = Object.freeze(
389
+ ['a', 'an', 'the', 'of', 'to', 'for', 'in', 'on', 'at', 'by', 'with', 'from', 'into', 'and', 'or', 'as'],
390
+ );
391
+
392
+ /** camelCase / snake_case / kebab-case / digits to lowercase words
393
+ * @param {string} s @returns {string[]} */
394
+ function wordsOf(s) {
395
+ return s
396
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
397
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
398
+ .split(/[^A-Za-z0-9]+/)
399
+ .filter(Boolean)
400
+ .map((w) => w.toLowerCase());
401
+ }
402
+
403
+ /** the light inflections of a word: s, es, ed, d, ing (silent e dropped), ies
404
+ * @param {string} w @returns {Set<string>} */
405
+ function inflections(w) {
406
+ const f = new Set([w, `${w}s`, `${w}es`, `${w}ed`, `${w}d`, `${w}ing`]);
407
+ if (w.endsWith('e')) f.add(`${w.slice(0, -1)}ing`);
408
+ if (w.endsWith('y')) f.add(`${w.slice(0, -1)}ies`);
409
+ return f;
410
+ }
411
+
412
+ /** @param {string} a @param {string} b */
413
+ const sameWord = (a, b) => inflections(a).has(b) || inflections(b).has(a);
414
+
415
+ /**
416
+ * Prefix match (hamr ruling A, 2026-10-09): one word starts with the other and the SHORTER is
417
+ * at least 4 letters, so a description's "Initialize" echoes a name's `init` (and `calc` /
418
+ * `calculate`). HONEST CEILING AND RISK: a name word of 3 or fewer letters never prefix-matches
419
+ * ("add" vs "address", "get" vs "getter"), so those echoes pass; and a real longer word that
420
+ * merely begins with a name word ("read" vs "reader", "parse" vs "parser") counts as an echo,
421
+ * a false red only when the description adds nothing else.
422
+ * @param {string} a @param {string} b @returns {boolean}
423
+ */
424
+ function sharesPrefix(a, b) {
425
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
426
+ return short.length >= 4 && long.startsWith(short);
427
+ }
428
+
429
+ /**
430
+ * The field names a destructured parameter's pattern text declares, read mechanically:
431
+ * `{ x, y }` -> x, y; `{ table, limit = 10 } = {}` -> table, limit; a rename `{ a: b }` -> a (the field is
432
+ * `a`). HONEST CEILING: a nested pattern (`{ a: { b } }`), an array pattern, a rest element (`...rest`) or
433
+ * anything with brackets / parentheses in a default makes the field list unsure, and unsure here means NO
434
+ * fields: the params rule then never reds on fields (conservative, never a false red).
435
+ * @param {string} text @returns {string[]}
436
+ */
437
+ function patternFields(text) {
438
+ const m = /^\s*\{([^{}[\]()]*)\}\s*(?:=\s*\{\s*\})?\s*$/.exec(text);
439
+ if (!m || m[1].includes('...')) return [];
440
+ /** @type {string[]} */
441
+ const out = [];
442
+ for (const part of m[1].split(',')) {
443
+ const field = /^\s*([A-Za-z_$][\w$]*)\s*(?::|=|$)/.exec(part)?.[1];
444
+ if (part.trim() === '') continue;
445
+ if (!field) return [];
446
+ out.push(field);
447
+ }
448
+ return out;
449
+ }
450
+
451
+ /** a doc-block line with its comment decoration (opener, leading star, closer) removed
452
+ * @param {string} line @returns {string} */
453
+ function undecorated(line) {
454
+ return line.trim().replace(/^\/\*+/, '').replace(/\*+\/$/, '').replace(/^\*+/, '').trim();
455
+ }
456
+
457
+ /**
458
+ * The prose of a quoted description: its undecorated lines up to the first `@tag`.
459
+ * @param {string} quote @returns {string[]}
460
+ */
461
+ function descriptionLines(quote) {
462
+ /** @type {string[]} */
463
+ const out = [];
464
+ for (const l of quote.split('\n').map(undecorated)) {
465
+ if (l === '') continue;
466
+ if (l.startsWith('@')) break;
467
+ out.push(l);
468
+ }
469
+ return out;
470
+ }
471
+
293
472
  /**
294
473
  * THE OWNED RULE TABLE. Frozen: the rulebook is the arbiter's, and a caller that
295
474
  * could add a rule at runtime would be authoring the arbiter.
296
- * @type {Readonly<Record<string, {id: string, ask: string, check: (fn: any, lines: Set<string>|null) => RuleRed[]}>>}
475
+ * `means` is the rule's plain-words reach: what it CAN check, and what it cannot. The case-proposal
476
+ * prompt renders it, so a proposed case never expects a red the rule cannot raise.
477
+ * @type {Readonly<Record<string, {id: string, means: string, ask: string, check: (fn: any, text: string|null) => RuleRed[]}>>}
297
478
  */
298
479
  export const JUDGE_RULES = Object.freeze({
299
480
  'has-doc': Object.freeze({
300
481
  id: 'has-doc',
482
+ means: 'checks only that a JSDoc block sits directly above the function; it cannot judge what the block says, '
483
+ + 'so a function with ANY doc block above it never breaks this rule',
301
484
  ask: ' "docQuote": the first line of the JSDoc block (a /** ... */ comment) IMMEDIATELY above the '
302
485
  + 'declaration, VERBATIM, or null if there is no such block',
303
- check(fn, lines) {
486
+ check(fn, text) {
304
487
  const name = String(fn?.name ?? '(unnamed)');
305
488
  const decl = quoteOf(fn?.declarationQuote);
306
489
  const doc = quoteOf(fn?.docQuote);
307
490
  if (!doc) return [{ fn: name, why: `no JSDoc block above the declaration`, quote: decl }];
308
- // quote-anchored, not derived: a "doc" line that is not a JSDoc opener is
309
- // not a doc block, whatever the model called it.
310
- if (!doc.includes('/**')) {
311
- return [{ fn: name, why: 'the quoted line above the declaration is not a JSDoc opener (`/**`)', quote: doc }];
491
+ const q = quoteReds(fn, ['docQuote', 'declarationQuote'], text);
492
+ if (q.length) return q;
493
+ // CODE DOES THE CHECK, THE JUDGE ONLY POINTS (hamr ruling A, F192). The
494
+ // judge was asked for the opener line and routinely quotes the first
495
+ // WORDED line instead; testing the quote for `/**` read that as no doc.
496
+ // Now the quote is only a pointer: the artifact itself says whether a
497
+ // JSDoc block sits directly above the declaration, and the quote passes
498
+ // iff every line of it is inside that block.
499
+ if (typeof text !== 'string') {
500
+ // no artifact in hand (no real caller reaches this): the old quote-anchored test, nothing looser
501
+ if (!doc.includes('/**')) {
502
+ return [{ fn: name, why: 'the quoted line above the declaration is not a JSDoc opener (`/**`)', quote: doc }];
503
+ }
504
+ return [];
505
+ }
506
+ if (!docQuoteInBlock(text, decl, doc)) {
507
+ return [{ fn: name, why: 'the quoted doc line is not inside a JSDoc block (`/** ... */`) directly above the declaration', quote: doc }];
312
508
  }
313
- return quoteReds(fn, ['docQuote', 'declarationQuote'], lines);
509
+ return [];
510
+ },
511
+ }),
512
+
513
+ 'says-what': Object.freeze({
514
+ id: 'says-what',
515
+ means: 'checks that the doc block\'s description says something beyond the function\'s own name: it reds when the '
516
+ + 'description only restates the name (e.g. `parseDate` documented as "Parse the date"). It catches a pure name '
517
+ + 'echo only; a description that adds even generic words ("Returns the total.") passes',
518
+ ask: ' "descriptionQuote": the DESCRIPTION of the JSDoc block IMMEDIATELY above the declaration (the prose '
519
+ + 'line(s) before the first @tag), VERBATIM, or null if the block has no prose before its first tag or there '
520
+ + 'is no block',
521
+ check(fn, text) {
522
+ const name = String(fn?.name ?? '(unnamed)');
523
+ const decl = quoteOf(fn?.declarationQuote);
524
+ const desc = quoteOf(fn?.descriptionQuote);
525
+ // ONE RED PER ABSENCE (F192, 2026-10-09): with NO doc block at all, `has-doc` owns the
526
+ // red and `says-what` stays silent. Only when the artifact itself proves it (the
527
+ // declaration line matched exactly once and no JSDoc block sits above it); a missing or
528
+ // duplicated declaration is unsure, and unsure is red. A block with no prose still reds.
529
+ if (typeof text === 'string' && decl && !quoteReds(fn, ['declarationQuote'], text).length) {
530
+ const loc = locateDocBlock(text, decl);
531
+ if (loc.declFound && loc.block === null) return [];
532
+ }
533
+ if (!desc) return [{ fn: name, why: 'the doc block has no description, so nothing says what the function does', quote: decl }];
534
+ // location + verbatim: unsure is red, and there is no looser no-artifact path
535
+ // (every real caller passes the artifact)
536
+ if (typeof text !== 'string') {
537
+ return [{ fn: name, why: 'no artifact to check the quoted description against: unsure, and unsure is red', quote: desc }];
538
+ }
539
+ const q = quoteReds(fn, ['descriptionQuote', 'declarationQuote'], text);
540
+ if (q.length) return q;
541
+ const block = decl ? docBlockAbove(text, decl) : null;
542
+ const inBlock = block === null ? null : new Set([...block].map(undecorated));
543
+ if (inBlock === null || !desc.split('\n').map(undecorated).filter(Boolean).every((l) => inBlock.has(l))) {
544
+ return [{ fn: name, why: 'the quoted description is not inside the JSDoc block directly above the declaration', quote: desc }];
545
+ }
546
+ const own = wordsOf(name);
547
+ const prose = descriptionLines(desc);
548
+ const said = prose.flatMap(wordsOf)
549
+ .filter((w) => !SAYS_WHAT_STOPWORDS.includes(w))
550
+ .filter((w) => !own.some((o) => sameWord(w, o) || sharesPrefix(w, o)));
551
+ if (said.length === 0) {
552
+ return [{ fn: name, why: `the doc description only restates the function name (\`${prose.join(' ')}\`); it says nothing more than \`${name}\` already does`, quote: desc }];
553
+ }
554
+ return [];
314
555
  },
315
556
  }),
316
557
 
317
558
  params: Object.freeze({
318
559
  id: 'params',
560
+ means: 'checks that the @param tags match the declared parameters: a parameter with no tag, or a tag naming a '
561
+ + 'parameter the function does not have. A destructured parameter (e.g. `{ table, limit = 10 }`) has no name of '
562
+ + 'its own, so it may be documented under ANY root name (`@param options`) and that is normal JSDoc, never a '
563
+ + 'params red; do not propose a case expecting one. But when the doc names the fields of a simple destructured '
564
+ + 'parameter one by one (`@param x`, or `@param opts.x`), EVERY field must be named: `@param x` alone for '
565
+ + '`{ x, y }` is a params red. Nested, array and rest patterns are never reddened on fields',
319
566
  ask: ' "paramNames": the parameter names in the declaration, in order. For a DESTRUCTURED parameter '
320
567
  + '(e.g. `{ a = 1 } = {}`) there is no name — use the literal text of the pattern.\n'
321
568
  + ' "paramIsPattern": one true/false per entry of paramNames, true when that entry is a destructuring '
322
569
  + 'pattern rather than a plain name\n'
323
570
  + ' "paramTagNames": the names appearing in @param tags of that JSDoc block (empty array if none)',
324
- check(fn, lines) {
571
+ check(fn, text) {
325
572
  const name = String(fn?.name ?? '(unnamed)');
326
573
  const decl = quoteOf(fn?.declarationQuote);
327
574
  const params = Array.isArray(fn?.paramNames) ? fn.paramNames.map(String) : null;
@@ -329,7 +576,7 @@ export const JUDGE_RULES = Object.freeze({
329
576
  if (params === null || tags === null) {
330
577
  return [{ fn: name, why: 'locate gave no param facts — unsure, and unsure is red', quote: decl }];
331
578
  }
332
- const q = quoteReds(fn, ['declarationQuote'], lines);
579
+ const q = quoteReds(fn, ['declarationQuote'], text);
333
580
  if (q.length) return q;
334
581
 
335
582
  // DERIVED-FACT HARDENING (the POC measured `paramNames` drifting between
@@ -362,18 +609,52 @@ export const JUDGE_RULES = Object.freeze({
362
609
  } else if (params.length > tags.length) {
363
610
  reds.push({ fn: name, why: `${params.length} param(s) but only ${tags.length} @param tag(s)`, quote: decl });
364
611
  }
612
+ // THE EXTRA TAG (F192 probe: a documented `@param overwrite` on
613
+ // copyFile(src, dest) was reported by locate and never reddened). A tag is
614
+ // extra when its name matches no declared parameter. Tag spellings are
615
+ // normalised first: `[opts]` / `[opts=1]` -> `opts`. A dotted tag
616
+ // (`opts.a`) documents a member of another param and is never extra.
617
+ // A DESTRUCTURED slot has no name to match, so the documented root of such
618
+ // a slot (`@param opts` for `{ a } = {}`) cannot be told from a phantom by
619
+ // name: each pattern slot absorbs ONE unmatched tag, and only the tags
620
+ // beyond that budget are extra. Conservative on purpose — it can miss a
621
+ // phantom sitting beside a pattern, it never reds a documented root.
622
+ const norm = tags.map((t) => t.trim().replace(/^\[/, '').replace(/\]$/, '').replace(/=.*$/, '').trim()).filter(Boolean);
623
+ // THE FIELDS OF A DESTRUCTURED PARAMETER (hamr ruling A, 2026-10-09, live renderPoint): `@param x` alone for
624
+ // `{ x, y }` documents ONE field and leaves the other unnamed. Fields are read off the declared pattern
625
+ // text mechanically (see `patternFields`). When the doc names ANY field of a pattern, bare (`x`) or
626
+ // dotted (`opts.x`, `param0.x`), EVERY field must be named; a doc that names the whole object under one
627
+ // root and no fields (`@param options`) still passes. Bare field tags are the pattern's own, never "extra".
628
+ const fieldsOf = params.map((p, i) => (pattern[i] ? patternFields(p) : null));
629
+ const allFields = new Set(fieldsOf.flatMap((f) => f ?? []));
630
+ for (let i = 0; i < params.length; i += 1) {
631
+ const fields = fieldsOf[i];
632
+ if (!fields || fields.length === 0) continue;
633
+ const said = (/** @type {string} */ f) => norm.some((t) => t === f || t.endsWith(`.${f}`));
634
+ if (!fields.some(said)) continue;
635
+ const gap = fields.find((f) => !said(f));
636
+ if (gap !== undefined) reds.push({ fn: name, why: `@param missing for field ${gap} of ${name}'s destructured parameter`, quote: decl });
637
+ }
638
+ const roots = norm.filter((t) => !/[.[]/.test(t));
639
+ const unmatched = roots.filter((t) => !named.includes(t) && !allFields.has(t));
640
+ const slack = pattern.filter(Boolean).length;
641
+ if (unmatched.length > slack) {
642
+ const extra = unmatched.slice(slack);
643
+ reds.push({ fn: name, why: `@param ${extra.join(', ')} names no parameter of ${name}`, quote: decl });
644
+ }
365
645
  return reds;
366
646
  },
367
647
  }),
368
648
 
369
649
  returns: Object.freeze({
370
650
  id: 'returns',
651
+ means: 'checks that a function whose body returns a value has an @returns tag',
371
652
  ask: ' "returnsTagQuote": the @returns/@return tag line VERBATIM, or null if absent\n'
372
653
  + ' "returnsValueQuote": a VERBATIM `return <something>` line from the function body, or null if the '
373
654
  + 'function never returns a value',
374
- check(fn, lines) {
655
+ check(fn, text) {
375
656
  const name = String(fn?.name ?? '(unnamed)');
376
- const q = quoteReds(fn, ['returnsTagQuote', 'returnsValueQuote'], lines);
657
+ const q = quoteReds(fn, ['returnsTagQuote', 'returnsValueQuote'], text);
377
658
  if (q.length) return q;
378
659
  const value = quoteOf(fn?.returnsValueQuote);
379
660
  const tag = quoteOf(fn?.returnsTagQuote);
@@ -543,6 +824,21 @@ export function validateCalibrationSet(cases, { card = null } = {}) {
543
824
  + 'counted twice, and the floor is over distinct evidence');
544
825
  } else artifacts.add(String(c.artifact));
545
826
 
827
+ // A has-doc red the rulebook cannot raise (F192, ruling 3A): has-doc only
828
+ // checks that a JSDoc block sits directly above the declaration, so a case
829
+ // that expects it on a function that HAS one can never be graded by any
830
+ // judge answer. Read off the artifact by the same owner `has-doc` uses.
831
+ if (str(c.artifact) && obj(c.expect) && Array.isArray(c.expect.reds)) {
832
+ for (const r of c.expect.reds) {
833
+ if (!obj(r) || r.rule !== 'has-doc' || !str(r.fn)) continue;
834
+ if (hasDocBlockDirectlyAbove(String(c.artifact), String(r.fn))) {
835
+ red('calibration-case', `${at}.expect.reds`, `case ${str(c.id) ? c.id : `#${i}`} expects has-doc red on ${r.fn}, `
836
+ + `but ${r.fn} has a JSDoc block directly above it — has-doc only checks that a block exists, so no judge `
837
+ + 'answer can grade this case');
838
+ }
839
+ }
840
+ }
841
+
546
842
  const e = c.expect;
547
843
  if (!obj(e)) { red('calibration-case', `${at}.expect`, `{verdict: ${CASE_VERDICTS.join('|')}, reds: [...]}`); return; }
548
844
  if (!CASE_VERDICTS.includes(e.verdict)) {
@@ -811,7 +1107,7 @@ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
811
1107
  return {
812
1108
  run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
813
1109
  seen = null;
814
- const res = await loop.run(msgs, tools, opts);
1110
+ const res = await loop.run(msgs, tools, { ...judgeCallOptions(provider), ...opts });
815
1111
  return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
816
1112
  },
817
1113
  };
@@ -986,15 +1282,13 @@ export function decide(facts, card, { artifactText = null } = {}) {
986
1282
  }
987
1283
  }
988
1284
 
989
- const lines = typeof artifactText === 'string'
990
- ? new Set(artifactText.split('\n').map((l) => l.trim()).filter(Boolean))
991
- : null;
1285
+ const text = typeof artifactText === 'string' ? artifactText : null;
992
1286
 
993
1287
  const graded = items.map((it) => {
994
1288
  const rule = JUDGE_RULES[it.rule];
995
1289
  /** @type {RuleRed[]} */
996
1290
  const reds = [];
997
- for (const fn of fns) reds.push(...rule.check(fn, lines));
1291
+ for (const fn of fns) reds.push(...rule.check(fn, text));
998
1292
  return { rule: it.rule, text: it.text, ok: reds.length === 0, reds };
999
1293
  });
1000
1294
 
package/src/kinds.js CHANGED
@@ -1897,6 +1897,14 @@ async function runJudgedFloor(stage, ctx) {
1897
1897
  }
1898
1898
  }
1899
1899
 
1900
+ // WHAT failed, as a set of plain keys — one per failing function (a red that names no function
1901
+ // keeps its item key), plus each unsure entry. Its SIZE is the ONE red count: the gap headline
1902
+ // prints it, `trendValueOf` reads it, and `readGrade` scrapes the same number back (F192 e).
1903
+ const redSet = [
1904
+ ...redItems.flatMap((it) => (it.reds.length ? it.reds.map((r) => `${it.path}|${it.rule}|${r.fn}`) : [`${it.path}|${it.rule}`])),
1905
+ ...unsure.map((u) => `${u.path}|unsure|${u.reason}`),
1906
+ ];
1907
+ const redCount = new Set(redSet).size;
1900
1908
  const red = redFiles > 0;
1901
1909
  if (red) {
1902
1910
  // FIRST-RED-WINS in the CARD's own signed order, across every artifact: the
@@ -1904,7 +1912,7 @@ async function runJudgedFloor(stage, ctx) {
1904
1912
  const order = p.card.items.map((/** @type {any} */ it) => it.rule);
1905
1913
  const firstRed = order.find((/** @type {string} */ r) => redItems.some((x) => x.rule === r))
1906
1914
  ?? (redItems[0]?.rule ?? 'unsure');
1907
- gap.push(`${stage.name}: the judged floor is not met — ${redItems.length} card item(s) red across ${redFiles} of `
1915
+ gap.push(`${stage.name}: the judged floor is not met — ${redCount} red(s) across ${redFiles} of `
1908
1916
  + `${paths.length} artifact(s); first red: ${firstRed}`);
1909
1917
  for (const u of unsure) gap.push(` ${u.path}: ${u.reason}`);
1910
1918
  for (const it of redItems) {
@@ -1930,6 +1938,10 @@ async function runJudgedFloor(stage, ctx) {
1930
1938
  redItems: redItems.length,
1931
1939
  unsure,
1932
1940
  redFiles,
1941
+ // the distinct red COUNT (the headline's number and the trend value) and the set it counts;
1942
+ // the set also lets the trend reader compare two numberless judged reds (run mv13ery3)
1943
+ redCount,
1944
+ redSet,
1933
1945
  },
1934
1946
  });
1935
1947
  }
package/src/legs.js CHANGED
@@ -169,3 +169,18 @@ export function watchdogNotePath(spineFile, leg) {
169
169
  export function stopFilePath(spineFile) {
170
170
  return `${spineFile}.stop`;
171
171
  }
172
+
173
+ /**
174
+ * Did a run (or one leg) END on a cap? The ONE spelling of the rule every surface follows (hamr 2026-10-08): the
175
+ * outcome is `cap-halt` / `wall-halt`, or it is `escalated` and the last escalation's category names the cap —
176
+ * `wall-halt`, or `cap-halt` backed by a `money-halt` record (a strike-ladder `cap-halt` is not the money cap).
177
+ * @param {{outcome: string|null|undefined, category?: string|null, moneyHalt?: boolean}} o
178
+ * @returns {'money'|'time'|null}
179
+ */
180
+ export function capKindOf(o) {
181
+ if (o.outcome === 'cap-halt') return 'money';
182
+ if (o.outcome === 'wall-halt') return 'time';
183
+ if (o.outcome !== 'escalated') return null;
184
+ if (o.category === 'wall-halt') return 'time';
185
+ return o.category === 'cap-halt' && o.moneyHalt ? 'money' : null;
186
+ }
@@ -13,7 +13,7 @@
13
13
  // The SIGN route additionally requires the EXACT `specHash` from that
14
14
  // session's own `signing.json` (never trusted from the request body alone
15
15
  // without that check) — a mismatch, a missing gate, or a session not yet
16
- // `prepared` all refuse. No other route in this file (start/send/revise/
16
+ // `prepared` all refuse. No other route in this file (start/send/
17
17
  // sign-prepare) can reach the sign path — `signRun` is the only function
18
18
  // here that ever calls `spawnFn`.
19
19
 
@@ -254,6 +254,13 @@ export function createAuthorRoutes(opts) {
254
254
  const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
255
255
  if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
256
256
  if (!pre.ok) { send(409, pre); return true; }
257
+ // only a run that earned a real green is a reuse source (hamr 2026-10-08) — the pickers already filter, but a
258
+ // hand-made POST must not sign a reuse of an already-green, red or still-running run. Imports are green by ruling;
259
+ // Edit in chat and Resume only READ start-from (GET) and never post a startFrom, so this gate never touches them.
260
+ if (fromImport === null && pre.outcome !== 'green') {
261
+ send(409, { ok: false, error: 'only a run that finished green can be reused — this one did not' });
262
+ return true;
263
+ }
257
264
  const locked = lockedFieldChanged(card, pre.card);
258
265
  if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
259
266
  const rv = validateReuseCard(card, { rows });
@@ -275,7 +282,7 @@ export function createAuthorRoutes(opts) {
275
282
  return true;
276
283
  }
277
284
 
278
- const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps|abandon))?$/.exec(pathname);
285
+ const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|sign-prepare|sign|check-deps|abandon))?$/.exec(pathname);
279
286
  if (!m) { send(404, { ok: false, error: 'not found' }); return true; }
280
287
  const session = sessions.get(m[1]);
281
288
  if (!session) { send(404, { ok: false, error: 'no such session' }); return true; }
@@ -293,10 +300,6 @@ export function createAuthorRoutes(opts) {
293
300
  send(r.ok ? 200 : 400, { ...r, state: session.state });
294
301
  return true;
295
302
  }
296
- if (sub === 'revise') {
297
- session.revise(String(body?.text ?? '')).then((r) => send(r.ok ? 200 : 400, { ...r, state: session.state }));
298
- return true;
299
- }
300
303
  if (sub === 'abandon') {
301
304
  const r = session.abandon();
302
305
  send(r.ok ? 200 : 400, { ...r, state: session.state });