@namewta/speculo 0.3.0 → 0.3.2

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 (149) hide show
  1. package/README.md +1 -2
  2. package/dist/src/cli.js +40 -6
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/index.js +5 -0
  5. package/dist/src/index.js.map +1 -1
  6. package/dist/src/skills-mirror.d.ts +38 -0
  7. package/dist/src/skills-mirror.js +160 -0
  8. package/dist/src/skills-mirror.js.map +1 -0
  9. package/package.json +3 -2
  10. package/template/canonical/README.md +7 -1
  11. package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +2040 -0
  12. package/template/canonical/canonical-specdev-goal-plan.md +1379 -0
  13. package/template/canonical/canonical-specdev-grill-with-docs.md +848 -285
  14. package/template/canonical/canonical-specdev-spec.md +1061 -46
  15. package/template/canonical/canonical-specdev-tickets.md +1529 -175
  16. package/template/canonical/canonical-specdev-wayfinder.md +677 -107
  17. package/template/commands/git-repository-audit.md +682 -0
  18. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +69 -36
  19. package/template/workflows/specdev/A-archive-and-consolidate/archive-checklist.md +15 -0
  20. package/template/workflows/specdev/A-archive-and-consolidate/knowledge-promotion-rules.md +32 -0
  21. package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +51 -51
  22. package/template/workflows/specdev/D-diagnose-bugs/diagnosis-template.md +64 -0
  23. package/template/workflows/specdev/E-engineering-cognitive-mentor/E-engineering-cognitive-mentor.md +252 -0
  24. package/template/workflows/specdev/E-engineering-cognitive-mentor/architecture-guidance.md +90 -0
  25. package/template/workflows/specdev/E-engineering-cognitive-mentor/bug-guidance.md +80 -0
  26. package/template/workflows/specdev/E-engineering-cognitive-mentor/codebase-guidance.md +107 -0
  27. package/template/workflows/specdev/E-engineering-cognitive-mentor/comprehension-and-closure.md +95 -0
  28. package/template/workflows/specdev/E-engineering-cognitive-mentor/domain-learning-guidance.md +62 -0
  29. package/template/workflows/specdev/E-engineering-cognitive-mentor/evidence-and-options.md +132 -0
  30. package/template/workflows/specdev/E-engineering-cognitive-mentor/interaction-protocol.md +116 -0
  31. package/template/workflows/specdev/E-engineering-cognitive-mentor/mentor-report-template.md +135 -0
  32. package/template/workflows/specdev/E-engineering-cognitive-mentor/mode-routing.md +47 -0
  33. package/template/workflows/specdev/E-engineering-cognitive-mentor/persistence-and-resume.md +147 -0
  34. package/template/workflows/specdev/E-engineering-cognitive-mentor/requirements-guidance.md +92 -0
  35. package/template/workflows/specdev/G-grill-with-docs/G-grill-with-docs.md +100 -30
  36. package/template/workflows/specdev/G-grill-with-docs/adr-format.md +22 -77
  37. package/template/workflows/specdev/G-grill-with-docs/context-format.md +27 -53
  38. package/template/workflows/specdev/G-grill-with-docs/domain-modeling-rules.md +6 -82
  39. package/template/workflows/specdev/G-grill-with-docs/grilling-protocol.md +32 -49
  40. package/template/workflows/specdev/G-grill-with-docs/log-format.md +16 -98
  41. package/template/workflows/specdev/I-implement/I-implement.md +168 -52
  42. package/template/workflows/specdev/I-implement/code-review-process.md +10 -76
  43. package/template/workflows/specdev/I-implement/codebase-design-glossary.md +12 -109
  44. package/template/workflows/specdev/I-implement/deepening.md +12 -32
  45. package/template/workflows/specdev/I-implement/design-it-twice.md +6 -41
  46. package/template/workflows/specdev/I-implement/evidence-template.md +69 -0
  47. package/template/workflows/specdev/I-implement/execution-preflight.md +20 -0
  48. package/template/workflows/specdev/I-implement/tdd-examples.md +10 -135
  49. package/template/workflows/specdev/I-implement/tdd-rules.md +12 -28
  50. package/template/workflows/specdev/I-init-setup/I-init-setup.md +81 -86
  51. package/template/workflows/specdev/I-init-setup/change-status-template.json +15 -0
  52. package/template/workflows/specdev/I-init-setup/config-template.json +26 -0
  53. package/template/workflows/specdev/I-init-setup/domain-layout-template.md +23 -0
  54. package/template/workflows/specdev/I-init-setup/status-labels-template.md +55 -0
  55. package/template/workflows/specdev/I-init-setup/status-template.json +7 -0
  56. package/template/workflows/specdev/I-init-setup/tracking-template.md +10 -0
  57. package/template/workflows/specdev/INDEX.md +165 -82
  58. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +108 -44
  59. package/template/workflows/specdev/P-goal-plan/completion-control.md +79 -0
  60. package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +105 -0
  61. package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +115 -0
  62. package/template/workflows/specdev/P-goal-plan/planning-modes.md +70 -0
  63. package/template/workflows/specdev/R-review-architecture/R-review-architecture.md +103 -40
  64. package/template/workflows/specdev/R-review-architecture/architecture-review-report-template.html +58 -0
  65. package/template/workflows/specdev/R-review-architecture/architecture-review-template.md +68 -0
  66. package/template/workflows/specdev/R-review-architecture/proposal-to-ticket.md +11 -0
  67. package/template/workflows/specdev/S-spec/S-spec.md +103 -49
  68. package/template/workflows/specdev/S-spec/spec-readiness.md +16 -0
  69. package/template/workflows/specdev/S-spec/spec-template.md +95 -0
  70. package/template/workflows/specdev/T-tickets/T-tickets.md +146 -133
  71. package/template/workflows/specdev/T-tickets/decomposition-rules.md +56 -0
  72. package/template/workflows/specdev/T-tickets/ticket-readiness.md +45 -0
  73. package/template/workflows/specdev/T-tickets/ticket-template.md +124 -0
  74. package/template/workflows/specdev/T-tickets/tickets-map-template.md +52 -50
  75. package/template/workflows/specdev/T-triage/T-triage.md +32 -63
  76. package/template/workflows/specdev/T-triage/triage-template.md +29 -0
  77. package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +88 -155
  78. package/template/workflows/specdev/W-wayfinder/investigation-ticket-template.md +50 -0
  79. package/template/workflows/specdev/W-wayfinder/wayfinder-map-template.md +46 -0
  80. package/template/workflows/specdev/_state/status.json +1 -1
  81. package/template/workflows/specdev/common/README.md +47 -0
  82. package/template/workflows/specdev/common/rules/artifact-contract.md +57 -0
  83. package/template/workflows/specdev/common/rules/code-commenting-rule.md +39 -0
  84. package/template/workflows/specdev/common/rules/deviation-control.md +43 -0
  85. package/template/workflows/specdev/common/rules/evidence-and-verification.md +57 -0
  86. package/template/workflows/specdev/common/rules/path-ownership.md +35 -0
  87. package/template/workflows/specdev/common/rules/path-reference-contract.md +116 -0
  88. package/template/workflows/specdev/common/rules/planning-principles.md +57 -0
  89. package/template/workflows/specdev/common/rules/readiness-and-depth.md +51 -0
  90. package/template/workflows/specdev/common/schemas/change-status.schema.json +170 -0
  91. package/template/workflows/specdev/common/schemas/config.schema.json +54 -0
  92. package/template/workflows/specdev/common/schemas/goal-plan.schema.json +21 -0
  93. package/template/workflows/specdev/common/schemas/spec.schema.json +16 -0
  94. package/template/workflows/specdev/common/schemas/status.schema.json +149 -0
  95. package/template/workflows/specdev/common/schemas/ticket.schema.json +130 -0
  96. package/template/workflows/specdev/common/schemas/tickets-map.schema.json +14 -0
  97. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +28 -0
  98. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +30 -0
  99. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +16 -0
  100. package/template/workflows/specdev/common/skills/research/SKILL.md +43 -0
  101. package/template/workflows/specdev/common/tools/README.md +16 -0
  102. package/template/workflows/specdev/common/tools/validate-specdev.mjs +1155 -0
  103. package/template/canonical/canonical-teach.md +0 -301
  104. package/template/workflows/specdev/A-archive-and-consolidate/archive-rules.md +0 -49
  105. package/template/workflows/specdev/A-archive-and-consolidate/cleanup-rules.md +0 -80
  106. package/template/workflows/specdev/A-archive-and-consolidate/consolidation-rules.md +0 -122
  107. package/template/workflows/specdev/A-archive-and-consolidate/discrimination-guide.md +0 -96
  108. package/template/workflows/specdev/A-archive-and-consolidate/knowledge-graduation.md +0 -51
  109. package/template/workflows/specdev/D-diagnose-bugs/cleanup-postmortem.md +0 -37
  110. package/template/workflows/specdev/D-diagnose-bugs/feedback-loop-techniques.md +0 -84
  111. package/template/workflows/specdev/D-diagnose-bugs/hypothesis-format.md +0 -46
  112. package/template/workflows/specdev/D-diagnose-bugs/instrumentation-rules.md +0 -51
  113. package/template/workflows/specdev/I-init-setup/domain-layout.md +0 -55
  114. package/template/workflows/specdev/I-init-setup/status-labels.md +0 -53
  115. package/template/workflows/specdev/I-init-setup/tracking-convention.md +0 -52
  116. package/template/workflows/specdev/P-goal-plan/execution-sections.md +0 -126
  117. package/template/workflows/specdev/P-goal-plan/governance-sections.md +0 -103
  118. package/template/workflows/specdev/P-goal-plan/input-validation.md +0 -94
  119. package/template/workflows/specdev/P-goal-plan/lead-orchestration-protocol.md +0 -158
  120. package/template/workflows/specdev/P-goal-plan/quick-reference-table.md +0 -60
  121. package/template/workflows/specdev/P-goal-plan/vision-sections.md +0 -80
  122. package/template/workflows/specdev/R-review-architecture/exploration-guide.md +0 -103
  123. package/template/workflows/specdev/R-review-architecture/html-report-template.md +0 -124
  124. package/template/workflows/specdev/T-triage/artifact-templates.md +0 -122
  125. package/template/workflows/specdev/T-triage/intake-rules.md +0 -71
  126. package/template/workflows/specdev/T-triage/routing-rules.md +0 -70
  127. package/template/workflows/specdev/T-triage/understanding-rules.md +0 -102
  128. package/template/workflows/specdev/_state/adr/.gitkeep +0 -0
  129. package/template/workflows/specdev/_state/context/.gitkeep +0 -0
  130. package/template/workflows/specdev/_state/research/.gitkeep +0 -0
  131. package/template/workflows/specdev/common/dev-worktree/SKILL.md +0 -48
  132. package/template/workflows/specdev/common/dev-worktree/references/create.md +0 -63
  133. package/template/workflows/specdev/common/dev-worktree/references/finalize.md +0 -102
  134. package/template/workflows/specdev/common/handoff/SKILL.md +0 -42
  135. package/template/workflows/specdev/common/neat-freak/SKILL.md +0 -210
  136. package/template/workflows/specdev/common/neat-freak/references/agent-paths.md +0 -72
  137. package/template/workflows/specdev/common/neat-freak/references/governance.md +0 -88
  138. package/template/workflows/specdev/common/neat-freak/references/sync-matrix.md +0 -77
  139. package/template/workflows/specdev/common/neat-freak/references/verification.md +0 -92
  140. package/template/workflows/specdev/common/neat-freak/scripts/audit-inventory.sh +0 -106
  141. package/template/workflows/specdev/common/prototype/LOGIC.md +0 -89
  142. package/template/workflows/specdev/common/prototype/SKILL.md +0 -78
  143. package/template/workflows/specdev/common/prototype/UI.md +0 -120
  144. package/template/workflows/specdev/common/research/SKILL.md +0 -54
  145. package/template/workflows/specdev/common/resolving-merge-conflicts/SKILL.md +0 -14
  146. package/template/workflows/specdev/common/scripts/hitl-loop.template.sh +0 -41
  147. package/template/workflows/specdev/common/triage/AGENT-BRIEF.md +0 -204
  148. package/template/workflows/specdev/common/triage/OUT-OF-SCOPE.md +0 -104
  149. package/template/workflows/specdev/common/triage/SKILL.md +0 -112
