@tea-agent/loop-agent 0.24.11-beta.0 → 0.25.1
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 +53 -4
- package/README.md +4 -4
- package/dist/application/dag/generate-task-dag.js +25 -8
- package/dist/commands/dag-init-hybrid.js +3 -2
- package/dist/commands/delegate.js +11 -11
- package/dist/commands/init.js +29 -14
- package/dist/commands/instructions.js +1 -2
- package/dist/executors/dag-pi-executor.js +74 -13
- package/dist/executors/pi-executor.js +25 -7
- package/dist/executors/pi-prompt-transport.js +198 -0
- package/dist/executors/pi-sdk-executor.js +1 -1
- package/dist/executors/process-tree.js +33 -0
- package/dist/executors/shell-executor.js +362 -24
- package/dist/executors/shell-verification.js +4 -2
- package/dist/infrastructure/harness/task-store.js +31 -0
- package/dist/shared/operator/capabilities.js +6 -0
- package/dist/task/read-model.js +3 -7
- package/dist/task/runtime.js +12 -3
- package/dist/verification/maven/cache.js +142 -0
- package/dist/verification/maven/index.js +120 -0
- package/dist/verification/maven/plan-commands.js +421 -0
- package/dist/verification/maven/pom-static.js +136 -0
- package/dist/verification/maven/scope-resolve.js +153 -0
- package/dist/verification/maven/stale.js +130 -0
- package/dist/verification/maven/types.js +23 -0
- package/dist/verification/maven/workspace-graph.js +322 -0
- package/dist/worker/cli.js +236 -21
- package/dist/worker/delivery/final-verification.js +12 -0
- package/dist/worker/delivery/git-transaction.js +32 -7
- package/dist/worker/delivery/package.js +9 -5
- package/dist/worker/delivery/verification-bundle.js +26 -5
- package/dist/worker/feature/advance.js +301 -0
- package/dist/worker/feature/doctor.js +223 -0
- package/dist/worker/feature/next-action.js +11 -3
- package/dist/worker/feature/scaffold.js +798 -0
- package/dist/worker/observe/dag-run-artifacts.js +90 -0
- package/dist/worker/observe/node-input.js +444 -0
- package/dist/worker/observe/routes.js +17 -0
- package/dist/worker/observe/static/api.js +9 -0
- package/dist/worker/observe/static/constants.js +9 -0
- package/dist/worker/observe/static/state.js +14 -0
- package/dist/worker/observe/static/styles.css +74 -0
- package/dist/worker/observe/static/views/dag-inspector.js +371 -15
- package/dist/worker/outcomes/projector.js +5 -1
- package/dist/worker/run-task/run-task.js +18 -2
- package/dist/worker/runner/run-ready.js +41 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +300 -9
- package/dist/workflows/dag/backend-test-result-contract.js +103 -0
- package/dist/workflows/dag/failure-category.js +5 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
- package/dist/workflows/dag/frontend-test-result-contract.js +106 -41
- package/dist/workflows/dag/init-hybrid.js +224 -39
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/reconcile-run.js +24 -0
- package/dist/workflows/dag/types.js +57 -0
- package/dist/workflows/dag/validate.js +19 -2
- package/dist/workflows/loop/actions/dag-action.js +2 -1
- package/docs/architecture/dag-execution.md +5 -1
- package/docs/architecture/runtime-boundaries.md +11 -1
- package/docs/architecture/worker-and-feature.md +2 -0
- package/docs/init-surface.manifest.json +3 -0
- package/docs/templates/backend-test-dag.json +3 -3
- package/docs/templates/product-line/README.md +17 -1
- package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
- package/docs/templates/product-line/scaffold-samples/README.md +36 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
- package/examples/l5-report-coms-process-definition.html +322 -0
- package/package.json +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +84 -0
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/loop-agent/SKILL.md +14 -16
- package/skills/loop-agent/references/command-reference.md +33 -12
- package/skills/loop-agent/references/harness-policy.md +28 -6
- package/skills/loop-agent/references/hybrid-dag.md +8 -7
- package/skills/loop-agent/references/post-implementation-and-patterns.md +19 -9
- package/skills/loop-agent/references/source-and-plan-practice.md +161 -0
- package/skills/loop-agent/references/task-workflow.md +13 -5
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { MavenPlanError, } from "./types.js";
|
|
3
|
+
function toPosix(value) {
|
|
4
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
5
|
+
}
|
|
6
|
+
function stripGlobSuffix(pattern) {
|
|
7
|
+
let p = toPosix(pattern.trim());
|
|
8
|
+
// Normalize common task globs to directory prefixes.
|
|
9
|
+
p = p.replace(/\/\*\*$/, "");
|
|
10
|
+
p = p.replace(/\/\*$/, "");
|
|
11
|
+
p = p.replace(/\*\*$/, "");
|
|
12
|
+
p = p.replace(/\*$/, "");
|
|
13
|
+
p = p.replace(/\/+$/, "");
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
function isPathInside(parent, child) {
|
|
17
|
+
const p = toPosix(parent);
|
|
18
|
+
const c = toPosix(child);
|
|
19
|
+
if (p === "")
|
|
20
|
+
return true;
|
|
21
|
+
if (c === p)
|
|
22
|
+
return true;
|
|
23
|
+
return c.startsWith(`${p}/`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A module is proven when at least one scope path is strictly inside that
|
|
27
|
+
* module directory tree (or equals the module dir). Sibling name similarity is
|
|
28
|
+
* never evidence. Broad "**" alone does not prove any module.
|
|
29
|
+
*/
|
|
30
|
+
function scopePathProvesModule(scopePath, moduleDir) {
|
|
31
|
+
const scope = stripGlobSuffix(scopePath);
|
|
32
|
+
if (scope === "" || scope === "**" || scope === ".") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const mod = toPosix(moduleDir);
|
|
36
|
+
if (mod === "") {
|
|
37
|
+
// Root module: only proven by explicit root-ish paths, not by "**".
|
|
38
|
+
return scope === "" || scope === "." || scope === "pom.xml";
|
|
39
|
+
}
|
|
40
|
+
// Scope prefix falls inside module, or module falls inside a write/source path
|
|
41
|
+
// that is more specific than the module parent.
|
|
42
|
+
if (isPathInside(mod, scope))
|
|
43
|
+
return true;
|
|
44
|
+
// writeSet file under module
|
|
45
|
+
if (isPathInside(mod, scope))
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
function collectScopePaths(scope) {
|
|
50
|
+
const values = [
|
|
51
|
+
...(scope.allowedPaths ?? []),
|
|
52
|
+
...(scope.writeSet ?? []),
|
|
53
|
+
...(scope.sourcePaths ?? []),
|
|
54
|
+
];
|
|
55
|
+
return values.map(toPosix).filter((value) => value.length > 0);
|
|
56
|
+
}
|
|
57
|
+
function nodeDir(node) {
|
|
58
|
+
return toPosix(node.dir);
|
|
59
|
+
}
|
|
60
|
+
function targetForNode(graph, node) {
|
|
61
|
+
const reactorRoot = graph.reactorRootByPomPath[node.pomPath];
|
|
62
|
+
if (reactorRoot && reactorRoot !== node.pomPath) {
|
|
63
|
+
const reactor = graph.reactors.find((r) => r.rootPomPath === reactorRoot);
|
|
64
|
+
// Prefer reactor-root-relative module path for -pl (nested aggregators).
|
|
65
|
+
const coordinate = reactor?.modulePathByPomPath?.[node.pomPath] ??
|
|
66
|
+
reactor?.artifactIdByPomPath[node.pomPath] ??
|
|
67
|
+
node.artifactId;
|
|
68
|
+
return {
|
|
69
|
+
pomPath: node.pomPath,
|
|
70
|
+
artifactId: node.artifactId,
|
|
71
|
+
reactorRootPomPath: reactorRoot,
|
|
72
|
+
moduleCoordinate: coordinate,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Root of a reactor selected itself, or independent POM.
|
|
76
|
+
return {
|
|
77
|
+
pomPath: node.pomPath,
|
|
78
|
+
artifactId: node.artifactId,
|
|
79
|
+
reactorRootPomPath: reactorRoot === node.pomPath ? reactorRoot : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve uniquely proven Maven targets from task scope.
|
|
84
|
+
* Never uses depth, lexical order, or first-match as selection heuristics.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveMavenTargets(input) {
|
|
87
|
+
const scopePaths = collectScopePaths(input.scope);
|
|
88
|
+
if (scopePaths.length === 0) {
|
|
89
|
+
throw new MavenPlanError("Maven verification requires task scope paths to prove a unique target; no allowedPaths/writeSet/sourcePaths were provided.", { code: "maven-scope-empty" });
|
|
90
|
+
}
|
|
91
|
+
const proven = [];
|
|
92
|
+
for (const node of input.graph.nodes) {
|
|
93
|
+
const dir = nodeDir(node);
|
|
94
|
+
const provenBy = scopePaths.some((scopePath) => scopePathProvesModule(scopePath, dir));
|
|
95
|
+
if (provenBy) {
|
|
96
|
+
proven.push(node);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (proven.length === 0) {
|
|
100
|
+
const candidates = input.graph.nodes.map((n) => n.pomPath).sort();
|
|
101
|
+
throw new MavenPlanError(`Unable to uniquely prove a Maven target from task scope. Candidates: ${candidates.join(", ") || "(none)"}`, { code: "maven-scope-no-target", candidates });
|
|
102
|
+
}
|
|
103
|
+
// If multiple modules are proven, keep them only when each is independently
|
|
104
|
+
// proven by scope — never drop by sorting.
|
|
105
|
+
// Prefer more specific modules over their reactor ancestors when both match
|
|
106
|
+
// because a child path also matches the parent prefix.
|
|
107
|
+
const provenDirs = new Set(proven.map((n) => nodeDir(n)));
|
|
108
|
+
const filtered = proven.filter((node) => {
|
|
109
|
+
const dir = nodeDir(node);
|
|
110
|
+
// Drop ancestor modules when a descendant module is also proven.
|
|
111
|
+
for (const other of proven) {
|
|
112
|
+
if (other.pomPath === node.pomPath)
|
|
113
|
+
continue;
|
|
114
|
+
const otherDir = nodeDir(other);
|
|
115
|
+
if (otherDir !== dir && isPathInside(dir, otherDir)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return provenDirs.has(dir);
|
|
120
|
+
});
|
|
121
|
+
if (filtered.length === 0) {
|
|
122
|
+
const candidates = proven.map((n) => n.pomPath).sort();
|
|
123
|
+
throw new MavenPlanError(`Ambiguous Maven target after filtering ancestors. Candidates: ${candidates.join(", ")}`, { code: "maven-scope-ambiguous", candidates });
|
|
124
|
+
}
|
|
125
|
+
// Wide "**" only scope that still matched nothing specific is already handled.
|
|
126
|
+
// If still multiple independent modules and the only scope paths are broad
|
|
127
|
+
// globs that equally cover all of them without nested proof, fail.
|
|
128
|
+
const specificProof = scopePaths.some((scopePath) => {
|
|
129
|
+
const stripped = stripGlobSuffix(scopePath);
|
|
130
|
+
return stripped !== "" && stripped !== "**" && stripped !== ".";
|
|
131
|
+
});
|
|
132
|
+
if (!specificProof && filtered.length > 1) {
|
|
133
|
+
const candidates = filtered.map((n) => n.pomPath).sort();
|
|
134
|
+
throw new MavenPlanError(`Ambiguous Maven task scope matches multiple modules without unique proof. Candidates: ${candidates.join(", ")}`, { code: "maven-scope-ambiguous", candidates });
|
|
135
|
+
}
|
|
136
|
+
// Deduplicate by pom path without sorting-based selection.
|
|
137
|
+
const byPom = new Map();
|
|
138
|
+
for (const node of filtered) {
|
|
139
|
+
byPom.set(node.pomPath, targetForNode(input.graph, node));
|
|
140
|
+
}
|
|
141
|
+
const targets = [...byPom.values()];
|
|
142
|
+
if (targets.length === 0) {
|
|
143
|
+
throw new MavenPlanError("No Maven targets remained after scope resolution.", {
|
|
144
|
+
code: "maven-scope-no-target",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return targets;
|
|
148
|
+
}
|
|
149
|
+
export function moduleDirFromPomPath(pomPath) {
|
|
150
|
+
const posix = toPosix(pomPath);
|
|
151
|
+
const dir = path.posix.dirname(posix);
|
|
152
|
+
return dir === "." ? "" : dir;
|
|
153
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isCachedGraphStillFresh } from "./cache.js";
|
|
3
|
+
import { MAVEN_CACHE_SCHEMA_VERSION, MAVEN_PLANNER_VERSION, MavenPlanStaleError, } from "./types.js";
|
|
4
|
+
import { buildMavenWorkspaceGraph } from "./workspace-graph.js";
|
|
5
|
+
function digestCommand(command) {
|
|
6
|
+
return createHash("sha256")
|
|
7
|
+
.update(JSON.stringify({ label: command.label, args: command.args }))
|
|
8
|
+
.digest("hex");
|
|
9
|
+
}
|
|
10
|
+
function digestShellCommand(command) {
|
|
11
|
+
return createHash("sha256").update(command).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
function commandsMatchPlan(plan, commands) {
|
|
14
|
+
if (plan.plannedCommands && plan.plannedCommands.length > 0) {
|
|
15
|
+
if (commands.length !== plan.plannedCommands.length)
|
|
16
|
+
return false;
|
|
17
|
+
for (let i = 0; i < commands.length; i += 1) {
|
|
18
|
+
const planned = plan.plannedCommands[i];
|
|
19
|
+
const actual = commands[i];
|
|
20
|
+
if (planned.label !== actual.label)
|
|
21
|
+
return false;
|
|
22
|
+
if (planned.args.length !== actual.args.length)
|
|
23
|
+
return false;
|
|
24
|
+
for (let j = 0; j < planned.args.length; j += 1) {
|
|
25
|
+
if (planned.args[j] !== actual.args[j])
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (plan.commandDigests && plan.commandDigests.length > 0) {
|
|
32
|
+
if (commands.length !== plan.commandDigests.length)
|
|
33
|
+
return false;
|
|
34
|
+
for (let i = 0; i < commands.length; i += 1) {
|
|
35
|
+
if (digestCommand(commands[i]) !== plan.commandDigests[i]) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function shellCommandsMatchPlan(plan, shellCommands) {
|
|
44
|
+
if (plan.shellCommandDigests && plan.shellCommandDigests.length > 0) {
|
|
45
|
+
if (shellCommands.length !== plan.shellCommandDigests.length)
|
|
46
|
+
return false;
|
|
47
|
+
for (let i = 0; i < shellCommands.length; i += 1) {
|
|
48
|
+
if (digestShellCommand(shellCommands[i]) !== plan.shellCommandDigests[i]) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
// No frozen shell digests — cannot prove drift; treat as matching.
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
function assertWorkspaceFresh(repoRoot, plan) {
|
|
58
|
+
// Prefer lightweight stat-only check against the frozen manifest.
|
|
59
|
+
if (plan.workspaceManifest) {
|
|
60
|
+
const probe = {
|
|
61
|
+
schemaVersion: plan.schemaVersion,
|
|
62
|
+
plannerVersion: plan.plannerVersion,
|
|
63
|
+
fingerprint: plan.fingerprint,
|
|
64
|
+
pomPaths: [],
|
|
65
|
+
wrapperPaths: [],
|
|
66
|
+
nodes: [],
|
|
67
|
+
reactors: [],
|
|
68
|
+
reactorRootByPomPath: {},
|
|
69
|
+
manifest: plan.workspaceManifest,
|
|
70
|
+
};
|
|
71
|
+
if (isCachedGraphStillFresh(repoRoot, probe)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Metadata changed: confirm via content fingerprint (targeted rebuild).
|
|
75
|
+
const graph = buildMavenWorkspaceGraph(repoRoot);
|
|
76
|
+
if (graph.fingerprint !== plan.fingerprint) {
|
|
77
|
+
throw new MavenPlanStaleError("verification-plan-stale: Maven POM/wrapper fingerprint drifted since DAG generation; regenerate/validate the DAG before running Maven");
|
|
78
|
+
}
|
|
79
|
+
// Directory metadata also changes for normal implementation work (for
|
|
80
|
+
// example, adding a Java source directory). The full static rebuild above
|
|
81
|
+
// proves the Maven inputs are unchanged, so the frozen plan remains valid.
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Legacy plans without manifest: full static fingerprint re-check.
|
|
85
|
+
const graph = buildMavenWorkspaceGraph(repoRoot);
|
|
86
|
+
if (graph.fingerprint !== plan.fingerprint) {
|
|
87
|
+
throw new MavenPlanStaleError("verification-plan-stale: Maven POM/wrapper fingerprint drifted since DAG generation; regenerate/validate the DAG before running Maven");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Re-check frozen plan against workspace + optional execution command sequence.
|
|
92
|
+
* Lightweight path only stats paths recorded in the frozen workspaceManifest.
|
|
93
|
+
* Throws MavenPlanStaleError (code verification-plan-stale) on drift.
|
|
94
|
+
*/
|
|
95
|
+
export function assertMavenPlanFresh(repoRoot, plan, commands) {
|
|
96
|
+
if (plan.schemaVersion !== MAVEN_CACHE_SCHEMA_VERSION) {
|
|
97
|
+
throw new MavenPlanStaleError(`verification-plan-stale: plan schemaVersion ${plan.schemaVersion} != ${MAVEN_CACHE_SCHEMA_VERSION}; regenerate the DAG`);
|
|
98
|
+
}
|
|
99
|
+
if (plan.plannerVersion !== MAVEN_PLANNER_VERSION) {
|
|
100
|
+
throw new MavenPlanStaleError(`verification-plan-stale: plan plannerVersion ${plan.plannerVersion} != ${MAVEN_PLANNER_VERSION}; regenerate the DAG`);
|
|
101
|
+
}
|
|
102
|
+
if (commands) {
|
|
103
|
+
if (Array.isArray(commands)) {
|
|
104
|
+
const normalized = commands.map((command) => ({
|
|
105
|
+
label: command.label,
|
|
106
|
+
args: command.args,
|
|
107
|
+
}));
|
|
108
|
+
if (!commandsMatchPlan(plan, normalized)) {
|
|
109
|
+
throw new MavenPlanStaleError("verification-plan-stale: planned Maven command sequence/digests drifted since DAG generation; regenerate/validate the DAG before running Maven");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
if (commands.verifyCommands) {
|
|
114
|
+
const normalized = commands.verifyCommands.map((command) => ({
|
|
115
|
+
label: command.label,
|
|
116
|
+
args: command.args,
|
|
117
|
+
}));
|
|
118
|
+
if (!commandsMatchPlan(plan, normalized)) {
|
|
119
|
+
throw new MavenPlanStaleError("verification-plan-stale: planned Maven command sequence/digests drifted since DAG generation; regenerate/validate the DAG before running Maven");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (commands.shellCommands) {
|
|
123
|
+
if (!shellCommandsMatchPlan(plan, commands.shellCommands)) {
|
|
124
|
+
throw new MavenPlanStaleError("verification-plan-stale: shell.commands sequence drifted from frozen Maven verification plan; regenerate/validate the DAG before running Maven");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
assertWorkspaceFresh(repoRoot, plan);
|
|
130
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Bump when planner decision rules change (invalidates cache). */
|
|
2
|
+
export const MAVEN_PLANNER_VERSION = "1.1.1";
|
|
3
|
+
/** Bump when on-disk cache JSON shape changes. */
|
|
4
|
+
export const MAVEN_CACHE_SCHEMA_VERSION = 2;
|
|
5
|
+
export class MavenPlanError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
candidates;
|
|
8
|
+
constructor(message, options) {
|
|
9
|
+
super(message, options?.cause ? { cause: options.cause } : undefined);
|
|
10
|
+
this.name = "MavenPlanError";
|
|
11
|
+
this.code = options?.code ?? "maven-plan-error";
|
|
12
|
+
this.candidates = options?.candidates;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class MavenPlanStaleError extends MavenPlanError {
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message, {
|
|
18
|
+
code: "verification-plan-stale",
|
|
19
|
+
cause: options?.cause,
|
|
20
|
+
});
|
|
21
|
+
this.name = "MavenPlanStaleError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, statSync, } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readStaticPomFile, repoRelativePosix } from "./pom-static.js";
|
|
5
|
+
import { MAVEN_CACHE_SCHEMA_VERSION, MAVEN_PLANNER_VERSION, MavenPlanError, } from "./types.js";
|
|
6
|
+
const SKIP_DIR_NAMES = new Set([
|
|
7
|
+
".git",
|
|
8
|
+
".harness",
|
|
9
|
+
"node_modules",
|
|
10
|
+
"target",
|
|
11
|
+
"dist",
|
|
12
|
+
"build",
|
|
13
|
+
".idea",
|
|
14
|
+
".vscode",
|
|
15
|
+
"coverage",
|
|
16
|
+
".turbo",
|
|
17
|
+
".next",
|
|
18
|
+
]);
|
|
19
|
+
const WRAPPER_BASENAMES = new Set([
|
|
20
|
+
"mvnw",
|
|
21
|
+
"mvnw.cmd",
|
|
22
|
+
"mvnw.bat",
|
|
23
|
+
"maven-wrapper.properties",
|
|
24
|
+
"maven-wrapper.jar",
|
|
25
|
+
]);
|
|
26
|
+
function toPosix(value) {
|
|
27
|
+
return value.replace(/\\/g, "/");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Walk repository files using Dirent and do not follow directory/file symlinks.
|
|
31
|
+
* Captures every successfully scanned directory in the same pass so cache-hit
|
|
32
|
+
* checks can detect a POM or wrapper added below a previously empty directory.
|
|
33
|
+
*/
|
|
34
|
+
function walkFiles(root) {
|
|
35
|
+
const files = [];
|
|
36
|
+
const directories = [];
|
|
37
|
+
const stack = [root];
|
|
38
|
+
while (stack.length > 0) {
|
|
39
|
+
const current = stack.pop();
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
directories.push({
|
|
48
|
+
path: current,
|
|
49
|
+
entryNames: entries
|
|
50
|
+
.filter((entry) => !entry.isSymbolicLink() && !SKIP_DIR_NAMES.has(entry.name))
|
|
51
|
+
.map((entry) => entry.name)
|
|
52
|
+
.sort(),
|
|
53
|
+
});
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (SKIP_DIR_NAMES.has(entry.name))
|
|
56
|
+
continue;
|
|
57
|
+
// Never follow symlinks (dir or file).
|
|
58
|
+
if (entry.isSymbolicLink())
|
|
59
|
+
continue;
|
|
60
|
+
const absolute = path.join(current, entry.name);
|
|
61
|
+
// Double-check with lstat in case platform Dirent is incomplete.
|
|
62
|
+
try {
|
|
63
|
+
const lst = lstatSync(absolute);
|
|
64
|
+
if (lst.isSymbolicLink())
|
|
65
|
+
continue;
|
|
66
|
+
if (lst.isDirectory()) {
|
|
67
|
+
stack.push(absolute);
|
|
68
|
+
}
|
|
69
|
+
else if (lst.isFile()) {
|
|
70
|
+
files.push(absolute);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { files, directories };
|
|
79
|
+
}
|
|
80
|
+
function isPomFile(filePath) {
|
|
81
|
+
return path.basename(filePath).toLowerCase() === "pom.xml";
|
|
82
|
+
}
|
|
83
|
+
function isWrapperFile(filePath) {
|
|
84
|
+
const base = path.basename(filePath).toLowerCase();
|
|
85
|
+
if (WRAPPER_BASENAMES.has(base))
|
|
86
|
+
return true;
|
|
87
|
+
const posix = toPosix(filePath).toLowerCase();
|
|
88
|
+
return posix.endsWith("/.mvn/wrapper/maven-wrapper.properties");
|
|
89
|
+
}
|
|
90
|
+
function contentSha(absolutePath) {
|
|
91
|
+
const buf = readFileSync(absolutePath);
|
|
92
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
93
|
+
}
|
|
94
|
+
function resolveModulePomPath(repoRoot, parentDir, moduleDecl) {
|
|
95
|
+
const absDir = path.resolve(repoRoot, parentDir, moduleDecl);
|
|
96
|
+
const candidates = [
|
|
97
|
+
path.join(absDir, "pom.xml"),
|
|
98
|
+
// moduleDecl may already point at a pom file
|
|
99
|
+
absDir.endsWith("pom.xml") ? absDir : null,
|
|
100
|
+
].filter((value) => Boolean(value));
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
try {
|
|
103
|
+
const lst = lstatSync(candidate);
|
|
104
|
+
if (lst.isSymbolicLink())
|
|
105
|
+
continue;
|
|
106
|
+
if (lst.isFile()) {
|
|
107
|
+
return repoRelativePosix(repoRoot, candidate);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// try next
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build reactor graphs with transitive nested aggregator membership.
|
|
118
|
+
* -pl coordinates are module paths relative to the reactor root, not bare artifactIds.
|
|
119
|
+
*/
|
|
120
|
+
function buildReactors(repoRoot, nodes) {
|
|
121
|
+
const byPom = new Map(nodes.map((node) => [node.pomPath, node]));
|
|
122
|
+
const reactors = [];
|
|
123
|
+
// Prefer outermost reactor root for each member when nested.
|
|
124
|
+
const reactorRootByPomPath = {};
|
|
125
|
+
const rootDepth = new Map();
|
|
126
|
+
for (const node of nodes) {
|
|
127
|
+
if (node.modules.length === 0)
|
|
128
|
+
continue;
|
|
129
|
+
const modulePomByArtifactId = {};
|
|
130
|
+
const artifactIdByPomPath = {
|
|
131
|
+
[node.pomPath]: node.artifactId,
|
|
132
|
+
};
|
|
133
|
+
const modulePathByPomPath = {};
|
|
134
|
+
const pomPaths = new Set([node.pomPath]);
|
|
135
|
+
const queue = node.modules.map((moduleDecl) => ({
|
|
136
|
+
parentNode: node,
|
|
137
|
+
moduleDecl,
|
|
138
|
+
pathFromRoot: toPosix(moduleDecl).replace(/\/+$/, ""),
|
|
139
|
+
stack: [node.pomPath],
|
|
140
|
+
}));
|
|
141
|
+
const seenEdges = new Set();
|
|
142
|
+
while (queue.length > 0) {
|
|
143
|
+
const item = queue.shift();
|
|
144
|
+
const edgeKey = `${item.parentNode.pomPath}=>${item.moduleDecl}`;
|
|
145
|
+
if (seenEdges.has(edgeKey))
|
|
146
|
+
continue;
|
|
147
|
+
seenEdges.add(edgeKey);
|
|
148
|
+
if (item.stack.includes(item.parentNode.pomPath) && item.stack.length > 1) {
|
|
149
|
+
// stack already contains parent from deeper cycle checks below
|
|
150
|
+
}
|
|
151
|
+
const modulePom = resolveModulePomPath(repoRoot, item.parentNode.dir, item.moduleDecl);
|
|
152
|
+
if (!modulePom) {
|
|
153
|
+
throw new MavenPlanError(`Reactor module path cannot be resolved statically from ${item.parentNode.pomPath}: ${item.moduleDecl}`, {
|
|
154
|
+
code: "maven-reactor-module-missing",
|
|
155
|
+
candidates: [item.parentNode.pomPath, item.moduleDecl],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (item.stack.includes(modulePom)) {
|
|
159
|
+
throw new MavenPlanError(`Cyclic reactor module relationship detected involving ${modulePom}`, {
|
|
160
|
+
code: "maven-reactor-cycle",
|
|
161
|
+
candidates: [...item.stack, modulePom],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const moduleNode = byPom.get(modulePom);
|
|
165
|
+
if (!moduleNode) {
|
|
166
|
+
throw new MavenPlanError(`Reactor module POM was not discovered: ${modulePom}`, { code: "maven-reactor-module-missing", candidates: [modulePom] });
|
|
167
|
+
}
|
|
168
|
+
// module path relative to reactor root (for -pl)
|
|
169
|
+
const modulePath = item.pathFromRoot
|
|
170
|
+
.replace(/\/pom\.xml$/i, "")
|
|
171
|
+
.replace(/^\.\//, "");
|
|
172
|
+
if (modulePomByArtifactId[moduleNode.artifactId] &&
|
|
173
|
+
modulePomByArtifactId[moduleNode.artifactId] !== modulePom) {
|
|
174
|
+
throw new MavenPlanError(`Duplicate artifactId '${moduleNode.artifactId}' in reactor ${node.pomPath}`, {
|
|
175
|
+
code: "maven-reactor-duplicate-artifactId",
|
|
176
|
+
candidates: [
|
|
177
|
+
modulePomByArtifactId[moduleNode.artifactId],
|
|
178
|
+
modulePom,
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
modulePomByArtifactId[moduleNode.artifactId] = modulePom;
|
|
183
|
+
artifactIdByPomPath[modulePom] = moduleNode.artifactId;
|
|
184
|
+
modulePathByPomPath[modulePom] = modulePath;
|
|
185
|
+
pomPaths.add(modulePom);
|
|
186
|
+
// Recurse into nested aggregator modules.
|
|
187
|
+
for (const childDecl of moduleNode.modules) {
|
|
188
|
+
const childPath = toPosix(path.posix.join(modulePath, toPosix(childDecl))).replace(/\\/g, "/");
|
|
189
|
+
queue.push({
|
|
190
|
+
parentNode: moduleNode,
|
|
191
|
+
moduleDecl: childDecl,
|
|
192
|
+
pathFromRoot: childPath,
|
|
193
|
+
stack: [...item.stack, modulePom],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const reactor = {
|
|
198
|
+
rootPomPath: node.pomPath,
|
|
199
|
+
modulePomByArtifactId,
|
|
200
|
+
artifactIdByPomPath,
|
|
201
|
+
// Store module path coordinates for -pl via artifactIdByPomPath values when
|
|
202
|
+
// consumers need paths: expose via modulePathByPomPath on the side channel.
|
|
203
|
+
pomPaths: [...pomPaths].sort(),
|
|
204
|
+
// Extended field kept optional for schema compatibility via cast below.
|
|
205
|
+
...(Object.keys(modulePathByPomPath).length > 0
|
|
206
|
+
? { modulePathByPomPath }
|
|
207
|
+
: {}),
|
|
208
|
+
};
|
|
209
|
+
// Attach modulePathByPomPath for planners (types extended separately).
|
|
210
|
+
reactor
|
|
211
|
+
.modulePathByPomPath = modulePathByPomPath;
|
|
212
|
+
reactors.push(reactor);
|
|
213
|
+
// Register membership: prefer outermost (shallower path depth) reactor root.
|
|
214
|
+
const depth = node.pomPath.split("/").length;
|
|
215
|
+
for (const member of pomPaths) {
|
|
216
|
+
const existing = reactorRootByPomPath[member];
|
|
217
|
+
if (!existing) {
|
|
218
|
+
reactorRootByPomPath[member] = node.pomPath;
|
|
219
|
+
rootDepth.set(member, depth);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const existingDepth = rootDepth.get(member) ?? Number.MAX_SAFE_INTEGER;
|
|
223
|
+
if (depth < existingDepth) {
|
|
224
|
+
reactorRootByPomPath[member] = node.pomPath;
|
|
225
|
+
rootDepth.set(member, depth);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { reactors, reactorRootByPomPath };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Statically discover all POMs and wrappers under the repository.
|
|
233
|
+
* Never executes Maven goals. Does not follow symlinks.
|
|
234
|
+
*/
|
|
235
|
+
/** Test/probe counter: increments only on full walk+parse builds. */
|
|
236
|
+
let fullBuildCount = 0;
|
|
237
|
+
export function getMavenFullBuildCount() {
|
|
238
|
+
return fullBuildCount;
|
|
239
|
+
}
|
|
240
|
+
export function resetMavenFullBuildCount() {
|
|
241
|
+
fullBuildCount = 0;
|
|
242
|
+
}
|
|
243
|
+
export function buildMavenWorkspaceGraph(repoRoot) {
|
|
244
|
+
if (!existsSync(repoRoot)) {
|
|
245
|
+
throw new MavenPlanError(`Repository root does not exist: ${repoRoot}`, {
|
|
246
|
+
code: "maven-repo-missing",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
fullBuildCount += 1;
|
|
250
|
+
const walk = walkFiles(repoRoot);
|
|
251
|
+
const allFiles = walk.files;
|
|
252
|
+
const pomAbs = allFiles.filter(isPomFile).sort();
|
|
253
|
+
const wrapperAbs = allFiles.filter(isWrapperFile).sort();
|
|
254
|
+
const nodes = [];
|
|
255
|
+
const fingerprintParts = [];
|
|
256
|
+
const fileMeta = [];
|
|
257
|
+
const dirMeta = walk.directories.map((directory) => ({
|
|
258
|
+
path: directory.path === repoRoot
|
|
259
|
+
? ""
|
|
260
|
+
: repoRelativePosix(repoRoot, directory.path),
|
|
261
|
+
entryNames: directory.entryNames,
|
|
262
|
+
}));
|
|
263
|
+
for (const abs of pomAbs) {
|
|
264
|
+
const pomPath = repoRelativePosix(repoRoot, abs);
|
|
265
|
+
const fields = readStaticPomFile(abs);
|
|
266
|
+
const st = statSync(abs);
|
|
267
|
+
const dir = toPosix(path.posix.dirname(pomPath));
|
|
268
|
+
nodes.push({
|
|
269
|
+
pomPath,
|
|
270
|
+
dir: dir === "." ? "" : dir,
|
|
271
|
+
artifactId: fields.artifactId,
|
|
272
|
+
groupId: fields.groupId,
|
|
273
|
+
packaging: fields.packaging,
|
|
274
|
+
modules: fields.modules,
|
|
275
|
+
parentRelativePath: fields.parentRelativePath,
|
|
276
|
+
});
|
|
277
|
+
const sha = contentSha(abs);
|
|
278
|
+
fingerprintParts.push(`pom:${pomPath}:${sha}`);
|
|
279
|
+
fileMeta.push({
|
|
280
|
+
path: pomPath,
|
|
281
|
+
size: st.size,
|
|
282
|
+
mtimeMs: st.mtimeMs,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const wrapperPaths = [];
|
|
286
|
+
for (const abs of wrapperAbs) {
|
|
287
|
+
const rel = repoRelativePosix(repoRoot, abs);
|
|
288
|
+
wrapperPaths.push(rel);
|
|
289
|
+
const st = statSync(abs);
|
|
290
|
+
const sha = contentSha(abs);
|
|
291
|
+
fingerprintParts.push(`wrapper:${rel}:${sha}`);
|
|
292
|
+
fileMeta.push({
|
|
293
|
+
path: rel,
|
|
294
|
+
size: st.size,
|
|
295
|
+
mtimeMs: st.mtimeMs,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
fingerprintParts.sort();
|
|
299
|
+
const fingerprint = createHash("sha256")
|
|
300
|
+
.update(fingerprintParts.join("\n"))
|
|
301
|
+
.update(`\nplanner:${MAVEN_PLANNER_VERSION}`)
|
|
302
|
+
.update(`\nschema:${MAVEN_CACHE_SCHEMA_VERSION}`)
|
|
303
|
+
.digest("hex");
|
|
304
|
+
const { reactors, reactorRootByPomPath } = buildReactors(repoRoot, nodes);
|
|
305
|
+
return {
|
|
306
|
+
schemaVersion: MAVEN_CACHE_SCHEMA_VERSION,
|
|
307
|
+
plannerVersion: MAVEN_PLANNER_VERSION,
|
|
308
|
+
fingerprint,
|
|
309
|
+
pomPaths: nodes.map((n) => n.pomPath).sort(),
|
|
310
|
+
wrapperPaths: wrapperPaths.sort(),
|
|
311
|
+
nodes,
|
|
312
|
+
reactors,
|
|
313
|
+
reactorRootByPomPath,
|
|
314
|
+
manifest: {
|
|
315
|
+
files: fileMeta.sort((a, b) => a.path.localeCompare(b.path)),
|
|
316
|
+
directories: dirMeta.sort((a, b) => a.path.localeCompare(b.path)),
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
export function computeWorkspaceFingerprint(repoRoot) {
|
|
321
|
+
return buildMavenWorkspaceGraph(repoRoot).fingerprint;
|
|
322
|
+
}
|