@nerviq/cli 1.21.0 → 1.23.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/README.md +20 -2
- package/bin/cli.js +3 -3
- package/package.json +1 -1
- package/src/audit/evidence.js +270 -0
- package/src/audit.js +67 -3
- package/src/formatters/csv.js +80 -0
- package/src/formatters/junit.js +100 -0
- package/src/formatters/markdown.js +130 -0
package/README.md
CHANGED
|
@@ -223,8 +223,8 @@ All successful operational responses are wrapped in a JSON envelope:
|
|
|
223
223
|
{
|
|
224
224
|
"data": {},
|
|
225
225
|
"meta": {
|
|
226
|
-
"version": "1.
|
|
227
|
-
"timestamp": "2026-04-
|
|
226
|
+
"version": "1.23.0",
|
|
227
|
+
"timestamp": "2026-04-15T02:00:00.000Z"
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
```
|
|
@@ -385,10 +385,28 @@ Levels:
|
|
|
385
385
|
| `--auto` | Apply without prompts |
|
|
386
386
|
| `--only A,B` | Limit apply to selected proposal IDs |
|
|
387
387
|
| `--format sarif` | SARIF output for code scanning |
|
|
388
|
+
| `--format markdown` | GitHub-flavoured PR-comment report |
|
|
389
|
+
| `--format junit` | JUnit XML for CI test reporters (GitHub Actions, Jenkins, GitLab) |
|
|
390
|
+
| `--format csv` | RFC 4180 CSV, one row per check |
|
|
388
391
|
| `--platform NAME` | Target platform (claude, codex, gemini, copilot, cursor, windsurf, aider, opencode) |
|
|
389
392
|
| `--workspace GLOB` | Audit workspaces separately as package-level live audits with summary-only JSON rows (e.g. packages/*) |
|
|
390
393
|
| `--external PATH` | Benchmark an external repo |
|
|
391
394
|
|
|
395
|
+
### CI output formats
|
|
396
|
+
|
|
397
|
+
Pipe audit output straight into the standard CI surfaces:
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
# PR comment (GitHub-flavoured markdown)
|
|
401
|
+
npx @nerviq/cli audit --format=markdown --out audit.md
|
|
402
|
+
|
|
403
|
+
# CI test report (JUnit XML — Jenkins, GitLab, GitHub Actions reporter)
|
|
404
|
+
npx @nerviq/cli audit --format=junit --out junit.xml
|
|
405
|
+
|
|
406
|
+
# Spreadsheet / dashboard ingestion (RFC 4180 CSV)
|
|
407
|
+
npx @nerviq/cli audit --format=csv --out audit.csv
|
|
408
|
+
```
|
|
409
|
+
|
|
392
410
|
Webhook delivery automatically retries transient failures twice by default. For authenticated internal endpoints, you can add custom headers such as:
|
|
393
411
|
|
|
394
412
|
```bash
|
package/bin/cli.js
CHANGED
|
@@ -694,7 +694,7 @@ const HELP = `
|
|
|
694
694
|
--team-profile N Load a saved team profile for audit (overrides threshold/platform)
|
|
695
695
|
--mcp-pack A,B Merge MCP packs into setup (live tool connectors; e.g. context7-docs,next-devtools)
|
|
696
696
|
--check-version V Pin catalog to a specific version (warn on mismatch)
|
|
697
|
-
--format NAME Output format: json | sarif | otel
|
|
697
|
+
--format NAME Output format: json | sarif | otel | markdown | junit | csv
|
|
698
698
|
--webhook URL Send audit results to a webhook (Slack/Discord/generic JSON)
|
|
699
699
|
--webhook-header H Add a custom webhook header (repeat; format: Name: Value)
|
|
700
700
|
--webhook-retries N Retry transient webhook failures N times (default: 2)
|
|
@@ -956,8 +956,8 @@ async function main() {
|
|
|
956
956
|
process.exit(1);
|
|
957
957
|
}
|
|
958
958
|
|
|
959
|
-
if (options.format !== null && !['json', 'sarif', 'otel'].includes(options.format)) {
|
|
960
|
-
console.error(`\n Error: Unsupported format '${options.format}'. Use 'json', 'sarif', or '
|
|
959
|
+
if (options.format !== null && !['json', 'sarif', 'otel', 'markdown', 'junit', 'csv'].includes(options.format)) {
|
|
960
|
+
console.error(`\n Error: Unsupported format '${options.format}'. Use 'json', 'sarif', 'otel', 'markdown', 'junit', or 'csv'.\n`);
|
|
961
961
|
process.exit(1);
|
|
962
962
|
}
|
|
963
963
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerviq/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "The intelligent nervous system for AI coding agents — 2,441 checks (8 platforms × ~300 governance rules), 10 languages, 62 domain packs. Audit, align, and amplify.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence resolver — CTO-04 trust-recovery depth.
|
|
3
|
+
*
|
|
4
|
+
* Given a failed check key and a ProjectContext, returns
|
|
5
|
+
* `{ file, line, snippet }` when the finding lives at a specific
|
|
6
|
+
* file location in the repo, or `null` when the check is genuinely
|
|
7
|
+
* not file-level (e.g. "absence of a file" or meta-property).
|
|
8
|
+
*
|
|
9
|
+
* This is a post-hoc resolver: many technique definitions do not
|
|
10
|
+
* declare `file`/`line` themselves, so we fill evidence in from here
|
|
11
|
+
* using the cached file content already loaded by ProjectContext.
|
|
12
|
+
* No extra filesystem scans are performed.
|
|
13
|
+
*
|
|
14
|
+
* Snippet is a 2-5 line excerpt centered on `line`, length-capped
|
|
15
|
+
* at 300 chars, using `\n` as line separator.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const SNIPPET_RADIUS = 2; // 2 lines before + match + 2 lines after = up to 5 lines
|
|
21
|
+
const SNIPPET_MAX_CHARS = 300;
|
|
22
|
+
|
|
23
|
+
function sliceSnippet(content, line) {
|
|
24
|
+
if (!content || !Number.isFinite(line) || line < 1) return null;
|
|
25
|
+
const lines = content.split(/\r?\n/);
|
|
26
|
+
const start = Math.max(0, line - 1 - SNIPPET_RADIUS);
|
|
27
|
+
const end = Math.min(lines.length, line + SNIPPET_RADIUS);
|
|
28
|
+
let snippet = lines.slice(start, end).join('\n');
|
|
29
|
+
if (snippet.length > SNIPPET_MAX_CHARS) {
|
|
30
|
+
snippet = snippet.slice(0, SNIPPET_MAX_CHARS - 3) + '...';
|
|
31
|
+
}
|
|
32
|
+
return snippet || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildEvidence(ctx, file, line) {
|
|
36
|
+
if (!file) return null;
|
|
37
|
+
const content = typeof ctx.fileContent === 'function' ? ctx.fileContent(file) : null;
|
|
38
|
+
if (!content) return { file, line: line || null, snippet: null };
|
|
39
|
+
const resolvedLine = Number.isFinite(line) && line >= 1 ? line : 1;
|
|
40
|
+
const snippet = sliceSnippet(content, resolvedLine);
|
|
41
|
+
return { file, line: resolvedLine, snippet };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Locate the best-match CLAUDE.md file (root or .claude/).
|
|
45
|
+
function claudeMdPath(ctx) {
|
|
46
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
47
|
+
if (ctx.fileContent('CLAUDE.md') !== null) return 'CLAUDE.md';
|
|
48
|
+
if (ctx.fileContent('.claude/CLAUDE.md') !== null) return '.claude/CLAUDE.md';
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function agentsMdPath(ctx) {
|
|
53
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
54
|
+
if (ctx.fileContent('AGENTS.md') !== null) return 'AGENTS.md';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolvers return { file, line } or null (category c).
|
|
59
|
+
// `file` MUST be a real path that exists; otherwise return null so we do not
|
|
60
|
+
// produce misleading evidence.
|
|
61
|
+
const RESOLVERS = {
|
|
62
|
+
// --- CLAUDE.md content checks (backport: file=CLAUDE.md, line=1) ---
|
|
63
|
+
claudeMd: (ctx) => {
|
|
64
|
+
const p = claudeMdPath(ctx);
|
|
65
|
+
return p ? { file: p, line: 1 } : null; // absent → null (category c)
|
|
66
|
+
},
|
|
67
|
+
importSyntax: (ctx) => {
|
|
68
|
+
const p = claudeMdPath(ctx);
|
|
69
|
+
return p ? { file: p, line: 1 } : null;
|
|
70
|
+
},
|
|
71
|
+
roleDefinition: (ctx) => {
|
|
72
|
+
const p = claudeMdPath(ctx);
|
|
73
|
+
return p ? { file: p, line: 1 } : null;
|
|
74
|
+
},
|
|
75
|
+
mermaidArchitecture: (ctx) => {
|
|
76
|
+
const p = claudeMdPath(ctx);
|
|
77
|
+
return p ? { file: p, line: 1 } : null;
|
|
78
|
+
},
|
|
79
|
+
underlines200: (ctx) => {
|
|
80
|
+
const p = claudeMdPath(ctx);
|
|
81
|
+
return p ? { file: p, line: 1 } : null;
|
|
82
|
+
},
|
|
83
|
+
xmlTags: (ctx) => {
|
|
84
|
+
const p = claudeMdPath(ctx);
|
|
85
|
+
return p ? { file: p, line: 1 } : null;
|
|
86
|
+
},
|
|
87
|
+
fewShotExamples: (ctx) => {
|
|
88
|
+
const p = claudeMdPath(ctx);
|
|
89
|
+
return p ? { file: p, line: 1 } : null;
|
|
90
|
+
},
|
|
91
|
+
outputStyleGuidance: (ctx) => {
|
|
92
|
+
const p = claudeMdPath(ctx);
|
|
93
|
+
return p ? { file: p, line: 1 } : null;
|
|
94
|
+
},
|
|
95
|
+
constraintBlocks: (ctx) => {
|
|
96
|
+
const p = claudeMdPath(ctx);
|
|
97
|
+
return p ? { file: p, line: 1 } : null;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// CLAUDE.local.md — genuinely absent → null
|
|
101
|
+
claudeLocalMd: (ctx) => {
|
|
102
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
103
|
+
return ctx.fileContent('CLAUDE.local.md') !== null
|
|
104
|
+
? { file: 'CLAUDE.local.md', line: 1 }
|
|
105
|
+
: null;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// --- .claude/settings.json shape checks ---
|
|
109
|
+
settingsPermissions: (ctx) => {
|
|
110
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
111
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
112
|
+
const line = typeof ctx.lineNumber === 'function'
|
|
113
|
+
? (ctx.lineNumber('.claude/settings.json', /"permissions"/) || 1)
|
|
114
|
+
: 1;
|
|
115
|
+
return { file: '.claude/settings.json', line };
|
|
116
|
+
},
|
|
117
|
+
permissionDeny: (ctx) => {
|
|
118
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
119
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
120
|
+
const line = typeof ctx.lineNumber === 'function'
|
|
121
|
+
? (ctx.lineNumber('.claude/settings.json', /"deny"/) || 1)
|
|
122
|
+
: 1;
|
|
123
|
+
return { file: '.claude/settings.json', line };
|
|
124
|
+
},
|
|
125
|
+
noBypassPermissions: (ctx) => {
|
|
126
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
127
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
128
|
+
const line = typeof ctx.lineNumber === 'function'
|
|
129
|
+
? (ctx.lineNumber('.claude/settings.json', /bypassPermissions|permissionMode/) || 1)
|
|
130
|
+
: 1;
|
|
131
|
+
return { file: '.claude/settings.json', line };
|
|
132
|
+
},
|
|
133
|
+
secretsProtection: (ctx) => {
|
|
134
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
135
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
136
|
+
return { file: '.claude/settings.json', line: 1 };
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// --- Hooks files ---
|
|
140
|
+
hooks: (ctx) => {
|
|
141
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
142
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
143
|
+
const line = typeof ctx.lineNumber === 'function'
|
|
144
|
+
? (ctx.lineNumber('.claude/settings.json', /"hooks"/) || 1)
|
|
145
|
+
: 1;
|
|
146
|
+
return { file: '.claude/settings.json', line };
|
|
147
|
+
},
|
|
148
|
+
stopFailureHook: (ctx) => {
|
|
149
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
150
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
151
|
+
return { file: '.claude/settings.json', line: 1 };
|
|
152
|
+
},
|
|
153
|
+
hooksNotificationEvent: (ctx) => {
|
|
154
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
155
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
156
|
+
return { file: '.claude/settings.json', line: 1 };
|
|
157
|
+
},
|
|
158
|
+
subagentStopHook: (ctx) => {
|
|
159
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
160
|
+
if (ctx.fileContent('.claude/settings.json') === null) return null;
|
|
161
|
+
return { file: '.claude/settings.json', line: 1 };
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// --- AGENTS.md (Codex surface) ---
|
|
165
|
+
codexAgentsMd: (ctx) => {
|
|
166
|
+
const p = agentsMdPath(ctx);
|
|
167
|
+
return p ? { file: p, line: 1 } : null;
|
|
168
|
+
},
|
|
169
|
+
codexAgentsMdSubstantive: (ctx) => {
|
|
170
|
+
const p = agentsMdPath(ctx);
|
|
171
|
+
return p ? { file: p, line: 1 } : null;
|
|
172
|
+
},
|
|
173
|
+
codexAgentsVerificationCommands: (ctx) => {
|
|
174
|
+
const p = agentsMdPath(ctx);
|
|
175
|
+
return p ? { file: p, line: 1 } : null;
|
|
176
|
+
},
|
|
177
|
+
codexAgentsArchitecture: (ctx) => {
|
|
178
|
+
const p = agentsMdPath(ctx);
|
|
179
|
+
return p ? { file: p, line: 1 } : null;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// --- Auto-memory / governance ---
|
|
183
|
+
autoMemoryAwareness: (ctx) => {
|
|
184
|
+
const p = claudeMdPath(ctx);
|
|
185
|
+
return p ? { file: p, line: 1 } : null;
|
|
186
|
+
},
|
|
187
|
+
loopSafetyBoundaries: (ctx) => {
|
|
188
|
+
const p = claudeMdPath(ctx);
|
|
189
|
+
return p ? { file: p, line: 1 } : null;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// --- CLAUDE.md content-awareness checks (backport ~10 more keys) ---
|
|
193
|
+
compactionAwareness: (ctx) => {
|
|
194
|
+
const p = claudeMdPath(ctx);
|
|
195
|
+
return p ? { file: p, line: 1 } : null;
|
|
196
|
+
},
|
|
197
|
+
contextManagement: (ctx) => {
|
|
198
|
+
const p = claudeMdPath(ctx);
|
|
199
|
+
return p ? { file: p, line: 1 } : null;
|
|
200
|
+
},
|
|
201
|
+
effortLevelConfigured: (ctx) => {
|
|
202
|
+
const p = claudeMdPath(ctx);
|
|
203
|
+
return p ? { file: p, line: 1 } : null;
|
|
204
|
+
},
|
|
205
|
+
channelsAwareness: (ctx) => {
|
|
206
|
+
const p = claudeMdPath(ctx);
|
|
207
|
+
return p ? { file: p, line: 1 } : null;
|
|
208
|
+
},
|
|
209
|
+
worktreeAwareness: (ctx) => {
|
|
210
|
+
const p = claudeMdPath(ctx);
|
|
211
|
+
return p ? { file: p, line: 1 } : null;
|
|
212
|
+
},
|
|
213
|
+
instinctToSkillProgression: (ctx) => {
|
|
214
|
+
const p = claudeMdPath(ctx);
|
|
215
|
+
return p ? { file: p, line: 1 } : null;
|
|
216
|
+
},
|
|
217
|
+
sandboxAwareness: (ctx) => {
|
|
218
|
+
const p = claudeMdPath(ctx);
|
|
219
|
+
return p ? { file: p, line: 1 } : null;
|
|
220
|
+
},
|
|
221
|
+
gitAttributionDecision: (ctx) => {
|
|
222
|
+
const p = claudeMdPath(ctx);
|
|
223
|
+
return p ? { file: p, line: 1 } : null;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// --- MCP ---
|
|
227
|
+
mcpHasEnvConfig: (ctx) => {
|
|
228
|
+
if (typeof ctx.fileContent !== 'function') return null;
|
|
229
|
+
if (ctx.fileContent('.mcp.json') !== null) return { file: '.mcp.json', line: 1 };
|
|
230
|
+
if (ctx.fileContent('.claude/settings.json') !== null) {
|
|
231
|
+
const line = typeof ctx.lineNumber === 'function'
|
|
232
|
+
? (ctx.lineNumber('.claude/settings.json', /"mcpServers"|"mcp"/) || 1)
|
|
233
|
+
: 1;
|
|
234
|
+
return { file: '.claude/settings.json', line };
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resolve file/line/snippet evidence for a failed check.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} key Check key (e.g. 'importSyntax').
|
|
244
|
+
* @param {Object} ctx ProjectContext instance.
|
|
245
|
+
* @param {Object} [existing] Pre-existing { file, line } from the check itself.
|
|
246
|
+
* @returns {{file:string, line:number|null, snippet:string|null}|null}
|
|
247
|
+
*/
|
|
248
|
+
function resolveEvidence(key, ctx, existing = null) {
|
|
249
|
+
// If the check already emitted a real file, enrich with snippet only.
|
|
250
|
+
if (existing && existing.file) {
|
|
251
|
+
return buildEvidence(ctx, existing.file, existing.line);
|
|
252
|
+
}
|
|
253
|
+
const resolver = RESOLVERS[key];
|
|
254
|
+
if (!resolver) return null;
|
|
255
|
+
let guess = null;
|
|
256
|
+
try {
|
|
257
|
+
guess = resolver(ctx);
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
if (!guess || !guess.file) return null;
|
|
262
|
+
return buildEvidence(ctx, guess.file, guess.line);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
resolveEvidence,
|
|
267
|
+
sliceSnippet,
|
|
268
|
+
SNIPPET_MAX_CHARS,
|
|
269
|
+
EVIDENCE_RESOLVER_KEYS: Object.keys(RESOLVERS),
|
|
270
|
+
};
|
package/src/audit.js
CHANGED
|
@@ -28,10 +28,14 @@ const { getRecommendationOutcomeSummary } = require('./activity');
|
|
|
28
28
|
const { getFeedbackSummary } = require('./feedback');
|
|
29
29
|
const { formatSarif } = require('./formatters/sarif');
|
|
30
30
|
const { formatOtelMetrics } = require('./formatters/otel');
|
|
31
|
+
const { formatMarkdown } = require('./formatters/markdown');
|
|
32
|
+
const { formatJUnit } = require('./formatters/junit');
|
|
33
|
+
const { formatCsv } = require('./formatters/csv');
|
|
31
34
|
const { collectAuditTerminology, formatTerminologyLines } = require('./terminology');
|
|
32
35
|
const { loadPlugins, mergePluginChecks } = require('./plugins');
|
|
33
36
|
const { detectDeprecationWarnings } = require('./deprecation');
|
|
34
37
|
const { buildWorkspaceHint, formatCount, guardSkippedInstructionFiles, inspectInstructionFiles } = require('./audit/instruction-files');
|
|
38
|
+
const { resolveEvidence } = require('./audit/evidence');
|
|
35
39
|
const {
|
|
36
40
|
WEIGHTS,
|
|
37
41
|
buildScoreCoaching,
|
|
@@ -409,13 +413,24 @@ async function audit(options) {
|
|
|
409
413
|
}
|
|
410
414
|
|
|
411
415
|
const passed = technique.check(ctx);
|
|
412
|
-
|
|
413
|
-
|
|
416
|
+
let file = typeof technique.file === 'function' ? (technique.file(ctx) ?? null) : (technique.file ?? null);
|
|
417
|
+
let line = typeof technique.line === 'function' ? (technique.line(ctx) ?? null) : (technique.line ?? null);
|
|
418
|
+
let snippet = null;
|
|
419
|
+
// CTO-04: only compute evidence on failed checks (cheap, and only where it adds trust).
|
|
420
|
+
if (passed === false) {
|
|
421
|
+
const evidence = resolveEvidence(key, ctx, { file, line });
|
|
422
|
+
if (evidence) {
|
|
423
|
+
file = evidence.file;
|
|
424
|
+
line = evidence.line;
|
|
425
|
+
snippet = evidence.snippet;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
414
428
|
results.push({
|
|
415
429
|
key,
|
|
416
430
|
...technique,
|
|
417
431
|
file,
|
|
418
432
|
line: Number.isFinite(line) ? line : null,
|
|
433
|
+
snippet,
|
|
419
434
|
passed,
|
|
420
435
|
});
|
|
421
436
|
}
|
|
@@ -490,6 +505,35 @@ async function audit(options) {
|
|
|
490
505
|
const organicScore = maxScore > 0 ? Math.round((organicEarned / maxScore) * 100) : 0;
|
|
491
506
|
const quickWins = getQuickWins(failed, { platform: spec.platform });
|
|
492
507
|
const topNextActions = buildTopNextActions(failed, 5, outcomeSummary.byKey, { platform: spec.platform, fpFeedbackByKey: fpFeedback.byKey });
|
|
508
|
+
|
|
509
|
+
// CTO-04: enrich top actions with file/line/snippet from the corresponding
|
|
510
|
+
// result record (evidence was resolved above during the check loop).
|
|
511
|
+
// CTO-05: project score-after-fix per action.
|
|
512
|
+
const resultByKey = new Map(results.map((r) => [r.key, r]));
|
|
513
|
+
for (const action of topNextActions) {
|
|
514
|
+
const source = resultByKey.get(action.key);
|
|
515
|
+
if (source) {
|
|
516
|
+
if (source.file && !action.file) action.file = source.file;
|
|
517
|
+
if (source.line && !action.line) action.line = source.line;
|
|
518
|
+
if (source.snippet) action.snippet = source.snippet;
|
|
519
|
+
}
|
|
520
|
+
// Projected score delta: if this single failed check flipped to passed.
|
|
521
|
+
const weight = WEIGHTS[action.impact] || 0;
|
|
522
|
+
if (maxScore > 0 && weight > 0) {
|
|
523
|
+
const projectedScoreAfter = Math.round(((earnedScore + weight) / maxScore) * 100);
|
|
524
|
+
action.projectedScoreDelta = projectedScoreAfter - score;
|
|
525
|
+
action.projectedScoreAfter = projectedScoreAfter;
|
|
526
|
+
const isScaffolded = scaffoldedKeys.has(action.key);
|
|
527
|
+
const projectedOrganicAfter = isScaffolded
|
|
528
|
+
? organicScore
|
|
529
|
+
: Math.round(((organicEarned + weight) / maxScore) * 100);
|
|
530
|
+
action.projectedOrganicScoreDelta = projectedOrganicAfter - organicScore;
|
|
531
|
+
} else {
|
|
532
|
+
action.projectedScoreDelta = 0;
|
|
533
|
+
action.projectedScoreAfter = score;
|
|
534
|
+
action.projectedOrganicScoreDelta = 0;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
493
537
|
const categoryScores = computeCategoryScores(applicable, passed);
|
|
494
538
|
const platformScopeNote = getPlatformScopeNote(spec, ctx);
|
|
495
539
|
const platformCaveats = getPlatformCaveats(spec, ctx);
|
|
@@ -645,6 +689,23 @@ async function audit(options) {
|
|
|
645
689
|
return result;
|
|
646
690
|
}
|
|
647
691
|
|
|
692
|
+
if (options.format === 'markdown') {
|
|
693
|
+
const enriched = { version: packageVersion, timestamp: new Date().toISOString(), ...result };
|
|
694
|
+
console.log(formatMarkdown(enriched, { dir: options.dir }));
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (options.format === 'junit') {
|
|
699
|
+
const enriched = { version: packageVersion, timestamp: new Date().toISOString(), ...result };
|
|
700
|
+
console.log(formatJUnit(enriched));
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (options.format === 'csv') {
|
|
705
|
+
console.log(formatCsv(result));
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
|
|
648
709
|
if (options.lite) {
|
|
649
710
|
printLiteAudit(result, options.dir);
|
|
650
711
|
sendInsights(result);
|
|
@@ -795,7 +856,10 @@ async function audit(options) {
|
|
|
795
856
|
console.log(colorize(' ⚡ Top 5 Next Actions', 'magenta'));
|
|
796
857
|
for (let i = 0; i < topNextActions.length; i++) {
|
|
797
858
|
const item = topNextActions[i];
|
|
798
|
-
|
|
859
|
+
const delta = Number.isFinite(item.projectedScoreDelta) && item.projectedScoreDelta > 0
|
|
860
|
+
? colorize(` (+${item.projectedScoreDelta} pts → ${item.projectedScoreAfter}/100)`, 'green')
|
|
861
|
+
: '';
|
|
862
|
+
console.log(` ${i + 1}. ${colorize(item.name, 'bold')}${delta}`);
|
|
799
863
|
console.log(colorize(` Why: ${item.why}`, 'dim'));
|
|
800
864
|
console.log(colorize(` Trace: ${item.signals.join(' | ')}`, 'dim'));
|
|
801
865
|
console.log(colorize(` Risk: ${item.risk} | Confidence: ${item.confidence}`, 'dim'));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV Formatter (RFC 4180)
|
|
3
|
+
*
|
|
4
|
+
* One row per check in a nerviq audit result.
|
|
5
|
+
* Columns: key,id,name,category,rating,severity,passed,file,line,sourceUrl,fix
|
|
6
|
+
*
|
|
7
|
+
* Quoting rules (RFC 4180):
|
|
8
|
+
* - Fields containing comma, double-quote, CR, or LF are wrapped in
|
|
9
|
+
* double-quotes.
|
|
10
|
+
* - Internal double-quotes are escaped by doubling them.
|
|
11
|
+
* - Header row is emitted first.
|
|
12
|
+
* - No UTF-8 BOM (some consumers mishandle it).
|
|
13
|
+
* - Line separator: LF (consumers accept LF; JUnit/XLSX/csv parsers
|
|
14
|
+
* normalize both).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const COLUMNS = [
|
|
20
|
+
'key',
|
|
21
|
+
'id',
|
|
22
|
+
'name',
|
|
23
|
+
'category',
|
|
24
|
+
'rating',
|
|
25
|
+
'severity',
|
|
26
|
+
'passed',
|
|
27
|
+
'file',
|
|
28
|
+
'line',
|
|
29
|
+
'sourceUrl',
|
|
30
|
+
'fix',
|
|
31
|
+
'projectedScoreDelta',
|
|
32
|
+
'projectedScoreAfter',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function csvEscape(value) {
|
|
36
|
+
if (value === null || value === undefined) return '';
|
|
37
|
+
const s = String(value);
|
|
38
|
+
if (/[",\r\n]/.test(s)) {
|
|
39
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
40
|
+
}
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rowFor(r, projections = null) {
|
|
45
|
+
const severity = r.severity || r.impact || '';
|
|
46
|
+
const proj = projections && projections.get(r.key);
|
|
47
|
+
const cells = [
|
|
48
|
+
r.key ?? '',
|
|
49
|
+
r.id ?? '',
|
|
50
|
+
r.name ?? '',
|
|
51
|
+
r.category ?? '',
|
|
52
|
+
r.rating ?? '',
|
|
53
|
+
severity,
|
|
54
|
+
r.passed === null || r.passed === undefined ? '' : String(r.passed),
|
|
55
|
+
r.file ?? '',
|
|
56
|
+
r.line ?? '',
|
|
57
|
+
r.sourceUrl ?? '',
|
|
58
|
+
r.fix ?? '',
|
|
59
|
+
proj && Number.isFinite(proj.projectedScoreDelta) ? String(proj.projectedScoreDelta) : '',
|
|
60
|
+
proj && Number.isFinite(proj.projectedScoreAfter) ? String(proj.projectedScoreAfter) : '',
|
|
61
|
+
];
|
|
62
|
+
return cells.map(csvEscape).join(',');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatCsv(auditResult) {
|
|
66
|
+
const results = Array.isArray(auditResult.results) ? auditResult.results : [];
|
|
67
|
+
const projections = new Map();
|
|
68
|
+
if (Array.isArray(auditResult.topNextActions)) {
|
|
69
|
+
for (const item of auditResult.topNextActions) {
|
|
70
|
+
if (item && item.key) projections.set(item.key, item);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const lines = [COLUMNS.join(',')];
|
|
74
|
+
for (const r of results) {
|
|
75
|
+
lines.push(rowFor(r, projections));
|
|
76
|
+
}
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { formatCsv, CSV_COLUMNS: COLUMNS };
|
|
@@ -0,0 +1,100 @@
|
|
|
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, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''');
|
|
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
|
+
if (r.passed === false) {
|
|
74
|
+
const msg = escapeXml(r.fix || r.name || r.key || 'check failed');
|
|
75
|
+
const type = escapeXml(severityFor(r));
|
|
76
|
+
let body = `${r.name || ''}`;
|
|
77
|
+
if (r.file) body += ` at ${r.file}${r.line ? ':' + r.line : ''}`;
|
|
78
|
+
if (r.sourceUrl) body += ` (${r.sourceUrl})`;
|
|
79
|
+
if (r.snippet) body += `\n---\n${r.snippet}`;
|
|
80
|
+
lines.push(` <testcase classname="${classname}" name="${name}" time="0">`);
|
|
81
|
+
lines.push(` <failure message="${msg}" type="${type}">${escapeXml(body)}</failure>`);
|
|
82
|
+
lines.push(` </testcase>`);
|
|
83
|
+
} else if (r.passed === null || r.skipped === true) {
|
|
84
|
+
lines.push(` <testcase classname="${classname}" name="${name}" time="0">`);
|
|
85
|
+
lines.push(` <skipped/>`);
|
|
86
|
+
lines.push(` </testcase>`);
|
|
87
|
+
} else {
|
|
88
|
+
lines.push(` <testcase classname="${classname}" name="${name}" time="0"/>`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
lines.push(' </testsuite>');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lines.push('</testsuites>');
|
|
96
|
+
lines.push(`<!-- nerviq v${escapeXml(auditResult.version || nerviqVersion)} -->`);
|
|
97
|
+
return lines.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { formatJUnit };
|
|
@@ -0,0 +1,130 @@
|
|
|
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 ``;
|
|
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
|
+
lines.push(`- [ ] **[${sev}] ${title}** (\`${key}\`)${loc}${delta}`);
|
|
82
|
+
const hint = item.fix || item.hint || '';
|
|
83
|
+
if (hint) {
|
|
84
|
+
lines.push(` - ${escapeInline(hint)}`);
|
|
85
|
+
}
|
|
86
|
+
if (item.snippet) {
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push(' ```');
|
|
89
|
+
for (const snipLine of String(item.snippet).split(/\r?\n/)) {
|
|
90
|
+
lines.push(` ${snipLine}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(' ```');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const failedResults = Array.isArray(auditResult.results)
|
|
99
|
+
? auditResult.results.filter((r) => r.passed === false)
|
|
100
|
+
: [];
|
|
101
|
+
|
|
102
|
+
if (failedResults.length > 0) {
|
|
103
|
+
lines.push('<details>');
|
|
104
|
+
lines.push(`<summary>All failed checks (${failedResults.length})</summary>`);
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push('| key | name | category | rating | file | line |');
|
|
107
|
+
lines.push('| --- | --- | --- | --- | --- | --- |');
|
|
108
|
+
for (const r of failedResults) {
|
|
109
|
+
const row = [
|
|
110
|
+
escapeCell(r.key),
|
|
111
|
+
escapeCell(r.name),
|
|
112
|
+
escapeCell(r.category),
|
|
113
|
+
escapeCell(r.rating ?? ''),
|
|
114
|
+
escapeCell(r.file || ''),
|
|
115
|
+
escapeCell(r.line || ''),
|
|
116
|
+
];
|
|
117
|
+
lines.push(`| ${row.join(' | ')} |`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('</details>');
|
|
121
|
+
lines.push('');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
lines.push(`---`);
|
|
125
|
+
lines.push(`Generated by [Nerviq](https://nerviq.net) v${version} · ${timestamp}`);
|
|
126
|
+
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { formatMarkdown };
|