@dzhechkov/harness-core 0.8.21 → 0.8.24
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 +68 -48
- package/README.md +163 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +5 -140
- package/dist/amendment-trace.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +3 -3
- package/dist/claude-hooks-assets.d.ts.map +1 -1
- package/dist/claude-hooks-assets.js +24 -11
- package/dist/claude-hooks-assets.js.map +1 -1
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +2 -1
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +3 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +17 -10
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- 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/markdown-masker.d.ts +21 -0
- package/dist/markdown-masker.d.ts.map +1 -0
- package/dist/markdown-masker.js +119 -0
- package/dist/markdown-masker.js.map +1 -0
- package/dist/mutation-gate.d.ts +16 -7
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +141 -30
- package/dist/mutation-gate.js.map +1 -1
- package/dist/statusline.d.ts +14 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +24 -1
- package/dist/statusline.js.map +1 -1
- package/dist/store-counts.d.ts +12 -0
- package/dist/store-counts.d.ts.map +1 -1
- package/dist/store-counts.js +30 -12
- package/dist/store-counts.js.map +1 -1
- package/dist/swarm-brief.d.ts.map +1 -1
- package/dist/swarm-brief.js +13 -144
- package/dist/swarm-brief.js.map +1 -1
- package/package.json +5 -5
- package/sbom.json +97 -47
- package/src/amendment-trace.ts +6 -123
- package/src/claude-hooks-assets.ts +24 -11
- package/src/discrimination-gate.ts +2 -1
- package/src/feature-adr-routing.ts +21 -11
- package/src/index.ts +2 -0
- package/src/loop-blobs.generated.ts +2 -2
- package/src/markdown-masker.ts +109 -0
- package/src/mutation-gate.ts +167 -31
- package/src/statusline.ts +35 -1
- package/src/store-counts.ts +44 -13
- package/src/swarm-brief.ts +13 -104
package/src/mutation-gate.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface MutationRegistry {
|
|
|
52
52
|
|
|
53
53
|
export type MutationVerdict =
|
|
54
54
|
| 'PROVEN' // mutation applied, suite went red, failing count (when known) >= minFailing
|
|
55
|
+
| 'ENTRY_INVALID' // this registry entry is malformed; valid neighbours still run, but the gate fails
|
|
56
|
+
| 'COVERAGE_GAP' // author-declared, reason-bearing gap; no mutation ran and the gate fails
|
|
55
57
|
| 'UNDEFENDED' // mutation applied, suite stayed GREEN — the property has no discriminating test
|
|
56
58
|
| '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
|
|
57
59
|
| 'NOT_APPLIED' // `find` occurred 0 or >1 times — code drifted; nothing was tested (rule 1)
|
|
@@ -201,18 +203,50 @@ const UNSAFE_FILE = /(^\/)|(^[A-Za-z]:)|(^~)|(^-)|(\/-)|(\.\.(\/|\\|$))|[\0`$;&|
|
|
|
201
203
|
|
|
202
204
|
export interface ParsedRegistry {
|
|
203
205
|
readonly registry: MutationRegistry | null;
|
|
204
|
-
/**
|
|
205
|
-
|
|
206
|
+
/** Per-entry outcomes that need no mutation run: malformed entries and declared gaps. */
|
|
207
|
+
readonly entryResults: readonly MutationEntryResult[];
|
|
208
|
+
/** Every defect found. Envelope/JSON defects make registry null; entry defects are also
|
|
209
|
+
* represented as ENTRY_INVALID while valid neighbours remain executable. */
|
|
206
210
|
readonly errors: readonly string[];
|
|
207
211
|
}
|
|
208
212
|
|
|
213
|
+
function preclassifiedEntryResult(
|
|
214
|
+
index: number,
|
|
215
|
+
raw: Record<string, unknown>,
|
|
216
|
+
verdict: 'ENTRY_INVALID' | 'COVERAGE_GAP',
|
|
217
|
+
detail: string,
|
|
218
|
+
idOverride?: string,
|
|
219
|
+
): MutationEntryResult {
|
|
220
|
+
const rawId = typeof raw['id'] === 'string' ? raw['id'] : '';
|
|
221
|
+
const id = idOverride ?? (SAFE_ID.test(rawId) ? rawId : `entry-${index + 1}-invalid`);
|
|
222
|
+
const property = typeof raw['property'] === 'string' && raw['property'].trim() !== ''
|
|
223
|
+
? raw['property'].trim()
|
|
224
|
+
: `Registry entry ${index + 1}`;
|
|
225
|
+
const file = typeof raw['file'] === 'string' && raw['file'].trim() !== ''
|
|
226
|
+
? raw['file'].trim()
|
|
227
|
+
: '<not provided>';
|
|
228
|
+
return {
|
|
229
|
+
id,
|
|
230
|
+
property,
|
|
231
|
+
file,
|
|
232
|
+
applied: false,
|
|
233
|
+
occurrences: 0,
|
|
234
|
+
exitCode: null,
|
|
235
|
+
failingCount: null,
|
|
236
|
+
verdict,
|
|
237
|
+
drop: false,
|
|
238
|
+
dropComparable: false,
|
|
239
|
+
detail,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
209
243
|
/** Parse + validate a registry JSON text. Accepts a bare array or `{testCommand?, requireCompletionReceipt?, entries}`. */
|
|
210
244
|
export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
211
245
|
let raw: unknown;
|
|
212
246
|
try {
|
|
213
247
|
raw = JSON.parse(text);
|
|
214
248
|
} catch (e) {
|
|
215
|
-
return { registry: null, errors: [`registry is not valid JSON: ${String((e as Error).message).slice(0, 120)}`] };
|
|
249
|
+
return { registry: null, entryResults: [], errors: [`registry is not valid JSON: ${String((e as Error).message).slice(0, 120)}`] };
|
|
216
250
|
}
|
|
217
251
|
|
|
218
252
|
let entriesRaw: unknown;
|
|
@@ -225,39 +259,69 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
225
259
|
entriesRaw = obj.entries;
|
|
226
260
|
if (obj.testCommand !== undefined) {
|
|
227
261
|
if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
|
|
228
|
-
return { registry: null, errors: ['testCommand must be a non-empty string when present'] };
|
|
262
|
+
return { registry: null, entryResults: [], errors: ['testCommand must be a non-empty string when present'] };
|
|
229
263
|
}
|
|
230
264
|
testCommand = obj.testCommand.trim();
|
|
231
265
|
}
|
|
232
266
|
if (obj.requireCompletionReceipt !== undefined) {
|
|
233
267
|
if (typeof obj.requireCompletionReceipt !== 'boolean') {
|
|
234
|
-
return { registry: null, errors: ['requireCompletionReceipt must be a boolean when present'] };
|
|
268
|
+
return { registry: null, entryResults: [], errors: ['requireCompletionReceipt must be a boolean when present'] };
|
|
235
269
|
}
|
|
236
270
|
requireCompletionReceipt = obj.requireCompletionReceipt;
|
|
237
271
|
}
|
|
238
272
|
}
|
|
239
273
|
if (!Array.isArray(entriesRaw)) {
|
|
240
|
-
return { registry: null, errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
|
|
274
|
+
return { registry: null, entryResults: [], errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
|
|
241
275
|
}
|
|
242
276
|
if (entriesRaw.length === 0) {
|
|
243
277
|
// An empty registry "passes" by testing nothing — the same silent hole as a skipped mutation.
|
|
244
|
-
return { registry: null, errors: ['registry has no entries — an empty registry proves nothing and is refused'] };
|
|
278
|
+
return { registry: null, entryResults: [], errors: ['registry has no entries — an empty registry proves nothing and is refused'] };
|
|
245
279
|
}
|
|
246
280
|
|
|
247
281
|
const errors: string[] = [];
|
|
248
282
|
const entries: MutationRegistryEntry[] = [];
|
|
283
|
+
const entryResults: MutationEntryResult[] = [];
|
|
249
284
|
const seen = new Set<string>();
|
|
250
285
|
entriesRaw.forEach((e: unknown, i: number) => {
|
|
251
286
|
const at = `entries[${i}]`;
|
|
252
|
-
|
|
287
|
+
const rawForResult = e && typeof e === 'object' ? e as Record<string, unknown> : {};
|
|
288
|
+
const reject = (detail: string, idOverride?: string): void => {
|
|
289
|
+
errors.push(detail);
|
|
290
|
+
entryResults.push(preclassifiedEntryResult(i, rawForResult, 'ENTRY_INVALID', detail, idOverride));
|
|
291
|
+
};
|
|
292
|
+
if (!e || typeof e !== 'object') { reject(`${at}: not an object`); return; }
|
|
253
293
|
const o = e as Record<string, unknown>;
|
|
254
294
|
const id = typeof o['id'] === 'string' ? o['id'] : '';
|
|
255
|
-
if (!SAFE_ID.test(id)) {
|
|
256
|
-
if (seen.has(id)) {
|
|
295
|
+
if (!SAFE_ID.test(id)) { reject(`${at}: id must be kebab-case [a-z0-9-], got ${JSON.stringify(o['id'])}`); return; }
|
|
296
|
+
if (seen.has(id)) { reject(`${at}: duplicate id '${id}'`, `entry-${i + 1}-invalid`); return; }
|
|
257
297
|
seen.add(id);
|
|
258
|
-
if (typeof o['property'] !== 'string' || o['property'].trim() === '') {
|
|
298
|
+
if (typeof o['property'] !== 'string' || o['property'].trim() === '') { reject(`${id}: property (the claimed sentence) is required`); return; }
|
|
299
|
+
|
|
300
|
+
if (o['uncoverable'] !== undefined && o['uncoverable'] !== true) {
|
|
301
|
+
reject(`${id}: uncoverable must be true when present`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (o['uncoverable'] === true) {
|
|
305
|
+
const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : '';
|
|
306
|
+
if (reason === '') {
|
|
307
|
+
reject(`${id}: reason is required when uncoverable is true`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const declaredFile = typeof o['file'] === 'string' ? o['file'].trim() : '';
|
|
311
|
+
if (declaredFile === '' || /[\u0000-\u001f\u007f]/.test(declaredFile)) {
|
|
312
|
+
reject(`${id}: file must be a non-empty single-line path for an uncoverable declaration`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
entryResults.push(preclassifiedEntryResult(
|
|
316
|
+
i,
|
|
317
|
+
o,
|
|
318
|
+
'COVERAGE_GAP',
|
|
319
|
+
`declared uncoverable: ${reason} — author declaration, not a measurement; no mutation ran`,
|
|
320
|
+
));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
259
323
|
const file = typeof o['file'] === 'string' ? o['file'] : '';
|
|
260
|
-
if (file === '' || UNSAFE_FILE.test(file)) {
|
|
324
|
+
if (file === '' || UNSAFE_FILE.test(file)) { reject(`${id}: file must be a plain package-relative path, got ${JSON.stringify(o['file'])}`); return; }
|
|
261
325
|
// A registry file under node_modules/ is refused OUTRIGHT (F-2): the registry names protections
|
|
262
326
|
// in the package's OWN code — a dependency's file is not this package's protection — and the
|
|
263
327
|
// scratch copy intentionally SHARES node_modules with the real tree (symlinked for
|
|
@@ -265,19 +329,19 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
265
329
|
// the real working tree (SPEC rule 3). The executor's realpath containment is the belt; this
|
|
266
330
|
// is the cheaper layer-1 refusal for the case that is always a mistake.
|
|
267
331
|
if (file.split('/').includes('node_modules')) {
|
|
268
|
-
|
|
332
|
+
reject(`${id}: file targets node_modules/ (${JSON.stringify(file)}) — refused: a dependency file is not this package's protection, and the scratch copy shares node_modules with the REAL tree (rule 3: never mutate the working tree)`);
|
|
269
333
|
return;
|
|
270
334
|
}
|
|
271
335
|
const mut = o['mutation'] as { find?: unknown; replace?: unknown } | undefined;
|
|
272
336
|
if (!mut || typeof mut !== 'object' || typeof mut.find !== 'string' || mut.find.length === 0 || typeof mut.replace !== 'string') {
|
|
273
|
-
|
|
337
|
+
reject(`${id}: mutation must be {find: <non-empty string>, replace: <string>}`);
|
|
274
338
|
return;
|
|
275
339
|
}
|
|
276
|
-
if (mut.find === mut.replace) {
|
|
340
|
+
if (mut.find === mut.replace) { reject(`${id}: mutation.replace equals mutation.find — a no-op mutation tests nothing`); return; }
|
|
277
341
|
let minFailing = 1;
|
|
278
342
|
if (o['minFailing'] !== undefined) {
|
|
279
343
|
if (typeof o['minFailing'] !== 'number' || !Number.isInteger(o['minFailing']) || o['minFailing'] < 1) {
|
|
280
|
-
|
|
344
|
+
reject(`${id}: minFailing must be a positive integer`);
|
|
281
345
|
return;
|
|
282
346
|
}
|
|
283
347
|
minFailing = o['minFailing'];
|
|
@@ -285,7 +349,7 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
285
349
|
let observed: number | undefined;
|
|
286
350
|
if (o['observed'] !== undefined) {
|
|
287
351
|
if (typeof o['observed'] !== 'number' || !Number.isInteger(o['observed']) || o['observed'] < 1) {
|
|
288
|
-
|
|
352
|
+
reject(`${id}: observed must be a positive integer when present`);
|
|
289
353
|
return;
|
|
290
354
|
}
|
|
291
355
|
observed = o['observed'];
|
|
@@ -293,11 +357,11 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
293
357
|
let maxFailing: number | undefined;
|
|
294
358
|
if (o['maxFailing'] !== undefined) {
|
|
295
359
|
if (typeof o['maxFailing'] !== 'number' || !Number.isInteger(o['maxFailing']) || o['maxFailing'] < 1) {
|
|
296
|
-
|
|
360
|
+
reject(`${id}: maxFailing must be a positive integer when present`);
|
|
297
361
|
return;
|
|
298
362
|
}
|
|
299
363
|
if (o['maxFailing'] < minFailing) {
|
|
300
|
-
|
|
364
|
+
reject(`${id}: maxFailing (${o['maxFailing']}) must be >= minFailing (${minFailing}) — a contradictory bound can never pass`);
|
|
301
365
|
return;
|
|
302
366
|
}
|
|
303
367
|
maxFailing = o['maxFailing'];
|
|
@@ -314,14 +378,14 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
314
378
|
entries.push(entry);
|
|
315
379
|
});
|
|
316
380
|
|
|
317
|
-
if (errors.length > 0) return { registry: null, errors };
|
|
318
381
|
return {
|
|
319
382
|
registry: {
|
|
320
383
|
...(testCommand !== undefined ? { testCommand } : {}),
|
|
321
384
|
...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
|
|
322
385
|
entries,
|
|
323
386
|
},
|
|
324
|
-
|
|
387
|
+
entryResults,
|
|
388
|
+
errors,
|
|
325
389
|
};
|
|
326
390
|
}
|
|
327
391
|
|
|
@@ -385,6 +449,18 @@ export function countFailingTests(rawOutput: string): number | null {
|
|
|
385
449
|
if (jest && jest[1] !== undefined) return Number(jest[1]);
|
|
386
450
|
const notOk = output.match(/^not ok\b/gm);
|
|
387
451
|
if (notOk !== null && notOk.length > 0) return notOk.length;
|
|
452
|
+
// Zero requires a complete, recognised vitest summary, never the absence of failure text.
|
|
453
|
+
// Keep TAP and every existing positive-count path above this additional fallback.
|
|
454
|
+
if (detectRunnerKind(output) === 'vitest') {
|
|
455
|
+
const zero = /^[ \t]*Tests[ \t]+(\d+[ \t]+(?:passed|skipped|todo)(?:[ \t]*\|[ \t]*\d+[ \t]+(?:passed|skipped|todo))*)[ \t]+\((\d+)\)[ \t]*\r?$/m.exec(output);
|
|
456
|
+
if (zero !== null) {
|
|
457
|
+
const counts = [...zero[1]!.matchAll(/(\d+)[ \t]+(?:passed|skipped|todo)/g)]
|
|
458
|
+
.map((match) => Number(match[1]));
|
|
459
|
+
const total = Number(zero[2]);
|
|
460
|
+
if (Number.isSafeInteger(total) && total > 0 && counts.every(Number.isSafeInteger)
|
|
461
|
+
&& counts.reduce((sum, count) => sum + count, 0) === total) return 0;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
388
464
|
return null;
|
|
389
465
|
}
|
|
390
466
|
|
|
@@ -447,8 +523,10 @@ export interface RunFailureClassification {
|
|
|
447
523
|
* carried no classifiable failure): a runner-coverage gap of this tool — the
|
|
448
524
|
* verdict must be INCONCLUSIVE, never PROVEN.
|
|
449
525
|
*/
|
|
450
|
-
readonly kind: 'file-load' | 'assertions' | 'unrecognised';
|
|
451
|
-
/**
|
|
526
|
+
readonly kind: 'file-load' | 'assertions' | 'runner-infrastructure' | 'unrecognised';
|
|
527
|
+
/** Closed reason set for an identified runner failure with zero failing tests. */
|
|
528
|
+
readonly reason?: 'worker-rpc-timeout';
|
|
529
|
+
/** Evidence of the load/infrastructure failure, or what could not be classified. */
|
|
452
530
|
readonly evidence?: string;
|
|
453
531
|
}
|
|
454
532
|
|
|
@@ -541,6 +619,15 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
|
|
|
541
619
|
if (/^\s*Tests\s+[^|\n]*?\d+\s+failed/m.test(output) || /\bFailed Tests\s+\d+\b/.test(output)) {
|
|
542
620
|
return { runner: 'vitest', kind: 'assertions' };
|
|
543
621
|
}
|
|
622
|
+
const workerTimeout = /\[vitest-worker\]: Timeout calling "([^"\r\n]+)"/.exec(output);
|
|
623
|
+
if (countFailingTests(output) === 0 && workerTimeout !== null) {
|
|
624
|
+
return {
|
|
625
|
+
runner: 'vitest',
|
|
626
|
+
kind: 'runner-infrastructure',
|
|
627
|
+
reason: 'worker-rpc-timeout',
|
|
628
|
+
evidence: `vitest worker RPC timeout (${workerTimeout[1]}), 0 failing tests`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
544
631
|
return { runner: 'vitest', kind: 'unrecognised', evidence: 'red vitest run with neither Failed Suites nor failed tests in the output — the redness has no classifiable source (unhandled error outside any test?)' };
|
|
545
632
|
}
|
|
546
633
|
|
|
@@ -557,6 +644,8 @@ export type BaselineAttributionSource = 'node-test' | 'vitest' | 'unparseable';
|
|
|
557
644
|
|
|
558
645
|
export interface BaselineAttribution {
|
|
559
646
|
readonly parsedFrom: BaselineAttributionSource;
|
|
647
|
+
/** Identified infrastructure failure from the same output; absent for all existing outcomes. */
|
|
648
|
+
readonly infrastructureFailure?: RunFailureClassification;
|
|
560
649
|
/** Package-relative failing paths, in first-seen order. */
|
|
561
650
|
readonly failingFiles: readonly string[];
|
|
562
651
|
/** Failing paths that match a registry file exactly (or by package-relative suffix). */
|
|
@@ -599,6 +688,10 @@ export function attributeBaselineRedness(
|
|
|
599
688
|
? 'unparseable'
|
|
600
689
|
: vitestMatches.length > 0 ? 'vitest' : 'node-test';
|
|
601
690
|
if (parsedFrom === 'unparseable') {
|
|
691
|
+
const failure = classifyRunFailure(output);
|
|
692
|
+
if (failure.kind === 'runner-infrastructure') {
|
|
693
|
+
return { parsedFrom, failingFiles: [], covered: [], extraneous: [], infrastructureFailure: failure };
|
|
694
|
+
}
|
|
602
695
|
return { parsedFrom, failingFiles: [], covered: [], extraneous: [] };
|
|
603
696
|
}
|
|
604
697
|
|
|
@@ -611,6 +704,7 @@ export function attributeBaselineRedness(
|
|
|
611
704
|
}
|
|
612
705
|
|
|
613
706
|
export type BaselineFailureReason =
|
|
707
|
+
| 'worker-rpc-timeout'
|
|
614
708
|
| 'runner-internal-error'
|
|
615
709
|
| 'runner-no-exit'
|
|
616
710
|
| 'extraneous-red-in-allowlist'
|
|
@@ -641,6 +735,14 @@ export function classifyBaseline(
|
|
|
641
735
|
detail: `baseline INCONCLUSIVE — suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; do not read this as a mutation result`,
|
|
642
736
|
};
|
|
643
737
|
}
|
|
738
|
+
const infrastructure = attribution?.infrastructureFailure;
|
|
739
|
+
if (infrastructure?.kind === 'runner-infrastructure') {
|
|
740
|
+
return {
|
|
741
|
+
ok: false,
|
|
742
|
+
...(infrastructure.reason !== undefined ? { reason: infrastructure.reason } : {}),
|
|
743
|
+
detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — runner infrastructure failure: ${infrastructure.reason} — ${infrastructure.evidence}; the broken copy cannot prove anything`,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
644
746
|
if (attribution === undefined || attribution.parsedFrom === 'unparseable') {
|
|
645
747
|
return {
|
|
646
748
|
ok: false,
|
|
@@ -804,7 +906,10 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
|
|
|
804
906
|
// not come back green, so the suite is flaky and an unrelated neighbour may be what went red.
|
|
805
907
|
// Not attributable ⇒ INCONCLUSIVE (a failure, never a pass).
|
|
806
908
|
if (obs.rebaselineExitCode !== undefined && obs.rebaselineExitCode !== 0) {
|
|
807
|
-
const
|
|
909
|
+
const infrastructure = obs.rebaselineAttribution?.infrastructureFailure;
|
|
910
|
+
const failing = infrastructure?.kind === 'runner-infrastructure'
|
|
911
|
+
? `runner infrastructure failure: ${infrastructure.reason} — ${infrastructure.evidence}`
|
|
912
|
+
: obs.rebaselineAttribution === undefined || obs.rebaselineAttribution.parsedFrom === 'unparseable'
|
|
808
913
|
? 'failing files: unparseable from runner output'
|
|
809
914
|
: `failing files: ${obs.rebaselineAttribution.failingFiles.join(', ')}`;
|
|
810
915
|
return {
|
|
@@ -858,13 +963,36 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
858
963
|
return { ...result, detail: `${result.detail}; ${obs.internalAttemptLog}` };
|
|
859
964
|
}
|
|
860
965
|
|
|
861
|
-
/**
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
966
|
+
/**
|
|
967
|
+
* Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass).
|
|
968
|
+
*
|
|
969
|
+
* COVERAGE_GAP is deliberately NOT here (owner decision 2026-09-09, option A). A declared gap is a
|
|
970
|
+
* DEBT, not a breakage: the orchestrator script is uncoverable by construction, so failing on gaps
|
|
971
|
+
* would make this package's gate red FOREVER — and a lamp that is always on is read exactly like a
|
|
972
|
+
* lamp that is off. What the mechanism owes is COUNTABILITY, and the summary delivers it: gaps are
|
|
973
|
+
* counted on their own line, carry their own per-entry verdict, and can never be mistaken for a
|
|
974
|
+
* proven protection. ENTRY_INVALID stays failing — a malformed entry is a broken claim, not a
|
|
975
|
+
* declared one, and it has an author who can fix it today.
|
|
976
|
+
*/
|
|
977
|
+
const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set([
|
|
978
|
+
'ENTRY_INVALID',
|
|
979
|
+
'UNDEFENDED',
|
|
980
|
+
'RECEIPT_MISMATCH',
|
|
981
|
+
'NOT_APPLIED',
|
|
982
|
+
'BELOW_MIN',
|
|
983
|
+
'MUTATION_UNPARSEABLE',
|
|
984
|
+
'MUTATION_LOAD_FATAL',
|
|
985
|
+
'OVER_FAILING',
|
|
986
|
+
'INCONCLUSIVE',
|
|
987
|
+
]);
|
|
988
|
+
|
|
989
|
+
/** Exit contract: 0 all runnable entries proven · 1 a runnable entry failed (or red baseline) ·
|
|
990
|
+
* 2 no mutation-eligible entry exists, so the registry/selection is unusable as a run. */
|
|
865
991
|
export function mutationGateExitCode(results: readonly MutationEntryResult[], baselineOk: boolean): number {
|
|
992
|
+
const hasRunnableEntry = results.some((result) =>
|
|
993
|
+
result.verdict !== 'ENTRY_INVALID' && result.verdict !== 'COVERAGE_GAP');
|
|
994
|
+
if (!hasRunnableEntry) return 2;
|
|
866
995
|
if (!baselineOk) return 1;
|
|
867
|
-
if (results.length === 0) return 1; // nothing ran ⇒ nothing proven
|
|
868
996
|
return results.some((r) => FAILING_VERDICTS.has(r.verdict)) ? 1 : 0;
|
|
869
997
|
}
|
|
870
998
|
|
|
@@ -873,6 +1001,8 @@ export function mutationGateExitCode(results: readonly MutationEntryResult[], ba
|
|
|
873
1001
|
export interface MutationGateSummary {
|
|
874
1002
|
readonly total: number;
|
|
875
1003
|
readonly proven: number;
|
|
1004
|
+
readonly entryInvalid: number;
|
|
1005
|
+
readonly coverageGaps: number;
|
|
876
1006
|
readonly undefended: number;
|
|
877
1007
|
readonly receiptMismatch: number;
|
|
878
1008
|
readonly notApplied: number;
|
|
@@ -892,6 +1022,8 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
|
|
|
892
1022
|
return {
|
|
893
1023
|
total: results.length,
|
|
894
1024
|
proven: results.filter((r) => r.verdict === 'PROVEN').length,
|
|
1025
|
+
entryInvalid: results.filter((r) => r.verdict === 'ENTRY_INVALID').length,
|
|
1026
|
+
coverageGaps: results.filter((r) => r.verdict === 'COVERAGE_GAP').length,
|
|
895
1027
|
undefended: results.filter((r) => r.verdict === 'UNDEFENDED').length,
|
|
896
1028
|
receiptMismatch: results.filter((r) => r.verdict === 'RECEIPT_MISMATCH').length,
|
|
897
1029
|
notApplied: results.filter((r) => r.verdict === 'NOT_APPLIED').length,
|
|
@@ -907,6 +1039,10 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
|
|
|
907
1039
|
|
|
908
1040
|
const VERDICT_MARK: Record<MutationVerdict, string> = {
|
|
909
1041
|
PROVEN: '✓',
|
|
1042
|
+
ENTRY_INVALID: '✗',
|
|
1043
|
+
// Пробел — объявленный ДОЛГ, а не отказ (вариант А владельца 2026-09-09): свой значок, чтобы
|
|
1044
|
+
// строку нельзя было прочитать как провал защиты.
|
|
1045
|
+
COVERAGE_GAP: '⚠',
|
|
910
1046
|
UNDEFENDED: '✗',
|
|
911
1047
|
RECEIPT_MISMATCH: '✗',
|
|
912
1048
|
NOT_APPLIED: '✗',
|
|
@@ -932,9 +1068,9 @@ export function renderMutationReport(
|
|
|
932
1068
|
if (r.verdict !== 'PROVEN' || r.drop) lines.push(` ${r.detail}`);
|
|
933
1069
|
}
|
|
934
1070
|
const s = summarizeMutationResults(results);
|
|
935
|
-
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)`);
|
|
1071
|
+
lines.push(` summary: ${s.proven}/${s.total} proven · ${s.entryInvalid} entry-invalid · ${s.coverageGaps} coverage-gap · ${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)`);
|
|
936
1072
|
lines.push(mutationGateExitCode(results, baseline.ok) === 0
|
|
937
1073
|
? ' verdict: PASS — every named protection has a test that goes red when the protection is deleted'
|
|
938
|
-
: ' verdict: FAIL — at least one named protection is undefended, unmutable, or unproven');
|
|
1074
|
+
: ' verdict: FAIL — at least one named protection is invalid, undefended, unmutable, or unproven');
|
|
939
1075
|
return lines.join('\n');
|
|
940
1076
|
}
|
package/src/statusline.ts
CHANGED
|
@@ -68,6 +68,10 @@ export interface FeatureAdrState {
|
|
|
68
68
|
export interface StatuslineData {
|
|
69
69
|
/** Count of learned patterns in the project's unified memory store. */
|
|
70
70
|
readonly patterns: number;
|
|
71
|
+
/** Absent on parity; missing/unreadable mirror is explicitly unavailable. */
|
|
72
|
+
readonly patternMirror?:
|
|
73
|
+
| { readonly state: 'different'; readonly lexical: number; readonly vector: number }
|
|
74
|
+
| { readonly state: 'unavailable' };
|
|
71
75
|
/** Exact lexical-tier availability split; omitted when the enhanced readonly count cannot be established. */
|
|
72
76
|
readonly patternBreakdown?: {
|
|
73
77
|
readonly source: 'lexical';
|
|
@@ -82,6 +86,12 @@ export interface StatuslineData {
|
|
|
82
86
|
readonly usedPatterns?: number;
|
|
83
87
|
/** Number of sources registered in the durable cross-project knowledge brain. */
|
|
84
88
|
readonly brainSources: number;
|
|
89
|
+
/**
|
|
90
|
+
* KU-объём КАЖДОГО источника, в том же порядке, что их перечисляет brain. Владелец 2026-09-09:
|
|
91
|
+
* одно число источников не говорит, велик ли корпус и не пуст ли какой-то из них.
|
|
92
|
+
* Пустой массив означает «перечислить не удалось», а не «источников нет» — их число рядом.
|
|
93
|
+
*/
|
|
94
|
+
readonly brainKuCounts: readonly number[];
|
|
85
95
|
/** Hours since the last `dz consolidate` run, if a watermark is present. */
|
|
86
96
|
readonly consolidatedAgeH?: number;
|
|
87
97
|
/** Live `/feature-adr` learning state — present ONLY when a fresh run is in flight. */
|
|
@@ -640,6 +650,24 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
|
|
|
640
650
|
storeHealth = undefined;
|
|
641
651
|
}
|
|
642
652
|
|
|
653
|
+
// Сравнивать можно только сравнимое: слева уроки лексического слоя, справа уроки зеркала.
|
|
654
|
+
// Полный объём зеркала (`vectorRows`) для этого не годится — в него входят идеи бэклога и
|
|
655
|
+
// книжные единицы, и показатель на нём горел бы всегда. Неизвестное число уроков — это
|
|
656
|
+
// ТРЕТЬЕ состояние «не читается», а не молчание: молчание означает «величины сошлись».
|
|
657
|
+
// ОТСУТСТВИЕ зеркала и НЕЧИТАЕМОСТЬ зеркала — разные положения (решение владельца 2026-09-09).
|
|
658
|
+
// Зеркала нет вовсе: сравнивать не с чем, показатель молчит — иначе у любого проекта, который
|
|
659
|
+
// зеркалом не пользуется, он горел бы всегда, а вечно горящий показатель не несёт сведений.
|
|
660
|
+
// Зеркало ЕСТЬ, но прочитать или разложить его не удалось: это отказ инструмента, и он горит.
|
|
661
|
+
const mirrorLessons = storeRows?.vectorLessonRows;
|
|
662
|
+
const mirrorAbsent = storeRows !== undefined && storeRows.vectorSourcePath === undefined;
|
|
663
|
+
const patternMirror: StatuslineData['patternMirror'] = mirrorAbsent
|
|
664
|
+
? undefined
|
|
665
|
+
: storeRows === undefined || storeRows.vectorRows === 'unreadable' || mirrorLessons === undefined
|
|
666
|
+
? { state: 'unavailable' }
|
|
667
|
+
: mirrorLessons !== patterns
|
|
668
|
+
? { state: 'different', lexical: patterns, vector: mirrorLessons }
|
|
669
|
+
: undefined;
|
|
670
|
+
|
|
643
671
|
let patternBreakdown: StatuslineData['patternBreakdown'];
|
|
644
672
|
try {
|
|
645
673
|
if (storeRows !== undefined
|
|
@@ -662,10 +690,14 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
|
|
|
662
690
|
}
|
|
663
691
|
|
|
664
692
|
let brainSources = 0;
|
|
693
|
+
let brainKuCounts: readonly number[] = [];
|
|
665
694
|
try {
|
|
666
|
-
|
|
695
|
+
const sources = listBrain();
|
|
696
|
+
brainSources = sources.length;
|
|
697
|
+
brainKuCounts = sources.map((s) => (typeof s.kuCount === 'number' ? s.kuCount : 0));
|
|
667
698
|
} catch {
|
|
668
699
|
brainSources = 0;
|
|
700
|
+
brainKuCounts = [];
|
|
669
701
|
}
|
|
670
702
|
|
|
671
703
|
let usedPatterns: number | undefined;
|
|
@@ -687,9 +719,11 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
|
|
|
687
719
|
|
|
688
720
|
return {
|
|
689
721
|
patterns,
|
|
722
|
+
...(patternMirror !== undefined ? { patternMirror } : {}),
|
|
690
723
|
...(patternBreakdown !== undefined ? { patternBreakdown } : {}),
|
|
691
724
|
...(usedPatterns !== undefined ? { usedPatterns } : {}),
|
|
692
725
|
brainSources,
|
|
726
|
+
brainKuCounts,
|
|
693
727
|
...(ageH !== undefined ? { consolidatedAgeH: ageH } : {}),
|
|
694
728
|
...(featureAdr !== undefined ? { featureAdr } : {}),
|
|
695
729
|
...(storeHealth !== undefined ? { storeHealth } : {}),
|
package/src/store-counts.ts
CHANGED
|
@@ -20,7 +20,19 @@ export interface LearningStoreRowCounts {
|
|
|
20
20
|
/** Coexisting JSONL is deliberately excluded from the selected SQLite population. */
|
|
21
21
|
readonly lexicalIgnoredRows?: number | 'unreadable';
|
|
22
22
|
readonly lexicalIgnoredSourcePath?: string;
|
|
23
|
+
/**
|
|
24
|
+
* ВСЕ строки зеркала, включая идеи бэклога и книжные единицы. Смысл поля НЕ сужен намеренно:
|
|
25
|
+
* его читает страж стора как признак целостности (отметка высшей точки `vectorMax`), и сужение
|
|
26
|
+
* до уроков уронило бы наблюдение с 1774 до 631 — страж прочитал бы это как обвал стора.
|
|
27
|
+
* Для показателя зеркала в панели есть отдельное поле `vectorLessonRows`.
|
|
28
|
+
*/
|
|
23
29
|
readonly vectorRows: number | 'unreadable';
|
|
30
|
+
/**
|
|
31
|
+
* Только зеркальные УРОКИ (task_type dz-teach/dz-learning) — величина, сравнимая с `lexicalRows`.
|
|
32
|
+
* Отсутствует, когда зеркала нет или разложить его по родам не удалось: тогда показатель обязан
|
|
33
|
+
* сказать «не читается», а не молчать, будто величины сошлись.
|
|
34
|
+
*/
|
|
35
|
+
readonly vectorLessonRows?: number;
|
|
24
36
|
/** Exact active quarantine labels on mirrored lesson rows; absent when the vector tier is missing/unreadable. */
|
|
25
37
|
readonly vectorQuarantinedRows?: number;
|
|
26
38
|
/** Present when the vector store file exists, including when its count is unreadable. */
|
|
@@ -60,6 +72,8 @@ export function countSqliteRowsReadonly(
|
|
|
60
72
|
interface SqliteRowsWithQuarantine {
|
|
61
73
|
readonly rows: number;
|
|
62
74
|
readonly quarantinedRows?: number;
|
|
75
|
+
/** Только для зеркала: подсчёт уроков внутри общего объёма. */
|
|
76
|
+
readonly lessonRows?: number;
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/** One aggregate query on the healthy path; an unsupported metadata shape falls back to total-only. */
|
|
@@ -82,22 +96,38 @@ function countSqliteRowsWithQuarantineReadonly(
|
|
|
82
96
|
COUNT(*) AS n
|
|
83
97
|
FROM memory_records GROUP BY 1
|
|
84
98
|
)`
|
|
85
|
-
: `
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
SELECT (SELECT COUNT(*) FROM reasoning_patterns) AS cnt,
|
|
93
|
-
COALESCE(SUM(CASE WHEN q_status = 'quarantined' THEN n ELSE 0 END), 0) AS quarantined
|
|
94
|
-
FROM lesson_statuses`;
|
|
95
|
-
const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown };
|
|
99
|
+
: `SELECT COUNT(*) AS cnt,
|
|
100
|
+
COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning') THEN 1 ELSE 0 END), 0) AS lessons,
|
|
101
|
+
COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning')
|
|
102
|
+
AND json_valid(metadata)
|
|
103
|
+
AND json_extract(metadata, '$.qStatus') = 'quarantined' THEN 1 ELSE 0 END), 0) AS quarantined
|
|
104
|
+
FROM reasoning_patterns`;
|
|
105
|
+
const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown; lessons?: unknown };
|
|
96
106
|
if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return undefined;
|
|
97
|
-
return {
|
|
107
|
+
return {
|
|
108
|
+
rows: row.cnt,
|
|
109
|
+
quarantinedRows: row.quarantined,
|
|
110
|
+
...(typeof row.lessons === 'number' ? { lessonRows: row.lessons } : {}),
|
|
111
|
+
};
|
|
98
112
|
} catch {
|
|
113
|
+
// Запасной путь: общий объём берём всегда, а разложение по родам пробуем ОТДЕЛЬНО —
|
|
114
|
+
// схема без `metadata`, но с `task_type` уроки различает, и терять это не за что.
|
|
115
|
+
// Если не различает и её — lessonRows остаётся неизвестным, и показатель обязан сказать
|
|
116
|
+
// «не читается» вместо мнимого совпадения.
|
|
99
117
|
const row = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as { cnt?: unknown };
|
|
100
|
-
|
|
118
|
+
if (typeof row?.cnt !== 'number') return undefined;
|
|
119
|
+
if (table !== 'reasoning_patterns') return { rows: row.cnt };
|
|
120
|
+
try {
|
|
121
|
+
const lesson = db.prepare(
|
|
122
|
+
`SELECT COUNT(*) AS cnt FROM reasoning_patterns
|
|
123
|
+
WHERE task_type IN ('dz-teach', 'dz-learning')`,
|
|
124
|
+
).get() as { cnt?: unknown };
|
|
125
|
+
return typeof lesson?.cnt === 'number'
|
|
126
|
+
? { rows: row.cnt, lessonRows: lesson.cnt }
|
|
127
|
+
: { rows: row.cnt };
|
|
128
|
+
} catch {
|
|
129
|
+
return { rows: row.cnt };
|
|
130
|
+
}
|
|
101
131
|
}
|
|
102
132
|
} finally {
|
|
103
133
|
db.close();
|
|
@@ -146,6 +176,7 @@ export function countLearningStoreRowsReadonly(projectRoot: string): LearningSto
|
|
|
146
176
|
lexicalIgnoredSourcePath: jsonlPath,
|
|
147
177
|
}),
|
|
148
178
|
vectorRows: vectorExists ? (vectorSqlite?.rows ?? 'unreadable') : 0,
|
|
179
|
+
...(vectorSqlite?.lessonRows === undefined ? {} : { vectorLessonRows: vectorSqlite.lessonRows }),
|
|
149
180
|
...(vectorSqlite?.quarantinedRows === undefined ? {} : {
|
|
150
181
|
vectorQuarantinedRows: vectorSqlite.quarantinedRows,
|
|
151
182
|
}),
|