forgedock 1.18.0 → 1.19.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/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +139 -7
- package/bin/runner.mjs +69 -9
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- package/package.json +2 -2
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.test.sh +2 -0
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +2 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// scoping.mjs - exact scoping: narrow each check to what this change can affect, widening whenever impact is unknown.
|
|
5
|
+
// 1. per-file checks (linters, formatters) run on the changed files only;
|
|
6
|
+
// 2. tests are selected by the ecosystem's own selector, else by a git-grep reference graph;
|
|
7
|
+
// 3. unknown impact (lockfile, build/CI config, unreferenced fixture, unknown language) widens to the full command;
|
|
8
|
+
// every decision carries a reason so the telemetry can say what was narrowed and why.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { git, run, shellSplit, shellQuote, globToRegex, dedupe, commandExists } from './lib.mjs';
|
|
13
|
+
|
|
14
|
+
const px = path.posix;
|
|
15
|
+
|
|
16
|
+
// ---- widening triggers: a change here has unknown reach, so nothing is narrowed ----
|
|
17
|
+
const LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|go\.sum|go\.work\.sum|poetry\.lock|uv\.lock|Pipfile\.lock|Gemfile\.lock|composer\.lock|gradle\.lockfile|packages\.lock\.json)$/;
|
|
18
|
+
const CONFIG = /(^|\/)(package\.json|tsconfig[^/]*\.json|jsconfig\.json|(jest|vitest|vite|webpack|rollup|babel|eslint|prettier|playwright|cypress|karma)\.(config|setup)[^/]*|\.babelrc[^/]*|\.eslintrc[^/]*|\.eslintignore|\.prettierrc[^/]*|\.prettierignore|\.shellcheckrc|\.editorconfig|\.flake8|\.pylintrc|\.ruff\.toml|ruff\.toml|\.markdownlint[^/]*|\.yamllint[^/]*|\.hadolint\.ya?ml|\.rubocop\.yml|pyproject\.toml|setup\.(cfg|py)|pytest\.ini|tox\.ini|noxfile\.py|conftest\.py|requirements[^/]*\.txt|go\.mod|go\.work|Cargo\.toml|rust-toolchain[^/]*|\.cargo\/config[^/]*|pom\.xml|build\.gradle[^/]*|settings\.gradle[^/]*|gradle\.properties|Makefile|makefile|GNUmakefile|justfile|Taskfile\.ya?ml|Dockerfile[^/]*|docker-compose[^/]*\.ya?ml|\.nvmrc|\.node-version|\.python-version|\.tool-versions|nx\.json|turbo\.json|lerna\.json|\.pre-commit-config\.yaml|\.gitlab-ci\.yml|azure-pipelines\.ya?ml|bitbucket-pipelines\.yml|forge\.yaml)$/;
|
|
19
|
+
const CI_DIR = /^(\.github\/(workflows|actions)\/|\.circleci\/|\.buildkite\/|\.gitlab\/)/;
|
|
20
|
+
const DOC_EXT = new Set(['md', 'mdx', 'txt', 'rst', 'adoc', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'pdf', 'license']);
|
|
21
|
+
const SRC_EXT = new Set(['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts', 'vue', 'svelte', 'py', 'pyi', 'go', 'rs', 'java', 'kt', 'kts', 'scala', 'rb', 'php', 'cs', 'fs', 'sh', 'bash', 'zsh', 'c', 'h', 'cc', 'cpp', 'hpp', 'swift', 'm', 'ex', 'exs', 'clj', 'dart', 'lua', 'pl', 'r', 'sql', 'css', 'scss', 'less', 'html', 'json', 'yaml', 'yml', 'toml', 'bats']);
|
|
22
|
+
const FIXTURE_DIR = /(^|\/)(fixtures?|__fixtures__|testdata|test-data|__snapshots__|snapshots|__mocks__)\//;
|
|
23
|
+
const TEST_NAME = /(\.test\.|\.spec\.|_test\.|(^|\/)test_[^/]*$|\.bats$|_spec\.rb$|Test\.java$|Tests?\.cs$)/;
|
|
24
|
+
|
|
25
|
+
export const extOf = (f) => { const b = px.basename(f); const i = b.lastIndexOf('.'); return i <= 0 ? '' : b.slice(i + 1).toLowerCase(); };
|
|
26
|
+
|
|
27
|
+
// A reason to run everything for the files in this change, or ''.
|
|
28
|
+
export function globalWiden(changed) {
|
|
29
|
+
for (const f of changed) {
|
|
30
|
+
if (LOCKFILE.test(f)) return `lockfile: ${f}`;
|
|
31
|
+
if (CI_DIR.test(f)) return `ci-config: ${f}`;
|
|
32
|
+
if (CONFIG.test(f)) return `build-config: ${f}`;
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function makeScopeCtx({ repo, baseRef, from, changed }) {
|
|
38
|
+
const tracked = git(repo, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']).out.split('\0').filter(Boolean);
|
|
39
|
+
const present = new Set(tracked.filter((f) => fs.existsSync(path.join(repo, f))));
|
|
40
|
+
return { repo, baseRef, from, changed, tracked, present, widen: globalWiden(changed), cache: { grep: new Map(), pkg: new Map(), lists: new Map() } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- argument parsing for per-file tools ----
|
|
44
|
+
const looksPath = (t) => /[/.*]/.test(t) || !t.startsWith('-');
|
|
45
|
+
|
|
46
|
+
function parseArgs(args, spec) {
|
|
47
|
+
const flags = []; const targets = []; let ambiguous = '';
|
|
48
|
+
for (let i = 0; i < args.length; i++) {
|
|
49
|
+
const t = args[i];
|
|
50
|
+
if (t === '--') { targets.push(...args.slice(i + 1)); break; }
|
|
51
|
+
if (t.startsWith('-') && t !== '-') {
|
|
52
|
+
if (t.includes('=') || spec.bool.has(t)) { flags.push(t); continue; }
|
|
53
|
+
if (spec.value.has(t)) { flags.push(t); if (i + 1 < args.length) flags.push(args[++i]); continue; }
|
|
54
|
+
flags.push(t);
|
|
55
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-') && looksPath(args[i + 1])) ambiguous = `unknown-flag-arity: ${t}`;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
targets.push(t);
|
|
59
|
+
}
|
|
60
|
+
return { flags, targets, ambiguous };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const set = (s) => new Set(s.split(' ').filter(Boolean));
|
|
64
|
+
// ext: file extensions the tool checks; name: extra basename regex; sub: required subcommand(s) for multi-command tools.
|
|
65
|
+
const LINTERS = {
|
|
66
|
+
shellcheck: { ext: set('sh bash ksh'), value: set('-e -i -s -S -C -f -P -o -W --exclude --include --shell --severity --format --source-path --enable --color --wiki-link-count --norc'), bool: set('-x -a --external-sources --check-sourced --norc --list-optional'), dot: false },
|
|
67
|
+
eslint: { ext: set('js jsx ts tsx mjs cjs mts cts vue'), value: set('-c --config --ext --format -f --max-warnings --parser --parser-options --plugin --rulesdir --ignore-path --ignore-pattern --resolve-plugins-relative-to --rule --global --env -o --output-file --cache-location'), bool: set('--fix --quiet --cache --no-eslintrc --no-error-on-unmatched-pattern --report-unused-disable-directives --debug'), dot: true },
|
|
68
|
+
prettier: { ext: set('js jsx ts tsx mjs cjs json md mdx css scss less html yaml yml vue'), value: set('--config --ignore-path --parser --plugin --print-width --tab-width --end-of-line --log-level --cache-location'), bool: set('--check -c --write -w --list-different -l --cache --no-config --ignore-unknown -u'), dot: false },
|
|
69
|
+
ruff: { ext: set('py pyi'), value: set('--config --select --ignore --extend-select --target-version --line-length --output-format --exclude --per-file-ignores --preview'), bool: set('--fix --check --diff --no-cache --quiet -q --exit-non-zero-on-fix'), dot: true, sub: set('check format') },
|
|
70
|
+
black: { ext: set('py pyi'), value: set('--config -l --line-length -t --target-version --exclude --include --extend-exclude --force-exclude --workers -W'), bool: set('--check --diff --quiet -q --verbose -v'), dot: false },
|
|
71
|
+
flake8: { ext: set('py'), value: set('--config --max-line-length --select --ignore --extend-ignore --exclude --per-file-ignores --format --max-complexity'), bool: set('--count --statistics -q'), dot: true },
|
|
72
|
+
isort: { ext: set('py pyi'), value: set('--settings-path --profile --line-length -l --skip --sp'), bool: set('--check-only --check --diff -c'), dot: false },
|
|
73
|
+
rubocop: { ext: set('rb rake'), value: set('-c --config --only --except -f --format -o --out'), bool: set('-a -A --auto-correct --parallel -P'), dot: true },
|
|
74
|
+
markdownlint: { ext: set('md'), value: set('-c --config -i --ignore -r --rules -o --output'), bool: set('--fix -f'), dot: false },
|
|
75
|
+
'markdownlint-cli2': { ext: set('md'), value: set(''), bool: set('--fix'), dot: false },
|
|
76
|
+
yamllint: { ext: set('yaml yml'), value: set('-c -d --config-file --config-data -f --format'), bool: set('-s --strict'), dot: false },
|
|
77
|
+
stylelint: { ext: set('css scss less'), value: set('--config --formatter -f --ignore-path --max-warnings'), bool: set('--fix --quiet'), dot: false },
|
|
78
|
+
gofmt: { ext: set('go'), value: set('-r'), bool: set('-l -d -s -w -e'), dot: false },
|
|
79
|
+
actionlint: { ext: set('yml yaml'), value: set('-config-file -format -ignore -shellcheck -pyflakes'), bool: set('-no-color -verbose'), dot: true, only: /^\.github\/workflows\// },
|
|
80
|
+
hadolint: { ext: set(''), value: set('-c --config --ignore --format -f --failure-threshold -t'), bool: set('--no-color'), dot: false, name: /(^|\/)(Dockerfile[^/]*|[^/]*\.dockerfile)$/ },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ---- test runners ----
|
|
84
|
+
// pattern: test-file predicate; ext: source extensions this runner's tests cover; value flags to skip when finding targets.
|
|
85
|
+
const RUNNERS = {
|
|
86
|
+
pytest: { test: (f) => /(^|\/)(test_[^/]*\.py|[^/]*_test\.py)$/.test(f), ext: set('py pyi'), value: set('-k -m -c -p -o --rootdir --maxfail --tb -n --junitxml --junit-xml --cov --cov-report --ignore --deselect -W --durations --timeout'), bool: set('-q -v -x -s -vv -qq --quiet --verbose -rA -ra --lf --ff'), sel: 'refgraph-pytest' },
|
|
87
|
+
mocha: { test: (f) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f) || /(^|\/)test\/[^/]*\.[cm]?js$/.test(f), ext: set('js jsx ts tsx mjs cjs'), value: set('--require -r --reporter -R --timeout -t --config --spec --grep -g'), bool: set('--exit --recursive'), sel: 'refgraph-mocha' },
|
|
88
|
+
nodetest: { test: (f) => /\.(test|spec)\.[cm]?[jt]s$/.test(f) || /(^|\/)test\/[^/]*\.[cm]?js$/.test(f), ext: set('js mjs cjs ts mts cts'), value: set('--test-name-pattern --test-concurrency --test-reporter --test-reporter-destination --import --require -r --experimental-test-coverage'), bool: set('--test --experimental-strip-types'), sel: 'refgraph-nodetest' },
|
|
89
|
+
bats: { test: (f) => /\.bats$/.test(f), ext: set('sh bash bats'), value: set('-j --jobs -f --filter -T --tap'), bool: set('-r --recursive -t --tap'), sel: 'refgraph-bats' },
|
|
90
|
+
rspec: { test: (f) => /_spec\.rb$/.test(f), ext: set('rb'), value: set('-e --example -t --tag --format -f -o --out -r --require'), bool: set(''), sel: 'refgraph-rspec' },
|
|
91
|
+
phpunit: { test: (f) => /Test\.php$/.test(f), ext: set('php'), value: set('--filter -c --configuration --testsuite --group --exclude-group --bootstrap'), bool: set(''), sel: 'refgraph-phpunit' },
|
|
92
|
+
'run-shell-tests': { test: (f) => /(^|\/)[^/]*\.test\.sh$/.test(f), ext: set('sh bash md json mjs yml yaml txt'), value: set(''), bool: set(''), sel: 'refgraph-shelltests' },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isTestFile = (f) => TEST_NAME.test(f);
|
|
96
|
+
|
|
97
|
+
function resolveNpmScript(sctx, cwdRel, script) {
|
|
98
|
+
const key = cwdRel + '|pkg';
|
|
99
|
+
if (!sctx.cache.pkg.has(key)) {
|
|
100
|
+
let pj = null; try { pj = JSON.parse(fs.readFileSync(path.join(sctx.repo, cwdRel, 'package.json'), 'utf8')); } catch { /* none */ }
|
|
101
|
+
sctx.cache.pkg.set(key, pj);
|
|
102
|
+
}
|
|
103
|
+
const pj = sctx.cache.pkg.get(key);
|
|
104
|
+
return pj && pj.scripts && typeof pj.scripts[script] === 'string' ? pj.scripts[script] : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function strip(toks) {
|
|
108
|
+
// Drop leading VAR=value assignments and transparent wrappers; return {prefix, rest}.
|
|
109
|
+
const prefix = []; let i = 0;
|
|
110
|
+
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) prefix.push(toks[i++]);
|
|
111
|
+
let rest = toks.slice(i);
|
|
112
|
+
for (;;) {
|
|
113
|
+
const a = px.basename(rest[0] || '');
|
|
114
|
+
if (['npx', 'bunx'].includes(a) || (['pnpm', 'yarn', 'bun'].includes(a) && ['exec', 'dlx'].includes(rest[1]))) { prefix.push(...rest.slice(0, a === 'npx' || a === 'bunx' ? 1 : 2)); rest = rest.slice(a === 'npx' || a === 'bunx' ? 1 : 2); while (rest[0] && /^--?(yes|y|no-install|quiet|q)$/.test(rest[0])) { prefix.push(rest[0]); rest = rest.slice(1); } continue; }
|
|
115
|
+
if (['bash', 'sh'].includes(a) && /(^|\/)with-run-tmpdir\.sh$/.test(rest[1] || '') && rest[2]) { prefix.push(rest[0], rest[1]); rest = rest.slice(2); continue; }
|
|
116
|
+
if (['uv', 'poetry', 'pipenv', 'pdm', 'hatch'].includes(a) && rest[1] === 'run') { prefix.push(rest[0], rest[1]); rest = rest.slice(2); continue; }
|
|
117
|
+
if (a === 'bundle' && rest[1] === 'exec') { prefix.push(rest[0], rest[1]); rest = rest.slice(2); continue; }
|
|
118
|
+
if (/^python[0-9.]*$/.test(a) && rest[1] === '-m' && rest[2]) { prefix.push(rest[0], rest[1]); rest = rest.slice(2); continue; }
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
return { prefix, rest };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rel = (cwdRel, t) => px.normalize(px.join(cwdRel || '.', t));
|
|
125
|
+
const fromCwd = (cwdRel, f) => px.relative(cwdRel || '.', f) || '.';
|
|
126
|
+
|
|
127
|
+
function targetMatcher(sctx, cwdRel, targets) {
|
|
128
|
+
const tests = targets.map((t) => {
|
|
129
|
+
if (t === '.' || t === './') { const base = rel(cwdRel, '.'); return (f) => base === '.' || f === base || f.startsWith(base + '/'); }
|
|
130
|
+
const r = rel(cwdRel, t);
|
|
131
|
+
if (/[*?[]/.test(t)) { const re = globToRegex(r); return (f) => re.test(f); }
|
|
132
|
+
if (fs.existsSync(path.join(sctx.repo, r)) && fs.statSync(path.join(sctx.repo, r)).isDirectory()) return (f) => f.startsWith(r.replace(/\/$/, '') + '/');
|
|
133
|
+
return (f) => f === r;
|
|
134
|
+
});
|
|
135
|
+
return (f) => tests.some((m) => m(f));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- reference graph (git grep) ----
|
|
139
|
+
function grepFiles(sctx, stems) {
|
|
140
|
+
const out = new Set();
|
|
141
|
+
const todo = stems.filter((s) => { if (sctx.cache.grep.has(s)) { for (const f of sctx.cache.grep.get(s)) out.add(f); return false; } return true; });
|
|
142
|
+
for (let i = 0; i < todo.length; i += 40) {
|
|
143
|
+
const batch = todo.slice(i, i + 40);
|
|
144
|
+
const args = ['grep', '-I', '-l', '-F', '-w', '--untracked', '--exclude-standard'];
|
|
145
|
+
for (const s of batch) args.push('-e', s);
|
|
146
|
+
const r = git(sctx.repo, args, { timeout: 60000 });
|
|
147
|
+
if (r.code > 1) return null;
|
|
148
|
+
const hits = r.out.split('\n').filter(Boolean);
|
|
149
|
+
// Attribute each hit to the batch (cache by batch granularity is unsafe for single stems, so re-grep singles lazily).
|
|
150
|
+
if (batch.length === 1) sctx.cache.grep.set(batch[0], new Set(hits));
|
|
151
|
+
for (const f of hits) out.add(f);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const CODE_EXT = set('js jsx ts tsx mjs cjs mts cts vue svelte py pyi go rs java kt kts scala rb php cs fs c h cc cpp hpp swift ex exs');
|
|
157
|
+
|
|
158
|
+
// Strings a referrer would contain: the full path, the basename with its extension and, for languages whose imports
|
|
159
|
+
// omit the extension, the bare module name. Precision matters: a generic stem would drag in the whole repo.
|
|
160
|
+
function stemsOf(f) {
|
|
161
|
+
const e = extOf(f); const base = px.basename(f);
|
|
162
|
+
const s = [f];
|
|
163
|
+
if (base.length >= 5) s.push(base);
|
|
164
|
+
if (CODE_EXT.has(e)) {
|
|
165
|
+
const noExt = f.slice(0, f.length - e.length - 1);
|
|
166
|
+
s.push(noExt);
|
|
167
|
+
const bare = px.basename(noExt);
|
|
168
|
+
if (bare.length >= 4) s.push(bare);
|
|
169
|
+
// forge#4040: short module names (db, io, __init__) cannot be grepped bare without drowning the repo, so reference them
|
|
170
|
+
// by their qualified forms: dotted python module paths and `from pkg import mod` / relative-import spellings.
|
|
171
|
+
if (e === 'py' || e === 'pyi') {
|
|
172
|
+
const isInit = bare === '__init__';
|
|
173
|
+
const modPath = isInit ? px.dirname(noExt) : noExt;
|
|
174
|
+
if (modPath && modPath !== '.') {
|
|
175
|
+
const dotted = modPath.split('/').join('.');
|
|
176
|
+
s.push(dotted);
|
|
177
|
+
const parts = dotted.split('.'); const leaf = parts.pop();
|
|
178
|
+
if (parts.length) s.push(`from ${parts.join('.')} import ${leaf}`);
|
|
179
|
+
s.push(`from . import ${leaf}`, `from .${leaf} import`);
|
|
180
|
+
if (isInit || bare.length < 4) s.push(modPath);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const m = FIXTURE_DIR.exec(f);
|
|
185
|
+
if (m) { const idx = m.index + (m[1] ? m[1].length : 0); const after = f.slice(idx).split('/'); s.push(after.slice(0, 2).join('/')); if (m.index > 0) s.push(f.slice(0, idx) + after.slice(0, 2).join('/')); }
|
|
186
|
+
return dedupe(s);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Conventional sibling tests: foo.sh -> foo.test.sh, foo.py -> test_foo.py / foo_test.py, foo.ts -> foo.spec.ts.
|
|
190
|
+
function siblingTests(f, testFiles) {
|
|
191
|
+
const e = extOf(f); const bare = px.basename(f, e ? '.' + e : '');
|
|
192
|
+
const dir = px.dirname(f);
|
|
193
|
+
return testFiles.filter((t) => {
|
|
194
|
+
const tb = px.basename(t); const td = px.dirname(t);
|
|
195
|
+
if (!(tb.startsWith(bare + '.test.') || tb.startsWith(bare + '.spec.') || tb.startsWith('test_' + bare + '.') || tb.startsWith(bare + '_test.') || tb === bare + '_spec.rb' || tb === bare + 'Test.java' || tb === bare + 'Test.php')) return false;
|
|
196
|
+
return td === dir || td.startsWith(dir + '/') || dir.startsWith(td + '/') || td === 'tests' || td === 'test' || td.endsWith('/tests') || td.endsWith('/test') || td.endsWith('/__tests__');
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Everything the change can reach through references (computed once per run): { files: Set, widen: reason|'' }.
|
|
201
|
+
function reach(sctx) {
|
|
202
|
+
if (sctx.cache.reach) return sctx.cache.reach;
|
|
203
|
+
const out = { files: new Set(), widen: '' };
|
|
204
|
+
sctx.cache.reach = out;
|
|
205
|
+
let frontier = sctx.changed.filter((f) => !isTestFile(f));
|
|
206
|
+
for (const f of sctx.changed) out.files.add(f);
|
|
207
|
+
const seen = new Set(frontier);
|
|
208
|
+
for (let depth = 0; depth < 3 && frontier.length; depth++) {
|
|
209
|
+
const hits = grepFiles(sctx, dedupe(frontier.flatMap(stemsOf)));
|
|
210
|
+
if (hits === null) { out.widen = 'git-grep-failed'; return out; }
|
|
211
|
+
if (hits.size > 600) { out.widen = `fan-out-cap: ${hits.size} files reference the change`; return out; }
|
|
212
|
+
const next = [];
|
|
213
|
+
// Documents that merely mention a file are leaves: a link is not a dependency, so they are not expanded further.
|
|
214
|
+
for (const f of hits) { out.files.add(f); if (!isTestFile(f) && !DOC_EXT.has(extOf(f)) && !seen.has(f)) { seen.add(f); next.push(f); } }
|
|
215
|
+
frontier = next;
|
|
216
|
+
}
|
|
217
|
+
// An unreferenced shared fixture has unknown reach: widen instead of silently selecting nothing.
|
|
218
|
+
for (const f of sctx.changed) {
|
|
219
|
+
if (FIXTURE_DIR.test(f) && !isTestFile(f)) {
|
|
220
|
+
const hits = grepFiles(sctx, stemsOf(f).filter((x) => x.includes('/')));
|
|
221
|
+
if (!hits || ![...hits].some((h) => isTestFile(h) || out.files.has(h))) { out.widen = `unreferenced-fixture: ${f}`; return out; }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Files reachable from ONE changed file through the reference graph (same walk as reach(), cached per file).
|
|
228
|
+
function reachFile(sctx, f) {
|
|
229
|
+
sctx.cache.reachFile = sctx.cache.reachFile || new Map();
|
|
230
|
+
if (sctx.cache.reachFile.has(f)) return sctx.cache.reachFile.get(f);
|
|
231
|
+
const out = { files: new Set([f]), widen: '' };
|
|
232
|
+
sctx.cache.reachFile.set(f, out);
|
|
233
|
+
let frontier = [f]; const seen = new Set(frontier);
|
|
234
|
+
for (let depth = 0; depth < 3 && frontier.length; depth++) {
|
|
235
|
+
const hits = grepFiles(sctx, dedupe(frontier.flatMap(stemsOf)));
|
|
236
|
+
if (hits === null) { out.widen = 'git-grep-failed'; return out; }
|
|
237
|
+
if (hits.size > 600) { out.widen = `fan-out-cap: ${hits.size} files reference ${f}`; return out; }
|
|
238
|
+
const next = [];
|
|
239
|
+
for (const h of hits) { out.files.add(h); if (!isTestFile(h) && !DOC_EXT.has(extOf(h)) && !seen.has(h)) { seen.add(h); next.push(h); } }
|
|
240
|
+
frontier = next;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Which of testFiles can this change reach? { selected, total, widen } ; widen is a reason string or ''.
|
|
246
|
+
export function impactedTests(sctx, testFiles) {
|
|
247
|
+
const r = reach(sctx);
|
|
248
|
+
if (r.widen) return { selected: [...testFiles], total: testFiles.length, widen: r.widen };
|
|
249
|
+
const selected = new Set();
|
|
250
|
+
for (const t of testFiles) if (r.files.has(t) && sctx.present.has(t)) selected.add(t);
|
|
251
|
+
for (const f of sctx.changed) if (!isTestFile(f)) for (const t of siblingTests(f, testFiles)) selected.add(t);
|
|
252
|
+
// forge#4040 (SEC-3, per file): every changed non-test source file must itself reach at least one test (reverse-reference
|
|
253
|
+
// graph or conventional sibling). One that reaches none widens the whole job: another changed file selecting tests must
|
|
254
|
+
// never hide it. The reason names the file.
|
|
255
|
+
const srcFiles = sctx.changed.filter((f) => !isTestFile(f) && (CODE_EXT.has(extOf(f)) || /^(sh|bash)$/.test(extOf(f))));
|
|
256
|
+
const unreached = [];
|
|
257
|
+
for (const f of srcFiles) {
|
|
258
|
+
const rf = reachFile(sctx, f);
|
|
259
|
+
if (rf.widen) return { selected: [...testFiles], total: testFiles.length, widen: rf.widen };
|
|
260
|
+
const hit = testFiles.some((t) => rf.files.has(t) && sctx.present.has(t)) || siblingTests(f, testFiles).length > 0;
|
|
261
|
+
if (!hit) unreached.push(f);
|
|
262
|
+
}
|
|
263
|
+
if (unreached.length) return { selected: [...testFiles], total: testFiles.length, widen: `empty-selection: no test reaches ${unreached.slice(0, 5).join(', ')}${unreached.length > 5 ? ` (+${unreached.length - 5} more)` : ''}` };
|
|
264
|
+
return { selected: [...selected].sort(), total: testFiles.length, widen: '' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- result helpers ----
|
|
268
|
+
const full = (command, why, extra = {}) => ({ action: 'run', command, mode: 'full', selected: null, total: null, widen_reason: why || '', ...extra });
|
|
269
|
+
const scoped = (command, selected, total, selector, extra = {}) => ({ action: 'run', command, mode: 'scoped', selected, total, selector, widen_reason: '', ...extra });
|
|
270
|
+
const withFiles = (r, files) => ({ ...r, files: files.slice(0, 500) });
|
|
271
|
+
const skipNo = (selected, total, selector, why) => ({ action: 'skip', command: '', mode: 'skip', selected, total, selector, widen_reason: '', reason: why });
|
|
272
|
+
|
|
273
|
+
// Data files (config, fixtures, queries, assets read at run time) are not reachable through an import graph.
|
|
274
|
+
const DATA_EXT = set('json yaml yml toml sql html css scss less');
|
|
275
|
+
|
|
276
|
+
// A changed file whose reach this runner cannot know. With `native` (an import-graph selector such as jest related or
|
|
277
|
+
// go list) another language's source and documents are irrelevant, but data files and unknown types widen. Without it
|
|
278
|
+
// (the git-grep reference graph finds path/name reads too) only unknown types widen.
|
|
279
|
+
function unmappedFile(changed, extSet, native = false) {
|
|
280
|
+
for (const f of changed) {
|
|
281
|
+
const e = extOf(f);
|
|
282
|
+
if (native) {
|
|
283
|
+
if (extSet.has(e) && !DATA_EXT.has(e)) continue;
|
|
284
|
+
if (DOC_EXT.has(e) || (SRC_EXT.has(e) && !DATA_EXT.has(e))) continue;
|
|
285
|
+
} else if (extSet.has(e) || DOC_EXT.has(e) || SRC_EXT.has(e)) continue;
|
|
286
|
+
if (e === '' && /(^|\/)(LICENSE|COPYING|NOTICE|CODEOWNERS|\.gitignore|\.gitattributes)$/.test(f)) continue;
|
|
287
|
+
return f;
|
|
288
|
+
}
|
|
289
|
+
return '';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- the entry point ----
|
|
293
|
+
// Returns the scoping decision for one simple command line run in cwdRel.
|
|
294
|
+
export function scopeCommand(cmd, cwdRel, sctx, depth = 0) {
|
|
295
|
+
const toks = shellSplit(cmd);
|
|
296
|
+
if (!toks || toks.length === 0) return full(cmd, '', { selector: 'unscoped:complex-shell' });
|
|
297
|
+
const { prefix, rest } = strip(toks);
|
|
298
|
+
if (!rest.length) return full(cmd, '', { selector: 'unscoped' });
|
|
299
|
+
const tool = px.basename(rest[0]);
|
|
300
|
+
const prefixStr = prefix.length ? prefix.map(shellQuote).join(' ') + ' ' : '';
|
|
301
|
+
const join = (arr) => prefixStr + arr.map(shellQuote).join(' ');
|
|
302
|
+
|
|
303
|
+
// npm/pnpm/yarn script indirection: resolve one level, scope the resolved command, run it directly.
|
|
304
|
+
if (['npm', 'pnpm', 'yarn', 'bun'].includes(tool) && depth < 2) {
|
|
305
|
+
let script = null; let extra = [];
|
|
306
|
+
if (['test', 't', 'tst'].includes(rest[1]) && tool !== 'yarn') { script = 'test'; extra = rest.slice(2); }
|
|
307
|
+
else if (['run', 'run-script'].includes(rest[1]) && rest[2]) { script = rest[2]; extra = rest.slice(3); }
|
|
308
|
+
else if (tool === 'yarn' && rest[1] && !rest[1].startsWith('-') && !['install', 'add', 'remove', 'exec', 'dlx', 'workspace', 'workspaces'].includes(rest[1])) { script = rest[1]; extra = rest.slice(2); }
|
|
309
|
+
if (script) {
|
|
310
|
+
const body = resolveNpmScript(sctx, cwdRel || '.', script);
|
|
311
|
+
if (body === null) return full(cmd, '', { selector: 'unscoped:script-not-found' });
|
|
312
|
+
if (extra[0] === '--') extra = extra.slice(1);
|
|
313
|
+
if (extra.length) return full(cmd, '', { selector: 'unscoped:script-args' });
|
|
314
|
+
const inner = scopeCommand(body, cwdRel, sctx, depth + 1);
|
|
315
|
+
if (inner.selector && inner.selector.startsWith('unscoped')) return full(cmd, '', { selector: inner.selector });
|
|
316
|
+
return inner.action === 'skip' ? inner : { ...inner, command: prefixStr + inner.command };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let r = null;
|
|
321
|
+
if (LINTERS[tool] && !(LINTERS[tool].sub && !LINTERS[tool].sub.has(rest[1]))) r = scopeLint(tool, rest, cmd, cwdRel, sctx, join);
|
|
322
|
+
else if (tool === 'jest') r = scopeJest(rest, cmd, cwdRel, sctx, join);
|
|
323
|
+
else if (tool === 'vitest') r = scopeVitest(rest, cmd, cwdRel, sctx, join);
|
|
324
|
+
else if (tool === 'pytest' || tool === 'py.test' || (/^python[0-9.]*$/.test(tool) && rest[1] === '-m' && rest[2] === 'pytest')) r = scopeRunner('pytest', tool === 'pytest' || tool === 'py.test' ? rest.slice(1) : rest.slice(3), tool === 'pytest' || tool === 'py.test' ? [rest[0]] : rest.slice(0, 3), cmd, cwdRel, sctx, join);
|
|
325
|
+
else if (tool === 'mocha') r = scopeRunner('mocha', rest.slice(1), [rest[0]], cmd, cwdRel, sctx, join);
|
|
326
|
+
else if (tool === 'bats') r = scopeRunner('bats', rest.slice(1), [rest[0]], cmd, cwdRel, sctx, join);
|
|
327
|
+
else if (tool === 'rspec') r = scopeRunner('rspec', rest.slice(1), [rest[0]], cmd, cwdRel, sctx, join);
|
|
328
|
+
else if (tool === 'phpunit') r = scopeRunner('phpunit', rest.slice(1), [rest[0]], cmd, cwdRel, sctx, join);
|
|
329
|
+
else if (tool === 'node' && rest.includes('--test')) r = scopeRunner('nodetest', rest.slice(1), [rest[0]], cmd, cwdRel, sctx, join);
|
|
330
|
+
else if (['bash', 'sh', 'node', 'python', 'python3', 'ruby', 'perl'].includes(tool) || /\.test\.sh$/.test(tool) || tool.startsWith('./')) r = scopeSingleTest(rest, cmd, cwdRel, sctx);
|
|
331
|
+
if (r === null && ['bash', 'sh'].includes(tool) && rest[1] && /(^|\/)run-shell-tests\.sh$/.test(rest[1])) r = scopeRunner('run-shell-tests', rest.slice(2), rest.slice(0, 2), cmd, cwdRel, sctx, join);
|
|
332
|
+
else if (r === null && tool === 'go' && rest[1] === 'test') r = scopeGo(rest, cmd, cwdRel, sctx, join);
|
|
333
|
+
else if (r === null && tool === 'cargo' && rest[1] === 'test') r = scopeCargo(rest, cmd, cwdRel, sctx, join);
|
|
334
|
+
else if (r === null && tool === 'mvn') r = scopeMaven(rest, cmd, cwdRel, sctx, join);
|
|
335
|
+
else if (r === null && ['nx', 'turbo'].includes(tool)) r = scopeMono(tool, rest, cmd, sctx, join);
|
|
336
|
+
else if (r === null && ['gradle', 'gradlew', 'dotnet', 'tox', 'nox', 'make', 'just', 'task'].includes(tool) && /\b(test|check|verify)\b/.test(cmd)) return full(cmd, '', { selector: `unscoped:no-selector:${tool}` });
|
|
337
|
+
return r || full(cmd, '', { selector: 'unscoped' });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function scopeLint(tool, rest, cmd, cwdRel, sctx, join) {
|
|
341
|
+
const spec = LINTERS[tool];
|
|
342
|
+
const head = spec.sub ? rest.slice(0, 2) : rest.slice(0, 1);
|
|
343
|
+
const args = rest.slice(head.length);
|
|
344
|
+
const { flags, targets, ambiguous } = parseArgs(args, spec);
|
|
345
|
+
if (ambiguous) return full(cmd, '', { selector: `unscoped:${ambiguous}` });
|
|
346
|
+
if (targets.some((t) => t.startsWith('-') || /[$`]/.test(t))) return full(cmd, '', { selector: 'unscoped:complex-target' });
|
|
347
|
+
const tgts = targets.length ? targets : (spec.dot ? ['.'] : []);
|
|
348
|
+
if (!tgts.length) return full(cmd, '', { selector: 'unscoped:no-targets' });
|
|
349
|
+
const inT = targetMatcher(sctx, cwdRel, tgts);
|
|
350
|
+
const isFile = (f) => (spec.name ? spec.name.test(f) : spec.ext.has(extOf(f))) && (!spec.only || spec.only.test(f));
|
|
351
|
+
const universe = sctx.tracked.filter((f) => inT(f) && isFile(f) && sctx.present.has(f));
|
|
352
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: `${tool}:full`, selected: universe.length, total: universe.length });
|
|
353
|
+
const sel = sctx.changed.filter((f) => sctx.present.has(f) && inT(f) && isFile(f)).sort();
|
|
354
|
+
if (!sel.length) return skipNo(0, universe.length, `${tool}:per-file`, 'no changed file of this tool in its targets');
|
|
355
|
+
return withFiles(scoped(join([...head, ...flags, ...sel.map((f) => fromCwd(cwdRel, f))]), sel.length, universe.length, `${tool}:per-file`), sel);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function scopeJest(rest, cmd, cwdRel, sctx, join) {
|
|
359
|
+
const { flags, targets, ambiguous } = parseArgs(rest.slice(1), { value: set('-c --config --testPathPattern -t --testNamePattern --maxWorkers -w --reporters --coverageDirectory --shard --testTimeout --roots'), bool: set('--ci --coverage -i --runInBand --silent --watchAll=false --forceExit --detectOpenHandles'), });
|
|
360
|
+
if (ambiguous || targets.length) return full(cmd, '', { selector: 'unscoped:explicit-targets' });
|
|
361
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: 'jest:full' });
|
|
362
|
+
const files = sctx.changed.filter((f) => sctx.present.has(f) && SRC_JS.has(extOf(f)));
|
|
363
|
+
const un = unmappedFile(sctx.changed, SRC_JS, true);
|
|
364
|
+
if (un) return full(cmd, `unknown-language: ${un}`, { selector: 'jest:full' });
|
|
365
|
+
if (!files.length) return skipNo(0, null, 'jest-related', 'no changed JS/TS file');
|
|
366
|
+
return scoped(join([rest[0], ...flags.filter((x) => x !== '--watch'), '--passWithNoTests', '--findRelatedTests', ...files.map((f) => fromCwd(cwdRel, f))]), files.length, null, 'jest-related');
|
|
367
|
+
}
|
|
368
|
+
const SRC_JS = set('js jsx ts tsx mjs cjs mts cts vue svelte');
|
|
369
|
+
|
|
370
|
+
function scopeVitest(rest, cmd, cwdRel, sctx, join) {
|
|
371
|
+
const sub = rest[1] && !rest[1].startsWith('-') ? rest[1] : '';
|
|
372
|
+
if (sub && sub !== 'run') return full(cmd, '', { selector: 'unscoped:vitest-subcommand' });
|
|
373
|
+
const { flags, targets, ambiguous } = parseArgs(rest.slice(sub ? 2 : 1), { value: set('-c --config --reporter --root -r --coverage.reporter --shard --testTimeout'), bool: set('--coverage --silent --ui') });
|
|
374
|
+
if (ambiguous || targets.length) return full(cmd, '', { selector: 'unscoped:explicit-targets' });
|
|
375
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: 'vitest:full' });
|
|
376
|
+
const files = sctx.changed.filter((f) => sctx.present.has(f) && SRC_JS.has(extOf(f)));
|
|
377
|
+
const un = unmappedFile(sctx.changed, SRC_JS, true);
|
|
378
|
+
if (un) return full(cmd, `unknown-language: ${un}`, { selector: 'vitest:full' });
|
|
379
|
+
if (!files.length) return skipNo(0, null, 'vitest-related', 'no changed JS/TS file');
|
|
380
|
+
return scoped(join([rest[0], 'related', '--run', '--passWithNoTests', ...flags, ...files.map((f) => fromCwd(cwdRel, f))]), files.length, null, 'vitest-related');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function listTests(sctx, name) {
|
|
384
|
+
const key = 'tests:' + name;
|
|
385
|
+
if (!sctx.cache.lists.has(key)) sctx.cache.lists.set(key, sctx.tracked.filter((f) => sctx.present.has(f) && RUNNERS[name].test(f)));
|
|
386
|
+
return sctx.cache.lists.get(key);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function scopeRunner(name, args, head, cmd, cwdRel, sctx, join) {
|
|
390
|
+
const spec = RUNNERS[name];
|
|
391
|
+
const { flags, targets, ambiguous } = parseArgs(args, spec);
|
|
392
|
+
if (ambiguous) return full(cmd, '', { selector: `unscoped:${ambiguous}` });
|
|
393
|
+
if (targets.some((t) => /[$`]/.test(t) || (/^[^/]+::/.test(t)))) return full(cmd, '', { selector: 'unscoped:complex-target' });
|
|
394
|
+
const universe = listTests(sctx, name);
|
|
395
|
+
const inT = targets.length ? targetMatcher(sctx, cwdRel, targets) : () => true;
|
|
396
|
+
const candidates = universe.filter(inT);
|
|
397
|
+
if (!candidates.length) return full(cmd, '', { selector: `${spec.sel}:no-candidates` });
|
|
398
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: spec.sel, selected: candidates.length, total: candidates.length });
|
|
399
|
+
const un = unmappedFile(sctx.changed.filter((f) => !isTestFile(f)), new Set([...spec.ext, ...SRC_EXT]));
|
|
400
|
+
if (un) return full(cmd, `unknown-language: ${un}`, { selector: spec.sel, selected: candidates.length, total: candidates.length });
|
|
401
|
+
const imp = impactedTests(sctx, candidates);
|
|
402
|
+
if (imp.widen) return full(cmd, imp.widen, { selector: spec.sel, selected: candidates.length, total: candidates.length });
|
|
403
|
+
if (!imp.selected.length) return skipNo(0, candidates.length, spec.sel, 'no test reaches the changed files');
|
|
404
|
+
const sel = imp.selected.map((f) => fromCwd(cwdRel, f));
|
|
405
|
+
return scoped(join([...head, ...flags, ...sel]), imp.selected.length, candidates.length, spec.sel);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// `bash scripts/x.test.sh`, `node test/x.test.mjs`, `python3 tests/test_x.py`: run only when the file is reached.
|
|
409
|
+
function scopeSingleTest(rest, cmd, cwdRel, sctx) {
|
|
410
|
+
const tool = px.basename(rest[0]);
|
|
411
|
+
const fileTok = tool.startsWith('.') || /\.test\.sh$/.test(rest[0]) ? rest[0] : rest.slice(1).find((t) => !t.startsWith('-'));
|
|
412
|
+
if (!fileTok || /[$`*?]/.test(fileTok)) return null;
|
|
413
|
+
const r = rel(cwdRel, fileTok);
|
|
414
|
+
if (!isTestFile(r) || !sctx.present.has(r)) return null;
|
|
415
|
+
if (rest.slice(rest.indexOf(fileTok) + 1).length) return full(cmd, '', { selector: 'unscoped:test-args' });
|
|
416
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: 'single-test', selected: 1, total: 1 });
|
|
417
|
+
const imp = impactedTests(sctx, [r]);
|
|
418
|
+
if (imp.widen) return full(cmd, imp.widen, { selector: 'single-test', selected: 1, total: 1 });
|
|
419
|
+
if (!imp.selected.length) return skipNo(0, 1, 'single-test', `${r} not reached by the change`);
|
|
420
|
+
return scoped(cmd, 1, 1, 'single-test');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function scopeGo(rest, cmd, cwdRel, sctx, join) {
|
|
424
|
+
const { flags, targets, ambiguous } = parseArgs(rest.slice(2), { value: set('-run -skip -count -timeout -tags -p -parallel -bench -cpu -coverprofile -covermode -ldflags -vet -json'), bool: set('-v -race -short -cover -failfast -shuffle') });
|
|
425
|
+
if (ambiguous) return full(cmd, '', { selector: 'unscoped:ambiguous-args' });
|
|
426
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: 'go-affected' });
|
|
427
|
+
if (!commandExists('go')) return null;
|
|
428
|
+
const goChanged = sctx.changed.filter((f) => extOf(f) === 'go' || /(^|\/)testdata\//.test(f));
|
|
429
|
+
const un = unmappedFile(sctx.changed.filter((f) => !goChanged.includes(f)), set('go'), true);
|
|
430
|
+
if (un) return full(cmd, `unknown-language: ${un}`, { selector: 'go-affected' });
|
|
431
|
+
if (!goChanged.length) return skipNo(0, null, 'go-affected', 'no changed Go file');
|
|
432
|
+
const ls = run('go', ['list', '-e', '-f', '{{.ImportPath}}|{{.Dir}}|{{join .Imports ","}}|{{join .TestImports ","}}|{{join .XTestImports ","}}', './...'], { cwd: path.join(sctx.repo, cwdRel || '.'), timeout: 120000 });
|
|
433
|
+
if (ls.code !== 0) return full(cmd, 'go-list-failed', { selector: 'go-affected' });
|
|
434
|
+
const root = path.resolve(sctx.repo);
|
|
435
|
+
const pk = ls.out.split('\n').filter(Boolean).map((l) => { const [ip, dir, a, b, c] = l.split('|'); return { ip, dir: px.relative(root.split(path.sep).join('/'), dir.split(path.sep).join('/')) || '.', deps: [a, b, c].join(',').split(',').filter(Boolean) }; });
|
|
436
|
+
const direct = new Set(pk.filter((p) => goChanged.some((f) => px.dirname(f) === p.dir || (/testdata\//.test(f) && f.startsWith(p.dir + '/')))).map((p) => p.ip));
|
|
437
|
+
if (!direct.size) return full(cmd, 'go-package-unmapped', { selector: 'go-affected' });
|
|
438
|
+
const affected = new Set(direct);
|
|
439
|
+
for (let grew = true; grew;) { grew = false; for (const p of pk) if (!affected.has(p.ip) && p.deps.some((d) => affected.has(d))) { affected.add(p.ip); grew = true; } }
|
|
440
|
+
const inT = targets.length ? targets.map((t) => (t.endsWith('/...') ? (ip, dir) => dir === rel(cwdRel, t.slice(0, -4)) || dir.startsWith(rel(cwdRel, t.slice(0, -4)) + '/') : (ip, dir) => dir === rel(cwdRel, t))) : [() => true];
|
|
441
|
+
const pick = pk.filter((p) => affected.has(p.ip) && inT.some((m) => m(p.ip, p.dir)));
|
|
442
|
+
if (!pick.length) return skipNo(0, pk.length, 'go-affected', 'no affected package in the command targets');
|
|
443
|
+
return scoped(join([...rest.slice(0, 2), ...flags, ...pick.map((p) => p.ip)]), pick.length, pk.length, 'go-affected');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function scopeCargo(rest, cmd, cwdRel, sctx, join) {
|
|
447
|
+
if (rest.some((t) => t === '-p' || t === '--package' || t.startsWith('--package='))) return full(cmd, '', { selector: 'unscoped:explicit-package' });
|
|
448
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: 'cargo-affected' });
|
|
449
|
+
if (!commandExists('cargo')) return null;
|
|
450
|
+
const rsChanged = sctx.changed.filter((f) => extOf(f) === 'rs');
|
|
451
|
+
const un = unmappedFile(sctx.changed.filter((f) => extOf(f) !== 'rs'), set('rs'), true);
|
|
452
|
+
if (un) return full(cmd, `unknown-language: ${un}`, { selector: 'cargo-affected' });
|
|
453
|
+
if (!rsChanged.length) return skipNo(0, null, 'cargo-affected', 'no changed Rust file');
|
|
454
|
+
const m = run('cargo', ['metadata', '--format-version', '1', '--offline'], { cwd: path.join(sctx.repo, cwdRel || '.'), timeout: 120000 });
|
|
455
|
+
if (m.code !== 0) return full(cmd, 'cargo-metadata-failed', { selector: 'cargo-affected' });
|
|
456
|
+
let meta; try { meta = JSON.parse(m.out); } catch { return full(cmd, 'cargo-metadata-unreadable', { selector: 'cargo-affected' }); }
|
|
457
|
+
const root = path.resolve(sctx.repo).split(path.sep).join('/');
|
|
458
|
+
const members = new Set(meta.workspace_members);
|
|
459
|
+
const pk = meta.packages.filter((p) => members.has(p.id)).map((p) => ({ id: p.id, name: p.name, dir: px.relative(root, px.dirname(p.manifest_path.split(path.sep).join('/'))) || '.' }));
|
|
460
|
+
const direct = new Set();
|
|
461
|
+
for (const f of rsChanged) { const owner = pk.filter((p) => p.dir === '.' || f === p.dir || f.startsWith(p.dir + '/')).sort((a, b) => b.dir.length - a.dir.length)[0]; if (!owner) return full(cmd, `rust-file-without-crate: ${f}`, { selector: 'cargo-affected' }); direct.add(owner.id); }
|
|
462
|
+
const deps = new Map((meta.resolve ? meta.resolve.nodes : []).map((n) => [n.id, n.dependencies || []]));
|
|
463
|
+
const affected = new Set(direct);
|
|
464
|
+
for (let grew = true; grew;) { grew = false; for (const p of pk) if (!affected.has(p.id) && (deps.get(p.id) || []).some((d) => affected.has(d))) { affected.add(p.id); grew = true; } }
|
|
465
|
+
const names = pk.filter((p) => affected.has(p.id)).map((p) => p.name);
|
|
466
|
+
const out = rest.filter((t) => t !== '--workspace' && t !== '--all');
|
|
467
|
+
const at = out.indexOf('test') + 1;
|
|
468
|
+
const pargs = names.flatMap((n) => ['-p', n]);
|
|
469
|
+
return scoped(join([...out.slice(0, at), ...pargs, ...out.slice(at)]), names.length, pk.length, 'cargo-affected');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function scopeMaven(rest, cmd, cwdRel, sctx, join) {
|
|
473
|
+
if (rest.some((t) => t === '-pl' || t === '--projects')) return full(cmd, '', { selector: 'unscoped:explicit-projects' });
|
|
474
|
+
if (!rest.some((t) => ['test', 'verify', 'package', 'install'].includes(t))) return null;
|
|
475
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: 'maven-affected' });
|
|
476
|
+
const mods = new Set();
|
|
477
|
+
for (const f of sctx.changed) {
|
|
478
|
+
let d = px.dirname(f); let found = '';
|
|
479
|
+
for (;;) { if (sctx.present.has(px.join(d, 'pom.xml').replace(/^\.\//, ''))) { found = d; break; } if (d === '.' || d === '/') break; d = px.dirname(d); }
|
|
480
|
+
if (!found || found === '.') { if (!DOC_EXT.has(extOf(f))) return full(cmd, `maven-root-or-unmapped: ${f}`, { selector: 'maven-affected' }); continue; }
|
|
481
|
+
mods.add(found);
|
|
482
|
+
}
|
|
483
|
+
if (!mods.size) return skipNo(0, null, 'maven-affected', 'no changed file in a Maven module');
|
|
484
|
+
return scoped(join([...rest, '-pl', [...mods].sort().join(','), '-amd']), mods.size, null, 'maven-affected');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function scopeMono(tool, rest, cmd, sctx, join) {
|
|
488
|
+
if (sctx.widen) return full(cmd, sctx.widen, { selector: `${tool}-affected` });
|
|
489
|
+
const base = sctx.from || sctx.baseRef;
|
|
490
|
+
if (tool === 'nx' && (rest[1] === 'run-many' || rest[1] === 'affected')) {
|
|
491
|
+
const out = rest[1] === 'affected' ? rest : ['nx', 'affected', ...rest.slice(2).filter((t) => t !== '--all')];
|
|
492
|
+
if (rest[1] === 'run-many' && !out.some((t) => t === '-t' || t.startsWith('--target'))) return full(cmd, '', { selector: 'unscoped:nx-no-target' });
|
|
493
|
+
return scoped(join([...out, `--base=${base}`]), null, null, 'nx-affected');
|
|
494
|
+
}
|
|
495
|
+
if (tool === 'turbo' && rest[1] === 'run') { if (rest.some((t) => t.startsWith('--filter'))) return full(cmd, '', { selector: 'unscoped:explicit-filter' }); return scoped(join([...rest, `--filter=...[${base}]`]), null, null, 'turbo-affected'); }
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Split a multi-line run block into independent simple commands when that is provably equivalent.
|
|
500
|
+
export function splitBlock(script) {
|
|
501
|
+
const lines = script.replace(/\r\n/g, '\n').split('\n').map((l) => l.replace(/^\s+|\s+$/g, '')).filter((l) => l && !l.startsWith('#'));
|
|
502
|
+
if (lines.length <= 1) return lines.length ? [lines[0]] : [];
|
|
503
|
+
const CONTROL = /^(if|then|else|elif|fi|for|while|until|do|done|case|esac|function|select|\{|\}|\(|\)|;;|cd|pushd|popd|export|source|\.|set|unset|trap|alias|read|shopt|ulimit|umask|eval|exec|local|declare|readonly|shift|wait|return|exit|[A-Za-z_][A-Za-z0-9_]*=)(\s|$|=)/;
|
|
504
|
+
for (const l of lines) {
|
|
505
|
+
if (CONTROL.test(l) || /(\\|&&|\|\||\||&|;)$/.test(l) || l.includes('<<') || ((l.match(/'/g) || []).length % 2) || ((l.match(/"/g) || []).length % 2) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(l)) return [lines.join('\n')];
|
|
506
|
+
}
|
|
507
|
+
return lines;
|
|
508
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# ci-local.sh - run the repo's own pull-request checks locally, before review, without spending CI minutes.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ci-local.sh plan --repo <worktree> --base <ref> [--base-branch B] [--head-branch B] [--files "a b" | --files-from FILE]
|
|
9
|
+
# [--out FILE] [--json] [--no-cache]
|
|
10
|
+
# ci-local.sh run --repo <worktree> --base <ref> [same options] [--budget-minutes N] [--max-parallel N]
|
|
11
|
+
# [--iteration N] [--result-file FILE]
|
|
12
|
+
#
|
|
13
|
+
# plan derives the check set from the repo's CI definitions (GitHub Actions, GitLab CI, CircleCI, Azure Pipelines,
|
|
14
|
+
# Bitbucket Pipelines, Buildkite; task runners when no CI gates the change; forge.yaml ci_local.extra_commands),
|
|
15
|
+
# reading definitions from the trusted base ref plus the PR's own edits, and scopes every check to what the
|
|
16
|
+
# change can affect (per-file lint on changed files, test impact selection, fail-safe widening).
|
|
17
|
+
# run executes the plan in the worktree (parallel jobs, machine-wide slot semaphore, timeouts, overall budget,
|
|
18
|
+
# docker-backed services, result cache) and emits `ci_local` telemetry.
|
|
19
|
+
#
|
|
20
|
+
# Output (stdout): CI_LOCAL_JOB / CI_LOCAL_DEFERRED / CI_LOCAL_CHECK / CI_LOCAL_FAIL lines, then exactly one
|
|
21
|
+
# CI_LOCAL_RESULT: PASS|FAIL|DEGRADED line.
|
|
22
|
+
# Exit: 0 every runnable job passed (deferred-to-ci jobs are listed), 1 a runnable job failed,
|
|
23
|
+
# 2 degraded (job timeout, exhausted or capped budget, gate could not run) or usage error; never a pass.
|
|
24
|
+
#
|
|
25
|
+
# This script only reads CI definitions and runs the commands they name on this machine. It never contacts GitHub
|
|
26
|
+
# and never starts a remote workflow. Requires: bash, node, git, and mikefarah yq v4 for YAML CI definitions.
|
|
27
|
+
# bash 3.2 portable.
|
|
28
|
+
|
|
29
|
+
set -u
|
|
30
|
+
|
|
31
|
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
32
|
+
CLI="$SCRIPT_DIR/ci-local/cli.mjs"
|
|
33
|
+
|
|
34
|
+
degraded() { echo "CI_LOCAL_RESULT: DEGRADED reason=$1"; exit 2; }
|
|
35
|
+
|
|
36
|
+
SUB="${1:-}"
|
|
37
|
+
case "$SUB" in
|
|
38
|
+
plan|run) shift ;;
|
|
39
|
+
-h|--help|help) sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
40
|
+
*) echo "usage: ci-local.sh plan|run --repo <worktree> --base <ref> [options]" >&2; exit 2 ;;
|
|
41
|
+
esac
|
|
42
|
+
|
|
43
|
+
command -v node >/dev/null 2>&1 || degraded "node not found"
|
|
44
|
+
[ -f "$CLI" ] || degraded "ci-local/cli.mjs not found next to ci-local.sh"
|
|
45
|
+
|
|
46
|
+
# Telemetry reuses the shared emitter (never fails the caller; FORGE_TELEMETRY=off disables it).
|
|
47
|
+
type forge_emit >/dev/null 2>&1 || { [ -f "$SCRIPT_DIR/lib/telemetry.sh" ] && . "$SCRIPT_DIR/lib/telemetry.sh"; }
|
|
48
|
+
type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
|
|
49
|
+
|
|
50
|
+
if [ "$SUB" = "plan" ]; then
|
|
51
|
+
node "$CLI" plan "$@"
|
|
52
|
+
exit $?
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
ITER=0
|
|
56
|
+
prev=""
|
|
57
|
+
for arg in "$@"; do
|
|
58
|
+
[ "$prev" = "--iteration" ] && ITER="$arg"
|
|
59
|
+
prev="$arg"
|
|
60
|
+
done
|
|
61
|
+
case "$ITER" in ''|*[!0-9]*) ITER=0 ;; esac
|
|
62
|
+
|
|
63
|
+
OUT=$(mktemp "${TMPDIR:-/tmp}/ci-local-out.XXXXXX") || degraded "could not create a temp file"
|
|
64
|
+
trap 'rm -f "$OUT"' EXIT
|
|
65
|
+
node "$CLI" run "$@" | tee "$OUT"
|
|
66
|
+
RC=${PIPESTATUS[0]}
|
|
67
|
+
|
|
68
|
+
RES=$(grep '^CI_LOCAL_RESULT:' "$OUT" | tail -n 1)
|
|
69
|
+
if [ -z "$RES" ]; then
|
|
70
|
+
echo "CI_LOCAL_RESULT: DEGRADED reason=runner produced no result line (exit $RC)"
|
|
71
|
+
RC=2
|
|
72
|
+
RES="CI_LOCAL_RESULT: DEGRADED"
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
field() { printf '%s\n' "$RES" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n 1; }
|
|
76
|
+
STATE=$(printf '%s\n' "$RES" | awk '{print $2}')
|
|
77
|
+
JR=$(field jobs_run); JD=$(field jobs_deferred); JF=$(field jobs_failed); DUR=$(field duration_ms)
|
|
78
|
+
forge_emit ci_local result="${STATE:-DEGRADED}" jobs_run="${JR:-0}" jobs_deferred="${JD:-0}" jobs_failed="${JF:-0}" \
|
|
79
|
+
duration_ms="${DUR:-0}" fix_iterations="$ITER" checks="$(field checks)" cache_hits="$(field cache_hits)" \
|
|
80
|
+
narrowed="$(field narrowed)" widened="$(field widened)" selected="$(field selected)" total="$(field total)" >/dev/null 2>&1 || true
|
|
81
|
+
|
|
82
|
+
# One event per scoped/widened/executed check, capped so a large plan never floods the telemetry comment.
|
|
83
|
+
EMITTED=0
|
|
84
|
+
while IFS= read -r line; do
|
|
85
|
+
[ "$EMITTED" -ge 20 ] && break
|
|
86
|
+
job=$(printf '%s\n' "$line" | sed -n 's/.* job=\([^ ]*\).*/\1/p')
|
|
87
|
+
sel=$(printf '%s\n' "$line" | sed -n 's/.* selector=\([^ ]*\).*/\1/p')
|
|
88
|
+
sc=$(printf '%s\n' "$line" | sed -n 's/.* selected=\([^ ]*\).*/\1/p')
|
|
89
|
+
tt=$(printf '%s\n' "$line" | sed -n 's/.* total=\([^ ]*\).*/\1/p')
|
|
90
|
+
ch=$(printf '%s\n' "$line" | sed -n 's/.* cache=\([^ ]*\).*/\1/p')
|
|
91
|
+
dm=$(printf '%s\n' "$line" | sed -n 's/.* duration_ms=\([^ ]*\).*/\1/p')
|
|
92
|
+
wd=$(printf '%s\n' "$line" | sed -n 's/.* widen=\([^ ]*\).*/\1/p')
|
|
93
|
+
forge_emit ci_local_check job="$job" selector="$sel" selected="${sc:-0}" total="${tt:-0}" cache="$ch" duration_ms="${dm:-0}" widen="$wd" >/dev/null 2>&1 || true
|
|
94
|
+
EMITTED=$((EMITTED + 1))
|
|
95
|
+
done < <(grep '^CI_LOCAL_CHECK:' "$OUT")
|
|
96
|
+
|
|
97
|
+
exit "$RC"
|