@nugehs/gate 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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/package.json +62 -0
- package/scripts/check-version.js +56 -0
- package/scripts/sync-server-version.mjs +14 -0
- package/server.json +28 -0
- package/src/adapters/aiglare.js +66 -0
- package/src/adapters/bouncer.js +66 -0
- package/src/adapters/index.js +9 -0
- package/src/adapters/repoctx.js +53 -0
- package/src/adapters/tieline.js +64 -0
- package/src/cli.js +136 -0
- package/src/color.js +16 -0
- package/src/mcp.js +183 -0
- package/src/orchestrator.js +117 -0
- package/src/report/terminal.js +66 -0
- package/src/resolve.js +52 -0
- package/src/run.js +63 -0
- package/src/verdict.js +83 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@nugehs/gate` are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
Initial release.
|
|
8
|
+
|
|
9
|
+
- Runs aiglare, bouncer, tieline & repoctx against a repo and merges their four
|
|
10
|
+
dialects (red/amber/green, pass/fail/unknown, matched/drift, PASS/WARN/FAIL)
|
|
11
|
+
into one normalized verdict (`pass | warn | fail | unknown | skipped | error`).
|
|
12
|
+
- `--ci` gate blocks on a `fail` verdict by default; `--strict` also blocks on warnings.
|
|
13
|
+
- A run where no domain executes (everything skipped or deselected) is **not** a
|
|
14
|
+
pass — under `--ci` it fails, so a typo can't silently turn the gate into a no-op.
|
|
15
|
+
- Per-tool resolution: `GATE_<TOOL>_BIN` env → installed `@nugehs/<tool>` → `../<tool>` sibling checkout.
|
|
16
|
+
- Terminal and JSON reporters; `--only` / `--skip` tool selection.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oluwasegun Olumbe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# gate
|
|
2
|
+
|
|
3
|
+
**One ship/no-ship verdict from your whole nugehs toolchain.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@nugehs/gate) [](LICENSE) [](https://www.npmjs.com/package/@nugehs/gate)
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
`gate` runs [aiglare](https://www.npmjs.com/package/@nugehs/aiglare),
|
|
10
|
+
[bouncer](https://www.npmjs.com/package/@nugehs/bouncer),
|
|
11
|
+
[tieline](https://www.npmjs.com/package/@nugehs/tieline) and
|
|
12
|
+
[repoctx](https://www.npmjs.com/package/@nugehs/repoctx) against a repo and
|
|
13
|
+
merges their four dialects into **one normalized verdict**. Each tool already
|
|
14
|
+
answers a different "can this ship?" question — gate is the place they finally
|
|
15
|
+
agree on the answer.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
npx @nugehs/gate # audit the current repo
|
|
19
|
+
npx @nugehs/gate ./service --ci # fail the build on a blocking verdict
|
|
20
|
+
npx @nugehs/gate --json # the unified verdict, machine-readable
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
┌──────────────────────────────────────────────┐
|
|
25
|
+
│ gate │
|
|
26
|
+
│ one config · one verdict · one report │
|
|
27
|
+
└──────────────────────────────────────────────┘
|
|
28
|
+
│ │ │ │
|
|
29
|
+
aiglare bouncer tieline repoctx
|
|
30
|
+
red/amber/ pass/fail/ matched/ PASS/WARN/
|
|
31
|
+
green unknown drift FAIL
|
|
32
|
+
│ │ │ │
|
|
33
|
+
└──── normalize to pass · warn · fail ─────┘
|
|
34
|
+
│
|
|
35
|
+
✗ FAIL ⚠ WARN ✓ PASS
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What it reports
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
gate · /path/to/repo
|
|
42
|
+
|
|
43
|
+
✗ AI governance fail 2 red · 1 amber · 13 green · 1 blocking side-effect
|
|
44
|
+
· Compliance skipped not configured (run `bouncer init`)
|
|
45
|
+
· Contract drift skipped not configured (run `tieline init`)
|
|
46
|
+
⚠ Merge readiness warn 1 of 8 checks need attention
|
|
47
|
+
|
|
48
|
+
verdict: FAIL — 1 blocking · 1 warn · 2 skipped
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each tool's native result is normalized onto one status vocabulary:
|
|
52
|
+
|
|
53
|
+
| Status | Meaning |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `pass` | the check ran and is clean |
|
|
56
|
+
| `warn` | ran, found something worth a look — not blocking |
|
|
57
|
+
| `fail` | ran, found a blocking problem |
|
|
58
|
+
| `unknown` | ran, but couldn't determine — explicitly **not** a pass |
|
|
59
|
+
| `skipped` | not applicable / not configured for this repo |
|
|
60
|
+
| `error` | the tool couldn't be run, or returned garbage |
|
|
61
|
+
|
|
62
|
+
The top-level verdict is the worst across the domains that actually ran
|
|
63
|
+
(`skipped` never counts). `unknown` and `error` roll up to `warn` so nothing
|
|
64
|
+
slips through as a silent pass.
|
|
65
|
+
|
|
66
|
+
## How each dialect maps
|
|
67
|
+
|
|
68
|
+
| Tool | Native signal | → gate |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| **aiglare** | a red surface on a side-effectful sink | `fail`; any red/amber → `warn` |
|
|
71
|
+
| **bouncer** | a `fail` finding (missing required control) | `fail`; any `unknown` control → `unknown` |
|
|
72
|
+
| **tieline** | `drift` > 0 (FE call with no BE route) | `fail`; `unverifiable` > 0 → `warn` |
|
|
73
|
+
| **repoctx** | `FAIL`/`BLOCK` merge verdict | `fail`; `WARN` → `warn` |
|
|
74
|
+
|
|
75
|
+
> gate runs aiglare **without** `--ci` and derives the blocking verdict itself,
|
|
76
|
+
> so a tool that `process.exit()`s before flushing its pipe can't truncate the
|
|
77
|
+
> report it feeds us.
|
|
78
|
+
|
|
79
|
+
**A run that checked nothing is not a pass.** If every domain is skipped or
|
|
80
|
+
deselected (e.g. `--skip` them all, or a typo'd `--only`), gate reports **NO
|
|
81
|
+
CHECKS RAN** (`ok:false`) and fails under `--ci` — a misconfiguration can't
|
|
82
|
+
silently turn the gate green.
|
|
83
|
+
|
|
84
|
+
**On repoctx + local mode.** repoctx's merge-readiness gate can only verify
|
|
85
|
+
review state (approvals, CODEOWNERS, required checks) against a host like
|
|
86
|
+
GitHub. Run locally it reports those as a `WARN`, so on a clean local repo gate
|
|
87
|
+
will often show `merge readiness: warn`. That's repoctx being honest about what
|
|
88
|
+
it can't see locally — not a problem with your change.
|
|
89
|
+
|
|
90
|
+
## CLI
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
gate [path] [options]
|
|
94
|
+
|
|
95
|
+
--json Emit the unified verdict as JSON
|
|
96
|
+
--ci Exit non-zero when the gate fails (blocking by default)
|
|
97
|
+
--strict Treat WARN/UNKNOWN as blocking too
|
|
98
|
+
--only <list> Run only these tools (aiglare,bouncer,tieline,repoctx)
|
|
99
|
+
--skip <list> Skip these tools
|
|
100
|
+
-h, --help Show this help
|
|
101
|
+
|
|
102
|
+
gate mcp Start the MCP server (stdio)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
By default only a `fail` verdict blocks under `--ci` — safe to adopt without
|
|
106
|
+
drowning a team in warnings. Add `--strict` when you want warnings to gate too.
|
|
107
|
+
|
|
108
|
+
## Tool resolution
|
|
109
|
+
|
|
110
|
+
gate doesn't bundle the four tools; it finds each one at runtime. Per tool, first hit wins:
|
|
111
|
+
|
|
112
|
+
1. `GATE_<TOOL>_BIN` environment variable (explicit override)
|
|
113
|
+
2. the installed `@nugehs/<tool>` package (from `node_modules`)
|
|
114
|
+
3. a sibling checkout at `../<tool>` (local development)
|
|
115
|
+
|
|
116
|
+
A tool that can't be resolved is reported as `skipped`, never a hard failure —
|
|
117
|
+
so `gate` is safe to run in a repo that only uses some of the toolchain.
|
|
118
|
+
|
|
119
|
+
## In CI
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
- run: npx @nugehs/gate . --ci
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
For a machine-readable record, `--json` emits the full verdict (schema version,
|
|
126
|
+
per-domain results, counts, and the blocking reasons) for dashboards or audit
|
|
127
|
+
evidence.
|
|
128
|
+
|
|
129
|
+
## MCP
|
|
130
|
+
|
|
131
|
+
gate is also an MCP server, so an agent can ask "can this ship?" in one call —
|
|
132
|
+
the unified verdict, not four separate tools.
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
gate mcp # stdio JSON-RPC server
|
|
136
|
+
npx @nugehs/gate mcp
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Tools:
|
|
140
|
+
|
|
141
|
+
| Tool | Returns |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `gate_check` | the unified verdict for a repo (`path`, optional `only`/`skip`/`ci`/`strict`) |
|
|
144
|
+
| `list_checks` | the four checks gate runs, each with its domain and what it answers |
|
|
145
|
+
|
|
146
|
+
Registry manifest: [`server.json`](server.json) (`io.github.nugehs/gate`).
|
|
147
|
+
|
|
148
|
+
## Roadmap
|
|
149
|
+
|
|
150
|
+
gate is the shared spine. The same normalized verdict already drives the CLI,
|
|
151
|
+
the `--ci` gate, and the MCP server above. Next clients on the same JSON:
|
|
152
|
+
|
|
153
|
+
- **Web cockpit** — a repo/PR verdict board over the JSON, unifying the four `*-web` sites.
|
|
154
|
+
- **Editor extension** — shift the gates left from CI into the editor as inline findings.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT © Oluwasegun Olumbe
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nugehs/gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"mcpName": "io.github.nugehs/gate",
|
|
5
|
+
"description": "gate — one ship/no-ship verdict from aiglare, bouncer, tieline & repoctx. The unified merge gate for the nugehs toolchain: run four deterministic checks, get one normalized verdict.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"gate": "src/cli.js",
|
|
9
|
+
"nugehs-gate": "src/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"scripts",
|
|
14
|
+
"server.json",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node src/cli.js",
|
|
21
|
+
"gate": "node src/cli.js",
|
|
22
|
+
"mcp": "node src/cli.js mcp",
|
|
23
|
+
"version:check": "node scripts/check-version.js",
|
|
24
|
+
"version": "node scripts/sync-server-version.mjs && git add server.json",
|
|
25
|
+
"test": "node --test test/*.test.js",
|
|
26
|
+
"ci": "npm run version:check && npm test"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.18"
|
|
30
|
+
},
|
|
31
|
+
"optionalDependencies": {
|
|
32
|
+
"@nugehs/aiglare": "^0.3.0",
|
|
33
|
+
"@nugehs/bouncer": "^0.1.3",
|
|
34
|
+
"@nugehs/repoctx": "^2.1.0",
|
|
35
|
+
"@nugehs/tieline": "^0.1.4"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/nugehs/gate.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/nugehs/gate#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/nugehs/gate/issues"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"ci",
|
|
50
|
+
"merge-gate",
|
|
51
|
+
"governance",
|
|
52
|
+
"compliance",
|
|
53
|
+
"ai-governance",
|
|
54
|
+
"contract-drift",
|
|
55
|
+
"static-analysis",
|
|
56
|
+
"monorepo",
|
|
57
|
+
"mcp",
|
|
58
|
+
"model-context-protocol"
|
|
59
|
+
],
|
|
60
|
+
"author": "Oluwasegun Olumbe",
|
|
61
|
+
"license": "MIT"
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
6
|
+
const packageJson = readJson(path.join(root, 'package.json'));
|
|
7
|
+
const packageLock = readJson(path.join(root, 'package-lock.json'), { optional: true });
|
|
8
|
+
const serverManifest = readJson(path.join(root, 'server.json'), { optional: true });
|
|
9
|
+
|
|
10
|
+
const semverPattern =
|
|
11
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
12
|
+
|
|
13
|
+
if (!semverPattern.test(packageJson.version)) {
|
|
14
|
+
fail(`package.json version must be valid SemVer, got ${JSON.stringify(packageJson.version)}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (packageLock) {
|
|
18
|
+
if (packageLock.version !== packageJson.version) {
|
|
19
|
+
fail(`package-lock.json version ${packageLock.version} does not match package.json version ${packageJson.version}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const rootPackage = packageLock.packages?.[''];
|
|
23
|
+
if (rootPackage?.version !== packageJson.version) {
|
|
24
|
+
fail(`package-lock root package version ${rootPackage?.version} does not match package.json version ${packageJson.version}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (serverManifest) {
|
|
29
|
+
if (serverManifest.version !== packageJson.version) {
|
|
30
|
+
fail(`server.json version ${serverManifest.version} does not match package.json version ${packageJson.version}`);
|
|
31
|
+
}
|
|
32
|
+
for (const pkg of serverManifest.packages ?? []) {
|
|
33
|
+
if (pkg.version && pkg.version !== packageJson.version) {
|
|
34
|
+
fail(`server.json package ${pkg.identifier} version ${pkg.version} does not match package.json version ${packageJson.version}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(`ok: gate version ${packageJson.version} is SemVer`);
|
|
40
|
+
|
|
41
|
+
function readJson(filePath, { optional = false } = {}) {
|
|
42
|
+
if (optional && !fs.existsSync(filePath)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
48
|
+
} catch (error) {
|
|
49
|
+
fail(`failed to read ${filePath}: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fail(message) {
|
|
54
|
+
console.error(`version:check failed: ${message}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Keeps server.json (MCP registry manifest) in lockstep with package.json.
|
|
2
|
+
// Wired into the `version` lifecycle script so `npm version` bumps both.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
6
|
+
const manifest = JSON.parse(fs.readFileSync('server.json', 'utf8'));
|
|
7
|
+
|
|
8
|
+
manifest.version = pkg.version;
|
|
9
|
+
for (const p of manifest.packages ?? []) {
|
|
10
|
+
if (p.version) p.version = pkg.version;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
fs.writeFileSync('server.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
14
|
+
console.log(`server.json synced to v${pkg.version}`);
|
package/server.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.nugehs/gate",
|
|
4
|
+
"description": "One ship/no-ship verdict from aiglare, bouncer, tieline & repoctx — the unified merge gate.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/nugehs/gate",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
14
|
+
"identifier": "@nugehs/gate",
|
|
15
|
+
"version": "0.1.0",
|
|
16
|
+
"runtimeHint": "npx",
|
|
17
|
+
"transport": {
|
|
18
|
+
"type": "stdio"
|
|
19
|
+
},
|
|
20
|
+
"packageArguments": [
|
|
21
|
+
{
|
|
22
|
+
"type": "positional",
|
|
23
|
+
"value": "mcp"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// aiglare — AI/LLM governance guardrails.
|
|
2
|
+
// Dialect: { ok, surfaceCount, summary:{red,amber,green}, surfaces:[{severity,sink,...}] }
|
|
3
|
+
//
|
|
4
|
+
// We deliberately do NOT pass --ci: under --ci aiglare calls process.exit(1),
|
|
5
|
+
// which truncates its own JSON when stdout is a pipe (the classic Node
|
|
6
|
+
// flush-on-exit gotcha). Instead we run it clean (exit 0, full output) and
|
|
7
|
+
// derive the blocking verdict ourselves — a red surface on a side-effectful
|
|
8
|
+
// sink is the "AI auto-triggers an irreversible action" case.
|
|
9
|
+
|
|
10
|
+
import { STATUS } from '../verdict.js';
|
|
11
|
+
|
|
12
|
+
const isBlocking = (s) => s.severity === 'red' && s.sink === 'side-effectful';
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
tool: 'aiglare',
|
|
16
|
+
pkg: '@nugehs/aiglare',
|
|
17
|
+
binRel: 'src/cli.js',
|
|
18
|
+
binName: 'aiglare',
|
|
19
|
+
domain: 'ai-governance',
|
|
20
|
+
label: 'AI governance',
|
|
21
|
+
|
|
22
|
+
args(ctx) {
|
|
23
|
+
return [ctx.path ?? '.', '--format', 'json'];
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
normalize(json) {
|
|
27
|
+
const s = json.summary ?? { red: 0, amber: 0, green: 0 };
|
|
28
|
+
const surfaces = json.surfaces ?? [];
|
|
29
|
+
const count = json.surfaceCount ?? surfaces.length;
|
|
30
|
+
const blocking = surfaces.filter(isBlocking);
|
|
31
|
+
|
|
32
|
+
if (count === 0) {
|
|
33
|
+
return {
|
|
34
|
+
status: STATUS.PASS,
|
|
35
|
+
summary: 'no AI surfaces detected',
|
|
36
|
+
counts: { surfaces: 0, red: 0, amber: 0, green: 0, blocking: 0 },
|
|
37
|
+
findings: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let status;
|
|
42
|
+
if (blocking.length > 0) status = STATUS.FAIL;
|
|
43
|
+
else if (s.red > 0 || s.amber > 0) status = STATUS.WARN;
|
|
44
|
+
else status = STATUS.PASS;
|
|
45
|
+
|
|
46
|
+
const parts = [`${s.red} red`, `${s.amber} amber`, `${s.green} green`];
|
|
47
|
+
if (blocking.length > 0) {
|
|
48
|
+
parts.push(`${blocking.length} blocking side-effect${blocking.length === 1 ? '' : 's'}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const reds = surfaces.filter((x) => x.severity === 'red');
|
|
52
|
+
const ordered = [...blocking, ...reds.filter((x) => !isBlocking(x))];
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
status,
|
|
56
|
+
summary: parts.join(' · '),
|
|
57
|
+
counts: { surfaces: count, red: s.red, amber: s.amber, green: s.green, blocking: blocking.length },
|
|
58
|
+
findings: ordered.slice(0, 5).map((x) => ({
|
|
59
|
+
id: x.file,
|
|
60
|
+
severity: 'red',
|
|
61
|
+
title: x.file,
|
|
62
|
+
sink: x.sink,
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// bouncer — static compliance-controls checker (rule packs).
|
|
2
|
+
// Dialect: { findings: [ { ruleId, packId, standard, severity, surface, status } ] }
|
|
3
|
+
// status: pass — required control found
|
|
4
|
+
// fail — required control missing
|
|
5
|
+
// unknown — surface not locatable; explicitly NOT a pass
|
|
6
|
+
// No findings at all means no packs are configured for this repo → skipped.
|
|
7
|
+
|
|
8
|
+
import { STATUS } from '../verdict.js';
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
tool: 'bouncer',
|
|
12
|
+
pkg: '@nugehs/bouncer',
|
|
13
|
+
binRel: 'src/cli.js',
|
|
14
|
+
binName: 'bouncer',
|
|
15
|
+
domain: 'compliance-controls',
|
|
16
|
+
label: 'Compliance',
|
|
17
|
+
|
|
18
|
+
args() {
|
|
19
|
+
return ['check', '--json'];
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
skip(res) {
|
|
23
|
+
if (/no\s+\S*config(?:\.json)?\s+found/i.test(`${res.stdout}\n${res.stderr}`)) {
|
|
24
|
+
return 'not configured (run `bouncer init`)';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
normalize(json) {
|
|
30
|
+
const findings = Array.isArray(json.findings) ? json.findings : [];
|
|
31
|
+
if (findings.length === 0) {
|
|
32
|
+
return {
|
|
33
|
+
status: STATUS.SKIPPED,
|
|
34
|
+
summary: 'no rule packs configured',
|
|
35
|
+
counts: { rules: 0, pass: 0, fail: 0, unknown: 0 },
|
|
36
|
+
findings: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fail = findings.filter((f) => f.status === 'fail');
|
|
41
|
+
const unknown = findings.filter((f) => f.status === 'unknown');
|
|
42
|
+
const pass = findings.filter((f) => f.status === 'pass');
|
|
43
|
+
|
|
44
|
+
let status;
|
|
45
|
+
if (fail.length > 0) status = STATUS.FAIL;
|
|
46
|
+
else if (unknown.length > 0) status = STATUS.UNKNOWN;
|
|
47
|
+
else status = STATUS.PASS;
|
|
48
|
+
|
|
49
|
+
const parts = [`${pass.length}/${findings.length} controls present`];
|
|
50
|
+
if (fail.length > 0) parts.push(`${fail.length} missing`);
|
|
51
|
+
if (unknown.length > 0) parts.push(`${unknown.length} unverifiable`);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
status,
|
|
55
|
+
summary: parts.join(', '),
|
|
56
|
+
counts: { rules: findings.length, pass: pass.length, fail: fail.length, unknown: unknown.length },
|
|
57
|
+
findings: [...fail, ...unknown].slice(0, 5).map((f) => ({
|
|
58
|
+
id: f.ruleId,
|
|
59
|
+
severity: f.severity,
|
|
60
|
+
title: f.standard,
|
|
61
|
+
surface: f.surface,
|
|
62
|
+
status: f.status,
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import aiglare from './aiglare.js';
|
|
2
|
+
import bouncer from './bouncer.js';
|
|
3
|
+
import tieline from './tieline.js';
|
|
4
|
+
import repoctx from './repoctx.js';
|
|
5
|
+
|
|
6
|
+
// Order is the display order in the report: governance → compliance → contracts → merge.
|
|
7
|
+
export const ADAPTERS = [aiglare, bouncer, tieline, repoctx];
|
|
8
|
+
|
|
9
|
+
export const ADAPTERS_BY_TOOL = Object.fromEntries(ADAPTERS.map((a) => [a.tool, a]));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// repoctx — deterministic merge-readiness gate.
|
|
2
|
+
// Dialect: { verdict: 'PASS'|'WARN'|'FAIL', checks: [ { name, status, summary } ] }
|
|
3
|
+
// This is the merge spine the other three feed; its verdict maps directly.
|
|
4
|
+
|
|
5
|
+
import { STATUS } from '../verdict.js';
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
tool: 'repoctx',
|
|
9
|
+
pkg: '@nugehs/repoctx',
|
|
10
|
+
binRel: 'src/cli.js',
|
|
11
|
+
binName: 'repoctx',
|
|
12
|
+
domain: 'merge-readiness',
|
|
13
|
+
label: 'Merge readiness',
|
|
14
|
+
|
|
15
|
+
args() {
|
|
16
|
+
return ['gate', '--json'];
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
skip(res) {
|
|
20
|
+
if (/not a git repos/i.test(`${res.stdout}\n${res.stderr}`)) {
|
|
21
|
+
return 'not a git repository';
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
normalize(json) {
|
|
27
|
+
const checks = Array.isArray(json.checks) ? json.checks : [];
|
|
28
|
+
const failing = checks.filter((c) => String(c.status).toUpperCase() !== 'PASS');
|
|
29
|
+
const v = String(json.verdict ?? '').toUpperCase();
|
|
30
|
+
|
|
31
|
+
let status;
|
|
32
|
+
if (v === 'PASS') status = STATUS.PASS;
|
|
33
|
+
else if (v === 'WARN') status = STATUS.WARN;
|
|
34
|
+
else if (v) status = STATUS.FAIL; // FAIL / BLOCK / anything blocking
|
|
35
|
+
else status = STATUS.UNKNOWN;
|
|
36
|
+
|
|
37
|
+
const summary =
|
|
38
|
+
failing.length > 0
|
|
39
|
+
? `${failing.length} of ${checks.length} checks need attention`
|
|
40
|
+
: `${checks.length} checks passed`;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
status,
|
|
44
|
+
summary,
|
|
45
|
+
counts: { checks: checks.length, failing: failing.length },
|
|
46
|
+
findings: failing.slice(0, 5).map((c) => ({
|
|
47
|
+
id: c.name,
|
|
48
|
+
severity: String(c.status).toLowerCase(),
|
|
49
|
+
title: c.summary,
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// tieline — static frontend↔backend contract-drift checker.
|
|
2
|
+
// Dialect: { totals:{matched,drift,unverifiable,dead}, drift:[], unverifiable:[], ... }
|
|
3
|
+
// drift — FE call resolves but BE has no such route/method (the bug bucket)
|
|
4
|
+
// unverifiable — call shape couldn't be resolved on one side
|
|
5
|
+
// All-zero totals means no contract map was built (not configured) → skipped.
|
|
6
|
+
|
|
7
|
+
import { STATUS } from '../verdict.js';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
tool: 'tieline',
|
|
11
|
+
pkg: '@nugehs/tieline',
|
|
12
|
+
binRel: 'bin/tieline.mjs',
|
|
13
|
+
binName: 'tieline',
|
|
14
|
+
domain: 'contract-drift',
|
|
15
|
+
label: 'Contract drift',
|
|
16
|
+
|
|
17
|
+
args() {
|
|
18
|
+
return ['check', '--json'];
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
skip(res) {
|
|
22
|
+
if (/no\s+\S*config(?:\.json)?\s+found/i.test(`${res.stdout}\n${res.stderr}`)) {
|
|
23
|
+
return 'not configured (run `tieline init`)';
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
normalize(json) {
|
|
29
|
+
const t = json.totals ?? { matched: 0, drift: 0, unverifiable: 0, dead: 0 };
|
|
30
|
+
const empty = !t.matched && !t.drift && !t.unverifiable && !t.dead;
|
|
31
|
+
// Reaching normalize() means tieline ran cleanly with a config loaded (the
|
|
32
|
+
// genuine "no config" case is caught earlier by skip()). So all-zero totals
|
|
33
|
+
// mean "configured but resolved nothing" — a likely stale config, not an
|
|
34
|
+
// absent one. Surface it as WARN; never claim "not configured".
|
|
35
|
+
if (empty) {
|
|
36
|
+
return {
|
|
37
|
+
status: STATUS.WARN,
|
|
38
|
+
summary: 'contract map empty — 0 endpoints resolved (stale config?)',
|
|
39
|
+
counts: t,
|
|
40
|
+
findings: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let status;
|
|
45
|
+
if (t.drift > 0) status = STATUS.FAIL;
|
|
46
|
+
else if (t.unverifiable > 0) status = STATUS.WARN;
|
|
47
|
+
else status = STATUS.PASS;
|
|
48
|
+
|
|
49
|
+
const parts = [`${t.drift} drift`, `${t.matched} matched`];
|
|
50
|
+
if (t.unverifiable > 0) parts.push(`${t.unverifiable} unverifiable`);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
status,
|
|
54
|
+
summary: parts.join(' · '),
|
|
55
|
+
counts: t,
|
|
56
|
+
findings: (json.drift ?? []).slice(0, 5).map((d) => ({
|
|
57
|
+
id: `${d.method ?? ''} ${d.path ?? d.url ?? ''}`.trim(),
|
|
58
|
+
severity: 'drift',
|
|
59
|
+
title: d.path ?? d.url ?? d.endpoint,
|
|
60
|
+
method: d.method,
|
|
61
|
+
})),
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { runGate } from './orchestrator.js';
|
|
5
|
+
import { renderTerminal } from './report/terminal.js';
|
|
6
|
+
import { startMcpServer } from './mcp.js';
|
|
7
|
+
import { ADAPTERS } from './adapters/index.js';
|
|
8
|
+
|
|
9
|
+
const TOOLS = ADAPTERS.map((a) => a.tool);
|
|
10
|
+
const KNOWN = new Set(TOOLS);
|
|
11
|
+
|
|
12
|
+
const HELP = `gate — one verdict from aiglare, bouncer, tieline & repoctx
|
|
13
|
+
|
|
14
|
+
Usage: gate [path] [options]
|
|
15
|
+
|
|
16
|
+
Runs the four nugehs checks against a repo and merges their results into a
|
|
17
|
+
single ship/no-ship verdict.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--json Emit the unified verdict as JSON
|
|
21
|
+
--ci Exit non-zero when the gate fails (blocking by default)
|
|
22
|
+
--strict Treat WARN/UNKNOWN as blocking too
|
|
23
|
+
--only <list> Run only these tools (comma-separated: ${TOOLS.join(',')})
|
|
24
|
+
--skip <list> Skip these tools
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
|
|
27
|
+
Subcommand:
|
|
28
|
+
gate mcp Start the MCP server (stdio) — exposes gate_check + list_checks
|
|
29
|
+
|
|
30
|
+
A run where no domain actually executes (everything skipped or deselected) is
|
|
31
|
+
NOT a pass — under --ci it fails, so a typo can't silently defeat the gate.
|
|
32
|
+
|
|
33
|
+
Tool resolution (per tool, first hit wins):
|
|
34
|
+
1. GATE_<TOOL>_BIN env var 2. installed @nugehs/<tool> 3. ../<tool> sibling checkout
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
gate # audit the current repo
|
|
38
|
+
gate ./service --ci # fail the build on a blocking verdict
|
|
39
|
+
gate --only aiglare,repoctx --json # just those two, machine-readable
|
|
40
|
+
gate --skip tieline --strict # everything but tieline, warnings block
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
function parseList(v) {
|
|
44
|
+
return v
|
|
45
|
+
.split(',')
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pure arg parser — returns `{ help }`, `{ error: {code, message} }`, or `{ args }`.
|
|
52
|
+
* Kept side-effect-free (no process.exit, no process.cwd) so it is unit-testable.
|
|
53
|
+
*/
|
|
54
|
+
export function parseArgs(argv) {
|
|
55
|
+
const args = { path: null, json: false, ci: false, strict: false, only: null, skip: [] };
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i];
|
|
58
|
+
if (a === '-h' || a === '--help') {
|
|
59
|
+
return { help: true };
|
|
60
|
+
} else if (a === '--json') {
|
|
61
|
+
args.json = true;
|
|
62
|
+
} else if (a === '--ci') {
|
|
63
|
+
args.ci = true;
|
|
64
|
+
} else if (a === '--strict') {
|
|
65
|
+
args.strict = true;
|
|
66
|
+
} else if (a === '--only' || a === '--skip') {
|
|
67
|
+
const v = argv[i + 1];
|
|
68
|
+
if (v === undefined || v.startsWith('-')) {
|
|
69
|
+
return { error: { code: 2, message: `${a} requires a comma-separated tool list (${TOOLS.join(',')})` } };
|
|
70
|
+
}
|
|
71
|
+
i++;
|
|
72
|
+
const list = parseList(v);
|
|
73
|
+
if (list.length === 0) {
|
|
74
|
+
return { error: { code: 2, message: `${a} requires at least one tool` } };
|
|
75
|
+
}
|
|
76
|
+
const unknown = list.filter((t) => !KNOWN.has(t));
|
|
77
|
+
if (unknown.length) {
|
|
78
|
+
return { error: { code: 2, message: `Unknown tool: ${unknown.join(', ')} (expected one of ${TOOLS.join(', ')})` } };
|
|
79
|
+
}
|
|
80
|
+
if (a === '--only') args.only = list;
|
|
81
|
+
else args.skip = list;
|
|
82
|
+
} else if (a.startsWith('-')) {
|
|
83
|
+
return { error: { code: 2, message: `Unknown option: ${a}` } };
|
|
84
|
+
} else {
|
|
85
|
+
args.path = a;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { args };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
const argv = process.argv.slice(2);
|
|
93
|
+
if (argv[0] === 'mcp') {
|
|
94
|
+
await startMcpServer();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const parsed = parseArgs(argv);
|
|
99
|
+
if (parsed.help) {
|
|
100
|
+
process.stdout.write(HELP);
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
if (parsed.error) {
|
|
104
|
+
console.error(parsed.error.message);
|
|
105
|
+
process.exit(parsed.error.code);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = await runGate(parsed.args);
|
|
109
|
+
|
|
110
|
+
if (parsed.args.json) {
|
|
111
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
112
|
+
} else {
|
|
113
|
+
process.stdout.write(renderTerminal(result) + '\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.gate.failed) process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Only run when invoked as a binary — importing this module (e.g. in tests) is
|
|
120
|
+
// side-effect-free. Resolve symlinks on both sides so it still fires when run
|
|
121
|
+
// through a symlinked bin (npm link, `npm i -g`, npx), where argv[1] is the link.
|
|
122
|
+
export function isMainModule() {
|
|
123
|
+
if (!process.argv[1]) return false;
|
|
124
|
+
try {
|
|
125
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isMainModule()) {
|
|
132
|
+
main().catch((e) => {
|
|
133
|
+
console.error(`gate: ${e?.stack ?? e}`);
|
|
134
|
+
process.exit(2);
|
|
135
|
+
});
|
|
136
|
+
}
|
package/src/color.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Zero-dependency ANSI color, matching the rest of the nugehs toolchain.
|
|
2
|
+
// Honors NO_COLOR and falls back to plain text when stdout is not a TTY.
|
|
3
|
+
|
|
4
|
+
const enabled = !process.env.NO_COLOR && process.stdout.isTTY === true;
|
|
5
|
+
|
|
6
|
+
const wrap = (open, close) => (s) => (enabled ? `[${open}m${s}[${close}m` : String(s));
|
|
7
|
+
|
|
8
|
+
export const color = {
|
|
9
|
+
enabled,
|
|
10
|
+
red: wrap(31, 39),
|
|
11
|
+
green: wrap(32, 39),
|
|
12
|
+
yellow: wrap(33, 39),
|
|
13
|
+
blue: wrap(34, 39),
|
|
14
|
+
dim: wrap(2, 22),
|
|
15
|
+
bold: wrap(1, 22),
|
|
16
|
+
};
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { runGate } from './orchestrator.js';
|
|
6
|
+
import { ADAPTERS } from './adapters/index.js';
|
|
7
|
+
|
|
8
|
+
// Hand-rolled JSON-RPC 2.0 server over stdio — no SDK dependency, matching the
|
|
9
|
+
// aiglare/repoctx MCP server convention. Reads line-delimited JSON from stdin
|
|
10
|
+
// and writes responses to stdout.
|
|
11
|
+
|
|
12
|
+
const protocolVersion = '2025-06-18';
|
|
13
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
15
|
+
|
|
16
|
+
const TOOL_NAMES = ADAPTERS.map((a) => a.tool);
|
|
17
|
+
|
|
18
|
+
const CHECK_DESCRIPTIONS = {
|
|
19
|
+
aiglare:
|
|
20
|
+
'AI/LLM governance guardrails — flags model output reaching a user or a side-effect without confidence handling, fallback, validation, or human-in-the-loop.',
|
|
21
|
+
bouncer:
|
|
22
|
+
"Static compliance-controls — verifies the controls a regulation requires (UK Online Safety Act, ICO Children's Code) actually exist in the code.",
|
|
23
|
+
tieline: 'Frontend↔backend contract drift — frontend API calls that resolve to no backend route.',
|
|
24
|
+
repoctx: 'Deterministic merge-readiness gate — secret safety, risk review, release discipline, required checks.',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const tools = [
|
|
28
|
+
{
|
|
29
|
+
name: 'gate_check',
|
|
30
|
+
title: 'Run the unified gate',
|
|
31
|
+
description:
|
|
32
|
+
'Run aiglare, bouncer, tieline & repoctx against a repo and return one normalized verdict (pass|warn|fail), with each tool reduced to a status and the blocking reasons. The single "can this ship?" call for the nugehs toolchain.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
path: { type: 'string', description: 'Repository path. Defaults to the current working directory.' },
|
|
37
|
+
only: {
|
|
38
|
+
type: 'array',
|
|
39
|
+
items: { type: 'string', enum: TOOL_NAMES },
|
|
40
|
+
description: 'Run only these checks.',
|
|
41
|
+
},
|
|
42
|
+
skip: {
|
|
43
|
+
type: 'array',
|
|
44
|
+
items: { type: 'string', enum: TOOL_NAMES },
|
|
45
|
+
description: 'Skip these checks.',
|
|
46
|
+
},
|
|
47
|
+
ci: { type: 'boolean', description: 'Compute gate.failed as a CI gate would (blocks on a fail verdict).' },
|
|
48
|
+
strict: { type: 'boolean', description: 'Treat WARN/UNKNOWN as blocking too.' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'list_checks',
|
|
54
|
+
title: 'List the gate checks',
|
|
55
|
+
description: 'List the four checks gate runs, each with the domain it covers and what it answers.',
|
|
56
|
+
inputSchema: { type: 'object', properties: {} },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
async function dispatchTool(name, args) {
|
|
61
|
+
switch (name) {
|
|
62
|
+
case 'gate_check':
|
|
63
|
+
return runGate({
|
|
64
|
+
path: args.path,
|
|
65
|
+
only: args.only ?? null,
|
|
66
|
+
skip: args.skip ?? [],
|
|
67
|
+
ci: args.ci ?? false,
|
|
68
|
+
strict: args.strict ?? false,
|
|
69
|
+
});
|
|
70
|
+
case 'list_checks':
|
|
71
|
+
return {
|
|
72
|
+
checks: ADAPTERS.map((a) => ({
|
|
73
|
+
tool: a.tool,
|
|
74
|
+
domain: a.domain,
|
|
75
|
+
label: a.label,
|
|
76
|
+
description: CHECK_DESCRIPTIONS[a.tool] ?? '',
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
default:
|
|
80
|
+
throw new McpProtocolError(-32602, `Unknown tool: ${name}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function startMcpServer({ input = process.stdin, output = process.stdout } = {}) {
|
|
85
|
+
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
86
|
+
for await (const line of rl) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed) continue;
|
|
89
|
+
|
|
90
|
+
let message;
|
|
91
|
+
try {
|
|
92
|
+
message = JSON.parse(trimmed);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
writeMessage(output, errorResponse(null, -32700, `Parse error: ${error.message}`));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const response = await handleMessage(message);
|
|
99
|
+
if (response) writeMessage(output, response);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function handleMessage(message) {
|
|
104
|
+
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
105
|
+
return errorResponse(message?.id ?? null, -32600, 'Invalid JSON-RPC request');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
switch (message.method) {
|
|
110
|
+
case 'initialize':
|
|
111
|
+
return successResponse(message.id, {
|
|
112
|
+
protocolVersion,
|
|
113
|
+
capabilities: { tools: { listChanged: false } },
|
|
114
|
+
serverInfo: { name: packageJson.name, version: packageJson.version },
|
|
115
|
+
});
|
|
116
|
+
case 'notifications/initialized':
|
|
117
|
+
return undefined;
|
|
118
|
+
case 'ping':
|
|
119
|
+
return successResponse(message.id, {});
|
|
120
|
+
case 'tools/list':
|
|
121
|
+
return successResponse(message.id, { tools });
|
|
122
|
+
case 'tools/call':
|
|
123
|
+
return successResponse(message.id, await callTool(message.params));
|
|
124
|
+
default:
|
|
125
|
+
return errorResponse(message.id, -32601, `Method not found: ${message.method}`);
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const code = error instanceof McpProtocolError ? error.code : -32603;
|
|
129
|
+
return errorResponse(message.id, code, error instanceof Error ? error.message : String(error));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function callTool(params = {}) {
|
|
134
|
+
if (!params || typeof params !== 'object') {
|
|
135
|
+
throw new McpProtocolError(-32602, 'Tool call params must be an object');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const name = params.name;
|
|
139
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
140
|
+
throw new McpProtocolError(-32602, 'Tool name is required');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const args = params.arguments ?? {};
|
|
144
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
145
|
+
throw new McpProtocolError(-32602, 'Tool arguments must be an object');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let result;
|
|
149
|
+
try {
|
|
150
|
+
result = await dispatchTool(name, args);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof McpProtocolError) throw error;
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
155
|
+
isError: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
161
|
+
structuredContent: result,
|
|
162
|
+
isError: false,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function successResponse(id, result) {
|
|
167
|
+
return { jsonrpc: '2.0', id, result };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function errorResponse(id, code, message) {
|
|
171
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function writeMessage(output, message) {
|
|
175
|
+
output.write(`${JSON.stringify(message)}\n`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class McpProtocolError extends Error {
|
|
179
|
+
constructor(code, message) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.code = code;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Run the selected adapters against a repo, in parallel, and merge their
|
|
2
|
+
// per-domain results into one unified verdict.
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ADAPTERS } from './adapters/index.js';
|
|
6
|
+
import { resolveTool } from './resolve.js';
|
|
7
|
+
import { runTool, extractJson } from './run.js';
|
|
8
|
+
import { mergeVerdict, STATUS } from './verdict.js';
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
export async function runAdapter(adapter, ctx) {
|
|
13
|
+
const base = { tool: adapter.tool, domain: adapter.domain, label: adapter.label };
|
|
14
|
+
|
|
15
|
+
const resolved = resolveTool(adapter);
|
|
16
|
+
if (!resolved) {
|
|
17
|
+
return {
|
|
18
|
+
...base,
|
|
19
|
+
status: STATUS.SKIPPED,
|
|
20
|
+
summary: `${adapter.tool} not installed`,
|
|
21
|
+
counts: {},
|
|
22
|
+
findings: [],
|
|
23
|
+
available: false,
|
|
24
|
+
source: null,
|
|
25
|
+
durationMs: 0,
|
|
26
|
+
exitCode: null,
|
|
27
|
+
error: `Could not resolve ${adapter.pkg}. Install it, or set GATE_${adapter.tool.toUpperCase()}_BIN.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const startedAt = Date.now();
|
|
32
|
+
const res = await runTool(resolved.entry, adapter.args(ctx), { cwd: ctx.path });
|
|
33
|
+
const durationMs = Date.now() - startedAt;
|
|
34
|
+
const meta = { available: true, source: resolved.source, durationMs, exitCode: res.exitCode };
|
|
35
|
+
|
|
36
|
+
if (res.spawnError || res.timedOut) {
|
|
37
|
+
return {
|
|
38
|
+
...base,
|
|
39
|
+
...meta,
|
|
40
|
+
status: STATUS.ERROR,
|
|
41
|
+
summary: res.timedOut ? 'timed out' : 'failed to run',
|
|
42
|
+
counts: {},
|
|
43
|
+
findings: [],
|
|
44
|
+
error: res.spawnError ?? 'timed out',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A tool that isn't configured for this repo (no config file, not a git repo)
|
|
49
|
+
// is "not applicable" — skip it rather than treating it as an error.
|
|
50
|
+
const skipReason = adapter.skip?.(res);
|
|
51
|
+
if (skipReason) {
|
|
52
|
+
return {
|
|
53
|
+
...base,
|
|
54
|
+
...meta,
|
|
55
|
+
status: STATUS.SKIPPED,
|
|
56
|
+
summary: skipReason,
|
|
57
|
+
counts: {},
|
|
58
|
+
findings: [],
|
|
59
|
+
error: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const json = extractJson(res.stdout);
|
|
64
|
+
if (!json) {
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
...meta,
|
|
68
|
+
status: STATUS.ERROR,
|
|
69
|
+
summary: 'no parseable output',
|
|
70
|
+
counts: {},
|
|
71
|
+
findings: [],
|
|
72
|
+
error: (res.stderr || res.stdout || '').trim().split('\n').slice(0, 3).join(' ') || 'empty output',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const norm = adapter.normalize(json);
|
|
78
|
+
return { ...base, ...meta, error: null, ...norm };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
...meta,
|
|
83
|
+
status: STATUS.ERROR,
|
|
84
|
+
summary: 'could not interpret output',
|
|
85
|
+
counts: {},
|
|
86
|
+
findings: [],
|
|
87
|
+
error: String(e?.message ?? e),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {{path?:string, only?:string[]|null, skip?:string[], ci?:boolean, strict?:boolean}} opts
|
|
94
|
+
*/
|
|
95
|
+
export async function runGate(opts = {}) {
|
|
96
|
+
const target = path.resolve(opts.path ?? process.cwd());
|
|
97
|
+
const only = opts.only ?? null;
|
|
98
|
+
const skip = opts.skip ?? [];
|
|
99
|
+
|
|
100
|
+
const selected = ADAPTERS.filter(
|
|
101
|
+
(a) => (!only || only.includes(a.tool)) && !skip.includes(a.tool)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const ctx = { path: target };
|
|
105
|
+
const domains = await Promise.all(selected.map((a) => runAdapter(a, ctx)));
|
|
106
|
+
|
|
107
|
+
const merged = mergeVerdict(domains, { ci: opts.ci, strict: opts.strict });
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
schemaVersion: SCHEMA_VERSION,
|
|
111
|
+
tool: 'gate',
|
|
112
|
+
generatedAt: new Date().toISOString(),
|
|
113
|
+
repo: { root: target, name: path.basename(target) },
|
|
114
|
+
...merged,
|
|
115
|
+
domains,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { color } from '../color.js';
|
|
2
|
+
import { STATUS } from '../verdict.js';
|
|
3
|
+
|
|
4
|
+
const ICON = {
|
|
5
|
+
[STATUS.PASS]: () => color.green('✓'),
|
|
6
|
+
[STATUS.WARN]: () => color.yellow('⚠'),
|
|
7
|
+
[STATUS.FAIL]: () => color.red('✗'),
|
|
8
|
+
[STATUS.UNKNOWN]: () => color.yellow('?'),
|
|
9
|
+
[STATUS.SKIPPED]: () => color.dim('·'),
|
|
10
|
+
[STATUS.ERROR]: () => color.red('!'),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const STATUS_WORD = {
|
|
14
|
+
[STATUS.PASS]: (s) => color.green(s),
|
|
15
|
+
[STATUS.WARN]: (s) => color.yellow(s),
|
|
16
|
+
[STATUS.FAIL]: (s) => color.red(s),
|
|
17
|
+
[STATUS.UNKNOWN]: (s) => color.yellow(s),
|
|
18
|
+
[STATUS.SKIPPED]: (s) => color.dim(s),
|
|
19
|
+
[STATUS.ERROR]: (s) => color.red(s),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function pad(s, n) {
|
|
23
|
+
return s.length >= n ? s : s + ' '.repeat(n - s.length);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderTerminal(result) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
lines.push('');
|
|
29
|
+
lines.push(` ${color.bold('gate')} ${color.dim('·')} ${result.repo.root}`);
|
|
30
|
+
lines.push('');
|
|
31
|
+
|
|
32
|
+
const labelWidth = Math.max(...result.domains.map((d) => d.label.length), 0);
|
|
33
|
+
for (const d of result.domains) {
|
|
34
|
+
const icon = (ICON[d.status] ?? ICON[STATUS.ERROR])();
|
|
35
|
+
const word = (STATUS_WORD[d.status] ?? STATUS_WORD[STATUS.ERROR])(pad(d.status, 8));
|
|
36
|
+
lines.push(` ${icon} ${color.bold(pad(d.label, labelWidth))} ${word} ${color.dim(d.summary)}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
lines.push('');
|
|
40
|
+
const v = result.verdict;
|
|
41
|
+
const verdictWord =
|
|
42
|
+
v === STATUS.FAIL ? color.red(color.bold('FAIL')) : v === STATUS.WARN ? color.yellow(color.bold('WARN')) : color.green(color.bold('PASS'));
|
|
43
|
+
|
|
44
|
+
const c = result.summary;
|
|
45
|
+
const tail = [];
|
|
46
|
+
if (c.fail) tail.push(`${c.fail} blocking`);
|
|
47
|
+
if (c.warn) tail.push(`${c.warn} warn`);
|
|
48
|
+
if (c.unknown) tail.push(`${c.unknown} unknown`);
|
|
49
|
+
if (c.error) tail.push(`${c.error} error`);
|
|
50
|
+
if (c.skipped) tail.push(`${c.skipped} skipped`);
|
|
51
|
+
const tailStr = tail.length ? color.dim(` — ${tail.join(' · ')}`) : '';
|
|
52
|
+
|
|
53
|
+
if (result.gate.nothingChecked) {
|
|
54
|
+
lines.push(` verdict: ${color.yellow(color.bold('NO CHECKS RAN'))}${color.dim(' — every domain was skipped or deselected')}`);
|
|
55
|
+
} else {
|
|
56
|
+
lines.push(` verdict: ${verdictWord}${tailStr}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (result.gate.failed) {
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(` ${color.red('✗ gate failed')}`);
|
|
62
|
+
for (const r of result.gate.reasons) lines.push(` ${color.dim('•')} ${r}`);
|
|
63
|
+
}
|
|
64
|
+
lines.push('');
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
package/src/resolve.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Find each tool's CLI entrypoint without hard-coding install layout.
|
|
2
|
+
//
|
|
3
|
+
// Resolution order, first hit wins:
|
|
4
|
+
// 1. GATE_<TOOL>_BIN env var — explicit override (CI, exotic installs)
|
|
5
|
+
// 2. node_modules — the published @nugehs/<tool> dependency
|
|
6
|
+
// 3. sibling checkout — ../<tool> next to this repo (local dev)
|
|
7
|
+
//
|
|
8
|
+
// This lets gate work both as a published package (deps resolve from
|
|
9
|
+
// node_modules) and straight from a clone sitting alongside the four tools.
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{tool:string, pkg:string, binRel:string, binName?:string}} spec
|
|
21
|
+
* @param {{root?:string}} [opts] - override the repo root the sibling fallback is
|
|
22
|
+
* resolved against (defaults to this package's root; injectable for tests).
|
|
23
|
+
* @returns {{entry:string, source:'env'|'node_modules'|'sibling'}|null}
|
|
24
|
+
*/
|
|
25
|
+
export function resolveTool({ tool, pkg, binRel, binName }, { root = ROOT } = {}) {
|
|
26
|
+
const envKey = `GATE_${tool.toUpperCase()}_BIN`;
|
|
27
|
+
const override = process.env[envKey];
|
|
28
|
+
if (override && fs.existsSync(override)) {
|
|
29
|
+
return { entry: override, source: 'env' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
|
|
34
|
+
const dir = path.dirname(pkgJsonPath);
|
|
35
|
+
const meta = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
36
|
+
const bin =
|
|
37
|
+
typeof meta.bin === 'string'
|
|
38
|
+
? meta.bin
|
|
39
|
+
: (meta.bin && (meta.bin[binName] ?? Object.values(meta.bin)[0]));
|
|
40
|
+
if (bin) {
|
|
41
|
+
const entry = path.join(dir, bin);
|
|
42
|
+
if (fs.existsSync(entry)) return { entry, source: 'node_modules' };
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// not installed — fall through to the sibling checkout
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sibling = path.resolve(root, '..', tool, binRel);
|
|
49
|
+
if (fs.existsSync(sibling)) return { entry: sibling, source: 'sibling' };
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
}
|
package/src/run.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Spawn a tool's CLI under the current node, capture stdout, and pull JSON out.
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const MAX_BUFFER = 32 * 1024 * 1024; // tool reports can be large; never truncate
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a tool entrypoint and fully capture its output. Never rejects — a
|
|
9
|
+
* non-zero exit is expected (these tools exit non-zero when they find
|
|
10
|
+
* problems), and stdout/stderr are captured regardless of exit code.
|
|
11
|
+
*
|
|
12
|
+
* @returns {Promise<{exitCode:number|null, stdout:string, stderr:string, spawnError?:string, timedOut?:boolean}>}
|
|
13
|
+
*/
|
|
14
|
+
export function runTool(entry, args, { cwd, timeoutMs = 120_000 } = {}) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
execFile(
|
|
17
|
+
process.execPath,
|
|
18
|
+
[entry, ...args],
|
|
19
|
+
{ cwd, env: process.env, timeout: timeoutMs, maxBuffer: MAX_BUFFER },
|
|
20
|
+
(err, stdout, stderr) => {
|
|
21
|
+
if (!err) {
|
|
22
|
+
resolve({ exitCode: 0, stdout, stderr });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (err.killed) {
|
|
26
|
+
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// A normal non-zero exit surfaces as err.code === <number>; stdout/stderr
|
|
30
|
+
// are still fully populated. Anything else (ENOENT, maxBuffer) is a real failure.
|
|
31
|
+
if (typeof err.code === 'number') {
|
|
32
|
+
resolve({ exitCode: err.code, stdout, stderr });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
resolve({ exitCode: null, stdout, stderr, spawnError: String(err.message ?? err) });
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort JSON extraction. Tools emit clean JSON under --json, but a stray
|
|
43
|
+
* leading line shouldn't break us — fall back to the outermost {...} or [...].
|
|
44
|
+
*/
|
|
45
|
+
export function extractJson(text) {
|
|
46
|
+
if (!text) return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text);
|
|
49
|
+
} catch {
|
|
50
|
+
// fall through
|
|
51
|
+
}
|
|
52
|
+
const start = text.search(/[[{]/);
|
|
53
|
+
if (start === -1) return null;
|
|
54
|
+
const open = text[start];
|
|
55
|
+
const close = open === '{' ? '}' : ']';
|
|
56
|
+
const end = text.lastIndexOf(close);
|
|
57
|
+
if (end <= start) return null;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/verdict.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The unified verdict model.
|
|
2
|
+
//
|
|
3
|
+
// Every tool speaks its own dialect — aiglare has red/amber/green, bouncer has
|
|
4
|
+
// pass/fail/unknown, tieline has matched/drift, repoctx has PASS/WARN/FAIL.
|
|
5
|
+
// Adapters normalize each into a single STATUS. This module rolls a set of
|
|
6
|
+
// per-domain results up into one verdict and decides whether the gate fails.
|
|
7
|
+
|
|
8
|
+
/** The normalized status vocabulary every adapter maps onto. */
|
|
9
|
+
export const STATUS = Object.freeze({
|
|
10
|
+
PASS: 'pass', // the check ran and is clean
|
|
11
|
+
WARN: 'warn', // ran, found something worth a look, not blocking
|
|
12
|
+
FAIL: 'fail', // ran, found a blocking problem
|
|
13
|
+
UNKNOWN: 'unknown', // ran, but could not determine — explicitly "not a pass"
|
|
14
|
+
SKIPPED: 'skipped', // not applicable / not configured for this repo
|
|
15
|
+
ERROR: 'error', // the tool could not be run or returned garbage
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// How bad each status is when rolling up. SKIPPED is invisible to the verdict;
|
|
19
|
+
// UNKNOWN and ERROR sit at WARN level so they never silently pass.
|
|
20
|
+
const RANK = Object.freeze({
|
|
21
|
+
pass: 0,
|
|
22
|
+
skipped: 0,
|
|
23
|
+
unknown: 2,
|
|
24
|
+
warn: 2,
|
|
25
|
+
error: 2,
|
|
26
|
+
fail: 3,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function statusForRank(rank) {
|
|
30
|
+
if (rank >= 3) return STATUS.FAIL;
|
|
31
|
+
if (rank >= 2) return STATUS.WARN;
|
|
32
|
+
return STATUS.PASS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Roll per-domain results into one verdict.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array<{tool:string,label:string,status:string,summary:string}>} domains
|
|
39
|
+
* @param {{ci?:boolean, strict?:boolean}} opts
|
|
40
|
+
* ci — when true, a blocking verdict sets gate.failed (drives a non-zero exit)
|
|
41
|
+
* strict — when true, WARN-level results also block (otherwise only FAIL blocks)
|
|
42
|
+
*/
|
|
43
|
+
export function mergeVerdict(domains, { ci = false, strict = false } = {}) {
|
|
44
|
+
const counts = {
|
|
45
|
+
domains: domains.length,
|
|
46
|
+
pass: 0,
|
|
47
|
+
warn: 0,
|
|
48
|
+
fail: 0,
|
|
49
|
+
unknown: 0,
|
|
50
|
+
skipped: 0,
|
|
51
|
+
error: 0,
|
|
52
|
+
};
|
|
53
|
+
for (const d of domains) {
|
|
54
|
+
if (counts[d.status] === undefined) counts[d.status] = 0;
|
|
55
|
+
counts[d.status] += 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// SKIPPED domains do not influence the verdict — the check simply did not apply.
|
|
59
|
+
const ran = domains.filter((d) => d.status !== STATUS.SKIPPED);
|
|
60
|
+
const nothingChecked = ran.length === 0;
|
|
61
|
+
let rank = 0;
|
|
62
|
+
for (const d of ran) rank = Math.max(rank, RANK[d.status] ?? 0);
|
|
63
|
+
const verdict = statusForRank(rank);
|
|
64
|
+
|
|
65
|
+
const blocks = (d) =>
|
|
66
|
+
d.status === STATUS.FAIL ||
|
|
67
|
+
(strict && (d.status === STATUS.WARN || d.status === STATUS.UNKNOWN || d.status === STATUS.ERROR));
|
|
68
|
+
|
|
69
|
+
const reasons = domains.filter(blocks).map((d) => `${d.label}: ${d.summary}`);
|
|
70
|
+
// A gate that checked nothing must not read as a clean pass — that's how a
|
|
71
|
+
// typo (`--skip` everything, a bare trailing `--only`) silently defeats CI.
|
|
72
|
+
if (nothingChecked) reasons.unshift('no checks ran — every domain was skipped or deselected');
|
|
73
|
+
|
|
74
|
+
const failed = ci && (verdict === STATUS.FAIL || nothingChecked || (strict && verdict === STATUS.WARN));
|
|
75
|
+
counts.ran = ran.length;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
verdict,
|
|
79
|
+
ok: verdict !== STATUS.FAIL && !nothingChecked,
|
|
80
|
+
summary: counts,
|
|
81
|
+
gate: { ci, strict, failed, nothingChecked, reasons },
|
|
82
|
+
};
|
|
83
|
+
}
|