@platforma-sdk/block-tools 2.10.16 → 2.10.18
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/dist/cli.js +13 -13
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +404 -373
- package/dist/cli.mjs.map +1 -1
- package/dist/structure/engine/api.d.ts +1 -1
- package/dist/structure/engine/builders.d.ts +9 -9
- package/dist/structure/engine/content-rules.d.ts +17 -0
- package/dist/structure/engine/registry-client.d.ts +30 -2
- package/dist/structure/engine/runner.d.ts +6 -0
- package/dist/structure/rules/root-pnpm-workspace.d.ts +13 -0
- package/package.json +5 -5
- package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -2
- package/src/structure/__tests__/tsconfig-idempotency.test.ts +94 -0
- package/src/structure/cli/run-structure.ts +12 -2
- package/src/structure/engine/__tests__/content-rules/pin-catalog-to-dependency-of.test.ts +65 -0
- package/src/structure/engine/__tests__/content-rules/when-inner.test.ts +30 -0
- package/src/structure/engine/__tests__/derived-pin-lookup.test.ts +54 -0
- package/src/structure/engine/api.ts +1 -0
- package/src/structure/engine/builders.ts +38 -23
- package/src/structure/engine/content-rules.ts +29 -0
- package/src/structure/engine/registry-client.ts +116 -2
- package/src/structure/engine/runner.ts +18 -2
- package/src/structure/init-block-constructor.ts +16 -3
- package/src/structure/rules/model-package-json.ts +35 -16
- package/src/structure/rules/model.ts +11 -35
- package/src/structure/rules/root-catalog-bump.ts +12 -1
- package/src/structure/rules/root-package-json.ts +7 -5
- package/src/structure/rules/root-pnpm-workspace.ts +24 -8
- package/src/structure/rules/ui-package-json.ts +34 -13
- package/src/structure/rules/ui.ts +10 -22
- package/src/structure/rules/workflow-package-json.ts +25 -11
- package/src/structure/rules/workflow.ts +21 -2
- package/src/structure/templates/static/model/tsconfig.node.json +7 -0
- package/src/structure/templates/static/ui/tsconfig.node.json +7 -0
|
@@ -51,5 +51,5 @@ export type RunContext = {
|
|
|
51
51
|
};
|
|
52
52
|
export type { ContentForm, ManagedBody, RunMode, Structure, TriggerFn };
|
|
53
53
|
export { defineStructure, scope, when, whenFilesExist, onUpdateDeps, onInitOrUpdate, fixed, managed, scaffold, seed, remove, rename, file, binaryFile, text, tpl, generate, blockVars, } from './builders';
|
|
54
|
-
export { ensureField, removeField, requireField, ensureFieldEntries, ensureScript, removeScript, ensureDep, ensureDevDep, ensurePeerDep, ensureOptionalDep, removeDep, ensureDeps, ensureDevDeps, ensurePeerDeps, ensureOptionalDeps, ensureWorkspaceScopeDeps, ensureWorkspaceScopeDevDeps, ensureWorkspaceScopePeerDeps, pruneKeysMatching, pruneKeysMatchingAt, enforceFieldOrder, enforceFieldOrderAt, enforceAlphabeticalOrder, transformAt, ensureWorkspaceModulePaths, ensureCatalogVersion, pinCatalogTo, ensureCatalogLatest, ensureCatalogAbsent, ensureGitignoreEntries, removeGitignoreEntries, withManagedBody, withManagedYaml, withManagedLines, } from './content-rules';
|
|
54
|
+
export { ensureField, removeField, requireField, ensureFieldEntries, ensureScript, removeScript, ensureDep, ensureDevDep, ensurePeerDep, ensureOptionalDep, removeDep, ensureDeps, ensureDevDeps, ensurePeerDeps, ensureOptionalDeps, ensureWorkspaceScopeDeps, ensureWorkspaceScopeDevDeps, ensureWorkspaceScopePeerDeps, pruneKeysMatching, pruneKeysMatchingAt, enforceFieldOrder, enforceFieldOrderAt, enforceAlphabeticalOrder, transformAt, ensureWorkspaceModulePaths, ensureCatalogVersion, pinCatalogTo, pinCatalogToDependencyOf, ensureCatalogLatest, ensureCatalogAbsent, ensureGitignoreEntries, removeGitignoreEntries, withManagedBody, withManagedYaml, withManagedLines, } from './content-rules';
|
|
55
55
|
export type { DepVersion } from './content-rules';
|
|
@@ -30,17 +30,17 @@ type InnerWhenHandler = (trigger: TriggerFn, body: () => void) => boolean;
|
|
|
30
30
|
* `content-rules.ts` at load time to avoid a circular import). */
|
|
31
31
|
export declare function registerInnerWhenHandler(h: InnerWhenHandler): void;
|
|
32
32
|
/**
|
|
33
|
-
* Conditionally run `body
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* synchronously.
|
|
33
|
+
* Conditionally run `body`, with an optional `elseBody`. One `when` symbol for
|
|
34
|
+
* both layers — `dispatchWhen` picks the timing:
|
|
35
|
+
* - inside `defineStructure(...)` → register a `WhenFrame`; its trigger fires
|
|
36
|
+
* later, per module, at runner time.
|
|
37
|
+
* - inside a `managed(...)` body → evaluate the trigger against the live block
|
|
38
|
+
* now and run/skip synchronously.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* `else` is just the negated trigger dispatched the same way, so exactly one
|
|
41
|
+
* branch applies (in either layer).
|
|
42
42
|
*/
|
|
43
|
-
export declare function when(trigger: TriggerFn, body: () => void): void;
|
|
43
|
+
export declare function when(trigger: TriggerFn, body: () => void, elseBody?: () => void): void;
|
|
44
44
|
/** Trigger factory: true when at least one file in the leaf's module
|
|
45
45
|
* matches `glob` (module-relative — see `TriggerContext.filesMatch`). Use
|
|
46
46
|
* to gate a rule on the presence of co-located files, e.g. add a vitest
|
|
@@ -35,6 +35,9 @@ export type WithManagedYamlOpts = WithManagedOpts & {
|
|
|
35
35
|
/** Sync accessor for prefetched latest versions. Required for
|
|
36
36
|
* `ensureCatalogLatest` to take effect. */
|
|
37
37
|
getLatestVersion?: (packageName: string) => string | undefined;
|
|
38
|
+
/** Sync accessor for prefetched derived catalog pins. Required for
|
|
39
|
+
* `pinCatalogToDependencyOf` to take effect. */
|
|
40
|
+
getDerivedDependencyPin?: (catalogEntry: string) => string | undefined;
|
|
38
41
|
};
|
|
39
42
|
/** Engine-internal: run `body` with `doc` as the active YAML state (for
|
|
40
43
|
* `pnpm-workspace.yaml`); returns the mutated document. Exported for tests. */
|
|
@@ -140,6 +143,20 @@ export declare function pinCatalogTo(name: string, version: string): void;
|
|
|
140
143
|
* and refreshes a present one, and on `init` it overwrites the
|
|
141
144
|
* placeholder with the real latest. */
|
|
142
145
|
export declare function ensureCatalogLatest(name: string): void;
|
|
146
|
+
/** Pin `catalog.<catalogEntry>` to the EXACT version that package `opts.of`
|
|
147
|
+
* declares for `catalogEntry` in its own manifest (read at npm latest, or at
|
|
148
|
+
* `opts.ofVersion` if given). The value is resolved by the runner's
|
|
149
|
+
* prefetched `getDerivedDependencyPin` accessor (the caller pre-resolves and
|
|
150
|
+
* validates — exact-version-only — at prefetch time; no network here).
|
|
151
|
+
* OVERWRITES like `pinCatalogTo` (not add-if-absent): a pre-existing loose
|
|
152
|
+
* entry (`vue: ^3.5.24`) is tightened to the exact derived version. No-op
|
|
153
|
+
* when no derived-pin lookup is available — default refresh/check pass none,
|
|
154
|
+
* leaving the entry exactly as the lockfile has it; the resolution leaves
|
|
155
|
+
* live in the `onInitOrUpdate` frame. */
|
|
156
|
+
export declare function pinCatalogToDependencyOf(catalogEntry: string, _opts: {
|
|
157
|
+
of: string;
|
|
158
|
+
ofVersion?: string;
|
|
159
|
+
}): void;
|
|
143
160
|
/** Remove `catalog.<name>` if present; no-op when absent. */
|
|
144
161
|
export declare function ensureCatalogAbsent(name: string): void;
|
|
145
162
|
/** Append each `entry` if not already present (comment-aware equality). */
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface RegistryClient {
|
|
2
2
|
getLatestVersion(packageName: string): Promise<string>;
|
|
3
|
+
/** Read the version `packageName@version` declares for `depName` in its
|
|
4
|
+
* published manifest. `version` may be "latest" (resolved via the
|
|
5
|
+
* dist-tags). Looks in `dependencies` first, then `peerDependencies`.
|
|
6
|
+
* Returns `undefined` when the dep is declared in neither section. */
|
|
7
|
+
getDeclaredDependency(packageName: string, version: string, depName: string): Promise<string | undefined>;
|
|
3
8
|
}
|
|
4
9
|
/** Parse the HTTP Retry-After header (RFC 9110): either a delay in
|
|
5
10
|
* seconds or an absolute HTTP-date. Returns the delay in ms, or null
|
|
@@ -7,8 +12,10 @@ export interface RegistryClient {
|
|
|
7
12
|
export declare function parseRetryAfter(header: string | null): number | null;
|
|
8
13
|
/** Live npm registry client. Hits the network on each call. */
|
|
9
14
|
export declare function createRealRegistryClient(): RegistryClient;
|
|
10
|
-
/** In-memory client backed by a fixed `name → version` map.
|
|
11
|
-
|
|
15
|
+
/** In-memory client backed by a fixed `name → version` map. The optional
|
|
16
|
+
* `manifests` map (`pkg@version` → declared deps) backs
|
|
17
|
+
* `getDeclaredDependency`; "latest" keys resolve via the `versions` map. */
|
|
18
|
+
export declare function createMockRegistryClient(versions: Record<string, string>, manifests?: Record<string, Record<string, string>>): RegistryClient;
|
|
12
19
|
/** Resolve a batch of `name → latest` entries in parallel. */
|
|
13
20
|
export declare function prefetchLatestVersions(client: RegistryClient, packageNames: readonly string[]): Promise<Map<string, string>>;
|
|
14
21
|
/** Build a sync lookup function (the shape `withManagedYaml` consumes)
|
|
@@ -32,3 +39,24 @@ export declare function matchesBumpPattern(name: string): boolean;
|
|
|
32
39
|
* (callers that require the network — `init` — let it propagate).
|
|
33
40
|
* `client` defaults to the live npm client; tests inject a mock. */
|
|
34
41
|
export declare function buildRegistryLookupForNames(names: readonly string[], client?: RegistryClient): Promise<(packageName: string) => string | undefined>;
|
|
42
|
+
/** A catalog entry whose version is DERIVED from the version another
|
|
43
|
+
* package declares for it. `entry` is the catalog key (== the dep name in
|
|
44
|
+
* the source manifest); `of` is the package whose manifest is read; the
|
|
45
|
+
* optional `ofVersion` pins which release of `of` to read (default: npm
|
|
46
|
+
* latest, the same release the catalog bump pins `of` to). Consumed by
|
|
47
|
+
* `pinCatalogToDependencyOf`. */
|
|
48
|
+
export type DerivedCatalogPin = {
|
|
49
|
+
entry: string;
|
|
50
|
+
of: string;
|
|
51
|
+
ofVersion?: string;
|
|
52
|
+
};
|
|
53
|
+
/** Prefetch every derived pin and build the sync lookup the runner passes
|
|
54
|
+
* to `pinCatalogToDependencyOf` (`entry → exact version`). Reads
|
|
55
|
+
* `pin.of`'s manifest (at `pin.ofVersion`, default npm latest) and the
|
|
56
|
+
* version it declares for `pin.entry`. Policy (b): THROWS at prefetch time
|
|
57
|
+
* if the source declares the dep as a non-exact (ranged) version or does
|
|
58
|
+
* not declare it at all — a derived pin must resolve to an exact version
|
|
59
|
+
* or it is a configuration error. Empty list → a lookup that resolves
|
|
60
|
+
* nothing (no network). A registry failure REJECTS. `client` defaults to
|
|
61
|
+
* the live npm client; tests inject a mock. */
|
|
62
|
+
export declare function buildDerivedPinLookupForPins(pins: readonly DerivedCatalogPin[], client?: RegistryClient): Promise<(entry: string) => string | undefined>;
|
|
@@ -32,6 +32,12 @@ export type RunOptions = {
|
|
|
32
32
|
* and passes the resulting sync map here; unit tests pass a mock.
|
|
33
33
|
* Absent → `ensureCatalogLatest` is a no-op (default refresh). */
|
|
34
34
|
registryLookup?: (packageName: string) => string | undefined;
|
|
35
|
+
/** Sync accessor for prefetched derived catalog pins
|
|
36
|
+
* (`catalogEntry → exact version`), consumed by `pinCatalogToDependencyOf`
|
|
37
|
+
* inside `onInitOrUpdate` managed bodies. The CLI / init constructor
|
|
38
|
+
* prefetches + validates these before the run (network). Absent →
|
|
39
|
+
* `pinCatalogToDependencyOf` is a no-op (default refresh). */
|
|
40
|
+
derivedPinLookup?: (catalogEntry: string) => string | undefined;
|
|
35
41
|
};
|
|
36
42
|
export declare class RecheckError extends Error {
|
|
37
43
|
readonly failing: Change;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RunContext } from '../engine/api';
|
|
2
|
+
import { DerivedCatalogPin } from '../engine/registry-client';
|
|
2
3
|
/** SDK packages that live in the catalog (membership only — no versions).
|
|
3
4
|
* Init fetches npm latest for each; refresh leaves them as the lockfile has
|
|
4
5
|
* them. */
|
|
@@ -6,6 +7,18 @@ export declare const SDK_CATALOG_PACKAGES: readonly string[];
|
|
|
6
7
|
/** Infra / tooling — a `~`-floored curated set. Not exact-pinned on refresh
|
|
7
8
|
* and not bumped by update-deps. */
|
|
8
9
|
export declare const INFRA_CATALOG_FLOOR: Record<string, string>;
|
|
10
|
+
/** Catalog entries whose exact version is DERIVED from the version another
|
|
11
|
+
* package declares for them (see `pinCatalogToDependencyOf`). Resolved on
|
|
12
|
+
* init + update-deps and OVERWRITES the on-disk entry.
|
|
13
|
+
*
|
|
14
|
+
* `vue`: the seeded ui depends on `vue` (catalog:). `@platforma-sdk/ui-vue`
|
|
15
|
+
* pins vue as an EXACT regular dependency (not a peer), so the block must
|
|
16
|
+
* pin the SAME exact version — a floated `~` lets the ui resolve a newer
|
|
17
|
+
* 3.5.x than ui-vue's, yielding two vue instances and a SdkPluginV3-vs-Plugin
|
|
18
|
+
* type clash in `main.ts`. Deriving the pin from ui-vue's declared dep keeps
|
|
19
|
+
* the block in lockstep with whatever vue ui-vue's npm-latest pins, with no
|
|
20
|
+
* hand-maintained version here. */
|
|
21
|
+
export declare const DERIVED_CATALOG_PINS: readonly DerivedCatalogPin[];
|
|
9
22
|
/** Catalog name of the python runenv. Added to a block's catalog only when
|
|
10
23
|
* the block carries a software module. Pinned to an EXACT version — updating
|
|
11
24
|
* the python runenv is an explicit author decision, never automatic. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@platforma-sdk/block-tools",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.18",
|
|
4
4
|
"description": "Utility to manipulate Platforma Blocks and Block Registry",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": {
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"zod": "~3.25.76",
|
|
36
36
|
"@milaboratories/pl-http": "1.2.4",
|
|
37
37
|
"@milaboratories/pl-model-middle-layer": "1.30.6",
|
|
38
|
-
"@milaboratories/pl-model-backend": "1.4.7",
|
|
39
|
-
"@milaboratories/resolve-helper": "1.1.3",
|
|
40
38
|
"@milaboratories/pl-model-common": "1.46.1",
|
|
41
|
-
"@milaboratories/
|
|
39
|
+
"@milaboratories/resolve-helper": "1.1.3",
|
|
40
|
+
"@milaboratories/pl-model-backend": "1.4.7",
|
|
42
41
|
"@milaboratories/ts-helpers": "1.8.3",
|
|
42
|
+
"@milaboratories/ts-helpers-oclif": "1.1.42",
|
|
43
43
|
"@platforma-sdk/blocks-deps-updater": "2.2.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"vite-plugin-dts": "^4.5.3",
|
|
58
58
|
"vitest": "^4.1.3",
|
|
59
59
|
"@milaboratories/oclif-index": "1.1.1",
|
|
60
|
-
"@milaboratories/ts-builder": "1.5.0",
|
|
61
60
|
"@milaboratories/ts-configs": "1.2.3",
|
|
61
|
+
"@milaboratories/ts-builder": "1.5.2",
|
|
62
62
|
"@milaboratories/build-configs": "2.0.0"
|
|
63
63
|
},
|
|
64
64
|
"oclif": {
|
|
@@ -34,8 +34,7 @@ const EXPECTED_MODEL_PACKAGE_JSON = `{
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@milaboratories/ts-builder": "catalog:",
|
|
36
36
|
"@milaboratories/ts-configs": "catalog:",
|
|
37
|
-
"@platforma-sdk/block-tools": "catalog:"
|
|
38
|
-
"vitest": "catalog:"
|
|
37
|
+
"@platforma-sdk/block-tools": "catalog:"
|
|
39
38
|
},
|
|
40
39
|
"peerDependencies": {
|
|
41
40
|
"@types/node": "*",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Regression: a test-bearing model/ui scope migrating from a LEGACY tsconfig
|
|
2
|
+
// must converge in a single pass.
|
|
3
|
+
//
|
|
4
|
+
// History: the tsconfig used to be a `managed` file whose body did
|
|
5
|
+
// `removeField("compilerOptions")`, conditionally re-added
|
|
6
|
+
// `compilerOptions.types: ["node"]`, then `ensureField("include", …)`. On a
|
|
7
|
+
// legacy tsconfig with no `include`, pass 1 appended `include` AFTER the
|
|
8
|
+
// re-added `compilerOptions` → `{extends, compilerOptions, include}`, while the
|
|
9
|
+
// body's fixpoint was `{extends, include, compilerOptions}`. The two differed
|
|
10
|
+
// only in key order, so the engine's post-run recheck saw a phantom diff and
|
|
11
|
+
// `refresh` aborted with a non-idempotency RecheckError on EVERY test-bearing
|
|
12
|
+
// block migrating off a legacy (vite-era) tsconfig.
|
|
13
|
+
//
|
|
14
|
+
// Now the tsconfig is a `fixed` file with two static end states selected by a
|
|
15
|
+
// `when`/else on co-located-test presence (`tsconfig.node.json` vs
|
|
16
|
+
// `tsconfig.json`). There is no imperative body and no insertion-order side
|
|
17
|
+
// effect, so single-pass idempotency holds by construction. This test guards
|
|
18
|
+
// that property.
|
|
19
|
+
|
|
20
|
+
import { describe, test, expect } from "vitest";
|
|
21
|
+
import { simulateInit, defaultTemplateProvider } from "../engine/testing";
|
|
22
|
+
import { run as engineRun } from "../engine/runner";
|
|
23
|
+
import { discoverRunContext } from "../engine/discovery-fs";
|
|
24
|
+
import { STRUCTURE } from "../structure-definition";
|
|
25
|
+
import { SDK_CATALOG_PACKAGES } from "../rules/root-pnpm-workspace";
|
|
26
|
+
import type { BlockVars } from "../engine/api";
|
|
27
|
+
|
|
28
|
+
const VARS: BlockVars = {
|
|
29
|
+
facadeName: "@platforma-open/test-org.demo",
|
|
30
|
+
baseName: "test-org.demo",
|
|
31
|
+
npmOrg: "@platforma-open",
|
|
32
|
+
orgScope: "test-org",
|
|
33
|
+
shortName: "demo",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const mockLookup = (name: string): string | undefined =>
|
|
37
|
+
SDK_CATALOG_PACKAGES.includes(name) ? "9.9.9" : undefined;
|
|
38
|
+
|
|
39
|
+
// `refresh` runs the full engine INCLUDING the post-run recheck (via
|
|
40
|
+
// `rediscover`); a non-idempotent rule set makes this throw.
|
|
41
|
+
function refresh(fs: ReturnType<typeof simulateInit>["fs"]) {
|
|
42
|
+
const ctx = discoverRunContext({ fs, isSdkInternal: false });
|
|
43
|
+
engineRun(STRUCTURE, fs, ctx, {
|
|
44
|
+
templates: defaultTemplateProvider(),
|
|
45
|
+
rediscover: () => discoverRunContext({ fs, isSdkInternal: false, dryRun: true }),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CANONICAL_ORDER = ["extends", "compilerOptions", "include"];
|
|
50
|
+
|
|
51
|
+
describe("tsconfig first-pass idempotency on legacy (no-include) tsconfig", () => {
|
|
52
|
+
test("model + ui co-located tests, legacy tsconfig without `include` → refresh converges in one pass", () => {
|
|
53
|
+
const { fs } = simulateInit({ vars: VARS, registryLookup: mockLookup });
|
|
54
|
+
|
|
55
|
+
// Co-located unit tests in both scopes -> the conditional node-types re-add fires.
|
|
56
|
+
fs.write("model/src/m.test.ts", `import { test } from "vitest";\ntest("x", () => {});\n`);
|
|
57
|
+
fs.write("ui/src/u.test.ts", `import { test } from "vitest";\ntest("x", () => {});\n`);
|
|
58
|
+
|
|
59
|
+
// Legacy tsconfigs as a vite-era block carried them: a `compilerOptions`
|
|
60
|
+
// block and NO top-level `include`. This is what made pass 1 append
|
|
61
|
+
// `include` after the re-added `compilerOptions`.
|
|
62
|
+
fs.write(
|
|
63
|
+
"model/tsconfig.json",
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
extends: "@milaboratories/ts-configs/block/model",
|
|
66
|
+
compilerOptions: { strict: true },
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
fs.write(
|
|
70
|
+
"ui/tsconfig.json",
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
extends: "@milaboratories/ts-configs/block/ui",
|
|
73
|
+
compilerOptions: { strict: true },
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Without the fix this throws RecheckError (rule set not idempotent).
|
|
78
|
+
expect(() => refresh(fs)).not.toThrow();
|
|
79
|
+
|
|
80
|
+
for (const scope of ["model", "ui"]) {
|
|
81
|
+
const ts = JSON.parse(fs.read(`${scope}/tsconfig.json`)) as {
|
|
82
|
+
extends: string;
|
|
83
|
+
include: string[];
|
|
84
|
+
compilerOptions: { types: string[] };
|
|
85
|
+
};
|
|
86
|
+
// Canonical, deterministic key order.
|
|
87
|
+
expect(Object.keys(ts)).toEqual(CANONICAL_ORDER);
|
|
88
|
+
expect(ts.extends).toBe(`@milaboratories/ts-configs/block/${scope}`);
|
|
89
|
+
expect(ts.include).toEqual(["src/**/*"]);
|
|
90
|
+
// node types wired (co-located test present); legacy `strict` pruned.
|
|
91
|
+
expect(ts.compilerOptions).toEqual({ types: ["node"] });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -11,8 +11,12 @@ import { NodeTemplateProvider } from "../engine/templates";
|
|
|
11
11
|
import { run as engineRun, type Change } from "../engine/runner";
|
|
12
12
|
import { discoverRunContext } from "../engine/discovery-fs";
|
|
13
13
|
import { STRUCTURE } from "../structure-definition";
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
matchesBumpPattern,
|
|
16
|
+
buildRegistryLookupForNames,
|
|
17
|
+
buildDerivedPinLookupForPins,
|
|
18
|
+
} from "../engine/registry-client";
|
|
19
|
+
import { SDK_CATALOG_PACKAGES, DERIVED_CATALOG_PINS } from "../rules/root-pnpm-workspace";
|
|
16
20
|
|
|
17
21
|
export type StructureMode = "check" | "refresh";
|
|
18
22
|
|
|
@@ -60,10 +64,16 @@ export async function runStructureForPath(input: RunStructureInput): Promise<Run
|
|
|
60
64
|
});
|
|
61
65
|
|
|
62
66
|
const registryLookup = input.updateDepsOnly ? await buildRegistryLookup() : undefined;
|
|
67
|
+
// Derived catalog pins (e.g. vue ← ui-vue's declared dep) resolve on the
|
|
68
|
+
// same prefetch budget as the SDK latest bump; absent on a default refresh.
|
|
69
|
+
const derivedPinLookup = input.updateDepsOnly
|
|
70
|
+
? await buildDerivedPinLookupForPins(DERIVED_CATALOG_PINS)
|
|
71
|
+
: undefined;
|
|
63
72
|
|
|
64
73
|
const result = engineRun(STRUCTURE, fs, ctx, {
|
|
65
74
|
templates,
|
|
66
75
|
registryLookup,
|
|
76
|
+
derivedPinLookup,
|
|
67
77
|
// Fresh re-discovery for the post-run recheck (default refresh only)
|
|
68
78
|
// — re-reads the just-written tree so a rename that shifted the
|
|
69
79
|
// module map is observed.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { pinCatalogToDependencyOf, withManagedYaml } from "../../content-rules";
|
|
3
|
+
import { parseYaml, stringifyYaml } from "../../parsers/yaml";
|
|
4
|
+
|
|
5
|
+
// The builder is sync: it reads a value the runner pre-resolved + validated
|
|
6
|
+
// into `getDerivedDependencyPin`. These tests inject that accessor directly.
|
|
7
|
+
const lookup =
|
|
8
|
+
(m: Record<string, string>) =>
|
|
9
|
+
(entry: string): string | undefined =>
|
|
10
|
+
m[entry];
|
|
11
|
+
|
|
12
|
+
describe("pinCatalogToDependencyOf (mocked derived-pin lookup)", () => {
|
|
13
|
+
test("overwrites a present LOOSE entry with the derived exact version", () => {
|
|
14
|
+
const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
|
|
15
|
+
withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }), {
|
|
16
|
+
getDerivedDependencyPin: lookup({ vue: "3.5.24" }),
|
|
17
|
+
});
|
|
18
|
+
const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
|
|
19
|
+
expect(cat.vue).toBe("3.5.24");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("creates an absent entry", () => {
|
|
23
|
+
const doc = parseYaml("catalog:\n unrelated: 1.0.0\n");
|
|
24
|
+
withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }), {
|
|
25
|
+
getDerivedDependencyPin: lookup({ vue: "3.5.24" }),
|
|
26
|
+
});
|
|
27
|
+
const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
|
|
28
|
+
expect(cat.vue).toBe("3.5.24");
|
|
29
|
+
expect(cat.unrelated).toBe("1.0.0");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("no-op when no derived-pin lookup is provided (default refresh)", () => {
|
|
33
|
+
const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
|
|
34
|
+
withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }));
|
|
35
|
+
const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
|
|
36
|
+
expect(cat.vue).toBe("^3.5.24");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("no-op when the entry was not prefetched (lookup returns undefined)", () => {
|
|
40
|
+
const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
|
|
41
|
+
withManagedYaml(doc, () => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }), {
|
|
42
|
+
getDerivedDependencyPin: lookup({ other: "1.0.0" }),
|
|
43
|
+
});
|
|
44
|
+
const cat = (doc.toJSON() as { catalog: Record<string, string> }).catalog;
|
|
45
|
+
expect(cat.vue).toBe("^3.5.24");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("idempotent — second run produces the same YAML", () => {
|
|
49
|
+
const doc = parseYaml("catalog:\n vue: ^3.5.24\n");
|
|
50
|
+
const opts = { getDerivedDependencyPin: lookup({ vue: "3.5.24" }) };
|
|
51
|
+
withManagedYaml(
|
|
52
|
+
doc,
|
|
53
|
+
() => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }),
|
|
54
|
+
opts,
|
|
55
|
+
);
|
|
56
|
+
const once = stringifyYaml(doc);
|
|
57
|
+
withManagedYaml(
|
|
58
|
+
doc,
|
|
59
|
+
() => pinCatalogToDependencyOf("vue", { of: "@platforma-sdk/ui-vue" }),
|
|
60
|
+
opts,
|
|
61
|
+
);
|
|
62
|
+
const twice = stringifyYaml(doc);
|
|
63
|
+
expect(twice).toBe(once);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -222,6 +222,36 @@ describe("when() — inner-mode dispatch inside managed bodies", () => {
|
|
|
222
222
|
).toThrow(/outside defineStructure.*outside any managed/);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
test("else branch: predicate=true runs body, skips else", () => {
|
|
226
|
+
const out = withManagedBody(
|
|
227
|
+
{} as JsonObject,
|
|
228
|
+
() => {
|
|
229
|
+
when(
|
|
230
|
+
({ pathExists }) => pathExists("tests/"),
|
|
231
|
+
() => ensureField("branch", "if"),
|
|
232
|
+
() => ensureField("branch", "else"),
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
{ triggerContext: makeTctx({ paths: new Set(["tests/"]) }) },
|
|
236
|
+
);
|
|
237
|
+
expect(out.branch).toBe("if");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("else branch: predicate=false runs else, skips body", () => {
|
|
241
|
+
const out = withManagedBody(
|
|
242
|
+
{} as JsonObject,
|
|
243
|
+
() => {
|
|
244
|
+
when(
|
|
245
|
+
({ pathExists }) => pathExists("tests/"),
|
|
246
|
+
() => ensureField("branch", "if"),
|
|
247
|
+
() => ensureField("branch", "else"),
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
{ triggerContext: makeTctx({ paths: new Set() }) },
|
|
251
|
+
);
|
|
252
|
+
expect(out.branch).toBe("else");
|
|
253
|
+
});
|
|
254
|
+
|
|
225
255
|
test("idempotent — double-run yields the same parsed state", () => {
|
|
226
256
|
const body = () => {
|
|
227
257
|
ensureField("type", "module");
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { buildDerivedPinLookupForPins, createMockRegistryClient } from "../registry-client";
|
|
3
|
+
|
|
4
|
+
describe("buildDerivedPinLookupForPins (mocked registry)", () => {
|
|
5
|
+
test("resolves the catalog entry to the source's declared exact dep version", async () => {
|
|
6
|
+
const client = createMockRegistryClient(
|
|
7
|
+
{ "@platforma-sdk/ui-vue": "1.79.6" },
|
|
8
|
+
{ "@platforma-sdk/ui-vue@1.79.6": { vue: "3.5.24" } },
|
|
9
|
+
);
|
|
10
|
+
const lookup = await buildDerivedPinLookupForPins(
|
|
11
|
+
[{ entry: "vue", of: "@platforma-sdk/ui-vue" }],
|
|
12
|
+
client,
|
|
13
|
+
);
|
|
14
|
+
expect(lookup("vue")).toBe("3.5.24");
|
|
15
|
+
expect(lookup("unrelated")).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("reads from a pinned source version when `ofVersion` is given", async () => {
|
|
19
|
+
const client = createMockRegistryClient(
|
|
20
|
+
{ "@platforma-sdk/ui-vue": "9.9.9" }, // latest — must NOT be used
|
|
21
|
+
{ "@platforma-sdk/ui-vue@1.79.3": { vue: "3.5.20" } },
|
|
22
|
+
);
|
|
23
|
+
const lookup = await buildDerivedPinLookupForPins(
|
|
24
|
+
[{ entry: "vue", of: "@platforma-sdk/ui-vue", ofVersion: "1.79.3" }],
|
|
25
|
+
client,
|
|
26
|
+
);
|
|
27
|
+
expect(lookup("vue")).toBe("3.5.20");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("empty pin list → lookup returns undefined for everything", async () => {
|
|
31
|
+
const lookup = await buildDerivedPinLookupForPins([], createMockRegistryClient({}));
|
|
32
|
+
expect(lookup("vue")).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("policy (b): throws when the source declares a NON-EXACT range", async () => {
|
|
36
|
+
const client = createMockRegistryClient(
|
|
37
|
+
{ "@platforma-sdk/ui-vue": "1.79.6" },
|
|
38
|
+
{ "@platforma-sdk/ui-vue@1.79.6": { vue: "^3.5.24" } },
|
|
39
|
+
);
|
|
40
|
+
await expect(
|
|
41
|
+
buildDerivedPinLookupForPins([{ entry: "vue", of: "@platforma-sdk/ui-vue" }], client),
|
|
42
|
+
).rejects.toThrow(/not an exact version/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("policy (b): throws when the source declares no such dependency", async () => {
|
|
46
|
+
const client = createMockRegistryClient(
|
|
47
|
+
{ "@platforma-sdk/ui-vue": "1.79.6" },
|
|
48
|
+
{ "@platforma-sdk/ui-vue@1.79.6": { react: "19.0.0" } },
|
|
49
|
+
);
|
|
50
|
+
await expect(
|
|
51
|
+
buildDerivedPinLookupForPins([{ entry: "vue", of: "@platforma-sdk/ui-vue" }], client),
|
|
52
|
+
).rejects.toThrow(/declares no/i);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -193,34 +193,49 @@ export function registerInnerWhenHandler(h: InnerWhenHandler): void {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
* Conditionally run `body
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* synchronously.
|
|
196
|
+
* Conditionally run `body`, with an optional `elseBody`. One `when` symbol for
|
|
197
|
+
* both layers — `dispatchWhen` picks the timing:
|
|
198
|
+
* - inside `defineStructure(...)` → register a `WhenFrame`; its trigger fires
|
|
199
|
+
* later, per module, at runner time.
|
|
200
|
+
* - inside a `managed(...)` body → evaluate the trigger against the live block
|
|
201
|
+
* now and run/skip synchronously.
|
|
202
202
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
203
|
+
* `else` is just the negated trigger dispatched the same way, so exactly one
|
|
204
|
+
* branch applies (in either layer).
|
|
205
205
|
*/
|
|
206
|
-
export function when(trigger: TriggerFn, body: () => void): void {
|
|
206
|
+
export function when(trigger: TriggerFn, body: () => void, elseBody?: () => void): void {
|
|
207
|
+
dispatchWhen(trigger, body);
|
|
208
|
+
if (elseBody) dispatchWhen((tctx) => !trigger(tctx), elseBody);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Single-branch dispatch shared by both `when` branches. In a managed body the
|
|
212
|
+
* handler runs `body` iff its trigger holds and returns whether a managed-body
|
|
213
|
+
* context existed; no context — and not tree-build either — means `when()` was
|
|
214
|
+
* called outside both layers, a misuse. */
|
|
215
|
+
function dispatchWhen(trigger: TriggerFn, body: () => void): void {
|
|
207
216
|
if (activeTree) {
|
|
208
|
-
|
|
209
|
-
const frame: WhenFrame = { kind: "when", trigger, children: [] };
|
|
210
|
-
t.stack[t.stack.length - 1]!.push(frame);
|
|
211
|
-
t.stack.push(frame.children);
|
|
212
|
-
try {
|
|
213
|
-
body();
|
|
214
|
-
} finally {
|
|
215
|
-
t.stack.pop();
|
|
216
|
-
}
|
|
217
|
+
pushWhenFrame(trigger, body);
|
|
217
218
|
return;
|
|
218
219
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
"
|
|
223
|
-
|
|
220
|
+
const handled = innerWhenHandler?.(trigger, body) ?? false;
|
|
221
|
+
if (!handled) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"when() called outside defineStructure() and outside any managed(...) body. " +
|
|
224
|
+
"Place this call inside one of the two.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function pushWhenFrame(trigger: TriggerFn, body: () => void): void {
|
|
230
|
+
const t = activeTree!;
|
|
231
|
+
const frame: WhenFrame = { kind: "when", trigger, children: [] };
|
|
232
|
+
t.stack[t.stack.length - 1]!.push(frame);
|
|
233
|
+
t.stack.push(frame.children);
|
|
234
|
+
try {
|
|
235
|
+
body();
|
|
236
|
+
} finally {
|
|
237
|
+
t.stack.pop();
|
|
238
|
+
}
|
|
224
239
|
}
|
|
225
240
|
|
|
226
241
|
/** Trigger factory: true when at least one file in the leaf's module
|
|
@@ -83,6 +83,9 @@ type YamlState = {
|
|
|
83
83
|
triggerContext?: TriggerContext;
|
|
84
84
|
/** Synchronous accessor for prefetched npm latest versions. */
|
|
85
85
|
getLatestVersion?: (packageName: string) => string | undefined;
|
|
86
|
+
/** Synchronous accessor for prefetched derived catalog pins
|
|
87
|
+
* (`catalogEntry → exact version`); see `pinCatalogToDependencyOf`. */
|
|
88
|
+
getDerivedDependencyPin?: (catalogEntry: string) => string | undefined;
|
|
86
89
|
};
|
|
87
90
|
|
|
88
91
|
type LinesState = {
|
|
@@ -177,6 +180,9 @@ export type WithManagedYamlOpts = WithManagedOpts & {
|
|
|
177
180
|
/** Sync accessor for prefetched latest versions. Required for
|
|
178
181
|
* `ensureCatalogLatest` to take effect. */
|
|
179
182
|
getLatestVersion?: (packageName: string) => string | undefined;
|
|
183
|
+
/** Sync accessor for prefetched derived catalog pins. Required for
|
|
184
|
+
* `pinCatalogToDependencyOf` to take effect. */
|
|
185
|
+
getDerivedDependencyPin?: (catalogEntry: string) => string | undefined;
|
|
180
186
|
};
|
|
181
187
|
|
|
182
188
|
/** Engine-internal: run `body` with `doc` as the active YAML state (for
|
|
@@ -193,6 +199,7 @@ export function withManagedYaml(
|
|
|
193
199
|
ctx: opts.ctx,
|
|
194
200
|
triggerContext: opts.triggerContext,
|
|
195
201
|
getLatestVersion: opts.getLatestVersion,
|
|
202
|
+
getDerivedDependencyPin: opts.getDerivedDependencyPin,
|
|
196
203
|
};
|
|
197
204
|
try {
|
|
198
205
|
body();
|
|
@@ -579,6 +586,28 @@ export function ensureCatalogLatest(name: string): void {
|
|
|
579
586
|
state.doc.setIn(["catalog", name], latest);
|
|
580
587
|
}
|
|
581
588
|
|
|
589
|
+
/** Pin `catalog.<catalogEntry>` to the EXACT version that package `opts.of`
|
|
590
|
+
* declares for `catalogEntry` in its own manifest (read at npm latest, or at
|
|
591
|
+
* `opts.ofVersion` if given). The value is resolved by the runner's
|
|
592
|
+
* prefetched `getDerivedDependencyPin` accessor (the caller pre-resolves and
|
|
593
|
+
* validates — exact-version-only — at prefetch time; no network here).
|
|
594
|
+
* OVERWRITES like `pinCatalogTo` (not add-if-absent): a pre-existing loose
|
|
595
|
+
* entry (`vue: ^3.5.24`) is tightened to the exact derived version. No-op
|
|
596
|
+
* when no derived-pin lookup is available — default refresh/check pass none,
|
|
597
|
+
* leaving the entry exactly as the lockfile has it; the resolution leaves
|
|
598
|
+
* live in the `onInitOrUpdate` frame. */
|
|
599
|
+
export function pinCatalogToDependencyOf(
|
|
600
|
+
catalogEntry: string,
|
|
601
|
+
_opts: { of: string; ofVersion?: string },
|
|
602
|
+
): void {
|
|
603
|
+
const state = requireYaml("pinCatalogToDependencyOf");
|
|
604
|
+
if (!state.getDerivedDependencyPin) return;
|
|
605
|
+
const version = state.getDerivedDependencyPin(catalogEntry);
|
|
606
|
+
if (version === undefined) return;
|
|
607
|
+
if (readCatalogString(state, catalogEntry) === version) return;
|
|
608
|
+
state.doc.setIn(["catalog", catalogEntry], version);
|
|
609
|
+
}
|
|
610
|
+
|
|
582
611
|
/** Remove `catalog.<name>` if present; no-op when absent. */
|
|
583
612
|
export function ensureCatalogAbsent(name: string): void {
|
|
584
613
|
const state = requireYaml("ensureCatalogAbsent");
|