Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ version = {attr = "iac_code.__version__"}
"**/*.png",
"**/*.svg",
"**/*.LICENSE",
"**/*.NOTICE",
"**/*.rego",
"**/*.mo",
"**/*.po",
Expand Down
1 change: 1 addition & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ repository root with `uv run python ...` unless a script-specific README says ot
| `rendering/test_diagram_render.py` | Manual diagram rendering check. |
| `rendering/preview_template_architecture_llm.py` | Manual ROS template architecture preview with a real LLM semantic pass. |
| `repl/e2e/` | Real PTY-driven REPL pipeline end-to-end scenario runner. POSIX-only because it uses `pexpect`. |
| `web/` | Web asset maintenance and visual evaluation tools. See `web/README.md`. |

## Common Commands

Expand Down
1 change: 1 addition & 0 deletions scripts/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| `provider-update-guide.zh-CN.md` | Provider 模型调研、代码更新、测试与提交的中文维护指南。 |
| `rendering/test_diagram_render.py` | 手工图表渲染检查。 |
| `repl/e2e/` | 基于真实 PTY 驱动的 REPL pipeline 端到端场景 runner。因为使用 `pexpect`,仅支持 POSIX 环境。 |
| `web/` | Web 静态资源维护和视觉评估工具,详见 `web/README.zh-CN.md`。 |

## 常用命令

Expand Down
67 changes: 67 additions & 0 deletions scripts/web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Web Development Utilities

The files in this directory support local Web development, asset maintenance, and manual visual evaluation. They are
repository tools: normal Python, Web, and Desktop builds do not execute or package them.

Runtime artifacts generated or checked by these tools live under `src/iac_code/web/static/`. Setuptools includes the
committed JavaScript, license, and notice files in Python distributions, while the Desktop sidecar copies the same
static directory. A release therefore consumes the committed artifacts and does not require Node.js, pnpm, an Eraser
source checkout, or the ROS template corpus.

## Eraser browser bundle

`build_eraser_vendor.mjs` creates the browser-only Eraser bundle. `eraser/browser-entry.js` is its source entry point;
it registers local fonts and icons and exposes the resolver/render adapter used by the sandboxed Web frame. Neither
file is loaded by the product at runtime.

The build is pinned to Eraser commit `6d377f296b94abf63481a07128884066e4930321`. The Eraser checkout requires Node
22.12 or newer and pnpm 10.33. From the two repository roots:

```bash
cd /path/to/eraser-diagrams
corepack pnpm install --frozen-lockfile
corepack pnpm build

cd /path/to/iac-code
node scripts/web/build_eraser_vendor.mjs /path/to/eraser-diagrams
```

The command updates these committed release inputs:

- `src/iac_code/web/static/js/vendor/eraser-diagrams.min.js`
- `src/iac_code/web/static/js/vendor/eraser-diagrams.NOTICE`

The Eraser MIT license is maintained separately at
`src/iac_code/web/static/js/vendor/eraser-diagrams.LICENSE`. Review all three files when changing the pinned Eraser
revision. The script rejects any source checkout whose HEAD differs from the pinned commit and fails when it cannot
collect a bundled dependency's license text.

This command is intentionally outside the normal release build. Rebuilding during every package build would add an
external Git checkout and a Node toolchain to an otherwise self-contained release. Update and commit the vendor files
explicitly instead.

## Fixed-corpus layout evaluation

`evaluate_eraser_corpus.py` checks the fixed 30-template ROS corpus through the public preview API and the same layout
Worker used by the browser. It reports sibling overlap, failed containment, canvas overflow, edge/node intersections,
and extreme aspect ratios. It is a deterministic geometry check and does not call an LLM.

Start the local Web service, then run:

```bash
uv run iac-code web --host 127.0.0.1 --port 8766

uv run python scripts/web/evaluate_eraser_corpus.py \
--templates-root /path/to/ros-templates \
--base-url http://127.0.0.1:8766 \
--output /tmp/iac-code-diagram-eval/run
```

The corpus manifest pins the expected `ros-templates` revision. Use `--allow-template-revision` only for an intentional
comparison with another revision. The evaluator reads templates and POSTs their contents to `--base-url`; keep the
default loopback URL unless sending those files to another service is intentional. The output path receives JSON and
Markdown reports and is not packaged.

The evaluation script remains outside `make test` because it needs a separate template checkout, Node.js, and a
running Web service. Unit and integration coverage for the preview API, graph projection, layout Worker, static assets,
and packaging stays under `tests/`.
59 changes: 59 additions & 0 deletions scripts/web/README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Web 开发工具

本目录用于 Web 本地开发、静态资源维护和手工视觉评估。正常的 Python、Web 和 Desktop 构建既不会执行,
也不会打包这里的脚本。

