@nerviq/cli 1.10.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/activity.js CHANGED
@@ -136,6 +136,37 @@ function summarizeSnapshot(snapshotKind, payload) {
136
136
  return {};
137
137
  }
138
138
 
139
+ function normalizeSnapshotTags(input) {
140
+ const values = Array.isArray(input) ? input : (input ? [input] : []);
141
+ const seen = new Set();
142
+ const tags = [];
143
+
144
+ for (const value of values) {
145
+ const parts = `${value || ''}`
146
+ .split(',')
147
+ .map((item) => item.replace(/\s+/g, ' ').trim())
148
+ .filter(Boolean);
149
+
150
+ for (const part of parts) {
151
+ const key = part.toLowerCase();
152
+ if (seen.has(key)) continue;
153
+ seen.add(key);
154
+ tags.push(part.slice(0, 48));
155
+ if (tags.length >= 8) {
156
+ return tags;
157
+ }
158
+ }
159
+ }
160
+
161
+ return tags;
162
+ }
163
+
164
+ function formatSnapshotTags(tags = []) {
165
+ const normalized = normalizeSnapshotTags(tags);
166
+ if (normalized.length === 0) return '';
167
+ return ` [${normalized.join(', ')}]`;
168
+ }
169
+
139
170
  function updateSnapshotIndex(snapshotDir, record) {
140
171
  const indexPath = path.join(snapshotDir, 'index.json');
141
172
  let entries = [];
@@ -173,6 +204,11 @@ function writeSnapshotArtifact(dir, snapshotKind, payload, meta = {}) {
173
204
  const { snapshotDir } = ensureArtifactDirs(dir);
174
205
  const filePath = path.join(snapshotDir, `${id}-${snapshotKind}.json`);
175
206
  const summary = summarizeSnapshot(snapshotKind, payload);
207
+ const metaTags = normalizeSnapshotTags([
208
+ ...(Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : [])),
209
+ ...(meta.tag ? [meta.tag] : []),
210
+ ]);
211
+ const { tags: _ignoredTags, tag: _ignoredTag, ...restMeta } = meta;
176
212
  const envelope = {
177
213
  schemaVersion: 1,
178
214
  artifactType: 'snapshot',
@@ -183,7 +219,8 @@ function writeSnapshotArtifact(dir, snapshotKind, payload, meta = {}) {
183
219
  generatedBy: `nerviq@${version}`,
184
220
  directory: dir,
185
221
  summary,
186
- ...meta,
222
+ tags: metaTags,
223
+ ...restMeta,
187
224
  payload,
188
225
  };
189
226
 
@@ -194,6 +231,7 @@ function writeSnapshotArtifact(dir, snapshotKind, payload, meta = {}) {
194
231
  snapshotKind,
195
232
  createdAt: envelope.createdAt,
196
233
  relativePath: path.relative(dir, filePath),
234
+ tags: metaTags,
197
235
  summary,
198
236
  };
199
237
  updateSnapshotIndex(snapshotDir, record);
@@ -236,6 +274,79 @@ function getHistory(dir, limit = 20) {
236
274
  .slice(0, limit);
237
275
  }
238
276
 
277
+ function buildCheckDiffDetail(previousResult, currentResult) {
278
+ const source = currentResult || previousResult || {};
279
+ const previousState = previousResult ? previousResult.passed : undefined;
280
+ const currentState = currentResult ? currentResult.passed : undefined;
281
+ return {
282
+ key: source.key,
283
+ name: source.name || source.key,
284
+ impact: source.impact || null,
285
+ category: source.category || null,
286
+ previousState,
287
+ currentState,
288
+ };
289
+ }
290
+
291
+ function collectCheckDiff(previousResults = [], currentResults = []) {
292
+ const prevMap = new Map();
293
+ const currMap = new Map();
294
+
295
+ for (const result of previousResults) {
296
+ if (result && result.key) prevMap.set(result.key, result);
297
+ }
298
+ for (const result of currentResults) {
299
+ if (result && result.key) currMap.set(result.key, result);
300
+ }
301
+
302
+ const regressions = [];
303
+ const improvements = [];
304
+ const newlyApplicable = [];
305
+ const noLongerApplicable = [];
306
+ const newChecks = [];
307
+ const removedChecks = [];
308
+
309
+ const allKeys = [...new Set([...prevMap.keys(), ...currMap.keys()])].sort();
310
+ for (const key of allKeys) {
311
+ const previousResult = prevMap.get(key);
312
+ const currentResult = currMap.get(key);
313
+ const previousState = previousResult ? previousResult.passed : undefined;
314
+ const currentState = currentResult ? currentResult.passed : undefined;
315
+ const detail = buildCheckDiffDetail(previousResult, currentResult);
316
+
317
+ if (!previousResult) {
318
+ if (currentState !== undefined) {
319
+ newChecks.push(detail);
320
+ }
321
+ continue;
322
+ }
323
+
324
+ if (!currentResult) {
325
+ removedChecks.push(detail);
326
+ continue;
327
+ }
328
+
329
+ if (previousState === true && currentState === false) {
330
+ regressions.push(detail);
331
+ } else if (previousState === false && currentState === true) {
332
+ improvements.push(detail);
333
+ } else if ((previousState === null || previousState === undefined) && (currentState === true || currentState === false)) {
334
+ newlyApplicable.push(detail);
335
+ } else if ((currentState === null || currentState === undefined) && (previousState === true || previousState === false)) {
336
+ noLongerApplicable.push(detail);
337
+ }
338
+ }
339
+
340
+ return {
341
+ regressions,
342
+ improvements,
343
+ newlyApplicable,
344
+ noLongerApplicable,
345
+ newChecks,
346
+ removedChecks,
347
+ };
348
+ }
349
+
239
350
  /**
240
351
  * Compare the two most recent audit snapshots and return the delta.
241
352
  * @param {string} dir - Project root directory.
@@ -247,6 +358,8 @@ function compareLatest(dir) {
247
358
 
248
359
  const current = audits[0];
249
360
  const previous = audits[1];
361
+ const currentPayload = loadSnapshotPayload(dir, current);
362
+ const previousPayload = loadSnapshotPayload(dir, previous);
250
363
 
251
364
  const delta = {
252
365
  score: (current.summary?.score || 0) - (previous.summary?.score || 0),
@@ -254,26 +367,64 @@ function compareLatest(dir) {
254
367
  passed: (current.summary?.passed || 0) - (previous.summary?.passed || 0),
255
368
  };
256
369
 
257
- const regressions = [];
258
- const improvements = [];
259
-
260
- const prevKeys = new Set(previous.summary?.topActionKeys || []);
261
- const currKeys = new Set(current.summary?.topActionKeys || []);
262
-
263
- for (const key of currKeys) {
264
- if (!prevKeys.has(key)) regressions.push(key);
265
- }
266
- for (const key of prevKeys) {
267
- if (!currKeys.has(key)) improvements.push(key);
370
+ let regressionDetails = [];
371
+ let improvementDetails = [];
372
+ let newlyApplicableDetails = [];
373
+ let noLongerApplicableDetails = [];
374
+ let newChecks = [];
375
+ let removedChecks = [];
376
+ let regressions = [];
377
+ let improvements = [];
378
+ let detailedDiffAvailable = false;
379
+
380
+ if (currentPayload && previousPayload && Array.isArray(currentPayload.results) && Array.isArray(previousPayload.results)) {
381
+ const diff = collectCheckDiff(previousPayload.results, currentPayload.results);
382
+ regressionDetails = diff.regressions;
383
+ improvementDetails = diff.improvements;
384
+ newlyApplicableDetails = diff.newlyApplicable;
385
+ noLongerApplicableDetails = diff.noLongerApplicable;
386
+ newChecks = diff.newChecks;
387
+ removedChecks = diff.removedChecks;
388
+ regressions = regressionDetails.map((item) => item.key);
389
+ improvements = improvementDetails.map((item) => item.key);
390
+ detailedDiffAvailable = true;
391
+ } else {
392
+ const prevKeys = new Set(previous.summary?.topActionKeys || []);
393
+ const currKeys = new Set(current.summary?.topActionKeys || []);
394
+ for (const key of currKeys) {
395
+ if (!prevKeys.has(key)) regressions.push(key);
396
+ }
397
+ for (const key of prevKeys) {
398
+ if (!currKeys.has(key)) improvements.push(key);
399
+ }
268
400
  }
269
401
 
270
402
  return {
271
403
  scoreType: 'audit-snapshot-score',
272
- current: { date: current.createdAt, score: current.summary?.score, passed: current.summary?.passed, scoreType: 'audit-snapshot-score' },
273
- previous: { date: previous.createdAt, score: previous.summary?.score, passed: previous.summary?.passed, scoreType: 'audit-snapshot-score' },
404
+ current: {
405
+ date: current.createdAt,
406
+ score: current.summary?.score,
407
+ passed: current.summary?.passed,
408
+ tags: current.tags || [],
409
+ scoreType: 'audit-snapshot-score',
410
+ },
411
+ previous: {
412
+ date: previous.createdAt,
413
+ score: previous.summary?.score,
414
+ passed: previous.summary?.passed,
415
+ tags: previous.tags || [],
416
+ scoreType: 'audit-snapshot-score',
417
+ },
274
418
  delta,
275
419
  regressions,
276
420
  improvements,
421
+ regressionDetails,
422
+ improvementDetails,
423
+ newlyApplicableDetails,
424
+ noLongerApplicableDetails,
425
+ newChecks,
426
+ removedChecks,
427
+ detailedDiffAvailable,
277
428
  trend: delta.score > 0 ? 'improving' : delta.score < 0 ? 'regressing' : 'stable',
278
429
  };
279
430
  }
@@ -303,13 +454,13 @@ function formatSnapshotBootstrap(dir, goal = 'history') {
303
454
 
304
455
  if (snapshotCount === 0) {
305
456
  lines.push(' Bootstrap it with:');
306
- lines.push(' 1. Run `nerviq audit --snapshot` to save the baseline.');
457
+ lines.push(' 1. Run `nerviq audit --snapshot --tag "baseline"` to save the baseline.');
307
458
  lines.push(' 2. Make a meaningful repo change (`nerviq setup --auto` or `nerviq fix --all-critical --auto`).');
308
- lines.push(' 3. Run `nerviq audit --snapshot` again to capture the next state.');
459
+ lines.push(' 3. Run `nerviq audit --snapshot --tag "after-change"` to capture the next state.');
309
460
  } else {
310
461
  lines.push(' Next:');
311
462
  lines.push(' 1. Make a meaningful repo change (`nerviq setup --auto` or `nerviq fix --all-critical --auto`).');
312
- lines.push(' 2. Run `nerviq audit --snapshot` again.');
463
+ lines.push(' 2. Run `nerviq audit --snapshot --tag "after-change"` again.');
313
464
  }
314
465
 
315
466
  if (goal === 'compare') {
@@ -340,7 +491,7 @@ function formatHistory(dir) {
340
491
  const score = entry.summary?.score ?? '?';
341
492
  const passed = entry.summary?.passed ?? '?';
342
493
  const total = entry.summary?.checkCount ?? '?';
343
- lines.push(` ${dateDisplay} snapshot ${score}/100 (${passed}/${total} checks passing)`);
494
+ lines.push(` ${dateDisplay} snapshot${formatSnapshotTags(entry.tags)} ${score}/100 (${passed}/${total} checks passing)`);
344
495
  }
345
496
 
346
497
  const comparison = compareLatest(dir);
@@ -348,6 +499,9 @@ function formatHistory(dir) {
348
499
  lines.push('');
349
500
  const sign = comparison.delta.score >= 0 ? '+' : '';
350
501
  lines.push(` Latest snapshot trend: ${comparison.trend} (${sign}${comparison.delta.score} since previous snapshot)`);
502
+ if ((comparison.previous.tags || []).length > 0 || (comparison.current.tags || []).length > 0) {
503
+ lines.push(` Snapshot tags: previous${formatSnapshotTags(comparison.previous.tags)} -> current${formatSnapshotTags(comparison.current.tags)}`);
504
+ }
351
505
  if (comparison.improvements.length > 0) {
352
506
  lines.push(` Fixed: ${comparison.improvements.join(', ')}`);
353
507
  }
@@ -378,21 +532,22 @@ function exportTrendReport(dir) {
378
532
  '',
379
533
  '## Audit Snapshot History',
380
534
  '',
381
- '| Date | Score | Passed | Checks |',
382
- '|------|-------|--------|--------|',
535
+ '| Date | Tags | Score | Passed | Checks |',
536
+ '|------|------|-------|--------|--------|',
383
537
  ];
384
538
 
385
539
  for (const entry of history) {
386
540
  const date = entry.createdAt?.split('T')[0] || '?';
387
- lines.push(`| ${date} | ${entry.summary?.score ?? '?'}/100 | ${entry.summary?.passed ?? '?'} | ${entry.summary?.checkCount ?? '?'} |`);
541
+ const tags = (entry.tags || []).length > 0 ? entry.tags.join(', ') : '-';
542
+ lines.push(`| ${date} | ${tags} | ${entry.summary?.score ?? '?'}/100 | ${entry.summary?.passed ?? '?'} | ${entry.summary?.checkCount ?? '?'} |`);
388
543
  }
389
544
 
390
545
  if (comparison) {
391
546
  lines.push('');
392
547
  lines.push('## Latest Comparison');
393
548
  lines.push('');
394
- lines.push(`- **Previous snapshot score:** ${comparison.previous.score}/100 (${comparison.previous.date?.split('T')[0]})`);
395
- lines.push(`- **Current snapshot score:** ${comparison.current.score}/100 (${comparison.current.date?.split('T')[0]})`);
549
+ lines.push(`- **Previous snapshot score:** ${comparison.previous.score}/100 (${comparison.previous.date?.split('T')[0]})${formatSnapshotTags(comparison.previous.tags)}`);
550
+ lines.push(`- **Current snapshot score:** ${comparison.current.score}/100 (${comparison.current.date?.split('T')[0]})${formatSnapshotTags(comparison.current.tags)}`);
396
551
  lines.push(`- **Snapshot delta:** ${comparison.delta.score >= 0 ? '+' : ''}${comparison.delta.score} points`);
397
552
  lines.push(`- **Trend:** ${comparison.trend}`);
398
553
  if (comparison.improvements.length > 0) lines.push(`- **Fixed:** ${comparison.improvements.join(', ')}`);
@@ -627,42 +782,11 @@ function checkHealth(dir) {
627
782
 
628
783
  const currentResults = currentPayload.results || [];
629
784
  const previousResults = previousPayload.results || [];
630
-
631
- // Build maps: key → passed (true/false/null)
632
- const prevMap = {};
633
- for (const r of previousResults) {
634
- if (r.key) prevMap[r.key] = r.passed;
635
- }
636
- const currMap = {};
637
- for (const r of currentResults) {
638
- if (r.key) currMap[r.key] = r.passed;
639
- }
640
-
641
- const regressions = []; // was passing → now failing
642
- const improvements = []; // was failing → now passing
643
- const newChecks = []; // not in previous
644
- const removedChecks = []; // not in current
645
-
646
- for (const r of currentResults) {
647
- if (!r.key) continue;
648
- const prev = prevMap[r.key];
649
- const curr = r.passed;
650
- if (prev === undefined) {
651
- if (curr !== null) newChecks.push({ key: r.key, name: r.name, impact: r.impact, passed: curr });
652
- continue;
653
- }
654
- if (prev === true && curr === false) {
655
- regressions.push({ key: r.key, name: r.name, impact: r.impact, category: r.category });
656
- } else if (prev === false && curr === true) {
657
- improvements.push({ key: r.key, name: r.name, impact: r.impact, category: r.category });
658
- }
659
- }
660
-
661
- for (const r of previousResults) {
662
- if (r.key && currMap[r.key] === undefined) {
663
- removedChecks.push({ key: r.key, name: r.name });
664
- }
665
- }
785
+ const diff = collectCheckDiff(previousResults, currentResults);
786
+ const regressions = diff.regressions;
787
+ const improvements = diff.improvements;
788
+ const newChecks = diff.newChecks;
789
+ const removedChecks = diff.removedChecks;
666
790
 
667
791
  // Detect potential platform format changes:
668
792
  // If 3+ checks in the same category regressed, flag it
@@ -855,6 +979,8 @@ module.exports = {
855
979
  writeActivityArtifact,
856
980
  writeRollbackArtifact,
857
981
  writeSnapshotArtifact,
982
+ normalizeSnapshotTags,
983
+ formatSnapshotTags,
858
984
  readSnapshotIndex,
859
985
  getHistory,
860
986
  compareLatest,
@@ -93,11 +93,11 @@ const PROPAGATION_CHECKLIST = [
93
93
  ];
94
94
 
95
95
  /**
96
- * Check release gate — are all P0 sources fresh?
97
- */
98
- function checkReleaseGate(overrides = {}) {
99
- const now = new Date();
100
- const results = P0_SOURCES.map(source => {
96
+ * Check release gate — are all P0 sources fresh?
97
+ */
98
+ function checkReleaseGate(overrides = {}) {
99
+ const now = new Date();
100
+ const results = P0_SOURCES.map(source => {
101
101
  const verifiedAt = overrides[source.key] || source.verifiedAt;
102
102
  if (!verifiedAt) {
103
103
  return { ...source, status: 'unverified', daysStale: null };
@@ -114,26 +114,29 @@ function checkReleaseGate(overrides = {}) {
114
114
  };
115
115
  });
116
116
 
117
- const allFresh = results.every(r => r.status === 'fresh');
118
-
119
- return {
120
- ready: allFresh,
121
- results,
122
- nerviqVersion: version,
123
- checkedAt: now.toISOString(),
124
- };
125
- }
126
-
127
- /**
128
- * Format release gate for display.
129
- */
130
- function formatReleaseGate(overrides = {}) {
131
- const gateResult = checkReleaseGate(overrides);
132
- const lines = [
133
- `Aider Release Freshness Gate (nerviq v${version})`,
134
- `Status: ${gateResult.ready ? 'READY' : 'NOT READY'}`,
135
- '',
136
- ];
117
+ const stale = results.filter((result) => result.status === 'stale' || result.status === 'unverified');
118
+ const fresh = results.filter((result) => result.status === 'fresh');
119
+ const allFresh = stale.length === 0;
120
+
121
+ return {
122
+ ready: allFresh,
123
+ stale,
124
+ fresh,
125
+ results,
126
+ nerviqVersion: version,
127
+ checkedAt: now.toISOString(),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Format release gate for display.
133
+ */
134
+ function formatReleaseGate(gateResult) {
135
+ const lines = [
136
+ `Aider Release Freshness Gate (nerviq v${version})`,
137
+ `Status: ${gateResult.ready ? 'READY' : 'NOT READY'}`,
138
+ '',
139
+ ];
137
140
 
138
141
  for (const result of gateResult.results) {
139
142
  const icon = result.status === 'fresh' ? '✓' : result.status === 'stale' ? '✗' : '?';
package/src/analyze.js CHANGED
@@ -11,6 +11,7 @@ const { STACKS } = require('./techniques');
11
11
  const { detectDomainPacks } = require('./domain-packs');
12
12
  const { detectCodexDomainPacks } = require('./codex/domain-packs');
13
13
  const { recommendMcpPacks } = require('./mcp-packs');
14
+ const { collectClaudeDenyRules } = require('./permission-rules');
14
15
 
15
16
  const COLORS = {
16
17
  reset: '\x1b[0m',
@@ -101,6 +102,7 @@ function collectClaudeAssets(ctx) {
101
102
  const sharedSettings = ctx.jsonFile('.claude/settings.json');
102
103
  const localSettings = ctx.jsonFile('.claude/settings.local.json');
103
104
  const settings = sharedSettings || localSettings || null;
105
+ const denyRules = collectClaudeDenyRules(ctx);
104
106
 
105
107
  const assetFiles = {
106
108
  claudeMd: ctx.fileContent('CLAUDE.md') ? 'CLAUDE.md' : (ctx.fileContent('.claude/CLAUDE.md') ? '.claude/CLAUDE.md' : null),
@@ -129,7 +131,7 @@ function collectClaudeAssets(ctx) {
129
131
  },
130
132
  permissions: settings && settings.permissions ? {
131
133
  defaultMode: settings.permissions.defaultMode || null,
132
- hasDenyRules: Array.isArray(settings.permissions.deny) && settings.permissions.deny.length > 0,
134
+ hasDenyRules: denyRules.length > 0,
133
135
  } : null,
134
136
  settingsSource: assetFiles.settings,
135
137
  summaryLine: `Commands: ${assetFiles.commands.length} | Rules: ${assetFiles.rules.length} | Hooks: ${assetFiles.hooks.length} | Agents: ${assetFiles.agents.length} | Skills: ${assetFiles.skills.length} | MCP servers: ${settings && settings.mcpServers ? Object.keys(settings.mcpServers).length : 0}`,
@@ -8,6 +8,8 @@ const {
8
8
  hasDocumentedVerificationGuidance,
9
9
  hasDocumentedTestCommand,
10
10
  } = require('./instruction-surfaces');
11
+ const { collectClaudeDenyRules } = require('./permission-rules');
12
+ const { containsEmbeddedSecret } = require('./secret-patterns');
11
13
 
12
14
  const ANTI_PATTERNS = [
13
15
  {
@@ -32,7 +34,7 @@ const ANTI_PATTERNS = [
32
34
  detect: (ctx) => {
33
35
  const settings = ctx.jsonFile('.claude/settings.json') || ctx.jsonFile('.claude/settings.local.json');
34
36
  if (!settings || !settings.permissions) return true;
35
- return !Array.isArray(settings.permissions.deny) || settings.permissions.deny.length === 0;
37
+ return collectClaudeDenyRules(ctx).length === 0;
36
38
  },
37
39
  },
38
40
  {
@@ -322,7 +324,7 @@ const ANTI_PATTERNS = [
322
324
  ];
323
325
  for (const file of hookFiles) {
324
326
  const content = ctx.fileContent(`.claude/hooks/${file}`) || '';
325
- if (secretPatterns.some(p => p.test(content))) {
327
+ if (secretPatterns.some(p => p.test(content)) || containsEmbeddedSecret(content)) {
326
328
  return true;
327
329
  }
328
330
  }