@mmnto/cli 1.117.0 → 1.118.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/bootstrap-wiring.test.js +28 -0
- package/dist/commands/bootstrap-wiring.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +6 -6
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +11 -8
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +54 -6
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +12 -1
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/lint.js +1 -1
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/pre-push-gate-matrix.test.d.ts +17 -0
- package/dist/commands/pre-push-gate-matrix.test.d.ts.map +1 -0
- package/dist/commands/pre-push-gate-matrix.test.js +135 -0
- package/dist/commands/pre-push-gate-matrix.test.js.map +1 -0
- package/dist/commands/shield-admission.test.d.ts +14 -0
- package/dist/commands/shield-admission.test.d.ts.map +1 -0
- package/dist/commands/shield-admission.test.js +301 -0
- package/dist/commands/shield-admission.test.js.map +1 -0
- package/dist/commands/shield-classify.d.ts +10 -0
- package/dist/commands/shield-classify.d.ts.map +1 -1
- package/dist/commands/shield-classify.js +19 -1
- package/dist/commands/shield-classify.js.map +1 -1
- package/dist/commands/shield-covariate.test.js +3 -0
- package/dist/commands/shield-covariate.test.js.map +1 -1
- package/dist/commands/shield-estimate.js +5 -5
- package/dist/commands/shield-estimate.js.map +1 -1
- package/dist/commands/shield-estimate.test.js +7 -3
- package/dist/commands/shield-estimate.test.js.map +1 -1
- package/dist/commands/shield-nonreview.test.d.ts +12 -14
- package/dist/commands/shield-nonreview.test.d.ts.map +1 -1
- package/dist/commands/shield-nonreview.test.js +228 -42
- package/dist/commands/shield-nonreview.test.js.map +1 -1
- package/dist/commands/shield.d.ts +116 -1
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +439 -116
- package/dist/commands/shield.js.map +1 -1
- package/dist/git.d.ts +20 -2
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +11 -4
- package/dist/git.js.map +1 -1
- package/dist/git.test.js +58 -18
- package/dist/git.test.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/commands/shield.js
CHANGED
|
@@ -974,14 +974,314 @@ export async function evaluateIncrementalEligibility(cwd, totemDir, configRoot)
|
|
|
974
974
|
linesChanged: totalLines,
|
|
975
975
|
};
|
|
976
976
|
}
|
|
977
|
+
// ─── Admission phase (mmnto-ai/totem#2473) ──────────────────────────────────
|
|
978
|
+
//
|
|
979
|
+
// Applicability is decided by ONE closed evaluator BEFORE the engine boots or
|
|
980
|
+
// any lane is configured: either an admitted payload (the exact fan inputs) or
|
|
981
|
+
// a `not-applicable` verdict with its deterministic reason (the exact record
|
|
982
|
+
// inputs). A deterministic skip is an ADMISSION outcome — never a lane status,
|
|
983
|
+
// never an `InvokeFailureKind` (#2452 describes failures after an invocation
|
|
984
|
+
// was attempted). The record binds the exact observation (scope + input hash +
|
|
985
|
+
// selection-policy fingerprint) so `--covariate` resolves the CURRENT identity
|
|
986
|
+
// deterministically — never wall-clock arbitration (codex review, 2026-08-14).
|
|
987
|
+
/**
|
|
988
|
+
* Base identifier for the non-code classifier whose partition the admission
|
|
989
|
+
* projection applies. `buildProjectionPolicy` appends a digest of the
|
|
990
|
+
* classifier's exported policy tables (`CLASSIFIER_POLICY_TABLES`), so a
|
|
991
|
+
* table edit re-keys every admission address MECHANICALLY — no hand-bumped
|
|
992
|
+
* version string to forget (falsification-leg MATERIAL 2). Bump the base
|
|
993
|
+
* only when the classification ALGORITHM changes shape (new lookup order,
|
|
994
|
+
* new rule class) rather than its tables.
|
|
995
|
+
*/
|
|
996
|
+
const REVIEW_CLASSIFIER_ID_BASE = 'classifyChangedFiles@1';
|
|
997
|
+
/** The requested selector expression, recorded as the no-diff arm's scope identity. */
|
|
998
|
+
export function requestedSelectorForm(options) {
|
|
999
|
+
if (options.diff !== undefined)
|
|
1000
|
+
return `--diff ${options.diff}`;
|
|
1001
|
+
if (options.staged === true)
|
|
1002
|
+
return '--staged';
|
|
1003
|
+
if (options.base !== undefined)
|
|
1004
|
+
return `--branch --base ${options.base}`;
|
|
1005
|
+
if (options.branch === true)
|
|
1006
|
+
return '--branch';
|
|
1007
|
+
return '(default-chain)';
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Mechanical payload narrowing shared by admission (full scope) and the legacy
|
|
1011
|
+
* incremental fast-path (delta): strip generated-artifact bytes (summarized,
|
|
1012
|
+
* not dropped), then filter non-code files. Pure classification — writes
|
|
1013
|
+
* nothing — so the read-only `--covariate` re-derivation can run it.
|
|
1014
|
+
*/
|
|
1015
|
+
async function prepareReviewPayload(diff, changedFiles, cwd, quiet) {
|
|
1016
|
+
const { filterDiffByPatterns } = await import('../git.js');
|
|
1017
|
+
const { classifyChangedFiles } = await import('./shield-classify.js');
|
|
1018
|
+
const { DEFAULT_GENERATED_ARTIFACT_GLOBS, buildGeneratedArtifactSection, classifyGeneratedArtifacts, formatGeneratedArtifactLine, readGitattributesGeneratedPatterns, } = await import('./shield-generated.js');
|
|
1019
|
+
// Stage 0.5: Exclude generated-artifact BYTES from the synthesis input
|
|
1020
|
+
// (mmnto-ai/totem#2398): lockfiles, compiled-rules.json, dist/**, *.wasm burn
|
|
1021
|
+
// review-context tokens on bytes no reviewer should read. Classified by the
|
|
1022
|
+
// seeded globs + `.gitattributes` linguist-generated, stripped with a
|
|
1023
|
+
// per-file SUMMARY instead of a silent drop.
|
|
1024
|
+
let keptDiff = diff;
|
|
1025
|
+
let keptFiles = changedFiles;
|
|
1026
|
+
let generatedArtifactSummary;
|
|
1027
|
+
const gitattr = readGitattributesGeneratedPatterns(cwd);
|
|
1028
|
+
const generated = classifyGeneratedArtifacts({
|
|
1029
|
+
diff,
|
|
1030
|
+
changedFiles,
|
|
1031
|
+
generatedGlobs: [...DEFAULT_GENERATED_ARTIFACT_GLOBS, ...gitattr.generated],
|
|
1032
|
+
excludeGlobs: gitattr.notGenerated,
|
|
1033
|
+
});
|
|
1034
|
+
if (generated.summaries.length > 0) {
|
|
1035
|
+
if (!quiet) {
|
|
1036
|
+
log.info(DISPLAY_TAG, `Excluded ${generated.summaries.length} generated-artifact file(s) from the review payload (summarized, not dropped):`);
|
|
1037
|
+
for (const summary of generated.summaries) {
|
|
1038
|
+
log.dim(DISPLAY_TAG, formatGeneratedArtifactLine(summary));
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
keptDiff = generated.keptDiff;
|
|
1042
|
+
keptFiles = generated.keptFiles;
|
|
1043
|
+
generatedArtifactSummary = buildGeneratedArtifactSection(generated.summaries);
|
|
1044
|
+
// All changed files were generated artifacts — nothing left to review.
|
|
1045
|
+
// This is the one skip that can drop a TRACKED, HASHED source file
|
|
1046
|
+
// (`.gitattributes linguist-generated` can mark a `.ts` as generated), so
|
|
1047
|
+
// it must never read as a clean review (mmnto-ai/totem#2466).
|
|
1048
|
+
if (!keptDiff.trim()) {
|
|
1049
|
+
return { empty: 'all-generated', skippedFiles: changedFiles };
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
// Stage 1: Classify files — fast-path for non-code-only diffs
|
|
1053
|
+
const classification = classifyChangedFiles(keptFiles);
|
|
1054
|
+
if (classification.allNonCode) {
|
|
1055
|
+
return { empty: 'all-non-code', skippedFiles: keptFiles };
|
|
1056
|
+
}
|
|
1057
|
+
// Stage 2: Filter diff to code-only files for mixed diffs
|
|
1058
|
+
let filteredDiff = keptDiff;
|
|
1059
|
+
let filteredFiles = keptFiles;
|
|
1060
|
+
if (!classification.allCode && classification.nonCodeFiles.length > 0) {
|
|
1061
|
+
filteredDiff = await filterDiffByPatterns(keptDiff, classification.nonCodeFiles);
|
|
1062
|
+
filteredFiles = classification.codeFiles;
|
|
1063
|
+
if (!filteredDiff.trim()) {
|
|
1064
|
+
// After filtering non-code files, no code diff remains — nothing was examined.
|
|
1065
|
+
return { empty: 'filtered-empty', skippedFiles: keptFiles };
|
|
1066
|
+
}
|
|
1067
|
+
if (!quiet) {
|
|
1068
|
+
log.dim(DISPLAY_TAG, `Filtered ${classification.nonCodeFiles.length} non-code file(s) from diff`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return {
|
|
1072
|
+
diff: keptDiff,
|
|
1073
|
+
changedFiles: keptFiles,
|
|
1074
|
+
filteredDiff,
|
|
1075
|
+
filteredFiles,
|
|
1076
|
+
generatedArtifactSummary,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Select the execution payload for an ADMITTED run: the incremental delta when
|
|
1081
|
+
* it is eligible AND re-projects to something reviewable, else the admitted
|
|
1082
|
+
* full-scope payload. A non-reviewable delta (docs-only since the last pass)
|
|
1083
|
+
* falls back — it can never demote the run to a skip, because admission
|
|
1084
|
+
* already admitted the full scope. Pure selection; the caller owns logging.
|
|
1085
|
+
*/
|
|
1086
|
+
export async function selectExecutionPayload(admitted, incremental, cwd, quiet) {
|
|
1087
|
+
const fullScope = {
|
|
1088
|
+
diff: admitted.diff,
|
|
1089
|
+
changedFiles: admitted.changedFiles,
|
|
1090
|
+
filteredDiff: admitted.filteredDiff,
|
|
1091
|
+
filteredFiles: admitted.filteredFiles,
|
|
1092
|
+
generatedArtifactSummary: admitted.generatedArtifactSummary,
|
|
1093
|
+
};
|
|
1094
|
+
if (!incremental.eligible || !incremental.deltaDiff || !incremental.changedFiles) {
|
|
1095
|
+
return { payload: fullScope, narrowed: false };
|
|
1096
|
+
}
|
|
1097
|
+
const delta = await prepareReviewPayload(incremental.deltaDiff, incremental.changedFiles, cwd, quiet);
|
|
1098
|
+
if (delta.empty !== undefined) {
|
|
1099
|
+
return { payload: fullScope, narrowed: false, deltaFallbackReason: delta.empty };
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
payload: {
|
|
1103
|
+
diff: delta.diff,
|
|
1104
|
+
changedFiles: delta.changedFiles,
|
|
1105
|
+
filteredDiff: delta.filteredDiff,
|
|
1106
|
+
filteredFiles: delta.filteredFiles,
|
|
1107
|
+
generatedArtifactSummary: delta.generatedArtifactSummary,
|
|
1108
|
+
},
|
|
1109
|
+
narrowed: true,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/** The effective selection policy whose projection produced the admission outcome. */
|
|
1113
|
+
export async function buildProjectionPolicy(config, cwd) {
|
|
1114
|
+
const { DEFAULT_GENERATED_ARTIFACT_GLOBS, readGitattributesGeneratedPatterns } = await import('./shield-generated.js');
|
|
1115
|
+
const { CLASSIFIER_POLICY_TABLES } = await import('./shield-classify.js');
|
|
1116
|
+
const crypto = await import('node:crypto');
|
|
1117
|
+
const gitattr = readGitattributesGeneratedPatterns(cwd);
|
|
1118
|
+
// Mechanical table binding (falsification-leg MATERIAL 2): the digest of the
|
|
1119
|
+
// classifier's exported tables rides the id, so an edited table changes the
|
|
1120
|
+
// policy hash with no version-bump discipline required.
|
|
1121
|
+
const tablesDigest = crypto
|
|
1122
|
+
.createHash('sha256')
|
|
1123
|
+
.update(JSON.stringify(CLASSIFIER_POLICY_TABLES), 'utf-8')
|
|
1124
|
+
.digest('hex')
|
|
1125
|
+
.slice(0, 12);
|
|
1126
|
+
return {
|
|
1127
|
+
sourceExtensions: config.review.sourceExtensions,
|
|
1128
|
+
generatedGlobs: [...DEFAULT_GENERATED_ARTIFACT_GLOBS, ...gitattr.generated],
|
|
1129
|
+
notGeneratedGlobs: gitattr.notGenerated,
|
|
1130
|
+
// Defensive `?? []`: the Zod-validated config always carries the field, but
|
|
1131
|
+
// this seam also receives cast fixtures (and the same guard shape git.ts
|
|
1132
|
+
// uses for `shieldIgnorePatterns`).
|
|
1133
|
+
ignorePatterns: [...(config.ignorePatterns ?? []), ...(config.shieldIgnorePatterns ?? [])],
|
|
1134
|
+
classifierId: `${REVIEW_CLASSIFIER_ID_BASE}:${tablesDigest}`,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* The closed admission evaluator (mmnto-ai/totem#2473 ruling item 1). Either
|
|
1139
|
+
* an admitted payload (the exact fan inputs) or `not-applicable` with the
|
|
1140
|
+
* exact record inputs. Read-only — the caller owns record emission.
|
|
1141
|
+
*/
|
|
1142
|
+
export async function evaluateAdmission(input) {
|
|
1143
|
+
const { computeProjectionPolicyHash } = await import('@mmnto/totem');
|
|
1144
|
+
const crypto = await import('node:crypto');
|
|
1145
|
+
const sha256Hex = (text) => crypto.createHash('sha256').update(text, 'utf-8').digest('hex');
|
|
1146
|
+
const projectionPolicyHash = computeProjectionPolicyHash(await buildProjectionPolicy(input.config, input.cwd));
|
|
1147
|
+
if (input.diffResult === null || 'empty' in input.diffResult) {
|
|
1148
|
+
// Bind the RESOLVED empty scope when the resolver supplied one; the
|
|
1149
|
+
// requested selector fills the selector slot where the resolver has none
|
|
1150
|
+
// (keeping an empty `--staged` run distinguishable from an empty
|
|
1151
|
+
// default-chain run even though both terminate at the same branch-vs-base
|
|
1152
|
+
// fallback). `source: 'none'` survives only for the scope-less legacy arm.
|
|
1153
|
+
const resolved = input.diffResult;
|
|
1154
|
+
return {
|
|
1155
|
+
status: 'not-applicable',
|
|
1156
|
+
reason: 'no-diff',
|
|
1157
|
+
scope: resolved === null
|
|
1158
|
+
? { source: 'none', base: null, head: null, selectorForm: input.requestedSelector }
|
|
1159
|
+
: {
|
|
1160
|
+
source: resolved.source,
|
|
1161
|
+
base: resolved.base ?? null,
|
|
1162
|
+
head: resolved.head ?? null,
|
|
1163
|
+
selectorForm: resolved.selectorForm ?? input.requestedSelector,
|
|
1164
|
+
},
|
|
1165
|
+
inputHash: sha256Hex(''),
|
|
1166
|
+
projectionPolicyHash,
|
|
1167
|
+
skippedFileCount: 0,
|
|
1168
|
+
skippedFiles: [],
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
const scope = {
|
|
1172
|
+
source: input.diffResult.source,
|
|
1173
|
+
base: input.diffResult.base ?? null,
|
|
1174
|
+
head: input.diffResult.head ?? null,
|
|
1175
|
+
selectorForm: input.diffResult.selectorForm ?? null,
|
|
1176
|
+
};
|
|
1177
|
+
const inputHash = sha256Hex(input.diffResult.diff);
|
|
1178
|
+
const payload = await prepareReviewPayload(input.diffResult.diff, input.diffResult.changedFiles, input.cwd, input.quiet);
|
|
1179
|
+
if (payload.empty !== undefined) {
|
|
1180
|
+
return {
|
|
1181
|
+
status: 'not-applicable',
|
|
1182
|
+
reason: payload.empty,
|
|
1183
|
+
scope,
|
|
1184
|
+
inputHash,
|
|
1185
|
+
projectionPolicyHash,
|
|
1186
|
+
skippedFileCount: payload.skippedFiles.length,
|
|
1187
|
+
skippedFiles: payload.skippedFiles,
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
return {
|
|
1191
|
+
status: 'admitted',
|
|
1192
|
+
diff: payload.diff,
|
|
1193
|
+
changedFiles: payload.changedFiles,
|
|
1194
|
+
filteredDiff: payload.filteredDiff,
|
|
1195
|
+
filteredFiles: payload.filteredFiles,
|
|
1196
|
+
generatedArtifactSummary: payload.generatedArtifactSummary,
|
|
1197
|
+
scope,
|
|
1198
|
+
inputHash,
|
|
1199
|
+
projectionPolicyHash,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Disposition→exit mapping for the admission phase. Bare review and `--gate`
|
|
1204
|
+
* both map every KNOWN not-applicable reason to exit 0 (the ruled
|
|
1205
|
+
* no-nonzero-by-default shape). The difference is the unknown arm: `--gate` is
|
|
1206
|
+
* a DECLARED mapping, so an unknown disposition fails CLOSED (nonzero via the
|
|
1207
|
+
* supplied error ctor) while the bare sensor warns and stays 0.
|
|
1208
|
+
*/
|
|
1209
|
+
export function resolveNotApplicableExit(reason, gate, errCtor) {
|
|
1210
|
+
switch (reason) {
|
|
1211
|
+
case 'no-diff':
|
|
1212
|
+
case 'all-non-code':
|
|
1213
|
+
case 'filtered-empty':
|
|
1214
|
+
case 'all-generated':
|
|
1215
|
+
return 0;
|
|
1216
|
+
default:
|
|
1217
|
+
if (gate) {
|
|
1218
|
+
throw new errCtor('SHIELD_FAILED', `--gate: unknown admission disposition "${reason}" — failing closed (the gate maps only declared dispositions to exits).`, 'Upgrade @mmnto/cli so the hook and the CLI agree on the disposition vocabulary, or drop --gate for the sensor default.');
|
|
1219
|
+
}
|
|
1220
|
+
log.warn(DISPLAY_TAG, `Unknown admission disposition "${reason}" — sensor default exit 0 (a --gate wiring would fail closed here).`);
|
|
1221
|
+
return 0;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/** Human detail for the single calm disposition line, per reason. */
|
|
1225
|
+
function describeNotApplicable(outcome) {
|
|
1226
|
+
// Counts are EXACT for what each arm's `skippedFileCount` actually holds
|
|
1227
|
+
// (falsification-leg MINOR 4): all-generated counts the changed files;
|
|
1228
|
+
// all-non-code and filtered-empty count the files remaining AFTER
|
|
1229
|
+
// generated-artifact exclusion. The rendered strings say "in scope" for the
|
|
1230
|
+
// post-strip arms — shorthand for that post-exclusion set (the record
|
|
1231
|
+
// schema's field doc carries the full reason-dependent basis; re-arm NIT 8).
|
|
1232
|
+
switch (outcome.reason) {
|
|
1233
|
+
case 'no-diff':
|
|
1234
|
+
return 'no changes detected in the resolved scope';
|
|
1235
|
+
case 'all-non-code':
|
|
1236
|
+
return `every file in scope (${outcome.skippedFileCount}) is non-code`;
|
|
1237
|
+
case 'filtered-empty':
|
|
1238
|
+
return `no code diff remains after filtering non-code files from ${outcome.skippedFileCount} file(s) in scope`;
|
|
1239
|
+
case 'all-generated':
|
|
1240
|
+
return `every changed file (${outcome.skippedFileCount}) is a generated artifact`;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* The SINGLE not-applicable emission path (one record, one calm line — ruling
|
|
1245
|
+
* item 2). Persisting the record is fail-soft: it is disclosure, not a gate
|
|
1246
|
+
* (Tenet 13) — the printed line is the loud surface and never silently drops,
|
|
1247
|
+
* which is what licenses the catch below against Tenet 4. Never stamps.
|
|
1248
|
+
*/
|
|
1249
|
+
async function emitNotApplicableDisposition(params) {
|
|
1250
|
+
const { ADMISSION_RECORD_SCHEMA_VERSION, saveAdmissionRecord } = await import('@mmnto/totem');
|
|
1251
|
+
const { outcome } = params;
|
|
1252
|
+
resolveNotApplicableExit(outcome.reason, params.gate, params.errCtor);
|
|
1253
|
+
let recordedNote = '';
|
|
1254
|
+
try {
|
|
1255
|
+
const saved = saveAdmissionRecord(params.totemDirAbs, {
|
|
1256
|
+
schemaVersion: ADMISSION_RECORD_SCHEMA_VERSION,
|
|
1257
|
+
disposition: 'not-applicable',
|
|
1258
|
+
reason: outcome.reason,
|
|
1259
|
+
createdAt: new Date().toISOString(),
|
|
1260
|
+
scope: outcome.scope,
|
|
1261
|
+
inputHash: outcome.inputHash,
|
|
1262
|
+
projectionPolicyHash: outcome.projectionPolicyHash,
|
|
1263
|
+
skippedFileCount: outcome.skippedFileCount,
|
|
1264
|
+
});
|
|
1265
|
+
recordedNote = ` (recorded ${saved.hash.slice(0, 8)})`;
|
|
1266
|
+
// totem-context: intentional cleanup — the record is disclosure, not a gate (Tenet 13): the printed disposition line below is the loud surface and never silently drops, so a failed persist degrades to the WARN here instead of blocking a docs-only push (design §Failure modes, mmnto-ai/totem#2473; strategy-claude review upheld this boundary)
|
|
1267
|
+
}
|
|
1268
|
+
catch (err) {
|
|
1269
|
+
log.warn(DISPLAY_TAG, `Admission record write failed — disposition not persisted (the line below remains the loud surface): ${err instanceof Error ? err.message : String(err)}`);
|
|
1270
|
+
}
|
|
1271
|
+
// ONE calm line, info-level — this prints on every docs-only run in every
|
|
1272
|
+
// consumer repo, so it must never read as a warning wall (#2473 consumer
|
|
1273
|
+
// datum). The no-stamp guarantee text is single-sourced via NO_STAMP_NOTICE.
|
|
1274
|
+
log.info(DISPLAY_TAG, `not-applicable (${outcome.reason}): ${describeNotApplicable(outcome)} — no lane ran. ${NO_STAMP_NOTICE}${recordedNote}`);
|
|
1275
|
+
if (outcome.skippedFiles.length > 0) {
|
|
1276
|
+
log.dim(DISPLAY_TAG, `Skipped: ${outcome.skippedFiles.join(', ')}`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
977
1279
|
// ─── Main command ───────────────────────────────────
|
|
978
1280
|
export async function shieldCommand(options) {
|
|
979
1281
|
const path = await import('node:path');
|
|
980
1282
|
const { TotemConfigError, TotemError } = await import('@mmnto/totem');
|
|
981
|
-
const {
|
|
982
|
-
const { classifyChangedFiles } = await import('./shield-classify.js');
|
|
1283
|
+
const { getDiffForReview } = await import('../git.js');
|
|
983
1284
|
const { extractShieldContextAnnotations, extractShieldHints } = await import('./shield-hints.js');
|
|
984
|
-
const { DEFAULT_GENERATED_ARTIFACT_GLOBS, buildGeneratedArtifactSection, classifyGeneratedArtifacts, formatGeneratedArtifactLine, readGitattributesGeneratedPatterns, } = await import('./shield-generated.js');
|
|
985
1285
|
// mmnto-ai/totem#1714: --estimate is the deterministic-rule pre-flight
|
|
986
1286
|
// path. Reject incompatible flag combinations BEFORE any other
|
|
987
1287
|
// validation (e.g. the --override length check below) so the user-
|
|
@@ -996,6 +1296,11 @@ export async function shieldCommand(options) {
|
|
|
996
1296
|
['fresh', '--fresh'],
|
|
997
1297
|
['mode', '--mode'],
|
|
998
1298
|
['raw', '--raw'],
|
|
1299
|
+
// --gate maps admission/round dispositions to exits; an estimate is a
|
|
1300
|
+
// zero-LLM forecast with neither, so the flag has no semantics here —
|
|
1301
|
+
// and rejecting it keeps the --gate/--fail-on conflict unreachable via
|
|
1302
|
+
// the estimate short-circuit (falsification-leg MINOR 8).
|
|
1303
|
+
['gate', '--gate'],
|
|
999
1304
|
];
|
|
1000
1305
|
for (const [key, flag] of incompatible) {
|
|
1001
1306
|
const value = options[key];
|
|
@@ -1021,6 +1326,17 @@ export async function shieldCommand(options) {
|
|
|
1021
1326
|
if (options.mode && options.mode !== 'standard' && options.mode !== 'structural') {
|
|
1022
1327
|
throw new TotemConfigError(`Invalid --mode "${options.mode}". Use "standard" or "structural".`, 'Check `totem review --help` for valid options.', 'CONFIG_INVALID');
|
|
1023
1328
|
}
|
|
1329
|
+
// Flag-contract validation runs BEFORE `upgradePrePushHookIfNeeded` or any
|
|
1330
|
+
// other side effect (codex on mmnto-ai/totem#2473): an invalid command must
|
|
1331
|
+
// not mutate the hook on its way to CONFIG_INVALID.
|
|
1332
|
+
if (options.gate === true && options.failOn !== undefined) {
|
|
1333
|
+
throw new TotemConfigError('--gate and --fail-on are contradictory (hook context is findings-report-only per mmnto-ai/totem#2551; a severity gate asserts the opposite).', 'Use --gate for hook wiring (declared disposition→exit mapping) OR --fail-on <severity> for a findings gate — not both.', 'CONFIG_INVALID');
|
|
1334
|
+
}
|
|
1335
|
+
// Gate G5: validate `--fail-on` (only the fan reads it, but a bad value is a hard
|
|
1336
|
+
// config error on any path so the user is never silently ignored).
|
|
1337
|
+
if (options.failOn !== undefined && options.failOn !== 'critical' && options.failOn !== 'warn') {
|
|
1338
|
+
throw new TotemConfigError(`Invalid --fail-on "${options.failOn}". Use "critical" or "warn".`, 'Pass --fail-on critical (exit non-zero on CRITICAL findings) or --fail-on warn (WARN or CRITICAL). Omit it for the default sensor exit 0.', 'CONFIG_INVALID');
|
|
1339
|
+
}
|
|
1024
1340
|
if (options.override !== undefined && options.override.length < 10) {
|
|
1025
1341
|
throw new TotemConfigError(`--override reason must be at least 10 characters (got ${options.override.length}).`, 'Provide a meaningful justification, e.g., --override "False positive: onWarn param visible at line 273"', 'CONFIG_INVALID');
|
|
1026
1342
|
}
|
|
@@ -1046,9 +1362,47 @@ export async function shieldCommand(options) {
|
|
|
1046
1362
|
// ordinary no-diff path performs — this verb writes nothing).
|
|
1047
1363
|
if (options.covariate) {
|
|
1048
1364
|
const diffResult = await getDiffForReview(options, config, cwd, DISPLAY_TAG);
|
|
1365
|
+
// Admission-aware resolution (mmnto-ai/totem#2473): re-derive the CURRENT
|
|
1366
|
+
// admission classification (read-only, zero-LLM) and resolve by exact
|
|
1367
|
+
// identity — deterministic, never wall-clock arbitration across record
|
|
1368
|
+
// families. A not-applicable current state resolves the admission store;
|
|
1369
|
+
// an admitted current state resolves the verdict store as before.
|
|
1370
|
+
const admission = await evaluateAdmission({
|
|
1371
|
+
diffResult,
|
|
1372
|
+
requestedSelector: requestedSelectorForm(options),
|
|
1373
|
+
cwd,
|
|
1374
|
+
config,
|
|
1375
|
+
quiet: true,
|
|
1376
|
+
});
|
|
1377
|
+
if (admission.status === 'not-applicable') {
|
|
1378
|
+
const { findAdmissionRecordByIdentity, renderAdmissionLine } = await import('@mmnto/totem');
|
|
1379
|
+
// Identity carries the OBSERVATION only — schemaVersion is writer
|
|
1380
|
+
// metadata, excluded from the address so a 1.x bump never orphans prior
|
|
1381
|
+
// records on this exact-identity path (falsification-leg MINOR 3).
|
|
1382
|
+
const found = findAdmissionRecordByIdentity(path.join(configRoot, config.totemDir), {
|
|
1383
|
+
disposition: 'not-applicable',
|
|
1384
|
+
reason: admission.reason,
|
|
1385
|
+
scope: admission.scope,
|
|
1386
|
+
inputHash: admission.inputHash,
|
|
1387
|
+
projectionPolicyHash: admission.projectionPolicyHash,
|
|
1388
|
+
skippedFileCount: admission.skippedFileCount,
|
|
1389
|
+
}, (msg) => log.warn(DISPLAY_TAG, `Sensor: ${msg}`));
|
|
1390
|
+
if (found !== undefined) {
|
|
1391
|
+
// STDOUT, not the stderr log: the line IS the transport payload.
|
|
1392
|
+
console.log(renderAdmissionLine(found));
|
|
1393
|
+
}
|
|
1394
|
+
else {
|
|
1395
|
+
// LOUD no-current-record sensor — never a silent fallback to an older
|
|
1396
|
+
// verdict on the lineage (codex on mmnto-ai/totem#2473).
|
|
1397
|
+
log.warn(DISPLAY_TAG, `Covariate: the current state is not-applicable (${admission.reason}) but no admission record exists for this exact observation — run \`totem review\` to record it (sensor; exit 0).`);
|
|
1398
|
+
}
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1049
1401
|
const { printCovariateLine } = await import('./review-fan.js');
|
|
1050
1402
|
await printCovariateLine({
|
|
1051
|
-
|
|
1403
|
+
// An empty/absent resolution never reaches here (it resolves through the
|
|
1404
|
+
// admission arm above); the null arm is type-narrowing, not a live path.
|
|
1405
|
+
diffMeta: diffResult === null || 'empty' in diffResult
|
|
1052
1406
|
? null
|
|
1053
1407
|
: {
|
|
1054
1408
|
source: diffResult.source,
|
|
@@ -1072,16 +1426,42 @@ export async function shieldCommand(options) {
|
|
|
1072
1426
|
if (!options.raw) {
|
|
1073
1427
|
log.info(DISPLAY_TAG, 'Supplementary AI review lanes (advisory — not a merge gate). Limits disclosed in output: LLM window truncation on large diffs; non-code files skipped.');
|
|
1074
1428
|
}
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1429
|
+
// ── Admission phase (mmnto-ai/totem#2473) ──
|
|
1430
|
+
// Always the FULL scope from `getDiffForReview` — the legacy incremental
|
|
1431
|
+
// fast-path narrows only the admitted execution payload below, never
|
|
1432
|
+
// admission identity. A not-applicable outcome takes the SINGLE emission
|
|
1433
|
+
// path (one record + one calm line, no stamp — the former no-diff "trivial
|
|
1434
|
+
// pass" stamp is removed: it minted authorization for a tree no reviewer
|
|
1435
|
+
// saw) and returns — the fan is unreachable past this gate by CONTROL FLOW
|
|
1436
|
+
// (boot-spy test-locked); the type barrier sits one seam later at
|
|
1437
|
+
// `selectExecutionPayload` (re-arm NIT 9 wording).
|
|
1438
|
+
const diffResult = await getDiffForReview(options, config, cwd, DISPLAY_TAG);
|
|
1439
|
+
const admission = await evaluateAdmission({
|
|
1440
|
+
diffResult,
|
|
1441
|
+
requestedSelector: requestedSelectorForm(options),
|
|
1442
|
+
cwd,
|
|
1443
|
+
config,
|
|
1444
|
+
quiet: false,
|
|
1445
|
+
});
|
|
1446
|
+
if (admission.status === 'not-applicable') {
|
|
1447
|
+
await emitNotApplicableDisposition({
|
|
1448
|
+
outcome: admission,
|
|
1449
|
+
gate: options.gate === true,
|
|
1450
|
+
totemDirAbs: path.join(configRoot, config.totemDir),
|
|
1451
|
+
errCtor: TotemError,
|
|
1452
|
+
});
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1078
1455
|
// ── Multi-lane review fan activation (Prop 304 R2, mmnto-ai/totem#2106) ──
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1456
|
+
// ADMITTED branch: fan configuration is downstream of admission (codex
|
|
1457
|
+
// conformance note 2 on #2473) — a malformed lane config must not preempt a
|
|
1458
|
+
// not-applicable disposition. Universal flag validation (--gate/--fail-on/
|
|
1459
|
+
// --mode/--override) stays at the top of the command.
|
|
1460
|
+
// Validate `review.lanes` (a hard init error on any violation) and
|
|
1461
|
+
// normalize. An explicit `--model` selects a ONE-lane invocation and never
|
|
1462
|
+
// joins the configured fan (precedence pinned); the fan also does not apply
|
|
1463
|
+
// to structural mode (context-blind single-lane stays legacy). `review.lanes`
|
|
1464
|
+
// absent ⇒ [] ⇒ the legacy single-lane path runs byte-for-byte (invariant 7).
|
|
1085
1465
|
const { validateReviewLanes, assertFanFlagsSupported } = await import('./review-fan.js');
|
|
1086
1466
|
const laneModels = validateReviewLanes(config.review.lanes, config.orchestrator?.provider, TotemConfigError);
|
|
1087
1467
|
// Finding 1: a fan-configured `--raw` stays the legacy ZERO-LLM context dump (no
|
|
@@ -1095,110 +1475,56 @@ export async function shieldCommand(options) {
|
|
|
1095
1475
|
// LOUDLY (naming the unsupported combination) rather than silently ignoring them.
|
|
1096
1476
|
if (fanActive)
|
|
1097
1477
|
assertFanFlagsSupported(options, TotemConfigError);
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1478
|
+
// Engine boot (mmnto-ai/totem#1794) — admitted runs only; a deterministic
|
|
1479
|
+
// skip never boots the engine. See lint.ts wiring for context.
|
|
1480
|
+
const { bootstrapEngine } = await import('../utils/bootstrap-engine.js');
|
|
1481
|
+
await bootstrapEngine(config, configRoot);
|
|
1482
|
+
// Resolved diff-scope metadata (Prop 304 R2) — the fan's verdict `diffScope` +
|
|
1483
|
+
// lineage, read from the admission's bound scope (one identity, never a
|
|
1484
|
+
// re-derive). Finding 10: the raw CLI selector form so `--diff main` and
|
|
1485
|
+
// `--diff main..HEAD` (same resolved refs) do NOT share a lineage — the
|
|
1486
|
+
// admitted arm carries the resolver's selectorForm only (never the
|
|
1487
|
+
// requested-selector fallback the empty arms use).
|
|
1488
|
+
const admittedScope = admission.scope;
|
|
1489
|
+
if (admittedScope.source === 'none') {
|
|
1490
|
+
// Unreachable: an admitted outcome requires a resolved diff. Fail loud,
|
|
1491
|
+
// never fabricate a lineage.
|
|
1492
|
+
throw new TotemError('SHIELD_FAILED', 'Admitted review carries no resolved diff scope — this is a bug in the admission evaluator.', 'Re-run `totem review`; if this persists, file it with the command line used.');
|
|
1493
|
+
}
|
|
1494
|
+
const diffScopeMeta = {
|
|
1495
|
+
source: admittedScope.source,
|
|
1496
|
+
base: admittedScope.base ?? undefined,
|
|
1497
|
+
head: admittedScope.head ?? undefined,
|
|
1498
|
+
selectorForm: admittedScope.selectorForm ?? undefined,
|
|
1499
|
+
};
|
|
1500
|
+
// --- Incremental fast-path (#1010) — a payload SELECTION step (codex note 3) ---
|
|
1501
|
+
// The fan needs full diff-scope metadata for lineage, so it bypasses the
|
|
1502
|
+
// incremental path. Selection can narrow the execution payload to the delta;
|
|
1503
|
+
// it can never change admission (a non-reviewable delta falls back to the
|
|
1504
|
+
// admitted full-scope payload).
|
|
1114
1505
|
const incremental = fanActive
|
|
1115
1506
|
? { eligible: false, reason: 'multi-lane fan requires full diff scope' }
|
|
1116
1507
|
: await evaluateIncrementalEligibility(cwd, config.totemDir, configRoot);
|
|
1117
|
-
|
|
1508
|
+
// quiet=true for the delta re-projection: the full-scope pass already printed
|
|
1509
|
+
// the generated/filtered disclosures, so the delta pass logs nothing (the
|
|
1510
|
+
// .gitattributes read itself still happens — quiet gates logging only).
|
|
1511
|
+
// Named trade-off: on a narrowed run the printed disclosures describe the
|
|
1512
|
+
// FULL scope, a superset of the payload actually reviewed.
|
|
1513
|
+
const selection = await selectExecutionPayload(admission, incremental, cwd, true);
|
|
1514
|
+
if (selection.narrowed) {
|
|
1118
1515
|
log.info(DISPLAY_TAG, `Incremental review: ${incremental.linesChanged} line(s) since last pass`);
|
|
1119
|
-
diff = incremental.deltaDiff;
|
|
1120
|
-
changedFiles = incremental.changedFiles;
|
|
1121
1516
|
}
|
|
1122
|
-
else {
|
|
1123
|
-
|
|
1124
|
-
log.dim(DISPLAY_TAG, `Full review: ${incremental.reason}`);
|
|
1125
|
-
}
|
|
1126
|
-
// Get git diff — shared helper merges ignore patterns, tries staged/all
|
|
1127
|
-
// then falls back to branch diff, and extracts changed file paths.
|
|
1128
|
-
const diffResult = await getDiffForReview(options, config, cwd, DISPLAY_TAG);
|
|
1129
|
-
if (!diffResult) {
|
|
1130
|
-
// No changes = trivial pass — stamp content hash
|
|
1131
|
-
await writeReviewedContentHash(cwd, config.totemDir, configRoot, config.review.sourceExtensions);
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
diff = diffResult.diff;
|
|
1135
|
-
changedFiles = diffResult.changedFiles;
|
|
1136
|
-
diffScopeMeta = {
|
|
1137
|
-
source: diffResult.source,
|
|
1138
|
-
base: diffResult.base,
|
|
1139
|
-
head: diffResult.head,
|
|
1140
|
-
// Finding 10: the raw CLI selector form so `--diff main` and `--diff main..HEAD`
|
|
1141
|
-
// (same resolved refs) do NOT share a lineage.
|
|
1142
|
-
selectorForm: diffResult.selectorForm,
|
|
1143
|
-
};
|
|
1517
|
+
else if (selection.deltaFallbackReason !== undefined) {
|
|
1518
|
+
log.dim(DISPLAY_TAG, `Incremental delta is non-reviewable after filtering (${selection.deltaFallbackReason}) — using the full-scope payload.`);
|
|
1144
1519
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
// dist/**, *.wasm, regenerated dashboards) burn review-context tokens on bytes
|
|
1148
|
-
// no reviewer should read. Classify them by default (seeded globs + honor
|
|
1149
|
-
// `.gitattributes` `linguist-generated`), strip their diff sections, and inject
|
|
1150
|
-
// a per-file SUMMARY instead of a silent drop — path, change shape, size delta,
|
|
1151
|
-
// semantic hash — so the "this regenerated" signal survives without the bytes.
|
|
1152
|
-
let generatedArtifactSummary;
|
|
1153
|
-
{
|
|
1154
|
-
const gitattr = readGitattributesGeneratedPatterns(cwd);
|
|
1155
|
-
const generated = classifyGeneratedArtifacts({
|
|
1156
|
-
diff,
|
|
1157
|
-
changedFiles,
|
|
1158
|
-
generatedGlobs: [...DEFAULT_GENERATED_ARTIFACT_GLOBS, ...gitattr.generated],
|
|
1159
|
-
excludeGlobs: gitattr.notGenerated,
|
|
1160
|
-
});
|
|
1161
|
-
if (generated.summaries.length > 0) {
|
|
1162
|
-
log.info(DISPLAY_TAG, `Excluded ${generated.summaries.length} generated-artifact file(s) from the review payload (summarized, not dropped):`);
|
|
1163
|
-
for (const summary of generated.summaries) {
|
|
1164
|
-
log.dim(DISPLAY_TAG, formatGeneratedArtifactLine(summary));
|
|
1165
|
-
}
|
|
1166
|
-
diff = generated.keptDiff;
|
|
1167
|
-
changedFiles = generated.keptFiles;
|
|
1168
|
-
generatedArtifactSummary = buildGeneratedArtifactSection(generated.summaries);
|
|
1169
|
-
// All changed files were generated artifacts — nothing left to review.
|
|
1170
|
-
// Not sending them to the LLM stays correct (their correctness is a gate
|
|
1171
|
-
// concern, not the LLM's) but that does NOT extend to stamping the push
|
|
1172
|
-
// gate on their behalf: this is the one skip path that can drop a TRACKED,
|
|
1173
|
-
// HASHED source file, because `.gitattributes linguist-generated` can mark
|
|
1174
|
-
// a `.ts` as generated. Stamping here would authorize code no reviewer saw
|
|
1175
|
-
// (mmnto-ai/totem#2466).
|
|
1176
|
-
if (!diff.trim()) {
|
|
1177
|
-
log.warn(DISPLAY_TAG, `NON-REVIEW: every changed file is a generated artifact, so no lane ran. ${NO_STAMP_NOTICE}`);
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
// Stage 1: Classify files — fast-path for non-code-only diffs
|
|
1183
|
-
const classification = classifyChangedFiles(changedFiles);
|
|
1184
|
-
if (classification.allNonCode) {
|
|
1185
|
-
log.warn(DISPLAY_TAG, `NON-REVIEW: every changed file is non-code, so no lane ran. ${NO_STAMP_NOTICE}`);
|
|
1186
|
-
log.dim(DISPLAY_TAG, `Skipped: ${changedFiles.join(', ')}`);
|
|
1187
|
-
return;
|
|
1188
|
-
}
|
|
1189
|
-
// Stage 2: Filter diff to code-only files for mixed diffs
|
|
1190
|
-
let filteredDiff = diff;
|
|
1191
|
-
let filteredFiles = changedFiles;
|
|
1192
|
-
if (!classification.allCode && classification.nonCodeFiles.length > 0) {
|
|
1193
|
-
filteredDiff = await filterDiffByPatterns(diff, classification.nonCodeFiles);
|
|
1194
|
-
filteredFiles = classification.codeFiles;
|
|
1195
|
-
if (!filteredDiff.trim()) {
|
|
1196
|
-
// After filtering non-code files, no code diff remains — nothing was examined.
|
|
1197
|
-
log.warn(DISPLAY_TAG, `NON-REVIEW: no code changes remain after filtering non-code files, so no lane ran. ${NO_STAMP_NOTICE}`);
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
log.dim(DISPLAY_TAG, `Filtered ${classification.nonCodeFiles.length} non-code file(s) from diff`);
|
|
1520
|
+
else if (incremental.reason && incremental.reason !== 'No previous shield state') {
|
|
1521
|
+
log.dim(DISPLAY_TAG, `Full review: ${incremental.reason}`);
|
|
1201
1522
|
}
|
|
1523
|
+
const { diff, changedFiles, filteredDiff, filteredFiles, generatedArtifactSummary } = selection.payload;
|
|
1524
|
+
// Generated-artifact exclusion (#2398) and non-code classification/filtering
|
|
1525
|
+
// now run inside the admission evaluator's `prepareReviewPayload` (one
|
|
1526
|
+
// emission path, mmnto-ai/totem#2473) — the admitted payload above already
|
|
1527
|
+
// carries `filteredDiff` / `filteredFiles` / `generatedArtifactSummary`.
|
|
1202
1528
|
// Extract annotations once (shared between hints and ledger)
|
|
1203
1529
|
const annotations = extractShieldContextAnnotations(filteredFiles, cwd);
|
|
1204
1530
|
// Auto-detect smart review hints from the filtered diff
|
|
@@ -1297,12 +1623,9 @@ export async function shieldCommand(options) {
|
|
|
1297
1623
|
// a verdict artifact, and enforce the cache-eligibility exit contract. The
|
|
1298
1624
|
// legacy single-lane path below is left byte-for-byte unchanged (invariant 7).
|
|
1299
1625
|
if (fanActive) {
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
// silent scope guess (Tenet 4).
|
|
1304
|
-
throw new TotemError('SHIELD_FAILED', 'Internal: diff-scope metadata was not resolved for the review fan.', 'Re-run `totem review`; report this if it recurs.');
|
|
1305
|
-
}
|
|
1626
|
+
// `diffScopeMeta` is a const built from the admission's bound scope (the
|
|
1627
|
+
// `source: 'none'` fail-loud guard above is the live unreachability check)
|
|
1628
|
+
// — the old `undefined` guard died with the incremental-path split.
|
|
1306
1629
|
// Exemptions are read once here and passed in side-effect-free (the fan is
|
|
1307
1630
|
// pure over them). --suppress mutation is not wired into the fan this slice;
|
|
1308
1631
|
// committed shared exemptions still filter each lane.
|