@farisabujolban/codeanchor 0.1.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.
Files changed (68) hide show
  1. package/README.md +249 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +177 -0
  4. package/dist/config.d.ts +9 -0
  5. package/dist/config.js +37 -0
  6. package/dist/engine.d.ts +18 -0
  7. package/dist/engine.js +30 -0
  8. package/dist/git/blame.d.ts +8 -0
  9. package/dist/git/blame.js +57 -0
  10. package/dist/git/diff.d.ts +5 -0
  11. package/dist/git/diff.js +75 -0
  12. package/dist/git/history.d.ts +7 -0
  13. package/dist/git/history.js +46 -0
  14. package/dist/reporter.d.ts +3 -0
  15. package/dist/reporter.js +67 -0
  16. package/dist/rules/ca-cd001.d.ts +2 -0
  17. package/dist/rules/ca-cd001.js +84 -0
  18. package/dist/rules/ca-ci001.d.ts +2 -0
  19. package/dist/rules/ca-ci001.js +86 -0
  20. package/dist/rules/ca-ci003.d.ts +2 -0
  21. package/dist/rules/ca-ci003.js +114 -0
  22. package/dist/rules/ca-docker001.d.ts +2 -0
  23. package/dist/rules/ca-docker001.js +69 -0
  24. package/dist/rules/ca-docker002.d.ts +2 -0
  25. package/dist/rules/ca-docker002.js +121 -0
  26. package/dist/rules/ca-docs001.d.ts +2 -0
  27. package/dist/rules/ca-docs001.js +75 -0
  28. package/dist/rules/ca-docs002.d.ts +2 -0
  29. package/dist/rules/ca-docs002.js +71 -0
  30. package/dist/rules/ca-docs003.d.ts +2 -0
  31. package/dist/rules/ca-docs003.js +105 -0
  32. package/dist/rules/ca-lock001.d.ts +2 -0
  33. package/dist/rules/ca-lock001.js +123 -0
  34. package/dist/rules/ca-own001.d.ts +2 -0
  35. package/dist/rules/ca-own001.js +71 -0
  36. package/dist/rules/ca-pkg001.d.ts +2 -0
  37. package/dist/rules/ca-pkg001.js +56 -0
  38. package/dist/rules/ca-pkg002.d.ts +2 -0
  39. package/dist/rules/ca-pkg002.js +93 -0
  40. package/dist/rules/ca-test001.d.ts +2 -0
  41. package/dist/rules/ca-test001.js +58 -0
  42. package/dist/rules/ca-test002.d.ts +2 -0
  43. package/dist/rules/ca-test002.js +60 -0
  44. package/dist/rules/ca-todo003.d.ts +2 -0
  45. package/dist/rules/ca-todo003.js +82 -0
  46. package/dist/rules/index.d.ts +2 -0
  47. package/dist/rules/index.js +32 -0
  48. package/dist/types.d.ts +47 -0
  49. package/dist/types.js +1 -0
  50. package/dist/util/approvals.d.ts +7 -0
  51. package/dist/util/approvals.js +63 -0
  52. package/dist/util/comment-parser.d.ts +4 -0
  53. package/dist/util/comment-parser.js +173 -0
  54. package/dist/util/exclude.d.ts +1 -0
  55. package/dist/util/exclude.js +12 -0
  56. package/dist/util/hash.d.ts +1 -0
  57. package/dist/util/hash.js +4 -0
  58. package/dist/util/ignore-rules.d.ts +3 -0
  59. package/dist/util/ignore-rules.js +39 -0
  60. package/dist/util/lang-cstyle.d.ts +2 -0
  61. package/dist/util/lang-cstyle.js +30 -0
  62. package/dist/util/lang-python.d.ts +2 -0
  63. package/dist/util/lang-python.js +19 -0
  64. package/dist/util/languages.d.ts +8 -0
  65. package/dist/util/languages.js +8 -0
  66. package/dist/util/ownership.d.ts +4 -0
  67. package/dist/util/ownership.js +49 -0
  68. package/package.json +40 -0
