ancient-fences 0.3.1 → 0.4.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 +10 -6
- package/bin/ancient-fences.mjs +34 -5
- package/package.json +1 -1
- package/src/name.mjs +52 -0
- package/src/report.mjs +57 -9
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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%">
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Marcin1000/ancient-fences/main/assets/readme-banner.png">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Marcin1000/ancient-fences/main/assets/readme-banner-light.png" alt="Ancient Fences" width="100%">
|
|
4
4
|
</picture>
|
|
5
5
|
|
|
6
6
|
# Ancient Fences
|
|
@@ -198,9 +198,13 @@ pull requests.
|
|
|
198
198
|
## Ancient Code
|
|
199
199
|
|
|
200
200
|
Ancient Fences answers one question about one repository. The same blindness
|
|
201
|
-
applies to everything else nobody re-checks in a long-running codebase:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
applies to everything else nobody re-checks in a long-running codebase: which
|
|
202
|
+
parts only one person understands, whether it builds from scratch, whether the
|
|
203
|
+
documentation still describes the system. [Ancient Code](https://ancientcode.net)
|
|
204
|
+
asks all of them, ships this scanner inside it, and is free and open source too:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npx ancient-code .
|
|
208
|
+
```
|
|
205
209
|
|
|
206
210
|
MIT licensed.
|
package/bin/ancient-fences.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { detectFences } from '../src/detect.mjs';
|
|
|
8
8
|
import { blameAll, historyDepth } from '../src/age.mjs';
|
|
9
9
|
import { checkGithubRefs, verdict } from '../src/tracker.mjs';
|
|
10
10
|
import { renderText, renderHtml, renderTasks, summarize } from '../src/report.mjs';
|
|
11
|
+
import { projectName } from '../src/name.mjs';
|
|
11
12
|
import { readInstalled } from '../src/lockfile.mjs';
|
|
12
13
|
|
|
13
14
|
const args = process.argv.slice(2);
|
|
@@ -72,6 +73,22 @@ if (positional.length > 1) {
|
|
|
72
73
|
process.exit(2);
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
const days = value('--max-age-days');
|
|
77
|
+
if (days !== null && (!Number.isFinite(Number(days)) || Number(days) < 0)) {
|
|
78
|
+
console.error(`ancient-fences: --max-age-days needs a number of days that is zero or more, not "${days}"`);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// An empty value is almost always a shell mishap (--report=$FILE with FILE
|
|
83
|
+
// unset). Writing to the current directory, or crashing on it, are both worse
|
|
84
|
+
// than saying what happened.
|
|
85
|
+
for (const f of ['--report', '--tasks', '--cache', '--api-base']) {
|
|
86
|
+
if (flags.includes(`${f}=`)) {
|
|
87
|
+
console.error(`ancient-fences: ${f}= needs a value`);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
const target = resolve(positional[0] ?? process.cwd());
|
|
76
93
|
|
|
77
94
|
// Scanning a path that is not there used to print a clean report saying zero
|
|
@@ -148,20 +165,32 @@ summary.skippedFiles = skipped;
|
|
|
148
165
|
summary.history = history;
|
|
149
166
|
summary.checkedAt = checking ? checkTimestamp(states) : null;
|
|
150
167
|
|
|
151
|
-
|
|
168
|
+
// Scanning one file, its own name is the subject. Scanning a repository, the
|
|
169
|
+
// folder is the last resort: a full clone of webpack in webpackfull/ produced
|
|
170
|
+
// a report titled "webpackfull".
|
|
171
|
+
const name = single ? basename(single) : await projectName(root);
|
|
152
172
|
|
|
153
173
|
const reportFlag = flags.find((f) => f === '--report' || f.startsWith('--report='));
|
|
154
174
|
if (reportFlag) {
|
|
155
175
|
const out = resolve(value('--report') ?? 'ancient-fences.html');
|
|
156
|
-
await
|
|
157
|
-
console.error(`report written to ${out}`);
|
|
176
|
+
await write(out, renderHtml(fences, summary, name, checking), 'report');
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
const tasksFlag = flags.find((f) => f === '--tasks' || f.startsWith('--tasks='));
|
|
161
180
|
if (tasksFlag) {
|
|
162
181
|
const out = resolve(value('--tasks') ?? 'ancient-fences-tasks.md');
|
|
163
|
-
await
|
|
164
|
-
|
|
182
|
+
await write(out, renderTasks(fences, name, checking), 'agent tasks');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A file that cannot be written is reported as such, not as a stack trace. */
|
|
186
|
+
async function write(out, body, what) {
|
|
187
|
+
try {
|
|
188
|
+
await writeFile(out, body, 'utf8');
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(`ancient-fences: could not write ${out}: ${err.message}`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
console.error(`${what} written to ${out}`);
|
|
165
194
|
}
|
|
166
195
|
|
|
167
196
|
if (has('--json')) {
|
package/package.json
CHANGED
package/src/name.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join, basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What to call the repository in a report somebody forwards.
|
|
10
|
+
*
|
|
11
|
+
* The folder name is the worst of the three answers and used to be the only
|
|
12
|
+
* one: a full clone of webpack sitting in webpackfull/ produced a report
|
|
13
|
+
* titled "webpackfull". The remote knows the real name, the manifest knows the
|
|
14
|
+
* published one, and the folder is only the last resort.
|
|
15
|
+
*/
|
|
16
|
+
export async function projectName(root) {
|
|
17
|
+
const remote = await gitRemote(root);
|
|
18
|
+
if (remote) return remote;
|
|
19
|
+
|
|
20
|
+
const pkg = await readJson(join(root, 'package.json'));
|
|
21
|
+
if (pkg?.name) return pkg.name;
|
|
22
|
+
|
|
23
|
+
return basename(root);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readJson(path) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function gitRemote(root) {
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await run('git', ['remote', 'get-url', 'origin'], { cwd: root });
|
|
37
|
+
return ownerRepo(stdout.trim());
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "git@github.com:webpack/webpack.git" and the https form both give webpack/webpack. */
|
|
44
|
+
export function ownerRepo(url) {
|
|
45
|
+
if (!url) return null;
|
|
46
|
+
const clean = String(url).trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
|
47
|
+
const m = /[:/]([^/:]+)\/([^/]+)$/.exec(clean);
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
// A local path clone has no owner worth printing, only a directory above it.
|
|
50
|
+
if (/^(\.|\/|[a-z]:\\)/i.test(clean) || clean.startsWith('file:')) return null;
|
|
51
|
+
return `${m[1]}/${m[2]}`;
|
|
52
|
+
}
|
package/src/report.mjs
CHANGED
|
@@ -149,6 +149,11 @@ export function renderText(fences, summary, repoName, checked = false) {
|
|
|
149
149
|
for (const [level, count] of Object.entries(summary.verdicts).sort((a, b) => b[1] - a[1])) {
|
|
150
150
|
L.push(` ${String(count).padStart(4)} ${level}`);
|
|
151
151
|
}
|
|
152
|
+
// "unchecked" on its own reads as a bug in this tool. The reason was
|
|
153
|
+
// recorded and then thrown away before anybody could see it.
|
|
154
|
+
for (const [why, count] of unreachable(fences)) {
|
|
155
|
+
L.push(` ${count} of them because the tracker could not answer: ${why}`);
|
|
156
|
+
}
|
|
152
157
|
L.push('');
|
|
153
158
|
}
|
|
154
159
|
|
|
@@ -192,6 +197,22 @@ export function renderText(fences, summary, repoName, checked = false) {
|
|
|
192
197
|
return L.join('\n');
|
|
193
198
|
}
|
|
194
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Why the tracker could not answer, grouped and counted. Rate limiting, no
|
|
202
|
+
* network and a repository that needs a token all land here, and each one is
|
|
203
|
+
* something the reader can act on. Silence is not.
|
|
204
|
+
*/
|
|
205
|
+
export function unreachable(fences) {
|
|
206
|
+
const reasons = new Map();
|
|
207
|
+
for (const f of fences) {
|
|
208
|
+
const why = f.verdict?.why;
|
|
209
|
+
if (f.verdict?.level !== 'unchecked' || !why) continue;
|
|
210
|
+
if (/no recorded reason/.test(why)) continue;
|
|
211
|
+
reasons.set(why, (reasons.get(why) ?? 0) + 1);
|
|
212
|
+
}
|
|
213
|
+
return [...reasons.entries()].sort((a, b) => b[1] - a[1]);
|
|
214
|
+
}
|
|
215
|
+
|
|
195
216
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
196
217
|
|
|
197
218
|
/**
|
|
@@ -212,10 +233,10 @@ export function renderHtml(fences, summary, repoName, checked = false) {
|
|
|
212
233
|
const y = yearsSince(f.lastTouched);
|
|
213
234
|
const v = f.verdict;
|
|
214
235
|
return `<tr>
|
|
215
|
-
<td class="num">${y === null ? '-' : y.toFixed(1) + ' yr'}</td>
|
|
216
|
-
<td><code>${esc(f.file)}:${f.line}</code><p>${esc(f.text.slice(0, 130))}</p></td>
|
|
217
|
-
<td class="num">${esc(premiseOf(f))}</td>
|
|
218
|
-
<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>
|
|
236
|
+
<td data-label="Untouched" class="num">${y === null ? '-' : y.toFixed(1) + ' yr'}</td>
|
|
237
|
+
<td data-label="Where"><code>${esc(f.file)}:${f.line}</code><p>${esc(f.text.slice(0, 130))}</p></td>
|
|
238
|
+
<td data-label="Reason given" class="num">${esc(premiseOf(f))}</td>
|
|
239
|
+
<td data-label="${checked ? 'Verdict' : 'State'}">${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>
|
|
219
240
|
</tr>`;
|
|
220
241
|
}).join('\n');
|
|
221
242
|
|
|
@@ -224,8 +245,8 @@ export function renderHtml(fences, summary, repoName, checked = false) {
|
|
|
224
245
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
225
246
|
<title>Ancient Fences: ${esc(repoName)}</title>
|
|
226
247
|
<style>
|
|
227
|
-
:root{--ground:#0F1216;--surface:#171C22;--surface2:#1E242B;--line:#2B333B;--ink:#E7E1D4;--dim:#A7A69C;--muted:#
|
|
228
|
-
@media (prefers-color-scheme:light){:root{--ground:#E3E2DC;--surface:#EDEBE4;--surface2:#F3F1EB;--line:#CFCCC1;--ink:#1A1E22;--dim:#4A4F53;--muted:#
|
|
248
|
+
:root{--ground:#0F1216;--surface:#171C22;--surface2:#1E242B;--line:#2B333B;--ink:#E7E1D4;--dim:#A7A69C;--muted:#878B83;--gold:#E0A45C;--inst:#8FC3D2}
|
|
249
|
+
@media (prefers-color-scheme:light){:root{--ground:#E3E2DC;--surface:#EDEBE4;--surface2:#F3F1EB;--line:#CFCCC1;--ink:#1A1E22;--dim:#4A4F53;--muted:#62665F;--gold:#8B5514;--inst:#2E6B7C}}
|
|
229
250
|
*{box-sizing:border-box}
|
|
230
251
|
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}
|
|
231
252
|
.wrap{max-width:68rem;margin:0 auto;padding:0 clamp(1rem,4vw,2.5rem)}
|
|
@@ -239,8 +260,12 @@ h1{font:300 clamp(2rem,5vw,3.2rem)/1.05 ui-serif,Georgia,serif;letter-spacing:-.
|
|
|
239
260
|
.stat b{font:400 2rem/1 ui-monospace,monospace;color:var(--gold);font-variant-numeric:tabular-nums}
|
|
240
261
|
.stat span{font-size:.78rem;color:var(--dim);line-height:1.35}
|
|
241
262
|
h2{font:300 1.6rem/1.1 ui-serif,Georgia,serif;margin:2.5rem 0 1rem}
|
|
263
|
+
.brand{display:flex;align-items:center;gap:.6rem;color:var(--ink);margin-bottom:1.4rem}
|
|
264
|
+
.brand span{font:300 1.2rem/1 ui-serif,Georgia,serif;transform:translateY(.09em)}
|
|
265
|
+
.brand b{color:var(--gold);font-weight:300}
|
|
242
266
|
.scroll{overflow-x:auto;border:1px solid var(--line);background:var(--surface)}
|
|
243
267
|
table{border-collapse:collapse;width:100%;min-width:40rem;font-size:.88rem;table-layout:fixed}
|
|
268
|
+
}
|
|
244
269
|
th,td{text-align:left;padding:.8rem 1rem;border-bottom:1px solid var(--line);vertical-align:top}
|
|
245
270
|
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}
|
|
246
271
|
td p{margin:.35rem 0 0;color:var(--dim);font-size:.82rem}
|
|
@@ -254,9 +279,31 @@ code{font-size:.82rem;color:var(--ink)}
|
|
|
254
279
|
footer{border-top:1px solid var(--line);margin-top:3rem;padding:2rem 0 4rem;color:var(--muted);font-size:.85rem}
|
|
255
280
|
footer strong{color:var(--ink)}
|
|
256
281
|
footer p{max-width:62ch}
|
|
282
|
+
/* Four columns of file paths never fit on a phone. Forced side by side, one
|
|
283
|
+
column collapses to nothing and its text prints a letter per line. Below this
|
|
284
|
+
width every row becomes a small block with its label in front. */
|
|
285
|
+
@media (max-width:640px){
|
|
286
|
+
.scroll{overflow-x:visible}
|
|
287
|
+
table,tbody{display:block;width:100%;min-width:0}
|
|
288
|
+
thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
289
|
+
tr{display:flex;flex-wrap:wrap;border-bottom:1px solid var(--line);padding:.55rem .2rem}
|
|
290
|
+
tr:last-child{border-bottom:0}
|
|
291
|
+
td{display:block;flex:1 1 100%;min-width:0;border-bottom:0;padding:.3rem 1rem;overflow-wrap:anywhere}
|
|
292
|
+
td.num{flex:0 0 auto;min-width:7rem;max-width:100%;white-space:normal}
|
|
293
|
+
td[data-label]::before{display:block;content:attr(data-label);font-family:ui-monospace,monospace;font-size:.6rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:.15rem}
|
|
294
|
+
}
|
|
257
295
|
</style></head><body>
|
|
258
296
|
<header><div class="wrap">
|
|
259
|
-
<
|
|
297
|
+
<div class="brand">
|
|
298
|
+
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
|
299
|
+
<rect x="2.6" y="3.2" width="18.8" height="2.4" fill="currentColor"/>
|
|
300
|
+
<rect x="5.6" y="7.4" width="2.4" height="13.4" fill="currentColor"/>
|
|
301
|
+
<rect x="10.8" y="7.4" width="2.4" height="13.4" fill="currentColor"/>
|
|
302
|
+
<rect x="16" y="7.4" width="2.4" height="13.4" fill="currentColor"/>
|
|
303
|
+
<rect x="3.4" y="12.6" width="17.2" height="1.8" fill="var(--gold)"/>
|
|
304
|
+
</svg><span>Ancient <b>Fences</b></span>
|
|
305
|
+
</div>
|
|
306
|
+
<p class="mono">Scanned ${esc(new Date().toISOString().slice(0, 10))}</p>
|
|
260
307
|
<h1>${esc(repoName)}</h1>
|
|
261
308
|
<p class="sub">Code that exists because of an external problem, and whether that problem is still there.</p>
|
|
262
309
|
</div></header>
|
|
@@ -272,6 +319,7 @@ footer p{max-width:62ch}
|
|
|
272
319
|
${summary.history && summary.history.usable === false ? `<p class="sub">Age was not measured: ${esc(summary.history.why)}.</p>` : ''}
|
|
273
320
|
${summary.checkedAt ? `<p class="sub">Issue states read ${esc(summary.checkedAt.newest.slice(0, 10))}.</p>` : ''}
|
|
274
321
|
${!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>` : ''}
|
|
322
|
+
${checked && unreachable(fences).length ? `<p class="sub">The tracker could not answer for ${unreachable(fences).reduce((a, [, n]) => a + n, 0)} of these: ${esc(unreachable(fences).map(([why, n]) => `${n} × ${why}`).join(', '))}. Their state is unknown, which is not the same as still valid.</p>` : ''}
|
|
275
323
|
${summary.total === 0 ? `<h2>Nothing found</h2>
|
|
276
324
|
<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>
|
|
277
325
|
<div class="scroll"><table>
|
|
@@ -282,13 +330,13 @@ footer p{max-width:62ch}
|
|
|
282
330
|
${fences.length ? `<h2>Where they are</h2>
|
|
283
331
|
<div class="scroll"><table>
|
|
284
332
|
<thead><tr><th>File</th><th>Fences</th><th>Kinds</th></tr></thead>
|
|
285
|
-
<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>
|
|
333
|
+
<tbody>${byFile(fences, 15).map((r) => `<tr><td data-label="File"><code>${esc(r.file)}</code></td><td data-label="Fences" class="num">${r.total}</td><td data-label="Kinds" class="num">${esc(Object.entries(r.kinds).map(([k, n]) => `${n} ${k}`).join(', '))}</td></tr>`).join('\n')}</tbody>
|
|
286
334
|
</table></div>` : ''}
|
|
287
335
|
${summary.skipped ? `<h2>Left out</h2>
|
|
288
336
|
<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>
|
|
289
337
|
<div class="scroll"><table>
|
|
290
338
|
<thead><tr><th>File</th><th>Why</th></tr></thead>
|
|
291
|
-
<tbody>${(summary.skippedFiles ?? []).map((f) => `<tr><td><code>${esc(f.path)}</code></td><td class="num">${esc(f.why)}</td></tr>`).join('\n')}</tbody>
|
|
339
|
+
<tbody>${(summary.skippedFiles ?? []).map((f) => `<tr><td data-label="File"><code>${esc(f.path)}</code></td><td data-label="Why" class="num">${esc(f.why)}</td></tr>`).join('\n')}</tbody>
|
|
292
340
|
</table></div>` : ''}
|
|
293
341
|
</main>
|
|
294
342
|
<footer><div class="wrap">
|