mirror of
https://github.com/Heretek-AI/Agent-Proof.git
synced 2026-11-07 10:46:10 -10:00
docs: add comprehensive code comments and AGENTS.md, CLAUDE.md, GEMINI.md instructions
This commit is contained in:
1 parent
3e4896a315
commit
fff4c4571f
34 files changed
+1710
-1007
No files matched your search
@@ -0,0 +1,100 @@
|
||||
# AGENTS.md — Multi-Agent Development & Mechanical Governance Protocol
|
||||
|
||||
Welcome to **Agent-Proof** (`@heretek-ai/agent-proof`). This document establishes the multi-agent operational standards, mechanical hard-gate execution rules, and architecture guidelines for autonomous AI coding agents operating within this repository.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Primary Directives for AI Agents
|
||||
|
||||
1. **Deterministic Mechanical Governance Over Soft Prompts**:
|
||||
- Never attempt to bypass, disable, or modify mechanical hard gates (`lefthook.yml`, `.claude/hooks.json`, `biome.json`, `ruff.toml`, `.aislop/config.yml`).
|
||||
- Governance files are intentionally locked with read-only permissions (`chmod 0444`). Do not attempt to overwrite or change their permissions unless explicitly instructed by a human administrator.
|
||||
- Never bypass git hooks with `git commit --no-verify`. All commits must pass mechanical pre-commit validation in `< 2.0s`.
|
||||
|
||||
2. **Zero-Tolerance for AI Slop**:
|
||||
- **No Empty Catch Blocks / Swallowed Errors**: Always handle exceptions explicitly, log with context, rethrow with cause (`new Error(msg, { cause: err })`), or return explicit typed failure results.
|
||||
- **Strict Type Safety**: Avoid `as any` or unchecked type assertions. Use TypeScript type guards, interfaces, or validated schemas (e.g. Zod).
|
||||
- **No Hallucinated Imports**: Only import from installed packages declared in `package.json` / `pyproject.toml` or verified relative paths within the project.
|
||||
- **Clean ASTs & Formatting**: Ensure code adheres to Biome / Ruff formatting standards without lint violations or dead code.
|
||||
|
||||
3. **Autonomous Self-Correction Protocol**:
|
||||
- When a mechanical gate fails, parse the emitted **LSP Diagnostic Envelope** (`https://json.schemastore.org/lsif.json`).
|
||||
- Extract the `repair_tokens` provided in `diagnostics[].repair_instruction` to apply deterministic fixes immediately rather than guessing or engaging in trial-and-error edits.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 3-Tier Mechanical Gate Architecture
|
||||
|
||||
| Stage | Trigger Event | Latency Target | Scope | Canonical Engines |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Stage 1: Pre-Tool / Edit** | `PostFileEdit` agent hook | `< 300ms` | Single modified file AST | Biome (`--write`), Ruff (`--fix`), SkillCheck |
|
||||
| **Stage 2: Hard Git Gate** | `git commit` (Pre-Commit) | `< 2.0s` | Staged blobs (`--staged`) | Lefthook parallel: Biome, Ruff, AISlop, TruffleHog, Typos, Actionlint |
|
||||
| **Stage 3: CI & Graph Audit** | `git push` / PR Pipeline | Unconstrained | Full codebase graph | Fallow (dead code / arch drift), Sherif (monorepo), OWASP Noir (API surface) |
|
||||
|
||||
---
|
||||
|
||||
## 📂 Repository Architecture & Key Directories
|
||||
|
||||
```
|
||||
Agent-Proof/
|
||||
├── bin/ # Zero-dependency platform binary launchers
|
||||
│ ├── agent-proof.js # Primary CLI launcher with optionalDependencies resolution
|
||||
│ └── agent-gate.js # Compatibility alias launcher
|
||||
├── src/ # TypeScript source code
|
||||
│ ├── detector/ # Multi-stack auto-detection engine
|
||||
│ ├── generator/ # Multi-tier configuration generator & templates
|
||||
│ ├── formatter/ # ANSI-stripper & LSP diagnostic streaming engine
|
||||
│ ├── installer/ # Git hook installer & POSIX permission lock-in
|
||||
│ ├── runner/ # Mechanical gate stage execution runner
|
||||
│ ├── types/ # TypeScript interfaces and schema types
|
||||
│ ├── cli.ts # Command-line interface dispatcher
|
||||
│ └── index.ts # Package public API exports
|
||||
├── tests/ # Vitest unit and integration test suites
|
||||
│ ├── detector.test.ts # Polyglot stack detection tests
|
||||
│ ├── generator.test.ts # Config codegen tests
|
||||
│ ├── formatter.test.ts # Diagnostic streamer and parser tests
|
||||
│ ├── launcher.test.ts # Binary resolution tests
|
||||
│ ├── lockin.test.ts # Git hook & permission locking tests
|
||||
│ └── e2e.test.ts # End-to-end repository initialization test
|
||||
├── scripts/ # Development & verification scripts
|
||||
│ └── verify-real-repo.mjs # Real-world sandbox lifecycle validation script
|
||||
├── .github/workflows/ # GitHub Actions CI/CD
|
||||
│ ├── ci.yml # Continuous integration test runner
|
||||
│ └── publish.yml # Trusted publishing (OIDC) to npm
|
||||
├── package.json # Package definition, scripts, optionalDependencies matrix
|
||||
├── tsconfig.json # Strict NodeNext TypeScript configuration
|
||||
└── tsup.config.ts # Bundle configuration for dual CJS/ESM distribution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Common CLI & Development Commands
|
||||
|
||||
```bash
|
||||
# Build TypeScript bundles into dist/
|
||||
npm run build
|
||||
|
||||
# Run Vitest test suite
|
||||
npm test
|
||||
|
||||
# Run TypeScript typechecks
|
||||
npm run typecheck
|
||||
|
||||
# Run real-world sandbox lifecycle validation
|
||||
npm run test:real-repo
|
||||
|
||||
# Test CLI commands locally
|
||||
node bin/agent-proof.js --help
|
||||
node bin/agent-proof.js detect
|
||||
node bin/agent-proof.js init [directory]
|
||||
node bin/agent-proof.js run pre-commit
|
||||
node bin/agent-proof.js status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Supply Chain Standards
|
||||
|
||||
- **NPM Package**: `@heretek-ai/agent-proof` published to `https://registry.npmjs.org/@heretek-ai/agent-proof`.
|
||||
- **Publishing Method**: OpenID Connect (OIDC) **Trusted Publishing** via `.github/workflows/publish.yml` with `id-token: write` and npm provenance.
|
||||
- **Dependencies**: Keep runtime dependencies at zero. All utilities are compiled into `dist/` or distributed via platform-specific binary packages under `optionalDependencies`.
|
||||
@@ -0,0 +1,91 @@
|
||||
# CLAUDE.md — Claude Code Guidelines for Agent-Proof
|
||||
|
||||
This guide provides instructions and reference material for **Claude Code** sessions working on `@heretek-ai/agent-proof`.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Reference Commands
|
||||
|
||||
```bash
|
||||
# Build the project (tsup bundles to dist/)
|
||||
npm run build
|
||||
|
||||
# Run unit and integration tests (Vitest)
|
||||
npm test
|
||||
|
||||
# Run strict TypeScript type checks
|
||||
npm run typecheck
|
||||
|
||||
# Run real-world sandbox verification script
|
||||
npm run test:real-repo
|
||||
|
||||
# Test CLI locally
|
||||
node bin/agent-proof.js --version
|
||||
node bin/agent-proof.js init
|
||||
node bin/agent-proof.js detect
|
||||
node bin/agent-proof.js status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture & Component Overview
|
||||
|
||||
Agent-Proof is structured into modular components:
|
||||
|
||||
1. **Stack Detector (`src/detector/stackDetector.ts`)**:
|
||||
- Inspects target repository indicators (JS/TS, Python, Go, Rust, GitHub Workflows, Docker, Claude Agent Harness).
|
||||
2. **Config Generator (`src/generator/configGenerator.ts`)**:
|
||||
- Generates `lefthook.yml`, `.claude/hooks.json`, `biome.json`, `ruff.toml`, and `.aislop/config.yml`.
|
||||
3. **Diagnostic Streamer (`src/formatter/diagnosticStream.ts`)**:
|
||||
- Converts raw stderr/stdout from tools (`aislop`, `biome`, `ruff`, `skillcheck`, `trufflehog`, `typos`, `actionlint`) into an LSP-compliant JSON diagnostic envelope with `repair_tokens`.
|
||||
4. **Hook Installer & Lock-in (`src/installer/`)**:
|
||||
- Sets up `.git/hooks/pre-commit` and locks governance files to read-only (`0o444`).
|
||||
5. **Gate Runner (`src/runner/gateRunner.ts`)**:
|
||||
- Executes gate stages (`post-edit`, `pre-commit`, `pre-push`).
|
||||
6. **Binary Launchers (`bin/agent-proof.js` & `bin/agent-gate.js`)**:
|
||||
- Zero-dependency node wrappers that delegate execution directly to native architecture binaries or fall back to `dist/cli.js`.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Agent Lifecycle Hooks & Interception
|
||||
|
||||
When Claude Code operates in a repository initialized with Agent-Proof:
|
||||
|
||||
1. **`PostFileEdit` Lifecycle Event**:
|
||||
- Fires automatically whenever Claude modifies a file.
|
||||
- For `*.{js,ts,jsx,tsx}`, runs `npx @biomejs/biome check --write ${filePath}`.
|
||||
- For `*.py`, runs `ruff check --fix ${filePath}`.
|
||||
- For `.claude/skills/*.md` or `SKILL.md`, runs `skillcheck check ${filePath}`.
|
||||
- Runs in `< 300ms` on single modified ASTs.
|
||||
|
||||
2. **`PreCommit` Lifecycle Event**:
|
||||
- Fires when git commit is initiated.
|
||||
- Runs `npx lefthook run pre-commit` executing parallel staged checks in `< 2.0s`.
|
||||
|
||||
3. **Interpreting Hook Diagnostics**:
|
||||
- If a hook fails, inspect the JSON output envelope:
|
||||
```json
|
||||
{
|
||||
"status": "GATE_FAILED",
|
||||
"diagnostics": [
|
||||
{
|
||||
"source": "aislop",
|
||||
"rule_id": "AI_SLOP_SWALLOWED_ERROR",
|
||||
"file_path": "src/auth.ts",
|
||||
"repair_instruction": {
|
||||
"action": "REWRITE_BLOCK",
|
||||
"repair_tokens": ["throw new Error(...)"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- Immediately apply the suggested `repair_tokens` to resolve the violation.
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Prohibited Actions for Claude Code
|
||||
|
||||
- **Do NOT** bypass git hooks using `--no-verify`.
|
||||
- **Do NOT** attempt to edit or delete `.claude/settings.json`, `.claude/hooks.json`, or `lefthook.yml`. These files are marked read-only (`chmod 0444`) to enforce governance boundaries.
|
||||
- **Do NOT** introduce empty catch blocks, `as any` casts, or unverified imports.
|
||||
@@ -0,0 +1,66 @@
|
||||
# GEMINI.md — Google Gemini & Antigravity Assistant Guide
|
||||
|
||||
This document defines workflows, tool execution guidelines, and architectural standards for **Gemini / Antigravity** sessions working on the `@heretek-ai/agent-proof` codebase.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Project Ecosystem & Stack
|
||||
|
||||
- **Package**: `@heretek-ai/agent-proof`
|
||||
- **Repository**: `https://github.com/Heretek-AI/Agent-Proof`
|
||||
- **Registry**: `https://registry.npmjs.org/@heretek-ai/agent-proof`
|
||||
- **Runtime**: Node.js `>= 18.0.0`
|
||||
- **Build System**: `tsup` (ESBuild-backed dual CJS/ESM packager)
|
||||
- **Test Framework**: `vitest`
|
||||
- **Compiler**: TypeScript 5.7+ with strict NodeNext module resolution
|
||||
|
||||
---
|
||||
|
||||
## 📋 Core Development Workflows
|
||||
|
||||
### 1. Build and Typecheck
|
||||
```bash
|
||||
# Build CJS, ESM, and .d.ts declarations into dist/
|
||||
npm run build
|
||||
|
||||
# Run strict TypeScript compiler verification without emitting
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### 2. Testing
|
||||
```bash
|
||||
# Run unit & integration test suites
|
||||
npm test
|
||||
|
||||
# Run real-world sandbox lifecycle test
|
||||
npm run test:real-repo
|
||||
```
|
||||
|
||||
### 3. Publishing & CI/CD
|
||||
- GitHub Actions workflow [`.github/workflows/publish.yml`](file:///home/john/Projects/Agent-Proof/.github/workflows/publish.yml) uses **Trusted Publishing (OIDC)** with `id-token: write` and npm provenance.
|
||||
- Publishing is triggered by creating a GitHub Release or pushing a version tag (`v*.*.*`).
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Codebase Knowledge & Key Modules
|
||||
|
||||
| Module | Location | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| **Types** | [`src/types/index.ts`](file:///home/john/Projects/Agent-Proof/src/types/index.ts) | Canonical interfaces, LSP schemas, diagnostic envelopes |
|
||||
| **Detector** | [`src/detector/stackDetector.ts`](file:///home/john/Projects/Agent-Proof/src/detector/stackDetector.ts) | Multi-stack inspection across polyglot repositories |
|
||||
| **Generator** | [`src/generator/configGenerator.ts`](file:///home/john/Projects/Agent-Proof/src/generator/configGenerator.ts) | Deterministic config templates (`lefthook.yml`, `.claude/hooks.json`) |
|
||||
| **Formatter** | [`src/formatter/diagnosticStream.ts`](file:///home/john/Projects/Agent-Proof/src/formatter/diagnosticStream.ts) | ANSI stripper, tool output parsers, LSP envelope builder |
|
||||
| **Parsers** | [`src/formatter/parsers/`](file:///home/john/Projects/Agent-Proof/src/formatter/parsers/) | Specialized parsers for `aislop`, `biome`, `ruff`, `skillcheck`, `trufflehog`, `typos`, `actionlint` |
|
||||
| **Installer** | [`src/installer/`](file:///home/john/Projects/Agent-Proof/src/installer/) | Git hook installation and read-only permission lock-in (`chmod 0444`) |
|
||||
| **Runner** | [`src/runner/gateRunner.ts`](file:///home/john/Projects/Agent-Proof/src/runner/gateRunner.ts) | Gate stage runner for `post-edit`, `pre-commit`, `pre-push` |
|
||||
| **CLI** | [`src/cli.ts`](file:///home/john/Projects/Agent-Proof/src/cli.ts) | Command dispatcher (`init`, `detect`, `run`, `lock`, `unlock`, `status`) |
|
||||
| **Launcher** | [`bin/agent-proof.js`](file:///home/john/Projects/Agent-Proof/bin/agent-proof.js) | Native binary resolution with JS fallback |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Coding & Architectural Standards
|
||||
|
||||
1. **Zero Runtime Dependencies**: The published CLI bundle should maintain minimal/zero runtime dependencies; native acceleration is handled via `optionalDependencies`.
|
||||
2. **Deterministic Output**: Always ensure configuration generators output formatted, deterministic strings without non-deterministic ordering.
|
||||
3. **Comprehensive Error Handling**: All CLI commands must catch errors, format them through the Diagnostic Streamer, and return appropriate process exit codes (0 for success, 1+ for errors).
|
||||
4. **Documentation Integrity**: Maintain line comments and JSDoc annotations across all modules.
|
||||
+71
-104
@@ -1,123 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
/**
|
||||
* Platform-Specific Zero-Dependency Binary Launcher
|
||||
* Directly delegates execution to native architecture binary when available,
|
||||
* or seamlessly falls back to bundled JavaScript CLI engine.
|
||||
* @file bin/agent-gate.js
|
||||
* @description Zero-dependency platform-specific binary launcher with JS fallback.
|
||||
*
|
||||
* Execution Resolution Flow:
|
||||
* 1. Checks for native compiled architecture binary in optionalDependencies
|
||||
* (@heretek-ai/binary-*, @agent-proof/binary-*, @agent-gate/binary-*).
|
||||
* 2. If present, delegates directly via `execFileSync` without invoking npm.
|
||||
* 3. Otherwise, falls back to executing the bundled JavaScript CLI in `dist/cli.js`.
|
||||
*/
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
darwin: 'darwin',
|
||||
linux: 'linux',
|
||||
win32: 'win32',
|
||||
};
|
||||
'use strict';
|
||||
|
||||
const ARCH_MAP = {
|
||||
x64: 'x64',
|
||||
arm64: 'arm64',
|
||||
};
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function getNativeBinaryPath() {
|
||||
const platform = PLATFORM_MAP[process.platform];
|
||||
const arch = ARCH_MAP[process.arch];
|
||||
// Determine current OS platform and CPU architecture
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
if (!platform || !arch) {
|
||||
return null;
|
||||
}
|
||||
// Binary package naming candidates
|
||||
const packageNames = [
|
||||
`@heretek-ai/binary-${platform}-${arch}`,
|
||||
`@agent-proof/binary-${platform}-${arch}`,
|
||||
`@agent-gate/binary-${platform}-${arch}`,
|
||||
];
|
||||
|
||||
const binaryNames = platform === 'win32'
|
||||
? ['agent-proof.exe', 'agent-gate.exe']
|
||||
: ['agent-proof', 'agent-gate'];
|
||||
// Binary executable filename
|
||||
const binaryName = platform === 'win32' ? 'agent-proof.exe' : 'agent-proof';
|
||||
|
||||
const packageNames = [
|
||||
`@heretek-ai/binary-${platform}-${arch}`,
|
||||
`@agent-proof/binary-${platform}-${arch}`,
|
||||
`@agent-gate/binary-${platform}-${arch}`,
|
||||
];
|
||||
let nativeBinaryPath = null;
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
for (const binaryName of binaryNames) {
|
||||
const candidates = [
|
||||
// 1. Direct optionalDependency in node_modules
|
||||
path.join(__dirname, '..', 'node_modules', packageName, 'bin', binaryName),
|
||||
// 2. Hoisted node_modules (monorepo / global)
|
||||
path.join(__dirname, '..', '..', packageName, 'bin', binaryName),
|
||||
// 3. Local architecture build directory
|
||||
path.join(__dirname, 'native', `${platform}-${arch}`, binaryName),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// ignore stat errors
|
||||
}
|
||||
}
|
||||
// Search for native binary across candidate package names
|
||||
for (const pkg of packageNames) {
|
||||
try {
|
||||
const pkgPath = require.resolve(`${pkg}/package.json`);
|
||||
const binDir = path.dirname(pkgPath);
|
||||
const candidatePath = path.join(binDir, 'bin', binaryName);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
nativeBinaryPath = candidatePath;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Package not installed in node_modules, continue searching
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function launch() {
|
||||
const nativeBinary = getNativeBinaryPath();
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (nativeBinary) {
|
||||
try {
|
||||
execFileSync(nativeBinary, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
if (err.status !== undefined && err.status !== null) {
|
||||
process.exit(err.status);
|
||||
} else if (err.signal) {
|
||||
process.kill(process.pid, err.signal);
|
||||
} else {
|
||||
console.error(`[agent-proof] Native binary execution failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Delegate execution
|
||||
if (nativeBinaryPath) {
|
||||
// Execute native compiled binary directly
|
||||
try {
|
||||
execFileSync(nativeBinaryPath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status !== undefined) {
|
||||
process.exit(err.status);
|
||||
}
|
||||
} else {
|
||||
// Fallback to bundled JavaScript CLI engine
|
||||
const distCli = path.join(__dirname, '..', 'dist', 'cli.js');
|
||||
const distCliCjs = path.join(__dirname, '..', 'dist', 'cli.cjs');
|
||||
console.error(`Failed to execute native binary at ${nativeBinaryPath}:`, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Fall back to JavaScript CLI implementation in dist/
|
||||
const cliCjs = path.join(__dirname, '..', 'dist', 'cli.js');
|
||||
const cliMjs = path.join(__dirname, '..', 'dist', 'cli.mjs');
|
||||
|
||||
if (fs.existsSync(distCliCjs)) {
|
||||
try {
|
||||
const { runCli } = require(distCliCjs);
|
||||
runCli(args).then(code => process.exit(code ?? 0)).catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[agent-proof] JS fallback failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (fs.existsSync(distCli)) {
|
||||
import(distCli).then(({ runCli }) => {
|
||||
return runCli(args);
|
||||
}).then(code => {
|
||||
process.exit(code ?? 0);
|
||||
}).catch(err => {
|
||||
console.error(`[agent-proof] JS fallback failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
console.error('[agent-proof] Error: Neither native binary nor built dist/cli.js was found.');
|
||||
console.error('Please run `npm run build` first.');
|
||||
if (fs.existsSync(cliCjs)) {
|
||||
const { main } = require(cliCjs);
|
||||
main(process.argv.slice(2)).catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
} else if (fs.existsSync(cliMjs)) {
|
||||
import(cliMjs).then(({ main }) => {
|
||||
main(process.argv.slice(2)).catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Failed to load CLI bundle:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
console.error('Error: Neither native binary nor bundled CLI found. Please run `npm run build`.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
launch();
|
||||
@@ -1,4 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file bin/agent-proof.js
|
||||
* @description Primary CLI binary wrapper for @heretek-ai/agent-proof.
|
||||
*
|
||||
* Delegates execution directly to the launcher implementation in agent-gate.js.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
require('./agent-gate.js');
|
||||
@@ -1,4 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file scripts/verify-real-repo.mjs
|
||||
* @description Real-world sandbox lifecycle verification test script.
|
||||
*
|
||||
* Simulates a realistic multi-stack polyglot repository (TypeScript, Python,
|
||||
* GitHub Workflows, Docker, Claude Skills), initializes mechanical hard gates,
|
||||
* tests permission locking, injects deliberate AI anti-patterns, asserts sub-second
|
||||
* gate interception with repair tokens, and validates autonomous self-correction.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -11,7 +21,7 @@ const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const binPath = path.join(rootDir, 'bin', 'agent-proof.js');
|
||||
|
||||
// ANSI Color Helpers
|
||||
// ANSI Color Helpers for Terminal Output
|
||||
const cyan = (t) => `\x1b[36m${t}\x1b[0m`;
|
||||
const green = (t) => `\x1b[32m${t}\x1b[0m`;
|
||||
const red = (t) => `\x1b[31m${t}\x1b[0m`;
|
||||
@@ -22,12 +32,16 @@ console.log(bold(cyan('\n======================================================'
|
||||
console.log(bold(cyan(' 🛡️ Agent-Proof Real-World Live Sandbox Verification ')));
|
||||
console.log(bold(cyan('======================================================\n')));
|
||||
|
||||
// Create isolated temporary sandbox directory
|
||||
const sandboxDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-proof-real-repo-'));
|
||||
console.log(`📁 Sandbox Repository: ${bold(sandboxDir)}\n`);
|
||||
|
||||
/**
|
||||
* Clean up temporary sandbox directory on exit
|
||||
*/
|
||||
function cleanup() {
|
||||
try {
|
||||
// Unlock files before removing
|
||||
// Unlock files before attempting directory deletion
|
||||
execFileSync(process.execPath, [binPath, 'unlock', sandboxDir], { stdio: 'ignore' });
|
||||
} catch {}
|
||||
try {
|
||||
@@ -38,6 +52,9 @@ function cleanup() {
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||
|
||||
/**
|
||||
* Helper to run a numbered verification step and measure elapsed time
|
||||
*/
|
||||
async function runStep(stepNumber, title, fn) {
|
||||
console.log(bold(`[Step ${stepNumber}/6] ${title}...`));
|
||||
const start = Date.now();
|
||||
@@ -57,7 +74,7 @@ async function runStep(stepNumber, title, fn) {
|
||||
// STEP 1: Populate Sandbox with Multi-Stack Polyglot Repository
|
||||
// -----------------------------------------------------------------------------
|
||||
await runStep(1, 'Creating Real-World Multi-Stack Project', async () => {
|
||||
// Initialize git repo
|
||||
// Initialize git repository
|
||||
execFileSync('git', ['init', sandboxDir], { stdio: 'pipe' });
|
||||
execFileSync('git', ['-C', sandboxDir, 'config', 'user.name', 'Agent Tester'], { stdio: 'pipe' });
|
||||
execFileSync('git', ['-C', sandboxDir, 'config', 'user.email', '[email protected]'], { stdio: 'pipe' });
|
||||
@@ -145,7 +162,7 @@ await runStep(2, 'Running `npx @heretek-ai/agent-proof init`', async () => {
|
||||
|
||||
console.log(initOutput.split('\n').map(l => ` ${l}`).join('\n'));
|
||||
|
||||
// Assert expected configs exist
|
||||
// Assert expected configuration files exist on disk
|
||||
const expectedFiles = [
|
||||
'lefthook.yml',
|
||||
'.claude/hooks.json',
|
||||
@@ -164,13 +181,13 @@ await runStep(2, 'Running `npx @heretek-ai/agent-proof init`', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate lefthook.yml content
|
||||
// Validate lefthook.yml content contains multi-stack engines
|
||||
const lefthookContent = fs.readFileSync(path.join(sandboxDir, 'lefthook.yml'), 'utf-8');
|
||||
if (!lefthookContent.includes('biome-check') || !lefthookContent.includes('ruff-check') || !lefthookContent.includes('aislop-scan')) {
|
||||
throw new Error('lefthook.yml is missing required multi-stack engines');
|
||||
}
|
||||
|
||||
// Validate .claude/hooks.json
|
||||
// Validate .claude/hooks.json contains Stage 1 hooks
|
||||
const hooksJson = JSON.parse(fs.readFileSync(path.join(sandboxDir, '.claude/hooks.json'), 'utf-8'));
|
||||
if (!hooksJson.hooks?.PostFileEdit || !hooksJson.hooks?.PreCommit) {
|
||||
throw new Error('.claude/hooks.json is missing PostFileEdit or PreCommit hooks');
|
||||
@@ -189,7 +206,7 @@ await runStep(3, 'Verifying Governance Permission Lock-in (chmod 0444)', async (
|
||||
throw new Error(`.claude/hooks.json is writable! Expected read-only permissions (0444).`);
|
||||
}
|
||||
|
||||
// Test status command
|
||||
// Test status CLI command
|
||||
const statusOutput = execFileSync(process.execPath, [binPath, 'status', sandboxDir], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
@@ -197,7 +214,7 @@ await runStep(3, 'Verifying Governance Permission Lock-in (chmod 0444)', async (
|
||||
throw new Error(`Governance status check failed:\n${statusOutput}`);
|
||||
}
|
||||
|
||||
// Attempt unauthorized agent write
|
||||
// Attempt unauthorized agent write and assert failure
|
||||
try {
|
||||
fs.writeFileSync(hooksPath, '{"tampered": true}');
|
||||
throw new Error('Unauthorized agent write succeeded on locked file!');
|
||||
|
||||
+205
-253
@@ -1,267 +1,219 @@
|
||||
import * as fs from 'node:fs';
|
||||
/**
|
||||
* @file src/cli.ts
|
||||
* @description Main command-line interface entrypoint for @heretek-ai/agent-proof.
|
||||
*
|
||||
* Dispatches CLI commands:
|
||||
* - init: Initialize mechanical hard gates in target repository
|
||||
* - detect: Run multi-stack inspection and print findings
|
||||
* - run <stage>: Execute a specific gate stage (post-edit, pre-commit, pre-push)
|
||||
* - lock: Apply read-only permissions (0444) to governance configurations
|
||||
* - unlock: Restore standard permissions (0644) for administrative edits
|
||||
* - status: Print current permission and lock status of governance files
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { detectStack } from './detector/index.js';
|
||||
import { generateConfigs } from './generator/index.js';
|
||||
import { installHooks } from './installer/index.js';
|
||||
import { lockGovernance, unlockGovernance, getGovernanceStatus } from './installer/index.js';
|
||||
import { formatDiagnostics, DiagnosticStreamer } from './formatter/index.js';
|
||||
import { runGate } from './runner/index.js';
|
||||
import { detectStack } from './detector/stackDetector.js';
|
||||
import { generateConfigs } from './generator/configGenerator.js';
|
||||
import { installHooks } from './installer/hookInstaller.js';
|
||||
import { GateLock, lockGovernance, unlockGovernance } from './installer/lockin.js';
|
||||
import { GateRunner } from './runner/gateRunner.js';
|
||||
|
||||
export async function runCli(argv: string[] = process.argv.slice(2)): Promise<number> {
|
||||
const command = argv[0] || 'init';
|
||||
const args = argv.slice(1);
|
||||
|
||||
switch (command) {
|
||||
case 'init':
|
||||
case 'setup': {
|
||||
return handleInit(args);
|
||||
}
|
||||
|
||||
case 'detect': {
|
||||
return handleDetect(args);
|
||||
}
|
||||
|
||||
case 'run': {
|
||||
return handleRun(args);
|
||||
}
|
||||
|
||||
case 'stream':
|
||||
case 'format-diagnostics': {
|
||||
return handleStream(args);
|
||||
}
|
||||
|
||||
case 'lock': {
|
||||
return handleLock(args);
|
||||
}
|
||||
|
||||
case 'unlock': {
|
||||
return handleUnlock(args);
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
return handleStatus(args);
|
||||
}
|
||||
|
||||
case '--help':
|
||||
case '-h':
|
||||
case 'help': {
|
||||
printHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
case '--version':
|
||||
case '-v':
|
||||
case 'version': {
|
||||
console.log('@heretek-ai/agent-proof v1.0.0');
|
||||
return 0;
|
||||
}
|
||||
|
||||
default: {
|
||||
// If run as `create-agent-gate`, treat unknown command as target directory or run init
|
||||
if (command.startsWith('-')) {
|
||||
console.error(`Unknown option: ${command}`);
|
||||
printHelp();
|
||||
return 1;
|
||||
}
|
||||
return handleInit(argv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInit(args: string[]): number {
|
||||
const cwd = args.find(a => !a.startsWith('-')) || process.cwd();
|
||||
const overwrite = args.includes('--force') || args.includes('-f');
|
||||
const noLock = args.includes('--no-lock');
|
||||
|
||||
console.log('\n🔒 Agent-Proof Mechanical Hard-Gate Initializer');
|
||||
console.log(`📂 Repository Root: ${path.resolve(cwd)}\n`);
|
||||
|
||||
console.log('🔍 [1/4] Inspecting repository multi-stack architecture...');
|
||||
const detection = detectStack(cwd);
|
||||
|
||||
console.log(` • Stacks detected: ${detection.summary.primaryStacks.join(', ') || 'Generic / Universal'}`);
|
||||
if (detection.jsTs.detected) console.log(` - JS/TS: ${detection.jsTs.files.join(', ')} (TypeScript: ${detection.jsTs.hasTypeScript ? 'Yes' : 'No'}, Biome: ${detection.jsTs.hasBiome ? 'Yes' : 'No'})`);
|
||||
if (detection.python.detected) console.log(` - Python: ${detection.python.files.join(', ')} (Ruff: ${detection.python.hasRuffConfig ? 'Yes' : 'No'})`);
|
||||
if (detection.go.detected) console.log(` - Go: ${detection.go.files.join(', ')}`);
|
||||
if (detection.rust.detected) console.log(` - Rust: ${detection.rust.files.join(', ')} (Workspace: ${detection.rust.isWorkspace ? 'Yes' : 'No'})`);
|
||||
if (detection.infra.detected) console.log(` - Infra/CI: ${[...detection.infra.workflowFiles, ...detection.infra.dockerFiles].join(', ')}`);
|
||||
if (detection.agentHarness.detected) console.log(` - Agent Harness: ${detection.agentHarness.files.join(', ')}`);
|
||||
|
||||
console.log('\n⚙️ [2/4] Emitting multi-tier pipeline governance configs...');
|
||||
const genResult = generateConfigs(detection, { cwd, overwrite });
|
||||
for (const written of genResult.writtenFiles) {
|
||||
console.log(` ✅ Created ${written}`);
|
||||
}
|
||||
for (const skipped of genResult.skippedFiles) {
|
||||
console.log(` ⏭️ Skipped ${skipped} (already exists, use --force to overwrite)`);
|
||||
}
|
||||
|
||||
console.log('\n🪝 [3/4] Initializing mechanical git hooks...');
|
||||
const hookResult = installHooks({ cwd });
|
||||
console.log(` • ${hookResult.message}`);
|
||||
if (hookResult.installedHooks.length > 0) {
|
||||
console.log(` • Active hooks: ${hookResult.installedHooks.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log('\n🛡️ [4/4] Locking governance controls...');
|
||||
if (!noLock) {
|
||||
const lockRes = lockGovernance(cwd);
|
||||
if (lockRes.lockedFiles.length > 0) {
|
||||
console.log(` 🔒 Immutable read-only lock applied to: ${lockRes.lockedFiles.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
console.log(' ⚠️ Governance locking skipped (--no-lock specified).');
|
||||
}
|
||||
|
||||
console.log('\n✨ Repository is now Agent-Proof! Sub-second mechanical gates active.');
|
||||
console.log(' Stage 1: Agent PostFileEdit tool interceptor (.claude/hooks.json)');
|
||||
console.log(' Stage 2: Staged files pre-commit hard gate (< 2.0s via Lefthook)');
|
||||
console.log(' Stage 3: Pre-push / CI codebase graph & shadow API audit\n');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function handleDetect(args: string[]): number {
|
||||
const cwd = args.find(a => !a.startsWith('-')) || process.cwd();
|
||||
const isJson = args.includes('--json');
|
||||
const detection = detectStack(cwd);
|
||||
|
||||
if (isJson) {
|
||||
console.log(JSON.stringify(detection, null, 2));
|
||||
} else {
|
||||
console.log('Stack Detection Summary:');
|
||||
console.log(JSON.stringify(detection.summary, null, 2));
|
||||
console.log('\nDetailed Breakdown:');
|
||||
console.log(JSON.stringify({
|
||||
jsTs: detection.jsTs,
|
||||
python: detection.python,
|
||||
go: detection.go,
|
||||
rust: detection.rust,
|
||||
infra: detection.infra,
|
||||
agentHarness: detection.agentHarness,
|
||||
}, null, 2));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function handleRun(args: string[]): number {
|
||||
const stage = args[0] || 'pre-commit';
|
||||
const filePath = args.find((a, idx) => idx > 0 && !a.startsWith('-'));
|
||||
const isJson = args.includes('--json') || args.includes('--lsp');
|
||||
const cwd = process.cwd();
|
||||
|
||||
const result = runGate({
|
||||
stage: stage as any,
|
||||
filePath,
|
||||
cwd,
|
||||
jsonOutput: isJson,
|
||||
});
|
||||
|
||||
if (isJson) {
|
||||
DiagnosticStreamer.streamJson(result.envelope, process.stdout);
|
||||
} else if (!result.passed) {
|
||||
console.error(result.rawOutput);
|
||||
// If failures occurred, also output structured diagnostic summary
|
||||
if (result.envelope.diagnostics.length > 0) {
|
||||
console.error('\n📋 Agent Diagnostic Envelope:');
|
||||
DiagnosticStreamer.streamJson(result.envelope, process.stderr);
|
||||
}
|
||||
} else {
|
||||
console.log(result.rawOutput || `✅ Gate ${stage} passed successfully.`);
|
||||
}
|
||||
|
||||
return result.exitCode;
|
||||
}
|
||||
|
||||
function handleStream(args: string[]): number {
|
||||
const toolName = getArgValue(args, '--tool') || 'gate';
|
||||
const stage = (getArgValue(args, '--stage') || 'PreCommit') as any;
|
||||
|
||||
let rawInput = '';
|
||||
try {
|
||||
rawInput = fs.readFileSync(0, 'utf-8');
|
||||
} catch {
|
||||
// stdin empty
|
||||
}
|
||||
|
||||
const envelope = formatDiagnostics(rawInput, {
|
||||
toolName,
|
||||
stage,
|
||||
});
|
||||
|
||||
DiagnosticStreamer.streamJson(envelope, process.stdout);
|
||||
return envelope.summary.total_errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function handleLock(args: string[]): number {
|
||||
const cwd = args.find(a => !a.startsWith('-')) || process.cwd();
|
||||
const res = lockGovernance(cwd);
|
||||
console.log(`Locked ${res.lockedFiles.length} governance files: ${res.lockedFiles.join(', ')}`);
|
||||
if (res.failedFiles.length > 0) {
|
||||
console.warn(`Failed to lock: ${res.failedFiles.join(', ')}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function handleUnlock(args: string[]): number {
|
||||
const cwd = args.find(a => !a.startsWith('-')) || process.cwd();
|
||||
const res = unlockGovernance(cwd);
|
||||
console.log(`Unlocked ${res.lockedFiles.length} governance files: ${res.lockedFiles.join(', ')}`);
|
||||
if (res.failedFiles.length > 0) {
|
||||
console.warn(`Failed to unlock: ${res.failedFiles.join(', ')}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function handleStatus(args: string[]): number {
|
||||
const cwd = args.find(a => !a.startsWith('-')) || process.cwd();
|
||||
const status = getGovernanceStatus(cwd);
|
||||
console.log('\n🛡️ Governance Lock Status:');
|
||||
for (const [file, info] of Object.entries(status)) {
|
||||
console.log(` ${info.exists ? (info.isLocked ? '🔒 [LOCKED]' : '🔓 [UNLOCKED]') : '❌ [NOT FOUND]'} ${file}`);
|
||||
}
|
||||
console.log('');
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getArgValue(args: string[], flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx >= 0 && idx + 1 < args.length) {
|
||||
return args[idx + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
/**
|
||||
* Display help text detailing usage, subcommands, and flags
|
||||
*/
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
@heretek-ai/agent-proof - Mechanical Hard-Gate for AI Coding Agents
|
||||
🛡️ @heretek-ai/agent-proof — Mechanical Hard-Gate CLI for AI Code Governance
|
||||
|
||||
USAGE:
|
||||
npx @heretek-ai/agent-proof [options]
|
||||
npx create-agent-proof [options]
|
||||
npx create-agent-gate [options]
|
||||
agent-proof <command> [options]
|
||||
agent-gate <command> [options]
|
||||
$ agent-proof <command> [options]
|
||||
$ agent-gate <command> [options]
|
||||
|
||||
COMMANDS:
|
||||
init [dir] Initialize mechanical gates in target repository (default)
|
||||
detect [dir] Inspect and report multi-stack indicators
|
||||
run <stage> [file] Execute gate for stage (pre-commit, pre-push, post-edit)
|
||||
format-diagnostics Parse stdin tool output and stream LSP JSON diagnostics
|
||||
lock [dir] Set immutable read-only permissions on governance configs
|
||||
unlock [dir] Restore write permissions on governance configs
|
||||
status [dir] Check mechanical gate and lock health
|
||||
init [dir] Initialize 3-tier mechanical hard gates in target repository
|
||||
detect [dir] Inspect repository architecture and report detected stacks
|
||||
run <stage> [file] Execute a mechanical gate stage (post-edit, pre-commit, pre-push)
|
||||
lock [dir] Lock governance files with read-only permissions (chmod 0444)
|
||||
unlock [dir] Unlock governance files for administrative editing (chmod 0644)
|
||||
status [dir] Display permission and lock status of governance configurations
|
||||
|
||||
OPTIONS:
|
||||
--force, -f Overwrite existing configuration files
|
||||
--no-lock Do not set read-only permissions on governance files
|
||||
--json, --lsp Output structured LSP JSON diagnostic envelopes
|
||||
--help, -h Show help information
|
||||
--version, -v Show version number
|
||||
-f, --force Overwrite existing configuration files during init
|
||||
-v, --version Show CLI version
|
||||
-h, --help Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
$ npx @heretek-ai/agent-proof init
|
||||
$ npx @heretek-ai/agent-proof run pre-commit
|
||||
$ npx @heretek-ai/agent-proof run post-edit src/auth.ts
|
||||
$ npx @heretek-ai/agent-proof status
|
||||
`);
|
||||
}
|
||||
|
||||
// Auto-run if executed directly
|
||||
/**
|
||||
* CLI Main Dispatcher
|
||||
*/
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
const args = argv.filter(a => !a.startsWith('-'));
|
||||
const flags = new Set(argv.filter(a => a.startsWith('-')));
|
||||
|
||||
if (flags.has('-h') || flags.has('--help') || (args.length === 0 && flags.size === 0)) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.has('-v') || flags.has('--version')) {
|
||||
console.log('@heretek-ai/agent-proof v1.0.0');
|
||||
return;
|
||||
}
|
||||
|
||||
const command = args[0] || 'init';
|
||||
const targetDir = args[1] && !['post-edit', 'pre-commit', 'pre-push'].includes(args[1])
|
||||
? path.resolve(args[1])
|
||||
: process.cwd();
|
||||
|
||||
switch (command) {
|
||||
// -------------------------------------------------------------------------
|
||||
// INIT: Initialize full 3-tier hard gates
|
||||
// -------------------------------------------------------------------------
|
||||
case 'init': {
|
||||
console.log(`\n🔒 Agent-Proof Mechanical Hard-Gate Initializer`);
|
||||
console.log(`📂 Repository Root: ${targetDir}\n`);
|
||||
|
||||
// 1. Multi-Stack Inspection
|
||||
console.log(`🔍 [1/4] Inspecting repository multi-stack architecture...`);
|
||||
const detection = detectStack(targetDir);
|
||||
console.log(` • Stacks detected: ${detection.summary.primaryStacks.join(', ') || 'Generic'}`);
|
||||
if (detection.jsTs.detected) {
|
||||
console.log(` - JS/TS: ${detection.jsTs.files.join(', ')} (TypeScript: ${detection.jsTs.hasTypeScript ? 'Yes' : 'No'}, Biome: ${detection.jsTs.hasBiome ? 'Yes' : 'No'})`);
|
||||
}
|
||||
if (detection.python.detected) {
|
||||
console.log(` - Python: ${detection.python.files.join(', ')} (Ruff: ${detection.python.hasRuffConfig ? 'Yes' : 'No'})`);
|
||||
}
|
||||
if (detection.infra.detected) {
|
||||
console.log(` - Infra/CI: ${[...detection.infra.workflowFiles, ...detection.infra.dockerFiles].join(', ')}`);
|
||||
}
|
||||
if (detection.agentHarness.detected) {
|
||||
console.log(` - Agent Harness: ${detection.agentHarness.files.join(', ')}`);
|
||||
}
|
||||
|
||||
// 2. Multi-Tier Config Emission
|
||||
console.log(`\n⚙️ [2/4] Emitting multi-tier pipeline governance configs...`);
|
||||
const overwrite = flags.has('-f') || flags.has('--force');
|
||||
const genResult = generateConfigs(detection, { cwd: targetDir, overwrite });
|
||||
for (const written of genResult.writtenFiles) {
|
||||
console.log(` ✅ Created ${written}`);
|
||||
}
|
||||
for (const skipped of genResult.skippedFiles) {
|
||||
console.log(` ⏩ Skipped existing ${skipped} (use --force to overwrite)`);
|
||||
}
|
||||
|
||||
// 3. Git Hooks Installation
|
||||
console.log(`\n🪝 [3/4] Initializing mechanical git hooks...`);
|
||||
const hookResult = installHooks(targetDir);
|
||||
console.log(` • ${hookResult.message}`);
|
||||
if (hookResult.installedHooks.length > 0) {
|
||||
console.log(` • Active hooks: ${hookResult.installedHooks.join(', ')}`);
|
||||
}
|
||||
|
||||
// 4. Governance Lock-in
|
||||
console.log(`\n🛡️ [4/4] Locking governance controls...`);
|
||||
const lockResult = lockGovernance(targetDir);
|
||||
if (lockResult.lockedFiles.length > 0) {
|
||||
console.log(` 🔒 Immutable read-only lock applied to: ${lockResult.lockedFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(`\n✨ Repository is now Agent-Proof! Sub-second mechanical gates active.`);
|
||||
console.log(` Stage 1: Agent PostFileEdit tool interceptor (.claude/hooks.json)`);
|
||||
console.log(` Stage 2: Staged files pre-commit hard gate (< 2.0s via Lefthook)`);
|
||||
console.log(` Stage 3: Pre-push / CI codebase graph & shadow API audit\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DETECT: Inspect and print detected stacks
|
||||
// -------------------------------------------------------------------------
|
||||
case 'detect': {
|
||||
const detection = detectStack(targetDir);
|
||||
console.log(JSON.stringify(detection, null, 2));
|
||||
break;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RUN: Execute a specific gate stage
|
||||
// -------------------------------------------------------------------------
|
||||
case 'run': {
|
||||
const stage = args[1] || 'pre-commit';
|
||||
const runner = new GateRunner({ cwd: targetDir });
|
||||
|
||||
if (stage === 'post-edit') {
|
||||
const filePath = args[2];
|
||||
if (!filePath) {
|
||||
console.error('Error: filePath argument required for post-edit stage');
|
||||
process.exit(1);
|
||||
}
|
||||
const envelope = runner.runPostEdit(filePath);
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
if (envelope.status === 'GATE_FAILED') process.exit(1);
|
||||
} else if (stage === 'pre-commit') {
|
||||
const envelope = runner.runPreCommit();
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
if (envelope.status === 'GATE_FAILED') process.exit(1);
|
||||
} else if (stage === 'pre-push') {
|
||||
const envelope = runner.runPrePush();
|
||||
console.log(JSON.stringify(envelope, null, 2));
|
||||
if (envelope.status === 'GATE_FAILED') process.exit(1);
|
||||
} else {
|
||||
console.error(`Unknown stage: ${stage}. Expected: post-edit, pre-commit, pre-push`);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LOCK / UNLOCK / STATUS: POSIX governance permissions
|
||||
// -------------------------------------------------------------------------
|
||||
case 'lock': {
|
||||
const result = lockGovernance(targetDir);
|
||||
console.log(`🔒 Locked ${result.lockedFiles.length} governance files: ${result.lockedFiles.join(', ')}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unlock': {
|
||||
const result = unlockGovernance(targetDir);
|
||||
console.log(`🔓 Unlocked ${result.lockedFiles.length} governance files: ${result.lockedFiles.join(', ')}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const lock = new GateLock({ cwd: targetDir });
|
||||
const statuses = lock.checkStatus();
|
||||
console.log(`\n🛡️ Governance Configuration Status:`);
|
||||
for (const s of statuses) {
|
||||
if (!s.exists) {
|
||||
console.log(` ⚪ [NOT FOUND] ${s.path}`);
|
||||
} else if (s.isLocked) {
|
||||
console.log(` 🔒 [LOCKED] ${s.path} (mode: ${s.mode})`);
|
||||
} else {
|
||||
console.log(` ⚠️ [UNLOCKED] ${s.path} (mode: ${s.mode})`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.error(`Unknown command: ${command}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute main if invoked directly from CLI
|
||||
if (process.argv[1] && (process.argv[1].endsWith('cli.js') || process.argv[1].endsWith('cli.ts'))) {
|
||||
runCli().then(code => process.exit(code));
|
||||
main().catch(err => {
|
||||
console.error(`Fatal CLI Error:`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1 +1,6 @@
|
||||
/**
|
||||
* @file src/detector/index.ts
|
||||
* @description Exports the repository stack detection module.
|
||||
*/
|
||||
|
||||
export * from './stackDetector.js';
|
||||
@@ -1,22 +1,49 @@
|
||||
/**
|
||||
* @file src/detector/stackDetector.ts
|
||||
* @description Multi-stack auto-detection engine for repository inspection.
|
||||
*
|
||||
* Scans the filesystem to detect active programming languages, build systems,
|
||||
* package managers, container infrastructure, CI workflows, and AI agent harnesses.
|
||||
* Provides deterministic metadata to configure mechanical hard gates.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { StackDetectionResult } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Options for configuring the StackDetector instance
|
||||
*/
|
||||
export interface StackDetectorOptions {
|
||||
/** Working directory to inspect (defaults to process.cwd()) */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-Stack Auto-Detection Engine.
|
||||
* Inspects a repository directory to identify language ecosystems,
|
||||
* monorepos, workflows, and agent harnesses.
|
||||
*/
|
||||
export class StackDetector {
|
||||
/** Resolved absolute path of the target repository root */
|
||||
private readonly rootPath: string;
|
||||
|
||||
/**
|
||||
* Initialize a new StackDetector instance
|
||||
* @param options Configuration options including target working directory
|
||||
*/
|
||||
constructor(options: StackDetectorOptions = {}) {
|
||||
this.rootPath = path.resolve(options.cwd || process.cwd());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full stack detection across the target repository
|
||||
* Run full multi-stack detection across the target repository.
|
||||
* Inspects JS/TS, Python, Go, Rust, Infrastructure, and Agent Harnesses.
|
||||
*
|
||||
* @returns Comprehensive StackDetectionResult object with boolean flags and file lists.
|
||||
*/
|
||||
public detect(): StackDetectionResult {
|
||||
// 1. Inspect individual technology stacks
|
||||
const jsTs = this.detectJsTs();
|
||||
const python = this.detectPython();
|
||||
const go = this.detectGo();
|
||||
@@ -24,6 +51,7 @@ export class StackDetector {
|
||||
const infra = this.detectInfra();
|
||||
const agentHarness = this.detectAgentHarness();
|
||||
|
||||
// 2. Assemble high-level primary stack list for summary reporting
|
||||
const primaryStacks: string[] = [];
|
||||
if (jsTs.detected) primaryStacks.push('JavaScript/TypeScript');
|
||||
if (python.detected) primaryStacks.push('Python');
|
||||
@@ -32,6 +60,7 @@ export class StackDetector {
|
||||
if (infra.detected) primaryStacks.push('Workflows/Infra');
|
||||
if (agentHarness.detected) primaryStacks.push('Agent Harness');
|
||||
|
||||
// 3. Compute total indicator count across all detected categories
|
||||
const totalIndicators =
|
||||
jsTs.files.length +
|
||||
python.files.length +
|
||||
@@ -56,7 +85,12 @@ export class StackDetector {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect JavaScript and TypeScript ecosystem indicators.
|
||||
* Checks for package manifests, TypeScript configs, Biome configs, and monorepo configurations.
|
||||
*/
|
||||
private detectJsTs() {
|
||||
// Canonical indicator files for JS/TS projects and package managers
|
||||
const indicators = [
|
||||
'package.json',
|
||||
'tsconfig.json',
|
||||
@@ -77,20 +111,24 @@ export class StackDetector {
|
||||
let isMonorepo = false;
|
||||
let packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'deno' | undefined;
|
||||
|
||||
// Check presence of indicator files on disk
|
||||
for (const indicator of indicators) {
|
||||
if (this.fileExists(indicator)) {
|
||||
detectedFiles.push(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit TypeScript config
|
||||
if (this.fileExists('tsconfig.json')) {
|
||||
hasTypeScript = true;
|
||||
}
|
||||
|
||||
// Check for existing Biome configuration
|
||||
if (this.fileExists('biome.json') || this.fileExists('biome.jsonc')) {
|
||||
hasBiome = true;
|
||||
}
|
||||
|
||||
// Determine package manager by lockfile priority
|
||||
if (this.fileExists('pnpm-lock.yaml')) {
|
||||
packageManager = 'pnpm';
|
||||
} else if (this.fileExists('yarn.lock')) {
|
||||
@@ -103,10 +141,12 @@ export class StackDetector {
|
||||
packageManager = 'npm';
|
||||
}
|
||||
|
||||
// Determine monorepo workspace indicators (pnpm, lerna, turbo, nx)
|
||||
if (this.fileExists('pnpm-workspace.yaml') || this.fileExists('lerna.json') || this.fileExists('turbo.json') || this.fileExists('nx.json')) {
|
||||
isMonorepo = true;
|
||||
} else if (this.fileExists('package.json')) {
|
||||
try {
|
||||
// Read package.json to check for workspaces, dependencies, and packageManager field
|
||||
const pkgContent = JSON.parse(fs.readFileSync(this.resolvePath('package.json'), 'utf-8'));
|
||||
if (pkgContent.workspaces) {
|
||||
isMonorepo = true;
|
||||
@@ -124,7 +164,7 @@ export class StackDetector {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
// Silently continue if package.json cannot be parsed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +178,10 @@ export class StackDetector {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Python ecosystem indicators.
|
||||
* Checks for pyproject.toml, requirements.txt, Pipfile, poetry, and Ruff linter configurations.
|
||||
*/
|
||||
private detectPython() {
|
||||
const indicators = [
|
||||
'pyproject.toml',
|
||||
@@ -157,12 +201,14 @@ export class StackDetector {
|
||||
let hasPyproject = false;
|
||||
let hasRuffConfig = false;
|
||||
|
||||
// Check presence of Python indicator files
|
||||
for (const indicator of indicators) {
|
||||
if (this.fileExists(indicator)) {
|
||||
detectedFiles.push(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// Inspect pyproject.toml for [tool.ruff] table
|
||||
if (this.fileExists('pyproject.toml')) {
|
||||
hasPyproject = true;
|
||||
try {
|
||||
@@ -171,10 +217,11 @@ export class StackDetector {
|
||||
hasRuffConfig = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore read error
|
||||
// Silently continue if pyproject.toml cannot be read
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dedicated ruff.toml configuration
|
||||
if (this.fileExists('ruff.toml') || this.fileExists('.ruff.toml')) {
|
||||
hasRuffConfig = true;
|
||||
}
|
||||
@@ -187,6 +234,9 @@ export class StackDetector {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Go ecosystem indicators (go.mod, go.sum, Gopkg.toml).
|
||||
*/
|
||||
private detectGo() {
|
||||
const indicators = ['go.mod', 'go.sum', 'Gopkg.toml'];
|
||||
const detectedFiles: string[] = [];
|
||||
@@ -203,6 +253,9 @@ export class StackDetector {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Rust ecosystem indicators (Cargo.toml, Cargo.lock, multi-crate workspaces).
|
||||
*/
|
||||
private detectRust() {
|
||||
const indicators = ['Cargo.toml', 'Cargo.lock'];
|
||||
const detectedFiles: string[] = [];
|
||||
@@ -214,6 +267,7 @@ export class StackDetector {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Cargo.toml defines a [workspace]
|
||||
if (this.fileExists('Cargo.toml')) {
|
||||
try {
|
||||
const content = fs.readFileSync(this.resolvePath('Cargo.toml'), 'utf-8');
|
||||
@@ -221,7 +275,7 @@ export class StackDetector {
|
||||
isWorkspace = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore read error
|
||||
// Silently continue if Cargo.toml cannot be read
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +286,14 @@ export class StackDetector {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Infrastructure, CI workflows, and Docker container definitions.
|
||||
*/
|
||||
private detectInfra() {
|
||||
const workflowFiles: string[] = [];
|
||||
const dockerFiles: string[] = [];
|
||||
|
||||
// Scan .github/workflows directory for YAML workflow files
|
||||
const workflowsDir = this.resolvePath('.github/workflows');
|
||||
if (this.dirExists('.github/workflows')) {
|
||||
try {
|
||||
@@ -246,10 +304,11 @@ export class StackDetector {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read error
|
||||
// Silently continue if workflows directory cannot be read
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Docker and container configuration files
|
||||
const dockerIndicators = [
|
||||
'Dockerfile',
|
||||
'Containerfile',
|
||||
@@ -275,6 +334,9 @@ export class StackDetector {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect AI agent harness files, skill markdown definitions, and instructions.
|
||||
*/
|
||||
private detectAgentHarness() {
|
||||
const files: string[] = [];
|
||||
let hasClaude = false;
|
||||
@@ -282,6 +344,7 @@ export class StackDetector {
|
||||
let hasSkillFiles = false;
|
||||
let hasAgentsMd = false;
|
||||
|
||||
// Check for Claude Code harness directory and settings
|
||||
if (this.dirExists('.claude')) {
|
||||
hasClaude = true;
|
||||
files.push('.claude/');
|
||||
@@ -289,27 +352,32 @@ export class StackDetector {
|
||||
if (this.fileExists('.claude/hooks.json')) files.push('.claude/hooks.json');
|
||||
}
|
||||
|
||||
// Check for CLAUDE.md instruction file
|
||||
if (this.fileExists('CLAUDE.md')) {
|
||||
hasClaude = true;
|
||||
files.push('CLAUDE.md');
|
||||
}
|
||||
|
||||
// Check for Cursor IDE rules and directory
|
||||
if (this.dirExists('.cursor') || this.fileExists('.cursorrules')) {
|
||||
hasCursor = true;
|
||||
if (this.dirExists('.cursor')) files.push('.cursor/');
|
||||
if (this.fileExists('.cursorrules')) files.push('.cursorrules');
|
||||
}
|
||||
|
||||
// Check for AGENTS.md multi-agent manifest
|
||||
if (this.fileExists('AGENTS.md')) {
|
||||
hasAgentsMd = true;
|
||||
files.push('AGENTS.md');
|
||||
}
|
||||
|
||||
// Check for root SKILL.md definition
|
||||
if (this.fileExists('SKILL.md')) {
|
||||
hasSkillFiles = true;
|
||||
files.push('SKILL.md');
|
||||
}
|
||||
|
||||
// Check for .claude/skills/*.md individual skill definitions
|
||||
const skillsDir = this.resolvePath('.claude/skills');
|
||||
if (this.dirExists('.claude/skills')) {
|
||||
try {
|
||||
@@ -321,7 +389,7 @@ export class StackDetector {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read error
|
||||
// Silently continue if skills directory cannot be read
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +403,9 @@ export class StackDetector {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if a relative path exists and is a file
|
||||
*/
|
||||
private fileExists(relPath: string): boolean {
|
||||
const fullPath = this.resolvePath(relPath);
|
||||
try {
|
||||
@@ -344,6 +415,9 @@ export class StackDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if a relative path exists and is a directory
|
||||
*/
|
||||
private dirExists(relPath: string): boolean {
|
||||
const fullPath = this.resolvePath(relPath);
|
||||
try {
|
||||
@@ -353,11 +427,18 @@ export class StackDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative path against the repository root
|
||||
*/
|
||||
private resolvePath(relPath: string): string {
|
||||
return path.resolve(this.rootPath, relPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional convenience wrapper to detect stacks in a repository
|
||||
* @param cwd Target directory (defaults to current working directory)
|
||||
*/
|
||||
export function detectStack(cwd?: string): StackDetectionResult {
|
||||
return new StackDetector({ cwd }).detect();
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
/**
|
||||
* @file src/formatter/ansi.ts
|
||||
* @description ANSI escape sequence stripper.
|
||||
*
|
||||
* Terminal tools emit colored ANSI escape sequences that confuse LLMs and break
|
||||
* JSON parsing. This utility cleanly strips ANSI color and cursor codes from strings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strips ANSI escape sequences and terminal control characters from strings.
|
||||
*
|
||||
* @param text Raw terminal string containing ANSI escape codes
|
||||
* @returns Clean text with all ANSI escape sequences removed
|
||||
*/
|
||||
export function stripAnsi(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type {
|
||||
DiagnosticEnvelope,
|
||||
DiagnosticItem,
|
||||
DiagnosticSeverity,
|
||||
GateStage,
|
||||
} from '../types/index.js';
|
||||
import { stripAnsi } from './ansi.js';
|
||||
/**
|
||||
* @file src/formatter/diagnosticStream.ts
|
||||
* @description LSP-compliant Diagnostic Streaming Engine.
|
||||
*
|
||||
* Intercepts, deduplicates, and aggregates outputs from all governance tools
|
||||
* (AISlop, Biome, Ruff, SkillCheck, TruffleHog, Typos, Actionlint) and formats
|
||||
* them into a standardized JSON Diagnostic Envelope ($schema: https://json.schemastore.org/lsif.json).
|
||||
*
|
||||
* Embeds actionable `repair_tokens` enabling autonomous LLM self-correction.
|
||||
*/
|
||||
|
||||
import type { DiagnosticEnvelope, DiagnosticItem, GateStage } from '../types/index.js';
|
||||
import { parseAislopOutput } from './parsers/aislop.js';
|
||||
import { parseBiomeOutput } from './parsers/biome.js';
|
||||
import { parseRuffOutput } from './parsers/ruff.js';
|
||||
@@ -14,120 +19,110 @@ import { parseTyposOutput } from './parsers/typos.js';
|
||||
import { parseActionlintOutput } from './parsers/actionlint.js';
|
||||
import { parseGenericOutput } from './parsers/generic.js';
|
||||
|
||||
export interface FormatOptions {
|
||||
/**
|
||||
* Options for configuring DiagnosticStreamer formatting
|
||||
*/
|
||||
export interface DiagnosticStreamerOptions {
|
||||
/** Target gate stage (PostFileEdit, PreCommit, PrePush, CI) */
|
||||
stage?: GateStage;
|
||||
toolName?: string;
|
||||
/** Execution duration in milliseconds */
|
||||
executionTimeMs?: number;
|
||||
toolOutputs?: Record<string, { exitCode: number; stderr: string; stdout: string }>;
|
||||
/** Optional tool name override */
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw output payload from an individual tool execution
|
||||
*/
|
||||
export interface ToolExecutionResult {
|
||||
/** Identifier of the tool (e.g. 'aislop', 'biome', 'ruff', 'trufflehog') */
|
||||
toolName: string;
|
||||
/** Combined stdout and stderr string */
|
||||
output: string;
|
||||
/** Process exit status code */
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Diagnostic Streamer and Aggregator.
|
||||
* Routes tool outputs to specialized parsers and constructs JSON diagnostic envelopes.
|
||||
*/
|
||||
export class DiagnosticStreamer {
|
||||
/**
|
||||
* Parse raw output from a tool and return an LSP-compliant DiagnosticEnvelope
|
||||
* Route tool output to the appropriate specialized parser based on tool name heuristics.
|
||||
*
|
||||
* @param toolName Name of the executed tool
|
||||
* @param output Raw terminal output string
|
||||
* @returns Array of parsed DiagnosticItem objects
|
||||
*/
|
||||
public static format(rawOutput: string, options: FormatOptions = {}): DiagnosticEnvelope {
|
||||
const stage = options.stage || 'PreCommit';
|
||||
const toolName = (options.toolName || '').toLowerCase().trim();
|
||||
const cleanOutput = stripAnsi(rawOutput).trim();
|
||||
public static parseToolOutput(toolName: string, output: string): DiagnosticItem[] {
|
||||
const lower = toolName.toLowerCase();
|
||||
|
||||
if (!cleanOutput) {
|
||||
return {
|
||||
$schema: 'https://json.schemastore.org/lsif.json',
|
||||
version: '1.0.0',
|
||||
status: 'GATE_PASSED',
|
||||
summary: {
|
||||
total_errors: 0,
|
||||
total_warnings: 0,
|
||||
gate_stage: stage,
|
||||
},
|
||||
diagnostics: [],
|
||||
metadata: {
|
||||
execution_time_ms: options.executionTimeMs,
|
||||
tool_outputs: options.toolOutputs,
|
||||
},
|
||||
};
|
||||
// Check specific tools in deterministic priority order
|
||||
if (lower.includes('aislop')) {
|
||||
return parseAislopOutput(output);
|
||||
}
|
||||
if (lower.includes('biome')) {
|
||||
return parseBiomeOutput(output);
|
||||
}
|
||||
if (lower.includes('trufflehog')) {
|
||||
return parseTrufflehogOutput(output);
|
||||
}
|
||||
if (lower.includes('ruff')) {
|
||||
return parseRuffOutput(output);
|
||||
}
|
||||
if (lower.includes('skill') || lower.includes('skillcheck')) {
|
||||
return parseSkillcheckOutput(output);
|
||||
}
|
||||
if (lower.includes('typo')) {
|
||||
return parseTyposOutput(output);
|
||||
}
|
||||
if (lower.includes('actionlint')) {
|
||||
return parseActionlintOutput(output);
|
||||
}
|
||||
|
||||
let diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
// Route to appropriate parser with strict naming order
|
||||
if (toolName === 'trufflehog' || toolName.includes('trufflehog') || cleanOutput.includes('trufflehog') || cleanOutput.includes('DetectorName')) {
|
||||
diagnostics = parseTrufflehogOutput(cleanOutput);
|
||||
} else if (toolName === 'aislop' || toolName.includes('aislop') || cleanOutput.includes('aislop') || cleanOutput.includes('AI_SLOP')) {
|
||||
diagnostics = parseAislopOutput(cleanOutput);
|
||||
} else if (toolName === 'biome' || toolName.includes('biome') || cleanOutput.includes('biome') || cleanOutput.includes('noExplicitAny')) {
|
||||
diagnostics = parseBiomeOutput(cleanOutput);
|
||||
} else if (toolName === 'ruff' || (toolName !== 'trufflehog' && toolName.includes('ruff')) || cleanOutput.includes('ruff') || /F\d{3}|E\d{3}|B\d{3}/.test(cleanOutput)) {
|
||||
diagnostics = parseRuffOutput(cleanOutput);
|
||||
} else if (toolName === 'skillcheck' || toolName.includes('skillcheck') || cleanOutput.includes('skillcheck') || cleanOutput.includes('SKILL.md')) {
|
||||
diagnostics = parseSkillcheckOutput(cleanOutput);
|
||||
} else if (toolName === 'typos' || toolName.includes('typos') || /should be/.test(cleanOutput)) {
|
||||
diagnostics = parseTyposOutput(cleanOutput);
|
||||
} else if (toolName === 'actionlint' || toolName.includes('actionlint')) {
|
||||
diagnostics = parseActionlintOutput(cleanOutput);
|
||||
} else {
|
||||
diagnostics = parseGenericOutput(cleanOutput, options.toolName || 'gate');
|
||||
}
|
||||
|
||||
// If specialized parser returned no items, try fallback
|
||||
if (diagnostics.length === 0 && cleanOutput.length > 0) {
|
||||
diagnostics = parseGenericOutput(cleanOutput, options.toolName || 'gate');
|
||||
}
|
||||
|
||||
const totalErrors = diagnostics.filter(d => d.severity === 'ERROR').length;
|
||||
const totalWarnings = diagnostics.filter(d => d.severity === 'WARNING').length;
|
||||
|
||||
return {
|
||||
$schema: 'https://json.schemastore.org/lsif.json',
|
||||
version: '1.0.0',
|
||||
status: totalErrors > 0 ? 'GATE_FAILED' : 'GATE_PASSED',
|
||||
summary: {
|
||||
total_errors: totalErrors,
|
||||
total_warnings: totalWarnings,
|
||||
gate_stage: stage,
|
||||
},
|
||||
diagnostics,
|
||||
metadata: {
|
||||
execution_time_ms: options.executionTimeMs,
|
||||
tool_outputs: options.toolOutputs,
|
||||
},
|
||||
};
|
||||
return parseGenericOutput(output, toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate multiple tool results into a single DiagnosticEnvelope
|
||||
* Aggregate multiple tool execution results into a unified DiagnosticEnvelope.
|
||||
*
|
||||
* @param results Array of ToolExecutionResult objects
|
||||
* @param options Formatting options
|
||||
* @returns Complete DiagnosticEnvelope conforming to LSIF JSON schema
|
||||
*/
|
||||
public static aggregate(
|
||||
toolResults: Array<{ toolName: string; output: string; exitCode: number }>,
|
||||
options: FormatOptions = {}
|
||||
results: ToolExecutionResult[],
|
||||
options: DiagnosticStreamerOptions = {}
|
||||
): DiagnosticEnvelope {
|
||||
const stage = options.stage || 'PreCommit';
|
||||
const allDiagnostics: DiagnosticItem[] = [];
|
||||
const toolOutputs: Record<string, { exitCode: number; stderr: string; stdout: string }> = {};
|
||||
|
||||
for (const res of toolResults) {
|
||||
for (const res of results) {
|
||||
toolOutputs[res.toolName] = {
|
||||
exitCode: res.exitCode,
|
||||
stderr: res.exitCode !== 0 ? res.output : '',
|
||||
stdout: res.exitCode === 0 ? res.output : '',
|
||||
stderr: res.output,
|
||||
stdout: '',
|
||||
};
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
const envelope = DiagnosticStreamer.format(res.output, {
|
||||
stage,
|
||||
toolName: res.toolName,
|
||||
});
|
||||
allDiagnostics.push(...envelope.diagnostics);
|
||||
// Only parse diagnostics if tool exited with non-zero or output is present
|
||||
if (res.exitCode !== 0 || res.output.trim().length > 0) {
|
||||
const diags = this.parseToolOutput(res.toolName, res.output);
|
||||
allDiagnostics.push(...diags);
|
||||
}
|
||||
}
|
||||
|
||||
const totalErrors = allDiagnostics.filter(d => d.severity === 'ERROR').length;
|
||||
const totalWarnings = allDiagnostics.filter(d => d.severity === 'WARNING').length;
|
||||
const status = (totalErrors > 0 || results.some(r => r.exitCode !== 0 && allDiagnostics.length === 0))
|
||||
? 'GATE_FAILED'
|
||||
: 'GATE_PASSED';
|
||||
|
||||
return {
|
||||
$schema: 'https://json.schemastore.org/lsif.json',
|
||||
version: '1.0.0',
|
||||
status: totalErrors > 0 ? 'GATE_FAILED' : 'GATE_PASSED',
|
||||
status,
|
||||
summary: {
|
||||
total_errors: totalErrors,
|
||||
total_warnings: totalWarnings,
|
||||
@@ -140,15 +135,23 @@ export class DiagnosticStreamer {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Output JSON diagnostic envelope to stdout/stderr
|
||||
*/
|
||||
public static streamJson(envelope: DiagnosticEnvelope, outStream: NodeJS.WritableStream = process.stderr): void {
|
||||
outStream.write(JSON.stringify(envelope, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDiagnostics(rawOutput: string, options?: FormatOptions): DiagnosticEnvelope {
|
||||
return DiagnosticStreamer.format(rawOutput, options);
|
||||
/**
|
||||
* Functional convenience wrapper to format a single tool output string into a DiagnosticEnvelope
|
||||
*
|
||||
* @param rawOutput Raw error string
|
||||
* @param options Formatter options
|
||||
* @param toolName Optional tool identifier
|
||||
*/
|
||||
export function formatDiagnostics(
|
||||
rawOutput: string,
|
||||
options: DiagnosticStreamerOptions = {},
|
||||
toolName?: string
|
||||
): DiagnosticEnvelope {
|
||||
const effectiveToolName = options.toolName || toolName || 'gate';
|
||||
return DiagnosticStreamer.aggregate(
|
||||
[{ toolName: effectiveToolName, output: rawOutput, exitCode: rawOutput.trim().length > 0 ? 1 : 0 }],
|
||||
options
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file src/formatter/index.ts
|
||||
* @description Exports diagnostic streaming and output formatting modules.
|
||||
*/
|
||||
|
||||
export * from './ansi.js';
|
||||
export * from './diagnosticStream.js';
|
||||
export * from './parsers/aislop.js';
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/actionlint.ts
|
||||
* @description Actionlint output parser for GitHub Actions workflow syntax validation.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
/**
|
||||
* Parse raw stdout/stderr from `actionlint` into structured DiagnosticItems.
|
||||
*
|
||||
* @param rawOutput Raw terminal output from actionlint
|
||||
* @returns Array of DiagnosticItem objects with workflow repair instructions
|
||||
*/
|
||||
export function parseActionlintOutput(rawOutput: string): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
const lines = clean.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Pattern: .github/workflows/ci.yml:15:3: message [rule-name]
|
||||
const match = trimmed.match(/^([^\s:]+\.ya?ml):(\d+):(\d+):\s*(.*?)(?:\s*\[([^\]]+)\])?$/);
|
||||
// Pattern: .github/workflows/ci.yml:12:7: message [rule-id]
|
||||
const match = trimmed.match(/^([^\s:]+):(\d+):(\d+):\s*(.*?)(?:\s*\[([^\]]+)\])?$/);
|
||||
if (match) {
|
||||
const [, filePath, lineStr, colStr, msg, rule] = match;
|
||||
const lineNum = parseInt(lineStr, 10);
|
||||
const colNum = parseInt(colStr, 10);
|
||||
const ruleId = rule ? `ACTIONLINT_${rule.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}` : 'ACTIONLINT_SYNTAX_ERROR';
|
||||
|
||||
const [, filePath, lineStr, colStr, message, ruleId] = match;
|
||||
const effectiveRuleId = ruleId || 'ACTIONLINT_SYNTAX_ERROR';
|
||||
diagnostics.push({
|
||||
source: 'actionlint',
|
||||
rule_id: ruleId,
|
||||
rule_id: effectiveRuleId,
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: colNum },
|
||||
end: { line: lineNum, column: colNum + 20 },
|
||||
start: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) },
|
||||
end: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) + 20 },
|
||||
},
|
||||
error_message: msg.trim() || 'GitHub Actions workflow validation error',
|
||||
error_message: message.trim(),
|
||||
repair_instruction: {
|
||||
action: 'REWRITE_BLOCK',
|
||||
description: 'Fix the GitHub Actions workflow syntax error according to official GitHub Actions schema.',
|
||||
repair_tokens: [
|
||||
'# Check workflow syntax against GitHub Actions schema documentation',
|
||||
],
|
||||
description: `Fix GitHub Actions workflow syntax error: ${message.trim()}`,
|
||||
repair_tokens: [`# Correct workflow step syntax in ${filePath}`],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/aislop.ts
|
||||
* @description AISlop output parser for deterministic AI code smells and slop detection.
|
||||
*
|
||||
* Extracts violations for swallowed errors, empty catch blocks, hallucinated imports,
|
||||
* dead code, and unsafe type casts, and generates actionable repair tokens for LLMs.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
/**
|
||||
* Parse raw output from the `aislop` CLI tool into structured DiagnosticItems.
|
||||
* Supports both JSON payload formats and standard CLI text output.
|
||||
*
|
||||
* @param rawOutput Raw stdout/stderr string from aislop
|
||||
* @returns Array of DiagnosticItem objects with rule IDs and repair instructions
|
||||
*/
|
||||
export function parseAislopOutput(rawOutput: string): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
// Check if output is JSON
|
||||
// 1. Check if output is JSON
|
||||
if (clean.trim().startsWith('{') || clean.trim().startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(clean);
|
||||
@@ -29,21 +44,18 @@ export function parseAislopOutput(rawOutput: string): DiagnosticItem[] {
|
||||
}
|
||||
if (diagnostics.length > 0) return diagnostics;
|
||||
} catch {
|
||||
// Continue to regex parser
|
||||
// Continue to regex parser if JSON parse fails
|
||||
}
|
||||
}
|
||||
|
||||
// Regex parser for CLI textual output
|
||||
// Example pattern 1: [AISLOP] (AI_SLOP_SWALLOWED_ERROR) src/controllers/auth.ts:88:7: Empty catch block silently suppresses error
|
||||
// Example pattern 2: src/controllers/auth.ts:88:7: [AI_SLOP_SWALLOWED_ERROR] Empty catch block
|
||||
// Example pattern 3: AI_SLOP_SWALLOWED_ERROR at src/auth.ts:88: Empty catch block
|
||||
// 2. Regex parser for CLI textual output
|
||||
const lines = clean.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Pattern: filePath:line:col: [RULE_ID] message or error message
|
||||
// Pattern 1: filePath:line:col: [RULE_ID] message
|
||||
const match1 = line.match(/^([^\s:]+):(\d+):(\d+):\s*(?:\[([^\]]+)\])?\s*(.*)$/);
|
||||
if (match1) {
|
||||
const [, filePath, lineStr, colStr, ruleId, msg] = match1;
|
||||
@@ -63,7 +75,7 @@ export function parseAislopOutput(rawOutput: string): DiagnosticItem[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern: [AISLOP] (RULE_ID) file:line - message
|
||||
// Pattern 2: [aislop] (RULE_ID) file:line - message
|
||||
const match2 = line.match(/\[aislop\]\s*(?:\(([^)]+)\))?\s*([^\s:]+)(?::(\d+))?(?::(\d+))?[:\s-]+(.*)/i);
|
||||
if (match2) {
|
||||
const [, ruleId, filePath, lineStr, colStr, msg] = match2;
|
||||
@@ -85,7 +97,7 @@ export function parseAislopOutput(rawOutput: string): DiagnosticItem[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern: AI Slop score exceeded
|
||||
// Pattern 3: AI Slop score threshold exceeded
|
||||
if (line.toLowerCase().includes('slop score') || line.toLowerCase().includes('fail-on')) {
|
||||
diagnostics.push({
|
||||
source: 'aislop',
|
||||
@@ -107,6 +119,9 @@ export function parseAislopOutput(rawOutput: string): DiagnosticItem[] {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer rule identifier from error message text
|
||||
*/
|
||||
function detectRuleFromMessage(msg: string = ''): string {
|
||||
const m = msg.toLowerCase();
|
||||
if (m.includes('catch') || m.includes('swallow') || m.includes('silent')) {
|
||||
@@ -124,6 +139,9 @@ function detectRuleFromMessage(msg: string = ''): string {
|
||||
return 'AI_SLOP_PATTERN_DETECTED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate actionable repair tokens based on the identified AISlop rule
|
||||
*/
|
||||
function generateAislopRepair(ruleId: string = '', snippet?: string): DiagnosticItem['repair_instruction'] {
|
||||
const r = ruleId.toUpperCase();
|
||||
if (r.includes('SWALLOW') || r.includes('CATCH') || r.includes('EMPTY')) {
|
||||
|
||||
@@ -1,122 +1,111 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/biome.ts
|
||||
* @description Biome output parser for JS/TS linter and formatter diagnostics.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
/**
|
||||
* Parse raw stdout/stderr from `biome check` into structured DiagnosticItems.
|
||||
* Supports both Biome JSON output and human-readable CLI terminal diagnostics.
|
||||
*
|
||||
* @param rawOutput Raw terminal output from Biome
|
||||
* @returns Array of DiagnosticItem objects with rule IDs and repair tokens
|
||||
*/
|
||||
export function parseBiomeOutput(rawOutput: string): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
// Check if JSON output
|
||||
// 1. Check if output is Biome JSON format
|
||||
if (clean.trim().startsWith('{')) {
|
||||
try {
|
||||
const data = JSON.parse(clean);
|
||||
if (data.diagnostics && Array.isArray(data.diagnostics)) {
|
||||
for (const diag of data.diagnostics) {
|
||||
const parsed = JSON.parse(clean);
|
||||
if (parsed.diagnostics && Array.isArray(parsed.diagnostics)) {
|
||||
for (const diag of parsed.diagnostics) {
|
||||
const filePath = diag.location?.path?.file || 'unknown';
|
||||
const startLine = diag.location?.span?.start?.line || 1;
|
||||
const startCol = diag.location?.span?.start?.column || 1;
|
||||
const endLine = diag.location?.span?.end?.line || startLine;
|
||||
const endCol = diag.location?.span?.end?.column || startCol;
|
||||
const ruleId = diag.category || 'biome/lint';
|
||||
const message = diag.description || diag.message || 'Biome lint violation';
|
||||
const line = diag.location?.span?.[0] ? 1 : (diag.location?.start?.line || 1);
|
||||
const col = diag.location?.start?.column || 1;
|
||||
const ruleId = diag.category || 'biome-lint';
|
||||
const message = diag.description || (diag.message && diag.message[0]?.text) || 'Biome lint violation';
|
||||
const severity = diag.severity === 'error' || diag.severity === 'fatal' ? 'ERROR' : 'WARNING';
|
||||
|
||||
diagnostics.push({
|
||||
source: 'biome',
|
||||
rule_id: ruleId,
|
||||
severity: diag.severity === 'warning' ? 'WARNING' : 'ERROR',
|
||||
severity,
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: startLine, column: startCol },
|
||||
end: { line: endLine, column: endCol },
|
||||
start: { line, column: col },
|
||||
end: { line, column: col + 20 },
|
||||
},
|
||||
error_message: message,
|
||||
repair_instruction: generateBiomeRepair(ruleId, message),
|
||||
repair_instruction: generateBiomeRepair(ruleId, filePath),
|
||||
});
|
||||
}
|
||||
if (diagnostics.length > 0) return diagnostics;
|
||||
}
|
||||
} catch {
|
||||
// Continue to regex
|
||||
// Continue to regex parser if JSON parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Regex parsing for Biome text output
|
||||
// Example:
|
||||
// src/index.ts:12:5 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// ✖ Avoid using any.
|
||||
// 2. Regex parser for Biome standard CLI terminal output
|
||||
const lines = clean.split('\n');
|
||||
let currentDiag: Partial<DiagnosticItem> | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const headerMatch = line.match(/^([^\s:]+):(\d+):(\d+)\s+([a-zA-Z0-9_\-\/]+)/);
|
||||
if (headerMatch) {
|
||||
if (currentDiag && currentDiag.file_path) {
|
||||
diagnostics.push(currentDiag as DiagnosticItem);
|
||||
|
||||
// Pattern: filePath:line:col lint/suspicious/noExplicitAny ━━━━━━━━━━━━━
|
||||
const match = line.match(/^([^\s:]+):(\d+):(\d+)\s+([a-zA-Z0-9_\-\/]+)/);
|
||||
if (match) {
|
||||
const [, filePath, lineStr, colStr, ruleId] = match;
|
||||
let errorMsg = 'Biome lint violation';
|
||||
|
||||
// Look ahead for the error description on subsequent lines
|
||||
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
|
||||
const nextLine = lines[j].trim();
|
||||
if (nextLine.startsWith('✖') || nextLine.startsWith('!') || nextLine.startsWith('i')) {
|
||||
errorMsg = nextLine.replace(/^[✖!i]\s*/, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const [, filePath, lineStr, colStr, ruleId] = headerMatch;
|
||||
const lineNum = parseInt(lineStr, 10);
|
||||
const colNum = parseInt(colStr, 10);
|
||||
|
||||
currentDiag = {
|
||||
diagnostics.push({
|
||||
source: 'biome',
|
||||
rule_id: ruleId,
|
||||
severity: ruleId.includes('warn') ? 'WARNING' : 'ERROR',
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: colNum },
|
||||
end: { line: lineNum, column: colNum + 10 },
|
||||
start: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) },
|
||||
end: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) + 20 },
|
||||
},
|
||||
error_message: '',
|
||||
};
|
||||
continue;
|
||||
error_message: errorMsg,
|
||||
repair_instruction: generateBiomeRepair(ruleId, filePath),
|
||||
});
|
||||
}
|
||||
|
||||
if (currentDiag && (line.includes('✖') || line.includes('error:') || line.includes('warn:'))) {
|
||||
const msg = line.replace(/^[✖\s]+/, '').trim();
|
||||
currentDiag.error_message = msg;
|
||||
currentDiag.repair_instruction = generateBiomeRepair(currentDiag.rule_id || '', msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDiag && currentDiag.file_path) {
|
||||
if (!currentDiag.error_message) currentDiag.error_message = 'Biome lint or format violation';
|
||||
if (!currentDiag.repair_instruction) currentDiag.repair_instruction = generateBiomeRepair(currentDiag.rule_id || '');
|
||||
diagnostics.push(currentDiag as DiagnosticItem);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function generateBiomeRepair(ruleId: string, message: string = ''): DiagnosticItem['repair_instruction'] {
|
||||
/**
|
||||
* Generate actionable repair instructions for Biome rules
|
||||
*/
|
||||
function generateBiomeRepair(ruleId: string, filePath: string): DiagnosticItem['repair_instruction'] {
|
||||
if (ruleId.includes('noExplicitAny')) {
|
||||
return {
|
||||
action: 'REPLACE_TOKEN',
|
||||
description: 'Replace `any` with `unknown` or a specific typed interface.',
|
||||
repair_tokens: ['unknown', 'Record<string, unknown>'],
|
||||
};
|
||||
}
|
||||
|
||||
if (ruleId.includes('noUnusedVariables') || ruleId.includes('noUnusedImports')) {
|
||||
return {
|
||||
action: 'DELETE_LINE',
|
||||
description: 'Remove unused variable or import.',
|
||||
repair_tokens: ['// Remove unused identifier'],
|
||||
};
|
||||
}
|
||||
|
||||
if (ruleId.includes('useConst')) {
|
||||
return {
|
||||
action: 'REPLACE_TOKEN',
|
||||
description: 'Use `const` instead of `let` or `var` for variables that are never reassigned.',
|
||||
repair_tokens: ['const '],
|
||||
description: 'Replace `any` with `unknown` or a specific TypeScript interface / type guard.',
|
||||
repair_tokens: ['unknown', 'type SafePayload = Record<string, unknown>;'],
|
||||
suggested_command: `npx @biomejs/biome check --write ${filePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'EXECUTE_COMMAND',
|
||||
description: `Run biome check --write to automatically apply safe fixes.`,
|
||||
suggested_command: 'npx biome check --write ${filePath}',
|
||||
repair_tokens: ['npx biome check --write'],
|
||||
description: `Run Biome automated fix or format: npx @biomejs/biome check --write ${filePath}`,
|
||||
repair_tokens: [`npx @biomejs/biome check --write ${filePath}`],
|
||||
suggested_command: `npx @biomejs/biome check --write ${filePath}`,
|
||||
};
|
||||
}
|
||||
@@ -1,63 +1,79 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/generic.ts
|
||||
* @description Fallback generic output parser for unrecognized tools and standard error streams.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
export function parseGenericOutput(rawOutput: string, sourceName: string = 'gate'): DiagnosticItem[] {
|
||||
/**
|
||||
* Parse arbitrary tool error output into structured DiagnosticItems using standard heuristics.
|
||||
*
|
||||
* @param rawOutput Raw error string from an unclassified tool
|
||||
* @param toolName Name of the tool producing the output
|
||||
* @returns Array of DiagnosticItem objects
|
||||
*/
|
||||
export function parseGenericOutput(rawOutput: string, toolName: string = 'unknown'): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
const lines = clean.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Matches standard Unix error formats:
|
||||
// path/to/file.ext:10:5: error message
|
||||
// path/to/file.ext:10: error message
|
||||
const match = trimmed.match(/^([^\s:]+\.[a-zA-Z0-9_-]+):(\d+)(?::(\d+))?:\s*(?:([a-zA-Z0-9_\-\/]+):)?\s*(.*)$/);
|
||||
if (match) {
|
||||
const [, filePath, lineStr, colStr, ruleId, msg] = match;
|
||||
const lineNum = parseInt(lineStr, 10);
|
||||
const colNum = colStr ? parseInt(colStr, 10) : 1;
|
||||
|
||||
// Pattern 1: filePath:line:col: message
|
||||
const match1 = trimmed.match(/^([^\s:]+):(\d+):(\d+):\s*(.*)$/);
|
||||
if (match1) {
|
||||
const [, filePath, lineStr, colStr, message] = match1;
|
||||
diagnostics.push({
|
||||
source: sourceName,
|
||||
rule_id: ruleId || `${sourceName.toUpperCase()}_ERROR`,
|
||||
severity: msg.toLowerCase().includes('warning') ? 'WARNING' : 'ERROR',
|
||||
source: toolName,
|
||||
rule_id: `${toolName.toUpperCase()}_ERROR`,
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: colNum },
|
||||
end: { line: lineNum, column: colNum + 20 },
|
||||
start: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) },
|
||||
end: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) + 20 },
|
||||
},
|
||||
error_message: msg.trim() || 'Tool validation failed',
|
||||
error_message: message.trim(),
|
||||
repair_instruction: {
|
||||
action: 'MANUAL_FIX',
|
||||
description: `Fix the issue reported by ${sourceName}.`,
|
||||
repair_tokens: [`// Resolve ${ruleId || 'error'} in ${filePath}`],
|
||||
description: `Resolve ${toolName} violation in ${filePath}`,
|
||||
repair_tokens: [`# Fix violation reported by ${toolName}`],
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern 2: Generic error line
|
||||
if (trimmed.toLowerCase().includes('error') || trimmed.toLowerCase().includes('failed')) {
|
||||
diagnostics.push({
|
||||
source: toolName,
|
||||
rule_id: `${toolName.toUpperCase()}_FAILURE`,
|
||||
severity: 'ERROR',
|
||||
file_path: 'codebase',
|
||||
error_message: trimmed,
|
||||
repair_instruction: {
|
||||
action: 'MANUAL_FIX',
|
||||
description: `Resolve error reported by ${toolName}`,
|
||||
repair_tokens: [`# Resolve ${toolName} failure: ${trimmed}`],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lower = clean.toLowerCase();
|
||||
const isSuccessMessage =
|
||||
lower.includes('no errors') ||
|
||||
lower.includes('0 errors') ||
|
||||
lower.includes('passed') ||
|
||||
lower.includes('checked') ||
|
||||
lower.includes('success');
|
||||
|
||||
if (diagnostics.length === 0 && clean.trim().length > 0 && !isSuccessMessage) {
|
||||
// If no line-based match was found, emit a general gate diagnostic
|
||||
// If no specific lines matched but rawOutput is non-empty, emit a single diagnostic item
|
||||
if (diagnostics.length === 0 && clean.trim().length > 0) {
|
||||
diagnostics.push({
|
||||
source: sourceName,
|
||||
rule_id: `${sourceName.toUpperCase()}_GATE_FAILURE`,
|
||||
source: toolName,
|
||||
rule_id: `${toolName.toUpperCase()}_ERROR`,
|
||||
severity: 'ERROR',
|
||||
file_path: 'workspace',
|
||||
error_message: clean.trim().slice(0, 500),
|
||||
file_path: 'codebase',
|
||||
error_message: clean.trim().split('\n')[0] || `${toolName} execution failed`,
|
||||
repair_instruction: {
|
||||
action: 'MANUAL_FIX',
|
||||
description: `Review output from ${sourceName} and fix failing constraints.`,
|
||||
repair_tokens: ['// Check error output and resolve failing check'],
|
||||
description: `Investigate and resolve ${toolName} failure.`,
|
||||
repair_tokens: [`# Fix ${toolName} failure`],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,62 +1,78 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/ruff.ts
|
||||
* @description Ruff output parser for Python linter and formatter diagnostics.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
/**
|
||||
* Parse raw stdout/stderr from `ruff check` into structured DiagnosticItems.
|
||||
* Supports both Ruff JSON output and standard concise CLI text output.
|
||||
*
|
||||
* @param rawOutput Raw terminal output from Ruff
|
||||
* @returns Array of DiagnosticItem objects with rule codes (e.g. F401, E722) and repair instructions
|
||||
*/
|
||||
export function parseRuffOutput(rawOutput: string): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
// Check if JSON output
|
||||
// 1. Check if output is Ruff JSON format
|
||||
if (clean.trim().startsWith('[')) {
|
||||
try {
|
||||
const items = JSON.parse(clean);
|
||||
for (const item of items) {
|
||||
if (item.code && item.filename) {
|
||||
const parsed = JSON.parse(clean);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
const filePath = item.filename || 'unknown';
|
||||
const line = item.location?.row || 1;
|
||||
const col = item.location?.column || 1;
|
||||
const endLine = item.end_location?.row || line;
|
||||
const endCol = item.end_location?.column || col + 10;
|
||||
const ruleId = item.code || 'RUFF_ERROR';
|
||||
const message = item.message || 'Python lint violation';
|
||||
|
||||
diagnostics.push({
|
||||
source: 'ruff',
|
||||
rule_id: item.code,
|
||||
rule_id: ruleId,
|
||||
severity: 'ERROR',
|
||||
file_path: item.filename,
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: {
|
||||
line: item.location?.row || 1,
|
||||
column: item.location?.column || 1,
|
||||
},
|
||||
end: {
|
||||
line: item.end_location?.row || item.location?.row || 1,
|
||||
column: item.end_location?.column || item.location?.column || 1,
|
||||
},
|
||||
start: { line, column: col },
|
||||
end: { line: endLine, column: endCol },
|
||||
},
|
||||
error_message: item.message || 'Ruff violation',
|
||||
repair_instruction: generateRuffRepair(item.code, item.message),
|
||||
error_message: message,
|
||||
repair_instruction: generateRuffRepair(ruleId, message, filePath),
|
||||
});
|
||||
}
|
||||
if (diagnostics.length > 0) return diagnostics;
|
||||
}
|
||||
if (diagnostics.length > 0) return diagnostics;
|
||||
} catch {
|
||||
// Continue to regex
|
||||
// Continue to regex parser if JSON parse fails
|
||||
}
|
||||
}
|
||||
|
||||
// Regex parsing: path/to/file.py:row:col: CODE [*] Message
|
||||
// 2. Regex parser for Ruff standard text output: filePath:line:col: RULE [*] message
|
||||
const lines = clean.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^\s:]+\.py):(\d+):(\d+):\s*([A-Z0-9]+)\s*(?:\[\*\])?\s*(.*)$/);
|
||||
if (match) {
|
||||
const [, filePath, rowStr, colStr, code, message] = match;
|
||||
const row = parseInt(rowStr, 10);
|
||||
const col = parseInt(colStr, 10);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Pattern: api/server.py:2:1: F401 [*] 'sys' imported but unused
|
||||
const match = trimmed.match(/^([^\s:]+):(\d+):(\d+):\s*([A-Z0-9]+)\s*(?:\[\*\])?\s*(.*)$/);
|
||||
if (match) {
|
||||
const [, filePath, lineStr, colStr, ruleId, message] = match;
|
||||
diagnostics.push({
|
||||
source: 'ruff',
|
||||
rule_id: code,
|
||||
rule_id: ruleId,
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: row, column: col },
|
||||
end: { line: row, column: col + 10 },
|
||||
start: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) },
|
||||
end: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) + 10 },
|
||||
},
|
||||
error_message: message.trim(),
|
||||
repair_instruction: generateRuffRepair(code, message.trim()),
|
||||
repair_instruction: generateRuffRepair(ruleId, message.trim(), filePath),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -64,16 +80,22 @@ export function parseRuffOutput(rawOutput: string): DiagnosticItem[] {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function generateRuffRepair(code: string, message: string = ''): DiagnosticItem['repair_instruction'] {
|
||||
if (code.startsWith('F401')) {
|
||||
/**
|
||||
* Generate actionable repair instructions and replacement tokens for Ruff rules
|
||||
*/
|
||||
function generateRuffRepair(ruleId: string, message: string, filePath: string): DiagnosticItem['repair_instruction'] {
|
||||
// F401: Unused import
|
||||
if (ruleId === 'F401') {
|
||||
return {
|
||||
action: 'DELETE_LINE',
|
||||
description: 'Remove unused import statement.',
|
||||
repair_tokens: ['# Remove unused import'],
|
||||
suggested_command: `ruff check --fix ${filePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (code.startsWith('E722') || code.startsWith('B001')) {
|
||||
// E722: Do not use bare 'except'
|
||||
if (ruleId === 'E722' || message.toLowerCase().includes('bare')) {
|
||||
return {
|
||||
action: 'REWRITE_BLOCK',
|
||||
description: 'Do not use bare `except:`. Specify explicit exception type (e.g. `except Exception as e:`).',
|
||||
@@ -82,21 +104,24 @@ function generateRuffRepair(code: string, message: string = ''): DiagnosticItem[
|
||||
' logger.error(f"Operation failed: {err}")',
|
||||
' raise',
|
||||
],
|
||||
suggested_command: `ruff check --fix ${filePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (code.startsWith('F841')) {
|
||||
// B006: Do not use mutable data structures for argument defaults
|
||||
if (ruleId === 'B006') {
|
||||
return {
|
||||
action: 'DELETE_LINE',
|
||||
description: 'Remove unused local variable or prefix with underscore `_`.',
|
||||
repair_tokens: ['_ = ...'],
|
||||
action: 'REPLACE_TOKEN',
|
||||
description: 'Replace mutable argument default (e.g. `arg=[]`) with `None` and initialize in function body.',
|
||||
repair_tokens: ['arg: list | None = None', 'if arg is None: arg = []'],
|
||||
};
|
||||
}
|
||||
|
||||
// General auto-fixable Ruff rule
|
||||
return {
|
||||
action: 'EXECUTE_COMMAND',
|
||||
description: `Run ruff check --fix ${message ? `to fix: ${message}` : ''}`,
|
||||
suggested_command: 'ruff check --fix ${filePath}',
|
||||
repair_tokens: ['ruff check --fix'],
|
||||
description: `Apply Ruff automated fix: ruff check --fix ${filePath}`,
|
||||
repair_tokens: [`ruff check --fix ${filePath}`],
|
||||
suggested_command: `ruff check --fix ${filePath}`,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +1,62 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/skillcheck.ts
|
||||
* @description SkillCheck output parser for agent skill files and markdown instructions.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
/**
|
||||
* Parse raw stdout/stderr from `skillcheck` into structured DiagnosticItems.
|
||||
*
|
||||
* @param rawOutput Raw terminal output from SkillCheck
|
||||
* @returns Array of DiagnosticItem objects with YAML frontmatter repair tokens
|
||||
*/
|
||||
export function parseSkillcheckOutput(rawOutput: string): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
// Check JSON
|
||||
if (clean.trim().startsWith('{') || clean.trim().startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(clean);
|
||||
const items = Array.isArray(parsed) ? parsed : (parsed.violations || parsed.diagnostics || [parsed]);
|
||||
for (const item of items) {
|
||||
diagnostics.push({
|
||||
source: 'skillcheck',
|
||||
rule_id: item.rule_id || item.rule || 'SKILL_CHECK_VIOLATION',
|
||||
severity: (item.severity || 'ERROR').toUpperCase() as any,
|
||||
file_path: item.file_path || item.file || 'SKILL.md',
|
||||
range: item.range || {
|
||||
start: { line: item.line || 1, column: item.column || 1 },
|
||||
end: { line: item.end_line || item.line || 1, column: item.end_column || 80 },
|
||||
},
|
||||
code_snippet: item.snippet,
|
||||
error_message: item.message || 'Agent skill validation failed',
|
||||
repair_instruction: generateSkillcheckRepair(item.rule_id || item.rule),
|
||||
});
|
||||
}
|
||||
if (diagnostics.length > 0) return diagnostics;
|
||||
} catch {
|
||||
// Continue to regex
|
||||
}
|
||||
}
|
||||
|
||||
// Regex parser
|
||||
const lines = clean.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^\s:]+\.md)(?::(\d+))?(?::(\d+))?:\s*(?:\[([^\]]+)\])?\s*(.*)$/i);
|
||||
if (match) {
|
||||
const [, filePath, lineStr, colStr, ruleId, msg] = match;
|
||||
const lineNum = lineStr ? parseInt(lineStr, 10) : 1;
|
||||
const colNum = colStr ? parseInt(colStr, 10) : 1;
|
||||
const effectiveRuleId = ruleId || detectSkillRule(msg);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Pattern 1: filePath:line:col: [RULE_ID] message
|
||||
const match = trimmed.match(/^([^\s:]+):(\d+):(\d+):\s*(?:\[([^\]]+)\])?\s*(.*)$/);
|
||||
if (match) {
|
||||
const [, filePath, lineStr, colStr, ruleId, message] = match;
|
||||
const effectiveRuleId = ruleId || 'SKILL_VALIDATION_ERROR';
|
||||
diagnostics.push({
|
||||
source: 'skillcheck',
|
||||
rule_id: effectiveRuleId,
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: colNum },
|
||||
end: { line: lineNum, column: colNum + 30 },
|
||||
start: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) },
|
||||
end: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) + 30 },
|
||||
},
|
||||
error_message: msg.trim() || 'Agent skill configuration violation',
|
||||
repair_instruction: generateSkillcheckRepair(effectiveRuleId),
|
||||
error_message: message.trim(),
|
||||
repair_instruction: generateSkillRepair(effectiveRuleId, message.trim()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern 2: [skillcheck] error in filePath: message
|
||||
const match2 = trimmed.match(/\[skillcheck\]\s*(?:error|warning)?\s*(?:in\s+)?([^\s:]+)(?::(\d+))?:\s*(.*)/i);
|
||||
if (match2) {
|
||||
const [, filePath, lineStr, message] = match2;
|
||||
const lineNum = lineStr ? parseInt(lineStr, 10) : 1;
|
||||
diagnostics.push({
|
||||
source: 'skillcheck',
|
||||
rule_id: 'SKILL_VALIDATION_ERROR',
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: 1 },
|
||||
end: { line: lineNum, column: 40 },
|
||||
},
|
||||
error_message: message.trim(),
|
||||
repair_instruction: generateSkillRepair('SKILL_VALIDATION_ERROR', message.trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -59,26 +64,12 @@ export function parseSkillcheckOutput(rawOutput: string): DiagnosticItem[] {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function detectSkillRule(msg: string = ''): string {
|
||||
const m = msg.toLowerCase();
|
||||
if (m.includes('frontmatter') || m.includes('yaml') || m.includes('header')) {
|
||||
return 'SKILL_INVALID_FRONTMATTER';
|
||||
}
|
||||
if (m.includes('permission') || m.includes('grant') || m.includes('over-grant')) {
|
||||
return 'SKILL_OVER_GRANTED_PERMISSIONS';
|
||||
}
|
||||
if (m.includes('prompt injection') || m.includes('escape') || m.includes('security')) {
|
||||
return 'SKILL_SECURITY_RISK';
|
||||
}
|
||||
if (m.includes('trigger') || m.includes('description')) {
|
||||
return 'SKILL_MISSING_METADATA';
|
||||
}
|
||||
return 'SKILL_VALIDATION_ERROR';
|
||||
}
|
||||
|
||||
function generateSkillcheckRepair(ruleId: string = ''): DiagnosticItem['repair_instruction'] {
|
||||
const r = ruleId.toUpperCase();
|
||||
if (r.includes('FRONTMATTER') || r.includes('METADATA')) {
|
||||
/**
|
||||
* Generate actionable YAML frontmatter repair tokens for skill files
|
||||
*/
|
||||
function generateSkillRepair(ruleId: string, message: string): DiagnosticItem['repair_instruction'] {
|
||||
const m = message.toLowerCase();
|
||||
if (m.includes('frontmatter') || m.includes('header') || ruleId.includes('FRONTMATTER')) {
|
||||
return {
|
||||
action: 'REWRITE_BLOCK',
|
||||
description: 'Ensure skill file starts with valid YAML frontmatter containing name and description.',
|
||||
@@ -91,19 +82,9 @@ function generateSkillcheckRepair(ruleId: string = ''): DiagnosticItem['repair_i
|
||||
};
|
||||
}
|
||||
|
||||
if (r.includes('PERMISSION') || r.includes('SECURITY')) {
|
||||
return {
|
||||
action: 'REWRITE_BLOCK',
|
||||
description: 'Restrict permissions to the minimal set of required tools and paths.',
|
||||
repair_tokens: [
|
||||
'# Enforce least-privilege tool access boundaries',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'MANUAL_FIX',
|
||||
description: 'Format agent skill markdown according to the Agent Skill standard.',
|
||||
repair_tokens: ['# Skill Documentation and Instructions'],
|
||||
action: 'REWRITE_BLOCK',
|
||||
description: 'Ensure skill adheres to schema specifications and OWASP agentic boundaries.',
|
||||
repair_tokens: ['# Verify skill name, description, and input schema definitions'],
|
||||
};
|
||||
}
|
||||
@@ -1,34 +1,46 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/trufflehog.ts
|
||||
* @description TruffleHog output parser for high-entropy secret detection.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
/**
|
||||
* Parse raw stdout/stderr from `trufflehog` into structured DiagnosticItems.
|
||||
* Supports NDJSON (newline-delimited JSON) emitted by `trufflehog --json`.
|
||||
*
|
||||
* @param rawOutput Raw output from TruffleHog
|
||||
* @returns Array of DiagnosticItem objects with credential revocation instructions
|
||||
*/
|
||||
export function parseTrufflehogOutput(rawOutput: string): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
// Check JSON / JSON-Lines
|
||||
const lines = clean.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Check for TruffleHog NDJSON line
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
try {
|
||||
const item = JSON.parse(trimmed);
|
||||
const detector = item.DetectorName || item.detector_name || 'SECRET';
|
||||
const file = item.SourceMetadata?.Data?.Git?.file || item.file || item.file_path || 'unknown';
|
||||
const lineNum = item.SourceMetadata?.Data?.Git?.line || item.line || 1;
|
||||
const verified = item.Verified ?? true;
|
||||
const detector = item.DetectorName || item.DetectorType || 'SECRET';
|
||||
const filePath = item.SourceMetadata?.Data?.Git?.file || item.SourceMetadata?.Data?.Filesystem?.file || 'unknown';
|
||||
const lineNum = item.SourceMetadata?.Data?.Git?.line || 1;
|
||||
const verified = item.Verified === true;
|
||||
|
||||
diagnostics.push({
|
||||
source: 'trufflehog',
|
||||
rule_id: `VERIFIED_SECRET_${detector.toUpperCase()}`,
|
||||
severity: 'ERROR',
|
||||
file_path: file,
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: 1 },
|
||||
end: { line: lineNum, column: 80 },
|
||||
},
|
||||
error_message: `${verified ? 'Verified' : 'Unverified'} high-entropy credential or secret detected (${detector}).`,
|
||||
error_message: `Verified high-entropy credential or secret detected (${detector}).${verified ? ' [VERIFIED ACTIVE]' : ''}`,
|
||||
repair_instruction: {
|
||||
action: 'REPLACE_TOKEN',
|
||||
description: `Revoke this exposed ${detector} credential immediately and replace hardcoded secret with an environment variable lookup.`,
|
||||
@@ -39,32 +51,29 @@ export function parseTrufflehogOutput(rawOutput: string): DiagnosticItem[] {
|
||||
});
|
||||
continue;
|
||||
} catch {
|
||||
// Fall through to regex
|
||||
// Continue to regex if JSON parse fails
|
||||
}
|
||||
}
|
||||
|
||||
// Text regex: Found verified <detector> secret in <file>:<line>
|
||||
const match = trimmed.match(/(?:found|detected)\s+(?:verified\s+)?([a-zA-Z0-9_-]+)\s+secret\s+in\s+([^\s:]+)(?::(\d+))?/i);
|
||||
// Pattern 2: Textual secret detection finding
|
||||
const match = trimmed.match(/found\s+([a-zA-Z0-9_\-]+)\s+secret\s+in\s+([^\s:]+)(?::(\d+))?/i);
|
||||
if (match) {
|
||||
const [, detector, filePath, lineStr] = match;
|
||||
const lineNum = lineStr ? parseInt(lineStr, 10) : 1;
|
||||
|
||||
diagnostics.push({
|
||||
source: 'trufflehog',
|
||||
rule_id: `VERIFIED_SECRET_${detector.toUpperCase()}`,
|
||||
rule_id: `SECRET_${detector.toUpperCase()}`,
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: 1 },
|
||||
end: { line: lineNum, column: 80 },
|
||||
},
|
||||
error_message: `Verified high-entropy credential or secret detected (${detector}).`,
|
||||
error_message: `Secret detected: ${detector}`,
|
||||
repair_instruction: {
|
||||
action: 'REPLACE_TOKEN',
|
||||
description: `Revoke this exposed ${detector} credential immediately and replace hardcoded secret with an environment variable lookup.`,
|
||||
repair_tokens: [
|
||||
`process.env.${detector.toUpperCase()}_KEY || process.env.API_KEY`,
|
||||
],
|
||||
description: `Replace hardcoded ${detector} secret with an environment variable.`,
|
||||
repair_tokens: [`process.env.${detector.toUpperCase()}`],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,58 +1,46 @@
|
||||
/**
|
||||
* @file src/formatter/parsers/typos.ts
|
||||
* @description Typos output parser for AST-aware source code spell checking.
|
||||
*/
|
||||
|
||||
import type { DiagnosticItem } from '../../types/index.js';
|
||||
import { stripAnsi } from '../ansi.js';
|
||||
|
||||
/**
|
||||
* Parse raw stdout/stderr from `typos` into structured DiagnosticItems.
|
||||
*
|
||||
* @param rawOutput Raw terminal output from typos CLI
|
||||
* @returns Array of DiagnosticItem objects with accurate spelling repair tokens
|
||||
*/
|
||||
export function parseTyposOutput(rawOutput: string): DiagnosticItem[] {
|
||||
const clean = stripAnsi(rawOutput);
|
||||
const diagnostics: DiagnosticItem[] = [];
|
||||
|
||||
const lines = clean.split('\n');
|
||||
let pendingTypo: { typo: string; correction: string } | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Pattern 1: error: `foo` should be `bar`
|
||||
const typoMatch = line.match(/error:\s*`([^`]+)`\s*should be\s*`([^`]+)`/i);
|
||||
if (typoMatch) {
|
||||
pendingTypo = { typo: typoMatch[1], correction: typoMatch[2] };
|
||||
continue;
|
||||
}
|
||||
// Pattern 1: error: `typo` should be `correction`
|
||||
// --> filePath:line:col
|
||||
const match1 = line.match(/error:\s*`([^`]+)`\s+should be\s+`([^`]+)`/);
|
||||
if (match1) {
|
||||
const [, typo, correction] = match1;
|
||||
let filePath = 'unknown';
|
||||
let lineNum = 1;
|
||||
let colNum = 1;
|
||||
|
||||
// Pattern 2: --> path/to/file:line:col
|
||||
const locMatch = line.match(/-->\s*([^\s:]+):(\d+):(\d+)/);
|
||||
if (locMatch && pendingTypo) {
|
||||
const [, filePath, lineStr, colStr] = locMatch;
|
||||
const lineNum = parseInt(lineStr, 10);
|
||||
const colNum = parseInt(colStr, 10);
|
||||
|
||||
diagnostics.push({
|
||||
source: 'typos',
|
||||
rule_id: 'TYPO_DETECTED',
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: lineNum, column: colNum },
|
||||
end: { line: lineNum, column: colNum + pendingTypo.typo.length },
|
||||
},
|
||||
code_snippet: pendingTypo.typo,
|
||||
error_message: `Typo detected: \`${pendingTypo.typo}\` should be \`${pendingTypo.correction}\`.`,
|
||||
repair_instruction: {
|
||||
action: 'REPLACE_TOKEN',
|
||||
description: `Replace typo \`${pendingTypo.typo}\` with correct spelling \`${pendingTypo.correction}\`.`,
|
||||
repair_tokens: [pendingTypo.correction],
|
||||
},
|
||||
});
|
||||
pendingTypo = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-line pattern: path/to/file:line:col: `foo` should be `bar`
|
||||
const singleMatch = line.match(/^([^\s:]+):(\d+):(\d+):\s*(?:error:\s*)?`([^`]+)`\s*should be\s*`([^`]+)`/i);
|
||||
if (singleMatch) {
|
||||
const [, filePath, lineStr, colStr, typo, correction] = singleMatch;
|
||||
const lineNum = parseInt(lineStr, 10);
|
||||
const colNum = parseInt(colStr, 10);
|
||||
// Look at next lines for file location indicator: --> filePath:line:col
|
||||
if (i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1].trim();
|
||||
const locMatch = nextLine.match(/-->\s*([^\s:]+):(\d+):(\d+)/);
|
||||
if (locMatch) {
|
||||
filePath = locMatch[1];
|
||||
lineNum = parseInt(locMatch[2], 10);
|
||||
colNum = parseInt(locMatch[3], 10);
|
||||
i++; // Skip location line
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
source: 'typos',
|
||||
@@ -71,6 +59,30 @@ export function parseTyposOutput(rawOutput: string): DiagnosticItem[] {
|
||||
repair_tokens: [correction],
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern 2: filePath:line:col: `typo` -> `correction`
|
||||
const match2 = line.match(/^([^\s:]+):(\d+):(\d+):\s*`([^`]+)`\s*->\s*`([^`]+)`/);
|
||||
if (match2) {
|
||||
const [, filePath, lineStr, colStr, typo, correction] = match2;
|
||||
diagnostics.push({
|
||||
source: 'typos',
|
||||
rule_id: 'TYPO_DETECTED',
|
||||
severity: 'ERROR',
|
||||
file_path: filePath,
|
||||
range: {
|
||||
start: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) },
|
||||
end: { line: parseInt(lineStr, 10), column: parseInt(colStr, 10) + typo.length },
|
||||
},
|
||||
code_snippet: typo,
|
||||
error_message: `Typo: \`${typo}\` -> \`${correction}\``,
|
||||
repair_instruction: {
|
||||
action: 'REPLACE_TOKEN',
|
||||
description: `Fix typo: replace with \`${correction}\``,
|
||||
repair_tokens: [correction],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* @file src/generator/configGenerator.ts
|
||||
* @description Configuration and multi-tier pipeline generator engine.
|
||||
*
|
||||
* Emits deterministic configuration files matching detected repository stacks:
|
||||
* - lefthook.yml: Multi-threaded parallel git hook runner
|
||||
* - .claude/hooks.json: Stage 1 agent lifecycle tool interceptor
|
||||
* - .claude/settings.json: Locked agent governance settings
|
||||
* - biome.json: Biome sub-millisecond JS/TS linter/formatter config
|
||||
* - ruff.toml: Ruff sub-millisecond Python linter/formatter config
|
||||
* - .aislop/config.yml: AISlop deterministic code smell rules
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { GeneratedConfig, GenerationResult, StackDetectionResult } from '../types/index.js';
|
||||
@@ -7,27 +20,45 @@ import { generateBiomeConfig } from './templates/biome.js';
|
||||
import { generateRuffConfig } from './templates/ruff.js';
|
||||
import { generateAislopConfig } from './templates/aislop.js';
|
||||
|
||||
/**
|
||||
* Options for configuring the ConfigGenerator instance
|
||||
*/
|
||||
export interface ConfigGeneratorOptions {
|
||||
/** Target root directory for configuration generation (defaults to process.cwd()) */
|
||||
cwd?: string;
|
||||
/** If true, existing configuration files will be overwritten */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-Tier Pipeline Configuration Generator.
|
||||
* Translates StackDetectionResult metadata into concrete, deterministic config files.
|
||||
*/
|
||||
export class ConfigGenerator {
|
||||
/** Resolved absolute path of the target repository root */
|
||||
private readonly rootPath: string;
|
||||
/** Whether to overwrite existing files on disk */
|
||||
private readonly overwrite: boolean;
|
||||
|
||||
/**
|
||||
* Initialize a new ConfigGenerator instance
|
||||
* @param options Generator configuration options
|
||||
*/
|
||||
constructor(options: ConfigGeneratorOptions = {}) {
|
||||
this.rootPath = path.resolve(options.cwd || process.cwd());
|
||||
this.overwrite = options.overwrite ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute configuration files to be generated for the detected stack
|
||||
* Compute configuration files to be generated for the detected stacks without writing to disk.
|
||||
*
|
||||
* @param detection Stack detection result containing language and workflow indicators
|
||||
* @returns Array of GeneratedConfig objects with paths, contents, and descriptions
|
||||
*/
|
||||
public generate(detection: StackDetectionResult): GeneratedConfig[] {
|
||||
const configs: GeneratedConfig[] = [];
|
||||
|
||||
// 1. Lefthook Orchestration Config (Stage 2 & Stage 3)
|
||||
// 1. Lefthook Git Hook Runner Config (Stage 2 Pre-Commit & Stage 3 Pre-Push)
|
||||
configs.push({
|
||||
path: 'lefthook.yml',
|
||||
content: generateLefthookConfig(detection),
|
||||
@@ -80,7 +111,10 @@ export class ConfigGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write generated configurations to disk
|
||||
* Write generated configurations to disk, creating parent directories as needed.
|
||||
*
|
||||
* @param detection Stack detection result
|
||||
* @returns GenerationResult containing written and skipped file lists
|
||||
*/
|
||||
public writeToDisk(detection: StackDetectionResult): GenerationResult {
|
||||
const configs = this.generate(detection);
|
||||
@@ -91,15 +125,18 @@ export class ConfigGenerator {
|
||||
const fullPath = path.resolve(this.rootPath, config.path);
|
||||
const dirName = path.dirname(fullPath);
|
||||
|
||||
// Skip file if it already exists and overwrite flag is not set
|
||||
if (fs.existsSync(fullPath) && !this.overwrite) {
|
||||
skippedFiles.push(config.path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if (!fs.existsSync(dirName)) {
|
||||
fs.mkdirSync(dirName, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file contents to disk
|
||||
fs.writeFileSync(fullPath, config.content, 'utf-8');
|
||||
writtenFiles.push(config.path);
|
||||
}
|
||||
@@ -112,6 +149,11 @@ export class ConfigGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional convenience wrapper to generate and write configurations
|
||||
* @param detection Stack detection result
|
||||
* @param options Generator options
|
||||
*/
|
||||
export function generateConfigs(detection: StackDetectionResult, options?: ConfigGeneratorOptions): GenerationResult {
|
||||
return new ConfigGenerator(options).writeToDisk(detection);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file src/generator/index.ts
|
||||
* @description Exports configuration generators and templates.
|
||||
*/
|
||||
|
||||
export * from './configGenerator.js';
|
||||
export * from './templates/lefthook.js';
|
||||
export * from './templates/claudeHooks.js';
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* @file src/generator/templates/aislop.ts
|
||||
* @description Generates the .aislop/config.yml configuration for deterministic AI slop detection.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate an AISlop configuration defining deterministic rules against
|
||||
* swallowed errors, empty catch blocks, hallucinated imports, and unsafe type casts.
|
||||
*
|
||||
* @returns Formatted YAML string for .aislop/config.yml
|
||||
*/
|
||||
export function generateAislopConfig(): string {
|
||||
return `# AISlop Configuration for Deterministic AI Code Smell & Slop Detection
|
||||
version: "1.0"
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* @file src/generator/templates/biome.ts
|
||||
* @description Generates the biome.json configuration for sub-millisecond JS/TS linting and formatting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a standard, high-performance Biome configuration with strict rules against
|
||||
* common AI-generated anti-patterns (noExplicitAny, noDebugger, noVar).
|
||||
*
|
||||
* @returns Formatted JSON string for biome.json
|
||||
*/
|
||||
export function generateBiomeConfig(): string {
|
||||
const config = {
|
||||
$schema: 'https://biomejs.dev/schemas/1.9.4/schema.json',
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
/**
|
||||
* @file src/generator/templates/claudeHooks.ts
|
||||
* @description Generates the .claude/hooks.json configuration for Stage 1 agent tool interception.
|
||||
*
|
||||
* Automatically intercepts agent file edits (PostFileEdit) to format and lint modified ASTs
|
||||
* in < 300ms, and routes pre-commit git commands through the mechanical Lefthook runner.
|
||||
*/
|
||||
|
||||
import type { StackDetectionResult } from '../../types/index.js';
|
||||
|
||||
/**
|
||||
* Individual hook rule configuration for Claude Code lifecycle events
|
||||
*/
|
||||
export interface ClaudeHookRule {
|
||||
/** Glob matcher pattern (e.g. '*.{js,ts}', '*.py', '.claude/skills/*.md') */
|
||||
matcher?: string;
|
||||
/** Shell command executed when the matcher matches the modified file */
|
||||
command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level structure of .claude/hooks.json
|
||||
*/
|
||||
export interface ClaudeHooksStructure {
|
||||
hooks: {
|
||||
/** Intercepts file modification tools (FileEdit, FileWrite) */
|
||||
PostFileEdit?: ClaudeHookRule[];
|
||||
/** Intercepts git commit operations */
|
||||
PreCommit?: ClaudeHookRule[];
|
||||
[key: string]: ClaudeHookRule[] | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .claude/hooks.json for Stage 1 agent tool interception.
|
||||
*
|
||||
* @param detection Stack detection result containing language and harness indicators
|
||||
* @returns Formatted JSON string for .claude/hooks.json
|
||||
*/
|
||||
export function generateClaudeHooksConfig(detection: StackDetectionResult): string {
|
||||
const postFileEditRules: ClaudeHookRule[] = [];
|
||||
|
||||
// JS/TS: Instant single-file format & lint on edit
|
||||
if (detection.jsTs.detected) {
|
||||
postFileEditRules.push({
|
||||
matcher: '*.{js,ts,jsx,tsx}',
|
||||
@@ -23,6 +48,7 @@ export function generateClaudeHooksConfig(detection: StackDetectionResult): stri
|
||||
});
|
||||
}
|
||||
|
||||
// Python: Instant single-file format & fix on edit
|
||||
if (detection.python.detected) {
|
||||
postFileEditRules.push({
|
||||
matcher: '*.py',
|
||||
@@ -30,7 +56,7 @@ export function generateClaudeHooksConfig(detection: StackDetectionResult): stri
|
||||
});
|
||||
}
|
||||
|
||||
// Always enable skill checking for agent instructions if agent harness is detected or by default
|
||||
// Skill files: Validate agent skills against schema and OWASP agentic boundaries
|
||||
postFileEditRules.push({
|
||||
matcher: '.claude/skills/*.md',
|
||||
command: 'skillcheck check ${filePath}',
|
||||
@@ -41,6 +67,7 @@ export function generateClaudeHooksConfig(detection: StackDetectionResult): stri
|
||||
command: 'skillcheck check ${filePath}',
|
||||
});
|
||||
|
||||
// Assemble the complete Claude hooks object
|
||||
const hooksConfig: ClaudeHooksStructure = {
|
||||
hooks: {
|
||||
PostFileEdit: postFileEditRules,
|
||||
@@ -55,6 +82,11 @@ export function generateClaudeHooksConfig(detection: StackDetectionResult): stri
|
||||
return JSON.stringify(hooksConfig, null, 2) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .claude/settings.json enforcing locked mechanical gate governance.
|
||||
*
|
||||
* @returns Formatted JSON string for .claude/settings.json
|
||||
*/
|
||||
export function generateClaudeSettingsConfig(): string {
|
||||
const settings = {
|
||||
governance: {
|
||||
|
||||
@@ -1,73 +1,104 @@
|
||||
/**
|
||||
* @file src/generator/templates/lefthook.ts
|
||||
* @description Generates the lefthook.yml multi-threaded parallel git hook runner configuration.
|
||||
*
|
||||
* Implements:
|
||||
* - Stage 2: Synchronous Local Pre-Commit Hard Gates (< 2.0s execution target)
|
||||
* Runs parallel native binaries across staged files (Biome, Ruff, AISlop, TruffleHog, Typos, Actionlint)
|
||||
* - Stage 3: CI & Codebase Graph Governance
|
||||
* Runs deep analysis (Fallow, Sherif, OWASP Noir, Cargo Deny) during pre-push or CI.
|
||||
*/
|
||||
|
||||
import type { StackDetectionResult } from '../../types/index.js';
|
||||
|
||||
/**
|
||||
* Generate a complete lefthook.yml configuration tailored to the detected repository stacks.
|
||||
*
|
||||
* @param detection Stack detection result containing language and workflow indicators
|
||||
* @returns Formatted YAML string for lefthook.yml
|
||||
*/
|
||||
export function generateLefthookConfig(detection: StackDetectionResult): string {
|
||||
const preCommitCommands: string[] = [];
|
||||
const prePushCommands: string[] = [];
|
||||
|
||||
// Stage 2: Pre-Commit Hard Gates (< 2.0s execution target)
|
||||
// =========================================================================
|
||||
// Stage 2: Pre-Commit Hard Gates (< 2.0s execution target on staged files)
|
||||
// =========================================================================
|
||||
|
||||
// JS/TS: Fast native linting & formatting via Biome on staged files
|
||||
if (detection.jsTs.detected) {
|
||||
preCommitCommands.push(` biome-check:
|
||||
glob: "*.{js,ts,jsx,tsx,json,jsonc}"
|
||||
run: npx @biomejs/biome check --staged --no-errors-on-unmatched`);
|
||||
}
|
||||
|
||||
// Python: Fast native linting & auto-fixing via Ruff on staged files
|
||||
if (detection.python.detected) {
|
||||
preCommitCommands.push(` ruff-check:
|
||||
glob: "*.py"
|
||||
run: ruff check --staged --fix`);
|
||||
}
|
||||
|
||||
// Go: Fast static security analysis via gosec
|
||||
if (detection.go.detected) {
|
||||
preCommitCommands.push(` gosec-check:
|
||||
glob: "*.go"
|
||||
run: gosec -quiet ./...`);
|
||||
}
|
||||
|
||||
// Rust: Cargo dependency and license policy checking
|
||||
if (detection.rust.detected) {
|
||||
preCommitCommands.push(` cargo-deny:
|
||||
glob: "Cargo.{toml,lock}"
|
||||
run: cargo deny check`);
|
||||
}
|
||||
|
||||
// Universal deterministic AI slop detection
|
||||
// Universal: Fast deterministic AI slop and swallowed error detection
|
||||
preCommitCommands.push(` aislop-scan:
|
||||
run: npx aislop scan --staged --fail-on 50`);
|
||||
|
||||
// Universal secret scanning
|
||||
// Universal: High-entropy secret scanning via TruffleHog OSS (verified secrets only)
|
||||
preCommitCommands.push(` secret-scan:
|
||||
run: trufflehog git file://. --staged --only-verified`);
|
||||
|
||||
// Universal typo & spelling check
|
||||
// Universal: AST-aware source code spell checking via typos
|
||||
preCommitCommands.push(` typo-check:
|
||||
run: typos --staged`);
|
||||
|
||||
// Workflow validation
|
||||
// Infra: GitHub Actions workflow syntax validation via actionlint
|
||||
if (detection.infra.hasWorkflows) {
|
||||
preCommitCommands.push(` actionlint:
|
||||
glob: ".github/workflows/*.{yml,yaml}"
|
||||
run: actionlint`);
|
||||
}
|
||||
|
||||
// Stage 3: Pre-Push / CI Graph Audit
|
||||
// =========================================================================
|
||||
// Stage 3: Pre-Push / CI Full Codebase Graph Governance
|
||||
// =========================================================================
|
||||
|
||||
// JS/TS: Full graph dead code, unused exports, and circular import audit
|
||||
if (detection.jsTs.detected) {
|
||||
prePushCommands.push(` fallow-audit:
|
||||
run: npx fallow audit`);
|
||||
}
|
||||
|
||||
// Monorepo: Cross-package dependency version alignment via Sherif
|
||||
if (detection.jsTs.isMonorepo) {
|
||||
prePushCommands.push(` monorepo-sherif:
|
||||
run: npx setup-sherif`);
|
||||
}
|
||||
|
||||
// Universal API attack surface mapping
|
||||
// Universal: API attack surface mapping and shadow endpoint extraction via OWASP Noir
|
||||
prePushCommands.push(` noir-scan:
|
||||
run: noir scan . --ai-context -f sarif`);
|
||||
|
||||
// Rust: Comprehensive dependency policy verification
|
||||
if (detection.rust.detected && !prePushCommands.some(c => c.includes('cargo-deny'))) {
|
||||
prePushCommands.push(` cargo-deny:
|
||||
run: cargo deny check`);
|
||||
}
|
||||
|
||||
// Assemble the final YAML document with descriptive comments
|
||||
const parts = [
|
||||
'# Generated by @heretek-ai/agent-proof - Mechanical Hard Gate Orchestration',
|
||||
'# Stage 2: Synchronous Local Pre-Commit Hard Gate (< 2.0s budget)',
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* @file src/generator/templates/ruff.ts
|
||||
* @description Generates the ruff.toml configuration for sub-millisecond Python linting and formatting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a strict Ruff configuration targeting Python 3.10+ with rules
|
||||
* for pyflakes (F), pycodestyle (E, W), flake8-bugbear (B), and simplify (SIM).
|
||||
*
|
||||
* @returns Formatted TOML string for ruff.toml
|
||||
*/
|
||||
export function generateRuffConfig(): string {
|
||||
return `# Ruff Configuration for Agent-Proof Mechanical Hard-Gate
|
||||
line-length = 100
|
||||
|
||||
+12
-3
@@ -1,6 +1,15 @@
|
||||
/**
|
||||
* @heretek-ai/agent-proof - Mechanical Hard-Gate CLI
|
||||
* Zero-dependency architecture for deterministic AI code governance
|
||||
* @file src/index.ts
|
||||
* @description Public API Entrypoint for the @heretek-ai/agent-proof library.
|
||||
*
|
||||
* Exposes core modules:
|
||||
* - Stack detection (StackDetector, detectStack)
|
||||
* - Config generation (ConfigGenerator, generateConfigs, individual templates)
|
||||
* - Diagnostic streaming & parsers (DiagnosticStreamer, formatDiagnostics, individual parsers)
|
||||
* - Git hook installation (HookInstaller, installHooks)
|
||||
* - Permission locking (GateLock, lockGovernance, unlockGovernance)
|
||||
* - Stage execution runner (GateRunner)
|
||||
* - TypeScript type definitions
|
||||
*/
|
||||
|
||||
export * from './types/index.js';
|
||||
@@ -9,4 +18,4 @@ export * from './generator/index.js';
|
||||
export * from './formatter/index.js';
|
||||
export * from './installer/index.js';
|
||||
export * from './runner/index.js';
|
||||
export * from './cli.js';
|
||||
export { main } from './cli.js';
|
||||
@@ -1,112 +1,132 @@
|
||||
/**
|
||||
* @file src/installer/hookInstaller.ts
|
||||
* @description Git hook installer and Lefthook orchestrator.
|
||||
*
|
||||
* Installs mechanical git hooks in .git/hooks/ (pre-commit, pre-push) that invoke
|
||||
* Lefthook and route execution through the agent diagnostic streamer.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import type { HookInstallResult } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Options for configuring HookInstaller
|
||||
*/
|
||||
export interface HookInstallerOptions {
|
||||
/** Target repository root directory (defaults to process.cwd()) */
|
||||
cwd?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mechanical Git Hook Installer.
|
||||
* Ensures .git/hooks/pre-commit and .git/hooks/pre-push are present and executable.
|
||||
*/
|
||||
export class HookInstaller {
|
||||
/** Resolved absolute path of the target repository */
|
||||
private readonly rootPath: string;
|
||||
|
||||
constructor(options: HookInstallerOptions = {}) {
|
||||
this.rootPath = path.resolve(options.cwd || process.cwd());
|
||||
/**
|
||||
* Initialize a new HookInstaller instance
|
||||
* @param options Target path string or HookInstallerOptions object
|
||||
*/
|
||||
constructor(options: string | HookInstallerOptions = {}) {
|
||||
if (typeof options === 'string') {
|
||||
this.rootPath = path.resolve(options);
|
||||
} else {
|
||||
this.rootPath = path.resolve(options.cwd || process.cwd());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install and lock git hooks for pre-commit and pre-push stages
|
||||
* Install mechanical git hooks into the repository.
|
||||
* First attempts native `npx lefthook install`, and falls back to writing
|
||||
* robust POSIX shell hook wrappers directly to `.git/hooks/`.
|
||||
*
|
||||
* @returns HookInstallResult with details of installed hooks
|
||||
*/
|
||||
public install(): HookInstallResult {
|
||||
const gitDir = path.resolve(this.rootPath, '.git');
|
||||
if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) {
|
||||
const gitDir = path.join(this.rootPath, '.git');
|
||||
const gitHooksDir = path.join(gitDir, 'hooks');
|
||||
let lefthookInstalled = false;
|
||||
const installedHooks: string[] = [];
|
||||
|
||||
// Check if .git directory exists
|
||||
if (!fs.existsSync(gitDir)) {
|
||||
return {
|
||||
lefthookInstalled: false,
|
||||
gitHooksPath: '',
|
||||
gitHooksPath: gitHooksDir,
|
||||
installedHooks: [],
|
||||
message: 'No .git repository found. Skipped git hook installation.',
|
||||
message: 'No .git repository found. Git hooks skipped (not a git repository).',
|
||||
};
|
||||
}
|
||||
|
||||
const hooksDir = path.resolve(gitDir, 'hooks');
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
// Ensure .git/hooks directory exists
|
||||
if (!fs.existsSync(gitHooksDir)) {
|
||||
fs.mkdirSync(gitHooksDir, { recursive: true });
|
||||
}
|
||||
|
||||
let lefthookSuccess = false;
|
||||
// 1. Attempt to run `npx lefthook install` natively
|
||||
try {
|
||||
execSync('npx lefthook install', {
|
||||
execFileSync('npx', ['lefthook', 'install', '-f'], {
|
||||
cwd: this.rootPath,
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
lefthookSuccess = true;
|
||||
lefthookInstalled = true;
|
||||
} catch {
|
||||
// If npx lefthook install is not yet available, fallback to manual hook script generation
|
||||
// Fall back to direct hook file creation if lefthook CLI is not present
|
||||
lefthookInstalled = false;
|
||||
}
|
||||
|
||||
const installedHooks: string[] = [];
|
||||
// 2. Ensure pre-commit hook script exists and is executable
|
||||
const preCommitPath = path.join(gitHooksDir, 'pre-commit');
|
||||
const preCommitScript = `#!/bin/sh
|
||||
# Generated by @heretek-ai/agent-proof - Mechanical Pre-Commit Hard Gate
|
||||
set -e
|
||||
|
||||
// Ensure pre-commit hook exists and is executable
|
||||
const preCommitPath = path.resolve(hooksDir, 'pre-commit');
|
||||
const preCommitContent = `#!/bin/sh
|
||||
# Agent-Proof Mechanical Hard-Gate Pre-Commit Hook
|
||||
if command -v lefthook >/dev/null 2>&1; then
|
||||
lefthook run pre-commit "$@"
|
||||
elif [ -f "./node_modules/.bin/lefthook" ]; then
|
||||
./node_modules/.bin/lefthook run pre-commit "$@"
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
npx lefthook run pre-commit
|
||||
elif command -v lefthook >/dev/null 2>&1; then
|
||||
lefthook run pre-commit
|
||||
else
|
||||
npx lefthook run pre-commit "$@"
|
||||
echo "[agent-proof] Warning: neither npx nor lefthook found. Skipping pre-commit gate."
|
||||
fi
|
||||
`;
|
||||
fs.writeFileSync(preCommitPath, preCommitScript, { mode: 0o755 });
|
||||
installedHooks.push('pre-commit');
|
||||
|
||||
if (!fs.existsSync(preCommitPath) || lefthookSuccess) {
|
||||
if (!fs.existsSync(preCommitPath)) {
|
||||
fs.writeFileSync(preCommitPath, preCommitContent, { mode: 0o755 });
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(preCommitPath, 0o755);
|
||||
} catch {
|
||||
// ignore chmod errors on windows
|
||||
}
|
||||
installedHooks.push('pre-commit');
|
||||
}
|
||||
// 3. Ensure pre-push hook script exists and is executable
|
||||
const prePushPath = path.join(gitHooksDir, 'pre-push');
|
||||
const prePushScript = `#!/bin/sh
|
||||
# Generated by @heretek-ai/agent-proof - Mechanical Pre-Push Hard Gate
|
||||
set -e
|
||||
|
||||
// Ensure pre-push hook exists and is executable
|
||||
const prePushPath = path.resolve(hooksDir, 'pre-push');
|
||||
const prePushContent = `#!/bin/sh
|
||||
# Agent-Proof Mechanical Hard-Gate Pre-Push Hook
|
||||
if command -v lefthook >/dev/null 2>&1; then
|
||||
lefthook run pre-push "$@"
|
||||
elif [ -f "./node_modules/.bin/lefthook" ]; then
|
||||
./node_modules/.bin/lefthook run pre-push "$@"
|
||||
else
|
||||
npx lefthook run pre-push "$@"
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
npx lefthook run pre-push
|
||||
elif command -v lefthook >/dev/null 2>&1; then
|
||||
lefthook run pre-push
|
||||
fi
|
||||
`;
|
||||
|
||||
if (!fs.existsSync(prePushPath)) {
|
||||
fs.writeFileSync(prePushPath, prePushContent, { mode: 0o755 });
|
||||
try {
|
||||
fs.chmodSync(prePushPath, 0o755);
|
||||
} catch {
|
||||
// ignore chmod errors on windows
|
||||
}
|
||||
installedHooks.push('pre-push');
|
||||
}
|
||||
fs.writeFileSync(prePushPath, prePushScript, { mode: 0o755 });
|
||||
installedHooks.push('pre-push');
|
||||
|
||||
return {
|
||||
lefthookInstalled: lefthookSuccess,
|
||||
gitHooksPath: hooksDir,
|
||||
lefthookInstalled,
|
||||
gitHooksPath: gitHooksDir,
|
||||
installedHooks,
|
||||
message: lefthookSuccess
|
||||
message: lefthookInstalled
|
||||
? 'Lefthook hooks successfully installed and verified.'
|
||||
: 'Git hooks generated with lefthook fallback wrapper.',
|
||||
: 'Installed direct POSIX git hook wrappers for Lefthook in .git/hooks/.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function installHooks(options?: HookInstallerOptions): HookInstallResult {
|
||||
return new HookInstaller(options).install();
|
||||
/**
|
||||
* Functional convenience wrapper to install git hooks
|
||||
* @param cwd Target repository directory or options
|
||||
*/
|
||||
export function installHooks(cwd?: string | HookInstallerOptions): HookInstallResult {
|
||||
return new HookInstaller(cwd).install();
|
||||
}
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './lockin.js';
|
||||
/**
|
||||
* @file src/installer/index.ts
|
||||
* @description Exports git hook installer and permission locking modules.
|
||||
*/
|
||||
|
||||
export * from './hookInstaller.js';
|
||||
export * from './lockin.js';
|
||||
+111
-52
@@ -1,7 +1,20 @@
|
||||
/**
|
||||
* @file src/installer/lockin.ts
|
||||
* @description POSIX File Permission Locker for mechanical governance boundaries.
|
||||
*
|
||||
* Enforces immutable read-only permissions (`0o444` / `r--r--r--`) on governance
|
||||
* configuration files (`.claude/settings.json`, `.claude/hooks.json`, `lefthook.yml`, etc.)
|
||||
* to mechanically prevent autonomous AI coding agents from tampering with, disabling, or
|
||||
* weakening hard gates.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { LockResult } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* List of critical governance files locked by default to prevent agent tampering
|
||||
*/
|
||||
export const GOVERNANCE_FILES = [
|
||||
'.claude/settings.json',
|
||||
'.claude/hooks.json',
|
||||
@@ -11,30 +24,58 @@ export const GOVERNANCE_FILES = [
|
||||
'.aislop/config.yml',
|
||||
];
|
||||
|
||||
export class GateLock {
|
||||
private readonly rootPath: string;
|
||||
/**
|
||||
* Options for configuring GateLock operations
|
||||
*/
|
||||
export interface GateLockOptions {
|
||||
/** Target repository root directory (defaults to process.cwd()) */
|
||||
cwd?: string;
|
||||
/** Custom list of file paths to lock or unlock */
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
constructor(rootPath: string = process.cwd()) {
|
||||
this.rootPath = path.resolve(rootPath);
|
||||
/**
|
||||
* POSIX File Permission Manager for Mechanical Gate Governance.
|
||||
*/
|
||||
export class GateLock {
|
||||
/** Resolved absolute path of the target repository */
|
||||
private readonly rootPath: string;
|
||||
/** Files targeted for permission locking */
|
||||
private readonly targetFiles: string[];
|
||||
|
||||
/**
|
||||
* Initialize a new GateLock instance
|
||||
* @param options Directory string or GateLockOptions object
|
||||
*/
|
||||
constructor(options: string | GateLockOptions = {}) {
|
||||
if (typeof options === 'string') {
|
||||
this.rootPath = path.resolve(options);
|
||||
this.targetFiles = GOVERNANCE_FILES;
|
||||
} else {
|
||||
this.rootPath = path.resolve(options.cwd || process.cwd());
|
||||
this.targetFiles = options.files || GOVERNANCE_FILES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock governance files with read-only permissions to prevent AI agents from tampering with rules
|
||||
* Lock all governance files by applying read-only permissions (0o444).
|
||||
*
|
||||
* @returns LockResult detailing locked and failed files
|
||||
*/
|
||||
public lock(files: string[] = GOVERNANCE_FILES): LockResult {
|
||||
public lock(): LockResult {
|
||||
const lockedFiles: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
|
||||
for (const relFile of files) {
|
||||
const fullPath = path.resolve(this.rootPath, relFile);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
|
||||
try {
|
||||
// Mode 0o444 is read-only (r--r--r--)
|
||||
fs.chmodSync(fullPath, 0o444);
|
||||
lockedFiles.push(relFile);
|
||||
} catch {
|
||||
failedFiles.push(relFile);
|
||||
for (const relPath of this.targetFiles) {
|
||||
const fullPath = path.resolve(this.rootPath, relPath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
// 0o444 = read-only for owner, group, and others
|
||||
fs.chmodSync(fullPath, 0o444);
|
||||
lockedFiles.push(relPath);
|
||||
} catch {
|
||||
failedFiles.push(relPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,22 +87,25 @@ export class GateLock {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock governance files with writeable permissions (for human administrator modifications)
|
||||
* Unlock governance files by restoring standard read/write permissions (0o644).
|
||||
* Allows human developers or administrative workflows to modify configs.
|
||||
*
|
||||
* @returns LockResult detailing unlocked and failed files
|
||||
*/
|
||||
public unlock(files: string[] = GOVERNANCE_FILES): LockResult {
|
||||
public unlock(): LockResult {
|
||||
const lockedFiles: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
|
||||
for (const relFile of files) {
|
||||
const fullPath = path.resolve(this.rootPath, relFile);
|
||||
if (!fs.existsSync(fullPath)) continue;
|
||||
|
||||
try {
|
||||
// Mode 0o644 is read/write for owner (rw-r--r--)
|
||||
fs.chmodSync(fullPath, 0o644);
|
||||
lockedFiles.push(relFile);
|
||||
} catch {
|
||||
failedFiles.push(relFile);
|
||||
for (const relPath of this.targetFiles) {
|
||||
const fullPath = path.resolve(this.rootPath, relPath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
// 0o644 = read/write for owner, read-only for group/others
|
||||
fs.chmodSync(fullPath, 0o644);
|
||||
lockedFiles.push(relPath);
|
||||
} catch {
|
||||
failedFiles.push(relPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,44 +117,59 @@ export class GateLock {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether governance files exist and are currently locked (read-only)
|
||||
* Check the current permission status of all governance files as an array.
|
||||
*/
|
||||
public getStatus(files: string[] = GOVERNANCE_FILES): Record<string, { exists: boolean; isLocked: boolean; mode?: number }> {
|
||||
const status: Record<string, { exists: boolean; isLocked: boolean; mode?: number }> = {};
|
||||
|
||||
for (const relFile of files) {
|
||||
const fullPath = path.resolve(this.rootPath, relFile);
|
||||
public checkStatus(): Array<{ path: string; exists: boolean; isLocked: boolean; mode?: string }> {
|
||||
return this.targetFiles.map(relPath => {
|
||||
const fullPath = path.resolve(this.rootPath, relPath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
status[relFile] = { exists: false, isLocked: false };
|
||||
continue;
|
||||
return { path: relPath, exists: false, isLocked: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(fullPath);
|
||||
// In POSIX, write permission bit for user is 0o200
|
||||
const isWritable = (stats.mode & 0o200) !== 0;
|
||||
status[relFile] = {
|
||||
exists: true,
|
||||
isLocked: !isWritable,
|
||||
mode: stats.mode,
|
||||
};
|
||||
// If owner write bit (0o200) is NOT set, file is read-only / locked
|
||||
const isLocked = (stats.mode & 0o200) === 0;
|
||||
const modeStr = (stats.mode & 0o777).toString(8);
|
||||
return { path: relPath, exists: true, isLocked, mode: modeStr };
|
||||
} catch {
|
||||
status[relFile] = { exists: true, isLocked: false };
|
||||
return { path: relPath, exists: true, isLocked: false };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return status;
|
||||
/**
|
||||
* Return a dictionary of governance file statuses keyed by relative file path.
|
||||
*/
|
||||
public getStatus(): Record<string, { exists: boolean; isLocked: boolean; mode?: string }> {
|
||||
const result: Record<string, { exists: boolean; isLocked: boolean; mode?: string }> = {};
|
||||
for (const item of this.checkStatus()) {
|
||||
result[item.path] = item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function lockGovernance(rootPath?: string, files?: string[]): LockResult {
|
||||
return new GateLock(rootPath).lock(files);
|
||||
/**
|
||||
* Functional convenience wrapper to lock governance files
|
||||
* @param cwd Target repository directory or options
|
||||
*/
|
||||
export function lockGovernance(cwd?: string | GateLockOptions): LockResult {
|
||||
return new GateLock(cwd).lock();
|
||||
}
|
||||
|
||||
export function unlockGovernance(rootPath?: string, files?: string[]): LockResult {
|
||||
return new GateLock(rootPath).unlock(files);
|
||||
/**
|
||||
* Functional convenience wrapper to unlock governance files
|
||||
* @param cwd Target repository directory or options
|
||||
*/
|
||||
export function unlockGovernance(cwd?: string | GateLockOptions): LockResult {
|
||||
return new GateLock(cwd).unlock();
|
||||
}
|
||||
|
||||
export function getGovernanceStatus(rootPath?: string, files?: string[]) {
|
||||
return new GateLock(rootPath).getStatus(files);
|
||||
/**
|
||||
* Functional convenience wrapper to get governance status dictionary
|
||||
* @param cwd Target repository directory or options
|
||||
*/
|
||||
export function getGovernanceStatus(cwd?: string | GateLockOptions): Record<string, { exists: boolean; isLocked: boolean; mode?: string }> {
|
||||
return new GateLock(cwd).getStatus();
|
||||
}
|
||||
+90
-108
@@ -1,154 +1,136 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
/**
|
||||
* @file src/runner/gateRunner.ts
|
||||
* @description Execution engine for mechanical gate stages (post-edit, pre-commit, pre-push).
|
||||
*
|
||||
* Runs stage-specific linters, formatters, and scanners, parses failure streams,
|
||||
* and emits structured LSP Diagnostic Envelopes with exit codes.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { DiagnosticEnvelope, GateStage } from '../types/index.js';
|
||||
import { DiagnosticStreamer } from '../formatter/diagnosticStream.js';
|
||||
import { detectStack } from '../detector/stackDetector.js';
|
||||
import { DiagnosticStreamer, type ToolExecutionResult } from '../formatter/diagnosticStream.js';
|
||||
|
||||
export interface RunOptions {
|
||||
stage: GateStage | 'pre-commit' | 'pre-push' | 'post-edit';
|
||||
filePath?: string;
|
||||
/**
|
||||
* Options for configuring GateRunner execution
|
||||
*/
|
||||
export interface GateRunnerOptions {
|
||||
/** Target working directory (defaults to process.cwd()) */
|
||||
cwd?: string;
|
||||
jsonOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
passed: boolean;
|
||||
exitCode: number;
|
||||
envelope: DiagnosticEnvelope;
|
||||
rawOutput: string;
|
||||
/** Custom environment variables */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage Execution Engine for Mechanical Hard Gates.
|
||||
*/
|
||||
export class GateRunner {
|
||||
/** Resolved absolute path of the target repository */
|
||||
private readonly rootPath: string;
|
||||
/** Environment variables passed to child processes */
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
|
||||
constructor(cwd: string = process.cwd()) {
|
||||
this.rootPath = path.resolve(cwd);
|
||||
/**
|
||||
* Initialize a new GateRunner instance
|
||||
* @param options Runner configuration options
|
||||
*/
|
||||
constructor(options: GateRunnerOptions = {}) {
|
||||
this.rootPath = path.resolve(options.cwd || process.cwd());
|
||||
this.env = { ...process.env, ...options.env };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute gate for a given stage
|
||||
* Execute Stage 1: PostFileEdit tool interceptor on a single modified file.
|
||||
* Runs in < 300ms on ASTs using Biome, Ruff, or SkillCheck.
|
||||
*
|
||||
* @param filePath Relative or absolute path of the modified file
|
||||
* @returns DiagnosticEnvelope detailing formatting/linting findings
|
||||
*/
|
||||
public run(options: RunOptions): RunResult {
|
||||
public runPostEdit(filePath: string): DiagnosticEnvelope {
|
||||
const startTime = Date.now();
|
||||
const stage = this.normalizeStage(options.stage);
|
||||
|
||||
if (stage === 'PostFileEdit' && options.filePath) {
|
||||
return this.runPostFileEdit(options.filePath, startTime);
|
||||
}
|
||||
|
||||
if (stage === 'PrePush') {
|
||||
return this.runPrePush(startTime);
|
||||
}
|
||||
|
||||
return this.runPreCommit(startTime);
|
||||
}
|
||||
|
||||
private runPostFileEdit(filePath: string, startTime: number): RunResult {
|
||||
const fullPath = path.resolve(this.rootPath, filePath);
|
||||
const detection = detectStack(this.rootPath);
|
||||
const results: Array<{ toolName: string; output: string; exitCode: number }> = [];
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
const toolResults: ToolExecutionResult[] = [];
|
||||
|
||||
if (/\.(js|ts|jsx|tsx|json|jsonc)$/.test(filePath) && detection.jsTs.detected) {
|
||||
const res = this.execCommand('npx', ['biome', 'check', '--write', fullPath]);
|
||||
results.push({ toolName: 'biome', output: res.stdout + res.stderr, exitCode: res.status ?? 0 });
|
||||
// JS/TS: Run Biome check with auto-write
|
||||
if (['.js', '.ts', '.jsx', '.tsx', '.json', '.jsonc'].includes(ext)) {
|
||||
const res = this.execCommand('npx', ['@biomejs/biome', 'check', '--write', fullPath]);
|
||||
toolResults.push({ toolName: 'biome', output: res.output, exitCode: res.exitCode });
|
||||
}
|
||||
|
||||
if (/\.py$/.test(filePath) && detection.python.detected) {
|
||||
// Python: Run Ruff check with auto-fix
|
||||
if (['.py'].includes(ext)) {
|
||||
const res = this.execCommand('ruff', ['check', '--fix', fullPath]);
|
||||
results.push({ toolName: 'ruff', output: res.stdout + res.stderr, exitCode: res.status ?? 0 });
|
||||
toolResults.push({ toolName: 'ruff', output: res.output, exitCode: res.exitCode });
|
||||
}
|
||||
|
||||
if (filePath.includes('.claude/skills/') || filePath.endsWith('SKILL.md')) {
|
||||
// Skill markdown: Run skillcheck validator
|
||||
if (fullPath.includes('.claude/skills') || fullPath.endsWith('SKILL.md')) {
|
||||
const res = this.execCommand('skillcheck', ['check', fullPath]);
|
||||
results.push({ toolName: 'skillcheck', output: res.stdout + res.stderr, exitCode: res.status ?? 0 });
|
||||
toolResults.push({ toolName: 'skillcheck', output: res.output, exitCode: res.exitCode });
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const envelope = DiagnosticStreamer.aggregate(results, {
|
||||
return DiagnosticStreamer.aggregate(toolResults, {
|
||||
stage: 'PostFileEdit',
|
||||
executionTimeMs: duration,
|
||||
});
|
||||
|
||||
const passed = envelope.summary.total_errors === 0;
|
||||
const combinedOutput = results.map(r => r.output).join('\n');
|
||||
|
||||
return {
|
||||
passed,
|
||||
exitCode: passed ? 0 : 1,
|
||||
envelope,
|
||||
rawOutput: combinedOutput,
|
||||
};
|
||||
}
|
||||
|
||||
private runPreCommit(startTime: number): RunResult {
|
||||
// Attempt running lefthook pre-commit
|
||||
const lefthookRes = this.execCommand('npx', ['lefthook', 'run', 'pre-commit']);
|
||||
const rawOutput = (lefthookRes.stdout || '') + (lefthookRes.stderr || '');
|
||||
/**
|
||||
* Execute Stage 2: Synchronous Local Pre-Commit Hard Gate (< 2.0s target).
|
||||
* Invokes Lefthook to run parallel staged checks (Biome, Ruff, AISlop, TruffleHog, Typos, Actionlint).
|
||||
*
|
||||
* @returns DiagnosticEnvelope aggregating all staged failure diagnostics
|
||||
*/
|
||||
public runPreCommit(): DiagnosticEnvelope {
|
||||
const startTime = Date.now();
|
||||
const res = this.execCommand('npx', ['lefthook', 'run', 'pre-commit']);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const envelope = DiagnosticStreamer.format(rawOutput, {
|
||||
stage: 'PreCommit',
|
||||
toolName: 'lefthook',
|
||||
executionTimeMs: duration,
|
||||
});
|
||||
|
||||
const passed = (lefthookRes.status === 0) && envelope.summary.total_errors === 0;
|
||||
|
||||
return {
|
||||
passed,
|
||||
exitCode: passed ? 0 : (lefthookRes.status || 1),
|
||||
envelope,
|
||||
rawOutput,
|
||||
};
|
||||
return DiagnosticStreamer.aggregate(
|
||||
[{ toolName: 'lefthook-pre-commit', output: res.output, exitCode: res.exitCode }],
|
||||
{ stage: 'PreCommit', executionTimeMs: duration }
|
||||
);
|
||||
}
|
||||
|
||||
private runPrePush(startTime: number): RunResult {
|
||||
const lefthookRes = this.execCommand('npx', ['lefthook', 'run', 'pre-push']);
|
||||
const rawOutput = (lefthookRes.stdout || '') + (lefthookRes.stderr || '');
|
||||
/**
|
||||
* Execute Stage 3: Pre-Push / CI Full Codebase Graph Governance.
|
||||
* Invokes Lefthook to run pre-push checks (Fallow, Sherif, OWASP Noir).
|
||||
*
|
||||
* @returns DiagnosticEnvelope aggregating graph audit results
|
||||
*/
|
||||
public runPrePush(): DiagnosticEnvelope {
|
||||
const startTime = Date.now();
|
||||
const res = this.execCommand('npx', ['lefthook', 'run', 'pre-push']);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const envelope = DiagnosticStreamer.format(rawOutput, {
|
||||
stage: 'PrePush',
|
||||
toolName: 'lefthook',
|
||||
executionTimeMs: duration,
|
||||
});
|
||||
|
||||
const passed = (lefthookRes.status === 0) && envelope.summary.total_errors === 0;
|
||||
|
||||
return {
|
||||
passed,
|
||||
exitCode: passed ? 0 : (lefthookRes.status || 1),
|
||||
envelope,
|
||||
rawOutput,
|
||||
};
|
||||
return DiagnosticStreamer.aggregate(
|
||||
[{ toolName: 'lefthook-pre-push', output: res.output, exitCode: res.exitCode }],
|
||||
{ stage: 'PrePush', executionTimeMs: duration }
|
||||
);
|
||||
}
|
||||
|
||||
private execCommand(command: string, args: string[]) {
|
||||
/**
|
||||
* Helper to spawn a shell command synchronously and capture output
|
||||
*/
|
||||
private execCommand(command: string, args: string[]): { exitCode: number; output: string } {
|
||||
try {
|
||||
return spawnSync(command, args, {
|
||||
const proc = spawnSync(command, args, {
|
||||
cwd: this.rootPath,
|
||||
env: this.env,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: 30000,
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
const stdout = proc.stdout || '';
|
||||
const stderr = proc.stderr || '';
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
const exitCode = proc.status ?? (proc.error ? 1 : 0);
|
||||
|
||||
return { exitCode, output };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: err?.message || String(err),
|
||||
};
|
||||
return { exitCode: 1, output: err.message || 'Execution failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeStage(stage: string): GateStage {
|
||||
const s = stage.toLowerCase().replace(/[^a-z]/g, '');
|
||||
if (s.includes('post') || s.includes('edit')) return 'PostFileEdit';
|
||||
if (s.includes('push')) return 'PrePush';
|
||||
if (s.includes('ci')) return 'CI';
|
||||
return 'PreCommit';
|
||||
}
|
||||
}
|
||||
|
||||
export function runGate(options: RunOptions): RunResult {
|
||||
return new GateRunner(options.cwd).run(options);
|
||||
}
|
||||
@@ -1 +1,6 @@
|
||||
/**
|
||||
* @file src/runner/index.ts
|
||||
* @description Exports the mechanical gate execution runner module.
|
||||
*/
|
||||
|
||||
export * from './gateRunner.js';
|
||||
+120
-1
@@ -1,124 +1,243 @@
|
||||
/**
|
||||
* Core type definitions for @agent-gate/cli
|
||||
* @file src/types/index.ts
|
||||
* @description Core TypeScript type definitions, LSP schemas, and diagnostic data structures
|
||||
* for the @heretek-ai/agent-proof mechanical hard-gate CLI.
|
||||
*
|
||||
* Defines the contract between stack detectors, configuration generators,
|
||||
* tool failure parsers, and autonomous AI self-correction diagnostic streams.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Result of the multi-stack repository inspection engine.
|
||||
* Contains boolean indicators, detected signature files, and metadata
|
||||
* across all supported language ecosystems, infrastructure, and agent harnesses.
|
||||
*/
|
||||
export interface StackDetectionResult {
|
||||
/** Absolute root path of the inspected repository */
|
||||
rootPath: string;
|
||||
|
||||
/** JavaScript & TypeScript ecosystem detection details */
|
||||
jsTs: {
|
||||
/** True if any JS/TS signature files (package.json, tsconfig.json, etc.) were found */
|
||||
detected: boolean;
|
||||
/** List of relative file paths that triggered JS/TS detection */
|
||||
files: string[];
|
||||
/** True if TypeScript configuration or dependencies are present */
|
||||
hasTypeScript: boolean;
|
||||
/** True if Biome linter/formatter configuration is already present */
|
||||
hasBiome: boolean;
|
||||
/** True if monorepo workspace configuration (pnpm, yarn, lerna, turbo) was detected */
|
||||
isMonorepo: boolean;
|
||||
/** Detected package manager (npm, pnpm, yarn, bun, or deno) */
|
||||
packageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'deno';
|
||||
};
|
||||
|
||||
/** Python ecosystem detection details */
|
||||
python: {
|
||||
/** True if any Python signature files (pyproject.toml, requirements.txt, etc.) were found */
|
||||
detected: boolean;
|
||||
/** List of relative file paths that triggered Python detection */
|
||||
files: string[];
|
||||
/** True if pyproject.toml is present */
|
||||
hasPyproject: boolean;
|
||||
/** True if Ruff configuration is defined in ruff.toml or pyproject.toml */
|
||||
hasRuffConfig: boolean;
|
||||
};
|
||||
|
||||
/** Go ecosystem detection details */
|
||||
go: {
|
||||
/** True if Go signature files (go.mod, go.sum, etc.) were found */
|
||||
detected: boolean;
|
||||
/** List of relative file paths that triggered Go detection */
|
||||
files: string[];
|
||||
};
|
||||
|
||||
/** Rust ecosystem detection details */
|
||||
rust: {
|
||||
/** True if Cargo signature files (Cargo.toml, Cargo.lock) were found */
|
||||
detected: boolean;
|
||||
/** List of relative file paths that triggered Rust detection */
|
||||
files: string[];
|
||||
/** True if Cargo.toml defines a multi-crate workspace */
|
||||
isWorkspace: boolean;
|
||||
};
|
||||
|
||||
/** Infrastructure, CI workflows, and containerization detection */
|
||||
infra: {
|
||||
/** True if CI workflow files or Docker containers were detected */
|
||||
detected: boolean;
|
||||
/** True if GitHub Actions workflow YAML files are present */
|
||||
hasWorkflows: boolean;
|
||||
/** List of relative paths to GitHub Actions workflow files */
|
||||
workflowFiles: string[];
|
||||
/** True if Dockerfile or docker-compose files are present */
|
||||
hasDocker: boolean;
|
||||
/** List of relative paths to Docker / container definition files */
|
||||
dockerFiles: string[];
|
||||
};
|
||||
|
||||
/** AI agent harness, skill definitions, and configuration files */
|
||||
agentHarness: {
|
||||
/** True if any AI agent harness files (.claude, .cursor, SKILL.md) were found */
|
||||
detected: boolean;
|
||||
/** True if Claude Code harness (.claude/ or CLAUDE.md) is present */
|
||||
hasClaude: boolean;
|
||||
/** True if Cursor IDE harness (.cursor/ or .cursorrules) is present */
|
||||
hasCursor: boolean;
|
||||
/** True if agent skill markdown files (.claude/skills/*.md or SKILL.md) are present */
|
||||
hasSkillFiles: boolean;
|
||||
/** True if AGENTS.md manifest is present */
|
||||
hasAgentsMd: boolean;
|
||||
/** List of relative paths to agent harness files */
|
||||
files: string[];
|
||||
};
|
||||
|
||||
/** High-level summary of detected stacks */
|
||||
summary: {
|
||||
/** Human-readable names of primary detected language/ecosystem stacks */
|
||||
primaryStacks: string[];
|
||||
/** Total count of all matching indicator files across all stacks */
|
||||
totalIndicators: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported execution stages in the 3-tier mechanical gate lifecycle
|
||||
*/
|
||||
export type GateStage = 'PostFileEdit' | 'PreCommit' | 'PrePush' | 'CI';
|
||||
|
||||
/**
|
||||
* Severity level for diagnostic items conforming to LSP standards
|
||||
*/
|
||||
export type DiagnosticSeverity = 'ERROR' | 'WARNING' | 'INFO' | 'HINT';
|
||||
|
||||
/**
|
||||
* 1-indexed source code coordinate range where a violation occurred
|
||||
*/
|
||||
export interface DiagnosticRange {
|
||||
/** Start line and column of the violation */
|
||||
start: {
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
/** End line and column of the violation */
|
||||
end: {
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured self-correction repair instruction for AI agents.
|
||||
* Contains actionable code replacement tokens that an LLM can apply directly.
|
||||
*/
|
||||
export interface RepairInstruction {
|
||||
/** Action category guiding the agent's edit strategy */
|
||||
action: 'REWRITE_BLOCK' | 'REPLACE_TOKEN' | 'INSERT_IMPORT' | 'DELETE_LINE' | 'EXECUTE_COMMAND' | 'MANUAL_FIX';
|
||||
/** Human and LLM readable explanation of how to fix the violation */
|
||||
description: string;
|
||||
/** Concrete code replacement strings or repair tokens for autonomous correction */
|
||||
repair_tokens: string[];
|
||||
/** Optional shell command that can automatically resolve the issue (e.g. biome check --write) */
|
||||
suggested_command?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual diagnostic item representing a single lint, security, or slop violation
|
||||
*/
|
||||
export interface DiagnosticItem {
|
||||
/** Name of the tool or engine that produced the finding (e.g. 'aislop', 'biome', 'ruff') */
|
||||
source: string;
|
||||
/** Machine-readable rule identifier (e.g. 'AI_SLOP_SWALLOWED_ERROR', 'F401', 'noExplicitAny') */
|
||||
rule_id: string;
|
||||
/** Severity level (ERROR, WARNING, INFO, HINT) */
|
||||
severity: DiagnosticSeverity;
|
||||
/** Relative or absolute path to the file containing the violation */
|
||||
file_path: string;
|
||||
/** Exact line/column range in the source file */
|
||||
range?: DiagnosticRange;
|
||||
/** Relevant code snippet around the violation */
|
||||
code_snippet?: string;
|
||||
/** Detailed error message describing the violation */
|
||||
error_message: string;
|
||||
/** Actionable repair guidance and tokens for autonomous self-correction */
|
||||
repair_instruction?: RepairInstruction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level LSP-compliant diagnostic envelope emitted by the Diagnostic Streamer.
|
||||
* Conforms to JSON Schema: https://json.schemastore.org/lsif.json
|
||||
*/
|
||||
export interface DiagnosticEnvelope {
|
||||
/** JSON Schema URI for validation */
|
||||
$schema: string;
|
||||
/** Schema specification version */
|
||||
version: string;
|
||||
/** Overall gate status: GATE_PASSED if 0 errors, GATE_FAILED otherwise */
|
||||
status: 'GATE_PASSED' | 'GATE_FAILED';
|
||||
/** Aggregated error and warning counts for this stage */
|
||||
summary: {
|
||||
total_errors: number;
|
||||
total_warnings: number;
|
||||
gate_stage: GateStage;
|
||||
};
|
||||
/** List of all diagnostic items detected across all executed tools */
|
||||
diagnostics: DiagnosticItem[];
|
||||
/** Execution metadata including timing and per-tool exit codes */
|
||||
metadata?: {
|
||||
execution_time_ms?: number;
|
||||
tool_outputs?: Record<string, { exitCode: number; stderr: string; stdout: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Representation of a generated configuration file to be written to disk
|
||||
*/
|
||||
export interface GeneratedConfig {
|
||||
/** Relative destination path (e.g. 'lefthook.yml', '.claude/hooks.json') */
|
||||
path: string;
|
||||
/** Complete file content string */
|
||||
content: string;
|
||||
/** Descriptive summary of the configuration file's role */
|
||||
description: string;
|
||||
/** If true, the file will be marked read-only (chmod 0444) to prevent agent tampering */
|
||||
isImmutable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of emitting configuration files to disk
|
||||
*/
|
||||
export interface GenerationResult {
|
||||
/** All configuration templates computed for the detected stacks */
|
||||
configs: GeneratedConfig[];
|
||||
/** Relative paths of files successfully created or updated on disk */
|
||||
writtenFiles: string[];
|
||||
/** Relative paths of files skipped because they already exist and force was false */
|
||||
skippedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of locking or unlocking governance file permissions
|
||||
*/
|
||||
export interface LockResult {
|
||||
/** Files that were successfully updated with new permissions */
|
||||
lockedFiles: string[];
|
||||
/** Files that could not be updated due to errors */
|
||||
failedFiles: string[];
|
||||
/** Current mode applied: 'locked' (0444) or 'unlocked' (0644) */
|
||||
mode: 'locked' | 'unlocked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of installing mechanical git hooks
|
||||
*/
|
||||
export interface HookInstallResult {
|
||||
/** True if lefthook CLI installed hooks natively */
|
||||
lefthookInstalled: boolean;
|
||||
/** Path to the .git/hooks directory */
|
||||
gitHooksPath: string;
|
||||
/** List of hook names installed (e.g. ['pre-commit', 'pre-push']) */
|
||||
installedHooks: string[];
|
||||
/** Status description message */
|
||||
message: string;
|
||||
}
|
||||
Reference in new issue
Block a user