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 +105 -0
- package/bin/infernoflow.mjs +88 -0
- package/lib/commands/check.mjs +154 -0
- package/lib/commands/docGate.mjs +57 -0
- package/lib/commands/init.mjs +193 -0
- package/lib/commands/status.mjs +133 -0
- package/lib/ui/output.mjs +72 -0
- package/lib/ui/prompts.mjs +20 -0
- package/package.json +25 -0
- package/templates/inferno/CHANGELOG.md +9 -0
- package/templates/inferno/capabilities.json +9 -0
- package/templates/inferno/contract.json +15 -0
- package/templates/inferno/scenarios/happy_path.json +16 -0
- package/templates/scripts/inferno-doc-gate.mjs +34 -0
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
|
+
{
|
|
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");
|