Files
josh de39c1b7c3 docs(01-02): complete eslint-firewall plan
SUMMARY documents the ESLint flat config, the 9 element types, the
single CORE-10 rule, the deliberate-violation fixture, the Vitest test
that runs ESLint programmatically against the violator, and the four
auto-fixed deviations (typescript-eslint parser-only integration,
real render target file for the violator import, eslint-import-resolver-
typescript wiring, tsconfig.app.json test-file exclusion).

Verifies: npm run lint -> 0 errors / 0 warnings; npm test -> 2/2 pass;
npm run build -> green; eslint --no-ignore on violator -> exits 1.

Self-Check: PASSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:38:19 -04:00

26 KiB
Raw Permalink Blame History

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
01-foundations-and-doctrine 02 infra
eslint
eslint-plugin-boundaries
typescript-eslint
firewall
lint
vitest
architectural-firewall
phase provides
01-foundations-and-doctrine/01 Phaser 4 + React 19 + Vite 8 + TS 6 scaffold with the seven src/ firewall directories pre-created (sim, render, ui, save, content, audio, store), ESLint 9.39.4 + eslint-plugin-boundaries 6.0.2 pre-installed, npm 'lint' script pre-declared with --max-warnings 0
ESLint 9 flat config (eslint.config.js) declaring 9 element types — the seven Phase-1 firewall subsystems plus the template's app + game — and one rule (severity: error): src/sim/ MUST NOT import from src/render/ or src/ui/ (CORE-10)
Deliberate-violation fixture (src/sim/__test_violation__/violator.ts) excluded from default lint glob via the eslint.config.js ignores block
Vitest test (src/sim/__test_violation__/lint-firewall.test.ts) that runs ESLint programmatically against the violator and asserts boundaries/element-types fires with severity=error and message containing both 'sim' and 'render|ui'
Render-side stub file (src/render/__firewall_target__.ts) — minimal export so the boundaries plugin can resolve the violator's import to a real path on disk. Without this, the plugin marks the import target as isUnknown and silently skips the rule (verified empirically; see Deviations).
TypeScript-aware import resolution for ESLint via eslint-import-resolver-typescript (devDep), wired through eslint.config.js settings.import/resolver
Build-glob exclusions in tsconfig.app.json for *.test.ts and src/sim/__test_violation__/** so 'tsc -b' does not try to typecheck Vitest test files (which use Node APIs) under the DOM-only project lib settings
01-03-save-layer
01-04-content-pipeline
01-05-asset-provenance
01-07-ci-workflow
02-onwards
added patterns
typescript-eslint@^8.59.2 — parser only (provides @typescript-eslint/parser bundled). NO rule sets enabled. Required because ESLint's default Espree parser cannot parse .ts/.tsx syntax. Documented as a Plan 02 deviation (Rule 3 — Blocking) below.
eslint-import-resolver-typescript@^4 — required by eslint-plugin-boundaries to follow extension-less TS imports ('../../render/foo' -> src/render/foo.ts). Without it, the boundaries plugin marks all TS-import targets as isUnknown and the firewall rule silently skips (verified via the plugin's debug output). Documented as a Plan 02 deviation (Rule 1 — Bug fix).
Single ESLint flat config at repo root with element-types + ignores + parser-only typescript-eslint integration. No legacy .eslintrc.* file. Plan 02 owns ONE architectural rule; broader code-quality lint sets (js.configs.recommended, tseslint.configs.recommended) are deliberately omitted to keep Phase 1 scope tight.
Default posture is 'allow' — Phase 1 enforces ONE rule (CORE-10), not a closed-by-default architecture. Future phases may add cross-subsystem restrictions (e.g., render cannot import save) by adding entries to the rules array without changing the default.
Lint-rule-correctness-via-Vitest pattern: the firewall rule's end-to-end correctness is proven by a Vitest test that runs ESLint via the JS API against a deliberate-violation fixture, NOT by 'lint exits 0 on clean code' (which proves nothing about the rule). The fixture is excluded from the default lint glob so CI stays green; the test passes ignore:false to override the exclusion.
Test-violation fixtures live under __test_violation__/ subdirectories and are doubly-excluded — from eslint.config.js ignores AND from tsconfig.app.json's exclude block — so neither 'npm run lint' nor 'tsc -b' trip on them. Vitest's own include glob (src/**/*.test.ts) discovers the test inside that directory.
created modified
eslint.config.js (ESLint 9 flat config — 9 element types, 1 rule, parser+resolver wiring, default-lint exclusions)
src/sim/__test_violation__/violator.ts (deliberate sim → render import)
src/sim/__test_violation__/lint-firewall.test.ts (Vitest test that asserts the rule fires)
src/render/__firewall_target__.ts (minimal render-side export stub the violator targets)
package.json (added typescript-eslint and eslint-import-resolver-typescript devDeps)
package-lock.json (lockfile entries for the two new devDeps and their transitive closure
18 + 14 packages)
tsconfig.app.json (added exclude block for *.test.ts and src/sim/__test_violation__/**)
Omitted js.configs.recommended and tseslint.configs.recommended rule sets. Plan 02 owns exactly one architectural rule (CORE-10); broader code-quality lint is out of Phase 1 scope. Future phases may layer more rules on top of this config without touching the firewall block. Plan 01's SUMMARY confirmed no template eslint baseline existed to preserve.
Created src/render/__firewall_target__.ts as a real TS module (not a non-existent path) for the violator to import. The plan's Step 1 said 'doesn't need to exist as a real module' but empirical testing showed the boundaries plugin classifies unresolvable imports as isUnknown and silently skips the rule — the import MUST resolve to a real file under src/render/ to be classified as type:render and trigger the disallow.
Wired eslint-import-resolver-typescript via the import/resolver setting (boundaries plugin reads this). Without it, ext-less TS imports cannot be followed and EVERY in-repo TS import is marked isUnknown — the firewall rule would silently no-op even when called against the right shape of code.
Used the legacy boundaries/element-types rule + array-form selectors ({ from: ['sim'], disallow: ['render', 'ui'] }) per the plan's Pattern 5 spec. The plugin emits stderr deprecation notices recommending boundaries/dependencies + object-form selectors (the v6 modern shape), but those notices are informational — they do NOT count as ESLint warnings (verified via -f json: 0 errors, 0 warnings) and do NOT trip --max-warnings 0. Migration to the v6 modern shape is deferred to a future phase if it ever becomes load-bearing.
Excluded src/sim/__test_violation__/** and *.test.ts from tsconfig.app.json's build glob (added an exclude block). Vitest discovers test files via its own include glob, completely independent of tsconfig — so this only narrows what 'tsc -b' compiles, not what 'npm test' runs. Required because the firewall test imports node:path / process which aren't in the DOM-only app lib config.
Suppressed the 'Multiple projects found' notice from eslint-import-resolver-typescript via noWarnOnMultipleProjects:true. The referenced-projects tsconfig layout (root tsconfig with references to tsconfig.app.json + tsconfig.node.json) is deliberate Plan 01 design — the resolver sees both as 'projects' and warns; we explicitly opt out.
Lint-rule correctness via Vitest + ESLint Node API pattern: any architectural rule landed in this project should be paired with a Vitest test that imports the ESLint class, runs it programmatically against a deliberate-violation fixture (under __test_violation__/), and asserts the expected ruleId + severity fires. This satisfies the Nyquist Rule for static-analysis rules ('lint exits 0' proves nothing about whether a specific rule actually works).
Double-exclusion pattern for test-violation fixtures: ignored by eslint.config.js (so npm run lint stays green) AND excluded from tsconfig.app.json (so tsc -b doesn't typecheck them). The Vitest test that consumes them passes ignore:false to ESLint to override the lint-side exclusion.
Real-target-required pattern for boundaries plugin tests: deliberate-violation fixtures must import from REAL files under the target element directory, not from non-existent paths. The boundaries plugin classifies import targets via element pattern after resolving the import to a file path; unresolvable imports are isUnknown and silently skip rule evaluation.
CORE-10
22min 2026-05-09

Phase 01 Plan 02: ESLint Firewall Summary

ESLint 9 flat config + eslint-plugin-boundaries 6.0.2 enforcing CORE-10 (src/sim/ cannot import src/render/ or src/ui/) at error severity, with a Vitest test that runs ESLint programmatically against a deliberate-violation fixture and asserts the rule fires end-to-end.

Performance

  • Duration: 22 min
  • Started: 2026-05-09T03:12:34Z (worktree spawn — first action ran ~3 min after spawn due to dependency install)
  • Completed: 2026-05-09T03:34:09Z
  • Tasks: 2 (both completed atomically)
  • Files created: 4 (eslint.config.js, violator.ts, lint-firewall.test.ts, firewall_target.ts)
  • Files modified: 3 (package.json, package-lock.json, tsconfig.app.json)

Accomplishments

  • CORE-10 firewall is structurally enforced. eslint.config.js declares the seven Phase-1 subsystem element types (sim, render, ui, save, content, audio, store) plus the template's app (the React/Phaser bridge files) and game (src/game/**) types — 9 total. One rule, severity error: { from: ['sim'], disallow: ['render', 'ui'] }. Default posture allow so Phase 1 enforces only this one architectural constraint, not a closed-by-default architecture.
  • The rule is provably correct end-to-end. src/sim/__test_violation__/lint-firewall.test.ts instantiates the ESLint class, runs it against src/sim/__test_violation__/violator.ts (which imports from src/render/__firewall_target__.ts), and asserts the result includes a boundaries/element-types message at severity 2 (error) whose text contains both sim and render|ui. Test passes in ~1 second. This satisfies the Nyquist Rule — the rule's correctness is automated, not assumed from "lint exits 0 on clean code".
  • npm run lint exits 0 on the clean codebase. Zero errors, zero warnings (verified via -f json formatter). The violator fixture is excluded by the ignores block in eslint.config.js, so it doesn't break CI; the test reaches it via ignore: false on the programmatic ESLint instance.
  • npm run build and npm test continue to pass. TypeScript strict-mode build is green; the test suite is now 2/2 (sentinel from Plan 01 + this firewall test).
  • Wave 2 sibling plans are unblocked. Plans 03/04/05/06 can now land their config and code without colliding on eslint.config.js. Plan 07's CI workflow can compose npm run lint && npm run test and rely on both being green for this rule.

Task Commits

Each task was committed atomically on worktree-agent-adaed29911349f3f4:

  1. Task 1: ESLint flat config + boundaries plugin + CORE-10 firewall rulee9b742d (chore)
  2. Task 2: CORE-10 firewall test + violator fixture + render target stub8c1d839 (test)

Plan metadata: (this commit, by the orchestrator after merge)docs(01-02): complete eslint-firewall plan

Final shape of eslint.config.js

import boundaries from 'eslint-plugin-boundaries';
import tseslint from 'typescript-eslint';

export default [
  // 1. Default-lint exclusions (the violator fixture lives under
  //    src/sim/__test_violation__/ and must NOT trip CI).
  { ignores: ['src/sim/__test_violation__/**', 'dist/**', 'node_modules/**', 'coverage/**', '*.tsbuildinfo'] },

  // 2. Phase-1 architectural firewall (CORE-10).
  {
    files: ['src/**/*.{ts,tsx,js,jsx,mjs,cjs}'],
    plugins: { boundaries },
    languageOptions: {
      parser: tseslint.parser,
      parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } },
    },
    settings: {
      'boundaries/elements': [
        { type: 'sim',     pattern: 'src/sim/**' },
        { type: 'render',  pattern: 'src/render/**' },
        { type: 'ui',      pattern: 'src/ui/**' },
        { type: 'save',    pattern: 'src/save/**' },
        { type: 'content', pattern: 'src/content/**' },
        { type: 'audio',   pattern: 'src/audio/**' },
        { type: 'store',   pattern: 'src/store/**' },
        { type: 'app',     pattern: 'src/{main,App,PhaserGame}.{ts,tsx}' },
        { type: 'game',    pattern: 'src/game/**' },
      ],
      'boundaries/include': ['src/**/*'],
      'boundaries/ignore': ['src/vite-env.d.ts', 'src/__sentinel__.test.ts'],
      'import/resolver': {
        typescript: {
          alwaysTryTypes: true,
          project: ['./tsconfig.app.json', './tsconfig.node.json'],
          noWarnOnMultipleProjects: true,
        },
      },
    },
    rules: {
      'boundaries/element-types': ['error', {
        default: 'allow',
        rules: [
          { from: ['sim'], disallow: ['render', 'ui'] },
        ],
      }],
    },
  },
];

