@intentius/chant 0.0.18 → 0.0.22
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.test.ts +0 -9
- package/src/cli/commands/init-lexicon.ts +0 -94
- package/src/cli/commands/init.ts +2 -20
- 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/server.test.ts +13 -9
- package/src/cli/mcp/server.ts +220 -6
- package/src/cli/mcp/tools/build.ts +2 -1
- 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/composite.test.ts +1 -1
- package/src/config.ts +4 -0
- package/src/declarable.test.ts +2 -1
- package/src/declarable.ts +1 -1
- 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.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/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
|
@@ -28,8 +28,8 @@ describe("importModule", () => {
|
|
|
28
28
|
|
|
29
29
|
const module = await importModule(filePath);
|
|
30
30
|
expect(module.default).toBeDefined();
|
|
31
|
-
expect(module.default.name).toBe("test");
|
|
32
|
-
expect(module.default.value).toBe(123);
|
|
31
|
+
expect((module.default as any).name).toBe("test");
|
|
32
|
+
expect((module.default as any).value).toBe(123);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
|
|
@@ -125,7 +125,7 @@ describe("importModule", () => {
|
|
|
125
125
|
|
|
126
126
|
const module = await importModule(filePath);
|
|
127
127
|
expect(module.MyClass).toBeDefined();
|
|
128
|
-
const instance = new module.MyClass(100);
|
|
128
|
+
const instance = new (module.MyClass as any)(100);
|
|
129
129
|
expect(instance.getValue()).toBe(100);
|
|
130
130
|
});
|
|
131
131
|
});
|
|
@@ -142,8 +142,8 @@ describe("importModule", () => {
|
|
|
142
142
|
const module = await importModule(filePath);
|
|
143
143
|
expect(module.add).toBeInstanceOf(Function);
|
|
144
144
|
expect(module.multiply).toBeInstanceOf(Function);
|
|
145
|
-
expect(module.add(2, 3)).toBe(5);
|
|
146
|
-
expect(module.multiply(4, 5)).toBe(20);
|
|
145
|
+
expect((module.add as Function)(2, 3)).toBe(5);
|
|
146
|
+
expect((module.multiply as Function)(4, 5)).toBe(20);
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
@@ -8,11 +8,13 @@ import { LOGICAL_NAME_SYMBOL, getLogicalName } from "../utils";
|
|
|
8
8
|
describe("resolveAttrRefs", () => {
|
|
9
9
|
test("sets logical names on all entities", () => {
|
|
10
10
|
const entity1: Declarable = {
|
|
11
|
+
lexicon: "test",
|
|
11
12
|
entityType: "Test1",
|
|
12
13
|
[DECLARABLE_MARKER]: true,
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
const entity2: Declarable = {
|
|
17
|
+
lexicon: "test",
|
|
16
18
|
entityType: "Test2",
|
|
17
19
|
[DECLARABLE_MARKER]: true,
|
|
18
20
|
};
|
|
@@ -30,11 +32,13 @@ describe("resolveAttrRefs", () => {
|
|
|
30
32
|
|
|
31
33
|
test("resolves AttrRef with parent in entities collection", () => {
|
|
32
34
|
const parent: Declarable = {
|
|
35
|
+
lexicon: "test",
|
|
33
36
|
entityType: "Parent",
|
|
34
37
|
[DECLARABLE_MARKER]: true,
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
const child: Declarable & { ref: AttrRef } = {
|
|
41
|
+
lexicon: "test",
|
|
38
42
|
entityType: "Child",
|
|
39
43
|
[DECLARABLE_MARKER]: true,
|
|
40
44
|
ref: new AttrRef(parent, "Arn"),
|
|
@@ -55,11 +59,13 @@ describe("resolveAttrRefs", () => {
|
|
|
55
59
|
|
|
56
60
|
test("resolves multiple AttrRefs on same entity", () => {
|
|
57
61
|
const parent: Declarable = {
|
|
62
|
+
lexicon: "test",
|
|
58
63
|
entityType: "Parent",
|
|
59
64
|
[DECLARABLE_MARKER]: true,
|
|
60
65
|
};
|
|
61
66
|
|
|
62
67
|
const child: Declarable & { arn: AttrRef; name: AttrRef } = {
|
|
68
|
+
lexicon: "test",
|
|
63
69
|
entityType: "Child",
|
|
64
70
|
[DECLARABLE_MARKER]: true,
|
|
65
71
|
arn: new AttrRef(parent, "Arn"),
|
|
@@ -83,16 +89,19 @@ describe("resolveAttrRefs", () => {
|
|
|
83
89
|
|
|
84
90
|
test("resolves AttrRefs with different parents", () => {
|
|
85
91
|
const parent1: Declarable = {
|
|
92
|
+
lexicon: "test",
|
|
86
93
|
entityType: "Parent1",
|
|
87
94
|
[DECLARABLE_MARKER]: true,
|
|
88
95
|
};
|
|
89
96
|
|
|
90
97
|
const parent2: Declarable = {
|
|
98
|
+
lexicon: "test",
|
|
91
99
|
entityType: "Parent2",
|
|
92
100
|
[DECLARABLE_MARKER]: true,
|
|
93
101
|
};
|
|
94
102
|
|
|
95
103
|
const child: Declarable & { ref1: AttrRef; ref2: AttrRef } = {
|
|
104
|
+
lexicon: "test",
|
|
96
105
|
entityType: "Child",
|
|
97
106
|
[DECLARABLE_MARKER]: true,
|
|
98
107
|
ref1: new AttrRef(parent1, "Arn"),
|
|
@@ -119,6 +128,7 @@ describe("resolveAttrRefs", () => {
|
|
|
119
128
|
const parent = {}; // Not a Declarable, not in entities
|
|
120
129
|
|
|
121
130
|
const child: Declarable & { ref: AttrRef } = {
|
|
131
|
+
lexicon: "test",
|
|
122
132
|
entityType: "Child",
|
|
123
133
|
[DECLARABLE_MARKER]: true,
|
|
124
134
|
ref: new AttrRef(parent, "Arn"),
|
|
@@ -133,11 +143,13 @@ describe("resolveAttrRefs", () => {
|
|
|
133
143
|
|
|
134
144
|
test("handles entities with no AttrRefs", () => {
|
|
135
145
|
const entity1: Declarable = {
|
|
146
|
+
lexicon: "test",
|
|
136
147
|
entityType: "Test1",
|
|
137
148
|
[DECLARABLE_MARKER]: true,
|
|
138
149
|
};
|
|
139
150
|
|
|
140
151
|
const entity2: Declarable & { prop: string } = {
|
|
152
|
+
lexicon: "test",
|
|
141
153
|
entityType: "Test2",
|
|
142
154
|
[DECLARABLE_MARKER]: true,
|
|
143
155
|
prop: "value",
|
|
@@ -162,17 +174,20 @@ describe("resolveAttrRefs", () => {
|
|
|
162
174
|
|
|
163
175
|
test("resolves chain of entities with AttrRefs", () => {
|
|
164
176
|
const root: Declarable = {
|
|
177
|
+
lexicon: "test",
|
|
165
178
|
entityType: "Root",
|
|
166
179
|
[DECLARABLE_MARKER]: true,
|
|
167
180
|
};
|
|
168
181
|
|
|
169
182
|
const middle: Declarable & { rootRef: AttrRef } = {
|
|
183
|
+
lexicon: "test",
|
|
170
184
|
entityType: "Middle",
|
|
171
185
|
[DECLARABLE_MARKER]: true,
|
|
172
186
|
rootRef: new AttrRef(root, "Id"),
|
|
173
187
|
};
|
|
174
188
|
|
|
175
189
|
const leaf: Declarable & { middleRef: AttrRef } = {
|
|
190
|
+
lexicon: "test",
|
|
176
191
|
entityType: "Leaf",
|
|
177
192
|
[DECLARABLE_MARKER]: true,
|
|
178
193
|
middleRef: new AttrRef(middle, "Name"),
|
|
@@ -196,6 +211,7 @@ describe("resolveAttrRefs", () => {
|
|
|
196
211
|
|
|
197
212
|
test("handles entity referencing itself", () => {
|
|
198
213
|
const entity: Declarable & { selfRef: AttrRef } = {
|
|
214
|
+
lexicon: "test",
|
|
199
215
|
entityType: "SelfReferencing",
|
|
200
216
|
[DECLARABLE_MARKER]: true,
|
|
201
217
|
selfRef: null as unknown as AttrRef, // Will be set below
|
|
@@ -214,6 +230,7 @@ describe("resolveAttrRefs", () => {
|
|
|
214
230
|
|
|
215
231
|
test("preserves logical name symbol on entities", () => {
|
|
216
232
|
const entity: Declarable = {
|
|
233
|
+
lexicon: "test",
|
|
217
234
|
entityType: "Test",
|
|
218
235
|
[DECLARABLE_MARKER]: true,
|
|
219
236
|
};
|
|
@@ -228,6 +245,7 @@ describe("resolveAttrRefs", () => {
|
|
|
228
245
|
|
|
229
246
|
test("uses export name as logical name", () => {
|
|
230
247
|
const entity: Declarable = {
|
|
248
|
+
lexicon: "test",
|
|
231
249
|
entityType: "Test",
|
|
232
250
|
[DECLARABLE_MARKER]: true,
|
|
233
251
|
};
|
|
@@ -243,11 +261,13 @@ describe("resolveAttrRefs", () => {
|
|
|
243
261
|
|
|
244
262
|
test("handles complex attribute names in AttrRef", () => {
|
|
245
263
|
const parent: Declarable = {
|
|
264
|
+
lexicon: "test",
|
|
246
265
|
entityType: "Parent",
|
|
247
266
|
[DECLARABLE_MARKER]: true,
|
|
248
267
|
};
|
|
249
268
|
|
|
250
269
|
const child: Declarable & { ref: AttrRef } = {
|
|
270
|
+
lexicon: "test",
|
|
251
271
|
entityType: "Child",
|
|
252
272
|
[DECLARABLE_MARKER]: true,
|
|
253
273
|
ref: new AttrRef(parent, "Outputs.WebsiteURL"),
|
package/src/discovery/resolve.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Declarable } from "../declarable";
|
|
2
2
|
import { AttrRef } from "../attrref";
|
|
3
|
-
import { LOGICAL_NAME_SYMBOL, getAttributes } from "../utils";
|
|
3
|
+
import { LOGICAL_NAME_SYMBOL, getAttributes, isAttrRefLike } from "../utils";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Resolves all AttrRef instances in a collection of entities
|
|
@@ -22,7 +22,7 @@ export function resolveAttrRefs(entities: Map<string, Declarable>): void {
|
|
|
22
22
|
for (const attrName of attributes) {
|
|
23
23
|
const attrRef = (entity as unknown as Record<string, unknown>)[attrName];
|
|
24
24
|
|
|
25
|
-
if (attrRef
|
|
25
|
+
if (isAttrRefLike(attrRef)) {
|
|
26
26
|
const parent = attrRef.parent.deref();
|
|
27
27
|
|
|
28
28
|
if (!parent) {
|
package/src/index.ts
CHANGED
package/src/lexicon.ts
CHANGED
|
@@ -193,6 +193,30 @@ export interface LexiconPlugin {
|
|
|
193
193
|
|
|
194
194
|
/** Return MCP resource contributions */
|
|
195
195
|
mcpResources?(): McpResourceContribution[];
|
|
196
|
+
|
|
197
|
+
// State
|
|
198
|
+
/** Query deployed resources and return API metadata. Opt-in. */
|
|
199
|
+
describeResources?(options: {
|
|
200
|
+
environment: string;
|
|
201
|
+
buildOutput: string;
|
|
202
|
+
entityNames: string[];
|
|
203
|
+
}): Promise<Record<string, ResourceMetadata>>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Metadata about a deployed resource, returned by describeResources.
|
|
208
|
+
*/
|
|
209
|
+
export interface ResourceMetadata {
|
|
210
|
+
/** Entity type (e.g. AWS::S3::Bucket, K8s::Apps::Deployment) */
|
|
211
|
+
type: string;
|
|
212
|
+
/** Provider-assigned physical ID (ARN, resource ID, pod name) */
|
|
213
|
+
physicalId?: string;
|
|
214
|
+
/** Provider-specific status string */
|
|
215
|
+
status: string;
|
|
216
|
+
/** ISO timestamp of last update */
|
|
217
|
+
lastUpdated?: string;
|
|
218
|
+
/** Cloud-assigned output properties */
|
|
219
|
+
attributes?: Record<string, unknown>;
|
|
196
220
|
}
|
|
197
221
|
|
|
198
222
|
/**
|
|
@@ -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
|
+
}
|