@brainervirus/workit-core 0.5.6 → 0.6.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/README.md +4 -17
- package/package.json +9 -9
- package/scripts/_shared/common.sh +18 -3
- package/scripts/install-opencode-plugin.sh +14 -9
- package/scripts/lib/config-dir.sh +26 -0
- package/scripts/sync-runtime.sh +4 -1
- package/src/core/branch.ts +143 -48
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +95 -15
- package/src/core/detector.ts +22 -11
- package/src/core/docs-repo.ts +50 -15
- package/src/core/docs-validate.ts +163 -37
- package/src/core/flow-state.ts +6 -2
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/core/hygiene.ts +27 -5
- package/src/core/init.ts +86 -21
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/youtrack-api.ts +3 -1
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/pr-create.ts +47 -15
- package/src/core/present.ts +11 -2
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-tool.ts +4 -1
- package/src/core/rules.ts +10 -7
- package/src/core/scripts.ts +7 -2
- package/src/core/sdd.ts +11 -3
- package/src/core/templates.ts +14 -4
- package/src/core/vcs-config.ts +94 -36
- package/src/core/verify-parse.ts +4 -2
- package/src/core/workspaces.ts +2 -2
- package/src/core/youtrack.ts +233 -58
- package/src/core.ts +18 -3
- package/src/tools/docs-repo.ts +12 -3
- package/src/tools/flow.ts +24 -13
- package/src/tools/handoff.ts +28 -23
- package/src/tools/present.ts +14 -10
- package/src/tools/repo.ts +220 -87
- package/src/tools/sdd.ts +93 -66
- package/src/tools/youtrack.ts +119 -52
- package/templates/superpowers-doc-contract.md +1 -1
|
@@ -15,17 +15,20 @@ const err = (code: string, message: string, path?: string): DocError => {
|
|
|
15
15
|
return item;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
const failValidate = (
|
|
19
|
-
errors: DocError[],
|
|
20
|
-
): { ok: false; errors: DocError[]; error: string } => ({
|
|
18
|
+
const failValidate = (errors: DocError[]): { ok: false; errors: DocError[]; error: string } => ({
|
|
21
19
|
ok: false,
|
|
22
20
|
errors,
|
|
23
|
-
error:
|
|
21
|
+
error:
|
|
22
|
+
errors
|
|
23
|
+
.map((e) => e.message)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join("; ") || "docs validation failed",
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
const readBranch = (text: string, label: string): [string | null, DocError | null] => {
|
|
27
29
|
const match = text.match(BRANCH_RE);
|
|
28
|
-
if (!match)
|
|
30
|
+
if (!match)
|
|
31
|
+
return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
|
|
29
32
|
return [match[1].trim().replace(/`/g, ""), null];
|
|
30
33
|
};
|
|
31
34
|
|
|
@@ -34,37 +37,55 @@ const scanTaskHeadings = (planText: string): [number[], string[], DocError | nul
|
|
|
34
37
|
const titles: string[] = [];
|
|
35
38
|
let inFence = false;
|
|
36
39
|
for (const line of planText.split("\n")) {
|
|
37
|
-
if (line.startsWith("```")) {
|
|
40
|
+
if (line.startsWith("```")) {
|
|
41
|
+
inFence = !inFence;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
38
44
|
if (inFence) continue;
|
|
39
45
|
const match = line.match(TASK_RE);
|
|
40
|
-
if (match) {
|
|
46
|
+
if (match) {
|
|
47
|
+
ids.push(Number(match[1]));
|
|
48
|
+
titles.push(match[2].trim());
|
|
49
|
+
}
|
|
41
50
|
}
|
|
42
|
-
if (ids.length === 0)
|
|
51
|
+
if (ids.length === 0)
|
|
52
|
+
return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
|
|
43
53
|
const expected = ids.map((_, i) => i + 1);
|
|
44
54
|
const sorted = [...ids].sort((a, b) => a - b);
|
|
45
55
|
if (JSON.stringify(sorted) !== JSON.stringify(expected) || new Set(ids).size !== ids.length) {
|
|
46
|
-
return [
|
|
56
|
+
return [
|
|
57
|
+
ids,
|
|
58
|
+
titles,
|
|
59
|
+
err("task_order", `task headings must be contiguous from 1..${ids.length}; found ${ids}`),
|
|
60
|
+
];
|
|
47
61
|
}
|
|
48
62
|
return [ids, titles, null];
|
|
49
63
|
};
|
|
50
64
|
|
|
51
65
|
// Port of scripts/lib/parse-plan-tasks.sh (JSON mode)
|
|
52
|
-
export const parseTasksFromPlan = (
|
|
66
|
+
export const parseTasksFromPlan = (
|
|
67
|
+
planText: string,
|
|
68
|
+
): { id: number; title: string; section_text: string }[] => {
|
|
53
69
|
const tasks: { id: number; title: string; section_text: string }[] = [];
|
|
54
70
|
let current: { id: number; title: string; body: string[] } | null = null;
|
|
55
71
|
let inFence = false;
|
|
56
72
|
for (const line of planText.split("\n")) {
|
|
57
|
-
if (line.startsWith("```")) {
|
|
73
|
+
if (line.startsWith("```")) {
|
|
74
|
+
inFence = !inFence;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
58
77
|
if (inFence) continue;
|
|
59
78
|
const match = line.match(TASK_RE);
|
|
60
79
|
if (match) {
|
|
61
|
-
if (current)
|
|
80
|
+
if (current)
|
|
81
|
+
tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
62
82
|
current = { id: Number(match[1]), title: match[2].trim(), body: [] };
|
|
63
83
|
} else if (current) {
|
|
64
84
|
current.body.push(line);
|
|
65
85
|
}
|
|
66
86
|
}
|
|
67
|
-
if (current)
|
|
87
|
+
if (current)
|
|
88
|
+
tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
68
89
|
return tasks;
|
|
69
90
|
};
|
|
70
91
|
|
|
@@ -77,7 +98,14 @@ export const docsValidate = ({
|
|
|
77
98
|
plan_path: string;
|
|
78
99
|
workspace_root: string;
|
|
79
100
|
}):
|
|
80
|
-
| {
|
|
101
|
+
| {
|
|
102
|
+
ok: true;
|
|
103
|
+
spec: string;
|
|
104
|
+
plan: string;
|
|
105
|
+
branch: string;
|
|
106
|
+
task_count: number;
|
|
107
|
+
quality: QualityFinding[];
|
|
108
|
+
}
|
|
81
109
|
| { ok: false; errors: DocError[]; error: string } => {
|
|
82
110
|
const cwd = path.resolve(workspace_root);
|
|
83
111
|
const specAbs = path.isAbsolute(spec_path) ? spec_path : path.join(cwd, spec_path);
|
|
@@ -85,13 +113,19 @@ export const docsValidate = ({
|
|
|
85
113
|
const errors: DocError[] = [];
|
|
86
114
|
|
|
87
115
|
const read = (p: string): string | null => {
|
|
88
|
-
try {
|
|
116
|
+
try {
|
|
117
|
+
return readFileSync(p, "utf8");
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
89
121
|
};
|
|
90
122
|
|
|
91
123
|
const specText = read(specAbs);
|
|
92
124
|
const planText = read(planAbs);
|
|
93
|
-
if (specText === null)
|
|
94
|
-
|
|
125
|
+
if (specText === null)
|
|
126
|
+
errors.push(err("missing_file", `spec not found: ${spec_path}`, spec_path));
|
|
127
|
+
if (planText === null)
|
|
128
|
+
errors.push(err("missing_file", `plan not found: ${plan_path}`, plan_path));
|
|
95
129
|
if (errors.length) return failValidate(errors);
|
|
96
130
|
|
|
97
131
|
const [specBranch, specErr] = readBranch(specText!, "spec");
|
|
@@ -106,12 +140,24 @@ export const docsValidate = ({
|
|
|
106
140
|
const linked = (linkMatch[1] ?? linkMatch[2] ?? "").trim();
|
|
107
141
|
const linkedAbs = path.isAbsolute(linked) ? linked : path.join(cwd, linked);
|
|
108
142
|
if (path.resolve(linkedAbs) !== path.resolve(specAbs)) {
|
|
109
|
-
errors.push(
|
|
143
|
+
errors.push(
|
|
144
|
+
err(
|
|
145
|
+
"spec_mismatch",
|
|
146
|
+
`plan **Spec:** ${linked} does not match spec_path ${spec_path}`,
|
|
147
|
+
plan_path,
|
|
148
|
+
),
|
|
149
|
+
);
|
|
110
150
|
}
|
|
111
151
|
}
|
|
112
152
|
|
|
113
153
|
if (specBranch && planBranch && specBranch !== planBranch) {
|
|
114
|
-
errors.push(
|
|
154
|
+
errors.push(
|
|
155
|
+
err(
|
|
156
|
+
"branch_mismatch",
|
|
157
|
+
`spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`,
|
|
158
|
+
plan_path,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
115
161
|
}
|
|
116
162
|
|
|
117
163
|
const [, , taskErr] = scanTaskHeadings(planText!);
|
|
@@ -123,10 +169,19 @@ export const docsValidate = ({
|
|
|
123
169
|
const [headingIds, headingTitles, headingErr] = scanTaskHeadings(planText!);
|
|
124
170
|
if (headingErr) return failValidate([headingErr]);
|
|
125
171
|
if (tasks.length !== headingIds.length) {
|
|
126
|
-
return failValidate([
|
|
172
|
+
return failValidate([
|
|
173
|
+
err(
|
|
174
|
+
"task_order",
|
|
175
|
+
`parse count ${tasks.length} != heading count ${headingIds.length}`,
|
|
176
|
+
plan_path,
|
|
177
|
+
),
|
|
178
|
+
]);
|
|
127
179
|
}
|
|
128
180
|
for (let i = 0; i < tasks.length; i++) {
|
|
129
|
-
if (
|
|
181
|
+
if (
|
|
182
|
+
String(tasks[i].id) !== String(headingIds[i]) ||
|
|
183
|
+
tasks[i].title.trim() !== headingTitles[i]
|
|
184
|
+
) {
|
|
130
185
|
return failValidate([err("task_order", `task mismatch at position ${i + 1}`, plan_path)]);
|
|
131
186
|
}
|
|
132
187
|
}
|
|
@@ -144,13 +199,45 @@ export const docsValidate = ({
|
|
|
144
199
|
}
|
|
145
200
|
const hygiene = hygieneFiles(cwd);
|
|
146
201
|
const hyState = hygiene.state;
|
|
147
|
-
if (hyState["CHANGELOG.md"] === "missing")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
202
|
+
if (hyState["CHANGELOG.md"] === "missing")
|
|
203
|
+
quality.push({
|
|
204
|
+
code: "changelog_missing",
|
|
205
|
+
message:
|
|
206
|
+
"CHANGELOG.md missing — create it with Keep a Changelog format (run wk-init hygiene)",
|
|
207
|
+
severity: "warning",
|
|
208
|
+
});
|
|
209
|
+
if (hyState["CHANGELOG.md"] === "invalid")
|
|
210
|
+
quality.push({
|
|
211
|
+
code: "changelog_invalid_format",
|
|
212
|
+
message: "CHANGELOG.md lacks ## [Unreleased] — Keep a Changelog format required",
|
|
213
|
+
severity: "warning",
|
|
214
|
+
});
|
|
215
|
+
if (hyState["README.md"] === "missing")
|
|
216
|
+
quality.push({ code: "readme_missing", message: "README.md missing", severity: "warning" });
|
|
217
|
+
if (hyState[".editorconfig"] === "missing")
|
|
218
|
+
quality.push({
|
|
219
|
+
code: "editorconfig_missing",
|
|
220
|
+
message: ".editorconfig missing",
|
|
221
|
+
severity: "warning",
|
|
222
|
+
});
|
|
223
|
+
if (hyState[".gitattributes"] === "missing")
|
|
224
|
+
quality.push({
|
|
225
|
+
code: "gitattributes_missing",
|
|
226
|
+
message: ".gitattributes missing",
|
|
227
|
+
severity: "warning",
|
|
228
|
+
});
|
|
229
|
+
if (hygiene.openSource && hyState.LICENSE === "missing")
|
|
230
|
+
quality.push({
|
|
231
|
+
code: "license_missing",
|
|
232
|
+
message: "LICENSE missing (open-source repo)",
|
|
233
|
+
severity: "warning",
|
|
234
|
+
});
|
|
235
|
+
if (hygiene.openSource && hyState["CONTRIBUTING.md"] === "missing")
|
|
236
|
+
quality.push({
|
|
237
|
+
code: "contributing_missing",
|
|
238
|
+
message: "CONTRIBUTING.md missing (open-source repo)",
|
|
239
|
+
severity: "warning",
|
|
240
|
+
});
|
|
154
241
|
return {
|
|
155
242
|
ok: true,
|
|
156
243
|
spec: relSpec,
|
|
@@ -175,12 +262,22 @@ const REQUIRED_SECTIONS = [
|
|
|
175
262
|
"## Acceptance criteria",
|
|
176
263
|
];
|
|
177
264
|
|
|
178
|
-
const UI_KEYWORDS = [
|
|
265
|
+
const UI_KEYWORDS = [
|
|
266
|
+
/\bui\b/,
|
|
267
|
+
/\binterface\b/,
|
|
268
|
+
/\bscreen\b/,
|
|
269
|
+
/\bmodal\b/,
|
|
270
|
+
/\bform\b/,
|
|
271
|
+
/\bcomponent\b/,
|
|
272
|
+
];
|
|
179
273
|
const FLOW_KEYWORDS = [/\bflow\b/, /\bpipeline\b/, /\bsequence\b/, /\bdiagram\b/];
|
|
180
274
|
const GLOSSARY_KEYWORDS = [/\bglossary\b/, /\bcontracts?\b/, /\bscope\b/];
|
|
181
275
|
|
|
182
|
-
const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding =>
|
|
183
|
-
|
|
276
|
+
const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding => ({
|
|
277
|
+
code,
|
|
278
|
+
message,
|
|
279
|
+
severity,
|
|
280
|
+
});
|
|
184
281
|
|
|
185
282
|
// Replace fenced code blocks with a single marker line so their content cannot
|
|
186
283
|
// satisfy the checks, but the fence itself (and its language) stays detectable.
|
|
@@ -212,27 +309,52 @@ export const qualitySpec = (text: string): QualityFinding[] => {
|
|
|
212
309
|
|
|
213
310
|
const hasCa = /^\s*(?:- CA-\d+|CA-\d+[.:])/m.test(body); // M1: ^ with /m covers line starts
|
|
214
311
|
if (!hasCa) {
|
|
215
|
-
findings.push(
|
|
312
|
+
findings.push(
|
|
313
|
+
finding(
|
|
314
|
+
"missing_acceptance_criteria",
|
|
315
|
+
"no enumerable CA-XX acceptance criteria found",
|
|
316
|
+
"hard",
|
|
317
|
+
),
|
|
318
|
+
);
|
|
216
319
|
}
|
|
217
320
|
|
|
218
321
|
const hasAsciiFence = /```(?:text|ascii)/.test(body);
|
|
219
322
|
const mentionsUi = UI_KEYWORDS.some((k) => k.test(lower));
|
|
220
323
|
if (mentionsUi && !hasAsciiFence) {
|
|
221
|
-
findings.push(
|
|
324
|
+
findings.push(
|
|
325
|
+
finding(
|
|
326
|
+
"missing_ascii_for_ui",
|
|
327
|
+
"spec mentions UI but has no ASCII wireframe fence",
|
|
328
|
+
"warning",
|
|
329
|
+
),
|
|
330
|
+
);
|
|
222
331
|
}
|
|
223
332
|
|
|
224
333
|
const hasMermaid = /```mermaid/.test(body);
|
|
225
334
|
const explicitlyNoFlow = /\bno (?:flow|pipeline|sequence|diagram)\b/.test(lower);
|
|
226
335
|
const mentionsFlow = FLOW_KEYWORDS.some((k) => k.test(lower));
|
|
227
336
|
if (mentionsFlow && !hasMermaid && !explicitlyNoFlow) {
|
|
228
|
-
findings.push(
|
|
337
|
+
findings.push(
|
|
338
|
+
finding(
|
|
339
|
+
"missing_mermaid_for_flow",
|
|
340
|
+
"spec describes a flow/pipeline/sequence but has no mermaid fence",
|
|
341
|
+
"warning",
|
|
342
|
+
),
|
|
343
|
+
);
|
|
229
344
|
}
|
|
230
345
|
|
|
231
346
|
const hasTable = /^\s*\|.+\|.+\|/m.test(body);
|
|
232
347
|
const mentionsGlossary = GLOSSARY_KEYWORDS.some((k) => k.test(lower));
|
|
233
|
-
const onlyOutOfScope =
|
|
348
|
+
const onlyOutOfScope =
|
|
349
|
+
/\bout of scope\b/.test(lower) && !/\bglossary\b/.test(lower) && !/\bcontracts?\b/.test(lower);
|
|
234
350
|
if (mentionsGlossary && !hasTable && !onlyOutOfScope) {
|
|
235
|
-
findings.push(
|
|
351
|
+
findings.push(
|
|
352
|
+
finding(
|
|
353
|
+
"missing_table",
|
|
354
|
+
"spec has glossary/contract/scope content but no markdown table",
|
|
355
|
+
"warning",
|
|
356
|
+
),
|
|
357
|
+
);
|
|
236
358
|
}
|
|
237
359
|
|
|
238
360
|
return findings;
|
|
@@ -247,7 +369,11 @@ const sddIgnored = (cwd: string, slug: string): boolean => {
|
|
|
247
369
|
return true; // not a git repo — nothing to be unignored in; validation presupposes a repo
|
|
248
370
|
}
|
|
249
371
|
try {
|
|
250
|
-
execFileSync(
|
|
372
|
+
execFileSync(
|
|
373
|
+
"git",
|
|
374
|
+
["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")],
|
|
375
|
+
{ stdio: "pipe" },
|
|
376
|
+
);
|
|
251
377
|
return true;
|
|
252
378
|
} catch {
|
|
253
379
|
return false;
|
package/src/core/flow-state.ts
CHANGED
|
@@ -104,7 +104,10 @@ export const transitionSpec = (
|
|
|
104
104
|
ok: false,
|
|
105
105
|
error:
|
|
106
106
|
"spec self-review failed: " +
|
|
107
|
-
hard
|
|
107
|
+
hard
|
|
108
|
+
.map((f) => `${f.code} — ${f.message}`)
|
|
109
|
+
.concat(missing)
|
|
110
|
+
.join("; ") +
|
|
108
111
|
" — see templates/spec-template.md for the required structure",
|
|
109
112
|
};
|
|
110
113
|
}
|
|
@@ -150,7 +153,8 @@ export const transitionPlan = (
|
|
|
150
153
|
}
|
|
151
154
|
const missing: string[] = [];
|
|
152
155
|
const stripped = stripFences(text);
|
|
153
|
-
if (parseTasksFromPlan(text).length === 0)
|
|
156
|
+
if (parseTasksFromPlan(text).length === 0)
|
|
157
|
+
missing.push("no ### Task N: sections outside fences");
|
|
154
158
|
if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
|
|
155
159
|
if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
|
|
156
160
|
if (missing.length > 0) {
|
package/src/core/gitignore.ts
CHANGED
|
@@ -25,7 +25,12 @@ export const ensureProjectGitignore = (
|
|
|
25
25
|
if (!confirmed) return { ok: false, error: "confirmed: true required" };
|
|
26
26
|
const file = path.join(workspaceRoot, ".gitignore");
|
|
27
27
|
const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
28
|
-
const existingLines = new Set(
|
|
28
|
+
const existingLines = new Set(
|
|
29
|
+
existing
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((l) => l.trim())
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
);
|
|
29
34
|
const added: string[] = [];
|
|
30
35
|
const append: string[] = [];
|
|
31
36
|
for (const entry of GITIGNORE_ENTRIES) {
|
|
@@ -35,7 +40,11 @@ export const ensureProjectGitignore = (
|
|
|
35
40
|
}
|
|
36
41
|
if (append.length) {
|
|
37
42
|
const separator = existing && !existing.endsWith("\n") ? "\n" : "";
|
|
38
|
-
writeFileSync(
|
|
43
|
+
writeFileSync(
|
|
44
|
+
file,
|
|
45
|
+
existing + separator + (existing ? "\n" : "") + append.join("\n") + "\n",
|
|
46
|
+
"utf8",
|
|
47
|
+
);
|
|
39
48
|
} else if (!existsSync(file)) {
|
|
40
49
|
writeFileSync(file, "", "utf8");
|
|
41
50
|
}
|
|
@@ -25,7 +25,8 @@ const resolveFromMessagePaths = (root: string, message: string): Resolved => {
|
|
|
25
25
|
const paths = extractMessagePaths(message);
|
|
26
26
|
if (paths.length === 0) return { error: "no paths" };
|
|
27
27
|
const slugs = [...new Set(paths.map((p) => p.split("/")[1]))];
|
|
28
|
-
if (slugs.length !== 1)
|
|
28
|
+
if (slugs.length !== 1)
|
|
29
|
+
return { error: "multiple features in message — use exactly one docs/<slug>/ pair" };
|
|
29
30
|
const slug = slugs[0];
|
|
30
31
|
const plan = `docs/${slug}/plan.md`;
|
|
31
32
|
const spec = `docs/${slug}/spec.md`;
|
|
@@ -49,8 +50,15 @@ const resolveActivePair = (root: string): Resolved => {
|
|
|
49
50
|
const plan = path.join("docs", slug, "plan.md");
|
|
50
51
|
const spec = path.join("docs", slug, "spec.md");
|
|
51
52
|
if (!existsSync(path.join(root, plan)) || !existsSync(path.join(root, spec))) continue;
|
|
52
|
-
const score = Math.max(
|
|
53
|
-
|
|
53
|
+
const score = Math.max(
|
|
54
|
+
statSync(path.join(root, spec)).mtimeMs,
|
|
55
|
+
statSync(path.join(root, plan)).mtimeMs,
|
|
56
|
+
);
|
|
57
|
+
if (
|
|
58
|
+
best === null ||
|
|
59
|
+
score > best.score ||
|
|
60
|
+
(score === best.score && slug < best.spec.split("/")[1])
|
|
61
|
+
) {
|
|
54
62
|
best = { score, spec, plan, source: "active_pair" };
|
|
55
63
|
}
|
|
56
64
|
}
|
|
@@ -69,7 +77,10 @@ export const resolveWorkflowPaths = (root: string, message: string): Resolved =>
|
|
|
69
77
|
if (!existsSync(docsDir) || listMd(docsDir).length === 0) {
|
|
70
78
|
return { error: "no docs/<slug>/ features found under docs/" };
|
|
71
79
|
}
|
|
72
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
error:
|
|
82
|
+
"could not resolve spec and plan — mention docs/<slug>/plan.md or create docs/<slug>/{spec.md,plan.md}",
|
|
83
|
+
};
|
|
73
84
|
};
|
|
74
85
|
|
|
75
86
|
export const buildHandoffContract = ({
|
|
@@ -85,7 +96,9 @@ export const buildHandoffContract = ({
|
|
|
85
96
|
}): { prompt: string } | { error: string } => {
|
|
86
97
|
const validated = docsValidate({ spec_path: spec, plan_path: plan, workspace_root: root });
|
|
87
98
|
if (validated.ok === false) {
|
|
88
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
error: `docs validation failed\n${JSON.stringify({ ok: false, errors: validated.errors })}`,
|
|
101
|
+
};
|
|
89
102
|
}
|
|
90
103
|
const branchResolved = resolveBranch({ spec_path: spec, plan_path: plan, workspace_root: root });
|
|
91
104
|
if ("error" in branchResolved) return { error: branchResolved.error as string };
|
package/src/core/hygiene.ts
CHANGED
|
@@ -3,7 +3,13 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { changelogUnreleasedStats } from "./changelog";
|
|
5
5
|
|
|
6
|
-
export type HygieneFile =
|
|
6
|
+
export type HygieneFile =
|
|
7
|
+
| "CHANGELOG.md"
|
|
8
|
+
| "README.md"
|
|
9
|
+
| ".editorconfig"
|
|
10
|
+
| ".gitattributes"
|
|
11
|
+
| "LICENSE"
|
|
12
|
+
| "CONTRIBUTING.md";
|
|
7
13
|
type State = "missing" | "invalid" | "ok" | "skip";
|
|
8
14
|
|
|
9
15
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
@@ -11,7 +17,11 @@ const templatesDir = () => path.join(repoRoot, "templates", "hygiene");
|
|
|
11
17
|
|
|
12
18
|
const packageJson = (root: string): Record<string, unknown> | null => {
|
|
13
19
|
if (!existsSync(path.join(root, "package.json"))) return null;
|
|
14
|
-
try {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
15
25
|
};
|
|
16
26
|
|
|
17
27
|
const isOpenSource = (root: string): boolean => {
|
|
@@ -34,15 +44,27 @@ const licenseHolder = (root: string): string => {
|
|
|
34
44
|
return "";
|
|
35
45
|
};
|
|
36
46
|
|
|
37
|
-
export const hygieneFiles = (
|
|
47
|
+
export const hygieneFiles = (
|
|
48
|
+
root: string,
|
|
49
|
+
): { state: Record<HygieneFile, State>; openSource: boolean } => {
|
|
38
50
|
const openSource = isOpenSource(root);
|
|
39
51
|
const state = {} as Record<HygieneFile, State>;
|
|
40
|
-
for (const file of [
|
|
52
|
+
for (const file of [
|
|
53
|
+
"CHANGELOG.md",
|
|
54
|
+
"README.md",
|
|
55
|
+
".editorconfig",
|
|
56
|
+
".gitattributes",
|
|
57
|
+
"LICENSE",
|
|
58
|
+
"CONTRIBUTING.md",
|
|
59
|
+
] as HygieneFile[]) {
|
|
41
60
|
if (file === "LICENSE" || file === "CONTRIBUTING.md") {
|
|
42
61
|
state[file] = openSource ? (existsSync(path.join(root, file)) ? "ok" : "missing") : "skip";
|
|
43
62
|
continue;
|
|
44
63
|
}
|
|
45
|
-
if (!existsSync(path.join(root, file))) {
|
|
64
|
+
if (!existsSync(path.join(root, file))) {
|
|
65
|
+
state[file] = "missing";
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
46
68
|
if (file === "CHANGELOG.md") {
|
|
47
69
|
const stats = changelogUnreleasedStats(root);
|
|
48
70
|
state[file] = stats.exists && stats.has_unreleased ? "ok" : "invalid";
|