synergyspec-selfevolving 2.1.5 → 2.1.6
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/dist/commands/learn.js +80 -24
- package/dist/commands/self-evolution-dream.d.ts +15 -1
- package/dist/commands/self-evolution-dream.js +111 -6
- package/dist/commands/self-evolution-episode.d.ts +3 -0
- package/dist/commands/self-evolution-episode.js +157 -108
- package/dist/commands/workflow/status.js +4 -0
- package/dist/core/archive.js +17 -9
- package/dist/core/change-readiness.d.ts +16 -1
- package/dist/core/change-readiness.js +441 -15
- package/dist/core/fitness/loss.d.ts +3 -5
- package/dist/core/fitness/loss.js +2 -2
- package/dist/core/fitness/test-metrics.d.ts +1 -0
- package/dist/core/fitness/test-metrics.js +49 -0
- package/dist/core/learn.js +129 -11
- package/dist/core/migration.d.ts +6 -14
- package/dist/core/migration.js +63 -21
- package/dist/core/runner-evidence.d.ts +53 -0
- package/dist/core/runner-evidence.js +613 -0
- package/dist/core/self-evolution/candidates.js +0 -2
- package/dist/core/self-evolution/dream.d.ts +57 -3
- package/dist/core/self-evolution/dream.js +480 -9
- package/dist/core/self-evolution/episode-orchestrator.d.ts +2 -0
- package/dist/core/self-evolution/episode-orchestrator.js +17 -5
- package/dist/core/self-evolution/episode-store.d.ts +5 -0
- package/dist/core/self-evolution/episode-store.js +6 -2
- package/dist/core/self-evolution/evolving-agent.js +8 -0
- package/dist/core/self-evolution/host-harness.d.ts +35 -12
- package/dist/core/self-evolution/host-harness.js +188 -49
- package/dist/core/self-evolution/reward-aggregator.js +2 -2
- package/dist/core/templates/workflows/archive-change.js +18 -18
- package/dist/core/templates/workflows/dream.js +57 -47
- package/dist/core/templates/workflows/learn.js +7 -5
- package/dist/core/templates/workflows/run-tests.js +48 -29
- package/dist/core/templates/workflows/self-evolving.js +11 -8
- package/dist/core/trajectory/facts.d.ts +1 -1
- package/dist/core/trajectory/registry.js +39 -8
- package/package.json +1 -1
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { parseTestMetrics, parseTestCollection, } from './fitness/test-metrics.js';
|
|
5
|
+
import { parseTestFailures } from './fitness/test-failures.js';
|
|
6
|
+
const RUNNER_EXIT_JSON_PATTERN = /`([^`\r\n]*runner-exit\.json)`|([^\s|`]+runner-exit\.json)/gi;
|
|
7
|
+
export async function readRunnerEvidenceFromTestReport(args) {
|
|
8
|
+
return (await readRunnerEvidenceResultFromTestReport(args)).evidence;
|
|
9
|
+
}
|
|
10
|
+
export async function readRunnerEvidenceResultFromTestReport(args) {
|
|
11
|
+
const projectRoot = path.resolve(args.projectRoot);
|
|
12
|
+
const changeDir = args.changeDir ? path.resolve(args.changeDir) : undefined;
|
|
13
|
+
const exitJsonPath = (await extractRunnerExitJsonPath(args.testReportContent, projectRoot, changeDir)) ??
|
|
14
|
+
(await findLatestRunnerExitJsonPath(projectRoot, args.changeName, changeDir));
|
|
15
|
+
if (!exitJsonPath)
|
|
16
|
+
return { evidence: null, reason: 'runner-exit.json not found' };
|
|
17
|
+
let rawExit;
|
|
18
|
+
try {
|
|
19
|
+
rawExit = await fs.readFile(exitJsonPath, 'utf-8');
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return {
|
|
23
|
+
evidence: null,
|
|
24
|
+
exitJsonPath,
|
|
25
|
+
reason: `runner-exit.json unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(rawExit);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return {
|
|
34
|
+
evidence: null,
|
|
35
|
+
exitJsonPath,
|
|
36
|
+
reason: `runner-exit.json is malformed: ${err instanceof Error ? err.message : String(err)}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const record = asRecord(parsed);
|
|
40
|
+
if (!record) {
|
|
41
|
+
return {
|
|
42
|
+
evidence: null,
|
|
43
|
+
exitJsonPath,
|
|
44
|
+
reason: 'runner-exit.json root must be an object',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const current = await readCurrentWorkspaceIdentity(projectRoot, args.changeName);
|
|
48
|
+
const workspaceIdentity = validateWorkspaceIdentity(projectRoot, args.changeName, record.workspaceIdentity, current);
|
|
49
|
+
if (!workspaceIdentity.verified) {
|
|
50
|
+
const evidence = {
|
|
51
|
+
exitJsonPath,
|
|
52
|
+
exitCode: numberOrNull(record.exitCode),
|
|
53
|
+
testMetrics: null,
|
|
54
|
+
outputText: '',
|
|
55
|
+
sourcePaths: [exitJsonPath],
|
|
56
|
+
workspaceIdentity,
|
|
57
|
+
};
|
|
58
|
+
return { evidence, exitJsonPath, reason: workspaceIdentity.reason };
|
|
59
|
+
}
|
|
60
|
+
const recordProblem = await validateRunnerEvidenceRecord(projectRoot, changeDir, record, exitJsonPath);
|
|
61
|
+
if (recordProblem) {
|
|
62
|
+
return {
|
|
63
|
+
evidence: null,
|
|
64
|
+
exitJsonPath,
|
|
65
|
+
reason: recordProblem,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const stdoutLogPath = await resolveEvidencePath(projectRoot, changeDir, stringOrUndefined(record.stdoutLog));
|
|
69
|
+
const stderrLogPath = await resolveEvidencePath(projectRoot, changeDir, stringOrUndefined(record.stderrLog));
|
|
70
|
+
const exitCode = numberOrNull(record.exitCode);
|
|
71
|
+
const command = stringOrUndefined(record.command);
|
|
72
|
+
const [stdout, stderr] = await Promise.all([
|
|
73
|
+
readOptionalText(stdoutLogPath),
|
|
74
|
+
readOptionalText(stderrLogPath),
|
|
75
|
+
]);
|
|
76
|
+
const outputText = [stdout, stderr].filter((text) => text.length > 0).join('\n');
|
|
77
|
+
const parsedMetrics = structuredTestMetrics(record.testMetrics) ?? parseTestMetrics(outputText);
|
|
78
|
+
const testMetrics = metricsWithExitCodeSignal(parsedMetrics, exitCode);
|
|
79
|
+
const sourcePaths = [
|
|
80
|
+
exitJsonPath,
|
|
81
|
+
...(stdoutLogPath ? [stdoutLogPath] : []),
|
|
82
|
+
...(stderrLogPath ? [stderrLogPath] : []),
|
|
83
|
+
];
|
|
84
|
+
return {
|
|
85
|
+
evidence: {
|
|
86
|
+
exitJsonPath,
|
|
87
|
+
...(command ? { command } : {}),
|
|
88
|
+
...(stdoutLogPath ? { stdoutLogPath } : {}),
|
|
89
|
+
...(stderrLogPath ? { stderrLogPath } : {}),
|
|
90
|
+
exitCode,
|
|
91
|
+
testMetrics,
|
|
92
|
+
outputText,
|
|
93
|
+
sourcePaths,
|
|
94
|
+
workspaceIdentity,
|
|
95
|
+
},
|
|
96
|
+
exitJsonPath,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function runnerEvidenceToTrajectoryFacts(args) {
|
|
100
|
+
const { evidence } = args;
|
|
101
|
+
if (!evidence.workspaceIdentity.verified)
|
|
102
|
+
return null;
|
|
103
|
+
const observedPassRate = evidence.testMetrics?.passRate ?? null;
|
|
104
|
+
const runnerExitCode = evidence.exitCode;
|
|
105
|
+
let observedStatus = null;
|
|
106
|
+
const failed = (runnerExitCode !== null && runnerExitCode > 0) ||
|
|
107
|
+
(observedPassRate !== null && observedPassRate < 1);
|
|
108
|
+
const passed = (runnerExitCode === 0 || runnerExitCode === null) &&
|
|
109
|
+
observedPassRate !== null &&
|
|
110
|
+
observedPassRate >= 1;
|
|
111
|
+
observedStatus = failed ? 'failure' : passed ? 'success' : null;
|
|
112
|
+
const expected = (args.expectedTestPaths ?? []).map(normPath).filter(Boolean);
|
|
113
|
+
const changeScope = changeScopeVerdict(evidence.outputText, expected, evidence.command);
|
|
114
|
+
let scopeDemoted = false;
|
|
115
|
+
if (changeScope === 'out-of-scope' && observedStatus === 'success') {
|
|
116
|
+
observedStatus = null;
|
|
117
|
+
scopeDemoted = true;
|
|
118
|
+
}
|
|
119
|
+
const observedFailures = evidence.outputText ? parseTestFailures(evidence.outputText) : [];
|
|
120
|
+
return {
|
|
121
|
+
harness: 'runner-evidence',
|
|
122
|
+
changeName: args.changeName,
|
|
123
|
+
testRunObserved: true,
|
|
124
|
+
runnerExitCode,
|
|
125
|
+
observedPassRate,
|
|
126
|
+
observedStatus,
|
|
127
|
+
verified: observedStatus !== null,
|
|
128
|
+
...(observedFailures.length > 0 ? { observedFailures } : {}),
|
|
129
|
+
...(changeScope !== undefined ? { changeScope } : {}),
|
|
130
|
+
...(scopeDemoted ? { scopeDemoted: true } : {}),
|
|
131
|
+
toolCallCount: 1,
|
|
132
|
+
subagentCount: 0,
|
|
133
|
+
sourcePaths: evidence.sourcePaths,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async function extractRunnerExitJsonPath(report, projectRoot, changeDir) {
|
|
137
|
+
RUNNER_EXIT_JSON_PATTERN.lastIndex = 0;
|
|
138
|
+
let latest = null;
|
|
139
|
+
for (const match of report.matchAll(RUNNER_EXIT_JSON_PATTERN)) {
|
|
140
|
+
const value = (match[1] ?? match[2] ?? '').trim();
|
|
141
|
+
if (!value)
|
|
142
|
+
continue;
|
|
143
|
+
const withoutMdLink = value.replace(/^\((.*)\)$/, '$1');
|
|
144
|
+
latest = {
|
|
145
|
+
direct: resolveProjectPath(projectRoot, withoutMdLink),
|
|
146
|
+
remapped: resolveChangeDirTestEvidencePath(projectRoot, changeDir, withoutMdLink),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (!latest)
|
|
150
|
+
return null;
|
|
151
|
+
if (latest.direct && (await fileExists(latest.direct)))
|
|
152
|
+
return latest.direct;
|
|
153
|
+
if (latest.remapped && (await fileExists(latest.remapped)))
|
|
154
|
+
return latest.remapped;
|
|
155
|
+
return latest.direct ?? latest.remapped;
|
|
156
|
+
}
|
|
157
|
+
async function findLatestRunnerExitJsonPath(projectRoot, changeName, changeDir) {
|
|
158
|
+
const evidenceRoot = changeDir
|
|
159
|
+
? path.join(changeDir, 'test-evidence')
|
|
160
|
+
: path.join(projectRoot, 'synergyspec-selfevolving', 'changes', changeName, 'test-evidence');
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = await fs.readdir(evidenceRoot);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
169
|
+
const candidate = path.join(evidenceRoot, entry, 'runner-exit.json');
|
|
170
|
+
try {
|
|
171
|
+
const st = await fs.stat(candidate);
|
|
172
|
+
return st.isFile() ? { file: candidate, mtimeMs: st.mtimeMs } : null;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
})))
|
|
178
|
+
.filter((entry) => entry !== null)
|
|
179
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs || right.file.localeCompare(left.file));
|
|
180
|
+
return candidates[0]?.file ?? null;
|
|
181
|
+
}
|
|
182
|
+
function resolveChangeDirTestEvidencePath(projectRoot, changeDir, rawPath) {
|
|
183
|
+
if (!changeDir || path.isAbsolute(rawPath))
|
|
184
|
+
return null;
|
|
185
|
+
const normalized = rawPath.replace(/\\/g, '/');
|
|
186
|
+
const marker = '/test-evidence/';
|
|
187
|
+
const markerIndex = normalized.indexOf(marker);
|
|
188
|
+
const bareMarkerIndex = normalized.startsWith('test-evidence/') ? 0 : -1;
|
|
189
|
+
const suffix = markerIndex >= 0
|
|
190
|
+
? normalized.slice(markerIndex + marker.length)
|
|
191
|
+
: bareMarkerIndex === 0
|
|
192
|
+
? normalized.slice('test-evidence/'.length)
|
|
193
|
+
: null;
|
|
194
|
+
if (!suffix)
|
|
195
|
+
return null;
|
|
196
|
+
const resolved = path.resolve(changeDir, 'test-evidence', ...suffix.split('/'));
|
|
197
|
+
return isInside(projectRoot, resolved) && isInside(changeDir, resolved) ? resolved : null;
|
|
198
|
+
}
|
|
199
|
+
function resolveProjectPath(projectRoot, rawPath) {
|
|
200
|
+
if (!rawPath)
|
|
201
|
+
return null;
|
|
202
|
+
const resolved = path.isAbsolute(rawPath)
|
|
203
|
+
? path.normalize(rawPath)
|
|
204
|
+
: path.resolve(projectRoot, rawPath);
|
|
205
|
+
if (!isInside(projectRoot, resolved))
|
|
206
|
+
return null;
|
|
207
|
+
return resolved;
|
|
208
|
+
}
|
|
209
|
+
async function validateRunnerEvidenceRecord(projectRoot, changeDir, record, exitJsonPath) {
|
|
210
|
+
if (!isInside(projectRoot, exitJsonPath)) {
|
|
211
|
+
return `runner-exit.json is outside project root: ${exitJsonPath}`;
|
|
212
|
+
}
|
|
213
|
+
if (!stringOrUndefined(record.command))
|
|
214
|
+
return 'runner-exit.json command is missing or invalid';
|
|
215
|
+
const cwd = stringOrUndefined(record.cwd);
|
|
216
|
+
if (!cwd)
|
|
217
|
+
return 'runner-exit.json cwd is missing or invalid';
|
|
218
|
+
if (!sameResolvedPath(cwd, projectRoot)) {
|
|
219
|
+
return `runner cwd changed from ${cwd} to ${path.resolve(projectRoot)}`;
|
|
220
|
+
}
|
|
221
|
+
if (!stringOrUndefined(record.startedAt))
|
|
222
|
+
return 'runner-exit.json startedAt is missing or invalid';
|
|
223
|
+
if (!stringOrUndefined(record.finishedAt))
|
|
224
|
+
return 'runner-exit.json finishedAt is missing or invalid';
|
|
225
|
+
if (typeof record.exitCode !== 'number' || !Number.isInteger(record.exitCode)) {
|
|
226
|
+
return 'runner-exit.json exitCode is missing or invalid';
|
|
227
|
+
}
|
|
228
|
+
const stdoutLog = stringOrUndefined(record.stdoutLog);
|
|
229
|
+
const stderrLog = stringOrUndefined(record.stderrLog);
|
|
230
|
+
if (!stdoutLog)
|
|
231
|
+
return 'runner-exit.json stdoutLog is missing or invalid';
|
|
232
|
+
if (!stderrLog)
|
|
233
|
+
return 'runner-exit.json stderrLog is missing or invalid';
|
|
234
|
+
const requiredPathProblem = (await validateEvidencePath(projectRoot, changeDir, stdoutLog, 'stdoutLog')) ??
|
|
235
|
+
(await validateEvidencePath(projectRoot, changeDir, stderrLog, 'stderrLog')) ??
|
|
236
|
+
(await validateEvidenceHash(projectRoot, changeDir, stdoutLog, record.stdoutLogSha256, 'stdoutLogSha256')) ??
|
|
237
|
+
(await validateEvidenceHash(projectRoot, changeDir, stderrLog, record.stderrLogSha256, 'stderrLogSha256'));
|
|
238
|
+
if (requiredPathProblem)
|
|
239
|
+
return requiredPathProblem;
|
|
240
|
+
for (const field of ['junitXml', 'coverageSummary', 'coverageLcov', 'coverageHtml']) {
|
|
241
|
+
const value = record[field];
|
|
242
|
+
if (value === null || value === undefined)
|
|
243
|
+
continue;
|
|
244
|
+
const pathValue = stringOrUndefined(value);
|
|
245
|
+
if (!pathValue)
|
|
246
|
+
return `runner-exit.json ${field} must be a path string or null`;
|
|
247
|
+
const problem = await validateEvidencePath(projectRoot, changeDir, pathValue, field);
|
|
248
|
+
if (problem)
|
|
249
|
+
return problem;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
async function validateEvidencePath(projectRoot, changeDir, rawPath, field) {
|
|
254
|
+
const fullPath = await resolveEvidencePath(projectRoot, changeDir, rawPath);
|
|
255
|
+
if (!fullPath)
|
|
256
|
+
return `runner-exit.json ${field} points outside project root`;
|
|
257
|
+
try {
|
|
258
|
+
await fs.access(fullPath);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return `runner-exit.json ${field} is missing: ${formatProjectPath(projectRoot, fullPath)}`;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
async function validateEvidenceHash(projectRoot, changeDir, rawPath, rawExpected, field) {
|
|
266
|
+
const expected = stringOrUndefined(rawExpected);
|
|
267
|
+
if (!expected)
|
|
268
|
+
return `runner-exit.json ${field} is missing or invalid`;
|
|
269
|
+
const fullPath = await resolveEvidencePath(projectRoot, changeDir, rawPath);
|
|
270
|
+
if (!fullPath)
|
|
271
|
+
return `runner-exit.json ${field} path points outside project root`;
|
|
272
|
+
let content;
|
|
273
|
+
try {
|
|
274
|
+
content = await fs.readFile(fullPath);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return `runner-exit.json ${field} cannot hash missing evidence: ${formatProjectPath(projectRoot, fullPath)}`;
|
|
278
|
+
}
|
|
279
|
+
const actual = createHash('sha256').update(content).digest('hex');
|
|
280
|
+
if (actual !== expected)
|
|
281
|
+
return `runner-exit.json ${field} does not match current log content`;
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
async function resolveEvidencePath(projectRoot, changeDir, rawPath) {
|
|
285
|
+
if (!rawPath)
|
|
286
|
+
return null;
|
|
287
|
+
const direct = resolveProjectPath(projectRoot, rawPath);
|
|
288
|
+
if (direct && (await fileExists(direct)))
|
|
289
|
+
return direct;
|
|
290
|
+
const remapped = resolveChangeDirTestEvidencePath(projectRoot, changeDir, rawPath);
|
|
291
|
+
if (remapped && (await fileExists(remapped)))
|
|
292
|
+
return remapped;
|
|
293
|
+
return direct ?? remapped;
|
|
294
|
+
}
|
|
295
|
+
function validateWorkspaceIdentity(projectRoot, changeName, value, current) {
|
|
296
|
+
const record = asRecord(value);
|
|
297
|
+
if (!record) {
|
|
298
|
+
return {
|
|
299
|
+
verified: false,
|
|
300
|
+
reason: 'runner-exit.json workspaceIdentity is missing or invalid',
|
|
301
|
+
current,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const recorded = workspaceIdentitySnapshotFromRecord(record);
|
|
305
|
+
const completenessProblems = validateWorkspaceIdentityCompleteness(recorded, current);
|
|
306
|
+
if (completenessProblems.length > 0) {
|
|
307
|
+
return {
|
|
308
|
+
verified: false,
|
|
309
|
+
reason: `runner-exit.json workspaceIdentity is incomplete: ${completenessProblems.join('; ')}`,
|
|
310
|
+
recorded,
|
|
311
|
+
current,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const mismatches = compareWorkspaceIdentities(projectRoot, changeName, recorded, current);
|
|
315
|
+
if (mismatches.length > 0) {
|
|
316
|
+
return {
|
|
317
|
+
verified: false,
|
|
318
|
+
reason: mismatches.join('; '),
|
|
319
|
+
recorded,
|
|
320
|
+
current,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
verified: true,
|
|
325
|
+
reason: 'current workspace package identity matches runner evidence',
|
|
326
|
+
recorded,
|
|
327
|
+
current,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
async function readCurrentWorkspaceIdentity(projectRoot, changeName) {
|
|
331
|
+
return {
|
|
332
|
+
cwd: path.resolve(projectRoot),
|
|
333
|
+
changeName,
|
|
334
|
+
pyproject: await readPyprojectIdentity(projectRoot),
|
|
335
|
+
packageJson: await readPackageJsonIdentity(projectRoot),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async function readPyprojectIdentity(projectRoot) {
|
|
339
|
+
const filePath = path.join(projectRoot, 'pyproject.toml');
|
|
340
|
+
let content;
|
|
341
|
+
try {
|
|
342
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
path: 'pyproject.toml',
|
|
349
|
+
name: parsePyprojectProjectName(content),
|
|
350
|
+
sha256: sha256(content),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
async function readPackageJsonIdentity(projectRoot) {
|
|
354
|
+
const filePath = path.join(projectRoot, 'package.json');
|
|
355
|
+
let content;
|
|
356
|
+
try {
|
|
357
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
let name;
|
|
363
|
+
try {
|
|
364
|
+
const parsed = JSON.parse(content);
|
|
365
|
+
name = typeof parsed.name === 'string' ? parsed.name : undefined;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
name = undefined;
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
path: 'package.json',
|
|
372
|
+
name,
|
|
373
|
+
sha256: sha256(content),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function workspaceIdentitySnapshotFromRecord(record) {
|
|
377
|
+
return {
|
|
378
|
+
cwd: stringOrUndefined(record.cwd),
|
|
379
|
+
changeName: stringOrUndefined(record.changeName),
|
|
380
|
+
taskId: stringOrUndefined(record.taskId),
|
|
381
|
+
pyproject: fileSnapshotFromUnknown(record.pyproject),
|
|
382
|
+
packageJson: fileSnapshotFromUnknown(record.packageJson),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function fileSnapshotFromUnknown(value) {
|
|
386
|
+
const record = asRecord(value);
|
|
387
|
+
if (!record)
|
|
388
|
+
return undefined;
|
|
389
|
+
const snapshot = {};
|
|
390
|
+
const snapshotPath = stringOrUndefined(record.path);
|
|
391
|
+
const name = stringOrUndefined(record.name);
|
|
392
|
+
const hash = stringOrUndefined(record.sha256);
|
|
393
|
+
if (snapshotPath)
|
|
394
|
+
snapshot.path = snapshotPath;
|
|
395
|
+
if (name)
|
|
396
|
+
snapshot.name = name;
|
|
397
|
+
if (hash)
|
|
398
|
+
snapshot.sha256 = hash;
|
|
399
|
+
return Object.keys(snapshot).length > 0 ? snapshot : undefined;
|
|
400
|
+
}
|
|
401
|
+
function compareWorkspaceIdentities(projectRoot, changeName, recorded, current) {
|
|
402
|
+
const mismatches = [];
|
|
403
|
+
if (recorded.changeName && !changeNamesEquivalent(recorded.changeName, changeName)) {
|
|
404
|
+
mismatches.push(`change name changed from ${recorded.changeName} to ${changeName}`);
|
|
405
|
+
}
|
|
406
|
+
if (recorded.cwd && !sameResolvedPath(recorded.cwd, projectRoot)) {
|
|
407
|
+
mismatches.push(`runner cwd changed from ${recorded.cwd} to ${path.resolve(projectRoot)}`);
|
|
408
|
+
}
|
|
409
|
+
compareIdentityFile('pyproject.toml', recorded.pyproject, current.pyproject, mismatches);
|
|
410
|
+
compareIdentityFile('package.json', recorded.packageJson, current.packageJson, mismatches);
|
|
411
|
+
return mismatches;
|
|
412
|
+
}
|
|
413
|
+
function validateWorkspaceIdentityCompleteness(recorded, current) {
|
|
414
|
+
const problems = [];
|
|
415
|
+
if (!recorded.cwd)
|
|
416
|
+
problems.push('cwd is missing');
|
|
417
|
+
if (!recorded.changeName)
|
|
418
|
+
problems.push('changeName is missing');
|
|
419
|
+
const currentFiles = [
|
|
420
|
+
['pyproject', 'pyproject.toml', current.pyproject],
|
|
421
|
+
['packageJson', 'package.json', current.packageJson],
|
|
422
|
+
];
|
|
423
|
+
for (const [key, label, currentFile] of currentFiles) {
|
|
424
|
+
if (!currentFile)
|
|
425
|
+
continue;
|
|
426
|
+
const recordedFile = recorded[key];
|
|
427
|
+
if (!recordedFile) {
|
|
428
|
+
problems.push(`${label} identity is missing`);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (!recordedFile.path)
|
|
432
|
+
problems.push(`${label} path is missing`);
|
|
433
|
+
if (currentFile.name && !recordedFile.name)
|
|
434
|
+
problems.push(`${label} name is missing`);
|
|
435
|
+
if (!recordedFile.sha256)
|
|
436
|
+
problems.push(`${label} sha256 is missing`);
|
|
437
|
+
}
|
|
438
|
+
return problems;
|
|
439
|
+
}
|
|
440
|
+
function changeNamesEquivalent(recorded, current) {
|
|
441
|
+
if (recorded === current)
|
|
442
|
+
return true;
|
|
443
|
+
const archived = current.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
|
|
444
|
+
return archived?.[1] === recorded;
|
|
445
|
+
}
|
|
446
|
+
function compareIdentityFile(label, recorded, current, mismatches) {
|
|
447
|
+
if (!recorded)
|
|
448
|
+
return;
|
|
449
|
+
if (!current) {
|
|
450
|
+
mismatches.push(`${label} was present in runner evidence but is absent now`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (recorded.name && current.name && recorded.name !== current.name) {
|
|
454
|
+
mismatches.push(`${label} name changed from ${recorded.name} to ${current.name}`);
|
|
455
|
+
}
|
|
456
|
+
else if (recorded.name && !current.name) {
|
|
457
|
+
mismatches.push(`${label} no longer exposes package name ${recorded.name}`);
|
|
458
|
+
}
|
|
459
|
+
if (recorded.sha256 && current.sha256 && recorded.sha256 !== current.sha256) {
|
|
460
|
+
mismatches.push(`${label} content changed since runner evidence was captured`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function structuredTestMetrics(value) {
|
|
464
|
+
const record = asRecord(value);
|
|
465
|
+
if (!record)
|
|
466
|
+
return null;
|
|
467
|
+
const total = numberOrNull(record.total);
|
|
468
|
+
const passed = numberOrNull(record.passed);
|
|
469
|
+
const failed = numberOrNull(record.failed);
|
|
470
|
+
if (total === null || passed === null || failed === null)
|
|
471
|
+
return null;
|
|
472
|
+
if (total < 0 || passed < 0 || failed < 0)
|
|
473
|
+
return null;
|
|
474
|
+
if (passed + failed !== total)
|
|
475
|
+
return null;
|
|
476
|
+
const passRate = total > 0 ? passed / total : 0;
|
|
477
|
+
return { total, passed, failed, passRate };
|
|
478
|
+
}
|
|
479
|
+
function metricsWithExitCodeSignal(metrics, exitCode) {
|
|
480
|
+
if (exitCode === null || exitCode <= 0)
|
|
481
|
+
return metrics;
|
|
482
|
+
if (!metrics)
|
|
483
|
+
return { total: 1, passed: 0, failed: 1, passRate: 0 };
|
|
484
|
+
if (metrics.failed > 0 || metrics.passRate < 1)
|
|
485
|
+
return metrics;
|
|
486
|
+
const total = metrics.total + 1;
|
|
487
|
+
const failed = metrics.failed + 1;
|
|
488
|
+
return {
|
|
489
|
+
total,
|
|
490
|
+
passed: metrics.passed,
|
|
491
|
+
failed,
|
|
492
|
+
passRate: total > 0 ? metrics.passed / total : 0,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function changeScopeVerdict(outputText, expected, command) {
|
|
496
|
+
if (expected.length === 0)
|
|
497
|
+
return undefined;
|
|
498
|
+
const collection = parseTestCollection(outputText);
|
|
499
|
+
const observed = collection?.paths ?? [];
|
|
500
|
+
if (observed.length > 0) {
|
|
501
|
+
return pathsIntersect(observed, expected) ? 'in-scope' : 'out-of-scope';
|
|
502
|
+
}
|
|
503
|
+
const commandPaths = commandTestPaths(command);
|
|
504
|
+
if (pathsIntersect(commandPaths, expected))
|
|
505
|
+
return 'in-scope';
|
|
506
|
+
const knownScope = collection !== null && collection.collected !== null;
|
|
507
|
+
return knownScope ? 'out-of-scope' : 'unknown';
|
|
508
|
+
}
|
|
509
|
+
function pathsIntersect(observed, expected) {
|
|
510
|
+
if (observed.length === 0 || expected.length === 0)
|
|
511
|
+
return false;
|
|
512
|
+
for (const o of observed.map(normPath)) {
|
|
513
|
+
for (const e of expected) {
|
|
514
|
+
if (o === e || o.endsWith('/' + e) || e.endsWith('/' + o)) {
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
function commandTestPaths(command) {
|
|
522
|
+
if (!command)
|
|
523
|
+
return [];
|
|
524
|
+
const paths = new Set();
|
|
525
|
+
const afterRunner = command.replace(/\b(?:pytest|py\.test|python\s+-m\s+pytest|vitest|npm\s+test|pnpm\s+test|yarn\s+test|go\s+test)\b/i, ' ');
|
|
526
|
+
for (const raw of afterRunner.split(/\s+/)) {
|
|
527
|
+
const tok = raw.trim().replace(/^['"]|['"]$/g, '');
|
|
528
|
+
if (!tok || tok.startsWith('-'))
|
|
529
|
+
continue;
|
|
530
|
+
const looksLikePath = /[\\/]/.test(tok) || /\b(?:tests?|spec|specs|benchmark_tests)\b/i.test(tok);
|
|
531
|
+
if (!looksLikePath)
|
|
532
|
+
continue;
|
|
533
|
+
if (/\.[a-z]+$/i.test(tok) && !/\.(?:py|[tj]sx?)$/i.test(tok))
|
|
534
|
+
continue;
|
|
535
|
+
paths.add(normPath(tok));
|
|
536
|
+
}
|
|
537
|
+
return [...paths];
|
|
538
|
+
}
|
|
539
|
+
async function readOptionalText(filePath) {
|
|
540
|
+
if (!filePath)
|
|
541
|
+
return '';
|
|
542
|
+
try {
|
|
543
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return '';
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function parsePyprojectProjectName(content) {
|
|
550
|
+
let inProject = false;
|
|
551
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
552
|
+
const line = rawLine.trim();
|
|
553
|
+
if (!line || line.startsWith('#'))
|
|
554
|
+
continue;
|
|
555
|
+
const section = line.match(/^\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
556
|
+
if (section) {
|
|
557
|
+
inProject = section[1].trim() === 'project';
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (!inProject)
|
|
561
|
+
continue;
|
|
562
|
+
const name = line.match(/^name\s*=\s*(['"])(.*?)\1\s*(?:#.*)?$/);
|
|
563
|
+
if (name)
|
|
564
|
+
return name[2];
|
|
565
|
+
}
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
function sameResolvedPath(left, right) {
|
|
569
|
+
const normalizedLeft = path.resolve(left);
|
|
570
|
+
const normalizedRight = path.resolve(right);
|
|
571
|
+
if (process.platform === 'win32') {
|
|
572
|
+
return normalizedLeft.toLowerCase() === normalizedRight.toLowerCase();
|
|
573
|
+
}
|
|
574
|
+
return normalizedLeft === normalizedRight;
|
|
575
|
+
}
|
|
576
|
+
function isInside(root, candidate) {
|
|
577
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
578
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
579
|
+
}
|
|
580
|
+
function formatProjectPath(projectRoot, filePath) {
|
|
581
|
+
const relative = path.relative(projectRoot, filePath);
|
|
582
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
583
|
+
return relative.split(path.sep).join('/');
|
|
584
|
+
}
|
|
585
|
+
return path.normalize(filePath);
|
|
586
|
+
}
|
|
587
|
+
function normPath(p) {
|
|
588
|
+
return p.replace(/\\/g, '/').toLowerCase().replace(/^\.\//, '').replace(/::.*/, '');
|
|
589
|
+
}
|
|
590
|
+
function stringOrUndefined(value) {
|
|
591
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
592
|
+
}
|
|
593
|
+
function numberOrNull(value) {
|
|
594
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
595
|
+
}
|
|
596
|
+
function asRecord(value) {
|
|
597
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
598
|
+
? value
|
|
599
|
+
: null;
|
|
600
|
+
}
|
|
601
|
+
async function fileExists(filePath) {
|
|
602
|
+
try {
|
|
603
|
+
await fs.access(filePath);
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function sha256(content) {
|
|
611
|
+
return createHash('sha256').update(content).digest('hex');
|
|
612
|
+
}
|
|
613
|
+
//# sourceMappingURL=runner-evidence.js.map
|
|
@@ -376,7 +376,6 @@ export async function listCandidates(layout, filter) {
|
|
|
376
376
|
raw = await fs.readFile(candidateJsonPath, 'utf8');
|
|
377
377
|
}
|
|
378
378
|
catch {
|
|
379
|
-
// eslint-disable-next-line no-console
|
|
380
379
|
console.warn(`[candidates] skipping ${entry.name}: missing or unreadable ${CANDIDATE_JSON_FILE}`);
|
|
381
380
|
continue;
|
|
382
381
|
}
|
|
@@ -385,7 +384,6 @@ export async function listCandidates(layout, filter) {
|
|
|
385
384
|
parsed = JSON.parse(raw);
|
|
386
385
|
}
|
|
387
386
|
catch {
|
|
388
|
-
// eslint-disable-next-line no-console
|
|
389
387
|
console.warn(`[candidates] skipping ${entry.name}: invalid JSON in ${CANDIDATE_JSON_FILE}`);
|
|
390
388
|
continue;
|
|
391
389
|
}
|