@pioneer_zmc/dsh-workbench 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/README.zh.md +100 -0
- package/client.js +122 -0
- package/cordis.patch.yml +8 -0
- package/dist/check-building.js +1669 -0
- package/dist/check-room.js +980 -0
- package/dist/index.js +2873 -0
- package/package.json +60 -0
- package/skills/specdev-building/SKILL.md +85 -0
- package/skills/specdev-building/assets/layouts/court.json +53 -0
- package/skills/specdev-building/assets/layouts/row.json +48 -0
- package/skills/specdev-building/assets/layouts/single.json +27 -0
- package/skills/specdev-building/assets/layouts/tower-9.json +71 -0
- package/skills/specdev-building/assets/layouts/tower.json +59 -0
- package/skills/specdev-building/references/blueprint-contract.md +126 -0
- package/skills/specdev-building/references/diagnostics.md +101 -0
- package/skills/specdev-building/references/layouts.md +112 -0
- package/skills/specdev-business/SKILL.md +91 -0
- package/skills/specdev-business/assets/plan-template.md +66 -0
- package/skills/specdev-business/assets/spec-template.md +111 -0
- package/skills/specdev-business/assets/tasks-template.md +36 -0
- package/skills/specdev-business/references/change.md +117 -0
- package/skills/specdev-business/references/evidence-review.md +125 -0
- package/skills/specdev-business/references/implementation.md +67 -0
- package/skills/specdev-business/references/project-contract.md +211 -0
- package/skills/specdev-business/references/source-map.md +110 -0
- package/skills/specdev-business/references/specification.md +93 -0
- package/skills/specdev-business/references/workflow.md +126 -0
- package/skills/specdev-room/SKILL.md +77 -0
- package/skills/specdev-room/assets/intro-room.json +14 -0
- package/skills/specdev-room/references/diagnostics.md +21 -0
- package/skills/specdev-room/references/room-contract.md +59 -0
- package/vendor/archify-renderer/VERSION.md +52 -0
- package/vendor/archify-renderer/archify/LICENSE +22 -0
- package/vendor/archify-renderer/archify/THIRD_PARTY_NOTICES.md +56 -0
- package/vendor/archify-renderer/archify/assets/template.html +14787 -0
- package/vendor/archify-renderer/archify/bin/archify.mjs +1990 -0
- package/vendor/archify-renderer/archify/renderers/shared/brand-marks.mjs +563 -0
- package/vendor/archify-renderer/archify/renderers/shared/cli.mjs +218 -0
- package/vendor/archify-renderer/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/vendor/archify-renderer/archify/renderers/shared/diagnostics.mjs +127 -0
- package/vendor/archify-renderer/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-validators.mjs +13 -0
- package/vendor/archify-renderer/archify/renderers/shared/geometry.mjs +1423 -0
- package/vendor/archify-renderer/archify/renderers/shared/i18n.mjs +594 -0
- package/vendor/archify-renderer/archify/renderers/shared/legend.mjs +217 -0
- package/vendor/archify-renderer/archify/renderers/shared/output-path.mjs +321 -0
- package/vendor/archify-renderer/archify/renderers/shared/repository-evidence.mjs +235 -0
- package/vendor/archify-renderer/archify/renderers/shared/text-fit.mjs +49 -0
- package/vendor/archify-renderer/archify/renderers/shared/utils.mjs +232 -0
- package/vendor/archify-renderer/archify/renderers/shared/validator.mjs +86 -0
- package/vendor/archify-renderer/archify/renderers/workflow/render-workflow.mjs +35 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/vendor/archify-renderer/archify/schemas/common.schema.json +115 -0
- package/vendor/archify-renderer/archify/schemas/workflow.schema.json +428 -0
- package/vendor/archify-renderer/archify/scripts/check-render-output.mjs +835 -0
- package/vendor/archify-renderer/shims/process.mjs +18 -0
- package/vendor/archify-renderer/stubs/child_process.mjs +2 -0
- package/vendor/archify-renderer/stubs/crypto.mjs +3 -0
- package/vendor/archify-renderer/stubs/dns-promises.mjs +2 -0
- package/vendor/archify-renderer/stubs/fs.mjs +3 -0
- package/vendor/archify-renderer/stubs/http.mjs +3 -0
- package/vendor/archify-renderer/stubs/https.mjs +3 -0
- package/vendor/archify-renderer/stubs/net.mjs +3 -0
- package/vendor/archify-renderer/stubs/path.mjs +3 -0
- package/vendor/archify-renderer/stubs/url.mjs +3 -0
- package/web/assets/building-kit.js +1157 -0
- package/web/assets/building-look.js +185 -0
- package/web/assets/building-plan.js +495 -0
- package/web/assets/building-scene.js +529 -0
- package/web/assets/building-walk.js +248 -0
- package/web/assets/building.js +859 -0
- package/web/assets/business.js +124 -0
- package/web/assets/chart-link.js +165 -0
- package/web/assets/common.js +302 -0
- package/web/assets/details.js +197 -0
- package/web/assets/evidence.js +30 -0
- package/web/assets/furniture/SOURCES.md +24 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/asset.json +25 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/model.js +116 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/asset.json +61 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/model.js +56 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/asset.json +81 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/model.js +67 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-vase-01/asset.json +21 -0
- package/web/assets/furniture/assets/ceramic-vase-01/model.js +119 -0
- package/web/assets/furniture/assets/ceramic-vase-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bed-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-bed-01/model.js +63 -0
- package/web/assets/furniture/assets/fairy-bed-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/model.js +71 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/asset.json +32 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/model.js +168 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-desk-01/asset.json +25 -0
- package/web/assets/furniture/assets/fairy-desk-01/model.js +132 -0
- package/web/assets/furniture/assets/fairy-desk-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/asset.json +23 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/model.js +125 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/model.js +128 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/model.js +66 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/model.js +182 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/catalog.js +137 -0
- package/web/assets/furniture/index.json +110 -0
- package/web/assets/furniture/registry.js +72 -0
- package/web/assets/furniture-page.css +68 -0
- package/web/assets/furniture-page.js +302 -0
- package/web/assets/home.js +65 -0
- package/web/assets/node-detail.js +148 -0
- package/web/assets/panel-link.js +47 -0
- package/web/assets/read.js +746 -0
- package/web/assets/room-scene.js +386 -0
- package/web/assets/room.css +287 -0
- package/web/assets/room.js +437 -0
- package/web/assets/rooms/bindings.js +449 -0
- package/web/assets/rooms/ceramic-reflection.js +193 -0
- package/web/assets/rooms/ceramic-room.js +302 -0
- package/web/assets/rooms/door.js +35 -0
- package/web/assets/rooms/fairy-room.js +492 -0
- package/web/assets/rooms/furniture-assembly.js +113 -0
- package/web/assets/rooms/navigation.js +262 -0
- package/web/assets/rooms/placeholder-catalog.json +71 -0
- package/web/assets/rooms/placeholder-furniture.js +76 -0
- package/web/assets/rooms/placement.js +588 -0
- package/web/assets/rooms/reader.js +441 -0
- package/web/assets/rooms/reading-gate.js +157 -0
- package/web/assets/rooms/room-lighting.js +50 -0
- package/web/assets/rooms/templates.json +50 -0
- package/web/assets/rooms/walk-frame.js +64 -0
- package/web/assets/rooms/walk-session.js +138 -0
- package/web/assets/save-result.js +89 -0
- package/web/assets/screenshots/business.png +0 -0
- package/web/assets/screenshots/node-detail.png +0 -0
- package/web/assets/screenshots/project-home.png +0 -0
- package/web/assets/screenshots/workflow.png +0 -0
- package/web/assets/showcase.js +232 -0
- package/web/assets/style.css +836 -0
- package/web/assets/template.js +7 -0
- package/web/assets/theme.js +29 -0
- package/web/assets/three/OrbitControls.js +1972 -0
- package/web/assets/three/VERSION.md +12 -0
- package/web/assets/three/three.core.js +60586 -0
- package/web/assets/three/three.module.js +19719 -0
- package/web/assets/workspaces.css +21 -0
- package/web/assets/workspaces.js +81 -0
- package/web/building.html +131 -0
- package/web/business.html +43 -0
- package/web/furniture.html +47 -0
- package/web/index.html +57 -0
- package/web/read.html +107 -0
- package/web/room.html +146 -0
- package/web/showcase.html +50 -0
- package/web/workspaces.html +32 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2873 @@
|
|
|
1
|
+
// 构建产物:由 scripts/build.mjs 从 src/ 生成,请勿手改。
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// src/dsh/index.ts
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
7
|
+
import path2 from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// src/core/errors.ts
|
|
11
|
+
var CoreError = class extends Error {
|
|
12
|
+
code;
|
|
13
|
+
httpStatus;
|
|
14
|
+
// 字段在构造函数体内赋值(不用 TS 参数属性):core 全部保持"可擦除语法",
|
|
15
|
+
// 纯 Node 剥类型模式即可加载,scripts/smoke.mjs 不依赖 tsx。
|
|
16
|
+
constructor(code, message, httpStatus = 500) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.httpStatus = httpStatus;
|
|
20
|
+
this.name = "CoreError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// src/core/git.ts
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { readFile, stat } from "node:fs/promises";
|
|
28
|
+
import { realpathSync } from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
var execFileAsync = promisify(execFile);
|
|
31
|
+
var GIT_TIMEOUT_MS = 1e4;
|
|
32
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
33
|
+
var EXEC_MAX_BUFFER = MAX_FILE_BYTES * 2;
|
|
34
|
+
async function runGit(repoRoot, args) {
|
|
35
|
+
let caught;
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execFileAsync("git", ["-C", repoRoot, ...args], {
|
|
38
|
+
timeout: GIT_TIMEOUT_MS,
|
|
39
|
+
maxBuffer: EXEC_MAX_BUFFER,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
windowsHide: true
|
|
42
|
+
});
|
|
43
|
+
return stdout;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
caught = error;
|
|
46
|
+
}
|
|
47
|
+
const err = caught;
|
|
48
|
+
if (err.code === "ENOENT") {
|
|
49
|
+
throw new CoreError("git-unavailable", "\u65E0\u6CD5\u542F\u52A8 git\uFF1A\u672A\u627E\u5230\u53EF\u6267\u884C\u6587\u4EF6\uFF08\u8BF7\u786E\u8BA4\u5DF2\u5B89\u88C5 git \u4E14\u5728 PATH \u4E2D\uFF09", 500);
|
|
50
|
+
}
|
|
51
|
+
if (err.killed || err.signal === "SIGTERM") {
|
|
52
|
+
throw new CoreError("git-timeout", `git ${args[0]} \u8D85\u8FC7 ${GIT_TIMEOUT_MS / 1e3} \u79D2\u672A\u8FD4\u56DE\uFF0C\u5DF2\u4E2D\u6B62`, 504);
|
|
53
|
+
}
|
|
54
|
+
const rawText = String(err.stderr || err.message || "");
|
|
55
|
+
if (/not a git repository/i.test(rawText)) {
|
|
56
|
+
throw new CoreError(
|
|
57
|
+
"not-a-git-repo",
|
|
58
|
+
`\u8FD9\u4E2A\u76EE\u5F55\u4E0D\u662F\u53EF\u7528\u7684 Git \u4ED3\u5E93\uFF1A${repoRoot}\uFF08\u5386\u53F2\u7248\u672C\u4E0E\u5FEB\u7167\u4FE1\u606F\u5B58\u653E\u5728 Git \u91CC\uFF0C\u9700\u8981\u662F Git \u4ED3\u5E93\u624D\u80FD\u7BA1\u7406\uFF09`,
|
|
59
|
+
422
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const firstLine = rawText.split("\n")[0].trim();
|
|
63
|
+
throw new CoreError("git-failed", `git ${args[0]} \u5931\u8D25\uFF1A${firstLine || "\u672A\u77E5\u9519\u8BEF"}`);
|
|
64
|
+
}
|
|
65
|
+
function isHex40(value) {
|
|
66
|
+
return typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
|
|
67
|
+
}
|
|
68
|
+
async function assertRepoUsable(repoRoot) {
|
|
69
|
+
let info;
|
|
70
|
+
try {
|
|
71
|
+
info = await stat(repoRoot);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const code = error?.code;
|
|
74
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
75
|
+
throw new CoreError("repo-unavailable", `\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u79FB\u52A8\uFF1A${repoRoot}`, 404);
|
|
76
|
+
}
|
|
77
|
+
throw new CoreError("repo-unavailable", `\u76EE\u5F55\u65E0\u6CD5\u8BFB\u53D6\uFF08${code ?? String(error)}\uFF09\uFF1A${repoRoot}`, 403);
|
|
78
|
+
}
|
|
79
|
+
if (!info.isDirectory()) {
|
|
80
|
+
throw new CoreError("repo-unavailable", `\u8DEF\u5F84\u4E0D\u662F\u4E00\u4E2A\u76EE\u5F55\uFF1A${repoRoot}`, 404);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function sameRealPath(a, b) {
|
|
84
|
+
const norm = (p) => {
|
|
85
|
+
let resolved;
|
|
86
|
+
try {
|
|
87
|
+
resolved = realpathSync(p);
|
|
88
|
+
} catch {
|
|
89
|
+
resolved = p;
|
|
90
|
+
}
|
|
91
|
+
const unified = path.resolve(resolved);
|
|
92
|
+
return process.platform === "win32" ? unified.toLowerCase() : unified;
|
|
93
|
+
};
|
|
94
|
+
return norm(a) === norm(b);
|
|
95
|
+
}
|
|
96
|
+
async function assertRepoTopLevel(repoRoot) {
|
|
97
|
+
const output = (await runGit(repoRoot, ["rev-parse", "--show-toplevel"])).trim();
|
|
98
|
+
if (!output) {
|
|
99
|
+
throw new CoreError("not-a-git-repo", `\u65E0\u6CD5\u786E\u5B9A ${repoRoot} \u7684 Git \u4ED3\u5E93\u9876\u5C42`, 422);
|
|
100
|
+
}
|
|
101
|
+
if (!sameRealPath(output, repoRoot)) {
|
|
102
|
+
throw new CoreError(
|
|
103
|
+
"repo-not-top-level",
|
|
104
|
+
`\u8FD9\u4E2A\u76EE\u5F55\u4E0D\u662F Git \u4ED3\u5E93\u7684\u9876\u5C42\uFF1A${repoRoot} \u5C5E\u4E8E\u5916\u5C42\u4ED3\u5E93 ${output}\u3002\u5386\u53F2\u7248\u672C\u4F1A\u4E0E\u5916\u5C42\u4ED3\u5E93\u6DF7\u5728\u4E00\u8D77\uFF0C\u7BA1\u7406\u9875\u4E0D\u4F1A\u81EA\u52A8\u6539\u7ED1\uFF1B\u8BF7\u628A\u4ED3\u5E93\u9876\u5C42\uFF08${output}\uFF09\u767B\u8BB0\u4E3A\u5DE5\u4F5C\u533A\u3002`,
|
|
105
|
+
422
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function gitShowFileOptional(repoRoot, commit, relPath) {
|
|
110
|
+
if (!isHex40(commit)) {
|
|
111
|
+
throw new CoreError("bad-request", `\u5185\u90E8\u9519\u8BEF\uFF1AgitShowFileOptional \u53EA\u63A5\u53D7 40 \u4F4D\u63D0\u4EA4\u53F7\uFF0C\u6536\u5230 ${commit}`, 400);
|
|
112
|
+
}
|
|
113
|
+
const spec = `${commit}:${relPath}`;
|
|
114
|
+
const entry = (await runGit(repoRoot, ["ls-tree", commit, "--", relPath])).trim();
|
|
115
|
+
if (entry === "") return null;
|
|
116
|
+
const objectType = entry.split(" ")[0].split(" ")[1];
|
|
117
|
+
if (objectType !== "blob") return null;
|
|
118
|
+
const sizeText = await runGit(repoRoot, ["cat-file", "-s", spec]);
|
|
119
|
+
const size = Number(sizeText.trim());
|
|
120
|
+
if (!Number.isFinite(size) || size < 0) {
|
|
121
|
+
throw new CoreError("git-failed", `\u65E0\u6CD5\u786E\u5B9A ${relPath} \u5728\u63D0\u4EA4 ${commit.slice(0, 8)} \u4E0A\u7684\u5927\u5C0F`);
|
|
122
|
+
}
|
|
123
|
+
if (size > MAX_FILE_BYTES) {
|
|
124
|
+
throw new CoreError(
|
|
125
|
+
"file-too-large",
|
|
126
|
+
`\u6587\u4EF6 ${relPath} \u5728\u63D0\u4EA4 ${commit.slice(0, 8)} \u4E0A\u4E3A ${size} \u5B57\u8282\uFF0C\u8D85\u8FC7\u8BFB\u53D6\u4E0A\u9650 ${MAX_FILE_BYTES} \u5B57\u8282`,
|
|
127
|
+
413
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return runGit(repoRoot, ["show", spec]);
|
|
131
|
+
}
|
|
132
|
+
async function resolveCommit(repoRoot, commit) {
|
|
133
|
+
if (!isHex40(commit)) {
|
|
134
|
+
throw new CoreError("bad-request", `\u63D0\u4EA4\u53F7\u5FC5\u987B\u662F 40 \u4F4D\u5341\u516D\u8FDB\u5236\uFF0C\u6536\u5230\uFF1A${commit}`, 400);
|
|
135
|
+
}
|
|
136
|
+
let out = null;
|
|
137
|
+
try {
|
|
138
|
+
out = await runGit(repoRoot, ["rev-parse", "--verify", `${commit}^{commit}`]);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const confirmedAbsent = error instanceof CoreError && error.code === "git-failed" && ["Needed a single revision", "expected commit type"].some((hint) => error.message.includes(hint));
|
|
141
|
+
if (!confirmedAbsent) throw error;
|
|
142
|
+
}
|
|
143
|
+
const resolved = out?.trim();
|
|
144
|
+
if (!isHex40(resolved)) {
|
|
145
|
+
throw new CoreError("not-found", `\u63D0\u4EA4 ${commit} \u65E0\u6CD5\u5728\u672C\u4ED3\u5E93\u89E3\u6790`, 404);
|
|
146
|
+
}
|
|
147
|
+
return resolved;
|
|
148
|
+
}
|
|
149
|
+
function statConfirmedAbsent(error) {
|
|
150
|
+
const code = error?.code;
|
|
151
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
152
|
+
}
|
|
153
|
+
function fsFailed(relPath, error) {
|
|
154
|
+
const code = error?.code;
|
|
155
|
+
return new CoreError("fs-failed", `\u8BBF\u95EE ${relPath} \u5931\u8D25\uFF08${code ?? String(error)}\uFF09`, 500);
|
|
156
|
+
}
|
|
157
|
+
async function worktreeDirExists(repoRoot, relPath) {
|
|
158
|
+
const normalized = relPath.split("/").filter((part) => part !== "" && part !== ".");
|
|
159
|
+
if (normalized.some((part) => part === "..") || path.isAbsolute(relPath)) {
|
|
160
|
+
throw new CoreError("bad-request", `\u975E\u6CD5\u8DEF\u5F84\uFF1A${relPath}`, 400);
|
|
161
|
+
}
|
|
162
|
+
const full = path.resolve(repoRoot, ...normalized);
|
|
163
|
+
try {
|
|
164
|
+
return (await stat(full)).isDirectory();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (statConfirmedAbsent(error)) return false;
|
|
167
|
+
throw fsFailed(relPath, error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function worktreeFileExists(repoRoot, relPath) {
|
|
171
|
+
const normalized = relPath.split("/").filter((part) => part !== "" && part !== ".");
|
|
172
|
+
if (normalized.some((part) => part === "..") || path.isAbsolute(relPath)) {
|
|
173
|
+
throw new CoreError("bad-request", `\u975E\u6CD5\u8DEF\u5F84\uFF1A${relPath}`, 400);
|
|
174
|
+
}
|
|
175
|
+
const full = path.resolve(repoRoot, ...normalized);
|
|
176
|
+
try {
|
|
177
|
+
return (await stat(full)).isFile();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (statConfirmedAbsent(error)) return false;
|
|
180
|
+
throw fsFailed(relPath, error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function readWorktreeFileOptional(repoRoot, relPath) {
|
|
184
|
+
const normalized = relPath.split("/").filter((part) => part !== "" && part !== ".");
|
|
185
|
+
if (normalized.some((part) => part === "..") || path.isAbsolute(relPath)) {
|
|
186
|
+
throw new CoreError("bad-request", `\u975E\u6CD5\u8DEF\u5F84\uFF1A${relPath}`, 400);
|
|
187
|
+
}
|
|
188
|
+
const full = path.resolve(repoRoot, ...normalized);
|
|
189
|
+
const root = path.resolve(repoRoot);
|
|
190
|
+
if (full !== root && !full.startsWith(root + path.sep)) {
|
|
191
|
+
throw new CoreError("bad-request", `\u8DEF\u5F84\u8D8A\u51FA\u4ED3\u5E93\u6839\uFF1A${relPath}`, 400);
|
|
192
|
+
}
|
|
193
|
+
let size;
|
|
194
|
+
try {
|
|
195
|
+
size = (await stat(full)).size;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (statConfirmedAbsent(error)) return null;
|
|
198
|
+
throw fsFailed(relPath, error);
|
|
199
|
+
}
|
|
200
|
+
if (size > MAX_FILE_BYTES) {
|
|
201
|
+
throw new CoreError(
|
|
202
|
+
"file-too-large",
|
|
203
|
+
`\u6587\u4EF6 ${relPath} \u4E3A ${size} \u5B57\u8282\uFF0C\u8D85\u8FC7\u8BFB\u53D6\u4E0A\u9650 ${MAX_FILE_BYTES} \u5B57\u8282`,
|
|
204
|
+
413
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return readFile(full, "utf8");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/core/types.ts
|
|
211
|
+
var CONVENTION_ROOT = "docs/specdev";
|
|
212
|
+
var SCHEMA = {
|
|
213
|
+
project: "specdev/project/1",
|
|
214
|
+
business: "specdev/business/1",
|
|
215
|
+
chart: "specdev/chart/1",
|
|
216
|
+
evidence: "specdev/evidence/1",
|
|
217
|
+
snapshot: "specdev/snapshot/1"
|
|
218
|
+
};
|
|
219
|
+
var BUILDING_BLUEPRINT_REL = `${CONVENTION_ROOT}/building.json`;
|
|
220
|
+
var BUILDING_FORMAT = "specdev-building";
|
|
221
|
+
var BUILDING_SCHEMA = "specdev/building/1";
|
|
222
|
+
|
|
223
|
+
// src/core/snapshots.ts
|
|
224
|
+
var STAGES = ["design", "implemented"];
|
|
225
|
+
function parseTagName(tag) {
|
|
226
|
+
const parts = tag.split("/");
|
|
227
|
+
if (parts.length < 3 || parts[0] !== "specdev" || !parts[1] || parts.some((p) => p === "")) return null;
|
|
228
|
+
return { chart: parts[1], version: parts.slice(2).join("/") };
|
|
229
|
+
}
|
|
230
|
+
async function listSpecdevTags(repoRoot) {
|
|
231
|
+
const format = "%(refname:short)%09%(objecttype)%09%(*objectname)%09%(*objecttype)%09%(creatordate:iso8601-strict)%09%(contents)%00";
|
|
232
|
+
const out = await runGit(repoRoot, ["for-each-ref", `refs/tags/${"specdev"}/`, `--format=${format}`]);
|
|
233
|
+
const records = [];
|
|
234
|
+
for (const raw of out.split("\0")) {
|
|
235
|
+
const chunk = raw.replace(/^[\r\n]+/, "").replace(/[\r\n]+$/, "");
|
|
236
|
+
if (!chunk) continue;
|
|
237
|
+
const head = chunk.indexOf(" ");
|
|
238
|
+
if (head < 0) continue;
|
|
239
|
+
const rest = chunk.slice(head + 1);
|
|
240
|
+
const fields = rest.split(" ");
|
|
241
|
+
records.push({
|
|
242
|
+
tag: chunk.slice(0, head),
|
|
243
|
+
objecttype: fields[0],
|
|
244
|
+
derefObject: fields[1],
|
|
245
|
+
derefType: fields[2],
|
|
246
|
+
createdDate: fields[3],
|
|
247
|
+
message: fields.slice(4).join(" ")
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return records;
|
|
251
|
+
}
|
|
252
|
+
function validateRecord(record) {
|
|
253
|
+
const parsed = parseTagName(record.tag);
|
|
254
|
+
if (!parsed) return { invalid: "\u6807\u7B7E\u540D\u4E0D\u7B26\u5408 specdev/<\u56FE\u7F16\u53F7>/<\u7248\u672C> \u7ED3\u6784" };
|
|
255
|
+
if (record.objecttype !== "tag") return { invalid: "\u4E0D\u662F\u9644\u6CE8\u6807\u7B7E\uFF08\u8F7B\u91CF\u6807\u7B7E\u4E0D\u767B\u8BB0\u5FEB\u7167\uFF09" };
|
|
256
|
+
if (!/^[0-9a-f]{40}$/.test(record.derefObject)) return { invalid: "\u5265\u58F3\u540E\u6CA1\u6709\u6307\u5411\u6709\u6548\u5BF9\u8C61" };
|
|
257
|
+
if (record.derefType !== "commit") {
|
|
258
|
+
return { invalid: `\u6807\u7B7E\u5265\u58F3\u540E\u6307\u5411\u7684\u4E0D\u662F\u63D0\u4EA4\uFF08${record.derefType || "\u65E0\u5BF9\u8C61"}\uFF09\uFF0C\u5FEB\u7167\u5FC5\u987B\u843D\u5728\u5177\u4F53\u63D0\u4EA4\u4E0A` };
|
|
259
|
+
}
|
|
260
|
+
let meta;
|
|
261
|
+
try {
|
|
262
|
+
meta = JSON.parse(record.message);
|
|
263
|
+
} catch {
|
|
264
|
+
return { invalid: "\u6807\u7B7E message \u4E0D\u662F\u5408\u6CD5 JSON" };
|
|
265
|
+
}
|
|
266
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return { invalid: "\u6807\u7B7E message \u4E0D\u662F JSON \u5BF9\u8C61" };
|
|
267
|
+
const m = meta;
|
|
268
|
+
if (m.schema !== SCHEMA.snapshot) return { invalid: `message.schema \u4E0D\u662F ${SCHEMA.snapshot}` };
|
|
269
|
+
if (typeof m.chart !== "string" || !m.chart) return { invalid: "message.chart \u7F3A\u5931\u6216\u4E0D\u662F\u5B57\u7B26\u4E32" };
|
|
270
|
+
if (typeof m.name !== "string" || !m.name.trim()) return { invalid: "message.name \u7F3A\u5931\u6216\u4E3A\u7A7A" };
|
|
271
|
+
if (!STAGES.includes(m.stage)) return { invalid: `message.stage \u5FC5\u987B\u662F ${STAGES.join(" / ")}` };
|
|
272
|
+
if (typeof m.dir !== "string" || !m.dir.startsWith(CONVENTION_ROOT + "/")) {
|
|
273
|
+
return { invalid: `message.dir \u5FC5\u987B\u4EE5 ${CONVENTION_ROOT}/ \u5F00\u5934` };
|
|
274
|
+
}
|
|
275
|
+
if (m.chart !== parsed.chart) return { invalid: `message.chart\uFF08${m.chart}\uFF09\u4E0E\u6807\u7B7E\u540D\u91CC\u7684\u56FE\u7F16\u53F7\uFF08${parsed.chart}\uFF09\u4E0D\u4E00\u81F4` };
|
|
276
|
+
if (m.savedAt !== void 0 && typeof m.savedAt !== "string") return { invalid: "message.savedAt \u4E0D\u662F\u5B57\u7B26\u4E32" };
|
|
277
|
+
if (m.note !== void 0 && typeof m.note !== "string") return { invalid: "message.note \u4E0D\u662F\u5B57\u7B26\u4E32" };
|
|
278
|
+
return {
|
|
279
|
+
meta: {
|
|
280
|
+
schema: SCHEMA.snapshot,
|
|
281
|
+
chart: m.chart,
|
|
282
|
+
name: m.name,
|
|
283
|
+
stage: m.stage,
|
|
284
|
+
note: m.note,
|
|
285
|
+
dir: m.dir,
|
|
286
|
+
savedAt: m.savedAt
|
|
287
|
+
},
|
|
288
|
+
commit: record.derefObject
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function snapshotsForChart(records, chartId) {
|
|
292
|
+
const snapshots = [];
|
|
293
|
+
let invalidCount = 0;
|
|
294
|
+
const invalidSamples = [];
|
|
295
|
+
for (const record of records) {
|
|
296
|
+
const parsed = parseTagName(record.tag);
|
|
297
|
+
if (!parsed || parsed.chart !== chartId) continue;
|
|
298
|
+
const result = validateRecord(record);
|
|
299
|
+
if ("invalid" in result) {
|
|
300
|
+
invalidCount += 1;
|
|
301
|
+
if (invalidSamples.length < 5) invalidSamples.push(record.tag);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
snapshots.push({
|
|
305
|
+
tag: record.tag,
|
|
306
|
+
version: parsed.version,
|
|
307
|
+
chart: chartId,
|
|
308
|
+
commit: result.commit,
|
|
309
|
+
createdDate: record.createdDate,
|
|
310
|
+
meta: result.meta
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
snapshots.sort((a, b) => {
|
|
314
|
+
const keyA = a.meta.savedAt ?? a.createdDate;
|
|
315
|
+
const keyB = b.meta.savedAt ?? b.createdDate;
|
|
316
|
+
if (keyA !== keyB) return keyA < keyB ? 1 : -1;
|
|
317
|
+
return a.tag < b.tag ? 1 : -1;
|
|
318
|
+
});
|
|
319
|
+
return { snapshots, invalidCount, invalidSamples };
|
|
320
|
+
}
|
|
321
|
+
function findSnapshot(records, tag) {
|
|
322
|
+
const record = records.find((item) => item.tag === tag);
|
|
323
|
+
if (!record) {
|
|
324
|
+
throw new CoreError("not-found", `\u6807\u7B7E ${tag} \u4E0D\u5B58\u5728`, 404);
|
|
325
|
+
}
|
|
326
|
+
const result = validateRecord(record);
|
|
327
|
+
if ("invalid" in result) {
|
|
328
|
+
throw new CoreError("not-found", `\u6807\u7B7E ${tag} \u4E0D\u7B26\u5408\u5FEB\u7167\u7EA6\u5B9A\uFF08${result.invalid}\uFF09\uFF0C\u5DF2\u88AB\u5FFD\u7565`, 404);
|
|
329
|
+
}
|
|
330
|
+
const parsed = parseTagName(tag);
|
|
331
|
+
return {
|
|
332
|
+
tag,
|
|
333
|
+
version: parsed.version,
|
|
334
|
+
chart: parsed.chart,
|
|
335
|
+
commit: result.commit,
|
|
336
|
+
createdDate: record.createdDate,
|
|
337
|
+
meta: result.meta
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/core/evidence.ts
|
|
342
|
+
function splitLines(text) {
|
|
343
|
+
const lines = text.split("\n");
|
|
344
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
345
|
+
return lines;
|
|
346
|
+
}
|
|
347
|
+
async function resolveEvidenceRefs(repoRoot, refs) {
|
|
348
|
+
const results = [];
|
|
349
|
+
for (let i = 0; i < refs.length; i++) {
|
|
350
|
+
results.push(await resolveOneRef(repoRoot, refs[i], i));
|
|
351
|
+
}
|
|
352
|
+
return results;
|
|
353
|
+
}
|
|
354
|
+
async function resolveOneRef(repoRoot, raw, index) {
|
|
355
|
+
const where = `refs[${index}]`;
|
|
356
|
+
const base = { id: `refs[${index}]`, label: `\u5F15\u7528 ${index + 1}` };
|
|
357
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
358
|
+
return { ...base, ok: false, error: `${where} \u4E0D\u662F\u5BF9\u8C61` };
|
|
359
|
+
}
|
|
360
|
+
const ref = raw;
|
|
361
|
+
if (typeof ref.id === "string" && ref.id) base.id = ref.id;
|
|
362
|
+
if (typeof ref.label === "string" && ref.label) base.label = ref.label;
|
|
363
|
+
const bad = (error) => ({ ...base, ok: false, error: `${where}\uFF08${base.id}\uFF09\uFF1A${error}` });
|
|
364
|
+
if (ref.repo !== void 0 && ref.repo !== ".") return bad('\u7B2C\u4E00\u6279\u53EA\u652F\u6301\u540C\u4ED3\u5F15\u7528\uFF08repo \u5FC5\u987B\u662F "."\uFF09');
|
|
365
|
+
if (typeof ref.commit !== "string" || !/^[0-9a-f]{40}$/.test(ref.commit)) {
|
|
366
|
+
return bad("commit \u5FC5\u987B\u662F 40 \u4F4D\u5341\u516D\u8FDB\u5236\u63D0\u4EA4\u53F7");
|
|
367
|
+
}
|
|
368
|
+
if (typeof ref.path !== "string" || !ref.path || pathIsUnsafe(ref.path)) {
|
|
369
|
+
return bad("path \u5FC5\u987B\u662F\u4ED3\u5E93\u5185\u7684\u76F8\u5BF9\u8DEF\u5F84");
|
|
370
|
+
}
|
|
371
|
+
const fromLine = ref.fromLine;
|
|
372
|
+
const toLine = ref.toLine;
|
|
373
|
+
if (typeof fromLine !== "number" || typeof toLine !== "number" || !Number.isInteger(fromLine) || !Number.isInteger(toLine)) {
|
|
374
|
+
return bad("fromLine / toLine \u5FC5\u987B\u662F\u6574\u6570");
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
await resolveCommit(repoRoot, ref.commit);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (!(error instanceof CoreError) || error.code !== "not-found") throw error;
|
|
380
|
+
return bad(`\u63D0\u4EA4 ${ref.commit.slice(0, 8)} \u65E0\u6CD5\u5728\u672C\u4ED3\u5E93\u89E3\u6790`);
|
|
381
|
+
}
|
|
382
|
+
let text;
|
|
383
|
+
try {
|
|
384
|
+
text = await gitShowFileOptional(repoRoot, ref.commit, ref.path);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return bad(error.message);
|
|
387
|
+
}
|
|
388
|
+
if (text === null) return bad(`\u6587\u4EF6 ${ref.path} \u5728\u63D0\u4EA4 ${ref.commit.slice(0, 8)} \u4E0A\u4E0D\u5B58\u5728`);
|
|
389
|
+
const lines = splitLines(text);
|
|
390
|
+
if (fromLine < 1 || fromLine > toLine || toLine > lines.length) {
|
|
391
|
+
return bad(`\u884C\u8303\u56F4\u8D8A\u754C\uFF1A\u8BE5\u63D0\u4EA4\u4E0A\u6587\u4EF6\u5171 ${lines.length} \u884C\uFF0C\u5F15\u7528 ${fromLine}\u2013${toLine} \u884C`);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
...base,
|
|
395
|
+
ok: true,
|
|
396
|
+
repo: ".",
|
|
397
|
+
commit: ref.commit,
|
|
398
|
+
path: ref.path,
|
|
399
|
+
fromLine,
|
|
400
|
+
toLine,
|
|
401
|
+
lineCount: lines.length,
|
|
402
|
+
text: lines.slice(fromLine - 1, toLine).join("\n")
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function pathIsUnsafe(p) {
|
|
406
|
+
if (p.includes("\\") || /^[a-zA-Z]:/.test(p) || p.startsWith("/")) return true;
|
|
407
|
+
const parts = p.split("/");
|
|
408
|
+
return parts.some((part) => part === "" || part === "." || part === "..");
|
|
409
|
+
}
|
|
410
|
+
async function loadEvidence(repoRoot, evidenceText) {
|
|
411
|
+
if (evidenceText === null) return { missing: true, refs: [] };
|
|
412
|
+
let parsed;
|
|
413
|
+
try {
|
|
414
|
+
parsed = JSON.parse(evidenceText);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return { missing: false, parseError: `evidence.json \u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${error.message}`, refs: [] };
|
|
417
|
+
}
|
|
418
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
419
|
+
return { missing: false, parseError: "evidence.json \u9876\u5C42\u5FC5\u987B\u662F\u5BF9\u8C61", refs: [] };
|
|
420
|
+
}
|
|
421
|
+
const refs = parsed.refs;
|
|
422
|
+
if (refs === void 0) {
|
|
423
|
+
return { missing: false, parseError: "evidence.json \u7F3A\u5C11 refs \u5B57\u6BB5", refs: [] };
|
|
424
|
+
}
|
|
425
|
+
if (!Array.isArray(refs)) {
|
|
426
|
+
return { missing: false, parseError: "refs \u5FC5\u987B\u662F\u6570\u7EC4", refs: [] };
|
|
427
|
+
}
|
|
428
|
+
return { missing: false, refs: await resolveEvidenceRefs(repoRoot, refs) };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/core/inventory.ts
|
|
432
|
+
import { readdir as readdir2 } from "node:fs/promises";
|
|
433
|
+
|
|
434
|
+
// src/core/chart.ts
|
|
435
|
+
import { readdir } from "node:fs/promises";
|
|
436
|
+
|
|
437
|
+
// src/core/descriptor.ts
|
|
438
|
+
async function readDescriptor(repoRoot, relPath) {
|
|
439
|
+
const text = await readWorktreeFileOptional(repoRoot, relPath);
|
|
440
|
+
if (text === null) return { data: null, error: `\u7F3A\u5C11\u8BF4\u660E\u6587\u4EF6 ${relPath}` };
|
|
441
|
+
try {
|
|
442
|
+
return { data: JSON.parse(text) };
|
|
443
|
+
} catch (error) {
|
|
444
|
+
return { data: null, error: `${relPath} \u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${error.message}` };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function isPlainObject(value) {
|
|
448
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
449
|
+
}
|
|
450
|
+
function requireStringFields(record, fields) {
|
|
451
|
+
for (const field of fields) {
|
|
452
|
+
const value = record[field];
|
|
453
|
+
if (typeof value !== "string" || !value.trim()) return `${field} \u7F3A\u5931\u6216\u4E0D\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`;
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/core/chart-files.ts
|
|
459
|
+
var CHART_FILE_NAMES = ["workflow.json", "details.md", "evidence.json"];
|
|
460
|
+
var FILE_KEY = {
|
|
461
|
+
"workflow.json": "workflow",
|
|
462
|
+
"details.md": "details",
|
|
463
|
+
"evidence.json": "evidence"
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// src/core/fingerprint.ts
|
|
467
|
+
import { createHash } from "node:crypto";
|
|
468
|
+
function normalizeEol(text) {
|
|
469
|
+
return text.replace(/\r\n/g, "\n");
|
|
470
|
+
}
|
|
471
|
+
function fingerprintOf(files) {
|
|
472
|
+
const json = JSON.stringify(
|
|
473
|
+
CHART_FILE_NAMES.map((name2) => {
|
|
474
|
+
const text = files[FILE_KEY[name2]];
|
|
475
|
+
return text === null ? null : normalizeEol(text);
|
|
476
|
+
})
|
|
477
|
+
);
|
|
478
|
+
return createHash("sha256").update(json, "utf8").digest("hex");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/core/chart.ts
|
|
482
|
+
async function chartIdOwners(repoRoot, chartId) {
|
|
483
|
+
const entries = await readdir(`${repoRoot}/${CONVENTION_ROOT}`, { withFileTypes: true });
|
|
484
|
+
const owners = [];
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
if (!entry.isDirectory()) continue;
|
|
487
|
+
if (await worktreeDirExists(repoRoot, `${CONVENTION_ROOT}/${entry.name}/${chartId}`)) owners.push(entry.name);
|
|
488
|
+
}
|
|
489
|
+
return owners;
|
|
490
|
+
}
|
|
491
|
+
async function locateChartDir(repoRoot, businessId, chartId) {
|
|
492
|
+
const businessDir = `${CONVENTION_ROOT}/${businessId}`;
|
|
493
|
+
const chartDir = `${businessDir}/${chartId}`;
|
|
494
|
+
if (!await worktreeDirExists(repoRoot, businessDir)) {
|
|
495
|
+
throw new CoreError("not-found", `\u4E1A\u52A1 ${businessId} \u4E0D\u5B58\u5728\uFF08\u7EA6\u5B9A\u76EE\u5F55 ${businessDir} \u4E0D\u5B58\u5728\uFF09`, 404);
|
|
496
|
+
}
|
|
497
|
+
if (!await worktreeDirExists(repoRoot, chartDir)) {
|
|
498
|
+
throw new CoreError("not-found", `\u56FE ${chartId} \u4E0D\u5B58\u5728\uFF08\u7EA6\u5B9A\u76EE\u5F55 ${chartDir} \u4E0D\u5B58\u5728\uFF09`, 404);
|
|
499
|
+
}
|
|
500
|
+
const owners = await chartIdOwners(repoRoot, chartId);
|
|
501
|
+
if (owners.length > 1) {
|
|
502
|
+
throw new CoreError(
|
|
503
|
+
"duplicate-chart-id",
|
|
504
|
+
`\u56FE\u7F16\u53F7 ${chartId} \u540C\u65F6\u5B58\u5728\u4E8E\u4E1A\u52A1\uFF08${owners.join("\u3001")}\uFF09\u4E0B\uFF1A\u5386\u53F2\u5FEB\u7167\u6309\u56FE\u7F16\u53F7\u5F52\u7EC4\uFF0C\u65E0\u6CD5\u533A\u5206\u5F52\u5C5E\u3002\u8BF7\u628A\u5176\u4E2D\u4E00\u4E2A\u56FE\u76EE\u5F55\u6539\u6210\u4E0D\u540C\u7684\u7F16\u53F7\u3002`,
|
|
505
|
+
409
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
return chartDir;
|
|
509
|
+
}
|
|
510
|
+
function normalizeOrNull(text) {
|
|
511
|
+
return text === null ? null : normalizeEol(text);
|
|
512
|
+
}
|
|
513
|
+
async function compareCurrentWithLatest(repoRoot, chartDir, latest) {
|
|
514
|
+
for (const name2 of CHART_FILE_NAMES) {
|
|
515
|
+
const worktree = await readWorktreeFileOptional(repoRoot, `${chartDir}/${name2}`);
|
|
516
|
+
const snapshot = await gitShowFileOptional(repoRoot, latest.commit, `${latest.meta.dir}/${name2}`);
|
|
517
|
+
if (normalizeOrNull(worktree) !== normalizeOrNull(snapshot)) return "changed";
|
|
518
|
+
}
|
|
519
|
+
return "identical";
|
|
520
|
+
}
|
|
521
|
+
var FILE_KEYS = { "workflow.json": "workflow", "details.md": "details", "evidence.json": "evidence" };
|
|
522
|
+
async function readWorktreeFiles(repoRoot, chartDir) {
|
|
523
|
+
const files = { workflow: null, details: null, evidence: null };
|
|
524
|
+
const errors = {};
|
|
525
|
+
for (const name2 of CHART_FILE_NAMES) {
|
|
526
|
+
const key = FILE_KEYS[name2];
|
|
527
|
+
try {
|
|
528
|
+
files[key] = await readWorktreeFileOptional(repoRoot, `${chartDir}/${name2}`);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
errors[key] = error instanceof Error ? error.message : String(error);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { files, errors };
|
|
534
|
+
}
|
|
535
|
+
async function readSnapshotFiles(repoRoot, snapshot) {
|
|
536
|
+
const dir = snapshot.meta.dir;
|
|
537
|
+
const files = { workflow: null, details: null, evidence: null };
|
|
538
|
+
const errors = {};
|
|
539
|
+
for (const name2 of CHART_FILE_NAMES) {
|
|
540
|
+
const key = FILE_KEYS[name2];
|
|
541
|
+
try {
|
|
542
|
+
files[key] = await gitShowFileOptional(repoRoot, snapshot.commit, `${dir}/${name2}`);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
errors[key] = error instanceof Error ? error.message : String(error);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { files, errors };
|
|
548
|
+
}
|
|
549
|
+
async function readChartPage(repoRoot, businessId, chartId, v) {
|
|
550
|
+
const businessDir = `${CONVENTION_ROOT}/${businessId}`;
|
|
551
|
+
const chartDir = await locateChartDir(repoRoot, businessId, chartId);
|
|
552
|
+
const business = await readDescriptor(repoRoot, `${businessDir}/business.json`);
|
|
553
|
+
const chart = await readDescriptor(repoRoot, `${chartDir}/chart.json`);
|
|
554
|
+
const chartName = chart.data && isPlainObject(chart.data) && typeof chart.data.name === "string" ? chart.data.name : chartId;
|
|
555
|
+
const chartSummaryOk = !!(chart.data && isPlainObject(chart.data) && chart.data.schema === SCHEMA.chart && !requireStringFields(chart.data, ["id", "name"]) && chart.data.id === chartId);
|
|
556
|
+
const tagRecords = await listSpecdevTags(repoRoot);
|
|
557
|
+
const snapshots = snapshotsForChart(tagRecords, chartId);
|
|
558
|
+
const latest = snapshots.snapshots[0];
|
|
559
|
+
let currentStatus;
|
|
560
|
+
let compareError;
|
|
561
|
+
if (!latest) {
|
|
562
|
+
currentStatus = "no-snapshot";
|
|
563
|
+
} else {
|
|
564
|
+
try {
|
|
565
|
+
currentStatus = await compareCurrentWithLatest(repoRoot, chartDir, latest);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
currentStatus = "compare-failed";
|
|
568
|
+
compareError = error instanceof Error ? error.message : String(error);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
let version;
|
|
572
|
+
let currentFingerprint = null;
|
|
573
|
+
if (v === "current") {
|
|
574
|
+
const { files, errors } = await readWorktreeFiles(repoRoot, chartDir);
|
|
575
|
+
currentFingerprint = errors.workflow || errors.details || errors.evidence ? null : fingerprintOf(files);
|
|
576
|
+
version = {
|
|
577
|
+
kind: "current",
|
|
578
|
+
files,
|
|
579
|
+
workflowError: errors.workflow ?? (files.workflow === null ? `\u5F53\u524D\u5DE5\u4F5C\u533A\u7F3A\u5C11 workflow.json\uFF08${chartDir}/workflow.json\uFF09\uFF0C\u8BE5\u56FE\u4E0D\u53EF\u8BFB` : void 0),
|
|
580
|
+
workflowErrorKind: errors.workflow ? "unreadable" : files.workflow === null ? "missing" : void 0,
|
|
581
|
+
detailsError: errors.details,
|
|
582
|
+
evidenceError: errors.evidence
|
|
583
|
+
};
|
|
584
|
+
} else {
|
|
585
|
+
const parsed = parseTagName(v);
|
|
586
|
+
if (!parsed || parsed.chart !== chartId) {
|
|
587
|
+
throw new CoreError("bad-request", `\u7248\u672C\u53C2\u6570\u5FC5\u987B\u662F current \u6216\u672C\u56FE\uFF08${chartId}\uFF09\u7684\u6807\u7B7E\u540D\uFF0C\u6536\u5230\uFF1A${v}`, 400);
|
|
588
|
+
}
|
|
589
|
+
const snapshot = findSnapshot(tagRecords, v);
|
|
590
|
+
const { files, errors } = await readSnapshotFiles(repoRoot, snapshot);
|
|
591
|
+
version = {
|
|
592
|
+
kind: "snapshot",
|
|
593
|
+
tag: snapshot.tag,
|
|
594
|
+
commit: snapshot.commit,
|
|
595
|
+
label: snapshot.meta.name,
|
|
596
|
+
stage: snapshot.meta.stage,
|
|
597
|
+
note: snapshot.meta.note,
|
|
598
|
+
savedAt: snapshot.meta.savedAt,
|
|
599
|
+
files,
|
|
600
|
+
workflowError: errors.workflow ?? (files.workflow === null ? `\u5FEB\u7167 ${snapshot.tag} \u91CC\u7F3A\u5C11 workflow.json\uFF08\u767B\u8BB0\u76EE\u5F55 ${snapshot.meta.dir}\uFF09\uFF0C\u8BE5\u7248\u672C\u4E0D\u53EF\u8BFB` : void 0),
|
|
601
|
+
workflowErrorKind: errors.workflow ? "unreadable" : files.workflow === null ? "missing" : void 0,
|
|
602
|
+
detailsError: errors.details,
|
|
603
|
+
evidenceError: errors.evidence
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
business: {
|
|
608
|
+
id: businessId,
|
|
609
|
+
name: business.data && isPlainObject(business.data) && typeof business.data.name === "string" ? business.data.name : businessId
|
|
610
|
+
},
|
|
611
|
+
chart: { id: chartId, name: chartName, summary: chartSummaryOk && typeof chart.data?.summary === "string" ? chart.data.summary : void 0 },
|
|
612
|
+
snapshots,
|
|
613
|
+
currentStatus,
|
|
614
|
+
compareError,
|
|
615
|
+
version,
|
|
616
|
+
currentFingerprint
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/core/inventory.ts
|
|
621
|
+
async function readProjectInfo(repoRoot) {
|
|
622
|
+
const relPath = `${CONVENTION_ROOT}/project.json`;
|
|
623
|
+
const text = await readWorktreeFileOptional(repoRoot, relPath);
|
|
624
|
+
if (text === null) {
|
|
625
|
+
throw new CoreError(
|
|
626
|
+
"no-convention-root",
|
|
627
|
+
`\u8FD9\u91CC\u8FD8\u6CA1\u6709\u6D41\u7A0B\u56FE\u8D44\u6599\uFF1A\u672A\u627E\u5230 ${relPath}\u3002\u4E0D\u4F1A\u81EA\u52A8\u521B\u5EFA\u6216\u6539\u52A8\u8FD9\u4E2A\u76EE\u5F55\u91CC\u7684\u4EFB\u4F55\u6587\u4EF6\u3002`,
|
|
628
|
+
404
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
let data;
|
|
632
|
+
try {
|
|
633
|
+
data = JSON.parse(text);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
throw new CoreError("bad-inventory", `${relPath} \u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${error.message}`, 500);
|
|
636
|
+
}
|
|
637
|
+
if (!isPlainObject(data) || data.schema !== SCHEMA.project) {
|
|
638
|
+
throw new CoreError("bad-inventory", `${relPath} \u7684 schema \u5FC5\u987B\u662F ${SCHEMA.project}`, 500);
|
|
639
|
+
}
|
|
640
|
+
const fieldError = requireStringFields(data, ["name"]);
|
|
641
|
+
if (fieldError) throw new CoreError("bad-inventory", `${relPath}\uFF1A${fieldError}`, 500);
|
|
642
|
+
return data;
|
|
643
|
+
}
|
|
644
|
+
async function readInventory(repoRoot) {
|
|
645
|
+
const project = await readProjectInfo(repoRoot);
|
|
646
|
+
const tagRecords = await listSpecdevTags(repoRoot);
|
|
647
|
+
const businessEntries = await readdir2(`${repoRoot}/${CONVENTION_ROOT}`, { withFileTypes: true });
|
|
648
|
+
const businesses = [];
|
|
649
|
+
for (const entry of businessEntries) {
|
|
650
|
+
if (!entry.isDirectory()) continue;
|
|
651
|
+
const businessId = entry.name;
|
|
652
|
+
const businessDir = `${CONVENTION_ROOT}/${businessId}`;
|
|
653
|
+
const summary = { id: businessId, name: businessId, docs: [], charts: [] };
|
|
654
|
+
const { data, error } = await readDescriptor(repoRoot, `${businessDir}/business.json`);
|
|
655
|
+
if (!data) {
|
|
656
|
+
summary.descriptorError = error ?? "business.json \u7F3A\u5931";
|
|
657
|
+
businesses.push(summary);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (!isPlainObject(data) || data.schema !== SCHEMA.business) {
|
|
661
|
+
summary.descriptorError = `${businessDir}/business.json \u7684 schema \u5FC5\u987B\u662F ${SCHEMA.business}`;
|
|
662
|
+
businesses.push(summary);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const fieldError = requireStringFields(data, ["id", "name"]);
|
|
666
|
+
if (fieldError) {
|
|
667
|
+
summary.descriptorError = `${businessDir}/business.json\uFF1A${fieldError}`;
|
|
668
|
+
businesses.push(summary);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (data.id !== businessId) {
|
|
672
|
+
summary.descriptorError = `business.json \u7684 id\uFF08${data.id}\uFF09\u4E0E\u76EE\u5F55\u540D\uFF08${businessId}\uFF09\u4E0D\u4E00\u81F4`;
|
|
673
|
+
businesses.push(summary);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
summary.name = data.name;
|
|
677
|
+
summary.intro = typeof data.intro === "string" ? data.intro : void 0;
|
|
678
|
+
if (Array.isArray(data.docs)) {
|
|
679
|
+
summary.docs = data.docs.filter((item) => typeof item === "string" && item !== "");
|
|
680
|
+
}
|
|
681
|
+
summary.charts = await readChartSummaries(repoRoot, businessDir, tagRecords);
|
|
682
|
+
businesses.push(summary);
|
|
683
|
+
}
|
|
684
|
+
businesses.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
685
|
+
const ownersByChartId = /* @__PURE__ */ new Map();
|
|
686
|
+
for (const business of businesses) {
|
|
687
|
+
for (const chart of business.charts) {
|
|
688
|
+
ownersByChartId.set(chart.id, [...ownersByChartId.get(chart.id) ?? [], business.id]);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
for (const business of businesses) {
|
|
692
|
+
for (const chart of business.charts) {
|
|
693
|
+
const owners = ownersByChartId.get(chart.id) ?? [];
|
|
694
|
+
if (owners.length > 1) {
|
|
695
|
+
chart.idConflict = `\u56FE\u7F16\u53F7 ${chart.id} \u5728\u4E1A\u52A1\uFF08${owners.join("\u3001")}\uFF09\u4E2D\u91CD\u590D\uFF1A\u5386\u53F2\u5FEB\u7167\u6309\u56FE\u7F16\u53F7\u5F52\u7EC4\uFF0C\u65E0\u6CD5\u533A\u5206\u5F52\u5C5E\uFF0C\u8BE5\u56FE\u7684\u9605\u8BFB\u5DF2\u505C\u7528\uFF08\u76F4\u8FBE\u9605\u8BFB\u9875\u4F1A\u660E\u786E\u62A5\u9519\uFF09`;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return { project, businesses };
|
|
700
|
+
}
|
|
701
|
+
async function readChartSummaries(repoRoot, businessDir, tagRecords) {
|
|
702
|
+
const chartEntries = await readdir2(`${repoRoot}/${businessDir}`, { withFileTypes: true });
|
|
703
|
+
const charts = [];
|
|
704
|
+
for (const entry of chartEntries) {
|
|
705
|
+
if (!entry.isDirectory()) continue;
|
|
706
|
+
const chartId = entry.name;
|
|
707
|
+
const chartDir = `${businessDir}/${chartId}`;
|
|
708
|
+
const snapshots = snapshotsForChart(tagRecords, chartId);
|
|
709
|
+
const summary = {
|
|
710
|
+
id: chartId,
|
|
711
|
+
name: chartId,
|
|
712
|
+
hasWorkflow: await worktreeFileExists(repoRoot, `${chartDir}/workflow.json`),
|
|
713
|
+
snapshotCount: snapshots.snapshots.length,
|
|
714
|
+
invalidTagCount: snapshots.invalidCount,
|
|
715
|
+
currentStatus: "no-snapshot"
|
|
716
|
+
};
|
|
717
|
+
const { data, error } = await readDescriptor(repoRoot, `${chartDir}/chart.json`);
|
|
718
|
+
if (!data) {
|
|
719
|
+
summary.descriptorError = error ?? "chart.json \u7F3A\u5931";
|
|
720
|
+
charts.push(summary);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (!isPlainObject(data) || data.schema !== SCHEMA.chart) {
|
|
724
|
+
summary.descriptorError = `${chartDir}/chart.json \u7684 schema \u5FC5\u987B\u662F ${SCHEMA.chart}`;
|
|
725
|
+
charts.push(summary);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
const fieldError = requireStringFields(data, ["id", "name"]);
|
|
729
|
+
if (fieldError) {
|
|
730
|
+
summary.descriptorError = `${chartDir}/chart.json\uFF1A${fieldError}`;
|
|
731
|
+
charts.push(summary);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (data.id !== chartId) {
|
|
735
|
+
summary.descriptorError = `chart.json \u7684 id\uFF08${data.id}\uFF09\u4E0E\u76EE\u5F55\u540D\uFF08${chartId}\uFF09\u4E0D\u4E00\u81F4`;
|
|
736
|
+
charts.push(summary);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
summary.name = data.name;
|
|
740
|
+
summary.summary = typeof data.summary === "string" ? data.summary : void 0;
|
|
741
|
+
const latest = snapshots.snapshots[0];
|
|
742
|
+
if (latest) {
|
|
743
|
+
try {
|
|
744
|
+
summary.currentStatus = await compareCurrentWithLatest(repoRoot, chartDir, latest);
|
|
745
|
+
} catch (error2) {
|
|
746
|
+
summary.currentStatus = "compare-failed";
|
|
747
|
+
summary.compareError = error2 instanceof Error ? error2.message : String(error2);
|
|
748
|
+
}
|
|
749
|
+
summary.latestSnapshot = {
|
|
750
|
+
tag: latest.tag,
|
|
751
|
+
name: latest.meta.name,
|
|
752
|
+
stage: latest.meta.stage,
|
|
753
|
+
savedAt: latest.meta.savedAt,
|
|
754
|
+
createdDate: latest.createdDate
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
charts.push(summary);
|
|
758
|
+
}
|
|
759
|
+
charts.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
760
|
+
return charts;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/core/room.ts
|
|
764
|
+
var ROOM_REQUIRED_FIELDS = ["schemaVersion", "roomId", "businessId", "templateRef", "instances"];
|
|
765
|
+
var ROOM_ALLOWED_FIELDS = [...ROOM_REQUIRED_FIELDS, "bindings"];
|
|
766
|
+
var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
767
|
+
function roomLayoutRel(businessId) {
|
|
768
|
+
return `${CONVENTION_ROOT}/${businessId}/room.json`;
|
|
769
|
+
}
|
|
770
|
+
async function readRoomLayout(repoRoot, businessId) {
|
|
771
|
+
if (!ID_PATTERN.test(businessId)) {
|
|
772
|
+
throw new CoreError("bad-request", `\u53C2\u6570 business \u4E0D\u5408\u6CD5\uFF1A${businessId}`, 400);
|
|
773
|
+
}
|
|
774
|
+
const inventory = await readInventory(repoRoot);
|
|
775
|
+
const business = inventory.businesses.find((item) => item.id === businessId);
|
|
776
|
+
if (!business) {
|
|
777
|
+
throw new CoreError("not-found", `\u4E1A\u52A1 ${businessId} \u4E0D\u5B58\u5728\uFF08\u9879\u76EE\u6E05\u5355\u91CC\u6CA1\u6709\u8FD9\u4E2A\u4E1A\u52A1\uFF09`, 404);
|
|
778
|
+
}
|
|
779
|
+
if (business.descriptorError) {
|
|
780
|
+
throw new CoreError(
|
|
781
|
+
"bad-business",
|
|
782
|
+
`\u4E1A\u52A1 ${businessId} \u7684\u8BF4\u660E\u6587\u4EF6\u6709\u95EE\u9898\uFF0C\u4E0D\u80FD\u5F53\u4F5C\u53EF\u7528\u4E1A\u52A1\uFF1A${business.descriptorError}`,
|
|
783
|
+
422
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
const relPath = roomLayoutRel(businessId);
|
|
787
|
+
const text = await readWorktreeFileOptional(repoRoot, relPath);
|
|
788
|
+
if (text === null) {
|
|
789
|
+
throw new CoreError(
|
|
790
|
+
"no-room-layout",
|
|
791
|
+
`\u4E1A\u52A1 ${businessId} \u8FD8\u6CA1\u6709\u623F\u95F4\u914D\u7F6E\uFF1A\u672A\u627E\u5230 ${relPath}\u3002\u4E0D\u4F1A\u81EA\u52A8\u751F\u6210\uFF0C\u4E5F\u4E0D\u56DE\u9000\u5230\u6837\u4F8B\u3002`,
|
|
792
|
+
404
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
let layout;
|
|
796
|
+
try {
|
|
797
|
+
layout = JSON.parse(text);
|
|
798
|
+
} catch (error) {
|
|
799
|
+
throw new CoreError("bad-room-layout", `${relPath} \u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${error.message}`, 422);
|
|
800
|
+
}
|
|
801
|
+
if (!isPlainObject(layout)) {
|
|
802
|
+
throw new CoreError(
|
|
803
|
+
"bad-room-layout",
|
|
804
|
+
`${relPath} \u9876\u5C42\u5FC5\u987B\u662F JSON \u5BF9\u8C61\uFF0C\u5B9E\u9645\u662F ${Array.isArray(layout) ? "\u6570\u7EC4" : typeof layout}`,
|
|
805
|
+
422
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
const unknownFields = Object.keys(layout).filter((key) => !ROOM_ALLOWED_FIELDS.includes(key));
|
|
809
|
+
if (unknownFields.length > 0) {
|
|
810
|
+
throw new CoreError(
|
|
811
|
+
"bad-room-layout",
|
|
812
|
+
`${relPath} \u53EA\u5141\u8BB8\u5B57\u6BB5 ${ROOM_ALLOWED_FIELDS.join("\u3001")}\uFF0C\u591A\u51FA\uFF1A${unknownFields.join("\u3001")}`,
|
|
813
|
+
422
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
const missingFields = ROOM_REQUIRED_FIELDS.filter((field) => !(field in layout));
|
|
817
|
+
if (missingFields.length > 0) {
|
|
818
|
+
throw new CoreError("bad-room-layout", `${relPath} \u7F3A\u5FC5\u586B\u5B57\u6BB5\uFF1A${missingFields.join("\u3001")}`, 422);
|
|
819
|
+
}
|
|
820
|
+
if (typeof layout.schemaVersion !== "number" || layout.schemaVersion !== 1) {
|
|
821
|
+
throw new CoreError(
|
|
822
|
+
"bad-room-layout",
|
|
823
|
+
`${relPath} \u7684 schemaVersion \u5FC5\u987B\u662F\u6570\u5B57 1\uFF08\u5B9E\u9645\u662F ${JSON.stringify(layout.schemaVersion)}\uFF09`,
|
|
824
|
+
422
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
if (typeof layout.roomId !== "string" || !ID_PATTERN.test(layout.roomId)) {
|
|
828
|
+
throw new CoreError(
|
|
829
|
+
"bad-room-layout",
|
|
830
|
+
`${relPath} \u7684 roomId \u5FC5\u987B\u662F\u7B26\u5408 ID \u89C4\u5219\uFF08\u5B57\u6BCD\u6570\u5B57\u5F00\u5934\uFF0C\u53EF\u542B . _ -\uFF09\u7684\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u5B9E\u9645\u662F ${JSON.stringify(layout.roomId)}\uFF09`,
|
|
831
|
+
422
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (typeof layout.businessId !== "string" || !ID_PATTERN.test(layout.businessId)) {
|
|
835
|
+
throw new CoreError(
|
|
836
|
+
"bad-room-layout",
|
|
837
|
+
`${relPath} \u7684 businessId \u5FC5\u987B\u662F\u7B26\u5408 ID \u89C4\u5219\u7684\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u5B9E\u9645\u662F ${JSON.stringify(layout.businessId)}\uFF09`,
|
|
838
|
+
422
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
if (typeof layout.templateRef !== "string" || !layout.templateRef.trim()) {
|
|
842
|
+
throw new CoreError(
|
|
843
|
+
"bad-room-layout",
|
|
844
|
+
`${relPath} \u7684 templateRef \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u5B9E\u9645\u662F ${JSON.stringify(layout.templateRef)}\uFF09`,
|
|
845
|
+
422
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (!Array.isArray(layout.instances)) {
|
|
849
|
+
throw new CoreError(
|
|
850
|
+
"bad-room-layout",
|
|
851
|
+
`${relPath} \u7684 instances \u5FC5\u987B\u662F\u6570\u7EC4\uFF08\u7A7A\u6570\u7EC4\uFF1D\u7A7A\u623F\u95F4\uFF1B\u5B9E\u9645\u662F ${typeof layout.instances}\uFF09`,
|
|
852
|
+
422
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if ("bindings" in layout && !Array.isArray(layout.bindings)) {
|
|
856
|
+
throw new CoreError(
|
|
857
|
+
"bad-room-layout",
|
|
858
|
+
`${relPath} \u7684 bindings \u5FC5\u987B\u662F\u6570\u7EC4\uFF08\u7701\u7565\u6216\u7A7A\u6570\u7EC4\uFF1D\u6CA1\u6709\u5BB6\u5177\u8D44\u6599\u7ED1\u5B9A\uFF1B\u5B9E\u9645\u662F ${layout.bindings === null ? "null" : typeof layout.bindings}\uFF09`,
|
|
859
|
+
422
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
if (layout.businessId !== businessId) {
|
|
863
|
+
throw new CoreError(
|
|
864
|
+
"room-business-mismatch",
|
|
865
|
+
`${relPath} \u7684 businessId\uFF08${layout.businessId}\uFF09\u4E0E\u8BF7\u6C42\u7684\u4E1A\u52A1\uFF08${businessId}\uFF09\u4E0D\u4E00\u81F4\uFF1A\u623F\u95F4\u914D\u7F6E\u53EA\u5F52\u5C5E\u5B83\u767B\u8BB0\u7684\u4E1A\u52A1\u3002`,
|
|
866
|
+
422
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
// docs 来自该业务清单登记(business.json 的 docs),charts 是本业务图目录的投影
|
|
871
|
+
// (E9c,既有 inventory 扫描结果:目录内 chart.json/workflow.json 等按既有规则扫描,
|
|
872
|
+
// 不新开扫描器,也不在 business.json 里加 charts 登记数组);页面拿一次房间响应即可
|
|
873
|
+
// 校验文档与图的资料绑定——不从 room.json 接受 docs/charts,也不混入其他业务(E3a/E9c)。
|
|
874
|
+
business: {
|
|
875
|
+
id: business.id,
|
|
876
|
+
name: business.name,
|
|
877
|
+
intro: business.intro,
|
|
878
|
+
docs: business.docs,
|
|
879
|
+
charts: business.charts.map((chart) => ({
|
|
880
|
+
id: chart.id,
|
|
881
|
+
name: chart.name,
|
|
882
|
+
...chart.summary === void 0 ? {} : { summary: chart.summary },
|
|
883
|
+
hasWorkflow: chart.hasWorkflow,
|
|
884
|
+
...chart.descriptorError === void 0 ? {} : { descriptorError: chart.descriptorError },
|
|
885
|
+
...chart.idConflict === void 0 ? {} : { idConflict: chart.idConflict }
|
|
886
|
+
}))
|
|
887
|
+
},
|
|
888
|
+
layout,
|
|
889
|
+
source: { path: relPath }
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// src/core/save.ts
|
|
894
|
+
async function resolveHeadOptional(repoRoot) {
|
|
895
|
+
try {
|
|
896
|
+
return (await runGit(repoRoot, ["rev-parse", "HEAD"])).trim();
|
|
897
|
+
} catch (error) {
|
|
898
|
+
const unborn = error instanceof CoreError && error.code === "git-failed" && error.message.includes("unknown revision");
|
|
899
|
+
if (unborn) return null;
|
|
900
|
+
throw error;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async function readTriFiles(read) {
|
|
904
|
+
const files = { workflow: null, details: null, evidence: null };
|
|
905
|
+
const errors = {};
|
|
906
|
+
for (const name2 of CHART_FILE_NAMES) {
|
|
907
|
+
const key = FILE_KEY[name2];
|
|
908
|
+
try {
|
|
909
|
+
files[key] = await read(name2);
|
|
910
|
+
} catch (error) {
|
|
911
|
+
errors[key] = error instanceof Error ? error.message : String(error);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return { files, errors };
|
|
915
|
+
}
|
|
916
|
+
async function collectSaveCheck(repoRoot, chartDir, head) {
|
|
917
|
+
const worktree = await readTriFiles((name2) => readWorktreeFileOptional(repoRoot, `${chartDir}/${name2}`));
|
|
918
|
+
const committed = await readTriFiles((name2) => gitShowFileOptional(repoRoot, head, `${chartDir}/${name2}`));
|
|
919
|
+
const problems = [];
|
|
920
|
+
if (worktree.files.workflow === null && !worktree.errors.workflow) {
|
|
921
|
+
problems.push("\u56FE\u8FD8\u4E0D\u5B58\u5728\uFF1Aworkflow.json \u7F3A\u5931\uFF0C\u6CA1\u6709\u53EF\u4FDD\u5B58\u7684\u5185\u5BB9");
|
|
922
|
+
}
|
|
923
|
+
for (const name2 of CHART_FILE_NAMES) {
|
|
924
|
+
const key = FILE_KEY[name2];
|
|
925
|
+
if (worktree.errors[key]) {
|
|
926
|
+
problems.push(`${name2} \u8BFB\u4E0D\u5F00\uFF08${worktree.errors[key]}\uFF09`);
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (committed.errors[key]) {
|
|
930
|
+
problems.push(`${name2} \u5728\u6700\u8FD1\u4E00\u6B21\u63D0\u4EA4\u4E0A\u8BFB\u4E0D\u5F00\uFF08${committed.errors[key]}\uFF09`);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (key === "workflow" && worktree.files.workflow === null) continue;
|
|
934
|
+
const w = worktree.files[key];
|
|
935
|
+
const h = committed.files[key];
|
|
936
|
+
if (h === null && w !== null) problems.push(`${name2} \u8FD8\u6CA1\u6709\u63D0\u4EA4\u8FC7\uFF08\u65B0\u6587\u4EF6\uFF09`);
|
|
937
|
+
else if (h !== null && w === null) problems.push(`${name2} \u5728\u5DE5\u4F5C\u533A\u88AB\u5220\u9664\uFF0C\u5220\u9664\u5C1A\u672A\u63D0\u4EA4`);
|
|
938
|
+
else if (h !== null && w !== null && normalizeEol(w) !== normalizeEol(h)) problems.push(`${name2} \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539`);
|
|
939
|
+
}
|
|
940
|
+
const fingerprint = committed.errors.workflow || committed.errors.details || committed.errors.evidence ? null : fingerprintOf(committed.files);
|
|
941
|
+
return { problems, fingerprint, headFiles: committed.files };
|
|
942
|
+
}
|
|
943
|
+
async function chartBlobIds(repoRoot, commit, chartDir) {
|
|
944
|
+
const out = await runGit(repoRoot, ["ls-tree", commit, "--", ...CHART_FILE_NAMES.map((n) => `${chartDir}/${n}`)]);
|
|
945
|
+
const blobs = { workflow: null, details: null, evidence: null };
|
|
946
|
+
for (const line of out.split("\n")) {
|
|
947
|
+
const tab = line.indexOf(" ");
|
|
948
|
+
if (tab < 0) continue;
|
|
949
|
+
const fields = line.slice(0, tab).split(" ");
|
|
950
|
+
const fileName = line.slice(tab + 1).trim().split("/").pop();
|
|
951
|
+
if (!(fileName in FILE_KEY) || fields[1] !== "blob") continue;
|
|
952
|
+
blobs[FILE_KEY[fileName]] = fields[2];
|
|
953
|
+
}
|
|
954
|
+
return blobs;
|
|
955
|
+
}
|
|
956
|
+
async function findDuplicateSnapshot(repoRoot, records, chartId, stage, headIds) {
|
|
957
|
+
for (const snapshot of snapshotsForChart(records, chartId).snapshots) {
|
|
958
|
+
if (snapshot.meta.stage !== stage) continue;
|
|
959
|
+
const snapIds = await chartBlobIds(repoRoot, snapshot.commit, snapshot.meta.dir);
|
|
960
|
+
if (CHART_FILE_NAMES.every((n) => headIds[FILE_KEY[n]] === snapIds[FILE_KEY[n]])) return snapshot;
|
|
961
|
+
}
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
function pickTagName(records, base) {
|
|
965
|
+
const taken = new Set(records.map((r) => r.tag));
|
|
966
|
+
let tag = base;
|
|
967
|
+
let suffix = 2;
|
|
968
|
+
while (taken.has(tag)) {
|
|
969
|
+
if (suffix > MAX_NAME_SUFFIX) {
|
|
970
|
+
throw new CoreError("tag-name-exhausted", `\u6807\u7B7E\u540D ${base} \u5DF2\u88AB\u5360\u7528\uFF0C\u4E14\u627E\u4E0D\u5230\u53EF\u7528\u53D8\u4F53`, 500);
|
|
971
|
+
}
|
|
972
|
+
tag = `${base}-${suffix}`;
|
|
973
|
+
suffix += 1;
|
|
974
|
+
}
|
|
975
|
+
return tag;
|
|
976
|
+
}
|
|
977
|
+
async function checkChartCommitted(repoRoot, businessId, chartId) {
|
|
978
|
+
const chartDir = await locateChartDir(repoRoot, businessId, chartId);
|
|
979
|
+
const head = await resolveHeadOptional(repoRoot);
|
|
980
|
+
if (head === null) {
|
|
981
|
+
return { head: null, ok: false, problems: ["\u8FD9\u4E2A\u4ED3\u5E93\u8FD8\u6CA1\u6709\u4EFB\u4F55\u63D0\u4EA4\uFF0C\u6CA1\u6709\u53EF\u4FDD\u5B58\u7684\u5185\u5BB9"], fingerprint: null };
|
|
982
|
+
}
|
|
983
|
+
const { problems, fingerprint } = await collectSaveCheck(repoRoot, chartDir, head);
|
|
984
|
+
return { head, ok: problems.length === 0, problems, fingerprint };
|
|
985
|
+
}
|
|
986
|
+
var NAME_MAX = 200;
|
|
987
|
+
var NOTE_MAX = 1e3;
|
|
988
|
+
var MAX_TAG_ATTEMPTS = 5;
|
|
989
|
+
var MAX_NAME_SUFFIX = 50;
|
|
990
|
+
var saveQueues = /* @__PURE__ */ new Map();
|
|
991
|
+
function withChartLock(key, task) {
|
|
992
|
+
const previous = saveQueues.get(key) ?? Promise.resolve();
|
|
993
|
+
const run = previous.then(task, task);
|
|
994
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
995
|
+
saveQueues.set(key, tail);
|
|
996
|
+
void tail.then(() => {
|
|
997
|
+
if (saveQueues.get(key) === tail) saveQueues.delete(key);
|
|
998
|
+
});
|
|
999
|
+
return run;
|
|
1000
|
+
}
|
|
1001
|
+
function createdUnverifiedError(tag, error) {
|
|
1002
|
+
if (error instanceof CoreError && error.code === "save-created-unverified") return error;
|
|
1003
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1004
|
+
return new CoreError(
|
|
1005
|
+
"save-created-unverified",
|
|
1006
|
+
`\u7248\u672C ${tag} \u5DF2\u7ECF\u5199\u4E0A\u53BB\u4E86\uFF0C\u4F46\u8BFB\u56DE\u6838\u5BF9\u6CA1\u505A\u5B8C\uFF1A${reason}\u3002\u8BF7\u5237\u65B0\u9875\u9762\u770B\u7248\u672C\u5217\u8868\u786E\u8BA4\u3002`,
|
|
1007
|
+
500
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
function validateInput(raw) {
|
|
1011
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1012
|
+
throw new CoreError("bad-request", "\u4FDD\u5B58\u4FE1\u606F\u4E0D\u5408\u6CD5\uFF08\u5FC5\u987B\u662F\u5BF9\u8C61\uFF09", 400);
|
|
1013
|
+
}
|
|
1014
|
+
const m = raw;
|
|
1015
|
+
const name2 = typeof m.name === "string" ? m.name.trim() : "";
|
|
1016
|
+
if (name2.length < 1 || name2.length > NAME_MAX) {
|
|
1017
|
+
throw new CoreError("bad-request", `\u7248\u672C\u540D\u79F0\u5FC5\u586B\uFF0C\u4E14\u4E0D\u8D85\u8FC7 ${NAME_MAX} \u4E2A\u5B57`, 400);
|
|
1018
|
+
}
|
|
1019
|
+
const stage = m.stage === "design" || m.stage === "implemented" ? m.stage : null;
|
|
1020
|
+
if (stage === null) {
|
|
1021
|
+
throw new CoreError("bad-request", "\u9636\u6BB5\u5FC5\u987B\u662F design\uFF08\u8BBE\u8BA1\u7248\uFF09\u6216 implemented\uFF08\u5B9E\u73B0\u7248\uFF09", 400);
|
|
1022
|
+
}
|
|
1023
|
+
if (m.note !== void 0 && m.note !== null && typeof m.note !== "string") {
|
|
1024
|
+
throw new CoreError("bad-request", "\u4FDD\u5B58\u8BF4\u660E\u4E0D\u5408\u6CD5\uFF08\u5FC5\u987B\u662F\u6587\u672C\uFF09", 400);
|
|
1025
|
+
}
|
|
1026
|
+
let note;
|
|
1027
|
+
if (typeof m.note === "string" && m.note.trim()) {
|
|
1028
|
+
note = m.note.trim();
|
|
1029
|
+
if (note.length > NOTE_MAX) {
|
|
1030
|
+
throw new CoreError("bad-request", `\u4FDD\u5B58\u8BF4\u660E\u4E0D\u8D85\u8FC7 ${NOTE_MAX} \u4E2A\u5B57`, 400);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return { name: name2, stage, note };
|
|
1034
|
+
}
|
|
1035
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
1036
|
+
function localIso(d) {
|
|
1037
|
+
const offsetMin = -d.getTimezoneOffset();
|
|
1038
|
+
const sign = offsetMin >= 0 ? "+" : "-";
|
|
1039
|
+
const abs = Math.abs(offsetMin);
|
|
1040
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
|
|
1041
|
+
}
|
|
1042
|
+
function localStamp(d) {
|
|
1043
|
+
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
|
|
1044
|
+
}
|
|
1045
|
+
async function saveChartSnapshot(repoRoot, businessId, chartId, input, options) {
|
|
1046
|
+
const { name: name2, stage, note } = validateInput(input);
|
|
1047
|
+
if (!/^[0-9a-f]{40}$/.test(options.expectedHead)) {
|
|
1048
|
+
throw new CoreError("bad-request", `\u786E\u8BA4\u8FC7\u7684\u63D0\u4EA4\u53F7\u4E0D\u5408\u6CD5\uFF1A${options.expectedHead}`, 400);
|
|
1049
|
+
}
|
|
1050
|
+
const chartDir = await locateChartDir(repoRoot, businessId, chartId);
|
|
1051
|
+
return withChartLock(
|
|
1052
|
+
`${repoRoot}\0${chartId}`,
|
|
1053
|
+
() => createSnapshot(repoRoot, chartDir, chartId, { name: name2, stage, note }, options)
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
async function createSnapshot(repoRoot, chartDir, chartId, meta, options) {
|
|
1057
|
+
const { name: name2, stage, note } = meta;
|
|
1058
|
+
const head = await resolveHeadOptional(repoRoot);
|
|
1059
|
+
if (head === null) {
|
|
1060
|
+
throw new CoreError("uncommitted-changes", "\u8FD9\u4E2A\u4ED3\u5E93\u8FD8\u6CA1\u6709\u4EFB\u4F55\u63D0\u4EA4\uFF0C\u6CA1\u6709\u53EF\u4FDD\u5B58\u7684\u5185\u5BB9", 409);
|
|
1061
|
+
}
|
|
1062
|
+
const check = await collectSaveCheck(repoRoot, chartDir, head);
|
|
1063
|
+
if (check.problems.length > 0) {
|
|
1064
|
+
throw new CoreError(
|
|
1065
|
+
"uncommitted-changes",
|
|
1066
|
+
`\u5F53\u524D\u56FE\u8FD8\u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u5148\u63D0\u4EA4\uFF0C\u518D\u4FDD\u5B58\u7248\u672C\uFF1A
|
|
1067
|
+
- ${check.problems.join("\n- ")}`,
|
|
1068
|
+
409
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
if (head !== options.expectedHead) {
|
|
1072
|
+
throw new CoreError("content-updated", "\u5185\u5BB9\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u786E\u8BA4\uFF08\u540E\u53F0\u6709\u4E86\u65B0\u7684\u63D0\u4EA4\uFF09", 409);
|
|
1073
|
+
}
|
|
1074
|
+
if (options.fingerprint !== check.fingerprint) {
|
|
1075
|
+
throw new CoreError("content-updated", "\u5185\u5BB9\u5DF2\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u786E\u8BA4\uFF08\u9875\u9762\u5C55\u793A\u7684\u5185\u5BB9\u4E0E\u5C06\u8981\u4FDD\u5B58\u7684\u4E0D\u4E00\u81F4\uFF09", 409);
|
|
1076
|
+
}
|
|
1077
|
+
const headIds = await chartBlobIds(repoRoot, head, chartDir);
|
|
1078
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1079
|
+
const base = `specdev/${chartId}/${localStamp(now)}`;
|
|
1080
|
+
const savedAt = localIso(now);
|
|
1081
|
+
const message = JSON.stringify({
|
|
1082
|
+
schema: SCHEMA.snapshot,
|
|
1083
|
+
chart: chartId,
|
|
1084
|
+
name: name2,
|
|
1085
|
+
stage,
|
|
1086
|
+
...note !== void 0 ? { note } : {},
|
|
1087
|
+
dir: chartDir,
|
|
1088
|
+
savedAt
|
|
1089
|
+
});
|
|
1090
|
+
let created = null;
|
|
1091
|
+
for (let attempt = 0; attempt < MAX_TAG_ATTEMPTS && created === null; attempt += 1) {
|
|
1092
|
+
const records = await listSpecdevTags(repoRoot);
|
|
1093
|
+
const duplicate = await findDuplicateSnapshot(repoRoot, records, chartId, stage, headIds);
|
|
1094
|
+
if (duplicate) return { snapshot: duplicate, alreadySaved: true };
|
|
1095
|
+
const tag = pickTagName(records, base);
|
|
1096
|
+
try {
|
|
1097
|
+
await runGit(repoRoot, ["tag", "-a", tag, "-m", message, head]);
|
|
1098
|
+
created = tag;
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
const latest = await listSpecdevTags(repoRoot);
|
|
1101
|
+
const contention = latest.some((r) => r.tag === tag) || await findDuplicateSnapshot(repoRoot, latest, chartId, stage, headIds) !== null;
|
|
1102
|
+
if (!contention) throw error;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (created === null) {
|
|
1106
|
+
throw new CoreError(
|
|
1107
|
+
"save-conflict",
|
|
1108
|
+
`\u8FDE\u7EED ${MAX_TAG_ATTEMPTS} \u6B21\u90FD\u6CA1\u80FD\u5360\u7528\u7248\u672C\u540D\uFF08\u540C\u540D\u6807\u7B7E\u4E00\u76F4\u88AB\u62A2\u5148\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
|
|
1109
|
+
409
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
const entry = findSnapshot(await listSpecdevTags(repoRoot), created);
|
|
1114
|
+
const expected = { schema: SCHEMA.snapshot, chart: chartId, name: name2, stage, note, dir: chartDir, savedAt };
|
|
1115
|
+
const mismatch = Object.keys(expected).filter((k) => entry.meta[k] !== expected[k]);
|
|
1116
|
+
if (entry.commit !== head || mismatch.length > 0) {
|
|
1117
|
+
throw new CoreError(
|
|
1118
|
+
"save-created-unverified",
|
|
1119
|
+
`\u7248\u672C ${created} \u5DF2\u7ECF\u5199\u4E0A\u53BB\u4E86\uFF0C\u4F46\u8BFB\u56DE\u6838\u5BF9\u4E0D\u4E00\u81F4\uFF08${["commit", ...mismatch].join("\u3001")}\uFF09\u3002\u8BF7\u5237\u65B0\u9875\u9762\u770B\u7248\u672C\u5217\u8868\u786E\u8BA4\u3002`,
|
|
1120
|
+
500
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
for (const n of CHART_FILE_NAMES) {
|
|
1124
|
+
const back = await gitShowFileOptional(repoRoot, entry.commit, `${chartDir}/${n}`);
|
|
1125
|
+
if (back !== check.headFiles[FILE_KEY[n]]) {
|
|
1126
|
+
throw new CoreError(
|
|
1127
|
+
"save-created-unverified",
|
|
1128
|
+
`\u7248\u672C ${created} \u5DF2\u7ECF\u5199\u4E0A\u53BB\u4E86\uFF0C\u4F46 ${n} \u7684\u8BFB\u56DE\u5185\u5BB9\u4E0E\u5C06\u8981\u4FDD\u5B58\u7684\u4E0D\u4E00\u81F4\u3002\u8BF7\u5237\u65B0\u9875\u9762\u770B\u7248\u672C\u5217\u8868\u786E\u8BA4\u3002`,
|
|
1129
|
+
500
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
return { snapshot: entry, alreadySaved: false };
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
throw createdUnverifiedError(created, error);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// src/core/house.ts
|
|
1140
|
+
var PHASES = [
|
|
1141
|
+
"design",
|
|
1142
|
+
"foundation",
|
|
1143
|
+
"structure",
|
|
1144
|
+
"enclosure",
|
|
1145
|
+
"services",
|
|
1146
|
+
"interior",
|
|
1147
|
+
"accepted"
|
|
1148
|
+
];
|
|
1149
|
+
var PROTOTYPES = {
|
|
1150
|
+
foyer: "#708c79",
|
|
1151
|
+
studio: "#4e8586",
|
|
1152
|
+
archive: "#9a8963",
|
|
1153
|
+
review: "#ab7966",
|
|
1154
|
+
observatory: "#728ba9"
|
|
1155
|
+
};
|
|
1156
|
+
var EPS = 1e-6;
|
|
1157
|
+
var SIGN_CLEARANCE = 0.07;
|
|
1158
|
+
var round = (n) => Math.round(n * 1e6) / 1e6;
|
|
1159
|
+
var eq = (a, b) => Math.abs(a - b) < EPS;
|
|
1160
|
+
var positiveOverlap = (a, b, c, d) => Math.min(b, d) - Math.max(a, c) > EPS;
|
|
1161
|
+
var overlap = (a, b) => positiveOverlap(a.x0, a.x1, b.x0, b.x1) && positiveOverlap(a.z0, a.z1, b.z0, b.z1);
|
|
1162
|
+
var inside = (b, p, margin = 0) => p.x >= b.x0 + margin - EPS && p.x <= b.x1 - margin + EPS && p.z >= b.z0 + margin - EPS && p.z <= b.z1 - margin + EPS;
|
|
1163
|
+
var sorted = (xs) => [...xs].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
1164
|
+
var sides = ["north", "south", "east", "west"];
|
|
1165
|
+
var boxRect = (r) => ({
|
|
1166
|
+
x0: round(r.x - r.width / 2),
|
|
1167
|
+
x1: round(r.x + r.width / 2),
|
|
1168
|
+
z0: round(r.z - r.depth / 2),
|
|
1169
|
+
z1: round(r.z + r.depth / 2)
|
|
1170
|
+
});
|
|
1171
|
+
var boundsOf = (bs) => ({
|
|
1172
|
+
x0: Math.min(...bs.map((b) => b.x0)),
|
|
1173
|
+
x1: Math.max(...bs.map((b) => b.x1)),
|
|
1174
|
+
z0: Math.min(...bs.map((b) => b.z0)),
|
|
1175
|
+
z1: Math.max(...bs.map((b) => b.z1))
|
|
1176
|
+
});
|
|
1177
|
+
var DEFAULT_STATUS = {
|
|
1178
|
+
design: "detailed",
|
|
1179
|
+
approval: "pending",
|
|
1180
|
+
implementation: "unknown",
|
|
1181
|
+
tests: "pending",
|
|
1182
|
+
acceptance: "pending",
|
|
1183
|
+
evidence: {}
|
|
1184
|
+
};
|
|
1185
|
+
var S = { type: "string" };
|
|
1186
|
+
var N = { type: "number" };
|
|
1187
|
+
var B = { type: "boolean" };
|
|
1188
|
+
var enumeration = (values) => ({ type: "string", enum: values });
|
|
1189
|
+
var object = (props, required = Object.keys(props)) => ({
|
|
1190
|
+
type: "object",
|
|
1191
|
+
props,
|
|
1192
|
+
required
|
|
1193
|
+
});
|
|
1194
|
+
var array = (item) => ({ type: "array", item });
|
|
1195
|
+
var idRule = { type: "string", pattern: /^[a-z][a-z0-9-]*$/ };
|
|
1196
|
+
var businessIdRule = { type: "string", pattern: /^[A-Za-z0-9][A-Za-z0-9._-]*$/ };
|
|
1197
|
+
var statusRule = object({
|
|
1198
|
+
design: enumeration(["concept", "detailed"]),
|
|
1199
|
+
approval: enumeration(["pending", "approved"]),
|
|
1200
|
+
implementation: enumeration(["absent", "unknown", "present"]),
|
|
1201
|
+
tests: enumeration(["pending", "passed"]),
|
|
1202
|
+
acceptance: enumeration(["pending", "passed"]),
|
|
1203
|
+
evidence: object({ implementation: S, tests: S, acceptance: S }, [])
|
|
1204
|
+
});
|
|
1205
|
+
var endpointRule = object({ spaceId: idRule, x: N, z: N });
|
|
1206
|
+
var blueprintRule = object(
|
|
1207
|
+
{
|
|
1208
|
+
format: enumeration([BUILDING_FORMAT]),
|
|
1209
|
+
schema: enumeration([BUILDING_SCHEMA]),
|
|
1210
|
+
units: enumeration(["m"]),
|
|
1211
|
+
style: enumeration(["warm-modern", "chinese-timber"]),
|
|
1212
|
+
defaults: object({ wallThickness: N, slabThickness: N, doorHeight: N }, []),
|
|
1213
|
+
floors: array(object({ id: idRule, name: S, elevation: N, height: N })),
|
|
1214
|
+
spaces: array(
|
|
1215
|
+
object(
|
|
1216
|
+
{
|
|
1217
|
+
id: idRule,
|
|
1218
|
+
name: S,
|
|
1219
|
+
kind: enumeration(["room", "corridor", "terrace", "yard"]),
|
|
1220
|
+
floorId: idRule,
|
|
1221
|
+
rect: object({ x: N, z: N, width: N, depth: N }),
|
|
1222
|
+
businessId: businessIdRule,
|
|
1223
|
+
directory: B,
|
|
1224
|
+
prototype: enumeration(Object.keys(PROTOTYPES)),
|
|
1225
|
+
height: N,
|
|
1226
|
+
accent: { type: "string", pattern: /^#[a-fA-F0-9]{6}$/ },
|
|
1227
|
+
wallType: enumeration(["plaster", "brick", "timber"]),
|
|
1228
|
+
status: statusRule
|
|
1229
|
+
},
|
|
1230
|
+
["id", "kind", "floorId", "rect"]
|
|
1231
|
+
)
|
|
1232
|
+
),
|
|
1233
|
+
openings: array(
|
|
1234
|
+
object(
|
|
1235
|
+
{
|
|
1236
|
+
id: idRule,
|
|
1237
|
+
kind: enumeration(["door", "window"]),
|
|
1238
|
+
spaceId: idRule,
|
|
1239
|
+
side: enumeration(sides),
|
|
1240
|
+
offset: N,
|
|
1241
|
+
width: N,
|
|
1242
|
+
height: N,
|
|
1243
|
+
sill: N,
|
|
1244
|
+
to: idRule
|
|
1245
|
+
},
|
|
1246
|
+
["id", "kind", "spaceId", "side", "offset", "width"]
|
|
1247
|
+
)
|
|
1248
|
+
),
|
|
1249
|
+
stairs: array(object({ id: idRule, from: endpointRule, to: endpointRule, width: N, steps: N })),
|
|
1250
|
+
roofs: array(
|
|
1251
|
+
object({ id: idRule, spaceIds: array(idRule), type: enumeration(["flat", "gable"]), rise: N, overhang: N }, [
|
|
1252
|
+
"id",
|
|
1253
|
+
"spaceIds",
|
|
1254
|
+
"type"
|
|
1255
|
+
])
|
|
1256
|
+
),
|
|
1257
|
+
construction: object({ phase: enumeration(PHASES), status: statusRule }),
|
|
1258
|
+
entry: endpointRule
|
|
1259
|
+
},
|
|
1260
|
+
["format", "schema", "units", "style", "floors", "spaces", "entry"]
|
|
1261
|
+
);
|
|
1262
|
+
function validateShape(value, rule, path3, report) {
|
|
1263
|
+
const type = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
|
|
1264
|
+
if (type !== rule.type || type === "number" && !Number.isFinite(value)) {
|
|
1265
|
+
report("INPUT_TYPE", path3, `\u9700\u8981 ${rule.type} \u7C7B\u578B${rule.type === "number" ? "\u7684\u6709\u9650\u6570\u503C" : ""}\u3002`);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (rule.type === "string") {
|
|
1269
|
+
const text = value;
|
|
1270
|
+
if (rule.enum && !rule.enum.includes(text)) report("INPUT_ENUM", path3, `\u5141\u8BB8\u503C\uFF1A${rule.enum.join("\u3001")}\u3002`);
|
|
1271
|
+
if (rule.pattern && !rule.pattern.test(text)) report("INPUT_PATTERN", path3, "\u683C\u5F0F\u4E0D\u5408\u6CD5\u3002");
|
|
1272
|
+
if (!text.trim()) report("INPUT_EMPTY", path3, "\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A\u3002");
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (rule.type === "object") {
|
|
1276
|
+
const record = value;
|
|
1277
|
+
for (const key of Object.keys(record)) {
|
|
1278
|
+
if (!Object.hasOwn(rule.props, key)) {
|
|
1279
|
+
report("INPUT_UNKNOWN_FIELD", `${path3}/${key}`, "\u672A\u5B9A\u4E49\u7684\u5B57\u6BB5\uFF0C\u8BF7\u68C0\u67E5\u62FC\u5199\u6216\u67E5\u9605\u5951\u7EA6\u3002");
|
|
1280
|
+
} else {
|
|
1281
|
+
validateShape(record[key], rule.props[key], `${path3}/${key}`, report);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
for (const key of rule.required) if (!Object.hasOwn(record, key)) report("INPUT_REQUIRED", `${path3}/${key}`, "\u7F3A\u5C11\u5FC5\u586B\u5B57\u6BB5\u3002");
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
if (rule.type === "array") value.forEach((item, i) => validateShape(item, rule.item, `${path3}/${i}`, report));
|
|
1288
|
+
}
|
|
1289
|
+
function sideOf(space, side) {
|
|
1290
|
+
const b = space.bounds;
|
|
1291
|
+
const alongX = side === "north" || side === "south";
|
|
1292
|
+
return {
|
|
1293
|
+
axis: alongX ? "x" : "z",
|
|
1294
|
+
at: side === "north" ? b.z0 : side === "south" ? b.z1 : side === "west" ? b.x0 : b.x1,
|
|
1295
|
+
lo: alongX ? b.x0 : b.z0,
|
|
1296
|
+
hi: alongX ? b.x1 : b.z1,
|
|
1297
|
+
center: alongX ? space.rect.x : space.rect.z,
|
|
1298
|
+
normal: { x: side === "west" ? 1 : side === "east" ? -1 : 0, y: 0, z: side === "north" ? 1 : side === "south" ? -1 : 0 }
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function sharedEdge(a, b) {
|
|
1302
|
+
const x = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
|
|
1303
|
+
const z = Math.min(a.z1, b.z1) - Math.max(a.z0, b.z0);
|
|
1304
|
+
if (x > EPS && (eq(a.z1, b.z0) || eq(a.z0, b.z1))) {
|
|
1305
|
+
return { axis: "x", at: eq(a.z1, b.z0) ? a.z1 : a.z0, lo: Math.max(a.x0, b.x0), hi: Math.min(a.x1, b.x1) };
|
|
1306
|
+
}
|
|
1307
|
+
if (z > EPS && (eq(a.x1, b.x0) || eq(a.x0, b.x1))) {
|
|
1308
|
+
return { axis: "z", at: eq(a.x1, b.x0) ? a.x1 : a.x0, lo: Math.max(a.z0, b.z0), hi: Math.min(a.z1, b.z1) };
|
|
1309
|
+
}
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
function inWallRange(edge, lo, hi) {
|
|
1313
|
+
return lo >= edge.lo - EPS && hi <= edge.hi + EPS;
|
|
1314
|
+
}
|
|
1315
|
+
function planeBox(axis, at, lo, hi, y0, y1, thickness) {
|
|
1316
|
+
return {
|
|
1317
|
+
position: { x: axis === "x" ? (lo + hi) / 2 : at, y: (y0 + y1) / 2, z: axis === "z" ? (lo + hi) / 2 : at },
|
|
1318
|
+
size: { x: axis === "x" ? hi - lo : thickness, y: y1 - y0, z: axis === "z" ? hi - lo : thickness }
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
function generateBuilding(input, sources) {
|
|
1322
|
+
const diagnostics = [];
|
|
1323
|
+
const report = (code, path3, message, ids = [], evidence = {}, severity = "error") => {
|
|
1324
|
+
diagnostics.push({ code, severity, message, subject: { path: path3, ids: [...ids] }, evidence });
|
|
1325
|
+
};
|
|
1326
|
+
const fail = () => ({ ok: false, model: null, diagnostics });
|
|
1327
|
+
const errors = () => diagnostics.some((d) => d.severity === "error");
|
|
1328
|
+
const rewriteIndexPaths = (source) => {
|
|
1329
|
+
if (typeof source !== "object" || source === null) return;
|
|
1330
|
+
const record = source;
|
|
1331
|
+
const idAt = (key, index) => {
|
|
1332
|
+
const list = record[key];
|
|
1333
|
+
if (!Array.isArray(list)) return null;
|
|
1334
|
+
const item = list[index];
|
|
1335
|
+
if (typeof item !== "object" || item === null) return null;
|
|
1336
|
+
const id = item.id;
|
|
1337
|
+
return typeof id === "string" ? id : null;
|
|
1338
|
+
};
|
|
1339
|
+
for (const d of diagnostics) {
|
|
1340
|
+
d.subject.path = d.subject.path.replace(
|
|
1341
|
+
/^\/(floors|spaces|openings|stairs|roofs)\/(\d+)(?=\/|$)/,
|
|
1342
|
+
(full, key, index) => {
|
|
1343
|
+
const id = idAt(key, Number(index));
|
|
1344
|
+
return id !== null ? `/${key}/${id}` : full;
|
|
1345
|
+
}
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
validateShape(input, blueprintRule, "", report);
|
|
1350
|
+
if (errors()) {
|
|
1351
|
+
rewriteIndexPaths(input);
|
|
1352
|
+
return fail();
|
|
1353
|
+
}
|
|
1354
|
+
const data = JSON.parse(JSON.stringify(input));
|
|
1355
|
+
const defaults = { wallThickness: 0.18, slabThickness: 0.18, doorHeight: 2.5, ...data.defaults ?? {} };
|
|
1356
|
+
const construction = data.construction ?? { phase: "design", status: structuredClone(DEFAULT_STATUS) };
|
|
1357
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
1358
|
+
if (!(value > 0 && value <= (key === "doorHeight" ? 8 : 0.6))) {
|
|
1359
|
+
report("DEFAULT_RANGE", `/defaults/${key}`, "\u539A\u5EA6\u5E94\u5728 (0, 0.6] \u7C73\uFF0C\u95E8\u9AD8\u5E94\u5728 (0, 8] \u7C73\u3002");
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (!data.floors.length || !data.spaces.length) report("EMPTY_BUILDING", "/spaces", "\u81F3\u5C11\u9700\u8981\u4E00\u5C42\u3001\u4E00\u4E2A\u7A7A\u95F4\u3002");
|
|
1363
|
+
if (data.spaces.length > 240 || data.floors.length > 24 || (data.openings ?? []).length > 2e3) {
|
|
1364
|
+
report("INPUT_LIMIT", "/spaces", "\u672C\u7248\u652F\u6301\u6700\u591A 240 \u7A7A\u95F4\u300124 \u5C42\u30012000 \u4E2A\u5F00\u53E3\u3002");
|
|
1365
|
+
}
|
|
1366
|
+
if ((data.stairs ?? []).length > 96 || (data.roofs ?? []).length > 240) {
|
|
1367
|
+
report("INPUT_LIMIT", "", "\u672C\u7248\u6700\u591A\u652F\u6301 96 \u8DD1\u697C\u68AF\u548C 240 \u4E2A\u5C4B\u9876\u3002");
|
|
1368
|
+
}
|
|
1369
|
+
if (errors()) return fail();
|
|
1370
|
+
const paths = /* @__PURE__ */ new Map();
|
|
1371
|
+
const identities = /* @__PURE__ */ new Map();
|
|
1372
|
+
const register = (item, container) => {
|
|
1373
|
+
const path3 = `${container}/${item.id}`;
|
|
1374
|
+
if (identities.has(item.id)) {
|
|
1375
|
+
report("ID_DUPLICATE", `${path3}/id`, "id \u5728\u84DD\u56FE\u5185\u91CD\u590D\u3002", [item.id], { duplicateOf: identities.get(item.id) });
|
|
1376
|
+
} else {
|
|
1377
|
+
identities.set(item.id, path3);
|
|
1378
|
+
paths.set(item.id, path3);
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
for (const key of ["floors", "spaces", "openings", "stairs", "roofs"]) {
|
|
1382
|
+
for (const item of data[key] ?? []) register(item, `/${key}`);
|
|
1383
|
+
}
|
|
1384
|
+
const pathOf = (id) => paths.get(id) ?? "";
|
|
1385
|
+
const checkStatus = (status, path3, ids = []) => {
|
|
1386
|
+
for (const key of ["implementation", "tests", "acceptance"]) {
|
|
1387
|
+
if (status[key] === (key === "implementation" ? "present" : "passed") && !(status.evidence[key] ?? "").trim()) {
|
|
1388
|
+
report("STATE_EVIDENCE_REQUIRED", `${path3}/evidence/${key}`, "\u6B64\u58F0\u660E\u5FC5\u987B\u6709\u5BF9\u5E94\u7684\u8BC1\u636E\u51FA\u5904\u3002", ids);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
if ((status.tests === "passed" || status.acceptance === "passed") && status.implementation !== "present") {
|
|
1392
|
+
report("STATE_CONTRADICTION", path3, "\u672A\u58F0\u660E\u6709\u5B9E\u73B0\uFF0C\u4E0D\u80FD\u58F0\u79F0\u5B9E\u73B0\u6D4B\u8BD5\u6216\u4F5C\u8005\u9A8C\u6536\u5DF2\u901A\u8FC7\u3002", ids);
|
|
1393
|
+
}
|
|
1394
|
+
if (status.approval === "approved" && status.design !== "detailed") {
|
|
1395
|
+
report("STATE_CONTRADICTION", path3, "\u672C\u7248\u53EA\u6709\u8BE6\u7EC6\u8BBE\u8BA1\u624D\u80FD\u767B\u8BB0\u6279\u51C6\u3002", ids);
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
checkStatus(construction.status, "/construction/status");
|
|
1399
|
+
const floors = sorted(data.floors).map((f) => ({ ...f, y: f.elevation }));
|
|
1400
|
+
const floorMap = new Map(floors.map((f) => [f.id, f]));
|
|
1401
|
+
for (const f of floors) {
|
|
1402
|
+
if (f.height < 2.6 || f.height > 12 || Math.abs(f.y) > 500) {
|
|
1403
|
+
report("FLOOR_RANGE", pathOf(f.id), "\u697C\u5C42\u51C0\u9AD8\u5E94\u5728 2.6\u201412 \u7C73\u4E4B\u95F4\uFF0C\u6807\u9AD8\u7EDD\u5BF9\u503C\u4E0D\u8D85\u8FC7 500 \u7C73\u3002", [f.id]);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const businessById = new Map(sources.businesses.map((b) => [b.id, b]));
|
|
1407
|
+
const spaces = sorted(data.spaces).map((s) => {
|
|
1408
|
+
const path3 = pathOf(s.id);
|
|
1409
|
+
const f = floorMap.get(s.floorId);
|
|
1410
|
+
if (!f) report("FLOOR_REF", `${path3}/floorId`, "\u697C\u5C42\u4E0D\u5B58\u5728\u3002", [s.id, s.floorId]);
|
|
1411
|
+
if (s.rect.width < 1.6 || s.rect.depth < 1.6 || s.rect.width > 200 || s.rect.depth > 200 || Math.abs(s.rect.x) > 1e3 || Math.abs(s.rect.z) > 1e3) {
|
|
1412
|
+
report("SPACE_DIMENSION", `${path3}/rect`, "\u7A7A\u95F4\u8FB9\u957F\u5E94\u4E3A 1.6\u2014200 \u7C73\uFF0C\u5750\u6807\u7EDD\u5BF9\u503C\u4E0D\u8D85\u8FC7 1000 \u7C73\u3002", [s.id]);
|
|
1413
|
+
}
|
|
1414
|
+
if (s.kind === "room" && !s.businessId) report("BUSINESS_REQUIRED", `${path3}/businessId`, "\u4E1A\u52A1\u623F\u9700\u8981\u7A33\u5B9A\u7684\u4E1A\u52A1 id\u3002", [s.id]);
|
|
1415
|
+
const height = s.height ?? f?.height ?? 3.4;
|
|
1416
|
+
if (height < 2.6 || height > 12) report("SPACE_HEIGHT", `${path3}/height`, "\u7A7A\u95F4\u5899\u9AD8\u5E94\u5728 2.6\u201412 \u7C73\u4E4B\u95F4\u3002", [s.id]);
|
|
1417
|
+
const status = s.status ?? structuredClone(construction.status);
|
|
1418
|
+
if (s.status) checkStatus(status, `${path3}/status`, [s.id]);
|
|
1419
|
+
if (construction.phase === "accepted" && s.kind === "room" && !(status.approval === "approved" && status.implementation === "present" && status.tests === "passed" && status.acceptance === "passed")) {
|
|
1420
|
+
report("ACCEPTANCE_INCOMPLETE", `${path3}/status`, "\u9A8C\u6536\u5165\u4F4F\u9700\u8981\u8BBE\u8BA1\u6279\u51C6\u3001\u5B9E\u73B0\u3001\u6D4B\u8BD5\u548C\u4F5C\u8005\u9A8C\u6536\u8BB0\u5F55\u9F50\u5907\u3002", [s.id]);
|
|
1421
|
+
}
|
|
1422
|
+
const source = s.businessId !== void 0 ? businessById.get(s.businessId) : void 0;
|
|
1423
|
+
const name2 = s.kind === "room" ? source?.name ?? s.name ?? s.businessId ?? s.id : s.name ?? s.id;
|
|
1424
|
+
return {
|
|
1425
|
+
...s,
|
|
1426
|
+
name: name2,
|
|
1427
|
+
intro: source?.intro,
|
|
1428
|
+
y: f?.y ?? 0,
|
|
1429
|
+
height,
|
|
1430
|
+
bounds: boxRect(s.rect),
|
|
1431
|
+
prototype: s.prototype ?? "studio",
|
|
1432
|
+
wallType: s.wallType ?? "plaster",
|
|
1433
|
+
accent: s.accent ?? PROTOTYPES[s.prototype ?? "studio"],
|
|
1434
|
+
status
|
|
1435
|
+
};
|
|
1436
|
+
});
|
|
1437
|
+
if (errors()) return fail();
|
|
1438
|
+
const spaceMap = new Map(spaces.map((s) => [s.id, s]));
|
|
1439
|
+
const rooms = spaces.filter((s) => s.kind === "room");
|
|
1440
|
+
for (let i = 0; i < spaces.length; i++) {
|
|
1441
|
+
for (let j = i + 1; j < spaces.length; j++) {
|
|
1442
|
+
const a = spaces[i];
|
|
1443
|
+
const b = spaces[j];
|
|
1444
|
+
if (a.floorId === b.floorId && overlap(a.bounds, b.bounds)) {
|
|
1445
|
+
report("SPACE_OVERLAP", `${pathOf(b.id)}/rect`, "\u540C\u5C42\u7A7A\u95F4\u5185\u90E8\u76F8\u4EA4\uFF1B\u5171\u8FB9\u53EF\u7528\uFF0C\u91CD\u53E0\u4E0D\u53EF\u7528\u3002", [a.id, b.id], {
|
|
1446
|
+
first: a.bounds,
|
|
1447
|
+
second: b.bounds
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
if (a.floorId !== b.floorId && overlap(a.bounds, b.bounds) && positiveOverlap(a.y, a.y + a.height + defaults.slabThickness, b.y, b.y + b.height + defaults.slabThickness)) {
|
|
1451
|
+
report("FLOOR_CLEARANCE", pathOf(b.id), "\u4E0A\u4E0B\u7A7A\u95F4\u51C0\u7A7A\u6216\u697C\u677F\u76F8\u4EA4\uFF0C\u8BF7\u8C03\u6574\u6807\u9AD8\u6216\u5899\u9AD8\u3002", [a.id, b.id]);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
const entrySpace = spaceMap.get(data.entry.spaceId);
|
|
1456
|
+
if (!entrySpace || !inside(entrySpace.bounds, data.entry, 0.35)) {
|
|
1457
|
+
report("ENTRY_POSITION", "/entry", "\u5165\u53E3\u8D77\u70B9\u9700\u4F4D\u4E8E\u5DF2\u767B\u8BB0\u7A7A\u95F4\u5185\uFF0C\u5E76\u4E0E\u8FB9\u7F18\u4FDD\u7559 0.35 \u7C73\u3002", [data.entry.spaceId]);
|
|
1458
|
+
}
|
|
1459
|
+
const entry = { ...data.entry, y: entrySpace?.y ?? 0 };
|
|
1460
|
+
const lines = /* @__PURE__ */ new Map();
|
|
1461
|
+
for (const room of rooms) {
|
|
1462
|
+
for (const side of sides) {
|
|
1463
|
+
const edge = sideOf(room, side);
|
|
1464
|
+
const key = `${room.floorId}|${edge.axis}|${edge.at}`;
|
|
1465
|
+
if (!lines.has(key)) lines.set(key, []);
|
|
1466
|
+
lines.get(key).push({ ...edge, room });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
const walls = [];
|
|
1470
|
+
for (const [key, entries] of [...lines].sort(([a], [b]) => a.localeCompare(b, "en"))) {
|
|
1471
|
+
const cuts = [...new Set(entries.flatMap((e) => [e.lo, e.hi]))].sort((a, b) => a - b);
|
|
1472
|
+
for (let i = 0; i < cuts.length - 1; i++) {
|
|
1473
|
+
const lo = cuts[i];
|
|
1474
|
+
const hi = cuts[i + 1];
|
|
1475
|
+
const owners = entries.filter((e) => e.lo < hi - EPS && e.hi > lo + EPS);
|
|
1476
|
+
if (!owners.length) continue;
|
|
1477
|
+
const room = owners[0].room;
|
|
1478
|
+
if (owners.some((e) => !eq(e.room.height, room.height) || e.room.wallType !== room.wallType)) {
|
|
1479
|
+
report("SHARED_WALL_CONFLICT", pathOf(room.id), "\u5171\u5899\u623F\u95F4\u5FC5\u987B\u4F7F\u7528\u76F8\u540C\u5899\u9AD8\u4E0E\u5899\u6750\u7C7B\u578B\u3002", owners.map((e) => e.room.id));
|
|
1480
|
+
}
|
|
1481
|
+
walls.push({
|
|
1482
|
+
id: `wall:${key}:${lo}:${hi}`,
|
|
1483
|
+
axis: owners[0].axis,
|
|
1484
|
+
at: owners[0].at,
|
|
1485
|
+
lo,
|
|
1486
|
+
hi,
|
|
1487
|
+
y: room.y,
|
|
1488
|
+
height: room.height,
|
|
1489
|
+
floorId: room.floorId,
|
|
1490
|
+
wallType: room.wallType,
|
|
1491
|
+
spaceIds: owners.map((e) => e.room.id).sort(),
|
|
1492
|
+
openings: [],
|
|
1493
|
+
segments: []
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const openings = [];
|
|
1498
|
+
for (const o of sorted(data.openings ?? [])) {
|
|
1499
|
+
const room = spaceMap.get(o.spaceId);
|
|
1500
|
+
const path3 = pathOf(o.id);
|
|
1501
|
+
if (!room || room.kind !== "room") {
|
|
1502
|
+
report("OPENING_ROOM", `${path3}/spaceId`, "\u5F00\u53E3\u5FC5\u987B\u5F15\u7528\u4E1A\u52A1\u623F\u3002", [o.id, o.spaceId]);
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
const edge = sideOf(room, o.side);
|
|
1506
|
+
const center = edge.center + o.offset;
|
|
1507
|
+
const lo = center - o.width / 2;
|
|
1508
|
+
const hi = center + o.width / 2;
|
|
1509
|
+
const height = o.height ?? (o.kind === "door" ? defaults.doorHeight : 1.2);
|
|
1510
|
+
const sill = o.sill ?? (o.kind === "door" ? 0 : 1);
|
|
1511
|
+
if (o.width < (o.kind === "door" ? 0.95 : 0.2) || height < 0.2 || o.kind === "door" && height < 2.2 || sill < 0 || sill + height > room.height - 0.12 || !inWallRange({ lo: edge.lo + 0.2, hi: edge.hi - 0.2 }, lo, hi)) {
|
|
1512
|
+
report("OPENING_SIZE", path3, "\u5F00\u53E3\u5C3A\u5BF8\u6216\u4F4D\u7F6E\u8D8A\u754C\uFF0C\u95E8\u9700\u81F3\u5C11\u5BBD 0.95\u3001\u9AD8 2.2 \u7C73\uFF0C\u5899\u89D2\u81F3\u5C11\u7559 0.2 \u7C73\u3002", [o.id]);
|
|
1513
|
+
}
|
|
1514
|
+
if (o.kind === "door") {
|
|
1515
|
+
const target = o.to !== void 0 ? spaceMap.get(o.to) : void 0;
|
|
1516
|
+
const shared = target ? sharedEdge(room.bounds, target.bounds) : null;
|
|
1517
|
+
if (sill !== 0 || !target || target.floorId !== room.floorId || !shared || shared.axis !== edge.axis || !eq(shared.at, edge.at) || !inWallRange(shared, lo, hi)) {
|
|
1518
|
+
report("DOOR_CONNECTION", path3, "\u95E8\u5FC5\u987B\u843D\u5728\u6765\u6E90\u623F\u95F4\u4E0E to \u7A7A\u95F4\u540C\u5C42\u7684\u5171\u8FB9\u4E0A\uFF0C\u4E14\u6CA1\u6709\u95E8\u69DB\u3002", [o.id, room.id, o.to ?? ""].filter(Boolean));
|
|
1519
|
+
}
|
|
1520
|
+
} else if (o.to !== void 0) {
|
|
1521
|
+
report("WINDOW_CONNECTION", `${path3}/to`, "\u7A97\u4E0D\u80FD\u58F0\u660E\u4E3A\u53EF\u901A\u884C\u8FDE\u63A5\u3002", [o.id]);
|
|
1522
|
+
}
|
|
1523
|
+
const wall = walls.find((w) => w.floorId === room.floorId && w.axis === edge.axis && eq(w.at, edge.at) && inWallRange(w, lo, hi));
|
|
1524
|
+
if (!wall) {
|
|
1525
|
+
report("OPENING_PARTITION", path3, "\u5F00\u53E3\u8DE8\u8D8A\u4E86\u5171\u5899\u5206\u533A\u7AEF\u70B9\uFF0C\u8BF7\u8C03\u6574\u4F4D\u7F6E\u3002", [o.id]);
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
const value = {
|
|
1529
|
+
...o,
|
|
1530
|
+
axis: edge.axis,
|
|
1531
|
+
at: edge.at,
|
|
1532
|
+
lo,
|
|
1533
|
+
hi,
|
|
1534
|
+
center,
|
|
1535
|
+
height,
|
|
1536
|
+
sill,
|
|
1537
|
+
y: room.y,
|
|
1538
|
+
floorId: room.floorId,
|
|
1539
|
+
wallId: wall.id,
|
|
1540
|
+
position: { x: edge.axis === "x" ? center : edge.at, y: room.y, z: edge.axis === "z" ? center : edge.at },
|
|
1541
|
+
normal: edge.normal
|
|
1542
|
+
};
|
|
1543
|
+
wall.openings.push(value);
|
|
1544
|
+
openings.push(value);
|
|
1545
|
+
}
|
|
1546
|
+
for (const wall of walls) {
|
|
1547
|
+
for (let i = 0; i < wall.openings.length; i++) {
|
|
1548
|
+
for (let j = i + 1; j < wall.openings.length; j++) {
|
|
1549
|
+
const a = wall.openings[i];
|
|
1550
|
+
const b = wall.openings[j];
|
|
1551
|
+
if (positiveOverlap(a.lo, a.hi, b.lo, b.hi) && positiveOverlap(a.sill, a.sill + a.height, b.sill, b.sill + b.height)) {
|
|
1552
|
+
report("OPENING_OVERLAP", pathOf(b.id), "\u540C\u4E00\u5899\u9762\u5F00\u53E3\u91CD\u53E0\uFF1B\u5171\u5899\u7684\u95E8\u53EA\u767B\u8BB0\u4E00\u6B21\u3002", [a.id, b.id]);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const cuts = [.../* @__PURE__ */ new Set([wall.lo, wall.hi, ...wall.openings.flatMap((o) => [o.lo, o.hi])])].sort((a, b) => a - b);
|
|
1557
|
+
for (let i = 0; i < cuts.length - 1; i++) {
|
|
1558
|
+
const lo = cuts[i];
|
|
1559
|
+
const hi = cuts[i + 1];
|
|
1560
|
+
const active = wall.openings.filter((o) => positiveOverlap(lo, hi, o.lo, o.hi)).sort((a, b) => a.sill - b.sill);
|
|
1561
|
+
let bottom = 0;
|
|
1562
|
+
for (const o of [...active, { sill: wall.height, height: 0 }]) {
|
|
1563
|
+
if (o.sill > bottom + EPS) {
|
|
1564
|
+
wall.segments.push({
|
|
1565
|
+
id: `${wall.id}:piece:${wall.segments.length}`,
|
|
1566
|
+
...planeBox(wall.axis, wall.at, lo, hi, wall.y + bottom, wall.y + o.sill, defaults.wallThickness)
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
bottom = Math.max(bottom, o.sill + o.height);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
const stairs = [];
|
|
1574
|
+
for (const s of sorted(data.stairs ?? [])) {
|
|
1575
|
+
const a = spaceMap.get(s.from.spaceId);
|
|
1576
|
+
const b = spaceMap.get(s.to.spaceId);
|
|
1577
|
+
const path3 = pathOf(s.id);
|
|
1578
|
+
if (!a || !b || a.floorId === b.floorId || b.y <= a.y) {
|
|
1579
|
+
report("STAIR_FLOORS", path3, "\u697C\u68AF\u9700\u4ECE\u4F4E\u5C42\u7A7A\u95F4\u8FDE\u5230\u9AD8\u5C42\u7A7A\u95F4\u3002", [s.id]);
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
if (a.kind === "room" || b.kind === "room") {
|
|
1583
|
+
report("STAIR_ENDPOINT_KIND", path3, "\u697C\u68AF\u7AEF\u70B9\u9700\u8FDE\u63A5\u8D70\u5ECA\u3001\u5E73\u53F0\u6216\u5EAD\u9662\uFF1B\u4E0D\u80FD\u7A7F\u8FC7\u672A\u5F00\u6D1E\u7684\u623F\u95F4\u8FB9\u754C\u3002", [s.id, a.id, b.id]);
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
const dx = s.to.x - s.from.x;
|
|
1587
|
+
const dz = s.to.z - s.from.z;
|
|
1588
|
+
const length = Math.hypot(dx, dz);
|
|
1589
|
+
const rise = b.y - a.y;
|
|
1590
|
+
if (!eq(dx, 0) && !eq(dz, 0) || length < rise || length < EPS || s.width < 1.2 || s.width > 8 || !Number.isInteger(s.steps) || s.steps < 2 || s.steps > 120) {
|
|
1591
|
+
report("STAIR_GEOMETRY", path3, "\u672C\u7248\u652F\u6301\u5BBD 1.2\u20148 \u7C73\u3001\u5761\u5EA6\u4E0D\u8D85\u8FC7 45\xB0\u30012\u2014120 \u7EA7\u7684\u8F74\u5411\u76F4\u8DD1\u68AF\u3002", [s.id]);
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
const ux = dx / length;
|
|
1595
|
+
const uz = dz / length;
|
|
1596
|
+
const rect = {
|
|
1597
|
+
x0: Math.min(s.from.x, s.to.x) - Math.abs(uz) * s.width / 2,
|
|
1598
|
+
x1: Math.max(s.from.x, s.to.x) + Math.abs(uz) * s.width / 2,
|
|
1599
|
+
z0: Math.min(s.from.z, s.to.z) - Math.abs(ux) * s.width / 2,
|
|
1600
|
+
z1: Math.max(s.from.z, s.to.z) + Math.abs(ux) * s.width / 2
|
|
1601
|
+
};
|
|
1602
|
+
let landed = true;
|
|
1603
|
+
for (const [space, p, sign] of [
|
|
1604
|
+
[a, s.from, -1],
|
|
1605
|
+
[b, s.to, 1]
|
|
1606
|
+
]) {
|
|
1607
|
+
for (const lateral of [-s.width / 2, s.width / 2]) {
|
|
1608
|
+
for (const longitudinal of [0, 0.6]) {
|
|
1609
|
+
const q = { x: p.x + ux * sign * longitudinal - uz * lateral, z: p.z + uz * sign * longitudinal + ux * lateral };
|
|
1610
|
+
if (!inside(space.bounds, q)) landed = false;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
if (inside(space.bounds, { x: p.x - ux * sign * 0.1, z: p.z - uz * sign * 0.1 }, 0.01)) landed = false;
|
|
1614
|
+
}
|
|
1615
|
+
if (!landed) {
|
|
1616
|
+
report("STAIR_LANDING", path3, "\u697C\u68AF\u7AEF\u70B9\u5FC5\u987B\u63A5\u5728\u5E73\u53F0\u8FB9\u754C\uFF0C\u5E73\u53F0\u8981\u5BB9\u7EB3\u68AF\u5BBD\u5E76\u5411\u5916\u5EF6\u4F38\u81F3\u5C11 0.6 \u7C73\u3002", [s.id, a.id, b.id]);
|
|
1617
|
+
}
|
|
1618
|
+
for (const space of spaces) {
|
|
1619
|
+
if (overlap(rect, space.bounds) && space.y + space.height > a.y + EPS && space.y - defaults.slabThickness < b.y + 2.2) {
|
|
1620
|
+
report("STAIR_SPACE_INTERSECTION", path3, "\u697C\u68AF\u6295\u5F71\u4E0E\u672C\u7248\u8981\u6C42\u7559\u7A7A\u7684\u68AF\u4E95\u76F8\u4EA4\uFF1B\u8BF7\u5206\u5272\u697C\u677F\u7A7A\u95F4\u907F\u5F00\u68AF\u4E95\u3002", [s.id, space.id]);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
const value = {
|
|
1624
|
+
...s,
|
|
1625
|
+
from: { ...s.from, y: a.y },
|
|
1626
|
+
to: { ...s.to, y: b.y },
|
|
1627
|
+
bounds: rect,
|
|
1628
|
+
length,
|
|
1629
|
+
rise,
|
|
1630
|
+
ux,
|
|
1631
|
+
uz
|
|
1632
|
+
};
|
|
1633
|
+
for (const other of stairs) {
|
|
1634
|
+
if (overlap(rect, other.bounds)) {
|
|
1635
|
+
const intersection = {
|
|
1636
|
+
x0: Math.max(rect.x0, other.bounds.x0),
|
|
1637
|
+
x1: Math.min(rect.x1, other.bounds.x1),
|
|
1638
|
+
z0: Math.max(rect.z0, other.bounds.z0),
|
|
1639
|
+
z1: Math.min(rect.z1, other.bounds.z1)
|
|
1640
|
+
};
|
|
1641
|
+
const yAt = (stair, x, z) => stair.from.y + ((x - stair.from.x) * stair.ux + (z - stair.from.z) * stair.uz) / stair.length * stair.rise;
|
|
1642
|
+
const gaps = [intersection.x0, intersection.x1].flatMap(
|
|
1643
|
+
(x) => [intersection.z0, intersection.z1].map((z) => yAt(value, x, z) - yAt(other, x, z))
|
|
1644
|
+
);
|
|
1645
|
+
if (Math.min(...gaps) < 2.2 && Math.max(...gaps) > -2.2) {
|
|
1646
|
+
report("STAIR_INTERSECTION", path3, "\u697C\u68AF\u4E4B\u95F4\u4E0D\u8DB3 2.2 \u7C73\u5934\u90E8\u51C0\u7A7A\u3002", [s.id, other.id]);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
stairs.push(value);
|
|
1651
|
+
if (rise / s.steps > 0.22 || length / s.steps < 0.24) {
|
|
1652
|
+
report("STAIR_COMFORT", path3, "\u8E0F\u6B65\u504F\u9661\u6216\u504F\u7A84\uFF0C\u5EFA\u8BAE\u8C03\u6574\u6B65\u6570\u548C\u6C34\u5E73\u957F\u5EA6\u4EE5\u6539\u5584\u89C2\u611F\u3002", [s.id], {
|
|
1653
|
+
risePerStep: rise / s.steps,
|
|
1654
|
+
runPerStep: length / s.steps
|
|
1655
|
+
}, "warning");
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
const navigation = {
|
|
1659
|
+
nodes: spaces.map((s) => ({ id: s.id, x: s.rect.x, y: s.y, z: s.rect.z })),
|
|
1660
|
+
edges: []
|
|
1661
|
+
};
|
|
1662
|
+
const connect = (a, b, kind, via) => {
|
|
1663
|
+
navigation.edges.push({ from: a, to: b, kind, via });
|
|
1664
|
+
};
|
|
1665
|
+
for (let i = 0; i < spaces.length; i++) {
|
|
1666
|
+
for (let j = i + 1; j < spaces.length; j++) {
|
|
1667
|
+
const a = spaces[i];
|
|
1668
|
+
const b = spaces[j];
|
|
1669
|
+
const edge = sharedEdge(a.bounds, b.bounds);
|
|
1670
|
+
if (a.kind !== "room" && b.kind !== "room" && a.floorId === b.floorId && edge && edge.hi - edge.lo >= 0.9) {
|
|
1671
|
+
connect(a.id, b.id, "open", null);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
for (const door of openings) {
|
|
1676
|
+
if (door.kind === "door" && door.to !== void 0 && spaceMap.has(door.to)) connect(door.spaceId, door.to, "door", door.id);
|
|
1677
|
+
}
|
|
1678
|
+
for (const stair of stairs) connect(stair.from.spaceId, stair.to.spaceId, "stair", stair.id);
|
|
1679
|
+
const seen = new Set(entrySpace ? [entrySpace.id] : []);
|
|
1680
|
+
const queue = [...seen];
|
|
1681
|
+
for (let i = 0; i < queue.length; i++) {
|
|
1682
|
+
for (const edge of navigation.edges) {
|
|
1683
|
+
const neighbor = edge.from === queue[i] ? edge.to : edge.to === queue[i] ? edge.from : null;
|
|
1684
|
+
if (neighbor && !seen.has(neighbor)) {
|
|
1685
|
+
seen.add(neighbor);
|
|
1686
|
+
queue.push(neighbor);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
for (const space of spaces) {
|
|
1691
|
+
if (!seen.has(space.id)) report("SPACE_UNREACHABLE", pathOf(space.id), "\u6309\u95E8\u548C\u697C\u68AF\u8FDE\u63A5\u4ECE\u5165\u53E3\u65E0\u6CD5\u5230\u8FBE\u6B64\u7A7A\u95F4\u3002", [space.id]);
|
|
1692
|
+
}
|
|
1693
|
+
const roofs = [];
|
|
1694
|
+
for (const r of sorted(data.roofs ?? [])) {
|
|
1695
|
+
const path3 = pathOf(r.id);
|
|
1696
|
+
const selected = r.spaceIds.map((id) => spaceMap.get(id));
|
|
1697
|
+
if (!selected.length || selected.some((s) => !s) || new Set(r.spaceIds).size !== r.spaceIds.length) {
|
|
1698
|
+
report("ROOF_REFS", `${path3}/spaceIds`, "\u5C4B\u9876\u9700\u5F15\u7528\u4E0D\u91CD\u590D\u7684\u73B0\u6709\u7A7A\u95F4\u3002", [r.id]);
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
const picked = selected;
|
|
1702
|
+
const bounds2 = boundsOf(picked.map((s) => s.bounds));
|
|
1703
|
+
const first = picked[0];
|
|
1704
|
+
const base = first.y + first.height;
|
|
1705
|
+
const area = picked.reduce((sum, s) => sum + s.rect.width * s.rect.depth, 0);
|
|
1706
|
+
const enclosingArea = (bounds2.x1 - bounds2.x0) * (bounds2.z1 - bounds2.z0);
|
|
1707
|
+
if (picked.some((s) => s.floorId !== first.floorId || !eq(s.y + s.height, base)) || Math.abs(area - enclosingArea) > EPS) {
|
|
1708
|
+
report("ROOF_RECTANGLE", path3, "\u672C\u7248\u5C4B\u9876\u8986\u76D6\u5FC5\u987B\u662F\u540C\u5C42\u3001\u540C\u5899\u9AD8\u4E14\u65E0\u7A7A\u6D1E\u7684\u5B8C\u6574\u77E9\u5F62\u3002", [r.id]);
|
|
1709
|
+
}
|
|
1710
|
+
const overhang = r.overhang ?? 0.35;
|
|
1711
|
+
const rise = r.type === "gable" ? r.rise ?? 1.8 : 0;
|
|
1712
|
+
if (overhang < 0 || overhang > 2 || r.type === "gable" && (rise <= 0 || rise > 8) || r.type === "flat" && r.rise !== void 0 && r.rise !== 0) {
|
|
1713
|
+
report("ROOF_SIZE", path3, "\u6311\u6A90\u8303\u56F4\u4E3A 0\u20142 \u7C73\uFF0C\u5761\u9876\u5347\u9AD8\u4E3A (0, 8] \u7C73\uFF0C\u5E73\u9876\u4E0D\u8BBE\u7F6E rise\u3002", [r.id]);
|
|
1714
|
+
}
|
|
1715
|
+
const expanded = {
|
|
1716
|
+
x0: bounds2.x0 - overhang,
|
|
1717
|
+
x1: bounds2.x1 + overhang,
|
|
1718
|
+
z0: bounds2.z0 - overhang,
|
|
1719
|
+
z1: bounds2.z1 + overhang
|
|
1720
|
+
};
|
|
1721
|
+
for (const s of spaces) {
|
|
1722
|
+
if (s.y > first.y + EPS && overlap(expanded, s.bounds) && s.y - defaults.slabThickness < base + rise + 0.2) {
|
|
1723
|
+
report("ROOF_UPPER_FLOOR", path3, "\u5C4B\u9876\u7A7F\u5165\u4E0A\u65B9\u697C\u5C42\uFF0C\u8BF7\u6539\u4E3A\u9876\u5C42\u5C4B\u9876\u6216\u8C03\u6574\u8303\u56F4\u3002", [r.id, s.id]);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
for (const other of roofs) {
|
|
1727
|
+
if (overlap(expanded, other.bounds) && positiveOverlap(base - 0.1, base + rise + 0.2, other.base - 0.1, other.base + other.rise + 0.2)) {
|
|
1728
|
+
report("ROOF_INTERSECTION", path3, "\u5C4B\u9876\u5305\u7EDC\u76F8\u4EA4\uFF1B\u672C\u7248\u4E0D\u81EA\u52A8\u5904\u7406\u4EA4\u63A5\u5C4B\u810A\u3002", [r.id, other.id]);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
roofs.push({ ...r, rise, overhang, bounds: expanded, base, floorId: first.floorId });
|
|
1732
|
+
}
|
|
1733
|
+
for (const room of rooms) {
|
|
1734
|
+
if (!roofs.some(
|
|
1735
|
+
(r) => inside(r.bounds, { x: room.bounds.x0, z: room.bounds.z0 }) && inside(r.bounds, { x: room.bounds.x1, z: room.bounds.z1 }) && r.base >= room.y + room.height - EPS
|
|
1736
|
+
)) {
|
|
1737
|
+
report("ROOF_MISSING", pathOf(room.id), "\u6B64\u623F\u95F4\u4E0A\u65B9\u672A\u767B\u8BB0\u5B8C\u6574\u8986\u76D6\u7684\u5C4B\u9876\uFF0C\u4ECD\u53EF\u751F\u6210\u8BBE\u8BA1\u6846\u67B6\u3002", [room.id], {}, "warning");
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
if (errors()) return fail();
|
|
1741
|
+
const floorJoints = [];
|
|
1742
|
+
const elevations = [...new Set(floors.map((f) => f.y))].sort((a, b) => a - b);
|
|
1743
|
+
for (const lower of rooms) {
|
|
1744
|
+
const nextY = elevations.find((y) => y > lower.y + EPS);
|
|
1745
|
+
if (nextY === void 0) continue;
|
|
1746
|
+
const y0 = round(lower.y + lower.height);
|
|
1747
|
+
const y1 = round(nextY - defaults.slabThickness);
|
|
1748
|
+
if (y1 - y0 <= EPS) continue;
|
|
1749
|
+
for (const upper of spaces) {
|
|
1750
|
+
if (!eq(upper.y, nextY) || !overlap(lower.bounds, upper.bounds)) continue;
|
|
1751
|
+
floorJoints.push({
|
|
1752
|
+
id: `floor-joint:${lower.id}:${upper.id}`,
|
|
1753
|
+
lowerSpaceId: lower.id,
|
|
1754
|
+
upperSpaceId: upper.id,
|
|
1755
|
+
lowerFloorId: lower.floorId,
|
|
1756
|
+
upperFloorId: upper.floorId,
|
|
1757
|
+
y0,
|
|
1758
|
+
y1,
|
|
1759
|
+
upperY: nextY,
|
|
1760
|
+
bounds: {
|
|
1761
|
+
x0: Math.max(lower.bounds.x0, upper.bounds.x0),
|
|
1762
|
+
x1: Math.min(lower.bounds.x1, upper.bounds.x1),
|
|
1763
|
+
z0: Math.max(lower.bounds.z0, upper.bounds.z0),
|
|
1764
|
+
z1: Math.min(lower.bounds.z1, upper.bounds.z1)
|
|
1765
|
+
}
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const signs = [
|
|
1770
|
+
{
|
|
1771
|
+
id: "project-sign",
|
|
1772
|
+
title: sources.project.name,
|
|
1773
|
+
subtitle: sources.project.description || "\u9879\u76EE\u5165\u53E3",
|
|
1774
|
+
position: { x: entry.x, y: entry.y + 2.6, z: entry.z },
|
|
1775
|
+
normal: { x: 0, y: 0, z: 1 }
|
|
1776
|
+
}
|
|
1777
|
+
];
|
|
1778
|
+
for (const room of rooms) {
|
|
1779
|
+
const door = openings.find((o) => o.kind === "door" && (o.spaceId === room.id || o.to === room.id));
|
|
1780
|
+
if (!door) continue;
|
|
1781
|
+
const normal = door.spaceId === room.id ? { x: -door.normal.x, y: 0, z: -door.normal.z } : door.normal;
|
|
1782
|
+
const signGap = defaults.wallThickness / 2 + SIGN_CLEARANCE;
|
|
1783
|
+
signs.push({
|
|
1784
|
+
id: `sign:${room.id}`,
|
|
1785
|
+
spaceId: room.id,
|
|
1786
|
+
floorId: room.floorId,
|
|
1787
|
+
title: room.name,
|
|
1788
|
+
subtitle: room.intro ?? room.businessId,
|
|
1789
|
+
position: { x: door.position.x + normal.x * signGap, y: room.y + door.height + 0.27, z: door.position.z + normal.z * signGap },
|
|
1790
|
+
normal
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
const bounds = {
|
|
1794
|
+
...boundsOf([...spaces.map((s) => s.bounds), ...roofs.map((r) => r.bounds), ...stairs.map((s) => s.bounds)]),
|
|
1795
|
+
y0: Math.min(...spaces.map((s) => s.y)) - defaults.slabThickness,
|
|
1796
|
+
y1: Math.max(...spaces.map((s) => s.y + s.height), ...roofs.map((r) => r.base + r.rise + 0.2))
|
|
1797
|
+
};
|
|
1798
|
+
const model = {
|
|
1799
|
+
format: "specdev-building-model",
|
|
1800
|
+
generator: BUILDING_SCHEMA,
|
|
1801
|
+
project: { name: sources.project.name, description: sources.project.description },
|
|
1802
|
+
style: data.style,
|
|
1803
|
+
defaults,
|
|
1804
|
+
construction,
|
|
1805
|
+
floors,
|
|
1806
|
+
spaces,
|
|
1807
|
+
rooms,
|
|
1808
|
+
walls,
|
|
1809
|
+
doors: openings.filter((o) => o.kind === "door"),
|
|
1810
|
+
openings,
|
|
1811
|
+
stairs,
|
|
1812
|
+
roofs,
|
|
1813
|
+
floorJoints,
|
|
1814
|
+
signs,
|
|
1815
|
+
entry,
|
|
1816
|
+
bounds,
|
|
1817
|
+
navigation,
|
|
1818
|
+
diagnostics
|
|
1819
|
+
};
|
|
1820
|
+
return { ok: true, model, diagnostics };
|
|
1821
|
+
}
|
|
1822
|
+
function presentBuilding(model, { preview = false } = {}) {
|
|
1823
|
+
const parts = [];
|
|
1824
|
+
const colliders = [];
|
|
1825
|
+
const surfaces = [];
|
|
1826
|
+
const phase = PHASES.indexOf(model.construction.phase);
|
|
1827
|
+
const timber = model.style === "chinese-timber";
|
|
1828
|
+
const palette = timber ? { frame: "#74503b", slab: "#b2a18b", wall: "#dfd6c4", roof: "#4b5550" } : { frame: "#a88059", slab: "#d8c9b5", wall: "#ece1cd", roof: "#69756f" };
|
|
1829
|
+
const roomMap = new Map(model.rooms.map((r) => [r.id, r]));
|
|
1830
|
+
const groundY = Math.min(...model.spaces.map((s) => s.y));
|
|
1831
|
+
const part = (id, kind, position, size, color, { opacity = 1, solid = false, rotation, spaceIds = [] } = {}) => {
|
|
1832
|
+
if (Math.min(size.x, size.y, size.z) <= 0) return;
|
|
1833
|
+
const value = { id, kind, position, size, color, opacity, solid, spaceIds };
|
|
1834
|
+
if (rotation) value.rotation = rotation;
|
|
1835
|
+
parts.push(value);
|
|
1836
|
+
if (solid && !rotation) {
|
|
1837
|
+
colliders.push({
|
|
1838
|
+
id,
|
|
1839
|
+
x0: position.x - size.x / 2,
|
|
1840
|
+
x1: position.x + size.x / 2,
|
|
1841
|
+
y0: position.y - size.y / 2,
|
|
1842
|
+
y1: position.y + size.y / 2,
|
|
1843
|
+
z0: position.z - size.z / 2,
|
|
1844
|
+
z1: position.z + size.z / 2
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
for (const space of model.spaces) {
|
|
1849
|
+
const { rect, y, bounds } = space;
|
|
1850
|
+
const builtFloor = preview || phase >= 2 || phase === 1 && eq(y, groundY);
|
|
1851
|
+
const depth = builtFloor ? model.defaults.slabThickness : 0.035;
|
|
1852
|
+
const kind = !builtFloor ? "plan-floor" : phase === 1 && !preview ? "foundation" : "floor";
|
|
1853
|
+
part(
|
|
1854
|
+
`floor:${space.id}`,
|
|
1855
|
+
kind,
|
|
1856
|
+
{ x: rect.x, y: y - depth / 2, z: rect.z },
|
|
1857
|
+
{ x: rect.width, y: depth, z: rect.depth },
|
|
1858
|
+
space.kind === "room" ? space.accent : palette.slab,
|
|
1859
|
+
{ opacity: builtFloor ? 1 : 0.35, spaceIds: [space.id] }
|
|
1860
|
+
);
|
|
1861
|
+
surfaces.push({ id: space.id, kind: "flat", bounds, y, spaceId: space.id });
|
|
1862
|
+
}
|
|
1863
|
+
const jointFrameKeys = /* @__PURE__ */ new Set();
|
|
1864
|
+
for (const joint of model.floorJoints) {
|
|
1865
|
+
const b = joint.bounds;
|
|
1866
|
+
const spaceIds = [joint.upperSpaceId];
|
|
1867
|
+
if (preview || phase >= 2) {
|
|
1868
|
+
part(
|
|
1869
|
+
joint.id,
|
|
1870
|
+
"floor-joint",
|
|
1871
|
+
{ x: (b.x0 + b.x1) / 2, y: (joint.y0 + joint.y1) / 2, z: (b.z0 + b.z1) / 2 },
|
|
1872
|
+
{ x: b.x1 - b.x0, y: joint.y1 - joint.y0, z: b.z1 - b.z0 },
|
|
1873
|
+
palette.slab,
|
|
1874
|
+
{ solid: true, spaceIds }
|
|
1875
|
+
);
|
|
1876
|
+
} else {
|
|
1877
|
+
for (const x of [b.x0, b.x1]) {
|
|
1878
|
+
for (const z of [b.z0, b.z1]) {
|
|
1879
|
+
const key = `${x}:${z}:${joint.y0}:${joint.upperY}`;
|
|
1880
|
+
if (jointFrameKeys.has(key)) continue;
|
|
1881
|
+
jointFrameKeys.add(key);
|
|
1882
|
+
part(
|
|
1883
|
+
`joint-frame:${key}`,
|
|
1884
|
+
"floor-joint-frame",
|
|
1885
|
+
{ x, y: (joint.y0 + joint.upperY) / 2, z },
|
|
1886
|
+
{ x: 0.045, y: joint.upperY - joint.y0, z: 0.045 },
|
|
1887
|
+
palette.frame,
|
|
1888
|
+
{ opacity: 0.55, spaceIds }
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
const frameKeys = /* @__PURE__ */ new Set();
|
|
1895
|
+
for (const wall of model.walls) {
|
|
1896
|
+
const owners = wall.spaceIds.map((id) => roomMap.get(id));
|
|
1897
|
+
const implemented = owners.every((r) => r?.status.implementation === "present");
|
|
1898
|
+
const complete = owners.every((r) => r?.status.tests === "passed" && r?.status.acceptance === "passed");
|
|
1899
|
+
const showSkin = preview || phase >= 3;
|
|
1900
|
+
if (showSkin) {
|
|
1901
|
+
for (const segment of wall.segments) {
|
|
1902
|
+
const color = preview || implemented && complete ? wall.wallType === "brick" ? "#938777" : wall.wallType === "timber" ? "#a08060" : palette.wall : implemented ? "#e4b84e" : "#a1c9d0";
|
|
1903
|
+
part(segment.id, "wall", segment.position, segment.size, color, {
|
|
1904
|
+
opacity: preview || implemented ? 1 : 0.22,
|
|
1905
|
+
solid: preview || implemented,
|
|
1906
|
+
spaceIds: wall.spaceIds
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
const width = phase >= 2 || preview ? 0.14 : 0.055;
|
|
1911
|
+
for (const at of [wall.lo, wall.hi]) {
|
|
1912
|
+
const x = wall.axis === "x" ? at : wall.at;
|
|
1913
|
+
const z = wall.axis === "z" ? at : wall.at;
|
|
1914
|
+
const key = `${wall.floorId}:${x}:${z}:${wall.height}`;
|
|
1915
|
+
if (frameKeys.has(key)) continue;
|
|
1916
|
+
frameKeys.add(key);
|
|
1917
|
+
part(
|
|
1918
|
+
`post:${key}`,
|
|
1919
|
+
"frame",
|
|
1920
|
+
{ x, y: wall.y + wall.height / 2, z },
|
|
1921
|
+
{ x: width, y: wall.height, z: width },
|
|
1922
|
+
palette.frame,
|
|
1923
|
+
{ opacity: phase === 0 && !preview ? 0.65 : 1, spaceIds: wall.spaceIds }
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
const beam = planeBox(wall.axis, wall.at, wall.lo, wall.hi, wall.y + wall.height - width, wall.y + wall.height, width);
|
|
1927
|
+
part(`beam:${wall.id}`, "frame", beam.position, beam.size, palette.frame, { spaceIds: wall.spaceIds });
|
|
1928
|
+
for (const o of wall.openings) {
|
|
1929
|
+
for (const at of [o.lo, o.hi]) {
|
|
1930
|
+
const jamb = planeBox(wall.axis, wall.at, at - 0.045, at + 0.045, wall.y + o.sill, wall.y + o.sill + o.height, model.defaults.wallThickness + 0.045);
|
|
1931
|
+
part(`jamb:${o.id}:${at}`, "opening-frame", jamb.position, jamb.size, palette.frame, {
|
|
1932
|
+
opacity: preview || phase >= 2 ? 1 : 0.35,
|
|
1933
|
+
spaceIds: wall.spaceIds
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
const lintel = planeBox(wall.axis, wall.at, o.lo, o.hi, wall.y + o.sill + o.height, wall.y + o.sill + o.height + 0.07, model.defaults.wallThickness + 0.045);
|
|
1937
|
+
part(`lintel:${o.id}`, "opening-frame", lintel.position, lintel.size, palette.frame, {
|
|
1938
|
+
opacity: preview || phase >= 2 ? 1 : 0.35,
|
|
1939
|
+
spaceIds: wall.spaceIds
|
|
1940
|
+
});
|
|
1941
|
+
if (o.kind === "window" && showSkin) {
|
|
1942
|
+
const glass = planeBox(wall.axis, wall.at, o.lo, o.hi, wall.y + o.sill, wall.y + o.sill + o.height, 0.035);
|
|
1943
|
+
part(`glass:${o.id}`, "glass", glass.position, glass.size, "#afcbd0", {
|
|
1944
|
+
opacity: 0.28,
|
|
1945
|
+
solid: preview || implemented,
|
|
1946
|
+
spaceIds: wall.spaceIds
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
for (const stair of model.stairs) {
|
|
1952
|
+
surfaces.push({
|
|
1953
|
+
id: stair.id,
|
|
1954
|
+
kind: "stair",
|
|
1955
|
+
bounds: stair.bounds,
|
|
1956
|
+
from: stair.from,
|
|
1957
|
+
to: stair.to,
|
|
1958
|
+
ux: stair.ux,
|
|
1959
|
+
uz: stair.uz,
|
|
1960
|
+
length: stair.length,
|
|
1961
|
+
rise: stair.rise,
|
|
1962
|
+
width: stair.width
|
|
1963
|
+
});
|
|
1964
|
+
for (let i = 0; i < stair.steps; i++) {
|
|
1965
|
+
const t = (i + 0.5) / stair.steps;
|
|
1966
|
+
const h = (i + 1) / stair.steps * stair.rise;
|
|
1967
|
+
part(
|
|
1968
|
+
`step:${stair.id}:${i}`,
|
|
1969
|
+
"stair",
|
|
1970
|
+
{ x: stair.from.x + stair.ux * stair.length * t, y: stair.from.y + h - 0.065, z: stair.from.z + stair.uz * stair.length * t },
|
|
1971
|
+
{
|
|
1972
|
+
x: Math.abs(stair.ux) > 0.5 ? stair.length / stair.steps : stair.width,
|
|
1973
|
+
y: 0.13,
|
|
1974
|
+
z: Math.abs(stair.uz) > 0.5 ? stair.length / stair.steps : stair.width
|
|
1975
|
+
},
|
|
1976
|
+
palette.frame,
|
|
1977
|
+
{ opacity: preview || phase >= 2 ? 1 : 0.28, spaceIds: [stair.from.spaceId, stair.to.spaceId] }
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
for (const roof of model.roofs) {
|
|
1982
|
+
const b = roof.bounds;
|
|
1983
|
+
const width = b.x1 - b.x0;
|
|
1984
|
+
const depth = b.z1 - b.z0;
|
|
1985
|
+
const x = (b.x0 + b.x1) / 2;
|
|
1986
|
+
const z = (b.z0 + b.z1) / 2;
|
|
1987
|
+
if (preview || phase >= 3) {
|
|
1988
|
+
if (roof.type === "flat") {
|
|
1989
|
+
part(`roof:${roof.id}`, "roof", { x, y: roof.base + 0.09, z }, { x: width, y: 0.18, z: depth }, palette.roof, {
|
|
1990
|
+
spaceIds: roof.spaceIds
|
|
1991
|
+
});
|
|
1992
|
+
} else {
|
|
1993
|
+
for (const sign of [-1, 1]) {
|
|
1994
|
+
part(
|
|
1995
|
+
`roof:${roof.id}:${sign}`,
|
|
1996
|
+
"roof",
|
|
1997
|
+
{ x, y: roof.base + roof.rise / 2, z: z + sign * depth / 4 },
|
|
1998
|
+
{ x: width, y: 0.16, z: Math.hypot(depth / 2, roof.rise) },
|
|
1999
|
+
palette.roof,
|
|
2000
|
+
{ rotation: { x: sign * Math.atan2(roof.rise, depth / 2), y: 0, z: 0 }, spaceIds: roof.spaceIds }
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
} else {
|
|
2005
|
+
if (roof.type === "gable") {
|
|
2006
|
+
part(`ridge:${roof.id}`, "roof-frame", { x, y: roof.base + roof.rise, z }, { x: width, y: 0.045, z: 0.045 }, palette.frame, {
|
|
2007
|
+
opacity: 0.55,
|
|
2008
|
+
spaceIds: roof.spaceIds
|
|
2009
|
+
});
|
|
2010
|
+
for (const end of [b.x0, b.x1]) {
|
|
2011
|
+
for (const sign of [-1, 1]) {
|
|
2012
|
+
part(
|
|
2013
|
+
`rafter:${roof.id}:${end}:${sign}`,
|
|
2014
|
+
"roof-frame",
|
|
2015
|
+
{ x: end, y: roof.base + roof.rise / 2, z: z + sign * depth / 4 },
|
|
2016
|
+
{ x: 0.045, y: 0.045, z: Math.hypot(depth / 2, roof.rise) },
|
|
2017
|
+
palette.frame,
|
|
2018
|
+
{ rotation: { x: sign * Math.atan2(roof.rise, depth / 2), y: 0, z: 0 }, opacity: 0.55, spaceIds: roof.spaceIds }
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
return { parts, colliders, surfaces };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// src/core/building.ts
|
|
2029
|
+
async function readBuildingBlueprint(repoRoot) {
|
|
2030
|
+
const text = await readWorktreeFileOptional(repoRoot, BUILDING_BLUEPRINT_REL);
|
|
2031
|
+
if (text === null) {
|
|
2032
|
+
throw new CoreError(
|
|
2033
|
+
"no-building-blueprint",
|
|
2034
|
+
`\u8BE5\u9879\u76EE\u8FD8\u6CA1\u6709\u5EFA\u7B51\u84DD\u56FE\uFF1A\u672A\u627E\u5230 ${BUILDING_BLUEPRINT_REL}\u3002\u4E0D\u4F1A\u81EA\u52A8\u521B\u5EFA\uFF1B\u84DD\u56FE\u63CF\u8FF0\u7A7A\u95F4\u5B89\u6392\uFF0C\u4E1A\u52A1\u4E0E\u8D44\u6599\u5185\u5BB9\u4EE5\u9879\u76EE\u6E05\u5355\u4E3A\u51C6\u3002`,
|
|
2035
|
+
404
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
let data;
|
|
2039
|
+
try {
|
|
2040
|
+
data = JSON.parse(text);
|
|
2041
|
+
} catch (error) {
|
|
2042
|
+
throw new CoreError("bad-blueprint", `${BUILDING_BLUEPRINT_REL} \u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${error.message}`, 500);
|
|
2043
|
+
}
|
|
2044
|
+
if (typeof data !== "object" || data === null || Array.isArray(data) || data.format !== BUILDING_FORMAT || data.schema !== BUILDING_SCHEMA) {
|
|
2045
|
+
throw new CoreError(
|
|
2046
|
+
"bad-blueprint",
|
|
2047
|
+
`${BUILDING_BLUEPRINT_REL} \u7684\u683C\u5F0F\u5934\u5FC5\u987B\u662F format=${BUILDING_FORMAT}\u3001schema=${BUILDING_SCHEMA}`,
|
|
2048
|
+
500
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
return data;
|
|
2052
|
+
}
|
|
2053
|
+
function extractSpaces(blueprint, problems) {
|
|
2054
|
+
const rawSpaces = blueprint.spaces;
|
|
2055
|
+
if (!Array.isArray(rawSpaces)) {
|
|
2056
|
+
problems.push({
|
|
2057
|
+
code: "BLUEPRINT_SHAPE",
|
|
2058
|
+
severity: "error",
|
|
2059
|
+
message: `${BUILDING_BLUEPRINT_REL} \u7F3A\u5C11 spaces \u6570\u7EC4\uFF08\u7A7A\u95F4\u5B89\u6392\u65E0\u6CD5\u6838\u5BF9\u7ED1\u5B9A\uFF09\u3002`,
|
|
2060
|
+
subject: { path: "/spaces", ids: [] }
|
|
2061
|
+
});
|
|
2062
|
+
return [];
|
|
2063
|
+
}
|
|
2064
|
+
const spaces = [];
|
|
2065
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2066
|
+
rawSpaces.forEach((raw, index) => {
|
|
2067
|
+
if (typeof raw !== "object" || raw === null || typeof raw.id !== "string" || raw.id === "") {
|
|
2068
|
+
problems.push({
|
|
2069
|
+
code: "BLUEPRINT_SHAPE",
|
|
2070
|
+
severity: "error",
|
|
2071
|
+
message: `spaces/${index} \u4E0D\u662F\u5E26\u975E\u7A7A id \u7684\u5BF9\u8C61\uFF0C\u65E0\u6CD5\u53C2\u4E0E\u7ED1\u5B9A\u6838\u5BF9\u3002`,
|
|
2072
|
+
subject: { path: `/spaces/${index}`, ids: [] }
|
|
2073
|
+
});
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
const space = raw;
|
|
2077
|
+
const first = seen.get(space.id);
|
|
2078
|
+
if (first !== void 0) {
|
|
2079
|
+
problems.push({
|
|
2080
|
+
code: "BLUEPRINT_SHAPE",
|
|
2081
|
+
severity: "error",
|
|
2082
|
+
message: `\u7A7A\u95F4 id ${space.id} \u5728\u84DD\u56FE\u5185\u91CD\u590D\u3002`,
|
|
2083
|
+
subject: { path: `/spaces/${index}/id`, ids: [space.id] },
|
|
2084
|
+
evidence: { firstAt: `/spaces/${first}/id` }
|
|
2085
|
+
});
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
seen.set(space.id, index);
|
|
2089
|
+
spaces.push(space);
|
|
2090
|
+
});
|
|
2091
|
+
return spaces;
|
|
2092
|
+
}
|
|
2093
|
+
function checkBusinessBinding(inventory, blueprint) {
|
|
2094
|
+
const problems = [];
|
|
2095
|
+
const spaces = extractSpaces(blueprint, problems);
|
|
2096
|
+
for (const space of spaces) {
|
|
2097
|
+
if (space.directory !== void 0 && typeof space.directory !== "boolean") {
|
|
2098
|
+
problems.push({
|
|
2099
|
+
code: "BLUEPRINT_SHAPE",
|
|
2100
|
+
severity: "error",
|
|
2101
|
+
message: `\u7A7A\u95F4 ${space.id} \u7684 directory \u5FC5\u987B\u662F\u5E03\u5C14\u503C\uFF08\u7F3A\u7701\u89C6\u4E3A\u5F00\u542F\uFF09\uFF1B\u6536\u5230 ${JSON.stringify(space.directory)}\u3002`,
|
|
2102
|
+
subject: { path: `/spaces/${space.id}/directory`, ids: [space.id] },
|
|
2103
|
+
evidence: { got: JSON.stringify(space.directory) }
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
const businessById = new Map(inventory.businesses.map((b) => [b.id, b]));
|
|
2108
|
+
const rooms = [];
|
|
2109
|
+
const boundSpaces = /* @__PURE__ */ new Map();
|
|
2110
|
+
for (const space of spaces) {
|
|
2111
|
+
const kind = typeof space.kind === "string" ? space.kind : "";
|
|
2112
|
+
const hasBusiness = typeof space.businessId === "string" && space.businessId !== "";
|
|
2113
|
+
if (kind !== "room") {
|
|
2114
|
+
if (hasBusiness) {
|
|
2115
|
+
problems.push({
|
|
2116
|
+
code: "BUSINESS_NOT_ROOM",
|
|
2117
|
+
severity: "error",
|
|
2118
|
+
message: `\u7A7A\u95F4 ${space.id} \u4E0D\u662F room\uFF0C\u4E0D\u80FD\u643A\u5E26 businessId\uFF08\u7B2C\u4E00\u671F\u4E1A\u52A1\u8EAB\u4EFD\u53EA\u843D\u5728\u623F\u95F4\u4E0A\uFF09\u3002`,
|
|
2119
|
+
subject: { path: `/spaces/${space.id}/businessId`, ids: [space.id, space.businessId] }
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
if (!hasBusiness) {
|
|
2125
|
+
problems.push({
|
|
2126
|
+
code: "BUSINESS_REQUIRED",
|
|
2127
|
+
severity: "error",
|
|
2128
|
+
message: `\u623F\u95F4 ${space.id} \u7F3A\u5C11 businessId\uFF1B\u7B2C\u4E00\u671F\u4E00\u4E1A\u52A1\u4E00\u623F\u95F4\uFF0C\u6BCF\u95F4\u4E1A\u52A1\u623F\u90FD\u8981\u7ED1\u5B9A\u6E05\u5355\u91CC\u7684\u4E1A\u52A1\u3002`,
|
|
2129
|
+
subject: { path: `/spaces/${space.id}/businessId`, ids: [space.id] }
|
|
2130
|
+
});
|
|
2131
|
+
continue;
|
|
2132
|
+
}
|
|
2133
|
+
const businessId = space.businessId;
|
|
2134
|
+
const business = businessById.get(businessId);
|
|
2135
|
+
if (!business) {
|
|
2136
|
+
problems.push({
|
|
2137
|
+
code: "BUSINESS_UNKNOWN",
|
|
2138
|
+
severity: "error",
|
|
2139
|
+
message: `\u623F\u95F4 ${space.id} \u7ED1\u5B9A\u7684\u4E1A\u52A1 ${businessId} \u4E0D\u5728\u9879\u76EE\u6E05\u5355\uFF08docs/specdev/ \u7684\u4E1A\u52A1\u76EE\u5F55\uFF09\u91CC\u3002`,
|
|
2140
|
+
subject: { path: `/spaces/${space.id}/businessId`, ids: [businessId, space.id] }
|
|
2141
|
+
});
|
|
2142
|
+
continue;
|
|
2143
|
+
}
|
|
2144
|
+
const firstSpace = boundSpaces.get(businessId);
|
|
2145
|
+
if (firstSpace !== void 0) {
|
|
2146
|
+
problems.push({
|
|
2147
|
+
code: "BUSINESS_DUPLICATE",
|
|
2148
|
+
severity: "error",
|
|
2149
|
+
message: `\u4E1A\u52A1 ${businessId} \u88AB\u623F\u95F4 ${firstSpace} \u4E0E ${space.id} \u91CD\u590D\u7ED1\u5B9A\uFF1B\u7B2C\u4E00\u671F\u4E00\u4E1A\u52A1\u4E00\u623F\u95F4\u3002`,
|
|
2150
|
+
subject: { path: `/spaces/${space.id}/businessId`, ids: [businessId, firstSpace, space.id] },
|
|
2151
|
+
evidence: { firstAt: `/spaces/${firstSpace}/businessId` }
|
|
2152
|
+
});
|
|
2153
|
+
continue;
|
|
2154
|
+
}
|
|
2155
|
+
boundSpaces.set(businessId, space.id);
|
|
2156
|
+
rooms.push({
|
|
2157
|
+
spaceId: space.id,
|
|
2158
|
+
spaceName: space.name,
|
|
2159
|
+
floorId: space.floorId,
|
|
2160
|
+
businessId,
|
|
2161
|
+
businessName: business.name,
|
|
2162
|
+
directory: space.directory !== false
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
for (const business of inventory.businesses) {
|
|
2166
|
+
if (!boundSpaces.has(business.id)) {
|
|
2167
|
+
problems.push({
|
|
2168
|
+
code: "BUSINESS_MISSING",
|
|
2169
|
+
severity: "error",
|
|
2170
|
+
message: `\u6E05\u5355\u4E1A\u52A1 ${business.id}\uFF08${business.name}\uFF09\u5728\u84DD\u56FE\u91CC\u6CA1\u6709\u5BF9\u5E94\u623F\u95F4\uFF1B\u6362\u5E03\u5C40\u4E0D\u80FD\u5220\u51CF\u9879\u76EE\u4E1A\u52A1\u3002`,
|
|
2171
|
+
subject: { path: "/spaces", ids: [business.id] },
|
|
2172
|
+
evidence: { businessName: business.name }
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
return { problems, rooms };
|
|
2177
|
+
}
|
|
2178
|
+
function materialKey(entry) {
|
|
2179
|
+
return entry.kind === "document" ? `doc:${entry.businessId}:${entry.path ?? ""}` : `chart:${entry.businessId}:${entry.chartId ?? ""}`;
|
|
2180
|
+
}
|
|
2181
|
+
async function buildReadingCatalog(repoRoot, inventory, rooms) {
|
|
2182
|
+
const problems = [];
|
|
2183
|
+
const businesses = [];
|
|
2184
|
+
const roomByBusiness = new Map(rooms.map((room) => [room.businessId, room]));
|
|
2185
|
+
const reportUnreadable = (businessId, evidence, reason) => {
|
|
2186
|
+
problems.push({
|
|
2187
|
+
code: "MATERIAL_UNREADABLE",
|
|
2188
|
+
severity: "error",
|
|
2189
|
+
message: `\u4E1A\u52A1 ${businessId} \u7684\u8D44\u6599\u65E0\u6CD5\u9605\u8BFB\uFF1A${reason}`,
|
|
2190
|
+
subject: { path: "", ids: [businessId] },
|
|
2191
|
+
evidence: { ...evidence, reason }
|
|
2192
|
+
});
|
|
2193
|
+
};
|
|
2194
|
+
for (const business of inventory.businesses) {
|
|
2195
|
+
const room = roomByBusiness.get(business.id);
|
|
2196
|
+
if (!room) continue;
|
|
2197
|
+
const entries = [];
|
|
2198
|
+
if (business.descriptorError) {
|
|
2199
|
+
reportUnreadable(
|
|
2200
|
+
business.id,
|
|
2201
|
+
{ businessId: business.id },
|
|
2202
|
+
`\u4E1A\u52A1\u8BF4\u660E\u6587\u4EF6\u6709\u95EE\u9898\uFF08${business.descriptorError}\uFF09\uFF0C\u8D44\u6599\u6E05\u5355\u4E0D\u53EF\u4FE1\uFF1B\u4E0D\u80FD\u5F53\u4F5C\u6CA1\u6709\u8D44\u6599\u7EE7\u7EED\u751F\u6210`
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
2205
|
+
if (room.directory) {
|
|
2206
|
+
for (const docPath of business.docs) {
|
|
2207
|
+
let reason = null;
|
|
2208
|
+
try {
|
|
2209
|
+
const text = await readWorktreeFileOptional(repoRoot, docPath);
|
|
2210
|
+
if (text === null) reason = `\u767B\u8BB0\u7684\u6587\u6863\u4E0D\u5B58\u5728\uFF08\u5DE5\u4F5C\u533A\uFF09\uFF1A${docPath}`;
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
reason = error instanceof CoreError ? error.message : `\u8BFB\u53D6\u6587\u6863 ${docPath} \u5931\u8D25\uFF1A${String(error)}`;
|
|
2213
|
+
}
|
|
2214
|
+
if (reason) reportUnreadable(business.id, { kind: "document", businessId: business.id, path: docPath }, reason);
|
|
2215
|
+
entries.push({
|
|
2216
|
+
kind: "document",
|
|
2217
|
+
businessId: business.id,
|
|
2218
|
+
path: docPath,
|
|
2219
|
+
title: docPath,
|
|
2220
|
+
href: `/specdev-workbench/api/doc?business=${encodeURIComponent(business.id)}&path=${encodeURIComponent(docPath)}`
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
for (const chart of business.charts) {
|
|
2224
|
+
const workflowRel = `${CONVENTION_ROOT}/${business.id}/${chart.id}/workflow.json`;
|
|
2225
|
+
let reason = chart.descriptorError ? `\u56FE\u8BF4\u660E\u6587\u4EF6\u6709\u95EE\u9898\uFF08${chart.descriptorError}\uFF09` : chart.idConflict ? `\u56FE\u7F16\u53F7\u51B2\u7A81\uFF1A${chart.idConflict}` : null;
|
|
2226
|
+
if (!reason) {
|
|
2227
|
+
try {
|
|
2228
|
+
const text = await readWorktreeFileOptional(repoRoot, workflowRel);
|
|
2229
|
+
if (text === null) reason = "\u56FE\u76EE\u5F55\u7F3A\u5C11 workflow.json\uFF0C\u65E0\u6CD5\u9605\u8BFB";
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
reason = error instanceof CoreError ? error.message : `\u8BFB\u53D6 ${workflowRel} \u5931\u8D25\uFF1A${String(error)}`;
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
if (reason) reportUnreadable(business.id, { kind: "workflow", businessId: business.id, chartId: chart.id }, reason);
|
|
2235
|
+
entries.push({
|
|
2236
|
+
kind: "workflow",
|
|
2237
|
+
businessId: business.id,
|
|
2238
|
+
chartId: chart.id,
|
|
2239
|
+
title: chart.descriptorError ? chart.id : chart.name,
|
|
2240
|
+
href: `/specdev-workbench/read/${encodeURIComponent(business.id)}/${encodeURIComponent(chart.id)}`
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
businesses.push({ businessId: business.id, businessName: business.name, spaceId: room.spaceId, entries });
|
|
2245
|
+
}
|
|
2246
|
+
return { problems, catalog: { mode: "room-directory", businesses } };
|
|
2247
|
+
}
|
|
2248
|
+
function checkCatalogCoverage(inventory, catalog, boundBusinessIds = new Set(catalog.businesses.map((b) => b.businessId))) {
|
|
2249
|
+
const problems = [];
|
|
2250
|
+
const covered = /* @__PURE__ */ new Set();
|
|
2251
|
+
for (const business of catalog.businesses) {
|
|
2252
|
+
for (const entry of business.entries) covered.add(materialKey(entry));
|
|
2253
|
+
}
|
|
2254
|
+
for (const business of inventory.businesses) {
|
|
2255
|
+
if (!boundBusinessIds.has(business.id)) continue;
|
|
2256
|
+
for (const docPath of business.docs) {
|
|
2257
|
+
const key = `doc:${business.id}:${docPath}`;
|
|
2258
|
+
if (!covered.has(key)) {
|
|
2259
|
+
problems.push({
|
|
2260
|
+
code: "MATERIAL_NO_ENTRY",
|
|
2261
|
+
severity: "error",
|
|
2262
|
+
message: `\u4E1A\u52A1 ${business.id} \u7684\u6587\u6863 ${docPath} \u6CA1\u6709\u4EFB\u4F55\u9605\u8BFB\u5165\u53E3\uFF1B\u6BCF\u4E2A\u4E1A\u52A1\u7684\u6587\u6863\u4E0E\u6D41\u7A0B\u56FE\u90FD\u5FC5\u987B\u53EF\u4EE5\u4ECE\u623F\u5185\u8D44\u6599\u76EE\u5F55\u8BBF\u95EE\u3002`,
|
|
2263
|
+
subject: { path: "/spaces", ids: [business.id] },
|
|
2264
|
+
evidence: { kind: "document", businessId: business.id, path: docPath }
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
for (const chart of business.charts) {
|
|
2269
|
+
const key = `chart:${business.id}:${chart.id}`;
|
|
2270
|
+
if (!covered.has(key)) {
|
|
2271
|
+
problems.push({
|
|
2272
|
+
code: "MATERIAL_NO_ENTRY",
|
|
2273
|
+
severity: "error",
|
|
2274
|
+
message: `\u4E1A\u52A1 ${business.id} \u7684\u56FE ${chart.id} \u6CA1\u6709\u4EFB\u4F55\u9605\u8BFB\u5165\u53E3\uFF1B\u6BCF\u4E2A\u4E1A\u52A1\u7684\u6587\u6863\u4E0E\u6D41\u7A0B\u56FE\u90FD\u5FC5\u987B\u53EF\u4EE5\u4ECE\u623F\u5185\u8D44\u6599\u76EE\u5F55\u8BBF\u95EE\u3002`,
|
|
2275
|
+
subject: { path: "/spaces", ids: [business.id] },
|
|
2276
|
+
evidence: { kind: "workflow", businessId: business.id, chartId: chart.id }
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
return problems;
|
|
2282
|
+
}
|
|
2283
|
+
async function readBuildingBinding(repoRoot) {
|
|
2284
|
+
const inventory = await readInventory(repoRoot);
|
|
2285
|
+
const blueprint = await readBuildingBlueprint(repoRoot);
|
|
2286
|
+
const binding = checkBusinessBinding(inventory, blueprint);
|
|
2287
|
+
const built = await buildReadingCatalog(repoRoot, inventory, binding.rooms);
|
|
2288
|
+
const coverage = checkCatalogCoverage(inventory, built.catalog);
|
|
2289
|
+
const problems = [...binding.problems, ...built.problems, ...coverage];
|
|
2290
|
+
let model = null;
|
|
2291
|
+
if (problems.every((p) => p.severity !== "error")) {
|
|
2292
|
+
const sources = {
|
|
2293
|
+
project: { name: inventory.project.name, description: inventory.project.description },
|
|
2294
|
+
businesses: inventory.businesses.map((b) => ({ id: b.id, name: b.name, intro: b.intro }))
|
|
2295
|
+
};
|
|
2296
|
+
const generation = generateBuilding(blueprint, sources);
|
|
2297
|
+
problems.push(
|
|
2298
|
+
...generation.diagnostics.map(
|
|
2299
|
+
(d) => ({
|
|
2300
|
+
code: d.code,
|
|
2301
|
+
severity: d.severity,
|
|
2302
|
+
message: d.message,
|
|
2303
|
+
subject: d.subject,
|
|
2304
|
+
evidence: d.evidence ?? {}
|
|
2305
|
+
})
|
|
2306
|
+
)
|
|
2307
|
+
);
|
|
2308
|
+
if (generation.ok) model = generation.model;
|
|
2309
|
+
}
|
|
2310
|
+
return {
|
|
2311
|
+
ok: problems.every((p) => p.severity !== "error"),
|
|
2312
|
+
blueprintPath: BUILDING_BLUEPRINT_REL,
|
|
2313
|
+
rooms: binding.rooms,
|
|
2314
|
+
catalog: built.catalog,
|
|
2315
|
+
problems,
|
|
2316
|
+
model
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// src/dsh/index.ts
|
|
2321
|
+
var name = "specdev-workbench";
|
|
2322
|
+
var inject = ["webServer", "connection", "skills", "fs"];
|
|
2323
|
+
function findPackageRoot() {
|
|
2324
|
+
let dir = path2.dirname(fileURLToPath(import.meta.url));
|
|
2325
|
+
while (true) {
|
|
2326
|
+
if (existsSync(path2.join(dir, "package.json"))) return dir;
|
|
2327
|
+
const parent = path2.dirname(dir);
|
|
2328
|
+
if (parent === dir) throw new Error("\u627E\u4E0D\u5230\u5305\u6839\uFF08package.json\uFF09");
|
|
2329
|
+
dir = parent;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
var MIME = {
|
|
2333
|
+
".html": "text/html; charset=utf-8",
|
|
2334
|
+
".js": "text/javascript; charset=utf-8",
|
|
2335
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
2336
|
+
".css": "text/css; charset=utf-8",
|
|
2337
|
+
".json": "application/json; charset=utf-8",
|
|
2338
|
+
".md": "text/markdown; charset=utf-8",
|
|
2339
|
+
".svg": "image/svg+xml",
|
|
2340
|
+
".webp": "image/webp",
|
|
2341
|
+
".txt": "text/plain; charset=utf-8"
|
|
2342
|
+
};
|
|
2343
|
+
function sendJson(res, status, body) {
|
|
2344
|
+
const text = JSON.stringify(body);
|
|
2345
|
+
res.writeHead(status, {
|
|
2346
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
2347
|
+
"Cache-Control": "no-store",
|
|
2348
|
+
"Content-Length": Buffer.byteLength(text)
|
|
2349
|
+
});
|
|
2350
|
+
res.end(text);
|
|
2351
|
+
}
|
|
2352
|
+
function sendError(res, error) {
|
|
2353
|
+
if (error instanceof CoreError) {
|
|
2354
|
+
sendJson(res, error.httpStatus, { code: error.code, error: error.message });
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
sendJson(res, 500, { code: "internal", error: error instanceof Error ? error.message : String(error) });
|
|
2358
|
+
}
|
|
2359
|
+
var BODY_TIMEOUT_MS = 1e4;
|
|
2360
|
+
async function readRequestText(req, res) {
|
|
2361
|
+
return await new Promise((resolve) => {
|
|
2362
|
+
const chunks = [];
|
|
2363
|
+
let received = 0;
|
|
2364
|
+
let settled = false;
|
|
2365
|
+
const finish = (value) => {
|
|
2366
|
+
if (settled) return;
|
|
2367
|
+
settled = true;
|
|
2368
|
+
clearTimeout(timer);
|
|
2369
|
+
chunks.length = 0;
|
|
2370
|
+
req.off("data", onData);
|
|
2371
|
+
req.off("end", onEnd);
|
|
2372
|
+
req.off("aborted", onAborted);
|
|
2373
|
+
res.off("close", onResponseClose);
|
|
2374
|
+
resolve(value);
|
|
2375
|
+
};
|
|
2376
|
+
const rejectBody = (error) => {
|
|
2377
|
+
if (settled) return;
|
|
2378
|
+
finish(null);
|
|
2379
|
+
req.pause();
|
|
2380
|
+
const disconnect = () => {
|
|
2381
|
+
clearTimeout(closeTimer);
|
|
2382
|
+
res.off("finish", disconnect);
|
|
2383
|
+
res.off("close", disconnect);
|
|
2384
|
+
req.destroy();
|
|
2385
|
+
};
|
|
2386
|
+
const closeTimer = setTimeout(disconnect, 1e3);
|
|
2387
|
+
closeTimer.unref?.();
|
|
2388
|
+
res.once("finish", disconnect);
|
|
2389
|
+
res.once("close", disconnect);
|
|
2390
|
+
if (!res.headersSent && !res.destroyed) {
|
|
2391
|
+
res.setHeader("Connection", "close");
|
|
2392
|
+
sendError(res, error);
|
|
2393
|
+
} else {
|
|
2394
|
+
disconnect();
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
const onData = (chunk) => {
|
|
2398
|
+
received += chunk.length;
|
|
2399
|
+
if (received > MAX_FILE_BYTES) {
|
|
2400
|
+
rejectBody(new CoreError("request-too-large", `\u8BF7\u6C42\u6B63\u6587\u8D85\u8FC7\u8BFB\u53D6\u4E0A\u9650 ${MAX_FILE_BYTES} \u5B57\u8282\uFF0C\u5DF2\u505C\u6B62\u63A5\u6536`, 413));
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
chunks.push(chunk);
|
|
2404
|
+
};
|
|
2405
|
+
const onEnd = () => finish(Buffer.concat(chunks).toString("utf8"));
|
|
2406
|
+
const onAborted = () => finish(null);
|
|
2407
|
+
const onError = () => finish(null);
|
|
2408
|
+
const onResponseClose = () => {
|
|
2409
|
+
finish(null);
|
|
2410
|
+
if (!req.complete) req.destroy();
|
|
2411
|
+
};
|
|
2412
|
+
const onRequestClose = () => {
|
|
2413
|
+
finish(null);
|
|
2414
|
+
req.off("error", onError);
|
|
2415
|
+
};
|
|
2416
|
+
const timer = setTimeout(() => rejectBody(new CoreError(
|
|
2417
|
+
"request-timeout",
|
|
2418
|
+
`\u8BF7\u6C42\u6B63\u6587\u8D85\u8FC7 ${BODY_TIMEOUT_MS / 1e3} \u79D2\u6CA1\u6709\u6536\u5B8C\uFF0C\u5DF2\u65AD\u5F00`,
|
|
2419
|
+
408
|
|
2420
|
+
)), BODY_TIMEOUT_MS);
|
|
2421
|
+
timer.unref?.();
|
|
2422
|
+
req.on("data", onData);
|
|
2423
|
+
req.once("end", onEnd);
|
|
2424
|
+
req.once("aborted", onAborted);
|
|
2425
|
+
req.on("error", onError);
|
|
2426
|
+
req.once("close", onRequestClose);
|
|
2427
|
+
res.once("close", onResponseClose);
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
function repoNotConfigured(res) {
|
|
2431
|
+
sendJson(res, 400, {
|
|
2432
|
+
code: "repo-not-configured",
|
|
2433
|
+
error: "\u5C1A\u672A\u914D\u7F6E\u4E1A\u52A1\u9879\u76EE\u76EE\u5F55\uFF08repoRoot\uFF09\u3002\u63D2\u4EF6\u672C\u8EAB\u5DF2\u6B63\u5E38\u52A0\u8F7D\uFF1B\u8BF7\u6309\u4E0B\u9762\u7684\u793A\u4F8B\u5728 profile \u914D\u7F6E\u91CC\u6307\u5B9A\u540E\u91CD\u542F DSH\u3002",
|
|
2434
|
+
guide: {
|
|
2435
|
+
where: "$DSH_HOME/profiles/<profile\u540D>/cordis.patch.yml\uFF08profile \u81EA\u5DF1\u7684\u914D\u7F6E\u5C42\uFF0C\u6309 id \u8986\u76D6\u672C\u63D2\u4EF6\u7684\u884C\uFF09",
|
|
2436
|
+
// 勿写 - insert:insert 是新增条目,会与本包自带条目撞 id(duplicate loader entry)启动失败;
|
|
2437
|
+
// 同 id 不带 insert 才是覆盖。
|
|
2438
|
+
example: [
|
|
2439
|
+
"- id: specdev-workbench",
|
|
2440
|
+
" config:",
|
|
2441
|
+
" repoRoot: 'D:/\u4F60\u7684/\u4E1A\u52A1\u9879\u76EE\u4ED3\u5E93'"
|
|
2442
|
+
].join("\n"),
|
|
2443
|
+
takesEffect: "\u4FDD\u5B58\u914D\u7F6E\u540E\u91CD\u542F DSH \u751F\u6548\uFF08\u914D\u7F6E\u5728\u542F\u52A8\u65F6\u7EC4\u5408\uFF0C\u6539\u914D\u7F6E\u6587\u4EF6\u4E0D\u4F1A\u70ED\u751F\u6548\uFF09\u3002"
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
async function serveFile(res, base, rel) {
|
|
2448
|
+
const parts = rel.split("/").filter((p) => p !== "" && p !== ".");
|
|
2449
|
+
if (parts.length === 0 || parts.some((p) => p === "..")) return false;
|
|
2450
|
+
const full = path2.resolve(base, ...parts);
|
|
2451
|
+
const root = path2.resolve(base);
|
|
2452
|
+
if (!full.startsWith(root + path2.sep)) return false;
|
|
2453
|
+
let size;
|
|
2454
|
+
try {
|
|
2455
|
+
size = (await stat2(full)).size;
|
|
2456
|
+
} catch {
|
|
2457
|
+
return false;
|
|
2458
|
+
}
|
|
2459
|
+
if (size > MAX_FILE_BYTES) {
|
|
2460
|
+
sendError(res, new CoreError("file-too-large", `\u9759\u6001\u8D44\u6E90 ${rel} \u4E3A ${size} \u5B57\u8282\uFF0C\u8D85\u8FC7\u8BFB\u53D6\u4E0A\u9650 ${MAX_FILE_BYTES} \u5B57\u8282`, 413));
|
|
2461
|
+
return true;
|
|
2462
|
+
}
|
|
2463
|
+
const body = await readFile2(full);
|
|
2464
|
+
res.writeHead(200, {
|
|
2465
|
+
"Content-Type": MIME[path2.extname(full).toLowerCase()] ?? "application/octet-stream",
|
|
2466
|
+
"Cache-Control": "no-store",
|
|
2467
|
+
"Content-Length": body.length
|
|
2468
|
+
});
|
|
2469
|
+
res.end(body);
|
|
2470
|
+
return true;
|
|
2471
|
+
}
|
|
2472
|
+
var ID_PATTERN2 = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
2473
|
+
function requireId(res, value, what) {
|
|
2474
|
+
if (value === null || !ID_PATTERN2.test(value)) {
|
|
2475
|
+
sendError(res, new CoreError("bad-request", `\u53C2\u6570 ${what} \u4E0D\u5408\u6CD5\uFF1A${value ?? "(\u7F3A\u5931)"}`, 400));
|
|
2476
|
+
return null;
|
|
2477
|
+
}
|
|
2478
|
+
return value;
|
|
2479
|
+
}
|
|
2480
|
+
function resolveRepoBinding(params) {
|
|
2481
|
+
const manual = params.manualRepoRoot.trim();
|
|
2482
|
+
if (params.workspaceParam === null) {
|
|
2483
|
+
if (!manual) {
|
|
2484
|
+
return { error: new CoreError("repo-not-configured", "\u5C1A\u672A\u914D\u7F6E\u4E1A\u52A1\u9879\u76EE\u76EE\u5F55\uFF08repoRoot\uFF09\uFF0C\u94FE\u63A5\u91CC\u4E5F\u6CA1\u6709\u5DE5\u4F5C\u533A\u6807\u8BC6\u3002", 400) };
|
|
2485
|
+
}
|
|
2486
|
+
return { root: manual, info: { mode: "manual", title: "\u624B\u52A8\u914D\u7F6E", path: manual, workspaceId: "" } };
|
|
2487
|
+
}
|
|
2488
|
+
if (!ID_PATTERN2.test(params.workspaceParam)) {
|
|
2489
|
+
return { error: new CoreError("bad-request", `\u5DE5\u4F5C\u533A\u6807\u8BC6\u4E0D\u5408\u6CD5\uFF1A${params.workspaceParam}`, 400) };
|
|
2490
|
+
}
|
|
2491
|
+
if (params.registry === void 0) {
|
|
2492
|
+
return {
|
|
2493
|
+
error: new CoreError(
|
|
2494
|
+
"workspace-service-unavailable",
|
|
2495
|
+
"\u5F53\u524D DSH \u6CA1\u6709\u63D0\u4F9B\u5DE5\u4F5C\u533A\u670D\u52A1\uFF08\u53EF\u80FD\u662F DSH \u7248\u672C\u53D8\u5316\uFF09\u3002\u8BF7\u4ECE DSH \u7684\u5DE5\u4F5C\u533A\u91CD\u65B0\u8FDB\u5165\uFF1B\u6216\u6539\u7528\u624B\u52A8\u914D\u7F6E\u6A21\u5F0F\uFF1A\u5728\u63D2\u4EF6\u914D\u7F6E\u91CC\u586B repoRoot\uFF0C\u5E76\u53BB\u6389\u94FE\u63A5\u91CC\u7684 workspace \u53C2\u6570\u3002",
|
|
2496
|
+
503
|
|
2497
|
+
)
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
const workspace = params.registry.get(params.workspaceParam);
|
|
2501
|
+
if (workspace === void 0) {
|
|
2502
|
+
return {
|
|
2503
|
+
error: new CoreError(
|
|
2504
|
+
"workspace-not-found",
|
|
2505
|
+
`\u5DE5\u4F5C\u533A\u4E0D\u5B58\u5728\u6216\u5DF2\u5931\u6548\uFF08${params.workspaceParam}\uFF09\u3002\u53EF\u80FD\u8BE5\u5DE5\u4F5C\u533A\u5DF2\u88AB\u5220\u9664\uFF0C\u6216\u8FD9\u4E2A\u94FE\u63A5\u6765\u81EA\u53E6\u4E00\u4E2A DSH \u5B9E\u4F8B\uFF1B\u8BF7\u4ECE DSH \u7684\u5DE5\u4F5C\u533A\u91CD\u65B0\u8FDB\u5165\u3002`,
|
|
2506
|
+
404
|
|
2507
|
+
)
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
const root = typeof workspace.path === "string" ? workspace.path.trim() : "";
|
|
2511
|
+
if (!root) {
|
|
2512
|
+
return { error: new CoreError("workspace-not-found", `\u5DE5\u4F5C\u533A ${params.workspaceParam} \u6CA1\u6709\u53EF\u7528\u7684\u76EE\u5F55\u8DEF\u5F84\u3002`, 500) };
|
|
2513
|
+
}
|
|
2514
|
+
const title = typeof workspace.title === "string" && workspace.title.trim() ? workspace.title.trim() : "(\u672A\u547D\u540D\u5DE5\u4F5C\u533A)";
|
|
2515
|
+
return { root, info: { mode: "workspace", title, path: root, workspaceId: params.workspaceParam } };
|
|
2516
|
+
}
|
|
2517
|
+
function listWorkspaces(registry) {
|
|
2518
|
+
if (registry === void 0) {
|
|
2519
|
+
return {
|
|
2520
|
+
error: new CoreError(
|
|
2521
|
+
"workspace-service-unavailable",
|
|
2522
|
+
"\u5F53\u524D DSH \u6CA1\u6709\u63D0\u4F9B\u5DE5\u4F5C\u533A\u670D\u52A1\uFF08\u53EF\u80FD\u662F DSH \u7248\u672C\u53D8\u5316\uFF09\uFF0C\u5217\u4E0D\u51FA\u5DE5\u4F5C\u533A\u6E05\u5355\u3002",
|
|
2523
|
+
503
|
|
2524
|
+
)
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
const workspaces = [];
|
|
2528
|
+
for (const workspace of registry.list()) {
|
|
2529
|
+
const id = typeof workspace.id === "string" ? workspace.id : "";
|
|
2530
|
+
if (!ID_PATTERN2.test(id)) continue;
|
|
2531
|
+
workspaces.push({
|
|
2532
|
+
id,
|
|
2533
|
+
title: typeof workspace.title === "string" && workspace.title.trim() ? workspace.title.trim() : "(\u672A\u547D\u540D\u5DE5\u4F5C\u533A)",
|
|
2534
|
+
path: typeof workspace.path === "string" ? workspace.path : ""
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
return { workspaces };
|
|
2538
|
+
}
|
|
2539
|
+
var BUNDLED_SKILLS = [
|
|
2540
|
+
{ dir: "specdev-business", sample: "references/specification.md" },
|
|
2541
|
+
{ dir: "specdev-building", sample: "references/blueprint-contract.md" },
|
|
2542
|
+
{ dir: "specdev-room", sample: "references/room-contract.md" }
|
|
2543
|
+
];
|
|
2544
|
+
function parseSkillFrontmatter(raw) {
|
|
2545
|
+
const text = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
|
|
2546
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return null;
|
|
2547
|
+
const end = text.indexOf("\n---", 4);
|
|
2548
|
+
if (end < 0) return null;
|
|
2549
|
+
let body = text.slice(end + 4);
|
|
2550
|
+
if (body.startsWith("\r")) body = body.slice(1);
|
|
2551
|
+
if (body.startsWith("\n")) body = body.slice(1);
|
|
2552
|
+
if (!body.trim()) return null;
|
|
2553
|
+
let parsedName = "";
|
|
2554
|
+
let description = "";
|
|
2555
|
+
for (const line of text.slice(4, end).split("\n")) {
|
|
2556
|
+
const matched = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]?([^\r\n]*)/.exec(line);
|
|
2557
|
+
if (!matched) continue;
|
|
2558
|
+
if (matched[1] === "name") parsedName = matched[2].trim();
|
|
2559
|
+
else if (matched[1] === "description") description = matched[2].trim();
|
|
2560
|
+
}
|
|
2561
|
+
if (!parsedName || !description) return null;
|
|
2562
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(parsedName)) return null;
|
|
2563
|
+
return { name: parsedName, description, content: body };
|
|
2564
|
+
}
|
|
2565
|
+
async function selfCheckSkill(ctx, expected, skillDir, sampleRel, log) {
|
|
2566
|
+
const registry = ctx.skills;
|
|
2567
|
+
if (!registry) return;
|
|
2568
|
+
const listed = (await registry.list()).some((skill) => skill.name === expected.name);
|
|
2569
|
+
const definition = await registry.get(expected.name);
|
|
2570
|
+
let sample;
|
|
2571
|
+
let sampleWhy = "";
|
|
2572
|
+
if (ctx.fs === void 0) sampleWhy = "ctx.fs \u4E0D\u53EF\u7528";
|
|
2573
|
+
else {
|
|
2574
|
+
try {
|
|
2575
|
+
const target = await ctx.fs.resolve(path2.join(skillDir, sampleRel));
|
|
2576
|
+
sample = await ctx.fs.readText(target);
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
sampleWhy = String(error);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
const contentOk = definition !== void 0 && definition.content === expected.content;
|
|
2582
|
+
const sampleNote = sample === void 0 ? `\u672A\u901A\u8FC7\uFF08${sampleWhy}\uFF09` : `${sample.length} \u5B57`;
|
|
2583
|
+
log(
|
|
2584
|
+
`\u6280\u80FD\u81EA\u67E5\uFF08${expected.name}\uFF09\uFF1Alist ${listed ? "\u5DF2\u89C1" : "\u672A\u89C1"}\uFF1Bget \u6B63\u6587 ${definition === void 0 ? "\u672A\u53D6\u5230" : `${definition.content.length} \u5B57${contentOk ? "\uFF08\u4E0E SKILL.md \u4E00\u81F4\uFF09" : "\uFF08\u4E0E SKILL.md \u4E0D\u4E00\u81F4\uFF09"}`}\uFF1Bfs \u8BFB ${sampleRel} ${sampleNote}`
|
|
2585
|
+
);
|
|
2586
|
+
if (!listed || !contentOk || sample === void 0) {
|
|
2587
|
+
log(`\u6280\u80FD\u81EA\u67E5\u5B58\u5728\u672A\u8FC7\u9879\uFF08${expected.name}\uFF09\uFF1Alist=${listed} \u6B63\u6587=${contentOk} \u53C2\u8003\u6587\u4EF6=${sample !== void 0}`);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
function registerBundledSkill(ctx, skillDir, sampleRel, log) {
|
|
2591
|
+
let raw;
|
|
2592
|
+
try {
|
|
2593
|
+
raw = readFileSync(path2.join(skillDir, "SKILL.md"), "utf8");
|
|
2594
|
+
} catch (error) {
|
|
2595
|
+
log(`\u968F\u5305\u6280\u80FD\u6CE8\u518C\u5931\u8D25\uFF1A\u8BFB\u4E0D\u5230 ${path2.join(skillDir, "SKILL.md")}\uFF08${String(error)}\uFF09`);
|
|
2596
|
+
return false;
|
|
2597
|
+
}
|
|
2598
|
+
const parsed = parseSkillFrontmatter(raw);
|
|
2599
|
+
if (!parsed) {
|
|
2600
|
+
log("\u968F\u5305\u6280\u80FD\u6CE8\u518C\u5931\u8D25\uFF1ASKILL.md frontmatter \u4E0D\u5408\u5F0F\uFF08\u9700 --- \u5305\u88F9\u7684 name \u4E0E description\uFF0Cname \u987B\u5C0F\u5199\u4E2D\u5212\u7EBF\uFF09");
|
|
2601
|
+
return false;
|
|
2602
|
+
}
|
|
2603
|
+
if (!ctx.skills) {
|
|
2604
|
+
log(`\u968F\u5305\u6280\u80FD\u6CE8\u518C\u8DF3\u8FC7\uFF1Actx.skills \u670D\u52A1\u4E0D\u53EF\u7528\uFF08\u5DF2\u89E3\u6790 ${parsed.name}\uFF09`);
|
|
2605
|
+
return false;
|
|
2606
|
+
}
|
|
2607
|
+
try {
|
|
2608
|
+
const accepted = ctx.skills.register({
|
|
2609
|
+
name: parsed.name,
|
|
2610
|
+
description: parsed.description,
|
|
2611
|
+
content: parsed.content,
|
|
2612
|
+
// source 必填:register() 只代补 invocation/provider,加载侧 validateDefinition 校验 source 必须是字符串。
|
|
2613
|
+
source: "runtime",
|
|
2614
|
+
resourceBase: { kind: "directory", path: skillDir }
|
|
2615
|
+
});
|
|
2616
|
+
if (accepted !== null && typeof accepted === "object" && typeof accepted.then === "function") {
|
|
2617
|
+
void Promise.resolve(accepted).catch((error) => {
|
|
2618
|
+
log(`\u968F\u5305\u6280\u80FD ${parsed.name} \u6CE8\u518C\u5931\u8D25\uFF1A${String(error)}`);
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
log(`\u968F\u5305\u6280\u80FD ${parsed.name} \u6CE8\u518C\u5931\u8D25\uFF1A${String(error)}`);
|
|
2623
|
+
return false;
|
|
2624
|
+
}
|
|
2625
|
+
log(`\u6280\u80FD ${parsed.name} \u5DF2\u6CE8\u518C\uFF08source=runtime\uFF0CresourceBase=${skillDir}\uFF09`);
|
|
2626
|
+
void selfCheckSkill(ctx, parsed, skillDir, sampleRel, log).catch((error) => {
|
|
2627
|
+
log(`\u6280\u80FD ${parsed.name} \u81EA\u67E5\u5931\u8D25\uFF1A${String(error)}`);
|
|
2628
|
+
});
|
|
2629
|
+
return true;
|
|
2630
|
+
}
|
|
2631
|
+
function apply(ctx, config) {
|
|
2632
|
+
console.error(`[specdev-workbench] apply \u8FD0\u884C\uFF1ArepoRoot=${JSON.stringify(config?.repoRoot ?? "(\u7F3A\u7701)")}\uFF08\u624B\u52A8\u6A21\u5F0F\u515C\u5E95\uFF1B\u5E26 workspace \u7684\u94FE\u63A5\u6309\u8BF7\u6C42\u89E3\u6790\uFF09`);
|
|
2633
|
+
const manualRepoRoot = typeof config?.repoRoot === "string" ? config.repoRoot.trim() : "";
|
|
2634
|
+
const packageRoot = findPackageRoot();
|
|
2635
|
+
const webRoot = path2.join(packageRoot, "web");
|
|
2636
|
+
const vendorRoot = path2.join(packageRoot, "vendor", "archify-renderer");
|
|
2637
|
+
const routeContext = {
|
|
2638
|
+
manualRepoRoot,
|
|
2639
|
+
webRoot,
|
|
2640
|
+
vendorRoot,
|
|
2641
|
+
workspaceRegistry: () => ctx.get("workspaceRegistry")
|
|
2642
|
+
};
|
|
2643
|
+
const dispose = ctx.webServer.register({
|
|
2644
|
+
kind: "prefix",
|
|
2645
|
+
path: "/specdev-workbench",
|
|
2646
|
+
async handler(req, res) {
|
|
2647
|
+
try {
|
|
2648
|
+
if (!authorizeRequest(ctx.connection, req, res)) return;
|
|
2649
|
+
await handle(req, res, routeContext);
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
if (!res.headersSent) sendError(res, error);
|
|
2652
|
+
else res.end();
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
console.error("[specdev-workbench] /specdev-workbench \u524D\u7F00\u8DEF\u7531\u5DF2\u6CE8\u518C\uFF08\u5165\u53E3\u7EDF\u4E00\u8BA4\u8BC1\uFF0Cfail-closed\uFF09");
|
|
2657
|
+
ctx.effect(() => dispose, "\u6D41\u7A0B\u56FE\u7BA1\u7406\u8DEF\u7531\u6E05\u7406");
|
|
2658
|
+
const log = (message) => console.error(`[specdev-workbench] ${message}`);
|
|
2659
|
+
for (const skill of BUNDLED_SKILLS) {
|
|
2660
|
+
registerBundledSkill(ctx, path2.join(packageRoot, "skills", skill.dir), skill.sample, log);
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
function authorizeRequest(connection, req, res) {
|
|
2664
|
+
let rejection = 503;
|
|
2665
|
+
if (connection !== null && typeof connection === "object" && typeof connection.requestRejection === "function") {
|
|
2666
|
+
try {
|
|
2667
|
+
rejection = connection.requestRejection(req);
|
|
2668
|
+
} catch {
|
|
2669
|
+
rejection = 503;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
if (rejection === void 0) return true;
|
|
2673
|
+
const status = rejection === 401 || rejection === 403 ? rejection : 503;
|
|
2674
|
+
const isApi = (req.url ?? "").split("?")[0].split("/").filter((s) => s !== "")[1] === "api";
|
|
2675
|
+
if (isApi) {
|
|
2676
|
+
const body = status === 401 ? { code: "unauthorized", error: "\u672A\u767B\u5F55\u6216\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF1A\u8BF7\u5148\u6253\u5F00 DSH \u542F\u52A8\u5730\u5740\u5B8C\u6210\u767B\u5F55\uFF0C\u518D\u4F7F\u7528\u6D41\u7A0B\u56FE\u7BA1\u7406\u3002" } : status === 403 ? { code: "forbidden", error: "\u8BF7\u6C42\u6765\u6E90\u4E0D\u88AB\u4FE1\u4EFB\uFF08Host/Origin \u6821\u9A8C\u672A\u901A\u8FC7\uFF09\uFF0C\u5DF2\u62D2\u7EDD\u8BBF\u95EE\u3002" } : { code: "auth-unavailable", error: "\u8BA4\u8BC1\u670D\u52A1\u4E0D\u53EF\u7528\uFF0C\u5DF2\u62D2\u7EDD\u8BBF\u95EE\uFF08fail-closed\uFF0C\u4E0D\u653E\u884C\uFF09\u3002" };
|
|
2677
|
+
sendJson(res, status, body);
|
|
2678
|
+
return false;
|
|
2679
|
+
}
|
|
2680
|
+
const text = status === 401 ? "\u8BF7\u5148\u901A\u8FC7 DSH \u542F\u52A8\u5730\u5740\u767B\u5F55\n" : status === 403 ? "\u8BF7\u6C42\u6765\u6E90\u4E0D\u88AB\u4FE1\u4EFB\uFF08Host/Origin \u6821\u9A8C\u672A\u901A\u8FC7\uFF09\uFF0C\u5DF2\u62D2\u7EDD\u8BBF\u95EE\u3002\n" : "\u8BA4\u8BC1\u670D\u52A1\u4E0D\u53EF\u7528\uFF0C\u5DF2\u62D2\u7EDD\u8BBF\u95EE\u3002\n";
|
|
2681
|
+
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
2682
|
+
res.end(text);
|
|
2683
|
+
return false;
|
|
2684
|
+
}
|
|
2685
|
+
async function handle(req, res, ctx) {
|
|
2686
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
2687
|
+
const segments = url.pathname.split("/").filter((s) => s !== "");
|
|
2688
|
+
const apiPost = req.method === "POST" && segments.length === 3 && segments[1] === "api" && (segments[2] === "evidence" || segments[2] === "snapshots");
|
|
2689
|
+
if (req.method !== "GET" && req.method !== "HEAD" && !apiPost) {
|
|
2690
|
+
sendError(res, new CoreError("bad-request", "\u63A5\u53E3\u53EA\u8BFB\uFF08GET/HEAD\uFF09\uFF1B\u4EC5 POST /api/evidence \u4E0E POST /api/snapshots \u4F8B\u5916", 405));
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
if (segments.length === 1) {
|
|
2694
|
+
if (await serveFile(res, ctx.webRoot, "index.html")) return;
|
|
2695
|
+
} else if (segments.length === 2 && segments[1] === "workspaces") {
|
|
2696
|
+
if (await serveFile(res, ctx.webRoot, "workspaces.html")) return;
|
|
2697
|
+
} else if (segments.length === 2 && segments[1] === "business") {
|
|
2698
|
+
if (await serveFile(res, ctx.webRoot, "business.html")) return;
|
|
2699
|
+
} else if (segments.length === 3 && segments[1] === "business" && ID_PATTERN2.test(segments[2])) {
|
|
2700
|
+
if (await serveFile(res, ctx.webRoot, "business.html")) return;
|
|
2701
|
+
} else if (segments.length === 2 && segments[1] === "showcase") {
|
|
2702
|
+
if (await serveFile(res, ctx.webRoot, "showcase.html")) return;
|
|
2703
|
+
} else if (segments.length === 2 && segments[1] === "building") {
|
|
2704
|
+
if (await serveFile(res, ctx.webRoot, "building.html")) return;
|
|
2705
|
+
} else if (segments.length >= 3 && segments[1] === "read") {
|
|
2706
|
+
if (await serveFile(res, ctx.webRoot, "read.html")) return;
|
|
2707
|
+
} else if (segments.length === 3 && segments[1] === "room" && ID_PATTERN2.test(segments[2])) {
|
|
2708
|
+
if (await serveFile(res, ctx.webRoot, "room.html")) return;
|
|
2709
|
+
} else if (segments.length === 2 && segments[1] === "furniture") {
|
|
2710
|
+
if (await serveFile(res, ctx.webRoot, "furniture.html")) return;
|
|
2711
|
+
}
|
|
2712
|
+
if (segments[1] === "assets" && segments.length > 2) {
|
|
2713
|
+
if (await serveFile(res, path2.join(ctx.webRoot, "assets"), segments.slice(2).join("/"))) return;
|
|
2714
|
+
}
|
|
2715
|
+
if (segments[1] === "vendor" && segments.length > 2) {
|
|
2716
|
+
if (await serveFile(res, ctx.vendorRoot, segments.slice(2).join("/"))) return;
|
|
2717
|
+
}
|
|
2718
|
+
if (segments[1] === "api") {
|
|
2719
|
+
await handleApi(req, url, res, ctx);
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
sendError(res, new CoreError("not-found", `\u8DEF\u5F84\u4E0D\u5B58\u5728\uFF1A${url.pathname}`, 404));
|
|
2723
|
+
}
|
|
2724
|
+
async function handleApi(req, url, res, ctx) {
|
|
2725
|
+
const api = url.pathname.split("/").filter((s) => s !== "")[2];
|
|
2726
|
+
const q = url.searchParams;
|
|
2727
|
+
if (api === "workspaces") {
|
|
2728
|
+
const listed = listWorkspaces(ctx.workspaceRegistry());
|
|
2729
|
+
if ("error" in listed) return sendError(res, listed.error);
|
|
2730
|
+
sendJson(res, 200, listed);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
const binding = resolveRepoBinding({
|
|
2734
|
+
workspaceParam: q.get("workspace"),
|
|
2735
|
+
manualRepoRoot: ctx.manualRepoRoot,
|
|
2736
|
+
registry: ctx.workspaceRegistry()
|
|
2737
|
+
});
|
|
2738
|
+
if ("error" in binding) {
|
|
2739
|
+
if (binding.error.code === "repo-not-configured") return repoNotConfigured(res);
|
|
2740
|
+
return sendError(res, binding.error);
|
|
2741
|
+
}
|
|
2742
|
+
await assertRepoUsable(binding.root);
|
|
2743
|
+
if (binding.info.mode === "workspace") await assertRepoTopLevel(binding.root);
|
|
2744
|
+
const root = binding.root;
|
|
2745
|
+
try {
|
|
2746
|
+
await dispatchApi(api, req, q, res, { root, repo: binding.info });
|
|
2747
|
+
} catch (error) {
|
|
2748
|
+
if (error instanceof CoreError) {
|
|
2749
|
+
sendJson(res, error.httpStatus, { code: error.code, error: error.message, repo: binding.info });
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
throw error;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
async function dispatchApi(api, req, q, res, ctx) {
|
|
2756
|
+
const root = ctx.root;
|
|
2757
|
+
if (api === "inventory") {
|
|
2758
|
+
sendJson(res, 200, { ...await readInventory(root), repo: ctx.repo });
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
if (api === "building") {
|
|
2762
|
+
const binding = await readBuildingBinding(root);
|
|
2763
|
+
const view = q.get("view");
|
|
2764
|
+
let present;
|
|
2765
|
+
if (binding.ok && binding.model && (view === "frame" || view === "preview")) {
|
|
2766
|
+
present = presentBuilding(binding.model, { preview: view === "preview" });
|
|
2767
|
+
}
|
|
2768
|
+
sendJson(res, 200, { ...binding, repo: ctx.repo, ...present !== void 0 ? { present } : {} });
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (api === "room") {
|
|
2772
|
+
const business = requireId(res, q.get("business"), "business");
|
|
2773
|
+
if (!business) return;
|
|
2774
|
+
sendJson(res, 200, { ...await readRoomLayout(root, business), repo: ctx.repo });
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (api === "chart") {
|
|
2778
|
+
const business = requireId(res, q.get("business"), "business");
|
|
2779
|
+
if (!business) return;
|
|
2780
|
+
const chart = requireId(res, q.get("chart"), "chart");
|
|
2781
|
+
if (!chart) return;
|
|
2782
|
+
const v = q.get("v") || "current";
|
|
2783
|
+
const page = await readChartPage(root, business, chart, v);
|
|
2784
|
+
sendJson(res, 200, { ...page, repo: ctx.repo });
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
if (api === "evidence") {
|
|
2788
|
+
if (req.method === "POST") {
|
|
2789
|
+
const text = await readRequestText(req, res);
|
|
2790
|
+
if (text === null) return;
|
|
2791
|
+
sendJson(res, 200, await loadEvidence(root, text));
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
const business = requireId(res, q.get("business"), "business");
|
|
2795
|
+
if (!business) return;
|
|
2796
|
+
const chart = requireId(res, q.get("chart"), "chart");
|
|
2797
|
+
if (!chart) return;
|
|
2798
|
+
const v = q.get("v") || "current";
|
|
2799
|
+
const page = await readChartPage(root, business, chart, v);
|
|
2800
|
+
if (page.version.evidenceError) {
|
|
2801
|
+
sendJson(res, 200, { missing: false, parseError: page.version.evidenceError, refs: [] });
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
const evidence = await loadEvidence(root, page.version.files.evidence);
|
|
2805
|
+
sendJson(res, 200, evidence);
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
if (api === "snapshots") {
|
|
2809
|
+
if (req.method === "POST") {
|
|
2810
|
+
const text = await readRequestText(req, res);
|
|
2811
|
+
if (text === null) return;
|
|
2812
|
+
let body;
|
|
2813
|
+
try {
|
|
2814
|
+
body = JSON.parse(text);
|
|
2815
|
+
} catch {
|
|
2816
|
+
sendError(res, new CoreError("bad-request", "\u4FDD\u5B58\u8BF7\u6C42\u6B63\u6587\u5FC5\u987B\u662F JSON", 400));
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
const fields = typeof body === "object" && body !== null ? body : {};
|
|
2820
|
+
const business2 = requireId(res, typeof fields.business === "string" ? fields.business : null, "business");
|
|
2821
|
+
if (!business2) return;
|
|
2822
|
+
const chart2 = requireId(res, typeof fields.chart === "string" ? fields.chart : null, "chart");
|
|
2823
|
+
if (!chart2) return;
|
|
2824
|
+
const result = await saveChartSnapshot(root, business2, chart2, body, {
|
|
2825
|
+
expectedHead: typeof fields.head === "string" ? fields.head : "",
|
|
2826
|
+
fingerprint: typeof fields.fingerprint === "string" ? fields.fingerprint : ""
|
|
2827
|
+
});
|
|
2828
|
+
sendJson(res, 200, result);
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
const business = requireId(res, q.get("business"), "business");
|
|
2832
|
+
if (!business) return;
|
|
2833
|
+
const chart = requireId(res, q.get("chart"), "chart");
|
|
2834
|
+
if (!chart) return;
|
|
2835
|
+
sendJson(res, 200, await checkChartCommitted(root, business, chart));
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
if (api === "doc") {
|
|
2839
|
+
const business = requireId(res, q.get("business"), "business");
|
|
2840
|
+
if (!business) return;
|
|
2841
|
+
const docPath = q.get("path");
|
|
2842
|
+
if (!docPath) {
|
|
2843
|
+
sendError(res, new CoreError("bad-request", "\u7F3A\u5C11 path \u53C2\u6570", 400));
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
const inventory = await readInventory(root);
|
|
2847
|
+
const entry = inventory.businesses.find((b) => b.id === business);
|
|
2848
|
+
if (!entry || !entry.docs.includes(docPath)) {
|
|
2849
|
+
sendError(res, new CoreError("not-found", `\u6587\u6863\u672A\u5728\u4E1A\u52A1 ${business} \u7684 docs \u91CC\u58F0\u660E\uFF1A${docPath}`, 404));
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
const text = await readWorktreeFileOptional(root, docPath);
|
|
2853
|
+
if (text === null) {
|
|
2854
|
+
sendError(res, new CoreError("not-found", `\u6587\u6863\u4E0D\u5B58\u5728\uFF08\u5DE5\u4F5C\u533A\uFF09\uFF1A${docPath}`, 404));
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
2858
|
+
res.end(text);
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
sendError(res, new CoreError("not-found", `\u63A5\u53E3\u4E0D\u5B58\u5728\uFF1A${api ?? "(\u7F3A\u5931)"}`, 404));
|
|
2862
|
+
}
|
|
2863
|
+
export {
|
|
2864
|
+
BUNDLED_SKILLS,
|
|
2865
|
+
apply,
|
|
2866
|
+
authorizeRequest,
|
|
2867
|
+
inject,
|
|
2868
|
+
listWorkspaces,
|
|
2869
|
+
name,
|
|
2870
|
+
parseSkillFrontmatter,
|
|
2871
|
+
registerBundledSkill,
|
|
2872
|
+
resolveRepoBinding
|
|
2873
|
+
};
|