ESLint version landscape

Per Plan 01's drift report:

  • ESLint: ^9.39.4 (installed by Plan 01, untouched here). Flat config only — no legacy .eslintrc.* file ever existed in the repo, so Task 1 was a creation, not a migration.
  • eslint-plugin-boundaries: ^6.0.2 (installed by Plan 01, untouched here).
  • typescript-eslint: ^8.59.2 — added by THIS plan (Task 1, devDep). Parser only (tseslint.parser); no rule sets are enabled.
  • eslint-import-resolver-typescript: ^4.x (latest installed) — added by THIS plan (Task 2, devDep). Required for the boundaries plugin's element classification to follow extension-less TS imports to disk.

Verification snapshot

Gate Command Result
Lint clean codebase npm run lint exit 0, 0 errors, 0 warnings
Firewall test passes npx vitest run src/sim/__test_violation__/lint-firewall.test.ts exit 0, 1/1 pass
Full test suite npm test exit 0, 2/2 pass (sentinel + firewall)
TS-strict build npm run build exit 0, dist/ produced
Rule fires when invoked npx eslint --no-ignore src/sim/__test_violation__/violator.ts exit 1, boundaries/element-types error mentioning sim/render

Decisions Made

See the key-decisions frontmatter block above. Brief rationale for each:

  1. Omitted broader rule sets — Plan 02 owns ONE rule (CORE-10). Pulling in js.configs.recommended would expand scope to dozens of code-quality rules on a clean greenfield codebase that Plan 01's TS-strict + manual-curation discipline already covers. Future phases may add rule sets on top without disturbing the firewall block.
  2. Real render target file (not a non-existent path) — empirical override of the plan's "doesn't need to exist as a real module" guidance. See Deviations below.
  3. TypeScript resolver wired in — required by the boundaries plugin to classify import targets. See Deviations below.
  4. Kept boundaries/element-types (not boundaries/dependencies) — followed the plan's Pattern 5 spec verbatim. The plugin's stderr deprecation notices are informational; they don't count as ESLint warnings and don't trip --max-warnings 0.
  5. Test-fixture dir excluded from tsconfig.app.json — the firewall test uses node:path and process, which aren't in the DOM-only app lib config. Vitest discovers tests via its own glob.
  6. Suppressed multi-project resolver warning — Plan 01 deliberately uses the referenced-projects tsconfig layout; the resolver's warning is asking us to undo Plan 01's design.