@@ -0,0 +1,1155 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Validate the SpecDev workflow package and SpecDev change artifacts.
5
+ *
6
+ * The validator is dependency-free and runs on the Node.js runtime required by
7
+ * Speculo. It checks canonical <Path> references, package layout, artifact
8
+ * frontmatter, Ticket readiness, traceability, DAG validity, path ownership,
9
+ * Goal Plan completeness, and Evidence presence.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ statSync,
17
+ } from "node:fs";
18
+ import { dirname, basename, extname, join, relative, resolve, sep } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const SCHEMA_VERSION = 3;
22
+ const WORKFLOW_PREFIX = "{roots.workflows}/specdev/";
23
+ const STATE_PREFIX = "{roots.state}/specdev/";
24
+
25
+ const EXPECTED_WORKS = new Set([
26
+ "A-archive-and-consolidate",
27
+ "D-diagnose-bugs",
28
+ "E-engineering-cognitive-mentor",
29
+ "G-grill-with-docs",
30
+ "I-implement",
31
+ "I-init-setup",
32
+ "P-goal-plan",
33
+ "R-review-architecture",
34
+ "S-spec",
35
+ "T-tickets",
36
+ "T-triage",
37
+ "W-wayfinder",
38
+ ]);
39
+ const EXPECTED_COMMON_DIRS = new Set(["rules", "schemas", "skills", "tools"]);
40
+ const VALID_TICKET_STATUS = new Set([
41
+ "draft",
42
+ "ready",
43
+ "in_progress",
44
+ "blocked",
45
+ "review",
46
+ "done",
47
+ "deviated",
48
+ "cancelled",
49
+ ]);
50
+ const VALID_DEPTH = new Set(["lite", "standard", "deep"]);
51
+ const VALID_RISK = new Set(["low", "medium", "high", "critical"]);
52
+ const VALID_PLAN_MODES = new Set([
53
+ "coordination",
54
+ "migration",
55
+ "high-assurance",
56
+ "reference-conformance",
57
+ "release-coordination",
58
+ ]);
59
+ const REQUIRED_TICKET_KEYS = new Set([
60
+ "schema_version",
61
+ "artifact",
62
+ "change",
63
+ "id",
64
+ "title",
65
+ "status",
66
+ "planning_depth",
67
+ "planning_depth_reason",
68
+ "ready",
69
+ "risk",
70
+ "blocked_by",
71
+ "contract_ids",
72
+ "owner",
73
+ "expected_changes",
74
+ "writable_paths",
75
+ "read_only_paths",
76
+ "shared_paths",
77
+ "shared_path_owners",
78
+ ]);
79
+ const REQUIRED_READY_TICKET_SECTIONS = new Set([
80
+ "## 1. 战略与来源",
81
+ "## 2. 决策状态",
82
+ "## 3. 范围边界",
83
+ "## 4. 要构建什么",
84
+ "## 7. 路径访问契约",
85
+ "## 8. 验证矩阵",
86
+ "## 10. 验收标准",
87
+ ]);
88
+ const REQUIRED_GOAL_PLAN_SECTIONS = new Set([
89
+ "## 1. Outcome and Authority",
90
+ "## 2. Execution Graph",
91
+ "## 3. Gates and Completion Evidence",
92
+ "## 4. Execution and Integration Protocol",
93
+ "## 5. Constraints, Risk and Recovery",
94
+ "## 6. Progress and Decisions",
95
+ ]);
96
+ const STATE_ARTIFACT_BASENAMES = new Set([
97
+ "spec.md",
98
+ "tickets-map.md",
99
+ "goal-plan.md",
100
+ "ADR.md",
101
+ "CONTEXT.md",
102
+ "LOG.md",
103
+ "status.json",
104
+ ".status.json",
105
+ "triage.md",
106
+ "diagnosis.md",
107
+ "source-issue.md",
108
+ "architecture-review.md",
109
+ "architecture-review.html",
110
+ "wayfinder-map.md",
111
+ ]);
112
+ const FORBIDDEN_OBSOLETE_BASENAMES = new Set([
113
+ "input-validation.md",
114
+ "vision-sections.md",
115
+ "execution-sections.md",
116
+ "governance-sections.md",
117
+ "lead-orchestration-protocol.md",
118
+ "quick-reference-table.md",
119
+ ]);
120
+
121
+ const PATH_TAG_RE = /<Path>(.*?)<\/Path>/gs;
122
+ const URL_TAG_RE = /<Url>(.*?)<\/Url>/gs;
123
+ const RELATIVE_MD_LINK_RE = /\[[^\]]*\]\((?!https?:\/\/|mailto:|#)([^)]+)\)/g;
124
+ const ABSOLUTE_MACHINE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\/Users\/|\/home\/|\/mnt\/|\/tmp\/)/;
125
+ const PLACEHOLDER_RE = /\{(?:change|ticket-file|investigation-id|[^}]+)\}|<[^>]+>|YYYY-MM|NN-|\.\.\.|\*|\?/;
126
+
127
+ function toPosix(value) {
128
+ return value.split(sep).join("/");
129
+ }
130
+
131
+ function readText(path) {
132
+ return readFileSync(path, "utf8");
133
+ }
134
+
135
+ function isFile(path) {
136
+ return existsSync(path) && statSync(path).isFile();
137
+ }
138
+
139
+ function isDirectory(path) {
140
+ return existsSync(path) && statSync(path).isDirectory();
141
+ }
142
+
143
+ function walk(root) {
144
+ if (!isDirectory(root)) return [];
145
+ const paths = [];
146
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
147
+ const path = join(root, entry.name);
148
+ paths.push(path);
149
+ if (entry.isDirectory()) paths.push(...walk(path));
150
+ }
151
+ return paths;
152
+ }
153
+
154
+ function parseScalar(raw) {
155
+ const value = raw.trim();
156
+ if (value === "true" || value === "True") return true;
157
+ if (value === "false" || value === "False") return false;
158
+ if (value === "null" || value === "Null" || value === "~") return null;
159
+ if (/^-?\d+$/.test(value)) return Number(value);
160
+ if (
161
+ (value.startsWith('"') && value.endsWith('"')) ||
162
+ (value.startsWith("'") && value.endsWith("'"))
163
+ ) {
164
+ return value.slice(1, -1);
165
+ }
166
+ if (value.startsWith("[") && value.endsWith("]")) {
167
+ const inner = value.slice(1, -1).trim();
168
+ if (!inner) return [];
169
+ return inner.split(",").map((item) => parseScalar(item));
170
+ }
171
+ if (value.startsWith("{") && value.endsWith("}")) {
172
+ try {
173
+ return JSON.parse(value);
174
+ } catch {
175
+ return value;
176
+ }
177
+ }
178
+ return value;
179
+ }
180
+
181
+ function parseFrontmatter(path) {
182
+ const text = readText(path);
183
+ const lines = text.split(/\r?\n/);
184
+ if (lines[0]?.trim() !== "---") return { meta: {}, body: text };
185
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
186
+ if (end < 0) return { meta: {}, body: text };
187
+
188
+ const meta = {};
189
+ let currentListKey = null;
190
+ for (const line of lines.slice(1, end)) {
191
+ const stripped = line.trim();
192
+ if (!stripped || stripped.startsWith("#")) continue;
193
+ if (currentListKey && /^\s+-\s+/.test(line)) {
194
+ meta[currentListKey].push(parseScalar(line.replace(/^\s+-\s+/, "")));
195
+ continue;
196
+ }
197
+ currentListKey = null;
198
+ const colon = line.indexOf(":");
199
+ if (colon < 0) continue;
200
+ const key = line.slice(0, colon).trim();
201
+ const raw = line.slice(colon + 1).trim();
202
+ if (!raw) {
203
+ meta[key] = [];
204
+ currentListKey = key;
205
+ } else {
206
+ meta[key] = parseScalar(raw);
207
+ }
208
+ }
209
+ return { meta, body: lines.slice(end + 1).join("\n") };
210
+ }
211
+
212
+ function escapeRegExp(value) {
213
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
214
+ }
215
+
216
+ function sectionBody(body, heading) {
217
+ const match = new RegExp(`^${escapeRegExp(heading)}\\s*$`, "m").exec(body);
218
+ if (!match) return "";
219
+ const tail = body.slice(match.index + match[0].length);
220
+ const next = /^##\s+/m.exec(tail);
221
+ return (next ? tail.slice(0, next.index) : tail).trim();
222
+ }
223
+
224
+ function stripPathTag(value) {
225
+ const match = /^<Path>([^<]+)<\/Path>$/.exec(String(value).trim());
226
+ return match ? match[1].trim() : null;
227
+ }
228
+
229
+ function normalizeProjectPath(value) {
230
+ const inner = stripPathTag(value) ?? String(value);
231
+ return inner
232
+ .replaceAll("\\", "/")
233
+ .trim()
234
+ .replace(/^\.?\//, "")
235
+ .replace(/\/$/, "");
236
+ }
237
+
238
+ function pathRoot(value) {
239
+ let normalized = normalizeProjectPath(value)
240
+ .replace(/\/\*\*$/, "")
241
+ .replace(/\/\*$/, "");
242
+ const wildcard = normalized.search(/[*?[\\]/);
243
+ if (wildcard >= 0) normalized = normalized.slice(0, wildcard).replace(/\/$/, "");
244
+ return normalized.replace(/\/$/, "");
245
+ }
246
+
247
+ function pathsOverlap(left, right) {
248
+ const a = pathRoot(left);
249
+ const b = pathRoot(right);
250
+ if (!a || !b) return true;
251
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
252
+ }
253
+
254
+ function findCycle(graph) {
255
+ const visiting = new Set();
256
+ const visited = new Set();
257
+ const stack = [];
258
+
259
+ function visit(node) {
260
+ if (visiting.has(node)) {
261
+ const index = stack.indexOf(node);
262
+ return [...stack.slice(index), node];
263
+ }
264
+ if (visited.has(node)) return null;
265
+ visiting.add(node);
266
+ stack.push(node);
267
+ for (const dependency of graph.get(node) ?? []) {
268
+ const cycle = visit(dependency);
269
+ if (cycle) return cycle;
270
+ }
271
+ stack.pop();
272
+ visiting.delete(node);
273
+ visited.add(node);
274
+ return null;
275
+ }
276
+
277
+ for (const node of graph.keys()) {
278
+ const cycle = visit(node);
279
+ if (cycle) return cycle;
280
+ }
281
+ return null;
282
+ }
283
+
284
+ function transitivelyDepends(graph, node, target, seen = new Set()) {
285
+ if (seen.has(node)) return false;
286
+ seen.add(node);
287
+ for (const dependency of graph.get(node) ?? []) {
288
+ if (dependency === target || transitivelyDepends(graph, dependency, target, seen)) {
289
+ return true;
290
+ }
291
+ }
292
+ return false;
293
+ }
294
+
295
+ function lineNumber(text, offset) {
296
+ return text.slice(0, offset).split("\n").length;
297
+ }
298
+
299
+ function validatePathValue(value, { allowProject = true } = {}) {
300
+ if (!value || value !== value.trim()) {
301
+ return "Path payload is empty or has surrounding whitespace";
302
+ }
303
+ if (value.includes("\\")) return "Path payload must use forward slashes";
304
+ if (ABSOLUTE_MACHINE_PATH_RE.test(value)) {
305
+ return "machine-specific absolute paths are forbidden";
306
+ }
307
+ if (value.startsWith(WORKFLOW_PREFIX)) {
308
+ if (value === WORKFLOW_PREFIX) return null;
309
+ const suffix = value.slice(WORKFLOW_PREFIX.length);
310
+ if (
311
+ suffix.startsWith("/") ||
312
+ suffix.startsWith("./") ||
313
+ suffix.startsWith("../") ||
314
+ suffix.includes("/../")
315
+ ) {
316
+ return "workflow Path is not canonical";
317
+ }
318
+ return null;
319
+ }
320
+ if (value.startsWith(STATE_PREFIX)) {
321
+ if (value === STATE_PREFIX) return null;
322
+ const suffix = value.slice(STATE_PREFIX.length);
323
+ if (
324
+ suffix.startsWith("/") ||
325
+ suffix.startsWith("./") ||
326
+ suffix.startsWith("../") ||
327
+ suffix.includes("/../")
328
+ ) {
329
+ return "state Path is not canonical";
330
+ }
331
+ return null;
332
+ }
333
+ if (value.includes("{roots.")) {
334
+ return "unknown or incomplete root variable; use roots.workflows or roots.state";
335
+ }
336
+ if (!allowProject) return "this field requires a rooted workflow or state Path";
337
+ if (
338
+ value.startsWith("/") ||
339
+ value.startsWith("./") ||
340
+ value.startsWith("../") ||
341
+ value.startsWith("~") ||
342
+ value.includes("/../")
343
+ ) {
344
+ return "project Path must be project-relative";
345
+ }
346
+ return null;
347
+ }
348
+
349
+ function validateDocumentReferences(root) {
350
+ const errors = [];
351
+ const warnings = [];
352
+ const entries = walk(root);
353
+ const knownFiles = new Set(entries.filter(isFile).map((path) => basename(path)));
354
+ const knownDirectories = new Set(entries.filter(isDirectory).map((path) => basename(path)));
355
+
356
+ for (const path of entries.filter(
357
+ (item) => isFile(item) && [".md", ".html"].includes(extname(item).toLowerCase()),
358
+ )) {
359
+ const label = toPosix(relative(root, path));
360
+ const text = readText(path);
361
+
362
+ if ((text.match(/<Path>/g) ?? []).length !== (text.match(/<\/Path>/g) ?? []).length) {
363
+ errors.push(`${label}: unbalanced <Path> tags`);
364
+ }
365
+ if ((text.match(/<Url>/g) ?? []).length !== (text.match(/<\/Url>/g) ?? []).length) {
366
+ errors.push(`${label}: unbalanced <Url> tags`);
367
+ }
368
+
369
+ for (const match of text.matchAll(PATH_TAG_RE)) {
370
+ const payload = match[1];
371
+ const issue = validatePathValue(payload);
372
+ if (issue) {
373
+ errors.push(
374
+ `${label}:${lineNumber(text, match.index)}: invalid <Path>${payload}</Path>: ${issue}`,
375
+ );
376
+ continue;
377
+ }
378
+ if (payload.startsWith(WORKFLOW_PREFIX)) {
379
+ const referenced = payload.slice(WORKFLOW_PREFIX.length);
380
+ if (!PLACEHOLDER_RE.test(referenced)) {
381
+ const target = join(root, referenced);
382
+ if (!existsSync(target)) {
383
+ errors.push(
384
+ `${label}:${lineNumber(text, match.index)}: broken workflow Path ${payload}`,
385
+ );
386
+ } else if (isDirectory(target) && !payload.endsWith("/")) {
387
+ errors.push(
388
+ `${label}:${lineNumber(text, match.index)}: directory workflow Path must end with /: ${payload}`,
389
+ );
390
+ } else if (isFile(target) && payload.endsWith("/")) {
391
+ errors.push(
392
+ `${label}:${lineNumber(text, match.index)}: file workflow Path must not end with /: ${payload}`,
393
+ );
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ for (const match of text.matchAll(URL_TAG_RE)) {
400
+ const payload = match[1].trim();
401
+ if (!/^https?:\/\//.test(payload)) {
402
+ errors.push(`${label}:${lineNumber(text, match.index)}: <Url> must contain http(s) URL`);
403
+ }
404
+ }
405
+
406
+ for (const match of text.matchAll(RELATIVE_MD_LINK_RE)) {
407
+ errors.push(
408
+ `${label}:${lineNumber(text, match.index)}: relative Markdown link is forbidden: ${match[1]}`,
409
+ );
410
+ }
411
+
412
+ let withoutTags = text
413
+ .replace(/`?\s*<Path>.*?<\/Path>\s*`?/gs, "")
414
+ .replace(/`?\s*<Url>.*?<\/Url>\s*`?/gs, "");
415
+
416
+ for (const match of withoutTags.matchAll(/\{roots\.(?:workflows|state)\}/g)) {
417
+ errors.push(
418
+ `${label}:${lineNumber(withoutTags, match.index)}: root variable must be inside <Path>`,
419
+ );
420
+ }
421
+
422
+ for (const name of [...knownFiles].sort((a, b) => b.length - a.length)) {
423
+ if (name === basename(path) || name.length < 5) continue;
424
+ const pattern = new RegExp(
425
+ `(?<![\\w/.-])${escapeRegExp(name)}(?![\\w.-])`,
426
+ );
427
+ const match = pattern.exec(withoutTags);
428
+ if (match) {
429
+ errors.push(
430
+ `${label}:${lineNumber(withoutTags, match.index)}: internal file reference '${name}' must use a full <Path>`,
431
+ );
432
+ break;
433
+ }
434
+ }
435
+
436
+ const artifactNames = [
437
+ ...STATE_ARTIFACT_BASENAMES,
438
+ ...FORBIDDEN_OBSOLETE_BASENAMES,
439
+ ].sort((a, b) => b.length - a.length);
440
+ for (const name of artifactNames) {
441
+ const pattern = new RegExp(
442
+ `(?<![\\w/.-])${escapeRegExp(name)}(?![\\w.-])`,
443
+ );
444
+ const match = pattern.exec(withoutTags);
445
+ if (match) {
446
+ errors.push(
447
+ `${label}:${lineNumber(withoutTags, match.index)}: concrete artifact reference '${name}' must use a full <Path>`,
448
+ );
449
+ break;
450
+ }
451
+ }
452
+
453
+ const pathLikeCode = /`([^`\n]+\.(?:md|json|mjs|html|yaml|yml))`/g.exec(withoutTags);
454
+ if (pathLikeCode) {
455
+ errors.push(
456
+ `${label}:${lineNumber(withoutTags, pathLikeCode.index)}: path-like code span '${pathLikeCode[1]}' must use a full <Path>`,
457
+ );
458
+ }
459
+
460
+ for (const directoryName of [...knownDirectories].sort((a, b) => b.length - a.length)) {
461
+ const match = new RegExp("`" + escapeRegExp(directoryName) + "/?`").exec(withoutTags);
462
+ if (match) {
463
+ errors.push(
464
+ `${label}:${lineNumber(withoutTags, match.index)}: internal directory reference '${directoryName}' must use a full <Path>`,
465
+ );
466
+ break;
467
+ }
468
+ }
469
+ }
470
+ return { errors, warnings };
471
+ }
472
+
473
+ function validateJsonFiles(root) {
474
+ const errors = [];
475
+ for (const path of walk(root).filter((item) => isFile(item) && extname(item) === ".json")) {
476
+ try {
477
+ JSON.parse(readText(path));
478
+ } catch (error) {
479
+ errors.push(`${toPosix(relative(root, path))}: invalid JSON: ${error.message}`);
480
+ }
481
+ }
482
+ return errors;
483
+ }
484
+
485
+ function capabilityChecks(root) {
486
+ const checks = new Map([
487
+ [
488
+ "init",
489
+ [
490
+ join(root, "I-init-setup", "I-init-setup.md"),
491
+ [
492
+ "tracking-template",
493
+ "domain-layout-template",
494
+ "status-labels-template",
495
+ "config-template",
496
+ "change-status-template",
497
+ ],
498
+ ],
499
+ ],
500
+ [
501
+ "triage",
502
+ [
503
+ join(root, "T-triage", "T-triage.md"),
504
+ ["source-issue", "不改写原意", "风险", "未知项分类", "推荐路线"],
505
+ ],
506
+ ],
507
+ [
508
+ "spec",
509
+ [
510
+ join(root, "S-spec", "S-spec.md"),
511
+ ["用户故事", "验收合同", "代码", "CONTEXT", "ADR", "验证接缝", "ready_for_tickets"],
512
+ ],
513
+ ],
514
+ [
515
+ "tickets",
516
+ [
517
+ join(root, "T-tickets", "T-tickets.md"),
518
+ ["Prefactor", "垂直切片", "Expand", "与用户核对", "Definition of Ready"],
519
+ ],
520
+ ],
521
+ [
522
+ "goal-plan",
523
+ [
524
+ join(root, "P-goal-plan", "P-goal-plan.md"),
525
+ ["Outcome", "Authority", "DAG", "Gate", "Wave", "Lead", "Definition of Done"],
526
+ ],
527
+ ],
528
+ [
529
+ "implement",
530
+ [
531
+ join(root, "I-implement", "I-implement.md"),
532
+ ["design-it-twice", "TDD", "双轴审查", "Evidence", "提交"],
533
+ ],
534
+ ],
535
+ [
536
+ "wayfinder",
537
+ [
538
+ join(root, "W-wayfinder", "W-wayfinder.md"),
539
+ ["共享地图", "claimed_investigations", "research", "decision", "并行"],
540
+ ],
541
+ ],
542
+ [
543
+ "architecture-review",
544
+ [
545
+ join(root, "R-review-architecture", "R-review-architecture.md"),
546
+ ["浅模块", "接缝泄漏", "局部性", "architecture-review.html", "逐项"],
547
+ ],
548
+ ],
549
+ [
550
+ "archive",
551
+ [
552
+ join(root, "A-archive-and-consolidate", "A-archive-and-consolidate.md"),
553
+ ["create", "update", "merge", "supersede", "deprecate", "skip"],
554
+ ],
555
+ ],
556
+ [
557
+ "grill",
558
+ [
559
+ join(root, "G-grill-with-docs", "G-grill-with-docs.md"),
560
+ ["一次一问", "LOG.md", "CONTEXT.md", "ADR.md", "推荐默认值"],
561
+ ],
562
+ ],
563
+ [
564
+ "diagnosis",
565
+ [
566
+ join(root, "D-diagnose-bugs", "D-diagnose-bugs.md"),
567
+ ["最短反馈回路", "可证伪", "最小实验", "根因", "回归测试"],
568
+ ],
569
+ ],
570
+ ]);
571
+
572
+ const errors = [];
573
+ for (const [ability, [path, markers]] of checks) {
574
+ if (!isFile(path)) {
575
+ errors.push(`capability '${ability}' has no entry file`);
576
+ continue;
577
+ }
578
+ const text = readText(path);
579
+ const missing = markers.filter((marker) => !text.includes(marker));
580
+ if (missing.length) {
581
+ errors.push(`capability '${ability}' lost markers: ${JSON.stringify(missing)}`);
582
+ }
583
+ }
584
+ if (!isFile(join(root, "R-review-architecture", "architecture-review-report-template.html"))) {
585
+ errors.push("architecture review lost its visual HTML report template");
586
+ }
587
+ return errors;
588
+ }
589
+
590
+ function selfCheck(root) {
591
+ const errors = [];
592
+ const warnings = [];
593
+
594
+ if (!isFile(join(root, "INDEX.md"))) errors.push("missing package INDEX");
595
+
596
+ const actualWorks = new Set(
597
+ readdirSync(root, { withFileTypes: true })
598
+ .filter((entry) => entry.isDirectory() && /^[A-Z]-/.test(entry.name))
599
+ .map((entry) => entry.name),
600
+ );
601
+ const missingWorks = [...EXPECTED_WORKS].filter((name) => !actualWorks.has(name));
602
+ const extraWorks = [...actualWorks].filter((name) => !EXPECTED_WORKS.has(name));
603
+ if (missingWorks.length) errors.push(`missing workflow directories: ${JSON.stringify(missingWorks.sort())}`);
604
+ if (extraWorks.length) warnings.push(`unexpected workflow directories: ${JSON.stringify(extraWorks.sort())}`);
605
+
606
+ const ids = [];
607
+ for (const directoryName of [...actualWorks].sort()) {
608
+ const entry = join(root, directoryName, `${directoryName}.md`);
609
+ if (!isFile(entry)) {
610
+ errors.push(`missing work entry ${toPosix(relative(root, entry))}`);
611
+ continue;
612
+ }
613
+ const { meta } = parseFrontmatter(entry);
614
+ if (meta.type !== "workflow-entry") {
615
+ errors.push(`${toPosix(relative(root, entry))}: type must be workflow-entry`);
616
+ }
617
+ if (meta.workflow !== "specdev") {
618
+ errors.push(`${toPosix(relative(root, entry))}: workflow must be specdev`);
619
+ }
620
+ if (typeof meta.id !== "string" || !meta.id.startsWith("specdev/")) {
621
+ errors.push(`${toPosix(relative(root, entry))}: invalid work id`);
622
+ } else {
623
+ ids.push(meta.id);
624
+ }
625
+ }
626
+ if (ids.length !== new Set(ids).size) errors.push("duplicate workflow entry id");
627
+
628
+ const common = join(root, "common");
629
+ if (!isDirectory(common)) {
630
+ errors.push("missing common directory");
631
+ } else {
632
+ const actualCommon = new Set(
633
+ readdirSync(common, { withFileTypes: true })
634
+ .filter((entry) => entry.isDirectory())
635
+ .map((entry) => entry.name),
636
+ );
637
+ const missingCommon = [...EXPECTED_COMMON_DIRS].filter((name) => !actualCommon.has(name));
638
+ if (missingCommon.length) {
639
+ errors.push(`missing common subdirectories: ${JSON.stringify(missingCommon.sort())}`);
640
+ }
641
+ for (const forbidden of ["schemas", "tools", "skills"]) {
642
+ if (existsSync(join(root, forbidden))) {
643
+ errors.push(`${forbidden}/ must live under common/`);
644
+ }
645
+ }
646
+ const flatRuleFiles = readdirSync(common, { withFileTypes: true })
647
+ .filter((entry) => entry.isFile() && extname(entry.name) === ".md" && entry.name !== "README.md")
648
+ .map((entry) => entry.name);
649
+ if (flatRuleFiles.length) {
650
+ errors.push(`common rule files must live under common/rules/: ${JSON.stringify(flatRuleFiles)}`);
651
+ }
652
+ }
653
+
654
+ errors.push(...validateJsonFiles(root));
655
+ const references = validateDocumentReferences(root);
656
+ errors.push(...references.errors);
657
+ warnings.push(...references.warnings);
658
+ errors.push(...capabilityChecks(root));
659
+
660
+ const obsolete = [
661
+ "P-goal-plan/input-validation.md",
662
+ "P-goal-plan/vision-sections.md",
663
+ "P-goal-plan/execution-sections.md",
664
+ "P-goal-plan/governance-sections.md",
665
+ "P-goal-plan/lead-orchestration-protocol.md",
666
+ "P-goal-plan/quick-reference-table.md",
667
+ "schemas",
668
+ "tools",
669
+ "skills",
670
+ ];
671
+ for (const item of obsolete.sort()) {
672
+ if (existsSync(join(root, item))) errors.push(`obsolete package location still exists: ${item}`);
673
+ }
674
+ return { errors, warnings };
675
+ }
676
+
677
+ function requireList(meta, key, label, errors) {
678
+ const value = meta[key];
679
+ if (!Array.isArray(value)) {
680
+ errors.push(`${label}: ${key} must be a list`);
681
+ return [];
682
+ }
683
+ return value;
684
+ }
685
+
686
+ function validatePathList(values, label, key, errors) {
687
+ for (const value of values) {
688
+ if (typeof value !== "string") {
689
+ errors.push(`${label}: ${key} entries must be strings`);
690
+ continue;
691
+ }
692
+ const inner = stripPathTag(value);
693
+ if (inner === null) {
694
+ errors.push(`${label}: ${key} entry must use <Path>...</Path>: ${JSON.stringify(value)}`);
695
+ continue;
696
+ }
697
+ const issue = validatePathValue(inner);
698
+ if (issue) errors.push(`${label}: invalid ${key} Path ${JSON.stringify(value)}: ${issue}`);
699
+ }
700
+ }
701
+
702
+ function validateSpec(path, errors, warnings) {
703
+ if (!isFile(path)) {
704
+ warnings.push("Spec is missing; contract traceability cannot be fully checked");
705
+ return null;
706
+ }
707
+ const { meta, body } = parseFrontmatter(path);
708
+ const label = basename(path);
709
+ if (meta.artifact !== "spec" || meta.schema_version !== SCHEMA_VERSION) {
710
+ errors.push(`${label}: artifact/schema_version must be spec/${SCHEMA_VERSION}`);
711
+ }
712
+ const sources = requireList(meta, "sources", label, errors);
713
+ if (!sources.length) errors.push(`${label}: sources must contain at least one source`);
714
+ if (meta.ready_for_tickets === true) {
715
+ const unresolved = sectionBody(body, "### 未决问题");
716
+ if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
717
+ errors.push(`${label}: ready Spec has unresolved high-impact questions`);
718
+ }
719
+ for (const heading of [
720
+ "## 1. 问题与目标",
721
+ "## 2. 解决方案与外部行为",
722
+ "## 4. 验收合同",
723
+ "## 5. 范围",
724
+ "## 9. 验证策略",
725
+ ]) {
726
+ if (!body.includes(heading)) errors.push(`${label}: ready Spec missing '${heading}'`);
727
+ }
728
+ }
729
+ return { path, meta, body };
730
+ }
731
+
732
+ function validateMap(path, errors) {
733
+ if (!isFile(path)) {
734
+ errors.push("missing Tickets Map");
735
+ return null;
736
+ }
737
+ const { meta, body } = parseFrontmatter(path);
738
+ if (meta.artifact !== "tickets-map" || meta.schema_version !== SCHEMA_VERSION) {
739
+ errors.push(`${basename(path)}: artifact/schema_version must be tickets-map/${SCHEMA_VERSION}`);
740
+ }
741
+ for (const heading of [
742
+ "## 2. 执行清单",
743
+ "## 3. 依赖 DAG",
744
+ "## 4. 合同覆盖矩阵",
745
+ "## 5. 并行与路径所有权",
746
+ ]) {
747
+ if (!body.includes(heading)) errors.push(`${basename(path)}: missing '${heading}'`);
748
+ }
749
+ return { path, meta, body };
750
+ }
751
+
752
+ function validateGoalPlan(path, errors) {
753
+ if (!isFile(path)) return null;
754
+ const { meta, body } = parseFrontmatter(path);
755
+ if (meta.artifact !== "goal-plan" || meta.schema_version !== SCHEMA_VERSION) {
756
+ errors.push(`${basename(path)}: artifact/schema_version must be goal-plan/${SCHEMA_VERSION}`);
757
+ }
758
+ const modes = requireList(meta, "modes", basename(path), errors);
759
+ const invalidModes = modes.filter((mode) => !VALID_PLAN_MODES.has(mode));
760
+ if (invalidModes.length) {
761
+ errors.push(`${basename(path)}: invalid modes ${JSON.stringify(invalidModes)}`);
762
+ }
763
+ if (meta.ready_for_execution === true) {
764
+ if (!new Set(["ready", "in_progress", "completed"]).has(meta.status)) {
765
+ errors.push(`${basename(path)}: ready_for_execution conflicts with status`);
766
+ }
767
+ for (const heading of REQUIRED_GOAL_PLAN_SECTIONS) {
768
+ if (!body.includes(heading)) {
769
+ errors.push(`${basename(path)}: ready Goal Plan missing '${heading}'`);
770
+ }
771
+ }
772
+ const assumptions = sectionBody(body, "## Assumptions");
773
+ if (assumptions && assumptions.includes("高影响") && !assumptions.includes("必须为 `false`")) {
774
+ errors.push(`${basename(path)}: high-impact assumptions cannot remain in a ready Goal Plan`);
775
+ }
776
+ }
777
+ return { path, meta, body };
778
+ }
779
+
780
+ function validateTicket(path, errors) {
781
+ const { meta, body } = parseFrontmatter(path);
782
+ const missing = [...REQUIRED_TICKET_KEYS].filter((key) => !(key in meta));
783
+ if (missing.length) {
784
+ errors.push(`${basename(path)}: missing frontmatter keys ${JSON.stringify(missing.sort())}`);
785
+ return null;
786
+ }
787
+ if (meta.artifact !== "ticket" || meta.schema_version !== SCHEMA_VERSION) {
788
+ errors.push(`${basename(path)}: artifact/schema_version must be ticket/${SCHEMA_VERSION}`);
789
+ }
790
+ const ticketId = String(meta.id);
791
+ if (!/^T-\d{2,}$/.test(ticketId)) errors.push(`${basename(path)}: invalid Ticket id ${ticketId}`);
792
+ if (!VALID_TICKET_STATUS.has(meta.status)) errors.push(`${basename(path)}: invalid status ${meta.status}`);
793
+ if (!VALID_DEPTH.has(meta.planning_depth)) {
794
+ errors.push(`${basename(path)}: invalid planning_depth ${meta.planning_depth}`);
795
+ }
796
+ if (!VALID_RISK.has(meta.risk)) errors.push(`${basename(path)}: invalid risk ${meta.risk}`);
797
+ if (!String(meta.planning_depth_reason ?? "").trim()) {
798
+ errors.push(`${basename(path)}: planning_depth_reason is required`);
799
+ }
800
+
801
+ const listKeys = [
802
+ "blocked_by",
803
+ "contract_ids",
804
+ "expected_changes",
805
+ "writable_paths",
806
+ "read_only_paths",
807
+ "shared_paths",
808
+ "shared_path_owners",
809
+ ];
810
+ const lists = Object.fromEntries(
811
+ listKeys.map((key) => [key, requireList(meta, key, basename(path), errors)]),
812
+ );
813
+ for (const key of ["expected_changes", "writable_paths", "read_only_paths", "shared_paths"]) {
814
+ validatePathList(lists[key], basename(path), key, errors);
815
+ }
816
+
817
+ const sharedPaths = lists.shared_paths.map(String);
818
+ const ownerEntries = lists.shared_path_owners.map(String);
819
+ for (const sharedPath of sharedPaths) {
820
+ if (!ownerEntries.some((entry) => entry.includes(sharedPath) && entry.includes("=>"))) {
821
+ errors.push(
822
+ `${basename(path)}: shared Path ${sharedPath} needs an entry '<Path>...</Path> => owner' in shared_path_owners`,
823
+ );
824
+ }
825
+ }
826
+ if (ownerEntries.length && !sharedPaths.length) {
827
+ errors.push(`${basename(path)}: shared_path_owners is non-empty while shared_paths is empty`);
828
+ }
829
+
830
+ if (meta.ready === true) {
831
+ if (!new Set(["ready", "in_progress", "review", "done"]).has(meta.status)) {
832
+ errors.push(`${basename(path)}: ready=true conflicts with status=${meta.status}`);
833
+ }
834
+ for (const heading of REQUIRED_READY_TICKET_SECTIONS) {
835
+ if (!body.includes(heading)) errors.push(`${basename(path)}: Ready Ticket missing '${heading}'`);
836
+ }
837
+ const unresolved = sectionBody(body, "### 未决问题");
838
+ if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
839
+ errors.push(`${basename(path)}: Ready Ticket has unresolved questions`);
840
+ }
841
+ if (
842
+ !lists.writable_paths.length &&
843
+ !body.includes("无代码变更") &&
844
+ !body.includes("仅文档")
845
+ ) {
846
+ errors.push(`${basename(path)}: Ready Ticket has no writable_paths and no no-code explanation`);
847
+ }
848
+ if (new Set(["standard", "deep"]).has(meta.planning_depth)) {
849
+ for (const heading of ["## 5. 实现契约", "## 6. 执行路线"]) {
850
+ if (!body.includes(heading)) {
851
+ errors.push(`${basename(path)}: ${meta.planning_depth} Ticket missing '${heading}'`);
852
+ }
853
+ }
854
+ }
855
+ if (meta.planning_depth === "deep" && !body.includes("## 9. 发布、迁移与恢复")) {
856
+ errors.push(`${basename(path)}: Deep Ticket missing release/migration/recovery`);
857
+ }
858
+ const verification = sectionBody(body, "## 8. 验证矩阵");
859
+ if (!verification || !verification.includes("|")) {
860
+ errors.push(`${basename(path)}: Ready Ticket has no usable verification matrix`);
861
+ }
862
+ const acceptance = sectionBody(body, "## 10. 验收标准").toLowerCase();
863
+ if (!acceptance.includes("- [ ]") && !acceptance.includes("- [x]")) {
864
+ errors.push(`${basename(path)}: Ready Ticket has no acceptance checklist`);
865
+ }
866
+ }
867
+ return { path, meta, body };
868
+ }
869
+
870
+ function validateChangeStatus(path, expectedChange, errors) {
871
+ if (!isFile(path)) {
872
+ errors.push("missing change status artifact");
873
+ return null;
874
+ }
875
+ let data;
876
+ try {
877
+ data = JSON.parse(readText(path));
878
+ } catch (error) {
879
+ errors.push(`${basename(path)}: invalid JSON: ${error.message}`);
880
+ return null;
881
+ }
882
+ const required = [
883
+ "schema_version",
884
+ "artifact",
885
+ "change",
886
+ "change_status",
887
+ "current_work",
888
+ "created_at",
889
+ "updated_at",
890
+ "completed_at",
891
+ "archived",
892
+ "archive_path",
893
+ "blockers",
894
+ "deviations",
895
+ ];
896
+ const missing = required.filter((key) => !(key in data));
897
+ if (missing.length) errors.push(`${basename(path)}: missing keys ${JSON.stringify(missing.sort())}`);
898
+ if (data.schema_version !== SCHEMA_VERSION || data.artifact !== "change-status") {
899
+ errors.push(`${basename(path)}: artifact/schema_version must be change-status/${SCHEMA_VERSION}`);
900
+ }
901
+ if (data.change !== expectedChange) {
902
+ errors.push(`${basename(path)}: change must equal directory name ${expectedChange}`);
903
+ }
904
+ if (!new Set(["active", "blocked", "completed", "archived"]).has(data.change_status)) {
905
+ errors.push(`${basename(path)}: invalid change_status ${data.change_status}`);
906
+ }
907
+ if (!Array.isArray(data.blockers) || !Array.isArray(data.deviations)) {
908
+ errors.push(`${basename(path)}: blockers and deviations must be arrays`);
909
+ }
910
+ if (data.change_status === "archived") {
911
+ if (data.archived !== true) errors.push(`${basename(path)}: archived status requires archived=true`);
912
+ const inner = typeof data.archive_path === "string" ? stripPathTag(data.archive_path) : null;
913
+ if (inner === null || !inner.startsWith(`${STATE_PREFIX}archive/`)) {
914
+ errors.push(`${basename(path)}: archived status requires a rooted archive_path`);
915
+ }
916
+ } else if (data.archived === true) {
917
+ errors.push(`${basename(path)}: archived=true conflicts with change_status=${data.change_status}`);
918
+ }
919
+ return data;
920
+ }
921
+
922
+ function validateChange(change) {
923
+ const errors = [];
924
+ const warnings = [];
925
+ if (!isDirectory(change)) {
926
+ return { errors: [`change directory does not exist: ${change}`], warnings };
927
+ }
928
+
929
+ const changeStatus = validateChangeStatus(join(change, ".status.json"), basename(change), errors);
930
+ const spec = validateSpec(join(change, "spec.md"), errors, warnings);
931
+ const ticketsMap = validateMap(join(change, "tickets-map.md"), errors);
932
+ const goalPlan = validateGoalPlan(join(change, "goal-plan.md"), errors);
933
+ for (const artifact of [spec, ticketsMap, goalPlan]) {
934
+ if (artifact && artifact.meta.change !== basename(change)) {
935
+ errors.push(`${basename(artifact.path)}: change must equal directory name ${basename(change)}`);
936
+ }
937
+ }
938
+
939
+ const ticketDir = join(change, "ticket");
940
+ if (!isDirectory(ticketDir)) return { errors: [...errors, "missing Ticket directory"], warnings };
941
+ const ticketFiles = readdirSync(ticketDir)
942
+ .filter((name) => name.endsWith(".md"))
943
+ .sort()
944
+ .map((name) => join(ticketDir, name));
945
+ if (!ticketFiles.length) {
946
+ return { errors: [...errors, "Ticket directory contains no Markdown tickets"], warnings };
947
+ }
948
+
949
+ const tickets = new Map();
950
+ for (const path of ticketFiles) {
951
+ const artifact = validateTicket(path, errors);
952
+ if (!artifact) continue;
953
+ const ticketId = String(artifact.meta.id);
954
+ if (artifact.meta.change !== basename(change)) {
955
+ errors.push(`${basename(path)}: change must equal directory name ${basename(change)}`);
956
+ }
957
+ const numericId = ticketId.replace(/^T-/, "");
958
+ if (!basename(path).startsWith(`${numericId}-`)) {
959
+ errors.push(`${basename(path)}: filename prefix must match ${ticketId}`);
960
+ }
961
+ if (tickets.has(ticketId)) errors.push(`duplicate Ticket id ${ticketId}`);
962
+ tickets.set(ticketId, artifact);
963
+ if (ticketsMap && !ticketsMap.body.includes(ticketId)) {
964
+ errors.push(`${basename(path)}: Ticket id is absent from Tickets Map`);
965
+ }
966
+ if (spec) {
967
+ for (const contractId of artifact.meta.contract_ids ?? []) {
968
+ if (!spec.body.includes(String(contractId))) {
969
+ errors.push(`${basename(path)}: contract ${contractId} not found in Spec`);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ if (spec) {
976
+ const declaredContracts = new Set(spec.body.match(/\bAC-\d+\b/g) ?? []);
977
+ const coveredContracts = new Set(
978
+ [...tickets.values()].flatMap((artifact) =>
979
+ (artifact.meta.contract_ids ?? []).map(String),
980
+ ),
981
+ );
982
+ let uncovered = [...declaredContracts].filter((id) => !coveredContracts.has(id)).sort();
983
+ if (ticketsMap) {
984
+ uncovered = uncovered.filter(
985
+ (id) => !new RegExp(`${escapeRegExp(id)}.*\\bdeferred\\b`, "i").test(ticketsMap.body),
986
+ );
987
+ }
988
+ if (uncovered.length) {
989
+ errors.push(`Spec acceptance contracts are not covered by Tickets: ${JSON.stringify(uncovered)}`);
990
+ }
991
+ if (spec.meta.ready_for_tickets === true && !declaredContracts.size) {
992
+ errors.push("ready Spec must define at least one AC-### acceptance contract");
993
+ }
994
+ }
995
+
996
+ const graph = new Map();
997
+ for (const [ticketId, artifact] of tickets) {
998
+ const dependencies = (artifact.meta.blocked_by ?? []).map(String);
999
+ graph.set(ticketId, dependencies);
1000
+ for (const dependency of dependencies) {
1001
+ if (!tickets.has(dependency)) {
1002
+ errors.push(`${basename(artifact.path)}: blocked_by references missing ${dependency}`);
1003
+ }
1004
+ }
1005
+ }
1006
+ const cycle = findCycle(graph);
1007
+ if (cycle) errors.push(`Ticket dependency cycle: ${cycle.join(" -> ")}`);
1008
+
1009
+ const activeReady = [...tickets]
1010
+ .filter(([, artifact]) => artifact.meta.ready === true && artifact.meta.status !== "done")
1011
+ .map(([ticketId]) => ticketId);
1012
+ for (let index = 0; index < activeReady.length; index += 1) {
1013
+ const leftId = activeReady[index];
1014
+ const left = tickets.get(leftId);
1015
+ for (const rightId of activeReady.slice(index + 1)) {
1016
+ const right = tickets.get(rightId);
1017
+ if (
1018
+ transitivelyDepends(graph, leftId, rightId) ||
1019
+ transitivelyDepends(graph, rightId, leftId)
1020
+ ) {
1021
+ continue;
1022
+ }
1023
+ const overlaps = [];
1024
+ for (const leftPath of left.meta.writable_paths ?? []) {
1025
+ for (const rightPath of right.meta.writable_paths ?? []) {
1026
+ if (pathsOverlap(String(leftPath), String(rightPath))) {
1027
+ overlaps.push([String(leftPath), String(rightPath)]);
1028
+ }
1029
+ }
1030
+ }
1031
+ if (!overlaps.length) continue;
1032
+ const leftShared = (left.meta.shared_paths ?? []).map(String);
1033
+ const rightShared = (right.meta.shared_paths ?? []).map(String);
1034
+ const unresolved = overlaps.filter(
1035
+ ([leftPath, rightPath]) =>
1036
+ !leftShared.some((shared) => pathsOverlap(leftPath, shared)) &&
1037
+ !rightShared.some((shared) => pathsOverlap(rightPath, shared)),
1038
+ );
1039
+ if (unresolved.length) {
1040
+ errors.push(
1041
+ `concurrent Ready Tickets ${leftId}/${rightId} have unowned writable overlap: ${JSON.stringify(unresolved.slice(0, 3))}`,
1042
+ );
1043
+ } else {
1044
+ warnings.push(
1045
+ `concurrent Ready Tickets ${leftId}/${rightId} share owned paths; Lead serialization is required`,
1046
+ );
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ const evidenceDir = join(change, "evidence");
1052
+ for (const [ticketId, artifact] of tickets) {
1053
+ if (artifact.meta.status !== "done") continue;
1054
+ const evidence = join(evidenceDir, `${ticketId}.md`);
1055
+ if (!isFile(evidence)) {
1056
+ errors.push(`${ticketId}: status is done but Evidence is missing`);
1057
+ continue;
1058
+ }
1059
+ const text = readText(evidence);
1060
+ for (const heading of [
1061
+ "## 2. 修改范围",
1062
+ "## 3. 验收与合同映射",
1063
+ "## 4. 验证执行",
1064
+ "## 5. 路径所有权审计",
1065
+ "## 6. 偏差与决策",
1066
+ "## 7. 残余风险与后续",
1067
+ ]) {
1068
+ if (!text.includes(heading)) {
1069
+ errors.push(`${toPosix(relative(change, evidence))}: missing '${heading}'`);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ if (goalPlan?.meta.ready_for_execution === true) {
1075
+ const notReady = [...tickets]
1076
+ .filter(
1077
+ ([, artifact]) =>
1078
+ !new Set(["done", "cancelled"]).has(artifact.meta.status) &&
1079
+ artifact.meta.ready !== true,
1080
+ )
1081
+ .map(([ticketId]) => ticketId);
1082
+ if (notReady.length) errors.push(`Goal Plan is Ready but Tickets are not Ready: ${JSON.stringify(notReady)}`);
1083
+ if (spec && spec.meta.ready_for_tickets !== true) {
1084
+ errors.push("Goal Plan is Ready but Spec is not ready_for_tickets");
1085
+ }
1086
+ if (
1087
+ ticketsMap &&
1088
+ !new Set(["ready", "in_progress", "completed"]).has(ticketsMap.meta.status)
1089
+ ) {
1090
+ errors.push("Goal Plan is Ready but Tickets Map status is not ready/in_progress/completed");
1091
+ }
1092
+ }
1093
+
1094
+ if (changeStatus) {
1095
+ if (
1096
+ changeStatus.change_status === "completed" &&
1097
+ [...tickets.values()].some(
1098
+ (artifact) => !new Set(["done", "cancelled"]).has(artifact.meta.status),
1099
+ )
1100
+ ) {
1101
+ errors.push("change_status is completed while planned Tickets remain unfinished");
1102
+ }
1103
+ if (changeStatus.change_status === "archived") {
1104
+ warnings.push("validating an archived change in place; normally it lives under the archive root");
1105
+ }
1106
+ }
1107
+
1108
+ for (const path of walk(change).filter((item) => isFile(item) && extname(item) === ".md")) {
1109
+ const text = readText(path);
1110
+ if ((text.match(/<Path>/g) ?? []).length !== (text.match(/<\/Path>/g) ?? []).length) {
1111
+ errors.push(`${toPosix(relative(change, path))}: unbalanced <Path> tags`);
1112
+ }
1113
+ const withoutPaths = text.replace(PATH_TAG_RE, "");
1114
+ if (withoutPaths.includes("{roots.")) {
1115
+ errors.push(`${toPosix(relative(change, path))}: root variable outside <Path>`);
1116
+ }
1117
+ for (const match of text.matchAll(PATH_TAG_RE)) {
1118
+ const issue = validatePathValue(match[1]);
1119
+ if (issue) errors.push(`${toPosix(relative(change, path))}: invalid Path: ${issue}`);
1120
+ }
1121
+ }
1122
+
1123
+ return { errors, warnings };
1124
+ }
1125
+
1126
+ function printResults(errors, warnings) {
1127
+ for (const warning of warnings) console.log(`WARNING: ${warning}`);
1128
+ for (const error of errors) console.error(`ERROR: ${error}`);
1129
+ console.log(`Summary: ${errors.length} error(s), ${warnings.length} warning(s)`);
1130
+ return errors.length ? 1 : 0;
1131
+ }
1132
+
1133
+ function usage() {
1134
+ console.error("Usage: node validate-specdev.mjs <change-directory> | --self-check");
1135
+ return 2;
1136
+ }
1137
+
1138
+ function main(argv) {
1139
+ const selfCheckRequested = argv.includes("--self-check");
1140
+ const positional = argv.filter((arg) => !arg.startsWith("--"));
1141
+ if (selfCheckRequested) {
1142
+ if (positional.length) return usage();
1143
+ const scriptDirectory = dirname(fileURLToPath(import.meta.url));
1144
+ const root = resolve(scriptDirectory, "..", "..");
1145
+ const result = selfCheck(root);
1146
+ return printResults(result.errors, result.warnings);
1147
+ }
1148
+ if (positional.length === 1) {
1149
+ const result = validateChange(resolve(positional[0]));
1150
+ return printResults(result.errors, result.warnings);
1151
+ }
1152
+ return usage();
1153
+ }
1154
+
1155
+ process.exitCode = main(process.argv.slice(2));