@zhuan-ai/zhuanspec 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Strict Validation Rules
3
+ *
4
+ * Implementation of three strict validation rules for `zhuanspec validate --strict`:
5
+ * 1. pre-clarification-completed: Check Pre-Clarification Log section
6
+ * 2. skill-tags-valid: Validate @skill tags against known skills
7
+ * 3. task-ordering-by-wave: Verify task ordering follows wave structure
8
+ */
9
+ import { generateExecutionPlan } from '../task-graph/execution-planner.js';
10
+ // Patterns for detecting unresolved markers
11
+ const UNRESOLVED_MARKERS = /\b(TODO|TBD|PENDING|\?)\b/i;
12
+ const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
13
+ const PRE_CLARIFICATION_SECTION_PATTERN = /^##\s+Pre-Clarification\s+Log\s*$/im;
14
+ const VALID_COMPLETION_MARKERS = [
15
+ /no\s+clarification\s+needed/i,
16
+ /status:\s*completed/i,
17
+ ];
18
+ // Skill mapping section header pattern
19
+ const SKILL_MAPPING_SECTION_PATTERN = /^##\s+Skill\s+Mapping\s*$/im;
20
+ // Table data row pattern (starts with |, not a separator row like |---|---|)
21
+ const TABLE_DATA_ROW_PATTERN = /^\|(?![-:|\s]+\|$)[^\n]+\|/gm;
22
+ const SKILL_TAG_PATTERN = /@skill:([^\s@]+)/g;
23
+ // Wave header pattern
24
+ const WAVE_HEADER_PATTERN = /^###\s+Wave\s+(\d+)/im;
25
+ /**
26
+ * Rule 1: pre-clarification-completed
27
+ *
28
+ * Checks that tasks.md has a valid "## Pre-Clarification Log" section.
29
+ * Valid content includes:
30
+ * - Actual Q&A record text
31
+ * - "No clarification needed"
32
+ * - "Status: COMPLETED"
33
+ * Invalid:
34
+ * - Section missing
35
+ * - Section empty (whitespace only)
36
+ * - Only HTML comments
37
+ * - Contains "TODO", "TBD", "PENDING", "?" unresolved markers
38
+ */
39
+ export function checkPreClarification(tasksContent) {
40
+ const result = {
41
+ ruleId: 'pre-clarification-completed',
42
+ ruleName: 'Pre-clarification completed',
43
+ passed: false,
44
+ errors: [],
45
+ warnings: [],
46
+ };
47
+ // Check if section exists
48
+ const sectionMatch = tasksContent.match(PRE_CLARIFICATION_SECTION_PATTERN);
49
+ if (!sectionMatch) {
50
+ result.errors.push('Pre-Clarification Log section is missing');
51
+ return result;
52
+ }
53
+ // Extract section content (from header to next ## or end)
54
+ const sectionStartIndex = sectionMatch.index + sectionMatch[0].length;
55
+ const nextSectionMatch = tasksContent.slice(sectionStartIndex).match(/^##\s+/m);
56
+ const sectionEndIndex = nextSectionMatch
57
+ ? sectionStartIndex + nextSectionMatch.index
58
+ : tasksContent.length;
59
+ let sectionContent = tasksContent.slice(sectionStartIndex, sectionEndIndex);
60
+ // Remove HTML comments for content analysis
61
+ const contentWithoutComments = sectionContent.replace(HTML_COMMENT_PATTERN, '').trim();
62
+ // Check if section is empty
63
+ if (!contentWithoutComments) {
64
+ result.errors.push('Pre-Clarification Log section is empty or contains only HTML comments');
65
+ return result;
66
+ }
67
+ // Check for unresolved markers
68
+ if (UNRESOLVED_MARKERS.test(contentWithoutComments)) {
69
+ result.errors.push('Pre-Clarification Log contains unresolved markers (TODO, TBD, PENDING, or ?)');
70
+ return result;
71
+ }
72
+ // Check for valid completion markers or actual content
73
+ const hasValidCompletionMarker = VALID_COMPLETION_MARKERS.some(pattern => pattern.test(contentWithoutComments));
74
+ const hasSubstantiveContent = contentWithoutComments.length > 20; // More than just a few words
75
+ if (hasValidCompletionMarker || hasSubstantiveContent) {
76
+ result.passed = true;
77
+ }
78
+ else {
79
+ result.errors.push('Pre-Clarification Log does not contain valid completion markers or substantive Q&A content');
80
+ }
81
+ return result;
82
+ }
83
+ /**
84
+ * Rule 2: skill-tags-valid
85
+ *
86
+ * Validates:
87
+ * a. Skill Mapping table exists with at least one data row
88
+ * b. Each task has @skill:xxx tag or @skill:none
89
+ * c. @skill:none tasks should have a reason in description
90
+ * d. All @skill:xxx names must exist in knownSkillNames
91
+ * e. Comma-separated multi-skills are validated individually
92
+ */
93
+ export function checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames) {
94
+ const result = {
95
+ ruleId: 'skill-tags-valid',
96
+ ruleName: 'Skill tags valid',
97
+ passed: false,
98
+ errors: [],
99
+ warnings: [],
100
+ };
101
+ // a. Check Skill Mapping section exists with table data
102
+ const sectionMatch = tasksContent.match(SKILL_MAPPING_SECTION_PATTERN);
103
+ if (!sectionMatch) {
104
+ result.warnings.push('Skill Mapping section not found');
105
+ }
106
+ else {
107
+ // Extract section content (from header to next ## or end)
108
+ const sectionStartIndex = sectionMatch.index + sectionMatch[0].length;
109
+ const nextSectionMatch = tasksContent.slice(sectionStartIndex).match(/^##\s+/m);
110
+ const sectionEndIndex = nextSectionMatch
111
+ ? sectionStartIndex + nextSectionMatch.index
112
+ : tasksContent.length;
113
+ const sectionContent = tasksContent.slice(sectionStartIndex, sectionEndIndex);
114
+ // Check for table data rows (lines starting with | that are not separator rows)
115
+ const tableRows = sectionContent.match(TABLE_DATA_ROW_PATTERN);
116
+ // Filter out header rows and separator rows
117
+ const dataRows = tableRows?.filter(row => {
118
+ const trimmed = row.trim();
119
+ // Skip separator rows like |---|---| or |:---|---:|
120
+ if (/^\|[-:\s|]+\|$/.test(trimmed))
121
+ return false;
122
+ // Skip header rows (first non-separator row is header)
123
+ return true;
124
+ });
125
+ // Need at least 2 rows (header + 1 data row)
126
+ if (!dataRows || dataRows.length < 2) {
127
+ result.warnings.push('Skill Mapping table has no data rows');
128
+ }
129
+ }
130
+ // If no tasks, consider it valid
131
+ if (parsedTasks.length === 0) {
132
+ result.passed = true;
133
+ return result;
134
+ }
135
+ // b, c, d, e. Validate each task's skill tags
136
+ const knownSkillSet = new Set(knownSkillNames.map(s => s.toLowerCase()));
137
+ for (const task of parsedTasks) {
138
+ // b. Check if task has skill tag
139
+ if (task.skills.length === 0) {
140
+ result.errors.push(`Task ${task.id}: missing @skill tag`);
141
+ continue;
142
+ }
143
+ // Validate each skill
144
+ for (const skill of task.skills) {
145
+ const skillLower = skill.toLowerCase();
146
+ // c. @skill:none should have a reason
147
+ if (skillLower === 'none') {
148
+ // Check if description mentions why no skill needed
149
+ const descLower = task.description.toLowerCase();
150
+ const hasReason = descLower.includes('manual') ||
151
+ descLower.includes('review') ||
152
+ descLower.includes('document') ||
153
+ descLower.includes('config') ||
154
+ descLower.includes('test') ||
155
+ descLower.includes('verify') ||
156
+ task.description.length > 30; // Substantial description is acceptable
157
+ if (!hasReason) {
158
+ result.warnings.push(`Task ${task.id}: @skill:none should have a reason in description`);
159
+ }
160
+ continue;
161
+ }
162
+ // d. Validate skill exists in known skills
163
+ if (knownSkillNames.length > 0 && !knownSkillSet.has(skillLower)) {
164
+ const availableSkills = knownSkillNames.length <= 10
165
+ ? knownSkillNames.join(', ')
166
+ : `${knownSkillNames.slice(0, 10).join(', ')}... (${knownSkillNames.length} total)`;
167
+ result.errors.push(`Task ${task.id}: @skill:${skill} is not a known skill. Available skills: ${availableSkills}`);
168
+ }
169
+ }
170
+ }
171
+ // Passed if no errors
172
+ result.passed = result.errors.length === 0;
173
+ return result;
174
+ }
175
+ /**
176
+ * Rule 3: task-ordering-by-wave
177
+ *
178
+ * Validates:
179
+ * - Tasks are ordered by wave (wave 1 tasks before wave 2, etc.)
180
+ * - Wave headers "### Wave N" match actual computed wave numbers
181
+ * - Tasks.md must have Wave headers
182
+ */
183
+ export function checkTaskOrderingByWave(tasksContent, parsedTasks) {
184
+ const result = {
185
+ ruleId: 'task-ordering-by-wave',
186
+ ruleName: 'Task ordering by wave',
187
+ passed: false,
188
+ errors: [],
189
+ warnings: [],
190
+ };
191
+ // If no tasks, consider it valid
192
+ if (parsedTasks.length === 0) {
193
+ result.passed = true;
194
+ return result;
195
+ }
196
+ // Check for wave headers
197
+ const waveHeaders = [];
198
+ const waveHeaderRegex = /^###\s+Wave\s+(\d+)/gim;
199
+ let match;
200
+ while ((match = waveHeaderRegex.exec(tasksContent)) !== null) {
201
+ waveHeaders.push({
202
+ wave: parseInt(match[1], 10),
203
+ position: match.index,
204
+ });
205
+ }
206
+ if (waveHeaders.length === 0) {
207
+ result.errors.push('No Wave headers found in tasks.md. Expected format: "### Wave N"');
208
+ return result;
209
+ }
210
+ // Generate execution plan to get actual waves
211
+ const executionPlan = generateExecutionPlan(parsedTasks);
212
+ if (executionPlan.hasCycle) {
213
+ result.errors.push('Task dependency graph has cycles, cannot validate wave ordering');
214
+ return result;
215
+ }
216
+ // Build a map of task ID to actual wave number
217
+ const taskToWave = new Map();
218
+ for (const wave of executionPlan.waves) {
219
+ for (const task of wave.tasks) {
220
+ taskToWave.set(task.id, wave.wave);
221
+ }
222
+ }
223
+ // Find task positions in content
224
+ const taskPositions = [];
225
+ for (const task of parsedTasks) {
226
+ // Find task line in content
227
+ const taskPattern = new RegExp(`-\\s+\\[[ xX]\\]\\s+${task.id.replace('.', '\\.')}\\s`, 'gm');
228
+ const taskMatch = taskPattern.exec(tasksContent);
229
+ if (taskMatch) {
230
+ const actualWave = taskToWave.get(task.id);
231
+ if (actualWave !== undefined) {
232
+ taskPositions.push({
233
+ id: task.id,
234
+ position: taskMatch.index,
235
+ actualWave,
236
+ });
237
+ }
238
+ }
239
+ }
240
+ // Sort by position in file
241
+ taskPositions.sort((a, b) => a.position - b.position);
242
+ // Check ordering: all tasks of wave N should appear before tasks of wave N+1
243
+ let lastWave = 0;
244
+ for (const task of taskPositions) {
245
+ if (task.actualWave < lastWave) {
246
+ result.errors.push(`Task ${task.id} (wave ${task.actualWave}) appears after tasks of wave ${lastWave}`);
247
+ }
248
+ lastWave = Math.max(lastWave, task.actualWave);
249
+ }
250
+ // Validate wave headers match actual wave numbers
251
+ const actualWaveNumbers = new Set(executionPlan.waves.map(w => w.wave));
252
+ const declaredWaveNumbers = new Set(waveHeaders.map(h => h.wave));
253
+ for (const declared of declaredWaveNumbers) {
254
+ if (!actualWaveNumbers.has(declared)) {
255
+ result.warnings.push(`Wave header "### Wave ${declared}" does not match any computed wave`);
256
+ }
257
+ }
258
+ for (const actual of actualWaveNumbers) {
259
+ if (!declaredWaveNumbers.has(actual)) {
260
+ result.errors.push(`Missing Wave header for computed wave ${actual}`);
261
+ }
262
+ }
263
+ // Check wave header ordering
264
+ for (let i = 1; i < waveHeaders.length; i++) {
265
+ if (waveHeaders[i].wave <= waveHeaders[i - 1].wave) {
266
+ result.errors.push(`Wave headers are not in ascending order: Wave ${waveHeaders[i - 1].wave} followed by Wave ${waveHeaders[i].wave}`);
267
+ }
268
+ }
269
+ result.passed = result.errors.length === 0;
270
+ return result;
271
+ }
272
+ /**
273
+ * Main entry: Run all strict validation rules
274
+ */
275
+ export async function runStrictValidation(tasksContent, parsedTasks, knownSkillNames) {
276
+ const checks = [
277
+ checkPreClarification(tasksContent),
278
+ checkSkillTagsValid(tasksContent, parsedTasks, knownSkillNames),
279
+ checkTaskOrderingByWave(tasksContent, parsedTasks),
280
+ ];
281
+ const allPassed = checks.every(check => check.passed);
282
+ return {
283
+ checks,
284
+ allPassed,
285
+ };
286
+ }
287
+ //# sourceMappingURL=strict-rules.js.map
@@ -6,6 +6,13 @@ export interface ValidationIssue {
6
6
  line?: number;
7
7
  column?: number;
8
8
  }
9
+ export interface StrictCheckSummary {
10
+ ruleId: string;
11
+ ruleName: string;
12
+ passed: boolean;
13
+ errors: string[];
14
+ warnings: string[];
15
+ }
9
16
  export interface ValidationReport {
10
17
  valid: boolean;
11
18
  issues: ValidationIssue[];
@@ -14,5 +21,8 @@ export interface ValidationReport {
14
21
  warnings: number;
15
22
  info: number;
16
23
  };
24
+ strictChecks?: StrictCheckSummary[];
25
+ /** Mermaid diagram content if task graph analysis succeeded */
26
+ mermaidDiagram?: string;
17
27
  }
18
28
  //# sourceMappingURL=types.d.ts.map
@@ -29,5 +29,10 @@ export declare class Validator {
29
29
  private containsShallOrMust;
30
30
  private countScenarios;
31
31
  private formatSectionList;
32
+ /**
33
+ * Find project root directory by walking up from changeDir.
34
+ * Looks for common project markers like package.json, .git, or skills directory.
35
+ */
36
+ private findProjectRoot;
32
37
  }
33
38
  //# sourceMappingURL=validator.d.ts.map
@@ -6,6 +6,12 @@ import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
7
7
  import { parseDeltaSpec, normalizeRequirementName } from '../parsers/requirement-blocks.js';
8
8
  import { FileSystemUtils } from '../../utils/file-system.js';
9
+ import { runStrictValidation } from './strict-rules.js';
10
+ import { discoverSkills } from '../skill-discovery.js';
11
+ import { existsSync } from 'fs';
12
+ import { parseTasks } from '../task-graph/task-parser.js';
13
+ import { generateExecutionPlan } from '../task-graph/execution-planner.js';
14
+ import { renderMermaidDiagram } from '../task-graph/mermaid-renderer.js';
9
15
  export class Validator {
10
16
  strictMode;
11
17
  constructor(strictMode = false) {
@@ -253,7 +259,84 @@ export class Validator {
253
259
  if (totalDeltas === 0) {
254
260
  issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
255
261
  }
256
- return this.createReport(issues);
262
+ // Strict mode validation for tasks.md
263
+ let strictChecks;
264
+ if (this.strictMode) {
265
+ const tasksFile = path.join(changeDir, 'tasks.md');
266
+ let tasksContent;
267
+ try {
268
+ tasksContent = await fs.readFile(tasksFile, 'utf-8');
269
+ }
270
+ catch {
271
+ // tasks.md not found - skip strict validation for tasks
272
+ }
273
+ if (tasksContent) {
274
+ const parsedTasks = parseTasks(tasksContent);
275
+ // Get known skill names
276
+ let knownSkillNames = [];
277
+ try {
278
+ const projectRoot = this.findProjectRoot(changeDir);
279
+ const skills = discoverSkills(projectRoot);
280
+ knownSkillNames = skills.map(s => s.name);
281
+ }
282
+ catch {
283
+ // skill discovery failed - add warning and continue without skill name validation
284
+ issues.push({
285
+ level: 'WARNING',
286
+ path: 'tasks.md',
287
+ message: 'Could not discover skills for strict validation — skill name check skipped',
288
+ });
289
+ }
290
+ // Run strict validation
291
+ const strictResult = await runStrictValidation(tasksContent, parsedTasks, knownSkillNames);
292
+ strictChecks = strictResult.checks;
293
+ // Merge strict errors into main issues
294
+ for (const check of strictResult.checks) {
295
+ for (const error of check.errors) {
296
+ issues.push({
297
+ level: 'ERROR',
298
+ path: 'tasks.md',
299
+ message: `[${check.ruleName}] ${error}`,
300
+ });
301
+ }
302
+ for (const warning of check.warnings) {
303
+ issues.push({
304
+ level: 'WARNING',
305
+ path: 'tasks.md',
306
+ message: `[${check.ruleName}] ${warning}`,
307
+ });
308
+ }
309
+ }
310
+ }
311
+ }
312
+ // Generate Mermaid diagram if tasks.md exists and has tasks
313
+ let mermaidDiagram;
314
+ const tasksFile = path.join(changeDir, 'tasks.md');
315
+ let tasksContentForMermaid;
316
+ try {
317
+ tasksContentForMermaid = await fs.readFile(tasksFile, 'utf-8');
318
+ }
319
+ catch {
320
+ // tasks.md not found - skip Mermaid generation
321
+ }
322
+ if (tasksContentForMermaid) {
323
+ const parsedTasksForMermaid = parseTasks(tasksContentForMermaid);
324
+ if (parsedTasksForMermaid.length > 0) {
325
+ const executionPlan = generateExecutionPlan(parsedTasksForMermaid);
326
+ // Only generate Mermaid diagram if execution plan is valid (no cycles)
327
+ if (!executionPlan.hasCycle && executionPlan.waves.length > 0) {
328
+ mermaidDiagram = renderMermaidDiagram(executionPlan, parsedTasksForMermaid);
329
+ }
330
+ }
331
+ }
332
+ const report = this.createReport(issues);
333
+ if (strictChecks) {
334
+ report.strictChecks = strictChecks;
335
+ }
336
+ if (mermaidDiagram) {
337
+ report.mermaidDiagram = mermaidDiagram;
338
+ }
339
+ return report;
257
340
  }
258
341
  convertZodErrors(error) {
259
342
  return error.issues.map(err => {
@@ -405,5 +488,24 @@ export class Validator {
405
488
  const last = sections[sections.length - 1];
406
489
  return `${head.join(', ')} and ${last}`;
407
490
  }
491
+ /**
492
+ * Find project root directory by walking up from changeDir.
493
+ * Looks for common project markers like package.json, .git, or skills directory.
494
+ */
495
+ findProjectRoot(changeDir) {
496
+ let current = path.resolve(changeDir);
497
+ const root = path.parse(current).root;
498
+ while (current !== root) {
499
+ // Check for project root markers
500
+ if (existsSync(path.join(current, 'package.json')) ||
501
+ existsSync(path.join(current, '.git')) ||
502
+ existsSync(path.join(current, 'skills'))) {
503
+ return current;
504
+ }
505
+ current = path.dirname(current);
506
+ }
507
+ // Fallback to process.cwd() if no markers found
508
+ return process.cwd();
509
+ }
408
510
  }
409
511
  //# sourceMappingURL=validator.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuan-ai/zhuanspec",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "zhuanspec",
@@ -38,6 +38,26 @@
38
38
  "!dist/**/__tests__",
39
39
  "!dist/**/*.map"
40
40
  ],
41
+ "scripts": {
42
+ "lint": "eslint src/",
43
+ "build": "node build.js",
44
+ "dev": "tsc --watch",
45
+ "dev:cli": "pnpm build && node bin/zhuanspec.js",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
48
+ "test:ui": "vitest --ui",
49
+ "test:coverage": "vitest --coverage",
50
+ "test:postinstall": "node scripts/postinstall.js",
51
+ "prepare": "pnpm run build",
52
+ "prepublishOnly": "pnpm run build",
53
+ "postinstall": "node scripts/postinstall.js",
54
+ "check:pack-version": "node scripts/pack-version-check.mjs",
55
+ "diagnose:cursor": "node scripts/diagnose-cursor-commands.js",
56
+ "release": "pnpm run release:ci",
57
+ "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
58
+ "release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
59
+ "changeset": "changeset"
60
+ },
41
61
  "engines": {
42
62
  "node": ">=20.19.0"
43
63
  },
@@ -59,23 +79,5 @@
59
79
  "ora": "^8.2.0",
60
80
  "yaml": "^2.8.2",
61
81
  "zod": "^4.0.17"
62
- },
63
- "scripts": {
64
- "lint": "eslint src/",
65
- "build": "node build.js",
66
- "dev": "tsc --watch",
67
- "dev:cli": "pnpm build && node bin/zhuanspec.js",
68
- "test": "vitest run",
69
- "test:watch": "vitest",
70
- "test:ui": "vitest --ui",
71
- "test:coverage": "vitest --coverage",
72
- "test:postinstall": "node scripts/postinstall.js",
73
- "postinstall": "node scripts/postinstall.js",
74
- "check:pack-version": "node scripts/pack-version-check.mjs",
75
- "diagnose:cursor": "node scripts/diagnose-cursor-commands.js",
76
- "release": "pnpm run release:ci",
77
- "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
78
- "release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
79
- "changeset": "changeset"
80
82
  }
81
- }
83
+ }