@safetnsr/vet 1.7.0 → 1.8.2

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 (42) hide show
  1. package/dist/checks/debt.js +17 -4
  2. package/dist/checks/deps.js +146 -135
  3. package/dist/checks/diff.js +23 -4
  4. package/dist/checks/integrity.js +94 -17
  5. package/dist/checks/memory.js +6 -10
  6. package/dist/checks/models.js +60 -13
  7. package/dist/checks/owasp/asi01-goal-hijack.d.ts +5 -0
  8. package/dist/checks/owasp/asi01-goal-hijack.js +49 -0
  9. package/dist/checks/owasp/asi02-tool-misuse.d.ts +5 -0
  10. package/dist/checks/owasp/asi02-tool-misuse.js +98 -0
  11. package/dist/checks/owasp/asi03-identity-abuse.d.ts +5 -0
  12. package/dist/checks/owasp/asi03-identity-abuse.js +80 -0
  13. package/dist/checks/owasp/asi04-supply-chain.d.ts +5 -0
  14. package/dist/checks/owasp/asi04-supply-chain.js +79 -0
  15. package/dist/checks/owasp/asi05-code-execution.d.ts +5 -0
  16. package/dist/checks/owasp/asi05-code-execution.js +67 -0
  17. package/dist/checks/owasp/asi06-memory-poisoning.d.ts +5 -0
  18. package/dist/checks/owasp/asi06-memory-poisoning.js +61 -0
  19. package/dist/checks/owasp/asi07-inter-agent.d.ts +5 -0
  20. package/dist/checks/owasp/asi07-inter-agent.js +62 -0
  21. package/dist/checks/owasp/asi08-cascading.d.ts +5 -0
  22. package/dist/checks/owasp/asi08-cascading.js +36 -0
  23. package/dist/checks/owasp/asi09-trust-exploitation.d.ts +5 -0
  24. package/dist/checks/owasp/asi09-trust-exploitation.js +65 -0
  25. package/dist/checks/owasp/asi10-rogue-agents.d.ts +5 -0
  26. package/dist/checks/owasp/asi10-rogue-agents.js +31 -0
  27. package/dist/checks/owasp/index.d.ts +11 -0
  28. package/dist/checks/owasp/index.js +11 -0
  29. package/dist/checks/owasp/shared.d.ts +11 -0
  30. package/dist/checks/owasp/shared.js +61 -0
  31. package/dist/checks/owasp.js +1 -1
  32. package/dist/checks/ready.js +42 -7
  33. package/dist/checks/receipt.js +2 -16
  34. package/dist/checks/scan.js +54 -6
  35. package/dist/checks/secrets.js +2 -20
  36. package/dist/checks/tests.d.ts +3 -0
  37. package/dist/checks/tests.js +10 -0
  38. package/dist/checks/verify.js +35 -2
  39. package/dist/cli.js +111 -69
  40. package/dist/util.d.ts +0 -1
  41. package/dist/util.js +1 -1
  42. package/package.json +1 -1
