@wkronmiller/lisa 0.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.
- package/LICENSE +21 -0
- package/README.md +407 -0
- package/bin/lisa-runtime.js +8797 -0
- package/bin/lisa.js +21 -0
- package/completion.ts +58 -0
- package/install.ps1 +51 -0
- package/install.sh +93 -0
- package/lisa.ts +6 -0
- package/package.json +66 -0
- package/skills/README.md +28 -0
- package/skills/claude-code/CLAUDE.md +151 -0
- package/skills/codex/AGENTS.md +151 -0
- package/skills/gemini/GEMINI.md +151 -0
- package/skills/opencode/AGENTS.md +152 -0
- package/src/cli.ts +85 -0
- package/src/harness/base-adapter.ts +47 -0
- package/src/harness/claude-code.ts +106 -0
- package/src/harness/codex.ts +80 -0
- package/src/harness/command.ts +173 -0
- package/src/harness/gemini.ts +74 -0
- package/src/harness/opencode.ts +84 -0
- package/src/harness/registry.ts +29 -0
- package/src/harness/runner.ts +19 -0
- package/src/harness/types.ts +73 -0
- package/src/output-mode.ts +32 -0
- package/src/skill/artifacts.ts +174 -0
- package/src/skill/cli.ts +29 -0
- package/src/skill/install.ts +317 -0
- package/src/spec/agent-guidance.ts +466 -0
- package/src/spec/cli.ts +151 -0
- package/src/spec/commands/check.ts +1 -0
- package/src/spec/commands/config.ts +146 -0
- package/src/spec/commands/diff.ts +1 -0
- package/src/spec/commands/generate.ts +1 -0
- package/src/spec/commands/guide.ts +1 -0
- package/src/spec/commands/harness-list.ts +36 -0
- package/src/spec/commands/implement.ts +1 -0
- package/src/spec/commands/import.ts +1 -0
- package/src/spec/commands/init.ts +1 -0
- package/src/spec/commands/status.ts +87 -0
- package/src/spec/config.ts +63 -0
- package/src/spec/diff.ts +791 -0
- package/src/spec/extensions/benchmark.ts +347 -0
- package/src/spec/extensions/registry.ts +59 -0
- package/src/spec/extensions/types.ts +56 -0
- package/src/spec/grammar/index.ts +14 -0
- package/src/spec/grammar/parser.ts +443 -0
- package/src/spec/grammar/types.ts +70 -0
- package/src/spec/grammar/validator.ts +104 -0
- package/src/spec/loader.ts +174 -0
- package/src/spec/local-config.ts +59 -0
- package/src/spec/parser.ts +226 -0
- package/src/spec/path-utils.ts +73 -0
- package/src/spec/planner.ts +299 -0
- package/src/spec/prompt-renderer.ts +318 -0
- package/src/spec/skill-content.ts +119 -0
- package/src/spec/types.ts +239 -0
- package/src/spec/validator.ts +443 -0
- package/src/spec/workflows/check.ts +1534 -0
- package/src/spec/workflows/diff.ts +209 -0
- package/src/spec/workflows/generate.ts +1270 -0
- package/src/spec/workflows/guide.ts +190 -0
- package/src/spec/workflows/implement.ts +797 -0
- package/src/spec/workflows/import.ts +986 -0
- package/src/spec/workflows/init.ts +548 -0
- package/src/spec/workflows/status.ts +22 -0
- package/src/spec/workspace.ts +541 -0
- package/uninstall.ps1 +21 -0
- package/uninstall.sh +22 -0
package/src/spec/diff.ts
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { basename, dirname, join, relative } from "path";
|
|
4
|
+
|
|
5
|
+
import { resolveExtensionKindForPath } from "./extensions/registry";
|
|
6
|
+
import { parseSpecDocument } from "./parser";
|
|
7
|
+
import type {
|
|
8
|
+
ParsedMarkdownSection,
|
|
9
|
+
ParsedSpecDocument,
|
|
10
|
+
SpecArea,
|
|
11
|
+
SpecDelta,
|
|
12
|
+
SpecDiffReport,
|
|
13
|
+
SpecFileChange,
|
|
14
|
+
SpecKind,
|
|
15
|
+
SpecSectionChange,
|
|
16
|
+
} from "./types";
|
|
17
|
+
import { assertSafeLisaStorageWritePath, listEffectiveWorkspaceFiles, resolveWorkspaceLayout, resolveWorkspaceRoot, toLogicalPath } from "./workspace";
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_SPEC_DIFF_BASE_REF = "HEAD";
|
|
20
|
+
export const WORKTREE_SPEC_DIFF_REF = "WORKTREE";
|
|
21
|
+
export const EXTERNAL_SPEC_DIFF_BASE_REF = "SNAPSHOT";
|
|
22
|
+
const EXTERNAL_SNAPSHOT_FILENAME = "effective-spec-tree.json";
|
|
23
|
+
|
|
24
|
+
interface ExternalSpecSnapshot {
|
|
25
|
+
repoRevision?: string;
|
|
26
|
+
codePaths?: Record<string, { baselineHash: string; verifiedHash: string }>;
|
|
27
|
+
files: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SPEC_DIFF_DIRECTORIES = [".specs/backend", ".specs/frontend"];
|
|
31
|
+
const SECTION_CHANGE_TYPES: Record<string, string> = {
|
|
32
|
+
Frontmatter: "frontmatter_changed",
|
|
33
|
+
Summary: "summary_changed",
|
|
34
|
+
"Use Cases": "use_case_added_or_removed",
|
|
35
|
+
Invariants: "invariant_added_or_removed",
|
|
36
|
+
"Failure Modes": "failure_modes_changed",
|
|
37
|
+
"Acceptance Criteria": "acceptance_added_or_removed",
|
|
38
|
+
"Out of Scope": "out_of_scope_changed",
|
|
39
|
+
Scenario: "scenario_changed",
|
|
40
|
+
"Load Model": "load_model_changed",
|
|
41
|
+
Notes: "notes_changed",
|
|
42
|
+
};
|
|
43
|
+
const SECTION_ORDER = [
|
|
44
|
+
"Frontmatter",
|
|
45
|
+
"Summary",
|
|
46
|
+
"Use Cases",
|
|
47
|
+
"Invariants",
|
|
48
|
+
"Failure Modes",
|
|
49
|
+
"Acceptance Criteria",
|
|
50
|
+
"Out of Scope",
|
|
51
|
+
"Scenario",
|
|
52
|
+
"Load Model",
|
|
53
|
+
"Notes",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function decodeOutput(bytes: Uint8Array<ArrayBufferLike>): string {
|
|
57
|
+
return new TextDecoder().decode(bytes);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getExternalSnapshotPath(workspacePath: string): string {
|
|
61
|
+
return join(resolveWorkspaceLayout(workspacePath).snapshotRoot, EXTERNAL_SNAPSHOT_FILENAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isExternalSnapshotCompatible(workspacePath: string, repoRevision: string | undefined): boolean {
|
|
65
|
+
if (!repoRevision) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const currentHead = resolveWorkspaceHeadRevision(workspacePath);
|
|
70
|
+
if (!currentHead || currentHead === repoRevision) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = runGitCommand(workspacePath, ["merge-base", "--is-ancestor", repoRevision, currentHead], { allowFailure: true });
|
|
75
|
+
return result.exitCode === 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readExternalSnapshot(workspacePath: string): ExternalSpecSnapshot {
|
|
79
|
+
const snapshotPath = getExternalSnapshotPath(workspacePath);
|
|
80
|
+
if (!existsSync(snapshotPath)) {
|
|
81
|
+
return { files: {} };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const raw = JSON.parse(readFileSync(snapshotPath, "utf8")) as {
|
|
86
|
+
repoRevision?: unknown;
|
|
87
|
+
codePaths?: Record<string, { baselineHash?: unknown; verifiedHash?: unknown }>;
|
|
88
|
+
files?: Record<string, string>;
|
|
89
|
+
};
|
|
90
|
+
if (!raw.files || typeof raw.files !== "object" || Array.isArray(raw.files)) {
|
|
91
|
+
return { files: {} };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const snapshot: ExternalSpecSnapshot = {
|
|
95
|
+
repoRevision: typeof raw.repoRevision === "string" && raw.repoRevision.trim().length > 0 ? raw.repoRevision.trim() : undefined,
|
|
96
|
+
codePaths: raw.codePaths && typeof raw.codePaths === "object" && !Array.isArray(raw.codePaths)
|
|
97
|
+
? Object.fromEntries(
|
|
98
|
+
Object.entries(raw.codePaths).filter(
|
|
99
|
+
(entry): entry is [string, { baselineHash: string; verifiedHash: string }] => {
|
|
100
|
+
const value = entry[1];
|
|
101
|
+
return typeof value === "object"
|
|
102
|
+
&& value !== null
|
|
103
|
+
&& typeof value.baselineHash === "string"
|
|
104
|
+
&& typeof value.verifiedHash === "string";
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
: undefined,
|
|
109
|
+
files: Object.fromEntries(
|
|
110
|
+
Object.entries(raw.files).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return isExternalSnapshotCompatible(workspacePath, snapshot.repoRevision) ? snapshot : { files: {} };
|
|
115
|
+
} catch {
|
|
116
|
+
return { files: {} };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readExternalSnapshotRepoRevision(cwd = process.cwd()): string | undefined {
|
|
121
|
+
return readExternalSnapshot(resolveWorkspaceRoot(cwd)).repoRevision;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function readExternalSnapshotCodePaths(cwd = process.cwd()): Record<string, { baselineHash: string; verifiedHash: string }> {
|
|
125
|
+
return readExternalSnapshot(resolveWorkspaceRoot(cwd)).codePaths ?? {};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readExternalSpecSnapshot(cwd = process.cwd()): ExternalSpecSnapshot {
|
|
129
|
+
return readExternalSnapshot(resolveWorkspaceRoot(cwd));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveWorkspaceHeadRevision(workspaceRoot: string): string | undefined {
|
|
133
|
+
const result = runGitCommand(workspaceRoot, ["rev-parse", "HEAD"], { allowFailure: true });
|
|
134
|
+
return result.exitCode === 0 ? result.stdout.trim() || undefined : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeCodePath(path: string): string {
|
|
138
|
+
return path.trim().split("\\").join("/");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readRevisionCodeHash(workspaceRoot: string, revision: string | undefined, path: string): string {
|
|
142
|
+
if (!revision) {
|
|
143
|
+
return "__missing__";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = runGitCommand(workspaceRoot, ["show", `${revision}:${path}`], { allowFailure: true });
|
|
147
|
+
return result.exitCode === 0
|
|
148
|
+
? createHash("sha1").update(result.stdout).digest("hex")
|
|
149
|
+
: "__missing__";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function listCurrentCodeSnapshotEntries(
|
|
153
|
+
workspaceRoot: string,
|
|
154
|
+
repoRevision: string | undefined,
|
|
155
|
+
): Record<string, { baselineHash: string; verifiedHash: string }> {
|
|
156
|
+
const diffOutput = runGitCommand(workspaceRoot, ["diff", "--name-only", "HEAD", "--", "."], { allowFailure: true }).stdout;
|
|
157
|
+
const untrackedOutput = runGitCommand(workspaceRoot, ["ls-files", "--others", "--exclude-standard"], { allowFailure: true }).stdout;
|
|
158
|
+
const entries = new Map<string, { baselineHash: string; verifiedHash: string }>();
|
|
159
|
+
|
|
160
|
+
for (const source of [diffOutput, untrackedOutput]) {
|
|
161
|
+
for (const line of source.split("\n")) {
|
|
162
|
+
const path = normalizeCodePath(line);
|
|
163
|
+
if (!path || path.startsWith(".specs/") || path.startsWith(".git/") || path.startsWith(".lisa/")) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const absolutePath = join(workspaceRoot, path);
|
|
168
|
+
const verifiedHash = existsSync(absolutePath)
|
|
169
|
+
? createHash("sha1").update(readFileSync(absolutePath)).digest("hex")
|
|
170
|
+
: "__missing__";
|
|
171
|
+
entries.set(path, {
|
|
172
|
+
baselineHash: readRevisionCodeHash(workspaceRoot, repoRevision, path),
|
|
173
|
+
verifiedHash,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return Object.fromEntries(Array.from(entries.entries()).sort(([left], [right]) => left.localeCompare(right)));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function writeExternalSpecSnapshot(cwd = process.cwd()): string | undefined {
|
|
182
|
+
const layout = resolveWorkspaceLayout(cwd);
|
|
183
|
+
if (layout.storageMode !== "external") {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const files = buildExternalWorktreeFiles(layout);
|
|
188
|
+
const snapshotPath = getExternalSnapshotPath(layout.workspacePath);
|
|
189
|
+
const repoRevision = resolveWorkspaceHeadRevision(layout.workspacePath);
|
|
190
|
+
assertSafeLisaStorageWritePath(layout.snapshotRoot, snapshotPath, "Lisa spec snapshot");
|
|
191
|
+
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
192
|
+
writeFileSync(snapshotPath, `${JSON.stringify({
|
|
193
|
+
repoRevision,
|
|
194
|
+
codePaths: listCurrentCodeSnapshotEntries(layout.workspacePath, repoRevision),
|
|
195
|
+
files,
|
|
196
|
+
}, null, 2)}\n`);
|
|
197
|
+
return snapshotPath;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function toRepoRelativePath(workspaceRoot: string, filePath: string): string {
|
|
201
|
+
const layout = resolveWorkspaceLayout(workspaceRoot);
|
|
202
|
+
return layout.storageMode === "external"
|
|
203
|
+
? toLogicalPath(layout, filePath)
|
|
204
|
+
: relative(workspaceRoot, filePath).split("\\").join("/");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildExternalWorktreeFiles(layout: ReturnType<typeof resolveWorkspaceLayout>): Record<string, string> {
|
|
208
|
+
const effective = listEffectiveWorkspaceFiles(layout);
|
|
209
|
+
const files = new Map<string, string>();
|
|
210
|
+
|
|
211
|
+
for (const path of [...effective.documentPaths, ...effective.environmentPaths]) {
|
|
212
|
+
files.set(toLogicalPath(layout, path), readFileSync(path, "utf8"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (effective.configPath && existsSync(effective.configPath)) {
|
|
216
|
+
files.set(".specs/config.yaml", readFileSync(effective.configPath, "utf8"));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return Object.fromEntries(Array.from(files.entries()).sort(([left], [right]) => left.localeCompare(right)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function deriveSpecKind(path: string): SpecKind {
|
|
223
|
+
const extensionKind = resolveExtensionKindForPath(basename(path));
|
|
224
|
+
if (!extensionKind) {
|
|
225
|
+
return "base";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return extensionKind === "benchmark" ? "benchmark" : "extension";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function deriveSpecArea(path: string): SpecArea | undefined {
|
|
232
|
+
const normalized = path.split("\\").join("/");
|
|
233
|
+
const areaMatch = normalized.match(/\.specs\/(backend|frontend)\//);
|
|
234
|
+
return areaMatch?.[1] as SpecArea | undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function deriveSpecIdFromPath(path: string): string {
|
|
238
|
+
const normalized = path.split("\\").join("/");
|
|
239
|
+
const areaMatch = normalized.match(/\.specs\/(backend|frontend)\//);
|
|
240
|
+
const filename = normalized.split("/").pop()?.replace(/\.md$/i, "") ?? normalized;
|
|
241
|
+
return areaMatch ? `${areaMatch[1]}.${filename}` : filename;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function stableValue(value: unknown): unknown {
|
|
245
|
+
if (Array.isArray(value)) {
|
|
246
|
+
return value.map((entry) => stableValue(entry));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (value && typeof value === "object") {
|
|
250
|
+
return Object.fromEntries(
|
|
251
|
+
Object.entries(value as Record<string, unknown>)
|
|
252
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
253
|
+
.map(([key, entryValue]) => [key, stableValue(entryValue)]),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return value;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function stableSerialize(value: unknown): string {
|
|
261
|
+
return JSON.stringify(stableValue(value));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeSectionTitle(title: string): string {
|
|
265
|
+
return title.toLowerCase().replace(/\s+/g, " ").trim();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function sectionSortKey(title: string): string {
|
|
269
|
+
const index = SECTION_ORDER.findIndex((entry) => normalizeSectionTitle(entry) === normalizeSectionTitle(title));
|
|
270
|
+
return `${String(index >= 0 ? index : SECTION_ORDER.length).padStart(4, "0")}:${title.toLowerCase()}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function mapSectionChangeType(title: string): string {
|
|
274
|
+
return SECTION_CHANGE_TYPES[title] ?? `${normalizeSectionTitle(title).replace(/[^a-z0-9]+/g, "_")}_changed`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function asSectionMap(document?: ParsedSpecDocument): Map<string, ParsedMarkdownSection> {
|
|
278
|
+
const map = new Map<string, ParsedMarkdownSection>();
|
|
279
|
+
if (!document) {
|
|
280
|
+
return map;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const section of document.sections) {
|
|
284
|
+
map.set(normalizeSectionTitle(section.title), section);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return map;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function createFallbackDocument(filePath: string, logicalPath: string, content: string): ParsedSpecDocument {
|
|
291
|
+
return {
|
|
292
|
+
path: filePath,
|
|
293
|
+
filename: basename(logicalPath),
|
|
294
|
+
area: deriveSpecArea(logicalPath),
|
|
295
|
+
kind: deriveSpecKind(logicalPath),
|
|
296
|
+
id: deriveSpecIdFromPath(logicalPath),
|
|
297
|
+
status: undefined,
|
|
298
|
+
frontmatter: {},
|
|
299
|
+
body: content.trim(),
|
|
300
|
+
sections: [],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseDocumentForDiff(workspaceRoot: string, path: string, content?: string, actualPath?: string): ParsedSpecDocument | undefined {
|
|
305
|
+
if (content === undefined) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const filePath = actualPath ?? join(workspaceRoot, path);
|
|
310
|
+
try {
|
|
311
|
+
return parseSpecDocument(filePath, content);
|
|
312
|
+
} catch {
|
|
313
|
+
return createFallbackDocument(filePath, path, content);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function runGitCommand(
|
|
318
|
+
workspaceRoot: string,
|
|
319
|
+
args: string[],
|
|
320
|
+
options: { allowFailure?: boolean } = {},
|
|
321
|
+
): { stdout: string; stderr: string; exitCode: number } {
|
|
322
|
+
const proc = Bun.spawnSync({
|
|
323
|
+
cmd: ["git", ...args],
|
|
324
|
+
cwd: workspaceRoot,
|
|
325
|
+
stdout: "pipe",
|
|
326
|
+
stderr: "pipe",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const stdout = decodeOutput(proc.stdout);
|
|
330
|
+
const stderr = decodeOutput(proc.stderr);
|
|
331
|
+
|
|
332
|
+
if (proc.exitCode !== 0 && !options.allowFailure) {
|
|
333
|
+
throw new Error(stderr.trim() || `git ${args.join(" ")} failed with exit code ${proc.exitCode}.`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { stdout, stderr, exitCode: proc.exitCode };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface TrackedSpecChange {
|
|
340
|
+
path: string;
|
|
341
|
+
fileChange: SpecFileChange;
|
|
342
|
+
previousPath?: string;
|
|
343
|
+
nextPath?: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface ExternalTrackedSpecChange extends TrackedSpecChange {
|
|
347
|
+
previousActualPath?: string;
|
|
348
|
+
nextActualPath?: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function listTrackedSpecChanges(workspaceRoot: string, baseRef: string, headRef?: string): TrackedSpecChange[] {
|
|
352
|
+
const args = [
|
|
353
|
+
"diff",
|
|
354
|
+
"--name-status",
|
|
355
|
+
"--find-renames",
|
|
356
|
+
...(headRef ? [baseRef, headRef] : [baseRef]),
|
|
357
|
+
"--",
|
|
358
|
+
...SPEC_DIFF_DIRECTORIES,
|
|
359
|
+
];
|
|
360
|
+
const { stdout } = runGitCommand(workspaceRoot, args);
|
|
361
|
+
if (!stdout.trim()) {
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const changes: TrackedSpecChange[] = [];
|
|
366
|
+
|
|
367
|
+
for (const line of stdout.split("\n").filter((entry) => entry.trim().length > 0)) {
|
|
368
|
+
const columns = line.split("\t");
|
|
369
|
+
const status = columns[0] ?? "";
|
|
370
|
+
|
|
371
|
+
if (status.startsWith("R") && columns[1] && columns[2]) {
|
|
372
|
+
changes.push({
|
|
373
|
+
path: columns[2],
|
|
374
|
+
fileChange: "modified",
|
|
375
|
+
previousPath: columns[1],
|
|
376
|
+
nextPath: columns[2],
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const path = columns[1];
|
|
382
|
+
if (!path) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (status.startsWith("A")) {
|
|
387
|
+
changes.push({ path, fileChange: "added" });
|
|
388
|
+
} else if (status.startsWith("D")) {
|
|
389
|
+
changes.push({ path, fileChange: "deleted" });
|
|
390
|
+
} else {
|
|
391
|
+
changes.push({ path, fileChange: "modified" });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return changes;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function listUntrackedSpecFiles(workspaceRoot: string): string[] {
|
|
399
|
+
const { stdout } = runGitCommand(
|
|
400
|
+
workspaceRoot,
|
|
401
|
+
["ls-files", "--others", "--exclude-standard", "--", ...SPEC_DIFF_DIRECTORIES],
|
|
402
|
+
{ allowFailure: true },
|
|
403
|
+
);
|
|
404
|
+
return stdout
|
|
405
|
+
.split("\n")
|
|
406
|
+
.map((entry) => entry.trim())
|
|
407
|
+
.filter((entry) => entry.length > 0);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readWorktreeFile(workspaceRoot: string, path: string): string | undefined {
|
|
411
|
+
const absolutePath = join(workspaceRoot, path);
|
|
412
|
+
if (!existsSync(absolutePath)) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return readFileSync(absolutePath, "utf8");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function readRevisionFile(workspaceRoot: string, ref: string, path: string): string | undefined {
|
|
420
|
+
const result = runGitCommand(workspaceRoot, ["show", `${ref}:${path}`], { allowFailure: true });
|
|
421
|
+
if (result.exitCode !== 0) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return result.stdout;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function readDiffSide(workspaceRoot: string, ref: string, path: string): string | undefined {
|
|
429
|
+
return ref === WORKTREE_SPEC_DIFF_REF ? readWorktreeFile(workspaceRoot, path) : readRevisionFile(workspaceRoot, ref, path);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function listExternalTrackedChanges(layout: ReturnType<typeof resolveWorkspaceLayout>): ExternalTrackedSpecChange[] {
|
|
433
|
+
const previousFiles = readExternalSnapshot(layout.workspacePath).files;
|
|
434
|
+
const nextFiles = buildExternalWorktreeFiles(layout);
|
|
435
|
+
const logicalToActual = new Map<string, string>();
|
|
436
|
+
const effective = listEffectiveWorkspaceFiles(layout);
|
|
437
|
+
for (const path of [...effective.documentPaths, ...effective.environmentPaths]) {
|
|
438
|
+
logicalToActual.set(toLogicalPath(layout, path), path);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (effective.configPath) {
|
|
442
|
+
logicalToActual.set(".specs/config.yaml", effective.configPath);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const keys = new Set<string>([...Object.keys(previousFiles), ...Object.keys(nextFiles)]);
|
|
446
|
+
const changes: ExternalTrackedSpecChange[] = [];
|
|
447
|
+
|
|
448
|
+
for (const key of Array.from(keys).sort()) {
|
|
449
|
+
const previous = previousFiles[key];
|
|
450
|
+
const next = nextFiles[key];
|
|
451
|
+
if (previous === next) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
changes.push({
|
|
456
|
+
path: key,
|
|
457
|
+
fileChange: previous === undefined ? "added" : next === undefined ? "deleted" : "modified",
|
|
458
|
+
previousPath: key,
|
|
459
|
+
nextPath: key,
|
|
460
|
+
nextActualPath: logicalToActual.get(key),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return changes.filter((change) => change.path.startsWith(".specs/backend/") || change.path.startsWith(".specs/frontend/"));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function buildSectionChanges(previousDocument?: ParsedSpecDocument, nextDocument?: ParsedSpecDocument): SpecSectionChange[] {
|
|
468
|
+
const changes: SpecSectionChange[] = [];
|
|
469
|
+
const previousFrontmatter = previousDocument ? stableSerialize(previousDocument.frontmatter) : undefined;
|
|
470
|
+
const nextFrontmatter = nextDocument ? stableSerialize(nextDocument.frontmatter) : undefined;
|
|
471
|
+
|
|
472
|
+
if (previousFrontmatter !== nextFrontmatter) {
|
|
473
|
+
changes.push({
|
|
474
|
+
section: "Frontmatter",
|
|
475
|
+
changeType: mapSectionChangeType("Frontmatter"),
|
|
476
|
+
oldText: previousDocument ? Bun.YAML.stringify(previousDocument.frontmatter).trim() : undefined,
|
|
477
|
+
newText: nextDocument ? Bun.YAML.stringify(nextDocument.frontmatter).trim() : undefined,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const previousSections = asSectionMap(previousDocument);
|
|
482
|
+
const nextSections = asSectionMap(nextDocument);
|
|
483
|
+
const titles = new Set<string>([
|
|
484
|
+
...previousSections.keys(),
|
|
485
|
+
...nextSections.keys(),
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
for (const key of Array.from(titles).sort((left, right) => sectionSortKey(left).localeCompare(sectionSortKey(right)))) {
|
|
489
|
+
const previousSection = previousSections.get(key);
|
|
490
|
+
const nextSection = nextSections.get(key);
|
|
491
|
+
const previousText = previousSection?.content.trim();
|
|
492
|
+
const nextText = nextSection?.content.trim();
|
|
493
|
+
|
|
494
|
+
if (previousText === nextText) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const sectionTitle = nextSection?.title ?? previousSection?.title ?? key;
|
|
499
|
+
changes.push({
|
|
500
|
+
section: sectionTitle,
|
|
501
|
+
changeType: mapSectionChangeType(sectionTitle),
|
|
502
|
+
oldText: previousText && previousText.length > 0 ? previousText : undefined,
|
|
503
|
+
newText: nextText && nextText.length > 0 ? nextText : undefined,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return changes;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface CollectSpecDiffOptions {
|
|
511
|
+
baseRef?: string;
|
|
512
|
+
headRef?: string;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function collectSpecDiff(cwd = process.cwd(), options: CollectSpecDiffOptions = {}): SpecDiffReport {
|
|
516
|
+
const workspacePath = resolveWorkspaceRoot(cwd);
|
|
517
|
+
const layout = resolveWorkspaceLayout(cwd);
|
|
518
|
+
const baseRef = options.baseRef ?? (layout.storageMode === "external" ? EXTERNAL_SPEC_DIFF_BASE_REF : DEFAULT_SPEC_DIFF_BASE_REF);
|
|
519
|
+
const headRef = options.headRef ?? WORKTREE_SPEC_DIFF_REF;
|
|
520
|
+
|
|
521
|
+
if (layout.storageMode === "external") {
|
|
522
|
+
const previousFiles = readExternalSnapshot(layout.workspacePath).files;
|
|
523
|
+
const nextFiles = buildExternalWorktreeFiles(layout);
|
|
524
|
+
const changes = listExternalTrackedChanges(layout);
|
|
525
|
+
const rawDeltas: SpecDelta[] = changes
|
|
526
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
527
|
+
.map((change) => {
|
|
528
|
+
const previousContent = change.fileChange === "added"
|
|
529
|
+
? undefined
|
|
530
|
+
: previousFiles[change.path];
|
|
531
|
+
const nextContent = change.fileChange === "deleted"
|
|
532
|
+
? undefined
|
|
533
|
+
: nextFiles[change.path];
|
|
534
|
+
const previousDocument = previousContent === undefined
|
|
535
|
+
? undefined
|
|
536
|
+
: parseDocumentForDiff(workspacePath, change.path, previousContent);
|
|
537
|
+
const nextDocument = nextContent === undefined
|
|
538
|
+
? undefined
|
|
539
|
+
: parseDocumentForDiff(workspacePath, change.path, nextContent, change.nextActualPath);
|
|
540
|
+
const document = nextDocument ?? previousDocument;
|
|
541
|
+
const kind = document?.kind ?? deriveSpecKind(change.path);
|
|
542
|
+
const specId = document?.id ?? deriveSpecIdFromPath(change.path);
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
specId,
|
|
546
|
+
path: change.nextActualPath ?? join(workspacePath, change.path),
|
|
547
|
+
kind,
|
|
548
|
+
extensionKind: document?.extensionKind,
|
|
549
|
+
fileChange: change.fileChange,
|
|
550
|
+
sectionChanges: buildSectionChanges(previousDocument, nextDocument),
|
|
551
|
+
previousDocument,
|
|
552
|
+
nextDocument,
|
|
553
|
+
extendsSpecId: kind !== "base"
|
|
554
|
+
? typeof (nextDocument ?? previousDocument)?.frontmatter.extends === "string"
|
|
555
|
+
? ((nextDocument ?? previousDocument)?.frontmatter.extends as string)
|
|
556
|
+
: undefined
|
|
557
|
+
: undefined,
|
|
558
|
+
} satisfies SpecDelta;
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const deltas: SpecDelta[] = [];
|
|
562
|
+
const consumed = new Set<number>();
|
|
563
|
+
|
|
564
|
+
for (const [index, delta] of rawDeltas.entries()) {
|
|
565
|
+
if (consumed.has(index)) {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (delta.fileChange === "deleted") {
|
|
570
|
+
const addedIndex = rawDeltas.findIndex((candidate, candidateIndex) => (
|
|
571
|
+
!consumed.has(candidateIndex)
|
|
572
|
+
&& candidate.fileChange === "added"
|
|
573
|
+
&& candidate.specId === delta.specId
|
|
574
|
+
&& candidate.kind === delta.kind
|
|
575
|
+
));
|
|
576
|
+
|
|
577
|
+
if (addedIndex >= 0) {
|
|
578
|
+
const added = rawDeltas[addedIndex] as SpecDelta;
|
|
579
|
+
consumed.add(index);
|
|
580
|
+
consumed.add(addedIndex);
|
|
581
|
+
deltas.push({
|
|
582
|
+
specId: delta.specId,
|
|
583
|
+
path: added.path,
|
|
584
|
+
kind: added.kind,
|
|
585
|
+
extensionKind: added.extensionKind,
|
|
586
|
+
fileChange: "modified",
|
|
587
|
+
sectionChanges: buildSectionChanges(delta.previousDocument, added.nextDocument),
|
|
588
|
+
previousDocument: delta.previousDocument,
|
|
589
|
+
nextDocument: added.nextDocument,
|
|
590
|
+
extendsSpecId: added.extendsSpecId,
|
|
591
|
+
});
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (delta.fileChange === "added") {
|
|
597
|
+
const deletedIndex = rawDeltas.findIndex((candidate, candidateIndex) => (
|
|
598
|
+
!consumed.has(candidateIndex)
|
|
599
|
+
&& candidate.fileChange === "deleted"
|
|
600
|
+
&& candidate.specId === delta.specId
|
|
601
|
+
&& candidate.kind === delta.kind
|
|
602
|
+
));
|
|
603
|
+
|
|
604
|
+
if (deletedIndex >= 0) {
|
|
605
|
+
const deleted = rawDeltas[deletedIndex] as SpecDelta;
|
|
606
|
+
consumed.add(index);
|
|
607
|
+
consumed.add(deletedIndex);
|
|
608
|
+
deltas.push({
|
|
609
|
+
specId: delta.specId,
|
|
610
|
+
path: delta.path,
|
|
611
|
+
kind: delta.kind,
|
|
612
|
+
extensionKind: delta.extensionKind,
|
|
613
|
+
fileChange: "modified",
|
|
614
|
+
sectionChanges: buildSectionChanges(deleted.previousDocument, delta.nextDocument),
|
|
615
|
+
previousDocument: deleted.previousDocument,
|
|
616
|
+
nextDocument: delta.nextDocument,
|
|
617
|
+
extendsSpecId: delta.extendsSpecId,
|
|
618
|
+
});
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
deltas.push(delta);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
workspacePath,
|
|
628
|
+
baseRef,
|
|
629
|
+
headRef,
|
|
630
|
+
deltas,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const trackedChanges = listTrackedSpecChanges(
|
|
635
|
+
workspacePath,
|
|
636
|
+
baseRef,
|
|
637
|
+
options.headRef,
|
|
638
|
+
);
|
|
639
|
+
const changes = new Map<string, TrackedSpecChange>();
|
|
640
|
+
|
|
641
|
+
for (const change of trackedChanges) {
|
|
642
|
+
changes.set(change.path, change);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!options.headRef) {
|
|
646
|
+
for (const untrackedPath of listUntrackedSpecFiles(workspacePath)) {
|
|
647
|
+
if (!changes.has(untrackedPath)) {
|
|
648
|
+
changes.set(untrackedPath, { path: untrackedPath, fileChange: "added", nextPath: untrackedPath });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const candidates = Array.from(changes.values())
|
|
654
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
655
|
+
.map((change) => {
|
|
656
|
+
const previousPath = change.previousPath ?? change.path;
|
|
657
|
+
const nextPath = change.nextPath ?? change.path;
|
|
658
|
+
const previousContent = change.fileChange === "added" ? undefined : readDiffSide(workspacePath, baseRef, previousPath);
|
|
659
|
+
const nextContent = change.fileChange === "deleted" ? undefined : readDiffSide(workspacePath, headRef, nextPath);
|
|
660
|
+
const previousDocument = parseDocumentForDiff(workspacePath, previousPath, previousContent);
|
|
661
|
+
const nextDocument = parseDocumentForDiff(workspacePath, nextPath, nextContent);
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
change,
|
|
665
|
+
previousPath,
|
|
666
|
+
nextPath,
|
|
667
|
+
previousDocument,
|
|
668
|
+
nextDocument,
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
const deltas: SpecDelta[] = [];
|
|
672
|
+
const consumed = new Set<number>();
|
|
673
|
+
|
|
674
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
675
|
+
if (consumed.has(index)) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const { change, previousPath, nextPath, previousDocument, nextDocument } = candidate;
|
|
680
|
+
const tryMergeRename = (
|
|
681
|
+
matchingIndex: number,
|
|
682
|
+
previous: ParsedSpecDocument,
|
|
683
|
+
next: ParsedSpecDocument,
|
|
684
|
+
resolvedNextPath: string,
|
|
685
|
+
): SpecDelta => ({
|
|
686
|
+
specId: next.id ?? deriveSpecIdFromPath(resolvedNextPath),
|
|
687
|
+
path: join(workspacePath, resolvedNextPath),
|
|
688
|
+
kind: next.kind,
|
|
689
|
+
extensionKind: next.extensionKind,
|
|
690
|
+
fileChange: "modified",
|
|
691
|
+
sectionChanges: buildSectionChanges(previous, next),
|
|
692
|
+
previousDocument: previous,
|
|
693
|
+
nextDocument: next,
|
|
694
|
+
extendsSpecId: next.kind !== "base" && typeof next.frontmatter.extends === "string"
|
|
695
|
+
? next.frontmatter.extends
|
|
696
|
+
: undefined,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
if (change.fileChange === "deleted" && previousDocument?.id) {
|
|
700
|
+
const addedIndex = candidates.findIndex((entry, entryIndex) => (
|
|
701
|
+
!consumed.has(entryIndex) &&
|
|
702
|
+
entry.change.fileChange === "added" &&
|
|
703
|
+
entry.nextDocument?.id === previousDocument.id &&
|
|
704
|
+
entry.nextDocument.kind === previousDocument.kind
|
|
705
|
+
));
|
|
706
|
+
|
|
707
|
+
if (addedIndex >= 0) {
|
|
708
|
+
const added = candidates[addedIndex];
|
|
709
|
+
consumed.add(index);
|
|
710
|
+
consumed.add(addedIndex);
|
|
711
|
+
deltas.push(tryMergeRename(addedIndex, previousDocument, added.nextDocument as ParsedSpecDocument, added.nextPath));
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (change.fileChange === "added" && nextDocument?.id) {
|
|
717
|
+
const deletedIndex = candidates.findIndex((entry, entryIndex) => (
|
|
718
|
+
!consumed.has(entryIndex) &&
|
|
719
|
+
entry.change.fileChange === "deleted" &&
|
|
720
|
+
entry.previousDocument?.id === nextDocument.id &&
|
|
721
|
+
entry.previousDocument.kind === nextDocument.kind
|
|
722
|
+
));
|
|
723
|
+
|
|
724
|
+
if (deletedIndex >= 0) {
|
|
725
|
+
const deleted = candidates[deletedIndex];
|
|
726
|
+
consumed.add(index);
|
|
727
|
+
consumed.add(deletedIndex);
|
|
728
|
+
deltas.push(tryMergeRename(deletedIndex, deleted.previousDocument as ParsedSpecDocument, nextDocument, nextPath));
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (change.previousPath && change.nextPath) {
|
|
734
|
+
const previousSpecId = previousDocument?.id ?? deriveSpecIdFromPath(previousPath);
|
|
735
|
+
const nextSpecId = nextDocument?.id ?? deriveSpecIdFromPath(nextPath);
|
|
736
|
+
if (previousSpecId !== nextSpecId) {
|
|
737
|
+
deltas.push({
|
|
738
|
+
specId: previousSpecId,
|
|
739
|
+
path: join(workspacePath, previousPath),
|
|
740
|
+
kind: previousDocument?.kind ?? deriveSpecKind(previousPath),
|
|
741
|
+
fileChange: "deleted",
|
|
742
|
+
sectionChanges: buildSectionChanges(previousDocument, undefined),
|
|
743
|
+
previousDocument,
|
|
744
|
+
});
|
|
745
|
+
deltas.push({
|
|
746
|
+
specId: nextSpecId,
|
|
747
|
+
path: join(workspacePath, nextPath),
|
|
748
|
+
kind: nextDocument?.kind ?? deriveSpecKind(nextPath),
|
|
749
|
+
extensionKind: nextDocument?.extensionKind,
|
|
750
|
+
fileChange: "added",
|
|
751
|
+
sectionChanges: buildSectionChanges(undefined, nextDocument),
|
|
752
|
+
nextDocument,
|
|
753
|
+
extendsSpecId: nextDocument?.kind !== "base" && typeof nextDocument.frontmatter.extends === "string"
|
|
754
|
+
? nextDocument.frontmatter.extends
|
|
755
|
+
: undefined,
|
|
756
|
+
});
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const document = nextDocument ?? previousDocument;
|
|
762
|
+
const kind = document?.kind ?? deriveSpecKind(change.path);
|
|
763
|
+
const specId = document?.id ?? deriveSpecIdFromPath(change.path);
|
|
764
|
+
deltas.push({
|
|
765
|
+
specId,
|
|
766
|
+
path: join(workspacePath, nextPath),
|
|
767
|
+
kind,
|
|
768
|
+
extensionKind: document?.extensionKind,
|
|
769
|
+
fileChange: change.fileChange,
|
|
770
|
+
sectionChanges: buildSectionChanges(previousDocument, nextDocument),
|
|
771
|
+
previousDocument,
|
|
772
|
+
nextDocument,
|
|
773
|
+
extendsSpecId: kind !== "base"
|
|
774
|
+
? typeof (nextDocument ?? previousDocument)?.frontmatter.extends === "string"
|
|
775
|
+
? ((nextDocument ?? previousDocument)?.frontmatter.extends as string)
|
|
776
|
+
: undefined
|
|
777
|
+
: undefined,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
workspacePath,
|
|
783
|
+
baseRef,
|
|
784
|
+
headRef,
|
|
785
|
+
deltas,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function getSpecDiffRelativePath(diff: SpecDiffReport, path: string): string {
|
|
790
|
+
return toRepoRelativePath(diff.workspacePath, path);
|
|
791
|
+
}
|