delimit-cli 2.3.2 → 2.4.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/CHANGELOG.md +33 -0
- package/README.md +81 -109
- package/adapters/codex-forge.js +107 -0
- package/adapters/codex-jamsons.js +142 -0
- package/adapters/codex-security.js +94 -0
- package/adapters/gemini-forge.js +109 -0
- package/bin/delimit-cli.js +44 -2
- package/package.json +2 -2
- package/tests/cli.test.js +359 -0
- package/tests/fixtures/openapi-changed.yaml +56 -0
- package/tests/fixtures/openapi.yaml +87 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [2.4.0] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- 29 real CLI tests covering init, lint, diff, explain, doctor, presets, and error handling
|
|
7
|
+
- Auto-write GitHub Actions workflow file on `delimit init`
|
|
8
|
+
|
|
9
|
+
### Improved
|
|
10
|
+
- Version now read from package.json instead of hardcoded
|
|
11
|
+
- Error handling across all commands
|
|
12
|
+
|
|
13
|
+
## [2.3.2] - 2026-03-09
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Clean --help output (legacy commands hidden)
|
|
17
|
+
- File existence checks before lint/diff operations
|
|
18
|
+
- --policy flag accepts preset names (strict, default, relaxed)
|
|
19
|
+
|
|
20
|
+
## [2.3.0] - 2026-03-07
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Policy presets: strict (all errors), default (balanced), relaxed (warnings only)
|
|
24
|
+
- `delimit doctor` command for environment diagnostics
|
|
25
|
+
- `delimit explain` command with 7 output templates
|
|
26
|
+
|
|
27
|
+
## [2.0.0] - 2026-02-28
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Deterministic diff engine (23 change types, 10 breaking)
|
|
31
|
+
- Policy enforcement with exit code 1 on violations
|
|
32
|
+
- Semver classification (MAJOR/MINOR/PATCH/NONE)
|
|
33
|
+
- Zero-Spec extraction for FastAPI, NestJS, Express
|
package/README.md
CHANGED
|
@@ -1,141 +1,108 @@
|
|
|
1
|
-
# delimit
|
|
1
|
+
# delimit
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Deterministic diff engine + policy enforcement + semver classification for OpenAPI specs. The independent successor to Optic.
|
|
3
|
+
Catch breaking API changes before they ship.
|
|
6
4
|
|
|
7
5
|
[](https://www.npmjs.com/package/delimit-cli)
|
|
6
|
+
[](https://github.com/marketplace/actions/delimit-api-governance)
|
|
8
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](#)
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Deterministic diff engine for OpenAPI specs. Detects breaking changes, classifies semver, enforces policy, and posts PR comments with migration guides. No API keys, no external services.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
npm install -g delimit-cli
|
|
14
|
-
```
|
|
12
|
+
---
|
|
15
13
|
|
|
16
|
-
##
|
|
14
|
+
## GitHub Action (recommended)
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
# 1. Initialize with a policy preset
|
|
20
|
-
delimit init --preset default
|
|
16
|
+
Add `.github/workflows/api-check.yml`:
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
```yaml
|
|
19
|
+
name: API Contract Check
|
|
20
|
+
on: pull_request
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
jobs:
|
|
23
|
+
delimit:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
permissions:
|
|
26
|
+
pull-requests: write
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
with:
|
|
31
|
+
ref: ${{ github.event.pull_request.base.sha }}
|
|
32
|
+
path: base
|
|
33
|
+
- uses: delimit-ai/delimit-action@v1
|
|
34
|
+
with:
|
|
35
|
+
old_spec: base/api/openapi.yaml
|
|
36
|
+
new_spec: api/openapi.yaml
|
|
27
37
|
```
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Delimit deterministically detects 23 types of API changes, including 10 breaking patterns:
|
|
32
|
-
|
|
33
|
-
- Endpoint or method removal
|
|
34
|
-
- Required parameter addition
|
|
35
|
-
- Response field removal
|
|
36
|
-
- Type changes
|
|
37
|
-
- Enum value removal
|
|
38
|
-
- And more
|
|
39
|
-
|
|
40
|
-
Every change is classified as `MAJOR`, `MINOR`, `PATCH`, or `NONE` per semver.
|
|
41
|
-
|
|
42
|
-
## Commands
|
|
43
|
-
|
|
44
|
-
| Command | Description |
|
|
45
|
-
|---------|-------------|
|
|
46
|
-
| `delimit init` | Create `.delimit/policies.yml` with a policy preset |
|
|
47
|
-
| `delimit lint <old> <new>` | Diff + policy check — returns exit code 1 on violations |
|
|
48
|
-
| `delimit diff <old> <new>` | Raw diff with `[BREAKING]`/`[safe]` tags |
|
|
49
|
-
| `delimit explain <old> <new>` | Human-readable change explanation |
|
|
39
|
+
Runs in **advisory mode** by default -- posts a PR comment but never fails your build. Set `mode: enforce` when you are ready to block merges on breaking changes.
|
|
50
40
|
|
|
51
|
-
|
|
41
|
+
---
|
|
52
42
|
|
|
53
|
-
|
|
43
|
+
## CLI
|
|
54
44
|
|
|
55
45
|
```bash
|
|
56
|
-
delimit
|
|
57
|
-
delimit
|
|
58
|
-
delimit
|
|
46
|
+
npx delimit-cli lint api/openapi.yaml
|
|
47
|
+
npx delimit-cli diff old.yaml new.yaml
|
|
48
|
+
npx delimit-cli explain old.yaml new.yaml --template migration
|
|
59
49
|
```
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
|--------|-----------------|--------------|---------------|
|
|
63
|
-
| `strict` | Error (blocks) | Error (blocks) | Error (blocks) |
|
|
64
|
-
| `default` | Error (blocks) | Warning | Error (blocks) |
|
|
65
|
-
| `relaxed` | Warning | Warning | Info |
|
|
66
|
-
|
|
67
|
-
Pass a preset directly to lint:
|
|
51
|
+
Or install globally:
|
|
68
52
|
|
|
69
53
|
```bash
|
|
70
|
-
|
|
54
|
+
npm install -g delimit-cli
|
|
55
|
+
delimit init --preset default
|
|
56
|
+
delimit lint old.yaml new.yaml
|
|
71
57
|
```
|
|
72
58
|
|
|
73
|
-
|
|
59
|
+
### Commands
|
|
74
60
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
delimit
|
|
61
|
+
| Command | What it does |
|
|
62
|
+
|---------|-------------|
|
|
63
|
+
| `delimit init [--preset]` | Create `.delimit/policies.yml` |
|
|
64
|
+
| `delimit lint <old> <new>` | Diff + policy check. Exit 1 on violations. |
|
|
65
|
+
| `delimit diff <old> <new>` | Raw diff with `[BREAKING]` / `[safe]` tags |
|
|
66
|
+
| `delimit explain <old> <new>` | Human-readable explanation (7 templates) |
|
|
78
67
|
|
|
79
|
-
|
|
80
|
-
delimit explain old.yaml new.yaml -t migration
|
|
81
|
-
delimit explain old.yaml new.yaml -t pr_comment
|
|
82
|
-
delimit explain old.yaml new.yaml -t changelog
|
|
68
|
+
---
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
delimit lint old.yaml new.yaml --json
|
|
86
|
-
```
|
|
70
|
+
## What it catches
|
|
87
71
|
|
|
88
|
-
|
|
72
|
+
10 breaking change types, detected deterministically:
|
|
89
73
|
|
|
90
|
-
|
|
|
91
|
-
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
| `
|
|
97
|
-
|
|
|
98
|
-
| `
|
|
74
|
+
| Breaking change | Example |
|
|
75
|
+
|----------------|---------|
|
|
76
|
+
| Endpoint removed | `DELETE /users/{id}` path deleted |
|
|
77
|
+
| Method removed | `PATCH` dropped from `/orders` |
|
|
78
|
+
| Required parameter added | New required query param on existing endpoint |
|
|
79
|
+
| Parameter removed | `?filter` param deleted |
|
|
80
|
+
| Response removed | `200` response code dropped |
|
|
81
|
+
| Required field added | New required field in request body |
|
|
82
|
+
| Response field removed | `email` field removed from response |
|
|
83
|
+
| Type changed | `age` changed from `string` to `integer` |
|
|
84
|
+
| Format changed | `date` changed to `date-time` |
|
|
85
|
+
| Enum value removed | `status: "pending"` no longer allowed |
|
|
99
86
|
|
|
100
|
-
|
|
87
|
+
Plus 7 non-breaking types (endpoint added, optional field added, etc.) for full change visibility. Every change is classified as `MAJOR`, `MINOR`, `PATCH`, or `NONE`.
|
|
101
88
|
|
|
102
|
-
|
|
89
|
+
---
|
|
103
90
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
permissions:
|
|
111
|
-
contents: read
|
|
112
|
-
pull-requests: write
|
|
113
|
-
jobs:
|
|
114
|
-
api-governance:
|
|
115
|
-
runs-on: ubuntu-latest
|
|
116
|
-
steps:
|
|
117
|
-
- uses: actions/checkout@v4
|
|
118
|
-
- uses: actions/checkout@v4
|
|
119
|
-
with:
|
|
120
|
-
ref: ${{ github.event.pull_request.base.sha }}
|
|
121
|
-
path: _base
|
|
122
|
-
- uses: delimit-ai/delimit@v1
|
|
123
|
-
with:
|
|
124
|
-
old_spec: _base/path/to/openapi.yaml
|
|
125
|
-
new_spec: path/to/openapi.yaml
|
|
126
|
-
mode: advisory # or 'enforce' to block PRs
|
|
91
|
+
## Policy presets
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
delimit init --preset strict # All breaking changes are errors. For public/payment APIs.
|
|
95
|
+
delimit init --preset default # Breaking changes error, type changes warn. For most teams.
|
|
96
|
+
delimit init --preset relaxed # Everything is a warning. For internal APIs and prototyping.
|
|
127
97
|
```
|
|
128
98
|
|
|
129
|
-
|
|
130
|
-
- Semver badge (`MAJOR` / `MINOR` / `PATCH`)
|
|
131
|
-
- Violation table with severity
|
|
132
|
-
- Expandable migration guide for breaking changes
|
|
99
|
+
Or pass inline: `delimit lint --policy strict old.yaml new.yaml`
|
|
133
100
|
|
|
134
|
-
|
|
101
|
+
---
|
|
135
102
|
|
|
136
|
-
## Custom
|
|
103
|
+
## Custom policies
|
|
137
104
|
|
|
138
|
-
Create `.delimit/policies.yml
|
|
105
|
+
Create `.delimit/policies.yml`:
|
|
139
106
|
|
|
140
107
|
```yaml
|
|
141
108
|
override_defaults: false
|
|
@@ -151,17 +118,22 @@ rules:
|
|
|
151
118
|
message: "V1 API is frozen. Make changes in V2."
|
|
152
119
|
```
|
|
153
120
|
|
|
154
|
-
|
|
121
|
+
---
|
|
155
122
|
|
|
156
|
-
|
|
123
|
+
## Supported formats
|
|
124
|
+
|
|
125
|
+
- OpenAPI 3.0 and 3.1
|
|
157
126
|
- Swagger 2.0
|
|
158
|
-
- YAML and JSON
|
|
127
|
+
- YAML and JSON
|
|
128
|
+
|
|
129
|
+
---
|
|
159
130
|
|
|
160
131
|
## Links
|
|
161
132
|
|
|
162
|
-
- [
|
|
163
|
-
- [GitHub](https://github.com/delimit-
|
|
164
|
-
- [
|
|
133
|
+
- [delimit.ai](https://delimit.ai) -- Project home
|
|
134
|
+
- [GitHub Action on Marketplace](https://github.com/marketplace/actions/delimit-api-governance) -- Install in one click
|
|
135
|
+
- [delimit-cli on npm](https://www.npmjs.com/package/delimit-cli) -- CLI package
|
|
136
|
+
- [Quickstart repo](https://github.com/delimit-ai/delimit-quickstart) -- Try it in 2 minutes
|
|
165
137
|
|
|
166
138
|
## License
|
|
167
139
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Delimit™ Codex Forge Adapter
|
|
4
|
+
* Layer: Forge (execution governance)
|
|
5
|
+
* Surfaces test failures, deploy state, and release gates before accepting code.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const axios = require('axios');
|
|
11
|
+
const AGENT_URL = `http://127.0.0.1:${process.env.DELIMIT_AGENT_PORT || 7823}`;
|
|
12
|
+
|
|
13
|
+
class DelimitCodexForge {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.name = 'delimit-forge';
|
|
16
|
+
this.version = '1.0.0';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Before accepting code: check test gate and deploy state.
|
|
21
|
+
*/
|
|
22
|
+
async onBeforeSuggestion(context) {
|
|
23
|
+
const [testState, deployState] = await Promise.allSettled([
|
|
24
|
+
this._getTestGate(),
|
|
25
|
+
this._getDeployState(),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const warnings = [];
|
|
29
|
+
|
|
30
|
+
if (testState.status === 'fulfilled' && testState.value.failing > 0) {
|
|
31
|
+
warnings.push(`[FORGE] ${testState.value.failing} test(s) failing — fix before accepting`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (deployState.status === 'fulfilled' && deployState.value.locked) {
|
|
35
|
+
warnings.push(`[FORGE] Deploy locked: ${deployState.value.reason || 'release in progress'}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (warnings.length > 0) {
|
|
39
|
+
return { allow: true, warning: warnings.join(' | ') };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { allow: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async onAfterAccept(context) {
|
|
46
|
+
try {
|
|
47
|
+
await axios.post(`${AGENT_URL}/audit`, {
|
|
48
|
+
action: 'forge_accept',
|
|
49
|
+
context,
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
}, { timeout: 2000 });
|
|
52
|
+
} catch (_) { /* silent */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async _getTestGate() {
|
|
56
|
+
const r = await axios.get(`${AGENT_URL}/test/status`, { timeout: 3000 });
|
|
57
|
+
return r.data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async _getDeployState() {
|
|
61
|
+
const r = await axios.get(`${AGENT_URL}/deploy/status`, { timeout: 3000 });
|
|
62
|
+
return r.data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async handleCommand(command, _args) {
|
|
66
|
+
const { execSync } = require('child_process');
|
|
67
|
+
const cmds = {
|
|
68
|
+
'forge': 'delimit status --layer=forge',
|
|
69
|
+
'tests': 'delimit test --summary',
|
|
70
|
+
'deploy': 'delimit deploy --status',
|
|
71
|
+
'release': 'delimit release --status',
|
|
72
|
+
};
|
|
73
|
+
if (cmds[command]) {
|
|
74
|
+
try {
|
|
75
|
+
return execSync(cmds[command], { timeout: 10000 }).toString();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return `[FORGE] Command failed: ${e.message}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns structured Forge context for consensus use.
|
|
84
|
+
*/
|
|
85
|
+
async getContext() {
|
|
86
|
+
const [tests, deploy, release] = await Promise.allSettled([
|
|
87
|
+
axios.get(`${AGENT_URL}/test/status`, { timeout: 3000 }),
|
|
88
|
+
axios.get(`${AGENT_URL}/deploy/status`, { timeout: 3000 }),
|
|
89
|
+
axios.get(`${AGENT_URL}/release/status`, { timeout: 3000 }),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
layer: 'forge',
|
|
94
|
+
tests: tests.status === 'fulfilled' ? tests.value.data : null,
|
|
95
|
+
deploy: deploy.status === 'fulfilled' ? deploy.value.data : null,
|
|
96
|
+
release: release.status === 'fulfilled' ? release.value.data : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
102
|
+
module.exports = new DelimitCodexForge();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof registerSkill === 'function') {
|
|
106
|
+
registerSkill(new DelimitCodexForge());
|
|
107
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Delimit™ Codex Jamsons Adapter
|
|
4
|
+
* Layer: Jamsons OS (strategy + operational governance)
|
|
5
|
+
* Injects portfolio context, logs decisions, surfaces venture/priority state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const axios = require('axios');
|
|
11
|
+
const AGENT_URL = `http://127.0.0.1:${process.env.DELIMIT_AGENT_PORT || 7823}`;
|
|
12
|
+
const JAMSONS_URL = `http://localhost:${process.env.JAMSONS_PORT || 8091}`;
|
|
13
|
+
|
|
14
|
+
const VENTURES = {
|
|
15
|
+
delimit: { priority: 'P0', status: 'active' },
|
|
16
|
+
domainvested: { priority: 'P2', status: 'maintenance' },
|
|
17
|
+
'wire.report': { priority: 'held', status: 'held' },
|
|
18
|
+
'livetube.ai': { priority: 'held', status: 'held' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
class DelimitCodexJamsons {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.name = 'delimit-jamsons';
|
|
24
|
+
this.version = '1.0.0';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Before suggestion: inject portfolio context so the model has strategic awareness.
|
|
29
|
+
*/
|
|
30
|
+
async onBeforeSuggestion(context) {
|
|
31
|
+
try {
|
|
32
|
+
const portfolioCtx = await this._getPortfolioContext(context);
|
|
33
|
+
// Attach context metadata — Codex passes this through to the model
|
|
34
|
+
context._jamsons = portfolioCtx;
|
|
35
|
+
} catch (_) { /* fail open */ }
|
|
36
|
+
return { allow: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async onAfterAccept(context) {
|
|
40
|
+
// Log accepted suggestion to Jamsons decision ledger
|
|
41
|
+
try {
|
|
42
|
+
await axios.post(`${JAMSONS_URL}/api/chatops/decisions`, {
|
|
43
|
+
type: 'task',
|
|
44
|
+
title: `Codex: accepted suggestion in ${context.file || 'unknown file'}`,
|
|
45
|
+
detail: `Language: ${context.language || 'unknown'} | Tool: codex`,
|
|
46
|
+
user_id: 'codex-adapter',
|
|
47
|
+
tags: ['delimit', 'chatops'],
|
|
48
|
+
}, {
|
|
49
|
+
headers: { Authorization: `Bearer ${process.env.CHATOPS_AUTH_TOKEN}` },
|
|
50
|
+
timeout: 2000,
|
|
51
|
+
});
|
|
52
|
+
} catch (_) { /* silent */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async _getPortfolioContext(context) {
|
|
56
|
+
const [memory, decisions] = await Promise.allSettled([
|
|
57
|
+
this._searchMemory(context.file || context.language || 'delimit'),
|
|
58
|
+
this._getPendingDecisions(),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ventures: VENTURES,
|
|
63
|
+
activeVenture: this._detectVenture(context),
|
|
64
|
+
memory: memory.status === 'fulfilled' ? memory.value : [],
|
|
65
|
+
pendingDecisions: decisions.status === 'fulfilled' ? decisions.value : [],
|
|
66
|
+
timestamp: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_detectVenture(context) {
|
|
71
|
+
const path = (context.file || '').toLowerCase();
|
|
72
|
+
if (path.includes('delimit')) return 'delimit';
|
|
73
|
+
if (path.includes('domainvested')) return 'domainvested';
|
|
74
|
+
return 'delimit'; // default active venture
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async _searchMemory(query) {
|
|
78
|
+
const r = await axios.get(`${JAMSONS_URL}/api/memory/search`, {
|
|
79
|
+
params: { q: query, limit: 5 },
|
|
80
|
+
timeout: 2000,
|
|
81
|
+
});
|
|
82
|
+
return r.data?.results || [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async _getPendingDecisions() {
|
|
86
|
+
const r = await axios.get(`${JAMSONS_URL}/api/chatops/decisions`, {
|
|
87
|
+
params: { status: 'pending', limit: 5 },
|
|
88
|
+
headers: { Authorization: `Bearer ${process.env.CHATOPS_AUTH_TOKEN}` },
|
|
89
|
+
timeout: 2000,
|
|
90
|
+
});
|
|
91
|
+
return r.data?.decisions || r.data || [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async handleCommand(command, _args) {
|
|
95
|
+
const { execSync } = require('child_process');
|
|
96
|
+
const cmds = {
|
|
97
|
+
'jamsons': 'delimit status --layer=jamsons',
|
|
98
|
+
'portfolio': 'delimit portfolio --summary',
|
|
99
|
+
'decisions': 'delimit decisions --pending',
|
|
100
|
+
'memory': 'delimit memory --recent',
|
|
101
|
+
};
|
|
102
|
+
if (cmds[command]) {
|
|
103
|
+
try {
|
|
104
|
+
return execSync(cmds[command], { timeout: 10000 }).toString();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return `[JAMSONS] Command failed: ${e.message}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns structured Jamsons context for consensus use.
|
|
113
|
+
*/
|
|
114
|
+
async getContext() {
|
|
115
|
+
const [memory, decisions] = await Promise.allSettled([
|
|
116
|
+
axios.get(`${JAMSONS_URL}/api/memory/search`, {
|
|
117
|
+
params: { q: 'delimit strategy', limit: 5 },
|
|
118
|
+
timeout: 3000,
|
|
119
|
+
}),
|
|
120
|
+
axios.get(`${JAMSONS_URL}/api/chatops/decisions`, {
|
|
121
|
+
params: { status: 'pending' },
|
|
122
|
+
headers: { Authorization: `Bearer ${process.env.CHATOPS_AUTH_TOKEN}` },
|
|
123
|
+
timeout: 3000,
|
|
124
|
+
}),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
layer: 'jamsons',
|
|
129
|
+
ventures: VENTURES,
|
|
130
|
+
memory: memory.status === 'fulfilled' ? (memory.value.data?.results || []) : null,
|
|
131
|
+
pendingDecisions: decisions.status === 'fulfilled' ? (decisions.value.data || []) : null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
137
|
+
module.exports = new DelimitCodexJamsons();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof registerSkill === 'function') {
|
|
141
|
+
registerSkill(new DelimitCodexJamsons());
|
|
142
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Delimit™ Codex Security Skill Adapter
|
|
4
|
+
* Layer: Delimit (code governance) — Security surface
|
|
5
|
+
* Triggered on pre-code-generation and pre-suggestion events
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const axios = require('axios');
|
|
11
|
+
const AGENT_URL = `http://127.0.0.1:${process.env.DELIMIT_AGENT_PORT || 7823}`;
|
|
12
|
+
|
|
13
|
+
class DelimitCodexSecurity {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.name = 'delimit-security';
|
|
16
|
+
this.version = '1.0.0';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async onBeforeSuggestion(context) {
|
|
20
|
+
try {
|
|
21
|
+
const { code, language, file } = context;
|
|
22
|
+
const response = await axios.post(`${AGENT_URL}/security`, {
|
|
23
|
+
action: 'codex_suggestion',
|
|
24
|
+
code,
|
|
25
|
+
language,
|
|
26
|
+
file,
|
|
27
|
+
tool: 'codex',
|
|
28
|
+
}, { timeout: 3000 });
|
|
29
|
+
|
|
30
|
+
const { severity, findings } = response.data;
|
|
31
|
+
|
|
32
|
+
if (severity === 'critical') {
|
|
33
|
+
return {
|
|
34
|
+
allow: false,
|
|
35
|
+
message: `[DELIMIT SECURITY] Blocked — critical finding: ${findings?.[0]?.message || 'see audit log'}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (severity === 'high') {
|
|
40
|
+
return {
|
|
41
|
+
allow: true,
|
|
42
|
+
warning: `[DELIMIT SECURITY] High-severity finding: ${findings?.[0]?.message || 'review before accepting'}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { allow: true };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err.response?.data?.action === 'block') {
|
|
49
|
+
return { allow: false, message: `[DELIMIT SECURITY] ${err.response.data.reason}` };
|
|
50
|
+
}
|
|
51
|
+
// Fail open — security service unavailable
|
|
52
|
+
console.warn('[DELIMIT SECURITY] Scan unavailable:', err.message);
|
|
53
|
+
return { allow: true };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async onAfterAccept(context) {
|
|
58
|
+
try {
|
|
59
|
+
await axios.post(`${AGENT_URL}/audit`, {
|
|
60
|
+
action: 'codex_security_accept',
|
|
61
|
+
context,
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
}, { timeout: 2000 });
|
|
64
|
+
} catch (_) { /* silent */ }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async handleCommand(command, _args) {
|
|
68
|
+
if (command === 'security') {
|
|
69
|
+
const { execSync } = require('child_process');
|
|
70
|
+
try {
|
|
71
|
+
return execSync('delimit security --verbose', { timeout: 10000 }).toString();
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return `[DELIMIT SECURITY] Command failed: ${e.message}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getContext() {
|
|
79
|
+
try {
|
|
80
|
+
const r = await axios.get(`${AGENT_URL}/security/status`, { timeout: 3000 });
|
|
81
|
+
return { layer: 'delimit-security', status: 'ok', data: r.data };
|
|
82
|
+
} catch (_) {
|
|
83
|
+
return { layer: 'delimit-security', status: 'unavailable' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
89
|
+
module.exports = new DelimitCodexSecurity();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof registerSkill === 'function') {
|
|
93
|
+
registerSkill(new DelimitCodexSecurity());
|
|
94
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Delimit™ Gemini CLI Forge Adapter
|
|
4
|
+
* Layer: Forge (execution governance)
|
|
5
|
+
* Used as: command handler called from Gemini CLI hooks and @commands
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const axios = require('axios');
|
|
11
|
+
const AGENT_URL = `http://127.0.0.1:${process.env.DELIMIT_AGENT_PORT || 7823}`;
|
|
12
|
+
|
|
13
|
+
class DelimitGeminiForge {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.id = 'delimit-forge';
|
|
16
|
+
this.version = '1.0.0';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pre-generation: surface test failures and deploy locks to Gemini before it generates code.
|
|
21
|
+
*/
|
|
22
|
+
async beforeCodeGeneration(request) {
|
|
23
|
+
const [testState, deployState, releaseState] = await Promise.allSettled([
|
|
24
|
+
axios.get(`${AGENT_URL}/test/status`, { timeout: 3000 }),
|
|
25
|
+
axios.get(`${AGENT_URL}/deploy/status`, { timeout: 3000 }),
|
|
26
|
+
axios.get(`${AGENT_URL}/release/status`, { timeout: 3000 }),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const warnings = [];
|
|
30
|
+
|
|
31
|
+
if (testState.status === 'fulfilled') {
|
|
32
|
+
const t = testState.value.data;
|
|
33
|
+
if (t?.failing > 0) warnings.push(`[FORGE] ${t.failing} test(s) failing`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (deployState.status === 'fulfilled') {
|
|
37
|
+
const d = deployState.value.data;
|
|
38
|
+
if (d?.locked) warnings.push(`[FORGE] Deploy locked: ${d.reason || 'release in progress'}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (releaseState.status === 'fulfilled') {
|
|
42
|
+
const rel = releaseState.value.data;
|
|
43
|
+
if (rel?.in_progress) warnings.push(`[FORGE] Release in progress: ${rel.version || 'unknown'}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (warnings.length > 0) {
|
|
47
|
+
console.warn(warnings.join('\n'));
|
|
48
|
+
// Inject warnings into system prompt context
|
|
49
|
+
request._forgeWarnings = warnings;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return request;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async afterResponse(response) {
|
|
56
|
+
try {
|
|
57
|
+
await axios.post(`${AGENT_URL}/audit`, {
|
|
58
|
+
action: 'gemini_forge_response',
|
|
59
|
+
response: {
|
|
60
|
+
model: response.model,
|
|
61
|
+
tokens: response.usage,
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
},
|
|
64
|
+
}, { timeout: 2000 });
|
|
65
|
+
} catch (_) { /* silent */ }
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async handleCommand(command, _args) {
|
|
70
|
+
const { execSync } = require('child_process');
|
|
71
|
+
const commands = {
|
|
72
|
+
'@forge': 'delimit status --layer=forge',
|
|
73
|
+
'@tests': 'delimit test --summary',
|
|
74
|
+
'@deploy': 'delimit deploy --status',
|
|
75
|
+
'@release': 'delimit release --status',
|
|
76
|
+
};
|
|
77
|
+
if (commands[command]) {
|
|
78
|
+
try {
|
|
79
|
+
return execSync(commands[command], { timeout: 10000 }).toString();
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return `[FORGE] Command failed: ${e.message}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Structured context for consensus — call this from hooks or consensus runners.
|
|
88
|
+
*/
|
|
89
|
+
async getContext() {
|
|
90
|
+
const [tests, deploy, release] = await Promise.allSettled([
|
|
91
|
+
axios.get(`${AGENT_URL}/test/status`, { timeout: 3000 }),
|
|
92
|
+
axios.get(`${AGENT_URL}/deploy/status`, { timeout: 3000 }),
|
|
93
|
+
axios.get(`${AGENT_URL}/release/status`, { timeout: 3000 }),
|
|
94
|
+
]);
|
|
95
|
+
return {
|
|
96
|
+
layer: 'forge',
|
|
97
|
+
tool: 'gemini',
|
|
98
|
+
tests: tests.status === 'fulfilled' ? tests.value.data : null,
|
|
99
|
+
deploy: deploy.status === 'fulfilled' ? deploy.value.data : null,
|
|
100
|
+
release: release.status === 'fulfilled' ? release.value.data : null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = new DelimitGeminiForge();
|
|
106
|
+
|
|
107
|
+
if (typeof registerExtension === 'function') {
|
|
108
|
+
registerExtension(new DelimitGeminiForge());
|
|
109
|
+
}
|
package/bin/delimit-cli.js
CHANGED
|
@@ -50,7 +50,7 @@ async function ensureAgent() {
|
|
|
50
50
|
program
|
|
51
51
|
.name('delimit')
|
|
52
52
|
.description('Prevent breaking API changes before they reach production')
|
|
53
|
-
.version('
|
|
53
|
+
.version(require('../package.json').version);
|
|
54
54
|
|
|
55
55
|
// Install command with modes
|
|
56
56
|
program
|
|
@@ -803,7 +803,7 @@ program
|
|
|
803
803
|
const specPath = foundSpecs[0];
|
|
804
804
|
console.log(` Detected spec: ${chalk.bold(specPath)}`);
|
|
805
805
|
console.log('');
|
|
806
|
-
console.log(chalk.bold('
|
|
806
|
+
console.log(chalk.bold(' Workflow template:\n'));
|
|
807
807
|
console.log(chalk.gray(` name: API Governance
|
|
808
808
|
on:
|
|
809
809
|
pull_request:
|
|
@@ -827,6 +827,48 @@ program
|
|
|
827
827
|
new_spec: ${specPath}
|
|
828
828
|
mode: advisory`));
|
|
829
829
|
console.log('');
|
|
830
|
+
|
|
831
|
+
// Auto-write the workflow file
|
|
832
|
+
const workflowDir = path.join(process.cwd(), '.github', 'workflows');
|
|
833
|
+
const workflowFile = path.join(workflowDir, 'api-governance.yml');
|
|
834
|
+
|
|
835
|
+
if (!fs.existsSync(workflowFile)) {
|
|
836
|
+
try {
|
|
837
|
+
fs.mkdirSync(workflowDir, { recursive: true });
|
|
838
|
+
const workflowContent = `name: API Governance
|
|
839
|
+
on:
|
|
840
|
+
pull_request:
|
|
841
|
+
paths:
|
|
842
|
+
- '${specPath}'
|
|
843
|
+
|
|
844
|
+
permissions:
|
|
845
|
+
contents: read
|
|
846
|
+
pull-requests: write
|
|
847
|
+
|
|
848
|
+
jobs:
|
|
849
|
+
api-governance:
|
|
850
|
+
runs-on: ubuntu-latest
|
|
851
|
+
steps:
|
|
852
|
+
- uses: actions/checkout@v4
|
|
853
|
+
- uses: actions/checkout@v4
|
|
854
|
+
with:
|
|
855
|
+
ref: \${{ github.event.pull_request.base.sha }}
|
|
856
|
+
path: _base
|
|
857
|
+
- uses: delimit-ai/delimit@v1
|
|
858
|
+
with:
|
|
859
|
+
old_spec: _base/${specPath}
|
|
860
|
+
new_spec: ${specPath}
|
|
861
|
+
mode: advisory
|
|
862
|
+
`;
|
|
863
|
+
fs.writeFileSync(workflowFile, workflowContent);
|
|
864
|
+
console.log(chalk.green(` Created .github/workflows/api-governance.yml\n`));
|
|
865
|
+
} catch (err) {
|
|
866
|
+
console.log(chalk.yellow(` Could not write workflow file: ${err.message}`));
|
|
867
|
+
console.log(chalk.bold(' Add this to .github/workflows/api-governance.yml manually (shown above)\n'));
|
|
868
|
+
}
|
|
869
|
+
} else {
|
|
870
|
+
console.log(chalk.yellow(' .github/workflows/api-governance.yml already exists — skipped\n'));
|
|
871
|
+
}
|
|
830
872
|
} else {
|
|
831
873
|
console.log(' No OpenAPI spec file detected.');
|
|
832
874
|
console.log(` Delimit also supports ${chalk.bold('Zero-Spec Mode')} — run ${chalk.bold('delimit lint')} in a FastAPI/NestJS/Express project.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "delimit-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Prevent breaking API changes before they reach production. Deterministic diff engine + policy enforcement + semver classification.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"install-mcp": "bash ./hooks/install-hooks.sh mcp-only",
|
|
13
13
|
"test-mcp": "bash ./hooks/install-hooks.sh troubleshoot",
|
|
14
14
|
"fix-mcp": "bash ./hooks/install-hooks.sh fix-mcp",
|
|
15
|
-
"test": "
|
|
15
|
+
"test": "node --test tests/cli.test.js"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"openapi",
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delimit CLI Tests
|
|
3
|
+
*
|
|
4
|
+
* Uses Node.js built-in test runner (node:test).
|
|
5
|
+
* Run with: node --test tests/cli.test.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { describe, it, before, after } = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const { execSync } = require('node:child_process');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
|
|
15
|
+
const CLI = path.join(__dirname, '..', 'bin', 'delimit-cli.js');
|
|
16
|
+
const FIXTURES = path.join(__dirname, 'fixtures');
|
|
17
|
+
const SPEC_CLEAN = path.join(FIXTURES, 'openapi.yaml');
|
|
18
|
+
const SPEC_BREAKING = path.join(FIXTURES, 'openapi-changed.yaml');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run the CLI and return { stdout, stderr, exitCode }.
|
|
22
|
+
* Does not throw on non-zero exit.
|
|
23
|
+
*/
|
|
24
|
+
function run(args, opts = {}) {
|
|
25
|
+
const cwd = opts.cwd || os.tmpdir();
|
|
26
|
+
try {
|
|
27
|
+
const stdout = execSync(`node "${CLI}" ${args}`, {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: 'utf-8',
|
|
30
|
+
timeout: 30000,
|
|
31
|
+
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
32
|
+
});
|
|
33
|
+
return { stdout, stderr: '', exitCode: 0 };
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return {
|
|
36
|
+
stdout: err.stdout || '',
|
|
37
|
+
stderr: err.stderr || '',
|
|
38
|
+
exitCode: err.status ?? 1,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// 1. CLI entry point loads without error
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe('CLI entry point', () => {
|
|
47
|
+
it('loads the module without throwing', () => {
|
|
48
|
+
assert.doesNotThrow(() => {
|
|
49
|
+
execSync(`node --check "${CLI}"`, { encoding: 'utf-8' });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// 2. --version returns the version from package.json
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
describe('--version', () => {
|
|
58
|
+
it('prints the version from package.json', () => {
|
|
59
|
+
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
60
|
+
const { stdout } = run('--version');
|
|
61
|
+
assert.equal(stdout.trim(), pkg.version);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// 3. --help works at top level
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
describe('--help (top level)', () => {
|
|
69
|
+
it('shows usage and lists commands', () => {
|
|
70
|
+
const { stdout } = run('--help');
|
|
71
|
+
assert.match(stdout, /Usage:/);
|
|
72
|
+
assert.match(stdout, /init/);
|
|
73
|
+
assert.match(stdout, /lint/);
|
|
74
|
+
assert.match(stdout, /diff/);
|
|
75
|
+
assert.match(stdout, /explain/);
|
|
76
|
+
assert.match(stdout, /doctor/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// 4. Each command has help text
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
describe('subcommand --help', () => {
|
|
84
|
+
for (const cmd of ['init', 'lint', 'diff', 'explain', 'doctor']) {
|
|
85
|
+
it(`"help ${cmd}" shows description`, () => {
|
|
86
|
+
const { stdout } = run(`help ${cmd}`);
|
|
87
|
+
assert.match(stdout, /Usage:/);
|
|
88
|
+
assert.match(stdout, new RegExp(cmd));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// 5. init creates .delimit/policies.yml with default preset
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
describe('init command', () => {
|
|
97
|
+
let tmpDir;
|
|
98
|
+
|
|
99
|
+
before(() => {
|
|
100
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'delimit-test-init-'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
after(() => {
|
|
104
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('creates .delimit/policies.yml with default preset', () => {
|
|
108
|
+
const { stdout, exitCode } = run('init', { cwd: tmpDir });
|
|
109
|
+
assert.equal(exitCode, 0);
|
|
110
|
+
const policyPath = path.join(tmpDir, '.delimit', 'policies.yml');
|
|
111
|
+
assert.ok(fs.existsSync(policyPath), 'policies.yml should exist');
|
|
112
|
+
const content = fs.readFileSync(policyPath, 'utf-8');
|
|
113
|
+
assert.match(content, /Delimit Policy Preset: default/);
|
|
114
|
+
assert.match(content, /override_defaults: false/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('reports already initialized on second run', () => {
|
|
118
|
+
const { stdout, exitCode } = run('init', { cwd: tmpDir });
|
|
119
|
+
assert.equal(exitCode, 0);
|
|
120
|
+
assert.match(stdout, /Already initialized/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('init --preset strict', () => {
|
|
125
|
+
let tmpDir;
|
|
126
|
+
|
|
127
|
+
before(() => {
|
|
128
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'delimit-test-init-strict-'));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
after(() => {
|
|
132
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('creates policies.yml with strict preset', () => {
|
|
136
|
+
const { stdout, exitCode } = run('init --preset strict', { cwd: tmpDir });
|
|
137
|
+
assert.equal(exitCode, 0);
|
|
138
|
+
const content = fs.readFileSync(
|
|
139
|
+
path.join(tmpDir, '.delimit', 'policies.yml'),
|
|
140
|
+
'utf-8'
|
|
141
|
+
);
|
|
142
|
+
assert.match(content, /Delimit Policy Preset: strict/);
|
|
143
|
+
assert.match(content, /no_endpoint_removal/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('init --preset relaxed', () => {
|
|
148
|
+
let tmpDir;
|
|
149
|
+
|
|
150
|
+
before(() => {
|
|
151
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'delimit-test-init-relaxed-'));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
after(() => {
|
|
155
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('creates policies.yml with relaxed preset', () => {
|
|
159
|
+
const { stdout, exitCode } = run('init --preset relaxed', { cwd: tmpDir });
|
|
160
|
+
assert.equal(exitCode, 0);
|
|
161
|
+
const content = fs.readFileSync(
|
|
162
|
+
path.join(tmpDir, '.delimit', 'policies.yml'),
|
|
163
|
+
'utf-8'
|
|
164
|
+
);
|
|
165
|
+
assert.match(content, /Delimit Policy Preset: relaxed/);
|
|
166
|
+
assert.match(content, /warn_endpoint_removal/);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// 6. lint with identical specs returns clean (exit 0)
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
describe('lint (clean)', () => {
|
|
174
|
+
it('returns exit 0 with no violations for identical specs', () => {
|
|
175
|
+
const { stdout, exitCode } = run(
|
|
176
|
+
`lint "${SPEC_CLEAN}" "${SPEC_CLEAN}" --json`
|
|
177
|
+
);
|
|
178
|
+
assert.equal(exitCode, 0);
|
|
179
|
+
const result = JSON.parse(stdout);
|
|
180
|
+
assert.equal(result.decision, 'pass');
|
|
181
|
+
assert.equal(result.exit_code, 0);
|
|
182
|
+
assert.equal(result.summary.breaking_changes, 0);
|
|
183
|
+
assert.equal(result.violations.length, 0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// 7. lint with breaking change returns exit 1
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
describe('lint (breaking)', () => {
|
|
191
|
+
it('returns exit 1 when breaking changes are detected', () => {
|
|
192
|
+
const { stdout, exitCode } = run(
|
|
193
|
+
`lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
|
|
194
|
+
);
|
|
195
|
+
assert.equal(exitCode, 1);
|
|
196
|
+
const result = JSON.parse(stdout);
|
|
197
|
+
assert.equal(result.decision, 'fail');
|
|
198
|
+
assert.equal(result.exit_code, 1);
|
|
199
|
+
assert.ok(result.summary.breaking_changes > 0, 'should have breaking changes');
|
|
200
|
+
assert.ok(result.violations.length > 0, 'should have violations');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('includes endpoint_removed in changes', () => {
|
|
204
|
+
const { stdout } = run(
|
|
205
|
+
`lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
|
|
206
|
+
);
|
|
207
|
+
const result = JSON.parse(stdout);
|
|
208
|
+
const types = result.all_changes.map(c => c.type);
|
|
209
|
+
assert.ok(types.includes('endpoint_removed'), 'should detect endpoint removal');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('includes semver bump classification', () => {
|
|
213
|
+
const { stdout } = run(
|
|
214
|
+
`lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
|
|
215
|
+
);
|
|
216
|
+
const result = JSON.parse(stdout);
|
|
217
|
+
assert.ok(result.semver, 'should have semver field');
|
|
218
|
+
assert.equal(result.semver.bump, 'major');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// 8. diff outputs change types
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
describe('diff command', () => {
|
|
226
|
+
it('outputs changes between two specs', () => {
|
|
227
|
+
const { stdout, exitCode } = run(
|
|
228
|
+
`diff "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
|
|
229
|
+
);
|
|
230
|
+
assert.equal(exitCode, 0);
|
|
231
|
+
const result = JSON.parse(stdout);
|
|
232
|
+
assert.ok(result.total_changes > 0, 'should have changes');
|
|
233
|
+
assert.ok(result.breaking_changes > 0, 'should have breaking changes');
|
|
234
|
+
assert.ok(Array.isArray(result.changes), 'changes should be an array');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('reports change types correctly', () => {
|
|
238
|
+
const { stdout } = run(
|
|
239
|
+
`diff "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
|
|
240
|
+
);
|
|
241
|
+
const result = JSON.parse(stdout);
|
|
242
|
+
const types = result.changes.map(c => c.type);
|
|
243
|
+
assert.ok(types.includes('endpoint_removed'));
|
|
244
|
+
assert.ok(types.includes('type_changed'));
|
|
245
|
+
assert.ok(types.includes('enum_value_removed'));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('marks breaking changes with is_breaking flag', () => {
|
|
249
|
+
const { stdout } = run(
|
|
250
|
+
`diff "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
|
|
251
|
+
);
|
|
252
|
+
const result = JSON.parse(stdout);
|
|
253
|
+
const breakingChanges = result.changes.filter(c => c.is_breaking);
|
|
254
|
+
assert.equal(breakingChanges.length, result.breaking_changes);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('returns no changes for identical specs', () => {
|
|
258
|
+
const { stdout, exitCode } = run(
|
|
259
|
+
`diff "${SPEC_CLEAN}" "${SPEC_CLEAN}" --json`
|
|
260
|
+
);
|
|
261
|
+
assert.equal(exitCode, 0);
|
|
262
|
+
const result = JSON.parse(stdout);
|
|
263
|
+
assert.equal(result.total_changes, 0);
|
|
264
|
+
assert.equal(result.breaking_changes, 0);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// 9. explain command
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
describe('explain command', () => {
|
|
272
|
+
it('generates human-readable explanation', () => {
|
|
273
|
+
const { stdout, exitCode } = run(
|
|
274
|
+
`explain "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
|
|
275
|
+
);
|
|
276
|
+
assert.equal(exitCode, 0);
|
|
277
|
+
const result = JSON.parse(stdout);
|
|
278
|
+
assert.ok(result.output, 'should have output text');
|
|
279
|
+
assert.ok(result.output.length > 0, 'output should not be empty');
|
|
280
|
+
assert.ok(result.template, 'should report template used');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('supports --template flag', () => {
|
|
284
|
+
const { stdout, exitCode } = run(
|
|
285
|
+
`explain "${SPEC_CLEAN}" "${SPEC_BREAKING}" --template migration --json`
|
|
286
|
+
);
|
|
287
|
+
assert.equal(exitCode, 0);
|
|
288
|
+
const result = JSON.parse(stdout);
|
|
289
|
+
assert.equal(result.template, 'migration');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// 10. lint with --policy preset
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
describe('lint --policy', () => {
|
|
297
|
+
it('accepts relaxed preset and does not fail on breaking changes', () => {
|
|
298
|
+
// relaxed preset uses action:warn, so decision is "warn" not "fail"
|
|
299
|
+
const { stdout, exitCode } = run(
|
|
300
|
+
`lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --policy relaxed --json`
|
|
301
|
+
);
|
|
302
|
+
assert.equal(exitCode, 0, 'relaxed preset should exit 0');
|
|
303
|
+
const result = JSON.parse(stdout);
|
|
304
|
+
assert.notEqual(result.decision, 'fail', 'relaxed should not produce fail decision');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('accepts strict preset and fails on breaking changes', () => {
|
|
308
|
+
const { stdout, exitCode } = run(
|
|
309
|
+
`lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --policy strict --json`
|
|
310
|
+
);
|
|
311
|
+
assert.equal(exitCode, 1);
|
|
312
|
+
const result = JSON.parse(stdout);
|
|
313
|
+
assert.equal(result.decision, 'fail');
|
|
314
|
+
assert.ok(result.violations.length > 0);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// 11. Error handling -- missing files
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
describe('error handling', () => {
|
|
322
|
+
it('lint with nonexistent spec file reports error', () => {
|
|
323
|
+
const { exitCode } = run('lint /nonexistent/old.yaml /nonexistent/new.yaml --json');
|
|
324
|
+
assert.notEqual(exitCode, 0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('diff with nonexistent spec file reports error', () => {
|
|
328
|
+
const { exitCode } = run('diff /nonexistent/old.yaml /nonexistent/new.yaml --json');
|
|
329
|
+
assert.notEqual(exitCode, 0);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// 12. api-engine module exports
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
describe('api-engine module', () => {
|
|
337
|
+
it('exports lint, diff, explain, semver, zeroSpec functions', () => {
|
|
338
|
+
const engine = require(path.join(__dirname, '..', 'lib', 'api-engine.js'));
|
|
339
|
+
assert.equal(typeof engine.lint, 'function');
|
|
340
|
+
assert.equal(typeof engine.diff, 'function');
|
|
341
|
+
assert.equal(typeof engine.explain, 'function');
|
|
342
|
+
assert.equal(typeof engine.semver, 'function');
|
|
343
|
+
assert.equal(typeof engine.zeroSpec, 'function');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('lint returns parsed JSON with decision field', () => {
|
|
347
|
+
const engine = require(path.join(__dirname, '..', 'lib', 'api-engine.js'));
|
|
348
|
+
const result = engine.lint(SPEC_CLEAN, SPEC_CLEAN);
|
|
349
|
+
assert.ok(result.decision, 'should have decision field');
|
|
350
|
+
assert.equal(result.decision, 'pass');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('diff returns parsed JSON with changes array', () => {
|
|
354
|
+
const engine = require(path.join(__dirname, '..', 'lib', 'api-engine.js'));
|
|
355
|
+
const result = engine.diff(SPEC_CLEAN, SPEC_BREAKING);
|
|
356
|
+
assert.ok(Array.isArray(result.changes), 'should have changes array');
|
|
357
|
+
assert.ok(result.total_changes > 0);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
openapi: "3.0.3"
|
|
2
|
+
info:
|
|
3
|
+
title: Pet Store API
|
|
4
|
+
version: "2.0.0"
|
|
5
|
+
description: Sample API for Delimit quickstart
|
|
6
|
+
paths:
|
|
7
|
+
/pets:
|
|
8
|
+
get:
|
|
9
|
+
summary: List all pets
|
|
10
|
+
operationId: listPets
|
|
11
|
+
parameters:
|
|
12
|
+
- name: limit
|
|
13
|
+
in: query
|
|
14
|
+
required: false
|
|
15
|
+
schema:
|
|
16
|
+
type: integer
|
|
17
|
+
format: int32
|
|
18
|
+
responses:
|
|
19
|
+
"200":
|
|
20
|
+
description: A list of pets
|
|
21
|
+
content:
|
|
22
|
+
application/json:
|
|
23
|
+
schema:
|
|
24
|
+
type: array
|
|
25
|
+
items:
|
|
26
|
+
$ref: "#/components/schemas/Pet"
|
|
27
|
+
post:
|
|
28
|
+
summary: Create a pet
|
|
29
|
+
operationId: createPet
|
|
30
|
+
requestBody:
|
|
31
|
+
required: true
|
|
32
|
+
content:
|
|
33
|
+
application/json:
|
|
34
|
+
schema:
|
|
35
|
+
$ref: "#/components/schemas/Pet"
|
|
36
|
+
responses:
|
|
37
|
+
"201":
|
|
38
|
+
description: Pet created
|
|
39
|
+
# /pets/{petId} removed -- this is the breaking change
|
|
40
|
+
components:
|
|
41
|
+
schemas:
|
|
42
|
+
Pet:
|
|
43
|
+
type: object
|
|
44
|
+
required:
|
|
45
|
+
- id
|
|
46
|
+
- name
|
|
47
|
+
properties:
|
|
48
|
+
id:
|
|
49
|
+
type: integer
|
|
50
|
+
name:
|
|
51
|
+
type: string
|
|
52
|
+
status:
|
|
53
|
+
type: string
|
|
54
|
+
enum:
|
|
55
|
+
- available
|
|
56
|
+
- adopted
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
openapi: "3.0.3"
|
|
2
|
+
info:
|
|
3
|
+
title: Pet Store API
|
|
4
|
+
version: "1.0.0"
|
|
5
|
+
description: Sample API for Delimit quickstart
|
|
6
|
+
paths:
|
|
7
|
+
/pets:
|
|
8
|
+
get:
|
|
9
|
+
summary: List all pets
|
|
10
|
+
operationId: listPets
|
|
11
|
+
parameters:
|
|
12
|
+
- name: limit
|
|
13
|
+
in: query
|
|
14
|
+
required: false
|
|
15
|
+
schema:
|
|
16
|
+
type: integer
|
|
17
|
+
format: int32
|
|
18
|
+
responses:
|
|
19
|
+
"200":
|
|
20
|
+
description: A list of pets
|
|
21
|
+
content:
|
|
22
|
+
application/json:
|
|
23
|
+
schema:
|
|
24
|
+
type: array
|
|
25
|
+
items:
|
|
26
|
+
$ref: "#/components/schemas/Pet"
|
|
27
|
+
post:
|
|
28
|
+
summary: Create a pet
|
|
29
|
+
operationId: createPet
|
|
30
|
+
requestBody:
|
|
31
|
+
required: true
|
|
32
|
+
content:
|
|
33
|
+
application/json:
|
|
34
|
+
schema:
|
|
35
|
+
$ref: "#/components/schemas/Pet"
|
|
36
|
+
responses:
|
|
37
|
+
"201":
|
|
38
|
+
description: Pet created
|
|
39
|
+
/pets/{petId}:
|
|
40
|
+
get:
|
|
41
|
+
summary: Get a pet by ID
|
|
42
|
+
operationId: getPetById
|
|
43
|
+
parameters:
|
|
44
|
+
- name: petId
|
|
45
|
+
in: path
|
|
46
|
+
required: true
|
|
47
|
+
schema:
|
|
48
|
+
type: string
|
|
49
|
+
responses:
|
|
50
|
+
"200":
|
|
51
|
+
description: A single pet
|
|
52
|
+
content:
|
|
53
|
+
application/json:
|
|
54
|
+
schema:
|
|
55
|
+
$ref: "#/components/schemas/Pet"
|
|
56
|
+
delete:
|
|
57
|
+
summary: Delete a pet
|
|
58
|
+
operationId: deletePet
|
|
59
|
+
parameters:
|
|
60
|
+
- name: petId
|
|
61
|
+
in: path
|
|
62
|
+
required: true
|
|
63
|
+
schema:
|
|
64
|
+
type: string
|
|
65
|
+
responses:
|
|
66
|
+
"204":
|
|
67
|
+
description: Pet deleted
|
|
68
|
+
components:
|
|
69
|
+
schemas:
|
|
70
|
+
Pet:
|
|
71
|
+
type: object
|
|
72
|
+
required:
|
|
73
|
+
- id
|
|
74
|
+
- name
|
|
75
|
+
properties:
|
|
76
|
+
id:
|
|
77
|
+
type: string
|
|
78
|
+
name:
|
|
79
|
+
type: string
|
|
80
|
+
tag:
|
|
81
|
+
type: string
|
|
82
|
+
status:
|
|
83
|
+
type: string
|
|
84
|
+
enum:
|
|
85
|
+
- available
|
|
86
|
+
- pending
|
|
87
|
+
- adopted
|