diff --git a/.changeset/action-destructive-declared-semantics.md b/.changeset/action-destructive-declared-semantics.md new file mode 100644 index 0000000000..f0e854428e --- /dev/null +++ b/.changeset/action-destructive-declared-semantics.md @@ -0,0 +1,27 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): `actionLooksDestructive` classifies on declared semantics only (#7828) + +`actionLooksDestructive` (the classifier behind the MCP `list_actions` tool's +`requiresConfirmation` field) treated the mere presence of `confirmText` — UI +dialog copy — as an AI-facing destructiveness signal. #7278/#7309 are actively +migrating authors away from pairing `confirmText` with `params`-bearing actions +(the confirm question now rides `description` instead), so the heuristic's +input was being withdrawn by design: measured on #7309's branch, 6 of its 14 +migrated identity actions flipped from destructive to not-destructive the +moment their `confirmText` was dropped, because none of them declares +`mode: 'delete'` or `variant: 'danger'` to fall back on. + +Maintainer ruling (issue #7828, Option A): drop the `confirmText` leg. +`mode === 'delete' || variant === 'danger'` remain the signal — closed, +declared enumerations an author sets on purpose, not UI copy a heuristic +was never meant to read as a safety property. + +This path is gated dead for every action shipped today (all 14 identity +actions are `sys_*`, `type: 'api'`, and none declares `ai.exposed: true`, so +none reaches the MCP `listActions` bridge that calls this classifier) — so +the change has no observable effect on any request a caller can make right +now. It closes the gap before a future `ai.exposed`, non-`sys_*` action +carrying only `confirmText` would have had its classification silently flip. diff --git a/packages/runtime/src/action-execution-destructive.test.ts b/packages/runtime/src/action-execution-destructive.test.ts new file mode 100644 index 0000000000..d6c741e971 --- /dev/null +++ b/packages/runtime/src/action-execution-destructive.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7828 — `actionLooksDestructive` classifies on declared semantics only. + * + * Binding ruling (issue #7828, comment 5265943521, Option A): drop the + * `confirmText` leg. `mode === 'delete' || variant === 'danger'` remain the + * signal, because those are closed, declared enumerations an author sets on + * purpose — `confirmText` is UI dialog copy, and #7278/#7309 are actively + * migrating authors away from pairing it with `params`-bearing actions (the + * confirm question now rides `description`). Measured on #7309's branch + * (merged PR #7827): 6 of its 14 migrated identity actions flipped from + * destructive to not-destructive the moment their `confirmText` was dropped, + * because none of them carries `mode: 'delete'` or `variant: 'danger'` to + * fall back on. + * + * ## Is this observable on a live path today? + * + * `actionLooksDestructive`'s sole caller is `summarizeAction`, reached only + * from the MCP `listActions` bridge (`packages/runtime/src/domains/mcp.ts`, + * the sole production implementer of `packages/mcp`'s `McpActionBridge`). + * That bridge gates every candidate action through THREE checks before + * `summarizeAction` ever runs: + * + * 1. fail-closed on `sys_*` objects (`isSystemObjectName`) + * 2. `isHeadlessInvokableAction` — only `type: 'script'` (with `target` or + * `body`) or `type: 'flow'` (with `target` + an automation service) has + * a headless dispatch path at all + * 3. `actionAiExposureError` — excluded unless the author set + * `ai.exposed: true` + * + * All 14 of #7309's identity actions live on `sys_*` objects, are + * `type: 'api'` (not `script`/`flow`, so gate 2 excludes them independently + * of gate 1), and none declares `ai.exposed`. So today's real declarations + * are excluded on THREE independent grounds before `actionLooksDestructive` + * ever runs on them (the #7828 triage comment names two; gate 2 is a third, + * confirmed below against the real objects) — this fix has zero observable + * effect on any request a caller can make today. + * + * That is a fact about *today's platform objects*, not about the heuristic — + * a future `ai.exposed`, non-`sys_*`, `script`/`flow` action carrying only + * `confirmText` WOULD have its classification flip live, which is exactly + * the erosion #7828 was filed to stop before it reaches a reachable action. + * `describe('the boundary IS reachable in general …')` below proves that + * with a synthetic action shaped like one, through the real `summarizeAction` + * boundary — the same function production callers actually go through, and + * the honest edge of what a unit test can pin without also re-deriving the + * gate wiring in `domains/mcp.ts`, which this fix does not touch. + */ + +import { describe, it, expect } from 'vitest'; +import { + actionLooksDestructive, + summarizeAction, + isSystemObjectName, + actionAiExposureError, + isHeadlessInvokableAction, + type ActionExecutionDeps, +} from './action-execution.js'; +import { + SysUser, + SysTwoFactor, + SysOrganization, + SysOauthApplication, + SysAccount, + SysSsoProvider, + SysTeamMember, +} from '@objectstack/platform-objects/identity'; + +const deps: ActionExecutionDeps = { + resolveService: (async () => undefined) as any, + getObjectQL: async () => null, +}; + +function actionByName(obj: any, name: string): any { + const found = (obj?.actions ?? []).find((a: any) => a?.name === name); + if (!found) throw new Error(`fixture action '${name}' not found on '${obj?.name}' — object shape moved`); + return found; +} + +// --------------------------------------------------------------------------- +// Required pin, direction 1 — a `confirmText`-only action no longer +// classifies as destructive. +// --------------------------------------------------------------------------- + +describe('actionLooksDestructive: confirmText alone is no longer a signal (#7828 Option A)', () => { + it('confirmText present, no mode/variant signal → not destructive', () => { + const action = { name: 'ask', confirmText: 'Are you sure?' }; + expect(actionLooksDestructive(deps, action)).toBe(false); + }); + + it('confirmText + a non-danger variant → still not destructive', () => { + const action = { name: 'ask', confirmText: 'Are you sure?', variant: 'secondary' }; + expect(actionLooksDestructive(deps, action)).toBe(false); + }); + + it('confirmText + a non-delete mode → still not destructive', () => { + const action = { name: 'ask', confirmText: 'Are you sure?', mode: 'custom' }; + expect(actionLooksDestructive(deps, action)).toBe(false); + }); + + it('no confirmText, no mode/variant signal at all → not destructive (unchanged)', () => { + expect(actionLooksDestructive(deps, { name: 'noop' })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Required pin, direction 2 — `mode: 'delete'` and `variant: 'danger'` still +// classify as destructive. This is the direction that stops the fix from +// being "delete the function" — a leg dropped from an OR must not silently +// become "never destructive". +// --------------------------------------------------------------------------- + +describe("actionLooksDestructive: mode:'delete' and variant:'danger' still classify destructive", () => { + it("mode: 'delete', no confirmText → still destructive", () => { + expect(actionLooksDestructive(deps, { name: 'wipe', mode: 'delete' })).toBe(true); + }); + + it("variant: 'danger', no confirmText → still destructive", () => { + expect(actionLooksDestructive(deps, { name: 'wipe', variant: 'danger' })).toBe(true); + }); + + it("mode: 'delete' AND confirmText together → still destructive (the leg's removal doesn't touch this combination)", () => { + expect(actionLooksDestructive(deps, { name: 'wipe', mode: 'delete', confirmText: 'Sure?' })).toBe(true); + }); + + it("variant: 'danger' AND confirmText together → still destructive", () => { + expect(actionLooksDestructive(deps, { name: 'wipe', variant: 'danger', confirmText: 'Sure?' })).toBe(true); + }); + + it('an explicit `ai.requiresConfirmation` override still wins over everything else (pre-existing behaviour, unchanged by this fix)', () => { + expect(actionLooksDestructive(deps, { mode: 'delete', ai: { requiresConfirmation: false } })).toBe(false); + expect(actionLooksDestructive(deps, { confirmText: 'Sure?', ai: { requiresConfirmation: true } })).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// The 6-of-14 flipped identity actions, read off their REAL declarations +// (not hand-rolled fixtures) — the check the ruling explicitly calls for. +// --------------------------------------------------------------------------- + +describe('the 6 identity actions #7309 flipped now read not-destructive, off their real declarations', () => { + const flipped: Array<[string, any, any]> = [ + ['sys_user.generate_backup_codes', SysUser, 'generate_backup_codes'], + ['sys_two_factor.regenerate_backup_codes', SysTwoFactor, 'regenerate_backup_codes'], + ['sys_organization.change_slug', SysOrganization, 'change_slug'], + ['sys_oauth_application.enable_oauth_application', SysOauthApplication, 'enable_oauth_application'], + ['sys_oauth_application.disable_oauth_application', SysOauthApplication, 'disable_oauth_application'], + ['sys_oauth_application.rotate_client_secret', SysOauthApplication, 'rotate_client_secret'], + ]; + + it.each(flipped)('%s: no confirmText, no mode:delete/variant:danger, no ai override → not destructive', (_label, obj, name) => { + const action = actionByName(obj, name); + // Guard the fixture's own premise: it must actually be the shape + // #7828 describes, or this pin proves nothing. + expect(action.confirmText, `${name} still carries confirmText — #7309's migration regressed`).toBeUndefined(); + expect(action.mode).not.toBe('delete'); + expect(action.variant).not.toBe('danger'); + expect(actionLooksDestructive(deps, action)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Same identity object family, actions that DO still classify destructive — +// the direct check that the fix did not turn "destructive" off wholesale for +// the family the flipped 6 live in. +// --------------------------------------------------------------------------- + +describe('sibling identity actions with a declared destructive signal still read destructive', () => { + it("sys_two_factor.disable_two_factor (variant: 'danger', confirmText dropped in #7309) → still destructive", () => { + const action = actionByName(SysTwoFactor, 'disable_two_factor'); + expect(action.variant).toBe('danger'); + expect(actionLooksDestructive(deps, action)).toBe(true); + }); + + it("sys_organization.delete_organization (mode: 'delete' + variant: 'danger' + confirmText) → still destructive", () => { + const action = actionByName(SysOrganization, 'delete_organization'); + expect(action.mode).toBe('delete'); + expect(action.variant).toBe('danger'); + expect(action.confirmText).toBeTruthy(); + expect(actionLooksDestructive(deps, action)).toBe(true); + }); + + it("sys_organization.leave_organization (variant: 'danger', confirmText, no mode:delete) → still destructive off variant alone", () => { + const action = actionByName(SysOrganization, 'leave_organization'); + expect(action.variant).toBe('danger'); + expect(action.mode).not.toBe('delete'); + expect(actionLooksDestructive(deps, action)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Observable boundary — `summarizeAction`, the sole caller, is what actually +// produces the `requiresConfirmation` field a caller reads. Pinned at this +// boundary (not just the raw predicate) so the field name/wiring the ruling +// cares about is covered, not just the internal function. +// --------------------------------------------------------------------------- + +describe('summarizeAction: requiresConfirmation reflects the same declared-semantics rule', () => { + const obj = { fields: {} }; + + it('confirmText-only action summarizes requiresConfirmation: false', () => { + const action = { name: 'ask', type: 'script', target: 'x', confirmText: 'Sure?' }; + const summary = summarizeAction(deps, action, obj, 'todo_task'); + expect(summary.requiresConfirmation).toBe(false); + }); + + it("mode: 'delete' action summarizes requiresConfirmation: true", () => { + const action = { name: 'wipe', type: 'script', target: 'x', mode: 'delete' }; + const summary = summarizeAction(deps, action, obj, 'todo_task'); + expect(summary.requiresConfirmation).toBe(true); + }); + + it("variant: 'danger' action summarizes requiresConfirmation: true", () => { + const action = { name: 'wipe', type: 'script', target: 'x', variant: 'danger' }; + const summary = summarizeAction(deps, action, obj, 'todo_task'); + expect(summary.requiresConfirmation).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// The boundary IS reachable in general — proven with a synthetic action +// shaped exactly like a future `ai.exposed`, non-`sys_*`, headless-invokable +// action, so "latent today" is pinned as a fact about today's identity +// objects, not mistaken for a fact about the classifier being unreachable in +// principle. This is the shape #7828 was filed to protect against. +// --------------------------------------------------------------------------- + +describe('the classification IS live-reachable for a shape gate 1/2/3 all pass — not merely latent by construction', () => { + it('a real example app action passes all three MCP listActions gates today (todo_task.delete_completed, examples/app-todo)', () => { + // Mirrors examples/app-todo/src/actions/task.actions.ts `delete_completed` + // field for field on the properties the three gates and the classifier + // read — the one non-sys, ai.exposed, headless action in the repo that + // still carries both confirmText and a mode/variant signal. It does NOT + // flip (variant: 'danger' already covers it), which is itself evidence + // that nothing shipped today silently changes behaviour under this fix. + const action = { + name: 'delete_completed', + objectName: 'todo_task', + type: 'script', + target: 'deleteCompletedTasks', + variant: 'danger', + confirmText: 'Permanently delete all completed tasks? This cannot be undone.', + ai: { exposed: true, description: 'Permanently delete every completed todo task.' }, + }; + expect(isSystemObjectName(action.objectName)).toBe(false); + expect(isHeadlessInvokableAction(deps, action, false)).toBe(true); + expect(actionAiExposureError(deps, action, action.objectName)).toBeNull(); + expect(actionLooksDestructive(deps, action)).toBe(true); + }); + + it('the SAME shape with confirmText only (no mode/variant) — the erosion #7828 stops — would have flipped without this fix, and reads not-destructive now', () => { + const action = { + name: 'hypothetical_ai_exposed_action', + objectName: 'todo_task', + type: 'script', + target: 'someHandler', + confirmText: 'Are you sure?', + ai: { exposed: true, description: 'A hypothetical AI-exposed action.' }, + }; + expect(isSystemObjectName(action.objectName)).toBe(false); + expect(isHeadlessInvokableAction(deps, action, false)).toBe(true); + expect(actionAiExposureError(deps, action, action.objectName)).toBeNull(); + // This is the exact defect class #7828 closes: a UI-copy field must not + // decide an AI-facing safety property. + expect(actionLooksDestructive(deps, action)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Documents (with real assertions, not narration) why today's 14 identity +// actions never reach the classifier via the live MCP path — confirming the +// #7828 triage comment's "double-gated dead" plus the third gate found while +// verifying this card (isHeadlessInvokableAction: all 14 are `type: 'api'`). +// --------------------------------------------------------------------------- + +describe('today, all 14 #7309 identity actions are excluded before actionLooksDestructive ever runs on them', () => { + // The canonical 14 sites #7309 converted — same set pinned by + // `platform-objects/src/identity/action-confirm-one-dialog.test.ts`'s + // `CONVERTED` map, reproduced here rather than imported so this suite + // does not depend on that test file's internals staying exported. + const allFourteen: Array<[string, string, any, string]> = [ + ['sys_user.ban_user', 'sys_user', SysUser, 'ban_user'], + ['sys_user.delete_my_account', 'sys_user', SysUser, 'delete_my_account'], + ['sys_user.disable_two_factor', 'sys_user', SysUser, 'disable_two_factor'], + ['sys_user.generate_backup_codes', 'sys_user', SysUser, 'generate_backup_codes'], + ['sys_oauth_application.enable_oauth_application', 'sys_oauth_application', SysOauthApplication, 'enable_oauth_application'], + ['sys_oauth_application.disable_oauth_application', 'sys_oauth_application', SysOauthApplication, 'disable_oauth_application'], + ['sys_oauth_application.rotate_client_secret', 'sys_oauth_application', SysOauthApplication, 'rotate_client_secret'], + ['sys_oauth_application.delete_oauth_application', 'sys_oauth_application', SysOauthApplication, 'delete_oauth_application'], + ['sys_two_factor.disable_two_factor', 'sys_two_factor', SysTwoFactor, 'disable_two_factor'], + ['sys_two_factor.regenerate_backup_codes', 'sys_two_factor', SysTwoFactor, 'regenerate_backup_codes'], + ['sys_account.unlink_account', 'sys_account', SysAccount, 'unlink_account'], + ['sys_organization.change_slug', 'sys_organization', SysOrganization, 'change_slug'], + ['sys_sso_provider.delete_sso_provider', 'sys_sso_provider', SysSsoProvider, 'delete_sso_provider'], + ['sys_team_member.remove_team_member', 'sys_team_member', SysTeamMember, 'remove_team_member'], + ]; + + it('the fixture reproduces all 14 #7309 sites, no more, no fewer', () => { + expect(allFourteen).toHaveLength(14); + }); + + it.each(allFourteen)('%s is excluded by isSystemObjectName (gate 1: sys_* fail-closed)', (_label, objName) => { + expect(isSystemObjectName(objName)).toBe(true); + }); + + it.each(allFourteen)('%s is excluded by isHeadlessInvokableAction (gate 2: type is not script/flow)', (_label, _objName, obj, name) => { + const action = actionByName(obj, name); + expect(action.type).toBe('api'); + expect(isHeadlessInvokableAction(deps, action, true)).toBe(false); + }); + + it.each(allFourteen)('%s is excluded by actionAiExposureError (gate 3: ai.exposed not set)', (_label, _objName, obj, name) => { + const action = actionByName(obj, name); + expect(action.ai?.exposed).not.toBe(true); + // Matches the real call site (domains/mcp.ts listActions): 2-arg, no + // `objectName` — real action declarations don't carry that field + // inline (it's the enclosing object's `.name`, tracked separately by + // `collectActionDeclarations`). + expect(actionAiExposureError(deps, action)).not.toBeNull(); + }); +}); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 7210d56955..de070d1881 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -617,9 +617,20 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, return result ?? null; } +/** + * [#7828] Declared semantics only. `confirmText` is UI dialog copy — the + * platform's own authoring convention (#7278/#7309) is actively moving confirm + * questions onto `description`, so keying an AI-facing safety property off + * `confirmText`'s mere presence classifies on copy the author never intended + * as a safety signal, and erodes as that migration proceeds (6 of the 14 + * #7309 identity actions flipped to "not destructive" the moment their + * `confirmText` was removed). `mode: 'delete'` and `variant: 'danger'` are + * closed, declared enumerations an author sets on purpose — those remain the + * signal. Maintainer ruling: issue #7828, comment 5265943521 (Option A). + */ export function actionLooksDestructive(_deps: ActionExecutionDeps, action: any): boolean { if (action?.ai?.requiresConfirmation !== undefined) return Boolean(action.ai.requiresConfirmation); - return Boolean(action?.confirmText || action?.mode === 'delete' || action?.variant === 'danger'); + return Boolean(action?.mode === 'delete' || action?.variant === 'danger'); } export function summarizeAction(deps: ActionExecutionDeps, action: any, obj: any, objectName: string): any { diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 4cb8dc468d..a7f0f8d74e 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -5,43 +5,86 @@ import path from 'node:path'; export default defineConfig({ resolve: { - alias: { - // Subpath before the bare package: the object form prefix-replaces, so + // ARRAY form, not the object form: only the array form accepts a RegExp + // `find`, and the `@objectstack/platform-objects` entries below have to be + // anchored (see their note). The pre-existing string entries keep the + // prefix-match semantics they had as object keys — Vite normalizes an alias + // object into exactly this list, in this order, first match wins — so this + // conversion changes no resolution, it only makes room for a regex. + alias: [ + // Subpath before the bare package: a string `find` prefix-replaces, so // without this entry `@objectstack/core/logger` (imported by the built // client, see route-ledger.conformance.test.ts) resolves to the garbage // path `…/core/src/index.ts/logger`. - '@objectstack/core/logger': path.resolve(__dirname, '../core/src/logger.ts'), - '@objectstack/core': path.resolve(__dirname, '../core/src/index.ts'), - '@objectstack/rest': path.resolve(__dirname, '../rest/src/index.ts'), - '@objectstack/spec/ai': path.resolve(__dirname, '../spec/src/ai/index.ts'), - '@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'), + { find: '@objectstack/core/logger', replacement: path.resolve(__dirname, '../core/src/logger.ts') }, + { find: '@objectstack/core', replacement: path.resolve(__dirname, '../core/src/index.ts') }, + // #7828 / PR #8128: action-execution-destructive.test.ts reads the REAL + // `sys_*` identity declarations to prove that today's platform objects + // are excluded before `actionLooksDestructive` ever runs on them. That + // import resolved through `exports` to `platform-objects/dist` — a build + // artifact — so all 66 pins were a verdict about build state rather than + // about the declarations in the checkout (`pnpm check:test-source-alias`, + // #7668/#7778). + // + // ANCHORED regexes, array form, deliberately — the same correction + // `@objectstack/spec` gets in packages/qa/downstream-contract (PR #8129). + // A bare `@objectstack/platform-objects` string entry matches by PREFIX, + // so with a FILE replacement it also swallows the `/identity` subpath and + // resolves it to `…/platform-objects/src/index.ts/identity` — `ENOTDIR`, + // at run time, from a config that reads as correct. + // + // One rule for every namespace rather than an enumeration of the ones + // reached today, so it cannot go stale as tests reach new subpaths. + // `/plugin` is listed ahead of it because it is the one exported subpath + // that is a FILE (`src/plugin.ts`) and not a namespace directory: the + // namespace rule would send it to `src/plugin/index.ts`, which does not + // exist — and which `check:test-source-alias` cannot catch, since that + // path still reads as pointing at source. + { + find: /^@objectstack\/platform-objects\/plugin$/, + replacement: path.resolve(__dirname, '../platform-objects/src/plugin.ts'), + }, + { + find: /^@objectstack\/platform-objects\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '..'), 'platform-objects/src/$1/index.ts'), + }, + { + find: /^@objectstack\/platform-objects$/, + replacement: path.resolve(__dirname, '../platform-objects/src/index.ts'), + }, + { find: '@objectstack/rest', replacement: path.resolve(__dirname, '../rest/src/index.ts') }, + { find: '@objectstack/spec/ai', replacement: path.resolve(__dirname, '../spec/src/ai/index.ts') }, + { find: '@objectstack/spec/api', replacement: path.resolve(__dirname, '../spec/src/api/index.ts') }, // `AppPlugin` reads a bundle function's declared effect off this // namespace (#4396). - '@objectstack/spec/automation': path.resolve(__dirname, '../spec/src/automation/index.ts'), - '@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'), - '@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'), + { find: '@objectstack/spec/automation', replacement: path.resolve(__dirname, '../spec/src/automation/index.ts') }, + { find: '@objectstack/spec/contracts', replacement: path.resolve(__dirname, '../spec/src/contracts/index.ts') }, + { find: '@objectstack/spec/data', replacement: path.resolve(__dirname, '../spec/src/data/index.ts') }, // Reached via `@objectstack/platform-objects` (sys-user.object.ts), which // notifications.hono.integration.test.ts pulls in for the real // `sys_notification` declaration. - '@objectstack/spec/identity': path.resolve(__dirname, '../spec/src/identity/index.ts'), - '@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'), - '@objectstack/spec/shared': path.resolve(__dirname, '../spec/src/shared/index.ts'), - '@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'), - '@objectstack/spec/ui': path.resolve(__dirname, '../spec/src/ui/index.ts'), + { find: '@objectstack/spec/identity', replacement: path.resolve(__dirname, '../spec/src/identity/index.ts') }, + { find: '@objectstack/spec/kernel', replacement: path.resolve(__dirname, '../spec/src/kernel/index.ts') }, + { find: '@objectstack/spec/shared', replacement: path.resolve(__dirname, '../spec/src/shared/index.ts') }, + { find: '@objectstack/spec/system', replacement: path.resolve(__dirname, '../spec/src/system/index.ts') }, + { find: '@objectstack/spec/ui', replacement: path.resolve(__dirname, '../spec/src/ui/index.ts') }, // [ADR-0105 D1] Reached transitively via `@objectstack/types` (tenancy posture). - '@objectstack/spec/security': path.resolve(__dirname, '../spec/src/security/index.ts'), - '@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'), - '@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'), + { find: '@objectstack/spec/security', replacement: path.resolve(__dirname, '../spec/src/security/index.ts') }, + { find: '@objectstack/spec', replacement: path.resolve(__dirname, '../spec/src/index.ts') }, + { find: '@objectstack/types', replacement: path.resolve(__dirname, '../types/src/index.ts') }, // Dev-only: app-plugin.jobs.test.ts drives the REAL CronJobAdapter, so // the #4567 regression (croner rejecting the expression envelope) is // reproduced by the actual scheduler rather than by a double. - '@objectstack/service-job': path.resolve(__dirname, '../services/service-job/src/index.ts'), + { find: '@objectstack/service-job', replacement: path.resolve(__dirname, '../services/service-job/src/index.ts') }, // Dev-only: app-plugin.disabled-seed.test.ts drives the REAL // `sys_packages` → registry rehydration (#5047), so the empty-env seed // regression is proven against the actual hydration code rather than a // re-implementation of it. - '@objectstack/service-package': path.resolve(__dirname, '../services/service-package/src/index.ts'), - }, + { + find: '@objectstack/service-package', + replacement: path.resolve(__dirname, '../services/service-package/src/index.ts'), + }, + ], }, test: { globals: true,