@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.
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/parity-detect.d.ts +137 -0
- package/dist/parity-detect.d.ts.map +1 -1
- package/dist/parity-detect.js +535 -1
- package/dist/parity-detect.js.map +1 -1
- package/dist/parity-detect.test.js +84 -0
- package/dist/parity-detect.test.js.map +1 -1
- package/dist/parity-lock-content.test.d.ts +9 -0
- package/dist/parity-lock-content.test.d.ts.map +1 -0
- package/dist/parity-lock-content.test.js +432 -0
- package/dist/parity-lock-content.test.js.map +1 -0
- package/dist/parity-manifest.d.ts +1 -1
- package/dist/parity-manifest.d.ts.map +1 -1
- package/dist/parity-manifest.js +6 -0
- package/dist/parity-manifest.js.map +1 -1
- package/package.json +1 -1
package/dist/parity-detect.js
CHANGED
|
@@ -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) {
|