@mmnto/cli 1.91.0 → 1.92.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/commands/doctor-parity.d.ts +105 -1
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +383 -30
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +288 -1
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +20 -0
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +36 -0
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +77 -0
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/shield-eval.integration.test.js +57 -13
- package/dist/commands/shield-eval.integration.test.js.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -36,6 +36,15 @@
|
|
|
36
36
|
* unconfigured → exactly one `skip` line; configured-but-missing / unparseable /
|
|
37
37
|
* unsupported-schema → `warn`, never a crash. Dynamic-import `@mmnto/totem` to
|
|
38
38
|
* keep core off the CLI cold-start graph, matching the other doctor checks.
|
|
39
|
+
*
|
|
40
|
+
* The **trust-readout** (mmnto-ai/totem#2327, Prop 303 §5(a)) post-processes
|
|
41
|
+
* the flat per-line dump into the doctor's aggregate output contract: verdict
|
|
42
|
+
* rollup (per-seat + global, R1), the run-time coverage denominator (R2),
|
|
43
|
+
* why-not per non-pass row at the level probed (R3), the `--json` verdict
|
|
44
|
+
* artifact (R4), and the `--strict` declaredly-toothless honesty line (R5).
|
|
45
|
+
* Spec: mmnto-ai/totem-strategy:doctrine/parity-manifest.md § "The
|
|
46
|
+
* trust-readout — the doctor's output contract"; deltas raise there, never
|
|
47
|
+
* silently diverge. Pure post-processing — zero probes added.
|
|
39
48
|
*/
|
|
40
49
|
import * as path from 'node:path';
|
|
41
50
|
// init-templates (large canonical strings) + the node:url / node:module builtins
|
|
@@ -347,7 +356,7 @@ export async function checkParity(cwd) {
|
|
|
347
356
|
name: CHECK_NAME,
|
|
348
357
|
status: 'skip',
|
|
349
358
|
message: 'no parity manifest configured',
|
|
350
|
-
}, configured);
|
|
359
|
+
}, configured, 'not-configured');
|
|
351
360
|
case 'not-found': {
|
|
352
361
|
// mmnto-ai/totem#2094 — when the configured manifest path lives under
|
|
353
362
|
// node_modules/ (the strategy-doctrine optional-pin shape), the normal
|
|
@@ -368,7 +377,7 @@ export async function checkParity(cwd) {
|
|
|
368
377
|
status: 'warn',
|
|
369
378
|
message: `parity manifest not found at ${rel(cwd, result.path)}`,
|
|
370
379
|
remediation,
|
|
371
|
-
}, configured);
|
|
380
|
+
}, configured, 'not-found');
|
|
372
381
|
}
|
|
373
382
|
case 'unparseable':
|
|
374
383
|
return single({
|
|
@@ -376,14 +385,14 @@ export async function checkParity(cwd) {
|
|
|
376
385
|
status: 'warn',
|
|
377
386
|
message: `parity manifest unreadable at ${rel(cwd, result.path)}: ${result.reason}`,
|
|
378
387
|
remediation: 'Fix the manifest YAML / schema, then re-run totem doctor --parity.',
|
|
379
|
-
}, configured);
|
|
388
|
+
}, configured, 'unparseable');
|
|
380
389
|
case 'unsupported-schema':
|
|
381
390
|
return single({
|
|
382
391
|
name: CHECK_NAME,
|
|
383
392
|
status: 'warn',
|
|
384
393
|
message: `parity manifest schema v${result.schemaVersion} unsupported (this doctor supports v${SUPPORTED_PARITY_SCHEMA_VERSION})`,
|
|
385
394
|
remediation: 'Upgrade @mmnto/cli or align the manifest schema-version.',
|
|
386
|
-
}, configured);
|
|
395
|
+
}, configured, 'unsupported-schema');
|
|
387
396
|
case 'ok': {
|
|
388
397
|
const { contracts } = result.manifest;
|
|
389
398
|
const summary = {
|
|
@@ -436,6 +445,10 @@ export async function checkParity(cwd) {
|
|
|
436
445
|
// regenerated git-hook canonical matches THIS repo's package manager — no false drift.
|
|
437
446
|
const fallbackCmd = getFallbackCommand(gitRoot);
|
|
438
447
|
const blockingDriftIds = [];
|
|
448
|
+
// Trust-readout per-contract metadata (mmnto-ai/totem#2327), tagged at the
|
|
449
|
+
// routing branch that owns each contract so the R2 coverage split derives
|
|
450
|
+
// from the run itself — never from a mirrored registry (Tenet 20).
|
|
451
|
+
const readoutMeta = {};
|
|
439
452
|
// The consumers-scope guard shared by the routing branches that don't
|
|
440
453
|
// self-guard in core (mechanical + capability-probe): a scoped contract
|
|
441
454
|
// must not emit drift in a repo that is not an intended consumer.
|
|
@@ -471,10 +484,20 @@ export async function checkParity(cwd) {
|
|
|
471
484
|
// NON-probe rungs (managed-block, version-pin, …) are informational —
|
|
472
485
|
// they fall through to the existing tractability routing unchanged.
|
|
473
486
|
if (c.manifestation === 'capability-probe') {
|
|
487
|
+
// Probe resolution BEFORE the scope guard is meta-only (pure switch, no
|
|
488
|
+
// probe executes): a scoped-out row still classifies by whether the
|
|
489
|
+
// registry implements it (#2327 R2).
|
|
490
|
+
const probes = capabilityProbesFor(c.id, gitRoot);
|
|
491
|
+
readoutMeta[c.id] =
|
|
492
|
+
probes === undefined
|
|
493
|
+
? { coverage: 'honest-absent' }
|
|
494
|
+
: {
|
|
495
|
+
coverage: 'mechanical',
|
|
496
|
+
...(probes[0] !== undefined ? { sensesProbed: probes[0].probedLevel } : {}),
|
|
497
|
+
};
|
|
474
498
|
const scopeSkip = consumersSkip(c);
|
|
475
499
|
if (scopeSkip !== undefined)
|
|
476
500
|
return scopeSkip;
|
|
477
|
-
const probes = capabilityProbesFor(c.id, gitRoot);
|
|
478
501
|
if (probes === undefined) {
|
|
479
502
|
return [
|
|
480
503
|
stub(c, `${c.dimension} (capability-probe) — probe not yet implemented for this row`),
|
|
@@ -510,6 +533,11 @@ export async function checkParity(cwd) {
|
|
|
510
533
|
// documented to avoid.
|
|
511
534
|
if (c.manifestation === 'value-equality') {
|
|
512
535
|
const fields = valueEqualityFieldsFor(c.id, gitRoot);
|
|
536
|
+
// The scalar read is a config-declaration probe (#2327 R3): 'declared'.
|
|
537
|
+
readoutMeta[c.id] =
|
|
538
|
+
fields === undefined
|
|
539
|
+
? { coverage: 'honest-absent' }
|
|
540
|
+
: { coverage: 'mechanical', sensesProbed: 'declared' };
|
|
513
541
|
if (fields === undefined) {
|
|
514
542
|
return [
|
|
515
543
|
stub(c, `${c.dimension} (value-equality) — drift detection not yet implemented for this row`),
|
|
@@ -538,6 +566,11 @@ export async function checkParity(cwd) {
|
|
|
538
566
|
// contract id at most ONCE for --strict so the count reflects contracts.
|
|
539
567
|
if (c.manifestation === 'content-hash') {
|
|
540
568
|
const packageDir = lockContentPackageDirFor(c.id, gitRoot);
|
|
569
|
+
// Installed-artifact hash equality probes on-disk content (#2327 R3): 'present'.
|
|
570
|
+
readoutMeta[c.id] =
|
|
571
|
+
packageDir === undefined
|
|
572
|
+
? { coverage: 'honest-absent' }
|
|
573
|
+
: { coverage: 'mechanical', sensesProbed: 'present' };
|
|
541
574
|
if (packageDir === undefined) {
|
|
542
575
|
return [
|
|
543
576
|
stub(c, `${c.dimension} (content-hash) — drift detection not yet implemented for this row`),
|
|
@@ -558,6 +591,7 @@ export async function checkParity(cwd) {
|
|
|
558
591
|
// Fail-loud PER ROW, never per manifest (the total-outage guard): an
|
|
559
592
|
// unrecognized rung value surfaces verbatim instead of darking the
|
|
560
593
|
// sensor or silently mis-routing.
|
|
594
|
+
readoutMeta[c.id] = { coverage: 'honest-absent' };
|
|
561
595
|
return [
|
|
562
596
|
stub(c, `manifestation '${c.manifestation}' unrecognized by this doctor — drift detection not yet implemented`),
|
|
563
597
|
];
|
|
@@ -570,10 +604,13 @@ export async function checkParity(cwd) {
|
|
|
570
604
|
// self-routes those to the toolchain reader (mmnto-ai/totem#2115). Only
|
|
571
605
|
// stub a version-pinned row that's neither a deps package nor a toolchain.
|
|
572
606
|
if (packageName === undefined && c.dimension !== TOOLCHAIN_DIMENSION) {
|
|
607
|
+
readoutMeta[c.id] = { coverage: 'honest-absent' };
|
|
573
608
|
return [
|
|
574
609
|
stub(c, `${c.dimension} (version-pinned) — drift detection not yet implemented`),
|
|
575
610
|
];
|
|
576
611
|
}
|
|
612
|
+
// Pin-currency reads the consumer's dependency declaration (#2327 R3): 'declared'.
|
|
613
|
+
readoutMeta[c.id] = { coverage: 'mechanical', sensesProbed: 'declared' };
|
|
577
614
|
const verdict = detectVersionPinnedContract(c, { cwd, gitRoot, repoId, packageName });
|
|
578
615
|
if (verdict.status === 'warn' && c.blocking === true)
|
|
579
616
|
blockingDriftIds.push(c.id);
|
|
@@ -581,6 +618,17 @@ export async function checkParity(cwd) {
|
|
|
581
618
|
}
|
|
582
619
|
// ── mechanical content-equality (mmnto-ai/totem#2073) ──
|
|
583
620
|
if (c.tractability === 'mechanical') {
|
|
621
|
+
// Skills resolution is pure (no probe executes) — resolved BEFORE the
|
|
622
|
+
// scope guard so a scoped-out row still classifies by whether the
|
|
623
|
+
// registry implements it (#2327 R2); reused as `artifacts` below.
|
|
624
|
+
const skillArtifacts = mechanicalArtifactsFor(c.id, gitRoot, extractManagedBlock, skillTemplates);
|
|
625
|
+
const mechanicalImplemented = c.id === 'git-hooks' ||
|
|
626
|
+
c.id === 'session-start-orientation' ||
|
|
627
|
+
skillArtifacts !== undefined;
|
|
628
|
+
// Content-equality probes on-disk artifacts (#2327 R3): 'present'.
|
|
629
|
+
readoutMeta[c.id] = mechanicalImplemented
|
|
630
|
+
? { coverage: 'mechanical', sensesProbed: 'present' }
|
|
631
|
+
: { coverage: 'honest-absent' };
|
|
584
632
|
// Honor the contract's `consumers` scope before sensing drift (mirrors
|
|
585
633
|
// detectVersionPinnedContract, which self-guards). A scoped mechanical
|
|
586
634
|
// contract must not emit drift — or, under --strict, fail — in a repo that
|
|
@@ -632,8 +680,9 @@ export async function checkParity(cwd) {
|
|
|
632
680
|
blockingDriftIds.push(c.id);
|
|
633
681
|
return lines;
|
|
634
682
|
}
|
|
635
|
-
// skills: managed-block content equality against the in-process template
|
|
636
|
-
|
|
683
|
+
// skills: managed-block content equality against the in-process template
|
|
684
|
+
// (resolved above, pre-scope-guard, for the readout classification).
|
|
685
|
+
const artifacts = skillArtifacts;
|
|
637
686
|
if (artifacts === undefined) {
|
|
638
687
|
return [
|
|
639
688
|
stub(c, `${c.dimension} (mechanical) — drift detection not yet implemented for this sub-class`),
|
|
@@ -665,6 +714,9 @@ export async function checkParity(cwd) {
|
|
|
665
714
|
// `info` can't enter blockingDriftIds, so these contracts cannot fail even
|
|
666
715
|
// under --strict (the manifest's "never fails" contract).
|
|
667
716
|
if (c.tractability === 'manual-attestation') {
|
|
717
|
+
// Declared-covered but mechanically uncovered (#2327 R2 — human-asserted
|
|
718
|
+
// judgment is not code-verified truth); nothing is probed.
|
|
719
|
+
readoutMeta[c.id] = { coverage: 'attestation-only' };
|
|
668
720
|
// The detector reads the sub-class discriminant (`c.package`) + the
|
|
669
721
|
// canonical source directly off the contract. `package:` set ⇒ vendor-SDK
|
|
670
722
|
// pin read; unset ⇒ doctrine-row pure-info surface. `attested` carries the
|
|
@@ -678,17 +730,31 @@ export async function checkParity(cwd) {
|
|
|
678
730
|
return [verdictToLine(c, verdict)];
|
|
679
731
|
}
|
|
680
732
|
// ── anything else (a future tractability) → skip stub; the slice boundary stays observable ──
|
|
733
|
+
readoutMeta[c.id] = { coverage: 'honest-absent' };
|
|
681
734
|
return [
|
|
682
735
|
stub(c, `${c.dimension} (${c.tractability}) — drift detection not yet implemented`),
|
|
683
736
|
];
|
|
684
737
|
});
|
|
685
|
-
return {
|
|
738
|
+
return {
|
|
739
|
+
results: [summary, ...perContract],
|
|
740
|
+
blockingDriftIds,
|
|
741
|
+
configured,
|
|
742
|
+
loadStatus: 'ok',
|
|
743
|
+
readout: {
|
|
744
|
+
manifest: {
|
|
745
|
+
schemaVersion: result.manifest.schemaVersion,
|
|
746
|
+
status: result.manifest.status,
|
|
747
|
+
},
|
|
748
|
+
contracts,
|
|
749
|
+
meta: readoutMeta,
|
|
750
|
+
},
|
|
751
|
+
};
|
|
686
752
|
}
|
|
687
753
|
}
|
|
688
754
|
}
|
|
689
|
-
/** Wrap a single summary line in the `ParityCheckResult` shape (no blocking ids). */
|
|
690
|
-
function single(result, configured) {
|
|
691
|
-
return { results: [result], blockingDriftIds: [], configured };
|
|
755
|
+
/** Wrap a single summary line in the `ParityCheckResult` shape (no blocking ids, no readout). */
|
|
756
|
+
function single(result, configured, loadStatus) {
|
|
757
|
+
return { results: [result], blockingDriftIds: [], configured, loadStatus };
|
|
692
758
|
}
|
|
693
759
|
/** Map a core `ParityContractVerdict` to a `ParityLine` keyed by the contract id. */
|
|
694
760
|
function verdictToLine(contract, verdict) {
|
|
@@ -726,6 +792,223 @@ function rel(cwd, target) {
|
|
|
726
792
|
const r = path.relative(cwd, target);
|
|
727
793
|
return r.length > 0 ? r : target;
|
|
728
794
|
}
|
|
795
|
+
// ─── Trust-readout assembly (mmnto-ai/totem#2327) ────────
|
|
796
|
+
/** The `--json` artifact's own version stamp (`readout-schema-version`). */
|
|
797
|
+
const READOUT_SCHEMA_VERSION = 1;
|
|
798
|
+
/**
|
|
799
|
+
* R2 claim-boundary sentence — the readout states its own scope in its own
|
|
800
|
+
* output: an active-but-unmanifested surface (an undeclared MCP server, a
|
|
801
|
+
* user-global plugin) is outside the claim, stated not implied.
|
|
802
|
+
*/
|
|
803
|
+
const CLAIM_BOUNDARY = 'covers the manifest-declared contract set only; active-but-unmanifested surfaces are outside this claim';
|
|
804
|
+
/**
|
|
805
|
+
* Whether a verdict line belongs to a `--strict`-promotable contract. A
|
|
806
|
+
* contract's drift can render across MULTIPLE artifact lines (e.g.
|
|
807
|
+
* `Parity: claude-skills (signoff)`), so a line is promotable when its name is
|
|
808
|
+
* the `Parity: <id>` summary OR a `Parity: <id> (…)` artifact line — an
|
|
809
|
+
* exact-name Set would leave the artifact lines rendered as WARN while the
|
|
810
|
+
* command still exits non-zero (GCA review). The trailing space guards against
|
|
811
|
+
* a contract id that is a prefix of another. Shared by the CLI render and the
|
|
812
|
+
* readout builder so the two can't disagree on what promoted.
|
|
813
|
+
*/
|
|
814
|
+
function isPromotableLineName(name, blockingDriftIds) {
|
|
815
|
+
return blockingDriftIds.some((id) => name === `Parity: ${id}` || name.startsWith(`Parity: ${id} `));
|
|
816
|
+
}
|
|
817
|
+
/** Resolve the contract a verdict line belongs to (same prefix rule as promotion). */
|
|
818
|
+
function contractForLineName(name, contracts) {
|
|
819
|
+
return contracts.find((c) => name === `Parity: ${c.id}` || name.startsWith(`Parity: ${c.id} `));
|
|
820
|
+
}
|
|
821
|
+
// The CLI-side stubs' "not yet implemented" message shape — the one code-owned
|
|
822
|
+
// string the classifier keys on. A future optional reasonClass on the core
|
|
823
|
+
// verdict type retires this coupling (flagged to strategy in the
|
|
824
|
+
// 2026-07-09T2256Z deltas dispatch as an observation).
|
|
825
|
+
const HONEST_ABSENT_RE = /not yet implemented/;
|
|
826
|
+
/**
|
|
827
|
+
* R3 reason class for one verdict line. `pass` omits its class. A `skip` is
|
|
828
|
+
* `honest-absent` when the stub message or the contract's coverage class says
|
|
829
|
+
* the detector is unbuilt; every other skip — scope-guard skips and
|
|
830
|
+
* scope-indeterminate skips (e.g. applicable-but-missing scaffolds) alike —
|
|
831
|
+
* classifies `scoping-skip`, never silently `honest-absent`, which would
|
|
832
|
+
* understate what the registry implements.
|
|
833
|
+
*/
|
|
834
|
+
function reasonClassFor(verdict, message, coverage) {
|
|
835
|
+
switch (verdict) {
|
|
836
|
+
case 'pass':
|
|
837
|
+
return undefined;
|
|
838
|
+
case 'warn':
|
|
839
|
+
case 'fail':
|
|
840
|
+
return 'drift';
|
|
841
|
+
case 'info':
|
|
842
|
+
return 'attestation';
|
|
843
|
+
case 'unknown':
|
|
844
|
+
return 'detector-error';
|
|
845
|
+
case 'skip':
|
|
846
|
+
if (HONEST_ABSENT_RE.test(message) || coverage === 'honest-absent')
|
|
847
|
+
return 'honest-absent';
|
|
848
|
+
return 'scoping-skip';
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Assemble the trust-readout from `checkParity`'s raw materials — pure
|
|
853
|
+
* post-processing over the existing detector output (Prop 303 non-goal 2:
|
|
854
|
+
* zero probes added here).
|
|
855
|
+
*/
|
|
856
|
+
export function buildParityReadout(inputs, results, blockingDriftIds, strict) {
|
|
857
|
+
const { contracts, meta } = inputs;
|
|
858
|
+
const rows = [];
|
|
859
|
+
for (const line of results) {
|
|
860
|
+
const contract = contractForLineName(line.name, contracts);
|
|
861
|
+
// The section summary line (`Parity`) is not a contract row.
|
|
862
|
+
if (contract === undefined)
|
|
863
|
+
continue;
|
|
864
|
+
const promoted = strict && line.status === 'warn' && isPromotableLineName(line.name, blockingDriftIds);
|
|
865
|
+
const verdict = promoted ? 'fail' : line.status;
|
|
866
|
+
const m = meta[contract.id];
|
|
867
|
+
const reasonClass = reasonClassFor(verdict, line.message, m?.coverage);
|
|
868
|
+
rows.push({
|
|
869
|
+
id: contract.id,
|
|
870
|
+
verdict,
|
|
871
|
+
...(m?.sensesProbed !== undefined ? { sensesProbed: m.sensesProbed } : {}),
|
|
872
|
+
...(reasonClass !== undefined ? { reasonClass } : {}),
|
|
873
|
+
message: line.message,
|
|
874
|
+
...(contract.lastAttested !== undefined ? { lastAttested: contract.lastAttested } : {}),
|
|
875
|
+
lineName: line.name,
|
|
876
|
+
skippedNotGated: verdict === 'skip' && contract.blocking === true,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
const zero = () => ({ pass: 0, warn: 0, info: 0, unknown: 0, skip: 0, fail: 0 });
|
|
880
|
+
const global = zero();
|
|
881
|
+
for (const r of rows)
|
|
882
|
+
global[r.verdict] += 1;
|
|
883
|
+
// Seats = the sorted union of declared vendor-adapter values. A line counts
|
|
884
|
+
// toward seat S when its contract is vendor-neutral (no vendor-adapter — it
|
|
885
|
+
// manifests on every seat) or declares S. An EMPTY vendor-adapter list is
|
|
886
|
+
// treated as vendor-neutral too: the schema admits `[]`, and excluding such
|
|
887
|
+
// a row from every seat while counting it globally would reopen the exact
|
|
888
|
+
// per-seat honesty hole R1 exists to close (spec-owner review on #2328).
|
|
889
|
+
// Per-seat exists precisely so a seat-scoped skip cannot hide inside the
|
|
890
|
+
// global number (R1).
|
|
891
|
+
const seats = [...new Set(contracts.flatMap((c) => c.vendorAdapter ?? []))].sort();
|
|
892
|
+
const perSeat = {};
|
|
893
|
+
for (const seat of seats)
|
|
894
|
+
perSeat[seat] = zero();
|
|
895
|
+
const adapterById = new Map(contracts.map((c) => [c.id, c.vendorAdapter]));
|
|
896
|
+
for (const r of rows) {
|
|
897
|
+
const adapter = adapterById.get(r.id);
|
|
898
|
+
for (const seat of seats) {
|
|
899
|
+
if (adapter === undefined || adapter.length === 0 || adapter.includes(seat)) {
|
|
900
|
+
perSeat[seat][r.verdict] += 1;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// R2: the denominator counts CONTRACTS (registry ∩ manifest is contract-
|
|
905
|
+
// granular), while the rollup above counts rendered verdict LINES — two
|
|
906
|
+
// populations, named apart in the render (raised Delta 2).
|
|
907
|
+
const denominator = { mechanical: 0, attestationOnly: 0, honestAbsent: 0 };
|
|
908
|
+
for (const c of contracts) {
|
|
909
|
+
const cls = meta[c.id]?.coverage ?? 'honest-absent';
|
|
910
|
+
if (cls === 'mechanical')
|
|
911
|
+
denominator.mechanical += 1;
|
|
912
|
+
else if (cls === 'attestation-only')
|
|
913
|
+
denominator.attestationOnly += 1;
|
|
914
|
+
else
|
|
915
|
+
denominator.honestAbsent += 1;
|
|
916
|
+
}
|
|
917
|
+
// R5: blocking-ids = the manifest's DECLARED `blocking: true` set, derived
|
|
918
|
+
// from the yaml at run time (never from prose — Tenet 20); gates-anything =
|
|
919
|
+
// that set is non-empty. Which blocking contracts actually drifted is
|
|
920
|
+
// visible as `fail` rows (strict) / promotable warns (default).
|
|
921
|
+
const blockingIds = contracts.filter((c) => c.blocking === true).map((c) => c.id);
|
|
922
|
+
return {
|
|
923
|
+
manifest: inputs.manifest,
|
|
924
|
+
rollup: { global, perSeat },
|
|
925
|
+
denominator,
|
|
926
|
+
strict: { armed: strict, blockingIds, gatesAnything: blockingIds.length > 0 },
|
|
927
|
+
rows,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/** One-line count rendering shared by the global + per-seat rollup lines. */
|
|
931
|
+
function renderCounts(c) {
|
|
932
|
+
return `${c.pass} pass · ${c.warn} warn · ${c.info} info · ${c.unknown} unknown · ${c.skip} skip · ${c.fail} fail`;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* R3 attestation-age fragment: days since `last-attested:` ("not recorded"
|
|
936
|
+
* when absent). Staleness refines the message, never the status (the existing
|
|
937
|
+
* manifest constraint, unchanged).
|
|
938
|
+
*/
|
|
939
|
+
function attestationAge(lastAttested) {
|
|
940
|
+
if (lastAttested === undefined)
|
|
941
|
+
return 'last attested: not recorded';
|
|
942
|
+
const t = Date.parse(lastAttested);
|
|
943
|
+
if (Number.isNaN(t))
|
|
944
|
+
return `last attested: ${lastAttested} (unparseable date)`;
|
|
945
|
+
const days = Math.max(0, Math.floor((Date.now() - t) / 86_400_000));
|
|
946
|
+
return `last attested ${days} day(s) ago`;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* The `--json` verdict artifact (R4) — field names fixed by the spec section
|
|
950
|
+
* (kebab-case, no local envelope: the spec's top-level shape overrides the
|
|
951
|
+
* `json-output.ts` success/error wrapper). Emitted bare on stdout so the
|
|
952
|
+
* artifact is diffable (Prop 302 verdict-artifact discipline). On degenerate
|
|
953
|
+
* manifest-load states the artifact carries the load status and empty
|
|
954
|
+
* rollup/rows — the honest "nothing to roll up" shape.
|
|
955
|
+
*/
|
|
956
|
+
function readoutJsonArtifact(readout, loadStatus, strict) {
|
|
957
|
+
const countsJson = (c) => ({
|
|
958
|
+
pass: c.pass,
|
|
959
|
+
warn: c.warn,
|
|
960
|
+
info: c.info,
|
|
961
|
+
unknown: c.unknown,
|
|
962
|
+
skip: c.skip,
|
|
963
|
+
fail: c.fail,
|
|
964
|
+
});
|
|
965
|
+
if (readout === undefined) {
|
|
966
|
+
const zero = { pass: 0, warn: 0, info: 0, unknown: 0, skip: 0, fail: 0 };
|
|
967
|
+
return {
|
|
968
|
+
'readout-schema-version': READOUT_SCHEMA_VERSION,
|
|
969
|
+
manifest: { status: loadStatus },
|
|
970
|
+
rollup: { global: countsJson(zero), 'per-seat': {} },
|
|
971
|
+
denominator: {
|
|
972
|
+
mechanical: 0,
|
|
973
|
+
'attestation-only': 0,
|
|
974
|
+
'honest-absent': 0,
|
|
975
|
+
'claim-boundary': CLAIM_BOUNDARY,
|
|
976
|
+
},
|
|
977
|
+
strict: { armed: strict, 'blocking-ids': [], 'gates-anything': false },
|
|
978
|
+
rows: [],
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
'readout-schema-version': READOUT_SCHEMA_VERSION,
|
|
983
|
+
manifest: {
|
|
984
|
+
'schema-version': readout.manifest.schemaVersion,
|
|
985
|
+
status: readout.manifest.status,
|
|
986
|
+
},
|
|
987
|
+
rollup: {
|
|
988
|
+
global: countsJson(readout.rollup.global),
|
|
989
|
+
'per-seat': Object.fromEntries(Object.entries(readout.rollup.perSeat).map(([seat, c]) => [seat, countsJson(c)])),
|
|
990
|
+
},
|
|
991
|
+
denominator: {
|
|
992
|
+
mechanical: readout.denominator.mechanical,
|
|
993
|
+
'attestation-only': readout.denominator.attestationOnly,
|
|
994
|
+
'honest-absent': readout.denominator.honestAbsent,
|
|
995
|
+
'claim-boundary': CLAIM_BOUNDARY,
|
|
996
|
+
},
|
|
997
|
+
strict: {
|
|
998
|
+
armed: readout.strict.armed,
|
|
999
|
+
'blocking-ids': readout.strict.blockingIds,
|
|
1000
|
+
'gates-anything': readout.strict.gatesAnything,
|
|
1001
|
+
},
|
|
1002
|
+
rows: readout.rows.map((r) => ({
|
|
1003
|
+
id: r.id,
|
|
1004
|
+
verdict: r.verdict,
|
|
1005
|
+
...(r.sensesProbed !== undefined ? { 'senses-probed': r.sensesProbed } : {}),
|
|
1006
|
+
...(r.reasonClass !== undefined ? { 'reason-class': r.reasonClass } : {}),
|
|
1007
|
+
message: r.message,
|
|
1008
|
+
...(r.lastAttested !== undefined ? { 'last-attested': r.lastAttested } : {}),
|
|
1009
|
+
})),
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
729
1012
|
// ─── CLI entry ──────────────────────────────────────────
|
|
730
1013
|
// Same value as CHECK_NAME — aliased (not re-literal'd) so the two can't drift.
|
|
731
1014
|
const TAG = CHECK_NAME;
|
|
@@ -747,23 +1030,50 @@ export async function doctorParityCliCommand(options = {}) {
|
|
|
747
1030
|
.replace(/ {2,}/g, ' ')
|
|
748
1031
|
.trim();
|
|
749
1032
|
const cwd = options.cwdForTest ?? process.cwd();
|
|
750
|
-
const { results, blockingDriftIds, configured } = await checkParity(cwd);
|
|
1033
|
+
const { results, blockingDriftIds, configured, loadStatus, readout: readoutInputs, } = await checkParity(cwd);
|
|
751
1034
|
// Folded-into-`--strict` no-op: when this run is the strict fold (not an explicit
|
|
752
1035
|
// `doctor --parity`) and no repo-local manifest is configured, render and gate
|
|
753
1036
|
// nothing — a non-adopter's `doctor --strict` stays byte-identical to before the
|
|
754
1037
|
// fold. Explicit `--parity` (onlyWhenConfigured omitted) still shows the SKIP.
|
|
755
1038
|
if (options.onlyWhenConfigured && !configured)
|
|
756
1039
|
return;
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
1040
|
+
const strict = options.strict === true;
|
|
1041
|
+
const readout = readoutInputs !== undefined
|
|
1042
|
+
? buildParityReadout(readoutInputs, results, blockingDriftIds, strict)
|
|
1043
|
+
: undefined;
|
|
1044
|
+
// `--json` replaces the human render wholesale: the artifact owns stdout so
|
|
1045
|
+
// it stays diffable (R4). The strict throw below still applies — artifact
|
|
1046
|
+
// AND exit code, matching R5's "exits non-zero iff ≥1 fail".
|
|
1047
|
+
if (options.json) {
|
|
1048
|
+
process.stdout.write(JSON.stringify(readoutJsonArtifact(readout, loadStatus, strict), null, 2) + '\n');
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
renderParityHuman(results, blockingDriftIds, strict, { log, bold, errorColor, successColor, warnColor }, render);
|
|
1052
|
+
if (readout !== undefined) {
|
|
1053
|
+
renderTrustReadout(readout, { log, bold }, render);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Sensor-not-gate: drift is report-only by default (exit 0). Only `--strict`
|
|
1057
|
+
// promotes a `blocking: true` contract's drift to a non-zero exit; non-blocking
|
|
1058
|
+
// drift never gates, and `info`/`unknown` are never promoted. The detectors
|
|
1059
|
+
// emit no raw `fail` status, so the gate is purely the strict+blocking
|
|
1060
|
+
// promotion — the gating model for a future slice that DOES emit a `fail` is
|
|
1061
|
+
// settled when that slice lands (CR review mmnto-ai/totem#2071: keep the gate from
|
|
1062
|
+
// suggesting a non-strict path that would break sensor-not-gate).
|
|
1063
|
+
if (options.strict && blockingDriftIds.length > 0) {
|
|
1064
|
+
throw new TotemError('PARITY_DRIFT_DETECTED', `${blockingDriftIds.length} parity contract(s) reported blocking drift under --strict.`, 'Reconcile each blocking contract against its canonical source, then re-run totem doctor --parity --strict.');
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
/** The pre-readout flat per-line render (unchanged output, extracted for the `--json` split). */
|
|
1068
|
+
function renderParityHuman(results, blockingDriftIds, strict, ui, render) {
|
|
1069
|
+
const { log, bold } = ui;
|
|
1070
|
+
const errorColor = ui.errorColor ?? ((s) => s);
|
|
1071
|
+
const successColor = ui.successColor ?? ((s) => s);
|
|
1072
|
+
const warnColor = ui.warnColor ?? ((s) => s);
|
|
765
1073
|
for (const r of results) {
|
|
766
|
-
const status =
|
|
1074
|
+
const status = strict && r.status === 'warn' && isPromotableLineName(r.name, blockingDriftIds)
|
|
1075
|
+
? 'fail'
|
|
1076
|
+
: r.status;
|
|
767
1077
|
switch (status) {
|
|
768
1078
|
case 'pass':
|
|
769
1079
|
log.success(TAG, `${successColor(bold('PASS'))} — ${render(r.message)}`);
|
|
@@ -799,15 +1109,58 @@ export async function doctorParityCliCommand(options = {}) {
|
|
|
799
1109
|
break;
|
|
800
1110
|
}
|
|
801
1111
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The trust-readout tail render (mmnto-ai/totem#2327 R1–R3, R5) — replaces the
|
|
1115
|
+
* bare "N loaded" ending with the rollup, coverage denominator, why-not lines,
|
|
1116
|
+
* and the `--strict` honesty statement. Verdict lines above stay untouched.
|
|
1117
|
+
*/
|
|
1118
|
+
function renderTrustReadout(readout, ui, render) {
|
|
1119
|
+
const { log, bold } = ui;
|
|
1120
|
+
log.info(TAG, bold('── trust-readout ──'));
|
|
1121
|
+
// R1 — one vocabulary, two scopes. Units named (verdict lines ≠ contracts).
|
|
1122
|
+
log.info(TAG, `rollup (verdict lines) — global: ${renderCounts(readout.rollup.global)}`);
|
|
1123
|
+
const seats = Object.keys(readout.rollup.perSeat);
|
|
1124
|
+
if (seats.length === 0) {
|
|
1125
|
+
log.dim(TAG, 'rollup — no seat-scoped rows declared (per-seat = global)');
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
for (const seat of seats) {
|
|
1129
|
+
log.info(TAG, `rollup — seat ${render(seat)}: ${renderCounts(readout.rollup.perSeat[seat])}`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// R2 — three counts on their own line, never one "covered" number; the
|
|
1133
|
+
// claim boundary stated in the readout's own words.
|
|
1134
|
+
const d = readout.denominator;
|
|
1135
|
+
log.info(TAG, `coverage (contracts) — ${d.mechanical} mechanically sensed · ${d.attestationOnly} attestation-only · ${d.honestAbsent} honest-absent`);
|
|
1136
|
+
log.dim(TAG, `claim boundary: ${CLAIM_BOUNDARY}`);
|
|
1137
|
+
// R3 — one why-not line per non-pass row, at the level actually probed
|
|
1138
|
+
// (green-halo cap: nothing renders above what was probed).
|
|
1139
|
+
const whyNot = readout.rows.filter((r) => r.verdict !== 'pass');
|
|
1140
|
+
if (whyNot.length > 0) {
|
|
1141
|
+
log.info(TAG, 'why-not (per non-pass row):');
|
|
1142
|
+
for (const r of whyNot) {
|
|
1143
|
+
const level = r.sensesProbed !== undefined ? ` at ${r.sensesProbed}` : '';
|
|
1144
|
+
const reason = r.reasonClass !== undefined ? ` [${r.reasonClass}]` : '';
|
|
1145
|
+
// R3 age-in-days for attestation rows. The detector message already
|
|
1146
|
+
// carries the raw date / "not recorded" text — append the derived age
|
|
1147
|
+
// only when a date exists to derive it from (no duplicate "not recorded").
|
|
1148
|
+
const age = r.reasonClass === 'attestation' && r.lastAttested !== undefined
|
|
1149
|
+
? ` · ${attestationAge(r.lastAttested)}`
|
|
1150
|
+
: '';
|
|
1151
|
+
const gate = r.skippedNotGated ? ' · blocking — skipped-not-gated, never a silent pass' : '';
|
|
1152
|
+
log.dim(TAG, ` ${render(r.lineName)}: ${r.verdict.toUpperCase()}${level}${reason} — ${render(r.message)}${age}${gate}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// R5 — declaredly-toothless in the readout's own words; derived from the
|
|
1156
|
+
// manifest's blocking set at run time, never from prose.
|
|
1157
|
+
const s = readout.strict;
|
|
1158
|
+
const armed = s.armed ? 'armed' : 'not armed';
|
|
1159
|
+
if (!s.gatesAnything) {
|
|
1160
|
+
log.info(TAG, `--strict (${armed}): currently gates nothing — the manifest declares no blocking: true contracts`);
|
|
1161
|
+
}
|
|
1162
|
+
else {
|
|
1163
|
+
log.info(TAG, `--strict (${armed}): gates ${s.blockingIds.length} blocking contract(s): ${s.blockingIds.map(render).join(', ')}`);
|
|
811
1164
|
}
|
|
812
1165
|
}
|
|
813
1166
|
//# sourceMappingURL=doctor-parity.js.map
|