diff --git a/.gitattributes b/.gitattributes index 8681aee12..dd49c4a03 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,10 @@ entrypoint.sh text eol=lf docker/entrypoint.sh text eol=lf +# Maintained frontend modules and this lane's byte-identical deployment output. +frontend/src/** text eol=lf +static/js/actionMenuOrder.js text eol=lf + # Windows-native scripts stay CRLF. *.ps1 text eol=crlf *.cmd text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51e12d341..42f0651f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,8 @@ jobs: python-version: "3.11" # Byte-compile sources — catches syntax errors without installing deps. - run: python -m compileall -q app.py core routes src services scripts tests + - name: Check committed frontend deployment modules + run: python scripts/build_frontend.py --check node-syntax: name: JS syntax (node --check) @@ -92,13 +94,19 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "20" + - run: npm ci --ignore-scripts --no-audit --no-fund + - name: Check frontend architecture + run: node scripts/check_frontend.mjs # Syntax-check our own JS (skip vendored libs in static/lib). - name: node --check run: | shopt -s globstar nullglob - for f in static/app.js static/js/**/*.js; do + for f in static/app.js static/js/**/*.js frontend/src/**/*.js; do node --check "$f" done + node --check scripts/check_frontend.mjs + - name: Test frontend architecture gates and stateless proof + run: node --test tests/frontendArchitecture.test.mjs python-tests: name: Python tests (pytest ${{ matrix.shard }}) diff --git a/docs/frontend-architecture/ADR-001-source-and-deployment.md b/docs/frontend-architecture/ADR-001-source-and-deployment.md new file mode 100644 index 000000000..cc1eca04e --- /dev/null +++ b/docs/frontend-architecture/ADR-001-source-and-deployment.md @@ -0,0 +1,135 @@ +# ADR 001: Feature-first native ESM with controlled deployment output + +Date: 2026-10-13. Status: implemented F1 proof, proposed for maintainer adoption. +Baseline: official `upstream/dev` at `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +Scope: source ownership and distribution foundation. Notes implementation is deferred. + +## Decision + +Maintain migrated JavaScript in `frontend/src/`. Publish native ES modules to +explicitly registered paths in `static/js/`, and commit source and output together. +Use `python scripts/build_frontend.py` to copy bytes without rewriting imports, +bundling, minification, source maps, timestamps or generated headers. A checked-in +`frontend/modules.json` maps source paths to deployment paths. All other static +files remain authored legacy modules or deployment assets until a reviewed slice +transfers their ownership. Do not claim that the whole static tree is generated. + +The proof transfers maintenance of the stateless action-menu ordering helper to +`frontend/src/shared/actionMenuOrder.js`. Its output remains +`/static/js/actionMenuOrder.js`, with the same three exports and identical bytes. +All seven current consumers, HTML, service worker and runtime entry points retain +their existing URLs. There is no wrapper, extra request or second browser module +instance. The source copy is a development input, not a second runtime entry point. + +Use `frontend/src/app/` for composition, `features//index.js` for feature +public APIs, and responsibility directories only when implementation requires +them. The [handbook](handbook.md) defines dependency, ownership and lifecycle rules. +F1 creates only the shared proof, without empty application or feature scaffolding. + +## Evidence and alternatives + +Both options were executed against this helper, rather than assumed to work. +Measurements are local compatibility observations, not general performance claims. + +| Property | Controlled native ESM copy, selected | esbuild 0.25.12 controlled ESM build | +| --- | --- | --- | +| Proof output | 1,688 bytes, identical SHA-256 to existing runtime | 1,554 bytes with `--bundle --format=esm --target=es2022 --platform=browser` | +| Repeatability | Clean temporary publication and idempotent rerun tested | Two independent compiler invocations emitted identical bytes | +| Runtime contract | Existing exports, imports, cache entry and module identity unchanged | Export names, icon and ordering matched in an executed Node import | +| Tooling | Python standard library; no production dependency | Compiler and platform binary needed for artifact production | +| Local cost | Copy check: 0.03 s, 15,596 KB maximum RSS | Cached `npx` ESM transform: 0.58 s, 114,196 KB maximum RSS | +| Incremental migration | Explicit file ownership, preserved module boundaries | Feasible for the stateless leaf; stateful facade/chunk identity still needs a separate graph spike | +| Release implications | Commit output; install/startup paths consume existing static assets | Would also need committed or release-produced output plus compiler locking and facade/chunk manifest policy | + +The timing rows measure different tool paths, including `npx` overhead. The +compiler's single-leaf success does not establish stateful graph or lazy chunk +compatibility. The 134-byte bundle reduction does not justify that additional +contract in F1. A future compiler adoption requires a new ADR and graph-level +evidence. React, Vue, TypeScript, a global state library, hashed chunks and a +development server have no demonstrated necessity here. + +Acorn 8.19.0 is added only as an exact, lockfile-pinned development dependency for +architecture checks. A real JavaScript parser is needed to enforce boundaries +across multiline imports, re-exports and lazy thunks without treating comments +and string literals as executable imports. It is MIT licensed, about 568 KB +unpacked, and contributes no deployed JavaScript or runtime dependency. + +## Distribution contract + +1. Source and generated output are reviewed and committed together. CI checks + byte parity; it does not silently rebuild and accept drift. +2. Native Python startup continues to use `STATIC_DIR` and `/static`, without + invoking the publisher. Native installers already obtain tracked static files. +3. Docker's current `COPY . .` includes the output. `.dockerignore` excludes + `node_modules/`. The Docker runtime and entrypoint require no frontend build. +4. `Odysseus.spec` and `build-windows-portable.ps1` already include `static/`. + The macOS wrapper uses the Python checkout and its static assets. Neither + needs the source tree or frontend tooling to serve the proof. +5. A release producer runs the checks before packaging and distributes the + entire matching static graph. The publisher is not a transactional deployment + mechanism. Partial copying of a new graph into a live deployment is unsupported. +6. New output paths must declare LF checkout behavior in `.gitattributes`, as + this proof does, so Windows checkout conversion cannot create false byte drift. + Before checking parity or writing output, the publisher uses the manifest to + require effective `text: set` and `eol: lf` on every source/output path. It asks + Git in a temporary repository containing only the applicable repository-owned + `.gitattributes` files, including nested rules. Global/local Git overrides + cannot supply missing declarations. No blanket legacy-JS rule is introduced. + Git is a development/publication prerequisite, not a startup dependency. + +An isolated distribution probe executes the real FastAPI lifespan, shell and +static requests after omitting `frontend/` and `node_modules/` and removing Node +from the child process search path. It verifies bytes, MIME, CSP, revalidation, +source-tree 404 behavior and static path confinement. The same probe runs in an existing Docker image +with networking disabled. This is not a claim that a fresh full Docker image or +Windows/macOS executable was built; see [validation](validation.md). + +## Imports, identity and offline operation + +New maintained modules use one unversioned deployment URL. Existing legacy URLs, +including their version queries, stay unchanged. A copy mapping must preserve +the resolution of every literal relative import; the architecture checker rejects +a mapping that changes that resolution. Canonical absolute `/static/js/...` +imports are also supported. No bare packages or browser imports of `frontend/src` +are supported. Existing graph and identity guards continue to cover the runtime. + +Each registered output joins the exact `PRECACHE` or `PANEL_PRECACHE` array. The +new checker parses those arrays, rather than accepting a URL mentioned in a +comment. Installation fetches do not evaluate lazy modules. Import-time DOM work, +timers, state creation and network I/O must be characterized before changing an +entry point's load timing. New source dependency cycles are rejected; existing +legacy cycles are inventoried and remain outside the new rules. + +The proof already has its exact precache entry, so no SW cache-name or HTML edit +is needed. Its first evaluation in a fresh offline browser page passes, and +importing the panel registry does not evaluate the editor. The current worker's +`/static/` scope cannot control the SPA root. Open PR #6227 addresses that existing +limitation; F1 does not fix or mask it. Full root offline reload is not certified. + +## Security and compatibility + +Keep backend authorization and owner scope authoritative. No agent, database, +auth, CSP, static mount or API implementation changes belong to this decision. +The helper remains same-origin ESM without eval, compiler runtime or external +resources. No new source maps or vendored assets are emitted; existing SRI/CSP +policy and distribution notices remain unchanged. The publisher validates path +containment, symlinks, duplicate ownership and unmapped source before writing. +It never deletes unowned static assets. Deleting or retiring outputs is an +explicit reviewed migration operation, not automatic cleanup. + +## Rollout, rollback and limits + +Adopt F1 for controlled, reviewed slices after maintainer review. Leave Notes, +bootstrap, HTML and service worker unchanged here. Register new files explicitly; +update offline manifests only when new runtime dependencies are introduced. +F1 rollback can remove the source manifest, proof tooling, gates and documentation +without changing runtime bytes. Later rollback restores the complete prior +source/output/facade/precache set through an authorized follow-up change. + +Boundary gates cannot prove semantic purity, owner isolation or correct teardown. +Domain platform/clock checks are intentionally conservative syntax guards; +behavior tests still establish those contracts. Worker URLs, styles and vendored +libraries are outside the maintained-JS import gate and need explicit review if +a future lane changes them. Legacy exceptions require an exact specifier and a +module-scoped reason; there are none in F1. New source is not permission to +grandfather new dependency cycles or to replicate broadly shared mutable state. diff --git a/docs/frontend-architecture/assessment.md b/docs/frontend-architecture/assessment.md new file mode 100644 index 000000000..d5c746c7c --- /dev/null +++ b/docs/frontend-architecture/assessment.md @@ -0,0 +1,127 @@ +# F1 runtime and architecture assessment + +Assessment date: 2026-10-13. Repository: `odysseus-dev/odysseus`. +Worktree: `worktrees/architecture/frontend-foundation`. +Branch: `refactor/frontend-architecture-foundation`. +HEAD, local `upstream/dev`, and the live GitHub official `dev` commit were all +`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The initial worktree was clean. +That commit is the merged PR #6495, selected Notes/Todos agent context. +`origin` points to the maintainer fork; integration targets official `upstream/dev`. + +Inputs read completely: the 2026-10-13 frontend architecture RFC and coding brief +from Downloads, `specs/frontend.md`, repository instructions and existing tests. +The current user brief supersedes the coding brief's `origin/dev` wording and +defines the Notes state/workflow slice before reminders. The design is self-contained +in this directory and does not require access to Downloads. + +Input provenance: + +- RFC SHA-256: `11dedcbb9971f4d787f2721b9c27aa43299baba39ca91066d01507d2d69ac4f8`. +- Coding brief SHA-256: `e4882867617b9c732ce4f445e3bc2da0b4ec1315119f8226c1fd8c1acbacf29d`. + +## Module graph and bootstrap + +`node scripts/check_frontend.mjs --inventory` reproduces the parser-based inventory. +It excludes vendored `static/lib/` and committed editor build output. At this base +it scans 230 JS files, finds 707 static import/re-export edges and 108 literal +dynamic import edges, and finds no missing targets. No computed dynamic imports +were found in the original parser audit. The earlier existing-regex scan counted +814 edges; executable syntax parsing counts 815. Use parser evidence for design +and retain the existing regression guard unchanged. + +| Area | Current boundary and implication | +| --- | --- | +| HTML shell | `static/index.html`: nonce-bearing boot scripts, CSS/preloads, direct ESM script tags, then app/init/a11y. Multiple entry roots mean module identity matters. | +| App composition | `static/app.js`: feature imports, globals, route/sidebar composition, session hydration and auth-fetch wiring. It eagerly imports Notes, Calendar, Chat, Documents and Settings. | +| Startup | `static/js/startupShell.js`, `sessions.js`, `init.js`: first-paint scheduling, deferred hydration, user-switch storage cleanup and cosmetic permission gates. Preserve boot ordering. | +| Lazy loading | `static/js/panels.js`: only `editor` is registered. A memoized import thunk shares successful/in-flight promises and retries failed imports. Do not label all panels lazy. | +| Optional libraries | `markdown.js` lazily loads vendored KaTeX/Mermaid. KaTeX and fonts are precached; Mermaid becomes cached after first use. Pyodide has a CDN dependency. | +| Feature packages | Settings registry/navigation/finder/lifecycle, Calendar utilities/reminders, Email wrapper/package and editor tools already demonstrate partial decomposition. | + +Highest import fan-in: `ui.js` 77, `spinner.js` 55, `editor/state.js` 39, +`escMenuStack.js` 23, and storage/modal/z-order helpers 17 each. The measured +static strongly connected components are: + +- Theme and UI. +- Email Inbox, compatibility wrapper, package index, AI reply, attachments, + body rendering, menus, reader, settings page and unsubscribe. +- Cookbook, diagnosis, HW Fit, download, running and serve modules. + +These are existing compatibility constraints, not approved patterns for new +source. Dynamic edges connect more features but do not themselves establish +initialization cycles. No global legacy dependency rewrite is attempted in F1. + +## State, side effects and ownership + +| Owner | Evidence and architectural treatment | +| --- | --- | +| Shared configuration | `appConfig.js` owns invalidatable settings/tool request promises and consumes a sessionStorage prefetch once. Retain single identity and writer invalidation. | +| Browser persistence | `storage.js` centralizes keys and safe access; feature files also use direct localStorage. `init.js` cleans storage on user changes. Preferences are not an authorization store. | +| Feature state | Notes has module variables, selection sets, undo/drafts and reminder state. Email exports a broadly mutable package state object; editor state is imported by 39 modules. Keep ownership explicit; do not expand these into a global store. | +| Import-time effects | Notes reads storage/window and schedules reminders. Email state reads localStorage. Theme/UI initialize from DOM readiness; modal manager installs global listeners. Chip/mobile helpers bind at startup. Moving an import can change behavior before any exported function is called. | +| Compatibility globals | App exposes controllers on `window`; Notes exposes `window.notesModule`. They are compatibility bridges, not the new inter-feature API convention. | +| Async ownership | Session hydration, config invalidation, draft serialized saves and request-generation guards already prevent stale completion. New slices must preserve these rather than only move declarations. | + +Good patterns to retain: narrow zero-state leaves, explicit wrapper export lists, +public API tests, retryable memoized lazy imports, shared config invalidation, +request-generation checks and source/package-aware regression helpers. + +Patterns to avoid: blanket barrels exposing internals, feature imports from +generic helpers, cycles through coordinators, direct writes to another feature's +state or DOM, anonymous global listener ownership, import-triggered network/timer +initialization in new APIs, and helpers grouped by convenience instead of purpose. + +## Runtime, assets and distribution + +| Contract | Verified source | +| --- | --- | +| Static URL and revalidation | `app.py`: `register_static_mime_types`, `_RevalidatingStatic`, `/static` mount. JS MIME is `text/javascript`; JS/CSS/HTML have `Cache-Control: no-cache`. | +| App paths | `src/constants.py` and `src/runtime_paths.py`: checkout root in native mode, PyInstaller `_MEIPASS` in frozen mode; static assets follow app root, persisted data follows data-directory policy. | +| CSP | `core/middleware.py`: same-origin scripts, nonce and jsDelivr allowance; `src/app_helpers.py` injects HTML nonces. No CSP relaxation is needed for the proof. | +| Integrity | No `integrity=` attributes were found in the shell. Proof output is byte-identical and emits no source maps; no existing SRI or vendored artifacts change. | +| Runtime-generated assets | `/api/generated-image/...` uses a confined data path and owner handling; report/render endpoints, Blob manifests/icons and uploads are runtime concerns outside static source publication. | +| Docker | `Dockerfile` copies app code and existing static output; `.dockerignore` excludes node_modules. Node/npm in the image support Browser MCP, not frontend startup. | +| Native installers | `setup.py`, `start-macos.sh`, `launch-windows.ps1`, systemd service: Python dependencies/startup consume the checkout. No frontend build is invoked. | +| Windows desktop | `Odysseus.spec` and `build-windows-portable.ps1` include the whole static directory. Maintain output here rather than require a new bundle path or compiler. | +| macOS | `build-macos-app.sh` creates a launcher around the Python checkout. Static serving follows the existing checkout path. Installer execution was not performed on Linux. | + +## PWA limitations and test coverage + +`static/sw.js` matches exact URLs, including queries. Install fetches each +precache entry separately and tolerates failed fetches. It does not discover +transitive dependencies. HTML root and JS/CSS handlers are network-first; +other static assets are cache-first with background refresh. API and non-GET +requests bypass caching. Cache updates need explicit version/manifest review. +The older frontend spec's root stale-while-revalidate description is stale. + +Registration in `static/index.html` currently requests no wider scope, so the +worker controls `/static/`, not `/`. Root offline reload is therefore a known +pre-existing gap. PR #6227 proposes the scope/header correction. F1 tests actual +offline ESM loading under the current scope and leaves that separate fix alone. +Offline APIs, first-use Mermaid and optional CDN runtimes are not certified. + +Existing coverage includes runtime graph resolution and version parity; editor +offline graph coverage and lazy-loader retries; Email API/export/evaluation +coverage; Notes autosave, XSS, selection/listener and agent-context regressions; +MIME, nonce and runtime paths. Tests use pytest, Node's built-in runner and existing +Playwright Chromium. `tests/TESTING_STANDARD.md` requires behavior-first, isolated +tests. F1 adds real publication failures, architecture violations and an offline +browser proof. It does not replace existing source-sensitive tests. + +## Open PR overlap, checked live on 2026-10-13 + +The open-PR listing was fetched through all pages; selected PR file lists were +inspected. Refresh this inventory before any later slice. + +| PR | Overlap and disposition | +| --- | --- | +| [#6470](https://github.com/odysseus-dev/odysseus/pull/6470) | Theme/sidebar redesign touches HTML, SW, shared UI and CSS. F1 changes none of those assets. Future manifest/UI slices need collision review. | +| [#6695](https://github.com/odysseus-dev/odysseus/pull/6695) | OXS touches bootstrap, HTML, platform, middleware and other backend boundaries. F1 does not establish an extension API or modify that platform. | +| [#6227](https://github.com/odysseus-dev/odysseus/pull/6227) | Root SW scope/header fix. Independent follow-up dependency for broader offline certification. | +| [#6394](https://github.com/odysseus-dev/odysseus/pull/6394) | Older CSS extraction touches legacy style/SW assets; requires separate supersession review. | +| [#6627](https://github.com/odysseus-dev/odysseus/pull/6627) | npm development-dependency update overlaps package/lock files; reconcile the exact Acorn development entry on integration. | +| [#6585](https://github.com/odysseus-dev/odysseus/pull/6585), [#6647](https://github.com/odysseus-dev/odysseus/pull/6647), [#6652](https://github.com/odysseus-dev/odysseus/pull/6652) | Notes optional-field persistence and agent routing/dispatch. Future characterization must reflect their merged state; F1 changes no Notes or backend behavior. | +| [#6594](https://github.com/odysseus-dev/odysseus/pull/6594), [#6152](https://github.com/odysseus-dev/odysseus/pull/6152) | Desktop launcher/build and frozen-runtime packaging work. Existing paths stay stable; packaging still needs platform-owner validation. | + +PR titles alone do not establish compatibility. This report records the checked +file overlap and relevant dependencies, not approval of those implementations. diff --git a/docs/frontend-architecture/handbook.md b/docs/frontend-architecture/handbook.md new file mode 100644 index 000000000..103e02ee9 --- /dev/null +++ b/docs/frontend-architecture/handbook.md @@ -0,0 +1,223 @@ +# Odysseus frontend architecture handbook + +Status: F1 foundation and continuation guide, pending review. The final Notes +reference case study belongs to the comprehensive Notes draft PR; the recipient +handoff follows its integration. This document must evolve with reviewed internal +checkpoints and distinguish executed work from proposals. + +Read the [ADR](ADR-001-source-and-deployment.md), [assessment](assessment.md), +[validation report](validation.md) and [Notes roadmap](notes-roadmap.md). +The frontend program ends with the Notes reference decomposition and handoff. +Calendar, Documents, Email, Chat, Skills, Settings and other later decompositions +belong to the designated frontend maintainer; do not start them automatically. + +## Source, ownership and public APIs + +`frontend/src/app/` composes public feature APIs and platform ports. Each +`features//index.js` is an intentional API, with explicit named exports +and a compatibility default only when existing consumers require one. Internals +live under `domain/`, `application/`, `infrastructure/` or `ui/` as needed. Do not +create empty directories, boilerplate controllers or a framework by convention. +Feature names `app` and `shared` are reserved. + +Keep pure business rules in domain; transitions and workflows in application; +HTTP/storage/browser services in infrastructure; DOM rendering and lifecycle in +UI. The index composes adapters, state and views. Application receives adapters +as explicit arguments or constructor ports, rather than importing concrete +infrastructure. Views call application operations; infrastructure does not own +business decisions. A public API returns data/operations, not mutable stores or +private DOM nodes. Importing an API should define capabilities; explicit lifecycle +methods activate them. Legacy side effects remain characterized until a reviewed +slice safely changes their placement. + +Shared code has a named, proven cross-feature responsibility. `shared/domain/` +holds pure primitives, `shared/platform/` holds narrowly scoped platform adapters, +and `shared/ui/` holds common DOM capabilities. A named stateless root leaf such +as `shared/actionMenuOrder.js` is also allowed and receives domain restrictions. +Do not create a central `utils.js` collection or move feature business rules into +shared simply to satisfy an import rule. + +## Mechanically checked dependency contract + +| Importer | Permitted maintained-source targets | +| --- | --- | +| App composition | Other app code, shared code, feature `index.js` APIs | +| Feature index | Its own responsibility modules and shared code; other feature public indexes | +| Feature domain | Its own domain and shared domain/stateless leaves | +| Feature application | Its own application/domain, shared domain, other feature public indexes | +| Feature infrastructure | Its own infrastructure/domain, shared domain/platform | +| Feature UI | Its own UI/application/domain, shared domain/platform/UI | +| Shared domain leaf | Shared domain leaves only | +| Shared platform | Shared platform/domain only | +| Shared UI | Shared UI/platform/domain only | + +No feature imports app composition. No shared code imports a feature. No external +feature imports private responsibility modules. UI and infrastructure communicate +with another feature through a supplied application operation/port. Maintained +source dependency cycles, including literal lazy imports, fail the architecture +gate. If features need bidirectional coordination, app composition injects a +callback or adapter instead of creating mutual imports. Prefer this even when a +one-way public index import would pass the gate. + +Cross-feature events must document event name, payload, acknowledgement, owner, +subscription disposal and delivery timing. Keep them local and specific; F1 adds +no global event bus. Preserve `odysseus:notes-context-selected` and its synchronous +`accepted` acknowledgement in future Notes work. The server remains authoritative +for owner filtering and untrusted selected IDs. Frontend APIs cannot grant access. + +The checker parses executable ESM, forbids computed imports in new source, +requires exact precache entries, rejects unresolved/remapped relative imports, +and permits only canonical static JS URLs with one unversioned identity for +maintained modules. Domain code cannot mention known browser/HTTP/timer globals +or an ambient clock; use `now`/clock inputs. This is a conservative vocabulary +check, including shadowed names, not a semantic purity proof. Randomness, hidden +side effects, authorization and lifecycle behavior require behavioral tests. + +Legacy imports from new source default to forbidden. If an incremental bridge is +necessary, declare `legacyImports` on that source's manifest entry as a list of +`{ "specifier": "/static/js/example.js?v=existing", "reason": "contract, owner and removal slice" }`. +Only feature composition, infrastructure or UI boundaries may use it. Exact +specifier/reason and target existence are required; unused exceptions fail. +Keep the existing query identity. New domain/application modules receive no +legacy exception. F1 has zero exceptions. Do not use exceptions to invent new +shared platform dependencies on feature internals. + +## State and lifecycle + +Each feature has one authoritative mutable application-state owner. A transition +operation owns writes; UI receives a view/snapshot or subscription. Preserve +existing objects and initialization timing when extracting a stateful module. +Do not instantiate a new store in both the legacy facade and the feature API. +Compatibility facades only forward the same live implementation. + +Document lifecycle scopes: page, authenticated user, session, mounted panel and +editor/draft. Name owners of subscriptions, DOM listeners, timers, observers, +pending requests and caches. Mounting must be idempotent; disposal releases the +resources it owns. Background reminders may outlive an open panel. Closing the +panel must not silently stop those reminders during extraction. + +Keep request-generation and selected-session checks around asynchronous +completions. Serialize writes where the current contract requires it. Dispose +subscriptions explicitly and prevent late updates to a replaced view. Feature +state changes on user/session switch must follow current cleanup policy. Browser +storage is for preferences/drafts/cache state, never a substitute for backend +authorization, credential storage or owner filtering. Define every storage key's +owner, retention/reset behavior and compatibility format before moving it. + +## Creating a module or introducing a feature + +1. Identify responsibility, owner, public consumers, side effects and behavioral + evidence before editing. Start with a pure leaf when introducing a feature. +2. Place only the required source file(s), for example + `features/example/domain/calculate.js`. Add an index only when there is a + real supported feature API to compose. +3. Register each source in `frontend/modules.json`. A typical new mapping is + `features/example/domain/calculate.js` to + `js/features/example/domain/calculate.js`. Preserve the source/deployment + relative graph. The proof's legacy URL mapping is an explicit special case. +4. For an extracted leaf, the legacy feature coordinator may import its output + as a temporary composition bridge. New maintained features use public indexes; + the legacy tree is not globally forced into the new boundary rules in F1. +5. Add the exact output URL to the appropriate explicit SW array, retain lazy + evaluation timing and bump the cache name when the graph changes. Declare + `text eol=lf` for both mapped paths in repository-owned `.gitattributes` + files; source-tree or scoped output patterns are allowed. The publisher checks + effective Git rules, including nested overrides, and rejects a missing LF rule + even if output bytes already match. Do not broaden unrelated legacy checkout + rules or automatically rewrite all existing query identities. +6. Run the publisher, inspect the source/output diff, then run architecture, + identity, graph, offline and focused behavior checks. Include both sides of + the mapping in review. Retiring output requires a separate reviewed deletion; + the publisher does not clean the static tree. + +```sh +python scripts/build_frontend.py +python scripts/build_frontend.py --check +npm ci --ignore-scripts --no-audit --no-fund +node scripts/check_frontend.mjs +node scripts/check_frontend.mjs --inventory +node --test tests/frontendArchitecture.test.mjs +./venv/bin/python -m pytest -q -rs tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py tests/test_panel_loader_js.py +./venv/bin/python scripts/verify_frontend_runtime.py +``` + +Use your actual repository virtualenv path; the F1 environment is recorded in +validation. Node 20+ follows current CI. Existing Playwright/browser prerequisites +are documented in `tests/README.md` and `tests/KNOWN_FAILURES.md`. Missing Node can +skip Python browser wrappers, which is a coverage gap, not a pass. The standalone +Node gates require locked development dependencies and fail when missing. + +The publisher validates all inputs before writing and only overwrites owned +outputs whose bytes differ. It is idempotent and working-directory independent. +Git must be available for publication and `--check`; neither runs at application +startup. Attribute evaluation uses a private temporary Git repository and ignores +global/local overrides, so checkout consistency is reproducible in CI. It adds +no runtime configuration or backend imports. CI checks LF attributes, parity and source +syntax in existing jobs; the Node job also runs architecture self-tests. Existing +runtime identity tests remain authoritative across legacy imports, HTML and SW. +The isolated runtime probe is included in the new pytest publication tests. + +## Review, release and rollback + +Review the source/output mapping and exact exported API, then the owner/dependency +contract and behavior tests. Check manifests, URL identities, MIME/CSP, load timing, +storage compatibility and disposal. Reject unexplained feature behavior changes. +Do not repair unrelated defects under an extraction title. + +Release from a validated revision with committed output. Native startup and +packagers consume static files without a compiler. Run parity before producing a +Docker or desktop artifact. Test the actual packaged application on its platform; +Linux contract probes cannot certify macOS/Windows executables. Deploy matching +shell, facades, graph and manifests as a unit. Keep an authorized path to the last +complete artifact. Rollback restores the whole matching set, not just an entry +file or cache version. Do not delete a facade until its public consumers have +migrated and compatibility tests make that decision reviewable. + +## Proposal and PR templates + +For a decomposition proposal, record: baseline; responsibility map; current public +API and consumers; state owner; I/O and lifecycle boundaries; planned slice; +allowed dependencies/temporary exceptions; preserved behavior; deterministic +characterization tests; deployment/offline changes; overlapping PRs; rollback; +explicit non-goals and remaining uncertainties. + +For each migration PR, lead with the responsibility moved and resulting boundary. +Record source/output paths, API compatibility, state/lifecycle ownership, +characterization results, exact validation commands, platform gaps, exception +removal plan, upstream dependencies and rollback. Attach a concrete before/after +example when needed to establish behavioral equivalence. Preserve the repository +PR template and never claim unexecuted checks. + +F1 is an entirely separate foundation-only PR. Deliver the complete Notes +decomposition, regression tests, compatibility validation and final reference +case study through one draft PR targeting official `dev`, using one Notes branch +and worktree after F1 integration. N0 through N6 and the N5 substages are internal +engineering checkpoints, not independent PRs or merges. Each checkpoint records +focused tests, review evidence, its validated revision or reproducible snapshot, +and rollback to the prior matching source/output/facade/precache set. Keep the +draft open while those checkpoints progress; mark it ready only after the entire +decomposition passes the [roadmap's acceptance gates](notes-roadmap.md). + +Structure the Notes PR description around the final integrated design and link +each checkpoint's evidence. The receiving maintainer may use smaller independent +PRs for future frontend features at their discretion; the one-PR Notes decision +does not impose that delivery size on later work. + +## Program backlog and final handoff + +Priority order: review/integrate F1; resolve root offline certification with the +owner of PR #6227; complete the Notes checkpoints and final reference case study +within one draft PR following the [roadmap](notes-roadmap.md); integrate the +complete Notes PR after its acceptance gates; finalize the recipient handoff. +Only then does the receiving maintainer prioritize Calendar, Documents, Email, +Chat, Skills and Settings against current behavior and risk. Existing partial +packages and high fan-in helpers need assessment, not automatic replacement. + +Final H acceptance requires a designated maintainer, the integrated Notes PR +revision and its internal checkpoint receipts, the actual public API/state/lifecycle +map and final reference case study included in that PR, validated deployment/offline +commands, known exceptions/debt, +outstanding PR dependencies, rollback evidence and a reproducible independent +setup by the recipient. F1 provides the handbook structure and contracts; it does +not claim Notes or the completed handoff have occurred. The initiating architect +then returns to separately authorized non-frontend architecture work. diff --git a/docs/frontend-architecture/notes-roadmap.md b/docs/frontend-architecture/notes-roadmap.md new file mode 100644 index 000000000..6371e9aca --- /dev/null +++ b/docs/frontend-architecture/notes-roadmap.md @@ -0,0 +1,173 @@ +# Notes reference decomposition roadmap + +Status: planning only. F1 changes no Notes implementation. Begin after the +foundation has been reviewed and integrated into official `upstream/dev`. +The reference program ends after Notes integration and the completed H handoff. +F1 remains an entirely separate foundation-only PR. The complete Notes +decomposition, regression tests, compatibility validation and final reference +case study belong to one comprehensive draft PR targeting official `dev`. + +## Verified current responsibility and compatibility map + +Baseline: `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +The module is `static/js/notes.js`, about 5,470 lines, with browser identity +`/static/js/notes.js?v=20260911notesselectioncancel1`. Preserve that identity across +the app import, HTML, SW and lazy consumers until a separately reviewed migration +changes the contract everywhere. + +| Responsibility | Current evidence | Intended owner after its reviewed slice | +| --- | --- | --- | +| State and CRUD | `_notes`, selection/filter/editing/loading variables, `_undoStack`, `_fetchNotes`, `_saveNote`, `_patchNote`, `_archiveNoteById` | One application state owner and workflow operations; no second reminder/UI copy | +| Dates and recurrence | `_normalizeRepeat`, `_advanceRecurring`, `_nthWeekdayOfMonth`, `_lastWeekdayOfMonth`, `_toLocalDatetimeStr`, repeat formatting helpers | Pure domain calculations with explicit clock inputs | +| Network and persistence | Notes CRUD/reorder, upload, reminder firing, draft and reminder localStorage keys | Infrastructure adapters preserving request payloads, credentials and error contracts | +| Reminders | `_checkReminders`, `_startReminderLoop`, `_initReminders`, notification permission/display and due/fired badge calculations | Application reminder workflow plus injected clock/storage/notification/API ports | +| Draft autosave | `_draftKey`, `_collectFormDraft`, `_wireDraftAutosave`, `_applyDraftToNote`, `_commitOpenInPlaceEditor` | Draft workflow with editor view/persistence ports and serialized saves | +| UI | `openPanel`, `closePanel`, `_renderNotes`, forms/cards/checklists, masonry, color/photo/drawing, mobile swipe/reorder | Scoped UI modules with explicit mount/dispose and application operations | +| Agent context | `#notes-send-agent` handler, selected IDs, `odysseus:notes-context-selected` | Preserve event payload and synchronous acknowledgement; Chat owns attachment/session state | +| Agent solve action | `_agentSolveNote`: default-chat lookup, session creation and chat stream | Separate existing workflow; do not conflate with selected-note attachment | +| Compatibility coordinator | Default controller, named aliases, `window.notesModule`, delayed reminder startup | Stable legacy facade/composition bridge until consumers safely migrate | + +The default API currently includes `openPanel`, `closePanel`, `togglePanel`, +`isPanelOpen`, `openNote`, `newNote`, `openNotes`, `closeNotes`, `isNotesOpen` and +`refreshDueBadge`. Named exports also include `dismissFiredReminderDot` and the +panel/open aliases. Characterization must pin the complete actual namespace and +default object separately, including identity of aliases and the window bridge. + +Consumers include `static/app.js` sidebar/routes/badge polling, +`chatStream.js` note tool updates, `chatRenderer.js` note links and +`document.js` coordination. `static/index.html` and `static/sw.js` are entry/cache +contracts. The shared UI, confetti, modal, color picker, drag, tiling, docking, +z-order and Escape helpers are direct dependencies. Those legacy dependencies +remain in the coordinator until their ownership is reviewed, rather than moving +all UI coupling into domain modules. + +Import-time behavior includes window origin/storage reads and +`setTimeout(_initReminders, 3000)`. The successful bootstrap loads Notes and starts +a 30-second reminder interval. App composition separately refreshes the badge +every five minutes. Panel/editing listeners, draft timers, highlight animations, +ResizeObserver and drawing/touch handlers have shorter scopes. Record them before +extraction; do not stop background reminders merely because a panel closes. +Listener cleanup concerns require characterization, not an unrelated fix hidden +inside an extraction. + +Persistence includes `odysseus-notes-view`, label collapse and hint preferences, +fired/glowed/pending/active reminder markers, dismissed timestamp, and +`odysseus-note-draft-` including `__new__`. Current direct APIs include Notes +CRUD/reorder/fire-reminder and upload, plus default-chat/session/chat-stream for +the existing solve action. Backend owner isolation and permission handling stay +authoritative and are outside this frontend decomposition's implementation scope. + +## PR #6495 contract that every relevant slice preserves + +Notes sends `odysseus:notes-context-selected` with `{ noteIds, accepted: false }`. +Chat's synchronous listener accepts only a valid active or pending chat, updates +session/pending selection, sets `accepted`, and updates the context indicator. +Notes clears select mode only after acceptance. Session lifecycle events +`odysseus:session-changed` carry session/materialization information. Chat appends +selected IDs as `selected_note_ids` when sending; server parsing validates IDs, +filters by owner and treats the resulting prompt context as untrusted. + +Keep selection transfer to a materialized chat, reset on a new chat, rejection +when no chat/model can accept it, aliases and indicator behavior. Do not broaden +frontend selections into permissions or bypass the backend. See +`tests/selectedNotesContext.test.mjs`, `tests/test_selected_notes_context.py`, +`static/js/chat.js` selected-context listeners and send payload, and +`static/js/sessions.js` lifecycle event producers. + +## One Notes branch, worktree and draft PR + +After F1 integration and authorization to begin Notes, create one worktree and +branch, suggested name `refactor/notes-reference-decomposition`, from the latest +official `upstream/dev`. N0 through N6 are sequential internal engineering +checkpoints on that same branch. No independent GitHub PR is required for a +checkpoint or a UI substage. Maintain one draft PR targeting official `dev`, with +checkpoint evidence linked from its description. Keep it a draft until the +complete decomposition passes all acceptance gates below. + +| Checkpoint | Responsibility | Deliverable and exit evidence | +| --- | --- | --- | +| N0 | Characterization | Public API/consumer, responsibility, state, lifecycle and network map; deterministic behavior tests; PR-overlap refresh; no extraction | +| N1 | Recurrence domain | Only pure date/recurrence rules to `features/notes/domain/recurrence.js`; preserve old wrapper call contracts, time-read position and returned strings; new offline dependency registered | +| N2 | Infrastructure | HTTP and storage adapters with exact request/error/key contracts; retain state ownership and backend authorization | +| N3 | Application | Single state owner and CRUD/optimistic/undo workflows; UI/reminders call operations; no competing arrays or stores | +| N4 | Reminders | Timer/notification/reminder orchestration through explicit ports; characterize startup, catch-up, deduplication, badge and background behavior | +| N5 | UI substages | Bounded card/panel, editor/draft, checklist, drawing and mobile reviews within the same PR; explicit listener/observer disposal and the same application operations | +| N6 | Compatibility and reference case study | Reduce coordinator; full API/consumer/lifecycle/offline comparison; retain legacy facade unless removal is proven safe; complete the final reference case study in the same PR | + +Do not create the Notes worktree, branch or PR during F1. N5 remains several +bounded internal reviews rather than a second monolith move. Every checkpoint +and UI substage must have its own focused tests, review evidence, lessons, +exceptions and a rollback plan. Record the validated revision or reproducible +patch/snapshot, prior known-good checkpoint, exact test commands/results and +how to restore its matching source/output/facade/precache set while preserving +unrelated work. Checkpoint commits require the applicable maintainer authority; +no checkpoint or commit is created by this planning document. + +Advance only after the current checkpoint's required checks and review findings +are resolved. A failed checkpoint returns to its prior validated state or receives +a bounded correction on the same branch; it does not require a separate PR. +If upstream changes require updating the baseline, update the same Notes branch, +record that revision and rerun affected checkpoint and compatibility gates. + +## Complete Notes PR acceptance gates + +Before marking the single draft PR ready for review: + +- Complete N0 through N6, including all N5 substages, with their tests, review + receipts and reproducible checkpoint/rollback evidence. +- Pass the required full Notes regression and compatibility checks on the final + combined revision, including PR #6495 selection/context behavior, public APIs, + module identities, single state ownership, storage formats, timers and teardown. +- Pass source/output parity, LF attribute, architecture, import graph and exact + offline manifest gates; verify current bootstrap/lazy behavior and relevant + native/packaging contracts. Record platform limits and distinguish preserved + offline module behavior from separately certified root offline reload. +- Resolve required gate failures and review findings; document unrelated existing + failures or coverage gaps explicitly rather than bypassing required checks. +- Include the final reference case study in this PR: actual before/after APIs, + dependency/state/lifecycle maps, stage decisions and lessons, validation recipes, + temporary exceptions, known debt and tested rollback/compatibility procedures. + +These are readiness gates for the complete PR, not separate stage merges. +The receiving maintainer may choose smaller independent PRs for future frontend +features; this one-PR delivery decision applies to the Notes reference program. + +## N0 and N1 characterization requirements + +Pin recurrence normalization for legacy bare repeat values and explicit values; +none/unknown/malformed input; daily/weekly/monthly/yearly advancement; month ends, +leap years, nth/last weekdays including absent fifth occurrences; overdue catch-up +and the 5,000-step guard; future anchors; local serialization and preserved hours; +DST gaps/overlaps under explicit time zones. `_advanceRecurring` currently reads +`Date.now()` after its first step. Inject a clock without changing that ordering. +Record the existing behavior of invalid dates rather than inventing improvements. + +Characterize request error handling, optimistic rollback/undo, archive/delete, +selection/filter/reset, ordering, uploads and notification permission errors. +For drafts, preserve synchronous local backup before the 500 ms persistence +timer, intentional empty text, checklist type fallback, serialized writes, +failure recovery and refusal to clear a newer draft when an older save finishes. + +Use stub clocks/ports and temporary storage for unit behavior; browser tests for +public namespace evaluation, DOM lifecycle, selected context and actual offline +loading. Run real modules through their output paths. Do not rewrite source-range +tests wholesale; migrate each only with its responsible slice and equivalent +behavioral assertions. Existing source-sensitive Notes autosave tests currently +read `notes.js`, so extraction has an explicit test-maintenance dependency. + +Before a slice, refresh #6585/#6647/#6652 backend behavior and #6470/#6695 shared +UI/platform overlaps from the [assessment](assessment.md). Existing fixes may +change characterization inputs, but this program does not merge or silently +reimplement those PRs. The independent root-SW fix #6227 is required before a +claim of full SPA offline reload readiness. + +## H completion and stopping rule + +The final reference case study is completed inside the Notes PR before it becomes +ready for review. After that PR is integrated, finalize H with the integrated +revision and checkpoint-to-integration receipts, outstanding dependencies and +the recipient's independent reproduction. Have the receiving maintainer +reproduce setup and validation independently and record that receipt. Transfer +future feature ownership explicitly. Do not automatically begin Calendar, +Documents, Email, Chat, Skills or Settings; the initiating architect continues +with separately approved non-frontend monolith work. diff --git a/docs/frontend-architecture/validation.md b/docs/frontend-architecture/validation.md new file mode 100644 index 000000000..1e85db081 --- /dev/null +++ b/docs/frontend-architecture/validation.md @@ -0,0 +1,453 @@ +# F1 implementation and review report + +Date: 2026-10-13. Recommendation: ready for controlled incremental adoption after +maintainer review, with the platform/offline limits below. The foundation proof +is implemented and tested; Notes decomposition and the completed H handoff are +planned only. No commit, push, merge, PR or issue operation was performed. + +## Notes delivery documentation correction + +The maintainer's final delivery decision is one comprehensive Notes draft PR +targeting official `dev`, after the separate foundation-only F1 PR is integrated. +N0 through N6 are internal engineering checkpoints in one Notes worktree and +branch. The complete decomposition, regression tests, compatibility validation +and final reference case study belong to that single PR; it stays a draft until +the complete acceptance gates pass. Each stage retains its own test results, +review receipts and reproducible rollback/checkpoint strategy, without requiring +an independent GitHub PR. The receiving maintainer may choose smaller independent +PRs for future frontend features. + +This correction updates only `notes-roadmap.md`, `handbook.md` and this report. +Implementation and test files retain their pre-correction bytes. All five +foundation documents passed link, fence, whitespace, no-em-dash and delivery +wording checks. `git diff --check` and the preserved-runtime diff check passed. +A before/after snapshot confirmed exactly these three changed documents and +15 unchanged existing F1 files. The implementation test results below remain +the previous executed evidence; implementation tests were not rerun in this +documentation-only pass. + +## Independent review correction + +The maintainer reports that the independent F1 review found no critical +architectural defects. This pass preserves the reviewed native ESM architecture +and scope. Its correction is to turn the documented LF checkout convention into +a manifest-driven publisher/CI requirement. The full independent review text +was not supplied; this report records the supplied finding and executed evidence. + +The correction changes only `scripts/build_frontend.py`, +`tests/test_frontend_build.py`, the ADR, the handbook and this report. The +`.gitattributes` rules, architecture checker, CI workflow, manifest, dependencies +and stateless source remain as reviewed. The full F1 inventory below still has +18 files, including `.gitattributes`. + +The publisher now evaluates both paths of every manifest entry through Git's +actual attribute engine before parity evaluation or any deployment write. It +requires `text: set` and `eol: lf` from applicable repository-owned attribute files, +including nested overrides. A private temporary repository and isolated Git +configuration prevent global attributes, local `.git/info/attributes`, templates +or alternate Git directory settings from masking an omitted declaration. Paths +are sent through NUL-delimited stdin, preserving spaces and using no shell/path +quoting assumptions. The working repository's attributes, index and output are +not changed by this check. Git is a publication/development prerequisite; the +application does not invoke it to build or check frontend assets at startup. + +The production integration diff is the new `check_lf_attributes(root, paths)` +helper, four standard-library imports, this call, and updated CLI help: + +```diff +@@ synchronize + if unlisted: + raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}") ++ check_lf_attributes(root, sources | outputs) + stale = [] +``` + +The tests add fixture LF declarations and 14 cases. Ten parameterized cases +reject missing LF, CRLF, auto-text and binary-text rules for source/output paths +in both publish and check modes, even when bytes already match. The other four +cover a future manifest pair failing before any output is published and passing +after its LF declaration; nested rules and a spaced path; global/local settings +failing to mask an omitted repository rule; and clear failure without development +Git. Existing publication, ownership, confinement, parity and runtime tests remain. + +## Exact attributes diff and checkout evidence + +`git diff -- .gitattributes` is unchanged from the reviewed F1 diff: + +```diff +diff --git a/.gitattributes b/.gitattributes +index 8681aee1..dd49c4a0 100644 +--- a/.gitattributes ++++ b/.gitattributes +@@ -10,6 +10,10 @@ + entrypoint.sh text eol=lf + docker/entrypoint.sh text eol=lf + ++# Maintained frontend modules and this lane's byte-identical deployment output. ++frontend/src/** text eol=lf ++static/js/actionMenuOrder.js text eol=lf ++ + # Windows-native scripts stay CRLF. + *.ps1 text eol=crlf + *.cmd text eol=crlf +``` + +Exact command and results: + +```text +$ git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +frontend/src/shared/actionMenuOrder.js: text: set +frontend/src/shared/actionMenuOrder.js: eol: lf +static/js/actionMenuOrder.js: text: set +static/js/actionMenuOrder.js: eol: lf +``` + +The sampled legacy contracts were also inspected: + +| Path | text | eol | +| --- | --- | --- | +| `static/js/notes.js` | auto | unspecified | +| `static/index.html` | auto | unspecified | +| `launch-windows.ps1` | set | crlf | +| `start-macos.sh` | set | lf | + +Beyond samples, all 2,479 unrelated tracked paths were compared against HEAD for +both attributes and matched. The current Git 2.53.0 supports the diagnostic +`--source=HEAD` option; the publisher uses only the longstanding plain +`check-attr --stdin` interface, so it does not depend on that newer option. +The read-only whole-tree comparison command was: + +```sh +python3 - <<'PY' +import subprocess +paths = subprocess.check_output(['git', 'ls-files', '-z']) +def attributes(source): + result = subprocess.run( + ['git', 'check-attr', *source, '-z', '--stdin', 'text', 'eol'], + input=paths, capture_output=True, check=True, + ) + fields = result.stdout.decode().split('\0')[:-1] + return {(fields[i], fields[i + 1]): fields[i + 2] + for i in range(0, len(fields), 3)} +before, after = attributes(['--source=HEAD']), attributes([]) +owned = lambda p: p.startswith('frontend/src/') or p == 'static/js/actionMenuOrder.js' +changes = [(p, a, before[(p, a)], value) for (p, a), value in after.items() + if not owned(p) and before[(p, a)] != value] +assert not changes, changes +print(len({p for p, a in after if not owned(p)}), 'unrelated tracked paths unchanged') +PY +``` + +## Ownership enforcement after correction + +| Contract | Enforcement and CI receipt | +| --- | --- | +| Canonical, contained source/output paths and unique ownership | Publisher validates the whole manifest before writing; `python-syntax` runs its `--check` | +| Every maintained JS source is registered | Publisher rejects unlisted source; duplicate sources/outputs also fail | +| Reproducible LF checkout of both mapped paths | Publisher queries isolated repository-owned attributes, even for already matching bytes; the same CI `--check` now enforces it | +| Committed output equals maintained source | Publisher checks byte parity without rebuilding in CI; normal publication writes only stale owned outputs | +| Feature API/layer direction, no new cycles, stable relative resolution and maintained identities | Existing architecture checker and Node self-tests in `node-syntax`; no checker changes needed | +| Explicit exact-URL offline coverage | Architecture checker parses the existing SW arrays; real offline browser proof remains green | +| Legacy runtime graph/URL identity and behavior | Existing focused pytest guards and the full CI pytest jobs remain in place | + +These gates enforce the documented maintained-JS contract together. They do not +retroactively impose source ownership on all legacy static files, clean retired +outputs or prove semantic state/lifecycle correctness. Those existing limits and +the independent packaging/offline limitations below remain unchanged. + +## Final correction validation + +The full focused pytest command printed below was rerun with the same interpreter +and all listed files: **193 passed, 1 skipped, 1 existing SQLAlchemy warning in +12.33 s**. It includes all 27 publisher tests and the isolated native startup, +static traversal and no-Node distribution probe. The new 14 tests account for +the increase from the initial F1 run's 179 passes. No test failed in this pass. + +| Correction check | Actual result | +| --- | --- | +| Combined focused pytest command below | 193 passed, 1 unavailable-image skip | +| `node --test tests/frontendArchitecture.test.mjs` | 10 passed | +| Notes oracle/autosave/selected-context command below | 11 passed | +| `python3 scripts/build_frontend.py` | Passed, zero output changes, attributes enforced | +| `python3 scripts/build_frontend.py --check` | Passed, attributes and byte parity enforced | +| `node scripts/check_frontend.mjs` | Passed, one maintained module | +| Helper SHA-256 and `git check-attr` | Identical source/output hash; both text=set, eol=lf | +| All unrelated tracked attributes versus HEAD | 2,479 paths unchanged | +| `git diff --check` and preserved-runtime command below | Passed, no runtime/backend/packaging diff | +| `python -m compileall -q scripts/build_frontend.py tests/test_frontend_build.py` | Passed using the recorded virtualenv interpreter | +| Isolated Docker runtime command below | Passed, networking disabled; no source tree, node_modules or Node on PATH | +| Complete inventory, whitespace and documentation links | All 18 changed files matched the inventory; checks passed | + +The one skip remains the existing test requiring `odysseus-odysseus:latest`. +No new skip or waiver was introduced. Windows/macOS executable builds, a fresh +full Dockerfile build, the full pytest suite, Firefox/WebKit and CI's exact +Python 3.11/Node 20 versions remain unexecuted locally. Native Git semantics, +nested overrides, paths with spaces and ignored `core.autocrlf`/global attribute +settings are covered by executed Linux tests; actual Windows execution remains a +platform-owner check. The existing `/static/` worker scope still prevents a claim +of full root offline reload readiness. No service-worker scope change is included. + +The earlier local ChromaDB probe incident remains recorded under failures below. +All runtime checks in this correction pass used the isolated distribution probe +with reserved non-listening Chroma port and disabled warmups; Docker additionally +had networking disabled. This pass performed no new unisolated ChromaDB probe and +no attempt to restore or alter the existing local service's tool index. + +## Reviewer-ready PR summary + +Suggested title: `Establish frontend source ownership and native ESM deployment contracts`. + +Maintain a stateless helper under `frontend/src/` while publishing identical bytes +at its existing static URL. Add manifest-owned publication, repository-owned LF +attribute and byte-parity checks, parsed dependency/API/identity/offline gates, +and a self-contained architecture handbook with the Notes decomposition roadmap. +Startup and existing feature behavior retain their current runtime contracts. + +Validation: 193 focused Python tests passed with one existing Docker-image skip; +10 architecture and 11 Notes compatibility tests passed; source/output parity, +LF gates, preserved-runtime diff and isolated native/Docker runtime proofs passed. +Desktop executable builds and full root offline reload remain explicit limits. +All 18 files are inventoried below. The working diff is ready for review and is +uncommitted; commit, push and PR creation require separate authorization. + +## Verified repository state + +Repository: `odysseus-dev/odysseus`. Branch: +`refactor/frontend-architecture-foundation`. Worktree: +`/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation`. +Initial worktree was clean. HEAD and local official `upstream/dev` are +`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The live official GitHub `dev` SHA +matched at both initial and final verification. This is PR #6495's merge commit. +The `origin` remote is the maintainer fork, not the integration target. + +Final changes are uncommitted F1 files only. Runtime JavaScript, Notes, HTML, +SW, backend, Dockerfile and desktop startup/packaging implementations have no +diff. The stateless source and existing deployed helper are both 1,688 bytes, +SHA-256 `e17baec5d6ac381ce6234ab6f028a8208487280dd07d6950a3469f8488809684`. +The final publisher run reported zero changed output modules. + +## Decisions and scope + +The [ADR](ADR-001-source-and-deployment.md) compares two executed approaches: +controlled native ESM copying and esbuild 0.25.12 ESM compilation/bundling. +Native byte copying wins on existing URL/module/cache compatibility and absence +of runtime tooling. Commit explicit output beside maintained source, publish +only manifest-owned modules and enforce parity in CI. Python startup never builds. + +The [handbook](handbook.md) defines feature public indexes, responsibility layers, +state/lifecycle ownership, cross-feature ports/events, shared-code limits, legacy +coexistence, module creation, validation, rollback and handoff contracts. +The [assessment](assessment.md) records graph/bootstrap/state/storage/security, +packaging assumptions and current open-PR overlaps. The +[Notes roadmap](notes-roadmap.md) defines N0 through N6 as internal checkpoints +for one comprehensive Notes draft PR, with the final reference case study in +that PR and H recipient handoff after integration. None is implemented in F1. +There is one maintained stateless leaf and zero legacy-import +exceptions, with no empty feature scaffolding. + +Acorn 8.19.0 is the sole new, exact-pinned development dependency, used to parse +architecture edges accurately. It is not loaded or distributed as runtime JS. +esbuild was a pinned one-off spike and is not a project dependency. Existing +frameworks, application entry points and backend contracts are unchanged. + +## Exact changed files + +| File | Change | +| --- | --- | +| `.gitattributes` | LF checkout for maintained source and the registered deployed proof | +| `.github/workflows/ci.yml` | Parity check; locked parser install; source syntax and architecture self-tests | +| `package.json` | Exact Acorn development dependency | +| `package-lock.json` | Acorn lock/integrity entry | +| `frontend/modules.json` | Explicit source/deployment ownership mapping | +| `frontend/src/package.json` | Native ESM interpretation for source tests, no dependencies | +| `frontend/src/shared/actionMenuOrder.js` | Authoritative stateless source, copied verbatim from the existing helper | +| `scripts/build_frontend.py` | Validated source publisher with manifest-driven, repository-owned LF enforcement and non-mutating parity check | +| `scripts/check_frontend.mjs` | Parsed import/layer/API/cycle/identity/precache guards and graph inventory | +| `scripts/verify_frontend_runtime.py` | Isolated current-code distribution/runtime compatibility probe | +| `tests/frontendArchitecture.test.mjs` | Behavioral API equivalence and positive/negative architecture gates | +| `tests/test_frontend_build.py` | Publication/ownership/drift/runtime coverage plus positive and negative Git attribute enforcement tests | +| `tests/test_frontend_foundation_offline_js.py` | Real SW precache, offline first evaluation, canonical namespace identity and lazy registry proof | +| `docs/frontend-architecture/ADR-001-source-and-deployment.md` | Decision, alternatives and distribution/rollback contracts | +| `docs/frontend-architecture/assessment.md` | Evidence-backed architecture and PR-overlap inventory | +| `docs/frontend-architecture/handbook.md` | Permanent foundation and maintainer continuation methodology | +| `docs/frontend-architecture/notes-roadmap.md` | Reference decomposition and final handoff plan | +| `docs/frontend-architecture/validation.md` | This implementation/review/validation report | + +## Initial F1 validation before the review correction + +Environment: Linux; native Python 3.14.4; pytest 9.1.1; FastAPI 0.143.0; +Starlette 1.8.0; Node 22.23.1; Playwright 1.62.1 with installed Chromium. +CI remains Python 3.11 and Node 20; that exact interpreter pair was not executed +locally. Use the repository virtualenv, not the system Python missing dependencies. +The interpreter used here was +`/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python`. + +The historical counts below are coordinator-executed checks in this worktree, excluding +delegated claims and earlier repeated subsets. Delegation was discontinued on +user instruction; no delegated implementation was integrated. + +| Check | Actual result | +| --- | --- | +| Read-only baseline: graph, identity, helper, loader, MIME, nonce, runtime paths | 54 passed, one existing SQLAlchemy deprecation warning | +| Final combined focused pytest command below | 179 passed, 1 skipped, one existing SQLAlchemy warning, 12.62 s | +| Final `pytest -q -rs tests/test_frontend_build.py` review rerun | 13 passed, including native startup and encoded static traversal rejection | +| `node --test tests/frontendArchitecture.test.mjs` | 10 passed, no skips or failures; rerun after final gate review | +| Notes oracle/autosave/selected-context Node command below | 11 passed, no skips or failures | +| `python3 scripts/build_frontend.py` and `--check` | Zero output changes; byte parity passed | +| `node scripts/check_frontend.mjs` | Passed, one maintained module | +| `node scripts/check_frontend.mjs --inventory` | 230 files, 707 static edges, 108 lazy literal edges, no missing targets, three existing static cyclic components | +| `npm ci --ignore-scripts --no-audit --no-fund` | Locked development dependency installation succeeded | +| Native `scripts/verify_frontend_runtime.py` | Real FastAPI lifespan and shell/static assertions passed without maintained source, node_modules or Node on PATH | +| Docker runtime probe below | Same current-code assertions passed with networking disabled and source mount read-only | +| esbuild spike | Two identical 1,554-byte bundle outputs; executed exports/icon/ordering equivalence passed | +| JS syntax checks and Python compileall below | Passed | +| `git diff --check`, preserved-scope diff check, hash/LF checks | Passed; no runtime/backend/packaging source diff | +| Foundation documentation links, trailing whitespace and em-dash checks | All five files passed | + +The skipped test is +`tests/test_docker_devops_hardening.py:187`, which requires the unavailable +`odysseus-odysseus:latest` image. It was not relabeled or bypassed. F1's separate +Docker runtime probe used the available `odysseus-maintainer-preview-odysseus:latest`, +image ID `sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`, +with Python 3.14.7, FastAPI 0.141.1 and Starlette 1.6.0. This proves compatibility +of current candidate code/assets with that image's runtime dependencies, not a +fresh build of the full repository Dockerfile or a deployment. + +Reproduce the final focused command with the actual tested interpreter: + +```sh +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m pytest -q -rs \ + tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \ + tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \ + tests/test_action_menu_order.py tests/test_panel_loader_js.py \ + tests/test_app_static_mime.py tests/test_serve_html_with_nonce.py \ + tests/test_runtime_paths.py tests/test_ci_authoritative_validation.py \ + tests/test_selected_notes_context.py tests/test_email_library_module_graph_js.py \ + tests/test_settings_module_import_paths.py tests/test_app_config_shared_fetch_js.py \ + tests/test_markdown_lazy_lib_loading_js.py tests/test_startup_shell_js.py \ + tests/test_static_stylesheet_manifest.py tests/test_notes_dom_xss_helpers.py \ + tests/test_notes_reminder_temporal_redos.py tests/test_notes_sidebar_new_note.py \ + tests/test_notes_mobile_checklist_toggle_static.py \ + tests/test_notes_document_mutual_minimize.py tests/test_docker_devops_hardening.py + +node --test tests/frontendArchitecture.test.mjs +ENDPOINT_URL=http://127.0.0.1:1 node --test \ + tests/noteTestOracle.test.mjs tests/notesDraftAutosave.test.mjs \ + tests/selectedNotesContext.test.mjs + +python3 scripts/build_frontend.py --check +node scripts/check_frontend.mjs +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python scripts/verify_frontend_runtime.py + +docker run --rm --network none \ + --mount type=bind,src=/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation,dst=/candidate,readonly \ + --workdir /candidate --entrypoint python \ + odysseus-maintainer-preview-odysseus:latest scripts/verify_frontend_runtime.py +``` + +`ENDPOINT_URL` satisfies the existing oracle's import-time configuration requirement. +Its unit-test imports exercise pure exported functions; its network-driving main +is guarded by the executable-module check and does not run. The closed loopback +address is not a live model endpoint. No oracle implementation was changed. + +Syntax commands executed: + +```sh +bash -c 'shopt -s globstar nullglob; for f in static/app.js static/js/**/*.js frontend/src/**/*.js scripts/check_frontend.mjs tests/frontendArchitecture.test.mjs; do node --check "$f" || exit; done' +/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m compileall -q app.py core routes src services scripts tests +git diff --check +git diff --exit-code -- static app.py Dockerfile .dockerignore Odysseus.spec build-macos-app.sh build-windows-portable.ps1 launcher.py core routes src services +git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +sha256sum frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js +``` + +Spike compiler invocation, executed twice with equality and Node import assertions: + +```sh +npx --yes esbuild@0.25.12 frontend/src/shared/actionMenuOrder.js \ + --bundle --format=esm --target=es2022 --platform=browser --log-level=error +``` + +The clean publication proof uses temporary roots in `test_frontend_build.py`, +including an initially absent output tree. Real-checkout publication is idempotent. +No emitted JS bytes, URLs, boot scripts or cache manifests changed in this lane. + +## Failures encountered and their resolution + +- An early architecture self-test expected an identity diagnostic but got an + unresolved source path for a query-bearing relative import. The checker now + rejects alternate identity before resolving that source path. All final tests + pass; no test requirement was weakened. +- The initial Notes oracle command failed during import because `ENDPOINT_URL` + was missing. Inspection established that the unit-test path never runs its + endpoint-driving main. The explicit closed-address configuration above passed + all 11 tests. This prerequisite is recorded for N0 rather than hidden. +- The first isolated distribution fixture omitted the existing `companion/` + package, so both probes failed with `ModuleNotFoundError`. The fixture now + includes that required runtime directory; neither application nor packaging + implementation was modified to make the tests pass. +- The first exploratory native startup check connected to the existing local + ChromaDB and refreshed the built-in tool index. That was unintended. Subsequent + probes use a private distribution/data directory, a reserved non-listening + Chroma port, disabled existing warmup options and no Node on PATH. Docker also + disables networking. No attempt was made to overwrite or restore that service's + data; the earlier unisolated probe is not the reproducible validation recipe. + +## Remaining risks and limits + +Root offline reload is not certified: existing SW scope is `/static/`, and open +[#6227](https://github.com/odysseus-dev/odysseus/pull/6227) proposes the independent +scope/header correction. F1 preserves and tests actual offline module behavior +at the current scope. No first-use offline guarantee is claimed for optional +CDN libraries, Mermaid or server APIs. + +Windows/macOS packaged executables, a fresh full Dockerfile build, the full pytest +suite, Firefox/WebKit offline behavior and CI's exact Python 3.11/Node 20 versions +were not executed here. Existing packaging paths are inspected and unchanged; +platform owners should run release validation before broader certification. +The unavailable-image test remains a coverage gap. Legacy source still has +cross-feature coupling, global side effects and static cycles. New boundaries +are enforced on maintained source, not retrospectively on the entire legacy tree. + +The publisher intentionally does not clean retired outputs or provide atomic +multi-file deployment. Review full source/output/facade/manifest changes and +ship a coherent artifact. Domain syntax checks cannot prove all semantic purity +or disposal; behavioral characterization remains required. Reconcile the package +lock with #6627 and refresh all PR overlaps before integration. + +## Reviewer checklist + +- Verify the base contains #6495 and the official integration target is dev. +- Confirm the deployed helper hash and runtime-scope diff are unchanged. +- Review the layer matrix, public API rules, single-state ownership and zero + current legacy exceptions; verify negative tests actually reject violations. +- Review the manifest mapping, byte-parity/LF policy, exact offline precache and + source/output relative-resolution guard; verify missing or overridden LF + declarations fail publication and CI even when output bytes already match. +- Reproduce the focused tests and inspect the skipped-image prerequisite. +- Confirm no frontend compiler/runtime dependency is required at startup and + inspect the isolated Python/Docker probe's limits. +- Review overlap with #6470/#6695/#6227/#6627 and the Notes backend follow-ups. +- Treat the ADR as awaiting adoption review, and the Notes/H documents as plans. +- Authorize any commit/push/PR action separately. Do not begin Notes on this branch. + +## Required next reviews and continuation + +1. Review this F1 working diff. On explicit authorization, commit and submit only + the foundation-only F1 PR to official dev using the normal maintainer workflow. +2. Coordinate the existing #6227 offline fix and platform release validation as + separate work. Do not bundle that behavioral change into F1. +3. After F1 integration and Notes implementation authorization, create one Notes + worktree and branch from the latest official dev, preserving any subsequently + merged Notes changes. Use one comprehensive draft PR targeting official dev. +4. Complete N0 through N6 from the [Notes roadmap](notes-roadmap.md) on that same + branch, recording tests, review and rollback evidence at each checkpoint. Include + the final reference case study in the same PR and keep it a draft until the full + decomposition and regression/compatibility acceptance gates pass. Recurrence + extraction follows characterization as an internal stage, not another PR. +5. Complete H only after Notes integration and recipient reproduction. Transfer + future frontend work to the designated maintainer and stop this program. + +Another maintainer can start at the handbook, run the documented source/parity, +architecture, runtime and behavior checks, inspect this report and select the +next authorized slice without relying on unwritten architecture decisions. diff --git a/frontend/modules.json b/frontend/modules.json new file mode 100644 index 000000000..0363222b1 --- /dev/null +++ b/frontend/modules.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "modules": [ + { + "source": "shared/actionMenuOrder.js", + "deployment": "js/actionMenuOrder.js" + } + ] +} diff --git a/frontend/src/package.json b/frontend/src/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/frontend/src/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/frontend/src/shared/actionMenuOrder.js b/frontend/src/shared/actionMenuOrder.js new file mode 100644 index 000000000..f5cd80b9d --- /dev/null +++ b/frontend/src/shared/actionMenuOrder.js @@ -0,0 +1,29 @@ +/** Stable semantic ordering for item-level action menus. */ +const COMMON_ACTION_ORDER = [ + { rank: 200, pattern: /^(rename|change name)\b/i }, + { rank: 650, pattern: /^select\b/i }, + { rank: 400, pattern: /^(favorite|unfavorite|favourite|unfavourite|pin|unpin)\b/i }, + { rank: 500, pattern: /^(copy|clone|duplicate)\b/i }, + { rank: 550, pattern: /^(export|download)\b/i }, + { rank: 900, pattern: /^(delete|remove|hide|dismiss|clear from list|move to trash|move to spam)\b/i }, + { rank: 700, pattern: /^(move to archive|archive|unarchive|restore)\b/i }, + { rank: 600, pattern: /^(move(?! to (?:archive|trash|spam)\b)|add to folder)\b/i }, + { rank: 1000, pattern: /^cancel\b/i }, +]; + +// Canonical Select glyph for item-level menus. Toolbar toggles may use the +// smaller version, but dropdown rows should all use this exact SVG. +export const SELECT_MENU_ICON = ''; + +export function actionMenuRank(item) { + if (Number.isFinite(item?.menuOrder)) return item.menuOrder; + const value = String(typeof item?.action === 'string' ? item.action : (item?.label || '')).trim(); + return COMMON_ACTION_ORDER.find(entry => entry.pattern.test(value))?.rank ?? 100; +} + +export function orderActionMenuItems(items) { + return items + .map((item, index) => ({ item, index, rank: actionMenuRank(item) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map(entry => entry.item); +} diff --git a/package-lock.json b/package-lock.json index 4d0b4b8d6..97fb6b572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "name": "odysseus", "devDependencies": { "@antithesishq/bombadil": "^0.7.0", - "@playwright/test": "^1.62.1" + "@playwright/test": "^1.62.1", + "acorn": "8.19.0" } }, "node_modules/@antithesishq/bombadil": { @@ -36,6 +37,19 @@ "node": ">=20" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/package.json b/package.json index 6f72684c8..aa454e017 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@antithesishq/bombadil": "^0.7.0", - "@playwright/test": "^1.62.1" + "@playwright/test": "^1.62.1", + "acorn": "8.19.0" } } diff --git a/scripts/build_frontend.py b/scripts/build_frontend.py new file mode 100644 index 000000000..371715783 --- /dev/null +++ b/scripts/build_frontend.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Publish explicitly owned native ES modules; never run at application startup.""" + +import argparse +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] + + +def module_path(base: Path, relative: str) -> Path: + """Accept canonical JS paths only, contained within the declared tree.""" + if not isinstance(relative, str): + raise ValueError("module paths must be strings") + parts = PurePosixPath(relative) + if ( + parts.is_absolute() + or parts.as_posix() != relative + or ".." in parts.parts + or "\\" in relative + or "?" in relative + or "#" in relative + or parts.suffix != ".js" + ): + raise ValueError(f"invalid module path: {relative!r}") + path = base / relative + if not path.resolve().is_relative_to(base.resolve()): + raise ValueError(f"module path escapes its tree: {relative!r}") + if any(p.is_symlink() for p in (path, *path.parents)): + raise ValueError(f"symlink in module path: {relative!r}") + return path + + +def check_lf_attributes(root: Path, paths: set[Path]) -> None: + """Require portable, repository-owned LF attributes for every mapped path. + + Ask Git in an isolated temporary repository so global attributes, local + info/attributes and alternate Git directories cannot mask missing rules. + Copy only attribute files at ancestors of the manifest's source/output paths. + """ + git = shutil.which("git") + if git is None: + raise ValueError("Git is required for frontend publication attribute checks") + attributes = {root / ".gitattributes"} + for path in paths: + for parent in path.parents: + if parent == root: + break + attributes.add(parent / ".gitattributes") + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + relative = sorted(path.relative_to(root).as_posix() for path in paths) + with tempfile.TemporaryDirectory(prefix="ody-frontend-attributes-") as directory: + target = Path(directory) + for attribute in attributes: + if attribute.is_symlink(): + raise ValueError(f"symlink attribute file: {attribute.relative_to(root)}") + if attribute.is_file(): + copy = target / attribute.relative_to(root) + copy.parent.mkdir(parents=True, exist_ok=True) + copy.write_bytes(attribute.read_bytes()) + command = [git, "-c", f"core.attributesFile={os.devnull}", "-C", str(target)] + try: + subprocess.run(command + ["init", "--quiet", "--template="], env=env, check=True, capture_output=True, timeout=15) + result = subprocess.run( + command + ["check-attr", "-z", "--stdin", "text", "eol"], + input=("\0".join(relative) + "\0").encode("utf-8"), + env=env, check=True, capture_output=True, timeout=15, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise ValueError("Git frontend attribute check failed") from error + fields = result.stdout.decode("utf-8").split("\0")[:-1] + effective = {name: {} for name in relative} + for index in range(0, len(fields), 3): + name, attribute, value = fields[index:index + 3] + effective[name][attribute] = value + invalid = [ + f"{name} (text={values.get('text')}, eol={values.get('eol')})" + for name, values in effective.items() + if values != {"text": "set", "eol": "lf"} + ] + if invalid: + raise ValueError("manifest paths require repository-owned 'text eol=lf' Git attributes: " + ", ".join(invalid)) + + +def synchronize(root: Path = ROOT, *, check: bool = False) -> list[str]: + """Validate the entire plan before writing. Return stale deployment paths.""" + root = root.resolve() + source_root = root / "frontend" / "src" + static_root = root / "static" + manifest = json.loads((root / "frontend" / "modules.json").read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or manifest.get("version") != 1 or not isinstance(manifest.get("modules"), list): + raise ValueError("expected frontend module manifest version 1") + if not manifest["modules"]: + raise ValueError("module manifest must not be empty") + sources, outputs, plan = set(), set(), [] + for entry in manifest["modules"]: + source = module_path(source_root, entry["source"]) + output = module_path(static_root, entry["deployment"]) + if not entry["deployment"].startswith("js/"): + raise ValueError("deployment modules must be under static/js/") + if source in sources or output in outputs: + raise ValueError("each source and deployment path must be unique") + sources.add(source) + outputs.add(output) + content = source.read_bytes() + if output.exists() and not output.is_file(): + raise ValueError(f"deployment path is not a file: {output}") + plan.append((output, content)) + unlisted = set(source_root.rglob("*.js")) - sources + if unlisted: + raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}") + check_lf_attributes(root, sources | outputs) + stale = [] + for output, content in plan: + if not output.is_file() or output.read_bytes() != content: + stale.append(output.relative_to(root).as_posix()) + if not check: + for output, content in plan: + if output.relative_to(root).as_posix() in stale: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(content) + return stale + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail on invalid attributes or stale output; write no deployment files") + args = parser.parse_args() + try: + stale = synchronize(check=args.check) + except (OSError, ValueError, KeyError, TypeError) as error: + parser.exit(2, f"frontend: {error}\n") + if args.check and stale: + print("Stale frontend deployment modules: " + ", ".join(stale)) + print("Run python scripts/build_frontend.py and include the source and output changes together.") + return 1 + print(f"Frontend {'check passed' if args.check else 'published'} ({len(stale)} changed modules).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_frontend.mjs b/scripts/check_frontend.mjs new file mode 100644 index 000000000..d9aa214a7 --- /dev/null +++ b/scripts/check_frontend.mjs @@ -0,0 +1,180 @@ +/** Architecture gates for maintained source. Legacy static modules stay under existing guards. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'acorn'; + +const layers = ['domain', 'application', 'infrastructure', 'ui']; +const platformIdentifiers = new Set(['window', 'document', 'globalThis', 'navigator', 'localStorage', 'sessionStorage', 'fetch', 'Notification', 'indexedDB', 'WebSocket', 'XMLHttpRequest', 'Worker', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'requestAnimationFrame', 'cancelAnimationFrame', 'performance', 'eval', 'Function']); + +function ownership(source) { + const parts = source.split('/'); + if (parts[0] === 'app' && parts.length >= 2) return { feature: 'app', layer: 'index' }; + if (parts[0] === 'shared') { + if (parts.length === 2) return { feature: 'shared', layer: 'domain' }; + const layer = { domain: 'domain', platform: 'infrastructure', ui: 'ui' }[parts[1]]; + if (layer) return { feature: 'shared', layer }; + } + if (parts[0] === 'features' && parts.length >= 3) { + if (['app', 'shared'].includes(parts[1])) throw new Error(`reserved feature name: ${parts[1]}`); + if (parts.length === 3 && parts[2] === 'index.js') return { feature: parts[1], layer: 'index' }; + if (layers.includes(parts[2]) && parts.length >= 4) return { feature: parts[1], layer: parts[2] }; + } + throw new Error(`unsupported source ownership: ${source}`); +} + +export function allowsDependency(from, to) { + const a = ownership(from), b = ownership(to); + if (b.feature === 'app') return a.feature === 'app'; + if (a.feature === 'app') return b.feature === 'shared' || b.layer === 'index'; + if (b.feature === 'shared') { + const shared = { domain: ['domain'], application: ['domain'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'infrastructure', 'ui'], index: layers }; + return shared[a.layer].includes(b.layer); + } + if (a.feature === 'shared') return false; + if (a.feature !== b.feature) return ['application', 'index'].includes(a.layer) && b.layer === 'index'; + const internal = { domain: ['domain'], application: ['domain', 'application'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'application', 'ui'], index: layers }; + return internal[a.layer].includes(b.layer); +} + +export function imports(source, { domain = false } = {}) { + const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true }); + const found = []; + function visit(node, parent, field) { + if (!node || typeof node !== 'object') return; + // Domain modules use explicit values/ports, including an injected clock. + // Keep platform vocabulary out even when a local binding shadows a global. + const propertyName = parent && !parent.computed && ( + (parent.type === 'MemberExpression' && field === 'property') || + (['Property', 'MethodDefinition'].includes(parent.type) && field === 'key' && !parent.shorthand) + ); + if (domain && !propertyName && node.type === 'Identifier' && platformIdentifiers.has(node.name)) { + throw new Error(`platform identifier in domain at line ${node.loc.start.line}: ${node.name}`); + } + if (domain && ((node.type === 'MemberExpression' && node.object.name === 'Date' && (node.property.name === 'now' || node.property.value === 'now')) || (['CallExpression', 'NewExpression'].includes(node.type) && node.callee.name === 'Date' && !node.arguments.length))) { + throw new Error(`ambient clock in domain at line ${node.loc.start.line}; inject time`); + } + if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ImportExpression'].includes(node.type) && node.source) { + if (node.source.type !== 'Literal' || typeof node.source.value !== 'string') { + throw new Error(`computed import at line ${node.loc.start.line}; use explicit import thunks`); + } + found.push({ specifier: node.source.value, line: node.loc.start.line, dynamic: node.type === 'ImportExpression' }); + } + for (const [key, value] of Object.entries(node)) { + if (Array.isArray(value)) value.forEach(item => visit(item, node, key)); + else if (value && typeof value === 'object' && value.type) visit(value, node, key); + } + } + visit(ast); + return found; +} + +export function checkArchitecture(root) { + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json'), 'utf8')); + const bySource = new Map(manifest.modules.map(m => [m.source, m])); + const byOutput = new Map(manifest.modules.map(m => [`/static/${m.deployment}`, m.source])); + const graph = new Map(); + const errors = []; + const sw = fs.readFileSync(path.join(root, 'static/sw.js'), 'utf8'); + // Parse the actual manifest arrays, not comments or unrelated strings. + const swAst = parse(sw, { ecmaVersion: 'latest', sourceType: 'script' }); + const precached = new Set(); + for (const statement of swAst.body) { + for (const declaration of statement.declarations || []) { + if (['PRECACHE', 'PANEL_PRECACHE'].includes(declaration.id.name)) { + for (const item of declaration.init.elements) { + if (item?.type === 'Literal' && typeof item.value === 'string') precached.add(item.value); + } + } + } + } + for (const module of manifest.modules) { + try { + const owner = ownership(module.source); + const deployed = `/static/${module.deployment}`; + if (!precached.has(deployed)) throw new Error(`deployment URL absent from explicit precache: ${deployed}`); + const dependencies = []; + const exceptions = module.legacyImports || []; + const used = new Set(); + for (const { specifier, line } of imports(fs.readFileSync(path.join(root, 'frontend/src', module.source), 'utf8'), { domain: owner.layer === 'domain' })) { + const fail = message => { throw new Error(`line ${line}: ${message}: ${specifier}`); }; + const runtime = new URL(specifier, `https://odysseus.invalid${deployed}`); + if (!runtime.pathname.startsWith('/static/js/') || !runtime.pathname.endsWith('.js') || runtime.pathname.includes('%')) fail('runtime imports must name canonical static JS paths'); + let target; + if (specifier.startsWith('.')) { + if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity'); + target = path.posix.normalize(path.posix.join(path.posix.dirname(module.source), specifier)); + if (!bySource.has(target)) fail('relative import must name maintained source'); + if (runtime.pathname !== `/static/${bySource.get(target).deployment}`) fail('source and deployment relative paths resolve differently'); + } else if (specifier.startsWith('/static/')) { + target = byOutput.get(runtime.pathname); + if (!target) { + const exception = exceptions.find(e => e.specifier === specifier && e.reason?.trim()); + if (!exception || !['index', 'infrastructure', 'ui'].includes(owner.layer) || owner.feature === 'shared') fail('legacy import requires an explicit module-scoped rationale at a composition, infrastructure or UI boundary'); + if (!fs.statSync(path.join(root, runtime.pathname.slice(1))).isFile()) fail('legacy target does not exist'); + used.add(exception); + continue; + } + } else fail('bare, external and source-tree runtime imports are unsupported'); + if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity'); + if (!allowsDependency(module.source, target)) fail(`forbidden dependency on ${target}`); + dependencies.push(target); + } + if (exceptions.some(e => !used.has(e))) throw new Error('unused legacy import exception'); + graph.set(module.source, dependencies); + } catch (error) { errors.push(`${module.source}: ${error.message}`); } + } + const visiting = new Set(), visited = new Set(); + function visit(source, trail = []) { + if (visiting.has(source)) throw new Error(`maintained-source dependency cycle: ${[...trail, source].join(' -> ')}`); + if (visited.has(source)) return; + visiting.add(source); + for (const target of graph.get(source) || []) visit(target, [...trail, source]); + visiting.delete(source); + visited.add(source); + } + try { for (const source of graph.keys()) visit(source); } + catch (error) { errors.push(error.message); } + if (errors.length) throw new Error(errors.join('\n')); + return graph.size; +} + +export function inventory(root) { + const staticRoot = path.join(root, 'static'); + const files = fs.readdirSync(staticRoot, { recursive: true }).map(p => p.split(path.sep).join('/')).filter(p => p.endsWith('.js') && !p.startsWith('lib/') && !p.startsWith('js/editor/build/')).sort(); + const graph = new Map(), fan = new Map(), missing = []; + let staticImports = 0, dynamicLiteralImports = 0; + for (const file of files) { + const targets = []; + for (const { specifier, dynamic } of imports(fs.readFileSync(path.join(staticRoot, file), 'utf8'))) { + if (!specifier.startsWith('.') && !specifier.startsWith('/static/')) continue; + const target = new URL(specifier, `https://odysseus.invalid/static/${file}`).pathname.slice('/static/'.length); + if (dynamic) dynamicLiteralImports++; + else { staticImports++; targets.push(target); } + fan.set(target, (fan.get(target) || 0) + 1); + if (!fs.existsSync(path.join(staticRoot, target))) missing.push({ file, specifier }); + } + graph.set(file, targets); + } + const indices = new Map(), low = new Map(), stack = [], active = new Set(), staticCycles = []; + function visit(v) { + indices.set(v, indices.size); low.set(v, indices.get(v)); stack.push(v); active.add(v); + for (const w of graph.get(v) || []) { + if (!indices.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); } + else if (active.has(w)) low.set(v, Math.min(low.get(v), indices.get(w))); + } + if (low.get(v) === indices.get(v)) { + const component = []; let w; + do { w = stack.pop(); active.delete(w); component.push(w); } while (w !== v); + if (component.length > 1) staticCycles.push(component.sort()); + } + } + for (const file of files) if (!indices.has(file)) visit(file); + return { files: files.length, staticImports, dynamicLiteralImports, missing, fanIn: [...fan].sort((a, b) => b[1] - a[1]).slice(0, 8), staticCycles }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + try { console.log(process.argv.includes('--inventory') ? JSON.stringify(inventory(root), null, 2) : `Frontend architecture passed (${checkArchitecture(root)} maintained modules).`); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/verify_frontend_runtime.py b/scripts/verify_frontend_runtime.py new file mode 100644 index 000000000..07b0bb3dd --- /dev/null +++ b/scripts/verify_frontend_runtime.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Verify the current Python runtime from an isolated distribution without frontend tooling.""" +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + +PROBE = """ +from pathlib import Path +import shutil +from starlette.testclient import TestClient +from app import app +assert not Path('frontend').exists() +assert not Path('node_modules').exists() +assert shutil.which('node') is None +with TestClient(app) as client: + response = client.get('/static/js/actionMenuOrder.js') + assert response.status_code == 200, response.status_code + assert response.content == Path('static/js/actionMenuOrder.js').read_bytes() + assert response.headers['content-type'].startswith('text/javascript'), response.headers + assert response.headers['cache-control'] == 'no-cache', response.headers + assert "script-src 'self' 'nonce-" in response.headers['content-security-policy'], response.headers + assert client.get('/frontend/src/shared/actionMenuOrder.js').status_code == 404 + assert client.get('/static/%2e%2e/app.py').status_code == 404 + shell = client.get('/') + assert shell.status_code == 200, shell.status_code + assert '{{CSP_NONCE}}' not in shell.text + assert '/static/app.js' in shell.text +print('PASS: isolated Python lifespan, shell, deployed bytes, MIME, revalidation, CSP and source-tree 404') +""" + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="ody-f1-distribution-") as directory, socket.socket() as chroma_guard: + target = Path(directory) + for name in ("app.py", "HARNESS_VERSION"): + shutil.copy2(ROOT / name, target / name) + for name in ("core", "routes", "src", "services", "resources", "mcp_servers", "companion", "config", "static", "scripts"): + shutil.copytree(ROOT / name, target / name, ignore=shutil.ignore_patterns("__pycache__", "node_modules", ".env", "secrets.env*", "*.db", "*.sqlite*")) + # Reserve a non-listening port so initialization cannot reach a real store. + chroma_guard.bind(("127.0.0.1", 0)) + env = dict(os.environ) + env.update( + ODYSSEUS_DATA_DIR=str(target / "data"), + DATABASE_URL="sqlite:///:memory:", + AUTH_ENABLED="false", + ODYSSEUS_STARTUP_WARMUPS="0", + ODYSSEUS_TOOL_INDEX_PREWARM="0", + CHROMADB_HOST="127.0.0.1", + CHROMADB_PORT=str(chroma_guard.getsockname()[1]), + FASTEMBED_CACHE_PATH=str(target / "fastembed"), + PATH=str(target / "no-development-tools"), + ) + # A caller's Python search path must not import the original checkout. + env.pop("PYTHONPATH", None) + result = subprocess.run([sys.executable, "-c", PROBE], cwd=target, env=env, capture_output=True, text=True, timeout=60) + print(result.stdout, end="") + if result.returncode: + print(result.stderr[-8000:], file=sys.stderr) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/frontendArchitecture.test.mjs b/tests/frontendArchitecture.test.mjs new file mode 100644 index 000000000..f9af0fbab --- /dev/null +++ b/tests/frontendArchitecture.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { allowsDependency, imports, checkArchitecture, inventory } from '../scripts/check_frontend.mjs'; +import * as source from '../frontend/src/shared/actionMenuOrder.js'; +import * as deployed from '../static/js/actionMenuOrder.js'; + +test('stateless helper preserves its public API, ordering, references and inputs', () => { + for (const module of [source, deployed]) { + assert.deepEqual(Object.keys(module).sort(), ['SELECT_MENU_ICON', 'actionMenuRank', 'orderActionMenuItems']); + const callback = () => {}; + const items = ['Delete', 'Select', 'Copy', 'Rename', 'Archive', 'Open', 'Cancel'].map(label => Object.freeze({ label, action: callback })); + Object.freeze(items); + const sorted = module.orderActionMenuItems(items); + assert.deepEqual(sorted.map(i => i.label), ['Open', 'Rename', 'Copy', 'Select', 'Archive', 'Delete', 'Cancel']); + assert.ok(sorted.every(i => items.includes(i))); + assert.deepEqual(module.orderActionMenuItems([]), []); + assert.equal(module.actionMenuRank({ menuOrder: 12, label: 'Delete' }), 12); + assert.equal(module.actionMenuRank({ label: 'Unknown' }), 100); + const equalRank = [{ label: 'Open first' }, { label: 'Open second' }]; + assert.deepEqual(module.orderActionMenuItems(equalRank), equalRank); + } + assert.equal(source.SELECT_MENU_ICON, deployed.SELECT_MENU_ICON); +}); + +test('dependency direction separates composition, workflow, adapters and views', () => { + const n = name => `features/notes/${name}.js`; + const allowed = [ + ['app/bootstrap.js', n('index')], + [n('index'), n('infrastructure/api')], + [n('application/crud'), n('domain/recurrence')], + [n('infrastructure/api'), 'shared/platform/http.js'], + [n('ui/panel'), n('application/crud')], + [n('application/reminders'), 'features/calendar/index.js'], + ]; + for (const [from, to] of allowed) assert.ok(allowsDependency(from, to), `${from} -> ${to}`); + const forbidden = [ + [n('domain/recurrence'), n('infrastructure/api')], + [n('application/crud'), n('ui/panel')], + [n('application/crud'), n('infrastructure/api')], + [n('ui/panel'), 'features/calendar/index.js'], + [n('application/crud'), 'features/calendar/application/store.js'], + ['app/bootstrap.js', n('domain/recurrence')], + ['shared/platform/http.js', n('index')], + [n('domain/recurrence'), 'app/bootstrap.js'], + ]; + for (const [from, to] of forbidden) assert.equal(allowsDependency(from, to), false, `${from} -> ${to}`); +}); + +test('parser sees re-exports, multiline and dynamic imports without comment false positives', () => { + const code = `// import './false.js'; + const text = "import './false.js'"; + import { a }\n from './a.js'; + export { b } from './b.js'; + export * from './c.js'; + const load = () => import('./d.js');`; + assert.deepEqual(imports(code).map(i => i.specifier), ['./a.js', './b.js', './c.js', './d.js']); + assert.throws(() => imports('const load = p => import(p);'), /computed import/); + assert.throws(() => imports('export const now = () => Date.now();', { domain: true }), /ambient clock/); + assert.throws(() => imports('export const load = () => fetch("/api/notes");', { domain: true }), /platform identifier/); + assert.doesNotThrow(() => imports('export const day = value => new Date(value);', { domain: true })); + assert.doesNotThrow(() => imports('export const copy = value => ({ document: value.document });', { domain: true })); +}); + +function fixture(t, modules, { cache = true } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ody-frontend-gates-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const entries = Object.entries(modules).map(([name, code]) => { + const file = path.join(root, 'frontend/src', name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, code); + return { source: name, deployment: `js/${name}` }; + }); + fs.mkdirSync(path.join(root, 'static'), { recursive: true }); + fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify({ version: 1, modules: entries })); + const urls = cache ? entries.map(m => `/static/${m.deployment}`) : []; + fs.writeFileSync(path.join(root, 'static/sw.js'), `const PRECACHE = ${JSON.stringify(urls)}; const PANEL_PRECACHE = [];`); + return root; +} + +test('complete mapped graph and exact precache pass', t => { + const root = fixture(t, { 'features/notes/index.js': "export { value } from './domain/value.js';", 'features/notes/domain/value.js': 'export const value = 1;' }); + assert.equal(checkArchitecture(root), 2); +}); + +test('feature-private import fails through the full checker', t => { + const root = fixture(t, { 'features/notes/application/a.js': "import '../../calendar/domain/b.js';", 'features/calendar/domain/b.js': 'export const value = 1;' }); + assert.throws(() => checkArchitecture(root), /forbidden dependency/); +}); + +test('undeclared legacy coupling and alternate identities fail', t => { + for (const specifier of ['/static/js/notes.js?v=legacy', './b.js?v=other', '/frontend/src/shared/b.js', 'some-package']) { + const root = fixture(t, { 'shared/a.js': `import '${specifier}';`, 'shared/b.js': 'export const value = 1;' }); + assert.throws(() => checkArchitecture(root), /legacy import|one unversioned|unsupported|canonical static/); + } +}); + +test('a copy mapping cannot silently change relative import resolution', t => { + const root = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': 'export const value = 1;' }); + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json'))); + manifest.modules[1].deployment = 'js/elsewhere/b.js'; + fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify(manifest)); + fs.writeFileSync(path.join(root, 'static/sw.js'), "const PRECACHE = ['/static/js/shared/a.js', '/static/js/elsewhere/b.js'];"); + assert.throws(() => checkArchitecture(root), /resolve differently/); +}); + +test('new modules must join exact offline manifests and cannot introduce cycles', t => { + const missing = fixture(t, { 'shared/a.js': 'export const a = 1;' }, { cache: false }); + assert.throws(() => checkArchitecture(missing), /absent from explicit precache/); + const cyclic = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': "import './a.js';" }); + assert.throws(() => checkArchitecture(cyclic), /dependency cycle/); +}); + +test('legacy adapters require exact, used exceptions and reserved names cannot bypass ownership', t => { + const root = fixture(t, { 'features/notes/infrastructure/api.js': "import '/static/js/legacy.js?v=existing';" }); + fs.mkdirSync(path.join(root, 'static/js'), { recursive: true }); + fs.writeFileSync(path.join(root, 'static/js/legacy.js'), 'export const legacy = true;'); + const file = path.join(root, 'frontend/modules.json'); + const manifest = JSON.parse(fs.readFileSync(file)); + manifest.modules[0].legacyImports = [{ specifier: '/static/js/legacy.js?v=existing', reason: 'Temporary existing transport adapter; remove in N2.' }]; + fs.writeFileSync(file, JSON.stringify(manifest)); + assert.equal(checkArchitecture(root), 1); + manifest.modules[0].legacyImports.push({ specifier: '/static/js/unused.js', reason: 'stale' }); + fs.writeFileSync(file, JSON.stringify(manifest)); + assert.throws(() => checkArchitecture(root), /unused legacy import exception/); + assert.throws(() => allowsDependency('features/shared/index.js', 'app/bootstrap.js'), /reserved feature name/); +}); + +test('inventory reports executable static cycles and separates dynamic edges', t => { + const root = fixture(t, { 'shared/a.js': 'export const a = 1;' }); + fs.mkdirSync(path.join(root, 'static/js'), { recursive: true }); + fs.writeFileSync(path.join(root, 'static/js/a.js'), "import './b.js'; const load = () => import('./c.js');"); + fs.writeFileSync(path.join(root, 'static/js/b.js'), "import './a.js';"); + fs.writeFileSync(path.join(root, 'static/js/c.js'), 'export const c = 1;'); + const result = inventory(root); + assert.equal(result.staticImports, 2); + assert.equal(result.dynamicLiteralImports, 1); + assert.deepEqual(result.staticCycles, [['js/a.js', 'js/b.js']]); + assert.deepEqual(result.missing, []); +}); diff --git a/tests/test_frontend_build.py b/tests/test_frontend_build.py new file mode 100644 index 000000000..cbfd3371b --- /dev/null +++ b/tests/test_frontend_build.py @@ -0,0 +1,170 @@ +"""Exercise publication failure modes without changing the real static tree.""" +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.helpers.cli_loader import load_script + +ROOT = Path(__file__).resolve().parents[1] +build = load_script("build_frontend.py") + + +def project(tmp_path, entries=None): + source = tmp_path / "frontend/src/shared/helper.js" + source.parent.mkdir(parents=True) + source.write_bytes(b"export const value = 1;\n") + manifest = {"version": 1, "modules": entries if entries is not None else [ + {"source": "shared/helper.js", "deployment": "js/helper.js"} + ]} + (tmp_path / "frontend/modules.json").write_text(json.dumps(manifest), encoding="utf-8") + (tmp_path / ".gitattributes").write_text("* text=auto\nfrontend/src/** text eol=lf\nstatic/js/helper.js text eol=lf\n", encoding="utf-8") + return source, tmp_path / "static/js/helper.js" + + +def test_check_detects_missing_and_stale_output_without_writing(tmp_path): + source, output = project(tmp_path) + assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"] + assert not output.exists() + assert build.synchronize(tmp_path) == ["static/js/helper.js"] + assert output.read_bytes() == source.read_bytes() + source.write_bytes(b"export const value = 2;\n") + assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"] + assert output.read_bytes() == b"export const value = 1;\n" + + +def test_publication_is_idempotent_and_preserves_unowned_assets(tmp_path): + source, output = project(tmp_path) + unowned = tmp_path / "static/js/notes.js" + unowned.parent.mkdir(parents=True) + unowned.write_bytes(b"legacy state owner\n") + build.synchronize(tmp_path) + timestamp = output.stat().st_mtime_ns + assert build.synchronize(tmp_path) == [] + assert output.stat().st_mtime_ns == timestamp + assert unowned.read_bytes() == b"legacy state owner\n" + assert output.read_bytes() == source.read_bytes() + + +@pytest.mark.parametrize("bad", ["../outside.js", "/absolute.js", "shared/../helper.js", "shared\\helper.js", "shared/helper.js?v=1", "shared/helper.js#fragment", "shared/helper.ts"]) +def test_invalid_plan_does_not_partially_publish(tmp_path, bad): + project(tmp_path, [ + {"source": "shared/helper.js", "deployment": "js/helper.js"}, + {"source": bad, "deployment": "js/other.js"}, + ]) + with pytest.raises(ValueError, match="invalid module path"): + build.synchronize(tmp_path) + assert not (tmp_path / "static").exists() + + +def test_duplicate_outputs_and_unmapped_sources_are_rejected(tmp_path): + source, output = project(tmp_path) + extra = source.with_name("extra.js") + extra.write_bytes(b"export const value = 2;\n") + with pytest.raises(ValueError, match="missing from manifest"): + build.synchronize(tmp_path) + manifest_path = tmp_path / "frontend/modules.json" + manifest = json.loads(manifest_path.read_text()) + manifest["modules"].append({"source": "shared/extra.js", "deployment": "js/helper.js"}) + manifest_path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="must be unique"): + build.synchronize(tmp_path) + assert not output.exists() + + +def test_symlink_output_cannot_overwrite_another_file(tmp_path): + _, output = project(tmp_path) + outside = tmp_path / "outside.js" + outside.write_bytes(b"untouched\n") + output.parent.mkdir(parents=True) + output.symlink_to(outside) + with pytest.raises(ValueError, match="escapes|symlink"): + build.synchronize(tmp_path) + assert outside.read_bytes() == b"untouched\n" + + +def test_real_checkout_check_works_from_another_directory(tmp_path): + result = subprocess.run([sys.executable, str(ROOT / "scripts/build_frontend.py"), "--check"], cwd=tmp_path, capture_output=True, text=True, timeout=20) + assert result.returncode == 0, result.stdout + result.stderr + assert (ROOT / "frontend/src/shared/actionMenuOrder.js").read_bytes() == (ROOT / "static/js/actionMenuOrder.js").read_bytes() + + +def test_isolated_python_distribution_runs_without_source_or_node(): + result = subprocess.run([sys.executable, str(ROOT / "scripts/verify_frontend_runtime.py")], cwd=ROOT, capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stdout + result.stderr + assert "PASS: isolated Python lifespan" in result.stdout + + +@pytest.mark.parametrize("check", [False, True]) +@pytest.mark.parametrize("path,rule", [ + ("frontend/src/shared/helper.js", "text"), + ("static/js/helper.js", "text"), + ("frontend/src/shared/helper.js", "text eol=crlf"), + ("static/js/helper.js", "text=auto eol=lf"), + ("static/js/helper.js", "-text eol=lf"), +]) +def test_each_manifest_path_requires_lf_even_when_bytes_already_match(tmp_path, check, path, rule): + source, output = project(tmp_path) + output.parent.mkdir(parents=True) + output.write_bytes(source.read_bytes()) + other = "static/js/helper.js" if path.startswith("frontend/") else "frontend/src/shared/helper.js" + (tmp_path / ".gitattributes").write_text(f"* text=auto\n{other} text eol=lf\n{path} {rule}\n", encoding="utf-8") + with pytest.raises(ValueError, match="require repository-owned.*" + path): + build.synchronize(tmp_path, check=check) + assert output.read_bytes() == source.read_bytes() + + +def test_future_manifest_pair_cannot_publish_without_output_attributes(tmp_path): + source, output = project(tmp_path) + extra = source.with_name("future.js") + extra.write_bytes(b"export const future = true;\n") + manifest_file = tmp_path / "frontend/modules.json" + manifest = json.loads(manifest_file.read_text()) + manifest["modules"].append({"source": "shared/future.js", "deployment": "js/future.js"}) + manifest_file.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="static/js/future.js"): + build.synchronize(tmp_path) + assert not output.exists() + assert not (tmp_path / "static/js/future.js").exists() + with (tmp_path / ".gitattributes").open("a", encoding="utf-8") as attributes: + attributes.write("static/js/future.js text eol=lf\n") + assert build.synchronize(tmp_path) == ["static/js/helper.js", "static/js/future.js"] + assert build.synchronize(tmp_path, check=True) == [] + + +def test_nested_git_attribute_rules_and_paths_with_spaces_are_supported(tmp_path): + source, _ = project(tmp_path, [{"source": "shared/helper.js", "deployment": "js/feature space/helper.js"}]) + attributes = tmp_path / "static/js/feature space/.gitattributes" + attributes.parent.mkdir(parents=True) + attributes.write_text("*.js text eol=lf\n", encoding="utf-8") + assert build.synchronize(tmp_path) == ["static/js/feature space/helper.js"] + assert (attributes.parent / "helper.js").read_bytes() == source.read_bytes() + attributes.write_text("*.js text eol=crlf\n", encoding="utf-8") + with pytest.raises(ValueError, match="eol=crlf"): + build.synchronize(tmp_path, check=True) + + +def test_global_and_local_git_settings_cannot_mask_missing_repository_rule(tmp_path, monkeypatch): + project(tmp_path) + (tmp_path / ".gitattributes").write_text("frontend/src/** text eol=lf\n", encoding="utf-8") + global_attributes = tmp_path / "global-attributes" + global_attributes.write_text("* text eol=lf\n", encoding="utf-8") + config = tmp_path / "global-config" + config.write_text(f'[core]\n attributesFile = "{global_attributes.as_posix()}"\n autocrlf = true\n', encoding="utf-8") + subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True, capture_output=True) + (tmp_path / ".git/info/attributes").write_text("* text eol=lf\n", encoding="utf-8") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + monkeypatch.setenv("GIT_DIR", str(tmp_path / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", str(tmp_path)) + with pytest.raises(ValueError, match="static/js/helper.js"): + build.synchronize(tmp_path, check=True) + + +def test_attribute_enforcement_fails_clearly_when_development_git_is_missing(tmp_path, monkeypatch): + _, output = project(tmp_path) + monkeypatch.setattr(build.shutil, "which", lambda name: None) + with pytest.raises(ValueError, match="Git is required for frontend publication"): + build.synchronize(tmp_path) + assert not output.exists() diff --git a/tests/test_frontend_foundation_offline_js.py b/tests/test_frontend_foundation_offline_js.py new file mode 100644 index 000000000..314280e5e --- /dev/null +++ b/tests/test_frontend_foundation_offline_js.py @@ -0,0 +1,63 @@ +"""Real Chromium proof at the existing /static/ service-worker scope. + +This deliberately does not widen SW scope or claim offline SPA root reload. +The helper is first evaluated offline, after install-time precaching only. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node binary not on PATH") +def test_precached_helper_imports_offline_in_a_fresh_page_and_panels_stay_lazy(): + script = r''' + import assert from 'node:assert/strict'; + import { chromium } from 'playwright'; + const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ serviceWorkers: 'allow' }); + const page = await context.newPage(); + await page.goto(`${origin}/static/js/panels.js`); + const installed = await page.evaluate(async () => { + await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1'); + const registration = await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true })); + const response = await caches.match('/static/js/actionMenuOrder.js'); + return { scope: registration.scope, cached: !!response && response.ok }; + }); + assert.equal(installed.scope, `${origin}/static/`); + assert.equal(installed.cached, true); + // New page gives a fresh ES-module map. No helper import occurred online. + await context.setOffline(true); + const offline = await context.newPage(); + await offline.goto(`${origin}/static/js/panels.js`); + const requests = []; + offline.on('request', request => requests.push(request.url())); + const result = await offline.evaluate(async () => { + const helper = await import('/static/js/actionMenuOrder.js'); + const again = await import('./actionMenuOrder.js'); + const panels = await import('/static/js/panels.js?v=20260909movepicklayer1'); + return { + sameModule: helper === again, + exports: Object.keys(helper).sort(), + order: helper.orderActionMenuItems([{ label: 'Delete' }, { label: 'Rename' }]).map(i => i.label), + panels: panels.panelNames(), + }; + }); + assert.equal(result.sameModule, true); + assert.deepEqual(result.order, ['Rename', 'Delete']); + assert.ok(result.panels.includes('editor')); + assert.equal(requests.some(url => url.includes('/galleryEditor.js')), false); + console.log(JSON.stringify({ ...result, ...installed })); + } finally { await browser.close(); } + ''' + result = subprocess.run(["node", "--input-type=module", "--eval", script], cwd=ROOT, capture_output=True, text=True, timeout=90) + assert result.returncode == 0, result.stdout + result.stderr + data = json.loads(result.stdout) + assert data["exports"] == ["SELECT_MENU_ICON", "actionMenuRank", "orderActionMenuItems"]