@ultimat3/cli 27.2.3 → 27.4.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/package.json +28 -28
- package/src/app-config-load.ts +1 -2
- package/src/app-env.ts +7 -2
- package/src/app-layout.ts +46 -0
- package/src/app-load.ts +2 -1
- package/src/app-root.ts +7 -15
- package/src/boundary-findings.ts +2 -0
- package/src/cmd-clean-spec.ts +13 -0
- package/src/cmd-clean.ts +36 -0
- package/src/cmd-env-spec.ts +1 -1
- package/src/doctor-offline.ts +1 -2
- package/src/error-codes.ts +2 -0
- package/src/index.ts +4 -0
- package/src/manifest-staleness.ts +1 -2
- package/src/mcp-errors.ts +2 -0
- package/src/messages.ts +4 -0
- package/src/registry.ts +2 -0
- package/src/test-database.ts +133 -0
- package/src/test-db-template.ts +64 -0
- package/src/test-housekeeping.ts +197 -0
- package/src/test-shards.ts +3 -0
- package/src/verify-checks.ts +1 -2
- package/src/verify-merge.ts +1 -2
- package/src/verify-test-run.ts +3 -0
- package/src/verify-tests.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.4.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,33 +39,33 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@babel/core": "^8.0.1",
|
|
42
|
-
"@ultimat3/action": "27.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
42
|
+
"@ultimat3/action": "27.4.0",
|
|
43
|
+
"@ultimat3/admin": "27.4.0",
|
|
44
|
+
"@ultimat3/ai": "27.4.0",
|
|
45
|
+
"@ultimat3/auth": "27.4.0",
|
|
46
|
+
"@ultimat3/cache": "27.4.0",
|
|
47
|
+
"@ultimat3/core": "27.4.0",
|
|
48
|
+
"@ultimat3/db": "27.4.0",
|
|
49
|
+
"@ultimat3/entity": "27.4.0",
|
|
50
|
+
"@ultimat3/flags": "27.4.0",
|
|
51
|
+
"@ultimat3/http": "27.4.0",
|
|
52
|
+
"@ultimat3/i18n": "27.4.0",
|
|
53
|
+
"@ultimat3/jobs": "27.4.0",
|
|
54
|
+
"@ultimat3/mail": "27.4.0",
|
|
55
|
+
"@ultimat3/manifest": "27.4.0",
|
|
56
|
+
"@ultimat3/mcp": "27.4.0",
|
|
57
|
+
"@ultimat3/money": "27.4.0",
|
|
58
|
+
"@ultimat3/notify": "27.4.0",
|
|
59
|
+
"@ultimat3/policy": "27.4.0",
|
|
60
|
+
"@ultimat3/pwa": "27.4.0",
|
|
61
|
+
"@ultimat3/query": "27.4.0",
|
|
62
|
+
"@ultimat3/realtime": "27.4.0",
|
|
63
|
+
"@ultimat3/render": "27.4.0",
|
|
64
|
+
"@ultimat3/schema": "27.4.0",
|
|
65
|
+
"@ultimat3/seo": "27.4.0",
|
|
66
|
+
"@ultimat3/storage": "27.4.0",
|
|
67
|
+
"@ultimat3/testing": "27.4.0",
|
|
68
|
+
"@ultimat3/time": "27.4.0",
|
|
69
69
|
"babel-preset-solid": "^1.9.15"
|
|
70
70
|
}
|
|
71
71
|
}
|
package/src/app-config-load.ts
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// why: Bun exposes no path-join primitive, and the config path is app-root-relative.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import type { AppConfig, AppConfigInput } from '@ultimat3/core';
|
|
9
|
-
import { ConfigInvalidError, defineConfig, describeValue } from '@ultimat3/core';
|
|
10
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
9
|
+
import { APP_CONFIG_FILE, ConfigInvalidError, defineConfig, describeValue } from '@ultimat3/core';
|
|
11
10
|
|
|
12
11
|
/** The export every app declares. Named, never default — the CLI and the runtime both import it. */
|
|
13
12
|
export const APP_CONFIG_EXPORT = 'config';
|
package/src/app-env.ts
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
// why: Bun exposes no path API — the two files this module reads are joined to the app root.
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
APP_CONFIG_FILE,
|
|
10
|
+
ENV_EXAMPLE_PATH,
|
|
11
|
+
ERROR_DOCS_URL,
|
|
12
|
+
parseEnvKeys,
|
|
13
|
+
pushWired,
|
|
14
|
+
} from '@ultimat3/core';
|
|
9
15
|
import { appConfigExport, loadAppConfig } from './app-config-load';
|
|
10
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
11
16
|
import type { AppSecretFacts } from './framework-env';
|
|
12
17
|
import { appEnvExample, configOwes, FRAMEWORK_SECRETS } from './framework-env';
|
|
13
18
|
import type { Finding } from './output';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The one feature layout, made a build error: a primitive module `actions.ts` (or `live`,
|
|
2
|
+
// `queries`, `jobs`, `tasks`) beside the directory of the same name is refused. `x g` writes the
|
|
3
|
+
// directory form — `<slice>/actions/<name>.ts`, one primitive per file — and an app that also keeps
|
|
4
|
+
// `actions.ts` has two homes for one kind of thing, where `./actions` resolves to the file and never
|
|
5
|
+
// the directory. A component module beside its folder (`ui.tsx` + `ui/`) is not a primitive's home,
|
|
6
|
+
// and `x g resource` writes exactly that pair.
|
|
7
|
+
|
|
8
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
9
|
+
import type { SourceFile } from './app-boundaries';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
|
|
12
|
+
/** The directories `x g` writes primitives into — the only names the refusal reads. */
|
|
13
|
+
export const PRIMITIVE_DIRS: ReadonlySet<string> = new Set([
|
|
14
|
+
'actions',
|
|
15
|
+
'live',
|
|
16
|
+
'queries',
|
|
17
|
+
'jobs',
|
|
18
|
+
'tasks',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const MODULE = /^(?<stem>.+)\.(?:ts|tsx)$/;
|
|
22
|
+
|
|
23
|
+
/** Every primitive module `X.ts` among `files` beside a directory `X/` holding another of them. */
|
|
24
|
+
export function siblingModuleFindings(files: readonly SourceFile[]): readonly Finding[] {
|
|
25
|
+
const directories = new Set<string>();
|
|
26
|
+
for (const { path } of files) {
|
|
27
|
+
const parts = path.split('/');
|
|
28
|
+
for (let depth = 1; depth < parts.length; depth += 1) {
|
|
29
|
+
directories.add(parts.slice(0, depth).join('/'));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return files.flatMap(({ path }): Finding[] => {
|
|
33
|
+
const stem = MODULE.exec(path)?.groups?.['stem'];
|
|
34
|
+
const name = stem?.split('/').at(-1) ?? '';
|
|
35
|
+
if (stem === undefined || !PRIMITIVE_DIRS.has(name) || !directories.has(stem)) return [];
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
code: 'X_LAYOUT_SIBLING_MODULE',
|
|
39
|
+
cause: `${path} sits beside the directory ${stem}/ — \`./${name}\` resolves to the file, and the directory form is the one layout`,
|
|
40
|
+
fix: `x verify --only boundaries --json # move each declaration in ${path} to ${stem}/<its-export-in-kebab-case>.ts, then delete ${path}`,
|
|
41
|
+
docs: ERROR_DOCS_URL,
|
|
42
|
+
at: path,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/app-load.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Bun ships no `Bun.*` path API: `join`/`resolve` reach the files a scan imports.
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
8
|
import { isAction, isMutator, listActions, registerActions } from '@ultimat3/action';
|
|
9
|
+
import { APP_CONFIG_FILE } from '@ultimat3/core';
|
|
9
10
|
import { describeEntities, registeredEntities } from '@ultimat3/entity';
|
|
10
11
|
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
11
12
|
import { appLocaleSet, UNDECLARED_LOCALES } from '@ultimat3/i18n/app-catalogs';
|
|
@@ -35,7 +36,7 @@ import {
|
|
|
35
36
|
trackModule,
|
|
36
37
|
trackStylesheets,
|
|
37
38
|
} from './app-reload-graph';
|
|
38
|
-
import { API_INDEX
|
|
39
|
+
import { API_INDEX } from './app-root';
|
|
39
40
|
import { collectDeclaredCodes } from './error-contract';
|
|
40
41
|
import type { Finding } from './output';
|
|
41
42
|
import { findingFrom } from './output';
|
package/src/app-root.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// Locating the app. `app.config.ts` is the one config file, so it is also the one root marker —
|
|
2
2
|
// commands that need an app resolve it here and nowhere else, and the failure names the fix.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { APP_CONFIG_FILE, appDirOf } from '@ultimat3/core';
|
|
6
6
|
import { BunVersionError, NotInAppError } from './errors';
|
|
7
7
|
|
|
8
|
-
export const APP_CONFIG_FILE = 'app.config.ts';
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* The one file `defineApi` is called from in a scaffolded app — here, beside the config file, so
|
|
12
10
|
* the module scan can import it first without pulling the generators into the serve graph.
|
|
@@ -46,18 +44,12 @@ export interface AppRoot {
|
|
|
46
44
|
readonly manifestPath: string;
|
|
47
45
|
}
|
|
48
46
|
|
|
49
|
-
/** Walk up from `from` looking for `app.config.ts
|
|
47
|
+
/** Walk up from `from` looking for `app.config.ts` (core's `appDirOf`). Undefined outside an app. */
|
|
50
48
|
export function findAppRoot(from: string): AppRoot | undefined {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return { dir, configPath, manifestPath: join(dir, MANIFEST_FILE) };
|
|
56
|
-
}
|
|
57
|
-
const parent = dirname(dir);
|
|
58
|
-
if (parent === dir) return undefined;
|
|
59
|
-
dir = parent;
|
|
60
|
-
}
|
|
49
|
+
const dir = appDirOf(from);
|
|
50
|
+
return dir === undefined
|
|
51
|
+
? undefined
|
|
52
|
+
: { dir, configPath: join(dir, APP_CONFIG_FILE), manifestPath: join(dir, MANIFEST_FILE) };
|
|
61
53
|
}
|
|
62
54
|
|
|
63
55
|
export function requireAppRoot(command: string, from: string): AppRoot {
|
package/src/boundary-findings.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { SourceFile } from './app-boundaries';
|
|
7
7
|
import { appImportGraph, checkImportRules, readAppSources } from './app-boundaries';
|
|
8
|
+
import { siblingModuleFindings } from './app-layout';
|
|
8
9
|
import { appTransportFindings } from './app-transport';
|
|
9
10
|
import { planBoundaryCuts } from './boundary-cuts';
|
|
10
11
|
import type { Finding } from './output';
|
|
@@ -44,5 +45,6 @@ export async function appBoundaryFindings(
|
|
|
44
45
|
return [
|
|
45
46
|
...withCutEdits(checkImportRules(files), appImportGraph(files)),
|
|
46
47
|
...(await appTransportFindings(root, files)),
|
|
48
|
+
...siblingModuleFindings(files),
|
|
47
49
|
];
|
|
48
50
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// `x clean`'s declaration, apart from its body: the parser, `x help` and the `errors` step read it
|
|
2
|
+
// without loading `cmd-clean.ts`, which `registry.ts` imports only when the command runs.
|
|
3
|
+
|
|
4
|
+
import type { CommandSpec } from './parse';
|
|
5
|
+
|
|
6
|
+
export const cleanSpec: CommandSpec = {
|
|
7
|
+
name: 'clean',
|
|
8
|
+
summary:
|
|
9
|
+
'remove what test runs leave behind: migrated test templates, .x/cache, stray .x folders, stale probe databases',
|
|
10
|
+
usage: 'x clean [--dry-run] [--json]',
|
|
11
|
+
requiresApp: true,
|
|
12
|
+
flags: [{ name: 'dry-run', type: 'boolean', summary: 'name what would go, remove nothing' }],
|
|
13
|
+
};
|
package/src/cmd-clean.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `x clean` — every test leftover the framework knows of, cleared on demand (#738): the migrated
|
|
2
|
+
// test-database templates under `.x/test-db`, the whole `.x/cache`, the `.x` folders a process
|
|
3
|
+
// started in a source folder once wrote beside the code, and — with `TEST_DATABASE_URL` set — the
|
|
4
|
+
// probe databases whose run is over. The test runner sweeps the same set before every run, keeping
|
|
5
|
+
// the newest two of each cache (`test-housekeeping.ts`); this is the same sweep, keeping none.
|
|
6
|
+
// Never touched: `.x/pgdata` (the dev database), `.x/storage`, the build output.
|
|
7
|
+
|
|
8
|
+
import { requireAppRoot } from './app-root';
|
|
9
|
+
import { cleanSpec } from './cmd-clean-spec';
|
|
10
|
+
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { msg } from './messages';
|
|
12
|
+
import type { CommandResult } from './output';
|
|
13
|
+
import { flagBool } from './parse';
|
|
14
|
+
import { tidyTestState } from './test-housekeeping';
|
|
15
|
+
|
|
16
|
+
export const cleanCommand: CliCommand = {
|
|
17
|
+
spec: cleanSpec,
|
|
18
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
19
|
+
const root = requireAppRoot('clean', ctx.cwd).dir;
|
|
20
|
+
const dryRun = flagBool(ctx.args, 'dry-run');
|
|
21
|
+
const report = await tidyTestState({ root, mode: 'all', env: ctx.env, dryRun });
|
|
22
|
+
const count = report.paths.length + report.databases.length;
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
command: 'clean',
|
|
26
|
+
summary:
|
|
27
|
+
count === 0
|
|
28
|
+
? msg('cli.clean.nothing')
|
|
29
|
+
: msg(dryRun ? 'cli.clean.would' : 'cli.clean.removed', {
|
|
30
|
+
paths: report.paths.length,
|
|
31
|
+
databases: report.databases.length,
|
|
32
|
+
}),
|
|
33
|
+
data: { dryRun, paths: [...report.paths], databases: [...report.databases] },
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
package/src/cmd-env-spec.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// `x env`'s declaration, apart from its body: the parser, `x help` and the `errors` step
|
|
2
2
|
// read it without loading `cmd-env.ts`, which `registry.ts` imports only when the command runs.
|
|
3
3
|
|
|
4
|
+
import { APP_CONFIG_FILE } from '@ultimat3/core';
|
|
4
5
|
import { ENV_SCHEMA_EXPORT } from './app-env';
|
|
5
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
6
6
|
import type { CommandSpec } from './parse';
|
|
7
7
|
|
|
8
8
|
export const envSpec: CommandSpec = {
|
package/src/doctor-offline.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
// `cmd-doctor.ts`'s job is the probe rather than the loading — the `db-backfill.ts` split, one
|
|
4
4
|
// diagnostic over.
|
|
5
5
|
|
|
6
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
|
+
import { APP_CONFIG_FILE, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
7
|
import { describePages } from '@ultimat3/render';
|
|
8
8
|
import { loadApp } from './app-load';
|
|
9
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
10
9
|
import type { Finding } from './output';
|
|
11
10
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
12
11
|
import { quoteArg } from './shell-quote';
|
package/src/error-codes.ts
CHANGED
|
@@ -232,6 +232,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
232
232
|
'X_PACKAGE_TEST_ONLY_SHIPPED',
|
|
233
233
|
'X_ISLAND_MODULE_DUPLICATED',
|
|
234
234
|
'X_BACKFILL_SLICE_ENTITY',
|
|
235
|
+
'X_LAYOUT_SIBLING_MODULE',
|
|
235
236
|
] as const;
|
|
236
237
|
|
|
237
238
|
/**
|
|
@@ -420,6 +421,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
420
421
|
X_PACKAGE_TEST_ONLY_SHIPPED: 'a test-only module ships in the tarball',
|
|
421
422
|
X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
|
|
422
423
|
X_BACKFILL_SLICE_ENTITY: 'the slice holds no entity a generated backfill can sweep',
|
|
424
|
+
X_LAYOUT_SIBLING_MODULE: 'a primitive module beside its directory',
|
|
423
425
|
};
|
|
424
426
|
|
|
425
427
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/index.ts
CHANGED
|
@@ -162,6 +162,10 @@ export { ROBOTS_PATH, SITEMAP_PATH, siteSeo } from './site-seo';
|
|
|
162
162
|
export { eachSourceFile, isGenerated, isTest, SOURCE_GLOBS } from './source-files';
|
|
163
163
|
export type { SkippedRoute, StaticReport } from './static-report';
|
|
164
164
|
export { parseStaticReport } from './static-report';
|
|
165
|
+
export type { MigratedDatabaseOptions } from './test-database';
|
|
166
|
+
export { migratedDatabase, migratedTemplate } from './test-database';
|
|
167
|
+
export type { HousekeepingOptions, HousekeepingReport } from './test-housekeeping';
|
|
168
|
+
export { tidyTestState } from './test-housekeeping';
|
|
165
169
|
export { filesIn, testArgs } from './test-shards';
|
|
166
170
|
export { defaultWorkers } from './test-workers';
|
|
167
171
|
export { scanCodeDeclarations, scanCodeFixSites, scanCodes } from './ts-scan';
|
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
// why: Bun exposes no path-join primitive; Bun.file takes a path already joined.
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
9
|
+
import { APP_CONFIG_FILE, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
10
|
import type { Manifest } from '@ultimat3/manifest';
|
|
11
11
|
import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
12
12
|
import { OPENAPI_FILE, openApiStaleness } from './app-openapi';
|
|
13
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
14
13
|
import type { Finding } from './output';
|
|
15
14
|
import { findingFrom } from './output';
|
|
16
15
|
|
package/src/mcp-errors.ts
CHANGED
|
@@ -302,6 +302,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
302
302
|
'gh issue create --repo developerz-ai/ultimate --title "X_ISLAND_MODULE_DUPLICATED" # paste this error\'s --json as the body: the framework bundled one of its own modules twice',
|
|
303
303
|
X_BACKFILL_SLICE_ENTITY:
|
|
304
304
|
'x errors explain X_BACKFILL_SLICE_ENTITY --json # the refusal itself names the x g job line for this slice',
|
|
305
|
+
X_LAYOUT_SIBLING_MODULE:
|
|
306
|
+
'x verify --only boundaries --json # then move each declaration in X.ts to X/<name>.ts, one primitive per file',
|
|
305
307
|
};
|
|
306
308
|
|
|
307
309
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/messages.ts
CHANGED
|
@@ -341,6 +341,10 @@ const CATALOG = {
|
|
|
341
341
|
'coverage: this shard measured its own slice — x verify merge folds every shard and judges the floor',
|
|
342
342
|
'cli.verify.serial': 'serial',
|
|
343
343
|
'cli.verify.workers': '{workers} workers',
|
|
344
|
+
'cli.clean.nothing': 'nothing to clean: no test leftovers under this app',
|
|
345
|
+
'cli.clean.removed': 'removed {paths} path(s) and {databases} probe database(s)',
|
|
346
|
+
'cli.clean.would':
|
|
347
|
+
'would remove {paths} path(s) and {databases} probe database(s) — --dry-run removed nothing',
|
|
344
348
|
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
|
345
349
|
'cli.env.invalid': '{count} of {total} declared variable(s) missing or malformed',
|
|
346
350
|
'cli.env.wrote': 'wrote {path} — {count} declared variable(s)',
|
package/src/registry.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { affectedSpec } from './cmd-affected-spec';
|
|
|
10
10
|
import { authSpec } from './cmd-auth-spec';
|
|
11
11
|
import { buildSpec } from './cmd-build-spec';
|
|
12
12
|
import { ciSpec } from './cmd-ci-spec';
|
|
13
|
+
import { cleanSpec } from './cmd-clean-spec';
|
|
13
14
|
import { dbSpec } from './cmd-db-spec';
|
|
14
15
|
import { deploySpec } from './cmd-deploy-spec';
|
|
15
16
|
import { devSpec } from './cmd-dev-spec';
|
|
@@ -89,6 +90,7 @@ export const LAZY_COMMANDS: readonly LazyCommand[] = [
|
|
|
89
90
|
lazy(secretsSpec, async () => (await import('./cmd-secrets')).secretsCommand),
|
|
90
91
|
lazy(authSpec, async () => (await import('./cmd-auth')).authCommand),
|
|
91
92
|
lazy(vapidSpec, async () => (await import('./cmd-vapid')).vapidCommand),
|
|
93
|
+
lazy(cleanSpec, async () => (await import('./cmd-clean')).cleanCommand),
|
|
92
94
|
lazy(manifestSpec, async () => (await import('./cmd-manifest')).manifestCommand),
|
|
93
95
|
lazy(routesSpec, async () => (await import('./cmd-routes')).routesCommand),
|
|
94
96
|
lazy(actionsSpec, async () => (await import('./cmd-registries')).actionsCommand),
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Single responsibility: the app's migrated test database — a fresh embedded Postgres holding the
|
|
2
|
+
// framework schema (`x_jobs`, `x_users`, …) and every app migration, exactly what `x db migrate`
|
|
3
|
+
// and `x dev` apply, so a test exercises the schema production runs and never a hand copy of it.
|
|
4
|
+
//
|
|
5
|
+
// Booted from a cached TEMPLATE: the migrated data directory, dumped once per (PGlite, framework
|
|
6
|
+
// schema, migrations) and kept under the APP ROOT's `.x/test-db` (`test-db-template.ts`). A fresh
|
|
7
|
+
// in-memory PGlite runs initdb (~4 s) and then every migration in every test file; restoring the
|
|
8
|
+
// dump is ~0.5 s. A changed migration is a different key, so a stale template is never loaded, and
|
|
9
|
+
// writing the new one evicts the old ones (#738). An app's hand-rolled copy of this kept them all.
|
|
10
|
+
//
|
|
11
|
+
// Per-test data hygiene is `reusableDatabase` (`@ultimat3/testing`): one database per test worker,
|
|
12
|
+
// its data put back to this template's before every file. The wiki's Testing page is the recipe.
|
|
13
|
+
|
|
14
|
+
import { appDirOf, assert } from '@ultimat3/core';
|
|
15
|
+
import {
|
|
16
|
+
migrate,
|
|
17
|
+
PGLITE_PACKAGE,
|
|
18
|
+
type PgliteClient,
|
|
19
|
+
type PgliteModule,
|
|
20
|
+
pgliteClient,
|
|
21
|
+
pgliteVersion,
|
|
22
|
+
raw,
|
|
23
|
+
} from '@ultimat3/db';
|
|
24
|
+
import { applyFrameworkSchema, FRAMEWORK_SCHEMA } from './framework-schema';
|
|
25
|
+
import { readMigrations } from './migrations';
|
|
26
|
+
import { readTemplate, templateKey, templatePath, writeTemplate } from './test-db-template';
|
|
27
|
+
|
|
28
|
+
export interface MigratedDatabaseOptions {
|
|
29
|
+
/** The app root, or any folder inside the app: the template lives under the root's `.x/`. */
|
|
30
|
+
readonly root: string;
|
|
31
|
+
/** A directory to put the database ON DISK instead of in memory (an empty one), for a test whose second process opens it. */
|
|
32
|
+
readonly dataDir?: string | undefined;
|
|
33
|
+
/** Extensions a migration creates (`citext`): PGlite links one only when told at boot. */
|
|
34
|
+
readonly extensions?: readonly string[] | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DumpablePglite {
|
|
38
|
+
dumpDataDir(compression?: 'none'): Promise<Blob>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type PgliteClass = PgliteModule['PGlite'];
|
|
42
|
+
|
|
43
|
+
/** Held in a variable, as `@ultimat3/db` holds it: a literal would make `tsc` resolve an optional peer. */
|
|
44
|
+
const importPglite = async (): Promise<PgliteClass> =>
|
|
45
|
+
((await import(PGLITE_PACKAGE)) as unknown as PgliteModule).PGlite;
|
|
46
|
+
|
|
47
|
+
/** One template per key for the life of the process: the second file in a worker reads no disk. */
|
|
48
|
+
const held = new Map<string, Blob>();
|
|
49
|
+
|
|
50
|
+
/** Bumped when how a template is BUILT changes, so a template built the old way is rebuilt. */
|
|
51
|
+
const TEMPLATE_RECIPE = 'framework-schema+migrations+checkpoint:1';
|
|
52
|
+
|
|
53
|
+
async function buildTemplate(
|
|
54
|
+
PGlite: PgliteClass,
|
|
55
|
+
root: string,
|
|
56
|
+
extensions: readonly string[] | undefined,
|
|
57
|
+
): Promise<Blob> {
|
|
58
|
+
// Built through the framework's own loader (its parsers, its extension linking), with the
|
|
59
|
+
// instance captured so its data directory can be dumped once the schema is in.
|
|
60
|
+
let driver: DumpablePglite | undefined;
|
|
61
|
+
const Capturing = class extends (PGlite as unknown as new (...args: unknown[]) => object) {
|
|
62
|
+
constructor(...args: unknown[]) {
|
|
63
|
+
super(...args);
|
|
64
|
+
driver = this as unknown as DumpablePglite;
|
|
65
|
+
}
|
|
66
|
+
} as unknown as PgliteClass;
|
|
67
|
+
const client = pgliteClient({
|
|
68
|
+
load: async () => ({ PGlite: Capturing }),
|
|
69
|
+
...(extensions === undefined ? {} : { extensions }),
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await applyFrameworkSchema((statement) => client.execute(raw(statement)));
|
|
73
|
+
await migrate({ migrations: await readMigrations(root), client, lock: false });
|
|
74
|
+
// Without a checkpoint every boot from the template replays the migrations' WAL as crash
|
|
75
|
+
// recovery (~130 ms of each ~600 ms boot, measured on notificado.co).
|
|
76
|
+
await client.execute(raw('CHECKPOINT'));
|
|
77
|
+
const captured = driver as DumpablePglite | undefined;
|
|
78
|
+
assert(
|
|
79
|
+
captured !== undefined,
|
|
80
|
+
'the migrated test template ran its schema, and no PGlite instance was constructed to dump',
|
|
81
|
+
'report it: pgliteClient booted a driver this module did not hand it',
|
|
82
|
+
);
|
|
83
|
+
return await captured.dumpDataDir('none');
|
|
84
|
+
} finally {
|
|
85
|
+
await client.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The migrated template for the app `root` is in: from memory, from `.x/test-db`, or built now. */
|
|
90
|
+
export async function migratedTemplate(options: MigratedDatabaseOptions): Promise<Blob> {
|
|
91
|
+
const PGlite = await importPglite();
|
|
92
|
+
// The migrations are read from the app ROOT whichever folder the caller named.
|
|
93
|
+
const root = appDirOf(options.root) ?? options.root;
|
|
94
|
+
const key = templateKey([
|
|
95
|
+
(await pgliteVersion()) ?? 'unknown',
|
|
96
|
+
JSON.stringify(FRAMEWORK_SCHEMA),
|
|
97
|
+
JSON.stringify(await readMigrations(root)),
|
|
98
|
+
JSON.stringify(options.extensions ?? []),
|
|
99
|
+
TEMPLATE_RECIPE,
|
|
100
|
+
]);
|
|
101
|
+
const memo = held.get(key);
|
|
102
|
+
if (memo !== undefined) return memo;
|
|
103
|
+
const path = templatePath(root, key);
|
|
104
|
+
const template =
|
|
105
|
+
(await readTemplate(path)) ??
|
|
106
|
+
(await (async () => {
|
|
107
|
+
const built = await buildTemplate(PGlite, root, options.extensions);
|
|
108
|
+
await writeTemplate(path, built);
|
|
109
|
+
return built;
|
|
110
|
+
})());
|
|
111
|
+
held.set(key, template);
|
|
112
|
+
return template;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A fresh database with the framework schema and every migration applied, booted from the cached
|
|
117
|
+
* template. The caller owns it: `close()` when done. It is not installed as `db()` — the recipe
|
|
118
|
+
* (`reusableDatabase` + `setDbClient`) does that.
|
|
119
|
+
*/
|
|
120
|
+
export async function migratedDatabase(options: MigratedDatabaseOptions): Promise<PgliteClient> {
|
|
121
|
+
const PGlite = await importPglite();
|
|
122
|
+
const template = await migratedTemplate(options);
|
|
123
|
+
const FromTemplate = class extends (PGlite as unknown as new (...args: unknown[]) => object) {
|
|
124
|
+
constructor(dataDir?: unknown, init?: Record<string, unknown>) {
|
|
125
|
+
super(dataDir, { ...init, loadDataDir: template });
|
|
126
|
+
}
|
|
127
|
+
} as unknown as PgliteClass;
|
|
128
|
+
return pgliteClient({
|
|
129
|
+
...(options.dataDir === undefined ? {} : { dataDir: options.dataDir }),
|
|
130
|
+
...(options.extensions === undefined ? {} : { extensions: options.extensions }),
|
|
131
|
+
load: async () => ({ PGlite: FromTemplate }),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Single responsibility: where the app's migrated test-database templates live and how one is read
|
|
2
|
+
// and written. A template is a PGlite data directory dumped after the framework schema and every
|
|
3
|
+
// migration ran (`test-database.ts` builds it), keyed by everything that went into it, under the
|
|
4
|
+
// APP ROOT's `.x/test-db` — never the cwd's.
|
|
5
|
+
//
|
|
6
|
+
// Writing a new key evicts the old ones (#738): nothing did, and an app's `.x/test-db` held one
|
|
7
|
+
// ~60 MB file per migration state it ever had — 72, 4.2 GB. The newest previous one stays, since a
|
|
8
|
+
// second run still on the old migrations may be reading it. No lock is needed: a template is read
|
|
9
|
+
// in ONE read, a missing one is a miss, and a write is a temp name renamed into place.
|
|
10
|
+
|
|
11
|
+
// why: Bun has no mkdir, rename or remove.
|
|
12
|
+
import { mkdir, rename, rm } from 'node:fs/promises';
|
|
13
|
+
// why: Bun ships no path joiner.
|
|
14
|
+
import { basename, dirname } from 'node:path';
|
|
15
|
+
import { appStatePath, evictCacheFiles } from '@ultimat3/core';
|
|
16
|
+
|
|
17
|
+
/** `.x/<this>` under the app root. */
|
|
18
|
+
export const TEST_DB_DIR = 'test-db';
|
|
19
|
+
|
|
20
|
+
const TEMPLATE = /^pglite-[0-9a-f]{16}\.tar$/;
|
|
21
|
+
const PARTIAL = /^pglite-[0-9a-f]{16}\.tar\..+\.partial$/;
|
|
22
|
+
|
|
23
|
+
/** Sixteen hex characters over every input, each length-prefixed so `ab|c` and `a|bc` differ. */
|
|
24
|
+
export function templateKey(parts: readonly string[]): string {
|
|
25
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
26
|
+
for (const part of parts) hasher.update(`${String(part.length)}:${part}`);
|
|
27
|
+
return hasher.digest('hex').slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `<app root>/.x/test-db/pglite-<key>.tar`, for a process started anywhere inside the app. */
|
|
31
|
+
export const templatePath = (from: string, key: string): string =>
|
|
32
|
+
appStatePath(from, TEST_DB_DIR, `pglite-${key}.tar`);
|
|
33
|
+
|
|
34
|
+
/** The whole template in memory, or `undefined` for one that is absent or vanished mid-read. */
|
|
35
|
+
export async function readTemplate(path: string): Promise<Blob | undefined> {
|
|
36
|
+
try {
|
|
37
|
+
return new Blob([await Bun.file(path).arrayBuffer()]);
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Atomic: several workers may build the same template at once, each renames its own temp file into
|
|
45
|
+
* place, and a reader sees a whole template or none. Then the eviction. Best effort throughout: a
|
|
46
|
+
* template that cannot be written costs the next file a rebuild, which is what it cost before.
|
|
47
|
+
*/
|
|
48
|
+
export async function writeTemplate(path: string, template: Blob): Promise<void> {
|
|
49
|
+
const partial = `${path}.${String(process.pid)}.${crypto.randomUUID()}.partial`;
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(dirname(path), { recursive: true });
|
|
52
|
+
await Bun.write(partial, template);
|
|
53
|
+
await rename(partial, path);
|
|
54
|
+
} catch {
|
|
55
|
+
await rm(partial, { force: true }).catch(() => undefined);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await evictCacheFiles({
|
|
59
|
+
dir: dirname(path),
|
|
60
|
+
current: basename(path),
|
|
61
|
+
matches: (name) => TEMPLATE.test(name),
|
|
62
|
+
temporary: (name) => PARTIAL.test(name),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Single responsibility: what a test run leaves behind, swept — the framework's DatabaseCleaner and
|
|
2
|
+
// `tmp:clear` in one place, so an app never accumulates it (#738: one app held 72 migrated
|
|
3
|
+
// templates, 4.2 GB, and `.x/cache` folders beside its source). Two callers, one rule:
|
|
4
|
+
//
|
|
5
|
+
// - the test runner, before every run (`'auto'`): the two newest templates and PGlite snapshots
|
|
6
|
+
// stay — the current state and the one a second checkout or an older branch may be reading;
|
|
7
|
+
// - `x clean` (`'all'`): every template and the whole `.x/cache`, on demand, `--dry-run` to look.
|
|
8
|
+
//
|
|
9
|
+
// Both remove the `.x` debris a process started in a source folder used to write beside the code,
|
|
10
|
+
// and with `TEST_DATABASE_URL` set, every probe database whose run is provably over. What a
|
|
11
|
+
// developer works in is never touched: `.x/pgdata` (the dev database), `.x/storage`, the build.
|
|
12
|
+
|
|
13
|
+
// why: Bun has no readdir, rmdir or recursive remove.
|
|
14
|
+
import { readdir, rm, rmdir } from 'node:fs/promises';
|
|
15
|
+
// why: Bun ships no path joiner.
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import type { PgExecutor, ProbeDatabaseSweepOptions } from '@ultimat3/core';
|
|
18
|
+
import {
|
|
19
|
+
APP_CONFIG_FILE,
|
|
20
|
+
APP_STATE_DIR,
|
|
21
|
+
appDirOf,
|
|
22
|
+
evictCacheFiles,
|
|
23
|
+
sweepStaleProbeDatabases,
|
|
24
|
+
} from '@ultimat3/core';
|
|
25
|
+
import { dbExecutor, postgresClient } from '@ultimat3/db';
|
|
26
|
+
import { TEST_DB_DIR } from './test-db-template';
|
|
27
|
+
|
|
28
|
+
export type HousekeepingMode = 'auto' | 'all';
|
|
29
|
+
|
|
30
|
+
export interface ProbeServer {
|
|
31
|
+
readonly executor: PgExecutor;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HousekeepingOptions extends ProbeDatabaseSweepOptions {
|
|
36
|
+
/** The app root, or any folder in it. */
|
|
37
|
+
readonly root: string;
|
|
38
|
+
readonly mode: HousekeepingMode;
|
|
39
|
+
/** Name what would go and remove nothing. */
|
|
40
|
+
readonly dryRun?: boolean;
|
|
41
|
+
/** Where `TEST_DATABASE_URL` is read; without it no database is swept and nothing connects. */
|
|
42
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
43
|
+
/** Seam: how the probe server is reached. Defaults to a one-connection pool. */
|
|
44
|
+
readonly connect?: (url: string) => Promise<ProbeServer>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HousekeepingReport {
|
|
48
|
+
/** Every file and directory removed (or, dry, that would be). */
|
|
49
|
+
readonly paths: readonly string[];
|
|
50
|
+
/** Every probe database dropped (or, dry, that would be). */
|
|
51
|
+
readonly databases: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Entries of each cache an `'auto'` sweep keeps: the current state and the newest previous one. */
|
|
55
|
+
const AUTO_KEEP = 2;
|
|
56
|
+
|
|
57
|
+
const TEMPLATE = /^pglite-[0-9a-f]{16}\.tar$/;
|
|
58
|
+
const TEMPLATE_PARTIAL = /^pglite-[0-9a-f]{16}\.tar\..+\.partial$/;
|
|
59
|
+
const SNAPSHOT = /^pglite-.+\.snapshot$/;
|
|
60
|
+
const SNAPSHOT_TEMP = /^pglite-.+\.snapshot\..+\.tmp$/;
|
|
61
|
+
|
|
62
|
+
/** Folders a walk for stray `.x` never enters: dependencies, history, and the app's own state. */
|
|
63
|
+
const NEVER_WALKED = new Set(['node_modules', '.git', APP_STATE_DIR]);
|
|
64
|
+
|
|
65
|
+
/** The bound on a probe server that does not answer: housekeeping never holds a run up. */
|
|
66
|
+
const CONNECT_BUDGET_MS = 5_000;
|
|
67
|
+
|
|
68
|
+
const defaultConnect = async (url: string): Promise<ProbeServer> => {
|
|
69
|
+
const client = postgresClient({ url, role: 'web', profile: { max: 1 } });
|
|
70
|
+
return { executor: dbExecutor(() => client), close: () => client.close() };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `.x` folders below `root` that are not an app's own: the cache a process started in a source
|
|
75
|
+
* folder wrote beside the code. A folder holding its own `app.config.ts` is another app, whose
|
|
76
|
+
* `.x` is its own, and is not walked into.
|
|
77
|
+
*/
|
|
78
|
+
async function strayStateDirs(root: string): Promise<readonly string[]> {
|
|
79
|
+
const found: string[] = [];
|
|
80
|
+
const walk = async (dir: string, top: boolean): Promise<void> => {
|
|
81
|
+
let entries: import('node:fs').Dirent[];
|
|
82
|
+
try {
|
|
83
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!top && entries.some((entry) => entry.isFile() && entry.name === APP_CONFIG_FILE)) return;
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!entry.isDirectory()) continue;
|
|
90
|
+
if (entry.name === APP_STATE_DIR && !top) found.push(join(dir, entry.name));
|
|
91
|
+
if (NEVER_WALKED.has(entry.name)) continue;
|
|
92
|
+
await walk(join(dir, entry.name), false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
await walk(root, true);
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The stray `.x`'s `cache` goes — the only thing the bug wrote — and the `.x` itself once empty. */
|
|
100
|
+
async function sweepStray(dir: string, dryRun: boolean): Promise<readonly string[]> {
|
|
101
|
+
const entries = await readdir(dir).catch(() => [] as string[]);
|
|
102
|
+
const onlyCache = entries.every((name) => name === 'cache');
|
|
103
|
+
const removed = onlyCache ? [dir] : entries.includes('cache') ? [join(dir, 'cache')] : [];
|
|
104
|
+
if (!dryRun) {
|
|
105
|
+
for (const path of removed) await rm(path, { recursive: true, force: true });
|
|
106
|
+
if (!onlyCache) await rmdir(dir).catch(() => undefined);
|
|
107
|
+
}
|
|
108
|
+
return removed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function sweepDatabases(options: HousekeepingOptions): Promise<readonly string[]> {
|
|
112
|
+
const url = options.env['TEST_DATABASE_URL'];
|
|
113
|
+
if (url === undefined || url.trim().length === 0) return [];
|
|
114
|
+
let server: ProbeServer | undefined;
|
|
115
|
+
try {
|
|
116
|
+
server = await (options.connect ?? defaultConnect)(url);
|
|
117
|
+
const connected = server;
|
|
118
|
+
const swept = sweepStaleProbeDatabases(connected.executor, {
|
|
119
|
+
...(options.isAlive === undefined ? {} : { isAlive: options.isAlive }),
|
|
120
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
121
|
+
dryRun: options.dryRun === true,
|
|
122
|
+
});
|
|
123
|
+
const late = Bun.sleep(CONNECT_BUDGET_MS).then(() => [] as readonly string[]);
|
|
124
|
+
return await Promise.race([swept, late]);
|
|
125
|
+
} catch {
|
|
126
|
+
// Best effort: an unreachable server costs the sweep its databases, never the test run.
|
|
127
|
+
return [];
|
|
128
|
+
} finally {
|
|
129
|
+
await server?.close().catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Sweep what tests leave behind under the app containing `options.root`. */
|
|
134
|
+
export async function tidyTestState(options: HousekeepingOptions): Promise<HousekeepingReport> {
|
|
135
|
+
const app = appDirOf(options.root);
|
|
136
|
+
const root = app ?? options.root;
|
|
137
|
+
const state = join(root, APP_STATE_DIR);
|
|
138
|
+
const dryRun = options.dryRun === true;
|
|
139
|
+
const keep = options.mode === 'all' ? 0 : AUTO_KEEP;
|
|
140
|
+
const paths: string[] = [
|
|
141
|
+
...(await evictCacheFiles({
|
|
142
|
+
dir: join(state, TEST_DB_DIR),
|
|
143
|
+
current: undefined,
|
|
144
|
+
keepPrevious: keep,
|
|
145
|
+
matches: (name) => TEMPLATE.test(name),
|
|
146
|
+
temporary: (name) => TEMPLATE_PARTIAL.test(name),
|
|
147
|
+
dryRun,
|
|
148
|
+
})),
|
|
149
|
+
];
|
|
150
|
+
if (options.mode === 'all') {
|
|
151
|
+
const cache = join(state, 'cache');
|
|
152
|
+
if ((await readdir(cache).catch(() => undefined)) !== undefined) {
|
|
153
|
+
paths.push(cache);
|
|
154
|
+
if (!dryRun) await rm(cache, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
paths.push(
|
|
158
|
+
...(await evictCacheFiles({
|
|
159
|
+
dir: join(state, 'cache'),
|
|
160
|
+
current: undefined,
|
|
161
|
+
keepPrevious: keep,
|
|
162
|
+
matches: (name) => SNAPSHOT.test(name),
|
|
163
|
+
temporary: (name) => SNAPSHOT_TEMP.test(name),
|
|
164
|
+
dryRun,
|
|
165
|
+
})),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
// Only inside an app: outside one (a package's own folder) a `.x` below is that folder's state.
|
|
169
|
+
if (app !== undefined) {
|
|
170
|
+
for (const stray of await strayStateDirs(root))
|
|
171
|
+
paths.push(...(await sweepStray(stray, dryRun)));
|
|
172
|
+
}
|
|
173
|
+
return { paths, databases: await sweepDatabases(options) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Roots this process already swept: `x verify` runs several test steps, and one sweep serves all. */
|
|
177
|
+
const swept = new Set<string>();
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The test runner's call, before it spawns `bun test` (`x test`, and every test step of `x verify`):
|
|
181
|
+
* the `'auto'` sweep, once per app per process. Never a reason for a run to fail — housekeeping
|
|
182
|
+
* that throws is logged nowhere and costs nothing but the leftovers it did not remove.
|
|
183
|
+
*/
|
|
184
|
+
export async function tidyBeforeTestRun(
|
|
185
|
+
root: string,
|
|
186
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
187
|
+
tidy: (options: HousekeepingOptions) => Promise<HousekeepingReport> = tidyTestState,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const key = appDirOf(root) ?? root;
|
|
190
|
+
if (swept.has(key)) return;
|
|
191
|
+
swept.add(key);
|
|
192
|
+
try {
|
|
193
|
+
await tidy({ root: key, mode: 'auto', env });
|
|
194
|
+
} catch {
|
|
195
|
+
// Best effort, by design: see above.
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/test-shards.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { testEnvOverrides } from './test-dotenv';
|
|
|
56
56
|
export const TEST_TIMINGS_FILE = '.x/test-timings.json';
|
|
57
57
|
|
|
58
58
|
import { runBatches, testBatches } from './test-batches';
|
|
59
|
+
import { tidyBeforeTestRun } from './test-housekeeping';
|
|
59
60
|
import { testPasses } from './test-passes';
|
|
60
61
|
import type { TestFile } from './test-select';
|
|
61
62
|
import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
|
|
@@ -265,6 +266,8 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
265
266
|
workers: options.workers,
|
|
266
267
|
...(options.type === undefined ? {} : { type: options.type }),
|
|
267
268
|
});
|
|
269
|
+
// What earlier runs left behind, swept before this one adds to it (#738).
|
|
270
|
+
await tidyBeforeTestRun(options.root, options.env ?? Bun.env);
|
|
268
271
|
const started = performance.now();
|
|
269
272
|
const steps: StepResult[] = [];
|
|
270
273
|
const spent: JsonValue[] = [];
|
package/src/verify-checks.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import { APP_CONFIG_FILE, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
8
|
import type { Manifest } from '@ultimat3/manifest';
|
|
9
9
|
import { AGENTS_MD_FILENAME, MANIFEST_FILENAME, verifyContract } from '@ultimat3/manifest';
|
|
10
10
|
import type { MetaIssue } from '@ultimat3/seo';
|
|
@@ -14,7 +14,6 @@ import { readAppSources } from './app-boundaries';
|
|
|
14
14
|
import { envExampleFindings } from './app-env';
|
|
15
15
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
16
16
|
import { policyFindings } from './app-permissions';
|
|
17
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
18
17
|
import { asyncPageFindings } from './async-pages';
|
|
19
18
|
import { appBoundaryFindings } from './boundary-findings';
|
|
20
19
|
import { checkBudgets, readBuildStats } from './budgets';
|
package/src/verify-merge.ts
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
// why: Bun ships no path-join primitive.
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { ERROR_DOCS_URL, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
20
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
19
|
+
import { APP_CONFIG_FILE, ERROR_DOCS_URL, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
21
20
|
import { judgeCoverage } from './coverage-floor';
|
|
22
21
|
import type { CoverageMap } from './coverage-lcov';
|
|
23
22
|
import { decodeCoverage, mergeCoverage } from './coverage-lcov';
|
package/src/verify-test-run.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Finding } from './output';
|
|
|
10
10
|
import { runBatches, testBatches } from './test-batches';
|
|
11
11
|
import { countsOf } from './test-counts';
|
|
12
12
|
import { testEnvOverrides } from './test-dotenv';
|
|
13
|
+
import { tidyBeforeTestRun } from './test-housekeeping';
|
|
13
14
|
import type { TestFile } from './test-select';
|
|
14
15
|
import { failureOf, TEST_TIMINGS_FILE, testArgs } from './test-shards';
|
|
15
16
|
import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
|
|
@@ -63,6 +64,8 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
|
|
|
63
64
|
workers,
|
|
64
65
|
options.isolate === true ? BATCH_FILES_PER_WORKER : SHARED_BATCH_FILES_PER_WORKER,
|
|
65
66
|
);
|
|
67
|
+
// What earlier runs left behind, swept before this one adds to it (#738).
|
|
68
|
+
await tidyBeforeTestRun(options.root, options.env ?? Bun.env);
|
|
66
69
|
const result = await runBatches({
|
|
67
70
|
runner: options.runner,
|
|
68
71
|
batches,
|
package/src/verify-tests.ts
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
// and `join` builds the host-separator path to its config file.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { APP_CONFIG_FILE } from '@ultimat3/core';
|
|
13
14
|
import type { TestType } from '@ultimat3/testing';
|
|
14
15
|
import { TEST_TYPES } from '@ultimat3/testing';
|
|
15
16
|
import { checkEvalBaselines, checkEvalCoverage, checkEvalRecording } from './app-evals';
|
|
16
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
17
17
|
import { judgeCoverage } from './coverage-floor';
|
|
18
18
|
import { msg } from './messages';
|
|
19
19
|
import { countsOf } from './test-counts';
|