@brainervirus/workit-core 2.0.2 → 2.1.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 +2 -2
- package/package.json +2 -3
- package/scripts/sync-runtime.sh +9 -5
- package/src/core/detect-hosts.ts +24 -22
- package/src/core/doctor.ts +11 -4
- package/src/core/host-install.ts +458 -0
- package/src/core/init.ts +1 -256
- package/src/core/policy-resolver.ts +0 -45
- package/src/core/registration.ts +3 -4
- package/src/core/repo-context.ts +1 -1
- package/src/core/rules.ts +1 -61
- package/src/core/setup.ts +203 -11
- package/src/core/templates.ts +1 -26
- package/src/core/vcs-config.ts +2 -8
- package/src/core/workspaces.ts +18 -17
- package/src/core/config-guard.ts +0 -27
- package/src/core/doc-render.ts +0 -14
- package/src/core/docs-layout.ts +0 -279
- package/src/core/docs-migration.ts +0 -639
- package/src/core/docs-repo.ts +0 -233
- package/src/core/docs-validate.ts +0 -393
- package/src/core/parse-sections.ts +0 -24
- package/src/core/ports/init-apply.ts +0 -15
- package/src/core/ports/init-status.ts +0 -4
- package/src/core/ports/init-toolkit-status.ts +0 -4
- package/src/core/ports/pr-create.ts +0 -30
- package/src/core/ports/present-ascii.ts +0 -10
- package/src/core/ports/present-flow.ts +0 -10
- package/src/core/ports/vcs-config.ts +0 -14
- package/src/core/ports/vcs-merged-style.ts +0 -5
- package/src/core/ports/vcs-verify-token.ts +0 -4
- package/src/core/ports/youtrack-api.ts +0 -19
- package/src/core/ports/youtrack-config.ts +0 -21
- package/src/core/ports/youtrack-greeting.ts +0 -10
- package/src/core/ports/youtrack-parse-duration.ts +0 -14
- package/src/core/ports/youtrack-token-create-url.ts +0 -4
- package/src/core/ports/youtrack-verify-token.ts +0 -12
- package/src/core/ports/youtrack-work-date-ms.ts +0 -10
- package/src/core/present.ts +0 -109
- package/src/core/repo-tool.ts +0 -12
- package/src/core/repo-tools.ts +0 -22
- package/src/core/sync-runtime.ts +0 -375
- package/src/core/verify-parse.ts +0 -29
- package/src/core/verify-project.ts +0 -181
package/src/core/docs-repo.ts
DELETED
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { configDir } from "./config";
|
|
5
|
-
import { resolveCanonicalLayout } from "./docs-layout";
|
|
6
|
-
|
|
7
|
-
const configPath = () =>
|
|
8
|
-
process.env.WORKFLOW_DOCS_REPO_CONFIG ?? path.join(configDir(), "docs-repo.json");
|
|
9
|
-
|
|
10
|
-
export const readDocsRepoConfig = (): { path: string } | null => {
|
|
11
|
-
try {
|
|
12
|
-
const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as { path?: string };
|
|
13
|
-
return parsed.path ? { path: parsed.path } : null;
|
|
14
|
-
} catch {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export const writeDocsRepoConfig = (docsPath: string): void => {
|
|
20
|
-
const file = configPath();
|
|
21
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
-
writeFileSync(file, JSON.stringify({ path: docsPath }, null, 2) + "\n", "utf8");
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const docsRepoPath = (): string | null => readDocsRepoConfig()?.path ?? null;
|
|
26
|
-
|
|
27
|
-
export const validateDocsRepo = (docsPath: string): { ok: true } | { ok: false; error: string } => {
|
|
28
|
-
if (!existsSync(docsPath))
|
|
29
|
-
return { ok: false, error: `docs repo path does not exist: ${docsPath}` };
|
|
30
|
-
try {
|
|
31
|
-
execFileSync("git", ["-C", docsPath, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
|
|
32
|
-
} catch {
|
|
33
|
-
return { ok: false, error: `docs repo is not a git repository: ${docsPath}` };
|
|
34
|
-
}
|
|
35
|
-
const featuresDir = path.join(docsPath, "features");
|
|
36
|
-
if (!existsSync(featuresDir)) mkdirSync(featuresDir, { recursive: true });
|
|
37
|
-
return { ok: true };
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export const linkDocsRepo = (
|
|
41
|
-
docsPath: string,
|
|
42
|
-
confirmed: boolean,
|
|
43
|
-
): { ok: true; path: string } | { ok: false; error: string } => {
|
|
44
|
-
if (!confirmed) return { ok: false, error: "confirmed: true required" };
|
|
45
|
-
const valid = validateDocsRepo(docsPath);
|
|
46
|
-
if (!valid.ok) return valid;
|
|
47
|
-
writeDocsRepoConfig(docsPath);
|
|
48
|
-
return { ok: true, path: docsPath };
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export const listSpecs = (
|
|
52
|
-
workspaceRoot: string,
|
|
53
|
-
): {
|
|
54
|
-
docs_repo: string | null;
|
|
55
|
-
specs: { slug: string; spec: string; promoted: boolean; target: string | null }[];
|
|
56
|
-
} => {
|
|
57
|
-
const repoPath = docsRepoPath();
|
|
58
|
-
const specs: { slug: string; spec: string; promoted: boolean; target: string | null }[] = [];
|
|
59
|
-
const docsDir = path.join(workspaceRoot, "docs");
|
|
60
|
-
if (existsSync(docsDir)) {
|
|
61
|
-
for (const slug of readdirSync(docsDir)) {
|
|
62
|
-
if (slug.startsWith(".")) continue;
|
|
63
|
-
const spec = path.posix.join("docs", slug, "spec.md");
|
|
64
|
-
if (!existsSync(path.join(workspaceRoot, spec))) continue;
|
|
65
|
-
let promoted = false;
|
|
66
|
-
let target: string | null = null;
|
|
67
|
-
if (repoPath) {
|
|
68
|
-
const featuresDir = path.join(repoPath, "features");
|
|
69
|
-
if (existsSync(featuresDir)) {
|
|
70
|
-
const match = readdirSync(featuresDir).find((d) =>
|
|
71
|
-
new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d),
|
|
72
|
-
);
|
|
73
|
-
if (match) {
|
|
74
|
-
promoted = true;
|
|
75
|
-
target = path.join(repoPath, "features", match);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
specs.push({ slug, spec, promoted, target });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return { docs_repo: repoPath, specs };
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
import { docsValidate, qualitySpec } from "./docs-validate";
|
|
86
|
-
|
|
87
|
-
const monthPrefix = () => {
|
|
88
|
-
const now = new Date();
|
|
89
|
-
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const readSafe = (p: string): string | null => {
|
|
93
|
-
try {
|
|
94
|
-
return readFileSync(p, "utf8");
|
|
95
|
-
} catch {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const specSummary = (specText: string): string => {
|
|
101
|
-
const contextMatch = specText.match(/## Context\n\n([\s\S]*?)(?=\n## |Z)/);
|
|
102
|
-
if (!contextMatch) return "";
|
|
103
|
-
const first = contextMatch[1]
|
|
104
|
-
.trim()
|
|
105
|
-
.split("\n")
|
|
106
|
-
.find((l) => l.trim() && !l.startsWith("<!--"));
|
|
107
|
-
return (first ?? "").trim();
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const specRepos = (specText: string): string => {
|
|
111
|
-
const match = specText.match(/^\*\*Repos:\*\*\s*(.+)$/m);
|
|
112
|
-
return match?.[1]?.trim() ?? "—";
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
export const promoteSpec = (
|
|
116
|
-
workspaceRoot: string,
|
|
117
|
-
slug: string,
|
|
118
|
-
opts: { confirmed: boolean; force?: boolean },
|
|
119
|
-
):
|
|
120
|
-
| { ok: true; target_dir: string; files: string[]; index_updated: boolean }
|
|
121
|
-
| { ok: false; error: string; findings?: unknown[] } => {
|
|
122
|
-
if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
|
|
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;
|
|
128
|
-
const repoPath = docsRepoPath();
|
|
129
|
-
if (!repoPath) return { ok: false, error: "docs repo not linked — run workit_docs_repo_link" };
|
|
130
|
-
const repoValid = validateDocsRepo(repoPath);
|
|
131
|
-
if (!repoValid.ok) return { ok: false, error: repoValid.error };
|
|
132
|
-
const specRel = path.posix.join("docs", slug, "spec.md");
|
|
133
|
-
const planRel = path.posix.join("docs", slug, "plan.md");
|
|
134
|
-
const specText = readSafe(resolved.layout.spec);
|
|
135
|
-
if (specText === null) return { ok: false, error: `docs/${slug}/spec.md not found` };
|
|
136
|
-
|
|
137
|
-
const planText = readSafe(resolved.layout.plan);
|
|
138
|
-
if (planText !== null) {
|
|
139
|
-
const validated = docsValidate({
|
|
140
|
-
spec_path: specRel,
|
|
141
|
-
plan_path: planRel,
|
|
142
|
-
workspace_root: workspaceRootCanonical,
|
|
143
|
-
});
|
|
144
|
-
if (validated.ok === false) return { ok: false, error: validated.error };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const findings = qualitySpec(specText);
|
|
148
|
-
const hardFindings = findings.filter((f) => f.severity === "hard");
|
|
149
|
-
if (hardFindings.length > 0 && !opts.force) {
|
|
150
|
-
return {
|
|
151
|
-
ok: false,
|
|
152
|
-
error: "spec has hard quality findings; pass force: true to override",
|
|
153
|
-
findings,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// SDD working state must be gitignored before promotion
|
|
158
|
-
if (!opts.force) {
|
|
159
|
-
const sddDir = path.join(workspaceRootCanonical, "docs", slug, "sdd");
|
|
160
|
-
if (existsSync(sddDir)) {
|
|
161
|
-
try {
|
|
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
|
-
);
|
|
172
|
-
} catch {
|
|
173
|
-
return {
|
|
174
|
-
ok: false,
|
|
175
|
-
error: `docs/${slug}/sdd/ is not gitignored — add 'docs/*/sdd/' to .gitignore or pass force: true`,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const prefix = monthPrefix();
|
|
182
|
-
const featuresDir = path.join(repoPath, "features");
|
|
183
|
-
const existing = existsSync(featuresDir)
|
|
184
|
-
? readdirSync(featuresDir).find((d) => new RegExp(`^20\\d{2}-\\d{2}-${slug}$`).test(d))
|
|
185
|
-
: undefined;
|
|
186
|
-
const targetDir = path.join(featuresDir, existing ?? `${prefix}-${slug}`);
|
|
187
|
-
mkdirSync(targetDir, { recursive: true });
|
|
188
|
-
|
|
189
|
-
const files: string[] = ["spec.md"];
|
|
190
|
-
writeFileSync(path.join(targetDir, "spec.md"), specText, "utf8");
|
|
191
|
-
if (planText !== null) {
|
|
192
|
-
writeFileSync(path.join(targetDir, "plan.md"), planText, "utf8");
|
|
193
|
-
files.push("plan.md");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const title = specText.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? slug;
|
|
197
|
-
const readme = `# Feature: ${title}
|
|
198
|
-
|
|
199
|
-
**Fecha:** ${prefix}
|
|
200
|
-
**Estado:** Spec en revisión
|
|
201
|
-
**Repos afectados:** ${specRepos(specText)}
|
|
202
|
-
|
|
203
|
-
## Resumen
|
|
204
|
-
|
|
205
|
-
${specSummary(specText)}
|
|
206
|
-
|
|
207
|
-
## Documentación
|
|
208
|
-
|
|
209
|
-
| Documento | Contenido |
|
|
210
|
-
| --- | --- |
|
|
211
|
-
| [spec.md](./spec.md) | Especificación completa |
|
|
212
|
-
${planText !== null ? "| [plan.md](./plan.md) | Plan de implementación |\n" : ""}`;
|
|
213
|
-
writeFileSync(path.join(targetDir, "README.md"), readme, "utf8");
|
|
214
|
-
files.push("README.md");
|
|
215
|
-
|
|
216
|
-
const indexPath = path.join(repoPath, "features", "README.md");
|
|
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`;
|
|
220
|
-
const row = `| [${slug}](./${prefix}-${slug}/) | ${specRepos(specText)} | Spec en revisión |`;
|
|
221
|
-
const rowRe = new RegExp(`^\\| \\[${slug}\\]\\([^)]*\\) \\|.*$`, "m");
|
|
222
|
-
let newIndex: string;
|
|
223
|
-
if (rowRe.test(indexText)) {
|
|
224
|
-
newIndex = indexText.replace(rowRe, row);
|
|
225
|
-
} else if (/^(\| --- \| --- \| --- \|\n)/m.test(indexText)) {
|
|
226
|
-
newIndex = indexText.replace(/^(\| --- \| --- \| --- \|\n)/m, `$1${row}\n`);
|
|
227
|
-
} else {
|
|
228
|
-
newIndex = indexText + `\n${row}\n`;
|
|
229
|
-
}
|
|
230
|
-
writeFileSync(indexPath, newIndex, "utf8");
|
|
231
|
-
|
|
232
|
-
return { ok: true, target_dir: targetDir, files, index_updated: true };
|
|
233
|
-
};
|
|
@@ -1,393 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { hygieneFiles } from "./hygiene";
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { resolveCanonicalLayout, resolveDocsPath } from "./docs-layout";
|
|
6
|
-
|
|
7
|
-
export type DocError = { code: string; message: string; path?: string };
|
|
8
|
-
|
|
9
|
-
const BRANCH_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/im;
|
|
10
|
-
const SPEC_LINK_RE = /^\s*\*+Spec:\*+\s*(?:`([^`]+)`|(\S+))\s*$/im;
|
|
11
|
-
const TASK_RE = /^###\s+Task\s+(\d+):\s*(.*)$/i;
|
|
12
|
-
|
|
13
|
-
const err = (code: string, message: string, path?: string): DocError => {
|
|
14
|
-
const item: DocError = { code, message };
|
|
15
|
-
if (path) item.path = path;
|
|
16
|
-
return item;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const failValidate = (errors: DocError[]): { ok: false; errors: DocError[]; error: string } => ({
|
|
20
|
-
ok: false,
|
|
21
|
-
errors,
|
|
22
|
-
error:
|
|
23
|
-
errors
|
|
24
|
-
.map((e) => e.message)
|
|
25
|
-
.filter(Boolean)
|
|
26
|
-
.join("; ") || "docs validation failed",
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const readBranch = (text: string, label: string): [string | null, DocError | null] => {
|
|
30
|
-
const match = text.match(BRANCH_RE);
|
|
31
|
-
if (!match)
|
|
32
|
-
return [null, err("missing_branch", `**Branch:** feature/* or bugfix/* required in ${label}`)];
|
|
33
|
-
return [match[1].trim().replace(/`/g, ""), null];
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const scanTaskHeadings = (planText: string): [number[], string[], DocError | null] => {
|
|
37
|
-
const ids: number[] = [];
|
|
38
|
-
const titles: string[] = [];
|
|
39
|
-
let inFence = false;
|
|
40
|
-
for (const line of planText.split("\n")) {
|
|
41
|
-
if (line.startsWith("```")) {
|
|
42
|
-
inFence = !inFence;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (inFence) continue;
|
|
46
|
-
const match = line.match(TASK_RE);
|
|
47
|
-
if (match) {
|
|
48
|
-
ids.push(Number(match[1]));
|
|
49
|
-
titles.push(match[2].trim());
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
if (ids.length === 0)
|
|
53
|
-
return [ids, titles, err("task_order", "no ### Task N sections found outside fences")];
|
|
54
|
-
const expected = ids.map((_, i) => i + 1);
|
|
55
|
-
const sorted = [...ids].sort((a, b) => a - b);
|
|
56
|
-
if (JSON.stringify(sorted) !== JSON.stringify(expected) || new Set(ids).size !== ids.length) {
|
|
57
|
-
return [
|
|
58
|
-
ids,
|
|
59
|
-
titles,
|
|
60
|
-
err("task_order", `task headings must be contiguous from 1..${ids.length}; found ${ids}`),
|
|
61
|
-
];
|
|
62
|
-
}
|
|
63
|
-
return [ids, titles, null];
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// Port of scripts/lib/parse-plan-tasks.sh (JSON mode)
|
|
67
|
-
export const parseTasksFromPlan = (
|
|
68
|
-
planText: string,
|
|
69
|
-
): { id: number; title: string; section_text: string }[] => {
|
|
70
|
-
const tasks: { id: number; title: string; section_text: string }[] = [];
|
|
71
|
-
let current: { id: number; title: string; body: string[] } | null = null;
|
|
72
|
-
let inFence = false;
|
|
73
|
-
for (const line of planText.split("\n")) {
|
|
74
|
-
if (line.startsWith("```")) {
|
|
75
|
-
inFence = !inFence;
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (inFence) continue;
|
|
79
|
-
const match = line.match(TASK_RE);
|
|
80
|
-
if (match) {
|
|
81
|
-
if (current)
|
|
82
|
-
tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
83
|
-
current = { id: Number(match[1]), title: match[2].trim(), body: [] };
|
|
84
|
-
} else if (current) {
|
|
85
|
-
current.body.push(line);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if (current)
|
|
89
|
-
tasks.push({ id: current.id, title: current.title, section_text: current.body.join("\n") });
|
|
90
|
-
return tasks;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
export const docsValidate = ({
|
|
94
|
-
spec_path,
|
|
95
|
-
plan_path,
|
|
96
|
-
workspace_root,
|
|
97
|
-
}: {
|
|
98
|
-
spec_path: string;
|
|
99
|
-
plan_path: string;
|
|
100
|
-
workspace_root: string;
|
|
101
|
-
}):
|
|
102
|
-
| {
|
|
103
|
-
ok: true;
|
|
104
|
-
spec: string;
|
|
105
|
-
plan: string;
|
|
106
|
-
branch: string;
|
|
107
|
-
task_count: number;
|
|
108
|
-
quality: QualityFinding[];
|
|
109
|
-
}
|
|
110
|
-
| { ok: false; errors: DocError[]; error: string } => {
|
|
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;
|
|
120
|
-
const errors: DocError[] = [];
|
|
121
|
-
|
|
122
|
-
const read = (p: string): string | null => {
|
|
123
|
-
try {
|
|
124
|
-
return readFileSync(p, "utf8");
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const specText = read(specAbs);
|
|
131
|
-
const planText = read(planAbs);
|
|
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));
|
|
136
|
-
if (errors.length) return failValidate(errors);
|
|
137
|
-
|
|
138
|
-
const [specBranch, specErr] = readBranch(specText!, "spec");
|
|
139
|
-
if (specErr) errors.push(specErr);
|
|
140
|
-
const [planBranch, planErr] = readBranch(planText!, "plan");
|
|
141
|
-
if (planErr) errors.push(planErr);
|
|
142
|
-
|
|
143
|
-
const linkMatch = planText!.match(SPEC_LINK_RE);
|
|
144
|
-
if (!linkMatch) {
|
|
145
|
-
errors.push(err("missing_spec_link", "**Spec:** link required in plan", plan_path));
|
|
146
|
-
} else {
|
|
147
|
-
const linked = (linkMatch[1] ?? linkMatch[2] ?? "").trim();
|
|
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
|
-
);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (specBranch && planBranch && specBranch !== planBranch) {
|
|
167
|
-
errors.push(
|
|
168
|
-
err(
|
|
169
|
-
"branch_mismatch",
|
|
170
|
-
`spec branch ${JSON.stringify(specBranch)} != plan branch ${JSON.stringify(planBranch)}`,
|
|
171
|
-
plan_path,
|
|
172
|
-
),
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const [, , taskErr] = scanTaskHeadings(planText!);
|
|
177
|
-
if (taskErr) errors.push(taskErr);
|
|
178
|
-
|
|
179
|
-
if (errors.length) return failValidate(errors);
|
|
180
|
-
|
|
181
|
-
const tasks = parseTasksFromPlan(planText!);
|
|
182
|
-
const [headingIds, headingTitles, headingErr] = scanTaskHeadings(planText!);
|
|
183
|
-
if (headingErr) return failValidate([headingErr]);
|
|
184
|
-
if (tasks.length !== headingIds.length) {
|
|
185
|
-
return failValidate([
|
|
186
|
-
err(
|
|
187
|
-
"task_order",
|
|
188
|
-
`parse count ${tasks.length} != heading count ${headingIds.length}`,
|
|
189
|
-
plan_path,
|
|
190
|
-
),
|
|
191
|
-
]);
|
|
192
|
-
}
|
|
193
|
-
for (let i = 0; i < tasks.length; i++) {
|
|
194
|
-
if (
|
|
195
|
-
String(tasks[i].id) !== String(headingIds[i]) ||
|
|
196
|
-
tasks[i].title.trim() !== headingTitles[i]
|
|
197
|
-
) {
|
|
198
|
-
return failValidate([err("task_order", `task mismatch at position ${i + 1}`, plan_path)]);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const relSpec = path.isAbsolute(spec_path) ? path.relative(cwd, specAbs) : spec_path;
|
|
203
|
-
const relPlan = path.isAbsolute(plan_path) ? path.relative(cwd, planAbs) : plan_path;
|
|
204
|
-
const quality = qualitySpec(specText!);
|
|
205
|
-
if (!sddIgnored(cwd, slug)) {
|
|
206
|
-
quality.push({
|
|
207
|
-
code: "sdd_not_ignored",
|
|
208
|
-
message: `docs/${slug}/sdd/ exists but is not gitignored — add 'docs/*/sdd/' to .gitignore (or run wk-init)`,
|
|
209
|
-
severity: "hard",
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
const hygiene = hygieneFiles(cwd);
|
|
213
|
-
const hyState = hygiene.state;
|
|
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
|
-
});
|
|
253
|
-
return {
|
|
254
|
-
ok: true,
|
|
255
|
-
spec: relSpec,
|
|
256
|
-
plan: relPlan,
|
|
257
|
-
branch: specBranch!,
|
|
258
|
-
task_count: tasks.length,
|
|
259
|
-
quality,
|
|
260
|
-
};
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
export type QualityFinding = {
|
|
264
|
-
code: string;
|
|
265
|
-
message: string;
|
|
266
|
-
severity: "warning" | "hard";
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const REQUIRED_SECTIONS = [
|
|
270
|
-
"## Context",
|
|
271
|
-
"## Goals",
|
|
272
|
-
"## Non-goals",
|
|
273
|
-
"## Architecture",
|
|
274
|
-
"## Acceptance criteria",
|
|
275
|
-
];
|
|
276
|
-
|
|
277
|
-
const UI_KEYWORDS = [
|
|
278
|
-
/\bui\b/,
|
|
279
|
-
/\binterface\b/,
|
|
280
|
-
/\bscreen\b/,
|
|
281
|
-
/\bmodal\b/,
|
|
282
|
-
/\bform\b/,
|
|
283
|
-
/\bcomponent\b/,
|
|
284
|
-
];
|
|
285
|
-
const FLOW_KEYWORDS = [/\bflow\b/, /\bpipeline\b/, /\bsequence\b/, /\bdiagram\b/];
|
|
286
|
-
const GLOSSARY_KEYWORDS = [/\bglossary\b/, /\bcontracts?\b/, /\bscope\b/];
|
|
287
|
-
|
|
288
|
-
const finding = (code: string, message: string, severity: "warning" | "hard"): QualityFinding => ({
|
|
289
|
-
code,
|
|
290
|
-
message,
|
|
291
|
-
severity,
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
// Replace fenced code blocks with a single marker line so their content cannot
|
|
295
|
-
// satisfy the checks, but the fence itself (and its language) stays detectable.
|
|
296
|
-
export const stripFences = (text: string): string => {
|
|
297
|
-
const lines = text.split("\n");
|
|
298
|
-
const out: string[] = [];
|
|
299
|
-
let inFence = false;
|
|
300
|
-
for (const line of lines) {
|
|
301
|
-
if (line.startsWith("```")) {
|
|
302
|
-
if (!inFence) out.push(line); // opening fence: keep the ```lang marker
|
|
303
|
-
inFence = !inFence;
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
if (!inFence) out.push(line);
|
|
307
|
-
}
|
|
308
|
-
return out.join("\n");
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
export const qualitySpec = (text: string): QualityFinding[] => {
|
|
312
|
-
const findings: QualityFinding[] = [];
|
|
313
|
-
const body = stripFences(text);
|
|
314
|
-
const lower = body.toLowerCase();
|
|
315
|
-
|
|
316
|
-
for (const section of REQUIRED_SECTIONS) {
|
|
317
|
-
if (!body.includes(section)) {
|
|
318
|
-
findings.push(finding("missing_section", `required section ${section} missing`, "hard"));
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const hasCa = /^\s*(?:- CA-\d+|CA-\d+[.:])/m.test(body); // M1: ^ with /m covers line starts
|
|
323
|
-
if (!hasCa) {
|
|
324
|
-
findings.push(
|
|
325
|
-
finding(
|
|
326
|
-
"missing_acceptance_criteria",
|
|
327
|
-
"no enumerable CA-XX acceptance criteria found",
|
|
328
|
-
"hard",
|
|
329
|
-
),
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const hasAsciiFence = /```(?:text|ascii)/.test(body);
|
|
334
|
-
const mentionsUi = UI_KEYWORDS.some((k) => k.test(lower));
|
|
335
|
-
if (mentionsUi && !hasAsciiFence) {
|
|
336
|
-
findings.push(
|
|
337
|
-
finding(
|
|
338
|
-
"missing_ascii_for_ui",
|
|
339
|
-
"spec mentions UI but has no ASCII wireframe fence",
|
|
340
|
-
"warning",
|
|
341
|
-
),
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const hasMermaid = /```mermaid/.test(body);
|
|
346
|
-
const explicitlyNoFlow = /\bno (?:flow|pipeline|sequence|diagram)\b/.test(lower);
|
|
347
|
-
const mentionsFlow = FLOW_KEYWORDS.some((k) => k.test(lower));
|
|
348
|
-
if (mentionsFlow && !hasMermaid && !explicitlyNoFlow) {
|
|
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
|
-
);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const hasTable = /^\s*\|.+\|.+\|/m.test(body);
|
|
359
|
-
const mentionsGlossary = GLOSSARY_KEYWORDS.some((k) => k.test(lower));
|
|
360
|
-
const onlyOutOfScope =
|
|
361
|
-
/\bout of scope\b/.test(lower) && !/\bglossary\b/.test(lower) && !/\bcontracts?\b/.test(lower);
|
|
362
|
-
if (mentionsGlossary && !hasTable && !onlyOutOfScope) {
|
|
363
|
-
findings.push(
|
|
364
|
-
finding(
|
|
365
|
-
"missing_table",
|
|
366
|
-
"spec has glossary/contract/scope content but no markdown table",
|
|
367
|
-
"warning",
|
|
368
|
-
),
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return findings;
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
const sddIgnored = (cwd: string, slug: string): boolean => {
|
|
376
|
-
const sddDir = path.join(cwd, "docs", slug, "sdd");
|
|
377
|
-
if (!existsSync(sddDir)) return true;
|
|
378
|
-
try {
|
|
379
|
-
execFileSync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
|
|
380
|
-
} catch {
|
|
381
|
-
return true; // not a git repo — nothing to be unignored in; validation presupposes a repo
|
|
382
|
-
}
|
|
383
|
-
try {
|
|
384
|
-
execFileSync(
|
|
385
|
-
"git",
|
|
386
|
-
["-C", cwd, "check-ignore", path.posix.join("docs", slug, "sdd", "progress.md")],
|
|
387
|
-
{ stdio: "pipe" },
|
|
388
|
-
);
|
|
389
|
-
return true;
|
|
390
|
-
} catch {
|
|
391
|
-
return false;
|
|
392
|
-
}
|
|
393
|
-
};
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Split script stdout on ## Section headers (print_section output). */
|
|
2
|
-
export function parseSections(stdout: string): Record<string, string> {
|
|
3
|
-
const sections: Record<string, string> = {};
|
|
4
|
-
const parts = stdout.split(/\n## /);
|
|
5
|
-
for (const part of parts.slice(1)) {
|
|
6
|
-
const nl = part.indexOf("\n");
|
|
7
|
-
const title = part.slice(0, nl).trim();
|
|
8
|
-
sections[title] = part.slice(nl + 1).trim();
|
|
9
|
-
}
|
|
10
|
-
return sections;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function parseKeyValueLines(text: string, keys: string[]): Record<string, string> {
|
|
14
|
-
const out: Record<string, string> = {};
|
|
15
|
-
for (const line of text.split("\n")) {
|
|
16
|
-
for (const key of keys) {
|
|
17
|
-
const prefix = `${key}: `;
|
|
18
|
-
if (line.startsWith(prefix)) {
|
|
19
|
-
out[key] = line.slice(prefix.length).trim();
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return out;
|
|
24
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// CLI port of scripts/init/apply.sh.
|
|
2
|
-
import { initApplyData } from "../init";
|
|
3
|
-
|
|
4
|
-
const action = process.argv[2] ?? "";
|
|
5
|
-
const confirmed = process.argv[3] ?? "false";
|
|
6
|
-
if (confirmed !== "true") {
|
|
7
|
-
console.error("ERROR: confirmed=true required to write config");
|
|
8
|
-
process.exit(1);
|
|
9
|
-
}
|
|
10
|
-
const out = initApplyData(action);
|
|
11
|
-
if (out.error) {
|
|
12
|
-
console.error("ERROR: " + out.error);
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
console.log(JSON.stringify(out));
|