@@ -0,0 +1,36 @@
1
+ import { readTextFile } from './shared.js';
2
+ // ── ASI08 — Cascading Failures ────────────────────────────────────────────────
3
+ export function checkASI08(cwd, configFiles) {
4
+ const findings = [];
5
+ if (configFiles.length === 0)
6
+ return { findings, deduction: 0 };
7
+ const workflowKeywords = ['step', 'workflow', 'pipeline', 'sequence', 'chain', 'loop', 'iterate'];
8
+ const errorHandlingKeywords = [
9
+ 'rollback', 'undo', 'revert', 'recover', 'retry', 'error handling', 'handle error',
10
+ 'circuit breaker', 'rate limit', 'max retries', 'timeout', 'fail safe', 'fallback',
11
+ 'on error', 'if it fails', 'if something goes wrong',
12
+ ];
13
+ let hasWorkflowContent = false;
14
+ let hasErrorHandling = false;
15
+ for (const filePath of configFiles) {
16
+ const content = readTextFile(filePath);
17
+ if (!content)
18
+ continue;
19
+ if (workflowKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
20
+ hasWorkflowContent = true;
21
+ }
22
+ if (errorHandlingKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
23
+ hasErrorHandling = true;
24
+ }
25
+ }
26
+ if (hasWorkflowContent && !hasErrorHandling) {
27
+ findings.push({
28
+ asiId: 'ASI08',
29
+ severity: 'warning',
30
+ message: 'ASI08: multi-step workflow config lacks error handling, rollback, or recovery instructions',
31
+ fixHint: 'add error handling instructions: rollback procedures, retry limits, and circuit breakers',
32
+ });
33
+ return { findings, deduction: 5 };
34
+ }
35
+ return { findings, deduction: 0 };
36
+ }
@@ -0,0 +1,5 @@
1
+ import { type OwaspFinding } from './shared.js';
2
+ export declare function checkASI09(cwd: string, configFiles: string[]): {
3
+ findings: OwaspFinding[];
4
+ deduction: number;
5
+ };
@@ -0,0 +1,65 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { readTextFile } from './shared.js';
4
+ // ── ASI09 — Human-Agent Trust Exploitation ────────────────────────────────────
5
+ export function checkASI09(cwd, configFiles) {
6
+ const findings = [];
7
+ if (configFiles.length === 0)
8
+ return { findings, deduction: 0 };
9
+ const destructiveKeywords = ['delete', 'drop', 'remove', 'deploy', 'publish', 'push', 'rm ', 'truncate'];
10
+ const approvalKeywords = [
11
+ 'confirm', 'approval', 'approve', 'ask.*before', 'human.*review', 'manual.*review',
12
+ 'permission', 'consent', 'verify.*before', 'check.*before', 'gate',
13
+ ];
14
+ let hasDestructiveOps = false;
15
+ let hasApprovalGate = false;
16
+ for (const filePath of configFiles) {
17
+ const content = readTextFile(filePath);
18
+ if (!content)
19
+ continue;
20
+ if (destructiveKeywords.some(kw => new RegExp(`\\b${kw}\\b`, 'i').test(content))) {
21
+ hasDestructiveOps = true;
22
+ }
23
+ if (approvalKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
24
+ hasApprovalGate = true;
25
+ }
26
+ }
27
+ const claudeSettings = join(cwd, '.claude', 'settings.json');
28
+ if (existsSync(claudeSettings)) {
29
+ const content = readTextFile(claudeSettings);
30
+ if (content) {
31
+ try {
32
+ const settings = JSON.parse(content);
33
+ if (settings.autoApprove === true || (Array.isArray(settings.autoApprove) && settings.autoApprove.length > 0)) {
34
+ findings.push({
35
+ asiId: 'ASI09',
36
+ severity: 'warning',
37
+ message: 'ASI09: autoApprove enabled in .claude/settings.json — destructive ops may run unattended',
38
+ file: '.claude/settings.json',
39
+ fixHint: 'disable autoApprove or restrict it to non-destructive operations only',
40
+ });
41
+ return { findings, deduction: 10 };
42
+ }
43
+ }
44
+ catch { /* intentional: skip unparseable settings */ }
45
+ }
46
+ }
47
+ if (hasDestructiveOps && !hasApprovalGate) {
48
+ findings.push({
49
+ asiId: 'ASI09',
50
+ severity: 'warning',
51
+ message: 'ASI09: agent config references destructive operations (delete/deploy/publish) without approval gates',
52
+ fixHint: 'require explicit human confirmation before destructive operations (delete, deploy, publish)',
53
+ });
54
+ return { findings, deduction: 10 };
55
+ }
56
+ if (!hasApprovalGate) {
57
+ findings.push({
58
+ asiId: 'ASI09',
59
+ severity: 'info',
60
+ message: 'ASI09: no human approval gates mentioned in agent configs',
61
+ fixHint: 'document which operations require human approval in your agent config',
62
+ });
63
+ }
64
+ return { findings, deduction: 0 };
65
+ }
@@ -0,0 +1,5 @@
1
+ import { type OwaspFinding } from './shared.js';
2
+ export declare function checkASI10(cwd: string, configFiles: string[]): {
3
+ findings: OwaspFinding[];
4
+ deduction: number;
5
+ };
@@ -0,0 +1,31 @@
1
+ import { readTextFile } from './shared.js';
2
+ // ── ASI10 — Rogue Agents ─────────────────────────────────────────────────────
3
+ export function checkASI10(cwd, configFiles) {
4
+ const findings = [];
5
+ if (configFiles.length === 0)
6
+ return { findings, deduction: 0 };
7
+ const monitoringKeywords = [
8
+ 'log', 'audit', 'monitor', 'alert', 'trace', 'observ', 'kill switch',
9
+ 'stop', 'timeout', 'session limit', 'max token', 'budget', 'governance',
10
+ ];
11
+ let hasGovernance = false;
12
+ for (const filePath of configFiles) {
13
+ const content = readTextFile(filePath);
14
+ if (!content)
15
+ continue;
16
+ if (monitoringKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
17
+ hasGovernance = true;
18
+ break;
19
+ }
20
+ }
21
+ if (!hasGovernance) {
22
+ findings.push({
23
+ asiId: 'ASI10',
24
+ severity: 'info',
25
+ message: 'ASI10: no monitoring, logging, or kill switch mechanisms mentioned in agent configs',
26
+ fixHint: 'add monitoring/audit trail requirements and session limits to your agent config',
27
+ });
28
+ return { findings, deduction: 5 };
29
+ }
30
+ return { findings, deduction: 0 };
31
+ }
@@ -0,0 +1,11 @@
1
+ export { type OwaspFinding, collectAgentConfigFiles, collectMcpConfigFiles, readTextFile } from './shared.js';
2
+ export { checkASI01 } from './asi01-goal-hijack.js';
3
+ export { checkASI02 } from './asi02-tool-misuse.js';
4
+ export { checkASI03 } from './asi03-identity-abuse.js';
5
+ export { checkASI04 } from './asi04-supply-chain.js';
6
+ export { checkASI05 } from './asi05-code-execution.js';
7
+ export { checkASI06 } from './asi06-memory-poisoning.js';
8
+ export { checkASI07 } from './asi07-inter-agent.js';
9
+ export { checkASI08 } from './asi08-cascading.js';
10
+ export { checkASI09 } from './asi09-trust-exploitation.js';
11
+ export { checkASI10 } from './asi10-rogue-agents.js';
@@ -0,0 +1,11 @@
1
+ export { collectAgentConfigFiles, collectMcpConfigFiles, readTextFile } from './shared.js';
2
+ export { checkASI01 } from './asi01-goal-hijack.js';
3
+ export { checkASI02 } from './asi02-tool-misuse.js';
4
+ export { checkASI03 } from './asi03-identity-abuse.js';
5
+ export { checkASI04 } from './asi04-supply-chain.js';
6
+ export { checkASI05 } from './asi05-code-execution.js';
7
+ export { checkASI06 } from './asi06-memory-poisoning.js';
8
+ export { checkASI07 } from './asi07-inter-agent.js';
9
+ export { checkASI08 } from './asi08-cascading.js';
10
+ export { checkASI09 } from './asi09-trust-exploitation.js';
11
+ export { checkASI10 } from './asi10-rogue-agents.js';
@@ -0,0 +1,11 @@
1
+ export declare function collectAgentConfigFiles(cwd: string): string[];
2
+ export declare function collectMcpConfigFiles(cwd: string): string[];
3
+ export declare function readTextFile(filePath: string): string | null;
4
+ export interface OwaspFinding {
5
+ asiId: string;
6
+ severity: 'error' | 'warning' | 'info';
7
+ message: string;
8
+ file?: string;
9
+ line?: number;
10
+ fixHint?: string;
11
+ }
@@ -0,0 +1,61 @@
1
+ import { join } from 'node:path';
2
+ import { readFileSync, statSync, existsSync } from 'node:fs';
3
+ import { isTextFile, collectDirFiles } from '../../util.js';
4
+ // ── Agent config file targets ─────────────────────────────────────────────────
5
+ const AGENT_CONFIG_TARGETS = [
6
+ '.claude',
7
+ 'CLAUDE.md',
8
+ 'AGENTS.md',
9
+ '.cursorrules',
10
+ '.cursor',
11
+ '.github/copilot-instructions.md',
12
+ '.mcp',
13
+ 'mcp.json',
14
+ '.aider.conf.yml',
15
+ '.continue',
16
+ '.roomodes',
17
+ '.roo',
18
+ 'codex.md',
19
+ ];
20
+ const MCP_CONFIG_PATHS = [
21
+ 'mcp.json',
22
+ '.mcp',
23
+ '.cursor/mcp.json',
24
+ '.claude/mcp.json',
25
+ ];
26
+ // ── File helpers ──────────────────────────────────────────────────────────────
27
+ /** Collect files for a given list of target paths (files or directories). */
28
+ function collectConfigFiles(cwd, targets) {
29
+ const files = [];
30
+ for (const target of targets) {
31
+ const full = join(cwd, target);
32
+ if (!existsSync(full))
33
+ continue;
34
+ try {
35
+ const s = statSync(full);
36
+ if (s.isFile()) {
37
+ files.push(full);
38
+ }
39
+ else if (s.isDirectory()) {
40
+ files.push(...collectDirFiles(full));
41
+ }
42
+ }
43
+ catch { /* intentional: resolver may fail on unreadable files */ }
44
+ }
45
+ return [...new Set(files)];
46
+ }
47
+ export function collectAgentConfigFiles(cwd) {
48
+ return collectConfigFiles(cwd, AGENT_CONFIG_TARGETS);
49
+ }
50
+ export function collectMcpConfigFiles(cwd) {
51
+ return collectConfigFiles(cwd, MCP_CONFIG_PATHS);
52
+ }
53
+ export function readTextFile(filePath) {
54
+ if (!isTextFile(filePath))
55
+ return null;
56
+ try {
57
+ return readFileSync(filePath, 'utf-8');
58
+ }
59
+ catch { /* intentional: resolver may fail on unreadable files */ }
60
+ return null;
61
+ }
@@ -1,4 +1,4 @@
1
- import { collectAgentConfigFiles, collectMcpConfigFiles, checkASI01, checkASI02, checkASI03, checkASI04, checkASI05, checkASI06, checkASI07, checkASI08, checkASI09, checkASI10, } from './owasp-checks.js';
1
+ import { collectAgentConfigFiles, collectMcpConfigFiles, checkASI01, checkASI02, checkASI03, checkASI04, checkASI05, checkASI06, checkASI07, checkASI08, checkASI09, checkASI10, } from './owasp/index.js';
2
2
  // ── Main export ───────────────────────────────────────────────────────────────
