@nerviq/cli 1.26.0 → 1.27.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.
@@ -1,103 +1,123 @@
1
- /**
2
- * JUnit XML Formatter
3
- *
4
- * Converts a nerviq audit result into Jenkins-compatible JUnit XML.
5
- * Schema: <testsuites><testsuite><testcase><failure/></testcase></testsuite></testsuites>
6
- *
7
- * - One <testsuite> per check category.
8
- * - Each check becomes a <testcase> (classname = category, name = key).
9
- * - Failed checks emit a <failure message="..." type="..."/> where:
10
- * - message = check.fix || check.name
11
- * - type = severity (check.severity || check.impact)
12
- * - Skipped checks emit <skipped/>.
13
- *
14
- * Parses with any standard JUnit XML consumer (GitHub Actions test
15
- * reporter, Jenkins, GitLab CI, CircleCI).
16
- */
17
-
18
- 'use strict';
19
-
20
- const { version: nerviqVersion } = require('../../package.json');
21
-
22
- function escapeXml(value) {
23
- if (value === null || value === undefined) return '';
24
- return String(value)
25
- .replace(/&/g, '&amp;')
26
- .replace(/</g, '&lt;')
27
- .replace(/>/g, '&gt;')
28
- .replace(/"/g, '&quot;')
29
- .replace(/'/g, '&apos;');
30
- }
31
-
32
- function severityFor(r) {
33
- return r.severity || r.impact || 'medium';
34
- }
35
-
36
- function groupByCategory(results) {
37
- const map = new Map();
38
- for (const r of results) {
39
- const cat = r.category || 'uncategorized';
40
- if (!map.has(cat)) map.set(cat, []);
41
- map.get(cat).push(r);
42
- }
43
- return map;
44
- }
45
-
46
- function formatJUnit(auditResult) {
47
- const allResults = Array.isArray(auditResult.results) ? auditResult.results : [];
48
- const timestamp = auditResult.timestamp || new Date().toISOString();
49
- const platform = auditResult.platform || 'claude';
50
-
51
- const totalTests = allResults.length;
52
- const totalFailures = allResults.filter((r) => r.passed === false).length;
53
- const totalSkipped = allResults.filter((r) => r.passed === null || r.skipped === true).length;
54
-
55
- const byCategory = groupByCategory(allResults);
56
-
57
- const lines = [];
58
- lines.push('<?xml version="1.0" encoding="UTF-8"?>');
59
- lines.push(
60
- `<testsuites name="nerviq" tests="${totalTests}" failures="${totalFailures}" skipped="${totalSkipped}" time="0">`,
61
- );
62
-
63
- for (const [category, checks] of byCategory) {
64
- const suiteFailures = checks.filter((r) => r.passed === false).length;
65
- const suiteSkipped = checks.filter((r) => r.passed === null || r.skipped === true).length;
66
- lines.push(
67
- ` <testsuite name="${escapeXml(category)}" tests="${checks.length}" failures="${suiteFailures}" skipped="${suiteSkipped}" time="0" timestamp="${escapeXml(timestamp)}" package="nerviq.${escapeXml(platform)}">`,
68
- );
69
-
70
- for (const r of checks) {
71
- const classname = escapeXml(r.category || 'uncategorized');
72
- const name = escapeXml(r.key || r.id || r.name || 'unknown');
73
- // CTO-08: surface scope layer as a testcase attribute so JUnit
74
- // consumers (GitHub Actions, Jenkins, GitLab) can filter/group.
75
- const layerAttr = r.layer ? ` layer="${escapeXml(r.layer)}"` : '';
76
- if (r.passed === false) {
77
- const msg = escapeXml(r.fix || r.name || r.key || 'check failed');
78
- const type = escapeXml(severityFor(r));
79
- let body = `${r.name || ''}`;
80
- if (r.file) body += ` at ${r.file}${r.line ? ':' + r.line : ''}`;
81
- if (r.sourceUrl) body += ` (${r.sourceUrl})`;
82
- if (r.snippet) body += `\n---\n${r.snippet}`;
83
- lines.push(` <testcase classname="${classname}" name="${name}"${layerAttr} time="0">`);
84
- lines.push(` <failure message="${msg}" type="${type}">${escapeXml(body)}</failure>`);
85
- lines.push(` </testcase>`);
86
- } else if (r.passed === null || r.skipped === true) {
87
- lines.push(` <testcase classname="${classname}" name="${name}"${layerAttr} time="0">`);
88
- lines.push(` <skipped/>`);
89
- lines.push(` </testcase>`);
90
- } else {
91
- lines.push(` <testcase classname="${classname}" name="${name}"${layerAttr} time="0"/>`);
92
- }
93
- }
94
-
95
- lines.push(' </testsuite>');
96
- }
97
-
98
- lines.push('</testsuites>');
99
- lines.push(`<!-- nerviq v${escapeXml(auditResult.version || nerviqVersion)} -->`);
100
- return lines.join('\n');
101
- }
102
-
103
- module.exports = { formatJUnit };
1
+ /**
2
+ * JUnit XML Formatter
3
+ *
4
+ * Converts a nerviq audit result into Jenkins-compatible JUnit XML.
5
+ * Schema: <testsuites><testsuite><testcase><failure/></testcase></testsuite></testsuites>
6
+ *
7
+ * - One <testsuite> per check category.
8
+ * - Each check becomes a <testcase> (classname = category, name = key).
9
+ * - Failed checks emit a <failure message="..." type="..."/> where:
10
+ * - message = check.fix || check.name
11
+ * - type = severity (check.severity || check.impact)
12
+ * - Skipped checks emit <skipped/>.
13
+ *
14
+ * Parses with any standard JUnit XML consumer (GitHub Actions test
15
+ * reporter, Jenkins, GitLab CI, CircleCI).
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { version: nerviqVersion } = require('../../package.json');
21
+
22
+ function escapeXml(value) {
23
+ if (value === null || value === undefined) return '';
24
+ return String(value)
25
+ .replace(/&/g, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&apos;');
30
+ }
31
+
32
+ function severityFor(r) {
33
+ return r.severity || r.impact || 'medium';
34
+ }
35
+
36
+ function groupByCategory(results) {
37
+ const map = new Map();
38
+ for (const r of results) {
39
+ const cat = r.category || 'uncategorized';
40
+ if (!map.has(cat)) map.set(cat, []);
41
+ map.get(cat).push(r);
42
+ }
43
+ return map;
44
+ }
45
+
46
+ function formatJUnit(auditResult) {
47
+ const allResults = Array.isArray(auditResult.results) ? auditResult.results : [];
48
+ const shallowRiskHints = Array.isArray(auditResult.shallowRiskHints) ? auditResult.shallowRiskHints : [];
49
+ const timestamp = auditResult.timestamp || new Date().toISOString();
50
+ const platform = auditResult.platform || 'claude';
51
+
52
+ const totalTests = allResults.length + shallowRiskHints.length;
53
+ const totalFailures = allResults.filter((r) => r.passed === false).length + shallowRiskHints.length;
54
+ const totalSkipped = allResults.filter((r) => r.passed === null || r.skipped === true).length;
55
+
56
+ const byCategory = groupByCategory(allResults);
57
+
58
+ const lines = [];
59
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
60
+ lines.push(
61
+ `<testsuites name="nerviq" tests="${totalTests}" failures="${totalFailures}" skipped="${totalSkipped}" time="0">`,
62
+ );
63
+
64
+ for (const [category, checks] of byCategory) {
65
+ const suiteFailures = checks.filter((r) => r.passed === false).length;
66
+ const suiteSkipped = checks.filter((r) => r.passed === null || r.skipped === true).length;
67
+ lines.push(
68
+ ` <testsuite name="${escapeXml(category)}" tests="${checks.length}" failures="${suiteFailures}" skipped="${suiteSkipped}" time="0" timestamp="${escapeXml(timestamp)}" package="nerviq.${escapeXml(platform)}">`,
69
+ );
70
+
71
+ for (const r of checks) {
72
+ const classname = escapeXml(r.category || 'uncategorized');
73
+ const name = escapeXml(r.key || r.id || r.name || 'unknown');
74
+ // CTO-08: surface scope layer as a testcase attribute so JUnit
75
+ // consumers (GitHub Actions, Jenkins, GitLab) can filter/group.
76
+ const layerAttr = r.layer ? ` layer="${escapeXml(r.layer)}"` : '';
77
+ if (r.passed === false) {
78
+ const msg = escapeXml(r.fix || r.name || r.key || 'check failed');
79
+ const type = escapeXml(severityFor(r));
80
+ let body = `${r.name || ''}`;
81
+ if (r.file) body += ` at ${r.file}${r.line ? ':' + r.line : ''}`;
82
+ if (r.sourceUrl) body += ` (${r.sourceUrl})`;
83
+ if (r.snippet) body += `\n---\n${r.snippet}`;
84
+ lines.push(` <testcase classname="${classname}" name="${name}"${layerAttr} time="0">`);
85
+ lines.push(` <failure message="${msg}" type="${type}">${escapeXml(body)}</failure>`);
86
+ lines.push(` </testcase>`);
87
+ } else if (r.passed === null || r.skipped === true) {
88
+ lines.push(` <testcase classname="${classname}" name="${name}"${layerAttr} time="0">`);
89
+ lines.push(` <skipped/>`);
90
+ lines.push(` </testcase>`);
91
+ } else {
92
+ lines.push(` <testcase classname="${classname}" name="${name}"${layerAttr} time="0"/>`);
93
+ }
94
+ }
95
+
96
+ lines.push(' </testsuite>');
97
+ }
98
+
99
+ if (Array.isArray(auditResult.shallowRiskHints)) {
100
+ lines.push(
101
+ ` <testsuite name="shallow-risk" tests="${shallowRiskHints.length}" failures="${shallowRiskHints.length}" skipped="0" time="0" timestamp="${escapeXml(timestamp)}" package="nerviq.${escapeXml(platform)}.shallow-risk">`,
102
+ );
103
+ for (const hint of shallowRiskHints) {
104
+ const name = escapeXml(hint.key || hint.name || 'shallow-risk');
105
+ const msg = escapeXml(hint.fix || hint.name || hint.key || 'shallow risk hint');
106
+ const type = escapeXml(severityFor(hint));
107
+ let body = `${hint.name || hint.key || ''}`;
108
+ if (hint.file) body += ` at ${hint.file}${hint.line ? ':' + hint.line : ''}`;
109
+ if (hint.sourceUrl) body += ` (${hint.sourceUrl})`;
110
+ if (hint.snippet) body += `\n---\n${hint.snippet}`;
111
+ lines.push(` <testcase classname="shallow-risk" name="${name}" layer="shallow-risk" time="0">`);
112
+ lines.push(` <failure message="${msg}" type="${type}">${escapeXml(body)}</failure>`);
113
+ lines.push(' </testcase>');
114
+ }
115
+ lines.push(' </testsuite>');
116
+ }
117
+
118
+ lines.push('</testsuites>');
119
+ lines.push(`<!-- nerviq v${escapeXml(auditResult.version || nerviqVersion)} -->`);
120
+ return lines.join('\n');
121
+ }
122
+
123
+ module.exports = { formatJUnit };
@@ -1,135 +1,164 @@
1
- /**
2
- * Markdown Formatter
3
- *
4
- * Converts a nerviq audit result into GitHub-flavoured markdown suitable
5
- * for posting as a PR comment. Structure:
6
- *
7
- * - Header with score badge and pass/fail/skip counts
8
- * - Top 5 topNextActions as a GitHub task-list checklist
9
- * - Collapsible <details> block with the full failed-checks table
10
- * - Footer with Nerviq link, version, timestamp
11
- *
12
- * Output is plain GitHub-flavoured markdown. The only HTML used is
13
- * <details>/<summary>, which GitHub renders natively.
14
- */
15
-
16
- 'use strict';
17
-
18
- const { version: nerviqVersion } = require('../../package.json');
19
-
20
- function escapeCell(value) {
21
- if (value === null || value === undefined) return '';
22
- return String(value)
23
- .replace(/\r?\n/g, ' ')
24
- .replace(/\|/g, '\\|');
25
- }
26
-
27
- function escapeInline(value) {
28
- if (value === null || value === undefined) return '';
29
- return String(value).replace(/\r?\n/g, ' ');
30
- }
31
-
32
- function scoreBadge(score) {
33
- const s = Number.isFinite(score) ? Math.round(score) : 0;
34
- let color;
35
- if (s >= 80) color = 'brightgreen';
36
- else if (s >= 60) color = 'yellow';
37
- else color = 'red';
38
- return `![score](https://img.shields.io/badge/nerviq_score-${s}%2F100-${color})`;
39
- }
40
-
41
- function severityFor(item) {
42
- return item.severity || item.impact || 'medium';
43
- }
44
-
45
- function formatMarkdown(auditResult, options = {}) {
46
- const platformLabel = auditResult.platformLabel || auditResult.platform || 'claude';
47
- const score = auditResult.score ?? 0;
48
- const passed = auditResult.passed ?? 0;
49
- const failed = auditResult.failed ?? 0;
50
- const skipped = auditResult.skipped ?? 0;
51
- const version = auditResult.version || nerviqVersion;
52
- const timestamp = auditResult.timestamp || new Date().toISOString();
53
-
54
- const lines = [];
55
-
56
- lines.push(`## Score: ${score}/100 ${scoreBadge(score)}`);
57
- lines.push('');
58
- lines.push(`**Platform:** ${platformLabel} `);
59
- lines.push(`**Checks:** ${passed} passed, ${failed} failed, ${skipped} skipped`);
60
- lines.push('');
61
-
62
- const top = Array.isArray(auditResult.topNextActions)
63
- ? auditResult.topNextActions.slice(0, 5)
64
- : [];
65
-
66
- if (top.length > 0) {
67
- lines.push('### Top next actions');
68
- lines.push('');
69
- for (const item of top) {
70
- const sev = severityFor(item).toString().toUpperCase();
71
- const title = escapeInline(item.name || item.title || item.key);
72
- const key = escapeInline(item.key || item.id || '');
73
- let loc = '';
74
- if (item.file) {
75
- loc = ` — \`${escapeInline(item.file)}${item.line ? ':' + item.line : ''}\``;
76
- }
77
- let delta = '';
78
- if (Number.isFinite(item.projectedScoreDelta) && item.projectedScoreDelta > 0) {
79
- delta = ` (+${item.projectedScoreDelta} pts ${item.projectedScoreAfter}/100)`;
80
- }
81
- // CTO-08: include scope layer as a small suffix after the key so
82
- // evaluators see which layer each next-action belongs to.
83
- const layerSuffix = item.layer ? ` · _layer: ${escapeInline(item.layer)}_` : '';
84
- lines.push(`- [ ] **[${sev}] ${title}** (\`${key}\`)${loc}${delta}${layerSuffix}`);
85
- const hint = item.fix || item.hint || '';
86
- if (hint) {
87
- lines.push(` - ${escapeInline(hint)}`);
88
- }
89
- if (item.snippet) {
90
- lines.push('');
91
- lines.push(' ```');
92
- for (const snipLine of String(item.snippet).split(/\r?\n/)) {
93
- lines.push(` ${snipLine}`);
94
- }
95
- lines.push(' ```');
96
- }
97
- }
98
- lines.push('');
99
- }
100
-
101
- const failedResults = Array.isArray(auditResult.results)
102
- ? auditResult.results.filter((r) => r.passed === false)
103
- : [];
104
-
105
- if (failedResults.length > 0) {
106
- lines.push('<details>');
107
- lines.push(`<summary>All failed checks (${failedResults.length})</summary>`);
108
- lines.push('');
109
- // CTO-08: add layer column between category and rating.
110
- lines.push('| key | name | category | layer | rating | file | line |');
111
- lines.push('| --- | --- | --- | --- | --- | --- | --- |');
112
- for (const r of failedResults) {
113
- const row = [
114
- escapeCell(r.key),
115
- escapeCell(r.name),
116
- escapeCell(r.category),
117
- escapeCell(r.layer || ''),
118
- escapeCell(r.rating ?? ''),
119
- escapeCell(r.file || ''),
120
- escapeCell(r.line || ''),
121
- ];
122
- lines.push(`| ${row.join(' | ')} |`);
123
- }
124
- lines.push('');
125
- lines.push('</details>');
126
- lines.push('');
127
- }
128
-
129
- lines.push(`---`);
130
- lines.push(`Generated by [Nerviq](https://nerviq.net) v${version} · ${timestamp}`);
131
-
132
- return lines.join('\n');
133
- }
134
-
135
- module.exports = { formatMarkdown };
1
+ /**
2
+ * Markdown Formatter
3
+ *
4
+ * Converts a nerviq audit result into GitHub-flavoured markdown suitable
5
+ * for posting as a PR comment. Structure:
6
+ *
7
+ * - Header with score badge and pass/fail/skip counts
8
+ * - Top 5 topNextActions as a GitHub task-list checklist
9
+ * - Collapsible <details> block with the full failed-checks table
10
+ * - Footer with Nerviq link, version, timestamp
11
+ *
12
+ * Output is plain GitHub-flavoured markdown. The only HTML used is
13
+ * <details>/<summary>, which GitHub renders natively.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const { version: nerviqVersion } = require('../../package.json');
19
+ const { SHALLOW_RISK_BANNER_LINES } = require('../shallow-risk');
20
+
21
+ function escapeCell(value) {
22
+ if (value === null || value === undefined) return '';
23
+ return String(value)
24
+ .replace(/\r?\n/g, ' ')
25
+ .replace(/\|/g, '\\|');
26
+ }
27
+
28
+ function escapeInline(value) {
29
+ if (value === null || value === undefined) return '';
30
+ return String(value).replace(/\r?\n/g, ' ');
31
+ }
32
+
33
+ function scoreBadge(score) {
34
+ const s = Number.isFinite(score) ? Math.round(score) : 0;
35
+ let color;
36
+ if (s >= 80) color = 'brightgreen';
37
+ else if (s >= 60) color = 'yellow';
38
+ else color = 'red';
39
+ return `![score](https://img.shields.io/badge/nerviq_score-${s}%2F100-${color})`;
40
+ }
41
+
42
+ function severityFor(item) {
43
+ return item.severity || item.impact || 'medium';
44
+ }
45
+
46
+ function formatMarkdown(auditResult, options = {}) {
47
+ const platformLabel = auditResult.platformLabel || auditResult.platform || 'claude';
48
+ const score = auditResult.score ?? 0;
49
+ const passed = auditResult.passed ?? 0;
50
+ const failed = auditResult.failed ?? 0;
51
+ const skipped = auditResult.skipped ?? 0;
52
+ const version = auditResult.version || nerviqVersion;
53
+ const timestamp = auditResult.timestamp || new Date().toISOString();
54
+
55
+ const lines = [];
56
+
57
+ lines.push(`## Score: ${score}/100 ${scoreBadge(score)}`);
58
+ lines.push('');
59
+ lines.push(`**Platform:** ${platformLabel} `);
60
+ lines.push(`**Checks:** ${passed} passed, ${failed} failed, ${skipped} skipped`);
61
+ lines.push('');
62
+
63
+ const top = Array.isArray(auditResult.topNextActions)
64
+ ? auditResult.topNextActions.slice(0, 5)
65
+ : [];
66
+
67
+ if (top.length > 0) {
68
+ lines.push('### Top next actions');
69
+ lines.push('');
70
+ for (const item of top) {
71
+ const sev = severityFor(item).toString().toUpperCase();
72
+ const title = escapeInline(item.name || item.title || item.key);
73
+ const key = escapeInline(item.key || item.id || '');
74
+ let loc = '';
75
+ if (item.file) {
76
+ loc = ` — \`${escapeInline(item.file)}${item.line ? ':' + item.line : ''}\``;
77
+ }
78
+ let delta = '';
79
+ if (Number.isFinite(item.projectedScoreDelta) && item.projectedScoreDelta > 0) {
80
+ delta = ` (+${item.projectedScoreDelta} pts → ${item.projectedScoreAfter}/100)`;
81
+ }
82
+ // CTO-08: include scope layer as a small suffix after the key so
83
+ // evaluators see which layer each next-action belongs to.
84
+ const layerSuffix = item.layer ? ` · _layer: ${escapeInline(item.layer)}_` : '';
85
+ lines.push(`- [ ] **[${sev}] ${title}** (\`${key}\`)${loc}${delta}${layerSuffix}`);
86
+ const hint = item.fix || item.hint || '';
87
+ if (hint) {
88
+ lines.push(` - ${escapeInline(hint)}`);
89
+ }
90
+ if (item.snippet) {
91
+ lines.push('');
92
+ lines.push(' ```');
93
+ for (const snipLine of String(item.snippet).split(/\r?\n/)) {
94
+ lines.push(` ${snipLine}`);
95
+ }
96
+ lines.push(' ```');
97
+ }
98
+ }
99
+ lines.push('');
100
+ }
101
+
102
+ if (Array.isArray(auditResult.shallowRiskHints)) {
103
+ lines.push('### Shallow Risk (experimental, opt-in)');
104
+ lines.push('');
105
+ for (const line of SHALLOW_RISK_BANNER_LINES) {
106
+ lines.push(`> ${line}`);
107
+ }
108
+ lines.push('');
109
+
110
+ if (auditResult.shallowRiskHints.length === 0) {
111
+ lines.push('_No shallow-risk hints found._');
112
+ lines.push('');
113
+ } else {
114
+ for (const item of auditResult.shallowRiskHints) {
115
+ const sev = severityFor(item).toString().toUpperCase();
116
+ const title = escapeInline(item.name || item.title || item.key);
117
+ let loc = '';
118
+ if (item.file) {
119
+ loc = ` — \`${escapeInline(item.file)}${item.line ? ':' + item.line : ''}\``;
120
+ }
121
+ lines.push(`- **[${sev}] ${title}** (\`${escapeInline(item.key || '')}\`)${loc}`);
122
+ if (item.fix) {
123
+ lines.push(` - ${escapeInline(item.fix)}`);
124
+ }
125
+ }
126
+ lines.push('');
127
+ }
128
+ }
129
+
130
+ const failedResults = Array.isArray(auditResult.results)
131
+ ? auditResult.results.filter((r) => r.passed === false)
132
+ : [];
133
+
134
+ if (failedResults.length > 0) {
135
+ lines.push('<details>');
136
+ lines.push(`<summary>All failed checks (${failedResults.length})</summary>`);
137
+ lines.push('');
138
+ // CTO-08: add layer column between category and rating.
139
+ lines.push('| key | name | category | layer | rating | file | line |');
140
+ lines.push('| --- | --- | --- | --- | --- | --- | --- |');
141
+ for (const r of failedResults) {
142
+ const row = [
143
+ escapeCell(r.key),
144
+ escapeCell(r.name),
145
+ escapeCell(r.category),
146
+ escapeCell(r.layer || ''),
147
+ escapeCell(r.rating ?? ''),
148
+ escapeCell(r.file || ''),
149
+ escapeCell(r.line || ''),
150
+ ];
151
+ lines.push(`| ${row.join(' | ')} |`);
152
+ }
153
+ lines.push('');
154
+ lines.push('</details>');
155
+ lines.push('');
156
+ }
157
+
158
+ lines.push(`---`);
159
+ lines.push(`Generated by [Nerviq](https://nerviq.net) v${version} · ${timestamp}`);
160
+
161
+ return lines.join('\n');
162
+ }
163
+
164
+ module.exports = { formatMarkdown };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { buildFinding, SHALLOW_RISK_BANNER, SHALLOW_RISK_BANNER_LINES } = require('./shared');
4
+
5
+ const patterns = [
6
+ require('./patterns/agent-config-missing-file'),
7
+ require('./patterns/agent-config-stack-contradiction'),
8
+ require('./patterns/agent-config-cross-platform-drift'),
9
+ require('./patterns/mcp-server-no-allowlist'),
10
+ require('./patterns/hook-script-missing'),
11
+ require('./patterns/agent-config-secret-literal'),
12
+ require('./patterns/agent-config-deprecated-keys'),
13
+ require('./patterns/agent-config-dangerous-autoapprove'),
14
+ ];
15
+
16
+ function runShallowRisk(ctx) {
17
+ if (!ctx || process.env.NERVIQ_SHALLOW_RISK === 'off') {
18
+ return [];
19
+ }
20
+
21
+ const findings = [];
22
+ const seen = new Set();
23
+
24
+ for (const pattern of patterns) {
25
+ let emitted = [];
26
+ try {
27
+ const next = pattern.run(ctx);
28
+ emitted = Array.isArray(next) ? next : [];
29
+ } catch {
30
+ emitted = [];
31
+ }
32
+
33
+ for (const finding of emitted) {
34
+ const normalized = buildFinding(pattern, ctx, finding || {});
35
+ const dedupeKey = [
36
+ normalized.key,
37
+ normalized.file || '',
38
+ normalized.line || '',
39
+ normalized.fix || '',
40
+ ].join('|');
41
+
42
+ if (seen.has(dedupeKey)) continue;
43
+ seen.add(dedupeKey);
44
+ findings.push(normalized);
45
+ }
46
+ }
47
+
48
+ return findings;
49
+ }
50
+
51
+ module.exports = {
52
+ patterns,
53
+ runShallowRisk,
54
+ SHALLOW_RISK_BANNER,
55
+ SHALLOW_RISK_BANNER_LINES,
56
+ };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ SHALLOW_RISK_DOC_URL,
5
+ collectStackClaims,
6
+ } = require('../shared');
7
+
8
+ module.exports = {
9
+ key: 'agent-config-cross-platform-drift',
10
+ name: 'Cross-platform stack drift detected',
11
+ severity: 'high',
12
+ layer: 'shallow-risk',
13
+ sourceUrl: SHALLOW_RISK_DOC_URL,
14
+ run(ctx) {
15
+ const claims = collectStackClaims(ctx).filter((claim) => claim.platform !== 'agent');
16
+ if (claims.length < 2) return [];
17
+
18
+ const byPlatform = new Map();
19
+ for (const claim of claims) {
20
+ const bucket = byPlatform.get(claim.platform) || [];
21
+ bucket.push(claim);
22
+ byPlatform.set(claim.platform, bucket);
23
+ }
24
+
25
+ const representatives = [];
26
+ for (const bucket of byPlatform.values()) {
27
+ const uniqueKeys = [...new Set(bucket.map((claim) => claim.key))];
28
+ if (uniqueKeys.length !== 1) continue;
29
+ representatives.push(bucket[0]);
30
+ }
31
+
32
+ representatives.sort((left, right) => left.file.localeCompare(right.file));
33
+
34
+ for (let index = 0; index < representatives.length; index++) {
35
+ for (let inner = index + 1; inner < representatives.length; inner++) {
36
+ const first = representatives[index];
37
+ const second = representatives[inner];
38
+ if (first.key === second.key) continue;
39
+
40
+ return [{
41
+ file: first.file,
42
+ line: first.line,
43
+ fix: `Drift detected: ${first.file} declares "${first.label}" while ${second.file} declares "${second.label}". Align the shared primary-language guidance or document an intentional platform-specific override.`,
44
+ }];
45
+ }
46
+ }
47
+
48
+ return [];
49
+ },
50
+ };