@tea-agent/loop-agent 0.26.1 → 0.26.2
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 +24 -0
- package/dist/application/dag/generate-task-dag.js +33 -0
- package/dist/commands/task-source-prepare.js +6 -0
- package/dist/executors/shell-executor.js +111 -0
- package/dist/executors/shell-presets.js +12 -4
- package/dist/task/config-types.js +6 -0
- package/dist/task/contract/constants.js +1 -0
- package/dist/task/contract/project.js +8 -0
- package/dist/task/contract/schema.js +1 -0
- package/dist/task/frontend-preflight.js +131 -0
- package/dist/task/runtime.js +2 -4
- package/dist/task/source-prepare/build-draft.js +9 -0
- package/dist/task/source-prepare/completeness.js +1 -1
- package/dist/worker/observability/read-model.js +134 -0
- package/dist/worker/observe/static/state.js +61 -0
- package/dist/worker/observe/static/styles.css +8 -0
- package/dist/worker/observe/static/views/dag-graph.js +107 -31
- package/dist/worker/observe/static/views/dag-inspector.js +374 -157
- package/dist/worker/observe/static/views/dag.js +4 -11
- package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
- package/dist/workflows/dag/convergence/controller.js +110 -21
- package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
- package/dist/workflows/dag/frontend-review-context.js +7 -1
- package/dist/workflows/dag/frontend-verification-trace.js +14 -3
- package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
- package/dist/workflows/dag/init-hybrid.js +96 -34
- package/dist/workflows/dag/output-protocol.js +180 -7
- package/dist/workflows/dag/runner.js +141 -52
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/templates/backend-test-dag.json +100 -8
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
|
@@ -84,6 +84,7 @@ export function loadFrontendImplementationContractJsonSchema(startDir = path.dir
|
|
|
84
84
|
return JSON.stringify(parsed);
|
|
85
85
|
}
|
|
86
86
|
const id = z.string().regex(/^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/);
|
|
87
|
+
const REQUIREMENT_ID_PATTERN = /^(?:REQ|BR|AC)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
|
|
87
88
|
const safePath = z
|
|
88
89
|
.string()
|
|
89
90
|
.min(1)
|
|
@@ -366,7 +367,7 @@ export function canonicalFrontendContractSourceBinding(binding) {
|
|
|
366
367
|
.filter((source) => source.kind === "reference")
|
|
367
368
|
.map((source) => source.path)
|
|
368
369
|
.sort(),
|
|
369
|
-
requirementIds:
|
|
370
|
+
requirementIds: binding.requirementIds.map(canonicalizeRequirementId),
|
|
370
371
|
};
|
|
371
372
|
}
|
|
372
373
|
function asRecord(value) {
|
|
@@ -384,6 +385,137 @@ function asStringArray(value) {
|
|
|
384
385
|
.map((item) => asString(item))
|
|
385
386
|
.filter((item) => item.length > 0);
|
|
386
387
|
}
|
|
388
|
+
function isRequirementId(value) {
|
|
389
|
+
return REQUIREMENT_ID_PATTERN.test(value);
|
|
390
|
+
}
|
|
391
|
+
/** Normalize the compact IDs models commonly emit (for example AC1) before
|
|
392
|
+
* strict schema validation. This keeps governance strict while making the
|
|
393
|
+
* boundary tolerant of presentation-only formatting differences. */
|
|
394
|
+
function canonicalizeRequirementId(value) {
|
|
395
|
+
const trimmed = value.trim().toUpperCase();
|
|
396
|
+
if (REQUIREMENT_ID_PATTERN.test(trimmed))
|
|
397
|
+
return trimmed;
|
|
398
|
+
const compact = /^(REQ|BR|AC)(\d+)$/.exec(trimmed);
|
|
399
|
+
if (compact)
|
|
400
|
+
return `${compact[1]}-${compact[2]}`;
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
function canonicalizeRequirementIdsInPayload(value) {
|
|
404
|
+
if (Array.isArray(value))
|
|
405
|
+
return value.map(canonicalizeRequirementIdsInPayload);
|
|
406
|
+
if (!value || typeof value !== "object")
|
|
407
|
+
return value;
|
|
408
|
+
const record = value;
|
|
409
|
+
const out = {};
|
|
410
|
+
for (const [key, child] of Object.entries(record)) {
|
|
411
|
+
if (key === "id" || key === "requirementId") {
|
|
412
|
+
out[key] = typeof child === "string" ? canonicalizeRequirementId(child) : child;
|
|
413
|
+
}
|
|
414
|
+
else if (key === "requirementIds" && Array.isArray(child)) {
|
|
415
|
+
out[key] = child.map((item) => typeof item === "string" ? canonicalizeRequirementId(item) : item);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
out[key] = canonicalizeRequirementIdsInPayload(child);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
function canonicalizeVerificationTargetAliases(value) {
|
|
424
|
+
const record = asRecord(value);
|
|
425
|
+
if (!record || !Array.isArray(record.verificationTargets))
|
|
426
|
+
return value;
|
|
427
|
+
const targets = record.verificationTargets
|
|
428
|
+
.map((item) => asRecord(item))
|
|
429
|
+
.filter((item) => Boolean(item));
|
|
430
|
+
const aliases = new Map();
|
|
431
|
+
for (const target of targets) {
|
|
432
|
+
const id = asString(target.id);
|
|
433
|
+
const label = asString(target.commandLabel).toLowerCase();
|
|
434
|
+
if (!id)
|
|
435
|
+
continue;
|
|
436
|
+
aliases.set(id.toLowerCase(), id);
|
|
437
|
+
if (label.includes("typecheck"))
|
|
438
|
+
aliases.set("vt-typecheck", id);
|
|
439
|
+
if (label.includes("unified surface"))
|
|
440
|
+
aliases.set("vt-unified-surface", id);
|
|
441
|
+
}
|
|
442
|
+
const requirements = Array.isArray(record.requirements)
|
|
443
|
+
? record.requirements.map((item) => {
|
|
444
|
+
const requirement = asRecord(item);
|
|
445
|
+
if (!requirement || !Array.isArray(requirement.verificationTargetIds))
|
|
446
|
+
return item;
|
|
447
|
+
return {
|
|
448
|
+
...requirement,
|
|
449
|
+
verificationTargetIds: requirement.verificationTargetIds.map((id) => typeof id === "string" ? aliases.get(id.toLowerCase()) ?? id : id),
|
|
450
|
+
};
|
|
451
|
+
})
|
|
452
|
+
: record.requirements;
|
|
453
|
+
return { ...record, requirements };
|
|
454
|
+
}
|
|
455
|
+
function assertFrontendContractPathsSafe(value) {
|
|
456
|
+
const record = asRecord(value);
|
|
457
|
+
if (!record)
|
|
458
|
+
return;
|
|
459
|
+
const pathFields = ["files", "file", "implementationTargets", "fixture", "consumer"];
|
|
460
|
+
for (const [key, child] of Object.entries(record)) {
|
|
461
|
+
if (pathFields.includes(key) && Array.isArray(child)) {
|
|
462
|
+
for (const item of child) {
|
|
463
|
+
if (typeof item === "string" && (item.startsWith("/") || item.includes("\\") || item.split("/").includes("..")))
|
|
464
|
+
throw new Error(`invalid-output: unsafe frontend contract path ${item}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else if (pathFields.includes(key) && typeof child === "string" && (child.startsWith("/") || child.includes("\\") || child.split("/").includes(".."))) {
|
|
468
|
+
throw new Error(`invalid-output: unsafe frontend contract path ${child}`);
|
|
469
|
+
}
|
|
470
|
+
if (Array.isArray(child))
|
|
471
|
+
child.forEach(assertFrontendContractPathsSafe);
|
|
472
|
+
else if (child && typeof child === "object")
|
|
473
|
+
assertFrontendContractPathsSafe(child);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function deriveContractRequirementIds(value) {
|
|
477
|
+
const record = asRecord(value);
|
|
478
|
+
if (!record)
|
|
479
|
+
return [];
|
|
480
|
+
const ids = [];
|
|
481
|
+
const push = (candidate) => {
|
|
482
|
+
const value = asString(candidate);
|
|
483
|
+
const canonical = canonicalizeRequirementId(value);
|
|
484
|
+
if (!canonical || !isRequirementId(canonical) || ids.includes(canonical))
|
|
485
|
+
return;
|
|
486
|
+
ids.push(canonical);
|
|
487
|
+
};
|
|
488
|
+
if (Array.isArray(record.requirements)) {
|
|
489
|
+
for (const item of record.requirements)
|
|
490
|
+
push(asRecord(item)?.id);
|
|
491
|
+
}
|
|
492
|
+
if (Array.isArray(record.requirementCoverage)) {
|
|
493
|
+
for (const item of record.requirementCoverage)
|
|
494
|
+
push(asRecord(item)?.id);
|
|
495
|
+
}
|
|
496
|
+
const rawVerification = Array.isArray(record.verificationTargets)
|
|
497
|
+
? record.verificationTargets
|
|
498
|
+
: asRecord(record.verificationTargets)
|
|
499
|
+
? Object.values(asRecord(record.verificationTargets))
|
|
500
|
+
: [];
|
|
501
|
+
for (const item of rawVerification) {
|
|
502
|
+
for (const requirementId of asStringArray(asRecord(item)?.requirementIds)) {
|
|
503
|
+
push(requirementId);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
for (const item of Array.isArray(record.evidenceGaps) ? record.evidenceGaps : []) {
|
|
507
|
+
push(asRecord(item)?.requirementId);
|
|
508
|
+
}
|
|
509
|
+
return ids;
|
|
510
|
+
}
|
|
511
|
+
function withDerivedRequirementIdsWhenUnscoped(binding, parsed) {
|
|
512
|
+
if (binding.requirementIds.length > 0)
|
|
513
|
+
return binding;
|
|
514
|
+
const derivedIds = deriveContractRequirementIds(parsed);
|
|
515
|
+
return derivedIds.length > 0
|
|
516
|
+
? { ...binding, requirementIds: derivedIds }
|
|
517
|
+
: binding;
|
|
518
|
+
}
|
|
387
519
|
function looksLikeStrictFrontendContract(value) {
|
|
388
520
|
const record = asRecord(value);
|
|
389
521
|
if (!record)
|
|
@@ -401,6 +533,11 @@ function looksLikeStrictFrontendContract(value) {
|
|
|
401
533
|
*/
|
|
402
534
|
export function coerceFrontendImplementationContractInput(value, canonicalBinding) {
|
|
403
535
|
const rawRecord = asRecord(value);
|
|
536
|
+
const rawMockApi = asRecord(rawRecord?.mockApi);
|
|
537
|
+
if (rawMockApi &&
|
|
538
|
+
typeof rawMockApi.strategy === "string" &&
|
|
539
|
+
!["native", "browser-intercept", "request-adapter", "not-needed"].includes(rawMockApi.strategy))
|
|
540
|
+
return value;
|
|
404
541
|
const verificationTargetIds = rawRecord && Array.isArray(rawRecord.verificationTargets)
|
|
405
542
|
? new Set(rawRecord.verificationTargets
|
|
406
543
|
.map((item) => asString(asRecord(item)?.id))
|
|
@@ -414,9 +551,10 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
414
551
|
const requirement = asRecord(item);
|
|
415
552
|
if (!requirement || !Array.isArray(requirement.verificationTargetIds))
|
|
416
553
|
return item;
|
|
554
|
+
const filtered = requirement.verificationTargetIds.filter((id) => typeof id === "string" && verificationTargetIds.has(id));
|
|
417
555
|
return {
|
|
418
556
|
...requirement,
|
|
419
|
-
verificationTargetIds:
|
|
557
|
+
verificationTargetIds: filtered.length > 0 ? filtered : requirement.verificationTargetIds,
|
|
420
558
|
};
|
|
421
559
|
})
|
|
422
560
|
: rawRecord.requirements,
|
|
@@ -444,8 +582,39 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
444
582
|
: rawRecord.uiStates,
|
|
445
583
|
}
|
|
446
584
|
: value;
|
|
447
|
-
|
|
448
|
-
|
|
585
|
+
// A payload can have the strict top-level shape while still containing
|
|
586
|
+
// empty requirement coverage arrays. Do not trust shape alone: route such
|
|
587
|
+
// payloads through the compatibility normalizer so targets and verification
|
|
588
|
+
// references are deterministically filled from the contract context.
|
|
589
|
+
if (looksLikeStrictFrontendContract(normalizedValue)) {
|
|
590
|
+
const strictRecord = asRecord(normalizedValue);
|
|
591
|
+
const strictMockApi = asRecord(strictRecord?.mockApi);
|
|
592
|
+
if (strictMockApi &&
|
|
593
|
+
typeof strictMockApi.strategy === "string" &&
|
|
594
|
+
!["native", "browser-intercept", "request-adapter", "not-needed"].includes(strictMockApi.strategy))
|
|
595
|
+
return normalizedValue;
|
|
596
|
+
const targetFiles = asStringArray(asRecord(strictRecord?.targets)?.files);
|
|
597
|
+
const verificationIds = Array.isArray(strictRecord?.verificationTargets)
|
|
598
|
+
? strictRecord.verificationTargets.map((item) => asString(asRecord(item)?.id)).filter(Boolean)
|
|
599
|
+
: [];
|
|
600
|
+
if (Array.isArray(strictRecord?.requirements)) {
|
|
601
|
+
const requirements = strictRecord.requirements.map((item) => {
|
|
602
|
+
const requirement = asRecord(item);
|
|
603
|
+
if (!requirement)
|
|
604
|
+
return item;
|
|
605
|
+
return {
|
|
606
|
+
...requirement,
|
|
607
|
+
implementationTargets: asStringArray(requirement.implementationTargets).length > 0
|
|
608
|
+
? requirement.implementationTargets
|
|
609
|
+
: targetFiles,
|
|
610
|
+
verificationTargetIds: asStringArray(requirement.verificationTargetIds).length > 0
|
|
611
|
+
? requirement.verificationTargetIds
|
|
612
|
+
: verificationIds,
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
return { ...strictRecord, requirements };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
449
618
|
const record = asRecord(normalizedValue);
|
|
450
619
|
if (!record)
|
|
451
620
|
return value;
|
|
@@ -729,8 +898,15 @@ export async function materializeFrontendImplementationContract(input) {
|
|
|
729
898
|
throw new Error("frontend implementation contract gate requires DAG sourceBinding");
|
|
730
899
|
const record = JSON.parse(await readFile(path.join(input.runDir, `${input.fromNodeId}.json`), "utf8"));
|
|
731
900
|
let parsed;
|
|
901
|
+
const rawContractText = record.assistantText?.trim() || record.stdout?.trim() || "";
|
|
902
|
+
if (rawContractText.includes('"files":["/'))
|
|
903
|
+
throw new Error("invalid-output: absolute frontend contract path is forbidden");
|
|
904
|
+
if (/(?:"(?:files|file|implementationTargets|fixture|consumer)"\s*:\s*(?:\[\s*)?)"\//.test(rawContractText))
|
|
905
|
+
throw new Error("invalid-output: absolute frontend contract path is forbidden");
|
|
906
|
+
if (/(?:"strategy"\s*:\s*")(?!native\b|browser-intercept\b|request-adapter\b|not-needed\b)[^"]+"/.test(rawContractText))
|
|
907
|
+
throw new Error("invalid-output: unsupported mock strategy");
|
|
732
908
|
try {
|
|
733
|
-
parsed = extractFrontendImplementationJson(
|
|
909
|
+
parsed = extractFrontendImplementationJson(rawContractText);
|
|
734
910
|
}
|
|
735
911
|
catch (error) {
|
|
736
912
|
throw new Error(`invalid-output: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -740,21 +916,46 @@ export async function materializeFrontendImplementationContract(input) {
|
|
|
740
916
|
throw new Error(`invalid-output: ${secrets.join("; ")}`);
|
|
741
917
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
742
918
|
throw new Error("invalid-output: frontend contract must be a JSON object");
|
|
743
|
-
const
|
|
919
|
+
const parsedTargetObject = parsed.targets;
|
|
920
|
+
const parsedFiles = parsedTargetObject && typeof parsedTargetObject === "object"
|
|
921
|
+
? parsedTargetObject.files
|
|
922
|
+
: undefined;
|
|
923
|
+
if (Array.isArray(parsedFiles) && parsedFiles.some((file) => typeof file === "string" && file.startsWith("/")))
|
|
924
|
+
throw new Error("invalid-output: absolute frontend contract path is forbidden");
|
|
925
|
+
// Normalize model-emitted compact requirement IDs before deriving the
|
|
926
|
+
// canonical binding or invoking the strict zod schema.
|
|
927
|
+
parsed = canonicalizeRequirementIdsInPayload(parsed);
|
|
928
|
+
parsed = canonicalizeVerificationTargetAliases(parsed);
|
|
929
|
+
assertFrontendContractPathsSafe(parsed);
|
|
930
|
+
const parsedTargets = asRecord(parsed)?.targets;
|
|
931
|
+
const parsedTargetFiles = asStringArray(asRecord(parsedTargets)?.files);
|
|
932
|
+
if (parsedTargetFiles.some((file) => file.startsWith("/") || file.includes("\\")))
|
|
933
|
+
throw new Error("invalid-output: frontend contract target paths must be relative POSIX paths");
|
|
934
|
+
const parsedStates = asRecord(parsed)?.uiStates;
|
|
935
|
+
if (Array.isArray(parsedStates) && parsedStates.some((item) => {
|
|
936
|
+
const state = asRecord(item);
|
|
937
|
+
return state?.applicable === true &&
|
|
938
|
+
(!asString(state.expectedBehavior) || asStringArray(state.implementationTargets).length === 0 || asStringArray(state.verificationTargetIds).length === 0);
|
|
939
|
+
}))
|
|
940
|
+
throw new Error("invalid-output: applicable UI state requires behavior, implementation, and verification");
|
|
941
|
+
const parsedMockApi = asRecord(parsed)?.mockApi;
|
|
942
|
+
if (asRecord(parsedMockApi) &&
|
|
943
|
+
typeof asRecord(parsedMockApi)?.strategy === "string" &&
|
|
944
|
+
!["native", "browser-intercept", "request-adapter", "not-needed"].includes(String(asRecord(parsedMockApi)?.strategy)))
|
|
945
|
+
throw new Error(`invalid-output: unsupported mock strategy ${String(asRecord(parsedMockApi)?.strategy)}`);
|
|
946
|
+
const baseCanonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
|
|
947
|
+
const canonicalBinding = withDerivedRequirementIdsWhenUnscoped(baseCanonicalBinding, parsed);
|
|
744
948
|
// Always inject DAG-owned identity. Model-provided sourceBinding is advisory
|
|
745
949
|
// only and must not fail a otherwise-valid contract (common live failure:
|
|
746
950
|
// wrong requirementPath/sha, extra referencePaths, or omitted binding).
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
if (!result.success) {
|
|
756
|
-
result = frontendImplementationContractSchema.safeParse(candidates[1]);
|
|
757
|
-
}
|
|
951
|
+
const normalizedContract = coerceFrontendImplementationContractInput(parsed, canonicalBinding);
|
|
952
|
+
// There is exactly one post-security candidate. A fallback candidate would
|
|
953
|
+
// allow malformed raw fields to bypass the boundary checks above.
|
|
954
|
+
const candidate = {
|
|
955
|
+
...(asRecord(normalizedContract) ?? parsed),
|
|
956
|
+
sourceBinding: canonicalBinding,
|
|
957
|
+
};
|
|
958
|
+
const result = frontendImplementationContractSchema.safeParse(candidate);
|
|
758
959
|
if (!result.success)
|
|
759
960
|
throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
|
|
760
961
|
const blockingGaps = [
|
|
@@ -76,8 +76,14 @@ function assertReviewEvidence(input) {
|
|
|
76
76
|
throw new Error("frontend review context invalid repair assessment");
|
|
77
77
|
}
|
|
78
78
|
export async function runFrontendReviewContextGate(input) {
|
|
79
|
-
const diff = await runFrontendWorktreeDiffGate(input);
|
|
80
79
|
const contract = await readRequiredJson(input.runDir, "contracts/frontend-implementation-contract.json");
|
|
80
|
+
const parsedContract = frontendImplementationContractSchema.safeParse(contract);
|
|
81
|
+
if (!parsedContract.success)
|
|
82
|
+
throw new Error("frontend review context invalid implementation contract");
|
|
83
|
+
const diff = await runFrontendWorktreeDiffGate({
|
|
84
|
+
...input,
|
|
85
|
+
authorizedChangedPaths: parsedContract.data.targets.files,
|
|
86
|
+
});
|
|
81
87
|
const verificationTrace = await readRequiredJson(input.runDir, "contracts/frontend-verification-trace.json");
|
|
82
88
|
const repairAssessment = await readRequiredJson(input.runDir, "contracts/frontend-repair-assessment.json");
|
|
83
89
|
const lintAssessment = await readOptionalLintAssessment(input.runDir);
|
|
@@ -30,6 +30,8 @@ function symbolEvidenceCandidates(symbol) {
|
|
|
30
30
|
const normalized = symbol.trim().toLowerCase();
|
|
31
31
|
if (normalized === "all describe blocks")
|
|
32
32
|
return ["describe("];
|
|
33
|
+
// A legacy contract may encode several symbols plus prose in one label.
|
|
34
|
+
// Validate the actual slash-delimited symbols individually.
|
|
33
35
|
const describeTitle = symbol.match(/^describe\((?:['"])(.+?)(?:['"])/i)?.[1];
|
|
34
36
|
if (describeTitle)
|
|
35
37
|
return [symbol, describeTitle, "describe("];
|
|
@@ -58,9 +60,18 @@ async function assertFileAndSymbol(input) {
|
|
|
58
60
|
}
|
|
59
61
|
if (input.symbol) {
|
|
60
62
|
const content = await readFile(absolute, "utf8");
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const symbols = input.symbol.includes(" / ")
|
|
64
|
+
? input.symbol
|
|
65
|
+
.split(/[((]/, 1)[0]
|
|
66
|
+
.split("/")
|
|
67
|
+
.map((symbol) => symbol.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
: [input.symbol];
|
|
70
|
+
for (const symbol of symbols) {
|
|
71
|
+
const matched = symbolEvidenceCandidates(symbol).some((candidate) => content.includes(candidate));
|
|
72
|
+
if (!matched) {
|
|
73
|
+
issues.push(`symbol not found: ${symbol} in ${input.file}`);
|
|
74
|
+
}
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
return issues;
|
|
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
6
|
+
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
6
7
|
export const FRONTEND_WORKTREE_DIFF_SCHEMA_ID = "frontend-worktree-diff-v1";
|
|
7
8
|
export const FRONTEND_WORKTREE_BASELINE_SCHEMA_ID = "frontend-worktree-baseline-v1";
|
|
8
9
|
function runGit(cwd, args) {
|
|
@@ -128,17 +129,27 @@ export async function runFrontendWorktreeDiffGate(input) {
|
|
|
128
129
|
}
|
|
129
130
|
const changedFilesAll = splitLines(nameOnly.stdout).sort();
|
|
130
131
|
const untrackedFilesAll = splitLines(untracked.stdout).sort();
|
|
132
|
+
const authorizedChangedPaths = input.authorizedChangedPaths ?? [];
|
|
131
133
|
if (baseline) {
|
|
132
134
|
for (const file of baseline.files) {
|
|
133
135
|
const currentHash = await sha256File(path.join(root, file));
|
|
134
|
-
|
|
136
|
+
const authorizedOverlap = authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
|
|
137
|
+
if (currentHash !== baseline.hashes[file] && !authorizedOverlap) {
|
|
135
138
|
throw new Error(`frontend worktree diff overlaps pre-existing change: ${file}`);
|
|
136
139
|
}
|
|
137
140
|
}
|
|
138
141
|
}
|
|
139
142
|
const baselineFiles = new Set(baseline?.files ?? []);
|
|
140
|
-
const changedFiles = changedFilesAll.filter((file) =>
|
|
141
|
-
|
|
143
|
+
const changedFiles = changedFilesAll.filter((file) => {
|
|
144
|
+
if (!baselineFiles.has(file))
|
|
145
|
+
return true;
|
|
146
|
+
return authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
|
|
147
|
+
});
|
|
148
|
+
const untrackedFiles = untrackedFilesAll.filter((file) => {
|
|
149
|
+
if (!baselineFiles.has(file))
|
|
150
|
+
return true;
|
|
151
|
+
return authorizedChangedPaths.some((pattern) => pathMatchesPattern(file, pattern));
|
|
152
|
+
});
|
|
142
153
|
const patchSource = baseline
|
|
143
154
|
? changedFiles.length > 0
|
|
144
155
|
? await runGit(root, ["diff", "--binary", "HEAD", "--", ...changedFiles])
|