@next-generation-collective/factory 0.0.0-stage → 0.3.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 +78 -2
- package/bin/factory.js +9 -0
- package/lib/agent.js +121 -0
- package/lib/agents.js +42 -0
- package/lib/analyse.js +252 -0
- package/lib/approval.js +72 -0
- package/lib/checks.js +454 -0
- package/lib/cli.js +157 -0
- package/lib/commands.js +1229 -0
- package/lib/config.js +97 -0
- package/lib/detect.js +184 -0
- package/lib/fixes.js +90 -0
- package/lib/interview.js +64 -0
- package/lib/journeys.js +222 -0
- package/lib/prompts.js +343 -0
- package/lib/review.js +151 -0
- package/lib/spec.js +136 -0
- package/lib/util.js +109 -0
- package/lib/verify.js +149 -0
- package/package.json +39 -4
- package/template/constitution.md +204 -0
- package/template/lesson.md +20 -0
- package/template/plan.md +52 -0
- package/template/review-rules.md +88 -0
- package/template/roles/builder.md +12 -0
- package/template/roles/planner.md +15 -0
- package/template/roles/reviewer.md +15 -0
- package/template/roles/shipper.md +12 -0
- package/template/roles/tester.md +13 -0
- package/template/spec.md +80 -0
- package/template/tasks.md +19 -0
package/lib/checks.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { gzipSync } from "node:zlib";
|
|
6
|
+
import { readConfig } from "./config.js";
|
|
7
|
+
import { buildOutputDirs, detectStack } from "./detect.js";
|
|
8
|
+
import { isTestFile, plural, readText, rel, walk } from "./util.js";
|
|
9
|
+
|
|
10
|
+
// Shapes that are a credential wherever they appear.
|
|
11
|
+
const STRONG_SECRETS = [
|
|
12
|
+
{ name: "private key", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
|
|
13
|
+
{ name: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
|
|
14
|
+
{ name: "GitHub fine-grained token", re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/ },
|
|
15
|
+
{ name: "OpenAI-style key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
|
16
|
+
{ name: "Stripe live key", re: /\bsk_live_[A-Za-z0-9]{20,}\b/ },
|
|
17
|
+
{ name: "AWS access key", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
18
|
+
{ name: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
19
|
+
{ name: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
20
|
+
{ name: "Supabase secret key", re: /\bsb_secret_[A-Za-z0-9_-]{20,}\b/ },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const ASSIGNED_SECRET =
|
|
24
|
+
/\b(?:api[_-]?key|apikey|secret|client[_-]?secret|password|passwd|pwd|access[_-]?token|auth[_-]?token)\b\s*[:=]\s*["']([^"']{8,})["']/i;
|
|
25
|
+
const CONNECTION_STRING =
|
|
26
|
+
/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/([^\s:@/]+):([^\s:@/]{4,})@([^\s/:?]+)/;
|
|
27
|
+
|
|
28
|
+
const PLACEHOLDER =
|
|
29
|
+
/(process\.env|\$\{|\$[A-Z_]|\{[^}]*\}|<[^>]+>|your[_-]?|example|sample|changeme|placeholder|redacted|dummy|fake|mock|stub|demo|not[_-]?real|test[_-]?key|abcdefghijklmnopqrstuvwxyz|1234567890|xxxx|\*\*\*|\.\.\.)/i;
|
|
30
|
+
const LOCAL_HOST = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal|\[::1\])$/i;
|
|
31
|
+
const SOFT_PATH =
|
|
32
|
+
/(?:^|[\\/])(?:tests?|__tests__|specs?|e2e|fixtures?|__fixtures__|__mocks__|mocks?|examples?|samples?|docs?|migrations?|seeds?)(?:[\\/]|$)/i;
|
|
33
|
+
const SOFT_NAME = /\.(?:md|mdx|txt|rst)$|(?:example|sample|template)/i;
|
|
34
|
+
|
|
35
|
+
const make = (status) => (id, title, kind, summary, details = [], evidence = []) => ({
|
|
36
|
+
id,
|
|
37
|
+
title,
|
|
38
|
+
kind,
|
|
39
|
+
status,
|
|
40
|
+
summary,
|
|
41
|
+
details,
|
|
42
|
+
evidence,
|
|
43
|
+
});
|
|
44
|
+
const ok = make("pass");
|
|
45
|
+
const bad = make("fail");
|
|
46
|
+
const na = (id, title, kind, summary) => make("na")(id, title, kind, summary);
|
|
47
|
+
const notRun = (id, title, kind, summary, details = [], evidence = []) =>
|
|
48
|
+
make("not-run")(id, title, kind, summary, details, evidence);
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
function isSoftFile(relPath) {
|
|
52
|
+
return isTestFile(relPath) || SOFT_PATH.test(relPath) || SOFT_NAME.test(relPath.split(/[\\/]/).pop());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Returns null (not a secret), "proven" or "maybe" for one line.
|
|
56
|
+
function classifyLine(line, relPath) {
|
|
57
|
+
for (const pattern of STRONG_SECRETS) {
|
|
58
|
+
if (pattern.re.test(line)) return { name: pattern.name, tier: "proven" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const conn = line.match(CONNECTION_STRING);
|
|
62
|
+
if (conn) {
|
|
63
|
+
const [, user, password, host] = conn;
|
|
64
|
+
if (!PLACEHOLDER.test(password)) {
|
|
65
|
+
const local = LOCAL_HOST.test(host) || user === password;
|
|
66
|
+
return {
|
|
67
|
+
name: "connection string with a password",
|
|
68
|
+
tier: local || isSoftFile(relPath) ? "maybe" : "proven",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const assigned = line.match(ASSIGNED_SECRET);
|
|
74
|
+
if (assigned) {
|
|
75
|
+
const value = assigned[1];
|
|
76
|
+
if (/\s/.test(value) || PLACEHOLDER.test(value)) return null;
|
|
77
|
+
if (line.trimStart().startsWith("//") || line.trimStart().startsWith("#")) return null;
|
|
78
|
+
const weak = value.length < 12 || isSoftFile(relPath);
|
|
79
|
+
return { name: "assigned secret", tier: weak ? "maybe" : "proven" };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function envProblem(root) {
|
|
85
|
+
if (!existsSync(join(root, ".env"))) return null;
|
|
86
|
+
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], {
|
|
87
|
+
cwd: root,
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
90
|
+
});
|
|
91
|
+
if (tracked.status === 0) return { tier: "proven", text: ".env is tracked by git" };
|
|
92
|
+
|
|
93
|
+
const check = spawnSync("git", ["check-ignore", "-q", ".env"], { cwd: root, stdio: "ignore" });
|
|
94
|
+
let ignored = check.status === 0;
|
|
95
|
+
if (check.status !== 0 && check.status !== 1) {
|
|
96
|
+
const ignore = readText(join(root, ".gitignore")) ?? "";
|
|
97
|
+
ignored = ignore
|
|
98
|
+
.split("\n")
|
|
99
|
+
.map((line) => line.trim())
|
|
100
|
+
.some((line) => [".env", ".env*", "*.env", "/.env", "/.env*"].includes(line));
|
|
101
|
+
}
|
|
102
|
+
return ignored ? null : { tier: "maybe", text: ".env exists and is not ignored by git" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Two rows, always both: real credentials (proven), and lines that look like test or
|
|
106
|
+
// dev passwords (a person decides). A false alarm is worse than a missed problem.
|
|
107
|
+
export function checkSecrets(root, { files } = {}) {
|
|
108
|
+
const proven = [];
|
|
109
|
+
const maybe = [];
|
|
110
|
+
let scanned = 0;
|
|
111
|
+
|
|
112
|
+
for (const file of files ?? walk(root)) {
|
|
113
|
+
const text = readText(file);
|
|
114
|
+
if (text == null) continue;
|
|
115
|
+
scanned++;
|
|
116
|
+
const where = rel(root, file);
|
|
117
|
+
const lines = text.split("\n");
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
if (lines[i].includes("factory:allow-secret")) continue;
|
|
120
|
+
const found = classifyLine(lines[i], where);
|
|
121
|
+
if (!found) continue;
|
|
122
|
+
(found.tier === "proven" ? proven : maybe).push(`${where}:${i + 1} ${found.name}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const env = envProblem(root);
|
|
127
|
+
if (env?.tier === "proven") proven.push(env.text);
|
|
128
|
+
if (env?.tier === "maybe") maybe.push(env.text);
|
|
129
|
+
|
|
130
|
+
const secrets = proven.length
|
|
131
|
+
? bad(
|
|
132
|
+
"secrets",
|
|
133
|
+
"Secrets in code",
|
|
134
|
+
"proven",
|
|
135
|
+
proven.length === 1
|
|
136
|
+
? `1 credential: ${proven[0].split(" ")[0]}`
|
|
137
|
+
: `${proven.length} credentials found`,
|
|
138
|
+
proven.slice(0, 50),
|
|
139
|
+
)
|
|
140
|
+
: ok("secrets", "Secrets in code", "proven", `No credentials found in ${plural(scanned, "file")}`);
|
|
141
|
+
|
|
142
|
+
const possible = maybe.length
|
|
143
|
+
? bad(
|
|
144
|
+
"secrets-maybe",
|
|
145
|
+
"Possible test passwords",
|
|
146
|
+
"flagged",
|
|
147
|
+
`${plural(maybe.length, "line")} in test and dev files; add factory:allow-secret on the line if it is fake`,
|
|
148
|
+
maybe.slice(0, 50),
|
|
149
|
+
)
|
|
150
|
+
: ok("secrets-maybe", "Possible test passwords", "flagged", "None seen");
|
|
151
|
+
|
|
152
|
+
return [secrets, possible];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const COULD_NOT_RUN =
|
|
156
|
+
/command not found|is not recognized as an internal or external command|No module named pytest|ENOENT|not found: (?:yarn|pnpm|bun|npm|uv|poetry)/i;
|
|
157
|
+
|
|
158
|
+
export function runCommand(root, command, logName, { cwd = null, logDir = null, timeoutMinutes = 10 } = {}) {
|
|
159
|
+
const dir = logDir ?? join(tmpdir(), "factory-logs");
|
|
160
|
+
mkdirSync(dir, { recursive: true });
|
|
161
|
+
const log = join(dir, logName);
|
|
162
|
+
const run = spawnSync(command, {
|
|
163
|
+
cwd: cwd ? join(root, cwd) : root,
|
|
164
|
+
shell: true,
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
timeout: timeoutMinutes * 60 * 1000,
|
|
167
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
168
|
+
});
|
|
169
|
+
const text = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
|
|
170
|
+
writeFileSync(
|
|
171
|
+
log,
|
|
172
|
+
[`$ ${command}${cwd ? ` (in ${cwd})` : ""}`, "", run.stdout ?? "", run.stderr ?? "", `exit ${run.status ?? "timeout"}`].join("\n"),
|
|
173
|
+
);
|
|
174
|
+
const tail = text
|
|
175
|
+
.split("\n")
|
|
176
|
+
.map((line) => line.trim())
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.slice(-8);
|
|
179
|
+
const couldNotRun = run.status === 127 || run.error?.code === "ENOENT" || (run.status !== 0 && COULD_NOT_RUN.test(text));
|
|
180
|
+
const reason = couldNotRun
|
|
181
|
+
? (text.split("\n").find((line) => COULD_NOT_RUN.test(line)) ?? "").replace(/^.*?:\s*(?=No module|command not found)/, "").trim()
|
|
182
|
+
: "";
|
|
183
|
+
return {
|
|
184
|
+
reason,
|
|
185
|
+
status: run.status,
|
|
186
|
+
timedOut: run.status == null,
|
|
187
|
+
couldNotRun,
|
|
188
|
+
log: logDir ? rel(root, log) : log,
|
|
189
|
+
tail,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function commandCheck(root, { deep, stack, config, logDir }, { id, title, command, logName }) {
|
|
194
|
+
if (!command) return na(id, title, "proven", `No ${id === "tests" ? "test" : "build"} command found`);
|
|
195
|
+
if (!deep) return notRun(id, title, "proven", "factory verify --deep runs it");
|
|
196
|
+
|
|
197
|
+
const minutes = config?.checks?.timeoutMinutes ?? 10;
|
|
198
|
+
const run = runCommand(root, command, logName, { cwd: stack.commandCwd, logDir, timeoutMinutes: minutes });
|
|
199
|
+
const where = stack.commandCwd ? ` in ${stack.commandCwd}` : "";
|
|
200
|
+
if (run.status === 0) return ok(id, title, "proven", `\`${command}\`${where} passed`, [], [run.log]);
|
|
201
|
+
if (run.couldNotRun) {
|
|
202
|
+
const why = run.reason ? ` (${run.reason.slice(0, 80)})` : "";
|
|
203
|
+
return notRun(id, title, "proven", `\`${command}\` could not start here${why}; install what it needs, then run it again`, run.tail, [run.log]);
|
|
204
|
+
}
|
|
205
|
+
if (run.timedOut) {
|
|
206
|
+
return notRun(id, title, "proven", `\`${command}\` did not finish within ${minutes} minutes; raise checks.timeoutMinutes`, run.tail, [run.log]);
|
|
207
|
+
}
|
|
208
|
+
return bad(id, title, "proven", `\`${command}\`${where} failed (exit ${run.status})`, run.tail, [run.log]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function checkTests(root, ctx) {
|
|
212
|
+
return commandCheck(root, ctx, {
|
|
213
|
+
id: "tests",
|
|
214
|
+
title: "Tests run and pass",
|
|
215
|
+
command: ctx.stack.testCommand,
|
|
216
|
+
logName: "tests.log",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function checkBuild(root, ctx) {
|
|
221
|
+
return commandCheck(root, ctx, {
|
|
222
|
+
id: "build",
|
|
223
|
+
title: "Builds",
|
|
224
|
+
command: ctx.stack.buildCommand,
|
|
225
|
+
logName: "build.log",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const MATCHER = /\.(?:toBe|toEqual|toStrictEqual|toMatch|toContain|toHaveLength|toThrow|toHaveTextContent|toBeVisible|toContainText|toHaveText|toHaveBeenCalled|rejects|resolves)\b/g;
|
|
230
|
+
const SCREEN_MATCHER = /\.(?:toHaveTextContent|toBeVisible|toContainText|toHaveText)\b/g;
|
|
231
|
+
const BROWSER_TEST = /(?:^|[\\/])(?:e2e|playwright|cypress)[\\/]|\.e2e\.[cm]?[jt]sx?$/i;
|
|
232
|
+
|
|
233
|
+
export function checkTestQuality(root, { files } = {}) {
|
|
234
|
+
const tests = (files ?? walk(root)).filter(isTestFile);
|
|
235
|
+
if (!tests.length) {
|
|
236
|
+
return bad("test-proof", "Tests prove something", "flagged", "No tests found: nothing here can be proven automatically yet");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const problems = [];
|
|
240
|
+
let checked = 0;
|
|
241
|
+
|
|
242
|
+
for (const file of tests) {
|
|
243
|
+
const text = readText(file);
|
|
244
|
+
if (!text) continue;
|
|
245
|
+
checked++;
|
|
246
|
+
|
|
247
|
+
const blocks =
|
|
248
|
+
(text.match(/\b(?:it|test|specify)\s*\(/g) || []).length +
|
|
249
|
+
(text.match(/\bdef test_\w+\s*\(/g) || []).length +
|
|
250
|
+
(text.match(/\bfunc Test\w+\s*\(\s*\w+\s+\*testing\.T\s*\)/g) || []).length;
|
|
251
|
+
if (!blocks) continue;
|
|
252
|
+
|
|
253
|
+
const assertions =
|
|
254
|
+
(text.match(/\bexpect\s*\(/g) || []).length +
|
|
255
|
+
(text.match(/\bassert(?:\.\w+)?\s*\(|\bassert\s+/g) || []).length +
|
|
256
|
+
(text.match(/\.assert\w+\s*\(/g) || []).length +
|
|
257
|
+
(text.match(/\bt\.(?:Error|Errorf|Fatal|Fatalf|Fail)\s*\(/g) || []).length +
|
|
258
|
+
(text.match(/\.should\b/g) || []).length;
|
|
259
|
+
|
|
260
|
+
const matchers = text.match(MATCHER) || [];
|
|
261
|
+
const screen = text.match(SCREEN_MATCHER) || [];
|
|
262
|
+
const skipped = (text.match(/\b(?:it|test|describe)\.(?:skip|todo)\s*\(/g) || []).length;
|
|
263
|
+
const where = rel(root, file);
|
|
264
|
+
|
|
265
|
+
if (!assertions) {
|
|
266
|
+
problems.push(`${where}: ${plural(blocks, "test")}, no assertion`);
|
|
267
|
+
} else if (screen.length && screen.length === matchers.length && !BROWSER_TEST.test(where)) {
|
|
268
|
+
problems.push(`${where}: ${plural(blocks, "test")}, only checks what is on screen`);
|
|
269
|
+
}
|
|
270
|
+
if (skipped) problems.push(`${where}: ${skipped} skipped`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!problems.length) {
|
|
274
|
+
return ok(
|
|
275
|
+
"test-proof",
|
|
276
|
+
"Tests prove something",
|
|
277
|
+
"flagged",
|
|
278
|
+
`${plural(checked, "test file")}; none empty or screen-only`,
|
|
279
|
+
["A static read of the test files. It cannot tell whether the assertions check the right thing."],
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const shown = problems.slice(0, 8);
|
|
284
|
+
if (problems.length > shown.length) shown.push(`...and ${problems.length - shown.length} more`);
|
|
285
|
+
return bad("test-proof", "Tests prove something", "flagged", `${plural(problems.length, "test file")} to look at`, shown);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const kb = (bytes) => (bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`);
|
|
289
|
+
|
|
290
|
+
export function checkWeight(root, { stack, config }) {
|
|
291
|
+
if (stack.kind === "mobile") return na("weight", "Download weight", "proven", "Phone app: size is checked in the store build");
|
|
292
|
+
if (stack.kind !== "web") return na("weight", "Download weight", "proven", "Not a website");
|
|
293
|
+
|
|
294
|
+
const buildDirs = stack.buildDirs?.length ? stack.buildDirs : buildOutputDirs(root);
|
|
295
|
+
if (!buildDirs.length) {
|
|
296
|
+
return notRun("weight", "Download weight", "proven", "No production build yet; factory verify --deep builds it");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const files = [];
|
|
300
|
+
for (const dirName of buildDirs) {
|
|
301
|
+
for (const file of walk(join(root, dirName))) {
|
|
302
|
+
if (!/\.(?:js|mjs|css|html)$/i.test(file)) continue;
|
|
303
|
+
let raw;
|
|
304
|
+
try {
|
|
305
|
+
raw = readFileSync(file);
|
|
306
|
+
} catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
files.push({ file: rel(root, file), gz: gzipSync(raw).length });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!files.length) return na("weight", "Download weight", "proven", "Build output has no JS, CSS or HTML");
|
|
314
|
+
|
|
315
|
+
files.sort((a, b) => b.gz - a.gz);
|
|
316
|
+
const total = files.reduce((sum, file) => sum + file.gz, 0);
|
|
317
|
+
const largest = files[0];
|
|
318
|
+
const budget = config.checks.budgetKb * 1024;
|
|
319
|
+
const maxFile = config.checks.maxFileKb * 1024;
|
|
320
|
+
const over = total > budget || largest.gz > maxFile;
|
|
321
|
+
|
|
322
|
+
const details = files.slice(0, 5).map((file) => `${file.file} ${kb(file.gz)} (gzip)`);
|
|
323
|
+
const summary = `${kb(total)} shipped across ${plural(files.length, "file")}; largest ${kb(largest.gz)}`;
|
|
324
|
+
|
|
325
|
+
if (over) {
|
|
326
|
+
const why = [];
|
|
327
|
+
if (total > budget) why.push(`total over ${config.checks.budgetKb} KB`);
|
|
328
|
+
if (largest.gz > maxFile) why.push(`one file over ${config.checks.maxFileKb} KB`);
|
|
329
|
+
return bad("weight", "Download weight", "proven", `${summary} (${why.join(", ")})`, details);
|
|
330
|
+
}
|
|
331
|
+
return ok("weight", "Download weight", "proven", summary, details);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Only for websites that claim offline support. Phone apps are checked on the device.
|
|
335
|
+
export function checkOffline(root, { kind, config, files } = {}) {
|
|
336
|
+
if (kind === "mobile") return na("offline", "No connection", "flagged", "Phone apps are checked on the device");
|
|
337
|
+
if (kind && kind !== "web") return na("offline", "No connection", "flagged", "Not a website");
|
|
338
|
+
|
|
339
|
+
const manifest =
|
|
340
|
+
existsSync(join(root, "public", "manifest.json")) || existsSync(join(root, "public", "manifest.webmanifest"));
|
|
341
|
+
if (!manifest && !config?.checks?.offline) {
|
|
342
|
+
return na("offline", "No connection", "flagged", "Offline support not claimed; set checks.offline to true to check it");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const all = files ?? walk(root);
|
|
346
|
+
const workers = all.filter((file) => /(?:^|[/\\])(?:sw|service-worker|serviceworker)\.(?:js|ts|mjs)$/i.test(file));
|
|
347
|
+
let registered = false;
|
|
348
|
+
if (!workers.length) {
|
|
349
|
+
for (const file of all) {
|
|
350
|
+
const text = readText(file);
|
|
351
|
+
if (text && /navigator\.serviceWorker\.register|serviceWorker\.register\s*\(/.test(text)) {
|
|
352
|
+
registered = true;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!workers.length && !registered) {
|
|
359
|
+
return bad(
|
|
360
|
+
"offline",
|
|
361
|
+
"No connection",
|
|
362
|
+
"flagged",
|
|
363
|
+
"The site has a web app manifest but no service worker; if the connection drops, it may not open",
|
|
364
|
+
["This is a prompt to look, not proof."],
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return ok("offline", "No connection", "flagged", "Offline support appears to exist; a browser run is still needed", [
|
|
369
|
+
...workers.map((file) => rel(root, file)),
|
|
370
|
+
...(registered ? ["service worker registration found in code"] : []),
|
|
371
|
+
"Offline behaviour is not proven without running it in a browser.",
|
|
372
|
+
]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const PERSONAL_RULES = [
|
|
376
|
+
{ id: "form", re: /<input[^>]+type=["'](?:email|password|tel)["']/i, why: "forms collect contact or login details" },
|
|
377
|
+
{
|
|
378
|
+
id: "identity",
|
|
379
|
+
re: /\b(?:id[_-]?number|national[_-]?id|passport(?:[_-]?number|Number)|date[_-]?of[_-]?birth)\b/i,
|
|
380
|
+
why: "identity fields",
|
|
381
|
+
},
|
|
382
|
+
{ id: "analytics", re: /\b(?:gtag|googletagmanager|mixpanel|amplitude|posthog|hotjar|fbq)\s*\(/, why: "analytics calls" },
|
|
383
|
+
{
|
|
384
|
+
id: "logs",
|
|
385
|
+
re: /console\.(?:log|info|debug)\([^)]*\b(?:email|password|phone|token|user)\b/i,
|
|
386
|
+
why: "personal data may be written to logs",
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
id: "device",
|
|
390
|
+
re: /localStorage\.setItem\([^)]*\b(?:user|email|token|profile)\b/i,
|
|
391
|
+
why: "personal data stored in the browser",
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
export function checkPersonalData(root, { files } = {}) {
|
|
396
|
+
const hits = new Map(PERSONAL_RULES.map((rule) => [rule.id, { rule, files: [], example: null }]));
|
|
397
|
+
let privacy = false;
|
|
398
|
+
let consent = false;
|
|
399
|
+
let scanned = 0;
|
|
400
|
+
|
|
401
|
+
for (const file of files ?? walk(root)) {
|
|
402
|
+
if (file.endsWith(".md")) continue;
|
|
403
|
+
const text = readText(file);
|
|
404
|
+
if (text == null) continue;
|
|
405
|
+
scanned++;
|
|
406
|
+
|
|
407
|
+
if (/privacy\s*(?:policy|notice)?|popia|paia/i.test(text)) privacy = true;
|
|
408
|
+
if (/\bconsent\b|i agree|accept (?:the )?terms|opt[_-]?in/i.test(text)) consent = true;
|
|
409
|
+
|
|
410
|
+
const lines = text.split("\n");
|
|
411
|
+
for (const rule of PERSONAL_RULES) {
|
|
412
|
+
const index = lines.findIndex((line) => rule.re.test(line));
|
|
413
|
+
if (index === -1) continue;
|
|
414
|
+
const entry = hits.get(rule.id);
|
|
415
|
+
entry.files.push(`${rel(root, file)}:${index + 1}`);
|
|
416
|
+
if (!entry.example && rule.id === "identity") entry.example = lines[index].match(rule.re)[0];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const found = [...hits.values()].filter((entry) => entry.files.length);
|
|
421
|
+
if (!found.length) {
|
|
422
|
+
return ok("personal", "Personal data", "flagged", `No personal-data signals found in ${plural(scanned, "file")}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const clauses = found
|
|
426
|
+
.slice(0, 3)
|
|
427
|
+
.map((entry) => `${entry.rule.why}${entry.example ? ` (${entry.example})` : ""} in ${plural(entry.files.length, "file")}`);
|
|
428
|
+
if (!privacy) clauses.push("no privacy notice found");
|
|
429
|
+
|
|
430
|
+
const details = [];
|
|
431
|
+
for (const entry of found) {
|
|
432
|
+
details.push(`${entry.rule.why}: ${entry.files.slice(0, 3).join(", ")}${entry.files.length > 3 ? `, and ${entry.files.length - 3} more` : ""}`);
|
|
433
|
+
}
|
|
434
|
+
if (!consent) details.push("No consent text found in the project files");
|
|
435
|
+
details.push("A prompt to look, not proof of compliance.");
|
|
436
|
+
|
|
437
|
+
return bad("personal", "Personal data", "flagged", clauses.join("; "), details);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function runChecks(root, { deep = false, config, stack, logDir = null } = {}) {
|
|
441
|
+
const s = stack ?? detectStack(root);
|
|
442
|
+
const cfg = config ?? readConfig(root);
|
|
443
|
+
const files = walk(root);
|
|
444
|
+
const ctx = { deep, stack: s, config: cfg, logDir, files };
|
|
445
|
+
return [
|
|
446
|
+
...checkSecrets(root, ctx),
|
|
447
|
+
checkTestQuality(root, ctx),
|
|
448
|
+
checkTests(root, ctx),
|
|
449
|
+
checkBuild(root, ctx),
|
|
450
|
+
checkWeight(root, ctx),
|
|
451
|
+
checkPersonalData(root, ctx),
|
|
452
|
+
checkOffline(root, { kind: s.kind, config: cfg, files }),
|
|
453
|
+
];
|
|
454
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import {
|
|
3
|
+
approveCommand,
|
|
4
|
+
build,
|
|
5
|
+
reviewCommand,
|
|
6
|
+
doctor,
|
|
7
|
+
init,
|
|
8
|
+
lessonNew,
|
|
9
|
+
planCommand,
|
|
10
|
+
report,
|
|
11
|
+
specCommand,
|
|
12
|
+
start,
|
|
13
|
+
status,
|
|
14
|
+
tasksCommand,
|
|
15
|
+
verifyCommand,
|
|
16
|
+
} from "./commands.js";
|
|
17
|
+
|
|
18
|
+
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
19
|
+
|
|
20
|
+
const COMMANDS = new Set([
|
|
21
|
+
"init", "spec", "approve", "plan", "tasks", "verify", "build", "review", "report",
|
|
22
|
+
"lesson", "status", "doctor", "help", "version",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const VALUE_FLAGS = new Set([
|
|
26
|
+
"attempts", "out", "name", "kind", "who", "never-break", "data", "agent",
|
|
27
|
+
"title", "what", "spot", "check", "example", "sa", "south-africa", "by", "base",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function parse(argv) {
|
|
31
|
+
const flags = {};
|
|
32
|
+
const positional = [];
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const token = argv[i];
|
|
36
|
+
if (token === "-h") {
|
|
37
|
+
flags.help = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (token.startsWith("--")) {
|
|
41
|
+
const body = token.slice(2);
|
|
42
|
+
const eq = body.indexOf("=");
|
|
43
|
+
if (eq !== -1) {
|
|
44
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (VALUE_FLAGS.has(body) && argv[i + 1] && !argv[i + 1].startsWith("-")) {
|
|
48
|
+
flags[body] = argv[++i];
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
flags[body] = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
positional.push(token);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { flags, positional };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function help() {
|
|
61
|
+
console.log(`factory: a team of AI engineers that cannot say done without proof.
|
|
62
|
+
|
|
63
|
+
Start here
|
|
64
|
+
factory what this project can prove, in about a second;
|
|
65
|
+
then it offers to fix the top problem it found
|
|
66
|
+
factory "what to change" writes the spec from your code, you approve it,
|
|
67
|
+
then it plans, builds and checks
|
|
68
|
+
|
|
69
|
+
Steps, if you want them one at a time
|
|
70
|
+
factory spec "what to change" write the spec
|
|
71
|
+
factory approve [name] read what matters in the spec, then approve it
|
|
72
|
+
factory build plan, tasks, build and check the approved spec;
|
|
73
|
+
a separate session then reviews the change
|
|
74
|
+
factory review [--base=ref] the independent review on its own
|
|
75
|
+
factory verify [--deep] the checks on their own (--deep runs tests and build)
|
|
76
|
+
factory verify --app use the app against the approved spec, with screenshots
|
|
77
|
+
factory report one-page handover
|
|
78
|
+
factory status the active spec and the last checks
|
|
79
|
+
factory doctor what was detected, which agent, which checks apply
|
|
80
|
+
factory lesson new turn a real failure into a dated lesson
|
|
81
|
+
factory init answer five questions to fill factory.config.json
|
|
82
|
+
|
|
83
|
+
Options
|
|
84
|
+
--ask ask all seven questions before writing the spec
|
|
85
|
+
--deep run the project's tests and build
|
|
86
|
+
--json machine-readable output (verify)
|
|
87
|
+
--print print the prompt instead of running an agent; writes nothing
|
|
88
|
+
--by=name who approved it (default: your saved name or git name)
|
|
89
|
+
--yes approve without the terminal prompt (approve)
|
|
90
|
+
--force rewrite an existing plan or task list
|
|
91
|
+
--hotfix build without an approved spec; recorded in the evidence
|
|
92
|
+
--attempts=N how many times to send failures back (default 2)
|
|
93
|
+
|
|
94
|
+
Nothing is written into your project until you start a job. Nothing is called
|
|
95
|
+
done until the checks pass, and it never says "bug-free" or "compliant".`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function main(argv) {
|
|
99
|
+
const { flags, positional } = parse(argv);
|
|
100
|
+
const root = process.cwd();
|
|
101
|
+
const [command, ...rest] = positional;
|
|
102
|
+
|
|
103
|
+
if (flags.version || command === "version") {
|
|
104
|
+
console.log(`factory ${VERSION}`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (flags.help || command === "help") {
|
|
109
|
+
help();
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!command) return start(root, flags);
|
|
114
|
+
|
|
115
|
+
if (!COMMANDS.has(command)) {
|
|
116
|
+
const asJob = rest.length > 0 || /\s/.test(command);
|
|
117
|
+
if (asJob) return start(root, { ...flags, idea: positional.join(" ") });
|
|
118
|
+
console.error(`Unknown command: ${command}`);
|
|
119
|
+
console.error("Run: factory --help");
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
switch (command) {
|
|
124
|
+
case "init":
|
|
125
|
+
return init(root, flags);
|
|
126
|
+
case "spec":
|
|
127
|
+
return specCommand(root, flags, rest);
|
|
128
|
+
case "review":
|
|
129
|
+
return reviewCommand(root, flags);
|
|
130
|
+
case "approve":
|
|
131
|
+
return approveCommand(root, flags, rest);
|
|
132
|
+
case "plan":
|
|
133
|
+
return planCommand(root, flags);
|
|
134
|
+
case "tasks":
|
|
135
|
+
return tasksCommand(root, flags);
|
|
136
|
+
case "verify":
|
|
137
|
+
return verifyCommand(root, flags);
|
|
138
|
+
case "build":
|
|
139
|
+
return build(root, { ...flags, idea: rest.join(" ") });
|
|
140
|
+
case "report":
|
|
141
|
+
return report(root, flags);
|
|
142
|
+
case "status":
|
|
143
|
+
return status(root);
|
|
144
|
+
case "doctor":
|
|
145
|
+
return doctor(root);
|
|
146
|
+
case "lesson":
|
|
147
|
+
if (rest[0] !== "new") {
|
|
148
|
+
console.error("Use: factory lesson new");
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
return lessonNew(root, flags);
|
|
152
|
+
default:
|
|
153
|
+
console.error(`Unknown command: ${command}`);
|
|
154
|
+
console.error("Run: factory --help");
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
}
|