eval-quality 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gates/check-doc-claims.js +239 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -1
|
@@ -50,6 +50,22 @@
|
|
|
50
50
|
// sentence was rewritten fails as a dead entry. That is weaker than deciding
|
|
51
51
|
// the claim and stronger than the nothing that precedes it.
|
|
52
52
|
//
|
|
53
|
+
// A "read" claim by itself proves only that the sentence was once registered,
|
|
54
|
+
// and nothing re-checks the reading afterward: the artifact it was read from
|
|
55
|
+
// can drift for years and the entry stays green. A claim may carry an `asOf`
|
|
56
|
+
// pin against that: a normalized-content sha256 of the file the human actually
|
|
57
|
+
// read, defaulting to the claim's own page. An edit to that file changes the
|
|
58
|
+
// hash and fails the entry, which is what turns "read once" into "read, and
|
|
59
|
+
// still current." A `settles` predicate needs none of this, because it already
|
|
60
|
+
// re-runs every check.
|
|
61
|
+
//
|
|
62
|
+
// The pin only works for a subject this run can read, so it is worth nothing
|
|
63
|
+
// against a fact about another repository or a live system; naming a subject
|
|
64
|
+
// in this tree that is not the actual evidence, such as the page merely
|
|
65
|
+
// stating the fact, buys a false sense of protection rather than none. Naming
|
|
66
|
+
// the real evidence file under `subject`, in-tree, is what makes the pin mean
|
|
67
|
+
// something.
|
|
68
|
+
//
|
|
53
69
|
// What stays outside all eight: editorial judgment, design rationale, anything
|
|
54
70
|
// about the world beyond the tree, any claim about runtime behaviour that only
|
|
55
71
|
// executing the code would settle, and whether a code a page names is the one
|
|
@@ -62,6 +78,7 @@
|
|
|
62
78
|
// Run by `node` directly: Node's type stripping erases types only, so no
|
|
63
79
|
// TypeScript enum, namespace, parameter property, or non-type re-export may
|
|
64
80
|
// appear in this file or anything it imports.
|
|
81
|
+
import { createHash } from 'node:crypto';
|
|
65
82
|
import { realpathSync } from 'node:fs';
|
|
66
83
|
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
67
84
|
import { resolve, sep } from 'node:path';
|
|
@@ -225,6 +242,41 @@ const VocabularyBlock = z
|
|
|
225
242
|
});
|
|
226
243
|
})
|
|
227
244
|
.describe('Every sentence saying a member of your vocabulary is accepted or refused agrees with the two sets.');
|
|
245
|
+
/**
|
|
246
|
+
* The 64-character lowercase-hex shape a sha256 digest prints as, so a
|
|
247
|
+
* truncated or upper-cased paste is refused at configuration load rather than
|
|
248
|
+
* comparing unequal to every subject forever.
|
|
249
|
+
*/
|
|
250
|
+
const Sha256Hex = z
|
|
251
|
+
.string()
|
|
252
|
+
.regex(/^[0-9a-f]{64}$/, 'is not a sha256 hex digest: 64 lowercase hex characters');
|
|
253
|
+
const DatedClaimAsOf = z
|
|
254
|
+
.strictObject({
|
|
255
|
+
subject: RelativePath.optional().describe("Which file the hash pins the claim to, when the judgment is about a file other than the one carrying the sentence. Defaults to the claim's own `file`. Has to be in this tree: a fact about another repository or a live system cannot be pinned, and naming the page that merely states such a fact is worse than naming nothing, since it reads as protected when it is not."),
|
|
256
|
+
hash: Sha256Hex.describe("The subject's normalized-content sha256, taken the moment a human confirmed this claim true against it."),
|
|
257
|
+
})
|
|
258
|
+
.describe('Pins a `read` claim to the content a human read it against, so an edit to that content fails the gate instead of a stale confirmation passing forever. A claim resting on more than one subject wants a predicate over the set, not several pins under one key.');
|
|
259
|
+
const DatedClaimEntry = z
|
|
260
|
+
.strictObject({
|
|
261
|
+
file: RelativePath,
|
|
262
|
+
key: NonEmpty.describe('A distinctive stretch of the sentence, matched literally. It names one sentence: a key short enough to match two lets a new and false claim ride in on an existing registration.'),
|
|
263
|
+
settles: z
|
|
264
|
+
.union([z.literal('read'), ModuleValue])
|
|
265
|
+
.describe('How the claim is settled. A predicate is run and a false answer fails the gate. "read" records that no artifact decides it.'),
|
|
266
|
+
reason: NonEmpty.describe('What the predicate reads, or why nothing in the tree can decide it.'),
|
|
267
|
+
asOf: DatedClaimAsOf.optional(),
|
|
268
|
+
})
|
|
269
|
+
.superRefine((entry, ctx) => {
|
|
270
|
+
// A predicate already re-runs every check; a content pin beside it would be
|
|
271
|
+
// a second staleness rule racing the first, and the two can disagree.
|
|
272
|
+
if (entry.asOf !== undefined && entry.settles !== 'read') {
|
|
273
|
+
ctx.addIssue({
|
|
274
|
+
code: 'custom',
|
|
275
|
+
path: ['asOf'],
|
|
276
|
+
message: 'is set, and settles is a predicate rather than "read"; a predicate is re-checked every run, so pinning a content hash beside it is redundant at best and contradictory at worst',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
});
|
|
228
280
|
const DatedBlock = z
|
|
229
281
|
.strictObject({
|
|
230
282
|
triggers: z
|
|
@@ -233,15 +285,26 @@ const DatedBlock = z
|
|
|
233
285
|
.describe('The shapes a claim takes when its truth depends on when it was written. A sentence matching one has to be registered below.'),
|
|
234
286
|
headings: ProsePattern.optional().describe('A heading that says its section is about what has not happened, so every bullet under one is dated whatever words it uses.'),
|
|
235
287
|
claims: z
|
|
236
|
-
.array(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
.
|
|
288
|
+
.array(DatedClaimEntry)
|
|
289
|
+
.min(1)
|
|
290
|
+
.superRefine((claims, ctx) => {
|
|
291
|
+
// One entry per sentence. Two entries sharing a `file` and `key` both
|
|
292
|
+
// pass the "seen" check below, since it is keyed on the same two
|
|
293
|
+
// values, so a duplicate is not caught there; left unrefused, it
|
|
294
|
+
// double-counts one sentence as two registered claims.
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
claims.forEach((claim, index) => {
|
|
297
|
+
const key = compositeKey(claim.file, claim.key);
|
|
298
|
+
if (seen.has(key)) {
|
|
299
|
+
ctx.addIssue({
|
|
300
|
+
code: 'custom',
|
|
301
|
+
path: [index, 'key'],
|
|
302
|
+
message: `repeats the file and key of an earlier entry (${claim.file}: "${claim.key}"), and a dated claim is registered once; give the sentence one entry`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
seen.add(key);
|
|
306
|
+
});
|
|
307
|
+
}),
|
|
245
308
|
})
|
|
246
309
|
.describe('Every sentence whose truth depends on when it was written is registered with how it is settled.');
|
|
247
310
|
const TranscriptionEntry = z.strictObject({
|
|
@@ -365,6 +428,148 @@ const sentenceAround = (line, offset) => {
|
|
|
365
428
|
const end = stop === -1 ? line.length : offset + stop + 1;
|
|
366
429
|
return line.slice(start, end);
|
|
367
430
|
};
|
|
431
|
+
/**
|
|
432
|
+
* A fenced code block's opening delimiter: three or more backticks or tildes,
|
|
433
|
+
* CommonMark's own two fence characters. Captured so the close can require the
|
|
434
|
+
* same character and at least as many repeats, the way CommonMark itself does:
|
|
435
|
+
* a shorter or differently-charactered run inside the fence is content, not a
|
|
436
|
+
* close.
|
|
437
|
+
*/
|
|
438
|
+
const FENCE_OPEN = /^(`{3,}|~{3,})/;
|
|
439
|
+
const closesFence = (trimmedStart, marker) => new RegExp(`^${marker[0] === '`' ? '`' : '~'}{${marker.length},}\\s*$`).test(trimmedStart);
|
|
440
|
+
/**
|
|
441
|
+
* What an `asOf` hash is taken over. Outside a fenced code block, each line's
|
|
442
|
+
* leading indentation is read as a nesting depth rather than kept byte-exact,
|
|
443
|
+
* and everything after it is trimmed of trailing whitespace and has its
|
|
444
|
+
* internal whitespace runs collapsed to one space; a run of blank lines
|
|
445
|
+
* collapses to one. Depth, not width, is what a nested list or an indented
|
|
446
|
+
* block actually carries: a formatter that reindents an existing structure
|
|
447
|
+
* two spaces to four, say, does not change what the page says and must not
|
|
448
|
+
* trip the pin, but un-nesting a list item, or nesting a new one, is a real
|
|
449
|
+
* structural change and has to. Comparing raw indentation width cannot tell
|
|
450
|
+
* these apart, so depth is tracked with a stack the way an indentation-block
|
|
451
|
+
* language is: a deeper indent than the current top pushes a new level, a
|
|
452
|
+
* shallower one pops back to it, and the line is rewritten with a canonical
|
|
453
|
+
* two-space unit per level of depth rather than its own original width. A
|
|
454
|
+
* fenced block is left byte-exact (line-ending normalized), because
|
|
455
|
+
* indentation inside one is meaning a formatter is not free to move, and
|
|
456
|
+
* either collapsing or renormalizing it would let a broken code sample hide
|
|
457
|
+
* behind a passing gate. A stray shorter or wrongly-charactered fence-like
|
|
458
|
+
* line inside an open fence does not close it, so a nested example fence
|
|
459
|
+
* stays part of the outer block's protected content.
|
|
460
|
+
*
|
|
461
|
+
* What this does not do runs in both directions. Normalization only touches
|
|
462
|
+
* whitespace, so two shapes of real change stay invisible: a hard line
|
|
463
|
+
* break's trailing two spaces can be removed, and content outside a fence
|
|
464
|
+
* that is reindented without crossing a depth boundary, such as a non-fenced
|
|
465
|
+
* indented code sample's own internal width, changes without changing the
|
|
466
|
+
* hash either, since depth tracking only sees where a line sits relative to
|
|
467
|
+
* its neighbors and not what its own further indentation means. Every other
|
|
468
|
+
* markdown-syntax change is a non-whitespace byte and always shows, which is
|
|
469
|
+
* why catching either of those needs real markdown parsing and nothing else
|
|
470
|
+
* here does. In the other direction, a table's delimiter-row padding
|
|
471
|
+
* (`|---|---|` vs `| --- | --- |`) or a prose line rewrapped to a different
|
|
472
|
+
* width changes the hash even though nothing about what the page says
|
|
473
|
+
* changed, so a formatter doing either still trips the pin it was meant to
|
|
474
|
+
* spare. A consumer whose formatter rewraps prose can avoid that one with
|
|
475
|
+
* its own `proseWrap: "preserve"` setting; this gate has no equivalent knob.
|
|
476
|
+
*/
|
|
477
|
+
const normalizeForHash = (text) => {
|
|
478
|
+
const lines = [];
|
|
479
|
+
let fenced = false;
|
|
480
|
+
let fenceMarker = '';
|
|
481
|
+
let blank = false;
|
|
482
|
+
// A stack of indentation widths seen on the path to the current line, the
|
|
483
|
+
// same technique an indentation-block language's own lexer uses: its
|
|
484
|
+
// length, not the raw column number on top of it, is the depth a
|
|
485
|
+
// formatter cannot change just by picking a different unit width.
|
|
486
|
+
const indentStack = [0];
|
|
487
|
+
for (const withCr of text.split('\n')) {
|
|
488
|
+
const raw = withCr.endsWith('\r') ? withCr.slice(0, -1) : withCr;
|
|
489
|
+
const trimmedStart = raw.trimStart();
|
|
490
|
+
if (fenced) {
|
|
491
|
+
if (closesFence(trimmedStart, fenceMarker)) {
|
|
492
|
+
fenced = false;
|
|
493
|
+
lines.push(trimmedStart.trimEnd());
|
|
494
|
+
blank = false;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
lines.push(raw);
|
|
498
|
+
blank = false;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const opened = FENCE_OPEN.exec(trimmedStart);
|
|
502
|
+
if (opened !== null) {
|
|
503
|
+
fenced = true;
|
|
504
|
+
fenceMarker = opened[1];
|
|
505
|
+
lines.push(trimmedStart.trimEnd());
|
|
506
|
+
blank = false;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const content = trimmedStart.replace(/\s+$/, '').replace(/[ \t]+/g, ' ');
|
|
510
|
+
if (content === '') {
|
|
511
|
+
if (blank)
|
|
512
|
+
continue;
|
|
513
|
+
blank = true;
|
|
514
|
+
lines.push('');
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
blank = false;
|
|
518
|
+
const indent = raw.length - trimmedStart.length;
|
|
519
|
+
while (indentStack.length > 1 &&
|
|
520
|
+
indent < indentStack[indentStack.length - 1]) {
|
|
521
|
+
indentStack.pop();
|
|
522
|
+
}
|
|
523
|
+
if (indent > indentStack[indentStack.length - 1]) {
|
|
524
|
+
indentStack.push(indent);
|
|
525
|
+
}
|
|
526
|
+
const depth = indentStack.length - 1;
|
|
527
|
+
lines.push(`${' '.repeat(depth)}${content}`);
|
|
528
|
+
}
|
|
529
|
+
// A document that ends without closing its last fence is malformed, and the
|
|
530
|
+
// trailing bytes inside that open fence are exactly the content the fenced
|
|
531
|
+
// branch above promises to keep byte-exact; trimming into them here would
|
|
532
|
+
// break that promise for the one shape that never legitimately arises in a
|
|
533
|
+
// well-formed page.
|
|
534
|
+
if (!fenced) {
|
|
535
|
+
while (lines.length > 0 && lines[0] === '')
|
|
536
|
+
lines.shift();
|
|
537
|
+
while (lines.length > 0 && lines[lines.length - 1] === '')
|
|
538
|
+
lines.pop();
|
|
539
|
+
}
|
|
540
|
+
return lines.join('\n');
|
|
541
|
+
};
|
|
542
|
+
/** What `dated.claims[].asOf.hash` holds: exported so a human confirming a claim can compute it. */
|
|
543
|
+
export const hashOfSubject = (text) => createHash('sha256').update(normalizeForHash(text)).digest('hex');
|
|
544
|
+
/**
|
|
545
|
+
* An `asOf.subject`'s content, read the way every other path this gate reads
|
|
546
|
+
* is read: a symbolic link is refused rather than followed, the same rule
|
|
547
|
+
* `walkPages` applies to a page and for the same reason, so a pin cannot be
|
|
548
|
+
* moved to point outside the tree the configuration names. A subject that is
|
|
549
|
+
* also a walked page is read from `pageText` rather than the disk a second
|
|
550
|
+
* time, which is both cheaper and, for that case, already covered by
|
|
551
|
+
* `walkPages`'s own symlink refusal.
|
|
552
|
+
*/
|
|
553
|
+
const readSubject = async (root, subject, pageText) => {
|
|
554
|
+
const cached = pageText.get(subject);
|
|
555
|
+
if (cached !== undefined)
|
|
556
|
+
return { kind: 'ok', body: cached.join('\n') };
|
|
557
|
+
const target = resolve(root, subject);
|
|
558
|
+
const info = await lstat(target).catch(() => null);
|
|
559
|
+
if (info === null)
|
|
560
|
+
return { kind: 'error', code: 'ENOENT' };
|
|
561
|
+
if (info.isSymbolicLink())
|
|
562
|
+
return { kind: 'symlink' };
|
|
563
|
+
try {
|
|
564
|
+
return { kind: 'ok', body: await readFile(target, 'utf8') };
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
return {
|
|
568
|
+
kind: 'error',
|
|
569
|
+
code: error.code ?? 'EUNKNOWN',
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
};
|
|
368
573
|
/**
|
|
369
574
|
* Which backticked tokens in a captured stretch count as list members. A
|
|
370
575
|
* spelling rule covers a set whose members share a shape; a module export covers
|
|
@@ -669,6 +874,7 @@ export async function runDocClaims(root, section) {
|
|
|
669
874
|
const seen = new Set();
|
|
670
875
|
let read = 0;
|
|
671
876
|
let derived = 0;
|
|
877
|
+
let pinned = 0;
|
|
672
878
|
for (const page of authoredPages) {
|
|
673
879
|
const lines = pageText.get(page);
|
|
674
880
|
let owedSection = false;
|
|
@@ -715,6 +921,29 @@ export async function runDocClaims(root, section) {
|
|
|
715
921
|
}
|
|
716
922
|
if (entry.settles === 'read') {
|
|
717
923
|
read += 1;
|
|
924
|
+
if (entry.asOf !== undefined) {
|
|
925
|
+
pinned += 1;
|
|
926
|
+
const subject = entry.asOf.subject ?? entry.file;
|
|
927
|
+
const read = await readSubject(root, subject, pageText);
|
|
928
|
+
if (read.kind === 'symlink') {
|
|
929
|
+
fail(`${entry.file}: dated.claims holds "${entry.key}" with asOf.subject "${subject}", ` +
|
|
930
|
+
'which is a symbolic link; name the file it resolves to, so the pin cannot be ' +
|
|
931
|
+
'moved to point outside the tree the configuration names');
|
|
932
|
+
}
|
|
933
|
+
else if (read.kind === 'error') {
|
|
934
|
+
fail(`${entry.file}: dated.claims holds "${entry.key}" with asOf.subject "${subject}", ` +
|
|
935
|
+
`which could not be read (${read.code})`);
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
const computed = hashOfSubject(read.body);
|
|
939
|
+
if (computed !== entry.asOf.hash) {
|
|
940
|
+
fail(`${entry.file}: "${entry.key}" was last confirmed against ${subject} at a ` +
|
|
941
|
+
`different content hash (stored ${entry.asOf.hash}, computed ${computed}); ` +
|
|
942
|
+
`run \`node scripts/hash-doc-claim-subject.ts ${subject}\` and update asOf.hash, ` +
|
|
943
|
+
'or fix/remove the entry');
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
718
947
|
continue;
|
|
719
948
|
}
|
|
720
949
|
derived += 1;
|
|
@@ -723,7 +952,7 @@ export async function runDocClaims(root, section) {
|
|
|
723
952
|
fail(`${entry.file}: "${entry.key}" is no longer true; the check that settles it ` +
|
|
724
953
|
`(${entry.reason}) now answers no`);
|
|
725
954
|
}
|
|
726
|
-
parts.push(`${derived + read} time-sensitive claims registered (${derived} settled by a predicate, ${read} by review)`);
|
|
955
|
+
parts.push(`${derived + read} time-sensitive claims registered (${derived} settled by a predicate, ${read} by review, ${pinned} pinned to a content hash)`);
|
|
727
956
|
}
|
|
728
957
|
// -----------------------------------------------------------------------
|
|
729
958
|
// Class 5: named codes
|
package/dist/index.d.ts
CHANGED
|
@@ -24,4 +24,4 @@ export { SEALED_EVALUATOR_BRIEF_SCHEMA_VERSION } from './core/schemas/sealed-eva
|
|
|
24
24
|
export type { Observation, SealedRunRecord, } from './core/schemas/sealed-run-record.ts';
|
|
25
25
|
export { SEALED_RUN_RECORD_SCHEMA_VERSION } from './core/schemas/sealed-run-record.ts';
|
|
26
26
|
export type { FixtureReset, ManifestationWitness, SensitivityWitness, SensitivityWitnessLeg, WitnessChannel, WitnessInputs, } from './core/schemas/sensitivity-witness.ts';
|
|
27
|
-
export declare const VERSION = "3.
|
|
27
|
+
export declare const VERSION = "3.4.0";
|
package/dist/index.js
CHANGED
|
@@ -38,4 +38,4 @@ export { PROBE_SCHEMA_VERSION } from './core/schemas/probe.js';
|
|
|
38
38
|
export { SCORING_POLICY_SCHEMA_VERSION } from './core/schemas/scoring-policy.js';
|
|
39
39
|
export { SEALED_EVALUATOR_BRIEF_SCHEMA_VERSION } from './core/schemas/sealed-evaluator-brief.js';
|
|
40
40
|
export { SEALED_RUN_RECORD_SCHEMA_VERSION } from './core/schemas/sealed-run-record.js';
|
|
41
|
-
export const VERSION = '3.
|
|
41
|
+
export const VERSION = '3.4.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eval-quality",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Compile disciplined Behavioral Evaluation Contracts and score their ability to catch known defects.",
|
|
5
5
|
"author": "Murat Ozcan",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"check:doc-invocations": "node scripts/gates-cli.ts doc-invocations",
|
|
77
77
|
"check:doc-counts": "node scripts/gates-cli.ts doc-counts",
|
|
78
78
|
"check:doc-claims": "node scripts/gates-cli.ts doc-claims",
|
|
79
|
+
"hash:doc-claim-subject": "node scripts/hash-doc-claim-subject.ts",
|
|
79
80
|
"lint:spine": "python3 scripts/spine-lint/lint_spine.py --registry-ad 5 --workspace-root . --fail-on high",
|
|
80
81
|
"test:spine-lint": "uv run --with pytest pytest scripts/spine-lint/tests -q",
|
|
81
82
|
"build:shareable": "node scripts/build-shareable.mjs",
|