mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
368 lines
15 KiB
TypeScript
368 lines
15 KiB
TypeScript
/**
|
|
* Classify changed files for impact selection.
|
|
*
|
|
* Some changed paths cannot affect the runtime the E2E suite exercises — repo
|
|
* tooling, docs, editor config. Left in the changed-set they have no map entry,
|
|
* so they hit the unmapped → broad fallback and drag an otherwise-scoped PR to
|
|
* the full suite. Dropping them before selection is a pure win: they can't run
|
|
* a real test regardless, so we only ever decline to run things that can't run.
|
|
*
|
|
* SURGICAL by design: only *named* config files are ignored — never all
|
|
* `*.json` / `*.yaml`, because node descriptions, i18n catalogues and test
|
|
* fixtures are real source data the map legitimately keys on.
|
|
*
|
|
* NOT ignored (deliberately): `docker`/Dockerfiles (define the container the
|
|
* E2E suite runs in), `pnpm-lock.yaml`/`package.json` (dependency changes —
|
|
* handled by the devDep classifier / dep-graph selector), `patches/**`
|
|
* (edits a dependency's code = a dependency change), and `tsconfig*.json`
|
|
* (can carry module-resolution keys — see {@link tsconfigForcesBroad}).
|
|
*/
|
|
|
|
const NON_IMPACTFUL: Array<(f: string) => boolean> = [
|
|
// Agent / editor / repo tooling
|
|
(f) => f.startsWith('.claude/'),
|
|
(f) => /^\.(vscode|idea)\//.test(f),
|
|
(f) =>
|
|
/(^|\/)\.(editorconfig|gitattributes|gitignore|npmrc|nvmrc|prettierignore|eslintignore)$/.test(
|
|
f,
|
|
),
|
|
// Docs + dictionaries + doc assets
|
|
(f) => /\.mdx?$/.test(f) || /(^|\/)(LICENSE|CHANGELOG\.md)$/.test(f),
|
|
(f) => /cspell|\.dic$/i.test(f),
|
|
(f) => /^(docs|assets)\/.*\.(png|jpe?g|gif|svg|webp)$/.test(f),
|
|
// Repo automation scripts (not shipped, not exercised by E2E)
|
|
(f) => f.startsWith('scripts/'),
|
|
// Named build / lint / test-runner config (NOT all json/yaml). `tsconfig*` is
|
|
// deliberately absent — it's routed through tsconfigForcesBroad instead.
|
|
(f) =>
|
|
/(^|\/)(turbo\.json|biome\.jsonc?|vitest\.config\.[cm]?[jt]s|\.eslintrc[\w.]*|\.prettierrc[\w.]*)$/.test(
|
|
f,
|
|
),
|
|
];
|
|
|
|
/** True if a changed file cannot affect the runtime the E2E suite exercises. */
|
|
export function isNonImpactful(file: string): boolean {
|
|
return NON_IMPACTFUL.some((matches) => matches(file));
|
|
}
|
|
|
|
/**
|
|
* Drop non-impactful paths from a changed-file set so they don't force a broad
|
|
* selection. Apply this once, at the entry, before any selector sees the files.
|
|
*/
|
|
export function filterImpactfulChanges(files: string[]): string[] {
|
|
return files.filter((file) => !isNonImpactful(file));
|
|
}
|
|
|
|
/**
|
|
* Paths that define the runtime the WHOLE E2E suite executes in — the container
|
|
* image and the container harness. The coverage map can't attribute these (a
|
|
* change here can reach any spec), so they force a broad run rather than being
|
|
* declared uncovered. This is a small, low-churn set, so broad is cheap here.
|
|
*
|
|
* Credential definitions belong here for a different reason: they're declarative
|
|
* metadata loaded into the registry once at boot, so no code in them re-executes
|
|
* attributably to a spec and the runtime coverage map never records them. Absent
|
|
* from the map, a credential change would be declared uncovered and skip its
|
|
* covering specs, so we force broad instead.
|
|
*
|
|
* The module registry is the same boot-time class, but worse: it IS in the map,
|
|
* so a scoped result looks correct. Boot code is attributed only to specs that
|
|
* start their own container — i.e. the specs that already enable the module via
|
|
* `N8N_ENABLED_MODULES` and so can't observe a default flip. The specs that
|
|
* break assert the off-branch, and no hit-coverage map reaches a branch that
|
|
* stopped executing. Churn is low, so broad is cheap here too.
|
|
*/
|
|
const FORCES_BROAD: Array<(f: string) => boolean> = [
|
|
(f) => f.startsWith('docker/'),
|
|
(f) => /(^|\/)Dockerfile(\.|$)|\.Dockerfile$/.test(f),
|
|
(f) => f.startsWith('packages/testing/containers/'),
|
|
(f) => f.startsWith('packages/nodes-base/credentials/'),
|
|
(f) => /^packages\/@n8n\/backend-common\/src\/modules\/[^/]+\.ts$/.test(f),
|
|
];
|
|
|
|
/** True if a changed file defines the E2E runtime → the whole suite must run. */
|
|
export function forcesBroad(file: string): boolean {
|
|
return FORCES_BROAD.some((matches) => matches(file));
|
|
}
|
|
|
|
/** A package.json change classified by which dependency sections moved. */
|
|
export type ManifestChangeKind = 'runtime' | 'devDep-only' | 'override' | 'none';
|
|
|
|
type ManifestJson = Record<string, Record<string, string> | undefined>;
|
|
/** package.json sections whose changes can reach the runtime bundle. */
|
|
export const RUNTIME_SECTIONS = [
|
|
'dependencies',
|
|
'optionalDependencies',
|
|
'peerDependencies',
|
|
] as const;
|
|
|
|
function parseManifest(raw: string): ManifestJson {
|
|
try {
|
|
return JSON.parse(raw) as ManifestJson;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Dependency names added / removed / version-changed in one manifest section. */
|
|
function changedKeysInSection(
|
|
before: ManifestJson,
|
|
after: ManifestJson,
|
|
section: string,
|
|
): string[] {
|
|
const b = before[section] ?? {};
|
|
const a = after[section] ?? {};
|
|
const changed: string[] = [];
|
|
for (const key of new Set([...Object.keys(b), ...Object.keys(a)])) {
|
|
if (b[key] !== a[key]) changed.push(key);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function sectionChanged(before: ManifestJson, after: ManifestJson, section: string): boolean {
|
|
return changedKeysInSection(before, after, section).length > 0;
|
|
}
|
|
|
|
/** `pnpm.overrides` selectors changed between two manifests, as written. */
|
|
function changedOverrideSelectors(before: ManifestJson, after: ManifestJson): string[] {
|
|
const b = (before.pnpm as { overrides?: Record<string, string> } | undefined)?.overrides ?? {};
|
|
const a = (after.pnpm as { overrides?: Record<string, string> } | undefined)?.overrides ?? {};
|
|
const changed: string[] = [];
|
|
for (const key of new Set([...Object.keys(b), ...Object.keys(a)])) {
|
|
if (b[key] !== a[key]) changed.push(key);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
const NPM_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
|
|
/**
|
|
* The package an override selector pins (`node-gyp>undici` → `undici`,
|
|
* `@vitest/browser@<4.1.10` → `@vitest/browser`), or `null` when the selector
|
|
* can't be parsed with confidence — callers must then stay broad.
|
|
*/
|
|
export function overrideTargetName(selector: string): string | null {
|
|
// A range `>` (`pkg@>2`, `pkg@1||>2`) follows `@` or `|`, touches whitespace,
|
|
// or is `>=` — never a parent>child separator.
|
|
let child = selector;
|
|
for (let i = selector.length - 1; i > 0; i--) {
|
|
if (selector[i] !== '>') continue;
|
|
const prev = selector[i - 1];
|
|
const next = selector[i + 1] ?? '';
|
|
if (
|
|
prev === '@' ||
|
|
prev === '|' ||
|
|
prev === '=' ||
|
|
next === '=' ||
|
|
/\s/.test(prev) ||
|
|
/\s/.test(next)
|
|
) {
|
|
continue;
|
|
}
|
|
child = selector.slice(i + 1);
|
|
break;
|
|
}
|
|
child = child.trim();
|
|
// `> 0` keeps a leading scope `@` intact when stripping the version range.
|
|
const at = child.lastIndexOf('@');
|
|
const name = at > 0 ? child.slice(0, at) : child;
|
|
return NPM_PACKAGE_NAME.test(name) ? name : null;
|
|
}
|
|
|
|
/**
|
|
* Packages whose `pnpm.overrides` pin was added, removed or changed. `null`
|
|
* when any changed selector fails to parse — the diff can't be attributed.
|
|
*/
|
|
export function changedOverrideTargets(before: string, after: string): string[] | null {
|
|
const selectors = changedOverrideSelectors(parseManifest(before), parseManifest(after));
|
|
const names = new Set<string>();
|
|
for (const selector of selectors) {
|
|
const name = overrideTargetName(selector);
|
|
if (name === null) return null;
|
|
names.add(name);
|
|
}
|
|
return [...names];
|
|
}
|
|
|
|
/**
|
|
* Classify a package.json change by the dependency sections it touched:
|
|
* - `runtime` — a runtime section (dependencies / optional / peer) moved, so
|
|
* it can reach the bundle the E2E suite exercises.
|
|
* - `override` — a `pnpm.overrides` pin moved; whether it reaches the
|
|
* runtime bundle takes a closure check (see {@link dropDevDepOnlyDeps}).
|
|
* - `devDep-only` — only devDependencies moved → cannot reach the runtime bundle.
|
|
* - `none` — no dependency section moved (scripts / version / engines / …).
|
|
* Unparseable content is treated as an empty manifest. Checked most-impactful
|
|
* first: a mixed devDep+override change classifies as `override`.
|
|
*/
|
|
export function classifyManifestChange(before: string, after: string): ManifestChangeKind {
|
|
const b = parseManifest(before);
|
|
const a = parseManifest(after);
|
|
if (RUNTIME_SECTIONS.some((s) => sectionChanged(b, a, s))) return 'runtime';
|
|
if (changedOverrideSelectors(b, a).length > 0) return 'override';
|
|
return sectionChanged(b, a, 'devDependencies') ? 'devDep-only' : 'none';
|
|
}
|
|
|
|
/**
|
|
* Names of the *runtime* dependencies (dependencies / optional / peer) that
|
|
* changed between two manifests — the input to the dep-graph selector,
|
|
* which walks each name to the workspace packages that declare it. devDeps are
|
|
* excluded (they can't reach the runtime bundle).
|
|
*/
|
|
export function changedRuntimeDeps(before: string, after: string): string[] {
|
|
const b = parseManifest(before);
|
|
const a = parseManifest(after);
|
|
const names = new Set<string>();
|
|
for (const section of RUNTIME_SECTIONS) {
|
|
for (const name of changedKeysInSection(b, a, section)) names.add(name);
|
|
}
|
|
return [...names];
|
|
}
|
|
|
|
/** {@link changedRuntimeDeps} unioned across every changed manifest. */
|
|
export function changedRuntimeDepsFromManifests(
|
|
manifests: Record<string, { before: string; after: string }>,
|
|
): string[] {
|
|
const names = new Set<string>();
|
|
for (const { before, after } of Object.values(manifests)) {
|
|
for (const name of changedRuntimeDeps(before, after)) names.add(name);
|
|
}
|
|
return [...names];
|
|
}
|
|
|
|
const isManifest = (f: string): boolean => /(^|\/)package\.json$/.test(f);
|
|
const isLockfile = (f: string): boolean => f === 'pnpm-lock.yaml';
|
|
|
|
export const isTsconfig = (f: string): boolean => /(^|\/)tsconfig([.\w-]*)\.json$/.test(f);
|
|
|
|
/** compilerOptions keys that change which module a bare import resolves to. */
|
|
const TSCONFIG_RESOLUTION_KEYS = [
|
|
'paths',
|
|
'baseUrl',
|
|
'moduleResolution',
|
|
'customConditions',
|
|
] as const;
|
|
|
|
/** Remove block and line comments. A comment-only edit must not look like a
|
|
* change. The `[^:]` guard keeps `http://` intact. */
|
|
function stripComments(raw: string): string {
|
|
return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
}
|
|
|
|
/** Tolerant parse for tsconfig (allows comments + trailing commas). Null when
|
|
* unparseable, which the caller treats as "force broad". */
|
|
function parseTsconfig(raw: string): Record<string, unknown> | null {
|
|
if (!raw.trim()) return null;
|
|
try {
|
|
return JSON.parse(raw) as Record<string, unknown>;
|
|
} catch {
|
|
try {
|
|
const stripped = stripComments(raw).replace(/,(\s*[}\]])/g, '$1');
|
|
return JSON.parse(stripped) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when a tsconfig change touches a resolution key (`paths`/`baseUrl`/
|
|
* `moduleResolution`/`customConditions`/`extends`), which re-points imports for
|
|
* every spec and can't be attributed in the coverage map → force the full
|
|
* suite. A type-check-only edit (`strict`, `target`, …) resolves to the same
|
|
* modules and is non-impactful.
|
|
*/
|
|
export function tsconfigForcesBroad(before: string, after: string): boolean {
|
|
const b = parseTsconfig(before);
|
|
const a = parseTsconfig(after);
|
|
if (!b || !a) return true;
|
|
if (JSON.stringify(b.extends) !== JSON.stringify(a.extends)) return true;
|
|
const bco = (b.compilerOptions ?? {}) as Record<string, unknown>;
|
|
const aco = (a.compilerOptions ?? {}) as Record<string, unknown>;
|
|
return TSCONFIG_RESOLUTION_KEYS.some((k) => JSON.stringify(bco[k]) !== JSON.stringify(aco[k]));
|
|
}
|
|
|
|
export const isBackendConfig = (f: string): boolean =>
|
|
/^packages\/@n8n\/config\/src\/configs\/[^/]+\.ts$/.test(f);
|
|
|
|
/** Every field default (`name=initializer`, empty when the field has none) and
|
|
* every `@Env` binding — the two inputs that decide a config's runtime value. */
|
|
function configDefaults(source: string): Set<string> {
|
|
const src = stripComments(source);
|
|
const defaults = new Set<string>();
|
|
for (const [, env] of src.matchAll(/@Env\(\s*['"]([^'"]+)['"]/g)) {
|
|
defaults.add(`env:${env}`);
|
|
}
|
|
for (const [, field, init] of src.matchAll(
|
|
/^\s*(?:readonly\s+)?([\w$]+)[?!]?\s*:\s*[^=;\n]+?(?:=\s*([^;\n]+))?;/gm,
|
|
)) {
|
|
defaults.add(`${field}=${(init ?? '').trim().replace(/\s+/g, ' ')}`);
|
|
}
|
|
return defaults;
|
|
}
|
|
|
|
/**
|
|
* True when a config change alters an EXISTING default — a field's fallback
|
|
* value, or the env var it reads. Config classes are built once with the DI
|
|
* container, so their defaults are unattributable (1 of ~57 files is in the
|
|
* map) and a changed default moves behaviour in specs that never touch the
|
|
* file. A purely additive change can't: the code reading the new field is in
|
|
* the same PR and IS mapped. That split matters at ~130 commits/90d.
|
|
*/
|
|
export function configForcesBroad(before: string, after: string): boolean {
|
|
const next = configDefaults(after);
|
|
return [...configDefaults(before)].some((d) => !next.has(d));
|
|
}
|
|
|
|
/** Remove the lockfile + every package.json from a changed-file set. */
|
|
export function stripDependencyFiles(files: string[]): string[] {
|
|
return files.filter((f) => !isLockfile(f) && !isManifest(f));
|
|
}
|
|
|
|
/**
|
|
* Drop the lockfile + manifests from a changed-file set when the dependency
|
|
* change is provably devDependencies-only — a devDep can't reach the runtime
|
|
* bundle, so it must not force broad. `manifests` maps each changed package.json
|
|
* path to its before/after content (the caller reads these from git).
|
|
*
|
|
* An override pins a TRANSITIVE package, which no declared section mentions —
|
|
* `fast-uri` (reaches runtime via `ajv`) and `@vitest/browser` (dev-only) look
|
|
* identical there. Only `runtimeClosure` membership can tell them apart.
|
|
*
|
|
* Conservative by construction — never drops without positive evidence:
|
|
* - any runtime-section change → keep everything (real dep change);
|
|
* - a changed package.json with no supplied diff → treated as runtime;
|
|
* - a lockfile change with no changed package.json at all (transitive bump) → kept;
|
|
* - an override change with a missing/empty closure, an unparseable selector,
|
|
* or any target inside the closure → kept.
|
|
*/
|
|
export function dropDevDepOnlyDeps(
|
|
files: string[],
|
|
manifests: Record<string, { before: string; after: string }>,
|
|
runtimeClosure?: ReadonlySet<string>,
|
|
): string[] {
|
|
const changedManifests = files.filter(isManifest);
|
|
if (changedManifests.length === 0) return files;
|
|
const kinds = changedManifests.map((f) =>
|
|
manifests[f] ? classifyManifestChange(manifests[f].before, manifests[f].after) : 'runtime',
|
|
);
|
|
if (kinds.includes('runtime')) return files;
|
|
|
|
if (kinds.includes('override')) {
|
|
// An empty closure is a broken walk, not proof nothing reaches runtime.
|
|
if (!runtimeClosure || runtimeClosure.size === 0) return files;
|
|
const targets = new Set<string>();
|
|
for (const f of changedManifests) {
|
|
if (!manifests[f]) continue;
|
|
const names = changedOverrideTargets(manifests[f].before, manifests[f].after);
|
|
if (names === null) return files;
|
|
for (const name of names) targets.add(name);
|
|
}
|
|
if (targets.size === 0 || [...targets].some((t) => runtimeClosure.has(t))) return files;
|
|
return stripDependencyFiles(files);
|
|
}
|
|
|
|
if (!kinds.includes('devDep-only')) return files;
|
|
return stripDependencyFiles(files);
|
|
}
|