@nerviq/cli 1.29.0 → 1.29.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +1527 -1493
  2. package/README.md +550 -538
  3. package/SECURITY.md +82 -82
  4. package/bin/cli.js +2562 -2558
  5. package/docs/api-reference.md +356 -356
  6. package/docs/audit-fix.md +109 -0
  7. package/docs/autofix.md +3 -62
  8. package/docs/getting-started.md +1 -1
  9. package/docs/index.html +592 -592
  10. package/docs/integration-contracts.md +287 -287
  11. package/docs/maintenance.md +128 -128
  12. package/docs/new-platform-guide.md +202 -202
  13. package/docs/release-process.md +63 -0
  14. package/docs/shallow-risk.md +244 -244
  15. package/docs/why-nerviq.md +82 -82
  16. package/package.json +67 -67
  17. package/src/aider/activity.js +226 -226
  18. package/src/aider/context.js +162 -162
  19. package/src/aider/freshness.js +123 -123
  20. package/src/aider/techniques.js +3465 -3465
  21. package/src/audit/layers.js +180 -180
  22. package/src/audit.js +1032 -1032
  23. package/src/benchmark.js +299 -299
  24. package/src/codex/activity.js +324 -324
  25. package/src/codex/freshness.js +142 -142
  26. package/src/codex/techniques.js +4895 -4895
  27. package/src/context.js +326 -326
  28. package/src/continuous-ops.js +11 -1
  29. package/src/convert.js +340 -340
  30. package/src/copilot/config-parser.js +280 -280
  31. package/src/copilot/context.js +218 -218
  32. package/src/copilot/freshness.js +177 -177
  33. package/src/copilot/patch.js +238 -238
  34. package/src/copilot/techniques.js +3578 -3578
  35. package/src/cursor/freshness.js +194 -194
  36. package/src/cursor/patch.js +243 -243
  37. package/src/cursor/techniques.js +3735 -3735
  38. package/src/doctor.js +201 -201
  39. package/src/fix-engine.js +511 -8
  40. package/src/formatters/csv.js +86 -86
  41. package/src/formatters/junit.js +123 -123
  42. package/src/formatters/markdown.js +164 -164
  43. package/src/formatters/otel.js +151 -151
  44. package/src/freshness.js +156 -156
  45. package/src/gemini/activity.js +402 -402
  46. package/src/gemini/context.js +290 -290
  47. package/src/gemini/freshness.js +183 -183
  48. package/src/gemini/patch.js +229 -229
  49. package/src/gemini/techniques.js +3811 -3811
  50. package/src/governance.js +533 -533
  51. package/src/harmony/audit.js +306 -306
  52. package/src/i18n.js +63 -63
  53. package/src/insights.js +119 -119
  54. package/src/integrations.js +134 -134
  55. package/src/locales/en.json +33 -33
  56. package/src/locales/es.json +33 -33
  57. package/src/migrate.js +354 -354
  58. package/src/opencode/activity.js +286 -286
  59. package/src/opencode/freshness.js +137 -137
  60. package/src/opencode/techniques.js +3450 -3450
  61. package/src/setup/analysis.js +12 -12
  62. package/src/setup.js +7 -6
  63. package/src/shallow-risk/index.js +56 -56
  64. package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +50 -50
  65. package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +46 -46
  66. package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +46 -46
  67. package/src/shallow-risk/patterns/agent-config-missing-file.js +317 -317
  68. package/src/shallow-risk/patterns/agent-config-secret-literal.js +49 -49
  69. package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +34 -34
  70. package/src/shallow-risk/patterns/hook-script-missing.js +70 -70
  71. package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +52 -52
  72. package/src/shallow-risk/shared.js +648 -648
  73. package/src/source-urls.js +295 -295
  74. package/src/state-paths.js +85 -85
  75. package/src/supplemental-checks.js +805 -805
  76. package/src/telemetry.js +160 -160
  77. package/src/windsurf/context.js +359 -359
  78. package/src/windsurf/freshness.js +194 -194
  79. package/src/windsurf/patch.js +231 -231
  80. package/src/windsurf/techniques.js +3779 -3779
