From e13c0d8656920e7f8832f6d6447bfa48fbc1b741 Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:32:24 +0800 Subject: [PATCH 01/17] fix(cli): run entrypoints when launched through a symlinked path The entrypoint guard compared the realpathed import.meta.url with the unresolved process.argv[1], so aas, aas-gui and bootstrap exited 0 without running when reached through an npm bin symlink, npm link, a macOS /tmp checkout or a Windows junction. gui:smoke through a link reported success without checking anything. Add isEntrypoint() to scripts/bootstrap.mjs, which compares both paths with realpathSync.native, and use it in all three guards. Track the six shebang scripts as executable. --- CHANGELOG.md | 7 ++++++ bin/aas-gui.mjs | 5 ++--- bin/aas.mjs | 5 +++-- examples/connector-conformance.mjs | 0 examples/review-handoff.mjs | 0 scripts/bootstrap.mjs | 29 +++++++++++++++++++++--- scripts/integration-check.mjs | 0 test/stack.test.mjs | 36 ++++++++++++++++++++++++++++-- 8 files changed, 72 insertions(+), 10 deletions(-) mode change 100644 => 100755 bin/aas-gui.mjs mode change 100644 => 100755 bin/aas.mjs mode change 100644 => 100755 examples/connector-conformance.mjs mode change 100644 => 100755 examples/review-handoff.mjs mode change 100644 => 100755 scripts/bootstrap.mjs mode change 100644 => 100755 scripts/integration-check.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db3447..817892f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ project adheres to [Semantic Versioning](https://semver.org/). - Playwright setup note in the Quick start section of `README.md`. ### Fixed +- `aas`, `aas-gui`, and `bootstrap` now run when launched through a link: an + npm bin symlink, `npm link`, a macOS /tmp checkout, or a Windows junction. + The entrypoint guard compared the realpathed module URL with the unresolved + `process.argv[1]`, so a linked launch exited 0 without doing anything, and + `npm run gui:smoke` through a link reported success without checking. The + six scripts with a `#!/usr/bin/env node` line are now tracked as + executable, so `./bin/aas.mjs` runs on a POSIX checkout. - Saved-case review now includes persisted stage stderr. `aas inspect` and the Markdown review omitted the diagnostic that the run report already stored, so a failed act looked like it had no child output. diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs old mode 100644 new mode 100755 index 228a138..5ace3e6 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -3,7 +3,6 @@ import { inspectCase, renderCaseMarkdown, renderComparisonMarkdown } from "./case-review.mjs"; import { createServer, request } from "node:http"; import { runGuiTask } from "./aas-gui-worker.mjs"; -import { pathToFileURL } from "node:url"; import { CHILD_JSON_LIMIT, DEFAULT_GUI_PORT, @@ -17,7 +16,7 @@ import { runCapture, selectPython, } from "./aas.mjs"; -import { assertFullStackNodeVersion } from "../scripts/bootstrap.mjs"; +import { assertFullStackNodeVersion, isEntrypoint } from "../scripts/bootstrap.mjs"; /** Bounded, strict JSON request body. The cap is enforced here regardless of any client-side check. */ async function readJsonRequest(request, { maxBytes = CHILD_JSON_LIMIT } = {}) { @@ -911,7 +910,7 @@ async function main() { await startGui({ port: resolveGuiPort() }); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isEntrypoint(import.meta.url)) { main().catch((error) => { process.stderr.write(`${JSON.stringify({ error: { message: error.message } })}\n`); process.exitCode = 1; diff --git a/bin/aas.mjs b/bin/aas.mjs old mode 100644 new mode 100755 index cd8d86d..39ccf25 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -25,12 +25,13 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; import { assertDependencyDirectory, assertFullStackNodeVersion, inspectDependencyDirectory, + isEntrypoint, loadComponentLock, } from "../scripts/bootstrap.mjs"; @@ -2176,7 +2177,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isEntrypoint(import.meta.url)) { main().catch((error) => { process.stderr.write(`${JSON.stringify({ error: { message: error.message } })}\n`); process.exitCode = 1; diff --git a/examples/connector-conformance.mjs b/examples/connector-conformance.mjs old mode 100644 new mode 100755 diff --git a/examples/review-handoff.mjs b/examples/review-handoff.mjs old mode 100644 new mode 100755 diff --git a/scripts/bootstrap.mjs b/scripts/bootstrap.mjs old mode 100644 new mode 100755 index 9140c9e..967e92f --- a/scripts/bootstrap.mjs +++ b/scripts/bootstrap.mjs @@ -4,13 +4,36 @@ * This script never accesses private repositories. */ import { spawnSync } from "node:child_process"; -import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { dirname, join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const lockPath = join(root, "stack-lock.json"); +/** + * True when the module at `metaUrl` is the script Node was asked to run. + * + * Node realpaths the main module before it builds `import.meta.url`, but + * `process.argv[1]` keeps the path as typed. Comparing the two directly fails + * whenever the script is reached through a link (an npm bin symlink, `npm + * link`, a macOS /tmp checkout, a Windows junction), and the entrypoint then + * exits 0 without running. Both sides are resolved with `realpathSync.native` + * so Windows short names and drive-letter case compare equal too. + * + * @param {string} metaUrl The caller's `import.meta.url`. + * @param {string|undefined} [argv1] The script path Node received. + * @returns {boolean} + */ +export function isEntrypoint(metaUrl, argv1 = process.argv[1]) { + if (!argv1) return false; + try { + return realpathSync.native(argv1) === realpathSync.native(fileURLToPath(metaUrl)); + } catch { + return false; + } +} + /** Full-stack floor: the pinned MandateBound package declares engines >=22.12.0. */ export const MIN_FULL_STACK_NODE = Object.freeze([22, 12, 0]); @@ -247,4 +270,4 @@ export function main() { process.stdout.write("Bootstrap complete.\n"); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); +if (isEntrypoint(import.meta.url)) main(); diff --git a/scripts/integration-check.mjs b/scripts/integration-check.mjs old mode 100644 new mode 100755 diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 695df46..4bb0e60 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -6,8 +6,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { assertFullStackNodeVersion, compareVersionTuples, loadComponentLock, inspectDependencyDirectory, MIN_FULL_STACK_NODE, npmInvocation, parseNodeVersion, prepareDependencies } from "../scripts/bootstrap.mjs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { assertFullStackNodeVersion, compareVersionTuples, isEntrypoint, loadComponentLock, inspectDependencyDirectory, MIN_FULL_STACK_NODE, npmInvocation, parseNodeVersion, prepareDependencies } from "../scripts/bootstrap.mjs"; import { buildRailReviewRequest, CHILD_JSON_LIMIT, @@ -917,6 +917,38 @@ test("CLI prints help for help tokens and demo --help", async () => { } }); +test("entrypoints run when launched through a linked directory", (t) => { + // npm installs `bin.aas` as a symlink, and `npm link`, a macOS /tmp checkout, + // or a Windows junction all reach the scripts through a link. A junction + // needs no privilege on Windows; POSIX ignores the type and makes a + // directory symlink. + const link = join(tempRoot(), "linked-bin"); + if (!linkOrSkip(t, join(ROOT, "bin"), link, "junction")) return; + const run = (args) => spawnSync(process.execPath, args, { cwd: ROOT, encoding: "utf8" }); + + const help = run([join(link, "aas.mjs"), "help"]); + assert.equal(help.status, 0, help.stderr); + assert.equal(help.stdout, helpText()); + + const unknown = run([join(link, "aas.mjs"), "no-such-command"]); + assert.equal(unknown.status, 2, unknown.stderr); + assert.match(unknown.stderr, /Unknown command: no-such-command/); + + const smoke = run([join(link, "aas-gui.mjs"), "--smoke-test"]); + assert.equal(smoke.status, 0, smoke.stderr); + assert.match(smoke.stdout, /GUI smoke test passed/); +}); + +test("isEntrypoint compares resolved paths and fails closed", () => { + const script = join(ROOT, "bin", "aas.mjs"); + const url = pathToFileURL(script).href; + assert.equal(isEntrypoint(url, script), true); + assert.equal(isEntrypoint(url, undefined), false); + assert.equal(isEntrypoint(url, ""), false); + assert.equal(isEntrypoint(url, join(ROOT, "package.json")), false); + assert.equal(isEntrypoint(url, join(tempRoot(), "missing.mjs")), false); +}); + test("CLI reports unknown commands, flags, and empty values as usage errors", async () => { const unknown = await captureMain(["nope"]); assert.equal(unknown.exitCode, 2); From 19e153535fef9488ecfaa839c358d3b8e66cd213 Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:34:18 +0800 Subject: [PATCH 02/17] build: pin LF line endings and derive the syntax gate from git CI's syntax step was a hand-written list of nine node --check calls that had already drifted once (#59). Replace it with scripts/check-syntax.mjs, which parses every module git ls-files reports and falls back to a tree walk outside a checkout. npm run check now runs the unit suite and the gate, and CI's test job calls npm run check:syntax. Add .gitattributes with text=auto eol=lf so Windows checkouts stay LF; every tracked file was already LF in the index, so renormalizing staged nothing. --- .gitattributes | 1 + .github/workflows/ci.yml | 6 +-- CHANGELOG.md | 6 +++ CONTRIBUTING.md | 6 ++- README.md | 5 ++- package.json | 3 +- scripts/check-syntax.mjs | 86 ++++++++++++++++++++++++++++++++++++++++ test/stack.test.mjs | 37 +++++++++++++++++ 8 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 .gitattributes create mode 100755 scripts/check-syntax.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b40f27..948db8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,7 @@ jobs: - name: Run tests run: npm test - name: Check syntax - # Every shipped module except the test files, which `npm test` and - # `npm run test:browser` already parse by running them. A parse error - # in the integration proof or an example otherwise surfaces only in the - # integration and browser jobs, and only on one platform. - run: node --check bin/aas.mjs && node --check bin/aas-gui.mjs && node --check bin/aas-gui-worker.mjs && node --check bin/case-review.mjs && node --check scripts/bootstrap.mjs && node --check scripts/integration-check.mjs && node --check examples/connector-conformance.mjs && node --check examples/review-handoff.mjs && node --check playwright.config.mjs + run: npm run check:syntax - name: Smoke test GUI run: npm run gui:smoke integration: diff --git a/CHANGELOG.md b/CHANGELOG.md index 817892f..3949712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,12 @@ project adheres to [Semantic Versioning](https://semver.org/). proof failed. Only the raw child payload is stripped now. ### Changed +- `npm run check` now runs the unit suite and a syntax gate, + `npm run check:syntax`, which parses every module `git ls-files` reports + with `node --check`. CI's test job runs the same gate in place of a + hand-written list of nine files, which had already drifted once. A new + `.gitattributes` pins LF line endings, so a Windows checkout no longer + turns every tracked file into a CRLF modification. - `aas help` now documents the whole CLI surface. The usage block listed neither `aas runs` nor `aas prune` although both are dispatched, the command list omitted `help`, and the option reference omitted nine accepted flags: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 735a483..9dfc089 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,11 +41,15 @@ contributions. ## Local checks ```bash -npm test +npm run check npm run integration npm run example:review-handoff ``` +`npm run check` is the unit suite (`npm test`) followed by the syntax gate +(`npm run check:syntax`), which parses every tracked JavaScript module. +`.gitattributes` keeps every text file LF, including on Windows checkouts. + `npm run test:browser` requires Playwright Chromium: ```bash diff --git a/README.md b/README.md index 55b94b9..ecff315 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,10 @@ npm test npm run check ``` -`npm test` is the unit suite (orchestrator and GUI models). `npm run +`npm test` is the unit suite (orchestrator and GUI models). `npm run check` +runs the unit suite and then the syntax gate, which parses every tracked +JavaScript module with `node --check`; the module list comes from +`git ls-files`, so a new script is covered without editing CI. `npm run integration` proves the pinned components from a clean checkout, and `npm run example:review-handoff` runs the integrator example. Installation, bootstrap, tests, and replay can write dependencies, caches, or diff --git a/package.json b/package.json index 20d1356..6194eb9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "test": "node --test --experimental-test-isolation=none test/gui.test.mjs test/stack.test.mjs", "integration": "node scripts/integration-check.mjs", "example:review-handoff": "node examples/review-handoff.mjs", - "check": "npm test", + "check": "npm test && npm run check:syntax", + "check:syntax": "node scripts/check-syntax.mjs", "test:browser": "playwright test", "example:connector-conformance": "node examples/connector-conformance.mjs" }, diff --git a/scripts/check-syntax.mjs b/scripts/check-syntax.mjs new file mode 100755 index 0000000..11ad340 --- /dev/null +++ b/scripts/check-syntax.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * Syntax gate: parse every tracked JavaScript module with `node --check`. + * + * The file list comes from `git ls-files`, so a new module is checked without + * editing CI. The hand-written list it replaces drifted once already, when it + * parsed three of nine shipped modules. Outside a Git checkout the gate walks + * the tree instead, skipping generated and dependency folders. + */ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isEntrypoint } from "./bootstrap.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const MODULE_PATTERN = /\.(?:mjs|cjs|js)$/; +const SKIPPED_DIRECTORIES = new Set([".git", "node_modules", "deps", ".out", "test-results", "playwright-report"]); + +function walk(projectRoot, directory, found) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(projectRoot, path, found); + } else if (entry.isFile() && MODULE_PATTERN.test(entry.name)) { + found.push(relative(projectRoot, path).split("\\").join("/")); + } + } + return found; +} + +/** + * Every JavaScript module the repository tracks, as sorted root-relative + * paths with forward slashes. + * + * @param {string} [projectRoot] + * @param {{command?: typeof spawnSync}} [options] + * @returns {string[]} + */ +export function listSyntaxTargets(projectRoot = root, { command = spawnSync } = {}) { + const listed = command("git", ["-C", projectRoot, "ls-files", "-z", "--", "*.mjs", "*.js", "*.cjs"], { + encoding: "utf8", + shell: false, + }); + if (!listed.error && listed.status === 0) { + return listed.stdout.split("\0").filter(Boolean).sort(); + } + return walk(projectRoot, projectRoot, []).sort(); +} + +/** + * Parse each file with the running Node.js and return the ones that fail. + * + * @param {string[]} files Paths relative to `cwd`, or absolute. + * @param {{cwd?: string}} [options] + * @returns {Array<{file: string, detail: string}>} + */ +export function checkSyntax(files, { cwd = root } = {}) { + const failures = []; + for (const file of files) { + const result = spawnSync(process.execPath, ["--check", file], { cwd, encoding: "utf8", shell: false }); + if (result.error || result.status !== 0) { + failures.push({ file, detail: (result.error?.message ?? result.stderr ?? "").trim() }); + } + } + return failures; +} + +export function main() { + const files = listSyntaxTargets(root); + if (files.length === 0) { + process.stderr.write("syntax: no JavaScript modules found; refusing to report a pass\n"); + process.exitCode = 1; + return; + } + const failures = checkSyntax(files, { cwd: root }); + if (failures.length === 0) { + process.stdout.write(`syntax: ${files.length} modules parsed\n`); + return; + } + for (const failure of failures) process.stderr.write(`syntax: ${failure.file} does not parse\n${failure.detail}\n`); + process.stderr.write(`syntax: ${failures.length} of ${files.length} modules failed\n`); + process.exitCode = 1; +} + +if (isEntrypoint(import.meta.url)) main(); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 4bb0e60..34c3080 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -8,6 +8,7 @@ import { Readable } from "node:stream"; import test from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; import { assertFullStackNodeVersion, compareVersionTuples, isEntrypoint, loadComponentLock, inspectDependencyDirectory, MIN_FULL_STACK_NODE, npmInvocation, parseNodeVersion, prepareDependencies } from "../scripts/bootstrap.mjs"; +import { checkSyntax, listSyntaxTargets } from "../scripts/check-syntax.mjs"; import { buildRailReviewRequest, CHILD_JSON_LIMIT, @@ -2195,6 +2196,42 @@ test('review handoff example honors the explicit interpreter override', () => { assert.equal(child.status,1);assert.match(child.stderr,/AAS_PYTHON/);assert.match(child.stderr,/did not report a usable Python version/); }); +test("syntax gate lists every tracked module and reports a parse failure", () => { + const targets = listSyntaxTargets(ROOT); + for (const file of [ + "bin/aas.mjs", + "bin/aas-gui.mjs", + "bin/aas-gui-worker.mjs", + "bin/case-review.mjs", + "examples/connector-conformance.mjs", + "examples/review-handoff.mjs", + "playwright.config.mjs", + "scripts/bootstrap.mjs", + "scripts/check-syntax.mjs", + "scripts/integration-check.mjs", + "test/browser/workbench.spec.mjs", + "test/component-compatibility.test.mjs", + "test/gui.test.mjs", + "test/stack.test.mjs", + ]) { + assert.ok(targets.includes(file), `syntax gate does not list ${file}`); + } + assert.ok(targets.every((file) => /\.(?:mjs|cjs|js)$/.test(file) && !file.includes("\\"))); + assert.ok(!targets.some((file) => /^(?:deps|node_modules|\.out)\//.test(file))); + + // Without Git the gate walks the tree and still skips generated folders. + const walked = listSyntaxTargets(ROOT, { command: () => ({ status: 128, stdout: "", stderr: "not a git repository" }) }); + assert.ok(walked.includes("bin/aas.mjs") && walked.includes("scripts/check-syntax.mjs")); + assert.ok(!walked.some((file) => /^(?:deps|node_modules|\.out)\//.test(file))); + + const scratch = tempRoot(); + writeFileSync(join(scratch, "valid.mjs"), "export const ok = 1;\n"); + writeFileSync(join(scratch, "broken.mjs"), "export const = ;\n"); + const failures = checkSyntax(["valid.mjs", "broken.mjs"], { cwd: scratch }); + assert.deepEqual(failures.map((failure) => failure.file), ["broken.mjs"]); + assert.match(failures[0].detail, /SyntaxError/); +}); + test("npm test reports every declared test", () => { // Process isolation ships test events on stdout. This file also writes // captured CLI output there, which drops events. The parent then reports a From a57adcd8df97711adc439577d4ca350fc372a149 Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:35:45 +0800 Subject: [PATCH 03/17] ci: harden checkouts, test the Python 3.11 floor, update pinned actions - Every checkout sets persist-credentials: false; the integration and browser jobs run npm ci and builds inside the pinned components. - Repin checkout v7.0.1, setup-node v7.0.0 and setup-python v7.0.0 by SHA (v7.1.0 of setup-node is one day old and is left for dependabot). - The integration matrix gains a fifth leg on Ubuntu with Node.js 22.12.0 and Python 3.11, the documented decide-stage floor. - Add a weekly grouped dependabot config for actions and npm. - Drop 'cached' from the Chromium step name; there is no cache. - A test now fails on any unpinned action, an action pinned to two SHAs, or a checkout that keeps credentials. --- .github/dependabot.yml | 20 +++++++++++++++++++ .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++---------- CHANGELOG.md | 13 ++++++++++++ docs/release-readiness.md | 2 +- test/stack.test.mjs | 42 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..426fc6e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Dependabot only opens pull requests. Each one goes through the full CI +# matrix and is reviewed and merged by hand; nothing here merges or releases. +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + all: + patterns: ["*"] + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + all: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 948db8e..9e61c41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Check out - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes. Keep the token out of .git/config while + # installed packages and pinned component code run in the workspace. + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} cache: npm @@ -44,19 +48,30 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] node: ["22.12.0", 24] + python: ["3.13"] + # The documented floor (MIN_PYTHON, README) on one leg. Overriding an + # original matrix value makes this a fifth job, not a change to one. + include: + - os: ubuntu-latest + node: "22.12.0" + python: "3.11" runs-on: ${{ matrix.os }} steps: - name: Check out - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes. Keep the token out of .git/config while + # installed packages and pinned component code run in the workspace. + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} cache: npm - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.13" + python-version: ${{ matrix.python }} - name: Run full-stack integration proof run: npm run integration --ignore-scripts - name: Run integrator example (pass path) @@ -78,19 +93,23 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes. Keep the token out of .git/config while + # installed packages and pinned component code run in the workspace. + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22.12.0" cache: npm - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - name: Install locked package set run: npm ci --ignore-scripts - - name: Install the Chromium browser (bounded, cached) + - name: Install the Chromium browser run: npx playwright install --with-deps chromium - name: Bootstrap pinned components run: npm run bootstrap diff --git a/CHANGELOG.md b/CHANGELOG.md index 3949712..0ca6abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,14 @@ project adheres to [Semantic Versioning](https://semver.org/). hand-written list of nine files, which had already drifted once. A new `.gitattributes` pins LF line endings, so a Windows checkout no longer turns every tracked file into a CRLF modification. +- CI now tests the documented Python 3.11 floor: the integration matrix adds + a fifth leg on Ubuntu with Node.js 22.12.0 and Python 3.11, next to the four + Python 3.13 legs. The pinned workflow actions move to their current + releases (checkout v7.0.1, setup-node v7.0.0, setup-python v7.0.0), still + pinned by commit SHA, and `.github/dependabot.yml` proposes weekly grouped + updates for GitHub Actions and npm. A test fails if any workflow step uses + an action without a full commit SHA and version comment. The browser job's + install step no longer claims a cache it does not have. - `aas help` now documents the whole CLI surface. The usage block listed neither `aas runs` nor `aas prune` although both are dispatched, the command list omitted `help`, and the option reference omitted nine accepted flags: @@ -124,4 +132,9 @@ project adheres to [Semantic Versioning](https://semver.org/). helper list is now a single `PAGE_HELPERS` constant shared by `renderPage` and the smoke check. +### Security +- Every CI checkout now sets `persist-credentials: false`. The integration and + browser jobs run `npm ci` and builds inside the pinned components, and the + job token used to stay in `.git/config` while that code ran. + [Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/main...HEAD \ No newline at end of file diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 8fb0a50..ea435f2 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -24,7 +24,7 @@ Local checks may install dependencies and write caches or temporary files; see - [ ] `npm ci --ignore-scripts` completes on Windows and Ubuntu. - [ ] `npm test` passes with lock mismatch, stale dependency, stage, child-process, and atomic-write coverage. - [ ] `npm run gui:smoke` passes. -- [ ] `npm run integration` passes on Ubuntu and Windows (Node.js 22.12.0 and 24, Python 3.13). +- [ ] `npm run integration` passes on Ubuntu and Windows (Node.js 22.12.0 and 24 with Python 3.13, plus Python 3.11 on Ubuntu with Node.js 22.12.0). - [ ] The public-copy scanner reports no punctuation or secret findings. - [ ] The final tree contains no placeholders or generated dependency directories. diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 34c3080..08b74b1 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -2232,6 +2232,48 @@ test("syntax gate lists every tracked module and reports a parse failure", () => assert.match(failures[0].detail, /SyntaxError/); }); +test("workflow actions are pinned by commit and checkouts drop the token", () => { + const workflows = join(ROOT, ".github", "workflows"); + const files = readdirSync(workflows).filter((name) => /\.ya?ml$/.test(name)).sort(); + assert.ok(files.includes("ci.yml"), "ci.yml is missing"); + const pins = new Map(); + let checkouts = 0; + for (const name of files) { + const lines = readFileSync(join(workflows, name), "utf8").split(/\r?\n/); + lines.forEach((line, index) => { + const uses = /^(\s*(?:-\s+)?)uses:\s*(\S+)/.exec(line); + if (!uses) return; + const where = `${name}:${index + 1}`; + const pinned = /^\s*(?:-\s+)?uses:\s*([\w.-]+\/[\w./-]+)@([0-9a-f]{40})\s+#\s*(v\d+\.\d+\.\d+)\s*$/.exec(line); + assert.ok(pinned, `${where} must pin a 40-character commit SHA with a # vX.Y.Z comment: ${line.trim()}`); + const [, action, sha, tag] = pinned; + const known = pins.get(action); + if (known) { + assert.equal(sha, known.sha, `${where} pins ${action} to a different SHA than ${known.where}`); + assert.equal(tag, known.tag, `${where} labels ${action} differently than ${known.where}`); + } else { + pins.set(action, { sha, tag, where }); + } + if (action !== "actions/checkout") return; + checkouts += 1; + // The step's keys sit at the column of `uses:`; the step ends at the + // first non-blank line indented less than that. + const keyIndent = uses[1].length; + const block = []; + for (const next of lines.slice(index + 1)) { + if (next.trim() === "") continue; + if (next.length - next.trimStart().length < keyIndent) break; + block.push(next.trim()); + } + assert.ok(block.includes("persist-credentials: false"), `${where} checkout must set persist-credentials: false`); + }); + } + assert.ok(checkouts >= 3, `expected every job to check out the repository, found ${checkouts}`); + for (const action of ["actions/checkout", "actions/setup-node", "actions/setup-python"]) { + assert.ok(pins.has(action), `${action} is not used by any workflow`); + } +}); + test("npm test reports every declared test", () => { // Process isolation ships test events on stdout. This file also writes // captured CLI output there, which drops events. The parent then reports a From 1f6c32f7688e679c1bf41a1056d121001de7d3b5 Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:38:27 +0800 Subject: [PATCH 04/17] build(release): add version consistency check; repair lock and changelog package.json, package-lock.json and CHANGELOG.md had drifted: the lock still said 0.2.0 after the 0.2.1 to 0.2.5 bumps, [Unreleased] carried three separate ### Changed headings, and its only link compared main with HEAD. scripts/check-version.mjs treats package.json as the source of truth and checks the lock, the Keep a Changelog structure (one [Unreleased] first, released sections dated and strictly descending, one heading per category, tag-based links), the release-readiness pin list against stack-lock.json, and with --tag that the tag matches. --notes extracts one release section for the release workflow. It runs in npm run check and in CI's test job. Repair the drift in the same change: set both lock version fields to 0.2.5, merge the Changed blocks without rewording them, and point [Unreleased] at the 0.2.0 release commit. --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 15 ++- CONTRIBUTING.md | 18 +++ docs/stack-lock.md | 3 + package-lock.json | 4 +- package.json | 3 +- scripts/check-version.mjs | 266 ++++++++++++++++++++++++++++++++++++++ test/stack.test.mjs | 120 +++++++++++++++++ 8 files changed, 423 insertions(+), 8 deletions(-) create mode 100755 scripts/check-version.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e61c41..c4bbb07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: run: npm test - name: Check syntax run: npm run check:syntax + - name: Check version consistency + run: npm run check:version - name: Smoke test GUI run: npm run gui:smoke integration: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca6abd..955accc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- `npm run check:version` (`scripts/check-version.mjs`), part of + `npm run check` and CI's test job. It fails when `package-lock.json`, this + changelog, the `docs/release-readiness.md` pin list, or a `--tag` disagree + with the `package.json` version, and `--notes` extracts one release's + section. It found and this change repairs two drifts: the lock still said + 0.2.0 after five patch bumps, and `[Unreleased]` carried three separate + `### Changed` headings and a `compare/main...HEAD` link that compared + nothing. `CONTRIBUTING.md` now describes the versioning and changelog + rules. - `CONTRIBUTING.md` describing cross-repo coordination and the `stack-lock.json` policy. - `CHANGELOG.md` (this file). @@ -115,15 +124,11 @@ project adheres to [Semantic Versioning](https://semver.org/). changed the result set. It now reports a missing value and exits 2, matching every other value-taking option in the CLI. The `aas cases` usage message also lists the `--domain`, `--outcome`, and `--search` filters it accepts. - -### Changed - `loadComponentLock` now rejects an `install` or `build` value other than the supported tokens (`npm-ci`, `npm-run-build`). Those fields are dispatched by exact string match, so an unrecognised value was skipped silently and `npm run bootstrap` still reported success for a component that had never been installed or built. The shipped `stack-lock.json` is unchanged. - -### Changed - `npm run gui:smoke` now requests `/api/health` and the workbench page over loopback HTTP and asserts the response status, the `text/html` content type, the content security policy header, and the presence of every embedded page @@ -137,4 +142,4 @@ project adheres to [Semantic Versioning](https://semver.org/). browser jobs run `npm ci` and builds inside the pinned components, and the job token used to stay in `.git/config` while that code ran. -[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/c9e89c001f51b48f1ebda6716996680546033a8b...HEAD \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dfc089..34881c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,24 @@ Do not bypass the lockfile. Editing `deps/` directly, swapping a remote URL, or relaxing the dirty-checkout rejection are all out of scope for normal contributions. +## Versioning and changelog + +`package.json` `version` is the single source of truth. `npm run +check:version` (part of `npm run check` and CI's test job) fails when +`package-lock.json`, `CHANGELOG.md`, `docs/release-readiness.md`, or a release +tag disagrees with it. + +- Feature and fix pull requests add one bullet under the matching category + of the existing `## [Unreleased]` section (Added, Changed, Deprecated, + Removed, Fixed, or Security; each at most once per section). They do not + bump the version. +- Prefix a breaking change with `BREAKING:`. The project is pre-1.0, so a + breaking change or a new feature takes a minor bump and a fix a patch. +- A release pull request runs `npm version X.Y.Z --no-git-tag-version + --ignore-scripts`, renames `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, opens + a new empty `## [Unreleased]` above it, and updates the link definitions at + the bottom of the file. After it merges, the merge commit is tagged `vX.Y.Z`. + ## Pull requests - One branch, one focused change. diff --git a/docs/stack-lock.md b/docs/stack-lock.md index e2b5d60..89268ab 100644 --- a/docs/stack-lock.md +++ b/docs/stack-lock.md @@ -56,6 +56,9 @@ the lock on its own. `build` only if the sibling's published contract actually changed. 4. Run `npm run integration` locally on Ubuntu and Windows. The CI integration job runs the same proof on every push and pull request. +5. Update the pin list in `docs/release-readiness.md` in the same pull + request. `npm run check:version` fails while that list is missing a + locked commit. Security fixes follow the same path. The schema version (`schema_version`) is bumped only when the shape changes in a way that requires loader changes; existing tools keep reading older versions until the bump lands diff --git a/package-lock.json b/package-lock.json index c92ea97..9138c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-action-stack", - "version": "0.2.0", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-action-stack", - "version": "0.2.0", + "version": "0.2.5", "license": "Apache-2.0", "bin": { "aas": "bin/aas.mjs" diff --git a/package.json b/package.json index 6194eb9..b5136ca 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "test": "node --test --experimental-test-isolation=none test/gui.test.mjs test/stack.test.mjs", "integration": "node scripts/integration-check.mjs", "example:review-handoff": "node examples/review-handoff.mjs", - "check": "npm test && npm run check:syntax", + "check": "npm test && npm run check:syntax && npm run check:version", "check:syntax": "node scripts/check-syntax.mjs", + "check:version": "node scripts/check-version.mjs", "test:browser": "playwright test", "example:connector-conformance": "node examples/connector-conformance.mjs" }, diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs new file mode 100755 index 0000000..7617998 --- /dev/null +++ b/scripts/check-version.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +/** + * Release consistency check. + * + * `package.json` "version" is the single source of truth. This script fails + * when package-lock.json, CHANGELOG.md, the release-readiness pin list, or an + * optional release tag disagree with it: + * + * node scripts/check-version.mjs [--tag vX.Y.Z] [--notes ] + * + * `--notes` writes the CHANGELOG section for the package version, which the + * release workflow publishes as the GitHub Release notes. + * + * bin/ must never import this module: the integration proof's offline replay + * copies only bin/aas.mjs, scripts/bootstrap.mjs, and stack-lock.json. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isEntrypoint } from "./bootstrap.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +export const REPOSITORY_URL = "https://github.com/EauDoon/agent-action-stack"; +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const RELEASED_HEADING = /^\[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$/; +const LINK_DEFINITION = /^\[([^\]]+)\]:\s+(\S+)\s*$/; +export const CHANGELOG_CATEGORIES = Object.freeze(["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]); + +export class UsageError extends Error {} + +/** @returns {[number, number, number]|null} */ +export function parseSemver(value) { + const match = typeof value === "string" ? SEMVER.exec(value) : null; + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1; + } + return 0; +} + +function isCalendarDate(value) { + const parsed = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +/** + * Split a Keep a Changelog document into its H2 sections and link + * definitions. Fenced code blocks are skipped so an example heading inside + * one is not read as structure. + */ +export function parseChangelog(text) { + const lines = String(text).split(/\r?\n/); + const sections = []; + const links = new Map(); + const stray = []; + let current = null; + let fenced = false; + lines.forEach((line, index) => { + if (/^\s*(?:```|~~~)/.test(line)) fenced = !fenced; + if (fenced) { + if (current) current.body.push(line); + return; + } + const link = LINK_DEFINITION.exec(line); + if (link) { + links.set(link[1], { url: link[2], line: index + 1 }); + return; + } + const h2 = /^## (.*)$/.exec(line); + if (h2) { + current = { title: h2[1].trim(), line: index + 1, headings: [], bullets: 0, body: [] }; + sections.push(current); + return; + } + const h3 = /^### (.*)$/.exec(line); + if (h3 && !current) { + stray.push({ title: h3[1].trim(), line: index + 1 }); + return; + } + if (!current) return; + if (h3) current.headings.push({ title: h3[1].trim(), line: index + 1 }); + if (/^[-*] \S/.test(line)) current.bullets += 1; + current.body.push(line); + }); + for (const section of sections) { + const released = RELEASED_HEADING.exec(section.title); + section.label = section.title === "[Unreleased]" ? "Unreleased" : released?.[1] ?? null; + section.date = released?.[2] ?? null; + section.text = `${section.body.join("\n").trim()}\n`; + } + return { sections, links, stray }; +} + +/** The body of one released section, ready to publish as release notes. */ +export function releaseNotes(changelog, version) { + const section = parseChangelog(changelog).sections.find((entry) => entry.label === version); + if (!section) throw new Error(`CHANGELOG.md has no section for ${version}.`); + return section.text; +} + +/** + * @param {{pkg: any, lock: any, changelog: string, readiness: string, stackLock: any, tag?: string|null}} inputs + * @returns {string[]} Every inconsistency found; empty means consistent. + */ +export function checkReleaseConsistency({ pkg, lock, changelog, readiness, stackLock, tag = null }) { + const problems = []; + const version = pkg?.version; + if (parseSemver(version) === null) { + problems.push(`package.json version ${JSON.stringify(version)} is not X.Y.Z.`); + } + if (lock?.version !== version) { + problems.push(`package-lock.json version ${JSON.stringify(lock?.version)} does not match package.json ${version}.`); + } + if (lock?.packages?.[""]?.version !== version) { + problems.push(`package-lock.json packages[""].version ${JSON.stringify(lock?.packages?.[""]?.version)} does not match package.json ${version}.`); + } + + const { sections, links, stray } = parseChangelog(changelog); + for (const heading of stray) { + problems.push(`CHANGELOG.md:${heading.line} has "### ${heading.title}" outside a version section.`); + } + if (sections[0]?.title !== "[Unreleased]") { + problems.push("CHANGELOG.md must open with a \"## [Unreleased]\" section."); + } + const released = []; + sections.forEach((section, index) => { + if (index > 0) { + if (section.date === null) { + problems.push(`CHANGELOG.md:${section.line} "## ${section.title}" is not "## [X.Y.Z] - YYYY-MM-DD".`); + } else if (parseSemver(section.label) === null || !isCalendarDate(section.date)) { + problems.push(`CHANGELOG.md:${section.line} "## ${section.title}" has an invalid version or date.`); + } else { + released.push(section); + } + } + const seen = new Set(); + for (const heading of section.headings) { + if (!CHANGELOG_CATEGORIES.includes(heading.title)) { + problems.push(`CHANGELOG.md:${heading.line} "### ${heading.title}" is not one of ${CHANGELOG_CATEGORIES.join(", ")}.`); + } + if (seen.has(heading.title)) { + problems.push(`CHANGELOG.md:${heading.line} repeats "### ${heading.title}" inside "## ${section.title}".`); + } + seen.add(heading.title); + } + }); + for (let index = 1; index < released.length; index += 1) { + const newer = released[index - 1]; + const older = released[index]; + if (compareSemver(newer.label, older.label) <= 0) { + problems.push(`CHANGELOG.md:${older.line} ${older.label} must be older than ${newer.label} above it; released sections run newest first without repeats.`); + } + } + + // Every heading resolves to a link, and released links name their tags. + for (const section of sections) { + if (section.label === null) continue; + const link = links.get(section.label); + if (!link) { + problems.push(`CHANGELOG.md has no [${section.label}] link definition.`); + continue; + } + if (!link.url.startsWith(`${REPOSITORY_URL}/`)) { + problems.push(`CHANGELOG.md:${link.line} [${section.label}] must link into ${REPOSITORY_URL}.`); + continue; + } + const position = released.indexOf(section); + let expected = null; + if (section.label === "Unreleased" && released.length > 0) { + expected = `${REPOSITORY_URL}/compare/v${released[0].label}...HEAD`; + } else if (position >= 0) { + const previous = released[position + 1]; + expected = previous + ? `${REPOSITORY_URL}/compare/v${previous.label}...v${section.label}` + : `${REPOSITORY_URL}/releases/tag/v${section.label}`; + } + if (expected !== null && link.url !== expected) { + problems.push(`CHANGELOG.md:${link.line} [${section.label}] should be ${expected}.`); + } + } + + if (released.length > 0 && released[0].label !== version) { + problems.push(`CHANGELOG.md newest release ${released[0].label} does not match package.json ${version}.`); + } + + const components = Array.isArray(stackLock?.components) ? stackLock.components : []; + if (components.length === 0) problems.push("stack-lock.json lists no components."); + for (const component of components) { + if (typeof component?.commit !== "string" || !String(readiness).includes(component.commit)) { + problems.push(`docs/release-readiness.md does not list the ${component?.name ?? "unnamed"} pin ${component?.commit}.`); + } + } + + if (tag !== null) { + if (tag !== `v${version}`) { + problems.push(`Tag ${tag} does not match package.json version ${version} (expected v${version}).`); + } + const section = released.find((entry) => entry.label === version); + if (!section) { + problems.push(`CHANGELOG.md has no released "## [${version}] - YYYY-MM-DD" section for tag ${tag}.`); + } else if (section.bullets === 0) { + problems.push(`CHANGELOG.md section ${version} has no entries to publish.`); + } + } + return problems; +} + +export function loadReleaseInputs(projectRoot = root) { + const read = (path) => readFileSync(join(projectRoot, path), "utf8"); + return { + pkg: JSON.parse(read("package.json")), + lock: JSON.parse(read("package-lock.json")), + changelog: read("CHANGELOG.md"), + readiness: read("docs/release-readiness.md"), + stackLock: JSON.parse(read("stack-lock.json")), + }; +} + +export function parseCheckArgs(args) { + const options = { tag: null, notes: null }; + for (let index = 0; index < args.length; index += 1) { + const name = args[index]; + const key = name === "--tag" ? "tag" : name === "--notes" ? "notes" : null; + const value = args[index + 1]; + if (key === null) throw new UsageError(`Unsupported option: ${name}`); + if (options[key] !== null) throw new UsageError(`Duplicate option: ${name}`); + if (value === undefined || value.startsWith("--") || value.trim() === "") throw new UsageError(`Missing value for ${name}`); + options[key] = value; + index += 1; + } + return options; +} + +export function main(args = process.argv.slice(2)) { + let options; + try { + options = parseCheckArgs(args); + } catch (error) { + process.stderr.write(`${error.message}\nUsage: node scripts/check-version.mjs [--tag vX.Y.Z] [--notes ]\n`); + process.exitCode = 2; + return; + } + const inputs = loadReleaseInputs(root); + const problems = checkReleaseConsistency({ ...inputs, tag: options.tag }); + if (problems.length === 0 && options.notes !== null) { + try { + writeFileSync(options.notes, releaseNotes(inputs.changelog, inputs.pkg.version)); + } catch (error) { + problems.push(`Cannot write release notes: ${error.message}`); + } + } + if (problems.length > 0) { + for (const problem of problems) process.stderr.write(`version: ${problem}\n`); + process.exitCode = 1; + return; + } + const tagged = options.tag === null ? "" : `, tag ${options.tag}`; + process.stdout.write(`version: ${inputs.pkg.version} consistent (package, lock, changelog, release pins${tagged})\n`); +} + +if (isEntrypoint(import.meta.url)) main(); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 08b74b1..eb74801 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -9,6 +9,7 @@ import test from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; import { assertFullStackNodeVersion, compareVersionTuples, isEntrypoint, loadComponentLock, inspectDependencyDirectory, MIN_FULL_STACK_NODE, npmInvocation, parseNodeVersion, prepareDependencies } from "../scripts/bootstrap.mjs"; import { checkSyntax, listSyntaxTargets } from "../scripts/check-syntax.mjs"; +import { checkReleaseConsistency, loadReleaseInputs, parseChangelog, releaseNotes } from "../scripts/check-version.mjs"; import { buildRailReviewRequest, CHILD_JSON_LIMIT, @@ -2274,6 +2275,125 @@ test("workflow actions are pinned by commit and checkouts drop the token", () => } }); +function releaseFixture({ version = "0.2.5", lockVersion = version, changelog, readiness, tag = null } = {}) { + const pins = loadComponentLock(LOCK).map((component) => component.commit); + return { + pkg: { name: "agent-action-stack", version }, + lock: { name: "agent-action-stack", version: lockVersion, packages: { "": { name: "agent-action-stack", version: lockVersion } } }, + changelog: changelog ?? [ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Fixed", + "- One fix.", + "", + "[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/c9e89c001f51b48f1ebda6716996680546033a8b...HEAD", + "", + ].join("\n"), + readiness: readiness ?? pins.map((sha) => `- pin \`${sha}\``).join("\n"), + stackLock: { components: loadComponentLock(LOCK) }, + tag, + }; +} + +const RELEASED_CHANGELOG = [ + "# Changelog", + "", + "## [Unreleased]", + "", + "## [0.3.0] - 2026-10-09", + "", + "### Changed", + "- BREAKING: a change.", + "", + "### Fixed", + "- A fix.", + "", + "## [0.2.0] - 2026-08-03", + "", + "### Added", + "- The GUI.", + "", + "## [0.1.0] - 2026-07-31", + "", + "### Added", + "- The first demo.", + "", + "[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/v0.3.0...HEAD", + "[0.3.0]: https://github.com/EauDoon/agent-action-stack/compare/v0.2.0...v0.3.0", + "[0.2.0]: https://github.com/EauDoon/agent-action-stack/compare/v0.1.0...v0.2.0", + "[0.1.0]: https://github.com/EauDoon/agent-action-stack/releases/tag/v0.1.0", + "", +].join("\n"); + +test("version check accepts the live repository and a well-formed release", () => { + assert.deepEqual(checkReleaseConsistency(loadReleaseInputs(ROOT)), []); + assert.deepEqual(checkReleaseConsistency(releaseFixture()), []); + assert.deepEqual(checkReleaseConsistency(releaseFixture({ version: "0.3.0", changelog: RELEASED_CHANGELOG, tag: "v0.3.0" })), []); +}); + +test("version check flags lock, changelog, pin, and tag drift", () => { + const flagged = (inputs, pattern) => { + const problems = checkReleaseConsistency(inputs); + assert.ok(problems.some((problem) => pattern.test(problem)), `expected ${pattern} in ${JSON.stringify(problems)}`); + }; + flagged(releaseFixture({ lockVersion: "0.2.0" }), /package-lock\.json version "0\.2\.0" does not match package\.json 0\.2\.5/); + flagged(releaseFixture({ lockVersion: "0.2.0" }), /packages\[""\]\.version "0\.2\.0"/); + flagged(releaseFixture({ version: "0.2" }), /is not X\.Y\.Z/); + flagged(releaseFixture({ changelog: "## [Unreleased]\n\n### Changed\n- a\n\n### Changed\n- b\n\n[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/x...HEAD\n" }), + /repeats "### Changed"/); + flagged(releaseFixture({ changelog: "## [Unreleased]\n\n### Improved\n- a\n\n[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/x...HEAD\n" }), + /"### Improved" is not one of/); + flagged(releaseFixture({ changelog: "## [0.2.5] - 2026-09-28\n\n### Fixed\n- a\n\n[0.2.5]: https://github.com/EauDoon/agent-action-stack/releases/tag/v0.2.5\n" }), + /must open with a "## \[Unreleased\]" section/); + flagged(releaseFixture({ changelog: "## [Unreleased]\n\n[Unreleased]: https://example.com/compare/x...HEAD\n" }), /must link into/); + flagged(releaseFixture({ changelog: "## [Unreleased]\n\n### Fixed\n- a\n" }), /no \[Unreleased\] link definition/); + + const unsorted = RELEASED_CHANGELOG.replace("## [0.2.0] - 2026-08-03", "## [0.4.0] - 2026-08-03"); + flagged(releaseFixture({ version: "0.3.0", changelog: unsorted }), /0\.4\.0 must be older than 0\.3\.0/); + flagged(releaseFixture({ version: "0.3.0", changelog: RELEASED_CHANGELOG.replace("2026-08-03", "2026-02-30") }), /invalid version or date/); + flagged(releaseFixture({ version: "0.3.0", changelog: RELEASED_CHANGELOG.replace("## [0.1.0] - 2026-07-31", "## [0.1.0]") }), /is not "## \[X\.Y\.Z\] - YYYY-MM-DD"/); + flagged(releaseFixture({ version: "0.3.1", changelog: RELEASED_CHANGELOG }), /newest release 0\.3\.0 does not match package\.json 0\.3\.1/); + flagged(releaseFixture({ version: "0.3.0", changelog: RELEASED_CHANGELOG.replace("compare/v0.3.0...HEAD", "compare/main...HEAD") }), + /\[Unreleased\] should be https:\/\/github\.com\/EauDoon\/agent-action-stack\/compare\/v0\.3\.0\.\.\.HEAD/); + flagged(releaseFixture({ version: "0.3.0", changelog: RELEASED_CHANGELOG.replace("releases/tag/v0.1.0", "releases/tag/v0.0.1") }), /\[0\.1\.0\] should be/); + + flagged(releaseFixture({ tag: "v9.9.9" }), /Tag v9\.9\.9 does not match package\.json version 0\.2\.5/); + flagged(releaseFixture({ tag: "v0.2.5" }), /no released "## \[0\.2\.5\] - YYYY-MM-DD" section/); + flagged(releaseFixture({ version: "0.3.0", tag: "v0.3.0", changelog: RELEASED_CHANGELOG.replace("- BREAKING: a change.", "").replace("- A fix.", "") }), + /section 0\.3\.0 has no entries/); + + const missingPin = loadComponentLock(LOCK)[1].commit; + flagged(releaseFixture({ readiness: loadComponentLock(LOCK).filter((component) => component.commit !== missingPin).map((component) => component.commit).join("\n") }), + new RegExp(`does not list the consequence-rail pin ${missingPin}`)); +}); + +test("release notes are exactly one changelog section", () => { + const notes = releaseNotes(RELEASED_CHANGELOG, "0.3.0"); + assert.equal(notes, "### Changed\n- BREAKING: a change.\n\n### Fixed\n- A fix.\n"); + assert.equal(releaseNotes(RELEASED_CHANGELOG, "0.1.0"), "### Added\n- The first demo.\n"); + assert.throws(() => releaseNotes(RELEASED_CHANGELOG, "9.9.9"), /no section for 9\.9\.9/); + // A heading inside a fenced block is content, not structure. + const fenced = parseChangelog("## [Unreleased]\n\n### Added\n- a\n\n```md\n### Added\n```\n"); + assert.deepEqual(fenced.sections[0].headings.map((heading) => heading.title), ["Added"]); +}); + +test("version check CLI reports drift with exit 1 and usage errors with exit 2", () => { + const script = join(ROOT, "scripts", "check-version.mjs"); + const live = spawnSync(process.execPath, [script], { cwd: ROOT, encoding: "utf8" }); + assert.equal(live.status, 0, live.stderr); + assert.match(live.stdout, /^version: \d+\.\d+\.\d+ consistent \(package, lock, changelog, release pins\)\n$/); + + const usage = spawnSync(process.execPath, [script, "--tag"], { cwd: ROOT, encoding: "utf8" }); + assert.equal(usage.status, 2); + assert.match(usage.stderr, /Missing value for --tag/); + + const wrongTag = spawnSync(process.execPath, [script, "--tag", "v0.0.0-not-this"], { cwd: ROOT, encoding: "utf8" }); + assert.equal(wrongTag.status, 1); + assert.match(wrongTag.stderr, /Tag v0\.0\.0-not-this does not match package\.json version/); +}); + test("npm test reports every declared test", () => { // Process isolation ships test events on stdout. This file also writes // captured CLI output there, which drops events. The parent then reports a From aed688efb57a81a671306da55ba21023b012e76a Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:40:17 +0800 Subject: [PATCH 05/17] fix(cli): validate demo arguments first and name missing saved cases main probed Node.js and up to seven Python candidates before runDemo parsed its options, so with a broken AAS_PYTHON a demo usage error exited 1 with the interpreter message instead of the documented 2. Extract parseDemoOptions, call it first in main's demo branch, and reuse it in runDemo so the messages stay the same. Saved-case reads printed raw ENOENT errors that carried the absolute local path. readRunBundleBody now reports 'Saved case not found: ' or names the missing case-relative file, keeping the error code so the GUI's 404 mapping is unchanged. --- CHANGELOG.md | 10 +++++++ bin/aas.mjs | 72 +++++++++++++++++++++++++++++++++++---------- test/stack.test.mjs | 53 ++++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 955accc..4ae5c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,16 @@ project adheres to [Semantic Versioning](https://semver.org/). `npm run gui:smoke` through a link reported success without checking. The six scripts with a `#!/usr/bin/env node` line are now tracked as executable, so `./bin/aas.mjs` runs on a POSIX checkout. +- `aas demo` validates its options before it checks the runtime. With a broken + `AAS_PYTHON`, a usage error such as `aas demo --bogus` exited 1 with the + interpreter message, after up to seven interpreter probes, instead of the + documented exit 2. `parseDemoOptions` now runs first, and `runDemo` uses + the same parser, so the messages are unchanged. +- `aas export`, `verify`, `inspect`, and `latest` now report a missing saved + case as `Saved case not found: `, and a missing or unreadable file + inside a case by its case-relative name. They printed the raw `ENOENT` + error, including the absolute local path. The error code stays `ENOENT`, + so the GUI's 404 responses are unchanged. - Saved-case review now includes persisted stage stderr. `aas inspect` and the Markdown review omitted the diagnostic that the run report already stored, so a failed act looked like it had no child output. diff --git a/bin/aas.mjs b/bin/aas.mjs index 39ccf25..06c5909 100755 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -1105,19 +1105,45 @@ export function readRunBundle(outputRoot, runId) { } } +/** + * Replace a filesystem error from a saved-case read with one that names the + * case instead of the absolute local path. The code is kept, so callers that + * map ENOENT to "not found" (the GUI's 404s) behave exactly as before. + */ +function caseReadError(error, runId, file) { + if (!error || typeof error.syscall !== "string") return error; + const message = error.code === "ENOENT" + ? (file === null ? `Saved case not found: ${runId}` : `Saved case ${runId} is missing ${file}.`) + : `Saved case ${runId} could not be read${file === null ? "" : ` (${file})`}: ${error.code ?? "filesystem error"}.`; + return Object.assign(new Error(message), { code: error.code }); +} + function readRunBundleBody(outputRoot, runId) { if (!isValidRunId(runId)) throw new Error("Invalid run id."); assertRunsDirectory(outputRoot); const bundleDir = join(outputRoot, "runs", runId); - const directory = lstatSync(bundleDir); + let directory; + try { + directory = lstatSync(bundleDir); + } catch (error) { + throw caseReadError(error, runId, null); + } if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("Case directory must be a regular directory."); - const manifest = readBoundedCaseJson(join(bundleDir, "manifest.json")); + // `file` is the manifest-relative name, already restricted by safeBundleFile. + const readCaseFile = (path, file) => { + try { + return readBoundedCaseJson(path); + } catch (error) { + throw caseReadError(error, runId, file); + } + }; + const manifest = readCaseFile(join(bundleDir, "manifest.json"), "manifest.json"); validateSavedManifest(manifest, bundleDir); - const report = readBoundedCaseJson(safeBundleFile(bundleDir, manifest.report, "report path")); + const report = readCaseFile(safeBundleFile(bundleDir, manifest.report, "report path"), manifest.report); if (manifest.run_id !== runId || report?.run_id !== runId) throw new Error("Case identity does not match its directory."); const stages = {}; for (const [name, stage] of Object.entries(manifest.stages)) { - if (stage.artifact) stages[name] = readBoundedCaseJson(safeBundleFile(bundleDir, stage.artifact, "stage artifact path")); + if (stage.artifact) stages[name] = readCaseFile(safeBundleFile(bundleDir, stage.artifact, "stage artifact path"), stage.artifact); } const bundle = { manifest, report, stages }; if (Buffer.byteLength(JSON.stringify(bundle, null, 2) + "\n") > CHILD_JSON_LIMIT) throw new Error("Exported case exceeds the replay byte limit."); @@ -1559,21 +1585,15 @@ export function printHuman(report, bundleDir = null) { } /** - * Run decide → act → prove and persist an isolated bundle. + * Validate demo flags and their values without touching the environment. + * `main` calls this before probing Node.js and Python, so a usage error + * exits 2 even when the runtime is broken, and costs no interpreter spawns. * - * @param {string[]} [args] Demo flags: --response, --fault, --dispute, --json. - * @param {object} [options] - * @returns {Promise} + * @param {string[]} args + * @returns {{responseName: "pass"|"fail", fault: string, forceDispute: boolean, asJson: boolean, proveMode: string, domain: string}} */ -export async function runDemo(args = [], options = {}) { +export function parseDemoOptions(args) { validateDemoArgs(args); - const childTimeoutMs = options.childTimeoutMs ?? resolveChildTimeoutMs(); - const runner = options.runner ?? ((command, args, opts = {}) => - runCapture(command, args, { timeout: childTimeoutMs, ...opts })); - const paths = { - ...DEFAULT_PATHS, - ...(options.paths ?? {}), - }; const responseName = option(args, "--response", "pass"); if (responseName !== "pass" && responseName !== "fail") { throw new UsageError("--response must be pass or fail"); @@ -1592,6 +1612,25 @@ export async function runDemo(args = [], options = {}) { if (!DEMO_DOMAINS.has(domain)) { throw new UsageError("--domain must be refund or inventory"); } + return { responseName, fault, forceDispute, asJson, proveMode, domain }; +} + +/** + * Run decide → act → prove and persist an isolated bundle. + * + * @param {string[]} [args] Demo flags: --response, --fault, --dispute, --json. + * @param {object} [options] + * @returns {Promise} + */ +export async function runDemo(args = [], options = {}) { + const { responseName, fault, forceDispute, asJson, proveMode, domain } = parseDemoOptions(args); + const childTimeoutMs = options.childTimeoutMs ?? resolveChildTimeoutMs(); + const runner = options.runner ?? ((command, args, opts = {}) => + runCapture(command, args, { timeout: childTimeoutMs, ...opts })); + const paths = { + ...DEFAULT_PATHS, + ...(options.paths ?? {}), + }; const responseFile = domain === "inventory" ? `inventory.response.${responseName}.json` @@ -2162,6 +2201,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { return; } try { + parseDemoOptions(argv.slice(1)); assertFullStackNodeVersion( options.nodeVersion === undefined ? {} : { version: options.nodeVersion }, ); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index eb74801..ae8395c 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1390,7 +1390,11 @@ test("export reads the persisted run bundle", async () => { const exported = exportRunBundle("export-run", { outputRoot }); assert.equal(exported.report.run_id, "export-run"); assert.deepEqual(Object.keys(exported.stages).sort(), ["act", "decide"]); - assert.throws(() => exportRunBundle("no-such-run", { outputRoot }), /Invalid run id|ENOENT/); + assert.throws(() => exportRunBundle("no-such-run", { outputRoot }), (error) => { + assert.equal(error.message, "Saved case not found: no-such-run"); + assert.equal(error.code, "ENOENT"); + return true; + }); assert.throws(() => exportRunBundle("../escape", { outputRoot }), /Invalid run id/); }); @@ -1538,6 +1542,53 @@ test("export and replay CLI validate arguments and missing files", async () => { assert.equal(extra.exitCode, 2); }); +test("demo usage errors exit 2 before any runtime probing", async () => { + // A broken interpreter override used to win: main probed Node.js and every + // Python candidate first, so a typo exited 1 with an AAS_PYTHON message. + await withEnv("AAS_PYTHON", "aas-synthetic-missing-python", async () => { + const bogus = await captureMain(["demo", "--bogus"]); + assert.equal(bogus.exitCode, 2); + assert.match(bogus.stderr, /Unsupported demo option: --bogus/); + assert.doesNotMatch(bogus.stderr, /AAS_PYTHON/); + + const value = await captureMain(["demo", "--response", "maybe"]); + assert.equal(value.exitCode, 2); + assert.match(value.stderr, /--response must be pass or fail/); + assert.doesNotMatch(value.stderr, /AAS_PYTHON/); + + const domain = await captureMain(["demo", "--domain", "payroll", "--json"]); + assert.equal(domain.exitCode, 2); + assert.deepEqual(JSON.parse(domain.stderr), { error: { message: "--domain must be refund or inventory" } }); + + // Valid arguments still reach the interpreter check and fail there. + const valid = await captureMain(["demo", "--response", "fail"]); + assert.equal(valid.exitCode, 1); + assert.match(valid.stderr, /AAS_PYTHON \(aas-synthetic-missing-python\)/); + }); +}); + +test("saved-case commands name a missing case without the local path", async () => { + const outputRoot = tempRoot(); + mkdirSync(join(outputRoot, "runs")); + for (const argv of [ + ["export", "missing-id", "--json", "--root", outputRoot], + ["verify", "missing-id", "--json", "--root", outputRoot], + ["inspect", "missing-id", "--json", "--root", outputRoot], + ]) { + const result = await captureMain(argv); + assert.equal(result.exitCode, 1, `${argv[0]} exit code`); + assert.deepEqual(JSON.parse(result.stderr), { error: { message: "Saved case not found: missing-id", code: "ENOENT" } }, argv[0]); + assert.ok(!result.stderr.includes(outputRoot), `${argv[0]} printed the local path`); + } + + // A case directory that lost its manifest is reported by name, not by path. + mkdirSync(join(outputRoot, "runs", "torn-run")); + const torn = await captureMain(["export", "torn-run", "--root", outputRoot]); + assert.equal(torn.exitCode, 1); + assert.match(torn.stderr, /^Saved case torn-run is missing manifest\.json\.\ncode: ENOENT\n$/); + assert.ok(!torn.stderr.includes(outputRoot)); +}); + async function makeRuns(outputRoot, count) { const ids = []; for (let index = 0; index < count; index += 1) { From 05a0dd9b3f077cdac7a025cd93baefb29ad91d96 Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:41:03 +0800 Subject: [PATCH 06/17] fix(run): record one prove skip reason in report and manifest on act error The runDemo catch block set stages.prove to act_error but left report.stages.prove at its initial not_reached, and finalize takes the reason from the report. The saved report and manifest therefore disagreed about why prove was skipped. Set report.stages.prove alongside stages.prove; the prove-error branch and its act-field preservation are unchanged. The GUI stage headline also gains a label for act_failed, which it printed as a raw token. --- CHANGELOG.md | 5 +++++ bin/aas-gui.mjs | 1 + bin/aas.mjs | 7 ++++++- test/gui.test.mjs | 21 +++++++++++++++++++++ test/stack.test.mjs | 6 ++++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae5c7e..0e44319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ project adheres to [Semantic Versioning](https://semver.org/). inside a case by its case-relative name. They printed the raw `ENOENT` error, including the absolute local path. The error code stays `ENOENT`, so the GUI's 404 responses are unchanged. +- After an act-stage error, `report.json` now records prove as skipped with + reason `act_error`, matching `manifest.json`. The report kept its initial + `not_reached`, so the GUI summary and the saved-case review disagreed about + the same run. The GUI summary also labels the `act_failed` skip reason + ("act failed") instead of printing the raw token. - Saved-case review now includes persisted stage stderr. `aas inspect` and the Markdown review omitted the diagnostic that the run report already stored, so a failed act looked like it had no child output. diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index 5ace3e6..3d386d7 100755 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -81,6 +81,7 @@ function stageHeadline(name, stage) { no_dispute: "settled and no dispute requested", not_reached: "not reached", act_error: "act errored", + act_failed: "act failed", }; if (!stage || typeof stage !== "object") return `${name}: unknown`; if (stage.status === "skipped") { diff --git a/bin/aas.mjs b/bin/aas.mjs index 06c5909..6eceb52 100755 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -1795,7 +1795,12 @@ export async function runDemo(args = [], options = {}) { stages.prove = stageRecord("error", stageErrorFields(error)); report.stages.prove = stages.prove; } - if (actStarted && !proveStarted) stages.prove = stageRecord("skipped", { reason: "act_error" }); + if (actStarted && !proveStarted) { + // Record the same skip reason the manifest carries; the report used to + // keep its initial not_reached here. + stages.prove = stageRecord("skipped", { reason: "act_error" }); + report.stages.prove = stages.prove; + } if (proveStarted && stages.act.status === "pending") { stages.act = stageRecord("skipped", { reason: "prove_error" }); report.stages.act = stages.act; diff --git a/test/gui.test.mjs b/test/gui.test.mjs index afca543..46638d2 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -216,6 +216,27 @@ test("summary model narrates status, gating reasons, and review results", () => assert.match(rail, /mode rail-review/); assert.match(rail, /review recorded/); assert.match(rail, /review review-abc/); + + const actErrored = summaryModel({ + flow: "decide -> act error", + stages: { + decide: { status: "passed", passed: true }, + act: { status: "error", reason: "act child process exited with status 7" }, + prove: { status: "skipped", reason: "act_error" }, + }, + }); + assert.match(actErrored, /prove: skipped \(act errored\)/); + + const actFailed = summaryModel({ + flow: "decide -> act -> stop (act failed)", + stages: { + decide: { status: "passed", passed: true }, + act: { status: "failed" }, + prove: { status: "skipped", reason: "act_failed" }, + }, + }); + assert.match(actFailed, /prove: skipped \(act failed\)/); + assert.doesNotMatch(actFailed, /act_failed/); }); test("summary model escapes untrusted values", () => { diff --git a/test/stack.test.mjs b/test/stack.test.mjs index ae8395c..748da7d 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -766,6 +766,12 @@ test("act and prove child-process errors keep the run isolated", async () => { assert.equal(actError.exitCode, 1); assert.equal(actError.manifest.stages.act.status, "error"); assert.equal(actError.manifest.stages.prove.reason, "act_error"); + // report.json and manifest.json must give the same reason for one run. + assert.equal(actError.report.stages.prove.status, "skipped"); + assert.equal(actError.report.stages.prove.reason, "act_error"); + const persistedReport = JSON.parse(readFileSync(join(actError.bundleDir, "report.json"), "utf8")); + assert.equal(persistedReport.stages.prove.reason, actError.manifest.stages.prove.reason); + assert.ok(!("raw" in persistedReport.stages.prove)); const proveOutputRoot = tempRoot(); const proveError = await runDemo(["--response", "pass", "--dispute"], stubOptions(proveOutputRoot, { From fd977927f4dad8701b9627c145a20ad14df34799 Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:43:16 +0800 Subject: [PATCH 07/17] fix(gui): announce run progress and failures in a visible status The run handler wrote 'Running...', request failures, the 503 busy message and the missing-deps bootstrap hint only to #output, which sits inside a collapsed
, while the aria-live summary was cleared. A first run before bootstrapping looked like a dead button. Add a role=status line under Run stack and write every progress, refusal and completion message there, keeping the existing latest-run stale guards. #output now holds only the raw run JSON. Unit stubs and the Playwright refusal test assert the status text. --- CHANGELOG.md | 8 +++++ bin/aas-gui.mjs | 17 +++++++--- test/browser/workbench.spec.mjs | 5 +++ test/gui.test.mjs | 55 +++++++++++++++++++++++++++++++-- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e44319..32d120d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,14 @@ project adheres to [Semantic Versioning](https://semver.org/). `not_reached`, so the GUI summary and the saved-case review disagreed about the same run. The GUI summary also labels the `act_failed` skip reason ("act failed") instead of printing the raw token. +- The workbench now reports run progress and failures in a visible status line + under **Run stack** (`#run-status`, announced by screen readers): running, + run not started (including the busy 503 and the `npm run bootstrap` hint + when `deps/` is missing), request failed, finished with its flow, finished + with a stage failure, and finished without a bundle. Those messages used + to go only to the raw report inside a collapsed `
`, so a click on + **Run stack** before bootstrapping appeared to do nothing. The raw report + now holds only the run JSON. - Saved-case review now includes persisted stage stderr. `aas inspect` and the Markdown review omitted the diagnostic that the run report already stored, so a failed act looked like it had no child output. diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index 3d386d7..b83daa1 100755 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -328,6 +328,7 @@ export function renderPage() {
+

Download run bundle
@@ -371,6 +372,7 @@ document.getElementById('apply-scenario').addEventListener('click',()=>{ }); for(const name of ['response','fault','prove','dispute']) document.getElementById(name).addEventListener('change',()=>{ document.getElementById('scenario-note').textContent='Custom options selected. Review the controls before running.'; }); const output=document.getElementById('output'); +const runStatus=document.getElementById('run-status'); const summary=document.getElementById('summary'); const bindings=document.getElementById('bindings'); const runButton=document.getElementById('run'); @@ -477,17 +479,23 @@ runButton.addEventListener('click',async()=>{ summary.innerHTML=''; bindings.innerHTML=''; clearImported(); - output.textContent='Running...'; + // Progress and failures go to the visible status line, which screen + // readers announce; #output under the collapsed details holds raw JSON only. + runStatus.textContent='Running the synthetic stack...'; + output.textContent=''; const query=new URLSearchParams({response:document.getElementById('response').value,fault:document.getElementById('fault').value,prove:document.getElementById('prove').value,domain:document.getElementById('domain').value}); if(document.getElementById('dispute').checked) query.set('dispute','1'); let runBody; try { const response=await fetch('/api/run?'+query,{method:'POST'}); runBody=await response.json(); - } catch(error) { if(token!==latestToken) return; output.textContent='Request failed: '+error.message; runButton.disabled=false; return; } + } catch(error) { if(token!==latestToken) return; runStatus.textContent='Run request failed: '+error.message; runButton.disabled=false; return; } if(token!==latestToken) return; - if(!runBody || typeof runBody.run_id!=='string') { output.textContent='Request failed: '+(runBody?.error ?? 'No run identity returned.'); runButton.disabled=false; return; } + if(!runBody || typeof runBody.run_id!=='string') { runStatus.textContent='Run not started: '+(runBody?.error ?? 'No run identity returned.'); runButton.disabled=false; return; } const runId=runBody.run_id; + const finished=runBody.exit_code!==0 + ? 'Run '+runId+' finished with a stage failure; see the summary.' + : 'Run '+runId+' finished: '+(typeof runBody.report?.flow==='string'?runBody.report.flow:'flow unavailable'); output.textContent=JSON.stringify(runBody.report ?? runBody,null,2); try { summary.innerHTML=summaryModel(runBody.report ?? {}); } catch(error) { summary.innerHTML='

Summary unavailable.

'; } let bundle; @@ -496,7 +504,7 @@ runButton.addEventListener('click',async()=>{ bundle=await bundleResponse.json(); if(bundleResponse.ok===false) throw new Error(bundle?.error ?? 'Bundle request failed.'); validateRunBundle(bundle,runId); - } catch(error) { if(token!==latestToken) return; bindings.textContent='Bundle unavailable: '+error.message; runButton.disabled=false; return; } + } catch(error) { if(token!==latestToken) return; bindings.textContent='Bundle unavailable: '+error.message; runStatus.textContent='Run '+runId+' finished, but its bundle is unavailable.'; runButton.disabled=false; return; } if(token!==latestToken) return; let bindingHtml; try { bindingHtml=await bindingsModel(bundle); } catch(error) { bindingHtml='

Bindings unavailable.

'; } @@ -505,6 +513,7 @@ runButton.addEventListener('click',async()=>{ if(token!==latestToken) return; download.href='/api/bundle/'+encodeURIComponent(runId); download.style.display='inline-block'; + runStatus.textContent=finished; runButton.disabled=false; }); replayButton.addEventListener('click',async()=>{ diff --git a/test/browser/workbench.spec.mjs b/test/browser/workbench.spec.mjs index d324790..4bd3561 100644 --- a/test/browser/workbench.spec.mjs +++ b/test/browser/workbench.spec.mjs @@ -67,6 +67,11 @@ test("refusal shows policy refusal and skips act and prove", async ({ page }) => await expect(page.locator("#summary")).toContainText("act: skipped"); await expect(page.locator("#summary")).toContainText("prove: skipped"); await expect(page.locator("#bindings")).toContainText("action: none"); + // The outcome is announced in the visible status, not only in the + // collapsed raw report. + await expect(page.locator("#run-status")).toBeVisible(); + await expect(page.locator("#run-status")).toContainText("finished"); + await expect(page.locator("#run-status")).toContainText("decide -> stop (policy failed)"); }); test("repeated runs replace the previous summary instead of stacking", async ({ page }) => { diff --git a/test/gui.test.mjs b/test/gui.test.mjs index 46638d2..ece6bc9 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -321,7 +321,7 @@ function pageScript() { function stubDocument() { const elements = {}; - for (const id of ["response", "fault", "dispute", "prove", "run", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list", "domain", "history-search", "history-outcome", "history-count", "inspect-case", "saved-download", "saved-status", "saved-summary", "saved-bindings", "scenario", "apply-scenario", "scenario-note", "restore-settings", "older-history", "history-page-status", "saved-case-id", "lookup-case", "saved-link", "replay-saved", "saved-review-status", "saved-review-result", "saved-artifacts", "saved-report", "comparison-download"]) { + for (const id of ["response", "fault", "dispute", "prove", "run", "run-status", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list", "domain", "history-search", "history-outcome", "history-count", "inspect-case", "saved-download", "saved-status", "saved-summary", "saved-bindings", "scenario", "apply-scenario", "scenario-note", "restore-settings", "older-history", "history-page-status", "saved-case-id", "lookup-case", "saved-link", "replay-saved", "saved-review-status", "saved-review-result", "saved-artifacts", "saved-report", "comparison-download"]) { elements[id] = { value: "pass", checked: false, disabled: false, textContent: "", innerHTML: "", href: null, style: {}, listeners: {}, addEventListener(name, fn) { const prior = this.listeners[name]; this.listeners[name] = prior ? (...args) => { prior(...args); return fn(...args); } : fn; }, removeAttribute(name) { delete this[name]; } }; @@ -373,11 +373,60 @@ test("page script surfaces request failures without stale exports", async () => const fetch = () => Promise.reject(new Error("boom")); const ui = run(document, fetch, globalThis.crypto); await ui.click(); - assert.match(document.elements.output.textContent, /Request failed: boom/); + assert.equal(document.elements["run-status"].textContent, "Run request failed: boom"); + assert.equal(document.elements.output.textContent, ""); assert.equal(document.elements.download.href, undefined); assert.equal(document.elements.run.disabled, false); }); +test("page script announces run progress, refusals, and outcomes in the visible status", async () => { + const script = pageScript(); + const run = new Function("document", "fetch", "crypto", `${script}; return { click: () => document.getElementById('run').listeners.click() };`); + const page = renderPage(); + // The status sits in the run panel, outside the collapsed raw-report details. + assert.match(page, / -

Choose a scenario or configure the options below. Applying a scenario only changes controls.

- - - - - -
-

-Download run bundle -
-
-
Raw current run report
Ready.
-

Replay an imported case

-

Import an exported case to inspect and re-verify it. Verification only: no action runs and no remedy is attempted. The imported case is reported separately from any live run above.

- - -
-
-

Case history and comparison

-

Compare two persisted cases by identity, policy reference, component revisions, outcome, evidence digest, and review result. This view loads summaries only, never raw evidence, and never modifies or deletes a case.

- -

- - -

Load recent cases to search. The bounded history may omit older or unreadable cases.

- - - -Download comparison review - - -Bookmark this local case -Download case review - -
-Download selected saved case -
-
-
-
-`; +`; + +/** + * The page's only script: the embedded helpers, then the controller. Built + * once, with line endings normalized: fn.toString() returns CRLF source on a + * CRLF checkout, while the browser hashes the script after the HTML parser + * has turned CRLF into LF. + */ +export const PAGE_SCRIPT = normalizeNewlines(`\n${PAGE_HELPERS.map((fn) => fn.toString()).join("\n")}\n${PAGE_CONTROLLER}`); + +function cspHash(text) { + return `'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`; +} + +/** + * The page content is static per process, so the policy pins the exact + * script and style by hash. Case data reaches the DOM only through + * escapeHtml, and no other inline script or style can run. + */ +export const PAGE_CSP = `default-src 'self'; script-src ${cspHash(PAGE_SCRIPT)}; style-src ${cspHash(PAGE_STYLE)}; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`; + +export function renderPage() { + return ` + +Agent Action Stack + +

Agent Action Stack

Run the local decide, act, and prove flow using the reviewed component lock.

+

Synthetic local demo only. No real account operations. The testbench response check gates the run; it is not a signed authorization over the rail proposal. Rail receipt verification and MandateBound recording remain separate authorities. Source truth is unknown; legal effect is not determined.

+
+

Choose a scenario or configure the options below. Applying a scenario only changes controls.

+ + + + + +
+

+Download run bundle
+
+
+
Raw current run report
Ready.
+

Replay an imported case

+

Import an exported case to inspect and re-verify it. Verification only: no action runs and no remedy is attempted. The imported case is reported separately from any live run above.

+ + +
+
+

Case history and comparison

+

Compare two persisted cases by identity, policy reference, component revisions, outcome, evidence digest, and review result. This view loads summaries only, never raw evidence, and never modifies or deletes a case.

+ +

+ + +

Load recent cases to search. The bounded history may omit older or unreadable cases.

+ + + +Download comparison review + + +Bookmark this local case +Download case review + +
+Download selected saved case +
+
+
+
+
`; } function savedPathId(pathname, prefix) { @@ -676,7 +702,7 @@ export function createGuiServer({ if (request.method === "GET" && url.pathname === "/") { response.writeHead(200, { "cache-control": "no-store", - "content-security-policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + "content-security-policy": PAGE_CSP, "content-type": "text/html; charset=utf-8", "x-content-type-options": "nosniff", "x-frame-options": "DENY", @@ -933,12 +959,18 @@ async function main() { if (page.status !== 200 || !/text\/html/.test(page.headers["content-type"] ?? "")) { throw new Error(`GUI page request failed: ${page.status}`); } - if (!page.headers["content-security-policy"]) throw new Error("GUI page is missing its content security policy."); + const policy = page.headers["content-security-policy"]; + if (policy !== PAGE_CSP || policy.includes("unsafe-inline")) { + throw new Error("GUI page does not send the hash-pinned content security policy."); + } if (!page.body.includes("`) || !page.body.includes(``)) { + throw new Error("GUI page script or style differs from the hashed content."); + } for (const fn of PAGE_HELPERS) { - if (!page.body.includes(fn.toString())) throw new Error(`GUI page is missing embedded helper ${fn.name}.`); + if (!page.body.includes(normalizeNewlines(fn.toString()))) throw new Error(`GUI page is missing embedded helper ${fn.name}.`); } } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); diff --git a/test/browser/workbench.spec.mjs b/test/browser/workbench.spec.mjs index 4bd3561..492dde0 100644 --- a/test/browser/workbench.spec.mjs +++ b/test/browser/workbench.spec.mjs @@ -60,6 +60,28 @@ test("run, inspect, export, import, and replay through the real UI", async ({ pa await expect(page.locator("#import-result")).toContainText("no action execution or remediation runs"); }); +test("the page script and stylesheet run under the hash-pinned policy", async ({ page }) => { + const violations = []; + page.on("console", (message) => { + if (/Content Security Policy/i.test(message.text())) violations.push(message.text()); + }); + await page.addInitScript(() => { + document.addEventListener("securitypolicyviolation", (event) => { + window.__cspViolations = [...(window.__cspViolations ?? []), event.violatedDirective]; + }); + }); + const response = await page.goto("/"); + expect(response.headers()["content-security-policy"]).not.toContain("unsafe-inline"); + // The stylesheet applied (body background #f7f9fc) and the script ran + // (applying a scenario rewrites the note). + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(247, 249, 252)"); + await page.selectOption("#scenario", "refusal"); + await page.click("#apply-scenario"); + await expect(page.locator("#scenario-note")).toContainText("Expected: policy refusal"); + expect(await page.evaluate(() => window.__cspViolations ?? [])).toEqual([]); + expect(violations).toEqual([]); +}); + test("refusal shows policy refusal and skips act and prove", async ({ page }) => { await page.goto("/"); await runStack(page, { response: "fail" }); diff --git a/test/gui.test.mjs b/test/gui.test.mjs index 3e0f079..af4f0bc 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -776,6 +776,37 @@ test("malformed percent-encoding in a saved-case path is a client error", async } }); +test("GUI content security policy pins the page script and style by hash", async () => { + const page = renderPage(); + const scripts = [...page.matchAll(/