@hecer/yoke 1.3.0 → 1.5.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +108 -16
- package/TODOS.md +0 -3
- package/bench/README.md +55 -46
- package/bench/output-compaction.mjs +65 -0
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/audit/command.js +13 -5
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/git.js +6 -4
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +216 -58
- package/dist/loop/runner.js +67 -32
- package/dist/loop/verify.js +65 -11
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/output/artifact.js +63 -0
- package/dist/output/compact.js +192 -0
- package/dist/output/types.js +4 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +26 -2
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
- package/gemini-extension.json +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { isAcceptanceCriterion } from './prd.js';
|
|
2
|
+
import { runQualityRepairLoop } from '../quality/loop.js';
|
|
3
|
+
function emptyEvidence() { return { criteria: [] }; }
|
|
4
|
+
function errorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
function assertNever(value) {
|
|
8
|
+
throw new Error(`Unexpected worker result: ${String(value)}`);
|
|
9
|
+
}
|
|
10
|
+
function cancellationReason(cancellation) {
|
|
11
|
+
if (cancellation?.signal?.aborted) {
|
|
12
|
+
return typeof cancellation.signal.reason === 'string' && cancellation.signal.reason.length > 0
|
|
13
|
+
? cancellation.signal.reason
|
|
14
|
+
: 'worker cancellation requested';
|
|
15
|
+
}
|
|
16
|
+
return cancellation?.handle?.isCancelled() ? 'worker cancellation requested' : null;
|
|
17
|
+
}
|
|
18
|
+
function runGate(gate, worktree, storyId) {
|
|
19
|
+
const previous = process.env.YOKE_STORY;
|
|
20
|
+
process.env.YOKE_STORY = storyId;
|
|
21
|
+
try {
|
|
22
|
+
return gate(worktree);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
if (previous === undefined)
|
|
26
|
+
delete process.env.YOKE_STORY;
|
|
27
|
+
else
|
|
28
|
+
process.env.YOKE_STORY = previous;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function reviewOutcome(result) {
|
|
32
|
+
if (result.reviewOutcome)
|
|
33
|
+
return result.reviewOutcome;
|
|
34
|
+
if (result.success)
|
|
35
|
+
return { kind: 'approved', verdict: { approved: true, summary: result.summary, findings: [] } };
|
|
36
|
+
return { kind: 'malformed', summary: result.summary };
|
|
37
|
+
}
|
|
38
|
+
function runMechanicalGates(input, context, evidence) {
|
|
39
|
+
const criteria = context.story.acceptance.filter(isAcceptanceCriterion);
|
|
40
|
+
if (criteria.length === 0) {
|
|
41
|
+
if (input.requireCriterionEvidence)
|
|
42
|
+
return { kind: 'failed', stage: 'criterion', summary: `story ${context.story.id} lacks executable criterion evidence` };
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (!input.verifyCriterion)
|
|
46
|
+
return { kind: 'failed', stage: 'criterion', summary: `story ${context.story.id} has criteria but no criterion verifier` };
|
|
47
|
+
for (const criterion of criteria) {
|
|
48
|
+
const cancelled = cancellationReason(input.cancellation);
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return { kind: 'cancelled', summary: cancelled };
|
|
51
|
+
const result = input.verifyCriterion(context.targetDir, context.story, criterion);
|
|
52
|
+
const criteriaEvidence = [...evidence.criteria, { id: criterion.id, passed: result.passed, summary: result.summary }];
|
|
53
|
+
evidence.criteria = criteriaEvidence;
|
|
54
|
+
input.callbacks?.onGate?.('criterion', result);
|
|
55
|
+
if (!result.passed)
|
|
56
|
+
return { kind: 'failed', stage: 'criterion', summary: result.summary };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const cancelled = cancellationReason(input.cancellation);
|
|
60
|
+
if (cancelled)
|
|
61
|
+
return { kind: 'cancelled', summary: cancelled };
|
|
62
|
+
input.reporter?.phase('verifying');
|
|
63
|
+
const verify = runGate(input.verify, context.targetDir, context.story.id);
|
|
64
|
+
evidence.verify = verify;
|
|
65
|
+
input.callbacks?.onGate?.('verify', verify);
|
|
66
|
+
if (!verify.passed)
|
|
67
|
+
return { kind: 'failed', stage: 'verify', summary: verify.summary };
|
|
68
|
+
const afterVerifyCancellation = cancellationReason(input.cancellation);
|
|
69
|
+
if (afterVerifyCancellation)
|
|
70
|
+
return { kind: 'cancelled', summary: afterVerifyCancellation };
|
|
71
|
+
if (input.perf) {
|
|
72
|
+
input.reporter?.phase('perf');
|
|
73
|
+
const perf = runGate(input.perf, context.targetDir, context.story.id);
|
|
74
|
+
evidence.perf = perf;
|
|
75
|
+
input.callbacks?.onGate?.('perf', perf);
|
|
76
|
+
if (!perf.passed)
|
|
77
|
+
return { kind: 'failed', stage: 'perf', summary: perf.summary };
|
|
78
|
+
const afterPerfCancellation = cancellationReason(input.cancellation);
|
|
79
|
+
if (afterPerfCancellation)
|
|
80
|
+
return { kind: 'cancelled', summary: afterPerfCancellation };
|
|
81
|
+
}
|
|
82
|
+
if (input.audit) {
|
|
83
|
+
input.reporter?.phase('audit');
|
|
84
|
+
const audit = runGate(input.audit, context.targetDir, context.story.id);
|
|
85
|
+
evidence.audit = audit;
|
|
86
|
+
input.callbacks?.onGate?.('audit', audit);
|
|
87
|
+
if (!audit.passed)
|
|
88
|
+
return { kind: 'failed', stage: 'audit', summary: audit.summary };
|
|
89
|
+
const afterAuditCancellation = cancellationReason(input.cancellation);
|
|
90
|
+
if (afterAuditCancellation)
|
|
91
|
+
return { kind: 'cancelled', summary: afterAuditCancellation };
|
|
92
|
+
}
|
|
93
|
+
return { kind: 'passed' };
|
|
94
|
+
}
|
|
95
|
+
function finalResult(input, result) {
|
|
96
|
+
input.callbacks?.onResult?.(result);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function baseResult(input, evidence, summary) {
|
|
100
|
+
return {
|
|
101
|
+
storyId: input.story.id,
|
|
102
|
+
worktree: input.worktree,
|
|
103
|
+
baseCommit: input.baseCommit,
|
|
104
|
+
provider: input.provider,
|
|
105
|
+
summary,
|
|
106
|
+
evidence,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function resultFromQuality(input, evidence, summary, outcome) {
|
|
110
|
+
switch (outcome.kind) {
|
|
111
|
+
case 'approved':
|
|
112
|
+
return {
|
|
113
|
+
...baseResult(input, evidence, summary),
|
|
114
|
+
kind: 'candidate',
|
|
115
|
+
routing: { outcome: 'pending-integration' },
|
|
116
|
+
};
|
|
117
|
+
case 'paused':
|
|
118
|
+
return { ...baseResult(input, evidence, summary), kind: 'paused' };
|
|
119
|
+
case 'cancelled':
|
|
120
|
+
return { ...baseResult(input, evidence, outcome.summary), kind: 'cancelled' };
|
|
121
|
+
case 'blocked':
|
|
122
|
+
switch (outcome.reason) {
|
|
123
|
+
case 'quality-inconsistent':
|
|
124
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'quality-failure', reason: 'inconsistent' };
|
|
125
|
+
case 'quality-infrastructure':
|
|
126
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'quality-failure', reason: 'infrastructure' };
|
|
127
|
+
case 'review-malformed':
|
|
128
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'review-failure', reason: 'malformed' };
|
|
129
|
+
case 'review-infrastructure':
|
|
130
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'review-failure', reason: 'infrastructure' };
|
|
131
|
+
case 'gate-failed':
|
|
132
|
+
return { ...baseResult(input, evidence, outcome.summary), kind: 'mechanical-failure', stage: outcome.stage };
|
|
133
|
+
case 'no-actionable-finding':
|
|
134
|
+
case 'round-budget-exhausted':
|
|
135
|
+
case 'time-budget-exhausted':
|
|
136
|
+
case 'repair-failed':
|
|
137
|
+
return { ...baseResult(input, evidence, outcome.summary ?? outcome.reason), kind: 'mechanical-failure', stage: 'quality' };
|
|
138
|
+
default:
|
|
139
|
+
return assertNever(outcome);
|
|
140
|
+
}
|
|
141
|
+
default:
|
|
142
|
+
return assertNever(outcome);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function runStoryWorker(input) {
|
|
146
|
+
const evidence = emptyEvidence();
|
|
147
|
+
const cancelled = cancellationReason(input.cancellation);
|
|
148
|
+
if (cancelled)
|
|
149
|
+
return finalResult(input, { ...baseResult(input, evidence, cancelled), kind: 'cancelled' });
|
|
150
|
+
const workerStory = structuredClone(input.story);
|
|
151
|
+
const context = { targetDir: input.worktree, story: workerStory };
|
|
152
|
+
const qualityEnabled = input.qualityEnabled?.(workerStory) ?? input.qualityStage !== undefined;
|
|
153
|
+
const preflight = qualityEnabled ? input.qualityPreflight?.(context) : undefined;
|
|
154
|
+
if (preflight?.kind === 'blocked') {
|
|
155
|
+
return finalResult(input, { ...baseResult(input, evidence, preflight.summary), kind: 'mechanical-failure', stage: 'quality' });
|
|
156
|
+
}
|
|
157
|
+
let implementation;
|
|
158
|
+
try {
|
|
159
|
+
implementation = await input.runner(context);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return finalResult(input, {
|
|
163
|
+
...baseResult(input, evidence, `worker implementation failed: ${errorMessage(error)}`),
|
|
164
|
+
kind: 'mechanical-failure',
|
|
165
|
+
stage: 'implementation',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (implementation.tokens)
|
|
169
|
+
input.reporter?.addTokens(implementation.tokens);
|
|
170
|
+
const afterImplementationCancellation = cancellationReason(input.cancellation);
|
|
171
|
+
if (afterImplementationCancellation) {
|
|
172
|
+
return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
|
|
173
|
+
}
|
|
174
|
+
const decision = input.beforeGates?.(context);
|
|
175
|
+
if (decision) {
|
|
176
|
+
return finalResult(input, { ...baseResult(input, evidence, decision), kind: 'paused', reason: 'decision' });
|
|
177
|
+
}
|
|
178
|
+
const gates = runMechanicalGates(input, context, evidence);
|
|
179
|
+
if (gates.kind === 'cancelled') {
|
|
180
|
+
return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'cancelled' });
|
|
181
|
+
}
|
|
182
|
+
if (gates.kind === 'failed') {
|
|
183
|
+
implementation.routing?.recordOutcome(false);
|
|
184
|
+
return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'mechanical-failure', stage: gates.stage });
|
|
185
|
+
}
|
|
186
|
+
const summary = implementation.success
|
|
187
|
+
? implementation.summary
|
|
188
|
+
: `${implementation.summary} (runner exited non-zero but verify is green)`;
|
|
189
|
+
if (!qualityEnabled && !input.review) {
|
|
190
|
+
return finalResult(input, {
|
|
191
|
+
...baseResult(input, evidence, summary),
|
|
192
|
+
kind: 'candidate',
|
|
193
|
+
routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
|
|
194
|
+
...(implementation.tokens ? { tokens: implementation.tokens } : {}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const beforeQualityCancellation = cancellationReason(input.cancellation);
|
|
198
|
+
if (beforeQualityCancellation) {
|
|
199
|
+
return finalResult(input, { ...baseResult(input, evidence, beforeQualityCancellation), kind: 'cancelled' });
|
|
200
|
+
}
|
|
201
|
+
const qualityStage = qualityEnabled ? input.qualityStage : undefined;
|
|
202
|
+
const review = input.review;
|
|
203
|
+
const quality = runQualityRepairLoop({
|
|
204
|
+
...(qualityStage
|
|
205
|
+
? { quality: (round) => {
|
|
206
|
+
input.reporter?.phase('comparing');
|
|
207
|
+
return qualityStage(context, round);
|
|
208
|
+
} }
|
|
209
|
+
: {}),
|
|
210
|
+
...(review
|
|
211
|
+
? { review: () => {
|
|
212
|
+
input.reporter?.phase('reviewing');
|
|
213
|
+
return reviewOutcome(review(context));
|
|
214
|
+
} }
|
|
215
|
+
: {}),
|
|
216
|
+
repair: request => {
|
|
217
|
+
if (!input.repair)
|
|
218
|
+
return { kind: 'blocked', summary: 'repair callback is not configured' };
|
|
219
|
+
input.reporter?.phase('repairing');
|
|
220
|
+
const repair = input.repair(context, request);
|
|
221
|
+
return repair.success ? { kind: 'repaired' } : { kind: 'blocked', summary: repair.summary };
|
|
222
|
+
},
|
|
223
|
+
rerunGates: () => {
|
|
224
|
+
const rerun = runMechanicalGates(input, context, evidence);
|
|
225
|
+
if (rerun.kind === 'passed')
|
|
226
|
+
return { kind: 'passed' };
|
|
227
|
+
if (rerun.kind === 'cancelled')
|
|
228
|
+
return rerun;
|
|
229
|
+
return { kind: 'failed', stage: rerun.stage, summary: rerun.summary };
|
|
230
|
+
},
|
|
231
|
+
...(input.repairLimits ? { limits: input.repairLimits } : {}),
|
|
232
|
+
pause: input.pause,
|
|
233
|
+
onStatus: status => {
|
|
234
|
+
const metadata = input.qualityMetadata?.(context);
|
|
235
|
+
input.reporter?.quality({ ...status, ...(metadata ?? { policy: 'blocking' }) });
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
evidence.quality = quality;
|
|
239
|
+
const afterQualityCancellation = cancellationReason(input.cancellation);
|
|
240
|
+
if (afterQualityCancellation) {
|
|
241
|
+
return finalResult(input, { ...baseResult(input, evidence, afterQualityCancellation), kind: 'cancelled' });
|
|
242
|
+
}
|
|
243
|
+
const result = resultFromQuality(input, evidence, summary, quality);
|
|
244
|
+
if (result.kind !== 'candidate' && result.kind !== 'paused')
|
|
245
|
+
implementation.routing?.recordOutcome(false);
|
|
246
|
+
if (result.kind === 'candidate') {
|
|
247
|
+
return finalResult(input, {
|
|
248
|
+
...result,
|
|
249
|
+
routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
|
|
250
|
+
...(implementation.tokens ? { tokens: implementation.tokens } : {}),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return finalResult(input, result);
|
|
254
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
function safeLabel(value) {
|
|
5
|
+
const cleaned = (value ?? '')
|
|
6
|
+
.replace(/[^A-Za-z0-9._-]+/gu, '-')
|
|
7
|
+
.replace(/^[.-]+|[.-]+$/gu, '')
|
|
8
|
+
.slice(0, 80);
|
|
9
|
+
return cleaned || 'session';
|
|
10
|
+
}
|
|
11
|
+
function assertContained(root, candidate) {
|
|
12
|
+
const fromRoot = relative(resolve(root), resolve(candidate));
|
|
13
|
+
if (fromRoot.startsWith('..') || isAbsolute(fromRoot))
|
|
14
|
+
throw new Error('artifact path escaped the project artifact root');
|
|
15
|
+
}
|
|
16
|
+
export function writeOutputArtifact(targetDir, raw, options) {
|
|
17
|
+
const sha256 = createHash('sha256').update(raw).digest('hex');
|
|
18
|
+
const label = safeLabel(options.storyId);
|
|
19
|
+
const relativeDir = join('.yoke', 'artifacts', label);
|
|
20
|
+
const projectRoot = realpathSync(targetDir);
|
|
21
|
+
const yokeRoot = join(targetDir, '.yoke');
|
|
22
|
+
if (existsSync(yokeRoot)) {
|
|
23
|
+
if (lstatSync(yokeRoot).isSymbolicLink())
|
|
24
|
+
throw new Error('artifact root parent must not be a symlink');
|
|
25
|
+
assertContained(projectRoot, realpathSync(yokeRoot));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
mkdirSync(yokeRoot, { mode: 0o700 });
|
|
29
|
+
}
|
|
30
|
+
const artifactRoot = join(targetDir, '.yoke', 'artifacts');
|
|
31
|
+
mkdirSync(artifactRoot, { recursive: true, mode: 0o700 });
|
|
32
|
+
if (lstatSync(artifactRoot).isSymbolicLink())
|
|
33
|
+
throw new Error('artifact root must not be a symlink');
|
|
34
|
+
const resolvedArtifactRoot = realpathSync(artifactRoot);
|
|
35
|
+
assertContained(projectRoot, resolvedArtifactRoot);
|
|
36
|
+
const artifactDir = join(artifactRoot, label);
|
|
37
|
+
mkdirSync(artifactDir, { recursive: true, mode: 0o700 });
|
|
38
|
+
assertContained(resolvedArtifactRoot, realpathSync(artifactDir));
|
|
39
|
+
let digestLength = 12;
|
|
40
|
+
let filename = `${options.phase}-${sha256.slice(0, digestLength)}.log`;
|
|
41
|
+
let file = join(artifactDir, filename);
|
|
42
|
+
while (existsSync(file) && !lstatSync(file).isSymbolicLink() && createHash('sha256').update(readFileSync(file)).digest('hex') !== sha256) {
|
|
43
|
+
if (digestLength === sha256.length)
|
|
44
|
+
throw new Error('artifact digest path contains mismatched content');
|
|
45
|
+
digestLength = Math.min(sha256.length, digestLength + 8);
|
|
46
|
+
filename = `${options.phase}-${sha256.slice(0, digestLength)}.log`;
|
|
47
|
+
file = join(artifactDir, filename);
|
|
48
|
+
}
|
|
49
|
+
if (existsSync(file) && lstatSync(file).isSymbolicLink())
|
|
50
|
+
throw new Error('artifact file must not be a symlink');
|
|
51
|
+
if (!existsSync(file))
|
|
52
|
+
writeFileSync(file, raw, { encoding: 'utf8', mode: 0o600 });
|
|
53
|
+
else
|
|
54
|
+
chmodSync(file, 0o600);
|
|
55
|
+
const relativePath = `${relativeDir.replace(/\\/gu, '/')}/${filename}`;
|
|
56
|
+
const bytes = Buffer.byteLength(raw);
|
|
57
|
+
return {
|
|
58
|
+
relativePath,
|
|
59
|
+
bytes,
|
|
60
|
+
sha256,
|
|
61
|
+
marker: `[full output: ${relativePath} | ${bytes} bytes | sha256:${sha256}]`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
const OSC = /(?:\x1B\]|\u009D)[^\x07\x1B\u009C]*(?:\x07|\x1B\\|\u009C)/gu;
|
|
2
|
+
const ANSI = /(?:\x1B\[|\u009B)[0-?]*[ -/]*[@-~]/gu;
|
|
3
|
+
const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu;
|
|
4
|
+
const ERROR_SIGNAL = /(?:(?:^|[^A-Za-z0-9])error(?:[^A-Za-z0-9]|$)|\bfail(?:ed|ure)?\b|\bnot ok\b|\bassertionerror\b|\bfatal\b|\bpanic\b|\bexception\b|\btraceback\b|\bE\d{4}\b|\bTS\d{4}\b|(?:^|\s)[✗✘×](?:\s|$))/iu;
|
|
5
|
+
const WARNING_SIGNAL = /(?:\bwarn(?:ing)?\b|\bdeprecat(?:ed|ion)\b)/iu;
|
|
6
|
+
const SUMMARY_SIGNAL = /^\s*(?:Test Files|Tests?|Test Suites?|Suites?|Ran all test suites|Failures?|Passed|Failed)(?:\s|:|$)/iu;
|
|
7
|
+
function cleanLine(line) {
|
|
8
|
+
return line.replace(OSC, '').replace(ANSI, '').replace(CONTROL, '').trimEnd();
|
|
9
|
+
}
|
|
10
|
+
function utf8Prefix(value, maxBytes) {
|
|
11
|
+
if (maxBytes <= 0)
|
|
12
|
+
return '';
|
|
13
|
+
let used = 0;
|
|
14
|
+
let result = '';
|
|
15
|
+
for (const char of value) {
|
|
16
|
+
const bytes = Buffer.byteLength(char);
|
|
17
|
+
if (used + bytes > maxBytes)
|
|
18
|
+
break;
|
|
19
|
+
result += char;
|
|
20
|
+
used += bytes;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
const MAX_PRIORITY_CANDIDATES = 1_024;
|
|
25
|
+
const MAX_DISTINCT_LINES = 4_096;
|
|
26
|
+
function addCandidate(candidates, indexes, entry) {
|
|
27
|
+
if (!entry || entry.line.trim() === '' || indexes.has(entry.index))
|
|
28
|
+
return;
|
|
29
|
+
if (candidates.length >= MAX_PRIORITY_CANDIDATES)
|
|
30
|
+
return;
|
|
31
|
+
indexes.add(entry.index);
|
|
32
|
+
candidates.push(entry);
|
|
33
|
+
}
|
|
34
|
+
function scanLines(raw, maxLineBytes) {
|
|
35
|
+
const errorCandidates = [];
|
|
36
|
+
const warningCandidates = [];
|
|
37
|
+
const errorIndexes = new Set();
|
|
38
|
+
const warningIndexes = new Set();
|
|
39
|
+
const tail = [];
|
|
40
|
+
const fallback = [];
|
|
41
|
+
const distinctNonEmpty = new Set();
|
|
42
|
+
let distinctOverflow = false;
|
|
43
|
+
let clippedLine = false;
|
|
44
|
+
let previous;
|
|
45
|
+
let pendingErrorContext = false;
|
|
46
|
+
let pendingWarningContext = false;
|
|
47
|
+
let firstError;
|
|
48
|
+
let firstWarning;
|
|
49
|
+
let firstNonEmpty;
|
|
50
|
+
let finalLine;
|
|
51
|
+
let finalSummary;
|
|
52
|
+
let index = 0;
|
|
53
|
+
let start = 0;
|
|
54
|
+
while (true) {
|
|
55
|
+
const newline = raw.indexOf('\n', start);
|
|
56
|
+
const end = newline === -1 ? raw.length : newline;
|
|
57
|
+
const cleaned = cleanLine(raw.slice(start, end));
|
|
58
|
+
const cleanedBytes = Buffer.byteLength(cleaned);
|
|
59
|
+
const line = cleanedBytes <= maxLineBytes ? cleaned : utf8Prefix(cleaned, maxLineBytes);
|
|
60
|
+
if (line !== cleaned)
|
|
61
|
+
clippedLine = true;
|
|
62
|
+
const current = { index, line };
|
|
63
|
+
if (index < 2)
|
|
64
|
+
fallback.push(current);
|
|
65
|
+
tail.push(current);
|
|
66
|
+
if (tail.length > 4)
|
|
67
|
+
tail.shift();
|
|
68
|
+
const nonEmpty = cleaned.trim() !== '';
|
|
69
|
+
if (nonEmpty) {
|
|
70
|
+
firstNonEmpty ??= line;
|
|
71
|
+
finalLine = line;
|
|
72
|
+
if (!distinctOverflow) {
|
|
73
|
+
distinctNonEmpty.add(line);
|
|
74
|
+
if (distinctNonEmpty.size > MAX_DISTINCT_LINES) {
|
|
75
|
+
distinctOverflow = true;
|
|
76
|
+
distinctNonEmpty.clear();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (SUMMARY_SIGNAL.test(cleaned))
|
|
81
|
+
finalSummary = line;
|
|
82
|
+
if (pendingErrorContext)
|
|
83
|
+
addCandidate(errorCandidates, errorIndexes, current);
|
|
84
|
+
if (pendingWarningContext)
|
|
85
|
+
addCandidate(warningCandidates, warningIndexes, current);
|
|
86
|
+
pendingErrorContext = false;
|
|
87
|
+
pendingWarningContext = false;
|
|
88
|
+
if (ERROR_SIGNAL.test(cleaned)) {
|
|
89
|
+
firstError ??= line;
|
|
90
|
+
addCandidate(errorCandidates, errorIndexes, current);
|
|
91
|
+
addCandidate(errorCandidates, errorIndexes, previous);
|
|
92
|
+
pendingErrorContext = true;
|
|
93
|
+
}
|
|
94
|
+
if (WARNING_SIGNAL.test(cleaned)) {
|
|
95
|
+
firstWarning ??= line;
|
|
96
|
+
addCandidate(warningCandidates, warningIndexes, current);
|
|
97
|
+
addCandidate(warningCandidates, warningIndexes, previous);
|
|
98
|
+
pendingWarningContext = true;
|
|
99
|
+
}
|
|
100
|
+
previous = current;
|
|
101
|
+
if (newline === -1)
|
|
102
|
+
break;
|
|
103
|
+
index++;
|
|
104
|
+
start = newline + 1;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
originalLines: index + 1,
|
|
108
|
+
errorCandidates,
|
|
109
|
+
warningCandidates,
|
|
110
|
+
tail,
|
|
111
|
+
fallback,
|
|
112
|
+
firstSignal: firstError ?? firstWarning ?? firstNonEmpty,
|
|
113
|
+
...(finalLine === undefined ? {} : { finalLine }),
|
|
114
|
+
...(finalSummary === undefined ? {} : { finalSummary }),
|
|
115
|
+
distinctNonEmpty,
|
|
116
|
+
completePossible: !distinctOverflow && !clippedLine,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function uniqueCandidateLines(scan) {
|
|
120
|
+
const seenIndexes = new Set();
|
|
121
|
+
const seenLines = new Set();
|
|
122
|
+
const selected = [];
|
|
123
|
+
const prioritized = [
|
|
124
|
+
...scan.errorCandidates,
|
|
125
|
+
...scan.warningCandidates,
|
|
126
|
+
...scan.tail,
|
|
127
|
+
...(scan.errorCandidates.length + scan.warningCandidates.length === 0 ? scan.fallback : []),
|
|
128
|
+
];
|
|
129
|
+
for (const entry of prioritized) {
|
|
130
|
+
if (entry.line.trim() === '' || seenIndexes.has(entry.index))
|
|
131
|
+
continue;
|
|
132
|
+
seenIndexes.add(entry.index);
|
|
133
|
+
if (seenLines.has(entry.line))
|
|
134
|
+
continue;
|
|
135
|
+
seenLines.add(entry.line);
|
|
136
|
+
selected.push(entry.line);
|
|
137
|
+
}
|
|
138
|
+
return selected;
|
|
139
|
+
}
|
|
140
|
+
export function compactCommandOutput(raw, options) {
|
|
141
|
+
const originalBytes = Buffer.byteLength(raw);
|
|
142
|
+
if (raw === '')
|
|
143
|
+
return { preview: '', originalBytes: 0, originalLines: 0, omitted: false };
|
|
144
|
+
const scan = scanLines(raw, options.previewBytes);
|
|
145
|
+
const originalLines = scan.originalLines;
|
|
146
|
+
const candidates = uniqueCandidateLines(scan);
|
|
147
|
+
const complete = scan.completePossible && candidates.length >= scan.distinctNonEmpty.size;
|
|
148
|
+
const joined = candidates.join('\n');
|
|
149
|
+
if (complete && Buffer.byteLength(joined) <= options.previewBytes) {
|
|
150
|
+
return { preview: joined, originalBytes, originalLines, omitted: false };
|
|
151
|
+
}
|
|
152
|
+
const marker = `[… omitted; ${originalLines} lines, ${originalBytes} bytes total …]`;
|
|
153
|
+
const markerBytes = Buffer.byteLength(marker);
|
|
154
|
+
const contentBudget = Math.max(0, options.previewBytes - markerBytes - 1);
|
|
155
|
+
const selected = [];
|
|
156
|
+
let used = 0;
|
|
157
|
+
const essentials = [...new Set([scan.firstSignal, scan.finalSummary ?? scan.finalLine]
|
|
158
|
+
.filter((line) => Boolean(line)))];
|
|
159
|
+
for (const [index, line] of essentials.entries()) {
|
|
160
|
+
const separator = selected.length === 0 ? 0 : 1;
|
|
161
|
+
const remaining = essentials.length - index - 1;
|
|
162
|
+
const available = Math.max(0, contentBudget - used - separator - remaining);
|
|
163
|
+
const lineBudget = remaining === 0 ? available : Math.floor(available / (remaining + 1));
|
|
164
|
+
const value = utf8Prefix(line, lineBudget);
|
|
165
|
+
if (!value)
|
|
166
|
+
continue;
|
|
167
|
+
selected.push(value);
|
|
168
|
+
used += separator + Buffer.byteLength(value);
|
|
169
|
+
}
|
|
170
|
+
for (const line of candidates) {
|
|
171
|
+
if (essentials.includes(line))
|
|
172
|
+
continue;
|
|
173
|
+
const separator = selected.length === 0 ? 0 : 1;
|
|
174
|
+
const bytes = Buffer.byteLength(line);
|
|
175
|
+
if (used + separator + bytes <= contentBudget) {
|
|
176
|
+
selected.push(line);
|
|
177
|
+
used += separator + bytes;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (selected.length === 0 && contentBudget > 0) {
|
|
181
|
+
const truncated = utf8Prefix(line, contentBudget);
|
|
182
|
+
selected.push(truncated);
|
|
183
|
+
used = Buffer.byteLength(truncated);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const content = selected.filter(Boolean).join('\n');
|
|
188
|
+
const preview = content
|
|
189
|
+
? `${content}\n${utf8Prefix(marker, options.previewBytes - Buffer.byteLength(content) - 1)}`
|
|
190
|
+
: utf8Prefix(marker, options.previewBytes);
|
|
191
|
+
return { preview, originalBytes, originalLines, omitted: true };
|
|
192
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { hasShellControlOperator, parseQualityCommand } from './process-command.js';
|
|
4
|
+
export function collectCandidateArtifacts(input, adapters) {
|
|
5
|
+
const captured = captureCandidateArtifacts(input, adapters);
|
|
6
|
+
if (captured.kind === 'rejected')
|
|
7
|
+
return captured;
|
|
8
|
+
return { kind: 'collected', artifacts: captured.artifacts.map(value => value.artifact), digests: captured.digests };
|
|
9
|
+
}
|
|
10
|
+
export function captureCandidateArtifacts(input, adapters) {
|
|
11
|
+
if ('command' in input.candidate) {
|
|
12
|
+
if (hasShellControlOperator(input.candidate.command))
|
|
13
|
+
return { kind: 'rejected', reason: 'shell-control' };
|
|
14
|
+
if (!parseQualityCommand(input.candidate.command))
|
|
15
|
+
return { kind: 'rejected', reason: 'invalid-command' };
|
|
16
|
+
const bytes = input.candidate.kind === 'command-output'
|
|
17
|
+
? adapters.commandOutput(input.candidate.command)
|
|
18
|
+
: adapters.benchmark(input.candidate.command);
|
|
19
|
+
const artifact = { kind: input.candidate.kind, source: input.candidate.command, bytes: bytes.byteLength, digest: digest(bytes) };
|
|
20
|
+
return { kind: 'captured', artifacts: [{ artifact, bytes }], digests: [artifact.digest] };
|
|
21
|
+
}
|
|
22
|
+
const projectRoot = resolve(input.projectDir);
|
|
23
|
+
const proofRoot = join(projectRoot, '.yoke', 'proof');
|
|
24
|
+
const kind = input.candidate.kind === 'screenshots' ? 'screenshot' : 'file';
|
|
25
|
+
const artifacts = [];
|
|
26
|
+
for (const source of input.candidate.paths) {
|
|
27
|
+
if (containsTraversal(source))
|
|
28
|
+
return { kind: 'rejected', reason: 'path-traversal' };
|
|
29
|
+
if (isAbsolute(source))
|
|
30
|
+
return { kind: 'rejected', reason: 'outside-project' };
|
|
31
|
+
const path = resolve(projectRoot, source);
|
|
32
|
+
if (!isInside(projectRoot, path))
|
|
33
|
+
return { kind: 'rejected', reason: 'outside-project' };
|
|
34
|
+
if (kind === 'screenshot' && !isInside(proofRoot, path))
|
|
35
|
+
return { kind: 'rejected', reason: 'outside-proof' };
|
|
36
|
+
const canonicalRoot = adapters.realpath?.(projectRoot) ?? projectRoot;
|
|
37
|
+
const canonicalPath = adapters.realpath?.(path) ?? path;
|
|
38
|
+
if (!isInside(canonicalRoot, canonicalPath))
|
|
39
|
+
return { kind: 'rejected', reason: 'outside-project' };
|
|
40
|
+
if (kind === 'screenshot' && !isInside(adapters.realpath?.(proofRoot) ?? proofRoot, canonicalPath))
|
|
41
|
+
return { kind: 'rejected', reason: 'outside-proof' };
|
|
42
|
+
const bytes = adapters.readFile(canonicalPath);
|
|
43
|
+
if (bytes === null)
|
|
44
|
+
return { kind: 'rejected', reason: 'missing' };
|
|
45
|
+
artifacts.push({ artifact: { kind, path: canonicalPath, bytes: bytes.byteLength, digest: digest(bytes) }, bytes });
|
|
46
|
+
}
|
|
47
|
+
artifacts.sort((left, right) => left.artifact.digest.localeCompare(right.artifact.digest) || (left.artifact.path ?? '').localeCompare(right.artifact.path ?? ''));
|
|
48
|
+
return { kind: 'captured', artifacts, digests: artifacts.map(artifact => artifact.artifact.digest) };
|
|
49
|
+
}
|
|
50
|
+
function digest(bytes) {
|
|
51
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
function containsTraversal(path) {
|
|
54
|
+
return path.split(/[\\/]+/).includes('..');
|
|
55
|
+
}
|
|
56
|
+
function isInside(root, path) {
|
|
57
|
+
const relativePath = relative(root, path);
|
|
58
|
+
return relativePath === '' || (!relativePath.startsWith(`..${sep}`) && relativePath !== '..' && !isAbsolute(relativePath));
|
|
59
|
+
}
|