carveout 0.1.0 → 0.1.2
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/AGENTS.md +21 -3
- package/NOTICE.md +2 -1
- package/README.md +274 -460
- package/dist/src/account.d.ts +37 -0
- package/dist/src/account.js +275 -0
- package/dist/src/api.d.ts +148 -0
- package/dist/src/api.js +120 -0
- package/dist/src/carve.d.ts +788 -0
- package/dist/src/carve.js +1721 -125
- package/dist/src/cli.d.ts +10 -0
- package/dist/src/cli.js +674 -47
- package/dist/src/command.d.ts +10 -0
- package/dist/src/core.d.ts +11 -0
- package/dist/src/cut.d.ts +78 -0
- package/dist/src/cut.js +25 -7
- package/dist/src/extract/compiler-config.d.ts +49 -0
- package/dist/src/extract/fixture.d.ts +103 -0
- package/dist/src/extract/hash.d.ts +6 -0
- package/dist/src/extract/package-registry.d.ts +47 -0
- package/dist/src/extract/package-types.d.ts +52 -0
- package/dist/src/extract/surface.d.ts +44 -0
- package/dist/src/extract/typescript.d.ts +180 -0
- package/dist/src/gate.d.ts +55 -0
- package/dist/src/gate.js +1 -0
- package/dist/src/graph.d.ts +142 -0
- package/dist/src/graph.js +4 -3
- package/dist/src/impact.d.ts +113 -0
- package/dist/src/imports.d.ts +67 -0
- package/dist/src/mergeback.d.ts +182 -0
- package/dist/src/mergeback.js +580 -35
- package/dist/src/platform.d.ts +69 -0
- package/dist/src/platform.js +184 -0
- package/dist/src/policy.d.ts +181 -0
- package/dist/src/policy.js +716 -230
- package/dist/src/project/build.d.ts +23 -0
- package/dist/src/project/catalog.d.ts +25 -0
- package/dist/src/project/project.d.ts +287 -0
- package/dist/src/project/project.js +479 -52
- package/dist/src/project/provenance.d.ts +73 -0
- package/dist/src/project/provenance.js +12 -0
- package/dist/src/project/runner.d.ts +19 -0
- package/dist/src/project/secrets.d.ts +64 -0
- package/dist/src/project/vitest-config.d.ts +70 -0
- package/dist/src/project/vitest-config.js +291 -0
- package/dist/src/pseudonyms/aliases.d.ts +107 -0
- package/dist/src/pseudonyms/aliases.js +394 -0
- package/dist/src/pseudonyms/draft.d.ts +64 -0
- package/dist/src/pseudonyms/draft.js +284 -0
- package/dist/src/pseudonyms/folder.d.ts +172 -0
- package/dist/src/pseudonyms/folder.js +527 -0
- package/dist/src/pseudonyms/index.d.ts +11 -0
- package/dist/src/pseudonyms/index.js +11 -0
- package/dist/src/pseudonyms/vocabulary.d.ts +113 -0
- package/dist/src/pseudonyms/vocabulary.js +522 -0
- package/dist/src/readiness.d.ts +71 -0
- package/dist/src/readiness.js +131 -0
- package/dist/src/receipt.d.ts +154 -0
- package/dist/src/receipt.js +296 -0
- package/dist/src/sandbox.d.ts +53 -0
- package/dist/src/sandbox.js +154 -0
- package/dist/src/slice/analysis.d.ts +75 -0
- package/dist/src/slice/externals.d.ts +51 -0
- package/dist/src/slice/graph.d.ts +69 -0
- package/dist/src/slice/manifest.d.ts +262 -0
- package/dist/src/slice/manifest.js +4 -3
- package/dist/src/slice/reach.d.ts +204 -0
- package/dist/src/slice/reach.js +11 -5
- package/dist/src/slice/report.d.ts +49 -0
- package/dist/src/slice/report.js +5 -2
- package/dist/src/specifiers.d.ts +48 -0
- package/dist/src/status.d.ts +46 -0
- package/dist/src/status.js +3 -2
- package/dist/src/test-integrity.d.ts +54 -0
- package/dist/src/test-integrity.js +812 -0
- package/package.json +24 -5
- package/skills/carveout/SKILL.md +517 -0
- package/skills/carveout-gate/SKILL.md +98 -0
- package/SKILL.md +0 -423
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How every dependency of a cut is satisfied: the cut modules become
|
|
3
|
+
* runtime mocks generated from their declarations, service packages and
|
|
4
|
+
* packages no lockfile shows come from the public registry become mock
|
|
5
|
+
* packages, and the rest install at the version the source pins.
|
|
6
|
+
*
|
|
7
|
+
* The slice manifest and boundary contract are the inputs; this module
|
|
8
|
+
* reads them and never the source.
|
|
9
|
+
*/
|
|
10
|
+
import type { SliceManifest, BoundaryContract } from "../slice/manifest.js";
|
|
11
|
+
export declare const DEPENDENCY_RESOLUTIONS: readonly ["included-code", "runtime-mock", "approved-package", "unresolved"];
|
|
12
|
+
export type DependencyResolution = (typeof DEPENDENCY_RESOLUTIONS)[number];
|
|
13
|
+
/** Whether the registry resolves a pin, directly or through an alias. */
|
|
14
|
+
export declare function isRegistryVersion(version: string): boolean;
|
|
15
|
+
export interface ResolvedDependency {
|
|
16
|
+
/** A package name, or a repository path for a cut module. */
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly kind: "package" | "module";
|
|
19
|
+
readonly version: string | null;
|
|
20
|
+
readonly resolution: DependencyResolution;
|
|
21
|
+
readonly detail: string;
|
|
22
|
+
}
|
|
23
|
+
/** What the owner may decide per dependency; the rest is derived. */
|
|
24
|
+
export type DependencyChoice = "runtime-mock" | "approved-package";
|
|
25
|
+
export interface ProvenanceBlocker {
|
|
26
|
+
readonly code: "slice_not_ready" | "dependency_unresolved" | "partial_signature_coverage" | "alias_rules_invalid" | "operation_unapproved";
|
|
27
|
+
readonly detail: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The classification a cut gives every path. Copied source keeps its
|
|
31
|
+
* structure; generated files live apart from it and are never editable.
|
|
32
|
+
*/
|
|
33
|
+
export declare const PATH_CLASSES: readonly ["source", "stub", "mock", "test-public", "config", "harness"];
|
|
34
|
+
export type PathClass = (typeof PATH_CLASSES)[number];
|
|
35
|
+
export interface ScopeRecord {
|
|
36
|
+
/** Repository paths a contribution may edit, sorted. */
|
|
37
|
+
readonly editablePaths: readonly string[];
|
|
38
|
+
/** Repository paths whose content is generated, sorted, never editable. */
|
|
39
|
+
readonly generatedPaths: readonly string[];
|
|
40
|
+
readonly permittedOperations: readonly ("edit" | "add" | "delete" | "rename")[];
|
|
41
|
+
readonly dependencies: readonly ResolvedDependency[];
|
|
42
|
+
readonly blockers: readonly ProvenanceBlocker[];
|
|
43
|
+
}
|
|
44
|
+
export interface ScopeInput {
|
|
45
|
+
readonly manifest: SliceManifest;
|
|
46
|
+
readonly contract: BoundaryContract;
|
|
47
|
+
/** Owner choices by package name; unspecified names take the default. */
|
|
48
|
+
readonly choices?: Readonly<Record<string, DependencyChoice>>;
|
|
49
|
+
}
|
|
50
|
+
/** Choices naming a package the slice does not require: a typo, never applied. */
|
|
51
|
+
export declare function unknownDependencyChoices(manifest: Pick<SliceManifest, "requiredBuildInputs">, choices: Readonly<Record<string, DependencyChoice>>): string[];
|
|
52
|
+
/**
|
|
53
|
+
* Resolves every dependency of the slice. Cut modules with full declaration
|
|
54
|
+
* coverage become runtime mocks generated from their declarations; service
|
|
55
|
+
* packages the externals pass named default to runtime mocks; a pinned
|
|
56
|
+
* package the repository shows comes from the public registry is approved
|
|
57
|
+
* for installation, and any other defaults to a runtime mock, since a
|
|
58
|
+
* private registry's name is anyone's on the public one. An owner may
|
|
59
|
+
* approve a package the repository says nothing of, never one it fetches
|
|
60
|
+
* from another registry or its own workspace's. An unpinned package blocks
|
|
61
|
+
* unless it is mocked, by default or by choice. Dynamic and unresolved
|
|
62
|
+
* imports are blockers the slice already carries, and they stay blockers
|
|
63
|
+
* here.
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveScope(input: ScopeInput): ScopeRecord;
|
|
66
|
+
/**
|
|
67
|
+
* Whether a name is one npm installs a package by: `zod`, `@scope/name`.
|
|
68
|
+
* A Vite virtual module (`virtual:build-info`), an alias that resolved
|
|
69
|
+
* nowhere (`@/api`) or any other scheme is not; a slice mocks it as a
|
|
70
|
+
* module of its own, never as a package.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isPackageName(name: string): boolean;
|
|
73
|
+
export declare function packageNameOf(specifier: string): string;
|
|
@@ -206,6 +206,18 @@ export function resolveScope(input) {
|
|
|
206
206
|
blockers,
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether a name is one npm installs a package by: `zod`, `@scope/name`.
|
|
211
|
+
* A Vite virtual module (`virtual:build-info`), an alias that resolved
|
|
212
|
+
* nowhere (`@/api`) or any other scheme is not; a slice mocks it as a
|
|
213
|
+
* module of its own, never as a package.
|
|
214
|
+
*/
|
|
215
|
+
export function isPackageName(name) {
|
|
216
|
+
// npm's rule, but a leading `~`: `~/lib/errors` is the common alias for a
|
|
217
|
+
// source folder, and `~icons/…` a bundler's virtual module, never a
|
|
218
|
+
// package anyone installs.
|
|
219
|
+
return name.length <= 214 && /^(@[a-z0-9-][a-z0-9._~-]*\/)?[a-z0-9-][a-z0-9._~-]*$/i.test(name);
|
|
220
|
+
}
|
|
209
221
|
export function packageNameOf(specifier) {
|
|
210
222
|
const parts = specifier.split("/");
|
|
211
223
|
return specifier.startsWith("@") && parts.length > 1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test runner of a test file that imports none: Vitest with `globals`
|
|
3
|
+
* (`describe`, `it`, `expect` as globals) in a package that depends on
|
|
4
|
+
* Vitest and not on Jest. A test that imports `node:test` or `vitest` says
|
|
5
|
+
* its runner itself (`testRunnerOf`). Without this, every test of a package
|
|
6
|
+
* on Vitest's globals was left behind as running on no known runner (E8g:
|
|
7
|
+
* all 11 tests of an n8n module).
|
|
8
|
+
*/
|
|
9
|
+
import { type TestRunner } from "../slice/reach.js";
|
|
10
|
+
/** The pattern's source, for the folder's own test runner to use. */
|
|
11
|
+
export declare const GLOBAL_CALL_SOURCE: string;
|
|
12
|
+
/** Whether a test file calls a runner's globals without importing one. */
|
|
13
|
+
export declare function usesTestGlobals(text: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The runner of each test file: its imports first, then a package on
|
|
16
|
+
* Vitest's globals (the file's package, else the repository's root). Jest
|
|
17
|
+
* is not run by a slice, so a package on Jest gives null.
|
|
18
|
+
*/
|
|
19
|
+
export declare function testRunnerResolver(repo: string, packageOf: (file: string) => string): (file: string, specifiers: readonly string[]) => TestRunner | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets: the credentials a source repository can hold in its text (a
|
|
3
|
+
* private key, a cloud or API token, a password assigned in code) and the
|
|
4
|
+
* files that exist to hold them (`.env`, `*.pem`, a service-account JSON).
|
|
5
|
+
*
|
|
6
|
+
* A sandbox is cut from a private repository, so a slice that reaches a
|
|
7
|
+
* secret-named file is refused, and a build scans every public file it
|
|
8
|
+
* generates and blocks on what it finds. A finding names its rule, file and
|
|
9
|
+
* line, never the value: a blocker is shown to the people and agents the
|
|
10
|
+
* value must not reach.
|
|
11
|
+
*
|
|
12
|
+
* Provider rules match a token's own shape (an `Authorization: Bearer`
|
|
13
|
+
* header's and an `.npmrc` `_authToken`'s too), and skip one only when what
|
|
14
|
+
* follows its prefix is a placeholder as a whole (`ghp_xxxx…`, `${TOKEN}`,
|
|
15
|
+
* `<token>`, `your-token-here`, AWS's documented `…EXAMPLE` keys): a real
|
|
16
|
+
* token can hold `Test` by chance or by its owner's choice. A URL's password
|
|
17
|
+
* (`postgres://app:…@db`) counts unless it is empty, a template or such a
|
|
18
|
+
* placeholder (`user:password@`).
|
|
19
|
+
*
|
|
20
|
+
* The generic rule needs a name whose last word says secret (`apiKey`,
|
|
21
|
+
* `DB_PASSWORD`, `client_secret`, not `tokenizerModel` or
|
|
22
|
+
* `passwordResetPath`) assigned a long literal of high entropy with letters
|
|
23
|
+
* and digits, and skips values with a placeholder word in them (`fixture`,
|
|
24
|
+
* `test`, `your`) and values that read as a path, an ARN or words joined
|
|
25
|
+
* (`token-refresh-queue-v2`, `gpt-4o-mini-2024-07-18`). A password-named
|
|
26
|
+
* literal of letters alone (`correcthorsebatterystaple`) counts too unless
|
|
27
|
+
* it is shaped like an identifier. An environment read is not a literal, so
|
|
28
|
+
* it never matches, but its fallback (`process.env.API_KEY ?? "…"`) is. A
|
|
29
|
+
* hex value under a name that says it is a hash (`tokenHash`,
|
|
30
|
+
* `secretDigest`) is a digest, not a credential.
|
|
31
|
+
*/
|
|
32
|
+
import type { ProjectBlocker } from "./project.js";
|
|
33
|
+
export declare const SECRET_RULES: readonly ["private-key", "aws-access-key-id", "github-token", "gitlab-token", "npm-token", "slack-token", "stripe-live-key", "google-api-key", "google-service-account", "sendgrid-key", "huggingface-token", "jwt", "anthropic-key", "openai-key", "bearer-token", "url-credentials", "generic-secret"];
|
|
34
|
+
export type SecretRule = (typeof SECRET_RULES)[number];
|
|
35
|
+
export interface SecretFinding {
|
|
36
|
+
readonly rule: SecretRule;
|
|
37
|
+
readonly file: string;
|
|
38
|
+
/** 1-based. */
|
|
39
|
+
readonly line: number;
|
|
40
|
+
}
|
|
41
|
+
/** Shannon entropy of a string, in bits per character. */
|
|
42
|
+
export declare function entropy(text: string): number;
|
|
43
|
+
/** Each secret found in each file's text, by file, line and rule. */
|
|
44
|
+
export declare function scanSecrets(files: readonly {
|
|
45
|
+
readonly path: string;
|
|
46
|
+
readonly text: string;
|
|
47
|
+
}[]): SecretFinding[];
|
|
48
|
+
/**
|
|
49
|
+
* Files that exist to hold credentials: environment files (`.env`,
|
|
50
|
+
* `.env.local`, `.env-prod`, `prod.env`, direnv's `.envrc`), keys and
|
|
51
|
+
* keystores, SSH identities, credential, service-account, admin SDK and
|
|
52
|
+
* key JSON, secrets files (`secrets.yml`, `app.secrets.json`), Terraform
|
|
53
|
+
* variables, kubeconfigs, AWS shared credentials and Docker's and Kubernetes'
|
|
54
|
+
* client configuration, and npm, PyPI, Git, Postgres and netrc credential
|
|
55
|
+
* files (which carry registry, host and database passwords). Matched on the
|
|
56
|
+
* basename, case-insensitively, except `.aws/credentials`,
|
|
57
|
+
* `.docker/config.json` and `.kube/config`, path suffixes. An environment
|
|
58
|
+
* template (`.env.example`, `.env.local.sample`, `.env-template`) documents
|
|
59
|
+
* keys, not values, so it is not one; its text is scanned like any other.
|
|
60
|
+
* No slice may include one, and no agent tool reads one.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isSecretPath(path: string): boolean;
|
|
63
|
+
/** A blocker for each file with findings, naming lines and rules, never values. */
|
|
64
|
+
export declare function secretBlockers(findings: readonly SecretFinding[]): ProjectBlocker[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Vitest settings a package's tests run under, read from its config's
|
|
3
|
+
* syntax and never run: the environment (`jsdom` for a component's tests,
|
|
4
|
+
* which otherwise fail on `document is not defined`), the setup files that
|
|
5
|
+
* run before them, the test timeout and the globals. A slice that carries
|
|
6
|
+
* the package's Vitest tests runs them under the same settings: the setup
|
|
7
|
+
* files come along as entry points, the environment's package is installed
|
|
8
|
+
* as the repository locks it, and the folder gets a `vitest.config.mjs`.
|
|
9
|
+
*/
|
|
10
|
+
/** What a package's Vitest config sets for its tests, as far as the slice needs. */
|
|
11
|
+
export interface VitestSettings {
|
|
12
|
+
/** `jsdom`, `happy-dom`, `node`, or another environment's name. */
|
|
13
|
+
readonly environment?: string;
|
|
14
|
+
/** Setup files, as paths from the repository's root. */
|
|
15
|
+
readonly setupFiles: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Setup files named by a package's specifier
|
|
18
|
+
* (`@testing-library/jest-dom/vitest`): installed, and run as named.
|
|
19
|
+
*/
|
|
20
|
+
readonly setupPackages?: readonly string[];
|
|
21
|
+
readonly testTimeout?: number;
|
|
22
|
+
readonly globals?: boolean;
|
|
23
|
+
/** The test files it runs (`test.include`), as globs from the repository's root. */
|
|
24
|
+
readonly include?: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* The config's inline projects (`test.projects`), each with what it
|
|
27
|
+
* sets: Vitest runs a test under the project whose `include` matches it.
|
|
28
|
+
*/
|
|
29
|
+
readonly projects?: readonly VitestProject[];
|
|
30
|
+
}
|
|
31
|
+
/** One inline project of a Vitest config, its paths from the repository's root. */
|
|
32
|
+
export interface VitestProject {
|
|
33
|
+
readonly name?: string;
|
|
34
|
+
/** Globs from the repository's root. */
|
|
35
|
+
readonly include?: readonly string[];
|
|
36
|
+
readonly environment?: string;
|
|
37
|
+
readonly setupFiles: readonly string[];
|
|
38
|
+
readonly setupPackages?: readonly string[];
|
|
39
|
+
readonly testTimeout?: number;
|
|
40
|
+
readonly globals?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** The packages each environment runs on. */
|
|
43
|
+
export declare const ENVIRONMENT_PACKAGES: Readonly<Record<string, readonly string[]>>;
|
|
44
|
+
/**
|
|
45
|
+
* The environments a test file asks for itself, in a docblock comment
|
|
46
|
+
* (`// @vitest-environment happy-dom`), which run whatever the config says.
|
|
47
|
+
*/
|
|
48
|
+
export declare function fileEnvironments(text: string): string[];
|
|
49
|
+
/**
|
|
50
|
+
* The settings of the first Vitest config in `dir` (`vitest.config.*`,
|
|
51
|
+
* then `vite.config.*`), else at the repository's root, or null when there
|
|
52
|
+
* is none or it sets nothing the slice can carry. The `test` object is the
|
|
53
|
+
* default export's (through `defineConfig`, a function, a name), else the
|
|
54
|
+
* first `test: {…}` in the file. Only literal values are read: a computed
|
|
55
|
+
* one is left to Vitest's default.
|
|
56
|
+
*/
|
|
57
|
+
export declare function vitestSettings(repo: string, dir: string): VitestSettings | null;
|
|
58
|
+
/**
|
|
59
|
+
* The folder's `vitest.config.mjs`: the settings, with the setup files at
|
|
60
|
+
* their folder paths; an alias for each module that is no package
|
|
61
|
+
* (`virtual:build-info`, and its subpaths) to its mock under `carveout/`;
|
|
62
|
+
* and a resolver that reads the source beside a built `.js`. The folder's
|
|
63
|
+
* build writes a `.js` beside each `.ts` for node:test, and a relative
|
|
64
|
+
* `./x.js` would otherwise be that file to Vitest: `vitest` run on its own
|
|
65
|
+
* after an edit tested the code as it was at the last build, where the
|
|
66
|
+
* repository's Vitest reads the source.
|
|
67
|
+
*/
|
|
68
|
+
export declare function vitestConfigText(settings: VitestSettings, folderPath?: (path: string) => string,
|
|
69
|
+
/** Module name → the folder of its mock, from the folder's root. */
|
|
70
|
+
virtualModules?: Readonly<Record<string, string>>): string;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Vitest settings a package's tests run under, read from its config's
|
|
3
|
+
* syntax and never run: the environment (`jsdom` for a component's tests,
|
|
4
|
+
* which otherwise fail on `document is not defined`), the setup files that
|
|
5
|
+
* run before them, the test timeout and the globals. A slice that carries
|
|
6
|
+
* the package's Vitest tests runs them under the same settings: the setup
|
|
7
|
+
* files come along as entry points, the environment's package is installed
|
|
8
|
+
* as the repository locks it, and the folder gets a `vitest.config.mjs`.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { join, posix } from "node:path";
|
|
12
|
+
import ts from "typescript";
|
|
13
|
+
const CONFIG_NAMES = ["vitest.config", "vite.config"].flatMap((name) => ["ts", "mts", "cts", "js", "mjs", "cjs"].map((ext) => `${name}.${ext}`));
|
|
14
|
+
/** The packages each environment runs on. */
|
|
15
|
+
export const ENVIRONMENT_PACKAGES = {
|
|
16
|
+
jsdom: ["jsdom"],
|
|
17
|
+
"happy-dom": ["happy-dom"],
|
|
18
|
+
"edge-runtime": ["@edge-runtime/vm"],
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The environments a test file asks for itself, in a docblock comment
|
|
22
|
+
* (`// @vitest-environment happy-dom`), which run whatever the config says.
|
|
23
|
+
*/
|
|
24
|
+
export function fileEnvironments(text) {
|
|
25
|
+
return [...new Set([...text.matchAll(/@vitest-environment\s+([\w-]+)/g)].map((match) => match[1] ?? ""))].filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
/** An expression with what only types it taken away: `(x)`, `x as T`, `x satisfies T`, `<T>x`. */
|
|
28
|
+
function bare(node) {
|
|
29
|
+
let at = node;
|
|
30
|
+
for (;;) {
|
|
31
|
+
if (ts.isParenthesizedExpression(at) || ts.isAsExpression(at) || ts.isSatisfiesExpression(at) || ts.isTypeAssertionExpression(at))
|
|
32
|
+
at = at.expression;
|
|
33
|
+
else
|
|
34
|
+
return at;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The object an expression of the config comes to, read and never run: an
|
|
39
|
+
* object literal; a call's (`defineConfig({…})`, `mergeConfig(a, b)`, the
|
|
40
|
+
* last that is one); a function's result (`defineConfig(() => ({…}))`, or
|
|
41
|
+
* its block's last `return`); a name declared in the file with one.
|
|
42
|
+
*/
|
|
43
|
+
function objectOf(node, source, depth = 0) {
|
|
44
|
+
if (node === undefined || depth > 8)
|
|
45
|
+
return null;
|
|
46
|
+
const at = bare(node);
|
|
47
|
+
if (ts.isObjectLiteralExpression(at))
|
|
48
|
+
return at;
|
|
49
|
+
if (ts.isCallExpression(at)) {
|
|
50
|
+
for (const argument of [...at.arguments].reverse()) {
|
|
51
|
+
const found = objectOf(argument, source, depth + 1);
|
|
52
|
+
if (found !== null)
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (ts.isArrowFunction(at) || ts.isFunctionExpression(at)) {
|
|
58
|
+
if (!ts.isBlock(at.body))
|
|
59
|
+
return objectOf(at.body, source, depth + 1);
|
|
60
|
+
const returns = [];
|
|
61
|
+
const visit = (child) => {
|
|
62
|
+
if (ts.isFunctionLike(child) && child !== at)
|
|
63
|
+
return;
|
|
64
|
+
if (ts.isReturnStatement(child) && child.expression !== undefined)
|
|
65
|
+
returns.push(child.expression);
|
|
66
|
+
ts.forEachChild(child, visit);
|
|
67
|
+
};
|
|
68
|
+
visit(at.body);
|
|
69
|
+
return objectOf(returns.at(-1), source, depth + 1);
|
|
70
|
+
}
|
|
71
|
+
if (ts.isIdentifier(at)) {
|
|
72
|
+
for (const statement of source.statements)
|
|
73
|
+
if (ts.isVariableStatement(statement))
|
|
74
|
+
for (const declaration of statement.declarationList.declarations)
|
|
75
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === at.text)
|
|
76
|
+
return objectOf(declaration.initializer, source, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/** An object literal's property's value by its key: `key: value`, or `key` alone (shorthand). */
|
|
81
|
+
function propertyOf(object, key) {
|
|
82
|
+
for (const each of object.properties) {
|
|
83
|
+
if (ts.isPropertyAssignment(each) && (ts.isIdentifier(each.name) || ts.isStringLiteral(each.name)) && each.name.text === key)
|
|
84
|
+
return each.initializer;
|
|
85
|
+
if (ts.isShorthandPropertyAssignment(each) && each.name.text === key)
|
|
86
|
+
return each.name;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The settings of the first Vitest config in `dir` (`vitest.config.*`,
|
|
92
|
+
* then `vite.config.*`), else at the repository's root, or null when there
|
|
93
|
+
* is none or it sets nothing the slice can carry. The `test` object is the
|
|
94
|
+
* default export's (through `defineConfig`, a function, a name), else the
|
|
95
|
+
* first `test: {…}` in the file. Only literal values are read: a computed
|
|
96
|
+
* one is left to Vitest's default.
|
|
97
|
+
*/
|
|
98
|
+
export function vitestSettings(repo, dir) {
|
|
99
|
+
const at = [dir, ...(dir === "" ? [] : [""])].flatMap((folder) => CONFIG_NAMES.filter((candidate) => existsSync(join(repo, folder, candidate))).map((name) => ({ folder, name })))[0];
|
|
100
|
+
if (at === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
const { folder, name } = at;
|
|
103
|
+
const text = readFileSync(join(repo, folder, name), "utf8");
|
|
104
|
+
const source = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true);
|
|
105
|
+
let test = null;
|
|
106
|
+
for (const statement of source.statements)
|
|
107
|
+
if (ts.isExportAssignment(statement)) {
|
|
108
|
+
const config = objectOf(statement.expression, source);
|
|
109
|
+
const value = config === null ? undefined : propertyOf(config, "test");
|
|
110
|
+
test = objectOf(value, source);
|
|
111
|
+
}
|
|
112
|
+
if (test === null) {
|
|
113
|
+
const visit = (node) => {
|
|
114
|
+
if (test !== null)
|
|
115
|
+
return;
|
|
116
|
+
if (ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && node.name.text === "test") {
|
|
117
|
+
const found = objectOf(node.initializer, source);
|
|
118
|
+
if (found !== null) {
|
|
119
|
+
test = found;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
ts.forEachChild(node, visit);
|
|
124
|
+
};
|
|
125
|
+
visit(source);
|
|
126
|
+
}
|
|
127
|
+
if (test === null)
|
|
128
|
+
return null;
|
|
129
|
+
const found = test;
|
|
130
|
+
const read = (object, base) => {
|
|
131
|
+
const property = (key) => {
|
|
132
|
+
const value = propertyOf(object, key);
|
|
133
|
+
return value === undefined ? undefined : bare(value);
|
|
134
|
+
};
|
|
135
|
+
const strings = (node) => node === undefined
|
|
136
|
+
? []
|
|
137
|
+
: ts.isStringLiteralLike(node)
|
|
138
|
+
? [node.text]
|
|
139
|
+
: ts.isArrayLiteralExpression(node)
|
|
140
|
+
? node.elements.flatMap((element) => (ts.isStringLiteralLike(element) ? [element.text] : []))
|
|
141
|
+
: [];
|
|
142
|
+
const root = property("root");
|
|
143
|
+
// Relative to the config's folder, or to the `root` it sets.
|
|
144
|
+
const from = root !== undefined && ts.isStringLiteralLike(root) ? posix.normalize(posix.join(base, root.text)) : base;
|
|
145
|
+
const named = strings(property("setupFiles"));
|
|
146
|
+
const setupFiles = named
|
|
147
|
+
.filter((file) => !isBareSpecifier(file))
|
|
148
|
+
.map((file) => posix.normalize(posix.join(from, file.replace(/^\.\//, ""))))
|
|
149
|
+
// As Vitest finds one: as written, or with a script's extension.
|
|
150
|
+
.flatMap((file) => ["", ".ts", ".tsx", ".mts", ".js", ".mjs"].map((ext) => `${file}${ext}`).find((path) => existsSync(join(repo, path)) && statSync(join(repo, path)).isFile()) ?? [])
|
|
151
|
+
.filter((file) => !file.startsWith(".."));
|
|
152
|
+
const setupPackages = named.filter(isBareSpecifier);
|
|
153
|
+
const include = strings(property("include")).map((glob) => (from === "" ? glob.replace(/^\.\//, "") : posix.join(from, glob.replace(/^\.\//, ""))));
|
|
154
|
+
const environment = property("environment");
|
|
155
|
+
const name = property("name");
|
|
156
|
+
const timeout = property("testTimeout");
|
|
157
|
+
const timeoutText = timeout !== undefined ? timeout.getText(source).replace(/_/g, "") : "";
|
|
158
|
+
const globals = property("globals");
|
|
159
|
+
return {
|
|
160
|
+
...(name !== undefined && ts.isStringLiteralLike(name) ? { name: name.text } : {}),
|
|
161
|
+
...(include.length === 0 ? {} : { include }),
|
|
162
|
+
...(environment !== undefined && ts.isStringLiteralLike(environment) ? { environment: environment.text } : {}),
|
|
163
|
+
setupFiles,
|
|
164
|
+
...(setupPackages.length === 0 ? {} : { setupPackages }),
|
|
165
|
+
...(/^\d+$/.test(timeoutText) ? { testTimeout: Number(timeoutText) } : {}),
|
|
166
|
+
...(globals?.kind === ts.SyntaxKind.TrueKeyword ? { globals: true } : {}),
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const top = read(found, folder);
|
|
170
|
+
// Inline projects: each its own `test`, under its own `root` if it sets one.
|
|
171
|
+
const listed = propertyOf(found, "projects");
|
|
172
|
+
const projects = listed !== undefined && ts.isArrayLiteralExpression(bare(listed))
|
|
173
|
+
? bare(listed).elements.flatMap((element) => {
|
|
174
|
+
if (!ts.isExpression(element))
|
|
175
|
+
return [];
|
|
176
|
+
const project = objectOf(element, source);
|
|
177
|
+
const projectTest = project === null ? null : objectOf(propertyOf(project, "test"), source);
|
|
178
|
+
return projectTest === null ? [] : [read(projectTest, folder)];
|
|
179
|
+
})
|
|
180
|
+
: [];
|
|
181
|
+
const settings = {
|
|
182
|
+
...(top.environment === undefined ? {} : { environment: top.environment }),
|
|
183
|
+
setupFiles: top.setupFiles,
|
|
184
|
+
...(top.setupPackages === undefined ? {} : { setupPackages: top.setupPackages }),
|
|
185
|
+
...(top.testTimeout === undefined ? {} : { testTimeout: top.testTimeout }),
|
|
186
|
+
...(top.globals === true ? { globals: true } : {}),
|
|
187
|
+
...(top.include === undefined ? {} : { include: top.include }),
|
|
188
|
+
...(projects.length === 0 ? {} : { projects }),
|
|
189
|
+
};
|
|
190
|
+
return settings.environment === undefined &&
|
|
191
|
+
settings.setupFiles.length === 0 &&
|
|
192
|
+
(settings.setupPackages ?? []).length === 0 &&
|
|
193
|
+
settings.testTimeout === undefined &&
|
|
194
|
+
settings.globals !== true &&
|
|
195
|
+
projects.length === 0
|
|
196
|
+
? null
|
|
197
|
+
: settings;
|
|
198
|
+
}
|
|
199
|
+
/** A specifier that names a package (`@testing-library/jest-dom/vitest`, `vitest-canvas-mock`), not a file. */
|
|
200
|
+
function isBareSpecifier(name) {
|
|
201
|
+
return !name.startsWith(".") && !name.startsWith("/") && !/^[A-Za-z]:/.test(name) && /^(@[\w.-]+\/)?[\w.-]+(\/[\w./-]*)?$/.test(name) && !/\.[cm]?[jt]sx?$/.test(name.split("/").pop() ?? "");
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The folder's `vitest.config.mjs`: the settings, with the setup files at
|
|
205
|
+
* their folder paths; an alias for each module that is no package
|
|
206
|
+
* (`virtual:build-info`, and its subpaths) to its mock under `carveout/`;
|
|
207
|
+
* and a resolver that reads the source beside a built `.js`. The folder's
|
|
208
|
+
* build writes a `.js` beside each `.ts` for node:test, and a relative
|
|
209
|
+
* `./x.js` would otherwise be that file to Vitest: `vitest` run on its own
|
|
210
|
+
* after an edit tested the code as it was at the last build, where the
|
|
211
|
+
* repository's Vitest reads the source.
|
|
212
|
+
*/
|
|
213
|
+
export function vitestConfigText(settings, folderPath = (path) => path,
|
|
214
|
+
/** Module name → the folder of its mock, from the folder's root. */
|
|
215
|
+
virtualModules = {}) {
|
|
216
|
+
const test = {
|
|
217
|
+
...(settings.environment === undefined ? {} : { environment: settings.environment }),
|
|
218
|
+
...(settings.setupFiles.length === 0 && (settings.setupPackages ?? []).length === 0
|
|
219
|
+
? {}
|
|
220
|
+
: { setupFiles: [...(settings.setupPackages ?? []), ...settings.setupFiles.map((file) => `./${folderPath(file)}`)] }),
|
|
221
|
+
...(settings.testTimeout === undefined ? {} : { testTimeout: settings.testTimeout }),
|
|
222
|
+
...(settings.globals === true ? { globals: true } : {}),
|
|
223
|
+
};
|
|
224
|
+
const aliases = Object.entries(virtualModules).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
225
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
226
|
+
return [
|
|
227
|
+
"// The repository's Vitest settings for the tests this folder carries, as the cut read them,",
|
|
228
|
+
"// the modules that are no package found at their mocks, and the source read beside a built .js.",
|
|
229
|
+
'import { existsSync } from "node:fs";',
|
|
230
|
+
'import { dirname, resolve } from "node:path";',
|
|
231
|
+
'import { fileURLToPath } from "node:url";',
|
|
232
|
+
"",
|
|
233
|
+
"const SOURCES = { js: [\".ts\", \".tsx\"], mjs: [\".mts\"], cjs: [\".cts\"] };",
|
|
234
|
+
"/** `./x.js` from the folder's own code is `./x.ts` when that is there, as in the repository. */",
|
|
235
|
+
"const sourceFirst = {",
|
|
236
|
+
' name: "carveout-source-first",',
|
|
237
|
+
' enforce: "pre",',
|
|
238
|
+
" resolveId(source, importer) {",
|
|
239
|
+
' if (importer === undefined || importer.includes("/node_modules/") || !/^\\.{1,2}\\//.test(source)) return null;',
|
|
240
|
+
" const match = /^(.*)\\.([cm]?js)$/.exec(source);",
|
|
241
|
+
' const from = dirname(importer.split("?")[0]);',
|
|
242
|
+
" if (match !== null) {",
|
|
243
|
+
" const stem = resolve(from, match[1]);",
|
|
244
|
+
" for (const ext of SOURCES[match[2]]) if (existsSync(stem + ext)) return stem + ext;",
|
|
245
|
+
" return null;",
|
|
246
|
+
" }",
|
|
247
|
+
" // No extension (`./sum`): the source, ahead of any `.mjs` or `.js` beside it.",
|
|
248
|
+
" if (/\\.[^/.]+$/.test(source)) return null;",
|
|
249
|
+
" const bare = resolve(from, source);",
|
|
250
|
+
' for (const candidate of [".ts", ".tsx", ".mts", ".cts", "/index.ts", "/index.tsx"]) if (existsSync(bare + candidate)) return bare + candidate;',
|
|
251
|
+
" return null;",
|
|
252
|
+
" },",
|
|
253
|
+
"};",
|
|
254
|
+
"",
|
|
255
|
+
"export default {",
|
|
256
|
+
" plugins: [sourceFirst],",
|
|
257
|
+
` test: ${JSON.stringify({
|
|
258
|
+
...test,
|
|
259
|
+
// Each inline project as the repository's config sets it, extending
|
|
260
|
+
// this one (its resolver and aliases) with its paths at the folder's.
|
|
261
|
+
...(settings.projects === undefined || settings.projects.length === 0
|
|
262
|
+
? {}
|
|
263
|
+
: {
|
|
264
|
+
projects: settings.projects.map((project) => ({
|
|
265
|
+
extends: true,
|
|
266
|
+
test: {
|
|
267
|
+
...(project.name === undefined ? {} : { name: project.name }),
|
|
268
|
+
...(project.include === undefined ? {} : { include: project.include.map(folderPath) }),
|
|
269
|
+
...(project.environment === undefined ? {} : { environment: project.environment }),
|
|
270
|
+
...(project.setupFiles.length === 0 && (project.setupPackages ?? []).length === 0
|
|
271
|
+
? {}
|
|
272
|
+
: { setupFiles: [...(project.setupPackages ?? []), ...project.setupFiles.map((file) => `./${folderPath(file)}`)] }),
|
|
273
|
+
...(project.testTimeout === undefined ? {} : { testTimeout: project.testTimeout }),
|
|
274
|
+
...(project.globals === true ? { globals: true } : {}),
|
|
275
|
+
},
|
|
276
|
+
})),
|
|
277
|
+
}),
|
|
278
|
+
}, null, 2).replace(/\n/g, "\n ")},`,
|
|
279
|
+
...(aliases.length === 0
|
|
280
|
+
? []
|
|
281
|
+
: [
|
|
282
|
+
" resolve: {",
|
|
283
|
+
" alias: [",
|
|
284
|
+
...aliases.map(([name, dir]) => ` { find: /^${escape(name)}(?:\\/.*)?$/, replacement: fileURLToPath(new URL(${JSON.stringify(`./${dir}/index.js`)}, import.meta.url)) },`),
|
|
285
|
+
" ],",
|
|
286
|
+
" },",
|
|
287
|
+
]),
|
|
288
|
+
"};",
|
|
289
|
+
"",
|
|
290
|
+
].join("\n");
|
|
291
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias rules: the private, bidirectional name table a sandbox version
|
|
3
|
+
* applies to copied source before it can leave the organization.
|
|
4
|
+
*
|
|
5
|
+
* A rule is an explicit pair with a kind and a scope. Rules are ordered,
|
|
6
|
+
* and applied in that order; they are validated as a set before any text
|
|
7
|
+
* is touched, because two rules that look harmless alone can interact (one
|
|
8
|
+
* rule's output is another's input, two rules share a target, two
|
|
9
|
+
* identifier or path names differ only by case). Application is
|
|
10
|
+
* deterministic and textual, bounded to identifier or path tokens so a name
|
|
11
|
+
* inside a longer identifier is never touched, and it is checked to be
|
|
12
|
+
* reversible: applying the inverse table to the output must give the input
|
|
13
|
+
* back byte for byte. A text rule matches its exact, case-sensitive
|
|
14
|
+
* characters and is inverted the same way, so two text rules that differ
|
|
15
|
+
* only by case are distinct and both hold. A
|
|
16
|
+
* transformation that cannot be mapped back is refused, never silently
|
|
17
|
+
* dropped. Redaction of implementation is not a rename and has no place
|
|
18
|
+
* here.
|
|
19
|
+
*/
|
|
20
|
+
export declare const ALIAS_KINDS: readonly ["identifier", "path", "text"];
|
|
21
|
+
export type AliasKind = (typeof ALIAS_KINDS)[number];
|
|
22
|
+
export interface AliasRule {
|
|
23
|
+
readonly before: string;
|
|
24
|
+
readonly after: string;
|
|
25
|
+
readonly kind: AliasKind;
|
|
26
|
+
/**
|
|
27
|
+
* Repository paths the rule applies to: a file, or a directory prefix.
|
|
28
|
+
* Empty means every file. Scoped rules let one name mean different
|
|
29
|
+
* things in different modules without a symbol table.
|
|
30
|
+
*/
|
|
31
|
+
readonly paths: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
export declare const ALIAS_PROBLEM_CODES: readonly ["empty", "identity", "invalid_identifier", "invalid_path", "duplicate_source", "duplicate_target", "chained", "casing_variant", "target_collision", "irreversible", "too_many", "unsupported_kind"];
|
|
34
|
+
export type AliasProblemCode = (typeof ALIAS_PROBLEM_CODES)[number];
|
|
35
|
+
export interface AliasProblem {
|
|
36
|
+
readonly code: AliasProblemCode;
|
|
37
|
+
/** Index into the rule list; null for a problem of the whole set. */
|
|
38
|
+
readonly rule: number | null;
|
|
39
|
+
readonly file: string | null;
|
|
40
|
+
readonly detail: string;
|
|
41
|
+
}
|
|
42
|
+
export declare const ALIAS_RULES_MAX = 500;
|
|
43
|
+
export interface AliasValidation {
|
|
44
|
+
readonly ok: boolean;
|
|
45
|
+
readonly problems: readonly AliasProblem[];
|
|
46
|
+
}
|
|
47
|
+
/** Whether the set is well formed and free of interactions. */
|
|
48
|
+
export declare function validateAliasRules(rules: readonly AliasRule[]): AliasValidation;
|
|
49
|
+
export interface AliasedFile {
|
|
50
|
+
readonly path: string;
|
|
51
|
+
readonly text: string;
|
|
52
|
+
}
|
|
53
|
+
export interface AliasApplication {
|
|
54
|
+
readonly ok: boolean;
|
|
55
|
+
readonly files: readonly AliasedFile[];
|
|
56
|
+
/** Original path to aliased path, for every file whose path changed. */
|
|
57
|
+
readonly renames: readonly {
|
|
58
|
+
readonly from: string;
|
|
59
|
+
readonly to: string;
|
|
60
|
+
}[];
|
|
61
|
+
readonly problems: readonly AliasProblem[];
|
|
62
|
+
/** Rule index to the number of replacements it made, for the record. */
|
|
63
|
+
readonly applied: readonly number[];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Applies the rules to the files and proves the result maps back. Problems
|
|
67
|
+
* refuse the whole application: a partially aliased candidate is worse
|
|
68
|
+
* than none, because it looks finished.
|
|
69
|
+
*/
|
|
70
|
+
export declare function applyAliases(files: readonly AliasedFile[], rules: readonly AliasRule[]): AliasApplication;
|
|
71
|
+
/**
|
|
72
|
+
* A JSON value's string leaves, each as a file of its own, for aliasing a
|
|
73
|
+
* structure without touching its shape.
|
|
74
|
+
*
|
|
75
|
+
* Aliasing a serialized object as one text renames its keys too: a quote is
|
|
76
|
+
* a word boundary, so a rule `name → label` turned `"name":` into
|
|
77
|
+
* `"label":` and the object lost the field. Leaves alone keep every key,
|
|
78
|
+
* number and boolean as they were. Each is named `base#pointer`, and
|
|
79
|
+
* `withJsonLeaves` puts them back in the same order.
|
|
80
|
+
*/
|
|
81
|
+
export declare function jsonLeaves(base: string, value: unknown): AliasedFile[];
|
|
82
|
+
/**
|
|
83
|
+
* `value` with its string leaves replaced by `texts`, in `jsonLeaves`'s
|
|
84
|
+
* order; a leaf with no text left keeps its own. A fresh value: the one
|
|
85
|
+
* given is not changed.
|
|
86
|
+
*/
|
|
87
|
+
export declare function withJsonLeaves<T>(value: T, texts: readonly string[]): T;
|
|
88
|
+
/**
|
|
89
|
+
* The name a build gives its alias table among the private files it writes:
|
|
90
|
+
* `private/pseudonym.lunox`, beside the hidden tests.
|
|
91
|
+
*/
|
|
92
|
+
export declare const PSEUDONYMS_FILE = "pseudonym.lunox";
|
|
93
|
+
export declare const PSEUDONYMS_SCHEMA_VERSION = 1;
|
|
94
|
+
/** `pseudonym.lunox`: the table a version's public files were renamed by. */
|
|
95
|
+
export interface PseudonymTable {
|
|
96
|
+
readonly schemaVersion: typeof PSEUDONYMS_SCHEMA_VERSION;
|
|
97
|
+
readonly rules: readonly AliasRule[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* One file renamed by the rules, without the checks `applyAliases` makes:
|
|
101
|
+
* for reading back a file already proved to map back, such as a public
|
|
102
|
+
* file in its private names under the inverse table. `file.path` is its
|
|
103
|
+
* repository path, which scoped rules are matched against.
|
|
104
|
+
*/
|
|
105
|
+
export declare function renameFile(file: AliasedFile, rules: readonly AliasRule[]): AliasedFile;
|
|
106
|
+
/** The inverse table, as a contribution's import applies it. */
|
|
107
|
+
export declare function invertAliasRules(rules: readonly AliasRule[]): AliasRule[];
|