From 61c26d5225fad6e1e61fbb6c2431a5d96c553a7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:25:50 +0000 Subject: [PATCH 1/3] feat(spec): resolve a flow action's target against the artifact via defineStack artifactFlows Claude-Session: https://claude.ai/code/session_011SU6BLnw3XLU8nKfnWMYVG Co-authored-by: Claude --- .../src/stack-artifact-flow-crossref.test.ts | 184 ++++++++++++++++ packages/spec/src/stack.zod.ts | 203 ++++++++++++++++-- 2 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 packages/spec/src/stack-artifact-flow-crossref.test.ts diff --git a/packages/spec/src/stack-artifact-flow-crossref.test.ts b/packages/spec/src/stack-artifact-flow-crossref.test.ts new file mode 100644 index 00000000000..7f69060c094 --- /dev/null +++ b/packages/spec/src/stack-artifact-flow-crossref.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25514 — a `type: 'flow'` action's target resolves against the ARTIFACT once + * the stack declares its siblings' flows (`DefineStackOptions.artifactFlows`), + * and a flow NO package of the artifact defines stays refused. + * + * ## The defect + * + * `validateCrossReferences` built its flow scope from the stack's own `flows` + * alone, so a sales action on an app-owned object launching a flow that a + * module package of the SAME artifact ships was refused at `defineStack` — + * measured downstream on HotCRM's module split as three "which is not defined + * in flows." refusals, while the composed artifact carried all three flows. + * + * ## The two directions this file pins, per position + * + * - a sibling-provided flow is ACCEPTED at `defineStack` and at `composeStacks`; + * - a flow no package provides is REFUSED — at `defineStack` when it is not + * claimed, and at `composeStacks` (the artifact pass) when it is claimed but + * no input defines it, with the per-stack pass's own message. + * + * Plus the fences: the option never switches the check on for a stack with no + * flows of its own, and an input that did not opt in cannot newly fail at + * composition even when it bypassed the strict parse. + */ +import { describe, it, expect } from 'vitest'; +import { composeStacks, defineStack } from './stack.zod'; + +const appManifest = { + id: 'app.objectstack.hotcrm', + name: 'HotCRM', + version: '3.1.0', + type: 'app' as const, + namespace: 'crm', +}; + +const moduleManifest = { + id: 'app.objectstack.hotcrm.revenue', + name: 'HotCRM Revenue', + version: '3.1.0', + type: 'module' as const, + namespace: 'crm', + dependencies: { 'app.objectstack.hotcrm': '^3.1.0' }, +}; + +const flow = (name: string) => ({ name, label: name, type: 'autolaunched' as const, nodes: [], edges: [] }); + +/** Owned by the module package — the flow every cross-package action launches. */ +const SIBLING = 'quote_generation'; +/** The flow NO package in the artifact defines. */ +const NOWHERE = 'quote_nowhere'; + +const account = { name: 'crm_account', label: 'Account', fields: { title: { type: 'text' as const } } }; +const quote = { name: 'crm_quote', label: 'Quote', fields: { title: { type: 'text' as const } } }; + +type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] }; + +function refusalOf(run: () => unknown): Envelope | null { + try { + run(); + return null; + } catch (e) { + return e as Envelope; + } +} + +const anyStack = (config: Record) => config as unknown as Parameters[0]; + +/** The module package: owns `crm_quote` and the flow. */ +const revenueStack = () => + defineStack(anyStack({ manifest: moduleManifest, objects: [quote], flows: [flow(SIBLING)] })); + +/** + * The app package: owns `crm_account` and a flow of its own (so the per-stack + * flow check runs at all), and launches `target` from all three positions. + */ +const appConfig = (target: string, ownFlows = [flow('crm_local_flow')]) => anyStack({ + manifest: appManifest, + objects: [ + { + ...account, + actions: [{ name: 'quote_from_account', label: 'Quote', type: 'flow', target }], + }, + ], + flows: ownFlows, + actions: [{ name: 'generate_quote', label: 'Generate Quote', type: 'flow', target }], + pages: [ + { + name: 'crm_home', + label: 'Home', + type: 'home', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:button', + properties: { label: 'Go', action: { name: 'inline_quote', type: 'flow', target } }, + }, + ], + }, + ], + }, + ], +}); + +const messages = (target: string) => [ + `Action 'generate_quote' references flow '${target}' which is not defined in flows.`, + `Action 'quote_from_account' on object 'crm_account' references flow '${target}' which is not defined in flows.`, + `Inline action 'inline_quote' on page 'crm_home' (regions.0.components.0) references flow '${target}' which is not defined in flows.`, +]; + +describe('the per-stack pass, without the opt-in — unchanged', () => { + it('REFUSES the sibling-provided flow from all three positions — the reported defect', () => { + const refused = refusalOf(() => defineStack(appConfig(SIBLING))); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toEqual(messages(SIBLING)); + }); +}); + +describe('`artifactFlows` resolves a sibling package’s flow', () => { + it('ACCEPTS all three positions at `defineStack`', () => { + const revenue = revenueStack(); + expect( + refusalOf(() => defineStack(appConfig(SIBLING), { artifactFlows: (revenue.flows ?? []).map((f) => f.name) })), + ).toBeNull(); + }); + + it('ACCEPTS the composed artifact — the claim is redeemed by the sibling', () => { + const app = defineStack(appConfig(SIBLING), { artifactFlows: [SIBLING] }); + expect(refusalOf(() => composeStacks([revenueStack(), app], { manifest: 'preserve' }))).toBeNull(); + }); + + it('keeps refusing a name it does NOT list, beside one it does', () => { + const refused = refusalOf(() => defineStack(appConfig(NOWHERE), { artifactFlows: [SIBLING] })); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toEqual(messages(NOWHERE)); + }); +}); + +describe('the ARTIFACT pass: a claimed flow NO package defines is refused', () => { + const composeClaiming = (name: string) => { + const app = defineStack(appConfig(name), { artifactFlows: [name] }); + return refusalOf(() => composeStacks([revenueStack(), app], { manifest: 'preserve' })); + }; + + it('REFUSES every position with the per-stack pass’s own message and envelope', () => { + const refused = composeClaiming(NOWHERE); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toEqual(messages(NOWHERE)); + expect(refused?.message).toContain('composeStacks artifact cross-reference validation failed'); + }); + + it('resolves against a nested multi-package input, whose flows live only in its package bodies', () => { + const inner = composeStacks( + [revenueStack(), defineStack(anyStack({ manifest: { ...moduleManifest, id: 'x.other' }, objects: [] }))], + { manifest: 'preserve' }, + ); + const app = defineStack(appConfig(SIBLING), { artifactFlows: [SIBLING] }); + expect(refusalOf(() => composeStacks([inner, app], { manifest: 'preserve' }))).toBeNull(); + }); +}); + +describe('fences', () => { + it('a stack with NO flows of its own keeps the plugin leniency — the option switches nothing on', () => { + const app = defineStack(appConfig(NOWHERE, []), { artifactFlows: [SIBLING] }); + // No claim was relied on, so the composition has nothing to redeem. + expect(refusalOf(() => composeStacks([revenueStack(), app], { manifest: 'preserve' }))).toBeNull(); + }); + + it('an input that did not opt in cannot newly fail at composition, even past `strict: false`', () => { + const loose = defineStack(appConfig(NOWHERE), { strict: false }); + expect(refusalOf(() => composeStacks([revenueStack(), loose], { manifest: 'preserve' }))).toBeNull(); + }); + + it('the claim record is not data — the built stack carries no new enumerable key', () => { + const app = defineStack(appConfig(SIBLING), { artifactFlows: [SIBLING] }); + expect(Object.keys(app)).toEqual(Object.keys(defineStack(appConfig('crm_local_flow')))); + expect(JSON.stringify(app)).not.toContain('artifact-flow-claims'); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 208905c1ae7..f2a2b74361e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2228,6 +2228,50 @@ export interface DefineStackOptions { * ``` */ artifactObjects?: readonly string[]; + + /** + * Flow names provided by the OTHER packages of the same release artifact + * (ADR-0130 D1, #25514) — the {@link artifactObjects} question, asked of the + * one reference class whose target is a FLOW. + * + * A `type: 'flow'` action's `target` — registered (`actions[]`), authored on + * an object (`objects[].actions[]`) or inline on a page element — resolves + * against this stack's own `flows` PLUS these names. The measured shape is a + * sales action on an app-owned object launching a flow that a module package + * of the same artifact ships: the runtime registers every package's flows in + * one automation engine, so the launch works, and only this per-stack check + * could not see the flow. + * + * ⚠️ It WIDENS a check and never switches one on. A stack declaring no flows + * of its own keeps the existing leniency — its flow targets are not resolved + * at all, because a plugin may serve them — so these names are consulted only + * where a target used to be refused: when the stack's own `flows` is + * non-empty. Nothing else reads them. + * + * ## The claim is verified, not trusted — in a composition of two or more packages + * + * Every target that resolved ONLY through this list is recorded on the + * returned stack — a non-enumerable symbol beside the provenance mark, never + * a key of `ObjectStackDefinitionSchema` — and {@link composeStacks} refuses + * the composition when no input of it defines that flow, with the same + * `STACK_CROSS_REFERENCE_INVALID` envelope and the same per-finding message + * the per-stack pass prints. A listed name no target uses claims nothing, so + * it is not checked. The boundary is {@link artifactObjects}' own: a + * one-package composition, or a stack that is never composed, leaves the + * claim unverified. + * + * Omitting the option keeps `defineStack`'s behaviour byte-for-byte. + * + * @example + * ```ts + * const revenue = defineStack(revenueConfig); // owns quote_generation + * const app = defineStack(appConfig, { // generate_quote → quote_generation + * artifactFlows: revenue.flows?.map((f) => f.name), + * }); + * export default composeStacks([revenue, app], { manifest: 'preserve' }); + * ``` + */ + artifactFlows?: readonly string[]; } /** @@ -3983,10 +4027,17 @@ function collectComposedExternalBindingDatasourceErrors( * single-package stack, which is every stack that does not opt in) the two * scopes are the same Set and this function behaves exactly as it did before * the option existed. + * + * `artifactFlows` (#25514) widens the flow target of a `type: 'flow'` action + * the same way, and every target that resolved ONLY through it is appended to + * `flowClaims` — the record {@link composeStacks} redeems (see + * {@link DefineStackOptions.artifactFlows}). */ function validateCrossReferences( config: ObjectStackDefinition, artifactObjects?: ReadonlySet, + artifactFlows?: ReadonlySet, + flowClaims?: ArtifactFlowClaim[], ): string[] { const errors: string[] = []; const objectNames = collectObjectNames(config); @@ -4220,6 +4271,20 @@ function validateCrossReferences( } } + // One judgement for the three positions below. The leniency is the + // stack's OWN flows being empty, unchanged by `artifactFlows` (#25514): the + // option widens where a target is refused today and switches nothing on. A + // target the option alone resolves is a claim on the artifact, recorded for + // `composeStacks` to redeem rather than trusted. + const checkFlowTarget = (subject: string, target: string | undefined): void => { + if (!target || flowNames.size === 0 || flowNames.has(target)) return; + if (artifactFlows?.has(target)) { + flowClaims?.push({ subject, target }); + return; + } + errors.push(flowTargetMessage(subject, target)); + }; + const pageNames = new Set(); if (config.pages) { for (const page of config.pages) { @@ -4244,11 +4309,7 @@ function validateCrossReferences( if (config.actions) { for (const action of config.actions) { // Validate flow-type actions reference a defined flow - if (action.type === 'flow' && action.target && flowNames.size > 0 && !flowNames.has(action.target)) { - errors.push( - `Action '${action.name}' references flow '${action.target}' which is not defined in flows.`, - ); - } + if (action.type === 'flow') checkFlowTarget(`Action '${action.name}'`, action.target); // Validate modal-type actions reference a defined page if (action.type === 'modal' && action.target && pageNames.size > 0 && !pageNames.has(action.target)) { @@ -4320,11 +4381,7 @@ function validateCrossReferences( if (config.objects) { for (const obj of config.objects) { for (const action of obj.actions ?? []) { - if (action.type === 'flow' && action.target && flowNames.size > 0 && !flowNames.has(action.target)) { - errors.push( - `Action '${action.name}' on object '${obj.name}' references flow '${action.target}' which is not defined in flows.`, - ); - } + if (action.type === 'flow') checkFlowTarget(`Action '${action.name}' on object '${obj.name}'`, action.target); if (action.type === 'modal' && action.target && pageNames.size > 0 && !pageNames.has(action.target)) { errors.push( @@ -4357,11 +4414,7 @@ function validateCrossReferences( if (config.pages) { for (const page of config.pages) { for (const { action, where } of collectInlinePageActions(page)) { - if (action.type === 'flow' && action.target && flowNames.size > 0 && !flowNames.has(action.target)) { - errors.push( - `${where} references flow '${action.target}' which is not defined in flows.`, - ); - } + if (action.type === 'flow') checkFlowTarget(where, action.target); if (action.type === 'modal' && action.target && pageNames.size > 0 && !pageNames.has(action.target)) { errors.push( @@ -5236,11 +5289,16 @@ function buildDefinedStack( throw new StackCapabilityUnknownError(`${header}\n\n${lines.join('\n')}`, capErrors); } + const flowClaims: ArtifactFlowClaim[] = []; const crossRefErrors = validateCrossReferences( data, options?.artifactObjects && options.artifactObjects.length > 0 ? new Set(options.artifactObjects) : undefined, + options?.artifactFlows && options.artifactFlows.length > 0 + ? new Set(options.artifactFlows) + : undefined, + flowClaims, ); if (crossRefErrors.length > 0) { const header = `defineStack cross-reference validation failed (${crossRefErrors.length} issue${crossRefErrors.length === 1 ? '' : 's'}):`; @@ -5313,7 +5371,10 @@ function buildDefinedStack( // this value went through the judgement above. Non-enumerable, so it reaches // neither the schema nor the compiled artifact (`stack-provenance.ts`). The // conversion record rides beside it, stamped in the same act. - return markStackProvenance(mergeActionsIntoObjects(data), 'defineStack', appliedConversions); + return recordArtifactFlowClaims( + markStackProvenance(mergeActionsIntoObjects(data), 'defineStack', appliedConversions), + flowClaims, + ); } /** @@ -6556,6 +6617,113 @@ function assemblePackageBody(stack: ObjectStackDefinition): AssembledPackageBody return body as AssembledPackageBody; } +/** + * One `type: 'flow'` action target that resolved ONLY through + * {@link DefineStackOptions.artifactFlows} (#25514): `subject` is the action + * as the per-stack refusal names it, `target` the flow it launches. + */ +interface ArtifactFlowClaim { + readonly subject: string; + readonly target: string; +} + +/** + * The per-finding line for a flow target no scope resolves — ONE spelling for + * the per-stack pass and the artifact pass, so a reader greps one string + * whichever pass refused it. + */ +function flowTargetMessage(subject: string, target: string): string { + return `${subject} references flow '${target}' which is not defined in flows.`; +} + +/** + * The symbol key the `artifactFlows` claims of a built stack live under. + * `Symbol.for` for the provenance mark's reason (`stack-provenance.ts`): a CLI + * and the config it loads may resolve two copies of this package. + */ +const ARTIFACT_FLOW_CLAIMS: symbol = Symbol.for('objectstack.stack.artifact-flow-claims'); + +/** + * Stamp the claims `defineStack` let past the per-stack pass on the stack it + * returns, and return that stack — the PROMISE half of + * {@link DefineStackOptions.artifactFlows}, which + * {@link collectArtifactFlowClaimErrors} redeems. + * + * Why a record and not a re-run (the shape {@link collectArtifactCrossReferenceErrors} + * uses for objects): that pass re-runs its rules over every input, and for + * flows a re-run judges more than the claim — it would newly refuse a dangling + * target in an input built with `strict: false`, which never reached the + * per-stack pass. The record holds exactly what the option let through, so + * the composition checks the promise and nothing else. + * + * Non-enumerable, non-writable, non-configurable, like the mark it sits + * beside: invisible to `ObjectStackDefinitionSchema`, to the compiled + * `dist/objectstack.json` and to every `Object.keys` walk, and dropped by any + * copy that drops the mark — which `composeStacks` refuses anyway. A stack + * with no claim is returned untouched. + */ +function recordArtifactFlowClaims(stack: T, claims: readonly ArtifactFlowClaim[]): T { + if (claims.length === 0 || stack === null || typeof stack !== 'object') return stack; + if (!Object.isExtensible(stack)) return stack; + Object.defineProperty(stack, ARTIFACT_FLOW_CLAIMS, { + value: Object.freeze(claims.map((claim) => Object.freeze({ ...claim }))), + enumerable: false, + writable: false, + configurable: false, + }); + return stack; +} + +/** The claims {@link recordArtifactFlowClaims} stamped on `stack`, or `[]`. */ +function artifactFlowClaimsOf(stack: unknown): readonly ArtifactFlowClaim[] { + if (stack === null || typeof stack !== 'object') return []; + const record = (stack as Record)[ARTIFACT_FLOW_CLAIMS]; + return Array.isArray(record) ? (record as readonly ArtifactFlowClaim[]) : []; +} + +/** + * Every flow name `stack` provides: its own `flows`, plus each `packages[]` + * body's when the input is itself a multi-package artifact — whose flows live + * ONLY in those bodies (#14512), so reading the top level alone would refuse + * a flow the input carries. + */ +function providedFlowNames(stack: unknown): string[] { + const names: string[] = []; + const collect = (flows: unknown): void => { + if (!Array.isArray(flows)) return; + for (const flow of flows) { + if (isRecord(flow) && typeof flow.name === 'string') names.push(flow.name); + } + }; + if (!isRecord(stack)) return names; + collect(stack.flows); + if (Array.isArray(stack.packages)) { + for (const entry of stack.packages) { + if (isRecord(entry) && isRecord(entry.manifest)) collect(entry.manifest.flows); + } + } + return names; +} + +/** + * The ARTIFACT pass for {@link DefineStackOptions.artifactFlows} (#25514): a + * claimed flow target that NO input of the composition defines is refused, + * with the per-stack pass's own message. + * + * Reads only the recorded claims (see {@link recordArtifactFlowClaims}), so an + * input that never opted in cannot newly fail here — strict or not — and the + * composition never judges a target the per-stack pass did not let through on + * the artifact's credit. + */ +function collectArtifactFlowClaimErrors(stacks: readonly ObjectStackDefinition[]): string[] { + const claims = stacks.flatMap((stack) => artifactFlowClaimsOf(stack)); + if (claims.length === 0) return []; + const artifactFlowNames = new Set(stacks.flatMap((stack) => providedFlowNames(stack))); + return claims + .filter((claim) => !artifactFlowNames.has(claim.target)) + .map((claim) => flowTargetMessage(claim.subject, claim.target)); +} + /** * The ARTIFACT pass of the cross-reference gate (#18202, ADR-0130 D1). * @@ -6868,6 +7036,8 @@ function composeBuiltStacks( // {@link collectComposedExternalBindingDatasourceErrors}. // [#25234] …and one membership key declared on two objects of the // composed artifact: see {@link collectComposedMembershipKeyErrors}. + // [#25514] …and every flow target an input resolved only through + // `artifactFlows`: see {@link collectArtifactFlowClaimErrors}. const artifactCrossRefErrors = [ ...collectArtifactCrossReferenceErrors( stacks, @@ -6876,6 +7046,7 @@ function composeBuiltStacks( ...collectComposedDefaultRecordPageErrors(stacks, opts.manifest === 'preserve'), ...collectComposedMembershipKeyErrors(stacks, objects as readonly unknown[] | undefined), ...collectComposedExternalBindingDatasourceErrors(stacks, objects), + ...collectArtifactFlowClaimErrors(stacks), ]; if (artifactCrossRefErrors.length > 0) { const count = artifactCrossRefErrors.length; From 9f0d2b9bb6b62e815f7e0481dcbff1275b570a48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:29:29 +0000 Subject: [PATCH 2/3] fix(lint): widget-dataset-unknown resolves a widget's dataset against the artifact's packages[] Claude-Session: https://claude.ai/code/session_011SU6BLnw3XLU8nKfnWMYVG Co-authored-by: Claude --- ...-widget-bindings.artifact-datasets.test.ts | 106 ++++++++++++++++++ packages/lint/src/validate-widget-bindings.ts | 18 +++ 2 files changed, 124 insertions(+) create mode 100644 packages/lint/src/validate-widget-bindings.artifact-datasets.test.ts diff --git a/packages/lint/src/validate-widget-bindings.artifact-datasets.test.ts b/packages/lint/src/validate-widget-bindings.artifact-datasets.test.ts new file mode 100644 index 00000000000..9ea32bbcf9d --- /dev/null +++ b/packages/lint/src/validate-widget-bindings.artifact-datasets.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25514 — `widget-dataset-unknown` resolves a widget's `dataset` against the + * ARTIFACT's `packages[]`, not the one package body it judges. + * + * The CLI's per-package pass (`runPerPackageAuthoringRules`) hands each rule + * one package body re-read as a stack, with the artifact's `packages[]` as + * resolution context (`packageBodyAsStack`). An app-package dashboard binding a + * dataset a module package owns was refused there — measured on HotCRM's + * module split as `dataset "product_metrics" does not resolve to a declared + * dataset.` while the composed artifact carried the dataset. + * + * Both directions are pinned: a sibling-provided dataset is accepted (and its + * dimensions / measures are then judged like a local one), and a name no + * package provides is refused exactly as before. + */ +import { describe, it, expect } from 'vitest'; +import { runAuthoringRules } from './authoring-rules.js'; +import { validateWidgetBindings, WIDGET_DATASET_UNKNOWN, WIDGET_MEASURE_UNKNOWN } from './validate-widget-bindings.js'; + +const revenueBody = { + id: 'app.objectstack.hotcrm.revenue', + name: 'revenue', + objects: [{ name: 'crm_product', fields: { name: { type: 'text' }, price: { type: 'currency' } } }], + datasets: [{ + name: 'product_metrics', + label: 'Product metrics', + object: 'crm_product', + measures: [{ name: 'product_count', label: 'Products', aggregate: 'count' }], + dimensions: [{ name: 'name', field: 'name' }], + }], +}; + +const appBody = (dataset: string, values: string[] = ['product_count']) => ({ + id: 'app.objectstack.hotcrm', + name: 'hotcrm', + dashboards: [{ + name: 'crm_overview_dashboard', + label: 'CRM Overview', + widgets: [{ + id: 'top_products', + type: 'table', + dataset, + dimensions: ['name'], + values, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], +}); + +/** `packageBodyAsStack`'s shape: the body, its own manifest, the artifact's entries. */ +const asPackageStack = (body: Record, siblings: Record[]) => ({ + ...body, + manifest: body, + packages: [body, ...siblings].map((manifest) => ({ manifest })), +}); + +const datasetUnknown = (stack: Record) => + validateWidgetBindings(stack).filter((f) => f.rule === WIDGET_DATASET_UNKNOWN); + +describe('widget-dataset-unknown — the artifact resolves a sibling package’s dataset', () => { + it('ACCEPTS an app widget bound to a dataset the module package owns', () => { + expect(datasetUnknown(asPackageStack(appBody('product_metrics'), [revenueBody]))).toEqual([]); + }); + + it('still REFUSES a dataset NO package of the artifact declares', () => { + const findings = datasetUnknown(asPackageStack(appBody('product_nowhere'), [revenueBody])); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + where: 'dashboard "crm_overview_dashboard" › widget "top_products"', + }); + }); + + it('REFUSES the sibling dataset when judged WITHOUT the artifact — the unchanged single-package answer', () => { + expect(datasetUnknown(appBody('product_metrics'))).toHaveLength(1); + }); + + it('judges the resolved sibling dataset’s measures like a local one', () => { + const findings = validateWidgetBindings(asPackageStack(appBody('product_metrics', ['revenue_sum']), [revenueBody])); + expect(findings.map((f) => f.rule)).toContain(WIDGET_MEASURE_UNKNOWN); + }); + + it('does not judge a sibling’s dataset-level conditions on this package’s pass', () => { + const incoherent = { + ...revenueBody, + datasets: [{ + ...revenueBody.datasets[0], + measures: [{ name: 'price_total', label: 'Total', aggregate: 'sum', field: 'price' }], + }], + }; + const findings = validateWidgetBindings(asPackageStack(appBody('product_metrics', ['price_total']), [incoherent])); + expect(findings.filter((f) => f.path.startsWith('datasets['))).toEqual([]); + }); + + it('reaches the rule table the per-package pass runs', () => { + const stack = asPackageStack(appBody('product_metrics'), [revenueBody]); + const findings = runAuthoringRules('validate', { normalized: stack, parsed: stack }); + expect(findings.filter((f) => f.rule === WIDGET_DATASET_UNKNOWN)).toEqual([]); + const dangling = asPackageStack(appBody('product_nowhere'), [revenueBody]); + expect( + runAuthoringRules('validate', { normalized: dangling, parsed: dangling }).filter((f) => f.rule === WIDGET_DATASET_UNKNOWN), + ).toHaveLength(1); + }); +}); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index d5b1b5debf3..605ce133dcc 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -9,6 +9,7 @@ import { indexObjectGraph, isUnjudgeable, joinablePrefixes, + packagesOf, recordsOf, resolveFieldPath, suggestName, @@ -691,6 +692,23 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { for (const ds of recordsOf(stack.datasets)) { if (typeof ds.name === 'string') datasets.set(ds.name, ds); } + // [#25514] A widget's `dataset` resolves against the ARTIFACT: the datasets + // every `packages[]` body declares join the map as RESOLUTION CONTEXT, the way + // `validate-object-references.ts` widens rung ① with the artifact's objects. + // A cross-domain dashboard in the app package reads a module's dataset (the + // ADR-0130 plan rule "multi-module dashboards move to the app package"), and + // the runtime serves every package's datasets from one analytics service. + // ⛔ It changes what a widget can RESOLVE, never what is JUDGED: the + // dataset-level checks below still walk `stack.datasets` alone, and a name no + // package declares still resolves nowhere. The stack's own declaration wins a + // name it shares with a body — on the per-package pass that body IS this one. + for (const entry of packagesOf(stack)) { + const body = entry.manifest; + if (!body || typeof body !== 'object' || Array.isArray(body)) continue; + for (const ds of recordsOf((body as AnyRec).datasets)) { + if (typeof ds.name === 'string' && !datasets.has(ds.name)) datasets.set(ds.name, ds); + } + } // ── (0) dataset measures aggregate their field coherently ── // A measure that SUMs a percentage/rate field produces a meaningless total From 5cf84d58498c214929fce7a76f128cd865889598 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:07:33 +0000 Subject: [PATCH 3/3] docs: changeset and multi-package guide for artifact-scoped flow and dataset references Claude-Session: https://claude.ai/code/session_011SU6BLnw3XLU8nKfnWMYVG Co-authored-by: Claude --- .../25514-artifact-flow-dataset-refs.md | 19 +++++++++++++++++++ content/docs/getting-started/examples.mdx | 9 +++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/25514-artifact-flow-dataset-refs.md diff --git a/.changeset/25514-artifact-flow-dataset-refs.md b/.changeset/25514-artifact-flow-dataset-refs.md new file mode 100644 index 00000000000..48ef3f3bcb6 --- /dev/null +++ b/.changeset/25514-artifact-flow-dataset-refs.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat: a package's action can launch a flow, and its dashboard widget can bind a dataset, that another package of the same release artifact ships + +Clause-②: yes (widening) +Metadata-protocol: added + +One release artifact carries several packages (ADR-0130 D4). Two per-package checks judged a reference against one package and refused a target a sibling package provides. The artifact carries the target, and the runtime serves it. + +- **`@objectstack/spec`: `defineStack(config, { artifactFlows })`.** This new `DefineStackOptions` key lists flow names that the other packages of the artifact provide. A `type: 'flow'` action's `target` resolves against the stack's own `flows` plus these names. That covers a top-level action, an action authored on an object, and an inline page action. It works the way `artifactObjects` already does for permission grants and seed rows. + - The option widens a check that already runs. A stack that declares no flows of its own still leaves its flow targets unresolved (a plugin may serve them). + - `defineStack` records every target that resolved only through the list, as a non-enumerable mark on the stack it returns. `composeStacks` refuses the composition when no input defines that flow. The refusal uses the same `STACK_CROSS_REFERENCE_INVALID` envelope and the same per-finding message as the per-stack pass. An input that did not opt in cannot newly fail at composition. + - A flow that no package of the artifact defines is refused, as before. +- **`@objectstack/lint`: `widget-dataset-unknown`.** The rule resolves a widget's `dataset` against the datasets that every `packages[]` body declares, as well as the stack's own. This is the resolution context that `os build`, `os validate` and `os lint` already pass to each package's run. Dataset-level checks still judge only the package's own datasets. A dataset that no package declares is refused, as before. The runtime publish gate passes no `packages[]`, so its behaviour is unchanged. + +Fix for a module split such as HotCRM's: in the app package, write `defineStack(appConfig, { artifactFlows: revenue.flows?.map((f) => f.name) })` and compose both packages with `composeStacks([...], { manifest: 'preserve' })`. A cross-package dataset binding needs no change. diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index be43a3526cc..abe77cf6c16 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -446,6 +446,15 @@ The rules worth knowing before you split a product this way: stack untouched, so composing one package on its own does not check the claim. Hooks are the deliberate exception: a hook still belongs to the package that owns its object. +- **Actions may launch a sibling package's flow; dashboards may bind its dataset.** + A `type: 'flow'` action resolves its `target` against the artifact once you + hand `defineStack` the sibling flow names: + `defineStack(appConfig, { artifactFlows: ordersStack.flows?.map((f) => f.name) })`. + The claim is checked the same way: in a composition of two or more packages, + `composeStacks` refuses a claimed flow that no package defines. A dashboard + widget's `dataset` needs no option. `os build`, `os validate` and `os lint` + resolve it against every package of the artifact, and still refuse a dataset + that no package declares. - **One artifact, one version.** Everything inside ships, installs and upgrades together; you cannot hot-fix one module on its own. A module that needs its own release cadence belongs in its own artifact.