@mmnto/totem 1.76.1 → 1.78.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.
@@ -39,8 +39,9 @@ import * as fs from 'node:fs';
39
39
  import * as path from 'node:path';
40
40
  import semver from 'semver';
41
41
  import { parse as parseYaml } from 'yaml';
42
+ import { z } from 'zod';
42
43
  import { PARITY_SENSES } from './parity-manifest.js';
43
- import { resolveStrategyRoot } from './strategy-resolver.js';
44
+ import { resolveStrategyRoot, } from './strategy-resolver.js';
44
45
  import { safeExec } from './sys/exec.js';
45
46
  import { resolveGitRoot } from './sys/git.js';
46
47
  // ─── Constants ──────────────────────────────────────────
@@ -60,6 +61,14 @@ const SIBLING_TOTEM_DIRNAME = 'totem';
60
61
  * — so `resolveCohortFloor` probes it via `resolveStrategyRoot`. mmnto-ai/totem#2108.
61
62
  */
62
63
  const SIBLING_STRATEGY_DIRNAME = 'totem-strategy';
64
+ /**
65
+ * Parity dimension for toolchain-version rows. A row in this dimension that
66
+ * resolves NO deps package (e.g. `pnpm-engine-version`) pins its engine via the
67
+ * consumer's `packageManager` field instead — the toolchain reader senses it
68
+ * (mmnto-ai/totem#2115). `mmnto-cli-version` shares this dimension but resolves
69
+ * `@mmnto/cli`, so it stays on the deps path.
70
+ */
71
+ export const TOOLCHAIN_DIMENSION = 'toolchain-version';
63
72
  /** Bounded git-command timeout for the origin-remote probe (matches `sys/git.ts`). */
64
73
  const GIT_REMOTE_TIMEOUT_MS = 15_000;
