frontend-harness 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -3
- package/dist/cli/index.js +7 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/runtime/builtin-skills.js +5 -3
- package/dist/runtime/builtin-skills.js.map +1 -1
- package/dist/runtime/clean.js +3 -1
- package/dist/runtime/clean.js.map +1 -1
- package/dist/runtime/command-taxonomy.js +1 -0
- package/dist/runtime/command-taxonomy.js.map +1 -1
- package/dist/runtime/evidence.d.ts +34 -0
- package/dist/runtime/evidence.js +636 -0
- package/dist/runtime/evidence.js.map +1 -0
- package/dist/runtime/plan/guidance.js +4 -2
- package/dist/runtime/plan/guidance.js.map +1 -1
- package/dist/runtime/plan.js +0 -3
- package/dist/runtime/plan.js.map +1 -1
- package/dist/runtime/protocol-init.js +3 -1
- package/dist/runtime/protocol-init.js.map +1 -1
- package/dist/runtime/state.js +1 -1
- package/dist/runtime/state.js.map +1 -1
- package/dist/runtime/ui-restoration.d.ts +3 -3
- package/dist/runtime/ui-restoration.js +20 -8
- package/dist/runtime/ui-restoration.js.map +1 -1
- package/dist/runtime/verify.d.ts +0 -24
- package/dist/runtime/verify.js +1 -610
- package/dist/runtime/verify.js.map +1 -1
- package/dist/schemas/types.d.ts +1 -1
- package/docs/DIRECTION.md +1 -0
- package/package.json +1 -1
package/dist/runtime/verify.d.ts
CHANGED
|
@@ -5,27 +5,3 @@ export interface VerifyOptions {
|
|
|
5
5
|
build?: string;
|
|
6
6
|
}
|
|
7
7
|
export declare function runVerification(projectRoot: string, options: VerifyOptions): VerificationResult;
|
|
8
|
-
export interface EvidenceTemplateResult {
|
|
9
|
-
manifestPath: string;
|
|
10
|
-
generatedAt: string;
|
|
11
|
-
status: "ready" | "not_configured";
|
|
12
|
-
entries: EvidenceManifestEntry[];
|
|
13
|
-
instructions: string[];
|
|
14
|
-
}
|
|
15
|
-
export interface EvidenceRecordResult {
|
|
16
|
-
status: "passed";
|
|
17
|
-
inputPath: string;
|
|
18
|
-
manifestPath: string;
|
|
19
|
-
recordedEntries: EvidenceManifestEntry[];
|
|
20
|
-
entryCount: number;
|
|
21
|
-
}
|
|
22
|
-
export declare function createEvidenceTemplate(projectRoot: string): EvidenceTemplateResult;
|
|
23
|
-
export declare function recordEvidence(projectRoot: string, inputPath: string | undefined): EvidenceRecordResult;
|
|
24
|
-
export declare function assertDesignSourceReadyForProjectChanges(projectRoot: string, files: string[]): void;
|
|
25
|
-
export interface EvidenceManifestEntry {
|
|
26
|
-
unitId: string;
|
|
27
|
-
kind: string;
|
|
28
|
-
artifactPaths: string[];
|
|
29
|
-
createdAt: string;
|
|
30
|
-
metadata?: Record<string, unknown>;
|
|
31
|
-
}
|
package/dist/runtime/verify.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
2
|
import fs from "node:fs";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import process from "node:process";
|
|
6
5
|
import { harnessPath } from "../storage/paths.js";
|
|
7
6
|
import { ensureDir, readJson, writeJson, writeText } from "../storage/json.js";
|
|
7
|
+
import { checkVerificationEvidence } from "./evidence.js";
|
|
8
8
|
import { checkStateContract, loadState, updateVerificationState } from "./state.js";
|
|
9
9
|
import { checkKnowledgeCoverage } from "./knowledge.js";
|
|
10
10
|
import { inspectVerificationCommands } from "./verification-commands.js";
|
|
11
11
|
import { createRepairPacket } from "./repair-packet.js";
|
|
12
12
|
import { projectScriptCommand } from "./command-taxonomy.js";
|
|
13
|
-
import { isProjectRelativeSubpath, normalizeProjectRelativePath, resolveProjectOrHarnessRelativePath } from "./project-paths.js";
|
|
14
13
|
const MAX_NODE_E_SCRIPT_LENGTH = 200;
|
|
15
14
|
export function runVerification(projectRoot, options) {
|
|
16
15
|
const startedFromFailed = readPreviousStatus(projectRoot) === "failed";
|
|
@@ -242,614 +241,6 @@ export function runVerification(projectRoot, options) {
|
|
|
242
241
|
}
|
|
243
242
|
return result;
|
|
244
243
|
}
|
|
245
|
-
export function createEvidenceTemplate(projectRoot) {
|
|
246
|
-
const manifestPath = ".frontend-harness/verification/evidence.json";
|
|
247
|
-
const requiredEvidence = requiredVerificationEvidence(projectRoot);
|
|
248
|
-
const generatedAt = new Date().toISOString();
|
|
249
|
-
return {
|
|
250
|
-
manifestPath,
|
|
251
|
-
generatedAt,
|
|
252
|
-
status: requiredEvidence.length > 0 ? "ready" : "not_configured",
|
|
253
|
-
entries: requiredEvidence.map((required) => evidenceTemplateEntry(required, generatedAt)),
|
|
254
|
-
instructions: requiredEvidence.length > 0
|
|
255
|
-
? [
|
|
256
|
-
"Use this as a schema template, not as proof.",
|
|
257
|
-
"Replace placeholder artifact paths, timestamps, metadata values, and hashes with facts from real source capture, command output, browser/manual inspection, or screenshot comparison.",
|
|
258
|
-
"For design_to_code sources, preserve the generated artifact under .frontend-harness/ and classify usability before copying or mining it for project source.",
|
|
259
|
-
"For visual-restoration entries, repair project UI when comparison fails before recording a passing verdict."
|
|
260
|
-
]
|
|
261
|
-
: [`Run ${projectScriptCommand("frontend-harness plan --json \"<task>\"")} before generating an evidence template.`]
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
export function recordEvidence(projectRoot, inputPath) {
|
|
265
|
-
if (!inputPath) {
|
|
266
|
-
throw new Error("evidence record requires --file <evidence-input.json>");
|
|
267
|
-
}
|
|
268
|
-
const input = resolveProjectOrHarnessRelativePath(projectRoot, inputPath, "evidence input file");
|
|
269
|
-
if (isProjectRelativeSubpath(input.relativePath, ".frontend-harness/verification")) {
|
|
270
|
-
throw new Error("evidence input file must be outside .frontend-harness/verification; use a project-local JSON file or a captured artifact under .frontend-harness/evidence/.");
|
|
271
|
-
}
|
|
272
|
-
if (isProjectRelativeSubpath(input.relativePath, ".frontend-harness") && !isProjectRelativeSubpath(input.relativePath, ".frontend-harness/evidence")) {
|
|
273
|
-
throw new Error("evidence input file under .frontend-harness must be stored below .frontend-harness/evidence/.");
|
|
274
|
-
}
|
|
275
|
-
if (!fs.existsSync(input.fullPath) || !fs.statSync(input.fullPath).isFile()) {
|
|
276
|
-
throw new Error(`evidence input file does not exist: ${input.relativePath}`);
|
|
277
|
-
}
|
|
278
|
-
const submitted = readEvidenceManifest(projectRoot, input.relativePath, { fillMissingCreatedAt: new Date().toISOString() });
|
|
279
|
-
if (!submitted.ok) {
|
|
280
|
-
throw new Error(submitted.error);
|
|
281
|
-
}
|
|
282
|
-
if (submitted.entries.length === 0) {
|
|
283
|
-
throw new Error("Evidence input must include at least one entry.");
|
|
284
|
-
}
|
|
285
|
-
const required = requiredVerificationEvidence(projectRoot);
|
|
286
|
-
const unsupported = submitted.entries.filter((entry) => !required.some((candidate) => candidate.unitId === entry.unitId && candidate.kind === entry.kind));
|
|
287
|
-
if (unsupported.length > 0) {
|
|
288
|
-
throw new Error(`Evidence input contains entries that are not required by the current plan: ${unsupported.map((entry) => `${entry.unitId}: ${entry.kind}`).join(", ")}.`);
|
|
289
|
-
}
|
|
290
|
-
const submittedRequired = submitted.entries.map((entry) => ({ unitId: entry.unitId, kind: entry.kind }));
|
|
291
|
-
const errors = validateEvidenceEntries(projectRoot, submittedRequired, submitted.entries);
|
|
292
|
-
if (errors.length > 0) {
|
|
293
|
-
throw new Error(`Evidence input failed validation: ${errors.join(" ")}`);
|
|
294
|
-
}
|
|
295
|
-
const manifestPath = ".frontend-harness/verification/evidence.json";
|
|
296
|
-
const existing = readOptionalEvidenceEntries(projectRoot, manifestPath);
|
|
297
|
-
const next = [...existing];
|
|
298
|
-
for (const entry of submitted.entries) {
|
|
299
|
-
const index = next.findIndex((candidate) => candidate.unitId === entry.unitId && candidate.kind === entry.kind);
|
|
300
|
-
if (index >= 0) {
|
|
301
|
-
next[index] = entry;
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
next.push(entry);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
writeJson(path.join(projectRoot, manifestPath), { entries: next });
|
|
308
|
-
return {
|
|
309
|
-
status: "passed",
|
|
310
|
-
inputPath: input.relativePath,
|
|
311
|
-
manifestPath,
|
|
312
|
-
recordedEntries: submitted.entries,
|
|
313
|
-
entryCount: next.length
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
export function assertDesignSourceReadyForProjectChanges(projectRoot, files) {
|
|
317
|
-
const state = loadState(projectRoot);
|
|
318
|
-
if (!requiresDesignRestorationEvidence(state)) {
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
const existingProjectFiles = files.flatMap((file) => {
|
|
322
|
-
const trimmed = file.trim();
|
|
323
|
-
if (!trimmed || isProjectRelativeSubpath(trimmed, ".frontend-harness")) {
|
|
324
|
-
return [];
|
|
325
|
-
}
|
|
326
|
-
const normalized = normalizeProjectRelativePath(trimmed, "changed file");
|
|
327
|
-
return [normalized];
|
|
328
|
-
}).filter((file) => {
|
|
329
|
-
const fullPath = path.join(projectRoot, file);
|
|
330
|
-
return fs.existsSync(fullPath) && fs.statSync(fullPath).isFile();
|
|
331
|
-
});
|
|
332
|
-
if (existingProjectFiles.length === 0) {
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
const changedUnitIds = new Set(state.units.filter((unit) => existingProjectFiles.includes(unit.file)).map((unit) => unit.id));
|
|
336
|
-
if (changedUnitIds.size === 0) {
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
const prerequisiteKinds = ["design-source", "semantic-map", "visual-baseline"];
|
|
340
|
-
const required = [...changedUnitIds].flatMap((unitId) => prerequisiteKinds.map((kind) => ({ unitId, kind })));
|
|
341
|
-
const manifest = readEvidenceManifest(projectRoot, ".frontend-harness/verification/evidence.json");
|
|
342
|
-
const errors = !manifest.ok
|
|
343
|
-
? [manifest.error]
|
|
344
|
-
: validateEvidenceEntries(projectRoot, required, manifest.entries);
|
|
345
|
-
if (errors.length > 0) {
|
|
346
|
-
throw new Error([
|
|
347
|
-
"Design restoration source files cannot be recorded as changed before design source, semantic mapping, and visual baseline evidence are recorded through evidence record --file.",
|
|
348
|
-
...errors
|
|
349
|
-
].join(" "));
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
function checkVerificationEvidence(projectRoot) {
|
|
353
|
-
const manifestPath = ".frontend-harness/verification/evidence.json";
|
|
354
|
-
const requiredEvidence = requiredVerificationEvidence(projectRoot);
|
|
355
|
-
if (requiredEvidence.length === 0) {
|
|
356
|
-
return {
|
|
357
|
-
status: "passed",
|
|
358
|
-
manifestPath,
|
|
359
|
-
requiredEvidence,
|
|
360
|
-
errors: []
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
const manifest = readEvidenceManifest(projectRoot, manifestPath);
|
|
364
|
-
if (!manifest.ok) {
|
|
365
|
-
return {
|
|
366
|
-
status: "failed",
|
|
367
|
-
manifestPath,
|
|
368
|
-
requiredEvidence,
|
|
369
|
-
errors: [manifest.error]
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
const entries = manifest.entries;
|
|
373
|
-
const errors = validateEvidenceEntries(projectRoot, requiredEvidence, entries);
|
|
374
|
-
return {
|
|
375
|
-
status: errors.length ? "failed" : "passed",
|
|
376
|
-
manifestPath,
|
|
377
|
-
requiredEvidence,
|
|
378
|
-
errors
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
function requiredVerificationEvidence(projectRoot) {
|
|
382
|
-
const state = loadState(projectRoot);
|
|
383
|
-
return state.units.flatMap((unit) => [...new Set(unit.verificationEvidence ?? [])]
|
|
384
|
-
.map(evidenceArtifactKind)
|
|
385
|
-
.filter((kind) => Boolean(kind))
|
|
386
|
-
.map((kind) => ({ unitId: unit.id, kind })));
|
|
387
|
-
}
|
|
388
|
-
function requiresDesignRestorationEvidence(state) {
|
|
389
|
-
return state.units.some((unit) => (unit.verificationEvidence ?? []).some((evidence) => evidence.toLowerCase().includes("design source")
|
|
390
|
-
|| evidence.toLowerCase().includes("visual baseline")
|
|
391
|
-
|| evidence.toLowerCase().includes("visual restoration")));
|
|
392
|
-
}
|
|
393
|
-
function evidenceArtifactKind(evidence) {
|
|
394
|
-
const normalized = evidence.toLowerCase();
|
|
395
|
-
if (normalized.includes("visual baseline") || normalized.includes("design baseline") || normalized.includes("design_to_code")) {
|
|
396
|
-
return "visual-baseline";
|
|
397
|
-
}
|
|
398
|
-
if (normalized.includes("visual")) {
|
|
399
|
-
return "visual-restoration";
|
|
400
|
-
}
|
|
401
|
-
if (normalized.includes("design source") || normalized.includes("design evidence")) {
|
|
402
|
-
return "design-source";
|
|
403
|
-
}
|
|
404
|
-
if (normalized.includes("semantic") && normalized.includes("mapping")) {
|
|
405
|
-
return "semantic-map";
|
|
406
|
-
}
|
|
407
|
-
if (normalized.includes("component behavior")) {
|
|
408
|
-
return "component-behavior";
|
|
409
|
-
}
|
|
410
|
-
if (normalized.includes("interaction")) {
|
|
411
|
-
return "interaction-smoke";
|
|
412
|
-
}
|
|
413
|
-
if (normalized.includes("responsive")) {
|
|
414
|
-
return "responsive-rendering";
|
|
415
|
-
}
|
|
416
|
-
return null;
|
|
417
|
-
}
|
|
418
|
-
function evidenceTemplateEntry(required, createdAt) {
|
|
419
|
-
const artifactPath = `.frontend-harness/evidence/${required.unitId}-${required.kind}.md`;
|
|
420
|
-
return {
|
|
421
|
-
unitId: required.unitId,
|
|
422
|
-
kind: required.kind,
|
|
423
|
-
artifactPaths: [artifactPath],
|
|
424
|
-
createdAt,
|
|
425
|
-
metadata: evidenceMetadataTemplate(required.kind)
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
function evidenceMetadataTemplate(kind) {
|
|
429
|
-
switch (kind) {
|
|
430
|
-
case "design-source":
|
|
431
|
-
return {
|
|
432
|
-
sourceType: "design-artifact | screenshot | design_to_code | generated_markup",
|
|
433
|
-
hasVisualReference: true,
|
|
434
|
-
hasStructureReference: false,
|
|
435
|
-
hasStyleReference: false,
|
|
436
|
-
referenceArtifactPaths: [".frontend-harness/evidence/<design-source-artifact>"]
|
|
437
|
-
};
|
|
438
|
-
case "semantic-map":
|
|
439
|
-
return {
|
|
440
|
-
mappedComponentCount: 1,
|
|
441
|
-
mappingActions: ["mapped <design region> to <project component or file>"]
|
|
442
|
-
};
|
|
443
|
-
case "visual-baseline":
|
|
444
|
-
return {
|
|
445
|
-
sourceType: "<actual baseline source type; use design_to_code only when generated design code exists>",
|
|
446
|
-
baselineArtifactPaths: [".frontend-harness/evidence/<baseline-or-generated-source>"],
|
|
447
|
-
viewports: ["desktop"],
|
|
448
|
-
preservedAt: "before-project-native-rewrite",
|
|
449
|
-
designToCodeOnlyFields: {
|
|
450
|
-
usability: "usable_as_visual_baseline | usable_for_patch_reference | usable_as_source | unusable",
|
|
451
|
-
usabilityReasons: ["required only when sourceType is design_to_code"],
|
|
452
|
-
temporaryAssetsLocalized: false,
|
|
453
|
-
sourceReadinessReasons: ["required only when usability is usable_as_source"]
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
case "visual-restoration":
|
|
457
|
-
return {
|
|
458
|
-
verdict: "pass",
|
|
459
|
-
score: 90,
|
|
460
|
-
threshold: 90,
|
|
461
|
-
comparisonTool: "<tool name>",
|
|
462
|
-
comparisonCommand: "<exact command or inspection procedure>",
|
|
463
|
-
viewports: ["desktop"],
|
|
464
|
-
comparedRegions: ["<region compared>"],
|
|
465
|
-
differences: ["<remaining visual difference or none>"],
|
|
466
|
-
acceptedGaps: [],
|
|
467
|
-
designArtifactPaths: [".frontend-harness/evidence/<design-screenshot>"],
|
|
468
|
-
actualArtifactPaths: [".frontend-harness/evidence/<actual-screenshot>"],
|
|
469
|
-
diffArtifactPaths: [".frontend-harness/evidence/<diff-artifact>"],
|
|
470
|
-
artifactSha256: {
|
|
471
|
-
design: "<sha256 matching designArtifactPaths>",
|
|
472
|
-
actual: "<sha256 matching actualArtifactPaths>",
|
|
473
|
-
diff: "<sha256 matching diffArtifactPaths>"
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
case "responsive-rendering":
|
|
477
|
-
return {
|
|
478
|
-
viewports: ["desktop", "mobile"]
|
|
479
|
-
};
|
|
480
|
-
case "component-behavior":
|
|
481
|
-
return {
|
|
482
|
-
checkedScenarios: ["<scenario checked>"]
|
|
483
|
-
};
|
|
484
|
-
case "interaction-smoke":
|
|
485
|
-
return {
|
|
486
|
-
checkedInteractions: ["<interaction checked>"]
|
|
487
|
-
};
|
|
488
|
-
default:
|
|
489
|
-
return {};
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
function readEvidenceManifest(projectRoot, relativePath, options = {}) {
|
|
493
|
-
const fullPath = path.join(projectRoot, relativePath);
|
|
494
|
-
if (!fs.existsSync(fullPath)) {
|
|
495
|
-
return { ok: false, error: `Missing evidence manifest: ${relativePath}.` };
|
|
496
|
-
}
|
|
497
|
-
let parsed;
|
|
498
|
-
try {
|
|
499
|
-
parsed = readJson(fullPath);
|
|
500
|
-
}
|
|
501
|
-
catch (error) {
|
|
502
|
-
return { ok: false, error: `Evidence manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}.` };
|
|
503
|
-
}
|
|
504
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
505
|
-
return { ok: false, error: "Evidence manifest root must be an object." };
|
|
506
|
-
}
|
|
507
|
-
const entryValues = parsed["entries"];
|
|
508
|
-
if (!Array.isArray(entryValues)) {
|
|
509
|
-
return { ok: false, error: "Evidence manifest must contain an entries array." };
|
|
510
|
-
}
|
|
511
|
-
const entries = [];
|
|
512
|
-
for (const [index, entryValue] of entryValues.entries()) {
|
|
513
|
-
const entry = parseEvidenceEntry(entryValue, index, options);
|
|
514
|
-
if (!entry.ok) {
|
|
515
|
-
return { ok: false, error: entry.error };
|
|
516
|
-
}
|
|
517
|
-
entries.push(entry.entry);
|
|
518
|
-
}
|
|
519
|
-
return { ok: true, entries };
|
|
520
|
-
}
|
|
521
|
-
function readOptionalEvidenceEntries(projectRoot, relativePath) {
|
|
522
|
-
if (!fs.existsSync(path.join(projectRoot, relativePath))) {
|
|
523
|
-
return [];
|
|
524
|
-
}
|
|
525
|
-
const manifest = readEvidenceManifest(projectRoot, relativePath);
|
|
526
|
-
if (!manifest.ok) {
|
|
527
|
-
throw new Error(manifest.error);
|
|
528
|
-
}
|
|
529
|
-
return manifest.entries;
|
|
530
|
-
}
|
|
531
|
-
function parseEvidenceEntry(value, index, options = {}) {
|
|
532
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
533
|
-
return { ok: false, error: `Evidence manifest entries[${index}] must be an object.` };
|
|
534
|
-
}
|
|
535
|
-
const entry = value;
|
|
536
|
-
const unitId = entry["unitId"];
|
|
537
|
-
const kind = entry["kind"];
|
|
538
|
-
const artifactPaths = entry["artifactPaths"];
|
|
539
|
-
const createdAt = entry["createdAt"];
|
|
540
|
-
if (typeof unitId !== "string"
|
|
541
|
-
|| typeof kind !== "string"
|
|
542
|
-
|| !Array.isArray(artifactPaths)
|
|
543
|
-
|| artifactPaths.some((artifactPath) => typeof artifactPath !== "string")
|
|
544
|
-
|| (createdAt !== undefined && typeof createdAt !== "string")
|
|
545
|
-
|| (createdAt === undefined && !options.fillMissingCreatedAt)) {
|
|
546
|
-
return { ok: false, error: `Evidence manifest entries[${index}] must include string unitId, string kind, string[] artifactPaths, and string createdAt.` };
|
|
547
|
-
}
|
|
548
|
-
return {
|
|
549
|
-
ok: true,
|
|
550
|
-
entry: {
|
|
551
|
-
unitId,
|
|
552
|
-
kind,
|
|
553
|
-
artifactPaths: artifactPaths,
|
|
554
|
-
createdAt: typeof createdAt === "string" ? createdAt : options.fillMissingCreatedAt,
|
|
555
|
-
...(isPlainRecord(entry["metadata"]) ? { metadata: entry["metadata"] } : {})
|
|
556
|
-
}
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
function validateEvidenceEntries(projectRoot, requiredEvidence, entries) {
|
|
560
|
-
const errors = [];
|
|
561
|
-
for (const required of requiredEvidence) {
|
|
562
|
-
const entry = entries.find((candidate) => candidate.unitId === required.unitId && candidate.kind === required.kind);
|
|
563
|
-
if (!entry) {
|
|
564
|
-
errors.push(`Missing evidence entry for ${required.unitId}: ${required.kind}.`);
|
|
565
|
-
continue;
|
|
566
|
-
}
|
|
567
|
-
if (!/^\d{4}-\d{2}-\d{2}T/.test(entry.createdAt)) {
|
|
568
|
-
errors.push(`Evidence entry for ${required.unitId}: ${required.kind} must include an ISO createdAt timestamp.`);
|
|
569
|
-
}
|
|
570
|
-
if (entry.artifactPaths.length === 0) {
|
|
571
|
-
errors.push(`Evidence entry for ${required.unitId}: ${required.kind} must include at least one artifact path.`);
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
574
|
-
for (const artifactPath of entry.artifactPaths) {
|
|
575
|
-
validateEvidenceArtifactPath(projectRoot, artifactPath, `${required.unitId}: ${required.kind}`, errors);
|
|
576
|
-
}
|
|
577
|
-
validateEvidenceMetadata(projectRoot, entry, `${required.unitId}: ${required.kind}`, errors);
|
|
578
|
-
}
|
|
579
|
-
return errors;
|
|
580
|
-
}
|
|
581
|
-
function validateEvidenceArtifactPath(projectRoot, artifactPath, label, errors) {
|
|
582
|
-
const normalized = path.posix.normalize(artifactPath.replace(/\\/g, "/"));
|
|
583
|
-
if (!normalized.startsWith(".frontend-harness/") || normalized.includes("..") || path.isAbsolute(artifactPath)) {
|
|
584
|
-
errors.push(`Evidence artifact for ${label} must be a project-relative path under .frontend-harness/: ${artifactPath}.`);
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
const fullPath = path.join(projectRoot, normalized);
|
|
588
|
-
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
|
589
|
-
errors.push(`Evidence artifact for ${label} does not exist: ${artifactPath}.`);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
function validateEvidenceMetadata(projectRoot, entry, label, errors) {
|
|
593
|
-
const metadata = entry.metadata;
|
|
594
|
-
switch (entry.kind) {
|
|
595
|
-
case "design-source":
|
|
596
|
-
requireMetadataObject(metadata, label, errors);
|
|
597
|
-
requireMetadataString(metadata, "sourceType", label, errors);
|
|
598
|
-
requireMetadataTrue(metadata, "hasVisualReference", label, errors);
|
|
599
|
-
requireMetadataBoolean(metadata, "hasStructureReference", label, errors);
|
|
600
|
-
requireMetadataBoolean(metadata, "hasStyleReference", label, errors);
|
|
601
|
-
requireMetadataArtifactPathArray(metadata, "referenceArtifactPaths", projectRoot, label, errors);
|
|
602
|
-
break;
|
|
603
|
-
case "semantic-map":
|
|
604
|
-
requireMetadataObject(metadata, label, errors);
|
|
605
|
-
requirePositiveMetadataNumber(metadata, "mappedComponentCount", label, errors);
|
|
606
|
-
requireMetadataStringArray(metadata, "mappingActions", label, errors);
|
|
607
|
-
break;
|
|
608
|
-
case "visual-restoration":
|
|
609
|
-
requireMetadataObject(metadata, label, errors);
|
|
610
|
-
requireVisualVerdict(metadata, label, errors);
|
|
611
|
-
requirePassingVisualScore(metadata, label, errors);
|
|
612
|
-
requireVisualComparisonProvenance(metadata, projectRoot, label, errors);
|
|
613
|
-
requireMetadataStringArray(metadata, "comparedRegions", label, errors);
|
|
614
|
-
requireMetadataStringArray(metadata, "differences", label, errors);
|
|
615
|
-
requireMetadataStringArray(metadata, "acceptedGaps", label, errors, { allowEmpty: true });
|
|
616
|
-
break;
|
|
617
|
-
case "visual-baseline":
|
|
618
|
-
requireMetadataObject(metadata, label, errors);
|
|
619
|
-
requireMetadataString(metadata, "sourceType", label, errors);
|
|
620
|
-
requireMetadataArtifactPathArray(metadata, "baselineArtifactPaths", projectRoot, label, errors);
|
|
621
|
-
requireMetadataStringArray(metadata, "viewports", label, errors);
|
|
622
|
-
requireMetadataString(metadata, "preservedAt", label, errors);
|
|
623
|
-
requireDesignToCodeUsability(metadata, label, errors);
|
|
624
|
-
break;
|
|
625
|
-
case "responsive-rendering":
|
|
626
|
-
requireMetadataObject(metadata, label, errors);
|
|
627
|
-
requireMetadataStringArray(metadata, "viewports", label, errors);
|
|
628
|
-
break;
|
|
629
|
-
case "component-behavior":
|
|
630
|
-
requireMetadataObject(metadata, label, errors);
|
|
631
|
-
requireMetadataStringArray(metadata, "checkedScenarios", label, errors);
|
|
632
|
-
break;
|
|
633
|
-
case "interaction-smoke":
|
|
634
|
-
requireMetadataObject(metadata, label, errors);
|
|
635
|
-
requireMetadataStringArray(metadata, "checkedInteractions", label, errors);
|
|
636
|
-
break;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
function requireMetadataObject(metadata, label, errors) {
|
|
640
|
-
if (!metadata) {
|
|
641
|
-
errors.push(`Evidence entry for ${label} must include metadata.`);
|
|
642
|
-
return false;
|
|
643
|
-
}
|
|
644
|
-
return true;
|
|
645
|
-
}
|
|
646
|
-
function requireMetadataString(metadata, key, label, errors) {
|
|
647
|
-
if (!metadata) {
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
if (typeof metadata[key] !== "string" || !metadata[key].trim()) {
|
|
651
|
-
errors.push(`Evidence metadata for ${label} must include non-empty ${key}.`);
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
if (metadata[key].includes("<") || metadata[key].includes(">")) {
|
|
655
|
-
errors.push(`Evidence metadata for ${label} must replace placeholder ${key} with real evidence.`);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
function requireDesignToCodeUsability(metadata, label, errors) {
|
|
659
|
-
if (!metadata || metadata["sourceType"] !== "design_to_code") {
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
const usability = metadata["usability"];
|
|
663
|
-
const allowed = ["usable_as_source", "usable_as_visual_baseline", "usable_for_patch_reference", "unusable"];
|
|
664
|
-
if (typeof usability !== "string" || !allowed.includes(usability)) {
|
|
665
|
-
errors.push(`Evidence metadata for ${label} with sourceType design_to_code must include usability of ${allowed.join(", ")}.`);
|
|
666
|
-
}
|
|
667
|
-
requireMetadataStringArray(metadata, "usabilityReasons", label, errors);
|
|
668
|
-
requireMetadataBoolean(metadata, "temporaryAssetsLocalized", label, errors);
|
|
669
|
-
if (usability === "usable_as_source") {
|
|
670
|
-
requireMetadataStringArray(metadata, "sourceReadinessReasons", label, errors);
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
function requireMetadataTrue(metadata, key, label, errors) {
|
|
674
|
-
if (!metadata) {
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
if (metadata[key] !== true) {
|
|
678
|
-
errors.push(`Evidence metadata for ${label} must set ${key} to true.`);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
function requireMetadataBoolean(metadata, key, label, errors) {
|
|
682
|
-
if (!metadata) {
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
if (typeof metadata[key] !== "boolean") {
|
|
686
|
-
errors.push(`Evidence metadata for ${label} must include boolean ${key}.`);
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
function requirePositiveMetadataNumber(metadata, key, label, errors) {
|
|
690
|
-
if (!metadata) {
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
const value = metadata[key];
|
|
694
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
695
|
-
errors.push(`Evidence metadata for ${label} must include positive numeric ${key}.`);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
function requireMetadataScore(metadata, label, errors) {
|
|
699
|
-
if (!metadata) {
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
const score = metadata["score"];
|
|
703
|
-
if (typeof score !== "number" || !Number.isFinite(score) || score < 0 || score > 100) {
|
|
704
|
-
errors.push(`Evidence metadata for ${label} must include numeric score between 0 and 100.`);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
function requirePassingVisualScore(metadata, label, errors) {
|
|
708
|
-
requireMetadataScore(metadata, label, errors);
|
|
709
|
-
if (!metadata) {
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
const score = metadata["score"];
|
|
713
|
-
if (typeof score === "number" && Number.isFinite(score) && score >= 0 && score <= 100 && score < 90) {
|
|
714
|
-
errors.push(`Evidence metadata for ${label} must include visual restoration score of at least 90.`);
|
|
715
|
-
}
|
|
716
|
-
const threshold = metadata["threshold"];
|
|
717
|
-
if (threshold !== undefined) {
|
|
718
|
-
if (typeof threshold !== "number" || !Number.isFinite(threshold) || threshold < 90 || threshold > 100) {
|
|
719
|
-
errors.push(`Evidence metadata for ${label} must include numeric threshold between 90 and 100 when threshold is present.`);
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
if (typeof score === "number" && Number.isFinite(score) && score >= 0 && score <= 100 && score < threshold) {
|
|
723
|
-
errors.push(`Evidence metadata for ${label} must include visual restoration score greater than or equal to threshold.`);
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
function requireVisualVerdict(metadata, label, errors) {
|
|
728
|
-
if (!metadata) {
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
const verdict = metadata["verdict"];
|
|
732
|
-
if (verdict !== "pass") {
|
|
733
|
-
errors.push(`Evidence metadata for ${label} must include verdict of pass.`);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
function requireVisualComparisonProvenance(metadata, projectRoot, label, errors) {
|
|
737
|
-
if (!metadata) {
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
requireMetadataString(metadata, "comparisonTool", label, errors);
|
|
741
|
-
requireMetadataString(metadata, "comparisonCommand", label, errors);
|
|
742
|
-
requireMetadataStringArray(metadata, "viewports", label, errors);
|
|
743
|
-
requireMetadataArtifactPathArray(metadata, "designArtifactPaths", projectRoot, label, errors);
|
|
744
|
-
requireMetadataArtifactPathArray(metadata, "actualArtifactPaths", projectRoot, label, errors);
|
|
745
|
-
requireMetadataArtifactPathArray(metadata, "diffArtifactPaths", projectRoot, label, errors);
|
|
746
|
-
requireArtifactSha256(metadata, projectRoot, label, errors);
|
|
747
|
-
}
|
|
748
|
-
function requireMetadataArtifactPathArray(metadata, key, projectRoot, label, errors) {
|
|
749
|
-
requireMetadataStringArray(metadata, key, label, errors);
|
|
750
|
-
if (!metadata) {
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
const value = metadata[key];
|
|
754
|
-
if (!Array.isArray(value)) {
|
|
755
|
-
return;
|
|
756
|
-
}
|
|
757
|
-
for (const artifactPath of value) {
|
|
758
|
-
if (typeof artifactPath === "string") {
|
|
759
|
-
validateEvidenceArtifactPath(projectRoot, artifactPath, `${label} metadata ${key}`, errors);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
function requireArtifactSha256(metadata, projectRoot, label, errors) {
|
|
764
|
-
if (!metadata) {
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
const artifactSha256 = metadata["artifactSha256"];
|
|
768
|
-
if (!isPlainRecord(artifactSha256)) {
|
|
769
|
-
errors.push(`Evidence metadata for ${label} must include artifactSha256 object.`);
|
|
770
|
-
return;
|
|
771
|
-
}
|
|
772
|
-
const categories = [
|
|
773
|
-
["design", "designArtifactPaths"],
|
|
774
|
-
["actual", "actualArtifactPaths"],
|
|
775
|
-
["diff", "diffArtifactPaths"]
|
|
776
|
-
];
|
|
777
|
-
for (const [key, pathKey] of categories) {
|
|
778
|
-
const value = artifactSha256[key];
|
|
779
|
-
const artifactPaths = metadata[pathKey];
|
|
780
|
-
if (!Array.isArray(artifactPaths) || artifactPaths.some((artifactPath) => typeof artifactPath !== "string")) {
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
if (typeof value === "string") {
|
|
784
|
-
if (!isSha256(value)) {
|
|
785
|
-
errors.push(`Evidence metadata for ${label} must include sha256 hash for artifactSha256.${key}.`);
|
|
786
|
-
continue;
|
|
787
|
-
}
|
|
788
|
-
if (artifactPaths.length !== 1) {
|
|
789
|
-
errors.push(`Evidence metadata for ${label} must use per-path hashes for artifactSha256.${key} when multiple ${pathKey} are present.`);
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
requireArtifactHashMatch(projectRoot, artifactPaths[0], value, `${label} artifactSha256.${key}`, errors);
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
if (Array.isArray(value)) {
|
|
796
|
-
if (value.length !== artifactPaths.length || value.some((item) => typeof item !== "string" || !isSha256(item))) {
|
|
797
|
-
errors.push(`Evidence metadata for ${label} must include one sha256 hash per ${pathKey} entry for artifactSha256.${key}.`);
|
|
798
|
-
continue;
|
|
799
|
-
}
|
|
800
|
-
value.forEach((hash, index) => requireArtifactHashMatch(projectRoot, artifactPaths[index], hash, `${label} artifactSha256.${key}[${index}]`, errors));
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
if (isPlainRecord(value)) {
|
|
804
|
-
for (const artifactPath of artifactPaths) {
|
|
805
|
-
const hash = value[artifactPath];
|
|
806
|
-
if (typeof hash !== "string" || !isSha256(hash)) {
|
|
807
|
-
errors.push(`Evidence metadata for ${label} must include sha256 hash for artifactSha256.${key}.${artifactPath}.`);
|
|
808
|
-
continue;
|
|
809
|
-
}
|
|
810
|
-
requireArtifactHashMatch(projectRoot, artifactPath, hash, `${label} artifactSha256.${key}.${artifactPath}`, errors);
|
|
811
|
-
}
|
|
812
|
-
continue;
|
|
813
|
-
}
|
|
814
|
-
errors.push(`Evidence metadata for ${label} must include sha256 hash for artifactSha256.${key}.`);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
function requireArtifactHashMatch(projectRoot, artifactPath, expectedHash, label, errors) {
|
|
818
|
-
const fullPath = evidenceArtifactFullPath(projectRoot, artifactPath);
|
|
819
|
-
if (!fullPath) {
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
const actualHash = createHash("sha256").update(fs.readFileSync(fullPath)).digest("hex");
|
|
823
|
-
if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
824
|
-
errors.push(`Evidence metadata for ${label} must match actual artifact sha256 for ${artifactPath}.`);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
function evidenceArtifactFullPath(projectRoot, artifactPath) {
|
|
828
|
-
const normalized = path.posix.normalize(artifactPath.replace(/\\/g, "/"));
|
|
829
|
-
if (!normalized.startsWith(".frontend-harness/") || normalized.includes("..") || path.isAbsolute(artifactPath)) {
|
|
830
|
-
return null;
|
|
831
|
-
}
|
|
832
|
-
const fullPath = path.join(projectRoot, normalized);
|
|
833
|
-
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
|
834
|
-
return null;
|
|
835
|
-
}
|
|
836
|
-
return fullPath;
|
|
837
|
-
}
|
|
838
|
-
function isSha256(value) {
|
|
839
|
-
return /^[a-f0-9]{64}$/i.test(value);
|
|
840
|
-
}
|
|
841
|
-
function requireMetadataStringArray(metadata, key, label, errors, options = {}) {
|
|
842
|
-
if (!metadata) {
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
const value = metadata[key];
|
|
846
|
-
if (!Array.isArray(value) || value.some((item) => typeof item !== "string") || (!options.allowEmpty && value.length === 0)) {
|
|
847
|
-
errors.push(`Evidence metadata for ${label} must include ${options.allowEmpty ? "array" : "non-empty string array"} ${key}.`);
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
function isPlainRecord(value) {
|
|
851
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
852
|
-
}
|
|
853
244
|
function resolveCommands(commands) {
|
|
854
245
|
return commands
|
|
855
246
|
.flatMap((entry) => entry.command ? [{ name: entry.name, source: entry.source, command: entry.command }] : []);
|