@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,35 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { MAX_ACTIONS_PER_FLOW } from "../../flowdoc.js";
|
|
6
|
+
import type { Rule } from "../types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* "No more than 250 Actions per flow."
|
|
10
|
+
* https://docs.aws.amazon.com/connect/latest/devguide/flow-language-example.html
|
|
11
|
+
* (transcribed in conformance/flow-language/actions.md, document envelope).
|
|
12
|
+
*
|
|
13
|
+
* The FlowDoc schema says maxItems 250, but nothing on the authoring path runs
|
|
14
|
+
* the schema: render, codegen, materialization, and both emitters read the
|
|
15
|
+
* document straight. `Flow.add` refuses the 251st block, so a flow built with
|
|
16
|
+
* the builder cannot exceed the limit, but a FlowDoc that arrives any other way
|
|
17
|
+
* (hand-edited, exported from an instance, produced by another tool) reached
|
|
18
|
+
* emit with no complaint and failed at deploy time with a Connect error that
|
|
19
|
+
* names no block. This rule is the check on the FlowDoc path.
|
|
20
|
+
*/
|
|
21
|
+
export const actionCount: Rule = {
|
|
22
|
+
id: "action-count",
|
|
23
|
+
description: `A flow holds no more than ${String(MAX_ACTIONS_PER_FLOW)} actions.`,
|
|
24
|
+
check({ doc, report }) {
|
|
25
|
+
const count = doc.content.Actions.length;
|
|
26
|
+
if (count > MAX_ACTIONS_PER_FLOW) {
|
|
27
|
+
report({
|
|
28
|
+
severity: "error",
|
|
29
|
+
message: `Flow has ${String(count)} actions; Connect allows at most ${String(
|
|
30
|
+
MAX_ACTIONS_PER_FLOW,
|
|
31
|
+
)}.`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
ActionType,
|
|
7
|
+
NO_MATCHING_CONDITION,
|
|
8
|
+
NO_MATCHING_ERROR,
|
|
9
|
+
TERMINAL_ACTIONS,
|
|
10
|
+
} from "../../actions.js";
|
|
11
|
+
import { isTerminal } from "../graph.js";
|
|
12
|
+
import type { Rule } from "../types.js";
|
|
13
|
+
|
|
14
|
+
const MODELED = new Set<string>(Object.values(ActionType));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The backstop for the type-level enforcement in the builder. It catches what
|
|
18
|
+
* types cannot: hand-edited FlowDocs, exported flows, and studio edits.
|
|
19
|
+
*
|
|
20
|
+
* Only modeled actions are checked. We cannot know an unmodeled action's error
|
|
21
|
+
* set, and some documented actions genuinely have none.
|
|
22
|
+
*/
|
|
23
|
+
export const errorBranches: Rule = {
|
|
24
|
+
id: "error-branches",
|
|
25
|
+
description: "Every non-terminal modeled action must wire its catch-all error branch.",
|
|
26
|
+
check({ doc, report }) {
|
|
27
|
+
for (const action of doc.content.Actions) {
|
|
28
|
+
if (isTerminal(action)) continue;
|
|
29
|
+
if (!MODELED.has(action.Type)) continue;
|
|
30
|
+
if (TERMINAL_ACTIONS.includes(action.Type)) continue;
|
|
31
|
+
|
|
32
|
+
// Compare is the one modeled action that fails with NoMatchingCondition.
|
|
33
|
+
const expected =
|
|
34
|
+
action.Type === ActionType.Compare ? NO_MATCHING_CONDITION : NO_MATCHING_ERROR;
|
|
35
|
+
const wired = (action.Transitions.Errors ?? []).some((e) => e.ErrorType === expected);
|
|
36
|
+
if (!wired) {
|
|
37
|
+
report({
|
|
38
|
+
severity: "error",
|
|
39
|
+
blockId: action.Identifier,
|
|
40
|
+
message: `${action.Type} does not wire its "${expected}" branch.`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Rule registry. Ids are stable and never renamed: the conformance fixtures,
|
|
6
|
+
// the CLI, the studio, and the future Go provider all key off them.
|
|
7
|
+
|
|
8
|
+
import { actionAllowedInFlowType } from "./action-allowed-in-flow-type.js";
|
|
9
|
+
import { actionCount } from "./action-count.js";
|
|
10
|
+
import { errorBranches } from "./error-branches.js";
|
|
11
|
+
import { moduleDepth5 } from "./module-depth-5.js";
|
|
12
|
+
import { noLiteralArn } from "./no-literal-arn.js";
|
|
13
|
+
import { noUnresolvedToken } from "./no-unresolved-token.js";
|
|
14
|
+
import { promptLength3000 } from "./prompt-length-3000.js";
|
|
15
|
+
import { reachableBlocks } from "./reachable-blocks.js";
|
|
16
|
+
import { recordingConsentBeforeRecord } from "./recording-consent-before-record.js";
|
|
17
|
+
import { terminalBlocks } from "./terminal-blocks.js";
|
|
18
|
+
import { uniqueNames } from "./unique-names.js";
|
|
19
|
+
import type { Rule } from "../types.js";
|
|
20
|
+
|
|
21
|
+
export const allRules: readonly Rule[] = [
|
|
22
|
+
noLiteralArn,
|
|
23
|
+
noUnresolvedToken,
|
|
24
|
+
reachableBlocks,
|
|
25
|
+
errorBranches,
|
|
26
|
+
terminalBlocks,
|
|
27
|
+
moduleDepth5,
|
|
28
|
+
promptLength3000,
|
|
29
|
+
recordingConsentBeforeRecord,
|
|
30
|
+
uniqueNames,
|
|
31
|
+
actionAllowedInFlowType,
|
|
32
|
+
actionCount,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function ruleById(id: string): Rule | undefined {
|
|
36
|
+
return allRules.find((r) => r.id === id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { NO_LITERAL_ARN, literalArnMessage, literalArnPaths } from "./no-literal-arn.js";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
actionAllowedInFlowType,
|
|
43
|
+
actionCount,
|
|
44
|
+
errorBranches,
|
|
45
|
+
moduleDepth5,
|
|
46
|
+
noLiteralArn,
|
|
47
|
+
noUnresolvedToken,
|
|
48
|
+
promptLength3000,
|
|
49
|
+
reachableBlocks,
|
|
50
|
+
recordingConsentBeforeRecord,
|
|
51
|
+
terminalBlocks,
|
|
52
|
+
uniqueNames,
|
|
53
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { ActionType } from "../../actions.js";
|
|
6
|
+
import { parseToken } from "../../refs.js";
|
|
7
|
+
import type { FlowDoc } from "../../flowdoc.js";
|
|
8
|
+
import type { Rule } from "../types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "You can invoke modules within other modules, supporting up to five levels of
|
|
12
|
+
* nesting with a stack limit to prevent recursive invocations."
|
|
13
|
+
* https://docs.aws.amazon.com/connect/latest/adminguide/contact-flow-modules.html
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_MODULE_DEPTH = 5;
|
|
16
|
+
|
|
17
|
+
function invokedModules(doc: FlowDoc): string[] {
|
|
18
|
+
const names: string[] = [];
|
|
19
|
+
for (const action of doc.content.Actions) {
|
|
20
|
+
if (action.Type !== ActionType.InvokeFlowModule) continue;
|
|
21
|
+
const id = action.Parameters.FlowModuleId;
|
|
22
|
+
if (typeof id !== "string") continue;
|
|
23
|
+
const ref = parseToken(id);
|
|
24
|
+
if (ref?.type === "module") names.push(ref.name);
|
|
25
|
+
}
|
|
26
|
+
return names;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Depth is only computable across a set: a document alone cannot know what the
|
|
31
|
+
* modules it invokes go on to invoke. Linting a single document therefore
|
|
32
|
+
* reports nothing here, which is correct rather than a false pass.
|
|
33
|
+
*/
|
|
34
|
+
export const moduleDepth5: Rule = {
|
|
35
|
+
id: "module-depth-5",
|
|
36
|
+
description:
|
|
37
|
+
"Flow module invocation must not nest more than five levels deep, and must not recurse.",
|
|
38
|
+
check({ doc, all, report }) {
|
|
39
|
+
// Depth is measured from an entry point. Walking from every document would
|
|
40
|
+
// report the same violation once per document in the chain, so only flows
|
|
41
|
+
// start a walk. A module unreachable from any flow is dead code, which is
|
|
42
|
+
// reachable-blocks territory rather than this rule's.
|
|
43
|
+
if (doc.kind !== "flow") return;
|
|
44
|
+
|
|
45
|
+
const modules = new Map(all.filter((d) => d.kind === "module").map((d) => [d.name, d]));
|
|
46
|
+
|
|
47
|
+
const walk = (current: FlowDoc, depth: number, stack: string[]): void => {
|
|
48
|
+
for (const name of invokedModules(current)) {
|
|
49
|
+
if (stack.includes(name)) {
|
|
50
|
+
report({
|
|
51
|
+
severity: "error",
|
|
52
|
+
message: `Recursive module invocation: ${[...stack, name].join(" -> ")}.`,
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (depth + 1 > MAX_MODULE_DEPTH) {
|
|
57
|
+
report({
|
|
58
|
+
severity: "error",
|
|
59
|
+
message: `Module nesting reaches depth ${depth + 1} via ${[...stack, name].join(" -> ")}; Connect allows ${MAX_MODULE_DEPTH}.`,
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const next = modules.get(name);
|
|
64
|
+
if (next !== undefined) walk(next, depth + 1, [...stack, name]);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
walk(doc, 0, [doc.name]);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { documentStrings, findingsForActions, walkStrings } from "../graph.js";
|
|
6
|
+
import type { Rule } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const ARN = /arn:aws[a-z-]*:/;
|
|
9
|
+
|
|
10
|
+
/** This rule's id, for tools that report the violation before lint can run. */
|
|
11
|
+
export const NO_LITERAL_ARN = "no-literal-arn";
|
|
12
|
+
|
|
13
|
+
/** The sentence every literal-ARN report ends with. */
|
|
14
|
+
export function literalArnMessage(path: string): string {
|
|
15
|
+
return `"${path}" contains a literal ARN. Use a \${cdref:type:name} token instead.`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Paths of every string holding a literal ARN in an arbitrary JSON value.
|
|
20
|
+
*
|
|
21
|
+
* Takes `unknown` rather than a FlowDoc on purpose: the CLI runs this over a
|
|
22
|
+
* document the schema has already rejected, because the schema's own pattern
|
|
23
|
+
* errors describe that one mistake several times over and never name the rule.
|
|
24
|
+
* See packages/cli/src/docs.ts.
|
|
25
|
+
*/
|
|
26
|
+
export function literalArnPaths(value: unknown): string[] {
|
|
27
|
+
return walkStrings(value)
|
|
28
|
+
.filter(([, v]) => ARN.test(v))
|
|
29
|
+
.map(([path]) => path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* References are tokens, never literal ARNs. A literal ARN pins a flow to one
|
|
34
|
+
* account, region, and instance, which is the whole thing this tooling exists
|
|
35
|
+
* to avoid.
|
|
36
|
+
*/
|
|
37
|
+
export const noLiteralArn: Rule = {
|
|
38
|
+
id: NO_LITERAL_ARN,
|
|
39
|
+
description: "Authored content must not contain a literal ARN; use a ${cdref:...} token.",
|
|
40
|
+
hard: true,
|
|
41
|
+
check({ doc, report }) {
|
|
42
|
+
// `refs` is authored content too: it is what the studio's reference
|
|
43
|
+
// pickers and the emitters read, and an ARN placed there satisfied this
|
|
44
|
+
// rule while the schema was the only thing catching it.
|
|
45
|
+
const documentLevel = [...documentStrings(doc), ...walkStrings(doc.refs ?? [], "refs")];
|
|
46
|
+
for (const [path, value] of documentLevel) {
|
|
47
|
+
if (ARN.test(value)) {
|
|
48
|
+
report({ severity: "error", message: literalArnMessage(path) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const { action, strings } of findingsForActions(doc)) {
|
|
53
|
+
for (const [path, value] of strings) {
|
|
54
|
+
if (ARN.test(value)) {
|
|
55
|
+
report({
|
|
56
|
+
severity: "error",
|
|
57
|
+
blockId: action.Identifier,
|
|
58
|
+
message: `${path.startsWith("Transitions") ? "Transition" : "Parameter"} ${literalArnMessage(path)}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { documentStrings, findingsForActions } from "../graph.js";
|
|
6
|
+
import { TOKEN_PATTERN, parseToken } from "../../refs.js";
|
|
7
|
+
import type { Rule } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/** Any ${...} placeholder, so malformed tokens are caught rather than ignored. */
|
|
10
|
+
/**
|
|
11
|
+
* Only placeholders that ATTEMPT to be a reference are this rule's business.
|
|
12
|
+
*
|
|
13
|
+
* Scanning every `${...}` produced false positives on ordinary prompt text:
|
|
14
|
+
* "Your balance is ${amount}" is a literal string Connect plays verbatim, since
|
|
15
|
+
* Connect's own dynamic syntax is JSONPath ($.Attributes.x), not ${}. Matching
|
|
16
|
+
* on the `cdref` prefix still catches the cases that matter, a well-formed
|
|
17
|
+
* token interpolated into a longer string and a misspelled cdref, without
|
|
18
|
+
* claiming authority over text we do not own.
|
|
19
|
+
*/
|
|
20
|
+
const CDREF_PLACEHOLDER = /\$\{\s*cdref[^}]*\}/gi;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every token must be well formed, must occupy its entire field, and must
|
|
24
|
+
* appear in the refs index. A token Connect never resolves deploys a flow that
|
|
25
|
+
* fails at runtime with an opaque error.
|
|
26
|
+
*/
|
|
27
|
+
export const noUnresolvedToken: Rule = {
|
|
28
|
+
id: "no-unresolved-token",
|
|
29
|
+
description:
|
|
30
|
+
"Every ${cdref:...} token must be well formed, stand alone, and appear in the refs index.",
|
|
31
|
+
hard: true,
|
|
32
|
+
check({ doc, report }) {
|
|
33
|
+
const indexed = new Set((doc.refs ?? []).map((r) => r.token));
|
|
34
|
+
|
|
35
|
+
const entries: { blockId?: string; path: string; value: string }[] = [
|
|
36
|
+
...documentStrings(doc).map(([path, value]) => ({ path, value })),
|
|
37
|
+
...findingsForActions(doc).flatMap(({ action, strings }) =>
|
|
38
|
+
strings.map(([path, value]) => ({ blockId: action.Identifier, path, value })),
|
|
39
|
+
),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const where = (path: string): string =>
|
|
43
|
+
path.startsWith("content.Metadata")
|
|
44
|
+
? ""
|
|
45
|
+
: path.startsWith("Transitions")
|
|
46
|
+
? "Transition "
|
|
47
|
+
: "Parameter ";
|
|
48
|
+
|
|
49
|
+
for (const { blockId, path, value } of entries) {
|
|
50
|
+
const placeholders = value.match(CDREF_PLACEHOLDER) ?? [];
|
|
51
|
+
if (placeholders.length === 0) continue;
|
|
52
|
+
|
|
53
|
+
// Connect requires reference fields to be fully static or a single
|
|
54
|
+
// JSONPath identifier, so a token cannot be part of a longer string.
|
|
55
|
+
if (!TOKEN_PATTERN.test(value)) {
|
|
56
|
+
report({
|
|
57
|
+
severity: "error",
|
|
58
|
+
...(blockId === undefined ? {} : { blockId }),
|
|
59
|
+
message: `${where(path)}"${path}" embeds a token in a larger string ("${value}"). A token must be the entire value.`,
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const token = placeholders[0]!;
|
|
65
|
+
if (parseToken(token) === undefined) {
|
|
66
|
+
report({
|
|
67
|
+
severity: "error",
|
|
68
|
+
...(blockId === undefined ? {} : { blockId }),
|
|
69
|
+
message: `${where(path)}"${path}" contains a malformed token "${token}".`,
|
|
70
|
+
});
|
|
71
|
+
} else if (!indexed.has(token)) {
|
|
72
|
+
report({
|
|
73
|
+
severity: "error",
|
|
74
|
+
...(blockId === undefined ? {} : { blockId }),
|
|
75
|
+
message: `Token "${token}" is missing from the refs index. Re-run synth to regenerate it.`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { ActionType } from "../../actions.js";
|
|
6
|
+
import type { Rule } from "../types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* "When you use text, either for text-to-speech or chat, you can use a maximum
|
|
10
|
+
* of 3,000 billed characters, which is 6,000 characters total."
|
|
11
|
+
* https://docs.aws.amazon.com/connect/latest/adminguide/play.html
|
|
12
|
+
*
|
|
13
|
+
* The Get customer input block states the same limit for its own text:
|
|
14
|
+
* https://docs.aws.amazon.com/connect/latest/adminguide/get-customer-input.html
|
|
15
|
+
*
|
|
16
|
+
* Billed characters are the spoken text. SSML markup counts toward the 6,000
|
|
17
|
+
* total but not the 3,000 billed, so the two are checked separately.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_BILLED_CHARACTERS = 3000;
|
|
20
|
+
export const MAX_TOTAL_CHARACTERS = 6000;
|
|
21
|
+
|
|
22
|
+
/** The modeled actions that carry a Text or SSML prompt. */
|
|
23
|
+
const PROMPT_ACTIONS: readonly string[] = [
|
|
24
|
+
ActionType.MessageParticipant,
|
|
25
|
+
ActionType.GetParticipantInput,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const stripSsmlTags = (s: string): string => s.replace(/<[^>]*>/g, "");
|
|
29
|
+
|
|
30
|
+
export const promptLength3000: Rule = {
|
|
31
|
+
id: "prompt-length-3000",
|
|
32
|
+
description: "Prompt text must stay within 3,000 billed characters and 6,000 total.",
|
|
33
|
+
check({ doc, report }) {
|
|
34
|
+
for (const action of doc.content.Actions) {
|
|
35
|
+
if (!PROMPT_ACTIONS.includes(action.Type)) continue;
|
|
36
|
+
|
|
37
|
+
const text = action.Parameters.Text;
|
|
38
|
+
if (typeof text === "string" && text.length > MAX_BILLED_CHARACTERS) {
|
|
39
|
+
report({
|
|
40
|
+
severity: "error",
|
|
41
|
+
blockId: action.Identifier,
|
|
42
|
+
message: `Text is ${text.length} characters; Connect allows ${MAX_BILLED_CHARACTERS} billed characters.`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ssml = action.Parameters.SSML;
|
|
47
|
+
if (typeof ssml === "string") {
|
|
48
|
+
if (ssml.length > MAX_TOTAL_CHARACTERS) {
|
|
49
|
+
report({
|
|
50
|
+
severity: "error",
|
|
51
|
+
blockId: action.Identifier,
|
|
52
|
+
message: `SSML is ${ssml.length} characters; Connect allows ${MAX_TOTAL_CHARACTERS} total.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const spoken = stripSsmlTags(ssml).length;
|
|
56
|
+
if (spoken > MAX_BILLED_CHARACTERS) {
|
|
57
|
+
report({
|
|
58
|
+
severity: "error",
|
|
59
|
+
blockId: action.Identifier,
|
|
60
|
+
message: `SSML contains ${spoken} spoken characters; Connect allows ${MAX_BILLED_CHARACTERS} billed characters.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { reachable, transitionTargets } from "../graph.js";
|
|
6
|
+
import { actionsById } from "../graph.js";
|
|
7
|
+
import type { Rule } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Unreachable actions are kept, never dropped, but they are almost always an
|
|
11
|
+
* editing mistake. Dangling targets are a hard error: Connect rejects the flow.
|
|
12
|
+
*/
|
|
13
|
+
export const reachableBlocks: Rule = {
|
|
14
|
+
id: "reachable-blocks",
|
|
15
|
+
description:
|
|
16
|
+
"Every action should be reachable from StartAction, and every transition target must exist.",
|
|
17
|
+
check({ doc, report }) {
|
|
18
|
+
const byId = actionsById(doc);
|
|
19
|
+
|
|
20
|
+
if (!byId.has(doc.content.StartAction)) {
|
|
21
|
+
report({
|
|
22
|
+
severity: "error",
|
|
23
|
+
message: `StartAction "${doc.content.StartAction}" does not match any action.`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const action of doc.content.Actions) {
|
|
28
|
+
for (const target of transitionTargets(action)) {
|
|
29
|
+
if (!byId.has(target)) {
|
|
30
|
+
report({
|
|
31
|
+
severity: "error",
|
|
32
|
+
blockId: action.Identifier,
|
|
33
|
+
message: `Transition points at "${target}", which does not exist.`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const live = reachable(doc);
|
|
40
|
+
for (const action of doc.content.Actions) {
|
|
41
|
+
if (!live.has(action.Identifier)) {
|
|
42
|
+
report({
|
|
43
|
+
severity: "warning",
|
|
44
|
+
blockId: action.Identifier,
|
|
45
|
+
message: "Action is not reachable from StartAction.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { ActionType } from "../../actions.js";
|
|
6
|
+
import { actionsById, transitionTargets } from "../graph.js";
|
|
7
|
+
import type { FlowAction, FlowDoc } from "../../flowdoc.js";
|
|
8
|
+
import type { Rule } from "../types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recording a caller without first telling them is a legal problem in many
|
|
12
|
+
* jurisdictions. This rule is deliberately generic: it requires that every path
|
|
13
|
+
* reaching an action which enables recording passes through an action that
|
|
14
|
+
* plays the participant something first. What that message says is the
|
|
15
|
+
* operator's business; that one exists is checkable here.
|
|
16
|
+
*/
|
|
17
|
+
function enablesRecording(action: FlowAction): boolean {
|
|
18
|
+
if (action.Type !== ActionType.UpdateContactRecordingBehavior) return false;
|
|
19
|
+
const behavior = action.Parameters.RecordingBehavior;
|
|
20
|
+
if (behavior === null || typeof behavior !== "object") return false;
|
|
21
|
+
const participants = (behavior as { RecordedParticipants?: unknown }).RecordedParticipants;
|
|
22
|
+
// An empty list disables recording, per the action's documentation.
|
|
23
|
+
return Array.isArray(participants) && participants.length > 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The parameters that play something on the two announcing actions. */
|
|
27
|
+
const BODY_FIELDS = ["Text", "SSML", "PromptId"] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* MessageParticipant carries exactly one of Text, SSML, or PromptId.
|
|
31
|
+
* GetParticipantInput plays its prompt before waiting for a key ("This call
|
|
32
|
+
* may be recorded. Press 1 to continue." is the usual consent menu), but every
|
|
33
|
+
* prompt field is optional on its action page, so a menu with none is silent.
|
|
34
|
+
* https://docs.aws.amazon.com/connect/latest/devguide/participant-actions-messageparticipant.html
|
|
35
|
+
* https://docs.aws.amazon.com/connect/latest/devguide/participant-actions-getparticipantinput.html
|
|
36
|
+
*
|
|
37
|
+
* Content is what counts, not the key: the studio's body switch writes
|
|
38
|
+
* `Text: ""` until the author types, and an empty or blank Text or SSML plays
|
|
39
|
+
* nothing on either action.
|
|
40
|
+
*/
|
|
41
|
+
function announces(action: FlowAction): boolean {
|
|
42
|
+
if (
|
|
43
|
+
action.Type !== ActionType.MessageParticipant &&
|
|
44
|
+
action.Type !== ActionType.GetParticipantInput
|
|
45
|
+
) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return BODY_FIELDS.some((k) => {
|
|
49
|
+
const body = action.Parameters[k];
|
|
50
|
+
return typeof body === "string" && body.trim() !== "";
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Ids from which `targetId` is reachable without passing an announcement. */
|
|
55
|
+
function pathsWithoutAnnouncement(doc: FlowDoc, targetId: string): boolean {
|
|
56
|
+
const byId = actionsById(doc);
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
const queue: string[] = [doc.content.StartAction];
|
|
59
|
+
|
|
60
|
+
while (queue.length > 0) {
|
|
61
|
+
const id = queue.shift()!;
|
|
62
|
+
if (id === targetId) return true;
|
|
63
|
+
if (seen.has(id)) continue;
|
|
64
|
+
seen.add(id);
|
|
65
|
+
const action = byId.get(id);
|
|
66
|
+
if (action === undefined) continue;
|
|
67
|
+
// An announcement on this path satisfies the rule, so stop exploring it.
|
|
68
|
+
if (announces(action)) continue;
|
|
69
|
+
queue.push(...transitionTargets(action));
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const recordingConsentBeforeRecord: Rule = {
|
|
75
|
+
id: "recording-consent-before-record",
|
|
76
|
+
description: "Recording must not start before the participant has been played a message.",
|
|
77
|
+
check({ doc, report }) {
|
|
78
|
+
for (const action of doc.content.Actions) {
|
|
79
|
+
if (!enablesRecording(action)) continue;
|
|
80
|
+
if (pathsWithoutAnnouncement(doc, action.Identifier)) {
|
|
81
|
+
report({
|
|
82
|
+
severity: "error",
|
|
83
|
+
blockId: action.Identifier,
|
|
84
|
+
message:
|
|
85
|
+
"Recording is enabled on a path that plays no message first. Announce recording before starting it.",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { TERMINAL_ACTIONS } from "../../actions.js";
|
|
6
|
+
import { isTerminal, reachable } from "../graph.js";
|
|
7
|
+
import { actionsById } from "../graph.js";
|
|
8
|
+
import type { Rule } from "../types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A flow that cannot terminate strands the contact. Also catches the inverse
|
|
12
|
+
* mistake: an action with empty Transitions that is not a terminal type, which
|
|
13
|
+
* Connect treats as an unintended dead end.
|
|
14
|
+
*/
|
|
15
|
+
export const terminalBlocks: Rule = {
|
|
16
|
+
id: "terminal-blocks",
|
|
17
|
+
description:
|
|
18
|
+
"A flow must reach a terminal action, and only terminal action types may have empty transitions.",
|
|
19
|
+
check({ doc, report }) {
|
|
20
|
+
const byId = actionsById(doc);
|
|
21
|
+
const live = reachable(doc);
|
|
22
|
+
|
|
23
|
+
const reachesTerminal = [...live].some((id) => {
|
|
24
|
+
const a = byId.get(id);
|
|
25
|
+
return a !== undefined && isTerminal(a) && TERMINAL_ACTIONS.includes(a.Type);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!reachesTerminal) {
|
|
29
|
+
report({
|
|
30
|
+
severity: "error",
|
|
31
|
+
message: "No terminal action is reachable from StartAction; the flow cannot end.",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const action of doc.content.Actions) {
|
|
36
|
+
if (isTerminal(action) && !TERMINAL_ACTIONS.includes(action.Type)) {
|
|
37
|
+
report({
|
|
38
|
+
severity: "warning",
|
|
39
|
+
blockId: action.Identifier,
|
|
40
|
+
message: `${action.Type} has no transitions but is not a terminal action type, so the flow dead-ends here.`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
import { isValidIdentifier } from "../../flowdoc.js";
|
|
6
|
+
import type { Rule } from "../types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Identifiers are unique within a flow and document names unique within a set,
|
|
10
|
+
* because both are used as keys by the emitters.
|
|
11
|
+
*/
|
|
12
|
+
export const uniqueNames: Rule = {
|
|
13
|
+
id: "unique-names",
|
|
14
|
+
description:
|
|
15
|
+
"Action Identifiers are unique within a flow, document names unique within a set, and both are valid.",
|
|
16
|
+
check({ doc, all, report }) {
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
for (const action of doc.content.Actions) {
|
|
19
|
+
if (seen.has(action.Identifier)) {
|
|
20
|
+
report({
|
|
21
|
+
severity: "error",
|
|
22
|
+
blockId: action.Identifier,
|
|
23
|
+
message: `Duplicate Identifier "${action.Identifier}".`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
seen.add(action.Identifier);
|
|
27
|
+
|
|
28
|
+
if (!isValidIdentifier(action.Identifier)) {
|
|
29
|
+
report({
|
|
30
|
+
severity: "error",
|
|
31
|
+
blockId: action.Identifier,
|
|
32
|
+
message: `Identifier "${action.Identifier}" is not valid: at most 50 characters, and none of % : ( \\ / ) = $ , ; [ ] { }.`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (all.filter((d) => d.name === doc.name).length > 1) {
|
|
38
|
+
report({
|
|
39
|
+
severity: "error",
|
|
40
|
+
message: `Duplicate document name "${doc.name}" in this flow set.`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|