@mainahq/core 0.2.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/README.md +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { traceFeature } from "../traceability";
|
|
6
|
+
|
|
7
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function makeTmpDir(): string {
|
|
10
|
+
const dir = join(
|
|
11
|
+
tmpdir(),
|
|
12
|
+
`maina-trace-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
13
|
+
);
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PLAN_WITH_TASKS = `# Implementation Plan
|
|
19
|
+
|
|
20
|
+
## Tasks
|
|
21
|
+
|
|
22
|
+
- T001: Implement login with email validation
|
|
23
|
+
- T002: Add error messages for failed attempts
|
|
24
|
+
- T003: Create session management middleware
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("traceFeature", () => {
|
|
30
|
+
let tmpDir: string;
|
|
31
|
+
let featureDir: string;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
tmpDir = makeTmpDir();
|
|
35
|
+
featureDir = join(tmpDir, ".maina", "features", "001-user-auth");
|
|
36
|
+
mkdirSync(featureDir, { recursive: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("feature with tasks and matching test files are traced", async () => {
|
|
44
|
+
writeFileSync(join(featureDir, "plan.md"), PLAN_WITH_TASKS);
|
|
45
|
+
|
|
46
|
+
// Create a test file that references T001
|
|
47
|
+
const testDir = join(tmpDir, "src", "__tests__");
|
|
48
|
+
mkdirSync(testDir, { recursive: true });
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(testDir, "login.test.ts"),
|
|
51
|
+
`// T001: login tests\nimport { describe } from "bun:test";\n`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const result = await traceFeature(featureDir, tmpDir);
|
|
55
|
+
|
|
56
|
+
expect(result.ok).toBe(true);
|
|
57
|
+
if (!result.ok) return;
|
|
58
|
+
|
|
59
|
+
const t001 = result.value.tasks.find((t) => t.taskId === "T001");
|
|
60
|
+
expect(t001).toBeDefined();
|
|
61
|
+
expect(t001?.testFile).toContain("login.test.ts");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("feature with task in commit message finds commit hash", async () => {
|
|
65
|
+
writeFileSync(join(featureDir, "plan.md"), PLAN_WITH_TASKS);
|
|
66
|
+
|
|
67
|
+
// Mock git log by providing a gitLog dependency
|
|
68
|
+
const result = await traceFeature(featureDir, tmpDir, {
|
|
69
|
+
gitLog: async () =>
|
|
70
|
+
"abc1234 feat(core): T001 implement login\ndef5678 fix: typo\n",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.ok).toBe(true);
|
|
74
|
+
if (!result.ok) return;
|
|
75
|
+
|
|
76
|
+
const t001 = result.value.tasks.find((t) => t.taskId === "T001");
|
|
77
|
+
expect(t001).toBeDefined();
|
|
78
|
+
expect(t001?.commitHash).toBe("abc1234");
|
|
79
|
+
|
|
80
|
+
// T002 has no matching commit
|
|
81
|
+
const t002 = result.value.tasks.find((t) => t.taskId === "T002");
|
|
82
|
+
expect(t002?.commitHash).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("feature with missing test file has testFile as null", async () => {
|
|
86
|
+
writeFileSync(join(featureDir, "plan.md"), PLAN_WITH_TASKS);
|
|
87
|
+
|
|
88
|
+
const result = await traceFeature(featureDir, tmpDir);
|
|
89
|
+
|
|
90
|
+
expect(result.ok).toBe(true);
|
|
91
|
+
if (!result.ok) return;
|
|
92
|
+
|
|
93
|
+
// No test files created, so all testFile should be null
|
|
94
|
+
for (const task of result.value.tasks) {
|
|
95
|
+
expect(task.testFile).toBeNull();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("feature with no plan.md returns error Result", async () => {
|
|
100
|
+
// featureDir exists but no plan.md
|
|
101
|
+
const result = await traceFeature(featureDir, tmpDir);
|
|
102
|
+
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
if (result.ok) return;
|
|
105
|
+
|
|
106
|
+
expect(result.error).toContain("plan.md");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("coverage counts are correct", async () => {
|
|
110
|
+
writeFileSync(join(featureDir, "plan.md"), PLAN_WITH_TASKS);
|
|
111
|
+
|
|
112
|
+
// Create test file for T001
|
|
113
|
+
const testDir = join(tmpDir, "src", "__tests__");
|
|
114
|
+
mkdirSync(testDir, { recursive: true });
|
|
115
|
+
writeFileSync(join(testDir, "login.test.ts"), `// T001: login tests\n`);
|
|
116
|
+
|
|
117
|
+
// Create impl file that mentions "login" and "email" (matching T001 keywords)
|
|
118
|
+
const srcDir = join(tmpDir, "src");
|
|
119
|
+
writeFileSync(
|
|
120
|
+
join(srcDir, "login.ts"),
|
|
121
|
+
`// T001: Implement login with email validation\nexport function login() {}\n`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const result = await traceFeature(featureDir, tmpDir, {
|
|
125
|
+
gitLog: async () =>
|
|
126
|
+
"abc1234 feat: T001 login\ndef5678 feat: T002 error messages\n",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.ok).toBe(true);
|
|
130
|
+
if (!result.ok) return;
|
|
131
|
+
|
|
132
|
+
const { coverage } = result.value;
|
|
133
|
+
expect(coverage.total).toBe(3);
|
|
134
|
+
expect(coverage.withTests).toBe(1); // Only T001 has matching test
|
|
135
|
+
expect(coverage.withCommits).toBe(2); // T001 and T002 have commits
|
|
136
|
+
expect(coverage.withImpl).toBe(1); // T001 has matching impl
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("non-existent feature directory returns error Result", async () => {
|
|
140
|
+
const result = await traceFeature(join(tmpDir, "nonexistent"), tmpDir);
|
|
141
|
+
|
|
142
|
+
expect(result.ok).toBe(false);
|
|
143
|
+
if (result.ok) return;
|
|
144
|
+
|
|
145
|
+
expect(result.error).toContain("does not exist");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-artifact consistency analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Checks consistency across the three files in a feature directory:
|
|
5
|
+
* - spec.md — WHAT and WHY (user stories, acceptance criteria)
|
|
6
|
+
* - plan.md — HOW (architecture, tasks)
|
|
7
|
+
* - tasks.md — Task breakdown (task list with status)
|
|
8
|
+
*
|
|
9
|
+
* Checks performed:
|
|
10
|
+
* 1. Missing files
|
|
11
|
+
* 2. Spec coverage — acceptance criteria addressed by tasks
|
|
12
|
+
* 3. Orphaned tasks — tasks not mapping to any spec requirement
|
|
13
|
+
* 4. WHAT/WHY vs HOW separation — implementation details in spec, user stories in plan
|
|
14
|
+
* 5. Task status consistency — task counts match between plan.md and tasks.md
|
|
15
|
+
* 6. Contradictions — conflicting information between plan.md and tasks.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { Result } from "../db/index";
|
|
21
|
+
import { extractAcceptanceCriteria, STOP_WORDS } from "../utils";
|
|
22
|
+
|
|
23
|
+
export interface AnalysisReport {
|
|
24
|
+
featureDir: string;
|
|
25
|
+
findings: AnalysisFinding[];
|
|
26
|
+
summary: { errors: number; warnings: number; info: number };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AnalysisFinding {
|
|
30
|
+
severity: "error" | "warning" | "info";
|
|
31
|
+
category:
|
|
32
|
+
| "missing-file"
|
|
33
|
+
| "spec-coverage"
|
|
34
|
+
| "orphaned-task"
|
|
35
|
+
| "separation-violation"
|
|
36
|
+
| "task-consistency"
|
|
37
|
+
| "contradiction";
|
|
38
|
+
message: string;
|
|
39
|
+
file?: string;
|
|
40
|
+
line?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read a file if it exists, returning null if missing.
|
|
45
|
+
*/
|
|
46
|
+
function readOptionalFile(path: string): string | null {
|
|
47
|
+
if (!existsSync(path)) return null;
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync(path, "utf-8");
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract task lines from a markdown file's `## Tasks` section.
|
|
57
|
+
* Returns objects with the task id (if present) and description.
|
|
58
|
+
*/
|
|
59
|
+
interface ParsedTask {
|
|
60
|
+
id: string | null;
|
|
61
|
+
description: string;
|
|
62
|
+
fullLine: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractTasks(content: string): ParsedTask[] {
|
|
66
|
+
const lines = content.split("\n");
|
|
67
|
+
const tasks: ParsedTask[] = [];
|
|
68
|
+
let inSection = false;
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
|
|
73
|
+
if (/^##?\s+tasks/i.test(trimmed)) {
|
|
74
|
+
inSection = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
inSection &&
|
|
80
|
+
/^##\s/.test(trimmed) &&
|
|
81
|
+
!/^###\s+(T\d+):/i.test(trimmed)
|
|
82
|
+
) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Support checklist format: - [ ] T001: description
|
|
87
|
+
if (inSection && trimmed.startsWith("-")) {
|
|
88
|
+
const taskContent = trimmed.replace(/^-\s*(\[.\]\s*)?/, "").trim();
|
|
89
|
+
if (taskContent.length === 0) continue;
|
|
90
|
+
|
|
91
|
+
const idMatch = taskContent.match(/^(T\d+):\s*(.*)/i);
|
|
92
|
+
if (idMatch) {
|
|
93
|
+
tasks.push({
|
|
94
|
+
id: idMatch[1]?.toUpperCase() ?? null,
|
|
95
|
+
description: idMatch[2] ?? "",
|
|
96
|
+
fullLine: taskContent,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
tasks.push({
|
|
100
|
+
id: null,
|
|
101
|
+
description: taskContent,
|
|
102
|
+
fullLine: taskContent,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Support heading format: ### T001: description
|
|
108
|
+
if (!inSection || !trimmed.startsWith("-")) {
|
|
109
|
+
const headingMatch = trimmed.match(/^###\s+(T\d+):\s*(.*)/i);
|
|
110
|
+
if (headingMatch) {
|
|
111
|
+
const id = headingMatch[1]?.toUpperCase() ?? null;
|
|
112
|
+
// Avoid duplicates if already found in ## Tasks checklist
|
|
113
|
+
if (!tasks.some((t) => t.id === id)) {
|
|
114
|
+
tasks.push({
|
|
115
|
+
id,
|
|
116
|
+
description: headingMatch[2] ?? "",
|
|
117
|
+
fullLine: `${id}: ${headingMatch[2] ?? ""}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return tasks;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract significant keywords from a text (3+ chars, not stop words).
|
|
129
|
+
*/
|
|
130
|
+
function significantWords(text: string): string[] {
|
|
131
|
+
return text
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.split(/\s+/)
|
|
134
|
+
.filter((w) => w.length >= 3)
|
|
135
|
+
.filter((w) => !STOP_WORDS.has(w));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check 1: Missing files.
|
|
140
|
+
*/
|
|
141
|
+
function checkMissingFiles(
|
|
142
|
+
specContent: string | null,
|
|
143
|
+
planContent: string | null,
|
|
144
|
+
tasksContent: string | null,
|
|
145
|
+
): AnalysisFinding[] {
|
|
146
|
+
const findings: AnalysisFinding[] = [];
|
|
147
|
+
|
|
148
|
+
if (specContent === null) {
|
|
149
|
+
findings.push({
|
|
150
|
+
severity: "warning",
|
|
151
|
+
category: "missing-file",
|
|
152
|
+
message: "spec.md is missing — cannot verify WHAT/WHY requirements",
|
|
153
|
+
file: "spec.md",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (planContent === null) {
|
|
158
|
+
findings.push({
|
|
159
|
+
severity: "warning",
|
|
160
|
+
category: "missing-file",
|
|
161
|
+
message: "plan.md is missing — cannot verify HOW implementation plan",
|
|
162
|
+
file: "plan.md",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (tasksContent === null) {
|
|
167
|
+
findings.push({
|
|
168
|
+
severity: "info",
|
|
169
|
+
category: "missing-file",
|
|
170
|
+
message: "tasks.md is missing — task tracking not available",
|
|
171
|
+
file: "tasks.md",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check 2: Spec coverage — every acceptance criterion should be addressed by at least one task.
|
|
180
|
+
*/
|
|
181
|
+
function checkSpecCoverage(
|
|
182
|
+
specContent: string,
|
|
183
|
+
planTasks: ParsedTask[],
|
|
184
|
+
tasksTasks: ParsedTask[],
|
|
185
|
+
): AnalysisFinding[] {
|
|
186
|
+
const criteria = extractAcceptanceCriteria(specContent);
|
|
187
|
+
const allTasks = [...planTasks, ...tasksTasks];
|
|
188
|
+
const allTasksText = allTasks
|
|
189
|
+
.map((t) => t.description.toLowerCase())
|
|
190
|
+
.join(" ");
|
|
191
|
+
|
|
192
|
+
const findings: AnalysisFinding[] = [];
|
|
193
|
+
|
|
194
|
+
for (const criterion of criteria) {
|
|
195
|
+
const keywords = significantWords(criterion);
|
|
196
|
+
if (keywords.length === 0) continue;
|
|
197
|
+
|
|
198
|
+
const matchedCount = keywords.filter((kw) =>
|
|
199
|
+
allTasksText.includes(kw),
|
|
200
|
+
).length;
|
|
201
|
+
const coverage = matchedCount / keywords.length;
|
|
202
|
+
|
|
203
|
+
if (coverage < 0.5) {
|
|
204
|
+
findings.push({
|
|
205
|
+
severity: "error",
|
|
206
|
+
category: "spec-coverage",
|
|
207
|
+
message: `Acceptance criterion not covered by any task: "${criterion}"`,
|
|
208
|
+
file: "spec.md",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check 3: Orphaned tasks — tasks that don't map to any requirement in spec.md.
|
|
218
|
+
*/
|
|
219
|
+
function checkOrphanedTasks(
|
|
220
|
+
specContent: string,
|
|
221
|
+
planTasks: ParsedTask[],
|
|
222
|
+
tasksTasks: ParsedTask[],
|
|
223
|
+
): AnalysisFinding[] {
|
|
224
|
+
// Include full spec text for broad keyword matching
|
|
225
|
+
const specLower = specContent.toLowerCase();
|
|
226
|
+
const allSpecWords = new Set(significantWords(specLower));
|
|
227
|
+
|
|
228
|
+
const findings: AnalysisFinding[] = [];
|
|
229
|
+
|
|
230
|
+
// Deduplicate tasks by id to avoid checking the same task from both files
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
const allTasks = [...planTasks, ...tasksTasks];
|
|
233
|
+
|
|
234
|
+
for (const task of allTasks) {
|
|
235
|
+
const key = task.id ?? task.description;
|
|
236
|
+
if (seen.has(key)) continue;
|
|
237
|
+
seen.add(key);
|
|
238
|
+
|
|
239
|
+
// Check if task references requirement IDs (R1, AC1, etc.) that appear in spec
|
|
240
|
+
const refPattern = /\b(?:R\d+|AC\d+)\b/gi;
|
|
241
|
+
const taskRefs = task.fullLine.match(refPattern) ?? [];
|
|
242
|
+
const hasSpecRef = taskRefs.some((ref) =>
|
|
243
|
+
specLower.includes(ref.toLowerCase()),
|
|
244
|
+
);
|
|
245
|
+
if (hasSpecRef) continue; // Task explicitly references a spec requirement
|
|
246
|
+
|
|
247
|
+
const taskWords = significantWords(task.description);
|
|
248
|
+
if (taskWords.length === 0) continue;
|
|
249
|
+
|
|
250
|
+
const matchedCount = taskWords.filter((w) => allSpecWords.has(w)).length;
|
|
251
|
+
const coverage = matchedCount / taskWords.length;
|
|
252
|
+
|
|
253
|
+
if (coverage < 0.2) {
|
|
254
|
+
const source = planTasks.includes(task) ? "plan.md" : "tasks.md";
|
|
255
|
+
findings.push({
|
|
256
|
+
severity: "warning",
|
|
257
|
+
category: "orphaned-task",
|
|
258
|
+
message: `Task does not map to any spec requirement: "${task.fullLine}"`,
|
|
259
|
+
file: source,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return findings;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Implementation-detail keywords that should not appear in spec.md.
|
|
269
|
+
*/
|
|
270
|
+
const IMPL_KEYWORDS =
|
|
271
|
+
/\b(JWT|REST|SQL|endpoint|database|schema|implementation|deploy)\b/i;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* User-story language that should not appear in plan.md.
|
|
275
|
+
*/
|
|
276
|
+
const STORY_PATTERN = /\bAs a (user|developer|admin|customer)\b/i;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check 4: WHAT/WHY vs HOW separation.
|
|
280
|
+
*/
|
|
281
|
+
function checkSeparation(
|
|
282
|
+
specContent: string | null,
|
|
283
|
+
planContent: string | null,
|
|
284
|
+
): AnalysisFinding[] {
|
|
285
|
+
const findings: AnalysisFinding[] = [];
|
|
286
|
+
|
|
287
|
+
if (specContent !== null) {
|
|
288
|
+
const lines = specContent.split("\n");
|
|
289
|
+
for (let i = 0; i < lines.length; i++) {
|
|
290
|
+
const line = lines[i] ?? "";
|
|
291
|
+
if (IMPL_KEYWORDS.test(line)) {
|
|
292
|
+
findings.push({
|
|
293
|
+
severity: "warning",
|
|
294
|
+
category: "separation-violation",
|
|
295
|
+
message: `spec.md contains implementation detail: "${line.trim()}"`,
|
|
296
|
+
file: "spec.md",
|
|
297
|
+
line: i + 1,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (planContent !== null) {
|
|
304
|
+
const lines = planContent.split("\n");
|
|
305
|
+
for (let i = 0; i < lines.length; i++) {
|
|
306
|
+
const line = lines[i] ?? "";
|
|
307
|
+
if (STORY_PATTERN.test(line)) {
|
|
308
|
+
findings.push({
|
|
309
|
+
severity: "warning",
|
|
310
|
+
category: "separation-violation",
|
|
311
|
+
message: `plan.md contains user story language: "${line.trim()}"`,
|
|
312
|
+
file: "plan.md",
|
|
313
|
+
line: i + 1,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return findings;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Check 5: Task status consistency — task counts match between plan.md and tasks.md.
|
|
324
|
+
*/
|
|
325
|
+
function checkTaskConsistency(
|
|
326
|
+
planTasks: ParsedTask[],
|
|
327
|
+
tasksTasks: ParsedTask[],
|
|
328
|
+
): AnalysisFinding[] {
|
|
329
|
+
const findings: AnalysisFinding[] = [];
|
|
330
|
+
|
|
331
|
+
if (planTasks.length !== tasksTasks.length) {
|
|
332
|
+
findings.push({
|
|
333
|
+
severity: "warning",
|
|
334
|
+
category: "task-consistency",
|
|
335
|
+
message: `Task count mismatch: plan.md has ${planTasks.length} tasks, tasks.md has ${tasksTasks.length} tasks`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return findings;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Check 6: Contradictions — conflicting task descriptions for the same T-number.
|
|
344
|
+
*/
|
|
345
|
+
function checkContradictions(
|
|
346
|
+
planTasks: ParsedTask[],
|
|
347
|
+
tasksTasks: ParsedTask[],
|
|
348
|
+
): AnalysisFinding[] {
|
|
349
|
+
const findings: AnalysisFinding[] = [];
|
|
350
|
+
|
|
351
|
+
const planById = new Map<string, ParsedTask>();
|
|
352
|
+
for (const task of planTasks) {
|
|
353
|
+
if (task.id) {
|
|
354
|
+
planById.set(task.id, task);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const tasksTask of tasksTasks) {
|
|
359
|
+
if (!tasksTask.id) continue;
|
|
360
|
+
const planTask = planById.get(tasksTask.id);
|
|
361
|
+
if (!planTask) continue;
|
|
362
|
+
|
|
363
|
+
// Compare descriptions — use keyword overlap to detect contradictions
|
|
364
|
+
const planWords = new Set(significantWords(planTask.description));
|
|
365
|
+
const tasksWords = significantWords(tasksTask.description);
|
|
366
|
+
|
|
367
|
+
if (tasksWords.length === 0 || planWords.size === 0) continue;
|
|
368
|
+
|
|
369
|
+
const matchedCount = tasksWords.filter((w) => planWords.has(w)).length;
|
|
370
|
+
const coverage = matchedCount / Math.max(tasksWords.length, planWords.size);
|
|
371
|
+
|
|
372
|
+
if (coverage < 0.4) {
|
|
373
|
+
findings.push({
|
|
374
|
+
severity: "warning",
|
|
375
|
+
category: "contradiction",
|
|
376
|
+
message: `${tasksTask.id} has conflicting descriptions — plan.md: "${planTask.description}" vs tasks.md: "${tasksTask.description}"`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return findings;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Analyze cross-artifact consistency within a feature directory.
|
|
386
|
+
*
|
|
387
|
+
* Checks consistency across spec.md, plan.md, and tasks.md.
|
|
388
|
+
* Returns an error Result only if the feature directory does not exist.
|
|
389
|
+
* Missing individual files produce findings, not errors.
|
|
390
|
+
*/
|
|
391
|
+
export function analyze(featureDir: string): Result<AnalysisReport, string> {
|
|
392
|
+
if (!existsSync(featureDir)) {
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
error: `Feature directory does not exist: ${featureDir}`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const specContent = readOptionalFile(join(featureDir, "spec.md"));
|
|
400
|
+
const planContent = readOptionalFile(join(featureDir, "plan.md"));
|
|
401
|
+
const tasksContent = readOptionalFile(join(featureDir, "tasks.md"));
|
|
402
|
+
|
|
403
|
+
const findings: AnalysisFinding[] = [];
|
|
404
|
+
|
|
405
|
+
// Check 1: Missing files
|
|
406
|
+
findings.push(...checkMissingFiles(specContent, planContent, tasksContent));
|
|
407
|
+
|
|
408
|
+
// Extract tasks from available files
|
|
409
|
+
const planTasks = planContent ? extractTasks(planContent) : [];
|
|
410
|
+
const tasksTasks = tasksContent ? extractTasks(tasksContent) : [];
|
|
411
|
+
|
|
412
|
+
// Check 2: Spec coverage (requires spec + at least one task source)
|
|
413
|
+
if (specContent && (planTasks.length > 0 || tasksTasks.length > 0)) {
|
|
414
|
+
findings.push(...checkSpecCoverage(specContent, planTasks, tasksTasks));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Check 3: Orphaned tasks (requires spec)
|
|
418
|
+
if (specContent && (planTasks.length > 0 || tasksTasks.length > 0)) {
|
|
419
|
+
findings.push(...checkOrphanedTasks(specContent, planTasks, tasksTasks));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Check 4: WHAT/WHY vs HOW separation
|
|
423
|
+
findings.push(...checkSeparation(specContent, planContent));
|
|
424
|
+
|
|
425
|
+
// Check 5: Task consistency (requires both plan and tasks)
|
|
426
|
+
if (planContent && tasksContent) {
|
|
427
|
+
findings.push(...checkTaskConsistency(planTasks, tasksTasks));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Check 6: Contradictions (requires both plan and tasks)
|
|
431
|
+
if (planContent && tasksContent) {
|
|
432
|
+
findings.push(...checkContradictions(planTasks, tasksTasks));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const summary = {
|
|
436
|
+
errors: findings.filter((f) => f.severity === "error").length,
|
|
437
|
+
warnings: findings.filter((f) => f.severity === "warning").length,
|
|
438
|
+
info: findings.filter((f) => f.severity === "info").length,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
ok: true,
|
|
443
|
+
value: { featureDir, findings, summary },
|
|
444
|
+
};
|
|
445
|
+
}
|