3
3
  export function checkOwasp(cwd) {
4
4
  const allConfigFiles = collectAgentConfigFiles(cwd);
@@ -48,13 +48,40 @@ function builtinReady(cwd, ignore) {
48
48
  if (!hasReadme) {
49
49
  issues.push({ severity: 'error', message: 'no README — AI agents have no project context', fixable: true, fixHint: 'create a README.md' });
50
50
  }
51
+ // Detect Python project (root or subdirs)
52
+ const pythonMarkers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'];
53
+ const hasPythonRoot = pythonMarkers.some(m => files.includes(m));
54
+ const hasPythonSubdir = files.some(f => pythonMarkers.some(m => f.endsWith('/' + m) || f.endsWith('\\' + m)));
55
+ const isPython = hasPythonRoot || hasPythonSubdir;
56
+ // Detect monorepo (multiple manifests in subdirs)
57
+ const subPyprojects = files.filter(f => f !== 'pyproject.toml' && f.endsWith('pyproject.toml'));
58
+ const subPackageJsons = files.filter(f => f !== 'package.json' && f.endsWith('package.json'));
59
+ const isMonorepo = subPyprojects.length > 0 || subPackageJsons.length > 1;
51
60
  const manifests = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json'];
52
61
  const hasManifest = manifests.some(m => files.includes(m));
53
- if (!hasManifest) {
62
+ // For Python projects, any pyproject.toml in subdirs counts as a manifest
63
+ const hasManifestAnywhere = hasManifest || isPython;
64
+ if (!hasManifestAnywhere) {
54
65
  issues.push({ severity: 'error', message: 'no package manifest — agents can\'t resolve dependencies', fixable: false });
55
66
  }
56
67
  const codeExts = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php', '.cs', '.swift', '.kt'];
57
- const testFiles = files.filter(f => /\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f) || f.includes('__tests__/') || f.startsWith('tests/') || f.startsWith('test/'));
68
+ // Broader test detection: includes Python test patterns and nested test directories
69
+ const testFiles = files.filter(f => {
70
+ if (/\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f))
71
+ return true;
72
+ if (f.includes('__tests__/'))
73
+ return true;
74
+ if (f.startsWith('tests/') || f.startsWith('test/'))
75
+ return true;
76
+ if (f.includes('/tests/') || f.includes('/test/'))
77
+ return true;
78
+ // Python test file patterns: test_*.py, *_test.py
79
+ if (/(?:^|[/\\])test_[^/\\]+\.py$/.test(f))
80
+ return true;
81
+ if (/(?:^|[/\\])[^/\\]+_test\.py$/.test(f))
82
+ return true;
83
+ return false;
84
+ });
58
85
  const codeFiles = files.filter(f => codeExts.some(ext => f.endsWith(ext)));
59
86
  if (codeFiles.length > 5 && testFiles.length === 0) {
60
87
  issues.push({ severity: 'error', message: 'no tests — AI agents produce better code when tests exist to validate against', fixable: false });
@@ -88,17 +115,25 @@ function builtinReady(cwd, ignore) {
88
115
  const warnings = issues.filter(i => i.severity === 'warning').length;
89
116
  const infos = issues.filter(i => i.severity === 'info').length;
90
117
  const score = Math.max(0, Math.min(100, 100 - errors * 30 - warnings * 15 - infos * 3));
118
+ let summary = issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} readiness issues`;
119
+ if (isMonorepo)
120
+ summary += ' (monorepo detected)';
91
121
  return {
92
122
  name: 'ready',
93
123
  score: Math.round(score),
94
124
  maxScore: 100,
95
125
  issues,
96
- summary: issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} readiness issues`,
126
+ summary,
97
127
  };
98
128
  }
99
129
  export async function checkReady(cwd, ignore) {
100
- const rich = await tryAiReady(cwd);
101
- if (rich)
102
- return rich;
103
- return builtinReady(cwd, ignore);
130
+ try {
131
+ const rich = await tryAiReady(cwd);
132
+ if (rich)
133
+ return rich;
134
+ return builtinReady(cwd, ignore);
135
+ }
136
+ catch {
137
+ return { name: 'ready', score: 100, maxScore: 100, issues: [], summary: 'ready check failed' };
138
+ }
104
139
  }
@@ -1,28 +1,14 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as crypto from 'node:crypto';
4
+ import { collectDirFiles } from '../util.js';
4
5
  import { createInterface } from 'node:readline';
5
6
  // ── Session file discovery ───────────────────────────────────────────────────
6
- function walkDir(dir) {
7
- const results = [];
8
- try {
9
- const entries = fs.readdirSync(dir, { withFileTypes: true });
10
- for (const entry of entries) {
11
- const full = path.join(dir, entry.name);
12
- if (entry.isDirectory())
13
- results.push(...walkDir(full));
14
- else
15
- results.push(full);
16
- }
17
- }
18
- catch { /* skip */ }
19
- return results;
20
- }
21
7
  export function findSessionFiles(baseDir) {
22
8
  const dir = baseDir || path.join(process.env['HOME'] || '~', '.claude', 'projects');
23
9
  if (!fs.existsSync(dir))
24
10
  return [];
25
- return walkDir(dir).filter(f => f.endsWith('.jsonl')).sort();
11
+ return collectDirFiles(dir).filter(f => f.endsWith('.jsonl')).sort();
26
12
  }
27
13
  export function findLatestSession(baseDir) {
28
14
  const files = findSessionFiles(baseDir);
@@ -9,16 +9,34 @@ const CRITICAL_PATTERNS = [
9
9
  regex: /(?:aHR0c|data:text\/html;base64)/i,
10
10
  },
11
11
  {
12
- id: 'curl-wget',
12
+ id: 'curl-pipe-shell',
13
13
  severity: 'critical',
14
- description: 'Network download command in agent config potential remote payload fetch',
15
- regex: /(?:curl|wget|fetch)\s+(?:https?:\/\/|[-])/i,
14
+ description: 'Download-and-execute pattern — remote code execution',
15
+ regex: /(?:curl|wget)\s+[^\n|]*\|\s*(?:ba)?sh\b/i,
16
16
  },
17
17
  {
18
- id: 'shell-injection',
18
+ id: 'pipe-to-shell',
19
19
  severity: 'critical',
20
- description: 'Shell injection patterncommand substitution or eval/exec call',
21
- regex: /\$\(|`[^`]+`|\beval\b|\bexec\b/,
20
+ description: 'Pipe to shell interpreter potential code execution',
21
+ regex: /\|\s*(?:ba)?sh\b|\|\s*\beval\b/,
22
+ },
23
+ {
24
+ id: 'command-substitution',
25
+ severity: 'critical',
26
+ description: 'Command substitution in config — potential injection vector',
27
+ regex: /\$\([^)]+\)/,
28
+ },
29
+ {
30
+ id: 'data-exfiltration',
31
+ severity: 'critical',
32
+ description: 'Data exfiltration pattern — sending local data to external URL',
33
+ regex: /curl\s+[^\n]*(?:-[dFT]\s*@|--data-binary\s*@|--upload-file)|curl\s+[^\n]*\$(?:API_KEY|SECRET|TOKEN|PASSWORD)|nc\s+-[^\n]*\d+\.\d+/i,
34
+ },
35
+ {
36
+ id: 'base64-exec',
37
+ severity: 'critical',
38
+ description: 'Base64-decoded payload piped to execution',
39
+ regex: /base64\s+(?:-d|--decode)[^\n]*\|\s*(?:ba)?sh\b/i,
22
40
  },
23
41
  {
24
42
  id: 'powershell-download',
@@ -118,12 +136,42 @@ function collectConfigFiles(cwd) {
118
136
  }
119
137
  return [...new Set(files)];
120
138
  }
139
+ /** Check if a line is inside a markdown code fence or inline code */
140
+ function isInCodeContext(lines, lineIndex) {
141
+ const line = lines[lineIndex];
142
+ // Check if the match is inside inline backticks on this line
143
+ // Simple heuristic: line contains backtick-wrapped content with the pattern
144
+ if (/`[^`]*\$\([^)]+\)[^`]*`/.test(line))
145
+ return true;
146
+ // Check if we're inside a fenced code block (``` or ~~~)
147
+ let inFence = false;
148
+ for (let i = 0; i < lineIndex; i++) {
149
+ if (/^```|^~~~/.test(lines[i].trim()))
150
+ inFence = !inFence;
151
+ }
152
+ return inFence;
153
+ }
154
+ /** Check if a file is a CI/workflow file where shell commands are expected */
155
+ function isWorkflowFile(relPath) {
156
+ const normalized = relPath.replace(/\\/g, '/');
157
+ return normalized.includes('.github/workflows/') ||
158
+ normalized.includes('.circleci/') ||
159
+ normalized.includes('.gitlab-ci') ||
160
+ /Makefile|Dockerfile|Jenkinsfile/i.test(normalized);
161
+ }
121
162
  function scanContent(content, relPath) {
122
163
  const findings = [];
123
164
  const lines = content.split('\n');
165
+ const isWorkflow = isWorkflowFile(relPath);
124
166
  for (let i = 0; i < lines.length; i++) {
125
167
  const line = lines[i];
126
168
  for (const pattern of ALL_SCAN_PATTERNS) {
169
+ // Skip command-substitution checks in workflow files (shell commands are expected)
170
+ if (pattern.id === 'command-substitution' && isWorkflow)
171
+ continue;
172
+ // Skip command-substitution in markdown code contexts
173
+ if (pattern.id === 'command-substitution' && relPath.endsWith('.md') && isInCodeContext(lines, i))
174
+ continue;
127
175
  if (pattern.regex.test(line)) {
128
176
  pattern.regex.lastIndex = 0;
129
177
  findings.push({
@@ -3,6 +3,7 @@ import { join, relative, extname, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { execSync } from 'node:child_process';
5
5
  import { createInterface } from 'node:readline';
6
+ import { collectDirFiles } from '../util.js';
6
7
  // ── Shannon entropy ──────────────────────────────────────────────────────────
7
8
  function calculateEntropy(str) {
8
9
  if (str.length === 0)
@@ -117,25 +118,6 @@ function detectBuildDir(cwd) {
117
118
  }
118
119
  return null;
119
120
  }
120
- function walkBuild(dir) {
121
- const results = [];
122
- try {
123
- const entries = readdirSync(dir, { withFileTypes: true });
124
- for (const entry of entries) {
125
- const full = join(dir, entry.name);
126
- if (entry.isDirectory()) {
127
- if (entry.name === 'node_modules')
128
- continue;
129
- results.push(...walkBuild(full));
130
- }
131
- else {
132
- results.push(full);
133
- }
134
- }
135
- }
136
- catch { /* skip */ }
137
- return results;
138
- }
139
121
  function shouldScan(filePath) {
140
122
  const ext = extname(filePath).toLowerCase();
141
123
  if (SKIP_EXTS.has(ext))
@@ -213,7 +195,7 @@ export async function checkSecrets(cwd) {
213
195
  // 1. Build output scan
214
196
  const buildDir = detectBuildDir(cwd);
215
197
  if (buildDir) {
216
- const buildFiles = walkBuild(buildDir).filter(f => shouldScan(f));
198
+ const buildFiles = collectDirFiles(buildDir).filter(f => shouldScan(f));
217
199
  for (const file of buildFiles) {
218
200
  try {
219
201
  const findings = await scanBuildFile(file);
@@ -1,2 +1,5 @@
1
1
  import type { CheckResult } from '../types.js';
2
+ /** Check if a file has a vet-ignore directive for a specific check in its first 5 lines.
3
+ * Format: // vet-ignore: check-name OR /* vet-ignore: check-name */
4
+ export declare function hasVetIgnore(content: string, checkName: string): boolean;
2
5
  export declare function checkTests(cwd: string, ignore: string[]): CheckResult;
@@ -191,6 +191,13 @@ function findDuplicateDescribes(lines, file) {
191
191
  }
192
192
  return issues;
193
193
  }
194
+ /** Check if a file has a vet-ignore directive for a specific check in its first 5 lines.
195
+ * Format: // vet-ignore: check-name OR /* vet-ignore: check-name */
196
+ export function hasVetIgnore(content, checkName) {
197
+ const firstLines = content.split('\n').slice(0, 5);
198
+ const re = new RegExp(`(?://|/\\*|#)\\s*vet-ignore:\\s*${checkName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
199
+ return firstLines.some(line => re.test(line));
200
+ }
194
201
  export function checkTests(cwd, ignore) {
195
202
  const allFiles = walkFiles(cwd, ignore);
196
203
  const testFiles = allFiles.filter(f => isTestFile(f));
@@ -203,6 +210,9 @@ export function checkTests(cwd, ignore) {
203
210
  catch {
204
211
  continue;
205
212
  }
213
+ // Skip files with vet-ignore: tests directive
214
+ if (hasVetIgnore(content, 'tests'))
215
+ continue;
206
216
  const lines = content.split('\n');
207
217
  issues.push(...findTautological(lines, rel));
208
218
  issues.push(...findEmptyBodies(content, rel));
@@ -21,6 +21,18 @@ const CONFIG_EXTENSIONS = new Set([
21
21
  const CONFIG_DIRS = ['.github/', '.husky/', '.vscode/', '.idea/'];
22
22
  const META_FILES = new Set([
23
23
  'FUNDING.yaml', 'CODEOWNERS', 'LICENSE',
24
+ 'py.typed', 'MANIFEST.in', 'CITATION.cff',
25
+ ]);
26
+ const META_EXTENSIONS = new Set([
27
+ '.cff', '.mdc', '.txt', '.html', '.md', '.rst', '.csv',
28
+ '.css', '.scss', '.less', '.map', '.wasm',
29
+ '.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1',
30
+ '.sql', '.graphql', '.gql', '.proto',
31
+ ]);
32
+ /** Source code extensions that should be checked for thin files */
33
+ const SOURCE_CODE_EXTS = new Set([
34
+ '.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs',
35
+ '.py', '.go', '.rs', '.java', '.rb', '.php', '.cs', '.swift', '.kt',
24
36
  ]);
25
37
  function isConfigOrMetaFile(filePath) {
26
38
  const base = basename(filePath);
@@ -32,6 +44,9 @@ function isConfigOrMetaFile(filePath) {
32
44
  // Config extensions
33
45
  if (CONFIG_EXTENSIONS.has(ext))
34
46
  return true;
47
+ // Meta/non-source extensions
48
+ if (META_EXTENSIONS.has(ext))
49
+ return true;
35
50
  // Config directories
36
51
  if (CONFIG_DIRS.some(d => normalized.includes(d) || normalized.startsWith(d)))
37
52
  return true;
@@ -40,6 +55,9 @@ function isConfigOrMetaFile(filePath) {
40
55
  return true;
41
56
  if (base.startsWith('CHANGELOG'))
42
57
  return true;
58
+ // Any file that is NOT source code should not be flagged for thin content
59
+ if (!SOURCE_CODE_EXTS.has(ext))
60
+ return true;
43
61
  return false;
44
62
  }
45
63
  // ── Code file extensions for test detection ──────────────────────────────────
@@ -127,16 +145,26 @@ function isPythonProject(cwd) {
127
145
  const markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'];
128
146
  return markers.some(m => existsSync(join(cwd, m)));
129
147
  }
148
+ /** Directories where small files are expected (examples, demos, docs) */
149
+ const SMALL_FILE_DIRS = ['examples/', 'example/', 'demos/', 'demo/', 'docs/'];
130
150
  function isPythonBoilerplate(filePath) {
131
151
  const base = basename(filePath);
132
152
  if (base === '__init__.py')
133
153
  return true;
154
+ if (base === '__main__.py')
155
+ return true;
156
+ if (base === 'py.typed')
157
+ return true;
134
158
  if (filePath.endsWith('.pyi'))
135
159
  return true;
136
160
  if (filePath.replace(/\\/g, '/').includes('__pycache__/'))
137
161
  return true;
138
162
  return false;
139
163
  }
164
+ function isInSmallFileDir(filePath) {
165
+ const normalized = filePath.replace(/\\/g, '/');
166
+ return SMALL_FILE_DIRS.some(d => normalized.includes(d) || normalized.startsWith(d));
167
+ }
140
168
  // ── Main check ───────────────────────────────────────────────────────────────
141
169
  export function checkVerify(cwd, since) {
142
170
  const issues = [];
@@ -220,8 +248,13 @@ export function checkVerify(cwd, since) {
220
248
  }
221
249
  const lineCount = countLines(content);
222
250
  // 2. File must have meaningful content (>10 non-empty lines)
223
- // Skip thin file check for Python boilerplate files
224
- if (python && isPythonBoilerplate(relPath)) {
251
+ // Skip thin file check for Python boilerplate files (always, regardless of project type)
252
+ if (isPythonBoilerplate(relPath)) {
253
+ verified++;
254
+ continue;
255
+ }
256
+ // Skip thin file check for files in examples/docs directories
257
+ if (isInSmallFileDir(relPath)) {
225
258
  verified++;
226
259
  continue;
227
260
  }