@intentius/chant-lexicon-temporal 0.1.5
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/dist/integrity.json +15 -0
- package/dist/manifest.json +8 -0
- package/dist/meta.json +26 -0
- package/dist/rules/tmp001.ts +53 -0
- package/dist/rules/tmp002.ts +45 -0
- package/dist/rules/tmp010-cron-syntax.ts +64 -0
- package/dist/rules/tmp011-namespace-reference.ts +49 -0
- package/dist/skills/chant-temporal-ops.md +184 -0
- package/dist/skills/chant-temporal.md +201 -0
- package/dist/types/index.d.ts +3 -0
- package/package.json +32 -0
- package/src/codegen/docs-cli.ts +7 -0
- package/src/codegen/docs.ts +23 -0
- package/src/codegen/generate-cli.ts +17 -0
- package/src/codegen/generate.ts +82 -0
- package/src/codegen/package-cli.ts +14 -0
- package/src/codegen/package.ts +55 -0
- package/src/composites/cloud-stack.ts +74 -0
- package/src/composites/composites.test.ts +131 -0
- package/src/composites/dev-stack.ts +81 -0
- package/src/config.ts +150 -0
- package/src/coverage.test.ts +9 -0
- package/src/coverage.ts +37 -0
- package/src/example.test.ts +59 -0
- package/src/generated/lexicon-temporal.json +26 -0
- package/src/index.ts +29 -0
- package/src/lint/post-synth/post-synth.test.ts +152 -0
- package/src/lint/post-synth/tmp010-cron-syntax.ts +64 -0
- package/src/lint/post-synth/tmp011-namespace-reference.ts +49 -0
- package/src/lint/rules/index.ts +2 -0
- package/src/lint/rules/lint-rules.test.ts +150 -0
- package/src/lint/rules/tmp001.ts +53 -0
- package/src/lint/rules/tmp002.ts +45 -0
- package/src/plugin.test.ts +97 -0
- package/src/plugin.ts +286 -0
- package/src/resources.ts +121 -0
- package/src/serializer.test.ts +292 -0
- package/src/serializer.ts +310 -0
- package/src/skills/chant-temporal-ops.md +184 -0
- package/src/skills/chant-temporal.md +201 -0
- package/src/validate-cli.ts +7 -0
- package/src/validate.test.ts +9 -0
- package/src/validate.ts +92 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the temporal-self-hosted example project.
|
|
3
|
+
*
|
|
4
|
+
* Builds the example from source using the temporal serializer and
|
|
5
|
+
* verifies all expected output files are produced.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect } from "vitest";
|
|
9
|
+
import { resolve } from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { build } from "@intentius/chant/build";
|
|
12
|
+
import { lintCommand } from "@intentius/chant/cli/commands/lint";
|
|
13
|
+
import { temporalSerializer } from "./serializer";
|
|
14
|
+
|
|
15
|
+
const examplesRoot = resolve(fileURLToPath(import.meta.url), "../../../../examples");
|
|
16
|
+
const srcDir = resolve(examplesRoot, "temporal-self-hosted", "src");
|
|
17
|
+
|
|
18
|
+
describe("temporal-self-hosted example", () => {
|
|
19
|
+
test("passes lint", async () => {
|
|
20
|
+
const result = await lintCommand({ path: srcDir, format: "stylish", fix: true });
|
|
21
|
+
if (!result.success || result.errorCount > 0) console.log(result.output);
|
|
22
|
+
expect(result.success).toBe(true);
|
|
23
|
+
expect(result.errorCount).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("build produces docker-compose.yml primary output", async () => {
|
|
27
|
+
const result = await build(srcDir, [temporalSerializer]);
|
|
28
|
+
expect(result.errors).toHaveLength(0);
|
|
29
|
+
const output = result.outputs.get("temporal")!;
|
|
30
|
+
const primary = typeof output === "string" ? output : (output as { primary: string }).primary;
|
|
31
|
+
expect(primary).toContain("temporalio/admin-tools");
|
|
32
|
+
expect(primary).toContain("temporal server start-dev");
|
|
33
|
+
expect(primary).toContain("7233");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("build produces temporal-setup.sh with namespace and search attributes", async () => {
|
|
37
|
+
const result = await build(srcDir, [temporalSerializer]);
|
|
38
|
+
const output = result.outputs.get("temporal")!;
|
|
39
|
+
const files =
|
|
40
|
+
typeof output === "string" ? {} : (output as { files: Record<string, string> }).files ?? {};
|
|
41
|
+
expect(files["temporal-setup.sh"]).toBeDefined();
|
|
42
|
+
expect(files["temporal-setup.sh"]).toContain("temporal operator namespace create");
|
|
43
|
+
expect(files["temporal-setup.sh"]).toContain('--namespace "my-app"');
|
|
44
|
+
expect(files["temporal-setup.sh"]).toContain("temporal operator search-attribute create");
|
|
45
|
+
expect(files["temporal-setup.sh"]).toContain('"JobType"');
|
|
46
|
+
expect(files["temporal-setup.sh"]).toContain('"Priority"');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("build produces schedules/daily-sync.ts", async () => {
|
|
50
|
+
const result = await build(srcDir, [temporalSerializer]);
|
|
51
|
+
const output = result.outputs.get("temporal")!;
|
|
52
|
+
const files =
|
|
53
|
+
typeof output === "string" ? {} : (output as { files: Record<string, string> }).files ?? {};
|
|
54
|
+
expect(files["schedules/daily-sync.ts"]).toBeDefined();
|
|
55
|
+
expect(files["schedules/daily-sync.ts"]).toContain("daily-sync");
|
|
56
|
+
expect(files["schedules/daily-sync.ts"]).toContain("syncWorkflow");
|
|
57
|
+
expect(files["schedules/daily-sync.ts"]).toContain("client.schedule.create");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"TemporalServer": {
|
|
3
|
+
"resourceType": "Temporal::Server",
|
|
4
|
+
"kind": "resource",
|
|
5
|
+
"lexicon": "temporal",
|
|
6
|
+
"description": "Temporal server deployment — emits docker-compose.yml and Helm values"
|
|
7
|
+
},
|
|
8
|
+
"TemporalNamespace": {
|
|
9
|
+
"resourceType": "Temporal::Namespace",
|
|
10
|
+
"kind": "resource",
|
|
11
|
+
"lexicon": "temporal",
|
|
12
|
+
"description": "Temporal namespace — emits namespace create command in temporal-setup.sh"
|
|
13
|
+
},
|
|
14
|
+
"SearchAttribute": {
|
|
15
|
+
"resourceType": "Temporal::SearchAttribute",
|
|
16
|
+
"kind": "resource",
|
|
17
|
+
"lexicon": "temporal",
|
|
18
|
+
"description": "Temporal search attribute — emits search-attribute create command in temporal-setup.sh"
|
|
19
|
+
},
|
|
20
|
+
"TemporalSchedule": {
|
|
21
|
+
"resourceType": "Temporal::Schedule",
|
|
22
|
+
"kind": "resource",
|
|
23
|
+
"lexicon": "temporal",
|
|
24
|
+
"description": "Temporal schedule — emits SDK schedule creation TypeScript to schedules/<id>.ts"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Plugin
|
|
2
|
+
export { temporalPlugin } from "./plugin";
|
|
3
|
+
|
|
4
|
+
// Serializer
|
|
5
|
+
export { temporalSerializer } from "./serializer";
|
|
6
|
+
|
|
7
|
+
// Resources (hand-written)
|
|
8
|
+
export {
|
|
9
|
+
TemporalServer,
|
|
10
|
+
TemporalNamespace,
|
|
11
|
+
SearchAttribute,
|
|
12
|
+
TemporalSchedule,
|
|
13
|
+
} from "./resources";
|
|
14
|
+
export type {
|
|
15
|
+
TemporalServerProps,
|
|
16
|
+
TemporalNamespaceProps,
|
|
17
|
+
SearchAttributeProps,
|
|
18
|
+
TemporalScheduleProps,
|
|
19
|
+
} from "./resources";
|
|
20
|
+
|
|
21
|
+
// Worker profile config shape + activity profiles
|
|
22
|
+
export type { TemporalWorkerProfile, TemporalChantConfig, TemporalActivityProfile } from "./config";
|
|
23
|
+
export { TEMPORAL_ACTIVITY_PROFILES } from "./config";
|
|
24
|
+
|
|
25
|
+
// Composites
|
|
26
|
+
export { TemporalDevStack } from "./composites/dev-stack";
|
|
27
|
+
export type { TemporalDevStackConfig, TemporalDevStackResources } from "./composites/dev-stack";
|
|
28
|
+
export { TemporalCloudStack } from "./composites/cloud-stack";
|
|
29
|
+
export type { TemporalCloudStackConfig, TemporalCloudStackResources } from "./composites/cloud-stack";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-synth check tests — TMP010, TMP011.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from "vitest";
|
|
6
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
7
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
8
|
+
import { tmp010 } from "./tmp010-cron-syntax";
|
|
9
|
+
import { tmp011 } from "./tmp011-namespace-reference";
|
|
10
|
+
|
|
11
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function makeCtxFromOutput(output: string | { primary: string; files: Record<string, string> }): PostSynthContext {
|
|
14
|
+
return {
|
|
15
|
+
outputs: new Map([["temporal", output]]),
|
|
16
|
+
entities: new Map(),
|
|
17
|
+
buildResult: {
|
|
18
|
+
outputs: new Map([["temporal", output]]),
|
|
19
|
+
entities: new Map(),
|
|
20
|
+
warnings: [],
|
|
21
|
+
errors: [],
|
|
22
|
+
sourceFileCount: 1,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeEntity(entityType: string, props: Record<string, unknown>) {
|
|
28
|
+
return {
|
|
29
|
+
[DECLARABLE_MARKER]: true,
|
|
30
|
+
entityType,
|
|
31
|
+
lexicon: "temporal",
|
|
32
|
+
kind: "resource",
|
|
33
|
+
props,
|
|
34
|
+
attributes: {},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeCtxFromEntities(entities: Map<string, unknown>): PostSynthContext {
|
|
39
|
+
return {
|
|
40
|
+
outputs: new Map([["temporal", ""]]),
|
|
41
|
+
entities: entities as Map<string, never>,
|
|
42
|
+
buildResult: {
|
|
43
|
+
outputs: new Map([["temporal", ""]]),
|
|
44
|
+
entities: entities as Map<string, never>,
|
|
45
|
+
warnings: [],
|
|
46
|
+
errors: [],
|
|
47
|
+
sourceFileCount: 1,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── TMP010: cron-syntax ──────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("TMP010: cron-syntax", () => {
|
|
55
|
+
test("warns for invalid cron with only 4 fields", () => {
|
|
56
|
+
const content = `cronExpressions: ["0 3 * *"]`;
|
|
57
|
+
const ctx = makeCtxFromOutput({
|
|
58
|
+
primary: "# docker-compose",
|
|
59
|
+
files: { "schedules/daily.ts": content },
|
|
60
|
+
});
|
|
61
|
+
const diags = tmp010.check(ctx);
|
|
62
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
63
|
+
expect(diags[0].checkId).toBe("TMP010");
|
|
64
|
+
expect(diags[0].severity).toBe("warning");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("passes for valid 5-field cron", () => {
|
|
68
|
+
const content = `cronExpressions: ["0 3 * * *"]`;
|
|
69
|
+
const ctx = makeCtxFromOutput({
|
|
70
|
+
primary: "# docker-compose",
|
|
71
|
+
files: { "schedules/daily.ts": content },
|
|
72
|
+
});
|
|
73
|
+
expect(tmp010.check(ctx)).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("passes for valid 6-field cron (with seconds)", () => {
|
|
77
|
+
const content = `cronExpressions: ["0 0 3 * * *"]`;
|
|
78
|
+
const ctx = makeCtxFromOutput({
|
|
79
|
+
primary: "# docker-compose",
|
|
80
|
+
files: { "schedules/daily.ts": content },
|
|
81
|
+
});
|
|
82
|
+
expect(tmp010.check(ctx)).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("skips non-temporal lexicons", () => {
|
|
86
|
+
const ctx: PostSynthContext = {
|
|
87
|
+
outputs: new Map([["aws", `cronExpressions: ["invalid"]`]]),
|
|
88
|
+
entities: new Map(),
|
|
89
|
+
buildResult: {
|
|
90
|
+
outputs: new Map([["aws", ""]]),
|
|
91
|
+
entities: new Map(),
|
|
92
|
+
warnings: [],
|
|
93
|
+
errors: [],
|
|
94
|
+
sourceFileCount: 1,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
expect(tmp010.check(ctx)).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("skips non-schedule files", () => {
|
|
101
|
+
const content = `cronExpressions: ["bad"]`;
|
|
102
|
+
const ctx = makeCtxFromOutput({
|
|
103
|
+
primary: content,
|
|
104
|
+
files: { "temporal-setup.sh": content },
|
|
105
|
+
});
|
|
106
|
+
expect(tmp010.check(ctx)).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("passes when output is plain string (no schedule files)", () => {
|
|
110
|
+
const ctx = makeCtxFromOutput("# docker-compose.yml\nservices:\n temporal:\n");
|
|
111
|
+
expect(tmp010.check(ctx)).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── TMP011: namespace-reference ──────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe("TMP011: namespace-reference", () => {
|
|
118
|
+
test("errors when SearchAttribute references undeclared namespace", () => {
|
|
119
|
+
const ctx = makeCtxFromEntities(new Map([
|
|
120
|
+
["attr", makeEntity("Temporal::SearchAttribute", { name: "Project", type: "Keyword", namespace: "prod" })],
|
|
121
|
+
]));
|
|
122
|
+
const diags = tmp011.check(ctx);
|
|
123
|
+
expect(diags).toHaveLength(1);
|
|
124
|
+
expect(diags[0].checkId).toBe("TMP011");
|
|
125
|
+
expect(diags[0].severity).toBe("error");
|
|
126
|
+
expect(diags[0].message).toContain("prod");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("passes when SearchAttribute namespace is declared", () => {
|
|
130
|
+
const ctx = makeCtxFromEntities(new Map([
|
|
131
|
+
["ns", makeEntity("Temporal::Namespace", { name: "prod", retention: "30d" })],
|
|
132
|
+
["attr", makeEntity("Temporal::SearchAttribute", { name: "Project", type: "Keyword", namespace: "prod" })],
|
|
133
|
+
]));
|
|
134
|
+
expect(tmp011.check(ctx)).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("passes when SearchAttribute has no namespace (global)", () => {
|
|
138
|
+
const ctx = makeCtxFromEntities(new Map([
|
|
139
|
+
["attr", makeEntity("Temporal::SearchAttribute", { name: "Project", type: "Keyword" })],
|
|
140
|
+
]));
|
|
141
|
+
expect(tmp011.check(ctx)).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("flags each attribute with a missing namespace independently", () => {
|
|
145
|
+
const ctx = makeCtxFromEntities(new Map([
|
|
146
|
+
["attr1", makeEntity("Temporal::SearchAttribute", { name: "A", type: "Keyword", namespace: "missing1" })],
|
|
147
|
+
["attr2", makeEntity("Temporal::SearchAttribute", { name: "B", type: "Keyword", namespace: "missing2" })],
|
|
148
|
+
]));
|
|
149
|
+
const diags = tmp011.check(ctx);
|
|
150
|
+
expect(diags).toHaveLength(2);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TMP010: TemporalSchedule cron expression syntax
|
|
3
|
+
*
|
|
4
|
+
* Validates that cron expressions in TemporalSchedule.spec.cronExpressions
|
|
5
|
+
* are valid 5- or 6-field cron syntax. Malformed crons are silently ignored
|
|
6
|
+
* by Temporal's scheduler, leading to schedules that never fire.
|
|
7
|
+
*
|
|
8
|
+
* This is a pre-submission guard — final validation is Temporal's own parser.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
|
|
13
|
+
/** Very permissive cron field pattern — catches obvious syntax errors. */
|
|
14
|
+
const CRON_FIELD = /^[0-9*,/\-?LW#]+$/;
|
|
15
|
+
|
|
16
|
+
function isValidCronExpression(expr: string): boolean {
|
|
17
|
+
const fields = expr.trim().split(/\s+/);
|
|
18
|
+
if (fields.length < 5 || fields.length > 6) return false;
|
|
19
|
+
return fields.every((f) => CRON_FIELD.test(f));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const tmp010: PostSynthCheck = {
|
|
23
|
+
id: "TMP010",
|
|
24
|
+
description: "TemporalSchedule cron expressions must be valid 5- or 6-field cron syntax",
|
|
25
|
+
|
|
26
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
27
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
28
|
+
|
|
29
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
30
|
+
if (lexicon !== "temporal") continue;
|
|
31
|
+
|
|
32
|
+
// Schedules emit individual TypeScript files — check the schedules/ files.
|
|
33
|
+
const files =
|
|
34
|
+
typeof output === "string"
|
|
35
|
+
? new Map<string, string>()
|
|
36
|
+
: (output as { primary: string; files?: Record<string, string> }).files
|
|
37
|
+
? new Map(Object.entries((output as { files: Record<string, string> }).files))
|
|
38
|
+
: new Map<string, string>();
|
|
39
|
+
|
|
40
|
+
for (const [filename, content] of files) {
|
|
41
|
+
if (!filename.startsWith("schedules/")) continue;
|
|
42
|
+
|
|
43
|
+
// Extract cron expressions from the generated TypeScript:
|
|
44
|
+
// cronExpressions: ["0 3 * * *"]
|
|
45
|
+
const cronMatches = [...content.matchAll(/cronExpressions:\s*\[([^\]]+)\]/g)];
|
|
46
|
+
for (const match of cronMatches) {
|
|
47
|
+
const exprs = [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
48
|
+
for (const expr of exprs) {
|
|
49
|
+
if (!isValidCronExpression(expr)) {
|
|
50
|
+
diagnostics.push({
|
|
51
|
+
checkId: "TMP010",
|
|
52
|
+
severity: "warning",
|
|
53
|
+
message: `${filename}: cron expression "${expr}" does not look like valid 5- or 6-field cron syntax`,
|
|
54
|
+
lexicon: "temporal",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return diagnostics;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TMP011: SearchAttribute references an undeclared namespace
|
|
3
|
+
*
|
|
4
|
+
* If SearchAttribute.namespace is set to a value X, there must be a
|
|
5
|
+
* TemporalNamespace entity with name === X in the project. A missing
|
|
6
|
+
* namespace means the search attribute create command will fail at runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
|
|
11
|
+
export const tmp011: PostSynthCheck = {
|
|
12
|
+
id: "TMP011",
|
|
13
|
+
description: "SearchAttribute.namespace must reference a declared TemporalNamespace entity",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
// Collect declared namespace names
|
|
19
|
+
const declaredNamespaces = new Set<string>();
|
|
20
|
+
for (const [, entity] of ctx.entities) {
|
|
21
|
+
const et = (entity as Record<string, unknown>).entityType as string;
|
|
22
|
+
if (et !== "Temporal::Namespace") continue;
|
|
23
|
+
const props = (entity as { props?: Record<string, unknown> }).props ?? {};
|
|
24
|
+
const name = props.name as string | undefined;
|
|
25
|
+
if (name) declaredNamespaces.add(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check each SearchAttribute that specifies a namespace
|
|
29
|
+
for (const [entityKey, entity] of ctx.entities) {
|
|
30
|
+
const et = (entity as Record<string, unknown>).entityType as string;
|
|
31
|
+
if (et !== "Temporal::SearchAttribute") continue;
|
|
32
|
+
|
|
33
|
+
const props = (entity as { props?: Record<string, unknown> }).props ?? {};
|
|
34
|
+
const ns = props.namespace as string | undefined;
|
|
35
|
+
if (!ns) continue; // no namespace specified — applies globally, OK
|
|
36
|
+
|
|
37
|
+
if (!declaredNamespaces.has(ns)) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
checkId: "TMP011",
|
|
40
|
+
severity: "error",
|
|
41
|
+
message: `SearchAttribute "${entityKey}" references namespace "${ns}" which is not declared — add a TemporalNamespace with name "${ns}"`,
|
|
42
|
+
lexicon: "temporal",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return diagnostics;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lint rule tests — TMP001, TMP002.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from "vitest";
|
|
6
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
7
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
8
|
+
import { tmp001 } from "./tmp001";
|
|
9
|
+
import { tmp002 } from "./tmp002";
|
|
10
|
+
|
|
11
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function makeEntity(entityType: string, props: Record<string, unknown>) {
|
|
14
|
+
return {
|
|
15
|
+
[DECLARABLE_MARKER]: true,
|
|
16
|
+
entityType,
|
|
17
|
+
lexicon: "temporal",
|
|
18
|
+
kind: "resource",
|
|
19
|
+
props,
|
|
20
|
+
attributes: {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeCtx(entities: Map<string, unknown>): LintContext {
|
|
25
|
+
return {
|
|
26
|
+
entities: entities as Map<string, never>,
|
|
27
|
+
project: { name: "test" } as never,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── TMP001: retention-too-short ──────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe("TMP001: retention-too-short", () => {
|
|
34
|
+
test("flags namespace with 1d retention", () => {
|
|
35
|
+
const ctx = makeCtx(new Map([
|
|
36
|
+
["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "1d" })],
|
|
37
|
+
]));
|
|
38
|
+
const diags = tmp001.check(ctx);
|
|
39
|
+
expect(diags).toHaveLength(1);
|
|
40
|
+
expect(diags[0].ruleId).toBe("TMP001");
|
|
41
|
+
expect(diags[0].severity).toBe("error");
|
|
42
|
+
expect(diags[0].message).toContain("1d");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("flags namespace with 48h retention", () => {
|
|
46
|
+
const ctx = makeCtx(new Map([
|
|
47
|
+
["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "48h" })],
|
|
48
|
+
]));
|
|
49
|
+
const diags = tmp001.check(ctx);
|
|
50
|
+
expect(diags).toHaveLength(1);
|
|
51
|
+
expect(diags[0].ruleId).toBe("TMP001");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("passes with 3d retention (exactly at threshold)", () => {
|
|
55
|
+
const ctx = makeCtx(new Map([
|
|
56
|
+
["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "3d" })],
|
|
57
|
+
]));
|
|
58
|
+
expect(tmp001.check(ctx)).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("passes with 7d retention", () => {
|
|
62
|
+
const ctx = makeCtx(new Map([
|
|
63
|
+
["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "7d" })],
|
|
64
|
+
]));
|
|
65
|
+
expect(tmp001.check(ctx)).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("passes when retention is unset (defaults to 7d)", () => {
|
|
69
|
+
const ctx = makeCtx(new Map([
|
|
70
|
+
["ns", makeEntity("Temporal::Namespace", { name: "default" })],
|
|
71
|
+
]));
|
|
72
|
+
expect(tmp001.check(ctx)).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("skips non-namespace entities", () => {
|
|
76
|
+
const ctx = makeCtx(new Map([
|
|
77
|
+
["s", makeEntity("Temporal::Server", { mode: "dev" })],
|
|
78
|
+
]));
|
|
79
|
+
expect(tmp001.check(ctx)).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("skips unrecognised retention format", () => {
|
|
83
|
+
const ctx = makeCtx(new Map([
|
|
84
|
+
["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "1week" })],
|
|
85
|
+
]));
|
|
86
|
+
expect(tmp001.check(ctx)).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── TMP002: allowall-without-note ────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("TMP002: allowall-without-note", () => {
|
|
93
|
+
test("warns for AllowAll overlap without state.note", () => {
|
|
94
|
+
const ctx = makeCtx(new Map([
|
|
95
|
+
["sched", makeEntity("Temporal::Schedule", {
|
|
96
|
+
scheduleId: "heavy-job",
|
|
97
|
+
spec: { cronExpressions: ["0 * * * *"] },
|
|
98
|
+
action: { workflowType: "heavyWorkflow", taskQueue: "heavy" },
|
|
99
|
+
policies: { overlap: "AllowAll" },
|
|
100
|
+
})],
|
|
101
|
+
]));
|
|
102
|
+
const diags = tmp002.check(ctx);
|
|
103
|
+
expect(diags).toHaveLength(1);
|
|
104
|
+
expect(diags[0].ruleId).toBe("TMP002");
|
|
105
|
+
expect(diags[0].severity).toBe("warning");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("passes when AllowAll has a note", () => {
|
|
109
|
+
const ctx = makeCtx(new Map([
|
|
110
|
+
["sched", makeEntity("Temporal::Schedule", {
|
|
111
|
+
scheduleId: "heavy-job",
|
|
112
|
+
spec: { cronExpressions: ["0 * * * *"] },
|
|
113
|
+
action: { workflowType: "heavyWorkflow", taskQueue: "heavy" },
|
|
114
|
+
policies: { overlap: "AllowAll" },
|
|
115
|
+
state: { note: "Workflow is idempotent — concurrent runs are safe" },
|
|
116
|
+
})],
|
|
117
|
+
]));
|
|
118
|
+
expect(tmp002.check(ctx)).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("passes for Skip overlap (no note needed)", () => {
|
|
122
|
+
const ctx = makeCtx(new Map([
|
|
123
|
+
["sched", makeEntity("Temporal::Schedule", {
|
|
124
|
+
scheduleId: "daily",
|
|
125
|
+
spec: { cronExpressions: ["0 3 * * *"] },
|
|
126
|
+
action: { workflowType: "dailyWorkflow", taskQueue: "daily" },
|
|
127
|
+
policies: { overlap: "Skip" },
|
|
128
|
+
})],
|
|
129
|
+
]));
|
|
130
|
+
expect(tmp002.check(ctx)).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("passes when no policies set", () => {
|
|
134
|
+
const ctx = makeCtx(new Map([
|
|
135
|
+
["sched", makeEntity("Temporal::Schedule", {
|
|
136
|
+
scheduleId: "daily",
|
|
137
|
+
spec: { cronExpressions: ["0 3 * * *"] },
|
|
138
|
+
action: { workflowType: "dailyWorkflow", taskQueue: "daily" },
|
|
139
|
+
})],
|
|
140
|
+
]));
|
|
141
|
+
expect(tmp002.check(ctx)).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("skips non-schedule entities", () => {
|
|
145
|
+
const ctx = makeCtx(new Map([
|
|
146
|
+
["ns", makeEntity("Temporal::Namespace", { name: "default" })],
|
|
147
|
+
]));
|
|
148
|
+
expect(tmp002.check(ctx)).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TMP001: TemporalNamespace retention too short
|
|
3
|
+
*
|
|
4
|
+
* Workflow history older than the retention period is permanently deleted.
|
|
5
|
+
* Retentions shorter than 3 days leave very little time for debugging
|
|
6
|
+
* failures or running ad-hoc queries against closed workflow executions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
10
|
+
|
|
11
|
+
/** Parse a retention string like "1d", "12h", "3d" → total hours. Returns NaN on unrecognised format. */
|
|
12
|
+
function retentionHours(retention: string): number {
|
|
13
|
+
const days = /^(\d+)d$/i.exec(retention);
|
|
14
|
+
if (days) return Number(days[1]) * 24;
|
|
15
|
+
const hours = /^(\d+)h$/i.exec(retention);
|
|
16
|
+
if (hours) return Number(hours[1]);
|
|
17
|
+
return NaN;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const tmp001: LintRule = {
|
|
21
|
+
id: "TMP001",
|
|
22
|
+
severity: "error",
|
|
23
|
+
category: "correctness",
|
|
24
|
+
description: "TemporalNamespace retention should be at least 3 days to preserve workflow history for debugging",
|
|
25
|
+
|
|
26
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
27
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
28
|
+
|
|
29
|
+
for (const [name, entity] of context.entities) {
|
|
30
|
+
const et = (entity as Record<string, unknown>).entityType as string;
|
|
31
|
+
if (et !== "Temporal::Namespace") continue;
|
|
32
|
+
|
|
33
|
+
const props = (entity as { props?: Record<string, unknown> }).props ?? {};
|
|
34
|
+
const retention = props.retention as string | undefined;
|
|
35
|
+
if (!retention) continue; // default "7d" — not an error
|
|
36
|
+
|
|
37
|
+
const hours = retentionHours(retention);
|
|
38
|
+
if (isNaN(hours)) continue; // unrecognised format — skip
|
|
39
|
+
|
|
40
|
+
if (hours < 72) {
|
|
41
|
+
diagnostics.push({
|
|
42
|
+
ruleId: "TMP001",
|
|
43
|
+
severity: "error",
|
|
44
|
+
message: `Namespace "${name}" has retention "${retention}" — minimum recommended is 3d (72h) to preserve workflow history`,
|
|
45
|
+
entity: name,
|
|
46
|
+
fix: 'Set retention to at least "3d" — e.g. retention: "7d"',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return diagnostics;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TMP002: TemporalSchedule AllowAll overlap without explanatory note
|
|
3
|
+
*
|
|
4
|
+
* AllowAll allows any number of concurrent schedule runs. This is safe for
|
|
5
|
+
* idempotent, read-only workflows, but can cause resource exhaustion or
|
|
6
|
+
* duplicate side-effects if not explicitly intended. Requiring a note forces
|
|
7
|
+
* the author to document the intent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
11
|
+
|
|
12
|
+
export const tmp002: LintRule = {
|
|
13
|
+
id: "TMP002",
|
|
14
|
+
severity: "warning",
|
|
15
|
+
category: "best-practices",
|
|
16
|
+
description: "TemporalSchedule with overlap AllowAll should include state.note explaining the intent",
|
|
17
|
+
|
|
18
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
19
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [name, entity] of context.entities) {
|
|
22
|
+
const et = (entity as Record<string, unknown>).entityType as string;
|
|
23
|
+
if (et !== "Temporal::Schedule") continue;
|
|
24
|
+
|
|
25
|
+
const props = (entity as { props?: Record<string, unknown> }).props ?? {};
|
|
26
|
+
const policies = props.policies as Record<string, unknown> | undefined;
|
|
27
|
+
if (policies?.overlap !== "AllowAll") continue;
|
|
28
|
+
|
|
29
|
+
const state = props.state as Record<string, unknown> | undefined;
|
|
30
|
+
const note = state?.note as string | undefined;
|
|
31
|
+
|
|
32
|
+
if (!note || note.trim() === "") {
|
|
33
|
+
diagnostics.push({
|
|
34
|
+
ruleId: "TMP002",
|
|
35
|
+
severity: "warning",
|
|
36
|
+
message: `Schedule "${name}" uses overlap "AllowAll" — add state.note explaining why concurrent runs are safe`,
|
|
37
|
+
entity: name,
|
|
38
|
+
fix: 'Add state: { note: "Workflow is idempotent — concurrent runs are safe" }',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return diagnostics;
|
|
44
|
+
},
|
|
45
|
+
};
|