first-tree 0.0.2 → 0.0.3
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/README.md +73 -39
- package/dist/cli.js +27 -13
- package/dist/help-xEI-s9iN.js +25 -0
- package/dist/init-DtOjj0wc.js +253 -0
- package/dist/installer-rcZpGLnM.js +47 -0
- package/dist/onboarding-6Fr5Gkrk.js +2 -0
- package/dist/onboarding-B9zPGvvG.js +10 -0
- package/dist/repo-BTJG8BU1.js +187 -0
- package/dist/upgrade-COGgI7Rj.js +96 -0
- package/dist/{verify-CSRIkuoM.js → verify-CxN6JiV9.js} +53 -24
- package/package.json +33 -10
- package/skills/first-tree/SKILL.md +109 -0
- package/skills/first-tree/agents/openai.yaml +4 -0
- package/skills/first-tree/assets/framework/VERSION +1 -0
- package/skills/first-tree/assets/framework/examples/claude-code/README.md +14 -0
- package/skills/first-tree/assets/framework/examples/claude-code/settings.json +14 -0
- package/skills/first-tree/assets/framework/helpers/generate-codeowners.ts +224 -0
- package/skills/first-tree/assets/framework/helpers/inject-tree-context.sh +15 -0
- package/skills/first-tree/assets/framework/helpers/run-review.ts +179 -0
- package/skills/first-tree/assets/framework/manifest.json +11 -0
- package/skills/first-tree/assets/framework/prompts/pr-review.md +38 -0
- package/skills/first-tree/assets/framework/templates/agent.md.template +48 -0
- package/skills/first-tree/assets/framework/templates/member-node.md.template +18 -0
- package/skills/first-tree/assets/framework/templates/members-domain.md.template +45 -0
- package/skills/first-tree/assets/framework/templates/root-node.md.template +38 -0
- package/skills/first-tree/assets/framework/workflows/codeowners.yml +31 -0
- package/skills/first-tree/assets/framework/workflows/pr-review.yml +146 -0
- package/skills/first-tree/assets/framework/workflows/validate.yml +19 -0
- package/skills/first-tree/engine/commands/help.ts +32 -0
- package/skills/first-tree/engine/commands/init.ts +1 -0
- package/skills/first-tree/engine/commands/upgrade.ts +1 -0
- package/skills/first-tree/engine/commands/verify.ts +1 -0
- package/skills/first-tree/engine/init.ts +145 -0
- package/skills/first-tree/engine/onboarding.ts +10 -0
- package/skills/first-tree/engine/repo.ts +184 -0
- package/skills/first-tree/engine/rules/agent-instructions.ts +37 -0
- package/skills/first-tree/engine/rules/agent-integration.ts +19 -0
- package/skills/first-tree/engine/rules/ci-validation.ts +72 -0
- package/skills/first-tree/engine/rules/framework.ts +13 -0
- package/skills/first-tree/engine/rules/index.ts +41 -0
- package/skills/first-tree/engine/rules/members.ts +21 -0
- package/skills/first-tree/engine/rules/populate-tree.ts +36 -0
- package/skills/first-tree/engine/rules/root-node.ts +41 -0
- package/skills/first-tree/engine/runtime/adapters.ts +22 -0
- package/skills/first-tree/engine/runtime/asset-loader.ts +134 -0
- package/skills/first-tree/engine/runtime/installer.ts +82 -0
- package/skills/first-tree/engine/runtime/upgrader.ts +23 -0
- package/skills/first-tree/engine/upgrade.ts +176 -0
- package/skills/first-tree/engine/validators/members.ts +215 -0
- package/skills/first-tree/engine/validators/nodes.ts +514 -0
- package/skills/first-tree/engine/verify.ts +97 -0
- package/skills/first-tree/references/about.md +36 -0
- package/skills/first-tree/references/maintainer-architecture.md +59 -0
- package/skills/first-tree/references/maintainer-build-and-distribution.md +56 -0
- package/skills/first-tree/references/maintainer-testing.md +58 -0
- package/skills/first-tree/references/maintainer-thin-cli.md +38 -0
- package/skills/first-tree/references/onboarding.md +162 -0
- package/skills/first-tree/references/ownership-and-naming.md +94 -0
- package/skills/first-tree/references/principles.md +113 -0
- package/skills/first-tree/references/source-map.md +94 -0
- package/skills/first-tree/references/upgrade-contract.md +85 -0
- package/skills/first-tree/scripts/check-skill-sync.sh +133 -0
- package/skills/first-tree/scripts/quick_validate.py +95 -0
- package/skills/first-tree/scripts/run-local-cli.sh +35 -0
- package/skills/first-tree/tests/asset-loader.test.ts +75 -0
- package/skills/first-tree/tests/generate-codeowners.test.ts +94 -0
- package/skills/first-tree/tests/helpers.ts +149 -0
- package/skills/first-tree/tests/init.test.ts +153 -0
- package/skills/first-tree/tests/repo.test.ts +362 -0
- package/skills/first-tree/tests/rules.test.ts +394 -0
- package/skills/first-tree/tests/run-review.test.ts +155 -0
- package/skills/first-tree/tests/skill-artifacts.test.ts +307 -0
- package/skills/first-tree/tests/thin-cli.test.ts +59 -0
- package/skills/first-tree/tests/upgrade.test.ts +89 -0
- package/skills/first-tree/tests/validate-members.test.ts +224 -0
- package/skills/first-tree/tests/validate-nodes.test.ts +198 -0
- package/skills/first-tree/tests/verify.test.ts +142 -0
- package/dist/init-CE_944sb.js +0 -283
- package/dist/repo-BByc3VvM.js +0 -111
- package/dist/upgrade-Chr7z0CY.js +0 -82
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { Repo } from "#skill/engine/repo.js";
|
|
5
|
+
import {
|
|
6
|
+
evaluateAll,
|
|
7
|
+
framework,
|
|
8
|
+
rootNode,
|
|
9
|
+
agentInstructions,
|
|
10
|
+
members,
|
|
11
|
+
agentIntegration,
|
|
12
|
+
ciValidation,
|
|
13
|
+
populateTree,
|
|
14
|
+
} from "#skill/engine/rules/index.js";
|
|
15
|
+
import {
|
|
16
|
+
useTmpDir,
|
|
17
|
+
makeFramework,
|
|
18
|
+
makeNode,
|
|
19
|
+
makeAgentMd,
|
|
20
|
+
makeMembers,
|
|
21
|
+
} from "./helpers.js";
|
|
22
|
+
|
|
23
|
+
// --- framework rule ---
|
|
24
|
+
|
|
25
|
+
describe("framework rule", () => {
|
|
26
|
+
it("reports missing framework", () => {
|
|
27
|
+
const tmp = useTmpDir();
|
|
28
|
+
const repo = new Repo(tmp.path);
|
|
29
|
+
const result = framework.evaluate(repo);
|
|
30
|
+
expect(result.group).toBe("Framework");
|
|
31
|
+
expect(result.tasks).toHaveLength(1);
|
|
32
|
+
expect(result.tasks[0]).toContain("skills/first-tree/");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("passes when framework exists", () => {
|
|
36
|
+
const tmp = useTmpDir();
|
|
37
|
+
makeFramework(tmp.path);
|
|
38
|
+
const repo = new Repo(tmp.path);
|
|
39
|
+
const result = framework.evaluate(repo);
|
|
40
|
+
expect(result.tasks).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// --- root_node rule ---
|
|
45
|
+
|
|
46
|
+
describe("rootNode rule", () => {
|
|
47
|
+
it("reports missing NODE.md", () => {
|
|
48
|
+
const tmp = useTmpDir();
|
|
49
|
+
const repo = new Repo(tmp.path);
|
|
50
|
+
const result = rootNode.evaluate(repo);
|
|
51
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("missing"))).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reports no frontmatter", () => {
|
|
55
|
+
const tmp = useTmpDir();
|
|
56
|
+
writeFileSync(join(tmp.path, "NODE.md"), "# No frontmatter\n");
|
|
57
|
+
const repo = new Repo(tmp.path);
|
|
58
|
+
const result = rootNode.evaluate(repo);
|
|
59
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("no frontmatter"))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reports placeholder title", () => {
|
|
63
|
+
const tmp = useTmpDir();
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(tmp.path, "NODE.md"),
|
|
66
|
+
"---\ntitle: '<YOUR ORG>'\nowners: [alice]\n---\n",
|
|
67
|
+
);
|
|
68
|
+
const repo = new Repo(tmp.path);
|
|
69
|
+
const result = rootNode.evaluate(repo);
|
|
70
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("placeholder title"))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reports placeholder owners", () => {
|
|
74
|
+
const tmp = useTmpDir();
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(tmp.path, "NODE.md"),
|
|
77
|
+
"---\ntitle: Real Title\nowners: [<your-github>]\n---\n",
|
|
78
|
+
);
|
|
79
|
+
const repo = new Repo(tmp.path);
|
|
80
|
+
const result = rootNode.evaluate(repo);
|
|
81
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("placeholder owners"))).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reports placeholder content", () => {
|
|
85
|
+
const tmp = useTmpDir();
|
|
86
|
+
writeFileSync(
|
|
87
|
+
join(tmp.path, "NODE.md"),
|
|
88
|
+
"---\ntitle: Real\nowners: [alice]\n---\n<!-- PLACEHOLDER -->\n",
|
|
89
|
+
);
|
|
90
|
+
const repo = new Repo(tmp.path);
|
|
91
|
+
const result = rootNode.evaluate(repo);
|
|
92
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("placeholder content"))).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("passes with valid node", () => {
|
|
96
|
+
const tmp = useTmpDir();
|
|
97
|
+
makeNode(tmp.path);
|
|
98
|
+
const repo = new Repo(tmp.path);
|
|
99
|
+
const result = rootNode.evaluate(repo);
|
|
100
|
+
expect(result.tasks).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// --- agent_instructions rule ---
|
|
105
|
+
|
|
106
|
+
describe("agentInstructions rule", () => {
|
|
107
|
+
it("reports missing AGENT.md", () => {
|
|
108
|
+
const tmp = useTmpDir();
|
|
109
|
+
const repo = new Repo(tmp.path);
|
|
110
|
+
const result = agentInstructions.evaluate(repo);
|
|
111
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("missing"))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("reports no markers", () => {
|
|
115
|
+
const tmp = useTmpDir();
|
|
116
|
+
makeAgentMd(tmp.path, { markers: false });
|
|
117
|
+
const repo = new Repo(tmp.path);
|
|
118
|
+
const result = agentInstructions.evaluate(repo);
|
|
119
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("markers"))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reports no user content", () => {
|
|
123
|
+
const tmp = useTmpDir();
|
|
124
|
+
makeAgentMd(tmp.path, { markers: true, userContent: false });
|
|
125
|
+
const repo = new Repo(tmp.path);
|
|
126
|
+
const result = agentInstructions.evaluate(repo);
|
|
127
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("project-specific"))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("passes with markers and user content", () => {
|
|
131
|
+
const tmp = useTmpDir();
|
|
132
|
+
makeAgentMd(tmp.path, { markers: true, userContent: true });
|
|
133
|
+
const repo = new Repo(tmp.path);
|
|
134
|
+
const result = agentInstructions.evaluate(repo);
|
|
135
|
+
expect(result.tasks).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --- members rule ---
|
|
140
|
+
|
|
141
|
+
describe("members rule", () => {
|
|
142
|
+
it("reports no members dir", () => {
|
|
143
|
+
const tmp = useTmpDir();
|
|
144
|
+
const repo = new Repo(tmp.path);
|
|
145
|
+
const result = members.evaluate(repo);
|
|
146
|
+
expect(result.tasks.length).toBeGreaterThanOrEqual(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("reports members dir without NODE.md", () => {
|
|
150
|
+
const tmp = useTmpDir();
|
|
151
|
+
mkdirSync(join(tmp.path, "members"));
|
|
152
|
+
const repo = new Repo(tmp.path);
|
|
153
|
+
const result = members.evaluate(repo);
|
|
154
|
+
expect(result.tasks.some((t) => t.includes("NODE.md"))).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("reports no children", () => {
|
|
158
|
+
const tmp = useTmpDir();
|
|
159
|
+
const membersDir = join(tmp.path, "members");
|
|
160
|
+
mkdirSync(membersDir);
|
|
161
|
+
writeFileSync(join(membersDir, "NODE.md"), "---\ntitle: Members\n---\n");
|
|
162
|
+
const repo = new Repo(tmp.path);
|
|
163
|
+
const result = members.evaluate(repo);
|
|
164
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("at least one member"))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("passes with children", () => {
|
|
168
|
+
const tmp = useTmpDir();
|
|
169
|
+
makeMembers(tmp.path, 1);
|
|
170
|
+
const repo = new Repo(tmp.path);
|
|
171
|
+
const result = members.evaluate(repo);
|
|
172
|
+
expect(result.tasks).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// --- agent_integration rule ---
|
|
177
|
+
|
|
178
|
+
describe("agentIntegration rule", () => {
|
|
179
|
+
it("reports no agent config", () => {
|
|
180
|
+
const tmp = useTmpDir();
|
|
181
|
+
const repo = new Repo(tmp.path);
|
|
182
|
+
const result = agentIntegration.evaluate(repo);
|
|
183
|
+
expect(result.tasks.some((t) => t.toLowerCase().includes("no agent configuration"))).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("reports claude settings without hook", () => {
|
|
187
|
+
const tmp = useTmpDir();
|
|
188
|
+
mkdirSync(join(tmp.path, ".claude"));
|
|
189
|
+
writeFileSync(join(tmp.path, ".claude", "settings.json"), "{}");
|
|
190
|
+
const repo = new Repo(tmp.path);
|
|
191
|
+
const result = agentIntegration.evaluate(repo);
|
|
192
|
+
expect(result.tasks.some((t) => t.includes("SessionStart"))).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("passes with claude settings and hook", () => {
|
|
196
|
+
const tmp = useTmpDir();
|
|
197
|
+
mkdirSync(join(tmp.path, ".claude"));
|
|
198
|
+
writeFileSync(
|
|
199
|
+
join(tmp.path, ".claude", "settings.json"),
|
|
200
|
+
'{"hooks": {"inject-tree-context": true}}',
|
|
201
|
+
);
|
|
202
|
+
const repo = new Repo(tmp.path);
|
|
203
|
+
const result = agentIntegration.evaluate(repo);
|
|
204
|
+
expect(result.tasks).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// --- ci_validation rule ---
|
|
209
|
+
|
|
210
|
+
describe("ciValidation rule", () => {
|
|
211
|
+
it("reports no workflows", () => {
|
|
212
|
+
const tmp = useTmpDir();
|
|
213
|
+
const repo = new Repo(tmp.path);
|
|
214
|
+
const result = ciValidation.evaluate(repo);
|
|
215
|
+
expect(result.tasks).toHaveLength(4);
|
|
216
|
+
expect(result.tasks[0]).toContain("validation workflow");
|
|
217
|
+
expect(result.tasks[0]).toContain("skills/first-tree/assets/framework/workflows/validate.yml");
|
|
218
|
+
expect(result.tasks[1]).toContain("PR reviews");
|
|
219
|
+
expect(result.tasks[2]).toContain("API secret");
|
|
220
|
+
expect(result.tasks[3]).toContain("CODEOWNERS");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("reports workflow without validate or pr-review", () => {
|
|
224
|
+
const tmp = useTmpDir();
|
|
225
|
+
const wfDir = join(tmp.path, ".github", "workflows");
|
|
226
|
+
mkdirSync(wfDir, { recursive: true });
|
|
227
|
+
writeFileSync(join(wfDir, "ci.yml"), "name: CI\non: push\njobs: {}\n");
|
|
228
|
+
const repo = new Repo(tmp.path);
|
|
229
|
+
const result = ciValidation.evaluate(repo);
|
|
230
|
+
expect(result.tasks).toHaveLength(4);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("passes validation but reports missing pr-review and codeowners", () => {
|
|
234
|
+
const tmp = useTmpDir();
|
|
235
|
+
const wfDir = join(tmp.path, ".github", "workflows");
|
|
236
|
+
mkdirSync(wfDir, { recursive: true });
|
|
237
|
+
writeFileSync(
|
|
238
|
+
join(wfDir, "validate.yml"),
|
|
239
|
+
"name: Validate\non: push\njobs:\n validate:\n steps:\n - run: python validate_nodes.py\n",
|
|
240
|
+
);
|
|
241
|
+
const repo = new Repo(tmp.path);
|
|
242
|
+
const result = ciValidation.evaluate(repo);
|
|
243
|
+
expect(result.tasks).toHaveLength(3);
|
|
244
|
+
expect(result.tasks[0]).toContain("PR reviews");
|
|
245
|
+
expect(result.tasks[1]).toContain("API secret");
|
|
246
|
+
expect(result.tasks[2]).toContain("CODEOWNERS");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("passes pr-review but reports missing validation and codeowners", () => {
|
|
250
|
+
const tmp = useTmpDir();
|
|
251
|
+
const wfDir = join(tmp.path, ".github", "workflows");
|
|
252
|
+
mkdirSync(wfDir, { recursive: true });
|
|
253
|
+
writeFileSync(
|
|
254
|
+
join(wfDir, "pr-review.yml"),
|
|
255
|
+
"name: PR Review\non: pull_request\njobs:\n review:\n steps:\n - run: npx tsx skills/first-tree/assets/framework/helpers/run-review.ts\n",
|
|
256
|
+
);
|
|
257
|
+
const repo = new Repo(tmp.path);
|
|
258
|
+
const result = ciValidation.evaluate(repo);
|
|
259
|
+
expect(result.tasks).toHaveLength(2);
|
|
260
|
+
expect(result.tasks[0]).toContain("validation workflow");
|
|
261
|
+
expect(result.tasks[1]).toContain("CODEOWNERS");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("passes with validate and pr-review but reports missing codeowners", () => {
|
|
265
|
+
const tmp = useTmpDir();
|
|
266
|
+
const wfDir = join(tmp.path, ".github", "workflows");
|
|
267
|
+
mkdirSync(wfDir, { recursive: true });
|
|
268
|
+
writeFileSync(
|
|
269
|
+
join(wfDir, "validate.yml"),
|
|
270
|
+
"name: Validate\non: push\njobs:\n validate:\n steps:\n - run: python validate_nodes.py\n",
|
|
271
|
+
);
|
|
272
|
+
writeFileSync(
|
|
273
|
+
join(wfDir, "pr-review.yml"),
|
|
274
|
+
"name: PR Review\non: pull_request\njobs:\n review:\n steps:\n - run: npx tsx skills/first-tree/assets/framework/helpers/run-review.ts\n",
|
|
275
|
+
);
|
|
276
|
+
const repo = new Repo(tmp.path);
|
|
277
|
+
const result = ciValidation.evaluate(repo);
|
|
278
|
+
expect(result.tasks).toHaveLength(1);
|
|
279
|
+
expect(result.tasks[0]).toContain("CODEOWNERS");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("passes with all three workflows", () => {
|
|
283
|
+
const tmp = useTmpDir();
|
|
284
|
+
const wfDir = join(tmp.path, ".github", "workflows");
|
|
285
|
+
mkdirSync(wfDir, { recursive: true });
|
|
286
|
+
writeFileSync(
|
|
287
|
+
join(wfDir, "validate.yml"),
|
|
288
|
+
"name: Validate\non: push\njobs:\n validate:\n steps:\n - run: python validate_nodes.py\n",
|
|
289
|
+
);
|
|
290
|
+
writeFileSync(
|
|
291
|
+
join(wfDir, "pr-review.yml"),
|
|
292
|
+
"name: PR Review\non: pull_request\njobs:\n review:\n steps:\n - run: npx tsx skills/first-tree/assets/framework/helpers/run-review.ts\n",
|
|
293
|
+
);
|
|
294
|
+
writeFileSync(
|
|
295
|
+
join(wfDir, "codeowners.yml"),
|
|
296
|
+
"name: Update CODEOWNERS\non: pull_request\njobs:\n update:\n steps:\n - run: npx tsx skills/first-tree/assets/framework/helpers/generate-codeowners.ts\n",
|
|
297
|
+
);
|
|
298
|
+
const repo = new Repo(tmp.path);
|
|
299
|
+
const result = ciValidation.evaluate(repo);
|
|
300
|
+
expect(result.tasks).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("pr-review task presents numbered options", () => {
|
|
304
|
+
const tmp = useTmpDir();
|
|
305
|
+
const repo = new Repo(tmp.path);
|
|
306
|
+
const result = ciValidation.evaluate(repo);
|
|
307
|
+
const prTask = result.tasks.find((t) => t.includes("PR review"));
|
|
308
|
+
expect(prTask).toContain("OpenRouter");
|
|
309
|
+
expect(prTask).toContain("Claude API");
|
|
310
|
+
expect(prTask).toContain("Skip");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("secret task presents options for gh CLI or manual setup", () => {
|
|
314
|
+
const tmp = useTmpDir();
|
|
315
|
+
const repo = new Repo(tmp.path);
|
|
316
|
+
const result = ciValidation.evaluate(repo);
|
|
317
|
+
const secretTask = result.tasks.find((t) => t.includes("API secret"));
|
|
318
|
+
expect(secretTask).toContain("Set it now");
|
|
319
|
+
expect(secretTask).toContain("I'll do it myself");
|
|
320
|
+
expect(secretTask).toContain("gh secret set");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// --- populateTree rule ---
|
|
325
|
+
|
|
326
|
+
describe("populateTree rule", () => {
|
|
327
|
+
it("always produces tasks", () => {
|
|
328
|
+
const tmp = useTmpDir();
|
|
329
|
+
const repo = new Repo(tmp.path);
|
|
330
|
+
const result = populateTree.evaluate(repo);
|
|
331
|
+
expect(result.group).toBe("Populate Tree");
|
|
332
|
+
expect(result.order).toBe(7);
|
|
333
|
+
expect(result.tasks.length).toBeGreaterThanOrEqual(4);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("first task asks user whether to populate", () => {
|
|
337
|
+
const tmp = useTmpDir();
|
|
338
|
+
const repo = new Repo(tmp.path);
|
|
339
|
+
const result = populateTree.evaluate(repo);
|
|
340
|
+
expect(result.tasks[0]).toContain("Yes");
|
|
341
|
+
expect(result.tasks[0]).toContain("No");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("includes sub-task parallelization instruction", () => {
|
|
345
|
+
const tmp = useTmpDir();
|
|
346
|
+
const repo = new Repo(tmp.path);
|
|
347
|
+
const result = populateTree.evaluate(repo);
|
|
348
|
+
expect(result.tasks.some((t) => t.includes("sub-task") || t.includes("TaskCreate"))).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// --- evaluateAll ---
|
|
353
|
+
|
|
354
|
+
describe("evaluateAll", () => {
|
|
355
|
+
it("returns sorted groups", () => {
|
|
356
|
+
const tmp = useTmpDir();
|
|
357
|
+
const repo = new Repo(tmp.path);
|
|
358
|
+
const groups = evaluateAll(repo);
|
|
359
|
+
expect(groups.length).toBeGreaterThanOrEqual(2);
|
|
360
|
+
const orders = groups.map((g) => g.order);
|
|
361
|
+
expect(orders).toEqual([...orders].sort((a, b) => a - b));
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("excludes empty groups", () => {
|
|
365
|
+
const tmp = useTmpDir();
|
|
366
|
+
makeFramework(tmp.path);
|
|
367
|
+
makeNode(tmp.path);
|
|
368
|
+
makeAgentMd(tmp.path, { markers: true, userContent: true });
|
|
369
|
+
makeMembers(tmp.path, 1);
|
|
370
|
+
mkdirSync(join(tmp.path, ".claude"));
|
|
371
|
+
writeFileSync(
|
|
372
|
+
join(tmp.path, ".claude", "settings.json"),
|
|
373
|
+
'{"hooks": {"inject-tree-context": true}}',
|
|
374
|
+
);
|
|
375
|
+
const wfDir = join(tmp.path, ".github", "workflows");
|
|
376
|
+
mkdirSync(wfDir, { recursive: true });
|
|
377
|
+
writeFileSync(
|
|
378
|
+
join(wfDir, "validate.yml"),
|
|
379
|
+
"steps:\n - run: validate_nodes\n - run: run-review\n - run: generate-codeowners\n",
|
|
380
|
+
);
|
|
381
|
+
const repo = new Repo(tmp.path);
|
|
382
|
+
const groups = evaluateAll(repo);
|
|
383
|
+
for (const g of groups) {
|
|
384
|
+
expect(g.tasks.length).toBeGreaterThan(0);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("includes populate tree group", () => {
|
|
389
|
+
const tmp = useTmpDir();
|
|
390
|
+
const repo = new Repo(tmp.path);
|
|
391
|
+
const groups = evaluateAll(repo);
|
|
392
|
+
expect(groups.some((g) => g.group === "Populate Tree")).toBe(true);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
extractStreamText,
|
|
4
|
+
extractReviewJson,
|
|
5
|
+
} from "../assets/framework/helpers/run-review.js";
|
|
6
|
+
|
|
7
|
+
// --- extractStreamText ---
|
|
8
|
+
|
|
9
|
+
describe("extractStreamText", () => {
|
|
10
|
+
it("extracts text from assistant message blocks", () => {
|
|
11
|
+
const jsonl = [
|
|
12
|
+
JSON.stringify({
|
|
13
|
+
type: "assistant",
|
|
14
|
+
message: {
|
|
15
|
+
content: [{ type: "text", text: "Hello " }, { type: "text", text: "world" }],
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
].join("\n");
|
|
19
|
+
expect(extractStreamText(jsonl)).toBe("Hello world");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("falls back to result field when no assistant text", () => {
|
|
23
|
+
const jsonl = JSON.stringify({
|
|
24
|
+
type: "result",
|
|
25
|
+
result: "fallback text",
|
|
26
|
+
});
|
|
27
|
+
expect(extractStreamText(jsonl)).toBe("fallback text");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("prefers assistant text over result field", () => {
|
|
31
|
+
const jsonl = [
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
type: "assistant",
|
|
34
|
+
message: { content: [{ type: "text", text: "from assistant" }] },
|
|
35
|
+
}),
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
type: "result",
|
|
38
|
+
result: "from result",
|
|
39
|
+
}),
|
|
40
|
+
].join("\n");
|
|
41
|
+
expect(extractStreamText(jsonl)).toBe("from assistant");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns empty string for empty input", () => {
|
|
45
|
+
expect(extractStreamText("")).toBe("");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("skips non-text content blocks", () => {
|
|
49
|
+
const jsonl = JSON.stringify({
|
|
50
|
+
type: "assistant",
|
|
51
|
+
message: {
|
|
52
|
+
content: [
|
|
53
|
+
{ type: "tool_use", name: "read" },
|
|
54
|
+
{ type: "text", text: "actual text" },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
expect(extractStreamText(jsonl)).toBe("actual text");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("handles malformed JSON lines gracefully", () => {
|
|
62
|
+
const jsonl = [
|
|
63
|
+
"not json",
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
type: "assistant",
|
|
66
|
+
message: { content: [{ type: "text", text: "ok" }] },
|
|
67
|
+
}),
|
|
68
|
+
"{broken",
|
|
69
|
+
].join("\n");
|
|
70
|
+
expect(extractStreamText(jsonl)).toBe("ok");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("concatenates text across multiple assistant messages", () => {
|
|
74
|
+
const jsonl = [
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
type: "assistant",
|
|
77
|
+
message: { content: [{ type: "text", text: "part1" }] },
|
|
78
|
+
}),
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
type: "assistant",
|
|
81
|
+
message: { content: [{ type: "text", text: "part2" }] },
|
|
82
|
+
}),
|
|
83
|
+
].join("\n");
|
|
84
|
+
expect(extractStreamText(jsonl)).toBe("part1part2");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// --- extractReviewJson ---
|
|
89
|
+
|
|
90
|
+
describe("extractReviewJson", () => {
|
|
91
|
+
it("extracts valid review JSON", () => {
|
|
92
|
+
const text = '{"verdict": "APPROVE", "summary": "Looks good"}';
|
|
93
|
+
const result = extractReviewJson(text);
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result!.verdict).toBe("APPROVE");
|
|
96
|
+
expect(result!.summary).toBe("Looks good");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("extracts JSON from markdown fences", () => {
|
|
100
|
+
const text = '```json\n{"verdict": "COMMENT", "summary": "Minor issues"}\n```';
|
|
101
|
+
const result = extractReviewJson(text);
|
|
102
|
+
expect(result).not.toBeNull();
|
|
103
|
+
expect(result!.verdict).toBe("COMMENT");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("extracts JSON embedded in surrounding text", () => {
|
|
107
|
+
const text =
|
|
108
|
+
'Here is my review:\n{"verdict": "REQUEST_CHANGES", "summary": "Needs work"}\nEnd of review.';
|
|
109
|
+
const result = extractReviewJson(text);
|
|
110
|
+
expect(result).not.toBeNull();
|
|
111
|
+
expect(result!.verdict).toBe("REQUEST_CHANGES");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("extracts JSON with inline_comments", () => {
|
|
115
|
+
const text = JSON.stringify({
|
|
116
|
+
verdict: "COMMENT",
|
|
117
|
+
summary: "Some notes",
|
|
118
|
+
inline_comments: [
|
|
119
|
+
{ file: "src/foo.ts", line: 10, comment: "Fix this" },
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
const result = extractReviewJson(text);
|
|
123
|
+
expect(result).not.toBeNull();
|
|
124
|
+
expect(result!.inline_comments).toHaveLength(1);
|
|
125
|
+
expect(result!.inline_comments![0].file).toBe("src/foo.ts");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns null for empty string", () => {
|
|
129
|
+
expect(extractReviewJson("")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns null for whitespace only", () => {
|
|
133
|
+
expect(extractReviewJson(" \n ")).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns null for text without JSON", () => {
|
|
137
|
+
expect(extractReviewJson("No JSON here, just text.")).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns null for invalid JSON", () => {
|
|
141
|
+
expect(extractReviewJson("{verdict: APPROVE}")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns null for JSON without verdict", () => {
|
|
145
|
+
expect(
|
|
146
|
+
extractReviewJson('{"summary": "Missing verdict field"}'),
|
|
147
|
+
).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns null for JSON with empty verdict", () => {
|
|
151
|
+
expect(
|
|
152
|
+
extractReviewJson('{"verdict": "", "summary": "Empty"}'),
|
|
153
|
+
).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|