infernoflow 0.10.5 → 0.10.7

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/README.md CHANGED
@@ -62,6 +62,12 @@ Override detected stack during adoption:
62
62
  infernoflow init --adopt --lang ts --framework angular --project-type frontend
63
63
  ```
64
64
 
65
+ C# / ASP.NET example:
66
+
67
+ ```bash
68
+ infernoflow init --adopt --lang cs --framework aspnet --project-type backend --report-human-only
69
+ ```
70
+
65
71
  JSON report for CI/logging:
66
72
 
67
73
  ```bash
@@ -85,6 +91,7 @@ What adoption creates:
85
91
  - `inferno/capabilities.json` (inferred registry)
86
92
  - `inferno/scenarios/adoption_baseline.json` (coverage baseline)
87
93
  - `inferno/adoption_profile.json` (detected components, display fields, external libraries, UI layout, styling hints)
94
+ - `inferno/adoption_profile.json` (detected components, display fields, external libraries, UI layout, styling hints, API call map)
88
95
  - `inferno/context-state.json` (saved development profile: language/framework/project type)
89
96
  - `inferno/CHANGELOG.md` (adoption entry)
90
97
 
@@ -159,6 +166,8 @@ infernoflow doc-gate --json
159
166
  | `infernoflow status` | At-a-glance health of your contract |
160
167
  | `infernoflow suggest` | Generate an AI prompt, apply capability updates |
161
168
  | `infernoflow implement` | Generate implementation prompts for coding agents |
169
+ | `infernoflow pr-impact` | Analyze changed files and infer capability/doc drift |
170
+ | `infernoflow sync --auto` | Deterministic sync flow for agents (skeleton) |
162
171
  | `infernoflow check` | Full validation: contract, capabilities, scenarios, changelog |
163
172
  | `infernoflow doc-gate` | Fails if code changed but docs weren't updated |
164
173
  | `infernoflow context` | Build/persist AI session context for this project |
@@ -176,6 +185,11 @@ infernoflow implement "..." --mode both
176
185
  infernoflow implement "..." --mode cursor
177
186
  infernoflow implement "..." --mode generic
178
187
  infernoflow implement "..." --mode both --copy
188
+ infernoflow pr-impact
189
+ infernoflow pr-impact --json
190
+ infernoflow sync --auto
191
+ infernoflow sync --auto --json
192
+ npm run inferno:hooks # install local git hooks (after init)
179
193
  infernoflow check --json # machine-readable output for CI
180
194
  infernoflow check --skip-doc-gate
181
195
  infernoflow status --json # machine-readable status summary
@@ -265,13 +279,25 @@ Recommended chain:
265
279
 
266
280
  ```yaml
267
281
  # .github/workflows/ci.yml
268
- - name: infernoflow check
269
- run: npx infernoflow check --json
282
+ - name: infernoflow impact + check
283
+ run: |
284
+ npx infernoflow pr-impact --json
285
+ npx infernoflow check --json
270
286
  env:
271
287
  BASE_SHA: ${{ github.event.pull_request.base.sha }}
272
288
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
273
289
  ```
274
290
 
291
+ When you run `infernoflow init`, it now scaffolds:
292
+ - `scripts/inferno-install-hooks.mjs`
293
+ - `.github/workflows/infernoflow-check.yml`
294
+
295
+ Install local hooks once per clone:
296
+
297
+ ```bash
298
+ npm run inferno:hooks
299
+ ```
300
+
275
301
  ## Release Checklist
276
302
 
277
303
  ```bash
@@ -11,6 +11,8 @@ const COMMAND_DESCRIPTIONS = {
11
11
  init: "Scaffold inferno/ in your project (or adopt existing project)",
12
12
  check: "Validate contract, capabilities, scenarios, changelog",
13
13
  status: "Show contract health at a glance",
14
+ "pr-impact": "Summarize PR impact on capabilities and docs",
15
+ sync: "Run deterministic inferno sync flow",
14
16
  "doc-gate": "Fail if code changed but docs were not updated",
15
17
  suggest: "Generate AI prompt + apply capability updates",
16
18
  implement: "Generate code-agent implementation prompt(s)",
@@ -21,6 +23,8 @@ const COMMAND_HANDLERS = {
21
23
  init: async (args) => (await import("../lib/commands/init.mjs")).initCommand(args),
22
24
  check: async (args) => (await import("../lib/commands/check.mjs")).checkCommand(args),
23
25
  status: async (args) => (await import("../lib/commands/status.mjs")).statusCommand(args),
26
+ "pr-impact": async (args) => (await import("../lib/commands/prImpact.mjs")).prImpactCommand(args),
27
+ sync: async (args) => (await import("../lib/commands/syncAuto.mjs")).syncCommand(args),
24
28
  suggest: async (args) => (await import("../lib/commands/suggest.mjs")).suggestCommand(args),
25
29
  implement: async (args) => (await import("../lib/commands/implement.mjs")).implementCommand(args),
26
30
  context: async (args) => (await import("../lib/commands/context.mjs")).contextCommand(args),
@@ -77,6 +81,8 @@ ${formatCommandsHelp()}
77
81
  ${gray("status --json")}
78
82
  ${gray("check --json")}
79
83
  ${gray("doc-gate --json")}
84
+ ${gray("pr-impact --json")}
85
+ ${gray("sync --auto --json")}
80
86
  `;