这些工具生成或检查的运行时文件位于 `src/iac_code/web/static/`。Python 发布包通过 setuptools 收录已提交的
JavaScript、许可证和 NOTICE,Desktop sidecar 则复制同一静态资源目录。因此正常发布只使用仓库里已经提交的
产物,不需要安装 Node.js、pnpm,也不需要准备 Eraser 源码仓库或 ROS 模板语料库。

## Eraser 浏览器 Bundle

`build_eraser_vendor.mjs` 生成仅供浏览器使用的 Eraser bundle。`eraser/browser-entry.js` 是构建入口,负责注册
本地字体和图标,并暴露 sandbox Web frame 使用的解析、渲染适配器。这两个文件都不会被产品运行时直接加载。

构建固定使用 Eraser 提交 `6d377f296b94abf63481a07128884066e4930321`。Eraser 仓库要求 Node 22.12 或更高
版本及 pnpm 10.33。在两个仓库根目录依次执行:

```bash
cd /path/to/eraser-diagrams
corepack pnpm install --frozen-lockfile
corepack pnpm build

cd /path/to/iac-code
node scripts/web/build_eraser_vendor.mjs /path/to/eraser-diagrams
```

该命令更新以下需要提交的发布输入:

- `src/iac_code/web/static/js/vendor/eraser-diagrams.min.js`
- `src/iac_code/web/static/js/vendor/eraser-diagrams.NOTICE`

Eraser 的 MIT 许可证单独维护在 `src/iac_code/web/static/js/vendor/eraser-diagrams.LICENSE`。升级固定版本时应同时
检查这三个文件。源码仓库 HEAD 与固定提交不一致,或者无法收集某个打包依赖的许可证文本时,脚本会直接失败。

这个命令有意不接入正常发布构建。否则每次打包都会额外依赖外部 Git checkout 和 Node 工具链。升级 Eraser 时应
显式重新生成并提交 vendor 文件。

## 固定语料布局评估

`evaluate_eraser_corpus.py` 通过公开预览 API 和浏览器使用的同一个布局 Worker 检查固定的 30 个 ROS 模板,输出
同级节点重叠、容器包含失败、画布越界、连线穿过节点和极端长宽比等指标。它是确定性几何检查,不调用 LLM。

先启动本地 Web 服务,再运行:

```bash
uv run iac-code web --host 127.0.0.1 --port 8766

uv run python scripts/web/evaluate_eraser_corpus.py \
--templates-root /path/to/ros-templates \
--base-url http://127.0.0.1:8766 \
--output /tmp/iac-code-diagram-eval/run
```

语料清单固定了预期的 `ros-templates` 提交。只有明确需要比较其他版本时才使用
`--allow-template-revision`。评估器会读取模板并把内容 POST 到 `--base-url`;除非明确需要发送给其他服务,否则应
保留默认的本机回环地址。JSON 和 Markdown 报告写入指定输出目录,不进入发布包。

