vibecodedshit 0.0.0-stage → 0.3.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 +95 -0
- package/README.md +73 -2
- package/assets/fonts/NotoEmoji-OFL.txt +93 -0
- package/assets/fonts/NotoEmoji-Poop.ttf +0 -0
- package/assets/fonts/SpaceGrotesk-Bold.ttf +0 -0
- package/assets/fonts/SpaceGrotesk-OFL.txt +93 -0
- package/dist/bin.js +11 -0
- package/dist/card/previous.js +38 -0
- package/dist/card/render.js +71 -0
- package/dist/cli.js +138 -0
- package/dist/commands/card.js +69 -0
- package/dist/commands/common.js +1 -0
- package/dist/commands/diff.js +54 -0
- package/dist/commands/footer.js +28 -0
- package/dist/commands/report.js +49 -0
- package/dist/commands/route.js +62 -0
- package/dist/commands/submit.js +125 -0
- package/dist/commands/summary.js +77 -0
- package/dist/diff/compare.js +73 -0
- package/dist/diff/conditions.js +87 -0
- package/dist/diff/format.js +23 -0
- package/dist/exec/docker.js +158 -0
- package/dist/exec/findings.js +73 -0
- package/dist/exec/plan.js +50 -0
- package/dist/exec/reports.js +90 -0
- package/dist/exec/roots.js +49 -0
- package/dist/exec/run.js +182 -0
- package/dist/exec/steps/build.js +45 -0
- package/dist/exec/steps/coverage-install.js +12 -0
- package/dist/exec/steps/install.js +41 -0
- package/dist/exec/steps/knip.js +28 -0
- package/dist/exec/steps/lint.js +25 -0
- package/dist/exec/steps/lockfiles.js +25 -0
- package/dist/exec/steps/test.js +17 -0
- package/dist/exec/steps/typecheck.js +40 -0
- package/dist/exec/test-reports/coverage-py.js +23 -0
- package/dist/exec/test-reports/coverage-ranges.js +65 -0
- package/dist/exec/test-reports/coverage-summary.js +23 -0
- package/dist/exec/test-reports/jest-json.js +31 -0
- package/dist/exec/test-reports/junit.js +24 -0
- package/dist/exec/test-reports/mocha-json.js +18 -0
- package/dist/exec/test-reports/vitest-json.js +5 -0
- package/dist/exec/test-runners/detect.js +99 -0
- package/dist/exec/test-runners/index.js +6 -0
- package/dist/exec/test-runners/jest.js +27 -0
- package/dist/exec/test-runners/mocha.js +28 -0
- package/dist/exec/test-runners/node-test.js +24 -0
- package/dist/exec/test-runners/pytest.js +18 -0
- package/dist/exec/test-runners/script.js +48 -0
- package/dist/exec/test-runners/spec.js +1 -0
- package/dist/exec/test-runners/vitest.js +28 -0
- package/dist/exec/tests.js +120 -0
- package/dist/exec/types.js +1 -0
- package/dist/interactive/after-scan.js +121 -0
- package/dist/interactive/docker-setup.js +97 -0
- package/dist/interactive/prompt.js +17 -0
- package/dist/interactive/system.js +10 -0
- package/dist/manifest/package-json.js +36 -0
- package/dist/manifest/python.js +106 -0
- package/dist/measure/d3/index.js +45 -0
- package/dist/measure/d3/presets.js +100 -0
- package/dist/measure/d3/py/adapter.js +67 -0
- package/dist/measure/d3/py/config.js +80 -0
- package/dist/measure/d3/py/contracts.js +57 -0
- package/dist/measure/d3/py/domain.js +57 -0
- package/dist/measure/d3/py/graph.js +214 -0
- package/dist/measure/d3/py/interpreter.js +31 -0
- package/dist/measure/d3/py/layers.js +120 -0
- package/dist/measure/d3/py/roots.js +58 -0
- package/dist/measure/d3/py/types.js +1 -0
- package/dist/measure/d3/scc.js +64 -0
- package/dist/measure/d3/ts/adapter.js +371 -0
- package/dist/measure/d3/ts/classify.js +107 -0
- package/dist/measure/d3/ts/config.js +318 -0
- package/dist/measure/d3/ts/domain.js +23 -0
- package/dist/measure/d3/ts/rules.js +65 -0
- package/dist/measure/d3/types.js +5 -0
- package/dist/measure/deps/cvss.js +66 -0
- package/dist/measure/deps/declared.js +195 -0
- package/dist/measure/deps/files.js +40 -0
- package/dist/measure/deps/lockfiles.js +308 -0
- package/dist/measure/deps/osv.js +122 -0
- package/dist/measure/deps/registry.js +142 -0
- package/dist/measure/deps/resolved.js +78 -0
- package/dist/measure/deps/units.js +5 -0
- package/dist/measure/eslint/health-plugin.js +19 -0
- package/dist/measure/ops/docs-commands.js +73 -0
- package/dist/measure/ops/docs-resolve.js +217 -0
- package/dist/measure/ops/entry-points.js +49 -0
- package/dist/measure/ops/env-example.js +20 -0
- package/dist/measure/ops/env-reads.js +46 -0
- package/dist/measure/ops/runtime.js +73 -0
- package/dist/measure/p1-1.js +8 -0
- package/dist/measure/p1-2.js +8 -0
- package/dist/measure/p1-3.js +8 -0
- package/dist/measure/p1-4.js +41 -0
- package/dist/measure/p1-5.js +59 -0
- package/dist/measure/p2-1.js +8 -0
- package/dist/measure/p2-2.js +44 -0
- package/dist/measure/p2-3.js +81 -0
- package/dist/measure/p2-4.js +288 -0
- package/dist/measure/p2-5.js +8 -0
- package/dist/measure/p3-1.js +16 -0
- package/dist/measure/p3-2.js +26 -0
- package/dist/measure/p3-3.js +37 -0
- package/dist/measure/p3-4.js +26 -0
- package/dist/measure/p3-5.js +26 -0
- package/dist/measure/p4-1.js +184 -0
- package/dist/measure/p4-2.js +27 -0
- package/dist/measure/p4-3.js +106 -0
- package/dist/measure/p4-4.js +309 -0
- package/dist/measure/p5-1.js +117 -0
- package/dist/measure/p5-2.js +80 -0
- package/dist/measure/p5-3.js +21 -0
- package/dist/measure/p5-4.js +161 -0
- package/dist/measure/p5-5.js +158 -0
- package/dist/measure/p6-1.js +20 -0
- package/dist/measure/p6-2.js +20 -0
- package/dist/measure/p6-3.js +40 -0
- package/dist/measure/p6-4.js +31 -0
- package/dist/measure/p6-5.js +60 -0
- package/dist/measure/p6-6.js +54 -0
- package/dist/measure/py/typecheck-config.js +95 -0
- package/dist/measure/registry.js +31 -0
- package/dist/measure/safety/py-tests.js +124 -0
- package/dist/measure/safety/ts-barrels.js +68 -0
- package/dist/measure/safety/ts-cases.js +111 -0
- package/dist/measure/safety/ts-credits.js +101 -0
- package/dist/measure/safety/ts-imports.js +63 -0
- package/dist/measure/safety/ts-resolve.js +43 -0
- package/dist/measure/ts/directives.js +37 -0
- package/dist/measure/ts/exports.js +60 -0
- package/dist/measure/ts/tsconfig.js +179 -0
- package/dist/measure/types.js +1 -0
- package/dist/measure/units.js +41 -0
- package/dist/methodology/load.js +159 -0
- package/dist/methodology/schema.js +105 -0
- package/dist/python/deadcode.js +27 -0
- package/dist/python/env.js +13 -0
- package/dist/python/helper.js +24 -0
- package/dist/python/imports.js +40 -0
- package/dist/python/nox.js +16 -0
- package/dist/python/scope.js +35 -0
- package/dist/python/tests.js +19 -0
- package/dist/python/typing.js +27 -0
- package/dist/report/escape.js +5 -0
- package/dist/report/format.js +75 -0
- package/dist/report/render.js +213 -0
- package/dist/report/roadmap.js +58 -0
- package/dist/report/scares.js +83 -0
- package/dist/report/style.js +27 -0
- package/dist/report-only/hotspots.js +48 -0
- package/dist/route/check.js +37 -0
- package/dist/route/files.js +74 -0
- package/dist/route/order.js +22 -0
- package/dist/route/select.js +114 -0
- package/dist/route/write.js +13 -0
- package/dist/run/read.js +89 -0
- package/dist/run/recompute.js +19 -0
- package/dist/run/scan.js +196 -0
- package/dist/run/schema.js +1 -0
- package/dist/run/score-run.js +42 -0
- package/dist/run/write.js +4 -0
- package/dist/scope/build.js +173 -0
- package/dist/scope/packages.js +87 -0
- package/dist/scope/rules.js +21 -0
- package/dist/scope/tracked.js +34 -0
- package/dist/scope/ts-lines.js +63 -0
- package/dist/scope/types.js +7 -0
- package/dist/scoring/aggregate.js +38 -0
- package/dist/scoring/pool.js +65 -0
- package/dist/scoring/score.js +81 -0
- package/dist/scoring/what-if.js +38 -0
- package/dist/scripts/checks.js +53 -0
- package/dist/scripts/ci.js +139 -0
- package/dist/scripts/entry-points/find.js +31 -0
- package/dist/scripts/entry-points/index.js +18 -0
- package/dist/scripts/entry-points/justfile.js +62 -0
- package/dist/scripts/entry-points/makefile.js +66 -0
- package/dist/scripts/entry-points/nox.js +29 -0
- package/dist/scripts/entry-points/package-json.js +21 -0
- package/dist/scripts/entry-points/pyproject.js +73 -0
- package/dist/scripts/entry-points/tox.js +130 -0
- package/dist/scripts/expand.js +300 -0
- package/dist/scripts/triggers.js +151 -0
- package/dist/scripts/types.js +1 -0
- package/dist/submit/constants.js +10 -0
- package/dist/submit/payload.js +42 -0
- package/dist/submit/repo-identity.js +46 -0
- package/dist/submit/send.js +29 -0
- package/dist/tools/catalog.js +46 -0
- package/dist/tools/image.js +180 -0
- package/dist/tools/kit.js +71 -0
- package/dist/tools/knip.js +169 -0
- package/dist/tools/outcome.js +21 -0
- package/dist/tools/resolve.js +66 -0
- package/dist/tools/workcopy.js +27 -0
- package/dist/util/ini.js +37 -0
- package/dist/util/pep440.js +163 -0
- package/dist/util/toml.js +27 -0
- package/docker/Dockerfile +142 -0
- package/docker/bin/health-capture +5 -0
- package/docker/bin/health-coverage-install +5 -0
- package/docker/bin/health-eslint +66 -0
- package/docker/bin/health-pytest +10 -0
- package/docker/bin/health-tsc +10 -0
- package/docker/bin/health-venv +11 -0
- package/docker/config/eslint.config.mjs +4 -0
- package/docker/config/eslint.rules.mjs +65 -0
- package/docker/config/eslint.untyped.config.mjs +4 -0
- package/docker/config/knip.json +1 -0
- package/docker/config/ruff.toml +7 -0
- package/methodology/LICENSE +396 -0
- package/methodology/v1.0/METHODOLOGY.md +1176 -0
- package/methodology/v1.0/dimensions/1-safety-net.yaml +240 -0
- package/methodology/v1.0/dimensions/2-type-static-rigor.yaml +289 -0
- package/methodology/v1.0/dimensions/3-architecture-boundaries.yaml +266 -0
- package/methodology/v1.0/dimensions/4-complexity-maintainability.yaml +192 -0
- package/methodology/v1.0/dimensions/5-dependency-security-hygiene.yaml +223 -0
- package/methodology/v1.0/dimensions/6-operability-feedback-loop.yaml +279 -0
- package/methodology/v1.0/methodology.yaml +242 -0
- package/methodology/v1.0/presets/django.yaml +43 -0
- package/methodology/v1.0/presets/express.yaml +47 -0
- package/methodology/v1.0/presets/fastapi.yaml +52 -0
- package/methodology/v1.0/presets/generic.yaml +41 -0
- package/methodology/v1.0/presets/nextjs.yaml +50 -0
- package/methodology/v1.0/scope.yaml +115 -0
- package/package.json +61 -4
- package/python/dead_code_helper.py +265 -0
- package/python/env_helper.py +71 -0
- package/python/graph_helper.py +277 -0
- package/python/imports_helper.py +132 -0
- package/python/nox_helper.py +79 -0
- package/python/scope_helper.py +96 -0
- package/python/tests_helper.py +171 -0
- package/python/typing_helper.py +235 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import picomatch from 'picomatch';
|
|
3
|
+
/** Detection order of the D3 dimension file. */
|
|
4
|
+
const DETECTION_ORDER = ['nextjs', 'express', 'django', 'fastapi', 'generic'];
|
|
5
|
+
const LANGUAGES = { typescript_javascript: ['typescript', 'javascript'], python: ['python'] };
|
|
6
|
+
/**
|
|
7
|
+
* The first preset in detection order whose `languages` include the group and whose detection predicate
|
|
8
|
+
* (`matches[key]`, supplied by the language adapter) holds. `generic` is the fallback.
|
|
9
|
+
*/
|
|
10
|
+
export function choosePreset(methodology, group, matches) {
|
|
11
|
+
for (const key of DETECTION_ORDER) {
|
|
12
|
+
const preset = methodology.presets[key];
|
|
13
|
+
if (!preset || !preset.languages.some((l) => LANGUAGES[group].includes(l)))
|
|
14
|
+
continue;
|
|
15
|
+
if (key === 'generic' || matches[key] === true)
|
|
16
|
+
return preset;
|
|
17
|
+
}
|
|
18
|
+
throw new Error('methodology has no generic preset');
|
|
19
|
+
}
|
|
20
|
+
const MATCH_OPTIONS = { dot: true };
|
|
21
|
+
/** TypeScript and JavaScript match a glob with or without `src/`. Python passes its root package directories. */
|
|
22
|
+
const TS_PREFIXES = ['', 'src/'];
|
|
23
|
+
/** A glob `g` matches path `p` when `p` matches `prefix + g` for any of the prefixes (each empty or ending in `/`). */
|
|
24
|
+
export function globMatcher(globs, prefixes = TS_PREFIXES) {
|
|
25
|
+
const matchers = prefixes.map((prefix) => picomatch(globs.map((g) => `${prefix}${g}`), MATCH_OPTIONS));
|
|
26
|
+
return (rel) => matchers.some((match) => match(rel));
|
|
27
|
+
}
|
|
28
|
+
/** Leading literal directory segments of a glob (`app/**` has 1, `features/*` has 1, `**` has 0). */
|
|
29
|
+
function literalDepth(glob) {
|
|
30
|
+
const segments = glob.split('/');
|
|
31
|
+
let depth = 0;
|
|
32
|
+
for (const segment of segments.slice(0, -1)) {
|
|
33
|
+
if (/[*?[\]{}()!]/.test(segment))
|
|
34
|
+
break;
|
|
35
|
+
depth += 1;
|
|
36
|
+
}
|
|
37
|
+
return depth;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The glob layer of each file (paths relative to the package directory): the layer with the longest
|
|
41
|
+
* matching directory prefix, ties to the first layer listed. Files in no layer are absent.
|
|
42
|
+
*/
|
|
43
|
+
export function globLayers(layers, relFiles, prefixes = TS_PREFIXES) {
|
|
44
|
+
const compiled = Object.entries(layers)
|
|
45
|
+
.filter((entry) => Array.isArray(entry[1]))
|
|
46
|
+
.map(([name, globs]) => ({
|
|
47
|
+
name,
|
|
48
|
+
entries: globs.map((glob) => ({ depth: literalDepth(glob), match: globMatcher([glob], prefixes) })),
|
|
49
|
+
}));
|
|
50
|
+
const result = new Map();
|
|
51
|
+
for (const rel of relFiles) {
|
|
52
|
+
let best;
|
|
53
|
+
for (const layer of compiled) {
|
|
54
|
+
for (const entry of layer.entries) {
|
|
55
|
+
if (!entry.match(rel))
|
|
56
|
+
continue;
|
|
57
|
+
const depth = entry.depth;
|
|
58
|
+
if (!best || depth > best.depth)
|
|
59
|
+
best = { name: layer.name, depth };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (best)
|
|
63
|
+
result.set(rel, best.name);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/** Directory (relative to the package) of the deepest module root containing the file, else undefined. */
|
|
68
|
+
export function moduleDirs(roots, relFiles, prefixes = TS_PREFIXES) {
|
|
69
|
+
const isRoot = globMatcher(roots, prefixes);
|
|
70
|
+
const known = new Map();
|
|
71
|
+
const rootDir = (dir) => {
|
|
72
|
+
let value = known.get(dir);
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
value = isRoot(dir);
|
|
75
|
+
known.set(dir, value);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
const result = new Map();
|
|
80
|
+
for (const rel of relFiles) {
|
|
81
|
+
let found;
|
|
82
|
+
for (let dir = path.posix.dirname(rel); dir !== '.' && dir !== '/'; dir = path.posix.dirname(dir)) {
|
|
83
|
+
if (rootDir(dir)) {
|
|
84
|
+
found = dir;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
result.set(rel, found);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/** The entry glob that applies to the group. */
|
|
93
|
+
export function publicEntryGlob(preset, group) {
|
|
94
|
+
const entry = preset.modules.public_entry;
|
|
95
|
+
if (typeof entry === 'string')
|
|
96
|
+
return entry;
|
|
97
|
+
if (Array.isArray(entry))
|
|
98
|
+
return undefined;
|
|
99
|
+
return entry[group];
|
|
100
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { pythonDeclaredDeps } from '../../../manifest/python.js';
|
|
2
|
+
import { trackedPaths } from '../../../scope/tracked.js';
|
|
3
|
+
import { ciInvokes } from '../../../scripts/expand.js';
|
|
4
|
+
import { choosePreset } from '../presets.js';
|
|
5
|
+
import { findPyDeclared } from './config.js';
|
|
6
|
+
import { buildPyGraph } from './graph.js';
|
|
7
|
+
import { pythonRoots } from './roots.js';
|
|
8
|
+
const GROUP = 'python';
|
|
9
|
+
function setup(ctx) {
|
|
10
|
+
return ctx.tools.memo(`d3:py:setup:${ctx.repoPath}`, async () => {
|
|
11
|
+
const warnings = [];
|
|
12
|
+
const tracked = new Set(trackedPaths(ctx.repoPath));
|
|
13
|
+
const byPackage = new Map();
|
|
14
|
+
for (const file of ctx.scope.files) {
|
|
15
|
+
if (file.group !== GROUP)
|
|
16
|
+
continue;
|
|
17
|
+
const list = byPackage.get(file.package) ?? [];
|
|
18
|
+
list.push(file);
|
|
19
|
+
byPackage.set(file.package, list);
|
|
20
|
+
}
|
|
21
|
+
const ciHits = ciInvokes(ctx.repoPath, ['lint-imports'], { warnings });
|
|
22
|
+
const declaredCache = new Map();
|
|
23
|
+
const units = [];
|
|
24
|
+
for (const [pkg, files] of [...byPackage].sort(([a], [b]) => a.localeCompare(b))) {
|
|
25
|
+
const logic = files.filter((f) => f.role === 'logic');
|
|
26
|
+
if (logic.length === 0)
|
|
27
|
+
continue;
|
|
28
|
+
const deps = pythonDeclaredDeps(ctx.repoPath, pkg);
|
|
29
|
+
const preset = choosePreset(ctx.methodology, GROUP, { django: deps.has('django'), fastapi: deps.has('fastapi') });
|
|
30
|
+
const declared = findPyDeclared(ctx.repoPath, pkg, warnings, declaredCache);
|
|
31
|
+
const weight = logic.reduce((sum, f) => sum + f.logicLines, 0);
|
|
32
|
+
const level31 = declared === undefined ? 'none' : ciHits.length > 0 ? 'enforced' : 'declared';
|
|
33
|
+
units.push({
|
|
34
|
+
package: pkg,
|
|
35
|
+
files,
|
|
36
|
+
logic,
|
|
37
|
+
weight,
|
|
38
|
+
preset,
|
|
39
|
+
declared,
|
|
40
|
+
roots: pythonRoots(pkg, files.map((f) => f.path), tracked, warnings),
|
|
41
|
+
rules: {
|
|
42
|
+
package: pkg,
|
|
43
|
+
group: GROUP,
|
|
44
|
+
weight,
|
|
45
|
+
preset: preset.preset,
|
|
46
|
+
rulesSource: declared === undefined ? 'preset' : 'declared',
|
|
47
|
+
...(declared === undefined ? {} : { rulesFile: declared.file }),
|
|
48
|
+
level31,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { units, warnings };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export const pyAdapter = {
|
|
56
|
+
group: GROUP,
|
|
57
|
+
async rules(ctx) {
|
|
58
|
+
const { units, warnings } = await setup(ctx);
|
|
59
|
+
return { units: units.map((u) => u.rules), warnings };
|
|
60
|
+
},
|
|
61
|
+
async graph(ctx) {
|
|
62
|
+
return ctx.tools.memo(`d3:py:graph:${ctx.repoPath}`, async () => {
|
|
63
|
+
const { units } = await setup(ctx);
|
|
64
|
+
return buildPyGraph(ctx, units);
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { parse } from 'smol-toml';
|
|
2
|
+
import { readTracked } from '../../../scope/tracked.js';
|
|
3
|
+
import { parseIni } from '../../../util/ini.js';
|
|
4
|
+
const DIRECTION_TYPES = new Set(['layers', 'forbidden', 'independence']);
|
|
5
|
+
/** The order import-linter itself tries its config files in. */
|
|
6
|
+
const CONFIG_NAMES = ['setup.cfg', '.importlinter', 'pyproject.toml'];
|
|
7
|
+
const lines = (value) => (value ?? '').split(/\r\n|\r|\n/).map((l) => l.trim()).filter((l) => l !== '');
|
|
8
|
+
function iniContracts(text, requireSection) {
|
|
9
|
+
const ini = parseIni(text);
|
|
10
|
+
if (requireSection && ini.importlinter === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const [section, values] of Object.entries(ini)) {
|
|
14
|
+
if (!section.startsWith('importlinter:contract:'))
|
|
15
|
+
continue;
|
|
16
|
+
out.push({
|
|
17
|
+
name: values.name ?? section.slice('importlinter:contract:'.length),
|
|
18
|
+
type: (values.type ?? '').trim(),
|
|
19
|
+
layers: lines(values.layers),
|
|
20
|
+
containers: lines(values.containers),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function strings(value) {
|
|
26
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
27
|
+
}
|
|
28
|
+
function tomlContracts(text, file, warnings) {
|
|
29
|
+
let doc;
|
|
30
|
+
try {
|
|
31
|
+
doc = parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
warnings.push(`${file}: could not be parsed, treated as absent`);
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const tool = doc.tool;
|
|
38
|
+
const section = tool?.importlinter;
|
|
39
|
+
if (section === undefined || typeof section !== 'object')
|
|
40
|
+
return undefined;
|
|
41
|
+
const list = Array.isArray(section.contracts) ? section.contracts : [];
|
|
42
|
+
return list
|
|
43
|
+
.filter((c) => typeof c === 'object' && c !== null)
|
|
44
|
+
.map((c, index) => ({
|
|
45
|
+
name: typeof c.name === 'string' ? c.name : `contract-${index + 1}`,
|
|
46
|
+
type: typeof c.type === 'string' ? c.type : '',
|
|
47
|
+
layers: strings(c.layers),
|
|
48
|
+
containers: strings(c.containers),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The rules file that governs a unit: walking from the unit directory up to the repository root, at each directory
|
|
53
|
+
* `setup.cfg`, `.importlinter`, `pyproject.toml` in that order; the first one with at least one layers, forbidden or
|
|
54
|
+
* independence contract. Files with other contract types only, an unparseable file and a `setup.cfg` without
|
|
55
|
+
* `[importlinter]` do not govern.
|
|
56
|
+
*/
|
|
57
|
+
export function findPyDeclared(repoPath, unitDir, warnings, cache) {
|
|
58
|
+
let dir = unitDir;
|
|
59
|
+
for (;;) {
|
|
60
|
+
for (const name of CONFIG_NAMES) {
|
|
61
|
+
const file = dir === '.' ? name : `${dir}/${name}`;
|
|
62
|
+
let result = cache.get(file);
|
|
63
|
+
if (result === undefined) {
|
|
64
|
+
const text = readTracked(repoPath, file);
|
|
65
|
+
let contracts;
|
|
66
|
+
if (text !== null)
|
|
67
|
+
contracts = name === 'pyproject.toml' ? tomlContracts(text, file, warnings) : iniContracts(text, name === 'setup.cfg' || name === '.importlinter');
|
|
68
|
+
const direction = contracts?.filter((c) => DIRECTION_TYPES.has(c.type)) ?? [];
|
|
69
|
+
result = direction.length > 0 ? { file, contracts: direction } : null;
|
|
70
|
+
cache.set(file, result);
|
|
71
|
+
}
|
|
72
|
+
if (result !== null)
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
if (dir === '.')
|
|
76
|
+
return undefined;
|
|
77
|
+
const slash = dir.lastIndexOf('/');
|
|
78
|
+
dir = slash === -1 ? '.' : dir.slice(0, slash);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { moduleName } from './roots.js';
|
|
2
|
+
/**
|
|
3
|
+
* Preset rules as contracts for one unit. `layerFiles` maps a layer to the unit's files in it; `unitFiles` is every
|
|
4
|
+
* in-scope file of the unit. One contract per (preset rule, `to` alternative), named `<package>:<rule>` with a
|
|
5
|
+
* `#packages` suffix on the packages alternative. Only files in a root package have module names.
|
|
6
|
+
*/
|
|
7
|
+
export function presetContracts(packageDir, rules, layerFiles, unitFiles, roots) {
|
|
8
|
+
const names = (files) => {
|
|
9
|
+
const out = new Set();
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
for (const root of roots) {
|
|
12
|
+
const name = moduleName(root, file);
|
|
13
|
+
if (name !== undefined) {
|
|
14
|
+
out.add(name);
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return [...out].sort();
|
|
20
|
+
};
|
|
21
|
+
const union = (layers) => [...new Set(layers.flatMap((l) => [...(layerFiles.get(l) ?? [])]))];
|
|
22
|
+
const contracts = [];
|
|
23
|
+
const meta = new Map();
|
|
24
|
+
for (const rule of rules) {
|
|
25
|
+
if (rule.severity === 'info')
|
|
26
|
+
continue;
|
|
27
|
+
let fromFiles;
|
|
28
|
+
if (rule.from.in)
|
|
29
|
+
fromFiles = union(rule.from.in);
|
|
30
|
+
else {
|
|
31
|
+
const excluded = new Set(union(rule.from.not_in ?? []));
|
|
32
|
+
fromFiles = unitFiles.filter((f) => !excluded.has(f));
|
|
33
|
+
}
|
|
34
|
+
const source = names(fromFiles);
|
|
35
|
+
if (source.length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
const base = `${packageDir}:${rule.name}`;
|
|
38
|
+
const add = (name, forbidden) => {
|
|
39
|
+
if (forbidden.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
contracts.push({
|
|
42
|
+
id: name,
|
|
43
|
+
name,
|
|
44
|
+
type: 'forbidden',
|
|
45
|
+
source_modules: source,
|
|
46
|
+
forbidden_modules: forbidden,
|
|
47
|
+
as_packages: 'false',
|
|
48
|
+
allow_indirect_imports: 'true',
|
|
49
|
+
unmatched_ignore_imports_alerting: 'none',
|
|
50
|
+
});
|
|
51
|
+
meta.set(name, { rule: rule.name, excludesTypeOnly: rule.type_only_imports === 'excluded' });
|
|
52
|
+
};
|
|
53
|
+
add(base, rule.to.in ? names(union(rule.to.in)) : []);
|
|
54
|
+
add(`${base}#packages`, rule.to.packages ?? []);
|
|
55
|
+
}
|
|
56
|
+
return { contracts, meta };
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { moduleName } from './roots.js';
|
|
3
|
+
/** Logic files with a directory named exactly `domain` anywhere in their path. */
|
|
4
|
+
function isDomainDir(file) {
|
|
5
|
+
return path.posix.dirname(file).split('/').includes('domain');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Dotted module names of the layers named `domain` in declared `layers` contracts: the last dotted component of the
|
|
9
|
+
* layer is `domain`; entries split on `|` and `:` and are prefixed with each container the contract names.
|
|
10
|
+
*/
|
|
11
|
+
export function declaredDomainModules(contracts) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const contract of contracts) {
|
|
14
|
+
if (contract.type !== 'layers')
|
|
15
|
+
continue;
|
|
16
|
+
for (const line of contract.layers) {
|
|
17
|
+
for (const raw of line.split(/[|:]/)) {
|
|
18
|
+
const layer = raw.trim();
|
|
19
|
+
if (layer === '' || layer.split('.').pop() !== 'domain')
|
|
20
|
+
continue;
|
|
21
|
+
if (contract.containers.length === 0)
|
|
22
|
+
out.push(layer);
|
|
23
|
+
else
|
|
24
|
+
for (const container of contract.containers)
|
|
25
|
+
out.push(`${container}.${layer}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export function domainFilesOf(logicFiles, roots, declaredModules) {
|
|
32
|
+
return logicFiles.filter((file) => {
|
|
33
|
+
if (isDomainDir(file))
|
|
34
|
+
return true;
|
|
35
|
+
if (declaredModules.length === 0)
|
|
36
|
+
return false;
|
|
37
|
+
const dotted = roots.map((root) => moduleName(root, file)).find((n) => n !== undefined);
|
|
38
|
+
return dotted !== undefined && declaredModules.some((m) => dotted === m || dotted.startsWith(`${m}.`));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function inInfrastructure(file, directories) {
|
|
42
|
+
if (isDomainDir(file))
|
|
43
|
+
return false;
|
|
44
|
+
return path.posix.dirname(file).split('/').some((segment) => directories.includes(segment));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Impure: an import statement (lazy and type-only included) of a forbidden package by its first dotted name, an edge to
|
|
48
|
+
* an in-scope file under an infrastructure directory (unless that file is itself under `domain`), or a builtin `open` call.
|
|
49
|
+
*/
|
|
50
|
+
export function isImpure(info, importedFiles, lists) {
|
|
51
|
+
const forbidden = new Set(lists.forbiddenImports.python);
|
|
52
|
+
if (info?.imports.some((s) => s.level === 0 && s.modules.some((m) => forbidden.has(m.split('.')[0]))))
|
|
53
|
+
return true;
|
|
54
|
+
if (importedFiles.some((file) => inInfrastructure(file, lists.infrastructureDirectories)))
|
|
55
|
+
return true;
|
|
56
|
+
return info?.opensBuiltin === true;
|
|
57
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { readPythonImports } from '../../../python/imports.js';
|
|
7
|
+
import { trackedPaths } from '../../../scope/tracked.js';
|
|
8
|
+
import { createWorkCopy } from '../../../tools/workcopy.js';
|
|
9
|
+
import { failureReason } from '../../../tools/outcome.js';
|
|
10
|
+
import { edgeKey } from '../types.js';
|
|
11
|
+
import { presetContracts } from './contracts.js';
|
|
12
|
+
import { declaredDomainModules, domainFilesOf, isImpure } from './domain.js';
|
|
13
|
+
import { classifyPython } from './layers.js';
|
|
14
|
+
import { pyInterpreter } from './interpreter.js';
|
|
15
|
+
import { rootPackageDir } from './roots.js';
|
|
16
|
+
const TOOL = 'import-linter';
|
|
17
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'python', 'graph_helper.py');
|
|
18
|
+
async function runHelper(ctx, unit, contracts, tool, interpreter) {
|
|
19
|
+
const tracked = trackedPaths(ctx.repoPath);
|
|
20
|
+
const dirs = unit.roots.map((r) => `${rootPackageDir(r)}/`);
|
|
21
|
+
const files = tracked.filter((p) => p.endsWith('.py') && dirs.some((d) => p.startsWith(d)) && existsSync(path.join(ctx.repoPath, p)));
|
|
22
|
+
if (unit.declared !== undefined && existsSync(path.join(ctx.repoPath, unit.declared.file)))
|
|
23
|
+
files.push(unit.declared.file);
|
|
24
|
+
const scratch = await mkdtemp(path.join(tmpdir(), 'health-d3py-'));
|
|
25
|
+
let copy;
|
|
26
|
+
try {
|
|
27
|
+
copy = await createWorkCopy(ctx.repoPath, [...new Set(files)]);
|
|
28
|
+
const input = {
|
|
29
|
+
workDir: copy.dir,
|
|
30
|
+
roots: unit.roots,
|
|
31
|
+
declared: unit.declared === undefined ? null : path.join(copy.dir, unit.declared.file),
|
|
32
|
+
contracts,
|
|
33
|
+
};
|
|
34
|
+
const inFile = path.join(scratch, 'in.json');
|
|
35
|
+
const outFile = path.join(scratch, 'out.json');
|
|
36
|
+
await writeFile(inFile, JSON.stringify(input));
|
|
37
|
+
const runTool = { ...tool, command: interpreter, prefixArgs: ['-I', helperPath] };
|
|
38
|
+
const result = await ctx.tools.run(runTool, ['--in', inFile, '--out', outFile], { cwd: copy.dir });
|
|
39
|
+
const failure = failureReason(tool, result, [0]);
|
|
40
|
+
if (failure !== null)
|
|
41
|
+
return { unavailable: `${TOOL} failed: ${failure}` };
|
|
42
|
+
let output;
|
|
43
|
+
try {
|
|
44
|
+
output = JSON.parse(await readFile(outFile, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { unavailable: `${TOOL} failed: unreadable output` };
|
|
48
|
+
}
|
|
49
|
+
if (output.error !== undefined)
|
|
50
|
+
return { unavailable: `${TOOL} failed: ${output.error}` };
|
|
51
|
+
return { output };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return { unavailable: `${TOOL} failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await copy?.cleanup();
|
|
58
|
+
await rm(scratch, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function statementsByLine(info) {
|
|
62
|
+
const out = new Map();
|
|
63
|
+
for (const statement of info?.imports ?? []) {
|
|
64
|
+
const list = out.get(statement.line) ?? [];
|
|
65
|
+
list.push(statement);
|
|
66
|
+
out.set(statement.line, list);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Runtime: at least one occurrence line maps to a statement that is neither lazy nor type-only; a line with no statement counts. */
|
|
71
|
+
export function isRuntime(info, lines) {
|
|
72
|
+
if (lines.length === 0)
|
|
73
|
+
return true;
|
|
74
|
+
const byLine = statementsByLine(info);
|
|
75
|
+
return lines.some((line) => {
|
|
76
|
+
const statements = byLine.get(line);
|
|
77
|
+
return statements === undefined || statements.some((s) => !s.lazy && !s.typeOnly);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function allTypeOnly(info, lines) {
|
|
81
|
+
if (lines.length === 0)
|
|
82
|
+
return false;
|
|
83
|
+
const byLine = statementsByLine(info);
|
|
84
|
+
return lines.every((line) => {
|
|
85
|
+
const statements = byLine.get(line);
|
|
86
|
+
return statements !== undefined && statements.length > 0 && statements.every((s) => s.typeOnly);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export function externalTypeOnly(info, name) {
|
|
90
|
+
const matching = (info?.imports ?? []).filter((s) => s.level === 0 && s.modules.some((m) => m === name || m.startsWith(`${name}.`)));
|
|
91
|
+
return matching.length > 0 && matching.every((s) => s.typeOnly);
|
|
92
|
+
}
|
|
93
|
+
export async function buildPyGraph(ctx, units) {
|
|
94
|
+
const tool = await ctx.tools.resolve(TOOL);
|
|
95
|
+
if (tool === undefined)
|
|
96
|
+
return { unavailable: ctx.tools.notFoundReason(TOOL) };
|
|
97
|
+
const found = pyInterpreter(tool);
|
|
98
|
+
if ('unavailable' in found)
|
|
99
|
+
return found;
|
|
100
|
+
ctx.tools.recordVersion(TOOL, tool.version);
|
|
101
|
+
const imports = await readPythonImports(ctx);
|
|
102
|
+
const tracked = new Set(trackedPaths(ctx.repoPath));
|
|
103
|
+
const warnings = [];
|
|
104
|
+
const result = [];
|
|
105
|
+
const scoped = new Map();
|
|
106
|
+
for (const file of ctx.scope.files)
|
|
107
|
+
if (file.group === 'python')
|
|
108
|
+
scoped.set(file.path, file);
|
|
109
|
+
for (const unit of units) {
|
|
110
|
+
const unitPaths = unit.files.map((f) => f.path);
|
|
111
|
+
const unitFiles = new Set(unitPaths);
|
|
112
|
+
const logicFiles = unit.logic.map((f) => f.path).sort();
|
|
113
|
+
for (const file of unitPaths) {
|
|
114
|
+
if (imports[file]?.syntaxError)
|
|
115
|
+
warnings.push(`${file}: syntax error, its imports are not counted`);
|
|
116
|
+
}
|
|
117
|
+
// Barrels (`__init__.py` that only re-export) are not scoped files but are the canonical public entries, as for
|
|
118
|
+
// TypeScript: module and entry classification covers every tracked Python file below the root packages.
|
|
119
|
+
const rootDirs = unit.roots.map((r) => `${rootPackageDir(r)}/`);
|
|
120
|
+
const graphPaths = [...new Set([...unitPaths, ...[...tracked].filter((p) => p.endsWith('.py') && rootDirs.some((d) => p.startsWith(d)))])].sort();
|
|
121
|
+
const graphFiles = new Set(graphPaths);
|
|
122
|
+
const classification = classifyPython(unit.preset, graphPaths, unit.package, unit.roots, tracked);
|
|
123
|
+
const layerFiles = new Map();
|
|
124
|
+
for (const [file, layers] of classification.layers) {
|
|
125
|
+
for (const layer of layers) {
|
|
126
|
+
const set = layerFiles.get(layer) ?? new Set();
|
|
127
|
+
set.add(file);
|
|
128
|
+
layerFiles.set(layer, set);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const preset = unit.declared === undefined
|
|
132
|
+
? presetContracts(unit.package, unit.preset.rules, layerFiles, unitPaths, unit.roots)
|
|
133
|
+
: { contracts: [], meta: new Map() };
|
|
134
|
+
let output = {};
|
|
135
|
+
if (unit.roots.length > 0) {
|
|
136
|
+
const run = await runHelper(ctx, unit, preset.contracts, tool, found.interpreter);
|
|
137
|
+
if ('unavailable' in run)
|
|
138
|
+
return run;
|
|
139
|
+
output = run.output;
|
|
140
|
+
if (output.versions?.grimp !== undefined)
|
|
141
|
+
ctx.tools.recordVersion('grimp', output.versions.grimp);
|
|
142
|
+
if (output.versions?.['import-linter'] !== undefined)
|
|
143
|
+
ctx.tools.recordVersion(TOOL, output.versions['import-linter']);
|
|
144
|
+
for (const skipped of output.skipped ?? [])
|
|
145
|
+
warnings.push(`${unit.package}: ${TOOL} contract ${skipped.contract} skipped (${skipped.reason})`);
|
|
146
|
+
}
|
|
147
|
+
const edgeLines = new Map();
|
|
148
|
+
const edges = [];
|
|
149
|
+
for (const edge of output.edges ?? []) {
|
|
150
|
+
if (!unitFiles.has(edge.from) || !unitFiles.has(edge.to) || edge.from === edge.to)
|
|
151
|
+
continue;
|
|
152
|
+
edgeLines.set(edgeKey(edge.from, edge.to), edge.lines);
|
|
153
|
+
edges.push({ from: edge.from, to: edge.to, fromTest: scoped.get(edge.from)?.role === 'test', runtime: isRuntime(imports[edge.from], edge.lines) });
|
|
154
|
+
}
|
|
155
|
+
edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
156
|
+
const violating = new Set();
|
|
157
|
+
const violationRules = {};
|
|
158
|
+
for (const violation of output.violations ?? []) {
|
|
159
|
+
if (!unitFiles.has(violation.importer) || scoped.get(violation.importer)?.role === 'test')
|
|
160
|
+
continue;
|
|
161
|
+
if (!violation.external && (!unitFiles.has(violation.imported) || violation.imported === violation.importer))
|
|
162
|
+
continue;
|
|
163
|
+
const info = preset.meta.get(violation.contract);
|
|
164
|
+
if (info !== undefined && !violation.external) {
|
|
165
|
+
const from = classification.layerModule.get(violation.importer);
|
|
166
|
+
if (from !== undefined && from === classification.layerModule.get(violation.imported))
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (info?.excludesTypeOnly) {
|
|
170
|
+
const typeOnly = violation.external
|
|
171
|
+
? externalTypeOnly(imports[violation.importer], violation.imported)
|
|
172
|
+
: allTypeOnly(imports[violation.importer], edgeLines.get(edgeKey(violation.importer, violation.imported)) ?? []);
|
|
173
|
+
if (typeOnly)
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const key = edgeKey(violation.importer, violation.imported);
|
|
177
|
+
violating.add(key);
|
|
178
|
+
const names = (violationRules[key] ??= []);
|
|
179
|
+
const label = info?.rule ?? violation.contract;
|
|
180
|
+
if (!names.includes(label))
|
|
181
|
+
names.push(label);
|
|
182
|
+
}
|
|
183
|
+
for (const names of Object.values(violationRules))
|
|
184
|
+
names.sort();
|
|
185
|
+
const moduleDirsOfUnit = new Set(logicFiles.map((f) => classification.moduleOf.get(f)).filter((m) => m !== undefined));
|
|
186
|
+
const crossings = [];
|
|
187
|
+
if (moduleDirsOfUnit.size >= 2) {
|
|
188
|
+
for (const edge of output.edges ?? []) {
|
|
189
|
+
if (!unitFiles.has(edge.from) || scoped.get(edge.from)?.role === 'test' || !graphFiles.has(edge.to) || edge.from === edge.to)
|
|
190
|
+
continue;
|
|
191
|
+
const fromModule = classification.moduleOf.get(edge.from);
|
|
192
|
+
const toModule = classification.moduleOf.get(edge.to);
|
|
193
|
+
if (fromModule === undefined || toModule === undefined || fromModule === toModule)
|
|
194
|
+
continue;
|
|
195
|
+
crossings.push({ from: edge.from, to: edge.to, toEntry: classification.entries.has(edge.to) });
|
|
196
|
+
}
|
|
197
|
+
crossings.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
198
|
+
}
|
|
199
|
+
const declaredModules = unit.declared === undefined ? [] : declaredDomainModules(unit.declared.contracts);
|
|
200
|
+
const domainFiles = domainFilesOf(logicFiles, unit.roots, declaredModules);
|
|
201
|
+
const impure = domainFiles.filter((file) => isImpure(imports[file], edges.filter((e) => e.from === file).map((e) => e.to), ctx.methodology.d3));
|
|
202
|
+
result.push({
|
|
203
|
+
...unit.rules,
|
|
204
|
+
logicFiles,
|
|
205
|
+
edges,
|
|
206
|
+
violating: [...violating].sort(),
|
|
207
|
+
violationRules,
|
|
208
|
+
moduleCount: moduleDirsOfUnit.size,
|
|
209
|
+
crossings,
|
|
210
|
+
domain: { files: domainFiles, impure },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return { units: result, warnings };
|
|
214
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
/**
|
|
3
|
+
* The Python interpreter that holds import-linter and grimp: the `#!<absolute path>` first line of the
|
|
4
|
+
* resolved `lint-imports` script (a pipx or uv tool environment keeps them out of the system python3).
|
|
5
|
+
* Any other first line, or an unreadable file, is unavailable; nothing falls back to the system python3.
|
|
6
|
+
* A tool resolved in the image names its interpreter itself.
|
|
7
|
+
*/
|
|
8
|
+
export function pyInterpreter(tool) {
|
|
9
|
+
if (tool.interpreter !== undefined)
|
|
10
|
+
return { interpreter: tool.interpreter };
|
|
11
|
+
const fail = () => ({ unavailable: `import-linter: cannot read its Python interpreter from ${tool.command}` });
|
|
12
|
+
let line;
|
|
13
|
+
try {
|
|
14
|
+
const fd = openSync(tool.command, 'r');
|
|
15
|
+
try {
|
|
16
|
+
const buffer = Buffer.alloc(512);
|
|
17
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
18
|
+
line = buffer.toString('utf8', 0, read).split(/\r?\n/, 1)[0] ?? '';
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
closeSync(fd);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return fail();
|
|
26
|
+
}
|
|
27
|
+
const match = /^#!(\/\S+)$/.exec(line.trim());
|
|
28
|
+
if (!match?.[1] || /(^|\/)env$/.test(match[1]))
|
|
29
|
+
return fail();
|
|
30
|
+
return { interpreter: match[1] };
|
|
31
|
+
}
|