@platforma-sdk/block-tools 2.10.14 → 2.10.15
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 +23 -2
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +1944 -100
- package/dist/cli.mjs.map +1 -1
- package/dist/cmd/index.d.ts +10 -4
- package/dist/cmd/structure/check.d.ts +10 -0
- package/dist/cmd/structure/init.d.ts +18 -0
- package/dist/cmd/structure/refresh.d.ts +11 -0
- package/dist/cmd/update-deps.d.ts +7 -0
- package/dist/structure/cli/run-structure.d.ts +19 -0
- package/dist/structure/engine/api.d.ts +55 -0
- package/dist/structure/engine/builders.d.ts +95 -0
- package/dist/structure/engine/content-rules.d.ts +148 -0
- package/dist/structure/engine/ctx.d.ts +34 -0
- package/dist/structure/engine/discovery-fs.d.ts +22 -0
- package/dist/structure/engine/discovery.d.ts +39 -0
- package/dist/structure/engine/flatten.d.ts +3 -0
- package/dist/structure/engine/fs/api.d.ts +27 -0
- package/dist/structure/engine/fs/memory.d.ts +16 -0
- package/dist/structure/engine/fs/node.d.ts +14 -0
- package/dist/structure/engine/glob.d.ts +4 -0
- package/dist/structure/engine/ir.d.ts +124 -0
- package/dist/structure/engine/parsers/json.d.ts +37 -0
- package/dist/structure/engine/parsers/lines.d.ts +9 -0
- package/dist/structure/engine/parsers/yaml.d.ts +18 -0
- package/dist/structure/engine/registry-client.d.ts +34 -0
- package/dist/structure/engine/runner.d.ts +45 -0
- package/dist/structure/engine/templates.d.ts +30 -0
- package/dist/structure/engine/testing.d.ts +34 -0
- package/dist/structure/engine/version.d.ts +18 -0
- package/dist/structure/init-block-constructor.d.ts +32 -0
- package/dist/structure/rules/block-package-json.d.ts +9 -0
- package/dist/structure/rules/block.d.ts +1 -0
- package/dist/structure/rules/migrations.d.ts +2 -0
- package/dist/structure/rules/model-package-json.d.ts +3 -0
- package/dist/structure/rules/model.d.ts +1 -0
- package/dist/structure/rules/root-catalog-bump.d.ts +1 -0
- package/dist/structure/rules/root-gitignore.d.ts +2 -0
- package/dist/structure/rules/root-package-json.d.ts +3 -0
- package/dist/structure/rules/root-pnpm-workspace.d.ts +15 -0
- package/dist/structure/rules/root.d.ts +1 -0
- package/dist/structure/rules/shared/colocated-tests.d.ts +2 -0
- package/dist/structure/rules/shared/key-order.d.ts +1 -0
- package/dist/structure/rules/software-package-json.d.ts +3 -0
- package/dist/structure/rules/software.d.ts +1 -0
- package/dist/structure/rules/test-package-json.d.ts +3 -0
- package/dist/structure/rules/test.d.ts +1 -0
- package/dist/structure/rules/ui-package-json.d.ts +3 -0
- package/dist/structure/rules/ui.d.ts +1 -0
- package/dist/structure/rules/workflow-package-json.d.ts +3 -0
- package/dist/structure/rules/workflow.d.ts +1 -0
- package/dist/structure/structure-definition.d.ts +1 -0
- package/package.json +9 -7
- package/src/cmd/index.ts +10 -4
- package/src/cmd/structure/check.ts +51 -0
- package/src/cmd/structure/init.ts +65 -0
- package/src/cmd/structure/refresh.ts +44 -0
- package/src/cmd/update-deps.ts +15 -3
- package/src/structure/__tests__/colocated-test-wiring.test.ts +107 -0
- package/src/structure/__tests__/floor-version.test.ts +52 -0
- package/src/structure/__tests__/init-catalog-resolution.test.ts +82 -0
- package/src/structure/__tests__/init-check-parity.test.ts +59 -0
- package/src/structure/__tests__/model-package-json.snapshot.test.ts +60 -0
- package/src/structure/__tests__/oxfmt-clean-init.test.ts +64 -0
- package/src/structure/__tests__/root-catalog-bump.test.ts +110 -0
- package/src/structure/__tests__/seed-disjointness.test.ts +79 -0
- package/src/structure/cli/run-structure.ts +89 -0
- package/src/structure/engine/__tests__/content-rules/active-state-guards.test.ts +61 -0
- package/src/structure/engine/__tests__/content-rules/catalog-pin.test.ts +46 -0
- package/src/structure/engine/__tests__/content-rules/enforce-alphabetical-order.test.ts +55 -0
- package/src/structure/engine/__tests__/content-rules/enforce-field-order.test.ts +73 -0
- package/src/structure/engine/__tests__/content-rules/ensure-catalog-absent.test.ts +29 -0
- package/src/structure/engine/__tests__/content-rules/ensure-catalog-latest.test.ts +70 -0
- package/src/structure/engine/__tests__/content-rules/ensure-dep.test.ts +63 -0
- package/src/structure/engine/__tests__/content-rules/ensure-deps-bulk.test.ts +46 -0
- package/src/structure/engine/__tests__/content-rules/ensure-field-entries.test.ts +35 -0
- package/src/structure/engine/__tests__/content-rules/ensure-field.test.ts +56 -0
- package/src/structure/engine/__tests__/content-rules/ensure-script.test.ts +39 -0
- package/src/structure/engine/__tests__/content-rules/ensure-workspace-scope-deps.test.ts +92 -0
- package/src/structure/engine/__tests__/content-rules/gitignore.test.ts +50 -0
- package/src/structure/engine/__tests__/content-rules/pin-catalog-to.test.ts +64 -0
- package/src/structure/engine/__tests__/content-rules/prune-keys.test.ts +59 -0
- package/src/structure/engine/__tests__/content-rules/registry-client.test.ts +116 -0
- package/src/structure/engine/__tests__/content-rules/remove-field.test.ts +49 -0
- package/src/structure/engine/__tests__/content-rules/require-field.test.ts +40 -0
- package/src/structure/engine/__tests__/content-rules/transform-at.test.ts +33 -0
- package/src/structure/engine/__tests__/content-rules/when-inner.test.ts +244 -0
- package/src/structure/engine/__tests__/content-rules/workspace-module-paths.test.ts +53 -0
- package/src/structure/engine/__tests__/discovery.test.ts +160 -0
- package/src/structure/engine/__tests__/fs-memory.test.ts +59 -0
- package/src/structure/engine/__tests__/glob.test.ts +43 -0
- package/src/structure/engine/__tests__/parsers-json.test.ts +189 -0
- package/src/structure/engine/__tests__/parsers-lines.test.ts +44 -0
- package/src/structure/engine/__tests__/parsers-yaml.test.ts +90 -0
- package/src/structure/engine/__tests__/recheck.test.ts +99 -0
- package/src/structure/engine/__tests__/registry-lookup.test.ts +49 -0
- package/src/structure/engine/__tests__/runner-when-inside-managed.test.ts +136 -0
- package/src/structure/engine/__tests__/runner.test.ts +136 -0
- package/src/structure/engine/__tests__/synthetic.test.ts +153 -0
- package/src/structure/engine/__tests__/version.test.ts +44 -0
- package/src/structure/engine/__tests__/when-files-exist.test.ts +89 -0
- package/src/structure/engine/api.ts +131 -0
- package/src/structure/engine/builders.ts +341 -0
- package/src/structure/engine/content-rules.ts +616 -0
- package/src/structure/engine/ctx.ts +94 -0
- package/src/structure/engine/discovery-fs.ts +209 -0
- package/src/structure/engine/discovery.ts +168 -0
- package/src/structure/engine/flatten.ts +117 -0
- package/src/structure/engine/fs/api.ts +40 -0
- package/src/structure/engine/fs/memory.ts +132 -0
- package/src/structure/engine/fs/node.ts +89 -0
- package/src/structure/engine/glob.ts +42 -0
- package/src/structure/engine/ir.ts +149 -0
- package/src/structure/engine/parsers/json.ts +212 -0
- package/src/structure/engine/parsers/lines.ts +42 -0
- package/src/structure/engine/parsers/yaml.ts +72 -0
- package/src/structure/engine/registry-client.ts +140 -0
- package/src/structure/engine/runner.ts +519 -0
- package/src/structure/engine/templates.ts +74 -0
- package/src/structure/engine/testing.ts +93 -0
- package/src/structure/engine/version.ts +60 -0
- package/src/structure/init-block-constructor.ts +164 -0
- package/src/structure/rules/block-package-json.ts +156 -0
- package/src/structure/rules/block.ts +29 -0
- package/src/structure/rules/migrations.ts +63 -0
- package/src/structure/rules/model-package-json.ts +123 -0
- package/src/structure/rules/model.ts +65 -0
- package/src/structure/rules/root-catalog-bump.ts +77 -0
- package/src/structure/rules/root-gitignore.ts +27 -0
- package/src/structure/rules/root-package-json.ts +119 -0
- package/src/structure/rules/root-pnpm-workspace.ts +103 -0
- package/src/structure/rules/root.ts +43 -0
- package/src/structure/rules/shared/colocated-tests.ts +11 -0
- package/src/structure/rules/shared/key-order.ts +34 -0
- package/src/structure/rules/software-package-json.ts +86 -0
- package/src/structure/rules/software.ts +33 -0
- package/src/structure/rules/test-package-json.ts +70 -0
- package/src/structure/rules/test.ts +40 -0
- package/src/structure/rules/ui-package-json.ts +108 -0
- package/src/structure/rules/ui.ts +75 -0
- package/src/structure/rules/workflow-package-json.ts +92 -0
- package/src/structure/rules/workflow.ts +51 -0
- package/src/structure/structure-definition.ts +28 -0
- package/src/structure/templates/static/block/index.d.ts +6 -0
- package/src/structure/templates/static/block/index.js +8 -0
- package/src/structure/templates/static/block/logos/block-logo.png +0 -0
- package/src/structure/templates/static/block/logos/organization-logo.png +0 -0
- package/src/structure/templates/static/model/.oxfmtrc.json +4 -0
- package/src/structure/templates/static/model/.oxlintrc.json +3 -0
- package/src/structure/templates/static/model/src/index.ts +13 -0
- package/src/structure/templates/static/model/tsconfig.json +4 -0
- package/src/structure/templates/static/root/.gitignore +13 -0
- package/src/structure/templates/static/root/.vscode/settings.json +3 -0
- package/src/structure/templates/static/root/turbo.json +35 -0
- package/src/structure/templates/static/test/tsconfig.json +5 -0
- package/src/structure/templates/static/test/vitest.config.mts +8 -0
- package/src/structure/templates/static/ui/.oxfmtrc.json +3 -0
- package/src/structure/templates/static/ui/.oxlintrc.json +3 -0
- package/src/structure/templates/static/ui/index.html +12 -0
- package/src/structure/templates/static/ui/src/main.ts +5 -0
- package/src/structure/templates/static/ui/tsconfig.json +4 -0
- package/src/structure/templates/static/workflow/format.el +42 -0
- package/src/structure/templates/static/workflow/tsconfig.json +16 -0
- package/src/structure/templates/static/workflow/vitest.config.mts +9 -0
- package/src/structure/templates/text/README.md +5 -0
- package/src/structure/templates/text/docs/description.md +3 -0
- package/src/structure/templates/text/ui/src/MainPage.tpl.vue +9 -0
- package/src/structure/templates/text/ui/src/app.tpl.ts +11 -0
- package/src/structure/templates/text/workflow/src/main.tpl.tengo +10 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// RunContext assembly helpers + module-lookup helpers. The full
|
|
2
|
+
// `RunContext` type lives in `api.ts` (rule authors see it via
|
|
3
|
+
// `({ ctx }) => ...` trigger predicates). This file provides convenience
|
|
4
|
+
// constructors and the scope-lookup helpers generators use.
|
|
5
|
+
|
|
6
|
+
import type { BlockVars, Module, RunContext, Scope } from "./api";
|
|
7
|
+
|
|
8
|
+
/** All modules of `scope` (zero or more). Never throws. Use when "zero or
|
|
9
|
+
* more" is the right intent (e.g. software). */
|
|
10
|
+
export function findModules(ctx: RunContext, scope: Scope): Module[] {
|
|
11
|
+
return ctx.modules.filter((m) => m.scope === scope);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The single module of `scope`. Throws if absent OR if more than one
|
|
15
|
+
* exists. Use when exactly one is required (model / ui / workflow). */
|
|
16
|
+
export function findModule(ctx: RunContext, scope: Scope): Module {
|
|
17
|
+
const ms = findModules(ctx, scope);
|
|
18
|
+
if (ms.length === 0) {
|
|
19
|
+
throw new Error(`findModule: expected one '${scope}' module, found none`);
|
|
20
|
+
}
|
|
21
|
+
if (ms.length > 1) {
|
|
22
|
+
throw new Error(`findModule: expected exactly one '${scope}' module, found ${ms.length}`);
|
|
23
|
+
}
|
|
24
|
+
return ms[0]!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Workspace dep map `{ name: "workspace:*" }` for the single module of
|
|
28
|
+
* `scope`. Throws if absent (via `findModule`) — guarantees the dep. */
|
|
29
|
+
export function scopeDepMap(ctx: RunContext, scope: Scope): Record<string, string> {
|
|
30
|
+
return { [findModule(ctx, scope).name]: "workspace:*" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Workspace dep map for ALL modules of `scope` (zero or more). Never
|
|
34
|
+
* throws — use where multiple modules are legitimate (e.g. software). */
|
|
35
|
+
export function scopeDepMaps(ctx: RunContext, scope: Scope): Record<string, string> {
|
|
36
|
+
const out: Record<string, string> = {};
|
|
37
|
+
for (const m of findModules(ctx, scope)) out[m.name] = "workspace:*";
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Module set produced by `init` for the given BlockVars: one module per
|
|
43
|
+
* base scope (root/block/model/ui/workflow/test) plus, when a platform
|
|
44
|
+
* was selected, one `software` module. `--platform` is single-valued, so
|
|
45
|
+
* `init` produces at most one software module; multi-software shapes only
|
|
46
|
+
* arise via DISCOVERY on existing blocks.
|
|
47
|
+
*
|
|
48
|
+
* Single source of truth shared by the real `init` constructor and the
|
|
49
|
+
* `simulateInit` test helper — keeping them in lockstep is what makes the
|
|
50
|
+
* init→check zero-diff invariant meaningful.
|
|
51
|
+
*/
|
|
52
|
+
export function modulesForInit(vars: BlockVars): Module[] {
|
|
53
|
+
const facade = vars.facadeName;
|
|
54
|
+
const mods: Module[] = [
|
|
55
|
+
{ scope: "root", name: facade, path: "" },
|
|
56
|
+
{ scope: "block", name: `${facade}.block`, path: "block" },
|
|
57
|
+
{ scope: "model", name: `${facade}.model`, path: "model" },
|
|
58
|
+
{ scope: "ui", name: `${facade}.ui`, path: "ui" },
|
|
59
|
+
{ scope: "workflow", name: `${facade}.workflow`, path: "workflow" },
|
|
60
|
+
{ scope: "test", name: `${facade}.test`, path: "test" },
|
|
61
|
+
];
|
|
62
|
+
if (vars.softwarePlatform) {
|
|
63
|
+
// Single software module lives at the flat `software/` dir with name
|
|
64
|
+
// `${facade}.software` — matches the boilerplate and every real
|
|
65
|
+
// single-software block. Multi-software blocks arise only via
|
|
66
|
+
// DISCOVERY on existing workspaces, never from `init`.
|
|
67
|
+
mods.push({
|
|
68
|
+
scope: "software",
|
|
69
|
+
name: `${facade}.software`,
|
|
70
|
+
path: "software",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return mods;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type CreateRunContextInput = {
|
|
77
|
+
isSdkInternal?: boolean;
|
|
78
|
+
updateDepsOnly?: boolean;
|
|
79
|
+
modules: Module[];
|
|
80
|
+
blockVars: BlockVars;
|
|
81
|
+
version?: number;
|
|
82
|
+
dryRun?: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function createRunContext(input: CreateRunContextInput): RunContext {
|
|
86
|
+
return {
|
|
87
|
+
isSdkInternal: input.isSdkInternal ?? false,
|
|
88
|
+
updateDepsOnly: input.updateDepsOnly ?? false,
|
|
89
|
+
modules: input.modules,
|
|
90
|
+
blockVars: input.blockVars,
|
|
91
|
+
version: input.version ?? 0,
|
|
92
|
+
dryRun: input.dryRun ?? false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Filesystem-backed DISCOVERY.
|
|
2
|
+
//
|
|
3
|
+
// The engine runner consumes a fully-assembled `RunContext` (modules,
|
|
4
|
+
// version, blockVars). This module is the "caller" that builds it from a
|
|
5
|
+
// block directory, working through the engine's `FileSystem` abstraction
|
|
6
|
+
// — so the same code path serves the CLI (NodeFileSystem) and the
|
|
7
|
+
// in-memory init→check round-trip (MemoryFileSystem), which re-discovers
|
|
8
|
+
// from the just-written FS instead of reusing init's module set, so a
|
|
9
|
+
// discovery/classification regression surfaces in the parity invariant.
|
|
10
|
+
//
|
|
11
|
+
// Two enumeration modes:
|
|
12
|
+
//
|
|
13
|
+
// - Standalone (default): read the block-root `pnpm-workspace.yaml`,
|
|
14
|
+
// read each `packages:` member's `package.json` (literal paths only —
|
|
15
|
+
// globs are a hard error), classify via `discoverModules`. This is the
|
|
16
|
+
// canonical path.
|
|
17
|
+
//
|
|
18
|
+
// - SDK-internal (`--sdk-internal`): in-monorepo blocks (e.g.
|
|
19
|
+
// `etc/blocks/*`) have NO block-local `pnpm-workspace.yaml` — they
|
|
20
|
+
// are members of the parent monorepo's workspace. The block's
|
|
21
|
+
// modules are therefore enumerated by scanning the block directory
|
|
22
|
+
// for sub-package `package.json` files (depth-limited, skipping
|
|
23
|
+
// `node_modules`).
|
|
24
|
+
//
|
|
25
|
+
// `.structure` version is read (missing → 0) and floor-checked. BlockVars
|
|
26
|
+
// are parsed from the block-scope module's `package.json` `name` (the
|
|
27
|
+
// facade name), or — for the root-as-block shape — from the root package.
|
|
28
|
+
|
|
29
|
+
import { parse as parseYamlText } from "yaml";
|
|
30
|
+
import type { BlockVars, Module, RunContext } from "./api";
|
|
31
|
+
import type { FileSystem } from "./fs/api";
|
|
32
|
+
import { discoverModules, type WorkspacePackage, type PackageJsonLike } from "./discovery";
|
|
33
|
+
import { createRunContext } from "./ctx";
|
|
34
|
+
import { STRUCTURE_META_FILE, STRUCTURE_MIN_SUPPORTED, assertVersionAboveFloor } from "./version";
|
|
35
|
+
|
|
36
|
+
export class StructureCliError extends Error {
|
|
37
|
+
constructor(msg: string) {
|
|
38
|
+
super(msg);
|
|
39
|
+
this.name = "StructureCliError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Join two block-relative path segments ("" = root). */
|
|
44
|
+
function joinRel(a: string, b: string): string {
|
|
45
|
+
return a === "" ? b : `${a}/${b}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readJsonIfExists(fs: FileSystem, rel: string): PackageJsonLike | undefined {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.read(rel)) as PackageJsonLike;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolve a single pnpm-workspace `packages:` entry to a block-relative
|
|
57
|
+
* directory path. Entries are literal paths only — the structurer does
|
|
58
|
+
* not expand globs: no real block uses them, and a globbing surface
|
|
59
|
+
* with no caller is dead complexity. A `*` anywhere is a hard error. */
|
|
60
|
+
function resolvePackagePath(pattern: string): string {
|
|
61
|
+
if (pattern.includes("*")) {
|
|
62
|
+
throw new StructureCliError(
|
|
63
|
+
`pnpm-workspace.yaml 'packages:' entry '${pattern}' contains a glob. ` +
|
|
64
|
+
`The structurer requires literal package paths — replace the glob ` +
|
|
65
|
+
`with one entry per package directory.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const p = pattern.replace(/^\.\//, "").replace(/\/$/, "");
|
|
69
|
+
return p === "." ? "" : p;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function enumerateStandalone(fs: FileSystem): WorkspacePackage[] {
|
|
73
|
+
let wsRaw: string;
|
|
74
|
+
try {
|
|
75
|
+
wsRaw = fs.read("pnpm-workspace.yaml");
|
|
76
|
+
} catch {
|
|
77
|
+
throw new StructureCliError(
|
|
78
|
+
`No pnpm-workspace.yaml found. This directory is empty or ` +
|
|
79
|
+
`unrecognisable as a block — run 'block-tools structure init' instead, ` +
|
|
80
|
+
`or pass --sdk-internal for an in-monorepo block.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const ws = (parseYamlText(wsRaw) ?? {}) as { packages?: string[] };
|
|
84
|
+
const patterns = Array.isArray(ws.packages) ? ws.packages : [];
|
|
85
|
+
// The block root ("") is ALWAYS a member, discovered implicitly.
|
|
86
|
+
// pnpm treats the directory holding pnpm-workspace.yaml as the implicit
|
|
87
|
+
// workspace root, so the structurer reads the root package.json directly
|
|
88
|
+
// — no real block lists "." in `packages:` (it would break turbo's task
|
|
89
|
+
// graph), and the structurer never requires or writes it. The Set
|
|
90
|
+
// dedupes if a workspace did list ".".
|
|
91
|
+
const dirs = new Set<string>([""]);
|
|
92
|
+
for (const pat of patterns) {
|
|
93
|
+
const d = resolvePackagePath(pat);
|
|
94
|
+
if (d === "" || fs.exists(d)) dirs.add(d);
|
|
95
|
+
}
|
|
96
|
+
const out: WorkspacePackage[] = [];
|
|
97
|
+
for (const d of dirs) {
|
|
98
|
+
const pkg = readJsonIfExists(fs, joinRel(d, "package.json"));
|
|
99
|
+
if (pkg) out.push({ path: d, pkg });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** SDK-internal enumeration: scan the block directory for sub-package
|
|
105
|
+
* `package.json` files. Depth-limited to two levels (covers `block/`,
|
|
106
|
+
* `model/`, … and `software/<name>/`), skipping `node_modules`. */
|
|
107
|
+
function enumerateSdkInternal(fs: FileSystem): WorkspacePackage[] {
|
|
108
|
+
const out: WorkspacePackage[] = [];
|
|
109
|
+
const SKIP = new Set(["node_modules", ".turbo", "dist", ".git", "block-pack"]);
|
|
110
|
+
|
|
111
|
+
function scan(rel: string, depth: number): void {
|
|
112
|
+
const entries = fs.listDir(rel);
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
if (!e.isDirectory || SKIP.has(e.name)) continue;
|
|
115
|
+
const childRel = joinRel(rel, e.name);
|
|
116
|
+
const pkg = readJsonIfExists(fs, joinRel(childRel, "package.json"));
|
|
117
|
+
if (pkg) out.push({ path: childRel, pkg });
|
|
118
|
+
if (depth < 2) scan(childRel, depth + 1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
scan("", 1);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Parse a facade package name `@npmOrg/orgScope.shortName` into BlockVars. */
|
|
126
|
+
export function parseBlockVars(facadeName: string): BlockVars {
|
|
127
|
+
const slash = facadeName.indexOf("/");
|
|
128
|
+
if (!facadeName.startsWith("@") || slash < 0) {
|
|
129
|
+
throw new StructureCliError(
|
|
130
|
+
`Cannot parse block name '${facadeName}': expected '@npm-org/org-scope.short-name'.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const npmOrg = facadeName.slice(0, slash);
|
|
134
|
+
const baseName = facadeName.slice(slash + 1);
|
|
135
|
+
const dot = baseName.indexOf(".");
|
|
136
|
+
if (dot < 0) {
|
|
137
|
+
throw new StructureCliError(
|
|
138
|
+
`Cannot parse block name '${facadeName}': base name '${baseName}' has no '.' separating org-scope from short-name.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
facadeName,
|
|
143
|
+
baseName,
|
|
144
|
+
npmOrg,
|
|
145
|
+
orgScope: baseName.slice(0, dot),
|
|
146
|
+
shortName: baseName.slice(dot + 1),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readVersion(fs: FileSystem): number {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(fs.read(STRUCTURE_META_FILE)) as { version?: number };
|
|
153
|
+
return typeof parsed.version === "number" ? parsed.version : 0;
|
|
154
|
+
} catch {
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type DiscoverInput = {
|
|
160
|
+
/** Block-rooted filesystem (NodeFileSystem for the CLI, MemoryFileSystem
|
|
161
|
+
* for the Layer-2 round-trip). */
|
|
162
|
+
fs: FileSystem;
|
|
163
|
+
isSdkInternal: boolean;
|
|
164
|
+
updateDepsOnly?: boolean;
|
|
165
|
+
dryRun?: boolean;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run DISCOVERY against a block filesystem and return a fully-assembled
|
|
170
|
+
* `RunContext`, ready for `engine.run`. Throws `StructureCliError` /
|
|
171
|
+
* `DiscoveryError` / `StructureVersionFloorError` on a non-block dir,
|
|
172
|
+
* an unclassified package, or a below-floor version.
|
|
173
|
+
*/
|
|
174
|
+
export function discoverRunContext(input: DiscoverInput): RunContext {
|
|
175
|
+
const { fs, isSdkInternal } = input;
|
|
176
|
+
|
|
177
|
+
const packages = isSdkInternal ? enumerateSdkInternal(fs) : enumerateStandalone(fs);
|
|
178
|
+
|
|
179
|
+
if (packages.length === 0) {
|
|
180
|
+
throw new StructureCliError(
|
|
181
|
+
`No workspace packages found. ` + `Run 'block-tools structure init' to scaffold a block.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const modules: Module[] = discoverModules(packages);
|
|
186
|
+
|
|
187
|
+
const version = readVersion(fs);
|
|
188
|
+
assertVersionAboveFloor(version, STRUCTURE_MIN_SUPPORTED);
|
|
189
|
+
|
|
190
|
+
// BlockVars from the block-scope module's name (facade). Fall back to
|
|
191
|
+
// the root module if a block has the root-as-block shape.
|
|
192
|
+
const blockMod =
|
|
193
|
+
modules.find((m) => m.scope === "block") ?? modules.find((m) => m.scope === "root");
|
|
194
|
+
if (!blockMod) {
|
|
195
|
+
throw new StructureCliError(
|
|
196
|
+
`Could not find a 'block' (or root-as-block) package to derive BlockVars from.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const blockVars = parseBlockVars(blockMod.name);
|
|
200
|
+
|
|
201
|
+
return createRunContext({
|
|
202
|
+
isSdkInternal,
|
|
203
|
+
updateDepsOnly: input.updateDepsOnly ?? false,
|
|
204
|
+
modules,
|
|
205
|
+
blockVars,
|
|
206
|
+
version,
|
|
207
|
+
dryRun: input.dryRun ?? false,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Workspace-package classification — the seven scope detection rules.
|
|
2
|
+
//
|
|
3
|
+
// Input: the parsed package.json of every workspace member, plus its
|
|
4
|
+
// block-relative path. Output: `Module[]` (scope + name + path).
|
|
5
|
+
// First-match wins on the detection rules; an explicit `block-scope`
|
|
6
|
+
// override on package.json skips detection.
|
|
7
|
+
|
|
8
|
+
import type { Module, Scope } from "./api";
|
|
9
|
+
|
|
10
|
+
export const ALL_SCOPES: Scope[] = ["root", "block", "model", "ui", "workflow", "test", "software"];
|
|
11
|
+
|
|
12
|
+
export type WorkspacePackage = {
|
|
13
|
+
/** Block-relative path of the package — "" or "." means the
|
|
14
|
+
* workspace root. */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Parsed package.json. */
|
|
17
|
+
pkg: PackageJsonLike;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type PackageJsonLike = {
|
|
21
|
+
name?: string;
|
|
22
|
+
main?: string;
|
|
23
|
+
files?: string[];
|
|
24
|
+
dependencies?: Record<string, string>;
|
|
25
|
+
devDependencies?: Record<string, string>;
|
|
26
|
+
peerDependencies?: Record<string, string>;
|
|
27
|
+
optionalDependencies?: Record<string, string>;
|
|
28
|
+
// Override field — value MUST be one of the seven scopes.
|
|
29
|
+
"block-scope"?: string;
|
|
30
|
+
"block-software"?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class DiscoveryError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
msg: string,
|
|
36
|
+
public readonly path?: string,
|
|
37
|
+
) {
|
|
38
|
+
super(msg);
|
|
39
|
+
this.name = "DiscoveryError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function depKeys(pkg: PackageJsonLike): Set<string> {
|
|
44
|
+
const all = new Set<string>();
|
|
45
|
+
for (const section of [
|
|
46
|
+
pkg.dependencies,
|
|
47
|
+
pkg.devDependencies,
|
|
48
|
+
pkg.peerDependencies,
|
|
49
|
+
pkg.optionalDependencies,
|
|
50
|
+
]) {
|
|
51
|
+
if (section) for (const k of Object.keys(section)) all.add(k);
|
|
52
|
+
}
|
|
53
|
+
return all;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isRoot(p: string): boolean {
|
|
57
|
+
return p === "" || p === ".";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Classify one package.
|
|
62
|
+
*
|
|
63
|
+
* @param wp The workspace package.
|
|
64
|
+
* @param siblingNames Set of all workspace package `name`s (used by
|
|
65
|
+
* rule 4 "block": depends on ≥2 sibling .model/.ui/.workflow).
|
|
66
|
+
* @returns Scope or `undefined` if unclassified.
|
|
67
|
+
*/
|
|
68
|
+
export function classifyOne(wp: WorkspacePackage, siblingNames: Set<string>): Scope | undefined {
|
|
69
|
+
const override = wp.pkg["block-scope"];
|
|
70
|
+
if (typeof override === "string") {
|
|
71
|
+
if (!(ALL_SCOPES as string[]).includes(override)) {
|
|
72
|
+
throw new DiscoveryError(
|
|
73
|
+
`package.json "block-scope" must be one of ${ALL_SCOPES.join(", ")}, got '${override}'`,
|
|
74
|
+
wp.path,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return override as Scope;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Standalone implicit root: the package at path "" is present
|
|
81
|
+
// because pnpm-workspace.yaml lives there, so it IS the workspace root —
|
|
82
|
+
// classify it as `root` unconditionally, BEFORE any detection rule. A
|
|
83
|
+
// nameless root carrying legacy facade cruft (`files: ["index.d.ts",
|
|
84
|
+
// "index.js"]` + block-tools, e.g. sequence-properties) must NOT fall
|
|
85
|
+
// through to the rule-4 block fallback and become a nameless second
|
|
86
|
+
// `block` module — root-as-facade is a forbidden anti-pattern.
|
|
87
|
+
// SDK-internal enumeration never produces a path-"" package (it scans
|
|
88
|
+
// sub-package dirs), so this only ever fires in standalone mode.
|
|
89
|
+
if (isRoot(wp.path)) return "root";
|
|
90
|
+
|
|
91
|
+
const deps = depKeys(wp.pkg);
|
|
92
|
+
|
|
93
|
+
// 1. software — top-level `block-software` field OR
|
|
94
|
+
// (package-builder AND any @platforma-open/*runenv* dep).
|
|
95
|
+
if (wp.pkg["block-software"] !== undefined) return "software";
|
|
96
|
+
if (deps.has("@platforma-sdk/package-builder")) {
|
|
97
|
+
for (const d of deps) {
|
|
98
|
+
if (/^@platforma-open\/.*runenv/.test(d)) return "software";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. workflow — @platforma-sdk/workflow-tengo in deps.
|
|
103
|
+
if (deps.has("@platforma-sdk/workflow-tengo")) return "workflow";
|
|
104
|
+
|
|
105
|
+
// 3. ui — (ui-vue OR vue) AND main is not set.
|
|
106
|
+
if ((deps.has("@platforma-sdk/ui-vue") || deps.has("vue")) && !wp.pkg.main) {
|
|
107
|
+
return "ui";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. block — block-tools in deps AND ≥2 sibling .model/.ui/.workflow.
|
|
111
|
+
if (deps.has("@platforma-sdk/block-tools")) {
|
|
112
|
+
const matches = [...deps].filter(
|
|
113
|
+
(d) =>
|
|
114
|
+
siblingNames.has(d) &&
|
|
115
|
+
(d.endsWith(".model") || d.endsWith(".ui") || d.endsWith(".workflow")),
|
|
116
|
+
);
|
|
117
|
+
if (matches.length >= 2) return "block";
|
|
118
|
+
// Fallback: `files == ["index.d.ts", "index.js"]` + block-tools.
|
|
119
|
+
const files = wp.pkg.files;
|
|
120
|
+
if (
|
|
121
|
+
Array.isArray(files) &&
|
|
122
|
+
files.length === 2 &&
|
|
123
|
+
files.includes("index.d.ts") &&
|
|
124
|
+
files.includes("index.js")
|
|
125
|
+
) {
|
|
126
|
+
return "block";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 5. model — @platforma-sdk/model in deps AND main is set.
|
|
131
|
+
if (deps.has("@platforma-sdk/model") && wp.pkg.main) return "model";
|
|
132
|
+
|
|
133
|
+
// 6. test — @platforma-sdk/test in deps.
|
|
134
|
+
if (deps.has("@platforma-sdk/test")) return "test";
|
|
135
|
+
|
|
136
|
+
// (root — the workspace root, path "", is classified above, before the
|
|
137
|
+
// detection rules, so it can never reach this fallthrough.)
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Classify a whole workspace. Throws on the first unclassified package
|
|
143
|
+
* with a message that directs the operator to add `block-scope` to its
|
|
144
|
+
* package.json.
|
|
145
|
+
*/
|
|
146
|
+
export function discoverModules(packages: WorkspacePackage[]): Module[] {
|
|
147
|
+
const siblingNames = new Set<string>();
|
|
148
|
+
for (const p of packages) {
|
|
149
|
+
if (typeof p.pkg.name === "string") siblingNames.add(p.pkg.name);
|
|
150
|
+
}
|
|
151
|
+
const out: Module[] = [];
|
|
152
|
+
for (const p of packages) {
|
|
153
|
+
const scope = classifyOne(p, siblingNames);
|
|
154
|
+
if (!scope) {
|
|
155
|
+
throw new DiscoveryError(
|
|
156
|
+
`Unclassified workspace package at '${p.path}'. ` +
|
|
157
|
+
`Add a top-level "block-scope" string to its package.json.`,
|
|
158
|
+
p.path,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
out.push({
|
|
162
|
+
scope,
|
|
163
|
+
name: p.pkg.name ?? p.path,
|
|
164
|
+
path: isRoot(p.path) ? "" : p.path,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Flatten: DFS over the tree IR; fan out each scope frame once per
|
|
2
|
+
// matching module in `ctx.modules`. Compose enclosing `when` triggers
|
|
3
|
+
// (AND of all on the path from root). Track which run modes the leaf
|
|
4
|
+
// fires in (its enclosing mode frame, or the `["default", "init"]`
|
|
5
|
+
// default) and emit that as `modes` on the FlatItem. No execution here —
|
|
6
|
+
// execution lives in `runner.ts`.
|
|
7
|
+
|
|
8
|
+
import type { Module, RunContext, Scope } from "./api";
|
|
9
|
+
import type { FlatItem, LeafItem, RunMode, Structure, TreeNode, TriggerFn } from "./ir";
|
|
10
|
+
|
|
11
|
+
/** Modes a leaf fires in when it sits outside any mode frame: default
|
|
12
|
+
* refresh/check plus init. (`onUpdateDeps` / `onInitOrUpdate` frames
|
|
13
|
+
* override this for their children.) */
|
|
14
|
+
const DEFAULT_MODES: RunMode[] = ["default", "init"];
|
|
15
|
+
|
|
16
|
+
function resolveRelative(modulePath: string, fileRel: string): string {
|
|
17
|
+
if (!modulePath) return fileRel;
|
|
18
|
+
if (!fileRel) return modulePath;
|
|
19
|
+
// Strip trailing slash on modulePath, leading slash on fileRel.
|
|
20
|
+
const m = modulePath.endsWith("/") ? modulePath.slice(0, -1) : modulePath;
|
|
21
|
+
const f = fileRel.startsWith("/") ? fileRel.slice(1) : fileRel;
|
|
22
|
+
return f ? `${m}/${f}` : m;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Lexicographic sort of modules by `path`.
|
|
27
|
+
* Used for deterministic fan-out order.
|
|
28
|
+
*/
|
|
29
|
+
function modulesByScope(modules: Module[], scope: Scope): Module[] {
|
|
30
|
+
return modules
|
|
31
|
+
.filter((m) => m.scope === scope)
|
|
32
|
+
.slice()
|
|
33
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function flatten(structure: Structure, ctx: RunContext): FlatItem[] {
|
|
37
|
+
const out: FlatItem[] = [];
|
|
38
|
+
|
|
39
|
+
function visit(
|
|
40
|
+
node: TreeNode,
|
|
41
|
+
boundScope: Scope | undefined,
|
|
42
|
+
boundModulePath: string | undefined,
|
|
43
|
+
triggers: TriggerFn[],
|
|
44
|
+
modes: RunMode[],
|
|
45
|
+
): void {
|
|
46
|
+
if (node.kind === "scope") {
|
|
47
|
+
const matches = modulesByScope(ctx.modules, node.scope);
|
|
48
|
+
for (const m of matches) {
|
|
49
|
+
for (const child of node.children) {
|
|
50
|
+
visit(child, node.scope, m.path, triggers, modes);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (node.kind === "when") {
|
|
56
|
+
const nextTriggers = triggers.concat(node.trigger);
|
|
57
|
+
for (const child of node.children) {
|
|
58
|
+
visit(child, boundScope, boundModulePath, nextTriggers, modes);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (node.kind === "mode") {
|
|
63
|
+
for (const child of node.children) {
|
|
64
|
+
visit(child, boundScope, boundModulePath, triggers, node.modes);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Leaf.
|
|
69
|
+
if (boundScope === undefined || boundModulePath === undefined) {
|
|
70
|
+
throw new Error(`flatten: leaf '${describeLeaf(node)}' is not inside a scope() frame`);
|
|
71
|
+
}
|
|
72
|
+
if (node.kind === "rename") {
|
|
73
|
+
out.push({
|
|
74
|
+
leaf: node,
|
|
75
|
+
scope: boundScope,
|
|
76
|
+
modulePath: boundModulePath,
|
|
77
|
+
resolvedPath: resolveRelative(boundModulePath, node.from),
|
|
78
|
+
resolvedTo: resolveRelative(boundModulePath, node.to),
|
|
79
|
+
triggers,
|
|
80
|
+
modes,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
out.push({
|
|
85
|
+
leaf: node,
|
|
86
|
+
scope: boundScope,
|
|
87
|
+
modulePath: boundModulePath,
|
|
88
|
+
resolvedPath: resolveRelative(boundModulePath, leafPath(node)),
|
|
89
|
+
triggers,
|
|
90
|
+
modes,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const node of structure.children) {
|
|
95
|
+
visit(node, undefined, undefined, [], DEFAULT_MODES);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function leafPath(leaf: LeafItem): string {
|
|
102
|
+
switch (leaf.kind) {
|
|
103
|
+
case "fixed":
|
|
104
|
+
case "managed":
|
|
105
|
+
case "scaffold":
|
|
106
|
+
case "seed":
|
|
107
|
+
case "remove":
|
|
108
|
+
return leaf.path;
|
|
109
|
+
case "rename":
|
|
110
|
+
return leaf.from;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function describeLeaf(leaf: LeafItem): string {
|
|
115
|
+
if (leaf.kind === "rename") return `rename ${leaf.from} -> ${leaf.to}`;
|
|
116
|
+
return `${leaf.kind} ${leaf.path}`;
|
|
117
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Small filesystem abstraction used by the engine. Two implementations:
|
|
2
|
+
// - memory.ts: in-memory map, used by tests and the post-run recheck
|
|
3
|
+
// dry-run.
|
|
4
|
+
// - node.ts: thin wrapper around node:fs (synchronous).
|
|
5
|
+
// Paths are always block-relative ("/"-separated) — the impls anchor
|
|
6
|
+
// them against a configured root.
|
|
7
|
+
//
|
|
8
|
+
// The interface is SYNCHRONOUS. The structurer is a one-shot CLI, so
|
|
9
|
+
// blocking fs calls (readFileSync, …) carry no event-loop cost, and a
|
|
10
|
+
// sync engine keeps the module-global run context race-free by
|
|
11
|
+
// construction (a sync call stack cannot interleave).
|
|
12
|
+
|
|
13
|
+
/** One shallow child of a directory (file or sub-directory). */
|
|
14
|
+
export type DirEntry = {
|
|
15
|
+
name: string;
|
|
16
|
+
isDirectory: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface FileSystem {
|
|
20
|
+
/** Read file as utf-8 string. Throws on missing file. */
|
|
21
|
+
read(path: string): string;
|
|
22
|
+
/** Write file (utf-8). Creates parent dirs as needed. Overwrites. */
|
|
23
|
+
write(path: string, content: string): void;
|
|
24
|
+
/** Write raw bytes. Creates parent dirs as needed. Overwrites. For binary
|
|
25
|
+
* assets (logos, images) that must not be utf-8 encoded. */
|
|
26
|
+
writeBinary(path: string, content: Uint8Array): void;
|
|
27
|
+
/** Path exists (file or directory). */
|
|
28
|
+
exists(path: string): boolean;
|
|
29
|
+
/** Recursive listing under a directory (relative paths, files only).
|
|
30
|
+
* Empty array if directory missing. */
|
|
31
|
+
list(dir: string): string[];
|
|
32
|
+
/** Shallow listing of a directory's immediate children (files +
|
|
33
|
+
* sub-dirs). Empty array if the directory is missing. Used by
|
|
34
|
+
* filesystem-backed DISCOVERY. */
|
|
35
|
+
listDir(dir: string): DirEntry[];
|
|
36
|
+
/** Move a file or directory. Source must exist; dest must not. */
|
|
37
|
+
move(from: string, to: string): void;
|
|
38
|
+
/** Delete a file or directory recursively. Missing is no-op. */
|
|
39
|
+
delete(path: string): void;
|
|
40
|
+
}
|