@luizsantiago/spec-guardrails 3.0.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/index.js +335 -0
  4. package/lib/archive.js +208 -0
  5. package/lib/assets.js +145 -0
  6. package/lib/brownfield.js +446 -0
  7. package/lib/config.js +293 -0
  8. package/lib/constants.js +262 -0
  9. package/lib/cursorrules.js +92 -0
  10. package/lib/delta-merge.js +248 -0
  11. package/lib/doctor.js +343 -0
  12. package/lib/download.js +133 -0
  13. package/lib/feature.js +272 -0
  14. package/lib/fs-utils.js +114 -0
  15. package/lib/gates.js +138 -0
  16. package/lib/install.js +140 -0
  17. package/lib/memory.js +34 -0
  18. package/lib/next-steps.js +50 -0
  19. package/lib/presets.js +176 -0
  20. package/lib/project-rules.js +210 -0
  21. package/lib/specs-utils.js +117 -0
  22. package/lib/token-cost.js +124 -0
  23. package/package.json +46 -0
  24. package/rules/engineering-baseline.mdc +56 -0
  25. package/scripts/_common.py +356 -0
  26. package/scripts/analyze_artifacts.py +187 -0
  27. package/scripts/check_commit.py +140 -0
  28. package/scripts/lessons.py +447 -0
  29. package/scripts/loop_plan.py +217 -0
  30. package/scripts/validate_spec.py +345 -0
  31. package/scripts/validate_state.py +385 -0
  32. package/scripts/validate_tasks.py +379 -0
  33. package/skills/agent-architecture.md +221 -0
  34. package/skills/appsec.md +83 -0
  35. package/skills/code-simplify.md +49 -0
  36. package/skills/engineering-standards.md +98 -0
  37. package/skills/git-handoff.md +213 -0
  38. package/skills/qa-strategy.md +83 -0
  39. package/skills/references/analyze.md +56 -0
  40. package/skills/references/archive.md +60 -0
  41. package/skills/references/constitution.md +66 -0
  42. package/skills/references/context-limits.md +73 -0
  43. package/skills/references/converge.md +47 -0
  44. package/skills/references/design.md +88 -0
  45. package/skills/references/discuss.md +68 -0
  46. package/skills/references/explore.md +61 -0
  47. package/skills/references/implement.md +175 -0
  48. package/skills/references/lessons.md +71 -0
  49. package/skills/references/memory.md +98 -0
  50. package/skills/references/project-init.md +62 -0
  51. package/skills/references/quick-mode.md +84 -0
  52. package/skills/references/specify.md +144 -0
  53. package/skills/references/sub-agents.md +117 -0
  54. package/skills/references/tasks.md +178 -0
  55. package/skills/references/validate.md +210 -0
  56. package/skills/security-review.md +120 -0
  57. package/skills/ship-ready.md +50 -0
  58. package/skills/task-graph-engineering.md +180 -0
  59. package/templates/GETTING_STARTED.md +61 -0
  60. package/templates/config.yaml.example +28 -0
  61. package/templates/presets/default.yaml +16 -0
  62. package/templates/presets/node-ts.yaml +22 -0
  63. package/templates/presets/python.yaml +22 -0
