specpi 0.10.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/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/NPM_RELEASE.md +110 -0
- package/README.md +155 -0
- package/SECURITY.md +85 -0
- package/SECURITY_MODEL.md +107 -0
- package/THIRD_PARTY.md +61 -0
- package/browser-runtime/package-lock.json +86 -0
- package/browser-runtime/package.json +15 -0
- package/extensions/browser/core.mjs +306 -0
- package/extensions/browser/index.ts +723 -0
- package/extensions/browser/smoke.mjs +47 -0
- package/extensions/command-guard/bash.mjs +1426 -0
- package/extensions/command-guard/cmd.mjs +369 -0
- package/extensions/command-guard/core.mjs +506 -0
- package/extensions/command-guard/index.ts +634 -0
- package/extensions/command-guard/managed-files.mjs +22 -0
- package/extensions/command-guard/paths.mjs +398 -0
- package/extensions/command-guard/powershell-parser.ps1 +47 -0
- package/extensions/command-guard/powershell.mjs +655 -0
- package/extensions/command-guard/redact.mjs +65 -0
- package/extensions/command-guard/rules.mjs +2557 -0
- package/extensions/command-guard/smoke.mjs +422 -0
- package/extensions/files/core.mjs +422 -0
- package/extensions/files/index.ts +678 -0
- package/extensions/spec/core.mjs +47 -0
- package/extensions/spec.ts +457 -0
- package/extensions/tool-wishlist/capabilities.json +114 -0
- package/extensions/tool-wishlist/core.mjs +1525 -0
- package/extensions/tool-wishlist/index.ts +804 -0
- package/extensions/tool-wishlist/registry.mjs +99 -0
- package/extensions/tool-wishlist/validators.mjs +345 -0
- package/extensions/ui-refresh/index.ts +54 -0
- package/extensions/workflow-controls/challenge.mjs +196 -0
- package/extensions/workflow-controls/experiments.mjs +628 -0
- package/extensions/workflow-controls/index.ts +1144 -0
- package/extensions/workflow-controls/scope.mjs +272 -0
- package/extensions/workflow-controls/smoke.mjs +201 -0
- package/package.json +98 -0
- package/scripts/check-package.mjs +483 -0
- package/scripts/check-pi-package.mjs +223 -0
- package/scripts/check-release-order.mjs +97 -0
- package/scripts/lib.mjs +182 -0
- package/scripts/lock.mjs +122 -0
- package/scripts/specpi.mjs +2037 -0
- package/scripts/verify-artifact.mjs +21 -0
- package/shell/pi-profiles.sh +14 -0
- package/site/logo.svg +9 -0
- package/site/self-improvement-loop-v2.svg +108 -0
- package/skills/donsetch/SKILL.md +76 -0
- package/skills/specpi-improve/SKILL.md +54 -0
- package/specpi +4 -0
- package/specpi.cmd +4 -0
- package/templates/AGENTS.md +23 -0
- package/templates/settings.json +10 -0
- package/themes/specpi-spec.json +96 -0
- package/themes/tea-house.json +89 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
export const MAX_REVIEW_CHARS = 16 * 1024;
|
|
8
|
+
export const DEFAULT_MAX_FILES = 4000;
|
|
9
|
+
export const DEFAULT_MAX_DEPTH = 16;
|
|
10
|
+
|
|
11
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
12
|
+
".git",
|
|
13
|
+
".next",
|
|
14
|
+
".nuxt",
|
|
15
|
+
".turbo",
|
|
16
|
+
".venv",
|
|
17
|
+
"build",
|
|
18
|
+
"coverage",
|
|
19
|
+
"dist",
|
|
20
|
+
"node_modules",
|
|
21
|
+
"target",
|
|
22
|
+
"venv",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function runGit(cwd, args) {
|
|
26
|
+
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
});
|
|
31
|
+
if (result.error || result.status !== 0) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result.stdout;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeRelative(value) {
|
|
39
|
+
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sanitizeTerminalText(value, options = {}) {
|
|
43
|
+
const preserveNewlines = options.preserveNewlines === true;
|
|
44
|
+
let result = "";
|
|
45
|
+
for (const character of String(value)) {
|
|
46
|
+
const code = character.codePointAt(0) ?? 0;
|
|
47
|
+
if (character === "\n" && preserveNewlines) {
|
|
48
|
+
result += character;
|
|
49
|
+
} else if (character === "\t") {
|
|
50
|
+
result += " ";
|
|
51
|
+
} else if (code < 32 || (code >= 127 && code <= 159)) {
|
|
52
|
+
result += "�";
|
|
53
|
+
} else {
|
|
54
|
+
result += character;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isInside(parent, candidate) {
|
|
62
|
+
const relativePath = path.relative(parent, candidate);
|
|
63
|
+
|
|
64
|
+
return relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== "..");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasSymlinkComponent(root, candidate) {
|
|
68
|
+
if (!isInside(root, candidate)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const relativePath = path.relative(root, candidate);
|
|
73
|
+
let current = root;
|
|
74
|
+
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
|
|
75
|
+
current = path.join(current, segment);
|
|
76
|
+
try {
|
|
77
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveBrowserRoot(argument, cwd, home = os.homedir()) {
|
|
89
|
+
let value = String(argument || "").trim();
|
|
90
|
+
if (!value) {
|
|
91
|
+
return path.resolve(cwd);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (value === "~") {
|
|
95
|
+
value = home;
|
|
96
|
+
} else if (value.startsWith(`~${path.sep}`) || value.startsWith("~/") || value.startsWith("~\\")) {
|
|
97
|
+
value = path.join(home, value.slice(2));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const resolved = path.resolve(cwd, value);
|
|
101
|
+
let canonical;
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
canonical = fs.realpathSync(resolved);
|
|
105
|
+
stat = fs.statSync(canonical);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`Path is not accessible: ${resolved}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!stat.isDirectory()) {
|
|
111
|
+
throw new Error(`Path is not a directory: ${resolved}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return canonical;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseStatus(output, repoRoot, root) {
|
|
118
|
+
const statuses = new Map();
|
|
119
|
+
const fields = output.split("\0");
|
|
120
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
121
|
+
const record = fields[index];
|
|
122
|
+
if (!record || record.length < 4) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const code = record.slice(0, 2);
|
|
127
|
+
const fileName = record.slice(3);
|
|
128
|
+
if ((code.includes("R") || code.includes("C")) && fields[index + 1]) {
|
|
129
|
+
index += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const absolute = path.resolve(repoRoot, fileName);
|
|
133
|
+
if (!isInside(root, absolute)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
statuses.set(normalizeRelative(path.relative(root, absolute)), code);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return statuses;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function discoverWithGit(root, repoRoot, maxFiles) {
|
|
144
|
+
const output = runGit(repoRoot, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]);
|
|
145
|
+
if (output === undefined) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const files = [];
|
|
150
|
+
let truncated = false;
|
|
151
|
+
for (const fileName of output.split("\0")) {
|
|
152
|
+
if (!fileName) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const absolute = path.resolve(repoRoot, fileName);
|
|
157
|
+
if (!isInside(root, absolute)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const linkStat = fs.lstatSync(absolute);
|
|
163
|
+
if (hasSymlinkComponent(root, absolute) || linkStat.isSymbolicLink() || !linkStat.isFile()) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
files.push(normalizeRelative(path.relative(root, absolute)));
|
|
171
|
+
if (files.length >= maxFiles) {
|
|
172
|
+
truncated = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { files, truncated };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function discoverWithFileSystem(root, maxFiles, maxDepth) {
|
|
181
|
+
const files = [];
|
|
182
|
+
let truncated = false;
|
|
183
|
+
|
|
184
|
+
function visit(directory, depth) {
|
|
185
|
+
if (truncated || depth > maxDepth) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let entries;
|
|
190
|
+
try {
|
|
191
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
192
|
+
} catch {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (truncated) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const absolute = path.join(directory, entry.name);
|
|
207
|
+
if (entry.isDirectory()) {
|
|
208
|
+
visit(absolute, depth + 1);
|
|
209
|
+
} else if (entry.isFile()) {
|
|
210
|
+
files.push(normalizeRelative(path.relative(root, absolute)));
|
|
211
|
+
if (files.length >= maxFiles) {
|
|
212
|
+
truncated = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
visit(root, 0);
|
|
219
|
+
|
|
220
|
+
return { files, truncated };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function discoverProject(root, options = {}) {
|
|
224
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
225
|
+
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
226
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
227
|
+
const repoRootOutput = runGit(canonicalRoot, ["rev-parse", "--show-toplevel"]);
|
|
228
|
+
const repoRoot = repoRootOutput ? path.resolve(repoRootOutput.trim()) : undefined;
|
|
229
|
+
const discovered = repoRoot
|
|
230
|
+
? discoverWithGit(canonicalRoot, repoRoot, maxFiles)
|
|
231
|
+
: discoverWithFileSystem(canonicalRoot, maxFiles, maxDepth);
|
|
232
|
+
const fallback = discovered ?? discoverWithFileSystem(canonicalRoot, maxFiles, maxDepth);
|
|
233
|
+
const statusOutput = repoRoot
|
|
234
|
+
? runGit(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"])
|
|
235
|
+
: undefined;
|
|
236
|
+
const statuses =
|
|
237
|
+
repoRoot && statusOutput !== undefined ? parseStatus(statusOutput, repoRoot, canonicalRoot) : new Map();
|
|
238
|
+
for (const [relativePath, status] of statuses) {
|
|
239
|
+
if (!status.includes("D") || fallback.files.includes(relativePath)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (fallback.files.length >= maxFiles) {
|
|
244
|
+
fallback.truncated = true;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fallback.files.push(relativePath);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
root: canonicalRoot,
|
|
253
|
+
repoRoot,
|
|
254
|
+
files: fallback.files.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" })),
|
|
255
|
+
statuses,
|
|
256
|
+
truncated: fallback.truncated,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function directoryNode(name, relativePath, absolutePath, parent) {
|
|
261
|
+
return { name, relativePath, absolutePath, parent, directory: true, children: [], changed: false };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function buildFileTree(snapshot) {
|
|
265
|
+
const root = directoryNode(path.basename(snapshot.root) || snapshot.root, "", snapshot.root, undefined);
|
|
266
|
+
const directories = new Map([["", root]]);
|
|
267
|
+
|
|
268
|
+
for (const relativePath of snapshot.files) {
|
|
269
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
270
|
+
let parent = root;
|
|
271
|
+
let parentPath = "";
|
|
272
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
273
|
+
const part = parts[index];
|
|
274
|
+
const currentPath = parentPath ? `${parentPath}/${part}` : part;
|
|
275
|
+
let node = directories.get(currentPath);
|
|
276
|
+
if (!node) {
|
|
277
|
+
node = directoryNode(part, currentPath, path.join(snapshot.root, ...currentPath.split("/")), parent);
|
|
278
|
+
parent.children.push(node);
|
|
279
|
+
directories.set(currentPath, node);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
parent = node;
|
|
283
|
+
parentPath = currentPath;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const name = parts.at(-1);
|
|
287
|
+
if (!name) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const status = snapshot.statuses.get(relativePath);
|
|
292
|
+
parent.children.push({
|
|
293
|
+
name,
|
|
294
|
+
relativePath,
|
|
295
|
+
absolutePath: path.join(snapshot.root, ...relativePath.split("/")),
|
|
296
|
+
parent,
|
|
297
|
+
directory: false,
|
|
298
|
+
status,
|
|
299
|
+
changed: Boolean(status),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function finish(node) {
|
|
304
|
+
if (!node.directory) {
|
|
305
|
+
return node.changed;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
node.children.sort((left, right) => {
|
|
309
|
+
if (left.directory !== right.directory) {
|
|
310
|
+
return left.directory ? -1 : 1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
|
314
|
+
});
|
|
315
|
+
let changed = false;
|
|
316
|
+
for (const child of node.children) {
|
|
317
|
+
if (finish(child)) {
|
|
318
|
+
changed = true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
node.changed = changed;
|
|
323
|
+
|
|
324
|
+
return changed;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
finish(root);
|
|
328
|
+
|
|
329
|
+
return root;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function flattenFileTree(root, expanded, query = "", changedOnly = false) {
|
|
333
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
334
|
+
const rows = [];
|
|
335
|
+
|
|
336
|
+
function matches(node) {
|
|
337
|
+
if (changedOnly && !node.changed) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!normalizedQuery) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!node.directory) {
|
|
346
|
+
return node.relativePath.toLowerCase().includes(normalizedQuery);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return node.children.some(matches);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function visit(node, depth) {
|
|
353
|
+
for (const child of node.children) {
|
|
354
|
+
if (!matches(child)) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
rows.push({ node: child, depth });
|
|
359
|
+
const forcedOpen = Boolean(normalizedQuery);
|
|
360
|
+
if (child.directory && (forcedOpen || expanded.has(child.relativePath))) {
|
|
361
|
+
visit(child, depth + 1);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
visit(root, 0);
|
|
367
|
+
|
|
368
|
+
return rows;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function readTextFile(filePath, maximumBytes = MAX_FILE_BYTES, root) {
|
|
372
|
+
if (root && hasSymlinkComponent(fs.realpathSync(root), path.resolve(filePath))) {
|
|
373
|
+
throw new Error("Symbolic links are not opened.");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const stat = fs.lstatSync(filePath);
|
|
377
|
+
if (stat.isSymbolicLink()) {
|
|
378
|
+
throw new Error("Symbolic links are not opened.");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!stat.isFile()) {
|
|
382
|
+
throw new Error("Only regular files can be opened.");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (stat.size > maximumBytes) {
|
|
386
|
+
throw new Error(`File is larger than ${Math.round(maximumBytes / 1024 / 1024)} MiB.`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const data = fs.readFileSync(filePath);
|
|
390
|
+
if (data.includes(0)) {
|
|
391
|
+
throw new Error("Binary files are not shown.");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return data.toString("utf8");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function readGitDiff(filePath, repoRoot) {
|
|
398
|
+
if (!repoRoot || !isInside(repoRoot, filePath)) {
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const relativePath = normalizeRelative(path.relative(repoRoot, filePath));
|
|
403
|
+
const output = runGit(repoRoot, ["diff", "--no-ext-diff", "--no-color", "HEAD", "--", relativePath]);
|
|
404
|
+
|
|
405
|
+
return output ? output.replace(/\n$/, "").split("\n") : [];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function formatReviewMessage(relativePath, startLine, endLine, selectedText, comment) {
|
|
409
|
+
const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
|
|
410
|
+
const safePath = sanitizeTerminalText(relativePath);
|
|
411
|
+
const extension = sanitizeTerminalText(path.extname(relativePath).slice(1));
|
|
412
|
+
const safeSelection = sanitizeTerminalText(selectedText, { preserveNewlines: true });
|
|
413
|
+
const longestFence = Math.max(3, ...[...safeSelection.matchAll(/`+/g)].map((match) => match[0].length + 1));
|
|
414
|
+
const fence = "`".repeat(longestFence);
|
|
415
|
+
const truncated = safeSelection.length > MAX_REVIEW_CHARS;
|
|
416
|
+
const excerpt = truncated
|
|
417
|
+
? `${safeSelection.slice(0, MAX_REVIEW_CHARS)}\n… [selection truncated by SpecPi]`
|
|
418
|
+
: safeSelection;
|
|
419
|
+
const safeComment = sanitizeTerminalText(comment, { preserveNewlines: true }).trim();
|
|
420
|
+
|
|
421
|
+
return `Review comment for ${JSON.stringify(safePath)} (${range}):\n\n${fence}${extension || "text"}\n${excerpt}\n${fence}\n\n${safeComment}`;
|
|
422
|
+
}
|