signoff-cli 0.0.0-stage → 4.0.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.
Files changed (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +336 -0
  3. package/LICENSE +21 -0
  4. package/README.md +540 -3
  5. package/action.yml +24 -0
  6. package/bin/signoff.js +218 -0
  7. package/docs/diagrams/README.md +41 -0
  8. package/docs/diagrams/signoff-1-capability-en.png +0 -0
  9. package/docs/diagrams/signoff-1-capability.png +0 -0
  10. package/docs/diagrams/signoff-2-market-en.png +0 -0
  11. package/docs/diagrams/signoff-2-market.png +0 -0
  12. package/docs/diagrams/signoff-3-user-en.png +0 -0
  13. package/docs/diagrams/signoff-3-user.png +0 -0
  14. package/docs/diagrams/signoff-4-flow-en.png +0 -0
  15. package/docs/diagrams/signoff-4-flow.png +0 -0
  16. package/docs/diagrams/signoff-5-competitors-en.png +0 -0
  17. package/docs/diagrams/signoff-5-competitors.png +0 -0
  18. package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
  19. package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
  20. package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
  21. package/docs/diagrams/src/signoff-2-market.mmd +72 -0
  22. package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
  23. package/docs/diagrams/src/signoff-3-user.mmd +92 -0
  24. package/docs/gates.en.md +105 -0
  25. package/docs/gates.md +88 -0
  26. package/docs/integrations.md +58 -0
  27. package/docs/promotion.md +71 -0
  28. package/docs/signoff.schema.json +108 -0
  29. package/docs/trust.md +72 -0
  30. package/gates/guard.py +474 -0
  31. package/gates/p1_verify.py +111 -0
  32. package/gates/p2_coverage.py +127 -0
  33. package/gates/p3_queue.py +186 -0
  34. package/gates/p4_ledger.py +249 -0
  35. package/gates/p5_slop.py +109 -0
  36. package/lib/audit.js +213 -0
  37. package/lib/bootstrap.js +361 -0
  38. package/lib/checkpoint.js +184 -0
  39. package/lib/ci.js +58 -0
  40. package/lib/core.js +120 -0
  41. package/lib/cov.js +253 -0
  42. package/lib/detect.js +169 -0
  43. package/lib/doctor.js +304 -0
  44. package/lib/editor.js +97 -0
  45. package/lib/explain.js +73 -0
  46. package/lib/guard.js +1229 -0
  47. package/lib/p1.js +469 -0
  48. package/lib/p2.js +323 -0
  49. package/lib/p3.js +633 -0
  50. package/lib/p4.js +733 -0
  51. package/lib/p5.js +438 -0
  52. package/lib/preset.js +66 -0
  53. package/lib/secrets.js +336 -0
  54. package/lib/sign.js +196 -0
  55. package/package.json +55 -6
  56. package/test/selftest.js +1226 -0
  57. package/test/selftest.py +190 -0
package/lib/p5.js ADDED
@@ -0,0 +1,438 @@
1
+ "use strict";
2
+ // SignOff gate 5 - empty words are a failure. Node port of gates/p5_slop.py.
3
+ //
4
+ // Six rules over markdown. Code fences and table rows are skipped so a legitimate
5
+ // example cannot be reported as slop; the point is documents that claim progress
6
+ // without a number behind it.
7
+ //
8
+ // Parity notes (the Python reference is the contract):
9
+ // * ROOT is the current working directory, not the package root.
10
+ // * Python's str.strip()/splitlines()/`\b`/`\d` are Unicode-aware; the helpers
11
+ // below reproduce those semantics because the word lists are Chinese.
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const core = require("./core");
16
+
17
+ const WIN = process.platform === "win32";
18
+ // Python writes through text-mode streams, which turn every "\n" into os.linesep.
19
+ // Matching that keeps stdout and .signoff/p5.json byte-identical on each platform.
20
+ const NL = WIN ? "\r\n" : "\n";
21
+
22
+ // A pack is one language's vocabulary. `word` marks packs that need word
23
+ // boundaries: Chinese has no spaces so substring matching is the only sane mode,
24
+ // while "very" as a substring would hit "delivery".
25
+ const PACK_ZH = {
26
+ id: "zh",
27
+ word: false,
28
+ aiWords: [
29
+ "赋能", "闭环", "抓手", "颗粒度", "底层逻辑", "组合拳", "全链路", "护城河",
30
+ "打通", "沉淀", "体系化", "复用", "生态位", "范式", "维度上", "协同增效",
31
+ ],
32
+ absolute: ["100%", "零缺陷", "永不", "绝对", "完美", "彻底", "极致", "无缝", "彻底解决"],
33
+ adverbs: ["极其", "非常", "十分", "相当", "大幅"],
34
+ claims: ["(已验证|已覆盖|全部通过|全绿|都验证过)"],
35
+ placeholder: ["(?<![\\p{L}\\p{N}_])(?:TODO|TBD|FIXME)(?![\\p{L}\\p{N}_])|待补|待定"],
36
+ };
37
+
38
+ const PACK_EN = {
39
+ id: "en",
40
+ word: true,
41
+ aiWords: [
42
+ "seamless", "seamlessly", "robust", "leverage", "empower", "holistic", "synergy",
43
+ "cutting-edge", "state-of-the-art", "best-in-class", "world-class", "game-changer",
44
+ "revolutionary", "effortless", "turnkey", "next-generation", "mission-critical",
45
+ "future-proof", "unparalleled", "comprehensive solution",
46
+ ],
47
+ absolute: ["100%", "zero defects", "never fails", "always works", "flawless", "bulletproof", "guaranteed", "no bugs", "perfect"],
48
+ adverbs: ["very", "extremely", "incredibly", "significantly", "substantially", "vastly", "highly"],
49
+ claims: ["\\b(?:verified|fully tested|all green|all passing|fully validated|production-ready)\\b"],
50
+ placeholder: ["\\b(?:TODO|TBD|FIXME|XXX)\\b|lorem ipsum"],
51
+ };
52
+
53
+ const PACKS = { zh: PACK_ZH, en: PACK_EN };
54
+
55
+ // Backward-compatible aliases: the zh lists are the historical default and
56
+ // `scan(text)` with no options still reports exactly what it used to.
57
+ const AI_WORDS = PACK_ZH.aiWords;
58
+ const ABSOLUTE = PACK_ZH.absolute;
59
+ const ADVERBS = PACK_ZH.adverbs;
60
+ const CLAIMS = new RegExp(PACK_ZH.claims[0]);
61
+ // `\\b(TODO|TBD|FIXME)\\b|待补|待定`, but with Python's Unicode word characters:
62
+ // a CJK ideograph counts as a word char, so "中TODO" is not a placeholder in Python.
63
+ const WORD = "[\\p{L}\\p{N}_]";
64
+ const PLACEHOLDER = new RegExp(PACK_ZH.placeholder[0], "u");
65
+ // Python's re `\d` on str matches every Unicode decimal digit (category Nd).
66
+ const DIGIT = /\p{Nd}/u;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Python str semantics
70
+
71
+ const PY_SPACE = /[\t\n\v\f\r \x1c-\x1f\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/;
72
+
73
+ function isPySpace(ch) {
74
+ // U+FEFF is whitespace for JS trim() but not for Python str.strip().
75
+ return ch !== "\uFEFF" && PY_SPACE.test(ch);
76
+ }
77
+
78
+ function pyStrip(s) {
79
+ let a = 0;
80
+ let b = s.length;
81
+ while (a < b && isPySpace(s[a])) a++;
82
+ while (b > a && isPySpace(s[b - 1])) b--;
83
+ return a === 0 && b === s.length ? s : s.slice(a, b);
84
+ }
85
+
86
+ const LINE_BREAK = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
87
+
88
+ function pySplitLines(text) {
89
+ const out = [];
90
+ let start = 0;
91
+ LINE_BREAK.lastIndex = 0;
92
+ let m;
93
+ while ((m = LINE_BREAK.exec(text)) !== null) {
94
+ out.push(text.slice(start, m.index));
95
+ start = m.index + m[0].length;
96
+ }
97
+ if (start < text.length) out.push(text.slice(start));
98
+ return out;
99
+ }
100
+
101
+ // str.count(): non-overlapping occurrences.
102
+ function countOf(hay, needle) {
103
+ let n = 0;
104
+ let i = hay.indexOf(needle);
105
+ while (i !== -1) {
106
+ n++;
107
+ i = hay.indexOf(needle, i + needle.length);
108
+ }
109
+ return n;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // rules
114
+
115
+ // opts: {packs: ["zh"]|["zh","en"], rules: {ai_words, absolute, adverbs, claims, placeholder}}
116
+ // The default is the historical zh behaviour, byte for byte.
117
+ function active(opts) {
118
+ const o = opts || {};
119
+ const ids = Array.isArray(o.packs) && o.packs.length ? o.packs : ["zh"];
120
+ const act = { aiWords: [], absolute: [], adverbs: [], claims: [], placeholder: [] };
121
+ const add = (bucket, value, word) => act[bucket].push({ v: String(value), w: !!word });
122
+ for (const id of ids) {
123
+ const p = PACKS[id];
124
+ if (!p) continue;
125
+ for (const w of p.aiWords) add("aiWords", w, p.word);
126
+ for (const w of p.absolute) add("absolute", w, p.word);
127
+ for (const w of p.adverbs) add("adverbs", w, p.word);
128
+ for (const c of p.claims) act.claims.push(new RegExp(c, p.word ? "iu" : "u"));
129
+ for (const c of p.placeholder) act.placeholder.push(new RegExp(c, p.word ? "iu" : "u"));
130
+ }
131
+ const r = o.rules || {};
132
+ for (const w of [].concat(r.ai_words || [])) add("aiWords", w, r.word !== false);
133
+ for (const w of [].concat(r.absolute || [])) add("absolute", w, r.word !== false);
134
+ for (const w of [].concat(r.adverbs || [])) add("adverbs", w, r.word !== false);
135
+ for (const c of [].concat(r.claims || [])) act.claims.push(new RegExp(c, "iu"));
136
+ for (const c of [].concat(r.placeholder || [])) act.placeholder.push(new RegExp(c, "iu"));
137
+ return act;
138
+ }
139
+
140
+ function escRe(s) {
141
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
142
+ }
143
+
144
+ // Word mode uses Unicode letter/number boundaries, so "very" does not fire on
145
+ // "delivery" and "perfect" does not fire on "perfectly"... the latter is still
146
+ // slop, which is why the en pack lists both forms when it wants them.
147
+ function wordRe(w) {
148
+ return new RegExp(`(?<![\\p{L}\\p{N}_])${escRe(w)}(?![\\p{L}\\p{N}_])`, "iu");
149
+ }
150
+
151
+ function hitOf(line, item) {
152
+ return item.w ? wordRe(item.v).test(line) : line.indexOf(item.v) !== -1;
153
+ }
154
+
155
+ function countOfItem(line, item) {
156
+ if (!item.w) return countOf(line, item.v);
157
+ const g = new RegExp(wordRe(item.v).source, "giu");
158
+ const m = line.match(g);
159
+ return m ? m.length : 0;
160
+ }
161
+
162
+ function scan(text, opts) {
163
+ const act = active(opts);
164
+ const hits = [];
165
+ const seen = new Set();
166
+ const push = (rule, n, word) => {
167
+ const key = `${rule}\u0000${n}\u0000${word}`;
168
+ if (seen.has(key)) return; // both packs may name the same token
169
+ seen.add(key);
170
+ hits.push([rule, n, word]);
171
+ };
172
+ let inFence = false;
173
+ const lines = pySplitLines(text);
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const n = i + 1;
176
+ let line = pyStrip(lines[i]);
177
+ if (line.startsWith("```")) {
178
+ inFence = !inFence;
179
+ continue;
180
+ }
181
+ if (inFence || line.startsWith("|") || !line) continue;
182
+ // A document may legitimately quote a banned word: `赋能` in backticks is
183
+ // an example, not a claim.
184
+ line = pyStrip(line.replace(/`[^`]*`/g, ""));
185
+ if (!line) continue;
186
+ if (act.placeholder.some((re) => re.test(line))) push("placeholder", n, "unfinished marker");
187
+ for (const it of act.aiWords) if (hitOf(line, it)) push("ai-word", n, it.v);
188
+ for (const it of act.absolute) if (hitOf(line, it)) push("absolute-claim", n, it.v);
189
+ if (act.claims.some((re) => re.test(line)) && !DIGIT.test(line)) {
190
+ push("claim-without-number", n, "verification claim with no figure");
191
+ }
192
+ let adverbs = 0;
193
+ for (const it of act.adverbs) adverbs += countOfItem(line, it);
194
+ if (adverbs >= 2) push("empty-adverbs", n, `${adverbs} intensifiers in one line`);
195
+ }
196
+ return hits;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // path discovery (mirrors sorted(Path.rglob("*.md")))
201
+
202
+ function statOrNull(p) {
203
+ try {
204
+ return fs.lstatSync(p);
205
+ } catch (e) {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ function isDir(p) {
211
+ try {
212
+ return fs.statSync(p).isDirectory();
213
+ } catch (e) {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function exists(p) {
219
+ try {
220
+ fs.statSync(p);
221
+ return true;
222
+ } catch (e) {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ function isMd(name) {
228
+ // pathlib glob is case-insensitive on Windows.
229
+ const low = WIN ? name.toLowerCase() : name;
230
+ return low.endsWith(".md");
231
+ }
232
+
233
+ function rglobMd(dir) {
234
+ const out = [];
235
+ let entries;
236
+ try {
237
+ entries = fs.readdirSync(dir, { withFileTypes: true });
238
+ } catch (e) {
239
+ return out;
240
+ }
241
+ for (const ent of entries) {
242
+ const p = path.join(dir, ent.name);
243
+ let dirLike = ent.isDirectory();
244
+ let fileLike = ent.isFile();
245
+ if (!dirLike && !fileLike) {
246
+ // A symlink: Path.rglob does not descend into symlinked directories, but
247
+ // does yield symlinked files.
248
+ const st = statOrNull(p);
249
+ if (st) fileLike = st.isFile();
250
+ }
251
+ if (dirLike) out.push(...rglobMd(p));
252
+ else if (fileLike && isMd(ent.name)) out.push(p);
253
+ }
254
+ return out;
255
+ }
256
+
257
+ // Python compares PurePath parts component-wise with normcase (lowercase here).
258
+ function pathCmp(a, b) {
259
+ const pa = a.split(/[\\/]+/).filter(Boolean);
260
+ const pb = b.split(/[\\/]+/).filter(Boolean);
261
+ const n = Math.min(pa.length, pb.length);
262
+ for (let i = 0; i < n; i++) {
263
+ const ka = WIN ? pa[i].toLowerCase() : pa[i];
264
+ const kb = WIN ? pb[i].toLowerCase() : pb[i];
265
+ if (ka !== kb) return ka < kb ? -1 : 1;
266
+ }
267
+ return pa.length - pb.length;
268
+ }
269
+
270
+ function targets(args) {
271
+ if (args.length) {
272
+ const out = [];
273
+ for (const a of args) {
274
+ const p = a;
275
+ if (isDir(p)) out.push(...rglobMd(p).sort(pathCmp));
276
+ else if (exists(p)) out.push(p);
277
+ }
278
+ return out;
279
+ }
280
+ const root = process.cwd();
281
+ const found = [path.join(root, "README.md"), path.join(root, "CHANGELOG.md")];
282
+ const docs = path.join(root, "docs");
283
+ if (isDir(docs)) found.push(...rglobMd(docs).sort(pathCmp));
284
+ return found.filter(exists);
285
+ }
286
+
287
+ // str(f.relative_to(ROOT)) if f.is_relative_to(ROOT) else str(f)
288
+ function displayPath(p) {
289
+ const root = path.resolve(process.cwd());
290
+ const abs = path.resolve(p);
291
+ const same = (s) => (WIN ? s.toLowerCase() : s);
292
+ const a = same(abs);
293
+ const r = same(root);
294
+ if (a === r) return ".";
295
+ if (a.startsWith(r.endsWith(path.sep) ? r : r + path.sep)) {
296
+ const rel = path.relative(root, abs);
297
+ return rel || ".";
298
+ }
299
+ // Given as a relative path: Python keeps it relative, with native separators.
300
+ return path.normalize(p);
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // CLI
305
+
306
+ const USAGE = "usage: signoff p5 [-h] [--json] [--allow ALLOW] [--pack zh|en|both] [--rules FILE] [paths ...]\n";
307
+
308
+ const HELP =
309
+ "signoff p5 - empty words are a failure\n\n" +
310
+ "positional arguments:\n" +
311
+ " paths files or directories (default: README.md CHANGELOG.md docs/)\n\n" +
312
+ "options:\n" +
313
+ " -h, --help show this help message and exit\n" +
314
+ " --json print one JSON summary line\n" +
315
+ " --allow rule rule id to ignore (repeatable)\n" +
316
+ " --pack P zh (default), en, or both\n" +
317
+ " --rules FILE extra rule pack (json: ai_words, absolute, adverbs, claims, placeholder)\n";
318
+
319
+ function run(argv) {
320
+ const args = argv || [];
321
+ const paths = [];
322
+ const allow = [];
323
+ let asJson = false;
324
+ let pack = null;
325
+ let rulesPath = null;
326
+ for (let i = 0; i < args.length; i++) {
327
+ const a = args[i];
328
+ if (a === "-h" || a === "--help") {
329
+ process.stdout.write(HELP);
330
+ return 0;
331
+ }
332
+ if (a === "--json") {
333
+ asJson = true;
334
+ continue;
335
+ }
336
+ if (a === "--allow") {
337
+ if (i + 1 >= args.length || args[i + 1].startsWith("--")) {
338
+ process.stderr.write(USAGE);
339
+ process.stderr.write("signoff p5: error: argument --allow: expected one argument\n");
340
+ return 2;
341
+ }
342
+ allow.push(args[++i]);
343
+ continue;
344
+ }
345
+ if (a.startsWith("--allow=")) {
346
+ allow.push(a.slice("--allow=".length));
347
+ continue;
348
+ }
349
+ if (a === "--pack" || a.startsWith("--pack=")) {
350
+ const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : args[++i];
351
+ if (!v) {
352
+ process.stderr.write(USAGE);
353
+ process.stderr.write("signoff p5: error: argument --pack: expected one argument\n");
354
+ return 2;
355
+ }
356
+ pack = v;
357
+ continue;
358
+ }
359
+ if (a === "--rules" || a.startsWith("--rules=")) {
360
+ const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : args[++i];
361
+ if (!v) {
362
+ process.stderr.write(USAGE);
363
+ process.stderr.write("signoff p5: error: argument --rules: expected one argument\n");
364
+ return 2;
365
+ }
366
+ rulesPath = v;
367
+ continue;
368
+ }
369
+ if (a.startsWith("-") && a.length > 1) {
370
+ process.stderr.write(USAGE);
371
+ process.stderr.write(`signoff p5: error: unrecognized arguments: ${a}\n`);
372
+ return 2;
373
+ }
374
+ paths.push(a);
375
+ }
376
+
377
+ // Which vocabulary applies: flag > .signoff/config.json > signoff.json > zh.
378
+ // "zh" stays the default so an existing repo's output does not change under it.
379
+ const cwd = process.cwd();
380
+ const cfg = core.readJSON(path.join(cwd, ".signoff", "config.json"), {}) || {};
381
+ const decl = core.readJSON(path.join(cwd, "signoff.json"), {}) || {};
382
+ const asObj = (v) => (typeof v === "string" ? { pack: v } : v && typeof v === "object" ? v : {});
383
+ const p5cfg = asObj(cfg.p5);
384
+ const declP5 = asObj(decl.p5);
385
+ const packName = String(pack || p5cfg.pack || declP5.pack || "zh");
386
+ const packs = packName === "both" ? ["zh", "en"] : [packName];
387
+ if (!packs.every((id) => PACKS[id])) {
388
+ process.stderr.write(USAGE);
389
+ process.stderr.write(`signoff p5: error: unknown pack '${packName}' (choose from zh, en, both)\n`);
390
+ return 2;
391
+ }
392
+ const allowAll = allow.concat(
393
+ [].concat(p5cfg.allow || []).map(String),
394
+ [].concat(declP5.allow || []).map(String)
395
+ );
396
+ const rulesFile = rulesPath || p5cfg.rules || declP5.rules || null;
397
+ let rules = null;
398
+ if (rulesFile) {
399
+ const rp = path.isAbsolute(String(rulesFile)) ? String(rulesFile) : path.join(cwd, String(rulesFile));
400
+ try {
401
+ rules = JSON.parse(fs.readFileSync(rp, "utf8"));
402
+ } catch (e) {
403
+ process.stderr.write(`signoff p5: cannot read rules '${rulesFile}' (${e && e.message})\n`);
404
+ return 2;
405
+ }
406
+ }
407
+
408
+ const files = targets(paths);
409
+ const report = [];
410
+ for (const f of files) {
411
+ // errors="replace" in the Python reference == Node's utf8 decoder fallback.
412
+ const text = fs.readFileSync(f).toString("utf8");
413
+ const hits = scan(text, { packs, rules }).filter((h) => !allowAll.includes(h[0]));
414
+ const rel = displayPath(f);
415
+ for (const [rule, line, word] of hits) report.push({ file: rel, line, rule, hit: word });
416
+ }
417
+
418
+ const ok = report.length === 0;
419
+ const payload = { gate: "p5", ok, files: files.length, hits: report };
420
+ // Same bytes as json.dumps(..., ensure_ascii=False, indent=2) + "\n" under Python's
421
+ // text mode, so `.signoff/p5.json` is byte-identical to the reference.
422
+ const stateFile = core.statePath(process.cwd(), "p5.json");
423
+ fs.writeFileSync(stateFile, (JSON.stringify(payload, null, 2) + "\n").replace(/\n/g, NL), "utf8");
424
+ if (asJson) {
425
+ // json.dumps(..., ensure_ascii=False) keeps ", " / ": " separators; the summary
426
+ // line must be byte-identical to the Python reference.
427
+ process.stdout.write(
428
+ `{"gate": "p5", "ok": ${ok}, "files": ${files.length}, "hits": ${report.length}}${NL}`
429
+ );
430
+ } else {
431
+ const lines = report.map((h) => `[p5] ${h.file}:${h.line} ${h.rule} ${h.hit}`);
432
+ lines.push(`[p5] ${ok ? "PASS" : "FAIL"} files=${files.length} hits=${report.length}`);
433
+ process.stdout.write(lines.join(NL) + NL);
434
+ }
435
+ return ok ? 0 : 1;
436
+ }
437
+
438
+ module.exports = { run, scan, targets };
package/lib/preset.js ADDED
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ // Scenario presets: one flag for the settings that are easy to get wrong.
3
+ //
4
+ // A preset never pins a runner that the machine may not have; it fills a config
5
+ // the repo can edit afterwards, and `signoff doctor` still explains what is missing.
6
+
7
+ const PRESETS = {
8
+ node: {
9
+ label: "Node / TypeScript",
10
+ budget: 180,
11
+ docs: ["README.md", "docs"],
12
+ coverage: { min: 80 },
13
+ p5: { pack: "both" },
14
+ hint: "test_cmd stays auto-detected so a clone without the script still runs something",
15
+ },
16
+ python: {
17
+ label: "Python",
18
+ budget: 240,
19
+ docs: ["README.md", "docs"],
20
+ coverage: { min: 80 },
21
+ p5: { pack: "both" },
22
+ hint: "python -m pytest -q (override with --lock-test if your suite lives elsewhere)",
23
+ },
24
+ go: {
25
+ label: "Go",
26
+ budget: 300,
27
+ docs: ["README.md", "docs"],
28
+ coverage: null,
29
+ p5: { pack: "en" },
30
+ hint: "go test ./... (Go writes coverage per package, so line coverage is opt-in via --coverage)",
31
+ },
32
+ java: {
33
+ label: "Java / Maven",
34
+ budget: 420,
35
+ docs: ["README.md", "docs"],
36
+ coverage: null,
37
+ p5: { pack: "en" },
38
+ hint: "mvn -q test (Gradle repos: --preset java then edit test_cmd)",
39
+ },
40
+ };
41
+
42
+ function names() {
43
+ return Object.keys(PRESETS);
44
+ }
45
+
46
+ // Mutates cfg; returns notes for the init summary. `--preset` never overwrites a
47
+ // value the user set on the same command line.
48
+ function apply(cfg, name, explicit) {
49
+ const key = String(name || "").toLowerCase();
50
+ const p = PRESETS[key];
51
+ if (!p) return { ok: false, error: `unknown preset '${name}' (choose from ${names().join(", ")})` };
52
+ const set = explicit || {};
53
+ if (!set.budget) cfg.budget = p.budget;
54
+ if (!set.docs) cfg.docs = p.docs.slice();
55
+ // A preset only proposes coverage; the guard runs it only when the field is set.
56
+ cfg.coverage = p.coverage ? Object.assign({}, p.coverage) : null;
57
+ cfg.p5 = Object.assign({}, p.p5);
58
+ const notes = [
59
+ ` preset ${key} (${p.label})`,
60
+ ` ${p.hint}`,
61
+ ` p5 pack=${cfg.p5.pack}, budget=${cfg.budget}s, coverage=${cfg.coverage ? cfg.coverage.min + "%" : "off"}`,
62
+ ];
63
+ return { ok: true, notes, preset: key };
64
+ }
65
+
66
+ module.exports = { PRESETS, apply, names };