@flow-as-code/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +30 -0
- package/SPEC.md +92 -0
- package/dist/actions.d.ts +87 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +204 -0
- package/dist/actions.js.map +1 -0
- package/dist/aws.d.ts +27 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +32 -0
- package/dist/aws.js.map +1 -0
- package/dist/blocks.d.ts +287 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +352 -0
- package/dist/blocks.js.map +1 -0
- package/dist/codegen.d.ts +21 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +1027 -0
- package/dist/codegen.js.map +1 -0
- package/dist/export.d.ts +256 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +799 -0
- package/dist/export.js.map +1 -0
- package/dist/flow.d.ts +41 -0
- package/dist/flow.d.ts.map +1 -0
- package/dist/flow.js +85 -0
- package/dist/flow.js.map +1 -0
- package/dist/flowdoc.d.ts +111 -0
- package/dist/flowdoc.d.ts.map +1 -0
- package/dist/flowdoc.js +139 -0
- package/dist/flowdoc.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +11 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +49 -0
- package/dist/layout.js.map +1 -0
- package/dist/lint/engine.d.ts +16 -0
- package/dist/lint/engine.d.ts.map +1 -0
- package/dist/lint/engine.js +37 -0
- package/dist/lint/engine.js.map +1 -0
- package/dist/lint/graph.d.ts +34 -0
- package/dist/lint/graph.d.ts.map +1 -0
- package/dist/lint/graph.js +79 -0
- package/dist/lint/graph.js.map +1 -0
- package/dist/lint/index.d.ts +7 -0
- package/dist/lint/index.d.ts.map +1 -0
- package/dist/lint/index.js +5 -0
- package/dist/lint/index.js.map +1 -0
- package/dist/lint/reporters.d.ts +6 -0
- package/dist/lint/reporters.d.ts.map +1 -0
- package/dist/lint/reporters.js +30 -0
- package/dist/lint/reporters.js.map +1 -0
- package/dist/lint/rules/action-allowed-in-flow-type.d.ts +11 -0
- package/dist/lint/rules/action-allowed-in-flow-type.d.ts.map +1 -0
- package/dist/lint/rules/action-allowed-in-flow-type.js +32 -0
- package/dist/lint/rules/action-allowed-in-flow-type.js.map +1 -0
- package/dist/lint/rules/action-count.d.ts +16 -0
- package/dist/lint/rules/action-count.d.ts.map +1 -0
- package/dist/lint/rules/action-count.js +32 -0
- package/dist/lint/rules/action-count.js.map +1 -0
- package/dist/lint/rules/error-branches.d.ts +10 -0
- package/dist/lint/rules/error-branches.d.ts.map +1 -0
- package/dist/lint/rules/error-branches.js +39 -0
- package/dist/lint/rules/error-branches.js.map +1 -0
- package/dist/lint/rules/index.d.ts +17 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/index.js +36 -0
- package/dist/lint/rules/index.js.map +1 -0
- package/dist/lint/rules/module-depth-5.d.ts +14 -0
- package/dist/lint/rules/module-depth-5.d.ts.map +1 -0
- package/dist/lint/rules/module-depth-5.js +67 -0
- package/dist/lint/rules/module-depth-5.js.map +1 -0
- package/dist/lint/rules/no-literal-arn.d.ts +21 -0
- package/dist/lint/rules/no-literal-arn.d.ts.map +1 -0
- package/dist/lint/rules/no-literal-arn.js +58 -0
- package/dist/lint/rules/no-literal-arn.js.map +1 -0
- package/dist/lint/rules/no-unresolved-token.d.ts +8 -0
- package/dist/lint/rules/no-unresolved-token.d.ts.map +1 -0
- package/dist/lint/rules/no-unresolved-token.js +71 -0
- package/dist/lint/rules/no-unresolved-token.js.map +1 -0
- package/dist/lint/rules/prompt-length-3000.d.ts +16 -0
- package/dist/lint/rules/prompt-length-3000.d.ts.map +1 -0
- package/dist/lint/rules/prompt-length-3000.js +61 -0
- package/dist/lint/rules/prompt-length-3000.js.map +1 -0
- package/dist/lint/rules/reachable-blocks.d.ts +7 -0
- package/dist/lint/rules/reachable-blocks.d.ts.map +1 -0
- package/dist/lint/rules/reachable-blocks.js +45 -0
- package/dist/lint/rules/reachable-blocks.js.map +1 -0
- package/dist/lint/rules/recording-consent-before-record.d.ts +3 -0
- package/dist/lint/rules/recording-consent-before-record.d.ts.map +1 -0
- package/dist/lint/rules/recording-consent-before-record.js +87 -0
- package/dist/lint/rules/recording-consent-before-record.js.map +1 -0
- package/dist/lint/rules/terminal-blocks.d.ts +8 -0
- package/dist/lint/rules/terminal-blocks.d.ts.map +1 -0
- package/dist/lint/rules/terminal-blocks.js +40 -0
- package/dist/lint/rules/terminal-blocks.js.map +1 -0
- package/dist/lint/rules/unique-names.d.ts +7 -0
- package/dist/lint/rules/unique-names.d.ts.map +1 -0
- package/dist/lint/rules/unique-names.js +40 -0
- package/dist/lint/rules/unique-names.js.map +1 -0
- package/dist/lint/types.d.ts +30 -0
- package/dist/lint/types.d.ts.map +1 -0
- package/dist/lint/types.js +11 -0
- package/dist/lint/types.js.map +1 -0
- package/dist/materialize.d.ts +31 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +192 -0
- package/dist/materialize.js.map +1 -0
- package/dist/package-names.d.ts +11 -0
- package/dist/package-names.d.ts.map +1 -0
- package/dist/package-names.js +25 -0
- package/dist/package-names.js.map +1 -0
- package/dist/refs.d.ts +35 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +69 -0
- package/dist/refs.js.map +1 -0
- package/dist/serialize.d.ts +13 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +86 -0
- package/dist/serialize.js.map +1 -0
- package/dist/simulate.d.ts +446 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +1115 -0
- package/dist/simulate.js.map +1 -0
- package/dist/synth.d.ts +25 -0
- package/dist/synth.d.ts.map +1 -0
- package/dist/synth.js +93 -0
- package/dist/synth.js.map +1 -0
- package/package.json +73 -0
- package/src/actions.ts +221 -0
- package/src/aws.ts +55 -0
- package/src/blocks.ts +619 -0
- package/src/codegen.ts +1144 -0
- package/src/export.ts +1190 -0
- package/src/flow.ts +108 -0
- package/src/flowdoc.ts +261 -0
- package/src/index.ts +31 -0
- package/src/layout.ts +54 -0
- package/src/lint/engine.ts +56 -0
- package/src/lint/graph.ts +86 -0
- package/src/lint/index.ts +10 -0
- package/src/lint/reporters.ts +44 -0
- package/src/lint/rules/action-allowed-in-flow-type.ts +32 -0
- package/src/lint/rules/action-count.ts +35 -0
- package/src/lint/rules/error-branches.ts +45 -0
- package/src/lint/rules/index.ts +53 -0
- package/src/lint/rules/module-depth-5.ts +70 -0
- package/src/lint/rules/no-literal-arn.ts +64 -0
- package/src/lint/rules/no-unresolved-token.ts +80 -0
- package/src/lint/rules/prompt-length-3000.ts +66 -0
- package/src/lint/rules/reachable-blocks.ts +50 -0
- package/src/lint/rules/recording-consent-before-record.ts +90 -0
- package/src/lint/rules/terminal-blocks.ts +45 -0
- package/src/lint/rules/unique-names.ts +44 -0
- package/src/lint/types.ts +43 -0
- package/src/materialize.ts +230 -0
- package/src/package-names.ts +26 -0
- package/src/refs.ts +93 -0
- package/src/serialize.ts +97 -0
- package/src/simulate.ts +1610 -0
- package/src/synth.ts +110 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Lint engine types.
|
|
6
|
+
//
|
|
7
|
+
// Nothing in this directory may import a Node builtin. The studio runs the
|
|
8
|
+
// engine in a web worker, so the whole module graph has to be browser-safe.
|
|
9
|
+
// Enforced by lint-browser-safe.test.ts, which walks the imports statically.
|
|
10
|
+
|
|
11
|
+
import type { FlowDoc } from "../flowdoc.js";
|
|
12
|
+
|
|
13
|
+
export type Severity = "error" | "warning";
|
|
14
|
+
|
|
15
|
+
export interface Finding {
|
|
16
|
+
/** Stable rule id. Never renamed; the Go provider keys off these. */
|
|
17
|
+
rule: string;
|
|
18
|
+
severity: Severity;
|
|
19
|
+
message: string;
|
|
20
|
+
/** Name of the FlowDoc the finding belongs to. */
|
|
21
|
+
doc: string;
|
|
22
|
+
/** Action Identifier, when the finding is about one action. */
|
|
23
|
+
blockId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RuleContext {
|
|
27
|
+
doc: FlowDoc;
|
|
28
|
+
/** Every doc being linted, for rules that need to follow module references. */
|
|
29
|
+
all: readonly FlowDoc[];
|
|
30
|
+
report(finding: Omit<Finding, "rule" | "doc">): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Rule {
|
|
34
|
+
id: string;
|
|
35
|
+
description: string;
|
|
36
|
+
/**
|
|
37
|
+
* A hard rule blocks a save in the studio rather than merely reporting.
|
|
38
|
+
* docs/02-studio-design.md: the studio can never save a doc that fails
|
|
39
|
+
* no-literal-arn or no-unresolved-token.
|
|
40
|
+
*/
|
|
41
|
+
hard?: boolean;
|
|
42
|
+
check(ctx: RuleContext): void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Materialization: FlowDoc in, deployable Flow language content out.
|
|
6
|
+
//
|
|
7
|
+
// Two backends resolve `${cdref:...}` tokens (SPEC.md, Materialization):
|
|
8
|
+
// - materializeWithMap: strict lookup in a token -> value map; every missing
|
|
9
|
+
// token is reported at once in a MaterializeError.
|
|
10
|
+
// - materializeWithBinder: the binder returns opaque strings (CDK or
|
|
11
|
+
// CloudFormation tokens). They pass through byte-for-byte with no
|
|
12
|
+
// validation and no arn-shape checks, because intrinsics are resolved by
|
|
13
|
+
// CloudFormation long after this code runs.
|
|
14
|
+
//
|
|
15
|
+
// Both backends emit content ONLY: `layout`, `refs`, and `meta` are tool
|
|
16
|
+
// metadata and are dropped. Before dropping, `layout` is projected into
|
|
17
|
+
// `content.Metadata` so the flow lays out correctly in the Connect console
|
|
18
|
+
// (docs/01-flowdoc-spec.md). The Metadata shape follows the Flow language
|
|
19
|
+
// example, which uses EntryPointPosition and ActionMetadata.<id>.Position:
|
|
20
|
+
// https://docs.aws.amazon.com/connect/latest/devguide/flow-language-example.html
|
|
21
|
+
|
|
22
|
+
import type { FlowAction, FlowContent, FlowDoc, Point, RefEntry } from "./flowdoc.js";
|
|
23
|
+
import { InvalidFlowDocError, assertFlowDoc } from "./flowdoc.js";
|
|
24
|
+
import { autoLayout } from "./layout.js";
|
|
25
|
+
import { collectRefs, parseToken } from "./refs.js";
|
|
26
|
+
import { canonicalAction, ordered, sortKeys } from "./serialize.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strict map materialization failed: one or more tokens in the document have
|
|
30
|
+
* no entry in the resource map. `missingTokens` is sorted and complete, so a
|
|
31
|
+
* caller fixes the map once, not one missing key per run.
|
|
32
|
+
*/
|
|
33
|
+
export class MaterializeError extends Error {
|
|
34
|
+
readonly missingTokens: readonly string[];
|
|
35
|
+
|
|
36
|
+
constructor(missingTokens: readonly string[], reason?: string) {
|
|
37
|
+
super(
|
|
38
|
+
reason === undefined
|
|
39
|
+
? `Cannot materialize: ${String(missingTokens.length)} unmapped token(s): ` +
|
|
40
|
+
missingTokens.join(", ")
|
|
41
|
+
: `Cannot materialize: ${reason} Offending token(s): ${missingTokens.join(", ")}`,
|
|
42
|
+
);
|
|
43
|
+
this.name = "MaterializeError";
|
|
44
|
+
this.missingTokens = missingTokens;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A reference token occupies an entire field value and is never interpolated
|
|
50
|
+
* into a longer string (FlowDoc invariant 4), so replacement only ever swaps
|
|
51
|
+
* whole string values. Binder output is inserted exactly as returned.
|
|
52
|
+
*/
|
|
53
|
+
function resolveDeep(value: unknown, resolve: (entry: RefEntry) => string): unknown {
|
|
54
|
+
if (typeof value === "string") {
|
|
55
|
+
const entry = parseToken(value);
|
|
56
|
+
return entry === undefined ? value : resolve(entry);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) return value.map((v) => resolveDeep(v, resolve));
|
|
59
|
+
if (value !== null && typeof value === "object") {
|
|
60
|
+
return Object.fromEntries(
|
|
61
|
+
Object.entries(value as Record<string, unknown>).map(([k, v]) => [
|
|
62
|
+
k,
|
|
63
|
+
resolveDeep(v, resolve),
|
|
64
|
+
]),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** How far left of the StartAction block the console entry point marker sits. */
|
|
71
|
+
const ENTRY_POINT_OFFSET_X = 100;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Projects `layout` into the Metadata shape the console reads. Positions come
|
|
75
|
+
* from the document's layout; any action without one gets the same
|
|
76
|
+
* deterministic auto-layout synth would have assigned. Keys an imported
|
|
77
|
+
* document may already carry in content.Metadata are preserved; only the
|
|
78
|
+
* position keys are overwritten, because `layout` is the single source of
|
|
79
|
+
* truth for position (docs/01-flowdoc-spec.md).
|
|
80
|
+
*/
|
|
81
|
+
function projectLayout(
|
|
82
|
+
doc: FlowDoc,
|
|
83
|
+
resolve: (entry: RefEntry) => string,
|
|
84
|
+
): Record<string, unknown> {
|
|
85
|
+
const actions = doc.content.Actions;
|
|
86
|
+
const layout = doc.layout ?? {};
|
|
87
|
+
const missing = actions.some((a) => layout[a.Identifier] === undefined);
|
|
88
|
+
const auto = missing ? autoLayout(actions) : {};
|
|
89
|
+
|
|
90
|
+
// Pre-existing Metadata (an imported document, say) is carried through, and
|
|
91
|
+
// it is RESOLVED like everything else: the strictness check scans the whole
|
|
92
|
+
// content, so substitution must cover the whole content too, or a token
|
|
93
|
+
// could survive into deployable output.
|
|
94
|
+
const existing = resolveDeep(doc.content.Metadata ?? {}, resolve) as Record<string, unknown>;
|
|
95
|
+
const existingRaw = existing.ActionMetadata;
|
|
96
|
+
const existingActionMetadata =
|
|
97
|
+
existingRaw !== null && typeof existingRaw === "object" && !Array.isArray(existingRaw)
|
|
98
|
+
? (existingRaw as Record<string, unknown>)
|
|
99
|
+
: {};
|
|
100
|
+
|
|
101
|
+
// Start from what was already there (entries for unknown ids survive; never
|
|
102
|
+
// drop content), then overlay Position per action: layout is the single
|
|
103
|
+
// source of truth for position, but sibling keys inside an entry are kept.
|
|
104
|
+
const actionMetadata: Record<string, unknown> = { ...existingActionMetadata };
|
|
105
|
+
const positions: Record<string, Point> = {};
|
|
106
|
+
for (const a of actions) {
|
|
107
|
+
const p = layout[a.Identifier] ?? auto[a.Identifier] ?? { x: 0, y: 0 };
|
|
108
|
+
positions[a.Identifier] = p;
|
|
109
|
+
const prior = actionMetadata[a.Identifier];
|
|
110
|
+
const priorEntry =
|
|
111
|
+
prior !== null && typeof prior === "object" && !Array.isArray(prior)
|
|
112
|
+
? (prior as Record<string, unknown>)
|
|
113
|
+
: {};
|
|
114
|
+
actionMetadata[a.Identifier] = { ...priorEntry, Position: { x: p.x, y: p.y } };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The example places the entry point marker left of the first block
|
|
118
|
+
// (EntryPointPosition x=88 vs first Action x=270).
|
|
119
|
+
const start = positions[doc.content.StartAction] ?? { x: 0, y: 0 };
|
|
120
|
+
const entryPointPosition = { x: Math.max(start.x - ENTRY_POINT_OFFSET_X, 0), y: start.y };
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...existing,
|
|
124
|
+
EntryPointPosition: entryPointPosition,
|
|
125
|
+
ActionMetadata: actionMetadata,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function materialize(doc: FlowDoc, resolve: (entry: RefEntry) => string): FlowContent {
|
|
130
|
+
// A module requires a top-level Settings in its deployable content; Connect
|
|
131
|
+
// rejects the create without it. Default to {} when the doc does not carry
|
|
132
|
+
// one; resolve tokens in it for consistency with the rest of the content.
|
|
133
|
+
const settings =
|
|
134
|
+
doc.kind === "module" || doc.content.Settings !== undefined
|
|
135
|
+
? (resolveDeep(doc.content.Settings ?? {}, resolve) as Record<string, unknown>)
|
|
136
|
+
: undefined;
|
|
137
|
+
return {
|
|
138
|
+
Version: doc.content.Version,
|
|
139
|
+
StartAction: doc.content.StartAction,
|
|
140
|
+
...(settings === undefined ? {} : { Settings: settings }),
|
|
141
|
+
Metadata: projectLayout(doc, resolve),
|
|
142
|
+
Actions: doc.content.Actions.map((a) => resolveDeep(a, resolve) as FlowAction),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Strict materialization against a token -> value map. Every token in the
|
|
148
|
+
* content must have an entry; otherwise a MaterializeError lists ALL missing
|
|
149
|
+
* tokens (sorted), not just the first. Mapped values are not validated: at
|
|
150
|
+
* this point literal ARNs are the goal, not a mistake.
|
|
151
|
+
*/
|
|
152
|
+
export function materializeWithMap(doc: FlowDoc, resourceMap: Record<string, string>): FlowContent {
|
|
153
|
+
assertFlowDoc(doc, "materializeWithMap");
|
|
154
|
+
if (typeof resourceMap !== "object" || resourceMap === null || Array.isArray(resourceMap)) {
|
|
155
|
+
throw new InvalidFlowDocError(
|
|
156
|
+
"materializeWithMap expects a token to value map object as its second argument.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const missing = collectRefs(doc.content)
|
|
160
|
+
.map((e) => e.token)
|
|
161
|
+
.filter((t) => !Object.hasOwn(resourceMap, t));
|
|
162
|
+
if (missing.length > 0) throw new MaterializeError(missing);
|
|
163
|
+
|
|
164
|
+
const content = materialize(doc, (entry) => resourceMap[entry.token] as string);
|
|
165
|
+
|
|
166
|
+
// Completeness is not enough. collectRefs finds a token anywhere in a string
|
|
167
|
+
// while substitution only replaces a whole-value token, so an interpolated
|
|
168
|
+
// token used to satisfy the check above and then survive into deployable
|
|
169
|
+
// output as literal text that Connect would read aloud. Refusing here means
|
|
170
|
+
// render and emit, which do not run lint, cannot ship one either.
|
|
171
|
+
const leaked = [...new Set(JSON.stringify(content).match(/\$\{cdref:[^}"]*\}/g) ?? [])].sort();
|
|
172
|
+
if (leaked.length > 0) {
|
|
173
|
+
throw new MaterializeError(
|
|
174
|
+
leaked,
|
|
175
|
+
"Token(s) survived materialization because they are embedded in a longer string. " +
|
|
176
|
+
"A reference must be the entire field value (FlowDoc invariant 4).",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return content;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Binder materialization for IaC backends (@flow-as-code/cdk). The binder returns an
|
|
184
|
+
* opaque string per reference, typically a CDK token that CloudFormation later
|
|
185
|
+
* resolves to an ARN. Output passes through exactly as returned, with no
|
|
186
|
+
* validation and no arn-shape lint (SPEC.md, Materialization).
|
|
187
|
+
*/
|
|
188
|
+
export function materializeWithBinder(
|
|
189
|
+
doc: FlowDoc,
|
|
190
|
+
binder: (ref: RefEntry) => string,
|
|
191
|
+
): FlowContent {
|
|
192
|
+
assertFlowDoc(doc, "materializeWithBinder");
|
|
193
|
+
if (typeof binder !== "function") {
|
|
194
|
+
throw new InvalidFlowDocError(
|
|
195
|
+
"materializeWithBinder expects a binder function as its second argument.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return materialize(doc, binder);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- Deterministic serialization of deployable content -----------------------
|
|
202
|
+
// Mirrors serialize.ts: fixed first-class key order, alphabetical for the
|
|
203
|
+
// rest, two-space indent, trailing newline. Byte-identical for the same
|
|
204
|
+
// content regardless of insertion order.
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Canonical JSON text of deployable content, two-space indented, newline
|
|
208
|
+
* terminated. Metadata leads with EntryPointPosition then ActionMetadata,
|
|
209
|
+
* matching the Flow language example; everything nested is key-sorted.
|
|
210
|
+
*/
|
|
211
|
+
export function serializeContent(content: FlowContent): string {
|
|
212
|
+
const canonical = ordered(
|
|
213
|
+
{
|
|
214
|
+
Version: content.Version,
|
|
215
|
+
StartAction: content.StartAction,
|
|
216
|
+
...(content.Settings === undefined ? {} : { Settings: sortKeys(content.Settings) }),
|
|
217
|
+
...(content.Metadata === undefined
|
|
218
|
+
? {}
|
|
219
|
+
: {
|
|
220
|
+
Metadata: ordered(sortKeys(content.Metadata) as Record<string, unknown>, [
|
|
221
|
+
"EntryPointPosition",
|
|
222
|
+
"ActionMetadata",
|
|
223
|
+
]),
|
|
224
|
+
}),
|
|
225
|
+
Actions: content.Actions.map(canonicalAction),
|
|
226
|
+
},
|
|
227
|
+
["Version", "StartAction", "Settings", "Metadata", "Actions"],
|
|
228
|
+
);
|
|
229
|
+
return JSON.stringify(canonical, null, 2) + "\n";
|
|
230
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// The one place the npm package names are written down as values.
|
|
6
|
+
//
|
|
7
|
+
// Everything that embeds a package name in its output, rather than importing
|
|
8
|
+
// it, reads it from here: codegen's default import, the cdk stack scaffold,
|
|
9
|
+
// the tf "generated by" banner, and the CLI's studio asset resolution. A
|
|
10
|
+
// rename is then one edit to PACKAGE_SCOPE plus the package.json files, and a
|
|
11
|
+
// test in core keeps this file honest against those manifests.
|
|
12
|
+
//
|
|
13
|
+
// The hosted demo build (docs/05-hosted-demo.md) aliases this module to a
|
|
14
|
+
// neutral placeholder so no package name reaches the deployed artifact.
|
|
15
|
+
|
|
16
|
+
/** The npm scope every package publishes under. */
|
|
17
|
+
export const PACKAGE_SCOPE = "@flow-as-code";
|
|
18
|
+
|
|
19
|
+
/** Published package names, keyed by their short names. */
|
|
20
|
+
export const PACKAGE_NAMES = {
|
|
21
|
+
core: `${PACKAGE_SCOPE}/core`,
|
|
22
|
+
cdk: `${PACKAGE_SCOPE}/cdk`,
|
|
23
|
+
cli: `${PACKAGE_SCOPE}/cli`,
|
|
24
|
+
tf: `${PACKAGE_SCOPE}/tf`,
|
|
25
|
+
studio: `${PACKAGE_SCOPE}/studio`,
|
|
26
|
+
} as const;
|
package/src/refs.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Reference tokens. A reference occupies an entire field value and is never
|
|
6
|
+
// interpolated into a longer string: Connect requires the fields that hold
|
|
7
|
+
// references to be "either fully static or a single valid JSONPath
|
|
8
|
+
// identifier". See conformance/flow-language/actions.md.
|
|
9
|
+
|
|
10
|
+
import type { RefEntry, RefType } from "./flowdoc.js";
|
|
11
|
+
import { SLUG_PATTERN } from "./flowdoc.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A reference token, branded with the kind of resource it points at, so a
|
|
15
|
+
* queue cannot be passed where a Lambda is expected.
|
|
16
|
+
*/
|
|
17
|
+
export type Ref<T extends RefType = RefType> = string & { readonly __ref: T };
|
|
18
|
+
|
|
19
|
+
/** A single JSONPath identifier, the dynamic alternative to a reference. */
|
|
20
|
+
export type JsonPath = string & { readonly __jsonPath: true };
|
|
21
|
+
|
|
22
|
+
/** A value legal in a reference-bearing parameter. */
|
|
23
|
+
export type RefValue<T extends RefType> = Ref<T> | JsonPath;
|
|
24
|
+
|
|
25
|
+
export const TOKEN_PATTERN =
|
|
26
|
+
/^\$\{cdref:(queue|hours|lambda|lex|prompt|flow|module):([a-z0-9]+(?:-[a-z0-9]+)*)(?:@([a-z0-9]+(?:-[a-z0-9]+)*))?\}$/;
|
|
27
|
+
|
|
28
|
+
/** Matches tokens anywhere in a string, for building the refs index. */
|
|
29
|
+
const TOKEN_SCAN = /\$\{cdref:[a-z]+:[^}]+\}/g;
|
|
30
|
+
|
|
31
|
+
const JSONPATH_PATTERN = /^\$\.[A-Za-z0-9_$.[\]'-]+$/;
|
|
32
|
+
|
|
33
|
+
function assertSlug(kind: string, value: string): void {
|
|
34
|
+
if (!SLUG_PATTERN.test(value)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Invalid ${kind} "${value}". Names must be lowercase words separated by single hyphens.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function token<T extends RefType>(type: T, name: string, alias?: string): Ref<T> {
|
|
42
|
+
assertSlug(`${type} name`, name);
|
|
43
|
+
if (alias !== undefined) assertSlug("module alias", alias);
|
|
44
|
+
const suffix = alias === undefined ? "" : `@${alias}`;
|
|
45
|
+
return `\${cdref:${type}:${name}${suffix}}` as Ref<T>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Refs = {
|
|
49
|
+
queue: (name: string): Ref<"queue"> => token("queue", name),
|
|
50
|
+
hours: (name: string): Ref<"hours"> => token("hours", name),
|
|
51
|
+
lambda: (name: string): Ref<"lambda"> => token("lambda", name),
|
|
52
|
+
lex: (name: string): Ref<"lex"> => token("lex", name),
|
|
53
|
+
prompt: (name: string): Ref<"prompt"> => token("prompt", name),
|
|
54
|
+
flow: (name: string): Ref<"flow"> => token("flow", name),
|
|
55
|
+
/** Module references carry an alias so a flow can pin which version it invokes. */
|
|
56
|
+
module: (name: string, alias: string): Ref<"module"> => token("module", name, alias),
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
/** Wraps a JSONPath expression for use in a reference-bearing parameter. */
|
|
60
|
+
export function jsonPath(path: string): JsonPath {
|
|
61
|
+
if (!JSONPATH_PATTERN.test(path)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid JSONPath "${path}". Expected a single identifier such as $.Attributes.queueId.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return path as JsonPath;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isToken(value: unknown): value is Ref {
|
|
70
|
+
return typeof value === "string" && TOKEN_PATTERN.test(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseToken(value: string): RefEntry | undefined {
|
|
74
|
+
const m = TOKEN_PATTERN.exec(value);
|
|
75
|
+
if (!m) return undefined;
|
|
76
|
+
const [, type, name, alias] = m;
|
|
77
|
+
const entry: RefEntry = { token: value, type: type as RefType, name: name! };
|
|
78
|
+
if (alias !== undefined) entry.alias = alias;
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Derived index of every token in a document, sorted by token so the output is
|
|
84
|
+
* stable. Regenerated on save; never hand-maintained.
|
|
85
|
+
*/
|
|
86
|
+
export function collectRefs(value: unknown): RefEntry[] {
|
|
87
|
+
const found = new Map<string, RefEntry>();
|
|
88
|
+
for (const raw of JSON.stringify(value ?? null).match(TOKEN_SCAN) ?? []) {
|
|
89
|
+
const entry = parseToken(raw);
|
|
90
|
+
if (entry) found.set(entry.token, entry);
|
|
91
|
+
}
|
|
92
|
+
return [...found.values()].sort((a, b) => (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
|
|
93
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Deterministic serialization. Invariant 1 of the FlowDoc spec: same inputs,
|
|
6
|
+
// byte-identical output. Two authorings of the same flow must produce the same
|
|
7
|
+
// bytes, so key order is fixed rather than insertion-dependent.
|
|
8
|
+
|
|
9
|
+
import type { FlowAction, FlowDoc, Transitions } from "./flowdoc.js";
|
|
10
|
+
|
|
11
|
+
/** Recursively sorts object keys. Arrays keep their order, which is meaningful. */
|
|
12
|
+
export function sortKeys(value: unknown): unknown {
|
|
13
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
14
|
+
if (value === null || typeof value !== "object") return value;
|
|
15
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
16
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
17
|
+
);
|
|
18
|
+
return Object.fromEntries(entries.map(([k, v]) => [k, sortKeys(v)]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Emits keys in the given order, then anything else alphabetically. */
|
|
22
|
+
export function ordered<T extends Record<string, unknown>>(value: T, keys: (keyof T)[]): T {
|
|
23
|
+
const out: Record<string, unknown> = {};
|
|
24
|
+
for (const k of keys) {
|
|
25
|
+
if (value[k as string] !== undefined) out[k as string] = value[k as string];
|
|
26
|
+
}
|
|
27
|
+
for (const k of Object.keys(value).sort()) {
|
|
28
|
+
if (!keys.includes(k) && value[k] !== undefined) out[k] = value[k];
|
|
29
|
+
}
|
|
30
|
+
return out as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function canonicalTransitions(t: Transitions): Transitions {
|
|
34
|
+
// The arrays' ELEMENTS need canonicalizing too. Ordering only the top level
|
|
35
|
+
// copied Errors and Conditions by reference, so { ErrorType, NextAction } and
|
|
36
|
+
// { NextAction, ErrorType } serialized to different bytes: two authorings of
|
|
37
|
+
// the same flow produced different output, and downstream a different
|
|
38
|
+
// CloudFormation logical id.
|
|
39
|
+
const canonical: Record<string, unknown> = { ...(t as Record<string, unknown>) };
|
|
40
|
+
if (t.Errors !== undefined) {
|
|
41
|
+
canonical.Errors = t.Errors.map((e) => ordered({ ...e }, ["ErrorType", "NextAction"]));
|
|
42
|
+
}
|
|
43
|
+
if (t.Conditions !== undefined) {
|
|
44
|
+
canonical.Conditions = t.Conditions.map((c) =>
|
|
45
|
+
ordered(
|
|
46
|
+
{
|
|
47
|
+
NextAction: c.NextAction,
|
|
48
|
+
Condition: ordered({ ...c.Condition }, ["Operator", "Operands"]),
|
|
49
|
+
},
|
|
50
|
+
["NextAction", "Condition"],
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return ordered(canonical, ["NextAction", "Errors", "Conditions"]) as unknown as Transitions;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function canonicalAction(a: FlowAction): FlowAction {
|
|
58
|
+
return {
|
|
59
|
+
Identifier: a.Identifier,
|
|
60
|
+
Type: a.Type,
|
|
61
|
+
// Parameters are opaque to us for GenericBlocks, so sort for stability.
|
|
62
|
+
Parameters: sortKeys(a.Parameters) as Record<string, unknown>,
|
|
63
|
+
Transitions: canonicalTransitions(a.Transitions),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonical in-memory form. Key order below is the byte order of the output.
|
|
69
|
+
*/
|
|
70
|
+
export function canonicalize(doc: FlowDoc): FlowDoc {
|
|
71
|
+
const out: Record<string, unknown> = {
|
|
72
|
+
flowdoc: doc.flowdoc,
|
|
73
|
+
kind: doc.kind,
|
|
74
|
+
name: doc.name,
|
|
75
|
+
connectType: doc.connectType,
|
|
76
|
+
content: ordered(
|
|
77
|
+
{
|
|
78
|
+
Version: doc.content.Version,
|
|
79
|
+
StartAction: doc.content.StartAction,
|
|
80
|
+
...(doc.content.Settings === undefined ? {} : { Settings: sortKeys(doc.content.Settings) }),
|
|
81
|
+
...(doc.content.Metadata === undefined ? {} : { Metadata: sortKeys(doc.content.Metadata) }),
|
|
82
|
+
Actions: doc.content.Actions.map(canonicalAction),
|
|
83
|
+
} as Record<string, unknown>,
|
|
84
|
+
["Version", "StartAction", "Settings", "Metadata", "Actions"],
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
if (doc.layout !== undefined) out.layout = sortKeys(doc.layout);
|
|
88
|
+
if (doc.refs !== undefined)
|
|
89
|
+
out.refs = doc.refs.map((r) => ordered({ ...r }, ["token", "type", "name", "alias"]));
|
|
90
|
+
if (doc.meta !== undefined) out.meta = sortKeys(doc.meta);
|
|
91
|
+
return out as unknown as FlowDoc;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Canonical JSON text, two-space indented, newline terminated. */
|
|
95
|
+
export function serialize(doc: FlowDoc): string {
|
|
96
|
+
return JSON.stringify(canonicalize(doc), null, 2) + "\n";
|
|
97
|
+
}
|