评估脚本没有接入 `make test`,因为它依赖单独的模板仓库、Node.js 和正在运行的 Web 服务。预览 API、图投影、
布局 Worker、静态资源与打包规则的单元及集成测试仍位于 `tests/`。
146 changes: 146 additions & 0 deletions scripts/web/build_eraser_vendor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const ERASER_COMMIT = "6d377f296b94abf63481a07128884066e4930321";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(process.argv[2] || "");
if (!process.argv[2]) {
throw new Error("Usage: node scripts/web/build_eraser_vendor.mjs /path/to/eraser-diagrams");
}
const actualCommit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: sourceRoot,
encoding: "utf8",
}).trim();
if (actualCommit !== ERASER_COMMIT) {
throw new Error(`Expected eraser-diagrams ${ERASER_COMMIT}, found ${actualCommit}`);
}
const require = createRequire(join(sourceRoot, "package.json"));
const { build } = require("esbuild");
const eraserModules = new Map([
["@eraserlabs/diagrams/library", "packages/diagrams/dist/library/index.js"],
["@eraserlabs/diagrams/normalizers", "packages/diagrams/dist/library/normalizers.js"],
["@eraserlabs/layout", "packages/layout/dist/index.js"],
["@eraserlabs/protocol", "packages/protocol/dist/index.js"],
["@eraserlabs/protocol/schema", "packages/protocol/dist/schema.js"],
["@eraserlabs/protocol/schemas/tag-schema", "packages/protocol/schemas/tag-schema.schema.json"],
["@eraserlabs/render", "packages/render/dist/index.js"],
["@eraserlabs/render/browser", "packages/render/dist/browser/index.js"],
["@eraserlabs/resolve", "packages/resolve/dist/index.js"],
["@eraserlabs/resolve/schema", "packages/resolve/dist/schema/index.js"],
["@eraserlabs/utils", "packages/utils/dist/index.js"],
]);
const result = await build({
absWorkingDir: "/",
entryPoints: [join(scriptDir, "eraser", "browser-entry.js")],
bundle: true,
platform: "browser",
format: "iife",
minify: true,
define: { "process.env": "{}" },
nodePaths: [join(sourceRoot, "node_modules")],
plugins: [
{
name: "eraser-workspace-modules",
setup(builder) {
builder.onResolve({ filter: /^@eraserlabs\// }, (args) => {
const relative = eraserModules.get(args.path);
return relative ? { path: join(sourceRoot, relative) } : null;
});
},
},
],
outfile: join(repositoryRoot, "src/iac_code/web/static/js/vendor/eraser-diagrams.min.js"),
legalComments: "none",
metafile: true,
});
const mitFallback = (owner) => `MIT License

Copyright (c) ${owner}

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.`;
const bundledPackages = [...new Set(
Object.keys(result.metafile.inputs)
.map((input) => input.match(/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)/)?.[1])
.filter(Boolean),
)].sort();
const thirdPartyLicenses = new Map();
function addPackageLicense(directory) {
const packageMetadata = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
const name = String(packageMetadata.name || "");
if (!name || name.startsWith("@eraserlabs/")) return;
const key = `${name}@${packageMetadata.version || "unknown"}`;
if (thirdPartyLicenses.has(key)) return;
const licenseName = readdirSync(directory)
.filter((entry) => /^(licen[cs]e|copying)(\.|$)/i.test(entry))
.sort()[0];
if (licenseName) {
thirdPartyLicenses.set(key, readFileSync(join(directory, licenseName), "utf8").trim());
} else if (packageMetadata.license === "MIT") {
const author = typeof packageMetadata.author === "string"
? packageMetadata.author
: packageMetadata.author?.name || name;
thirdPartyLicenses.set(key, mitFallback(author));
} else {
throw new Error(`Missing license file for bundled dependency ${key}`);
}
}
for (const input of Object.keys(result.metafile.inputs)) {
if (input.startsWith("<") || input.startsWith("(")) continue;
let directory = dirname(realpathSync(resolve("/", input)));
while (directory.startsWith(sourceRoot)) {
const packagePath = join(directory, "package.json");
if (existsSync(packagePath)) {
addPackageLicense(directory);
break;
}
const parent = dirname(directory);
if (parent === directory) break;
directory = parent;
}
}
const pnpmStore = join(sourceRoot, "node_modules", ".pnpm");
for (const packageName of bundledPackages) {
const hasLicense = [...thirdPartyLicenses.keys()].some((key) => key.startsWith(`${packageName}@`));
if (hasLicense) continue;
const packageDirectory = readdirSync(pnpmStore)
.map((entry) => join(pnpmStore, entry, "node_modules", packageName))
.find((candidate) => existsSync(join(candidate, "package.json")));
if (!packageDirectory) throw new Error(`Cannot locate bundled dependency ${packageName}`);
addPackageLicense(packageDirectory);
}
const notice = [
"Eraser diagrams browser bundle third-party notices",
"===================================================",
"",
`Generated from eraserlabs/eraser-diagrams commit ${ERASER_COMMIT}.`,
"The Eraser Diagrams license is in eraser-diagrams.LICENSE.",
"",
...[...thirdPartyLicenses.entries()].sort(([left], [right]) => left.localeCompare(right)).flatMap(([name, license]) => [
name,
"-".repeat(name.length),
license,
"",
]),
].join("\n");
writeFileSync(join(repositoryRoot, "src/iac_code/web/static/js/vendor/eraser-diagrams.NOTICE"), notice, "utf8");
console.log(`Built Eraser browser bundle from ${ERASER_COMMIT}: ${bundledPackages.join(", ")}`);
31 changes: 31 additions & 0 deletions scripts/web/eraser/browser-entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { stockLibrary, buildRenderPageSetup } from "@eraserlabs/diagrams/library";
import { stockNormalizers } from "@eraserlabs/diagrams/normalizers";
import "@eraserlabs/render/browser";
import { createResolver, prepareLibrary } from "@eraserlabs/resolve";

window.createIacEraserRenderer = async function createIacEraserRenderer(iconMap) {
const resolver = await createResolver({
library: stockLibrary,
normalizers: stockNormalizers,
iconLoader: async (name) => {
const icon = iconMap[name];
if (!icon) throw new Error(`Unknown local icon: ${name}`);
return icon;
},
});
window.__eraser.setup(buildRenderPageSetup(prepareLibrary(stockLibrary)));
await window.__eraser.registerFonts({
css:
':root{--font-clean:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB",sans-serif;--font-rough:var(--font-clean);--font-mono:ui-monospace,SFMono-Regular,Consolas,monospace}',
faces: [],
});
return async (input) => {
const resolved = await resolver.resolve(input);
if (!resolved.ok) {
const reason = resolved.errors?.map((item) => item.message || String(item)).join("; ") || "resolve failed";
throw new Error(reason);
}
const layout = await window.__eraser.run(resolved);
return { layout, warnings: resolved.warnings || [] };
};
};
Loading
Loading