@dzhechkov/harness-core 0.7.12 → 0.8.2
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/.dz-manifest.json +210 -70
- package/README.md +34 -1
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +17 -0
- package/dist/book-kb.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +61 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +116 -2
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +31 -8
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +57 -16
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +11 -9
- package/dist/named-lock.js.map +1 -1
- package/dist/native-dep-probe.d.ts +11 -0
- package/dist/native-dep-probe.d.ts.map +1 -0
- package/dist/native-dep-probe.js +100 -0
- package/dist/native-dep-probe.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -1
- package/dist/operations.js.map +1 -1
- package/dist/parser-safe-region.d.ts +47 -0
- package/dist/parser-safe-region.d.ts.map +1 -0
- package/dist/parser-safe-region.js +47 -0
- package/dist/parser-safe-region.js.map +1 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -1
- package/dist/patterns.js.map +1 -1
- package/dist/profile.d.ts +202 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +536 -0
- package/dist/profile.js.map +1 -0
- package/dist/publish.d.ts +5 -3
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +46 -8
- package/dist/publish.js.map +1 -1
- package/dist/qe-rounds.d.ts +75 -0
- package/dist/qe-rounds.d.ts.map +1 -0
- package/dist/qe-rounds.js +174 -0
- package/dist/qe-rounds.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +5 -1
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +8 -1
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +35 -4
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +86 -23
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts +29 -24
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +183 -61
- package/dist/sign.js.map +1 -1
- package/dist/store-location.d.ts +56 -0
- package/dist/store-location.d.ts.map +1 -0
- package/dist/store-location.js +56 -0
- package/dist/store-location.js.map +1 -0
- package/dist/store-lock.d.ts +1 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +7 -1
- package/dist/store-lock.js.map +1 -1
- package/dist/store-merge.d.ts +55 -0
- package/dist/store-merge.d.ts.map +1 -0
- package/dist/store-merge.js +77 -0
- package/dist/store-merge.js.map +1 -0
- package/dist/teach-target.d.ts +61 -0
- package/dist/teach-target.d.ts.map +1 -0
- package/dist/teach-target.js +105 -0
- package/dist/teach-target.js.map +1 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +63 -20
- package/dist/vector-tier.js.map +1 -1
- package/package.json +13 -13
- package/sbom.json +424 -70
- package/src/book-kb.ts +17 -0
- package/src/feature-adr-checkpoints.ts +111 -2
- package/src/index.ts +39 -1
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +74 -17
- package/src/named-lock.ts +10 -8
- package/src/native-dep-probe.ts +118 -0
- package/src/operations.ts +77 -1
- package/src/parser-safe-region.ts +65 -0
- package/src/patterns.ts +10 -1
- package/src/profile.ts +593 -0
- package/src/publish.ts +43 -7
- package/src/qe-rounds.ts +226 -0
- package/src/registry.ts +5 -1
- package/src/run-records.ts +9 -1
- package/src/setup.ts +87 -23
- package/src/sign.ts +180 -60
- package/src/store-location.ts +76 -0
- package/src/store-lock.ts +8 -1
- package/src/store-merge.ts +91 -0
- package/src/teach-target.ts +126 -0
- package/src/vector-tier.ts +58 -9
package/src/book-kb.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
16
16
|
import { pathToFileURL } from 'node:url';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
18
|
|
|
19
|
+
import { describeNativeDep, exerciseSqliteOpen, probeNativeDep } from './native-dep-probe.js';
|
|
20
|
+
|
|
19
21
|
/** A digitized Knowledge Unit as stored in the book KB. */
|
|
20
22
|
export interface BookKU {
|
|
21
23
|
readonly book: string; // ISBN or book slug (the immutable upstream key)
|
|
@@ -64,6 +66,11 @@ const SCHEMA = `CREATE VIRTUAL TABLE IF NOT EXISTS book_knowledge USING fts5(
|
|
|
64
66
|
);`;
|
|
65
67
|
|
|
66
68
|
async function openDb(projectRoot: string, dbPath?: string): Promise<NativeDb | { error: string }> {
|
|
69
|
+
const verdict = probeNativeDep(projectRoot, 'better-sqlite3', exerciseSqliteOpen, 'sqlite-open');
|
|
70
|
+
if (verdict.state === 'absent') {
|
|
71
|
+
return { error: 'better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
72
|
+
}
|
|
73
|
+
if (verdict.state === 'unusable') return { error: describeNativeDep(verdict) };
|
|
67
74
|
let sqliteUrl: string;
|
|
68
75
|
try {
|
|
69
76
|
const req = createRequire(join(projectRoot, 'package.json'));
|
|
@@ -71,6 +78,16 @@ async function openDb(projectRoot: string, dbPath?: string): Promise<NativeDb |
|
|
|
71
78
|
} catch {
|
|
72
79
|
return { error: 'better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
73
80
|
}
|
|
81
|
+
// The probe above absorbs the DEPENDENCY failures — absent, or present-but-unloadable — which is
|
|
82
|
+
// what cross-family review (F2) required: those are instrument failures and must be RETURNED.
|
|
83
|
+
//
|
|
84
|
+
// What follows must still THROW, and that is deliberate. `dz recall --books` distinguishes two
|
|
85
|
+
// states by throw-vs-return, and collapsing them loses a shipped honesty contract (measured
|
|
86
|
+
// 2026-08-27, `books-recall-honesty.test.ts`):
|
|
87
|
+
// throws -> the STORE ITSELF is unreadable -> exit 1, naming the path and a cure
|
|
88
|
+
// {error} -> the INSTRUMENT did not run -> exit 3 under --semantic, never a clean 0-hit
|
|
89
|
+
// A corrupted `books.sqlite` is the first state. Wrapping it into `{error}` made a corrupt store
|
|
90
|
+
// read as a missing instrument and exit 0 — a false clean pass over unreadable data.
|
|
74
91
|
const path = dbPath ?? bookKbPath(projectRoot);
|
|
75
92
|
mkdirSync(dirname(path), { recursive: true });
|
|
76
93
|
const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => NativeDb };
|
|
@@ -497,6 +497,113 @@ export const TRAINPAIR_PRIVACY_NOTE =
|
|
|
497
497
|
/** Coerce a stage input/output to text: strings pass through; objects serialize to JSON;
|
|
498
498
|
* an unserializable value degrades to String(v) — buildTrainingPair NEVER throws (capture
|
|
499
499
|
* is non-blocking by contract). */
|
|
500
|
+
/** Marker pair of the operator-profile block (feature operator-profile, ADR-001 Decision 5).
|
|
501
|
+
* DELIBERATE LOCAL COPIES of profile.ts's PROFILE_MARKER_START/END: this module stays import-free
|
|
502
|
+
* so the workflow can mirror it inline. `test/profile-redaction.test.ts` pins the pairs equal. */
|
|
503
|
+
export const TP_PROFILE_MARKER_START = '<!-- dz:profile:start -->';
|
|
504
|
+
export const TP_PROFILE_MARKER_END = '<!-- dz:profile:end -->';
|
|
505
|
+
|
|
506
|
+
/** What a redacted block is replaced with — visible in the dataset, so a missing profile is
|
|
507
|
+
* distinguishable from a never-present one. */
|
|
508
|
+
export const TP_PROFILE_REDACTED = '[dz:profile REDACTED]';
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Strip every operator-profile block from `text` BEFORE a training pair is persisted.
|
|
512
|
+
*
|
|
513
|
+
* Why here and not "the guard already says never write the profile into a project": training-pair
|
|
514
|
+
* capture records the FULL prompt as the model received it into `.dz/fa-training/`, which is
|
|
515
|
+
* deliberately NOT gitignored — so a profile injected into context would reach a committable
|
|
516
|
+
* directory, and the never-in-a-project guard would be defeated through this path (ADR-001
|
|
517
|
+
* Decision 5, exit 3). Redaction at the single assembly seam closes it for capture AND backfill.
|
|
518
|
+
*
|
|
519
|
+
* Semantics: every complete `start…end` span is replaced (markers included) with
|
|
520
|
+
* {@link TP_PROFILE_REDACTED}. A START marker with no matching END fails CLOSED — everything from
|
|
521
|
+
* the marker to the end of the text is dropped (over-redaction is a lost training sample;
|
|
522
|
+
* under-redaction is a personal-data leak). Text without markers passes through byte-identical.
|
|
523
|
+
* Works on JSON-stringified payloads too: the marker literals contain no characters JSON escapes.
|
|
524
|
+
*/
|
|
525
|
+
export function redactProfileBlock(text: string): string {
|
|
526
|
+
if (typeof text !== 'string' || text === '') return typeof text === 'string' ? text : '';
|
|
527
|
+
let out = '';
|
|
528
|
+
let rest = text;
|
|
529
|
+
for (;;) {
|
|
530
|
+
const start = rest.indexOf(TP_PROFILE_MARKER_START);
|
|
531
|
+
if (start === -1) return out + rest;
|
|
532
|
+
out += rest.slice(0, start) + TP_PROFILE_REDACTED;
|
|
533
|
+
const end = rest.indexOf(TP_PROFILE_MARKER_END, start + TP_PROFILE_MARKER_START.length);
|
|
534
|
+
if (end === -1) return out; // unterminated block: fail closed, drop the tail
|
|
535
|
+
rest = rest.slice(end + TP_PROFILE_MARKER_END.length);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Deep redaction over an already-PARSED training-pair payload — the PERSIST-SIDE half of CF-6.
|
|
541
|
+
*
|
|
542
|
+
* Why a second entry point next to {@link redactProfileBlock}: the DEFAULT-ON capture in the
|
|
543
|
+
* canonical workflow builds its pair with an INLINE mirror of buildTrainingPair and hands the
|
|
544
|
+
* serialised JSON to `dz feature-adr-record` — a path that never passes through the core builder.
|
|
545
|
+
* Redacting at the builder alone therefore guarded the path that does NOT run (Codex cross-family
|
|
546
|
+
* finding, 2026-08-28: guard 1 defeated through guard 3, one seam further down — the exact shape
|
|
547
|
+
* ADR-001 Decision 5 names). This function runs inside `decideRecordWrite` (run-records.ts), the
|
|
548
|
+
* one decision every witnessed training-pair write funnels through, so a FUTURE pair builder is
|
|
549
|
+
* covered without patching its caller.
|
|
550
|
+
*
|
|
551
|
+
* Semantics: every string leaf (keys included) goes through {@link redactProfileBlock} — same
|
|
552
|
+
* fail-closed rule on an unterminated block; arrays and plain objects are walked; numbers,
|
|
553
|
+
* booleans and null pass through untouched. A payload with no markers anywhere round-trips to a
|
|
554
|
+
* deep-equal value.
|
|
555
|
+
*
|
|
556
|
+
* Two mechanics, both cross-family findings (2026-08-28), both load-bearing:
|
|
557
|
+
*
|
|
558
|
+
* - Rebuilt objects have a NULL prototype, so every JSON key — `__proto__` included — lands as an
|
|
559
|
+
* OWN property. The previous `{}` + assignment invoked the inherited `__proto__` SETTER for a
|
|
560
|
+
* payload like `{"slug":"s","stage":"code","__proto__":{"input":"i","output":"o"}}`: the result
|
|
561
|
+
* then INHERITED input/output (the shape check passed) while serialization dropped them — an
|
|
562
|
+
* invalid pair reported `written`. With own-key reconstruction the JSON keys round-trip exactly
|
|
563
|
+
* and that payload fails the shape check honestly.
|
|
564
|
+
* - The walk is ITERATIVE (explicit stack), not recursive: ~5000 nested arrays is a ~10 KB payload
|
|
565
|
+
* that passes JSON.parse and sits under the line cap, but a recursive map hit RangeError before
|
|
566
|
+
* any size guard — a throw escaping a seam whose callers promise non-blocking verdicts. The
|
|
567
|
+
* iterative walk chose over a caught-RangeError→`refused` wrapper because it keeps the honest
|
|
568
|
+
* outcome for deep-but-valid payloads (they get redacted and judged on their merits) instead of
|
|
569
|
+
* refusing them at an arbitrary engine-dependent depth. A repeated container is walked once and
|
|
570
|
+
* reused (WeakMap), so a shared or cyclic reference can never loop the walk either.
|
|
571
|
+
*/
|
|
572
|
+
export function redactTrainingPayload(v: unknown): unknown {
|
|
573
|
+
if (typeof v === 'string') return redactProfileBlock(v);
|
|
574
|
+
if (v === null || typeof v !== 'object') return v;
|
|
575
|
+
const makeDst = (src: object): Record<string, unknown> | unknown[] =>
|
|
576
|
+
Array.isArray(src) ? new Array(src.length) : (Object.create(null) as Record<string, unknown>);
|
|
577
|
+
const dstOf = new WeakMap<object, Record<string, unknown> | unknown[]>();
|
|
578
|
+
const root = makeDst(v);
|
|
579
|
+
dstOf.set(v, root);
|
|
580
|
+
const stack: object[] = [v];
|
|
581
|
+
const walkChild = (child: unknown): unknown => {
|
|
582
|
+
if (typeof child === 'string') return redactProfileBlock(child);
|
|
583
|
+
if (child === null || typeof child !== 'object') return child;
|
|
584
|
+
let dst = dstOf.get(child);
|
|
585
|
+
if (dst === undefined) {
|
|
586
|
+
dst = makeDst(child);
|
|
587
|
+
dstOf.set(child, dst);
|
|
588
|
+
stack.push(child);
|
|
589
|
+
}
|
|
590
|
+
return dst;
|
|
591
|
+
};
|
|
592
|
+
while (stack.length > 0) {
|
|
593
|
+
const src = stack.pop()!;
|
|
594
|
+
const dst = dstOf.get(src)!;
|
|
595
|
+
if (Array.isArray(src)) {
|
|
596
|
+
const arr = dst as unknown[];
|
|
597
|
+
for (let i = 0; i < src.length; i++) arr[i] = walkChild(src[i]);
|
|
598
|
+
} else {
|
|
599
|
+
const obj = dst as Record<string, unknown>;
|
|
600
|
+
// null prototype ⇒ this assignment defines an OWN property even for the key '__proto__'
|
|
601
|
+
for (const [key, value] of Object.entries(src)) obj[redactProfileBlock(key)] = walkChild(value);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return root;
|
|
605
|
+
}
|
|
606
|
+
|
|
500
607
|
function coerceText(v: unknown): string {
|
|
501
608
|
if (typeof v === 'string') return v;
|
|
502
609
|
if (v === null || v === undefined) return '';
|
|
@@ -526,8 +633,10 @@ export function buildTrainingPair(opts: {
|
|
|
526
633
|
captureMode?: unknown;
|
|
527
634
|
resumed?: unknown;
|
|
528
635
|
}): TrainingPair {
|
|
529
|
-
|
|
530
|
-
|
|
636
|
+
// Operator-profile redaction FIRST — before the oversize guard, so the truncation hashes are
|
|
637
|
+
// hashes of the redacted text and the full-text fnv1a64 never fingerprints personal data.
|
|
638
|
+
let input = redactProfileBlock(coerceText(opts.input));
|
|
639
|
+
let output = redactProfileBlock(coerceText(opts.output));
|
|
531
640
|
let truncated: TrainingPairTruncation | null = null;
|
|
532
641
|
if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {
|
|
533
642
|
truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };
|
package/src/index.ts
CHANGED
|
@@ -182,7 +182,8 @@ export type {
|
|
|
182
182
|
ReindexVectorReport,
|
|
183
183
|
TeachGuardResult,
|
|
184
184
|
} from './vector-tier.js';
|
|
185
|
-
export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION
|
|
185
|
+
export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION,
|
|
186
|
+
agentdbStorePath, agentdbMcpStorePath, agentdbStoreSeparationProblem } from './setup.js';
|
|
186
187
|
export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath } from './statusline.js';
|
|
187
188
|
export type { StatuslineData, FeatureAdrState, WriteFeatureAdrStateInput } from './statusline.js';
|
|
188
189
|
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES } from './agentdb-index.js';
|
|
@@ -251,6 +252,11 @@ export {
|
|
|
251
252
|
serializeTrainingPair,
|
|
252
253
|
trainingPairPath,
|
|
253
254
|
trainingPairAppendCmd,
|
|
255
|
+
// operator-profile (ADR-001 Decision 5): the profile block never reaches .dz/fa-training/.
|
|
256
|
+
redactProfileBlock,
|
|
257
|
+
TP_PROFILE_MARKER_START,
|
|
258
|
+
TP_PROFILE_MARKER_END,
|
|
259
|
+
TP_PROFILE_REDACTED,
|
|
254
260
|
// wave1-instrument-repair (ADR-003 Condition 3): the persist allowlist + composite shape check.
|
|
255
261
|
codeCheckpointPersistAllowed,
|
|
256
262
|
codeStageResultShapeValid,
|
|
@@ -691,6 +697,12 @@ export type {
|
|
|
691
697
|
CodexQeDeclineDetail,
|
|
692
698
|
CodexReviewResult,
|
|
693
699
|
} from './feature-adr-routing.js';
|
|
700
|
+
export {
|
|
701
|
+
PARSER_SAFE_REGION_START,
|
|
702
|
+
PARSER_SAFE_REGION_END,
|
|
703
|
+
checkParserSafeRegion,
|
|
704
|
+
} from './parser-safe-region.js';
|
|
705
|
+
export type { ParserSafeRegionVerdict, ParserSafeRegionRefusal } from './parser-safe-region.js';
|
|
694
706
|
export {
|
|
695
707
|
CLAUDE_USAGE_MODELS,
|
|
696
708
|
computeUsage,
|
|
@@ -858,3 +870,29 @@ export { quiescenceProbeScript, decideWriterQuiescence, WQ_WINDOW_SECONDS, WQ_MA
|
|
|
858
870
|
export type { WriterQuiescenceDecision } from './writer-quiescence.js';
|
|
859
871
|
export { decideCadenceWindow, isoWeekOf, weeklyBuckets, guardRepeatDecay, buildCadenceReport, CADENCE_WINDOW_DAYS } from './cadence.js';
|
|
860
872
|
export type { CadenceWindow, CadenceReport, CadenceWindowDecision } from './cadence.js';
|
|
873
|
+
export { readQeRounds, countQeRounds, QE_ROUNDS_DEFAULT_CEILING } from './qe-rounds.js';
|
|
874
|
+
export type { QeRound, QeFailedAttempt, QeRoundsReport, QeRoundsStatus } from './qe-rounds.js';
|
|
875
|
+
export { describeStoreLocation, storeLocationLine } from './store-location.js';
|
|
876
|
+
export type { StoreLocation, StoreOrigin } from './store-location.js';
|
|
877
|
+
export { mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel } from './store-merge.js';
|
|
878
|
+
export type { HitOrigin, OriginTagged } from './store-merge.js';
|
|
879
|
+
export { resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, TEACH_STORES } from './teach-target.js';
|
|
880
|
+
export type { TeachStore, TeachReason, TeachTarget } from './teach-target.js';
|
|
881
|
+
export { probeNativeDep, describeNativeDep, exerciseSqliteOpen } from './native-dep-probe.js';
|
|
882
|
+
export type { NativeDepState, NativeDepVerdict } from './native-dep-probe.js';
|
|
883
|
+
|
|
884
|
+
// Operator profile (feature operator-profile, ADR-001) — WHO the assistant is talking to: a
|
|
885
|
+
// per-user 0600 store under homedir(), rendered into a marked block in ~/.claude/CLAUDE.md
|
|
886
|
+
// (layer 2 — loaded in every project, no dz required). The register changes FORM, never FACTS.
|
|
887
|
+
export {
|
|
888
|
+
renderProfileBlock, readProfile, writeProfile, validateProfile,
|
|
889
|
+
profileStorePath, claudeMdPath, syncProfileBlock, checkProfileDrift,
|
|
890
|
+
mergeProfileBlock, extractProfileBlock, wrapProfileBlock,
|
|
891
|
+
parseRegister, registerOwnerWord, profileAgeDays,
|
|
892
|
+
parseDomainList, domainListText, parseYesNo,
|
|
893
|
+
PROFILE_MARKER_START, PROFILE_MARKER_END, REGISTERS,
|
|
894
|
+
} from './profile.js';
|
|
895
|
+
export type {
|
|
896
|
+
Register, Domain, OperatorProfile, ProfileReadResult, ProfileSyncResult,
|
|
897
|
+
ProfileDriftVerdict, ProfileDriftResult,
|
|
898
|
+
} from './profile.js';
|
|
@@ -52,11 +52,11 @@ export const BLOBS: Record<string, LoopBlob> = {
|
|
|
52
52
|
"training-pairs": {
|
|
53
53
|
name: "training-pairs",
|
|
54
54
|
version: "1.1.0",
|
|
55
|
-
contentHash: "
|
|
55
|
+
contentHash: "199eaaa8ce5dba4f54744b4a4f34eea32a3c33d4de8dbbddd3d119b70459361d",
|
|
56
56
|
sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts",
|
|
57
57
|
requires: ["checkpoints"],
|
|
58
58
|
exports: ["TRAINPAIR_SCHEMA_VERSION","TRAINPAIR_MAX_IO_CHARS","trainingPairFamily","trainingPairPath","TRAINPAIR_PRIVACY_NOTE","buildTrainingPair","serializeTrainingPair","trainingPairAppendCmd","decideCaptureMode","captureFailureRecord","trainingPairBackfillCmd","TP_BACKFILL_OK","TP_BACKFILL_SKIP"],
|
|
59
|
-
code: "function decideCaptureMode(opts) {\n if (!opts.enabled)\n return 'skip-disabled';\n if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0)\n return 'skip-empty';\n return opts.resumed ? 'backfill' : 'capture';\n}\nfunction captureFailureRecord(stage, mode, reason, detail) {\n const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';\n const normalizedMode = mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'\n ? mode\n : null;\n const normalizedReason = reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'\n ? reason\n : 'threw';\n let normalizedDetail = null;\n if (detail !== null && detail !== undefined) {\n try {\n const text = String(detail);\n if (text !== '')\n normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;\n }\n catch {\n normalizedDetail = null;\n }\n }\n return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };\n}\nconst TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = \"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.\";\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = coerceText(opts.input);\n let output = coerceText(opts.output);\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',\n resumed: opts.resumed === true,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}\nconst TP_BACKFILL_OK = 'TP-BACKFILL-OK';\nconst TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';\nconst TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';\nfunction trainingPairBackfillCmd(repoAbs, slug, stage, lines, markKey) {\n if (typeof repoAbs !== 'string' || repoAbs === '')\n return null;\n if (typeof slug !== 'string' || slug === '')\n return null;\n if (typeof stage !== 'string' || stage === '')\n return null;\n if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== ''))\n return null;\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';\n const markStage = stage.replace(/\\.\\./g, '_').replace(/\\//g, '_');\n const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\\0' + lines.join('\\n')) : markKey;\n const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;\n const appends = lines\n .map(line => \"printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs))\n .join(' && ');\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n ' && mkdir -p ' + shellQuote(markDir) +\n ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +\n 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +\n '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +\n '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi');\n}",
|
|
59
|
+
code: "function decideCaptureMode(opts) {\n if (!opts.enabled)\n return 'skip-disabled';\n if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0)\n return 'skip-empty';\n return opts.resumed ? 'backfill' : 'capture';\n}\nfunction captureFailureRecord(stage, mode, reason, detail) {\n const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';\n const normalizedMode = mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'\n ? mode\n : null;\n const normalizedReason = reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'\n ? reason\n : 'threw';\n let normalizedDetail = null;\n if (detail !== null && detail !== undefined) {\n try {\n const text = String(detail);\n if (text !== '')\n normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;\n }\n catch {\n normalizedDetail = null;\n }\n }\n return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };\n}\nconst TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = \"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.\";\nconst TP_PROFILE_MARKER_START = '<!-- dz:profile:start -->';\nconst TP_PROFILE_MARKER_END = '<!-- dz:profile:end -->';\nconst TP_PROFILE_REDACTED = '[dz:profile REDACTED]';\nfunction redactProfileBlock(text) {\n if (typeof text !== 'string' || text === '')\n return typeof text === 'string' ? text : '';\n let out = '';\n let rest = text;\n for (;;) {\n const start = rest.indexOf(TP_PROFILE_MARKER_START);\n if (start === -1)\n return out + rest;\n out += rest.slice(0, start) + TP_PROFILE_REDACTED;\n const end = rest.indexOf(TP_PROFILE_MARKER_END, start + TP_PROFILE_MARKER_START.length);\n if (end === -1)\n return out;\n rest = rest.slice(end + TP_PROFILE_MARKER_END.length);\n }\n}\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = redactProfileBlock(coerceText(opts.input));\n let output = redactProfileBlock(coerceText(opts.output));\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',\n resumed: opts.resumed === true,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}\nconst TP_BACKFILL_OK = 'TP-BACKFILL-OK';\nconst TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';\nconst TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';\nfunction trainingPairBackfillCmd(repoAbs, slug, stage, lines, markKey) {\n if (typeof repoAbs !== 'string' || repoAbs === '')\n return null;\n if (typeof slug !== 'string' || slug === '')\n return null;\n if (typeof stage !== 'string' || stage === '')\n return null;\n if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== ''))\n return null;\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';\n const markStage = stage.replace(/\\.\\./g, '_').replace(/\\//g, '_');\n const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\\0' + lines.join('\\n')) : markKey;\n const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;\n const appends = lines\n .map(line => \"printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs))\n .join(' && ');\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n ' && mkdir -p ' + shellQuote(markDir) +\n ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +\n 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +\n '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +\n '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi');\n}",
|
|
60
60
|
},
|
|
61
61
|
"model-resolver": {
|
|
62
62
|
name: "model-resolver",
|
package/src/mutation-gate.ts
CHANGED
|
@@ -45,12 +45,15 @@ export interface MutationRegistryEntry {
|
|
|
45
45
|
export interface MutationRegistry {
|
|
46
46
|
/** optional suite command override for the whole registry (default `npm test`). */
|
|
47
47
|
readonly testCommand?: string;
|
|
48
|
+
/** opt-in proof that the suite harness reached its clean completion path. */
|
|
49
|
+
readonly requireCompletionReceipt?: boolean;
|
|
48
50
|
readonly entries: readonly MutationRegistryEntry[];
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export type MutationVerdict =
|
|
52
54
|
| 'PROVEN' // mutation applied, suite went red, failing count (when known) >= minFailing
|
|
53
55
|
| 'UNDEFENDED' // mutation applied, suite stayed GREEN — the property has no discriminating test
|
|
56
|
+
| 'RECEIPT_MISMATCH' // the suite harness declared a receipt error, or an opted-in completion receipt was absent — neither the exit code nor failing count may be read as discrimination
|
|
54
57
|
| 'NOT_APPLIED' // `find` occurred 0 or >1 times — code drifted; nothing was tested (rule 1)
|
|
55
58
|
| 'BELOW_MIN' // suite red, but a RELIABLE count came in under the entry's own minFailing contract
|
|
56
59
|
| 'MUTATION_UNPARSEABLE' // the MUTATED file no longer parses — a registry/setup error: whole-suite redness from a load failure proves nothing (route a)
|
|
@@ -89,12 +92,22 @@ export interface MutationObservation {
|
|
|
89
92
|
* failure), NEVER PROVEN: redness that cannot be attributed proves nothing.
|
|
90
93
|
*/
|
|
91
94
|
readonly outputUnrecognised?: string;
|
|
95
|
+
/**
|
|
96
|
+
* set to the marker text when the suite harness declares its own execution-receipt contract
|
|
97
|
+
* violated, or to the missing-receipt detail for an opted-in registry. Either condition
|
|
98
|
+
* invalidates both green and count-based readings of the same run.
|
|
99
|
+
*/
|
|
100
|
+
readonly receiptMismatch?: string;
|
|
101
|
+
/** named reason from spawnSync when no exit code exists (timeout, signal, maxBuffer, spawn). */
|
|
102
|
+
readonly runFailureReason?: string;
|
|
92
103
|
/**
|
|
93
104
|
* exit code of the suite re-run on the RESTORED tree after this entry (the attribution check
|
|
94
105
|
* for flaky neighbours). undefined = not run; 0 = reproducibly green; any other value (or null)
|
|
95
106
|
* = the "green" baseline is not reproducible, so the red under mutation is not attributable.
|
|
96
107
|
*/
|
|
97
108
|
readonly rebaselineExitCode?: number | null;
|
|
109
|
+
/** named no-exit reason for the restored-tree attribution run, when it produced none. */
|
|
110
|
+
readonly rebaselineFailureReason?: string;
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
export interface MutationEntryResult {
|
|
@@ -135,7 +148,7 @@ export interface ParsedRegistry {
|
|
|
135
148
|
readonly errors: readonly string[];
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
/** Parse + validate a registry JSON text. Accepts a bare array
|
|
151
|
+
/** Parse + validate a registry JSON text. Accepts a bare array or `{testCommand?, requireCompletionReceipt?, entries}`. */
|
|
139
152
|
export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
140
153
|
let raw: unknown;
|
|
141
154
|
try {
|
|
@@ -146,10 +159,11 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
146
159
|
|
|
147
160
|
let entriesRaw: unknown;
|
|
148
161
|
let testCommand: string | undefined;
|
|
162
|
+
let requireCompletionReceipt: boolean | undefined;
|
|
149
163
|
if (Array.isArray(raw)) {
|
|
150
164
|
entriesRaw = raw;
|
|
151
165
|
} else if (raw && typeof raw === 'object') {
|
|
152
|
-
const obj = raw as { testCommand?: unknown; entries?: unknown };
|
|
166
|
+
const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; entries?: unknown };
|
|
153
167
|
entriesRaw = obj.entries;
|
|
154
168
|
if (obj.testCommand !== undefined) {
|
|
155
169
|
if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
|
|
@@ -157,9 +171,15 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
157
171
|
}
|
|
158
172
|
testCommand = obj.testCommand.trim();
|
|
159
173
|
}
|
|
174
|
+
if (obj.requireCompletionReceipt !== undefined) {
|
|
175
|
+
if (typeof obj.requireCompletionReceipt !== 'boolean') {
|
|
176
|
+
return { registry: null, errors: ['requireCompletionReceipt must be a boolean when present'] };
|
|
177
|
+
}
|
|
178
|
+
requireCompletionReceipt = obj.requireCompletionReceipt;
|
|
179
|
+
}
|
|
160
180
|
}
|
|
161
181
|
if (!Array.isArray(entriesRaw)) {
|
|
162
|
-
return { registry: null, errors: ['registry must be an array of entries or {testCommand?, entries: [...]}'] };
|
|
182
|
+
return { registry: null, errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
|
|
163
183
|
}
|
|
164
184
|
if (entriesRaw.length === 0) {
|
|
165
185
|
// An empty registry "passes" by testing nothing — the same silent hole as a skipped mutation.
|
|
@@ -237,9 +257,14 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
237
257
|
});
|
|
238
258
|
|
|
239
259
|
if (errors.length > 0) return { registry: null, errors };
|
|
240
|
-
return
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
return {
|
|
261
|
+
registry: {
|
|
262
|
+
...(testCommand !== undefined ? { testCommand } : {}),
|
|
263
|
+
...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
|
|
264
|
+
entries,
|
|
265
|
+
},
|
|
266
|
+
errors: [],
|
|
267
|
+
};
|
|
243
268
|
}
|
|
244
269
|
|
|
245
270
|
// ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
|
|
@@ -315,6 +340,21 @@ function stripSgr(s: string): string {
|
|
|
315
340
|
return s.replace(SGR, '');
|
|
316
341
|
}
|
|
317
342
|
|
|
343
|
+
/** Return the first suite-harness receipt error at column 0, with SGR removed. */
|
|
344
|
+
export function detectSuiteReceiptMismatch(rawOutput: string): string | undefined {
|
|
345
|
+
const match = /^mutation-suite-receipt-error:[^\S\r\n]*(.*)$/m.exec(stripSgr(rawOutput));
|
|
346
|
+
return match === null ? undefined : (match[1] ?? '').trim();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Return the first suite-harness clean-completion receipt at column 0, with SGR removed. */
|
|
350
|
+
export function detectSuiteCompletionReceipt(rawOutput: string): { lanes: number; names: number } | undefined {
|
|
351
|
+
const match = /^mutation-suite-receipt-ok:[^\S\r\n]*lanes=(\d+)[^\S\r\n]+names=(\d+)[^\S\r\n]*$/m.exec(stripSgr(rawOutput));
|
|
352
|
+
if (match === null) return undefined;
|
|
353
|
+
const lanes = Number(match[1]);
|
|
354
|
+
const names = Number(match[2]);
|
|
355
|
+
return Number.isSafeInteger(lanes) && Number.isSafeInteger(names) ? { lanes, names } : undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
318
358
|
export type RunnerKind = 'node-test' | 'vitest' | 'unknown';
|
|
319
359
|
|
|
320
360
|
/**
|
|
@@ -464,10 +504,10 @@ export interface BaselineResult {
|
|
|
464
504
|
* A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
|
|
465
505
|
* "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
|
|
466
506
|
*/
|
|
467
|
-
export function classifyBaseline(exitCode: number | null): BaselineResult {
|
|
507
|
+
export function classifyBaseline(exitCode: number | null, runFailureReason?: string): BaselineResult {
|
|
468
508
|
if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
|
|
469
509
|
if (exitCode === null) {
|
|
470
|
-
return { ok: false, detail:
|
|
510
|
+
return { ok: false, detail: `baseline suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; fix the copy, do not read this as a mutation result` };
|
|
471
511
|
}
|
|
472
512
|
return { ok: false, detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — a broken copy cannot prove anything; fix the copy (node_modules link? path-dependent test?) before trusting any verdict` };
|
|
473
513
|
}
|
|
@@ -489,13 +529,16 @@ export function classifyBaseline(exitCode: number | null): BaselineResult {
|
|
|
489
529
|
* the flaky-rebaseline check (a structurally-broken run needs no
|
|
490
530
|
* attribution analysis);
|
|
491
531
|
* 4. INCONCLUSIVE (no exit) — the run produced nothing to classify at all;
|
|
492
|
-
* 5.
|
|
532
|
+
* 5. RECEIPT_MISMATCH — the harness declared its own receipt contract broken, which
|
|
533
|
+
* invalidates BOTH the green reading and every count-based reading
|
|
534
|
+
* of the same run;
|
|
535
|
+
* 6. UNDEFENDED (exit 0) — the disjoint GREEN arm: mutually exclusive with every red-based
|
|
493
536
|
* verdict below;
|
|
494
|
-
*
|
|
537
|
+
* 7. INCONCLUSIVE (unrecognised output) — red, but the shape is unreadable: counts parsed out of
|
|
495
538
|
* unrecognised output must not reach BELOW_MIN/OVER_FAILING/PROVEN;
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
539
|
+
* 8. INCONCLUSIVE (flaky rebaseline) — red, readable, but not attributable;
|
|
540
|
+
* 9. BELOW_MIN → 10. OVER_FAILING — reliable-count contract checks, both failing;
|
|
541
|
+
* 11. PROVEN — applied, red, behavioural, attributable, within bounds.
|
|
499
542
|
*
|
|
500
543
|
* THE DROP DECISION (SPEC §Reporting, decided here + justified): `failing < observed` but still
|
|
501
544
|
* `>= minFailing` is a LOUD WARNING, not a failure. Two reasons, both load-bearing:
|
|
@@ -568,7 +611,18 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
568
611
|
applied: true,
|
|
569
612
|
verdict: 'INCONCLUSIVE',
|
|
570
613
|
drop: false,
|
|
571
|
-
detail:
|
|
614
|
+
detail: `suite produced NO exit code under the mutation (${obs.runFailureReason ?? 'unknown timeout / spawn failure'}) — inconclusive is a FAILURE, never a pass`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (obs.receiptMismatch !== undefined) {
|
|
619
|
+
const boundedReceipt = Array.from(obs.receiptMismatch).slice(0, 220).join('');
|
|
620
|
+
return {
|
|
621
|
+
...base,
|
|
622
|
+
applied: true,
|
|
623
|
+
verdict: 'RECEIPT_MISMATCH',
|
|
624
|
+
drop: false,
|
|
625
|
+
detail: `the suite harness declared its own receipt contract violated (${boundedReceipt}) — neither this run's exit code nor its failing count may be read as discrimination; RECEIPT_MISMATCH is a FAILURE, never PROVEN`,
|
|
572
626
|
};
|
|
573
627
|
}
|
|
574
628
|
|
|
@@ -604,7 +658,7 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
604
658
|
applied: true,
|
|
605
659
|
verdict: 'INCONCLUSIVE',
|
|
606
660
|
drop: false,
|
|
607
|
-
detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ?
|
|
661
|
+
detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — the suite is flaky; the redness is not attributable to the protection and may be an unrelated neighbour`,
|
|
608
662
|
};
|
|
609
663
|
}
|
|
610
664
|
|
|
@@ -645,7 +699,7 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
645
699
|
}
|
|
646
700
|
|
|
647
701
|
/** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
|
|
648
|
-
const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
|
|
702
|
+
const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'RECEIPT_MISMATCH', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
|
|
649
703
|
|
|
650
704
|
/** Exit contract: 0 all proven · 1 any entry failed (or red baseline) · (2 = usage/setup, CLI-side). */
|
|
651
705
|
export function mutationGateExitCode(results: readonly MutationEntryResult[], baselineOk: boolean): number {
|
|
@@ -660,6 +714,7 @@ export interface MutationGateSummary {
|
|
|
660
714
|
readonly total: number;
|
|
661
715
|
readonly proven: number;
|
|
662
716
|
readonly undefended: number;
|
|
717
|
+
readonly receiptMismatch: number;
|
|
663
718
|
readonly notApplied: number;
|
|
664
719
|
readonly belowMin: number;
|
|
665
720
|
readonly unparseable: number;
|
|
@@ -678,6 +733,7 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
|
|
|
678
733
|
total: results.length,
|
|
679
734
|
proven: results.filter((r) => r.verdict === 'PROVEN').length,
|
|
680
735
|
undefended: results.filter((r) => r.verdict === 'UNDEFENDED').length,
|
|
736
|
+
receiptMismatch: results.filter((r) => r.verdict === 'RECEIPT_MISMATCH').length,
|
|
681
737
|
notApplied: results.filter((r) => r.verdict === 'NOT_APPLIED').length,
|
|
682
738
|
belowMin: results.filter((r) => r.verdict === 'BELOW_MIN').length,
|
|
683
739
|
unparseable: results.filter((r) => r.verdict === 'MUTATION_UNPARSEABLE').length,
|
|
@@ -692,6 +748,7 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
|
|
|
692
748
|
const VERDICT_MARK: Record<MutationVerdict, string> = {
|
|
693
749
|
PROVEN: '✓',
|
|
694
750
|
UNDEFENDED: '✗',
|
|
751
|
+
RECEIPT_MISMATCH: '✗',
|
|
695
752
|
NOT_APPLIED: '✗',
|
|
696
753
|
BELOW_MIN: '✗',
|
|
697
754
|
MUTATION_UNPARSEABLE: '✗',
|
|
@@ -715,7 +772,7 @@ export function renderMutationReport(
|
|
|
715
772
|
if (r.verdict !== 'PROVEN' || r.drop) lines.push(` ${r.detail}`);
|
|
716
773
|
}
|
|
717
774
|
const s = summarizeMutationResults(results);
|
|
718
|
-
lines.push(` summary: ${s.proven}/${s.total} proven · ${s.undefended} undefended · ${s.notApplied} not-applied · ${s.belowMin} below-min · ${s.unparseable} unparseable · ${s.loadFatal} load-fatal · ${s.overFailing} over-failing · ${s.inconclusive} inconclusive · ${s.drops} coverage drop(s) among ${s.dropComparable}/${s.total} observed-anchored entries (a drop is undetectable without an \`observed\` anchor)`);
|
|
775
|
+
lines.push(` summary: ${s.proven}/${s.total} proven · ${s.undefended} undefended · ${s.receiptMismatch} receipt-mismatch · ${s.notApplied} not-applied · ${s.belowMin} below-min · ${s.unparseable} unparseable · ${s.loadFatal} load-fatal · ${s.overFailing} over-failing · ${s.inconclusive} inconclusive · ${s.drops} coverage drop(s) among ${s.dropComparable}/${s.total} observed-anchored entries (a drop is undetectable without an \`observed\` anchor)`);
|
|
719
776
|
lines.push(mutationGateExitCode(results, baseline.ok) === 0
|
|
720
777
|
? ' verdict: PASS — every named protection has a test that goes red when the protection is deleted'
|
|
721
778
|
: ' verdict: FAIL — at least one named protection is undefended, unmutable, or unproven');
|
package/src/named-lock.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { createRequire } from 'node:module';
|
|
|
33
33
|
import { join, resolve } from 'node:path';
|
|
34
34
|
import { lockSync } from 'proper-lockfile';
|
|
35
35
|
|
|
36
|
-
import { LOCK_TIMEOUT_MS,
|
|
36
|
+
import { LOCK_TIMEOUT_MS, resolveStaleMs } from './store-lock.js';
|
|
37
37
|
import type { StoreLockOptions } from './store-lock.js';
|
|
38
38
|
|
|
39
39
|
/** `proper-lockfile` silently clamps `stale` up to this minimum. */
|
|
@@ -99,7 +99,7 @@ const sleepSync = (ms: number): void => {
|
|
|
99
99
|
|
|
100
100
|
function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
|
|
101
101
|
return {
|
|
102
|
-
staleMs: Math.max(opts.staleMs ??
|
|
102
|
+
staleMs: Math.max(opts.staleMs ?? resolveStaleMs(), MIN_STALE_MS),
|
|
103
103
|
timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
|
|
104
104
|
pollMs: opts.pollMs ?? 25,
|
|
105
105
|
};
|
|
@@ -206,12 +206,14 @@ function tryAcquire(
|
|
|
206
206
|
// NEVER remove a lock directory we cannot prove is ours — including at process exit.
|
|
207
207
|
return disarmExitRemoval(resourceKey) ? 'stolen' : 'stolen-undisarmed';
|
|
208
208
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
} catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
|
|
209
|
+
// Load-bearing order: remove our marker while the directory still excludes waiters. If we
|
|
210
|
+
// unlock first, a successor can acquire and publish its marker before our cleanup runs.
|
|
212
211
|
try {
|
|
213
212
|
rmSync(ownerMarkerPath(lockPath), { force: true });
|
|
214
213
|
} catch { /* best effort: a stale marker is overwritten by the next holder anyway */ }
|
|
214
|
+
try {
|
|
215
|
+
release();
|
|
216
|
+
} catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
|
|
215
217
|
return 'released';
|
|
216
218
|
},
|
|
217
219
|
};
|
|
@@ -257,9 +259,9 @@ export function withNamedLockSync<T>(
|
|
|
257
259
|
lockPath,
|
|
258
260
|
compromised ??
|
|
259
261
|
new Error(
|
|
260
|
-
'the lock directory no longer carries this holder\u2019s ownership token
|
|
261
|
-
'
|
|
262
|
-
'lock
|
|
262
|
+
'the lock directory no longer carries this holder\u2019s ownership token; a waiter may have broken it ' +
|
|
263
|
+
'as stale, or another holder\u2019s release may have removed it. The current holder left the observed ' +
|
|
264
|
+
'lock untouched; THIS run must be treated as having raced and retried.' +
|
|
263
265
|
(outcome === 'stolen-undisarmed'
|
|
264
266
|
? ' WARNING: proper-lockfile\u2019s exit-time lock removal could NOT be disarmed (its getLocks() accessor ' +
|
|
265
267
|
'is unavailable in this version), so this process may still remove the successor\u2019s lock when it exits.'
|