@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +591 -274
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +264 -101
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +3 -0
- package/src/constants.ts +1 -1
- package/src/mcp/core.ts +23 -24
- package/src/mcp/server.ts +11 -12
- package/src/project-context.ts +9 -1
- package/src/project-initializer.ts +4 -4
- package/src/session-rollover.ts +10 -0
- 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
|
|
312
|
-
if (
|
|
313
|
-
|
|
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 = [
|
|
1481
|
-
const unknown =
|
|
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": "
|
|
6
|
+
"runner.spec.mjs": "25910031e9d9b413843a19c2ac5496b2109546e40283bb68dd9aa2ac9b97b743",
|
|
7
7
|
"playwright.config.mjs": "301c0b97e229f83e6934994718b11b93a1da5d46ec606cfd11a8292e9d622b02",
|
|
8
8
|
"reporter.mjs": "5c91d68c50a93603f583cf82345d427bcd9ce9a7cfb78fd6d0989abddbd55275",
|
|
9
|
-
"explore-host.mjs": "
|
|
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
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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",
|
package/src/project-context.ts
CHANGED
|
@@ -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
|
|
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 >
|
|
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
|
+
}
|