ancient-fences 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcin1000
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="assets/readme-banner.png">
3
+ <img src="assets/readme-banner-light.png" alt="Ancient Fences" width="100%">
4
+ </picture>
5
+
6
+ # Ancient Fences
7
+
8
+ **Find the code you wrote because of someone else's bug. Then check whether that bug is still there.**
9
+
10
+ Dependabot bumps the version. Nobody removes the workaround you wrote because
11
+ the old version was broken.
12
+
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
17
+ ```
18
+
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.
22
+
23
+ ## The problem
24
+
25
+ You hit a bug in a library. You write code around it and, if you are decent, leave a note:
26
+
27
+ ```js
28
+ // Workaround for https://github.com/some/lib/issues/2500 (remove when fixed)
29
+ ```
30
+
31
+ Then the bug gets fixed. The issue is closed. The library is replaced. And
32
+ nothing happens, because there is no link between someone else's tracker and
33
+ your code. The workaround stays forever, and after two years nobody dares
34
+ touch it: the note says "bug", so maybe the bug is still there.
35
+
36
+ This is Chesterton's Fence at industrial scale. The code knows the fence is
37
+ there. Git knows how long it has stood. Nobody checks whether the reason
38
+ still exists.
39
+
40
+ ## Real example
41
+
42
+ In `webpack`, `test/configCases/plugins/terser-plugin/extract.js:3`:
43
+
44
+ ```js
45
+ // ⚠️ move the following comment back to the top
46
+ // https://github.com/mishoo/UglifyJS2/issues/2500
47
+ ```
48
+
49
+ Written **2017-11-08**. The referenced issue is closed. Webpack dropped
50
+ UglifyJS for terser in 2018. The string `uglify` does not appear in its
51
+ `package.json` even once. The directory is literally named `terser-plugin`.
52
+ The fence has stood untouched for **8.8 years**, guarding a hole in a road
53
+ that no longer exists.
54
+
55
+ Two runs, full clones, August 2026:
56
+
57
+ | Repository | Fences | Because of someone else's bug | No reason recorded | Oldest |
58
+ |---|---|---|---|---|
59
+ | puppeteer/puppeteer | 99 | 63 | 29 | 7.9 yr |
60
+ | webpack/webpack | 354 | 65 | 288 | 8.8 yr |
61
+
62
+ Both are well-maintained projects by good engineers. That is the point.
63
+
64
+ ## What it finds
65
+
66
+ | Kind | What it means | The repair when the reason dies |
67
+ |---|---|---|
68
+ | `code` | code written because of someone else's bug | delete the workaround |
69
+ | `docs` | a documented limitation pointing at an issue | the docs now lie, so fix them |
70
+ | `deadline` | a date written into a comment | the date passed, revisit |
71
+ | `unmarked` | clearly a workaround, no recorded reason | write the reason down, or remove it |
72
+
73
+ Trackers understood: GitHub issues and pull requests, Chromium (`crbug.com`),
74
+ Mozilla Bugzilla, WebKit.
75
+
76
+ ## Verdicts
77
+
78
+ `remove` · `upgrade first` · `review` · `still valid` · `unchecked` · `unmarked`
79
+
80
+ A closed issue is weak evidence on its own. With `--check`, Ancient Fences also
81
+ reads the version the fix shipped in (a release milestone, or "fixed in 1.2.3"
82
+ in the issue) and compares it against your lockfile:
83
+
84
+ ```
85
+ VERDICT: remove (fix shipped in 0.30.0 (milestone), you run sharp 0.33.1)
86
+ VERDICT: upgrade first (fix shipped in 0.34.0, but sharp is pinned at 0.33.1)
87
+ ```
88
+
89
+ Those are two different jobs. The first is code you can delete this afternoon.
90
+ The second is the more uncomfortable finding: you are still paying to maintain
91
+ a workaround for a bug that was fixed years ago, because nobody upgraded.
92
+
93
+ `package-lock.json`, `yarn.lock` and `pnpm-lock.yaml` are read when present. No
94
+ lockfile means the tool says less, never something false.
95
+
96
+ **An unknown issue state never produces `still valid`.** Not knowing is not a
97
+ green light. A tool that reassures you without grounds is worse than no tool.
98
+ (The test enforcing this caught a real bug on the prototype's first run.)
99
+
100
+ ## Options
101
+
102
+ ```
103
+ --check ask GitHub whether the referenced issues are still open
104
+ (set GITHUB_TOKEN to lift the 60 requests/hour limit)
105
+ --report[=file] write a shareable HTML report (default: ancient-fences.html)
106
+ --tasks[=file] write the dead fences as instructions for a coding agent
107
+ --api-base=URL alternate API (GitHub Enterprise, or a mock in tests)
108
+ --json full machine-readable output
109
+ --no-blame skip fence age (faster, tells you less)
110
+ ```
111
+
112
+ Issue states are cached in `.ancient-fences-cache.json` next to the scanned
113
+ repository. Add it to your `.gitignore`.
114
+
115
+ ## Working with an agent
116
+
117
+ Ancient Fences does not edit your code, and that is deliberate. Knowing that a
118
+ fence is dead is the scarce part; every editor now ships something that can do
119
+ the deleting. So `--tasks` writes the verified findings as work:
120
+
121
+ ```bash
122
+ npx github:Marcin1000/ancient-fences . --check --tasks
123
+ ```
124
+
125
+ You get a markdown file with one entry per dead fence: the file and line, the
126
+ reason originally recorded, what proves it no longer holds, and the instruction
127
+ ("remove the workaround and the comment, then run the test suite"). Point Claude
128
+ Code, Cursor, Copilot or your own script at it. The tests are the safety net,
129
+ which is why the instruction always ends there.
130
+
131
+ ## Tests
132
+
133
+ ```bash
134
+ npm test
135
+ ```
136
+
137
+ The tracker tests run against a mock GitHub API on localhost, so they work
138
+ offline and without a token.
139
+
140
+ ## Status
141
+
142
+ Early, but real. Detection, ageing, issue checking, fix-version matching against
143
+ the lockfile, the HTML report and agent tasks all work and are covered by tests
144
+ that run offline. Not there yet: `@ancient premise:` annotations for conditions
145
+ that live outside a tracker (a contract, a vendor limit, a certificate expiry),
146
+ trackers other than GitHub and Chromium, and a GitHub Action that comments on
147
+ pull requests.
148
+
149
+ ## Ancient Code
150
+
151
+ Ancient Fences answers one question about one repository. The same blindness
152
+ applies to everything else nobody re-checks in a long-running codebase: what
153
+ was already paid for, which parts only one person understands, whether the
154
+ whole thing could be handed to another team at all. That is
155
+ [Ancient Code](https://ancientcode.net). This tool is its open-source front door.
156
+
157
+ MIT licensed.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { resolve, basename, join } from 'node:path';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { walkFiles } from '../src/walk.mjs';
5
+ import { detectFences } from '../src/detect.mjs';
6
+ import { blameAll } from '../src/age.mjs';
7
+ import { checkGithubRefs, verdict } from '../src/tracker.mjs';
8
+ import { renderText, renderHtml, renderTasks, summarize } from '../src/report.mjs';
9
+ import { readInstalled } from '../src/lockfile.mjs';
10
+
11
+ const args = process.argv.slice(2);
12
+
13
+ // "ancient-fences ." has to scan the current directory. The first version read
14
+ // any first argument as a command name, so the documented form printed the
15
+ // help text and did nothing. Only the words below are commands; everything
16
+ // else is a path.
17
+ const COMMANDS = new Set(['scan', 'help']);
18
+ const cmd = args[0] && COMMANDS.has(args[0]) ? args[0] : 'scan';
19
+ const flags = args.filter((a) => a.startsWith('--'));
20
+ const has = (f) => flags.some((x) => x === f);
21
+ const value = (f) => {
22
+ const hit = flags.find((x) => x.startsWith(`${f}=`));
23
+ return hit ? hit.slice(f.length + 1) : null;
24
+ };
25
+ const positional = args.filter((a) => !a.startsWith('--') && !COMMANDS.has(a));
26
+ const root = resolve(positional[0] ?? process.cwd());
27
+
28
+ if (cmd !== 'scan') {
29
+ const usage = `ancient-fences: find the code you wrote because of someone else's bug,
30
+ then check whether that bug is still there.
31
+
32
+ npx ancient-fences [path] [options]
33
+
34
+ --check ask GitHub whether the referenced issues are still open
35
+ (set GITHUB_TOKEN to lift the 60 requests/hour limit)
36
+ --report[=file] write a shareable HTML report (default: ancient-fences.html)
37
+ --tasks[=file] write the dead fences as instructions for a coding agent
38
+ --api-base=URL alternate API (GitHub Enterprise, or a mock in tests)
39
+ --json full machine-readable output
40
+ --no-blame skip fence age (faster, tells you less)
41
+ `;
42
+ console.log(usage);
43
+ process.exit(cmd === 'help' || has('--help') ? 0 : 1);
44
+ }
45
+
46
+ const fences = [];
47
+ for await (const file of walkFiles(root)) {
48
+ fences.push(...detectFences(file.path, file.text));
49
+ }
50
+
51
+ if (!has('--no-blame')) await blameAll(root, fences);
52
+
53
+ let states = new Map();
54
+ const checking = has('--check');
55
+ if (checking) {
56
+ const ids = new Set();
57
+ for (const f of fences) {
58
+ if (f.premise.type === 'tracker') {
59
+ for (const r of f.premise.refs) if (r.id.startsWith('github:')) ids.add(r.id);
60
+ }
61
+ }
62
+ states = await checkGithubRefs([...ids], {
63
+ apiBase: value('--api-base') ?? undefined,
64
+ cachePath: join(root, '.ancient-fences-cache.json'),
65
+ });
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
+ }
71
+
72
+ const summary = summarize(fences, states);
73
+ const name = basename(root);
74
+
75
+ const reportFlag = flags.find((f) => f === '--report' || f.startsWith('--report='));
76
+ if (reportFlag) {
77
+ const out = resolve(value('--report') ?? 'ancient-fences.html');
78
+ await writeFile(out, renderHtml(fences, summary, name, checking), 'utf8');
79
+ console.error(`report written to ${out}`);
80
+ }
81
+
82
+ const tasksFlag = flags.find((f) => f === '--tasks' || f.startsWith('--tasks='));
83
+ if (tasksFlag) {
84
+ const out = resolve(value('--tasks') ?? 'ancient-fences-tasks.md');
85
+ await writeFile(out, renderTasks(fences, name), 'utf8');
86
+ console.error(`agent tasks written to ${out}`);
87
+ }
88
+
89
+ if (has('--json')) {
90
+ console.log(JSON.stringify({ repo: root, summary, fences }, null, 2));
91
+ } else {
92
+ console.log(renderText(fences, summary, name, checking));
93
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "ancient-fences",
3
+ "version": "0.1.0",
4
+ "description": "Finds the code you wrote because of someone else's bug, and checks whether that bug is still there.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ancient-fences": "bin/ancient-fences.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "technical-debt",
17
+ "workaround",
18
+ "chesterton-fence",
19
+ "code-audit",
20
+ "maintenance",
21
+ "stale-code"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "license": "MIT",
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"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Marcin1000/ancient-fences.git"
33
+ },
34
+ "homepage": "https://ancientcode.net",
35
+ "bugs": {
36
+ "url": "https://github.com/Marcin1000/ancient-fences/issues"
37
+ },
38
+ "author": "Marcin Przybylski"
39
+ }
package/src/age.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const run = promisify(execFile);
5
+
6
+ /**
7
+ * How long the fence has stood: when anyone last touched this line.
8
+ * This is the one number nobody can fake. A comment can lie, blame cannot.
9
+ */
10
+ export async function blameYear(repo, file, line) {
11
+ try {
12
+ const { stdout } = await run(
13
+ 'git',
14
+ ['blame', '--porcelain', '-L', `${line},${line}`, '--', file],
15
+ { cwd: repo, maxBuffer: 1024 * 1024 },
16
+ );
17
+ const m = stdout.match(/^author-time (\d+)$/m);
18
+ if (!m) return null;
19
+ return new Date(Number(m[1]) * 1000);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export async function blameAll(repo, fences, concurrency = 8) {
26
+ let i = 0;
27
+ const workers = Array.from({ length: concurrency }, async () => {
28
+ while (i < fences.length) {
29
+ const f = fences[i++];
30
+ f.lastTouched = await blameYear(repo, f.file, f.line);
31
+ }
32
+ });
33
+ await Promise.all(workers);
34
+ return fences;
35
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // Finding "fences": code that exists because of an external condition.
2
+ // Chesterton's Fence at industrial scale. The fence stands long after the
3
+ // reason for building it is gone, and nobody dares take it down.
4
+
5
+ const FENCE_WORDS = [
6
+ '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',
15
+ ];
16
+
17
+ // External trackers: the condition lives outside this repository, which is
18
+ // exactly why nobody notices when it stops being true.
19
+ const REF_PATTERNS = [
20
+ { kind: 'github', re: /https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d+)/gi,
21
+ id: (m) => `github:${m[1]}/${m[2]}#${m[3]}`, url: (m) => m[0] },
22
+ { kind: 'chromium', re: /(?:https?:\/\/)?(?:crbug\.com\/|bugs\.chromium\.org\/p\/chromium\/issues\/detail\?id=)(\d+)/gi,
23
+ id: (m) => `crbug:${m[1]}`, url: (m) => `https://crbug.com/${m[1]}` },
24
+ { kind: 'mozilla', re: /bugzilla\.mozilla\.org\/show_bug\.cgi\?id=(\d+)/gi,
25
+ id: (m) => `bugzilla:${m[1]}`, url: (m) => m[0] },
26
+ { kind: 'webkit', re: /bugs\.webkit\.org\/show_bug\.cgi\?id=(\d+)/gi,
27
+ id: (m) => `webkit:${m[1]}`, url: (m) => m[0] },
28
+ ];
29
+
30
+ 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
+ const VERSION_RE = /\b(?:until|once|when|fixed in|released in|requires?|needs?)\b[^\n]{0,40}?\bv?(\d+\.\d+(?:\.\d+)?)/i;
32
+
33
+ const COMMENT_START = /^\s*(?:\/\/+|#+|\*|\/\*|--|;;?|<!--|%|"""|''')/;
34
+
35
+ function isCommentish(line) {
36
+ return COMMENT_START.test(line);
37
+ }
38
+
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');
43
+ const blocks = [];
44
+ let cur = null;
45
+ 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: [] };
50
+ cur.end = i + 1;
51
+ cur.lines.push(line.trim());
52
+ } else if (cur) {
53
+ blocks.push(cur);
54
+ cur = null;
55
+ }
56
+ }
57
+ if (cur) blocks.push(cur);
58
+ return blocks;
59
+ }
60
+
61
+ function fenceWordsIn(text) {
62
+ const low = text.toLowerCase();
63
+ return FENCE_WORDS.filter((w) => low.includes(w));
64
+ }
65
+
66
+ function refsIn(text) {
67
+ const found = [];
68
+ for (const p of REF_PATTERNS) {
69
+ p.re.lastIndex = 0;
70
+ let m;
71
+ while ((m = p.re.exec(text)) !== null) {
72
+ const id = p.id(m);
73
+ if (!found.some((f) => f.id === id)) found.push({ id, kind: p.kind, url: p.url(m) });
74
+ }
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /**
80
+ * Fences found in a single file.
81
+ * A fence is a comment showing that code exists because of an external
82
+ * condition: someone else's bug, a version, a deadline.
83
+ */
84
+ export function detectFences(file, text) {
85
+ const out = [];
86
+ for (const block of commentBlocks(text)) {
87
+ const body = block.lines.join(' ').replace(/\s+/g, ' ').slice(0, 600);
88
+ if (body.length < 12) continue;
89
+
90
+ const words = fenceWordsIn(body);
91
+ const refs = refsIn(body);
92
+ const dateM = body.match(DATE_RE);
93
+ const verM = body.match(VERSION_RE);
94
+
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
+ let premise;
100
+ if (refs.length > 0) premise = { type: 'tracker', refs };
101
+ else if (dateM) {
102
+ const iso = `${dateM[1]}-${dateM[2]}-${dateM[3] ?? '01'}`;
103
+ premise = { type: 'date', date: iso, overdue: new Date(iso) < new Date() };
104
+ } else if (verM) premise = { type: 'version', version: verM[1] };
105
+ else premise = { type: 'none' };
106
+
107
+ // The class says what breaks when the premise dies, and those are two
108
+ // different repairs, which is why the distinction earns its keep.
109
+ const isDoc = /^\s*(?:\/\*\*|\*)/.test(block.lines[0] ?? '')
110
+ || /@remarks|@param|@returns|\{@link/.test(body);
111
+ let kind;
112
+ if (refs.length > 0 && (words.length > 0 || !isDoc)) kind = 'code';
113
+ else if (refs.length > 0) kind = 'docs';
114
+ else if (premise.type === 'date') kind = 'deadline';
115
+ else kind = 'unmarked';
116
+
117
+ out.push({
118
+ file,
119
+ line: block.start,
120
+ endLine: block.end,
121
+ text: body.replace(/^\W+/, '').slice(0, 240),
122
+ words: words.slice(0, 3),
123
+ premise,
124
+ kind,
125
+ confidence: refs.length > 0 ? 'high' : premise.type === 'date' ? 'medium' : 'low',
126
+ });
127
+ }
128
+ return out;
129
+ }
@@ -0,0 +1,85 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * What is actually installed, read from whatever lockfile the project keeps.
6
+ * Deliberately forgiving: a lockfile we cannot parse means we say less, never
7
+ * that we say something wrong.
8
+ */
9
+ export async function readInstalled(root) {
10
+ const installed = new Map();
11
+ await npmLock(root, installed);
12
+ await yarnLock(root, installed);
13
+ await pnpmLock(root, installed);
14
+ return installed;
15
+ }
16
+
17
+ async function npmLock(root, out) {
18
+ let raw;
19
+ try {
20
+ raw = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8'));
21
+ } catch {
22
+ return;
23
+ }
24
+ // lockfileVersion 2 and 3 keep everything under "packages", keyed by path.
25
+ for (const [path, entry] of Object.entries(raw.packages ?? {})) {
26
+ if (!path || !entry?.version) continue;
27
+ const name = entry.name ?? path.replace(/^.*node_modules\//, '');
28
+ if (name) keep(out, name, entry.version);
29
+ }
30
+ for (const [name, entry] of Object.entries(raw.dependencies ?? {})) {
31
+ if (entry?.version) keep(out, name, entry.version);
32
+ }
33
+ }
34
+
35
+ async function yarnLock(root, out) {
36
+ let text;
37
+ try {
38
+ text = await readFile(join(root, 'yarn.lock'), 'utf8');
39
+ } catch {
40
+ return;
41
+ }
42
+ let current = null;
43
+ for (const line of text.split('\n')) {
44
+ const header = line.match(/^"?((?:@[^/\s"]+\/)?[^@\s"]+)@/);
45
+ if (header && !line.startsWith(' ')) {
46
+ current = header[1];
47
+ continue;
48
+ }
49
+ const version = line.match(/^\s+version:?\s+"?([\w.\-+]+)"?/);
50
+ if (version && current) {
51
+ keep(out, current, version[1]);
52
+ current = null;
53
+ }
54
+ }
55
+ }
56
+
57
+ async function pnpmLock(root, out) {
58
+ let text;
59
+ try {
60
+ text = await readFile(join(root, 'pnpm-lock.yaml'), 'utf8');
61
+ } catch {
62
+ return;
63
+ }
64
+ for (const line of text.split('\n')) {
65
+ const m = line.match(/^\s{2}\/?((?:@[^/\s]+\/)?[^/\s@]+)[@/](\d+\.\d+\.\d+[\w.\-+]*)/);
66
+ if (m) keep(out, m[1], m[2]);
67
+ }
68
+ }
69
+
70
+ /** Keep the highest version seen: transitive copies of a package are common. */
71
+ function keep(map, name, version) {
72
+ const prev = map.get(name);
73
+ if (!prev) {
74
+ map.set(name, version);
75
+ return;
76
+ }
77
+ const a = prev.split('.').map(Number);
78
+ const b = version.split('.').map(Number);
79
+ for (let i = 0; i < 3; i++) {
80
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) {
81
+ if ((b[i] ?? 0) > (a[i] ?? 0)) map.set(name, version);
82
+ return;
83
+ }
84
+ }
85
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,207 @@
1
+ const YEAR = 365.25 * 24 * 3600 * 1000;
2
+
3
+ export function yearsSince(date) {
4
+ if (!date) return null;
5
+ return (Date.now() - date.getTime()) / YEAR;
6
+ }
7
+
8
+ export const KIND_LABEL = {
9
+ code: 'code written because of someone else’s bug',
10
+ docs: 'documented limitation pointing at an issue',
11
+ deadline: 'deadline written into a comment',
12
+ unmarked: 'fence with no sign: nobody recorded why it is there',
13
+ };
14
+
15
+ export function summarize(fences, states = new Map()) {
16
+ const byKind = { code: 0, docs: 0, deadline: 0, unmarked: 0 };
17
+ const trackers = new Set();
18
+ let overdue = 0;
19
+ let old = 0;
20
+ let oldest = null;
21
+ for (const f of fences) {
22
+ byKind[f.kind]++;
23
+ if (f.premise.type === 'tracker') for (const r of f.premise.refs) trackers.add(r.id);
24
+ if (f.premise.type === 'date' && f.premise.overdue) overdue++;
25
+ const y = yearsSince(f.lastTouched);
26
+ if (y !== null && y >= 3) old++;
27
+ if (y !== null && (oldest === null || y > oldest)) oldest = y;
28
+ }
29
+ const verdicts = {};
30
+ for (const f of fences) {
31
+ if (!f.verdict) continue;
32
+ verdicts[f.verdict.level] = (verdicts[f.verdict.level] ?? 0) + 1;
33
+ }
34
+ return { total: fences.length, byKind, trackers: trackers.size, overdue, old, oldest, verdicts, checked: states.size };
35
+ }
36
+
37
+ export function ranked(fences) {
38
+ return fences
39
+ .filter((f) => f.premise.type === 'tracker' || (f.premise.type === 'date' && f.premise.overdue))
40
+ .sort((a, b) => (yearsSince(b.lastTouched) ?? 0) - (yearsSince(a.lastTouched) ?? 0));
41
+ }
42
+
43
+ function premiseOf(f) {
44
+ return f.premise.type === 'tracker'
45
+ ? f.premise.refs.map((r) => r.id).join(', ')
46
+ : `deadline ${f.premise.date} (passed)`;
47
+ }
48
+
49
+ export function renderText(fences, summary, repoName, checked = false) {
50
+ const L = [];
51
+ const n = (x) => String(x).padStart(4);
52
+ L.push('');
53
+ L.push(` ANCIENT FENCES · ${repoName}`);
54
+ L.push(' ' + '='.repeat(70));
55
+ L.push(` ${n(summary.total)} fences standing in this codebase`);
56
+ L.push(` ${n(summary.byKind.code)} code written because of someone else's bug`);
57
+ L.push(` ${n(summary.byKind.docs)} documented limitations pointing at an issue`);
58
+ L.push(` ${n(summary.byKind.deadline)} deadlines in comments (passed: ${summary.overdue})`);
59
+ L.push(` ${n(summary.byKind.unmarked)} fences with no sign`);
60
+ L.push(' ' + '-'.repeat(70));
61
+ 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`);
64
+ L.push('');
65
+
66
+ if (checked) {
67
+ L.push(' VERDICTS');
68
+ L.push(' ' + '-'.repeat(70));
69
+ for (const [level, count] of Object.entries(summary.verdicts).sort((a, b) => b[1] - a[1])) {
70
+ L.push(` ${String(count).padStart(4)} ${level}`);
71
+ }
72
+ L.push('');
73
+ }
74
+
75
+ L.push(' CHECK THESE FIRST (longest untouched)');
76
+ L.push(' ' + '='.repeat(70));
77
+ for (const f of ranked(fences).slice(0, 15)) {
78
+ const y = yearsSince(f.lastTouched);
79
+ const age = y === null ? ' ? ' : `${y.toFixed(1)} yr`;
80
+ L.push(` ${age.padStart(7)} ${f.file}:${f.line} [${f.kind}]`);
81
+ L.push(` reason: ${premiseOf(f)}`);
82
+ L.push(` comment: ${f.text.slice(0, 92)}`);
83
+ if (f.verdict) L.push(` VERDICT: ${f.verdict.level} (${f.verdict.why})`);
84
+ L.push('');
85
+ }
86
+ L.push(' Every closed issue above is code you can delete.');
87
+ L.push('');
88
+ return L.join('\n');
89
+ }
90
+
91
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
92
+
93
+ /**
94
+ * A standalone report meant to be forwarded to whoever pays for the codebase.
95
+ * Developers find the fences; someone else decides what they cost.
96
+ */
97
+ export function renderHtml(fences, summary, repoName, checked = false) {
98
+ const rows = ranked(fences).slice(0, 40).map((f) => {
99
+ const y = yearsSince(f.lastTouched);
100
+ const v = f.verdict;
101
+ return `<tr>
102
+ <td class="num">${y === null ? '-' : y.toFixed(1) + ' yr'}</td>
103
+ <td><code>${esc(f.file)}:${f.line}</code><p>${esc(f.text.slice(0, 130))}</p></td>
104
+ <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>
106
+ </tr>`;
107
+ }).join('\n');
108
+
109
+ return `<!doctype html>
110
+ <html lang="en"><head><meta charset="utf-8">
111
+ <meta name="viewport" content="width=device-width,initial-scale=1">
112
+ <title>Ancient Fences: ${esc(repoName)}</title>
113
+ <style>
114
+ :root{--ground:#0F1216;--surface:#171C22;--surface2:#1E242B;--line:#2B333B;--ink:#E7E1D4;--dim:#A7A69C;--muted:#83877F;--gold:#E0A45C;--inst:#8FC3D2}
115
+ @media (prefers-color-scheme:light){:root{--ground:#E3E2DC;--surface:#EDEBE4;--surface2:#F3F1EB;--line:#CFCCC1;--ink:#1A1E22;--dim:#4A4F53;--muted:#6B6F68;--gold:#8F5B18;--inst:#2E6B7C}}
116
+ *{box-sizing:border-box}
117
+ body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
118
+ .wrap{max-width:68rem;margin:0 auto;padding:0 clamp(1rem,4vw,2.5rem)}
119
+ header{border-bottom:1px solid var(--line);padding:3rem 0 2rem}
120
+ h1{font:300 clamp(2rem,5vw,3.2rem)/1.05 ui-serif,Georgia,serif;letter-spacing:-.02em;margin:0}
121
+ .sub{color:var(--dim);margin:.7rem 0 0}
122
+ .mono,code,.num{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
123
+ .mono{font-size:.7rem;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
124
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:1px;background:var(--line);border:1px solid var(--line);margin:2.5rem 0}
125
+ .stat{background:var(--surface);padding:1.1rem 1.2rem;display:flex;flex-direction:column;gap:.3rem}
126
+ .stat b{font:400 2rem/1 ui-monospace,monospace;color:var(--gold);font-variant-numeric:tabular-nums}
127
+ .stat span{font-size:.78rem;color:var(--dim);line-height:1.35}
128
+ h2{font:300 1.6rem/1.1 ui-serif,Georgia,serif;margin:2.5rem 0 1rem}
129
+ .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}
131
+ th,td{text-align:left;padding:.8rem 1rem;border-bottom:1px solid var(--line);vertical-align:top}
132
+ 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
+ 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}
135
+ code{font-size:.82rem;color:var(--ink)}
136
+ .v{font-family:ui-monospace,monospace;font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
137
+ .v-remove{color:var(--gold)}
138
+ .v-review{color:var(--inst)}
139
+ footer{border-top:1px solid var(--line);margin-top:3rem;padding:2rem 0 4rem;color:var(--muted);font-size:.85rem}
140
+ footer strong{color:var(--ink)}
141
+ footer p{max-width:62ch}
142
+ </style></head><body>
143
+ <header><div class="wrap">
144
+ <p class="mono">Ancient Fences · ${esc(new Date().toISOString().slice(0, 10))}</p>
145
+ <h1>${esc(repoName)}</h1>
146
+ <p class="sub">Code that exists because of an external problem, and whether that problem is still there.</p>
147
+ </div></header>
148
+ <main class="wrap">
149
+ <div class="stats">
150
+ <div class="stat"><b>${summary.total}</b><span>fences standing</span></div>
151
+ <div class="stat"><b>${summary.byKind.code}</b><span>written because of someone else's bug</span></div>
152
+ <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>
155
+ </div>
156
+ <h2>Check these first</h2>
157
+ <div class="scroll"><table>
158
+ <thead><tr><th>Untouched</th><th>Where</th><th>Reason given</th><th>${checked ? 'Verdict' : 'State'}</th></tr></thead>
159
+ <tbody>${rows}</tbody>
160
+ </table></div>
161
+ </main>
162
+ <footer><div class="wrap">
163
+ <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>
164
+ <p style="margin-top:1rem">Generated by <strong>Ancient Fences</strong>. Open source, MIT.</p>
165
+ </div></footer>
166
+ </body></html>`;
167
+ }
168
+
169
+ /**
170
+ * The dead fences, written as work for whatever agent the team already uses.
171
+ * Ancient Fences does not edit code: knowing a fence is dead is the scarce
172
+ * part, and every editor now ships something that can do the deleting.
173
+ */
174
+ export function renderTasks(fences, repoName) {
175
+ const dead = fences.filter((f) => f.verdict && (f.verdict.level === 'remove' || f.verdict.level === 'upgrade first'));
176
+ const L = [];
177
+ L.push(`# Dead fences in ${repoName}`);
178
+ L.push('');
179
+ 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' : ''}.`);
181
+ L.push('');
182
+ if (dead.length === 0) {
183
+ L.push('Nothing to remove. Run with --check first if you have not.');
184
+ return L.join('\n');
185
+ }
186
+ dead.forEach((f, i) => {
187
+ const y = yearsSince(f.lastTouched);
188
+ L.push(`## ${i + 1}. ${f.file}:${f.line}`);
189
+ L.push('');
190
+ L.push(`- Recorded reason: ${premiseOf(f)}`);
191
+ L.push(`- Current state: ${f.verdict.why}`);
192
+ if (y !== null) L.push(`- Untouched for: ${y.toFixed(1)} years`);
193
+ L.push(`- Comment says: ${f.text.slice(0, 160)}`);
194
+ L.push('');
195
+ if (f.verdict.level === 'upgrade first') {
196
+ L.push(`Task: do not remove this yet. Upgrade the package to the version named above, run the test suite, and only then delete the workaround and this comment.`);
197
+ } else if (f.kind === 'docs') {
198
+ L.push(`Task: the documented limitation no longer exists. Correct the documentation here, do not touch behaviour.`);
199
+ } else {
200
+ L.push(`Task: remove the workaround this comment describes, along with the comment. Keep the change minimal, then run the project's test suite and report what broke.`);
201
+ }
202
+ L.push('');
203
+ });
204
+ L.push('---');
205
+ L.push('Generated by Ancient Fences. Verify each removal with tests before merging.');
206
+ return L.join('\n');
207
+ }
@@ -0,0 +1,132 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { fixVersionFrom, packageForRef, shippedStatus } from './versions.mjs';
4
+
5
+ /**
6
+ * Checks whether the referenced issues are still open. Without this step the
7
+ * tool is a glorified grep; with it, it answers the question nobody asks:
8
+ * does the reason still exist?
9
+ */
10
+ export async function checkGithubRefs(ids, opts = {}) {
11
+ const apiBase = opts.apiBase ?? 'https://api.github.com';
12
+ const token = opts.token ?? process.env.GITHUB_TOKEN ?? null;
13
+ const cache = await loadCache(opts.cachePath);
14
+ const result = new Map();
15
+
16
+ for (const id of ids) {
17
+ if (cache[id] && !opts.noCache) {
18
+ result.set(id, cache[id]);
19
+ continue;
20
+ }
21
+ const m = id.match(/^github:([\w.-]+)\/([\w.-]+)#(\d+)$/);
22
+ if (!m) continue;
23
+ const url = `${apiBase}/repos/${m[1]}/${m[2]}/issues/${m[3]}`;
24
+ const headers = { accept: 'application/vnd.github+json', 'user-agent': 'ancient-fences' };
25
+ if (token) headers.authorization = `Bearer ${token}`;
26
+
27
+ let res;
28
+ try {
29
+ res = await fetch(url, { headers });
30
+ } catch (err) {
31
+ result.set(id, { state: 'unknown', reason: `network: ${err.message}` });
32
+ continue;
33
+ }
34
+ if (res.status === 403 || res.status === 429) {
35
+ result.set(id, { state: 'unknown', reason: 'rate limited or no access' });
36
+ continue;
37
+ }
38
+ if (res.status === 404) {
39
+ result.set(id, { state: 'unknown', reason: 'issue missing or private' });
40
+ continue;
41
+ }
42
+ if (!res.ok) {
43
+ result.set(id, { state: 'unknown', reason: `HTTP ${res.status}` });
44
+ continue;
45
+ }
46
+ const body = await res.json();
47
+ const entry = {
48
+ state: body.state === 'closed' ? 'closed' : 'open',
49
+ closedAt: body.closed_at ?? null,
50
+ title: body.title ?? null,
51
+ isPull: Boolean(body.pull_request),
52
+ merged: body.pull_request?.merged_at ?? null,
53
+ // Kept rather than the whole payload: this is the only part that tells
54
+ // us whether the fix reached a release, and the cache stays small.
55
+ fix: fixVersionFrom(body),
56
+ };
57
+ result.set(id, entry);
58
+ cache[id] = entry;
59
+ }
60
+
61
+ await saveCache(opts.cachePath, cache);
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * What to do about it. Without a verdict the report is just a list of links.
67
+ * `installed` is what the lockfile says you actually run, which is what turns
68
+ * "the issue is closed" into "you can delete this today".
69
+ */
70
+ export function verdict(fence, states, installed = new Map()) {
71
+ if (fence.premise.type === 'date') {
72
+ return fence.premise.overdue
73
+ ? { level: 'remove', why: `deadline ${fence.premise.date} has passed` }
74
+ : { level: 'still valid', why: `deadline ${fence.premise.date} not reached yet` };
75
+ }
76
+ if (fence.premise.type !== 'tracker') {
77
+ return { level: 'unmarked', why: 'no recorded reason for this code' };
78
+ }
79
+ // An unknown state must never come out as "still valid". Not knowing is not
80
+ // 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' };
85
+ if (known.every((s) => s.state === 'closed')) {
86
+ 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 };
89
+ 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' };
91
+ }
92
+ if (known.some((s) => s.state === 'closed')) {
93
+ return { level: 'review', why: 'some of the reasons are gone' };
94
+ }
95
+ return { level: 'still valid', why: 'issue still open' };
96
+ }
97
+
98
+ /**
99
+ * Did the fix reach a version you run? Answered only when both halves are
100
+ * known: a version stated by the project, and that package in your lockfile.
101
+ */
102
+ export function shippedFor(fence, states, installed) {
103
+ if (!installed || installed.size === 0) return null;
104
+ for (const ref of fence.premise.refs ?? []) {
105
+ const state = states.get(ref.id);
106
+ if (!state?.fix) continue;
107
+ const pkg = packageForRef(ref.id, installed);
108
+ if (!pkg) continue;
109
+ const status = shippedStatus({ fix: state.fix, installed: pkg });
110
+ if (status) return status;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ async function loadCache(path) {
116
+ if (!path) return {};
117
+ try {
118
+ return JSON.parse(await readFile(path, 'utf8'));
119
+ } catch {
120
+ return {};
121
+ }
122
+ }
123
+
124
+ async function saveCache(path, cache) {
125
+ if (!path) return;
126
+ try {
127
+ await mkdir(dirname(path), { recursive: true });
128
+ await writeFile(path, JSON.stringify(cache, null, 1));
129
+ } catch {
130
+ /* a missing cache is annoying, not fatal */
131
+ }
132
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A closed issue is weak evidence. The strong question is whether the fix
3
+ * actually shipped in a version you are running. Everything here answers that,
4
+ * with no dependencies and no network.
5
+ */
6
+
7
+ const SEMVER = /(\d+)\.(\d+)(?:\.(\d+))?/;
8
+
9
+ export function parseVersion(text) {
10
+ if (!text) return null;
11
+ const m = String(text).match(SEMVER);
12
+ if (!m) return null;
13
+ return [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
14
+ }
15
+
16
+ /** -1, 0, 1 like a comparator. Returns null when either side is unparseable. */
17
+ export function compareVersions(a, b) {
18
+ const x = parseVersion(a);
19
+ const y = parseVersion(b);
20
+ if (!x || !y) return null;
21
+ for (let i = 0; i < 3; i++) {
22
+ if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
23
+ }
24
+ return 0;
25
+ }
26
+
27
+ /**
28
+ * Where a fix version can be stated, in descending order of trust:
29
+ * a release milestone, then a maintainer writing "fixed in 1.2.3" in the body.
30
+ */
31
+ export function fixVersionFrom(issue) {
32
+ if (!issue) return null;
33
+ const milestone = issue.milestone?.title;
34
+ if (milestone && parseVersion(milestone)) {
35
+ return { version: milestone.match(SEMVER)[0], source: 'milestone' };
36
+ }
37
+ const body = issue.body ?? '';
38
+ const m = body.match(/\b(?:fixed|resolved|released|landed|shipped)\s+in\s+v?(\d+\.\d+(?:\.\d+)?)/i);
39
+ if (m) return { version: m[1], source: 'issue text' };
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Guess which installed package a tracker reference belongs to.
45
+ * Repository name equals package name often enough to be useful, and being
46
+ * wrong here only means we stay silent rather than say something false.
47
+ */
48
+ export function packageForRef(refId, installed) {
49
+ const m = refId.match(/^github:([\w.-]+)\/([\w.-]+)#\d+$/);
50
+ if (!m) return null;
51
+ const [, owner, repo] = m;
52
+ const candidates = [repo, repo.toLowerCase(), `@${owner}/${repo}`, `${owner}-${repo}`];
53
+ for (const name of candidates) {
54
+ if (installed.has(name)) return { name, version: installed.get(name) };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * The three answers worth having about one fence, once versions are known.
61
+ * "shipped" is the only one that lets you delete code with confidence.
62
+ */
63
+ export function shippedStatus({ fix, installed }) {
64
+ if (!fix || !installed) return null;
65
+ const cmp = compareVersions(installed.version, fix.version);
66
+ if (cmp === null) return null;
67
+ if (cmp >= 0) {
68
+ return {
69
+ state: 'shipped',
70
+ text: `fix shipped in ${fix.version} (${fix.source}), you run ${installed.name} ${installed.version}`,
71
+ };
72
+ }
73
+ return {
74
+ state: 'not upgraded',
75
+ text: `fix shipped in ${fix.version}, but ${installed.name} is pinned at ${installed.version}`,
76
+ };
77
+ }
package/src/walk.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, extname, relative } from 'node:path';
3
+
4
+ const SKIP_DIRS = new Set([
5
+ '.git', 'node_modules', 'dist', 'build', 'out', 'vendor', 'target',
6
+ '.next', '.nuxt', 'coverage', '__pycache__', '.venv', 'venv', 'third_party',
7
+ ]);
8
+
9
+ const TEXT_EXT = new Set([
10
+ '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts',
11
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift', '.php',
12
+ '.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.m', '.mm',
13
+ '.sh', '.bash', '.zsh', '.sql', '.css', '.scss', '.less',
14
+ '.yml', '.yaml', '.toml', '.tf', '.gradle', '.dockerfile', '.vue', '.svelte',
15
+ ]);
16
+
17
+ const MAX_BYTES = 512 * 1024;
18
+
19
+ export async function* walkFiles(root) {
20
+ const stack = [root];
21
+ while (stack.length) {
22
+ const dir = stack.pop();
23
+ let entries;
24
+ try {
25
+ entries = await readdir(dir, { withFileTypes: true });
26
+ } catch {
27
+ continue;
28
+ }
29
+ for (const e of entries) {
30
+ const full = join(dir, e.name);
31
+ if (e.isDirectory()) {
32
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) stack.push(full);
33
+ continue;
34
+ }
35
+ if (!e.isFile()) continue;
36
+ const ext = extname(e.name).toLowerCase();
37
+ if (!TEXT_EXT.has(ext) && e.name.toLowerCase() !== 'dockerfile') continue;
38
+ let info;
39
+ try {
40
+ info = await stat(full);
41
+ } catch {
42
+ continue;
43
+ }
44
+ if (info.size > MAX_BYTES || info.size === 0) continue;
45
+ let text;
46
+ try {
47
+ text = await readFile(full, 'utf8');
48
+ } catch {
49
+ continue;
50
+ }
51
+ yield { path: relative(root, full), text };
52
+ }
53
+ }
54
+ }