Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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 <pkg>` / `yarn add -D <pkg>`,
never `npm install`. Use `npx expo install <pkg>` 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<boolean>`; `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.
71 changes: 71 additions & 0 deletions cli/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
'',
'<!-- template-only:begin -->',
'GitHub Copilot reads the same guidance from',
'[.github/copilot-instructions.md](../.github/copilot-instructions.md).',
'<!-- template-only:end -->',
'',
].join('\n'),
);
writeFileSync(
join(root, 'doc', 'DadJokes.md'),
[
'# The Dad Jokes sample feature',
'',
'The canonical vertical slice.',
'',
'<!-- template-only:begin -->',
'The same recipe is mirrored in',
'[.github/copilot-instructions.md](../.github/copilot-instructions.md).',
'<!-- template-only:end -->',
'',
].join('\n'),
);
writeFileSync(
join(root, 'doc', 'GettingStarted.md'),
[
'# Getting Started',
'',
'The verify loop every change must pass.',
'',
'<!-- template-only:begin -->',
'The same requirement is stated in',
'[.github/copilot-instructions.md](../.github/copilot-instructions.md).',
'<!-- template-only:end -->',
'',
].join('\n'),
);
});

afterEach(() => {
Expand Down Expand Up @@ -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']));
Expand Down
16 changes: 11 additions & 5 deletions cli/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
];

/**
Expand Down Expand Up @@ -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)];
}
Expand Down
6 changes: 6 additions & 0 deletions doc/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- template-only:begin -->
GitHub Copilot reads the same guidance from
[.github/copilot-instructions.md](../.github/copilot-instructions.md) — keep the
two agent instruction files in sync.
<!-- template-only:end -->
5 changes: 5 additions & 0 deletions doc/DadJokes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- template-only:begin -->
The same recipe is mirrored for GitHub Copilot in
[.github/copilot-instructions.md](../.github/copilot-instructions.md#adding-a-feature-foo).
<!-- template-only:end -->

## The slice, layer by layer

### Access — `src/access/jokes/`
Expand Down
5 changes: 5 additions & 0 deletions doc/GettingStarted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

<!-- template-only:begin -->
The same requirement is stated for GitHub Copilot in
[.github/copilot-instructions.md](../.github/copilot-instructions.md).
<!-- template-only:end -->
15 changes: 10 additions & 5 deletions doc/ProjectGenerator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down