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 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
- # Not on npm yet, so run it straight from this repository:
15
- npx github:Marcin1000/ancient-fences . # what is standing in this codebase
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
- Once it is published to npm the command becomes `npx ancient-fences .`. Until
20
- then the line above is the one that works, and this README says so rather than
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 | No reason recorded | Oldest |
55
+ | Repository | Fences | Because of someone else's bug | Untouched 3+ years | Oldest |
58
56
  |---|---|---|---|---|
59
- | puppeteer/puppeteer | 99 | 63 | 29 | 7.9 yr |
60
- | webpack/webpack | 354 | 65 | 288 | 8.8 yr |
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
- Issue states are cached in `.ancient-fences-cache.json` next to the scanned
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 github:Marcin1000/ancient-fences . --check --tasks
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
@@ -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
- if (cmd !== 'scan') {
29
- const usage = `ancient-fences: find the code you wrote because of someone else's bug,
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(cmd === 'help' || has('--help') ? 0 : 1);
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
- for await (const file of walkFiles(root)) {
48
- fences.push(...detectFences(file.path, file.text));
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
- if (!has('--no-blame')) await blameAll(root, fences);
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: join(root, '.ancient-fences-cache.json'),
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
- const name = basename(root);
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.1.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
- const FENCE_WORDS = [
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
- 'hack', 'kludge', 'monkey patch', 'monkeypatch', 'monkey-patch',
8
- 'temporary', 'temporarily', 'for now',
9
- 'remove when', 'remove once', 'remove after', 'remove this',
10
- 'revert when', 'revert once', 'drop when', 'delete when',
11
- 'can be removed', 'no longer needed', 'once fixed', 'when fixed',
12
- 'until ', 'due to a bug', 'due to bug', 'because of a bug',
13
- 'broken in', 'regression', 'upstream bug', 'known bug',
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
- const COMMENT_START = /^\s*(?:\/\/+|#+|\*|\/\*|--|;;?|<!--|%|"""|''')/;
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 isCommentish(line) {
36
- return COMMENT_START.test(line);
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
- // Group adjacent comment lines into blocks. Deliberately language-agnostic:
40
- // this has to work on any text file, including languages we have never seen.
41
- export function commentBlocks(text) {
42
- const lines = text.split('\n');
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 line = lines[i];
47
- const inlineComment = !isCommentish(line) && /(?:\/\/|#|\/\*)\s*\S/.test(line) && !/['"`]/.test(line);
48
- if (isCommentish(line) || inlineComment) {
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(line.trim());
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 fenceWordsIn(text) {
62
- const low = text.toLowerCase();
63
- return FENCE_WORDS.filter((w) => low.includes(w));
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
- for (const block of commentBlocks(text)) {
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 words = fenceWordsIn(body);
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.lines[0] ?? '')
207
+ const isDoc = /^\s*(?:\/\*\*|\*)/.test(block.raw ?? '')
110
208
  || /@remarks|@param|@returns|\{@link/.test(body);
111
209
  let kind;
112
- if (refs.length > 0 && (words.length > 0 || !isDoc)) kind = 'code';
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: words.slice(0, 3),
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
- L.push(` ${n(summary.old)} fences untouched for 3+ years`);
63
- if (summary.oldest !== null) L.push(` ${n(Math.round(summary.oldest))} years old is the oldest one`);
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
- L.push(' CHECK THESE FIRST (longest untouched)');
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
- for (const f of ranked(fences).slice(0, 15)) {
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.verdict) L.push(` VERDICT: ${f.verdict.level} (${f.verdict.why})`);
160
+ if (showVerdict(f, checked)) L.push(` VERDICT: ${f.verdict.level} (${f.verdict.why})`);
84
161
  L.push('');
85
162
  }
86
- L.push(' Every closed issue above is code you can delete.');
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) => ({ '&': '&amp;', '<': '&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>${v ? `<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>
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:46rem;font-size:.88rem}
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{white-space:nowrap;color:var(--dim);font-variant-numeric:tabular-nums;font-size:.8rem}
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 ? '-' : Math.round(summary.oldest)}</b><span>years, the oldest one</span></div>
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
- <h2>Check these first</h2>
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
- L.push(`Verified against the referenced tracker${dead.some((f) => /shipped in/.test(f.verdict.why)) ? ' and the lockfile' : ''}.`);
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('Nothing to remove. Run with --check first if you have not.');
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 cache = await loadCache(opts.cachePath);
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
- if (cache[id] && !opts.noCache) {
18
- result.set(id, cache[id]);
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 known = fence.premise.refs
82
- .map((r) => states.get(r.id))
83
- .filter((s) => s && s.state !== 'unknown');
84
- if (known.length === 0) return { level: 'unchecked', why: 'could not determine issue state' };
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
- export async function* walkFiles(root) {
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
  }