81
87
 
82
88
  const [, , cmd, ...rest] = process.argv;
@@ -43,7 +43,7 @@ export function discoverCapabilities(cwd) {
43
43
 
44
44
  function collectCodeFiles(cwd) {
45
45
  const files = [];
46
- const roots = ["src", "server", "app", "backend", "frontend", "api"];
46
+ const roots = ["src", "server", "app", "backend", "frontend", "api", "Controllers"];
47
47
  for (const r of roots) {
48
48
  const root = path.join(cwd, r);
49
49
  if (!fs.existsSync(root)) continue;
@@ -55,12 +55,19 @@ function collectCodeFiles(cwd) {
55
55
  if (entry.isDirectory()) {
56
56
  if (["node_modules", ".git", "dist", "build"].includes(entry.name)) continue;
57
57
  stack.push(p);
58
- } else if (/\.(js|jsx|ts|tsx|mjs|cjs|json|md|html|htm)$/.test(entry.name)) {
58
+ } else if (/\.(js|jsx|ts|tsx|mjs|cjs|json|md|html|htm|cs|csproj)$/.test(entry.name)) {
59
59
  files.push(p);
60
60
  }
61
61
  }
62
62
  }
63
63
  }
64
+ // Include common root-level .NET entry files without scanning the whole repo.
65
+ for (const entry of fs.readdirSync(cwd, { withFileTypes: true })) {
66
+ if (!entry.isFile()) continue;
67
+ if (/^(Program\.cs|.+\.csproj)$/i.test(entry.name)) {
68
+ files.push(path.join(cwd, entry.name));
69
+ }
70
+ }
64
71
  return files;
65
72
  }
66
73
 
@@ -237,6 +244,21 @@ function detectDevelopmentProfile(cwd, files, externalLibraries, overrides = {})
237
244
  const autoLanguage = sortedLang[0]?.[1] > 0 ? sortedLang[0][0] : "unknown";
238
245
 
239
246
  let autoFramework = "unknown";
247
+ let hasDotnetWebSdk = false;
248
+ let hasMinimalApi = false;
249
+ let hasBlazor = false;
250
+ for (const filePath of files) {
251
+ const base = path.basename(filePath).toLowerCase();
252
+ if (base.endsWith(".csproj")) {
253
+ const text = safeRead(filePath);
254
+ if (/Microsoft\.NET\.Sdk\.Web/i.test(text)) hasDotnetWebSdk = true;
255
+ if (/Blazor/i.test(text) || /Microsoft\.AspNetCore\.Components/i.test(text)) hasBlazor = true;
256
+ }
257
+ if (base === "program.cs") {
258
+ const text = safeRead(filePath);
259
+ if (/app\.Map(Get|Post|Put|Delete|Patch)\s*\(/i.test(text)) hasMinimalApi = true;
260
+ }
261
+ }
240
262
  const hasDep = (name) => externalLibraries.includes(name);
241
263
  if (externalLibraries.some((d) => d.startsWith("@angular/"))) autoFramework = "angular";
242
264
  else if (hasDep("react")) autoFramework = "react";
@@ -250,14 +272,18 @@ function detectDevelopmentProfile(cwd, files, externalLibraries, overrides = {})
250
272
  else if (hasDep("flask")) autoFramework = "flask";
251
273
  else if (hasDep("django")) autoFramework = "django";
252
274
  else if (hasDep("spring-boot")) autoFramework = "spring";
275
+ else if (hasBlazor) autoFramework = "blazor";
276
+ else if (hasMinimalApi) autoFramework = "minimalapi";
277
+ else if (hasDotnetWebSdk || extCount.cs > 0) autoFramework = "aspnet";
253
278
 
254
279
  let autoProjectType = "fullstack";
255
280
  const hasClientRoots = ["src", "frontend", "app"].some((d) => fs.existsSync(path.join(cwd, d)));
256
281
  const hasServerRoots = ["server", "backend", "api"].some((d) => fs.existsSync(path.join(cwd, d)));
257
282
  if (["react", "angular", "vue", "svelte", "nextjs", "nuxt"].includes(autoFramework)) autoProjectType = "frontend";
258
- if (["express", "nestjs", "fastify", "flask", "django", "spring"].includes(autoFramework)) autoProjectType = "backend";
283
+ if (["express", "nestjs", "fastify", "flask", "django", "spring", "aspnet", "minimalapi"].includes(autoFramework)) autoProjectType = "backend";
259
284
  if (hasClientRoots && hasServerRoots) autoProjectType = "fullstack";
260
285
  if (!hasClientRoots && !hasServerRoots) autoProjectType = "library";
286
+ if (autoFramework === "blazor") autoProjectType = "frontend";
261
287
 
262
288
  return {
263
289
  language: overrides.language || autoLanguage,
@@ -271,6 +297,231 @@ function detectDevelopmentProfile(cwd, files, externalLibraries, overrides = {})
271
297
  };
272
298
  }
