@bendyline/gezel 0.1.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/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/checks/index.d.ts +693 -0
- package/dist/checks/index.js +1848 -0
- package/dist/device-safety-DezzpNyR.d.ts +10 -0
- package/dist/index-D_dch9Qh.d.ts +59398 -0
- package/dist/index.d.ts +3217 -0
- package/dist/index.js +25602 -0
- package/dist/markdown/index.d.ts +174 -0
- package/dist/markdown/index.js +4022 -0
- package/dist/native/index.d.ts +650 -0
- package/dist/native/index.js +1121 -0
- package/dist/paths.d.ts +578 -0
- package/dist/paths.js +573 -0
- package/dist/report-action-DzdQHzGG.d.ts +4270 -0
- package/dist/schemas/index.d.ts +3 -0
- package/dist/schemas/index.js +15382 -0
- package/package.json +85 -0
|
@@ -0,0 +1,1848 @@
|
|
|
1
|
+
// src/checks/html.ts
|
|
2
|
+
var MIN_INLINE_JS_BYTES = 2048;
|
|
3
|
+
var SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
4
|
+
var SCRIPT_OPEN_RE = /<script\b[^>]*>/gi;
|
|
5
|
+
var SCRIPT_CLOSE_RE = /<\/script\s*>/gi;
|
|
6
|
+
function detectUnclosedScript(html) {
|
|
7
|
+
const opens = (html.match(SCRIPT_OPEN_RE) ?? []).length;
|
|
8
|
+
const closes = (html.match(SCRIPT_CLOSE_RE) ?? []).length;
|
|
9
|
+
return { opens, closes, unclosed: opens > closes };
|
|
10
|
+
}
|
|
11
|
+
function extractInlineScripts(html) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const m of html.matchAll(SCRIPT_RE)) {
|
|
14
|
+
const attrs = m[1] ?? "";
|
|
15
|
+
const body = m[2] ?? "";
|
|
16
|
+
if (/\bsrc\s*=/.test(attrs)) continue;
|
|
17
|
+
const typeMatch = attrs.match(/\btype\s*=\s*["']([^"']+)["']/i);
|
|
18
|
+
if (typeMatch) {
|
|
19
|
+
const t = typeMatch[1].toLowerCase().trim();
|
|
20
|
+
if (t !== "text/javascript" && t !== "application/javascript" && t !== "module") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
out.push({ body, attrs });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function validateScriptSyntax(scripts) {
|
|
29
|
+
const perScript = [];
|
|
30
|
+
let totalBytes = 0;
|
|
31
|
+
let allParse = true;
|
|
32
|
+
let firstError;
|
|
33
|
+
for (const s of scripts) {
|
|
34
|
+
const bytes = s.body.length;
|
|
35
|
+
totalBytes += bytes;
|
|
36
|
+
if (bytes === 0) {
|
|
37
|
+
perScript.push({ bytes: 0, parses: true });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const isModule = /\btype\s*=\s*["']module["']/i.test(s.attrs);
|
|
41
|
+
if (isModule) {
|
|
42
|
+
perScript.push({ bytes, parses: true });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
new Function(s.body);
|
|
47
|
+
perScript.push({ bytes, parses: true });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
let message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
const tsism = detectTypeScriptOnlySyntax(s.body);
|
|
51
|
+
if (tsism) {
|
|
52
|
+
message = `${message} \u2014 the script uses TypeScript-only syntax (${tsism}) which browsers cannot parse; rewrite the inline <script> as plain JavaScript (no type annotations, no \`!\` assertions, no \`as\` casts)`;
|
|
53
|
+
}
|
|
54
|
+
perScript.push({ bytes, parses: false, error: message });
|
|
55
|
+
allParse = false;
|
|
56
|
+
if (firstError === void 0) firstError = message;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
totalBytes,
|
|
61
|
+
allParse,
|
|
62
|
+
perScript,
|
|
63
|
+
...firstError !== void 0 ? { firstError } : {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function detectTypeScriptOnlySyntax(body) {
|
|
67
|
+
const probes = [
|
|
68
|
+
[/\w[)\]]?!(?=\s*[,);.\]])/, "a postfix `!` non-null assertion"],
|
|
69
|
+
[/\)\s*as\s+[A-Z]\w*/, "an `as Type` cast"],
|
|
70
|
+
[/\b(?:private|public|readonly)\s+\w+\s*[:=]/, "a class property modifier"],
|
|
71
|
+
[/^\s*(?:export\s+)?(?:interface|enum)\s+\w+/m, "an `interface`/`enum` declaration"],
|
|
72
|
+
[/\(\s*\w+\s*:\s*(?:string|number|boolean|any|\w+\[\])\s*[,)]/, "a parameter type annotation"]
|
|
73
|
+
];
|
|
74
|
+
for (const [re, label] of probes) {
|
|
75
|
+
const m = body.match(re);
|
|
76
|
+
if (m) return `${label}, e.g. \`${m[0].trim().slice(0, 40)}\``;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function inlineJsBytes(html) {
|
|
81
|
+
let total = 0;
|
|
82
|
+
for (const m of html.matchAll(SCRIPT_RE)) {
|
|
83
|
+
total += (m[2] ?? "").trim().length;
|
|
84
|
+
}
|
|
85
|
+
return total;
|
|
86
|
+
}
|
|
87
|
+
function htmlCompleteSniff(html) {
|
|
88
|
+
const lower = html.toLowerCase();
|
|
89
|
+
const opens = (lower.match(/<script\b/g) ?? []).length;
|
|
90
|
+
const closes = (lower.match(/<\/script>/g) ?? []).length;
|
|
91
|
+
const scriptsBalanced = opens === closes;
|
|
92
|
+
const closedDoc = lower.includes("</body>") || lower.includes("</html>");
|
|
93
|
+
return scriptsBalanced && closedDoc;
|
|
94
|
+
}
|
|
95
|
+
function htmlGameSniff(html, minJsBytes = 400) {
|
|
96
|
+
const lower = html.toLowerCase();
|
|
97
|
+
const hasRenderTarget = /<canvas\b/.test(lower) || /<svg\b/.test(lower);
|
|
98
|
+
const hasFrameLoop = /requestanimationframe\s*\(/.test(lower) || /set(?:interval|timeout)\s*\(/.test(lower) || /\bfunction\s+(?:tick|update|loop|gameloop|gametick|step|render|frame)\b/.test(lower);
|
|
99
|
+
const hasSurface = hasRenderTarget || hasFrameLoop;
|
|
100
|
+
const opens = (lower.match(/<script\b/g) ?? []).length;
|
|
101
|
+
const closes = (lower.match(/<\/script>/g) ?? []).length;
|
|
102
|
+
const scriptClosed = opens > 0 && opens === closes;
|
|
103
|
+
const jsSubstantial = inlineJsBytes(html) >= minJsBytes;
|
|
104
|
+
return hasSurface && scriptClosed && jsSubstantial;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/checks/files.ts
|
|
108
|
+
async function fileMinBytes(ws, file, bytes, trim = false) {
|
|
109
|
+
const content = await ws.read(file);
|
|
110
|
+
const n = (trim ? content?.trim() : content)?.length ?? 0;
|
|
111
|
+
return n >= bytes ? { ok: true, detail: `${file} is ${n} bytes` } : { ok: false, detail: `${file} is ${n} bytes, need \u2265 ${bytes}` };
|
|
112
|
+
}
|
|
113
|
+
async function fileMinLines(ws, file, minLines) {
|
|
114
|
+
const content = await ws.read(file);
|
|
115
|
+
const lines = (content ?? "").split("\n").filter((l) => l.trim().length > 0).length;
|
|
116
|
+
return lines >= minLines ? { ok: true, detail: `${file} has ${lines} non-blank lines` } : { ok: false, detail: `${file} has ${lines} non-blank line(s), need \u2265 ${minLines}` };
|
|
117
|
+
}
|
|
118
|
+
async function totalMinBytes(ws, files, bytes) {
|
|
119
|
+
let total = 0;
|
|
120
|
+
for (const f of files) total += (await ws.read(f))?.length ?? 0;
|
|
121
|
+
return total >= bytes ? { ok: true, detail: `${files.join(" + ")} total ${total} bytes` } : { ok: false, detail: `${files.join(" + ")} total ${total} bytes, need \u2265 ${bytes}` };
|
|
122
|
+
}
|
|
123
|
+
function isValidRasterAsset(bytes) {
|
|
124
|
+
if (bytes.length < 12) return false;
|
|
125
|
+
const containsAscii = (token) => {
|
|
126
|
+
const wanted = [...token].map((char) => char.charCodeAt(0));
|
|
127
|
+
outer: for (let i = 0; i <= bytes.length - wanted.length; i++) {
|
|
128
|
+
for (let j = 0; j < wanted.length; j++) {
|
|
129
|
+
if (bytes[i + j] !== wanted[j]) continue outer;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
};
|
|
135
|
+
const ascii = (from, to) => String.fromCharCode(...bytes.slice(from, to));
|
|
136
|
+
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10].every(
|
|
137
|
+
(value, index) => bytes[index] === value
|
|
138
|
+
);
|
|
139
|
+
const png = pngSignature && ascii(12, 16) === "IHDR" && containsAscii("IDAT") && containsAscii("IEND");
|
|
140
|
+
const jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 && bytes[bytes.length - 2] === 255 && bytes[bytes.length - 1] === 217;
|
|
141
|
+
const gif = (ascii(0, 6) === "GIF87a" || ascii(0, 6) === "GIF89a") && bytes.includes(44) && bytes[bytes.length - 1] === 59;
|
|
142
|
+
const webp = ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP" && ["VP8 ", "VP8L", "VP8X"].includes(ascii(12, 16));
|
|
143
|
+
return bytes.length >= 1024 && (png || jpeg || gif || webp);
|
|
144
|
+
}
|
|
145
|
+
var RASTER_EXTS = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp"]);
|
|
146
|
+
async function fileCountByExt(ws, ext, min, dir, opts) {
|
|
147
|
+
const all = await ws.list();
|
|
148
|
+
const exts = new Set(ext.map((e) => e.toLowerCase().replace(/^\./, "")));
|
|
149
|
+
const dirPrefix = dir ? `${dir.toLowerCase().replace(/\/+$/, "")}/` : null;
|
|
150
|
+
let matched = all.filter((p) => {
|
|
151
|
+
const lower = p.toLowerCase();
|
|
152
|
+
if (dirPrefix && !lower.startsWith(dirPrefix)) return false;
|
|
153
|
+
return exts.has(lower.split(".").pop() ?? "");
|
|
154
|
+
});
|
|
155
|
+
let unverifiable = false;
|
|
156
|
+
if (opts?.verifyImageBytes) {
|
|
157
|
+
const readBytes = ws.readBytes?.bind(ws);
|
|
158
|
+
if (!readBytes) {
|
|
159
|
+
unverifiable = true;
|
|
160
|
+
} else {
|
|
161
|
+
const verdicts = await Promise.all(
|
|
162
|
+
matched.map(async (p) => {
|
|
163
|
+
const fileExt = p.toLowerCase().split(".").pop() ?? "";
|
|
164
|
+
if (!RASTER_EXTS.has(fileExt)) return true;
|
|
165
|
+
const bytes = await readBytes(p).catch(() => null);
|
|
166
|
+
return bytes !== null && isValidRasterAsset(bytes);
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
matched = matched.filter((_, i) => verdicts[i]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (unverifiable) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
detail: `cannot verify image bytes for ${ext.join("/")} files \u2014 this workspace view serves no binary reads, so the count would only prove filenames exist`,
|
|
176
|
+
matched
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const where = dir ? ` in ${dir.replace(/\/+$/, "")}/` : "";
|
|
180
|
+
const verified = opts?.verifyImageBytes ? " real (byte-verified)" : "";
|
|
181
|
+
const stubNote = opts?.verifyImageBytes ? " A file with an image name but placeholder/text bytes does not count \u2014 generate the actual image." : "";
|
|
182
|
+
return matched.length >= min ? {
|
|
183
|
+
ok: true,
|
|
184
|
+
detail: `found ${matched.length}${verified} ${ext.join("/")} file(s)${where}`,
|
|
185
|
+
matched
|
|
186
|
+
} : {
|
|
187
|
+
ok: false,
|
|
188
|
+
detail: `found ${matched.length}${verified} ${ext.join("/")} file(s)${where}, need \u2265 ${min} \u2014 create the missing ${ext[0] ?? ""} file(s)${where ? ` under ${dir.replace(/\/+$/, "")}/` : ""}.${stubNote}`,
|
|
189
|
+
matched
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function cssMinBytes(ws, bytes, file = "index.html") {
|
|
193
|
+
const html = await ws.read(file) ?? "";
|
|
194
|
+
let css = 0;
|
|
195
|
+
for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
196
|
+
css += (m[1] ?? "").trim().length;
|
|
197
|
+
}
|
|
198
|
+
for (const m of html.matchAll(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
199
|
+
css += (m[1] ?? m[2] ?? "").trim().length;
|
|
200
|
+
}
|
|
201
|
+
for (const m of html.matchAll(
|
|
202
|
+
/<link\b[^>]*rel=["']?stylesheet["']?[^>]*href=["']([^"']+)["']/gi
|
|
203
|
+
)) {
|
|
204
|
+
const href = m[1];
|
|
205
|
+
if (href && !/^https?:/i.test(href)) {
|
|
206
|
+
css += (await ws.read(href.replace(/^\.?\//, "")))?.length ?? 0;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return css >= bytes ? { ok: true, detail: `CSS is ${css} bytes (inline + linked in ${file})` } : { ok: false, detail: `CSS is ${css} bytes (inline + linked in ${file}), need \u2265 ${bytes}` };
|
|
210
|
+
}
|
|
211
|
+
async function containsPattern(ws, file, pattern, flags, label) {
|
|
212
|
+
const content = await ws.read(file);
|
|
213
|
+
if (content === null) return { ok: false, detail: `${file} not found` };
|
|
214
|
+
let re;
|
|
215
|
+
try {
|
|
216
|
+
re = new RegExp(pattern, flags);
|
|
217
|
+
} catch {
|
|
218
|
+
return { ok: false, detail: `invalid gate pattern /${pattern}/` };
|
|
219
|
+
}
|
|
220
|
+
const target = label ? `: ${label}` : ` /${pattern}/`;
|
|
221
|
+
return re.test(content) ? { ok: true, detail: `${file} contains required content${target}` } : {
|
|
222
|
+
ok: false,
|
|
223
|
+
detail: `${file} is missing required content${target} \u2014 nothing in its ${content.length} bytes matches${label ? ` /${pattern}/` : ""}. Add that content.`
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
async function notContainsPattern(ws, file, pattern, flags, label) {
|
|
227
|
+
const content = await ws.read(file);
|
|
228
|
+
if (content === null) return { ok: false, detail: `${file} not found` };
|
|
229
|
+
let re;
|
|
230
|
+
try {
|
|
231
|
+
re = new RegExp(pattern, flags);
|
|
232
|
+
} catch {
|
|
233
|
+
return { ok: false, detail: `invalid gate pattern /${pattern}/` };
|
|
234
|
+
}
|
|
235
|
+
const match = re.exec(content);
|
|
236
|
+
const target = label ? `: ${label}` : ` /${pattern}/`;
|
|
237
|
+
const matchedText = match?.[0]?.replace(/\s+/g, " ").slice(0, 180);
|
|
238
|
+
const matchedSuffix = label && matchedText ? ` (matched "${matchedText}")` : "";
|
|
239
|
+
return match ? { ok: false, detail: `${file} contains forbidden content${target}${matchedSuffix}` } : { ok: true, detail: `${file} excludes forbidden content${target}` };
|
|
240
|
+
}
|
|
241
|
+
async function grepMatches(ws, pattern, opts = {}) {
|
|
242
|
+
const min = opts.minMatches ?? 1;
|
|
243
|
+
let re;
|
|
244
|
+
try {
|
|
245
|
+
re = new RegExp(pattern, opts.flags);
|
|
246
|
+
} catch {
|
|
247
|
+
return { ok: false, detail: `invalid grep pattern /${pattern}/`, matched: [] };
|
|
248
|
+
}
|
|
249
|
+
const dirPrefix = opts.dir ? `${opts.dir.toLowerCase().replace(/\/+$/, "")}/` : null;
|
|
250
|
+
const exts = opts.ext?.length ? new Set(opts.ext.map((e) => e.toLowerCase().replace(/^\./, ""))) : null;
|
|
251
|
+
const matched = [];
|
|
252
|
+
for (const p of await ws.list()) {
|
|
253
|
+
const lower = p.toLowerCase();
|
|
254
|
+
if (dirPrefix && !lower.startsWith(dirPrefix)) continue;
|
|
255
|
+
if (exts && !exts.has(lower.split(".").pop() ?? "")) continue;
|
|
256
|
+
const content = await ws.read(p);
|
|
257
|
+
if (content !== null && re.test(content)) {
|
|
258
|
+
matched.push(p);
|
|
259
|
+
if (matched.length >= Math.max(min, 5)) break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return matched.length >= min ? { ok: true, detail: `${matched.length} file(s) match /${pattern}/`, matched } : {
|
|
263
|
+
ok: false,
|
|
264
|
+
detail: `${matched.length} file(s) match /${pattern}/, need \u2265 ${min}`,
|
|
265
|
+
matched
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/checks/refs.ts
|
|
270
|
+
var IMG_EXT = /\.(png|jpe?g|webp|gif|svg)$/i;
|
|
271
|
+
function resolveRelative(basePath, srcRaw) {
|
|
272
|
+
if (/^(?:[a-z]+:|\/\/|#|data:|mailto:)/i.test(srcRaw)) return null;
|
|
273
|
+
const src = srcRaw.replace(/[?#].*$/, "").trim();
|
|
274
|
+
if (!src) return null;
|
|
275
|
+
if (src.startsWith("/")) return src.slice(1);
|
|
276
|
+
const baseDirParts = basePath.split(/[\\/]+/);
|
|
277
|
+
baseDirParts.pop();
|
|
278
|
+
const parts = src.split(/[\\/]+/);
|
|
279
|
+
for (const part of parts) {
|
|
280
|
+
if (part === "" || part === ".") continue;
|
|
281
|
+
if (part === "..") {
|
|
282
|
+
if (baseDirParts.length === 0) return null;
|
|
283
|
+
baseDirParts.pop();
|
|
284
|
+
} else {
|
|
285
|
+
baseDirParts.push(part);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return baseDirParts.join("/");
|
|
289
|
+
}
|
|
290
|
+
function findImageRefs(html) {
|
|
291
|
+
const refs = [];
|
|
292
|
+
for (const m of html.matchAll(/<img\b[^>]*\bsrc\s*=\s*("([^"]+)"|'([^']+)')/gi)) {
|
|
293
|
+
const src = m[2] ?? m[3];
|
|
294
|
+
if (src) refs.push(src);
|
|
295
|
+
}
|
|
296
|
+
return refs;
|
|
297
|
+
}
|
|
298
|
+
function imageRefsResolve(html, htmlPath, projectFiles, requireAll = false) {
|
|
299
|
+
const fileSet = new Set(projectFiles.map((p) => p.replace(/\\/g, "/")));
|
|
300
|
+
const refs = findImageRefs(html).filter((src) => IMG_EXT.test(src.replace(/[?#].*$/, "")));
|
|
301
|
+
const broken = [];
|
|
302
|
+
let working = 0;
|
|
303
|
+
for (const src of refs) {
|
|
304
|
+
const resolved = resolveRelative(htmlPath, src);
|
|
305
|
+
if (resolved !== null && fileSet.has(resolved)) working++;
|
|
306
|
+
else broken.push(src);
|
|
307
|
+
}
|
|
308
|
+
const ok = requireAll ? refs.length > 0 && broken.length === 0 : working > 0;
|
|
309
|
+
const detail = ok ? `${working}/${refs.length} image ref(s) resolve` : refs.length === 0 ? `${htmlPath} has no image references` : `${broken.length} image ref(s) point at missing files: ${broken.slice(0, 4).join(", ")}`;
|
|
310
|
+
return { ok, detail, broken, working, total: refs.length };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/checks/esm.ts
|
|
314
|
+
var NODE_BUILTIN_EXPORTS = {
|
|
315
|
+
"node:path": [
|
|
316
|
+
"dirname",
|
|
317
|
+
"basename",
|
|
318
|
+
"extname",
|
|
319
|
+
"join",
|
|
320
|
+
"resolve",
|
|
321
|
+
"normalize",
|
|
322
|
+
"relative",
|
|
323
|
+
"isAbsolute",
|
|
324
|
+
"sep",
|
|
325
|
+
"delimiter",
|
|
326
|
+
"parse",
|
|
327
|
+
"format",
|
|
328
|
+
"toNamespacedPath",
|
|
329
|
+
"matchesGlob",
|
|
330
|
+
"posix",
|
|
331
|
+
"win32"
|
|
332
|
+
],
|
|
333
|
+
"node:url": [
|
|
334
|
+
"fileURLToPath",
|
|
335
|
+
"pathToFileURL",
|
|
336
|
+
"URL",
|
|
337
|
+
"URLSearchParams",
|
|
338
|
+
"format",
|
|
339
|
+
"parse",
|
|
340
|
+
"resolve",
|
|
341
|
+
"domainToASCII",
|
|
342
|
+
"domainToUnicode",
|
|
343
|
+
"urlToHttpOptions",
|
|
344
|
+
"Url"
|
|
345
|
+
]
|
|
346
|
+
};
|
|
347
|
+
var UNIQUE_OWNER = (() => {
|
|
348
|
+
const seen = /* @__PURE__ */ new Map();
|
|
349
|
+
for (const [mod, names] of Object.entries(NODE_BUILTIN_EXPORTS)) {
|
|
350
|
+
for (const name of names) {
|
|
351
|
+
const arr = seen.get(name) ?? [];
|
|
352
|
+
arr.push(mod);
|
|
353
|
+
seen.set(name, arr);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const owner = /* @__PURE__ */ new Map();
|
|
357
|
+
for (const [name, mods] of seen) {
|
|
358
|
+
if (mods.length === 1 && mods[0]) owner.set(name, mods[0]);
|
|
359
|
+
}
|
|
360
|
+
return owner;
|
|
361
|
+
})();
|
|
362
|
+
var NAMED_IMPORT_RE = /import\s+[^{}'"]*\{([^}]*)\}\s*from\s*['"](node:[a-z/]+)['"]/g;
|
|
363
|
+
var REQUIRE_CALL_RE = /(?<![.\w])require\s*\(/;
|
|
364
|
+
function esmImports(content, file = "") {
|
|
365
|
+
for (const m of content.matchAll(NAMED_IMPORT_RE)) {
|
|
366
|
+
const mod = m[2] ?? "";
|
|
367
|
+
for (const raw of (m[1] ?? "").split(",")) {
|
|
368
|
+
const name = raw.trim().split(/\s+as\s+/)[0]?.trim();
|
|
369
|
+
if (!name) continue;
|
|
370
|
+
const owner = UNIQUE_OWNER.get(name);
|
|
371
|
+
if (owner && owner !== mod) {
|
|
372
|
+
const label = file || "this file";
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
detail: `${label} imports { ${name} } from '${mod}', but '${mod}' has no '${name}' export \u2014 it is a '${owner}' export. Import it from '${owner}' (e.g. \`import { ${name} } from '${owner}'\`, or \`import path from 'node:path'\` then \`path.${name}(\u2026)\`). A wrong-source named import throws "SyntaxError: \u2026 does not provide an export named \u2026" at load and nothing runs.`
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (file.endsWith(".mjs") && REQUIRE_CALL_RE.test(content)) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
detail: `${file} is an ES module (.mjs) but calls require(...) \u2014 \`require\` is not defined in ESM and throws at runtime. Use \`import\` instead (read JSON via node:fs, or import a module's exports directly).`
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return { ok: true, detail: "node: named imports resolve; no require() in ESM" };
|
|
387
|
+
}
|
|
388
|
+
function standaloneJsParses(content, file = "") {
|
|
389
|
+
const stripped = content.replace(/^[ \t]*import\b[\s\S]*?from\s*['"][^'"]*['"];?[ \t]*$/gm, "").replace(/^[ \t]*import\s*['"][^'"]*['"];?[ \t]*$/gm, "").replace(/^[ \t]*export\s+default\s+/gm, "").replace(/^[ \t]*export\s*\{[^}]*\}\s*(?:from\s*['"][^'"]*['"])?;?[ \t]*$/gm, "").replace(/^[ \t]*export\s+(?=(?:async\s+)?(?:const|let|var|function|class)\b)/gm, "").replace(/^([ \t]*)await\b/gm, "$1void ");
|
|
390
|
+
try {
|
|
391
|
+
new Function(stripped);
|
|
392
|
+
return { ok: true, detail: `${file || "source"} parses` };
|
|
393
|
+
} catch (err) {
|
|
394
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
detail: `${file || "source"} does not parse: ${message}. The file will not load until this is fixed \u2014 commonly a truncated file or an unbalanced brace/parenthesis.`
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/checks/text.ts
|
|
403
|
+
function countDistinctMatches(text, pattern) {
|
|
404
|
+
const seen = /* @__PURE__ */ new Set();
|
|
405
|
+
const re = pattern.flags.includes("g") ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
|
|
406
|
+
for (const m of text.matchAll(re)) {
|
|
407
|
+
const key = (m[1] ?? m[0]).toLowerCase();
|
|
408
|
+
if (key.length > 0) seen.add(key);
|
|
409
|
+
}
|
|
410
|
+
return seen.size;
|
|
411
|
+
}
|
|
412
|
+
function requireOrderedSections(text, headers) {
|
|
413
|
+
let cursor = 0;
|
|
414
|
+
for (const header of headers) {
|
|
415
|
+
const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
416
|
+
const re = new RegExp(`^#+\\s+${escaped}\\s*$`, "im");
|
|
417
|
+
const slice = text.slice(cursor);
|
|
418
|
+
const m = slice.match(re);
|
|
419
|
+
if (!m || m.index === void 0) {
|
|
420
|
+
return { ok: false, missing: header, foundIndex: cursor };
|
|
421
|
+
}
|
|
422
|
+
cursor += m.index + m[0].length;
|
|
423
|
+
}
|
|
424
|
+
return { ok: true };
|
|
425
|
+
}
|
|
426
|
+
function jsonValid(content) {
|
|
427
|
+
try {
|
|
428
|
+
JSON.parse(content);
|
|
429
|
+
return { ok: true };
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function parseJsonPath(path) {
|
|
435
|
+
const trimmed = path.trim().replace(/^\$\.?/, "");
|
|
436
|
+
if (trimmed.length === 0) return [];
|
|
437
|
+
const parts = [];
|
|
438
|
+
for (const rawPart of trimmed.split(".")) {
|
|
439
|
+
if (rawPart.length === 0) return null;
|
|
440
|
+
const keyMatch = rawPart.match(/^[^\[\]]+/);
|
|
441
|
+
if (keyMatch) parts.push(keyMatch[0]);
|
|
442
|
+
const rest = rawPart.slice(keyMatch?.[0].length ?? 0);
|
|
443
|
+
if (rest.length === 0) continue;
|
|
444
|
+
const bracketRe = /\[(\d+)\]/g;
|
|
445
|
+
let consumed = "";
|
|
446
|
+
for (const match of rest.matchAll(bracketRe)) {
|
|
447
|
+
consumed += match[0];
|
|
448
|
+
parts.push(Number(match[1]));
|
|
449
|
+
}
|
|
450
|
+
if (consumed !== rest) return null;
|
|
451
|
+
}
|
|
452
|
+
return parts;
|
|
453
|
+
}
|
|
454
|
+
function getPathValue(root, path) {
|
|
455
|
+
let cursor = root;
|
|
456
|
+
for (const part of path) {
|
|
457
|
+
if (typeof part === "number") {
|
|
458
|
+
if (!Array.isArray(cursor) || part >= cursor.length) return void 0;
|
|
459
|
+
cursor = cursor[part];
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (cursor === null || typeof cursor !== "object" || !(part in cursor)) return void 0;
|
|
463
|
+
cursor = cursor[part];
|
|
464
|
+
}
|
|
465
|
+
return cursor;
|
|
466
|
+
}
|
|
467
|
+
function printableValue(value) {
|
|
468
|
+
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
469
|
+
}
|
|
470
|
+
async function jsonPathEquals(ws, file, path, expected, label) {
|
|
471
|
+
const content = await ws.read(file);
|
|
472
|
+
if (content === null) {
|
|
473
|
+
return { ok: false, detail: `${file} not found`, actual: void 0 };
|
|
474
|
+
}
|
|
475
|
+
let parsed;
|
|
476
|
+
try {
|
|
477
|
+
parsed = JSON.parse(content);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
480
|
+
return { ok: false, detail: `${file} is not valid JSON: ${detail}`, actual: void 0 };
|
|
481
|
+
}
|
|
482
|
+
const parts = parseJsonPath(path);
|
|
483
|
+
if (parts === null) {
|
|
484
|
+
return { ok: false, detail: `${path} is not a supported JSON path`, actual: void 0 };
|
|
485
|
+
}
|
|
486
|
+
const actual = getPathValue(parsed, parts);
|
|
487
|
+
if (Object.is(actual, expected)) {
|
|
488
|
+
return { ok: true, detail: `${file} ${path} equals ${printableValue(expected)}`, actual };
|
|
489
|
+
}
|
|
490
|
+
const guidance = label ? `: ${label}` : "";
|
|
491
|
+
return {
|
|
492
|
+
ok: false,
|
|
493
|
+
detail: `${file} ${path} should equal ${printableValue(expected)} but was ${printableValue(actual)}${guidance}`,
|
|
494
|
+
actual
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/checks/grounding.ts
|
|
499
|
+
function normalizeDigitGroups(text) {
|
|
500
|
+
return text.replace(/(\d)[,\s](?=\d{3}(?:\D|$))/g, "$1");
|
|
501
|
+
}
|
|
502
|
+
function safeRegExp(src, flags) {
|
|
503
|
+
try {
|
|
504
|
+
return new RegExp(src, flags);
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function valueGrounding(text, facts, opts = {}) {
|
|
510
|
+
const haystack = opts.normalizeDigits === false ? text : normalizeDigitGroups(text);
|
|
511
|
+
const signals = [];
|
|
512
|
+
const decoysDetected = [];
|
|
513
|
+
let firstFailure = null;
|
|
514
|
+
if (facts.length === 0) {
|
|
515
|
+
return { ok: false, detail: "no grounding facts configured", signals, decoysDetected };
|
|
516
|
+
}
|
|
517
|
+
for (const fact of facts) {
|
|
518
|
+
const label = fact.label ?? fact.id;
|
|
519
|
+
const hasRequired = fact.required.some((src) => safeRegExp(src, "i")?.test(haystack) ?? false);
|
|
520
|
+
const hitForbidden = (fact.forbidden ?? []).filter(
|
|
521
|
+
(src) => safeRegExp(src, "i")?.test(haystack) ?? false
|
|
522
|
+
);
|
|
523
|
+
decoysDetected.push(...hitForbidden);
|
|
524
|
+
if (hasRequired && hitForbidden.length === 0) {
|
|
525
|
+
signals.push(fact.id);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (firstFailure === null) {
|
|
529
|
+
firstFailure = hitForbidden.length > 0 ? `${label}: forbidden value /${hitForbidden[0]}/ appears \u2014 that figure comes from an unauthorized source and must not appear at all (not even to contrast it).` : `${label}: no authorized value found \u2014 quote the exact value from an authorized source (expected one of: ${fact.required.map((r) => `/${r}/`).join(", ")}).`;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
ok: firstFailure === null,
|
|
534
|
+
detail: firstFailure ?? `all ${facts.length} facts grounded (no forbidden values present)`,
|
|
535
|
+
signals,
|
|
536
|
+
decoysDetected
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
var DEFAULT_CITATION_RE = /\(source:\s*([^)\s]+)(?:\s+\[[^\]]*\])*\s*\)|\]\(\s*(?!#)([^)\s]+?)\s*\)|`([^`]*\/[^`]+)`/gi;
|
|
540
|
+
function extractCitations(text, re) {
|
|
541
|
+
const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
|
|
542
|
+
const global = new RegExp(re.source, flags);
|
|
543
|
+
const out = [];
|
|
544
|
+
for (const m of text.matchAll(global)) {
|
|
545
|
+
const cap = m.slice(1).find((x) => x !== void 0) ?? m[0];
|
|
546
|
+
if (cap) out.push(cap);
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
function cleanCitation(raw) {
|
|
551
|
+
return raw.trim().replace(/^[<'"`(]+/, "").replace(/[>'"`).,;:]+$/, "");
|
|
552
|
+
}
|
|
553
|
+
function normalizePath(p) {
|
|
554
|
+
return p.trim().toLowerCase().replace(/^\.?\//, "").replace(/^workspace\//, "");
|
|
555
|
+
}
|
|
556
|
+
async function citationsResolve(ws, file, opts = {}) {
|
|
557
|
+
const content = await ws.read(file);
|
|
558
|
+
if (content === null) {
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
detail: `${file} not found \u2014 write the deliverable before advancing.`,
|
|
562
|
+
resolved: [],
|
|
563
|
+
unresolved: [],
|
|
564
|
+
urls: []
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const re = opts.pattern ? safeRegExp(opts.pattern, opts.flags ?? "gi") : new RegExp(DEFAULT_CITATION_RE.source, "gi");
|
|
568
|
+
if (!re) {
|
|
569
|
+
return {
|
|
570
|
+
ok: false,
|
|
571
|
+
detail: `invalid citation pattern /${opts.pattern}/`,
|
|
572
|
+
resolved: [],
|
|
573
|
+
unresolved: [],
|
|
574
|
+
urls: []
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const cites = [...new Set(extractCitations(content, re).map(cleanCitation).filter(Boolean))];
|
|
578
|
+
const min = opts.minCitations ?? 1;
|
|
579
|
+
const listing = new Set((await ws.list()).map(normalizePath));
|
|
580
|
+
const corpus = opts.corpus ? new Set(opts.corpus.map((c) => c.toLowerCase())) : null;
|
|
581
|
+
const resolved = [];
|
|
582
|
+
const unresolved = [];
|
|
583
|
+
const urls = [];
|
|
584
|
+
for (const c of cites) {
|
|
585
|
+
if (/^[a-z][\w+.-]*:\/\//i.test(c) || c.startsWith("mailto:")) {
|
|
586
|
+
urls.push(c);
|
|
587
|
+
if (corpus && !corpus.has(c.toLowerCase())) unresolved.push(c);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (listing.has(normalizePath(c)) || corpus?.has(c.toLowerCase())) resolved.push(c);
|
|
591
|
+
else unresolved.push(c);
|
|
592
|
+
}
|
|
593
|
+
if (cites.length < min) {
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
detail: `${file} has ${cites.length} citation(s), need \u2265 ${min} \u2014 cite the source path/URL for each claim.`,
|
|
597
|
+
resolved,
|
|
598
|
+
unresolved,
|
|
599
|
+
urls
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (unresolved.length > 0) {
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
detail: `${file} cites ${unresolved.length} source(s) that do not exist: ${unresolved.slice(0, 5).join(", ")} \u2014 every cited path must resolve to a real file in the workspace${corpus ? "/corpus" : ""} (no fabricated citations).`,
|
|
606
|
+
resolved,
|
|
607
|
+
unresolved,
|
|
608
|
+
urls
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
detail: `${file} cites ${resolved.length} resolvable source(s)${urls.length ? ` (+${urls.length} URL(s) not checked offline)` : ""}`,
|
|
614
|
+
resolved,
|
|
615
|
+
unresolved,
|
|
616
|
+
urls
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function valuesSubsetOf(outputText, sourceTexts, spec) {
|
|
620
|
+
const flags = (spec.flags ?? "").includes("g") ? spec.flags ?? "" : `${spec.flags ?? ""}g`;
|
|
621
|
+
const re = safeRegExp(spec.pattern, flags);
|
|
622
|
+
if (!re) {
|
|
623
|
+
return { ok: false, detail: `invalid pattern /${spec.pattern}/`, checked: 0, invented: [] };
|
|
624
|
+
}
|
|
625
|
+
const extract = (text) => {
|
|
626
|
+
const out = [];
|
|
627
|
+
for (const m of text.matchAll(new RegExp(re.source, flags))) {
|
|
628
|
+
const v = (m[1] ?? m[0]).trim();
|
|
629
|
+
if (v) out.push(v);
|
|
630
|
+
}
|
|
631
|
+
return out;
|
|
632
|
+
};
|
|
633
|
+
const caseInsensitive = flags.includes("i");
|
|
634
|
+
const sourceHaystacks = sourceTexts.map((t) => caseInsensitive ? t.toLowerCase() : t);
|
|
635
|
+
const appearsInSources = (value) => {
|
|
636
|
+
const needle = caseInsensitive ? value.toLowerCase() : value;
|
|
637
|
+
return sourceHaystacks.some((t) => t.includes(needle));
|
|
638
|
+
};
|
|
639
|
+
const seen = /* @__PURE__ */ new Set();
|
|
640
|
+
const invented = [];
|
|
641
|
+
let checked = 0;
|
|
642
|
+
for (const v of extract(outputText)) {
|
|
643
|
+
if (seen.has(v)) continue;
|
|
644
|
+
seen.add(v);
|
|
645
|
+
checked += 1;
|
|
646
|
+
if (!appearsInSources(v)) invented.push(v);
|
|
647
|
+
}
|
|
648
|
+
const min = spec.minMatches ?? 0;
|
|
649
|
+
if (checked < min) {
|
|
650
|
+
return {
|
|
651
|
+
ok: false,
|
|
652
|
+
detail: `output carries ${checked} value(s) matching /${spec.pattern}/, need \u2265 ${min} \u2014 the transform must preserve the source values, not drop them.`,
|
|
653
|
+
checked,
|
|
654
|
+
invented
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
if (invented.length > 0) {
|
|
658
|
+
return {
|
|
659
|
+
ok: false,
|
|
660
|
+
detail: `${invented.length} value(s) in the output appear in no source: ${invented.slice(0, 3).join(", ")}${invented.length > 3 ? ", \u2026" : ""} \u2014 copy identifiers verbatim from the source data; never renumber or invent them.`,
|
|
661
|
+
checked,
|
|
662
|
+
invented
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
ok: true,
|
|
667
|
+
detail: `all ${checked} output value(s) trace back to the sources`,
|
|
668
|
+
checked,
|
|
669
|
+
invented
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/checks/security-report.ts
|
|
674
|
+
var DEFAULT_SECTIONS = [
|
|
675
|
+
"Verdict",
|
|
676
|
+
"Systemic Themes",
|
|
677
|
+
"Findings",
|
|
678
|
+
"Verified Safe",
|
|
679
|
+
"Not Statically Verifiable"
|
|
680
|
+
];
|
|
681
|
+
var VALID_SEVERITIES = /* @__PURE__ */ new Set(["critical", "high", "medium", "low", "info"]);
|
|
682
|
+
function str(x) {
|
|
683
|
+
return typeof x === "string" ? x.trim() : "";
|
|
684
|
+
}
|
|
685
|
+
function normalizePath2(p) {
|
|
686
|
+
return p.trim().replace(/^`+|`+$/g, "").replace(/[:#].*$/, "").replace(/^\.\//, "").replace(/^\/+/, "").replace(/^workspace\//i, "").toLowerCase();
|
|
687
|
+
}
|
|
688
|
+
function escapeRe(s) {
|
|
689
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
690
|
+
}
|
|
691
|
+
function hasSection(content, name) {
|
|
692
|
+
return new RegExp(`^#{1,4}\\s+${escapeRe(name)}\\b`, "im").test(content);
|
|
693
|
+
}
|
|
694
|
+
function sectionBody(content, name) {
|
|
695
|
+
const m = new RegExp(`^(#{1,4})\\s+${escapeRe(name)}\\b`, "im").exec(content);
|
|
696
|
+
if (!m) return "";
|
|
697
|
+
const level = m[1].length;
|
|
698
|
+
const start = m.index + m[0].length;
|
|
699
|
+
const rest = content.slice(start);
|
|
700
|
+
const next = new RegExp(`^#{1,${level}}\\s+\\S`, "m").exec(rest);
|
|
701
|
+
return next ? rest.slice(0, next.index) : rest;
|
|
702
|
+
}
|
|
703
|
+
function countThemeItems(section) {
|
|
704
|
+
let n = 0;
|
|
705
|
+
for (const line of section.split(/\r?\n/)) {
|
|
706
|
+
if (/^\s*(#{3,4}\s+\S|[-*]\s+\S|\d+\.\s+\S|\*\*[^*]+\*\*)/.test(line)) n++;
|
|
707
|
+
}
|
|
708
|
+
return n;
|
|
709
|
+
}
|
|
710
|
+
function uniq(xs) {
|
|
711
|
+
return [...new Set(xs)];
|
|
712
|
+
}
|
|
713
|
+
async function securityReport(ws, reportFile, opts = {}) {
|
|
714
|
+
const fail = (detail, count = 0, fabricated2 = []) => ({
|
|
715
|
+
ok: false,
|
|
716
|
+
detail,
|
|
717
|
+
findingCount: count,
|
|
718
|
+
fabricated: fabricated2
|
|
719
|
+
});
|
|
720
|
+
const content = await ws.read(reportFile);
|
|
721
|
+
if (content === null) {
|
|
722
|
+
return fail(`${reportFile} not found \u2014 write the security report before advancing.`);
|
|
723
|
+
}
|
|
724
|
+
const findingsPath = opts.findings ?? "security-review/findings.json";
|
|
725
|
+
const raw = await ws.read(findingsPath);
|
|
726
|
+
if (raw === null) {
|
|
727
|
+
return fail(
|
|
728
|
+
`${findingsPath} not found \u2014 emit a machine-readable findings JSON alongside the report.`
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
let parsed;
|
|
732
|
+
try {
|
|
733
|
+
parsed = JSON.parse(raw);
|
|
734
|
+
} catch (e) {
|
|
735
|
+
return fail(
|
|
736
|
+
`${findingsPath} is not valid JSON (${e instanceof Error ? e.message : "parse error"}). Emit a JSON array of findings.`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
const arr = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.findings) ? parsed.findings : null;
|
|
740
|
+
if (!arr) {
|
|
741
|
+
return fail(`${findingsPath} must be a JSON array of findings (or { "findings": [...] }).`);
|
|
742
|
+
}
|
|
743
|
+
const listing = new Set((await ws.list()).map(normalizePath2));
|
|
744
|
+
const problems = [];
|
|
745
|
+
const fabricated = [];
|
|
746
|
+
let critHigh = 0;
|
|
747
|
+
arr.forEach((raw2, i) => {
|
|
748
|
+
const f = raw2 ?? {};
|
|
749
|
+
const file = str(f.file ?? f.path);
|
|
750
|
+
const sev = str(f.severity).toLowerCase();
|
|
751
|
+
const remediation = str(f.remediation ?? f.fix ?? f.recommendation);
|
|
752
|
+
const title = str(f.title ?? f.description ?? f.summary);
|
|
753
|
+
const line = f.line ?? f.lineStart;
|
|
754
|
+
const label = file || `#${i + 1}`;
|
|
755
|
+
if (!file) problems.push(`finding #${i + 1} has no file`);
|
|
756
|
+
else if (!listing.has(normalizePath2(file))) fabricated.push(file);
|
|
757
|
+
if (!VALID_SEVERITIES.has(sev)) problems.push(`${label} has an invalid severity "${sev}"`);
|
|
758
|
+
if (!remediation) problems.push(`${label} has no remediation`);
|
|
759
|
+
if (!title) problems.push(`${label} has no title/description`);
|
|
760
|
+
if (typeof line !== "number") problems.push(`${label} is not pinned to a line`);
|
|
761
|
+
if (sev === "critical" || sev === "high") critHigh++;
|
|
762
|
+
});
|
|
763
|
+
if (fabricated.length > 0) {
|
|
764
|
+
return fail(
|
|
765
|
+
`findings cite ${fabricated.length} file(s) that don't exist in the workspace: ${uniq(fabricated).slice(0, 5).join(", ")} \u2014 every finding must point at a real file:line (no fabricated citations).`,
|
|
766
|
+
arr.length,
|
|
767
|
+
uniq(fabricated)
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
if (problems.length > 0) {
|
|
771
|
+
return fail(
|
|
772
|
+
`${problems.length} finding(s) are incomplete: ${problems.slice(0, 4).join("; ")} \u2014 every finding needs a real file:line, a severity, and a concrete remediation.`,
|
|
773
|
+
arr.length
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const requiredSections = opts.requiredSections ?? DEFAULT_SECTIONS;
|
|
777
|
+
const missing = requiredSections.filter((s) => !hasSection(content, s));
|
|
778
|
+
if (missing.length > 0) {
|
|
779
|
+
return fail(
|
|
780
|
+
`the report is missing required section(s): ${missing.map((s) => `## ${s}`).join(", ")}.`,
|
|
781
|
+
arr.length
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
const themeThreshold = opts.themeThreshold ?? 3;
|
|
785
|
+
const minThemes = opts.minThemes ?? 2;
|
|
786
|
+
if (arr.length >= themeThreshold) {
|
|
787
|
+
const themes = sectionBody(content, "Systemic Themes");
|
|
788
|
+
const items = countThemeItems(themes);
|
|
789
|
+
if (items < minThemes) {
|
|
790
|
+
return fail(
|
|
791
|
+
`the "Systemic Themes" section lists ${items} theme(s); a review with ${arr.length} findings needs \u2265 ${minThemes}, each naming a root cause and its blast radius.`,
|
|
792
|
+
arr.length
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
if (!/root[\s-]?cause/i.test(themes) || !/(blast[\s-]?radius|impact|scope|reach)/i.test(themes)) {
|
|
796
|
+
return fail(
|
|
797
|
+
`the "Systemic Themes" section must analyze each theme's root cause and blast radius \u2014 that analysis is absent.`,
|
|
798
|
+
arr.length
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const verdict = sectionBody(content, "Verdict").toLowerCase();
|
|
803
|
+
if (critHigh > 0 && /(safe[\s-]?to[\s-]?merge|no (security )?(issues|findings|vulnerabilities)|looks secure|no concerns|all clear)/.test(
|
|
804
|
+
verdict
|
|
805
|
+
) && !/(block|after[\s-]?fix|merge[\s-]?after|do not merge|concern|open (critical|high))/.test(
|
|
806
|
+
verdict
|
|
807
|
+
)) {
|
|
808
|
+
return fail(
|
|
809
|
+
`the verdict reads "safe" but there ${critHigh === 1 ? "is" : "are"} ${critHigh} critical/high finding(s) \u2014 the verdict must reflect the open severity.`,
|
|
810
|
+
arr.length
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
ok: true,
|
|
815
|
+
detail: `security report OK: ${arr.length} finding(s), all cite real files, required sections present${arr.length >= themeThreshold ? ", systemic themes analyzed" : ""}.`,
|
|
816
|
+
findingCount: arr.length,
|
|
817
|
+
fabricated: []
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/checks/records.ts
|
|
822
|
+
var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
823
|
+
function isRealIsoDate(value) {
|
|
824
|
+
if (!ISO_DATE_RE.test(value)) return false;
|
|
825
|
+
const parts = value.split("-").map(Number);
|
|
826
|
+
const y = parts[0] ?? 0;
|
|
827
|
+
const m = parts[1] ?? 0;
|
|
828
|
+
const d = parts[2] ?? 0;
|
|
829
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
830
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
831
|
+
}
|
|
832
|
+
function checkCellType(value, type) {
|
|
833
|
+
switch (type) {
|
|
834
|
+
case "string":
|
|
835
|
+
case "nonempty":
|
|
836
|
+
return value.length > 0 ? { ok: true, detail: "" } : { ok: false, detail: "empty value" };
|
|
837
|
+
case "number":
|
|
838
|
+
return /^-?\d+(\.\d+)?$/.test(value) ? { ok: true, detail: "" } : { ok: false, detail: `"${value}" is not a number` };
|
|
839
|
+
case "integer":
|
|
840
|
+
return /^-?\d+$/.test(value) ? { ok: true, detail: "" } : { ok: false, detail: `"${value}" is not an integer` };
|
|
841
|
+
case "boolean":
|
|
842
|
+
return /^(true|false)$/i.test(value) ? { ok: true, detail: "" } : { ok: false, detail: `"${value}" is not a boolean` };
|
|
843
|
+
case "date":
|
|
844
|
+
case "iso-date":
|
|
845
|
+
return isRealIsoDate(value) ? { ok: true, detail: "" } : { ok: false, detail: `"${value}" is not an ISO yyyy-mm-dd date` };
|
|
846
|
+
case "email":
|
|
847
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? { ok: true, detail: "" } : { ok: false, detail: `"${value}" is not an email` };
|
|
848
|
+
default: {
|
|
849
|
+
let re = null;
|
|
850
|
+
try {
|
|
851
|
+
re = new RegExp(type);
|
|
852
|
+
} catch {
|
|
853
|
+
return { ok: false, detail: `invalid column type /${type}/` };
|
|
854
|
+
}
|
|
855
|
+
return re.test(value) ? { ok: true, detail: "" } : { ok: false, detail: `"${value}" does not match /${type}/` };
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function splitTableRow(line) {
|
|
860
|
+
let s = line.trim();
|
|
861
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
862
|
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
|
863
|
+
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
|
|
864
|
+
}
|
|
865
|
+
function isDelimiterRow(line) {
|
|
866
|
+
if (!line.includes("-")) return false;
|
|
867
|
+
const cells = splitTableRow(line);
|
|
868
|
+
return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c.trim()));
|
|
869
|
+
}
|
|
870
|
+
function parseMarkdownTable(text) {
|
|
871
|
+
const lines = text.split("\n");
|
|
872
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
873
|
+
const header = lines[i];
|
|
874
|
+
const delim = lines[i + 1];
|
|
875
|
+
if (!header || !header.includes("|") || !delim || !isDelimiterRow(delim)) continue;
|
|
876
|
+
const headers = splitTableRow(header);
|
|
877
|
+
if (headers.length === 0) continue;
|
|
878
|
+
const rows = [];
|
|
879
|
+
for (let j = i + 2; j < lines.length; j++) {
|
|
880
|
+
const l = lines[j];
|
|
881
|
+
if (!l || l.trim() === "" || !l.includes("|")) break;
|
|
882
|
+
rows.push(splitTableRow(l));
|
|
883
|
+
}
|
|
884
|
+
return { headers, rows };
|
|
885
|
+
}
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
function tableShape(text, spec) {
|
|
889
|
+
const table = parseMarkdownTable(text);
|
|
890
|
+
if (!table) {
|
|
891
|
+
return {
|
|
892
|
+
ok: false,
|
|
893
|
+
detail: 'no Markdown table found \u2014 provide a "| header | \u2026 |" row with a "|---|" delimiter.',
|
|
894
|
+
headers: [],
|
|
895
|
+
rowCount: 0
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
const headersLower = table.headers.map((h) => h.toLowerCase());
|
|
899
|
+
const base = { headers: table.headers, rowCount: table.rows.length };
|
|
900
|
+
for (const col of spec.requiredColumns ?? []) {
|
|
901
|
+
if (!headersLower.includes(col.toLowerCase())) {
|
|
902
|
+
return {
|
|
903
|
+
ok: false,
|
|
904
|
+
detail: `table is missing required column "${col}" \u2014 found: ${table.headers.join(", ")}.`,
|
|
905
|
+
...base
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const minRows = spec.minRows ?? 0;
|
|
910
|
+
if (table.rows.length < minRows) {
|
|
911
|
+
return {
|
|
912
|
+
ok: false,
|
|
913
|
+
detail: `table has ${table.rows.length} row(s), need \u2265 ${minRows}.`,
|
|
914
|
+
...base
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
for (const [col, type] of Object.entries(spec.columnTypes ?? {})) {
|
|
918
|
+
const idx = headersLower.indexOf(col.toLowerCase());
|
|
919
|
+
if (idx === -1) {
|
|
920
|
+
return { ok: false, detail: `column-type check names unknown column "${col}".`, ...base };
|
|
921
|
+
}
|
|
922
|
+
for (let r = 0; r < table.rows.length; r++) {
|
|
923
|
+
const cell = (table.rows[r]?.[idx] ?? "").trim();
|
|
924
|
+
const v = checkCellType(cell, type);
|
|
925
|
+
if (!v.ok) {
|
|
926
|
+
return { ok: false, detail: `row ${r + 1} column "${col}": ${v.detail}`, ...base };
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
ok: true,
|
|
932
|
+
detail: `table has ${table.headers.length} column(s) and ${table.rows.length} row(s)`,
|
|
933
|
+
...base
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
function parseCsv(text) {
|
|
937
|
+
const rows = [];
|
|
938
|
+
let row = [];
|
|
939
|
+
let cell = "";
|
|
940
|
+
let inQuotes = false;
|
|
941
|
+
let started = false;
|
|
942
|
+
const s = text.replace(/^\uFEFF/, "");
|
|
943
|
+
for (let i = 0; i < s.length; i++) {
|
|
944
|
+
const ch = s[i];
|
|
945
|
+
started = true;
|
|
946
|
+
if (inQuotes) {
|
|
947
|
+
if (ch === '"') {
|
|
948
|
+
if (s[i + 1] === '"') {
|
|
949
|
+
cell += '"';
|
|
950
|
+
i++;
|
|
951
|
+
} else {
|
|
952
|
+
inQuotes = false;
|
|
953
|
+
}
|
|
954
|
+
} else {
|
|
955
|
+
cell += ch;
|
|
956
|
+
}
|
|
957
|
+
} else if (ch === '"') {
|
|
958
|
+
inQuotes = true;
|
|
959
|
+
} else if (ch === ",") {
|
|
960
|
+
row.push(cell);
|
|
961
|
+
cell = "";
|
|
962
|
+
} else if (ch === "\n") {
|
|
963
|
+
row.push(cell);
|
|
964
|
+
rows.push(row);
|
|
965
|
+
row = [];
|
|
966
|
+
cell = "";
|
|
967
|
+
} else if (ch !== "\r") {
|
|
968
|
+
cell += ch;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (started && (cell.length > 0 || row.length > 0)) {
|
|
972
|
+
row.push(cell);
|
|
973
|
+
rows.push(row);
|
|
974
|
+
}
|
|
975
|
+
return rows;
|
|
976
|
+
}
|
|
977
|
+
function csvQuoteError(text) {
|
|
978
|
+
let inQuotes = false;
|
|
979
|
+
const s = text.replace(/^\uFEFF/, "");
|
|
980
|
+
for (let i = 0; i < s.length; i++) {
|
|
981
|
+
const ch = s[i];
|
|
982
|
+
if (inQuotes) {
|
|
983
|
+
if (ch === '"') {
|
|
984
|
+
if (s[i + 1] === '"') {
|
|
985
|
+
i++;
|
|
986
|
+
} else {
|
|
987
|
+
inQuotes = false;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
} else if (ch === '"') {
|
|
991
|
+
inQuotes = true;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return inQuotes ? "CSV has an unclosed quoted field" : null;
|
|
995
|
+
}
|
|
996
|
+
function dataTableSniff(text) {
|
|
997
|
+
const stripped = text.replace(/^\uFEFF/, "").trim();
|
|
998
|
+
if (stripped.length === 0) return false;
|
|
999
|
+
if (stripped[0] === "[") {
|
|
1000
|
+
try {
|
|
1001
|
+
const parsed = JSON.parse(stripped);
|
|
1002
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
1003
|
+
} catch {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const md = parseMarkdownTable(stripped);
|
|
1008
|
+
if (md && md.headers.length > 0 && md.rows.length > 0) return true;
|
|
1009
|
+
const grid = parseCsv(stripped).filter((r) => r.some((c) => c.trim().length > 0));
|
|
1010
|
+
if (grid.length >= 2) {
|
|
1011
|
+
const cols = grid[0]?.length ?? 0;
|
|
1012
|
+
if (cols >= 2 && grid.slice(1).some((r) => r.length === cols)) return true;
|
|
1013
|
+
}
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
function csvShapeFailure(detail, headers = [], rowCount = 0) {
|
|
1017
|
+
return { ok: false, detail, headers, rowCount };
|
|
1018
|
+
}
|
|
1019
|
+
function summarizeCsvRow(row, max = 180) {
|
|
1020
|
+
const summary = row.map((cell) => cell.trim()).join(" | ");
|
|
1021
|
+
return summary.length <= max ? summary : `${summary.slice(0, max - 3)}...`;
|
|
1022
|
+
}
|
|
1023
|
+
function csvShape(text, spec) {
|
|
1024
|
+
if (text === null) {
|
|
1025
|
+
return csvShapeFailure("CSV file not found");
|
|
1026
|
+
}
|
|
1027
|
+
const quoteError = csvQuoteError(text);
|
|
1028
|
+
if (quoteError) {
|
|
1029
|
+
return csvShapeFailure(quoteError);
|
|
1030
|
+
}
|
|
1031
|
+
const grid = parseCsv(text).filter((r) => r.some((c) => c.trim().length > 0));
|
|
1032
|
+
if (grid.length === 0) {
|
|
1033
|
+
return csvShapeFailure("CSV has no rows");
|
|
1034
|
+
}
|
|
1035
|
+
const headers = (grid[0] ?? []).map((h) => h.trim());
|
|
1036
|
+
const dataRows = grid.slice(1);
|
|
1037
|
+
const base = { headers, rowCount: dataRows.length };
|
|
1038
|
+
if (headers.length === 0 || headers.every((h) => h.length === 0)) {
|
|
1039
|
+
return csvShapeFailure("CSV header row is empty", headers, dataRows.length);
|
|
1040
|
+
}
|
|
1041
|
+
if (spec.exactColumns) {
|
|
1042
|
+
const expected = spec.exactColumns;
|
|
1043
|
+
const matches = headers.length === expected.length && headers.every((header, index) => header === expected[index]);
|
|
1044
|
+
if (!matches) {
|
|
1045
|
+
return {
|
|
1046
|
+
ok: false,
|
|
1047
|
+
detail: `CSV header should be exactly: ${expected.join(", ")}; found: ${headers.join(", ")}`,
|
|
1048
|
+
...base
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
for (const column of spec.requiredColumns ?? []) {
|
|
1053
|
+
if (!headers.includes(column)) {
|
|
1054
|
+
return {
|
|
1055
|
+
ok: false,
|
|
1056
|
+
detail: `CSV is missing required column "${column}" \u2014 found: ${headers.join(", ")}`,
|
|
1057
|
+
...base
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
const minRows = spec.minRows ?? 0;
|
|
1062
|
+
if (dataRows.length < minRows) {
|
|
1063
|
+
return {
|
|
1064
|
+
ok: false,
|
|
1065
|
+
detail: `CSV has ${dataRows.length} data row(s), need \u2265 ${minRows}`,
|
|
1066
|
+
...base
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
if (spec.consistentColumns ?? true) {
|
|
1070
|
+
for (let i = 0; i < dataRows.length; i++) {
|
|
1071
|
+
const row = dataRows[i] ?? [];
|
|
1072
|
+
if (row.length !== headers.length) {
|
|
1073
|
+
return {
|
|
1074
|
+
ok: false,
|
|
1075
|
+
detail: `CSV row ${i + 2} has ${row.length} column(s), expected ${headers.length}. Keep empty placeholders as adjacent commas so every row matches the header. Header order: ${headers.join(" | ")}. Row ${i + 2}: ${summarizeCsvRow(row)}`,
|
|
1076
|
+
...base
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
for (const [column, allowedValues] of Object.entries(spec.allowedValues ?? {})) {
|
|
1082
|
+
const index = headers.indexOf(column);
|
|
1083
|
+
if (index === -1) {
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
detail: `CSV allowed-values check names unknown column "${column}"`,
|
|
1087
|
+
...base
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
for (let i = 0; i < dataRows.length; i++) {
|
|
1091
|
+
const value = (dataRows[i]?.[index] ?? "").trim();
|
|
1092
|
+
if (value.length > 0 && !allowedValues.includes(value)) {
|
|
1093
|
+
return {
|
|
1094
|
+
ok: false,
|
|
1095
|
+
detail: `CSV row ${i + 2} column "${column}" has "${value}", expected one of: ${allowedValues.join(", ")}`,
|
|
1096
|
+
...base
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
ok: true,
|
|
1103
|
+
detail: `CSV has ${headers.length} column(s) and ${dataRows.length} data row(s)`,
|
|
1104
|
+
...base
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function recordSchema(text, spec) {
|
|
1108
|
+
if (text === null) {
|
|
1109
|
+
return {
|
|
1110
|
+
ok: false,
|
|
1111
|
+
detail: "deliverable not found \u2014 write the records file before advancing.",
|
|
1112
|
+
rowCount: 0
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
if (spec.fields.length === 0) {
|
|
1116
|
+
return { ok: false, detail: 'no fields configured \u2014 set the schema "fields".', rowCount: 0 };
|
|
1117
|
+
}
|
|
1118
|
+
const stripped = text.replace(/^\uFEFF/, "");
|
|
1119
|
+
const fmt = spec.format && spec.format !== "auto" ? spec.format : /^\s*[[{]/.test(stripped) ? "json" : "csv";
|
|
1120
|
+
let records;
|
|
1121
|
+
if (fmt === "json") {
|
|
1122
|
+
let parsed;
|
|
1123
|
+
try {
|
|
1124
|
+
parsed = JSON.parse(stripped);
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1127
|
+
return { ok: false, detail: `not valid JSON: ${msg.slice(0, 160)}`, rowCount: 0 };
|
|
1128
|
+
}
|
|
1129
|
+
if (!Array.isArray(parsed)) {
|
|
1130
|
+
return {
|
|
1131
|
+
ok: false,
|
|
1132
|
+
detail: `expected a top-level JSON array, got ${typeof parsed}`,
|
|
1133
|
+
rowCount: 0
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
records = parsed;
|
|
1137
|
+
} else {
|
|
1138
|
+
const grid = parseCsv(text).filter((r) => r.some((c) => c.trim().length > 0));
|
|
1139
|
+
if (grid.length === 0) return { ok: false, detail: "CSV has no rows", rowCount: 0 };
|
|
1140
|
+
const headers = (grid[0] ?? []).map((h) => h.trim());
|
|
1141
|
+
records = grid.slice(1).map((cells) => {
|
|
1142
|
+
const o = {};
|
|
1143
|
+
headers.forEach((h, i) => {
|
|
1144
|
+
o[h] = (cells[i] ?? "").trim();
|
|
1145
|
+
});
|
|
1146
|
+
return o;
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
const fieldNames = spec.fields.map((f) => f.name);
|
|
1150
|
+
const allowExtra = spec.allowExtraFields ?? false;
|
|
1151
|
+
for (let i = 0; i < records.length; i++) {
|
|
1152
|
+
const row = records[i];
|
|
1153
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
1154
|
+
return {
|
|
1155
|
+
ok: false,
|
|
1156
|
+
detail: `record ${i} is not an object: ${JSON.stringify(row)?.slice(0, 120)}`,
|
|
1157
|
+
rowCount: records.length
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
for (const f of spec.fields) {
|
|
1161
|
+
const required = f.required !== false;
|
|
1162
|
+
const raw = row[f.name];
|
|
1163
|
+
const present = Object.prototype.hasOwnProperty.call(row, f.name) && raw !== "" && raw !== null && raw !== void 0;
|
|
1164
|
+
if (!present) {
|
|
1165
|
+
if (required) {
|
|
1166
|
+
return {
|
|
1167
|
+
ok: false,
|
|
1168
|
+
detail: `record ${i} is missing required field "${f.name}"`,
|
|
1169
|
+
rowCount: records.length
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
if (f.type && f.type !== "string") {
|
|
1175
|
+
const v = checkCellType(String(raw), f.type);
|
|
1176
|
+
if (!v.ok) {
|
|
1177
|
+
return {
|
|
1178
|
+
ok: false,
|
|
1179
|
+
detail: `record ${i} field "${f.name}": ${v.detail}`,
|
|
1180
|
+
rowCount: records.length
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (!allowExtra) {
|
|
1186
|
+
const extra = Object.keys(row).filter((k) => !fieldNames.includes(k));
|
|
1187
|
+
if (extra.length > 0) {
|
|
1188
|
+
return {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
detail: `record ${i} has unexpected field(s) ${extra.join(", ")} \u2014 allowed: ${fieldNames.join(", ")}`,
|
|
1191
|
+
rowCount: records.length
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (spec.minRows !== void 0 && records.length < spec.minRows) {
|
|
1197
|
+
return {
|
|
1198
|
+
ok: false,
|
|
1199
|
+
detail: `${records.length} record(s), need \u2265 ${spec.minRows}`,
|
|
1200
|
+
rowCount: records.length
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
if (spec.uniqueBy) {
|
|
1204
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1205
|
+
for (let i = 0; i < records.length; i++) {
|
|
1206
|
+
const key = String(records[i][spec.uniqueBy] ?? "");
|
|
1207
|
+
if (seen.has(key)) {
|
|
1208
|
+
return {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
detail: `duplicate ${spec.uniqueBy} "${key}" at record ${i} \u2014 values must be unique`,
|
|
1211
|
+
rowCount: records.length
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
seen.add(key);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
ok: true,
|
|
1219
|
+
detail: `${records.length} record(s) validate against the schema`,
|
|
1220
|
+
rowCount: records.length
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/checks/prose.ts
|
|
1225
|
+
function stripMarkdown(md) {
|
|
1226
|
+
return md.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^[#>\-*+\s]+/gm, " ").replace(/[*_~]/g, " ");
|
|
1227
|
+
}
|
|
1228
|
+
function countWords(text) {
|
|
1229
|
+
return (text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
|
|
1230
|
+
}
|
|
1231
|
+
function wordBand(text, opts = {}) {
|
|
1232
|
+
const cleaned = opts.stripMarkdown === false ? text : stripMarkdown(text);
|
|
1233
|
+
const words = countWords(cleaned);
|
|
1234
|
+
if (opts.min !== void 0 && words < opts.min) {
|
|
1235
|
+
return { ok: false, detail: `${words} words, need \u2265 ${opts.min}`, words };
|
|
1236
|
+
}
|
|
1237
|
+
if (opts.max !== void 0 && words > opts.max) {
|
|
1238
|
+
return {
|
|
1239
|
+
ok: false,
|
|
1240
|
+
detail: `${words} words, need \u2264 ${opts.max} \u2014 trim it (verbosity is a failure mode too)`,
|
|
1241
|
+
words
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
const band = opts.min !== void 0 && opts.max !== void 0 ? ` (band ${opts.min}\u2013${opts.max})` : "";
|
|
1245
|
+
return { ok: true, detail: `${words} words${band}`, words };
|
|
1246
|
+
}
|
|
1247
|
+
function countSyllables(word) {
|
|
1248
|
+
const w = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
1249
|
+
if (w.length === 0) return 0;
|
|
1250
|
+
if (w.length <= 3) return 1;
|
|
1251
|
+
const groups = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "").replace(/^y/, "").match(/[aeiouy]{1,2}/g);
|
|
1252
|
+
return Math.max(1, groups ? groups.length : 1);
|
|
1253
|
+
}
|
|
1254
|
+
function round1(n) {
|
|
1255
|
+
return Math.round(n * 10) / 10;
|
|
1256
|
+
}
|
|
1257
|
+
function readingLevel(text, opts = {}) {
|
|
1258
|
+
const cleaned = opts.stripMarkdown === false ? text : stripMarkdown(text);
|
|
1259
|
+
const sentences = Math.max(1, (cleaned.match(/[.!?]+(?=\s|$)/g) ?? []).length);
|
|
1260
|
+
const wordTokens = cleaned.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? [];
|
|
1261
|
+
const words = wordTokens.length;
|
|
1262
|
+
if (words === 0) {
|
|
1263
|
+
return { ok: false, detail: "no words to score", grade: 0, ease: 0, words: 0, sentences };
|
|
1264
|
+
}
|
|
1265
|
+
const syllables = wordTokens.reduce((s, w) => s + countSyllables(w), 0);
|
|
1266
|
+
const wps = words / sentences;
|
|
1267
|
+
const spw = syllables / words;
|
|
1268
|
+
const grade = round1(0.39 * wps + 11.8 * spw - 15.59);
|
|
1269
|
+
const ease = round1(206.835 - 1.015 * wps - 84.6 * spw);
|
|
1270
|
+
let fail = null;
|
|
1271
|
+
if (opts.maxGrade !== void 0 && grade > opts.maxGrade) {
|
|
1272
|
+
fail = `grade ${grade} > max ${opts.maxGrade} \u2014 simplify sentences and word choice`;
|
|
1273
|
+
} else if (opts.minGrade !== void 0 && grade < opts.minGrade) {
|
|
1274
|
+
fail = `grade ${grade} < min ${opts.minGrade}`;
|
|
1275
|
+
} else if (opts.minEase !== void 0 && ease < opts.minEase) {
|
|
1276
|
+
fail = `reading-ease ${ease} < min ${opts.minEase} \u2014 too dense, shorten sentences`;
|
|
1277
|
+
} else if (opts.maxEase !== void 0 && ease > opts.maxEase) {
|
|
1278
|
+
fail = `reading-ease ${ease} > max ${opts.maxEase}`;
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
ok: fail === null,
|
|
1282
|
+
detail: fail ?? `grade ${grade}, reading-ease ${ease}`,
|
|
1283
|
+
grade,
|
|
1284
|
+
ease,
|
|
1285
|
+
words,
|
|
1286
|
+
sentences
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function literalWordRegExp(s) {
|
|
1290
|
+
const esc = s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1291
|
+
try {
|
|
1292
|
+
return new RegExp(`(?<!\\w)${esc}(?!\\w)`);
|
|
1293
|
+
} catch {
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
function namedEntitiesConsistent(text, entities) {
|
|
1298
|
+
const violations = [];
|
|
1299
|
+
for (const e of entities) {
|
|
1300
|
+
for (const variant of e.variants) {
|
|
1301
|
+
if (variant === e.canonical) continue;
|
|
1302
|
+
const re = literalWordRegExp(variant);
|
|
1303
|
+
if (re?.test(text)) violations.push({ canonical: e.canonical, variant });
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (violations.length > 0) {
|
|
1307
|
+
const v = violations[0];
|
|
1308
|
+
return {
|
|
1309
|
+
ok: false,
|
|
1310
|
+
detail: `inconsistent reference: "${v.variant}" appears \u2014 use the canonical form "${v.canonical}" consistently throughout.`,
|
|
1311
|
+
violations
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
return {
|
|
1315
|
+
ok: true,
|
|
1316
|
+
detail: `all ${entities.length} entit${entities.length === 1 ? "y" : "ies"} referenced consistently`,
|
|
1317
|
+
violations
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
function normalizedProse(text) {
|
|
1321
|
+
return stripMarkdown(text).replace(/\s+/g, " ").trim().toLowerCase();
|
|
1322
|
+
}
|
|
1323
|
+
function regexFlags(flags) {
|
|
1324
|
+
const base = flags === void 0 ? "i" : flags;
|
|
1325
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1326
|
+
let out = "";
|
|
1327
|
+
for (const flag of `${base}g`) {
|
|
1328
|
+
if (seen.has(flag)) continue;
|
|
1329
|
+
seen.add(flag);
|
|
1330
|
+
out += flag;
|
|
1331
|
+
}
|
|
1332
|
+
return out;
|
|
1333
|
+
}
|
|
1334
|
+
function shortList(paths) {
|
|
1335
|
+
if (paths.length <= 3) return paths.join(", ");
|
|
1336
|
+
return `${paths.slice(0, 3).join(", ")} and ${paths.length - 3} more`;
|
|
1337
|
+
}
|
|
1338
|
+
async function unsupportedClaims(ws, file, sourceFiles, patterns, opts = {}) {
|
|
1339
|
+
const content = await ws.read(file);
|
|
1340
|
+
if (content === null) {
|
|
1341
|
+
return {
|
|
1342
|
+
ok: false,
|
|
1343
|
+
detail: `${file} not found`,
|
|
1344
|
+
violations: [],
|
|
1345
|
+
missingSources: []
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
if (sourceFiles.length === 0) {
|
|
1349
|
+
return {
|
|
1350
|
+
ok: false,
|
|
1351
|
+
detail: `${file} has no source files to ground claim wording against`,
|
|
1352
|
+
violations: [],
|
|
1353
|
+
missingSources: []
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
const missingSources = [];
|
|
1357
|
+
const sourceParts = [];
|
|
1358
|
+
for (const sourceFile of sourceFiles) {
|
|
1359
|
+
const source = await ws.read(sourceFile);
|
|
1360
|
+
if (source === null) {
|
|
1361
|
+
missingSources.push(sourceFile);
|
|
1362
|
+
} else {
|
|
1363
|
+
sourceParts.push(source);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
if (missingSources.length > 0) {
|
|
1367
|
+
return {
|
|
1368
|
+
ok: false,
|
|
1369
|
+
detail: `${shortList(missingSources)} not found (needed to ground claim wording in ${file})`,
|
|
1370
|
+
violations: [],
|
|
1371
|
+
missingSources
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
const sourceText = normalizedProse(sourceParts.join("\n"));
|
|
1375
|
+
const violations = [];
|
|
1376
|
+
const maxViolations = opts.maxViolations ?? 5;
|
|
1377
|
+
for (const candidate of patterns) {
|
|
1378
|
+
let re;
|
|
1379
|
+
try {
|
|
1380
|
+
re = new RegExp(candidate.pattern, regexFlags(opts.flags));
|
|
1381
|
+
} catch {
|
|
1382
|
+
return {
|
|
1383
|
+
ok: false,
|
|
1384
|
+
detail: `invalid unsupported-claims pattern /${candidate.pattern}/`,
|
|
1385
|
+
violations: [],
|
|
1386
|
+
missingSources: []
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
for (const match of content.matchAll(re)) {
|
|
1390
|
+
const rawMatch = match[0]?.replace(/\s+/g, " ").trim();
|
|
1391
|
+
if (!rawMatch) continue;
|
|
1392
|
+
const grounded = sourceText.includes(normalizedProse(rawMatch));
|
|
1393
|
+
if (!grounded) {
|
|
1394
|
+
violations.push({
|
|
1395
|
+
pattern: candidate.pattern,
|
|
1396
|
+
...candidate.label ? { label: candidate.label } : {},
|
|
1397
|
+
match: rawMatch.slice(0, 120)
|
|
1398
|
+
});
|
|
1399
|
+
if (violations.length >= maxViolations) break;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (violations.length >= maxViolations) break;
|
|
1403
|
+
}
|
|
1404
|
+
if (violations.length > 0) {
|
|
1405
|
+
const first = violations[0];
|
|
1406
|
+
const guidance = first.label ? `: ${first.label}` : "";
|
|
1407
|
+
return {
|
|
1408
|
+
ok: false,
|
|
1409
|
+
detail: `${file} has unsupported claim wording${guidance} (matched "${first.match}") \u2014 rewrite it using only source facts from ${shortList(sourceFiles)} or remove it.`,
|
|
1410
|
+
violations,
|
|
1411
|
+
missingSources: []
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
ok: true,
|
|
1416
|
+
detail: `${file} claim wording is grounded in ${shortList(sourceFiles)}`,
|
|
1417
|
+
violations: [],
|
|
1418
|
+
missingSources: []
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// src/checks/runtime-hints.ts
|
|
1423
|
+
function wrapperReturnHint(outputLines) {
|
|
1424
|
+
let wrapperKey = null;
|
|
1425
|
+
let hits = 0;
|
|
1426
|
+
for (const line of outputLines) {
|
|
1427
|
+
const m = line.match(/expected (\[.*?), got (\{.*)$/);
|
|
1428
|
+
if (!m) continue;
|
|
1429
|
+
hits += 1;
|
|
1430
|
+
const got = m[2];
|
|
1431
|
+
if (wrapperKey === null && got) {
|
|
1432
|
+
const k = got.match(/^\{\s*"([^"]+)"/);
|
|
1433
|
+
if (k?.[1]) wrapperKey = k[1];
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
if (hits === 0) return null;
|
|
1437
|
+
const example = wrapperKey ? `{ "${wrapperKey}": [...] }` : "{ ... }";
|
|
1438
|
+
return `Hint: the function is returning an OBJECT where the caller expects an ARRAY \u2014 it received a wrapper like \`${example}\`. Return the array itself (\`return result;\`), not an object with it nested inside.`;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// src/checks/markdown.ts
|
|
1442
|
+
function cleanHeading(text) {
|
|
1443
|
+
return text.replace(/\s+#+\s*$/, "").replace(/[*_`]/g, "").replace(/[–—]/g, "-").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLocaleLowerCase();
|
|
1444
|
+
}
|
|
1445
|
+
function documentH1s(markdown) {
|
|
1446
|
+
return [...markdown.matchAll(/^#\s+(.+?)\s*$/gm)].map((match) => match[1].trim());
|
|
1447
|
+
}
|
|
1448
|
+
function outlineSlideHeadings(markdown) {
|
|
1449
|
+
const headings = [];
|
|
1450
|
+
for (const match of markdown.matchAll(/^#{2,6}\s+(.+?)\s*$/gm)) {
|
|
1451
|
+
const raw = match[1].trim();
|
|
1452
|
+
const numbered = /^(?:slide\s+)?\d+(?:\s*[.:\-–—]\s*|\s+)(\S.*)$/i.exec(raw);
|
|
1453
|
+
if (numbered?.[1]) headings.push(numbered[1].trim());
|
|
1454
|
+
}
|
|
1455
|
+
return headings;
|
|
1456
|
+
}
|
|
1457
|
+
async function markdownHeadingsMatch(ws, file, outlineFile) {
|
|
1458
|
+
const [document, outline] = await Promise.all([ws.read(file), ws.read(outlineFile)]);
|
|
1459
|
+
if (document === null) {
|
|
1460
|
+
return {
|
|
1461
|
+
ok: false,
|
|
1462
|
+
detail: `${file} not found \u2014 write the Markdown document before advancing.`,
|
|
1463
|
+
outlineHeadings: [],
|
|
1464
|
+
documentHeadings: []
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
if (outline === null) {
|
|
1468
|
+
return {
|
|
1469
|
+
ok: false,
|
|
1470
|
+
detail: `${outlineFile} not found \u2014 the locked outline is required before writing ${file}.`,
|
|
1471
|
+
outlineHeadings: [],
|
|
1472
|
+
documentHeadings: documentH1s(document)
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
const expected = outlineSlideHeadings(outline);
|
|
1476
|
+
const actual = documentH1s(document);
|
|
1477
|
+
if (expected.length === 0) {
|
|
1478
|
+
return {
|
|
1479
|
+
ok: false,
|
|
1480
|
+
detail: `${outlineFile} has no numbered slide headings. Use \`## Slide 1 \u2014 Title\` through the final slide so the deck can be checked mechanically.`,
|
|
1481
|
+
outlineHeadings: expected,
|
|
1482
|
+
documentHeadings: actual
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
if (actual.length !== expected.length) {
|
|
1486
|
+
return {
|
|
1487
|
+
ok: false,
|
|
1488
|
+
detail: `${file} has ${actual.length} H1 slide headings, but ${outlineFile} locks ${expected.length}. Add or remove slides without merging outline items.`,
|
|
1489
|
+
outlineHeadings: expected,
|
|
1490
|
+
documentHeadings: actual
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
1494
|
+
if (cleanHeading(actual[index]) === cleanHeading(expected[index])) continue;
|
|
1495
|
+
return {
|
|
1496
|
+
ok: false,
|
|
1497
|
+
detail: `${file} slide ${index + 1} is "${actual[index]}", but ${outlineFile} requires "${expected[index]}" in that position. Preserve the locked slide titles and order.`,
|
|
1498
|
+
outlineHeadings: expected,
|
|
1499
|
+
documentHeadings: actual,
|
|
1500
|
+
mismatchIndex: index
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
return {
|
|
1504
|
+
ok: true,
|
|
1505
|
+
detail: `${file}: ${actual.length} H1 slide headings match ${outlineFile} exactly and in order`,
|
|
1506
|
+
outlineHeadings: expected,
|
|
1507
|
+
documentHeadings: actual
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// src/checks/sniff-explain.ts
|
|
1512
|
+
function explainSniff(name, content) {
|
|
1513
|
+
switch (name) {
|
|
1514
|
+
case "html-complete": {
|
|
1515
|
+
const scripts = detectUnclosedScript(content);
|
|
1516
|
+
if (scripts.unclosed) {
|
|
1517
|
+
return `${scripts.opens} <script> tag${scripts.opens === 1 ? "" : "s"} open but only ${scripts.closes} close${scripts.closes === 1 ? "s" : ""} \u2014 the document is truncated mid-script. Finish the file: close the script and end with </body></html>.`;
|
|
1518
|
+
}
|
|
1519
|
+
const lower = content.toLowerCase();
|
|
1520
|
+
if (!lower.includes("</body>") && !lower.includes("</html>")) {
|
|
1521
|
+
return "missing a closing </body> or </html> \u2014 the document is incomplete; finish it.";
|
|
1522
|
+
}
|
|
1523
|
+
return "the document is incomplete \u2014 finish and close it.";
|
|
1524
|
+
}
|
|
1525
|
+
case "html-game": {
|
|
1526
|
+
const lower = content.toLowerCase();
|
|
1527
|
+
const hasRenderTarget = /<canvas\b/.test(lower) || /<svg\b/.test(lower);
|
|
1528
|
+
const hasFrameLoop = /requestanimationframe\s*\(/.test(lower) || /set(?:interval|timeout)\s*\(/.test(lower) || /\bfunction\s+(?:tick|update|loop|gameloop|gametick|step|render|frame)\b/.test(lower);
|
|
1529
|
+
if (!hasRenderTarget && !hasFrameLoop) {
|
|
1530
|
+
return "no render surface (<canvas>/<svg>) and no frame loop (requestAnimationFrame / setInterval / a tick()/update() function) \u2014 add the game loop.";
|
|
1531
|
+
}
|
|
1532
|
+
const scripts = detectUnclosedScript(content);
|
|
1533
|
+
if (scripts.opens === 0) {
|
|
1534
|
+
return "no <script> block \u2014 the page has no game logic; add the inline script.";
|
|
1535
|
+
}
|
|
1536
|
+
if (scripts.unclosed) {
|
|
1537
|
+
return `${scripts.opens} <script> tag${scripts.opens === 1 ? "" : "s"} open but only ${scripts.closes} close${scripts.closes === 1 ? "s" : ""} \u2014 truncated mid-script; close the script.`;
|
|
1538
|
+
}
|
|
1539
|
+
const js = inlineJsBytes(content);
|
|
1540
|
+
if (js < 400) {
|
|
1541
|
+
return `inline JavaScript is ${js} bytes, need >= 400 \u2014 the page has no substantive game logic yet.`;
|
|
1542
|
+
}
|
|
1543
|
+
return "the page does not read as a working game yet \u2014 check the script and game loop.";
|
|
1544
|
+
}
|
|
1545
|
+
case "json-valid": {
|
|
1546
|
+
const parsed = jsonValid(content);
|
|
1547
|
+
if (!parsed.ok) {
|
|
1548
|
+
return `not valid JSON: ${parsed.error ?? "parse failed"} \u2014 fix the syntax error.`;
|
|
1549
|
+
}
|
|
1550
|
+
return "the file must be valid JSON.";
|
|
1551
|
+
}
|
|
1552
|
+
case "nonempty":
|
|
1553
|
+
return "the file is empty \u2014 write the deliverable content.";
|
|
1554
|
+
case "data-table":
|
|
1555
|
+
return "not parseable data \u2014 expected a non-empty JSON array, a comma-delimited table (header + at least one row), or a Markdown table. If you wrote the transform/pipeline code but not its output, RUN it and write the produced data to the file.";
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/checks/judge.ts
|
|
1560
|
+
var MIN_JUDGE_EVIDENCE_SUBSTRING = 24;
|
|
1561
|
+
function buildJudgePrompt(opts) {
|
|
1562
|
+
const sources = (opts.sources ?? []).map((s) => `--- source: ${s.path} ---
|
|
1563
|
+
${s.text.slice(0, 8e3)}`).join("\n\n");
|
|
1564
|
+
const evidenceRule = opts.requireEvidence === false ? "" : ' A "fail" verdict MUST include at least one EVIDENCE quote copied from the artifact VERBATIM, character for character \u2014 a verdict with fabricated or paraphrased evidence is discarded.';
|
|
1565
|
+
return [
|
|
1566
|
+
`You are judging one quality of a deliverable. Rubric: ${opts.rubric}`,
|
|
1567
|
+
sources ? `Reference material:
|
|
1568
|
+
|
|
1569
|
+
${sources}` : "",
|
|
1570
|
+
`--- artifact: ${opts.file} ---
|
|
1571
|
+
${opts.artifactText.slice(0, 24e3)}`,
|
|
1572
|
+
`Reply with STRICT JSON only, matching: {"verdict": "pass" | "fail", "reasons": ["\u2026" (max 5)], "evidence": ["verbatim quote from the artifact", \u2026], "confidence": "low" | "medium" | "high"}.${evidenceRule} No prose outside the JSON.`
|
|
1573
|
+
].filter(Boolean).join("\n\n");
|
|
1574
|
+
}
|
|
1575
|
+
function parseJudgeVerdict(raw) {
|
|
1576
|
+
const fenced = /```(?:json)?\s*\n?([\s\S]*?)```/.exec(raw);
|
|
1577
|
+
const candidates = [];
|
|
1578
|
+
if (fenced?.[1]) candidates.push(fenced[1]);
|
|
1579
|
+
candidates.push(raw.trim());
|
|
1580
|
+
const first = raw.indexOf("{");
|
|
1581
|
+
const last = raw.lastIndexOf("}");
|
|
1582
|
+
if (first >= 0 && last > first) candidates.push(raw.slice(first, last + 1));
|
|
1583
|
+
let lastError;
|
|
1584
|
+
for (const candidate of candidates) {
|
|
1585
|
+
try {
|
|
1586
|
+
return validateJudgeVerdict(JSON.parse(candidate));
|
|
1587
|
+
} catch (err) {
|
|
1588
|
+
lastError = err;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
throw lastError instanceof Error ? lastError : new Error("no parseable judge verdict found");
|
|
1592
|
+
}
|
|
1593
|
+
function validateJudgeVerdict(value) {
|
|
1594
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1595
|
+
throw new Error("judge verdict must be a JSON object");
|
|
1596
|
+
}
|
|
1597
|
+
const candidate = value;
|
|
1598
|
+
if (candidate.verdict !== "pass" && candidate.verdict !== "fail") {
|
|
1599
|
+
throw new Error('judge verdict must be "pass" or "fail"');
|
|
1600
|
+
}
|
|
1601
|
+
if (!Array.isArray(candidate.reasons) || candidate.reasons.length > 5 || candidate.reasons.some((reason) => typeof reason !== "string" || reason.length === 0)) {
|
|
1602
|
+
throw new Error("judge reasons must be an array of at most five non-empty strings");
|
|
1603
|
+
}
|
|
1604
|
+
const evidence = candidate.evidence ?? [];
|
|
1605
|
+
if (!Array.isArray(evidence) || evidence.some((quote) => typeof quote !== "string")) {
|
|
1606
|
+
throw new Error("judge evidence must be an array of strings");
|
|
1607
|
+
}
|
|
1608
|
+
const confidence = candidate.confidence;
|
|
1609
|
+
if (confidence !== void 0 && confidence !== "low" && confidence !== "medium" && confidence !== "high") {
|
|
1610
|
+
throw new Error('judge confidence must be "low", "medium", or "high"');
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
verdict: candidate.verdict,
|
|
1614
|
+
reasons: candidate.reasons,
|
|
1615
|
+
evidence,
|
|
1616
|
+
...confidence === void 0 ? {} : { confidence }
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
function normalizeForEvidence(text) {
|
|
1620
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
1621
|
+
}
|
|
1622
|
+
function validateJudgeEvidence(verdict, artifactText) {
|
|
1623
|
+
const haystack = normalizeForEvidence(artifactText);
|
|
1624
|
+
const kept = [];
|
|
1625
|
+
let dropped = 0;
|
|
1626
|
+
for (const quote of verdict.evidence) {
|
|
1627
|
+
const needle = normalizeForEvidence(quote);
|
|
1628
|
+
if (needle.length >= MIN_JUDGE_EVIDENCE_SUBSTRING && haystack.includes(needle)) {
|
|
1629
|
+
kept.push(quote);
|
|
1630
|
+
} else {
|
|
1631
|
+
dropped += 1;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
return { kept, dropped };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/checks/plan.ts
|
|
1638
|
+
var REQUIRED_COLUMNS = ["id", "task", "owner", "depends on", "done when"];
|
|
1639
|
+
function headerIndex(headers, name) {
|
|
1640
|
+
return headers.findIndex((h) => h.trim().toLowerCase() === name);
|
|
1641
|
+
}
|
|
1642
|
+
function parseDeps(cell) {
|
|
1643
|
+
const trimmed = cell.trim();
|
|
1644
|
+
if (!trimmed || /^[-\u2010-\u2015\u2212]$/.test(trimmed) || /^(none|n\/a)$/i.test(trimmed)) {
|
|
1645
|
+
return [];
|
|
1646
|
+
}
|
|
1647
|
+
return trimmed.split(/[,;]/).map((s) => s.trim()).filter(Boolean);
|
|
1648
|
+
}
|
|
1649
|
+
function planStructure(text, spec = {}) {
|
|
1650
|
+
const fail = (detail, partial = {}) => ({
|
|
1651
|
+
ok: false,
|
|
1652
|
+
detail,
|
|
1653
|
+
rows: [],
|
|
1654
|
+
unknownDeps: [],
|
|
1655
|
+
cycleIds: [],
|
|
1656
|
+
missingOwners: [],
|
|
1657
|
+
weakDoneStates: [],
|
|
1658
|
+
...partial
|
|
1659
|
+
});
|
|
1660
|
+
const table = parseMarkdownTable(text);
|
|
1661
|
+
if (!table) {
|
|
1662
|
+
return fail(
|
|
1663
|
+
"no Markdown table found \u2014 the plan needs a table with columns ID | Task | Owner | Depends on | Done when"
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
const headers = table.headers.map((h) => h.trim());
|
|
1667
|
+
const missing = REQUIRED_COLUMNS.filter((c) => headerIndex(headers, c) < 0);
|
|
1668
|
+
if (missing.length > 0) {
|
|
1669
|
+
return fail(
|
|
1670
|
+
`the plan table is missing column(s): ${missing.join(", ")} (found: ${headers.join(" | ")})`
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
const col = {
|
|
1674
|
+
id: headerIndex(headers, "id"),
|
|
1675
|
+
task: headerIndex(headers, "task"),
|
|
1676
|
+
owner: headerIndex(headers, "owner"),
|
|
1677
|
+
deps: headerIndex(headers, "depends on"),
|
|
1678
|
+
done: headerIndex(headers, "done when"),
|
|
1679
|
+
estimate: headerIndex(headers, "estimate")
|
|
1680
|
+
};
|
|
1681
|
+
const rows = table.rows.map((cells) => ({
|
|
1682
|
+
id: (cells[col.id] ?? "").trim(),
|
|
1683
|
+
task: (cells[col.task] ?? "").trim(),
|
|
1684
|
+
owner: (cells[col.owner] ?? "").trim(),
|
|
1685
|
+
dependsOn: parseDeps(cells[col.deps] ?? ""),
|
|
1686
|
+
doneWhen: (cells[col.done] ?? "").trim(),
|
|
1687
|
+
...col.estimate >= 0 && (cells[col.estimate] ?? "").trim() ? { estimate: (cells[col.estimate] ?? "").trim() } : {}
|
|
1688
|
+
}));
|
|
1689
|
+
const minRows = spec.minRows ?? 1;
|
|
1690
|
+
if (rows.length < minRows) {
|
|
1691
|
+
return fail(`the plan table has ${rows.length} row(s) \u2014 at least ${minRows} required`, {
|
|
1692
|
+
rows
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
const ids = rows.map((r) => r.id);
|
|
1696
|
+
const idSet = new Set(ids);
|
|
1697
|
+
if (idSet.size !== ids.length) {
|
|
1698
|
+
const dupe = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
1699
|
+
return fail(`duplicate plan row id "${dupe}" \u2014 every ID must be unique`, { rows });
|
|
1700
|
+
}
|
|
1701
|
+
for (const row of rows) {
|
|
1702
|
+
if (!row.id) return fail("a plan row has an empty ID cell", { rows });
|
|
1703
|
+
}
|
|
1704
|
+
const roster = spec.ownerRoster?.map((o) => o.trim().toLowerCase());
|
|
1705
|
+
const missingOwners = [];
|
|
1706
|
+
for (const row of rows) {
|
|
1707
|
+
if (!row.owner) {
|
|
1708
|
+
missingOwners.push(row.id);
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
if (roster && !roster.includes(row.owner.toLowerCase())) {
|
|
1712
|
+
return fail(
|
|
1713
|
+
`row ${row.id}: Owner "${row.owner}" is not on the roster (${(spec.ownerRoster ?? []).join(", ")})`,
|
|
1714
|
+
{ rows, missingOwners }
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
if (missingOwners.length > 0) {
|
|
1719
|
+
return fail(`row ${missingOwners[0]}: the Owner cell is empty \u2014 every task needs an owner`, {
|
|
1720
|
+
rows,
|
|
1721
|
+
missingOwners
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
const indexOfId = new Map(rows.map((r, i) => [r.id, i]));
|
|
1725
|
+
const unknownDeps = [];
|
|
1726
|
+
for (const row of rows) {
|
|
1727
|
+
for (const dep of row.dependsOn) {
|
|
1728
|
+
if (!idSet.has(dep)) {
|
|
1729
|
+
unknownDeps.push(`${row.id}\u2192${dep}`);
|
|
1730
|
+
return fail(
|
|
1731
|
+
`row ${row.id}: "Depends on" references ${dep}, which is not a row ID in this plan`,
|
|
1732
|
+
{ rows, unknownDeps }
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
if (dep === row.id) {
|
|
1736
|
+
return fail(`row ${row.id} depends on itself`, { rows });
|
|
1737
|
+
}
|
|
1738
|
+
if ((spec.requireEarlierOnly ?? true) && indexOfId.get(dep) > indexOfId.get(row.id)) {
|
|
1739
|
+
return fail(
|
|
1740
|
+
`row ${row.id}: "Depends on" references ${dep}, which is a LATER row \u2014 order the plan so dependencies come first`,
|
|
1741
|
+
{ rows }
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
const cycleIds = findCycle(rows);
|
|
1747
|
+
if (cycleIds.length > 0) {
|
|
1748
|
+
return fail(`dependency cycle: ${cycleIds.join(" \u2192 ")} \u2014 break the loop`, { rows, cycleIds });
|
|
1749
|
+
}
|
|
1750
|
+
const doneFloor = spec.doneWhenMinChars ?? 12;
|
|
1751
|
+
const weakDoneStates = rows.filter((r) => r.doneWhen.length < doneFloor).map((r) => r.id);
|
|
1752
|
+
if (weakDoneStates.length > 0) {
|
|
1753
|
+
const row = rows.find((r) => r.id === weakDoneStates[0]);
|
|
1754
|
+
return fail(
|
|
1755
|
+
`row ${row.id}: "Done when" is "${row.doneWhen}" \u2014 too vague to check (write an observable completion state, at least ${doneFloor} characters)`,
|
|
1756
|
+
{ rows, weakDoneStates }
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
return {
|
|
1760
|
+
ok: true,
|
|
1761
|
+
detail: "",
|
|
1762
|
+
rows,
|
|
1763
|
+
unknownDeps: [],
|
|
1764
|
+
cycleIds: [],
|
|
1765
|
+
missingOwners: [],
|
|
1766
|
+
weakDoneStates: []
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
function findCycle(rows) {
|
|
1770
|
+
const deps = new Map(rows.map((r) => [r.id, r.dependsOn]));
|
|
1771
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1772
|
+
const done = /* @__PURE__ */ new Set();
|
|
1773
|
+
const path = [];
|
|
1774
|
+
let cycle = [];
|
|
1775
|
+
const visit = (id) => {
|
|
1776
|
+
if (done.has(id)) return false;
|
|
1777
|
+
if (visiting.has(id)) {
|
|
1778
|
+
const start = path.indexOf(id);
|
|
1779
|
+
cycle = [...path.slice(start), id];
|
|
1780
|
+
return true;
|
|
1781
|
+
}
|
|
1782
|
+
visiting.add(id);
|
|
1783
|
+
path.push(id);
|
|
1784
|
+
for (const dep of deps.get(id) ?? []) {
|
|
1785
|
+
if (visit(dep)) return true;
|
|
1786
|
+
}
|
|
1787
|
+
visiting.delete(id);
|
|
1788
|
+
path.pop();
|
|
1789
|
+
done.add(id);
|
|
1790
|
+
return false;
|
|
1791
|
+
};
|
|
1792
|
+
for (const row of rows) {
|
|
1793
|
+
if (visit(row.id)) break;
|
|
1794
|
+
}
|
|
1795
|
+
return cycle;
|
|
1796
|
+
}
|
|
1797
|
+
export {
|
|
1798
|
+
IMG_EXT,
|
|
1799
|
+
MIN_INLINE_JS_BYTES,
|
|
1800
|
+
MIN_JUDGE_EVIDENCE_SUBSTRING,
|
|
1801
|
+
buildJudgePrompt,
|
|
1802
|
+
citationsResolve,
|
|
1803
|
+
containsPattern,
|
|
1804
|
+
countDistinctMatches,
|
|
1805
|
+
cssMinBytes,
|
|
1806
|
+
csvShape,
|
|
1807
|
+
dataTableSniff,
|
|
1808
|
+
detectTypeScriptOnlySyntax,
|
|
1809
|
+
detectUnclosedScript,
|
|
1810
|
+
esmImports,
|
|
1811
|
+
explainSniff,
|
|
1812
|
+
extractInlineScripts,
|
|
1813
|
+
fileCountByExt,
|
|
1814
|
+
fileMinBytes,
|
|
1815
|
+
fileMinLines,
|
|
1816
|
+
findImageRefs,
|
|
1817
|
+
grepMatches,
|
|
1818
|
+
htmlCompleteSniff,
|
|
1819
|
+
htmlGameSniff,
|
|
1820
|
+
imageRefsResolve,
|
|
1821
|
+
inlineJsBytes,
|
|
1822
|
+
isRealIsoDate,
|
|
1823
|
+
jsonPathEquals,
|
|
1824
|
+
jsonValid,
|
|
1825
|
+
markdownHeadingsMatch,
|
|
1826
|
+
namedEntitiesConsistent,
|
|
1827
|
+
normalizeDigitGroups,
|
|
1828
|
+
notContainsPattern,
|
|
1829
|
+
parseCsv,
|
|
1830
|
+
parseJudgeVerdict,
|
|
1831
|
+
parseMarkdownTable,
|
|
1832
|
+
planStructure,
|
|
1833
|
+
readingLevel,
|
|
1834
|
+
recordSchema,
|
|
1835
|
+
requireOrderedSections,
|
|
1836
|
+
resolveRelative,
|
|
1837
|
+
securityReport,
|
|
1838
|
+
standaloneJsParses,
|
|
1839
|
+
tableShape,
|
|
1840
|
+
totalMinBytes,
|
|
1841
|
+
unsupportedClaims,
|
|
1842
|
+
validateJudgeEvidence,
|
|
1843
|
+
validateScriptSyntax,
|
|
1844
|
+
valueGrounding,
|
|
1845
|
+
valuesSubsetOf,
|
|
1846
|
+
wordBand,
|
|
1847
|
+
wrapperReturnHint
|
|
1848
|
+
};
|