@@ -0,0 +1,46 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export function parseSinceDuration(since) {
3
+ const m = since.match(/^(\d+)([dmy])$/);
4
+ if (!m)
5
+ return since;
6
+ const n = parseInt(m[1], 10);
7
+ const unit = { d: 'day', m: 'month', y: 'year' }[m[2]];
8
+ return `${n} ${unit}${n !== 1 ? 's' : ''} ago`;
9
+ }
10
+ export function getHotFiles(repoRoot, since, minCommits = 3) {
11
+ const dateStr = parseSinceDuration(since);
12
+ let raw;
13
+ try {
14
+ raw = execFileSync('git', ['log', `--since=${dateStr}`, '--name-only', '--format='], {
15
+ encoding: 'utf-8',
16
+ cwd: repoRoot,
17
+ });
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ const counts = new Map();
23
+ for (const line of raw.split('\n')) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed)
26
+ continue;
27
+ counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
28
+ }
29
+ const results = [];
30
+ for (const [filePath, count] of counts) {
31
+ if (count >= minCommits) {
32
+ results.push({ path: filePath, commitCount: count });
33
+ }
34
+ }
35
+ return results.sort((a, b) => b.commitCount - a.commitCount);
36
+ }
37
+ export function getFileCommitCount(repoRoot, filePath, since) {
38
+ const dateStr = parseSinceDuration(since);
39
+ try {
40
+ const out = execFileSync('git', ['log', `--since=${dateStr}`, '--oneline', '--', filePath], { encoding: 'utf-8', cwd: repoRoot });
41
+ return out.trim().split('\n').filter(Boolean).length;
42
+ }
43
+ catch {
44
+ return 0;
45
+ }
46
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScanResult } from './types.js';
2
+ export declare function printResult(result: ScanResult): void;
3
+ export declare function renderMarkdown(result: ScanResult): string;
@@ -0,0 +1,67 @@
1
+ export function printResult(result) {
2
+ if (result.findings.length === 0) {
3
+ console.log('No issues found.');
4
+ return;
5
+ }
6
+ for (const f of result.findings) {
7
+ const loc = f.line != null ? `${f.file}:${f.line}` : f.file;
8
+ console.log(`\n ${f.ruleId} ${f.severity} ${loc}`);
9
+ console.log(` ${f.message}`);
10
+ if (f.detail)
11
+ console.log(` ${f.detail}`);
12
+ if (f.fix)
13
+ console.log(` → ${f.fix}`);
14
+ }
15
+ const parts = [];
16
+ if (result.errorCount > 0) {
17
+ parts.push(`${result.errorCount} error${result.errorCount !== 1 ? 's' : ''}`);
18
+ }
19
+ if (result.warnCount > 0) {
20
+ parts.push(`${result.warnCount} warning${result.warnCount !== 1 ? 's' : ''}`);
21
+ }
22
+ if (parts.length > 0) {
23
+ console.log(`\n ${parts.join(', ')}`);
24
+ }
25
+ if (result.errorCount > 0) {
26
+ console.log('\n Run failed.');
27
+ }
28
+ }
29
+ export function renderMarkdown(result) {
30
+ const date = result.timestamp.slice(0, 10);
31
+ const lines = [
32
+ `# codeanchor Report`,
33
+ ``,
34
+ `**Mode:** ${result.mode} | **Date:** ${date} | **Errors:** ${result.errorCount} | **Warnings:** ${result.warnCount}`,
35
+ ``,
36
+ ];
37
+ const errors = result.findings.filter(f => f.severity === 'error');
38
+ const warnings = result.findings.filter(f => f.severity === 'warn');
39
+ if (errors.length > 0) {
40
+ lines.push(`## Errors`, ``);
41
+ for (const f of errors)
42
+ lines.push(...findingToMarkdown(f));
43
+ }
44
+ if (warnings.length > 0) {
45
+ lines.push(`## Warnings`, ``);
46
+ for (const f of warnings)
47
+ lines.push(...findingToMarkdown(f));
48
+ }
49
+ if (result.findings.length === 0) {
50
+ lines.push(`No issues found.`);
51
+ }
52
+ return lines.join('\n') + '\n';
53
+ }
54
+ function findingToMarkdown(f) {
55
+ const loc = f.line != null ? `${f.file}:${f.line}` : f.file;
56
+ const out = [
57
+ `### ${f.ruleId}`,
58
+ `**File:** \`${loc}\``,
59
+ f.message,
60
+ ];
61
+ if (f.detail)
62
+ out.push(f.detail);
63
+ if (f.fix)
64
+ out.push(`**Fix:** \`${f.fix}\``);
65
+ out.push(``, `---`, ``);
66
+ return out;
67
+ }
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../engine.js';
2
+ export declare const caCd001: Rule;
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getDriver } from '../util/languages.js';
4
+ import { extractLeadingComments } from '../util/comment-parser.js';
5
+ import { shouldIgnoreComment } from '../util/ignore-rules.js';
6
+ import { getOwnedRegion } from '../util/ownership.js';
7
+ import { loadApprovals, findApproval, isApprovalValid } from '../util/approvals.js';
8
+ import { diffTouchesRange } from '../git/diff.js';
9
+ import { isExcluded } from '../util/exclude.js';
10
+ export const caCd001 = {
11
+ id: 'CA-CD001',
12
+ description: 'Code changed but leading comment was not updated.',
13
+ defaultSeverity: 'error',
14
+ applicableModes: ['staged'],
15
+ async run(ctx) {
16
+ const { stagedDiffs, repoRoot, config } = ctx;
17
+ if (!stagedDiffs)
18
+ return [];
19
+ const ruleCfg = config.rules['CA-CD001'];
20
+ if (ruleCfg === false)
21
+ return [];
22
+ const maxOwnershipDistance = typeof ruleCfg === 'object' && ruleCfg?.maxOwnershipDistance
23
+ ? ruleCfg.maxOwnershipDistance
24
+ : 20;
25
+ const findings = [];
26
+ const approvalsStore = loadApprovals(repoRoot);
27
+ for (const fileDiff of stagedDiffs) {
28
+ if (fileDiff.status === 'deleted')
29
+ continue;
30
+ if (fileDiff.status === 'added')
31
+ continue;
32
+ const driver = getDriver(fileDiff.path);
33
+ if (!driver)
34
+ continue;
35
+ if (isExcluded(fileDiff.path, config.exclude))
36
+ continue;
37
+ const absPath = path.join(repoRoot, fileDiff.path);
38
+ if (!fs.existsSync(absPath))
39
+ continue;
40
+ let content;
41
+ try {
42
+ content = fs.readFileSync(absPath, 'utf-8');
43
+ }
44
+ catch {
45
+ continue;
46
+ }
47
+ const lines = content.split('\n');
48
+ const comments = extractLeadingComments(content, driver);
49
+ for (const comment of comments) {
50
+ if (shouldIgnoreComment(comment, driver))
51
+ continue;
52
+ const region = getOwnedRegion(comment, lines, maxOwnershipDistance, driver);
53
+ if (!region)
54
+ continue;
55
+ const codeChanged = diffTouchesRange(fileDiff, region.startLine, region.endLine);
56
+ if (!codeChanged)
57
+ continue;
58
+ const commentChanged = diffTouchesRange(fileDiff, comment.startLine, comment.endLine);
59
+ if (commentChanged)
60
+ continue;
61
+ const approval = findApproval(approvalsStore, fileDiff.path, comment.startLine);
62
+ if (approval && isApprovalValid(approval, comment, lines, region))
63
+ continue;
64
+ const changedInRegion = [];
65
+ for (let l = region.startLine; l <= region.endLine; l++) {
66
+ if (fileDiff.changedLines.has(l))
67
+ changedInRegion.push(l);
68
+ }
69
+ const rangeStr = changedInRegion.length === 1
70
+ ? `${changedInRegion[0]}`
71
+ : `${changedInRegion[0]}–${changedInRegion[changedInRegion.length - 1]}`;
72
+ findings.push({
73
+ ruleId: 'CA-CD001',
74
+ severity: 'error',
75
+ file: fileDiff.path,
76
+ line: comment.startLine,
77
+ message: `Code changed at lines ${rangeStr} but leading comment was not updated.`,
78
+ fix: `codeanchor approve ${fileDiff.path} ${comment.startLine}`,
79
+ });
80
+ }
81
+ }
82
+ return findings;
83
+ },
84
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../engine.js';
2
+ export declare const caCi001: Rule;
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { isExcluded } from '../util/exclude.js';
5
+ function findWorkflowFiles(root) {
6
+ const dir = path.join(root, '.github', 'workflows');
7
+ if (!fs.existsSync(dir))
8
+ return [];
9
+ return fs
10
+ .readdirSync(dir)
11
+ .filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
12
+ .map(f => path.join(dir, f));
13
+ }
14
+ const scriptPattern = /(?:npm run|pnpm run|yarn run|pnpm|yarn)\s+([a-zA-Z0-9:_-]+)/g;
15
+ function extractScriptNames(runBlock) {
16
+ const names = [];
17
+ scriptPattern.lastIndex = 0;
18
+ let m;
19
+ while ((m = scriptPattern.exec(runBlock)) !== null) {
20
+ names.push(m[1]);
21
+ }
22
+ return names;
23
+ }
24
+ function findScriptLine(rawLines, scriptName) {
25
+ const re = new RegExp(`(?:npm run|pnpm run|yarn run|pnpm|yarn)\\s+${scriptName}(?:\\s|$)`);
26
+ for (let i = 0; i < rawLines.length; i++) {
27
+ if (re.test(rawLines[i]))
28
+ return i + 1;
29
+ }
30
+ return undefined;
31
+ }
32
+ export const caCi001 = {
33
+ id: 'CA-CI001',
34
+ description: 'GitHub Actions workflow references an npm script missing from package.json.',
35
+ defaultSeverity: 'error',
36
+ applicableModes: ['repo', 'pr'],
37
+ async run(ctx) {
38
+ const pkgPath = path.join(ctx.repoRoot, 'package.json');
39
+ if (!fs.existsSync(pkgPath))
40
+ return [];
41
+ let scripts = {};
42
+ try {
43
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
44
+ scripts = pkg.scripts ?? {};
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ const findings = [];
50
+ for (const wfFile of findWorkflowFiles(ctx.repoRoot)) {
51
+ const relPath = path.relative(ctx.repoRoot, wfFile);
52
+ if (isExcluded(relPath, ctx.config.exclude))
53
+ continue;
54
+ let doc;
55
+ const raw = fs.readFileSync(wfFile, 'utf-8');
56
+ try {
57
+ doc = yaml.load(raw);
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ if (!doc?.jobs)
63
+ continue;
64
+ const rawLines = raw.split('\n');
65
+ for (const job of Object.values(doc.jobs)) {
66
+ for (const step of job.steps ?? []) {
67
+ if (!step.run)
68
+ continue;
69
+ for (const scriptName of extractScriptNames(step.run)) {
70
+ if (!(scriptName in scripts)) {
71
+ findings.push({
72
+ ruleId: 'CA-CI001',
73
+ severity: 'error',
74
+ file: relPath,
75
+ line: findScriptLine(rawLines, scriptName),
76
+ message: `Script "${scriptName}" referenced in workflow but not found in package.json.`,
77
+ detail: `Available: ${Object.keys(scripts).join(', ')}`,
78
+ });
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return findings;
85
+ },
86
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../engine.js';
2
+ export declare const caCi003: Rule;
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { isExcluded } from '../util/exclude.js';
5
+ // Patterns for local file references inside `run:` blocks.
6
+ // Intentionally conservative to keep false positives low.
7
+ // Path form: optional ./ or ../ prefix, then one or more dir/segments, then file.ext
8
+ const LOCAL_PATH = /((?:\.{1,2}\/)?(?:[\w.-]+\/)+[\w.-]+\.\w+)/;
9
+ const RUN_PATH_PATTERNS = [
10
+ // node scripts/foo.js | node ./scripts/foo.js
11
+ new RegExp(`\\bnode\\s+${LOCAL_PATH.source}`),
12
+ // ts-node / tsx
13
+ new RegExp(`\\b(?:ts-node|tsx)\\s+${LOCAL_PATH.source}`),
14
+ // bash/sh scripts/foo.sh | bash ./scripts/foo.sh
15
+ new RegExp(`\\b(?:bash|sh)\\s+${LOCAL_PATH.source}`),
16
+ // python tools/foo.py
17
+ new RegExp(`\\bpython3?\\s+${LOCAL_PATH.source}`),
18
+ // ./scripts/foo.sh (bare relative invocation)
19
+ /(\.\/[\w./-]+\.\w+)/,
20
+ ];
21
+ function findWorkflowFiles(root) {
22
+ const dir = path.join(root, '.github', 'workflows');
23
+ if (!fs.existsSync(dir))
24
+ return [];
25
+ return fs
26
+ .readdirSync(dir)
27
+ .filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
28
+ .map(f => path.join(dir, f));
29
+ }
30
+ function extractRunPaths(runBlock) {
31
+ const found = new Set();
32
+ for (const line of runBlock.split('\n')) {
33
+ for (const re of RUN_PATH_PATTERNS) {
34
+ const m = line.match(re);
35
+ if (m?.[1])
36
+ found.add(m[1]);
37
+ }
38
+ }
39
+ return [...found];
40
+ }
41
+ function findLineOf(rawLines, text) {
42
+ for (let i = 0; i < rawLines.length; i++) {
43
+ if (rawLines[i].includes(text))
44
+ return i + 1;
45
+ }
46
+ return undefined;
47
+ }
48
+ export const caCi003 = {
49
+ id: 'CA-CI003',
50
+ description: 'GitHub Actions workflow references a local path that does not exist.',
51
+ defaultSeverity: 'error',
52
+ applicableModes: ['repo', 'pr'],
53
+ async run(ctx) {
54
+ const findings = [];
55
+ for (const wfFile of findWorkflowFiles(ctx.repoRoot)) {
56
+ const relPath = path.relative(ctx.repoRoot, wfFile);
57
+ if (isExcluded(relPath, ctx.config.exclude))
58
+ continue;
59
+ const raw = fs.readFileSync(wfFile, 'utf-8');
60
+ let doc;
61
+ try {
62
+ doc = yaml.load(raw);
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ if (!doc?.jobs)
68
+ continue;
69
+ const rawLines = raw.split('\n');
70
+ function report(localPath, hint) {
71
+ const resolved = path.resolve(ctx.repoRoot, localPath);
72
+ if (!fs.existsSync(resolved)) {
73
+ findings.push({
74
+ ruleId: 'CA-CI003',
75
+ severity: 'error',
76
+ file: relPath,
77
+ line: findLineOf(rawLines, localPath),
78
+ message: `${hint} "${localPath}" does not exist.`,
79
+ });
80
+ }
81
+ }
82
+ for (const job of Object.values(doc.jobs)) {
83
+ for (const step of job.steps ?? []) {
84
+ // working-directory
85
+ const wd = step['working-directory'];
86
+ if (typeof wd === 'string' && (wd.startsWith('./') || wd.startsWith('../') || (!wd.startsWith('/') && !wd.includes('$')))) {
87
+ report(wd, 'working-directory');
88
+ }
89
+ // with.path and with.cache-dependency-path
90
+ if (step.with && typeof step.with === 'object') {
91
+ for (const key of ['path', 'cache-dependency-path']) {
92
+ const val = step.with[key];
93
+ if (typeof val === 'string') {
94
+ // may be multi-line (newline-separated list)
95
+ for (const entry of val.split('\n').map(s => s.trim()).filter(Boolean)) {
96
+ if (entry.startsWith('./') || entry.startsWith('../')) {
97
+ report(entry, `with.${key}`);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ // run: block — extract conservatively matched local file references
104
+ if (step.run) {
105
+ for (const localPath of extractRunPaths(step.run)) {
106
+ report(localPath, 'run: script reference');
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return findings;
113
+ },
114
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../engine.js';
2
+ export declare const caDocker001: Rule;
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isExcluded } from '../util/exclude.js';
4
+ function findDockerfiles(root) {
5
+ const files = [];
6
+ try {
7
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
8
+ if (entry.isFile() &&
9
+ (entry.name === 'Dockerfile' || entry.name.startsWith('Dockerfile.'))) {
10
+ files.push(path.join(root, entry.name));
11
+ }
12
+ }
13
+ }
14
+ catch { /* ignore unreadable root */ }
15
+ return files;
16
+ }
17
+ export const caDocker001 = {
18
+ id: 'CA-DOCKER001',
19
+ description: 'Dockerfile COPY or ADD references a source path that does not exist.',
20
+ defaultSeverity: 'warn',
21
+ applicableModes: ['repo', 'pr'],
22
+ async run(ctx) {
23
+ const findings = [];
24
+ for (const dockerFile of findDockerfiles(ctx.repoRoot)) {
25
+ const relPath = path.relative(ctx.repoRoot, dockerFile);
26
+ if (isExcluded(relPath, ctx.config.exclude))
27
+ continue;
28
+ const content = fs.readFileSync(dockerFile, 'utf-8');
29
+ const lines = content.split('\n');
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const trimmed = lines[i].trim();
32
+ // Skip multi-stage COPY --from=
33
+ if (/^COPY\s+--from=/i.test(trimmed))
34
+ continue;
35
+ let src = null;
36
+ if (/^COPY\s+/i.test(trimmed)) {
37
+ // Strip inline flags like --chown= before splitting
38
+ const withoutFlags = trimmed.replace(/--\w[\w-]*=\S+\s*/g, '');
39
+ const parts = withoutFlags.split(/\s+/).slice(1);
40
+ if (parts.length >= 2)
41
+ src = parts[0];
42
+ }
43
+ else if (/^ADD\s+/i.test(trimmed)) {
44
+ const parts = trimmed.split(/\s+/).slice(1);
45
+ if (parts.length >= 2) {
46
+ const candidate = parts[0];
47
+ // Skip URLs
48
+ if (/^https?:\/\//i.test(candidate))
49
+ continue;
50
+ src = candidate;
51
+ }
52
+ }
53
+ if (src) {
54
+ const resolved = path.join(ctx.repoRoot, src);
55
+ if (!fs.existsSync(resolved)) {
56
+ findings.push({
57
+ ruleId: 'CA-DOCKER001',
58
+ severity: 'warn',
59
+ file: relPath,
60
+ line: i + 1,
61
+ message: `COPY source path "${src}" does not exist.`,
62
+ });
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return findings;
68
+ },
69
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../engine.js';
2
+ export declare const caDocker002: Rule;
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isExcluded } from '../util/exclude.js';
4
+ function findDockerfiles(root) {
5
+ const files = [];
6
+ try {
7
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
8
+ if (entry.isFile() &&
9
+ (entry.name === 'Dockerfile' || entry.name.startsWith('Dockerfile.'))) {
10
+ files.push(path.join(root, entry.name));
11
+ }
12
+ }
13
+ }
14
+ catch { /* ignore */ }
15
+ return files;
16
+ }
17
+ // RUN npm run <script> / RUN pnpm run <script> / RUN yarn run <script>
18
+ // Also: RUN pnpm <script> / RUN yarn <script> (shorthand without "run")
19
+ const PKG_SCRIPT_RE = /\b(?:npm run|pnpm run|yarn run|pnpm|yarn)\s+([a-zA-Z0-9:_-]+)/g;
20
+ // CMD/ENTRYPOINT node dist/foo.js (local relative path — no leading ./ required for CMD)
21
+ // We only check paths that look like relative file paths (contain a dot in filename or start with .)
22
+ const CMD_NODE_RE = /\bnode\s+((?:\.\/)?[\w./]+\.\w+)/;
23
+ const CMD_TS_NODE_RE = /\b(?:ts-node|tsx)\s+((?:\.\/)?[\w./]+\.\w+)/;
24
+ function extractScriptNamesFromRun(runLine) {
25
+ const names = [];
26
+ PKG_SCRIPT_RE.lastIndex = 0;
27
+ let m;
28
+ while ((m = PKG_SCRIPT_RE.exec(runLine)) !== null) {
29
+ names.push(m[1]);
30
+ }
31
+ return names;
32
+ }
33
+ function extractNodePathFromExec(cmd) {
34
+ let m = cmd.match(CMD_NODE_RE);
35
+ if (m)
36
+ return m[1];
37
+ m = cmd.match(CMD_TS_NODE_RE);
38
+ if (m)
39
+ return m[1];
40
+ return null;
41
+ }
42
+ // Parse CMD/ENTRYPOINT — handles both shell form and JSON array form
43
+ function parseCmdEntrypoint(trimmed) {
44
+ // JSON array form: CMD ["node", "dist/index.js"]
45
+ const jsonMatch = trimmed.match(/^(?:CMD|ENTRYPOINT)\s+(\[.*\])\s*$/i);
46
+ if (jsonMatch) {
47
+ try {
48
+ const arr = JSON.parse(jsonMatch[1]);
49
+ return arr.join(' ');
50
+ }
51
+ catch { /* fall through to shell form */ }
52
+ }
53
+ // Shell form: CMD node dist/index.js
54
+ const shellMatch = trimmed.match(/^(?:CMD|ENTRYPOINT)\s+(.+)$/i);
55
+ return shellMatch ? shellMatch[1] : '';
56
+ }
57
+ export const caDocker002 = {
58
+ id: 'CA-DOCKER002',
59
+ description: 'Dockerfile RUN/CMD/ENTRYPOINT references a script or local file that does not exist.',
60
+ defaultSeverity: 'warn',
61
+ applicableModes: ['repo', 'pr'],
62
+ async run(ctx) {
63
+ const findings = [];
64
+ // Load scripts from package.json once
65
+ const pkgPath = path.join(ctx.repoRoot, 'package.json');
66
+ let scripts = {};
67
+ if (fs.existsSync(pkgPath) && !isExcluded('package.json', ctx.config.exclude)) {
68
+ try {
69
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
70
+ scripts = pkg.scripts ?? {};
71
+ }
72
+ catch { /* no scripts */ }
73
+ }
74
+ for (const dockerFile of findDockerfiles(ctx.repoRoot)) {
75
+ const relPath = path.relative(ctx.repoRoot, dockerFile);
76
+ if (isExcluded(relPath, ctx.config.exclude))
77
+ continue;
78
+ const content = fs.readFileSync(dockerFile, 'utf-8');
79
+ const lines = content.split('\n');
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const trimmed = lines[i].trim();
82
+ const lineNum = i + 1;
83
+ // RUN lines — check npm/yarn/pnpm script references
84
+ if (/^RUN\s+/i.test(trimmed)) {
85
+ const runBody = trimmed.replace(/^RUN\s+/i, '');
86
+ for (const scriptName of extractScriptNamesFromRun(runBody)) {
87
+ if (Object.keys(scripts).length > 0 && !(scriptName in scripts)) {
88
+ findings.push({
89
+ ruleId: 'CA-DOCKER002',
90
+ severity: 'warn',
91
+ file: relPath,
92
+ line: lineNum,
93
+ message: `RUN references npm script "${scriptName}" which is not in package.json.`,
94
+ detail: `Available: ${Object.keys(scripts).join(', ')}`,
95
+ });
96
+ }
97
+ }
98
+ continue;
99
+ }
100
+ // CMD / ENTRYPOINT — check node local file references
101
+ if (/^(?:CMD|ENTRYPOINT)\s+/i.test(trimmed)) {
102
+ const cmdStr = parseCmdEntrypoint(trimmed);
103
+ const localFile = extractNodePathFromExec(cmdStr);
104
+ if (localFile) {
105
+ const resolved = path.resolve(ctx.repoRoot, localFile);
106
+ if (!fs.existsSync(resolved)) {
107
+ findings.push({
108
+ ruleId: 'CA-DOCKER002',
109
+ severity: 'warn',
110
+ file: relPath,
111
+ line: lineNum,
112
+ message: `CMD/ENTRYPOINT references "${localFile}" which does not exist.`,
113
+ });
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ return findings;
120
+ },
121
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from '../engine.js';
2
+ export declare const caDocs001: Rule;