273
299
 
300
+ function detectApiCalls(cwd, files) {
301
+ const calls = [];
302
+ const seen = new Set();
303
+ const normalizeEndpointPattern = (value) => {
304
+ let out = String(value || "").trim();
305
+ if (!out) return "";
306
+ out = out.replace(/https?:\/\/[^/]+/gi, "");
307
+ out = out.replace(/\$\{[^}]+\}/g, "{var}");
308
+ out = out.replace(/\{[A-Za-z_][A-Za-z0-9_]*\}/g, "{var}");
309
+ out = out.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, "{var}");
310
+ out = out.replace(/\/\d+(?=\/|$)/g, "/{id}");
311
+ out = out.replace(/=[^&\s]+/g, "={value}");
312
+ out = out.replace(/\/+/g, "/");
313
+ return out;
314
+ };
315
+ const addCall = (call) => {
316
+ const endpointPattern = normalizeEndpointPattern(call.endpointPattern);
317
+ if (!endpointPattern) return;
318
+ const key = `${call.method}|${endpointPattern}|${call.sourceFile}|${call.style}`;
319
+ if (seen.has(key)) return;
320
+ seen.add(key);
321
+ calls.push({ ...call, endpointPattern });
322
+ };
323
+
324
+ for (const filePath of files) {
325
+ if (!/\.(ts|tsx|js|jsx|mjs|cjs|cs)$/i.test(filePath)) continue;
326
+ const rel = path.relative(cwd, filePath);
327
+ const text = safeRead(filePath);
328
+ const looksLikeService = /service|api|client|controller|program\.cs/i.test(rel) || /HttpClient|fetch\(|app\.Map(Get|Post|Put|Delete|Patch)\(/i.test(text);
329
+ if (!looksLikeService) continue;
330
+
331
+ const normalized = text.replace(/\r\n/g, "\n");
332
+
333
+ const constStrings = {};
334
+ const normalizeExpr = (expr) => {
335
+ let out = String(expr || "").trim();
336
+ if (!out) return "";
337
+ out = out.replace(/;+$/, "").trim();
338
+ out = out.replace(/\(\s*$/, ""); // e.g. "this._nextPage("
339
+ // unwrap surrounding quotes/templates
340
+ while ((out.startsWith("'") && out.endsWith("'")) || (out.startsWith('"') && out.endsWith('"')) || (out.startsWith("`") && out.endsWith("`"))) {
341
+ out = out.slice(1, -1).trim();
342
+ }
343
+ return out;
344
+ };
345
+ const isLikelyEndpoint = (value) => {
346
+ const v = String(value || "").trim();
347
+ if (!v) return false;
348
+ if (/^https?:\/\//i.test(v)) return true;
349
+ if (v.startsWith("/")) return true;
350
+ if (/\bapi\b/i.test(v)) return true;
351
+ if (/\$\{[^}]+\}/.test(v)) return true;
352
+ if (/\?[^=\s]+=?/.test(v)) return true;
353
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_./${}-]+$/.test(v)) return true;
354
+ return false;
355
+ };
356
+ const storeConst = (name, raw) => {
357
+ if (!name || !raw) return;
358
+ constStrings[name] = normalizeExpr(raw);
359
+ };
360
+
361
+ const constPattern = /(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]*?);/g;
362
+ for (const m of normalized.matchAll(constPattern)) {
363
+ storeConst(m[1], m[2]);
364
+ }
365
+ const readonlyPattern =
366
+ /(?:public|private|protected)?\s*(?:readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]*?);/g;
367
+ for (const m of normalized.matchAll(readonlyPattern)) {
368
+ storeConst(m[1], m[2]);
369
+ }
370
+
371
+ const resolveExpr = (expr) => {
372
+ const trimmed = normalizeExpr(expr);
373
+ if (!trimmed) return "";
374
+ if (/^['"`][\s\S]*['"`]$/.test(trimmed)) {
375
+ return trimmed.replace(/^['"`]|['"`]$/g, "");
376
+ }
377
+ if (constStrings[trimmed] && isLikelyEndpoint(constStrings[trimmed])) return constStrings[trimmed];
378
+ const thisRef = trimmed.match(/^this\.([A-Za-z_][A-Za-z0-9_]*)$/);
379
+ if (thisRef && constStrings[thisRef[1]] && isLikelyEndpoint(constStrings[thisRef[1]])) return constStrings[thisRef[1]];
380
+ const parts = trimmed.split("+").map((s) => s.trim()).filter(Boolean);
381
+ if (parts.length > 1) {
382
+ const rebuilt = parts
383
+ .map((p) => {
384
+ if (/^['"`][\s\S]*['"`]$/.test(p)) return p.replace(/^['"`]|['"`]$/g, "");
385
+ if (constStrings[p] && isLikelyEndpoint(constStrings[p])) return constStrings[p];
386
+ const thisP = p.match(/^this\.([A-Za-z_][A-Za-z0-9_]*)$/);
387
+ if (thisP && constStrings[thisP[1]] && isLikelyEndpoint(constStrings[thisP[1]])) return constStrings[thisP[1]];
388
+ return `{${p}}`;
389
+ })
390
+ .join("");
391
+ if (rebuilt) return rebuilt;
392
+ }
393
+ const ternary = trimmed.match(/^(.+?)\?(.+?):(.+)$/s);
394
+ if (ternary) {
395
+ const left = resolveExpr(ternary[2]);
396
+ const right = resolveExpr(ternary[3]);
397
+ if (left || right) return `${left || "{optionA}"} | ${right || "{optionB}"}`;
398
+ }
399
+ return trimmed;
400
+ };
401
+
402
+ const httpClientPattern =
403
+ /\.\s*(get|post|put|patch|delete)\s*(?:<[\s\S]*?>)?\s*\(\s*([\s\S]*?)(?:,|\))/gi;
404
+ for (const m of normalized.matchAll(httpClientPattern)) {
405
+ const method = m[1].toUpperCase();
406
+ const raw = resolveExpr(m[2]);
407
+ if (!raw || !isLikelyEndpoint(raw)) continue;
408
+ addCall({
409
+ method,
410
+ endpointPattern: raw,
411
+ style: "httpClient",
412
+ sourceFile: rel,
413
+ });
414
+ }
415
+
416
+ const fetchPattern = /\bfetch\s*\(\s*([\s\S]*?)(?:,|\))/gi;
417
+ for (const m of normalized.matchAll(fetchPattern)) {
418
+ const firstArg = resolveExpr(m[1]);
419
+ if (!firstArg || !isLikelyEndpoint(firstArg)) continue;
420
+ const fromIdx = m.index || 0;
421
+ const lookahead = normalized.slice(fromIdx, fromIdx + 260);
422
+ const methodMatch = /method\s*:\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i.exec(lookahead);
423
+ const method = (methodMatch?.[1] || "GET").toUpperCase();
424
+ addCall({
425
+ method,
426
+ endpointPattern: firstArg,
427
+ style: "fetch",
428
+ sourceFile: rel,
429
+ });
430
+ }
431
+
432
+ const axiosPattern = /\baxios\.(get|post|put|patch|delete)\s*\(\s*([\s\S]*?)(?:,|\))/gi;
433
+ for (const m of normalized.matchAll(axiosPattern)) {
434
+ const method = m[1].toUpperCase();
435
+ const endpoint = resolveExpr(m[2]);
436
+ if (!endpoint || !isLikelyEndpoint(endpoint)) continue;
437
+ addCall({
438
+ method,
439
+ endpointPattern: endpoint,
440
+ style: "axios",
441
+ sourceFile: rel,
442
+ });
443
+ }
444
+
445
+ const axiosObjPattern = /\baxios\s*\(\s*\{([\s\S]*?)\}\s*\)/gi;
446
+ for (const m of normalized.matchAll(axiosObjPattern)) {
447
+ const body = m[1];
448
+ const methodMatch = /\bmethod\s*:\s*["']?(get|post|put|patch|delete)["']?/i.exec(body);
449
+ const urlMatch = /\burl\s*:\s*([^,\n]+)/i.exec(body);
450
+ const method = (methodMatch?.[1] || "get").toUpperCase();
451
+ const endpoint = resolveExpr(urlMatch?.[1] || "");
452
+ if (!endpoint || !isLikelyEndpoint(endpoint)) continue;
453
+ addCall({
454
+ method,
455
+ endpointPattern: endpoint,
456
+ style: "axios-config",
457
+ sourceFile: rel,
458
+ });
459
+ }
460
+
461
+ const requestPattern = /\.\s*request\s*\(\s*["'](GET|POST|PUT|PATCH|DELETE)["']\s*,\s*([\s\S]*?)(?:,|\))/gi;
462
+ for (const m of normalized.matchAll(requestPattern)) {
463
+ const method = m[1].toUpperCase();
464
+ const endpoint = resolveExpr(m[2]);
465
+ if (!endpoint || !isLikelyEndpoint(endpoint)) continue;
466
+ addCall({
467
+ method,
468
+ endpointPattern: endpoint,
469
+ style: "request",
470
+ sourceFile: rel,
471
+ });
472
+ }
473
+
474
+ if (/\.cs$/i.test(filePath)) {
475
+ const mapPattern = /\bapp\.Map(Get|Post|Put|Delete|Patch)\s*\(\s*"([^"]+)"/gi;
476
+ for (const m of normalized.matchAll(mapPattern)) {
477
+ addCall({
478
+ method: m[1].toUpperCase(),
479
+ endpointPattern: m[2],
480
+ style: "csharp-map",
481
+ sourceFile: rel,
482
+ });
483
+ }
484
+
485
+ const classRouteMatch = /\[Route\("([^"]+)"\)\][\s\S]*?class\s+\w+/i.exec(normalized);
486
+ const classRoute = classRouteMatch ? classRouteMatch[1] : "";
487
+ const attrPattern = /\[(HttpGet|HttpPost|HttpPut|HttpDelete|HttpPatch)(?:\("([^"]*)"\))?\]/gi;
488
+ for (const m of normalized.matchAll(attrPattern)) {
489
+ const method = m[1].replace("Http", "").toUpperCase();
490
+ const attrRoute = m[2] || "";
491
+ const endpoint = [classRoute, attrRoute].filter(Boolean).join("/").replace(/\/+/g, "/").replace(/\[controller\]/gi, "{controller}");
492
+ addCall({
493
+ method,
494
+ endpointPattern: endpoint || classRoute || "{controller-route}",
495
+ style: "csharp-controller",
496
+ sourceFile: rel,
497
+ });
498
+ }
499
+
500
+ const httpClientPattern = /\b(GetAsync|PostAsync|PutAsync|DeleteAsync|SendAsync)\s*\(\s*"([^"]+)"/gi;
501
+ for (const m of normalized.matchAll(httpClientPattern)) {
502
+ const method = m[1].replace("Async", "").replace("Send", "SEND").toUpperCase();
503
+ addCall({
504
+ method,
505
+ endpointPattern: m[2],
506
+ style: "csharp-httpclient",
507
+ sourceFile: rel,
508
+ });
509
+ }
510
+ }
511
+ }
512
+
513
+ const byMethod = calls.reduce((acc, c) => {
514
+ acc[c.method] = (acc[c.method] || 0) + 1;
515
+ return acc;
516
+ }, {});
517
+
518
+ return {
519
+ totalCalls: calls.length,
520
+ byMethod,
521
+ calls: calls.slice(0, 80),
522
+ };
523
+ }
524
+
274
525
  export function discoverProjectSignals(cwd, profileOverrides = {}) {
275
526
  const files = collectCodeFiles(cwd);
276
527
  const inferred = new Map();
@@ -324,6 +575,7 @@ export function discoverProjectSignals(cwd, profileOverrides = {}) {
324
575
  uiLayout: detectUiLayout(files),
325
576
  styling: detectStyling(cwd, files, externalLibraries),
326
577
  developmentProfile: detectDevelopmentProfile(cwd, files, externalLibraries, profileOverrides),
578
+ apiCalls: detectApiCalls(cwd, files),
327
579
  };
328
580
  }
329
581
 
@@ -425,6 +677,14 @@ export function buildSignalsReport(signals) {
425
677
  ` - language : ${signals.developmentProfile?.language || "unknown"} (auto: ${signals.developmentProfile?.detected?.language || "unknown"})`,
426
678
  ` - framework : ${signals.developmentProfile?.framework || "unknown"} (auto: ${signals.developmentProfile?.detected?.framework || "unknown"})`,
427
679
  ` - project type: ${signals.developmentProfile?.projectType || "unknown"} (auto: ${signals.developmentProfile?.detected?.projectType || "unknown"})`,
680
+ "API calls",
681
+ "-".repeat(56),
682
+ ` - total calls : ${signals.apiCalls?.totalCalls ?? 0}`,
683
+ ` - by method : ${Object.entries(signals.apiCalls?.byMethod || {}).map(([k, v]) => `${k}:${v}`).join(", ") || "none"}`,
684
+ ...(signals.apiCalls?.calls || []).slice(0, 6).map((c) => ` - ${c.method} ${c.endpointPattern} [${c.style}] (${c.sourceFile})`),
685
+ ...((signals.apiCalls?.calls || []).length > 6
686
+ ? [` - ... +${(signals.apiCalls?.calls || []).length - 6} more`]
687
+ : []),
428
688
  "=".repeat(56),
429
689
  ].join("\n");
430
690
  }
@@ -488,6 +748,7 @@ export function writeAdoptionBaseline(infernoDir, policyId, capabilities, signal
488
748
  projectType: "unknown",
489
749
  detected: { language: "unknown", framework: "unknown", projectType: "unknown" },
490
750
  },
751
+ apiCalls: signals.apiCalls || { totalCalls: 0, byMethod: {}, calls: [] },
491
752
  };
492
753
  fs.writeFileSync(path.join(infernoDir, "adoption_profile.json"), JSON.stringify(profile, null, 2) + "\n");
493
754
  }