Deviations from Plan

Auto-fixed Issues

1. [Rule 3 — Blocking] Added typescript-eslint (parser only) so ESLint can parse .ts/.tsx

  • Found during: Task 1, Step 4 (running npm run lint after writing the initial config).
  • Issue: ESLint's default parser (Espree) cannot parse TypeScript syntax or JSX. Initial npm run lint produced 5 "Parsing error: Unexpected token" errors against src/main.tsx, src/App.tsx, src/PhaserGame.tsx, src/game/main.ts, src/game/scenes/Boot.ts. Without a TS-aware parser, npm run lint cannot exit 0 on a TypeScript-strict scaffold, which violates Task 1's <verify> gate.
  • Fix: Installed typescript-eslint@^8.59.2 (the meta-package that bundles @typescript-eslint/parser). Wired ONLY the parser via languageOptions.parser: tseslint.parser in eslint.config.js. NO tseslint.configs.* rule sets are enabled — Plan 02's discipline of owning exactly one architectural rule is preserved.
  • Files modified: package.json, package-lock.json, eslint.config.js.
  • Verification: npm run lint exits 0 with 0 errors / 0 warnings (verified via -f json JSON formatter).
  • Committed in: e9b742d (Task 1 commit).

2. [Rule 1 — Bug] Created src/render/__firewall_target__.ts as a real import target for the violator

  • Found during: Task 2, Step 3 (running the Vitest test for the first time — it failed with expected 0 to be greater than 0, meaning the rule did not fire even though the violator was clearly a sim-importing-render shape).
  • Issue: The plan's Step 1 said "ESLint's boundaries plugin lints the import path against element-type rules without resolving the module" — implying the violator could import a non-existent path like '../../render/this-file-does-not-exist'. This is empirically false. Running the boundaries plugin with ESLINT_PLUGIN_BOUNDARIES_DEBUG=1 showed the import target classified as { type: null, isUnknown: true }, and the rule then has nothing to disallow against and silently skips. The plugin REQUIRES the import target to resolve to a real file on disk so it can match the file path against element patterns.
  • Fix: (a) Created src/render/__firewall_target__.ts exporting a single marker constant. (b) Updated the violator to import from this real file: import { FIREWALL_TARGET_MARKER } from '../../render/__firewall_target__';. Added documentation comments in both files explaining the role.
  • Files modified: src/sim/__test_violation__/violator.ts, src/render/__firewall_target__.ts (new).
  • Verification: ESLINT_PLUGIN_BOUNDARIES_DEBUG=1 npx eslint ... now shows the target classified as { type: 'render', isUnknown: false }. After also fixing #3 below, the rule fires with: Dependencies to elements of type "render" are not allowed in elements of type "sim" and captured "null". Denied by rule at index 0 boundaries/element-types.
  • Committed in: 8c1d839 (Task 2 commit).

