devcontour-harness 0.1.0-alpha.5 → 0.1.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -2
- package/docs/compatibility-versions.md +56 -0
- package/docs/compatibility.md +2 -2
- package/docs/connectors.md +323 -0
- package/docs/contracts.md +33 -5
- package/docs/integrations/hindsight.md +148 -0
- package/docs/quickstart.md +1 -1
- package/docs/release-notes/0.1.0-alpha.6.md +238 -0
- package/docs/release-notes/0.1.0-alpha.7.md +71 -0
- package/docs/review-probes.md +199 -0
- package/docs/review-protocol.md +251 -0
- package/docs/toolchains.md +167 -0
- package/docs/turbo-starter.md +269 -0
- package/docs/upgrade.md +77 -24
- package/examples/README.md +1 -0
- package/examples/toolchains/README.md +32 -0
- package/examples/toolchains/go/bad/label/label.go +9 -0
- package/examples/toolchains/go/good/label/label.go +9 -0
- package/examples/toolchains/go/policy.json +79 -0
- package/examples/toolchains/go/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/go/repo/_gitignore +1 -0
- package/examples/toolchains/go/repo/go.mod +5 -0
- package/examples/toolchains/go/repo/go.sum +2 -0
- package/examples/toolchains/go/repo/label/label.go +9 -0
- package/examples/toolchains/go/repo/label/label_test.go +27 -0
- package/examples/toolchains/go/repo/label/version.go +9 -0
- package/examples/toolchains/go/task.json +25 -0
- package/examples/toolchains/mixed-go/bad/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/bad/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/good/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/good/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/policy.json +126 -0
- package/examples/toolchains/mixed-go/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/mixed-go/repo/_gitignore +6 -0
- package/examples/toolchains/mixed-go/repo/backend/go.mod +3 -0
- package/examples/toolchains/mixed-go/repo/backend/label/label.go +9 -0
- package/examples/toolchains/mixed-go/repo/backend/label/label_test.go +30 -0
- package/examples/toolchains/mixed-go/repo/contract/check.mjs +17 -0
- package/examples/toolchains/mixed-go/repo/contract/label.json +9 -0
- package/examples/toolchains/mixed-go/repo/frontend/package.json +15 -0
- package/examples/toolchains/mixed-go/repo/frontend/pnpm-lock.yaml +22 -0
- package/examples/toolchains/mixed-go/repo/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-go/repo/frontend/test/label.test.js +11 -0
- package/examples/toolchains/mixed-go/task.json +25 -0
- package/examples/toolchains/mixed-python/bad/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/bad/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/good/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/good/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/policy.json +121 -0
- package/examples/toolchains/mixed-python/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/mixed-python/repo/_gitignore +6 -0
- package/examples/toolchains/mixed-python/repo/backend/.python-version +1 -0
- package/examples/toolchains/mixed-python/repo/backend/pyproject.toml +19 -0
- package/examples/toolchains/mixed-python/repo/backend/src/fx_backend/__init__.py +6 -0
- package/examples/toolchains/mixed-python/repo/backend/tests/test_label.py +11 -0
- package/examples/toolchains/mixed-python/repo/backend/uv.lock +108 -0
- package/examples/toolchains/mixed-python/repo/contract/check.mjs +17 -0
- package/examples/toolchains/mixed-python/repo/contract/label.json +9 -0
- package/examples/toolchains/mixed-python/repo/frontend/package.json +15 -0
- package/examples/toolchains/mixed-python/repo/frontend/pnpm-lock.yaml +22 -0
- package/examples/toolchains/mixed-python/repo/frontend/src/label.js +4 -0
- package/examples/toolchains/mixed-python/repo/frontend/test/label.test.js +11 -0
- package/examples/toolchains/mixed-python/task.json +25 -0
- package/examples/toolchains/npm/bad/src/label.js +4 -0
- package/examples/toolchains/npm/good/src/label.js +4 -0
- package/examples/toolchains/npm/policy.json +82 -0
- package/examples/toolchains/npm/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/npm/repo/_gitignore +4 -0
- package/examples/toolchains/npm/repo/package-lock.json +22 -0
- package/examples/toolchains/npm/repo/package.json +13 -0
- package/examples/toolchains/npm/repo/src/label.js +4 -0
- package/examples/toolchains/npm/repo/test/label.test.js +13 -0
- package/examples/toolchains/npm/task.json +25 -0
- package/examples/toolchains/pnpm/bad/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/good/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/policy.json +85 -0
- package/examples/toolchains/pnpm/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/pnpm/repo/_gitignore +4 -0
- package/examples/toolchains/pnpm/repo/package.json +10 -0
- package/examples/toolchains/pnpm/repo/packages/checker/check.mjs +5 -0
- package/examples/toolchains/pnpm/repo/packages/checker/package.json +9 -0
- package/examples/toolchains/pnpm/repo/packages/label/package.json +14 -0
- package/examples/toolchains/pnpm/repo/packages/label/src/label.js +4 -0
- package/examples/toolchains/pnpm/repo/packages/label/test/label.test.js +13 -0
- package/examples/toolchains/pnpm/repo/pnpm-lock.yaml +29 -0
- package/examples/toolchains/pnpm/repo/pnpm-workspace.yaml +2 -0
- package/examples/toolchains/pnpm/task.json +25 -0
- package/examples/toolchains/python/bad/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/good/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/policy.json +80 -0
- package/examples/toolchains/python/repo/.python-version +1 -0
- package/examples/toolchains/python/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/python/repo/_gitignore +5 -0
- package/examples/toolchains/python/repo/pyproject.toml +19 -0
- package/examples/toolchains/python/repo/src/fx_label/__init__.py +6 -0
- package/examples/toolchains/python/repo/tests/test_label.py +13 -0
- package/examples/toolchains/python/repo/uv.lock +108 -0
- package/examples/toolchains/python/task.json +25 -0
- package/examples/toolchains/turbo/bad/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/good/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/policy.json +110 -0
- package/examples/toolchains/turbo/repo/REQUIREMENTS.md +9 -0
- package/examples/toolchains/turbo/repo/_gitignore +4 -0
- package/examples/toolchains/turbo/repo/package.json +13 -0
- package/examples/toolchains/turbo/repo/packages/checker/check.mjs +5 -0
- package/examples/toolchains/turbo/repo/packages/checker/package.json +9 -0
- package/examples/toolchains/turbo/repo/packages/label/package.json +14 -0
- package/examples/toolchains/turbo/repo/packages/label/src/label.js +4 -0
- package/examples/toolchains/turbo/repo/packages/label/test/label.test.js +13 -0
- package/examples/toolchains/turbo/repo/pnpm-lock.yaml +94 -0
- package/examples/toolchains/turbo/repo/pnpm-workspace.yaml +2 -0
- package/examples/toolchains/turbo/repo/turbo.json +7 -0
- package/examples/toolchains/turbo/task.json +25 -0
- package/examples/turbo-starter/README.md +16 -0
- package/examples/turbo-starter/changes/issued-at-consumer-only.patch +12 -0
- package/examples/turbo-starter/changes/issued-at.patch +130 -0
- package/examples/turbo-starter/cleanup.mjs +58 -0
- package/examples/turbo-starter/graph.json +32 -0
- package/examples/turbo-starter/harness/acceptance/db-isolation.test.mjs +97 -0
- package/examples/turbo-starter/harness/acceptance/env-probe.test.mjs +75 -0
- package/examples/turbo-starter/harness/acceptance/generation.test.mjs +54 -0
- package/examples/turbo-starter/harness/acceptance/issued-at.test.mjs +43 -0
- package/examples/turbo-starter/harness/acceptance/lifecycle-probe.test.mjs +47 -0
- package/examples/turbo-starter/harness/compose-bridge.mjs +593 -0
- package/examples/turbo-starter/harness/compose.acceptance.yml +23 -0
- package/examples/turbo-starter/harness/env/backend.env.template +7 -0
- package/examples/turbo-starter/harness/env/compose.env.template +6 -0
- package/examples/turbo-starter/policy.json +109 -0
- package/examples/turbo-starter/policy.lifecycle.json +114 -0
- package/examples/turbo-starter/setup.mjs +352 -0
- package/lib/agents/cli-adapter.js +10 -1
- package/lib/agents/inspect.d.ts +56 -2
- package/lib/agents/inspect.js +264 -14
- package/lib/agents/review-probe.d.ts +229 -7
- package/lib/agents/review-probe.js +830 -72
- package/lib/agents/schemas.d.ts +3 -1
- package/lib/agents/schemas.js +7 -3
- package/lib/cli.js +167 -7
- package/lib/contracts/cli.d.ts +820 -11
- package/lib/contracts/cli.js +243 -4
- package/lib/contracts/errors.d.ts +3 -1
- package/lib/contracts/errors.js +2 -0
- package/lib/contracts/events.d.ts +37 -5
- package/lib/contracts/events.js +9 -0
- package/lib/contracts/execution.d.ts +389 -15
- package/lib/contracts/execution.js +76 -4
- package/lib/contracts/failure.js +5 -0
- package/lib/contracts/index.d.ts +1 -0
- package/lib/contracts/index.js +1 -0
- package/lib/contracts/policy.d.ts +15 -0
- package/lib/contracts/policy.js +10 -0
- package/lib/contracts/ports.d.ts +50 -0
- package/lib/contracts/profiles.d.ts +14 -0
- package/lib/contracts/profiles.js +4 -0
- package/lib/contracts/published.d.ts +279 -8
- package/lib/contracts/result.d.ts +171 -0
- package/lib/contracts/result.js +39 -1
- package/lib/contracts/review.d.ts +231 -0
- package/lib/contracts/review.js +326 -2
- package/lib/contracts/toolchain.d.ts +96 -0
- package/lib/contracts/toolchain.js +89 -0
- package/lib/control/doctor.d.ts +16 -1
- package/lib/control/doctor.js +45 -3
- package/lib/control/harness.d.ts +52 -6
- package/lib/control/harness.js +28 -1
- package/lib/control/recovery.d.ts +3 -1
- package/lib/control/review.js +23 -11
- package/lib/engine/budget.d.ts +3 -3
- package/lib/engine/classify.js +1 -0
- package/lib/engine/engine.d.ts +67 -17
- package/lib/engine/prompt.d.ts +29 -1
- package/lib/engine/prompt.js +57 -0
- package/lib/engine/property-recovery.js +2 -0
- package/lib/engine/result.js +52 -0
- package/lib/engine/reuse.js +6 -1
- package/lib/engine/runner.d.ts +25 -0
- package/lib/engine/runner.js +500 -123
- package/lib/environment/lifecycle.js +6 -0
- package/lib/environment/live-calls.js +3 -2
- package/lib/environment/os-tools.d.ts +16 -0
- package/lib/environment/os-tools.js +35 -0
- package/lib/environment/process.d.ts +8 -0
- package/lib/environment/process.js +13 -6
- package/lib/integrations/connector/commands.d.ts +358 -0
- package/lib/integrations/connector/commands.js +428 -0
- package/lib/integrations/connector/config.d.ts +215 -0
- package/lib/integrations/connector/config.js +214 -0
- package/lib/integrations/connector/delivery.d.ts +107 -0
- package/lib/integrations/connector/delivery.js +591 -0
- package/lib/integrations/connector/fake.d.ts +291 -0
- package/lib/integrations/connector/fake.js +786 -0
- package/lib/integrations/connector/history.d.ts +124 -0
- package/lib/integrations/connector/history.js +497 -0
- package/lib/integrations/connector/index.d.ts +16 -0
- package/lib/integrations/connector/index.js +16 -0
- package/lib/integrations/connector/redact.d.ts +19 -0
- package/lib/integrations/connector/redact.js +91 -0
- package/lib/integrations/connector/registry.d.ts +15 -0
- package/lib/integrations/connector/registry.js +43 -0
- package/lib/integrations/connector/snapshot.d.ts +131 -0
- package/lib/integrations/connector/snapshot.js +422 -0
- package/lib/integrations/connector/source.d.ts +37 -0
- package/lib/integrations/connector/source.js +73 -0
- package/lib/integrations/connector/steps.d.ts +71 -0
- package/lib/integrations/connector/steps.js +188 -0
- package/lib/integrations/connector/store.d.ts +140 -0
- package/lib/integrations/connector/store.js +277 -0
- package/lib/integrations/connector/summary.d.ts +38 -0
- package/lib/integrations/connector/summary.js +59 -0
- package/lib/integrations/connector/testing-worker.d.ts +1 -0
- package/lib/integrations/connector/testing-worker.js +81 -0
- package/lib/integrations/connector/testing.d.ts +205 -0
- package/lib/integrations/connector/testing.js +1615 -0
- package/lib/integrations/connector/transport.d.ts +240 -0
- package/lib/integrations/connector/transport.js +28 -0
- package/lib/integrations/memory/context.d.ts +120 -0
- package/lib/integrations/memory/context.js +239 -0
- package/lib/integrations/memory/index.d.ts +8 -0
- package/lib/integrations/memory/index.js +8 -0
- package/lib/integrations/memory/provider.d.ts +79 -0
- package/lib/integrations/memory/provider.js +22 -0
- package/lib/integrations/memory/sink.d.ts +56 -0
- package/lib/integrations/memory/sink.js +116 -0
- package/lib/integrations/memory/testing.d.ts +44 -0
- package/lib/integrations/memory/testing.js +61 -0
- package/lib/integrations/relay-v1/index.d.ts +122 -0
- package/lib/integrations/relay-v1/index.js +860 -0
- package/lib/integrations/relay-v2/index.d.ts +145 -0
- package/lib/integrations/relay-v2/index.js +752 -0
- package/lib/integrations/relay-v2/process.d.ts +32 -0
- package/lib/integrations/relay-v2/process.js +112 -0
- package/lib/integrations/relay-v2/protocol.d.ts +172 -0
- package/lib/integrations/relay-v2/protocol.js +142 -0
- package/lib/profiles/resolve.d.ts +5 -1
- package/lib/profiles/resolve.js +31 -0
- package/lib/storage/document.d.ts +116 -16
- package/lib/toolchains/index.d.ts +9 -0
- package/lib/toolchains/index.js +9 -0
- package/lib/toolchains/inspect.d.ts +105 -0
- package/lib/toolchains/inspect.js +831 -0
- package/lib/toolchains/manifests.d.ts +67 -0
- package/lib/toolchains/manifests.js +238 -0
- package/lib/toolchains/probe.d.ts +28 -0
- package/lib/toolchains/probe.js +54 -0
- package/lib/toolchains/recipes.d.ts +33 -0
- package/lib/toolchains/recipes.js +51 -0
- package/lib/verification/acceptance.d.ts +16 -0
- package/lib/verification/acceptance.js +16 -0
- package/lib/verification/gates.d.ts +38 -0
- package/lib/verification/gates.js +108 -26
- package/package.json +22 -1
- package/profiles/go-gotestsum.json +21 -0
- package/profiles/npm-ci.json +22 -0
- package/profiles/pnpm-frozen.json +24 -0
- package/profiles/python-uv.json +26 -0
- package/schemas/execution-document.schema.json +345 -4
- package/schemas/execution-policy.schema.json +48 -0
- package/schemas/result-envelope.schema.json +213 -1
- package/schemas/run-event.schema.json +93 -0
- package/schemas/run-result.schema.json +207 -0
- package/schemas/stack-profile.schema.json +43 -0
- package/templates/controller-mode/AGENTS.controller.md +58 -0
- package/templates/controller-mode/README.md +44 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, posix, resolve, sep } from 'node:path';
|
|
3
|
+
import { HarnessError, } from "../contracts/index.js";
|
|
4
|
+
import { Manifests, goModule, join2, lockedVersion, lockfiles, packageManagerField, scriptsOf, turboTasks, uvLocked, workspacePackages, } from "./manifests.js";
|
|
5
|
+
import { PROBE_TIMEOUT_MS, defaultProbe, moduleVersionOf, resolveExecutable, satisfies, versionOf, } from "./probe.js";
|
|
6
|
+
/**
|
|
7
|
+
* Коды проблем диагностики toolchain. Каждая проблема называет поле
|
|
8
|
+
* конфигурации (`field`) и, когда причина в файле проекта, — путь (`path`).
|
|
9
|
+
*/
|
|
10
|
+
export const toolchainProblemCodes = [
|
|
11
|
+
'REPOSITORY_MISSING',
|
|
12
|
+
'CWD_MISSING',
|
|
13
|
+
'TOOL_MISSING',
|
|
14
|
+
'TOOL_VERSION',
|
|
15
|
+
'TOOL_VERSION_UNKNOWN',
|
|
16
|
+
'TOOL_NOT_LOCKED',
|
|
17
|
+
'MANIFEST_MISSING',
|
|
18
|
+
'LOCKFILE_MISSING',
|
|
19
|
+
'LOCKFILE_AMBIGUOUS',
|
|
20
|
+
'SCRIPT_UNKNOWN',
|
|
21
|
+
'TASK_UNKNOWN',
|
|
22
|
+
'FILTER_UNKNOWN',
|
|
23
|
+
'INSTALL_UNLOCKED',
|
|
24
|
+
'UNPINNED',
|
|
25
|
+
'IF_PRESENT',
|
|
26
|
+
'CACHE_REPLAY',
|
|
27
|
+
'REPORT_MISMATCH',
|
|
28
|
+
'COMPOSITION_CONFLICT',
|
|
29
|
+
'PROJECT_FILE_MISSING',
|
|
30
|
+
'SCRIPT_UNRESOLVED',
|
|
31
|
+
'TOOL_IN_PROJECT',
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Исполняемый файл под контролем проекта: его канонический путь (после всех
|
|
35
|
+
* symlink, включая symlink каталога PATH) лежит в защищённом корне — в
|
|
36
|
+
* репозитории (с его node_modules/.bin и .venv) или в каталоге состояния с
|
|
37
|
+
* worktrees попыток. Такой файл диагностика не запускает. Внешние инструменты
|
|
38
|
+
* в node_modules окружения (npm, pnpm из nvm) — не код проекта.
|
|
39
|
+
*/
|
|
40
|
+
const projectControlled = (real, roots) => roots.some((r) => real === r || real.startsWith(r + sep));
|
|
41
|
+
const canonical = (path) => {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(path);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const fmtField = (f) => f.join('.');
|
|
50
|
+
const pathFlag = (args, names) => {
|
|
51
|
+
for (let i = 0; i < args.length; i++) {
|
|
52
|
+
const a = args[i];
|
|
53
|
+
for (const n of names) {
|
|
54
|
+
if (a === n)
|
|
55
|
+
return args[i + 1];
|
|
56
|
+
if (a.startsWith(n + '='))
|
|
57
|
+
return a.slice(n.length + 1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
const allFlags = (args, names) => {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const a = args[i];
|
|
66
|
+
for (const n of names) {
|
|
67
|
+
if (a === n && args[i + 1] !== undefined)
|
|
68
|
+
out.push(args[i + 1]);
|
|
69
|
+
else if (a.startsWith(n + '='))
|
|
70
|
+
out.push(a.slice(n.length + 1));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
/** Позиционные аргументы: флаги со значением пропускаются вместе со значением. */
|
|
76
|
+
const positional = (args, valued) => {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (let i = 0; i < args.length; i++) {
|
|
79
|
+
const a = args[i];
|
|
80
|
+
if (a === '--')
|
|
81
|
+
break;
|
|
82
|
+
if (a.startsWith('-')) {
|
|
83
|
+
if (valued.includes(a))
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out.push(a);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
const normal = (dir, rel) => {
|
|
92
|
+
const joined = posix.normalize(dir === '.' ? rel : `${dir}/${rel}`).replace(/\/$/, '');
|
|
93
|
+
return joined === '' ? '.' : joined;
|
|
94
|
+
};
|
|
95
|
+
const count1 = (args) => args.some((a, i) => a === '-count=1' || (a === '-count' && args[i + 1] === '1'));
|
|
96
|
+
const npmScriptAliases = {
|
|
97
|
+
test: 'test',
|
|
98
|
+
t: 'test',
|
|
99
|
+
tst: 'test',
|
|
100
|
+
start: 'start',
|
|
101
|
+
stop: 'stop',
|
|
102
|
+
restart: 'restart',
|
|
103
|
+
};
|
|
104
|
+
const pnpmBuiltins = new Set([
|
|
105
|
+
'install',
|
|
106
|
+
'i',
|
|
107
|
+
'add',
|
|
108
|
+
'remove',
|
|
109
|
+
'rm',
|
|
110
|
+
'update',
|
|
111
|
+
'up',
|
|
112
|
+
'exec',
|
|
113
|
+
'dlx',
|
|
114
|
+
'run',
|
|
115
|
+
'test',
|
|
116
|
+
't',
|
|
117
|
+
'store',
|
|
118
|
+
'list',
|
|
119
|
+
'ls',
|
|
120
|
+
'why',
|
|
121
|
+
'outdated',
|
|
122
|
+
'audit',
|
|
123
|
+
'config',
|
|
124
|
+
'fetch',
|
|
125
|
+
'rebuild',
|
|
126
|
+
'prune',
|
|
127
|
+
'link',
|
|
128
|
+
'unlink',
|
|
129
|
+
'import',
|
|
130
|
+
'create',
|
|
131
|
+
'licenses',
|
|
132
|
+
'deploy',
|
|
133
|
+
]);
|
|
134
|
+
/**
|
|
135
|
+
* Диагностика toolchain одного репозитория политики: какие команды и где
|
|
136
|
+
* исполнятся, какие внешние инструменты и версии им нужны, есть ли lockfiles
|
|
137
|
+
* и не противоречат ли они выбору, существуют ли scripts/tasks/фильтры,
|
|
138
|
+
* каталоги и отчёты. Только чтение файлов и ограниченные по времени пробы
|
|
139
|
+
* версий: ни установки, ни кода проекта, ни новых файлов.
|
|
140
|
+
*/
|
|
141
|
+
export async function inspectRepository(repo, root, field, options = {}) {
|
|
142
|
+
const report = {
|
|
143
|
+
repositoryId: repo.id,
|
|
144
|
+
root,
|
|
145
|
+
...(options.configSource ? { configSource: options.configSource } : {}),
|
|
146
|
+
plan: [],
|
|
147
|
+
tools: [],
|
|
148
|
+
lockfiles: [],
|
|
149
|
+
reports: [],
|
|
150
|
+
ambiguities: [],
|
|
151
|
+
warnings: [],
|
|
152
|
+
problems: [],
|
|
153
|
+
};
|
|
154
|
+
const problem = (code, f, message, path) => report.problems.push({ code, field: f, message, ...(path !== undefined ? { path } : {}) });
|
|
155
|
+
const warning = (code, f, message) => report.warnings.push({ code, field: f, message });
|
|
156
|
+
if (!existsSync(root)) {
|
|
157
|
+
problem('REPOSITORY_MISSING', [...field, 'path'], 'Каталог репозитория не найден: ' + root);
|
|
158
|
+
return report;
|
|
159
|
+
}
|
|
160
|
+
const m = new Manifests(root);
|
|
161
|
+
const env = options.env ?? process.env;
|
|
162
|
+
const values = { ...options.environment?.values, ...repo.environment?.values };
|
|
163
|
+
const PATH = values.PATH ?? env.PATH;
|
|
164
|
+
const probe = options.probe ?? defaultProbe;
|
|
165
|
+
const timeoutMs = options.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
|
|
166
|
+
const tools = new Map();
|
|
167
|
+
const tool = (name, provider, usedBy) => {
|
|
168
|
+
const key = provider + ':' + name;
|
|
169
|
+
let entry = tools.get(key);
|
|
170
|
+
if (!entry) {
|
|
171
|
+
entry = { name, provider, found: false, required: [], usedBy: [] };
|
|
172
|
+
tools.set(key, entry);
|
|
173
|
+
}
|
|
174
|
+
if (usedBy && !entry.usedBy.includes(fmtField(usedBy)))
|
|
175
|
+
entry.usedBy.push(fmtField(usedBy));
|
|
176
|
+
return entry;
|
|
177
|
+
};
|
|
178
|
+
const requires = (name, version, source) => {
|
|
179
|
+
const entry = tool(name, 'path');
|
|
180
|
+
if (!entry.required.some((r) => r.version === version && r.source === source))
|
|
181
|
+
entry.required.push({ version, source });
|
|
182
|
+
};
|
|
183
|
+
/** Каталог node-проекта → менеджеры, чьи команды в нём исполняются. */
|
|
184
|
+
const managers = new Map();
|
|
185
|
+
const useManager = (dir, manager, f) => {
|
|
186
|
+
const project = nodeProject(dir);
|
|
187
|
+
const used = managers.get(project) ?? new Map();
|
|
188
|
+
if (!used.has(manager))
|
|
189
|
+
used.set(manager, f);
|
|
190
|
+
managers.set(project, used);
|
|
191
|
+
};
|
|
192
|
+
const nodeProject = (dir) => m.nearest(dir, 'pnpm-workspace.yaml') ??
|
|
193
|
+
m.ancestors(dir).find((d) => m.lockfilesIn(d).length) ??
|
|
194
|
+
dir;
|
|
195
|
+
const dirOf = (base, value, f) => {
|
|
196
|
+
if (value === undefined)
|
|
197
|
+
return base;
|
|
198
|
+
if (isAbsolute(value)) {
|
|
199
|
+
problem('CWD_MISSING', f, 'Каталог команды должен быть относительным: ' + value);
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
const d = normal(base, value);
|
|
203
|
+
if (d.startsWith('..') || !m.isDir(d)) {
|
|
204
|
+
problem('CWD_MISSING', f, 'Каталог команды не найден в репозитории: ' + d, d);
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
return d;
|
|
208
|
+
};
|
|
209
|
+
const checkScript = (dir, script, f, inv) => {
|
|
210
|
+
if (!script)
|
|
211
|
+
return;
|
|
212
|
+
const scripts = scriptsOf(m, dir);
|
|
213
|
+
if (!scripts)
|
|
214
|
+
return problem('MANIFEST_MISSING', f, `Нет package.json для script ${script}`, join2(dir, 'package.json'));
|
|
215
|
+
if (!(script in scripts))
|
|
216
|
+
problem('SCRIPT_UNKNOWN', f, `Script ${script} не объявлен в ${join2(dir, 'package.json')}; есть: ${Object.keys(scripts).join(', ') || '—'}`, join2(dir, 'package.json'));
|
|
217
|
+
else if (inv)
|
|
218
|
+
chain(inv, dir, script);
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Цепочка package scripts gate: script, вызывающий turbo напрямую или через
|
|
222
|
+
* другой script (`pnpm run`, `pnpm --filter … run`, `npm run`), проверяется
|
|
223
|
+
* так же, как прямой argv. Script, который нельзя разобрать статически
|
|
224
|
+
* (подстановка, конвейер, `sh -c`, `eval`), в turbo-проекте — проблема:
|
|
225
|
+
* угадывать, читает ли он cache, нельзя.
|
|
226
|
+
*/
|
|
227
|
+
const chain = (inv, dir, script, seen = new Set()) => {
|
|
228
|
+
if (inv.kind === undefined)
|
|
229
|
+
return;
|
|
230
|
+
const key = dir + '#' + script;
|
|
231
|
+
if (seen.has(key))
|
|
232
|
+
return;
|
|
233
|
+
seen.add(key);
|
|
234
|
+
const text = scriptsOf(m, dir)?.[script];
|
|
235
|
+
if (text === undefined)
|
|
236
|
+
return;
|
|
237
|
+
const file = join2(dir, 'package.json');
|
|
238
|
+
if (/\$\(|`|(^|[^|])\|(?!\|)|\beval\b|\b(?:ba)?sh\s+-c\b|\bxargs\b/.test(text)) {
|
|
239
|
+
if (m.nearest(dir, 'turbo.json') !== undefined || /\bturbo\b/.test(text))
|
|
240
|
+
problem('SCRIPT_UNRESOLVED', inv.field, `Script ${script} в ${file} не разбирается статически («${text}»): нельзя доказать, что turbo не восстановит отчёт из cache`, file);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
for (const segment of text.split(/&&|\|\||;/)) {
|
|
244
|
+
const tokens = segment
|
|
245
|
+
.trim()
|
|
246
|
+
.split(/\s+/)
|
|
247
|
+
.filter(Boolean)
|
|
248
|
+
.map((t) => t.replace(/^['"]|['"]$/g, ''));
|
|
249
|
+
const assigned = {};
|
|
250
|
+
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) {
|
|
251
|
+
const [k, ...v] = tokens.shift().split('=');
|
|
252
|
+
assigned[k] = v.join('=');
|
|
253
|
+
}
|
|
254
|
+
const [t0, ...rest] = tokens;
|
|
255
|
+
if (!t0)
|
|
256
|
+
continue;
|
|
257
|
+
const force = ['true', '1'].includes(assigned.TURBO_FORCE ?? '') ? ['--force'] : [];
|
|
258
|
+
const viaTurbo = (args, at) => turbo(inv, [...args, ...force], at, true);
|
|
259
|
+
if (t0 === 'turbo')
|
|
260
|
+
viaTurbo(rest, dir);
|
|
261
|
+
else if (t0 === 'npx' && rest[0] === 'turbo')
|
|
262
|
+
viaTurbo(rest.slice(1), dir);
|
|
263
|
+
else if (t0 === 'pnpm' || t0 === 'npm') {
|
|
264
|
+
const flag = pathFlag(rest, t0 === 'pnpm' ? ['--dir', '-C'] : ['--prefix']);
|
|
265
|
+
const base = flag ? normal(dir, flag) : dir;
|
|
266
|
+
const filters = t0 === 'pnpm' ? allFlags(rest, ['--filter', '-F']) : [];
|
|
267
|
+
const pos = positional(rest, [
|
|
268
|
+
'--dir',
|
|
269
|
+
'-C',
|
|
270
|
+
'--filter',
|
|
271
|
+
'-F',
|
|
272
|
+
'--prefix',
|
|
273
|
+
'-w',
|
|
274
|
+
'--workspace',
|
|
275
|
+
]);
|
|
276
|
+
if ((pos[0] === 'exec' && pos[1] === 'turbo') || (t0 === 'pnpm' && pos[0] === 'turbo')) {
|
|
277
|
+
viaTurbo(rest.slice(rest.indexOf('turbo') + 1), base);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const name = pos[0] === 'run' || pos[0] === 'run-script'
|
|
281
|
+
? pos[1]
|
|
282
|
+
: pos[0] && npmScriptAliases[pos[0]]
|
|
283
|
+
? npmScriptAliases[pos[0]]
|
|
284
|
+
: t0 === 'pnpm' && pos[0] && !pnpmBuiltins.has(pos[0])
|
|
285
|
+
? pos[0]
|
|
286
|
+
: undefined;
|
|
287
|
+
if (!name)
|
|
288
|
+
continue;
|
|
289
|
+
const targets = filters.length || rest.includes('-r') || rest.includes('--recursive')
|
|
290
|
+
? (selectPackages(m.nearest(base, 'pnpm-workspace.yaml') ?? base, filters, inv.field) ?? []).map((p) => p.dir)
|
|
291
|
+
: [base];
|
|
292
|
+
for (const target of targets)
|
|
293
|
+
chain(inv, target, name, seen);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const matchPackages = (pkgs, filter, wsRoot) => {
|
|
298
|
+
let f = filter
|
|
299
|
+
.replace(/^\.\.\./, '')
|
|
300
|
+
.replace(/\.\.\.$/, '')
|
|
301
|
+
.replace(/^\^/, '');
|
|
302
|
+
if (f.startsWith('{') && f.endsWith('}'))
|
|
303
|
+
f = f.slice(1, -1);
|
|
304
|
+
const glob = (pattern) => new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
|
|
305
|
+
if (f.startsWith('./') || f === '.') {
|
|
306
|
+
const want = glob(normal(wsRoot, f));
|
|
307
|
+
return pkgs.filter((p) => want.test(p.dir));
|
|
308
|
+
}
|
|
309
|
+
const want = glob(f);
|
|
310
|
+
return pkgs.filter((p) => p.name !== undefined && want.test(p.name));
|
|
311
|
+
};
|
|
312
|
+
const selectPackages = (wsRoot, filters, f) => {
|
|
313
|
+
const pkgs = workspacePackages(m, wsRoot);
|
|
314
|
+
if (!filters.length)
|
|
315
|
+
return pkgs;
|
|
316
|
+
const out = new Map();
|
|
317
|
+
for (const filter of filters) {
|
|
318
|
+
const found = matchPackages(pkgs, filter, wsRoot);
|
|
319
|
+
if (!found.length) {
|
|
320
|
+
problem('FILTER_UNKNOWN', f, `Фильтр ${filter} не выбирает ни одного пакета workspace ${wsRoot}; есть: ${pkgs.map((p) => p.name ?? p.dir).join(', ')}`, join2(wsRoot, 'pnpm-workspace.yaml'));
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
for (const p of found)
|
|
324
|
+
out.set(p.dir, p);
|
|
325
|
+
}
|
|
326
|
+
return [...out.values()];
|
|
327
|
+
};
|
|
328
|
+
const checkReport = (inv, dir, value, f) => {
|
|
329
|
+
if (!value || !inv.report)
|
|
330
|
+
return;
|
|
331
|
+
const produced = normal(dir, value);
|
|
332
|
+
if (produced !== posix.normalize(inv.report))
|
|
333
|
+
problem('REPORT_MISMATCH', f, `Команда пишет JUnit в ${produced}, а gate читает ${inv.report}; отчёт gate не будет свежим`);
|
|
334
|
+
};
|
|
335
|
+
const turbo = (inv, args, dir, projectBin) => {
|
|
336
|
+
const f = inv.field;
|
|
337
|
+
const ws = m.nearest(dir, 'turbo.json');
|
|
338
|
+
if (projectBin) {
|
|
339
|
+
const root = ws ?? nodeProject(dir);
|
|
340
|
+
const pkg = m.json(join2(root, 'package.json')) ?? {};
|
|
341
|
+
const declared = ['devDependencies', 'dependencies'].map((k) => pkg[k]?.turbo);
|
|
342
|
+
const version = declared.find(Boolean);
|
|
343
|
+
const entry = tool('turbo', 'project', f);
|
|
344
|
+
entry.found = Boolean(version);
|
|
345
|
+
if (version) {
|
|
346
|
+
// Диапазон в package.json допустим, если закоммиченный lockfile
|
|
347
|
+
// закрепляет точную разрешённую версию: её и ставит locked install.
|
|
348
|
+
const locked = /^\d+\.\d+\.\d+$/.test(version) ? version : lockedVersion(m, root, 'turbo');
|
|
349
|
+
entry.version = locked ?? version;
|
|
350
|
+
if (!locked)
|
|
351
|
+
problem('UNPINNED', f, `turbo ${version} в ${join2(root, 'package.json')} без точной версии в lockfile: закрепите версию`, join2(root, 'package.json'));
|
|
352
|
+
}
|
|
353
|
+
else
|
|
354
|
+
problem('TOOL_MISSING', f, `turbo не объявлен в ${join2(root, 'package.json')}`, join2(root, 'package.json'));
|
|
355
|
+
}
|
|
356
|
+
if (!ws)
|
|
357
|
+
return problem('MANIFEST_MISSING', f, 'Нет turbo.json для turbo', join2(dir, 'turbo.json'));
|
|
358
|
+
const valued = [
|
|
359
|
+
'--filter',
|
|
360
|
+
'-F',
|
|
361
|
+
'--cache',
|
|
362
|
+
'--cache-dir',
|
|
363
|
+
'--concurrency',
|
|
364
|
+
'--env-mode',
|
|
365
|
+
'--log-order',
|
|
366
|
+
'--output-logs',
|
|
367
|
+
'--graph',
|
|
368
|
+
'--summarize',
|
|
369
|
+
];
|
|
370
|
+
const pos = positional(args, valued);
|
|
371
|
+
const tasks = pos[0] === 'run' ? pos.slice(1) : pos.slice(0, 1);
|
|
372
|
+
if (!tasks.length)
|
|
373
|
+
return problem('TASK_UNKNOWN', f, 'turbo без задачи');
|
|
374
|
+
const known = turboTasks(m, ws) ?? [];
|
|
375
|
+
const filters = allFlags(args, ['--filter', '-F']);
|
|
376
|
+
const selected = selectPackages(ws, filters, f);
|
|
377
|
+
for (const task of tasks) {
|
|
378
|
+
if (!known.some((k) => k === task || k.endsWith('#' + task)))
|
|
379
|
+
problem('TASK_UNKNOWN', f, `Задача ${task} не объявлена в ${join2(ws, 'turbo.json')}; есть: ${known.join(', ') || '—'}`, join2(ws, 'turbo.json'));
|
|
380
|
+
else if (selected && !selected.some((p) => task in p.scripts && p.dir !== ws))
|
|
381
|
+
problem('TASK_UNKNOWN', f, `Ни один выбранный пакет (${selected.map((p) => p.name ?? p.dir).join(', ')}) не объявляет script ${task}: turbo ничего не исполнит`, join2(ws, 'turbo.json'));
|
|
382
|
+
}
|
|
383
|
+
if (inv.kind === 'test') {
|
|
384
|
+
const cache = pathFlag(args, ['--cache']);
|
|
385
|
+
const readDisabled = cache !== undefined && !/:r/.test(cache);
|
|
386
|
+
if (!args.includes('--force') && !readDisabled && values.TURBO_FORCE !== 'true')
|
|
387
|
+
problem('CACHE_REPLAY', f, 'Test gate через turbo без --force (или --cache без чтения): попадание в cache восстановит старый отчёт вместо прогона тестов');
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const goTool = (inv, dir) => {
|
|
391
|
+
const mod = m.nearest(dir, 'go.mod');
|
|
392
|
+
if (!mod) {
|
|
393
|
+
problem('MANIFEST_MISSING', inv.field, 'Нет go.mod для Go-команды', join2(dir, 'go.mod'));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const gm = goModule(m, mod);
|
|
397
|
+
if (gm.go)
|
|
398
|
+
requires('go', '>=' + gm.go, join2(mod, 'go.mod') + '#go');
|
|
399
|
+
if (gm.toolchain)
|
|
400
|
+
requires('go', '>=' + gm.toolchain, join2(mod, 'go.mod') + '#toolchain');
|
|
401
|
+
if (gm.requires && !m.isFile(join2(mod, 'go.sum')))
|
|
402
|
+
problem('LOCKFILE_MISSING', inv.field, 'go.mod с require без go.sum', join2(mod, 'go.sum'));
|
|
403
|
+
};
|
|
404
|
+
const analyze = (inv, argv, dir) => {
|
|
405
|
+
const f = inv.field;
|
|
406
|
+
const [exe, ...args] = argv;
|
|
407
|
+
for (const a of argv)
|
|
408
|
+
if (/@latest\b/.test(a))
|
|
409
|
+
problem('UNPINNED', f, `Незакреплённая версия в команде: ${a}`);
|
|
410
|
+
if (exe.includes('/')) {
|
|
411
|
+
// Файл проекта: проверяется существование, но не исполняется.
|
|
412
|
+
const rel = isAbsolute(exe) ? m.rel(exe) : normal(dir, exe);
|
|
413
|
+
if (isAbsolute(exe) && rel === undefined) {
|
|
414
|
+
const entry = tool(exe, 'path', f);
|
|
415
|
+
entry.found = Boolean(resolveExecutable(exe, PATH));
|
|
416
|
+
if (!entry.found)
|
|
417
|
+
problem('TOOL_MISSING', f, 'Исполняемый файл не найден: ' + exe);
|
|
418
|
+
}
|
|
419
|
+
else if (!rel || !m.isFile(rel))
|
|
420
|
+
problem('PROJECT_FILE_MISSING', f, 'Файл команды не найден в репозитории: ' + exe, rel);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
tool(exe, 'path', f);
|
|
424
|
+
const name = basename(exe);
|
|
425
|
+
if (name === 'npm') {
|
|
426
|
+
const d = dirOf(dir, pathFlag(args, ['--prefix']), f);
|
|
427
|
+
if (!d)
|
|
428
|
+
return;
|
|
429
|
+
const pos = positional(args, ['--prefix', '-w', '--workspace']);
|
|
430
|
+
const sub = pos[0];
|
|
431
|
+
useManager(d, 'npm', f);
|
|
432
|
+
if (args.includes('--if-present'))
|
|
433
|
+
problem('IF_PRESENT', f, '--if-present превращает отсутствующий script в зелёный шаг');
|
|
434
|
+
if (sub === 'ci' || sub === 'clean-install') {
|
|
435
|
+
if (!m.isFile(join2(d, 'package.json')))
|
|
436
|
+
problem('MANIFEST_MISSING', f, 'npm ci без package.json', join2(d, 'package.json'));
|
|
437
|
+
const project = nodeProject(d);
|
|
438
|
+
if (!['package-lock.json', 'npm-shrinkwrap.json'].some((l) => m.isFile(join2(project, l))))
|
|
439
|
+
problem('LOCKFILE_MISSING', f, 'npm ci без package-lock.json', join2(project, 'package-lock.json'));
|
|
440
|
+
}
|
|
441
|
+
else if (sub === 'install' || sub === 'i' || sub === 'add' || sub === 'update')
|
|
442
|
+
problem('INSTALL_UNLOCKED', f, `npm ${sub} может переписать package-lock.json; используйте npm ci`);
|
|
443
|
+
else if (sub === 'run' || sub === 'run-script' || sub === 'rum' || sub === 'urn')
|
|
444
|
+
checkScript(d, pos[1], f, inv);
|
|
445
|
+
else if (sub && npmScriptAliases[sub])
|
|
446
|
+
checkScript(d, npmScriptAliases[sub], f, inv);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (name === 'npx') {
|
|
450
|
+
problem('UNPINNED', f, 'npx может скачать пакет; используйте script проекта или npm exec --no');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (name === 'pnpm') {
|
|
454
|
+
const d = dirOf(dir, pathFlag(args, ['--dir', '-C']), f);
|
|
455
|
+
if (!d)
|
|
456
|
+
return;
|
|
457
|
+
const valued = [
|
|
458
|
+
'--dir',
|
|
459
|
+
'-C',
|
|
460
|
+
'--filter',
|
|
461
|
+
'-F',
|
|
462
|
+
'--reporter',
|
|
463
|
+
'--loglevel',
|
|
464
|
+
'--workspace-concurrency',
|
|
465
|
+
];
|
|
466
|
+
const pos = positional(args, valued);
|
|
467
|
+
const sub = pos[0];
|
|
468
|
+
useManager(d, 'pnpm', f);
|
|
469
|
+
if (args.includes('--if-present'))
|
|
470
|
+
problem('IF_PRESENT', f, '--if-present превращает отсутствующий script в зелёный шаг');
|
|
471
|
+
const wsRoot = m.nearest(d, 'pnpm-workspace.yaml') ?? d;
|
|
472
|
+
const filters = allFlags(args, ['--filter', '-F']);
|
|
473
|
+
const scriptTargets = (script) => {
|
|
474
|
+
if (!script)
|
|
475
|
+
return;
|
|
476
|
+
if (!filters.length && !args.includes('-r') && !args.includes('--recursive'))
|
|
477
|
+
return checkScript(d, script, f, inv);
|
|
478
|
+
const selected = selectPackages(wsRoot, filters, f);
|
|
479
|
+
for (const p of selected ?? [])
|
|
480
|
+
if (script in p.scripts)
|
|
481
|
+
chain(inv, p.dir, script);
|
|
482
|
+
if (selected && !selected.some((p) => script in p.scripts))
|
|
483
|
+
problem('SCRIPT_UNKNOWN', f, `Ни один выбранный пакет (${selected.map((p) => p.name ?? p.dir).join(', ')}) не объявляет script ${script}`, join2(wsRoot, 'pnpm-workspace.yaml'));
|
|
484
|
+
};
|
|
485
|
+
if (sub === 'install' || sub === 'i') {
|
|
486
|
+
if (!args.includes('--frozen-lockfile'))
|
|
487
|
+
problem('INSTALL_UNLOCKED', f, 'pnpm install без --frozen-lockfile может переписать pnpm-lock.yaml');
|
|
488
|
+
const lockDir = m.nearest(d, 'pnpm-lock.yaml') ?? wsRoot;
|
|
489
|
+
if (!m.isFile(join2(lockDir, 'pnpm-lock.yaml')))
|
|
490
|
+
problem('LOCKFILE_MISSING', f, 'pnpm install без pnpm-lock.yaml', join2(wsRoot, 'pnpm-lock.yaml'));
|
|
491
|
+
}
|
|
492
|
+
else if (sub && ['add', 'remove', 'rm', 'update', 'up'].includes(sub))
|
|
493
|
+
problem('INSTALL_UNLOCKED', f, `pnpm ${sub} меняет зависимости и lockfile`);
|
|
494
|
+
else if (sub === 'exec') {
|
|
495
|
+
const rest = args.slice(args.indexOf('exec') + 1);
|
|
496
|
+
if (rest[0] === 'turbo')
|
|
497
|
+
turbo(inv, rest.slice(1), d, true);
|
|
498
|
+
}
|
|
499
|
+
else if (sub === 'dlx') {
|
|
500
|
+
const spec = pos[1] ?? '';
|
|
501
|
+
if (!/^@?[^@]+@\d/.test(spec))
|
|
502
|
+
problem('UNPINNED', f, 'pnpm dlx без точной версии пакета');
|
|
503
|
+
}
|
|
504
|
+
else if (sub === 'run')
|
|
505
|
+
scriptTargets(pos[1]);
|
|
506
|
+
else if (sub === 'test' || sub === 't')
|
|
507
|
+
scriptTargets('test');
|
|
508
|
+
else if (sub && !pnpmBuiltins.has(sub)) {
|
|
509
|
+
const scripts = scriptsOf(m, d) ?? {};
|
|
510
|
+
if (sub in scripts || filters.length)
|
|
511
|
+
scriptTargets(sub);
|
|
512
|
+
else if (sub === 'turbo')
|
|
513
|
+
turbo(inv, args.slice(args.indexOf('turbo') + 1), d, true);
|
|
514
|
+
else
|
|
515
|
+
checkScript(d, sub, f, inv);
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (name === 'turbo')
|
|
520
|
+
return turbo(inv, args, dir, false);
|
|
521
|
+
if (name === 'go') {
|
|
522
|
+
let rest = args;
|
|
523
|
+
let d = dir;
|
|
524
|
+
if (rest[0] === '-C') {
|
|
525
|
+
d = dirOf(dir, rest[1], f);
|
|
526
|
+
rest = rest.slice(2);
|
|
527
|
+
}
|
|
528
|
+
else if (rest[0]?.startsWith('-C=')) {
|
|
529
|
+
d = dirOf(dir, rest[0].slice(3), f);
|
|
530
|
+
rest = rest.slice(1);
|
|
531
|
+
}
|
|
532
|
+
if (!d)
|
|
533
|
+
return;
|
|
534
|
+
const sub = rest[0];
|
|
535
|
+
if (sub === 'version' || sub === 'env')
|
|
536
|
+
return;
|
|
537
|
+
goTool(inv, d);
|
|
538
|
+
if (sub === 'get' || (sub === 'mod' && rest[1] === 'tidy'))
|
|
539
|
+
problem('INSTALL_UNLOCKED', f, `go ${rest.slice(0, 2).join(' ')} меняет go.mod/go.sum`);
|
|
540
|
+
if (sub === 'test' && inv.kind === 'test' && !count1(rest))
|
|
541
|
+
warning('CACHE_REPLAY', f, 'go test без -count=1: результат может быть взят из кэша тестов Go (ключ — содержимое; JUnit всё равно пишется заново)');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (name === 'gotestsum') {
|
|
545
|
+
tool('go', 'path', f);
|
|
546
|
+
goTool(inv, dir);
|
|
547
|
+
const split = args.indexOf('--');
|
|
548
|
+
const own = split >= 0 ? args.slice(0, split) : args;
|
|
549
|
+
const goArgs = split >= 0 ? args.slice(split + 1) : [];
|
|
550
|
+
checkReport(inv, dir, pathFlag(own, ['--junitfile']), f);
|
|
551
|
+
if (inv.kind === 'test' && !count1(goArgs))
|
|
552
|
+
warning('CACHE_REPLAY', f, 'gotestsum без -- -count=1: результат может быть взят из кэша тестов Go (ключ — содержимое; JUnit всё равно пишется заново)');
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (name === 'uv') {
|
|
556
|
+
const d = dirOf(dir, pathFlag(args, ['--directory', '--project']), f);
|
|
557
|
+
if (!d)
|
|
558
|
+
return;
|
|
559
|
+
const valued = [
|
|
560
|
+
'--directory',
|
|
561
|
+
'--project',
|
|
562
|
+
'--python',
|
|
563
|
+
'-p',
|
|
564
|
+
'--with',
|
|
565
|
+
'--group',
|
|
566
|
+
'--extra',
|
|
567
|
+
'--package',
|
|
568
|
+
];
|
|
569
|
+
const pos = positional(args, valued);
|
|
570
|
+
const sub = pos[0];
|
|
571
|
+
if (sub === 'tool' || sub === 'pip') {
|
|
572
|
+
problem('INSTALL_UNLOCKED', f, `uv ${sub} ставит пакеты вне uv.lock`);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (sub === 'self' || sub === 'version' || sub === 'python')
|
|
576
|
+
return;
|
|
577
|
+
const project = m.nearest(d, 'pyproject.toml');
|
|
578
|
+
if (!project) {
|
|
579
|
+
problem('MANIFEST_MISSING', f, 'Нет pyproject.toml для uv', join2(d, 'pyproject.toml'));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const lockDir = m.nearest(d, 'uv.lock') ?? project;
|
|
583
|
+
if (sub === 'sync') {
|
|
584
|
+
if (!args.includes('--locked') && !args.includes('--frozen'))
|
|
585
|
+
problem('INSTALL_UNLOCKED', f, 'uv sync без --locked может переписать uv.lock');
|
|
586
|
+
if (!m.isFile(join2(lockDir, 'uv.lock')))
|
|
587
|
+
problem('LOCKFILE_MISSING', f, 'uv sync --locked без uv.lock', join2(project, 'uv.lock'));
|
|
588
|
+
}
|
|
589
|
+
else if (sub === 'lock' || sub === 'add' || sub === 'remove')
|
|
590
|
+
problem('INSTALL_UNLOCKED', f, `uv ${sub} меняет uv.lock`);
|
|
591
|
+
else if (sub === 'run') {
|
|
592
|
+
if (!['--no-sync', '--locked', '--frozen'].some((x) => args.includes(x)))
|
|
593
|
+
problem('INSTALL_UNLOCKED', f, 'uv run без --no-sync/--locked может пересобрать среду и uv.lock');
|
|
594
|
+
if (allFlags(args, ['--with']).length)
|
|
595
|
+
problem('UNPINNED', f, 'uv run --with ставит пакет вне uv.lock');
|
|
596
|
+
const at = args.indexOf(pos[1] ?? '\0');
|
|
597
|
+
const inner = at >= 0 ? args.slice(at) : [];
|
|
598
|
+
const innerExe = inner[0];
|
|
599
|
+
if (innerExe && !innerExe.includes('/') && !/^python(\d(\.\d+)?)?$/.test(innerExe)) {
|
|
600
|
+
const locked = uvLocked(m, lockDir);
|
|
601
|
+
const entry = tool(innerExe, 'project', f);
|
|
602
|
+
const version = locked?.get(innerExe.toLowerCase());
|
|
603
|
+
entry.found = version !== undefined;
|
|
604
|
+
if (version)
|
|
605
|
+
entry.version = version;
|
|
606
|
+
else
|
|
607
|
+
problem('TOOL_NOT_LOCKED', f, `${innerExe} не закреплён в ${join2(lockDir, 'uv.lock')}: среда попытки его не получит`, join2(lockDir, 'uv.lock'));
|
|
608
|
+
}
|
|
609
|
+
if (innerExe === 'pytest')
|
|
610
|
+
checkReport(inv, d, pytestJunit(inner.slice(1)), f);
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (name === 'uvx') {
|
|
615
|
+
if (!/@\d/.test(args.find((a) => !a.startsWith('-')) ?? ''))
|
|
616
|
+
problem('UNPINNED', f, 'uvx без точной версии инструмента');
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (name === 'pytest')
|
|
620
|
+
return checkReport(inv, dir, pytestJunit(args), f);
|
|
621
|
+
if (name === 'node' && args[0] && !args[0].startsWith('-')) {
|
|
622
|
+
const file = normal(dir, args[0]);
|
|
623
|
+
if (!m.isFile(file))
|
|
624
|
+
problem('PROJECT_FILE_MISSING', f, 'Скрипт node не найден в репозитории: ' + args[0], file);
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const steps = (phase, list, at) => list.forEach((step, i) => {
|
|
628
|
+
const entry = {
|
|
629
|
+
phase,
|
|
630
|
+
id: step.id,
|
|
631
|
+
cwd: '.',
|
|
632
|
+
argv: step.command,
|
|
633
|
+
field: [...at, i, 'command'],
|
|
634
|
+
};
|
|
635
|
+
report.plan.push(entry);
|
|
636
|
+
analyze({ field: entry.field }, step.command, '.');
|
|
637
|
+
});
|
|
638
|
+
steps('prepare', repo.prepare, [...field, 'prepare']);
|
|
639
|
+
steps('dependencyBuild', repo.dependencyBuild, [...field, 'dependencyBuild']);
|
|
640
|
+
steps('preflight', repo.preflight, [...field, 'preflight']);
|
|
641
|
+
const lifecycle = repo.lifecycle ?? options.lifecycle;
|
|
642
|
+
const lifecycleField = repo.lifecycle ? [...field, 'lifecycle'] : ['lifecycle'];
|
|
643
|
+
if (lifecycle)
|
|
644
|
+
for (const phase of ['setup', 'ready', 'teardown'])
|
|
645
|
+
steps(phase, lifecycle[phase], [...lifecycleField, phase]);
|
|
646
|
+
const gates = [
|
|
647
|
+
...repo.gates.map((gate, i) => ({ gate, field: [...field, 'gates', i] })),
|
|
648
|
+
...(options.workspaceGates ?? []),
|
|
649
|
+
];
|
|
650
|
+
for (const { gate, field: at } of gates) {
|
|
651
|
+
let cwd = '.';
|
|
652
|
+
if (gate.cwd !== undefined)
|
|
653
|
+
cwd = dirOf('.', gate.cwd, [...at, 'cwd']);
|
|
654
|
+
const entry = {
|
|
655
|
+
phase: 'gate',
|
|
656
|
+
id: gate.id,
|
|
657
|
+
kind: gate.kind,
|
|
658
|
+
cwd: cwd ?? gate.cwd ?? '.',
|
|
659
|
+
argv: gate.command,
|
|
660
|
+
...(gate.report ? { report: gate.report.path } : {}),
|
|
661
|
+
field: [...at, 'command'],
|
|
662
|
+
};
|
|
663
|
+
report.plan.push(entry);
|
|
664
|
+
if (gate.report)
|
|
665
|
+
report.reports.push({ gate: gate.id, path: gate.report.path, cwd: entry.cwd });
|
|
666
|
+
if (cwd === undefined)
|
|
667
|
+
continue;
|
|
668
|
+
analyze({ kind: gate.kind, ...(gate.report ? { report: gate.report.path } : {}), field: entry.field }, gate.command, cwd);
|
|
669
|
+
}
|
|
670
|
+
// Выбор менеджера пакетов и lockfiles каждого node-проекта.
|
|
671
|
+
const toolchain = repo.toolchain;
|
|
672
|
+
const tcField = [...field, 'toolchain'];
|
|
673
|
+
for (const dir of Object.keys(toolchain?.packageManagers ?? {}))
|
|
674
|
+
if (!m.isDir(dir))
|
|
675
|
+
problem('CWD_MISSING', [...tcField, 'packageManagers', dir], 'Каталог не найден: ' + dir, dir);
|
|
676
|
+
const projects = new Set([...managers.keys(), ...Object.keys(toolchain?.packageManagers ?? {})]);
|
|
677
|
+
for (const dir of [...projects].sort()) {
|
|
678
|
+
const used = managers.get(dir) ?? new Map();
|
|
679
|
+
const explicit = toolchain?.packageManagers[dir];
|
|
680
|
+
const declared = packageManagerField(m, dir);
|
|
681
|
+
const files = m.lockfilesIn(dir);
|
|
682
|
+
const lockManagers = [...new Set(files.map((f) => lockfiles[f]))];
|
|
683
|
+
const entry = { dir, files };
|
|
684
|
+
if (used.size > 1)
|
|
685
|
+
problem('COMPOSITION_CONFLICT', [...used.values()][1], `В каталоге ${dir} команды разных менеджеров пакетов: ${[...used.keys()].join(' и ')} (${[...used.values()].map(fmtField).join(', ')})`, dir);
|
|
686
|
+
if (explicit && [...used.keys()].some((u) => u !== explicit))
|
|
687
|
+
problem('COMPOSITION_CONFLICT', [...tcField, 'packageManagers', dir], `Каталог ${dir} закреплён за ${explicit}, но команда использует ${[...used.keys()].join(', ')}`, dir);
|
|
688
|
+
if (declared?.version && explicit && declared.name !== explicit)
|
|
689
|
+
problem('COMPOSITION_CONFLICT', [...tcField, 'packageManagers', dir], `${join2(dir, 'package.json')} объявляет packageManager ${declared.raw}, а политика — ${explicit}`, join2(dir, 'package.json'));
|
|
690
|
+
else if (declared?.version && [...used.keys()].some((u) => u !== declared.name))
|
|
691
|
+
problem('COMPOSITION_CONFLICT', [...used.values()][0], `${join2(dir, 'package.json')} объявляет packageManager ${declared.raw}, а команда использует ${[...used.keys()].join(', ')}`, join2(dir, 'package.json'));
|
|
692
|
+
if (declared?.version && (declared.name === 'npm' || declared.name === 'pnpm'))
|
|
693
|
+
requires(declared.name, declared.version, join2(dir, 'package.json') + '#packageManager');
|
|
694
|
+
const selected = explicit ?? (declared?.version ? declared.name : undefined);
|
|
695
|
+
if (lockManagers.length > 1) {
|
|
696
|
+
if (selected) {
|
|
697
|
+
entry.manager = selected;
|
|
698
|
+
entry.selectedBy = explicit
|
|
699
|
+
? fmtField([...tcField, 'packageManagers', dir])
|
|
700
|
+
: join2(dir, 'package.json') + '#packageManager';
|
|
701
|
+
report.ambiguities.push({
|
|
702
|
+
field: [...tcField, 'packageManagers', dir],
|
|
703
|
+
path: dir,
|
|
704
|
+
message: `В ${dir} lockfiles нескольких менеджеров (${files.join(', ')})`,
|
|
705
|
+
resolvedBy: entry.selectedBy,
|
|
706
|
+
});
|
|
707
|
+
if (!lockManagers.includes(selected))
|
|
708
|
+
problem('LOCKFILE_MISSING', [...tcField, 'packageManagers', dir], `Нет lockfile ${selected} в ${dir}`, dir);
|
|
709
|
+
}
|
|
710
|
+
else
|
|
711
|
+
problem('LOCKFILE_AMBIGUOUS', [...tcField, 'packageManagers', dir], `В ${dir} lockfiles нескольких менеджеров (${files.join(', ')}): выберите менеджер явно (toolchain.packageManagers["${dir}"] или packageManager в package.json)`, dir);
|
|
712
|
+
}
|
|
713
|
+
else if (lockManagers.length === 1) {
|
|
714
|
+
entry.manager = lockManagers[0];
|
|
715
|
+
const other = [...used.keys()].filter((u) => u !== lockManagers[0]);
|
|
716
|
+
if (other.length && !selected)
|
|
717
|
+
problem('COMPOSITION_CONFLICT', used.get(other[0]), `В ${dir} lockfile ${files[0]} (${lockManagers[0]}), а команда использует ${other.join(', ')}`, join2(dir, files[0]));
|
|
718
|
+
}
|
|
719
|
+
report.lockfiles.push(entry);
|
|
720
|
+
}
|
|
721
|
+
// Требования версий из политики.
|
|
722
|
+
for (const [name, version] of Object.entries(toolchain?.tools ?? {}))
|
|
723
|
+
requires(name, version, fmtField([...tcField, 'tools', name]));
|
|
724
|
+
// Пробы: только внешние инструменты из PATH, по одному разу, с пределом времени.
|
|
725
|
+
const roots = [root, ...(options.protectedRoots ?? [])].flatMap((r) => canonical(r) ?? []);
|
|
726
|
+
const external = (file) => {
|
|
727
|
+
const real = file ? canonical(file) : undefined;
|
|
728
|
+
return real && !projectControlled(real, roots) ? real : undefined;
|
|
729
|
+
};
|
|
730
|
+
const goPath = external(resolveExecutable('go', PATH));
|
|
731
|
+
for (const entry of tools.values()) {
|
|
732
|
+
if (entry.provider !== 'path' || entry.name.includes('/'))
|
|
733
|
+
continue;
|
|
734
|
+
const file = resolveExecutable(entry.name, PATH);
|
|
735
|
+
const firstUse = entry.usedBy[0];
|
|
736
|
+
const at = firstUse
|
|
737
|
+
? firstUse.split('.').map((p) => (/^\d+$/.test(p) ? Number(p) : p))
|
|
738
|
+
: [...tcField, 'tools', entry.name];
|
|
739
|
+
if (!file) {
|
|
740
|
+
problem('TOOL_MISSING', at, `${entry.name} не найден в PATH` +
|
|
741
|
+
(entry.required.length
|
|
742
|
+
? ` (нужен ${entry.required.map((r) => r.version).join(', ')})`
|
|
743
|
+
: ''));
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
entry.found = true;
|
|
747
|
+
const real = external(file);
|
|
748
|
+
if (!real) {
|
|
749
|
+
// Инструмент из PATH разрешается (в том числе через symlink файла или
|
|
750
|
+
// каталога PATH) в код проекта: не запускается ни пробой, ни версией.
|
|
751
|
+
entry.provider = 'project';
|
|
752
|
+
problem('TOOL_IN_PROJECT', at, `${entry.name} из PATH разрешается в файл под контролем проекта (${canonical(file) ?? file}): диагностика его не запускает; нужен внешний инструмент`);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
// Под нагрузкой проба может не уложиться в предел: один повтор с
|
|
756
|
+
// удвоенным пределом. Итог по-прежнему ограничен (≤ 3 × timeoutMs).
|
|
757
|
+
const probeEnv = { PATH, HOME: env.HOME, ...(env.TMPDIR ? { TMPDIR: env.TMPDIR } : {}) };
|
|
758
|
+
const argv = [real, entry.name === 'go' ? 'version' : '--version'];
|
|
759
|
+
let run = await probe(argv, { timeoutMs, env: probeEnv });
|
|
760
|
+
if (run.timedOut)
|
|
761
|
+
run = await probe(argv, { timeoutMs: timeoutMs * 2, env: probeEnv });
|
|
762
|
+
let version = run.code === 0 && !run.timedOut ? versionOf(run.stdout + '\n' + run.stderr) : undefined;
|
|
763
|
+
if (!version && goPath && entry.name !== 'go') {
|
|
764
|
+
// Go-бинарник без метки версии (go install): версия модуля из build info.
|
|
765
|
+
const info = await probe([goPath, 'version', '-m', real], {
|
|
766
|
+
timeoutMs,
|
|
767
|
+
env: { PATH, HOME: env.HOME },
|
|
768
|
+
});
|
|
769
|
+
if (info.code === 0)
|
|
770
|
+
version = moduleVersionOf(info.stdout);
|
|
771
|
+
}
|
|
772
|
+
if (version)
|
|
773
|
+
entry.version = version;
|
|
774
|
+
for (const req of entry.required) {
|
|
775
|
+
const reqField = req.source.startsWith(fmtField(tcField))
|
|
776
|
+
? [...tcField, 'tools', entry.name]
|
|
777
|
+
: at;
|
|
778
|
+
const reqPath = req.source.startsWith(fmtField(tcField))
|
|
779
|
+
? undefined
|
|
780
|
+
: req.source.split('#')[0];
|
|
781
|
+
if (!version)
|
|
782
|
+
problem('TOOL_VERSION_UNKNOWN', reqField, `Версия ${entry.name} не определена пробой, а ${req.source} требует ${req.version}`, reqPath);
|
|
783
|
+
else if (!satisfies(version, req.version))
|
|
784
|
+
problem('TOOL_VERSION', reqField, `${entry.name} ${version} не подходит: ${req.source} требует ${req.version}`, reqPath);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
report.tools = [...tools.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
788
|
+
return report;
|
|
789
|
+
}
|
|
790
|
+
function pytestJunit(args) {
|
|
791
|
+
return args
|
|
792
|
+
.map((a, i) => a.startsWith('--junitxml=') || a.startsWith('--junit-xml=')
|
|
793
|
+
? a.slice(a.indexOf('=') + 1)
|
|
794
|
+
: a === '--junitxml' || a === '--junit-xml'
|
|
795
|
+
? args[i + 1]
|
|
796
|
+
: undefined)
|
|
797
|
+
.find((v) => v !== undefined);
|
|
798
|
+
}
|
|
799
|
+
/** Диагностика всех репозиториев политики; пути — от каталога политики. */
|
|
800
|
+
export async function inspectPolicy(policy, baseDir, options = {}) {
|
|
801
|
+
const reports = [];
|
|
802
|
+
for (const [i, repo] of policy.repositories.entries())
|
|
803
|
+
reports.push(await inspectRepository(repo, resolve(baseDir, repo.path), ['repositories', i], {
|
|
804
|
+
...options,
|
|
805
|
+
// Ни один репозиторий политики не даёт исполняемых файлов для проб.
|
|
806
|
+
protectedRoots: [
|
|
807
|
+
...(options.protectedRoots ?? []),
|
|
808
|
+
...policy.repositories.map((r) => resolve(baseDir, r.path)),
|
|
809
|
+
],
|
|
810
|
+
...(policy.lifecycle ? { lifecycle: policy.lifecycle } : {}),
|
|
811
|
+
...(policy.environment ? { environment: policy.environment } : {}),
|
|
812
|
+
workspaceGates: policy.workspaceGates.flatMap((gate, j) => gate.repositoryId === repo.id ? [{ gate, field: ['workspaceGates', j] }] : []),
|
|
813
|
+
}));
|
|
814
|
+
return reports;
|
|
815
|
+
}
|
|
816
|
+
/** Проблемы диагностики строками для `doctor.problems`. */
|
|
817
|
+
export const problemLines = (reports) => reports.flatMap((r) => r.problems.map((p) => `toolchain ${r.repositoryId}: ${p.code} ${fmtField(p.field)}${p.path ? ` (${p.path})` : ''}: ${p.message}`));
|
|
818
|
+
/**
|
|
819
|
+
* Обязательный preflight запуска: та же диагностика, что у `doctor`, но
|
|
820
|
+
* проблема — отказ `NOT_READY` до любой записи состояния и вызова runtime.
|
|
821
|
+
*/
|
|
822
|
+
export async function assertToolchainReady(policy, baseDir, options = {}) {
|
|
823
|
+
const reports = await inspectPolicy(policy, baseDir, options);
|
|
824
|
+
const problems = reports.flatMap((r) => r.problems.map((p) => ({ repository: r, problem: p })));
|
|
825
|
+
if (problems.length)
|
|
826
|
+
throw new HarnessError('NOT_READY', `Preflight toolchain: ${problems.length} проблем(ы); исправьте конфигурацию до запуска`, problems.map(({ problem: p }) => ({
|
|
827
|
+
path: ['policy', ...p.field],
|
|
828
|
+
message: `${p.code}: ${p.message}${p.path ? ` (${p.path})` : ''}`,
|
|
829
|
+
})));
|
|
830
|
+
return reports;
|
|
831
|
+
}
|