@mmnto/cli 1.14.12 → 1.14.14
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/assets/universal-lessons.d.ts +1 -1
- package/dist/assets/universal-lessons.d.ts.map +1 -1
- package/dist/assets/universal-lessons.js +1 -1
- package/dist/commands/init-templates.d.ts +2 -2
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +6 -8
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/run-compiled-rules.d.ts.map +1 -1
- package/dist/commands/run-compiled-rules.js +290 -286
- package/dist/commands/run-compiled-rules.js.map +1 -1
- package/dist/commands/run-compiled-rules.test.js +11 -9
- package/dist/commands/run-compiled-rules.test.js.map +1 -1
- package/dist/commands/shield-eval.integration.test.js +9 -5
- package/dist/commands/shield-eval.integration.test.js.map +1 -1
- package/dist/commands/shield.js +1 -1
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/shield.test.js +8 -4
- package/dist/commands/shield.test.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/test-utils.d.ts +8 -0
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +12 -0
- package/dist/test-utils.js.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -3
- package/dist/utils.js.map +1 -1
- package/package.json +2 -2
- package/dist/commands/audit-templates.d.ts +0 -16
- package/dist/commands/audit-templates.d.ts.map +0 -1
- package/dist/commands/audit-templates.js +0 -57
- package/dist/commands/audit-templates.js.map +0 -1
- package/dist/commands/audit.d.ts +0 -41
- package/dist/commands/audit.d.ts.map +0 -1
- package/dist/commands/audit.js +0 -359
- package/dist/commands/audit.js.map +0 -1
- package/dist/commands/audit.test.d.ts +0 -2
- package/dist/commands/audit.test.d.ts.map +0 -1
- package/dist/commands/audit.test.js +0 -268
- package/dist/commands/audit.test.js.map +0 -1
- package/dist/commands/bridge.d.ts +0 -7
- package/dist/commands/bridge.d.ts.map +0 -1
- package/dist/commands/bridge.js +0 -50
- package/dist/commands/bridge.js.map +0 -1
- package/dist/commands/bridge.test.d.ts +0 -2
- package/dist/commands/bridge.test.d.ts.map +0 -1
- package/dist/commands/bridge.test.js +0 -50
- package/dist/commands/bridge.test.js.map +0 -1
- package/dist/commands/briefing.d.ts +0 -18
- package/dist/commands/briefing.d.ts.map +0 -1
- package/dist/commands/briefing.js +0 -146
- package/dist/commands/briefing.js.map +0 -1
- package/dist/commands/briefing.test.d.ts +0 -2
- package/dist/commands/briefing.test.d.ts.map +0 -1
- package/dist/commands/briefing.test.js +0 -59
- package/dist/commands/briefing.test.js.map +0 -1
|
@@ -9,319 +9,323 @@ export async function runCompiledRules(options) {
|
|
|
9
9
|
const path = await import('node:path');
|
|
10
10
|
const { bold, errorColor, log, success: successColor } = await import('../ui.js');
|
|
11
11
|
const { writeOutput } = await import('../utils.js');
|
|
12
|
-
const { applyAstRulesToAdditions, applyRulesToAdditions, enrichWithAstContext, extractAddedLines, loadCompiledRules, loadRuleMetrics, matchesGlob, recordContextHit, recordEvaluation, recordSuppression, recordTrigger, resolveGitRoot, safeExec, saveRuleMetrics,
|
|
12
|
+
const { applyAstRulesToAdditions, applyRulesToAdditions, enrichWithAstContext, extractAddedLines, loadCompiledRules, loadRuleMetrics, matchesGlob, recordContextHit, recordEvaluation, recordSuppression, recordTrigger, resolveGitRoot, safeExec, saveRuleMetrics, TotemError, } = await import('@mmnto/totem');
|
|
13
13
|
const { diff, cwd, totemDir, format, outPath, exportPaths, ignorePatterns, tag, isStaged } = options;
|
|
14
|
-
//
|
|
15
|
-
|
|
14
|
+
// Per-invocation rule-engine context (ADR-071 + mmnto/totem#1441): logger
|
|
15
|
+
// threads into the engine as a parameter rather than a module-level setter,
|
|
16
|
+
// so concurrent / federated runs cannot clobber each other's wiring.
|
|
17
|
+
const ruleCtx = {
|
|
18
|
+
logger: { warn: (msg) => log.warn(tag, msg) },
|
|
19
|
+
state: { hasWarnedShieldContext: false },
|
|
20
|
+
};
|
|
21
|
+
const resolvedTotemDir = path.join(options.configRoot ?? cwd, totemDir);
|
|
22
|
+
// Load compiled rules
|
|
23
|
+
const rulesPath = path.join(resolvedTotemDir, COMPILED_RULES_FILE);
|
|
24
|
+
const rules = loadCompiledRules(rulesPath);
|
|
25
|
+
if (rules.length === 0) {
|
|
26
|
+
throw new TotemError('NO_RULES', `No compiled rules found at ${totemDir}/${COMPILED_RULES_FILE}.`, "Run 'totem compile' to generate rules.");
|
|
27
|
+
}
|
|
28
|
+
log.info(tag, `Running ${rules.length} rules (zero LLM)...`);
|
|
29
|
+
// Extract additions, exclude compiled rules file, export targets, and binary files
|
|
30
|
+
const BINARY_EXTENSIONS = new Set([
|
|
31
|
+
'.png',
|
|
32
|
+
'.jpg',
|
|
33
|
+
'.jpeg',
|
|
34
|
+
'.gif',
|
|
35
|
+
'.mp4',
|
|
36
|
+
'.pdf',
|
|
37
|
+
'.zip',
|
|
38
|
+
'.tar',
|
|
39
|
+
'.gz',
|
|
40
|
+
'.woff',
|
|
41
|
+
'.woff2',
|
|
42
|
+
'.eot',
|
|
43
|
+
'.ttf',
|
|
44
|
+
'.mp3',
|
|
45
|
+
'.wav',
|
|
46
|
+
'.ico',
|
|
47
|
+
'.bin',
|
|
48
|
+
]);
|
|
49
|
+
const rulesRelPath = path.join(totemDir, COMPILED_RULES_FILE).replace(/\\/g, '/');
|
|
50
|
+
const excluded = new Set([rulesRelPath]);
|
|
51
|
+
if (exportPaths) {
|
|
52
|
+
for (const ep of exportPaths) {
|
|
53
|
+
excluded.add(ep.replace(/\\/g, '/'));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const additions = extractAddedLines(diff)
|
|
57
|
+
.filter((a) => !excluded.has(a.file))
|
|
58
|
+
.filter((a) => !BINARY_EXTENSIONS.has(path.extname(a.file).toLowerCase()))
|
|
59
|
+
.filter((a) => !ignorePatterns || !ignorePatterns.some((pattern) => matchesGlob(a.file, pattern)));
|
|
60
|
+
// Resolve repo root once — git diff paths are always repo-root-relative,
|
|
61
|
+
// so both staged and non-staged paths need the repo root for file resolution.
|
|
62
|
+
const repoRoot = resolveGitRoot(cwd);
|
|
63
|
+
// Enrich with AST context
|
|
16
64
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (rules.length === 0) {
|
|
22
|
-
throw new TotemError('NO_RULES', `No compiled rules found at ${totemDir}/${COMPILED_RULES_FILE}.`, "Run 'totem compile' to generate rules.");
|
|
65
|
+
await enrichWithAstContext(additions, { cwd: repoRoot ?? cwd });
|
|
66
|
+
const classified = additions.filter((a) => a.astContext !== undefined).length;
|
|
67
|
+
if (classified > 0) {
|
|
68
|
+
log.dim(tag, `AST classified ${classified}/${additions.length} additions`);
|
|
23
69
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'.woff',
|
|
37
|
-
'.woff2',
|
|
38
|
-
'.eot',
|
|
39
|
-
'.ttf',
|
|
40
|
-
'.mp3',
|
|
41
|
-
'.wav',
|
|
42
|
-
'.ico',
|
|
43
|
-
'.bin',
|
|
44
|
-
]);
|
|
45
|
-
const rulesRelPath = path.join(totemDir, COMPILED_RULES_FILE).replace(/\\/g, '/');
|
|
46
|
-
const excluded = new Set([rulesRelPath]);
|
|
47
|
-
if (exportPaths) {
|
|
48
|
-
for (const ep of exportPaths) {
|
|
49
|
-
excluded.add(ep.replace(/\\/g, '/'));
|
|
50
|
-
}
|
|
70
|
+
// totem-context: intentional graceful degradation — AST enrichment is best-effort
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
log.dim(tag, 'AST classification unavailable, falling back to raw matching');
|
|
74
|
+
}
|
|
75
|
+
// Record metrics + Trap Ledger
|
|
76
|
+
const { appendLedgerEvent } = await import('@mmnto/totem');
|
|
77
|
+
const metrics = loadRuleMetrics(resolvedTotemDir, (msg) => log.dim(tag, msg));
|
|
78
|
+
const ruleEventCallback = (event, hash, context) => {
|
|
79
|
+
if (event === 'trigger') {
|
|
80
|
+
recordTrigger(metrics, hash);
|
|
81
|
+
recordContextHit(metrics, hash, context?.astContext);
|
|
51
82
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
else if (event === 'suppress') {
|
|
84
|
+
recordSuppression(metrics, hash);
|
|
85
|
+
// Append to Trap Ledger (fire-and-forget). When the suppressed rule
|
|
86
|
+
// was shipped by a pack with immutable: true (ADR-089,
|
|
87
|
+
// mmnto-ai/totem#1485), the event carries the flag so auditors can
|
|
88
|
+
// surface every attempt to silence an enforced security rule via
|
|
89
|
+
// `jq 'select(.immutable == true)'` over events.ndjson.
|
|
90
|
+
if (context) {
|
|
91
|
+
appendLedgerEvent(resolvedTotemDir, {
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
type: context.justification ? 'override' : 'suppress',
|
|
94
|
+
ruleId: hash,
|
|
95
|
+
file: context.file,
|
|
96
|
+
line: context.line,
|
|
97
|
+
justification: context.justification ?? '',
|
|
98
|
+
source: 'lint',
|
|
99
|
+
...(context.immutable === true ? { immutable: true } : {}),
|
|
100
|
+
}, (msg) => log.dim(tag, msg));
|
|
65
101
|
}
|
|
66
102
|
}
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
else {
|
|
104
|
+
// mmnto/totem#1408: 'failure' event fires when a compiled rule's
|
|
105
|
+
// runtime findAll throws (per-rule try/catch in executeQuery). Log
|
|
106
|
+
// the hash and reason so the operator can see WHICH rule failed
|
|
107
|
+
// without crashing the batch. Metric recording (recordFailure) is
|
|
108
|
+
// a follow-up once `rule-metrics` gains a failure counter.
|
|
109
|
+
log.warn(tag, `rule ${hash} failed at runtime${context?.failureReason ? `: ${context.failureReason}` : ''}`);
|
|
69
110
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
line: context.line,
|
|
92
|
-
justification: context.justification ?? '',
|
|
93
|
-
source: 'lint',
|
|
94
|
-
...(context.immutable === true ? { immutable: true } : {}),
|
|
95
|
-
}, (msg) => log.dim(tag, msg));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
// mmnto/totem#1408: 'failure' event fires when a compiled rule's
|
|
100
|
-
// runtime findAll throws (per-rule try/catch in executeQuery). Log
|
|
101
|
-
// the hash and reason so the operator can see WHICH rule failed
|
|
102
|
-
// without crashing the batch. Metric recording (recordFailure) is
|
|
103
|
-
// a follow-up once `rule-metrics` gains a failure counter.
|
|
104
|
-
log.warn(tag, `rule ${hash} failed at runtime${context?.failureReason ? `: ${context.failureReason}` : ''}`);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
const regexViolations = applyRulesToAdditions(rules, additions, ruleEventCallback);
|
|
108
|
-
// Run AST rules (async — reads files and runs Tree-sitter/ast-grep queries)
|
|
109
|
-
const astRules = rules.filter((r) => r.engine === 'ast' || r.engine === 'ast-grep');
|
|
110
|
-
let astViolations = [];
|
|
111
|
-
if (astRules.length > 0) {
|
|
112
|
-
log.dim(tag, `Running ${astRules.length} AST rule(s)...`);
|
|
113
|
-
try {
|
|
114
|
-
const workingDirectory = repoRoot ?? cwd;
|
|
115
|
-
let readStrategy = undefined;
|
|
116
|
-
if (isStaged) {
|
|
117
|
-
if (repoRoot) {
|
|
118
|
-
readStrategy = async (filePath) => {
|
|
119
|
-
try {
|
|
120
|
-
// 1. Detect symlinks explicitly (git ls-files -s returns mode 120000).
|
|
121
|
-
// The `--` separator prevents filePath values starting with `-` from
|
|
122
|
-
// being interpreted as git options.
|
|
123
|
-
const lsOutput = safeExec('git', ['ls-files', '--recurse-submodules', '-s', '--', filePath], { cwd: repoRoot, env: { ...process.env, LC_ALL: 'C' } });
|
|
124
|
-
if (lsOutput.startsWith('120000 ')) {
|
|
125
|
-
return null; // Explicitly exclude symlinks from AST checks
|
|
126
|
-
}
|
|
127
|
-
// 2. Read staged content
|
|
128
|
-
const content = safeExec('git', ['show', `:${filePath}`], {
|
|
129
|
-
cwd: repoRoot,
|
|
130
|
-
trim: false,
|
|
131
|
-
env: { ...process.env, LC_ALL: 'C' },
|
|
132
|
-
});
|
|
133
|
-
// 3. Normalize CRLF to LF specifically for the staged callback
|
|
134
|
-
// Disk-read callback preserves existing behavior per Invariant #4.
|
|
135
|
-
return content.replace(/\r\n/g, '\n');
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
// Explicit throw per Failure Mode 1 decision
|
|
139
|
-
throw new TotemError('STAGED_READ_FAILED', `Failed to read staged content for ${filePath}`, `git show :${filePath} failed. The file may not exist in the index or may be staged for deletion. Ensure --staged is used correctly.`, { cause: err });
|
|
111
|
+
};
|
|
112
|
+
const regexViolations = applyRulesToAdditions(ruleCtx, rules, additions, ruleEventCallback);
|
|
113
|
+
// Run AST rules (async — reads files and runs Tree-sitter/ast-grep queries)
|
|
114
|
+
const astRules = rules.filter((r) => r.engine === 'ast' || r.engine === 'ast-grep');
|
|
115
|
+
let astViolations = [];
|
|
116
|
+
if (astRules.length > 0) {
|
|
117
|
+
log.dim(tag, `Running ${astRules.length} AST rule(s)...`);
|
|
118
|
+
try {
|
|
119
|
+
const workingDirectory = repoRoot ?? cwd;
|
|
120
|
+
let readStrategy = undefined;
|
|
121
|
+
if (isStaged) {
|
|
122
|
+
if (repoRoot) {
|
|
123
|
+
readStrategy = async (filePath) => {
|
|
124
|
+
try {
|
|
125
|
+
// totem-context: false positive — comment mentions `git ls-files`; the actual call below already uses --recurse-submodules
|
|
126
|
+
// 1. Detect symlinks explicitly (git ls-files -s returns mode 120000).
|
|
127
|
+
// The `--` separator prevents filePath values starting with `-` from
|
|
128
|
+
// being interpreted as git options.
|
|
129
|
+
const lsOutput = safeExec('git', ['ls-files', '--recurse-submodules', '-s', '--', filePath], { cwd: repoRoot, env: { ...process.env, LC_ALL: 'C' } });
|
|
130
|
+
if (lsOutput.startsWith('120000 ')) {
|
|
131
|
+
return null; // Explicitly exclude symlinks from AST checks
|
|
140
132
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
log.warn(tag, `AST rules skipped (WASM engine unavailable): ${msg}`);
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
throw err;
|
|
133
|
+
// 2. Read staged content
|
|
134
|
+
const content = safeExec('git', ['show', `:${filePath}`], {
|
|
135
|
+
cwd: repoRoot,
|
|
136
|
+
trim: false,
|
|
137
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
138
|
+
});
|
|
139
|
+
// 3. Normalize CRLF to LF specifically for the staged callback
|
|
140
|
+
// totem-context: Invariant #4 refers to an internal invariant number, not an issue ref
|
|
141
|
+
// Disk-read callback preserves existing behavior per Invariant #4.
|
|
142
|
+
return content.replace(/\r\n/g, '\n');
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
// Explicit throw per Failure Mode 1 decision
|
|
146
|
+
throw new TotemError('STAGED_READ_FAILED', `Failed to read staged content for ${filePath}`, `git show :${filePath} failed. The file may not exist in the index or may be staged for deletion. Ensure --staged is used correctly.`, { cause: err });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
161
149
|
}
|
|
162
150
|
}
|
|
151
|
+
astViolations = await applyAstRulesToAdditions(ruleCtx, rules, additions, workingDirectory, ruleEventCallback, (msg) => log.warn(tag, msg), readStrategy);
|
|
163
152
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
});
|
|
180
|
-
if (!hasMatch)
|
|
181
|
-
zeroMatchRules.push(rule);
|
|
153
|
+
catch (err) {
|
|
154
|
+
// STAGED_READ_FAILED must propagate — the pre-commit guarantee depends
|
|
155
|
+
// on surfacing staged-read failures rather than silently falling back.
|
|
156
|
+
if (err instanceof TotemError && err.code === 'STAGED_READ_FAILED') {
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
160
|
+
const isWasmFailure = /not initialized|wasm|web-tree-sitter/i.test(msg);
|
|
161
|
+
if (process.env['TOTEM_LITE'] === '1' && isWasmFailure) {
|
|
162
|
+
// In the lite binary, WASM init may fail under Node.js (works in Bun).
|
|
163
|
+
// Degrade gracefully: skip AST rules, warn, continue with regex results.
|
|
164
|
+
log.warn(tag, `AST rules skipped (WASM engine unavailable): ${msg}`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
throw err;
|
|
182
168
|
}
|
|
183
169
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
170
|
+
}
|
|
171
|
+
const violations = [...regexViolations, ...astViolations];
|
|
172
|
+
// ── Zero-match rule detection (mmnto-ai/totem#1061) ────────────
|
|
173
|
+
// Count rules whose fileGlobs matched none of the files in this diff.
|
|
174
|
+
const diffFiles = [...new Set(additions.map((a) => a.file))];
|
|
175
|
+
const zeroMatchRules = [];
|
|
176
|
+
for (const rule of rules) {
|
|
177
|
+
if (rule.fileGlobs && rule.fileGlobs.length > 0) {
|
|
178
|
+
const positive = rule.fileGlobs.filter((g) => typeof g === 'string' && !g.startsWith('!'));
|
|
179
|
+
const negative = rule.fileGlobs
|
|
180
|
+
.filter((g) => typeof g === 'string' && g.startsWith('!'))
|
|
181
|
+
.map((g) => g.slice(1));
|
|
182
|
+
const hasMatch = diffFiles.some((file) => {
|
|
183
|
+
const positiveMatch = positive.length === 0 || positive.some((g) => matchesGlob(file, g));
|
|
184
|
+
const negativeMatch = negative.some((g) => matchesGlob(file, g));
|
|
185
|
+
return positiveMatch && !negativeMatch;
|
|
186
|
+
});
|
|
187
|
+
if (!hasMatch)
|
|
188
|
+
zeroMatchRules.push(rule);
|
|
202
189
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
190
|
+
}
|
|
191
|
+
if (zeroMatchRules.length > 0) {
|
|
192
|
+
log.dim(tag, `${zeroMatchRules.length} rule(s) matched no files in this diff`);
|
|
193
|
+
}
|
|
194
|
+
// mmnto-ai/totem#1483: tick evaluationCount once per rule per lint run.
|
|
195
|
+
// Invariant: one run loads the rule set, evaluates each rule against the
|
|
196
|
+
// diff additions, and increments the counter here exactly once per
|
|
197
|
+
// lessonHash. Multiple matches on a rule within one run still produce a
|
|
198
|
+
// single increment. This counter is the "was this rule exercised" signal
|
|
199
|
+
// the doctor stale-rule check reads to distinguish a dormant rule from a
|
|
200
|
+
// rule that has genuinely sat through N lint cycles without firing.
|
|
201
|
+
for (const rule of rules) {
|
|
202
|
+
recordEvaluation(metrics, rule.lessonHash);
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
saveRuleMetrics(resolvedTotemDir, metrics);
|
|
206
|
+
// totem-context: intentional graceful degradation — metric save is best-effort
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
log.warn(tag, `Could not save rule metrics: ${err instanceof Error ? err.message : String(err)}`);
|
|
210
|
+
}
|
|
211
|
+
// Classify violations by severity (computed once, reused across all output formats)
|
|
212
|
+
const errors = violations.filter((v) => (v.rule.severity ?? 'error') === 'error');
|
|
213
|
+
const warnings = violations.filter((v) => (v.rule.severity ?? 'error') === 'warning');
|
|
214
|
+
// Convert to unified findings model once (ADR-071)
|
|
215
|
+
const { violationToFinding } = await import('@mmnto/totem');
|
|
216
|
+
const findings = violations.map(violationToFinding);
|
|
217
|
+
// Build output
|
|
218
|
+
let output;
|
|
219
|
+
if (format === 'sarif') {
|
|
220
|
+
const { buildSarifLog, getHeadSha } = await import('@mmnto/totem');
|
|
221
|
+
const { createRequire } = await import('node:module');
|
|
222
|
+
const req = createRequire(import.meta.url);
|
|
223
|
+
const version = req('../../package.json').version;
|
|
224
|
+
const commitHash = getHeadSha(cwd) ?? undefined;
|
|
225
|
+
// SARIF is a strict channel for error-severity findings only.
|
|
226
|
+
// Warnings are probationary (Rule Nursery) and stay as local telemetry
|
|
227
|
+
// to prevent alert fatigue in the PR UI (Proposal 190).
|
|
228
|
+
const sarif = buildSarifLog(errors, rules, { version, commitHash });
|
|
229
|
+
// Surface warning count as a single note so users know they exist
|
|
230
|
+
if (warnings.length > 0) {
|
|
231
|
+
const summaryRuleIdx = sarif.runs[0].tool.driver.rules.length;
|
|
232
|
+
sarif.runs[0].tool.driver.rules.push({
|
|
233
|
+
id: 'totem/warning-summary',
|
|
234
|
+
shortDescription: {
|
|
235
|
+
text: 'Probationary warnings detected — run `totem lint` locally to review',
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
sarif.runs[0].results.push({
|
|
239
|
+
ruleId: 'totem/warning-summary',
|
|
240
|
+
ruleIndex: summaryRuleIdx,
|
|
241
|
+
level: 'note',
|
|
242
|
+
message: {
|
|
243
|
+
text: `${warnings.length} warning-severity finding(s) detected. Warnings are probationary and not shown in PR reviews. Run \`totem lint\` locally to review.`,
|
|
244
|
+
},
|
|
245
|
+
locations: [
|
|
246
|
+
{
|
|
247
|
+
physicalLocation: {
|
|
248
|
+
artifactLocation: { uri: '.totem/compiled-rules.json' },
|
|
249
|
+
region: { startLine: 1 },
|
|
243
250
|
},
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
output = JSON.stringify(sarif, null, 2);
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
});
|
|
248
254
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
output = JSON.stringify(sarif, null, 2);
|
|
256
|
+
}
|
|
257
|
+
else if (format === 'json') {
|
|
258
|
+
output = JSON.stringify({
|
|
259
|
+
pass: errors.length === 0,
|
|
260
|
+
rules: rules.length,
|
|
261
|
+
errors: errors.length,
|
|
262
|
+
warnings: warnings.length,
|
|
263
|
+
findings,
|
|
264
|
+
violations,
|
|
265
|
+
}, null, 2);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const lines = [];
|
|
269
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
270
|
+
// Clean pass — only emit verbose markdown when writing to file
|
|
271
|
+
if (outPath) {
|
|
272
|
+
lines.push('### Verdict');
|
|
273
|
+
lines.push(`**PASS** - All ${rules.length} rules passed.`);
|
|
274
|
+
lines.push('');
|
|
275
|
+
lines.push('### Details');
|
|
276
|
+
lines.push('No violations detected against compiled rules.');
|
|
277
|
+
}
|
|
258
278
|
}
|
|
259
279
|
else {
|
|
260
|
-
|
|
261
|
-
if (errors.length
|
|
262
|
-
|
|
263
|
-
if (outPath) {
|
|
264
|
-
lines.push('### Verdict');
|
|
265
|
-
lines.push(`**PASS** - All ${rules.length} rules passed.`);
|
|
266
|
-
lines.push('');
|
|
267
|
-
lines.push('### Details');
|
|
268
|
-
lines.push('No violations detected against compiled rules.');
|
|
269
|
-
}
|
|
280
|
+
lines.push('### Verdict');
|
|
281
|
+
if (errors.length > 0) {
|
|
282
|
+
lines.push(`**FAIL** - ${errors.length} error(s)${warnings.length > 0 ? `, ${warnings.length} warning(s)` : ''} across ${rules.length} rules.`);
|
|
270
283
|
}
|
|
271
284
|
else {
|
|
272
|
-
lines.push(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
285
|
+
lines.push(`**PASS** - ${warnings.length} warning(s), 0 errors across ${rules.length} rules.`);
|
|
286
|
+
}
|
|
287
|
+
if (errors.length > 0) {
|
|
288
|
+
lines.push('');
|
|
289
|
+
lines.push('### Errors');
|
|
290
|
+
for (const v of errors) {
|
|
291
|
+
lines.push(`- **${v.file}:${v.lineNumber}** - ${v.rule.message}`);
|
|
292
|
+
lines.push(` Pattern: \`/${v.rule.pattern}/\``);
|
|
293
|
+
lines.push(` Lesson: "${v.rule.lessonHeading}"`);
|
|
294
|
+
lines.push(` Line: \`${v.line.trim()}\``);
|
|
280
295
|
lines.push('');
|
|
281
|
-
lines.push('### Errors');
|
|
282
|
-
for (const v of errors) {
|
|
283
|
-
lines.push(`- **${v.file}:${v.lineNumber}** - ${v.rule.message}`);
|
|
284
|
-
lines.push(` Pattern: \`/${v.rule.pattern}/\``);
|
|
285
|
-
lines.push(` Lesson: "${v.rule.lessonHeading}"`);
|
|
286
|
-
lines.push(` Line: \`${v.line.trim()}\``);
|
|
287
|
-
lines.push('');
|
|
288
|
-
}
|
|
289
296
|
}
|
|
290
|
-
|
|
297
|
+
}
|
|
298
|
+
if (warnings.length > 0) {
|
|
299
|
+
lines.push('');
|
|
300
|
+
lines.push('### Warnings');
|
|
301
|
+
for (const v of warnings) {
|
|
302
|
+
lines.push(`- **${v.file}:${v.lineNumber}** - ${v.rule.message}`);
|
|
303
|
+
lines.push(` Pattern: \`/${v.rule.pattern}/\``);
|
|
304
|
+
lines.push(` Lesson: "${v.rule.lessonHeading}"`);
|
|
305
|
+
lines.push(` Line: \`${v.line.trim()}\``);
|
|
291
306
|
lines.push('');
|
|
292
|
-
lines.push('### Warnings');
|
|
293
|
-
for (const v of warnings) {
|
|
294
|
-
lines.push(`- **${v.file}:${v.lineNumber}** - ${v.rule.message}`);
|
|
295
|
-
lines.push(` Pattern: \`/${v.rule.pattern}/\``);
|
|
296
|
-
lines.push(` Lesson: "${v.rule.lessonHeading}"`);
|
|
297
|
-
lines.push(` Line: \`${v.line.trim()}\``);
|
|
298
|
-
lines.push('');
|
|
299
|
-
}
|
|
300
307
|
}
|
|
301
308
|
}
|
|
302
|
-
output = lines.join('\n');
|
|
303
|
-
}
|
|
304
|
-
writeOutput(output, outPath);
|
|
305
|
-
if (outPath)
|
|
306
|
-
log.success(tag, `Written to ${outPath}`);
|
|
307
|
-
if (errors.length > 0) {
|
|
308
|
-
const verdictLabel = errorColor(bold('FAIL'));
|
|
309
|
-
const warnSuffix = warnings.length > 0 ? `, ${warnings.length} warning(s)` : '';
|
|
310
|
-
log.info(tag, `Verdict: ${verdictLabel} - ${errors.length} error(s)${warnSuffix}`);
|
|
311
|
-
throw new TotemError('SHIELD_FAILED', 'Violations detected', 'Fix the violations above or use totem explain <hash> for details.');
|
|
312
309
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
310
|
+
output = lines.join('\n');
|
|
311
|
+
}
|
|
312
|
+
writeOutput(output, outPath);
|
|
313
|
+
if (outPath)
|
|
314
|
+
log.success(tag, `Written to ${outPath}`);
|
|
315
|
+
if (errors.length > 0) {
|
|
316
|
+
const verdictLabel = errorColor(bold('FAIL'));
|
|
317
|
+
const warnSuffix = warnings.length > 0 ? `, ${warnings.length} warning(s)` : '';
|
|
318
|
+
log.info(tag, `Verdict: ${verdictLabel} - ${errors.length} error(s)${warnSuffix}`);
|
|
319
|
+
throw new TotemError('SHIELD_FAILED', 'Violations detected', 'Fix the violations above or use totem explain <hash> for details.');
|
|
320
|
+
}
|
|
321
|
+
else if (warnings.length > 0) {
|
|
322
|
+
const verdictLabel = successColor(bold('PASS'));
|
|
323
|
+
log.info(tag, `Verdict: ${verdictLabel} - ${warnings.length} warning(s), 0 errors`);
|
|
322
324
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
+
else {
|
|
326
|
+
const verdictLabel = successColor(bold('PASS'));
|
|
327
|
+
log.info(tag, `Verdict: ${verdictLabel} - ${rules.length} rules, 0 violations`);
|
|
325
328
|
}
|
|
329
|
+
return { violations, findings, rules, output };
|
|
326
330
|
}
|
|
327
331
|
//# sourceMappingURL=run-compiled-rules.js.map
|