@slowcook-ai/cli 0.6.14 → 0.7.1
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/commands/init/plan.d.ts.map +1 -1
- package/dist/commands/init/plan.js +10 -17
- package/dist/commands/init/plan.js.map +1 -1
- package/dist/commands/init/templates.d.ts +0 -5
- package/dist/commands/init/templates.d.ts.map +1 -1
- package/dist/commands/init/templates.js +0 -229
- package/dist/commands/init/templates.js.map +1 -1
- package/dist/commands/refine/agent.d.ts +3 -0
- package/dist/commands/refine/agent.d.ts.map +1 -1
- package/dist/commands/refine/agent.js +16 -5
- package/dist/commands/refine/agent.js.map +1 -1
- package/dist/commands/refine/prompts.d.ts +1 -1
- package/dist/commands/refine/prompts.d.ts.map +1 -1
- package/dist/commands/refine/prompts.js +26 -8
- package/dist/commands/refine/prompts.js.map +1 -1
- package/dist/commands/refine/relationship.d.ts +20 -2
- package/dist/commands/refine/relationship.d.ts.map +1 -1
- package/dist/commands/refine/relationship.js +48 -5
- package/dist/commands/refine/relationship.js.map +1 -1
- package/dist/commands/refine/spec-yaml.d.ts +32 -32
- package/dist/commands/testgen/agent.d.ts +33 -0
- package/dist/commands/testgen/agent.d.ts.map +1 -1
- package/dist/commands/testgen/agent.js +185 -14
- package/dist/commands/testgen/agent.js.map +1 -1
- package/dist/commands/testgen/prompts.d.ts +10 -12
- package/dist/commands/testgen/prompts.d.ts.map +1 -1
- package/dist/commands/testgen/prompts.js +150 -87
- package/dist/commands/testgen/prompts.js.map +1 -1
- package/package.json +4 -4
|
@@ -11,11 +11,29 @@ export interface RelationshipOptions {
|
|
|
11
11
|
}
|
|
12
12
|
export declare function analyzeRelationship(input: RelationshipInput, options: RelationshipOptions): Promise<RelationshipVerdict>;
|
|
13
13
|
export declare function parseVerdict(raw: string): RelationshipVerdict;
|
|
14
|
+
/**
|
|
15
|
+
* Format a spec reference for user-facing prose (issue comments, PR bodies).
|
|
16
|
+
* Prefers GitHub's native issue-number reference (\`#N\`) which auto-renders
|
|
17
|
+
* as a hyperlink. Falls back to the internal \`story-N\` id if the spec has
|
|
18
|
+
* no \`source_issue\` recorded.
|
|
19
|
+
*
|
|
20
|
+
* Inside YAML specs and internal slowcook state, keep using \`story-N\` —
|
|
21
|
+
* it's the stable canonical identifier. This function is only for prose.
|
|
22
|
+
*/
|
|
23
|
+
export declare function specRefForProse(spec: Spec): string;
|
|
14
24
|
/** Comment body posted to the issue when relationship analysis surfaces a conflict. */
|
|
15
25
|
export declare function overlapCommentBody(verdict: Extract<RelationshipVerdict, {
|
|
16
26
|
kind: "overlap";
|
|
17
|
-
}
|
|
27
|
+
}>, activeSpecs?: Spec[]): string;
|
|
28
|
+
/**
|
|
29
|
+
* Comment body for the follow_up verdict — posted informationally, does NOT
|
|
30
|
+
* halt refinement. Tells the PM "I noticed this builds on X, will cite it in
|
|
31
|
+
* the resulting spec, proceeding with refinement now."
|
|
32
|
+
*/
|
|
33
|
+
export declare function followUpCommentBody(verdict: Extract<RelationshipVerdict, {
|
|
34
|
+
kind: "follow_up";
|
|
35
|
+
}>, activeSpecs?: Spec[]): string;
|
|
18
36
|
export declare function contradictionCommentBody(verdict: Extract<RelationshipVerdict, {
|
|
19
37
|
kind: "contradiction";
|
|
20
|
-
}>, hasChangeOfMind: boolean): string;
|
|
38
|
+
}>, hasChangeOfMind: boolean, activeSpecs?: Spec[]): string;
|
|
21
39
|
//# sourceMappingURL=relationship.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relationship.d.ts","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,IAAI,EAAE,mBAAmB,EAAgB,MAAM,mBAAmB,CAAC;AASjF,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,IAAI,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,SAAS,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,iBAAiB,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAiDD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,
|
|
1
|
+
{"version":3,"file":"relationship.d.ts","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,IAAI,EAAE,mBAAmB,EAAgB,MAAM,mBAAmB,CAAC;AASjF,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,IAAI,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,SAAS,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,iBAAiB,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,mBAAmB,CAAC,CAa9B;AAiDD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,CA6B7D;AAmBD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAIlD;AAYD,uFAAuF;AACvF,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC,EAC1D,WAAW,GAAE,IAAI,EAAO,GACvB,MAAM,CAeR;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,CAAC,EAC5D,WAAW,GAAE,IAAI,EAAO,GACvB,MAAM,CASR;AAED,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,CAAC,mBAAmB,EAAE;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE,CAAC,EAChE,eAAe,EAAE,OAAO,EACxB,WAAW,GAAE,IAAI,EAAO,GACvB,MAAM,CAwBR"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { RELATIONSHIP_ANALYST_SYSTEM } from "./prompts.js";
|
|
3
3
|
const VerdictSchema = z.object({
|
|
4
|
-
kind: z.enum(["new_or_independent", "overlap", "contradiction"]),
|
|
4
|
+
kind: z.enum(["new_or_independent", "follow_up", "overlap", "contradiction"]),
|
|
5
5
|
conflicting_ids: z.array(z.string()),
|
|
6
6
|
reasoning: z.string(),
|
|
7
7
|
});
|
|
@@ -80,6 +80,9 @@ export function parseVerdict(raw) {
|
|
|
80
80
|
if (v.kind === "new_or_independent") {
|
|
81
81
|
return { kind: "new_or_independent", reasoning: v.reasoning };
|
|
82
82
|
}
|
|
83
|
+
if (v.kind === "follow_up") {
|
|
84
|
+
return { kind: "follow_up", related_ids: v.conflicting_ids, reasoning: v.reasoning };
|
|
85
|
+
}
|
|
83
86
|
if (v.kind === "overlap") {
|
|
84
87
|
return { kind: "overlap", conflicting_ids: v.conflicting_ids, reasoning: v.reasoning };
|
|
85
88
|
}
|
|
@@ -103,9 +106,34 @@ function extractJsonObject(raw) {
|
|
|
103
106
|
}
|
|
104
107
|
return trimmed;
|
|
105
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Format a spec reference for user-facing prose (issue comments, PR bodies).
|
|
111
|
+
* Prefers GitHub's native issue-number reference (\`#N\`) which auto-renders
|
|
112
|
+
* as a hyperlink. Falls back to the internal \`story-N\` id if the spec has
|
|
113
|
+
* no \`source_issue\` recorded.
|
|
114
|
+
*
|
|
115
|
+
* Inside YAML specs and internal slowcook state, keep using \`story-N\` —
|
|
116
|
+
* it's the stable canonical identifier. This function is only for prose.
|
|
117
|
+
*/
|
|
118
|
+
export function specRefForProse(spec) {
|
|
119
|
+
const issueNum = spec.source_issue?.match(/#?(\d+)/)?.[1];
|
|
120
|
+
if (issueNum)
|
|
121
|
+
return `#${issueNum} (story-${spec.story_id})`;
|
|
122
|
+
return `story-${spec.story_id}`;
|
|
123
|
+
}
|
|
124
|
+
function specsRef(ids, activeSpecs) {
|
|
125
|
+
return ids
|
|
126
|
+
.map((id) => {
|
|
127
|
+
const found = activeSpecs.find((s) => s.story_id === id);
|
|
128
|
+
if (found)
|
|
129
|
+
return specRefForProse(found);
|
|
130
|
+
return `\`story-${id}\``;
|
|
131
|
+
})
|
|
132
|
+
.join(", ");
|
|
133
|
+
}
|
|
106
134
|
/** Comment body posted to the issue when relationship analysis surfaces a conflict. */
|
|
107
|
-
export function overlapCommentBody(verdict) {
|
|
108
|
-
const specs = verdict.conflicting_ids
|
|
135
|
+
export function overlapCommentBody(verdict, activeSpecs = []) {
|
|
136
|
+
const specs = specsRef(verdict.conflicting_ids, activeSpecs);
|
|
109
137
|
return `### slowcook · overlap detected
|
|
110
138
|
|
|
111
139
|
This issue overlaps with existing active specs: ${specs}.
|
|
@@ -120,8 +148,23 @@ Please choose how to proceed:
|
|
|
120
148
|
|
|
121
149
|
I'll pause refinement until the issue body or labels are updated.`;
|
|
122
150
|
}
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Comment body for the follow_up verdict — posted informationally, does NOT
|
|
153
|
+
* halt refinement. Tells the PM "I noticed this builds on X, will cite it in
|
|
154
|
+
* the resulting spec, proceeding with refinement now."
|
|
155
|
+
*/
|
|
156
|
+
export function followUpCommentBody(verdict, activeSpecs = []) {
|
|
157
|
+
const specs = specsRef(verdict.related_ids, activeSpecs);
|
|
158
|
+
return `### slowcook · follow-up detected
|
|
159
|
+
|
|
160
|
+
This issue builds on top of ${specs} — it fulfills scope the prior spec(s) explicitly deferred via \`non_goals\` or "future story" phrasing. That's a legitimate scope-growth pattern, not a duplication. **Continuing with refinement.**
|
|
161
|
+
|
|
162
|
+
**Reasoning:** ${verdict.reasoning}
|
|
163
|
+
|
|
164
|
+
The resulting spec will list the predecessor(s) in its \`related_specs\` field so the chain is auditable.`;
|
|
165
|
+
}
|
|
166
|
+
export function contradictionCommentBody(verdict, hasChangeOfMind, activeSpecs = []) {
|
|
167
|
+
const specs = specsRef(verdict.conflicting_ids, activeSpecs);
|
|
125
168
|
if (hasChangeOfMind) {
|
|
126
169
|
return `### slowcook · change-of-mind authorized
|
|
127
170
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relationship.js","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,EAAE,2BAA2B,EAAE,MAAM,cAAc,CAAC;AAE3D,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7B,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,oBAAoB,EAAE,SAAS,EAAE,eAAe,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"relationship.js","sourceRoot":"","sources":["../../../src/commands/refine/relationship.ts"],"names":[],"mappings":"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"}
|
|
@@ -34,44 +34,44 @@ export declare const schemas: {
|
|
|
34
34
|
supersedes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
35
35
|
superseded_by: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNull]>>;
|
|
36
36
|
}, "strip", z.ZodTypeAny, {
|
|
37
|
-
title: string;
|
|
38
37
|
status: "draft" | "active" | "superseded";
|
|
38
|
+
title: string;
|
|
39
|
+
supersedes?: string[] | undefined;
|
|
40
|
+
superseded_by?: string | null | undefined;
|
|
39
41
|
source_issue?: string | undefined;
|
|
40
42
|
tags?: string[] | undefined;
|
|
41
43
|
summary?: string | undefined;
|
|
42
|
-
supersedes?: string[] | undefined;
|
|
43
|
-
superseded_by?: string | null | undefined;
|
|
44
44
|
}, {
|
|
45
|
-
title: string;
|
|
46
45
|
status: "draft" | "active" | "superseded";
|
|
46
|
+
title: string;
|
|
47
|
+
supersedes?: string[] | undefined;
|
|
48
|
+
superseded_by?: string | null | undefined;
|
|
47
49
|
source_issue?: string | undefined;
|
|
48
50
|
tags?: string[] | undefined;
|
|
49
51
|
summary?: string | undefined;
|
|
50
|
-
supersedes?: string[] | undefined;
|
|
51
|
-
superseded_by?: string | null | undefined;
|
|
52
52
|
}>>;
|
|
53
53
|
}, "strip", z.ZodTypeAny, {
|
|
54
54
|
schema_version: 1;
|
|
55
55
|
stories: Record<string, {
|
|
56
|
-
title: string;
|
|
57
56
|
status: "draft" | "active" | "superseded";
|
|
57
|
+
title: string;
|
|
58
|
+
supersedes?: string[] | undefined;
|
|
59
|
+
superseded_by?: string | null | undefined;
|
|
58
60
|
source_issue?: string | undefined;
|
|
59
61
|
tags?: string[] | undefined;
|
|
60
62
|
summary?: string | undefined;
|
|
61
|
-
supersedes?: string[] | undefined;
|
|
62
|
-
superseded_by?: string | null | undefined;
|
|
63
63
|
}>;
|
|
64
64
|
$schema?: string | undefined;
|
|
65
65
|
}, {
|
|
66
66
|
schema_version: 1;
|
|
67
67
|
stories: Record<string, {
|
|
68
|
-
title: string;
|
|
69
68
|
status: "draft" | "active" | "superseded";
|
|
69
|
+
title: string;
|
|
70
|
+
supersedes?: string[] | undefined;
|
|
71
|
+
superseded_by?: string | null | undefined;
|
|
70
72
|
source_issue?: string | undefined;
|
|
71
73
|
tags?: string[] | undefined;
|
|
72
74
|
summary?: string | undefined;
|
|
73
|
-
supersedes?: string[] | undefined;
|
|
74
|
-
superseded_by?: string | null | undefined;
|
|
75
75
|
}>;
|
|
76
76
|
$schema?: string | undefined;
|
|
77
77
|
}>;
|
|
@@ -109,20 +109,20 @@ export declare const schemas: {
|
|
|
109
109
|
note: z.ZodOptional<z.ZodString>;
|
|
110
110
|
}, "strip", z.ZodTypeAny, {
|
|
111
111
|
id: string;
|
|
112
|
-
relationship: "
|
|
112
|
+
relationship: "overlap" | "superseded" | "related";
|
|
113
113
|
note?: string | undefined;
|
|
114
114
|
}, {
|
|
115
115
|
id: string;
|
|
116
|
-
relationship: "
|
|
116
|
+
relationship: "overlap" | "superseded" | "related";
|
|
117
117
|
note?: string | undefined;
|
|
118
118
|
}>, "many">>;
|
|
119
119
|
}, "strip", z.ZodTypeAny, {
|
|
120
|
-
title: string;
|
|
121
120
|
status: "draft" | "active" | "superseded";
|
|
122
|
-
supersedes: string[];
|
|
123
|
-
superseded_by: string | null;
|
|
124
121
|
story_id: string;
|
|
122
|
+
title: string;
|
|
125
123
|
created_at: string;
|
|
124
|
+
supersedes: string[];
|
|
125
|
+
superseded_by: string | null;
|
|
126
126
|
actors: {
|
|
127
127
|
name: string;
|
|
128
128
|
notes?: string | undefined;
|
|
@@ -131,25 +131,25 @@ export declare const schemas: {
|
|
|
131
131
|
invariants: string[];
|
|
132
132
|
acceptance_scenarios: string[];
|
|
133
133
|
non_goals: string[];
|
|
134
|
-
source_issue?: string | undefined;
|
|
135
|
-
$schema?: string | undefined;
|
|
136
134
|
token_budget_usd?: number | undefined;
|
|
137
135
|
estimate?: "small" | "medium" | "large" | undefined;
|
|
136
|
+
source_issue?: string | undefined;
|
|
138
137
|
refined_by?: string | undefined;
|
|
139
138
|
api_contract?: unknown[] | undefined;
|
|
140
139
|
ui_behavior?: Record<string, string> | undefined;
|
|
141
140
|
related_specs?: {
|
|
142
141
|
id: string;
|
|
143
|
-
relationship: "
|
|
142
|
+
relationship: "overlap" | "superseded" | "related";
|
|
144
143
|
note?: string | undefined;
|
|
145
144
|
}[] | undefined;
|
|
145
|
+
$schema?: string | undefined;
|
|
146
146
|
}, {
|
|
147
|
-
title: string;
|
|
148
147
|
status: "draft" | "active" | "superseded";
|
|
149
|
-
supersedes: string[];
|
|
150
|
-
superseded_by: string | null;
|
|
151
148
|
story_id: string;
|
|
149
|
+
title: string;
|
|
152
150
|
created_at: string;
|
|
151
|
+
supersedes: string[];
|
|
152
|
+
superseded_by: string | null;
|
|
153
153
|
actors: {
|
|
154
154
|
name: string;
|
|
155
155
|
notes?: string | undefined;
|
|
@@ -158,18 +158,18 @@ export declare const schemas: {
|
|
|
158
158
|
invariants: string[];
|
|
159
159
|
acceptance_scenarios: string[];
|
|
160
160
|
non_goals: string[];
|
|
161
|
-
source_issue?: string | undefined;
|
|
162
|
-
$schema?: string | undefined;
|
|
163
161
|
token_budget_usd?: number | undefined;
|
|
164
162
|
estimate?: "small" | "medium" | "large" | undefined;
|
|
163
|
+
source_issue?: string | undefined;
|
|
165
164
|
refined_by?: string | undefined;
|
|
166
165
|
api_contract?: unknown[] | undefined;
|
|
167
166
|
ui_behavior?: Record<string, string> | undefined;
|
|
168
167
|
related_specs?: {
|
|
169
168
|
id: string;
|
|
170
|
-
relationship: "
|
|
169
|
+
relationship: "overlap" | "superseded" | "related";
|
|
171
170
|
note?: string | undefined;
|
|
172
171
|
}[] | undefined;
|
|
172
|
+
$schema?: string | undefined;
|
|
173
173
|
}>;
|
|
174
174
|
SpecIndexEntry: z.ZodObject<{
|
|
175
175
|
title: z.ZodString;
|
|
@@ -180,21 +180,21 @@ export declare const schemas: {
|
|
|
180
180
|
supersedes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
181
181
|
superseded_by: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNull]>>;
|
|
182
182
|
}, "strip", z.ZodTypeAny, {
|
|
183
|
-
title: string;
|
|
184
183
|
status: "draft" | "active" | "superseded";
|
|
184
|
+
title: string;
|
|
185
|
+
supersedes?: string[] | undefined;
|
|
186
|
+
superseded_by?: string | null | undefined;
|
|
185
187
|
source_issue?: string | undefined;
|
|
186
188
|
tags?: string[] | undefined;
|
|
187
189
|
summary?: string | undefined;
|
|
188
|
-
supersedes?: string[] | undefined;
|
|
189
|
-
superseded_by?: string | null | undefined;
|
|
190
190
|
}, {
|
|
191
|
-
title: string;
|
|
192
191
|
status: "draft" | "active" | "superseded";
|
|
192
|
+
title: string;
|
|
193
|
+
supersedes?: string[] | undefined;
|
|
194
|
+
superseded_by?: string | null | undefined;
|
|
193
195
|
source_issue?: string | undefined;
|
|
194
196
|
tags?: string[] | undefined;
|
|
195
197
|
summary?: string | undefined;
|
|
196
|
-
supersedes?: string[] | undefined;
|
|
197
|
-
superseded_by?: string | null | undefined;
|
|
198
198
|
}>;
|
|
199
199
|
};
|
|
200
200
|
/** Minimal helper for the agent to build a SpecIndexEntry from a Spec. */
|
|
@@ -43,6 +43,39 @@ export type TestgenOutcome = {
|
|
|
43
43
|
*/
|
|
44
44
|
export declare function runTestgen(ctx: TestgenContext): Promise<TestgenOutcome>;
|
|
45
45
|
export declare function buildProjectContext(repoRoot: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Phase B2 (0.7.0) testgen output: one test file, zero-or-more route stubs,
|
|
48
|
+
* zero-or-more mock helpers. The LLM emits these as XML-tagged blocks
|
|
49
|
+
* (see TESTGEN_SYSTEM for the exact format); slowcook parses, de-duplicates
|
|
50
|
+
* against existing files, and writes only what's new.
|
|
51
|
+
*/
|
|
52
|
+
export interface TestgenBundle {
|
|
53
|
+
testContent: string;
|
|
54
|
+
stubs: Array<{
|
|
55
|
+
path: string;
|
|
56
|
+
contents: string;
|
|
57
|
+
}>;
|
|
58
|
+
helpers: Array<{
|
|
59
|
+
path: string;
|
|
60
|
+
contents: string;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse XML-tagged multi-artifact output into a TestgenBundle.
|
|
65
|
+
*
|
|
66
|
+
* Accepted shape (from the prompt):
|
|
67
|
+
* <test_file>...</test_file>
|
|
68
|
+
* <stub path="src/app/api/foo/route.ts">...</stub> (zero or more)
|
|
69
|
+
* <helper path="tests/helpers/mocks/bar.ts">...</helper> (zero or more)
|
|
70
|
+
*
|
|
71
|
+
* Tolerant of code-fenced output: if the LLM wraps the whole thing in
|
|
72
|
+
* ```, we strip it. If a block's contents are themselves code-fenced,
|
|
73
|
+
* we strip those too — tier-1 test / helper / stub files are raw TS.
|
|
74
|
+
*
|
|
75
|
+
* Throws if `<test_file>` is missing or empty — that's the one mandatory
|
|
76
|
+
* artifact.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseTestgenBundle(raw: string, storyId: string): TestgenBundle;
|
|
46
79
|
/**
|
|
47
80
|
* Tier-1 conformance lint. Run on every generated test file before commit.
|
|
48
81
|
* Catches patterns the prompt forbids — inline `vi.mock`, `fetch(...)`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../../src/commands/testgen/agent.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../../src/commands/testgen/agent.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,YAAY,EAAQ,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AASlD,eAAO,MAAM,iBAAiB,gBAAgB,CAAC;AAC/C,eAAO,MAAM,qBAAqB,oBAAoB,CAAC;AAEvD,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AACzD,eAAO,MAAM,aAAa,uBAAuB,CAAC;AAClD,eAAO,MAAM,gBAAgB,wBAAwB,CAAC;AAEtD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,YAAY,CAAC;IACpB,GAAG,EAAE,SAAS,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,GAAG,EAAE,OAAO,CAAC;IACb,2EAA2E;IAC3E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,gCAAgC;IAChC,GAAG,EAAE,IAAI,CAAC;CACX;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,MAAM,EAAE,CAAA;CAAE,GACzG;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,qBAAqB,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5E;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CA8I7E;AAkED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA8F5D;AAsCD;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpD;AAqBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,aAAa,CAiC9E;AASD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AA6DD,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAepF;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BjF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync, } from "node:fs";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { buildManifest, } from "@slowcook-ai/core";
|
|
@@ -26,14 +26,14 @@ export async function runTestgen(ctx) {
|
|
|
26
26
|
const toRemove = [];
|
|
27
27
|
for (const spec of specs) {
|
|
28
28
|
const projectContext = buildProjectContext(ctx.repoRoot);
|
|
29
|
-
const
|
|
29
|
+
const bundle = await generateTestBundle(spec, ctx, projectContext);
|
|
30
30
|
const testPath = join(TESTS_INTEGRATION_DIR, `story-${spec.story_id}.test.ts`);
|
|
31
31
|
// Tier-1 conformance gate: if the LLM slipped back to tier-0 habits
|
|
32
32
|
// (inline vi.mock, fetch(), etc.), we refuse to ship the file. Halts
|
|
33
33
|
// loudly here rather than quietly producing HTTP-loopback tests the
|
|
34
34
|
// brewing loop can't ratchet against. The caller can re-run with a
|
|
35
35
|
// different seed or hand-edit and re-run testgen.
|
|
36
|
-
const violations = lintTierOneTest(testPath,
|
|
36
|
+
const violations = lintTierOneTest(testPath, bundle.testContent);
|
|
37
37
|
if (violations.length > 0) {
|
|
38
38
|
const details = violations
|
|
39
39
|
.slice(0, 10)
|
|
@@ -44,7 +44,12 @@ export async function runTestgen(ctx) {
|
|
|
44
44
|
`The LLM emitted patterns banned by docs/plans/0.7-testgen-two-tier.md §4.1-§7.3. ` +
|
|
45
45
|
`Re-run testgen with a different model/seed, or hand-edit the generated file to use project mock helpers.`);
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// De-dupe stubs + helpers: skip anything whose target file exists and
|
|
48
|
+
// isn't a @slowcook-stub (for stubs) or isn't empty (for helpers). This
|
|
49
|
+
// lets testgen re-run safely without clobbering in-progress impls.
|
|
50
|
+
const stubsToWrite = bundle.stubs.filter((s) => shouldWriteStub(ctx.repoRoot, s.path));
|
|
51
|
+
const helpersToWrite = bundle.helpers.filter((h) => shouldWriteHelper(ctx.repoRoot, h.path));
|
|
52
|
+
const manifestIds = extractTestIdsFromFile(testPath, bundle.testContent);
|
|
48
53
|
const manifest = buildManifest({
|
|
49
54
|
slowcookVersion: ctx.cliVersion,
|
|
50
55
|
storyId: spec.story_id,
|
|
@@ -52,7 +57,14 @@ export async function runTestgen(ctx) {
|
|
|
52
57
|
suites: [{ suite: "backend", command: "npx vitest list", test_count: manifestIds.length }],
|
|
53
58
|
now: ctx.now,
|
|
54
59
|
});
|
|
55
|
-
generated.push({
|
|
60
|
+
generated.push({
|
|
61
|
+
spec,
|
|
62
|
+
testPath,
|
|
63
|
+
fileContents: bundle.testContent,
|
|
64
|
+
manifest,
|
|
65
|
+
stubs: stubsToWrite,
|
|
66
|
+
helpers: helpersToWrite,
|
|
67
|
+
});
|
|
56
68
|
for (const superseded of spec.supersedes) {
|
|
57
69
|
toRemove.push(superseded);
|
|
58
70
|
}
|
|
@@ -62,6 +74,12 @@ export async function runTestgen(ctx) {
|
|
|
62
74
|
writeFileAt(ctx.repoRoot, g.testPath, g.fileContents);
|
|
63
75
|
const manifestPath = join(MANIFESTS_DIR, `story-${g.spec.story_id}.json`);
|
|
64
76
|
writeFileAt(ctx.repoRoot, manifestPath, JSON.stringify(g.manifest, null, 2) + "\n");
|
|
77
|
+
for (const stub of g.stubs) {
|
|
78
|
+
writeFileAt(ctx.repoRoot, stub.path, stub.contents);
|
|
79
|
+
}
|
|
80
|
+
for (const helper of g.helpers) {
|
|
81
|
+
writeFileAt(ctx.repoRoot, helper.path, helper.contents);
|
|
82
|
+
}
|
|
65
83
|
}
|
|
66
84
|
const actuallyRemoved = [];
|
|
67
85
|
for (const id of toRemove) {
|
|
@@ -77,6 +95,10 @@ export async function runTestgen(ctx) {
|
|
|
77
95
|
for (const g of generated) {
|
|
78
96
|
await ctx.forge.git.stage(g.testPath);
|
|
79
97
|
await ctx.forge.git.stage(join(MANIFESTS_DIR, `story-${g.spec.story_id}.json`));
|
|
98
|
+
for (const stub of g.stubs)
|
|
99
|
+
await ctx.forge.git.stage(stub.path);
|
|
100
|
+
for (const helper of g.helpers)
|
|
101
|
+
await ctx.forge.git.stage(helper.path);
|
|
80
102
|
}
|
|
81
103
|
for (const id of actuallyRemoved) {
|
|
82
104
|
await ctx.forge.git.stage(join(TESTS_INTEGRATION_DIR, `story-${id}.test.ts`));
|
|
@@ -123,6 +145,38 @@ export async function runTestgen(ctx) {
|
|
|
123
145
|
throw e;
|
|
124
146
|
}
|
|
125
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Decide whether to write a stub file. Write when:
|
|
150
|
+
* - The target doesn't exist — most common case, new story.
|
|
151
|
+
* - The target exists but is itself a @slowcook-stub (marker on line 1).
|
|
152
|
+
* Lets testgen re-runs refresh stubs as spec evolves.
|
|
153
|
+
* Skip when:
|
|
154
|
+
* - The target exists and has real implementation (no stub marker).
|
|
155
|
+
* Could be a brownfield consumer where the route already exists, or
|
|
156
|
+
* brewing has already replaced the stub body. Either way, don't clobber.
|
|
157
|
+
*/
|
|
158
|
+
function shouldWriteStub(repoRoot, path) {
|
|
159
|
+
const full = join(repoRoot, path);
|
|
160
|
+
if (!existsSync(full))
|
|
161
|
+
return true;
|
|
162
|
+
try {
|
|
163
|
+
const first = readFileSync(full, "utf8").split("\n")[0] ?? "";
|
|
164
|
+
return first.includes("@slowcook-stub");
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Decide whether to write a helper file. Write when the target doesn't
|
|
172
|
+
* exist. Never clobber an existing helper — the consumer may have
|
|
173
|
+
* hand-customised it, and the generated version would lose those edits.
|
|
174
|
+
* Operator can delete the file and re-run testgen to get a fresh
|
|
175
|
+
* auto-generated helper.
|
|
176
|
+
*/
|
|
177
|
+
function shouldWriteHelper(repoRoot, path) {
|
|
178
|
+
return !existsSync(join(repoRoot, path));
|
|
179
|
+
}
|
|
126
180
|
function collectTargetSpecs(ctx) {
|
|
127
181
|
const index = readIndex(ctx.repoRoot);
|
|
128
182
|
const all = Object.entries(index.stories)
|
|
@@ -185,10 +239,26 @@ export function buildProjectContext(repoRoot) {
|
|
|
185
239
|
else {
|
|
186
240
|
bits.push("\nNo existing integration tests — this will be the first in the repo.");
|
|
187
241
|
}
|
|
242
|
+
// List existing API route files so the LLM knows NOT to emit a <stub>
|
|
243
|
+
// block for them. (A route that already exists has a real impl; stubbing
|
|
244
|
+
// over it would clobber production code. The consumer may also have
|
|
245
|
+
// brownfield code that pre-existed slowcook adoption — listed here so
|
|
246
|
+
// testgen respects what's there.)
|
|
247
|
+
const appDir = join(repoRoot, "src", "app");
|
|
248
|
+
if (existsSync(appDir)) {
|
|
249
|
+
const routes = listAppRouterFiles(appDir).sort();
|
|
250
|
+
if (routes.length > 0) {
|
|
251
|
+
bits.push(`\n### Existing API route files (under src/app/)\n\nThese already exist — do NOT emit a \`<stub>\` block for any of them. If the test imports from one of these, assume the route file exists and skip stub generation.`);
|
|
252
|
+
for (const r of routes.slice(0, 50))
|
|
253
|
+
bits.push(`- \`${r}\``);
|
|
254
|
+
if (routes.length > 50)
|
|
255
|
+
bits.push(`- … (${routes.length - 50} more)`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
188
258
|
// List existing mock helpers so the LLM knows which to import. The
|
|
189
259
|
// helper pattern is load-bearing for the future record-and-replay swap
|
|
190
260
|
// (plans/0.7-testgen-two-tier.md §4.3). Helpers NOT listed here will
|
|
191
|
-
//
|
|
261
|
+
// be auto-generated by testgen B2 as <helper> blocks.
|
|
192
262
|
const helpersDir = join(repoRoot, MOCK_HELPERS_DIR);
|
|
193
263
|
if (existsSync(helpersDir)) {
|
|
194
264
|
const helpers = readdirSync(helpersDir)
|
|
@@ -214,28 +284,111 @@ export function buildProjectContext(repoRoot) {
|
|
|
214
284
|
}
|
|
215
285
|
}
|
|
216
286
|
else {
|
|
217
|
-
bits.push(`\n### Mock helpers\n\nNo \`${MOCK_HELPERS_DIR}/\` directory yet — this project hasn't set up the helper pattern. Emit
|
|
287
|
+
bits.push(`\n### Mock helpers\n\nNo \`${MOCK_HELPERS_DIR}/\` directory yet — this project hasn't set up the helper pattern. Emit a \`<helper>\` block for each external dependency the handler calls, matching the helper-file shape in the system prompt. Also emit a \`<helper path="${MOCK_HELPERS_DIR}/index.ts">\` barrel re-exporting your new helpers.`);
|
|
218
288
|
}
|
|
219
289
|
return bits.join("\n");
|
|
220
290
|
}
|
|
221
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Walk src/app/ and return every route file path (repo-relative) that
|
|
293
|
+
* Next.js App Router treats as an endpoint. We surface these to the
|
|
294
|
+
* testgen LLM so it doesn't emit a <stub> for a file that already
|
|
295
|
+
* exists.
|
|
296
|
+
*/
|
|
297
|
+
function listAppRouterFiles(appDir) {
|
|
298
|
+
const out = [];
|
|
299
|
+
const walk = (dir) => {
|
|
300
|
+
let entries;
|
|
301
|
+
try {
|
|
302
|
+
entries = readdirSync(dir);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
for (const name of entries) {
|
|
308
|
+
const full = join(dir, name);
|
|
309
|
+
let stat;
|
|
310
|
+
try {
|
|
311
|
+
stat = statSync(full);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (stat.isDirectory())
|
|
317
|
+
walk(full);
|
|
318
|
+
else if (stat.isFile() && /^(route|page)\.(ts|tsx)$/.test(name)) {
|
|
319
|
+
// Return repo-relative path. Parent dirs up to "src/app" are derivable
|
|
320
|
+
// from appDir; we trim appDir and prefix "src/app".
|
|
321
|
+
const rel = full.slice(full.indexOf("src/app"));
|
|
322
|
+
out.push(rel);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
walk(appDir);
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
async function generateTestBundle(spec, ctx, projectContext) {
|
|
222
330
|
const systemPrompt = TESTGEN_SYSTEM(projectContext);
|
|
223
|
-
const userMessage = `Here is the spec YAML. Generate the
|
|
331
|
+
const userMessage = `Here is the spec YAML. Generate the tier-1 test bundle (test file + any needed stubs + any needed helpers):\n\n\`\`\`yaml\n${YAML.stringify(spec)}\n\`\`\``;
|
|
224
332
|
const raw = await ctx.llm.complete({
|
|
225
333
|
system: systemPrompt,
|
|
226
334
|
cacheSystem: true,
|
|
227
335
|
model: ctx.model,
|
|
228
336
|
messages: [{ role: "user", content: userMessage }],
|
|
229
|
-
maxTokens:
|
|
337
|
+
maxTokens: 16384,
|
|
230
338
|
});
|
|
231
|
-
return
|
|
339
|
+
return parseTestgenBundle(raw, spec.story_id);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Parse XML-tagged multi-artifact output into a TestgenBundle.
|
|
343
|
+
*
|
|
344
|
+
* Accepted shape (from the prompt):
|
|
345
|
+
* <test_file>...</test_file>
|
|
346
|
+
* <stub path="src/app/api/foo/route.ts">...</stub> (zero or more)
|
|
347
|
+
* <helper path="tests/helpers/mocks/bar.ts">...</helper> (zero or more)
|
|
348
|
+
*
|
|
349
|
+
* Tolerant of code-fenced output: if the LLM wraps the whole thing in
|
|
350
|
+
* ```, we strip it. If a block's contents are themselves code-fenced,
|
|
351
|
+
* we strip those too — tier-1 test / helper / stub files are raw TS.
|
|
352
|
+
*
|
|
353
|
+
* Throws if `<test_file>` is missing or empty — that's the one mandatory
|
|
354
|
+
* artifact.
|
|
355
|
+
*/
|
|
356
|
+
export function parseTestgenBundle(raw, storyId) {
|
|
357
|
+
const trimmed = raw.trim();
|
|
358
|
+
// Strip outer code fence if the LLM wrapped everything
|
|
359
|
+
const outerFenceMatch = trimmed.match(/^```[a-z]*\s*\n([\s\S]*)\n```$/);
|
|
360
|
+
const body = outerFenceMatch && outerFenceMatch[1] ? outerFenceMatch[1] : trimmed;
|
|
361
|
+
const testMatch = body.match(/<test_file>([\s\S]*?)<\/test_file>/);
|
|
362
|
+
if (!testMatch || !testMatch[1]) {
|
|
363
|
+
throw new Error(`testgen: LLM output for story-${storyId} missing a <test_file> block. ` +
|
|
364
|
+
`Got ${body.length} chars starting with: ${body.slice(0, 120)}...`);
|
|
365
|
+
}
|
|
366
|
+
const testContent = stripInnerFence(testMatch[1]);
|
|
367
|
+
const stubs = [];
|
|
368
|
+
const stubRe = /<stub\s+path="([^"]+)">([\s\S]*?)<\/stub>/g;
|
|
369
|
+
let m;
|
|
370
|
+
while ((m = stubRe.exec(body)) !== null) {
|
|
371
|
+
const p = m[1] ?? "";
|
|
372
|
+
const c = m[2] ?? "";
|
|
373
|
+
if (p && c.trim())
|
|
374
|
+
stubs.push({ path: p, contents: stripInnerFence(c) });
|
|
375
|
+
}
|
|
376
|
+
const helpers = [];
|
|
377
|
+
const helperRe = /<helper\s+path="([^"]+)">([\s\S]*?)<\/helper>/g;
|
|
378
|
+
while ((m = helperRe.exec(body)) !== null) {
|
|
379
|
+
const p = m[1] ?? "";
|
|
380
|
+
const c = m[2] ?? "";
|
|
381
|
+
if (p && c.trim())
|
|
382
|
+
helpers.push({ path: p, contents: stripInnerFence(c) });
|
|
383
|
+
}
|
|
384
|
+
return { testContent, stubs, helpers };
|
|
232
385
|
}
|
|
233
|
-
function
|
|
386
|
+
function stripInnerFence(raw) {
|
|
234
387
|
const t = raw.trim();
|
|
235
|
-
const fence = t.match(/^```(?:typescript|ts)?\s*\n([\s\S]*)\n```$/);
|
|
388
|
+
const fence = t.match(/^```(?:typescript|ts|tsx)?\s*\n([\s\S]*)\n```$/);
|
|
236
389
|
if (fence && fence[1])
|
|
237
390
|
return fence[1];
|
|
238
|
-
return t;
|
|
391
|
+
return t + "\n"; // ensure trailing newline for file writes
|
|
239
392
|
}
|
|
240
393
|
/**
|
|
241
394
|
* Two scan modes:
|
|
@@ -461,6 +614,24 @@ function buildPrBody(args) {
|
|
|
461
614
|
const manifestCount = g.manifest.tests.length;
|
|
462
615
|
sections.push(`- \`story-${g.spec.story_id}\` — *${g.spec.title}* — ${manifestCount} test(s) in \`${g.testPath}\``);
|
|
463
616
|
}
|
|
617
|
+
const allStubs = args.generated.flatMap((g) => g.stubs);
|
|
618
|
+
if (allStubs.length > 0) {
|
|
619
|
+
sections.push("");
|
|
620
|
+
sections.push("## Generated stubs (route files)");
|
|
621
|
+
sections.push("Minimal throwing route files so tier-1 tests can collect. Each carries an \`@slowcook-stub\` marker on line 1. **Brewing will replace these bodies** with the real implementation across its iterations. Reviewer check: correct file path + export signature + \`@slowcook-stub\` marker present. If the signature is wrong the whole PR is wrong — flag it now.");
|
|
622
|
+
for (const s of allStubs) {
|
|
623
|
+
sections.push(`- \`${s.path}\``);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const allHelpers = args.generated.flatMap((g) => g.helpers);
|
|
627
|
+
if (allHelpers.length > 0) {
|
|
628
|
+
sections.push("");
|
|
629
|
+
sections.push("## Generated mock helpers");
|
|
630
|
+
sections.push("Signature-asserting fakes for external services the handlers consume. **Three load-bearing properties**: (1) calling the real module's exported function with wrong args throws loudly (catches the class of bug where tests pass via mock-arg-ignoring but production crashes); (2) every chained method pushes to \`client.calls\` so tests assert against that instead of poking \`vi.fn\` internals; (3) config is intent-level (\`user\`, \`tables\`) not implementation-level (\`return_value_for_from\`). Reviewer check: the \`realShaped*\` wrapper exists AND matches the real module's signature from \`src/\`.");
|
|
631
|
+
for (const h of allHelpers) {
|
|
632
|
+
sections.push(`- \`${h.path}\``);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
464
635
|
if (args.removedStoryIds.length > 0) {
|
|
465
636
|
sections.push("");
|
|
466
637
|
sections.push("## Tests removed (supersede chain)");
|