From 9f540b491713a185d11182f8c18499e90a5fabf5 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 12:47:19 +0200 Subject: [PATCH 1/5] Add type-checked reactor replay-decision diagnostic --- .../with_configurations.ts | 5 + .../with_reactor_replay_decisions.ts | 84 +++++++++++ Source/CodeAnalysis/index.ts | 7 +- Source/CodeAnalysis/rules/arcchr0006.ts | 136 ++++++++++++++++++ 4 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts create mode 100644 Source/CodeAnalysis/rules/arcchr0006.ts diff --git a/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_configurations.ts b/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_configurations.ts index d515109f..7f27f41d 100644 --- a/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_configurations.ts +++ b/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_configurations.ts @@ -22,6 +22,11 @@ describe('when configuring the Arc rules', () => { } }); + it('should enable the reactor replay rule only with type information', () => { + (recommended.rules?.['arc-core/arcchr0006'] === undefined).should.equal(true); + (typeChecked.rules?.['arc-core/arcchr0006'] === 'error').should.equal(true); + }); + it('should enable the Guid response rule only with type information', () => { (recommended.rules?.['arc-core/arcchr0010'] === undefined).should.equal(true); (typeChecked.rules?.['arc-core/arcchr0010'] === 'error').should.equal(true); diff --git a/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts b/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts new file mode 100644 index 00000000..fcad227b --- /dev/null +++ b/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { RuleTester } from '@typescript-eslint/rule-tester'; +import parser from '@typescript-eslint/parser'; +import { afterAll, describe, it } from 'vitest'; +import { arcchr0006 } from '../../rules/arcchr0006.js'; + +RuleTester.afterAll = afterAll; +RuleTester.describe = describe; +RuleTester.it = it; +const filename = `${process.cwd()}/lint-fixture.ts`; +const tester = new RuleTester({ languageOptions: { parser, parserOptions: { + projectService: { allowDefaultProject: ['lint-fixture.ts'] }, tsconfigRootDir: process.cwd() +} }, defaultFilenames: { ts: filename, tsx: `${process.cwd()}/lint-fixture.tsx` } }); +const imports = `import { reactor, onceOnly, replay } from '@cratis/chronicle/reactors'; +import { eventType } from '@cratis/chronicle/events'; +import { command } from '@cratis/arc.core'; +@eventType() class BookReserved { isbn = ''; } +@eventType() class BookReturned { isbn = ''; } +@command() class DecreaseStock { constructor(public isbn = '') {} handle() {} } +`; +const fixture = (members: string, prefix = '@reactor()'): string => `${imports}${prefix} class StockKeeping { ${members} }`; +const invalid = (code: string, handlers = "'bookReserved'") => ({ code, errors: [{ messageId: 'replay' as const, data: { handlers } }] }); + +tester.run('arcchr0006 when a reactor returns commands', arcchr0006, { + valid: [ + fixture('@onceOnly() bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }'), + fixture('bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }', '@onceOnly() @reactor()'), + fixture(`bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + @replay() replayBookReserved(event: BookReserved) {}`), + fixture(`bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + @replay(BookReserved) private onReplay(event: BookReserved) {}`), + // Replay dispatch uses the method name rather than the TypeScript parameter annotation. + fixture(`bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + @replay() replayBookReserved(event: BookReturned) {}`), + fixture(`bookReserved(event: BookReserved) {} + @replay() replayBookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }`), + fixture(`@onceOnly() bookReserved(event: BookReserved) { return this.adjust(event.isbn); } + @onceOnly() bookReturned(event: BookReturned) { return this.adjust(event.isbn); } + private adjust(isbn: string) { return new DecreaseStock(isbn); }`), + fixture('private unused(value: string) { return new DecreaseStock(value); }'), + fixture('private bookReserved(value: string) { return new DecreaseStock(value); }'), + fixture('bookReserved(event: BookReserved) { return event; }'), + fixture('bookReserved(event: BookReserved) { return new Undecorated(); }') + '\nclass Undecorated {}', + fixture('bookReserved(value: string) { return new DecreaseStock(value); }'), + fixture('bookReserved(value: UnknownEvent) { return new DecreaseStock(value.isbn); }') + + '\nclass UnknownEvent { isbn = ""; }', + fixture('bookReserved(event: BookReserved) { return new Other(); } classMethod() {}') + '\nclass Other {}', + fixture('bookReserved(event: BookReserved) {} private unrelated(event: BookReserved) { return new DecreaseStock(event.isbn); }'), + `${imports}class NotAReactor { bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } }`, + `import { reactor } from 'other'; ${imports}@reactor() class OtherReactor { + bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } }`, + fixture('bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }', + "@reactor() @onceOnly()"), + fixture(`bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + @replay(BookReturned) replayOther(event: BookReturned) {}`.replace('return new DecreaseStock(event.isbn);', 'return event;')) + ], + invalid: [ + invalid(fixture('bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }')), + invalid(fixture(`bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + @replay() replayBookReturned(event: BookReturned) {}`)), + invalid(`import { reactor as observe } from '@cratis/chronicle'; + import { eventType } from '@cratis/chronicle/events'; + import { command as arcCommand } from '@cratis/arc.core'; + @eventType() class Created {} + @arcCommand() class C { handle() {} } + @observe() class R { created(event: Created) { return new C(); } }`, "'created'"), + invalid(fixture('private bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }')), + invalid(fixture('bookReserved(event: BookReserved) { return [new DecreaseStock(event.isbn)]; }')), + invalid(fixture('async bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }')), + invalid(fixture(`bookReserved(event: BookReserved) { + const create = () => new DecreaseStock(event.isbn); return create(); }`)), + invalid(fixture(`bookReserved(event: BookReserved) { return this.adjust(event.isbn); } + private adjust(isbn: string) { return new DecreaseStock(isbn); }`)), + invalid(fixture(`bookReserved(event: BookReserved) { return this.first(event.isbn); } + private first(isbn: string) { return this.second(isbn); } + private second(isbn: string) { return new DecreaseStock(isbn); }`)), + invalid(fixture(`bookReserved(event: BookReserved) { return this.adjust(event.isbn); } + bookReturned(event: BookReturned) { return this.adjust(event.isbn); } + private adjust(isbn: string) { return new DecreaseStock(isbn); }`), "'bookReserved' and 'bookReturned'"), + invalid(fixture(`bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + @replay(BookReturned) other(event: BookReturned) {}`)) + ] +}); diff --git a/Source/CodeAnalysis/index.ts b/Source/CodeAnalysis/index.ts index b5dd18a5..08b3c9b7 100644 --- a/Source/CodeAnalysis/index.ts +++ b/Source/CodeAnalysis/index.ts @@ -12,6 +12,7 @@ import { arc0014 } from './rules/arc0014.js'; import { arc0015 } from './rules/arc0015.js'; import { arc0019 } from './rules/arc0019.js'; import { arcchr0003 } from './rules/arcchr0003.js'; +import { arcchr0006 } from './rules/arcchr0006.js'; import { arcchr0007 } from './rules/arcchr0007.js'; import { arcchr0009 } from './rules/arcchr0009.js'; import { arcchr0010 } from './rules/arcchr0010.js'; @@ -27,7 +28,7 @@ import { validatorTarget } from './rules/validatorTarget.js'; /** Arc server diagnostics, including .NET ARC identifiers where semantics overlap. */ const rules = { arc0002, arc0003, arc0004, arc0005, arc0010, arc0012, arc0014, arc0015, arc0019, - arcchr0003, arcchr0007, arcchr0009, arcchr0010, + arcchr0003, arcchr0006, arcchr0007, arcchr0009, arcchr0010, 'missing-field': missingField, 'declared-field': declaredField, 'inject-binding': injectBinding, @@ -46,13 +47,13 @@ const recommended: TSESLint.FlatConfig.Config = { name: 'arc-core/recommended', files: ['**/*.ts', '**/*.tsx'], plugins: { 'arc-core': plugin }, - rules: Object.fromEntries(Object.keys(rules).filter(name => name !== 'query-argument-name' && name !== 'arcchr0010').map(name => [`arc-core/${name}`, 'error'])) + rules: Object.fromEntries(Object.keys(rules).filter(name => name !== 'query-argument-name' && name !== 'arcchr0006' && name !== 'arcchr0010').map(name => [`arc-core/${name}`, 'error'])) }; const recommendedTypeChecked: TSESLint.FlatConfig.Config = { ...recommended, name: 'arc-core/recommended-type-checked', languageOptions: { parserOptions: { projectService: true } }, - rules: { ...recommended.rules, 'arc-core/arcchr0010': 'error' } + rules: { ...recommended.rules, 'arc-core/arcchr0006': 'error', 'arc-core/arcchr0010': 'error' } }; plugin.configs = { recommended, 'recommended-type-checked': recommendedTypeChecked }; export { rules, recommended, recommendedTypeChecked }; diff --git a/Source/CodeAnalysis/rules/arcchr0006.ts b/Source/CodeAnalysis/rules/arcchr0006.ts new file mode 100644 index 00000000..706597dd --- /dev/null +++ b/Source/CodeAnalysis/rules/arcchr0006.ts @@ -0,0 +1,136 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { AST_NODE_TYPES, ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; +import { typesFor } from './bindingTypes.js'; +import { imported, tsImported } from './syntax.js'; + +/** Warn when a reactor returns an Arc command without deciding what replay should do. */ +export const arcchr0006 = ESLintUtils.RuleCreator.withoutDocs({ + meta: { type: 'problem', docs: { description: 'Reactor returning commands needs a replay decision' }, + messages: { replay: 'Reactor handler {{handlers}} returns an Arc command, which replay will execute again. ' + + 'Mark the class or handler @onceOnly() to skip replay (not ordinary re-delivery), or declare an @replay() handler for the same event. ' + + 'Choose carefully: once-only skips replay and may not be appropriate for recurring events.' }, schema: [] }, + defaultOptions: [], + create(context) { + const types = typesFor(context); + if (!types) return {}; + const { checker } = types; + const chronicleDecorator = (node: TSESTree.Node, name: string): boolean => + 'decorators' in node && Array.isArray(node.decorators) && node.decorators.some(decorator => { + const expression = decorator.expression.type === AST_NODE_TYPES.CallExpression ? decorator.expression.callee : decorator.expression; + return imported(context, expression, '@cratis/chronicle/reactors', name) || + imported(context, expression, '@cratis/chronicle', name); + }); + const decoratedCommand = (value: ts.Type): boolean => { + if (value.isUnion()) return value.types.some(decoratedCommand); + const awaited = checker.getAwaitedType(value); + if (awaited && awaited !== value) return decoratedCommand(awaited); + if (checker.isArrayType(value) || checker.isTupleType(value)) { + const element = checker.getIndexTypeOfType(value, ts.IndexKind.Number); + return !!element && decoratedCommand(element); + } + return value.getSymbol()?.declarations?.some(declaration => ts.isClassDeclaration(declaration) && + ts.canHaveDecorators(declaration) && ts.getDecorators(declaration)?.some(decorator => + ts.isCallExpression(decorator.expression) && + tsImported(checker, decorator.expression.expression, '@cratis/arc.core', 'command'))) ?? false; + }; + const eventSymbol = (method: TSESTree.MethodDefinition): ts.Symbol | undefined => { + const parameter = method.value.params[0]; + if (!parameter || parameter.type !== AST_NODE_TYPES.Identifier || !parameter.typeAnnotation) return undefined; + const symbol = checker.getTypeAtLocation(types.node(parameter.typeAnnotation.typeAnnotation)).getSymbol(); + if (!symbol?.declarations?.some(declaration => ts.isClassDeclaration(declaration) && + ts.canHaveDecorators(declaration) && ts.getDecorators(declaration)?.some(decorator => + ts.isCallExpression(decorator.expression) && + (tsImported(checker, decorator.expression.expression, '@cratis/chronicle/events', 'eventType') || + tsImported(checker, decorator.expression.expression, '@cratis/chronicle', 'eventType'))))) return undefined; + return symbol; + }; + const replayEvent = (method: TSESTree.MethodDefinition, events: Map): ts.Symbol | undefined => { + const decorator = method.decorators?.find(item => { + const expression = item.expression.type === AST_NODE_TYPES.CallExpression ? item.expression.callee : item.expression; + return imported(context, expression, '@cratis/chronicle/reactors', 'replay') || + imported(context, expression, '@cratis/chronicle', 'replay'); + }); + if (!decorator) return undefined; + const expression = decorator.expression; + if (expression.type === AST_NODE_TYPES.CallExpression && expression.arguments.length) { + const target = expression.arguments[0]; + if (!target || target.type === AST_NODE_TYPES.SpreadElement) return undefined; + const symbol = checker.getSymbolAtLocation(types.node(target)); + return symbol && (symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol); + } + const name = method.key.type === AST_NODE_TYPES.Identifier ? method.key.name : ''; + return name.startsWith('replay') ? events.get(name.slice('replay'.length)) : undefined; + }; + return { ClassDeclaration(node) { + if (!node.id || !chronicleDecorator(node, 'reactor') || chronicleDecorator(node, 'onceOnly')) return; + const methods = node.body.body.filter((member): member is TSESTree.MethodDefinition => + member.type === AST_NODE_TYPES.MethodDefinition && !member.static && member.key.type === AST_NODE_TYPES.Identifier); + const events = new Map(); + for (const method of methods) { + const event = eventSymbol(method); + if (event) events.set(event.name, event); + } + const replayed = new Set(methods.map(method => replayEvent(method, events)).filter((event): event is ts.Symbol => !!event)); + const handlers = methods.filter(method => { + const event = eventSymbol(method); + if (!event || method.key.type !== AST_NODE_TYPES.Identifier || + method.key.name !== event.name.charAt(0).toLowerCase() + event.name.slice(1) || + chronicleDecorator(method, 'replay') || chronicleDecorator(method, 'onceOnly') || replayed.has(event)) return false; + return true; + }); + if (!handlers.length) return; + const owner = types.node(node); + const results = new Map(); + const calls = new Map>(); + for (const method of methods) { + const declaration = types.node(method); + if (!ts.isMethodDeclaration(declaration) || !declaration.body) continue; + const returns: ts.ReturnStatement[] = []; + const reached = new Set(); + const visit = (part: ts.Node): void => { + if (part !== declaration.body && (ts.isClassLike(part) || ts.isFunctionLike(part))) return; + if (ts.isReturnStatement(part) && part.expression && decoratedCommand(checker.getTypeAtLocation(part.expression))) { + const expression = ts.isAwaitExpression(part.expression) ? part.expression.expression : part.expression; + const callee = ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && + expression.expression.expression.kind === ts.SyntaxKind.ThisKeyword ? + checker.getSymbolAtLocation(expression.expression.name) : undefined; + // The callee's return is reported at its own site, once for every reaching handler. + if (!callee?.declarations?.some(candidate => ts.isMethodDeclaration(candidate) && candidate.parent === owner)) returns.push(part); + } + if (ts.isCallExpression(part) && ts.isPropertyAccessExpression(part.expression) && + part.expression.expression.kind === ts.SyntaxKind.ThisKeyword) { + const symbol = checker.getSymbolAtLocation(part.expression.name); + for (const candidate of symbol?.declarations ?? []) { + if (ts.isMethodDeclaration(candidate) && candidate.parent === owner) reached.add(candidate); + } + } + ts.forEachChild(part, visit); + }; + visit(declaration.body); + results.set(declaration, returns); + calls.set(declaration, reached); + } + const reaches = (start: ts.MethodDeclaration, target: ts.MethodDeclaration, visited: Set): boolean => { + if (start === target) return true; + if (visited.has(start)) return false; + visited.add(start); + return [...calls.get(start) ?? []].some(next => reaches(next, target, visited)); + }; + for (const [method, returns] of results) { + if (!returns.length) continue; + const undecided = handlers.filter(handler => reaches(types.node(handler) as ts.MethodDeclaration, method, new Set())) + .map(handler => handler.key.type === AST_NODE_TYPES.Identifier ? handler.key.name : '') + .sort(); + if (!undecided.length) continue; + const names = undecided.map(name => `'${name}'`); + const formatted = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; + for (const statement of returns) { + const location = types.estree(statement.expression!); + if (location) context.report({ node: location, messageId: 'replay', data: { handlers: formatted } }); + } + } + } }; + } +}); From 94de4dab48b7400a0c9e52dc899c1b9d904b1f05 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 12:47:23 +0200 Subject: [PATCH 2/5] Document ARCCHR0006 and its replay-analysis bounds --- Documentation/chronicle/code-analysis.md | 18 +++++---- Documentation/code-analysis/ARCCHR0006.md | 45 +++++++++++++++++++++++ Documentation/code-analysis/index.md | 4 +- Documentation/code-analysis/toc.yml | 2 + Documentation/reference/capabilities.md | 4 +- Documentation/reference/diagnostics.md | 3 +- 6 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 Documentation/code-analysis/ARCCHR0006.md diff --git a/Documentation/chronicle/code-analysis.md b/Documentation/chronicle/code-analysis.md index cc9920f9..5f531f3c 100644 --- a/Documentation/chronicle/code-analysis.md +++ b/Documentation/chronicle/code-analysis.md @@ -4,10 +4,10 @@ description: Arc on .NET's Chronicle diagnostics mapped to TypeScript lint rules --- Arc on .NET v22.23.0 ships ten Chronicle analyzers (`ARCCHR0001`–`ARCCHR0010`). -Four have bounded TypeScript ESLint analogs in `@cratis/eslint-plugin-arc-core`. +Five have bounded TypeScript ESLint analogs in `@cratis/eslint-plugin-arc-core`. Configure the plugin as described in [Code analysis](../code-analysis/index.md). Both presets enable `arcchr0003`, `arcchr0007`, and `arcchr0009`. -`arcchr0010` needs type information and is enabled by `recommended-type-checked`. +`arcchr0006` and `arcchr0010` need type information and are enabled by `recommended-type-checked`. ESLint reports enabled rules as errors, including analogs of .NET warnings. ## ARCCHR mapping @@ -19,7 +19,7 @@ ESLint reports enabled rules as errors, including analogs of .NET warnings. | [ARCCHR0003](../code-analysis/ARCCHR0003.md), reactor reaches default log | Warning | ESLint analog | | ARCCHR0004, redundant `[EventType]` id | Warning | N/A | | ARCCHR0005, Chronicle used but not configured | Warning | Partly caught at runtime | -| ARCCHR0006, reactor executes a command without replay decision | Warning | Not implemented yet | +| [ARCCHR0006](../code-analysis/ARCCHR0006.md), reactor returns a command without a replay decision | Warning | Type-checked ESLint analog | | [ARCCHR0007](../code-analysis/ARCCHR0007.md), command injects event log | Warning | ESLint analog | | ARCCHR0008, data annotations `[Key]` | Warning | N/A | | [ARCCHR0009](../code-analysis/ARCCHR0009.md), secret-looking command property | Warning | ESLint analog for names not masked at runtime | @@ -37,9 +37,11 @@ ESLint reports enabled rules as errors, including analogs of .NET warnings. - **ARCCHR0005:** `commandReadModel(Type)` without an owner fails `build()`. A returned event without `withChronicle` becomes an ordinary response and is not caught. A per-file ESLint rule cannot prove registration in a separate host module. -- **ARCCHR0006:** Not implemented yet. TypeScript reactors normally return commands instead of calling - .NET's `ICommandPipeline.Execute`; a returned command still needs an explicit replay policy. - The SDK supports `@onceOnly()` and `@replay()`, so a bounded lint rule is feasible. +- **ARCCHR0006:** TypeScript reactors return Arc commands rather than calling .NET's + `ICommandPipeline.Execute`. The type-checked rule follows same-class helper calls back to live + handlers and warns when none has a replay decision. Class- or handler-level `@onceOnly()` and an + `@replay()` handler for the same event silence it. Dispatch matches the camel-cased event class + name, not the parameter annotation. See [the rule's bounds](../code-analysis/ARCCHR0006.md). - **ARCCHR0007:** The rule finds direct `eventLog.append` or `appendMany` calls from a command's `handle()` or `provide()` through an artifact store or a `@inject(ChronicleReadModels | ChronicleRuntime)` parameter, including local and inline `getStore()` calls. Indirect appends remain a review concern. @@ -59,8 +61,8 @@ this mapping does not add either rule. ## What to check in review - Appends through helper methods or stores not held directly by a command or reactor can bypass the return-value pipeline. -- Reactors returning commands need a replay decision; `@onceOnly()` skips replay but does not prevent - failed-partition re-delivery. See [Returning commands from a reactor](reactors/command-side-effects.md#when-a-command-fails). +- Returned commands executed outside a direct reactor return expression, such as manual Arc server calls, + still need a replay decision; `@onceOnly()` skips replay but does not prevent failed-partition re-delivery. See [Returning commands from a reactor](reactors/command-side-effects.md#when-a-command-fails). - An app that returns events must install `withChronicle`; check the host, not just the artifact file. - A tuple carrying an ordinary string instead of `eventSourceIdResponse(id)` does not select an event source; see [Resolving the event source ID](resolving-event-source-id.md#return-the-id-to-the-caller). diff --git a/Documentation/code-analysis/ARCCHR0006.md b/Documentation/code-analysis/ARCCHR0006.md new file mode 100644 index 00000000..76959c64 --- /dev/null +++ b/Documentation/code-analysis/ARCCHR0006.md @@ -0,0 +1,45 @@ +--- +title: ARCCHR0006 — Reactor returning commands needs a replay decision +description: Declare how a Chronicle reactor that returns an Arc command behaves during replay. +--- + +A reactor returning an Arc `@command()` instance (or an array of commands) runs that command again +when Chronicle replays the event. Decide whether replay should run the effect: mark the reactor or +individual handler `@onceOnly()` to skip it, or provide an alternate `@replay()` handler for the +same event. This is a type-checked analog of the .NET warning for `ICommandPipeline.Execute`; +ESLint reports it as an error in the `recommended-type-checked` preset. + +```ts +import { onceOnly, reactor } from '@cratis/chronicle/reactors'; + +@reactor() +class StockKeeping { + @onceOnly() + bookReserved(event: BookReserved): DecreaseStock { + return new DecreaseStock(event.isbn); + } +} +``` + +`@onceOnly()` is a replay decision, not duplicate protection: failed-partition recovery can still +re-deliver an event and run the command again. Do not use once-only if replay needs to rebuild the +side effect. Instead use `@replay()` on a separate method: `replayBookReserved()` for an event class +named `BookReserved`, or `@replay(BookReserved)` on a differently named method. Chronicle chooses +handlers by the event class name, **not** by the TypeScript type annotation of the first parameter. +An alternate replay handler takes over for that event even if it returns nothing. See +[Returning commands from a reactor](../chronicle/reactors/command-side-effects.md#when-a-command-fails). + +The rule recognizes `@reactor()`, `@onceOnly()`, and `@replay()` imported from +`@cratis/chronicle/reactors` or `@cratis/chronicle`, and return expressions whose checked type is +an Arc `@command()` class (including promises and arrays). It follows direct `this.method()` calls +inside the same reactor through helpers and reports at the return site, naming undecided handlers +that reach it. The live handler must have the event class's camel-cased name and a first parameter +typed as a decorated `@eventType()` class. A replay handler must name that event by convention or +pass its constructor to `@replay(Event)`; a replay handler for another event does not count. + +This is bounded static analysis: it does not prove artifact registration, follow calls through +other objects or inherited helpers, infer commands behind erased `unknown`/`any` values, or find +commands executed manually through an Arc server instead of returned. Calls to same-class helpers +are treated as reachable even when their result is discarded or they run conditionally; inspect +such reports in context. It does not assess whether replay and retries are safe for a particular +command. [Chronicle code analysis](../chronicle/code-analysis.md) maps the remaining diagnostics. diff --git a/Documentation/code-analysis/index.md b/Documentation/code-analysis/index.md index 42f463c2..f823352c 100644 --- a/Documentation/code-analysis/index.md +++ b/Documentation/code-analysis/index.md @@ -17,7 +17,7 @@ export default tseslint.config( ); ``` -Run `eslint src`. Both presets enable the mapped ARC diagnostics, three Chronicle rules, and TypeScript-only rules below, except the opt-in name rule. `recommended` works without type information: type-dependent comparisons are skipped, while syntax checks still run. `recommended-type-checked` enables `parserOptions.projectService` to check parameter and token types and adds `arcchr0010`; files without an applicable tsconfig must be excluded or added to the project service. Both presets require a TypeScript parser. Arc decorators must resolve to imports from `@cratis/arc.core`, and `@field` must come from `@cratis/fundamentals` (including aliases and namespace imports); custom wrappers are not recognized. Runtime and proxy-generator checks remain authoritative. +Run `eslint src`. Both presets enable the mapped ARC diagnostics, three Chronicle rules, and TypeScript-only rules below, except the opt-in name rule. `recommended` works without type information: type-dependent comparisons are skipped, while syntax checks still run. `recommended-type-checked` enables `parserOptions.projectService` to check parameter and token types and adds `arcchr0006` and `arcchr0010`; files without an applicable tsconfig must be excluded or added to the project service. Both presets require a TypeScript parser. Arc decorators must resolve to imports from `@cratis/arc.core`, and `@field` must come from `@cratis/fundamentals` (including aliases and namespace imports); custom wrappers are not recognized. Runtime and proxy-generator checks remain authoritative. ## ARC diagnostic mapping @@ -42,6 +42,6 @@ The .NET IDs are retained only when the same mistake can happen in the TypeScrip | [ARC0019](ARC0019.md) | Analog | `@allowAnonymous()` conflicts with `@authorize()` or `@roles()` on one declaration. | | ARC0020–ARC0021 | N/A | ASP.NET Core attributes and authentication schemes do not exist in this Node host. | -Chronicle analogs: [ARCCHR0003](ARCCHR0003.md), [ARCCHR0007](ARCCHR0007.md), [ARCCHR0009](ARCCHR0009.md), and type-checked [ARCCHR0010](ARCCHR0010.md). See [Chronicle code analysis](../chronicle/code-analysis.md) for the complete .NET ARCCHR mapping, including diagnostics without TypeScript equivalents. +Chronicle analogs: [ARCCHR0003](ARCCHR0003.md), type-checked [ARCCHR0006](ARCCHR0006.md), [ARCCHR0007](ARCCHR0007.md), [ARCCHR0009](ARCCHR0009.md), and type-checked [ARCCHR0010](ARCCHR0010.md). See [Chronicle code analysis](../chronicle/code-analysis.md) for the complete .NET ARCCHR mapping, including diagnostics without TypeScript equivalents. TypeScript-only rules: [missing-field](missing-field.md), [declared-field](declared-field.md), [inject-binding](inject-binding.md), [query-binding](query-binding.md), [query-argument-name](query-argument-name.md) (opt-in), [misplaced-decorator](misplaced-decorator.md), [unexported-artifact](unexported-artifact.md), and [validator-target](validator-target.md). No automatic fix is offered: .NET's `Task` unwrap fix changes async rejection semantics in JavaScript; its `nameof` fix has no TypeScript equivalent. Manual fixes are shown on each page. diff --git a/Documentation/code-analysis/toc.yml b/Documentation/code-analysis/toc.yml index 5d02b57f..8a1adc64 100644 --- a/Documentation/code-analysis/toc.yml +++ b/Documentation/code-analysis/toc.yml @@ -20,6 +20,8 @@ href: ARC0015.md - name: ARC0019 Authorization conflict href: ARC0019.md +- name: ARCCHR0006 Reactor replay decision + href: ARCCHR0006.md - name: Missing field href: missing-field.md - name: Declared field diff --git a/Documentation/reference/capabilities.md b/Documentation/reference/capabilities.md index a5788b30..88d5f5af 100644 --- a/Documentation/reference/capabilities.md +++ b/Documentation/reference/capabilities.md @@ -119,8 +119,8 @@ Evidence paths are relative to the repository root. Spec folders follow `for_ Date: Sat, 26 Sep 2026 12:58:26 +0200 Subject: [PATCH 3/5] Tighten reactor command return analysis to runtime shapes --- .../with_reactor_replay_decisions.ts | 28 ++++++- Source/CodeAnalysis/rules/arcchr0006.ts | 74 ++++++++++++------- 2 files changed, 72 insertions(+), 30 deletions(-) diff --git a/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts b/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts index fcad227b..d3fb8209 100644 --- a/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts +++ b/Source/CodeAnalysis/for_rules/when_linting_artifacts/with_reactor_replay_decisions.ts @@ -20,7 +20,9 @@ import { command } from '@cratis/arc.core'; @command() class DecreaseStock { constructor(public isbn = '') {} handle() {} } `; const fixture = (members: string, prefix = '@reactor()'): string => `${imports}${prefix} class StockKeeping { ${members} }`; -const invalid = (code: string, handlers = "'bookReserved'") => ({ code, errors: [{ messageId: 'replay' as const, data: { handlers } }] }); +const invalid = (code: string, handlers = "'bookReserved'", line?: number) => ({ code, errors: [{ messageId: 'replay' as const, + data: { handlers, handlerLabel: handlers.includes(' and ') ? 'handlers' : 'handler', + action: handlers.includes(' and ') ? 'return' : 'returns' }, ...line && { line } }] }); tester.run('arcchr0006 when a reactor returns commands', arcchr0006, { valid: [ @@ -47,13 +49,18 @@ tester.run('arcchr0006 when a reactor returns commands', arcchr0006, { '\nclass UnknownEvent { isbn = ""; }', fixture('bookReserved(event: BookReserved) { return new Other(); } classMethod() {}') + '\nclass Other {}', fixture('bookReserved(event: BookReserved) {} private unrelated(event: BookReserved) { return new DecreaseStock(event.isbn); }'), + fixture(`bookReserved(event: BookReserved) { this.adjust(event.isbn); } + private adjust(isbn: string) { return new DecreaseStock(isbn); }`), + fixture('bookReserved(event: BookReserved) { return [Promise.resolve(new DecreaseStock(event.isbn))]; }'), + fixture('bookReserved(event: BookReserved) { return [[new DecreaseStock(event.isbn)]]; }'), + `${imports}const StockKeeping = @onceOnly() @reactor() class { + bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + };`, `${imports}class NotAReactor { bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } }`, `import { reactor } from 'other'; ${imports}@reactor() class OtherReactor { bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } }`, fixture('bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }', - "@reactor() @onceOnly()"), - fixture(`bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } - @replay(BookReturned) replayOther(event: BookReturned) {}`.replace('return new DecreaseStock(event.isbn);', 'return event;')) + '@reactor() @onceOnly()') ], invalid: [ invalid(fixture('bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }')), @@ -68,6 +75,16 @@ tester.run('arcchr0006 when a reactor returns commands', arcchr0006, { invalid(fixture('private bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }')), invalid(fixture('bookReserved(event: BookReserved) { return [new DecreaseStock(event.isbn)]; }')), invalid(fixture('async bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); }')), + invalid(fixture('bookReserved(event: BookReserved) { return Promise.resolve([new DecreaseStock(event.isbn)]); }')), + invalid(fixture('bookReserved(event: BookReserved) { return [this.adjust(event.isbn)]; } private adjust(isbn: string) { return new DecreaseStock(isbn); }')), + invalid(fixture('bookReserved(event: BookReserved) { return [...this.adjust(event.isbn)]; } private adjust(isbn: string) { return [new DecreaseStock(isbn)]; }')), + invalid(fixture(`bookReserved(event: BookReserved) { return this.ready ? this.adjust(event.isbn) : new DecreaseStock(event.isbn); } + ready = true; private adjust(isbn: string) { return new DecreaseStock(isbn); }`), "'bookReserved'", 7), + invalid(fixture(`async bookReserved(event: BookReserved) { return await this.adjust(event.isbn); } + private async adjust(isbn: string) { return new DecreaseStock(isbn); }`)), + invalid(`${imports}const StockKeeping = @reactor() class { + bookReserved(event: BookReserved) { return new DecreaseStock(event.isbn); } + };`), invalid(fixture(`bookReserved(event: BookReserved) { const create = () => new DecreaseStock(event.isbn); return create(); }`)), invalid(fixture(`bookReserved(event: BookReserved) { return this.adjust(event.isbn); } @@ -75,6 +92,9 @@ tester.run('arcchr0006 when a reactor returns commands', arcchr0006, { invalid(fixture(`bookReserved(event: BookReserved) { return this.first(event.isbn); } private first(isbn: string) { return this.second(isbn); } private second(isbn: string) { return new DecreaseStock(isbn); }`)), + invalid(fixture(`bookReserved(event: BookReserved) { return this.ready ? this.first(event.isbn) : this.second(event.isbn); } + ready = true; private first(isbn: string) { return this.second(isbn); } + private second(isbn: string) { return new DecreaseStock(isbn); }`)), invalid(fixture(`bookReserved(event: BookReserved) { return this.adjust(event.isbn); } bookReturned(event: BookReturned) { return this.adjust(event.isbn); } private adjust(isbn: string) { return new DecreaseStock(isbn); }`), "'bookReserved' and 'bookReturned'"), diff --git a/Source/CodeAnalysis/rules/arcchr0006.ts b/Source/CodeAnalysis/rules/arcchr0006.ts index 706597dd..9d0349d2 100644 --- a/Source/CodeAnalysis/rules/arcchr0006.ts +++ b/Source/CodeAnalysis/rules/arcchr0006.ts @@ -8,9 +8,9 @@ import { imported, tsImported } from './syntax.js'; /** Warn when a reactor returns an Arc command without deciding what replay should do. */ export const arcchr0006 = ESLintUtils.RuleCreator.withoutDocs({ meta: { type: 'problem', docs: { description: 'Reactor returning commands needs a replay decision' }, - messages: { replay: 'Reactor handler {{handlers}} returns an Arc command, which replay will execute again. ' + - 'Mark the class or handler @onceOnly() to skip replay (not ordinary re-delivery), or declare an @replay() handler for the same event. ' + - 'Choose carefully: once-only skips replay and may not be appropriate for recurring events.' }, schema: [] }, + messages: { replay: 'Reactor {{handlerLabel}} {{handlers}} {{action}} an Arc command, which replay will execute again. ' + + 'Use @onceOnly() on the class or handler unless replay must rebuild the effect; then declare an @replay() handler for the same event. ' + + '@onceOnly() does not prevent ordinary re-delivery.' }, schema: [] }, defaultOptions: [], create(context) { const types = typesFor(context); @@ -22,19 +22,34 @@ export const arcchr0006 = ESLintUtils.RuleCreator.withoutDocs({ return imported(context, expression, '@cratis/chronicle/reactors', name) || imported(context, expression, '@cratis/chronicle', name); }); - const decoratedCommand = (value: ts.Type): boolean => { - if (value.isUnion()) return value.types.some(decoratedCommand); - const awaited = checker.getAwaitedType(value); - if (awaited && awaited !== value) return decoratedCommand(awaited); - if (checker.isArrayType(value) || checker.isTupleType(value)) { + const decoratedCommand = (value: ts.Type, mayAwait = true, mayContainArray = true): boolean => { + if (value.isUnion()) return value.types.some(part => decoratedCommand(part, mayAwait, mayContainArray)); + if (mayAwait) { + const awaited = checker.getAwaitedType(value); + if (awaited && awaited !== value) return decoratedCommand(awaited, false, mayContainArray); + } + if (mayContainArray && (checker.isArrayType(value) || checker.isTupleType(value))) { const element = checker.getIndexTypeOfType(value, ts.IndexKind.Number); - return !!element && decoratedCommand(element); + return !!element && decoratedCommand(element, false, false); } return value.getSymbol()?.declarations?.some(declaration => ts.isClassDeclaration(declaration) && ts.canHaveDecorators(declaration) && ts.getDecorators(declaration)?.some(decorator => ts.isCallExpression(decorator.expression) && tsImported(checker, decorator.expression.expression, '@cratis/arc.core', 'command'))) ?? false; }; + // Only the top-level result is awaited; an array is executed as one level of commands. + const returnParts = (expression: ts.Expression, mayAwait = true, mayContainArray = true): + { expression: ts.Expression; mayAwait: boolean; mayContainArray: boolean }[] => { + if (ts.isAwaitExpression(expression)) return returnParts(expression.expression, mayAwait, mayContainArray); + if (ts.isConditionalExpression(expression)) return [ + ...returnParts(expression.whenTrue, mayAwait, mayContainArray), + ...returnParts(expression.whenFalse, mayAwait, mayContainArray) + ]; + if (mayContainArray && ts.isArrayLiteralExpression(expression)) return expression.elements.flatMap(element => + ts.isSpreadElement(element) ? [{ expression: element.expression, mayAwait: false, mayContainArray: true }] : + returnParts(element, false, false)); + return [{ expression, mayAwait, mayContainArray }]; + }; const eventSymbol = (method: TSESTree.MethodDefinition): ts.Symbol | undefined => { const parameter = method.value.params[0]; if (!parameter || parameter.type !== AST_NODE_TYPES.Identifier || !parameter.typeAnnotation) return undefined; @@ -63,8 +78,8 @@ export const arcchr0006 = ESLintUtils.RuleCreator.withoutDocs({ const name = method.key.type === AST_NODE_TYPES.Identifier ? method.key.name : ''; return name.startsWith('replay') ? events.get(name.slice('replay'.length)) : undefined; }; - return { ClassDeclaration(node) { - if (!node.id || !chronicleDecorator(node, 'reactor') || chronicleDecorator(node, 'onceOnly')) return; + const analyze = (node: TSESTree.ClassDeclaration | TSESTree.ClassExpression): void => { + if (!chronicleDecorator(node, 'reactor') || chronicleDecorator(node, 'onceOnly')) return; const methods = node.body.body.filter((member): member is TSESTree.MethodDefinition => member.type === AST_NODE_TYPES.MethodDefinition && !member.static && member.key.type === AST_NODE_TYPES.Identifier); const events = new Map(); @@ -91,20 +106,25 @@ export const arcchr0006 = ESLintUtils.RuleCreator.withoutDocs({ const reached = new Set(); const visit = (part: ts.Node): void => { if (part !== declaration.body && (ts.isClassLike(part) || ts.isFunctionLike(part))) return; - if (ts.isReturnStatement(part) && part.expression && decoratedCommand(checker.getTypeAtLocation(part.expression))) { - const expression = ts.isAwaitExpression(part.expression) ? part.expression.expression : part.expression; - const callee = ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && - expression.expression.expression.kind === ts.SyntaxKind.ThisKeyword ? - checker.getSymbolAtLocation(expression.expression.name) : undefined; - // The callee's return is reported at its own site, once for every reaching handler. - if (!callee?.declarations?.some(candidate => ts.isMethodDeclaration(candidate) && candidate.parent === owner)) returns.push(part); - } - if (ts.isCallExpression(part) && ts.isPropertyAccessExpression(part.expression) && - part.expression.expression.kind === ts.SyntaxKind.ThisKeyword) { - const symbol = checker.getSymbolAtLocation(part.expression.name); - for (const candidate of symbol?.declarations ?? []) { - if (ts.isMethodDeclaration(candidate) && candidate.parent === owner) reached.add(candidate); + if (ts.isReturnStatement(part) && part.expression) { + const returned = returnParts(part.expression).filter(candidate => + decoratedCommand(checker.getTypeAtLocation(candidate.expression), candidate.mayAwait, candidate.mayContainArray)); + const helpers = new Set(); + let direct = false; + for (const candidate of returned) { + const expression = candidate.expression; + const symbol = ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && + expression.expression.expression.kind === ts.SyntaxKind.ThisKeyword ? + checker.getSymbolAtLocation(expression.expression.name) : undefined; + const targets = symbol?.declarations?.filter((target): target is ts.MethodDeclaration => + ts.isMethodDeclaration(target) && target.parent === owner) ?? []; + if (targets.length) targets.forEach(target => helpers.add(target)); + else direct = true; } + if (direct) returns.push(part); + // Report the enclosing return when it also supplies a command directly; + // otherwise trace returned helper values to their own return site. + if (!direct) helpers.forEach(helper => reached.add(helper)); } ts.forEachChild(part, visit); }; @@ -128,9 +148,11 @@ export const arcchr0006 = ESLintUtils.RuleCreator.withoutDocs({ const formatted = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; for (const statement of returns) { const location = types.estree(statement.expression!); - if (location) context.report({ node: location, messageId: 'replay', data: { handlers: formatted } }); + if (location) context.report({ node: location, messageId: 'replay', data: { handlers: formatted, + handlerLabel: names.length === 1 ? 'handler' : 'handlers', action: names.length === 1 ? 'returns' : 'return' } }); } } - } }; + }; + return { ClassDeclaration: analyze, ClassExpression: analyze }; } }); From 381c2150f5abe9fe2261ce1cec470ebc89b5d588 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 12:58:26 +0200 Subject: [PATCH 4/5] Clarify ARCCHR0006 bounds and link Chronicle diagnostics --- Documentation/chronicle/code-analysis.md | 5 +++-- Documentation/code-analysis/ARCCHR0006.md | 26 +++++++++++++---------- Documentation/code-analysis/toc.yml | 8 +++++++ Documentation/reference/capabilities.md | 4 ++-- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/Documentation/chronicle/code-analysis.md b/Documentation/chronicle/code-analysis.md index 5f531f3c..ae5a635f 100644 --- a/Documentation/chronicle/code-analysis.md +++ b/Documentation/chronicle/code-analysis.md @@ -61,8 +61,9 @@ this mapping does not add either rule. ## What to check in review - Appends through helper methods or stores not held directly by a command or reactor can bypass the return-value pipeline. -- Returned commands executed outside a direct reactor return expression, such as manual Arc server calls, - still need a replay decision; `@onceOnly()` skips replay but does not prevent failed-partition re-delivery. See [Returning commands from a reactor](reactors/command-side-effects.md#when-a-command-fails). +- Commands executed manually inside a reactor (for example through an Arc server) are not detected. + They still need a replay decision; `@onceOnly()` skips replay but does not prevent failed-partition + re-delivery. See [Returning commands from a reactor](reactors/command-side-effects.md#when-a-command-fails). - An app that returns events must install `withChronicle`; check the host, not just the artifact file. - A tuple carrying an ordinary string instead of `eventSourceIdResponse(id)` does not select an event source; see [Resolving the event source ID](resolving-event-source-id.md#return-the-id-to-the-caller). diff --git a/Documentation/code-analysis/ARCCHR0006.md b/Documentation/code-analysis/ARCCHR0006.md index 76959c64..85976f46 100644 --- a/Documentation/code-analysis/ARCCHR0006.md +++ b/Documentation/code-analysis/ARCCHR0006.md @@ -31,15 +31,19 @@ An alternate replay handler takes over for that event even if it returns nothing The rule recognizes `@reactor()`, `@onceOnly()`, and `@replay()` imported from `@cratis/chronicle/reactors` or `@cratis/chronicle`, and return expressions whose checked type is -an Arc `@command()` class (including promises and arrays). It follows direct `this.method()` calls -inside the same reactor through helpers and reports at the return site, naming undecided handlers -that reach it. The live handler must have the event class's camel-cased name and a first parameter -typed as a decorated `@eventType()` class. A replay handler must name that event by convention or -pass its constructor to `@replay(Event)`; a replay handler for another event does not count. +an Arc `@command()` class (including top-level promises and one level of arrays). It follows +returned `this.method()` calls inside the same reactor through helpers and reports at the return +site, naming undecided handlers that reach it. Reactor class declarations and decorated class +expressions are supported. A replay handler must name that event by convention or pass its +constructor to `@replay(Event)`; a replay handler for another event does not count. -This is bounded static analysis: it does not prove artifact registration, follow calls through -other objects or inherited helpers, infer commands behind erased `unknown`/`any` values, or find -commands executed manually through an Arc server instead of returned. Calls to same-class helpers -are treated as reachable even when their result is discarded or they run conditionally; inspect -such reports in context. It does not assess whether replay and retries are safe for a particular -command. [Chronicle code analysis](../chronicle/code-analysis.md) maps the remaining diagnostics. +This is bounded static analysis: a live handler must have the event class's camel-cased name **and** +a first parameter typed as that same decorated `@eventType()` class. The SDK dispatches by name +alone, so a handler with no parameter, an `any` parameter, or a parameter typed as another event +class is missed. Decorators on classes from compiled shared packages are absent from their `.d.ts` +files, so those event and command classes are also missed. The rule does not prove artifact +registration, follow calls through other objects or inherited helpers, infer commands behind erased +`unknown`/`any` return types, or find commands executed manually through an Arc server instead of +returned. Conditional returned helpers are treated as reachable; calls whose results are discarded +are not. It does not assess whether replay and retries are safe for a particular command. +[Chronicle code analysis](../chronicle/code-analysis.md) maps the remaining diagnostics. diff --git a/Documentation/code-analysis/toc.yml b/Documentation/code-analysis/toc.yml index 8a1adc64..d85ffee7 100644 --- a/Documentation/code-analysis/toc.yml +++ b/Documentation/code-analysis/toc.yml @@ -20,8 +20,16 @@ href: ARC0015.md - name: ARC0019 Authorization conflict href: ARC0019.md +- name: ARCCHR0003 Reactor default log + href: ARCCHR0003.md - name: ARCCHR0006 Reactor replay decision href: ARCCHR0006.md +- name: ARCCHR0007 Command default log + href: ARCCHR0007.md +- name: ARCCHR0009 Sensitive command fields + href: ARCCHR0009.md +- name: ARCCHR0010 Guid response + href: ARCCHR0010.md - name: Missing field href: missing-field.md - name: Declared field diff --git a/Documentation/reference/capabilities.md b/Documentation/reference/capabilities.md index 88d5f5af..e4fab9a5 100644 --- a/Documentation/reference/capabilities.md +++ b/Documentation/reference/capabilities.md @@ -119,8 +119,8 @@ Evidence paths are relative to the repository root. Spec folders follow `for_ Date: Sat, 26 Sep 2026 13:00:52 +0200 Subject: [PATCH 5/5] Prepare the v0.36.0 source preview --- ContractTests/Client/package.json | 2 +- Documentation/index.md | 2 +- Documentation/reference/packages.md | 2 +- README.md | 2 +- Source/Chronicle/package.json | 6 +++--- Source/CodeAnalysis/package.json | 2 +- Source/Core/package.json | 2 +- Source/Cratis/package.json | 2 +- Source/Drizzle/package.json | 4 ++-- Source/Express/package.json | 2 +- Source/Fastify/package.json | 2 +- Source/Hono/package.json | 2 +- Source/MongoDB/package.json | 4 ++-- Source/Testing/package.json | 2 +- Source/Tools/ProxyGenerator/package.json | 2 +- yarn.lock | 8 ++++---- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index 3c47c3d7..903cafc3 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.33.0", + "version": "0.36.0", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/index.md b/Documentation/index.md index 2d55d2f3..6ce5e8da 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source. :::caution[Source preview, no full parity] -No package is published to npm; the manifests are at version 0.33.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. +No package is published to npm; the manifests are at version 0.36.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. ::: ## What it looks like diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md index aabb32c6..5779f7a7 100644 --- a/Documentation/reference/packages.md +++ b/Documentation/reference/packages.md @@ -3,7 +3,7 @@ title: Packages description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client. --- -Every package in this repository is at version 0.33.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: +Every package in this repository is at version 0.36.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: - **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository's Yarn workspace. - **In your own project.** Pack each package you need with `yarn workspace pack --out ` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers. diff --git a/README.md b/README.md index 63ffe686..7321bfe9 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ export class TaskItem { | `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.14.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. | | `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. | -Every package manifest is at version 0.33.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. +Every package manifest is at version 0.36.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. ## Try it diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index fba743d1..ebfc4fd9 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.33.0", + "version": "0.36.0", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.33.0", - "@cratis/arc.testing": "^0.33.0", + "@cratis/arc.core": "^0.36.0", + "@cratis/arc.testing": "^0.36.0", "@cratis/chronicle": "^6.7.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json index b4b54fdc..5cd9e5b5 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "description": "ESLint diagnostics for Arc for TypeScript server artifacts", diff --git a/Source/Core/package.json b/Source/Core/package.json index 5b6a1543..b2ead8e8 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index 95a1d35e..407dcf66 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "description": "Arc and experimental Chronicle composition for Node.js", diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json index 4fb0416f..1f7baacd 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.33.0", + "@cratis/arc.core": "^0.36.0", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0" }, diff --git a/Source/Express/package.json b/Source/Express/package.json index db12bc3a..986e5551 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index 508d9915..e6a3a8ab 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/package.json b/Source/Hono/package.json index 680fbf32..ab34b51f 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index 3013c039..01bf90fd 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.33.0", + "@cratis/arc.core": "^0.36.0", "@cratis/fundamentals": "^7.19.6", "@opentelemetry/api": "^1.9.0", "mongodb": "^6.21.0", diff --git a/Source/Testing/package.json b/Source/Testing/package.json index 892780dd..d3b4fb33 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.33.0", + "version": "0.36.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index 8fda3406..084dce40 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.33.0", + "version": "0.36.0", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index d198494e..07718f67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45,8 +45,8 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/arc.core": ^0.33.0 - "@cratis/arc.testing": ^0.33.0 + "@cratis/arc.core": ^0.36.0 + "@cratis/arc.testing": ^0.36.0 "@cratis/chronicle": ^6.7.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 @@ -145,7 +145,7 @@ __metadata: postgres: "npm:^3.4.9" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.33.0 + "@cratis/arc.core": ^0.36.0 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 languageName: unknown @@ -210,7 +210,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.33.0 + "@cratis/arc.core": ^0.36.0 "@cratis/fundamentals": ^7.19.6 "@opentelemetry/api": ^1.9.0 mongodb: ^6.21.0