vibecodedshit 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +95 -0
- package/README.md +64 -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 +8 -0
- package/dist/card/previous.js +38 -0
- package/dist/card/render.js +71 -0
- package/dist/cli.js +121 -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 +25 -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/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 +9 -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,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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { globLayers, moduleDirs, publicEntryGlob } from '../presets.js';
|
|
3
|
+
import { rootOf, rootPackageDir } from './roots.js';
|
|
4
|
+
const stem = (file) => path.posix.basename(file, '.py');
|
|
5
|
+
function empty() {
|
|
6
|
+
return { layers: new Map(), moduleOf: new Map(), entries: new Set(), layerModule: new Map() };
|
|
7
|
+
}
|
|
8
|
+
function entryNames(preset) {
|
|
9
|
+
const entry = preset.modules.public_entry;
|
|
10
|
+
const list = Array.isArray(entry) ? entry : [];
|
|
11
|
+
return { file: list.includes('__init__.py'), names: list.filter((e) => e !== '__init__.py') };
|
|
12
|
+
}
|
|
13
|
+
function django(preset, files, unitDir, tracked) {
|
|
14
|
+
const out = empty();
|
|
15
|
+
const prefix = unitDir === '.' ? '' : `${unitDir}/`;
|
|
16
|
+
const apps = [...tracked]
|
|
17
|
+
.filter((p) => p.startsWith(prefix) && path.posix.basename(p) === 'apps.py')
|
|
18
|
+
.map((p) => path.posix.dirname(p))
|
|
19
|
+
.sort((a, b) => b.length - a.length);
|
|
20
|
+
const entries = entryNames(preset);
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
const app = apps.find((a) => (a === '.' ? true : file.startsWith(`${a}/`)));
|
|
23
|
+
if (app === undefined)
|
|
24
|
+
continue;
|
|
25
|
+
const rel = app === '.' ? file : file.slice(app.length + 1);
|
|
26
|
+
const parts = rel.split('/');
|
|
27
|
+
const key = parts.length > 1 ? parts[0] : stem(rel);
|
|
28
|
+
out.moduleOf.set(file, app);
|
|
29
|
+
if (key === '__init__') {
|
|
30
|
+
if (entries.file && parts.length === 1)
|
|
31
|
+
out.entries.add(file);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const inLayers = Object.entries(preset.layers)
|
|
35
|
+
.filter((entry) => Array.isArray(entry[1]) && entry[1].includes(key))
|
|
36
|
+
.map(([name]) => name);
|
|
37
|
+
if (inLayers.length > 0)
|
|
38
|
+
out.layers.set(file, inLayers);
|
|
39
|
+
out.layerModule.set(file, `${app}/${key}`);
|
|
40
|
+
if (entries.names.includes(key))
|
|
41
|
+
out.entries.add(file);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function fastapi(preset, files, roots) {
|
|
46
|
+
const out = empty();
|
|
47
|
+
const layerOf = new Map();
|
|
48
|
+
for (const [name, list] of Object.entries(preset.layers))
|
|
49
|
+
if (Array.isArray(list))
|
|
50
|
+
for (const segment of list)
|
|
51
|
+
layerOf.set(segment, name);
|
|
52
|
+
const inFiles = new Set(files);
|
|
53
|
+
const moduleRoots = new Set();
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const root = rootOf(roots, file);
|
|
56
|
+
if (root === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
const base = rootPackageDir(root);
|
|
59
|
+
const segments = path.posix.dirname(file) === base ? [] : path.posix.dirname(file).slice(base.length + 1).split('/');
|
|
60
|
+
if (stem(file) !== '__init__')
|
|
61
|
+
segments.push(stem(file));
|
|
62
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
63
|
+
const layer = layerOf.get(segments[i]);
|
|
64
|
+
if (layer !== undefined) {
|
|
65
|
+
out.layers.set(file, [layer]);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Modules are directories below the root package: those holding router.py and the direct children of features/modules.
|
|
70
|
+
const dir = path.posix.dirname(file);
|
|
71
|
+
if (dir !== base && path.posix.basename(file) === 'router.py')
|
|
72
|
+
moduleRoots.add(dir);
|
|
73
|
+
for (let d = dir; d.length > base.length; d = path.posix.dirname(d)) {
|
|
74
|
+
const parent = path.posix.dirname(d);
|
|
75
|
+
if (parent.length >= base.length && ['features', 'modules'].includes(path.posix.basename(parent)))
|
|
76
|
+
moduleRoots.add(d);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const sorted = [...moduleRoots].sort((a, b) => b.length - a.length);
|
|
80
|
+
const entries = entryNames(preset);
|
|
81
|
+
for (const file of files) {
|
|
82
|
+
const dir = sorted.find((m) => file.startsWith(`${m}/`));
|
|
83
|
+
if (dir === undefined)
|
|
84
|
+
continue;
|
|
85
|
+
out.moduleOf.set(file, dir);
|
|
86
|
+
if (entries.file && path.posix.dirname(file) === dir && path.posix.basename(file) === '__init__.py' && inFiles.has(file))
|
|
87
|
+
out.entries.add(file);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function generic(preset, files, unitDir, roots) {
|
|
92
|
+
const out = empty();
|
|
93
|
+
const prefix = unitDir === '.' ? '' : `${unitDir}/`;
|
|
94
|
+
const rels = files.map((f) => f.slice(prefix.length));
|
|
95
|
+
const prefixes = ['', ...roots.map((r) => `${path.posix.relative(unitDir, rootPackageDir(r))}/`)];
|
|
96
|
+
const full = new Map(rels.map((rel, i) => [rel, files[i]]));
|
|
97
|
+
for (const [rel, layer] of globLayers(preset.layers, rels, prefixes))
|
|
98
|
+
out.layers.set(full.get(rel), [layer]);
|
|
99
|
+
const globs = Array.isArray(preset.modules.roots) ? preset.modules.roots : [];
|
|
100
|
+
const dirs = moduleDirs(globs, rels, prefixes);
|
|
101
|
+
const entryGlob = publicEntryGlob(preset, 'python');
|
|
102
|
+
for (const rel of rels) {
|
|
103
|
+
const dir = dirs.get(rel);
|
|
104
|
+
if (dir === undefined)
|
|
105
|
+
continue;
|
|
106
|
+
const file = full.get(rel);
|
|
107
|
+
out.moduleOf.set(file, `${prefix}${dir}`);
|
|
108
|
+
if (entryGlob !== undefined && path.posix.dirname(rel) === dir && path.posix.basename(rel) === entryGlob)
|
|
109
|
+
out.entries.add(file);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** Layers, modules and public entries of a unit's in-scope Python files under the preset. `files` are repo-relative. */
|
|
114
|
+
export function classifyPython(preset, files, unitDir, roots, tracked) {
|
|
115
|
+
if (preset.preset === 'django')
|
|
116
|
+
return django(preset, files, unitDir, tracked);
|
|
117
|
+
if (preset.preset === 'fastapi')
|
|
118
|
+
return fastapi(preset, files, roots);
|
|
119
|
+
return generic(preset, files, unitDir, roots);
|
|
120
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
|
+
/** Repo-relative directory of the root package. */
|
|
4
|
+
export const rootPackageDir = (root) => (root.dir === '.' ? root.name : `${root.dir}/${root.name}`);
|
|
5
|
+
/**
|
|
6
|
+
* First-party root packages of a unit. Source roots: `<unit>/src` (when it holds in-scope Python files and has no
|
|
7
|
+
* `__init__.py`) and the unit directory. Root packages are the identifier-named child directories of a source root
|
|
8
|
+
* that hold an in-scope Python file at any depth, with or without `__init__.py`. When a name occurs under both
|
|
9
|
+
* source roots the `src` one wins.
|
|
10
|
+
*/
|
|
11
|
+
export function pythonRoots(unitDir, files, tracked, warnings) {
|
|
12
|
+
const at = (rel) => (unitDir === '.' ? rel : `${unitDir}/${rel}`);
|
|
13
|
+
const srcDir = at('src');
|
|
14
|
+
const srcHasFiles = files.some((f) => f.startsWith(`${srcDir}/`));
|
|
15
|
+
const srcIsRoot = srcHasFiles && !tracked.has(`${srcDir}/__init__.py`);
|
|
16
|
+
const sourceRoots = srcIsRoot ? [srcDir, unitDir] : [unitDir];
|
|
17
|
+
const found = new Map();
|
|
18
|
+
for (const sourceRoot of sourceRoots) {
|
|
19
|
+
const prefix = sourceRoot === '.' ? '' : `${sourceRoot}/`;
|
|
20
|
+
const children = new Set();
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (!file.startsWith(prefix))
|
|
23
|
+
continue;
|
|
24
|
+
const parts = file.slice(prefix.length).split('/');
|
|
25
|
+
if (parts.length >= 2)
|
|
26
|
+
children.add(parts[0]);
|
|
27
|
+
}
|
|
28
|
+
for (const name of [...children].sort()) {
|
|
29
|
+
if (!IDENTIFIER.test(name))
|
|
30
|
+
continue;
|
|
31
|
+
if (sourceRoot === unitDir && srcIsRoot && name === 'src')
|
|
32
|
+
continue;
|
|
33
|
+
const existing = found.get(name);
|
|
34
|
+
if (existing !== undefined) {
|
|
35
|
+
warnings.push(`${path.posix.join(existing.dir, name)} and ${path.posix.join(sourceRoot, name)} share the package name ${name}, the src one is used`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
found.set(name, { dir: sourceRoot, name });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...found.values()];
|
|
42
|
+
}
|
|
43
|
+
/** Dotted module name of a file below a root, or undefined when it is not in the root or a segment is not an identifier. */
|
|
44
|
+
export function moduleName(root, file) {
|
|
45
|
+
const prefix = root.dir === '.' ? '' : `${root.dir}/`;
|
|
46
|
+
if (!file.startsWith(`${prefix}${root.name}/`) || !file.endsWith('.py'))
|
|
47
|
+
return undefined;
|
|
48
|
+
const parts = file.slice(prefix.length, -3).split('/');
|
|
49
|
+
if (parts[parts.length - 1] === '__init__')
|
|
50
|
+
parts.pop();
|
|
51
|
+
return parts.every((p) => IDENTIFIER.test(p)) ? parts.join('.') : undefined;
|
|
52
|
+
}
|
|
53
|
+
export function rootOf(roots, file) {
|
|
54
|
+
return roots.find((root) => {
|
|
55
|
+
const dir = rootPackageDir(root);
|
|
56
|
+
return file.startsWith(`${dir}/`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Strongly connected components of two or more nodes (Tarjan, iterative). Self loops are ignored. */
|
|
2
|
+
export function cyclicNodes(nodes, edges) {
|
|
3
|
+
const adjacency = new Map();
|
|
4
|
+
for (const node of nodes)
|
|
5
|
+
adjacency.set(node, []);
|
|
6
|
+
for (const [from, to] of edges) {
|
|
7
|
+
if (from !== to && adjacency.has(from) && adjacency.has(to))
|
|
8
|
+
adjacency.get(from)?.push(to);
|
|
9
|
+
}
|
|
10
|
+
const index = new Map();
|
|
11
|
+
const low = new Map();
|
|
12
|
+
const onStack = new Set();
|
|
13
|
+
const stack = [];
|
|
14
|
+
const result = new Set();
|
|
15
|
+
let counter = 0;
|
|
16
|
+
for (const start of adjacency.keys()) {
|
|
17
|
+
if (index.has(start))
|
|
18
|
+
continue;
|
|
19
|
+
const work = [{ node: start, next: 0 }];
|
|
20
|
+
index.set(start, counter);
|
|
21
|
+
low.set(start, counter);
|
|
22
|
+
counter += 1;
|
|
23
|
+
stack.push(start);
|
|
24
|
+
onStack.add(start);
|
|
25
|
+
while (work.length > 0) {
|
|
26
|
+
const frame = work[work.length - 1];
|
|
27
|
+
const neighbours = adjacency.get(frame.node);
|
|
28
|
+
if (frame.next < neighbours.length) {
|
|
29
|
+
const to = neighbours[frame.next];
|
|
30
|
+
frame.next += 1;
|
|
31
|
+
if (!index.has(to)) {
|
|
32
|
+
index.set(to, counter);
|
|
33
|
+
low.set(to, counter);
|
|
34
|
+
counter += 1;
|
|
35
|
+
stack.push(to);
|
|
36
|
+
onStack.add(to);
|
|
37
|
+
work.push({ node: to, next: 0 });
|
|
38
|
+
}
|
|
39
|
+
else if (onStack.has(to)) {
|
|
40
|
+
low.set(frame.node, Math.min(low.get(frame.node), index.get(to)));
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
work.pop();
|
|
45
|
+
const parent = work[work.length - 1];
|
|
46
|
+
if (parent)
|
|
47
|
+
low.set(parent.node, Math.min(low.get(parent.node), low.get(frame.node)));
|
|
48
|
+
if (low.get(frame.node) === index.get(frame.node)) {
|
|
49
|
+
const component = [];
|
|
50
|
+
for (;;) {
|
|
51
|
+
const member = stack.pop();
|
|
52
|
+
onStack.delete(member);
|
|
53
|
+
component.push(member);
|
|
54
|
+
if (member === frame.node)
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (component.length >= 2)
|
|
58
|
+
for (const member of component)
|
|
59
|
+
result.add(member);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|