@nerviq/cli 1.15.0 → 1.17.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 CHANGED
@@ -42,28 +42,34 @@ Nerviq audits, sets up, and governs AI coding agent configurations for **8 platf
42
42
 
43
43
  ## What Nerviq Does
44
44
 
45
- Nerviq scores your AI coding agent setup from 0 to 100, finds what's missing, and fixes it with rollback for every change.
45
+ Most repos use **more than one** AI coding agent Claude and Cursor, Copilot and Codex, Gemini and Windsurf. Their configs drift silently. Nerviq is the neutral control plane that detects that drift, scores it, and helps align it.
46
+
47
+ When your repo has 2+ platforms configured, `nerviq audit` leads with the Harmony Score — cross-platform alignment — before any single-platform results. Single-platform repos still get a normal per-platform audit.
46
48
 
47
49
  ```
48
- nerviq audit
49
- ═══════════════════════════════════════
50
- Detected: React, TypeScript, Docker
50
+ $ nerviq audit
51
+ Detected: Next.js + TypeScript + Claude + Cursor
52
+
53
+ Harmony Score: 78/100 — 3 drift issues across 2 platforms (Claude Code + Cursor)
54
+ Run `nerviq harmony-audit` for the full cross-platform report.
51
55
 
52
- ████████████████░░░░ 78/100
56
+ Platform: Claude Code
57
+ CLAUDE.md .............. 23/25 checks passed
58
+ .claude/settings.json .. 6/9 checks passed
53
59
 
54
- CLAUDE.md with architecture diagram
55
- Hooks (PreToolUse + PostToolUse)
56
- Custom skills (3 skills)
57
- ✅ MCP servers configured
60
+ Platform: Cursor
61
+ .cursor/rules/ ......... 14/16 checks passed
62
+ .cursorrules ........... 4/7 checks passed
58
63
 
59
- Top 3 Next Actions
60
- 1. Add verification commands to CLAUDE.md
61
- 2. Configure deny rules for dangerous operations
62
- 3. Add path-specific rules in .claude/rules/
64
+ Drift: trust-mode mismatch (Claude relaxed / Cursor strict)
65
+ MCP coverage gap 3 servers in Claude, 1 in Cursor
66
+ format-on-save hook missing from Cursor
63
67
 
64
- Next: nerviq setup
68
+ 3 suggestions → `nerviq harmony-sync` to align.
65
69
  ```
66
70
 
71
+ Single-platform repos still work the same way — the Harmony Score only appears when 2+ platforms are detected. Use `--no-harmony-first` to suppress it even on multi-platform repos.
72
+
67
73
  ## Quick Start
68
74
 
69
75
  ```bash
@@ -217,8 +223,8 @@ All successful operational responses are wrapped in a JSON envelope:
217
223
  {
218
224
  "data": {},
219
225
  "meta": {
220
- "version": "1.15.0",
221
- "timestamp": "2026-04-11T12:00:00.000Z"
226
+ "version": "1.17.0",
227
+ "timestamp": "2026-04-12T12:00:00.000Z"
222
228
  }
223
229
  }
224
230
  ```
