@nerviq/cli 1.26.0 → 1.27.1
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/CHANGELOG.md +1407 -0
- package/README.md +4 -4
- package/SECURITY.md +82 -0
- package/bin/cli.js +13 -1
- package/contracts/audit-webhook-event.schema.json +138 -0
- package/contracts/pack-contract.schema.json +15 -0
- package/contracts/technique-contract.schema.json +18 -0
- package/docs/ARCHITECTURE.md +74 -0
- package/docs/api-reference.md +356 -0
- package/docs/autofix.md +64 -0
- package/docs/bitbucket-pipe.yml +57 -0
- package/docs/case-studies.md +149 -0
- package/docs/category-definition-kit.md +56 -0
- package/docs/ci-integration.md +127 -0
- package/docs/claude-code-style.md +24 -0
- package/docs/claude-maintainer-ops.md +19 -0
- package/docs/external-validation.md +78 -0
- package/docs/first-tier-integration-gate.md +59 -0
- package/docs/getting-started.md +119 -0
- package/docs/gitlab-ci-template.yml +54 -0
- package/docs/index.html +597 -0
- package/docs/integration-contracts.md +287 -0
- package/docs/license-faq.md +53 -0
- package/docs/maintenance.md +155 -0
- package/docs/methodology.md +236 -0
- package/docs/new-platform-guide.md +202 -0
- package/docs/open-vsx-publishing.md +46 -0
- package/docs/platform-change-ingestion.md +46 -0
- package/docs/plugins.md +101 -0
- package/docs/pre-commit.md +58 -0
- package/docs/security-model.md +63 -0
- package/docs/shallow-risk.md +246 -0
- package/docs/versioning-policy.md +63 -0
- package/docs/why-nerviq.md +82 -0
- package/package.json +7 -2
- package/sdk/README.md +190 -0
- package/src/audit/layers.js +180 -179
- package/src/audit.js +118 -48
- package/src/codex/setup.js +3 -2
- package/src/formatters/csv.js +86 -85
- package/src/formatters/junit.js +123 -103
- package/src/formatters/markdown.js +164 -135
- package/src/gemini/setup.js +3 -2
- package/src/init.js +4 -3
- package/src/opencode/context.js +42 -3
- package/src/opencode/techniques.js +198 -142
- package/src/output-icons.js +44 -0
- package/src/setup/runtime.js +6 -5
- package/src/setup.js +4 -3
- package/src/shallow-risk/index.js +56 -0
- package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +50 -0
- package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +46 -0
- package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +46 -0
- package/src/shallow-risk/patterns/agent-config-missing-file.js +72 -0
- package/src/shallow-risk/patterns/agent-config-secret-literal.js +49 -0
- package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +34 -0
- package/src/shallow-risk/patterns/hook-script-missing.js +70 -0
- package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +52 -0
- package/src/shallow-risk/shared.js +520 -0
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.replace(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
else color = '
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
lines.push(
|
|
58
|
-
lines.push(
|
|
59
|
-
lines.push(`**
|
|
60
|
-
lines.push(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
lines.push('');
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
lines.push('
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
lines.push('');
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 ``;
|
|
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 };
|
package/src/gemini/setup.js
CHANGED
|
@@ -6,6 +6,7 @@ const { STACKS } = require('../techniques');
|
|
|
6
6
|
const { writeActivityArtifact, writeRollbackArtifact } = require('../activity');
|
|
7
7
|
const { GeminiProjectContext } = require('./context');
|
|
8
8
|
const { recommendGeminiMcpPacks, packToJson } = require('./mcp-packs');
|
|
9
|
+
const { icon } = require('../output-icons');
|
|
9
10
|
|
|
10
11
|
function detectScripts(ctx) {
|
|
11
12
|
const pkg = ctx.jsonFile('package.json');
|
|
@@ -622,14 +623,14 @@ async function setupGemini(options) {
|
|
|
622
623
|
const fullPath = path.join(options.dir, file.path);
|
|
623
624
|
if (fs.existsSync(fullPath)) {
|
|
624
625
|
preservedFiles.push(file.path);
|
|
625
|
-
log(` \x1b[2m
|
|
626
|
+
log(` \x1b[2m${icon('skip')} Skipped ${file.path} (already exists - your version is kept)\x1b[0m`);
|
|
626
627
|
continue;
|
|
627
628
|
}
|
|
628
629
|
|
|
629
630
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
630
631
|
fs.writeFileSync(fullPath, file.content, 'utf8');
|
|
631
632
|
writtenFiles.push(file.path);
|
|
632
|
-
log(` \x1b[32m
|
|
633
|
+
log(` \x1b[32m${icon('ok')}\x1b[0m Created ${file.path}`);
|
|
633
634
|
}
|
|
634
635
|
|
|
635
636
|
const skippedSet = new Set(preservedFiles);
|
package/src/init.js
CHANGED
|
@@ -6,6 +6,7 @@ const { audit } = require('./audit');
|
|
|
6
6
|
const { setup } = require('./setup');
|
|
7
7
|
const { ProjectContext } = require('./context');
|
|
8
8
|
const { STACKS } = require('./techniques');
|
|
9
|
+
const { icon } = require('./output-icons');
|
|
9
10
|
|
|
10
11
|
const PLATFORM_LABELS = {
|
|
11
12
|
claude: 'Claude Code',
|
|
@@ -136,10 +137,10 @@ async function runInit(dir, flags) {
|
|
|
136
137
|
});
|
|
137
138
|
|
|
138
139
|
for (const f of setupResult.writtenFiles) {
|
|
139
|
-
console.log(` ${green}
|
|
140
|
+
console.log(` ${green}${icon('ok')}${reset} Created ${f}`);
|
|
140
141
|
}
|
|
141
142
|
for (const f of setupResult.preservedFiles) {
|
|
142
|
-
console.log(` ${dim}
|
|
143
|
+
console.log(` ${dim}${icon('skip')} Kept ${f} (already exists)${reset}`);
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
// --- Run additional platform setups ---
|
|
@@ -153,7 +154,7 @@ async function runInit(dir, flags) {
|
|
|
153
154
|
mcpPacks: [],
|
|
154
155
|
});
|
|
155
156
|
for (const f of extraResult.writtenFiles) {
|
|
156
|
-
console.log(` ${green}
|
|
157
|
+
console.log(` ${green}${icon('ok')}${reset} Created ${f}`);
|
|
157
158
|
}
|
|
158
159
|
} catch {
|
|
159
160
|
// Platform setup not available, skip
|
package/src/opencode/context.js
CHANGED
|
@@ -203,12 +203,51 @@ class OpenCodeProjectContext extends ProjectContext {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
skillDirs() {
|
|
206
|
-
const
|
|
207
|
-
|
|
206
|
+
const names = new Set();
|
|
207
|
+
const roots = [
|
|
208
|
+
path.join('.opencode', 'skills'),
|
|
209
|
+
path.join('.opencode', 'skill'),
|
|
210
|
+
path.join('.claude', 'skills'),
|
|
211
|
+
path.join('.agents', 'skills'),
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
for (const root of roots) {
|
|
215
|
+
const fullRoot = path.join(this.dir, root);
|
|
216
|
+
for (const entry of listDirs(fullRoot)) {
|
|
217
|
+
if (this.fileContent(path.join(root, entry.name, 'SKILL.md'))) {
|
|
218
|
+
names.add(entry.name);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Legacy NERVIQ compatibility: older generated fixtures placed skills
|
|
224
|
+
// under .opencode/commands/<name>/SKILL.md before OpenCode's native
|
|
225
|
+
// .opencode/skills/ path was verified.
|
|
226
|
+
const legacyCommandsRoot = path.join(this.dir, '.opencode', 'commands');
|
|
227
|
+
for (const entry of listDirs(legacyCommandsRoot)) {
|
|
228
|
+
if (this.fileContent(path.join('.opencode', 'commands', entry.name, 'SKILL.md'))) {
|
|
229
|
+
names.add(entry.name);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return [...names];
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
skillMetadata(name) {
|
|
211
|
-
|
|
237
|
+
const candidates = [
|
|
238
|
+
path.join('.opencode', 'skills', name, 'SKILL.md'),
|
|
239
|
+
path.join('.opencode', 'skill', name, 'SKILL.md'),
|
|
240
|
+
path.join('.claude', 'skills', name, 'SKILL.md'),
|
|
241
|
+
path.join('.agents', 'skills', name, 'SKILL.md'),
|
|
242
|
+
path.join('.opencode', 'commands', name, 'SKILL.md'),
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
for (const candidate of candidates) {
|
|
246
|
+
const content = this.fileContent(candidate);
|
|
247
|
+
if (content) return content;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return null;
|
|
212
251
|
}
|
|
213
252
|
|
|
214
253
|
themeFiles() {
|