@loomhelm/multi-task-flow 0.2.6 → 0.2.7

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.
Files changed (93) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/toolkit/artifact-manifest.json +330 -26
  4. package/toolkit/package.json +1 -1
  5. package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_cli.ts +12 -0
  6. package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_docs.ts +115 -0
  7. package/toolkit/skills/docs-migrate/scripts/_runtime/compatibility_shared.ts +230 -0
  8. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/checks/replay.ts +336 -0
  9. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/git.ts +54 -0
  10. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  11. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  12. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  13. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  14. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  15. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  16. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  17. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  18. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  19. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  20. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  21. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  22. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  23. package/toolkit/skills/docs-migrate/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  24. package/toolkit/skills/docs-migrate/scripts/gen_legacy_spec_id.ts +1 -1
  25. package/toolkit/skills/docs-migrate/scripts/specs_migrate_guard.ts +1 -1
  26. package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_cli.ts +12 -0
  27. package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_docs.ts +115 -0
  28. package/toolkit/skills/find-next-plan/scripts/_runtime/compatibility_shared.ts +230 -0
  29. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/checks/replay.ts +336 -0
  30. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/git.ts +54 -0
  31. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  32. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  33. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  34. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  35. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  36. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  37. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  38. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  39. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  40. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  41. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  42. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  43. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  44. package/toolkit/skills/find-next-plan/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  45. package/toolkit/skills/find-next-plan/scripts/find_next_plan.ts +1 -1
  46. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_cli.ts +12 -0
  47. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_docs.ts +115 -0
  48. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/compatibility_shared.ts +230 -0
  49. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/checks/replay.ts +336 -0
  50. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/git.ts +54 -0
  51. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  52. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  53. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  54. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  55. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  56. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  57. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  58. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  59. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  60. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  61. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  62. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  63. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  64. package/toolkit/skills/hardware-doc-ingest/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  65. package/toolkit/skills/hardware-doc-ingest/scripts/ingest_mineru_extract.ts +1 -1
  66. package/toolkit/skills/hardware-doc-ingest/scripts/verify_doc_locks.ts +1 -1
  67. package/toolkit/skills/multi-task-flow/dist/app.js +66 -66
  68. package/toolkit/skills/multi-task-flow/lib/view.ts +6 -2
  69. package/toolkit/skills/multi-task-flow/web/app.ts +2 -2
  70. package/toolkit/skills/multi-task-flow/web/graph-layout.ts +3 -3
  71. package/toolkit/skills/source-manifest.json +315 -11
  72. package/toolkit/skills/spec-sync/SKILL.md +1 -1
  73. package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_cli.ts +12 -0
  74. package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_docs.ts +115 -0
  75. package/toolkit/skills/spec-sync/scripts/_runtime/compatibility_shared.ts +230 -0
  76. package/toolkit/skills/spec-sync/scripts/_runtime/modules/checks/replay.ts +336 -0
  77. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/git.ts +54 -0
  78. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/hardware_ingest.ts +377 -0
  79. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/hardware_locks.ts +181 -0
  80. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/legacy_spec_id.ts +131 -0
  81. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/migration_common.ts +291 -0
  82. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/next_plan.ts +199 -0
  83. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/next_plan_support.ts +245 -0
  84. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_contract.ts +299 -0
  85. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift.ts +387 -0
  86. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_helpers.ts +381 -0
  87. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_legacy.ts +376 -0
  88. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_paths.ts +3 -0
  89. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_types.ts +27 -0
  90. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_drift_validation.ts +251 -0
  91. package/toolkit/skills/spec-sync/scripts/_runtime/modules/docs/spec_migration.ts +191 -0
  92. package/toolkit/skills/spec-sync/scripts/spec_drift_check.ts +1 -1
  93. package/toolkit/skills/support-manifest.json +77 -1
