@nerviq/cli 1.8.7 → 1.8.8

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
@@ -347,16 +347,3 @@ If Nerviq helped you, consider giving it a ⭐ on [GitHub](https://github.com/ne
347
347
  | `BETA` | Works but has limited real-world testing. API may change |
348
348
  | `EXPERIMENTAL` | Early stage, static rules, results may vary |
349
349
 
350
- ## Previously nerviq-cli
351
-
352
- Nerviq was previously published as `nerviq-cli`. If you were using it:
353
-
354
- ```bash
355
- # Old
356
- npx nerviq-cli
357
-
358
- # New
359
- npx @nerviq/cli audit
360
- ```
361
-
362
- All features are preserved and expanded.
package/bin/cli.js CHANGED
@@ -285,6 +285,53 @@ function printWorkspaceSummary(summary, options) {
285
285
  console.log('');
286
286
  }
287
287
 
288
+ function printScanDetail(summary, options) {
289
+ if (options.json) {
290
+ console.log(JSON.stringify(summary, null, 2));
291
+ return;
292
+ }
293
+
294
+ console.log('');
295
+ console.log('\x1b[1m nerviq scan — per-repo comparison\x1b[0m');
296
+ console.log('\x1b[2m ═══════════════════════════════════════\x1b[0m');
297
+ console.log(` Platform: ${summary.platform} | Repos: ${summary.repoCount} | Average: \x1b[1m${summary.averageScore}/100\x1b[0m`);
298
+ console.log('');
299
+
300
+ for (const item of summary.repos) {
301
+ if (item.error) {
302
+ console.log(` \x1b[31m✗ ${item.name}\x1b[0m — ${item.error}`);
303
+ console.log('');
304
+ continue;
305
+ }
306
+ const scoreColor = item.score >= 80 ? '\x1b[32m' : item.score >= 50 ? '\x1b[33m' : '\x1b[31m';
307
+ console.log(` \x1b[1m${item.name}\x1b[0m ${scoreColor}${item.score}/100\x1b[0m (${item.passed}/${item.total} checks passed)`);
308
+
309
+ // Show per-category breakdown if result is available
310
+ if (item.result && item.result.results) {
311
+ const categories = {};
312
+ for (const r of item.result.results) {
313
+ const cat = r.category || 'other';
314
+ if (!categories[cat]) categories[cat] = { passed: 0, total: 0 };
315
+ categories[cat].total++;
316
+ if (r.passed) categories[cat].passed++;
317
+ }
318
+ const catEntries = Object.entries(categories).sort((a, b) => (a[1].passed / a[1].total) - (b[1].passed / b[1].total));
319
+ const catLine = catEntries.map(([cat, v]) => `${cat}: ${v.passed}/${v.total}`).join(' ');
320
+ console.log(` \x1b[2m${catLine}\x1b[0m`);
321
+ }
322
+
323
+ // Show top 3 gaps
324
+ if (item.result && item.result.topNextActions && item.result.topNextActions.length > 0) {
325
+ const gaps = item.result.topNextActions.slice(0, 3);
326
+ console.log(' Top gaps:');
327
+ for (const gap of gaps) {
328
+ console.log(` \x1b[33m→\x1b[0m ${gap.name || gap.key}${gap.impact ? ` \x1b[2m(+${gap.impact})\x1b[0m` : ''}`);
329
+ }
330
+ }
331
+ console.log('');
332
+ }
333
+ }
334
+
288
335
  function printOrgSummary(summary, options) {
289
336
  if (options.json) {
290
337
  console.log(JSON.stringify(summary, null, 2));
@@ -525,6 +572,21 @@ async function main() {
525
572
  }
526
573
  }
527
574
 
575
+ // Apply built-in governance profile (--profile flag) to audit options
576
+ if (parsed.profile && parsed.profile !== 'safe-write') {
577
+ const { getPermissionProfile } = require('../src/governance');
578
+ const govProfile = getPermissionProfile(parsed.profile);
579
+ if (govProfile) {
580
+ options.governanceProfile = govProfile;
581
+ if (govProfile.deny && govProfile.deny.length > 0) {
582
+ options.suppressedChecks = options.suppressedChecks || [];
583
+ }
584
+ if (!options.json) {
585
+ console.log(` Using governance profile: ${govProfile.label} (${govProfile.risk} risk)`);
586
+ }
587
+ }
588
+ }
589
+
528
590
  const SUPPORTED_PLATFORMS = ['claude', 'codex', 'gemini', 'copilot', 'cursor', 'windsurf', 'aider', 'opencode'];
529
591
  if (!SUPPORTED_PLATFORMS.includes(options.platform)) {
530
592
  console.error(`\n Error: Unsupported platform '${options.platform}'.`);
@@ -595,7 +657,7 @@ async function main() {
595
657
  // Harmony + Synergy (cross-platform)
596
658
  'harmony-audit', 'harmony-sync', 'harmony-drift', 'harmony-advise',
597
659
  'harmony-watch', 'harmony-governance', 'harmony-add', 'synergy-report', 'anti-patterns', 'rules-export',
598
- 'freshness', 'profile',
660
+ 'freshness', 'profile', 'migrate',
599
661
  ]);
600
662
 
601
663
  if (options.platform === 'codex') {
@@ -648,7 +710,7 @@ async function main() {
648
710
  process.exit(1);
649
711
  }
650
712
  const summary = await scanOrg(scanDirs, options.platform);
651
- printOrgSummary(summary, options);
713
+ printScanDetail(summary, options);
652
714
  if (options.threshold !== null && summary.averageScore < options.threshold) {
653
715
  process.exit(1);
654
716
  }
@@ -1341,11 +1403,39 @@ async function main() {
1341
1403
  process.exit(1);
1342
1404
  }
1343
1405
  const profile = loadProfile(options.dir, profileArg);
1406
+
1407
+ // Apply profile settings to .claude/settings.json
1408
+ const fs = require('fs');
1409
+ const settingsPath = require('path').join(options.dir, '.claude', 'settings.json');
1410
+ let settings = {};
1411
+ if (fs.existsSync(settingsPath)) {
1412
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
1413
+ }
1414
+ // Apply deny rules from governance profile if platforms include claude
1415
+ if (profile.platforms && profile.platforms.includes('claude')) {
1416
+ const { getPermissionProfile } = require('../src/governance');
1417
+ const govProfile = getPermissionProfile(profileArg);
1418
+ if (govProfile && govProfile.deny && govProfile.deny.length > 0) {
1419
+ settings.deny = govProfile.deny;
1420
+ }
1421
+ }
1422
+ // Apply threshold and suppressed checks
1423
+ if (profile.threshold != null) {
1424
+ settings.threshold = profile.threshold;
1425
+ }
1426
+ if (profile.suppressedChecks && profile.suppressedChecks.length > 0) {
1427
+ settings.suppressedChecks = profile.suppressedChecks;
1428
+ }
1429
+ const settingsDir = require('path').dirname(settingsPath);
1430
+ fs.mkdirSync(settingsDir, { recursive: true });
1431
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
1432
+
1344
1433
  if (options.json) {
1345
1434
  console.log(JSON.stringify(profile, null, 2));
1346
1435
  } else {
1347
1436
  console.log('');
1348
1437
  console.log(formatProfile(profile));
1438
+ console.log(`\n Settings applied to ${settingsPath}`);
1349
1439
  console.log('');
1350
1440
  }
1351
1441
  process.exit(0);
@@ -1404,7 +1494,8 @@ async function main() {
1404
1494
  const fixKey = parsed.extraArgs[0] || null;
1405
1495
  const allCritical = flags.includes('--all-critical');
1406
1496
  const promptOnly = flags.includes('--prompt');
1407
- const autoApply = options.auto || options.dryRun;
1497
+ const autoApply = options.auto;
1498
+ const isDryRun = options.dryRun;
1408
1499
 
1409
1500
  // Step 1: Run silent audit to find failed checks (only actual failures, not skipped/null)
1410
1501
  const auditResult = await audit({ dir: options.dir, silent: true, platform: options.platform });
@@ -1447,6 +1538,13 @@ async function main() {
1447
1538
  for (const entry of denyEntries) {
1448
1539
  if (!settings.permissions.deny.includes(entry)) settings.permissions.deny.push(entry);
1449
1540
  }
1541
+ // Remove overly broad allow:["*"] if present
1542
+ if (Array.isArray(settings.permissions.allow) && settings.permissions.allow.includes('*')) {
1543
+ settings.permissions.allow = settings.permissions.allow.filter(a => a !== '*');
1544
+ if (settings.permissions.allow.length === 0) {
1545
+ delete settings.permissions.allow;
1546
+ }
1547
+ }
1450
1548
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
1451
1549
  return true;
1452
1550
  },
@@ -1558,7 +1656,7 @@ async function main() {
1558
1656
  const predictedScore = maxScore > 0 ? Math.round((simulatedEarned / maxScore) * 100) : 0;
1559
1657
  const predictedDelta = predictedScore - preScore;
1560
1658
 
1561
- if (!autoApply) {
1659
+ if (!autoApply && !isDryRun) {
1562
1660
  console.log('');
1563
1661
  if (allCritical && fixableTargets.length > 1) {
1564
1662
  // Multi-fix summary
@@ -1607,9 +1705,9 @@ async function main() {
1607
1705
  const allCreatedFiles = [];
1608
1706
  const fixResults = []; // { key, name, status, delta }
1609
1707
 
1610
- if (!options.dryRun && targetKeys.length > 0) {
1611
- // Snapshot existing files for rollback
1612
- const snapshotFiles = {};
1708
+ const snapshotFiles = {};
1709
+ if (!isDryRun && targetKeys.length > 0) {
1710
+ // Snapshot existing files for rollback (before applying fixes)
1613
1711
  for (const key of targetKeys) {
1614
1712
  const technique = TECHNIQUES[key];
1615
1713
  if (technique && technique.template && technique.template.path) {
@@ -1619,14 +1717,6 @@ async function main() {
1619
1717
  }
1620
1718
  }
1621
1719
  }
1622
- const rollbackArtifact = writeRollbackArtifact(options.dir, {
1623
- sourcePlan: 'fix-batch',
1624
- preSnapshot: snapshotFiles,
1625
- createdFiles: [],
1626
- patchedFiles: Object.keys(snapshotFiles),
1627
- rollbackInstructions: ['Use nerviq rollback to undo these fixes'],
1628
- });
1629
- rollbackId = rollbackArtifact.id;
1630
1720
  }
1631
1721
 
1632
1722
  // Step 3b: Apply fixes sequentially with progress
@@ -1641,7 +1731,7 @@ async function main() {
1641
1731
  const progress = isBatch ? `${i + 1}/${targetKeys.length}: ` : '';
1642
1732
 
1643
1733
  if (technique && technique.template) {
1644
- if (options.dryRun) {
1734
+ if (isDryRun) {
1645
1735
  console.log(` [dry-run] Would fix: ${progress}${failedCheck.name} (${key})`);
1646
1736
  fixResults.push({ key, name: failedCheck.name, status: 'dry-run', delta: 0 });
1647
1737
  fixed++;
@@ -1671,7 +1761,7 @@ async function main() {
1671
1761
  }
1672
1762
  }
1673
1763
  } else if (INLINE_FIXERS[key]) {
1674
- if (options.dryRun) {
1764
+ if (isDryRun) {
1675
1765
  console.log(` [dry-run] Would fix: ${progress}${failedCheck.name} (${key})`);
1676
1766
  fixResults.push({ key, name: failedCheck.name, status: 'dry-run', delta: 0 });
1677
1767
  fixed++;
@@ -1716,26 +1806,33 @@ async function main() {
1716
1806
  }
1717
1807
 
1718
1808
  // Record accepted patterns for successfully fixed checks
1719
- if (!options.dryRun) {
1809
+ if (!isDryRun) {
1720
1810
  for (const key of targetKeys) {
1721
1811
  const fr = fixResults.find(r => r.key === key);
1722
1812
  recordPattern(options.dir, key, fr && fr.status === 'fixed' ? 'accepted' : 'rejected');
1723
1813
  }
1724
1814
  }
1725
1815
 
1726
- // Update rollback artifact with actual created files
1727
- if (!options.dryRun && rollbackId && allCreatedFiles.length > 0) {
1728
- const { ensureArtifactDirs } = require('../src/activity');
1729
- const { rollbackDir } = ensureArtifactDirs(options.dir);
1730
- const rbFiles = fs.readdirSync(rollbackDir).filter(f => f.includes(rollbackId));
1731
- if (rbFiles.length > 0) {
1732
- const rbPath = pathMod.join(rollbackDir, rbFiles[0]);
1733
- try {
1734
- const rbData = JSON.parse(fs.readFileSync(rbPath, 'utf8'));
1735
- rbData.createdFiles = allCreatedFiles;
1736
- fs.writeFileSync(rbPath, JSON.stringify(rbData, null, 2), 'utf8');
1737
- } catch { /* best effort */ }
1816
+ // Write rollback artifact AFTER fixes are applied (with actual file lists)
1817
+ if (!isDryRun && targetKeys.length > 0 && fixed > 0) {
1818
+ const allPatchedFiles = Object.keys(snapshotFiles);
1819
+ // Also track inline-fixer patched files
1820
+ for (const fr of fixResults) {
1821
+ if (fr.status === 'fixed' && INLINE_FIXERS[fr.key]) {
1822
+ const inlinePath = fr.key === 'gitIgnoreEnv' ? '.gitignore' : fr.key === 'secretsProtection' ? '.claude/settings.json' : null;
1823
+ if (inlinePath && !allPatchedFiles.includes(inlinePath)) {
1824
+ allPatchedFiles.push(inlinePath);
1825
+ }
1826
+ }
1738
1827
  }
1828
+ const rollbackArtifact = writeRollbackArtifact(options.dir, {
1829
+ sourcePlan: 'fix-batch',
1830
+ preSnapshot: snapshotFiles,
1831
+ createdFiles: allCreatedFiles,
1832
+ patchedFiles: allPatchedFiles,
1833
+ rollbackInstructions: ['Use nerviq rollback to undo these fixes'],
1834
+ });
1835
+ rollbackId = rollbackArtifact.id;
1739
1836
  }
1740
1837
 
1741
1838
  // Step 4: Show batch summary or simple score impact
@@ -1751,10 +1848,10 @@ async function main() {
1751
1848
  const totalDelta = runningScore - preScore;
1752
1849
  console.log('');
1753
1850
  console.log(` Score: ${preScore} → ${runningScore} (${totalDelta >= 0 ? '+' : ''}${totalDelta})`);
1754
- if (rollbackId && !options.dryRun) {
1851
+ if (rollbackId && !isDryRun) {
1755
1852
  console.log(` Rollback available: nerviq rollback --id ${rollbackId}`);
1756
1853
  }
1757
- } else if (fixed > 0 && !options.dryRun) {
1854
+ } else if (fixed > 0 && !isDryRun) {
1758
1855
  const postResult = await audit({ dir: options.dir, silent: true, platform: options.platform });
1759
1856
  const delta = postResult.score - preScore;
1760
1857
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerviq/cli",
3
- "version": "1.8.7",
3
+ "version": "1.8.8",
4
4
  "description": "The intelligent nervous system for AI coding agents — 2,431 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/context.js CHANGED
@@ -185,12 +185,38 @@ class ProjectContext {
185
185
  return deps;
186
186
  }
187
187
 
188
+ /**
189
+ * Recursively check if a file or directory name exists anywhere under a given base directory.
190
+ * Searches up to maxDepth levels deep.
191
+ */
192
+ _findInSubdirs(name, baseDir, maxDepth = 3) {
193
+ if (maxDepth <= 0) return false;
194
+ try {
195
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
196
+ for (const entry of entries) {
197
+ if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git') continue;
198
+ if (entry.name === name || entry.name.endsWith(name)) return true;
199
+ if (entry.isDirectory()) {
200
+ if (this._findInSubdirs(name, path.join(baseDir, entry.name), maxDepth - 1)) return true;
201
+ }
202
+ }
203
+ } catch {
204
+ // directory not readable
205
+ }
206
+ return false;
207
+ }
208
+
188
209
  detectStacks(STACKS) {
189
210
  const detected = [];
190
211
  for (const [key, stack] of Object.entries(STACKS)) {
191
- const hasFile = stack.files.some(f => {
212
+ // Check root-level files first (fast path)
213
+ let hasFile = stack.files.some(f => {
192
214
  return this.files.some(pf => pf.startsWith(f));
193
215
  });
216
+ // If not found at root, search subdirectories (up to 3 levels deep)
217
+ if (!hasFile) {
218
+ hasFile = stack.files.some(f => this._findInSubdirs(f, this.dir));
219
+ }
194
220
  if (!hasFile) continue;
195
221
 
196
222
  let contentMatch = true;
package/src/convert.js CHANGED
@@ -98,7 +98,7 @@ function readSourceConfig(dir, from) {
98
98
  if (descMatch) desc = descMatch[1].trim();
99
99
  }
100
100
  canonical.rules.push({
101
- name: file.replace('.mdc', ''),
101
+ name: file.replace(/\.(mdc|md|txt)$/i, ''),
102
102
  content,
103
103
  alwaysOn,
104
104
  glob,
@@ -165,7 +165,11 @@ function readSourceConfig(dir, from) {
165
165
 
166
166
  function buildTargetOutput(canonical, to, { dryRun = false } = {}) {
167
167
  const outputs = []; // Array of { path, content }
168
- const combinedContent = canonical.rules.map(r => r.content).join('\n\n');
168
+ // Strip MDC frontmatter from rule content for non-cursor targets to prevent leaking
169
+ const stripFrontmatter = (text) => text.replace(/^---[\s\S]*?---\n/m, '').trim();
170
+ const combinedContent = to === 'cursor'
171
+ ? canonical.rules.map(r => r.content).join('\n\n')
172
+ : canonical.rules.map(r => stripFrontmatter(r.content)).join('\n\n');
169
173
 
170
174
  if (to === 'claude') {
171
175
  // Extract or create CLAUDE.md from combined rules
package/src/setup.js CHANGED
@@ -810,7 +810,7 @@ process.stdin.on('end', () => {
810
810
  console.log(JSON.stringify({ decision: 'allow' }));
811
811
  }
812
812
  } catch (e) {
813
- console.log(JSON.stringify({ decision: 'allow' }));
813
+ console.log(JSON.stringify({ decision: 'block', reason: 'Hook error - blocking for safety' }));
814
814
  }
815
815
  });
816
816
  `,
@@ -1062,7 +1062,14 @@ Prepare a release candidate for: $ARGUMENTS
1062
1062
  - Mock external dependencies, not internal logic
1063
1063
  - Include both happy path and edge case tests
1064
1064
  `;
1065
- rules['repository.md'] = `When changing release, packaging, or workflow files:
1065
+ rules['repository.md'] = hasPython
1066
+ ? `When changing release, packaging, or workflow files:
1067
+ - Keep pyproject.toml (or requirements.txt), CHANGELOG.md, README.md, and docs in sync
1068
+ - Prefer tagged release references over floating branch references in public docs
1069
+ - Preserve backward compatibility in CLI flags where practical
1070
+ - Any automation that writes files must document rollback expectations
1071
+ `
1072
+ : `When changing release, packaging, or workflow files:
1066
1073
  - Keep package.json, CHANGELOG.md, README.md, and docs in sync
1067
1074
  - Prefer tagged release references over floating branch references in public docs
1068
1075
  - Preserve backward compatibility in CLI flags where practical
@@ -1115,6 +1122,18 @@ async function setup(options) {
1115
1122
  if (options.platform === 'codex') {
1116
1123
  return setupCodex(options);
1117
1124
  }
1125
+ if (options.platform === 'windsurf') {
1126
+ const { setupWindsurf } = require('./windsurf/setup');
1127
+ return setupWindsurf(options);
1128
+ }
1129
+ if (options.platform === 'aider') {
1130
+ const { setupAider } = require('./aider/setup');
1131
+ return setupAider(options);
1132
+ }
1133
+ if (options.platform === 'cursor') {
1134
+ const { setupCursor } = require('./cursor/setup');
1135
+ return setupCursor(options);
1136
+ }
1118
1137
 
1119
1138
  const ctx = new ProjectContext(options.dir);
1120
1139
  const stacks = ctx.detectStacks(STACKS);
@@ -1218,21 +1237,40 @@ async function setup(options) {
1218
1237
  }
1219
1238
  }
1220
1239
 
1221
- // Auto-register hooks in settings if hooks were created but no settings exist
1240
+ // Auto-register hooks in settings always merge hooks into settings.json
1222
1241
  const hooksDir = path.join(options.dir, '.claude/hooks');
1223
1242
  const settingsPath = path.join(options.dir, '.claude/settings.json');
1224
- if (fs.existsSync(hooksDir) && !fs.existsSync(settingsPath)) {
1243
+ if (fs.existsSync(hooksDir)) {
1225
1244
  const hookFiles = fs.readdirSync(hooksDir).filter(f => f.endsWith('.sh') || f.endsWith('.js'));
1226
1245
  if (hookFiles.length > 0) {
1227
- const settings = buildSettingsForProfile({
1246
+ const newSettings = buildSettingsForProfile({
1228
1247
  profileKey: options.profile || 'safe-write',
1229
1248
  hookFiles,
1230
1249
  mcpPackKeys: options.mcpPacks || [],
1231
1250
  });
1232
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
1233
- writtenFiles.push('.claude/settings.json');
1234
- log(` \x1b[32m✅\x1b[0m Created .claude/settings.json (hooks registered)`);
1235
- created++;
1251
+ // Merge new settings into existing settings.json, preserving all fields
1252
+ let existingSettings = {};
1253
+ if (fs.existsSync(settingsPath)) {
1254
+ try {
1255
+ existingSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1256
+ } catch (_) {
1257
+ // If settings.json is malformed, start fresh
1258
+ existingSettings = {};
1259
+ }
1260
+ }
1261
+ // Merge all fields from newSettings into existing, preserving existing values
1262
+ if (newSettings.hooks) existingSettings.hooks = newSettings.hooks;
1263
+ if (newSettings.permissions) existingSettings.permissions = { ...existingSettings.permissions, ...newSettings.permissions };
1264
+ if (newSettings.mcpServers) existingSettings.mcpServers = { ...existingSettings.mcpServers, ...newSettings.mcpServers };
1265
+ if (newSettings.nerviqSetup) existingSettings.nerviqSetup = { ...existingSettings.nerviqSetup, ...newSettings.nerviqSetup };
1266
+ fs.writeFileSync(settingsPath, JSON.stringify(existingSettings, null, 2), 'utf8');
1267
+ if (!writtenFiles.includes('.claude/settings.json') && !preservedFiles.includes('.claude/settings.json')) {
1268
+ writtenFiles.push('.claude/settings.json');
1269
+ log(` \x1b[32m✅\x1b[0m Updated .claude/settings.json (hooks registered)`);
1270
+ created++;
1271
+ } else {
1272
+ log(` \x1b[32m✅\x1b[0m Merged hooks into existing .claude/settings.json`);
1273
+ }
1236
1274
  }
1237
1275
  }
1238
1276
 
package/src/techniques.js CHANGED
@@ -759,7 +759,11 @@ const TECHNIQUES = {
759
759
  const settings = ctx.jsonFile('.claude/settings.json') || ctx.jsonFile('.claude/settings.local.json');
760
760
  if (!settings || !settings.permissions) return false;
761
761
  const deny = JSON.stringify(settings.permissions.deny || []);
762
- return deny.includes('.env') || deny.includes('secrets');
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;
766
+ return hasDeny;
763
767
  },
764
768
  impact: 'critical',
765
769
  rating: 5,
@@ -5474,7 +5478,7 @@ const STACKS = {
5474
5478
  ruby: { files: ['Gemfile'], content: {}, label: 'Ruby' },
5475
5479
  java: { files: ['pom.xml'], content: {}, label: 'Java' },
5476
5480
  kotlin: { files: ['build.gradle.kts'], content: {}, label: 'Kotlin' },
5477
- swift: { files: ['Package.swift'], content: {}, label: 'Swift' },
5481
+ swift: { files: ['Package.swift', '.xcodeproj'], content: {}, label: 'Swift' },
5478
5482
  terraform: { files: ['main.tf', 'terraform'], content: {}, label: 'Terraform' },
5479
5483
  kubernetes: { files: ['k8s', 'kubernetes', 'helm'], content: {}, label: 'Kubernetes' },
5480
5484
  cpp: { files: ['CMakeLists.txt', 'Makefile', '.clang-format'], content: {}, label: 'C++' },