infernoflow 0.1.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/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # 🔥 infernoflow
2
+
3
+ > The forge for liquid code — keep capabilities, contracts, and docs in sync with your codebase.
4
+
5
+ ## What it does
6
+
7
+ infernoflow ensures that when your code changes, your **capability contracts** and **documentation** stay in sync. It prevents "semantic drift" — where code evolves but no one knows what the system can actually do.
8
+
9
+ ```
10
+ inferno/
11
+ ├── contract.json ← what your system promises to do
12
+ ├── capabilities.json ← registry of each capability
13
+ ├── scenarios/ ← test scenarios covering each capability
14
+ └── CHANGELOG.md ← history of capability changes
15
+ ```
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install -g infernoflow
21
+ # or use without installing:
22
+ npx infernoflow init
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```bash
28
+ # In your project root:
29
+ npx infernoflow init
30
+
31
+ # See your contract health:
32
+ infernoflow status
33
+
34
+ # Validate everything:
35
+ infernoflow check
36
+
37
+ # In CI / pre-push hook:
38
+ infernoflow doc-gate
39
+ ```
40
+
41
+ ## Commands
42
+
43
+ | Command | Description |
44
+ |---|---|
45
+ | `infernoflow init` | Interactive scaffold — creates `inferno/` in your project |
46
+ | `infernoflow status` | At-a-glance health of your contract |
47
+ | `infernoflow check` | Full validation: contract, capabilities, scenarios, changelog |
48
+ | `infernoflow doc-gate` | Fails if code changed but docs weren't updated |
49
+
50
+ ### Options
51
+
52
+ ```bash
53
+ infernoflow init --force # overwrite existing files
54
+ infernoflow init --yes # skip prompts, use defaults
55
+ infernoflow check --json # machine-readable output for CI
56
+ infernoflow check --skip-doc-gate
57
+ ```
58
+
59
+ ## Example: Todo App
60
+
61
+ After `infernoflow init` in a React Todo project:
62
+
63
+ ```
64
+ inferno/
65
+ ├── contract.json
66
+ │ {
67
+ │ "policyId": "todo-app",
68
+ │ "policyVersion": 1,
69
+ │ "capabilities": ["CreateTask", "ReadTasks", "UpdateTask", "ToggleComplete", "DeleteTask"]
70
+ │ }
71
+ ├── capabilities.json
72
+ │ { capabilities: [{ id: "CreateTask", title: "Create a task", since: "0.1.0" }, ...] }
73
+ ├── scenarios/
74
+ │ └── happy_path.json ← covers all 5 capabilities
75
+ └── CHANGELOG.md
76
+ ```
77
+
78
+ Then in CI:
79
+
80
+ ```yaml
81
+ - run: npm run inferno:check
82
+ ```
83
+
84
+ ## Why infernoflow?
85
+
86
+ **The problem:** AI-assisted development moves fast. Code changes daily. But what does the system *actually do*? What changed? What's covered?
87
+
88
+ **The metaphor:** A forge (כיבשן). Metal becomes liquid — flexible, shapeable. The forge is the controlled environment where that change happens safely, with molds (contracts) and tempering (tests).
89
+
90
+ **The principle:** Liquid where you want flexibility. Solid where you need safety.
91
+
92
+ ## CI Integration
93
+
94
+ ```yaml
95
+ # .github/workflows/ci.yml
96
+ - name: infernoflow check
97
+ run: npx infernoflow check --json
98
+ env:
99
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
100
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
101
+ ```
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { bold, gray, cyan, red, orange } from "../lib/ui/output.mjs";
5
+
6
+ const VERSION = "0.1.0";
7
+
8
+ const HELP = `
9
+ ${bold("🔥 infernoflow")} ${gray("v" + VERSION)}
10
+ ${gray("The forge for liquid code")}
11
+
12
+ ${bold("Usage:")}
13
+ infernoflow <command> [options]
14
+
15
+ ${bold("Commands:")}
16
+ init Scaffold inferno/ in your project (interactive)
17
+ check Validate contract, capabilities, scenarios, changelog
18
+ status Show contract health at a glance
19
+ doc-gate Fail if code changed but docs were not updated
20
+
21
+ ${bold("Options:")}
22
+ init:
23
+ --force, -f Overwrite existing files
24
+ --yes, -y Skip prompts, use defaults
25
+
26
+ check:
27
+ --skip-doc-gate Skip the git doc-gate check
28
+ --json Machine-readable JSON output (for CI)
29
+
30
+ ${bold("Examples:")}
31
+ ${cyan("npx infernoflow init")}
32
+ ${cyan("infernoflow status")}
33
+ ${cyan("infernoflow check")}
34
+ ${cyan("infernoflow check --json")}
35
+ ${cyan("infernoflow doc-gate")}
36
+
37
+ ${bold("CI example:")}
38
+ ${gray("# In GitHub Actions:")}
39
+ ${gray("- run: npx infernoflow check --json")}
40
+ ${gray(" env:")}
41
+ ${gray(" BASE_SHA: ${{ github.event.pull_request.base.sha }}")}
42
+ ${gray(" HEAD_SHA: ${{ github.event.pull_request.head.sha }}")}
43
+ `;
44
+
45
+ const [,, cmd, ...rest] = process.argv;
46
+
47
+ if (!cmd || cmd === "--help" || cmd === "-h") {
48
+ console.log(HELP);
49
+ process.exit(0);
50
+ }
51
+
52
+ if (cmd === "--version" || cmd === "-v") {
53
+ console.log(VERSION);
54
+ process.exit(0);
55
+ }
56
+
57
+ const commands = ["init", "check", "status", "doc-gate"];
58
+
59
+ if (!commands.includes(cmd)) {
60
+ console.error(red(`\nUnknown command: ${cmd}`));
61
+ console.error(gray("Run: infernoflow --help\n"));
62
+ process.exit(1);
63
+ }
64
+
65
+ const args = [cmd, ...rest];
66
+
67
+ switch (cmd) {
68
+ case "init":
69
+ import("../lib/commands/init.mjs")
70
+ .then(m => m.initCommand(args))
71
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
72
+ break;
73
+ case "check":
74
+ import("../lib/commands/check.mjs")
75
+ .then(m => m.checkCommand(args))
76
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
77
+ break;
78
+ case "status":
79
+ import("../lib/commands/status.mjs")
80
+ .then(m => m.statusCommand(args))
81
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
82
+ break;
83
+ case "doc-gate":
84
+ import("../lib/commands/docGate.mjs")
85
+ .then(m => m.docGateCommand())
86
+ .catch(err => { console.error(red("\nError: ") + err.message); process.exit(1); });
87
+ break;
88
+ }
@@ -0,0 +1,154 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { header, ok, fail, warn, info, section, done, errorAndExit, cyan, bold, red, green, yellow, gray } from "../ui/output.mjs";
4
+ import { docGateCommand } from "./docGate.mjs";
5
+
6
+ function readJson(filePath) {
7
+ try { return JSON.parse(fs.readFileSync(filePath, "utf8")); }
8
+ catch (err) {
9
+ errorAndExit(
10
+ `Cannot parse ${path.basename(filePath)}`,
11
+ `Check JSON syntax in: ${filePath}`
12
+ );
13
+ }
14
+ }
15
+
16
+ function getScenarioFiles(scenariosDir) {
17
+ if (!fs.existsSync(scenariosDir)) return [];
18
+ return fs.readdirSync(scenariosDir)
19
+ .filter(f => f.endsWith(".json"))
20
+ .map(f => path.join(scenariosDir, f));
21
+ }
22
+
23
+ function getCovered(scenarioFiles) {
24
+ const covered = new Set();
25
+ for (const f of scenarioFiles) {
26
+ try {
27
+ const s = JSON.parse(fs.readFileSync(f, "utf8"));
28
+ (s.capabilitiesCovered || []).forEach(c => covered.add(c));
29
+ } catch {}
30
+ }
31
+ return covered;
32
+ }
33
+
34
+ export async function checkCommand(args) {
35
+ const cwd = process.cwd();
36
+ const infernoDir = path.join(cwd, "inferno");
37
+ const skipDocGate = args.includes("--skip-doc-gate");
38
+ const jsonOut = args.includes("--json");
39
+
40
+ if (!jsonOut) header("check");
41
+
42
+ const errors = [];
43
+ const warnings = [];
44
+
45
+ // ── inferno/ exists ─────────────────────────────────────────────
46
+ if (!fs.existsSync(infernoDir)) {
47
+ if (jsonOut) { console.log(JSON.stringify({ ok: false, errors: ["inferno/ not found"] })); process.exit(1); }
48
+ errorAndExit("inferno/ not found", `Run: infernoflow init`);
49
+ }
50
+
51
+ // ── contract.json ───────────────────────────────────────────────
52
+ const contractPath = path.join(infernoDir, "contract.json");
53
+ const capsPath = path.join(infernoDir, "capabilities.json");
54
+ const scenariosDir = path.join(infernoDir, "scenarios");
55
+ const changelogPath = path.join(infernoDir, "CHANGELOG.md");
56
+
57
+ if (!jsonOut) section("Contract");
58
+ if (!fs.existsSync(contractPath)) {
59
+ fail("contract.json not found", "Run: infernoflow init");
60
+ errors.push("contract.json missing");
61
+ } else {
62
+ const contract = readJson(contractPath);
63
+ const caps = contract.capabilities || [];
64
+
65
+ if (!contract.policyId) { fail("policyId missing"); errors.push("policyId missing"); }
66
+ else if (!jsonOut) ok(`policyId: ${bold(contract.policyId)}`);
67
+
68
+ if (!Number.isInteger(contract.policyVersion)) { fail("policyVersion must be an integer"); errors.push("policyVersion invalid"); }
69
+ else if (!jsonOut) ok(`policyVersion: ${bold("v" + contract.policyVersion)}`);
70
+
71
+ if (caps.length === 0) { fail("capabilities array is empty"); errors.push("no capabilities"); }
72
+ else if (!jsonOut) ok(`${caps.length} capabilities declared`);
73
+
74
+ // ── capabilities.json ────────────────────────────────────────
75
+ if (!jsonOut) section("Capabilities Registry");
76
+ if (!fs.existsSync(capsPath)) {
77
+ fail("capabilities.json not found"); errors.push("capabilities.json missing");
78
+ } else {
79
+ const registry = readJson(capsPath);
80
+ const registryIds = new Set((registry.capabilities || []).map(c => c?.id).filter(Boolean));
81
+
82
+ const missingInRegistry = caps.filter(c => !registryIds.has(c));
83
+ if (missingInRegistry.length > 0) {
84
+ missingInRegistry.forEach(c => {
85
+ if (!jsonOut) fail(`"${c}" in contract but missing from capabilities.json`, `Add it to inferno/capabilities.json`);
86
+ errors.push(`"${c}" not registered`);
87
+ });
88
+ } else if (!jsonOut) {
89
+ ok(`All ${registryIds.size} capabilities registered`);
90
+ }
91
+
92
+ // ── scenarios ───────────────────────────────────────────────
93
+ if (!jsonOut) section("Scenarios");
94
+ const scenarioFiles = getScenarioFiles(scenariosDir);
95
+ if (scenarioFiles.length === 0) {
96
+ warn("No scenarios found"); warnings.push("no scenarios");
97
+ } else {
98
+ const covered = getCovered(scenarioFiles);
99
+ const requireCoverage = contract?.rules?.requireScenarioForEachCapability !== false;
100
+ const uncovered = caps.filter(c => !covered.has(c));
101
+
102
+ if (!jsonOut) ok(`${scenarioFiles.length} scenario file(s) found`);
103
+
104
+ if (uncovered.length > 0 && requireCoverage) {
105
+ uncovered.forEach(c => {
106
+ if (!jsonOut) fail(`"${c}" has no scenario coverage`, `Add to capabilitiesCovered in a scenario file`);
107
+ errors.push(`"${c}" uncovered`);
108
+ });
109
+ } else if (!jsonOut) {
110
+ ok(`All capabilities covered by scenarios`);
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ // ── CHANGELOG ────────────────────────────────────────────────────
117
+ if (!jsonOut) section("Changelog");
118
+ if (!fs.existsSync(changelogPath)) {
119
+ fail("inferno/CHANGELOG.md not found"); errors.push("CHANGELOG missing");
120
+ } else {
121
+ const txt = fs.readFileSync(changelogPath, "utf8");
122
+ if (!/##\s+Unreleased/i.test(txt)) {
123
+ fail("Missing '## Unreleased' section", "Add it to inferno/CHANGELOG.md");
124
+ errors.push("CHANGELOG missing Unreleased");
125
+ } else if (!jsonOut) {
126
+ ok("CHANGELOG.md has ## Unreleased section");
127
+ }
128
+ }
129
+
130
+ // ── doc-gate ─────────────────────────────────────────────────────
131
+ if (!skipDocGate) {
132
+ if (!jsonOut) section("Doc Gate");
133
+ await docGateCommand({ silent: jsonOut, captureExit: true }).catch(() => {
134
+ errors.push("doc-gate failed");
135
+ });
136
+ }
137
+
138
+ // ── Summary ──────────────────────────────────────────────────────
139
+ if (jsonOut) {
140
+ console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
141
+ if (errors.length > 0) process.exit(1);
142
+ return;
143
+ }
144
+
145
+ console.log();
146
+ if (errors.length > 0) {
147
+ console.log(" " + red(`✘ check failed — ${errors.length} error(s)\n`));
148
+ process.exit(1);
149
+ } else if (warnings.length > 0) {
150
+ console.log(" " + yellow(`⚠ check passed with ${warnings.length} warning(s)\n`));
151
+ } else {
152
+ done("check passed — everything is in sync");
153
+ }
154
+ }
@@ -0,0 +1,57 @@
1
+ import { execSync } from "node:child_process";
2
+ import { ok, fail, warn, info, gray } from "../ui/output.mjs";
3
+
4
+ function sh(cmd) {
5
+ return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString("utf8").trim();
6
+ }
7
+
8
+ const CODE_PREFIXES = [
9
+ "src/", "frontend/", "backend/",
10
+ "app/", "pages/", "components/",
11
+ "Controllers/", "Services/", "Endpoints/",
12
+ "lib/", "api/", "server/"
13
+ ];
14
+
15
+ export async function docGateCommand(opts = {}) {
16
+ const silent = opts?.silent || false;
17
+ const captureExit = opts?.captureExit || false;
18
+ const base = process.env.BASE_SHA || "HEAD~1";
19
+ const head = process.env.HEAD_SHA || "HEAD";
20
+
21
+ let files = [];
22
+ try {
23
+ const out = sh(`git diff --name-only ${base}..${head}`);
24
+ files = out ? out.split("\n").filter(Boolean) : [];
25
+ } catch {
26
+ if (!silent) info(gray("doc-gate skipped (no git available)"));
27
+ return;
28
+ }
29
+
30
+ if (files.length === 0) {
31
+ if (!silent) ok("doc-gate: no changed files");
32
+ return;
33
+ }
34
+
35
+ const changedCode = files.some(f =>
36
+ CODE_PREFIXES.some(p => f.startsWith(p) || f.includes("/" + p))
37
+ );
38
+ const changedInferno = files.some(f => f.startsWith("inferno/"));
39
+
40
+ if (changedCode && !changedInferno) {
41
+ if (!silent) {
42
+ fail(
43
+ "Code changed but inferno/ was NOT updated",
44
+ "Update at least one file in inferno/ before committing"
45
+ );
46
+ const codeFiles = files.filter(f => CODE_PREFIXES.some(p => f.startsWith(p))).slice(0, 5);
47
+ if (codeFiles.length) {
48
+ console.log();
49
+ codeFiles.forEach(f => console.log(" " + gray("• " + f)));
50
+ }
51
+ }
52
+ if (captureExit) throw new Error("doc-gate failed");
53
+ process.exit(1);
54
+ }
55
+
56
+ if (!silent) ok("doc-gate: docs are up to date");
57
+ }
@@ -0,0 +1,193 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as readline from "node:readline";
4
+ import { fileURLToPath } from "node:url";
5
+ import { header, ok, warn, done, nextSteps, cyan, yellow, gray } from "../ui/output.mjs";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ function getTemplatesRoot() {
10
+ return path.resolve(__dirname, "../../templates");
11
+ }
12
+
13
+ function ask(rl, question, defaultVal = "") {
14
+ return new Promise(resolve => {
15
+ const hint = defaultVal ? gray(` (${defaultVal})`) : "";
16
+ rl.question(` ${question}${hint}: `, answer => {
17
+ resolve(answer.trim() || defaultVal);
18
+ });
19
+ });
20
+ }
21
+
22
+ function copyFile(src, dst, force) {
23
+ if (fs.existsSync(dst) && !force) {
24
+ warn("Skipped (exists): " + path.relative(process.cwd(), dst));
25
+ return false;
26
+ }
27
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
28
+ fs.copyFileSync(src, dst);
29
+ ok("Created: " + cyan(path.relative(process.cwd(), dst)));
30
+ return true;
31
+ }
32
+
33
+ function copyDirDeep(srcDir, dstDir, force) {
34
+ fs.mkdirSync(dstDir, { recursive: true });
35
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
36
+ const src = path.join(srcDir, entry.name);
37
+ const dst = path.join(dstDir, entry.name);
38
+ if (entry.isDirectory()) copyDirDeep(src, dst, force);
39
+ else copyFile(src, dst, force);
40
+ }
41
+ }
42
+
43
+ function upsertScripts(cwd) {
44
+ const pkgPath = path.join(cwd, "package.json");
45
+ if (!fs.existsSync(pkgPath)) return;
46
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
47
+ pkg.scripts = pkg.scripts || {};
48
+ let changed = false;
49
+ const toAdd = {
50
+ "inferno:check": "infernoflow check",
51
+ "inferno:status": "infernoflow status",
52
+ "inferno:gate": "infernoflow doc-gate"
53
+ };
54
+ for (const [k, v] of Object.entries(toAdd)) {
55
+ if (!pkg.scripts[k]) { pkg.scripts[k] = v; changed = true; }
56
+ }
57
+ if (changed) {
58
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
59
+ ok("Updated " + cyan("package.json") + " scripts");
60
+ }
61
+ }
62
+
63
+ function detectProjectName(cwd) {
64
+ const pkgPath = path.join(cwd, "package.json");
65
+ if (fs.existsSync(pkgPath)) {
66
+ try {
67
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
68
+ if (pkg.name) return pkg.name.replace(/[^a-z0-9_-]/gi, "_");
69
+ } catch {}
70
+ }
71
+ return path.basename(cwd);
72
+ }
73
+
74
+ function writeContract(contractPath, policyId, capabilities) {
75
+ const contract = {
76
+ policyId,
77
+ policyVersion: 1,
78
+ capabilities,
79
+ rules: {
80
+ docsRequiredOnCapabilityChange: true,
81
+ requireScenarioForEachCapability: true,
82
+ requireChangelogOnCapabilityChange: true
83
+ }
84
+ };
85
+ fs.writeFileSync(contractPath, JSON.stringify(contract, null, 2) + "\n");
86
+ }
87
+
88
+ function writeCapabilities(capsPath, capabilities) {
89
+ const registry = {
90
+ schemaVersion: 1,
91
+ capabilities: capabilities.map(id => ({
92
+ id,
93
+ title: id.replace(/([A-Z])/g, " $1").trim(),
94
+ since: "0.1.0"
95
+ }))
96
+ };
97
+ fs.writeFileSync(capsPath, JSON.stringify(registry, null, 2) + "\n");
98
+ }
99
+
100
+ function writeScenario(scenariosDir, capabilities) {
101
+ fs.mkdirSync(scenariosDir, { recursive: true });
102
+ const scenario = {
103
+ scenarioId: "happy_path",
104
+ description: "Basic happy-path flow covering all capabilities",
105
+ capabilitiesCovered: capabilities,
106
+ steps: capabilities.map(c => ({
107
+ action: c,
108
+ expect: `${c} works as expected`
109
+ }))
110
+ };
111
+ fs.writeFileSync(
112
+ path.join(scenariosDir, "happy_path.json"),
113
+ JSON.stringify(scenario, null, 2) + "\n"
114
+ );
115
+ }
116
+
117
+ function writeChangelog(changelogPath, policyId) {
118
+ const content = `# Changelog — ${policyId}
119
+
120
+ ## Unreleased
121
+
122
+ - Initial capabilities defined
123
+
124
+ ## 0.1.0 — Initial release
125
+
126
+ - Project initialized with infernoflow
127
+ `;
128
+ fs.writeFileSync(changelogPath, content);
129
+ }
130
+
131
+ export async function initCommand(args) {
132
+ const cwd = process.cwd();
133
+ const force = args.includes("--force") || args.includes("-f");
134
+ const yes = args.includes("--yes") || args.includes("-y");
135
+
136
+ header("init");
137
+
138
+ const infernoDir = path.join(cwd, "inferno");
139
+ if (fs.existsSync(infernoDir) && !force) {
140
+ warn("inferno/ already exists. Use --force to overwrite.");
141
+ console.log();
142
+ process.exit(0);
143
+ }
144
+
145
+ const detectedName = detectProjectName(cwd);
146
+ const defaultCaps = "CreateTask, ReadTasks, UpdateTask, ToggleComplete, DeleteTask";
147
+
148
+ let policyId = detectedName;
149
+ let capabilities = defaultCaps.split(",").map(c => c.trim());
150
+
151
+ if (!yes) {
152
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
153
+ console.log(gray(" Press Enter to accept defaults\n"));
154
+ policyId = await ask(rl, "Project / policy name", detectedName);
155
+ const capsRaw = await ask(rl, "Capabilities (comma-separated)", defaultCaps);
156
+ capabilities = capsRaw.split(",").map(c => c.trim()).filter(Boolean);
157
+ rl.close();
158
+ console.log();
159
+ }
160
+
161
+ // Write files
162
+ fs.mkdirSync(infernoDir, { recursive: true });
163
+
164
+ writeContract(path.join(infernoDir, "contract.json"), policyId, capabilities);
165
+ ok("Created: " + cyan("inferno/contract.json"));
166
+
167
+ writeCapabilities(path.join(infernoDir, "capabilities.json"), capabilities);
168
+ ok("Created: " + cyan("inferno/capabilities.json"));
169
+
170
+ writeScenario(path.join(infernoDir, "scenarios"), capabilities);
171
+ ok("Created: " + cyan("inferno/scenarios/happy_path.json"));
172
+
173
+ writeChangelog(path.join(infernoDir, "CHANGELOG.md"), policyId);
174
+ ok("Created: " + cyan("inferno/CHANGELOG.md"));
175
+
176
+ // Copy doc-gate script
177
+ const templates = getTemplatesRoot();
178
+ const srcScript = path.join(templates, "scripts", "inferno-doc-gate.mjs");
179
+ const dstScript = path.join(cwd, "scripts", "inferno-doc-gate.mjs");
180
+ copyFile(srcScript, dstScript, force);
181
+
182
+ upsertScripts(cwd);
183
+
184
+ done("infernoflow initialized!");
185
+
186
+ nextSteps([
187
+ cyan("infernoflow status") + " — see your contract at a glance",
188
+ cyan("infernoflow check") + " — validate everything",
189
+ "Edit " + yellow("inferno/capabilities.json") + " to describe each capability in detail",
190
+ "Add more " + yellow("inferno/scenarios/*.json") + " files for edge cases",
191
+ "Add " + cyan("inferno:check") + " to your CI pipeline"
192
+ ]);
193
+ }
@@ -0,0 +1,133 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { header, ok, fail, warn, info, section, bold, cyan, yellow, gray, green, red, white } from "../ui/output.mjs";
4
+
5
+ function timeAgo(ms) {
6
+ const s = Math.floor((Date.now() - ms) / 1000);
7
+ if (s < 60) return "just now";
8
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
9
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
10
+ return `${Math.floor(s / 86400)}d ago`;
11
+ }
12
+
13
+ function getCoverage(scenariosDir, caps) {
14
+ const covered = new Set();
15
+ if (fs.existsSync(scenariosDir)) {
16
+ for (const f of fs.readdirSync(scenariosDir).filter(f => f.endsWith(".json"))) {
17
+ try {
18
+ const s = JSON.parse(fs.readFileSync(path.join(scenariosDir, f), "utf8"));
19
+ (s.capabilitiesCovered || []).forEach(c => covered.add(c));
20
+ } catch {}
21
+ }
22
+ }
23
+ return { covered: caps.filter(c => covered.has(c)), uncovered: caps.filter(c => !covered.has(c)) };
24
+ }
25
+
26
+ export async function statusCommand() {
27
+ const cwd = process.cwd();
28
+ const infernoDir = path.join(cwd, "inferno");
29
+
30
+ header("status");
31
+
32
+ if (!fs.existsSync(infernoDir)) {
33
+ fail("inferno/ not found", `Run: infernoflow init`);
34
+ console.log();
35
+ process.exit(1);
36
+ }
37
+
38
+ const contractPath = path.join(infernoDir, "contract.json");
39
+ if (!fs.existsSync(contractPath)) {
40
+ fail("contract.json not found");
41
+ console.log();
42
+ process.exit(1);
43
+ }
44
+
45
+ const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
46
+ const caps = contract.capabilities || [];
47
+ const stat = fs.statSync(contractPath);
48
+ const scenariosDir = path.join(infernoDir, "scenarios");
49
+ const changelogPath = path.join(infernoDir, "CHANGELOG.md");
50
+ const capsPath = path.join(infernoDir, "capabilities.json");
51
+
52
+ // ── Project ─────────────────────────────────────────────────────
53
+ section("Project");
54
+ console.log(` ${gray("policy")} ${bold(contract.policyId || "—")}`);
55
+ console.log(` ${gray("version")} ${bold("v" + (contract.policyVersion || "?"))}`);
56
+ console.log(` ${gray("last change")} ${gray(timeAgo(stat.mtimeMs))}`);
57
+
58
+ // ── Capabilities ─────────────────────────────────────────────────
59
+ section(`Capabilities ${gray("(" + caps.length + ")")}`);
60
+
61
+ let capsRegistry = {};
62
+ if (fs.existsSync(capsPath)) {
63
+ try {
64
+ const reg = JSON.parse(fs.readFileSync(capsPath, "utf8"));
65
+ (reg.capabilities || []).forEach(c => { capsRegistry[c.id] = c; });
66
+ } catch {}
67
+ }
68
+
69
+ const { covered, uncovered } = getCoverage(scenariosDir, caps);
70
+
71
+ caps.forEach(cap => {
72
+ const reg = capsRegistry[cap];
73
+ const hasCoverage = covered.includes(cap);
74
+ const icon = hasCoverage ? green("✔") : red("✘");
75
+ const title = reg?.title ? gray(` — ${reg.title}`) : "";
76
+ const since = reg?.since ? gray(` [${reg.since}]`) : "";
77
+ console.log(` ${icon} ${white(cap)}${title}${since}`);
78
+ });
79
+
80
+ if (uncovered.length > 0) {
81
+ console.log(`\n ${yellow("⚠")} ${uncovered.length} capability(ies) lack scenario coverage`);
82
+ } else {
83
+ console.log(`\n ${green("✔")} All capabilities have scenario coverage`);
84
+ }
85
+
86
+ // ── Scenarios ─────────────────────────────────────────────────────
87
+ section("Scenarios");
88
+ if (fs.existsSync(scenariosDir)) {
89
+ const files = fs.readdirSync(scenariosDir).filter(f => f.endsWith(".json"));
90
+ if (files.length === 0) {
91
+ warn("No scenario files — add .json files to inferno/scenarios/");
92
+ } else {
93
+ files.forEach(f => {
94
+ try {
95
+ const s = JSON.parse(fs.readFileSync(path.join(scenariosDir, f), "utf8"));
96
+ const steps = s.steps?.length || 0;
97
+ const capCount = (s.capabilitiesCovered || []).length;
98
+ console.log(` ${green("✔")} ${cyan(f)} ${gray(`— ${steps} steps, ${capCount} caps covered`)}`);
99
+ } catch {
100
+ console.log(` ${red("✘")} ${cyan(f)} ${gray("— invalid JSON")}`);
101
+ }
102
+ });
103
+ }
104
+ } else {
105
+ warn("scenarios/ directory not found");
106
+ }
107
+
108
+ // ── Changelog ─────────────────────────────────────────────────────
109
+ section("Changelog");
110
+ if (fs.existsSync(changelogPath)) {
111
+ const txt = fs.readFileSync(changelogPath, "utf8");
112
+ if (/##\s+Unreleased/i.test(txt)) {
113
+ ok("Has ## Unreleased section");
114
+ } else {
115
+ fail("Missing ## Unreleased section");
116
+ }
117
+ const sections = txt.split("\n").filter(l => /^##\s/.test(l)).slice(0, 3);
118
+ sections.forEach(l => console.log(` ${gray(l)}`));
119
+ } else {
120
+ fail("inferno/CHANGELOG.md not found");
121
+ }
122
+
123
+ // ── Health ────────────────────────────────────────────────────────
124
+ console.log();
125
+ const hasChangelog = fs.existsSync(changelogPath) && /##\s+Unreleased/i.test(fs.readFileSync(changelogPath, "utf8"));
126
+ const allGood = uncovered.length === 0 && hasChangelog;
127
+ if (allGood) {
128
+ console.log(` ${green("●")} ${bold(green("ready"))} ${gray("— run infernoflow check for full validation")}`);
129
+ } else {
130
+ console.log(` ${yellow("●")} ${bold(yellow("needs attention"))} ${gray("— run infernoflow check for details")}`);
131
+ }
132
+ console.log();
133
+ }
@@ -0,0 +1,72 @@
1
+ // Zero-dependency color/output utilities using ANSI codes
2
+
3
+ const c = {
4
+ reset: "\x1b[0m",
5
+ bold: "\x1b[1m",
6
+ red: "\x1b[31m",
7
+ green: "\x1b[32m",
8
+ yellow: "\x1b[33m",
9
+ blue: "\x1b[34m",
10
+ cyan: "\x1b[36m",
11
+ white: "\x1b[37m",
12
+ gray: "\x1b[90m",
13
+ orange: "\x1b[38;5;208m",
14
+ };
15
+
16
+ const noColor = !process.stdout.isTTY || process.env.NO_COLOR;
17
+
18
+ function paint(code, text) {
19
+ if (noColor) return text;
20
+ return `${code}${text}${c.reset}`;
21
+ }
22
+
23
+ export const bold = t => paint(c.bold, t);
24
+ export const red = t => paint(c.red, t);
25
+ export const green = t => paint(c.green, t);
26
+ export const yellow = t => paint(c.yellow, t);
27
+ export const cyan = t => paint(c.cyan, t);
28
+ export const gray = t => paint(c.gray, t);
29
+ export const white = t => paint(c.white, t);
30
+ export const orange = t => paint(c.orange, t);
31
+ export const boldRed = t => paint(c.bold + c.red, t);
32
+ export const boldGreen = t => paint(c.bold + c.green, t);
33
+ export const boldYellow = t => paint(c.bold + c.yellow, t);
34
+ export const boldOrange = t => paint(c.bold + c.orange, t);
35
+
36
+ function strip(str) {
37
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
38
+ }
39
+
40
+ export function header(text) {
41
+ const title = boldOrange("🔥 infernoflow") + gray(" — " + text);
42
+ console.log("\n" + title);
43
+ console.log(gray("─".repeat(50)));
44
+ }
45
+
46
+ export function ok(msg) { console.log(" " + green("✔") + " " + msg); }
47
+ export function fail(msg, hint) {
48
+ console.log(" " + red("✘") + " " + red(msg));
49
+ if (hint) console.log(" " + gray("→ " + hint));
50
+ }
51
+ export function warn(msg) { console.log(" " + yellow("⚠") + " " + yellow(msg)); }
52
+ export function info(msg) { console.log(" " + cyan("ℹ") + " " + msg); }
53
+ export function section(title) { console.log("\n" + bold(white(title))); }
54
+
55
+ export function done(msg) {
56
+ console.log("\n" + boldGreen("✨ " + msg) + "\n");
57
+ }
58
+
59
+ export function nextSteps(steps) {
60
+ console.log(bold("Next steps:"));
61
+ steps.forEach((s, i) => {
62
+ console.log(" " + gray((i + 1) + ".") + " " + s);
63
+ });
64
+ console.log();
65
+ }
66
+
67
+ export function errorAndExit(msg, hint) {
68
+ console.error("\n" + boldRed("Error: ") + red(msg));
69
+ if (hint) console.error(gray(" → " + hint));
70
+ console.error();
71
+ process.exit(1);
72
+ }
@@ -0,0 +1,20 @@
1
+ // Zero-dependency interactive prompts using readline
2
+
3
+ import * as readline from "node:readline";
4
+
5
+ function ask(question, defaultVal = "") {
6
+ return new Promise(resolve => {
7
+ const hint = defaultVal ? ` (${defaultVal})` : "";
8
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
9
+ rl.question(` ${question}${hint}: `, answer => {
10
+ rl.close();
11
+ resolve(answer.trim() || defaultVal);
12
+ });
13
+ });
14
+ }
15
+
16
+ export async function promptInit() {
17
+ const policyId = await ask("Project / policy name", process.env._INFERNO_DEFAULT_POLICY || "my-project");
18
+ const caps = await ask("Capabilities (comma-separated)", "CreateTask, ReadTasks, UpdateTask, DeleteTask");
19
+ return { policyId, capabilities: caps.split(",").map(c => c.trim()).filter(Boolean) };
20
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "infernoflow",
3
+ "version": "0.1.0",
4
+ "description": "The forge for liquid code — keep capabilities, contracts, and docs in sync.",
5
+ "type": "module",
6
+ "bin": {
7
+ "infernoflow": "./bin/infernoflow.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "lib",
15
+ "templates",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "node bin/infernoflow.mjs --help"
20
+ },
21
+ "keywords": ["cli", "capabilities", "contract", "documentation", "ai", "liquid-code"],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "dependencies": {}
25
+ }
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Initial capabilities: CreateItem, ReadItems, UpdateItem, DeleteItem
6
+
7
+ ## 0.1.0 — Initial release
8
+
9
+ - Project initialized with infernoflow
@@ -0,0 +1,9 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "capabilities": [
4
+ { "id": "CreateItem", "title": "Create item", "since": "0.1.0" },
5
+ { "id": "ReadItems", "title": "List items", "since": "0.1.0" },
6
+ { "id": "UpdateItem", "title": "Edit item", "since": "0.1.0" },
7
+ { "id": "DeleteItem", "title": "Delete item", "since": "0.1.0" }
8
+ ]
9
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "policyId": "my_project",
3
+ "policyVersion": 1,
4
+ "capabilities": [
5
+ "CreateItem",
6
+ "ReadItems",
7
+ "UpdateItem",
8
+ "DeleteItem"
9
+ ],
10
+ "rules": {
11
+ "docsRequiredOnCapabilityChange": true,
12
+ "requireScenarioForEachCapability": true,
13
+ "requireChangelogOnCapabilityChange": true
14
+ }
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "scenarioId": "happy_path",
3
+ "description": "Basic CRUD flow",
4
+ "capabilitiesCovered": [
5
+ "CreateItem",
6
+ "ReadItems",
7
+ "UpdateItem",
8
+ "DeleteItem"
9
+ ],
10
+ "steps": [
11
+ { "action": "CreateItem", "expect": "A new item appears in the list" },
12
+ { "action": "ReadItems", "expect": "All items are visible" },
13
+ { "action": "UpdateItem", "expect": "Item content is updated" },
14
+ { "action": "DeleteItem", "expect": "Item is removed from the list" }
15
+ ]
16
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // inferno-doc-gate.mjs
3
+ // Run as a git pre-push or CI step:
4
+ // node scripts/inferno-doc-gate.mjs
5
+ import { execSync } from "node:child_process";
6
+
7
+ const CODE_PREFIXES = ["src/", "frontend/", "backend/", "app/", "pages/", "components/", "lib/", "api/"];
8
+ const INFERNO_PATTERNS = [f => f.startsWith("inferno/")];
9
+
10
+ function sh(cmd) {
11
+ return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString("utf8").trim();
12
+ }
13
+
14
+ const base = process.env.BASE_SHA || "HEAD~1";
15
+ const head = process.env.HEAD_SHA || "HEAD";
16
+
17
+ let files = [];
18
+ try {
19
+ const out = sh(`git diff --name-only ${base}..${head}`);
20
+ files = out ? out.split("\n").filter(Boolean) : [];
21
+ } catch {
22
+ console.log("[inferno doc-gate] skipped (git unavailable)");
23
+ process.exit(0);
24
+ }
25
+
26
+ const changedCode = files.some(f => CODE_PREFIXES.some(p => f.startsWith(p)));
27
+ const changedInferno = files.some(f => INFERNO_PATTERNS.some(fn => fn(f)));
28
+
29
+ if (changedCode && !changedInferno) {
30
+ console.error("\n[inferno doc-gate] ✘ Code changed but inferno/ was NOT updated.");
31
+ console.error("Update at least one file in inferno/ before pushing.\n");
32
+ process.exit(1);
33
+ }
34
+ console.log("[inferno doc-gate] ✔ OK");