3. [Rule 1 — Bug] Added eslint-import-resolver-typescript so the boundaries plugin can resolve extension-less TS imports

  • Found during: Task 2, Step 3 (after fix #2, the target was STILL isUnknown because '../../render/__firewall_target__' has no .ts extension and Node-style resolution doesn't add one).
  • Issue: eslint-plugin-boundaries uses eslint-plugin-import's resolver mechanism to follow imports to disk. The default resolver is Node-style and refuses to add a .ts extension to extension-less imports. Without a TS-aware resolver, EVERY in-repo TS import is marked isUnknown and the firewall rule silently no-ops — even with a real target file present. This is a load-bearing wiring requirement the plan didn't anticipate (the plan focused on the rule-config shape; resolver wiring was implicit).
  • Fix: Installed eslint-import-resolver-typescript (latest, ^4.x) as a devDep. Added 'import/resolver': { typescript: { alwaysTryTypes: true, project: ['./tsconfig.app.json', './tsconfig.node.json'], noWarnOnMultipleProjects: true } } to the boundaries config block in eslint.config.js. The two-project array reflects Plan 01's referenced-projects tsconfig layout.
  • Files modified: package.json, package-lock.json, eslint.config.js.
  • Verification: Debug output now shows imports resolving to disk paths and classifying correctly; the rule fires against the violator; the Vitest test passes. npm run lint still exits 0 with 0 errors / 0 warnings.
  • Committed in: 8c1d839 (Task 2 commit).

4. [Rule 3 — Blocking] Excluded *.test.ts and src/sim/__test_violation__/** from tsconfig.app.json build glob

  • Found during: Task 2, Step 4 (running npm run build after Task 2 created the test file — tsc -b failed with 3 TS2591 errors on the test file's node:path and process references).
  • Issue: The firewall test uses Node APIs (node:path for resolve, process.cwd()) but tsconfig.app.json has lib: ["ES2022", "DOM", "DOM.Iterable"] and types: ["vite/client"] — no Node types. The original tsconfig.app.json had include: ["src"] with no exclude block, so tsc -b tried to compile all of src/ including test files. The test file was correct TypeScript for its target environment (Node, via Vitest), but wrong for the app's DOM-only project.
  • Fix: Added an exclude: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/sim/__test_violation__/**"] block to tsconfig.app.json. Vitest discovers tests via its own include glob in vitest.config.ts, completely independent of tsconfig — so this only narrows what tsc -b compiles, not what npm test runs.
  • Files modified: tsconfig.app.json.
  • Verification: npm run build now exits 0 (tsc -b clean, vite build clean); npm test still exits 0 with 2/2 passing.
  • Committed in: 8c1d839 (Task 2 commit).

Total deviations: 4 auto-fixed (2 blocking, 2 bug fixes — all under the Rule 1/2/3 auto-fix umbrella; none required architectural changes per Rule 4). Impact on plan: All four deviations are mechanical wiring requirements the plan's high-level spec didn't anticipate. The plan's intent (CORE-10 enforced + provably tested) is satisfied exactly. No scope creep — the only added dependencies are tooling (typescript-eslint parser, eslint-import-resolver-typescript); no rule sets, no broader lint coverage. Wave-2 sibling plans (0306) are unaffected.

Issues Encountered

  • node_modules/ was not present in the worktree at agent spawn. Worktrees inherit .git but not the working tree's installed dependencies. Ran npm ci --no-audit --no-fund (10s, 209 packages) before any other work. Time cost: ~10s.
  • Boundaries plugin debug spelunking. Three full debug-output cycles (ESLINT_PLUGIN_BOUNDARIES_DEBUG=1) were needed to diagnose deviations #2 and #3. The plugin's debug output is excellent — it shows the classification of both source and target files, which made the root causes visible immediately. Time cost: ~5 min.

Authentication Gates

None — Phase 1 is build/dev tooling only; no external auth needed.

Threat Flags

None — this plan is static-analysis tooling (no network, no auth, no file IO outside the build), and the boundary rule's mitigation effect is architectural integrity (preventing the simulation core from becoming non-deterministic or non-headless), not security. The threat-model section of the PLAN.md says "No security-relevant code in this plan; this is static-analysis tooling."

Known Stubs

  • src/render/__firewall_target__.ts is a one-line export-stub for the firewall test ONLY. It is NOT part of the runtime render layer. src/render/ is otherwise empty in Phase 1 (only .gitkeep). Phase 2 will populate src/render/ with real Phaser scenes. If the firewall test is ever rewritten to point at a real render module, this stub should be removed. Documented in the file's header comment.
  • src/sim/__test_violation__/violator.ts is intentionally a deliberate-violation fixture, lint-tested only. It is excluded from both the lint glob and the TS build. Documented in the file's header comment.

These are intentional, plan-anticipated stubs. They exist because the test infrastructure for an architectural rule MUST stress-test the rule end-to-end, and that requires a real (not synthetic) sim → render edge in code.

Next Plan Readiness

  • Plan 03 (save layer): Unaffected. The boundaries rule does not restrict imports into src/save/; Plan 03 can populate src/save/ freely. The TS resolver and the test-file exclusion in tsconfig.app.json will benefit Plan 03's IDB tests too — they should add their src/save/**/*.test.ts files and they'll be picked up by Vitest while excluded from tsc -b.
  • Plan 04 (content pipeline): Unaffected. src/content/ is a declared element type but has no disallow rule against it.
  • Plan 05 (asset provenance): Unaffected — Plan 05 writes scripts/validate-assets.mjs, which lives outside src/ and is therefore outside the boundaries rule's scope.
  • Plan 06 (doctrine docs): Unaffected — pure markdown.
  • Plan 07 (CI workflow): Ready. CI can compose npm run lint && npm run test && npm run build with high confidence — all three are green here, and the firewall rule has an automated test (not just "lint runs"), so a future regression that breaks the rule will be caught immediately.

Self-Check

  • eslint.config.js exists at repo root — test -f eslint.config.js PASS.
  • eslint.config.js contains boundaries/element-types — PASS.
  • All 7 firewall element types declared (sim, render, ui, save, content, audio, store) — verified by individual grep "type: '<name>'" for each. PASS.
  • disallow: ['render', 'ui'] from sim — PASS (line: { from: ['sim'], disallow: ['render', 'ui'] },).
  • No legacy .eslintrc.* file remains — PASS (ls .eslintrc.* returns no matches).
  • __test_violation__ is in the ignores block — PASS.
  • npm run lint exits 0 — PASS (0 errors, 0 warnings via JSON formatter).
  • src/sim/__test_violation__/violator.ts exists and imports from '../../render/' — PASS.
  • src/sim/__test_violation__/lint-firewall.test.ts exists, references boundaries/element-types, imports ESLint from eslint, and asserts both sim and render|ui — PASS (count of toMatch lines mentioning sim/render = 2).
  • npx vitest run src/sim/__test_violation__/lint-firewall.test.ts exits 0 — PASS.
  • npx eslint --no-ignore src/sim/__test_violation__/violator.ts exits non-zero — PASS (exit 1, expected error fires).
  • npm run build exits 0 — PASS.
  • npm test exits 0 with 2/2 passing — PASS.
  • Task 1 commit exists: e9b742d — verified in git log.
  • Task 2 commit exists: 8c1d839 — verified in git log.

## Self-Check: PASSED


Phase: 01-foundations-and-doctrine Plan: 02 of 7 Completed: 2026-05-09