infernoflow 0.23.0 → 0.27.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.
@@ -122,29 +122,45 @@ function getNodeName(node) {
122
122
  return null;
123
123
  }
124
124
 
125
- function collectCallsInNode(node, calls = new Set()) {
126
- if (!ts) return calls;
127
- if (ts.isCallExpression(node)) {
128
- const expr = node.expression;
129
- if (ts.isIdentifier(expr)) {
130
- calls.add(expr.text + "()");
131
- } else if (ts.isPropertyAccessExpression(expr)) {
132
- calls.add(expr.name.text + "()");
125
+ /**
126
+ * Walk all descendants of root using node.forEachChild (instance method).
127
+ * Collects all call expressions and throw statements globally,
128
+ * then assigns them to containing functions by source position range.
129
+ */
130
+ function collectAllNodes(root) {
131
+ const calls = []; // { pos, end, name }
132
+ const throws = []; // { pos, end, name }
133
+
134
+ function walk(node) {
135
+ if (ts.isCallExpression(node)) {
136
+ const expr = node.expression;
137
+ if (ts.isIdentifier(expr)) {
138
+ calls.push({ pos: node.pos, end: node.end, name: expr.text + "()" });
139
+ } else if (ts.isPropertyAccessExpression(expr)) {
140
+ calls.push({ pos: node.pos, end: node.end, name: expr.name.text + "()" });
141
+ }
142
+ }
143
+ if (ts.isThrowStatement(node) && node.expression) {
144
+ if (ts.isNewExpression(node.expression) && ts.isIdentifier(node.expression.expression)) {
145
+ throws.push({ pos: node.pos, end: node.end, name: node.expression.expression.text });
146
+ }
133
147
  }
148
+ node.forEachChild?.(walk);
134
149
  }
135
- ts.forEachChild(node, child => collectCallsInNode(child, calls));
136
- return calls;
150
+ walk(root);
151
+ return { calls, throws };
137
152
  }
138
153
 
139
- function collectThrowsInNode(node, throws = new Set()) {
140
- if (!ts) return throws;
141
- if (ts.isThrowStatement(node) && node.expression) {
142
- if (ts.isNewExpression(node.expression) && ts.isIdentifier(node.expression.expression)) {
143
- throws.add(node.expression.expression.text);
144
- }
145
- }
146
- ts.forEachChild(node, child => collectThrowsInNode(child, throws));
147
- return throws;
154
+ function callsInRange(allCalls, pos, end) {
155
+ return [...new Set(
156
+ allCalls.filter(c => c.pos >= pos && c.end <= end).map(c => c.name)
157
+ )].slice(0, 20);
158
+ }
159
+
160
+ function throwsInRange(allThrows, pos, end) {
161
+ return [...new Set(
162
+ allThrows.filter(t => t.pos >= pos && t.end <= end).map(t => t.name)
163
+ )];
148
164
  }
149
165
 
150
166
  function isFunctionNode(node) {
@@ -179,25 +195,28 @@ function analyzeJsTs(filePath, code) {
179
195
  return null;
180
196
  }
181
197
 
198
+ // Collect ALL call/throw nodes in one pass from root
199
+ const { calls: allCalls, throws: allThrows } = collectAllNodes(srcFile);
200
+
182
201
  const functions = [];
183
202
 
184
203
  function visit(node) {
185
204
  if (isFunctionNode(node)) {
186
- const name = getNodeName(node) || getParentVariableName(node) || "<anonymous>";
187
- const calls = [...collectCallsInNode(node)].slice(0, 20);
188
- const throws = [...collectThrowsInNode(node)];
189
- const text = code.slice(node.pos, node.end);
205
+ const name = getNodeName(node) || getParentVariableName(node) || "<anonymous>";
206
+ const text = code.slice(node.pos, node.end);
207
+ const calls = callsInRange(allCalls, node.pos, node.end);
208
+ const throws = throwsInRange(allThrows, node.pos, node.end);
190
209
  functions.push({
191
210
  name,
192
211
  calls,
193
212
  throws,
194
- services: detectServices(text),
195
- dbCalls: detectDbCalls(text),
213
+ services: detectServices(text),
214
+ dbCalls: detectDbCalls(text),
196
215
  httpCalls: detectHttpCalls(text),
197
216
  loc: srcFile.getLineAndCharacterOfPosition(node.pos).line + 1,
198
217
  });
199
218
  }
200
- ts.forEachChild(node, visit);
219
+ node.forEachChild?.(visit);
201
220
  }
202
221
 
203
222
  visit(srcFile);
@@ -0,0 +1,293 @@
1
+ /**
2
+ * infernoflow freeze / thaw / stability
3
+ *
4
+ * The solid/liquid layer — mark capabilities as frozen (don't touch),
5
+ * stable (be careful), or experimental (feel free to reshape).
6
+ *
7
+ * Usage:
8
+ * infernoflow stability List all caps with stability level
9
+ * infernoflow freeze <cap-id> Mark a capability as frozen
10
+ * infernoflow freeze <cap-id> --stable Mark as stable (default middle tier)
11
+ * infernoflow thaw <cap-id> Reset to experimental
12
+ * infernoflow stability --json Machine-readable output
13
+ *
14
+ * Levels:
15
+ * experimental New or actively changing — AI may freely refactor
16
+ * stable Settled API — AI should be careful, prefer additive changes
17
+ * frozen Core contract — AI must never modify without explicit instruction
18
+ */
19
+
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
23
+
24
+ // ── constants ─────────────────────────────────────────────────────────────────
25
+
26
+ export const LEVELS = ["experimental", "stable", "frozen"];
27
+
28
+ const LEVEL_ICON = {
29
+ experimental: "🌊", // liquid — flows freely
30
+ stable: "〰️", // semi-fluid — treat with care
31
+ frozen: "🧊", // solid — do not touch
32
+ };
33
+
34
+ const LEVEL_COLOR = {
35
+ experimental: green,
36
+ stable: yellow,
37
+ frozen: red,
38
+ };
39
+
40
+ // ── helpers ───────────────────────────────────────────────────────────────────
41
+
42
+ function loadCaps(capsPath) {
43
+ try {
44
+ const data = JSON.parse(fs.readFileSync(capsPath, "utf8"));
45
+ return Array.isArray(data) ? data : (data.capabilities || []);
46
+ } catch (e) {
47
+ console.error(red("✗ Failed to read capabilities.json: " + e.message));
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ function saveCaps(capsPath, caps) {
53
+ fs.writeFileSync(capsPath, JSON.stringify(caps, null, 2));
54
+ }
55
+
56
+ function getLevel(cap) {
57
+ return cap.stability || "experimental";
58
+ }
59
+
60
+ function bar(level) {
61
+ const idx = LEVELS.indexOf(level);
62
+ const color = LEVEL_COLOR[level] || gray;
63
+ const filled = "█".repeat(idx + 1);
64
+ const empty = "░".repeat(LEVELS.length - idx - 1);
65
+ return color(filled) + gray(empty);
66
+ }
67
+
68
+ // ── sub-commands ──────────────────────────────────────────────────────────────
69
+
70
+ function cmdList(caps, jsonMode) {
71
+ if (jsonMode) {
72
+ const out = caps.map(c => ({ id: c.id, name: c.name || c.title, stability: getLevel(c) }));
73
+ console.log(JSON.stringify(out, null, 2));
74
+ return;
75
+ }
76
+
77
+ const byLevel = { frozen: [], stable: [], experimental: [] };
78
+ for (const cap of caps) byLevel[getLevel(cap)].push(cap);
79
+
80
+ console.log();
81
+ console.log(bold(" Capability Stability"));
82
+ console.log(gray(" ───────────────────────────────────────────────────────────"));
83
+ console.log(
84
+ gray(" ") + bold(cyan("Capability".padEnd(32))) +
85
+ bold(cyan("Level".padEnd(16))) + bold(cyan("Solid/Liquid"))
86
+ );
87
+ console.log(gray(" ───────────────────────────────────────────────────────────"));
88
+
89
+ for (const cap of caps) {
90
+ const level = getLevel(cap);
91
+ const icon = LEVEL_ICON[level];
92
+ const color = LEVEL_COLOR[level] || gray;
93
+ console.log(
94
+ ` ${icon} ${cap.id.padEnd(30)} ${color(level.padEnd(14))} ${bar(level)}`
95
+ );
96
+ }
97
+
98
+ console.log(gray(" ───────────────────────────────────────────────────────────"));
99
+ console.log();
100
+
101
+ const counts = {
102
+ frozen: byLevel.frozen.length,
103
+ stable: byLevel.stable.length,
104
+ experimental: byLevel.experimental.length,
105
+ };
106
+ console.log(
107
+ ` ${red("🧊 Frozen:")} ${counts.frozen} ` +
108
+ `${yellow("〰️ Stable:")} ${counts.stable} ` +
109
+ `${green("🌊 Experimental:")} ${counts.experimental}`
110
+ );
111
+ console.log();
112
+ console.log(gray(" Tip: infernoflow freeze <cap-id> — infernoflow thaw <cap-id>"));
113
+ console.log();
114
+ }
115
+
116
+ function cmdFreeze(caps, capsPath, capId, level) {
117
+ if (!LEVELS.includes(level)) {
118
+ console.error(red(`✗ Invalid level "${level}". Must be: ${LEVELS.join(", ")}`));
119
+ process.exit(1);
120
+ }
121
+
122
+ const idx = caps.findIndex(c => c.id === capId);
123
+ if (idx === -1) {
124
+ console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
125
+ process.exit(1);
126
+ }
127
+
128
+ const prev = getLevel(caps[idx]);
129
+ caps[idx] = { ...caps[idx], stability: level, stabilitySetAt: new Date().toISOString() };
130
+ saveCaps(capsPath, caps);
131
+
132
+ const icon = LEVEL_ICON[level];
133
+ const color = LEVEL_COLOR[level];
134
+ console.log();
135
+ console.log(` ${icon} ${bold(capId)} ${gray(prev)} → ${color(level)}`);
136
+ if (level === "frozen") {
137
+ console.log(gray(" AI assistants will be instructed not to modify this capability."));
138
+ console.log(gray(" Run `infernoflow setup` to update CLAUDE.md with this change."));
139
+ }
140
+ console.log();
141
+ }
142
+
143
+ function cmdThaw(caps, capsPath, capId) {
144
+ const idx = caps.findIndex(c => c.id === capId);
145
+ if (idx === -1) {
146
+ console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
147
+ process.exit(1);
148
+ }
149
+
150
+ const prev = getLevel(caps[idx]);
151
+ caps[idx] = { ...caps[idx], stability: "experimental", stabilitySetAt: new Date().toISOString() };
152
+ saveCaps(capsPath, caps);
153
+
154
+ console.log();
155
+ console.log(` 🌊 ${bold(capId)} ${gray(prev)} → ${green("experimental")}`);
156
+ console.log(gray(" This capability is now liquid — free to evolve."));
157
+ console.log();
158
+ }
159
+
160
+ // ── scan drift check (frozen caps whose files changed) ────────────────────────
161
+
162
+ export function checkFrozenDrift(infernoDir, cwd) {
163
+ const capsPath = path.join(infernoDir, "capabilities.json");
164
+ const scanPath = path.join(infernoDir, "scan.json");
165
+ if (!fs.existsSync(capsPath) || !fs.existsSync(scanPath)) return [];
166
+
167
+ const caps = loadCaps(capsPath);
168
+ const scan = JSON.parse(fs.readFileSync(scanPath, "utf8"));
169
+ const scannedAt = new Date(scan.scannedAt);
170
+
171
+ const warnings = [];
172
+ for (const cap of caps) {
173
+ if (getLevel(cap) !== "frozen") continue;
174
+ const scanEntry = scan.capabilities?.find(c => c.id === cap.id);
175
+ if (!scanEntry?.codeAnalysis?.sourceFiles) continue;
176
+
177
+ for (const relFile of scanEntry.codeAnalysis.sourceFiles) {
178
+ const absFile = path.join(cwd, relFile);
179
+ try {
180
+ const stat = fs.statSync(absFile);
181
+ if (stat.mtimeMs > scannedAt.getTime()) {
182
+ warnings.push({ capId: cap.id, file: relFile });
183
+ }
184
+ } catch {}
185
+ }
186
+ }
187
+ return warnings;
188
+ }
189
+
190
+ // ── stability summary for CLAUDE.md ──────────────────────────────────────────
191
+
192
+ export function buildStabilitySummary(caps) {
193
+ const frozen = caps.filter(c => getLevel(c) === "frozen").map(c => c.id);
194
+ const stable = caps.filter(c => getLevel(c) === "stable").map(c => c.id);
195
+ const experimental = caps.filter(c => getLevel(c) === "experimental").map(c => c.id);
196
+
197
+ if (frozen.length === 0 && stable.length === 0) return null;
198
+
199
+ const lines = ["### Capability Stability (Solid/Liquid Layer)", ""];
200
+
201
+ if (frozen.length > 0) {
202
+ lines.push("**🧊 Frozen — NEVER modify without explicit instruction:**");
203
+ for (const id of frozen) lines.push(`- \`${id}\``);
204
+ lines.push("");
205
+ }
206
+ if (stable.length > 0) {
207
+ lines.push("**〰️ Stable — prefer additive changes, avoid breaking API:**");
208
+ for (const id of stable) lines.push(`- \`${id}\``);
209
+ lines.push("");
210
+ }
211
+ if (experimental.length > 0) {
212
+ lines.push(`**🌊 Experimental — free to refactor:** ${experimental.map(id => `\`${id}\``).join(", ")}`);
213
+ lines.push("");
214
+ }
215
+
216
+ lines.push("> Run `infernoflow stability` to see the full liquid/solid map.");
217
+ return lines.join("\n");
218
+ }
219
+
220
+ // ── entry point ───────────────────────────────────────────────────────────────
221
+
222
+ export async function stabilityCommand(rawArgs) {
223
+ const args = (rawArgs || []).slice(1); // skip command name
224
+ const jsonMode = args.includes("--json");
225
+ const cwd = process.cwd();
226
+ const infernoDir = path.join(cwd, "inferno");
227
+ const capsPath = path.join(infernoDir, "capabilities.json");
228
+
229
+ if (!fs.existsSync(capsPath)) {
230
+ console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
231
+ process.exit(1);
232
+ }
233
+
234
+ const caps = loadCaps(capsPath);
235
+ cmdList(caps, jsonMode);
236
+
237
+ // Also check for frozen drift if scan.json exists
238
+ const driftWarnings = checkFrozenDrift(infernoDir, cwd);
239
+ if (driftWarnings.length > 0) {
240
+ console.log(red(" ⚠ Frozen capability drift detected!"));
241
+ for (const w of driftWarnings) {
242
+ console.log(red(` ${w.capId}: ${w.file} was modified since last scan`));
243
+ }
244
+ console.log(gray(" Run `infernoflow scan` to update the baseline."));
245
+ console.log();
246
+ }
247
+ }
248
+
249
+ export async function freezeCommand(rawArgs) {
250
+ const args = (rawArgs || []).slice(1); // skip command name
251
+ const capId = args.find(a => !a.startsWith("--"));
252
+ const isStable = args.includes("--stable");
253
+ const level = isStable ? "stable" : "frozen";
254
+
255
+ if (!capId) {
256
+ console.error(red("✗ Usage: infernoflow freeze <capability-id> [--stable]"));
257
+ process.exit(1);
258
+ }
259
+
260
+ const cwd = process.cwd();
261
+ const infernoDir = path.join(cwd, "inferno");
262
+ const capsPath = path.join(infernoDir, "capabilities.json");
263
+
264
+ if (!fs.existsSync(capsPath)) {
265
+ console.error(red("✗ inferno/capabilities.json not found."));
266
+ process.exit(1);
267
+ }
268
+
269
+ const caps = loadCaps(capsPath);
270
+ cmdFreeze(caps, capsPath, capId, level);
271
+ }
272
+
273
+ export async function thawCommand(rawArgs) {
274
+ const args = (rawArgs || []).slice(1); // skip command name
275
+ const capId = args.find(a => !a.startsWith("--"));
276
+
277
+ if (!capId) {
278
+ console.error(red("✗ Usage: infernoflow thaw <capability-id>"));
279
+ process.exit(1);
280
+ }
281
+
282
+ const cwd = process.cwd();
283
+ const infernoDir = path.join(cwd, "inferno");
284
+ const capsPath = path.join(infernoDir, "capabilities.json");
285
+
286
+ if (!fs.existsSync(capsPath)) {
287
+ console.error(red("✗ inferno/capabilities.json not found."));
288
+ process.exit(1);
289
+ }
290
+
291
+ const caps = loadCaps(capsPath);
292
+ cmdThaw(caps, capsPath, capId);
293
+ }