diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7e214a9 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,146 @@ +# Copilot instructions + +React Native + TypeScript mobile app (Expo, Continuous Native Generation) with a +layered architecture, MVVM-style state, and a composition-root DI seam. + +This repository is the **template** apps are generated from (`yarn generate`, +see [doc/ProjectGenerator.md](../doc/ProjectGenerator.md)). Generated apps drop +the governance/scaffolding files and the `cli/` folder. + +[CLAUDE.md](../CLAUDE.md) holds the same guidance in more detail; `doc/` holds +one permanent page per cross-cutting concern ([doc/README.md](../doc/README.md) +is the index, [doc/Architecture.md](../doc/Architecture.md) the best start). + +## Commands + +Package manager is **Yarn 1 (classic)** — `yarn add ` / `yarn add -D `, +never `npm install`. Use `npx expo install ` for Expo/RN libraries so +versions stay SDK-compatible. + +```sh +yarn typecheck && yarn lint && yarn test # the verify loop; must pass before proposing a change +yarn lint:fix # ESLint + Prettier autofix +yarn test:coverage # coverage report (diagnostic, not a gate) +yarn android / yarn ios / yarn start # expo run:android / run:ios / Metro only +yarn e2e # Maestro flows (device/CI only) +yarn audit:scan # dependency (SCA) audit +``` + +Running a subset of tests (Jest takes a path regex / `-t` name filter): + +```sh +yarn test test/business/jokes/DefaultJokesService.test.ts # one file +yarn test jokes # path substring +yarn test -t 'toggleFavorite' # one test by name +yarn test --watch src/presentation # watch a folder +``` + +`yarn typecheck` checks both `tsconfig.json` and `cli/tsconfig.json`. Jest roots +are `src/`, `test/`, and `cli/`. + +## Architecture + +Dependencies point strictly downward; inside every layer code is grouped +**by feature** (`access/jokes/`, `business/jokes/`, `presentation/jokes/`). + +| Layer | Folder | Contains | May depend on | +|-------|--------|----------|---------------| +| Access (DAL) | `src/access/` | HTTP clients, storage, native wrappers, zod-parsed DTOs | nothing above it | +| Business | `src/business/` | Domain services & immutable entities (plain TS, RxJS) | Access interfaces | +| Presentation | `src/presentation/` | Screens, hooks, navigation, theme | Business interfaces via `useServices()` | +| Framework | `src/framework/` | Composition root, providers, i18n, logging setup | all (it wires them) | + +- **DI without a container.** Every service is an interface constructed in the + single composition root `src/framework/composition/createServices.ts`, exposed + through `ServicesProvider` / `useServices()`. Constructor injection only; tests + override edges via `createServices({ jokesRepository: fake })`. +- **Two state paths (MVVM).** Fetched request/response data → React Query, with + keys **only** from the `src/presentation/queryKeys.ts` factory (never ad-hoc + arrays). Live domain state → an RxJS `BehaviorSubject` behind a service + interface, read in the UI only through `src/presentation/hooks/useObservable.ts` + — no RxJS operator pipelines in the UI; transformation belongs in the service. +- **Hooks are thin bindings**; heavy logic stays in plain-TS Access/Business units. +- **Operational gates, not routes.** Forced update and the kill switch expose an + `Observable`; `AppGate` swaps the whole tree (forced update wins over + kill switch, auto-recovers). `DiagnosticsHost` mounts *outside* `AppGate`. +- **Restart-to-apply switches.** Environment selection and the real-vs-mock flag + are persisted and resolved once at startup (`resolveMockingEnabled`) — never + re-wire the live graph. +- **Vendor SDKs live behind seams** (`RemoteConfigProvider`, `AnalyticsSink`, + `CrashReporter`, `AppReviewGateway`). Opt-in native SDKs (Firebase Remote + Config, Bugsee) are loaded by a literal guarded `require` inside one gateway, + reachable only from `src/framework/composition/platformIntegrations.ts`, so the + default graph and base native build stay SDK-free. + +`src/{access,business,presentation}/jokes/` (Dad Jokes) is the canonical vertical +slice — copy it; see [doc/DadJokes.md](../doc/DadJokes.md). + +## Adding a feature `foo` + +1. `src/access/foo/` — `FooRepository` interface + `HttpFooRepository` (axios + from `createHttpClient` + a zod schema) + `MockFooRepository`. +2. `src/business/foo/` — `FooService` interface + `DefaultFooService` (plain TS, + `BehaviorSubject` for live state, Access deps via constructor). +3. `src/presentation/foo/` — thin `useFoo` hook (`useServices()`, React Query, + `useObservable`) + components built from the design-system base components. +4. Wire the real implementations in `createServices.ts` — the only wiring step. +5. Test Tier 1 (plain TS) + Tier 2 (`renderHook`/RTL), then run the verify loop. + +Never introduce a DI container, and never move heavy logic into hooks. + +## Conventions + +- **TypeScript strict** (`expo/tsconfig.base`); no unjustified `any`. +- **Casing:** feature folders camelCase; components/screens/type/class/interface + files PascalCase (`JokesScreen.tsx`, `JokesRepository.ts`); hooks and plain + modules camelCase (`useJokes.ts`, `queryKeys.ts`). Naming: `FooRepository` / + `HttpFooRepository` / `MockFooRepository`; `FooService` / `DefaultFooService`. +- **Never hand-edit or commit `android/` or `ios/`** — generated by + `expo prebuild`, gitignored. No EAS Build, no `expo-updates`/OTA. +- **App config is `app.config.ts`**; features read `EnvironmentService.getConfig()` + at runtime, never the config file. +- **Storage:** `KeyValueStore` (MMKV, synchronous) for plain data; `SecureStore` + (expo-secure-store, async) for secrets only. Both interfaces with in-memory mocks. +- **Serialization:** every Access DTO is a zod `fooSchema` + `z.infer`; parse at + the Access boundary only — fail loud on network payloads, fail soft on persisted. +- **Errors:** failures surface as the typed taxonomy in `src/access/http/errors.ts`; + screens render fetched state through `QueryStateView`. +- **Logging:** inject the `Logger`; no `console.*` in app code except `ConsoleTransport`. +- **Navigation:** typed `RootStackParamList`; no string-literal route names + outside the param lists; `navigationRef` for imperative navigation from services. +- **Theming:** colors/spacing/radii/typography come from `useTheme()` or the base + components (`Screen`, `Card`, `AppText`, `Button`, `TextField`) — no inline hex, + font sizes, or magic margins. Add a token instead of a one-off value. +- **Localization:** all user-facing copy through `t('key')` (lint-enforced on + `src/presentation/**`); add every key to both `en.json` and `fr.json`. +- **Forms:** `react-hook-form` + zod resolver, schema as a `(t) => z.object(...)` + builder for localized messages, fields bound with `Controller` to `TextField`. +- **Lint:** ESLint flat config on `eslint-config-expo` with Prettier as a rule; + `react-hooks/exhaustive-deps` and `i18next/no-literal-string` are errors. +- **No vendor keys in the repo** (it is public): Firebase config files are + gitignored (`.example` placeholders only), Bugsee tokens come from CI env vars + through `extra.bugsee`. + +## Testing + +Three tiers — use the lowest that catches the bug. Tier 1: plain TS in Node +(services, RxJS streams, composition root). Tier 2: `@testing-library/react-native` +headless (`render`/`renderHook` are **async** in RNTL v14 — `await` them; flush +observable-driven presses inside `await act(async () => ...)`). Tier 3: Maestro +flows in `e2e/`, selectors are **`testID`s, never localized copy**. + +- Suites live in `test/` (mirrors `src/`, examples in `test/examples/`) or + co-located as `*.test.ts(x)`; headless operational flows in `test/integration/`. +- Fake the edges: **MSW** for network, `Mock*Repository`/fakes for data. Native + modules are faked (MMKV via `moduleNameMapper`, safe-area via `jest.setup.js`). +- Tier-2 query clients: `new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })`. +- Tier-1 observable tests: subscribe → act → assert → **unsubscribe**. +- Restart-to-apply behavior is proven by building a second graph over a shared + `KeyValueStore`. + +## Repository housekeeping + +- Commits must follow **Conventional Commits** (enforced by + `.github/workflows/conventional-commits.yml`). +- Add a `doc/` page for every new cross-cutting concern, and keep the index in + `doc/README.md` current. diff --git a/cli/generate.test.ts b/cli/generate.test.ts index d9612e6..2328e1f 100644 --- a/cli/generate.test.ts +++ b/cli/generate.test.ts @@ -375,6 +375,51 @@ describe('generate (filesystem)', () => { ].join('\n'), ); writeFileSync(join(root, 'README.md'), '# Template'); + // Permanent doc pages that point at `.github/copilot-instructions.md`. The + // whole `.github` folder is deleted, so those pointers sit in `template-only` + // blocks and are scrubbed in place; the surrounding guidance stays. + writeFileSync( + join(root, 'doc', 'Architecture.md'), + [ + '# Architecture', + '', + 'The layer boundaries every generated app keeps.', + '', + '', + 'GitHub Copilot reads the same guidance from', + '[.github/copilot-instructions.md](../.github/copilot-instructions.md).', + '', + '', + ].join('\n'), + ); + writeFileSync( + join(root, 'doc', 'DadJokes.md'), + [ + '# The Dad Jokes sample feature', + '', + 'The canonical vertical slice.', + '', + '', + 'The same recipe is mirrored in', + '[.github/copilot-instructions.md](../.github/copilot-instructions.md).', + '', + '', + ].join('\n'), + ); + writeFileSync( + join(root, 'doc', 'GettingStarted.md'), + [ + '# Getting Started', + '', + 'The verify loop every change must pass.', + '', + '', + 'The same requirement is stated in', + '[.github/copilot-instructions.md](../.github/copilot-instructions.md).', + '', + '', + ].join('\n'), + ); }); afterEach(() => { @@ -490,6 +535,32 @@ describe('generate (filesystem)', () => { expect(result.unwired).toEqual(expect.arrayContaining(['CLAUDE.md', 'doc/README.md'])); }); + it('scrubs the Copilot-instructions pointers from the docs that survive generation', () => { + const result = generate(root, ACME); + + // `.github` is deleted wholesale, so every link into it must be gone from the + // permanent doc pages — otherwise a generated app ships dangling links. + for (const page of ['Architecture.md', 'DadJokes.md', 'GettingStarted.md']) { + const content = readFileSync(join(root, 'doc', page), 'utf8'); + expect(content).not.toContain('copilot-instructions'); + expect(content).not.toContain('template-only'); + } + // The surrounding guidance survives the scrub. + expect(readFileSync(join(root, 'doc', 'Architecture.md'), 'utf8')).toContain( + 'The layer boundaries every generated app keeps.', + ); + expect(readFileSync(join(root, 'doc', 'DadJokes.md'), 'utf8')).toContain( + 'The canonical vertical slice.', + ); + expect(readFileSync(join(root, 'doc', 'GettingStarted.md'), 'utf8')).toContain( + 'The verify loop every change must pass.', + ); + + expect(result.unwired).toEqual( + expect.arrayContaining(['doc/Architecture.md', 'doc/DadJokes.md', 'doc/GettingStarted.md']), + ); + }); + it('un-wires the generator from package.json and jest.config.js when cli is removed', () => { const result = generate(root, ACME); expect(result.unwired).toEqual(expect.arrayContaining(['package.json', 'jest.config.js'])); diff --git a/cli/generate.ts b/cli/generate.ts index f6149f5..6eac839 100644 --- a/cli/generate.ts +++ b/cli/generate.ts @@ -481,8 +481,10 @@ export function stripTemplateOnlyBlocks(content: string): string { /** * Files (relative to the project root) that carry `template-only` regions the * generated app must not keep — e.g. the CI stage that runs this generator, the - * "this repository is a template" framing in `CLAUDE.md`, and the doc-index link - * to the (deleted) generator page. Deleting the generator's `cli` folder handles + * "this repository is a template" framing in `CLAUDE.md`, the doc-index link + * to the (deleted) generator page, and the doc cross-references to + * `.github/copilot-instructions.md` (the whole `.github` folder is removed, so + * those links would dangle). Deleting the generator's `cli` folder handles * the code; these files instead have a marked region scrubbed in place so the * rest of the file (the real pipeline, the architecture guidance, the rest of the * index) survives. @@ -492,6 +494,9 @@ export const FILES_WITH_TEMPLATE_ONLY_BLOCKS: readonly string[] = [ 'doc/AzurePipelines.md', 'CLAUDE.md', 'doc/README.md', + 'doc/Architecture.md', + 'doc/DadJokes.md', + 'doc/GettingStarted.md', ]; /** @@ -579,9 +584,10 @@ export function generate( // Once the cli folder is removed, its references in the tooling would break // the quality gates; strip them so the generated project stays green. Also // scrub every `template-only` region — the CI stage that drives this - // generator, the doc-index link to the deleted generator page, and the - // "this repository is a template" framing in CLAUDE.md / the doc index — so a - // generated app reads as an app, not the template it was stamped from. + // generator, the doc-index link to the deleted generator page, the + // "this repository is a template" framing in CLAUDE.md / the doc index, and + // the doc cross-references to the removed `.github/copilot-instructions.md` + // — so a generated app reads as an app, not the template it was stamped from. if (!dryRun) { unwired = [...unwireGeneratorTooling(root), ...stripTemplateOnlyBlocksInFiles(root)]; } diff --git a/doc/Architecture.md b/doc/Architecture.md index 78453c1..100736c 100644 --- a/doc/Architecture.md +++ b/doc/Architecture.md @@ -76,3 +76,9 @@ repositories, favorites as a persisted `BehaviorSubject` source of truth, list/detail navigation, and the [design system](DesignSystem.md). Read [DadJokes.md](DadJokes.md) for the full file-by-file walkthrough before starting a new feature. + + +GitHub Copilot reads the same guidance from +[.github/copilot-instructions.md](../.github/copilot-instructions.md) — keep the +two agent instruction files in sync. + diff --git a/doc/DadJokes.md b/doc/DadJokes.md index 1b988e1..c7db57e 100644 --- a/doc/DadJokes.md +++ b/doc/DadJokes.md @@ -7,6 +7,11 @@ every pattern the architecture names: interface + real + mock at the Access boundary, an RxJS source of truth in Business, and the two data paths (React Query + `useObservable`) in Presentation. + +The same recipe is mirrored for GitHub Copilot in +[.github/copilot-instructions.md](../.github/copilot-instructions.md#adding-a-feature-foo). + + ## The slice, layer by layer ### Access — `src/access/jokes/` diff --git a/doc/GettingStarted.md b/doc/GettingStarted.md index 6bdfe54..c5d3e49 100644 --- a/doc/GettingStarted.md +++ b/doc/GettingStarted.md @@ -90,3 +90,8 @@ yarn typecheck && yarn lint && yarn test This must pass before proposing any change (see [CLAUDE.md](../CLAUDE.md)). It is the same sequence CI runs on every PR ([AzurePipelines.md](AzurePipelines.md)). + + +The same requirement is stated for GitHub Copilot in +[.github/copilot-instructions.md](../.github/copilot-instructions.md). + diff --git a/doc/ProjectGenerator.md b/doc/ProjectGenerator.md index b122393..3969467 100644 --- a/doc/ProjectGenerator.md +++ b/doc/ProjectGenerator.md @@ -83,8 +83,11 @@ Maestro `appId`s, the CI signing conventions, and the Firebase example configs). - **Scrubbed in place** (regions bracketed by `template-only` markers, so the rest of each file survives): the `Template_Validation` stage in `build/azure-pipelines.yml` and its section in `doc/AzurePipelines.md`; the - "this repository is a template" framing in `CLAUDE.md`; and the doc index's link - to this page in `doc/README.md`. Prose that describes the app rather than the + "this repository is a template" framing in `CLAUDE.md`; the doc index's link + to this page in `doc/README.md`; and the pointers to + `.github/copilot-instructions.md` in `doc/Architecture.md`, `doc/DadJokes.md`, + and `doc/GettingStarted.md` (the whole `.github/` folder is removed, so those + links would otherwise dangle). Prose that describes the app rather than the template is reworded at the source, so it needs no marker. - **Formatting**: files it edited are re-run through Prettier, because shortening or lengthening an identifier can change how a line wraps. @@ -114,9 +117,11 @@ pure functions in `cli/generate.ts`: shorter one can never partially match a longer one. - `stripTemplateOnlyBlocks` removes each `template-only:begin`…`template-only:end` region from the files that must survive generation (the `Template_Validation` - stage in the pipeline and its doc, the template framing in `CLAUDE.md`, and the - generator link in the doc index), so each keeps everything *except* the marked - block. `stripTemplateOnlyBlocksInFiles` applies it to that file list. + stage in the pipeline and its doc, the template framing in `CLAUDE.md`, the + generator link in the doc index, and the Copilot-instructions pointers in the + architecture, Dad Jokes, and getting-started pages), so each keeps everything + *except* the marked block. `stripTemplateOnlyBlocksInFiles` applies it to that + file list. - `generate` orchestrates substitution, README rewrite, cleanup, un-wiring, and the block strip; `formatFiles` runs the Prettier pass.