@@ -0,0 +1,248 @@
1
+ const REQUIREMENT_HEADING =
2
+ /^(?<level>#{2,6})\s*(?<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\s*[:\-–]?\s*(?<title>.*)$/gm;
3
+ const ANY_HEADING = /^(?<level>#{1,6})\s+\S/m;
4
+
5
+ const DELTA_SECTIONS = [
6
+ "ADDED Requirements",
7
+ "MODIFIED Requirements",
8
+ "REMOVED Requirements",
9
+ ];
10
+
11
+ const REMOVED_ID = /^\s*(?:-\s*)?(?<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\b/gm;
12
+
13
+ /**
14
+ * @param {string} text
15
+ * @returns {boolean}
16
+ */
17
+ export function isDeltaSpec(text) {
18
+ return DELTA_SECTIONS.some((heading) => hasSection(text, heading));
19
+ }
20
+
21
+ /**
22
+ * @param {string} text
23
+ * @param {string} heading
24
+ * @returns {boolean}
25
+ */
26
+ function hasSection(text, heading) {
27
+ const pattern = new RegExp(`^#{1,6}\\s+${escapeRegExp(heading)}\\s*$`, "im");
28
+ return pattern.test(text);
29
+ }
30
+
31
+ /**
32
+ * @param {string} value
33
+ * @returns {string}
34
+ */
35
+ function escapeRegExp(value) {
36
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ }
38
+
39
+ /**
40
+ * @param {string} text
41
+ * @param {string} sectionHeading
42
+ * @returns {string | null}
43
+ */
44
+ function sectionBody(text, sectionHeading) {
45
+ const headingPattern = new RegExp(
46
+ `^(?<level>#{2,6})\\s*${escapeRegExp(sectionHeading)}\\b`,
47
+ "im",
48
+ );
49
+ const match = headingPattern.exec(text);
50
+ if (!match) {
51
+ return null;
52
+ }
53
+
54
+ const level = match[0].match(/^#+/)?.[0].length ?? 2;
55
+ const start = match.index + match[0].length;
56
+ let end = text.length;
57
+
58
+ const rest = text.slice(start);
59
+ for (const heading of rest.matchAll(/^(#{1,6})\s+\S/gm)) {
60
+ if (heading.index === undefined) {
61
+ continue;
62
+ }
63
+ if (heading[1].length <= level) {
64
+ end = start + heading.index;
65
+ break;
66
+ }
67
+ }
68
+
69
+ return text.slice(start, end);
70
+ }
71
+
72
+ /**
73
+ * @param {string} scoped
74
+ * @returns {Map<string, string>}
75
+ */
76
+ function extractRequirementBlocks(scoped) {
77
+ /** @type {Map<string, string>} */
78
+ const blocks = new Map();
79
+
80
+ const headingRe = new RegExp(REQUIREMENT_HEADING.source, REQUIREMENT_HEADING.flags);
81
+ let match = headingRe.exec(scoped);
82
+
83
+ while (match) {
84
+ const level = match.groups.level.length;
85
+ const id = match.groups.id;
86
+ const blockStart = match.index;
87
+ let blockEnd = scoped.length;
88
+
89
+ const scanRe = new RegExp(REQUIREMENT_HEADING.source, REQUIREMENT_HEADING.flags);
90
+ scanRe.lastIndex = match.index + match[0].length;
91
+ let next = scanRe.exec(scoped);
92
+ while (next) {
93
+ if (next.groups.level.length <= level) {
94
+ blockEnd = next.index;
95
+ break;
96
+ }
97
+ next = scanRe.exec(scoped);
98
+ }
99
+
100
+ const headingLine = scoped.slice(blockStart, match.index + match[0].length).split("\n")[0];
101
+ const body = scoped.slice(match.index + match[0].length, blockEnd).trimEnd();
102
+ blocks.set(id, `${headingLine}\n${body}`.trimEnd());
103
+
104
+ headingRe.lastIndex = blockEnd;
105
+ match = headingRe.exec(scoped);
106
+ }
107
+
108
+ return blocks;
109
+ }
110
+
111
+ /**
112
+ * @param {string} text
113
+ * @returns {Map<string, string>}
114
+ */
115
+ export function extractDomainRequirements(text) {
116
+ const requirementsSection = sectionBody(text, "Requirements");
117
+ if (!requirementsSection) {
118
+ return new Map();
119
+ }
120
+ return extractRequirementBlocks(requirementsSection);
121
+ }
122
+
123
+ /**
124
+ * @param {string} domainSpec
125
+ * @param {Map<string, string>} requirements
126
+ * @returns {string}
127
+ */
128
+ function writeDomainRequirements(domainSpec, requirements) {
129
+ const blocks = [...requirements.values()];
130
+ const requirementsBody = blocks.length ? `\n${blocks.join("\n\n")}\n` : "\n- none\n";
131
+
132
+ if (hasSection(domainSpec, "Requirements")) {
133
+ const headingPattern = /^#{2,6}\s*Requirements\b/im;
134
+ const match = headingPattern.exec(domainSpec);
135
+ if (!match) {
136
+ return domainSpec;
137
+ }
138
+
139
+ const level = match[0].match(/^#+/)?.[0] ?? "##";
140
+ const start = match.index;
141
+ const levelLen = level.length;
142
+ let end = domainSpec.length;
143
+
144
+ for (const heading of domainSpec.slice(start + match[0].length).matchAll(/^(#{1,6})\s+\S/gm)) {
145
+ if (heading.index === undefined) {
146
+ continue;
147
+ }
148
+ if (heading[1].length <= levelLen) {
149
+ end = start + match[0].length + heading.index;
150
+ break;
151
+ }
152
+ }
153
+
154
+ return (
155
+ domainSpec.slice(0, start) +
156
+ `${level} Requirements${requirementsBody}` +
157
+ domainSpec.slice(end).replace(/^\n+/, "")
158
+ );
159
+ }
160
+
161
+ const trimmed = domainSpec.trimEnd();
162
+ return `${trimmed}\n\n## Requirements${requirementsBody}`;
163
+ }
164
+
165
+ /**
166
+ * @param {string} scoped
167
+ * @returns {string[]}
168
+ */
169
+ function extractRemovedIds(scoped) {
170
+ if (!scoped) {
171
+ return [];
172
+ }
173
+ if (/\bnone\b/i.test(scoped.trim())) {
174
+ return [];
175
+ }
176
+
177
+ /** @type {string[]} */
178
+ const ids = [];
179
+ for (const match of scoped.matchAll(REMOVED_ID)) {
180
+ ids.push(match.groups.id);
181
+ }
182
+ return ids;
183
+ }
184
+
185
+ /**
186
+ * Merge a feature spec (full or delta) into a domain spec.
187
+ *
188
+ * @param {string} domainSpec
189
+ * @param {string} featureSpec
190
+ * @returns {{ spec: string, summary: string[] }}
191
+ */
192
+ export function mergeFeatureIntoDomain(domainSpec, featureSpec) {
193
+ const summary = [];
194
+ let requirements = extractDomainRequirements(domainSpec);
195
+
196
+ if (isDeltaSpec(featureSpec)) {
197
+ const added = sectionBody(featureSpec, "ADDED Requirements");
198
+ const modified = sectionBody(featureSpec, "MODIFIED Requirements");
199
+ const removed = sectionBody(featureSpec, "REMOVED Requirements");
200
+
201
+ for (const [id, block] of extractRequirementBlocks(added ?? "")) {
202
+ requirements.set(id, block);
203
+ summary.push(`ADDED ${id}`);
204
+ }
205
+
206
+ for (const [id, block] of extractRequirementBlocks(modified ?? "")) {
207
+ requirements.set(id, block);
208
+ summary.push(`MODIFIED ${id}`);
209
+ }
210
+
211
+ for (const id of extractRemovedIds(removed ?? "")) {
212
+ if (requirements.delete(id)) {
213
+ summary.push(`REMOVED ${id}`);
214
+ } else {
215
+ summary.push(`REMOVED ${id} (not in domain spec)`);
216
+ }
217
+ }
218
+ } else {
219
+ const fullRequirements = sectionBody(featureSpec, "Requirements");
220
+ if (fullRequirements) {
221
+ requirements = extractRequirementBlocks(fullRequirements);
222
+ summary.push(`copied ${requirements.size} requirement(s) from full spec`);
223
+ } else {
224
+ summary.push("full spec has no Requirements section — domain spec unchanged");
225
+ }
226
+ }
227
+
228
+ return {
229
+ spec: writeDomainRequirements(domainSpec, requirements),
230
+ summary,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * @param {string} domain
236
+ * @param {string} featureId
237
+ * @returns {string}
238
+ */
239
+ export function domainSpecStub(domain, featureId) {
240
+ return `# Domain: ${domain}
241
+
242
+ > Seeded by archive-feature from \`${featureId}\`.
243
+
244
+ ## Requirements
245
+
246
+ - none
247
+ `;
248
+ }
package/lib/doctor.js ADDED
@@ -0,0 +1,343 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ import { NPX, SKILL_DIRS } from "./constants.js";
7
+ import { resolveScriptsDir } from "./gates.js";
8
+ import { readFileSafe } from "./fs-utils.js";
9
+ import { listFeatureIds } from "./specs-utils.js";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /**
14
+ * @typedef {{
15
+ * id: string,
16
+ * label: string,
17
+ * weight: number,
18
+ * pass: boolean,
19
+ * suggest?: string,
20
+ * optional?: boolean,
21
+ * }} DoctorCheck
22
+ */
23
+
24
+ /**
25
+ * @param {string} cwd
26
+ * @param {string} relativePath
27
+ * @returns {Promise<boolean>}
28
+ */
29
+ async function pathExists(cwd, relativePath) {
30
+ try {
31
+ await fs.access(path.join(cwd, relativePath));
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {string} cwd
40
+ * @returns {Promise<boolean>}
41
+ */
42
+ async function pythonAvailable() {
43
+ for (const bin of ["python3", "python"]) {
44
+ try {
45
+ const { stdout } = await execFileAsync(bin, ["--version"]);
46
+ const match = stdout.match(/(\d+)\.(\d+)/);
47
+ if (!match) {
48
+ continue;
49
+ }
50
+ const major = Number(match[1]);
51
+ const minor = Number(match[2]);
52
+ if (major > 3 || (major === 3 && minor >= 10)) {
53
+ return true;
54
+ }
55
+ } catch {
56
+ // try next binary
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /**
63
+ * @param {string} cwd
64
+ * @returns {Promise<string | null>}
65
+ */
66
+ async function readActiveFeature(cwd) {
67
+ try {
68
+ const state = await readFileSafe(path.join(cwd, ".specs/STATE.md"));
69
+ const match = state.match(/^\s*[-*]\s*\*\*Active feature\*\*:\s*(.+)$/im);
70
+ if (!match) {
71
+ return null;
72
+ }
73
+ const value = match[1].trim();
74
+ if (!value || /^none|idle|—|-$/i.test(value)) {
75
+ return null;
76
+ }
77
+ return value.replace(/^`|`$/g, "");
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {string} cwd
85
+ * @returns {Promise<DoctorCheck[]>}
86
+ */
87
+ export async function runDoctorChecks(cwd) {
88
+ /** @type {DoctorCheck[]} */
89
+ const checks = [];
90
+
91
+ const hubInstalled = (
92
+ await Promise.all(
93
+ SKILL_DIRS.map((dir) => pathExists(cwd, path.join(dir, "agent-architecture.md"))),
94
+ )
95
+ ).some(Boolean);
96
+
97
+ checks.push({
98
+ id: "skills-hub",
99
+ label: "Agent hub skill (agent-architecture.md)",
100
+ weight: 12,
101
+ pass: hubInstalled,
102
+ suggest: NPX("install"),
103
+ });
104
+
105
+ const scriptsDir = await resolveScriptsDir(cwd);
106
+ const gatesDir = path.join(cwd, scriptsDir);
107
+ let gatesPresent = false;
108
+ try {
109
+ const entries = await fs.readdir(gatesDir);
110
+ gatesPresent = entries.some((name) => name.endsWith(".py"));
111
+ } catch {
112
+ gatesPresent = false;
113
+ }
114
+
115
+ checks.push({
116
+ id: "gate-scripts",
117
+ label: `Python gate scripts under ${scriptsDir}/`,
118
+ weight: 15,
119
+ pass: gatesPresent,
120
+ suggest: NPX("install"),
121
+ });
122
+
123
+ const pythonOk = await pythonAvailable();
124
+ checks.push({
125
+ id: "python",
126
+ label: "Python 3.10+ available for gates",
127
+ weight: 8,
128
+ pass: pythonOk,
129
+ suggest: "Install Python 3.10+ or run gate checklists manually from skills/references/",
130
+ });
131
+
132
+ const specsScaffold =
133
+ (await pathExists(cwd, ".specs/STATE.md")) &&
134
+ (await pathExists(cwd, ".specs/features")) &&
135
+ (await pathExists(cwd, ".specs/project"));
136
+
137
+ checks.push({
138
+ id: "specs-scaffold",
139
+ label: ".specs/ memory scaffold (STATE, features/, project/)",
140
+ weight: 10,
141
+ pass: specsScaffold,
142
+ suggest: NPX("install"),
143
+ });
144
+
145
+ checks.push({
146
+ id: "config",
147
+ label: ".specs/config.yaml project config",
148
+ weight: 10,
149
+ pass: await pathExists(cwd, ".specs/config.yaml"),
150
+ suggest: NPX("init-config --preset default"),
151
+ });
152
+
153
+ checks.push({
154
+ id: "baseline-rule",
155
+ label: "engineering-baseline.mdc always-on rule",
156
+ weight: 8,
157
+ pass: await pathExists(cwd, ".cursor/rules/engineering-baseline.mdc"),
158
+ suggest: NPX("install"),
159
+ });
160
+
161
+ const hasProjectMd = await pathExists(cwd, ".specs/project/PROJECT.md");
162
+ checks.push({
163
+ id: "project-context",
164
+ label: "PROJECT.md brownfield context",
165
+ weight: 10,
166
+ pass: hasProjectMd,
167
+ optional: true,
168
+ suggest: NPX("project-init"),
169
+ });
170
+
171
+ const activeFeature = await readActiveFeature(cwd);
172
+ let activeFeatureOk = true;
173
+ let activeFeatureSuggest;
174
+
175
+ if (activeFeature) {
176
+ const features = await listFeatureIds(cwd);
177
+ activeFeatureOk = features.includes(activeFeature);
178
+ if (!activeFeatureOk) {
179
+ activeFeatureSuggest = `Update .specs/STATE.md or run feature-init — "${activeFeature}" not found under .specs/features/`;
180
+ }
181
+ }
182
+
183
+ checks.push({
184
+ id: "state-feature",
185
+ label: "STATE.md active feature matches .specs/features/",
186
+ weight: 5,
187
+ pass: activeFeatureOk,
188
+ suggest: activeFeatureSuggest,
189
+ });
190
+
191
+ let gateSmoke = false;
192
+ if (gatesPresent && pythonOk) {
193
+ try {
194
+ const script = path.join(cwd, scriptsDir, "check_commit.py");
195
+ await execFileAsync("python3", [
196
+ script,
197
+ "--message",
198
+ "chore(guardrails): doctor smoke test",
199
+ ], { cwd });
200
+ gateSmoke = true;
201
+ } catch {
202
+ gateSmoke = false;
203
+ }
204
+ }
205
+
206
+ checks.push({
207
+ id: "gate-smoke",
208
+ label: "check-commit gate runs",
209
+ weight: 12,
210
+ pass: gateSmoke,
211
+ suggest: gatesPresent
212
+ ? "Fix Python gate install or run: python3 .specs/guardrails/scripts/check_commit.py --message \"test: smoke\""
213
+ : NPX("install"),
214
+ });
215
+
216
+ if (activeFeature) {
217
+ const featureDir = path.join(cwd, ".specs/features", activeFeature);
218
+ const tasksPath = path.join(featureDir, "tasks.md");
219
+ let taskCount = 0;
220
+ let needsGraph = false;
221
+ let hasGraph = false;
222
+
223
+ try {
224
+ const tasks = await readFileSafe(tasksPath);
225
+ taskCount = (tasks.match(/^#{2,6}\s*T\d+/gim) ?? []).length;
226
+ needsGraph = taskCount >= 3;
227
+ hasGraph = await pathExists(cwd, path.join(".specs/features", activeFeature, "task-graph.md"));
228
+ } catch {
229
+ needsGraph = false;
230
+ }
231
+
232
+ if (needsGraph) {
233
+ checks.push({
234
+ id: "task-graph",
235
+ label: `task-graph.md for active feature (${taskCount} tasks)`,
236
+ weight: 10,
237
+ pass: hasGraph,
238
+ suggest: `Draw the DAG in .specs/features/${activeFeature}/task-graph.md (see task-graph-engineering.md)`,
239
+ });
240
+ }
241
+ }
242
+
243
+ return checks;
244
+ }
245
+
246
+ /**
247
+ * Contextual Execute hint when the active feature has tasks.md.
248
+ *
249
+ * @param {string} cwd
250
+ * @param {string | null} activeFeature
251
+ * @returns {Promise<string | null>}
252
+ */
253
+ export async function resolveExecuteHint(cwd, activeFeature) {
254
+ if (!activeFeature) {
255
+ return null;
256
+ }
257
+
258
+ const tasksPath = path.join(cwd, ".specs/features", activeFeature, "tasks.md");
259
+
260
+ try {
261
+ const tasks = await readFileSafe(tasksPath);
262
+ const taskIds = tasks.match(/^#{2,6}\s*T\d+/gim) ?? [];
263
+ if (taskIds.length === 0) {
264
+ return null;
265
+ }
266
+
267
+ const completeCount = (tasks.match(/-\s*\[x\]\s*complete\b/gi) ?? []).length;
268
+ if (completeCount >= taskIds.length) {
269
+ return `${NPX(`validate-state ${activeFeature}`)} — tasks look complete; run Validate before /verify`;
270
+ }
271
+
272
+ return `${NPX(`loop-plan ${activeFeature}`)} — next Execute wave (then /loop in chat)`;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * @param {DoctorCheck[]} checks
280
+ * @returns {number}
281
+ */
282
+ export function scoreDoctorChecks(checks) {
283
+ const scored = checks.filter((check) => !check.optional);
284
+ const earned = scored.filter((check) => check.pass).reduce((sum, check) => sum + check.weight, 0);
285
+ const total = scored.reduce((sum, check) => sum + check.weight, 0);
286
+ if (total === 0) {
287
+ return 0;
288
+ }
289
+ return Math.round((earned / total) * 100);
290
+ }
291
+
292
+ /**
293
+ * @param {DoctorCheck[]} checks
294
+ * @param {number} limit
295
+ * @returns {DoctorCheck[]}
296
+ */
297
+ export function topDoctorSuggestions(checks, limit = 3) {
298
+ return checks.filter((check) => !check.pass && check.suggest).slice(0, limit);
299
+ }
300
+
301
+ /**
302
+ * @param {string} cwd
303
+ * @param {{ suggest?: boolean, json?: boolean }} [options]
304
+ * @returns {Promise<{ score: number, checks: DoctorCheck[], suggestions: DoctorCheck[], executeHint: string | null }>}
305
+ */
306
+ export async function doctor(cwd, options = {}) {
307
+ const checks = await runDoctorChecks(cwd);
308
+ const score = scoreDoctorChecks(checks);
309
+ const suggestions = topDoctorSuggestions(checks);
310
+ const activeFeature = await readActiveFeature(cwd);
311
+ const executeHint = await resolveExecuteHint(cwd, activeFeature);
312
+
313
+ if (options.json) {
314
+ console.log(
315
+ JSON.stringify({ score, checks, suggestions, executeHint }, null, 2),
316
+ );
317
+ return { score, checks, suggestions, executeHint };
318
+ }
319
+
320
+ console.log(`Guardrails Ready: ${score}/100\n`);
321
+
322
+ for (const check of checks) {
323
+ const mark = check.pass ? "✓" : "✗";
324
+ const optional = check.optional ? " (optional)" : "";
325
+ console.log(`${mark} ${check.label}${optional}`);
326
+ if (!check.pass && check.suggest && options.suggest !== false) {
327
+ console.log(` → ${check.suggest}`);
328
+ }
329
+ }
330
+
331
+ if (suggestions.length > 0) {
332
+ console.log("\nTop next actions:");
333
+ suggestions.forEach((check, index) => {
334
+ console.log(`${index + 1}. ${check.suggest}`);
335
+ });
336
+ }
337
+
338
+ if (executeHint) {
339
+ console.log(`\nExecute hint:\n → ${executeHint}`);
340
+ }
341
+
342
+ return { score, checks, suggestions, executeHint };
343
+ }
@@ -0,0 +1,133 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { Readable, Transform } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+
5
+ import { assertSafeDownloadUrl } from "./constants.js";
6
+ import { assertSafeWriteTarget, removeFileSafe } from "./fs-utils.js";
7
+
8
+ /** A stalled mirror should fail the install instead of hanging it. */
9
+ const REQUEST_TIMEOUT_MS = 30_000;
10
+
11
+ /** Harness assets are markdown and small scripts; anything larger is suspect. */
12
+ const MAX_ASSET_BYTES = 2 * 1024 * 1024;
13
+
14
+ /** Cap redirect chains so a malicious mirror cannot loop forever. */
15
+ const MAX_REDIRECTS = 10;
16
+
17
+ function limitSize(url, limit) {
18
+ let received = 0;
19
+
20
+ return new Transform({
21
+ transform(chunk, _encoding, callback) {
22
+ received += chunk.length;
23
+
24
+ if (received > limit) {
25
+ callback(
26
+ new Error(
27
+ `Download failed: ${url} exceeds the ${limit}-byte asset limit`,
28
+ ),
29
+ );
30
+ return;
31
+ }
32
+
33
+ callback(null, chunk);
34
+ },
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Follow redirects manually so every hop re-validates the HTTPS/localhost policy.
40
+ *
41
+ * @param {string} startUrl
42
+ * @returns {Promise<Response>}
43
+ */
44
+ async function fetchWithSafeRedirects(startUrl) {
45
+ let current = assertSafeDownloadUrl(startUrl);
46
+
47
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
48
+ let response;
49
+
50
+ try {
51
+ response = await fetch(current, {
52
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
53
+ redirect: "manual",
54
+ });
55
+ } catch (err) {
56
+ if (err.name === "TimeoutError" || err.name === "AbortError") {
57
+ throw new Error(
58
+ `Download failed: ${current} timed out after ${REQUEST_TIMEOUT_MS}ms`,
59
+ );
60
+ }
61
+ throw new Error(
62
+ `Download failed: unable to reach ${current} (${err.message})`,
63
+ );
64
+ }
65
+
66
+ if (response.status >= 300 && response.status < 400) {
67
+ const location = response.headers.get("location");
68
+ if (!location) {
69
+ throw new Error(
70
+ `Download failed: redirect ${response.status} from ${current} with no Location`,
71
+ );
72
+ }
73
+
74
+ let next;
75
+ try {
76
+ next = new URL(location, current).href;
77
+ } catch {
78
+ throw new Error(
79
+ `Download failed: invalid redirect Location from ${current}`,
80
+ );
81
+ }
82
+
83
+ try {
84
+ current = assertSafeDownloadUrl(next, current);
85
+ } catch (err) {
86
+ throw new Error(
87
+ `Download failed: redirect from ${startUrl} to disallowed URL (${err.message})`,
88
+ );
89
+ }
90
+ continue;
91
+ }
92
+
93
+ return response;
94
+ }
95
+
96
+ throw new Error(
97
+ `Download failed: too many redirects while fetching ${startUrl}`,
98
+ );
99
+ }
100
+
101
+ export async function downloadToFile(url, destPath) {
102
+ await assertSafeWriteTarget(destPath);
103
+
104
+ const response = await fetchWithSafeRedirects(url);
105
+
106
+ if (!response.ok) {
107
+ throw new Error(`Download failed: ${response.status} ${url}`);
108
+ }
109
+
110
+ if (!response.body) {
111
+ throw new Error(`Download failed: empty response body from ${url}`);
112
+ }
113
+
114
+ const declaredLength = Number(response.headers.get("content-length"));
115
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_ASSET_BYTES) {
116
+ throw new Error(
117
+ `Download failed: ${url} exceeds the ${MAX_ASSET_BYTES}-byte asset limit`,
118
+ );
119
+ }
120
+
121
+ const body = Readable.fromWeb(response.body);
122
+ const fileStream = createWriteStream(destPath);
123
+
124
+ try {
125
+ await pipeline(body, limitSize(url, MAX_ASSET_BYTES), fileStream);
126
+ } catch (err) {
127
+ await removeFileSafe(destPath);
128
+ if (err.code === "EACCES" || err.code === "EPERM") {
129
+ throw new Error(`Permission denied: cannot write ${destPath}`);
130
+ }
131
+ throw err;
132
+ }
133
+ }