@nerviq/cli 1.25.0 → 1.27.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.
@@ -0,0 +1,520 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { resolveEvidence } = require('../audit/evidence');
6
+ const { LAYERS } = require('../audit/layers');
7
+ const { STACKS } = require('../techniques');
8
+ const { P0_SOURCES: AIDER_P0_SOURCES } = require('../aider/freshness');
9
+
10
+ const SHALLOW_RISK_DOC_URL = 'https://github.com/nerviq/nerviq/blob/main/docs/shallow-risk.md';
11
+ const SHALLOW_RISK_BANNER_LINES = [
12
+ 'Shallow Risk mode (experimental, opt-in). NERVIQ checks 8 patterns',
13
+ 'that sit at the intersection of your AI agent configuration and',
14
+ 'your codebase - the kind of issues no generic scanner can find',
15
+ 'because they require understanding CLAUDE.md, .claude/settings.json,',
16
+ 'and similar files. For broader code-level security coverage, pair',
17
+ 'this with Semgrep, CodeQL, or a dedicated secret scanner.',
18
+ ];
19
+ const SHALLOW_RISK_BANNER = SHALLOW_RISK_BANNER_LINES.join('\n');
20
+
21
+ const ROOT_AGENT_FILES = [
22
+ 'CLAUDE.md',
23
+ 'AGENTS.md',
24
+ 'GEMINI.md',
25
+ '.cursorrules',
26
+ '.windsurfrules',
27
+ '.aider.conf.yml',
28
+ '.aider.conf.yaml',
29
+ '.mcp.json',
30
+ '.claude/settings.json',
31
+ '.claude/CLAUDE.md',
32
+ '.gemini/settings.json',
33
+ '.github/copilot-instructions.md',
34
+ '.vscode/mcp.json',
35
+ '.vscode/settings.json',
36
+ '.codex/config.toml',
37
+ 'opencode.json',
38
+ ];
39
+
40
+ const ROOT_AGENT_DIRS = [
41
+ '.claude/agents',
42
+ '.claude/commands',
43
+ '.claude/hooks',
44
+ '.claude/rules',
45
+ '.claude/skills',
46
+ '.cursor/rules',
47
+ '.windsurf/rules',
48
+ '.codex/agents',
49
+ '.codex/hooks',
50
+ '.codex/skills',
51
+ '.github/instructions',
52
+ ];
53
+
54
+ const EXCLUDED_DIRS = new Set([
55
+ '.git',
56
+ 'node_modules',
57
+ 'coverage',
58
+ 'dist',
59
+ 'build',
60
+ '.next',
61
+ '.turbo',
62
+ '.cache',
63
+ '__pycache__',
64
+ ]);
65
+
66
+ const SPECIAL_FILE_BASENAMES = new Set([
67
+ 'AGENTS.md',
68
+ 'CLAUDE.md',
69
+ 'GEMINI.md',
70
+ 'SECURITY.md',
71
+ 'README.md',
72
+ 'CONTRIBUTING.md',
73
+ 'CODEOWNERS',
74
+ 'Dockerfile',
75
+ 'Makefile',
76
+ 'justfile',
77
+ 'package.json',
78
+ 'pyproject.toml',
79
+ 'go.mod',
80
+ 'Cargo.toml',
81
+ ]);
82
+
83
+ const KNOWN_CONVENTION_PATHS = new Set([
84
+ 'CODEOWNERS',
85
+ '.github/CODEOWNERS',
86
+ ]);
87
+
88
+ const LOCAL_MCP_BINARIES = new Set([
89
+ 'context7-mcp',
90
+ 'nerviq-mcp',
91
+ ]);
92
+
93
+ const STACK_CLAIMS = [
94
+ { key: 'go', label: 'Go', stackKeys: ['go'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:go|golang)\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\b(?:go|golang)\b/i, /\bwritten in\s+(?:go|golang)\b/i] },
95
+ { key: 'python', label: 'Python', stackKeys: ['python', 'django', 'fastapi'], patterns: [/\bprimary (?:language|stack)\s*:\s*python\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\bpython\b/i, /\bwritten in\s+python\b/i] },
96
+ { key: 'node', label: 'Node.js', stackKeys: ['node'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:node|node\.js)\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\bnode(?:\.js)?\b/i] },
97
+ { key: 'javascript', label: 'JavaScript', stackKeys: ['node'], patterns: [/\bprimary (?:language|stack)\s*:\s*javascript\b/i, /\bpure javascript project\b/i, /\bthis (?:repo|project|codebase|app)\b[\s\S]{0,40}\bjavascript\b/i] },
98
+ { key: 'typescript', label: 'TypeScript', stackKeys: ['typescript', 'node'], patterns: [/\bprimary (?:language|stack)\s*:\s*typescript\b/i, /\buse\s+typescript\b/i, /\btypescript strict mode\b/i, /\bthis (?:repo|project|codebase|app)\b[\s\S]{0,40}\btypescript\b/i] },
99
+ { key: 'rust', label: 'Rust', stackKeys: ['rust'], patterns: [/\bprimary (?:language|stack)\s*:\s*rust\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\brust\b/i] },
100
+ { key: 'java', label: 'Java', stackKeys: ['java'], patterns: [/\bprimary (?:language|stack)\s*:\s*java\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bjava\b/i] },
101
+ { key: 'kotlin', label: 'Kotlin', stackKeys: ['kotlin'], patterns: [/\bprimary (?:language|stack)\s*:\s*kotlin\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bkotlin\b/i] },
102
+ { key: 'ruby', label: 'Ruby', stackKeys: ['ruby'], patterns: [/\bprimary (?:language|stack)\s*:\s*ruby\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bruby\b/i] },
103
+ { key: 'php', label: 'PHP', stackKeys: ['php', 'laravel'], patterns: [/\bprimary (?:language|stack)\s*:\s*php\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bphp\b/i] },
104
+ { key: 'dotnet', label: '.NET', stackKeys: ['dotnet'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:\.net|dotnet|c#)\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\b(?:\.net|dotnet|c#)\b/i] },
105
+ { key: 'swift', label: 'Swift', stackKeys: ['swift'], patterns: [/\bprimary (?:language|stack)\s*:\s*swift\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bswift\b/i] },
106
+ { key: 'flutter', label: 'Flutter', stackKeys: ['flutter'], patterns: [/\bprimary (?:language|stack)\s*:\s*flutter\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bflutter\b/i] },
107
+ ];
108
+
109
+ const STACK_CLAIM_BY_KEY = new Map(STACK_CLAIMS.map((claim) => [claim.key, claim]));
110
+
111
+ function toPosix(filePath) {
112
+ return String(filePath || '').replace(/\\/g, '/');
113
+ }
114
+
115
+ function escapeRegExp(value) {
116
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
117
+ }
118
+
119
+ function existsSyncSafe(targetPath) {
120
+ try {
121
+ return fs.existsSync(targetPath);
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function isLikelyTextFile(relPath) {
128
+ const base = path.posix.basename(toPosix(relPath));
129
+ if (SPECIAL_FILE_BASENAMES.has(base)) return true;
130
+ if (base === '.cursorrules' || base === '.windsurfrules') return true;
131
+ return /\.(?:md|mdc|txt|rst|json|jsonc|ya?ml|toml|conf|sh|ps1|js|cjs|mjs|ts|tsx|jsx|py|go|rs|java|kt|cs|rb|php|swift)$/i.test(base);
132
+ }
133
+
134
+ function fileExists(ctx, relPath) {
135
+ return existsSyncSafe(path.join(ctx.dir, relPath));
136
+ }
137
+
138
+ function listFilesRecursive(rootDir, relDir = '', output = []) {
139
+ const absDir = path.join(rootDir, relDir);
140
+ let entries = [];
141
+ try {
142
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
143
+ } catch {
144
+ return output;
145
+ }
146
+
147
+ for (const entry of entries) {
148
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
149
+ const nextRel = toPosix(path.join(relDir, entry.name));
150
+ if (entry.isDirectory()) {
151
+ listFilesRecursive(rootDir, nextRel, output);
152
+ continue;
153
+ }
154
+ if (entry.isFile() && isLikelyTextFile(nextRel)) {
155
+ output.push(nextRel);
156
+ }
157
+ }
158
+ return output;
159
+ }
160
+
161
+ function getAgentConfigFiles(ctx) {
162
+ if (Array.isArray(ctx.__nerviqShallowRiskFiles)) {
163
+ return ctx.__nerviqShallowRiskFiles;
164
+ }
165
+
166
+ const files = new Set();
167
+
168
+ for (const relPath of ROOT_AGENT_FILES) {
169
+ if (fileExists(ctx, relPath)) {
170
+ files.add(toPosix(relPath));
171
+ }
172
+ }
173
+
174
+ for (const relDir of ROOT_AGENT_DIRS) {
175
+ if (!existsSyncSafe(path.join(ctx.dir, relDir))) continue;
176
+ for (const relPath of listFilesRecursive(ctx.dir, relDir)) {
177
+ files.add(toPosix(relPath));
178
+ }
179
+ }
180
+
181
+ ctx.__nerviqShallowRiskFiles = [...files]
182
+ .filter((relPath) => {
183
+ try {
184
+ const size = fs.statSync(path.join(ctx.dir, relPath)).size;
185
+ return Number.isFinite(size) && size <= 512 * 1024;
186
+ } catch {
187
+ return false;
188
+ }
189
+ })
190
+ .sort();
191
+
192
+ return ctx.__nerviqShallowRiskFiles;
193
+ }
194
+
195
+ function platformForFile(relPath) {
196
+ const normalized = toPosix(relPath);
197
+ if (normalized === 'CLAUDE.md' || normalized.startsWith('.claude/')) return 'claude';
198
+ if (normalized === 'AGENTS.md' || normalized.startsWith('.codex/')) return 'codex';
199
+ if (normalized === 'GEMINI.md' || normalized.startsWith('.gemini/')) return 'gemini';
200
+ if (normalized === '.cursorrules' || normalized.startsWith('.cursor/')) return 'cursor';
201
+ if (normalized === '.windsurfrules' || normalized.startsWith('.windsurf/')) return 'windsurf';
202
+ if (normalized.startsWith('.aider.')) return 'aider';
203
+ if (normalized.startsWith('.github/') || normalized.startsWith('.vscode/')) return 'copilot';
204
+ if (normalized === 'opencode.json' || normalized.startsWith('.opencode/')) return 'opencode';
205
+ if (normalized === '.mcp.json') return 'claude';
206
+ return 'agent';
207
+ }
208
+
209
+ function getAgentConfigEntries(ctx) {
210
+ if (Array.isArray(ctx.__nerviqShallowRiskEntries)) {
211
+ return ctx.__nerviqShallowRiskEntries;
212
+ }
213
+
214
+ ctx.__nerviqShallowRiskEntries = getAgentConfigFiles(ctx)
215
+ .map((file) => {
216
+ const content = ctx.fileContent(file);
217
+ if (!content || !content.trim()) return null;
218
+ return {
219
+ path: file,
220
+ platform: platformForFile(file),
221
+ content,
222
+ };
223
+ })
224
+ .filter(Boolean);
225
+
226
+ return ctx.__nerviqShallowRiskEntries;
227
+ }
228
+
229
+ function stripWrapperChars(value) {
230
+ return String(value || '')
231
+ .replace(/^[`"'(<\[]+/, '')
232
+ .replace(/[`"')>\].,:;!?]+$/, '');
233
+ }
234
+
235
+ function normalizeCandidatePath(rawValue) {
236
+ let value = stripWrapperChars(rawValue);
237
+ if (value.startsWith('@')) value = value.slice(1);
238
+ return value;
239
+ }
240
+
241
+ function looksLikeRelativeFileReference(candidate) {
242
+ if (!candidate) return false;
243
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
244
+ if (/^[A-Za-z0-9-]+\.[A-Za-z]{2,}\//.test(candidate)) return false;
245
+ if (candidate.startsWith('#')) return false;
246
+
247
+ const normalized = candidate.replace(/^\.\//, '');
248
+ const base = path.posix.basename(normalized);
249
+
250
+ if (KNOWN_CONVENTION_PATHS.has(normalized) || SPECIAL_FILE_BASENAMES.has(base)) {
251
+ return true;
252
+ }
253
+
254
+ if (normalized.includes('/')) {
255
+ return /\.(?:md|mdc|txt|rst|json|jsonc|ya?ml|toml|conf|sh|ps1|js|cjs|mjs|ts|tsx|jsx|py|go|rs|java|kt|cs|rb|php|swift)$/i.test(base);
256
+ }
257
+
258
+ return /^(?:\.?[A-Za-z0-9_-]+\.)[A-Za-z0-9._-]+$/i.test(normalized);
259
+ }
260
+
261
+ function resolveRepoPath(ctx, fromFile, candidate, mode = 'relative-to-file') {
262
+ const normalized = toPosix(candidate.replace(/^\.\//, ''));
263
+ const baseDir = mode === 'repo-root'
264
+ ? ctx.dir
265
+ : path.join(ctx.dir, path.posix.dirname(toPosix(fromFile)));
266
+ const absolute = path.resolve(baseDir, normalized);
267
+ const root = path.resolve(ctx.dir);
268
+
269
+ if (!(absolute === root || absolute.startsWith(`${root}${path.sep}`))) {
270
+ return null;
271
+ }
272
+
273
+ return toPosix(path.relative(root, absolute));
274
+ }
275
+
276
+ function getScannableLines(content) {
277
+ const lines = String(content || '').split(/\r?\n/);
278
+ const output = [];
279
+ let fence = null;
280
+
281
+ for (let index = 0; index < lines.length; index++) {
282
+ const line = lines[index];
283
+ const trimmed = line.trim();
284
+
285
+ if (!fence && /^(```|~~~)/.test(trimmed)) {
286
+ fence = trimmed.slice(0, 3);
287
+ continue;
288
+ }
289
+
290
+ if (fence) {
291
+ if (trimmed.startsWith(fence)) {
292
+ fence = null;
293
+ }
294
+ continue;
295
+ }
296
+
297
+ output.push({ lineNumber: index + 1, text: line });
298
+ }
299
+
300
+ return output;
301
+ }
302
+
303
+ function buildFinding(pattern, ctx, finding) {
304
+ const evidence = resolveEvidence(pattern.key, ctx, {
305
+ file: finding.file,
306
+ line: finding.line,
307
+ });
308
+
309
+ return {
310
+ key: pattern.key,
311
+ id: null,
312
+ name: pattern.name,
313
+ category: 'shallow-risk',
314
+ layer: LAYERS.SHALLOW_RISK,
315
+ severity: finding.severity || pattern.severity,
316
+ impact: finding.severity || pattern.severity,
317
+ rating: null,
318
+ passed: false,
319
+ file: evidence ? evidence.file : (finding.file || null),
320
+ line: evidence ? evidence.line : (finding.line || null),
321
+ snippet: evidence ? evidence.snippet : (finding.snippet || null),
322
+ fix: finding.fix || null,
323
+ sourceUrl: finding.sourceUrl || pattern.sourceUrl || SHALLOW_RISK_DOC_URL,
324
+ };
325
+ }
326
+
327
+ function isKnownConventionPath(relPath) {
328
+ const normalized = toPosix(relPath).replace(/^\.\//, '');
329
+ return KNOWN_CONVENTION_PATHS.has(normalized);
330
+ }
331
+
332
+ function findFirstRepoPath(ctx, matcher, options = {}) {
333
+ const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : 4;
334
+ const queue = [{ absDir: ctx.dir, relDir: '', depth: 0 }];
335
+
336
+ while (queue.length > 0) {
337
+ const current = queue.shift();
338
+ let entries = [];
339
+ try {
340
+ entries = fs.readdirSync(current.absDir, { withFileTypes: true });
341
+ } catch {
342
+ continue;
343
+ }
344
+
345
+ for (const entry of entries) {
346
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
347
+ const relPath = toPosix(path.join(current.relDir, entry.name));
348
+ const absPath = path.join(current.absDir, entry.name);
349
+
350
+ if (entry.isFile()) {
351
+ if (typeof matcher === 'function' ? matcher(relPath, entry.name) : matcher === relPath) {
352
+ return relPath;
353
+ }
354
+ continue;
355
+ }
356
+
357
+ if (entry.isDirectory() && current.depth < maxDepth) {
358
+ queue.push({ absDir: absPath, relDir: relPath, depth: current.depth + 1 });
359
+ }
360
+ }
361
+ }
362
+
363
+ return null;
364
+ }
365
+
366
+ function findFirstStackEvidence(ctx, stackKey) {
367
+ const stack = STACKS[stackKey];
368
+ if (!stack) return null;
369
+
370
+ for (const probe of stack.files || []) {
371
+ if (fileExists(ctx, probe)) return probe;
372
+ }
373
+
374
+ return findFirstRepoPath(ctx, (_relPath, baseName) => (stack.files || []).includes(baseName));
375
+ }
376
+
377
+ function getDetectedStackEvidence(ctx) {
378
+ if (Array.isArray(ctx.__nerviqShallowRiskStackEvidence)) {
379
+ return ctx.__nerviqShallowRiskStackEvidence;
380
+ }
381
+
382
+ const seen = new Set();
383
+ const evidence = [];
384
+
385
+ for (const stack of ctx.detectStacks(STACKS)) {
386
+ if (seen.has(stack.key)) continue;
387
+ seen.add(stack.key);
388
+ const file = findFirstStackEvidence(ctx, stack.key);
389
+ if (!file) continue;
390
+ evidence.push({
391
+ key: stack.key,
392
+ label: stack.label,
393
+ file,
394
+ });
395
+ }
396
+
397
+ ctx.__nerviqShallowRiskStackEvidence = evidence;
398
+ return evidence;
399
+ }
400
+
401
+ function detectClaimOnLine(line) {
402
+ for (const claim of STACK_CLAIMS) {
403
+ for (const pattern of claim.patterns) {
404
+ pattern.lastIndex = 0;
405
+ if (pattern.test(line)) {
406
+ return claim;
407
+ }
408
+ }
409
+ }
410
+ return null;
411
+ }
412
+
413
+ function collectStackClaims(ctx) {
414
+ if (Array.isArray(ctx.__nerviqShallowRiskClaims)) {
415
+ return ctx.__nerviqShallowRiskClaims;
416
+ }
417
+
418
+ const claims = [];
419
+
420
+ for (const entry of getAgentConfigEntries(ctx)) {
421
+ for (const { lineNumber, text } of getScannableLines(entry.content)) {
422
+ const claim = detectClaimOnLine(text);
423
+ if (!claim) continue;
424
+ claims.push({
425
+ key: claim.key,
426
+ label: claim.label,
427
+ stackKeys: claim.stackKeys,
428
+ file: entry.path,
429
+ line: lineNumber,
430
+ platform: entry.platform,
431
+ text,
432
+ });
433
+ }
434
+ }
435
+
436
+ ctx.__nerviqShallowRiskClaims = claims;
437
+ return claims;
438
+ }
439
+
440
+ function getClaimByKey(key) {
441
+ return STACK_CLAIM_BY_KEY.get(key) || null;
442
+ }
443
+
444
+ function isClearlyLocalMcpBinary(command) {
445
+ if (!command) return false;
446
+ const base = path.posix.basename(toPosix(command)).toLowerCase();
447
+ if (LOCAL_MCP_BINARIES.has(base)) return true;
448
+ if (/^(node|npx|python|python3|bash|sh|pwsh|powershell)$/i.test(base)) return false;
449
+ return /(?:^|[-_.])mcp$/i.test(base) || /-mcp\b/i.test(base);
450
+ }
451
+
452
+ function getHookCommandPath(command) {
453
+ if (typeof command !== 'string' || !command.trim()) return null;
454
+ const tokens = command.match(/"[^"]+"|'[^']+'|\S+/g) || [];
455
+ const cleaned = tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
456
+ if (cleaned.length === 0) return null;
457
+
458
+ const first = cleaned[0];
459
+ if (looksLikeRelativeFileReference(first)) return first;
460
+
461
+ if (/^(node|python|python3|bash|sh|pwsh|powershell)$/i.test(first)) {
462
+ const second = cleaned[1];
463
+ if (!second || /^-(?:e|c|Command|EncodedCommand)$/.test(second)) return null;
464
+ if (looksLikeRelativeFileReference(second)) return second;
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ function hasLegacyAiderPin(ctx) {
471
+ const files = [
472
+ 'requirements.txt',
473
+ 'requirements-dev.txt',
474
+ 'requirements-dev.in',
475
+ 'pyproject.toml',
476
+ ];
477
+
478
+ const legacyVersion = /(?:aider|aider-chat)\s*(?:==|~=|<=|<)\s*0\.(\d+)/ig;
479
+ for (const file of files) {
480
+ const content = ctx.fileContent(file) || '';
481
+ legacyVersion.lastIndex = 0;
482
+ let match = legacyVersion.exec(content);
483
+ while (match) {
484
+ const minor = Number(match[1]);
485
+ if (Number.isFinite(minor) && minor < 60) {
486
+ return true;
487
+ }
488
+ match = legacyVersion.exec(content);
489
+ }
490
+ }
491
+
492
+ return false;
493
+ }
494
+
495
+ module.exports = {
496
+ AIDER_P0_SOURCES,
497
+ SHALLOW_RISK_BANNER,
498
+ SHALLOW_RISK_BANNER_LINES,
499
+ SHALLOW_RISK_DOC_URL,
500
+ buildFinding,
501
+ collectStackClaims,
502
+ escapeRegExp,
503
+ fileExists,
504
+ findFirstRepoPath,
505
+ findFirstStackEvidence,
506
+ getAgentConfigEntries,
507
+ getAgentConfigFiles,
508
+ getClaimByKey,
509
+ getDetectedStackEvidence,
510
+ getHookCommandPath,
511
+ getScannableLines,
512
+ hasLegacyAiderPin,
513
+ isClearlyLocalMcpBinary,
514
+ isKnownConventionPath,
515
+ looksLikeRelativeFileReference,
516
+ normalizeCandidatePath,
517
+ platformForFile,
518
+ resolveRepoPath,
519
+ toPosix,
520
+ };
@@ -202,6 +202,106 @@ function isDotnetProject(ctx) {
202
202
  return ctx.__nerviqIsDotnet;
203
203
  }
204
204
 
205
+ // ─── CTO-07 Framework-native verification signals ───────────────────────
206
+ // Memoized on ctx. These are "this stack has verification wired up"
207
+ // signals that augment documentation-surface detection.
208
+
209
+ function hasIosXcodeProject(ctx) {
210
+ if (ctx.__nerviqHasIosXcode !== undefined) return ctx.__nerviqHasIosXcode;
211
+ ctx.__nerviqHasIosXcode =
212
+ hasCoreProjectFile(ctx, /\.xcodeproj\//i) ||
213
+ hasCoreProjectFile(ctx, /\.xcworkspace\//i) ||
214
+ hasCoreRootFile(ctx, /(^|\/)Package\.swift$/i);
215
+ return ctx.__nerviqHasIosXcode;
216
+ }
217
+
218
+ function hasAndroidGradle(ctx) {
219
+ if (ctx.__nerviqHasAndroidGradle !== undefined) return ctx.__nerviqHasAndroidGradle;
220
+ ctx.__nerviqHasAndroidGradle =
221
+ hasCoreRootFile(ctx, /(^|\/)build\.gradle(\.kts)?$/i) ||
222
+ hasCoreRootFile(ctx, /(^|\/)settings\.gradle(\.kts)?$/i);
223
+ return ctx.__nerviqHasAndroidGradle;
224
+ }
225
+
226
+ function hasFlutterProject(ctx) {
227
+ if (ctx.__nerviqHasFlutter !== undefined) return ctx.__nerviqHasFlutter;
228
+ const pubspec = ctx.fileContent('pubspec.yaml') || '';
229
+ ctx.__nerviqHasFlutter = /\bflutter:\s*\n/i.test(pubspec) || /\bsdk:\s*flutter\b/i.test(pubspec);
230
+ return ctx.__nerviqHasFlutter;
231
+ }
232
+
233
+ function _pyProjectText(ctx) {
234
+ return getPythonProjectText(ctx);
235
+ }
236
+
237
+ function hasPythonPoetry(ctx) {
238
+ if (ctx.__nerviqHasPoetry !== undefined) return ctx.__nerviqHasPoetry;
239
+ const text = _pyProjectText(ctx);
240
+ ctx.__nerviqHasPoetry = /\[tool\.poetry\]/i.test(text) || !!ctx.fileContent('poetry.lock');
241
+ return ctx.__nerviqHasPoetry;
242
+ }
243
+
244
+ function hasPythonUv(ctx) {
245
+ if (ctx.__nerviqHasUv !== undefined) return ctx.__nerviqHasUv;
246
+ const text = _pyProjectText(ctx);
247
+ ctx.__nerviqHasUv = /\[tool\.uv\]/i.test(text) || !!ctx.fileContent('uv.lock');
248
+ return ctx.__nerviqHasUv;
249
+ }
250
+
251
+ function hasPythonPdm(ctx) {
252
+ if (ctx.__nerviqHasPdm !== undefined) return ctx.__nerviqHasPdm;
253
+ const text = _pyProjectText(ctx);
254
+ ctx.__nerviqHasPdm = /\[tool\.pdm\b/i.test(text) || !!ctx.fileContent('pdm.lock');
255
+ return ctx.__nerviqHasPdm;
256
+ }
257
+
258
+ function hasPythonHatch(ctx) {
259
+ if (ctx.__nerviqHasHatch !== undefined) return ctx.__nerviqHasHatch;
260
+ const text = _pyProjectText(ctx);
261
+ ctx.__nerviqHasHatch = /\[tool\.hatch\b/i.test(text);
262
+ return ctx.__nerviqHasHatch;
263
+ }
264
+
265
+ function hasFastApiProject(ctx) {
266
+ if (ctx.__nerviqHasFastApi !== undefined) return ctx.__nerviqHasFastApi;
267
+ const text = _pyProjectText(ctx);
268
+ ctx.__nerviqHasFastApi = /\bfastapi\b/i.test(text);
269
+ return ctx.__nerviqHasFastApi;
270
+ }
271
+
272
+ const ML_DEP_PATTERN = /\b(pytorch|torch|tensorflow|keras|scikit-learn|sklearn|jax|transformers|datasets|huggingface|accelerate|xgboost|lightgbm)\b/i;
273
+
274
+ function hasMlScaffolding(ctx) {
275
+ if (ctx.__nerviqHasMl !== undefined) return ctx.__nerviqHasMl;
276
+ const text = _pyProjectText(ctx);
277
+ if (ML_DEP_PATTERN.test(text)) {
278
+ ctx.__nerviqHasMl = true;
279
+ return true;
280
+ }
281
+ // Heuristic: notebooks/ or experiments/ dir with actual .ipynb files
282
+ const hasNotebooks = findProjectFiles(ctx, /\.ipynb$/i).length > 0;
283
+ ctx.__nerviqHasMl = hasNotebooks;
284
+ return ctx.__nerviqHasMl;
285
+ }
286
+
287
+ /**
288
+ * Checks whether a Python tool is actively configured in pyproject.toml /
289
+ * setup.cfg (e.g., `[tool.ruff]`, `[tool.pytest.ini_options]`,
290
+ * `[tool.mypy]`). When configured, any verification-surface check for that
291
+ * tool should pass: an agent working in this repo can run the tool.
292
+ */
293
+ function hasConfiguredTooling(ctx, toolName) {
294
+ const text = _pyProjectText(ctx);
295
+ if (!text) return false;
296
+ const name = String(toolName || '').toLowerCase();
297
+ const sectionRe = new RegExp(`\\[tool\\.${name.replace(/[-_.]/g, '[-_.]')}(?:[.\\]]|\\s|$)`, 'i');
298
+ if (sectionRe.test(text)) return true;
299
+ if (name === 'pytest') {
300
+ return /\[tool\.pytest\.ini_options\]/i.test(text) || /\[tool:pytest\]/i.test(text);
301
+ }
302
+ return false;
303
+ }
304
+
205
305
  /**
206
306
  * Map category names to their project detection function.
207
307
  * Used by the audit to skip entire categories when the stack isn't detected.
@@ -418,6 +518,16 @@ module.exports = {
418
518
  isPhpProject,
419
519
  isDotnetProject,
420
520
  STACK_CATEGORY_DETECTORS,
521
+ hasIosXcodeProject,
522
+ hasAndroidGradle,
523
+ hasFlutterProject,
524
+ hasPythonPoetry,
525
+ hasPythonUv,
526
+ hasPythonPdm,
527
+ hasPythonHatch,
528
+ hasFastApiProject,
529
+ hasMlScaffolding,
530
+ hasConfiguredTooling,
421
531
  getPythonFiles,
422
532
  getMainPythonFiles,
423
533
  getPythonProjectText,