@nerviq/cli 1.5.2 → 1.6.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/README.md CHANGED
@@ -310,6 +310,7 @@ Every write command supports `--snapshot` for automatic backup before changes.
310
310
  - **npm**: [@nerviq/cli](https://www.npmjs.com/package/@nerviq/cli)
311
311
  - **GitHub**: [github.com/nerviq/nerviq](https://github.com/nerviq/nerviq)
312
312
  - **Website**: [nerviq.net](https://nerviq.net)
313
+ - **Discord**: [Join the community](https://discord.gg/nerviq)
313
314
 
314
315
  ## What Nerviq Is — and Isn't
315
316
 
package/bin/cli.js CHANGED
@@ -26,7 +26,7 @@ const COMMAND_ALIASES = {
26
26
  gov: 'governance',
27
27
  outcome: 'feedback',
28
28
  };
29
- const KNOWN_COMMANDS = ['audit', 'org', 'setup', 'augment', 'suggest-only', 'plan', 'apply', 'fix', 'governance', 'benchmark', 'deep-review', 'interactive', 'watch', 'badge', 'insights', 'history', 'compare', 'trend', 'scan', 'feedback', 'doctor', 'convert', 'migrate', 'catalog', 'certify', 'serve', 'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise', 'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export', 'freshness', 'help', 'version'];
29
+ const KNOWN_COMMANDS = ['audit', 'org', 'setup', 'augment', 'suggest-only', 'plan', 'apply', 'fix', 'governance', 'benchmark', 'deep-review', 'interactive', 'watch', 'badge', 'insights', 'history', 'compare', 'trend', 'scan', 'feedback', 'doctor', 'convert', 'migrate', 'catalog', 'certify', 'serve', 'check-health', 'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise', 'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export', 'freshness', 'help', 'version'];
30
30
 
31
31
  function levenshtein(a, b) {
32
32
  const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
@@ -305,6 +305,7 @@ const HELP = `
305
305
  nerviq setup Generate starter-safe baseline config files
306
306
  nerviq setup --auto Apply all generated files without prompts
307
307
  nerviq interactive Step-by-step guided wizard
308
+ nerviq check-health Detect regressions + platform format changes between snapshots
308
309
  nerviq doctor Self-diagnostics: Node, deps, freshness, platform detection
309
310
 
310
311
  FIX
@@ -1041,6 +1042,16 @@ async function main() {
1041
1042
  console.log(output);
1042
1043
  }
1043
1044
  process.exit(0);
1045
+ } else if (normalizedCommand === 'check-health') {
1046
+ const { checkHealth, formatCheckHealth } = require('../src/activity');
1047
+ const report = checkHealth(options.dir);
1048
+ if (options.json) {
1049
+ console.log(JSON.stringify(report, null, 2));
1050
+ } else {
1051
+ console.log('');
1052
+ console.log(formatCheckHealth(report));
1053
+ }
1054
+ process.exit(0);
1044
1055
  } else if (normalizedCommand === 'freshness') {
1045
1056
  const { TECHNIQUES } = require('../src/techniques');
1046
1057
  const stats = getVerificationStats();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.5.2",
3
+ "version": "1.6.1",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,431 checks across 8 platforms, 10 languages, and 62 domain packs. Audit, align, and amplify.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/activity.js CHANGED
@@ -533,6 +533,170 @@ function formatRecommendationOutcomeSummary(dir) {
533
533
  return lines.join('\n');
534
534
  }
535
535
 
536
+ /**
537
+ * Load the full payload of a snapshot by its index entry.
538
+ * @param {string} dir - Project root directory.
539
+ * @param {Object} indexEntry - Snapshot index entry with relativePath.
540
+ * @returns {Object|null} Full snapshot envelope, or null if unreadable.
541
+ */
542
+ function loadSnapshotPayload(dir, indexEntry) {
543
+ if (!indexEntry || !indexEntry.relativePath) return null;
544
+ const filePath = path.join(dir, indexEntry.relativePath);
545
+ try {
546
+ const envelope = JSON.parse(fs.readFileSync(filePath, 'utf8'));
547
+ return envelope.payload || null;
548
+ } catch {
549
+ return null;
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Analyze check health by comparing the two most recent audit snapshots.
555
+ * Detects checks that regressed (passed → failed), improved (failed → passed),
556
+ * and flags sudden drops that may indicate platform format changes.
557
+ *
558
+ * @param {string} dir - Project root directory.
559
+ * @returns {Object|null} Health report, or null if fewer than 2 audit snapshots exist.
560
+ */
561
+ function checkHealth(dir) {
562
+ const history = getHistory(dir, 2);
563
+ if (history.length < 2) return null;
564
+
565
+ const currentPayload = loadSnapshotPayload(dir, history[0]);
566
+ const previousPayload = loadSnapshotPayload(dir, history[1]);
567
+ if (!currentPayload || !previousPayload) return null;
568
+
569
+ const currentResults = currentPayload.results || [];
570
+ const previousResults = previousPayload.results || [];
571
+
572
+ // Build maps: key → passed (true/false/null)
573
+ const prevMap = {};
574
+ for (const r of previousResults) {
575
+ if (r.key) prevMap[r.key] = r.passed;
576
+ }
577
+ const currMap = {};
578
+ for (const r of currentResults) {
579
+ if (r.key) currMap[r.key] = r.passed;
580
+ }
581
+
582
+ const regressions = []; // was passing → now failing
583
+ const improvements = []; // was failing → now passing
584
+ const newChecks = []; // not in previous
585
+ const removedChecks = []; // not in current
586
+
587
+ for (const r of currentResults) {
588
+ if (!r.key) continue;
589
+ const prev = prevMap[r.key];
590
+ const curr = r.passed;
591
+ if (prev === undefined) {
592
+ if (curr !== null) newChecks.push({ key: r.key, name: r.name, impact: r.impact, passed: curr });
593
+ continue;
594
+ }
595
+ if (prev === true && curr === false) {
596
+ regressions.push({ key: r.key, name: r.name, impact: r.impact, category: r.category });
597
+ } else if (prev === false && curr === true) {
598
+ improvements.push({ key: r.key, name: r.name, impact: r.impact, category: r.category });
599
+ }
600
+ }
601
+
602
+ for (const r of previousResults) {
603
+ if (r.key && currMap[r.key] === undefined) {
604
+ removedChecks.push({ key: r.key, name: r.name });
605
+ }
606
+ }
607
+
608
+ // Detect potential platform format changes:
609
+ // If 3+ checks in the same category regressed, flag it
610
+ const regressionsByCategory = {};
611
+ for (const r of regressions) {
612
+ if (!regressionsByCategory[r.category]) regressionsByCategory[r.category] = [];
613
+ regressionsByCategory[r.category].push(r);
614
+ }
615
+ const platformAlerts = [];
616
+ for (const [cat, items] of Object.entries(regressionsByCategory)) {
617
+ if (items.length >= 3) {
618
+ platformAlerts.push({
619
+ category: cat,
620
+ regressionCount: items.length,
621
+ message: `${items.length} checks in '${cat}' regressed — possible platform format change`,
622
+ checks: items.map(i => i.key),
623
+ });
624
+ }
625
+ }
626
+
627
+ return {
628
+ currentDate: history[0].createdAt,
629
+ previousDate: history[1].createdAt,
630
+ scoreDelta: (currentPayload.score || 0) - (previousPayload.score || 0),
631
+ regressions,
632
+ improvements,
633
+ newChecks,
634
+ removedChecks,
635
+ platformAlerts,
636
+ summary: {
637
+ regressionsCount: regressions.length,
638
+ improvementsCount: improvements.length,
639
+ newChecksCount: newChecks.length,
640
+ removedChecksCount: removedChecks.length,
641
+ alertsCount: platformAlerts.length,
642
+ },
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Format check-health report for CLI display.
648
+ */
649
+ function formatCheckHealth(healthReport) {
650
+ if (!healthReport) return 'Need at least 2 audit snapshots. Run `nerviq audit --snapshot` twice.';
651
+
652
+ const lines = [];
653
+ const { scoreDelta, regressions, improvements, platformAlerts, newChecks, summary } = healthReport;
654
+ const sign = scoreDelta >= 0 ? '+' : '';
655
+
656
+ lines.push(` Check Health Report`);
657
+ lines.push(` ─────────────────────────────────────`);
658
+ lines.push(` Period: ${healthReport.previousDate?.split('T')[0]} → ${healthReport.currentDate?.split('T')[0]}`);
659
+ lines.push(` Score delta: ${sign}${scoreDelta}`);
660
+ lines.push('');
661
+
662
+ if (platformAlerts.length > 0) {
663
+ lines.push(` ⚠️ PLATFORM ALERTS (${platformAlerts.length})`);
664
+ for (const alert of platformAlerts) {
665
+ lines.push(` ${alert.message}`);
666
+ lines.push(` Checks: ${alert.checks.join(', ')}`);
667
+ }
668
+ lines.push('');
669
+ }
670
+
671
+ if (regressions.length > 0) {
672
+ lines.push(` 🔴 Regressions (${regressions.length} checks now failing)`);
673
+ for (const r of regressions) {
674
+ lines.push(` ${r.name} [${r.impact}]`);
675
+ }
676
+ lines.push('');
677
+ }
678
+
679
+ if (improvements.length > 0) {
680
+ lines.push(` ✅ Improvements (${improvements.length} checks now passing)`);
681
+ for (const r of improvements) {
682
+ lines.push(` ${r.name}`);
683
+ }
684
+ lines.push('');
685
+ }
686
+
687
+ if (newChecks.length > 0) {
688
+ lines.push(` 🆕 New checks (${newChecks.length})`);
689
+ lines.push('');
690
+ }
691
+
692
+ if (regressions.length === 0 && platformAlerts.length === 0) {
693
+ lines.push(` ✅ All checks stable. No regressions detected.`);
694
+ lines.push('');
695
+ }
696
+
697
+ return lines.join('\n');
698
+ }
699
+
536
700
  module.exports = {
537
701
  getUserId,
538
702
  ensureArtifactDirs,
@@ -550,4 +714,7 @@ module.exports = {
550
714
  getRecommendationOutcomeSummary,
551
715
  getRecommendationAdjustment,
552
716
  formatRecommendationOutcomeSummary,
717
+ checkHealth,
718
+ formatCheckHealth,
719
+ loadSnapshotPayload,
553
720
  };
package/src/audit.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  const path = require('path');
6
- const { TECHNIQUES: CLAUDE_TECHNIQUES, STACKS } = require('./techniques');
6
+ const { TECHNIQUES: CLAUDE_TECHNIQUES, STACKS, STACK_CATEGORY_DETECTORS } = require('./techniques');
7
7
  const { ProjectContext } = require('./context');
8
8
  const { CODEX_TECHNIQUES } = require('./codex/techniques');
9
9
  const { detectCodexDomainPacks } = require('./codex/domain-packs');
@@ -907,9 +907,9 @@ function printLiteAudit(result, dir) {
907
907
  return;
908
908
  }
909
909
 
910
- // Urgency summary line
911
- const criticalCount = (result.results || []).filter(r => !r.passed && r.impact === 'critical').length;
912
- const highCount = (result.results || []).filter(r => !r.passed && r.impact === 'high').length;
910
+ // Urgency summary line (only count actual failures, not skipped/null)
911
+ const criticalCount = (result.results || []).filter(r => r.passed === false && r.impact === 'critical').length;
912
+ const highCount = (result.results || []).filter(r => r.passed === false && r.impact === 'high').length;
913
913
  const mediumCount = result.failed - criticalCount - highCount;
914
914
  const urgencyParts = [];
915
915
  if (criticalCount > 0) urgencyParts.push(colorize(`🔴 ${criticalCount} critical`, 'red'));
@@ -964,8 +964,40 @@ async function audit(options) {
964
964
  ? mergePluginChecks(spec.techniques, plugins)
965
965
  : spec.techniques;
966
966
 
967
+ // Pre-compute which stack categories are active for this project
968
+ const activeStackCategories = new Set();
969
+ for (const [category, detector] of Object.entries(STACK_CATEGORY_DETECTORS)) {
970
+ if (detector(ctx)) activeStackCategories.add(category);
971
+ }
972
+
973
+ // Generic quality categories that are NOT about AI agent configuration.
974
+ // These are only included with --verbose or --full --verbose (deep quality mode).
975
+ const GENERIC_QUALITY_CATEGORIES = new Set([
976
+ 'observability', 'accessibility', 'i18n', 'privacy', 'error-tracking',
977
+ 'supply-chain', 'api-versioning', 'caching', 'rate-limiting', 'feature-flags',
978
+ 'docs-quality', 'monorepo', 'performance-budget', 'realtime', 'graphql',
979
+ 'testing-strategy', 'code-quality', 'api-design', 'database', 'authentication',
980
+ 'monitoring', 'dependency-management', 'cost-optimization',
981
+ ]);
982
+ const includeGenericQuality = options.verbose;
983
+
967
984
  // Run all technique checks
968
985
  for (const [key, technique] of Object.entries(techniques)) {
986
+ // Skip entire stack category if the stack is not detected at a core location
987
+ // Skip generic quality categories unless --verbose is set
988
+ const cat = technique.category;
989
+ if ((!includeGenericQuality && GENERIC_QUALITY_CATEGORIES.has(cat)) ||
990
+ (STACK_CATEGORY_DETECTORS[cat] && !activeStackCategories.has(cat))) {
991
+ results.push({
992
+ key,
993
+ ...technique,
994
+ file: null,
995
+ line: null,
996
+ passed: null, // not applicable
997
+ });
998
+ continue;
999
+ }
1000
+
969
1001
  const passed = technique.check(ctx);
970
1002
  const file = typeof technique.file === 'function' ? (technique.file(ctx) ?? null) : (technique.file ?? null);
971
1003
  const line = typeof technique.line === 'function' ? (technique.line(ctx) ?? null) : (technique.line ?? null);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "synced_from": "claudex",
3
- "synced_at": "2026-04-05T17:08:58Z",
3
+ "synced_at": "2026-04-06T08:01:13Z",
4
4
  "total_items": 1118,
5
5
  "tested": 948,
6
6
  "last_id": 1137
@@ -28,66 +28,21 @@ function resolveDir(options) {
28
28
 
29
29
  /**
30
30
  * Collect audit results from all detectable platforms.
31
- * This is a lightweight aggregation - each platform module is loaded lazily.
31
+ * Runs audit() for each platform in sequence (audit is async).
32
32
  */
33
- function collectPlatformAudits(dir) {
33
+ async function collectPlatformAudits(dir) {
34
+ const { audit } = require('../audit');
35
+ const platforms = ['claude', 'codex', 'gemini', 'copilot', 'cursor', 'windsurf', 'aider', 'opencode'];
34
36
  const results = [];
35
37
 
36
- // Try Claude audit
37
- try {
38
- const { audit } = require('../audit');
39
- const result = audit({ dir, silent: true, platform: 'claude' });
40
- if (result) results.push({ platform: 'claude', ...result });
41
- } catch (_e) { /* platform not available */ }
42
-
43
- // Try Codex audit
44
- try {
45
- const { audit } = require('../audit');
46
- const result = audit({ dir, silent: true, platform: 'codex' });
47
- if (result) results.push({ platform: 'codex', ...result });
48
- } catch (_e) { /* platform not available */ }
49
-
50
- // Try Gemini audit
51
- try {
52
- const { audit } = require('../audit');
53
- const result = audit({ dir, silent: true, platform: 'gemini' });
54
- if (result) results.push({ platform: 'gemini', ...result });
55
- } catch (_e) { /* platform not available */ }
56
-
57
- // Try Copilot audit
58
- try {
59
- const { audit } = require('../audit');
60
- const result = audit({ dir, silent: true, platform: 'copilot' });
61
- if (result) results.push({ platform: 'copilot', ...result });
62
- } catch (_e) { /* platform not available */ }
63
-
64
- // Try Cursor audit
65
- try {
66
- const { audit } = require('../audit');
67
- const result = audit({ dir, silent: true, platform: 'cursor' });
68
- if (result) results.push({ platform: 'cursor', ...result });
69
- } catch (_e) { /* platform not available */ }
70
-
71
- // Try Windsurf audit
72
- try {
73
- const { audit } = require('../audit');
74
- const result = audit({ dir, silent: true, platform: 'windsurf' });
75
- if (result) results.push({ platform: 'windsurf', ...result });
76
- } catch (_e) { /* platform not available */ }
77
-
78
- // Try Aider audit
79
- try {
80
- const { audit } = require('../audit');
81
- const result = audit({ dir, silent: true, platform: 'aider' });
82
- if (result) results.push({ platform: 'aider', ...result });
83
- } catch (_e) { /* platform not available */ }
84
-
85
- // Try OpenCode audit
86
- try {
87
- const { audit } = require('../audit');
88
- const result = audit({ dir, silent: true, platform: 'opencode' });
89
- if (result) results.push({ platform: 'opencode', ...result });
90
- } catch (_e) { /* platform not available */ }
38
+ for (const platform of platforms) {
39
+ try {
40
+ const result = await audit({ dir, silent: true, platform });
41
+ if (result && typeof result.score === 'number') {
42
+ results.push({ platform, ...result });
43
+ }
44
+ } catch (_e) { /* platform not available */ }
45
+ }
91
46
 
92
47
  return results;
93
48
  }
@@ -99,7 +54,7 @@ function collectPlatformAudits(dir) {
99
54
  */
100
55
  async function runHarmonyAudit(options) {
101
56
  const dir = resolveDir(options);
102
- const platformAudits = collectPlatformAudits(dir);
57
+ const platformAudits = await collectPlatformAudits(dir);
103
58
 
104
59
  if (options.json) {
105
60
  console.log(JSON.stringify({ dir, platforms: platformAudits }, null, 2));
@@ -147,7 +102,7 @@ async function runHarmonyAudit(options) {
147
102
  */
148
103
  async function runHarmonySync(options) {
149
104
  const dir = resolveDir(options);
150
- const platformAudits = collectPlatformAudits(dir);
105
+ const platformAudits = await collectPlatformAudits(dir);
151
106
 
152
107
  // Load or build canonical model from memory
153
108
  const state = loadHarmonyState(dir);
@@ -234,7 +189,7 @@ async function runHarmonyDrift(options) {
234
189
  */
235
190
  async function runHarmonyAdvise(options) {
236
191
  const dir = resolveDir(options);
237
- const platformAudits = collectPlatformAudits(dir);
192
+ const platformAudits = await collectPlatformAudits(dir);
238
193
  const state = loadHarmonyState(dir);
239
194
  const canonicalModel = state.canon || null;
240
195
 
@@ -309,7 +264,7 @@ async function runHarmonyWatch(options) {
309
264
  // Logged by watch module itself
310
265
  },
311
266
  runAudit: options.noAudit ? null : async (auditDir) => {
312
- const audits = collectPlatformAudits(auditDir);
267
+ const audits = await collectPlatformAudits(auditDir);
313
268
  const result = {};
314
269
  for (const audit of audits) {
315
270
  result[audit.platform] = audit;
@@ -326,7 +281,7 @@ async function runHarmonyWatch(options) {
326
281
  */
327
282
  async function runHarmonyGovernance(options) {
328
283
  const dir = resolveDir(options);
329
- const platformAudits = collectPlatformAudits(dir);
284
+ const platformAudits = await collectPlatformAudits(dir);
330
285
  const state = loadHarmonyState(dir);
331
286
  const canonicalModel = state.canon || null;
332
287
 
package/src/techniques.js CHANGED
@@ -95,6 +95,26 @@ function hasProjectFile(ctx, pattern) {
95
95
  return findProjectFiles(ctx, pattern).length > 0;
96
96
  }
97
97
 
98
+ /**
99
+ * Check if a stack-indicator file exists at a "core" location (root, src/, lib/, app/, packages/)
100
+ * rather than inside examples/, docs/, test/, vendor/, or deeply nested paths.
101
+ * This prevents false stack detection from example/demo code.
102
+ */
103
+ const EXCLUDED_STACK_DIRS = /^(examples?|docs?|test|tests|fixtures?|samples?|demo|vendor|third[_-]?party|\.github)\//i;
104
+
105
+ function hasCoreProjectFile(ctx, pattern) {
106
+ return findProjectFiles(ctx, pattern).some(file => !EXCLUDED_STACK_DIRS.test(file));
107
+ }
108
+
109
+ function hasCoreRootFile(ctx, pattern) {
110
+ // Only match files at project root (no / in path) or one level deep (src/X, lib/X, app/X)
111
+ return findProjectFiles(ctx, pattern).some(file => {
112
+ if (EXCLUDED_STACK_DIRS.test(file)) return false;
113
+ const depth = (file.match(/\//g) || []).length;
114
+ return depth <= 1;
115
+ });
116
+ }
117
+
98
118
  function readProjectFiles(ctx, pattern, limit = 60) {
99
119
  return findProjectFiles(ctx, pattern)
100
120
  .slice(0, limit)
@@ -105,40 +125,42 @@ function readProjectFiles(ctx, pattern, limit = 60) {
105
125
 
106
126
  function isPythonProject(ctx) {
107
127
  if (ctx.__nerviqIsPython !== undefined) return ctx.__nerviqIsPython;
128
+ // Require a Python config file (pyproject.toml, requirements.txt, setup.py) at a core location.
129
+ // Stray .py files in examples/ or docs/ don't make it a Python project.
108
130
  ctx.__nerviqIsPython =
109
- hasProjectFile(ctx, /(^|\/)(pyproject\.toml|requirements[^/]*\.txt|setup\.py)$/i) ||
110
- hasProjectFile(ctx, /\.py$/i);
131
+ hasCoreRootFile(ctx, /(^|\/)(pyproject\.toml|setup\.py|Pipfile)$/i) ||
132
+ hasCoreRootFile(ctx, /(^|\/)requirements\.txt$/i);
111
133
  return ctx.__nerviqIsPython;
112
134
  }
113
135
 
114
136
  function isGoProject(ctx) {
115
137
  if (ctx.__nerviqIsGo !== undefined) return ctx.__nerviqIsGo;
116
- ctx.__nerviqIsGo = hasProjectFile(ctx, /(^|\/)go\.mod$/i);
138
+ ctx.__nerviqIsGo = hasCoreRootFile(ctx, /(^|\/)go\.mod$/i);
117
139
  return ctx.__nerviqIsGo;
118
140
  }
119
141
 
120
142
  function isRustProject(ctx) {
121
143
  if (ctx.__nerviqIsRust !== undefined) return ctx.__nerviqIsRust;
122
- ctx.__nerviqIsRust = hasProjectFile(ctx, /(^|\/)Cargo\.toml$/i);
144
+ ctx.__nerviqIsRust = hasCoreRootFile(ctx, /(^|\/)Cargo\.toml$/i);
123
145
  return ctx.__nerviqIsRust;
124
146
  }
125
147
 
126
148
  function isJavaProject(ctx) {
127
149
  if (ctx.__nerviqIsJava !== undefined) return ctx.__nerviqIsJava;
128
- ctx.__nerviqIsJava = hasProjectFile(ctx, /(^|\/)(pom\.xml|build\.gradle|build\.gradle\.kts)$/i);
150
+ ctx.__nerviqIsJava = hasCoreRootFile(ctx, /(^|\/)(pom\.xml|build\.gradle|build\.gradle\.kts)$/i);
129
151
  return ctx.__nerviqIsJava;
130
152
  }
131
153
 
132
154
  function isFlutterProject(ctx) {
133
155
  if (ctx.__nerviqIsFlutter !== undefined) return ctx.__nerviqIsFlutter;
134
- ctx.__nerviqIsFlutter = hasProjectFile(ctx, /(^|\/)pubspec\.yaml$/i);
156
+ ctx.__nerviqIsFlutter = hasCoreRootFile(ctx, /(^|\/)pubspec\.yaml$/i);
135
157
  return ctx.__nerviqIsFlutter;
136
158
  }
137
159
 
138
160
  function isSwiftProject(ctx) {
139
161
  if (ctx.__nerviqIsSwift !== undefined) return ctx.__nerviqIsSwift;
140
- ctx.__nerviqIsSwift = hasProjectFile(ctx, /(^|\/)Package\.swift$/i) ||
141
- hasProjectFile(ctx, /\.xcodeproj/i);
162
+ ctx.__nerviqIsSwift = hasCoreRootFile(ctx, /(^|\/)Package\.swift$/i) ||
163
+ hasCoreProjectFile(ctx, /\.xcodeproj/i);
142
164
  return ctx.__nerviqIsSwift;
143
165
  }
144
166
 
@@ -149,6 +171,41 @@ function isKotlinProject(ctx) {
149
171
  return ctx.__nerviqIsKotlin;
150
172
  }
151
173
 
174
+ function isRubyProject(ctx) {
175
+ if (ctx.__nerviqIsRuby !== undefined) return ctx.__nerviqIsRuby;
176
+ ctx.__nerviqIsRuby = hasCoreRootFile(ctx, /(^|\/)Gemfile$/i);
177
+ return ctx.__nerviqIsRuby;
178
+ }
179
+
180
+ function isPhpProject(ctx) {
181
+ if (ctx.__nerviqIsPhp !== undefined) return ctx.__nerviqIsPhp;
182
+ ctx.__nerviqIsPhp = hasCoreRootFile(ctx, /(^|\/)composer\.json$/i);
183
+ return ctx.__nerviqIsPhp;
184
+ }
185
+
186
+ function isDotnetProject(ctx) {
187
+ if (ctx.__nerviqIsDotnet !== undefined) return ctx.__nerviqIsDotnet;
188
+ ctx.__nerviqIsDotnet = hasCoreProjectFile(ctx, /(^|\/)(.*\.csproj|.*\.sln|global\.json)$/i);
189
+ return ctx.__nerviqIsDotnet;
190
+ }
191
+
192
+ /**
193
+ * Map category names to their project detection function.
194
+ * Used by the audit to skip entire categories when the stack isn't detected.
195
+ */
196
+ const STACK_CATEGORY_DETECTORS = {
197
+ python: isPythonProject,
198
+ go: isGoProject,
199
+ rust: isRustProject,
200
+ java: isJavaProject,
201
+ flutter: isFlutterProject,
202
+ swift: isSwiftProject,
203
+ kotlin: isKotlinProject,
204
+ ruby: isRubyProject,
205
+ php: isPhpProject,
206
+ dotnet: isDotnetProject,
207
+ };
208
+
152
209
  function getPythonFiles(ctx) {
153
210
  if (ctx.__nerviqPythonFiles) return ctx.__nerviqPythonFiles;
154
211
  ctx.__nerviqPythonFiles = findProjectFiles(ctx, /\.py$/i);
@@ -5434,4 +5491,4 @@ const STACKS = {
5434
5491
 
5435
5492
  attachSourceUrls('claude', TECHNIQUES);
5436
5493
 
5437
- module.exports = { TECHNIQUES, STACKS, containsEmbeddedSecret };
5494
+ module.exports = { TECHNIQUES, STACKS, STACK_CATEGORY_DETECTORS, containsEmbeddedSecret };