@brainervirus/workit-core 0.6.0 → 0.7.0
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/package.json +10 -14
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +25 -22
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +166 -57
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +227 -32
- package/src/core/detector.ts +22 -11
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +58 -21
- package/src/core/docs-validate.ts +181 -43
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -137
- package/src/core/git.ts +22 -5
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
- package/src/core/hygiene.ts +53 -17
- package/src/core/init.ts +128 -31
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +4 -2
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +156 -29
- package/src/core/present.ts +11 -2
- package/src/core/registration.ts +215 -0
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tool.ts +4 -1
- package/src/core/repo-tools.ts +23 -0
- package/src/core/rules.ts +10 -7
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -39
- package/src/core/sdd.ts +56 -31
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +16 -6
- package/src/core/vcs-config.ts +191 -62
- package/src/core/verify-parse.ts +4 -2
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +320 -87
- package/src/core.ts +18 -3
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +5 -4
- package/scripts/_shared/common.sh +0 -158
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -42
- package/src/tools/flow.ts +0 -88
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -45
- package/src/tools/repo.ts +0 -357
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -189
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -360
package/src/core/docs-repo.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { configDir } from "./config";
|
|
5
|
+
import { resolveCanonicalLayout } from "./docs-layout";
|
|
5
6
|
|
|
6
7
|
const configPath = () =>
|
|
7
|
-
process.env.WORKFLOW_DOCS_REPO_CONFIG
|
|
8
|
-
?? path.join(configDir(), "docs-repo.json");
|
|
8
|
+
process.env.WORKFLOW_DOCS_REPO_CONFIG ?? path.join(configDir(), "docs-repo.json");
|
|
9
9
|
|
|
10
10
|
export const readDocsRepoConfig = (): { path: string } | null => {
|
|
11
11
|
try {
|
|
@@ -25,7 +25,8 @@ export const writeDocsRepoConfig = (docsPath: string): void => {
|
|
|
25
25
|
export const docsRepoPath = (): string | null => readDocsRepoConfig()?.path ?? null;
|
|
26
26
|
|
|
27
27
|
export const validateDocsRepo = (docsPath: string): { ok: true } | { ok: false; error: string } => {
|
|
28
|
-
if (!existsSync(docsPath))
|
|
28
|
+
if (!existsSync(docsPath))
|
|
29
|
+
return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
|
|
29
30
|
try {
|
|
30
31
|
execFileSync("git", ["-C", docsPath, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
|
|
31
32
|
} catch {
|
|
@@ -49,7 +50,10 @@ export const linkDocsRepo = (
|
|
|
49
50
|
|
|
50
51
|
export const listSpecs = (
|
|
51
52
|
workspaceRoot: string,
|
|
52
|
-
): {
|
|
53
|
+
): {
|
|
54
|
+
docs_repo: string | null;
|
|
55
|
+
specs: { slug: string; spec: string; promoted: boolean; target: string | null }[];
|
|
56
|
+
} => {
|
|
53
57
|
const repoPath = docsRepoPath();
|
|
54
58
|
const specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] = [];
|
|
55
59
|
const docsDir = path.join(workspaceRoot, "docs");
|
|
@@ -63,7 +67,9 @@ export const listSpecs = (
|
|
|
63
67
|
if (repoPath) {
|
|
64
68
|
const featuresDir = path.join(repoPath, "features");
|
|
65
69
|
if (existsSync(featuresDir)) {
|
|
66
|
-
const match = readdirSync(featuresDir).find((d) =>
|
|
70
|
+
const match = readdirSync(featuresDir).find((d) =>
|
|
71
|
+
new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d),
|
|
72
|
+
);
|
|
67
73
|
if (match) {
|
|
68
74
|
promoted = true;
|
|
69
75
|
target = path.join(repoPath, "features", match);
|
|
@@ -84,13 +90,20 @@ const monthPrefix = () => {
|
|
|
84
90
|
};
|
|
85
91
|
|
|
86
92
|
const readSafe = (p: string): string | null => {
|
|
87
|
-
try {
|
|
93
|
+
try {
|
|
94
|
+
return readFileSync(p, "utf8");
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
88
98
|
};
|
|
89
99
|
|
|
90
100
|
const specSummary = (specText: string): string => {
|
|
91
|
-
const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n##
|
|
101
|
+
const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |Z)/);
|
|
92
102
|
if (!contextMatch) return "";
|
|
93
|
-
const first = contextMatch[1]
|
|
103
|
+
const first = contextMatch[1]
|
|
104
|
+
.trim()
|
|
105
|
+
.split("\n")
|
|
106
|
+
.find((l) => l.trim() && !l.startsWith("<!--"));
|
|
94
107
|
return (first ?? "").trim();
|
|
95
108
|
};
|
|
96
109
|
|
|
@@ -99,46 +112,68 @@ const specRepos = (specText: string): string => {
|
|
|
99
112
|
return match?.[1]?.trim() ?? "—";
|
|
100
113
|
};
|
|
101
114
|
|
|
102
|
-
const SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
103
|
-
|
|
104
115
|
export const promoteSpec = (
|
|
105
116
|
workspaceRoot: string,
|
|
106
117
|
slug: string,
|
|
107
118
|
opts: { confirmed: boolean; force?: boolean },
|
|
108
|
-
):
|
|
119
|
+
):
|
|
120
|
+
| { ok: true; target_dir: string; files: string[]; index_updated: boolean }
|
|
109
121
|
| { ok: false; error: string; findings?: unknown[] } => {
|
|
110
122
|
if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
|
|
111
|
-
|
|
123
|
+
// One shared contained path contract (DC-01, DC-02): invalid slugs and
|
|
124
|
+
// non-canonical workspace roots fail here before any read or write.
|
|
125
|
+
const resolved = resolveCanonicalLayout({ workspace_root: workspaceRoot, slug });
|
|
126
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
127
|
+
const workspaceRootCanonical = resolved.layout.workspace;
|
|
112
128
|
const repoPath = docsRepoPath();
|
|
113
129
|
if (!repoPath) return { ok: false, error: "docs repo not linked — run workflow_docs_repo_link" };
|
|
114
130
|
const repoValid = validateDocsRepo(repoPath);
|
|
115
131
|
if (!repoValid.ok) return { ok: false, error: repoValid.error };
|
|
116
|
-
|
|
117
132
|
const specRel = path.posix.join("docs", slug, "spec.md");
|
|
118
133
|
const planRel = path.posix.join("docs", slug, "plan.md");
|
|
119
|
-
const specText = readSafe(
|
|
134
|
+
const specText = readSafe(resolved.layout.spec);
|
|
120
135
|
if (specText === null) return { ok: false, error: `docs/${slug}/spec.md not found` };
|
|
121
136
|
|
|
122
|
-
const planText = readSafe(
|
|
137
|
+
const planText = readSafe(resolved.layout.plan);
|
|
123
138
|
if (planText !== null) {
|
|
124
|
-
const validated = docsValidate({
|
|
139
|
+
const validated = docsValidate({
|
|
140
|
+
spec_path: specRel,
|
|
141
|
+
plan_path: planRel,
|
|
142
|
+
workspace_root: workspaceRootCanonical,
|
|
143
|
+
});
|
|
125
144
|
if (validated.ok === false) return { ok: false, error: validated.error };
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
const findings = qualitySpec(specText);
|
|
129
148
|
const hardFindings = findings.filter((f) => f.severity === "hard");
|
|
130
149
|
if (hardFindings.length > 0 && !opts.force) {
|
|
131
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: "spec has hard quality findings; pass force: true to override",
|
|
153
|
+
findings,
|
|
154
|
+
};
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
// SDD working state must be gitignored before promotion
|
|
135
158
|
if (!opts.force) {
|
|
136
|
-
const sddDir = path.join(
|
|
159
|
+
const sddDir = path.join(workspaceRootCanonical, "docs", slug, "sdd");
|
|
137
160
|
if (existsSync(sddDir)) {
|
|
138
161
|
try {
|
|
139
|
-
execFileSync(
|
|
162
|
+
execFileSync(
|
|
163
|
+
"git",
|
|
164
|
+
[
|
|
165
|
+
"-C",
|
|
166
|
+
workspaceRootCanonical,
|
|
167
|
+
"check-ignore",
|
|
168
|
+
path.posix.join("docs", slug, "sdd", "progress.md"),
|
|
169
|
+
],
|
|
170
|
+
{ stdio: "pipe" },
|
|
171
|
+
);
|
|
140
172
|
} catch {
|
|
141
|
-
return {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true`,
|
|
176
|
+
};
|
|
142
177
|
}
|
|
143
178
|
}
|
|
144
179
|
}
|
|
@@ -179,7 +214,9 @@ ${planText !== null ? "| [plan.md](./plan.md) | Plan de implementación |\n" : "
|
|
|
179
214
|
files.push("README.md");
|
|
180
215
|
|
|
181
216
|
const indexPath = path.join(repoPath, "features", "README.md");
|
|
182
|
-
const indexText =
|
|
217
|
+
const indexText =
|
|
218
|
+
readSafe(indexPath) ??
|
|
219
|
+
`# Features\n\nEspecificaciones y planes por feature.\n\n## Features documentadas\n\n| Feature | Repos afectados | Estado |\n| --- | --- | --- |\n`;
|
|
183
220
|
const row = `| [${slug}](./${prefix}-${slug}/) | ${specRepos(specText)} | Spec en revisión |`;
|
|
184
221
|
const rowRe = new RegExp(`^\\| \\[${slug}\\]\\([^)]*\\) \\|.*$`, "m");
|
|
185
222
|
let newIndex: string;
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { hygieneFiles } from "./hygiene";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { resolveCanonicalLayout, resolveDocsPath } from "./docs-layout";
|
|
5
6
|
|
|
6
7
|
export type DocError = { code: string; message: string; path?: string };
|
|
7
8
|
|
|
@@ -15,17 +16,20 @@ const err = (code: string, message: string, path?: string): DocError => {
|
|
|
15
16
|
return item;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
const failValidate = (
|
|
19
|
-
errors: DocError[],
|
|
20
|
-
): { ok: false; errors: DocError[]; error: string } => ({
|
|
19
|
+
const failValidate = (errors: DocError[]): { ok: false; errors: DocError[]; error: string } => ({
|
|
21
20
|
ok: false,
|
|
22
21
|
errors,
|
|
23
|
-
error:
|
|
22
|
+
error:
|
|
23
|
+
errors
|
|
24
|
+
.map((e) => e.message)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join("; ") || "docs validation failed",
|
|
24
27
|
});
|
|
25
28
|
|
|
26
29
|
const readBranch = (text: string, label: string): [string | null, DocError | null] => {
|
|
27
30
|
const match = text.match(BRANCH_RE);
|
|
28
|
-
if (!match)
|
|
31
|
+
if (!match)
|
|
32
|
+
return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
|
|
29
33
|
return [match[1].trim().replace(/`/g, ""), null];
|
|
30
34
|
};
|
|
31
35
|
|
|
@@ -34,37 +38,55 @@ const scanTaskHeadings = (planText: string): [number[], string[], DocError | nul
|
|
|
34
38
|
const titles: string[] = [];
|
|
35
39
|
let inFence = false;
|
|
36
40
|
for (const line of planText.split("\n")) {
|
|
37
|
-
if (line.startsWith("```")) {
|
|
41
|
+
if (line.startsWith("```")) {
|
|
42
|
+
inFence = !inFence;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
38
45
|
if (inFence) continue;
|
|
39
46
|
const match = line.match(TASK_RE);
|
|
40
|
-
if (match) {
|
|
47
|
+
if (match) {
|
|
48
|
+
ids.push(Number(match[1]));
|
|
49
|
+
titles.push(match[2].trim());
|
|
50
|
+
}
|
|
41
51
|
}
|
|
42
|
-
if (ids.length === 0)
|
|
52
|
+
if (ids.length === 0)
|
|
53
|
+
return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
|
|
43
54
|
const expected = ids.map((_, i) => i + 1);
|
|
44
55
|
const sorted = [...ids].sort((a, b) => a - b);
|
|
45
56
|
if (JSON.stringify(sorted) !== JSON.stringify(expected) || new Set(ids).size !== ids.length) {
|
|
46
|
-
return [
|
|
57
|
+
return [
|
|
58
|
+
ids,
|
|
59
|
+
titles,
|
|
60
|
+
err("task_order", `task headings must be contiguous from 1..${ids.length}; found ${ids}`),
|
|
61
|
+
];
|
|
47
62
|
}
|
|
48
63
|
return [ids, titles, null];
|
|
49
64
|
};
|
|
50
65
|
|
|
51
66
|
// Port of scripts/lib/parse-plan-tasks.sh (JSON mode)
|
|
52
|
-
export const parseTasksFromPlan = (
|
|
67
|
+
export const parseTasksFromPlan = (
|
|
68
|
+
planText: string,
|
|
69
|
+
): { id: number; title: string; section_text: string }[] => {
|
|
53
70
|
const tasks: { id: number; title: string; section_text: string }[] = [];
|
|
54
71
|
let current: { id: number; title: string; body: string[] } | null = null;
|
|
55
72
|
let inFence = false;
|
|
56
73
|
for (const line of planText.split("\n")) {
|
|
57
|
-
if (line.startsWith("```")) {
|
|
74
|
+
if (line.startsWith("```")) {
|
|
75
|
+
inFence = !inFence;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
58
78
|
if (inFence) continue;
|
|
59
79
|
const match = line.match(TASK_RE);
|
|
60
80
|
if (match) {
|
|
61
|
-
if (current)
|
|
81
|
+
if (current)
|
|
82
|
+
tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
62
83
|
current = { id: Number(match[1]), title: match[2].trim(), body: [] };
|
|
63
84
|
} else if (current) {
|
|
64
85
|
current.body.push(line);
|
|
65
86
|
}
|
|
66
87
|
}
|
|
67
|
-
if (current)
|
|
88
|
+
if (current)
|
|
89
|
+
tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
68
90
|
return tasks;
|
|
69
91
|
};
|
|
70
92
|
|
|
@@ -77,21 +99,40 @@ export const docsValidate = ({
|
|
|
77
99
|
plan_path: string;
|
|
78
100
|
workspace_root: string;
|
|
79
101
|
}):
|
|
80
|
-
| {
|
|
102
|
+
| {
|
|
103
|
+
ok: true;
|
|
104
|
+
spec: string;
|
|
105
|
+
plan: string;
|
|
106
|
+
branch: string;
|
|
107
|
+
task_count: number;
|
|
108
|
+
quality: QualityFinding[];
|
|
109
|
+
}
|
|
81
110
|
| { ok: false; errors: DocError[]; error: string } => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
111
|
+
// One shared contained path contract (DC-01, DC-02): absolute/traversal/
|
|
112
|
+
// symlink-escape/cross-slug/wrong-basename/legacy paths all fail here before
|
|
113
|
+
// any document bytes are read.
|
|
114
|
+
const resolved = resolveCanonicalLayout({ workspace_root, spec_path, plan_path });
|
|
115
|
+
if (!resolved.ok) return failValidate([err("path_contract", resolved.error)]);
|
|
116
|
+
const cwd = resolved.layout.workspace;
|
|
117
|
+
const specAbs = resolved.layout.spec;
|
|
118
|
+
const planAbs = resolved.layout.plan;
|
|
119
|
+
const slug = resolved.layout.slug;
|
|
85
120
|
const errors: DocError[] = [];
|
|
86
121
|
|
|
87
122
|
const read = (p: string): string | null => {
|
|
88
|
-
try {
|
|
123
|
+
try {
|
|
124
|
+
return readFileSync(p, "utf8");
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
89
128
|
};
|
|
90
129
|
|
|
91
130
|
const specText = read(specAbs);
|
|
92
131
|
const planText = read(planAbs);
|
|
93
|
-
if (specText === null)
|
|
94
|
-
|
|
132
|
+
if (specText === null)
|
|
133
|
+
errors.push(err("missing_file", `spec not found: ${spec_path}`, spec_path));
|
|
134
|
+
if (planText === null)
|
|
135
|
+
errors.push(err("missing_file", `plan not found: ${plan_path}`, plan_path));
|
|
95
136
|
if (errors.length) return failValidate(errors);
|
|
96
137
|
|
|
97
138
|
const [specBranch, specErr] = readBranch(specText!, "spec");
|
|
@@ -104,14 +145,32 @@ export const docsValidate = ({
|
|
|
104
145
|
errors.push(err("missing_spec_link", "**Spec:** link required in plan", plan_path));
|
|
105
146
|
} else {
|
|
106
147
|
const linked = (linkMatch[1] ?? linkMatch[2] ?? "").trim();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
148
|
+
// The plan link is a path inside the workspace docs tree: canonicalize it
|
|
149
|
+
// under the same contained contract before comparing to the canonical spec.
|
|
150
|
+
const linkedResolved = resolveDocsPath({ workspace_root: cwd, path: linked });
|
|
151
|
+
if (!linkedResolved.ok) {
|
|
152
|
+
errors.push(
|
|
153
|
+
err("spec_link_escape", `plan **Spec:** ${linked} is not a contained docs path`, plan_path),
|
|
154
|
+
);
|
|
155
|
+
} else if (linkedResolved.path !== specAbs) {
|
|
156
|
+
errors.push(
|
|
157
|
+
err(
|
|
158
|
+
"spec_mismatch",
|
|
159
|
+
`plan **Spec:** ${linked} does not match spec_path ${spec_path}`,
|
|
160
|
+
plan_path,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
110
163
|
}
|
|
111
164
|
}
|
|
112
165
|
|
|
113
166
|
if (specBranch && planBranch && specBranch !== planBranch) {
|
|
114
|
-
errors.push(
|
|
167
|
+
errors.push(
|
|
168
|
+
err(
|
|
169
|
+
"branch_mismatch",
|
|
170
|
+
`spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`,
|
|
171
|
+
plan_path,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
115
174
|
}
|
|
116
175
|
|
|
117
176
|
const [, , taskErr] = scanTaskHeadings(planText!);
|
|
@@ -123,10 +182,19 @@ export const docsValidate = ({
|
|
|
123
182
|
const [headingIds, headingTitles, headingErr] = scanTaskHeadings(planText!);
|
|
124
183
|
if (headingErr) return failValidate([headingErr]);
|
|
125
184
|
if (tasks.length !== headingIds.length) {
|
|
126
|
-
return failValidate([
|
|
185
|
+
return failValidate([
|
|
186
|
+
err(
|
|
187
|
+
"task_order",
|
|
188
|
+
`parse count ${tasks.length} != heading count ${headingIds.length}`,
|
|
189
|
+
plan_path,
|
|
190
|
+
),
|
|
191
|
+
]);
|
|
127
192
|
}
|
|
128
193
|
for (let i = 0; i < tasks.length; i++) {
|
|
129
|
-
if (
|
|
194
|
+
if (
|
|
195
|
+
String(tasks[i].id) !== String(headingIds[i]) ||
|
|
196
|
+
tasks[i].title.trim() !== headingTitles[i]
|
|
197
|
+
) {
|
|
130
198
|
return failValidate([err("task_order", `task mismatch at position ${i + 1}`, plan_path)]);
|
|
131
199
|
}
|
|
132
200
|
}
|
|
@@ -134,7 +202,6 @@ export const docsValidate = ({
|
|
|
134
202
|
const relSpec = path.isAbsolute(spec_path) ? path.relative(cwd, specAbs) : spec_path;
|
|
135
203
|
const relPlan = path.isAbsolute(plan_path) ? path.relative(cwd, planAbs) : plan_path;
|
|
136
204
|
const quality = qualitySpec(specText!);
|
|
137
|
-
const slug = path.basename(path.dirname(spec_path));
|
|
138
205
|
if (!sddIgnored(cwd, slug)) {
|
|
139
206
|
quality.push({
|
|
140
207
|
code: "sdd_not_ignored",
|
|
@@ -144,13 +211,45 @@ export const docsValidate = ({
|
|
|
144
211
|
}
|
|
145
212
|
const hygiene = hygieneFiles(cwd);
|
|
146
213
|
const hyState = hygiene.state;
|
|
147
|
-
if (hyState["CHANGELOG.md"] === "missing")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
214
|
+
if (hyState["CHANGELOG.md"] === "missing")
|
|
215
|
+
quality.push({
|
|
216
|
+
code: "changelog_missing",
|
|
217
|
+
message:
|
|
218
|
+
"CHANGELOG.md missing — create it with Keep a Changelog format (run wk-init hygiene)",
|
|
219
|
+
severity: "warning",
|
|
220
|
+
});
|
|
221
|
+
if (hyState["CHANGELOG.md"] === "invalid")
|
|
222
|
+
quality.push({
|
|
223
|
+
code: "changelog_invalid_format",
|
|
224
|
+
message: "CHANGELOG.md lacks ## [Unreleased] — Keep a Changelog format required",
|
|
225
|
+
severity: "warning",
|
|
226
|
+
});
|
|
227
|
+
if (hyState["README.md"] === "missing")
|
|
228
|
+
quality.push({ code: "readme_missing", message: "README.md missing", severity: "warning" });
|
|
229
|
+
if (hyState[".editorconfig"] === "missing")
|
|
230
|
+
quality.push({
|
|
231
|
+
code: "editorconfig_missing",
|
|
232
|
+
message: ".editorconfig missing",
|
|
233
|
+
severity: "warning",
|
|
234
|
+
});
|
|
235
|
+
if (hyState[".gitattributes"] === "missing")
|
|
236
|
+
quality.push({
|
|
237
|
+
code: "gitattributes_missing",
|
|
238
|
+
message: ".gitattributes missing",
|
|
239
|
+
severity: "warning",
|
|
240
|
+
});
|
|
241
|
+
if (hygiene.openSource && hyState.LICENSE === "missing")
|
|
242
|
+
quality.push({
|
|
243
|
+
code: "license_missing",
|
|
244
|
+
message: "LICENSE missing (open-source repo)",
|
|
245
|
+
severity: "warning",
|
|
246
|
+
});
|
|
247
|
+
if (hygiene.openSource && hyState["CONTRIBUTING.md"] === "missing")
|
|
248
|
+
quality.push({
|
|
249
|
+
code: "contributing_missing",
|
|
250
|
+
message: "CONTRIBUTING.md missing (open-source repo)",
|
|
251
|
+
severity: "warning",
|
|
252
|
+
});
|
|
154
253
|
return {
|
|
155
254
|
ok: true,
|
|
156
255
|
spec: relSpec,
|
|
@@ -175,12 +274,22 @@ const REQUIRED_SECTIONS = [
|
|
|
175
274
|
"## Acceptance criteria",
|
|
176
275
|
];
|
|
177
276
|
|
|
178
|
-
const UI_KEYWORDS = [
|
|
277
|
+
const UI_KEYWORDS = [
|
|
278
|
+
/\bui\b/,
|
|
279
|
+
/\binterface\b/,
|
|
280
|
+
/\bscreen\b/,
|
|
281
|
+
/\bmodal\b/,
|
|
282
|
+
/\bform\b/,
|
|
283
|
+
/\bcomponent\b/,
|
|
284
|
+
];
|
|
179
285
|
const FLOW_KEYWORDS = [/\bflow\b/, /\bpipeline\b/, /\bsequence\b/, /\bdiagram\b/];
|
|
180
286
|
const GLOSSARY_KEYWORDS = [/\bglossary\b/, /\bcontracts?\b/, /\bscope\b/];
|
|
181
287
|
|
|
182
|
-
const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding =>
|
|
183
|
-
|
|
288
|
+
const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding => ({
|
|
289
|
+
code,
|
|
290
|
+
message,
|
|
291
|
+
severity,
|
|
292
|
+
});
|
|
184
293
|
|
|
185
294
|
// Replace fenced code blocks with a single marker line so their content cannot
|
|
186
295
|
// satisfy the checks, but the fence itself (and its language) stays detectable.
|
|
@@ -212,27 +321,52 @@ export const qualitySpec = (text: string): QualityFinding[] => {
|
|
|
212
321
|
|
|
213
322
|
const hasCa = /^\s*(?:- CA-\d+|CA-\d+[.:])/m.test(body); // M1: ^ with /m covers line starts
|
|
214
323
|
if (!hasCa) {
|
|
215
|
-
findings.push(
|
|
324
|
+
findings.push(
|
|
325
|
+
finding(
|
|
326
|
+
"missing_acceptance_criteria",
|
|
327
|
+
"no enumerable CA-XX acceptance criteria found",
|
|
328
|
+
"hard",
|
|
329
|
+
),
|
|
330
|
+
);
|
|
216
331
|
}
|
|
217
332
|
|
|
218
333
|
const hasAsciiFence = /```(?:text|ascii)/.test(body);
|
|
219
334
|
const mentionsUi = UI_KEYWORDS.some((k) => k.test(lower));
|
|
220
335
|
if (mentionsUi && !hasAsciiFence) {
|
|
221
|
-
findings.push(
|
|
336
|
+
findings.push(
|
|
337
|
+
finding(
|
|
338
|
+
"missing_ascii_for_ui",
|
|
339
|
+
"spec mentions UI but has no ASCII wireframe fence",
|
|
340
|
+
"warning",
|
|
341
|
+
),
|
|
342
|
+
);
|
|
222
343
|
}
|
|
223
344
|
|
|
224
345
|
const hasMermaid = /```mermaid/.test(body);
|
|
225
346
|
const explicitlyNoFlow = /\bno (?:flow|pipeline|sequence|diagram)\b/.test(lower);
|
|
226
347
|
const mentionsFlow = FLOW_KEYWORDS.some((k) => k.test(lower));
|
|
227
348
|
if (mentionsFlow && !hasMermaid && !explicitlyNoFlow) {
|
|
228
|
-
findings.push(
|
|
349
|
+
findings.push(
|
|
350
|
+
finding(
|
|
351
|
+
"missing_mermaid_for_flow",
|
|
352
|
+
"spec describes a flow/pipeline/sequence but has no mermaid fence",
|
|
353
|
+
"warning",
|
|
354
|
+
),
|
|
355
|
+
);
|
|
229
356
|
}
|
|
230
357
|
|
|
231
358
|
const hasTable = /^\s*\|.+\|.+\|/m.test(body);
|
|
232
359
|
const mentionsGlossary = GLOSSARY_KEYWORDS.some((k) => k.test(lower));
|
|
233
|
-
const onlyOutOfScope =
|
|
360
|
+
const onlyOutOfScope =
|
|
361
|
+
/\bout of scope\b/.test(lower) && !/\bglossary\b/.test(lower) && !/\bcontracts?\b/.test(lower);
|
|
234
362
|
if (mentionsGlossary && !hasTable && !onlyOutOfScope) {
|
|
235
|
-
findings.push(
|
|
363
|
+
findings.push(
|
|
364
|
+
finding(
|
|
365
|
+
"missing_table",
|
|
366
|
+
"spec has glossary/contract/scope content but no markdown table",
|
|
367
|
+
"warning",
|
|
368
|
+
),
|
|
369
|
+
);
|
|
236
370
|
}
|
|
237
371
|
|
|
238
372
|
return findings;
|
|
@@ -247,7 +381,11 @@ const sddIgnored = (cwd: string, slug: string): boolean => {
|
|
|
247
381
|
return true; // not a git repo — nothing to be unignored in; validation presupposes a repo
|
|
248
382
|
}
|
|
249
383
|
try {
|
|
250
|
-
execFileSync(
|
|
384
|
+
execFileSync(
|
|
385
|
+
"git",
|
|
386
|
+
["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")],
|
|
387
|
+
{ stdio: "pipe" },
|
|
388
|
+
);
|
|
251
389
|
return true;
|
|
252
390
|
} catch {
|
|
253
391
|
return false;
|