Angular
Angular applications — subscription teardown, change detection, injection sinks, guards.
What this is
Angular applications — subscription teardown, change detection, injection sinks, guards.
How to onboard it
A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:
$ npx redlinegate init # detects the profile $ npx redlinegate init --profile web-angular # or name one
One profile pulls these rules in: web-angular.
Once onboarded, your files match this stack when they fit any of these globs:
**/*.component.ts**/*.component.html**/*.service.ts**/*.directive.ts**/*.pipe.ts**/*.guard.ts**/*.interceptor.tssrc/app/**apps/**/src/app/**packages/**/src/app/**
How to use it — 24 rules
Nothing to run. Once your profile includes Angular, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.
| Rule id | Severity | Catches |
|---|---|---|
angular/bypass-security-trust | BLOCKER | `DomSanitizer.bypassSecurityTrust*` on a value derived from external input. The call exists to switch Angular's sanitiser off; passing a URL, a query param or an API field through it is an injection sink with the guard removed. |
angular/open-redirect-navigation | BLOCKER | `router.navigateByUrl`, `location.href` or `[routerLink]` fed a user-controlled URL. An `?next=` param that reaches navigation without an allow-list is an open redirect, and on `location.href` a `javascript:` URL is script execution. |
angular/dynamic-template-compilation | BLOCKER | A component, template or `NgModule` compiled from a string that came from outside. There is no sanitiser on this path — the string is code. |
angular/guard-as-only-auth | BLOCKER | A route guard treated as the authorisation check. Guards decide what gets rendered, never what gets served: every endpoint behind the route re-checks, or the data is one `curl` away. |
angular/secret-in-environment-file | BLOCKER | A secret in `environment.ts` / `environment.prod.ts`. These files are compiled into the browser bundle. Anything in them is public, whatever the filename suggests. |
angular/unsubscribed-subscription | BLOCKER | `.subscribe()` with no teardown. Use the `async` pipe, `takeUntilDestroyed()`, or unsubscribe in `ngOnDestroy` — a long-lived stream holds the component, its template and everything they close over. |
angular/uncancelled-request-race | BLOCKER | A request per keystroke or per navigation that is not cancelled when it is superseded. With `mergeMap` the responses land out of order and the slower, older one wins; with `concatMap` they stay ordered but queue, so the view shows an answer to a query the user has already moved on from. `switchMap` cancels the superseded request. |
angular/timer-not-cleared | BLOCKER | `setInterval`, `setTimeout` or a manual event listener not torn down in `ngOnDestroy`. It keeps firing against a destroyed view. |
angular/function-call-in-template | HIGH | Method or getter invoked from a template binding (`{{ total() }}`, `*ngIf="isReady()"`) — it re-runs on every change-detection cycle, including ones triggered by unrelated events. Precompute, or use a signal or `computed`. |
angular/default-change-detection-hot-component | HIGH | A list, table or frequently-updated component left on default change detection. Require `ChangeDetectionStrategy.OnPush` where the inputs are immutable. |
angular/manual-change-detection | HIGH | `detectChanges()` / `markForCheck()` called to make the view update. It is a symptom: the state was mutated in place, or the work escaped the zone. Fix the source. |
angular/nested-subscribe | HIGH | `subscribe()` inside `subscribe()` — no cancellation, no error propagation, no ordering guarantee. Flatten with `switchMap`/`concatMap`. |
angular/duplicate-http-subscription | HIGH | The same cold `HttpClient` observable subscribed twice (two `async` pipes, or a `subscribe` plus a pipe) — that is two identical requests. `shareReplay({ bufferSize: 1, refCount: true })`. |
angular/input-object-mutation | HIGH | A child mutating an object it received as `@Input()`. The parent owns it; with `OnPush` the parent never learns it changed. |
angular/service-scope-mismatch | HIGH | `providedIn: 'root'` for state that must be per-component, or component `providers` for state that must be shared — one gives every consumer the same instance, the other silently gives each a fresh one. |
angular/effect-writes-own-signal | HIGH | An `effect()` writing a signal it also reads, or reaching for `allowSignalWrites` to make that legal. That is a cycle; `computed()` is what derived state is for. |
angular/missing-trackby | HIGH | `*ngFor` / `@for` over a list that reorders or re-fetches without `trackBy` (or `track`) — Angular destroys and rebuilds every row, losing focus and element state. |
angular/interceptor-swallows-error | HIGH | An `HttpInterceptor` or `catchError` returning `of(null)` / `EMPTY` so the caller sees a successful empty response. The failure is now indistinguishable from no data. |
angular/unvalidated-route-param | HIGH | A route param or query param consumed as a typed value (`+id`, `as Status`) with no validation — it is external input and a user types what they like. |
angular/prefer-async-pipe | SUGGESTION | `async` pipe over a manual subscribe with a component field; subscription teardown comes free. |
angular/prefer-signal-over-behaviorsubject | SUGGESTION | Signals for synchronous component state in new code; keep RxJS for streams that are genuinely asynchronous. |
angular/prefer-standalone-component | SUGGESTION | Standalone components for new work rather than a new `NgModule`. |
angular/prefer-inject-function | SUGGESTION | `inject()` over constructor parameter injection in new code — it composes inside functions and base classes. |
angular/oversized-component | SUGGESTION | Component beyond ~300 lines, or a template beyond ~150 — suggest a split. |
Expected output
A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:
Redline/BLOCKER [angular/bypass-security-trust]: <one-line problem>Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 24 rules here, 8 BLOCKER, 11 HIGH and 5 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.
If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.
How to edit it
Rules are edited in standards/stacks/angular.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.
- Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
- node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
- node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
- Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
- Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
- node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.
Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.
The full file
# Angular Review Rules
**Scope:** Angular 16+ applications and libraries — components, services, guards,
interceptors and templates. Where a rule exists in both the core standard and here,
the core rule wins and this file names only the Angular-specific shape of it.
Templates are reviewable code. A `.component.html` file is where change detection,
injection sinks and dead subscriptions actually become visible.
## BLOCKER — request changes
- `angular/bypass-security-trust` — **`DomSanitizer.bypassSecurityTrust*` on a value derived from external input.** The call exists to switch Angular's sanitiser off; passing a URL, a query param or an API field through it is an injection sink with the guard removed.
```ts
// WRONG
this.html = this.sanitizer.bypassSecurityTrustHtml(comment.body);
// RIGHT — leave sanitisation on; bind the raw string
this.html = comment.body; // [innerHTML] sanitises it
```
- `angular/open-redirect-navigation` — **`router.navigateByUrl`, `location.href` or `[routerLink]` fed a user-controlled URL.** An `?next=` param that reaches navigation without an allow-list is an open redirect, and on `location.href` a `javascript:` URL is script execution.
- `angular/dynamic-template-compilation` — **A component, template or `NgModule` compiled from a string that came from outside.** There is no sanitiser on this path — the string is code.
- `angular/guard-as-only-auth` — **A route guard treated as the authorisation check.** Guards decide what gets rendered, never what gets served: every endpoint behind the route re-checks, or the data is one `curl` away.
- `angular/secret-in-environment-file` — **A secret in `environment.ts` / `environment.prod.ts`.** These files are compiled into the browser bundle. Anything in them is public, whatever the filename suggests.
- `angular/unsubscribed-subscription` — **`.subscribe()` with no teardown.** Use the `async` pipe, `takeUntilDestroyed()`, or unsubscribe in `ngOnDestroy` — a long-lived stream holds the component, its template and everything they close over.
```ts
// WRONG
ngOnInit() { this.socket.messages$.subscribe((m) => this.messages.push(m)); }
// RIGHT
private readonly messages$ = this.socket.messages$.pipe(takeUntilDestroyed());
```
- `angular/uncancelled-request-race` — **A request per keystroke or per navigation that is not cancelled when it is superseded.** With `mergeMap` the responses land out of order and the slower, older one wins; with `concatMap` they stay ordered but queue, so the view shows an answer to a query the user has already moved on from. `switchMap` cancels the superseded request.
- `angular/timer-not-cleared` — **`setInterval`, `setTimeout` or a manual event listener not torn down in `ngOnDestroy`.** It keeps firing against a destroyed view.
## HIGH
- `angular/function-call-in-template` — Method or getter invoked from a template binding (`{{ total() }}`, `*ngIf="isReady()"`) — it re-runs on every change-detection cycle, including ones triggered by unrelated events. Precompute, or use a signal or `computed`.
- `angular/default-change-detection-hot-component` — A list, table or frequently-updated component left on default change detection. Require `ChangeDetectionStrategy.OnPush` where the inputs are immutable.
- `angular/manual-change-detection` — `detectChanges()` / `markForCheck()` called to make the view update. It is a symptom: the state was mutated in place, or the work escaped the zone. Fix the source.
- `angular/nested-subscribe` — `subscribe()` inside `subscribe()` — no cancellation, no error propagation, no ordering guarantee. Flatten with `switchMap`/`concatMap`.
- `angular/duplicate-http-subscription` — The same cold `HttpClient` observable subscribed twice (two `async` pipes, or a `subscribe` plus a pipe) — that is two identical requests. `shareReplay({ bufferSize: 1, refCount: true })`.
- `angular/input-object-mutation` — A child mutating an object it received as `@Input()`. The parent owns it; with `OnPush` the parent never learns it changed.
- `angular/service-scope-mismatch` — `providedIn: 'root'` for state that must be per-component, or component `providers` for state that must be shared — one gives every consumer the same instance, the other silently gives each a fresh one.
- `angular/effect-writes-own-signal` — An `effect()` writing a signal it also reads, or reaching for `allowSignalWrites` to make that legal. That is a cycle; `computed()` is what derived state is for.
- `angular/missing-trackby` — `*ngFor` / `@for` over a list that reorders or re-fetches without `trackBy` (or `track`) — Angular destroys and rebuilds every row, losing focus and element state.
- `angular/interceptor-swallows-error` — An `HttpInterceptor` or `catchError` returning `of(null)` / `EMPTY` so the caller sees a successful empty response. The failure is now indistinguishable from no data.
- `angular/unvalidated-route-param` — A route param or query param consumed as a typed value (`+id`, `as Status`) with no validation — it is external input and a user types what they like.
## SUGGESTION
- `angular/prefer-async-pipe` — `async` pipe over a manual subscribe with a component field; subscription teardown comes free.
- `angular/prefer-signal-over-behaviorsubject` — Signals for synchronous component state in new code; keep RxJS for streams that are genuinely asynchronous.
- `angular/prefer-standalone-component` — Standalone components for new work rather than a new `NgModule`.
- `angular/prefer-inject-function` — `inject()` over constructor parameter injection in new code — it composes inside functions and base classes.
- `angular/oversized-component` — Component beyond ~300 lines, or a template beyond ~150 — suggest a split.