@@ -1,648 +1,648 @@
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
- 'manifest.json',
78
- 'package.json',
79
- 'pyproject.toml',
80
- 'go.mod',
81
- 'Cargo.toml',
82
- ]);
83
-
84
- const COMMON_DOTFILE_BASENAMES = new Set([
85
- '.editorconfig',
86
- '.env',
87
- '.env.example',
88
- '.env.sample',
89
- '.env.template',
90
- '.gitattributes',
91
- '.gitignore',
92
- '.npmrc',
93
- '.nvmrc',
94
- '.prettierrc',
95
- '.python-version',
96
- '.tool-versions',
97
- ]);
98
-
99
- const KNOWN_CONVENTION_PATHS = new Set([
100
- 'CODEOWNERS',
101
- '.github/CODEOWNERS',
102
- ]);
103
-
104
- const FILE_REFERENCE_EXTENSION_RE = /\.(?:md|mdc|txt|rst|json|jsonc|ya?ml|toml|conf|sh|ps1|js|cjs|mjs|ts|tsx|jsx|cts|mts|py|go|rs|java|kt|kts|gradle|cs|rb|php|swift|pbxproj|xcconfig|xcworkspace|xcodeproj|h|hpp|c|cc|cpp|m|mm|sql|ini|cfg|properties|xml|html|css|scss|sass|lock)$/i;
105
- const KNOWN_DOMAIN_TLDS = new Set([
106
- 'ai',
107
- 'app',
108
- 'co',
109
- 'com',
110
- 'dev',
111
- 'io',
112
- 'net',
113
- 'org',
114
- 'sh',
115
- ]);
116
- const KNOWN_HIDDEN_PATH_SEGMENTS = new Set([
117
- '.claude',
118
- '.codex',
119
- '.cursor',
120
- '.gemini',
121
- '.github',
122
- '.opencode',
123
- '.vscode',
124
- '.windsurf',
125
- ]);
126
- const FRAMEWORK_LABEL_TOKENS = new Set([
127
- 'd3.js',
128
- 'go',
129
- 'golang',
130
- 'javascript',
131
- 'kotlin',
132
- 'next',
133
- 'next.js',
134
- 'node',
135
- 'node.js',
136
- 'python',
137
- 'rust',
138
- 'swift',
139
- 'typescript',
140
- ]);
141
-
142
- const LOCAL_MCP_BINARIES = new Set([
143
- 'context7-mcp',
144
- 'nerviq-mcp',
145
- ]);
146
-
147
- const STACK_CLAIMS = [
148
- { 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] },
149
- { 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] },
150
- { 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] },
151
- { 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] },
152
- { 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] },
153
- { 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] },
154
- { 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] },
155
- { 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] },
156
- { 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] },
157
- { 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] },
158
- { 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] },
159
- { 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] },
160
- { 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] },
161
- ];
162
-
163
- const STACK_CLAIM_BY_KEY = new Map(STACK_CLAIMS.map((claim) => [claim.key, claim]));
164
-
165
- function toPosix(filePath) {
166
- return String(filePath || '').replace(/\\/g, '/');
167
- }
168
-
169
- function escapeRegExp(value) {
170
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
171
- }
172
-
173
- function existsSyncSafe(targetPath) {
174
- try {
175
- return fs.existsSync(targetPath);
176
- } catch {
177
- return false;
178
- }
179
- }
180
-
181
- function isLikelyTextFile(relPath) {
182
- const base = path.posix.basename(toPosix(relPath));
183
- if (SPECIAL_FILE_BASENAMES.has(base)) return true;
184
- if (COMMON_DOTFILE_BASENAMES.has(base)) return true;
185
- if (base === '.cursorrules' || base === '.windsurfrules') return true;
186
- return hasKnownFileExtension(base);
187
- }
188
-
189
- function fileExists(ctx, relPath) {
190
- return existsSyncSafe(path.join(ctx.dir, relPath));
191
- }
192
-
193
- function listFilesRecursive(rootDir, relDir = '', output = []) {
194
- const absDir = path.join(rootDir, relDir);
195
- let entries = [];
196
- try {
197
- entries = fs.readdirSync(absDir, { withFileTypes: true });
198
- } catch {
199
- return output;
200
- }
201
-
202
- for (const entry of entries) {
203
- if (EXCLUDED_DIRS.has(entry.name)) continue;
204
- const nextRel = toPosix(path.join(relDir, entry.name));
205
- if (entry.isDirectory()) {
206
- listFilesRecursive(rootDir, nextRel, output);
207
- continue;
208
- }
209
- if (entry.isFile() && isLikelyTextFile(nextRel)) {
210
- output.push(nextRel);
211
- }
212
- }
213
- return output;
214
- }
215
-
216
- function getAgentConfigFiles(ctx) {
217
- if (Array.isArray(ctx.__nerviqShallowRiskFiles)) {
218
- return ctx.__nerviqShallowRiskFiles;
219
- }
220
-
221
- const files = new Set();
222
-
223
- for (const relPath of ROOT_AGENT_FILES) {
224
- if (fileExists(ctx, relPath)) {
225
- files.add(toPosix(relPath));
226
- }
227
- }
228
-
229
- for (const relDir of ROOT_AGENT_DIRS) {
230
- if (!existsSyncSafe(path.join(ctx.dir, relDir))) continue;
231
- for (const relPath of listFilesRecursive(ctx.dir, relDir)) {
232
- files.add(toPosix(relPath));
233
- }
234
- }
235
-
236
- ctx.__nerviqShallowRiskFiles = [...files]
237
- .filter((relPath) => {
238
- try {
239
- const size = fs.statSync(path.join(ctx.dir, relPath)).size;
240
- return Number.isFinite(size) && size <= 512 * 1024;
241
- } catch {
242
- return false;
243
- }
244
- })
245
- .sort();
246
-
247
- return ctx.__nerviqShallowRiskFiles;
248
- }
249
-
250
- function platformForFile(relPath) {
251
- const normalized = toPosix(relPath);
252
- if (normalized === 'CLAUDE.md' || normalized.startsWith('.claude/')) return 'claude';
253
- if (normalized === 'AGENTS.md' || normalized.startsWith('.codex/')) return 'codex';
254
- if (normalized === 'GEMINI.md' || normalized.startsWith('.gemini/')) return 'gemini';
255
- if (normalized === '.cursorrules' || normalized.startsWith('.cursor/')) return 'cursor';
256
- if (normalized === '.windsurfrules' || normalized.startsWith('.windsurf/')) return 'windsurf';
257
- if (normalized.startsWith('.aider.')) return 'aider';
258
- if (normalized.startsWith('.github/') || normalized.startsWith('.vscode/')) return 'copilot';
259
- if (normalized === 'opencode.json' || normalized.startsWith('.opencode/')) return 'opencode';
260
- if (normalized === '.mcp.json') return 'claude';
261
- return 'agent';
262
- }
263
-
264
- function getAgentConfigEntries(ctx) {
265
- if (Array.isArray(ctx.__nerviqShallowRiskEntries)) {
266
- return ctx.__nerviqShallowRiskEntries;
267
- }
268
-
269
- ctx.__nerviqShallowRiskEntries = getAgentConfigFiles(ctx)
270
- .map((file) => {
271
- const content = ctx.fileContent(file);
272
- if (!content || !content.trim()) return null;
273
- return {
274
- path: file,
275
- platform: platformForFile(file),
276
- content,
277
- };
278
- })
279
- .filter(Boolean);
280
-
281
- return ctx.__nerviqShallowRiskEntries;
282
- }
283
-
284
- function stripWrapperChars(value) {
285
- return String(value || '')
286
- .replace(/^[`"'(<\[]+/, '')
287
- .replace(/[`"')>\].,:;!?]+$/, '');
288
- }
289
-
290
- function normalizeCandidatePath(rawValue) {
291
- let value = stripWrapperChars(rawValue);
292
- if (value.startsWith('@')) value = value.slice(1);
293
- if (/^mdc:/i.test(value)) value = value.slice(4);
294
- return value;
295
- }
296
-
297
- function hasKnownFileExtension(baseName) {
298
- return FILE_REFERENCE_EXTENSION_RE.test(baseName || '');
299
- }
300
-
301
- function isVersionLikeToken(candidate) {
302
- return /^v?\d+(?:\.\d+)+(?:[a-z]+\d*|\.[xX*])?$/i.test(candidate || '');
303
- }
304
-
305
- function isFrameworkLabelToken(candidate) {
306
- return FRAMEWORK_LABEL_TOKENS.has(String(candidate || '').toLowerCase());
307
- }
308
-
309
- function isDomainLikeToken(candidate) {
310
- if (!candidate || candidate.includes('/')) return false;
311
- const parts = String(candidate).split('.');
312
- if (parts.length < 2) return false;
313
- const tld = parts[parts.length - 1].toLowerCase();
314
- if (!KNOWN_DOMAIN_TLDS.has(tld)) return false;
315
- return parts.slice(0, -1).every((part) => /^[A-Za-z0-9-]+$/.test(part));
316
- }
317
-
318
- function lineHasExampleContext(line) {
319
- const text = String(line || '');
320
- if (/^\s*\|/.test(text)) return true;
321
- if (/^\s*#{1,6}\s+/.test(text)) return true;
322
- return /\b(?:e\.g\.?|for example|examples?|sample|placeholder|template|snippet|user request|problem|solution)\b/i.test(text);
323
- }
324
-
325
- function looksLikeRelativeFileReference(candidate) {
326
- if (!candidate) return false;
327
- if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
328
- if (candidate.startsWith('#')) return false;
329
- if (/[<>{}|]/.test(candidate)) return false;
330
-
331
- const normalized = candidate.replace(/^\.\//, '');
332
- const base = path.posix.basename(normalized);
333
- const lowered = normalized.toLowerCase();
334
-
335
- if (isDomainLikeToken(normalized)) return false;
336
- if (isVersionLikeToken(normalized)) return false;
337
- if (isFrameworkLabelToken(normalized)) return false;
338
- if (base.startsWith('.') && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
339
- return false;
340
- }
341
- if (normalized.split('/').some((segment) => /^\.[A-Za-z0-9_-]+$/.test(segment) && !COMMON_DOTFILE_BASENAMES.has(segment.toLowerCase()) && !KNOWN_HIDDEN_PATH_SEGMENTS.has(segment.toLowerCase()))) {
342
- return false;
343
- }
344
- if (/^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*){2,}$/i.test(normalized) && !hasKnownFileExtension(base)) {
345
- return false;
346
- }
347
- if (/^\.[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+$/.test(base) && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
348
- return false;
349
- }
350
-
351
- if (KNOWN_CONVENTION_PATHS.has(normalized) || SPECIAL_FILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(lowered)) {
352
- return true;
353
- }
354
-
355
- return hasKnownFileExtension(base);
356
- }
357
-
358
- function resolveRepoPath(ctx, fromFile, candidate, mode = 'relative-to-file') {
359
- const normalized = toPosix(candidate.replace(/^\.\//, ''));
360
- const baseDir = mode === 'repo-root'
361
- ? ctx.dir
362
- : path.join(ctx.dir, path.posix.dirname(toPosix(fromFile)));
363
- const absolute = path.resolve(baseDir, normalized);
364
- const root = path.resolve(ctx.dir);
365
-
366
- if (!(absolute === root || absolute.startsWith(`${root}${path.sep}`))) {
367
- return null;
368
- }
369
-
370
- return toPosix(path.relative(root, absolute));
371
- }
372
-
373
- function getScannableLines(content) {
374
- const lines = String(content || '').split(/\r?\n/);
375
- const output = [];
376
- let fence = null;
377
- let htmlComment = false;
378
- let frontmatter = false;
379
- let frontmatterConsumed = false;
380
-
381
- for (let index = 0; index < lines.length; index++) {
382
- const line = lines[index];
383
- const trimmed = line.trim();
384
-
385
- if (!frontmatterConsumed && index === 0 && /^(---|\+\+\+)$/.test(trimmed)) {
386
- frontmatter = true;
387
- frontmatterConsumed = true;
388
- continue;
389
- }
390
-
391
- if (frontmatter) {
392
- if (/^(---|\+\+\+)$/.test(trimmed)) {
393
- frontmatter = false;
394
- }
395
- continue;
396
- }
397
-
398
- if (!fence && htmlComment) {
399
- if (trimmed.includes('-->')) {
400
- htmlComment = false;
401
- }
402
- continue;
403
- }
404
-
405
- if (!fence && /^(```|~~~)/.test(trimmed)) {
406
- fence = trimmed.slice(0, 3);
407
- continue;
408
- }
409
-
410
- if (fence) {
411
- if (trimmed.startsWith(fence)) {
412
- fence = null;
413
- }
414
- continue;
415
- }
416
-
417
- if (/^<!--/.test(trimmed)) {
418
- if (!trimmed.includes('-->')) {
419
- htmlComment = true;
420
- }
421
- continue;
422
- }
423
-
424
- output.push({ lineNumber: index + 1, text: line });
425
- }
426
-
427
- return output;
428
- }
429
-
430
- function buildFinding(pattern, ctx, finding) {
431
- const evidence = resolveEvidence(pattern.key, ctx, {
432
- file: finding.file,
433
- line: finding.line,
434
- });
435
-
436
- return {
437
- key: pattern.key,
438
- id: null,
439
- name: pattern.name,
440
- category: 'shallow-risk',
441
- layer: LAYERS.SHALLOW_RISK,
442
- severity: finding.severity || pattern.severity,
443
- impact: finding.severity || pattern.severity,
444
- rating: null,
445
- passed: false,
446
- file: evidence ? evidence.file : (finding.file || null),
447
- line: evidence ? evidence.line : (finding.line || null),
448
- snippet: evidence ? evidence.snippet : (finding.snippet || null),
449
- fix: finding.fix || null,
450
- sourceUrl: finding.sourceUrl || pattern.sourceUrl || SHALLOW_RISK_DOC_URL,
451
- };
452
- }
453
-
454
- function isKnownConventionPath(relPath) {
455
- const normalized = toPosix(relPath).replace(/^\.\//, '');
456
- return KNOWN_CONVENTION_PATHS.has(normalized);
457
- }
458
-
459
- function findFirstRepoPath(ctx, matcher, options = {}) {
460
- const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : 4;
461
- const queue = [{ absDir: ctx.dir, relDir: '', depth: 0 }];
462
-
463
- while (queue.length > 0) {
464
- const current = queue.shift();
465
- let entries = [];
466
- try {
467
- entries = fs.readdirSync(current.absDir, { withFileTypes: true });
468
- } catch {
469
- continue;
470
- }
471
-
472
- for (const entry of entries) {
473
- if (EXCLUDED_DIRS.has(entry.name)) continue;
474
- const relPath = toPosix(path.join(current.relDir, entry.name));
475
- const absPath = path.join(current.absDir, entry.name);
476
-
477
- if (entry.isFile()) {
478
- if (typeof matcher === 'function' ? matcher(relPath, entry.name) : matcher === relPath) {
479
- return relPath;
480
- }
481
- continue;
482
- }
483
-
484
- if (entry.isDirectory() && current.depth < maxDepth) {
485
- queue.push({ absDir: absPath, relDir: relPath, depth: current.depth + 1 });
486
- }
487
- }
488
- }
489
-
490
- return null;
491
- }
492
-
493
- function findFirstStackEvidence(ctx, stackKey) {
494
- const stack = STACKS[stackKey];
495
- if (!stack) return null;
496
-
497
- for (const probe of stack.files || []) {
498
- if (fileExists(ctx, probe)) return probe;
499
- }
500
-
501
- return findFirstRepoPath(ctx, (_relPath, baseName) => (stack.files || []).includes(baseName));
502
- }
503
-
504
- function getDetectedStackEvidence(ctx) {
505
- if (Array.isArray(ctx.__nerviqShallowRiskStackEvidence)) {
506
- return ctx.__nerviqShallowRiskStackEvidence;
507
- }
508
-
509
- const seen = new Set();
510
- const evidence = [];
511
-
512
- for (const stack of ctx.detectStacks(STACKS)) {
513
- if (seen.has(stack.key)) continue;
514
- seen.add(stack.key);
515
- const file = findFirstStackEvidence(ctx, stack.key);
516
- if (!file) continue;
517
- evidence.push({
518
- key: stack.key,
519
- label: stack.label,
520
- file,
521
- });
522
- }
523
-
524
- ctx.__nerviqShallowRiskStackEvidence = evidence;
525
- return evidence;
526
- }
527
-
528
- function detectClaimOnLine(line) {
529
- for (const claim of STACK_CLAIMS) {
530
- for (const pattern of claim.patterns) {
531
- pattern.lastIndex = 0;
532
- if (pattern.test(line)) {
533
- return claim;
534
- }
535
- }
536
- }
537
- return null;
538
- }
539
-
540
- function collectStackClaims(ctx) {
541
- if (Array.isArray(ctx.__nerviqShallowRiskClaims)) {
542
- return ctx.__nerviqShallowRiskClaims;
543
- }
544
-
545
- const claims = [];
546
-
547
- for (const entry of getAgentConfigEntries(ctx)) {
548
- for (const { lineNumber, text } of getScannableLines(entry.content)) {
549
- const claim = detectClaimOnLine(text);
550
- if (!claim) continue;
551
- claims.push({
552
- key: claim.key,
553
- label: claim.label,
554
- stackKeys: claim.stackKeys,
555
- file: entry.path,
556
- line: lineNumber,
557
- platform: entry.platform,
558
- text,
559
- });
560
- }
561
- }
562
-
563
- ctx.__nerviqShallowRiskClaims = claims;
564
- return claims;
565
- }
566
-
567
- function getClaimByKey(key) {
568
- return STACK_CLAIM_BY_KEY.get(key) || null;
569
- }
570
-
571
- function isClearlyLocalMcpBinary(command) {
572
- if (!command) return false;
573
- const base = path.posix.basename(toPosix(command)).toLowerCase();
574
- if (LOCAL_MCP_BINARIES.has(base)) return true;
575
- if (/^(node|npx|python|python3|bash|sh|pwsh|powershell)$/i.test(base)) return false;
576
- return /(?:^|[-_.])mcp$/i.test(base) || /-mcp\b/i.test(base);
577
- }
578
-
579
- function getHookCommandPath(command) {
580
- if (typeof command !== 'string' || !command.trim()) return null;
581
- const tokens = command.match(/"[^"]+"|'[^']+'|\S+/g) || [];
582
- const cleaned = tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
583
- if (cleaned.length === 0) return null;
584
-
585
- const first = cleaned[0];
586
- if (looksLikeRelativeFileReference(first)) return first;
587
-
588
- if (/^(node|python|python3|bash|sh|pwsh|powershell)$/i.test(first)) {
589
- const second = cleaned[1];
590
- if (!second || /^-(?:e|c|Command|EncodedCommand)$/.test(second)) return null;
591
- if (looksLikeRelativeFileReference(second)) return second;
592
- }
593
-
594
- return null;
595
- }
596
-
597
- function hasLegacyAiderPin(ctx) {
598
- const files = [
599
- 'requirements.txt',
600
- 'requirements-dev.txt',
601
- 'requirements-dev.in',
602
- 'pyproject.toml',
603
- ];
604
-
605
- const legacyVersion = /(?:aider|aider-chat)\s*(?:==|~=|<=|<)\s*0\.(\d+)/ig;
606
- for (const file of files) {
607
- const content = ctx.fileContent(file) || '';
608
- legacyVersion.lastIndex = 0;
609
- let match = legacyVersion.exec(content);
610
- while (match) {
611
- const minor = Number(match[1]);
612
- if (Number.isFinite(minor) && minor < 60) {
613
- return true;
614
- }
615
- match = legacyVersion.exec(content);
616
- }
617
- }
618
-
619
- return false;
620
- }
621
-
622
- module.exports = {
623
- AIDER_P0_SOURCES,
624
- SHALLOW_RISK_BANNER,
625
- SHALLOW_RISK_BANNER_LINES,
626
- SHALLOW_RISK_DOC_URL,
627
- buildFinding,
628
- collectStackClaims,
629
- escapeRegExp,
630
- fileExists,
631
- findFirstRepoPath,
632
- findFirstStackEvidence,
633
- getAgentConfigEntries,
634
- getAgentConfigFiles,
635
- getClaimByKey,
636
- getDetectedStackEvidence,
637
- getHookCommandPath,
638
- getScannableLines,
639
- hasLegacyAiderPin,
640
- isClearlyLocalMcpBinary,
641
- isKnownConventionPath,
642
- lineHasExampleContext,
643
- looksLikeRelativeFileReference,
644
- normalizeCandidatePath,
645
- platformForFile,
646
- resolveRepoPath,
647
- toPosix,
648
- };
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
+ 'manifest.json',
78
+ 'package.json',
79
+ 'pyproject.toml',
80
+ 'go.mod',
81
+ 'Cargo.toml',
82
+ ]);
83
+
84
+ const COMMON_DOTFILE_BASENAMES = new Set([
85
+ '.editorconfig',
86
+ '.env',
87
+ '.env.example',
88
+ '.env.sample',
89
+ '.env.template',
90
+ '.gitattributes',
91
+ '.gitignore',
92
+ '.npmrc',
93
+ '.nvmrc',
94
+ '.prettierrc',
95
+ '.python-version',
96
+ '.tool-versions',
97
+ ]);
98
+
99
+ const KNOWN_CONVENTION_PATHS = new Set([
100
+ 'CODEOWNERS',
101
+ '.github/CODEOWNERS',
102
+ ]);
103
+
104
+ const FILE_REFERENCE_EXTENSION_RE = /\.(?:md|mdc|txt|rst|json|jsonc|ya?ml|toml|conf|sh|ps1|js|cjs|mjs|ts|tsx|jsx|cts|mts|py|go|rs|java|kt|kts|gradle|cs|rb|php|swift|pbxproj|xcconfig|xcworkspace|xcodeproj|h|hpp|c|cc|cpp|m|mm|sql|ini|cfg|properties|xml|html|css|scss|sass|lock)$/i;
105
+ const KNOWN_DOMAIN_TLDS = new Set([
106
+ 'ai',
107
+ 'app',
108
+ 'co',
109
+ 'com',
110
+ 'dev',
111
+ 'io',
112
+ 'net',
113
+ 'org',
114
+ 'sh',
115
+ ]);
116
+ const KNOWN_HIDDEN_PATH_SEGMENTS = new Set([
117
+ '.claude',
118
+ '.codex',
119
+ '.cursor',
120
+ '.gemini',
121
+ '.github',
122
+ '.opencode',
123
+ '.vscode',
124
+ '.windsurf',
125
+ ]);
126
+ const FRAMEWORK_LABEL_TOKENS = new Set([
127
+ 'd3.js',
128
+ 'go',
129
+ 'golang',
130
+ 'javascript',
131
+ 'kotlin',
132
+ 'next',
133
+ 'next.js',
134
+ 'node',
135
+ 'node.js',
136
+ 'python',
137
+ 'rust',
138
+ 'swift',
139
+ 'typescript',
140
+ ]);
141
+
142
+ const LOCAL_MCP_BINARIES = new Set([
143
+ 'context7-mcp',
144
+ 'nerviq-mcp',
145
+ ]);
146
+
147
+ const STACK_CLAIMS = [
148
+ { 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] },
149
+ { 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] },
150
+ { 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] },
151
+ { 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] },
152
+ { 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] },
153
+ { 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] },
154
+ { 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] },
155
+ { 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] },
156
+ { 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] },
157
+ { 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] },
158
+ { 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] },
159
+ { 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] },
160
+ { 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] },
161
+ ];
162
+
163
+ const STACK_CLAIM_BY_KEY = new Map(STACK_CLAIMS.map((claim) => [claim.key, claim]));
164
+
165
+ function toPosix(filePath) {
166
+ return String(filePath || '').replace(/\\/g, '/');
167
+ }
168
+
169
+ function escapeRegExp(value) {
170
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
171
+ }
172
+
173
+ function existsSyncSafe(targetPath) {
174
+ try {
175
+ return fs.existsSync(targetPath);
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ function isLikelyTextFile(relPath) {
182
+ const base = path.posix.basename(toPosix(relPath));
183
+ if (SPECIAL_FILE_BASENAMES.has(base)) return true;
184
+ if (COMMON_DOTFILE_BASENAMES.has(base)) return true;
185
+ if (base === '.cursorrules' || base === '.windsurfrules') return true;
186
+ return hasKnownFileExtension(base);
187
+ }
188
+
189
+ function fileExists(ctx, relPath) {
190
+ return existsSyncSafe(path.join(ctx.dir, relPath));
191
+ }
192
+
193
+ function listFilesRecursive(rootDir, relDir = '', output = []) {
194
+ const absDir = path.join(rootDir, relDir);
195
+ let entries = [];
196
+ try {
197
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
198
+ } catch {
199
+ return output;
200
+ }
201
+
202
+ for (const entry of entries) {
203
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
204
+ const nextRel = toPosix(path.join(relDir, entry.name));
205
+ if (entry.isDirectory()) {
206
+ listFilesRecursive(rootDir, nextRel, output);
207
+ continue;
208
+ }
209
+ if (entry.isFile() && isLikelyTextFile(nextRel)) {
210
+ output.push(nextRel);
211
+ }
212
+ }
213
+ return output;
214
+ }
215
+
216
+ function getAgentConfigFiles(ctx) {
217
+ if (Array.isArray(ctx.__nerviqShallowRiskFiles)) {
218
+ return ctx.__nerviqShallowRiskFiles;
219
+ }
220
+
221
+ const files = new Set();
222
+
223
+ for (const relPath of ROOT_AGENT_FILES) {
224
+ if (fileExists(ctx, relPath)) {
225
+ files.add(toPosix(relPath));
226
+ }
227
+ }
228
+
229
+ for (const relDir of ROOT_AGENT_DIRS) {
230
+ if (!existsSyncSafe(path.join(ctx.dir, relDir))) continue;
231
+ for (const relPath of listFilesRecursive(ctx.dir, relDir)) {
232
+ files.add(toPosix(relPath));
233
+ }
234
+ }
235
+
236
+ ctx.__nerviqShallowRiskFiles = [...files]
237
+ .filter((relPath) => {
238
+ try {
239
+ const size = fs.statSync(path.join(ctx.dir, relPath)).size;
240
+ return Number.isFinite(size) && size <= 512 * 1024;
241
+ } catch {
242
+ return false;
243
+ }
244
+ })
245
+ .sort();
246
+
247
+ return ctx.__nerviqShallowRiskFiles;
248
+ }
249
+
250
+ function platformForFile(relPath) {
251
+ const normalized = toPosix(relPath);
252
+ if (normalized === 'CLAUDE.md' || normalized.startsWith('.claude/')) return 'claude';
253
+ if (normalized === 'AGENTS.md' || normalized.startsWith('.codex/')) return 'codex';
254
+ if (normalized === 'GEMINI.md' || normalized.startsWith('.gemini/')) return 'gemini';
255
+ if (normalized === '.cursorrules' || normalized.startsWith('.cursor/')) return 'cursor';
256
+ if (normalized === '.windsurfrules' || normalized.startsWith('.windsurf/')) return 'windsurf';
257
+ if (normalized.startsWith('.aider.')) return 'aider';
258
+ if (normalized.startsWith('.github/') || normalized.startsWith('.vscode/')) return 'copilot';
259
+ if (normalized === 'opencode.json' || normalized.startsWith('.opencode/')) return 'opencode';
260
+ if (normalized === '.mcp.json') return 'claude';
261
+ return 'agent';
262
+ }
263
+
264
+ function getAgentConfigEntries(ctx) {
265
+ if (Array.isArray(ctx.__nerviqShallowRiskEntries)) {
266
+ return ctx.__nerviqShallowRiskEntries;
267
+ }
268
+
269
+ ctx.__nerviqShallowRiskEntries = getAgentConfigFiles(ctx)
270
+ .map((file) => {
271
+ const content = ctx.fileContent(file);
272
+ if (!content || !content.trim()) return null;
273
+ return {
274
+ path: file,
275
+ platform: platformForFile(file),
276
+ content,
277
+ };
278
+ })
279
+ .filter(Boolean);
280
+
281
+ return ctx.__nerviqShallowRiskEntries;
282
+ }
283
+
284
+ function stripWrapperChars(value) {
285
+ return String(value || '')
286
+ .replace(/^[`"'(<\[]+/, '')
287
+ .replace(/[`"')>\].,:;!?]+$/, '');
288
+ }
289
+
290
+ function normalizeCandidatePath(rawValue) {
291
+ let value = stripWrapperChars(rawValue);
292
+ if (value.startsWith('@')) value = value.slice(1);
293
+ if (/^mdc:/i.test(value)) value = value.slice(4);
294
+ return value;
295
+ }
296
+
297
+ function hasKnownFileExtension(baseName) {
298
+ return FILE_REFERENCE_EXTENSION_RE.test(baseName || '');
299
+ }
300
+
301
+ function isVersionLikeToken(candidate) {
302
+ return /^v?\d+(?:\.\d+)+(?:[a-z]+\d*|\.[xX*])?$/i.test(candidate || '');
303
+ }
304
+
305
+ function isFrameworkLabelToken(candidate) {
306
+ return FRAMEWORK_LABEL_TOKENS.has(String(candidate || '').toLowerCase());
307
+ }
308
+
309
+ function isDomainLikeToken(candidate) {
310
+ if (!candidate || candidate.includes('/')) return false;
311
+ const parts = String(candidate).split('.');
312
+ if (parts.length < 2) return false;
313
+ const tld = parts[parts.length - 1].toLowerCase();
314
+ if (!KNOWN_DOMAIN_TLDS.has(tld)) return false;
315
+ return parts.slice(0, -1).every((part) => /^[A-Za-z0-9-]+$/.test(part));
316
+ }
317
+
318
+ function lineHasExampleContext(line) {
319
+ const text = String(line || '');
320
+ if (/^\s*\|/.test(text)) return true;
321
+ if (/^\s*#{1,6}\s+/.test(text)) return true;
322
+ return /\b(?:e\.g\.?|for example|examples?|sample|placeholder|template|snippet|user request|problem|solution)\b/i.test(text);
323
+ }
324
+
325
+ function looksLikeRelativeFileReference(candidate) {
326
+ if (!candidate) return false;
327
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
328
+ if (candidate.startsWith('#')) return false;
329
+ if (/[<>{}|]/.test(candidate)) return false;
330
+
331
+ const normalized = candidate.replace(/^\.\//, '');
332
+ const base = path.posix.basename(normalized);
333
+ const lowered = normalized.toLowerCase();
334
+
335
+ if (isDomainLikeToken(normalized)) return false;
336
+ if (isVersionLikeToken(normalized)) return false;
337
+ if (isFrameworkLabelToken(normalized)) return false;
338
+ if (base.startsWith('.') && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
339
+ return false;
340
+ }
341
+ if (normalized.split('/').some((segment) => /^\.[A-Za-z0-9_-]+$/.test(segment) && !COMMON_DOTFILE_BASENAMES.has(segment.toLowerCase()) && !KNOWN_HIDDEN_PATH_SEGMENTS.has(segment.toLowerCase()))) {
342
+ return false;
343
+ }
344
+ if (/^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*){2,}$/i.test(normalized) && !hasKnownFileExtension(base)) {
345
+ return false;
346
+ }
347
+ if (/^\.[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+$/.test(base) && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
348
+ return false;
349
+ }
350
+
351
+ if (KNOWN_CONVENTION_PATHS.has(normalized) || SPECIAL_FILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(lowered)) {
352
+ return true;
353
+ }
354
+
355
+ return hasKnownFileExtension(base);
356
+ }
357
+
358
+ function resolveRepoPath(ctx, fromFile, candidate, mode = 'relative-to-file') {
359
+ const normalized = toPosix(candidate.replace(/^\.\//, ''));
360
+ const baseDir = mode === 'repo-root'
361
+ ? ctx.dir
362
+ : path.join(ctx.dir, path.posix.dirname(toPosix(fromFile)));
363
+ const absolute = path.resolve(baseDir, normalized);
364
+ const root = path.resolve(ctx.dir);
365
+
366
+ if (!(absolute === root || absolute.startsWith(`${root}${path.sep}`))) {
367
+ return null;
368
+ }
369
+
370
+ return toPosix(path.relative(root, absolute));
371
+ }
372
+
373
+ function getScannableLines(content) {
374
+ const lines = String(content || '').split(/\r?\n/);
375
+ const output = [];
376
+ let fence = null;
377
+ let htmlComment = false;
378
+ let frontmatter = false;
379
+ let frontmatterConsumed = false;
380
+
381
+ for (let index = 0; index < lines.length; index++) {
382
+ const line = lines[index];
383
+ const trimmed = line.trim();
384
+
385
+ if (!frontmatterConsumed && index === 0 && /^(---|\+\+\+)$/.test(trimmed)) {
386
+ frontmatter = true;
387
+ frontmatterConsumed = true;
388
+ continue;
389
+ }
390
+
391
+ if (frontmatter) {
392
+ if (/^(---|\+\+\+)$/.test(trimmed)) {
393
+ frontmatter = false;
394
+ }
395
+ continue;
396
+ }
397
+
398
+ if (!fence && htmlComment) {
399
+ if (trimmed.includes('-->')) {
400
+ htmlComment = false;
401
+ }
402
+ continue;
403
+ }
404
+
405
+ if (!fence && /^(```|~~~)/.test(trimmed)) {
406
+ fence = trimmed.slice(0, 3);
407
+ continue;
408
+ }
409
+
410
+ if (fence) {
411
+ if (trimmed.startsWith(fence)) {
412
+ fence = null;
413
+ }
414
+ continue;
415
+ }
416
+
417
+ if (/^<!--/.test(trimmed)) {
418
+ if (!trimmed.includes('-->')) {
419
+ htmlComment = true;
420
+ }
421
+ continue;
422
+ }
423
+
424
+ output.push({ lineNumber: index + 1, text: line });
425
+ }
426
+
427
+ return output;
428
+ }
429
+
430
+ function buildFinding(pattern, ctx, finding) {
431
+ const evidence = resolveEvidence(pattern.key, ctx, {
432
+ file: finding.file,
433
+ line: finding.line,
434
+ });
435
+
436
+ return {
437
+ key: pattern.key,
438
+ id: null,
439
+ name: pattern.name,
440
+ category: 'shallow-risk',
441
+ layer: LAYERS.SHALLOW_RISK,
442
+ severity: finding.severity || pattern.severity,
443
+ impact: finding.severity || pattern.severity,
444
+ rating: null,
445
+ passed: false,
446
+ file: evidence ? evidence.file : (finding.file || null),
447
+ line: evidence ? evidence.line : (finding.line || null),
448
+ snippet: evidence ? evidence.snippet : (finding.snippet || null),
449
+ fix: finding.fix || null,
450
+ sourceUrl: finding.sourceUrl || pattern.sourceUrl || SHALLOW_RISK_DOC_URL,
451
+ };
452
+ }
453
+
454
+ function isKnownConventionPath(relPath) {
455
+ const normalized = toPosix(relPath).replace(/^\.\//, '');
456
+ return KNOWN_CONVENTION_PATHS.has(normalized);
457
+ }
458
+
459
+ function findFirstRepoPath(ctx, matcher, options = {}) {
460
+ const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : 4;
461
+ const queue = [{ absDir: ctx.dir, relDir: '', depth: 0 }];
462
+
463
+ while (queue.length > 0) {
464
+ const current = queue.shift();
465
+ let entries = [];
466
+ try {
467
+ entries = fs.readdirSync(current.absDir, { withFileTypes: true });
468
+ } catch {
469
+ continue;
470
+ }
471
+
472
+ for (const entry of entries) {
473
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
474
+ const relPath = toPosix(path.join(current.relDir, entry.name));
475
+ const absPath = path.join(current.absDir, entry.name);
476
+
477
+ if (entry.isFile()) {
478
+ if (typeof matcher === 'function' ? matcher(relPath, entry.name) : matcher === relPath) {
479
+ return relPath;
480
+ }
481
+ continue;
482
+ }
483
+
484
+ if (entry.isDirectory() && current.depth < maxDepth) {
485
+ queue.push({ absDir: absPath, relDir: relPath, depth: current.depth + 1 });
486
+ }
487
+ }
488
+ }
489
+
490
+ return null;
491
+ }
492
+
493
+ function findFirstStackEvidence(ctx, stackKey) {
494
+ const stack = STACKS[stackKey];
495
+ if (!stack) return null;
496
+
497
+ for (const probe of stack.files || []) {
498
+ if (fileExists(ctx, probe)) return probe;
499
+ }
500
+
501
+ return findFirstRepoPath(ctx, (_relPath, baseName) => (stack.files || []).includes(baseName));
502
+ }
503
+
504
+ function getDetectedStackEvidence(ctx) {
505
+ if (Array.isArray(ctx.__nerviqShallowRiskStackEvidence)) {
506
+ return ctx.__nerviqShallowRiskStackEvidence;
507
+ }
508
+
509
+ const seen = new Set();
510
+ const evidence = [];
511
+
512
+ for (const stack of ctx.detectStacks(STACKS)) {
513
+ if (seen.has(stack.key)) continue;
514
+ seen.add(stack.key);
515
+ const file = findFirstStackEvidence(ctx, stack.key);
516
+ if (!file) continue;
517
+ evidence.push({
518
+ key: stack.key,
519
+ label: stack.label,
520
+ file,
521
+ });
522
+ }
523
+
524
+ ctx.__nerviqShallowRiskStackEvidence = evidence;
525
+ return evidence;
526
+ }
527
+
528
+ function detectClaimOnLine(line) {
529
+ for (const claim of STACK_CLAIMS) {
530
+ for (const pattern of claim.patterns) {
531
+ pattern.lastIndex = 0;
532
+ if (pattern.test(line)) {
533
+ return claim;
534
+ }
535
+ }
536
+ }
537
+ return null;
538
+ }
539
+
540
+ function collectStackClaims(ctx) {
541
+ if (Array.isArray(ctx.__nerviqShallowRiskClaims)) {
542
+ return ctx.__nerviqShallowRiskClaims;
543
+ }
544
+
545
+ const claims = [];
546
+
547
+ for (const entry of getAgentConfigEntries(ctx)) {
548
+ for (const { lineNumber, text } of getScannableLines(entry.content)) {
549
+ const claim = detectClaimOnLine(text);
550
+ if (!claim) continue;
551
+ claims.push({
552
+ key: claim.key,
553
+ label: claim.label,
554
+ stackKeys: claim.stackKeys,
555
+ file: entry.path,
556
+ line: lineNumber,
557
+ platform: entry.platform,
558
+ text,
559
+ });
560
+ }
561
+ }
562
+
563
+ ctx.__nerviqShallowRiskClaims = claims;
564
+ return claims;
565
+ }
566
+
567
+ function getClaimByKey(key) {
568
+ return STACK_CLAIM_BY_KEY.get(key) || null;
569
+ }
570
+
571
+ function isClearlyLocalMcpBinary(command) {
572
+ if (!command) return false;
573
+ const base = path.posix.basename(toPosix(command)).toLowerCase();
574
+ if (LOCAL_MCP_BINARIES.has(base)) return true;
575
+ if (/^(node|npx|python|python3|bash|sh|pwsh|powershell)$/i.test(base)) return false;
576
+ return /(?:^|[-_.])mcp$/i.test(base) || /-mcp\b/i.test(base);
577
+ }
578
+
579
+ function getHookCommandPath(command) {
580
+ if (typeof command !== 'string' || !command.trim()) return null;
581
+ const tokens = command.match(/"[^"]+"|'[^']+'|\S+/g) || [];
582
+ const cleaned = tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
583
+ if (cleaned.length === 0) return null;
584
+
585
+ const first = cleaned[0];
586
+ if (looksLikeRelativeFileReference(first)) return first;
587
+
588
+ if (/^(node|python|python3|bash|sh|pwsh|powershell)$/i.test(first)) {
589
+ const second = cleaned[1];
590
+ if (!second || /^-(?:e|c|Command|EncodedCommand)$/.test(second)) return null;
591
+ if (looksLikeRelativeFileReference(second)) return second;
592
+ }
593
+
594
+ return null;
595
+ }
596
+
597
+ function hasLegacyAiderPin(ctx) {
598
+ const files = [
599
+ 'requirements.txt',
600
+ 'requirements-dev.txt',
601
+ 'requirements-dev.in',
602
+ 'pyproject.toml',
603
+ ];
604
+
605
+ const legacyVersion = /(?:aider|aider-chat)\s*(?:==|~=|<=|<)\s*0\.(\d+)/ig;
606
+ for (const file of files) {
607
+ const content = ctx.fileContent(file) || '';
608
+ legacyVersion.lastIndex = 0;
609
+ let match = legacyVersion.exec(content);
610
+ while (match) {
611
+ const minor = Number(match[1]);
612
+ if (Number.isFinite(minor) && minor < 60) {
613
+ return true;
614
+ }
615
+ match = legacyVersion.exec(content);
616
+ }
617
+ }
618
+
619
+ return false;
620
+ }
621
+
622
+ module.exports = {
623
+ AIDER_P0_SOURCES,
624
+ SHALLOW_RISK_BANNER,
625
+ SHALLOW_RISK_BANNER_LINES,
626
+ SHALLOW_RISK_DOC_URL,
627
+ buildFinding,
628
+ collectStackClaims,
629
+ escapeRegExp,
630
+ fileExists,
631
+ findFirstRepoPath,
632
+ findFirstStackEvidence,
633
+ getAgentConfigEntries,
634
+ getAgentConfigFiles,
635
+ getClaimByKey,
636
+ getDetectedStackEvidence,
637
+ getHookCommandPath,
638
+ getScannableLines,
639
+ hasLegacyAiderPin,
640
+ isClearlyLocalMcpBinary,
641
+ isKnownConventionPath,
642
+ lineHasExampleContext,
643
+ looksLikeRelativeFileReference,
644
+ normalizeCandidatePath,
645
+ platformForFile,
646
+ resolveRepoPath,
647
+ toPosix,
648
+ };