ancient-fences 0.1.0 → 0.2.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 +55 -12
- package/bin/ancient-fences.mjs +115 -19
- package/package.json +2 -2
- package/src/age.mjs +33 -0
- package/src/detect.mjs +131 -33
- package/src/report.mjs +129 -16
- package/src/tracker.mjs +50 -17
- package/src/walk.mjs +35 -1
package/README.md
CHANGED
|
@@ -11,14 +11,12 @@ Dependabot bumps the version. Nobody removes the workaround you wrote because
|
|
|
11
11
|
the old version was broken.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
15
|
-
npx
|
|
16
|
-
npx github:Marcin1000/ancient-fences . --check # and whether the reasons still hold
|
|
14
|
+
npx ancient-fences . # what is standing in this codebase
|
|
15
|
+
npx ancient-fences . --check # and whether the reasons still hold
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
promising something that returns a 404.
|
|
18
|
+
No installation, no configuration, no account. It reads the repository you
|
|
19
|
+
point it at and prints what it found.
|
|
22
20
|
|
|
23
21
|
## The problem
|
|
24
22
|
|
|
@@ -54,13 +52,18 @@ that no longer exists.
|
|
|
54
52
|
|
|
55
53
|
Two runs, full clones, August 2026:
|
|
56
54
|
|
|
57
|
-
| Repository | Fences | Because of someone else's bug |
|
|
55
|
+
| Repository | Fences | Because of someone else's bug | Untouched 3+ years | Oldest |
|
|
58
56
|
|---|---|---|---|---|
|
|
59
|
-
| puppeteer/puppeteer |
|
|
60
|
-
| webpack/webpack |
|
|
57
|
+
| puppeteer/puppeteer | 77 | 62 | 48 | 9.1 yr |
|
|
58
|
+
| webpack/webpack | 93 | 65 | 23 | 8.8 yr |
|
|
61
59
|
|
|
62
60
|
Both are well-maintained projects by good engineers. That is the point.
|
|
63
61
|
|
|
62
|
+
Measured on a full clone, because a shallow one cannot date a line. Numbers
|
|
63
|
+
from earlier versions were higher and worse: counting the word "until" as
|
|
64
|
+
evidence turned two hundred ordinary comments in webpack into findings. A
|
|
65
|
+
number you have to discount is not worth printing.
|
|
66
|
+
|
|
64
67
|
## What it finds
|
|
65
68
|
|
|
66
69
|
| Kind | What it means | The repair when the reason dies |
|
|
@@ -70,6 +73,15 @@ Both are well-maintained projects by good engineers. That is the point.
|
|
|
70
73
|
| `deadline` | a date written into a comment | the date passed, revisit |
|
|
71
74
|
| `unmarked` | clearly a workaround, no recorded reason | write the reason down, or remove it |
|
|
72
75
|
|
|
76
|
+
A comment counts as a fence when it links to an external tracker, or says
|
|
77
|
+
something that only a fence says ("workaround", "kludge", "no longer needed",
|
|
78
|
+
"do not upgrade"). Words that merely appear in fences ("until", "polyfill",
|
|
79
|
+
"temporary", "regression") count only when the comment also names the
|
|
80
|
+
condition: a deadline or a version. Phrases that read as prose elsewhere
|
|
81
|
+
("remove this", "blocked by") count next to a `TODO`, `FIXME` or `HACK`, or
|
|
82
|
+
next to a named condition. Comment markers are read per language, so `#fff` in
|
|
83
|
+
a stylesheet and `a // b` in Python are not comments.
|
|
84
|
+
|
|
73
85
|
Trackers understood: GitHub issues and pull requests, Chromium (`crbug.com`),
|
|
74
86
|
Mozilla Bugzilla, WebKit.
|
|
75
87
|
|
|
@@ -93,6 +105,11 @@ a workaround for a bug that was fixed years ago, because nobody upgraded.
|
|
|
93
105
|
`package-lock.json`, `yarn.lock` and `pnpm-lock.yaml` are read when present. No
|
|
94
106
|
lockfile means the tool says less, never something false.
|
|
95
107
|
|
|
108
|
+
Fence age comes from `git blame`, which needs history. In a shallow clone
|
|
109
|
+
(`--depth 1`, and every default CI checkout) git dates every line to the day it
|
|
110
|
+
was fetched, so the report says age was not measured rather than printing a
|
|
111
|
+
confident zero.
|
|
112
|
+
|
|
96
113
|
**An unknown issue state never produces `still valid`.** Not knowing is not a
|
|
97
114
|
green light. A tool that reassures you without grounds is worse than no tool.
|
|
98
115
|
(The test enforcing this caught a real bug on the prototype's first run.)
|
|
@@ -107,10 +124,36 @@ green light. A tool that reassures you without grounds is worse than no tool.
|
|
|
107
124
|
--api-base=URL alternate API (GitHub Enterprise, or a mock in tests)
|
|
108
125
|
--json full machine-readable output
|
|
109
126
|
--no-blame skip fence age (faster, tells you less)
|
|
127
|
+
--include-generated scan bundles and minified builds too
|
|
128
|
+
--cache=FILE where to keep issue states
|
|
129
|
+
--no-cache ignore cached state and ask the tracker again
|
|
130
|
+
--max-age-days=N how old a cached state may be before it is re-checked
|
|
131
|
+
(default 7)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
An option this tool does not recognise stops the run. A mistyped `--chek`
|
|
135
|
+
used to print a normal report, and the reader believed the trackers had been
|
|
136
|
+
consulted.
|
|
137
|
+
|
|
138
|
+
You can also point it at a single file: `npx ancient-fences src/thing.js`.
|
|
139
|
+
|
|
140
|
+
Bundles committed into a repository (`vendor.js`, a browserify or webpack
|
|
141
|
+
build, anything minified) are skipped, and the summary says how many were left
|
|
142
|
+
out. The fences inside them belong to the libraries they were built from, so
|
|
143
|
+
listing them buries the ones your team can actually act on. `dist`, `build`,
|
|
144
|
+
`node_modules`, `vendor` and `third_party` are skipped for the same reason.
|
|
145
|
+
|
|
146
|
+
Issue states are cached in your user cache directory (`$XDG_CACHE_HOME`,
|
|
147
|
+
`%LOCALAPPDATA%`, or `~/.cache`), never inside the repository being scanned.
|
|
148
|
+
Every entry records when it was read, entries older than a week are re-checked,
|
|
149
|
+
and if the tracker cannot be reached the report says which day the answer is
|
|
150
|
+
from:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
VERDICT: remove (reason disappeared 2021-04-02 (state as of 2026-08-18))
|
|
110
154
|
```
|
|
111
155
|
|
|
112
|
-
|
|
113
|
-
repository. Add it to your `.gitignore`.
|
|
156
|
+
An issue state is a snapshot, not a fact. Closed issues get reopened.
|
|
114
157
|
|
|
115
158
|
## Working with an agent
|
|
116
159
|
|
|
@@ -119,7 +162,7 @@ fence is dead is the scarce part; every editor now ships something that can do
|
|
|
119
162
|
the deleting. So `--tasks` writes the verified findings as work:
|
|
120
163
|
|
|
121
164
|
```bash
|
|
122
|
-
npx
|
|
165
|
+
npx ancient-fences . --check --tasks
|
|
123
166
|
```
|
|
124
167
|
|
|
125
168
|
You get a markdown file with one entry per dead fence: the file and line, the
|
package/bin/ancient-fences.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { resolve, basename, join } from 'node:path';
|
|
3
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve, basename, dirname, join } from 'node:path';
|
|
3
|
+
import { writeFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
4
6
|
import { walkFiles } from '../src/walk.mjs';
|
|
5
7
|
import { detectFences } from '../src/detect.mjs';
|
|
6
|
-
import { blameAll } from '../src/age.mjs';
|
|
8
|
+
import { blameAll, historyDepth } from '../src/age.mjs';
|
|
7
9
|
import { checkGithubRefs, verdict } from '../src/tracker.mjs';
|
|
8
10
|
import { renderText, renderHtml, renderTasks, summarize } from '../src/report.mjs';
|
|
9
11
|
import { readInstalled } from '../src/lockfile.mjs';
|
|
@@ -23,10 +25,13 @@ const value = (f) => {
|
|
|
23
25
|
return hit ? hit.slice(f.length + 1) : null;
|
|
24
26
|
};
|
|
25
27
|
const positional = args.filter((a) => !a.startsWith('--') && !COMMANDS.has(a));
|
|
26
|
-
const root = resolve(positional[0] ?? process.cwd());
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
const KNOWN = new Set([
|
|
30
|
+
'--check', '--report', '--tasks', '--api-base', '--json', '--no-blame',
|
|
31
|
+
'--include-generated', '--no-cache', '--cache', '--help', '--max-age-days',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const usage = `ancient-fences: find the code you wrote because of someone else's bug,
|
|
30
35
|
then check whether that bug is still there.
|
|
31
36
|
|
|
32
37
|
npx ancient-fences [path] [options]
|
|
@@ -38,20 +43,83 @@ then check whether that bug is still there.
|
|
|
38
43
|
--api-base=URL alternate API (GitHub Enterprise, or a mock in tests)
|
|
39
44
|
--json full machine-readable output
|
|
40
45
|
--no-blame skip fence age (faster, tells you less)
|
|
46
|
+
--include-generated scan bundles and minified builds too. They are skipped by
|
|
47
|
+
default: their fences belong to the libraries they were
|
|
48
|
+
built from, not to you
|
|
49
|
+
--cache=FILE where to keep issue states (default: your user cache
|
|
50
|
+
directory, never inside the repository being scanned)
|
|
51
|
+
--no-cache ignore any cached issue state and ask the tracker again
|
|
52
|
+
--max-age-days=N how old a cached state may be before it is re-checked
|
|
53
|
+
(default 7)
|
|
41
54
|
`;
|
|
55
|
+
|
|
56
|
+
if (cmd !== 'scan' || has('--help')) {
|
|
42
57
|
console.log(usage);
|
|
43
|
-
process.exit(
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A mistyped flag used to be ignored in silence, so "--chek" printed a normal
|
|
62
|
+
// report and the reader believed the issues had been checked. Anything this
|
|
63
|
+
// tool does not understand stops it.
|
|
64
|
+
const unknown = flags.filter((f) => !KNOWN.has(f.split('=')[0]));
|
|
65
|
+
if (unknown.length > 0) {
|
|
66
|
+
console.error(`ancient-fences: unknown option ${unknown.join(', ')}`);
|
|
67
|
+
console.error(usage);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
if (positional.length > 1) {
|
|
71
|
+
console.error(`ancient-fences: one path at a time, got ${positional.length}: ${positional.join(', ')}`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const target = resolve(positional[0] ?? process.cwd());
|
|
76
|
+
|
|
77
|
+
// Scanning a path that is not there used to print a clean report saying zero
|
|
78
|
+
// fences, which reads exactly like good news. A tool that says "nothing to
|
|
79
|
+
// worry about" when it looked at nothing is worse than no tool.
|
|
80
|
+
let single = null;
|
|
81
|
+
let root = target;
|
|
82
|
+
try {
|
|
83
|
+
const info = await stat(target);
|
|
84
|
+
if (info.isFile()) {
|
|
85
|
+
single = target;
|
|
86
|
+
root = dirname(target);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
console.error(`ancient-fences: nothing to scan at ${target}`);
|
|
90
|
+
if (positional[0] && positional[0].includes('--')) {
|
|
91
|
+
console.error('It looks like an option got stuck to the path. Put a space before it:');
|
|
92
|
+
console.error(' npx ancient-fences . --report');
|
|
93
|
+
}
|
|
94
|
+
process.exit(1);
|
|
44
95
|
}
|
|
45
96
|
|
|
97
|
+
const skipped = [];
|
|
46
98
|
const fences = [];
|
|
47
|
-
|
|
48
|
-
|
|
99
|
+
if (single) {
|
|
100
|
+
const { readFile } = await import('node:fs/promises');
|
|
101
|
+
const name = basename(single);
|
|
102
|
+
fences.push(...detectFences(name, await readFile(single, 'utf8')));
|
|
103
|
+
} else {
|
|
104
|
+
for await (const file of walkFiles(root, { skipped, includeGenerated: has('--include-generated') })) {
|
|
105
|
+
fences.push(...detectFences(file.path, file.text));
|
|
106
|
+
}
|
|
49
107
|
}
|
|
50
108
|
|
|
51
|
-
|
|
109
|
+
// Age comes from git blame, so it is only a number when there is history to
|
|
110
|
+
// read. A shallow clone dates every line to the day it was fetched, which
|
|
111
|
+
// turned "the oldest fence here is 8 years old" into a confident zero.
|
|
112
|
+
// --no-blame is a choice, not a measurement. Saying "0 untouched for 3+
|
|
113
|
+
// years" after skipping the only step that could tell is the same lie as
|
|
114
|
+
// measuring a shallow clone.
|
|
115
|
+
const history = has('--no-blame')
|
|
116
|
+
? { usable: false, why: '--no-blame was given, and blame is the only thing that knows' }
|
|
117
|
+
: await historyDepth(root);
|
|
118
|
+
if (history.usable) await blameAll(root, fences);
|
|
52
119
|
|
|
53
120
|
let states = new Map();
|
|
54
121
|
const checking = has('--check');
|
|
122
|
+
const cachePath = value('--cache') ? resolve(value('--cache')) : defaultCachePath(root);
|
|
55
123
|
if (checking) {
|
|
56
124
|
const ids = new Set();
|
|
57
125
|
for (const f of fences) {
|
|
@@ -61,16 +129,26 @@ if (checking) {
|
|
|
61
129
|
}
|
|
62
130
|
states = await checkGithubRefs([...ids], {
|
|
63
131
|
apiBase: value('--api-base') ?? undefined,
|
|
64
|
-
cachePath
|
|
132
|
+
cachePath,
|
|
133
|
+
noCache: has('--no-cache'),
|
|
134
|
+
maxAgeDays: Number(value('--max-age-days') ?? 7),
|
|
65
135
|
});
|
|
66
|
-
// What the lockfile says you actually run turns "the issue is closed" into
|
|
67
|
-
// "you can delete this today", or into "upgrade before you can".
|
|
68
|
-
const installed = await readInstalled(root);
|
|
69
|
-
for (const f of fences) f.verdict = verdict(f, states, installed);
|
|
70
136
|
}
|
|
71
137
|
|
|
138
|
+
// What the lockfile says you actually run turns "the issue is closed" into
|
|
139
|
+
// "you can delete this today", or into "upgrade before you can". Verdicts are
|
|
140
|
+
// computed whether or not the tracker was consulted, because a deadline that
|
|
141
|
+
// has passed needs no network to judge.
|
|
142
|
+
const installed = await readInstalled(root);
|
|
143
|
+
for (const f of fences) f.verdict = verdict(f, states, installed);
|
|
144
|
+
|
|
72
145
|
const summary = summarize(fences, states);
|
|
73
|
-
|
|
146
|
+
summary.skipped = skipped.length;
|
|
147
|
+
summary.skippedFiles = skipped;
|
|
148
|
+
summary.history = history;
|
|
149
|
+
summary.checkedAt = checking ? checkTimestamp(states) : null;
|
|
150
|
+
|
|
151
|
+
const name = single ? basename(single) : basename(root);
|
|
74
152
|
|
|
75
153
|
const reportFlag = flags.find((f) => f === '--report' || f.startsWith('--report='));
|
|
76
154
|
if (reportFlag) {
|
|
@@ -82,12 +160,30 @@ if (reportFlag) {
|
|
|
82
160
|
const tasksFlag = flags.find((f) => f === '--tasks' || f.startsWith('--tasks='));
|
|
83
161
|
if (tasksFlag) {
|
|
84
162
|
const out = resolve(value('--tasks') ?? 'ancient-fences-tasks.md');
|
|
85
|
-
await writeFile(out, renderTasks(fences, name), 'utf8');
|
|
163
|
+
await writeFile(out, renderTasks(fences, name, checking), 'utf8');
|
|
86
164
|
console.error(`agent tasks written to ${out}`);
|
|
87
165
|
}
|
|
88
166
|
|
|
89
167
|
if (has('--json')) {
|
|
90
|
-
console.log(JSON.stringify({ repo: root, summary, fences }, null, 2));
|
|
168
|
+
console.log(JSON.stringify({ repo: root, summary, fences, skipped }, null, 2));
|
|
91
169
|
} else {
|
|
92
170
|
console.log(renderText(fences, summary, name, checking));
|
|
93
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Issue states are cached so a second run does not spend the hourly request
|
|
175
|
+
* budget again. The first version wrote that cache into the repository being
|
|
176
|
+
* scanned, which left a file behind in someone else's working tree.
|
|
177
|
+
*/
|
|
178
|
+
function defaultCachePath(repo) {
|
|
179
|
+
const base = process.env.XDG_CACHE_HOME
|
|
180
|
+
?? (process.platform === 'win32' ? process.env.LOCALAPPDATA : null)
|
|
181
|
+
?? join(homedir() || tmpdir(), '.cache');
|
|
182
|
+
const key = createHash('sha256').update(resolve(repo)).digest('hex').slice(0, 16);
|
|
183
|
+
return join(base, 'ancient-fences', `${key}.json`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkTimestamp(states) {
|
|
187
|
+
const times = [...states.values()].map((s) => s.checkedAt).filter(Boolean).sort();
|
|
188
|
+
return times.length ? { oldest: times[0], newest: times[times.length - 1] } : null;
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ancient-fences",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Finds the code you wrote because of someone else's bug, and checks whether that bug is still there.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node test/detect.test.mjs && node test/tracker.test.mjs && node test/versions.test.mjs && node test/lockfile.test.mjs && node test/cli.test.mjs"
|
|
28
|
+
"test": "node test/detect.test.mjs && node test/tracker.test.mjs && node test/versions.test.mjs && node test/lockfile.test.mjs && node test/walk.test.mjs && node test/cli.test.mjs"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
package/src/age.mjs
CHANGED
|
@@ -22,6 +22,39 @@ export async function blameYear(repo, file, line) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Whether ages can be measured here at all.
|
|
27
|
+
*
|
|
28
|
+
* A shallow clone (`git clone --depth 1`, and every CI checkout by default)
|
|
29
|
+
* rewrites every line's date to the day it was fetched. Blame then answers
|
|
30
|
+
* "touched today" for an eight-year-old workaround, and the report says
|
|
31
|
+
* nothing is old. That reads as an all clear, so the tool has to say plainly
|
|
32
|
+
* that it could not measure rather than print a confident zero.
|
|
33
|
+
*/
|
|
34
|
+
export async function historyDepth(repo) {
|
|
35
|
+
const git = async (args) => {
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await run('git', args, { cwd: repo });
|
|
38
|
+
return stdout.trim();
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
if (await git(['rev-parse', '--git-dir']) === null) {
|
|
44
|
+
return { usable: false, why: 'not a git repository, so fence age cannot be measured' };
|
|
45
|
+
}
|
|
46
|
+
if (await git(['rev-parse', '--is-shallow-repository']) === 'true') {
|
|
47
|
+
return {
|
|
48
|
+
usable: false,
|
|
49
|
+
shallow: true,
|
|
50
|
+
why: 'shallow clone: git dates every line to the day it was fetched, so fence age cannot be measured (clone with full history, or actions/checkout with fetch-depth: 0)',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const count = await git(['rev-list', '--count', 'HEAD']);
|
|
54
|
+
if (count === null) return { usable: false, why: 'no commits yet, so fence age cannot be measured' };
|
|
55
|
+
return { usable: true, commits: Number(count) };
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
export async function blameAll(repo, fences, concurrency = 8) {
|
|
26
59
|
let i = 0;
|
|
27
60
|
const workers = Array.from({ length: concurrency }, async () => {
|
package/src/detect.mjs
CHANGED
|
@@ -2,18 +2,44 @@
|
|
|
2
2
|
// Chesterton's Fence at industrial scale. The fence stands long after the
|
|
3
3
|
// reason for building it is gone, and nobody dares take it down.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Two classes of word, because they carry different weight. A "workaround" is
|
|
6
|
+
// a fence by definition. The word "until" is ordinary English that happens to
|
|
7
|
+
// appear in fences, and treating it as proof produced two hundred findings on
|
|
8
|
+
// webpack that were plain explanatory comments. A weak word only counts when
|
|
9
|
+
// something else in the comment names the condition: a tracker link, a
|
|
10
|
+
// deadline, a version.
|
|
11
|
+
const STRONG_WORDS = [
|
|
6
12
|
'workaround', 'work around', 'work-around',
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'remove when
|
|
10
|
-
'
|
|
11
|
-
'can be removed', 'no longer needed', 'once
|
|
12
|
-
'
|
|
13
|
-
'broken in', '
|
|
14
|
-
'polyfill', 'shim ', 'pinned because', 'pin because', 'do not upgrade',
|
|
13
|
+
'kludge', 'monkey ?-?patch(?:ed|ing)?',
|
|
14
|
+
'hack(?:s|ed|ing)?(?: around)?',
|
|
15
|
+
'remove (?:when|once|after)', 'revert (?:when|once)',
|
|
16
|
+
'drop once', 'delete (?:when|once)',
|
|
17
|
+
'can be removed', 'no longer needed', '(?:once|when) fixed',
|
|
18
|
+
'due to (?:a )?bug', 'because of (?:a )?bug', 'upstream bug', 'known bug',
|
|
19
|
+
'broken in', 'do not upgrade', 'pinn?ed because', 'pin because',
|
|
15
20
|
];
|
|
16
21
|
|
|
22
|
+
// Phrases that read as a fence in a note and as ordinary prose everywhere
|
|
23
|
+
// else. "TODO remove this class" is a fence; "remove this chunk from its
|
|
24
|
+
// parents" is a sentence about what the code does. They count only next to a
|
|
25
|
+
// marker or a named condition.
|
|
26
|
+
const MEDIUM_WORDS = [
|
|
27
|
+
'remove (?:this|it)', 'delete (?:this|it)', 'drop when',
|
|
28
|
+
'blocked by', 'waiting on',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const MARKER_RE = /\b(?:TODO|FIXME|HACK|XXX)\b/i;
|
|
32
|
+
|
|
33
|
+
const WEAK_WORDS = [
|
|
34
|
+
'until', 'temporar(?:y|ily)', 'for now', 'regression',
|
|
35
|
+
'polyfill', 'shim', 'fallback', 'legacy',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const rx = (words) => new RegExp(`\\b(?:${words.join('|')})\\b`, 'gi');
|
|
39
|
+
const STRONG_RE = rx(STRONG_WORDS);
|
|
40
|
+
const MEDIUM_RE = rx(MEDIUM_WORDS);
|
|
41
|
+
const WEAK_RE = rx(WEAK_WORDS);
|
|
42
|
+
|
|
17
43
|
// External trackers: the condition lives outside this repository, which is
|
|
18
44
|
// exactly why nobody notices when it stops being true.
|
|
19
45
|
const REF_PATTERNS = [
|
|
@@ -30,25 +56,82 @@ const REF_PATTERNS = [
|
|
|
30
56
|
const DATE_RE = /\b(?:until|after|before|remove|revisit|expires?|expire[sd]?|do)\b[^\n]{0,24}?(20\d{2})-(\d{2})(?:-(\d{2}))?/i;
|
|
31
57
|
const VERSION_RE = /\b(?:until|once|when|fixed in|released in|requires?|needs?)\b[^\n]{0,40}?\bv?(\d+\.\d+(?:\.\d+)?)/i;
|
|
32
58
|
|
|
33
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Which comment markers a file can have. Getting this wrong in either
|
|
61
|
+
* direction is expensive: "//" inside a Python floor division is not a
|
|
62
|
+
* comment, and "#fff" in a stylesheet is not one either. Both produced
|
|
63
|
+
* findings in the first version.
|
|
64
|
+
*/
|
|
65
|
+
const C_LIKE = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts',
|
|
66
|
+
'.java', '.kt', '.swift', '.go', '.rs', '.c', '.h', '.cc', '.cpp', '.hpp',
|
|
67
|
+
'.cs', '.m', '.mm', '.php', '.scss', '.less', '.vue', '.svelte', '.gradle']);
|
|
68
|
+
const HASH_LIKE = new Set(['.py', '.rb', '.sh', '.bash', '.zsh', '.yml', '.yaml',
|
|
69
|
+
'.toml', '.tf', '.dockerfile']);
|
|
34
70
|
|
|
35
|
-
function
|
|
36
|
-
|
|
71
|
+
export function styleFor(file) {
|
|
72
|
+
const lower = file.toLowerCase();
|
|
73
|
+
const dot = lower.lastIndexOf('.');
|
|
74
|
+
const ext = dot === -1 ? '' : lower.slice(dot);
|
|
75
|
+
if (lower.endsWith('dockerfile')) return { hash: true };
|
|
76
|
+
if (ext === '.sql') return { dashes: true, block: true };
|
|
77
|
+
if (ext === '.css') return { block: true };
|
|
78
|
+
if (HASH_LIKE.has(ext)) return { hash: true, docstring: ext === '.py' };
|
|
79
|
+
if (C_LIKE.has(ext)) return { slash: true, block: true };
|
|
80
|
+
return { slash: true, hash: true, block: true };
|
|
37
81
|
}
|
|
38
82
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
83
|
+
const TRIPLE = ['"""', "'''"];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The comment on one line, or null. Quotes are tracked so that a URL inside a
|
|
87
|
+
* string is not read as a comment, and so that a comment sitting after real
|
|
88
|
+
* code is still found: the first version skipped any line containing a quote,
|
|
89
|
+
* which missed every `doSomething("x"); // workaround for ...`.
|
|
90
|
+
*/
|
|
91
|
+
export function commentPart(line, style = { slash: true, hash: true, block: true }) {
|
|
92
|
+
const trimmed = line.trimStart();
|
|
93
|
+
if (style.docstring) {
|
|
94
|
+
for (const q of TRIPLE) {
|
|
95
|
+
if (trimmed.startsWith(q)) return trimmed.slice(3).replace(/("""|''')\s*$/, '').trim();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (style.block && trimmed.startsWith('*') && !trimmed.startsWith('*/')) {
|
|
99
|
+
return trimmed.replace(/^\*+\s?/, '').replace(/\*\/\s*$/, '').trim();
|
|
100
|
+
}
|
|
101
|
+
let quote = null;
|
|
102
|
+
for (let i = 0; i < line.length; i++) {
|
|
103
|
+
const c = line[i];
|
|
104
|
+
if (quote) {
|
|
105
|
+
if (c === '\\') i++;
|
|
106
|
+
else if (c === quote) quote = null;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (c === '"' || c === "'" || c === '`') { quote = c; continue; }
|
|
110
|
+
if (style.slash && c === '/' && line[i + 1] === '/') return line.slice(i + 2).trim();
|
|
111
|
+
if (style.block && c === '/' && line[i + 1] === '*') {
|
|
112
|
+
return line.slice(i + 2).replace(/\*\/.*$/, '').trim();
|
|
113
|
+
}
|
|
114
|
+
if (style.hash && c === '#') return line.slice(i + 1).trim();
|
|
115
|
+
if (style.dashes && c === '-' && line[i + 1] === '-') return line.slice(i + 2).trim();
|
|
116
|
+
if (c === '<' && line.startsWith('<!--', i)) return line.slice(i + 4).replace(/-->.*$/, '').trim();
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Adjacent comment lines, grouped, with the code stripped off. Deliberately
|
|
123
|
+
* language agnostic: this has to work on files nobody here has seen.
|
|
124
|
+
*/
|
|
125
|
+
export function commentBlocks(text, style) {
|
|
126
|
+
const lines = text.split(/\r?\n/);
|
|
43
127
|
const blocks = [];
|
|
44
128
|
let cur = null;
|
|
45
129
|
for (let i = 0; i < lines.length; i++) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!cur) cur = { start: i + 1, end: i + 1, lines: [] };
|
|
130
|
+
const part = commentPart(lines[i], style);
|
|
131
|
+
if (part) {
|
|
132
|
+
if (!cur) cur = { start: i + 1, end: i + 1, lines: [], raw: lines[i] };
|
|
50
133
|
cur.end = i + 1;
|
|
51
|
-
cur.lines.push(
|
|
134
|
+
cur.lines.push(part);
|
|
52
135
|
} else if (cur) {
|
|
53
136
|
blocks.push(cur);
|
|
54
137
|
cur = null;
|
|
@@ -58,9 +141,12 @@ export function commentBlocks(text) {
|
|
|
58
141
|
return blocks;
|
|
59
142
|
}
|
|
60
143
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
144
|
+
function wordsIn(text, re) {
|
|
145
|
+
re.lastIndex = 0;
|
|
146
|
+
const found = new Set();
|
|
147
|
+
let m;
|
|
148
|
+
while ((m = re.exec(text)) !== null) found.add(m[0].toLowerCase());
|
|
149
|
+
return [...found];
|
|
64
150
|
}
|
|
65
151
|
|
|
66
152
|
function refsIn(text) {
|
|
@@ -83,19 +169,18 @@ function refsIn(text) {
|
|
|
83
169
|
*/
|
|
84
170
|
export function detectFences(file, text) {
|
|
85
171
|
const out = [];
|
|
86
|
-
|
|
172
|
+
const style = styleFor(file);
|
|
173
|
+
for (const block of commentBlocks(text, style)) {
|
|
87
174
|
const body = block.lines.join(' ').replace(/\s+/g, ' ').slice(0, 600);
|
|
88
175
|
if (body.length < 12) continue;
|
|
89
176
|
|
|
90
|
-
const
|
|
177
|
+
const strong = wordsIn(body, STRONG_RE);
|
|
178
|
+
const medium = wordsIn(body, MEDIUM_RE);
|
|
179
|
+
const weak = wordsIn(body, WEAK_RE);
|
|
91
180
|
const refs = refsIn(body);
|
|
92
181
|
const dateM = body.match(DATE_RE);
|
|
93
182
|
const verM = body.match(VERSION_RE);
|
|
94
183
|
|
|
95
|
-
// A link to an external tracker is itself the premise: someone wrote this
|
|
96
|
-
// code because of that bug. Words like "workaround" only confirm it.
|
|
97
|
-
if (refs.length === 0 && words.length === 0) continue;
|
|
98
|
-
|
|
99
184
|
let premise;
|
|
100
185
|
if (refs.length > 0) premise = { type: 'tracker', refs };
|
|
101
186
|
else if (dateM) {
|
|
@@ -104,12 +189,25 @@ export function detectFences(file, text) {
|
|
|
104
189
|
} else if (verM) premise = { type: 'version', version: verM[1] };
|
|
105
190
|
else premise = { type: 'none' };
|
|
106
191
|
|
|
192
|
+
// What it takes to call a comment a fence. A link to an external tracker
|
|
193
|
+
// is the premise by itself: someone wrote this code because of that bug.
|
|
194
|
+
// Failing that, a strong word. A weak word ("until", "polyfill") only
|
|
195
|
+
// counts when the comment also names a deadline or a version, because on
|
|
196
|
+
// its own it is ordinary English and produces mostly noise.
|
|
197
|
+
const named = premise.type === 'date' || premise.type === 'version';
|
|
198
|
+
const marker = MARKER_RE.test(body);
|
|
199
|
+
const qualifies = refs.length > 0
|
|
200
|
+
|| strong.length > 0
|
|
201
|
+
|| (medium.length > 0 && (marker || named))
|
|
202
|
+
|| (weak.length > 0 && named);
|
|
203
|
+
if (!qualifies) continue;
|
|
204
|
+
|
|
107
205
|
// The class says what breaks when the premise dies, and those are two
|
|
108
206
|
// different repairs, which is why the distinction earns its keep.
|
|
109
|
-
const isDoc = /^\s*(?:\/\*\*|\*)/.test(block.
|
|
207
|
+
const isDoc = /^\s*(?:\/\*\*|\*)/.test(block.raw ?? '')
|
|
110
208
|
|| /@remarks|@param|@returns|\{@link/.test(body);
|
|
111
209
|
let kind;
|
|
112
|
-
if (refs.length > 0 && (
|
|
210
|
+
if (refs.length > 0 && (strong.length > 0 || medium.length > 0 || !isDoc)) kind = 'code';
|
|
113
211
|
else if (refs.length > 0) kind = 'docs';
|
|
114
212
|
else if (premise.type === 'date') kind = 'deadline';
|
|
115
213
|
else kind = 'unmarked';
|
|
@@ -119,7 +217,7 @@ export function detectFences(file, text) {
|
|
|
119
217
|
line: block.start,
|
|
120
218
|
endLine: block.end,
|
|
121
219
|
text: body.replace(/^\W+/, '').slice(0, 240),
|
|
122
|
-
words:
|
|
220
|
+
words: [...strong, ...medium, ...weak].slice(0, 3),
|
|
123
221
|
premise,
|
|
124
222
|
kind,
|
|
125
223
|
confidence: refs.length > 0 ? 'high' : premise.type === 'date' ? 'medium' : 'low',
|
package/src/report.mjs
CHANGED
|
@@ -29,11 +29,29 @@ export function summarize(fences, states = new Map()) {
|
|
|
29
29
|
const verdicts = {};
|
|
30
30
|
for (const f of fences) {
|
|
31
31
|
if (!f.verdict) continue;
|
|
32
|
+
if (states.size === 0 && f.premise.type !== 'date') continue;
|
|
32
33
|
verdicts[f.verdict.level] = (verdicts[f.verdict.level] ?? 0) + 1;
|
|
33
34
|
}
|
|
34
35
|
return { total: fences.length, byKind, trackers: trackers.size, overdue, old, oldest, verdicts, checked: states.size };
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Fences per file, biggest first. The ranked list only shows the ones with a
|
|
40
|
+
* tracker or a passed deadline, so without this a reader cannot tell where the
|
|
41
|
+
* rest of the count comes from, and cannot check whether the tool skipped or
|
|
42
|
+
* counted the right files.
|
|
43
|
+
*/
|
|
44
|
+
export function byFile(fences, limit = 8) {
|
|
45
|
+
const counts = new Map();
|
|
46
|
+
for (const f of fences) {
|
|
47
|
+
const row = counts.get(f.file) ?? { file: f.file, total: 0, kinds: {} };
|
|
48
|
+
row.total++;
|
|
49
|
+
row.kinds[f.kind] = (row.kinds[f.kind] ?? 0) + 1;
|
|
50
|
+
counts.set(f.file, row);
|
|
51
|
+
}
|
|
52
|
+
return [...counts.values()].sort((a, b) => b.total - a.total).slice(0, limit);
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
export function ranked(fences) {
|
|
38
56
|
return fences
|
|
39
57
|
.filter((f) => f.premise.type === 'tracker' || (f.premise.type === 'date' && f.premise.overdue))
|
|
@@ -46,6 +64,22 @@ function premiseOf(f) {
|
|
|
46
64
|
: `deadline ${f.premise.date} (passed)`;
|
|
47
65
|
}
|
|
48
66
|
|
|
67
|
+
/** Long explanations have to stay readable in a terminal. */
|
|
68
|
+
function wrap(text, width) {
|
|
69
|
+
const out = [];
|
|
70
|
+
let line = '';
|
|
71
|
+
for (const word of String(text).split(/\s+/)) {
|
|
72
|
+
if (line && line.length + word.length + 1 > width) {
|
|
73
|
+
out.push(line);
|
|
74
|
+
line = word;
|
|
75
|
+
} else {
|
|
76
|
+
line = line ? `${line} ${word}` : word;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (line) out.push(line);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
49
83
|
export function renderText(fences, summary, repoName, checked = false) {
|
|
50
84
|
const L = [];
|
|
51
85
|
const n = (x) => String(x).padStart(4);
|
|
@@ -59,8 +93,29 @@ export function renderText(fences, summary, repoName, checked = false) {
|
|
|
59
93
|
L.push(` ${n(summary.byKind.unmarked)} fences with no sign`);
|
|
60
94
|
L.push(' ' + '-'.repeat(70));
|
|
61
95
|
L.push(` ${n(summary.trackers)} distinct external issues to check`);
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
if (summary.history && summary.history.usable === false) {
|
|
97
|
+
L.push(` -- age not measured`);
|
|
98
|
+
for (const line of wrap(summary.history.why, 62)) L.push(` ${line}`);
|
|
99
|
+
} else {
|
|
100
|
+
L.push(` ${n(summary.old)} fences untouched for 3+ years`);
|
|
101
|
+
if (summary.oldest !== null && summary.oldest >= 1) {
|
|
102
|
+
L.push(` ${n(summary.oldest.toFixed(1))} years old is the oldest one`);
|
|
103
|
+
} else if (summary.oldest !== null) {
|
|
104
|
+
L.push(` every fence here was touched within the last year`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (summary.checkedAt) {
|
|
108
|
+
const { oldest, newest } = summary.checkedAt;
|
|
109
|
+
L.push(oldest.slice(0, 10) === newest.slice(0, 10)
|
|
110
|
+
? ` issue states read ${newest.slice(0, 10)}`
|
|
111
|
+
: ` issue states read between ${oldest.slice(0, 10)} and ${newest.slice(0, 10)}`);
|
|
112
|
+
}
|
|
113
|
+
if (summary.skipped) {
|
|
114
|
+
L.push(` ${n(summary.skipped)} bundled or minified files left out: their fences belong to`);
|
|
115
|
+
L.push(' the libraries they were built from (--include-generated to scan them)');
|
|
116
|
+
for (const f of (summary.skippedFiles ?? []).slice(0, 6)) L.push(` ${f.path} [${f.why}]`);
|
|
117
|
+
if ((summary.skippedFiles ?? []).length > 6) L.push(` and ${summary.skippedFiles.length - 6} more`);
|
|
118
|
+
}
|
|
64
119
|
L.push('');
|
|
65
120
|
|
|
66
121
|
if (checked) {
|
|
@@ -72,18 +127,42 @@ export function renderText(fences, summary, repoName, checked = false) {
|
|
|
72
127
|
L.push('');
|
|
73
128
|
}
|
|
74
129
|
|
|
75
|
-
|
|
130
|
+
if (summary.total === 0) {
|
|
131
|
+
L.push(' Nothing found. No comment in this codebase records an external');
|
|
132
|
+
L.push(' reason for the code around it: no tracker link, no deadline, no');
|
|
133
|
+
L.push(' note about a workaround. That is either a clean codebase or an');
|
|
134
|
+
L.push(' undocumented one, and this tool cannot tell those apart.');
|
|
135
|
+
L.push('');
|
|
136
|
+
return L.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
L.push(' WHERE THEY ARE');
|
|
140
|
+
L.push(' ' + '-'.repeat(70));
|
|
141
|
+
for (const row of byFile(fences)) {
|
|
142
|
+
const kinds = Object.entries(row.kinds).map(([k, n]) => `${n} ${k}`).join(', ');
|
|
143
|
+
L.push(` ${n(row.total)} ${row.file} (${kinds})`);
|
|
144
|
+
}
|
|
145
|
+
L.push('');
|
|
146
|
+
|
|
147
|
+
const list = ranked(fences);
|
|
148
|
+
L.push(` CHECK THESE FIRST (longest untouched)`);
|
|
76
149
|
L.push(' ' + '='.repeat(70));
|
|
77
|
-
|
|
150
|
+
if (list.length > 15) {
|
|
151
|
+
L.push(` showing 15 of ${list.length} with a recorded reason; --json or --report has them all`);
|
|
152
|
+
L.push('');
|
|
153
|
+
}
|
|
154
|
+
for (const f of list.slice(0, 15)) {
|
|
78
155
|
const y = yearsSince(f.lastTouched);
|
|
79
156
|
const age = y === null ? ' ? ' : `${y.toFixed(1)} yr`;
|
|
80
157
|
L.push(` ${age.padStart(7)} ${f.file}:${f.line} [${f.kind}]`);
|
|
81
158
|
L.push(` reason: ${premiseOf(f)}`);
|
|
82
159
|
L.push(` comment: ${f.text.slice(0, 92)}`);
|
|
83
|
-
if (f
|
|
160
|
+
if (showVerdict(f, checked)) L.push(` VERDICT: ${f.verdict.level} (${f.verdict.why})`);
|
|
84
161
|
L.push('');
|
|
85
162
|
}
|
|
86
|
-
L.push(
|
|
163
|
+
L.push(checked
|
|
164
|
+
? ' Every closed issue above is code you can delete.'
|
|
165
|
+
: ' Run again with --check to ask the trackers whether these reasons still hold.');
|
|
87
166
|
L.push('');
|
|
88
167
|
return L.join('\n');
|
|
89
168
|
}
|
|
@@ -94,6 +173,15 @@ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '&l
|
|
|
94
173
|
* A standalone report meant to be forwarded to whoever pays for the codebase.
|
|
95
174
|
* Developers find the fences; someone else decides what they cost.
|
|
96
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* A verdict is worth showing when it means something. Without --check the only
|
|
178
|
+
* ones the tool can stand behind are the deadlines, which need no network, and
|
|
179
|
+
* repeating "the tracker was not consulted" on forty rows says nothing.
|
|
180
|
+
*/
|
|
181
|
+
function showVerdict(fence, checked) {
|
|
182
|
+
return Boolean(fence.verdict) && (checked || fence.premise.type === 'date');
|
|
183
|
+
}
|
|
184
|
+
|
|
97
185
|
export function renderHtml(fences, summary, repoName, checked = false) {
|
|
98
186
|
const rows = ranked(fences).slice(0, 40).map((f) => {
|
|
99
187
|
const y = yearsSince(f.lastTouched);
|
|
@@ -102,7 +190,7 @@ export function renderHtml(fences, summary, repoName, checked = false) {
|
|
|
102
190
|
<td class="num">${y === null ? '-' : y.toFixed(1) + ' yr'}</td>
|
|
103
191
|
<td><code>${esc(f.file)}:${f.line}</code><p>${esc(f.text.slice(0, 130))}</p></td>
|
|
104
192
|
<td class="num">${esc(premiseOf(f))}</td>
|
|
105
|
-
<td>${
|
|
193
|
+
<td>${showVerdict(f, checked) ? `<span class="v v-${esc(v.level.replace(/\s+/g, '-'))}">${esc(v.level)}</span><p>${esc(v.why)}</p>` : '<span class="v">not checked</span>'}</td>
|
|
106
194
|
</tr>`;
|
|
107
195
|
}).join('\n');
|
|
108
196
|
|
|
@@ -127,11 +215,13 @@ h1{font:300 clamp(2rem,5vw,3.2rem)/1.05 ui-serif,Georgia,serif;letter-spacing:-.
|
|
|
127
215
|
.stat span{font-size:.78rem;color:var(--dim);line-height:1.35}
|
|
128
216
|
h2{font:300 1.6rem/1.1 ui-serif,Georgia,serif;margin:2.5rem 0 1rem}
|
|
129
217
|
.scroll{overflow-x:auto;border:1px solid var(--line);background:var(--surface)}
|
|
130
|
-
table{border-collapse:collapse;width:100%;min-width:
|
|
218
|
+
table{border-collapse:collapse;width:100%;min-width:40rem;font-size:.88rem;table-layout:fixed}
|
|
131
219
|
th,td{text-align:left;padding:.8rem 1rem;border-bottom:1px solid var(--line);vertical-align:top}
|
|
132
220
|
thead th{font-family:ui-monospace,monospace;font-size:.64rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);font-weight:400;background:var(--surface2);white-space:nowrap}
|
|
133
221
|
td p{margin:.35rem 0 0;color:var(--dim);font-size:.82rem}
|
|
134
|
-
td.num{
|
|
222
|
+
td.num{color:var(--dim);font-variant-numeric:tabular-nums;font-size:.8rem;overflow-wrap:anywhere}
|
|
223
|
+
col.age{width:6rem}col.reason{width:14rem}col.state{width:12rem}
|
|
224
|
+
code{overflow-wrap:anywhere}
|
|
135
225
|
code{font-size:.82rem;color:var(--ink)}
|
|
136
226
|
.v{font-family:ui-monospace,monospace;font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
|
137
227
|
.v-remove{color:var(--gold)}
|
|
@@ -150,14 +240,30 @@ footer p{max-width:62ch}
|
|
|
150
240
|
<div class="stat"><b>${summary.total}</b><span>fences standing</span></div>
|
|
151
241
|
<div class="stat"><b>${summary.byKind.code}</b><span>written because of someone else's bug</span></div>
|
|
152
242
|
<div class="stat"><b>${summary.trackers}</b><span>external issues to check</span></div>
|
|
153
|
-
<div class="stat"><b>${summary.old}</b><span>untouched for 3+ years</span></div>
|
|
154
|
-
<div class="stat"><b>${summary.oldest === null ? '-' :
|
|
243
|
+
<div class="stat"><b>${summary.history && summary.history.usable === false ? '-' : summary.old}</b><span>untouched for 3+ years</span></div>
|
|
244
|
+
<div class="stat"><b>${summary.history && summary.history.usable === false ? '-' : (summary.oldest === null ? '-' : summary.oldest.toFixed(1))}</b><span>years, the oldest one</span></div>
|
|
155
245
|
</div>
|
|
156
|
-
|
|
246
|
+
${summary.history && summary.history.usable === false ? `<p class="sub">Age was not measured: ${esc(summary.history.why)}.</p>` : ''}
|
|
247
|
+
${summary.checkedAt ? `<p class="sub">Issue states read ${esc(summary.checkedAt.newest.slice(0, 10))}.</p>` : ''}
|
|
248
|
+
${!checked && summary.trackers > 0 ? `<p class="sub">The trackers were not consulted in this run, so the state column is empty. <code>--check</code> asks them whether these issues are still open.</p>` : ''}
|
|
249
|
+
${summary.total === 0 ? `<h2>Nothing found</h2>
|
|
250
|
+
<p class="sub">No comment in this codebase records an external reason for the code around it: no tracker link, no deadline, no note about a workaround. That is either a clean codebase or an undocumented one, and this tool cannot tell those apart.</p>` : `<h2>Check these first</h2>
|
|
157
251
|
<div class="scroll"><table>
|
|
252
|
+
<colgroup><col class="age"><col><col class="reason"><col class="state"></colgroup>
|
|
158
253
|
<thead><tr><th>Untouched</th><th>Where</th><th>Reason given</th><th>${checked ? 'Verdict' : 'State'}</th></tr></thead>
|
|
159
254
|
<tbody>${rows}</tbody>
|
|
160
|
-
</table></div
|
|
255
|
+
</table></div>`}
|
|
256
|
+
${fences.length ? `<h2>Where they are</h2>
|
|
257
|
+
<div class="scroll"><table>
|
|
258
|
+
<thead><tr><th>File</th><th>Fences</th><th>Kinds</th></tr></thead>
|
|
259
|
+
<tbody>${byFile(fences, 15).map((r) => `<tr><td><code>${esc(r.file)}</code></td><td class="num">${r.total}</td><td class="num">${esc(Object.entries(r.kinds).map(([k, n]) => `${n} ${k}`).join(', '))}</td></tr>`).join('\n')}</tbody>
|
|
260
|
+
</table></div>` : ''}
|
|
261
|
+
${summary.skipped ? `<h2>Left out</h2>
|
|
262
|
+
<p class="sub">${summary.skipped} file${summary.skipped === 1 ? ' was' : 's were'} skipped as a build product. The fences inside a bundle belong to the libraries it was built from, not to this team.</p>
|
|
263
|
+
<div class="scroll"><table>
|
|
264
|
+
<thead><tr><th>File</th><th>Why</th></tr></thead>
|
|
265
|
+
<tbody>${(summary.skippedFiles ?? []).map((f) => `<tr><td><code>${esc(f.path)}</code></td><td class="num">${esc(f.why)}</td></tr>`).join('\n')}</tbody>
|
|
266
|
+
</table></div>` : ''}
|
|
161
267
|
</main>
|
|
162
268
|
<footer><div class="wrap">
|
|
163
269
|
<p><strong>This is one repository and one kind of risk.</strong> The same blindness applies to everything else nobody re-checks: what was already paid for, which parts only one person understands, whether this codebase could be handed to another team at all. That is what Ancient Code measures.</p>
|
|
@@ -171,16 +277,23 @@ footer p{max-width:62ch}
|
|
|
171
277
|
* Ancient Fences does not edit code: knowing a fence is dead is the scarce
|
|
172
278
|
* part, and every editor now ships something that can do the deleting.
|
|
173
279
|
*/
|
|
174
|
-
export function renderTasks(fences, repoName) {
|
|
280
|
+
export function renderTasks(fences, repoName, checked = false) {
|
|
175
281
|
const dead = fences.filter((f) => f.verdict && (f.verdict.level === 'remove' || f.verdict.level === 'upgrade first'));
|
|
176
282
|
const L = [];
|
|
177
283
|
L.push(`# Dead fences in ${repoName}`);
|
|
178
284
|
L.push('');
|
|
179
285
|
L.push(`Each item below is code kept alive by a condition that no longer holds.`);
|
|
180
|
-
|
|
286
|
+
if (checked) {
|
|
287
|
+
L.push(`Verified against the referenced tracker${dead.some((f) => /shipped in/.test(f.verdict.why)) ? ' and the lockfile' : ''}.`);
|
|
288
|
+
} else {
|
|
289
|
+
L.push(`The trackers were not consulted in this run, so only deadlines written into`);
|
|
290
|
+
L.push(`the comments were judged. Run with --check to include issue state.`);
|
|
291
|
+
}
|
|
181
292
|
L.push('');
|
|
182
293
|
if (dead.length === 0) {
|
|
183
|
-
L.push(
|
|
294
|
+
L.push(checked
|
|
295
|
+
? 'Nothing to remove: every recorded reason still holds.'
|
|
296
|
+
: 'Nothing to remove from deadlines alone. Run again with --check to ask the trackers.');
|
|
184
297
|
return L.join('\n');
|
|
185
298
|
}
|
|
186
299
|
dead.forEach((f, i) => {
|
package/src/tracker.mjs
CHANGED
|
@@ -8,14 +8,20 @@ import { fixVersionFrom, packageForRef, shippedStatus } from './versions.mjs';
|
|
|
8
8
|
* does the reason still exist?
|
|
9
9
|
*/
|
|
10
10
|
export async function checkGithubRefs(ids, opts = {}) {
|
|
11
|
-
const apiBase = opts.apiBase ?? 'https://api.github.com';
|
|
11
|
+
const apiBase = (opts.apiBase ?? 'https://api.github.com').replace(/\/+$/, '');
|
|
12
12
|
const token = opts.token ?? process.env.GITHUB_TOKEN ?? null;
|
|
13
|
-
const
|
|
13
|
+
const maxAgeDays = Number.isFinite(opts.maxAgeDays) ? opts.maxAgeDays : 7;
|
|
14
|
+
const cache = opts.noCache ? {} : await loadCache(opts.cachePath);
|
|
14
15
|
const result = new Map();
|
|
15
16
|
|
|
16
17
|
for (const id of ids) {
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// An issue state is not a fact, it is a snapshot: closed issues get
|
|
19
|
+
// reopened. A cache with no age would let this tool say "the reason
|
|
20
|
+
// disappeared" forever on the strength of one lookup, offline, with
|
|
21
|
+
// nothing on screen to say how old the answer is.
|
|
22
|
+
const cached = cache[id];
|
|
23
|
+
if (cached && freshEnough(cached, maxAgeDays)) {
|
|
24
|
+
result.set(id, cached);
|
|
19
25
|
continue;
|
|
20
26
|
}
|
|
21
27
|
const m = id.match(/^github:([\w.-]+)\/([\w.-]+)#(\d+)$/);
|
|
@@ -28,19 +34,19 @@ export async function checkGithubRefs(ids, opts = {}) {
|
|
|
28
34
|
try {
|
|
29
35
|
res = await fetch(url, { headers });
|
|
30
36
|
} catch (err) {
|
|
31
|
-
result.set(id, { state: 'unknown', reason: `network: ${err.message}` });
|
|
37
|
+
result.set(id, staleOr(cached, { state: 'unknown', reason: `network: ${err.message}` }));
|
|
32
38
|
continue;
|
|
33
39
|
}
|
|
34
40
|
if (res.status === 403 || res.status === 429) {
|
|
35
|
-
result.set(id, { state: 'unknown', reason: 'rate limited or no access' });
|
|
41
|
+
result.set(id, staleOr(cached, { state: 'unknown', reason: 'rate limited or no access' }));
|
|
36
42
|
continue;
|
|
37
43
|
}
|
|
38
44
|
if (res.status === 404) {
|
|
39
|
-
result.set(id, { state: 'unknown', reason: 'issue missing or private' });
|
|
45
|
+
result.set(id, staleOr(cached, { state: 'unknown', reason: 'issue missing or private' }));
|
|
40
46
|
continue;
|
|
41
47
|
}
|
|
42
48
|
if (!res.ok) {
|
|
43
|
-
result.set(id, { state: 'unknown', reason: `HTTP ${res.status}` });
|
|
49
|
+
result.set(id, staleOr(cached, { state: 'unknown', reason: `HTTP ${res.status}` }));
|
|
44
50
|
continue;
|
|
45
51
|
}
|
|
46
52
|
const body = await res.json();
|
|
@@ -53,6 +59,7 @@ export async function checkGithubRefs(ids, opts = {}) {
|
|
|
53
59
|
// Kept rather than the whole payload: this is the only part that tells
|
|
54
60
|
// us whether the fix reached a release, and the cache stays small.
|
|
55
61
|
fix: fixVersionFrom(body),
|
|
62
|
+
checkedAt: new Date().toISOString(),
|
|
56
63
|
};
|
|
57
64
|
result.set(id, entry);
|
|
58
65
|
cache[id] = entry;
|
|
@@ -62,6 +69,21 @@ export async function checkGithubRefs(ids, opts = {}) {
|
|
|
62
69
|
return result;
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
function freshEnough(entry, maxAgeDays) {
|
|
73
|
+
if (!entry?.checkedAt) return false;
|
|
74
|
+
const age = Date.now() - new Date(entry.checkedAt).getTime();
|
|
75
|
+
return Number.isFinite(age) && age >= 0 && age < maxAgeDays * 24 * 3600 * 1000;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The tracker could not be reached now. An old answer is still worth more than
|
|
80
|
+
* nothing, as long as every report that uses it says how old it is.
|
|
81
|
+
*/
|
|
82
|
+
function staleOr(cached, failure) {
|
|
83
|
+
if (!cached || !cached.checkedAt) return failure;
|
|
84
|
+
return { ...cached, stale: true, reason: `${failure.reason}; using the state read on ${cached.checkedAt.slice(0, 10)}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
/**
|
|
66
88
|
* What to do about it. Without a verdict the report is just a list of links.
|
|
67
89
|
* `installed` is what the lockfile says you actually run, which is what turns
|
|
@@ -78,21 +100,32 @@ export function verdict(fence, states, installed = new Map()) {
|
|
|
78
100
|
}
|
|
79
101
|
// An unknown state must never come out as "still valid". Not knowing is not
|
|
80
102
|
// a green light, and a tool that reassures without grounds is worse than none.
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
const seen = fence.premise.refs.map((r) => states.get(r.id)).filter(Boolean);
|
|
104
|
+
const known = seen.filter((s) => s.state !== 'unknown');
|
|
105
|
+
if (known.length === 0) {
|
|
106
|
+
// Not asking and asking without an answer are different states, and a
|
|
107
|
+
// report that spells both "unchecked" invites the reader to assume the
|
|
108
|
+
// worse one happened.
|
|
109
|
+
return seen.length === 0
|
|
110
|
+
? { level: 'unchecked', why: 'the tracker was not consulted in this run (--check does that)' }
|
|
111
|
+
: { level: 'unchecked', why: seen[0].reason ?? 'could not determine issue state' };
|
|
112
|
+
}
|
|
113
|
+
// An answer read from an old cache still says when it was read, in the
|
|
114
|
+
// verdict itself, so nobody acts on a year-old snapshot believing it is now.
|
|
115
|
+
const asOf = known.some((s) => s.stale)
|
|
116
|
+
? ` (state as of ${known.map((s) => s.checkedAt).filter(Boolean).sort()[0]?.slice(0, 10)})`
|
|
117
|
+
: '';
|
|
85
118
|
if (known.every((s) => s.state === 'closed')) {
|
|
86
119
|
const shipped = shippedFor(fence, states, installed);
|
|
87
|
-
if (shipped?.state === 'shipped') return { level: 'remove', why: shipped.text };
|
|
88
|
-
if (shipped?.state === 'not upgraded') return { level: 'upgrade first', why: shipped.text };
|
|
120
|
+
if (shipped?.state === 'shipped') return { level: 'remove', why: shipped.text + asOf };
|
|
121
|
+
if (shipped?.state === 'not upgraded') return { level: 'upgrade first', why: shipped.text + asOf };
|
|
89
122
|
const when = known.map((s) => s.closedAt).filter(Boolean).sort().pop();
|
|
90
|
-
return { level: 'remove', why: when ? `reason disappeared ${when.slice(0, 10)}` : 'issue closed' };
|
|
123
|
+
return { level: 'remove', why: (when ? `reason disappeared ${when.slice(0, 10)}` : 'issue closed') + asOf };
|
|
91
124
|
}
|
|
92
125
|
if (known.some((s) => s.state === 'closed')) {
|
|
93
|
-
return { level: 'review', why: 'some of the reasons are gone' };
|
|
126
|
+
return { level: 'review', why: 'some of the reasons are gone' + asOf };
|
|
94
127
|
}
|
|
95
|
-
return { level: 'still valid', why: 'issue still open' };
|
|
128
|
+
return { level: 'still valid', why: 'issue still open' + asOf };
|
|
96
129
|
}
|
|
97
130
|
|
|
98
131
|
/**
|
package/src/walk.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { join, extname, relative } from 'node:path';
|
|
|
4
4
|
const SKIP_DIRS = new Set([
|
|
5
5
|
'.git', 'node_modules', 'dist', 'build', 'out', 'vendor', 'target',
|
|
6
6
|
'.next', '.nuxt', 'coverage', '__pycache__', '.venv', 'venv', 'third_party',
|
|
7
|
+
'bower_components', 'jspm_packages', 'site-packages', 'Pods',
|
|
7
8
|
]);
|
|
8
9
|
|
|
9
10
|
const TEXT_EXT = new Set([
|
|
@@ -16,7 +17,35 @@ const TEXT_EXT = new Set([
|
|
|
16
17
|
|
|
17
18
|
const MAX_BYTES = 512 * 1024;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// A bundle dropped into the repository is not code anyone here wrote, and the
|
|
21
|
+
// fences inside it belong to the libraries it was built from. Reporting them
|
|
22
|
+
// buries the ones the team can act on, which is the whole point of the tool.
|
|
23
|
+
const GENERATED_NAME = /(?:[.-]min\.[a-z]+|\.bundle\.[a-z]+|\.pack\.js|-bundle\.js)$/i;
|
|
24
|
+
|
|
25
|
+
// Only ever consulted on large files, so a hand-written module wrapper in a
|
|
26
|
+
// small file is never mistaken for a build product.
|
|
27
|
+
const GENERATED_HEAD = /webpackBootstrap|__webpack_require__|parcelRequire|System\.register\(|function e\(t,\s*n,\s*r\)\s*\{\s*function s\(|["']object["']\s*==\s*typeof\s+exports|typeof\s+exports\s*===?\s*["']object["']/;
|
|
28
|
+
|
|
29
|
+
const BIG_ENOUGH_TO_JUDGE = 32 * 1024;
|
|
30
|
+
const MINIFIED_LINE = 1000;
|
|
31
|
+
|
|
32
|
+
export function looksGenerated(name, text) {
|
|
33
|
+
if (GENERATED_NAME.test(name)) return 'built file';
|
|
34
|
+
if (text.length < BIG_ENOUGH_TO_JUDGE) return null;
|
|
35
|
+
if (GENERATED_HEAD.test(text.slice(0, 4096))) return 'bundle';
|
|
36
|
+
for (const line of text.split('\n')) {
|
|
37
|
+
if (line.length >= MINIFIED_LINE) return 'minified';
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} root
|
|
44
|
+
* @param {{ includeGenerated?: boolean, skipped?: Array }} options
|
|
45
|
+
* skipped is filled in as the walk runs, so the report can say how much of
|
|
46
|
+
* the repository was left out and why.
|
|
47
|
+
*/
|
|
48
|
+
export async function* walkFiles(root, { includeGenerated = false, skipped = [] } = {}) {
|
|
20
49
|
const stack = [root];
|
|
21
50
|
while (stack.length) {
|
|
22
51
|
const dir = stack.pop();
|
|
@@ -48,6 +77,11 @@ export async function* walkFiles(root) {
|
|
|
48
77
|
} catch {
|
|
49
78
|
continue;
|
|
50
79
|
}
|
|
80
|
+
const generated = looksGenerated(e.name, text);
|
|
81
|
+
if (generated && !includeGenerated) {
|
|
82
|
+
skipped.push({ path: relative(root, full), why: generated });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
51
85
|
yield { path: relative(root, full), text };
|
|
52
86
|
}
|
|
53
87
|
}
|