dev-diagnosis-cli 0.0.0-stage → 0.1.1
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/CHANGELOG.md +23 -0
- package/LICENSE +201 -0
- package/README.md +132 -2
- package/bin/dev-diagnosis.js +401 -0
- package/examples/demo-repo/.cache/out.txt +1 -0
- package/examples/demo-repo/.env.example +1 -0
- package/examples/demo-repo/build.mjs +19 -0
- package/examples/demo-repo/data/export.json +20000 -0
- package/examples/demo-repo/dist/app.bundle.js +2 -0
- package/examples/demo-repo/logs/dev.log +2 -0
- package/examples/demo-repo/package-lock.json +1 -0
- package/examples/demo-repo/package.json +1 -0
- package/examples/demo-repo/src/index.js +1 -0
- package/examples/demo.mjs +20 -0
- package/examples/runs/run-01/results.xml +29 -0
- package/examples/runs/run-02/results.xml +22 -0
- package/lib/cache.mjs +159 -0
- package/lib/context.mjs +213 -0
- package/lib/flakes.mjs +265 -0
- package/lib/pathcheck.mjs +393 -0
- package/lib/report.mjs +18 -0
- package/lib/run-command.mjs +73 -0
- package/lib/xml.mjs +221 -0
- package/package.json +1 -6
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"lockfileVersion":3,"name":"demo-repo","packages":{"":{"name":"demo-repo","version":"0.1.0"}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"demo-repo","private":true,"type":"module","version":"0.1.0"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
console.log('demo repo: hello from src/index.js');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo: run dev-diagnosis against the fixture repo and print the report.
|
|
3
|
+
* The output is pasted verbatim into the README, so this script prints
|
|
4
|
+
* only the CLI's own output. The demo repo has real noise and a failing
|
|
5
|
+
* test on purpose, so the exit code is 1; this script exits 0 so that
|
|
6
|
+
* `npm run example` stays a demo, not a failing check.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const bin = path.join(root, 'bin', 'dev-diagnosis.js');
|
|
14
|
+
const result = spawnSync(
|
|
15
|
+
process.execPath,
|
|
16
|
+
[bin, 'all', 'examples/demo-repo', '--command', 'node', '--flakes', 'examples/runs', '--', 'node', 'build.mjs'],
|
|
17
|
+
{ cwd: root, encoding: 'utf8' },
|
|
18
|
+
);
|
|
19
|
+
process.stdout.write(result.stdout);
|
|
20
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<testsuites>
|
|
3
|
+
<testsuite name="com.example.CheckoutTest" timestamp="2026-01-05T10:00:00">
|
|
4
|
+
<testcase classname="com.example.CheckoutTest" name="appliesCoupon">
|
|
5
|
+
<flakyFailure message="expected 10% discount, got 0">
|
|
6
|
+
<stackTrace>org.opentest4j.AssertionFailedError: expected 10% discount, got 0
|
|
7
|
+
at CheckoutTest.appliesCoupon(CheckoutTest.java:42)</stackTrace>
|
|
8
|
+
</flakyFailure>
|
|
9
|
+
</testcase>
|
|
10
|
+
</testsuite>
|
|
11
|
+
<testsuite name="com.example.LoginTest" timestamp="2026-01-05T10:00:00">
|
|
12
|
+
<testcase classname="com.example.LoginTest" name="rejectsExpiredToken">
|
|
13
|
+
<failure message="expected 401, got 200">
|
|
14
|
+
<stackTrace>org.opentest4j.AssertionFailedError: expected 401, got 200</stackTrace>
|
|
15
|
+
</failure>
|
|
16
|
+
</testcase>
|
|
17
|
+
</testsuite>
|
|
18
|
+
<testsuite name="com.example.SearchTest" timestamp="2026-01-05T10:00:00">
|
|
19
|
+
<testcase classname="com.example.SearchTest" name="filtersByPrice"/>
|
|
20
|
+
</testsuite>
|
|
21
|
+
<testsuite name="com.example.CartTest" timestamp="2026-01-05T10:00:00">
|
|
22
|
+
<testcase classname="com.example.CartTest" name="addsItem"/>
|
|
23
|
+
</testsuite>
|
|
24
|
+
<testsuite name="com.example.ReportTest" timestamp="2026-01-05T10:00:00">
|
|
25
|
+
<testcase classname="com.example.ReportTest" name="exportsCsv">
|
|
26
|
+
<skipped message="CSV export not configured"/>
|
|
27
|
+
</testcase>
|
|
28
|
+
</testsuite>
|
|
29
|
+
</testsuites>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<testsuites>
|
|
3
|
+
<testsuite name="com.example.CheckoutTest" timestamp="2026-01-06T10:00:00">
|
|
4
|
+
<testcase classname="com.example.CheckoutTest" name="appliesCoupon"/>
|
|
5
|
+
</testsuite>
|
|
6
|
+
<testsuite name="com.example.LoginTest" timestamp="2026-01-06T10:00:00">
|
|
7
|
+
<testcase classname="com.example.LoginTest" name="rejectsExpiredToken">
|
|
8
|
+
<failure message="expected 401, got 200"/>
|
|
9
|
+
</testcase>
|
|
10
|
+
</testsuite>
|
|
11
|
+
<testsuite name="com.example.SearchTest" timestamp="2026-01-06T10:00:00">
|
|
12
|
+
<testcase classname="com.example.SearchTest" name="filtersByPrice"/>
|
|
13
|
+
</testsuite>
|
|
14
|
+
<testsuite name="com.example.CartTest" timestamp="2026-01-06T10:00:00">
|
|
15
|
+
<testcase classname="com.example.CartTest" name="addsItem"/>
|
|
16
|
+
</testsuite>
|
|
17
|
+
<testsuite name="com.example.ReportTest" timestamp="2026-01-06T10:00:00">
|
|
18
|
+
<testcase classname="com.example.ReportTest" name="exportsCsv">
|
|
19
|
+
<skipped message="CSV export not configured"/>
|
|
20
|
+
</testcase>
|
|
21
|
+
</testsuite>
|
|
22
|
+
</testsuites>
|
package/lib/cache.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-cache verdict.
|
|
3
|
+
*
|
|
4
|
+
* Simplified from cache-verdict (WJDdidi-1/cache-verdict, Apache-2.0).
|
|
5
|
+
* The original detects the ecosystem, hashes inputs into a content key,
|
|
6
|
+
* and keeps a ledger of stored caches. This version does the one thing
|
|
7
|
+
* the question needs and nothing else: run the same build command twice,
|
|
8
|
+
* once with the cache moved aside (cold) and once with it present
|
|
9
|
+
* (warm), time both, and say whether the cache pays. The cache directory
|
|
10
|
+
* is always restored to exactly the state it was in before the check,
|
|
11
|
+
* even when a run fails.
|
|
12
|
+
*
|
|
13
|
+
* Safety rules, enforced before anything moves:
|
|
14
|
+
* - the cache directory must live inside the project root;
|
|
15
|
+
* - it must not be the project root, and must not contain the project;
|
|
16
|
+
* - it must not be inside dev-diagnosis's own work area (there is none —
|
|
17
|
+
* the stash lives in os.tmpdir(), never in the project).
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { runCommand } from './run-command.mjs';
|
|
23
|
+
|
|
24
|
+
export const CACHE_DIR_CANDIDATES = [
|
|
25
|
+
'node_modules/.cache',
|
|
26
|
+
'.next/cache',
|
|
27
|
+
'.cache',
|
|
28
|
+
'.turbo',
|
|
29
|
+
'target',
|
|
30
|
+
'.gradle',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** First existing cache directory under root, or null. */
|
|
34
|
+
export function detectCacheDir(root) {
|
|
35
|
+
const absolute = path.resolve(root);
|
|
36
|
+
for (const candidate of CACHE_DIR_CANDIDATES) {
|
|
37
|
+
const full = path.join(absolute, candidate);
|
|
38
|
+
try {
|
|
39
|
+
if (fs.statSync(full).isDirectory()) return full;
|
|
40
|
+
} catch { /* not present — try the next candidate */ }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertCacheDirSafe(root, cacheDir) {
|
|
46
|
+
const project = path.resolve(root);
|
|
47
|
+
const resolved = path.resolve(cacheDir);
|
|
48
|
+
if (resolved === project) throw new Error('cacheDir must not point at the project root');
|
|
49
|
+
const rel = path.relative(project, resolved);
|
|
50
|
+
if (rel === '' || rel.startsWith(`..${path.sep}`) || rel === '..' || path.isAbsolute(rel)) {
|
|
51
|
+
throw new Error(`cacheDir ${resolved} is outside the project ${project}`);
|
|
52
|
+
}
|
|
53
|
+
const projectInside = path.relative(resolved, project);
|
|
54
|
+
if (projectInside !== '' && !projectInside.startsWith(`..${path.sep}`) && projectInside !== '..' && !path.isAbsolute(projectInside)) {
|
|
55
|
+
throw new Error(`cacheDir ${resolved} contains the project itself`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function movePath(from, to) {
|
|
60
|
+
fs.renameSync(from, to);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function removeTree(target) {
|
|
64
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run the build command cold (cache moved aside) and warm (cache present).
|
|
69
|
+
* Returns timings in ms and a verdict. The cache directory is restored to
|
|
70
|
+
* its original state in a finally block, whatever happens.
|
|
71
|
+
*/
|
|
72
|
+
export function runCacheCheck(options = {}) {
|
|
73
|
+
const root = path.resolve(options.root || process.cwd());
|
|
74
|
+
const command = options.command;
|
|
75
|
+
if (!Array.isArray(command) || command.length === 0) {
|
|
76
|
+
throw new Error('cache check needs a build command after --, for example: dev-diagnosis cache -- node build.mjs');
|
|
77
|
+
}
|
|
78
|
+
let cacheDir = options.cacheDir ? path.resolve(root, options.cacheDir) : detectCacheDir(root);
|
|
79
|
+
if (!cacheDir) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'no cache directory found in this project; pass --cache-dir <dir> '
|
|
82
|
+
+ `(looked for ${CACHE_DIR_CANDIDATES.join(', ')})`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
assertCacheDirSafe(root, cacheDir);
|
|
86
|
+
|
|
87
|
+
const stashRoot = path.join(
|
|
88
|
+
path.dirname(cacheDir),
|
|
89
|
+
`.dev-diagnosis-stash-${process.pid}-${randomUUID()}`,
|
|
90
|
+
);
|
|
91
|
+
// The stash lives next to the cache directory, on the same filesystem:
|
|
92
|
+
// renaming across devices (for example into os.tmpdir()) fails with
|
|
93
|
+
// EXDEV. It is removed in the finally block below.
|
|
94
|
+
fs.mkdirSync(stashRoot);
|
|
95
|
+
const originalStash = path.join(stashRoot, 'original');
|
|
96
|
+
const producedStash = path.join(stashRoot, 'produced');
|
|
97
|
+
const hadOriginal = fs.existsSync(cacheDir);
|
|
98
|
+
let movedOriginal = false;
|
|
99
|
+
let movedProduced = false;
|
|
100
|
+
let originalRestored = false;
|
|
101
|
+
|
|
102
|
+
const run = () => runCommand(command, { cwd: root, timeoutMs: options.timeoutMs });
|
|
103
|
+
let cold;
|
|
104
|
+
let warm;
|
|
105
|
+
try {
|
|
106
|
+
if (hadOriginal) { movePath(cacheDir, originalStash); movedOriginal = true; }
|
|
107
|
+
cold = run();
|
|
108
|
+
if (!cold.ok) {
|
|
109
|
+
throw new Error(`cold build failed (exit ${cold.status ?? 'unknown'}): ${(cold.stderr || cold.error || '').trim().split('\n')[0] || 'no output'}`);
|
|
110
|
+
}
|
|
111
|
+
if (hadOriginal) {
|
|
112
|
+
// Set the freshly produced cache aside; the warm run measures the
|
|
113
|
+
// cache as it was before this check.
|
|
114
|
+
if (fs.existsSync(cacheDir)) { movePath(cacheDir, producedStash); movedProduced = true; }
|
|
115
|
+
movePath(originalStash, cacheDir);
|
|
116
|
+
originalRestored = true;
|
|
117
|
+
}
|
|
118
|
+
// With no original cache, the cold run's output stays in place: it is
|
|
119
|
+
// the cache the warm run should measure.
|
|
120
|
+
warm = run();
|
|
121
|
+
if (!warm.ok) {
|
|
122
|
+
throw new Error(`warm build failed (exit ${warm.status ?? 'unknown'}): ${(warm.stderr || warm.error || '').trim().split('\n')[0] || 'no output'}`);
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
// Restore the original cache exactly. If it was already put back for
|
|
126
|
+
// the warm run it is sitting in cacheDir now and must be left alone;
|
|
127
|
+
// only an error before that point leaves it in the stash.
|
|
128
|
+
try {
|
|
129
|
+
if (movedOriginal && !originalRestored) {
|
|
130
|
+
if (fs.existsSync(cacheDir)) removeTree(cacheDir);
|
|
131
|
+
if (fs.existsSync(originalStash)) movePath(originalStash, cacheDir);
|
|
132
|
+
}
|
|
133
|
+
if (movedProduced && fs.existsSync(producedStash)) removeTree(producedStash);
|
|
134
|
+
} catch { /* best effort: never let cleanup hide the real result */ }
|
|
135
|
+
try { removeTree(stashRoot); } catch { /* best effort */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const coldMs = cold.ms;
|
|
139
|
+
const warmMs = warm.ms;
|
|
140
|
+
const savedMs = coldMs - warmMs;
|
|
141
|
+
const savedPct = coldMs > 0 ? Math.round((savedMs / coldMs) * 100) : 0;
|
|
142
|
+
let verdict;
|
|
143
|
+
let verdictDetail;
|
|
144
|
+
if (savedMs >= 50 && savedPct >= 10) {
|
|
145
|
+
verdict = 'cache pays';
|
|
146
|
+
verdictDetail = `warm run is ${savedMs.toFixed(0)} ms (${savedPct}%) faster than cold`;
|
|
147
|
+
} else if (savedMs <= -50 && savedPct <= -10) {
|
|
148
|
+
verdict = 'cache costs';
|
|
149
|
+
verdictDetail = `warm run is ${(-savedMs).toFixed(0)} ms (${-savedPct}%) slower than cold — the cache is not helping this build`;
|
|
150
|
+
} else {
|
|
151
|
+
verdict = 'inconclusive';
|
|
152
|
+
verdictDetail = 'cold and warm runs are within noise of each other; rerun to confirm';
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
root, cacheDir, command,
|
|
156
|
+
coldMs, warmMs, savedMs, savedPct, verdict, verdictDetail,
|
|
157
|
+
coldStdout: cold.stdout, warmStdout: warm.stdout,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/lib/context.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-noise check.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from ai-context-doctor (WJDdidi-1/ai-context-doctor, Apache-2.0):
|
|
5
|
+
* the same classification rules and the same category-weighted score — build
|
|
6
|
+
* output costs at most 40 points, large files 25, lockfiles 20, logs 15 —
|
|
7
|
+
* so one pathological file cannot speak for the whole repository. The
|
|
8
|
+
* five-dimension score, .aiignore levels and policy handling from the
|
|
9
|
+
* original are deliberately left out: this is the small, single-purpose
|
|
10
|
+
* version that answers "what should an AI agent not be handed".
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const SECRET_LIKE = /(^\.env(\..+)?$)|(\.env$)|(^credentials(\..+)?$)|(^secrets?(\..+)?$)|(\.pem$)|(\.key$)/i;
|
|
16
|
+
const TEMPLATE = /\.(example|sample|template|dist)$/i;
|
|
17
|
+
|
|
18
|
+
export const IGNORED_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', 'vendor']);
|
|
19
|
+
|
|
20
|
+
export const NOISE_DIRS = new Map([
|
|
21
|
+
['dist', 'generated build output'],
|
|
22
|
+
['build', 'generated build output'],
|
|
23
|
+
['coverage', 'test coverage output'],
|
|
24
|
+
['.next', 'framework output'],
|
|
25
|
+
['.turbo', 'framework cache'],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const LOCKFILES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']);
|
|
29
|
+
|
|
30
|
+
export const LARGE_FILE_THRESHOLD_BYTES = 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
export const CATEGORY_WEIGHTS = [
|
|
33
|
+
{ id: 'build', label: 'build output', weight: 40 },
|
|
34
|
+
{ id: 'large', label: 'large files', weight: 25 },
|
|
35
|
+
{ id: 'lockfile', label: 'lockfiles', weight: 20 },
|
|
36
|
+
{ id: 'log', label: 'logs', weight: 15 },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function isSecretLikeFile(basename) {
|
|
40
|
+
return SECRET_LIKE.test(basename);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isTemplateFile(basename) {
|
|
44
|
+
return TEMPLATE.test(basename);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatBytes(bytes) {
|
|
48
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
49
|
+
const units = ['KB', 'MB', 'GB'];
|
|
50
|
+
let value = bytes / 1024;
|
|
51
|
+
let unit = units[0];
|
|
52
|
+
for (let i = 1; i < units.length && value >= 1024; i += 1) {
|
|
53
|
+
value /= 1024;
|
|
54
|
+
unit = units[i];
|
|
55
|
+
}
|
|
56
|
+
return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function noiseCategory(reason) {
|
|
60
|
+
if (reason === 'lockfile') return 'lockfile';
|
|
61
|
+
if (reason === 'local log file') return 'log';
|
|
62
|
+
if (reason === 'generated build output' || reason === 'test coverage output'
|
|
63
|
+
|| reason === 'framework output' || reason === 'framework cache') return 'build';
|
|
64
|
+
return 'large';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function scoreNoise(noise, totalBytes) {
|
|
68
|
+
const bytesByCategory = new Map();
|
|
69
|
+
for (const item of noise) {
|
|
70
|
+
const id = noiseCategory(item.reason);
|
|
71
|
+
bytesByCategory.set(id, (bytesByCategory.get(id) || 0) + item.bytes);
|
|
72
|
+
}
|
|
73
|
+
const breakdown = CATEGORY_WEIGHTS.map(({ id, label, weight }) => {
|
|
74
|
+
const bytes = bytesByCategory.get(id) || 0;
|
|
75
|
+
const share = totalBytes > 0 ? bytes / totalBytes : 0;
|
|
76
|
+
const lost = Math.min(weight, weight * share);
|
|
77
|
+
return { id, label, weight, bytes, share: Math.round(share * 1000) / 10, lost: Math.round(lost * 10) / 10 };
|
|
78
|
+
});
|
|
79
|
+
const lost = Math.round(breakdown.reduce((sum, item) => sum + item.lost, 0) * 10) / 10;
|
|
80
|
+
return { score: Math.max(0, Math.min(100, Math.round(100 - lost))), lost, breakdown };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function statusFor(score) {
|
|
84
|
+
if (score >= 85) return 'Looks clean for an AI coding agent';
|
|
85
|
+
if (score >= 60) return 'Some context noise found';
|
|
86
|
+
return 'Too much noise for an AI coding agent';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Classify one path without touching the filesystem. The relative path is
|
|
91
|
+
* normalised to forward slashes first, so a Windows path and a POSIX path
|
|
92
|
+
* classify the same way.
|
|
93
|
+
*/
|
|
94
|
+
export function classifyPath(relativePath, size = 0) {
|
|
95
|
+
const normalized = String(relativePath).split(path.sep).join('/').split('\\').join('/');
|
|
96
|
+
const parts = normalized.split('/');
|
|
97
|
+
const basename = parts[parts.length - 1];
|
|
98
|
+
|
|
99
|
+
for (const part of parts.slice(0, -1)) {
|
|
100
|
+
if (NOISE_DIRS.has(part)) {
|
|
101
|
+
return { noise: { label: `${part}/`, reason: NOISE_DIRS.get(part) }, suggestion: `${part}/` };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (LOCKFILES.has(basename)) {
|
|
105
|
+
return { noise: { label: normalized, reason: 'lockfile' }, suggestion: basename };
|
|
106
|
+
}
|
|
107
|
+
if (basename.endsWith('.log')) {
|
|
108
|
+
return { noise: { label: normalized, reason: 'local log file' }, suggestion: '*.log' };
|
|
109
|
+
}
|
|
110
|
+
if (size > LARGE_FILE_THRESHOLD_BYTES) {
|
|
111
|
+
return { noise: { label: normalized, reason: 'large file' }, suggestion: null };
|
|
112
|
+
}
|
|
113
|
+
if (isTemplateFile(basename)) {
|
|
114
|
+
return { review: { label: normalized, reason: 'environment template (usually safe to share)' }, suggestion: null };
|
|
115
|
+
}
|
|
116
|
+
if (isSecretLikeFile(basename)) {
|
|
117
|
+
return {
|
|
118
|
+
review: { label: normalized, reason: 'environment-like file' },
|
|
119
|
+
suggestion: basename.startsWith('.env') ? '.env*' : basename,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (/deploy/i.test(normalized)) {
|
|
123
|
+
return { review: { label: normalized, reason: 'deployment script' }, suggestion: null };
|
|
124
|
+
}
|
|
125
|
+
return { suggestion: null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function walk(root, dir, out, state) {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
132
|
+
} catch {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Sort entries so two runs over the same tree report in the same order on
|
|
136
|
+
// every platform; directory listing order is not guaranteed.
|
|
137
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const full = path.join(dir, entry.name);
|
|
140
|
+
// Always report forward slashes, so output (and tests) are identical
|
|
141
|
+
// on Windows, macOS and Linux.
|
|
142
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
145
|
+
walk(root, full, out, state);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!entry.isFile()) continue;
|
|
149
|
+
if (state.files >= state.maxFiles) {
|
|
150
|
+
state.truncated = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
let size = 0;
|
|
154
|
+
try {
|
|
155
|
+
size = fs.statSync(full).size;
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
state.files += 1;
|
|
160
|
+
state.bytes += size;
|
|
161
|
+
out.push({ relativePath: rel, size });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Scan a directory and describe how much of it is context noise.
|
|
167
|
+
* Synchronous and dependency-free, like the original. `maxFiles` caps the
|
|
168
|
+
* walk; a capped walk reports `truncated: true` instead of silently
|
|
169
|
+
* under-counting.
|
|
170
|
+
*/
|
|
171
|
+
export function scanDirectory(root, options = {}) {
|
|
172
|
+
const absolute = path.resolve(root);
|
|
173
|
+
let stat;
|
|
174
|
+
try {
|
|
175
|
+
stat = fs.statSync(absolute);
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error(`directory not found: ${root}`);
|
|
178
|
+
}
|
|
179
|
+
if (!stat.isDirectory()) throw new Error(`not a directory: ${root}`);
|
|
180
|
+
const maxFiles = options.maxFiles == null ? 10000 : options.maxFiles;
|
|
181
|
+
const state = { files: 0, bytes: 0, maxFiles, truncated: false };
|
|
182
|
+
const files = [];
|
|
183
|
+
walk(absolute, absolute, files, state);
|
|
184
|
+
|
|
185
|
+
const noise = [];
|
|
186
|
+
const review = [];
|
|
187
|
+
const suggestions = new Set();
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
const result = classifyPath(file.relativePath, file.size);
|
|
190
|
+
if (result.noise) {
|
|
191
|
+
noise.push({ ...result.noise, bytes: file.size, path: file.relativePath.split(path.sep).join('/') });
|
|
192
|
+
if (result.suggestion) suggestions.add(result.suggestion);
|
|
193
|
+
} else if (result.review) {
|
|
194
|
+
review.push({ ...result.review, bytes: file.size, path: file.relativePath.split(path.sep).join('/') });
|
|
195
|
+
if (result.suggestion) suggestions.add(result.suggestion);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const scored = scoreNoise(noise, state.bytes);
|
|
200
|
+
return {
|
|
201
|
+
root: absolute,
|
|
202
|
+
files: state.files,
|
|
203
|
+
bytes: state.bytes,
|
|
204
|
+
truncated: state.truncated,
|
|
205
|
+
noise,
|
|
206
|
+
review,
|
|
207
|
+
suggestions: [...suggestions].sort(),
|
|
208
|
+
score: scored.score,
|
|
209
|
+
lost: scored.lost,
|
|
210
|
+
breakdown: scored.breakdown,
|
|
211
|
+
status: statusFor(scored.score),
|
|
212
|
+
};
|
|
213
|
+
}
|