ancient-fences 0.1.1 → 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 +43 -5
- package/bin/ancient-fences.mjs +95 -19
- package/package.json +1 -1
- package/src/age.mjs +33 -0
- package/src/detect.mjs +131 -33
- package/src/report.mjs +125 -16
- package/src/tracker.mjs +50 -17
package/README.md
CHANGED
|
@@ -52,13 +52,18 @@ that no longer exists.
|
|
|
52
52
|
|
|
53
53
|
Two runs, full clones, August 2026:
|
|
54
54
|
|
|
55
|
-
| Repository | Fences | Because of someone else's bug |
|
|
55
|
+
| Repository | Fences | Because of someone else's bug | Untouched 3+ years | Oldest |
|
|
56
56
|
|---|---|---|---|---|
|
|
57
|
-
| puppeteer/puppeteer |
|
|
58
|
-
| webpack/webpack |
|
|
57
|
+
| puppeteer/puppeteer | 77 | 62 | 48 | 9.1 yr |
|
|
58
|
+
| webpack/webpack | 93 | 65 | 23 | 8.8 yr |
|
|
59
59
|
|
|
60
60
|
Both are well-maintained projects by good engineers. That is the point.
|
|
61
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
|
+
|
|
62
67
|
## What it finds
|
|
63
68
|
|
|
64
69
|
| Kind | What it means | The repair when the reason dies |
|
|
@@ -68,6 +73,15 @@ Both are well-maintained projects by good engineers. That is the point.
|
|
|
68
73
|
| `deadline` | a date written into a comment | the date passed, revisit |
|
|
69
74
|
| `unmarked` | clearly a workaround, no recorded reason | write the reason down, or remove it |
|
|
70
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
|
+
|
|
71
85
|
Trackers understood: GitHub issues and pull requests, Chromium (`crbug.com`),
|
|
72
86
|
Mozilla Bugzilla, WebKit.
|
|
73
87
|
|
|
@@ -91,6 +105,11 @@ a workaround for a bug that was fixed years ago, because nobody upgraded.
|
|
|
91
105
|
`package-lock.json`, `yarn.lock` and `pnpm-lock.yaml` are read when present. No
|
|
92
106
|
lockfile means the tool says less, never something false.
|
|
93
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
|
+
|
|
94
113
|
**An unknown issue state never produces `still valid`.** Not knowing is not a
|
|
95
114
|
green light. A tool that reassures you without grounds is worse than no tool.
|
|
96
115
|
(The test enforcing this caught a real bug on the prototype's first run.)
|
|
@@ -106,16 +125,35 @@ green light. A tool that reassures you without grounds is worse than no tool.
|
|
|
106
125
|
--json full machine-readable output
|
|
107
126
|
--no-blame skip fence age (faster, tells you less)
|
|
108
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)
|
|
109
132
|
```
|
|
110
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
|
+
|
|
111
140
|
Bundles committed into a repository (`vendor.js`, a browserify or webpack
|
|
112
141
|
build, anything minified) are skipped, and the summary says how many were left
|
|
113
142
|
out. The fences inside them belong to the libraries they were built from, so
|
|
114
143
|
listing them buries the ones your team can actually act on. `dist`, `build`,
|
|
115
144
|
`node_modules`, `vendor` and `third_party` are skipped for the same reason.
|
|
116
145
|
|
|
117
|
-
Issue states are cached in
|
|
118
|
-
|
|
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))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
An issue state is a snapshot, not a fact. Closed issues get reopened.
|
|
119
157
|
|
|
120
158
|
## Working with an agent
|
|
121
159
|
|
package/bin/ancient-fences.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { resolve, basename, join } from 'node:path';
|
|
2
|
+
import { resolve, basename, dirname, join } from 'node:path';
|
|
3
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]
|
|
@@ -41,19 +46,47 @@ then check whether that bug is still there.
|
|
|
41
46
|
--include-generated scan bundles and minified builds too. They are skipped by
|
|
42
47
|
default: their fences belong to the libraries they were
|
|
43
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)
|
|
44
54
|
`;
|
|
55
|
+
|
|
56
|
+
if (cmd !== 'scan' || has('--help')) {
|
|
45
57
|
console.log(usage);
|
|
46
|
-
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);
|
|
47
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());
|
|
48
76
|
|
|
49
77
|
// Scanning a path that is not there used to print a clean report saying zero
|
|
50
78
|
// fences, which reads exactly like good news. A tool that says "nothing to
|
|
51
79
|
// worry about" when it looked at nothing is worse than no tool.
|
|
80
|
+
let single = null;
|
|
81
|
+
let root = target;
|
|
52
82
|
try {
|
|
53
|
-
const info = await stat(
|
|
54
|
-
if (
|
|
83
|
+
const info = await stat(target);
|
|
84
|
+
if (info.isFile()) {
|
|
85
|
+
single = target;
|
|
86
|
+
root = dirname(target);
|
|
87
|
+
}
|
|
55
88
|
} catch {
|
|
56
|
-
console.error(`ancient-fences: nothing to scan at ${
|
|
89
|
+
console.error(`ancient-fences: nothing to scan at ${target}`);
|
|
57
90
|
if (positional[0] && positional[0].includes('--')) {
|
|
58
91
|
console.error('It looks like an option got stuck to the path. Put a space before it:');
|
|
59
92
|
console.error(' npx ancient-fences . --report');
|
|
@@ -63,14 +96,30 @@ try {
|
|
|
63
96
|
|
|
64
97
|
const skipped = [];
|
|
65
98
|
const fences = [];
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
107
|
}
|
|
69
108
|
|
|
70
|
-
|
|
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);
|
|
71
119
|
|
|
72
120
|
let states = new Map();
|
|
73
121
|
const checking = has('--check');
|
|
122
|
+
const cachePath = value('--cache') ? resolve(value('--cache')) : defaultCachePath(root);
|
|
74
123
|
if (checking) {
|
|
75
124
|
const ids = new Set();
|
|
76
125
|
for (const f of fences) {
|
|
@@ -80,17 +129,26 @@ if (checking) {
|
|
|
80
129
|
}
|
|
81
130
|
states = await checkGithubRefs([...ids], {
|
|
82
131
|
apiBase: value('--api-base') ?? undefined,
|
|
83
|
-
cachePath
|
|
132
|
+
cachePath,
|
|
133
|
+
noCache: has('--no-cache'),
|
|
134
|
+
maxAgeDays: Number(value('--max-age-days') ?? 7),
|
|
84
135
|
});
|
|
85
|
-
// What the lockfile says you actually run turns "the issue is closed" into
|
|
86
|
-
// "you can delete this today", or into "upgrade before you can".
|
|
87
|
-
const installed = await readInstalled(root);
|
|
88
|
-
for (const f of fences) f.verdict = verdict(f, states, installed);
|
|
89
136
|
}
|
|
90
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
|
+
|
|
91
145
|
const summary = summarize(fences, states);
|
|
92
146
|
summary.skipped = skipped.length;
|
|
93
|
-
|
|
147
|
+
summary.skippedFiles = skipped;
|
|
148
|
+
summary.history = history;
|
|
149
|
+
summary.checkedAt = checking ? checkTimestamp(states) : null;
|
|
150
|
+
|
|
151
|
+
const name = single ? basename(single) : basename(root);
|
|
94
152
|
|
|
95
153
|
const reportFlag = flags.find((f) => f === '--report' || f.startsWith('--report='));
|
|
96
154
|
if (reportFlag) {
|
|
@@ -102,7 +160,7 @@ if (reportFlag) {
|
|
|
102
160
|
const tasksFlag = flags.find((f) => f === '--tasks' || f.startsWith('--tasks='));
|
|
103
161
|
if (tasksFlag) {
|
|
104
162
|
const out = resolve(value('--tasks') ?? 'ancient-fences-tasks.md');
|
|
105
|
-
await writeFile(out, renderTasks(fences, name), 'utf8');
|
|
163
|
+
await writeFile(out, renderTasks(fences, name, checking), 'utf8');
|
|
106
164
|
console.error(`agent tasks written to ${out}`);
|
|
107
165
|
}
|
|
108
166
|
|
|
@@ -111,3 +169,21 @@ if (has('--json')) {
|
|
|
111
169
|
} else {
|
|
112
170
|
console.log(renderText(fences, summary, name, checking));
|
|
113
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
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,11 +93,28 @@ 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
|
+
}
|
|
64
113
|
if (summary.skipped) {
|
|
65
114
|
L.push(` ${n(summary.skipped)} bundled or minified files left out: their fences belong to`);
|
|
66
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`);
|
|
67
118
|
}
|
|
68
119
|
L.push('');
|
|
69
120
|
|
|
@@ -76,18 +127,42 @@ export function renderText(fences, summary, repoName, checked = false) {
|
|
|
76
127
|
L.push('');
|
|
77
128
|
}
|
|
78
129
|
|
|
79
|
-
|
|
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)`);
|
|
80
149
|
L.push(' ' + '='.repeat(70));
|
|
81
|
-
|
|
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)) {
|
|
82
155
|
const y = yearsSince(f.lastTouched);
|
|
83
156
|
const age = y === null ? ' ? ' : `${y.toFixed(1)} yr`;
|
|
84
157
|
L.push(` ${age.padStart(7)} ${f.file}:${f.line} [${f.kind}]`);
|
|
85
158
|
L.push(` reason: ${premiseOf(f)}`);
|
|
86
159
|
L.push(` comment: ${f.text.slice(0, 92)}`);
|
|
87
|
-
if (f
|
|
160
|
+
if (showVerdict(f, checked)) L.push(` VERDICT: ${f.verdict.level} (${f.verdict.why})`);
|
|
88
161
|
L.push('');
|
|
89
162
|
}
|
|
90
|
-
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.');
|
|
91
166
|
L.push('');
|
|
92
167
|
return L.join('\n');
|
|
93
168
|
}
|
|
@@ -98,6 +173,15 @@ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '&l
|
|
|
98
173
|
* A standalone report meant to be forwarded to whoever pays for the codebase.
|
|
99
174
|
* Developers find the fences; someone else decides what they cost.
|
|
100
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
|
+
|
|
101
185
|
export function renderHtml(fences, summary, repoName, checked = false) {
|
|
102
186
|
const rows = ranked(fences).slice(0, 40).map((f) => {
|
|
103
187
|
const y = yearsSince(f.lastTouched);
|
|
@@ -106,7 +190,7 @@ export function renderHtml(fences, summary, repoName, checked = false) {
|
|
|
106
190
|
<td class="num">${y === null ? '-' : y.toFixed(1) + ' yr'}</td>
|
|
107
191
|
<td><code>${esc(f.file)}:${f.line}</code><p>${esc(f.text.slice(0, 130))}</p></td>
|
|
108
192
|
<td class="num">${esc(premiseOf(f))}</td>
|
|
109
|
-
<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>
|
|
110
194
|
</tr>`;
|
|
111
195
|
}).join('\n');
|
|
112
196
|
|
|
@@ -131,11 +215,13 @@ h1{font:300 clamp(2rem,5vw,3.2rem)/1.05 ui-serif,Georgia,serif;letter-spacing:-.
|
|
|
131
215
|
.stat span{font-size:.78rem;color:var(--dim);line-height:1.35}
|
|
132
216
|
h2{font:300 1.6rem/1.1 ui-serif,Georgia,serif;margin:2.5rem 0 1rem}
|
|
133
217
|
.scroll{overflow-x:auto;border:1px solid var(--line);background:var(--surface)}
|
|
134
|
-
table{border-collapse:collapse;width:100%;min-width:
|
|
218
|
+
table{border-collapse:collapse;width:100%;min-width:40rem;font-size:.88rem;table-layout:fixed}
|
|
135
219
|
th,td{text-align:left;padding:.8rem 1rem;border-bottom:1px solid var(--line);vertical-align:top}
|
|
136
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}
|
|
137
221
|
td p{margin:.35rem 0 0;color:var(--dim);font-size:.82rem}
|
|
138
|
-
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}
|
|
139
225
|
code{font-size:.82rem;color:var(--ink)}
|
|
140
226
|
.v{font-family:ui-monospace,monospace;font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
|
141
227
|
.v-remove{color:var(--gold)}
|
|
@@ -154,14 +240,30 @@ footer p{max-width:62ch}
|
|
|
154
240
|
<div class="stat"><b>${summary.total}</b><span>fences standing</span></div>
|
|
155
241
|
<div class="stat"><b>${summary.byKind.code}</b><span>written because of someone else's bug</span></div>
|
|
156
242
|
<div class="stat"><b>${summary.trackers}</b><span>external issues to check</span></div>
|
|
157
|
-
<div class="stat"><b>${summary.old}</b><span>untouched for 3+ years</span></div>
|
|
158
|
-
<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>
|
|
159
245
|
</div>
|
|
160
|
-
|
|
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>
|
|
161
251
|
<div class="scroll"><table>
|
|
252
|
+
<colgroup><col class="age"><col><col class="reason"><col class="state"></colgroup>
|
|
162
253
|
<thead><tr><th>Untouched</th><th>Where</th><th>Reason given</th><th>${checked ? 'Verdict' : 'State'}</th></tr></thead>
|
|
163
254
|
<tbody>${rows}</tbody>
|
|
164
|
-
</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>` : ''}
|
|
165
267
|
</main>
|
|
166
268
|
<footer><div class="wrap">
|
|
167
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>
|
|
@@ -175,16 +277,23 @@ footer p{max-width:62ch}
|
|
|
175
277
|
* Ancient Fences does not edit code: knowing a fence is dead is the scarce
|
|
176
278
|
* part, and every editor now ships something that can do the deleting.
|
|
177
279
|
*/
|
|
178
|
-
export function renderTasks(fences, repoName) {
|
|
280
|
+
export function renderTasks(fences, repoName, checked = false) {
|
|
179
281
|
const dead = fences.filter((f) => f.verdict && (f.verdict.level === 'remove' || f.verdict.level === 'upgrade first'));
|
|
180
282
|
const L = [];
|
|
181
283
|
L.push(`# Dead fences in ${repoName}`);
|
|
182
284
|
L.push('');
|
|
183
285
|
L.push(`Each item below is code kept alive by a condition that no longer holds.`);
|
|
184
|
-
|
|
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
|
+
}
|
|
185
292
|
L.push('');
|
|
186
293
|
if (dead.length === 0) {
|
|
187
|
-
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.');
|
|
188
297
|
return L.join('\n');
|
|
189
298
|
}
|
|
190
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
|
/**
|