65
74
  /**
@@ -389,6 +398,12 @@ export function detectVersionPinnedContract(contract, ctx) {
389
398
  // standalone callers (tests). gitRoot is the floorRoot for a path locator.
390
399
  const packageName = ctx.packageName ?? packageNameForContract(contract, ctx.gitRoot);
391
400
  if (packageName === undefined) {
401
+ // A toolchain-version row with no deps package pins its engine via the
402
+ // consumer's `packageManager` field (e.g. pnpm-engine-version) — route it to
403
+ // the packageManager reader rather than skipping (mmnto-ai/totem#2115).
404
+ if (contract.dimension === TOOLCHAIN_DIMENSION) {
405
+ return detectPackageManagerToolchain(contract, ctx);
406
+ }
392
407
  return {
393
408
  status: 'skip',
394
409
  message: `not a deps version-pinned contract this slice handles (${contract.id})`,
@@ -477,6 +492,102 @@ export function detectVersionPinnedContract(contract, ctx) {
477
492
  remediation: `Bump the ${packageName} dependency to >= ${floor.version} and reinstall, then re-run totem doctor --parity.`,
478
493
  };
479
494
  }
495
+ /**
496
+ * Parse a `<name>@<version>(+<hash>)?` corepack spec.
497
+ *
498
+ * Two modes (greptile review mmnto-ai/totem#2254):
499
+ * - `exact: false` (default) — match the LEADING token, tolerating trailing
500
+ * prose. For the manifest FLOOR, expressed as `pnpm@11.2.2 floor; pin >= floor`.
501
+ * - `exact: true` — the WHOLE trimmed value must match. For the consumer's
502
+ * `packageManager` field: corepack requires the entire value to be a valid
503
+ * `<name>@<version>[+<hash>]`, so a declaration like `pnpm@11.2.2 garbage`
504
+ * is MALFORMED — doctor must not report it current off a leading-token match.
505
+ *
506
+ * Returns undefined when no spec matches. Pure + total — never throws (mmnto-ai/totem#2115).
507
+ */
508
+ function parsePackageManagerSpec(raw, opts = {}) {
509
+ if (typeof raw !== 'string')
510
+ return undefined;
511
+ // <name> = a package-manager slug; <version> = a semver core (+ optional
512
+ // prerelease/build that stops at whitespace or the `+hash` delimiter). Under
513
+ // `exact`, a trailing `$` rejects anything after the (optional) hash.
514
+ const pattern = opts.exact
515
+ ? /^([a-z][a-z0-9-]*)@(\d+\.\d+\.\d+[^\s+]*)(\+\S+)?$/i
516
+ : /^([a-z][a-z0-9-]*)@(\d+\.\d+\.\d+[^\s+]*)(\+\S+)?/i;
517
+ const m = raw.trim().match(pattern);
518
+ if (m?.[1] === undefined || m[2] === undefined)
519
+ return undefined;
520
+ return { name: m[1], version: m[2], hash: m[3] !== undefined ? m[3].slice(1) : undefined };
521
+ }
522
+ /**
523
+ * Sense a toolchain-version row that pins its engine via the consumer's
524
+ * `packageManager` field (e.g. `pnpm-engine-version`). The manifest row carries
525
+ * the floor in `expected-value-or-derivation` (`<engine>@<floor>` — there is no
526
+ * `packages/*​/package.json` to glob, `canonical-source` is null). Reads the
527
+ * DECLARATION only (`senses: declared`) — never probes the installed binary.
528
+ *
529
+ * Verdicts (claim-class bound to pin currency, mirroring the deps path):
530
+ * - **pass** — consumer's `packageManager` engine matches + version ≥ floor.
531
+ * - **warn** — engine matches but version < floor (stale).
532
+ * - **skip** — no floor derivable / no `packageManager` field / unparseable pin
533
+ * / a DIFFERENT engine (the floor doesn't apply) / invalid versions.
534
+ * NEVER throws, NEVER networks (mmnto-ai/totem#2115).
535
+ */
536
+ function detectPackageManagerToolchain(contract, ctx) {
537
+ const floorSpec = parsePackageManagerSpec(contract.expectedValueOrDerivation);
538
+ if (floorSpec === undefined) {
539
+ return {
540
+ status: 'skip',
541
+ message: `${contract.id}: cohort floor not derivable from expected-value "${contract.expectedValueOrDerivation}"`,
542
+ };
543
+ }
544
+ const readPkg = ctx.readPackageJson ?? readPackageJson;
545
+ const consumerPkg = readPkg(path.join(ctx.cwd, 'package.json'));
546
+ const pmField = consumerPkg?.packageManager;
547
+ if (typeof pmField !== 'string' || pmField.trim().length === 0) {
548
+ return {
549
+ status: 'skip',
550
+ message: `${contract.id}: no packageManager field declared (honest-absent)`,
551
+ };
552
+ }
553
+ // Exact match: the WHOLE field must be a valid corepack pin — a malformed
554
+ // declaration (trailing junk after the version/hash) must not pass off a
555
+ // leading-token match (greptile mmnto-ai/totem#2254).
556
+ const pin = parsePackageManagerSpec(pmField, { exact: true });
557
+ if (pin === undefined) {
558
+ return {
559
+ status: 'skip',
560
+ message: `${contract.id}: packageManager "${pmField}" is not a parseable <name>@<version> pin`,
561
+ };
562
+ }
563
+ // A different engine → this row's floor simply doesn't apply here.
564
+ if (pin.name !== floorSpec.name) {
565
+ return {
566
+ status: 'skip',
567
+ message: `${contract.id}: consumer pins ${pin.name}@${pin.version}, not ${floorSpec.name} — cohort floor does not apply`,
568
+ };
569
+ }
570
+ if (semver.valid(pin.version) === null || semver.valid(floorSpec.version) === null) {
571
+ return {
572
+ status: 'skip',
573
+ message: `${contract.id}: unparseable version (pin "${pin.version}", floor "${floorSpec.version}")`,
574
+ };
575
+ }
576
+ // Hashless pins lose corepack integrity verification — surfaced as a note, not
577
+ // a failure (strategy#566 Greptile P2: hashless pins are a derive-before-authoring smell).
578
+ const hashNote = pin.hash === undefined ? ' (pin is hashless — corepack integrity not pinned)' : '';
579
+ if (semver.gte(pin.version, floorSpec.version)) {
580
+ return {
581
+ status: 'pass',
582
+ message: `${pin.name} engine pin current — packageManager ${pin.version} ≥ cohort floor ${floorSpec.version}${hashNote}`,
583
+ };
584
+ }
585
+ return {
586
+ status: 'warn',
587
+ message: `${pin.name} engine pin stale — packageManager ${pin.version} < cohort floor ${floorSpec.version}${hashNote}`,
588
+ remediation: `Bump the packageManager field to ${pin.name}@${floorSpec.version} (or a newer EXACT version — corepack requires an exact pin, not a range), then re-run totem doctor --parity.`,
589
+ };
590
+ }
480
591
  /**
481
592
  * Resolve the consumer's installed `@mmnto/*` version. Walks UP the directory
482
593
  * tree from `cwd` reading `<dir>/node_modules/<pkg>/package.json#version` at each
@@ -1320,6 +1431,429 @@ export function detectValueEqualityContract(contract, ctx) {
1320
1431
  remediation: `Reconcile ${pathLabel} to ${rawExpected} in ${fileLabel}, or update the contract if the cohort canonical changed.`,
1321
1432
  };
1322
1433
  }
1434
+ // ─── Lock-content detector (mmnto-ai/totem#2107, strategy#754 content-hash rung) ──
1435
+ /**
1436
+ * §6 normalize-before-hash for a distributed lock artifact — byte-for-byte the
1437
+ * canonical `tools/build-strategy-doctrine.cjs` `normalize()` the publisher hashes
1438
+ * with (the builder header MANDATES the verifier reconcile with it): CRLF / lone-CR
1439
+ * → LF, strip trailing spaces/tabs per line, pop ALL trailing blank lines, join with
1440
+ * exactly one terminal `\n`. Idempotent — re-normalizing a shipped (already-normalized)
1441
+ * file is a no-op, so `hash(shipped)` holds cross-platform.
1442
+ *
1443
+ * DISTINCT from {@link normalizeManagedBlock} (which ALSO trims LEADING blank lines and
1444
+ * leaves NO terminal newline) — the two must not be conflated; this one mirrors the lock
1445
+ * publisher exactly. A golden test pins the byte-for-byte parity against a precomputed hash.
1446
+ */
1447
+ export function normalizeLockArtifact(text) {
1448
+ const lines = text
1449
+ .replace(/\r\n?/g, '\n')
1450
+ .split('\n')
1451
+ .map((l) => l.replace(/[ \t]+$/g, ''));
1452
+ while (lines.length > 0 && lines[lines.length - 1] === '')
1453
+ lines.pop();
1454
+ return lines.join('\n') + '\n';
1455
+ }
1456
+ /**
1457
+ * Full sha256 of a normalized lock artifact, `sha256:`-prefixed hex — byte-for-byte
1458
+ * the publisher's `sha256` helper (`'sha256:' + sha256hex(utf8)`). DISTINCT from
1459
+ * {@link hashManagedBlock} (a 12-char SHORT hash for managed-block verdict records);
1460
+ * the lock content-hash is the FULL digest the lock records.
1461
+ */
1462
+ export function hashLockArtifact(normalized) {
1463
+ return 'sha256:' + crypto.createHash('sha256').update(normalized, 'utf-8').digest('hex');
1464
+ }
1465
+ /** The single lock schema version this reader understands (mirrors the manifest gate). */
1466
+ export const SUPPORTED_LOCK_SCHEMA_VERSION = 1;
1467
+ const RawLockArtifactSchema = z.object({
1468
+ path: z.string(),
1469
+ 'canonical-source': z.string(),
1470
+ 'content-hash': z.string(),
1471
+ 'last-published-sha': z.string(),
1472
+ });
1473
+ const RawStrategyDoctrineLockSchema = z.object({
1474
+ 'schema-version': z.number(),
1475
+ package: z.string(),
1476
+ version: z.string(),
1477
+ published: z.string(),
1478
+ artifacts: z.array(RawLockArtifactSchema),
1479
+ });
1480
+ /**
1481
+ * Parse raw `strategy-doctrine.lock` JSON into a validated {@link StrategyDoctrineLock}.
1482
+ * The `schema-version` gate runs BEFORE full validation so an incompatible future shape
1483
+ * is rejected with a clear `unsupported-schema` signal rather than a confusing
1484
+ * v1-shaped Zod failure (mirrors {@link parseParityManifest}). NEVER throws.
1485
+ */
1486
+ export function parseStrategyDoctrineLock(jsonText) {
1487
+ let doc;
1488
+ try {
1489
+ doc = JSON.parse(jsonText);
1490
+ // totem-context: malformed lock JSON degrades to an `unparseable` signal (the sensor warns) — never a throw that would sink the doctor pipeline.
1491
+ }
1492
+ catch (err) {
1493
+ const reason = err instanceof Error ? err.message.split('\n')[0] : String(err);
1494
+ return { status: 'unparseable', reason: `Invalid JSON: ${reason}` };
1495
+ }
1496
+ // schema-version gate BEFORE full validation (a non-mapping / missing version → unparseable).
1497
+ const versionProbe = z.object({ 'schema-version': z.number() }).safeParse(doc);
1498
+ if (!versionProbe.success) {
1499
+ return {
1500
+ status: 'unparseable',
1501
+ reason: 'Lock is not a mapping with a numeric `schema-version`',
1502
+ };
1503
+ }
1504
+ const rawVersion = versionProbe.data['schema-version'];
1505
+ if (rawVersion !== SUPPORTED_LOCK_SCHEMA_VERSION) {
1506
+ return { status: 'unsupported-schema', schemaVersion: rawVersion };
1507
+ }
1508
+ const parsed = RawStrategyDoctrineLockSchema.safeParse(doc);
1509
+ if (!parsed.success) {
1510
+ return {
1511
+ status: 'unparseable',
1512
+ reason: `Lock failed schema validation: ${parsed.error.issues
1513
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
1514
+ .join('; ')}`,
1515
+ };
1516
+ }
1517
+ return {
1518
+ status: 'ok',
1519
+ lock: {
1520
+ schemaVersion: parsed.data['schema-version'],
1521
+ package: parsed.data.package,
1522
+ version: parsed.data.version,
1523
+ published: parsed.data.published,
1524
+ artifacts: parsed.data.artifacts.map((a) => ({
1525
+ path: a.path,
1526
+ canonicalSource: a['canonical-source'],
1527
+ contentHash: a['content-hash'],
1528
+ lastPublishedSha: a['last-published-sha'],
1529
+ })),
1530
+ },
1531
+ };
1532
+ }
1533
+ /** The canonical-source repo prefix this slice resolves for the vs-canonical layer. */
1534
+ const STRATEGY_CANONICAL_PREFIX = 'mmnto-ai/totem-strategy:';
1535
+ /** Default lock filename inside the `@mmnto/strategy-doctrine` package. */
1536
+ const STRATEGY_DOCTRINE_LOCK_FILENAME = 'strategy-doctrine.lock';
1537
+ /** The package name the lock-content slice senses (for honest-absent messages). */
1538
+ const STRATEGY_DOCTRINE_PACKAGE = '@mmnto/strategy-doctrine';
1539
+ /** A short, render-only form of a commit sha for provenance evidence. */
1540
+ function shortSha(sha) {
1541
+ return /^[0-9a-f]{7,}$/i.test(sha) ? sha.slice(0, 8) : sha;
1542
+ }
1543
+ /**
1544
+ * Parse a lock `canonical-source` into its strategy-repo-relative path, ONLY for the
1545
+ * `mmnto-ai/totem-strategy:<path>` shape this slice resolves. Any other repo / shape →
1546
+ * undefined (the vs-canonical layer skips that artifact rather than over-claiming).
1547
+ */
1548
+ function parseStrategyCanonicalPath(canonicalSource) {
1549
+ if (!canonicalSource.startsWith(STRATEGY_CANONICAL_PREFIX))
1550
+ return undefined;
1551
+ const rel = canonicalSource.slice(STRATEGY_CANONICAL_PREFIX.length).trim();
1552
+ return rel.length > 0 ? rel : undefined;
1553
+ }
1554
+ /**
1555
+ * Real-path resolver for the symlink-escape guard. Returns the canonicalized absolute
1556
+ * path, or undefined when the target does not exist / is unresolvable.
1557
+ */
1558
+ function defaultRealpath(p) {
1559
+ try {
1560
+ return fs.realpathSync(p);
1561
+ // totem-context: a non-existent path (ENOENT) makes realpathSync throw — the honest "no real target to follow" signal; the caller keeps the lexical path and the subsequent read degrades to honest-absent, never a sensor crash.
1562
+ }
1563
+ catch {
1564
+ return undefined;
1565
+ }
1566
+ }
1567
+ /**
1568
+ * Resolve `relPath` under `baseDir`, or undefined if it escapes (path-escape guard —
1569
+ * a malformed lock must never read outside the package / sibling root). Also rejects a
1570
+ * path that resolves to `baseDir` itself (a dir, not an artifact file).
1571
+ *
1572
+ * The lexical `path.relative` check proves only LEXICAL containment; a symlink INSIDE
1573
+ * `baseDir` can still redirect the real read outside it (CR + greptile security review on
1574
+ * mmnto-ai/totem#2256). So the real paths are re-checked: `realpath(baseDir)` canonicalizes
1575
+ * pnpm's own symlinked `node_modules` (both sides resolve into `.pnpm`, so a legitimate
1576
+ * install stays contained), while a malformed lock pointing THROUGH an escaping symlink
1577
+ * fails the real-path containment. An absent target (`realpath` → undefined) keeps the
1578
+ * lexical path — the subsequent read degrades to honest-absent, never a false escape.
1579
+ */
1580
+ function resolveWithinDir(baseDir, relPath, realpath) {
1581
+ const resolved = path.resolve(baseDir, relPath);
1582
+ const rel = path.relative(baseDir, resolved);
1583
+ if (rel.length === 0 || rel.startsWith('..') || path.isAbsolute(rel))
1584
+ return undefined;
1585
+ // Symlink-escape guard: re-check containment on the REAL paths.
1586
+ const realTarget = realpath(resolved);
1587
+ if (realTarget === undefined)
1588
+ return resolved; // target absent → lexical guard holds; read honest-absents
1589
+ const realBase = realpath(baseDir);
1590
+ if (realBase === undefined)
1591
+ return resolved; // base unresolvable → keep the lexical result
1592
+ const realRel = path.relative(realBase, realTarget);
1593
+ if (realRel.length === 0 || realRel.startsWith('..') || path.isAbsolute(realRel))
1594
+ return undefined;
1595
+ return resolved;
1596
+ }
1597
+ /**
1598
+ * Local git-object existence check (`git cat-file -e <sha>^{commit}`) for the
1599
+ * last-published-sha provenance note — NEVER networks, NEVER gates. A non-existent /
1600
+ * non-commit object exits non-zero (the honest "not a local object" signal).
1601
+ */
1602
+ function defaultGitObjectExists(sha, cwd) {
1603
+ if (!/^[0-9a-f]{7,40}$/i.test(sha))
1604
+ return false;
1605
+ try {
1606
+ safeExec('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd, timeout: GIT_REMOTE_TIMEOUT_MS });
1607
+ return true;
1608
+ // totem-context: a missing / non-commit object makes `git cat-file -e` exit non-zero (safeExec throws) — the honest "not a local object" signal for a provenance-INFO note, never a sensor failure.
1609
+ }
1610
+ catch {
1611
+ return false;
1612
+ }
1613
+ }
1614
+ /**
1615
+ * The self-consistency line for ONE artifact: `sha256(normalize(packaged file)) == lock
1616
+ * content-hash`. ALWAYS local — proves the shipped snapshot is internally intact. The
1617
+ * `last-published-sha` rides as provenance-info evidence (never a comparator).
1618
+ */
1619
+ function selfConsistencyLine(contract, artifact, packageDir, readFile, realpath) {
1620
+ const lineName = `Parity: ${contract.id} (${artifact.path} · self)`;
1621
+ const provenance = ` [last-published-sha ${shortSha(artifact.lastPublishedSha)}, provenance-info]`;
1622
+ const pkg = contract.package ?? STRATEGY_DOCTRINE_PACKAGE;
1623
+ const filePath = resolveWithinDir(packageDir, artifact.path, realpath);
1624
+ if (filePath === undefined) {
1625
+ return {
1626
+ lineName,
1627
+ verdict: {
1628
+ status: 'warn',
1629
+ message: `artifact path '${artifact.path}' escapes the package dir — refusing to read (lock may be malformed)`,
1630
+ },
1631
+ };
1632
+ }
1633
+ const raw = readFile(filePath);
1634
+ if (raw === undefined) {
1635
+ return {
1636
+ lineName,
1637
+ verdict: {
1638
+ status: 'warn',
1639
+ message: `packaged artifact ${artifact.path} absent — expected ${artifact.contentHash}${provenance}`,
1640
+ remediation: `Reinstall ${pkg} to restore the packaged artifact, then re-run totem doctor --parity.`,
1641
+ },
1642
+ };
1643
+ }
1644
+ const actual = hashLockArtifact(normalizeLockArtifact(raw));
1645
+ if (actual === artifact.contentHash) {
1646
+ return {
1647
+ lineName,
1648
+ verdict: {
1649
+ status: 'pass',
1650
+ message: `packaged ${artifact.path} intact — ${artifact.contentHash}${provenance}`,
1651
+ },
1652
+ };
1653
+ }
1654
+ return {
1655
+ lineName,
1656
+ verdict: {
1657
+ status: 'warn',
1658
+ message: `integrity drift — recomputed ${actual} != lock ${artifact.contentHash} at ${artifact.path}${provenance}`,
1659
+ remediation: `The packaged ${artifact.path} does not match its own lock record — reinstall ${pkg}.`,
1660
+ },
1661
+ };
1662
+ }
1663
+ /**
1664
+ * The vs-canonical line for ONE artifact: the same recompute against the artifact's
1665
+ * lock `canonical-source` within a resolved local `../totem-strategy` sibling. Runs ONLY
1666
+ * when a sibling resolves (else SKIP honest-absent) — NEVER a fetch. A mismatch is
1667
+ * currency drift (the pin lags local canonical), not an integrity failure.
1668
+ */
1669
+ function vsCanonicalLine(contract, artifact, ctx, readFile, realpath, sibling) {
1670
+ const lineName = `Parity: ${contract.id} (${artifact.path} · vs-canonical)`;
1671
+ if (!sibling.resolved) {
1672
+ return {
1673
+ lineName,
1674
+ verdict: {
1675
+ status: 'skip',
1676
+ message: `no local ../totem-strategy sibling resolvable — vs-canonical honest-absent (never a fetch): ${sibling.reason}`,
1677
+ },
1678
+ };
1679
+ }
1680
+ const relPath = parseStrategyCanonicalPath(artifact.canonicalSource);
1681
+ if (relPath === undefined) {
1682
+ return {
1683
+ lineName,
1684
+ verdict: {
1685
+ status: 'skip',
1686
+ message: `canonical-source '${artifact.canonicalSource}' is not a ${STRATEGY_CANONICAL_PREFIX}<path> ref — vs-canonical unprovable this slice`,
1687
+ },
1688
+ };
1689
+ }
1690
+ const canonicalPath = resolveWithinDir(sibling.path, relPath, realpath);
1691
+ if (canonicalPath === undefined) {
1692
+ return {
1693
+ lineName,
1694
+ verdict: {
1695
+ status: 'warn',
1696
+ message: `canonical-source path '${relPath}' escapes the strategy sibling root — refusing to read`,
1697
+ },
1698
+ };
1699
+ }
1700
+ const raw = readFile(canonicalPath);
1701
+ const lpsNote = lastPublishedNote(artifact.lastPublishedSha, sibling.path, ctx);
1702
+ if (raw === undefined) {
1703
+ return {
1704
+ lineName,
1705
+ verdict: {
1706
+ status: 'warn',
1707
+ message: `canonical source ${relPath} absent under the resolved sibling (${sibling.path}) — vs-canonical drift${lpsNote}`,
1708
+ remediation: `The local ../totem-strategy no longer carries ${relPath}; sync it, or this pin no longer reflects canonical.`,
1709
+ },
1710
+ };
1711
+ }
1712
+ const actual = hashLockArtifact(normalizeLockArtifact(raw));
1713
+ if (actual === artifact.contentHash) {
1714
+ return {
1715
+ lineName,
1716
+ verdict: {
1717
+ status: 'pass',
1718
+ message: `lock matches local strategy-canonical at ${relPath} — ${artifact.contentHash}${lpsNote}`,
1719
+ },
1720
+ };
1721
+ }
1722
+ return {
1723
+ lineName,
1724
+ verdict: {
1725
+ status: 'warn',
1726
+ message: `currency drift — lock ${artifact.contentHash} != current canonical ${actual} at ${relPath}${lpsNote}`,
1727
+ remediation: `The consumed ${contract.package ?? STRATEGY_DOCTRINE_PACKAGE} pin lags the local ../totem-strategy canonical for ${relPath}; bump the pin when a new doctrine bundle publishes (a local sibling ahead of the published pin is expected currency drift, not a defect).`,
1728
+ },
1729
+ };
1730
+ }
1731
+ /** Render the last-published-sha provenance note (+ a local git-object existence check). */
1732
+ function lastPublishedNote(sha, strategyRoot, ctx) {
1733
+ const exists = ctx.gitObjectExists ?? defaultGitObjectExists;
1734
+ const resolvable = exists(sha, strategyRoot) ? 'resolvable in sibling' : 'not a local git object';
1735
+ return ` [last-published-sha ${shortSha(sha)} ${resolvable}, provenance-info]`;
1736
+ }
1737
+ /**
1738
+ * Detect drift for the `manifestation: content-hash` strategy-doctrine lock-content
1739
+ * contract (mmnto-ai/totem#2107, strategy#754). Re-derives each distributed artifact's
1740
+ * `content-hash` from the consumed lock via the §6 normalize+sha256 contract and compares
1741
+ * it in TWO honest-absent layers, NEVER a fetch (Tenet 6/13):
1742
+ *
1743
+ * - **self-consistency** (ALWAYS, local): `sha256(normalize(packaged file at path)) ==
1744
+ * its lock content-hash` — proves the shipped snapshot is internally intact.
1745
+ * - **vs-canonical** (ONLY when a local `../totem-strategy` sibling resolves): the same
1746
+ * recompute against the artifact's lock `canonical-source` within the sibling — proves
1747
+ * the pin still reflects strategy-canonical. SKIP honest-absent otherwise.
1748
+ *
1749
+ * Returns ONE line per artifact × per layer (the layers render SEPARATELY — a collapsed
1750
+ * "content drift" verdict would over-claim which layer drifted). `last-published-sha` is
1751
+ * provenance-INFO only (a LOCAL git-object existence note when a sibling resolves) — NEVER
1752
+ * a gating comparator, NEVER `sha == HEAD`.
1753
+ *
1754
+ * Top-level honest-absent: not-a-consumer / repo-id-unresolvable → skip; package not
1755
+ * installed → skip (the version-pin currency row senses the pin); lock absent while the
1756
+ * package is present → warn (structurally incomplete); lock unparseable / unsupported-schema
1757
+ * → warn.
1758
+ *
1759
+ * NEVER emits `fail` (the CLI edge owns `--strict` promotion). NEVER throws (reads
1760
+ * degrade). NEVER networks.
1761
+ */
1762
+ export function detectLockContentContract(contract, ctx) {
1763
+ const idLine = (verdict) => [
1764
+ { lineName: `Parity: ${contract.id}`, verdict },
1765
+ ];
1766
+ // ── Applicability: consumers scope (verbatim parity with the other detectors) ──
1767
+ if (contract.consumers !== undefined) {
1768
+ if (ctx.repoId === undefined) {
1769
+ return idLine({
1770
+ status: 'skip',
1771
+ message: `cannot determine applicability — repo id unresolvable; contract is scoped to consumers [${contract.consumers.join(', ')}]`,
1772
+ });
1773
+ }
1774
+ if (!contract.consumers.includes(ctx.repoId)) {
1775
+ return idLine({
1776
+ status: 'skip',
1777
+ message: `cohort permits absence here (${ctx.repoId} not in consumers)`,
1778
+ });
1779
+ }
1780
+ }
1781
+ const readFile = ctx.readFile ?? readFileText;
1782
+ const realpath = ctx.realpath ?? defaultRealpath;
1783
+ const dirExists = ctx.dirExists ?? isDirectory;
1784
+ const lockFileName = ctx.lockFileName ?? STRATEGY_DOCTRINE_LOCK_FILENAME;
1785
+ const pkg = contract.package ?? STRATEGY_DOCTRINE_PACKAGE;
1786
+ // ── Package not installed → honest-absent skip (the currency row senses the pin) ──
1787
+ if (!dirExists(ctx.packageDir)) {
1788
+ return idLine({
1789
+ status: 'skip',
1790
+ message: `${pkg} not installed at ${ctx.packageDir} — lock-content unprovable (the version-pin currency row senses the pin)`,
1791
+ });
1792
+ }
1793
+ // ── Read the consumed lock (package present but lock absent → structurally incomplete) ──
1794
+ // Route the lock filename through the same containment guard as artifacts/canonical
1795
+ // sources so the exported `lockFileName` seam can't `../`-escape the package dir
1796
+ // (CR review on mmnto-ai/totem#2256 — uniform "no raw path.join in this detector").
1797
+ const lockPath = resolveWithinDir(ctx.packageDir, lockFileName, realpath);
1798
+ if (lockPath === undefined) {
1799
+ return idLine({
1800
+ status: 'warn',
1801
+ message: `${lockFileName} escapes the package dir — refusing to read (lock filename may be malformed)`,
1802
+ });
1803
+ }
1804
+ const lockRaw = readFile(lockPath);
1805
+ if (lockRaw === undefined) {
1806
+ return idLine({
1807
+ status: 'warn',
1808
+ message: `${pkg} installed but ${lockFileName} is absent — distributed package is structurally incomplete`,
1809
+ remediation: `Reinstall ${pkg} (a publish without its lock is a packaging defect), then re-run totem doctor --parity.`,
1810
+ });
1811
+ }
1812
+ // ── Parse (unparseable / unsupported-schema → warn; never throw) ──
1813
+ const parsed = parseStrategyDoctrineLock(lockRaw);
1814
+ if (parsed.status === 'unparseable') {
1815
+ return idLine({
1816
+ status: 'warn',
1817
+ message: `${lockFileName} is unparseable — ${parsed.reason}`,
1818
+ remediation: `Reinstall ${pkg} to restore a valid lock, then re-run totem doctor --parity.`,
1819
+ });
1820
+ }
1821
+ if (parsed.status === 'unsupported-schema') {
1822
+ return idLine({
1823
+ status: 'warn',
1824
+ message: `${lockFileName} schema-version ${parsed.schemaVersion} unsupported (this doctor understands ${SUPPORTED_LOCK_SCHEMA_VERSION}) — content-hash unprovable`,
1825
+ remediation: `Upgrade @mmnto/cli to a build that understands lock schema ${parsed.schemaVersion}, or reinstall a compatible ${pkg}.`,
1826
+ });
1827
+ }
1828
+ const { lock } = parsed;
1829
+ // Wrong-package guard (CR review on mmnto-ai/totem#2256): a stale / mispackaged lock from
1830
+ // ANOTHER package could self-consistently `pass` (its hashes match its own bundled files),
1831
+ // masking a packaging defect. The lock declaring a different package than the one it was
1832
+ // loaded under IS the structural inconsistency this sensor exists to catch.
1833
+ if (lock.package !== pkg) {
1834
+ return idLine({
1835
+ status: 'warn',
1836
+ message: `${lockFileName} declares package ${lock.package} but was loaded from ${pkg} — distributed package is structurally inconsistent`,
1837
+ remediation: `Reinstall ${pkg} to restore the correct lock, then re-run totem doctor --parity.`,
1838
+ });
1839
+ }
1840
+ if (lock.artifacts.length === 0) {
1841
+ return idLine({
1842
+ status: 'warn',
1843
+ message: `${lockFileName} declares no artifacts[] — nothing to verify (structurally incomplete)`,
1844
+ });
1845
+ }
1846
+ // ── Resolve the vs-canonical sibling ONCE (honest-absent; never a fetch) ──
1847
+ const resolveRoot = ctx.resolveStrategyRootFn ?? resolveStrategyRoot;
1848
+ const sibling = resolveRoot(ctx.gitRoot, { gitRoot: ctx.gitRoot });
1849
+ // ── Per artifact × per layer (rendered SEPARATELY — no collapsed verdict) ──
1850
+ const lines = [];
1851
+ for (const artifact of lock.artifacts) {
1852
+ lines.push(selfConsistencyLine(contract, artifact, ctx.packageDir, readFile, realpath));
1853
+ lines.push(vsCanonicalLine(contract, artifact, ctx, readFile, realpath, sibling));
1854
+ }
1855
+ return lines;
1856
+ }
1323
1857
  // ─── Shared filesystem helpers ──────────────────────────
1324
1858
  /** Read a UTF-8 text file, or undefined on any read failure (honest-absent). */
1325
1859
  function readFileText(absPath) {