From 71ff16608a419220380d706eb762ba297eba773b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 13:31:33 +0200 Subject: [PATCH 01/10] Define Brunch architecture-check ownership Co-authored-by: Cursor --- libs/@hashintel/brunch-agent/MISSION.md | 203 +++++++++--------------- 1 file changed, 79 insertions(+), 124 deletions(-) diff --git a/libs/@hashintel/brunch-agent/MISSION.md b/libs/@hashintel/brunch-agent/MISSION.md index 571858e5b9b..1a14ea653e5 100644 --- a/libs/@hashintel/brunch-agent/MISSION.md +++ b/libs/@hashintel/brunch-agent/MISSION.md @@ -1,18 +1,10 @@ -# Brunch remote browser-origin policy +# Brunch architecture-check ownership ## Status -**Live as of 2026-09-08** for -[SRE-1042](https://linear.app/hash/issue/SRE-1042/configure-petrinauts-deployment-variables-for-the-brunch-agent-chat) -on `t/sre-1042-allow-wildcard-origins-for-brunch-previews`, cut from `main` after -[FE-1626](https://github.com/hashintel/hash/pull/9583) established the exact-origin allow-list for -`/agents/*`. This file is the branch's sole execution authority. - -Exact origins alone do not fit the deployment: every Petrinaut preview has its own -`https://petrinaut-git-.stage.hash.ai` origin, so the allow-list additionally accepts a -wildcard for exactly one leading host label. CORS governs whether a conforming browser exposes a cross-origin response -to client code; it does not authenticate or restrict non-browser callers, authorize a -conversation, or make public exposure safe by itself. +**Live as of 2026-09-09** for +[SRE-1010](https://linear.app/hash/issue/SRE-1010/move-brunch-architecture-checks-out-of-core-unit-tests) +on `ln/sre-1010-move-brunch-checks`. This file is the branch's sole execution authority. ## Supplemental FE-1580 settlement follow-up @@ -45,144 +37,107 @@ Voice contract without changing the CORS authority in this file. ## Imperative -Let a deployed Petrinaut website use the Brunch `/agents/*` Flue routes from an explicitly trusted -browser origin while causing browsers to withhold cross-origin access from unlisted origins. Do -this now because the deployed website and Brunch service are separate origins and -[SRE-1042](https://linear.app/hash/issue/SRE-1042/configure-petrinauts-deployment-variables-for-the-brunch-agent-chat) -cannot point the browser at the deployed Brunch route until preflight and response headers work. +Make Brunch architecture checks run from a workspace that is downstream of every workspace they +govern, so ordinary core and app unit-test tasks own only their local contracts. Do this now +because the SRE-1007 hotfix modeled sibling packages and the app as inputs to core, creating a +reverse `libs -> apps` edge and requiring downstream trees in a core prune. ## Throughline ```text -Petrinaut browser at one configured exact origin -→ OPTIONS /agents// with requested method and headers -→ route-scoped Hono CORS middleware before ownership middleware -→ 204 preflight carrying the matching origin, GET/POST/OPTIONS, and Flue request headers -→ browser FlueClient GET/POST with x-brunch-principal + x-brunch-conversation -→ existing agentOwnershipGuard and createAgentRouter -→ response exposes the Flue/Durable Streams headers the browser SDK reads +change in apps/brunch-agent or a Brunch package +→ Turbo follows the declared workspace dependency into @tests/brunch-agent-architecture +→ its dedicated unit-test task scans the governed manifests and authored source +→ a package-family boundary violation fails in the architecture workspace + +change in apps/brunch-agent +→ @apps/brunch-agent#test:unit +→ app-local agent-module and emitted-artifact checks + +change in Brunch core +→ @hashintel/brunch-agent#test:unit +→ core-local tests, without app or sibling-package inputs ``` -`BRUNCH_CORS_ALLOWED_ORIGINS` is read once at startup as a comma-separated list of HTTP(S) -origins, each either exact or with a wildcard as the whole leading host label in front of a domain -with at least two labels (`https://*.stage.hash.ai`). A wildcard matches exactly one label, like a -wildcard TLS certificate. Parsing trims whitespace, normalizes an optional trailing slash through -`URL.origin`, and deduplicates values. Credentials, non-root paths, queries, fragments, wildcards in -any other position, opaque origins, and non-HTTP(S) schemes are startup configuration errors. Missing or blank configuration means an -empty allowlist: same-origin and non-browser callers continue through the existing route, but -browser code at another origin receives no CORS grant. See the -[Brunch application README](../../../apps/brunch-agent/README.md#production-container) for -operator configuration details. - -The middleware applies only to `/agents/*` and runs before `agentOwnershipGuard`, so a valid -preflight does not need conversation headers. It permits `GET`, `POST`, and `OPTIONS`; permits -`Content-Type`, `x-brunch-principal`, and `x-brunch-conversation`; does not permit credentials; and -uses a 600-second preflight cache. It exposes the non-safelisted response headers read by the -installed Flue 2.0.3 and Durable Streams 0.2.6 clients: - -- `flue-error-ref` -- `Stream-Next-Offset` -- `Stream-Cursor` -- `Stream-Up-To-Date` -- `Stream-Closed` -- `stream-sse-data-encoding` - -Hono's maintained CORS middleware owns header emission, `Vary` handling, and the `OPTIONS` response. -Non-browser callers can still send requests and receive ordinary HTTP responses because CORS is -enforced by browsers, not by the service as caller authentication. A response to an unlisted -browser origin carries no `Access-Control-Allow-Origin`, so the browser withholds that response -from client code. +The architecture workspace declares every governed Brunch workspace as a development dependency. +That graph direction is the selection and prune mechanism; do not recreate it with cross-workspace +Turbo input globs or prune exceptions. ## Proof -This mission establishes the application-side CORS contract required by the deployed browser -transport. It does **not** establish authentication, authorization, rate limiting, infrastructure -configuration, a deployed endpoint, or end-to-end remote verification. - -1. **Configuration is exact and fail-closed.** Missing and blank configuration produce no allowed - origins; whitespace, trailing slashes, duplicates, and multiple exact origins normalize - deterministically; malformed or broader-than-origin entries fail with the offending variable - named. Oracle: focused unit cases in `apps/brunch-agent/test/cors.test.ts`. -2. **Allowed browser traffic receives the complete grant.** An allowed origin receives its exact - value on an `/agents/*` response. Its preflight receives 204 before ownership, the three allowed - methods, the three allowed request headers, the six exposed response headers, no credentials - grant, and the required `Vary` values. Oracle: in-process Hono requests in - `apps/brunch-agent/test/cors.test.ts`. -3. **Rejected origins receive no grant.** An unlisted origin's preflight and ordinary response omit - `Access-Control-Allow-Origin`; an allowed origin does not make another origin pass. Oracle: - focused negative cases in `apps/brunch-agent/test/cors.test.ts`. -4. **The policy cannot widen unrelated routes.** `/health`, `/`, and `/assets/*` carry no Brunch - CORS grant. Existing ownership checks still return 401/403 for actual agent requests with - missing or mismatched identity. Oracle: CORS route-scope tests plus the existing - `apps/brunch-agent/test/agent-ownership.test.ts`. -5. **The shipped artifact and operator contract agree.** Brunch's README documents the variable, - exact-origin configuration, empty-list behavior, and the fact that CORS governs browser access - rather than authenticating or restricting non-browser callers. Oracle: - `yarn workspace @apps/brunch-agent test:unit`, - `yarn workspace @apps/brunch-agent lint:tsc`, - `yarn workspace @apps/brunch-agent lint:eslint`, and - `yarn workspace @apps/brunch-agent build`. +This mission establishes task ownership, affected-task selection, and prune closure for the +existing Brunch architecture checks. It does **not** redesign the architecture rules, broaden +their policy, or replace Yarn and package-local lint enforcement. + +1. **App checks are app-local.** The agent directive, pinned identity, app-entrypoint, and emitted + artifact checks pass from `@apps/brunch-agent`, and its architecture helper reads no sibling + package. Oracle: `yarn workspace @apps/brunch-agent test:unit`. +2. **Family checks have one downstream owner.** Package naming, dependency direction, schema, + import, runtime, export-lane, and hermetic-test inventory checks pass from + `@tests/brunch-agent-architecture`. Oracle: + `yarn workspace @tests/brunch-agent-architecture test:unit`. +3. **Relevant changes select the family check.** A Turbo affected-task query against representative + app and package changes selects `@tests/brunch-agent-architecture#test:unit`; generated and + dependency directories do not become authored inputs. Oracle: focused Turbo query/dry-run + fixtures recorded in the implementation verification. +4. **A core prune owns no downstream Brunch tree.** The core task has no app or sibling-package + input glob, and requesting only core no longer adds the app or plugins through prune + exceptions. Oracle: `.github/actions/prune-repository/prune_test.py` plus inspection of + `turbo run test:unit --filter @hashintel/brunch-agent --dry=json`. +5. **The new workspace is independently healthy.** Its tests, typecheck, lint, and relevant + package graph checks pass. Oracle: focused workspace test/lint/typecheck commands and + `yarn constraints`. ## Constraints -- Use Hono's built-in CORS middleware; do not create a parallel HTTP server or hand-maintain generic - CORS response logic. -- Keep one Flue product route and the existing ownership guard. CORS must not add, proxy, rename, or - reinterpret an agent route. -- The origin list is explicit: exact origins or one-label wildcards, matched by scheme, host and - port. Do not hard-code Petrinaut domains, reflect arbitrary `Origin` values, or silently skip - malformed entries. -- Keep credentials disabled. The current browser client uses explicit ownership headers, not - cookies, and those headers are not authentication. -- Answer preflight before ownership while preserving ownership enforcement on every non-preflight - agent request. -- Read configuration once at startup. Dynamic policy storage or hot reload is not earned by this - deployment. -- Preserve local same-origin proxying when the variable is unset. +- Preserve the behavior and failure coverage of every existing architecture assertion while + splitting app-only checks from family checks. +- Keep Yarn constraints and package-local Oxlint rules with their current owning packages. +- Model governed workspaces as dependencies of the architecture-test workspace; never make a + governed library depend on the checker. +- Keep core's context-root script coverage only as narrowly hashed inputs that it genuinely reads; + do not use the Brunch-family glob. +- Use the existing unit-test matrix and Turbo dependency semantics for CI selection. Add a bespoke + workflow only if evidence shows the dedicated workspace cannot be selected correctly. - No implementation begins until this authority cut is committed separately. Material changes to this contract require owner review and another focused authority commit. ### Expected touched paths ```text -~ libs/@hashintel/brunch-agent/MISSION.md branch authority -~ apps/brunch-agent/src/http/cors.ts exact and one-label wildcard origins, Hono middleware -~ apps/brunch-agent/src/app.ts mount CORS before ownership on /agents/* -+ apps/brunch-agent/test/cors.test.ts parser, allowed, rejected, preflight, route-scope tests -~ apps/brunch-agent/README.md deployment variable and security boundary -~ apps/brunch-agent/turbo.json pass the variable into the local dev task +~ libs/@hashintel/brunch-agent/MISSION.md +~ apps/brunch-agent/test/architecture/* ++ tests/brunch-agent-architecture/* +~ libs/@hashintel/brunch-agent/packages/core/turbo.json +~ apps/brunch-agent/turbo.json +~ .github/actions/prune-repository/prune.py +~ .github/actions/prune-repository/prune_test.py +~ yarn.lock ``` ## Fog-line -- Infrastructure repository access is unavailable in this worktree, so this branch can prove only - the application contract. Runtime deployment configuration must supply the chosen origins before - remote verification. -- A one-label wildcard admits every host directly under the configured domain, not only Petrinaut - previews. Narrow the deployed pattern or return to exact origins if that breadth becomes a - problem in practice. -- The allowed and exposed headers are pinned to the installed Flue and Durable Streams clients. - Re-evaluate them from client source when either dependency changes. +- Turbo's `^manifest` chain is expected to select the architecture workspace for changes in each + declared dependency. If an affected-task probe disproves that, stop and choose the smallest + explicit dedicated task input at the checker, never at a governed package. +- Some app unit tests independently read non-workspace Brunch evaluation and documentation assets. + This mission removes family architecture ownership from the app but does not remove inputs those + product tests still genuinely consume. +- Core's Linear graph test still exercises a context-root script. Narrow hashing is in scope; + relocating that unrelated utility is not. ## Stop or reorient -Stop if the real browser client emits a request method or non-safelisted request header outside the -pinned contract, reads another non-safelisted response header, or needs cookie credentials. Bring -that evidence back to the contract before broadening the grant. - -Stop if middleware ordering bypasses ownership for a non-`OPTIONS` request, if an invalid -configuration widens access or is ignored, if an unlisted origin receives -`Access-Control-Allow-Origin`, or if `/health`, `/`, or `/assets/*` inherit the policy. +Stop if the dedicated workspace cannot be selected from both app and package changes through the +declared dependency graph, if its prune omits a governed workspace, or if moving an assertion +changes what it permits or rejects. -Do not represent a green CORS test as permission for unauthenticated public exposure. Authentication, -per-conversation authorization, rate/spend controls, and the infrastructure ingress boundary remain -separate release gates. +Stop if removing a prune exception makes a non-architecture core or app test lose a genuine +fixture. Preserve that proven dependency narrowly and report it instead of deleting it to satisfy +the desired graph shape. ## Deferred -- SRE-1013 owns injection of the allowlist into the Brunch runtime deployment. SRE-1042 owns - `VITE_BRUNCH_CHAT_ENDPOINT`, Voice deployment variables, and the deployed browser verification - after this application contract lands. -- FE-1615 and FE-1616 retain authentication and rate-limit work. CORS does not discharge either. -- A same-origin Petrinaut proxy stays deferred; the one-label wildcard covers the preview - deployments the exact list could not. +The canonical future planning record remains +[`MISSION.next.md`](MISSION.next.md). SRE-1008 owns repository-wide detection of undeclared Turbo +task inputs; this mission supplies a declared Brunch task layout for it to inspect. From 88330d47696879770f67845ea0d79971ef5f9e0d Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 13:37:43 +0200 Subject: [PATCH 02/10] Move Brunch architecture checks to a dedicated workspace Co-authored-by: Cursor --- .github/actions/prune-repository/prune.py | 25 +--- .../actions/prune-repository/prune_test.py | 55 ++++---- apps/brunch-agent/docs/task-dependencies.json | 2 - .../test/architecture/app-boundaries.test.ts | 80 +++++++++++ .../test/architecture/app-workspace.test.ts | 44 ++++++ .../test/architecture/app-workspace.ts | 63 +++++++++ .../test/architecture/boundaries.test.ts | 5 - .../test/architecture/workspace.test.ts | 127 ----------------- apps/brunch-agent/turbo.json | 7 +- .../brunch-agent/packages/core/turbo.json | 3 +- .../brunch-agent-architecture/.oxlintrc.json | 18 +++ .../docs/task-dependencies.json | 18 +++ tests/brunch-agent-architecture/package.json | 28 ++++ .../test/architecture-workspace.test.ts | 50 +++++++ .../test/architecture-workspace.ts | 52 +------ .../test/architecture.test.ts | 129 ++++-------------- tests/brunch-agent-architecture/tsconfig.json | 20 +++ tests/brunch-agent-architecture/turbo.json | 14 ++ yarn.lock | 25 +++- 19 files changed, 416 insertions(+), 349 deletions(-) create mode 100644 apps/brunch-agent/test/architecture/app-boundaries.test.ts create mode 100644 apps/brunch-agent/test/architecture/app-workspace.test.ts create mode 100644 apps/brunch-agent/test/architecture/app-workspace.ts delete mode 100644 apps/brunch-agent/test/architecture/boundaries.test.ts delete mode 100644 apps/brunch-agent/test/architecture/workspace.test.ts create mode 100644 tests/brunch-agent-architecture/.oxlintrc.json create mode 100644 tests/brunch-agent-architecture/docs/task-dependencies.json create mode 100644 tests/brunch-agent-architecture/package.json create mode 100644 tests/brunch-agent-architecture/test/architecture-workspace.test.ts rename apps/brunch-agent/test/architecture/workspace.ts => tests/brunch-agent-architecture/test/architecture-workspace.ts (78%) rename apps/brunch-agent/test/architecture/boundaries.integration.ts => tests/brunch-agent-architecture/test/architecture.test.ts (77%) create mode 100644 tests/brunch-agent-architecture/tsconfig.json create mode 100644 tests/brunch-agent-architecture/turbo.json diff --git a/.github/actions/prune-repository/prune.py b/.github/actions/prune-repository/prune.py index fcb64d187cd..d201a35e82c 100644 --- a/.github/actions/prune-repository/prune.py +++ b/.github/actions/prune-repository/prune.py @@ -44,39 +44,22 @@ REQUESTED_DEPENDENCIES: dict[str, list[str]] = { # The frontend's wire-conformance suite decodes the atlas crate's checked-in fixtures "@apps/hash-frontend": ["@rust/hash-graph-atlas"], - # Brunch core's architecture and contract tests inspect the app and shipped - # plugins, but a job whose requested scope is only a sibling or a consumer - # of core must not pull those fixtures. - "@hashintel/brunch-agent": [ - "@apps/brunch-agent", - "@hashintel/brunch-agent-plugin-gherkin", - "@hashintel/brunch-agent-plugin-sdcpn", - ], } # Non-workspace paths required by packages in the *requested* scope. # `turbo prune` copies workspace directories and root manifests only. REQUESTED_PATHS: dict[str, list[str]] = { - # Core's shipped-definition and baseline tests read the non-workspace - # context root alongside their plugin task dependencies. + # Core's Linear graph tests exercise the context-root script. "@hashintel/brunch-agent": [ ".config/oxlint/brunch", - "libs/@hashintel/brunch-agent/AGENTS.md", - "libs/@hashintel/brunch-agent/CONTEXT.md", - "libs/@hashintel/brunch-agent/docs", - "libs/@hashintel/brunch-agent/evaluations", - "libs/@hashintel/brunch-agent/scripts", + "libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts", ], - # The app's tests execute evaluation runners and govern the complete Brunch - # composition. Its context root is not a workspace, so copy the docs, - # scripts, and agent contract files explicitly. + # The app's product tests execute evaluation runners and inspect committed + # evidence. Those non-workspace inputs are copied explicitly. "@apps/brunch-agent": [ ".config/oxlint/brunch", - "libs/@hashintel/brunch-agent/AGENTS.md", - "libs/@hashintel/brunch-agent/CONTEXT.md", "libs/@hashintel/brunch-agent/docs", "libs/@hashintel/brunch-agent/evaluations", - "libs/@hashintel/brunch-agent/scripts", "libs/@hashintel/petrinaut/docs", ], } diff --git a/.github/actions/prune-repository/prune_test.py b/.github/actions/prune-repository/prune_test.py index 1e895793f25..c58730ee926 100644 --- a/.github/actions/prune-repository/prune_test.py +++ b/.github/actions/prune-repository/prune_test.py @@ -15,6 +15,9 @@ CORE = "@hashintel/brunch-agent" TRANSPORT = "@hashintel/brunch-agent-transport-aisdk" APP = "@apps/brunch-agent" +ARCHITECTURE = "@tests/brunch-agent-architecture" +BINDING_FLUE = "@hashintel/brunch-agent-binding-flue" +PLUGIN_DAFNY = "@hashintel/brunch-agent-plugin-dafny" PLUGIN_GHERKIN = "@hashintel/brunch-agent-plugin-gherkin" PLUGIN_SDCPN = "@hashintel/brunch-agent-plugin-sdcpn" WEBSITE = "@apps/petrinaut-website" @@ -45,57 +48,51 @@ def test_frontend_transitive(self) -> None: class BrunchRequestedExtras(unittest.TestCase): - def test_core_adds_the_app_and_plugins(self) -> None: - expected_workspaces = frozenset({APP, PLUGIN_GHERKIN, PLUGIN_SDCPN}) - self.assertEqual(extras_for_requested({CORE}), expected_workspaces) - self.assertTrue( - expected_workspaces.issubset( - fixpoint_expand( - {CORE} | extras_for_requested({CORE}), - { - CORE: frozenset(), - APP: frozenset({CORE}), - PLUGIN_GHERKIN: frozenset({CORE}), - PLUGIN_SDCPN: frozenset({CORE}), - }, - ) - ) + def test_architecture_workspace_owns_the_complete_brunch_family(self) -> None: + expected_workspaces = frozenset( + { + APP, + CORE, + BINDING_FLUE, + PLUGIN_DAFNY, + PLUGIN_GHERKIN, + PLUGIN_SDCPN, + TRANSPORT, + } ) - - def test_app_task_adds_the_core_plugins_and_context_paths(self) -> None: - expected_workspaces = frozenset({CORE, PLUGIN_GHERKIN, PLUGIN_SDCPN}) expanded = fixpoint_expand( - {APP}, + {ARCHITECTURE}, { - APP: expected_workspaces, + ARCHITECTURE: expected_workspaces, + APP: frozenset({CORE, PLUGIN_SDCPN, TRANSPORT, BINDING_FLUE}), + BINDING_FLUE: frozenset({CORE}), + PLUGIN_DAFNY: frozenset({CORE}), PLUGIN_GHERKIN: frozenset({CORE}), PLUGIN_SDCPN: frozenset({CORE}), + TRANSPORT: frozenset(), }, ) self.assertTrue(expected_workspaces.issubset(expanded)) + self.assertEqual(extras_for_requested({ARCHITECTURE}), frozenset()) + + def test_app_keeps_only_its_non_workspace_product_inputs(self) -> None: self.assertEqual( extra_paths_for_requested({APP}), [ ".config/oxlint/brunch", - "libs/@hashintel/brunch-agent/AGENTS.md", - "libs/@hashintel/brunch-agent/CONTEXT.md", "libs/@hashintel/brunch-agent/docs", "libs/@hashintel/brunch-agent/evaluations", - "libs/@hashintel/brunch-agent/scripts", "libs/@hashintel/petrinaut/docs", ], ) - def test_core_adds_its_non_workspace_test_fixtures(self) -> None: + def test_core_adds_only_its_context_root_script(self) -> None: + self.assertEqual(extras_for_requested({CORE}), frozenset()) self.assertEqual( extra_paths_for_requested({CORE}), [ ".config/oxlint/brunch", - "libs/@hashintel/brunch-agent/AGENTS.md", - "libs/@hashintel/brunch-agent/CONTEXT.md", - "libs/@hashintel/brunch-agent/docs", - "libs/@hashintel/brunch-agent/evaluations", - "libs/@hashintel/brunch-agent/scripts", + "libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts", ], ) diff --git a/apps/brunch-agent/docs/task-dependencies.json b/apps/brunch-agent/docs/task-dependencies.json index 1f3bc60e5fa..f85e288f2a2 100644 --- a/apps/brunch-agent/docs/task-dependencies.json +++ b/apps/brunch-agent/docs/task-dependencies.json @@ -75,8 +75,6 @@ "test:unit": [ "@hashintel/brunch-agent#build", "@hashintel/brunch-agent-binding-flue#build", - "@hashintel/brunch-agent-plugin-dafny#build", - "@hashintel/brunch-agent-plugin-gherkin#build", "@hashintel/brunch-agent-plugin-sdcpn#build", "@hashintel/brunch-agent-transport-aisdk#build", "@hashintel/petrinaut-core#build", diff --git a/apps/brunch-agent/test/architecture/app-boundaries.test.ts b/apps/brunch-agent/test/architecture/app-boundaries.test.ts new file mode 100644 index 00000000000..56fcbde0ca0 --- /dev/null +++ b/apps/brunch-agent/test/architecture/app-boundaries.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + AGENT_DIRECTIVE_STATEMENT, + APP_ROOT, + isAgentModule, + pinnedIdentities, + sourceFiles, +} from "./app-workspace"; + +const appSourceFiles = sourceFiles(); +const agentModules = appSourceFiles.filter(isAgentModule); + +describe("the Brunch application owns its agent registration", () => { + test("has at least one agent module", () => { + expect(agentModules.length).toBeGreaterThan(0); + }); + + test("'use agent' is each agent module's first statement", () => { + for (const file of agentModules) { + const withoutLeadingComments = file.text + .replace(/^/u, "") + .replace(/^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/))*\s*/u, ""); + const firstStatement = withoutLeadingComments.split("\n")[0] ?? ""; + expect({ + file: file.relPath, + firstStatementIsDirective: + AGENT_DIRECTIVE_STATEMENT.test(firstStatement), + }).toEqual({ file: file.relPath, firstStatementIsDirective: true }); + } + }); + + test("agentName is a pinned string literal", () => { + for (const file of agentModules) { + const identities = pinnedIdentities(file); + expect({ file: file.relPath, pinned: identities.length > 0 }).toEqual({ + file: file.relPath, + pinned: true, + }); + for (const identity of identities) { + expect(identity).toMatch(/^[a-z][a-z0-9-]*$/u); + } + } + }); + + test("a pinned identity appears only in its own agent module", () => { + const identities = agentModules.flatMap((file) => + pinnedIdentities(file).map((identity) => ({ + identity, + pinnedIn: file.relPath, + })), + ); + expect(identities.length).toBeGreaterThan(0); + + const stringLiteral = /(['"`])(?:\\.|(?!\1)[^\\\n])*\1/gu; + for (const { identity, pinnedIn } of identities) { + const duplicatedIn = appSourceFiles + .filter((file) => file.relPath !== pinnedIn) + .filter((file) => + (file.text.match(stringLiteral) ?? []).some((literal) => + literal.includes(identity), + ), + ) + .map((file) => file.relPath); + expect({ identity, duplicatedIn }).toEqual({ + identity, + duplicatedIn: [], + }); + } + }); + + test("owns the mount and conversation store", () => { + for (const file of ["src/app.ts", "src/db.ts"]) { + expect(() => readFileSync(join(APP_ROOT, file), "utf8")).not.toThrow(); + } + }); +}); diff --git a/apps/brunch-agent/test/architecture/app-workspace.test.ts b/apps/brunch-agent/test/architecture/app-workspace.test.ts new file mode 100644 index 00000000000..5d04f9f2956 --- /dev/null +++ b/apps/brunch-agent/test/architecture/app-workspace.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { isAgentModule, type SourceFile } from "./app-workspace"; + +const file = (text: string): SourceFile => ({ + path: "/fake/module.ts", + relPath: "fake/module.ts", + text, +}); + +describe("isAgentModule", () => { + test("recognizes either quote style", () => { + expect(isAgentModule(file("'use agent';\nexport {};\n"))).toBe(true); + expect(isAgentModule(file('"use agent";\nexport {};\n'))).toBe(true); + }); + + test("allows leading comments and trailing directive comments", () => { + expect( + isAgentModule( + file( + "/** The agent. */\n'use agent'; // registers the agent\nexport {};\n", + ), + ), + ).toBe(true); + }); + + test("does not confuse a comment for a directive", () => { + expect( + isAgentModule( + file("// the 'use agent' directive must come first\nexport {};\n"), + ), + ).toBe(false); + }); + + test("detects a misplaced directive for the placement check", () => { + expect(isAgentModule(file("import 'x';\n'use agent';\nexport {};\n"))).toBe( + true, + ); + }); + + test("requires matching quotes", () => { + expect(isAgentModule(file("'use agent\";\nexport {};\n"))).toBe(false); + }); +}); diff --git a/apps/brunch-agent/test/architecture/app-workspace.ts b/apps/brunch-agent/test/architecture/app-workspace.ts new file mode 100644 index 00000000000..a8ef20b9feb --- /dev/null +++ b/apps/brunch-agent/test/architecture/app-workspace.ts @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const APP_ROOT = fileURLToPath( + new URL("../..", import.meta.url), +).replace(/[/\\]$/u, ""); + +export interface SourceFile { + readonly path: string; + readonly relPath: string; + readonly text: string; +} + +const SOURCE_EXTENSIONS = /\.(ts|tsx|mts|mjs|js|jsx)$/u; +const SKIP_DIRECTORIES = new Set([ + "node_modules", + "dist", + ".flue", + ".git", + ".turbo", + "test", + "tests", + "__tests__", +]); + +export const sourceFiles = (): SourceFile[] => { + const found: SourceFile[] = []; + const walk = (current: string): void => { + for (const entry of readdirSync(current)) { + if (SKIP_DIRECTORIES.has(entry)) { + continue; + } + const path = join(current, entry); + if (statSync(path).isDirectory()) { + walk(path); + } else if (SOURCE_EXTENSIONS.test(entry)) { + found.push({ + path, + relPath: relative(APP_ROOT, path).replaceAll("\\", "/"), + text: readFileSync(path, "utf8"), + }); + } + } + }; + walk(APP_ROOT); + return found; +}; + +/** + * Deliberately not anchored to the first statement: a misplaced directive + * must still be detected so the placement check can fail it. + */ +export const AGENT_DIRECTIVE_STATEMENT = + /^\s*(["'])use agent\1;?\s*(?:$|\/\/|\/\*)/mu; + +export const isAgentModule = (file: SourceFile): boolean => + AGENT_DIRECTIVE_STATEMENT.test(file.text); + +export const pinnedIdentities = (file: SourceFile): string[] => + [...file.text.matchAll(/\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu)].map( + (match) => match[2]!, + ); diff --git a/apps/brunch-agent/test/architecture/boundaries.test.ts b/apps/brunch-agent/test/architecture/boundaries.test.ts deleted file mode 100644 index 712624bb2b6..00000000000 --- a/apps/brunch-agent/test/architecture/boundaries.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Keep the filesystem-wide architecture suite runnable by Vitest while its - * implementation remains a non-test entry point for boundary self-inspection. - */ -import "./boundaries.integration.ts"; diff --git a/apps/brunch-agent/test/architecture/workspace.test.ts b/apps/brunch-agent/test/architecture/workspace.test.ts deleted file mode 100644 index ffc9df70dac..00000000000 --- a/apps/brunch-agent/test/architecture/workspace.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * The introspection helpers themselves, at the seams where a wrong answer - * makes some other invariant pass vacuously or fail spuriously. - * - * Agent-module detection is the load-bearing one: the FE-1361 review found the - * old bare substring match turned any file whose *comments* mention the - * `'use agent'` directive into an "agent module", failing two assertions on a - * comment-only change. Detection is now anchored to a directive *statement* — - * and deliberately not to the first statement only, so a misplaced directive - * is still detected and then failed by the first-statement invariant instead - * of escaping it. - */ - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, join, relative } from "node:path"; - -import { afterAll, describe, expect, test } from "vitest"; - -import { - isAgentModule, - sourceFiles, - testFiles, - type SourceFile, - type WorkspacePackage, -} from "./workspace"; - -const file = (text: string): SourceFile => ({ - path: "/fake/module.ts", - relPath: "fake/module.ts", - text, -}); - -describe("isAgentModule", () => { - test("a module whose first statement is the directive is one", () => { - expect(isAgentModule(file("'use agent';\nexport function A() {}\n"))).toBe( - true, - ); - expect(isAgentModule(file('"use agent";\nexport function A() {}\n'))).toBe( - true, - ); - }); - - test("a leading doc block before the directive still counts", () => { - expect( - isAgentModule(file("/** The agent. */\n'use agent';\nexport {};\n")), - ).toBe(true); - }); - - test("a comment mentioning the directive is not a directive", () => { - // The regression the FE-1361 review verified: CI went red on a - // comment-only change because detection matched raw file text. - expect( - isAgentModule( - file("// the 'use agent' directive must come first\nexport {};\n"), - ), - ).toBe(false); - expect( - isAgentModule( - file( - '/**\n * A file about the "use agent" directive.\n */\nexport {};\n', - ), - ), - ).toBe(false); - }); - - test("a trailing comment on the directive line does not hide the module", () => { - // `'use agent'; // registers X` is still a directive at runtime — comments - // are not statements — so it must still be detected, or every downstream - // invariant passes vacuously for that module. - expect( - isAgentModule( - file("'use agent'; // registers the elicitor\nexport {};\n"), - ), - ).toBe(true); - expect( - isAgentModule(file("'use agent'; /* registered */\nexport {};\n")), - ).toBe(true); - }); - - test("a misplaced directive is still detected, so the first-statement invariant can fail it", () => { - // If detection required the directive to be first, a module that misplaced - // it would silently stop being checked at all — the exact silent pass the - // first-statement test exists to prevent. - expect(isAgentModule(file("import 'x';\n'use agent';\nexport {};\n"))).toBe( - true, - ); - }); - - test("quotes must match", () => { - expect(isAgentModule(file("'use agent\";\nexport {};\n"))).toBe(false); - }); -}); - -describe("the source/test partition is total", () => { - // A real directory rather than fakes, because the property under test is the - // walk itself: the FE-1400 review's verified escape was a file in a *nested* - // test directory (`src/test/`), pruned by the source walk but never collected - // by the old top-level-only test walk — governed by nothing. - const dir = mkdtempSync(join(tmpdir(), "brunch-partition-")); - mkdirSync(join(dir, "src/test"), { recursive: true }); - mkdirSync(join(dir, "__tests__")); - writeFileSync(join(dir, "src/index.ts"), "export {};\n"); - writeFileSync(join(dir, "src/test/nested.ts"), "export {};\n"); - writeFileSync(join(dir, "__tests__/top.test.ts"), "export {};\n"); - const pkg: WorkspacePackage = { - name: "@hashintel/brunch-agent-fixture", - dir: basename(dir), - path: dir, - relPath: `packages/${basename(dir)}`, - kind: "package", - manifest: { name: "@hashintel/brunch-agent-fixture" }, - }; - - afterAll(() => rmSync(dir, { recursive: true, force: true })); - - test("every file is exactly one of source or test — nested test dirs included", () => { - const source = sourceFiles(pkg) - .map((f) => relative(dir, f.path)) - .sort(); - const test = testFiles(pkg) - .map((f) => relative(dir, f.path)) - .sort(); - expect(source).toEqual(["src/index.ts"]); - expect(test).toEqual(["__tests__/top.test.ts", "src/test/nested.ts"]); - }); -}); diff --git a/apps/brunch-agent/turbo.json b/apps/brunch-agent/turbo.json index d560b99652d..0912a4e7f13 100644 --- a/apps/brunch-agent/turbo.json +++ b/apps/brunch-agent/turbo.json @@ -38,15 +38,14 @@ "codegen", "^build", "@hashintel/brunch-agent#build", - "@hashintel/brunch-agent-plugin-dafny#build", - "@hashintel/brunch-agent-plugin-gherkin#build", "@hashintel/brunch-agent-plugin-sdcpn#build" ], "env": ["TEST_COVERAGE"], "inputs": [ "$TURBO_DEFAULT$", - "$TURBO_ROOT$/libs/@hashintel/brunch-agent/**", - "!$TURBO_ROOT$/libs/@hashintel/brunch-agent/**/{.flue,.turbo,dist,node_modules}/**" + "$TURBO_ROOT$/libs/@hashintel/brunch-agent/docs/**", + "$TURBO_ROOT$/libs/@hashintel/brunch-agent/evaluations/**", + "$TURBO_ROOT$/libs/@hashintel/petrinaut/docs/**" ] } } diff --git a/libs/@hashintel/brunch-agent/packages/core/turbo.json b/libs/@hashintel/brunch-agent/packages/core/turbo.json index 2bde83ff3e6..3224e792ba6 100644 --- a/libs/@hashintel/brunch-agent/packages/core/turbo.json +++ b/libs/@hashintel/brunch-agent/packages/core/turbo.json @@ -11,8 +11,7 @@ "test:unit": { "inputs": [ "$TURBO_DEFAULT$", - "$TURBO_ROOT$/libs/@hashintel/brunch-agent/**", - "!$TURBO_ROOT$/libs/@hashintel/brunch-agent/**/{.flue,.turbo,dist,node_modules}/**" + "$TURBO_ROOT$/libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts" ] } } diff --git a/tests/brunch-agent-architecture/.oxlintrc.json b/tests/brunch-agent-architecture/.oxlintrc.json new file mode 100644 index 00000000000..190f0c5737d --- /dev/null +++ b/tests/brunch-agent-architecture/.oxlintrc.json @@ -0,0 +1,18 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../../.config/oxlint/brunch/base.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "ignorePatterns": ["coverage/**", "*.tsbuildinfo", ".turbo/**"] +} diff --git a/tests/brunch-agent-architecture/docs/task-dependencies.json b/tests/brunch-agent-architecture/docs/task-dependencies.json new file mode 100644 index 00000000000..e0d4554d771 --- /dev/null +++ b/tests/brunch-agent-architecture/docs/task-dependencies.json @@ -0,0 +1,18 @@ +{ + "package": "@tests/brunch-agent-architecture", + "dependencies": [ + "@apps/brunch-agent", + "@hashintel/brunch-agent", + "@hashintel/brunch-agent-binding-flue", + "@hashintel/brunch-agent-plugin-dafny", + "@hashintel/brunch-agent-plugin-gherkin", + "@hashintel/brunch-agent-plugin-sdcpn", + "@hashintel/brunch-agent-transport-aisdk" + ], + "tasks": { + "fix:eslint": [], + "lint:eslint": [], + "lint:tsc": [], + "test:unit": [] + } +} diff --git a/tests/brunch-agent-architecture/package.json b/tests/brunch-agent-architecture/package.json new file mode 100644 index 00000000000..67a1a5de336 --- /dev/null +++ b/tests/brunch-agent-architecture/package.json @@ -0,0 +1,28 @@ +{ + "name": "@tests/brunch-agent-architecture", + "version": "0.0.0-private", + "private": true, + "description": "Repository-level architecture checks for the Brunch workspace family.", + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit", + "test:unit": "vitest run" + }, + "devDependencies": { + "@apps/brunch-agent": "workspace:*", + "@hashintel/brunch-agent": "workspace:*", + "@hashintel/brunch-agent-binding-flue": "workspace:*", + "@hashintel/brunch-agent-plugin-dafny": "workspace:*", + "@hashintel/brunch-agent-plugin-gherkin": "workspace:*", + "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*", + "@hashintel/brunch-agent-transport-aisdk": "workspace:*", + "@types/node": "22.18.13", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "vitest": "4.1.10" + } +} diff --git a/tests/brunch-agent-architecture/test/architecture-workspace.test.ts b/tests/brunch-agent-architecture/test/architecture-workspace.test.ts new file mode 100644 index 00000000000..040192e3ce8 --- /dev/null +++ b/tests/brunch-agent-architecture/test/architecture-workspace.test.ts @@ -0,0 +1,50 @@ +/** + * The introspection helpers themselves, at the seams where a wrong answer + * makes some other invariant pass vacuously or fail spuriously. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, relative } from "node:path"; + +import { afterAll, describe, expect, test } from "vitest"; + +import { + sourceFiles, + testFiles, + type WorkspacePackage, +} from "./architecture-workspace"; + +describe("the source/test partition is total", () => { + // A real directory rather than fakes, because the property under test is the + // walk itself: the FE-1400 review's verified escape was a file in a *nested* + // test directory (`src/test/`), pruned by the source walk but never collected + // by the old top-level-only test walk — governed by nothing. + const dir = mkdtempSync(join(tmpdir(), "brunch-partition-")); + mkdirSync(join(dir, "src/test"), { recursive: true }); + mkdirSync(join(dir, "__tests__")); + writeFileSync(join(dir, "src/index.ts"), "export {};\n"); + writeFileSync(join(dir, "src/test/nested.ts"), "export {};\n"); + writeFileSync(join(dir, "__tests__/top.test.ts"), "export {};\n"); + const pkg: WorkspacePackage = { + name: "@hashintel/brunch-agent-fixture", + dir: basename(dir), + path: dir, + relPath: `packages/${basename(dir)}`, + kind: "package", + manifest: { name: "@hashintel/brunch-agent-fixture" }, + }; + + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + test("every file is exactly one of source or test — nested test dirs included", () => { + const source = sourceFiles(pkg) + .map((f) => relative(dir, f.path)) + .sort(); + const test = testFiles(pkg) + .map((f) => relative(dir, f.path)) + .sort(); + expect(source).toEqual(["src/index.ts"]); + expect(test).toEqual(["__tests__/top.test.ts", "src/test/nested.ts"]); + }); +}); diff --git a/apps/brunch-agent/test/architecture/workspace.ts b/tests/brunch-agent-architecture/test/architecture-workspace.ts similarity index 78% rename from apps/brunch-agent/test/architecture/workspace.ts rename to tests/brunch-agent-architecture/test/architecture-workspace.ts index 35633612110..0bcc7bda2fe 100644 --- a/apps/brunch-agent/test/architecture/workspace.ts +++ b/tests/brunch-agent-architecture/test/architecture-workspace.ts @@ -17,22 +17,9 @@ import { fileURLToPath } from "node:url"; /** Resolved through `fileURLToPath` — a raw `URL.pathname` is percent-encoded. */ export const HASH_ROOT = fileURLToPath( - new URL("../../../..", import.meta.url), + new URL("../../..", import.meta.url), ).replace(/[/\\]$/, ""); export const CONTEXT_ROOT = join(HASH_ROOT, "libs/@hashintel/brunch-agent"); -export const REPO_ROOT = CONTEXT_ROOT; - -/** - * The context root's `docs/` and `scripts/` belong to no workspace, so - * `turbo prune` never copies them; CI's prune-repository action copies them - * into its pruned checkout via a requested-scope extra-path rule. This guard is the - * fallback for a pruned tree where that rule is absent or has drifted: the - * context-root tests skip there instead of failing on missing files, and run - * in every full checkout, where this is always true. - */ -export const contextRootPresent = - existsSync(join(CONTEXT_ROOT, "docs")) && - existsSync(join(CONTEXT_ROOT, "scripts")); const PACKAGES_ROOT = join(CONTEXT_ROOT, "packages"); const APP_ROOT = join(HASH_ROOT, "apps/brunch-agent"); @@ -188,38 +175,6 @@ export function testFiles(pkg: WorkspacePackage): SourceFile[] { return partitionedFiles(pkg).test; } -/** - * The `'use agent'` directive as a statement: alone on its line, terminated, - * quotes matching. Anchoring to a statement rather than matching raw text - * keeps a comment that merely *mentions* the directive from turning a file - * into an agent module (the FE-1361 review's verified failure: CI red on a - * comment-only change). - * - * Deliberately not anchored to the *first* statement: a misplaced directive - * must still be detected, so the first-statement invariant in - * `test/boundaries.test.ts` can fail it loudly instead of never seeing it. - */ -export const AGENT_DIRECTIVE_STATEMENT = - /^\s*(["'])use agent\1;?\s*(?:$|\/\/|\/\*)/mu; - -/** Whether a file declares itself an agent module (well-placed or not). */ -export function isAgentModule(file: SourceFile): boolean { - return AGENT_DIRECTIVE_STATEMENT.test(file.text); -} - -/** - * The pinned identities a file assigns, extracted by the one pattern both - * suites share: `test/boundaries.test.ts` checks each identity is never - * duplicated in source, `test/build-artifact.test.ts` checks each is bound in - * the emitted bundle. Two hand-copies of the regex would let the two checks - * silently diverge on what counts as an identity. - */ -export function pinnedIdentities(file: SourceFile): string[] { - return [...file.text.matchAll(/\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu)].map( - (match) => match[2]!, - ); -} - /** * A model-key environment variable name, as a pattern source for callers to * anchor or extend. Composed rather than written literally, so the hermeticity @@ -227,11 +182,6 @@ export function pinnedIdentities(file: SourceFile): string[] { */ export const MODEL_KEY_NAME = `[A-Z_]*${"API"}_${"KEY"}`; -/** Every agent module a package ships. */ -export function agentModules(pkg: WorkspacePackage): SourceFile[] { - return sourceFiles(pkg).filter(isAgentModule); -} - /** * The module specifiers a source file imports or re-exports. * diff --git a/apps/brunch-agent/test/architecture/boundaries.integration.ts b/tests/brunch-agent-architecture/test/architecture.test.ts similarity index 77% rename from apps/brunch-agent/test/architecture/boundaries.integration.ts rename to tests/brunch-agent-architecture/test/architecture.test.ts index 8bd4e9aed0e..d56a9b94a3a 100644 --- a/apps/brunch-agent/test/architecture/boundaries.integration.ts +++ b/tests/brunch-agent-architecture/test/architecture.test.ts @@ -4,33 +4,34 @@ * Spec §4 and §12.2 state the dependency direction as invariants; an invariant * nobody can run is a wish. These are the mechanical checks — they read the * real tree, so a package added later is governed without opting in. - * - * Two of them are load-bearing beyond tidiness, because the Flue build is - * silent about the failure: a `'use agent'` directive that is not the file's - * first statement builds green and simply never registers the agent. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; import { - AGENT_DIRECTIVE_STATEMENT, - agentModules, allDependencies, importedPackages, MODEL_KEY_NAME, packageOf, - pinnedIdentities, runtimeDependencies, sourceFiles, testFiles, workspacePackages, + type PackageManifest, type WorkspacePackage, -} from "./workspace"; +} from "./architecture-workspace"; const PACKAGES = workspacePackages(); +const CHECKER_MANIFEST = JSON.parse( + readFileSync( + fileURLToPath(new URL("../package.json", import.meta.url)), + "utf8", + ), +) as PackageManifest; const CORE = "@hashintel/brunch-agent"; /** Flue is the selected agent runtime; lower-level Pi packages remain binding/test concerns. */ @@ -55,6 +56,19 @@ test("every package is actually scanned", () => { } }); +test("the checker depends on every governed workspace", () => { + const governedDependencies = Object.keys( + CHECKER_MANIFEST.devDependencies ?? {}, + ) + .filter( + (dependency) => + dependency === "@apps/brunch-agent" || + dependency.startsWith("@hashintel/brunch-agent"), + ) + .sort(); + expect(governedDependencies).toEqual(PACKAGES.map(({ name }) => name).sort()); +}); + describe("role prefixes name what a package is architecturally (spec §12.2)", () => { test("every package under packages/ is core or carries a role prefix", () => { for (const pkg of PACKAGES.filter((p) => p.kind === "package")) { @@ -264,92 +278,6 @@ describe("Valibot is the schema library at every boundary (spec §12.4)", () => }); describe("recorded Flue constraints hold by construction (spec §10)", () => { - const dev = PACKAGES.find((pkg) => pkg.relPath === "apps/brunch-agent")!; - // Statement-anchored detection (see workspace.ts): a comment mentioning the - // directive is not an agent module, but a *misplaced* directive still is — - // so the first-statement test below sees it and goes red. - const devAgentModules = agentModules(dev); - - test("the dev app has at least one agent module", () => { - expect(devAgentModules.length).toBeGreaterThan(0); - }); - - test("'use agent' is the module's first statement", () => { - // The build does NOT catch this: a misplaced directive builds green and - // the module simply stops being an agent. Nothing else would notice until - // a conversation failed to start. - for (const file of devAgentModules) { - // Comments are not statements, so a leading doc block is legal and must - // not read as a violation. - const withoutLeadingComments = file.text - .replace(/^/, "") - .replace(/^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/))*\s*/, ""); - // Judged by the same pattern that detects the directive at all - // (workspace.ts), so every form the lexicon declares legal — either - // quote style, optional semicolon, trailing comment — passes and only - // placement can fail. The old literal comparison spuriously failed - // forms this suite's own fixtures assert are legal. - const firstStatement = withoutLeadingComments.split("\n")[0] ?? ""; - expect({ - file: file.relPath, - firstStatementIsDirective: - AGENT_DIRECTIVE_STATEMENT.test(firstStatement), - }).toEqual({ file: file.relPath, firstStatementIsDirective: true }); - } - }); - - test("agentName is a pinned string literal", () => { - // Conversation storage keys on it, so a computed value is unsupportable - // and a changed value orphans every existing conversation. Extraction - // goes through the shared pinnedIdentities pattern — a third hand-copied - // regex here could silently disagree with the other suites on what - // counts as an identity — and this test adds the shape constraint. - for (const file of devAgentModules) { - const identities = pinnedIdentities(file); - expect({ file: file.relPath, pinned: identities.length > 0 }).toEqual({ - file: file.relPath, - pinned: true, - }); - for (const identity of identities) { - expect(identity).toMatch(/^[a-z][a-z0-9-]*$/); - } - } - }); - - test("a pinned identity appears only in its own agent module", () => { - // The mount path (and anything else naming the agent) must derive from - // `agentName`, not copy it: a duplicated literal is the seam the FE-1361 - // review verified — a copy-pasted second agent shadows the mount while - // every test stays green, because nothing ties the copies together. - const identities = devAgentModules.flatMap((file) => - pinnedIdentities(file).map((identity) => ({ - identity, - pinnedIn: file.relPath, - })), - ); - expect(identities.length).toBeGreaterThan(0); - // Quoted occurrences only: prose in a comment may *name* the agent without - // duplicating its identity into anything the runtime reads — flagging that - // would be the cry-wolf failure this ticket removed from the directive - // check. A string literal carrying the identity anywhere else is real - // duplication, including inside a longer path like '/agents/…'. - const STRING_LITERAL = /(['"`])(?:\\.|(?!\1)[^\\\n])*\1/g; - for (const { identity, pinnedIn } of identities) { - const duplicatedIn = sourceFiles(dev) - .filter((file) => file.relPath !== pinnedIn) - .filter((file) => - (file.text.match(STRING_LITERAL) ?? []).some((literal) => - literal.includes(identity), - ), - ) - .map((file) => file.relPath); - expect({ identity, duplicatedIn }).toEqual({ - identity, - duplicatedIn: [], - }); - } - }); - test("vite is pinned to 8, which @flue/vite requires", () => { const pinned = PACKAGES.flatMap((pkg) => { const range = @@ -360,15 +288,6 @@ describe("recorded Flue constraints hold by construction (spec §10)", () => { for (const range of pinned) expect(range).toMatch(/^\^?8(\.|$)/); }); - test("the dev app owns the agent module, the mount, and the conversation store", () => { - // Every host authors its own thin `'use agent'` module, `app.ts` and - // `db.ts` — Flue's build-time scan makes shipping a pre-registered agent - // from a library structurally unavailable (spec §12.1). - for (const file of ["src/app.ts", "src/db.ts"]) { - expect(() => readFileSync(join(dev.path, file), "utf8")).not.toThrow(); - } - }); - test("runtime entrypoints never select Flue's Bun adapter", () => { for (const pkg of PACKAGES) { for (const file of [...sourceFiles(pkg), ...testFiles(pkg)]) { @@ -460,8 +379,8 @@ describe("the HASH smoke is runnable without a model key or a network (spec §12 // is deliberately not part of this run, and this is what stops it drifting // in unnoticed. expect(suite.length).toBeGreaterThan(0); - // Composed in workspace.ts rather than written literally, so this check - // does not flag its own source or the pattern's. + // Composed in architecture-workspace.ts rather than written literally, so + // this check does not flag its own source or the pattern's. const modelKey = new RegExp(MODEL_KEY_NAME, "g"); for (const file of suite) { expect({ diff --git a/tests/brunch-agent-architecture/tsconfig.json b/tests/brunch-agent-architecture/tsconfig.json new file mode 100644 index 00000000000..91c0afd5d23 --- /dev/null +++ b/tests/brunch-agent-architecture/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["ESNext"], + "types": ["node"], + "module": "preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["test"] +} diff --git a/tests/brunch-agent-architecture/turbo.json b/tests/brunch-agent-architecture/turbo.json new file mode 100644 index 00000000000..5daa8e68e15 --- /dev/null +++ b/tests/brunch-agent-architecture/turbo.json @@ -0,0 +1,14 @@ +{ + "extends": ["//"], + "tasks": { + "fix:eslint": { + "dependsOn": [] + }, + "lint:eslint": { + "dependsOn": [] + }, + "lint:tsc": { + "dependsOn": [] + } + } +} diff --git a/yarn.lock b/yarn.lock index 7d4c19bdd98..5c89ebf1626 100644 --- a/yarn.lock +++ b/yarn.lock @@ -430,7 +430,7 @@ __metadata: languageName: node linkType: hard -"@apps/brunch-agent@workspace:apps/brunch-agent": +"@apps/brunch-agent@workspace:*, @apps/brunch-agent@workspace:apps/brunch-agent": version: 0.0.0-use.local resolution: "@apps/brunch-agent@workspace:apps/brunch-agent" dependencies: @@ -7526,7 +7526,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/brunch-agent-plugin-dafny@workspace:libs/@hashintel/brunch-agent/packages/plugin-dafny": +"@hashintel/brunch-agent-plugin-dafny@workspace:*, @hashintel/brunch-agent-plugin-dafny@workspace:libs/@hashintel/brunch-agent/packages/plugin-dafny": version: 0.0.0-use.local resolution: "@hashintel/brunch-agent-plugin-dafny@workspace:libs/@hashintel/brunch-agent/packages/plugin-dafny" dependencies: @@ -7541,7 +7541,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/brunch-agent-plugin-gherkin@workspace:libs/@hashintel/brunch-agent/packages/plugin-gherkin": +"@hashintel/brunch-agent-plugin-gherkin@workspace:*, @hashintel/brunch-agent-plugin-gherkin@workspace:libs/@hashintel/brunch-agent/packages/plugin-gherkin": version: 0.0.0-use.local resolution: "@hashintel/brunch-agent-plugin-gherkin@workspace:libs/@hashintel/brunch-agent/packages/plugin-gherkin" dependencies: @@ -17778,6 +17778,25 @@ __metadata: languageName: node linkType: hard +"@tests/brunch-agent-architecture@workspace:tests/brunch-agent-architecture": + version: 0.0.0-use.local + resolution: "@tests/brunch-agent-architecture@workspace:tests/brunch-agent-architecture" + dependencies: + "@apps/brunch-agent": "workspace:*" + "@hashintel/brunch-agent": "workspace:*" + "@hashintel/brunch-agent-binding-flue": "workspace:*" + "@hashintel/brunch-agent-plugin-dafny": "workspace:*" + "@hashintel/brunch-agent-plugin-gherkin": "workspace:*" + "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*" + "@hashintel/brunch-agent-transport-aisdk": "workspace:*" + "@types/node": "npm:22.18.13" + "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + oxlint: "npm:1.63.0" + oxlint-tsgolint: "npm:0.22.1" + vitest: "npm:4.1.10" + languageName: unknown + linkType: soft + "@tests/hash-backend-integration@workspace:tests/hash-backend-integration": version: 0.0.0-use.local resolution: "@tests/hash-backend-integration@workspace:tests/hash-backend-integration" From 0b99d8806fc9418e20659d5cc28f805164eda34f Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 13:54:34 +0200 Subject: [PATCH 03/10] Address architecture review findings Co-authored-by: Cursor --- .../test/architecture/app-workspace.ts | 12 ++++---- .../core/test/architecture/context-root.ts | 6 ++-- yarn.config.cjs | 29 +++++++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/brunch-agent/test/architecture/app-workspace.ts b/apps/brunch-agent/test/architecture/app-workspace.ts index a8ef20b9feb..9c26c2689e1 100644 --- a/apps/brunch-agent/test/architecture/app-workspace.ts +++ b/apps/brunch-agent/test/architecture/app-workspace.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; @@ -27,14 +27,14 @@ const SKIP_DIRECTORIES = new Set([ export const sourceFiles = (): SourceFile[] => { const found: SourceFile[] = []; const walk = (current: string): void => { - for (const entry of readdirSync(current)) { - if (SKIP_DIRECTORIES.has(entry)) { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (SKIP_DIRECTORIES.has(entry.name)) { continue; } - const path = join(current, entry); - if (statSync(path).isDirectory()) { + const path = join(current, entry.name); + if (entry.isDirectory()) { walk(path); - } else if (SOURCE_EXTENSIONS.test(entry)) { + } else if (SOURCE_EXTENSIONS.test(entry.name)) { found.push({ path, relPath: relative(APP_ROOT, path).replaceAll("\\", "/"), diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts index 4525fc8fbe1..dbd6889f9d8 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts @@ -11,6 +11,6 @@ export const CONTEXT_ROOT = fileURLToPath( * Pruned jobs copy these non-workspace paths explicitly. Skip the affected * tests instead of failing opaquely if that CI contract drifts. */ -export const contextRootPresent = - existsSync(join(CONTEXT_ROOT, "docs")) && - existsSync(join(CONTEXT_ROOT, "scripts")); +export const contextRootPresent = existsSync( + join(CONTEXT_ROOT, "scripts/linear-project-graph.ts"), +); diff --git a/yarn.config.cjs b/yarn.config.cjs index 151062f51c1..ddf8ce32b33 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -39,6 +39,8 @@ const ignoredWorkspaces = []; const allowedGitDependencies = []; const brunchAgentCore = "@hashintel/brunch-agent"; +const brunchAgentApp = "@apps/brunch-agent"; +const brunchArchitectureChecker = "@tests/brunch-agent-architecture"; const brunchAgentTransportPrefix = `${brunchAgentCore}-transport-`; const brunchSubstrateScopes = ["@earendil-works/", "@flue/"]; @@ -346,6 +348,32 @@ function enforceBrunchTransportBoundary({ Yarn }) { } } +/** + * Keeps every governed Brunch workspace upstream of the architecture checker. + * + * This constraint runs against the complete repository graph, so adding a new + * Brunch workspace cannot bypass the checker by being absent from its prune. + * + * @param {Context} context - The Yarn constraint context. + */ +function enforceBrunchArchitectureCheckerCoverage({ Yarn }) { + const checker = Yarn.workspace({ ident: brunchArchitectureChecker }); + + if (checker === null) { + throw new Error(`missing workspace ${brunchArchitectureChecker}`); + } + + for (const workspace of Yarn.workspaces()) { + if ( + workspace.ident === brunchAgentApp || + workspace.ident === brunchAgentCore || + workspace.ident?.startsWith(`${brunchAgentCore}-`) + ) { + checker.set(`devDependencies.${workspace.ident}`, "workspace:*"); + } + } +} + module.exports = defineConfig({ async constraints(context) { enforceConsistentDependenciesAcrossTheProject(context); @@ -354,5 +382,6 @@ module.exports = defineConfig({ enforceDevDependenciesAreProperlyDeclared(context); enforceNoInstallScripts(context); enforceBrunchTransportBoundary(context); + enforceBrunchArchitectureCheckerCoverage(context); }, }); From 40625b1e4e29ce1d9c2ed8f16693669dda333eed Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 14:34:53 +0200 Subject: [PATCH 04/10] Add license to architecture test workspace Co-authored-by: Cursor --- tests/brunch-agent-architecture/LICENSE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 tests/brunch-agent-architecture/LICENSE.md diff --git a/tests/brunch-agent-architecture/LICENSE.md b/tests/brunch-agent-architecture/LICENSE.md new file mode 100644 index 00000000000..4deff4ef955 --- /dev/null +++ b/tests/brunch-agent-architecture/LICENSE.md @@ -0,0 +1,5 @@ +GNU Affero General Public License v3.0 +===================================== + +This workspace is licensed under the GNU Affero General Public License v3.0. +See the [repository license](../../LICENSE.md) for the complete terms. From 1be771d145d7a1a6a7a60bfb5a91c2fc6e5cb6f0 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 14:35:21 +0200 Subject: [PATCH 05/10] Restore architecture mission after main merge Co-authored-by: Cursor --- libs/@hashintel/brunch-agent/MISSION.md | 29 ------------------------- 1 file changed, 29 deletions(-) diff --git a/libs/@hashintel/brunch-agent/MISSION.md b/libs/@hashintel/brunch-agent/MISSION.md index 1a14ea653e5..156b68dbf15 100644 --- a/libs/@hashintel/brunch-agent/MISSION.md +++ b/libs/@hashintel/brunch-agent/MISSION.md @@ -6,35 +6,6 @@ [SRE-1010](https://linear.app/hash/issue/SRE-1010/move-brunch-architecture-checks-out-of-core-unit-tests) on `ln/sre-1010-move-brunch-checks`. This file is the branch's sole execution authority. -## Supplemental FE-1580 settlement follow-up - -**Live as of 2026-09-08** for -[PR #9588](https://github.com/hashintel/hash/pull/9588) on -`kostandin/fe-1580-port-voice-settlement-fixes`, based directly on current -`main` after #9564 and #9537 merged. This supplement preserves the accepted -Voice contract without changing the CORS authority in this file. - -- **Imperative:** semantically port the omitted #9531 commit `9415e1b007`; - release silent Voice ownership and settle completed submissions without - canonical prose. Preserve failed durable Stop errors and remove the stale - browser `brunch_ask` catalogue entry. -- **Throughline:** OpenAI terminal output → session/bridge/controller ownership; - correlated Brunch settlement → next Voice turn; panel Stop rejection → - deferred browser-tool termination; shared browser catalogue → - transport/history. -- **Proof:** donor session/bridge/controller and preview regressions; panel DOM - tests for persistent Stop failure and withheld continuation; catalogue and - fixture tests; focused unit, build, TypeScript, ESLint and formatting checks. - These tests establish local settlement behavior, not paid-provider behavior, - audible latency or a new microphone witness. -- **Constraints:** preserve current `main`'s accepted Voice and CORS joins; no - #9538 grounding, #9550 VAD/interruption, snapshot-overlay or provenance - rollback work, generic interactive tools, obsolete shim, or `brunch_ask` - restoration. -- **Stop or reorient:** stop if the port erases errors, releases unrelated - playback, revives withheld tools, weakens the CORS policy, or disturbs other - work. - ## Imperative Make Brunch architecture checks run from a workspace that is downstream of every workspace they From f4f78e739d0b46ca300fb7902ae7c795ccf25256 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 16:05:56 +0200 Subject: [PATCH 06/10] Reorient architecture checks to repo chores Co-authored-by: Cursor --- libs/@hashintel/brunch-agent/MISSION.md | 70 +++++++++++++------------ 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/libs/@hashintel/brunch-agent/MISSION.md b/libs/@hashintel/brunch-agent/MISSION.md index 156b68dbf15..b4063372a90 100644 --- a/libs/@hashintel/brunch-agent/MISSION.md +++ b/libs/@hashintel/brunch-agent/MISSION.md @@ -8,18 +8,18 @@ on `ln/sre-1010-move-brunch-checks`. This file is the branch's sole execution au ## Imperative -Make Brunch architecture checks run from a workspace that is downstream of every workspace they -govern, so ordinary core and app unit-test tasks own only their local contracts. Do this now -because the SRE-1007 hotfix modeled sibling packages and the app as inputs to core, creating a -reverse `libs -> apps` edge and requiring downstream trees in a core prune. +Move Brunch family-wide architecture conventions into repo-chores as a dedicated lint task, so +ordinary core and app unit-test tasks own only their local contracts. Do this now because the +SRE-1007 hotfix modeled sibling packages and the app as inputs to core, creating a reverse +`libs -> apps` edge and requiring downstream trees in a core prune. ## Throughline ```text change in apps/brunch-agent or a Brunch package -→ Turbo follows the declared workspace dependency into @tests/brunch-agent-architecture -→ its dedicated unit-test task scans the governed manifests and authored source -→ a package-family boundary violation fails in the architecture workspace +→ the checker-owned Turbo inputs invalidate @local/repo-chores#lint:brunch-architecture +→ the dedicated Global lint step runs the repo-chores command against the full checkout +→ a package-family boundary violation fails as a repository lint error change in apps/brunch-agent → @apps/brunch-agent#test:unit @@ -30,9 +30,9 @@ change in Brunch core → core-local tests, without app or sibling-package inputs ``` -The architecture workspace declares every governed Brunch workspace as a development dependency. -That graph direction is the selection and prune mechanism; do not recreate it with cross-workspace -Turbo input globs or prune exceptions. +The repo-chores task owns the explicit Brunch-family inputs because it is the repository-level +consumer of those files. It runs in the unpruned Global lint job; do not make governed workspaces +depend on the checker or recreate the old reverse edge at core. ## Proof @@ -43,33 +43,32 @@ their policy, or replace Yarn and package-local lint enforcement. 1. **App checks are app-local.** The agent directive, pinned identity, app-entrypoint, and emitted artifact checks pass from `@apps/brunch-agent`, and its architecture helper reads no sibling package. Oracle: `yarn workspace @apps/brunch-agent test:unit`. -2. **Family checks have one downstream owner.** Package naming, dependency direction, schema, - import, runtime, export-lane, and hermetic-test inventory checks pass from - `@tests/brunch-agent-architecture`. Oracle: - `yarn workspace @tests/brunch-agent-architecture test:unit`. -3. **Relevant changes select the family check.** A Turbo affected-task query against representative - app and package changes selects `@tests/brunch-agent-architecture#test:unit`; generated and - dependency directories do not become authored inputs. Oracle: focused Turbo query/dry-run - fixtures recorded in the implementation verification. +2. **Family checks have one repository owner.** Package naming, dependency direction, schema, + import, runtime, export-lane, and hermetic-test inventory checks pass from the repo-chores + `check-brunch-architecture` command. Oracle: + `yarn workspace @local/repo-chores lint:brunch-architecture`. +3. **Relevant changes invalidate the family check.** The repo-chores Turbo task hashes the governed + app and package manifests, authored source, and tests while excluding generated and dependency + directories. Oracle: focused Turbo dry-run fixtures recorded in the implementation verification. 4. **A core prune owns no downstream Brunch tree.** The core task has no app or sibling-package input glob, and requesting only core no longer adds the app or plugins through prune exceptions. Oracle: `.github/actions/prune-repository/prune_test.py` plus inspection of `turbo run test:unit --filter @hashintel/brunch-agent --dry=json`. -5. **The new workspace is independently healthy.** Its tests, typecheck, lint, and relevant - package graph checks pass. Oracle: focused workspace test/lint/typecheck commands and - `yarn constraints`. +5. **The new command is independently healthy.** The command, its scanner tests, repo-chores + typecheck and lint, and relevant package graph checks pass. Oracle: focused repo-chores + test/lint/typecheck commands and `yarn constraints`. ## Constraints - Preserve the behavior and failure coverage of every existing architecture assertion while - splitting app-only checks from family checks. + splitting app-only checks from repository-family lint. - Keep Yarn constraints and package-local Oxlint rules with their current owning packages. -- Model governed workspaces as dependencies of the architecture-test workspace; never make a - governed library depend on the checker. +- Keep checker inputs on the repo-chores task; never make a governed workspace depend on the + checker. - Keep core's context-root script coverage only as narrowly hashed inputs that it genuinely reads; do not use the Brunch-family glob. -- Use the existing unit-test matrix and Turbo dependency semantics for CI selection. Add a bespoke - workflow only if evidence shows the dedicated workspace cannot be selected correctly. +- Run the family check as its own step in the existing Global lint job, where the complete + repository is available without prune exceptions. - No implementation begins until this authority cut is committed separately. Material changes to this contract require owner review and another focused authority commit. @@ -78,19 +77,22 @@ their policy, or replace Yarn and package-local lint enforcement. ```text ~ libs/@hashintel/brunch-agent/MISSION.md ~ apps/brunch-agent/test/architecture/* -+ tests/brunch-agent-architecture/* +~ libs/@local/repo-chores/node/scripts/check-brunch-architecture* +~ libs/@local/repo-chores/node/package.json +~ libs/@local/repo-chores/node/turbo.json ~ libs/@hashintel/brunch-agent/packages/core/turbo.json ~ apps/brunch-agent/turbo.json ~ .github/actions/prune-repository/prune.py ~ .github/actions/prune-repository/prune_test.py +~ .github/workflows/lint.yml ~ yarn.lock ``` ## Fog-line -- Turbo's `^manifest` chain is expected to select the architecture workspace for changes in each - declared dependency. If an affected-task probe disproves that, stop and choose the smallest - explicit dedicated task input at the checker, never at a governed package. +- Turbo's task inputs must include every governed manifest, source file, and test while excluding + generated and dependency directories. If a focused dry run disproves that, stop and correct the + checker-owned inputs rather than widening a governed package task. - Some app unit tests independently read non-workspace Brunch evaluation and documentation assets. This mission removes family architecture ownership from the app but does not remove inputs those product tests still genuinely consume. @@ -99,9 +101,9 @@ their policy, or replace Yarn and package-local lint enforcement. ## Stop or reorient -Stop if the dedicated workspace cannot be selected from both app and package changes through the -declared dependency graph, if its prune omits a governed workspace, or if moving an assertion -changes what it permits or rejects. +Stop if the dedicated repo-chores task does not invalidate for both app and package changes, if it +requires a prune exception in the Global job, or if moving an assertion changes what it permits or +rejects. Stop if removing a prune exception makes a non-architecture core or app test lose a genuine fixture. Preserve that proven dependency narrowly and report it instead of deleting it to satisfy @@ -111,4 +113,4 @@ the desired graph shape. The canonical future planning record remains [`MISSION.next.md`](MISSION.next.md). SRE-1008 owns repository-wide detection of undeclared Turbo -task inputs; this mission supplies a declared Brunch task layout for it to inspect. +task inputs; this mission supplies declared checker-owned Brunch inputs for it to inspect. From d59dffa0815aac0e4d519a0a21cbc71519a939bc Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 16:12:06 +0200 Subject: [PATCH 07/10] Move Brunch architecture checks to repo chores Co-authored-by: Cursor --- .github/workflows/lint.yml | 4 + .../node/docs/task-dependencies.json | 1 + libs/@local/repo-chores/node/package.json | 1 + .../node/scripts/check-brunch-architecture.ts | 476 ++++++++++++++++++ .../workspace.test.ts | 32 +- .../check-brunch-architecture/workspace.ts | 62 ++- libs/@local/repo-chores/node/turbo.json | 11 + .../brunch-agent-architecture/.oxlintrc.json | 18 - tests/brunch-agent-architecture/LICENSE.md | 5 - .../docs/task-dependencies.json | 18 - tests/brunch-agent-architecture/package.json | 28 -- .../test/architecture.test.ts | 422 ---------------- tests/brunch-agent-architecture/tsconfig.json | 20 - tests/brunch-agent-architecture/turbo.json | 14 - turbo.json | 1 + yarn.config.cjs | 29 -- yarn.lock | 25 +- 17 files changed, 546 insertions(+), 621 deletions(-) create mode 100644 libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts rename tests/brunch-agent-architecture/test/architecture-workspace.test.ts => libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts (59%) rename tests/brunch-agent-architecture/test/architecture-workspace.ts => libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts (82%) delete mode 100644 tests/brunch-agent-architecture/.oxlintrc.json delete mode 100644 tests/brunch-agent-architecture/LICENSE.md delete mode 100644 tests/brunch-agent-architecture/docs/task-dependencies.json delete mode 100644 tests/brunch-agent-architecture/package.json delete mode 100644 tests/brunch-agent-architecture/test/architecture.test.ts delete mode 100644 tests/brunch-agent-architecture/tsconfig.json delete mode 100644 tests/brunch-agent-architecture/turbo.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fa91feb9138..a57b8f05f92 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -283,6 +283,10 @@ jobs: exit 1 fi + - name: Run Brunch architecture checks + if: ${{ success() || failure() }} + run: turbo run lint:brunch-architecture --filter @local/repo-chores + - name: Run yarn lint:license-in-workspaces if: ${{ success() || failure() }} env: diff --git a/libs/@local/repo-chores/node/docs/task-dependencies.json b/libs/@local/repo-chores/node/docs/task-dependencies.json index 0d4c7e45abc..1ff1179e65d 100644 --- a/libs/@local/repo-chores/node/docs/task-dependencies.json +++ b/libs/@local/repo-chores/node/docs/task-dependencies.json @@ -12,6 +12,7 @@ "@local/hash-backend-utils#build", "@local/hash-isomorphic-utils#build" ], + "lint:brunch-architecture": [], "lint:eslint": [ "@local/eslint#build", "@local/hash-backend-utils#build", diff --git a/libs/@local/repo-chores/node/package.json b/libs/@local/repo-chores/node/package.json index b19054a3947..a752242f0f6 100644 --- a/libs/@local/repo-chores/node/package.json +++ b/libs/@local/repo-chores/node/package.json @@ -9,6 +9,7 @@ "scripts": { "exe": "tsx", "fix:eslint": "eslint --fix .", + "lint:brunch-architecture": "node --import tsx scripts/check-brunch-architecture.ts", "lint:eslint": "eslint --report-unused-disable-directives .", "lint:tsc": "tsc --noEmit", "test:unit": "vitest --run" diff --git a/libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts b/libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts new file mode 100644 index 00000000000..0e186ab504f --- /dev/null +++ b/libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts @@ -0,0 +1,476 @@ +import path from "node:path"; + +import { + allDependencies, + importedPackages, + MODEL_KEY_NAME, + packageOf, + runtimeDependencies, + sourceFiles, + testFiles, + workspacePackages, + type WorkspacePackage, +} from "./check-brunch-architecture/workspace"; +import { UserFriendlyError } from "./shared/errors"; + +const corePackageName = "@hashintel/brunch-agent"; +const substrateScopes = ["@flue/", "@earendil-works/"]; +const flueRuntime = "@flue/runtime"; + +const otherSchemaLibraries = [ + "zod", + "yup", + "joi", + "ajv", + "superstruct", + "arktype", + "io-ts", + "runtypes", + "@sinclair/typebox", + "@standard-schema/spec", + "@standard-schema/utils", +]; + +const substrateIntegrationEntryPoints: Readonly> = { + "libs/@hashintel/brunch-agent/packages/core/test/question-marker.test.ts": + "Types the Flue logger and calls the core marker tool with a mocked data-part writer and logger; no runtime boot, provider, key or socket.", + "apps/brunch-agent/test/brunch-turn.test.ts": + "Types Flue's client, admission, and conversation snapshot and constructs FlueExecutionError so the persona bridge can be unit-tested against a stubbed client — no provider key, no socket, no model call, no runtime boot.", + "apps/brunch-agent/test/flue-transcript.test.ts": + "Types Flue's public conversation snapshot so the transcript projector can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", + "apps/brunch-agent/test/petrinaut-chat.integration.ts": + "Boots the plain Flue chat agent on Flue's node runtime with pi-ai's faux provider, drives the browser ChatTransport against the mounted Flue route over app.fetch, and proves streamed reasoning/text, server tools, client-tool resume, SDK history ownership, SQLite restart, and harness-side idempotent apply-sweep — no provider key, no socket, no extraction model call. Run as a child process by petrinaut-chat.test.ts.", + "apps/brunch-agent/test/prepared-workpiece.integration.ts": + "Boots the built Flue ChatAgent with pi-ai's faux provider, creates a prepared fixture through one tagged public signal with fixture-scoped initial data, retries its deterministic idempotency key, and proves prepared/model workpiece selection from canonical history — no provider key, socket, or network model call.", + "apps/brunch-agent/test/proof-artifacts.test.ts": + "Types Flue's public conversation snapshot so canonical trace derivation, workpiece binding, and atomic evidence retention can be unit-tested against an in-memory fixture — no provider key, no socket, no model call, no runtime boot.", + "apps/brunch-agent/test/runbook-artifacts.test.ts": + "Types Flue's public conversation snapshot so runbook artifact recovery can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", + "apps/brunch-agent/test/runbook-elicitation-faux-provider.ts": + "Defines the scripted pi-ai faux provider loaded only by the hermetic prospective-runner test — no provider key, no socket, and no network model call.", + "apps/brunch-agent/test/runbook-headless.integration.ts": + "Boots the built Flue ChatAgent with pi-ai's faux provider and a headless Petrinaut client to prove validated construct-only tool flow without a provider key, socket, or network model call.", + "apps/brunch-agent/test/telemetry.test.ts": + "Constructs Flue's content-free OpenTelemetry instrumentation with an injected exporter setup to prove disposal order; it registers no global instrumentation, opens no socket, and makes no provider call.", + "apps/brunch-agent/test/workpiece.test.ts": + "Types Flue's public conversation snapshot so the substrate-neutral workpiece selector and app-owned SHA-256 projection can be unit-tested against in-memory messages — no provider key, no socket, no model call, no runtime boot.", + "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts": + "Types a stubbed public Flue client and stream chunks to prove finite AI SDK projection and client-tool signal admission — no runtime boot, provider key, socket, or model call.", + "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts": + "Types Flue's public conversation snapshot so history can be projected into AI SDK messages without a runtime boot, provider key, socket, or model call.", + "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ui-stream.test.ts": + "Types Flue conversation-stream chunks so the finite AI SDK projector can be unit-tested without a runtime boot, provider key, socket, or model call.", +}; + +const isSubstrate = (name: string): boolean => + substrateScopes.some((scope) => name.startsWith(scope)); + +const byRole = ( + packages: readonly WorkspacePackage[], + role: string, +): WorkspacePackage[] => + packages.filter((workspacePackage) => + workspacePackage.dir.startsWith(`${role}-`), + ); + +const valuesEqual = (actual: unknown, expected: unknown): boolean => + JSON.stringify(actual) === JSON.stringify(expected); + +const checkBrunchArchitecture = (): string[] => { + const packages = workspacePackages(); + const violations: string[] = []; + + const assertCondition = ( + condition: boolean, + rule: string, + details: string, + ): void => { + if (!condition) { + violations.push(`${rule}: ${details}`); + } + }; + + const assertEqual = ( + actual: unknown, + expected: unknown, + rule: string, + subject: string, + ): void => { + assertCondition( + valuesEqual(actual, expected), + rule, + `${subject}; expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`, + ); + }; + + for (const workspacePackage of packages) { + assertCondition( + sourceFiles(workspacePackage).length > 0, + "complete scan", + `${workspacePackage.relPath} has no scanned source files`, + ); + } + + for (const workspacePackage of packages.filter( + ({ kind }) => kind === "package", + )) { + assertCondition( + /^(core|plugin-[a-z0-9-]+|binding-[a-z0-9-]+|transport-[a-z0-9-]+)$/.test( + workspacePackage.dir, + ), + "role prefix", + `${workspacePackage.relPath} is not core or a role-prefixed package`, + ); + } + + for (const workspacePackage of packages) { + assertCondition( + !/^(adapter|wrapper|elicit)-/.test(workspacePackage.dir), + "role vocabulary", + `${workspacePackage.relPath} uses an avoided role noun`, + ); + + const expectedName = + workspacePackage.kind === "app" + ? "@apps/brunch-agent" + : workspacePackage.dir === "core" + ? corePackageName + : `@hashintel/brunch-agent-${workspacePackage.dir}`; + assertEqual( + workspacePackage.name, + expectedName, + "workspace identity", + workspacePackage.relPath, + ); + } + + const core = packages.find(({ name }) => name === corePackageName); + assertCondition( + core !== undefined, + "core workspace", + `${corePackageName} is missing`, + ); + + if (core !== undefined) { + assertEqual( + allDependencies(core).filter(isSubstrate), + [flueRuntime], + "core runtime", + "agent-runtime dependencies", + ); + + for (const file of sourceFiles(core)) { + const substrateImports = importedPackages(file).filter((specifier) => + isSubstrate(packageOf(specifier)), + ); + const expectedImports = + file.relPath.endsWith("/src/flue.ts") || + file.relPath.endsWith("/src/skills/skill-markdown.ts") + ? [flueRuntime] + : []; + assertEqual( + substrateImports, + expectedImports, + "core runtime", + file.relPath, + ); + } + + for (const dependency of runtimeDependencies(core)) { + assertCondition( + !/^@hashintel\/brunch-agent-(binding|plugin)-/u.test(dependency), + "core direction", + `${core.relPath} depends on ${dependency}`, + ); + } + + assertEqual( + Object.keys(core.manifest.exports ?? {}), + [ + ".", + "./client-tools", + "./flue", + "./question-marker", + "./storage", + "./workpiece", + ], + "core exports", + core.relPath, + ); + } + + const plugins = byRole(packages, "plugin"); + assertCondition( + plugins.length > 0, + "plugin inventory", + "no plugins were found", + ); + for (const plugin of plugins) { + const workspaceDependencies = runtimeDependencies(plugin).filter( + (dependency) => dependency.startsWith(corePackageName), + ); + assertEqual( + workspaceDependencies, + [corePackageName], + "plugin direction", + plugin.relPath, + ); + assertEqual( + allDependencies(plugin).filter( + (dependency) => isSubstrate(dependency) && dependency !== flueRuntime, + ), + [], + "plugin substrate", + plugin.relPath, + ); + + for (const file of sourceFiles(plugin)) { + for (const specifier of importedPackages(file)) { + const importedPackage = packageOf(specifier); + assertCondition( + !isSubstrate(importedPackage) || importedPackage === flueRuntime, + "plugin substrate", + `${file.relPath} imports ${specifier}`, + ); + assertCondition( + !importedPackage.startsWith(corePackageName) || + importedPackage === corePackageName, + "plugin direction", + `${file.relPath} imports ${specifier}`, + ); + assertCondition( + specifier !== `${corePackageName}/storage`, + "plugin storage lane", + `${file.relPath} imports ${specifier}`, + ); + } + } + } + + const bindings = byRole(packages, "binding"); + assertCondition( + bindings.length > 0, + "binding inventory", + "no bindings were found", + ); + for (const binding of bindings) { + const dependencies = runtimeDependencies(binding); + assertCondition( + dependencies.includes(corePackageName), + "binding direction", + `${binding.relPath} does not depend on ${corePackageName}`, + ); + assertCondition( + dependencies.some(isSubstrate), + "binding direction", + `${binding.relPath} does not depend on a substrate`, + ); + for (const dependency of dependencies) { + assertCondition( + !dependency.startsWith("@hashintel/brunch-agent-plugin-"), + "binding direction", + `${binding.relPath} depends on plugin ${dependency}`, + ); + } + } + + const transports = byRole(packages, "transport"); + assertCondition( + transports.length > 0, + "transport inventory", + "no transports were found", + ); + for (const transport of transports) { + assertEqual( + runtimeDependencies(transport).sort(), + ["@flue/sdk", "ai"], + "transport dependencies", + transport.relPath, + ); + for (const file of sourceFiles(transport).filter((sourceFile) => + sourceFile.path.startsWith(path.join(transport.path, "src")), + )) { + for (const specifier of importedPackages(file)) { + if (!specifier.startsWith("node:")) { + assertCondition( + ["@flue/sdk", "ai"].includes(packageOf(specifier)), + "transport imports", + `${file.relPath} imports ${specifier}`, + ); + } + } + } + } + + for (const workspacePackage of packages) { + const declared = runtimeDependencies(workspacePackage); + for (const file of sourceFiles(workspacePackage)) { + const importedWorkspaces = importedPackages(file) + .map(packageOf) + .filter( + (imported) => + imported === corePackageName || + imported.startsWith(`${corePackageName}-`), + ); + for (const imported of importedWorkspaces) { + assertCondition( + declared.includes(imported), + "declared runtime dependency", + `${file.relPath} imports undeclared workspace ${imported}`, + ); + } + } + } + + for (const workspacePackage of packages) { + const dependencies = allDependencies(workspacePackage); + for (const forbidden of otherSchemaLibraries) { + assertCondition( + !dependencies.includes(forbidden), + "schema library", + `${workspacePackage.relPath} declares ${forbidden}`, + ); + } + + const files = sourceFiles(workspacePackage); + for (const file of files) { + for (const specifier of importedPackages(file)) { + assertCondition( + !otherSchemaLibraries.includes(packageOf(specifier)), + "schema library", + `${file.relPath} imports ${specifier}`, + ); + } + } + + const importsValibot = files.some((file) => + importedPackages(file).some( + (specifier) => packageOf(specifier) === "valibot", + ), + ); + const declaresValibot = dependencies.includes("valibot"); + assertEqual( + declaresValibot, + importsValibot, + "valibot declaration", + workspacePackage.relPath, + ); + } + + const viteRanges = packages.flatMap((workspacePackage) => { + const range = + workspacePackage.manifest.devDependencies?.vite ?? + workspacePackage.manifest.dependencies?.vite; + return range === undefined ? [] : [range]; + }); + assertCondition( + viteRanges.length > 0, + "Vite constraint", + "no Vite range was found", + ); + for (const range of viteRanges) { + assertCondition( + /^\^?8(\.|$)/u.test(range), + "Vite constraint", + `expected Vite 8, received ${range}`, + ); + } + + for (const workspacePackage of packages) { + for (const file of [ + ...sourceFiles(workspacePackage), + ...testFiles(workspacePackage), + ]) { + assertCondition( + !importedPackages(file).includes("@flue/runtime/bun"), + "Flue runtime adapter", + `${file.relPath} imports @flue/runtime/bun`, + ); + } + } + + for (const workspacePackage of packages.filter( + ({ dir }) => !dir.startsWith("binding-"), + )) { + for (const file of sourceFiles(workspacePackage)) { + assertCondition( + !importedPackages(file).includes(`${corePackageName}/storage`), + "core storage lane", + `${file.relPath} imports ${corePackageName}/storage`, + ); + } + } + + for (const workspacePackage of packages) { + assertCondition( + typeof workspacePackage.manifest.scripts?.["lint:eslint"] === "string", + "workspace tasks", + `${workspacePackage.relPath} has no lint:eslint script`, + ); + assertCondition( + typeof workspacePackage.manifest.scripts?.["lint:tsc"] === "string", + "workspace tasks", + `${workspacePackage.relPath} has no lint:tsc script`, + ); + assertCondition( + workspacePackage.manifest.scripts?.["test:unit"]?.includes( + "vitest run", + ) === true, + "workspace tasks", + `${workspacePackage.relPath} test:unit does not run Vitest`, + ); + } + + const suite = packages.flatMap((workspacePackage) => + testFiles(workspacePackage), + ); + assertCondition( + suite.length > 0, + "test inventory", + "no test files were found", + ); + const modelKey = new RegExp(MODEL_KEY_NAME, "gu"); + for (const file of suite) { + const keys = file.text.match(modelKey) ?? []; + assertEqual(keys, [], "model credential", file.relPath); + } + + const substrateImporters = suite + .filter((file) => + importedPackages(file).some((specifier) => + isSubstrate(packageOf(specifier)), + ), + ) + .map(({ relPath }) => relPath) + .sort(); + assertEqual( + substrateImporters, + Object.keys(substrateIntegrationEntryPoints).sort(), + "hermetic substrate inventory", + "reviewed test entry points", + ); + + for (const [filePath, review] of Object.entries( + substrateIntegrationEntryPoints, + )) { + assertCondition( + review.trim().length > 0, + "hermetic substrate inventory", + `${filePath} has no review rationale`, + ); + } + + return violations; +}; + +const violations = checkBrunchArchitecture(); + +if (violations.length > 0) { + throw new UserFriendlyError( + `Brunch architecture check failed:\n${violations + .map((violation) => `- ${violation}`) + .join("\n")}`, + ); +} + +console.log("Brunch architecture checks passed."); diff --git a/tests/brunch-agent-architecture/test/architecture-workspace.test.ts b/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts similarity index 59% rename from tests/brunch-agent-architecture/test/architecture-workspace.test.ts rename to libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts index 040192e3ce8..e48032d7f3e 100644 --- a/tests/brunch-agent-architecture/test/architecture-workspace.test.ts +++ b/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts @@ -5,32 +5,28 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join, relative } from "node:path"; +import path from "node:path"; import { afterAll, describe, expect, test } from "vitest"; -import { - sourceFiles, - testFiles, - type WorkspacePackage, -} from "./architecture-workspace"; +import { sourceFiles, testFiles, type WorkspacePackage } from "./workspace"; describe("the source/test partition is total", () => { // A real directory rather than fakes, because the property under test is the // walk itself: the FE-1400 review's verified escape was a file in a *nested* // test directory (`src/test/`), pruned by the source walk but never collected // by the old top-level-only test walk — governed by nothing. - const dir = mkdtempSync(join(tmpdir(), "brunch-partition-")); - mkdirSync(join(dir, "src/test"), { recursive: true }); - mkdirSync(join(dir, "__tests__")); - writeFileSync(join(dir, "src/index.ts"), "export {};\n"); - writeFileSync(join(dir, "src/test/nested.ts"), "export {};\n"); - writeFileSync(join(dir, "__tests__/top.test.ts"), "export {};\n"); + const dir = mkdtempSync(path.join(tmpdir(), "brunch-partition-")); + mkdirSync(path.join(dir, "src/test"), { recursive: true }); + mkdirSync(path.join(dir, "__tests__")); + writeFileSync(path.join(dir, "src/index.ts"), "export {};\n"); + writeFileSync(path.join(dir, "src/test/nested.ts"), "export {};\n"); + writeFileSync(path.join(dir, "__tests__/top.test.ts"), "export {};\n"); const pkg: WorkspacePackage = { name: "@hashintel/brunch-agent-fixture", - dir: basename(dir), + dir: path.basename(dir), path: dir, - relPath: `packages/${basename(dir)}`, + relPath: `packages/${path.basename(dir)}`, kind: "package", manifest: { name: "@hashintel/brunch-agent-fixture" }, }; @@ -39,12 +35,12 @@ describe("the source/test partition is total", () => { test("every file is exactly one of source or test — nested test dirs included", () => { const source = sourceFiles(pkg) - .map((f) => relative(dir, f.path)) + .map((file) => path.relative(dir, file.path)) .sort(); - const test = testFiles(pkg) - .map((f) => relative(dir, f.path)) + const tests = testFiles(pkg) + .map((file) => path.relative(dir, file.path)) .sort(); expect(source).toEqual(["src/index.ts"]); - expect(test).toEqual(["__tests__/top.test.ts", "src/test/nested.ts"]); + expect(tests).toEqual(["__tests__/top.test.ts", "src/test/nested.ts"]); }); }); diff --git a/tests/brunch-agent-architecture/test/architecture-workspace.ts b/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts similarity index 82% rename from tests/brunch-agent-architecture/test/architecture-workspace.ts rename to libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts index 0bcc7bda2fe..85b36401eae 100644 --- a/tests/brunch-agent-architecture/test/architecture-workspace.ts +++ b/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts @@ -11,17 +11,18 @@ * skipped into a vacuous pass. */ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; -import { fileURLToPath } from "node:url"; - -/** Resolved through `fileURLToPath` — a raw `URL.pathname` is percent-encoded. */ -export const HASH_ROOT = fileURLToPath( - new URL("../../..", import.meta.url), -).replace(/[/\\]$/, ""); -export const CONTEXT_ROOT = join(HASH_ROOT, "libs/@hashintel/brunch-agent"); -const PACKAGES_ROOT = join(CONTEXT_ROOT, "packages"); -const APP_ROOT = join(HASH_ROOT, "apps/brunch-agent"); +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import nodePath from "node:path"; + +import { monorepoRootDirPath } from "../shared/monorepo"; + +export const HASH_ROOT = monorepoRootDirPath; +export const CONTEXT_ROOT = nodePath.join( + HASH_ROOT, + "libs/@hashintel/brunch-agent", +); +const PACKAGES_ROOT = nodePath.join(CONTEXT_ROOT, "packages"); +const APP_ROOT = nodePath.join(HASH_ROOT, "apps/brunch-agent"); export interface WorkspacePackage { /** Package name from its manifest, e.g. `@hashintel/brunch-agent-plugin-gherkin`. */ @@ -46,16 +47,16 @@ export interface PackageManifest { } function directoriesIn(parent: string): string[] { - return readdirSync(parent).filter((entry) => - statSync(join(parent, entry)).isDirectory(), - ); + return readdirSync(parent, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); } export function workspacePackages(): WorkspacePackage[] { const packagePaths = directoriesIn(PACKAGES_ROOT).map((dir) => ({ dir, kind: "package" as const, - path: join(PACKAGES_ROOT, dir), + path: nodePath.join(PACKAGES_ROOT, dir), })); const workspacePaths = [ ...packagePaths, @@ -63,10 +64,10 @@ export function workspacePackages(): WorkspacePackage[] { ]; return workspacePaths.map(({ dir, kind, path }) => { - const manifestPath = join(path, "package.json"); + const manifestPath = nodePath.join(path, "package.json"); if (!existsSync(manifestPath)) { throw new Error( - `${relative(HASH_ROOT, path)} has no package.json — every Brunch workspace needs one.`, + `${nodePath.relative(HASH_ROOT, path)} has no package.json — every Brunch workspace needs one.`, ); } const manifest = JSON.parse( @@ -76,7 +77,7 @@ export function workspacePackages(): WorkspacePackage[] { name: manifest.name, dir, path, - relPath: relative(HASH_ROOT, path).replaceAll("\\", "/"), + relPath: nodePath.relative(HASH_ROOT, path).replaceAll("\\", "/"), kind, manifest, }; @@ -112,18 +113,23 @@ export function filesIn( dir: string, skip: readonly string[] = SKIP_DIRECTORIES, ): SourceFile[] { - if (!existsSync(dir)) return []; + if (!existsSync(dir)) { + return []; + } const skipped = new Set(skip); const found: SourceFile[] = []; const walk = (current: string): void => { - for (const entry of readdirSync(current)) { - if (skipped.has(entry)) continue; - const path = join(current, entry); - if (statSync(path).isDirectory()) walk(path); - else if (SOURCE_EXTENSIONS.test(entry)) { + for (const entry of readdirSync(current, { withFileTypes: true })) { + if (skipped.has(entry.name)) { + continue; + } + const path = nodePath.join(current, entry.name); + if (entry.isDirectory()) { + walk(path); + } else if (SOURCE_EXTENSIONS.test(entry.name)) { found.push({ path, - relPath: relative(HASH_ROOT, path).replaceAll("\\", "/"), + relPath: nodePath.relative(HASH_ROOT, path).replaceAll("\\", "/"), text: readFileSync(path, "utf8"), }); } @@ -149,7 +155,7 @@ function partitionedFiles(pkg: WorkspacePackage): { const source: SourceFile[] = []; const test: SourceFile[] = []; for (const file of filesIn(pkg.path)) { - const segments = relative(pkg.path, file.path).split(/[/\\]/); + const segments = nodePath.relative(pkg.path, file.path).split(/[/\\]/); (segments.some((segment) => TEST_DIRECTORIES.has(segment)) ? test : source @@ -201,7 +207,9 @@ export function importedModules(file: SourceFile): string[] { ]; for (const pattern of patterns) { for (const match of file.text.matchAll(pattern)) { - if (match[1]) specifiers.add(match[1]); + if (match[1]) { + specifiers.add(match[1]); + } } } return [...specifiers]; diff --git a/libs/@local/repo-chores/node/turbo.json b/libs/@local/repo-chores/node/turbo.json index 86bcf486428..61923ca7a0a 100644 --- a/libs/@local/repo-chores/node/turbo.json +++ b/libs/@local/repo-chores/node/turbo.json @@ -1,6 +1,17 @@ { "extends": ["//"], "tasks": { + "lint:brunch-architecture": { + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/apps/brunch-agent/package.json", + "$TURBO_ROOT$/apps/brunch-agent/**/*.{ts,tsx,mts,mjs,js,jsx}", + "!$TURBO_ROOT$/apps/brunch-agent/{dist,node_modules,.flue,.turbo}/**", + "$TURBO_ROOT$/libs/@hashintel/brunch-agent/packages/*/package.json", + "$TURBO_ROOT$/libs/@hashintel/brunch-agent/packages/**/*.{ts,tsx,mts,mjs,js,jsx}", + "!$TURBO_ROOT$/libs/@hashintel/brunch-agent/packages/**/{dist,node_modules,.flue,.turbo}/**" + ] + }, "test:unit": { "dependsOn": ["^build"] } diff --git a/tests/brunch-agent-architecture/.oxlintrc.json b/tests/brunch-agent-architecture/.oxlintrc.json deleted file mode 100644 index 190f0c5737d..00000000000 --- a/tests/brunch-agent-architecture/.oxlintrc.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "../../node_modules/oxlint/configuration_schema.json", - "extends": ["../../.config/oxlint/brunch/base.json"], - "categories": { - "correctness": "error", - "perf": "warn" - }, - "options": { - "typeAware": true, - "typeCheck": true - }, - "env": { - "builtin": true, - "es2026": true, - "node": true - }, - "ignorePatterns": ["coverage/**", "*.tsbuildinfo", ".turbo/**"] -} diff --git a/tests/brunch-agent-architecture/LICENSE.md b/tests/brunch-agent-architecture/LICENSE.md deleted file mode 100644 index 4deff4ef955..00000000000 --- a/tests/brunch-agent-architecture/LICENSE.md +++ /dev/null @@ -1,5 +0,0 @@ -GNU Affero General Public License v3.0 -===================================== - -This workspace is licensed under the GNU Affero General Public License v3.0. -See the [repository license](../../LICENSE.md) for the complete terms. diff --git a/tests/brunch-agent-architecture/docs/task-dependencies.json b/tests/brunch-agent-architecture/docs/task-dependencies.json deleted file mode 100644 index e0d4554d771..00000000000 --- a/tests/brunch-agent-architecture/docs/task-dependencies.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "package": "@tests/brunch-agent-architecture", - "dependencies": [ - "@apps/brunch-agent", - "@hashintel/brunch-agent", - "@hashintel/brunch-agent-binding-flue", - "@hashintel/brunch-agent-plugin-dafny", - "@hashintel/brunch-agent-plugin-gherkin", - "@hashintel/brunch-agent-plugin-sdcpn", - "@hashintel/brunch-agent-transport-aisdk" - ], - "tasks": { - "fix:eslint": [], - "lint:eslint": [], - "lint:tsc": [], - "test:unit": [] - } -} diff --git a/tests/brunch-agent-architecture/package.json b/tests/brunch-agent-architecture/package.json deleted file mode 100644 index 67a1a5de336..00000000000 --- a/tests/brunch-agent-architecture/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@tests/brunch-agent-architecture", - "version": "0.0.0-private", - "private": true, - "description": "Repository-level architecture checks for the Brunch workspace family.", - "license": "AGPL-3.0", - "type": "module", - "scripts": { - "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", - "lint:tsc": "tsgo --noEmit", - "test:unit": "vitest run" - }, - "devDependencies": { - "@apps/brunch-agent": "workspace:*", - "@hashintel/brunch-agent": "workspace:*", - "@hashintel/brunch-agent-binding-flue": "workspace:*", - "@hashintel/brunch-agent-plugin-dafny": "workspace:*", - "@hashintel/brunch-agent-plugin-gherkin": "workspace:*", - "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*", - "@hashintel/brunch-agent-transport-aisdk": "workspace:*", - "@types/node": "22.18.13", - "@typescript/native-preview": "7.0.0-dev.20260511.1", - "oxlint": "1.63.0", - "oxlint-tsgolint": "0.22.1", - "vitest": "4.1.10" - } -} diff --git a/tests/brunch-agent-architecture/test/architecture.test.ts b/tests/brunch-agent-architecture/test/architecture.test.ts deleted file mode 100644 index d56a9b94a3a..00000000000 --- a/tests/brunch-agent-architecture/test/architecture.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -/** - * The architectural boundaries, as tests rather than as documentation. - * - * Spec §4 and §12.2 state the dependency direction as invariants; an invariant - * nobody can run is a wish. These are the mechanical checks — they read the - * real tree, so a package added later is governed without opting in. - */ - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, test } from "vitest"; - -import { - allDependencies, - importedPackages, - MODEL_KEY_NAME, - packageOf, - runtimeDependencies, - sourceFiles, - testFiles, - workspacePackages, - type PackageManifest, - type WorkspacePackage, -} from "./architecture-workspace"; - -const PACKAGES = workspacePackages(); -const CHECKER_MANIFEST = JSON.parse( - readFileSync( - fileURLToPath(new URL("../package.json", import.meta.url)), - "utf8", - ), -) as PackageManifest; - -const CORE = "@hashintel/brunch-agent"; -/** Flue is the selected agent runtime; lower-level Pi packages remain binding/test concerns. */ -const SUBSTRATE_SCOPES = ["@flue/", "@earendil-works/"]; -const FLUE_RUNTIME = "@flue/runtime"; - -const isSubstrate = (name: string): boolean => - SUBSTRATE_SCOPES.some((scope) => name.startsWith(scope)); -const byRole = (role: string): WorkspacePackage[] => - PACKAGES.filter((pkg) => pkg.dir.startsWith(`${role}-`)); - -test("every package is actually scanned", () => { - // Without this, a package the file walker misses passes every file-level - // invariant vacuously — the substrate-import ban, the plugin-resolves-core - // rule, and the schema-library ban would all iterate an empty list and go - // green. A silent exemption is worse than no check at all. - for (const pkg of PACKAGES) { - expect({ pkg: pkg.relPath, scanned: sourceFiles(pkg).length > 0 }).toEqual({ - pkg: pkg.relPath, - scanned: true, - }); - } -}); - -test("the checker depends on every governed workspace", () => { - const governedDependencies = Object.keys( - CHECKER_MANIFEST.devDependencies ?? {}, - ) - .filter( - (dependency) => - dependency === "@apps/brunch-agent" || - dependency.startsWith("@hashintel/brunch-agent"), - ) - .sort(); - expect(governedDependencies).toEqual(PACKAGES.map(({ name }) => name).sort()); -}); - -describe("role prefixes name what a package is architecturally (spec §12.2)", () => { - test("every package under packages/ is core or carries a role prefix", () => { - for (const pkg of PACKAGES.filter((p) => p.kind === "package")) { - expect(pkg.dir).toMatch( - /^(core|plugin-[a-z0-9-]+|binding-[a-z0-9-]+|transport-[a-z0-9-]+)$/, - ); - } - }); - - test("no package uses an avoided role noun", () => { - // The glossary's own noun is `binding`; `adapter-*` and `wrapper-*` are - // avoided terms, and `elicit-*` names function rather than identity. - for (const pkg of PACKAGES) { - expect(pkg.dir).not.toMatch(/^(adapter|wrapper|elicit)-/); - } - }); - - test("the manifest name matches the role-prefixed directory", () => { - for (const pkg of PACKAGES) { - const expectedName = - pkg.kind === "app" - ? "@apps/brunch-agent" - : pkg.dir === "core" - ? CORE - : `@hashintel/brunch-agent-${pkg.dir}`; - expect(pkg.name).toBe(expectedName); - } - }); -}); - -describe("dependency direction", () => { - test("core's only agent-runtime dependency is Flue", () => { - const core = PACKAGES.find((pkg) => pkg.name === CORE); - expect(core).toBeDefined(); - expect(allDependencies(core!).filter(isSubstrate)).toEqual([FLUE_RUNTIME]); - for (const file of sourceFiles(core!)) { - const substrateImports = importedPackages(file).filter((specifier) => - isSubstrate(packageOf(specifier)), - ); - expect({ file: file.relPath, substrateImports }).toEqual({ - file: file.relPath, - substrateImports: - file.relPath.endsWith("/src/flue.ts") || - file.relPath.endsWith("/src/skills/skill-markdown.ts") - ? [FLUE_RUNTIME] - : [], - }); - } - }); - - test("the harness depends on no binding and no plugin", () => { - const core = PACKAGES.find((pkg) => pkg.name === CORE)!; - for (const dependency of runtimeDependencies(core)) { - expect(dependency).not.toMatch( - /^@hashintel\/brunch-agent-(binding|plugin)-/u, - ); - } - }); - - test("plugins depend inward on core and may contribute through Flue directly", () => { - const plugins = byRole("plugin"); - expect(plugins.length).toBeGreaterThan(0); - for (const plugin of plugins) { - const workspaceDeps = runtimeDependencies(plugin).filter((dependency) => - dependency.startsWith("@hashintel/brunch-agent"), - ); - expect(workspaceDeps).toEqual([CORE]); - expect( - allDependencies(plugin).filter( - (dependency) => - isSubstrate(dependency) && dependency !== FLUE_RUNTIME, - ), - ).toEqual([]); - - for (const file of sourceFiles(plugin)) { - for (const specifier of importedPackages(file)) { - const pkg = packageOf(specifier); - expect(isSubstrate(pkg) && pkg !== FLUE_RUNTIME).toBe(false); - expect(pkg.startsWith("@hashintel/brunch-agent") ? pkg : CORE).toBe( - CORE, - ); - expect(specifier).not.toBe(`${CORE}/storage`); - } - } - } - }); - - test("a binding imports both", () => { - const bindings = byRole("binding"); - expect(bindings.length).toBeGreaterThan(0); - for (const binding of bindings) { - const deps = runtimeDependencies(binding); - expect(deps).toContain(CORE); - expect(deps.some(isSubstrate)).toBe(true); - } - }); - - test("bindings depend on no plugin — the harness discovers plugins, not the substrate", () => { - for (const binding of byRole("binding")) { - for (const dependency of runtimeDependencies(binding)) { - expect(dependency).not.toMatch(/^@hashintel\/brunch-agent-plugin-/u); - } - } - }); - - test("transports consume their wire encoder and the public Flue client only — never core, a binding, or the runtime", () => { - const transports = byRole("transport"); - expect(transports.length).toBeGreaterThan(0); - for (const transport of transports) { - expect(runtimeDependencies(transport).sort()).toEqual([ - "@flue/sdk", - "ai", - ]); - for (const file of sourceFiles(transport).filter((file) => - file.path.startsWith(join(transport.path, "src")), - )) { - for (const specifier of importedPackages(file)) { - if (specifier.startsWith("node:")) continue; - expect(["@flue/sdk", "ai"]).toContain(packageOf(specifier)); - } - } - } - }); -}); - -describe("the direction is enforced under HASH's linker", () => { - // HASH may hoist dependencies, so physical resolution is not an authority: - // an undeclared package can be resolvable by accident. Runtime manifests and - // authored imports are the stable boundary surfaces. - test("every imported Brunch workspace is a declared runtime dependency", () => { - for (const pkg of PACKAGES) { - const declared = runtimeDependencies(pkg); - for (const file of sourceFiles(pkg)) { - const importedWorkspaces = importedPackages(file) - .map(packageOf) - .filter( - (imported) => - imported === CORE || - imported.startsWith("@hashintel/brunch-agent-"), - ); - for (const imported of importedWorkspaces) { - expect({ - file: file.relPath, - imported, - declared: declared.includes(imported), - }).toEqual({ - file: file.relPath, - imported, - declared: true, - }); - } - } - } - }); -}); - -describe("Valibot is the schema library at every boundary (spec §12.4)", () => { - // Flue locks Valibot at every boundary. A Standard-Schema waist would buy - // comfort at the cost of a conversion seam that can silently drop - // constraints — the silent-coercion smell. - const OTHER_SCHEMA_LIBRARIES = [ - "zod", - "yup", - "joi", - "ajv", - "superstruct", - "arktype", - "io-ts", - "runtypes", - "@sinclair/typebox", - "@standard-schema/spec", - "@standard-schema/utils", - ]; - - test("no package declares another schema library", () => { - for (const pkg of PACKAGES) { - for (const forbidden of OTHER_SCHEMA_LIBRARIES) { - expect(allDependencies(pkg)).not.toContain(forbidden); - } - } - }); - - test("no source file imports another schema library", () => { - for (const pkg of PACKAGES) { - for (const file of sourceFiles(pkg)) { - for (const specifier of importedPackages(file)) { - expect(OTHER_SCHEMA_LIBRARIES).not.toContain(packageOf(specifier)); - } - } - } - }); - - test("a package declares valibot exactly when its source imports it", () => { - for (const pkg of PACKAGES) { - const importsValibot = sourceFiles(pkg).some((file) => - importedPackages(file).some( - (specifier) => packageOf(specifier) === "valibot", - ), - ); - const declaresValibot = allDependencies(pkg).includes("valibot"); - expect({ pkg: pkg.relPath, declaresValibot }).toEqual({ - pkg: pkg.relPath, - declaresValibot: importsValibot, - }); - } - }); -}); - -describe("recorded Flue constraints hold by construction (spec §10)", () => { - test("vite is pinned to 8, which @flue/vite requires", () => { - const pinned = PACKAGES.flatMap((pkg) => { - const range = - pkg.manifest.devDependencies?.vite ?? pkg.manifest.dependencies?.vite; - return range ? [range] : []; - }); - expect(pinned.length).toBeGreaterThan(0); - for (const range of pinned) expect(range).toMatch(/^\^?8(\.|$)/); - }); - - test("runtime entrypoints never select Flue's Bun adapter", () => { - for (const pkg of PACKAGES) { - for (const file of [...sourceFiles(pkg), ...testFiles(pkg)]) { - expect(importedPackages(file)).not.toContain("@flue/runtime/bun"); - } - } - }); -}); - -describe("core auxiliary subpaths stay in their assigned lanes", () => { - test("core exposes Flue composition, browser, storage, and workpiece contracts as explicit subpaths", () => { - const core = PACKAGES.find((pkg) => pkg.name === CORE)!; - expect(Object.keys(core.manifest.exports ?? {})).toEqual([ - ".", - "./client-tools", - "./flue", - "./question-marker", - "./storage", - "./workpiece", - ]); - }); - - test("only bindings import core/storage", () => { - for (const pkg of PACKAGES.filter( - (candidate) => !candidate.dir.startsWith("binding-"), - )) { - for (const file of sourceFiles(pkg)) { - expect(importedPackages(file)).not.toContain(`${CORE}/storage`); - } - } - }); -}); - -describe("the HASH smoke is runnable without a model key or a network (spec §12.5)", () => { - test("every Brunch workspace exposes HASH lint, typecheck, and unit-test tasks", () => { - for (const pkg of PACKAGES) { - expect(typeof pkg.manifest.scripts?.["lint:eslint"]).toBe("string"); - expect(typeof pkg.manifest.scripts?.["lint:tsc"]).toBe("string"); - expect(pkg.manifest.scripts?.["test:unit"]).toContain("vitest run"); - } - }); - - /** Every Brunch test file Turbo can reach through the workspace tasks. */ - const suite = PACKAGES.flatMap((pkg) => testFiles(pkg)); - - /** - * The test files permitted to import a substrate package, each reviewed once - * and recorded here with what makes it hermetic. - * - * Declared data, not a claim a file makes about itself. The previous gate - * admitted any `*.integration.ts` containing the string - * `hermetic-substrate-test: faux-provider`, so any new file granted itself - * substrate access by copying a comment — the FE-1389 deep read's finding. A - * path enters here by review only. - */ - const SUBSTRATE_INTEGRATION_ENTRY_POINTS: Readonly> = { - "libs/@hashintel/brunch-agent/packages/core/test/question-marker.test.ts": - "Types the Flue logger and calls the core marker tool with a mocked data-part writer and logger; no runtime boot, provider, key or socket.", - "apps/brunch-agent/test/brunch-turn.test.ts": - "Types Flue's client, admission, and conversation snapshot and constructs FlueExecutionError so the persona bridge can be unit-tested against a stubbed client — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/flue-transcript.test.ts": - "Types Flue's public conversation snapshot so the transcript projector can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/petrinaut-chat.integration.ts": - "Boots the plain Flue chat agent on Flue's node runtime with pi-ai's faux provider, drives the browser ChatTransport against the mounted Flue route over app.fetch, and proves streamed reasoning/text, server tools, client-tool resume, SDK history ownership, SQLite restart, and harness-side idempotent apply-sweep — no provider key, no socket, no extraction model call. Run as a child process by petrinaut-chat.test.ts.", - "apps/brunch-agent/test/prepared-workpiece.integration.ts": - "Boots the built Flue ChatAgent with pi-ai's faux provider, creates a prepared fixture through one tagged public signal with fixture-scoped initial data, retries its deterministic idempotency key, and proves prepared/model workpiece selection from canonical history — no provider key, socket, or network model call.", - "apps/brunch-agent/test/proof-artifacts.test.ts": - "Types Flue's public conversation snapshot so canonical trace derivation, workpiece binding, and atomic evidence retention can be unit-tested against an in-memory fixture — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/runbook-artifacts.test.ts": - "Types Flue's public conversation snapshot so runbook artifact recovery can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/runbook-elicitation-faux-provider.ts": - "Defines the scripted pi-ai faux provider loaded only by the hermetic prospective-runner test — no provider key, no socket, and no network model call.", - "apps/brunch-agent/test/runbook-headless.integration.ts": - "Boots the built Flue ChatAgent with pi-ai's faux provider and a headless Petrinaut client to prove validated construct-only tool flow without a provider key, socket, or network model call.", - "apps/brunch-agent/test/telemetry.test.ts": - "Constructs Flue's content-free OpenTelemetry instrumentation with an injected exporter setup to prove disposal order; it registers no global instrumentation, opens no socket, and makes no provider call.", - "apps/brunch-agent/test/workpiece.test.ts": - "Types Flue's public conversation snapshot so the substrate-neutral workpiece selector and app-owned SHA-256 projection can be unit-tested against in-memory messages — no provider key, no socket, no model call, no runtime boot.", - "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts": - "Types a stubbed public Flue client and stream chunks to prove finite AI SDK projection and client-tool signal admission — no runtime boot, provider key, socket, or model call.", - "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts": - "Types Flue's public conversation snapshot so history can be projected into AI SDK messages without a runtime boot, provider key, socket, or model call.", - "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ui-stream.test.ts": - "Types Flue conversation-stream chunks so the finite AI SDK projector can be unit-tested without a runtime boot, provider key, socket, or model call.", - }; - - test("no test file carries a live model credential", () => { - // The spec names an optional secret-gated real-model `flue run` smoke; it - // is deliberately not part of this run, and this is what stops it drifting - // in unnoticed. - expect(suite.length).toBeGreaterThan(0); - // Composed in architecture-workspace.ts rather than written literally, so - // this check does not flag its own source or the pattern's. - const modelKey = new RegExp(MODEL_KEY_NAME, "g"); - for (const file of suite) { - expect({ - file: file.relPath, - keys: file.text.match(modelKey) ?? [], - }).toEqual({ - file: file.relPath, - keys: [], - }); - } - }); - - test("the substrate is imported by exactly the reviewed entry points", () => { - // Set equality in both directions. An unlisted importer is substrate - // access nobody reviewed; a listed file that no longer imports the - // substrate is a permission standing for nothing, which is how an - // inventory stops describing the tree it governs. - const importers = suite - .filter((file) => - importedPackages(file).some((s) => isSubstrate(packageOf(s))), - ) - .map((file) => file.relPath) - .sort(); - expect(importers).toEqual( - Object.keys(SUBSTRATE_INTEGRATION_ENTRY_POINTS).sort(), - ); - }); - - test("every reviewed entry point records what makes it hermetic", () => { - for (const [path, review] of Object.entries( - SUBSTRATE_INTEGRATION_ENTRY_POINTS, - )) { - expect({ path, reviewed: review.trim().length > 0 }).toEqual({ - path, - reviewed: true, - }); - } - }); -}); diff --git a/tests/brunch-agent-architecture/tsconfig.json b/tests/brunch-agent-architecture/tsconfig.json deleted file mode 100644 index 91c0afd5d23..00000000000 --- a/tests/brunch-agent-architecture/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "es2024", - "lib": ["ESNext"], - "types": ["node"], - "module": "preserve", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "resolveJsonModule": true, - "noEmit": true, - "skipLibCheck": true, - "isolatedModules": true - }, - "include": ["test"] -} diff --git a/tests/brunch-agent-architecture/turbo.json b/tests/brunch-agent-architecture/turbo.json deleted file mode 100644 index 5daa8e68e15..00000000000 --- a/tests/brunch-agent-architecture/turbo.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": ["//"], - "tasks": { - "fix:eslint": { - "dependsOn": [] - }, - "lint:eslint": { - "dependsOn": [] - }, - "lint:tsc": { - "dependsOn": [] - } - } -} diff --git a/turbo.json b/turbo.json index bd646ec6d78..09443512d62 100644 --- a/turbo.json +++ b/turbo.json @@ -110,6 +110,7 @@ "env": ["GITHUB_EVENT_NAME"], "dependsOn": ["^manifest"] }, + "lint:brunch-architecture": {}, // Type-aware linting resolves workspace imports from the dependencies' dist. "lint:eslint": { "env": ["CHECK_TEMPORARILY_DISABLED_RULES"], diff --git a/yarn.config.cjs b/yarn.config.cjs index ddf8ce32b33..151062f51c1 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -39,8 +39,6 @@ const ignoredWorkspaces = []; const allowedGitDependencies = []; const brunchAgentCore = "@hashintel/brunch-agent"; -const brunchAgentApp = "@apps/brunch-agent"; -const brunchArchitectureChecker = "@tests/brunch-agent-architecture"; const brunchAgentTransportPrefix = `${brunchAgentCore}-transport-`; const brunchSubstrateScopes = ["@earendil-works/", "@flue/"]; @@ -348,32 +346,6 @@ function enforceBrunchTransportBoundary({ Yarn }) { } } -/** - * Keeps every governed Brunch workspace upstream of the architecture checker. - * - * This constraint runs against the complete repository graph, so adding a new - * Brunch workspace cannot bypass the checker by being absent from its prune. - * - * @param {Context} context - The Yarn constraint context. - */ -function enforceBrunchArchitectureCheckerCoverage({ Yarn }) { - const checker = Yarn.workspace({ ident: brunchArchitectureChecker }); - - if (checker === null) { - throw new Error(`missing workspace ${brunchArchitectureChecker}`); - } - - for (const workspace of Yarn.workspaces()) { - if ( - workspace.ident === brunchAgentApp || - workspace.ident === brunchAgentCore || - workspace.ident?.startsWith(`${brunchAgentCore}-`) - ) { - checker.set(`devDependencies.${workspace.ident}`, "workspace:*"); - } - } -} - module.exports = defineConfig({ async constraints(context) { enforceConsistentDependenciesAcrossTheProject(context); @@ -382,6 +354,5 @@ module.exports = defineConfig({ enforceDevDependenciesAreProperlyDeclared(context); enforceNoInstallScripts(context); enforceBrunchTransportBoundary(context); - enforceBrunchArchitectureCheckerCoverage(context); }, }); diff --git a/yarn.lock b/yarn.lock index 5c89ebf1626..7d4c19bdd98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -430,7 +430,7 @@ __metadata: languageName: node linkType: hard -"@apps/brunch-agent@workspace:*, @apps/brunch-agent@workspace:apps/brunch-agent": +"@apps/brunch-agent@workspace:apps/brunch-agent": version: 0.0.0-use.local resolution: "@apps/brunch-agent@workspace:apps/brunch-agent" dependencies: @@ -7526,7 +7526,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/brunch-agent-plugin-dafny@workspace:*, @hashintel/brunch-agent-plugin-dafny@workspace:libs/@hashintel/brunch-agent/packages/plugin-dafny": +"@hashintel/brunch-agent-plugin-dafny@workspace:libs/@hashintel/brunch-agent/packages/plugin-dafny": version: 0.0.0-use.local resolution: "@hashintel/brunch-agent-plugin-dafny@workspace:libs/@hashintel/brunch-agent/packages/plugin-dafny" dependencies: @@ -7541,7 +7541,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/brunch-agent-plugin-gherkin@workspace:*, @hashintel/brunch-agent-plugin-gherkin@workspace:libs/@hashintel/brunch-agent/packages/plugin-gherkin": +"@hashintel/brunch-agent-plugin-gherkin@workspace:libs/@hashintel/brunch-agent/packages/plugin-gherkin": version: 0.0.0-use.local resolution: "@hashintel/brunch-agent-plugin-gherkin@workspace:libs/@hashintel/brunch-agent/packages/plugin-gherkin" dependencies: @@ -17778,25 +17778,6 @@ __metadata: languageName: node linkType: hard -"@tests/brunch-agent-architecture@workspace:tests/brunch-agent-architecture": - version: 0.0.0-use.local - resolution: "@tests/brunch-agent-architecture@workspace:tests/brunch-agent-architecture" - dependencies: - "@apps/brunch-agent": "workspace:*" - "@hashintel/brunch-agent": "workspace:*" - "@hashintel/brunch-agent-binding-flue": "workspace:*" - "@hashintel/brunch-agent-plugin-dafny": "workspace:*" - "@hashintel/brunch-agent-plugin-gherkin": "workspace:*" - "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*" - "@hashintel/brunch-agent-transport-aisdk": "workspace:*" - "@types/node": "npm:22.18.13" - "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" - oxlint: "npm:1.63.0" - oxlint-tsgolint: "npm:0.22.1" - vitest: "npm:4.1.10" - languageName: unknown - linkType: soft - "@tests/hash-backend-integration@workspace:tests/hash-backend-integration": version: 0.0.0-use.local resolution: "@tests/hash-backend-integration@workspace:tests/hash-backend-integration" From d8b605cafda62d720df3addc78fe178c1a3bd278 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 16:47:14 +0200 Subject: [PATCH 08/10] Remove obsolete architecture prune fixture Co-authored-by: Cursor --- .../actions/prune-repository/prune_test.py | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/.github/actions/prune-repository/prune_test.py b/.github/actions/prune-repository/prune_test.py index c58730ee926..d1141eb0ea3 100644 --- a/.github/actions/prune-repository/prune_test.py +++ b/.github/actions/prune-repository/prune_test.py @@ -15,11 +15,6 @@ CORE = "@hashintel/brunch-agent" TRANSPORT = "@hashintel/brunch-agent-transport-aisdk" APP = "@apps/brunch-agent" -ARCHITECTURE = "@tests/brunch-agent-architecture" -BINDING_FLUE = "@hashintel/brunch-agent-binding-flue" -PLUGIN_DAFNY = "@hashintel/brunch-agent-plugin-dafny" -PLUGIN_GHERKIN = "@hashintel/brunch-agent-plugin-gherkin" -PLUGIN_SDCPN = "@hashintel/brunch-agent-plugin-sdcpn" WEBSITE = "@apps/petrinaut-website" FRONTEND = "@apps/hash-frontend" PLAYWRIGHT = "@tests/hash-playwright" @@ -48,33 +43,6 @@ def test_frontend_transitive(self) -> None: class BrunchRequestedExtras(unittest.TestCase): - def test_architecture_workspace_owns_the_complete_brunch_family(self) -> None: - expected_workspaces = frozenset( - { - APP, - CORE, - BINDING_FLUE, - PLUGIN_DAFNY, - PLUGIN_GHERKIN, - PLUGIN_SDCPN, - TRANSPORT, - } - ) - expanded = fixpoint_expand( - {ARCHITECTURE}, - { - ARCHITECTURE: expected_workspaces, - APP: frozenset({CORE, PLUGIN_SDCPN, TRANSPORT, BINDING_FLUE}), - BINDING_FLUE: frozenset({CORE}), - PLUGIN_DAFNY: frozenset({CORE}), - PLUGIN_GHERKIN: frozenset({CORE}), - PLUGIN_SDCPN: frozenset({CORE}), - TRANSPORT: frozenset(), - }, - ) - self.assertTrue(expected_workspaces.issubset(expanded)) - self.assertEqual(extras_for_requested({ARCHITECTURE}), frozenset()) - def test_app_keeps_only_its_non_workspace_product_inputs(self) -> None: self.assertEqual( extra_paths_for_requested({APP}), From 83c46716ffcfb0dc130c3a9ce8f17029c006c3b8 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 17:21:06 +0200 Subject: [PATCH 09/10] Reorient checks to native enforcement Co-authored-by: Cursor --- libs/@hashintel/brunch-agent/MISSION.md | 120 ++++++++++++------------ 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/libs/@hashintel/brunch-agent/MISSION.md b/libs/@hashintel/brunch-agent/MISSION.md index b4063372a90..b463d16025a 100644 --- a/libs/@hashintel/brunch-agent/MISSION.md +++ b/libs/@hashintel/brunch-agent/MISSION.md @@ -8,67 +8,69 @@ on `ln/sre-1010-move-brunch-checks`. This file is the branch's sole execution au ## Imperative -Move Brunch family-wide architecture conventions into repo-chores as a dedicated lint task, so -ordinary core and app unit-test tasks own only their local contracts. Do this now because the -SRE-1007 hotfix modeled sibling packages and the app as inputs to core, creating a reverse -`libs -> apps` edge and requiring downstream trees in a core prune. +Remove static-policy source walkers from Brunch unit tests and let each existing enforcement +mechanism own the contract it can prove: Oxlint owns authored imports, Yarn owns manifests, and +behavioral tests own emitted behavior. Do this now because the SRE-1007 hotfix modeled sibling +packages and the app as inputs to core, creating a reverse `libs -> apps` edge and requiring +downstream trees in a core prune. ## Throughline ```text -change in apps/brunch-agent or a Brunch package -→ the checker-owned Turbo inputs invalidate @local/repo-chores#lint:brunch-architecture -→ the dedicated Global lint step runs the repo-chores command against the full checkout -→ a package-family boundary violation fails as a repository lint error +authored dependency-boundary violation +→ the owning workspace's package-local Oxlint configuration +→ an AST-backed lint diagnostic in the existing package lint task + +forbidden manifest dependency +→ the repository's Yarn constraints +→ install and Global constraint lint fail change in apps/brunch-agent → @apps/brunch-agent#test:unit -→ app-local agent-module and emitted-artifact checks +→ the built artifact must contain every declared agent registration change in Brunch core → @hashintel/brunch-agent#test:unit -→ core-local tests, without app or sibling-package inputs +→ core-local tests and utilities, without context-root, app, or sibling-package inputs ``` -The repo-chores task owns the explicit Brunch-family inputs because it is the repository-level -consumer of those files. It runs in the unpruned Global lint job; do not make governed workspaces -depend on the checker or recreate the old reverse edge at core. +No replacement family scanner is introduced. Assertions that restate package manifests, filenames, +source strings, review inventories, or tool configuration are deleted when the existing tool, +compiler, build, or review is already authoritative. ## Proof -This mission establishes task ownership, affected-task selection, and prune closure for the -existing Brunch architecture checks. It does **not** redesign the architecture rules, broaden -their policy, or replace Yarn and package-local lint enforcement. - -1. **App checks are app-local.** The agent directive, pinned identity, app-entrypoint, and emitted - artifact checks pass from `@apps/brunch-agent`, and its architecture helper reads no sibling - package. Oracle: `yarn workspace @apps/brunch-agent test:unit`. -2. **Family checks have one repository owner.** Package naming, dependency direction, schema, - import, runtime, export-lane, and hermetic-test inventory checks pass from the repo-chores - `check-brunch-architecture` command. Oracle: - `yarn workspace @local/repo-chores lint:brunch-architecture`. -3. **Relevant changes invalidate the family check.** The repo-chores Turbo task hashes the governed - app and package manifests, authored source, and tests while excluding generated and dependency - directories. Oracle: focused Turbo dry-run fixtures recorded in the implementation verification. -4. **A core prune owns no downstream Brunch tree.** The core task has no app or sibling-package - input glob, and requesting only core no longer adds the app or plugins through prune - exceptions. Oracle: `.github/actions/prune-repository/prune_test.py` plus inspection of +This mission establishes native enforcement ownership and prune closure. It does **not** add a new +policy surface merely to preserve every historical assertion. + +1. **Import boundaries use existing lint infrastructure.** Each Brunch workspace's existing + `no-restricted-imports` policy catches forbidden authored imports using Oxlint's parser. Oracle: + representative negative lint probes in the owning packages. +2. **Manifest boundaries use existing repository infrastructure.** The Brunch transport Yarn + constraint continues to reject forbidden runtime edges. Oracle: `yarn constraints` and a + representative negative manifest probe. +3. **Agent registration is behavioral.** The app build-artifact test proves every declared agent + reaches the emitted registration bundle; source-walking directive and filename checks are + removed. Oracle: `yarn workspace @apps/brunch-agent test:unit`. +4. **A core prune owns no downstream or context-root Brunch tree.** The core task has no app, + sibling-package, or context-root input glob, and requesting only core no longer adds them through + prune exceptions. The Linear graph utility and its tests are co-located in core. Oracle: + `.github/actions/prune-repository/prune_test.py` plus inspection of `turbo run test:unit --filter @hashintel/brunch-agent --dry=json`. -5. **The new command is independently healthy.** The command, its scanner tests, repo-chores - typecheck and lint, and relevant package graph checks pass. Oracle: focused repo-chores - test/lint/typecheck commands and `yarn constraints`. +5. **No bespoke checker remains.** The temporary architecture workspace and repo-chores command, + task, scanner, CI step, and coverage constraint are absent. Oracle: repository search plus + relevant package lint, typecheck, tests, constraints, and formatting. ## Constraints -- Preserve the behavior and failure coverage of every existing architecture assertion while - splitting app-only checks from repository-family lint. -- Keep Yarn constraints and package-local Oxlint rules with their current owning packages. -- Keep checker inputs on the repo-chores task; never make a governed workspace depend on the - checker. -- Keep core's context-root script coverage only as narrowly hashed inputs that it genuinely reads; - do not use the Brunch-family glob. -- Run the family check as its own step in the existing Global lint job, where the complete - repository is available without prune exceptions. +- Keep Yarn constraints and package-local Oxlint rules with their current owning packages; do not + duplicate them in tests or repo-chores. +- Preserve behavioral tests that can fail while source and manifests remain unchanged. +- Delete static assertions whose only oracle is a hard-coded mirror of source, package metadata, + review inventory, or file layout. +- Move the Linear graph utility into core so its real unit tests use static imports and never skip + based on checkout shape. +- Never make a governed workspace depend on a checker or recreate a family-wide source walker. - No implementation begins until this authority cut is committed separately. Material changes to this contract require owner review and another focused authority commit. @@ -76,34 +78,36 @@ their policy, or replace Yarn and package-local lint enforcement. ```text ~ libs/@hashintel/brunch-agent/MISSION.md -~ apps/brunch-agent/test/architecture/* -~ libs/@local/repo-chores/node/scripts/check-brunch-architecture* -~ libs/@local/repo-chores/node/package.json -~ libs/@local/repo-chores/node/turbo.json +~ apps/brunch-agent/test/build-artifact.test.ts +- apps/brunch-agent/test/architecture/* +> libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts + -> libs/@hashintel/brunch-agent/packages/core/src/linear-project-graph.ts +~ libs/@hashintel/brunch-agent/packages/core/test/architecture/linear-project-graph.test.ts +- libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts +- libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps* +~ libs/@hashintel/brunch-agent/packages/core/package.json ~ libs/@hashintel/brunch-agent/packages/core/turbo.json ~ apps/brunch-agent/turbo.json ~ .github/actions/prune-repository/prune.py ~ .github/actions/prune-repository/prune_test.py -~ .github/workflows/lint.yml ~ yarn.lock ``` ## Fog-line -- Turbo's task inputs must include every governed manifest, source file, and test while excluding - generated and dependency directories. If a focused dry run disproves that, stop and correct the - checker-owned inputs rather than widening a governed package task. +- If an import boundary lacks package-local lint coverage, extend the owning workspace's Oxlint + rule rather than introducing a cross-workspace scanner. - Some app unit tests independently read non-workspace Brunch evaluation and documentation assets. This mission removes family architecture ownership from the app but does not remove inputs those product tests still genuinely consume. -- Core's Linear graph test still exercises a context-root script. Narrow hashing is in scope; - relocating that unrelated utility is not. +- The non-blocking review identifies similar tautological tests outside this architecture cluster. + They are evidence of the same defect pattern, but broad product-test cleanup is deferred unless a + touched test blocks this ownership change. ## Stop or reorient -Stop if the dedicated repo-chores task does not invalidate for both app and package changes, if it -requires a prune exception in the Global job, or if moving an assertion changes what it permits or -rejects. +Stop if deleting a source-walking assertion removes the only enforcement of a security boundary or +observable runtime behavior. Preserve that contract in its native tool before deleting the mirror. Stop if removing a prune exception makes a non-architecture core or app test lose a genuine fixture. Preserve that proven dependency narrowly and report it instead of deleting it to satisfy @@ -111,6 +115,6 @@ the desired graph shape. ## Deferred -The canonical future planning record remains -[`MISSION.next.md`](MISSION.next.md). SRE-1008 owns repository-wide detection of undeclared Turbo -task inputs; this mission supplies declared checker-owned Brunch inputs for it to inspect. +The canonical future planning record remains [`MISSION.next.md`](MISSION.next.md). SRE-1008 owns +repository-wide detection of undeclared Turbo task inputs. The broader non-blocking cleanup of +tautological core tests is deferred. From 84ffdfe7e7478e4b27235614521506c2b9fb871f Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 9 Sep 2026 17:24:09 +0200 Subject: [PATCH 10/10] Replace static architecture tests with native checks Co-authored-by: Cursor --- .github/actions/prune-repository/prune.py | 6 +- .../actions/prune-repository/prune_test.py | 7 +- .github/workflows/lint.yml | 4 - .../test/architecture/app-boundaries.test.ts | 80 --- .../test/architecture/app-workspace.test.ts | 44 -- .../test/architecture/app-workspace.ts | 63 --- apps/brunch-agent/test/build-artifact.test.ts | 47 +- .../brunch-agent/packages/core/package.json | 2 +- .../core/src}/linear-project-graph.ts | 20 +- .../core/test/architecture/context-root.ts | 16 - .../architecture/linear-project-graph.test.ts | 120 +---- .../core/test/architecture/open-gaps.test.ts | 61 --- .../core/test/architecture/open-gaps.ts | 45 -- .../brunch-agent/packages/core/turbo.json | 6 - .../node/docs/task-dependencies.json | 1 - libs/@local/repo-chores/node/package.json | 1 - .../node/scripts/check-brunch-architecture.ts | 476 ------------------ .../workspace.test.ts | 46 -- .../check-brunch-architecture/workspace.ts | 231 --------- libs/@local/repo-chores/node/turbo.json | 11 - turbo.json | 1 - 21 files changed, 39 insertions(+), 1249 deletions(-) delete mode 100644 apps/brunch-agent/test/architecture/app-boundaries.test.ts delete mode 100644 apps/brunch-agent/test/architecture/app-workspace.test.ts delete mode 100644 apps/brunch-agent/test/architecture/app-workspace.ts rename libs/@hashintel/brunch-agent/{scripts => packages/core/src}/linear-project-graph.ts (98%) delete mode 100644 libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts delete mode 100644 libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts delete mode 100644 libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.ts delete mode 100644 libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts delete mode 100644 libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts delete mode 100644 libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts diff --git a/.github/actions/prune-repository/prune.py b/.github/actions/prune-repository/prune.py index d201a35e82c..b4438b26844 100644 --- a/.github/actions/prune-repository/prune.py +++ b/.github/actions/prune-repository/prune.py @@ -49,11 +49,7 @@ # Non-workspace paths required by packages in the *requested* scope. # `turbo prune` copies workspace directories and root manifests only. REQUESTED_PATHS: dict[str, list[str]] = { - # Core's Linear graph tests exercise the context-root script. - "@hashintel/brunch-agent": [ - ".config/oxlint/brunch", - "libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts", - ], + "@hashintel/brunch-agent": [".config/oxlint/brunch"], # The app's product tests execute evaluation runners and inspect committed # evidence. Those non-workspace inputs are copied explicitly. "@apps/brunch-agent": [ diff --git a/.github/actions/prune-repository/prune_test.py b/.github/actions/prune-repository/prune_test.py index d1141eb0ea3..24e4e17b647 100644 --- a/.github/actions/prune-repository/prune_test.py +++ b/.github/actions/prune-repository/prune_test.py @@ -54,14 +54,11 @@ def test_app_keeps_only_its_non_workspace_product_inputs(self) -> None: ], ) - def test_core_adds_only_its_context_root_script(self) -> None: + def test_core_adds_only_its_shared_lint_config(self) -> None: self.assertEqual(extras_for_requested({CORE}), frozenset()) self.assertEqual( extra_paths_for_requested({CORE}), - [ - ".config/oxlint/brunch", - "libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts", - ], + [".config/oxlint/brunch"], ) def test_sibling_or_website_job_does_not_add_context_paths(self) -> None: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a57b8f05f92..fa91feb9138 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -283,10 +283,6 @@ jobs: exit 1 fi - - name: Run Brunch architecture checks - if: ${{ success() || failure() }} - run: turbo run lint:brunch-architecture --filter @local/repo-chores - - name: Run yarn lint:license-in-workspaces if: ${{ success() || failure() }} env: diff --git a/apps/brunch-agent/test/architecture/app-boundaries.test.ts b/apps/brunch-agent/test/architecture/app-boundaries.test.ts deleted file mode 100644 index 56fcbde0ca0..00000000000 --- a/apps/brunch-agent/test/architecture/app-boundaries.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { describe, expect, test } from "vitest"; - -import { - AGENT_DIRECTIVE_STATEMENT, - APP_ROOT, - isAgentModule, - pinnedIdentities, - sourceFiles, -} from "./app-workspace"; - -const appSourceFiles = sourceFiles(); -const agentModules = appSourceFiles.filter(isAgentModule); - -describe("the Brunch application owns its agent registration", () => { - test("has at least one agent module", () => { - expect(agentModules.length).toBeGreaterThan(0); - }); - - test("'use agent' is each agent module's first statement", () => { - for (const file of agentModules) { - const withoutLeadingComments = file.text - .replace(/^/u, "") - .replace(/^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/))*\s*/u, ""); - const firstStatement = withoutLeadingComments.split("\n")[0] ?? ""; - expect({ - file: file.relPath, - firstStatementIsDirective: - AGENT_DIRECTIVE_STATEMENT.test(firstStatement), - }).toEqual({ file: file.relPath, firstStatementIsDirective: true }); - } - }); - - test("agentName is a pinned string literal", () => { - for (const file of agentModules) { - const identities = pinnedIdentities(file); - expect({ file: file.relPath, pinned: identities.length > 0 }).toEqual({ - file: file.relPath, - pinned: true, - }); - for (const identity of identities) { - expect(identity).toMatch(/^[a-z][a-z0-9-]*$/u); - } - } - }); - - test("a pinned identity appears only in its own agent module", () => { - const identities = agentModules.flatMap((file) => - pinnedIdentities(file).map((identity) => ({ - identity, - pinnedIn: file.relPath, - })), - ); - expect(identities.length).toBeGreaterThan(0); - - const stringLiteral = /(['"`])(?:\\.|(?!\1)[^\\\n])*\1/gu; - for (const { identity, pinnedIn } of identities) { - const duplicatedIn = appSourceFiles - .filter((file) => file.relPath !== pinnedIn) - .filter((file) => - (file.text.match(stringLiteral) ?? []).some((literal) => - literal.includes(identity), - ), - ) - .map((file) => file.relPath); - expect({ identity, duplicatedIn }).toEqual({ - identity, - duplicatedIn: [], - }); - } - }); - - test("owns the mount and conversation store", () => { - for (const file of ["src/app.ts", "src/db.ts"]) { - expect(() => readFileSync(join(APP_ROOT, file), "utf8")).not.toThrow(); - } - }); -}); diff --git a/apps/brunch-agent/test/architecture/app-workspace.test.ts b/apps/brunch-agent/test/architecture/app-workspace.test.ts deleted file mode 100644 index 5d04f9f2956..00000000000 --- a/apps/brunch-agent/test/architecture/app-workspace.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { isAgentModule, type SourceFile } from "./app-workspace"; - -const file = (text: string): SourceFile => ({ - path: "/fake/module.ts", - relPath: "fake/module.ts", - text, -}); - -describe("isAgentModule", () => { - test("recognizes either quote style", () => { - expect(isAgentModule(file("'use agent';\nexport {};\n"))).toBe(true); - expect(isAgentModule(file('"use agent";\nexport {};\n'))).toBe(true); - }); - - test("allows leading comments and trailing directive comments", () => { - expect( - isAgentModule( - file( - "/** The agent. */\n'use agent'; // registers the agent\nexport {};\n", - ), - ), - ).toBe(true); - }); - - test("does not confuse a comment for a directive", () => { - expect( - isAgentModule( - file("// the 'use agent' directive must come first\nexport {};\n"), - ), - ).toBe(false); - }); - - test("detects a misplaced directive for the placement check", () => { - expect(isAgentModule(file("import 'x';\n'use agent';\nexport {};\n"))).toBe( - true, - ); - }); - - test("requires matching quotes", () => { - expect(isAgentModule(file("'use agent\";\nexport {};\n"))).toBe(false); - }); -}); diff --git a/apps/brunch-agent/test/architecture/app-workspace.ts b/apps/brunch-agent/test/architecture/app-workspace.ts deleted file mode 100644 index 9c26c2689e1..00000000000 --- a/apps/brunch-agent/test/architecture/app-workspace.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join, relative } from "node:path"; -import { fileURLToPath } from "node:url"; - -export const APP_ROOT = fileURLToPath( - new URL("../..", import.meta.url), -).replace(/[/\\]$/u, ""); - -export interface SourceFile { - readonly path: string; - readonly relPath: string; - readonly text: string; -} - -const SOURCE_EXTENSIONS = /\.(ts|tsx|mts|mjs|js|jsx)$/u; -const SKIP_DIRECTORIES = new Set([ - "node_modules", - "dist", - ".flue", - ".git", - ".turbo", - "test", - "tests", - "__tests__", -]); - -export const sourceFiles = (): SourceFile[] => { - const found: SourceFile[] = []; - const walk = (current: string): void => { - for (const entry of readdirSync(current, { withFileTypes: true })) { - if (SKIP_DIRECTORIES.has(entry.name)) { - continue; - } - const path = join(current, entry.name); - if (entry.isDirectory()) { - walk(path); - } else if (SOURCE_EXTENSIONS.test(entry.name)) { - found.push({ - path, - relPath: relative(APP_ROOT, path).replaceAll("\\", "/"), - text: readFileSync(path, "utf8"), - }); - } - } - }; - walk(APP_ROOT); - return found; -}; - -/** - * Deliberately not anchored to the first statement: a misplaced directive - * must still be detected so the placement check can fail it. - */ -export const AGENT_DIRECTIVE_STATEMENT = - /^\s*(["'])use agent\1;?\s*(?:$|\/\/|\/\*)/mu; - -export const isAgentModule = (file: SourceFile): boolean => - AGENT_DIRECTIVE_STATEMENT.test(file.text); - -export const pinnedIdentities = (file: SourceFile): string[] => - [...file.text.matchAll(/\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu)].map( - (match) => match[2]!, - ); diff --git a/apps/brunch-agent/test/build-artifact.test.ts b/apps/brunch-agent/test/build-artifact.test.ts index 1a71004eb07..f7b873b97ae 100644 --- a/apps/brunch-agent/test/build-artifact.test.ts +++ b/apps/brunch-agent/test/build-artifact.test.ts @@ -1,16 +1,13 @@ /** * What the build actually emitted — checked against the artifact, not the source. * - * `test/boundaries.test.ts` catches a misplaced `'use agent'` directive by - * reading the source. This checks the same property from the other end: that - * the agent really is registered in the emitted bundle. The distinction earns - * its keep because the failure mode here is silent — `@flue/vite` drops a - * module that stops looking like an agent module and the build stays green, so - * "it compiled" says nothing about whether the app has any agents in it. + * The failure mode here is silent — `@flue/vite` drops a module that stops + * looking like an agent module and the build stays green, so "it compiled" + * says nothing about whether the app has its agent in the bundle. * - * Any future change that quietly stops an agent, its route, or the conversation + * Any future change that quietly stops the agent, its route, or the conversation * store from reaching the bundle fails here, whatever the cause: a directive - * moved, a config path changed, an entry dropped from the scan glob. + * moved or a config path changed. */ import { existsSync, readdirSync, readFileSync } from "node:fs"; @@ -19,6 +16,7 @@ import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { ChatAgent } from "../src/agents/chat-agent/agent"; import { loadBuiltBrunchApplication } from "../src/evaluations/runbook/load-built-application"; const DEV_APP = fileURLToPath(new URL("..", import.meta.url)).replace( @@ -51,35 +49,13 @@ afterAll(() => { } }); -/** The pinned identity of every agent module in the app, read from source. */ -function declaredAgentIdentities(): string[] { - const agentsDirectory = join(DEV_APP, "src/agents"); - return readdirSync(agentsDirectory, { - recursive: true, - encoding: "utf8", - }) - .filter((entry) => entry.endsWith(".ts")) - .flatMap((entry) => - Array.from( - readFileSync(join(agentsDirectory, entry), "utf8").matchAll( - /\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu, - ), - ), - ) - .map((match) => match[2]!); -} - describe("the emitted server bundle", () => { test("exists", () => { expect(existsSync(DIST)).toBe(true); expect(bundle.length).toBeGreaterThan(0); }); - test("registers every declared agent under its pinned identity", () => { - // The check that matters. A `'use agent'` directive that is not the first - // statement builds green and simply never registers — the app boots with no - // agents and nothing says so until a conversation fails to start. - // + test("registers the chat agent under its pinned identity", () => { // Asserted against the emitted `__flueBindAgentModule(Fn, { identity })` // call rather than the bare string, because the string survives that // failure: the `agentName` assignment is still in the bundle as ordinary @@ -91,14 +67,7 @@ describe("the emitted server bundle", () => { ), ].map((match) => match[1]!), ); - const identities = declaredAgentIdentities(); - expect(identities.length).toBeGreaterThan(0); - for (const identity of identities) { - expect({ identity, bound: bound.has(identity) }).toEqual({ - identity, - bound: true, - }); - } + expect(bound.has(ChatAgent.agentName)).toBe(true); }); test("includes the fail-closed production store", () => { diff --git a/libs/@hashintel/brunch-agent/packages/core/package.json b/libs/@hashintel/brunch-agent/packages/core/package.json index 70593a8eb94..f09a83c0f7a 100644 --- a/libs/@hashintel/brunch-agent/packages/core/package.json +++ b/libs/@hashintel/brunch-agent/packages/core/package.json @@ -34,7 +34,7 @@ "scripts": { "build": "vite build", "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", - "linear:graph": "node --experimental-strip-types ../../scripts/linear-project-graph.ts", + "linear:graph": "node --experimental-strip-types src/linear-project-graph.ts", "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "test:unit": "vitest run" diff --git a/libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts b/libs/@hashintel/brunch-agent/packages/core/src/linear-project-graph.ts similarity index 98% rename from libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts rename to libs/@hashintel/brunch-agent/packages/core/src/linear-project-graph.ts index 7d8ee640ff7..57b8f88f3dd 100644 --- a/libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/linear-project-graph.ts @@ -44,7 +44,7 @@ interface LinearRelation { readonly relatedIssue?: LinearIssueRef; } -interface LinearIssueRecord extends LinearIssueRef { +export interface LinearIssueRecord extends LinearIssueRef { readonly parent: { readonly identifier: string } | null; readonly relations: { readonly pageInfo: { readonly hasNextPage: boolean }; @@ -56,7 +56,7 @@ interface LinearIssueRecord extends LinearIssueRef { }; } -interface ProjectIssuePage { +export interface ProjectIssuePage { readonly projectName: string; readonly viewer: { readonly id: string; readonly name: string }; readonly issues: readonly LinearIssueRecord[]; @@ -569,20 +569,20 @@ if (isMain) { try { const options = parseArguments(process.argv.slice(2)); if (options.help) { - console.log(usage); + process.stdout.write(`${usage}\n`); } else { - console.log( - renderProjectGraph( + process.stdout.write( + `${renderProjectGraph( fetchProjectGraph(options.projectName, options.includeClosed), - ), + )}\n`, ); } } catch (error) { - console.error( - `linear:graph: ${error instanceof Error ? error.message : String(error)}`, + process.stderr.write( + `linear:graph: ${error instanceof Error ? error.message : String(error)}\n`, ); - console.error( - "Run `turbo run linear:graph --filter '@hashintel/brunch-agent' -- --help` for usage.", + process.stderr.write( + "Run `turbo run linear:graph --filter '@hashintel/brunch-agent' -- --help` for usage.\n", ); process.exitCode = 1; } diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts deleted file mode 100644 index dbd6889f9d8..00000000000 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/context-root.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -/** The non-workspace root shared by Brunch packages and evaluation assets. */ -export const CONTEXT_ROOT = fileURLToPath( - new URL("../../../..", import.meta.url), -).replace(/[/\\]$/, ""); - -/** - * Pruned jobs copy these non-workspace paths explicitly. Skip the affected - * tests instead of failing opaquely if that CI contract drifts. - */ -export const contextRootPresent = existsSync( - join(CONTEXT_ROOT, "scripts/linear-project-graph.ts"), -); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/linear-project-graph.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/linear-project-graph.test.ts index 77563d891b0..09658fc5f13 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/linear-project-graph.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/linear-project-graph.test.ts @@ -1,77 +1,14 @@ -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; - import { describe, expect, test } from "vitest"; -import { CONTEXT_ROOT, contextRootPresent } from "./context-root"; - -/** - * The module under test lives at the context root, outside every workspace, so - * CI's pruned checkout lacks it. The specifier is computed so `lint:tsc` does - * not resolve it statically; the structural types below restate the contract - * the tests exercise. - */ -const SCRIPT_MODULE_URL = pathToFileURL( - join(CONTEXT_ROOT, "scripts/linear-project-graph.ts"), -).href; - -interface ProjectIssue { - readonly identifier: string; - readonly title: string; - readonly stateName: string; - readonly parentIdentifier?: string; - readonly assigneeName?: string; - readonly assignedToViewer: boolean; - readonly external: boolean; -} - -interface ProjectGraph { - readonly projectName: string; - readonly viewerName: string; - readonly includeClosed: boolean; - readonly issues: readonly ProjectIssue[]; - readonly hardEdges: readonly { readonly from: string; readonly to: string }[]; -} - -interface LinearProjectGraphModule { - readonly renderProjectGraph: (graph: ProjectGraph) => string; - readonly readProjectIssuePage: (value: unknown) => ProjectIssuePage; - readonly fetchProjectGraph: ( - projectName: string, - includeClosed: boolean, - queryPage: (projectName: string, after: string | null) => ProjectIssuePage, - ) => ProjectGraph; - readonly parseArguments: (arguments_: readonly string[]) => { - readonly projectName: string; - readonly includeClosed: boolean; - readonly help: boolean; - }; -} - -interface ProjectIssuePage { - readonly projectName: string; - readonly viewer: { readonly id: string; readonly name: string }; - readonly issues: readonly LinearIssueRecord[]; - readonly hasNextPage: boolean; - readonly endCursor: string | null; -} - -interface LinearIssueRecord { - readonly identifier: string; - readonly title: string; - readonly state: { readonly name: string; readonly type: string }; - readonly project: { readonly name: string } | null; - readonly assignee?: { readonly id: string; readonly name: string } | null; - readonly parent: { readonly identifier: string } | null; - readonly relations: { - readonly pageInfo: { readonly hasNextPage: boolean }; - readonly nodes: readonly []; - }; - readonly inverseRelations: { - readonly pageInfo: { readonly hasNextPage: boolean }; - readonly nodes: readonly []; - }; -} +import { + fetchProjectGraph, + parseArguments, + readProjectIssuePage, + renderProjectGraph, + type LinearIssueRecord, + type ProjectGraph, + type ProjectIssuePage, +} from "../../src/linear-project-graph"; const issue = ( identifier: string, @@ -108,18 +45,8 @@ const response = ( }, }); -async function loadRenderProjectGraph(): Promise< - LinearProjectGraphModule["renderProjectGraph"] -> { - const module = (await import(SCRIPT_MODULE_URL)) as LinearProjectGraphModule; - return module.renderProjectGraph; -} - -describe.skipIf(!contextRootPresent)("the compact Linear project graph", () => { - test("parses viewer identity separately from its display name", async () => { - const { readProjectIssuePage } = (await import( - SCRIPT_MODULE_URL - )) as LinearProjectGraphModule; +describe("the compact Linear project graph", () => { + test("parses viewer identity separately from its display name", () => { const page = readProjectIssuePage( response({ id: "viewer-id", name: "Same Display Name" }, [ issue("FE-1", { id: "other-id", name: "Same Display Name" }), @@ -130,20 +57,14 @@ describe.skipIf(!contextRootPresent)("the compact Linear project graph", () => { test.each([undefined, null, {}, { id: "", name: "Lu" }])( "rejects missing or malformed viewer: %j", - async (viewer) => { - const { readProjectIssuePage } = (await import( - SCRIPT_MODULE_URL - )) as LinearProjectGraphModule; + (viewer) => { expect(() => readProjectIssuePage(response(viewer))).toThrow( "missing or malformed authenticated viewer", ); }, ); - test("classifies viewer, wrong, unassigned, null, and absent assignees by ID", async () => { - const { readProjectIssuePage, fetchProjectGraph } = (await import( - SCRIPT_MODULE_URL - )) as LinearProjectGraphModule; + test("classifies viewer, wrong, unassigned, null, and absent assignees by ID", () => { const parsed = readProjectIssuePage( response({ id: "viewer-id", name: "Lu" }, [ issue("FE-1", { id: "viewer-id", name: "Lu" }), @@ -167,10 +88,7 @@ describe.skipIf(!contextRootPresent)("the compact Linear project graph", () => { ]); }); - test("accumulates two pages and passes the returned cursor", async () => { - const { fetchProjectGraph } = (await import( - SCRIPT_MODULE_URL - )) as LinearProjectGraphModule; + test("accumulates two pages and passes the returned cursor", () => { const calls: Array = []; const pages: ProjectIssuePage[] = [ { @@ -203,9 +121,7 @@ describe.skipIf(!contextRootPresent)("the compact Linear project graph", () => { ]); }); - test("defaults to open issues and --all includes closed issues", async () => { - const { fetchProjectGraph, parseArguments, renderProjectGraph } = - (await import(SCRIPT_MODULE_URL)) as LinearProjectGraphModule; + test("defaults to open issues and --all includes closed issues", () => { const page: ProjectIssuePage = { projectName: "brunch-agent", viewer: { id: "viewer-id", name: "Lu" }, @@ -230,8 +146,7 @@ describe.skipIf(!contextRootPresent)("the compact Linear project graph", () => { ); }); - test("renders hard-dependency layers with enough issue context for agent inference", async () => { - const renderProjectGraph = await loadRenderProjectGraph(); + test("renders hard-dependency layers with enough issue context for agent inference", () => { const graph: ProjectGraph = { projectName: "brunch-agent", viewerName: "Lu Nelson", @@ -288,8 +203,7 @@ L2 FE-103 [Todo p:FE-1 a:self] <=FE-101 | Ship the integration cycles: none`); }); - test("makes a hard-dependency cycle explicit instead of inventing an order", async () => { - const renderProjectGraph = await loadRenderProjectGraph(); + test("makes a hard-dependency cycle explicit instead of inventing an order", () => { const graph: ProjectGraph = { projectName: "brunch-agent", viewerName: "Lu Nelson", diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts deleted file mode 100644 index b4717bf4040..00000000000 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Keeps the open gaps in front of whoever is working, and keeps each entry - * well formed enough to act on. - * - * See `open-gaps.ts` for why the ledger exists and why closing a gap is - * deleting its entry rather than satisfying a predicate. - */ - -import { describe, expect, test } from "vitest"; - -import { OPEN_GAPS } from "./open-gaps"; - -// Printed once per run, so the list is in front of whoever is working rather -// than filed somewhere they would have to think to look. Silent when the ledger -// is empty, because that is the goal state and not a warning. -if (OPEN_GAPS.length > 0) { - process.stderr.write( - [ - "", - `⚠ ${OPEN_GAPS.length} verification gaps are open (spec §14.5 and friends):`, - ...OPEN_GAPS.map( - (entry) => ` · ${entry.id} — ${entry.ticket} (spec ${entry.spec})`, - ), - " Closing one means deleting its entry in the commit that lands its proof.", - "", - ].join("\n"), - ); -} - -describe("open gaps", () => { - test("every entry names its ticket, spec section, gap, and the proof that will delete it", () => { - for (const entry of OPEN_GAPS) { - // The entry id is pinned on both sides so a failure says which record is - // malformed even when the malformed field is the id itself. - expect({ - entry: entry.id, - id: entry.id, - ticket: entry.ticket, - spec: entry.spec, - statesGap: entry.gap.trim().length > 0, - statesProof: entry.proof.trim().length > 0, - }).toEqual({ - entry: entry.id, - id: expect.stringMatching( - /^[a-z0-9]+(?:-[a-z0-9]+)*$/, - ) as unknown as string, - ticket: expect.stringMatching(/^[A-Z]+-\d+$/) as unknown as string, - spec: expect.stringMatching( - /^§\d+(?:\.\d+)*(?:, §\d+(?:\.\d+)*)*$/, - ) as unknown as string, - statesGap: true, - statesProof: true, - }); - } - }); - - test("gap ids are unique", () => { - const ids = OPEN_GAPS.map((entry) => entry.id); - expect(new Set(ids).size).toBe(ids.length); - }); -}); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.ts deleted file mode 100644 index bc6de8fc722..00000000000 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * The verification gaps this codebase knows are open. - * - * Spec §14.5 names open verification items, and prose decays: an item nobody - * runs into is an item nobody closes. The ones here are exactly that kind — a - * substrate behaviour nobody has driven, a durability claim nobody has - * restarted into. - * - * So the ledger is data, and an entry's *existence* is the claim that its gap - * is still open. Closing a gap is an explicit repository action: the commit - * that lands the behavioural proof deletes the entry, so a single diff carries - * the proof and the closure together and a reviewer can check one against the - * other. - * - * Nothing here infers closure from source text, and nothing may be added that - * does. The mechanism this replaces searched the tree for a citation token - * alongside a `test(`/`expect(` in the same file: it passed for a test that - * cited a gap while proving something else, and it went red on any closure - * written outside the shape it guessed. A banner that can be wrong in both - * directions is a banner nobody reads. - */ - -export interface OpenGap { - /** Short stable handle, used in the run banner and on the ticket. */ - readonly id: string; - /** Spec section that names the item. */ - readonly spec: string; - /** The Linear issue that owns closing it. */ - readonly ticket: string; - /** What is not yet known, in one sentence. */ - readonly gap: string; - /** The behavioural proof whose landing commit deletes this entry. */ - readonly proof: string; -} - -export const OPEN_GAPS: readonly OpenGap[] = [ - { - id: "compaction-vs-durable-history", - spec: "§9.7, §14.5", - ticket: "FE-1386", - gap: "Flue 2.0.3 source shows compaction appends to the canonical stream and rewrites model context only, but no session has behaviorally pinned that the public message projection and persistent state survive a real compaction boundary.", - proof: - "A test under libs/@hashintel/brunch-agent/packages/binding-flue/test driving one genuine compaction, deep-comparing complete public messages and settlements aside from offset/incarnation, verifying persistent state, and asserting an FE-1391 archive pointer still resolves.", - }, -]; diff --git a/libs/@hashintel/brunch-agent/packages/core/turbo.json b/libs/@hashintel/brunch-agent/packages/core/turbo.json index 3224e792ba6..6623a93364c 100644 --- a/libs/@hashintel/brunch-agent/packages/core/turbo.json +++ b/libs/@hashintel/brunch-agent/packages/core/turbo.json @@ -7,12 +7,6 @@ }, "linear:graph": { "cache": false - }, - "test:unit": { - "inputs": [ - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/libs/@hashintel/brunch-agent/scripts/linear-project-graph.ts" - ] } } } diff --git a/libs/@local/repo-chores/node/docs/task-dependencies.json b/libs/@local/repo-chores/node/docs/task-dependencies.json index 1ff1179e65d..0d4c7e45abc 100644 --- a/libs/@local/repo-chores/node/docs/task-dependencies.json +++ b/libs/@local/repo-chores/node/docs/task-dependencies.json @@ -12,7 +12,6 @@ "@local/hash-backend-utils#build", "@local/hash-isomorphic-utils#build" ], - "lint:brunch-architecture": [], "lint:eslint": [ "@local/eslint#build", "@local/hash-backend-utils#build", diff --git a/libs/@local/repo-chores/node/package.json b/libs/@local/repo-chores/node/package.json index a752242f0f6..b19054a3947 100644 --- a/libs/@local/repo-chores/node/package.json +++ b/libs/@local/repo-chores/node/package.json @@ -9,7 +9,6 @@ "scripts": { "exe": "tsx", "fix:eslint": "eslint --fix .", - "lint:brunch-architecture": "node --import tsx scripts/check-brunch-architecture.ts", "lint:eslint": "eslint --report-unused-disable-directives .", "lint:tsc": "tsc --noEmit", "test:unit": "vitest --run" diff --git a/libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts b/libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts deleted file mode 100644 index 0e186ab504f..00000000000 --- a/libs/@local/repo-chores/node/scripts/check-brunch-architecture.ts +++ /dev/null @@ -1,476 +0,0 @@ -import path from "node:path"; - -import { - allDependencies, - importedPackages, - MODEL_KEY_NAME, - packageOf, - runtimeDependencies, - sourceFiles, - testFiles, - workspacePackages, - type WorkspacePackage, -} from "./check-brunch-architecture/workspace"; -import { UserFriendlyError } from "./shared/errors"; - -const corePackageName = "@hashintel/brunch-agent"; -const substrateScopes = ["@flue/", "@earendil-works/"]; -const flueRuntime = "@flue/runtime"; - -const otherSchemaLibraries = [ - "zod", - "yup", - "joi", - "ajv", - "superstruct", - "arktype", - "io-ts", - "runtypes", - "@sinclair/typebox", - "@standard-schema/spec", - "@standard-schema/utils", -]; - -const substrateIntegrationEntryPoints: Readonly> = { - "libs/@hashintel/brunch-agent/packages/core/test/question-marker.test.ts": - "Types the Flue logger and calls the core marker tool with a mocked data-part writer and logger; no runtime boot, provider, key or socket.", - "apps/brunch-agent/test/brunch-turn.test.ts": - "Types Flue's client, admission, and conversation snapshot and constructs FlueExecutionError so the persona bridge can be unit-tested against a stubbed client — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/flue-transcript.test.ts": - "Types Flue's public conversation snapshot so the transcript projector can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/petrinaut-chat.integration.ts": - "Boots the plain Flue chat agent on Flue's node runtime with pi-ai's faux provider, drives the browser ChatTransport against the mounted Flue route over app.fetch, and proves streamed reasoning/text, server tools, client-tool resume, SDK history ownership, SQLite restart, and harness-side idempotent apply-sweep — no provider key, no socket, no extraction model call. Run as a child process by petrinaut-chat.test.ts.", - "apps/brunch-agent/test/prepared-workpiece.integration.ts": - "Boots the built Flue ChatAgent with pi-ai's faux provider, creates a prepared fixture through one tagged public signal with fixture-scoped initial data, retries its deterministic idempotency key, and proves prepared/model workpiece selection from canonical history — no provider key, socket, or network model call.", - "apps/brunch-agent/test/proof-artifacts.test.ts": - "Types Flue's public conversation snapshot so canonical trace derivation, workpiece binding, and atomic evidence retention can be unit-tested against an in-memory fixture — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/runbook-artifacts.test.ts": - "Types Flue's public conversation snapshot so runbook artifact recovery can be unit-tested; the import is type-only — no provider key, no socket, no model call, no runtime boot.", - "apps/brunch-agent/test/runbook-elicitation-faux-provider.ts": - "Defines the scripted pi-ai faux provider loaded only by the hermetic prospective-runner test — no provider key, no socket, and no network model call.", - "apps/brunch-agent/test/runbook-headless.integration.ts": - "Boots the built Flue ChatAgent with pi-ai's faux provider and a headless Petrinaut client to prove validated construct-only tool flow without a provider key, socket, or network model call.", - "apps/brunch-agent/test/telemetry.test.ts": - "Constructs Flue's content-free OpenTelemetry instrumentation with an injected exporter setup to prove disposal order; it registers no global instrumentation, opens no socket, and makes no provider call.", - "apps/brunch-agent/test/workpiece.test.ts": - "Types Flue's public conversation snapshot so the substrate-neutral workpiece selector and app-owned SHA-256 projection can be unit-tested against in-memory messages — no provider key, no socket, no model call, no runtime boot.", - "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts": - "Types a stubbed public Flue client and stream chunks to prove finite AI SDK projection and client-tool signal admission — no runtime boot, provider key, socket, or model call.", - "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts": - "Types Flue's public conversation snapshot so history can be projected into AI SDK messages without a runtime boot, provider key, socket, or model call.", - "libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ui-stream.test.ts": - "Types Flue conversation-stream chunks so the finite AI SDK projector can be unit-tested without a runtime boot, provider key, socket, or model call.", -}; - -const isSubstrate = (name: string): boolean => - substrateScopes.some((scope) => name.startsWith(scope)); - -const byRole = ( - packages: readonly WorkspacePackage[], - role: string, -): WorkspacePackage[] => - packages.filter((workspacePackage) => - workspacePackage.dir.startsWith(`${role}-`), - ); - -const valuesEqual = (actual: unknown, expected: unknown): boolean => - JSON.stringify(actual) === JSON.stringify(expected); - -const checkBrunchArchitecture = (): string[] => { - const packages = workspacePackages(); - const violations: string[] = []; - - const assertCondition = ( - condition: boolean, - rule: string, - details: string, - ): void => { - if (!condition) { - violations.push(`${rule}: ${details}`); - } - }; - - const assertEqual = ( - actual: unknown, - expected: unknown, - rule: string, - subject: string, - ): void => { - assertCondition( - valuesEqual(actual, expected), - rule, - `${subject}; expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`, - ); - }; - - for (const workspacePackage of packages) { - assertCondition( - sourceFiles(workspacePackage).length > 0, - "complete scan", - `${workspacePackage.relPath} has no scanned source files`, - ); - } - - for (const workspacePackage of packages.filter( - ({ kind }) => kind === "package", - )) { - assertCondition( - /^(core|plugin-[a-z0-9-]+|binding-[a-z0-9-]+|transport-[a-z0-9-]+)$/.test( - workspacePackage.dir, - ), - "role prefix", - `${workspacePackage.relPath} is not core or a role-prefixed package`, - ); - } - - for (const workspacePackage of packages) { - assertCondition( - !/^(adapter|wrapper|elicit)-/.test(workspacePackage.dir), - "role vocabulary", - `${workspacePackage.relPath} uses an avoided role noun`, - ); - - const expectedName = - workspacePackage.kind === "app" - ? "@apps/brunch-agent" - : workspacePackage.dir === "core" - ? corePackageName - : `@hashintel/brunch-agent-${workspacePackage.dir}`; - assertEqual( - workspacePackage.name, - expectedName, - "workspace identity", - workspacePackage.relPath, - ); - } - - const core = packages.find(({ name }) => name === corePackageName); - assertCondition( - core !== undefined, - "core workspace", - `${corePackageName} is missing`, - ); - - if (core !== undefined) { - assertEqual( - allDependencies(core).filter(isSubstrate), - [flueRuntime], - "core runtime", - "agent-runtime dependencies", - ); - - for (const file of sourceFiles(core)) { - const substrateImports = importedPackages(file).filter((specifier) => - isSubstrate(packageOf(specifier)), - ); - const expectedImports = - file.relPath.endsWith("/src/flue.ts") || - file.relPath.endsWith("/src/skills/skill-markdown.ts") - ? [flueRuntime] - : []; - assertEqual( - substrateImports, - expectedImports, - "core runtime", - file.relPath, - ); - } - - for (const dependency of runtimeDependencies(core)) { - assertCondition( - !/^@hashintel\/brunch-agent-(binding|plugin)-/u.test(dependency), - "core direction", - `${core.relPath} depends on ${dependency}`, - ); - } - - assertEqual( - Object.keys(core.manifest.exports ?? {}), - [ - ".", - "./client-tools", - "./flue", - "./question-marker", - "./storage", - "./workpiece", - ], - "core exports", - core.relPath, - ); - } - - const plugins = byRole(packages, "plugin"); - assertCondition( - plugins.length > 0, - "plugin inventory", - "no plugins were found", - ); - for (const plugin of plugins) { - const workspaceDependencies = runtimeDependencies(plugin).filter( - (dependency) => dependency.startsWith(corePackageName), - ); - assertEqual( - workspaceDependencies, - [corePackageName], - "plugin direction", - plugin.relPath, - ); - assertEqual( - allDependencies(plugin).filter( - (dependency) => isSubstrate(dependency) && dependency !== flueRuntime, - ), - [], - "plugin substrate", - plugin.relPath, - ); - - for (const file of sourceFiles(plugin)) { - for (const specifier of importedPackages(file)) { - const importedPackage = packageOf(specifier); - assertCondition( - !isSubstrate(importedPackage) || importedPackage === flueRuntime, - "plugin substrate", - `${file.relPath} imports ${specifier}`, - ); - assertCondition( - !importedPackage.startsWith(corePackageName) || - importedPackage === corePackageName, - "plugin direction", - `${file.relPath} imports ${specifier}`, - ); - assertCondition( - specifier !== `${corePackageName}/storage`, - "plugin storage lane", - `${file.relPath} imports ${specifier}`, - ); - } - } - } - - const bindings = byRole(packages, "binding"); - assertCondition( - bindings.length > 0, - "binding inventory", - "no bindings were found", - ); - for (const binding of bindings) { - const dependencies = runtimeDependencies(binding); - assertCondition( - dependencies.includes(corePackageName), - "binding direction", - `${binding.relPath} does not depend on ${corePackageName}`, - ); - assertCondition( - dependencies.some(isSubstrate), - "binding direction", - `${binding.relPath} does not depend on a substrate`, - ); - for (const dependency of dependencies) { - assertCondition( - !dependency.startsWith("@hashintel/brunch-agent-plugin-"), - "binding direction", - `${binding.relPath} depends on plugin ${dependency}`, - ); - } - } - - const transports = byRole(packages, "transport"); - assertCondition( - transports.length > 0, - "transport inventory", - "no transports were found", - ); - for (const transport of transports) { - assertEqual( - runtimeDependencies(transport).sort(), - ["@flue/sdk", "ai"], - "transport dependencies", - transport.relPath, - ); - for (const file of sourceFiles(transport).filter((sourceFile) => - sourceFile.path.startsWith(path.join(transport.path, "src")), - )) { - for (const specifier of importedPackages(file)) { - if (!specifier.startsWith("node:")) { - assertCondition( - ["@flue/sdk", "ai"].includes(packageOf(specifier)), - "transport imports", - `${file.relPath} imports ${specifier}`, - ); - } - } - } - } - - for (const workspacePackage of packages) { - const declared = runtimeDependencies(workspacePackage); - for (const file of sourceFiles(workspacePackage)) { - const importedWorkspaces = importedPackages(file) - .map(packageOf) - .filter( - (imported) => - imported === corePackageName || - imported.startsWith(`${corePackageName}-`), - ); - for (const imported of importedWorkspaces) { - assertCondition( - declared.includes(imported), - "declared runtime dependency", - `${file.relPath} imports undeclared workspace ${imported}`, - ); - } - } - } - - for (const workspacePackage of packages) { - const dependencies = allDependencies(workspacePackage); - for (const forbidden of otherSchemaLibraries) { - assertCondition( - !dependencies.includes(forbidden), - "schema library", - `${workspacePackage.relPath} declares ${forbidden}`, - ); - } - - const files = sourceFiles(workspacePackage); - for (const file of files) { - for (const specifier of importedPackages(file)) { - assertCondition( - !otherSchemaLibraries.includes(packageOf(specifier)), - "schema library", - `${file.relPath} imports ${specifier}`, - ); - } - } - - const importsValibot = files.some((file) => - importedPackages(file).some( - (specifier) => packageOf(specifier) === "valibot", - ), - ); - const declaresValibot = dependencies.includes("valibot"); - assertEqual( - declaresValibot, - importsValibot, - "valibot declaration", - workspacePackage.relPath, - ); - } - - const viteRanges = packages.flatMap((workspacePackage) => { - const range = - workspacePackage.manifest.devDependencies?.vite ?? - workspacePackage.manifest.dependencies?.vite; - return range === undefined ? [] : [range]; - }); - assertCondition( - viteRanges.length > 0, - "Vite constraint", - "no Vite range was found", - ); - for (const range of viteRanges) { - assertCondition( - /^\^?8(\.|$)/u.test(range), - "Vite constraint", - `expected Vite 8, received ${range}`, - ); - } - - for (const workspacePackage of packages) { - for (const file of [ - ...sourceFiles(workspacePackage), - ...testFiles(workspacePackage), - ]) { - assertCondition( - !importedPackages(file).includes("@flue/runtime/bun"), - "Flue runtime adapter", - `${file.relPath} imports @flue/runtime/bun`, - ); - } - } - - for (const workspacePackage of packages.filter( - ({ dir }) => !dir.startsWith("binding-"), - )) { - for (const file of sourceFiles(workspacePackage)) { - assertCondition( - !importedPackages(file).includes(`${corePackageName}/storage`), - "core storage lane", - `${file.relPath} imports ${corePackageName}/storage`, - ); - } - } - - for (const workspacePackage of packages) { - assertCondition( - typeof workspacePackage.manifest.scripts?.["lint:eslint"] === "string", - "workspace tasks", - `${workspacePackage.relPath} has no lint:eslint script`, - ); - assertCondition( - typeof workspacePackage.manifest.scripts?.["lint:tsc"] === "string", - "workspace tasks", - `${workspacePackage.relPath} has no lint:tsc script`, - ); - assertCondition( - workspacePackage.manifest.scripts?.["test:unit"]?.includes( - "vitest run", - ) === true, - "workspace tasks", - `${workspacePackage.relPath} test:unit does not run Vitest`, - ); - } - - const suite = packages.flatMap((workspacePackage) => - testFiles(workspacePackage), - ); - assertCondition( - suite.length > 0, - "test inventory", - "no test files were found", - ); - const modelKey = new RegExp(MODEL_KEY_NAME, "gu"); - for (const file of suite) { - const keys = file.text.match(modelKey) ?? []; - assertEqual(keys, [], "model credential", file.relPath); - } - - const substrateImporters = suite - .filter((file) => - importedPackages(file).some((specifier) => - isSubstrate(packageOf(specifier)), - ), - ) - .map(({ relPath }) => relPath) - .sort(); - assertEqual( - substrateImporters, - Object.keys(substrateIntegrationEntryPoints).sort(), - "hermetic substrate inventory", - "reviewed test entry points", - ); - - for (const [filePath, review] of Object.entries( - substrateIntegrationEntryPoints, - )) { - assertCondition( - review.trim().length > 0, - "hermetic substrate inventory", - `${filePath} has no review rationale`, - ); - } - - return violations; -}; - -const violations = checkBrunchArchitecture(); - -if (violations.length > 0) { - throw new UserFriendlyError( - `Brunch architecture check failed:\n${violations - .map((violation) => `- ${violation}`) - .join("\n")}`, - ); -} - -console.log("Brunch architecture checks passed."); diff --git a/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts b/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts deleted file mode 100644 index e48032d7f3e..00000000000 --- a/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * The introspection helpers themselves, at the seams where a wrong answer - * makes some other invariant pass vacuously or fail spuriously. - */ - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { afterAll, describe, expect, test } from "vitest"; - -import { sourceFiles, testFiles, type WorkspacePackage } from "./workspace"; - -describe("the source/test partition is total", () => { - // A real directory rather than fakes, because the property under test is the - // walk itself: the FE-1400 review's verified escape was a file in a *nested* - // test directory (`src/test/`), pruned by the source walk but never collected - // by the old top-level-only test walk — governed by nothing. - const dir = mkdtempSync(path.join(tmpdir(), "brunch-partition-")); - mkdirSync(path.join(dir, "src/test"), { recursive: true }); - mkdirSync(path.join(dir, "__tests__")); - writeFileSync(path.join(dir, "src/index.ts"), "export {};\n"); - writeFileSync(path.join(dir, "src/test/nested.ts"), "export {};\n"); - writeFileSync(path.join(dir, "__tests__/top.test.ts"), "export {};\n"); - const pkg: WorkspacePackage = { - name: "@hashintel/brunch-agent-fixture", - dir: path.basename(dir), - path: dir, - relPath: `packages/${path.basename(dir)}`, - kind: "package", - manifest: { name: "@hashintel/brunch-agent-fixture" }, - }; - - afterAll(() => rmSync(dir, { recursive: true, force: true })); - - test("every file is exactly one of source or test — nested test dirs included", () => { - const source = sourceFiles(pkg) - .map((file) => path.relative(dir, file.path)) - .sort(); - const tests = testFiles(pkg) - .map((file) => path.relative(dir, file.path)) - .sort(); - expect(source).toEqual(["src/index.ts"]); - expect(tests).toEqual(["__tests__/top.test.ts", "src/test/nested.ts"]); - }); -}); diff --git a/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts b/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts deleted file mode 100644 index 85b36401eae..00000000000 --- a/libs/@local/repo-chores/node/scripts/check-brunch-architecture/workspace.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Workspace introspection for the boundary tests. - * - * The point of reading the tree rather than listing packages by hand is that a - * package added later is governed by the same invariants without anyone - * remembering to opt it in. Two rules keep that promise honest: - * - * - the scan covers a package's whole directory, not a `src/` convention, so a - * package laid out differently cannot fall through every file-level check; - * - a workspace directory that cannot be read is reported, never silently - * skipped into a vacuous pass. - */ - -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import nodePath from "node:path"; - -import { monorepoRootDirPath } from "../shared/monorepo"; - -export const HASH_ROOT = monorepoRootDirPath; -export const CONTEXT_ROOT = nodePath.join( - HASH_ROOT, - "libs/@hashintel/brunch-agent", -); -const PACKAGES_ROOT = nodePath.join(CONTEXT_ROOT, "packages"); -const APP_ROOT = nodePath.join(HASH_ROOT, "apps/brunch-agent"); - -export interface WorkspacePackage { - /** Package name from its manifest, e.g. `@hashintel/brunch-agent-plugin-gherkin`. */ - readonly name: string; - /** Directory basename — the role-prefixed one, e.g. `plugin-gherkin`. */ - readonly dir: string; - /** Absolute path to the package directory. */ - readonly path: string; - /** Path relative to HASH root. */ - readonly relPath: string; - /** `packages/` holds shells; `apps/` holds hosts. */ - readonly kind: "package" | "app"; - readonly manifest: PackageManifest; -} - -export interface PackageManifest { - name: string; - dependencies?: Record; - devDependencies?: Record; - exports?: Record; - scripts?: Record; -} - -function directoriesIn(parent: string): string[] { - return readdirSync(parent, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); -} - -export function workspacePackages(): WorkspacePackage[] { - const packagePaths = directoriesIn(PACKAGES_ROOT).map((dir) => ({ - dir, - kind: "package" as const, - path: nodePath.join(PACKAGES_ROOT, dir), - })); - const workspacePaths = [ - ...packagePaths, - { dir: "brunch-agent", kind: "app" as const, path: APP_ROOT }, - ]; - - return workspacePaths.map(({ dir, kind, path }) => { - const manifestPath = nodePath.join(path, "package.json"); - if (!existsSync(manifestPath)) { - throw new Error( - `${nodePath.relative(HASH_ROOT, path)} has no package.json — every Brunch workspace needs one.`, - ); - } - const manifest = JSON.parse( - readFileSync(manifestPath, "utf8"), - ) as PackageManifest; - return { - name: manifest.name, - dir, - path, - relPath: nodePath.relative(HASH_ROOT, path).replaceAll("\\", "/"), - kind, - manifest, - }; - }); -} - -/** Every declared dependency of a package, production and dev alike. */ -export function allDependencies(pkg: WorkspacePackage): string[] { - return [ - ...Object.keys(pkg.manifest.dependencies ?? {}), - ...Object.keys(pkg.manifest.devDependencies ?? {}), - ]; -} - -/** Runtime dependencies encode the production package direction. */ -export function runtimeDependencies(pkg: WorkspacePackage): string[] { - return Object.keys(pkg.manifest.dependencies ?? {}); -} - -export interface SourceFile { - readonly path: string; - readonly relPath: string; - readonly text: string; -} - -const SOURCE_EXTENSIONS = /\.(ts|tsx|mts|mjs|js|jsx)$/; -/** Never scanned: not authored here, or build output. */ -const SKIP_DIRECTORIES = ["node_modules", "dist", ".flue", ".git", ".turbo"]; -const TEST_DIRECTORIES = new Set(["test", "tests", "__tests__"]); - -/** Every source file under a directory, recursively. A missing directory yields none. */ -export function filesIn( - dir: string, - skip: readonly string[] = SKIP_DIRECTORIES, -): SourceFile[] { - if (!existsSync(dir)) { - return []; - } - const skipped = new Set(skip); - const found: SourceFile[] = []; - const walk = (current: string): void => { - for (const entry of readdirSync(current, { withFileTypes: true })) { - if (skipped.has(entry.name)) { - continue; - } - const path = nodePath.join(current, entry.name); - if (entry.isDirectory()) { - walk(path); - } else if (SOURCE_EXTENSIONS.test(entry.name)) { - found.push({ - path, - relPath: nodePath.relative(HASH_ROOT, path).replaceAll("\\", "/"), - text: readFileSync(path, "utf8"), - }); - } - } - }; - walk(dir); - return found; -} - -/** - * Every file of a package, partitioned into source and test — totally: a file - * under a test directory at any depth is a test file, and every other file is - * source. One walk feeding both sides is what makes the partition total by - * construction. The previous pair of independent walks left a hole (pruning - * test dirs at any depth on one side, collecting only top-level test dirs on - * the other), so a file in e.g. `src/test/` was governed by neither the - * source invariants nor the test-hermeticity rules. - */ -function partitionedFiles(pkg: WorkspacePackage): { - source: SourceFile[]; - test: SourceFile[]; -} { - const source: SourceFile[] = []; - const test: SourceFile[] = []; - for (const file of filesIn(pkg.path)) { - const segments = nodePath.relative(pkg.path, file.path).split(/[/\\]/); - (segments.some((segment) => TEST_DIRECTORIES.has(segment)) - ? test - : source - ).push(file); - } - return { source, test }; -} - -/** - * A package's own source files — everything it ships, wherever it puts them. - * - * Deliberately not `src/`-only: keying the scan off a directory convention - * would silently exempt a package laid out any other way from every file-level - * invariant, which is the exact failure the boundary suite exists to prevent. - * Tests are excluded because they are governed by their own rules. - */ -export function sourceFiles(pkg: WorkspacePackage): SourceFile[] { - return partitionedFiles(pkg).source; -} - -/** Every test file belonging to a package, at any depth. */ -export function testFiles(pkg: WorkspacePackage): SourceFile[] { - return partitionedFiles(pkg).test; -} - -/** - * A model-key environment variable name, as a pattern source for callers to - * anchor or extend. Composed rather than written literally, so the hermeticity - * check that reuses it does not flag this file's own source. - */ -export const MODEL_KEY_NAME = `[A-Z_]*${"API"}_${"KEY"}`; - -/** - * The module specifiers a source file imports or re-exports. - * - * Deliberately textual: the invariants being checked are about which packages - * a shell is *allowed to name at all*, so a regex over the source is the right - * granularity — and it catches type-only imports, which erase at runtime but - * still encode a forbidden dependency direction. - */ -export function importedModules(file: SourceFile): string[] { - const specifiers = new Set(); - const patterns = [ - /(?:^|\n)\s*import\s[^;]*?from\s*['"]([^'"]+)['"]/g, - /(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g, - /(?:^|\n)\s*export\s[^;]*?from\s*['"]([^'"]+)['"]/g, - /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, - /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, - ]; - for (const pattern of patterns) { - for (const match of file.text.matchAll(pattern)) { - if (match[1]) { - specifiers.add(match[1]); - } - } - } - return [...specifiers]; -} - -/** Bare package specifiers only — relative and absolute paths dropped. */ -export function importedPackages(file: SourceFile): string[] { - return importedModules(file).filter( - (specifier) => !specifier.startsWith(".") && !specifier.startsWith("/"), - ); -} - -/** `@scope/name` or `name` — the installable unit a specifier resolves to. */ -export function packageOf(specifier: string): string { - const parts = specifier.split("/"); - return specifier.startsWith("@") - ? parts.slice(0, 2).join("/") - : (parts[0] ?? specifier); -} diff --git a/libs/@local/repo-chores/node/turbo.json b/libs/@local/repo-chores/node/turbo.json index 61923ca7a0a..86bcf486428 100644 --- a/libs/@local/repo-chores/node/turbo.json +++ b/libs/@local/repo-chores/node/turbo.json @@ -1,17 +1,6 @@ { "extends": ["//"], "tasks": { - "lint:brunch-architecture": { - "inputs": [ - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/apps/brunch-agent/package.json", - "$TURBO_ROOT$/apps/brunch-agent/**/*.{ts,tsx,mts,mjs,js,jsx}", - "!$TURBO_ROOT$/apps/brunch-agent/{dist,node_modules,.flue,.turbo}/**", - "$TURBO_ROOT$/libs/@hashintel/brunch-agent/packages/*/package.json", - "$TURBO_ROOT$/libs/@hashintel/brunch-agent/packages/**/*.{ts,tsx,mts,mjs,js,jsx}", - "!$TURBO_ROOT$/libs/@hashintel/brunch-agent/packages/**/{dist,node_modules,.flue,.turbo}/**" - ] - }, "test:unit": { "dependsOn": ["^build"] } diff --git a/turbo.json b/turbo.json index 09443512d62..bd646ec6d78 100644 --- a/turbo.json +++ b/turbo.json @@ -110,7 +110,6 @@ "env": ["GITHUB_EVENT_NAME"], "dependsOn": ["^manifest"] }, - "lint:brunch-architecture": {}, // Type-aware linting resolves workspace imports from the dependencies' dist. "lint:eslint": { "env": ["CHECK_TEMPORARILY_DISABLED_RULES"],