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:

terminal
$ 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.ts
  • src/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 idSeverityCatches
angular/bypass-security-trustBLOCKER`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-navigationBLOCKER`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-compilationBLOCKERA 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-authBLOCKERA 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-fileBLOCKERA 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-subscriptionBLOCKER`.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-raceBLOCKERA 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-clearedBLOCKER`setInterval`, `setTimeout` or a manual event listener not torn down in `ngOnDestroy`. It keeps firing against a destroyed view.
angular/function-call-in-templateHIGHMethod 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-componentHIGHA list, table or frequently-updated component left on default change detection. Require `ChangeDetectionStrategy.OnPush` where the inputs are immutable.
angular/manual-change-detectionHIGH`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-subscribeHIGH`subscribe()` inside `subscribe()` — no cancellation, no error propagation, no ordering guarantee. Flatten with `switchMap`/`concatMap`.
angular/duplicate-http-subscriptionHIGHThe 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-mutationHIGHA child mutating an object it received as `@Input()`. The parent owns it; with `OnPush` the parent never learns it changed.
angular/service-scope-mismatchHIGH`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-signalHIGHAn `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-trackbyHIGH`*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-errorHIGHAn `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-paramHIGHA 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-pipeSUGGESTION`async` pipe over a manual subscribe with a component field; subscription teardown comes free.
angular/prefer-signal-over-behaviorsubjectSUGGESTIONSignals for synchronous component state in new code; keep RxJS for streams that are genuinely asynchronous.
angular/prefer-standalone-componentSUGGESTIONStandalone components for new work rather than a new `NgModule`.
angular/prefer-inject-functionSUGGESTION`inject()` over constructor parameter injection in new code — it composes inside functions and base classes.
angular/oversized-componentSUGGESTIONComponent 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:

a finding from this file
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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

standards/stacks/angular.md · 58 lines · 5.7 KB
# 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.