/** * Affected-package test scope (owner decision D11150). * * Before merge, a test run limited to the workspace packages a branch diff * touches — plus every package that depends on them, transitively — counts for * `testsPassed`, recorded as `scope: 'affected'`. After merge, merged CI * (`ci:`, D11149) or a full run supersedes it. * * The set is derived deterministically: * - each changed path maps to the workspace package whose directory holds it; * - documentation outside the code roots is ignored; * - CLEO-owned config (`.cleo/project-context.json`, `.cleo/config.json`) is * judged by `tool:config-schema`, never by tests, so it is set aside as * `cleoConfig` instead of widening the scope (T13779); * - any other path outside every package (lockfile, root `package.json`, * workspace/build/test config, `scripts/`, `.github/`) can affect every * package, so the scope becomes `full` and a full run is required — * unless the consumer checks or runs files (`outsideTests`, T13754): then * a path below a top-level directory owes every test file outside every * package under that directory, and only a repo-root file or a top-level * directory with no test file stays `full`; * - the dependents closure follows `dependencies`, `devDependencies`, * `peerDependencies` and `optionalDependencies` between workspace packages. * * With `{projects}`, vitest itself names the projects to select; anything it * cannot resolve, or a changed package no project tests, refuses the scope * (fail closed: a full run, never a narrower one). * * @task T12635 */ import { originDefaultMergeBase } from '../git/base-ref.js'; import type { MergeVerdict } from './affected-scope.js'; import { type AffectedTemplate } from './affected-template.js'; import { type ResolvedToolCommand } from './tool-resolver.js'; import { type ReleaseSlotFn } from './tool-semaphore.js'; /** One workspace package. */ export interface WorkspacePackage { /** `package.json` name. */ name: string; /** Directory relative to the workspace root (`packages/core`). */ dir: string; /** Names of OTHER workspace packages it depends on. */ deps: string[]; /** Declares a `scripts.test`, so `{filters}`/`{packages}` runs test it. */ hasTestScript: boolean; } /** The affected set, or the reason only a full run will do. */ export type AffectedScope = { scope: 'affected'; direct: string[]; packages: string[]; /** * Test files outside every workspace package a covering run must pass, * when `outsideTests` narrowed non-workspace paths (T13754). */ outsideTests?: string[]; /** * Changed CLEO-owned config files (T13779): judged by `tool:config-schema`, * never by a test run, so they do not widen the scope. Evidence that * leaves them to config-schema records them as `configPaths`. */ cleoConfig?: string[]; } | { scope: 'full'; reason: string; }; /** A test file, by the names vitest, jest and node:test pick up. */ export declare const TEST_FILE: RegExp; /** * Whether `path` lies in the package directory `dir`. The root package of a * standalone project (`dir === ''`, T13403) holds every path inside the root. * * @param path - Root-relative path, `/` separators. * @param dir - Package directory relative to the root (`''` for the root itself). * @returns True when the package directory holds the path. * @task T13403 */ export declare function inPackageDir(path: string, dir: string): boolean; /** * The workspace's packages and their workspace-internal dependencies. * * A project that declares no workspace (no `pnpm-workspace.yaml`, no * `package.json#workspaces`) is one package: its root `package.json`, with * `dir` `''` (T13403). Only when `root` is its git checkout's top level, since * changed paths are spelled relative to that top level; a root in a * subdirectory keeps no packages, so every change there stays `full`. * * @param root - Workspace root (the git checkout). * @returns Every package with a readable `package.json` name. * @task T12635 * @task T13403 */ export declare function listWorkspacePackages(root: string): WorkspacePackage[]; /** * The root package of a standalone (non-workspace) project, or null in a * workspace or when the root has no named `package.json` (T13403). * * @param root - Project root (the git checkout). * @returns The single root package, or null. * @task T13403 */ export declare function standalonePackage(root: string): WorkspacePackage | null; /** * Whether a changed path is non-code (T13745): documentation * ({@link isCiDocumentPath}) or a known-inert editor or bot setting * (renovate, .editorconfig, .vscode/, .idea/, .zed/). Everything else is code. * * @param path - Repo-relative changed path. * @returns True when no build, test, lint, CI or deploy reads the path. * @task T13745 */ export declare function isNonCodePath(path: string): boolean; /** * The CLEO schema a CLEO-owned config file is validated against, or null for * any other path (T13758). CLEO is these files' consumer, so its schema check * is a real, targeted test of a change to them. * * @param path - Repo-relative changed path. * @returns The schema file name, or null. * @task T13758 */ export declare function cleoConfigSchemaFor(path: string): string | null; /** * Whether a change set is non-empty and made only of CLEO-owned config files * ({@link cleoConfigSchemaFor}); any other path removes the route (T13758). * * @param paths - The change's repo-relative paths. * @returns True for a CLEO-config-only change. * @task T13758 */ export declare function isCleoConfigOnlyChange(paths: readonly string[]): boolean; /** * Whether a change set is non-empty and made only of {@link isNonCodePath} * paths: no test reads it and no TypeScript changed (T13745). * * @param paths - The change's repo-relative paths. * @returns True for a non-code-only change. * @task T13745 */ export declare function isNonCodeOnlyChange(paths: readonly string[]): boolean; /** * `seeds` plus every workspace package that depends on one, transitively, * through declared workspace dependencies. * * @param packages - The workspace packages. * @param seeds - Package names to start from. * @returns The closure, seeds included. * @task T12635 * @task T13783 */ export declare function dependentsClosure(packages: readonly WorkspacePackage[], seeds: Iterable): Set; /** * The changed workspace packages whose every changed non-test path matches a * glob of `evidence.typecheckOnlyDependents` on the default branch (T13783): a * regenerated client (`lib/api-zod/**`) or declared seed/data files * (`lib/db/src/seed-data/**`, T13786). Edited test files are left out of the * judgement; the report must run them. A * package depending on the change only through these owes a typecheck, not * a test file. Read from the merge-base copy only, so a change cannot * declare its own globs; never inferred from names. * * @param root - Execution root. * @param changed - Root-relative changed paths. * @param workspace - Workspace packages, longest directory first. * @returns The package names. * @task T13783 * @task T13786 */ export declare function typecheckOnlyPackages(root: string, changed: readonly string[], workspace: readonly WorkspacePackage[]): Set; /** * Derive the affected-package set for a diff. * * @param root - Workspace root. * @param changedPaths - Repo-relative changed paths. * @param opts - `outsideTests`: narrow a non-workspace path to the test files * of its directory ({@link AffectedScope} `outsideTests`) instead of the * whole workspace, for consumers that check or run those files (T13754). * CLEO-owned config files never widen the scope; they are returned as * `cleoConfig` for `tool:config-schema` to judge (T13779). * @returns The directly changed packages plus their transitive dependents, or * `full` naming the paths that affect the whole workspace. * @task T12635 * @task T13754 * @task T13779 */ export declare function deriveAffectedPackages(root: string, changedPaths: readonly string[], opts?: { outsideTests?: boolean; }): AffectedScope; /** A vitest project as vitest itself resolves it: its name and root directory. */ export interface VitestProject { /** The name `--project` matches (vitest's own: `test.name`, else package.json name, else basename). */ name: string; /** Project root relative to the workspace root (`''` for the root itself). */ dir: string; } /** Vitest's resolved projects, or why they could not be resolved. */ export type VitestProjectsResult = { ok: true; projects: VitestProject[]; } | { ok: false; reason: string; /** Admission refused it now and the caller would not wait (T12656 review, T13133). */ busy?: true; }; /** * The report a caller that will not wait gets while the machine budget is in * use (T13133: the admission ledger replaced the `test` slot). */ export declare const TEST_SLOT_BUSY = "scope pending: the machine budget is in use"; /** The report a caller that will not wait gets while memory pressure refuses heavy work. */ export declare const MEMORY_PRESSURE_BUSY = "scope pending: memory pressure"; /** Options for {@link listVitestProjects}. */ export interface ListVitestProjectsOptions { /** Admission (tests inject; defaults to the admission ledger as a light `test` run). */ acquireSlot?: (canonical: 'test') => Promise; /** * Queue for admission (true: `cleo done`, about to run tests anyway), or * take it only if admitted now (false: `--plan`, which never waits — a * refusal reports {@link TEST_SLOT_BUSY} or {@link MEMORY_PRESSURE_BUSY}). * Default false. */ wait?: boolean; } /** * Ask vitest which projects the workspace config resolves to (T12635 review). * * Runs the workspace's OWN vitest (resolved from `root`) in a child process and * reads `createVitest(...).projects`, so globs, inline project objects, * unnamed projects and every future config shape are named exactly as * `--project` will match them. No config parsing happens here. * * The child runs asynchronously under machine-wide admission as a light `test` * run (loading a workspace's configs is test tooling, but one small process), * and results are memoized per tree state, so `cleo done` planning and * recording resolve once (T12657, T13133). * * @param root - Workspace root. * @param opts - Slot acquisition override. * @returns The projects, or the reason they could not be resolved. * @task T12635 * @task T12657 */ export declare function listVitestProjects(root: string, opts?: ListVitestProjectsOptions): Promise; /** Test targets for an affected run, or why only a full run is safe. */ export type AffectedTestTargets = { ok: true; projects: string[]; untested: string[]; } | { ok: false; reason: string; busy?: true; }; /** * The vitest projects an affected run must select (T12635 review). * * Fails CLOSED: anything that cannot be resolved yields `ok: false`, so the * caller runs the full suite, never a narrower one. * * - Projects come from vitest itself ({@link listVitestProjects}); a project * covers a package when vitest names it after the package, or its root is * the package directory. * - Every project that covers NO workspace package (e.g. the root `scripts` * project, whose tests read live package files such as templates and skills) * is always appended, since no package dependency edge reaches it. * - A DIRECTLY changed package with no project refuses the affected scope; * an affected dependent with none is reported in `untested`. * * @param root - Workspace root. * @param packages - Affected package names (direct plus dependents). * @param direct - The directly changed packages. * @param resolve - Project resolver (vitest by default). * @returns Project names to run and the dependents that have none, or a refusal. * @task T12635 */ export declare function affectedTestTargets(root: string, packages: readonly string[], direct: readonly string[], resolve?: (root: string) => Promise): Promise; /** * The packages a `{filters}`/`{packages}` run tests (T12657): a package with no * `scripts.test` runs nothing. A DIRECTLY changed one refuses the affected * scope; an affected dependent with none is reported in `untested`. * * @param root - Workspace root. * @param packages - Affected package names (direct plus dependents). * @param direct - The directly changed packages. * @returns The packages to pass and the dependents with no test script, or a refusal. * @task T12657 */ export declare function scriptTestTargets(root: string, packages: readonly string[], direct: readonly string[]): AffectedTestTargets; /** * Expand a `testing.affectedCommand` template into a spawnable command. * `{projects}` → `--project ` per package, `{filters}` → `--filter * ` per package, `{workspaces}` → `--workspace ` per package (npm, * T13125), `{packages}` → the names. Each expands to separate arguments. The template is split with POSIX `sh` quoting and run without a * shell, so shell syntax (`&&`, `|`, `$`, …) is refused rather than passed to * the target as literal words, which could run a different program and * false-PASS (T12718). * * @param template - e.g. `pnpm exec vitest run {projects}`. * @param packages - Affected package names. * @returns The executable and its arguments. * @throws When the template uses shell syntax or has an unterminated quote. * @task T12635 */ export declare function buildAffectedTestCommand(template: string, packages: readonly string[], projects?: readonly string[]): { cmd: string; args: string[]; }; export { originDefaultMergeBase }; /** * Files CLEO itself writes into a project for its harness hooks (T12983, * T13124): never part of a change's scope, tracked or not. A hand-installed * `.opencode/plugins/cleo-heavy-command.js` that no task touched made every * change in an opencode project "workspace-wide" and forced a full suite * (gh#1805). * * @task T13135 */ export declare const CLEO_MANAGED_SCOPE_EXCLUDES: readonly string[]; /** * A string-list key of the project's `evidence` block, read from the * MERGE-BASE version of `.cleo/project-context.json` (`git show * :…`) — never the working copy. A change could otherwise declare * its own patterns and drop its own code from evidence (review of #1823); a * pattern counts only once it is on the default branch. * * @param root - Execution root (the CLEO root of the checkout). * @param mergeBase - `merge-base(origin/, HEAD)`. * @param key - `scopeExcludes` (T13135), `runtimePaths` (T13554) or * `typecheckOnlyDependents` (T13783). * @returns The declared patterns; none when the file or key is absent there. * @task T13554 */ export declare function declaredEvidencePatterns(root: string, mergeBase: string, key: 'scopeExcludes' | 'runtimePaths' | 'typecheckOnlyDependents'): string[]; /** * The project context evidence reads its commands from (review of #2302): the * default-branch (merge-base) copy of `.cleo/project-context.json` whenever * the working copy differs from it, so a change cannot relax the test, lint * or typecheck commands that judge it; the working copy otherwise. A tracked * file the default branch lacks yields none (the change added it). * * @param storeRoot - CLEO store root (holds the working copy). * @param root - Execution root (the git checkout). * @returns The context to read commands from, or null. * @task T13779 */ export declare function evidenceProjectContext(storeRoot: string, root: string): Record | null; /** * The project's declared `evidence.scopeExcludes` ({@link declaredEvidencePatterns}). * * @param root - Execution root (the CLEO root of the checkout). * @param mergeBase - `merge-base(origin/, HEAD)`. * @returns The declared patterns; none when the file or key is absent there. * @task T13135 */ export declare function declaredScopeExcludes(root: string, mergeBase: string): string[]; /** * Whether a root-relative path is out of evidence scope: one of * {@link CLEO_MANAGED_SCOPE_EXCLUDES}, or a match for a declared pattern (exact * path, or `path.matchesGlob` with `**` crossing directories). A declared * pattern never removes `.cleo/project-context.json` or a path inside a * workspace package — code always stays in scope (review of #1823). * * @param rel - Path relative to the CLEO root, `/` separators. * @param declared - From {@link declaredScopeExcludes}. * @param packageDirs - Workspace package directories, relative to the root. * @returns `true` when the path never counts as part of a change. * @task T13135 */ export declare function isScopeExcluded(rel: string, declared: readonly string[], packageDirs: readonly string[]): boolean; /** The changed paths evidence scoping counts, and the ones it set aside. */ export interface ScopedChangedPaths { /** Paths that count, as {@link changedPathsSinceDefault} returns them. */ readonly paths: string[]; /** Changed paths set aside as out of scope (managed hook files, declared excludes). */ readonly excluded: string[]; /** * With `narrowUntracked` (T14000): untracked files outside every directory * the tracked change touches — another agent's work in a shared checkout, * not this change. Evidence records them so `cleo complete` can refuse it * once one of them is committed. Empty otherwise. */ readonly untrackedSetAside: string[]; } /** * The most untracked files a binding sets aside (T14000, review of #2426). * Above it the run is not narrowed: every untracked file counts, as before, * so an atom never carries thousands of paths. */ export declare const MAX_UNTRACKED_SET_ASIDE = 500; /** * Paths the tree under test changed relative to origin's default branch: * committed (`merge-base(origin/, HEAD)..HEAD`), uncommitted tracked * edits and untracked files — the tests run on the working tree, so all matter * — split into the ones that count and the ones set aside as out of scope * ({@link isScopeExcluded}, T13135). * * An untracked file that is tool or session state is not the change at all * (T13780): one under {@link UNTRACKED_STATE_DIRS} (`.cleo/rcasd/`, * `.claude/`), or one the default branch's `evidence.scopeExcludes` matches * (this repo declares `.opencode/**`). Every other untracked file counts, * whatever directory holds it (`.github/`, `.husky/`): a file a later commit * adds keeps the same tree hash, so leaving it out of scope would let * evidence recorded before the commit skip it (review of #2302). * * Excludes are anchored at the CLEO root: diff paths are relative to the git * top level, so a CLEO root in a subdirectory strips its prefix before * matching; untracked paths are already root-relative. * * With `narrowUntracked` (T14000, the test-run and qa-run bindings), an * untracked file counts only when it lies in a directory the tracked change * touches ({@link nearTrackedChange}); the rest is returned as * `untrackedSetAside`, so a shared dirty checkout cannot refuse a run the * plan asked for, and the binding records what it left out. More than * {@link MAX_UNTRACKED_SET_ASIDE} such files are not set aside at all. * * @param root - Execution root. * @param opts - `narrowUntracked` for the bindings (T14000). * @returns The split, or null when no origin default branch exists or git fails. * @task T12635 * @task T13135 */ export declare function scopedChangedPaths(root: string, opts?: { readonly narrowUntracked?: boolean; }): ScopedChangedPaths | null; /** * Paths the tree under test changed relative to origin's default branch that * count for evidence scope: {@link scopedChangedPaths}' `paths`. * * @param root - Execution root. * @returns The paths (committed, uncommitted and untracked), or null when no * origin default branch exists or git fails. * @task T12635 * @task T13135 */ export declare function changedPathsSinceDefault(root: string): string[] | null; /** A planned affected-scope run, or why only the full suite will do. */ export type AffectedTestRun = { ok: true; /** Canonical `test`, so it shares `tool:test`'s caps and cache discipline. */ command: ResolvedToolCommand; /** Affected packages (direct plus dependents). */ packages: string[]; /** Test projects the run selects (with `{projects}`; else the packages). */ projects: string[]; /** Affected dependents with no test project. */ untested: string[]; /** The template the command came from, declared or derived (T13125). */ template: AffectedTemplate; /** Test files outside every package the run names, for a non-workspace change (T13754). */ testFiles?: string[]; /** Changed CLEO config the run leaves to `tool:config-schema` (T13779). */ configPaths?: string[]; } | { ok: false; /** `E_EVIDENCE_TOOL_UNAVAILABLE` when unconfigured, else `E_EVIDENCE_INSUFFICIENT`. */ codeName: 'E_EVIDENCE_TOOL_UNAVAILABLE' | 'E_EVIDENCE_INSUFFICIENT'; reason: string; /** The scope is not refused, only unresolved yet: the `test` slot was busy. */ pending?: true; }; /** * Plan the affected-scope test run for the tree at `root` — the one path both * `tool:test-affected` validation and `cleo done` planning use. Fails closed: * no template, no origin default branch, a workspace-wide change, an empty * set, or unresolvable test projects each refuse the scope. * * @param storeRoot - CLEO store root (project context). * @param root - Execution root whose diff defines the set. * @param opts - `wait`: queue for the `test` slot (`cleo done`); `--plan` does not. * @returns The command and its receipt fields, or the refusal reason. * @task T12635 */ export declare function planAffectedTestRun(storeRoot: string, root: string, opts?: { wait?: boolean; }): Promise; /** * Why a scope-aware `tool:test` runs the whole suite when no affected template * is declared or derivable (T13125), naming `ci:` as the preferred * `testsPassed` evidence when the project accepts it. * * @param storeRoot - CLEO store root (project context). * @returns The reason recorded on the `tool` atom. * @task T13125 */ export declare function wholeSuiteReason(storeRoot: string): string; /** How a scope-aware `tool:test` will run (T12959). */ export type ScopedTestRun = { /** Only the affected packages and their dependents. */ scope: 'affected'; /** The planned affected run. */ run: Extract; } | { /** The whole suite (`testing.command`). */ scope: 'full'; /** Why the affected scope was not used ({@link wholeSuiteReason} when none exists). */ reason: string | null; } | { /** Unresolved: the `test` slot was busy while listing test projects. */ scope: 'pending'; /** The planner's reason. */ reason: string; }; /** * Decide how `tool:test` runs (T12959): the affected packages first whenever * an affected template is declared (`testing.affectedCommand`) or derivable * from `testing.command` (T13125), the full suite only when that scope cannot * be trusted. Full when the project opts out (`testing.preferAffected: * false`), has no template, or the change is not known to be unmerged (a scoped run counts before merge only, D11150); * otherwise whatever {@link planAffectedTestRun} decides, its refusal (root * config changed, no origin, nothing touched, …) becoming the full run's * recorded reason. An affected plan that would leave a dependent package * untested (no test project) also runs the full suite. * * @param storeRoot - CLEO store root (project context). * @param root - Execution root whose diff defines the set. * @param opts - `wait` queues for the `test` slot; `mergeState` resolves the * task's merge state lazily, with why a lookup failed when one did (omitted * when no task is in context). * @returns The scope and the plan, or why the full suite runs. * @example * ```ts * const plan = await planScopedTestRun(storeRoot, executionRoot, { wait: true }); * if (plan.scope === 'affected') console.log(plan.run.packages); * ``` * @task T12959 */ export declare function planScopedTestRun(storeRoot: string, root: string, opts?: { wait?: boolean; mergeState?: () => Promise; }): Promise; //# sourceMappingURL=affected-packages.d.ts.map