@nerviq/cli 1.9.0 → 1.11.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/src/techniques.js CHANGED
@@ -4,8 +4,16 @@
4
4
  * Each technique includes: what to check, how to fix, impact level.
5
5
  */
6
6
 
7
- const fs = require('fs');
8
- const path = require('path');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { collectClaudeDenyRules } = require('./permission-rules');
10
+ const {
11
+ getClaudeInstructionBundle,
12
+ hasDocumentedVerificationGuidance,
13
+ hasDocumentedTestCommand,
14
+ hasDocumentedLintCommand,
15
+ hasDocumentedBuildCommand,
16
+ } = require('./instruction-surfaces');
9
17
 
10
18
  function hasFrontendSignals(ctx) {
11
19
  const pkg = ctx.fileContent('package.json') || '';
@@ -444,62 +452,58 @@ const TECHNIQUES = {
444
452
  // === QUALITY & TESTING (category: 'quality') ================
445
453
  // ============================================================
446
454
 
447
- verificationLoop: {
448
- id: 93,
449
- name: 'Verification criteria in CLAUDE.md',
450
- check: (ctx) => {
451
- const md = ctx.claudeMdContent() || '';
452
- return /\b(npm test|yarn test|pnpm test|pytest|go test|make test|npm run lint|yarn lint|npx |ruff |eslint)\b/i.test(md) ||
453
- /\b(test command|lint command|build command|verify|run tests|run lint)\b/i.test(md);
454
- },
455
- impact: 'critical',
456
- rating: 5,
457
- category: 'quality',
458
- fix: 'Add test/lint/build commands to CLAUDE.md so Claude can verify its own work.',
459
- template: null
460
- },
461
-
462
- testCommand: {
463
- id: 93001,
464
- name: 'CLAUDE.md contains a test command',
465
- check: (ctx) => {
466
- const md = ctx.claudeMdContent() || '';
467
- return /npm test|pytest|jest|vitest|cargo test|go test|mix test|rspec/.test(md);
468
- },
469
- impact: 'high',
470
- rating: 5,
471
- category: 'quality',
472
- fix: 'Add an explicit test command to CLAUDE.md (e.g. "Run `npm test` before committing").',
473
- template: null
474
- },
475
-
476
- lintCommand: {
477
- id: 93002,
478
- name: 'CLAUDE.md contains a lint command',
479
- check: (ctx) => {
480
- const md = ctx.claudeMdContent() || '';
481
- return /eslint|prettier|ruff|black|clippy|golangci-lint|rubocop|npm run lint|yarn lint|pnpm lint|bun lint/.test(md);
482
- },
483
- impact: 'high',
484
- rating: 4,
485
- category: 'quality',
486
- fix: 'Add a lint command to CLAUDE.md so Claude auto-formats and checks code style.',
487
- template: null
488
- },
489
-
490
- buildCommand: {
491
- id: 93003,
492
- name: 'CLAUDE.md contains a build command',
493
- check: (ctx) => {
494
- const md = ctx.claudeMdContent() || '';
495
- return /npm run build|cargo build|go build|make|tsc|gradle build|mvn compile/.test(md);
496
- },
497
- impact: 'medium',
498
- rating: 4,
499
- category: 'quality',
500
- fix: 'Add a build command to CLAUDE.md so Claude can verify compilation before committing.',
501
- template: null
502
- },
455
+ verificationLoop: {
456
+ id: 93,
457
+ name: 'Claude instruction surfaces include verification criteria',
458
+ check: (ctx) => {
459
+ const docs = getClaudeInstructionBundle(ctx);
460
+ return hasDocumentedVerificationGuidance(docs);
461
+ },
462
+ impact: 'critical',
463
+ rating: 5,
464
+ category: 'quality',
465
+ fix: 'Add canonical test/lint/build commands to your Claude instruction surfaces (CLAUDE.md, imported docs, or .claude/commands) so Claude can verify its own work.',
466
+ template: null
467
+ },
468
+
469
+ testCommand: {
470
+ id: 93001,
471
+ name: 'Claude instruction surfaces include a test command',
472
+ check: (ctx) => {
473
+ return hasDocumentedTestCommand(getClaudeInstructionBundle(ctx));
474
+ },
475
+ impact: 'high',
476
+ rating: 5,
477
+ category: 'quality',
478
+ fix: 'Add an explicit test command to your Claude instruction surfaces (for example "Run `npm test` before committing").',
479
+ template: null
480
+ },
481
+
482
+ lintCommand: {
483
+ id: 93002,
484
+ name: 'Claude instruction surfaces include a lint command',
485
+ check: (ctx) => {
486
+ return hasDocumentedLintCommand(getClaudeInstructionBundle(ctx));
487
+ },
488
+ impact: 'high',
489
+ rating: 4,
490
+ category: 'quality',
491
+ fix: 'Add a lint command to your Claude instruction surfaces so Claude can check style and static quality automatically.',
492
+ template: null
493
+ },
494
+
495
+ buildCommand: {
496
+ id: 93003,
497
+ name: 'Claude instruction surfaces include a build command',
498
+ check: (ctx) => {
499
+ return hasDocumentedBuildCommand(getClaudeInstructionBundle(ctx));
500
+ },
501
+ impact: 'medium',
502
+ rating: 4,
503
+ category: 'quality',
504
+ fix: 'Add a build command to your Claude instruction surfaces so Claude can verify compilation before committing.',
505
+ template: null
506
+ },
503
507
 
504
508
  // ============================================================
505
509
  // === GIT SAFETY (category: 'git') ===========================
@@ -557,19 +561,19 @@ const TECHNIQUES = {
557
561
  template: null
558
562
  },
559
563
 
560
- noSecretsInClaude: {
561
- id: 1039,
562
- name: 'CLAUDE.md has no embedded API keys',
563
- check: (ctx) => {
564
- const md = ctx.claudeMdContent() || '';
565
- return !containsEmbeddedSecret(md);
566
- },
567
- impact: 'critical',
568
- rating: 5,
569
- category: 'git',
570
- fix: 'Remove API keys from CLAUDE.md. Use environment variables or .env files instead.',
571
- template: null
572
- },
564
+ noSecretsInClaude: {
565
+ id: 1039,
566
+ name: 'CLAUDE.md has no embedded secrets',
567
+ check: (ctx) => {
568
+ const md = ctx.claudeMdContent() || '';
569
+ return !containsEmbeddedSecret(md);
570
+ },
571
+ impact: 'critical',
572
+ rating: 5,
573
+ category: 'git',
574
+ fix: 'Remove hardcoded secrets, tokens, private keys, and connection strings from CLAUDE.md. Use environment variables or external secret stores instead.',
575
+ template: null
576
+ },
573
577
 
574
578
  // ============================================================
575
579
  // === WORKFLOW (category: 'workflow') =========================
@@ -714,16 +718,13 @@ const TECHNIQUES = {
714
718
  template: null
715
719
  },
716
720
 
717
- permissionDeny: {
718
- id: 2401,
719
- name: 'Deny rules configured in permissions',
720
- check: (ctx) => {
721
- const settings = ctx.jsonFile('.claude/settings.local.json') || ctx.jsonFile('.claude/settings.json');
722
- if (!settings || !settings.permissions) return false;
723
- const deny = settings.permissions.deny;
724
- return Array.isArray(deny) && deny.length > 0;
725
- },
726
- impact: 'high',
721
+ permissionDeny: {
722
+ id: 2401,
723
+ name: 'Deny rules configured in permissions',
724
+ check: (ctx) => {
725
+ return collectClaudeDenyRules(ctx).length > 0;
726
+ },
727
+ impact: 'high',
727
728
  rating: 5,
728
729
  category: 'security',
729
730
  fix: 'Add permissions.deny rules to block dangerous operations (e.g. rm -rf, dropping databases).',
@@ -751,18 +752,19 @@ const TECHNIQUES = {
751
752
  template: null
752
753
  },
753
754
 
754
- secretsProtection: {
755
- id: 1096,
756
- name: 'Secrets protection configured',
757
- check: (ctx) => {
758
- // Prefer shared settings.json (committed) over local override
759
- const settings = ctx.jsonFile('.claude/settings.json') || ctx.jsonFile('.claude/settings.local.json');
760
- if (!settings || !settings.permissions) return false;
761
- const deny = JSON.stringify(settings.permissions.deny || []);
762
- const hasDeny = deny.includes('.env') || deny.includes('secrets');
763
- // Fail if allow includes "*" (overly broad — bypasses deny rules)
764
- const allow = settings.permissions.allow || [];
765
- if (Array.isArray(allow) && allow.includes('*')) return false;
755
+ secretsProtection: {
756
+ id: 1096,
757
+ name: 'Secrets protection configured',
758
+ check: (ctx) => {
759
+ const shared = ctx.jsonFile('.claude/settings.json');
760
+ const local = ctx.jsonFile('.claude/settings.local.json');
761
+ const settings = shared || local;
762
+ if (!settings || !settings.permissions) return false;
763
+ const denyRules = collectClaudeDenyRules(ctx);
764
+ const hasDeny = denyRules.some((rule) => rule.protectsSecrets);
765
+ // Fail if allow includes "*" (overly broad — bypasses deny rules)
766
+ const allow = settings.permissions.allow || [];
767
+ if (Array.isArray(allow) && allow.includes('*')) return false;
766
768
  return hasDeny;
767
769
  },
768
770
  impact: 'critical',
@@ -1524,16 +1526,13 @@ const TECHNIQUES = {
1524
1526
  template: null
1525
1527
  },
1526
1528
 
1527
- denyRulesDepth: {
1528
- id: 2014,
1529
- name: 'Deny rules cover 3+ patterns',
1530
- check: (ctx) => {
1531
- const shared = ctx.jsonFile('.claude/settings.json');
1532
- const local = ctx.jsonFile('.claude/settings.local.json');
1533
- const deny = (shared?.permissions?.deny || []).concat(local?.permissions?.deny || []);
1534
- return deny.length >= 3;
1535
- },
1536
- impact: 'high', rating: 4, category: 'security',
1529
+ denyRulesDepth: {
1530
+ id: 2014,
1531
+ name: 'Deny rules cover 3+ patterns',
1532
+ check: (ctx) => {
1533
+ return collectClaudeDenyRules(ctx).length >= 3;
1534
+ },
1535
+ impact: 'high', rating: 4, category: 'security',
1537
1536
  fix: 'Add at least 3 deny rules: rm -rf, force-push, and .env reads. More patterns = safer Claude.',
1538
1537
  template: null
1539
1538
  },
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const TERMINOLOGY = {
4
+ governance: {
5
+ label: 'governance',
6
+ description: 'the rollout safety layer: permissions, hooks, profiles, and policy packs',
7
+ },
8
+ hooks: {
9
+ label: 'hooks',
10
+ description: 'auto-run checks or scripts triggered before or after agent tool actions',
11
+ },
12
+ denyRules: {
13
+ label: 'deny rules',
14
+ description: 'explicit blocks for risky reads or commands like .env access or rm -rf',
15
+ },
16
+ mcp: {
17
+ label: 'MCP',
18
+ description: 'live external tool connectors for docs, APIs, databases, and other systems',
19
+ },
20
+ };
21
+
22
+ const TERM_ORDER = ['governance', 'hooks', 'denyRules', 'mcp'];
23
+
24
+ function normalizeTermKeys(keys = []) {
25
+ const seen = new Set();
26
+ for (const key of keys) {
27
+ if (!TERMINOLOGY[key]) continue;
28
+ seen.add(key);
29
+ }
30
+ return TERM_ORDER.filter((key) => seen.has(key));
31
+ }
32
+
33
+ function collectAuditTerminology(result = {}) {
34
+ const terms = new Set();
35
+ const texts = [];
36
+
37
+ for (const item of result.topNextActions || []) {
38
+ texts.push(item.name || '', item.fix || '', item.why || '', item.module || '', ...(item.signals || []));
39
+ }
40
+
41
+ for (const item of result.results || []) {
42
+ if (item.passed === false) {
43
+ texts.push(item.key || '', item.name || '', item.fix || '', item.category || '');
44
+ }
45
+ }
46
+
47
+ const blob = texts.join('\n');
48
+ if (/\bhook/i.test(blob)) terms.add('hooks');
49
+ if (/\bdeny rules?\b|permissions?\.deny|bypasspermissions|\.env access|rm -rf/i.test(blob)) terms.add('denyRules');
50
+ if (/\bmcp\b|context7|external tool/i.test(blob)) terms.add('mcp');
51
+ if (/\bgovernance\b|policy pack|permission profile/i.test(blob) || terms.size > 0) terms.add('governance');
52
+
53
+ return normalizeTermKeys([...terms]);
54
+ }
55
+
56
+ function formatTerminologyLines(keys, options = {}) {
57
+ const normalized = normalizeTermKeys(keys);
58
+ if (normalized.length === 0) return [];
59
+ const title = options.title || ' Terms used here:';
60
+ const indent = options.indent || ' ';
61
+ const bullet = options.bullet || '-';
62
+
63
+ return [
64
+ title,
65
+ ...normalized.map((key) => `${indent}${bullet} ${TERMINOLOGY[key].label}: ${TERMINOLOGY[key].description}`),
66
+ ];
67
+ }
68
+
69
+ module.exports = {
70
+ TERMINOLOGY,
71
+ collectAuditTerminology,
72
+ formatTerminologyLines,
73
+ };
@@ -0,0 +1,35 @@
1
+ function splitIdentifierSegments(value) {
2
+ return String(value || '')
3
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
4
+ .replace(/[_./:-]+/g, ' ')
5
+ .split(/\s+/)
6
+ .filter(Boolean);
7
+ }
8
+
9
+ function estimateSegmentTokens(segment) {
10
+ const charCount = [...String(segment || '')].length;
11
+ return Math.max(1, Math.ceil(charCount / 4));
12
+ }
13
+
14
+ function estimateTokenCount(text) {
15
+ if (typeof text !== 'string' || !text) return 0;
16
+
17
+ const parts = text.match(/[\p{L}\p{N}_]+|[^\s]/gu) || [];
18
+ let total = 0;
19
+
20
+ for (const part of parts) {
21
+ if (/^[\p{L}\p{N}_]+$/u.test(part)) {
22
+ const segments = splitIdentifierSegments(part);
23
+ total += segments.reduce((sum, segment) => sum + estimateSegmentTokens(segment), 0);
24
+ continue;
25
+ }
26
+
27
+ total += 1;
28
+ }
29
+
30
+ return total;
31
+ }
32
+
33
+ module.exports = {
34
+ estimateTokenCount,
35
+ };
package/src/workspace.js CHANGED
@@ -166,6 +166,115 @@ function parseWorkspaceSelection(value) {
166
166
  return unique(String(value).split(',').map((item) => item.trim()).filter(Boolean));
167
167
  }
168
168
 
169
+ function summarizeAuditResult(result, scoreType, scope) {
170
+ return {
171
+ scope,
172
+ scoreType,
173
+ score: typeof result?.score === 'number' ? result.score : null,
174
+ passed: typeof result?.passed === 'number' ? result.passed : 0,
175
+ total: typeof result?.checkCount === 'number' ? result.checkCount : 0,
176
+ topAction: result?.topNextActions?.[0]?.name || null,
177
+ };
178
+ }
179
+
180
+ function summarizeWorkspaceEntry(result, workspacePath, absPath, platform) {
181
+ const stackKeys = (result.stacks || []).map((item) => item.key);
182
+ const stackLabels = (result.stacks || []).map((item) => item.label);
183
+ return {
184
+ name: path.basename(workspacePath),
185
+ workspace: workspacePath,
186
+ dir: absPath,
187
+ platform,
188
+ stackKeys,
189
+ stackLabels,
190
+ workspaceProfile: classifyWorkspaceProfile(stackKeys),
191
+ ...summarizeAuditResult(result, 'workspace-live-audit', 'workspace-package'),
192
+ };
193
+ }
194
+
195
+ function classifyWorkspaceProfile(stackKeys) {
196
+ const keys = new Set(Array.isArray(stackKeys) ? stackKeys : []);
197
+ const matchAny = (candidates) => candidates.some((candidate) => keys.has(candidate));
198
+
199
+ if (matchAny(['go'])) {
200
+ return { key: 'go-workspace', label: 'Go workspace' };
201
+ }
202
+ if (matchAny(['python', 'django', 'fastapi'])) {
203
+ return { key: 'python-workspace', label: 'Python workspace' };
204
+ }
205
+ if (matchAny(['dotnet'])) {
206
+ return { key: 'dotnet-workspace', label: '.NET workspace' };
207
+ }
208
+ if (matchAny(['java', 'spring'])) {
209
+ return { key: 'java-workspace', label: 'Java workspace' };
210
+ }
211
+ if (matchAny(['flutter', 'dart'])) {
212
+ return { key: 'flutter-workspace', label: 'Flutter workspace' };
213
+ }
214
+ if (matchAny(['swift'])) {
215
+ return { key: 'swift-workspace', label: 'Swift workspace' };
216
+ }
217
+ if (matchAny(['kotlin'])) {
218
+ return { key: 'kotlin-workspace', label: 'Kotlin workspace' };
219
+ }
220
+ if (matchAny(['react', 'nextjs', 'node', 'typescript', 'javascript', 'nestjs', 'vue', 'angular', 'svelte'])) {
221
+ return { key: 'node-workspace', label: 'Node / JS workspace' };
222
+ }
223
+
224
+ return { key: 'general-workspace', label: 'General workspace' };
225
+ }
226
+
227
+ function buildProfileBreakdown(results) {
228
+ const grouped = new Map();
229
+
230
+ for (const item of results) {
231
+ const profileKey = item.workspaceProfile?.key || 'general-workspace';
232
+ const profileLabel = item.workspaceProfile?.label || 'General workspace';
233
+ if (!grouped.has(profileKey)) {
234
+ grouped.set(profileKey, {
235
+ profileKey,
236
+ profileLabel,
237
+ scoreType: 'workspace-live-audit',
238
+ workspaceCount: 0,
239
+ workspaces: [],
240
+ stackLabels: new Set(),
241
+ scores: [],
242
+ totals: [],
243
+ });
244
+ }
245
+
246
+ const entry = grouped.get(profileKey);
247
+ entry.workspaceCount += 1;
248
+ entry.workspaces.push(item.workspace);
249
+ for (const label of item.stackLabels || []) {
250
+ entry.stackLabels.add(label);
251
+ }
252
+ if (typeof item.score === 'number') {
253
+ entry.scores.push(item.score);
254
+ }
255
+ if (typeof item.total === 'number') {
256
+ entry.totals.push(item.total);
257
+ }
258
+ }
259
+
260
+ return [...grouped.values()]
261
+ .map((entry) => ({
262
+ profileKey: entry.profileKey,
263
+ profileLabel: entry.profileLabel,
264
+ scoreType: 'workspace-live-audit',
265
+ workspaceCount: entry.workspaceCount,
266
+ averageScore: entry.scores.length > 0
267
+ ? Math.round(entry.scores.reduce((sum, value) => sum + value, 0) / entry.scores.length)
268
+ : 0,
269
+ averageTotal: entry.totals.length > 0
270
+ ? Math.round(entry.totals.reduce((sum, value) => sum + value, 0) / entry.totals.length)
271
+ : 0,
272
+ stackLabels: [...entry.stackLabels].sort(),
273
+ workspaces: entry.workspaces.sort(),
274
+ }))
275
+ .sort((left, right) => left.profileLabel.localeCompare(right.profileLabel));
276
+ }
277
+
169
278
  async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
170
279
  const { audit } = require('./audit');
171
280
  const rootDir = path.resolve(dir);
@@ -175,32 +284,43 @@ async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
175
284
  ? expandWorkspacePatterns(rootDir, selectedPatterns)
176
285
  : detectWorkspaces(rootDir);
177
286
  const results = [];
287
+ let rootGovernance;
288
+
289
+ try {
290
+ const rootResult = await audit({ dir: rootDir, platform, silent: true });
291
+ rootGovernance = summarizeAuditResult(rootResult, 'root-live-audit', 'root-governance');
292
+ } catch (error) {
293
+ rootGovernance = {
294
+ scope: 'root-governance',
295
+ scoreType: 'root-live-audit',
296
+ score: null,
297
+ passed: 0,
298
+ total: 0,
299
+ topAction: null,
300
+ error: error.message,
301
+ };
302
+ }
178
303
 
179
304
  for (const workspacePath of workspacePaths) {
180
305
  const absPath = path.join(rootDir, workspacePath);
181
306
  try {
182
307
  const result = await audit({ dir: absPath, platform, silent: true });
183
- results.push({
184
- name: path.basename(workspacePath),
185
- workspace: workspacePath,
186
- dir: absPath,
187
- platform,
188
- score: result.score,
189
- passed: result.passed,
190
- total: result.checkCount,
191
- topAction: result.topNextActions?.[0]?.name || null,
192
- result,
193
- });
308
+ results.push(summarizeWorkspaceEntry(result, workspacePath, absPath, platform));
194
309
  } catch (error) {
195
310
  results.push({
196
311
  name: path.basename(workspacePath),
197
312
  workspace: workspacePath,
198
313
  dir: absPath,
199
314
  platform,
315
+ scope: 'workspace-package',
316
+ scoreType: 'workspace-live-audit',
200
317
  score: null,
201
318
  passed: 0,
202
319
  total: 0,
203
320
  topAction: null,
321
+ stackKeys: [],
322
+ stackLabels: [],
323
+ workspaceProfile: { key: 'general-workspace', label: 'General workspace' },
204
324
  error: error.message,
205
325
  });
206
326
  }
@@ -210,17 +330,39 @@ async function auditWorkspaces(dir, workspaceGlobs, platform = 'claude') {
210
330
  const averageScore = validScores.length > 0
211
331
  ? Math.round(validScores.reduce((sum, value) => sum + value, 0) / validScores.length)
212
332
  : 0;
333
+ const maxScore = validScores.length > 0 ? Math.max(...validScores) : 0;
334
+ const minScore = validScores.length > 0 ? Math.min(...validScores) : 0;
335
+ const profileBreakdown = buildProfileBreakdown(results);
213
336
 
214
337
  return {
338
+ summaryType: 'monorepo-workspace-audit',
215
339
  rootDir,
216
340
  platform,
341
+ selectionMode: selectedPatterns.length > 0 ? 'explicit-patterns' : 'detected-workspaces',
217
342
  patterns: sourcePatterns,
343
+ rootGovernance,
344
+ workspaceAggregate: {
345
+ scope: 'workspace-aggregate',
346
+ scoreType: 'workspace-average-live-audit',
347
+ score: averageScore,
348
+ workspaceCount: workspacePaths.length,
349
+ maxScore,
350
+ minScore,
351
+ },
352
+ profileBreakdown,
353
+ scoreSemantics: {
354
+ rootGovernance: 'Root repo live audit for shared instructions, hooks, permissions, and top-level governance files.',
355
+ workspaceAggregate: 'Average of the selected workspace live audit scores. This is a package coverage rollup, not the root repo score.',
356
+ workspaceEntries: 'Each workspace row is a package-level live audit. Package scores can differ from the root governance score for legitimate reasons.',
357
+ workspaceProfiles: 'Workspace totals can differ because each package uses a stack-specific check profile based on detected languages and frameworks.',
358
+ },
218
359
  workspaces: results,
219
360
  detectedWorkspaces: workspacePaths,
220
361
  workspaceCount: workspacePaths.length,
362
+ averageScoreType: 'workspace-average-live-audit',
221
363
  averageScore,
222
- maxScore: validScores.length > 0 ? Math.max(...validScores) : 0,
223
- minScore: validScores.length > 0 ? Math.min(...validScores) : 0,
364
+ maxScore,
365
+ minScore,
224
366
  };
225
367
  }
226
368