@really-knows-ai/foundry 3.3.8 → 3.4.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/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +2 -0
- package/dist/.opencode/plugins/foundry-tools/validate-tools.js +8 -245
- package/dist/CHANGELOG.md +57 -0
- package/dist/scripts/appraise-module.js +452 -0
- package/dist/scripts/lib/artefacts.js +12 -0
- package/dist/scripts/lib/validation.js +230 -0
- package/dist/scripts/orchestrate-cycle.js +65 -0
- package/dist/scripts/orchestrate-phases.js +9 -2
- package/dist/scripts/orchestrate.js +167 -43
- package/dist/scripts/quench-module.js +153 -0
- package/dist/scripts/sort.js +24 -7
- package/dist/skills/orchestrate/SKILL.md +29 -1
- package/package.json +5 -5
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appraise module — gathers context for parallel appraiser dispatch and
|
|
3
|
+
* consolidates results after all appraisers have run.
|
|
4
|
+
*
|
|
5
|
+
* Gather phase: reads artefacts, laws, and appraiser personalities, builds
|
|
6
|
+
* subagent prompts, and returns a dispatch_multi action so the orchestrator's
|
|
7
|
+
* LLM dispatches appraisers in parallel.
|
|
8
|
+
*
|
|
9
|
+
* Consolidate phase: receives lastResults from the orchestrator, unions and
|
|
10
|
+
* de-duplicates appraiser issues, posts feedback, and finalises the stage
|
|
11
|
+
* so the orchestrator can re-sort and determine the next action.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getArtefactsForCycle } from './lib/artefacts.js';
|
|
15
|
+
import { selectAppraisers, getLaws } from './lib/config.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Public API — gather
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gather appraise context: read draft artefacts, select appraisers, read laws
|
|
23
|
+
* and artefact content, then build a dispatch_multi action with one task per
|
|
24
|
+
* (artefact, appraiser) pair.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} ctx
|
|
27
|
+
* @param {string} ctx.cycleId
|
|
28
|
+
* @param {object} ctx.io
|
|
29
|
+
* @param {string} ctx.foundryDir
|
|
30
|
+
* @param {string} [ctx.defaultModel] - Fallback model when an appraiser has no
|
|
31
|
+
* explicit model.
|
|
32
|
+
* @returns {Promise<{action: string, tasks: Array, stage: string, cycle: string}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function gatherAppraiseContext(ctx) {
|
|
35
|
+
if (!ctx.cycleId) {
|
|
36
|
+
return violation('cycleId is required', []);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const artefacts = getArtefactsForCycle(ctx.cycleId, ctx.io);
|
|
40
|
+
if (artefacts.length === 0) {
|
|
41
|
+
return emptyDispatch(ctx.cycleId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tasks = await collectTasks(artefacts, ctx);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
action: 'dispatch_multi',
|
|
48
|
+
tasks,
|
|
49
|
+
stage: `appraise:${ctx.cycleId}`,
|
|
50
|
+
cycle: ctx.cycleId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build all appraiser tasks across artefacts, caching per type.
|
|
56
|
+
*/
|
|
57
|
+
async function collectTasks(artefacts, ctx) {
|
|
58
|
+
const tasks = [];
|
|
59
|
+
const typeCache = new Map();
|
|
60
|
+
|
|
61
|
+
for (const artefact of artefacts) {
|
|
62
|
+
const entry = await resolveTypeEntry(artefact.type, typeCache, ctx);
|
|
63
|
+
if (!entry) continue;
|
|
64
|
+
|
|
65
|
+
addTasksForArtefact(tasks, artefact, entry, ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return tasks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get or create a cached (appraisers, laws) entry for an artefact type.
|
|
73
|
+
* Returns null when no appraisers are available for the type.
|
|
74
|
+
*/
|
|
75
|
+
async function resolveTypeEntry(typeId, cache, ctx) {
|
|
76
|
+
if (cache.has(typeId)) {
|
|
77
|
+
return cache.get(typeId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [appraisers, laws] = await Promise.all([
|
|
81
|
+
selectAppraisers(ctx.foundryDir, typeId, { io: ctx.io }),
|
|
82
|
+
getLaws(ctx.foundryDir, ctx.io, { typeId }),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const entry = appraisers.length === 0 ? null : { appraisers, laws };
|
|
86
|
+
cache.set(typeId, entry);
|
|
87
|
+
return entry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build and append appraiser tasks for a single artefact.
|
|
92
|
+
*/
|
|
93
|
+
function addTasksForArtefact(tasks, artefact, entry, ctx) {
|
|
94
|
+
const content = ctx.io.readFile(artefact.file);
|
|
95
|
+
|
|
96
|
+
for (const appraiser of entry.appraisers) {
|
|
97
|
+
const prompt = buildAppraiserPrompt({
|
|
98
|
+
appraiser,
|
|
99
|
+
artefact: { file: artefact.file, content },
|
|
100
|
+
laws: entry.laws,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
tasks.push({
|
|
104
|
+
subagent_type: resolveSubagentType(appraiser, ctx),
|
|
105
|
+
prompt,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Map an appraiser's model to a subagent type string.
|
|
112
|
+
*/
|
|
113
|
+
function resolveSubagentType(appraiser, ctx) {
|
|
114
|
+
const name = appraiser.model || ctx.defaultModel || 'general';
|
|
115
|
+
if (name === 'general') return 'general';
|
|
116
|
+
|
|
117
|
+
return `foundry-${name.replace(/[/.]/g, '-')}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Empty dispatch response when there is nothing to appraise.
|
|
122
|
+
*/
|
|
123
|
+
function emptyDispatch(cycleId) {
|
|
124
|
+
return {
|
|
125
|
+
action: 'dispatch_multi',
|
|
126
|
+
tasks: [],
|
|
127
|
+
stage: `appraise:${cycleId}`,
|
|
128
|
+
cycle: cycleId,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Public API — consolidate
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Consolidate appraiser results after all subagents have run.
|
|
138
|
+
*
|
|
139
|
+
* Parses each successful output for structured issues, unions across
|
|
140
|
+
* appraisers, de-duplicates by (file, law-id, issue text), posts feedback,
|
|
141
|
+
* resolves stale prior appraise feedback, and finalises the stage.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} ctx
|
|
144
|
+
* @param {Array<{ok: boolean, output?: string, error?: string}>} lastResults
|
|
145
|
+
* @returns {Promise<{ok: boolean, summary?: string}|violation>}
|
|
146
|
+
*/
|
|
147
|
+
export async function consolidateAppraise(ctx, lastResults) {
|
|
148
|
+
const baseSha = ctx.activeStage?.baseSha;
|
|
149
|
+
if (!baseSha) {
|
|
150
|
+
return violation('No active stage found', []);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const results = arrayFrom(lastResults);
|
|
154
|
+
const successful = results.filter(r => r.ok === true);
|
|
155
|
+
|
|
156
|
+
if (allAppraisersFailed(results, successful)) {
|
|
157
|
+
return violation('All appraisers failed to evaluate the artefact', []);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const consolidated = parseConsolidated(successful);
|
|
161
|
+
const stageId = `appraise:${ctx.cycleId}`;
|
|
162
|
+
|
|
163
|
+
postConsolidatedFeedback(ctx, consolidated);
|
|
164
|
+
resolvePriorAppraise(ctx, consolidated, stageId);
|
|
165
|
+
|
|
166
|
+
const summary = buildConsolidateSummary(consolidated.length);
|
|
167
|
+
|
|
168
|
+
await ctx.finalize({
|
|
169
|
+
lastStage: { stage: stageId, summary, baseSha },
|
|
170
|
+
activeStage: ctx.activeStage,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return { ok: true, summary };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parse all successful appraiser outputs and de-duplicate the combined issue
|
|
178
|
+
* list by (file, law-id, issue text).
|
|
179
|
+
*/
|
|
180
|
+
function parseConsolidated(successful) {
|
|
181
|
+
const all = [];
|
|
182
|
+
|
|
183
|
+
for (const result of successful) {
|
|
184
|
+
const issues = parseAppraiserOutput(result.output || '');
|
|
185
|
+
all.push(...issues);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return deduplicateIssues(all);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* De-duplicate an issue array by (file, law, issue text).
|
|
193
|
+
*/
|
|
194
|
+
function deduplicateIssues(issues) {
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
const result = [];
|
|
197
|
+
|
|
198
|
+
for (const issue of issues) {
|
|
199
|
+
const key = `${issue.file}:${issue.law}:${issue.issue}`;
|
|
200
|
+
if (!seen.has(key)) {
|
|
201
|
+
seen.add(key);
|
|
202
|
+
result.push(issue);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Post one feedback item per consolidated issue.
|
|
211
|
+
*/
|
|
212
|
+
function postConsolidatedFeedback(ctx, consolidated) {
|
|
213
|
+
for (const issue of consolidated) {
|
|
214
|
+
ctx.feedback.add({
|
|
215
|
+
file: issue.file,
|
|
216
|
+
text: issue.issue,
|
|
217
|
+
tag: `law:${issue.law}`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve prior appraise feedback: items still present stay rejected, stale
|
|
224
|
+
* items are approved.
|
|
225
|
+
*/
|
|
226
|
+
function resolvePriorAppraise(ctx, consolidated, stageId) {
|
|
227
|
+
const current = new Set(
|
|
228
|
+
consolidated.map(i => `${i.file}:law:${i.law}`)
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const priorItems = ctx.feedback.list({ source: stageId });
|
|
232
|
+
|
|
233
|
+
for (const prior of priorItems) {
|
|
234
|
+
if (prior.state === 'resolved') continue;
|
|
235
|
+
const sig = `${prior.file}:${prior.tag}`;
|
|
236
|
+
const decision = current.has(sig) ? 'rejected' : 'approved';
|
|
237
|
+
ctx.feedback.resolve(prior.id, decision);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build the summary string for consolidation.
|
|
243
|
+
*/
|
|
244
|
+
function buildConsolidateSummary(count) {
|
|
245
|
+
if (count === 0) return 'No issues found by appraisers';
|
|
246
|
+
|
|
247
|
+
return `${count} issue(s) found by appraisers`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Prompt builder
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build a subagent prompt for a single (appraiser, artefact) pair.
|
|
256
|
+
*
|
|
257
|
+
* Follows the template from the appraise skill (src/skills/appraise/SKILL.md)
|
|
258
|
+
* extended to include the file path for deterministic result parsing.
|
|
259
|
+
*/
|
|
260
|
+
function buildAppraiserPrompt({ appraiser, artefact, laws }) {
|
|
261
|
+
const lawSections = laws
|
|
262
|
+
.map(law => `## ${law.id}\n\n${law.text}`)
|
|
263
|
+
.join('\n\n');
|
|
264
|
+
|
|
265
|
+
const lines = [
|
|
266
|
+
'You are an appraiser. Your personality:',
|
|
267
|
+
'',
|
|
268
|
+
appraiser.personality,
|
|
269
|
+
'',
|
|
270
|
+
'Evaluate the following artefact against each law below. For each law,',
|
|
271
|
+
'either:',
|
|
272
|
+
'- Note no issues (pass)',
|
|
273
|
+
'- Describe the issue, quoting evidence from the artefact',
|
|
274
|
+
'',
|
|
275
|
+
'## Artefact',
|
|
276
|
+
'',
|
|
277
|
+
artefact.content,
|
|
278
|
+
'',
|
|
279
|
+
'## Laws',
|
|
280
|
+
'',
|
|
281
|
+
lawSections,
|
|
282
|
+
'',
|
|
283
|
+
'## Output',
|
|
284
|
+
'',
|
|
285
|
+
'Return a list of issues. For each issue:',
|
|
286
|
+
`- file: ${artefact.file}`,
|
|
287
|
+
'- law: <law-id>',
|
|
288
|
+
'- issue: <description>',
|
|
289
|
+
'- evidence: <quote from artefact>',
|
|
290
|
+
'',
|
|
291
|
+
'If there are no issues, return an empty list.',
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
return lines.join('\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Output parsing
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Parse a structured issue list from an appraiser subagent output.
|
|
303
|
+
*
|
|
304
|
+
* Expected per-issue format (YAML list):
|
|
305
|
+
* - file: <path>
|
|
306
|
+
* law: <law-id>
|
|
307
|
+
* issue: <description>
|
|
308
|
+
* evidence: <quote>
|
|
309
|
+
*
|
|
310
|
+
* Returns an array of { file, law, issue, evidence } objects. Entries that
|
|
311
|
+
* lack a file, law, or issue field are silently skipped.
|
|
312
|
+
*/
|
|
313
|
+
function parseAppraiserOutput(output) {
|
|
314
|
+
// Split output into entries on boundaries where a new line starts a
|
|
315
|
+
// list entry ("- "). Avoids regex to prevent sonarjs/slow-regex.
|
|
316
|
+
const entries = [];
|
|
317
|
+
let buffer = '';
|
|
318
|
+
|
|
319
|
+
for (const line of output.split('\n')) {
|
|
320
|
+
if (buffer && isListEntryStart(line)) {
|
|
321
|
+
entries.push(buffer);
|
|
322
|
+
buffer = line;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
buffer = concatLine(buffer, line);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (buffer) entries.push(buffer);
|
|
330
|
+
|
|
331
|
+
return entries
|
|
332
|
+
.map(parseRawEntry)
|
|
333
|
+
.filter(e => e !== null);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Append a line to the current buffer string.
|
|
338
|
+
*/
|
|
339
|
+
function concatLine(buffer, line) {
|
|
340
|
+
if (!buffer) return line;
|
|
341
|
+
return `${buffer}\n${line}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* True when a line marks the start of a new YAML list entry (starts with
|
|
346
|
+
* "- " after optional whitespace).
|
|
347
|
+
*/
|
|
348
|
+
function isListEntryStart(line) {
|
|
349
|
+
for (let i = 0; i < line.length; i++) {
|
|
350
|
+
const ch = line[i];
|
|
351
|
+
if (ch === ' ' || ch === '\t') continue;
|
|
352
|
+
return ch === '-' && line[i + 1] === ' ';
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Parse a single raw entry block into an issue object, or null when
|
|
359
|
+
* required fields are missing.
|
|
360
|
+
*/
|
|
361
|
+
function parseRawEntry(raw) {
|
|
362
|
+
const block = raw.trim();
|
|
363
|
+
|
|
364
|
+
const file = extractField(block, 'file');
|
|
365
|
+
const law = extractField(block, 'law');
|
|
366
|
+
const issue = extractField(block, 'issue');
|
|
367
|
+
const evidence = extractField(block, 'evidence');
|
|
368
|
+
|
|
369
|
+
if (!file || !law || !issue) return null;
|
|
370
|
+
|
|
371
|
+
return { file, law, issue, evidence: evidence || '' };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Extract a YAML list item field value.
|
|
376
|
+
*
|
|
377
|
+
* Matches lines like:
|
|
378
|
+
* - file: value
|
|
379
|
+
* law: value
|
|
380
|
+
*
|
|
381
|
+
* The field name may be preceded by optional whitespace and/or a "- " list
|
|
382
|
+
* marker. Returns the value portion, trimmed.
|
|
383
|
+
*/
|
|
384
|
+
function extractField(text, key) {
|
|
385
|
+
// Walk lines to find "key: value" preceded only by whitespace or a "- "
|
|
386
|
+
// list marker. Avoids regex quantifiers that trigger sonarjs/slow-regex.
|
|
387
|
+
|
|
388
|
+
for (const line of text.split('\n')) {
|
|
389
|
+
const trimmed = line.trim();
|
|
390
|
+
const value = tryExtractKey(trimmed, key);
|
|
391
|
+
if (value !== null) return value;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Given a single trimmed line, try to extract the value for key.
|
|
399
|
+
* Returns null if the pattern is not found.
|
|
400
|
+
*/
|
|
401
|
+
function tryExtractKey(line, key) {
|
|
402
|
+
const needle = `${key}:`;
|
|
403
|
+
const idx = line.indexOf(needle);
|
|
404
|
+
if (idx < 0) return null;
|
|
405
|
+
|
|
406
|
+
const before = line.slice(0, idx);
|
|
407
|
+
if (before.length > 0 && !isLegalPrefix(before)) return null;
|
|
408
|
+
|
|
409
|
+
const value = line.slice(idx + needle.length).trim();
|
|
410
|
+
return value || null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* True when the text before a key: on a line is either all whitespace or
|
|
415
|
+
* the "- " list marker.
|
|
416
|
+
*/
|
|
417
|
+
function isLegalPrefix(before) {
|
|
418
|
+
if (before === '- ') return true;
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < before.length; i++) {
|
|
421
|
+
if (before[i] !== ' ' && before[i] !== '\t') return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Shared helpers
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
function violation(details, affectedFiles) {
|
|
432
|
+
return {
|
|
433
|
+
action: 'violation',
|
|
434
|
+
details,
|
|
435
|
+
recoverable: false,
|
|
436
|
+
affected_files: affectedFiles,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Safely coerce a value to an array, defaulting to empty array.
|
|
442
|
+
*/
|
|
443
|
+
function arrayFrom(value) {
|
|
444
|
+
return Array.isArray(value) ? value : [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* True when there were results but none succeeded.
|
|
449
|
+
*/
|
|
450
|
+
function allAppraisersFailed(results, successful) {
|
|
451
|
+
return results.length > 0 && successful.length === 0;
|
|
452
|
+
}
|
|
@@ -149,3 +149,15 @@ export function setArtefactStatus(text, file, newStatus) {
|
|
|
149
149
|
|
|
150
150
|
throw new Error(`File not found in artefacts table: ${file}`);
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get draft artefacts for a specific cycle from the artefacts table.
|
|
155
|
+
* @param {string} cycleId - Cycle ID to filter by
|
|
156
|
+
* @param {object} io - IO interface
|
|
157
|
+
* @returns {Array<{file: string, type: string, cycle: string, status: string}>}
|
|
158
|
+
*/
|
|
159
|
+
export function getArtefactsForCycle(cycleId, io) {
|
|
160
|
+
const text = io.readFile('WORK.md');
|
|
161
|
+
const artefacts = parseArtefactsTable(text);
|
|
162
|
+
return artefacts.filter(a => a.cycle === cycleId && a.status === 'draft');
|
|
163
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared validation utilities extracted from the plugin's validate-tools.
|
|
3
|
+
*
|
|
4
|
+
* These functions run deterministic validator commands (no LLM involvement)
|
|
5
|
+
* and return structured results consumed by the quench module and the
|
|
6
|
+
* plugin's `foundry_validate_run` tool.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
import { readdir } from 'fs/promises';
|
|
11
|
+
import { join, relative, dirname, sep } from 'path';
|
|
12
|
+
import { minimatch } from 'minimatch';
|
|
13
|
+
import { getLawsForQuench, getArtefactType } from './config.js';
|
|
14
|
+
import { parseValidatorJsonl } from './validator-jsonl.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Private helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
function shellQuote(value) {
|
|
21
|
+
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validatePatterns(patterns, typeId) {
|
|
25
|
+
if (!Array.isArray(patterns) || patterns.length === 0) {
|
|
26
|
+
return { ok: false, error: `Artefact type ${typeId} has no file-patterns` };
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SKIP_DIRS = new Set(['node_modules', '.git']);
|
|
32
|
+
|
|
33
|
+
function toPosix(p) {
|
|
34
|
+
return sep === '/' ? p : p.split(sep).join('/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readdirSafe(dir) {
|
|
38
|
+
try {
|
|
39
|
+
return await readdir(dir, { withFileTypes: true });
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function* walkFiles(root, dir) {
|
|
46
|
+
for (const entry of await readdirSafe(dir)) {
|
|
47
|
+
const full = join(dir, entry.name);
|
|
48
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
49
|
+
yield* walkFiles(root, full);
|
|
50
|
+
} else if (entry.isFile()) {
|
|
51
|
+
yield toPosix(relative(root, full));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fileMatchesAnyPattern(rel, patterns) {
|
|
57
|
+
for (const pattern of patterns) {
|
|
58
|
+
if (minimatch(rel, pattern)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Exported functions
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read file-patterns for an artefact type from its definition.
|
|
69
|
+
*/
|
|
70
|
+
export async function getValidationPatterns(foundryDir, typeId, io) {
|
|
71
|
+
const artType = await getArtefactType(foundryDir, typeId, io);
|
|
72
|
+
return artType.frontmatter['file-patterns'] || [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run validation commands for an artefact type deterministically.
|
|
77
|
+
*
|
|
78
|
+
* Accepts an IO interface and foundryDir directly (no plugin context
|
|
79
|
+
* dependency). Returns a plain result object.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ typeId: string, io: object, foundryDir: string }} params
|
|
82
|
+
* @returns {Promise<{ ok: boolean, validatorsRun: number, items: Array, errors: Array }>}
|
|
83
|
+
*/
|
|
84
|
+
export async function performValidation({ typeId, io, foundryDir }) {
|
|
85
|
+
let patterns;
|
|
86
|
+
try {
|
|
87
|
+
patterns = await getValidationPatterns(foundryDir, typeId, io);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return { ok: false, error: err.message };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const validationErr = validatePatterns(patterns, typeId);
|
|
93
|
+
if (validationErr) return validationErr;
|
|
94
|
+
|
|
95
|
+
const laws = await getLawsForQuench(foundryDir, io, { typeId });
|
|
96
|
+
if (!laws?.length) {
|
|
97
|
+
return { ok: true, validatorsRun: 0, items: [], errors: [] };
|
|
98
|
+
}
|
|
99
|
+
return runValidatorsAndReport(laws, patterns, foundryDir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run validators for a set of laws and build the aggregated result.
|
|
104
|
+
*/
|
|
105
|
+
export async function runValidatorsAndReport(laws, patterns, foundryDir) {
|
|
106
|
+
const worktree = dirname(foundryDir);
|
|
107
|
+
const expandedFiles = await expandPatterns(patterns, worktree);
|
|
108
|
+
const substitutions = {
|
|
109
|
+
pattern: patterns.map(shellQuote).join(' '),
|
|
110
|
+
files: expandedFiles.map(shellQuote).join(' '),
|
|
111
|
+
};
|
|
112
|
+
const results = await runValidators(laws, patterns, substitutions, worktree);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
ok: results.errors.length === 0,
|
|
116
|
+
validatorsRun: results.validatorsRun,
|
|
117
|
+
items: results.items,
|
|
118
|
+
errors: results.errors,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Expand glob patterns to actual file paths in the worktree.
|
|
124
|
+
*
|
|
125
|
+
* Uses readdir + minimatch for Node 20 compatibility (glob added in Node 22).
|
|
126
|
+
*/
|
|
127
|
+
export async function expandPatterns(patterns, worktree) {
|
|
128
|
+
const files = new Set();
|
|
129
|
+
for await (const rel of walkFiles(worktree, worktree)) {
|
|
130
|
+
if (fileMatchesAnyPattern(rel, patterns)) {
|
|
131
|
+
files.add(rel);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return Array.from(files).sort();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run all validators across a list of laws and collect results.
|
|
139
|
+
*/
|
|
140
|
+
export async function runValidators(laws, patterns, substitutions, worktree) {
|
|
141
|
+
const results = {
|
|
142
|
+
validatorsRun: 0,
|
|
143
|
+
items: [],
|
|
144
|
+
errors: [],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const law of laws) {
|
|
148
|
+
if (!law.validators || law.validators.length === 0) continue;
|
|
149
|
+
await runLawValidators(law, patterns, substitutions, worktree, results);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Run validators for a single law.
|
|
157
|
+
*/
|
|
158
|
+
export async function runLawValidators(law, patterns, substitutions, worktree, results) {
|
|
159
|
+
for (const validator of law.validators) {
|
|
160
|
+
// Skip commands that require {files} when there are no matching files
|
|
161
|
+
if (substitutions.files === '' && /(?:^|\s)\{files\}(?=\s|$)/.test(validator.command)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
results.validatorsRun++;
|
|
165
|
+
const expanded = expandValidatorCommand(validator.command, substitutions);
|
|
166
|
+
const parseResult = await executeValidator(expanded, worktree, patterns);
|
|
167
|
+
collectValidatorResult(parseResult, law.id, validator.id, results);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Fold a single validator's parse result into the aggregate results.
|
|
173
|
+
*/
|
|
174
|
+
export function collectValidatorResult(parseResult, lawId, validatorId, results) {
|
|
175
|
+
for (const item of parseResult.items) {
|
|
176
|
+
results.items.push({ lawId, validatorId, ...item });
|
|
177
|
+
}
|
|
178
|
+
for (const message of parseResult.parseErrors) {
|
|
179
|
+
results.errors.push({ lawId, validatorId, type: 'parse', message });
|
|
180
|
+
}
|
|
181
|
+
for (const message of parseResult.patternErrors) {
|
|
182
|
+
results.errors.push({ lawId, validatorId, type: 'pattern-mismatch', message });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Execute a validator command and parse its JSONL output.
|
|
188
|
+
*/
|
|
189
|
+
export async function executeValidator(expanded, worktree, patterns) {
|
|
190
|
+
try {
|
|
191
|
+
const output = execSync(expanded, {
|
|
192
|
+
cwd: worktree,
|
|
193
|
+
encoding: 'utf8',
|
|
194
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
195
|
+
});
|
|
196
|
+
const { Readable } = await import('stream');
|
|
197
|
+
const stream = Readable.from([output]);
|
|
198
|
+
return await parseValidatorJsonl(stream, patterns);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// Validator command failed — prefer stdout for JSONL
|
|
201
|
+
// (tools like rg exit 1 with results on stdout)
|
|
202
|
+
const output = (err.stdout || err.stderr || err.message || '').trim();
|
|
203
|
+
const { Readable } = await import('stream');
|
|
204
|
+
const stream = Readable.from([output]);
|
|
205
|
+
return await parseValidatorJsonl(stream, patterns);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Expand validator command placeholders {pattern} and {files}.
|
|
211
|
+
*
|
|
212
|
+
* Both placeholders are recognised only as standalone tokens bounded by
|
|
213
|
+
* whitespace or start/end of string. Surrounding single or double quotes
|
|
214
|
+
* around the placeholder are stripped first.
|
|
215
|
+
*/
|
|
216
|
+
export function expandValidatorCommand(command, { pattern, files }) {
|
|
217
|
+
let cmd = command
|
|
218
|
+
.replace(/"\{pattern\}"/g, '{pattern}')
|
|
219
|
+
.replace(/'\{pattern\}'/g, '{pattern}')
|
|
220
|
+
.replace(/"\{files\}"/g, '{files}')
|
|
221
|
+
.replace(/'\{files\}'/g, '{files}');
|
|
222
|
+
|
|
223
|
+
cmd = cmd.replace(/(?:^|\s)\{pattern\}(?=\s|$)/g, (match) =>
|
|
224
|
+
match.startsWith('{') ? pattern : ' ' + pattern);
|
|
225
|
+
|
|
226
|
+
cmd = cmd.replace(/(?:^|\s)\{files\}(?=\s|$)/g, (match) =>
|
|
227
|
+
match.startsWith('{') ? files : ' ' + files);
|
|
228
|
+
|
|
229
|
+
return cmd;
|
|
230
|
+
}
|