@davidahmann/mill 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +335 -0
- package/dist/audit/repository.d.ts +8 -0
- package/dist/audit/repository.d.ts.map +1 -0
- package/dist/audit/repository.js +232 -0
- package/dist/audit/repository.js.map +1 -0
- package/dist/cli-program.d.ts +12 -0
- package/dist/cli-program.d.ts.map +1 -0
- package/dist/cli-program.js +1020 -0
- package/dist/cli-program.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/lock.d.ts +12 -0
- package/dist/config/lock.d.ts.map +1 -0
- package/dist/config/lock.js +83 -0
- package/dist/config/lock.js.map +1 -0
- package/dist/contracts/canonical.d.ts +7 -0
- package/dist/contracts/canonical.d.ts.map +1 -0
- package/dist/contracts/canonical.js +30 -0
- package/dist/contracts/canonical.js.map +1 -0
- package/dist/contracts/schemas.d.ts +2705 -0
- package/dist/contracts/schemas.d.ts.map +1 -0
- package/dist/contracts/schemas.js +988 -0
- package/dist/contracts/schemas.js.map +1 -0
- package/dist/doctor.d.ts +21 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +138 -0
- package/dist/doctor.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/prd.d.ts +14 -0
- package/dist/intake/prd.d.ts.map +1 -0
- package/dist/intake/prd.js +39 -0
- package/dist/intake/prd.js.map +1 -0
- package/dist/planning/impact.d.ts +59 -0
- package/dist/planning/impact.d.ts.map +1 -0
- package/dist/planning/impact.js +400 -0
- package/dist/planning/impact.js.map +1 -0
- package/dist/planning/specification.d.ts +42 -0
- package/dist/planning/specification.d.ts.map +1 -0
- package/dist/planning/specification.js +235 -0
- package/dist/planning/specification.js.map +1 -0
- package/dist/qualification/public-alpha.d.ts +16 -0
- package/dist/qualification/public-alpha.d.ts.map +1 -0
- package/dist/qualification/public-alpha.js +128 -0
- package/dist/qualification/public-alpha.js.map +1 -0
- package/dist/recipes/node-typescript-next-web.d.ts +19 -0
- package/dist/recipes/node-typescript-next-web.d.ts.map +1 -0
- package/dist/recipes/node-typescript-next-web.js +136 -0
- package/dist/recipes/node-typescript-next-web.js.map +1 -0
- package/dist/repository/integration.d.ts +58 -0
- package/dist/repository/integration.d.ts.map +1 -0
- package/dist/repository/integration.js +1385 -0
- package/dist/repository/integration.js.map +1 -0
- package/dist/repository/scan.d.ts +27 -0
- package/dist/repository/scan.d.ts.map +1 -0
- package/dist/repository/scan.js +388 -0
- package/dist/repository/scan.js.map +1 -0
- package/dist/result.d.ts +23 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +24 -0
- package/dist/result.js.map +1 -0
- package/dist/runtime/codex.d.ts +28 -0
- package/dist/runtime/codex.d.ts.map +1 -0
- package/dist/runtime/codex.js +439 -0
- package/dist/runtime/codex.js.map +1 -0
- package/dist/runtime/context.d.ts +10 -0
- package/dist/runtime/context.d.ts.map +1 -0
- package/dist/runtime/context.js +151 -0
- package/dist/runtime/context.js.map +1 -0
- package/dist/runtime/delivery.d.ts +58 -0
- package/dist/runtime/delivery.d.ts.map +1 -0
- package/dist/runtime/delivery.js +1073 -0
- package/dist/runtime/delivery.js.map +1 -0
- package/dist/runtime/dependencies.d.ts +19 -0
- package/dist/runtime/dependencies.d.ts.map +1 -0
- package/dist/runtime/dependencies.js +495 -0
- package/dist/runtime/dependencies.js.map +1 -0
- package/dist/runtime/github.d.ts +112 -0
- package/dist/runtime/github.d.ts.map +1 -0
- package/dist/runtime/github.js +510 -0
- package/dist/runtime/github.js.map +1 -0
- package/dist/runtime/inputs.d.ts +24 -0
- package/dist/runtime/inputs.d.ts.map +1 -0
- package/dist/runtime/inputs.js +237 -0
- package/dist/runtime/inputs.js.map +1 -0
- package/dist/runtime/lease.d.ts +11 -0
- package/dist/runtime/lease.d.ts.map +1 -0
- package/dist/runtime/lease.js +52 -0
- package/dist/runtime/lease.js.map +1 -0
- package/dist/runtime/lifecycle.d.ts +87 -0
- package/dist/runtime/lifecycle.d.ts.map +1 -0
- package/dist/runtime/lifecycle.js +1024 -0
- package/dist/runtime/lifecycle.js.map +1 -0
- package/dist/runtime/process.d.ts +39 -0
- package/dist/runtime/process.d.ts.map +1 -0
- package/dist/runtime/process.js +304 -0
- package/dist/runtime/process.js.map +1 -0
- package/dist/runtime/repository.d.ts +36 -0
- package/dist/runtime/repository.d.ts.map +1 -0
- package/dist/runtime/repository.js +458 -0
- package/dist/runtime/repository.js.map +1 -0
- package/dist/runtime/state.d.ts +121 -0
- package/dist/runtime/state.d.ts.map +1 -0
- package/dist/runtime/state.js +1017 -0
- package/dist/runtime/state.js.map +1 -0
- package/dist/runtime/verifier.d.ts +31 -0
- package/dist/runtime/verifier.d.ts.map +1 -0
- package/dist/runtime/verifier.js +478 -0
- package/dist/runtime/verifier.js.map +1 -0
- package/dist/runtime/worker.d.ts +67 -0
- package/dist/runtime/worker.d.ts.map +1 -0
- package/dist/runtime/worker.js +34 -0
- package/dist/runtime/worker.js.map +1 -0
- package/dist/security/safe-path.d.ts +3 -0
- package/dist/security/safe-path.d.ts.map +1 -0
- package/dist/security/safe-path.js +102 -0
- package/dist/security/safe-path.js.map +1 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/workflows/founder.d.ts +33 -0
- package/dist/workflows/founder.d.ts.map +1 -0
- package/dist/workflows/founder.js +422 -0
- package/dist/workflows/founder.js.map +1 -0
- package/package.json +79 -0
- package/recipes/node-typescript-next-web/.github/workflows/ci.yml +34 -0
- package/recipes/node-typescript-next-web/.node-version +1 -0
- package/recipes/node-typescript-next-web/.tool-versions +1 -0
- package/recipes/node-typescript-next-web/AGENTS.md +44 -0
- package/recipes/node-typescript-next-web/README.md +16 -0
- package/recipes/node-typescript-next-web/WORKFLOW.md +6 -0
- package/recipes/node-typescript-next-web/app/api/health/route.ts +5 -0
- package/recipes/node-typescript-next-web/app/layout.tsx +19 -0
- package/recipes/node-typescript-next-web/app/page.tsx +12 -0
- package/recipes/node-typescript-next-web/app/styles.css +21 -0
- package/recipes/node-typescript-next-web/docs/development.md +16 -0
- package/recipes/node-typescript-next-web/eslint.config.mjs +9 -0
- package/recipes/node-typescript-next-web/gitignore.template +5 -0
- package/recipes/node-typescript-next-web/next-env.d.ts +7 -0
- package/recipes/node-typescript-next-web/next.config.ts +11 -0
- package/recipes/node-typescript-next-web/package-lock.json +7540 -0
- package/recipes/node-typescript-next-web/package.json +39 -0
- package/recipes/node-typescript-next-web/playwright.config.ts +23 -0
- package/recipes/node-typescript-next-web/recipe.yaml +71 -0
- package/recipes/node-typescript-next-web/scripts/check-package.mjs +3 -0
- package/recipes/node-typescript-next-web/src/health.ts +3 -0
- package/recipes/node-typescript-next-web/test/browser/home.spec.ts +14 -0
- package/recipes/node-typescript-next-web/test/integration/health-route.test.ts +11 -0
- package/recipes/node-typescript-next-web/test/unit/health.test.ts +9 -0
- package/recipes/node-typescript-next-web/tsconfig.json +33 -0
- package/schemas/README.md +64 -0
- package/schemas/audit-report.schema.json +59 -0
- package/schemas/blueprint.schema.json +34 -0
- package/schemas/context-manifest.schema.json +88 -0
- package/schemas/delivery-record.schema.json +266 -0
- package/schemas/impact-manifest.schema.json +146 -0
- package/schemas/managed-repository.schema.json +36 -0
- package/schemas/mill-config.schema.json +212 -0
- package/schemas/mill-lock.schema.json +96 -0
- package/schemas/outcome-plan.schema.json +51 -0
- package/schemas/product-contract.schema.json +181 -0
- package/schemas/public-alpha-qualification.schema.json +391 -0
- package/schemas/recipe-manifest.schema.json +187 -0
- package/schemas/release-evidence.schema.json +170 -0
- package/schemas/repository-integration-plan.schema.json +156 -0
- package/schemas/review-result.schema.json +57 -0
- package/schemas/scenario-set.schema.json +84 -0
- package/schemas/source-manifest.schema.json +96 -0
- package/schemas/specification-proposal.schema.json +406 -0
- package/schemas/support-tuple.schema.json +107 -0
- package/schemas/task-packet.schema.json +395 -0
- package/schemas/validation-evidence.schema.json +125 -0
- package/schemas/worker-invocation.schema.json +119 -0
- package/schemas/worker-profile.schema.json +53 -0
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { canonicalDigest } from "../contracts/canonical.js";
|
|
7
|
+
import { millConfigSchema, millLockSchema, repositoryIntegrationPlanSchema, taskPacketV1Schema, taskPacketV2Schema, } from "../contracts/schemas.js";
|
|
8
|
+
import { findTrustedExecutable } from "../doctor.js";
|
|
9
|
+
import { ExitCode, MillError } from "../errors.js";
|
|
10
|
+
import { assessSpecificationProposal, loadPlanningSources, loadSpecificationProposal, promoteSpecificationProposal, } from "../planning/specification.js";
|
|
11
|
+
import { loadNodeWebRecipe, renderNodeWebRecipe, } from "../recipes/node-typescript-next-web.js";
|
|
12
|
+
import { isWithin, safeReadText } from "../security/safe-path.js";
|
|
13
|
+
import { MILL_PACKAGE, MILL_VERSION } from "../version.js";
|
|
14
|
+
import { processCancellationScope, runProcess } from "../runtime/process.js";
|
|
15
|
+
import { assertVisibleIndexState, resolveCommit, } from "../runtime/repository.js";
|
|
16
|
+
import { textDigest } from "../runtime/inputs.js";
|
|
17
|
+
import { prepareDependencySnapshot } from "../runtime/dependencies.js";
|
|
18
|
+
import { repositoryStateDirectory } from "../runtime/state.js";
|
|
19
|
+
import { verifyDeclaredCommands } from "../runtime/verifier.js";
|
|
20
|
+
import { scanRepository } from "./scan.js";
|
|
21
|
+
const gitEnvironment = {
|
|
22
|
+
HOME: "/var/empty",
|
|
23
|
+
LANG: "C",
|
|
24
|
+
LC_ALL: "C",
|
|
25
|
+
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
26
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
27
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
28
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
29
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
30
|
+
GIT_PAGER: "cat",
|
|
31
|
+
PAGER: "cat",
|
|
32
|
+
};
|
|
33
|
+
function digest(content) {
|
|
34
|
+
return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
|
|
35
|
+
}
|
|
36
|
+
function slug(value) {
|
|
37
|
+
const normalized = value
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.normalize("NFKD")
|
|
40
|
+
.replaceAll(/[^a-z0-9._-]+/gu, "-")
|
|
41
|
+
.replaceAll(/^-+|-+$/gu, "")
|
|
42
|
+
.slice(0, 48);
|
|
43
|
+
return normalized.length === 0 ? "first-outcome" : normalized;
|
|
44
|
+
}
|
|
45
|
+
function hasControlCharacter(value) {
|
|
46
|
+
for (const character of value) {
|
|
47
|
+
const code = character.codePointAt(0) ?? 0;
|
|
48
|
+
if (code <= 31 || code === 127)
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
function assertRelativePath(value, label) {
|
|
54
|
+
if (value.length === 0 ||
|
|
55
|
+
path.isAbsolute(value) ||
|
|
56
|
+
value.split(/[\\/]/u).includes("..") ||
|
|
57
|
+
hasControlCharacter(value)) {
|
|
58
|
+
throw new MillError("INVALID_INTEGRATION_PATH", `${label} must be a safe relative path.`, ExitCode.configuration, { value });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function greenfieldTarget(sourceRoot, targetDirectory) {
|
|
62
|
+
assertRelativePath(targetDirectory, "Target directory");
|
|
63
|
+
const canonicalRoot = await realpath(sourceRoot);
|
|
64
|
+
const segments = targetDirectory.split(/[\\/]/u).filter(Boolean);
|
|
65
|
+
let current = canonicalRoot;
|
|
66
|
+
for (const [index, segment] of segments.slice(0, -1).entries()) {
|
|
67
|
+
current = path.join(current, segment);
|
|
68
|
+
try {
|
|
69
|
+
const information = await lstat(current);
|
|
70
|
+
if (information.isSymbolicLink() || !information.isDirectory()) {
|
|
71
|
+
throw new MillError("GREENFIELD_TARGET_ANCESTOR_UNSAFE", "Greenfield target ancestors must be real directories inside the approved source root.", ExitCode.configuration, { path: segments.slice(0, index + 1).join("/") });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof Error &&
|
|
76
|
+
"code" in error &&
|
|
77
|
+
error.code === "ENOENT") {
|
|
78
|
+
throw new MillError("GREENFIELD_TARGET_ANCESTOR_UNSAFE", "Greenfield target parents must already exist as real directories inside the approved source root.", ExitCode.configuration, { path: segments.slice(0, index + 1).join("/") });
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const requestedTarget = path.resolve(canonicalRoot, targetDirectory);
|
|
84
|
+
if (!isWithin(canonicalRoot, requestedTarget)) {
|
|
85
|
+
throw new MillError("GREENFIELD_TARGET_OUTSIDE_ROOT", "The greenfield target must remain inside the approved source root.", ExitCode.configuration);
|
|
86
|
+
}
|
|
87
|
+
const canonicalParent = await realpath(path.dirname(requestedTarget));
|
|
88
|
+
if (!isWithin(canonicalRoot, canonicalParent)) {
|
|
89
|
+
throw new MillError("GREENFIELD_TARGET_ANCESTOR_UNSAFE", "The greenfield target parent resolved outside the approved source root.", ExitCode.configuration);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
target: path.join(canonicalParent, path.basename(requestedTarget)),
|
|
93
|
+
parent: canonicalParent,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function assertIdentity(input) {
|
|
97
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(input.repositoryId)) {
|
|
98
|
+
throw new MillError("INVALID_REPOSITORY_ID", "Repository integration requires one explicit UUIDv4 identity.", ExitCode.configuration);
|
|
99
|
+
}
|
|
100
|
+
if (!validAuthorEmail(input.authorEmail)) {
|
|
101
|
+
throw new MillError("INVALID_AUTHOR_IDENTITY", "Repository integration requires an explicit valid author email.", ExitCode.configuration);
|
|
102
|
+
}
|
|
103
|
+
if (input.authorName.trim().length === 0 ||
|
|
104
|
+
input.approvedBy.trim().length === 0) {
|
|
105
|
+
throw new MillError("INVALID_AUTHOR_IDENTITY", "Repository integration requires explicit author and approval identities.", ExitCode.configuration);
|
|
106
|
+
}
|
|
107
|
+
const approval = z.iso.datetime().safeParse(input.approvedAt);
|
|
108
|
+
if (!approval.success || Date.parse(input.approvedAt) > Date.now()) {
|
|
109
|
+
throw new MillError("INVALID_APPROVAL_TIME", "Repository integration requires an exact active ISO approval time.", ExitCode.configuration);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function validAuthorEmail(value) {
|
|
113
|
+
if (value.length > 254)
|
|
114
|
+
return false;
|
|
115
|
+
const separator = value.indexOf("@");
|
|
116
|
+
if (separator <= 0 ||
|
|
117
|
+
separator !== value.lastIndexOf("@") ||
|
|
118
|
+
separator > 64) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const local = value.slice(0, separator);
|
|
122
|
+
const domain = value.slice(separator + 1);
|
|
123
|
+
if (local.startsWith(".") ||
|
|
124
|
+
local.endsWith(".") ||
|
|
125
|
+
local.includes("..") ||
|
|
126
|
+
domain.length === 0 ||
|
|
127
|
+
domain.length > 253) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const localPunctuation = "!#$%&'*+-/=?^_`{|}~.";
|
|
131
|
+
for (const character of local) {
|
|
132
|
+
const code = character.codePointAt(0) ?? 0;
|
|
133
|
+
const alphanumeric = (code >= 48 && code <= 57) ||
|
|
134
|
+
(code >= 65 && code <= 90) ||
|
|
135
|
+
(code >= 97 && code <= 122);
|
|
136
|
+
if (!alphanumeric && !localPunctuation.includes(character))
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const labels = domain.split(".");
|
|
140
|
+
if (labels.length < 2)
|
|
141
|
+
return false;
|
|
142
|
+
for (const label of labels) {
|
|
143
|
+
if (label.length === 0 || label.length > 63)
|
|
144
|
+
return false;
|
|
145
|
+
for (let index = 0; index < label.length; index += 1) {
|
|
146
|
+
const code = label.codePointAt(index) ?? 0;
|
|
147
|
+
const alphanumeric = (code >= 48 && code <= 57) ||
|
|
148
|
+
(code >= 65 && code <= 90) ||
|
|
149
|
+
(code >= 97 && code <= 122);
|
|
150
|
+
if (!alphanumeric &&
|
|
151
|
+
(label[index] !== "-" || index === 0 || index === label.length - 1)) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
async function authority(input) {
|
|
159
|
+
assertIdentity(input);
|
|
160
|
+
const [planning, proposal] = await Promise.all([
|
|
161
|
+
loadPlanningSources({
|
|
162
|
+
root: input.sourceRoot,
|
|
163
|
+
prdPath: input.prdPath,
|
|
164
|
+
sourceManifestPath: input.sourceManifestPath,
|
|
165
|
+
}),
|
|
166
|
+
loadSpecificationProposal(input.sourceRoot, input.proposalPath),
|
|
167
|
+
]);
|
|
168
|
+
const assessment = assessSpecificationProposal({
|
|
169
|
+
proposal,
|
|
170
|
+
prdPath: planning.prdPath,
|
|
171
|
+
prdDigest: planning.prdDigest,
|
|
172
|
+
sourceManifest: planning.sourceManifest,
|
|
173
|
+
sourceManifestDigest: planning.sourceManifestDigest,
|
|
174
|
+
});
|
|
175
|
+
const promoted = promoteSpecificationProposal({
|
|
176
|
+
proposal,
|
|
177
|
+
approvalDigest: input.productApprovalDigest,
|
|
178
|
+
assessment,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
prd: planning.prd,
|
|
182
|
+
sourceManifest: planning.sourceManifest,
|
|
183
|
+
proposal,
|
|
184
|
+
proposalDigest: promoted.proposalDigest,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function assertApprovedRecipe(approved, recipe) {
|
|
188
|
+
const blueprint = approved.proposal.blueprints[0];
|
|
189
|
+
const runtime = `node-${recipe.manifest.runtime.node}-npm-${recipe.manifest.runtime.npm}`;
|
|
190
|
+
if (blueprint?.recipe !== recipe.manifest.id ||
|
|
191
|
+
blueprint.recipeVersion !== recipe.manifest.version ||
|
|
192
|
+
blueprint.runtime !== runtime) {
|
|
193
|
+
throw new MillError("APPROVED_RECIPE_UNSUPPORTED", "The approved blueprint does not select the exact supported recipe, version, and runtime.", ExitCode.configuration, {
|
|
194
|
+
approvedRecipe: blueprint?.recipe ?? null,
|
|
195
|
+
approvedVersion: blueprint?.recipeVersion ?? null,
|
|
196
|
+
approvedRuntime: blueprint?.runtime ?? null,
|
|
197
|
+
supportedRecipe: recipe.manifest.id,
|
|
198
|
+
supportedVersion: recipe.manifest.version,
|
|
199
|
+
supportedRuntime: runtime,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function yaml(value) {
|
|
204
|
+
return stringifyYaml(value, { lineWidth: 80 });
|
|
205
|
+
}
|
|
206
|
+
function recipeCommands(nativeCommands, writablePaths) {
|
|
207
|
+
const controlPaths = {
|
|
208
|
+
"format:check": ["package.json", "package-lock.json"],
|
|
209
|
+
lint: ["package.json", "package-lock.json", "eslint.config.mjs"],
|
|
210
|
+
typecheck: [
|
|
211
|
+
"package.json",
|
|
212
|
+
"package-lock.json",
|
|
213
|
+
"tsconfig.json",
|
|
214
|
+
"next-env.d.ts",
|
|
215
|
+
],
|
|
216
|
+
"test:unit": ["package.json", "package-lock.json", "test/unit/**"],
|
|
217
|
+
"test:integration": [
|
|
218
|
+
"package.json",
|
|
219
|
+
"package-lock.json",
|
|
220
|
+
"test/integration/**",
|
|
221
|
+
],
|
|
222
|
+
"test:browser": [
|
|
223
|
+
"package.json",
|
|
224
|
+
"package-lock.json",
|
|
225
|
+
"playwright.config.ts",
|
|
226
|
+
"test/browser/**",
|
|
227
|
+
],
|
|
228
|
+
"test:package": [
|
|
229
|
+
"package.json",
|
|
230
|
+
"package-lock.json",
|
|
231
|
+
"next.config.ts",
|
|
232
|
+
"scripts/check-package.mjs",
|
|
233
|
+
],
|
|
234
|
+
check: [
|
|
235
|
+
"package.json",
|
|
236
|
+
"package-lock.json",
|
|
237
|
+
"eslint.config.mjs",
|
|
238
|
+
"tsconfig.json",
|
|
239
|
+
"next.config.ts",
|
|
240
|
+
"playwright.config.ts",
|
|
241
|
+
"scripts/check-package.mjs",
|
|
242
|
+
"test/**",
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
return Object.fromEntries(nativeCommands.map((id) => [
|
|
246
|
+
id,
|
|
247
|
+
{
|
|
248
|
+
argv: ["npm", "run", id],
|
|
249
|
+
cwd: ".",
|
|
250
|
+
controlPaths: controlPaths[id] ?? ["package.json", "package-lock.json"],
|
|
251
|
+
capability: id === "test:package" ? "package" : "test",
|
|
252
|
+
required: true,
|
|
253
|
+
timeoutSeconds: id === "check" ? 900 : 300,
|
|
254
|
+
execution: "oci",
|
|
255
|
+
writablePaths,
|
|
256
|
+
},
|
|
257
|
+
]));
|
|
258
|
+
}
|
|
259
|
+
function selectedOutcome(proposal) {
|
|
260
|
+
const outcome = proposal.productContract.outcomes[0];
|
|
261
|
+
if (outcome === undefined) {
|
|
262
|
+
throw new MillError("OUTCOME_REQUIRED", "Repository integration requires at least one approved product outcome.", ExitCode.configuration);
|
|
263
|
+
}
|
|
264
|
+
const acceptanceIds = outcome.acceptanceIds ??
|
|
265
|
+
proposal.productContract.acceptance.map((item) => item.id);
|
|
266
|
+
const acceptanceSet = new Set(acceptanceIds);
|
|
267
|
+
const scenarios = proposal.scenarioSet.scenarios.filter((scenario) => scenario.acceptanceRefs.some((id) => acceptanceIds.includes(id)));
|
|
268
|
+
const crossingScenario = scenarios.find((scenario) => scenario.acceptanceRefs.some((id) => !acceptanceSet.has(id)));
|
|
269
|
+
if (crossingScenario !== undefined) {
|
|
270
|
+
throw new MillError("OUTCOME_SCENARIO_SCOPE_INVALID", "A selected first-outcome scenario cannot reference acceptance outside that outcome.", ExitCode.configuration, { scenarioId: crossingScenario.id });
|
|
271
|
+
}
|
|
272
|
+
if (acceptanceIds.length === 0 || scenarios.length === 0) {
|
|
273
|
+
throw new MillError("OUTCOME_ORACLE_REQUIRED", "The first outcome requires explicit acceptance IDs and approved scenarios before recipe apply.", ExitCode.configuration);
|
|
274
|
+
}
|
|
275
|
+
return { outcome, acceptanceIds, scenarios };
|
|
276
|
+
}
|
|
277
|
+
function dynamicFiles(input) {
|
|
278
|
+
const { proposal } = input.authority;
|
|
279
|
+
const selection = selectedOutcome(proposal);
|
|
280
|
+
const supportedCommands = new Set(input.nativeCommands);
|
|
281
|
+
const recipeOracles = new Map(input.recipe.manifest.oracles.map((oracle) => [oracle.id, oracle]));
|
|
282
|
+
const commandIds = new Set();
|
|
283
|
+
for (const scenario of selection.scenarios) {
|
|
284
|
+
const recipeOracle = scenario.recipeOracle === undefined
|
|
285
|
+
? undefined
|
|
286
|
+
: recipeOracles.get(scenario.recipeOracle);
|
|
287
|
+
if (recipeOracle === undefined ||
|
|
288
|
+
scenario.executionRef === undefined ||
|
|
289
|
+
recipeOracle.commandId !== scenario.executionRef ||
|
|
290
|
+
!supportedCommands.has(scenario.executionRef)) {
|
|
291
|
+
throw new MillError("RECIPE_SCENARIO_UNSUPPORTED", "An approved first-outcome scenario requires an explicit matching recipe-specific oracle.", ExitCode.configuration, {
|
|
292
|
+
scenarioId: scenario.id,
|
|
293
|
+
recipeOracle: scenario.recipeOracle ?? null,
|
|
294
|
+
executionRef: scenario.executionRef ?? null,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
commandIds.add(scenario.executionRef);
|
|
298
|
+
}
|
|
299
|
+
for (const invariant of proposal.productContract.invariants) {
|
|
300
|
+
if (selection.scenarios.some((scenario) => scenario.invariantRefs.includes(invariant.id))) {
|
|
301
|
+
if (invariant.verification.mode !== "command") {
|
|
302
|
+
throw new MillError("RECIPE_INVARIANT_UNSUPPORTED", "A recipe-generated task requires command-verifiable affected invariants.", ExitCode.configuration, {
|
|
303
|
+
invariantId: invariant.id,
|
|
304
|
+
verificationMode: invariant.verification.mode,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (!supportedCommands.has(invariant.verification.ref)) {
|
|
308
|
+
throw new MillError("RECIPE_INVARIANT_UNSUPPORTED", "An affected invariant is not mapped to a supported native recipe command.", ExitCode.configuration, {
|
|
309
|
+
invariantId: invariant.id,
|
|
310
|
+
executionRef: invariant.verification.ref,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
commandIds.add(invariant.verification.ref);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!supportedCommands.has("check")) {
|
|
317
|
+
throw new MillError("RECIPE_FULL_GATE_UNAVAILABLE", "Repository integration requires one native check command for preservation evidence.", ExitCode.configuration);
|
|
318
|
+
}
|
|
319
|
+
commandIds.add("check");
|
|
320
|
+
const productContractDigest = canonicalDigest(proposal.productContract);
|
|
321
|
+
const outcomeSlug = slug(selection.outcome.id.replace(/^OUT-/u, ""));
|
|
322
|
+
const impactPath = `product/impacts/${outcomeSlug}.yaml`;
|
|
323
|
+
const taskPath = `product/tasks/${outcomeSlug}.yaml`;
|
|
324
|
+
const acceptance = selection.acceptanceIds.map((id) => {
|
|
325
|
+
const contract = proposal.productContract.acceptance.find((item) => item.id === id);
|
|
326
|
+
if (contract === undefined) {
|
|
327
|
+
throw new MillError("OUTCOME_ACCEPTANCE_UNRESOLVED", "The first outcome references an unknown acceptance item.", ExitCode.configuration, { acceptanceId: id });
|
|
328
|
+
}
|
|
329
|
+
const scenarios = selection.scenarios.filter((scenario) => scenario.acceptanceRefs.includes(id));
|
|
330
|
+
const executionRef = scenarios[0]?.executionRef;
|
|
331
|
+
if (executionRef === undefined) {
|
|
332
|
+
throw new MillError("OUTCOME_ORACLE_REQUIRED", "Each first-outcome acceptance item requires an executable approved scenario.", ExitCode.configuration, { acceptanceId: id });
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
id,
|
|
336
|
+
statement: contract.statement,
|
|
337
|
+
invariantIds: [
|
|
338
|
+
...new Set(scenarios.flatMap((item) => item.invariantRefs)),
|
|
339
|
+
],
|
|
340
|
+
scenarioIds: scenarios.map((item) => item.id),
|
|
341
|
+
coverage: scenarios.every((item) => item.coverage === "new_behavior")
|
|
342
|
+
? "new_behavior"
|
|
343
|
+
: scenarios.every((item) => item.coverage === "preservation")
|
|
344
|
+
? "preservation"
|
|
345
|
+
: "both",
|
|
346
|
+
evidence: { mode: "command", commandId: executionRef },
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
const affectedInvariantIds = [
|
|
350
|
+
...new Set(selection.scenarios.flatMap((scenario) => scenario.invariantRefs)),
|
|
351
|
+
];
|
|
352
|
+
let riskClass = "low";
|
|
353
|
+
for (const invariant of proposal.productContract.invariants) {
|
|
354
|
+
if (!affectedInvariantIds.includes(invariant.id))
|
|
355
|
+
continue;
|
|
356
|
+
if (invariant.criticality === "high" ||
|
|
357
|
+
invariant.criticality === "critical") {
|
|
358
|
+
riskClass = "high";
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
if (invariant.criticality === "medium")
|
|
362
|
+
riskClass = "medium";
|
|
363
|
+
}
|
|
364
|
+
if (riskClass !== "low" &&
|
|
365
|
+
!selection.scenarios.some((scenario) => scenario.kind !== "normal")) {
|
|
366
|
+
throw new MillError("OUTCOME_RISK_EVIDENCE_REQUIRED", "A medium- or high-risk outcome requires an approved non-normal executable scenario.", ExitCode.configuration, { riskClass, affectedInvariantIds });
|
|
367
|
+
}
|
|
368
|
+
const impactProposal = {
|
|
369
|
+
schemaVersion: "1",
|
|
370
|
+
id: outcomeSlug,
|
|
371
|
+
productContractDigest,
|
|
372
|
+
outcomeId: selection.outcome.id,
|
|
373
|
+
riskClass,
|
|
374
|
+
acceptanceIds: selection.acceptanceIds,
|
|
375
|
+
affectedInvariantIds,
|
|
376
|
+
uncertainInvariantIds: [],
|
|
377
|
+
surfaces: [
|
|
378
|
+
{
|
|
379
|
+
id: "web-product",
|
|
380
|
+
kind: "user",
|
|
381
|
+
change: selection.outcome.statement,
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
scenarioIds: selection.scenarios.map((scenario) => scenario.id),
|
|
385
|
+
commandIds: [...commandIds].sort(),
|
|
386
|
+
materialDecisions: proposal.productContract.decisions
|
|
387
|
+
.filter((decision) => decision.status === "approved")
|
|
388
|
+
.map((decision) => decision.id),
|
|
389
|
+
unresolved: [],
|
|
390
|
+
exceptions: [],
|
|
391
|
+
approval: null,
|
|
392
|
+
};
|
|
393
|
+
const impact = {
|
|
394
|
+
...impactProposal,
|
|
395
|
+
approval: {
|
|
396
|
+
approvedBy: input.options.approvedBy,
|
|
397
|
+
approvedAt: input.options.approvedAt,
|
|
398
|
+
proposalDigest: canonicalDigest(impactProposal),
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
const product = yaml(proposal.productContract);
|
|
402
|
+
const scenarios = yaml(proposal.scenarioSet);
|
|
403
|
+
const policy = input.existingByPath.get("WORKFLOW.md")?.content ??
|
|
404
|
+
"# Delivery workflow\n\nEvery product change binds approved product, scenario, architecture, impact,\ntask, and exact-base identities. A candidate is committed before the complete\nnative gate and independent review. Only the attended shipper may open a draft\npull request, and a configured human remains the merge authority.\n";
|
|
405
|
+
const blueprint = yaml(proposal.blueprints[0]);
|
|
406
|
+
const plan = yaml({
|
|
407
|
+
schemaVersion: "1",
|
|
408
|
+
productContractDigest,
|
|
409
|
+
outcomes: proposal.productContract.outcomes.map((outcome, index) => ({
|
|
410
|
+
id: outcome.id,
|
|
411
|
+
title: outcome.statement,
|
|
412
|
+
acceptance: (outcome.acceptanceIds ??
|
|
413
|
+
proposal.productContract.acceptance.map((item) => item.id))
|
|
414
|
+
.map((id) => proposal.productContract.acceptance.find((item) => item.id === id)
|
|
415
|
+
?.statement)
|
|
416
|
+
.filter((value) => value !== undefined),
|
|
417
|
+
acceptanceIds: outcome.acceptanceIds ??
|
|
418
|
+
proposal.productContract.acceptance.map((item) => item.id),
|
|
419
|
+
dependsOn: index === 0
|
|
420
|
+
? []
|
|
421
|
+
: [proposal.productContract.outcomes[index - 1]?.id].filter((value) => value !== undefined),
|
|
422
|
+
status: index === 0 ? "ready" : "approved",
|
|
423
|
+
...(index === 0 ? { taskRef: taskPath } : {}),
|
|
424
|
+
})),
|
|
425
|
+
});
|
|
426
|
+
const impactSource = yaml(impact);
|
|
427
|
+
const prdTarget = input.mode === "greenfield"
|
|
428
|
+
? "product/PRD.md"
|
|
429
|
+
: path.normalize(input.options.prdPath).split(path.sep).join("/");
|
|
430
|
+
assertRelativePath(prdTarget, "PRD integration path");
|
|
431
|
+
const builderAllowedPaths = ["app/**", "public/**", "src/**"];
|
|
432
|
+
if (builderAllowedPaths.some((pattern) => {
|
|
433
|
+
const prefix = pattern.slice(0, -3);
|
|
434
|
+
return prdTarget === prefix || prdTarget.startsWith(`${prefix}/`);
|
|
435
|
+
})) {
|
|
436
|
+
throw new MillError("PRD_BUILDER_SCOPE_OVERLAP", "The approved PRD cannot be placed inside the generated builder-writable scope.", ExitCode.configuration, { path: prdTarget });
|
|
437
|
+
}
|
|
438
|
+
const task = taskPacketV2Schema.parse({
|
|
439
|
+
schemaVersion: "2",
|
|
440
|
+
id: outcomeSlug,
|
|
441
|
+
title: selection.outcome.statement,
|
|
442
|
+
objective: selection.outcome.statement,
|
|
443
|
+
riskClass,
|
|
444
|
+
baseRef: "HEAD",
|
|
445
|
+
authority: {
|
|
446
|
+
productContract: {
|
|
447
|
+
path: "product/contract.yaml",
|
|
448
|
+
digest: textDigest(product),
|
|
449
|
+
},
|
|
450
|
+
scenarioSet: {
|
|
451
|
+
path: "quality/scenarios.yaml",
|
|
452
|
+
digest: textDigest(scenarios),
|
|
453
|
+
},
|
|
454
|
+
policy: { path: "WORKFLOW.md", digest: textDigest(policy) },
|
|
455
|
+
impactManifest: { path: impactPath, digest: textDigest(impactSource) },
|
|
456
|
+
},
|
|
457
|
+
contextPaths: [
|
|
458
|
+
"WORKFLOW.md",
|
|
459
|
+
"architecture/blueprint.yaml",
|
|
460
|
+
"product/contract.yaml",
|
|
461
|
+
"quality/scenarios.yaml",
|
|
462
|
+
prdTarget,
|
|
463
|
+
],
|
|
464
|
+
allowedPaths: builderAllowedPaths,
|
|
465
|
+
commandIds: [...commandIds].sort(),
|
|
466
|
+
attestations: [],
|
|
467
|
+
acceptance,
|
|
468
|
+
commit: {
|
|
469
|
+
message: `feat: deliver ${outcomeSlug}\n\nSigned-off-by: ${input.options.authorName} <${input.options.authorEmail}>`,
|
|
470
|
+
authorName: input.options.authorName,
|
|
471
|
+
authorEmail: input.options.authorEmail,
|
|
472
|
+
},
|
|
473
|
+
budget: {
|
|
474
|
+
deadlineSeconds: 3600,
|
|
475
|
+
maxOutputBytes: 2_000_000,
|
|
476
|
+
retryCount: 1,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
const config = millConfigSchema.parse({
|
|
480
|
+
schemaVersion: "1",
|
|
481
|
+
repositoryId: input.options.repositoryId,
|
|
482
|
+
trustCeiling: "build",
|
|
483
|
+
sensitivePaths: [
|
|
484
|
+
".env",
|
|
485
|
+
".env.local",
|
|
486
|
+
".env.production",
|
|
487
|
+
".env.development",
|
|
488
|
+
".env.test",
|
|
489
|
+
".npmrc",
|
|
490
|
+
"config/credentials.json",
|
|
491
|
+
],
|
|
492
|
+
verifier: {
|
|
493
|
+
image: input.recipe.manifest.verifierImage,
|
|
494
|
+
network: "none",
|
|
495
|
+
dependencies: {
|
|
496
|
+
manager: "npm",
|
|
497
|
+
registry: input.recipe.manifest.registry,
|
|
498
|
+
targetPath: "node_modules",
|
|
499
|
+
lockPaths: ["package.json", "package-lock.json"],
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
commands: recipeCommands(input.mode === "greenfield"
|
|
503
|
+
? input.nativeCommands
|
|
504
|
+
: [...commandIds].sort(), input.recipe.manifest.writablePaths),
|
|
505
|
+
});
|
|
506
|
+
const sourcePrdPath = path
|
|
507
|
+
.normalize(input.options.prdPath)
|
|
508
|
+
.split(path.sep)
|
|
509
|
+
.join("/");
|
|
510
|
+
const prdDigest = textDigest(input.authority.prd);
|
|
511
|
+
let prdSourceCount = 0;
|
|
512
|
+
let prdSourceId;
|
|
513
|
+
const sourceManifest = {
|
|
514
|
+
...input.authority.sourceManifest,
|
|
515
|
+
sources: input.authority.sourceManifest.sources.map((source) => {
|
|
516
|
+
const sourceUri = path.normalize(source.uri).split(path.sep).join("/");
|
|
517
|
+
if (sourceUri !== sourcePrdPath)
|
|
518
|
+
return source;
|
|
519
|
+
prdSourceCount += 1;
|
|
520
|
+
prdSourceId = source.id;
|
|
521
|
+
if (source.revision !== prdDigest && source.digest !== prdDigest) {
|
|
522
|
+
throw new MillError("PRD_SOURCE_IDENTITY_MISMATCH", "The PRD source entry must bind the exact approved PRD digest.", ExitCode.configuration, { sourceId: source.id });
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
...source,
|
|
526
|
+
uri: prdTarget,
|
|
527
|
+
revision: prdDigest,
|
|
528
|
+
digest: prdDigest,
|
|
529
|
+
};
|
|
530
|
+
}),
|
|
531
|
+
};
|
|
532
|
+
if (prdSourceCount !== 1) {
|
|
533
|
+
throw new MillError("PRD_SOURCE_IDENTITY_REQUIRED", "Repository integration requires exactly one source entry for the approved PRD.", ExitCode.configuration, { prdPath: input.options.prdPath, matches: prdSourceCount });
|
|
534
|
+
}
|
|
535
|
+
if (prdSourceId === undefined ||
|
|
536
|
+
!proposal.productContract.sourceRefs.includes(prdSourceId)) {
|
|
537
|
+
throw new MillError("PRD_SOURCE_PRODUCT_BINDING_REQUIRED", "The approved product contract must bind the exact PRD source used for repository integration.", ExitCode.configuration, { sourceId: prdSourceId ?? null });
|
|
538
|
+
}
|
|
539
|
+
const values = [
|
|
540
|
+
[prdTarget, input.authority.prd, "generated_once"],
|
|
541
|
+
["product/sources.yaml", yaml(sourceManifest), "generated_once"],
|
|
542
|
+
["product/contract.yaml", product, "generated_once"],
|
|
543
|
+
["quality/scenarios.yaml", scenarios, "generated_once"],
|
|
544
|
+
["architecture/blueprint.yaml", blueprint, "generated_once"],
|
|
545
|
+
["product/plan.yaml", plan, "generated_once"],
|
|
546
|
+
[impactPath, impactSource, "generated_once"],
|
|
547
|
+
[taskPath, yaml(task), "generated_once"],
|
|
548
|
+
["mill.yaml", yaml(config), "mill_only"],
|
|
549
|
+
...(input.mode === "adoption"
|
|
550
|
+
? [["WORKFLOW.md", policy, "generated_once"]]
|
|
551
|
+
: []),
|
|
552
|
+
];
|
|
553
|
+
return values.map(([file, content, ownership]) => ({
|
|
554
|
+
path: file,
|
|
555
|
+
content,
|
|
556
|
+
contentDigest: digest(content),
|
|
557
|
+
ownership,
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
async function existingFile(root, relative) {
|
|
561
|
+
try {
|
|
562
|
+
const information = await lstat(path.join(root, relative));
|
|
563
|
+
if (!information.isFile() ||
|
|
564
|
+
information.isSymbolicLink() ||
|
|
565
|
+
information.size > 8 * 1024 * 1024) {
|
|
566
|
+
throw new MillError("INTEGRATION_FILE_UNSAFE", "An integration target is not a bounded regular file.", ExitCode.configuration, { path: relative });
|
|
567
|
+
}
|
|
568
|
+
const content = await readFile(path.join(root, relative), "utf8");
|
|
569
|
+
return { content, digest: digest(content) };
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
573
|
+
return undefined;
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function existingMap(root, paths) {
|
|
578
|
+
const entries = await Promise.all([...new Set(paths)].map(async (relative) => [relative, await existingFile(root, relative)]));
|
|
579
|
+
return new Map(entries.filter((entry) => entry[1] !== undefined));
|
|
580
|
+
}
|
|
581
|
+
async function commitFile(root, commit, relative, maxOutputBytes = 8 * 1024 * 1024) {
|
|
582
|
+
assertRelativePath(relative, "Committed integration file");
|
|
583
|
+
const gitPath = path.normalize(relative).split(path.sep).join("/");
|
|
584
|
+
assertRelativePath(gitPath, "Normalized committed integration file");
|
|
585
|
+
const listing = await git(root, [
|
|
586
|
+
"ls-tree",
|
|
587
|
+
"-z",
|
|
588
|
+
"--full-tree",
|
|
589
|
+
"--format=%(objectmode)%x09%(objecttype)%x09%(objectname)%x09%(path)",
|
|
590
|
+
commit,
|
|
591
|
+
"--",
|
|
592
|
+
gitPath,
|
|
593
|
+
], undefined, 256 * 1024);
|
|
594
|
+
if (listing.length === 0)
|
|
595
|
+
return undefined;
|
|
596
|
+
const records = listing.split("\0").filter(Boolean);
|
|
597
|
+
if (records.length !== 1) {
|
|
598
|
+
throw new MillError("INTEGRATION_BASE_FILE_UNSAFE", "An exact-base integration path did not resolve to one regular Git blob.", ExitCode.configuration, { path: relative });
|
|
599
|
+
}
|
|
600
|
+
const [mode, type, objectId, listedPath] = records[0]?.split("\t") ?? [];
|
|
601
|
+
if ((mode !== "100644" && mode !== "100755") ||
|
|
602
|
+
type !== "blob" ||
|
|
603
|
+
!/^[a-f0-9]{40,64}$/u.test(objectId ?? "") ||
|
|
604
|
+
listedPath !== gitPath) {
|
|
605
|
+
throw new MillError("INTEGRATION_BASE_FILE_UNSAFE", "An exact-base integration path is not a bounded regular Git file.", ExitCode.configuration, { path: gitPath, mode, type });
|
|
606
|
+
}
|
|
607
|
+
const content = await git(root, ["cat-file", "blob", `${commit}:${gitPath}`], undefined, maxOutputBytes);
|
|
608
|
+
const roundTripObjectId = (await git(root, ["hash-object", "--stdin"], undefined, 256 * 1024, content)).trim();
|
|
609
|
+
if (roundTripObjectId !== objectId) {
|
|
610
|
+
throw new MillError("INTEGRATION_BASE_FILE_UNSAFE", "An exact-base integration file is not losslessly decodable UTF-8 text.", ExitCode.configuration, { path: gitPath });
|
|
611
|
+
}
|
|
612
|
+
return { content, digest: digest(content) };
|
|
613
|
+
}
|
|
614
|
+
async function existingMapAtCommit(root, commit, paths) {
|
|
615
|
+
const entries = await Promise.all([...new Set(paths)].map(async (relative) => [relative, await commitFile(root, commit, relative)]));
|
|
616
|
+
return new Map(entries.filter((entry) => entry[1] !== undefined));
|
|
617
|
+
}
|
|
618
|
+
function lockFile(input) {
|
|
619
|
+
const lock = millLockSchema.parse({
|
|
620
|
+
schemaVersion: "1",
|
|
621
|
+
mill: { package: MILL_PACKAGE, version: MILL_VERSION },
|
|
622
|
+
schemaDigests: {},
|
|
623
|
+
recipe: {
|
|
624
|
+
id: input.recipe.manifest.id,
|
|
625
|
+
version: input.recipe.manifest.version,
|
|
626
|
+
digest: input.recipe.digest,
|
|
627
|
+
},
|
|
628
|
+
integration: {
|
|
629
|
+
mode: input.mode,
|
|
630
|
+
planDigest: input.planDigest,
|
|
631
|
+
baseCommit: input.baseCommit,
|
|
632
|
+
files: input.files.map((file) => ({
|
|
633
|
+
path: file.path,
|
|
634
|
+
ownership: file.ownership,
|
|
635
|
+
templateDigest: file.contentDigest,
|
|
636
|
+
installedDigest: file.contentDigest,
|
|
637
|
+
preexistingDigest: input.existingByPath.get(file.path)?.digest ?? null,
|
|
638
|
+
})),
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
const content = yaml(lock);
|
|
642
|
+
return {
|
|
643
|
+
path: "mill.lock",
|
|
644
|
+
content,
|
|
645
|
+
contentDigest: digest(content),
|
|
646
|
+
ownership: "mill_only",
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function integrationPlanBody(input) {
|
|
650
|
+
return {
|
|
651
|
+
generator: { package: MILL_PACKAGE, version: MILL_VERSION },
|
|
652
|
+
mode: input.mode,
|
|
653
|
+
target: {
|
|
654
|
+
directoryName: input.directoryName,
|
|
655
|
+
canonicalPathDigest: canonicalDigest({
|
|
656
|
+
canonicalPath: input.canonicalTargetPath,
|
|
657
|
+
}),
|
|
658
|
+
baseCommit: input.baseCommit,
|
|
659
|
+
scanDigest: input.scan?.digest ?? null,
|
|
660
|
+
},
|
|
661
|
+
productProposalDigest: input.authority.proposalDigest,
|
|
662
|
+
productContractDigest: canonicalDigest(input.authority.proposal.productContract),
|
|
663
|
+
recipe: {
|
|
664
|
+
id: input.recipe.manifest.id,
|
|
665
|
+
version: input.recipe.manifest.version,
|
|
666
|
+
digest: input.recipe.digest,
|
|
667
|
+
status: "supported",
|
|
668
|
+
verifierImage: input.recipe.manifest.verifierImage,
|
|
669
|
+
},
|
|
670
|
+
approval: {
|
|
671
|
+
approvedBy: input.options.approvedBy,
|
|
672
|
+
approvedAt: input.options.approvedAt,
|
|
673
|
+
},
|
|
674
|
+
files: input.files.map((file) => ({
|
|
675
|
+
path: file.path,
|
|
676
|
+
ownership: file.ownership,
|
|
677
|
+
action: input.existingByPath.has(file.path)
|
|
678
|
+
? "retain_identical"
|
|
679
|
+
: "create",
|
|
680
|
+
contentDigest: file.contentDigest,
|
|
681
|
+
preexistingDigest: input.existingByPath.get(file.path)?.digest ?? null,
|
|
682
|
+
})),
|
|
683
|
+
commandIds: input.recipe.manifest.commands.required,
|
|
684
|
+
networkDisclosure: input.mode === "greenfield"
|
|
685
|
+
? [`HTTPS package installation from ${input.recipe.manifest.registry}`]
|
|
686
|
+
: [],
|
|
687
|
+
baseline: "unverified",
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function integrationPlanIdentity(input) {
|
|
691
|
+
return canonicalDigest({
|
|
692
|
+
schemaVersion: "1",
|
|
693
|
+
...integrationPlanBody(input),
|
|
694
|
+
derivedFiles: [
|
|
695
|
+
{
|
|
696
|
+
path: "mill.lock",
|
|
697
|
+
ownership: "mill_only",
|
|
698
|
+
derivation: "mill-lock-v1-from-plan-digest-and-generator",
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
function integrationPlan(input) {
|
|
704
|
+
const plan = repositoryIntegrationPlanSchema.parse({
|
|
705
|
+
schemaVersion: "1",
|
|
706
|
+
planDigest: input.planDigest,
|
|
707
|
+
...integrationPlanBody(input),
|
|
708
|
+
});
|
|
709
|
+
return {
|
|
710
|
+
plan,
|
|
711
|
+
approvalDigest: input.planDigest,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
async function assertCompatibleAdoption(root, baseCommit, productTitle) {
|
|
715
|
+
const [packageSource, lockSource, currentPackage, currentLock] = await Promise.all([
|
|
716
|
+
commitFile(root, baseCommit, "package.json", 2 * 1024 * 1024),
|
|
717
|
+
commitFile(root, baseCommit, "package-lock.json", 8 * 1024 * 1024),
|
|
718
|
+
existingFile(root, "package.json"),
|
|
719
|
+
existingFile(root, "package-lock.json"),
|
|
720
|
+
]);
|
|
721
|
+
if (packageSource === undefined || lockSource === undefined) {
|
|
722
|
+
throw new MillError("ADOPTION_MANIFEST_INVALID", "Compatible adoption requires package.json and package-lock.json in the exact base commit.", ExitCode.configuration);
|
|
723
|
+
}
|
|
724
|
+
let packageJson;
|
|
725
|
+
let packageLock;
|
|
726
|
+
try {
|
|
727
|
+
packageJson = JSON.parse(packageSource.content);
|
|
728
|
+
packageLock = JSON.parse(lockSource.content);
|
|
729
|
+
JSON.parse(currentPackage?.content ?? "");
|
|
730
|
+
JSON.parse(currentLock?.content ?? "");
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
throw new MillError("ADOPTION_MANIFEST_INVALID", "Compatible adoption requires valid package.json and package-lock.json files.", ExitCode.data, { cause: String(error) });
|
|
734
|
+
}
|
|
735
|
+
if (currentPackage?.digest !== packageSource.digest ||
|
|
736
|
+
currentLock?.digest !== lockSource.digest) {
|
|
737
|
+
throw new MillError("ADOPTION_ORACLE_INCOMPATIBLE", "Compatible adoption requires the working dependency manifests to match the exact base bytes.", ExitCode.configuration);
|
|
738
|
+
}
|
|
739
|
+
const projectName = packageJson.name;
|
|
740
|
+
if (typeof projectName !== "string" || projectName.length === 0) {
|
|
741
|
+
throw new MillError("ADOPTION_MANIFEST_INVALID", "Compatible adoption requires one explicit package name.", ExitCode.configuration);
|
|
742
|
+
}
|
|
743
|
+
const rendered = await renderNodeWebRecipe({ projectName, productTitle });
|
|
744
|
+
const renderedByPath = new Map(rendered.map((file) => [file.path, file]));
|
|
745
|
+
const expectedPackage = JSON.parse(renderedByPath.get("package.json")?.content ?? "{}");
|
|
746
|
+
const expectedLock = JSON.parse(renderedByPath.get("package-lock.json")?.content ?? "{}");
|
|
747
|
+
if (canonicalDigest(packageLock) !== canonicalDigest(expectedLock)) {
|
|
748
|
+
throw new MillError("ADOPTION_ORACLE_INCOMPATIBLE", "Compatible adoption requires the exact qualified dependency lock graph.", ExitCode.configuration, { path: "package-lock.json" });
|
|
749
|
+
}
|
|
750
|
+
for (const field of [
|
|
751
|
+
"type",
|
|
752
|
+
"engines",
|
|
753
|
+
"packageManager",
|
|
754
|
+
"scripts",
|
|
755
|
+
"dependencies",
|
|
756
|
+
"devDependencies",
|
|
757
|
+
]) {
|
|
758
|
+
const actualValue = packageJson[field];
|
|
759
|
+
const expectedValue = expectedPackage[field];
|
|
760
|
+
if (actualValue === undefined ||
|
|
761
|
+
expectedValue === undefined ||
|
|
762
|
+
canonicalDigest(actualValue) !==
|
|
763
|
+
canonicalDigest(expectedValue)) {
|
|
764
|
+
throw new MillError("ADOPTION_ORACLE_INCOMPATIBLE", "Compatible adoption requires exact qualified runtime, dependency, and native-oracle pins.", ExitCode.configuration, { field });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const dependencies = packageJson.dependencies;
|
|
768
|
+
const devDependencies = packageJson.devDependencies;
|
|
769
|
+
const nextVersion = dependencies?.next;
|
|
770
|
+
const reactVersion = dependencies?.react;
|
|
771
|
+
const reactDomVersion = dependencies?.["react-dom"];
|
|
772
|
+
if (nextVersion !== "16.3.4" ||
|
|
773
|
+
reactVersion !== "19.2.8" ||
|
|
774
|
+
reactDomVersion !== "19.2.8" ||
|
|
775
|
+
devDependencies?.typescript !== "6.0.3") {
|
|
776
|
+
throw new MillError("ADOPTION_RECIPE_INCOMPATIBLE", "The first adoption mapping supports only the exact qualified Next.js 16.3.4, React 19.2.8, and TypeScript 6.0.3 tuple.", ExitCode.configuration);
|
|
777
|
+
}
|
|
778
|
+
const scripts = packageJson.scripts;
|
|
779
|
+
const nativeCommands = Object.entries(scripts ?? {})
|
|
780
|
+
.filter((entry) => typeof entry[1] === "string")
|
|
781
|
+
.map(([id]) => id);
|
|
782
|
+
if (!nativeCommands.includes("check")) {
|
|
783
|
+
throw new MillError("ADOPTION_COMMANDS_INCOMPATIBLE", "Compatible adoption requires one existing native check script; Mill will not invent or replace it.", ExitCode.configuration);
|
|
784
|
+
}
|
|
785
|
+
const requiredScripts = {
|
|
786
|
+
"format:check": "prettier --check .",
|
|
787
|
+
lint: "eslint . --max-warnings 0",
|
|
788
|
+
typecheck: "tsc --noEmit",
|
|
789
|
+
"test:unit": "vitest run test/unit",
|
|
790
|
+
"test:integration": "vitest run test/integration",
|
|
791
|
+
"test:browser": "playwright test",
|
|
792
|
+
"test:package": "npm run build && node scripts/check-package.mjs",
|
|
793
|
+
check: "npm run format:check && npm run lint && npm run typecheck && npm run test:unit && npm run test:integration && npm run test:browser && npm run test:package",
|
|
794
|
+
};
|
|
795
|
+
for (const [id, expected] of Object.entries(requiredScripts)) {
|
|
796
|
+
if (scripts?.[id] !== expected) {
|
|
797
|
+
throw new MillError("ADOPTION_ORACLE_INCOMPATIBLE", "Compatible adoption requires the exact qualified native check closure; Mill will not infer an existing repository's oracle graph.", ExitCode.configuration, { commandId: id });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
await Promise.all([
|
|
802
|
+
".node-version",
|
|
803
|
+
".tool-versions",
|
|
804
|
+
"eslint.config.mjs",
|
|
805
|
+
"tsconfig.json",
|
|
806
|
+
"next-env.d.ts",
|
|
807
|
+
"next.config.ts",
|
|
808
|
+
"playwright.config.ts",
|
|
809
|
+
"scripts/check-package.mjs",
|
|
810
|
+
"test/unit/health.test.ts",
|
|
811
|
+
"test/integration/health-route.test.ts",
|
|
812
|
+
"test/browser/home.spec.ts",
|
|
813
|
+
].map(async (relative) => {
|
|
814
|
+
const [actual, current] = await Promise.all([
|
|
815
|
+
commitFile(root, baseCommit, relative, 2 * 1024 * 1024),
|
|
816
|
+
existingFile(root, relative),
|
|
817
|
+
]);
|
|
818
|
+
const expected = renderedByPath.get(relative);
|
|
819
|
+
if (actual?.digest !== expected?.contentDigest ||
|
|
820
|
+
current?.digest !== actual?.digest) {
|
|
821
|
+
throw new MillError("ADOPTION_ORACLE_INCOMPATIBLE", "Compatible adoption requires exact qualified native oracle bytes.", ExitCode.configuration, { path: relative });
|
|
822
|
+
}
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
throw new MillError("ADOPTION_ORACLE_INCOMPATIBLE", "Compatible adoption requires the exact qualified native oracle files.", ExitCode.configuration, { cause: String(error) });
|
|
827
|
+
}
|
|
828
|
+
return { nativeCommands };
|
|
829
|
+
}
|
|
830
|
+
function assertNoConflicts(files, existing) {
|
|
831
|
+
for (const file of files) {
|
|
832
|
+
const current = existing.get(file.path);
|
|
833
|
+
if (current !== undefined && current.digest !== file.contentDigest) {
|
|
834
|
+
throw new MillError("ADOPTION_AUTHORITY_CONFLICT", "Adoption would overwrite an existing authority or generated path.", ExitCode.configuration, { path: file.path });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
export async function planGreenfieldIntegration(input) {
|
|
839
|
+
const { target } = await greenfieldTarget(input.sourceRoot, input.targetDirectory);
|
|
840
|
+
try {
|
|
841
|
+
await lstat(target);
|
|
842
|
+
throw new MillError("GREENFIELD_TARGET_EXISTS", "Greenfield apply requires a target path that does not yet exist.", ExitCode.configuration);
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
const [approved, recipe] = await Promise.all([
|
|
849
|
+
authority(input),
|
|
850
|
+
loadNodeWebRecipe(),
|
|
851
|
+
]);
|
|
852
|
+
assertApprovedRecipe(approved, recipe);
|
|
853
|
+
const template = await renderNodeWebRecipe({
|
|
854
|
+
projectName: path.basename(target),
|
|
855
|
+
productTitle: approved.proposal.productContract.title,
|
|
856
|
+
});
|
|
857
|
+
const existingByPath = new Map();
|
|
858
|
+
const dynamic = dynamicFiles({
|
|
859
|
+
options: input,
|
|
860
|
+
authority: approved,
|
|
861
|
+
mode: "greenfield",
|
|
862
|
+
baseCommit: null,
|
|
863
|
+
recipe,
|
|
864
|
+
nativeCommands: recipe.manifest.commands.native,
|
|
865
|
+
existingByPath,
|
|
866
|
+
});
|
|
867
|
+
const withoutLock = [...template, ...dynamic].sort((left, right) => left.path.localeCompare(right.path));
|
|
868
|
+
const planInput = {
|
|
869
|
+
mode: "greenfield",
|
|
870
|
+
directoryName: input.targetDirectory,
|
|
871
|
+
canonicalTargetPath: target,
|
|
872
|
+
baseCommit: null,
|
|
873
|
+
scan: null,
|
|
874
|
+
authority: approved,
|
|
875
|
+
recipe,
|
|
876
|
+
files: withoutLock,
|
|
877
|
+
existingByPath,
|
|
878
|
+
options: input,
|
|
879
|
+
};
|
|
880
|
+
const planDigest = integrationPlanIdentity(planInput);
|
|
881
|
+
const files = [
|
|
882
|
+
...withoutLock,
|
|
883
|
+
lockFile({
|
|
884
|
+
mode: "greenfield",
|
|
885
|
+
planDigest,
|
|
886
|
+
baseCommit: null,
|
|
887
|
+
recipe,
|
|
888
|
+
files: withoutLock,
|
|
889
|
+
existingByPath,
|
|
890
|
+
}),
|
|
891
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
892
|
+
const planned = integrationPlan({
|
|
893
|
+
...planInput,
|
|
894
|
+
files,
|
|
895
|
+
planDigest,
|
|
896
|
+
});
|
|
897
|
+
return { ...planned, files };
|
|
898
|
+
}
|
|
899
|
+
export async function planAdoptionIntegration(input) {
|
|
900
|
+
const root = await realpath(input.repositoryRoot);
|
|
901
|
+
await assertVisibleIndexState(root);
|
|
902
|
+
const baseCommit = await resolveCommit(root, "HEAD");
|
|
903
|
+
const [approved, recipe, scan] = await Promise.all([
|
|
904
|
+
authority(input),
|
|
905
|
+
loadNodeWebRecipe(),
|
|
906
|
+
scanRepository(root),
|
|
907
|
+
]);
|
|
908
|
+
assertApprovedRecipe(approved, recipe);
|
|
909
|
+
const compatibility = await assertCompatibleAdoption(root, baseCommit, approved.proposal.productContract.title);
|
|
910
|
+
if (scan.gitConfigHazards.length > 0 ||
|
|
911
|
+
scan.truncatedDirectories.length > 0 ||
|
|
912
|
+
scan.symlinksSkipped.length > 0 ||
|
|
913
|
+
scan.secretReferences.length > 0) {
|
|
914
|
+
throw new MillError("ADOPTION_SCAN_BLOCKED", "Adoption requires a complete static scan with no Git hazards, symbolic links, or credential-like files.", ExitCode.configuration, {
|
|
915
|
+
gitConfigHazards: scan.gitConfigHazards,
|
|
916
|
+
truncatedDirectories: scan.truncatedDirectories,
|
|
917
|
+
symlinksSkipped: scan.symlinksSkipped,
|
|
918
|
+
secretReferences: scan.secretReferences,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
const candidatePaths = [
|
|
922
|
+
"WORKFLOW.md",
|
|
923
|
+
"README.md",
|
|
924
|
+
input.prdPath,
|
|
925
|
+
"product/sources.yaml",
|
|
926
|
+
"product/contract.yaml",
|
|
927
|
+
"quality/scenarios.yaml",
|
|
928
|
+
"architecture/blueprint.yaml",
|
|
929
|
+
"product/plan.yaml",
|
|
930
|
+
"mill.yaml",
|
|
931
|
+
"mill.lock",
|
|
932
|
+
];
|
|
933
|
+
const [initialExisting, workingExisting] = await Promise.all([
|
|
934
|
+
existingMapAtCommit(root, baseCommit, candidatePaths),
|
|
935
|
+
existingMap(root, candidatePaths),
|
|
936
|
+
]);
|
|
937
|
+
if (initialExisting.has("mill.yaml") ||
|
|
938
|
+
initialExisting.has("mill.lock") ||
|
|
939
|
+
workingExisting.has("mill.yaml") ||
|
|
940
|
+
workingExisting.has("mill.lock")) {
|
|
941
|
+
throw new MillError("REPOSITORY_ALREADY_MANAGED", "Adoption apply is only for a repository without an existing Mill integration.", ExitCode.configuration);
|
|
942
|
+
}
|
|
943
|
+
const dynamic = dynamicFiles({
|
|
944
|
+
options: input,
|
|
945
|
+
authority: approved,
|
|
946
|
+
mode: "adoption",
|
|
947
|
+
baseCommit,
|
|
948
|
+
recipe,
|
|
949
|
+
nativeCommands: compatibility.nativeCommands,
|
|
950
|
+
existingByPath: initialExisting,
|
|
951
|
+
});
|
|
952
|
+
const [existingByPath, workingDynamic] = await Promise.all([
|
|
953
|
+
existingMapAtCommit(root, baseCommit, dynamic.map((file) => file.path)),
|
|
954
|
+
existingMap(root, dynamic.map((file) => file.path)),
|
|
955
|
+
]);
|
|
956
|
+
assertNoConflicts(dynamic, existingByPath);
|
|
957
|
+
assertNoConflicts(dynamic, workingDynamic);
|
|
958
|
+
const withoutLock = dynamic.sort((left, right) => left.path.localeCompare(right.path));
|
|
959
|
+
const planInput = {
|
|
960
|
+
mode: "adoption",
|
|
961
|
+
directoryName: path.basename(root),
|
|
962
|
+
canonicalTargetPath: root,
|
|
963
|
+
baseCommit,
|
|
964
|
+
scan,
|
|
965
|
+
authority: approved,
|
|
966
|
+
recipe,
|
|
967
|
+
files: withoutLock,
|
|
968
|
+
existingByPath,
|
|
969
|
+
options: input,
|
|
970
|
+
};
|
|
971
|
+
const planDigest = integrationPlanIdentity(planInput);
|
|
972
|
+
const files = [
|
|
973
|
+
...withoutLock,
|
|
974
|
+
lockFile({
|
|
975
|
+
mode: "adoption",
|
|
976
|
+
planDigest,
|
|
977
|
+
baseCommit,
|
|
978
|
+
recipe,
|
|
979
|
+
files: withoutLock,
|
|
980
|
+
existingByPath,
|
|
981
|
+
}),
|
|
982
|
+
].sort((left, right) => left.path.localeCompare(right.path));
|
|
983
|
+
const planned = integrationPlan({
|
|
984
|
+
...planInput,
|
|
985
|
+
files,
|
|
986
|
+
planDigest,
|
|
987
|
+
});
|
|
988
|
+
return { ...planned, files };
|
|
989
|
+
}
|
|
990
|
+
async function writeFiles(root, files) {
|
|
991
|
+
const canonicalRoot = await realpath(root);
|
|
992
|
+
for (const file of files) {
|
|
993
|
+
assertRelativePath(file.path, "Integration file");
|
|
994
|
+
const parentSegments = path
|
|
995
|
+
.dirname(file.path)
|
|
996
|
+
.split(/[\\/]/u)
|
|
997
|
+
.filter((segment) => segment !== "." && segment !== "");
|
|
998
|
+
let parent = canonicalRoot;
|
|
999
|
+
for (const segment of parentSegments) {
|
|
1000
|
+
parent = path.join(parent, segment);
|
|
1001
|
+
try {
|
|
1002
|
+
await mkdir(parent, { mode: 0o700 });
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
if (!(error instanceof Error &&
|
|
1006
|
+
"code" in error &&
|
|
1007
|
+
error.code === "EEXIST")) {
|
|
1008
|
+
throw error;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const information = await lstat(parent);
|
|
1012
|
+
if (information.isSymbolicLink() || !information.isDirectory()) {
|
|
1013
|
+
throw new MillError("INTEGRATION_FILE_ANCESTOR_UNSAFE", "Integration file ancestors must be real directories inside the repository.", ExitCode.configuration, { path: file.path });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const canonicalParent = await realpath(parent);
|
|
1017
|
+
if (!isWithin(canonicalRoot, canonicalParent)) {
|
|
1018
|
+
throw new MillError("INTEGRATION_FILE_ANCESTOR_UNSAFE", "An integration file ancestor resolved outside the repository.", ExitCode.configuration, { path: file.path });
|
|
1019
|
+
}
|
|
1020
|
+
const destination = path.join(canonicalParent, path.basename(file.path));
|
|
1021
|
+
const current = await existingFile(canonicalRoot, file.path);
|
|
1022
|
+
if (current !== undefined) {
|
|
1023
|
+
if (current.digest !== file.contentDigest) {
|
|
1024
|
+
throw new MillError("INTEGRATION_PLAN_DRIFT", "An integration target changed after approval.", ExitCode.configuration, { path: file.path });
|
|
1025
|
+
}
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
await writeFile(destination, file.content, {
|
|
1029
|
+
encoding: "utf8",
|
|
1030
|
+
flag: "wx",
|
|
1031
|
+
mode: 0o600,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async function publishGreenfieldStaging(staging, target, signal) {
|
|
1036
|
+
try {
|
|
1037
|
+
await mkdir(target, { mode: 0o700 });
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
1041
|
+
throw new MillError("GREENFIELD_TARGET_EXISTS", "The approved greenfield target appeared before publication and was preserved.", ExitCode.configuration);
|
|
1042
|
+
}
|
|
1043
|
+
throw error;
|
|
1044
|
+
}
|
|
1045
|
+
try {
|
|
1046
|
+
const entries = await readdir(staging);
|
|
1047
|
+
if (!entries.includes(".git")) {
|
|
1048
|
+
throw new MillError("GREENFIELD_AUTHORITY_MISSING", "The qualified staging repository has no Git authority marker.", ExitCode.data);
|
|
1049
|
+
}
|
|
1050
|
+
for (const entry of entries.filter((entry) => entry !== ".git")) {
|
|
1051
|
+
if (signal.aborted) {
|
|
1052
|
+
throw new MillError("GREENFIELD_CANCELLED", "Greenfield apply was cancelled before repository authority was published.", ExitCode.temporary);
|
|
1053
|
+
}
|
|
1054
|
+
await cp(path.join(staging, entry), path.join(target, entry), {
|
|
1055
|
+
recursive: true,
|
|
1056
|
+
force: false,
|
|
1057
|
+
errorOnExist: true,
|
|
1058
|
+
preserveTimestamps: true,
|
|
1059
|
+
verbatimSymlinks: true,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
// Portable Node has no atomic no-replace directory rename. The absent
|
|
1063
|
+
// target is therefore reserved exclusively, non-authoritative files are
|
|
1064
|
+
// materialized, and the complete Git authority marker is published last
|
|
1065
|
+
// with one same-filesystem rename. Before that boundary this is not a repo.
|
|
1066
|
+
if (signal.aborted) {
|
|
1067
|
+
throw new MillError("GREENFIELD_CANCELLED", "Greenfield apply was cancelled before repository authority was published.", ExitCode.temporary);
|
|
1068
|
+
}
|
|
1069
|
+
await rename(path.join(staging, ".git"), path.join(target, ".git"));
|
|
1070
|
+
}
|
|
1071
|
+
catch (error) {
|
|
1072
|
+
await rm(target, { recursive: true, force: true });
|
|
1073
|
+
throw error;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function git(root, args, signal, maxOutputBytes = 2 * 1024 * 1024, stdin) {
|
|
1077
|
+
const executable = await findTrustedExecutable("git", root);
|
|
1078
|
+
if (executable === undefined) {
|
|
1079
|
+
throw new MillError("GIT_UNAVAILABLE", "A trusted Git executable is required.", ExitCode.unavailable);
|
|
1080
|
+
}
|
|
1081
|
+
const result = await runProcess({
|
|
1082
|
+
executable,
|
|
1083
|
+
args: [
|
|
1084
|
+
"-c",
|
|
1085
|
+
"core.hooksPath=/dev/null",
|
|
1086
|
+
"-c",
|
|
1087
|
+
"core.fsmonitor=false",
|
|
1088
|
+
...args,
|
|
1089
|
+
],
|
|
1090
|
+
cwd: root,
|
|
1091
|
+
env: gitEnvironment,
|
|
1092
|
+
deadlineMs: Date.now() + 60_000,
|
|
1093
|
+
maxOutputBytes,
|
|
1094
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
1095
|
+
...(signal === undefined ? {} : { signal }),
|
|
1096
|
+
});
|
|
1097
|
+
if (result.exitCode !== 0 ||
|
|
1098
|
+
result.timedOut ||
|
|
1099
|
+
result.outputExceeded ||
|
|
1100
|
+
result.cancelled) {
|
|
1101
|
+
throw new MillError("GIT_COMMAND_FAILED", `Git command failed: git ${args[0] ?? ""}`, ExitCode.io, { exitCode: result.exitCode, stderr: result.stderr.slice(0, 2_000) });
|
|
1102
|
+
}
|
|
1103
|
+
return result.stdout;
|
|
1104
|
+
}
|
|
1105
|
+
async function commitIntegration(root, input, message, files, signal) {
|
|
1106
|
+
await git(root, ["add", "--force", "--", ...files.map((file) => file.path)], signal);
|
|
1107
|
+
if (signal?.aborted === true) {
|
|
1108
|
+
throw new MillError("INTEGRATION_CANCELLED", "Repository integration was cancelled before the commit publication boundary.", ExitCode.temporary);
|
|
1109
|
+
}
|
|
1110
|
+
// Once commit publication begins, finish and report the exact result even if
|
|
1111
|
+
// cancellation arrives. Returning failure after advancing the branch would
|
|
1112
|
+
// strand deterministic authority and make a safe retry impossible.
|
|
1113
|
+
await git(root, [
|
|
1114
|
+
"-c",
|
|
1115
|
+
`user.name=${input.authorName}`,
|
|
1116
|
+
"-c",
|
|
1117
|
+
`user.email=${input.authorEmail}`,
|
|
1118
|
+
"-c",
|
|
1119
|
+
"commit.gpgsign=false",
|
|
1120
|
+
"commit",
|
|
1121
|
+
"--no-verify",
|
|
1122
|
+
"--no-gpg-sign",
|
|
1123
|
+
"-m",
|
|
1124
|
+
`${message}\n\nSigned-off-by: ${input.authorName} <${input.authorEmail}>`,
|
|
1125
|
+
]);
|
|
1126
|
+
const commit = (await git(root, ["rev-parse", "HEAD"])).trim();
|
|
1127
|
+
for (const file of files) {
|
|
1128
|
+
const committed = await commitFile(root, commit, file.path);
|
|
1129
|
+
if (committed?.digest !== file.contentDigest) {
|
|
1130
|
+
throw new MillError("INTEGRATION_COMMIT_MISMATCH", "The resulting commit does not contain every exact approved integration file.", ExitCode.data, { path: file.path });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return commit;
|
|
1134
|
+
}
|
|
1135
|
+
function assertPlanApproval(planned, approvalDigest) {
|
|
1136
|
+
if (approvalDigest !== planned.approvalDigest) {
|
|
1137
|
+
throw new MillError("INTEGRATION_APPROVAL_MISMATCH", "Integration apply requires the exact current file-plan approval digest.", ExitCode.configuration, { expected: planned.approvalDigest });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
async function applyGreenfieldIntegrationWithSignal(input) {
|
|
1141
|
+
if (!input.attended) {
|
|
1142
|
+
throw new MillError("ATTENDANCE_REQUIRED", "Greenfield apply requires attended registry and local Git authority.", ExitCode.configuration);
|
|
1143
|
+
}
|
|
1144
|
+
const planned = await planGreenfieldIntegration(input);
|
|
1145
|
+
assertPlanApproval(planned, input.planApprovalDigest);
|
|
1146
|
+
const { target, parent } = await greenfieldTarget(input.sourceRoot, input.targetDirectory);
|
|
1147
|
+
const lockDirectory = path.join(parent, `.mill-new-${createHash("sha256").update(target).digest("hex").slice(0, 16)}.lock`);
|
|
1148
|
+
try {
|
|
1149
|
+
await mkdir(lockDirectory, { mode: 0o700 });
|
|
1150
|
+
}
|
|
1151
|
+
catch (error) {
|
|
1152
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
1153
|
+
throw new MillError("GREENFIELD_APPLY_ACTIVE", "Another apply owns this exact target; remove the lock only after proving no apply is active.", ExitCode.temporary, { target: input.targetDirectory, lockDirectory });
|
|
1154
|
+
}
|
|
1155
|
+
throw error;
|
|
1156
|
+
}
|
|
1157
|
+
let staging;
|
|
1158
|
+
const stateDirectory = repositoryStateDirectory(input.repositoryId, path.join(target, ".git"));
|
|
1159
|
+
let statePreviouslyExisted = true;
|
|
1160
|
+
let preparedDirectory;
|
|
1161
|
+
let preparedReused = false;
|
|
1162
|
+
let targetPublished = false;
|
|
1163
|
+
try {
|
|
1164
|
+
statePreviouslyExisted = await lstat(stateDirectory)
|
|
1165
|
+
.then(() => true)
|
|
1166
|
+
.catch((error) => {
|
|
1167
|
+
if (error instanceof Error &&
|
|
1168
|
+
"code" in error &&
|
|
1169
|
+
error.code === "ENOENT")
|
|
1170
|
+
return false;
|
|
1171
|
+
throw error;
|
|
1172
|
+
});
|
|
1173
|
+
staging = await mkdtemp(path.join(parent, ".mill-new-"));
|
|
1174
|
+
await chmod(staging, 0o700);
|
|
1175
|
+
await writeFiles(staging, planned.files);
|
|
1176
|
+
const configSource = await readFile(path.join(staging, "mill.yaml"), "utf8");
|
|
1177
|
+
const config = millConfigSchema.parse(parseYaml(configSource));
|
|
1178
|
+
const prepared = await prepareDependencySnapshot({
|
|
1179
|
+
root: staging,
|
|
1180
|
+
stateDirectory,
|
|
1181
|
+
config,
|
|
1182
|
+
attended: input.attended,
|
|
1183
|
+
signal: input.signal,
|
|
1184
|
+
});
|
|
1185
|
+
preparedDirectory = prepared.directory;
|
|
1186
|
+
preparedReused = prepared.reused;
|
|
1187
|
+
const task = taskPacketV1Schema.parse({
|
|
1188
|
+
schemaVersion: "1",
|
|
1189
|
+
id: "recipe-bootstrap",
|
|
1190
|
+
title: "Qualify the exact generated repository",
|
|
1191
|
+
objective: "Prove every required native recipe gate before Git authority exists.",
|
|
1192
|
+
riskClass: "low",
|
|
1193
|
+
baseRef: "HEAD",
|
|
1194
|
+
authority: {
|
|
1195
|
+
productContract: {
|
|
1196
|
+
path: "product/contract.yaml",
|
|
1197
|
+
digest: digest("bootstrap"),
|
|
1198
|
+
},
|
|
1199
|
+
scenarioSet: {
|
|
1200
|
+
path: "quality/scenarios.yaml",
|
|
1201
|
+
digest: digest("bootstrap"),
|
|
1202
|
+
},
|
|
1203
|
+
policy: { path: "WORKFLOW.md", digest: digest("bootstrap") },
|
|
1204
|
+
},
|
|
1205
|
+
contextPaths: ["README.md"],
|
|
1206
|
+
allowedPaths: ["app/**"],
|
|
1207
|
+
commandIds: planned.plan.commandIds,
|
|
1208
|
+
acceptance: [
|
|
1209
|
+
{
|
|
1210
|
+
id: "RECIPE-BOOTSTRAP",
|
|
1211
|
+
statement: "All required recipe gates pass.",
|
|
1212
|
+
},
|
|
1213
|
+
],
|
|
1214
|
+
commit: {
|
|
1215
|
+
message: "chore: qualify generated repository",
|
|
1216
|
+
authorName: input.authorName,
|
|
1217
|
+
authorEmail: input.authorEmail,
|
|
1218
|
+
},
|
|
1219
|
+
budget: {
|
|
1220
|
+
deadlineSeconds: 1200,
|
|
1221
|
+
maxOutputBytes: 2_000_000,
|
|
1222
|
+
retryCount: 0,
|
|
1223
|
+
},
|
|
1224
|
+
});
|
|
1225
|
+
const evidence = await verifyDeclaredCommands({
|
|
1226
|
+
root: staging,
|
|
1227
|
+
dependencyRoot: prepared.directory,
|
|
1228
|
+
candidateCommit: "0".repeat(40),
|
|
1229
|
+
config,
|
|
1230
|
+
task,
|
|
1231
|
+
deadlineMs: Date.now() + 20 * 60_000,
|
|
1232
|
+
maxOutputBytes: 2_000_000,
|
|
1233
|
+
signal: input.signal,
|
|
1234
|
+
});
|
|
1235
|
+
if (!evidence.passed) {
|
|
1236
|
+
throw new MillError("RECIPE_QUALIFICATION_FAILED", "A required generated-repository gate failed before Git authority was created.", ExitCode.configuration, { evidence });
|
|
1237
|
+
}
|
|
1238
|
+
await git(staging, ["init", "--initial-branch=main"], input.signal);
|
|
1239
|
+
const commit = await commitIntegration(staging, input, "chore: bootstrap approved product repository", planned.files, input.signal);
|
|
1240
|
+
if (input.signal.aborted) {
|
|
1241
|
+
throw new MillError("GREENFIELD_CANCELLED", "Greenfield apply was cancelled before repository authority was published.", ExitCode.temporary);
|
|
1242
|
+
}
|
|
1243
|
+
await publishGreenfieldStaging(staging, target, input.signal);
|
|
1244
|
+
targetPublished = true;
|
|
1245
|
+
await rm(staging, { recursive: true, force: true });
|
|
1246
|
+
staging = undefined;
|
|
1247
|
+
return {
|
|
1248
|
+
repository: input.targetDirectory,
|
|
1249
|
+
commit,
|
|
1250
|
+
planDigest: planned.approvalDigest,
|
|
1251
|
+
baseline: "unverified",
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
if (staging !== undefined)
|
|
1256
|
+
await rm(staging, { recursive: true, force: true });
|
|
1257
|
+
if (targetPublished) {
|
|
1258
|
+
await rm(target, { recursive: true, force: true });
|
|
1259
|
+
}
|
|
1260
|
+
if (!statePreviouslyExisted) {
|
|
1261
|
+
await rm(stateDirectory, { recursive: true, force: true });
|
|
1262
|
+
}
|
|
1263
|
+
else if (preparedDirectory !== undefined && !preparedReused) {
|
|
1264
|
+
await rm(preparedDirectory, { recursive: true, force: true });
|
|
1265
|
+
}
|
|
1266
|
+
throw error;
|
|
1267
|
+
}
|
|
1268
|
+
finally {
|
|
1269
|
+
await rm(lockDirectory, { recursive: true, force: true });
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
export async function applyGreenfieldIntegration(input) {
|
|
1273
|
+
const signals = processCancellationScope(input.signal);
|
|
1274
|
+
try {
|
|
1275
|
+
return await applyGreenfieldIntegrationWithSignal({
|
|
1276
|
+
...input,
|
|
1277
|
+
signal: signals.signal,
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
finally {
|
|
1281
|
+
signals.dispose();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
async function applyAdoptionIntegrationWithSignal(input) {
|
|
1285
|
+
if (!input.attended) {
|
|
1286
|
+
throw new MillError("ATTENDANCE_REQUIRED", "Adoption apply requires attended isolated-branch authority.", ExitCode.configuration);
|
|
1287
|
+
}
|
|
1288
|
+
const planned = await planAdoptionIntegration(input);
|
|
1289
|
+
assertPlanApproval(planned, input.planApprovalDigest);
|
|
1290
|
+
if (input.signal.aborted) {
|
|
1291
|
+
throw new MillError("ADOPTION_CANCELLED", "Adoption apply was cancelled before creating Git authority.", ExitCode.temporary);
|
|
1292
|
+
}
|
|
1293
|
+
const root = await realpath(input.repositoryRoot);
|
|
1294
|
+
const checkoutStatus = await git(root, ["status", "--porcelain=v1", "--untracked-files=all"], input.signal);
|
|
1295
|
+
if (checkoutStatus.length > 0) {
|
|
1296
|
+
throw new MillError("ADOPTION_CHECKOUT_DIRTY", "Adoption apply requires a clean checkout at the approved exact base.", ExitCode.configuration);
|
|
1297
|
+
}
|
|
1298
|
+
const baseCommit = planned.plan.target.baseCommit;
|
|
1299
|
+
if (baseCommit === null ||
|
|
1300
|
+
(await resolveCommit(root, "HEAD")) !== baseCommit) {
|
|
1301
|
+
throw new MillError("INTEGRATION_BASE_DRIFT", "The adoption base changed after file-plan approval.", ExitCode.configuration);
|
|
1302
|
+
}
|
|
1303
|
+
const branch = `mill/adopt-${planned.approvalDigest.slice(7, 19)}`;
|
|
1304
|
+
const temporary = await mkdtemp(path.join(path.dirname(root), ".mill-adopt-"));
|
|
1305
|
+
await chmod(temporary, 0o700);
|
|
1306
|
+
try {
|
|
1307
|
+
await rm(temporary, { recursive: true });
|
|
1308
|
+
await git(root, ["worktree", "add", "-b", branch, temporary, baseCommit], input.signal);
|
|
1309
|
+
await writeFiles(temporary, planned.files);
|
|
1310
|
+
const commit = await commitIntegration(temporary, input, "chore: adopt Mill delivery contracts", planned.files, input.signal);
|
|
1311
|
+
await git(root, ["worktree", "remove", "--force", temporary]);
|
|
1312
|
+
return {
|
|
1313
|
+
branch,
|
|
1314
|
+
commit,
|
|
1315
|
+
planDigest: planned.approvalDigest,
|
|
1316
|
+
baseline: "unverified",
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
catch (error) {
|
|
1320
|
+
try {
|
|
1321
|
+
await git(root, ["worktree", "remove", "--force", temporary]);
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
await rm(temporary, { recursive: true, force: true });
|
|
1325
|
+
await git(root, ["worktree", "prune", "--expire", "now"]);
|
|
1326
|
+
}
|
|
1327
|
+
try {
|
|
1328
|
+
await git(root, ["update-ref", "-d", `refs/heads/${branch}`, baseCommit]);
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
// Preserve the branch when it contains a committed result or no longer
|
|
1332
|
+
// equals the exact disposable base.
|
|
1333
|
+
}
|
|
1334
|
+
throw error;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
export async function applyAdoptionIntegration(input) {
|
|
1338
|
+
const signals = processCancellationScope(input.signal);
|
|
1339
|
+
try {
|
|
1340
|
+
return await applyAdoptionIntegrationWithSignal({
|
|
1341
|
+
...input,
|
|
1342
|
+
signal: signals.signal,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
finally {
|
|
1346
|
+
signals.dispose();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
export async function planDetach(root) {
|
|
1350
|
+
const source = await safeReadText(root, "mill.lock", 2 * 1024 * 1024);
|
|
1351
|
+
let raw;
|
|
1352
|
+
try {
|
|
1353
|
+
raw = parseYaml(source);
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
throw new MillError("INVALID_MILL_LOCK", "mill.lock is invalid YAML.", ExitCode.data, {
|
|
1357
|
+
cause: String(error),
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
const lock = millLockSchema.parse(raw);
|
|
1361
|
+
if (lock.integration === undefined) {
|
|
1362
|
+
throw new MillError("DETACH_METADATA_UNAVAILABLE", "This integration predates ownership metadata; use documented manual disposition.", ExitCode.configuration);
|
|
1363
|
+
}
|
|
1364
|
+
const remove = ["mill.lock"];
|
|
1365
|
+
const retain = [];
|
|
1366
|
+
const changed = [];
|
|
1367
|
+
for (const file of lock.integration.files) {
|
|
1368
|
+
const current = await existingFile(root, file.path);
|
|
1369
|
+
if (current?.digest !== file.installedDigest) {
|
|
1370
|
+
changed.push(file.path);
|
|
1371
|
+
}
|
|
1372
|
+
if (file.ownership === "mill_only")
|
|
1373
|
+
remove.push(file.path);
|
|
1374
|
+
else
|
|
1375
|
+
retain.push(file.path);
|
|
1376
|
+
}
|
|
1377
|
+
return {
|
|
1378
|
+
mode: "manual",
|
|
1379
|
+
remove: [...new Set(remove)].sort(),
|
|
1380
|
+
retain: [...new Set(retain)].sort(),
|
|
1381
|
+
changed: [...new Set(changed)].sort(),
|
|
1382
|
+
stateAction: "Run millctl state purge only after every local run is reviewed or terminal.",
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
//# sourceMappingURL=integration.js.map
|