quick-gate 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { loadChangedFiles } from './fs-utils.js';
4
+ import { executeRun } from './run-command.js';
5
+ import { executeSummarize } from './summarize-command.js';
6
+ import { executeRepair } from './repair-command.js';
7
+ import { checkEnvironment, hasOllama } from './env-check.js';
8
+
9
+ function parseArgs(argv) {
10
+ const args = {};
11
+ for (let i = 0; i < argv.length; i += 1) {
12
+ const token = argv[i];
13
+ if (!token.startsWith('--')) continue;
14
+ const key = token.replace(/^--/, '');
15
+ const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
16
+ args[key] = value;
17
+ if (value !== true) i += 1;
18
+ }
19
+ return args;
20
+ }
21
+
22
+ function usage() {
23
+ console.log(`Quick Gate v0.2.0-alpha
24
+
25
+ Commands:
26
+ quick-gate run --mode canary|full --changed-files <path>
27
+ quick-gate summarize --input .quick-gate/failures.json
28
+ quick-gate repair --input .quick-gate/failures.json [--max-attempts 3] [--deterministic-only]
29
+
30
+ Options:
31
+ --deterministic-only Skip model-assisted repair (no Ollama required)
32
+ --help, -h Show this help message`);
33
+ }
34
+
35
+ async function main() {
36
+ const [, , cmd, ...rest] = process.argv;
37
+ if (!cmd || cmd === '--help' || cmd === '-h') {
38
+ usage();
39
+ process.exit(0);
40
+ }
41
+
42
+ const args = parseArgs(rest);
43
+
44
+ const warnings = checkEnvironment({ command: cmd });
45
+ for (const w of warnings) {
46
+ console.error(`[quick-gate] ${w}`);
47
+ }
48
+
49
+ try {
50
+ if (cmd === 'run') {
51
+ if (!args.mode || !['canary', 'full'].includes(String(args.mode))) {
52
+ throw new Error('run requires --mode canary|full');
53
+ }
54
+ if (!args['changed-files']) {
55
+ throw new Error('run requires --changed-files <path>');
56
+ }
57
+ const changedFilesPath = path.resolve(process.cwd(), String(args['changed-files']));
58
+ const changedFiles = loadChangedFiles(changedFilesPath);
59
+ const result = executeRun({ mode: String(args.mode), changedFiles });
60
+ console.log(JSON.stringify(result, null, 2));
61
+ process.exit(result.status === 'pass' ? 0 : 1);
62
+ }
63
+
64
+ if (cmd === 'summarize') {
65
+ if (!args.input) {
66
+ throw new Error('summarize requires --input <path>');
67
+ }
68
+ const result = executeSummarize({ input: String(args.input) });
69
+ console.log(JSON.stringify(result, null, 2));
70
+ process.exit(0);
71
+ }
72
+
73
+ if (cmd === 'repair') {
74
+ if (!args.input) {
75
+ throw new Error('repair requires --input <path>');
76
+ }
77
+ const deterministicOnly = args['deterministic-only'] === true || !hasOllama();
78
+ const result = executeRepair({
79
+ input: String(args.input),
80
+ maxAttempts: args['max-attempts'],
81
+ deterministicOnly,
82
+ });
83
+ console.log(JSON.stringify(result, null, 2));
84
+ process.exit(result.status === 'pass' ? 0 : 2);
85
+ }
86
+
87
+ throw new Error(`Unknown command: ${cmd}`);
88
+ } catch (error) {
89
+ console.error(error instanceof Error ? error.message : String(error));
90
+ process.exit(1);
91
+ }
92
+ }
93
+
94
+ main();
package/src/config.js ADDED
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { DEFAULT_POLICY } from './constants.js';
3
+ import { fileExists, readJsonFileSync } from './fs-utils.js';
4
+
5
+ export function loadConfig(cwd = process.cwd()) {
6
+ const configPath = path.join(cwd, 'quick-gate.config.json');
7
+ if (!fileExists(configPath)) {
8
+ return {
9
+ policy: { ...DEFAULT_POLICY },
10
+ commands: {},
11
+ lighthouse: {
12
+ thresholds: {
13
+ performance: 0.8,
14
+ accessibility: 0.8,
15
+ 'best-practices': 0.8,
16
+ seo: 0.8,
17
+ },
18
+ },
19
+ source: 'defaults',
20
+ };
21
+ }
22
+
23
+ const userConfig = readJsonFileSync(configPath);
24
+ return {
25
+ policy: { ...DEFAULT_POLICY, ...(userConfig.policy || {}) },
26
+ commands: { ...(userConfig.commands || {}) },
27
+ lighthouse: {
28
+ thresholds: {
29
+ performance: 0.8,
30
+ accessibility: 0.8,
31
+ 'best-practices': 0.8,
32
+ seo: 0.8,
33
+ ...(userConfig.lighthouse?.thresholds || {}),
34
+ },
35
+ },
36
+ source: configPath,
37
+ };
38
+ }
@@ -0,0 +1,20 @@
1
+ export const QUICK_GATE_DIR = '.quick-gate';
2
+ export const FAILURES_FILE = '.quick-gate/failures.json';
3
+ export const RUN_METADATA_FILE = '.quick-gate/run-metadata.json';
4
+ export const AGENT_BRIEF_MD_FILE = '.quick-gate/agent-brief.md';
5
+ export const AGENT_BRIEF_JSON_FILE = '.quick-gate/agent-brief.json';
6
+
7
+ export const DEFAULT_POLICY = {
8
+ maxAttempts: 3,
9
+ maxPatchLines: 150,
10
+ abortOnNoImprovement: 2,
11
+ timeCapMs: 20 * 60 * 1000,
12
+ };
13
+
14
+ export const ESCALATION_CODES = {
15
+ NO_IMPROVEMENT: 'NO_IMPROVEMENT',
16
+ PATCH_BUDGET_EXCEEDED: 'PATCH_BUDGET_EXCEEDED',
17
+ ARCHITECTURAL_CHANGE_REQUIRED: 'ARCHITECTURAL_CHANGE_REQUIRED',
18
+ FLAKY_EVALUATOR: 'FLAKY_EVALUATOR',
19
+ UNKNOWN_BLOCKER: 'UNKNOWN_BLOCKER',
20
+ };
@@ -0,0 +1,66 @@
1
+ import path from 'node:path';
2
+ import { runCommand } from './exec.js';
3
+
4
+ function shellQuote(value) {
5
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
6
+ }
7
+
8
+ function inScopeFiles(failures) {
9
+ const fromChanged = Array.isArray(failures.changed_files) ? failures.changed_files : [];
10
+ const fromFindings = (failures.findings || []).flatMap((f) => (Array.isArray(f.files) ? f.files : []));
11
+ const unique = [];
12
+ const seen = new Set();
13
+ for (const file of [...fromChanged, ...fromFindings]) {
14
+ if (!file || seen.has(file)) continue;
15
+ seen.add(file);
16
+ unique.push(file);
17
+ }
18
+ return unique;
19
+ }
20
+
21
+ function hasGateFailure(failures, gate) {
22
+ return (failures.findings || []).some((f) => f.gate === gate);
23
+ }
24
+
25
+ function runLintProblemAutofix({ cwd, files }) {
26
+ if (files.length === 0) return null;
27
+ const fileArgs = files.map((f) => shellQuote(f)).join(' ');
28
+ const cmd = `npx eslint ${fileArgs} --fix --fix-type problem`;
29
+ const result = runCommand(cmd, { cwd });
30
+ return {
31
+ rule_id: 'LINT_PROBLEM_AUTOFIX',
32
+ strategy: 'deterministic_prefix',
33
+ accepted: result.exit_code === 0,
34
+ command: cmd,
35
+ exit_code: result.exit_code,
36
+ files,
37
+ rationale: 'Apply only ESLint problem fixes on scoped files to minimize semantic-risk edits.',
38
+ };
39
+ }
40
+
41
+ export function runDeterministicPreFix({ cwd, failures }) {
42
+ const actions = [];
43
+ const isEligibleFile = (f) => {
44
+ if (!/\.(js|jsx|ts|tsx|mjs|cjs)$/.test(f)) return false;
45
+ if (/(^|\/)(dist|build|coverage|\.next)\//.test(f)) return false;
46
+ if (/\.min\.(js|mjs|cjs)$/.test(f)) return false;
47
+ return true;
48
+ };
49
+ const scopedFiles = inScopeFiles(failures)
50
+ .filter((f) => !f.startsWith('.'))
51
+ .filter((f) => !path.isAbsolute(f))
52
+ .filter((f) => !f.includes('..'))
53
+ .filter((f) => !f.includes('node_modules'))
54
+ .filter((f) => path.extname(f) !== '')
55
+ .filter((f) => isEligibleFile(f))
56
+ .slice(0, 20);
57
+
58
+ // Rule 1 (low risk): only lint "problem" fixes, scoped to impacted files.
59
+ if (hasGateFailure(failures, 'lint')) {
60
+ const action = runLintProblemAutofix({ cwd, files: scopedFiles });
61
+ if (action) actions.push(action);
62
+ }
63
+
64
+ // Future rules should follow same invariants: explicit trigger, scoped files, rollback-safe rerun.
65
+ return actions;
66
+ }
@@ -0,0 +1,41 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ const cache = new Map();
4
+
5
+ function commandExists(name) {
6
+ if (cache.has(name)) return cache.get(name);
7
+ const result = spawnSync('which', [name], { encoding: 'utf8', timeout: 5000 });
8
+ const exists = result.status === 0;
9
+ cache.set(name, exists);
10
+ return exists;
11
+ }
12
+
13
+ export function hasGit() {
14
+ return commandExists('git');
15
+ }
16
+
17
+ export function hasOllama() {
18
+ return commandExists('ollama');
19
+ }
20
+
21
+ export function hasRsync() {
22
+ return commandExists('rsync');
23
+ }
24
+
25
+ export function checkEnvironment({ command }) {
26
+ const warnings = [];
27
+
28
+ if (!hasGit()) {
29
+ warnings.push('git not found -- repo metadata (branch, remote) will be unavailable.');
30
+ }
31
+
32
+ if (command === 'repair' && !hasOllama()) {
33
+ warnings.push('Ollama not found -- running deterministic fixes only (eslint --fix). Install Ollama for model-assisted repair: https://ollama.ai');
34
+ }
35
+
36
+ if (command === 'repair' && !hasRsync()) {
37
+ warnings.push('rsync not found -- using cp for workspace backup (slower but functional).');
38
+ }
39
+
40
+ return warnings;
41
+ }
package/src/exec.js ADDED
@@ -0,0 +1,24 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export function runCommand(command, options = {}) {
4
+ const startedAt = Date.now();
5
+ const result = spawnSync(command, {
6
+ shell: true,
7
+ cwd: options.cwd || process.cwd(),
8
+ encoding: 'utf8',
9
+ env: { ...process.env, ...(options.env || {}) },
10
+ timeout: options.timeoutMs,
11
+ maxBuffer: 20 * 1024 * 1024,
12
+ });
13
+
14
+ return {
15
+ command,
16
+ cwd: options.cwd || process.cwd(),
17
+ started_at: new Date(startedAt).toISOString(),
18
+ duration_ms: Date.now() - startedAt,
19
+ exit_code: typeof result.status === 'number' ? result.status : 1,
20
+ timed_out: Boolean(result.error && result.error.code === 'ETIMEDOUT'),
21
+ stdout: result.stdout || '',
22
+ stderr: result.stderr || '',
23
+ };
24
+ }
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function ensureDirSync(dirPath) {
5
+ fs.mkdirSync(dirPath, { recursive: true });
6
+ }
7
+
8
+ export function readJsonFileSync(filePath) {
9
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
10
+ }
11
+
12
+ export function writeJsonFileSync(filePath, data) {
13
+ ensureDirSync(path.dirname(filePath));
14
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
15
+ }
16
+
17
+ export function writeTextFileSync(filePath, text) {
18
+ ensureDirSync(path.dirname(filePath));
19
+ fs.writeFileSync(filePath, text, 'utf8');
20
+ }
21
+
22
+ export function fileExists(filePath) {
23
+ return fs.existsSync(filePath);
24
+ }
25
+
26
+ export function nowIso() {
27
+ return new Date().toISOString();
28
+ }
29
+
30
+ export function loadChangedFiles(changedFilesPath) {
31
+ const raw = fs.readFileSync(changedFilesPath, 'utf8').trim();
32
+ if (!raw) {
33
+ return [];
34
+ }
35
+
36
+ if (raw.startsWith('[')) {
37
+ const parsed = JSON.parse(raw);
38
+ if (!Array.isArray(parsed)) {
39
+ throw new Error(`Changed files JSON must be an array: ${changedFilesPath}`);
40
+ }
41
+ return parsed.map(String);
42
+ }
43
+
44
+ return raw
45
+ .split(/\r?\n/)
46
+ .map((line) => line.trim())
47
+ .filter(Boolean);
48
+ }
package/src/gates.js ADDED
@@ -0,0 +1,191 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runCommand } from './exec.js';
4
+
5
+ function packageScripts(cwd) {
6
+ const packagePath = path.join(cwd, 'package.json');
7
+ if (!fs.existsSync(packagePath)) {
8
+ throw new Error(`No package.json found in ${cwd}`);
9
+ }
10
+ const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
11
+ return pkg.scripts || {};
12
+ }
13
+
14
+ function resolveGateCommand(gate, scripts, configCommands) {
15
+ if (configCommands[gate]) {
16
+ return configCommands[gate];
17
+ }
18
+ if (gate === 'typecheck') {
19
+ if (scripts.typecheck) return 'npm run typecheck';
20
+ return 'npx tsc --noEmit';
21
+ }
22
+ if (scripts[gate]) {
23
+ return `npm run ${gate}`;
24
+ }
25
+ if (gate === 'lighthouse') {
26
+ if (scripts.lighthouse) return 'npm run lighthouse';
27
+ if (scripts['ci:lighthouse']) return 'npm run ci:lighthouse';
28
+ if (scripts.lhci) return 'npm run lhci';
29
+ return 'npx lhci autorun --upload.target=filesystem --upload.outputDir=.quick-gate/lhci';
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function parseLighthouseFindings(cwd, thresholds) {
35
+ const assertionResultsPath = path.join(cwd, '.lighthouseci', 'assertion-results.json');
36
+ if (!fs.existsSync(assertionResultsPath)) {
37
+ return [];
38
+ }
39
+
40
+ const data = JSON.parse(fs.readFileSync(assertionResultsPath, 'utf8'));
41
+ const findings = [];
42
+
43
+ const metricThresholds = thresholds || {};
44
+ const routePath = (rawUrl) => {
45
+ if (!rawUrl) return '/';
46
+ try {
47
+ const u = new URL(rawUrl);
48
+ return u.pathname || '/';
49
+ } catch {
50
+ return String(rawUrl);
51
+ }
52
+ };
53
+
54
+ const thresholdForAssertion = (row) => {
55
+ if (typeof row.expected === 'number' || typeof row.expected === 'string') {
56
+ return {
57
+ value: row.expected,
58
+ source: 'assertion_expected',
59
+ };
60
+ }
61
+
62
+ const assertion = String(row.assertion || '');
63
+ const parts = assertion.split(':');
64
+ if (parts.length === 2 && parts[0] === 'categories' && metricThresholds[parts[1]] !== undefined) {
65
+ return {
66
+ value: metricThresholds[parts[1]],
67
+ source: `config_category:${parts[1]}`,
68
+ };
69
+ }
70
+
71
+ if (metricThresholds[assertion] !== undefined) {
72
+ return {
73
+ value: metricThresholds[assertion],
74
+ source: `config_metric:${assertion}`,
75
+ };
76
+ }
77
+
78
+ return {
79
+ value: 'n/a',
80
+ source: 'unknown',
81
+ };
82
+ };
83
+
84
+ for (const row of data) {
85
+ if (row.passed) continue;
86
+ const route = routePath(row.url);
87
+ const metric = String(row.assertion || 'lighthouse_assertion');
88
+ const threshold = thresholdForAssertion(row);
89
+ const findingId = `lh_${route.replace(/[^a-zA-Z0-9]+/g, '_')}_${metric.replace(/[^a-zA-Z0-9]+/g, '_')}`.toLowerCase();
90
+ const actual = typeof row.numericValue === 'number' ? row.numericValue : String(row.value ?? 'n/a');
91
+ findings.push({
92
+ id: findingId,
93
+ gate: 'lighthouse',
94
+ severity: 'high',
95
+ summary: row.message || `Lighthouse assertion failed: ${metric}`,
96
+ route,
97
+ metric,
98
+ actual,
99
+ threshold: threshold.value,
100
+ status: 'fail',
101
+ raw: {
102
+ level: row.level,
103
+ auditProperty: row.auditProperty,
104
+ threshold_source: threshold.source,
105
+ operator: row.operator ?? null,
106
+ },
107
+ });
108
+ }
109
+
110
+ return findings;
111
+ }
112
+
113
+ function findingForExitCode(gate, result) {
114
+ return {
115
+ id: `${gate}_exit_${Date.now()}`,
116
+ gate,
117
+ severity: gate === 'build' ? 'critical' : 'high',
118
+ summary: `${gate} command failed with exit code ${result.exit_code}`,
119
+ actual: result.exit_code,
120
+ threshold: 0,
121
+ status: 'fail',
122
+ raw: {
123
+ command: result.command,
124
+ stderr_excerpt: result.stderr.split('\n').slice(0, 30).join('\n'),
125
+ stdout_excerpt: result.stdout.split('\n').slice(0, 30).join('\n'),
126
+ },
127
+ };
128
+ }
129
+
130
+ export function runDeterministicGates({ mode, cwd, config, changedFiles }) {
131
+ const scripts = packageScripts(cwd);
132
+ const traces = [];
133
+ const findings = [];
134
+
135
+ const gatePlan = [
136
+ { name: 'lint', enabled: true },
137
+ { name: 'typecheck', enabled: true },
138
+ { name: 'build', enabled: mode === 'full' },
139
+ { name: 'lighthouse', enabled: true },
140
+ ];
141
+
142
+ const gates = [];
143
+
144
+ for (const gate of gatePlan) {
145
+ if (!gate.enabled) {
146
+ gates.push({ name: gate.name, status: 'skipped', duration_ms: 0 });
147
+ continue;
148
+ }
149
+
150
+ const command = resolveGateCommand(gate.name, scripts, config.commands);
151
+ if (!command) {
152
+ gates.push({ name: gate.name, status: 'fail', duration_ms: 0 });
153
+ findings.push({
154
+ id: `${gate.name}_missing_command`,
155
+ gate: gate.name,
156
+ severity: 'high',
157
+ summary: `No command configured for gate: ${gate.name}`,
158
+ files: changedFiles,
159
+ actual: 'missing',
160
+ threshold: 'configured_command_required',
161
+ status: 'fail',
162
+ });
163
+ continue;
164
+ }
165
+
166
+ const result = runCommand(command, { cwd });
167
+ traces.push(result);
168
+
169
+ const status = result.exit_code === 0 ? 'pass' : 'fail';
170
+ gates.push({ name: gate.name, status, duration_ms: result.duration_ms });
171
+
172
+ if (status === 'fail') {
173
+ if (gate.name === 'lighthouse') {
174
+ const lighthouseFindings = parseLighthouseFindings(cwd, config.lighthouse.thresholds);
175
+ if (lighthouseFindings.length > 0) {
176
+ findings.push(...lighthouseFindings);
177
+ } else {
178
+ findings.push(findingForExitCode(gate.name, result));
179
+ }
180
+ } else {
181
+ findings.push(findingForExitCode(gate.name, result));
182
+ }
183
+ }
184
+ }
185
+
186
+ return {
187
+ gates,
188
+ findings,
189
+ traces,
190
+ };
191
+ }