@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.
- package/README.md +249 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +177 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +37 -0
- package/dist/engine.d.ts +18 -0
- package/dist/engine.js +30 -0
- package/dist/git/blame.d.ts +8 -0
- package/dist/git/blame.js +57 -0
- package/dist/git/diff.d.ts +5 -0
- package/dist/git/diff.js +75 -0
- package/dist/git/history.d.ts +7 -0
- package/dist/git/history.js +46 -0
- package/dist/reporter.d.ts +3 -0
- package/dist/reporter.js +67 -0
- package/dist/rules/ca-cd001.d.ts +2 -0
- package/dist/rules/ca-cd001.js +84 -0
- package/dist/rules/ca-ci001.d.ts +2 -0
- package/dist/rules/ca-ci001.js +86 -0
- package/dist/rules/ca-ci003.d.ts +2 -0
- package/dist/rules/ca-ci003.js +114 -0
- package/dist/rules/ca-docker001.d.ts +2 -0
- package/dist/rules/ca-docker001.js +69 -0
- package/dist/rules/ca-docker002.d.ts +2 -0
- package/dist/rules/ca-docker002.js +121 -0
- package/dist/rules/ca-docs001.d.ts +2 -0
- package/dist/rules/ca-docs001.js +75 -0
- package/dist/rules/ca-docs002.d.ts +2 -0
- package/dist/rules/ca-docs002.js +71 -0
- package/dist/rules/ca-docs003.d.ts +2 -0
- package/dist/rules/ca-docs003.js +105 -0
- package/dist/rules/ca-lock001.d.ts +2 -0
- package/dist/rules/ca-lock001.js +123 -0
- package/dist/rules/ca-own001.d.ts +2 -0
- package/dist/rules/ca-own001.js +71 -0
- package/dist/rules/ca-pkg001.d.ts +2 -0
- package/dist/rules/ca-pkg001.js +56 -0
- package/dist/rules/ca-pkg002.d.ts +2 -0
- package/dist/rules/ca-pkg002.js +93 -0
- package/dist/rules/ca-test001.d.ts +2 -0
- package/dist/rules/ca-test001.js +58 -0
- package/dist/rules/ca-test002.d.ts +2 -0
- package/dist/rules/ca-test002.js +60 -0
- package/dist/rules/ca-todo003.d.ts +2 -0
- package/dist/rules/ca-todo003.js +82 -0
- package/dist/rules/index.d.ts +2 -0
- package/dist/rules/index.js +32 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/dist/util/approvals.d.ts +7 -0
- package/dist/util/approvals.js +63 -0
- package/dist/util/comment-parser.d.ts +4 -0
- package/dist/util/comment-parser.js +173 -0
- package/dist/util/exclude.d.ts +1 -0
- package/dist/util/exclude.js +12 -0
- package/dist/util/hash.d.ts +1 -0
- package/dist/util/hash.js +4 -0
- package/dist/util/ignore-rules.d.ts +3 -0
- package/dist/util/ignore-rules.js +39 -0
- package/dist/util/lang-cstyle.d.ts +2 -0
- package/dist/util/lang-cstyle.js +30 -0
- package/dist/util/lang-python.d.ts +2 -0
- package/dist/util/lang-python.js +19 -0
- package/dist/util/languages.d.ts +8 -0
- package/dist/util/languages.js +8 -0
- package/dist/util/ownership.d.ts +4 -0
- package/dist/util/ownership.js +49 -0
- 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();
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -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;
|
package/dist/git/diff.js
ADDED
|
@@ -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;
|