@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
package/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # codeanchor
2
+
3
+ `codeanchor` catches the class of bugs that ESLint, Prettier, and type checkers cannot: broken references between your repo's moving parts. Docs that reference deleted scripts. CI workflows that call scripts that don't exist. Dockerfiles that COPY paths that were renamed. Comments that silently lie about the code beneath them.
4
+
5
+ It does not replace any existing tool. It runs alongside them.
6
+
7
+ ---
8
+
9
+ ## The problem it solves
10
+
11
+ You rename `scripts/build.js` to `scripts/bundle.js`. Your README still says `npm run build`, your CI workflow still calls it, and your Dockerfile still tries to COPY the old path. Nothing fails until you deploy or onboard a new engineer. `codeanchor` catches this before the commit lands.
12
+
13
+ ---
14
+
15
+ ## Rules
16
+
17
+ | ID | Description | Mode | Default Severity | Languages |
18
+ |---|---|---|---|---|
19
+ | CA-CD001 | Leading comment not updated after code changed | `staged` | error | JS, TS, Java, C, C++, C#, Go, Python |
20
+ | CA-DOCS001 | README/docs reference an npm script missing from `package.json` | `repo`, `pr` | error | Markdown |
21
+ | CA-DOCS002 | README/docs have a broken local Markdown link | `repo`, `pr` | error | Markdown |
22
+ | CA-DOCS003 | README/docs mention a backtick-enclosed local path that doesn't exist | `repo`, `pr` | warn | Markdown |
23
+ | CA-CI001 | GitHub Actions workflow references a missing npm script | `repo`, `pr` | error | YAML |
24
+ | CA-CI003 | GitHub Actions workflow references a local path that doesn't exist | `repo`, `pr` | error | YAML |
25
+ | CA-DOCKER001 | Dockerfile `COPY`/`ADD` references a path that doesn't exist | `repo`, `pr` | warn | Dockerfile |
26
+ | CA-DOCKER002 | Dockerfile `RUN`/`CMD`/`ENTRYPOINT` references a missing script or file | `repo`, `pr` | warn | Dockerfile |
27
+ | CA-PKG001 | `package.json` script references a local file that doesn't exist | `repo`, `pr` | error | JSON |
28
+ | CA-PKG002 | `package.json` entrypoint field (`main`, `exports`, etc.) references a missing file | `repo`, `pr` | error | JSON |
29
+ | CA-LOCK001 | Dependency fields changed in `package.json` but no lockfile was updated | `staged`, `pr` | error | JSON |
30
+ | CA-TEST001 | Frequently changed file has no associated test | `history` | warn | All |
31
+ | CA-TEST002 | Source changed much more often than its test — test may be stale | `history` | warn | All |
32
+ | CA-OWN001 | Frequently changed file has no CODEOWNERS entry | `history` | warn | All |
33
+ | CA-TODO003 | TODO/FIXME/HACK older than 90 days with no issue link | `history` | warn | All |
34
+
35
+ ---
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ npm install -g codeanchor
41
+ ```
42
+
43
+ Or use without installing:
44
+
45
+ ```bash
46
+ npx codeanchor scan --repo
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Quick start
52
+
53
+ **Pre-commit (staged files only):**
54
+ ```bash
55
+ codeanchor scan --staged
56
+ ```
57
+
58
+ **Full repo scan:**
59
+ ```bash
60
+ codeanchor scan --repo
61
+ ```
62
+
63
+ **PR diff (e.g. in CI):**
64
+ ```bash
65
+ codeanchor scan --base origin/main --head HEAD
66
+ ```
67
+
68
+ **History report (run weekly, never blocks commits):**
69
+ ```bash
70
+ codeanchor scan --history --since 90d
71
+ ```
72
+
73
+ **JSON output:**
74
+ ```bash
75
+ codeanchor scan --repo --json report.json
76
+ ```
77
+
78
+ **Markdown report:**
79
+ ```bash
80
+ codeanchor scan --history --since 90d --markdown maintenance-report.md
81
+ ```
82
+
83
+ **List all rules:**
84
+ ```bash
85
+ codeanchor rules
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Pre-commit setup
91
+
92
+ ### Husky
93
+
94
+ ```bash
95
+ npm install --save-dev husky
96
+ npx husky init
97
+ echo "npx codeanchor scan --staged" > .husky/pre-commit
98
+ ```
99
+
100
+ ### pre-commit (Python ecosystem)
101
+
102
+ ```yaml
103
+ # .pre-commit-config.yaml
104
+ repos:
105
+ - repo: local
106
+ hooks:
107
+ - id: codeanchor
108
+ name: codeanchor
109
+ language: node
110
+ entry: npx codeanchor scan --staged
111
+ pass_filenames: false
112
+ ```
113
+
114
+ ---
115
+
116
+ ## GitHub Actions setup
117
+
118
+ ```yaml
119
+ # .github/workflows/codeanchor.yml
120
+ name: codeanchor
121
+ on:
122
+ pull_request:
123
+ push:
124
+ branches: [main]
125
+ jobs:
126
+ scan:
127
+ runs-on: ubuntu-latest
128
+ steps:
129
+ - uses: actions/checkout@v4
130
+ with:
131
+ fetch-depth: 0
132
+ - uses: actions/setup-node@v4
133
+ with:
134
+ node-version: '20'
135
+ - run: npx codeanchor scan --base origin/main --head HEAD
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Config reference
141
+
142
+ Place `codeanchor.config.json` in your repo root. All fields are optional.
143
+
144
+ ```json
145
+ {
146
+ "exclude": ["dist/**", "*.generated.ts", "vendor/**"],
147
+ "rules": {
148
+ "CA-CD001": { "severity": "error", "maxOwnershipDistance": 20 },
149
+ "CA-DOCS001": { "severity": "error" },
150
+ "CA-DOCS002": { "severity": "error" },
151
+ "CA-CI001": { "severity": "error" },
152
+ "CA-DOCKER001": { "severity": "warn" },
153
+ "CA-PKG001": { "severity": "error" },
154
+ "CA-TEST001": { "severity": "warn" },
155
+ "CA-TEST002": { "severity": "warn" },
156
+ "CA-OWN001": { "severity": "warn" },
157
+ "CA-TODO003": { "severity": "warn" }
158
+ }
159
+ }
160
+ ```
161
+
162
+ Disable a rule entirely:
163
+
164
+ ```json
165
+ { "rules": { "CA-DOCKER001": false } }
166
+ ```
167
+
168
+ ### `maxOwnershipDistance` (CA-CD001)
169
+
170
+ How many lines of code below a comment it owns. Default: 20. Increase for files with dense comment blocks; decrease for tighter enforcement.
171
+
172
+ ---
173
+
174
+ ## Approving intentional stale comments (CA-CD001)
175
+
176
+ If a comment intentionally describes behavior that differs from the current code:
177
+
178
+ ```bash
179
+ codeanchor approve src/api.ts 12
180
+ codeanchor approve src/utils.py 8
181
+ codeanchor approve src/Auth.java 22
182
+ ```
183
+
184
+ Approvals are stored in `.commentguard/approvals.json` and are invalidated automatically if either the comment or the code beneath it changes.
185
+
186
+ ---
187
+
188
+ ## Exit codes
189
+
190
+ | Code | Meaning |
191
+ |---|---|
192
+ | 0 | No error-severity findings |
193
+ | 1 | One or more error-severity findings |
194
+ | 2 | Config or usage error |
195
+
196
+ Use `--fail-on-warn` to exit 1 on warnings too.
197
+
198
+ ---
199
+
200
+ ## Supported languages (CA-CD001)
201
+
202
+ | Language | Extensions | Comment syntax |
203
+ |---|---|---|
204
+ | JavaScript / TypeScript | `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs` | `//`, `/* */`, `/** */` |
205
+ | Java | `.java` | `//`, `/* */`, `/** */` |
206
+ | C / C++ | `.c`, `.h`, `.cpp`, `.hpp`, `.cc` | `//`, `/* */` |
207
+ | C# | `.cs` | `//`, `/* */` |
208
+ | Go | `.go` | `//`, `/* */` |
209
+ | Python | `.py` | `#`, `"""docstrings"""` |
210
+
211
+ ---
212
+
213
+ ## Migrating from stale-comment-guard
214
+
215
+ `codeanchor` is a drop-in superset. Your existing `.commentguard/approvals.json` is read without migration.
216
+
217
+ | Old command | New command |
218
+ |---|---|
219
+ | `stale-comment-guard check` | `codeanchor scan --staged` |
220
+ | `stale-comment-guard approve <file> <line>` | `codeanchor approve <file> <line>` |
221
+
222
+ The config format changes from `.commentguard.json` to `codeanchor.config.json` under the `rules.CA-CD001` key. The old config is not read automatically — copy your settings across if needed.
223
+
224
+ ---
225
+
226
+ ## Demo
227
+
228
+ The `demo/` directory is an intentionally broken repo. Run:
229
+
230
+ ```bash
231
+ cd demo
232
+ codeanchor scan --repo
233
+ ```
234
+
235
+ Expected output will show violations for all Phase 1+2 rules.
236
+
237
+ ---
238
+
239
+ ## Contributing
240
+
241
+ ```bash
242
+ git clone https://github.com/your-username/codeanchor
243
+ cd codeanchor
244
+ npm install
245
+ npm test
246
+ npm run build
247
+ ```
248
+
249
+ Tests use `vitest`. Phase 1–2 tests use temporary file fixtures; Phase 3 tests create real temporary git repos.
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,177 @@
1
+ import { Command } from 'commander';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadConfig } from './config.js';
5
+ import { getStagedDiff, getPrDiff, parseDiff } from './git/diff.js';
6
+ import { runEngine } from './engine.js';
7
+ import { printResult, renderMarkdown } from './reporter.js';
8
+ import { getDriver } from './util/languages.js';
9
+ import { findCommentAtLine } from './util/comment-parser.js';
10
+ import { getOwnedRegion } from './util/ownership.js';
11
+ import { loadApprovals, buildApproval, upsertApproval, saveApprovals } from './util/approvals.js';
12
+ import { allRules } from './rules/index.js';
13
+ const program = new Command();
14
+ program
15
+ .name('codeanchor')
16
+ .description('Deterministic tech-debt and workflow-drift CLI')
17
+ .version('0.1.0');
18
+ program
19
+ .command('scan')
20
+ .description('Scan for repo drift issues')
21
+ .option('--staged', 'Check only staged changes (pre-commit mode)')
22
+ .option('--repo', 'Check current repo state')
23
+ .option('--base <ref>', 'Base ref for PR diff')
24
+ .option('--head <ref>', 'Head ref for PR diff')
25
+ .option('--history', 'Enable history-based rules')
26
+ .option('--since <duration>', 'Window for history mode (e.g. 90d, 6m)')
27
+ .option('--rules <ids>', 'Comma-separated rule IDs to run')
28
+ .option('--json [path]', 'Write JSON output (stdout if no path given)')
29
+ .option('--markdown [path]', 'Write Markdown report (stdout if no path given)')
30
+ .option('--fail-on-warn', 'Exit 1 even for warnings')
31
+ .option('--no-color', 'Disable color output')
32
+ .action(async (opts) => {
33
+ const cwd = process.cwd();
34
+ const config = loadConfig(cwd);
35
+ let ruleIds;
36
+ if (opts.rules) {
37
+ ruleIds = opts.rules.split(',').map((s) => s.trim()).filter(Boolean);
38
+ const unknown = ruleIds.filter(id => !allRules.some(r => r.id === id));
39
+ if (unknown.length > 0) {
40
+ console.error(`Unknown rule IDs: ${unknown.join(', ')}`);
41
+ console.error(`Valid IDs: ${allRules.map(r => r.id).join(', ')}`);
42
+ process.exit(2);
43
+ }
44
+ }
45
+ let mode = 'repo';
46
+ let stagedDiffs = undefined;
47
+ if (opts.staged) {
48
+ mode = 'staged';
49
+ const rawDiff = getStagedDiff();
50
+ if (!rawDiff.trim()) {
51
+ console.log('No staged changes.');
52
+ process.exit(0);
53
+ }
54
+ stagedDiffs = parseDiff(rawDiff);
55
+ }
56
+ else if (opts.base && opts.head) {
57
+ mode = 'pr';
58
+ const rawDiff = getPrDiff(opts.base, opts.head);
59
+ if (rawDiff.trim()) {
60
+ stagedDiffs = parseDiff(rawDiff);
61
+ }
62
+ }
63
+ else if (opts.history) {
64
+ mode = 'history';
65
+ }
66
+ const result = await runEngine({
67
+ mode,
68
+ repoRoot: cwd,
69
+ config,
70
+ stagedDiffs,
71
+ since: opts.since,
72
+ ruleIds,
73
+ });
74
+ const shouldFail = result.errorCount > 0 || (opts.failOnWarn && result.warnCount > 0);
75
+ if (opts.json !== undefined) {
76
+ const ruleBreakdown = {};
77
+ for (const f of result.findings) {
78
+ ruleBreakdown[f.ruleId] = (ruleBreakdown[f.ruleId] ?? 0) + 1;
79
+ }
80
+ const jsonOutput = {
81
+ version: '1',
82
+ mode: result.mode,
83
+ timestamp: result.timestamp,
84
+ repoRoot: result.repoRoot,
85
+ summary: {
86
+ errorCount: result.errorCount,
87
+ warnCount: result.warnCount,
88
+ ruleBreakdown,
89
+ },
90
+ findings: result.findings,
91
+ };
92
+ const json = JSON.stringify(jsonOutput, null, 2) + '\n';
93
+ if (typeof opts.json === 'string') {
94
+ fs.writeFileSync(opts.json, json, 'utf-8');
95
+ }
96
+ else {
97
+ process.stdout.write(json);
98
+ }
99
+ if (shouldFail)
100
+ process.exit(1);
101
+ return;
102
+ }
103
+ if (opts.markdown !== undefined) {
104
+ const md = renderMarkdown(result);
105
+ if (typeof opts.markdown === 'string') {
106
+ fs.writeFileSync(opts.markdown, md, 'utf-8');
107
+ }
108
+ else {
109
+ process.stdout.write(md);
110
+ }
111
+ if (shouldFail)
112
+ process.exit(1);
113
+ return;
114
+ }
115
+ printResult(result);
116
+ if (shouldFail) {
117
+ process.exit(1);
118
+ }
119
+ });
120
+ program
121
+ .command('approve <file> <line>')
122
+ .description('Mark a stale comment as intentionally reviewed')
123
+ .action((file, lineStr) => {
124
+ const cwd = process.cwd();
125
+ const line = parseInt(lineStr, 10);
126
+ if (isNaN(line)) {
127
+ console.error(`Invalid line number: ${lineStr}`);
128
+ process.exit(2);
129
+ }
130
+ const driver = getDriver(file);
131
+ if (!driver) {
132
+ console.error(`Unsupported file type: ${file}`);
133
+ process.exit(2);
134
+ }
135
+ const absPath = path.resolve(cwd, file);
136
+ let content;
137
+ try {
138
+ content = fs.readFileSync(absPath, 'utf-8');
139
+ }
140
+ catch {
141
+ console.error(`Cannot read file: ${file}`);
142
+ process.exit(2);
143
+ }
144
+ const comment = findCommentAtLine(content, line, driver);
145
+ if (!comment) {
146
+ console.error(`No leading comment found at line ${line} in ${file}`);
147
+ process.exit(2);
148
+ }
149
+ const config = loadConfig(cwd);
150
+ const ruleCfg = config.rules['CA-CD001'];
151
+ const maxDist = typeof ruleCfg === 'object' && ruleCfg?.maxOwnershipDistance
152
+ ? ruleCfg.maxOwnershipDistance
153
+ : 20;
154
+ const lines = content.split('\n');
155
+ const region = getOwnedRegion(comment, lines, maxDist, driver);
156
+ if (!region) {
157
+ console.error(`Could not determine owned region for comment at ${file}:${line}`);
158
+ process.exit(2);
159
+ }
160
+ const store = loadApprovals(cwd);
161
+ const approval = buildApproval(file, comment, lines, region, cwd);
162
+ upsertApproval(store, approval);
163
+ saveApprovals(store, cwd);
164
+ console.log(`Approved ${file}:${line}`);
165
+ });
166
+ program
167
+ .command('rules')
168
+ .description('List all rules with ID, description, mode, and default severity')
169
+ .action(() => {
170
+ console.log('\nAvailable rules:\n');
171
+ for (const rule of allRules) {
172
+ const modes = rule.applicableModes.join(', ');
173
+ console.log(` ${rule.id} [${rule.defaultSeverity}] modes: ${modes}`);
174
+ console.log(` ${rule.description}\n`);
175
+ }
176
+ });
177
+ program.parse();
@@ -0,0 +1,9 @@
1
+ export interface RuleConfig {
2
+ severity?: 'error' | 'warn' | 'info';
3
+ maxOwnershipDistance?: number;
4
+ }
5
+ export interface CodeAnchorConfig {
6
+ exclude: string[];
7
+ rules: Record<string, RuleConfig | false | undefined>;
8
+ }
9
+ export declare function loadConfig(cwd?: string): CodeAnchorConfig;
package/dist/config.js ADDED
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const DEFAULTS = {
4
+ exclude: [],
5
+ rules: {
6
+ 'CA-CD001': { severity: 'error', maxOwnershipDistance: 20 },
7
+ 'CA-DOCS001': { severity: 'error' },
8
+ 'CA-DOCS002': { severity: 'error' },
9
+ 'CA-DOCS003': { severity: 'warn' },
10
+ 'CA-CI001': { severity: 'error' },
11
+ 'CA-CI003': { severity: 'error' },
12
+ 'CA-DOCKER001': { severity: 'warn' },
13
+ 'CA-DOCKER002': { severity: 'warn' },
14
+ 'CA-PKG001': { severity: 'error' },
15
+ 'CA-PKG002': { severity: 'error' },
16
+ 'CA-LOCK001': { severity: 'error' },
17
+ 'CA-TEST001': { severity: 'warn' },
18
+ 'CA-TEST002': { severity: 'warn' },
19
+ 'CA-OWN001': { severity: 'warn' },
20
+ 'CA-TODO003': { severity: 'warn' },
21
+ },
22
+ };
23
+ export function loadConfig(cwd = process.cwd()) {
24
+ const configPath = path.join(cwd, 'codeanchor.config.json');
25
+ if (!fs.existsSync(configPath))
26
+ return DEFAULTS;
27
+ try {
28
+ const user = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
29
+ return {
30
+ exclude: user.exclude ?? DEFAULTS.exclude,
31
+ rules: { ...DEFAULTS.rules, ...user.rules },
32
+ };
33
+ }
34
+ catch {
35
+ return DEFAULTS;
36
+ }
37
+ }
@@ -0,0 +1,18 @@
1
+ import type { Finding, ScanResult, ScanMode, FileDiff } from './types.js';
2
+ import type { CodeAnchorConfig } from './config.js';
3
+ export interface RuleContext {
4
+ mode: ScanMode;
5
+ repoRoot: string;
6
+ config: CodeAnchorConfig;
7
+ stagedDiffs?: FileDiff[];
8
+ since?: string;
9
+ ruleIds?: string[];
10
+ }
11
+ export interface Rule {
12
+ id: string;
13
+ description: string;
14
+ defaultSeverity: 'error' | 'warn' | 'info';
15
+ applicableModes: ScanMode[];
16
+ run(ctx: RuleContext): Promise<Finding[]>;
17
+ }
18
+ export declare function runEngine(ctx: RuleContext): Promise<ScanResult>;
package/dist/engine.js ADDED
@@ -0,0 +1,30 @@
1
+ import { allRules } from './rules/index.js';
2
+ export async function runEngine(ctx) {
3
+ const applicableRules = allRules.filter(rule => {
4
+ if (ctx.ruleIds && !ctx.ruleIds.includes(rule.id))
5
+ return false;
6
+ const ruleCfg = ctx.config.rules[rule.id];
7
+ if (ruleCfg === false)
8
+ return false;
9
+ return rule.applicableModes.includes(ctx.mode);
10
+ });
11
+ const allFindings = [];
12
+ for (const rule of applicableRules) {
13
+ const findings = await rule.run(ctx);
14
+ const ruleCfg = ctx.config.rules[rule.id];
15
+ if (typeof ruleCfg === 'object' && ruleCfg?.severity) {
16
+ for (const f of findings) {
17
+ f.severity = ruleCfg.severity;
18
+ }
19
+ }
20
+ allFindings.push(...findings);
21
+ }
22
+ return {
23
+ mode: ctx.mode,
24
+ timestamp: new Date().toISOString(),
25
+ repoRoot: ctx.repoRoot,
26
+ findings: allFindings,
27
+ errorCount: allFindings.filter(f => f.severity === 'error').length,
28
+ warnCount: allFindings.filter(f => f.severity === 'warn').length,
29
+ };
30
+ }
@@ -0,0 +1,8 @@
1
+ export interface BlameLine {
2
+ lineNumber: number;
3
+ commitHash: string;
4
+ authorTime: number;
5
+ content: string;
6
+ }
7
+ export declare function getBlameLines(repoRoot: string, filePath: string): BlameLine[];
8
+ export declare function getBlameAge(repoRoot: string, filePath: string): Map<number, number>;
@@ -0,0 +1,57 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export function getBlameLines(repoRoot, filePath) {
3
+ let raw;
4
+ try {
5
+ raw = execFileSync('git', ['blame', '--porcelain', '--', filePath], {
6
+ encoding: 'utf-8',
7
+ cwd: repoRoot,
8
+ });
9
+ }
10
+ catch {
11
+ return [];
12
+ }
13
+ const lines = [];
14
+ // commitTimes caches author-time per commit hash — porcelain omits it for repeated commits
15
+ const commitTimes = new Map();
16
+ const parts = raw.split('\n');
17
+ let i = 0;
18
+ while (i < parts.length) {
19
+ const line = parts[i];
20
+ if (!line) {
21
+ i++;
22
+ continue;
23
+ }
24
+ const headerMatch = line.match(/^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/);
25
+ if (!headerMatch) {
26
+ i++;
27
+ continue;
28
+ }
29
+ const hash = headerMatch[1];
30
+ const finalLine = parseInt(headerMatch[2], 10);
31
+ i++;
32
+ let authorTime = commitTimes.get(hash) ?? 0;
33
+ // Parse header fields until we hit the tab-prefixed content line
34
+ while (i < parts.length && !parts[i].startsWith('\t')) {
35
+ const field = parts[i];
36
+ if (field.startsWith('author-time ')) {
37
+ authorTime = parseInt(field.slice(12), 10);
38
+ commitTimes.set(hash, authorTime);
39
+ }
40
+ i++;
41
+ }
42
+ const content = parts[i]?.startsWith('\t') ? parts[i].slice(1) : '';
43
+ lines.push({ lineNumber: finalLine, commitHash: hash, authorTime, content });
44
+ i++;
45
+ }
46
+ return lines;
47
+ }
48
+ // Returns a map from 1-indexed line number → age in seconds
49
+ export function getBlameAge(repoRoot, filePath) {
50
+ const blameLines = getBlameLines(repoRoot, filePath);
51
+ const now = Date.now() / 1000;
52
+ const ageMap = new Map();
53
+ for (const bl of blameLines) {
54
+ ageMap.set(bl.lineNumber, now - bl.authorTime);
55
+ }
56
+ return ageMap;
57
+ }
@@ -0,0 +1,5 @@
1
+ import type { FileDiff } from '../types.js';
2
+ export declare function getStagedDiff(): string;
3
+ export declare function getPrDiff(base: string, head: string): string;
4
+ export declare function parseDiff(raw: string): FileDiff[];
5
+ export declare function diffTouchesRange(fileDiff: FileDiff, startLine: number, endLine: number): boolean;
@@ -0,0 +1,75 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export function getStagedDiff() {
3
+ try {
4
+ return execFileSync('git', ['diff', '--staged'], { encoding: 'utf-8' });
5
+ }
6
+ catch {
7
+ return '';
8
+ }
9
+ }
10
+ export function getPrDiff(base, head) {
11
+ try {
12
+ return execFileSync('git', ['diff', `${base}...${head}`], { encoding: 'utf-8' });
13
+ }
14
+ catch {
15
+ return '';
16
+ }
17
+ }
18
+ export function parseDiff(raw) {
19
+ const results = [];
20
+ const fileBlocks = raw.split(/^diff --git /m).slice(1);
21
+ for (const block of fileBlocks) {
22
+ const lines = block.split('\n');
23
+ let path = '';
24
+ let status = 'modified';
25
+ const changedLines = new Set();
26
+ for (const line of lines) {
27
+ if (line.startsWith('--- /dev/null')) {
28
+ status = 'added';
29
+ }
30
+ else if (line.startsWith('+++ /dev/null')) {
31
+ status = 'deleted';
32
+ }
33
+ else if (line.startsWith('+++ b/')) {
34
+ path = line.slice(6).trim();
35
+ }
36
+ else if (line.startsWith('rename to ')) {
37
+ status = 'renamed';
38
+ path = line.slice(10).trim();
39
+ }
40
+ }
41
+ if (!path)
42
+ continue;
43
+ let newLineNum = 0;
44
+ let inHunk = false;
45
+ for (const line of lines) {
46
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
47
+ if (hunkMatch) {
48
+ newLineNum = parseInt(hunkMatch[1], 10);
49
+ inHunk = true;
50
+ continue;
51
+ }
52
+ if (!inHunk)
53
+ continue;
54
+ if (line.startsWith('+') && !line.startsWith('+++')) {
55
+ changedLines.add(newLineNum);
56
+ newLineNum++;
57
+ }
58
+ else if (line.startsWith('-') && !line.startsWith('---')) {
59
+ // removed line — doesn't advance new-file counter
60
+ }
61
+ else if (!line.startsWith('\\')) {
62
+ newLineNum++;
63
+ }
64
+ }
65
+ results.push({ path, status, changedLines });
66
+ }
67
+ return results;
68
+ }
69
+ export function diffTouchesRange(fileDiff, startLine, endLine) {
70
+ for (let i = startLine; i <= endLine; i++) {
71
+ if (fileDiff.changedLines.has(i))
72
+ return true;
73
+ }
74
+ return false;
75
+ }
@@ -0,0 +1,7 @@
1
+ export interface HotFile {
2
+ path: string;
3
+ commitCount: number;
4
+ }
5
+ export declare function parseSinceDuration(since: string): string;
6
+ export declare function getHotFiles(repoRoot: string, since: string, minCommits?: number): HotFile[];
7
+ export declare function getFileCommitCount(repoRoot: string, filePath: string, since: string): number;