@intentius/chant 0.0.18 → 0.0.24
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/bin/chant +4 -1
- package/package.json +20 -1
- package/src/build.test.ts +4 -2
- package/src/build.ts +3 -0
- package/src/builder.test.ts +3 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
- package/src/cli/commands/build.ts +5 -12
- package/src/cli/commands/diff.test.ts +2 -1
- package/src/cli/commands/diff.ts +2 -1
- package/src/cli/commands/init-lexicon/templates/codegen.ts +188 -0
- package/src/cli/commands/init-lexicon/templates/docs.ts +81 -0
- package/src/cli/commands/init-lexicon/templates/examples.ts +35 -0
- package/src/cli/commands/init-lexicon/templates/lint.ts +30 -0
- package/src/cli/commands/init-lexicon/templates/lsp.ts +39 -0
- package/src/cli/commands/init-lexicon/templates/plugin.ts +110 -0
- package/src/cli/commands/init-lexicon/templates/project.ts +182 -0
- package/src/cli/commands/init-lexicon/templates/spec.ts +57 -0
- package/src/cli/commands/init-lexicon/templates/tests.ts +70 -0
- package/src/cli/commands/init-lexicon.test.ts +0 -9
- package/src/cli/commands/init-lexicon.ts +12 -868
- package/src/cli/commands/init.ts +2 -20
- package/src/cli/conflict-check.test.ts +43 -0
- package/src/cli/handlers/build.ts +3 -3
- package/src/cli/handlers/lint.ts +2 -2
- package/src/cli/handlers/spell.ts +396 -0
- package/src/cli/handlers/state.ts +230 -0
- package/src/cli/lsp/server.test.ts +4 -0
- package/src/cli/main.ts +37 -3
- package/src/cli/mcp/resource-handlers.ts +227 -0
- package/src/cli/mcp/server.test.ts +13 -9
- package/src/cli/mcp/server.ts +24 -199
- package/src/cli/mcp/state-tools.ts +138 -0
- package/src/cli/mcp/tools/build.ts +2 -1
- package/src/cli/mcp/types.ts +45 -0
- package/src/cli/plugins.ts +1 -1
- package/src/cli/reporters/stylish.test.ts +2 -2
- package/src/cli/reporters/stylish.ts +1 -1
- package/src/codegen/docs-file-markers.ts +69 -0
- package/src/codegen/docs-rule-scanning.ts +159 -0
- package/src/codegen/docs-sections.ts +159 -0
- package/src/codegen/docs-sidebar.ts +56 -0
- package/src/codegen/docs-types.ts +79 -0
- package/src/codegen/docs.ts +9 -495
- package/src/composite.test.ts +76 -1
- package/src/composite.ts +37 -0
- package/src/config.ts +4 -0
- package/src/declarable.test.ts +2 -1
- package/src/declarable.ts +1 -1
- package/src/discovery/collect.test.ts +34 -0
- package/src/discovery/collect.ts +12 -0
- package/src/discovery/graph.test.ts +40 -0
- package/src/discovery/import.test.ts +5 -5
- package/src/discovery/resolve.test.ts +20 -0
- package/src/discovery/resolve.ts +2 -2
- package/src/index.ts +2 -0
- package/src/lexicon-plugin-helpers.ts +130 -0
- package/src/lexicon.ts +24 -0
- package/src/lint/rule-options.test.ts +3 -3
- package/src/lint/rule-registry.test.ts +1 -1
- package/src/lint/rules/composite-scope.ts +1 -1
- package/src/serializer-walker.ts +2 -1
- package/src/spell/discovery.ts +183 -0
- package/src/spell/index.ts +3 -0
- package/src/spell/prompt.ts +133 -0
- package/src/spell/types.ts +89 -0
- package/src/state/digest.ts +88 -0
- package/src/state/git.ts +317 -0
- package/src/state/index.ts +4 -0
- package/src/state/snapshot.ts +179 -0
- package/src/state/types.ts +59 -0
- package/src/toml-emit.ts +182 -0
- package/src/toml-parse.ts +370 -0
- package/src/toml-utils.ts +60 -0
- package/src/toml.ts +5 -602
- package/src/types.ts +2 -1
- package/src/utils.test.ts +16 -3
- package/src/utils.ts +31 -1
- package/src/validation.test.ts +11 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
|
@@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
|
2
2
|
import { parseRuleConfig, loadConfig } from "./config";
|
|
3
3
|
import { fileDeclarableLimitRule } from "./rules/file-declarable-limit";
|
|
4
4
|
import * as ts from "typescript";
|
|
5
|
-
import type { LintContext } from "./rule";
|
|
5
|
+
import type { LintContext, RuleConfig } from "./rule";
|
|
6
6
|
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
|
|
@@ -37,12 +37,12 @@ describe("parseRuleConfig", () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test("parses [severity, options] tuple", () => {
|
|
40
|
-
const result = parseRuleConfig(["warning", { max: 12 }]);
|
|
40
|
+
const result = parseRuleConfig(["warning" as const, { max: 12 }]);
|
|
41
41
|
expect(result).toEqual({ severity: "warning", options: { max: 12 } });
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
test("throws for invalid tuple length", () => {
|
|
45
|
-
expect(() => parseRuleConfig([] as unknown as
|
|
45
|
+
expect(() => parseRuleConfig([] as unknown as RuleConfig)).toThrow(
|
|
46
46
|
/expected a severity string or \[severity, options\] tuple/
|
|
47
47
|
);
|
|
48
48
|
});
|
|
@@ -88,7 +88,7 @@ describe("buildRuleRegistry", () => {
|
|
|
88
88
|
|
|
89
89
|
test("falls back to rule ID when description is missing", () => {
|
|
90
90
|
const rule = mockRule("COR001");
|
|
91
|
-
delete (rule as Record<string, unknown>).description;
|
|
91
|
+
delete (rule as unknown as Record<string, unknown>).description;
|
|
92
92
|
|
|
93
93
|
const entries = buildRuleRegistry([rule]);
|
|
94
94
|
expect(entries[0].description).toBe("COR001");
|
|
@@ -14,7 +14,7 @@ export function isInsideCompositeFactory(node: ts.Node): boolean {
|
|
|
14
14
|
|
|
15
15
|
while (current) {
|
|
16
16
|
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
|
|
17
|
-
const parent = current.parent;
|
|
17
|
+
const parent: ts.Node | undefined = current.parent;
|
|
18
18
|
if (parent && ts.isCallExpression(parent) && parent.arguments[0] === current) {
|
|
19
19
|
const callee = parent.expression;
|
|
20
20
|
if (isCompositeCallee(callee)) {
|
package/src/serializer-walker.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { Declarable } from "./declarable";
|
|
|
9
9
|
import { isPropertyDeclarable } from "./declarable";
|
|
10
10
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
11
11
|
import { AttrRef } from "./attrref";
|
|
12
|
+
import { isAttrRefLike } from "./utils";
|
|
12
13
|
|
|
13
14
|
export interface SerializerVisitor {
|
|
14
15
|
/** Format an attribute reference (e.g. CFN Fn::GetAttr). */
|
|
@@ -33,7 +34,7 @@ export function walkValue(
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
// Handle AttrRef
|
|
36
|
-
if (value
|
|
37
|
+
if (isAttrRefLike(value)) {
|
|
37
38
|
const name = value.getLogicalName();
|
|
38
39
|
if (!name) {
|
|
39
40
|
throw new Error(
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spell discovery: find, import, validate, and index spell files.
|
|
3
|
+
*/
|
|
4
|
+
import { getRuntime } from "../runtime-adapter";
|
|
5
|
+
import type { SpellDefinition, Status } from "./types";
|
|
6
|
+
import { readdir } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface DiscoveredSpell {
|
|
10
|
+
definition: SpellDefinition;
|
|
11
|
+
filePath: string;
|
|
12
|
+
status: Status;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SpellDiscoveryResult {
|
|
16
|
+
spells: Map<string, DiscoveredSpell>;
|
|
17
|
+
errors: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find the git root directory.
|
|
22
|
+
*/
|
|
23
|
+
async function findGitRoot(cwd?: string): Promise<string> {
|
|
24
|
+
const rt = getRuntime();
|
|
25
|
+
const result = await rt.spawn(["git", "rev-parse", "--show-toplevel"], { cwd });
|
|
26
|
+
if (result.exitCode !== 0) {
|
|
27
|
+
throw new Error("Not in a git repository");
|
|
28
|
+
}
|
|
29
|
+
return result.stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Discover all spells from the spells/ directory at the git root.
|
|
34
|
+
*/
|
|
35
|
+
export async function discoverSpells(
|
|
36
|
+
opts?: { cwd?: string },
|
|
37
|
+
): Promise<SpellDiscoveryResult> {
|
|
38
|
+
const errors: string[] = [];
|
|
39
|
+
const spells = new Map<string, DiscoveredSpell>();
|
|
40
|
+
|
|
41
|
+
const gitRoot = await findGitRoot(opts?.cwd);
|
|
42
|
+
const spellsDir = join(gitRoot, "spells");
|
|
43
|
+
|
|
44
|
+
// List *.spell.ts files
|
|
45
|
+
let files: string[];
|
|
46
|
+
try {
|
|
47
|
+
const entries = await readdir(spellsDir);
|
|
48
|
+
files = entries.filter((f) => f.endsWith(".spell.ts")).map((f) => join(spellsDir, f));
|
|
49
|
+
} catch {
|
|
50
|
+
// spells/ directory doesn't exist — that's OK
|
|
51
|
+
return { spells, errors };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Import each file
|
|
55
|
+
const fileMap = new Map<string, string>(); // name → filePath for duplicate detection
|
|
56
|
+
for (const filePath of files) {
|
|
57
|
+
try {
|
|
58
|
+
const mod = await import(filePath);
|
|
59
|
+
const def = mod.default as SpellDefinition | undefined;
|
|
60
|
+
|
|
61
|
+
if (!def) {
|
|
62
|
+
errors.push(`File ${filePath} has no default export`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Validate shape
|
|
67
|
+
if (!def.name || typeof def.name !== "string") {
|
|
68
|
+
errors.push(`File ${filePath}: default export has no valid name`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!def.tasks || !Array.isArray(def.tasks)) {
|
|
72
|
+
errors.push(`File ${filePath}: default export has no valid tasks`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Duplicate check
|
|
77
|
+
if (fileMap.has(def.name)) {
|
|
78
|
+
errors.push(
|
|
79
|
+
`Duplicate name "${def.name}" in ${filePath} and ${fileMap.get(def.name)}`,
|
|
80
|
+
);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fileMap.set(def.name, filePath);
|
|
85
|
+
spells.set(def.name, {
|
|
86
|
+
definition: def,
|
|
87
|
+
filePath,
|
|
88
|
+
status: "ready", // placeholder — computed after all are loaded
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
errors.push(
|
|
92
|
+
`${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Validate dependencies
|
|
98
|
+
for (const [name, spell] of spells) {
|
|
99
|
+
const deps = spell.definition.depends;
|
|
100
|
+
if (!deps) continue;
|
|
101
|
+
|
|
102
|
+
for (const depName of deps) {
|
|
103
|
+
if (!spells.has(depName)) {
|
|
104
|
+
errors.push(
|
|
105
|
+
`Spell "${name}" depends on "${depName}" which does not exist`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Detect circular dependencies via topological sort
|
|
112
|
+
const circularError = detectCycles(spells);
|
|
113
|
+
if (circularError) {
|
|
114
|
+
errors.push(circularError);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Compute status for each spell
|
|
118
|
+
computeStatuses(spells);
|
|
119
|
+
|
|
120
|
+
return { spells, errors };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Detect circular dependencies. Returns an error message or null.
|
|
125
|
+
*/
|
|
126
|
+
function detectCycles(spells: Map<string, DiscoveredSpell>): string | null {
|
|
127
|
+
const visited = new Set<string>();
|
|
128
|
+
const visiting = new Set<string>();
|
|
129
|
+
|
|
130
|
+
function visit(name: string, path: string[]): string | null {
|
|
131
|
+
if (visiting.has(name)) {
|
|
132
|
+
const cycle = [...path.slice(path.indexOf(name)), name];
|
|
133
|
+
return `Circular dependency: ${cycle.join(" → ")}`;
|
|
134
|
+
}
|
|
135
|
+
if (visited.has(name)) return null;
|
|
136
|
+
|
|
137
|
+
visiting.add(name);
|
|
138
|
+
path.push(name);
|
|
139
|
+
|
|
140
|
+
const spell = spells.get(name);
|
|
141
|
+
if (spell?.definition.depends) {
|
|
142
|
+
for (const dep of spell.definition.depends) {
|
|
143
|
+
if (spells.has(dep)) {
|
|
144
|
+
const err = visit(dep, path);
|
|
145
|
+
if (err) return err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
visiting.delete(name);
|
|
151
|
+
visited.add(name);
|
|
152
|
+
path.pop();
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const name of spells.keys()) {
|
|
157
|
+
const err = visit(name, []);
|
|
158
|
+
if (err) return err;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Compute statuses: blocked / ready / done.
|
|
165
|
+
*/
|
|
166
|
+
function computeStatuses(spells: Map<string, DiscoveredSpell>): void {
|
|
167
|
+
for (const [, spell] of spells) {
|
|
168
|
+
const allTasksDone = spell.definition.tasks.every((t) => t.done);
|
|
169
|
+
if (allTasksDone) {
|
|
170
|
+
spell.status = "done";
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const deps = spell.definition.depends ?? [];
|
|
175
|
+
const hasIncompleteDep = deps.some((depName) => {
|
|
176
|
+
const dep = spells.get(depName);
|
|
177
|
+
if (!dep) return true; // dangling dep counts as incomplete
|
|
178
|
+
return !dep.definition.tasks.every((t) => t.done);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
spell.status = hasIncompleteDep ? "blocked" : "ready";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap prompt generation for spells.
|
|
3
|
+
*
|
|
4
|
+
* Resolves context items (static strings, files, commands), assembles the
|
|
5
|
+
* full prompt with overview, context, task list, and afterAll instructions.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { getRuntime } from "../runtime-adapter";
|
|
10
|
+
import type { SpellDefinition, ContextItem, Task } from "./types";
|
|
11
|
+
import type { LexiconPlugin } from "../lexicon";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a single context item to a string.
|
|
15
|
+
*/
|
|
16
|
+
async function resolveContextItem(
|
|
17
|
+
item: string | ContextItem,
|
|
18
|
+
gitRoot: string,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
if (typeof item === "string") return item;
|
|
21
|
+
|
|
22
|
+
if (item.type === "file") {
|
|
23
|
+
const filePath = join(gitRoot, item.value);
|
|
24
|
+
try {
|
|
25
|
+
const content = await readFile(filePath, "utf-8");
|
|
26
|
+
return `--- ${item.value} ---\n${content}`;
|
|
27
|
+
} catch {
|
|
28
|
+
return `[Context error: ${item.value} not found]`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (item.type === "cmd") {
|
|
33
|
+
const rt = getRuntime();
|
|
34
|
+
try {
|
|
35
|
+
const result = await rt.spawn(["sh", "-c", item.value], { cwd: gitRoot });
|
|
36
|
+
if (result.exitCode !== 0) {
|
|
37
|
+
return `[Context error: command "${item.value}" failed with exit code ${result.exitCode}]\n${result.stderr}`;
|
|
38
|
+
}
|
|
39
|
+
return `--- $ ${item.value} ---\n${result.stdout}`;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return `[Context error: command "${item.value}" failed: ${err instanceof Error ? err.message : String(err)}]`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return String(item);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format the task list for the prompt.
|
|
50
|
+
*/
|
|
51
|
+
function formatTasks(tasks: Task[]): string {
|
|
52
|
+
return tasks
|
|
53
|
+
.map((t, i) => {
|
|
54
|
+
const check = t.done ? "[x]" : "[ ]";
|
|
55
|
+
return `${i + 1}. ${check} ${t.description}`;
|
|
56
|
+
})
|
|
57
|
+
.join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find relevant skill content from a lexicon plugin.
|
|
62
|
+
*/
|
|
63
|
+
function getLexiconSkillContent(
|
|
64
|
+
lexiconName: string,
|
|
65
|
+
plugins: LexiconPlugin[],
|
|
66
|
+
): string | null {
|
|
67
|
+
const plugin = plugins.find((p) => p.name === lexiconName);
|
|
68
|
+
if (!plugin?.skills) return null;
|
|
69
|
+
const skills = plugin.skills();
|
|
70
|
+
if (skills.length === 0) return null;
|
|
71
|
+
|
|
72
|
+
return skills
|
|
73
|
+
.map((s) => `### ${s.name}\n\n${s.content}`)
|
|
74
|
+
.join("\n\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PromptOptions {
|
|
78
|
+
gitRoot: string;
|
|
79
|
+
plugins?: LexiconPlugin[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate the bootstrap prompt for a spell.
|
|
84
|
+
*/
|
|
85
|
+
export async function generatePrompt(
|
|
86
|
+
spell: SpellDefinition,
|
|
87
|
+
opts: PromptOptions,
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const sections: string[] = [];
|
|
90
|
+
|
|
91
|
+
// Header
|
|
92
|
+
sections.push(`# Spell: ${spell.name}\n`);
|
|
93
|
+
|
|
94
|
+
// Overview
|
|
95
|
+
sections.push(`## Overview\n\n${spell.overview}\n`);
|
|
96
|
+
|
|
97
|
+
// Resolved context
|
|
98
|
+
if (spell.context && spell.context.length > 0) {
|
|
99
|
+
const resolved = await Promise.all(
|
|
100
|
+
spell.context.map((item) => resolveContextItem(item, opts.gitRoot)),
|
|
101
|
+
);
|
|
102
|
+
sections.push(`## Context\n\n${resolved.join("\n\n")}\n`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Lexicon skill guidance
|
|
106
|
+
if (spell.lexicon && opts.plugins) {
|
|
107
|
+
const skillContent = getLexiconSkillContent(spell.lexicon, opts.plugins);
|
|
108
|
+
if (skillContent) {
|
|
109
|
+
sections.push(`## ${spell.lexicon} Guidance\n\n${skillContent}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Task list
|
|
114
|
+
sections.push(`## Tasks\n\n${formatTasks(spell.tasks)}\n`);
|
|
115
|
+
|
|
116
|
+
// After all
|
|
117
|
+
if (spell.afterAll && spell.afterAll.length > 0) {
|
|
118
|
+
sections.push(
|
|
119
|
+
`## After Completion\n\nAfter all tasks are done, run:\n${spell.afterAll.map((c) => `- \`${c}\``).join("\n")}\n`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Instructions
|
|
124
|
+
sections.push(
|
|
125
|
+
`## Instructions\n\n` +
|
|
126
|
+
`- Mark tasks done with: \`chant spell done ${spell.name} <task-number>\`\n` +
|
|
127
|
+
`- Task numbers are 1-based\n` +
|
|
128
|
+
`- Commit with trailer: \`Spell: ${spell.name}\`\n` +
|
|
129
|
+
`- Work through tasks in order\n`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return sections.join("\n");
|
|
133
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spell types and factory functions.
|
|
3
|
+
*
|
|
4
|
+
* A spell is a structured task definition for agent orchestration.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── Types ────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface ContextItem {
|
|
10
|
+
type: "file" | "cmd";
|
|
11
|
+
value: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Task {
|
|
15
|
+
description: string;
|
|
16
|
+
done: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Status = "blocked" | "ready" | "done";
|
|
20
|
+
|
|
21
|
+
export interface SpellDefinition {
|
|
22
|
+
name: string;
|
|
23
|
+
lexicon?: string;
|
|
24
|
+
overview: string;
|
|
25
|
+
context?: (string | ContextItem)[];
|
|
26
|
+
tasks: Task[];
|
|
27
|
+
depends?: string[];
|
|
28
|
+
afterAll?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Validation ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
34
|
+
const MAX_NAME_LENGTH = 64;
|
|
35
|
+
const RESERVED_NAMES = new Set([
|
|
36
|
+
"add", "list", "show", "cast", "done", "rm", "graph",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function validateName(name: string): void {
|
|
40
|
+
if (!name) {
|
|
41
|
+
throw new Error("Spell name must not be empty");
|
|
42
|
+
}
|
|
43
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
44
|
+
throw new Error(`Spell name must be at most ${MAX_NAME_LENGTH} characters: "${name}"`);
|
|
45
|
+
}
|
|
46
|
+
if (!NAME_PATTERN.test(name)) {
|
|
47
|
+
throw new Error(`Spell name must be kebab-case (lowercase letters, numbers, hyphens): "${name}"`);
|
|
48
|
+
}
|
|
49
|
+
if (RESERVED_NAMES.has(name)) {
|
|
50
|
+
throw new Error(`Spell name "${name}" is reserved (conflicts with CLI subcommand)`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Factory functions ────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Define a spell. Validates the name and freezes the object.
|
|
58
|
+
*/
|
|
59
|
+
export function spell(def: SpellDefinition): SpellDefinition {
|
|
60
|
+
validateName(def.name);
|
|
61
|
+
if (!def.overview) {
|
|
62
|
+
throw new Error(`Spell "${def.name}" must have a non-empty overview`);
|
|
63
|
+
}
|
|
64
|
+
if (!def.tasks || def.tasks.length === 0) {
|
|
65
|
+
throw new Error(`Spell "${def.name}" must have at least one task`);
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({ ...def });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Define a task within a spell.
|
|
72
|
+
*/
|
|
73
|
+
export function task(description: string, opts?: { done?: boolean }): Task {
|
|
74
|
+
return { description, done: opts?.done ?? false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* File context item — contents inlined at cast time.
|
|
79
|
+
*/
|
|
80
|
+
export function file(path: string): ContextItem {
|
|
81
|
+
return { type: "file", value: path };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Command context item — stdout inlined at cast time.
|
|
86
|
+
*/
|
|
87
|
+
export function cmd(command: string): ContextItem {
|
|
88
|
+
return { type: "cmd", value: command };
|
|
89
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build digest: fingerprints of resource declarations + dependency graph.
|
|
3
|
+
*
|
|
4
|
+
* The digest captures *what was declared* at a point in time, enabling
|
|
5
|
+
* diff operations without re-parsing templates.
|
|
6
|
+
*/
|
|
7
|
+
import type { BuildResult } from "../build";
|
|
8
|
+
import type { Declarable } from "../declarable";
|
|
9
|
+
import type { BuildDigest, ResourceDigest, DigestDiff } from "./types";
|
|
10
|
+
import { sortedJsonReplacer } from "../utils";
|
|
11
|
+
import { getRuntime } from "../runtime-adapter";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hash an entity's props deterministically.
|
|
15
|
+
*/
|
|
16
|
+
export function hashProps(props: unknown): string {
|
|
17
|
+
const json = JSON.stringify(props, sortedJsonReplacer);
|
|
18
|
+
return getRuntime().hash(json);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute a full build digest from a BuildResult.
|
|
23
|
+
*/
|
|
24
|
+
export function computeBuildDigest(buildResult: BuildResult): BuildDigest {
|
|
25
|
+
const resources: Record<string, ResourceDigest> = {};
|
|
26
|
+
|
|
27
|
+
for (const [name, entity] of buildResult.entities) {
|
|
28
|
+
const props = "props" in entity && entity.props != null ? entity.props : {};
|
|
29
|
+
resources[name] = {
|
|
30
|
+
type: entity.entityType,
|
|
31
|
+
lexicon: entity.lexicon,
|
|
32
|
+
propsHash: hashProps(props),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Convert dependency Map<string, Set<string>> to Record<string, string[]>
|
|
37
|
+
const dependencies: Record<string, string[]> = {};
|
|
38
|
+
for (const [name, deps] of buildResult.dependencies) {
|
|
39
|
+
dependencies[name] = Array.from(deps);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
resources,
|
|
44
|
+
dependencies,
|
|
45
|
+
outputs: buildResult.manifest.outputs,
|
|
46
|
+
deployOrder: buildResult.manifest.deployOrder,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compare two digests and categorize resources.
|
|
52
|
+
*/
|
|
53
|
+
export function diffDigests(
|
|
54
|
+
current: BuildDigest,
|
|
55
|
+
previous: BuildDigest | undefined,
|
|
56
|
+
): DigestDiff {
|
|
57
|
+
const added: string[] = [];
|
|
58
|
+
const removed: string[] = [];
|
|
59
|
+
const changed: string[] = [];
|
|
60
|
+
const unchanged: string[] = [];
|
|
61
|
+
|
|
62
|
+
if (!previous) {
|
|
63
|
+
// No previous digest — everything is added
|
|
64
|
+
added.push(...Object.keys(current.resources));
|
|
65
|
+
return { added, removed, changed, unchanged };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check current resources against previous
|
|
69
|
+
for (const name of Object.keys(current.resources)) {
|
|
70
|
+
const prev = previous.resources[name];
|
|
71
|
+
if (!prev) {
|
|
72
|
+
added.push(name);
|
|
73
|
+
} else if (current.resources[name].propsHash !== prev.propsHash) {
|
|
74
|
+
changed.push(name);
|
|
75
|
+
} else {
|
|
76
|
+
unchanged.push(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for removed resources
|
|
81
|
+
for (const name of Object.keys(previous.resources)) {
|
|
82
|
+
if (!(name in current.resources)) {
|
|
83
|
+
removed.push(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { added, removed, changed, unchanged };
|
|
88
|
+
}
|