@williambeto/ai-workflow 2.2.6 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist-assets/docs/cli-reference.md +38 -10
- package/dist-assets/docs/compatibility/provider-usage.md +8 -0
- package/dist-assets/docs/compatibility/runtime-matrix.md +1 -0
- package/dist-assets/templates/.antigravityignore.template +8 -0
- package/dist-assets/templates/ANTIGRAVITY.md.template +20 -0
- package/docs/releases/v2.3.0-release-decision.md +35 -0
- package/package.json +14 -4
- package/read_only_safety_verification.md +48 -0
- package/src/adapters/index.js +1 -0
- package/src/adapters/platforms/antigravity.js +382 -0
- package/src/adapters/platforms/codex.js +13 -0
- package/src/adapters/platforms/gemini.js +14 -1
- package/src/cli.js +6 -3
- package/src/commands/collect-evidence.js +3 -39
- package/src/commands/doctor.js +16 -0
- package/src/commands/execute.js +303 -119
- package/src/commands/init.js +25 -2
- package/src/core/delegation-controller.js +134 -0
- package/src/core/evidence/evidence-ledger.js +53 -0
- package/src/core/execution-planner.js +5 -1
- package/src/core/finalization/finalizer.js +77 -0
- package/src/core/finalization/workspace-snapshot.js +110 -0
- package/src/core/gates/branch-gate.js +23 -35
- package/src/core/gates/merge-gate.js +74 -0
- package/src/core/healing/healer-engine.js +34 -1
- package/src/core/healing/runtime-remediation-executor.js +136 -0
- package/src/core/runtime/opencode-adapter.js +137 -62
- package/src/core/templates.js +8 -0
- package/src/core/validation/evidence-collector.js +30 -3
- package/src/core/validation/quality-guard.js +8 -0
- package/src/core/validation/stack-detector.js +65 -0
- package/src/core/validation/validation-planner.js +134 -0
- package/src/core/workspace/read-only-workspace.js +119 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { OpenCodeAdapter } from "../runtime/opencode-adapter.js";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export function createRuntimeRemediationExecutor(cwd, ledger = null) {
|
|
7
|
+
const adapter = new OpenCodeAdapter({ cwd });
|
|
8
|
+
|
|
9
|
+
function getChangedFiles() {
|
|
10
|
+
try {
|
|
11
|
+
const output = execSync("git status --short", { cwd, encoding: "utf8" });
|
|
12
|
+
return output
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.map((line) => line.slice(3).trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return async function runtimeRemediationExecutor({ attempt, maxAttempts, mode, affectedChecks, requestPath, result }) {
|
|
23
|
+
console.log(`\n[HEALER] Running Runtime/Phoenix Structural Remediation [Attempt ${attempt}/${maxAttempts}]`);
|
|
24
|
+
console.log(`[HEALER] Affected checks: ${affectedChecks.join(", ")}`);
|
|
25
|
+
|
|
26
|
+
if (ledger) {
|
|
27
|
+
ledger.logEvent({
|
|
28
|
+
actor: "Phoenix",
|
|
29
|
+
actorType: "runtime-agent",
|
|
30
|
+
observed: true,
|
|
31
|
+
runtime: "opencode",
|
|
32
|
+
eventType: "remediation_start",
|
|
33
|
+
provenance: "opencode-adapter",
|
|
34
|
+
data: {
|
|
35
|
+
attempt,
|
|
36
|
+
affectedChecks,
|
|
37
|
+
event: "remediation.started"
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let requestContent = {};
|
|
43
|
+
try {
|
|
44
|
+
requestContent = JSON.parse(await fs.readFile(requestPath, "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore/fallback
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const filesBefore = getChangedFiles();
|
|
50
|
+
const fileContentsBefore = {};
|
|
51
|
+
for (const file of filesBefore) {
|
|
52
|
+
try {
|
|
53
|
+
fileContentsBefore[file] = await fs.readFile(path.join(cwd, file), "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
fileContentsBefore[file] = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract concrete command outputs for failing checks
|
|
60
|
+
const failedCommands = (result?.evidence?.commands || [])
|
|
61
|
+
.filter(cmd => ["FAIL", "BLOCKED"].includes(cmd.status));
|
|
62
|
+
|
|
63
|
+
let concreteOutput = "";
|
|
64
|
+
if (failedCommands.length > 0) {
|
|
65
|
+
concreteOutput = failedCommands.map(cmd => {
|
|
66
|
+
return `Command: ${cmd.name} (${Array.isArray(cmd.command) ? cmd.command.join(" ") : cmd.command})
|
|
67
|
+
Exit Code: ${cmd.exitCode}
|
|
68
|
+
Output:
|
|
69
|
+
${cmd.output}`;
|
|
70
|
+
}).join("\n\n");
|
|
71
|
+
} else {
|
|
72
|
+
concreteOutput = JSON.stringify(result?.quality || result || {}, null, 2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const promptMsg = `We are in remediation mode (attempt ${attempt}/${maxAttempts}).
|
|
76
|
+
The following validation checks have failed:
|
|
77
|
+
${affectedChecks.join(", ")}
|
|
78
|
+
|
|
79
|
+
Details of the failure:
|
|
80
|
+
\`\`\`
|
|
81
|
+
${concreteOutput}
|
|
82
|
+
\`\`\`
|
|
83
|
+
|
|
84
|
+
Please inspect the code, identify the root cause of these failures (e.g. failing tests, syntax issues, build errors), and write a correction to the relevant files in the workspace to make the tests/validations pass.`;
|
|
85
|
+
|
|
86
|
+
console.log(`[HEALER] Sending structural healing request to Phoenix agent...`);
|
|
87
|
+
const runResult = await adapter.execute(promptMsg, {
|
|
88
|
+
agent: "Phoenix"
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const filesAfter = getChangedFiles();
|
|
92
|
+
const changedFiles = [];
|
|
93
|
+
for (const file of filesAfter) {
|
|
94
|
+
if (!filesBefore.includes(file)) {
|
|
95
|
+
changedFiles.push(file);
|
|
96
|
+
} else {
|
|
97
|
+
try {
|
|
98
|
+
const contentAfter = await fs.readFile(path.join(cwd, file), "utf8");
|
|
99
|
+
if (contentAfter !== fileContentsBefore[file]) {
|
|
100
|
+
changedFiles.push(file);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
if (fileContentsBefore[file] !== null) {
|
|
104
|
+
changedFiles.push(file);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const success = runResult.success && changedFiles.length > 0;
|
|
110
|
+
|
|
111
|
+
if (!runResult.success) {
|
|
112
|
+
console.log(`[HEALER] Phoenix remediation failed: ${runResult.error || "Unknown error"}`);
|
|
113
|
+
return {
|
|
114
|
+
applied: false,
|
|
115
|
+
reason: `Phoenix runtime execution failed: ${runResult.error || "Unknown error"}`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (changedFiles.length === 0) {
|
|
120
|
+
console.log(`[HEALER] Phoenix execution succeeded but no files were modified.`);
|
|
121
|
+
return {
|
|
122
|
+
applied: false,
|
|
123
|
+
reason: "Phoenix runtime executed but did not modify any files in the workspace."
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`[HEALER] Phoenix successfully executed remediation commands.`);
|
|
128
|
+
console.log(`[HEALER] Files modified: ${changedFiles.join(", ")}`);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
applied: true,
|
|
132
|
+
changedFiles,
|
|
133
|
+
evidenceAdded: ["phoenix-healing-run"]
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn,
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import readline from "node:readline";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -9,86 +9,161 @@ export class OpenCodeAdapter {
|
|
|
9
9
|
this.cwd = cwd;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Inspects the availability and capability of the OpenCode CLI.
|
|
14
|
+
* @returns {Promise<{available: boolean, supports: {run: boolean, formatJson: boolean, agent: boolean, model: boolean}}>}
|
|
15
|
+
*/
|
|
16
|
+
async inspect() {
|
|
17
|
+
try {
|
|
18
|
+
const cliHelp = spawnSync("opencode", ["--help"], { encoding: "utf8" });
|
|
19
|
+
const runHelp = spawnSync("opencode", ["run", "--help"], { encoding: "utf8" });
|
|
20
|
+
|
|
21
|
+
const available = cliHelp.status === 0 && runHelp.status === 0;
|
|
22
|
+
if (!available) {
|
|
23
|
+
return {
|
|
24
|
+
available: false,
|
|
25
|
+
supports: { run: false, formatJson: false, agent: false, model: false }
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
available: true,
|
|
31
|
+
supports: {
|
|
32
|
+
run: /\brun\b/.test(cliHelp.stdout),
|
|
33
|
+
formatJson: /--format/.test(runHelp.stdout),
|
|
34
|
+
agent: /--agent/.test(runHelp.stdout),
|
|
35
|
+
model: /--model/.test(runHelp.stdout),
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return {
|
|
40
|
+
available: false,
|
|
41
|
+
supports: { run: false, formatJson: false, agent: false, model: false }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
12
46
|
/**
|
|
13
47
|
* Runs opencode with a prompt and options.
|
|
14
48
|
* @param {string} message - The message prompt.
|
|
15
|
-
* @param {Object} options - CLI options (e.g. agent, model,
|
|
16
|
-
* @returns {Promise<{success: boolean, commandsRun: string[], eventCount: number}>}
|
|
49
|
+
* @param {Object} options - CLI options (e.g. agent, model, readOnly).
|
|
50
|
+
* @returns {Promise<{success: boolean, commandsRun: string[], eventCount: number, error?: string}>}
|
|
17
51
|
*/
|
|
18
|
-
async execute(message, { agent = null, model = null,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
52
|
+
async execute(message, { agent = null, model = null, readOnly = false } = {}) {
|
|
53
|
+
const inspection = await this.inspect();
|
|
54
|
+
if (!inspection.available) {
|
|
55
|
+
return {
|
|
56
|
+
success: false,
|
|
57
|
+
commandsRun: [],
|
|
58
|
+
eventCount: 0,
|
|
59
|
+
error: "OpenCode CLI is not installed or not available in the system PATH."
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const runInWorkspace = async (targetCwd) => {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const args = ["run", message];
|
|
66
|
+
if (inspection.supports.formatJson) {
|
|
67
|
+
args.push("--format", "json");
|
|
68
|
+
}
|
|
69
|
+
if (agent && inspection.supports.agent) {
|
|
70
|
+
args.push("--agent", agent);
|
|
71
|
+
}
|
|
72
|
+
if (model && inspection.supports.model) {
|
|
73
|
+
args.push("--model", model);
|
|
74
|
+
}
|
|
30
75
|
|
|
31
|
-
|
|
76
|
+
console.log(`[RUNTIME] Delegating to OpenCode: opencode ${args.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}`);
|
|
32
77
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
});
|
|
78
|
+
const child = spawn("opencode", args, {
|
|
79
|
+
cwd: targetCwd,
|
|
80
|
+
stdio: ["ignore", "pipe", "inherit"] // Inherit stderr to show warnings directly
|
|
81
|
+
});
|
|
38
82
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
83
|
+
const rl = readline.createInterface({
|
|
84
|
+
input: child.stdout,
|
|
85
|
+
terminal: false
|
|
86
|
+
});
|
|
43
87
|
|
|
44
|
-
|
|
45
|
-
|
|
88
|
+
const commandsRun = [];
|
|
89
|
+
let eventCount = 0;
|
|
46
90
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
91
|
+
rl.on("line", (line) => {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (!trimmed) return;
|
|
50
94
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
try {
|
|
96
|
+
const event = JSON.parse(trimmed);
|
|
97
|
+
eventCount++;
|
|
54
98
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
99
|
+
// 1. Stream agent output text in real-time to user
|
|
100
|
+
if (event.type === "text" && event.part?.text) {
|
|
101
|
+
process.stdout.write(event.part.text);
|
|
102
|
+
}
|
|
59
103
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
// 2. Capture tool executions/commands
|
|
105
|
+
if (event.type === "step_start" && event.part?.toolCalls) {
|
|
106
|
+
for (const call of event.part.toolCalls) {
|
|
107
|
+
if (call.name === "run_command" && call.args?.CommandLine) {
|
|
108
|
+
commandsRun.push(call.args.CommandLine);
|
|
109
|
+
}
|
|
110
|
+
if (readOnly) {
|
|
111
|
+
const name = call.name || "";
|
|
112
|
+
const isWriteTool = name.startsWith("write_") || name.includes("write") || name.includes("replace") || name.includes("edit") || name.includes("delete") || name.includes("remove");
|
|
113
|
+
let isDestructiveGit = false;
|
|
114
|
+
if (name === "run_command" && call.args?.CommandLine) {
|
|
115
|
+
const cmd = call.args.CommandLine;
|
|
116
|
+
const destPattern = /\b(git\s+(commit|add|push|merge|reset|rm|branch|checkout|switch|stash|init)|touch|rm|mkdir|cp|mv|chmod|chown|tee|sed|awk)\b|>>|>/;
|
|
117
|
+
if (destPattern.test(cmd)) {
|
|
118
|
+
isDestructiveGit = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (isWriteTool || isDestructiveGit) {
|
|
122
|
+
console.error(`\n[SECURITY WARNING] Read-only execution blocked attempt to write/modify workspace: tool '${name}' or command detected.`);
|
|
123
|
+
child.kill();
|
|
124
|
+
resolve({
|
|
125
|
+
success: false,
|
|
126
|
+
commandsRun,
|
|
127
|
+
eventCount,
|
|
128
|
+
error: `Read-only confinement violation: tool '${name}' or command attempted write/modify operation during read-only task.`
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
65
133
|
}
|
|
66
134
|
}
|
|
135
|
+
} catch {
|
|
136
|
+
// If a line is not valid JSON (e.g. starting/finishing logs), print it directly
|
|
137
|
+
console.log(trimmed);
|
|
67
138
|
}
|
|
68
|
-
}
|
|
69
|
-
// If a line is not valid JSON (e.g. starting/finishing logs), print it directly
|
|
70
|
-
console.log(trimmed);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
139
|
+
});
|
|
73
140
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
141
|
+
child.on("close", (code) => {
|
|
142
|
+
console.log("\n[RUNTIME] OpenCode finished execution.");
|
|
143
|
+
resolve({
|
|
144
|
+
success: code === 0,
|
|
145
|
+
commandsRun,
|
|
146
|
+
eventCount
|
|
147
|
+
});
|
|
80
148
|
});
|
|
81
|
-
});
|
|
82
149
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
150
|
+
child.on("error", (err) => {
|
|
151
|
+
console.error(`\n[RUNTIME] Failed to launch opencode: ${err.message}`);
|
|
152
|
+
resolve({
|
|
153
|
+
success: false,
|
|
154
|
+
commandsRun,
|
|
155
|
+
eventCount: 0,
|
|
156
|
+
error: err.message
|
|
157
|
+
});
|
|
90
158
|
});
|
|
91
159
|
});
|
|
92
|
-
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (readOnly) {
|
|
163
|
+
const { runInReadOnlyWorkspace } = await import("../workspace/read-only-workspace.js");
|
|
164
|
+
return runInReadOnlyWorkspace(this.cwd, runInWorkspace);
|
|
165
|
+
} else {
|
|
166
|
+
return runInWorkspace(this.cwd);
|
|
167
|
+
}
|
|
93
168
|
}
|
|
94
169
|
}
|
package/src/core/templates.js
CHANGED
|
@@ -62,6 +62,14 @@ function buildRuntimeFiles({ includeFormalEvidence = false } = {}) {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Copy governance policy into opencode/docs/policies/ so the relative link
|
|
66
|
+
// ../../docs/policies/SKILLS_COMMON_GOVERNANCE.md resolves correctly from
|
|
67
|
+
// opencode/skills/{skill}/SKILL.md for OpenCode and all platform adapters.
|
|
68
|
+
const opencodeGovernancePolicy = readPackageFile("dist-assets/docs/policies/SKILLS_COMMON_GOVERNANCE.md");
|
|
69
|
+
if (opencodeGovernancePolicy !== null) {
|
|
70
|
+
files["opencode/docs/policies/SKILLS_COMMON_GOVERNANCE.md"] = opencodeGovernancePolicy;
|
|
71
|
+
}
|
|
72
|
+
|
|
65
73
|
const commandFiles = discoverPackageFiles("dist-assets/commands");
|
|
66
74
|
for (const [relPath, content] of Object.entries(commandFiles)) {
|
|
67
75
|
const targetPath = relPath.replace(/^dist-assets\//, "opencode/");
|
|
@@ -42,18 +42,45 @@ export class EvidenceCollector {
|
|
|
42
42
|
if (task.presetStatus) {
|
|
43
43
|
return { name: task.name, command: task.command, kind, status: task.presetStatus, exitCode: null, summary: task.summary, output: "" };
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
let cmd = task.command;
|
|
47
|
+
let args = [];
|
|
48
|
+
if (Array.isArray(cmd)) {
|
|
49
|
+
const [c, ...a] = cmd;
|
|
50
|
+
cmd = c;
|
|
51
|
+
args = a;
|
|
52
|
+
} else if (typeof cmd === "string") {
|
|
53
|
+
const parts = cmd.split(/\s+/);
|
|
54
|
+
cmd = parts[0];
|
|
55
|
+
args = parts.slice(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isWin32 = process.platform === "win32";
|
|
59
|
+
const isUncPath = typeof this.cwd === "string" && (this.cwd.startsWith("\\\\") || this.cwd.startsWith("//"));
|
|
60
|
+
const shellOption = isWin32 && isUncPath ? "powershell.exe" : (isWin32 ? true : false);
|
|
61
|
+
|
|
62
|
+
const result = spawnSync(cmd, args, {
|
|
46
63
|
cwd: this.cwd,
|
|
47
|
-
shell:
|
|
64
|
+
shell: shellOption,
|
|
48
65
|
encoding: "utf8",
|
|
49
66
|
timeout: this.timeout
|
|
50
67
|
});
|
|
68
|
+
|
|
51
69
|
let status = result.status === 0 ? "PASS" : "FAIL";
|
|
70
|
+
if (result.error) {
|
|
71
|
+
status = "FAIL";
|
|
72
|
+
if (result.error.code === "ETIMEDOUT") {
|
|
73
|
+
status = "BLOCKED";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
let summary = status === "PASS" ? "Command completed successfully." : "Command failed.";
|
|
53
78
|
if (result.error?.code === "ETIMEDOUT") {
|
|
54
|
-
status = "BLOCKED";
|
|
55
79
|
summary = `Command timed out after ${this.timeout / 1000}s.`;
|
|
80
|
+
} else if (result.error) {
|
|
81
|
+
summary = `Command execution error: ${result.error.message}`;
|
|
56
82
|
}
|
|
83
|
+
|
|
57
84
|
const rawOutput = `${result.stdout || ""}${result.stderr || ""}`.trim();
|
|
58
85
|
const output = rawOutput.length > this.maxLogLength ? `${rawOutput.slice(0, this.maxLogLength)}\n... [TRUNCATED]` : rawOutput;
|
|
59
86
|
return { name: task.name, command: task.command, kind, status, exitCode: result.status, signal: result.signal, summary, output };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { MergeGate } from "../gates/merge-gate.js";
|
|
4
5
|
|
|
5
6
|
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
|
6
7
|
|
|
@@ -146,6 +147,13 @@ export class QualityGuard {
|
|
|
146
147
|
if (implementation && PROTECTED_BRANCHES.has(branch)) {
|
|
147
148
|
return { status: "FAIL_QUALITY_GATE", branch, reason: `Implementation changes are present on protected branch '${branch}'.` };
|
|
148
149
|
}
|
|
150
|
+
if (this.mode === "full") {
|
|
151
|
+
const mergeGate = new MergeGate({ cwd: this.cwd });
|
|
152
|
+
const mergeResult = mergeGate.check(branch);
|
|
153
|
+
if (mergeResult.status === "FAIL_QUALITY_GATE") {
|
|
154
|
+
return { status: "FAIL_QUALITY_GATE", branch, reason: mergeResult.reason };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
149
157
|
return { status: "PASS", branch };
|
|
150
158
|
}
|
|
151
159
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class StackDetector {
|
|
5
|
+
constructor({ cwd = process.cwd() } = {}) {
|
|
6
|
+
this.cwd = cwd;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async detect() {
|
|
10
|
+
const stacks = [];
|
|
11
|
+
|
|
12
|
+
// 1. Node check
|
|
13
|
+
const packageJsonExists = await fs.access(path.join(this.cwd, "package.json")).then(() => true).catch(() => false);
|
|
14
|
+
if (packageJsonExists) {
|
|
15
|
+
stacks.push("node");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Python check
|
|
19
|
+
const pyprojectExists = await fs.access(path.join(this.cwd, "pyproject.toml")).then(() => true).catch(() => false) ||
|
|
20
|
+
await fs.access(path.join(this.cwd, "requirements.txt")).then(() => true).catch(() => false) ||
|
|
21
|
+
await fs.access(path.join(this.cwd, "setup.py")).then(() => true).catch(() => false) ||
|
|
22
|
+
await fs.access(path.join(this.cwd, "Pipfile")).then(() => true).catch(() => false);
|
|
23
|
+
|
|
24
|
+
let hasPythonFiles = pyprojectExists;
|
|
25
|
+
if (!hasPythonFiles) {
|
|
26
|
+
hasPythonFiles = await this.hasFileWithExtension(this.cwd, ".py");
|
|
27
|
+
}
|
|
28
|
+
if (hasPythonFiles) {
|
|
29
|
+
stacks.push("python");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 3. PHP check
|
|
33
|
+
const composerExists = await fs.access(path.join(this.cwd, "composer.json")).then(() => true).catch(() => false);
|
|
34
|
+
let hasPhpFiles = composerExists;
|
|
35
|
+
if (!hasPhpFiles) {
|
|
36
|
+
hasPhpFiles = await this.hasFileWithExtension(this.cwd, ".php");
|
|
37
|
+
}
|
|
38
|
+
if (hasPhpFiles) {
|
|
39
|
+
stacks.push("php");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return stacks;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async hasFileWithExtension(dir, ext) {
|
|
46
|
+
try {
|
|
47
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
if (["node_modules", "vendor", ".git", "dist", "build", ".cache"].includes(entry.name)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const subPath = path.join(dir, entry.name);
|
|
54
|
+
const found = await this.hasFileWithExtension(subPath, ext);
|
|
55
|
+
if (found) return true;
|
|
56
|
+
} else if (entry.isFile() && entry.name.endsWith(ext)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { StackDetector } from "./stack-detector.js";
|
|
4
|
+
|
|
5
|
+
export class ValidationPlanner {
|
|
6
|
+
constructor({ cwd = process.cwd(), qualityGuard } = {}) {
|
|
7
|
+
this.cwd = cwd;
|
|
8
|
+
this.qualityGuard = qualityGuard;
|
|
9
|
+
this.stackDetector = new StackDetector({ cwd });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async plan(profile = "generic") {
|
|
13
|
+
const stacks = await this.stackDetector.detect();
|
|
14
|
+
const tasks = [];
|
|
15
|
+
|
|
16
|
+
const changedFiles = this.qualityGuard.getChangedFiles().filter((file) => {
|
|
17
|
+
return !/(^|\/)(node_modules|vendor|dist|coverage|\.cache)(\/|$)/.test(String(file).replaceAll("\\", "/"));
|
|
18
|
+
});
|
|
19
|
+
const executableBehavior = await this.qualityGuard.hasExecutableBehaviorChanges();
|
|
20
|
+
|
|
21
|
+
// Node Stack Planning
|
|
22
|
+
if (stacks.includes("node")) {
|
|
23
|
+
try {
|
|
24
|
+
const pkg = JSON.parse(await fs.readFile(path.join(this.cwd, "package.json"), "utf8"));
|
|
25
|
+
const scripts = pkg.scripts || {};
|
|
26
|
+
|
|
27
|
+
this.addNodeScriptTask(tasks, scripts, "lint", ["npm", "run", "lint"]);
|
|
28
|
+
this.addNodeScriptTask(tasks, scripts, "test", ["npm", "test"], { rejectNoOp: executableBehavior });
|
|
29
|
+
this.addNodeScriptTask(tasks, scripts, "build", ["npm", "run", "build"]);
|
|
30
|
+
this.addNodeScriptTask(tasks, scripts, "typecheck", ["npm", "run", "typecheck"]);
|
|
31
|
+
|
|
32
|
+
if (scripts.validate && !String(scripts.validate).includes("ai-workflow collect-evidence")) {
|
|
33
|
+
this.addNodeScriptTask(tasks, scripts, "validate", ["npm", "run", "validate"]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasTs = changedFiles.some((file) => /\.(ts|tsx)$/.test(file)) ||
|
|
37
|
+
await fs.access(path.join(this.cwd, "tsconfig.json")).then(() => true).catch(() => false);
|
|
38
|
+
if (hasTs && !scripts.typecheck) {
|
|
39
|
+
tasks.push({
|
|
40
|
+
name: "typecheck",
|
|
41
|
+
command: ["npm", "run", "typecheck"],
|
|
42
|
+
kind: "typecheck",
|
|
43
|
+
presetStatus: "FAIL_QUALITY_GATE",
|
|
44
|
+
summary: "TypeScript detected but no typecheck script is configured."
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Fallback or ignore if pkg read fails
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Python Stack Planning
|
|
53
|
+
if (stacks.includes("python")) {
|
|
54
|
+
const pythonFiles = changedFiles.filter(f => f.endsWith(".py"));
|
|
55
|
+
|
|
56
|
+
// Compiler syntax check for changed python files
|
|
57
|
+
if (pythonFiles.length > 0) {
|
|
58
|
+
tasks.push({
|
|
59
|
+
name: "python-syntax",
|
|
60
|
+
command: ["python", "-m", "py_compile", ...pythonFiles],
|
|
61
|
+
kind: "lint"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if testing is needed
|
|
66
|
+
if (executableBehavior) {
|
|
67
|
+
// Check if pytest or unittest is configured
|
|
68
|
+
const hasPytestConfig = await fs.access(path.join(this.cwd, "pytest.ini")).then(() => true).catch(() => false) ||
|
|
69
|
+
await fs.access(path.join(this.cwd, "pyproject.toml")).then(() => true).catch(() => false);
|
|
70
|
+
|
|
71
|
+
if (hasPytestConfig || pythonFiles.length > 0) {
|
|
72
|
+
tasks.push({
|
|
73
|
+
name: "python-test",
|
|
74
|
+
command: ["pytest"],
|
|
75
|
+
kind: "test"
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// PHP Stack Planning
|
|
82
|
+
if (stacks.includes("php")) {
|
|
83
|
+
const phpFiles = changedFiles.filter(f => f.endsWith(".php") || f.endsWith(".inc"));
|
|
84
|
+
|
|
85
|
+
// Compiler syntax check for each changed PHP file - split into separate tasks
|
|
86
|
+
if (phpFiles.length > 0) {
|
|
87
|
+
for (const file of phpFiles) {
|
|
88
|
+
tasks.push({
|
|
89
|
+
name: `php-syntax:${file}`,
|
|
90
|
+
command: ["php", "-l", file],
|
|
91
|
+
kind: "lint"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check if testing is needed
|
|
97
|
+
if (executableBehavior) {
|
|
98
|
+
const hasPhpUnitXml = await fs.access(path.join(this.cwd, "phpunit.xml")).then(() => true).catch(() => false) ||
|
|
99
|
+
await fs.access(path.join(this.cwd, "phpunit.xml.dist")).then(() => true).catch(() => false);
|
|
100
|
+
if (hasPhpUnitXml || phpFiles.length > 0) {
|
|
101
|
+
tasks.push({
|
|
102
|
+
name: "php-test",
|
|
103
|
+
command: ["vendor/bin/phpunit"],
|
|
104
|
+
kind: "test"
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return tasks;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
addNodeScriptTask(tasks, scripts, name, command, { rejectNoOp = false } = {}) {
|
|
114
|
+
const script = scripts[name];
|
|
115
|
+
if (!script) return;
|
|
116
|
+
if (!rejectNoOp && name === "test" && this.isNoOpScript(script)) return;
|
|
117
|
+
if (rejectNoOp && this.isNoOpScript(script)) {
|
|
118
|
+
tasks.push({
|
|
119
|
+
name,
|
|
120
|
+
command,
|
|
121
|
+
kind: name === "test" ? "test" : name,
|
|
122
|
+
presetStatus: "FAIL_QUALITY_GATE",
|
|
123
|
+
summary: `Configured ${name} script is a no-op: ${script}`
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
tasks.push({ name, command, kind: name === "test" ? "test" : name });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
isNoOpScript(script = "") {
|
|
131
|
+
const value = String(script).trim().toLowerCase();
|
|
132
|
+
return /^(echo\b|true$|exit\s+0$|node\s+-e\s+["']?console\.log)/.test(value) || value.includes("tests-pass");
|
|
133
|
+
}
|
|
134
|
+
}
|