@@ -0,0 +1,245 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { gitText } from "./git";
4
+
5
+ export const STATUS_READY = "待实现";
6
+ export const STATUS_PARTIAL_PREFIX = "部分完成";
7
+ export const STATUS_PENDING_DESIGN = "待设计";
8
+ export const STATUS_SKIPPED = "跳过";
9
+ export const STATUS_DONE = "已完成";
10
+ export const STATUS_VOID = "作废";
11
+ export const STATUS_REDESIGN_PREFIX = "重新设计";
12
+
13
+ export type Json = Record<string, unknown>;
14
+ export interface Candidate {
15
+ id: string;
16
+ title: string;
17
+ status: string;
18
+ path: string;
19
+ createdAt: string | null;
20
+ rank: number;
21
+ }
22
+
23
+ export function row(line: string): string[] {
24
+ let raw = line.trim();
25
+ if (raw.startsWith("|")) raw = raw.slice(1);
26
+ if (raw.endsWith("|")) raw = raw.slice(0, -1);
27
+ return raw.split("|").map((cell) => cell.trim());
28
+ }
29
+
30
+ export function separator(cells: string[]): boolean {
31
+ return (
32
+ cells.length > 0 &&
33
+ cells.every((cell) => {
34
+ const value = cell.replaceAll(":", "").trim();
35
+ return value === "" || [...value].every((char) => char === "-");
36
+ })
37
+ );
38
+ }
39
+
40
+ export function pathCell(value: string): string | null {
41
+ const text = value.trim();
42
+ const link = /^\s*\[[^\]]+\]\(([^)]+)\)\s*$/.exec(text);
43
+ return link?.[1]?.trim() ?? (text || null);
44
+ }
45
+
46
+ export function table(lines: string[]): {
47
+ columns: Record<string, number>;
48
+ start: number;
49
+ pathLabel: string;
50
+ mode: "status" | "lifecycle";
51
+ } | null {
52
+ for (let index = 0; index + 1 < lines.length; index += 1) {
53
+ if (!lines[index]?.trimStart().startsWith("|")) continue;
54
+ const headers = row(lines[index] as string);
55
+ if (
56
+ !headers.length ||
57
+ separator(headers) ||
58
+ !separator(row(lines[index + 1] as string))
59
+ )
60
+ continue;
61
+ const map: Record<string, number> = {};
62
+ headers.forEach((header, position) => {
63
+ if (header) map[header.toLowerCase()] = position;
64
+ });
65
+ const pathLabel =
66
+ map.spec !== undefined ? "spec" : map.plan !== undefined ? "plan" : null;
67
+ if (!pathLabel) continue;
68
+ if (
69
+ map.id !== undefined &&
70
+ map.title !== undefined &&
71
+ map.status !== undefined
72
+ )
73
+ return {
74
+ columns: {
75
+ id: map.id,
76
+ title: map.title,
77
+ status: map.status,
78
+ path: map[pathLabel] as number,
79
+ },
80
+ start: index + 2,
81
+ pathLabel,
82
+ mode: "status",
83
+ };
84
+ if (
85
+ map.id !== undefined &&
86
+ map.title !== undefined &&
87
+ map.lifecycle !== undefined
88
+ )
89
+ return {
90
+ columns: {
91
+ id: map.id,
92
+ title: map.title,
93
+ lifecycle: map.lifecycle,
94
+ path: map[pathLabel] as number,
95
+ },
96
+ start: index + 2,
97
+ pathLabel,
98
+ mode: "lifecycle",
99
+ };
100
+ if (map.topic !== undefined && map.lifecycle !== undefined)
101
+ return {
102
+ columns: {
103
+ topic: map.topic,
104
+ lifecycle: map.lifecycle,
105
+ path: map[pathLabel] as number,
106
+ },
107
+ start: index + 2,
108
+ pathLabel,
109
+ mode: "lifecycle",
110
+ };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ export function relOrAbs(root: string, path: string): string {
116
+ const relativePath = relative(resolve(root), resolve(path)).replaceAll(
117
+ "\\",
118
+ "/",
119
+ );
120
+ return relativePath.startsWith("../") ? resolve(path) : relativePath;
121
+ }
122
+
123
+ export function detectRoot(
124
+ root: string,
125
+ ): { mode: string; primary: string; fallback: string | null } | null {
126
+ const specs = join(root, "docs/specs");
127
+ const plan = join(root, "docs/plan");
128
+ if (existsSync(specs))
129
+ return {
130
+ mode: "specs",
131
+ primary: specs,
132
+ fallback: existsSync(plan) ? plan : null,
133
+ };
134
+ if (existsSync(plan))
135
+ return { mode: "plan_legacy", primary: plan, fallback: null };
136
+ return null;
137
+ }
138
+
139
+ export function findPaths(root: string, id: string): string[] {
140
+ if (!existsSync(root)) return [];
141
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
+ const directoryPattern = new RegExp(`^${escaped}(?:-|:).+`);
143
+ const matches: string[] = [];
144
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
145
+ if (!entry.isDirectory() || !directoryPattern.test(entry.name)) continue;
146
+ for (const file of ["SPEC.md", "PLAN.md"]) {
147
+ const candidate = join(root, entry.name, file);
148
+ if (existsSync(candidate) && statSync(candidate).isFile())
149
+ matches.push(resolve(candidate));
150
+ }
151
+ }
152
+ return [...new Set(matches)];
153
+ }
154
+
155
+ export function resolveCandidate(
156
+ root: string,
157
+ id: string,
158
+ cell: string,
159
+ primary: string,
160
+ fallback: string | null,
161
+ ): { path: string | null; candidates: string[] } {
162
+ const raw = pathCell(cell);
163
+ if (raw) {
164
+ const direct = resolve(root, raw);
165
+ if (existsSync(direct) && statSync(direct).isFile())
166
+ return {
167
+ path: relOrAbs(root, direct),
168
+ candidates: [relOrAbs(root, direct)],
169
+ };
170
+ }
171
+ const primaryMatches = findPaths(primary, id);
172
+ const specOnly = primaryMatches.filter(
173
+ (path) => basename(path) === "SPEC.md",
174
+ );
175
+ if (primaryMatches.length === 1 || specOnly.length === 1) {
176
+ const selected = (
177
+ primaryMatches.length === 1 ? primaryMatches : specOnly
178
+ )[0] as string;
179
+ return {
180
+ path: relOrAbs(root, selected),
181
+ candidates: [relOrAbs(root, selected)],
182
+ };
183
+ }
184
+ if (primaryMatches.length)
185
+ return {
186
+ path: null,
187
+ candidates: primaryMatches.map((path) => relOrAbs(root, path)),
188
+ };
189
+ const fallbackMatches = fallback ? findPaths(fallback, id) : [];
190
+ const fallbackSpecs = fallbackMatches.filter(
191
+ (path) => basename(path) === "SPEC.md",
192
+ );
193
+ if (fallbackMatches.length === 1 || fallbackSpecs.length === 1) {
194
+ const selected = (
195
+ fallbackMatches.length === 1 ? fallbackMatches : fallbackSpecs
196
+ )[0] as string;
197
+ return {
198
+ path: relOrAbs(root, selected),
199
+ candidates: [relOrAbs(root, selected)],
200
+ };
201
+ }
202
+ return {
203
+ path: null,
204
+ candidates: fallbackMatches.map((path) => relOrAbs(root, path)),
205
+ };
206
+ }
207
+
208
+ export function createdAt(root: string, path: string): string | null {
209
+ const output = gitText(root, [
210
+ "log",
211
+ "--diff-filter=A",
212
+ "--follow",
213
+ "--format=%aI",
214
+ "--",
215
+ path,
216
+ ]);
217
+ if (!output) return null;
218
+ const lines = output.split(/\r?\n/).filter(Boolean);
219
+ return lines[lines.length - 1] ?? null;
220
+ }
221
+
222
+ export function statusRank(status: string): number {
223
+ return status.startsWith(STATUS_PARTIAL_PREFIX)
224
+ ? 0
225
+ : status === STATUS_READY
226
+ ? 1
227
+ : 99;
228
+ }
229
+ export function noPick(statuses: string[]): string {
230
+ if (statuses.some((status) => status.includes(STATUS_PENDING_DESIGN)))
231
+ return "no_implementable_has_pending_design";
232
+ if (statuses.some((status) => status.includes(STATUS_SKIPPED)))
233
+ return "no_implementable_has_skipped";
234
+ if (
235
+ statuses.length > 0 &&
236
+ statuses.every(
237
+ (status) =>
238
+ status === STATUS_DONE ||
239
+ status === STATUS_VOID ||
240
+ status.startsWith(STATUS_REDESIGN_PREFIX),
241
+ )
242
+ )
243
+ return "no_implementable_all_terminal";
244
+ return "no_implementable";
245
+ }
@@ -0,0 +1,299 @@
1
+ import { lstatSync, readFileSync, statSync } from "node:fs";
2
+ import { relative, resolve } from "node:path";
3
+ import { gitText } from "./git";
4
+
5
+ const TOP_LEVEL_RE = /^##\s+(.+?)\s*$/;
6
+ const REQ_RE = /\bREQ-[A-Z0-9][A-Z0-9_-]*\b/g;
7
+ const VER_RE = /\bVER-[A-Z0-9][A-Z0-9_-]*\b/g;
8
+ const NORMATIVE_RE =
9
+ /\b(MUST|SHALL|SHOULD|MAY NOT|MUST NOT)\b|必须|不得|仅可|只能|应当|不可/i;
10
+ const UNRESOLVED_RE =
11
+ /\b(TBD|TBA|TBC|TODO|TO BE DETERMINED|TO BE CONFIRMED|OPEN QUESTION)\b|待确认|待定|未决/i;
12
+ const FORBIDDEN_HEADINGS = new Set([
13
+ "acceptance checklist",
14
+ "quality gates",
15
+ "related prs",
16
+ "related pr",
17
+ "pull requests",
18
+ "pr checklist",
19
+ "related changes",
20
+ "test commands",
21
+ "verification commands",
22
+ "risks",
23
+ "open questions",
24
+ "assumptions",
25
+ "task orchestration",
26
+ "execution steps",
27
+ "current status",
28
+ "milestones",
29
+ "implementation status",
30
+ "delivery status",
31
+ "execution",
32
+ ]);
33
+
34
+ export interface SpecIssue {
35
+ path: string;
36
+ code: string;
37
+ message: string;
38
+ line?: number;
39
+ }
40
+
41
+ function emit(
42
+ path: string,
43
+ code: string,
44
+ message: string,
45
+ line?: number,
46
+ ): SpecIssue {
47
+ return { path, code, message, ...(line === undefined ? {} : { line }) };
48
+ }
49
+
50
+ function repoRelative(path: string, root: string): string | null {
51
+ const rel = relative(resolve(root), resolve(path)).replaceAll("\\", "/");
52
+ return rel === "" || (!rel.startsWith("../") && rel !== "..") ? rel : null;
53
+ }
54
+
55
+ function regularFile(path: string): boolean {
56
+ try {
57
+ return lstatSync(path).isFile();
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function collectIds(text: string, pattern: RegExp): string[] {
64
+ return [...new Set(text.match(pattern) ?? [])].sort();
65
+ }
66
+
67
+ function headingBodies(
68
+ lines: string[],
69
+ ): Map<string, { line: number; body: string[] }> {
70
+ const headings = new Map<string, { line: number; body: string[] }>();
71
+ let current: { name: string; line: number; body: string[] } | undefined;
72
+ for (const [index, line] of lines.entries()) {
73
+ const match = TOP_LEVEL_RE.exec(line);
74
+ if (match?.[1] !== undefined) {
75
+ if (current)
76
+ headings.set(current.name, { line: current.line, body: current.body });
77
+ current = { name: match[1].trim(), line: index + 1, body: [] };
78
+ } else if (current) {
79
+ current.body.push(line);
80
+ }
81
+ }
82
+ if (current)
83
+ headings.set(current.name, { line: current.line, body: current.body });
84
+ return headings;
85
+ }
86
+
87
+ export function repositoryRoot(cwd = process.cwd()): string {
88
+ return gitText(cwd, ["rev-parse", "--show-toplevel"])?.trim() ?? cwd;
89
+ }
90
+
91
+ export function checkSpec(path: string, root: string): SpecIssue[] {
92
+ const absolute = resolve(path);
93
+ const rel = repoRelative(absolute, root);
94
+ if (rel === null) {
95
+ return [
96
+ emit(
97
+ absolute,
98
+ "SPEC_PATH_INVALID",
99
+ "Spec path must remain inside the repository root",
100
+ ),
101
+ ];
102
+ }
103
+ if (!/^docs\/specs\/[a-z0-9]+(?:-[a-z0-9]+)*\/SPEC\.md$/.test(rel)) {
104
+ return [
105
+ emit(
106
+ rel,
107
+ "SPEC_PATH_INVALID",
108
+ "strict checking only applies to canonical docs/specs/<slug>/SPEC.md",
109
+ ),
110
+ ];
111
+ }
112
+ if (!regularFile(absolute)) {
113
+ return [emit(rel, "SPEC_NOT_REGULAR_FILE", "Spec must be a regular file")];
114
+ }
115
+
116
+ const lines = readFileSync(absolute, "utf8").split(/\r?\n/);
117
+ const headings = headingBodies(lines);
118
+ const errors: SpecIssue[] = [];
119
+ for (const required of [
120
+ "Context and Scope",
121
+ "Requirements",
122
+ "Verification",
123
+ "Related ADRs",
124
+ ]) {
125
+ if (!headings.has(required))
126
+ errors.push(
127
+ emit(
128
+ rel,
129
+ "SPEC_SECTION_MISSING",
130
+ `missing required section: ## ${required}`,
131
+ ),
132
+ );
133
+ }
134
+ for (const [heading, value] of headings) {
135
+ const normalized = heading.toLowerCase().trim();
136
+ if (
137
+ [...FORBIDDEN_HEADINGS].some(
138
+ (forbidden) =>
139
+ normalized === forbidden || normalized.startsWith(`${forbidden} `),
140
+ )
141
+ ) {
142
+ errors.push(
143
+ emit(
144
+ rel,
145
+ "SPEC_FORBIDDEN_SECTION",
146
+ `task-state or execution section is not allowed in SPEC.md: ## ${heading}`,
147
+ value.line,
148
+ ),
149
+ );
150
+ }
151
+ }
152
+ for (const [index, line] of lines.entries()) {
153
+ if (/^\s*-\s*\[[ xX]\]/.test(line))
154
+ errors.push(
155
+ emit(
156
+ rel,
157
+ "SPEC_CHECKBOX_FORBIDDEN",
158
+ "SPEC.md cannot contain checkbox progress",
159
+ index + 1,
160
+ ),
161
+ );
162
+ }
163
+
164
+ const requirements = headings.get("Requirements")?.body.join("\n") ?? "";
165
+ const verification = headings.get("Verification")?.body.join("\n") ?? "";
166
+ const reqIds = collectIds(requirements, REQ_RE);
167
+ const verIds = collectIds(verification, VER_RE);
168
+ if (reqIds.length === 0)
169
+ errors.push(
170
+ emit(
171
+ rel,
172
+ "SPEC_REQUIREMENTS_MISSING",
173
+ "Requirements must declare stable REQ-* identifiers",
174
+ ),
175
+ );
176
+ if (verIds.length === 0)
177
+ errors.push(
178
+ emit(
179
+ rel,
180
+ "SPEC_VERIFICATION_MISSING",
181
+ "Verification must declare stable VER-* identifiers",
182
+ ),
183
+ );
184
+ if (requirements && !NORMATIVE_RE.test(requirements))
185
+ errors.push(
186
+ emit(
187
+ rel,
188
+ "SPEC_NORMATIVE_BOUNDARY_MISSING",
189
+ "Requirements must contain normative MUST/SHALL or Chinese normative language",
190
+ ),
191
+ );
192
+ for (const id of reqIds) {
193
+ const pattern = new RegExp(
194
+ `(?:covers?|覆盖)\\s*[:=]\\s*[^\\n]*\\b${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
195
+ "i",
196
+ );
197
+ if (!pattern.test(verification))
198
+ errors.push(
199
+ emit(
200
+ rel,
201
+ "SPEC_TRACEABILITY_MISSING",
202
+ `no VER-* verification explicitly covers ${id}`,
203
+ ),
204
+ );
205
+ }
206
+ for (const id of verIds) {
207
+ if (!new RegExp(`\\b${id}\\b`).test(verification))
208
+ errors.push(
209
+ emit(
210
+ rel,
211
+ "SPEC_VERIFICATION_INVALID",
212
+ `verification id ${id} is not readable`,
213
+ ),
214
+ );
215
+ }
216
+
217
+ const related =
218
+ headings.get("Related ADRs")?.body.filter((line) => line.trim()) ?? [];
219
+ if (headings.has("Related ADRs")) {
220
+ if (related.length === 0)
221
+ errors.push(
222
+ emit(
223
+ rel,
224
+ "SPEC_RELATED_ADRS_INVALID",
225
+ "Related ADRs must be exactly None or contain ADR links",
226
+ ),
227
+ );
228
+ else if (related.length !== 1 || related[0] !== "None") {
229
+ const paths: string[] = [];
230
+ for (const line of related) {
231
+ const match =
232
+ /^-\s+\[[^\]]+\]\(\.\.\/\.\.\/(adr\/[0-9]{4}-[a-z0-9]+(?:-[a-z0-9]+)*\.md)\)$/.exec(
233
+ line,
234
+ );
235
+ if (!match?.[1]) {
236
+ errors.push(
237
+ emit(
238
+ rel,
239
+ "SPEC_RELATED_ADRS_INVALID",
240
+ `invalid Related ADRs entry: ${line}`,
241
+ ),
242
+ );
243
+ continue;
244
+ }
245
+ const adr = `docs/${match[1]}`;
246
+ paths.push(adr);
247
+ try {
248
+ if (!statSync(resolve(root, adr)).isFile())
249
+ errors.push(
250
+ emit(
251
+ rel,
252
+ "SPEC_RELATED_ADRS_INVALID",
253
+ `missing ADR target: ${adr}`,
254
+ ),
255
+ );
256
+ } catch {
257
+ errors.push(
258
+ emit(
259
+ rel,
260
+ "SPEC_RELATED_ADRS_INVALID",
261
+ `missing ADR target: ${adr}`,
262
+ ),
263
+ );
264
+ }
265
+ }
266
+ if (paths.join("\n") !== [...new Set(paths)].sort().join("\n"))
267
+ errors.push(
268
+ emit(
269
+ rel,
270
+ "SPEC_RELATED_ADRS_INVALID",
271
+ "Related ADRs must be path-sorted and duplicate-free",
272
+ ),
273
+ );
274
+ }
275
+ }
276
+ for (const [index, line] of lines.entries()) {
277
+ if (
278
+ UNRESOLVED_RE.test(line) &&
279
+ !line.trimStart().startsWith(">") &&
280
+ !line.trimStart().startsWith("<!--")
281
+ )
282
+ errors.push(
283
+ emit(
284
+ rel,
285
+ "SPEC_UNRESOLVED_DECISION",
286
+ "unresolved decision markers do not belong in canonical SPEC.md",
287
+ index + 1,
288
+ ),
289
+ );
290
+ }
291
+ return errors;
292
+ }
293
+
294
+ export function checkSpecs(
295
+ paths: string[],
296
+ root = repositoryRoot(),
297
+ ): SpecIssue[] {
298
+ return paths.flatMap((path) => checkSpec(resolve(root, path), root));
299
+ }