@@ -65,7 +65,10 @@ function upsertScripts(cwd, silent = false) {
65
65
  const toAdd = {
66
66
  "inferno:check": "infernoflow check",
67
67
  "inferno:status": "infernoflow status",
68
- "inferno:gate": "infernoflow doc-gate"
68
+ "inferno:gate": "infernoflow doc-gate",
69
+ "inferno:impact": "infernoflow pr-impact --json",
70
+ "inferno:sync": "infernoflow sync --auto --json",
71
+ "inferno:hooks": "node scripts/inferno-install-hooks.mjs"
69
72
  };
70
73
  for (const [k, v] of Object.entries(toAdd)) {
71
74
  if (!pkg.scripts[k]) { pkg.scripts[k] = v; changed = true; }
@@ -167,6 +170,7 @@ export async function initCommand(args) {
167
170
  }
168
171
 
169
172
  const infernoDir = path.join(cwd, "inferno");
173
+ const workflowsDir = path.join(cwd, ".github", "workflows");
170
174
  if (fs.existsSync(infernoDir) && !force) {
171
175
  if (silent) {
172
176
  console.log(JSON.stringify({ ok: false, error: "inferno_exists", hint: "Use --force to overwrite" }, null, 2));
@@ -216,6 +220,7 @@ export async function initCommand(args) {
216
220
  uiLayout: signals.uiLayout,
217
221
  styling: signals.styling,
218
222
  developmentProfile: signals.developmentProfile,
223
+ apiCalls: signals.apiCalls,
219
224
  },
220
225
  null,
221
226
  2
@@ -240,6 +245,7 @@ export async function initCommand(args) {
240
245
  uiLayout: signals.uiLayout,
241
246
  styling: signals.styling,
242
247
  developmentProfile: signals.developmentProfile,
248
+ apiCalls: signals.apiCalls,
243
249
  },
244
250
  null,
245
251
  2
@@ -301,6 +307,12 @@ export async function initCommand(args) {
301
307
  const srcScript = path.join(templates, "scripts", "inferno-doc-gate.mjs");
302
308
  const dstScript = path.join(cwd, "scripts", "inferno-doc-gate.mjs");
303
309
  copyFile(srcScript, dstScript, force, silent);
310
+ const srcHookScript = path.join(templates, "scripts", "inferno-install-hooks.mjs");
311
+ const dstHookScript = path.join(cwd, "scripts", "inferno-install-hooks.mjs");
312
+ copyFile(srcHookScript, dstHookScript, force, silent);
313
+ const srcWorkflow = path.join(templates, "ci", "github-inferno-check.yml");
314
+ const dstWorkflow = path.join(workflowsDir, "infernoflow-check.yml");
315
+ copyFile(srcWorkflow, dstWorkflow, force, silent);
304
316
 
305
317
  upsertScripts(cwd, silent);
306
318
 
@@ -0,0 +1,157 @@
1
+ import { execSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { header, section, ok, warn, fail, gray, cyan, yellow } from "../ui/output.mjs";
5
+
6
+ const CODE_PREFIXES = ["src/", "frontend/", "backend/", "app/", "pages/", "components/", "lib/", "api/", "server/", "Controllers/"];
7
+
8
+ function sh(cmd) {
9
+ return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString("utf8").trim();
10
+ }
11
+
12
+ function readJson(filePath, fallback = null) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
15
+ } catch {
16
+ return fallback;
17
+ }
18
+ }
19
+
20
+ function readFile(filePath, fallback = "") {
21
+ try {
22
+ return fs.readFileSync(filePath, "utf8");
23
+ } catch {
24
+ return fallback;
25
+ }
26
+ }
27
+
28
+ function getChangedFiles(base, head) {
29
+ const out = base && head
30
+ ? sh(`git diff --name-only ${base}..${head}`)
31
+ : sh("git diff --name-only HEAD");
32
+ return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : [];
33
+ }
34
+
35
+ function buildCapabilityHints(cwd) {
36
+ const infernoDir = path.join(cwd, "inferno");
37
+ const contract = readJson(path.join(infernoDir, "contract.json"), { capabilities: [] });
38
+ const registry = readJson(path.join(infernoDir, "capabilities.json"), { capabilities: [] });
39
+ const titleById = new Map((registry.capabilities || []).map((c) => [c.id, c.title || c.id]));
40
+ return (contract.capabilities || []).map((id) => {
41
+ const title = titleById.get(id) || id;
42
+ const keywords = new Set(
43
+ `${id} ${title}`
44
+ .replace(/([A-Z])/g, " $1")
45
+ .toLowerCase()
46
+ .split(/[^a-z0-9]+/)
47
+ .filter((k) => k.length >= 4)
48
+ );
49
+ return { id, title, keywords: Array.from(keywords) };
50
+ });
51
+ }
52
+
53
+ function inferImpactedCapabilities(cwd, changedCodeFiles) {
54
+ const hints = buildCapabilityHints(cwd);
55
+ const impacted = [];
56
+ for (const hint of hints) {
57
+ const matched = [];
58
+ for (const rel of changedCodeFiles) {
59
+ const abs = path.join(cwd, rel);
60
+ const text = readFile(abs, "").toLowerCase();
61
+ if (!text) continue;
62
+ if (hint.keywords.some((k) => text.includes(k))) {
63
+ matched.push(rel);
64
+ }
65
+ }
66
+ if (matched.length) {
67
+ impacted.push({ id: hint.id, title: hint.title, matchedFiles: matched.slice(0, 5) });
68
+ }
69
+ }
70
+ return impacted;
71
+ }
72
+
73
+ export async function prImpactCommand(args = []) {
74
+ const asJson = args.includes("--json");
75
+ const cwd = process.cwd();
76
+ const base = process.env.BASE_SHA || null;
77
+ const head = process.env.HEAD_SHA || null;
78
+
79
+ let changedFiles = [];
80
+ try {
81
+ changedFiles = getChangedFiles(base, head);
82
+ } catch {
83
+ const payload = { ok: true, skipped: true, reason: "no_git_available" };
84
+ if (asJson) {
85
+ console.log(JSON.stringify(payload, null, 2));
86
+ return;
87
+ }
88
+ header("pr-impact");
89
+ warn("git not available; cannot compute PR impact");
90
+ console.log();
91
+ return;
92
+ }
93
+
94
+ const changedCodeFiles = changedFiles.filter((f) => CODE_PREFIXES.some((p) => f.startsWith(p)));
95
+ const changedInfernoFiles = changedFiles.filter((f) => f.startsWith("inferno/"));
96
+ const impactedCapabilities = inferImpactedCapabilities(cwd, changedCodeFiles);
97
+ const inferredBehaviorChange = changedCodeFiles.length > 0;
98
+ const missingInfernoUpdate = inferredBehaviorChange && changedInfernoFiles.length === 0;
99
+ const confidence = impactedCapabilities.length > 0 ? "high" : inferredBehaviorChange ? "medium" : "low";
100
+ const reasonCodes = [];
101
+ if (inferredBehaviorChange) reasonCodes.push("CODE_CHANGED");
102
+ if (missingInfernoUpdate) reasonCodes.push("INFERNO_NOT_UPDATED");
103
+ if (impactedCapabilities.length > 0) reasonCodes.push("CAPABILITY_HINT_MATCH");
104
+ if (!reasonCodes.length) reasonCodes.push("NO_BEHAVIOR_SIGNAL");
105
+
106
+ const payload = {
107
+ ok: !missingInfernoUpdate,
108
+ base: base || "HEAD",
109
+ head: head || "WORKTREE",
110
+ changedFiles,
111
+ changedCodeFiles,
112
+ changedInfernoFiles,
113
+ inferredBehaviorChange,
114
+ impactedCapabilities,
115
+ confidence,
116
+ reasonCodes,
117
+ recommendations: missingInfernoUpdate
118
+ ? ["Run infernoflow suggest \"describe behavior change\" and update inferno/", "Run infernoflow check --json"]
119
+ : ["Run infernoflow check --json to validate final state"],
120
+ };
121
+
122
+ if (asJson) {
123
+ console.log(JSON.stringify(payload, null, 2));
124
+ process.exit(payload.ok ? 0 : 1);
125
+ }
126
+
127
+ header("pr-impact");
128
+
129
+ section("Diff Scope");
130
+ ok(`Changed files: ${cyan(String(changedFiles.length))}`);
131
+ ok(`Code files: ${cyan(String(changedCodeFiles.length))}`);
132
+ ok(`Inferno files: ${cyan(String(changedInfernoFiles.length))}`);
133
+
134
+ section("Capability Impact");
135
+ if (impactedCapabilities.length === 0) {
136
+ warn("No capability hints matched changed code files");
137
+ } else {
138
+ impactedCapabilities.forEach((c) => {
139
+ console.log(` ${cyan("•")} ${c.id} ${gray(`(${c.title})`)}`);
140
+ c.matchedFiles.slice(0, 3).forEach((f) => console.log(` ${gray("- " + f)}`));
141
+ });
142
+ }
143
+
144
+ section("Doc Sync");
145
+ if (missingInfernoUpdate) {
146
+ fail("Code changed but inferno/ was not updated", "Run infernoflow suggest and then infernoflow check");
147
+ } else {
148
+ ok("No immediate inferno drift signal from changed files");
149
+ }
150
+ ok(`Confidence: ${cyan(confidence)}`);
151
+
152
+ section("Suggested Next");
153
+ payload.recommendations.forEach((r) => console.log(` ${yellow("→")} ${r}`));
154
+ console.log();
155
+ process.exit(payload.ok ? 0 : 1);
156
+ }
157
+
@@ -0,0 +1,96 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { header, section, ok, warn, yellow, gray } from "../ui/output.mjs";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const binPath = path.resolve(__dirname, "..", "..", "bin", "infernoflow.mjs");
9
+
10
+ function runCliJson(args) {
11
+ const out = execFileSync(process.execPath, [binPath, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
12
+ return JSON.parse(out);
13
+ }
14
+
15
+ function tryRunCliJson(args) {
16
+ try {
17
+ return { ok: true, data: runCliJson(args) };
18
+ } catch (err) {
19
+ const stdout = err?.stdout?.toString?.() || "";
20
+ try {
21
+ return { ok: false, data: JSON.parse(stdout) };
22
+ } catch {
23
+ return { ok: false, data: { ok: false, errors: ["command_failed"] } };
24
+ }
25
+ }
26
+ }
27
+
28
+ export async function syncCommand(args = []) {
29
+ const auto = args.includes("--auto");
30
+ const asJson = args.includes("--json");
31
+ const dryRun = args.includes("--dry-run");
32
+
33
+ if (!auto) {
34
+ const payload = { ok: false, error: "missing_required_flag", hint: "Use: infernoflow sync --auto" };
35
+ if (asJson) {
36
+ console.log(JSON.stringify(payload, null, 2));
37
+ process.exit(1);
38
+ }
39
+ header("sync");
40
+ warn("missing --auto flag");
41
+ console.log(` ${yellow("→")} infernoflow sync --auto`);
42
+ console.log();
43
+ process.exit(1);
44
+ }
45
+
46
+ const impact = tryRunCliJson(["pr-impact", "--json"]);
47
+ const needsSync = !impact.data?.ok;
48
+ const confidence = impact.data?.confidence || "low";
49
+ const policyDecision = confidence === "high" ? "auto" : confidence === "medium" ? "ask" : "block";
50
+ const actions = needsSync
51
+ ? ["Generate inferno update proposal (suggest)", "Review changes", "Validate with check --json"]
52
+ : ["No inferno drift detected", "Validate with check --json"];
53
+
54
+ const check = tryRunCliJson(["check", "--json"]);
55
+ const payload = {
56
+ ok: impact.ok && check.ok && !!check.data?.ok,
57
+ mode: "auto-skeleton",
58
+ dryRun,
59
+ needsSync,
60
+ didApply: false,
61
+ confidence,
62
+ policyDecision,
63
+ actions,
64
+ prImpact: impact.data,
65
+ postCheck: check.data,
66
+ reasonCodes: [
67
+ ...(needsSync ? ["DRIFT_DETECTED"] : ["NO_DRIFT"]),
68
+ `POLICY_${policyDecision.toUpperCase()}`,
69
+ ...(policyDecision === "auto" ? ["AUTO_APPLY_DISABLED_IN_SKELETON"] : []),
70
+ ],
71
+ };
72
+
73
+ if (asJson) {
74
+ console.log(JSON.stringify(payload, null, 2));
75
+ process.exit(payload.ok ? 0 : 1);
76
+ }
77
+
78
+ header("sync --auto");
79
+ section("State");
80
+ if (needsSync) warn("Inferno drift detected");
81
+ else ok("No inferno drift detected");
82
+ ok(`Confidence: ${gray(confidence)}`);
83
+ ok(`Policy decision: ${gray(policyDecision)}`);
84
+ ok(`Apply mode: ${gray("skeleton (no file writes)")}`);
85
+ if (dryRun) ok("Dry run enabled");
86
+
87
+ section("Plan");
88
+ actions.forEach((a) => console.log(` ${yellow("→")} ${a}`));
89
+
90
+ section("Validation");
91
+ if (check.ok && check.data?.ok) ok("Post-check passed");
92
+ else warn("Post-check failed; see infernoflow check --json");
93
+ console.log();
94
+ process.exit(payload.ok ? 0 : 1);
95
+ }
96
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
4
4
  "description": "The forge for liquid code — keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "README.md"
17
17
  ],
18
18
  "scripts": {
19
- "test": "node scripts/smoke.mjs && node scripts/json-smoke.mjs && node scripts/json-negative-smoke.mjs && node scripts/implement-smoke.mjs",
19
+ "test": "node scripts/smoke.mjs && node scripts/json-smoke.mjs && node scripts/json-negative-smoke.mjs && node scripts/implement-smoke.mjs && node scripts/adopt-smoke.mjs && node scripts/pr-impact-smoke.mjs && node scripts/sync-smoke.mjs",
20
20
  "test:help": "node bin/infernoflow.mjs --help"
21
21
  },
22
22
  "keywords": [
@@ -0,0 +1,36 @@
1
+ name: infernoflow-check
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main, master]
7
+
8
+ jobs:
9
+ inferno:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout
13
+ uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0
16
+
17
+ - name: Setup Node
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: "20"
21
+
22
+ - name: Install dependencies
23
+ run: npm ci --ignore-scripts || npm install --ignore-scripts
24
+
25
+ - name: Inferno PR impact
26
+ run: npx infernoflow pr-impact --json
27
+ env:
28
+ BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
29
+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
30
+
31
+ - name: Inferno check
32
+ run: npx infernoflow check --json
33
+ env:
34
+ BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
35
+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
36
+
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ const cwd = process.cwd();
6
+ const gitDir = path.join(cwd, ".git");
7
+ const hooksDir = path.join(gitDir, "hooks");
8
+
9
+ if (!fs.existsSync(gitDir)) {
10
+ console.error("[inferno hooks] .git not found. Run inside a git repository.");
11
+ process.exit(1);
12
+ }
13
+
14
+ fs.mkdirSync(hooksDir, { recursive: true });
15
+
16
+ const preCommit = `#!/bin/sh
17
+ echo "[inferno hooks] pre-commit: infernoflow check --skip-doc-gate"
18
+ npx infernoflow check --skip-doc-gate
19
+ `;
20
+
21
+ const prePush = `#!/bin/sh
22
+ echo "[inferno hooks] pre-push: infernoflow doc-gate"
23
+ npx infernoflow doc-gate
24
+ `;
25
+
26
+ const writeHook = (name, content) => {
27
+ const filePath = path.join(hooksDir, name);
28
+ fs.writeFileSync(filePath, content, "utf8");
29
+ fs.chmodSync(filePath, 0o755);
30
+ console.log(`[inferno hooks] installed ${name}`);
31
+ };
32
+
33
+ writeHook("pre-commit", preCommit);
34
+ writeHook("pre-push", prePush);
35
+ console.log("[inferno hooks] done");
36
+