@guanlin4924/devflow-pi 0.1.97 → 0.1.99

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.
Files changed (25) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +591 -274
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
  13. package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
  14. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +264 -101
  15. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
  16. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
  17. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
  18. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +3 -0
  19. package/src/constants.ts +1 -1
  20. package/src/mcp/core.ts +23 -24
  21. package/src/mcp/server.ts +11 -12
  22. package/src/project-context.ts +9 -1
  23. package/src/project-initializer.ts +4 -4
  24. package/src/session-rollover.ts +10 -0
  25. package/src/workflow-diagnosis.ts +407 -0
@@ -227,6 +227,7 @@ var API_ACTIONS = new Set(["request"]);
227
227
  var BUILTIN_VARIABLES = new Set(["execution_id", "namespace"]);
228
228
  var VAR_REF = /\$\{([^}]*)\}/g;
229
229
  var VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
230
+ var SECRET_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"]);
230
231
  var FRAGILE_CSS = [
231
232
  /el-table_\d+(?:_column_\d+)?/,
232
233
  /:nth-(?:last-)?(?:child|of-type)\(/,
@@ -245,6 +246,15 @@ function collectRefs(value, out = new Set) {
245
246
  }
246
247
  return out;
247
248
  }
249
+ function unterminatedRefs(value, at, issues) {
250
+ if (typeof value === "string") {
251
+ if (value.replace(VAR_REF, "").includes("${"))
252
+ issues.push({ path: at, message: "unterminated variable reference" });
253
+ } else if (value && typeof value === "object") {
254
+ for (const [key, child] of Object.entries(value))
255
+ unterminatedRefs(child, `${at}/${key}`, issues);
256
+ }
257
+ }
248
258
  function validateDsl(dsl, schema) {
249
259
  const issues = new SchemaValidator(schema).validate(dsl);
250
260
  if (issues.length)
@@ -261,6 +271,7 @@ function semanticIssues(dsl) {
261
271
  const ids = new Set;
262
272
  const screenshotNames = new Set;
263
273
  const defined = new Set([...BUILTIN_VARIABLES, ...Object.keys(dsl.variables)]);
274
+ const sensitive = new Set(Object.entries(dsl.variables).filter(([, variable]) => variable.secret !== undefined || variable.sensitive === true).map(([name]) => name));
264
275
  for (const name of Object.keys(dsl.variables)) {
265
276
  if (BUILTIN_VARIABLES.has(name))
266
277
  issues.push({ path: `/variables/${name}`, message: "shadows a built-in variable" });
@@ -270,6 +281,7 @@ function semanticIssues(dsl) {
270
281
  if (refs.size)
271
282
  issues.push({ path: `/variables/${name}`, message: "variable values must be literals" });
272
283
  }
284
+ unterminatedRefs(dsl.data.namespace, "/data/namespace", issues);
273
285
  for (const ref of collectRefs(dsl.data.namespace)) {
274
286
  if (ref !== "execution_id")
275
287
  issues.push({ path: "/data/namespace", message: `namespace may only reference \${execution_id}, got \${${ref}}` });
@@ -283,6 +295,8 @@ function semanticIssues(dsl) {
283
295
  const allowed = step.driver === "api" ? API_ACTIONS : UI_ACTIONS;
284
296
  if (!allowed.has(step.action))
285
297
  issues.push({ path: `${at}/action`, message: `unknown action ${step.action}` });
298
+ const payload = Object.fromEntries(Object.entries(step).filter(([key]) => !["id", "driver", "action", "covers", "extract", "assertions", "retry"].includes(key)));
299
+ unterminatedRefs(payload, at, issues);
286
300
  for (const ref of collectRefs(step)) {
287
301
  if (!VAR_NAME.test(ref))
288
302
  issues.push({ path: at, message: `malformed variable reference \${${ref}}` });
@@ -298,6 +312,7 @@ function semanticIssues(dsl) {
298
312
  if (step.driver === "api") {
299
313
  const assertions = step.assertions ?? [];
300
314
  assertions.forEach((a, i) => {
315
+ unterminatedRefs(a.value, `${at}/assertions/${i}/value`, issues);
301
316
  const needsValue = a.op !== "exists" && a.op !== "not_exists";
302
317
  if (needsValue && !("value" in a))
303
318
  issues.push({ path: `${at}/assertions/${i}`, message: `op ${a.op} requires value` });
@@ -308,11 +323,21 @@ function semanticIssues(dsl) {
308
323
  if (step.retry && (method === "POST" || method === "PATCH") && step.idempotent !== true) {
309
324
  issues.push({ path: `${at}/retry`, message: `${method} retry requires idempotent=true` });
310
325
  }
311
- for (const name of Object.keys(step.extract ?? {})) {
312
- if (BUILTIN_VARIABLES.has(name) || name in dsl.variables) {
313
- issues.push({ path: `${at}/extract/${name}`, message: "extract may not overwrite a declared or built-in variable" });
326
+ for (const [header, value] of Object.entries(step.headers ?? {})) {
327
+ if (!SECRET_HEADERS.has(header.toLowerCase()))
328
+ continue;
329
+ const refs = collectRefs(value);
330
+ if (!refs.size || [...refs].some((name) => !sensitive.has(name))) {
331
+ issues.push({ path: `${at}/headers/${header}`, message: "must be built only from secret or sensitive variables" });
332
+ }
333
+ }
334
+ for (const [name, spec] of Object.entries(step.extract ?? {})) {
335
+ if (defined.has(name)) {
336
+ issues.push({ path: `${at}/extract/${name}`, message: "extract may not overwrite an existing variable" });
314
337
  }
315
338
  defined.add(name);
339
+ if (spec.sensitive === true || spec.header !== undefined && spec.sensitive !== false)
340
+ sensitive.add(name);
316
341
  }
317
342
  } else if (step.target && typeof step.target === "object") {
318
343
  semanticTargetIssues(step.target, `${at}/target`, issues, step.action);
@@ -769,6 +794,17 @@ async function executeApi(step, ctx) {
769
794
  return details;
770
795
  }
771
796
 
797
+ // plugins/devflow-quality/runtime/playwright/src/text-match.ts
798
+ function normalizeText(value, normalizeWhitespace, caseSensitive) {
799
+ let out = normalizeWhitespace ? value.replace(/\s+/g, " ").trim() : value;
800
+ if (!caseSensitive)
801
+ out = out.toLowerCase();
802
+ return out;
803
+ }
804
+ function textMatches(actual, expected, match) {
805
+ return match === "exact" ? actual === expected : actual.includes(expected);
806
+ }
807
+
772
808
  // plugins/devflow-quality/runtime/playwright/src/ui.ts
773
809
  import path from "node:path";
774
810
  import fs from "node:fs";
@@ -995,15 +1031,6 @@ function resolveNavigationUrl(raw, ctx) {
995
1031
  throw new StepFailure("blocked", "invalid_url", `goto url must be absolute or start with '/': ${ctx.redactor.text(url)}`);
996
1032
  }
997
1033
  }
998
- function normalizeText(value, normalizeWhitespace, caseSensitive) {
999
- let out = normalizeWhitespace ? value.replace(/\s+/g, " ").trim() : value;
1000
- if (!caseSensitive)
1001
- out = out.toLowerCase();
1002
- return out;
1003
- }
1004
- function textMatches(actual, expected, match) {
1005
- return match === "exact" ? actual === expected : actual.includes(expected);
1006
- }
1007
1034
  async function executeUi(step, ctx) {
1008
1035
  const page = ctx.page;
1009
1036
  const defaultTimeout = step.action === "goto" ? DEFAULT_NAVIGATION_TIMEOUT_MS : DEFAULT_UI_TIMEOUT_MS;
@@ -1477,8 +1504,8 @@ async function executeRun(page, runtimeDir) {
1477
1504
  } catch {
1478
1505
  return outcome({ status: "invalid", error_code: "dsl_not_json", dsl_sha256: dslSha, started_at: startedAt }, redactor);
1479
1506
  }
1480
- const allSteps = [...dsl?.data?.setup ?? [], ...dsl?.steps ?? [], ...dsl?.data?.cleanup ?? []];
1481
- const unknown = Array.isArray(allSteps) && allSteps.some((s) => {
1507
+ const allSteps = [dsl?.data?.setup, dsl?.steps, dsl?.data?.cleanup].flatMap((steps) => Array.isArray(steps) ? steps : []);
1508
+ const unknown = allSteps.some((s) => {
1482
1509
  const set = s?.driver === "api" ? API_ACTIONS : s?.driver === "ui" ? UI_ACTIONS : null;
1483
1510
  return !set || !set.has(s?.action);
1484
1511
  });
@@ -3,10 +3,10 @@
3
3
  "runtime_version": "0.1.0",
4
4
  "playwright_version": "1.63.0",
5
5
  "files": {
6
- "runner.spec.mjs": "abf0c5c9aff43b4f233de4c44db0946001c970758ef63ab861fad1d125c1a6fc",
6
+ "runner.spec.mjs": "25910031e9d9b413843a19c2ac5496b2109546e40283bb68dd9aa2ac9b97b743",
7
7
  "playwright.config.mjs": "301c0b97e229f83e6934994718b11b93a1da5d46ec606cfd11a8292e9d622b02",
8
8
  "reporter.mjs": "5c91d68c50a93603f583cf82345d427bcd9ce9a7cfb78fd6d0989abddbd55275",
9
- "explore-host.mjs": "0973ff6be3b4a3cdd332b90cc6c09ebe96b4ea98bc1e1273be6c09b9bf79efd9",
9
+ "explore-host.mjs": "686474bcbe9af1151849e4e25266d60e01e8a49162511c49e071c5ec60a65979",
10
10
  "devflow-test-dsl-v1.schema.json": "df07e55507697a70df214ae820403b530b04aeb04a12d0aa85715bf5e528e194"
11
11
  }
12
12
  }
@@ -10,6 +10,8 @@ description: "Use after code review, as the binding-phase prerequisite of run-ac
10
10
  ## Hard Rules
11
11
 
12
12
  - `devflow_explore` 只能辅助回归设计或本步骤的 DSL 绑定;它产生的视图、断言和 draft 都不是验收证据。只能消费经人工检查的草稿,仍须运行本技能的编译、目录校验、独立审查和 `execution_plan` 审批流程。
13
+ - 草稿到执行只有一条路径:①人工检查草稿并处理 `review_items`;②回归流程先由 `regression-test-design` 冻结 TC(生成 `tests.json`,将草稿 `tc_id` 替换为冻结编号);③本 `regression-dsl` 步骤运行 `compile_dsl.py <草稿或 case-descriptions 路径>`(没有 `--draft` 参数),通过校验后提交 `execution_plan` 批准;④由 `run-acceptance-tests` 执行。草稿不能直接执行,也不能用内置浏览器重放代替验收执行。冻结 TC 若提示需要激活合同,应通过 `/devflow control` →“启动当前可执行步骤”进入。
14
+ - DSL 的 `screenshot` 步骤可以留存截图,截图只供用户在本地查看。
13
15
  - 探索草稿中的 secret 变量只能保持 `${KEY}` 引用,禁止读取、回填或输出凭据值;页面中的个人信息也不能被当作通用 oracle。
14
16
 
15
17
  - MUST NOT hardcode `platform-backend` or `platform-frontend`.
@@ -15,6 +15,8 @@ description: "Use from an idle project to derive a user-confirmed, frozen regres
15
15
 
16
16
  需要补充当前页面行为时,可在已批准的测试环境中调用 `devflow_explore`(start/observe/act/assert/draft)。探索只用于发现和录制草稿,不是验收证据;密码、密钥和凭据值不得写入描述、日志或草稿。草稿必须由后续 `workflow-generate-mainflow-cases` 编译、校验并经用户批准后才能执行。
17
17
 
18
+ 草稿到执行的唯一合法路径是:①人工检查草稿并处理 `review_items`;②在回归流程的 `regression-test-design` 中冻结 TC(生成 `tests.json`,并将草稿中的 `tc_id` 替换为冻结后的编号);③在 `regression-dsl` 步骤使用 `compile_dsl.py <草稿或 case-descriptions 路径>` 编译(没有 `--draft` 参数),通过校验后提交 `execution_plan` 批准;④由 `run-acceptance-tests` 执行。草稿不能直接执行,也不能用内置浏览器重放代替验收执行。冻结 TC 若提示需要激活合同,应通过 `/devflow control` →“启动当前可执行步骤”进入。
19
+
18
20
  1. 读取 `devflow_requirement_graph(action="inspect")` 和 `.devflow/planning/code-index/`,确认当前功能、代码索引 revision、入口、接口、数据流和调用关系。
19
21
  2. 阅读实际前端与后端源码、路由/控制器、服务、权限规则、数据访问和已有测试;代码只能用于发现真实功能点与可执行入口,不能把实现细节直接当作预期。
20
22
  3. 阅读 `.devflow/planning/test-environments/*.json` 中的环境声明;需要登录或测试 token 时,只记录 `secret_env` 的变量名和所需前置,不读取或输出值。
@@ -67,3 +69,4 @@ description: "Use from an idle project to derive a user-confirmed, frozen regres
67
69
  - 不读取或使用 agent 交付自述、`SUMMARY.md` 或 git diff 来决定测试范围。
68
70
  - 不写入 `acceptance-results.json`,不生成人工通过结果,不确认执行计划或环境批准。
69
71
  - 不向模型上下文、文档或日志输出测试凭据值。
72
+ - DSL 的 `screenshot` 步骤可以留存截图,截图只供用户在本地查看。
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.97";
2
+ export const PRODUCT_VERSION = "0.1.99";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
package/src/mcp/core.ts CHANGED
@@ -45,33 +45,32 @@ export const runPython: CoreRunner = async (script, args, cwd, stdin, timeoutMs
45
45
  const timeoutPromise = new Promise<"timeout">(resolve => {
46
46
  timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs);
47
47
  });
48
- const exitPromise = child.exited.then(exitCode => ({ exitCode }));
49
- const winner = await Promise.race([exitPromise, timeoutPromise]);
50
- if (winner === "timeout") {
51
- timedOut = true;
52
- terminateProcessGroup(child.pid, "SIGTERM");
53
- try { child.kill("SIGTERM"); } catch { /* already exited */ }
54
- await new Promise(resolve => setTimeout(resolve, TERMINATION_GRACE_MS));
55
- terminateProcessGroup(child.pid, "SIGKILL");
56
- try { child.kill("SIGKILL"); } catch { /* already exited */ }
48
+ // The parent can exit while descendants still hold its output pipes open.
49
+ // Keep the deadline active until both exit and output collection complete.
50
+ const completion = Promise.all([child.exited, stdoutPromise, stderrPromise])
51
+ .then(([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr }));
52
+ try {
53
+ const winner = await Promise.race([completion, timeoutPromise]);
54
+ if (winner === "timeout") {
55
+ timedOut = true;
56
+ terminateProcessGroup(child.pid, "SIGTERM");
57
+ try { child.kill("SIGTERM"); } catch { /* already exited */ }
58
+ await new Promise(resolve => setTimeout(resolve, TERMINATION_GRACE_MS));
59
+ terminateProcessGroup(child.pid, "SIGKILL");
60
+ try { child.kill("SIGKILL"); } catch { /* already exited */ }
61
+ return {
62
+ exitCode: null,
63
+ stdout: await settleText(stdoutPromise, 100),
64
+ stderr: await settleText(stderrPromise, 100),
65
+ timedOut,
66
+ };
67
+ }
68
+
69
+ return { ...winner, timedOut };
70
+ } finally {
57
71
  if (progressTimer !== undefined) clearInterval(progressTimer);
58
72
  clearTimeout(timeoutHandle);
59
- return {
60
- exitCode: null,
61
- stdout: await settleText(stdoutPromise, 100),
62
- stderr: await settleText(stderrPromise, 100),
63
- timedOut,
64
- };
65
73
  }
66
-
67
- if (progressTimer !== undefined) clearInterval(progressTimer);
68
- clearTimeout(timeoutHandle);
69
- return {
70
- exitCode: winner.exitCode,
71
- stdout: await stdoutPromise,
72
- stderr: await stderrPromise,
73
- timedOut,
74
- };
75
74
  };
76
75
 
77
76
  export async function invokeCore(
package/src/mcp/server.ts CHANGED
@@ -114,20 +114,19 @@ export async function createMcpServer(options: McpServerOptions) {
114
114
  if (tool === "run_feature" && running) throw new McpError("mcp_busy", "another run_feature is in progress");
115
115
 
116
116
  if (tool === "run_feature") {
117
- const bindingFile = path.join(auth.project, ".devflow/planning/features", String(feature), "acceptance-test-bindings.json");
118
- let bindings: unknown;
119
- try {
120
- bindings = JSON.parse(await readFile(bindingFile, "utf8"));
121
- } catch {
122
- throw new McpError("mcp_input_invalid", "acceptance-test-bindings.json is missing or invalid");
123
- }
124
- const profiles = collectEnvironmentProfiles(bindings);
125
- if ([...profiles].some(profile => !auth.grant.environment_profiles.includes(profile))) {
126
- throw new McpError("mcp_environment_not_allowed", "environment profile is not authorized");
127
- }
128
-
129
117
  running = true;
130
118
  try {
119
+ const bindingFile = path.join(auth.project, ".devflow/planning/features", String(feature), "acceptance-test-bindings.json");
120
+ let bindings: unknown;
121
+ try {
122
+ bindings = JSON.parse(await readFile(bindingFile, "utf8"));
123
+ } catch {
124
+ throw new McpError("mcp_input_invalid", "acceptance-test-bindings.json is missing or invalid");
125
+ }
126
+ const profiles = collectEnvironmentProfiles(bindings);
127
+ if ([...profiles].some(profile => !auth.grant.environment_profiles.includes(profile))) {
128
+ throw new McpError("mcp_environment_not_allowed", "environment profile is not authorized");
129
+ }
131
130
  const runResult = await invokeCore(
132
131
  runner,
133
132
  "run",
@@ -138,6 +138,10 @@ export async function assertPlanningBoundary(root: string): Promise<string> {
138
138
  }
139
139
 
140
140
  export async function loadProjectContext(root: string): Promise<ProjectContext> {
141
+ return readProjectContext(root, false);
142
+ }
143
+
144
+ async function readProjectContext(root: string, registerWorkspace: boolean): Promise<ProjectContext> {
141
145
  const canonicalRoot = await canonicalDirectory(root);
142
146
  const manifestPath = projectLayout(canonicalRoot).manifestPath;
143
147
  const manifestValue = JSON.parse(await readFile(manifestPath, "utf8")) as Record<string, unknown>;
@@ -150,6 +154,10 @@ export async function loadProjectContext(root: string): Promise<ProjectContext>
150
154
  const manifest = validateManifest(manifestValue, manifestPath);
151
155
  await mkdir(projectLayout(canonicalRoot).planningRoot, { recursive: true });
152
156
  const planningRoot = await assertPlanningBoundary(canonicalRoot);
157
+ if (registerWorkspace) {
158
+ const store = await InitializationStore.open({ root: canonicalRoot, manifest });
159
+ try { await store.registerWorkspace(); } finally { store.close(); }
160
+ }
153
161
  if (!(await exists(projectControlDatabasePath(manifest.projectId)))) throw new Error("Project initialization database is not registered; run explicit project initialization first");
154
162
  return attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot });
155
163
  }
@@ -198,7 +206,7 @@ export async function initializeProject(root: string): Promise<{ context: Projec
198
206
  await mkdir(devflowDir, { recursive: true });
199
207
  await assertPlanningBoundary(canonicalRoot);
200
208
  if (await exists(manifestPath)) {
201
- const context = await loadProjectContext(canonicalRoot);
209
+ const context = await readProjectContext(canonicalRoot, true);
202
210
  await ensureTestRuntimeLayout(canonicalRoot, context.planningRoot);
203
211
  return { context, created: false };
204
212
  }
@@ -42,7 +42,7 @@ function source(file: SourceFile, kind: FactKind, objectKey: string, value: Reco
42
42
  return { kind, objectKey, repository: file.repository, value, evidenceLevel: "observed", sourcePath: file.path, sourceLine: line, sourceFingerprint: file.contentHash, extractor, confidence: "heuristic" };
43
43
  }
44
44
 
45
- async function walk(repository: string, repositoryRoot: string, includes: string[], excludes: string[], diagnostics: ScanDiagnostic[], relative = ""): Promise<SourceFile[]> {
45
+ async function walk(repository: string, repositoryRoot: string, includes: string[], excludes: string[], diagnostics: ScanDiagnostic[], maxFileSize: number, relative = ""): Promise<SourceFile[]> {
46
46
  const directory = path.join(repositoryRoot, relative);
47
47
  let entries;
48
48
  try { entries = await readdir(directory, { withFileTypes: true }); }
@@ -54,11 +54,11 @@ async function walk(repository: string, repositoryRoot: string, includes: string
54
54
  const child = path.join(relative, entry.name);
55
55
  const normalized = child.replaceAll("\\", "/");
56
56
  if (excludes.some(pattern => globMatches(normalized, pattern) || globMatches(`${normalized}/`, pattern))) { if (diagnostics.length < 1000) diagnostics.push({ repository, path: normalized, reason: "excluded" }); continue; }
57
- if (entry.isDirectory()) { result.push(...await walk(repository, repositoryRoot, includes, excludes, diagnostics, child)); continue; }
57
+ if (entry.isDirectory()) { result.push(...await walk(repository, repositoryRoot, includes, excludes, diagnostics, maxFileSize, child)); continue; }
58
58
  if (!entry.isFile()) continue;
59
59
  if (includes.length && !includes.some(pattern => globMatches(normalized, pattern))) continue;
60
60
  const absolute = path.join(repositoryRoot, child); const info = await stat(absolute);
61
- if (info.size > 2 * 1024 * 1024) { diagnostics.push({ repository, path: normalized, reason: "too_large" }); continue; }
61
+ if (info.size > maxFileSize) { diagnostics.push({ repository, path: normalized, reason: "too_large" }); continue; }
62
62
  let bytes: Buffer;
63
63
  try { bytes = await readFile(absolute); }
64
64
  catch (error) { diagnostics.push({ repository, path: normalized, reason: "unreadable", message: error instanceof Error ? error.message : String(error) }); continue; }
@@ -84,7 +84,7 @@ async function scan(context: ProjectContext, config: ScanConfigInput): Promise<{
84
84
  const root = path.resolve(context.root, repository.relativePath);
85
85
  if (!path.isAbsolute(repository.relativePath) && root !== context.root && !root.startsWith(`${context.root}${path.sep}`)) throw new Error(`Repository escapes project root: ${repository.relativePath}`);
86
86
  repositoryVersions.push({ repository: repository.name, revision: await gitRevision(root) });
87
- all.push(...await walk(repository.name, root, includes, excludes, diagnostics));
87
+ all.push(...await walk(repository.name, root, includes, excludes, diagnostics, config.maxFileSize ?? 2 * 1024 * 1024));
88
88
  }
89
89
  return { files: all.sort((a, b) => `${a.repository}/${a.path}`.localeCompare(`${b.repository}/${b.path}`)), diagnostics, repositoryVersions };
90
90
  }
@@ -0,0 +1,10 @@
1
+ /** The current prepared revision differs from the immutable handoff's source revision after recovery. */
2
+ export function preparedRolloverRevision(state: Record<string, unknown>): number | undefined {
3
+ const rollover = state.rollover as Record<string, unknown> | undefined;
4
+ if (rollover?.status !== "prepared") return undefined;
5
+ const revision = rollover.prepared_state_revision ?? (
6
+ typeof rollover.expected_state_revision === "number" ? rollover.expected_state_revision + 1 : undefined
7
+ );
8
+ return typeof revision === "number" && Number.isSafeInteger(revision) && revision === state.revision
9
+ ? revision : undefined;
10
+ }