@mmnto/cli 1.14.12 → 1.14.13

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.
@@ -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, setCoreLogger, TotemError, } = await import('@mmnto/totem');
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
- // Wire core logger to CLI UI (ADR-071: core must not use console.warn directly)
15
- setCoreLogger({ warn: (msg) => log.warn(tag, msg) });
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
- const resolvedTotemDir = path.join(options.configRoot ?? cwd, totemDir);
18
- // Load compiled rules
19
- const rulesPath = path.join(resolvedTotemDir, COMPILED_RULES_FILE);
20
- const rules = loadCompiledRules(rulesPath);
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
- log.info(tag, `Running ${rules.length} rules (zero LLM)...`);
25
- // Extract additions, exclude compiled rules file, export targets, and binary files
26
- const BINARY_EXTENSIONS = new Set([
27
- '.png',
28
- '.jpg',
29
- '.jpeg',
30
- '.gif',
31
- '.mp4',
32
- '.pdf',
33
- '.zip',
34
- '.tar',
35
- '.gz',
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
- const additions = extractAddedLines(diff)
53
- .filter((a) => !excluded.has(a.file))
54
- .filter((a) => !BINARY_EXTENSIONS.has(path.extname(a.file).toLowerCase()))
55
- .filter((a) => !ignorePatterns || !ignorePatterns.some((pattern) => matchesGlob(a.file, pattern)));
56
- // Resolve repo root once git diff paths are always repo-root-relative,
57
- // so both staged and non-staged paths need the repo root for file resolution.
58
- const repoRoot = resolveGitRoot(cwd);
59
- // Enrich with AST context
60
- try {
61
- await enrichWithAstContext(additions, { cwd: repoRoot ?? cwd });
62
- const classified = additions.filter((a) => a.astContext !== undefined).length;
63
- if (classified > 0) {
64
- log.dim(tag, `AST classified ${classified}/${additions.length} additions`);
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
- catch {
68
- log.dim(tag, 'AST classification unavailable, falling back to raw matching');
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
- // Record metrics + Trap Ledger
71
- const { appendLedgerEvent } = await import('@mmnto/totem');
72
- const metrics = loadRuleMetrics(resolvedTotemDir, (msg) => log.dim(tag, msg));
73
- const ruleEventCallback = (event, hash, context) => {
74
- if (event === 'trigger') {
75
- recordTrigger(metrics, hash);
76
- recordContextHit(metrics, hash, context?.astContext);
77
- }
78
- else if (event === 'suppress') {
79
- recordSuppression(metrics, hash);
80
- // Append to Trap Ledger (fire-and-forget). When the suppressed rule
81
- // was shipped by a pack with immutable: true (ADR-089,
82
- // mmnto-ai/totem#1485), the event carries the flag so auditors can
83
- // surface every attempt to silence an enforced security rule via
84
- // `jq 'select(.immutable == true)'` over events.ndjson.
85
- if (context) {
86
- appendLedgerEvent(resolvedTotemDir, {
87
- timestamp: new Date().toISOString(),
88
- type: context.justification ? 'override' : 'suppress',
89
- ruleId: hash,
90
- file: context.file,
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
- astViolations = await applyAstRulesToAdditions(rules, additions, workingDirectory, ruleEventCallback, (msg) => log.warn(tag, msg), readStrategy);
145
- }
146
- catch (err) {
147
- // STAGED_READ_FAILED must propagate the pre-commit guarantee depends
148
- // on surfacing staged-read failures rather than silently falling back.
149
- if (err instanceof TotemError && err.code === 'STAGED_READ_FAILED') {
150
- throw err;
151
- }
152
- const msg = err instanceof Error ? err.message : String(err);
153
- const isWasmFailure = /not initialized|wasm|web-tree-sitter/i.test(msg);
154
- if (process.env['TOTEM_LITE'] === '1' && isWasmFailure) {
155
- // In the lite binary, WASM init may fail under Node.js (works in Bun).
156
- // Degrade gracefully: skip AST rules, warn, continue with regex results.
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
- const violations = [...regexViolations, ...astViolations];
165
- // ── Zero-match rule detection (#1061) ────────────
166
- // Count rules whose fileGlobs matched none of the files in this diff.
167
- const diffFiles = [...new Set(additions.map((a) => a.file))];
168
- const zeroMatchRules = [];
169
- for (const rule of rules) {
170
- if (rule.fileGlobs && rule.fileGlobs.length > 0) {
171
- const positive = rule.fileGlobs.filter((g) => typeof g === 'string' && !g.startsWith('!'));
172
- const negative = rule.fileGlobs
173
- .filter((g) => typeof g === 'string' && g.startsWith('!'))
174
- .map((g) => g.slice(1));
175
- const hasMatch = diffFiles.some((file) => {
176
- const positiveMatch = positive.length === 0 || positive.some((g) => matchesGlob(file, g));
177
- const negativeMatch = negative.some((g) => matchesGlob(file, g));
178
- return positiveMatch && !negativeMatch;
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
- if (zeroMatchRules.length > 0) {
185
- log.dim(tag, `${zeroMatchRules.length} rule(s) matched no files in this diff`);
186
- }
187
- // mmnto-ai/totem#1483: tick evaluationCount once per rule per lint run.
188
- // Invariant: one run loads the rule set, evaluates each rule against the
189
- // diff additions, and increments the counter here exactly once per
190
- // lessonHash. Multiple matches on a rule within one run still produce a
191
- // single increment. This counter is the "was this rule exercised" signal
192
- // the doctor stale-rule check reads to distinguish a dormant rule from a
193
- // rule that has genuinely sat through N lint cycles without firing.
194
- for (const rule of rules) {
195
- recordEvaluation(metrics, rule.lessonHash);
196
- }
197
- try {
198
- saveRuleMetrics(resolvedTotemDir, metrics);
199
- }
200
- catch (err) {
201
- log.warn(tag, `Could not save rule metrics: ${err instanceof Error ? err.message : String(err)}`);
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
- // Classify violations by severity (computed once, reused across all output formats)
204
- const errors = violations.filter((v) => (v.rule.severity ?? 'error') === 'error');
205
- const warnings = violations.filter((v) => (v.rule.severity ?? 'error') === 'warning');
206
- // Convert to unified findings model once (ADR-071)
207
- const { violationToFinding } = await import('@mmnto/totem');
208
- const findings = violations.map(violationToFinding);
209
- // Build output
210
- let output;
211
- if (format === 'sarif') {
212
- const { buildSarifLog, getHeadSha } = await import('@mmnto/totem');
213
- const { createRequire } = await import('node:module');
214
- const req = createRequire(import.meta.url);
215
- const version = req('../../package.json').version;
216
- const commitHash = getHeadSha(cwd) ?? undefined;
217
- // SARIF is a strict channel for error-severity findings only.
218
- // Warnings are probationary (Rule Nursery) and stay as local telemetry
219
- // to prevent alert fatigue in the PR UI (Proposal 190).
220
- const sarif = buildSarifLog(errors, rules, { version, commitHash });
221
- // Surface warning count as a single note so users know they exist
222
- if (warnings.length > 0) {
223
- const summaryRuleIdx = sarif.runs[0].tool.driver.rules.length;
224
- sarif.runs[0].tool.driver.rules.push({
225
- id: 'totem/warning-summary',
226
- shortDescription: {
227
- text: 'Probationary warnings detected run `totem lint` locally to review',
228
- },
229
- });
230
- sarif.runs[0].results.push({
231
- ruleId: 'totem/warning-summary',
232
- ruleIndex: summaryRuleIdx,
233
- level: 'note',
234
- message: {
235
- text: `${warnings.length} warning-severity finding(s) detected. Warnings are probationary and not shown in PR reviews. Run \`totem lint\` locally to review.`,
236
- },
237
- locations: [
238
- {
239
- physicalLocation: {
240
- artifactLocation: { uri: '.totem/compiled-rules.json' },
241
- region: { startLine: 1 },
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
- else if (format === 'json') {
250
- output = JSON.stringify({
251
- pass: errors.length === 0,
252
- rules: rules.length,
253
- errors: errors.length,
254
- warnings: warnings.length,
255
- findings,
256
- violations,
257
- }, null, 2);
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
- const lines = [];
261
- if (errors.length === 0 && warnings.length === 0) {
262
- // Clean pass only emit verbose markdown when writing to file
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('### Verdict');
273
- if (errors.length > 0) {
274
- lines.push(`**FAIL** - ${errors.length} error(s)${warnings.length > 0 ? `, ${warnings.length} warning(s)` : ''} across ${rules.length} rules.`);
275
- }
276
- else {
277
- lines.push(`**PASS** - ${warnings.length} warning(s), 0 errors across ${rules.length} rules.`);
278
- }
279
- if (errors.length > 0) {
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
- if (warnings.length > 0) {
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
- else if (warnings.length > 0) {
314
- const verdictLabel = successColor(bold('PASS'));
315
- log.info(tag, `Verdict: ${verdictLabel} - ${warnings.length} warning(s), 0 errors`);
316
- }
317
- else {
318
- const verdictLabel = successColor(bold('PASS'));
319
- log.info(tag, `Verdict: ${verdictLabel} - ${rules.length} rules, 0 violations`);
320
- }
321
- return { violations, findings, rules, output };
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
- finally {
324
- setCoreLogger({ warn: () => { } });
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