specweave 0.21.3 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +198 -6
- package/README.md +33 -3
- package/dist/plugins/specweave-github/lib/CodeValidator.d.ts +101 -0
- package/dist/plugins/specweave-github/lib/CodeValidator.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/CodeValidator.js +219 -0
- package/dist/plugins/specweave-github/lib/CodeValidator.js.map +1 -0
- package/dist/plugins/specweave-github/lib/ThreeLayerSyncManager.d.ts +182 -0
- package/dist/plugins/specweave-github/lib/ThreeLayerSyncManager.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/ThreeLayerSyncManager.js +603 -0
- package/dist/plugins/specweave-github/lib/ThreeLayerSyncManager.js.map +1 -0
- package/dist/plugins/specweave-github/lib/types.d.ts +34 -0
- package/dist/plugins/specweave-github/lib/types.d.ts.map +1 -1
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +60 -5
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/config/types.d.ts +8 -8
- package/dist/src/core/living-docs/CompletionPropagator.d.ts.map +1 -1
- package/dist/src/core/living-docs/CompletionPropagator.js +4 -3
- package/dist/src/core/living-docs/CompletionPropagator.js.map +1 -1
- package/dist/src/core/living-docs/SpecDistributor.d.ts +5 -0
- package/dist/src/core/living-docs/SpecDistributor.d.ts.map +1 -1
- package/dist/src/core/living-docs/SpecDistributor.js +12 -0
- package/dist/src/core/living-docs/SpecDistributor.js.map +1 -1
- package/dist/src/core/living-docs/project-detector.d.ts.map +1 -1
- package/dist/src/core/living-docs/project-detector.js +38 -0
- package/dist/src/core/living-docs/project-detector.js.map +1 -1
- package/dist/src/core/types/config.d.ts +23 -0
- package/dist/src/core/types/config.d.ts.map +1 -1
- package/dist/src/core/types/config.js +10 -0
- package/dist/src/core/types/config.js.map +1 -1
- package/dist/src/init/ArchitecturePresenter.d.ts +47 -0
- package/dist/src/init/ArchitecturePresenter.d.ts.map +1 -0
- package/dist/src/init/ArchitecturePresenter.js +180 -0
- package/dist/src/init/ArchitecturePresenter.js.map +1 -0
- package/dist/src/init/InitFlow.d.ts.map +1 -1
- package/dist/src/init/InitFlow.js +30 -1
- package/dist/src/init/InitFlow.js.map +1 -1
- package/dist/src/init/architecture/CostEstimator.d.ts +52 -0
- package/dist/src/init/architecture/CostEstimator.d.ts.map +1 -0
- package/dist/src/init/architecture/CostEstimator.js +107 -0
- package/dist/src/init/architecture/CostEstimator.js.map +1 -0
- package/dist/src/init/architecture/InfrastructureMapper.d.ts +41 -0
- package/dist/src/init/architecture/InfrastructureMapper.d.ts.map +1 -0
- package/dist/src/init/architecture/InfrastructureMapper.js +140 -0
- package/dist/src/init/architecture/InfrastructureMapper.js.map +1 -0
- package/dist/src/init/architecture/ProjectGenerator.d.ts +44 -0
- package/dist/src/init/architecture/ProjectGenerator.d.ts.map +1 -0
- package/dist/src/init/architecture/ProjectGenerator.js +216 -0
- package/dist/src/init/architecture/ProjectGenerator.js.map +1 -0
- package/dist/src/init/research/src/config/types.d.ts +8 -8
- package/package.json +9 -8
- package/plugins/specweave-ado/lib/enhanced-ado-sync.js +170 -0
- package/plugins/specweave-github/lib/CodeValidator.js +195 -0
- package/plugins/specweave-github/lib/CodeValidator.ts +284 -0
- package/plugins/specweave-github/lib/ThreeLayerSyncManager.js +545 -0
- package/plugins/specweave-github/lib/ThreeLayerSyncManager.ts +809 -0
- package/plugins/specweave-github/lib/types.ts +38 -0
- package/plugins/specweave-release/hooks/.specweave/logs/dora-tracking.log +1200 -0
- package/src/templates/AGENTS.md.template +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specweave",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Spec-driven development framework for Claude Code. AI-native workflow with living documentation, intelligent agents, and multilingual support (9 languages). Enterprise-grade traceability with permanent specs and temporary increments.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,17 +16,18 @@
|
|
|
16
16
|
"dev": "tsc --watch",
|
|
17
17
|
"prepare": "npm run build",
|
|
18
18
|
"prepublishOnly": "npm run rebuild",
|
|
19
|
-
"test:unit": "
|
|
20
|
-
"test:integration": "
|
|
19
|
+
"test:unit": "vitest run tests/unit --coverage",
|
|
20
|
+
"test:integration": "vitest run tests/integration --coverage",
|
|
21
21
|
"test:smoke": "bash tests/smoke/smoke-test.sh",
|
|
22
22
|
"test:e2e": "playwright test tests/e2e/ --grep-invert=\"(should default to claude adapter|should use claude adapter when explicitly requested|should use generic adapter|should create .claude|should initialize project with specweave init|should create correct directory structure|should handle non-interactive mode correctly|should validate config.json structure|should create .specweave directory structure|should create CLAUDE.md and AGENTS.md|should initialize git repository|should install SpecWeave|should scaffold SaaS|should create proper directory|should create required configuration|should install core skills|should install core agents|should have deployment|should have Stripe|ADO Sync|Increment Discipline Blocking|Self-Reflection|Increment Discipline Enforcement)\"",
|
|
23
23
|
"test:all": "npm run test:unit && npm run test:integration && npm run test:e2e",
|
|
24
|
-
"test:coverage": "
|
|
24
|
+
"test:coverage": "vitest run --coverage",
|
|
25
25
|
"test": "npm run test:smoke",
|
|
26
26
|
"benchmark": "ts-node tests/performance/run-all-benchmarks.ts",
|
|
27
27
|
"validate:plugins": "node scripts/validate-plugin-manifests.cjs",
|
|
28
28
|
"validate:platforms": "npx ts-node scripts/validate-platforms.ts",
|
|
29
29
|
"metrics:dora": "node dist/src/metrics/dora-calculator.js",
|
|
30
|
+
"migrate:copy-sync": "node scripts/migrate-to-copy-based-sync.js",
|
|
30
31
|
"generate:diagrams": "bash scripts/generate-diagram-svgs.sh",
|
|
31
32
|
"docs:dev": "cd docs-site && npm start",
|
|
32
33
|
"docs:build": "cd docs-site && npm run build",
|
|
@@ -94,15 +95,15 @@
|
|
|
94
95
|
"@playwright/test": "^1.48.0",
|
|
95
96
|
"@types/fs-extra": "^11.0.4",
|
|
96
97
|
"@types/inquirer": "^9.0.7",
|
|
97
|
-
"@types/jest": "^30.0.0",
|
|
98
98
|
"@types/js-yaml": "^4.0.9",
|
|
99
99
|
"@types/node": "^24.10.0",
|
|
100
|
+
"@vitest/coverage-v8": "^2.1.0",
|
|
101
|
+
"@vitest/ui": "^2.1.0",
|
|
100
102
|
"ajv": "^8.17.1",
|
|
101
103
|
"ajv-formats": "^3.0.1",
|
|
102
104
|
"dotenv": "^17.2.3",
|
|
103
105
|
"gray-matter": "^4.0.3",
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"typescript": "^5.3.0"
|
|
106
|
+
"typescript": "^5.3.0",
|
|
107
|
+
"vitest": "^2.1.0"
|
|
107
108
|
}
|
|
108
109
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { AdoClientV2 } from "./ado-client-v2.js";
|
|
2
|
+
import { EnhancedContentBuilder } from "../../../src/core/sync/enhanced-content-builder.js";
|
|
3
|
+
import { SpecIncrementMapper } from "../../../src/core/sync/spec-increment-mapper.js";
|
|
4
|
+
import { parseSpecContent } from "../../../src/core/spec-content-sync.js";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import fs from "fs/promises";
|
|
7
|
+
async function syncSpecToAdoWithEnhancedContent(options) {
|
|
8
|
+
const { specPath, organization, project, dryRun = false, verbose = false } = options;
|
|
9
|
+
try {
|
|
10
|
+
const baseSpec = await parseSpecContent(specPath);
|
|
11
|
+
if (!baseSpec) {
|
|
12
|
+
return {
|
|
13
|
+
success: false,
|
|
14
|
+
action: "error",
|
|
15
|
+
error: "Failed to parse spec content"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (verbose) {
|
|
19
|
+
console.log(`\u{1F4C4} Parsed spec: ${baseSpec.identifier.compact}`);
|
|
20
|
+
}
|
|
21
|
+
const specId = baseSpec.identifier.full || baseSpec.identifier.compact;
|
|
22
|
+
const rootDir = await findSpecWeaveRoot(specPath);
|
|
23
|
+
const mapper = new SpecIncrementMapper(rootDir);
|
|
24
|
+
const mapping = await mapper.mapSpecToIncrements(specId);
|
|
25
|
+
if (verbose) {
|
|
26
|
+
console.log(`\u{1F517} Found ${mapping.increments.length} related increments`);
|
|
27
|
+
}
|
|
28
|
+
const taskMapping = buildTaskMapping(mapping.increments, organization, project);
|
|
29
|
+
const architectureDocs = await findArchitectureDocs(rootDir, specId);
|
|
30
|
+
const enhancedSpec = {
|
|
31
|
+
...baseSpec,
|
|
32
|
+
summary: baseSpec.description,
|
|
33
|
+
taskMapping,
|
|
34
|
+
architectureDocs
|
|
35
|
+
};
|
|
36
|
+
const builder = new EnhancedContentBuilder();
|
|
37
|
+
const description = builder.buildExternalDescription(enhancedSpec);
|
|
38
|
+
if (verbose) {
|
|
39
|
+
console.log(`\u{1F4DD} Generated description: ${description.length} characters`);
|
|
40
|
+
}
|
|
41
|
+
if (dryRun) {
|
|
42
|
+
console.log("\u{1F50D} DRY RUN - Would create/update feature with:");
|
|
43
|
+
console.log(` Title: ${baseSpec.title}`);
|
|
44
|
+
console.log(` Description length: ${description.length}`);
|
|
45
|
+
return {
|
|
46
|
+
success: true,
|
|
47
|
+
action: "no-change",
|
|
48
|
+
tasksLinked: taskMapping?.tasks.length || 0
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (!organization || !project) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
action: "error",
|
|
55
|
+
error: "Azure DevOps organization/project not specified"
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const profile = {
|
|
59
|
+
provider: "ado",
|
|
60
|
+
displayName: `${organization}/${project}`,
|
|
61
|
+
config: {
|
|
62
|
+
organization,
|
|
63
|
+
project
|
|
64
|
+
},
|
|
65
|
+
timeRange: { default: "1M", max: "6M" }
|
|
66
|
+
};
|
|
67
|
+
const pat = process.env.AZURE_DEVOPS_PAT || "";
|
|
68
|
+
const client = new AdoClientV2(profile, pat);
|
|
69
|
+
const existingFeature = await findExistingFeature(client, baseSpec.identifier.compact);
|
|
70
|
+
let result;
|
|
71
|
+
if (existingFeature) {
|
|
72
|
+
await client.updateWorkItem(existingFeature.id, {
|
|
73
|
+
title: `[${baseSpec.identifier.compact}] ${baseSpec.title}`,
|
|
74
|
+
description
|
|
75
|
+
});
|
|
76
|
+
result = {
|
|
77
|
+
success: true,
|
|
78
|
+
action: "updated",
|
|
79
|
+
featureId: existingFeature.id,
|
|
80
|
+
featureUrl: `https://dev.azure.com/${organization}/${project}/_workitems/edit/${existingFeature.id}`,
|
|
81
|
+
tasksLinked: taskMapping?.tasks.length || 0
|
|
82
|
+
};
|
|
83
|
+
} else {
|
|
84
|
+
const feature = await client.createEpic({
|
|
85
|
+
title: `[${baseSpec.identifier.compact}] ${baseSpec.title}`,
|
|
86
|
+
description,
|
|
87
|
+
tags: ["spec", "external-tool-sync"]
|
|
88
|
+
});
|
|
89
|
+
result = {
|
|
90
|
+
success: true,
|
|
91
|
+
action: "created",
|
|
92
|
+
featureId: feature.id,
|
|
93
|
+
featureUrl: `https://dev.azure.com/${organization}/${project}/_workitems/edit/${feature.id}`,
|
|
94
|
+
tasksLinked: taskMapping?.tasks.length || 0
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (verbose) {
|
|
98
|
+
console.log(`\u2705 ${result.action === "created" ? "Created" : "Updated"} feature #${result.featureId}`);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
action: "error",
|
|
105
|
+
error: error.message
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function findSpecWeaveRoot(specPath) {
|
|
110
|
+
let currentDir = path.dirname(specPath);
|
|
111
|
+
while (true) {
|
|
112
|
+
const specweaveDir = path.join(currentDir, ".specweave");
|
|
113
|
+
try {
|
|
114
|
+
await fs.access(specweaveDir);
|
|
115
|
+
return currentDir;
|
|
116
|
+
} catch {
|
|
117
|
+
const parentDir = path.dirname(currentDir);
|
|
118
|
+
if (parentDir === currentDir) {
|
|
119
|
+
throw new Error(".specweave directory not found");
|
|
120
|
+
}
|
|
121
|
+
currentDir = parentDir;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function buildTaskMapping(increments, organization, project) {
|
|
126
|
+
if (increments.length === 0) return void 0;
|
|
127
|
+
const firstIncrement = increments[0];
|
|
128
|
+
const tasks = firstIncrement.tasks.map((task) => ({
|
|
129
|
+
id: task.id,
|
|
130
|
+
title: task.title,
|
|
131
|
+
userStories: task.userStories
|
|
132
|
+
}));
|
|
133
|
+
return {
|
|
134
|
+
incrementId: firstIncrement.id,
|
|
135
|
+
tasks,
|
|
136
|
+
tasksUrl: `https://dev.azure.com/${organization}/${project}/_git/repo?path=/.specweave/increments/${firstIncrement.id}/tasks.md`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async function findArchitectureDocs(rootDir, specId) {
|
|
140
|
+
const docs = [];
|
|
141
|
+
const archDir = path.join(rootDir, ".specweave/docs/internal/architecture");
|
|
142
|
+
try {
|
|
143
|
+
const adrDir = path.join(archDir, "adr");
|
|
144
|
+
try {
|
|
145
|
+
const adrs = await fs.readdir(adrDir);
|
|
146
|
+
const relatedAdrs = adrs.filter((file) => file.includes(specId.replace("spec-", "")));
|
|
147
|
+
for (const adr of relatedAdrs) {
|
|
148
|
+
docs.push({
|
|
149
|
+
type: "adr",
|
|
150
|
+
path: path.join(adrDir, adr),
|
|
151
|
+
title: adr.replace(".md", "").replace(/-/g, " ")
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
return docs;
|
|
159
|
+
}
|
|
160
|
+
async function findExistingFeature(client, specId) {
|
|
161
|
+
try {
|
|
162
|
+
const features = await client.queryWorkItems(`[System.Title] Contains '[${specId}]' AND [System.WorkItemType] = 'Feature'`);
|
|
163
|
+
return features[0] || null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export {
|
|
169
|
+
syncSpecToAdoWithEnhancedContent
|
|
170
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
class CodeValidator {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.options = {
|
|
6
|
+
minLines: options.minLines ?? 3,
|
|
7
|
+
minChars: options.minChars ?? 50,
|
|
8
|
+
projectRoot: options.projectRoot ?? process.cwd()
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Validate that code exists for a task
|
|
13
|
+
*
|
|
14
|
+
* Extracts file paths from task description and verifies:
|
|
15
|
+
* 1. Files exist
|
|
16
|
+
* 2. Files have meaningful content
|
|
17
|
+
* 3. Files are not just stubs
|
|
18
|
+
*
|
|
19
|
+
* @param taskDescription - Task description with file paths
|
|
20
|
+
* @param taskId - Task ID for error messages
|
|
21
|
+
* @returns Validation result
|
|
22
|
+
*/
|
|
23
|
+
async validateTask(taskDescription, taskId) {
|
|
24
|
+
const filePaths = this.extractFilePaths(taskDescription);
|
|
25
|
+
if (filePaths.length === 0) {
|
|
26
|
+
return {
|
|
27
|
+
taskId,
|
|
28
|
+
valid: true,
|
|
29
|
+
files: [],
|
|
30
|
+
reason: "No file paths specified in task description"
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const fileResults = [];
|
|
34
|
+
let allValid = true;
|
|
35
|
+
const reasons = [];
|
|
36
|
+
for (const filePath of filePaths) {
|
|
37
|
+
const result = await this.validateFile(filePath);
|
|
38
|
+
fileResults.push(result);
|
|
39
|
+
if (!result.exists) {
|
|
40
|
+
allValid = false;
|
|
41
|
+
reasons.push(`File not found: ${filePath}`);
|
|
42
|
+
} else if (!result.hasContent) {
|
|
43
|
+
allValid = false;
|
|
44
|
+
reasons.push(`File has no meaningful content: ${filePath} (${result.reason})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
taskId,
|
|
49
|
+
valid: allValid,
|
|
50
|
+
files: fileResults,
|
|
51
|
+
reason: reasons.length > 0 ? reasons.join("; ") : void 0
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validate a single file
|
|
56
|
+
*
|
|
57
|
+
* @param filePath - Path to file (relative or absolute)
|
|
58
|
+
* @returns File validation result
|
|
59
|
+
*/
|
|
60
|
+
async validateFile(filePath) {
|
|
61
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(this.options.projectRoot, filePath);
|
|
62
|
+
const exists = await fs.pathExists(absolutePath);
|
|
63
|
+
if (!exists) {
|
|
64
|
+
return {
|
|
65
|
+
path: filePath,
|
|
66
|
+
exists: false,
|
|
67
|
+
hasContent: false,
|
|
68
|
+
lineCount: 0,
|
|
69
|
+
reason: "File does not exist"
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const content = await fs.readFile(absolutePath, "utf-8");
|
|
73
|
+
const lines = content.split("\n");
|
|
74
|
+
const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
|
|
75
|
+
if (nonEmptyLines.length < this.options.minLines) {
|
|
76
|
+
return {
|
|
77
|
+
path: filePath,
|
|
78
|
+
exists: true,
|
|
79
|
+
hasContent: false,
|
|
80
|
+
lineCount: nonEmptyLines.length,
|
|
81
|
+
reason: `Only ${nonEmptyLines.length} non-empty lines (minimum: ${this.options.minLines})`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const trimmedContent = content.trim();
|
|
85
|
+
if (trimmedContent.length < this.options.minChars) {
|
|
86
|
+
return {
|
|
87
|
+
path: filePath,
|
|
88
|
+
exists: true,
|
|
89
|
+
hasContent: false,
|
|
90
|
+
lineCount: nonEmptyLines.length,
|
|
91
|
+
reason: `Only ${trimmedContent.length} characters (minimum: ${this.options.minChars})`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const stubPatterns = [
|
|
95
|
+
/^\/\/\s*TODO:/i,
|
|
96
|
+
/^#\s*TODO:/i,
|
|
97
|
+
/^\s*throw new Error\(['"]Not implemented['"]\)/i,
|
|
98
|
+
/^\s*return null;?\s*$/m,
|
|
99
|
+
/^\s*pass\s*$/m,
|
|
100
|
+
// Python
|
|
101
|
+
/^\s*\.\.\.$/m
|
|
102
|
+
// TypeScript
|
|
103
|
+
];
|
|
104
|
+
const isStub = stubPatterns.some((pattern) => pattern.test(trimmedContent));
|
|
105
|
+
if (isStub) {
|
|
106
|
+
return {
|
|
107
|
+
path: filePath,
|
|
108
|
+
exists: true,
|
|
109
|
+
hasContent: false,
|
|
110
|
+
lineCount: nonEmptyLines.length,
|
|
111
|
+
reason: "File contains stub/placeholder code"
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
path: filePath,
|
|
116
|
+
exists: true,
|
|
117
|
+
hasContent: true,
|
|
118
|
+
lineCount: nonEmptyLines.length
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Extract file paths from task description
|
|
123
|
+
*
|
|
124
|
+
* Supports multiple formats:
|
|
125
|
+
* - **Files**: src/foo.ts, src/bar.ts
|
|
126
|
+
* - **Files to create**: src/foo.ts
|
|
127
|
+
* - **Files to modify**: src/bar.ts
|
|
128
|
+
* - Inline code blocks with file paths
|
|
129
|
+
*
|
|
130
|
+
* @param description - Task description text
|
|
131
|
+
* @returns Array of file paths
|
|
132
|
+
*/
|
|
133
|
+
extractFilePaths(description) {
|
|
134
|
+
const paths = /* @__PURE__ */ new Set();
|
|
135
|
+
const filesMatch = description.match(/\*\*Files\*\*:\s*([^\n]+)/i);
|
|
136
|
+
if (filesMatch) {
|
|
137
|
+
const filePaths = filesMatch[1].split(",").map((p) => p.trim());
|
|
138
|
+
filePaths.forEach((p) => paths.add(p));
|
|
139
|
+
}
|
|
140
|
+
const createMatch = description.match(/\*\*Files to create\*\*:\s*([^\n]+)/i);
|
|
141
|
+
if (createMatch) {
|
|
142
|
+
const filePaths = createMatch[1].split(",").map((p) => p.trim());
|
|
143
|
+
filePaths.forEach((p) => paths.add(p));
|
|
144
|
+
}
|
|
145
|
+
const modifyMatch = description.match(/\*\*Files to modify\*\*:\s*([^\n]+)/i);
|
|
146
|
+
if (modifyMatch) {
|
|
147
|
+
const filePaths = modifyMatch[1].split(",").map((p) => p.trim());
|
|
148
|
+
filePaths.forEach((p) => paths.add(p));
|
|
149
|
+
}
|
|
150
|
+
const inlineMatches = description.matchAll(/`([a-zA-Z0-9_\-./]+\.(ts|js|tsx|jsx|py|java|go|rs|cpp|c|h))`/g);
|
|
151
|
+
for (const match of inlineMatches) {
|
|
152
|
+
paths.add(match[1]);
|
|
153
|
+
}
|
|
154
|
+
const listMatches = description.matchAll(/^[-*]\s+([a-zA-Z0-9_\-./]+\.(ts|js|tsx|jsx|py|java|go|rs|cpp|c|h))/gm);
|
|
155
|
+
for (const match of listMatches) {
|
|
156
|
+
paths.add(match[1]);
|
|
157
|
+
}
|
|
158
|
+
return Array.from(paths);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Batch validate multiple tasks
|
|
162
|
+
*
|
|
163
|
+
* @param tasks - Array of {taskId, description}
|
|
164
|
+
* @returns Array of validation results
|
|
165
|
+
*/
|
|
166
|
+
async validateTasks(tasks) {
|
|
167
|
+
const validationPromises = tasks.map(
|
|
168
|
+
(task) => this.validateTask(task.description, task.taskId)
|
|
169
|
+
);
|
|
170
|
+
return Promise.all(validationPromises);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Get summary of validation results
|
|
174
|
+
*
|
|
175
|
+
* @param results - Array of task validation results
|
|
176
|
+
* @returns Summary statistics
|
|
177
|
+
*/
|
|
178
|
+
summarizeResults(results) {
|
|
179
|
+
const total = results.length;
|
|
180
|
+
const valid = results.filter((r) => r.valid).length;
|
|
181
|
+
const invalid = results.filter((r) => !r.valid).length;
|
|
182
|
+
const noFiles = results.filter((r) => r.files.length === 0).length;
|
|
183
|
+
const invalidTasks = results.filter((r) => !r.valid).map((r) => r.taskId);
|
|
184
|
+
return {
|
|
185
|
+
total,
|
|
186
|
+
valid,
|
|
187
|
+
invalid,
|
|
188
|
+
noFiles,
|
|
189
|
+
invalidTasks
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export {
|
|
194
|
+
CodeValidator
|
|
195
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates that completed tasks have actual code implementation.
|
|
5
|
+
* Prevents marking tasks as complete when:
|
|
6
|
+
* - Files don't exist
|
|
7
|
+
* - Files are empty or have trivial content
|
|
8
|
+
* - Implementation is incomplete
|
|
9
|
+
*
|
|
10
|
+
* Used by ThreeLayerSyncManager to enforce code-completion discipline.
|
|
11
|
+
*
|
|
12
|
+
* @module CodeValidator
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs-extra';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* File validation result
|
|
20
|
+
*/
|
|
21
|
+
export interface FileValidationResult {
|
|
22
|
+
path: string;
|
|
23
|
+
exists: boolean;
|
|
24
|
+
hasContent: boolean;
|
|
25
|
+
lineCount: number;
|
|
26
|
+
reason?: string; // Why validation failed
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Task validation result
|
|
31
|
+
*/
|
|
32
|
+
export interface TaskValidationResult {
|
|
33
|
+
taskId: string;
|
|
34
|
+
valid: boolean;
|
|
35
|
+
files: FileValidationResult[];
|
|
36
|
+
reason?: string; // Summary of why task validation failed
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* CodeValidator options
|
|
41
|
+
*/
|
|
42
|
+
export interface CodeValidatorOptions {
|
|
43
|
+
minLines?: number; // Minimum lines for a file to be considered non-empty (default: 3)
|
|
44
|
+
minChars?: number; // Minimum characters for meaningful content (default: 50)
|
|
45
|
+
projectRoot?: string; // Project root for resolving relative paths
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class CodeValidator {
|
|
49
|
+
private options: Required<CodeValidatorOptions>;
|
|
50
|
+
|
|
51
|
+
constructor(options: CodeValidatorOptions = {}) {
|
|
52
|
+
this.options = {
|
|
53
|
+
minLines: options.minLines ?? 3,
|
|
54
|
+
minChars: options.minChars ?? 50,
|
|
55
|
+
projectRoot: options.projectRoot ?? process.cwd()
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate that code exists for a task
|
|
61
|
+
*
|
|
62
|
+
* Extracts file paths from task description and verifies:
|
|
63
|
+
* 1. Files exist
|
|
64
|
+
* 2. Files have meaningful content
|
|
65
|
+
* 3. Files are not just stubs
|
|
66
|
+
*
|
|
67
|
+
* @param taskDescription - Task description with file paths
|
|
68
|
+
* @param taskId - Task ID for error messages
|
|
69
|
+
* @returns Validation result
|
|
70
|
+
*/
|
|
71
|
+
async validateTask(taskDescription: string, taskId: string): Promise<TaskValidationResult> {
|
|
72
|
+
const filePaths = this.extractFilePaths(taskDescription);
|
|
73
|
+
|
|
74
|
+
if (filePaths.length === 0) {
|
|
75
|
+
// No file paths specified - consider it valid (task might be non-code)
|
|
76
|
+
return {
|
|
77
|
+
taskId,
|
|
78
|
+
valid: true,
|
|
79
|
+
files: [],
|
|
80
|
+
reason: 'No file paths specified in task description'
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fileResults: FileValidationResult[] = [];
|
|
85
|
+
let allValid = true;
|
|
86
|
+
const reasons: string[] = [];
|
|
87
|
+
|
|
88
|
+
for (const filePath of filePaths) {
|
|
89
|
+
const result = await this.validateFile(filePath);
|
|
90
|
+
fileResults.push(result);
|
|
91
|
+
|
|
92
|
+
if (!result.exists) {
|
|
93
|
+
allValid = false;
|
|
94
|
+
reasons.push(`File not found: ${filePath}`);
|
|
95
|
+
} else if (!result.hasContent) {
|
|
96
|
+
allValid = false;
|
|
97
|
+
reasons.push(`File has no meaningful content: ${filePath} (${result.reason})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
taskId,
|
|
103
|
+
valid: allValid,
|
|
104
|
+
files: fileResults,
|
|
105
|
+
reason: reasons.length > 0 ? reasons.join('; ') : undefined
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validate a single file
|
|
111
|
+
*
|
|
112
|
+
* @param filePath - Path to file (relative or absolute)
|
|
113
|
+
* @returns File validation result
|
|
114
|
+
*/
|
|
115
|
+
async validateFile(filePath: string): Promise<FileValidationResult> {
|
|
116
|
+
// Resolve relative paths
|
|
117
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
118
|
+
? filePath
|
|
119
|
+
: path.join(this.options.projectRoot, filePath);
|
|
120
|
+
|
|
121
|
+
// Check if file exists
|
|
122
|
+
const exists = await fs.pathExists(absolutePath);
|
|
123
|
+
if (!exists) {
|
|
124
|
+
return {
|
|
125
|
+
path: filePath,
|
|
126
|
+
exists: false,
|
|
127
|
+
hasContent: false,
|
|
128
|
+
lineCount: 0,
|
|
129
|
+
reason: 'File does not exist'
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read file content
|
|
134
|
+
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
135
|
+
const lines = content.split('\n');
|
|
136
|
+
const nonEmptyLines = lines.filter(line => line.trim().length > 0);
|
|
137
|
+
|
|
138
|
+
// Check line count
|
|
139
|
+
if (nonEmptyLines.length < this.options.minLines) {
|
|
140
|
+
return {
|
|
141
|
+
path: filePath,
|
|
142
|
+
exists: true,
|
|
143
|
+
hasContent: false,
|
|
144
|
+
lineCount: nonEmptyLines.length,
|
|
145
|
+
reason: `Only ${nonEmptyLines.length} non-empty lines (minimum: ${this.options.minLines})`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check character count
|
|
150
|
+
const trimmedContent = content.trim();
|
|
151
|
+
if (trimmedContent.length < this.options.minChars) {
|
|
152
|
+
return {
|
|
153
|
+
path: filePath,
|
|
154
|
+
exists: true,
|
|
155
|
+
hasContent: false,
|
|
156
|
+
lineCount: nonEmptyLines.length,
|
|
157
|
+
reason: `Only ${trimmedContent.length} characters (minimum: ${this.options.minChars})`
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check for stub patterns (common placeholder patterns)
|
|
162
|
+
const stubPatterns = [
|
|
163
|
+
/^\/\/\s*TODO:/i,
|
|
164
|
+
/^#\s*TODO:/i,
|
|
165
|
+
/^\s*throw new Error\(['"]Not implemented['"]\)/i,
|
|
166
|
+
/^\s*return null;?\s*$/m,
|
|
167
|
+
/^\s*pass\s*$/m, // Python
|
|
168
|
+
/^\s*\.\.\.$/m // TypeScript
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const isStub = stubPatterns.some(pattern => pattern.test(trimmedContent));
|
|
172
|
+
if (isStub) {
|
|
173
|
+
return {
|
|
174
|
+
path: filePath,
|
|
175
|
+
exists: true,
|
|
176
|
+
hasContent: false,
|
|
177
|
+
lineCount: nonEmptyLines.length,
|
|
178
|
+
reason: 'File contains stub/placeholder code'
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// All checks passed
|
|
183
|
+
return {
|
|
184
|
+
path: filePath,
|
|
185
|
+
exists: true,
|
|
186
|
+
hasContent: true,
|
|
187
|
+
lineCount: nonEmptyLines.length
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Extract file paths from task description
|
|
193
|
+
*
|
|
194
|
+
* Supports multiple formats:
|
|
195
|
+
* - **Files**: src/foo.ts, src/bar.ts
|
|
196
|
+
* - **Files to create**: src/foo.ts
|
|
197
|
+
* - **Files to modify**: src/bar.ts
|
|
198
|
+
* - Inline code blocks with file paths
|
|
199
|
+
*
|
|
200
|
+
* @param description - Task description text
|
|
201
|
+
* @returns Array of file paths
|
|
202
|
+
*/
|
|
203
|
+
extractFilePaths(description: string): string[] {
|
|
204
|
+
const paths: Set<string> = new Set();
|
|
205
|
+
|
|
206
|
+
// Pattern 1: **Files**: path1, path2, path3
|
|
207
|
+
const filesMatch = description.match(/\*\*Files\*\*:\s*([^\n]+)/i);
|
|
208
|
+
if (filesMatch) {
|
|
209
|
+
const filePaths = filesMatch[1].split(',').map(p => p.trim());
|
|
210
|
+
filePaths.forEach(p => paths.add(p));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Pattern 2: **Files to create**: path1, path2
|
|
214
|
+
const createMatch = description.match(/\*\*Files to create\*\*:\s*([^\n]+)/i);
|
|
215
|
+
if (createMatch) {
|
|
216
|
+
const filePaths = createMatch[1].split(',').map(p => p.trim());
|
|
217
|
+
filePaths.forEach(p => paths.add(p));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Pattern 3: **Files to modify**: path1, path2
|
|
221
|
+
const modifyMatch = description.match(/\*\*Files to modify\*\*:\s*([^\n]+)/i);
|
|
222
|
+
if (modifyMatch) {
|
|
223
|
+
const filePaths = modifyMatch[1].split(',').map(p => p.trim());
|
|
224
|
+
filePaths.forEach(p => paths.add(p));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Pattern 4: Inline file references (e.g., `src/foo/bar.ts`)
|
|
228
|
+
const inlineMatches = description.matchAll(/`([a-zA-Z0-9_\-./]+\.(ts|js|tsx|jsx|py|java|go|rs|cpp|c|h))`/g);
|
|
229
|
+
for (const match of inlineMatches) {
|
|
230
|
+
paths.add(match[1]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Pattern 5: Markdown list items with file paths
|
|
234
|
+
const listMatches = description.matchAll(/^[-*]\s+([a-zA-Z0-9_\-./]+\.(ts|js|tsx|jsx|py|java|go|rs|cpp|c|h))/gm);
|
|
235
|
+
for (const match of listMatches) {
|
|
236
|
+
paths.add(match[1]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return Array.from(paths);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Batch validate multiple tasks
|
|
244
|
+
*
|
|
245
|
+
* @param tasks - Array of {taskId, description}
|
|
246
|
+
* @returns Array of validation results
|
|
247
|
+
*/
|
|
248
|
+
async validateTasks(tasks: Array<{ taskId: string; description: string }>): Promise<TaskValidationResult[]> {
|
|
249
|
+
// Use parallel validation for performance
|
|
250
|
+
const validationPromises = tasks.map(task =>
|
|
251
|
+
this.validateTask(task.description, task.taskId)
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return Promise.all(validationPromises);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get summary of validation results
|
|
259
|
+
*
|
|
260
|
+
* @param results - Array of task validation results
|
|
261
|
+
* @returns Summary statistics
|
|
262
|
+
*/
|
|
263
|
+
summarizeResults(results: TaskValidationResult[]): {
|
|
264
|
+
total: number;
|
|
265
|
+
valid: number;
|
|
266
|
+
invalid: number;
|
|
267
|
+
noFiles: number;
|
|
268
|
+
invalidTasks: string[];
|
|
269
|
+
} {
|
|
270
|
+
const total = results.length;
|
|
271
|
+
const valid = results.filter(r => r.valid).length;
|
|
272
|
+
const invalid = results.filter(r => !r.valid).length;
|
|
273
|
+
const noFiles = results.filter(r => r.files.length === 0).length;
|
|
274
|
+
const invalidTasks = results.filter(r => !r.valid).map(r => r.taskId);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
total,
|
|
278
|
+
valid,
|
|
279
|
+
invalid,
|
|
280
|
+
noFiles,
|
|
281
|
+
invalidTasks
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|