package/bin/cli.js CHANGED
@@ -568,7 +568,8 @@ const HELP = `
568
568
  New here? Run: nerviq --beginner
569
569
 
570
570
  DISCOVER
571
- nerviq audit Quick scan: score + top 3 gaps (default)
571
+ nerviq audit Quick scan: score + top 3 gaps (Harmony-first when 2+ platforms detected)
572
+ nerviq audit --no-harmony-first Skip the cross-platform Harmony header
572
573
  nerviq audit --full Full audit with all checks, weakest areas, badge
573
574
  nerviq audit --platform X Audit specific platform (claude|codex|cursor|copilot|gemini|windsurf|aider|opencode)
574
575
  nerviq audit --json Machine-readable JSON output (for CI)
@@ -846,6 +847,7 @@ async function main() {
846
847
  historyView: flags.includes('--history'),
847
848
  compareView: flags.includes('--compare'),
848
849
  diffOnly: flags.includes('--diff-only'),
850
+ noHarmonyFirst: flags.includes('--no-harmony-first'),
849
851
  diffBase: parsed.diffBase || null,
850
852
  diffHead: parsed.diffHead || null,
851
853
  driftMode: parsed.driftMode || null,
@@ -2504,8 +2506,32 @@ async function main() {
2504
2506
  }
2505
2507
  process.exit(0);
2506
2508
  }
2509
+ // MOAT-01: Harmony-first default — when 2+ platforms and platform not explicit
2510
+ let harmonyFirstResult = null;
2511
+ if (!options.platformExplicit && !options.noHarmonyFirst && !options.diffOnly && !options.driftMode && !options.workspace) {
2512
+ const detected = detectPlatforms(options.dir) || [];
2513
+ if (detected.length >= 2) {
2514
+ try {
2515
+ const { harmonyAudit } = require('../src/harmony/audit');
2516
+ harmonyFirstResult = await harmonyAudit({ dir: options.dir, silent: true });
2517
+ if (!options.json && harmonyFirstResult) {
2518
+ const hs = harmonyFirstResult.harmonyScore;
2519
+ const driftCount = (harmonyFirstResult.drift && harmonyFirstResult.drift.drifts) ? harmonyFirstResult.drift.drifts.length : 0;
2520
+ const platformLabels = (harmonyFirstResult.activePlatforms || []).map(p => p.label || p.platform).join(' + ');
2521
+ const color = hs >= 70 ? '\x1b[32m' : hs >= 40 ? '\x1b[33m' : '\x1b[31m';
2522
+ const issueWord = driftCount === 1 ? 'issue' : 'issues';
2523
+ console.log('');
2524
+ console.log(`\x1b[1m Harmony Score: ${color}${hs}/100\x1b[0m — ${driftCount} drift ${issueWord} across ${detected.length} platforms (${platformLabels})`);
2525
+ console.log('\x1b[2m Run `nerviq harmony-audit` for the full cross-platform report. Use --no-harmony-first to hide.\x1b[0m');
2526
+ }
2527
+ } catch {
2528
+ harmonyFirstResult = null;
2529
+ }
2530
+ }
2531
+ }
2532
+
2507
2533
  let result;
2508
- const renderAuditJsonLocally = options.json && Boolean(options.driftMode);
2534
+ const renderAuditJsonLocally = options.json && (Boolean(options.driftMode) || Boolean(harmonyFirstResult));
2509
2535
  if (options.diffOnly) {
2510
2536
  const { getChangedFiles, buildDiffOnlyAuditView, printDiffOnlyAudit } = require('../src/diff-only');
2511
2537
  const fullResult = await audit({ ...options, silent: true });
@@ -2580,9 +2606,17 @@ async function main() {
2580
2606
  }
2581
2607
  }
2582
2608
  } else if (renderAuditJsonLocally) {
2609
+ const harmonyEnvelope = harmonyFirstResult ? {
2610
+ harmony: {
2611
+ score: harmonyFirstResult.harmonyScore,
2612
+ driftCount: (harmonyFirstResult.drift && harmonyFirstResult.drift.drifts) ? harmonyFirstResult.drift.drifts.length : 0,
2613
+ platforms: (harmonyFirstResult.activePlatforms || []).map(p => p.platform),
2614
+ },
2615
+ } : {};
2583
2616
  console.log(JSON.stringify({
2584
2617
  version,
2585
2618
  timestamp: new Date().toISOString(),
2619
+ ...harmonyEnvelope,
2586
2620
  ...result,
2587
2621
  }, null, 2));
2588
2622
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.15.0",
3
+ "version": "1.17.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": {
package/src/audit.js CHANGED
@@ -514,6 +514,42 @@ async function audit(options) {
514
514
  const recommendedDomainPacks = spec.platform === 'codex'
515
515
  ? detectCodexDomainPacks(ctx, stacks, getCodexDomainPackSignals(ctx))
516
516
  : [];
517
+
518
+ // FB-05: framework-aware fix rewriting — don't recommend `npm test` on a
519
+ // Python/Go/Rust-only repo. Only rewrites when Node/JS stacks are absent.
520
+ const stackKeys = new Set(stacks.map(s => s.key));
521
+ const hasNodeStack = stackKeys.has('node') || stackKeys.has('react') || stackKeys.has('vue') ||
522
+ stackKeys.has('nextjs') || stackKeys.has('angular') || stackKeys.has('svelte') ||
523
+ stackKeys.has('nestjs') || stackKeys.has('remix') || stackKeys.has('astro') ||
524
+ stackKeys.has('typescript') || stackKeys.has('deno') || stackKeys.has('bun');
525
+ if (!hasNodeStack) {
526
+ let preferredTest = null;
527
+ let preferredInstall = null;
528
+ if (stackKeys.has('python') || stackKeys.has('django') || stackKeys.has('fastapi')) {
529
+ preferredTest = 'pytest'; preferredInstall = 'pip install -r requirements.txt';
530
+ } else if (stackKeys.has('go')) {
531
+ preferredTest = 'go test ./...'; preferredInstall = 'go mod download';
532
+ } else if (stackKeys.has('rust')) {
533
+ preferredTest = 'cargo test'; preferredInstall = 'cargo fetch';
534
+ } else if (stackKeys.has('ruby')) {
535
+ preferredTest = 'bundle exec rspec'; preferredInstall = 'bundle install';
536
+ } else if (stackKeys.has('java') || stackKeys.has('kotlin')) {
537
+ preferredTest = './gradlew test'; preferredInstall = './gradlew build';
538
+ } else if (stackKeys.has('elixir')) {
539
+ preferredTest = 'mix test'; preferredInstall = 'mix deps.get';
540
+ } else if (stackKeys.has('dotnet')) {
541
+ preferredTest = 'dotnet test'; preferredInstall = 'dotnet restore';
542
+ }
543
+ if (preferredTest) {
544
+ for (const r of results) {
545
+ if (typeof r.fix !== 'string') continue;
546
+ if (/\bnpm\s+test\b/i.test(r.fix)) r.fix = r.fix.replace(/`npm\s+test`/gi, '`' + preferredTest + '`').replace(/\bnpm\s+test\b/gi, preferredTest);
547
+ if (/\bnpm\s+ci\b/i.test(r.fix) && preferredInstall) r.fix = r.fix.replace(/`npm\s+ci`/gi, '`' + preferredInstall + '`').replace(/\bnpm\s+ci\b/gi, preferredInstall);
548
+ if (/\bnpm\s+install\b/i.test(r.fix) && preferredInstall) r.fix = r.fix.replace(/`npm\s+install`/gi, '`' + preferredInstall + '`').replace(/\bnpm\s+install\b/gi, preferredInstall);
549
+ }
550
+ }
551
+ }
552
+
517
553
  const result = {
518
554
  platform: spec.platform,
519
555
  platformLabel: spec.platformLabel,
@@ -1,9 +1,9 @@
1
1
  const os = require('os');
2
- const path = require('path');
3
- const { EMBEDDED_SECRET_PATTERNS, containsEmbeddedSecret } = require('../secret-patterns');
4
- const { attachSourceUrls } = require('../source-urls');
5
- const { buildSupplementalChecks } = require('../supplemental-checks');
6
- const { resolveProjectStateReadPath } = require('../state-paths');
2
+ const path = require('path');
3
+ const { EMBEDDED_SECRET_PATTERNS, containsEmbeddedSecret } = require('../secret-patterns');
4
+ const { attachSourceUrls } = require('../source-urls');
5
+ const { buildSupplementalChecks } = require('../supplemental-checks');
6
+ const { resolveProjectStateReadPath } = require('../state-paths');
7
7
 
8
8
  const CODEX_SUPPLEMENTAL_SOURCE_URLS = {
9
9
  'testing-strategy': 'https://developers.openai.com/codex/cli',
@@ -157,7 +157,14 @@ function hasCommandMention(content, category) {
157
157
  }
158
158
 
159
159
  function agentsHasArchitecture(content) {
160
- return /```mermaid|flowchart\b|graph\s+(TD|LR|RL|BT)\b|##\s+Architecture\b|##\s+Project Map\b|##\s+Structure\b/i.test(content);
160
+ // Explicit architecture/structure markers
161
+ if (/```mermaid|flowchart\b|graph\s+(TD|LR|RL|BT)\b/i.test(content)) return true;
162
+ // Heading variants seen in real repos (openai-agents-python, etc.)
163
+ if (/^#{1,4}\s+(Architecture|Project Map|Structure|Project Structure( Guide)?|Repo Structure( & Important Files)?|Repository Layout|Codebase (Guide|Map|Overview|Structure)|Repo Map|Key Directories|Module Map|Directory Layout|Folder Structure|Package Structure)\b/im.test(content)) return true;
164
+ // Enumerated file/directory maps (3+ backtick-wrapped paths in a row)
165
+ const pathList = content.match(/^[-*]?\s*`[^`]+\/`?/gm);
166
+ if (pathList && pathList.length >= 3) return true;
167
+ return false;
161
168
  }
162
169
 
163
170
  function findFillerLine(content) {
@@ -345,7 +352,31 @@ function projectMcpServers(ctx) {
345
352
  return config.data.mcp_servers;
346
353
  }
347
354
 
355
+ function isSdkOrLibraryRepo(ctx) {
356
+ const pkg = ctx.fileContent('package.json');
357
+ if (pkg) {
358
+ try {
359
+ const parsed = JSON.parse(pkg);
360
+ // Library if it declares a main/exports/module entry AND no start script that runs a server
361
+ const hasEntry = parsed.main || parsed.exports || parsed.module;
362
+ const scripts = parsed.scripts || {};
363
+ const hasServerStart = /(node|uvicorn|gunicorn).*(server|app|index\.js)/i.test(JSON.stringify(scripts));
364
+ if (hasEntry && !hasServerStart) return true;
365
+ } catch { /* fall through */ }
366
+ }
367
+ const pyproject = ctx.fileContent('pyproject.toml') || '';
368
+ if (/\[project\][^\[]*\b(packages|py_modules)\s*=/i.test(pyproject) && !ctx.hasDir('app') && !ctx.hasDir('server')) return true;
369
+ // README signals
370
+ const readme = ctx.fileContent('README.md') || '';
371
+ if (/\b(npm install|pip install|pnpm add|yarn add)\b.*\b(this|the)? ?(package|library|sdk)/i.test(readme)) return true;
372
+ if (/^# .*\bSDK\b/im.test(readme)) return true;
373
+ return false;
374
+ }
375
+
348
376
  function repoNeedsExternalTools(ctx) {
377
+ // SDK/library repos document integrations but don't need project-scoped MCP.
378
+ if (isSdkOrLibraryRepo(ctx)) return false;
379
+
349
380
  const deps = ctx.projectDependencies ? Object.keys(ctx.projectDependencies()) : [];
350
381
  const depSet = new Set(deps);
351
382
  const files = new Set(ctx.files || []);
@@ -639,12 +670,27 @@ function skillArtifacts(ctx) {
639
670
  }));
640
671
  }
641
672
 
673
+ function extractFrontmatter(content) {
674
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
675
+ if (!match) return null;
676
+ const fm = {};
677
+ for (const line of match[1].split(/\r?\n/)) {
678
+ const m = line.match(/^([a-zA-Z_-]+)\s*:\s*(.+)$/);
679
+ if (m) fm[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
680
+ }
681
+ return fm;
682
+ }
683
+
642
684
  function extractSkillTitle(content) {
685
+ const fm = extractFrontmatter(content);
686
+ if (fm && fm.name) return fm.name;
643
687
  const match = content.match(/^#\s+(.+)$/m);
644
688
  return match ? match[1].trim() : '';
645
689
  }
646
690
 
647
691
  function extractSkillDescription(content) {
692
+ const fm = extractFrontmatter(content);
693
+ if (fm && fm.description) return fm.description;
648
694
  const lines = content.split(/\r?\n/);
649
695
  const meaningful = [];
650
696
  let seenHeading = false;
@@ -1116,8 +1162,16 @@ function requirementsTomlIssue(ctx) {
1116
1162
 
1117
1163
  function sharedOrManagedMachineSignals(ctx) {
1118
1164
  const docs = docsBundle(ctx);
1119
- return Boolean(ctx.fileContent('requirements.toml')) ||
1120
- /\bshared\b|\bmanaged\b|\badmin[- ]enforced\b|\bmulti-user\b|\benterprise\b|\bkiosk\b|\bvdi\b/i.test(docs);
1165
+ if (ctx.fileContent('requirements.toml')) return true;
1166
+ // Explicit workstation/admin context only — not generic "shared" or "managed" words.
1167
+ // Must match specific managed-device or multi-user terminology.
1168
+ if (/\bmanaged[- ](device|laptop|workstation|host)\b/i.test(docs)) return true;
1169
+ if (/\bshared[- ](workstation|host|laptop|machine|computer)\b/i.test(docs)) return true;
1170
+ if (/\bmulti-user[- ](host|machine|workstation)\b/i.test(docs)) return true;
1171
+ if (/\b(kiosk|vdi|virtual desktop)\b/i.test(docs)) return true;
1172
+ if (/\benterprise[- ](managed|deployment|workstation)\b/i.test(docs)) return true;
1173
+ if (/\badmin[- ]enforced\b/i.test(docs)) return true;
1174
+ return false;
1121
1175
  }
1122
1176
 
1123
1177
  function authCredentialsStoreIssue(ctx) {
@@ -3049,17 +3103,25 @@ const CODEX_TECHNIQUES = {
3049
3103
  id: 'CX-N03',
3050
3104
  name: 'Pack recommendations are grounded in detected signals',
3051
3105
  check: (ctx) => {
3052
- // This check validates that the project has enough signals for meaningful pack recommendation
3106
+ // Ecosystem-neutral grounding: any primary manifest counts.
3053
3107
  const agents = agentsContent(ctx);
3054
3108
  const config = ctx.configContent ? (ctx.configContent() || '') : (ctx.fileContent('.codex/config.toml') || '');
3055
- const hasPkg = Boolean(ctx.jsonFile('package.json'));
3109
+ const manifestFiles = [
3110
+ 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod',
3111
+ 'Gemfile', 'composer.json', 'pom.xml', 'build.gradle',
3112
+ 'flake.nix', 'shard.yml', 'mix.exs', 'rebar.config',
3113
+ 'Makefile', 'CMakeLists.txt', 'Package.swift', 'pubspec.yaml',
3114
+ ];
3115
+ const hasManifest = manifestFiles.some(f => ctx.files.includes(f));
3116
+ // If no signals at all, N/A rather than fail
3117
+ if (!agents && !config && !hasManifest) return null;
3056
3118
  // At least 2 signal sources for grounded recommendation
3057
- return [Boolean(agents), Boolean(config), hasPkg].filter(Boolean).length >= 2;
3119
+ return [Boolean(agents), Boolean(config), hasManifest].filter(Boolean).length >= 2;
3058
3120
  },
3059
3121
  impact: 'medium',
3060
3122
  rating: 3,
3061
3123
  category: 'pack-posture',
3062
- fix: 'Add package.json and AGENTS.md so pack recommendations can be grounded in real project signals.',
3124
+ fix: 'Add AGENTS.md and ensure a primary manifest (package.json, pyproject.toml, Cargo.toml, go.mod, etc.) is present so pack recommendations can be grounded in real project signals.',
3063
3125
  template: 'codex-agents-md',
3064
3126
  file: () => 'AGENTS.md',
3065
3127
  line: () => 1,
@@ -3098,15 +3160,15 @@ const CODEX_TECHNIQUES = {
3098
3160
  // CP-08: New checks (O. Repeat-Usage Hygiene)
3099
3161
  // =============================================
3100
3162
 
3101
- codexSnapshotRetention: {
3102
- id: 'CX-O01',
3103
- name: 'At least one prior audit snapshot exists for repeat-usage',
3104
- check: (ctx) => {
3105
- try {
3106
- const indexPath = resolveProjectStateReadPath(ctx.dir, 'snapshots', 'index.json');
3107
- const fs = require('fs');
3108
- if (!fs.existsSync(indexPath)) return null; // No snapshots yet, not a failure
3109
- const entries = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
3163
+ codexSnapshotRetention: {
3164
+ id: 'CX-O01',
3165
+ name: 'At least one prior audit snapshot exists for repeat-usage',
3166
+ check: (ctx) => {
3167
+ try {
3168
+ const indexPath = resolveProjectStateReadPath(ctx.dir, 'snapshots', 'index.json');
3169
+ const fs = require('fs');
3170
+ if (!fs.existsSync(indexPath)) return null; // No snapshots yet, not a failure
3171
+ const entries = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
3110
3172
  return Array.isArray(entries) && entries.length > 0;
3111
3173
  } catch {
3112
3174
  return null;
@@ -3121,15 +3183,15 @@ const CODEX_TECHNIQUES = {
3121
3183
  line: () => null,
3122
3184
  },
3123
3185
 
3124
- codexFeedbackLoopHealth: {
3125
- id: 'CX-O02',
3126
- name: 'Feedback loop is functional when feedback has been submitted',
3127
- check: (ctx) => {
3128
- try {
3129
- const indexPath = resolveProjectStateReadPath(ctx.dir, 'outcomes', 'index.json');
3130
- const fs = require('fs');
3131
- if (!fs.existsSync(indexPath)) return null; // No feedback yet, not a failure
3132
- const entries = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
3186
+ codexFeedbackLoopHealth: {
3187
+ id: 'CX-O02',
3188
+ name: 'Feedback loop is functional when feedback has been submitted',
3189
+ check: (ctx) => {
3190
+ try {
3191
+ const indexPath = resolveProjectStateReadPath(ctx.dir, 'outcomes', 'index.json');
3192
+ const fs = require('fs');
3193
+ if (!fs.existsSync(indexPath)) return null; // No feedback yet, not a failure
3194
+ const entries = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
3133
3195
  return Array.isArray(entries) && entries.length > 0;
3134
3196
  } catch {
3135
3197
  return null;
@@ -3144,15 +3206,15 @@ const CODEX_TECHNIQUES = {
3144
3206
  line: () => null,
3145
3207
  },
3146
3208
 
3147
- codexTrendDataAvailability: {
3148
- id: 'CX-O03',
3149
- name: 'Trend data is computable (2+ snapshots with compatible schemas)',
3150
- check: (ctx) => {
3151
- try {
3152
- const indexPath = resolveProjectStateReadPath(ctx.dir, 'snapshots', 'index.json');
3153
- const fs = require('fs');
3154
- if (!fs.existsSync(indexPath)) return null;
3155
- const entries = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
3209
+ codexTrendDataAvailability: {
3210
+ id: 'CX-O03',
3211
+ name: 'Trend data is computable (2+ snapshots with compatible schemas)',
3212
+ check: (ctx) => {
3213
+ try {
3214
+ const indexPath = resolveProjectStateReadPath(ctx.dir, 'snapshots', 'index.json');
3215
+ const fs = require('fs');
3216
+ if (!fs.existsSync(indexPath)) return null;
3217
+ const entries = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
3156
3218
  const audits = (Array.isArray(entries) ? entries : []).filter(e => e.snapshotKind === 'audit');
3157
3219
  return audits.length >= 2;
3158
3220
  } catch {
@@ -3342,8 +3404,25 @@ const CODEX_TECHNIQUES = {
3342
3404
 
3343
3405
  codexPythonFormatterConfigured: {
3344
3406
  id: 'CX-PY08',
3345
- name: 'Formatter configured (black / isort / ruff format)',
3346
- check: (ctx) => { const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f)); if (!hasPy) return null; const pp = ctx.fileContent('pyproject.toml') || ''; return /\[tool\.black|\[tool\.isort|\[tool\.ruff\.format/i.test(pp); },
3407
+ name: 'Formatter configured (black / isort / ruff / yapf)',
3408
+ check: (ctx) => {
3409
+ const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f));
3410
+ if (!hasPy) return null;
3411
+ const pp = ctx.fileContent('pyproject.toml') || '';
3412
+ // Explicit formatter sections
3413
+ if (/\[tool\.black|\[tool\.isort|\[tool\.ruff\.format|\[tool\.yapf|\[tool\.autopep8/i.test(pp)) return true;
3414
+ // Ruff config implies formatting capability in modern setups (ruff 0.3+)
3415
+ if (/\[tool\.ruff(\.lint)?\]/i.test(pp)) return true;
3416
+ // Standalone config files
3417
+ if (ctx.files.some(f => /^(ruff\.toml|\.ruff\.toml|\.isort\.cfg|\.yapfrc|setup\.cfg)$/i.test(f))) {
3418
+ const setupCfg = ctx.fileContent('setup.cfg') || '';
3419
+ if (/\[isort\]|\[yapf\]|\[flake8\]/i.test(setupCfg)) return true;
3420
+ if (f => /ruff|yapf|isort/i.test(f)) return true;
3421
+ }
3422
+ // Dev dependency signal
3423
+ if (/\b(black|isort|ruff|yapf|autopep8)\b/i.test(pp)) return true;
3424
+ return false;
3425
+ },
3347
3426
  impact: 'medium',
3348
3427
  category: 'python',
3349
3428
  fix: 'Configure black, isort, or ruff format in pyproject.toml.',
@@ -3365,7 +3444,24 @@ const CODEX_TECHNIQUES = {
3365
3444
  codexPythonFastapiEntryDocumented: {
3366
3445
  id: 'CX-PY10',
3367
3446
  name: 'FastAPI entry point documented if FastAPI project',
3368
- check: (ctx) => { const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f)); if (!hasPy) return null; const deps = (ctx.fileContent('pyproject.toml') || '') + (ctx.fileContent('requirements.txt') || ''); if (!/fastapi/i.test(deps)) return null; const docs = (ctx.claudeMdContent ? ctx.claudeMdContent() : ctx.fileContent('CLAUDE.md')) || ctx.fileContent('README.md') || ''; return /fastapi|uvicorn|app\.py|main\.py/i.test(docs); },
3447
+ check: (ctx) => {
3448
+ const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f));
3449
+ if (!hasPy) return null;
3450
+ const pp = ctx.fileContent('pyproject.toml') || '';
3451
+ const reqs = ctx.fileContent('requirements.txt') || '';
3452
+ // FastAPI only in dev/optional/example deps → N/A (SDK with example server)
3453
+ const inMain = /^\s*fastapi\b/im.test(reqs) ||
3454
+ /\[project\.dependencies\][\s\S]*?fastapi/i.test(pp) ||
3455
+ /\[tool\.poetry\.dependencies\][\s\S]*?fastapi/i.test(pp) ||
3456
+ /^\s*dependencies\s*=\s*\[[^\]]*"fastapi/im.test(pp);
3457
+ if (!inMain) return null;
3458
+ const docs = [
3459
+ ctx.claudeMdContent ? ctx.claudeMdContent() : ctx.fileContent('CLAUDE.md'),
3460
+ ctx.fileContent('README.md'),
3461
+ ctx.fileContent('AGENTS.md'),
3462
+ ].filter(Boolean).join('\n');
3463
+ return /fastapi|uvicorn|app\.py|main\.py/i.test(docs);
3464
+ },
3369
3465
  impact: 'high',
3370
3466
  category: 'python',
3371
3467
  fix: 'Document FastAPI entry point and how to run the development server.',
@@ -3376,7 +3472,18 @@ const CODEX_TECHNIQUES = {
3376
3472
  codexPythonMigrationsDocumented: {
3377
3473
  id: 'CX-PY11',
3378
3474
  name: 'Database migrations mentioned (alembic / Django migrations)',
3379
- check: (ctx) => { const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f)); if (!hasPy) return null; const docs = (ctx.claudeMdContent ? ctx.claudeMdContent() : ctx.fileContent('CLAUDE.md')) || ctx.fileContent('README.md') || ''; return /alembic|migrate|makemigrations|django.{0,10}migration/i.test(docs) || ctx.files.some(f => /alembic[.]ini$|alembic[/]/.test(f)); },
3475
+ check: (ctx) => {
3476
+ const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f));
3477
+ if (!hasPy) return null;
3478
+ // SDK/library repos don't need migration docs
3479
+ if (isSdkOrLibraryRepo(ctx)) return null;
3480
+ // Only applicable when repo actually uses a database
3481
+ const deps = (ctx.fileContent('pyproject.toml') || '') + (ctx.fileContent('requirements.txt') || '');
3482
+ const hasDb = /sqlalchemy|django|peewee|tortoise|asyncpg|psycopg|pymongo|pymysql|alembic/i.test(deps);
3483
+ if (!hasDb) return null;
3484
+ const docs = (ctx.claudeMdContent ? ctx.claudeMdContent() : ctx.fileContent('CLAUDE.md')) || ctx.fileContent('README.md') || '';
3485
+ return /alembic|migrate|makemigrations|django.{0,10}migration/i.test(docs) || ctx.files.some(f => /alembic[.]ini$|alembic[/]/.test(f));
3486
+ },
3380
3487
  impact: 'medium',
3381
3488
  category: 'python',
3382
3489
  fix: 'Document database migration workflow (alembic or Django migrations).',
@@ -3464,7 +3571,12 @@ const CODEX_TECHNIQUES = {
3464
3571
  codexPythonPackageStructure: {
3465
3572
  id: 'CX-PY19',
3466
3573
  name: 'Python package has proper structure (src/ layout or __init__.py)',
3467
- check: (ctx) => { const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f)); if (!hasPy) return null; return ctx.files.some(f => /src[/].*[/]__init__\.py$|^[^/]+[/]__init__\.py$/.test(f)); },
3574
+ check: (ctx) => {
3575
+ const hasPy = ctx.files.some(f => /pyproject\.toml$|requirements\.txt$|setup\.py$|manage\.py$/.test(f));
3576
+ if (!hasPy) return null;
3577
+ // Path-separator agnostic: match both forward and back slashes (Windows compat)
3578
+ return ctx.files.some(f => /(^|[/\\])src[/\\].*[/\\]__init__\.py$|^[^/\\]+[/\\]__init__\.py$/.test(f));
3579
+ },
3468
3580
  impact: 'medium',
3469
3581
  category: 'python',
3470
3582
  fix: 'Use src/ layout or ensure packages have __init__.py files.',
@@ -36,16 +36,36 @@ class CursorProjectContext extends ProjectContext {
36
36
  * NOTE: Subdirectories inside .cursor/rules/ are silently ignored by Cursor.
37
37
  */
38
38
  cursorRules() {
39
- const dir = path.join(this.dir, '.cursor', 'rules');
40
- const files = listFiles(dir, f => f.endsWith('.mdc'));
39
+ const fs = require('fs');
40
+ const rulesPath = path.join(this.dir, '.cursor', 'rules');
41
+ let dir = rulesPath;
42
+ let basePath = '.cursor/rules';
43
+
44
+ // File-redirect pattern: .cursor/rules is a file pointing to another path
45
+ // (e.g., cal.com uses agents/rules/ with .cursor/rules as a text pointer).
46
+ try {
47
+ const stat = fs.statSync(rulesPath);
48
+ if (stat.isFile()) {
49
+ const redirect = fs.readFileSync(rulesPath, 'utf8').trim();
50
+ if (redirect && redirect.length < 500) {
51
+ const resolved = path.resolve(path.dirname(rulesPath), redirect);
52
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
53
+ dir = resolved;
54
+ basePath = path.relative(this.dir, resolved).replace(/\\/g, '/');
55
+ }
56
+ }
57
+ }
58
+ } catch { /* .cursor/rules may not exist — fall through to empty list */ }
59
+
60
+ const files = listFiles(dir, f => f.endsWith('.mdc') || f.endsWith('.md'));
41
61
  return files.map(f => {
42
- const relPath = `.cursor/rules/${f}`;
62
+ const relPath = `${basePath}/${f}`;
43
63
  const content = this.fileContent(relPath);
44
64
  if (!content) return null;
45
65
  const parsed = parseMdc(content);
46
66
  const ruleType = detectRuleType(parsed.frontmatter);
47
67
  return {
48
- name: f.replace('.mdc', ''),
68
+ name: f.replace(/\.(mdc|md)$/, ''),
49
69
  path: relPath,
50
70
  frontmatter: parsed.frontmatter,
51
71
  body: parsed.body,
@@ -14,12 +14,12 @@
14
14
  const os = require('os');
15
15
  const path = require('path');
16
16
  const { CursorProjectContext } = require('./context');
17
- const { EMBEDDED_SECRET_PATTERNS, containsEmbeddedSecret } = require('../secret-patterns');
18
- const { attachSourceUrls } = require('../source-urls');
19
- const { buildStackChecks } = require('../stack-checks');
20
- const { isApiProject, isDatabaseProject, isAuthProject, isMonitoringRelevant } = require('../supplemental-checks');
21
- const { hasCostBudgetOrUsageTracking } = require('../cost-tracking');
22
- const { validateMdcFrontmatter, validateMcpEnvVars } = require('./config-parser');
17
+ const { EMBEDDED_SECRET_PATTERNS, containsEmbeddedSecret } = require('../secret-patterns');
18
+ const { attachSourceUrls } = require('../source-urls');
19
+ const { buildStackChecks } = require('../stack-checks');
20
+ const { isApiProject, isDatabaseProject, isAuthProject, isMonitoringRelevant } = require('../supplemental-checks');
21
+ const { hasCostBudgetOrUsageTracking } = require('../cost-tracking');
22
+ const { validateMdcFrontmatter, validateMcpEnvVars } = require('./config-parser');
23
23
 
24
24
  // ─── Shared helpers ─────────────────────────────────────────────────────────
25
25
 
@@ -180,6 +180,10 @@ const CURSOR_TECHNIQUES = {
180
180
  name: 'No .cursorrules without migration warning',
181
181
  check: (ctx) => {
182
182
  const hasLegacy = ctx.hasLegacyRules ? ctx.hasLegacyRules() : Boolean(ctx.fileContent('.cursorrules'));
183
+ const hasNewRules = ctx.cursorRules ? ctx.cursorRules().length > 0 : false;
184
+ const hasMcp = Boolean(ctx.fileContent('.cursor/mcp.json'));
185
+ // N/A when repo has no Cursor configuration at all — don't reward absence
186
+ if (!hasLegacy && !hasNewRules && !hasMcp) return null;
183
187
  return !hasLegacy;
184
188
  },
185
189
  impact: 'critical',
@@ -507,17 +511,21 @@ const CURSOR_TECHNIQUES = {
507
511
 
508
512
  cursorPrivacyMode: {
509
513
  id: 'CU-C01',
510
- name: 'Privacy Mode enabled (or explicitly documented as off)',
514
+ name: 'Privacy Mode documented in rules/docs',
511
515
  check: (ctx) => {
512
- // Cannot detect Privacy Mode from files (stored in SQLite state.vscdb)
513
- // Check if rules/docs document the privacy mode status
516
+ // Privacy Mode is an IDE setting stored in SQLite state.vscdb — not auditable
517
+ // from repo files. This check validates that the repo documents its stance.
518
+ const hasNewRules = ctx.cursorRules ? ctx.cursorRules().length > 0 : false;
519
+ const hasLegacy = ctx.hasLegacyRules ? ctx.hasLegacyRules() : Boolean(ctx.fileContent('.cursorrules'));
520
+ // N/A when no rules exist to check against
521
+ if (!hasNewRules && !hasLegacy) return null;
514
522
  const docs = docsBundle(ctx);
515
523
  return /privacy mode|zero.?retention|data retention|privacy.*enabled/i.test(docs);
516
524
  },
517
- impact: 'critical',
518
- rating: 5,
525
+ impact: 'low',
526
+ rating: 3,
519
527
  category: 'trust',
520
- fix: 'Privacy Mode is OFF by default — code is sent to all third-party providers (OpenAI, Anthropic, etc.) unless explicitly enabled. Enable in Cursor Settings → Privacy → Privacy Mode, or document the deliberate decision to keep it off.',
528
+ fix: 'Privacy Mode is OFF by default in Cursor — code is sent to providers unless enabled. Document your stance in a rules file so contributors know whether Privacy Mode is expected on/off. Enable in Cursor Settings → Privacy → Privacy Mode.',
521
529
  template: null,
522
530
  file: () => '.cursor/rules/',
523
531
  line: () => null,
@@ -2212,17 +2220,17 @@ const CURSOR_TECHNIQUES = {
2212
2220
  fix: 'Enable prompt caching in MCP configuration or document caching strategy for repeated prompts.',
2213
2221
  template: null, file: () => '.cursor/mcp.json', line: () => null,
2214
2222
  },
2215
- cursorCostBudgetDefined: {
2216
- id: 'CU-T48', name: 'AI cost budget or per-run usage tracking documented',
2217
- check: (ctx) => {
2218
- const docs = docsBundle(ctx) + (ctx.fileContent('CLAUDE.md') || '');
2219
- if (!docs.trim() && !hasCostBudgetOrUsageTracking('', ctx)) return null;
2220
- return hasCostBudgetOrUsageTracking(docs, ctx);
2221
- },
2222
- impact: 'low', rating: 2, category: 'cost-optimization',
2223
- fix: 'Document AI cost guardrails or per-run usage tracking in README.md or Cursor rules so spend is observable, not guessed.',
2224
- template: null, file: () => 'README.md', line: () => null,
2225
- },
2223
+ cursorCostBudgetDefined: {
2224
+ id: 'CU-T48', name: 'AI cost budget or per-run usage tracking documented',
2225
+ check: (ctx) => {
2226
+ const docs = docsBundle(ctx) + (ctx.fileContent('CLAUDE.md') || '');
2227
+ if (!docs.trim() && !hasCostBudgetOrUsageTracking('', ctx)) return null;
2228
+ return hasCostBudgetOrUsageTracking(docs, ctx);
2229
+ },
2230
+ impact: 'low', rating: 2, category: 'cost-optimization',
2231
+ fix: 'Document AI cost guardrails or per-run usage tracking in README.md or Cursor rules so spend is observable, not guessed.',
2232
+ template: null, file: () => 'README.md', line: () => null,
2233
+ },
2226
2234
 
2227
2235
  // ============================================================
2228
2236
  // === PYTHON STACK CHECKS (category: 'python') ===============