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/CHANGELOG.md +53 -0
- package/bareloop.context.md +48 -26
- package/package.json +3 -3
- package/src/authorflow.js +3 -2
- package/src/authoring.js +62 -3
- package/src/authorjob.js +37 -24
- package/src/authorreadout.js +449 -2
- package/src/authorrun.js +21 -4
- package/src/calibrate.js +84 -10
- package/src/cardauthor.js +4 -1
- package/src/declaredclose.js +10 -4
- package/src/job.js +1 -1
- package/src/judged.js +322 -28
- package/src/kinds.js +13 -1
- package/src/legs.js +15 -0
- package/src/panel/authorroutes.js +9 -6
- package/src/panel/authorsession.js +66 -49
- package/src/panel/index.html +321 -355
- package/src/panel/server.js +33 -13
- package/src/panel/status.js +5 -4
- package/src/planrun.js +4 -3
- package/src/providers.js +27 -0
- package/src/ralph.js +8 -2
- package/src/replay.js +23 -3
- package/src/trend.js +1 -0
- package/types/authoring.d.ts +24 -1
- package/types/authorjob.d.ts +17 -10
- package/types/authorreadout.d.ts +106 -0
- package/types/calibrate.d.ts +4 -1
- package/types/declaredclose.d.ts +2 -1
- package/types/judged.d.ts +21 -2
- package/types/legs.d.ts +12 -0
- package/types/panel/authorsession.d.ts +15 -1
- package/types/panel/server.d.ts +5 -4
- package/types/panel/status.d.ts +1 -1
- package/types/providers.d.ts +7 -0
- package/types/ralph.d.ts +6 -1
- package/types/trend.d.ts +1 -0
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
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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,
|
|
268
|
-
if (
|
|
269
|
-
return quote
|
|
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 {
|
|
282
|
+
* @param {any} fn @param {string[]} fields @param {string|null} text
|
|
278
283
|
* @returns {RuleRed[]}
|
|
279
284
|
*/
|
|
280
|
-
function quoteReds(fn, fields,
|
|
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,
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
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,
|
|
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'],
|
|
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,
|
|
655
|
+
check(fn, text) {
|
|
375
656
|
const name = String(fn?.name ?? '(unnamed)');
|
|
376
|
-
const q = quoteReds(fn, ['returnsTagQuote', 'returnsValueQuote'],
|
|
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
|
|
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,
|
|
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 — ${
|
|
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/
|
|
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|
|
|
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 });
|