infernoflow 0.27.0 → 0.28.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.
@@ -61,6 +61,7 @@ const COMMAND_DESCRIPTIONS = {
61
61
  thaw: "Reset a capability to experimental (liquid) — free to evolve",
62
62
  why: "Given a file or function name — show which capability it serves, scenarios, stability, and git history",
63
63
  impact: "Blast radius analysis — see every cap, scenario, and risk level affected before you change anything",
64
+ scaffold: "Generate a new capability — source skeleton, contract registration, and placeholder scenario in one command",
64
65
  };
65
66
 
66
67
  const COMMAND_HANDLERS = {
@@ -115,6 +116,7 @@ const COMMAND_HANDLERS = {
115
116
  thaw: async (args) => (await import("../lib/commands/stability.mjs")).thawCommand(args),
116
117
  why: async (args) => (await import("../lib/commands/why.mjs")).whyCommand(args),
117
118
  impact: async (args) => (await import("../lib/commands/impact.mjs")).impactCommand(args),
119
+ scaffold: async (args) => (await import("../lib/commands/scaffold.mjs")).scaffoldCommand(args),
118
120
  };
119
121
 
120
122
  function formatCommandsHelp() {
@@ -405,6 +407,14 @@ ${formatCommandsHelp()}
405
407
  --check Exit 1 if risk level is HIGH or CRITICAL (CI gate)
406
408
  --json Machine-readable output
407
409
 
410
+ ${bold("scaffold options:")}
411
+ infernoflow scaffold <cap-id> Generate a new capability skeleton
412
+ --dir <path> Output directory for the source file (default: auto-detected)
413
+ --lang ts|js|py|go Language override (default: auto-detected from project)
414
+ --description "..." Capability description to embed in the file
415
+ --dry-run Preview what would be generated without writing files
416
+ --json Machine-readable output including generated code
417
+
408
418
  ${bold("Machine output:")}
409
419
  ${gray("status --json")}
410
420
  ${gray("check --json")}
@@ -0,0 +1,489 @@
1
+ /**
2
+ * infernoflow scaffold
3
+ *
4
+ * Generate a new capability — source file skeleton + contract registration
5
+ * + placeholder scenario — pre-wired to the project's detected patterns.
6
+ *
7
+ * Usage:
8
+ * infernoflow scaffold payment-refund
9
+ * infernoflow scaffold payment-refund --dir src/payments
10
+ * infernoflow scaffold payment-refund --lang ts --dry-run
11
+ * infernoflow scaffold payment-refund --json
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
17
+
18
+ // ── helpers ───────────────────────────────────────────────────────────────────
19
+
20
+ function loadJson(p) {
21
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); }
22
+ catch { return null; }
23
+ }
24
+
25
+ function saveJson(p, data) {
26
+ fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
27
+ }
28
+
29
+ // ── name derivers ─────────────────────────────────────────────────────────────
30
+
31
+ /** payment-refund → PaymentRefund */
32
+ function toPascalCase(id) {
33
+ return id.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("");
34
+ }
35
+
36
+ /** payment-refund → paymentRefund */
37
+ function toCamelCase(id) {
38
+ const p = toPascalCase(id);
39
+ return p.charAt(0).toLowerCase() + p.slice(1);
40
+ }
41
+
42
+ /** payment-refund → "Payment Refund" */
43
+ function toTitle(id) {
44
+ return id.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
45
+ }
46
+
47
+ /**
48
+ * Derive a primary function name from the cap ID.
49
+ * payment-refund → refundPayment, user-auth → authenticateUser
50
+ */
51
+ function primaryFnName(id) {
52
+ const parts = id.split(/[-_]/);
53
+ if (parts.length === 1) return toCamelCase(id);
54
+
55
+ // Common verb patterns: if second word is a verb-like term, flip order
56
+ const VERBS = ["auth", "login", "logout", "register", "refresh", "validate",
57
+ "verify", "process", "refund", "charge", "send", "fetch",
58
+ "create", "update", "delete", "get", "list", "search",
59
+ "sync", "import", "export", "scan", "check", "notify"];
60
+
61
+ const last = parts[parts.length - 1];
62
+ const first = parts[0];
63
+
64
+ // If last part looks like a noun and first part looks verb-like, use as-is
65
+ if (VERBS.includes(first)) {
66
+ // auth-user → authenticateUser style (expand verb)
67
+ const verbExpand = { auth: "authenticate", get: "get", list: "list",
68
+ send: "send", check: "check", notify: "notify" };
69
+ const verb = verbExpand[first] || first;
70
+ const rest = parts.slice(1).map((w, i) =>
71
+ i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w
72
+ ).join("");
73
+ return verb + rest.charAt(0).toUpperCase() + rest.slice(1);
74
+ }
75
+
76
+ // Default: flip last+first — payment-refund → refundPayment
77
+ if (VERBS.includes(last)) {
78
+ const noun = parts.slice(0, -1).map((w, i) =>
79
+ i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w
80
+ ).join("");
81
+ return last + noun;
82
+ }
83
+
84
+ return toCamelCase(id);
85
+ }
86
+
87
+ // ── language detector ─────────────────────────────────────────────────────────
88
+
89
+ function detectLang(scan, profile, cwd) {
90
+ // 1. From scan source files
91
+ if (scan?.capabilities?.length) {
92
+ const files = scan.capabilities.flatMap(c => c.codeAnalysis?.sourceFiles || []);
93
+ const exts = files.map(f => path.extname(f));
94
+ if (exts.filter(e => e === ".ts").length > exts.filter(e => e === ".js").length) return "ts";
95
+ if (exts.includes(".py")) return "py";
96
+ if (exts.includes(".go")) return "go";
97
+ if (exts.some(e => e === ".js" || e === ".mjs")) return "js";
98
+ }
99
+
100
+ // 2. From profile
101
+ const lang = profile?.language || profile?.lang;
102
+ if (lang) return lang.toLowerCase().replace("javascript", "js").replace("typescript", "ts");
103
+
104
+ // 3. From project files
105
+ if (fs.existsSync(path.join(cwd, "tsconfig.json"))) return "ts";
106
+ if (fs.existsSync(path.join(cwd, "pyproject.toml"))) return "py";
107
+ if (fs.existsSync(path.join(cwd, "go.mod"))) return "go";
108
+
109
+ return "js";
110
+ }
111
+
112
+ function detectSrcDir(scan, cwd) {
113
+ if (!scan?.capabilities?.length) return null;
114
+ const files = scan.capabilities.flatMap(c => c.codeAnalysis?.sourceFiles || []);
115
+ if (!files.length) return null;
116
+
117
+ // Count dir prefixes
118
+ const dirCount = {};
119
+ for (const f of files) {
120
+ const dir = path.dirname(f).split("/")[0];
121
+ dirCount[dir] = (dirCount[dir] || 0) + 1;
122
+ }
123
+ const top = Object.entries(dirCount).sort((a, b) => b[1] - a[1])[0];
124
+ return top ? top[0] : null;
125
+ }
126
+
127
+ function detectServices(scan) {
128
+ if (!scan?.capabilities?.length) return [];
129
+ const all = scan.capabilities.flatMap(c => c.codeAnalysis?.services || []);
130
+ return [...new Set(all)];
131
+ }
132
+
133
+ // ── code generators ───────────────────────────────────────────────────────────
134
+
135
+ function generateTs(id, name, description, fn, services) {
136
+ const pascal = toPascalCase(id);
137
+ const errorName = `${pascal}Error`;
138
+ const imports = buildImports("ts", services);
139
+
140
+ return `/**
141
+ * ${name}
142
+ *
143
+ * ${description}
144
+ *
145
+ * @capability ${id}
146
+ * @stability experimental
147
+ */
148
+ ${imports}
149
+
150
+ // ── errors ────────────────────────────────────────────────────────────────────
151
+
152
+ export class ${errorName} extends Error {
153
+ constructor(message: string, public readonly code?: string) {
154
+ super(message);
155
+ this.name = "${errorName}";
156
+ }
157
+ }
158
+
159
+ // ── types ─────────────────────────────────────────────────────────────────────
160
+
161
+ export interface ${pascal}Input {
162
+ // TODO: define input fields
163
+ }
164
+
165
+ export interface ${pascal}Result {
166
+ // TODO: define result fields
167
+ success: boolean;
168
+ }
169
+
170
+ // ── implementation ────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * ${fn} — primary entry point for ${name}.
174
+ * TODO: implement this function.
175
+ */
176
+ export async function ${fn}(input: ${pascal}Input): Promise<${pascal}Result> {
177
+ // TODO: implement
178
+ throw new ${errorName}("Not implemented yet");
179
+ }
180
+ `;
181
+ }
182
+
183
+ function generateJs(id, name, description, fn, services) {
184
+ const pascal = toPascalCase(id);
185
+ const errorName = `${pascal}Error`;
186
+ const imports = buildImports("js", services);
187
+
188
+ return `/**
189
+ * ${name}
190
+ *
191
+ * ${description}
192
+ *
193
+ * @capability ${id}
194
+ * @stability experimental
195
+ */
196
+ ${imports}
197
+
198
+ // ── errors ────────────────────────────────────────────────────────────────────
199
+
200
+ export class ${errorName} extends Error {
201
+ constructor(message, code) {
202
+ super(message);
203
+ this.name = "${errorName}";
204
+ this.code = code;
205
+ }
206
+ }
207
+
208
+ // ── implementation ────────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * ${fn} — primary entry point for ${name}.
212
+ * TODO: implement this function.
213
+ *
214
+ * @param {object} input
215
+ * @returns {Promise<object>}
216
+ */
217
+ export async function ${fn}(input = {}) {
218
+ // TODO: implement
219
+ throw new ${errorName}("Not implemented yet");
220
+ }
221
+ `;
222
+ }
223
+
224
+ function generatePy(id, name, description, fn) {
225
+ const cls = toPascalCase(id);
226
+
227
+ return `"""
228
+ ${name}
229
+
230
+ ${description}
231
+
232
+ capability: ${id}
233
+ stability: experimental
234
+ """
235
+
236
+ from typing import Any
237
+
238
+
239
+ class ${cls}Error(Exception):
240
+ """Raised when ${name} operations fail."""
241
+ def __init__(self, message: str, code: str | None = None):
242
+ super().__init__(message)
243
+ self.code = code
244
+
245
+
246
+ async def ${fn.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '')}(input: dict[str, Any]) -> dict[str, Any]:
247
+ """Primary entry point for ${name}.
248
+
249
+ TODO: implement this function.
250
+ """
251
+ raise ${cls}Error("Not implemented yet")
252
+ `;
253
+ }
254
+
255
+ function generateGo(id, name, description, fn) {
256
+ const pkg = id.split("-")[0];
257
+
258
+ return `// Package ${pkg} implements ${name}.
259
+ //
260
+ // ${description}
261
+ //
262
+ // capability: ${id}
263
+ // stability: experimental
264
+ package ${pkg}
265
+
266
+ import "errors"
267
+
268
+ // Err${toPascalCase(id)} is returned when ${name} operations fail.
269
+ var Err${toPascalCase(id)} = errors.New("${id}: operation failed")
270
+
271
+ // ${toPascalCase(fn)} is the primary entry point for ${name}.
272
+ // TODO: implement this function.
273
+ func ${toPascalCase(fn)}(input map[string]any) (map[string]any, error) {
274
+ \treturn nil, Err${toPascalCase(id)}
275
+ }
276
+ `;
277
+ }
278
+
279
+ function buildImports(lang, services) {
280
+ if (!services.length) return "";
281
+ const lines = [];
282
+ if (lang === "ts" || lang === "js") {
283
+ // Suggest common client imports for detected services
284
+ const serviceImports = {
285
+ stripe: `// import Stripe from 'stripe';`,
286
+ postgres: `// import { Pool } from 'pg';`,
287
+ mysql: `// import mysql from 'mysql2/promise';`,
288
+ redis: `// import { createClient } from 'redis';`,
289
+ s3: `// import { S3Client } from '@aws-sdk/client-s3';`,
290
+ sendgrid: `// import sgMail from '@sendgrid/mail';`,
291
+ twilio: `// import twilio from 'twilio';`,
292
+ openai: `// import OpenAI from 'openai';`,
293
+ };
294
+ for (const svc of services) {
295
+ const imp = serviceImports[svc.toLowerCase()];
296
+ if (imp) lines.push(imp);
297
+ }
298
+ }
299
+ return lines.length ? lines.join("\n") + "\n" : "";
300
+ }
301
+
302
+ // ── scenario generator ────────────────────────────────────────────────────────
303
+
304
+ function generateScenario(id, name, fn) {
305
+ return {
306
+ scenarioId: `${id}-happy-path`,
307
+ description: `Happy path for ${name}`,
308
+ capabilitiesCovered: [id],
309
+ createdAt: new Date().toISOString(),
310
+ steps: [
311
+ { step: 1, action: `Call ${fn} with valid input`, expected: "Returns success result" },
312
+ { step: 2, action: `Call ${fn} with invalid input`, expected: "Throws appropriate error" },
313
+ ],
314
+ };
315
+ }
316
+
317
+ // ── printer ───────────────────────────────────────────────────────────────────
318
+
319
+ function printResult({ id, filePath, scenarioPath, lang, fn, dryRun }) {
320
+ console.log();
321
+ console.log(bold(` 🌊 ${green(id)}`));
322
+ console.log(gray(" stability: experimental — free to evolve"));
323
+ console.log();
324
+ console.log(gray(" Generated:"));
325
+ console.log(` ${green("+")} ${cyan(filePath)} ${gray(`(${lang} source skeleton)`)}`);
326
+ console.log(` ${green("+")} ${cyan("inferno/capabilities.json")} ${gray("(capability registered)")}`);
327
+ console.log(` ${green("+")} ${cyan(scenarioPath)} ${gray("(placeholder scenario)")}`);
328
+ console.log();
329
+ if (dryRun) {
330
+ console.log(yellow(" [dry-run] — no files were written"));
331
+ } else {
332
+ console.log(gray(" Next steps:"));
333
+ console.log(gray(` 1. Implement ${fn}() in ${filePath}`));
334
+ console.log(gray(` 2. Run: infernoflow scan — to extract call graph`));
335
+ console.log(gray(` 3. Run: infernoflow graph — to see dependencies`));
336
+ console.log(gray(` 4. Run: infernoflow check — to validate contract`));
337
+ }
338
+ console.log();
339
+ }
340
+
341
+ // ── entry point ───────────────────────────────────────────────────────────────
342
+
343
+ export async function scaffoldCommand(rawArgs) {
344
+ const args = (rawArgs || []).slice(1);
345
+ const dryRun = args.includes("--dry-run");
346
+ const jsonMode = args.includes("--json");
347
+
348
+ const langIdx = args.indexOf("--lang");
349
+ const langArg = langIdx !== -1 ? args[langIdx + 1] : null;
350
+
351
+ const dirIdx = args.indexOf("--dir");
352
+ const dirArg = dirIdx !== -1 ? args[dirIdx + 1] : null;
353
+
354
+ const descIdx = args.indexOf("--description");
355
+ const descArg = descIdx !== -1 ? args[descIdx + 1] : null;
356
+
357
+ // Cap ID: first non-flag arg (skip values after --lang, --dir, --description)
358
+ const skipIdxs = new Set([langIdx + 1, dirIdx + 1, descIdx + 1].filter(i => i > 0));
359
+ const capId = args.find((a, i) => !a.startsWith("--") && !skipIdxs.has(i));
360
+
361
+ if (!capId) {
362
+ console.error(red("✗ Usage: infernoflow scaffold <capability-id> [--dir <src>] [--lang ts|js|py|go] [--dry-run] [--json]"));
363
+ console.error(gray(" Example: infernoflow scaffold payment-refund"));
364
+ process.exit(1);
365
+ }
366
+
367
+ // Validate cap ID format
368
+ if (!/^[a-z][a-z0-9-]*$/.test(capId)) {
369
+ console.error(red(`✗ Invalid capability ID: "${capId}"`));
370
+ console.error(gray(" Use lowercase kebab-case: payment-refund, user-auth, etc."));
371
+ process.exit(1);
372
+ }
373
+
374
+ const cwd = process.cwd();
375
+ const infernoDir = path.join(cwd, "inferno");
376
+
377
+ // Load context
378
+ const capsPath = path.join(infernoDir, "capabilities.json");
379
+ if (!fs.existsSync(capsPath)) {
380
+ console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
381
+ process.exit(1);
382
+ }
383
+
384
+ let allCaps = [];
385
+ const rawCaps = loadJson(capsPath);
386
+ if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
387
+
388
+ // Check duplicate
389
+ if (allCaps.some(c => c.id === capId)) {
390
+ console.error(red(`✗ Capability "${capId}" already exists in capabilities.json`));
391
+ console.error(gray(" Use a different ID, or run: infernoflow why " + capId));
392
+ process.exit(1);
393
+ }
394
+
395
+ const scan = loadJson(path.join(infernoDir, "scan.json"));
396
+ const profile = loadJson(path.join(infernoDir, "developer-profile.json"));
397
+
398
+ // Detect language
399
+ const lang = langArg || detectLang(scan, profile, cwd);
400
+
401
+ // Detect output directory
402
+ const srcDir = dirArg || detectSrcDir(scan, cwd) || "src";
403
+ const ext = { ts: ".ts", js: ".js", py: ".py", go: ".go" }[lang] || ".js";
404
+
405
+ // Derive names
406
+ const name = toTitle(capId);
407
+ const description = descArg || `TODO: describe ${name}`;
408
+ const fn = primaryFnName(capId);
409
+ const services = detectServices(scan);
410
+
411
+ // Generate code
412
+ let code;
413
+ if (lang === "ts") code = generateTs(capId, name, description, fn, services);
414
+ else if (lang === "py") code = generatePy(capId, name, description, fn);
415
+ else if (lang === "go") code = generateGo(capId, name, description, fn);
416
+ else code = generateJs(capId, name, description, fn, services);
417
+
418
+ // File path: srcDir/capId (replace dashes with nothing) + ext
419
+ // e.g. payment-refund → src/paymentRefund.ts
420
+ const fileName = toCamelCase(capId) + ext;
421
+ const filePath = path.join(srcDir, fileName);
422
+ const absFile = path.join(cwd, filePath);
423
+
424
+ // Scenario path
425
+ const scenarioPath = path.join("inferno", "scenarios", `${capId}.json`);
426
+ const absScenario = path.join(cwd, scenarioPath);
427
+ const scenario = generateScenario(capId, name, fn);
428
+
429
+ // New capability entry
430
+ const newCap = {
431
+ id: capId,
432
+ name,
433
+ description,
434
+ stability: "experimental",
435
+ since: new Date().toISOString().slice(0, 10),
436
+ };
437
+
438
+ if (jsonMode) {
439
+ const out = {
440
+ capId,
441
+ name,
442
+ stability: "experimental",
443
+ lang,
444
+ filePath,
445
+ scenarioPath,
446
+ primaryFn: fn,
447
+ dryRun,
448
+ code,
449
+ };
450
+ console.log(JSON.stringify(out, null, 2));
451
+ return;
452
+ }
453
+
454
+ console.log(gray(`\n infernoflow scaffold → ${bold(capId)}`));
455
+ console.log(gray(" ──────────────────────────────────────────────────────────────"));
456
+
457
+ if (!dryRun) {
458
+ // Create source directory if needed
459
+ const absDir = path.dirname(absFile);
460
+ if (!fs.existsSync(absDir)) fs.mkdirSync(absDir, { recursive: true });
461
+
462
+ // Write source file
463
+ if (fs.existsSync(absFile)) {
464
+ console.error(red(` ✗ File already exists: ${filePath}`));
465
+ console.error(gray(" Delete it first or choose a different --dir"));
466
+ process.exit(1);
467
+ }
468
+ fs.writeFileSync(absFile, code, "utf8");
469
+
470
+ // Register capability
471
+ allCaps.push(newCap);
472
+ saveJson(capsPath, allCaps);
473
+
474
+ // Write scenario
475
+ const scenDir = path.join(cwd, "inferno", "scenarios");
476
+ if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
477
+ if (!fs.existsSync(absScenario)) {
478
+ saveJson(absScenario, scenario);
479
+ }
480
+ }
481
+
482
+ // Show code preview
483
+ const previewLines = code.split("\n").slice(0, 12).map(l => " " + l).join("\n");
484
+ console.log(gray("\n Preview:"));
485
+ console.log(gray(previewLines));
486
+ console.log(gray(" ..."));
487
+
488
+ printResult({ id: capId, filePath, scenarioPath, lang, fn, dryRun });
489
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {