ai-maestro 1.0.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 +11 -0
- package/LICENSE +15 -0
- package/README.md +727 -0
- package/bin/maestro.mjs +246 -0
- package/package.json +29 -0
- package/src/checkpoint.mjs +102 -0
- package/src/codex-diagnostics.mjs +682 -0
- package/src/codex-home-inspect.mjs +252 -0
- package/src/codex-home.mjs +258 -0
- package/src/commands.mjs +809 -0
- package/src/config.mjs +11 -0
- package/src/debug.mjs +164 -0
- package/src/encoding-guard.mjs +125 -0
- package/src/engines/ai-router-engine.mjs +37 -0
- package/src/engines/codex-engine.mjs +21 -0
- package/src/engines/engine.mjs +21 -0
- package/src/engines/registry.mjs +29 -0
- package/src/files.mjs +65 -0
- package/src/format.mjs +132 -0
- package/src/lock.mjs +439 -0
- package/src/memory-log.mjs +18 -0
- package/src/memory.mjs +1 -0
- package/src/orchestration/attempt-chain-runtime.mjs +627 -0
- package/src/orchestration/budget-manager.mjs +121 -0
- package/src/orchestration/capability-registry.mjs +108 -0
- package/src/orchestration/consolidator.mjs +772 -0
- package/src/orchestration/delegation-executor.mjs +262 -0
- package/src/orchestration/delegation-manager.mjs +116 -0
- package/src/orchestration/deployment-intent.mjs +36 -0
- package/src/orchestration/engine-history.mjs +110 -0
- package/src/orchestration/engine-policy.mjs +73 -0
- package/src/orchestration/engine-selector.mjs +187 -0
- package/src/orchestration/execution-context.mjs +45 -0
- package/src/orchestration/failure-classifier.mjs +136 -0
- package/src/orchestration/failure-evidence.mjs +237 -0
- package/src/orchestration/fallback-chain-lock.mjs +217 -0
- package/src/orchestration/fallback-chain-trail.mjs +218 -0
- package/src/orchestration/fallback-executor.mjs +146 -0
- package/src/orchestration/fallback-graph.mjs +106 -0
- package/src/orchestration/fallback-recommendation.mjs +73 -0
- package/src/orchestration/file-conflict-detector.mjs +126 -0
- package/src/orchestration/host-validation.mjs +241 -0
- package/src/orchestration/orchestration-loop.mjs +1971 -0
- package/src/orchestration/orchestration-runtime.mjs +1019 -0
- package/src/orchestration/orchestration-scheduler.mjs +135 -0
- package/src/orchestration/orchestration-trail.mjs +192 -0
- package/src/orchestration/preflight.mjs +212 -0
- package/src/orchestration/provider-health.mjs +566 -0
- package/src/orchestration/provider-router.mjs +352 -0
- package/src/orchestration/rc-check-adapters.mjs +817 -0
- package/src/orchestration/rc-check.mjs +544 -0
- package/src/orchestration/rc-policy.mjs +200 -0
- package/src/orchestration/runtime-gate.mjs +146 -0
- package/src/orchestration/sector-managers.mjs +215 -0
- package/src/orchestration/self-hosting-canary.mjs +231 -0
- package/src/orchestration/self-hosting-readiness.mjs +244 -0
- package/src/orchestration/spec-planner.mjs +877 -0
- package/src/orchestration/subtask-planner.mjs +176 -0
- package/src/orchestration/task-classifier.mjs +241 -0
- package/src/orchestration/verifier.mjs +1608 -0
- package/src/orchestration-commands.mjs +279 -0
- package/src/planner.mjs +38 -0
- package/src/project.mjs +1 -0
- package/src/run-diagnostics.mjs +641 -0
- package/src/runner.mjs +243 -0
- package/src/safety.mjs +116 -0
- package/src/schema.mjs +371 -0
- package/src/session-commands.mjs +521 -0
- package/src/shell.mjs +93 -0
- package/src/smart-planner.mjs +249 -0
- package/src/task-commands.mjs +1182 -0
- package/src/tasks.mjs +134 -0
- package/src/ui/commands.mjs +76 -0
- package/src/ui/events.mjs +45 -0
- package/src/ui/public/app.js +600 -0
- package/src/ui/public/index.html +88 -0
- package/src/ui/public/style.css +460 -0
- package/src/ui/server.mjs +112 -0
- package/src/ui/state.mjs +504 -0
- package/src/usage.mjs +178 -0
- package/src/workspace-diff.mjs +228 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execFileRaw } from './shell.mjs';
|
|
4
|
+
import { stripAccents } from './smart-planner.mjs';
|
|
5
|
+
import { stripBom } from './files.mjs';
|
|
6
|
+
|
|
7
|
+
const ANALYSIS_ONLY_ACCEPTANCE_PATTERN = /nao alterar codigo/;
|
|
8
|
+
const ANALYSIS_ONLY_KEYWORD_PATTERN = /\b(mapear|analisar|identificar|localizar|diagnosticar|listar|relatorio)\b|nao alterar|sem alterar/;
|
|
9
|
+
|
|
10
|
+
export function isAnalysisOnlyTask(task) {
|
|
11
|
+
if (!task) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (task.requestKind === 'analysis-only') {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const acceptanceText = stripAccents((task.acceptance || []).join(' ').toLowerCase());
|
|
18
|
+
if (ANALYSIS_ONLY_ACCEPTANCE_PATTERN.test(acceptanceText)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const text = stripAccents(((task.title || '') + ' ' + (task.description || '')).toLowerCase());
|
|
22
|
+
return ANALYSIS_ONLY_KEYWORD_PATTERN.test(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const UNSUPPORTED_TOOL_PATTERNS = [
|
|
26
|
+
/unsupported call: apply_patch/i,
|
|
27
|
+
/cannot update goal because this thread has no goal/i,
|
|
28
|
+
/error=unsupported call/i,
|
|
29
|
+
// Codex's internal plan tool rejecting a status Maestro uses internally,
|
|
30
|
+
// e.g. "unknown variant `blocked`, expected one of `pending`,
|
|
31
|
+
// `in_progress`, `completed`". Maestro statuses must be mapped at the
|
|
32
|
+
// boundary (see mapStatusToCodexPlanVariant in task-commands.mjs); when
|
|
33
|
+
// this error still shows up, it must at least be flagged.
|
|
34
|
+
/unknown variant/i,
|
|
35
|
+
/ParserError/,
|
|
36
|
+
/MissingExpressionAfterOperator/
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function detectUnsupportedToolCall(output) {
|
|
40
|
+
const text = String(output || '');
|
|
41
|
+
return UNSUPPORTED_TOOL_PATTERNS.some(pattern => pattern.test(text));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CLAIM_PATTERN = /\b(updated|created|modified|alterado|atualizado|criado|completed|done)\b/i;
|
|
45
|
+
|
|
46
|
+
// Phrases where the worker states, in prose, that it did NOT or could NOT
|
|
47
|
+
// complete the task -- independent of exit code. A worker's own CLI process
|
|
48
|
+
// can exit 0 (it didn't crash) while its final answer plainly says it
|
|
49
|
+
// failed; that must never read as `completed`. Kept narrow and anchored to
|
|
50
|
+
// completion/action verbs so we don't flag unrelated uses of "failure",
|
|
51
|
+
// "falha" or "não foi possível" (e.g. "não foi possível reproduzir o bug",
|
|
52
|
+
// "a falha esperada no teste", "corrigi o erro que fazia o processo
|
|
53
|
+
// falhar") -- see tests for the exact false-positive cases this must avoid.
|
|
54
|
+
const EXPLICIT_FAILURE_PATTERNS = [
|
|
55
|
+
/\bnao consegui\b/i,
|
|
56
|
+
/\bnao foi possivel (conclu\w*|criar|executar|rodar|completar|finalizar|implementar|acessar|modificar|alterar)\b/i,
|
|
57
|
+
/\bfalhei ao\b/i,
|
|
58
|
+
/\ba implementacao nao foi realizada\b/i,
|
|
59
|
+
/\bunable to complete\b/i,
|
|
60
|
+
/\bcould not complete\b/i,
|
|
61
|
+
/\bfailed to implement\b/i,
|
|
62
|
+
/\bi was unable to\b/i,
|
|
63
|
+
/\bno changes were made\b/i,
|
|
64
|
+
/\bcould not access the filesystem\b/i,
|
|
65
|
+
/\bpermission denied\b/i,
|
|
66
|
+
/\bsandbox acl\b/i,
|
|
67
|
+
/\bworker failed\b/i,
|
|
68
|
+
/\bcould not (run|modify|create)\b/i,
|
|
69
|
+
// Hotfix A.2: the Windows-sandbox ACL failure and the way workers phrase
|
|
70
|
+
// it. "I cannot read/write ... files" is anchored to the first person so
|
|
71
|
+
// prose about a bug ("the parser cannot read files with BOM") does not
|
|
72
|
+
// trip it.
|
|
73
|
+
/\bi am blocked\b/i,
|
|
74
|
+
/\bi cannot (read|write|access|modify|create)\b/i,
|
|
75
|
+
/\bhelper_unknown_error\b/i,
|
|
76
|
+
/apply deny-read acls/i
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function splitSentences(text) {
|
|
80
|
+
const parts = String(text || '')
|
|
81
|
+
.split(/(?<=[.!?\n])\s+|\r?\n+/)
|
|
82
|
+
.map(part => part.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
return parts.length ? parts : [String(text || '')];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function detectExplicitFailure(output) {
|
|
88
|
+
const evidence = [];
|
|
89
|
+
for (const sentence of splitSentences(output)) {
|
|
90
|
+
const normalized = stripAccents(sentence.toLowerCase());
|
|
91
|
+
if (EXPLICIT_FAILURE_PATTERNS.some(pattern => pattern.test(normalized))) {
|
|
92
|
+
evidence.push(sentence);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { explicitWorkerFailure: evidence.length > 0, explicitFailureEvidence: evidence };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildRunDiagnostics({ output, unsupportedToolCall, workspaceChanged, noChangeExpected = false }) {
|
|
99
|
+
const text = String(output || '');
|
|
100
|
+
const warnings = [];
|
|
101
|
+
if (unsupportedToolCall) {
|
|
102
|
+
warnings.push('worker used an unsupported tool call (e.g. apply_patch)');
|
|
103
|
+
}
|
|
104
|
+
const claimsSuccess = CLAIM_PATTERN.test(text);
|
|
105
|
+
// For an analysis-only or memory/documentation task, no workspace change is
|
|
106
|
+
// the expected, correct outcome, not a sign the worker is lying: analysis
|
|
107
|
+
// tasks aren't supposed to touch code, and memory-task success normally
|
|
108
|
+
// lives entirely inside .memory/, which is excluded from the workspace
|
|
109
|
+
// diff by design. Only flag it when the worker also used a tool that's
|
|
110
|
+
// known to fail.
|
|
111
|
+
const noVerifiedChange = noChangeExpected ? false : !workspaceChanged;
|
|
112
|
+
const suspectedFalsePositive = claimsSuccess && (unsupportedToolCall || noVerifiedChange);
|
|
113
|
+
if (suspectedFalsePositive) {
|
|
114
|
+
warnings.push('worker claimed success but the change could not be verified');
|
|
115
|
+
}
|
|
116
|
+
const { explicitWorkerFailure, explicitFailureEvidence } = detectExplicitFailure(text);
|
|
117
|
+
if (explicitWorkerFailure) {
|
|
118
|
+
warnings.push('worker explicitly reported it could not complete the task: ' + explicitFailureEvidence[0]);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
warnings,
|
|
122
|
+
unsupportedToolCall: !!unsupportedToolCall,
|
|
123
|
+
suspectedFalsePositive,
|
|
124
|
+
explicitWorkerFailure,
|
|
125
|
+
explicitFailureEvidence
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// These match the actual PowerShell error text produced when a Unix
|
|
130
|
+
// command is really executed. Generic patterns like "cat >" or "<< EOF"
|
|
131
|
+
// are deliberately excluded: they also appear verbatim in our own worker
|
|
132
|
+
// prompt (as examples of what NOT to do), which Codex echoes back into
|
|
133
|
+
// its transcript, so matching them there flags the prompt itself instead
|
|
134
|
+
// of anything the worker actually ran.
|
|
135
|
+
const WINDOWS_SHELL_MISMATCH_PATTERNS = [
|
|
136
|
+
/não é possível localizar um parâmetro que coincida com o nome de parâmetro 'la'/i,
|
|
137
|
+
/tail\s*:\s*O termo 'tail' não é reconhecido/i,
|
|
138
|
+
/Operador '<' reservado para uso futuro/i,
|
|
139
|
+
/Especificação de arquivo ausente após o operador de redirecionamento/i
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
export function detectWindowsShellMismatch(output) {
|
|
143
|
+
const text = String(output || '');
|
|
144
|
+
return WINDOWS_SHELL_MISMATCH_PATTERNS.some(pattern => pattern.test(text));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function checkNodeSyntax(filePath) {
|
|
148
|
+
const result = await execFileRaw(process.execPath, ['--check', filePath], { quiet: true });
|
|
149
|
+
return result.code === 0 ? { ok: true } : { ok: false, error: summarizeError(result.stderr) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function checkJsonSyntax(filePath) {
|
|
153
|
+
try {
|
|
154
|
+
JSON.parse(stripBom(await fs.readFile(filePath, 'utf-8')));
|
|
155
|
+
return { ok: true };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { ok: false, error: error.message };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function checkPythonSyntax(filePath) {
|
|
162
|
+
try {
|
|
163
|
+
const result = await execFileRaw('python', ['-m', 'py_compile', filePath], { quiet: true });
|
|
164
|
+
return result.code === 0 ? { ok: true } : { ok: false, error: summarizeError(result.stderr) };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error.code === 'ENOENT') {
|
|
167
|
+
return { ok: true, skipped: true, error: 'python not available' };
|
|
168
|
+
}
|
|
169
|
+
return { ok: false, error: error.message };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function checkNonEmptyFile(filePath) {
|
|
174
|
+
try {
|
|
175
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
176
|
+
return content.trim().length > 0 ? { ok: true } : { ok: false, error: 'file is empty' };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return { ok: false, error: error.message };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function summarizeError(text) {
|
|
183
|
+
return String(text || '').split(/\r?\n/).filter(Boolean).slice(0, 3).join(' | ');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function validateChangedFile(relPath) {
|
|
187
|
+
const fullPath = path.resolve(relPath);
|
|
188
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
189
|
+
let result;
|
|
190
|
+
if (ext === '.js' || ext === '.mjs') {
|
|
191
|
+
result = await checkNodeSyntax(fullPath);
|
|
192
|
+
} else if (ext === '.json') {
|
|
193
|
+
result = await checkJsonSyntax(fullPath);
|
|
194
|
+
} else if (ext === '.py') {
|
|
195
|
+
result = await checkPythonSyntax(fullPath);
|
|
196
|
+
} else if (ext === '.html' || ext === '.css') {
|
|
197
|
+
result = await checkNonEmptyFile(fullPath);
|
|
198
|
+
} else {
|
|
199
|
+
result = { ok: true, skipped: true };
|
|
200
|
+
}
|
|
201
|
+
return { path: relPath, type: ext || 'unknown', ...result };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function validateChangedFiles(filePaths = []) {
|
|
205
|
+
const syntaxValidation = await Promise.all(filePaths.map(validateChangedFile));
|
|
206
|
+
const syntaxValidationFailed = syntaxValidation.some(entry => entry.ok === false);
|
|
207
|
+
return { syntaxValidation, syntaxValidationFailed };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeRelPath(value) {
|
|
211
|
+
return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').trim();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isExplicitFilePath(value) {
|
|
215
|
+
const normalized = normalizeRelPath(value);
|
|
216
|
+
return Boolean(normalized) && !normalized.endsWith('/') && path.extname(normalized).length > 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isSafeRelativePath(value) {
|
|
220
|
+
const normalized = normalizeRelPath(value);
|
|
221
|
+
return Boolean(normalized) &&
|
|
222
|
+
!path.isAbsolute(normalized) &&
|
|
223
|
+
!normalized.split('/').includes('..') &&
|
|
224
|
+
!normalized.includes('\0');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isMissingPathError(error) {
|
|
228
|
+
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function containmentPath(value) {
|
|
232
|
+
const resolved = path.resolve(value);
|
|
233
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isContainedPath(root, target) {
|
|
237
|
+
const relative = path.relative(containmentPath(root), containmentPath(target));
|
|
238
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function realPathForPossiblyMissingTarget(absolutePath) {
|
|
242
|
+
const targetPath = path.resolve(absolutePath);
|
|
243
|
+
let existingPath = targetPath;
|
|
244
|
+
|
|
245
|
+
while (true) {
|
|
246
|
+
try {
|
|
247
|
+
const realAncestor = await fs.realpath(existingPath);
|
|
248
|
+
const missingTail = path.relative(existingPath, targetPath);
|
|
249
|
+
if (missingTail && (missingTail.startsWith('..') || path.isAbsolute(missingTail))) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return missingTail ? path.resolve(realAncestor, missingTail) : realAncestor;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (!isMissingPathError(error)) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const parent = path.dirname(existingPath);
|
|
258
|
+
if (parent === existingPath) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
existingPath = parent;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function realContained(projectRoot, absolutePath) {
|
|
267
|
+
const logicalRoot = path.resolve(projectRoot);
|
|
268
|
+
const logicalTarget = path.resolve(absolutePath);
|
|
269
|
+
if (!isContainedPath(logicalRoot, logicalTarget)) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let realRoot;
|
|
274
|
+
try {
|
|
275
|
+
realRoot = await fs.realpath(logicalRoot);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let realTarget;
|
|
281
|
+
try {
|
|
282
|
+
realTarget = await fs.realpath(logicalTarget);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (!isMissingPathError(error)) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
realTarget = await realPathForPossiblyMissingTarget(logicalTarget);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return Boolean(realTarget) && isContainedPath(realRoot, realTarget);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function uniqueSorted(values) {
|
|
294
|
+
return [...new Set((values || []).filter(Boolean).map(String))].sort();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeTaskText(task) {
|
|
298
|
+
return stripAccents([
|
|
299
|
+
task && task.title,
|
|
300
|
+
task && task.description,
|
|
301
|
+
...(Array.isArray(task && task.acceptance) ? task.acceptance : [])
|
|
302
|
+
].filter(Boolean).join('\n').toLowerCase());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function requiredExportsFromTask(task) {
|
|
306
|
+
const exports = [];
|
|
307
|
+
const acceptance = Array.isArray(task && task.acceptance) ? task.acceptance : [];
|
|
308
|
+
for (const item of acceptance) {
|
|
309
|
+
const match = String(item || '').match(/\bExportar\s+([A-Za-z_$][\w$]*)\b/i);
|
|
310
|
+
if (match) exports.push(match[1]);
|
|
311
|
+
}
|
|
312
|
+
return exports;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function requiredExportsFromSourceSpec(task, projectRoot) {
|
|
316
|
+
if (!task || !isSafeRelativePath(task.sourceSpec || '')) return [];
|
|
317
|
+
try {
|
|
318
|
+
const specPath = path.resolve(projectRoot, normalizeRelPath(task.sourceSpec));
|
|
319
|
+
const relative = path.relative(projectRoot, specPath);
|
|
320
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return [];
|
|
321
|
+
if (!await realContained(projectRoot, specPath)) return [];
|
|
322
|
+
const content = await fs.readFile(specPath, 'utf-8');
|
|
323
|
+
const exports = [];
|
|
324
|
+
const pattern = /^\s*export\s+function\s+([A-Za-z_$][\w$]*)\s*\(/gm;
|
|
325
|
+
let match;
|
|
326
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
327
|
+
exports.push(match[1]);
|
|
328
|
+
}
|
|
329
|
+
return exports;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function hasExport(content, exportedName) {
|
|
336
|
+
const escaped = exportedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
337
|
+
return new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${escaped}\\s*\\(`).test(content) ||
|
|
338
|
+
new RegExp(`\\bexport\\s+(?:const|let|var)\\s+${escaped}\\b`).test(content) ||
|
|
339
|
+
new RegExp(`\\bexport\\s*\\{[^}]*\\b${escaped}\\b[^}]*\\}`).test(content);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isTestFile(file) {
|
|
343
|
+
return /(\.test|\.spec)\.[cm]?[jt]s$/i.test(file) || file.startsWith('tests/');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function placeholderReasons(content) {
|
|
347
|
+
const normalized = stripAccents(String(content || '').toLowerCase());
|
|
348
|
+
const reasons = [];
|
|
349
|
+
const patterns = [
|
|
350
|
+
[/\bplaceholder\b/, 'placeholder marker present'],
|
|
351
|
+
[/\btodo\b/, 'TODO marker present'],
|
|
352
|
+
[/\bstub\b/, 'stub marker present'],
|
|
353
|
+
[/\bnot implemented\b/, 'not implemented marker present'],
|
|
354
|
+
[/\bimplementacao pendente\b/, 'pending implementation marker present'],
|
|
355
|
+
[/\bestrutura inicial\b/, 'initial structure marker present']
|
|
356
|
+
];
|
|
357
|
+
for (const [pattern, reason] of patterns) {
|
|
358
|
+
if (pattern.test(normalized)) reasons.push(reason);
|
|
359
|
+
}
|
|
360
|
+
return reasons;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function countTestCalls(content) {
|
|
364
|
+
const matches = String(content || '').match(/\b(?:test|it)\s*\(/g);
|
|
365
|
+
return matches ? matches.length : 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function inspectFileContent({ file, content, taskText }) {
|
|
369
|
+
const failures = [];
|
|
370
|
+
const warnings = [];
|
|
371
|
+
const ext = path.extname(file).toLowerCase();
|
|
372
|
+
const trimmed = String(content || '').trim();
|
|
373
|
+
if (!trimmed) {
|
|
374
|
+
failures.push(`expected file is empty: ${file}`);
|
|
375
|
+
return { failures, warnings };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const reason of placeholderReasons(content)) {
|
|
379
|
+
failures.push(`${reason}: ${file}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if ((taskText.includes('date.now') || taskText.includes('timestamp') || taskText.includes('deterministic')) &&
|
|
383
|
+
/\b(Date\.now|new Date)\s*\(/.test(content)) {
|
|
384
|
+
failures.push(`non-deterministic clock usage in expected file: ${file}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (ext === '.mjs' || ext === '.js') {
|
|
388
|
+
if (!/\bexport\b/.test(content) && !/\bimport\b/.test(content)) {
|
|
389
|
+
warnings.push(`module has no import/export evidence: ${file}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isTestFile(file)) {
|
|
394
|
+
if (!/\bnode:test\b/.test(content) && countTestCalls(content) === 0) {
|
|
395
|
+
failures.push(`test file has no executable test cases: ${file}`);
|
|
396
|
+
}
|
|
397
|
+
if (!/\bassert\b/.test(content) && !/\bexpect\s*\(/.test(content)) {
|
|
398
|
+
failures.push(`test file has no assertions: ${file}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { failures, warnings };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function verifyDeliverableEvidence({
|
|
406
|
+
task,
|
|
407
|
+
projectRoot = process.cwd(),
|
|
408
|
+
changedFiles = [],
|
|
409
|
+
createdFiles = [],
|
|
410
|
+
deletedFiles = [],
|
|
411
|
+
outOfScopeChanges = [],
|
|
412
|
+
syntaxValidation = [],
|
|
413
|
+
hostValidationFallback = null,
|
|
414
|
+
noChangeExpected = false
|
|
415
|
+
} = {}) {
|
|
416
|
+
const expectedFiles = uniqueSorted((task && Array.isArray(task.files) ? task.files : [])
|
|
417
|
+
.map(normalizeRelPath)
|
|
418
|
+
.filter(isExplicitFilePath));
|
|
419
|
+
const changedSet = new Set(uniqueSorted([...changedFiles, ...createdFiles].map(normalizeRelPath)));
|
|
420
|
+
const deletedSet = new Set(uniqueSorted(deletedFiles.map(normalizeRelPath)));
|
|
421
|
+
const failures = [];
|
|
422
|
+
const warnings = [];
|
|
423
|
+
const acceptedEvidence = [];
|
|
424
|
+
const missingEvidence = [];
|
|
425
|
+
const readableContents = [];
|
|
426
|
+
const taskText = normalizeTaskText(task);
|
|
427
|
+
|
|
428
|
+
for (const file of uniqueSorted(outOfScopeChanges.map(normalizeRelPath))) {
|
|
429
|
+
failures.push(`out-of-scope change detected: ${file}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (isAnalysisOnlyTask(task)) {
|
|
433
|
+
const outcome = failures.length > 0 ? 'rejected' : 'not_applicable';
|
|
434
|
+
return {
|
|
435
|
+
schemaVersion: 1,
|
|
436
|
+
outcome,
|
|
437
|
+
required: failures.length > 0,
|
|
438
|
+
expectedFiles,
|
|
439
|
+
requiredExports: [],
|
|
440
|
+
failures: uniqueSorted(failures),
|
|
441
|
+
warnings: uniqueSorted(warnings),
|
|
442
|
+
acceptedEvidence,
|
|
443
|
+
missingEvidence,
|
|
444
|
+
source: 'deliverable-verification'
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (expectedFiles.length === 0) {
|
|
449
|
+
if (noChangeExpected) {
|
|
450
|
+
const outcome = failures.length > 0 ? 'rejected' : 'not_applicable';
|
|
451
|
+
return {
|
|
452
|
+
schemaVersion: 1,
|
|
453
|
+
outcome,
|
|
454
|
+
required: failures.length > 0,
|
|
455
|
+
failures: uniqueSorted(failures),
|
|
456
|
+
warnings: uniqueSorted(warnings),
|
|
457
|
+
acceptedEvidence,
|
|
458
|
+
missingEvidence,
|
|
459
|
+
source: 'deliverable-verification'
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const evidenceFiles = uniqueSorted([...changedFiles, ...createdFiles].map(normalizeRelPath).filter(isExplicitFilePath));
|
|
464
|
+
if (evidenceFiles.length === 0) {
|
|
465
|
+
missingEvidence.push('task declares no expected files and no workspace evidence was found');
|
|
466
|
+
if (hostValidationFallback && hostValidationFallback.used && hostValidationFallback.result === 'passed') {
|
|
467
|
+
warnings.push('host validation passed but did not provide workspace evidence for expected files');
|
|
468
|
+
}
|
|
469
|
+
const outcome = failures.length > 0 ? 'rejected' : 'inconclusive';
|
|
470
|
+
return {
|
|
471
|
+
schemaVersion: 1,
|
|
472
|
+
outcome,
|
|
473
|
+
required: true,
|
|
474
|
+
failures: uniqueSorted(failures),
|
|
475
|
+
warnings: uniqueSorted(warnings),
|
|
476
|
+
acceptedEvidence,
|
|
477
|
+
missingEvidence: uniqueSorted(missingEvidence),
|
|
478
|
+
source: 'deliverable-verification'
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const file of evidenceFiles) {
|
|
483
|
+
if (!isSafeRelativePath(file)) {
|
|
484
|
+
failures.push(`unsafe workspace evidence path: ${file}`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (deletedSet.has(file)) {
|
|
488
|
+
failures.push(`workspace evidence file was deleted: ${file}`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const absolutePath = path.resolve(projectRoot, file);
|
|
493
|
+
const relative = path.relative(projectRoot, absolutePath);
|
|
494
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
495
|
+
failures.push(`workspace evidence resolves outside workspace: ${file}`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (!await realContained(projectRoot, absolutePath)) {
|
|
499
|
+
failures.push(`workspace evidence resolves outside workspace: ${file}`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const stat = await fs.stat(absolutePath);
|
|
505
|
+
if (!stat.isFile()) {
|
|
506
|
+
failures.push(`workspace evidence path is not a file: ${file}`);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (stat.size === 0) {
|
|
510
|
+
failures.push(`expected file is empty: ${file}`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
514
|
+
const inspected = inspectFileContent({ file, content, taskText });
|
|
515
|
+
failures.push(...inspected.failures);
|
|
516
|
+
warnings.push(...inspected.warnings);
|
|
517
|
+
acceptedEvidence.push(`file:${file}:size:${stat.size}`);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
failures.push(`workspace evidence file cannot be read: ${file}: ${error.message}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for (const entry of Array.isArray(syntaxValidation) ? syntaxValidation : []) {
|
|
524
|
+
if (entry && entry.ok === false && evidenceFiles.includes(normalizeRelPath(entry.path || entry.file))) {
|
|
525
|
+
failures.push(`syntax validation failed for workspace evidence file: ${normalizeRelPath(entry.path || entry.file)}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let outcome = 'verified';
|
|
530
|
+
if (failures.length > 0) {
|
|
531
|
+
outcome = 'rejected';
|
|
532
|
+
} else if (warnings.length > 0) {
|
|
533
|
+
outcome = 'verified_with_warnings';
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
schemaVersion: 1,
|
|
538
|
+
outcome,
|
|
539
|
+
required: true,
|
|
540
|
+
failures: uniqueSorted(failures),
|
|
541
|
+
warnings: uniqueSorted(warnings),
|
|
542
|
+
acceptedEvidence: uniqueSorted(acceptedEvidence),
|
|
543
|
+
missingEvidence: uniqueSorted(missingEvidence),
|
|
544
|
+
source: 'deliverable-verification'
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const requiredExports = uniqueSorted([
|
|
549
|
+
...requiredExportsFromTask(task),
|
|
550
|
+
...await requiredExportsFromSourceSpec(task, projectRoot)
|
|
551
|
+
]);
|
|
552
|
+
|
|
553
|
+
for (const file of expectedFiles) {
|
|
554
|
+
if (!isSafeRelativePath(file)) {
|
|
555
|
+
failures.push(`unsafe expected file path: ${file}`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (deletedSet.has(file)) {
|
|
559
|
+
failures.push(`expected file was deleted: ${file}`);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (!changedSet.has(file)) {
|
|
563
|
+
missingEvidence.push(`expected file has no workspace evidence: ${file}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const absolutePath = path.resolve(projectRoot, file);
|
|
567
|
+
const relative = path.relative(projectRoot, absolutePath);
|
|
568
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
569
|
+
failures.push(`expected file resolves outside workspace: ${file}`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!await realContained(projectRoot, absolutePath)) {
|
|
573
|
+
failures.push(`expected file resolves outside workspace: ${file}`);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const stat = await fs.stat(absolutePath);
|
|
579
|
+
if (!stat.isFile()) {
|
|
580
|
+
failures.push(`expected path is not a file: ${file}`);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (stat.size === 0) {
|
|
584
|
+
failures.push(`expected file is empty: ${file}`);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
588
|
+
readableContents.push({ file, content });
|
|
589
|
+
const inspected = inspectFileContent({ file, content, taskText });
|
|
590
|
+
failures.push(...inspected.failures);
|
|
591
|
+
warnings.push(...inspected.warnings);
|
|
592
|
+
acceptedEvidence.push(`file:${file}:size:${stat.size}`);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
failures.push(`expected file cannot be read: ${file}: ${error.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const exportCandidateFiles = expectedFiles.filter(file =>
|
|
599
|
+
['.mjs', '.js'].includes(path.extname(file).toLowerCase()) && !isTestFile(file)
|
|
600
|
+
);
|
|
601
|
+
if (exportCandidateFiles.length > 0) {
|
|
602
|
+
const exportCandidateContents = readableContents.filter(entry => exportCandidateFiles.includes(entry.file));
|
|
603
|
+
for (const exportedName of requiredExports) {
|
|
604
|
+
if (!exportCandidateContents.some(entry => hasExport(entry.content, exportedName))) {
|
|
605
|
+
failures.push(`required export missing in expected implementation files: ${exportedName}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
for (const entry of Array.isArray(syntaxValidation) ? syntaxValidation : []) {
|
|
611
|
+
if (entry && entry.ok === false && expectedFiles.includes(normalizeRelPath(entry.path || entry.file))) {
|
|
612
|
+
failures.push(`syntax validation failed for expected file: ${normalizeRelPath(entry.path || entry.file)}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (hostValidationFallback && hostValidationFallback.used && hostValidationFallback.result === 'passed' && missingEvidence.length > 0) {
|
|
617
|
+
warnings.push('host validation passed but did not provide workspace evidence for expected files');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let outcome = 'verified';
|
|
621
|
+
if (failures.length > 0) {
|
|
622
|
+
outcome = 'rejected';
|
|
623
|
+
} else if (missingEvidence.length > 0) {
|
|
624
|
+
outcome = 'inconclusive';
|
|
625
|
+
} else if (warnings.length > 0) {
|
|
626
|
+
outcome = 'verified_with_warnings';
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
schemaVersion: 1,
|
|
631
|
+
outcome,
|
|
632
|
+
required: true,
|
|
633
|
+
expectedFiles,
|
|
634
|
+
requiredExports,
|
|
635
|
+
failures: uniqueSorted(failures),
|
|
636
|
+
warnings: uniqueSorted(warnings),
|
|
637
|
+
acceptedEvidence: uniqueSorted(acceptedEvidence),
|
|
638
|
+
missingEvidence: uniqueSorted(missingEvidence),
|
|
639
|
+
source: 'deliverable-verification'
|
|
640
|
+
};
|
|
641
|
+
}
|