@nerviq/cli 1.22.0 → 1.24.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 +2 -2
- package/package.json +1 -1
- package/src/audit/evidence.js +270 -0
- package/src/audit.js +47 -3
- package/src/formatters/csv.js +13 -2
- package/src/formatters/junit.js +1 -0
- package/src/formatters/markdown.js +13 -1
- package/src/techniques/automation.js +42 -5
- package/src/techniques/instructions.js +28 -5
- package/src/techniques/tools.js +24 -3
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.24.0",
|
|
227
|
+
"timestamp": "2026-04-15T06:00:00.000Z"
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nerviq/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.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
|
@@ -35,6 +35,7 @@ const { collectAuditTerminology, formatTerminologyLines } = require('./terminolo
|
|
|
35
35
|
const { loadPlugins, mergePluginChecks } = require('./plugins');
|
|
36
36
|
const { detectDeprecationWarnings } = require('./deprecation');
|
|
37
37
|
const { buildWorkspaceHint, formatCount, guardSkippedInstructionFiles, inspectInstructionFiles } = require('./audit/instruction-files');
|
|
38
|
+
const { resolveEvidence } = require('./audit/evidence');
|
|
38
39
|
const {
|
|
39
40
|
WEIGHTS,
|
|
40
41
|
buildScoreCoaching,
|
|
@@ -412,13 +413,24 @@ async function audit(options) {
|
|
|
412
413
|
}
|
|
413
414
|
|
|
414
415
|
const passed = technique.check(ctx);
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
}
|
|
417
428
|
results.push({
|
|
418
429
|
key,
|
|
419
430
|
...technique,
|
|
420
431
|
file,
|
|
421
432
|
line: Number.isFinite(line) ? line : null,
|
|
433
|
+
snippet,
|
|
422
434
|
passed,
|
|
423
435
|
});
|
|
424
436
|
}
|
|
@@ -493,6 +505,35 @@ async function audit(options) {
|
|
|
493
505
|
const organicScore = maxScore > 0 ? Math.round((organicEarned / maxScore) * 100) : 0;
|
|
494
506
|
const quickWins = getQuickWins(failed, { platform: spec.platform });
|
|
495
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
|
+
}
|
|
496
537
|
const categoryScores = computeCategoryScores(applicable, passed);
|
|
497
538
|
const platformScopeNote = getPlatformScopeNote(spec, ctx);
|
|
498
539
|
const platformCaveats = getPlatformCaveats(spec, ctx);
|
|
@@ -815,7 +856,10 @@ async function audit(options) {
|
|
|
815
856
|
console.log(colorize(' ⚡ Top 5 Next Actions', 'magenta'));
|
|
816
857
|
for (let i = 0; i < topNextActions.length; i++) {
|
|
817
858
|
const item = topNextActions[i];
|
|
818
|
-
|
|
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}`);
|
|
819
863
|
console.log(colorize(` Why: ${item.why}`, 'dim'));
|
|
820
864
|
console.log(colorize(` Trace: ${item.signals.join(' | ')}`, 'dim'));
|
|
821
865
|
console.log(colorize(` Risk: ${item.risk} | Confidence: ${item.confidence}`, 'dim'));
|
package/src/formatters/csv.js
CHANGED
|
@@ -28,6 +28,8 @@ const COLUMNS = [
|
|
|
28
28
|
'line',
|
|
29
29
|
'sourceUrl',
|
|
30
30
|
'fix',
|
|
31
|
+
'projectedScoreDelta',
|
|
32
|
+
'projectedScoreAfter',
|
|
31
33
|
];
|
|
32
34
|
|
|
33
35
|
function csvEscape(value) {
|
|
@@ -39,8 +41,9 @@ function csvEscape(value) {
|
|
|
39
41
|
return s;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
function rowFor(r) {
|
|
44
|
+
function rowFor(r, projections = null) {
|
|
43
45
|
const severity = r.severity || r.impact || '';
|
|
46
|
+
const proj = projections && projections.get(r.key);
|
|
44
47
|
const cells = [
|
|
45
48
|
r.key ?? '',
|
|
46
49
|
r.id ?? '',
|
|
@@ -53,15 +56,23 @@ function rowFor(r) {
|
|
|
53
56
|
r.line ?? '',
|
|
54
57
|
r.sourceUrl ?? '',
|
|
55
58
|
r.fix ?? '',
|
|
59
|
+
proj && Number.isFinite(proj.projectedScoreDelta) ? String(proj.projectedScoreDelta) : '',
|
|
60
|
+
proj && Number.isFinite(proj.projectedScoreAfter) ? String(proj.projectedScoreAfter) : '',
|
|
56
61
|
];
|
|
57
62
|
return cells.map(csvEscape).join(',');
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
function formatCsv(auditResult) {
|
|
61
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
|
+
}
|
|
62
73
|
const lines = [COLUMNS.join(',')];
|
|
63
74
|
for (const r of results) {
|
|
64
|
-
lines.push(rowFor(r));
|
|
75
|
+
lines.push(rowFor(r, projections));
|
|
65
76
|
}
|
|
66
77
|
return lines.join('\n');
|
|
67
78
|
}
|
package/src/formatters/junit.js
CHANGED
|
@@ -76,6 +76,7 @@ function formatJUnit(auditResult) {
|
|
|
76
76
|
let body = `${r.name || ''}`;
|
|
77
77
|
if (r.file) body += ` at ${r.file}${r.line ? ':' + r.line : ''}`;
|
|
78
78
|
if (r.sourceUrl) body += ` (${r.sourceUrl})`;
|
|
79
|
+
if (r.snippet) body += `\n---\n${r.snippet}`;
|
|
79
80
|
lines.push(` <testcase classname="${classname}" name="${name}" time="0">`);
|
|
80
81
|
lines.push(` <failure message="${msg}" type="${type}">${escapeXml(body)}</failure>`);
|
|
81
82
|
lines.push(` </testcase>`);
|
|
@@ -74,11 +74,23 @@ function formatMarkdown(auditResult, options = {}) {
|
|
|
74
74
|
if (item.file) {
|
|
75
75
|
loc = ` — \`${escapeInline(item.file)}${item.line ? ':' + item.line : ''}\``;
|
|
76
76
|
}
|
|
77
|
-
|
|
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}`);
|
|
78
82
|
const hint = item.fix || item.hint || '';
|
|
79
83
|
if (hint) {
|
|
80
84
|
lines.push(` - ${escapeInline(hint)}`);
|
|
81
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
|
+
}
|
|
82
94
|
}
|
|
83
95
|
lines.push('');
|
|
84
96
|
}
|
|
@@ -8,6 +8,27 @@ const {
|
|
|
8
8
|
readProjectFiles,
|
|
9
9
|
} = require('./shared');
|
|
10
10
|
|
|
11
|
+
// PP-06 recalibration helpers: opt-in signals. Repos with no infra/hooks
|
|
12
|
+
// signal at all get N/A instead of a hard fail on opt-in advisories.
|
|
13
|
+
function _repoHasInfraSignal(ctx) {
|
|
14
|
+
return ctx.files.some(f => /^Dockerfile/i.test(f))
|
|
15
|
+
|| ctx.files.some(f => /^docker-compose\.(yml|yaml)$/i.test(f))
|
|
16
|
+
|| ctx.files.some(f => /\.tf$/.test(f))
|
|
17
|
+
|| ctx.files.includes('main.tf')
|
|
18
|
+
|| ctx.hasDir('k8s')
|
|
19
|
+
|| ctx.hasDir('kubernetes')
|
|
20
|
+
|| ctx.hasDir('infra')
|
|
21
|
+
|| ctx.hasDir('terraform')
|
|
22
|
+
|| ctx.hasDir('deploy');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function _repoHasHooksBlock(ctx) {
|
|
26
|
+
const shared = ctx.jsonFile('.claude/settings.json') || {};
|
|
27
|
+
const local = ctx.jsonFile('.claude/settings.local.json') || {};
|
|
28
|
+
return !!((shared.hooks && Object.keys(shared.hooks).length > 0)
|
|
29
|
+
|| (local.hooks && Object.keys(local.hooks).length > 0));
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
module.exports = {
|
|
12
33
|
hooks: {
|
|
13
34
|
id: 19,
|
|
@@ -91,7 +112,11 @@ module.exports = {
|
|
|
91
112
|
dockerfile: {
|
|
92
113
|
id: 399,
|
|
93
114
|
name: 'Has Dockerfile',
|
|
94
|
-
check: (ctx) =>
|
|
115
|
+
check: (ctx) => {
|
|
116
|
+
if (ctx.files.some(f => /^Dockerfile/i.test(f))) return true;
|
|
117
|
+
// PP-06 recalibration: N/A on repos with no infra signal at all.
|
|
118
|
+
return _repoHasInfraSignal(ctx) ? false : null;
|
|
119
|
+
},
|
|
95
120
|
impact: 'medium',
|
|
96
121
|
rating: 3,
|
|
97
122
|
category: 'devops',
|
|
@@ -102,7 +127,11 @@ module.exports = {
|
|
|
102
127
|
dockerCompose: {
|
|
103
128
|
id: 39901,
|
|
104
129
|
name: 'Has docker-compose.yml',
|
|
105
|
-
check: (ctx) =>
|
|
130
|
+
check: (ctx) => {
|
|
131
|
+
if (ctx.files.some(f => /^docker-compose\.(yml|yaml)$/i.test(f))) return true;
|
|
132
|
+
// PP-06 recalibration: N/A on repos with no infra signal at all.
|
|
133
|
+
return _repoHasInfraSignal(ctx) ? false : null;
|
|
134
|
+
},
|
|
106
135
|
impact: 'medium',
|
|
107
136
|
rating: 3,
|
|
108
137
|
category: 'devops',
|
|
@@ -126,7 +155,11 @@ module.exports = {
|
|
|
126
155
|
terraformFiles: {
|
|
127
156
|
id: 397,
|
|
128
157
|
name: 'Infrastructure as Code (Terraform)',
|
|
129
|
-
check: (ctx) =>
|
|
158
|
+
check: (ctx) => {
|
|
159
|
+
if (ctx.files.some(f => /\.tf$/.test(f)) || ctx.files.includes('main.tf')) return true;
|
|
160
|
+
// PP-06 recalibration: N/A on repos with no infra signal at all.
|
|
161
|
+
return _repoHasInfraSignal(ctx) ? false : null;
|
|
162
|
+
},
|
|
130
163
|
impact: 'medium',
|
|
131
164
|
rating: 3,
|
|
132
165
|
category: 'devops',
|
|
@@ -290,7 +323,9 @@ module.exports = {
|
|
|
290
323
|
check: (ctx) => {
|
|
291
324
|
const shared = ctx.jsonFile('.claude/settings.json') || {};
|
|
292
325
|
const local = ctx.jsonFile('.claude/settings.local.json') || {};
|
|
293
|
-
|
|
326
|
+
if (shared.hooks?.Notification || local.hooks?.Notification) return true;
|
|
327
|
+
// PP-06 recalibration: N/A unless settings define a hooks block at all.
|
|
328
|
+
return _repoHasHooksBlock(ctx) ? false : null;
|
|
294
329
|
},
|
|
295
330
|
impact: 'low',
|
|
296
331
|
rating: 2,
|
|
@@ -305,7 +340,9 @@ module.exports = {
|
|
|
305
340
|
check: (ctx) => {
|
|
306
341
|
const shared = ctx.jsonFile('.claude/settings.json') || {};
|
|
307
342
|
const local = ctx.jsonFile('.claude/settings.local.json') || {};
|
|
308
|
-
|
|
343
|
+
if (shared.hooks?.SubagentStop || local.hooks?.SubagentStop) return true;
|
|
344
|
+
// PP-06 recalibration: N/A unless settings define a hooks block at all.
|
|
345
|
+
return _repoHasHooksBlock(ctx) ? false : null;
|
|
309
346
|
},
|
|
310
347
|
impact: 'low',
|
|
311
348
|
rating: 2,
|
|
@@ -50,8 +50,17 @@ module.exports = {
|
|
|
50
50
|
name: 'CLAUDE.md uses @path imports for modularity',
|
|
51
51
|
check: (ctx) => {
|
|
52
52
|
const md = ctx.claudeMdContent() || '';
|
|
53
|
-
//
|
|
54
|
-
|
|
53
|
+
// Positive-signal check (PP-06 recalibration): N/A when no CLAUDE.md
|
|
54
|
+
// surface exists, so we don't fail every repo that happens to have a
|
|
55
|
+
// short CLAUDE.md. Only fire as an advisory on long CLAUDE.md files
|
|
56
|
+
// where modular @-imports would genuinely help.
|
|
57
|
+
if (!md) return null;
|
|
58
|
+
const hasImport = /@\S+\.(md|txt|json|yml|yaml|toml)/i.test(md) || /@\w+\//.test(md);
|
|
59
|
+
if (hasImport) return true;
|
|
60
|
+
// Only advise splitting when the CLAUDE.md is long enough to warrant it.
|
|
61
|
+
const lineCount = md.split('\n').length;
|
|
62
|
+
if (lineCount < 80) return null;
|
|
63
|
+
return false;
|
|
55
64
|
},
|
|
56
65
|
impact: 'medium',
|
|
57
66
|
rating: 4,
|
|
@@ -140,8 +149,17 @@ module.exports = {
|
|
|
140
149
|
id: 2002,
|
|
141
150
|
name: 'CLAUDE.local.md for personal overrides',
|
|
142
151
|
check: (ctx) => {
|
|
143
|
-
// CLAUDE.local.md is for personal, non-committed overrides
|
|
144
|
-
|
|
152
|
+
// CLAUDE.local.md is for personal, non-committed overrides.
|
|
153
|
+
const hasLocal = ctx.files.includes('CLAUDE.local.md') || ctx.files.includes('.claude/CLAUDE.local.md');
|
|
154
|
+
if (hasLocal) return true;
|
|
155
|
+
// PP-06 recalibration: N/A when the repo has no personal-overrides
|
|
156
|
+
// convention at all. Only advise creating CLAUDE.local.md when the
|
|
157
|
+
// repo explicitly opts in to that convention (references it in
|
|
158
|
+
// .gitignore or in CLAUDE.md).
|
|
159
|
+
const gitignore = ctx.fileContent('.gitignore') || '';
|
|
160
|
+
const md = ctx.claudeMdContent() || '';
|
|
161
|
+
const mentioned = /CLAUDE\.local\.md/i.test(gitignore) || /CLAUDE\.local\.md/i.test(md);
|
|
162
|
+
return mentioned ? false : null;
|
|
145
163
|
},
|
|
146
164
|
impact: 'low',
|
|
147
165
|
rating: 2,
|
|
@@ -155,7 +173,12 @@ module.exports = {
|
|
|
155
173
|
name: 'Auto-memory or memory management mentioned',
|
|
156
174
|
check: (ctx) => {
|
|
157
175
|
const md = ctx.claudeMdContent() || '';
|
|
158
|
-
|
|
176
|
+
if (/auto.?memory|memory.*manage|remember|persistent.*context/i.test(md)) return true;
|
|
177
|
+
// PP-06 recalibration: N/A on repos that don't use Claude Code memory
|
|
178
|
+
// at all. Only fire the advisory when the repo opts in (mentions memory
|
|
179
|
+
// or has a memory directory under .claude/).
|
|
180
|
+
const opts_in = /\bmemory\b/i.test(md) || ctx.hasDir('.claude/memory');
|
|
181
|
+
return opts_in ? false : null;
|
|
159
182
|
},
|
|
160
183
|
impact: 'low', rating: 3, category: 'memory',
|
|
161
184
|
fix: 'Claude Code supports auto-memory for cross-session learning. Mention your memory strategy if relevant.',
|
package/src/techniques/tools.js
CHANGED
|
@@ -6,6 +6,20 @@
|
|
|
6
6
|
const {
|
|
7
7
|
} = require('./shared');
|
|
8
8
|
|
|
9
|
+
// PP-06 recalibration: opt-in signal for MCP. A repo "opts in" to MCP checks if
|
|
10
|
+
// it has any MCP file (even empty/partial) or mentions MCP in its Claude
|
|
11
|
+
// instructions. Repos with no MCP signal at all get N/A on MCP advisories
|
|
12
|
+
// instead of a hard fail.
|
|
13
|
+
function _repoOptsInToMcp(ctx) {
|
|
14
|
+
if (ctx.files.includes('.mcp.json')) return true;
|
|
15
|
+
const shared = ctx.jsonFile('.claude/settings.json') || {};
|
|
16
|
+
const local = ctx.jsonFile('.claude/settings.local.json') || {};
|
|
17
|
+
if (shared.mcpServers || local.mcpServers) return true;
|
|
18
|
+
const md = ctx.claudeMdContent() || '';
|
|
19
|
+
if (/\bMCP\b|mcpServers|model[\s-]?context[\s-]?protocol/i.test(md)) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
module.exports = {
|
|
10
24
|
mcpServers: {
|
|
11
25
|
id: 18,
|
|
@@ -16,7 +30,9 @@ module.exports = {
|
|
|
16
30
|
if (mcpJson && mcpJson.mcpServers && Object.keys(mcpJson.mcpServers).length > 0) return true;
|
|
17
31
|
// Fallback: check settings for legacy format
|
|
18
32
|
const settings = ctx.jsonFile('.claude/settings.local.json') || ctx.jsonFile('.claude/settings.json');
|
|
19
|
-
|
|
33
|
+
if (settings && settings.mcpServers && Object.keys(settings.mcpServers).length > 0) return true;
|
|
34
|
+
// PP-06 recalibration: N/A on repos that don't reference MCP at all.
|
|
35
|
+
return _repoOptsInToMcp(ctx) ? false : null;
|
|
20
36
|
},
|
|
21
37
|
impact: 'medium',
|
|
22
38
|
rating: 3,
|
|
@@ -34,7 +50,9 @@ module.exports = {
|
|
|
34
50
|
if (mcpJson && mcpJson.mcpServers) count += Object.keys(mcpJson.mcpServers).length;
|
|
35
51
|
const settings = ctx.jsonFile('.claude/settings.local.json') || ctx.jsonFile('.claude/settings.json');
|
|
36
52
|
if (settings && settings.mcpServers) count += Object.keys(settings.mcpServers).length;
|
|
37
|
-
|
|
53
|
+
if (count >= 2) return true;
|
|
54
|
+
// PP-06 recalibration: N/A on repos that don't reference MCP at all.
|
|
55
|
+
return _repoOptsInToMcp(ctx) ? false : null;
|
|
38
56
|
},
|
|
39
57
|
impact: 'medium',
|
|
40
58
|
rating: 4,
|
|
@@ -51,7 +69,10 @@ module.exports = {
|
|
|
51
69
|
const local = ctx.jsonFile('.claude/settings.local.json') || {};
|
|
52
70
|
const mcp = ctx.jsonFile('.mcp.json') || {};
|
|
53
71
|
const all = { ...(shared.mcpServers || {}), ...(local.mcpServers || {}), ...(mcp.mcpServers || {}) };
|
|
54
|
-
if (Object.keys(all).length === 0)
|
|
72
|
+
if (Object.keys(all).length === 0) {
|
|
73
|
+
// PP-06 recalibration: N/A on repos that don't reference MCP at all.
|
|
74
|
+
return _repoOptsInToMcp(ctx) ? false : null;
|
|
75
|
+
}
|
|
55
76
|
return Object.keys(all).some(k => /context7/i.test(k));
|
|
56
77
|
},
|
|
57
78
|
impact: 'medium',
|