Skip to content

Commit 169ff7e

Browse files
feat(cli): os validate --target judges the stack against a target environment's restriction profile (#31237)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiLhXvGV9xRtyZvH3Pjto2JA)_ Fixes #31156 Clause-②: yes Metadata-protocol: none ## 维护者速读 - 改了什么:`os validate` 新增 `--target PROFILE_FILE`。它读取目标环境的限制配置(第 1 项已合入的 `RestrictionProfileSchema`),把本地元数据里该环境不允许的东西逐条拒掉,每条写明是哪一项、被哪条规则拒(写代码、定时任务、流程节点类型);本地 spec / skills 版本和目标环境不一致时只告警,不拒。 - 为什么改:云端与本地唯一的区别就是这份限制配置(cloud#3659 裁决 A)。开发者在部署前就能知道"这份元数据在目标环境会被拒",而不是部署后才发现。 - 风险与回滚:不带 `--target` 时命令行为完全不变(有对照测试)。只改 `packages/cli`,patch 版本。回滚就是还原这个 PR。另外:`--target` 目前只接受文件,传 URL 会被明确拒绝,因为目标环境还没有定义发布限制配置的地址(第 3 项在 cloud 仓)。 - 先看:`packages/cli/src/utils/restriction-profile.ts`(三条读者规则在 `judgeNodeType`);`packages/cli/src/commands/validate.ts` 的 step 0 / 1c / 3f;`packages/cli/test/validate-target-restriction-profile.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run src/utils/restriction-profile.test.ts test/validate-target-restriction-profile.test.ts`,预期 30 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes `os validate --target PROFILE_FILE` judges the stack against a target environment's restriction profile. Without `--target` nothing new runs. **Step 0, the profile.** `loadRestrictionProfile` reads the file and parses it with `RestrictionProfileSchema` (`@objectstack/spec/system`, PR #31191). It runs before the stack is loaded. A URL, a missing file, a file that is not JSON, or a profile the schema refuses is refused through the catch-all (`--json`: `error`, exit 1), and the message names the schema issue path. `{}` is refused, so an absent dimension never reads as "no restriction". **Step 1c, version drift.** `restrictionProfileVersionDrift` compares the project's `@objectstack/spec` and `@objectstack/skills` with `versions.spec` / `versions.skills`. They are resolved from the config's directory; spec falls back to the CLI's own copy, the way the protocol drift advisory resolves it. Each package that differs gives one warning (`package`, `local`, `target`, `message`). A project that does not install the skills catalog gets `local: null`. The warnings ride `--json` `warnings` (appended last in `warningsSoFar()`), and the text face prints them in their own block. They never refuse, and `--strict` does not gate them: the target's version is the environment's, not something the stack can fix, the same reason the protocol drift advisory is never gated by `--strict`. **Step 3f, the refusals.** `judgeRestrictionProfile` runs after every other refusal. Those gates ask whether any runtime accepts the stack; this one asks whether this target does. It gives one row per refused item: `rule` (the ONE profile dimension), `where` (the item, named), `path` and `message`. It walks each `packages[]` entry, or the stack and its `manifest` when there are no packages, the way the CLI's other walks do. - `allowCode: false` refuses each `hooks` entry (the profile docblock names hooks) and each item under a key the spec classifies as code: `UNMANAGED_INSTALL_KEY_DISPOSITIONS` disposition `code`, the one table `defineStack`'s unmanaged-install refusal and the install planner already share (`functions` per function, `plugins` / `devPlugins` per entry, the others per key, on the stack or its manifest). An empty key (`functions: {}`) refuses nothing, by the same `unmanagedInstallValueCarries` test. - `allowScheduledAutomation: false` refuses each flow of `type: 'schedule'` and each `jobs` entry. - `allowedFlowNodeTypes` refuses each node, at every region depth (`collectFlowGraphs`), whose built-in type is not listed. Reader rules, from the contract review of item 1 (record 6710350208 ③/④, restated in the claim): 1. `start` / `end` (`FLOW_STRUCTURAL_NODE_TYPES`) are always allowed. 2. A node type outside `FlowNodeAction` (`FLOW_BUILTIN_NODE_TYPES`) cannot be named by `allowedFlowNodeTypes`, so `allowCode` governs it. Under `allowCode: false` it is refused naming that rule. Under `true` it is allowed. 3. A `script` node under `allowCode: false` is refused by `allowCode` alone, whether or not `allowedFlowNodeTypes` lists it. Under `true` it is judged by `allowedFlowNodeTypes`. **URL form.** No route for an environment's published profile is defined in this repository: no `restriction` route appears in the spec route ledger or under `packages/spec/src/api`. Card 3 (objectstack-ai/cloud#3713) is in the cloud repository, which this session cannot read. So the URL form is not guessed. A URL is refused, and the message names the file form as the remedy. Once card 3 publishes an endpoint, the URL form is a follow-up. The gate-parity ledger (`test/validate-build-gate-parity.test.ts`) gains `judgeRestrictionProfile` under `VALIDATE_ONLY_GATES` (`os build` names no target, so there is no profile for it to judge against), and the other two calls under a `NOT_A_GATE` reason. ## Tests - `src/utils/restriction-profile.test.ts` (21): a CONTROL all-open profile; each dimension (allowCode over a hook, the functions, a plugin, a script node and a plugin node; an empty code key; a manifest code key; allowScheduledAutomation over a job and a scheduled flow; allowedFlowNodeTypes at the top level and inside a loop body); the `packages[]` walk; the three reader rules (start/end under an empty list; `approval` under allowCode false and true; `script` naming one rule, and judged by the list under allowCode true); the loader (valid, invalid naming the issue path, `{}`, not JSON, missing, URL); version drift (CONTROL equal, one package differing, skills not installed). - `test/validate-target-restriction-profile.test.ts` (9, in-process `Validate.run`): CONTROL without `--target` (exit 0, no drift entry); CONTROL all-open profile; one refusal per dimension through `--json`; the text face; version drift as a warning (exit 0, and `--strict` with the drifted target reads the same exit as `--strict` with no target); an invalid profile; a URL. <details><summary>Ablations (one-time, scripts/ablation-replace.mjs, each restored to blob 11f11e5 == HEAD with git diff HEAD empty; the tests import src, so no dist is on the path)</summary> - Reader rule 3 disabled (`type === 'script'` → `'ABLATED_script'`): 3 failed / 27 passed (the command's allowCode row set, the reader's allowCode row set, "script names ONE rule"). - Scheduled flow check disabled (`flow.type === 'schedule'` → `'ABLATED_schedule'`): 3 failed / 27 passed (the command's allowScheduledAutomation row, its text face, the reader's schedule row set). - Reader rule 1 deleted (the `FLOW_STRUCTURAL_NODE_TYPES` early return): 5 failed / 25 passed (every allowedFlowNodeTypes case now also refuses `start` / `end`). </details> ## Acceptance notes - **Engine built-ins outside `FlowNodeAction`.** `parallel` and `try_catch` (ADR-0031 containers) are registered by `@objectstack/service-automation`'s own builtins (`src/builtin/parallel-node.ts`, `try-catch-node.ts`), but they are not `FlowNodeAction` ids. Reader rule 2, as ruled, therefore refuses them under `allowCode: false` (the message says only that the type is outside the registry, not that it is a plugin), and no profile can list them. This is fail-closed, not silent. Raised as an open question for the spec lane in the report. - `devPlugins` is refused under `allowCode: false` because the spec's code table classifies it `code`, although it is a dev-only overlay. Kept literal to the one table rather than carving out a second list. Noted in the report. - `allowCode` reads exactly what the profile docblock and the code table name. JS bodies elsewhere (a job's or an action's sandboxed `body`) are not judged by `allowCode`: a job is still refused by `allowScheduledAutomation`, and an action's `target` function is refused through `functions`. Noted, not filed. - The optional one-sentence `allowedFlowNodeTypes` docblock note in `packages/spec` (review 6710350208 ④) is not ridden here: the claim keeps `packages/spec` out. Listed as an open question in the report. - In the vitest environment `pnpm exec` sets `NODE_PATH` to the workspace's hoisted store, so a tmp-dir fixture resolves the workspace `@objectstack/skills`. The command test therefore installs its own skills manifest at a known version, and the not-installed case is pinned on the reader with a tree whose resolution is controlled. ## Verification The derived gate list below was taken on dcbb8da, based on `66bf5b4a`. `origin/main` then moved to `df22adc37` and was merged once before opening (head 8c5e74a). The incoming commits touch `packages/spec` (docs, migration entries) and one unrelated `packages/cli` test, so the scoped re-checks ran on 8c5e74a: `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated` exit 0 (all 14 artifacts up to date); the two new test files plus `test/validate-build-gate-parity.test.ts`, Test Files 3 passed, Tests 63 passed; `pnpm --filter @objectstack/cli typecheck` exit 0. The branch delta against `origin/main` is still exactly the six files of this PR. Before the merge, 651989c added only a one-line changeset wording change (an angle-bracket placeholder spelled as a path), and the families that read the changeset were rerun on it: `node scripts/check-adr-0087-registration.mjs --base origin/main`, `node scripts/check-changeset-no-major.mjs --base origin/main`, `node scripts/check-empty-changeset.mjs --base origin/main`, `pnpm check:changeset-gate-self-tests`, `pnpm check:doc-authoring`, `pnpm check:issue-citations`, `pnpm check:nul-bytes` and `node scripts/check-closing-keyword-parity.mjs`, each exit 0. <details><summary>Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 62 commands)</summary> All on dcbb8da: - `node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0` - `node scripts/check-adr-0087-registration.mjs --self-test :: exit 0` - `node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0` - `node scripts/check-closing-keyword-parity.mjs :: exit 0` - `node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0` - `node scripts/check-comment-mask-adoption.mjs :: exit 0` - `node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0` - `node scripts/check-comment-mask-corpus.mjs :: exit 0` - `node scripts/check-dts-emitted.mjs --self-test :: exit 0` - `node scripts/check-empty-changeset.mjs --base origin/main :: exit 0` - `node scripts/check-empty-changeset.mjs --self-test :: exit 0` - `node scripts/check-issue-citations.mjs :: exit 0` - `node scripts/check-plugin-teardown-shape.mjs :: exit 0` - `node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0` - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0` - `node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0` - `node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0` - `node scripts/ci/snapshot-next.mjs --self-test :: exit 0` - `node scripts/docs-audit/check-affected-docs.mjs :: exit 0` - `node scripts/docs-audit/check-drift-comment.mjs :: exit 0` - `node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0` - `node scripts/release-pending-publish.mjs --self-test :: exit 0` - `pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0` - `pnpm check:changeset-gate-self-tests :: exit 0` - `pnpm check:ci-filter-parity :: exit 0` - `pnpm check:cli-test-child-env :: exit 0` - `pnpm check:closed-ceremony-text :: exit 0` - `pnpm check:cross-package-test-inputs :: exit 0` - `pnpm check:doc-authoring :: exit 0` - `pnpm check:driver-memory-census :: exit 0` - `pnpm check:dts-closure :: exit 0` - `pnpm check:dual-build-cjs-loads :: exit 0` - `pnpm check:engine-double-contract :: exit 0` - `pnpm check:gitlink-declared :: exit 0` - `pnpm check:i18n :: exit 0` - `pnpm check:i18n-coverage :: exit 0` - `pnpm check:i18n-walk-parity :: exit 0` - `pnpm check:issue-citations :: exit 0` - `pnpm check:keyed-text-bounds :: exit 0` - `pnpm check:lean-entry-closure :: exit 0` - `pnpm check:logger-receiver-detach :: exit 0` - `pnpm check:nul-bytes :: exit 0` - `pnpm check:objectql-double-limit :: exit 0` - `pnpm check:org-identifier :: exit 0` - `pnpm check:page-declaration-shape :: exit 0` - `pnpm check:platform-object-tenancy-census :: exit 0` - `pnpm check:published-files :: exit 0` - `pnpm check:query-options-erasure :: exit 0` - `pnpm check:refd-timer-probe :: exit 0` - `pnpm check:registry-log-declared :: exit 0` - `pnpm check:rest-log-spy-declared :: exit 0` - `pnpm check:slot-lookup :: exit 0` - `pnpm check:sourcemap-no-sources-content :: exit 0` - `pnpm check:system-context-census :: exit 0` - `pnpm check:test-source-alias :: exit 0` - `pnpm check:tier-file-adoption :: exit 0` - `pnpm check:type-check-coverage :: exit 0` - `pnpm check:type-check-debt :: exit 0` - `pnpm check:undeclared-dep-imports :: exit 0` - `pnpm check:vouch-for-reference-callers :: exit 0` - `pnpm check:watch-hint-literal :: exit 0` - `pnpm check:where-matcher :: exit 0` On the first pass (head 3f6ecdb), six were not green and were fixed or rerun: - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 3: the shallow clone did not hold the positive-control commit 621a487. It was fetched, then exit 0. - `pnpm check:dual-build-cjs-loads`, `pnpm check:i18n`, `pnpm check:i18n-coverage`, `pnpm check:i18n-walk-parity` exit 3: their prerequisites were not built. `turbo run build --filter='./packages/**' --filter='./examples/**'` ran (82/82 tasks), then each exited 0. - `pnpm check:test-source-alias` exit 1: the new command test reached `@objectstack/objectql/core` and `@objectstack/plugin-security` through the source's dynamic imports. Module-top imports were added (dcbb8da), then exit 0. Reconciliation: `node scripts/pm/dispatch-gates.mjs --ran` with the exit-coded record gives 62 derived, 62 run, 0 NOT-MEASURED (a derived zero: all 62 recorded an exit code). Package, on dcbb8da: - `pnpm --filter @objectstack/cli typecheck` exit 0 (tsc + check:test-typecheck); - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` VERDICT command-exit 0: Test Files 400 passed (400), Tests 5653 passed (5653). The first full run (3f6ecdb) found `validate-build-gate-parity.test.ts` "every call site … is classified" red on the three new names. They were classified in dcbb8da. Its two `published-subpath-*` suite errors were "packages/cli is not built" and cleared after the build; - `node --stack-size=4000 node_modules/eslint/bin/eslint.js packages/cli --no-inline-config` exit 0; - `node scripts/check-nul-bytes.mjs` exit 0. The integration tier (`--project integration`) is declared to CI: the diff touches no spawn entry and no integration-tier file. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint over packages/cli, `--no-inline-config`, exit 0.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (`@objectstack/cli` patch; `Metadata-protocol: none` grades patch.) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2fKNXpyrradyNkX9jx)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a0b40f2 commit 169ff7e

6 files changed

Lines changed: 929 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os validate --target ./restriction-profile.json` judges the stack against a target environment's restriction profile
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
A hosted environment differs from a local one only by the restriction profile it publishes (`RestrictionProfileSchema`, `@objectstack/spec/system`). `os validate --target ./restriction-profile.json` reads that profile, parses it with the spec's schema (an invalid profile is refused, naming the schema issue), and refuses every item of the stack the profile forbids — one row per item, each naming the item and the one profile dimension that refuses it:
11+
12+
- `allowCode: false` refuses each object lifecycle hook, each named handler function, each code plugin and the other keys the spec classifies as code, a `script` flow node, and a flow node whose type is not in the spec's built-in node registry (`FlowNodeAction`), which `allowedFlowNodeTypes` cannot name;
13+
- `allowScheduledAutomation: false` refuses each flow of `type: 'schedule'` and each background job;
14+
- `allowedFlowNodeTypes` refuses each flow node, at any region depth, whose built-in type is not listed; `start` and `end` are always allowed.
15+
16+
When the project's `@objectstack/spec` or `@objectstack/skills` is not the version the target runs, the command warns (`--json`: an entry in `warnings` with `package`, `local` and `target`); it does not refuse, and `--strict` does not gate it. Without `--target` the command is unchanged. Only a profile file is accepted: a URL is refused, because no environment publishes its profile at a defined address yet.

‎packages/cli/src/commands/validate.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js';
9191
// reported when the declaring package depends on packages outside the stack.
9292
// Walked the way the load path registers, like the refusal above.
9393
import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js';
94+
// `--target`: a target environment's restriction profile (`RestrictionProfileSchema`,
95+
// `@objectstack/spec/system`) — what that environment refuses, and the versions it runs.
96+
import {
97+
loadRestrictionProfile,
98+
judgeRestrictionProfile,
99+
restrictionProfileVersionDrift,
100+
type ParsedRestrictionProfile,
101+
type RestrictionVersionDrift,
102+
} from '../utils/restriction-profile.js';
94103

95104
export default class Validate extends Command {
96105
static override description =
@@ -103,6 +112,11 @@ export default class Validate extends Command {
103112
static override flags = {
104113
strict: Flags.boolean({ description: 'Treat warnings as errors' }),
105114
json: Flags.boolean({ description: 'Output results as JSON' }),
115+
target: Flags.string({
116+
description:
117+
'Also judge the stack against a target environment\'s restriction profile (a JSON file): refuse what '
118+
+ 'it forbids, warn when the local @objectstack/spec / @objectstack/skills version differs from the target\'s',
119+
}),
106120
};
107121

108122
async run(): Promise<void> {
@@ -206,6 +220,13 @@ export default class Validate extends Command {
206220
// permission-set lists: reports, never refuses, NOT in `--strict`'s list,
207221
// and `os compile` computes the identical records.
208222
let dependencyRangeWarnings: DependencyRangeDiagnostic[] = [];
223+
// `--target` only (step 1c): the local `@objectstack/spec` / `@objectstack/skills`
224+
// versions that are not the ones the target's restriction profile says it
225+
// runs. Same class as the list above — reports, never refuses, and is NOT in
226+
// the `warnings` list `--strict` reads: the target's version is the
227+
// environment's, not something this stack can fix, the reason the protocol
228+
// drift advisory is never gated by `--strict` either. Empty without `--target`.
229+
let targetVersionWarnings: RestrictionVersionDrift[] = [];
209230
const warningsSoFar = () => [
210231
...ruleAdvisories,
211232
...docWarnings,
@@ -228,6 +249,8 @@ export default class Validate extends Command {
228249
...picklistReferenceNotices,
229250
// APPENDED for the same reason, one member later again.
230251
...dependencyRangeWarnings,
252+
// APPENDED for the same reason, one member later again.
253+
...targetVersionWarnings,
231254
];
232255
// [commit 79cf692b0] The ADR-0087 D2 conversion notices, hoisted for the SAME reason
233256
// and under the SAME ruling as the five lists above — one field over. The
@@ -264,6 +287,19 @@ export default class Validate extends Command {
264287
const conversionNotices: ConversionNotice[] = [];
265288

266289
try {
290+
// 0. `--target`: read the target environment's restriction profile FIRST,
291+
// before the stack — an unreadable or invalid profile is refused
292+
// (thrown into the catch-all: `--json` gets `error`, exit 1) whatever
293+
// the stack holds, so no run ever judges against half a profile. No
294+
// `--target`, no profile: nothing below that reads it runs, and the
295+
// command is exactly what it was.
296+
let targetProfile: ParsedRestrictionProfile | undefined;
297+
if (flags.target !== undefined) {
298+
const loadedProfile = loadRestrictionProfile(flags.target);
299+
targetProfile = loadedProfile.profile;
300+
if (!flags.json) printKV('Target profile', loadedProfile.file);
301+
}
302+
267303
// 1. Load configuration
268304
if (!flags.json) printStep('Loading configuration...');
269305
const loaded = await loadConfig(args.config);
@@ -291,6 +327,18 @@ export default class Validate extends Command {
291327
printKV('Load time', `${duration}ms`);
292328
}
293329

330+
// 1c. `--target`: the version drift between this project and the target.
331+
// Warns, never refuses (see `targetVersionWarnings` above). Resolved
332+
// from the config's directory — the project being validated.
333+
if (targetProfile) {
334+
targetVersionWarnings = restrictionProfileVersionDrift(targetProfile, dirname(absolutePath));
335+
if (targetVersionWarnings.length > 0 && !flags.json) {
336+
console.log('');
337+
printWarning(`Version drift from the target environment (${targetVersionWarnings.length})`);
338+
printBulletList(targetVersionWarnings.map((w) => w.message), { noun: 'version drift warning' });
339+
}
340+
}
341+
294342
// 2. Normalize map-formatted stack definition and validate against schema.
295343
// The ADR-0087 D2 conversion layer runs here (inside normalizeStackInput);
296344
// surface each applied conversion as a non-blocking deprecation notice so
@@ -1054,6 +1102,37 @@ export default class Validate extends Command {
10541102
printBulletList(dependencyRangeWarnings.map(formatDependencyRangeDiagnostic), { noun: 'dependency-range diagnostic' });
10551103
}
10561104

1105+
// 3f. `--target`: what the target environment's restriction profile
1106+
// forbids. LAST of the refusals on purpose: every gate above asks
1107+
// whether ANY runtime accepts the stack, this one asks whether THIS
1108+
// target does, and the first question is the one worth answering
1109+
// first. One row per refused item, each naming the item and the one
1110+
// profile dimension that refuses it (`utils/restriction-profile.ts`
1111+
// holds the reader rules).
1112+
if (targetProfile) {
1113+
const restrictionRefusals = judgeRestrictionProfile(result.data as Record<string, unknown>, targetProfile);
1114+
if (restrictionRefusals.length > 0) {
1115+
if (flags.json) {
1116+
await emitJson({
1117+
valid: false,
1118+
errors: restrictionRefusals,
1119+
warnings: warningsSoFar(),
1120+
conversions: conversionNotices,
1121+
duration: timer.elapsed(),
1122+
});
1123+
this.exit(1);
1124+
}
1125+
const n = restrictionRefusals.length;
1126+
console.log('');
1127+
printError(`The target environment's restriction profile refuses this stack (${n} item${n > 1 ? 's' : ''})`);
1128+
printBulletList(
1129+
restrictionRefusals.map((r) => `[${r.rule}] ${r.where}: ${r.message}`),
1130+
{ noun: 'restriction refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
1131+
);
1132+
this.exit(1);
1133+
}
1134+
}
1135+
10571136
// 4. Collect and display stats
10581137
const stats = collectMetadataStats(config);
10591138

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The reader of a target environment's restriction profile
5+
* (`restriction-profile.ts`): one refusal per profile dimension, the three
6+
* reader rules, the version drift warning, and an invalid profile refused.
7+
* `test/validate-target-restriction-profile.test.ts` holds the command's door.
8+
*/
9+
10+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11+
import { tmpdir } from 'node:os';
12+
import { join } from 'node:path';
13+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
14+
import { FLOW_BUILTIN_NODE_TYPES } from '@objectstack/spec/automation';
15+
16+
import {
17+
judgeRestrictionProfile,
18+
loadRestrictionProfile,
19+
restrictionProfileVersionDrift,
20+
type ParsedRestrictionProfile,
21+
} from './restriction-profile.js';
22+
23+
/** Everything on: every built-in node type listed, code and schedules allowed. */
24+
const OPEN: ParsedRestrictionProfile = {
25+
allowCode: true,
26+
allowScheduledAutomation: true,
27+
allowedFlowNodeTypes: [...FLOW_BUILTIN_NODE_TYPES] as ParsedRestrictionProfile['allowedFlowNodeTypes'],
28+
versions: { spec: '18.4.0', skills: '18.4.0' },
29+
};
30+
const profile = (patch: Partial<ParsedRestrictionProfile>): ParsedRestrictionProfile => ({ ...OPEN, ...patch });
31+
32+
const node = (id: string, type: string, extra: Record<string, unknown> = {}) => ({ id, type, label: id, ...extra });
33+
const flow = (name: string, nodes: unknown[], extra: Record<string, unknown> = {}) => ({
34+
name, label: name, type: 'autolaunched', nodes, edges: [], ...extra,
35+
});
36+
37+
/** One item per dimension, plus the reader-rule cases. */
38+
const STACK = {
39+
manifest: { id: 'com.example.rp', namespace: 'rp', version: '1.0.0', name: 'Rp', type: 'app' },
40+
hooks: [{ name: 'rp_audit', object: 'rp_doc', events: ['beforeInsert'], handler: 'rp_audit_fn' }],
41+
functions: { rp_audit_fn: 'rp_audit_fn', rp_calc: 'rp_calc' },
42+
plugins: ['@acme/rp-plugin'],
43+
jobs: [{ name: 'rp_sweep', schedule: { type: 'interval', intervalMs: 60_000 }, handler: 'rp_calc' }],
44+
flows: [
45+
flow('rp_nightly', [node('start', 'start'), node('end', 'end')], { type: 'schedule' }),
46+
flow('rp_route', [
47+
node('start', 'start'),
48+
node('fetch', 'get_record'),
49+
node('call', 'http'),
50+
node('calc', 'script', { config: { function: 'rp_calc' } }),
51+
node('ok', 'approval'),
52+
node('end', 'end'),
53+
]),
54+
],
55+
};
56+
57+
const rowsOf = (p: ParsedRestrictionProfile, stack: Record<string, unknown> = STACK) =>
58+
judgeRestrictionProfile(stack, p).map(({ rule, where, path }) => ({ rule, where, path }));
59+
60+
describe('judgeRestrictionProfile — one refusal per dimension', () => {
61+
it('CONTROL: a profile that allows everything refuses nothing', () => {
62+
expect(rowsOf(OPEN)).toEqual([]);
63+
});
64+
65+
it('allowCode: false refuses each hook, each function and each plugin — every row naming allowCode', () => {
66+
const rows = rowsOf(profile({ allowCode: false }));
67+
expect(rows).toEqual([
68+
{ rule: 'allowCode', where: "hook 'rp_audit'", path: 'hooks[0]' },
69+
{ rule: 'allowCode', where: "function 'rp_audit_fn'", path: 'functions.rp_audit_fn' },
70+
{ rule: 'allowCode', where: "function 'rp_calc'", path: 'functions.rp_calc' },
71+
{ rule: 'allowCode', where: "plugin '@acme/rp-plugin'", path: 'plugins[0]' },
72+
{ rule: 'allowCode', where: "flow 'rp_route' › node 'calc' (script)", path: 'flows[1].nodes[3]' },
73+
{ rule: 'allowCode', where: "flow 'rp_route' › node 'ok' (approval)", path: 'flows[1].nodes[4]' },
74+
]);
75+
});
76+
77+
it('allowCode: false — an empty code key carries nothing and refuses nothing', () => {
78+
const stack = { manifest: STACK.manifest, functions: {}, plugins: [] };
79+
expect(rowsOf(profile({ allowCode: false }), stack)).toEqual([]);
80+
});
81+
82+
it('allowCode: false — a code key on the manifest is refused under its manifest path', () => {
83+
const stack = { manifest: { ...STACK.manifest, main: 'dist/index.mjs' } };
84+
expect(rowsOf(profile({ allowCode: false }), stack)).toEqual([
85+
{ rule: 'allowCode', where: '`main`', path: 'manifest.main' },
86+
]);
87+
});
88+
89+
it('allowScheduledAutomation: false refuses the scheduled flow and the job, naming that rule', () => {
90+
expect(rowsOf(profile({ allowScheduledAutomation: false }))).toEqual([
91+
{ rule: 'allowScheduledAutomation', where: "job 'rp_sweep'", path: 'jobs[0]' },
92+
{ rule: 'allowScheduledAutomation', where: "flow 'rp_nightly'", path: 'flows[0]' },
93+
]);
94+
});
95+
96+
it('allowedFlowNodeTypes refuses each node whose built-in type is not listed', () => {
97+
const rows = rowsOf(profile({ allowedFlowNodeTypes: ['get_record', 'script'] }));
98+
expect(rows).toEqual([
99+
{ rule: 'allowedFlowNodeTypes', where: "flow 'rp_route' › node 'call' (http)", path: 'flows[1].nodes[2]' },
100+
]);
101+
});
102+
103+
it('allowedFlowNodeTypes reaches a node inside a region, at its region path', () => {
104+
const stack = {
105+
manifest: STACK.manifest,
106+
flows: [flow('rp_deep', [
107+
node('start', 'start'),
108+
node('each', 'loop', { config: { body: { nodes: [node('send', 'notify')], edges: [] } } }),
109+
node('end', 'end'),
110+
])],
111+
};
112+
expect(rowsOf(profile({ allowedFlowNodeTypes: ['loop'] }), stack)).toEqual([
113+
{ rule: 'allowedFlowNodeTypes', where: "flow 'rp_deep' › loop 'each' body › node 'send' (notify)", path: 'flows[0].nodes[1].config.body.nodes[0]' },
114+
]);
115+
});
116+
117+
it('walks each packages[] entry of an assembled artifact, at that entry\'s path', () => {
118+
const stack = {
119+
manifest: STACK.manifest,
120+
packages: [
121+
{ manifest: { id: 'com.example.rp', name: 'Rp', hooks: [] } },
122+
{ manifest: { id: 'com.example.rp.ops', name: 'Ops', hooks: STACK.hooks, jobs: STACK.jobs } },
123+
],
124+
};
125+
expect(rowsOf(profile({ allowCode: false, allowScheduledAutomation: false }), stack)).toEqual([
126+
{ rule: 'allowCode', where: "hook 'rp_audit'", path: 'packages[1].manifest.hooks[0]' },
127+
{ rule: 'allowScheduledAutomation', where: "job 'rp_sweep'", path: 'packages[1].manifest.jobs[0]' },
128+
]);
129+
});
130+
});
131+
132+
describe('judgeRestrictionProfile — the reader rules', () => {
133+
it('start / end are always allowed, even by a profile that lists no node type', () => {
134+
const stack = { manifest: STACK.manifest, flows: [flow('rp_bare', [node('start', 'start'), node('end', 'end')])] };
135+
expect(rowsOf(profile({ allowedFlowNodeTypes: [] }), stack)).toEqual([]);
136+
});
137+
138+
it('a type outside FlowNodeAction is governed by allowCode: refused naming allowCode when it is false', () => {
139+
const rows = judgeRestrictionProfile(STACK, profile({ allowCode: false })).filter((r) => r.where.includes('(approval)'));
140+
expect(rows.map((r) => r.rule)).toEqual(['allowCode']);
141+
expect(rows[0]!.message).toContain('allowedFlowNodeTypes` cannot name it');
142+
});
143+
144+
it('a type outside FlowNodeAction is allowed when allowCode is true, whatever allowedFlowNodeTypes lists', () => {
145+
const rows = rowsOf(profile({ allowedFlowNodeTypes: ['get_record', 'http', 'script'] }));
146+
expect(rows.filter((r) => r.where.includes('(approval)'))).toEqual([]);
147+
});
148+
149+
it('a script node under allowCode: false is refused naming ONE rule, also when allowedFlowNodeTypes omits it', () => {
150+
const rows = rowsOf(profile({ allowCode: false, allowedFlowNodeTypes: ['get_record', 'http'] }));
151+
expect(rows.filter((r) => r.where.includes('(script)'))).toEqual([
152+
{ rule: 'allowCode', where: "flow 'rp_route' › node 'calc' (script)", path: 'flows[1].nodes[3]' },
153+
]);
154+
});
155+
156+
it('a script node under allowCode: true is judged by allowedFlowNodeTypes', () => {
157+
const rows = rowsOf(profile({ allowedFlowNodeTypes: ['get_record', 'http'] }));
158+
expect(rows).toEqual([
159+
{ rule: 'allowedFlowNodeTypes', where: "flow 'rp_route' › node 'calc' (script)", path: 'flows[1].nodes[3]' },
160+
]);
161+
});
162+
});
163+
164+
describe('loadRestrictionProfile', () => {
165+
let dir = '';
166+
beforeAll(() => {
167+
dir = mkdtempSync(join(tmpdir(), 'os-restriction-profile-'));
168+
});
169+
afterAll(() => {
170+
if (dir) rmSync(dir, { recursive: true, force: true });
171+
});
172+
173+
it('parses a valid profile file with the spec schema', () => {
174+
writeFileSync(join(dir, 'ok.json'), JSON.stringify(OPEN));
175+
expect(loadRestrictionProfile('ok.json', dir).profile).toEqual(OPEN);
176+
});
177+
178+
it('refuses an invalid profile, naming the schema issue', () => {
179+
writeFileSync(join(dir, 'bad.json'), JSON.stringify({ ...OPEN, allowedFlowNodeTypes: ['approval'] }));
180+
expect(() => loadRestrictionProfile('bad.json', dir)).toThrow(/not a valid restriction profile.*allowedFlowNodeTypes\.0/);
181+
});
182+
183+
it('refuses {} — an absent dimension is not "no restriction"', () => {
184+
writeFileSync(join(dir, 'empty.json'), '{}');
185+
expect(() => loadRestrictionProfile('empty.json', dir)).toThrow(/allowCode/);
186+
});
187+
188+
it('refuses a file that is not JSON, and a missing file', () => {
189+
writeFileSync(join(dir, 'garbled.json'), 'allowCode: false');
190+
expect(() => loadRestrictionProfile('garbled.json', dir)).toThrow(/is not JSON/);
191+
expect(() => loadRestrictionProfile('missing.json', dir)).toThrow(/no restriction profile file/);
192+
});
193+
194+
it('refuses a URL rather than fetching from a guessed route', () => {
195+
expect(() => loadRestrictionProfile('https://env.example.com', dir)).toThrow(/does not publish its restriction profile at a defined/);
196+
});
197+
});
198+
199+
describe('restrictionProfileVersionDrift', () => {
200+
let dir = '';
201+
const install = (name: string, version: string) => {
202+
const at = join(dir, 'node_modules', ...name.split('/'));
203+
mkdirSync(at, { recursive: true });
204+
writeFileSync(join(at, 'package.json'), JSON.stringify({ name, version }));
205+
};
206+
beforeAll(() => {
207+
dir = mkdtempSync(join(tmpdir(), 'os-restriction-versions-'));
208+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'rp-app', private: true }));
209+
install('@objectstack/spec', '18.4.0');
210+
install('@objectstack/skills', '18.4.0');
211+
});
212+
afterAll(() => {
213+
if (dir) rmSync(dir, { recursive: true, force: true });
214+
});
215+
216+
it('CONTROL: no warning when both local versions are the target\'s', () => {
217+
expect(restrictionProfileVersionDrift(OPEN, dir)).toEqual([]);
218+
});
219+
220+
it('warns — one entry per package that differs, local and target named', () => {
221+
const drift = restrictionProfileVersionDrift(profile({ versions: { spec: '18.5.0', skills: '18.4.0' } }), dir);
222+
expect(drift.map(({ package: name, local, target }) => ({ name, local, target }))).toEqual([
223+
{ name: '@objectstack/spec', local: '18.4.0', target: '18.5.0' },
224+
]);
225+
});
226+
227+
it('warns when the project does not install @objectstack/skills at all', () => {
228+
rmSync(join(dir, 'node_modules', '@objectstack', 'skills'), { recursive: true, force: true });
229+
const drift = restrictionProfileVersionDrift(OPEN, dir);
230+
expect(drift.map(({ package: name, local }) => ({ name, local }))).toEqual([{ name: '@objectstack/skills', local: null }]);
231+
});
232+
});

0 commit comments

Comments
 (0)