redlinegate 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/bin/redline.js +247 -42
- package/dist/bin/redline.js.map +1 -1
- package/dist/commands/init.js +274 -26
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/remove.js +40 -0
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/status.js +94 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/config/redline-json.js +18 -1
- package/dist/config/redline-json.js.map +1 -1
- package/dist/core/git.js +50 -3
- package/dist/core/git.js.map +1 -1
- package/dist/core/version.js +6 -0
- package/dist/core/version.js.map +1 -1
- package/dist/detect/setup.js +200 -0
- package/dist/detect/setup.js.map +1 -0
- package/dist/detect/stack.js +56 -17
- package/dist/detect/stack.js.map +1 -1
- package/dist/metrics/options.js +0 -9
- package/dist/metrics/options.js.map +1 -1
- package/dist/platforms/azure/install.js +1 -5
- package/dist/platforms/azure/install.js.map +1 -1
- package/dist/platforms/azure/verify.js +4 -2
- package/dist/platforms/azure/verify.js.map +1 -1
- package/dist/platforms/github/install.js +110 -22
- package/dist/platforms/github/install.js.map +1 -1
- package/dist/platforms/github/vendor.js +81 -0
- package/dist/platforms/github/vendor.js.map +1 -0
- package/dist/platforms/github/verify.js +17 -2
- package/dist/platforms/github/verify.js.map +1 -1
- package/dist/platforms/types.js +4 -0
- package/dist/platforms/types.js.map +1 -1
- package/dist/render/standards.js +8 -1
- package/dist/render/standards.js.map +1 -1
- package/dist/render/vendors.js +9 -52
- package/dist/render/vendors.js.map +1 -1
- package/dist/rules/catalogue.js +127 -0
- package/dist/rules/catalogue.js.map +1 -0
- package/dist/ui/facts.js +30 -1
- package/dist/ui/facts.js.map +1 -1
- package/dist/ui/prompt.js +137 -8
- package/dist/ui/prompt.js.map +1 -1
- package/dist/ui/report.js +214 -0
- package/dist/ui/report.js.map +1 -0
- package/dist/ui/tty.js +358 -25
- package/dist/ui/tty.js.map +1 -1
- package/dist/ui/wizard.js +133 -19
- package/dist/ui/wizard.js.map +1 -1
- package/dist/verify/remote.js +33 -0
- package/dist/verify/remote.js.map +1 -1
- package/package.json +4 -1
- package/scripts/fetch-stars.mjs +92 -0
- package/scripts/lib/rules.d.mts +18 -0
- package/scripts/publish-local.mjs +183 -0
- package/standards/manifest.json +79 -8
- package/standards/stacks/angular.md +57 -0
- package/standards/stacks/dom.md +61 -0
- package/standards/stacks/svelte.md +45 -0
- package/standards/stacks/vue.md +54 -0
- package/templates/redline.yml +4 -0
- package/workflows/redline-gate.yml +28 -4
- package/scripts/measure-context.mjs +0 -101
package/standards/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Single source of truth for Redline. scripts/render.mjs turns this into vendor-specific artifacts. Never hand-edit generated output.",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"core": {
|
|
5
5
|
"title": "Redline Core Engineering Standards",
|
|
6
6
|
"source": "standards/core.md"
|
|
@@ -28,6 +28,60 @@
|
|
|
28
28
|
"apps/**/src/**/*.ts"
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
|
+
"angular": {
|
|
32
|
+
"title": "Angular",
|
|
33
|
+
"source": "standards/stacks/angular.md",
|
|
34
|
+
"globs": [
|
|
35
|
+
"**/*.component.ts",
|
|
36
|
+
"**/*.component.html",
|
|
37
|
+
"**/*.service.ts",
|
|
38
|
+
"**/*.directive.ts",
|
|
39
|
+
"**/*.pipe.ts",
|
|
40
|
+
"**/*.guard.ts",
|
|
41
|
+
"**/*.interceptor.ts",
|
|
42
|
+
"src/app/**",
|
|
43
|
+
"apps/**/src/app/**",
|
|
44
|
+
"packages/**/src/app/**"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"vue": {
|
|
48
|
+
"title": "Vue",
|
|
49
|
+
"source": "standards/stacks/vue.md",
|
|
50
|
+
"globs": [
|
|
51
|
+
"**/*.vue",
|
|
52
|
+
"composables/**",
|
|
53
|
+
"pages/**",
|
|
54
|
+
"server/**/*.ts",
|
|
55
|
+
"src/**/*.ts",
|
|
56
|
+
"app/**/*.ts",
|
|
57
|
+
"apps/**/src/**/*.ts",
|
|
58
|
+
"packages/**/src/**/*.ts"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"svelte": {
|
|
62
|
+
"title": "Svelte / SvelteKit",
|
|
63
|
+
"source": "standards/stacks/svelte.md",
|
|
64
|
+
"globs": [
|
|
65
|
+
"**/*.svelte",
|
|
66
|
+
"**/*.svelte.ts",
|
|
67
|
+
"**/*.svelte.js",
|
|
68
|
+
"src/routes/**",
|
|
69
|
+
"src/lib/**",
|
|
70
|
+
"apps/**/src/routes/**",
|
|
71
|
+
"apps/**/src/lib/**",
|
|
72
|
+
"packages/**/src/lib/**"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"dom": {
|
|
76
|
+
"title": "Browser / DOM",
|
|
77
|
+
"source": "standards/stacks/dom.md",
|
|
78
|
+
"globs": [
|
|
79
|
+
"**/*.js",
|
|
80
|
+
"**/*.mjs",
|
|
81
|
+
"**/*.ts",
|
|
82
|
+
"**/*.html"
|
|
83
|
+
]
|
|
84
|
+
},
|
|
31
85
|
"react-native": {
|
|
32
86
|
"title": "React Native",
|
|
33
87
|
"source": "standards/stacks/react-native.md",
|
|
@@ -126,10 +180,26 @@
|
|
|
126
180
|
"tooling": [
|
|
127
181
|
"javascript"
|
|
128
182
|
],
|
|
129
|
-
"web": [
|
|
183
|
+
"web-react": [
|
|
130
184
|
"javascript",
|
|
131
185
|
"react"
|
|
132
186
|
],
|
|
187
|
+
"web-angular": [
|
|
188
|
+
"javascript",
|
|
189
|
+
"angular"
|
|
190
|
+
],
|
|
191
|
+
"web-vue": [
|
|
192
|
+
"javascript",
|
|
193
|
+
"vue"
|
|
194
|
+
],
|
|
195
|
+
"web-svelte": [
|
|
196
|
+
"javascript",
|
|
197
|
+
"svelte"
|
|
198
|
+
],
|
|
199
|
+
"web-vanilla": [
|
|
200
|
+
"javascript",
|
|
201
|
+
"dom"
|
|
202
|
+
],
|
|
133
203
|
"mobile-rn": [
|
|
134
204
|
"javascript",
|
|
135
205
|
"react",
|
|
@@ -174,6 +244,7 @@
|
|
|
174
244
|
},
|
|
175
245
|
"$aliasComment": "Legacy profile names accepted by sync-targets.txt for backwards compatibility.",
|
|
176
246
|
"profileAliases": {
|
|
247
|
+
"web": "web-react",
|
|
177
248
|
"mobile": "mobile-rn",
|
|
178
249
|
"java": "service-java",
|
|
179
250
|
"go": "service-go",
|
|
@@ -190,18 +261,18 @@
|
|
|
190
261
|
"title": "AGENTS.md (OpenAI Codex, Copilot coding agent, Jules, Cursor agent, Devin)",
|
|
191
262
|
"enabled": true
|
|
192
263
|
},
|
|
264
|
+
"codex": {
|
|
265
|
+
"$comment": "Renders AGENTS.md, the same file the \"agents\" vendor writes — that is the file OpenAI Codex reads, and inventing a second Codex-specific path would render rules nothing looks for. Listed separately because an operator looking for Codex should find it by name; selecting either produces the same artifact, and selecting both writes it once.",
|
|
266
|
+
"title": "OpenAI Codex (AGENTS.md)",
|
|
267
|
+
"enabled": true
|
|
268
|
+
},
|
|
193
269
|
"claude": {
|
|
194
270
|
"title": "Claude Code / Claude in GitHub",
|
|
195
271
|
"enabled": true
|
|
196
272
|
},
|
|
197
273
|
"cursor": {
|
|
198
274
|
"title": "Cursor rules",
|
|
199
|
-
"enabled":
|
|
200
|
-
},
|
|
201
|
-
"skills": {
|
|
202
|
-
"$comment": "Ships off. It renders the same rules as the claude vendor in a different shape, and a repository selecting both loads every stack twice. Select it INSTEAD of claude where per-stack loading is wanted: redline init --vendors skills,copilot,agents.",
|
|
203
|
-
"title": "Claude skills (per-stack, loaded on demand)",
|
|
204
|
-
"enabled": false
|
|
275
|
+
"enabled": true
|
|
205
276
|
}
|
|
206
277
|
},
|
|
207
278
|
"$deterministicComment": "Rules a checker decides without a model. The classification lives HERE and not in the markdown deliberately: standards/*.md is what a reviewer reads, and removing a rule from it because a checker also covers it would narrow what the model considers. Every rule is classified by construction \u2014 listed here means deterministic, absent means judgement. Ids are permanent and are never rewritten by this list. scripts/validate.mjs fails the build if an id here has no implementation in cli/policy/checks.ts, which is the failure mode where a rule is silently enforced by nobody.",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Angular Review Rules
|
|
2
|
+
|
|
3
|
+
**Scope:** Angular 16+ applications and libraries — components, services, guards,
|
|
4
|
+
interceptors and templates. Where a rule exists in both the core standard and here,
|
|
5
|
+
the core rule wins and this file names only the Angular-specific shape of it.
|
|
6
|
+
|
|
7
|
+
Templates are reviewable code. A `.component.html` file is where change detection,
|
|
8
|
+
injection sinks and dead subscriptions actually become visible.
|
|
9
|
+
|
|
10
|
+
## BLOCKER — request changes
|
|
11
|
+
|
|
12
|
+
- `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.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// WRONG
|
|
16
|
+
this.html = this.sanitizer.bypassSecurityTrustHtml(comment.body);
|
|
17
|
+
// RIGHT — leave sanitisation on; bind the raw string
|
|
18
|
+
this.html = comment.body; // [innerHTML] sanitises it
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- `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.
|
|
22
|
+
- `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.
|
|
23
|
+
- `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.
|
|
24
|
+
- `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.
|
|
25
|
+
- `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.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// WRONG
|
|
29
|
+
ngOnInit() { this.socket.messages$.subscribe((m) => this.messages.push(m)); }
|
|
30
|
+
// RIGHT
|
|
31
|
+
private readonly messages$ = this.socket.messages$.pipe(takeUntilDestroyed());
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `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.
|
|
35
|
+
- `angular/timer-not-cleared` — **`setInterval`, `setTimeout` or a manual event listener not torn down in `ngOnDestroy`.** It keeps firing against a destroyed view.
|
|
36
|
+
|
|
37
|
+
## HIGH
|
|
38
|
+
|
|
39
|
+
- `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`.
|
|
40
|
+
- `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.
|
|
41
|
+
- `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.
|
|
42
|
+
- `angular/nested-subscribe` — `subscribe()` inside `subscribe()` — no cancellation, no error propagation, no ordering guarantee. Flatten with `switchMap`/`concatMap`.
|
|
43
|
+
- `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 })`.
|
|
44
|
+
- `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.
|
|
45
|
+
- `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.
|
|
46
|
+
- `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.
|
|
47
|
+
- `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.
|
|
48
|
+
- `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.
|
|
49
|
+
- `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.
|
|
50
|
+
|
|
51
|
+
## SUGGESTION
|
|
52
|
+
|
|
53
|
+
- `angular/prefer-async-pipe` — `async` pipe over a manual subscribe with a component field; subscription teardown comes free.
|
|
54
|
+
- `angular/prefer-signal-over-behaviorsubject` — Signals for synchronous component state in new code; keep RxJS for streams that are genuinely asynchronous.
|
|
55
|
+
- `angular/prefer-standalone-component` — Standalone components for new work rather than a new `NgModule`.
|
|
56
|
+
- `angular/prefer-inject-function` — `inject()` over constructor parameter injection in new code — it composes inside functions and base classes.
|
|
57
|
+
- `angular/oversized-component` — Component beyond ~300 lines, or a template beyond ~150 — suggest a split.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Browser / DOM Review Rules
|
|
2
|
+
|
|
3
|
+
**Scope:** browser code with no component framework — progressive-enhancement scripts,
|
|
4
|
+
widgets, embeds, web components, and anything talking to the DOM directly. A framework
|
|
5
|
+
repository does not install these rules: React, Angular, Vue and Svelte each close most
|
|
6
|
+
of these holes by default, and repeating them there is noise.
|
|
7
|
+
|
|
8
|
+
These are the failures a framework normally hides. Without one, they are the whole
|
|
9
|
+
attack surface.
|
|
10
|
+
|
|
11
|
+
## BLOCKER — request changes
|
|
12
|
+
|
|
13
|
+
- `dom/innerhtml-sink` — **`innerHTML` / `outerHTML` assigned anything not a hard-coded literal.** Concatenated markup around a variable is the sink, even when the variable "comes from our own API". Build nodes, or set `textContent`.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// WRONG
|
|
17
|
+
el.innerHTML = `<a href="${url}">${name}</a>`;
|
|
18
|
+
// RIGHT
|
|
19
|
+
const a = document.createElement('a');
|
|
20
|
+
a.href = url; // still validate the scheme
|
|
21
|
+
a.textContent = name;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `dom/insert-adjacent-html-sink` — **`insertAdjacentHTML`, `document.write`, or `Range.createContextualFragment` on a built string.** Same sink as `innerHTML`, with less scrutiny.
|
|
25
|
+
- `dom/set-attribute-event-handler` — **`setAttribute` of an `on*` handler, or a `src`/`href` whose scheme is not checked.** `javascript:` and `data:text/html` both execute; an allow-list of `https:` (and `mailto:` where meant) is the check.
|
|
26
|
+
- `dom/postmessage-no-origin-check` — **A `message` listener that reads `event.data` without first comparing `event.origin` to an expected origin.** Any page that can get a handle to the window can post to it.
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
// WRONG
|
|
30
|
+
window.addEventListener('message', (e) => apply(e.data));
|
|
31
|
+
// RIGHT
|
|
32
|
+
window.addEventListener('message', (e) => {
|
|
33
|
+
if (e.origin !== TRUSTED_ORIGIN) return;
|
|
34
|
+
apply(e.data);
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `dom/postmessage-wildcard-target` — **`postMessage(data, '*')`.** The payload goes to whatever document currently occupies that frame. Name the target origin.
|
|
39
|
+
- `dom/url-input-into-sink` — **`location.hash`, `location.search` or a `data-*` attribute reaching markup, navigation or `eval` without validation.** It is the most attacker-controllable input a page has.
|
|
40
|
+
- `dom/open-redirect` — **`location.href` / `location.assign` / `window.open` given a user-supplied URL** with no same-origin or allow-list check.
|
|
41
|
+
- `dom/dynamic-script-injection` — **A `<script>` element whose `src` is built from external input**, or `new Function` / `eval` over fetched content.
|
|
42
|
+
|
|
43
|
+
## HIGH
|
|
44
|
+
|
|
45
|
+
- `dom/listener-never-removed` — `addEventListener` on `window`, `document` or a long-lived node with no matching `removeEventListener` (or `{ signal }`) when the widget is torn down. The handler keeps the whole closure alive and fires against detached state.
|
|
46
|
+
- `dom/observer-never-disconnected` — `IntersectionObserver`, `ResizeObserver`, `MutationObserver` or `matchMedia` listener created without a `disconnect()` path.
|
|
47
|
+
- `dom/timer-never-cleared` — `setInterval`/`setTimeout` retained past teardown.
|
|
48
|
+
- `dom/fetch-no-abort` — A `fetch` that can be superseded (search-as-you-type, tab switch, re-render) started without an `AbortController` — the stale response arrives last and wins.
|
|
49
|
+
- `dom/unparsed-json-boundary` — `JSON.parse` of a `data-*` attribute, a storage value or a response body with no `try`/`catch` and no shape check. All three are external input; a throw here takes the whole script down.
|
|
50
|
+
- `dom/layout-thrash` — Reading a layout property (`offsetHeight`, `getBoundingClientRect`) and writing a style in the same loop. Each pair forces a synchronous reflow; batch reads, then writes, inside `requestAnimationFrame`.
|
|
51
|
+
- `dom/document-wide-query-in-loop` — `document.querySelectorAll` (or `getElementById`) re-run per iteration or per event. Hoist the lookup; the DOM is not a cache.
|
|
52
|
+
- `dom/scroll-resize-unthrottled` — `scroll`, `resize`, `mousemove` or `pointermove` handlers doing layout or network work with no throttle and no `{ passive: true }` — this is jank you can measure.
|
|
53
|
+
- `dom/form-submit-not-prevented` — An `submit`/`click` handler doing async work without `preventDefault`, or preventing it without ever re-enabling the control — double submits or a permanently dead button.
|
|
54
|
+
|
|
55
|
+
## SUGGESTION
|
|
56
|
+
|
|
57
|
+
- `dom/prefer-event-delegation` — One delegated listener on a container over one per row for lists that change.
|
|
58
|
+
- `dom/prefer-classlist` — `classList.add`/`toggle` over string surgery on `className`.
|
|
59
|
+
- `dom/prefer-abort-signal-timeout` — `AbortSignal.timeout(ms)` over a manual `setTimeout` plus `controller.abort()`.
|
|
60
|
+
- `dom/prefer-target-blank-noopener` — `rel="noopener"` on `target="_blank"` links; also state it explicitly on `window.open`.
|
|
61
|
+
- `dom/prefer-dataset` — `el.dataset.x` over `getAttribute('data-x')`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Svelte Review Rules
|
|
2
|
+
|
|
3
|
+
**Scope:** Svelte 4 and 5 components, stores, runes, and SvelteKit routes. Rules that
|
|
4
|
+
name a rune (`$state`, `$derived`, `$effect`) apply to Svelte 5 files; the store and
|
|
5
|
+
`$:` rules apply to Svelte 4 files and to Svelte 5 files still using them.
|
|
6
|
+
|
|
7
|
+
SvelteKit's load functions are the sharpest edge here: a file's name decides whether its
|
|
8
|
+
code runs only on the server or also in the browser, and nothing about the code says so.
|
|
9
|
+
|
|
10
|
+
## BLOCKER — request changes
|
|
11
|
+
|
|
12
|
+
- `svelte/html-tag-sink` — **`{@html}` with a value that was not sanitised server-side.** Svelte does no escaping inside it. Markdown output, a CMS field and a translation string are all attacker-reachable often enough to count.
|
|
13
|
+
- `svelte/private-env-in-shared-code` — **`$env/static/private` or `$env/dynamic/private` imported from a component, a universal `+page.js`, or anything under `$lib` that a component imports.** The build fails when it notices; when it does not notice, the secret ships to the browser.
|
|
14
|
+
- `svelte/secret-in-universal-load` — **A secret, a database handle or an internal-only URL used in `+page.js` / `+layout.js`.** Universal load runs on the server *and* again in the browser. Server-only work belongs in `+page.server.js`.
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
// WRONG — +page.js, also runs in the browser
|
|
18
|
+
export const load = async () => db.query('select * from orders');
|
|
19
|
+
// RIGHT — +page.server.js
|
|
20
|
+
export const load = async () => ({ orders: await db.query('select * from orders') });
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `svelte/module-context-shared-state` — **Mutable state in `<script context="module">` (Svelte 5: `<script module>`), or at the top level of a `$lib` module.** It is shared by every component instance, and on the server by every request — one user's data in another user's page.
|
|
24
|
+
- `svelte/endpoint-missing-auth` — **A form action or `+server.js` handler with no authorisation check.** The route being reachable only from a guarded page is not a check; the endpoint is a public URL.
|
|
25
|
+
- `svelte/unvalidated-form-data` — **`await request.formData()` fields consumed without validation.** A form action is an HTTP endpoint: field presence, type and length are all attacker-chosen.
|
|
26
|
+
|
|
27
|
+
## HIGH
|
|
28
|
+
|
|
29
|
+
- `svelte/reactive-statement-side-effect` — A `$:` block performing a fetch, a mutation or navigation. Its dependencies are inferred from what it reads, so an unrelated assignment re-runs it — and a value it writes can re-trigger it.
|
|
30
|
+
- `svelte/store-not-unsubscribed` — `store.subscribe()` called manually without keeping and calling the returned unsubscriber. The `$store` auto-subscription form handles this; hand-rolled subscriptions in `onMount` usually do not.
|
|
31
|
+
- `svelte/effect-for-derived-state` — `$effect` writing a `$state` that is computable from other state. `$derived` cannot go stale, cannot loop, and needs no cleanup.
|
|
32
|
+
- `svelte/effect-missing-cleanup` — `$effect` (or `onMount`) starting a timer, listener, observer or request with no teardown returned — it survives the component.
|
|
33
|
+
- `svelte/each-missing-key` — `{#each}` over a list that reorders or filters, with no `(item.id)` key — Svelte reuses nodes positionally and component state follows position.
|
|
34
|
+
- `svelte/load-waterfall` — Sequential `await`s in `load` for independent data. They serialise the whole page's time-to-first-byte; use `Promise.all`, or return the promises and stream them.
|
|
35
|
+
- `svelte/global-fetch-in-load` — `load` using global `fetch` instead of the `fetch` from its event argument. The event's version forwards cookies, resolves relative URLs on the server and lets the SSR response be reused on hydration — the global one silently does none of that.
|
|
36
|
+
- `svelte/error-swallowed-in-load` — `load` catching a failure and returning empty data instead of `error(status, …)`. The page renders as if there were no orders, rather than as an error.
|
|
37
|
+
- `svelte/state-mutation-across-boundary` — Mutating a `$state` object passed into a child, or a prop object, from that child. Ownership is the parent's; with `$props()` the write is not propagated back.
|
|
38
|
+
- `svelte/unvalidated-url-param` — `params` / `url.searchParams` consumed as a typed value with no validation.
|
|
39
|
+
|
|
40
|
+
## SUGGESTION
|
|
41
|
+
|
|
42
|
+
- `svelte/prefer-runes` — Runes (`$state`, `$derived`, `$props`) over `export let` and `$:` in new Svelte 5 components — the dependency graph stops being positional.
|
|
43
|
+
- `svelte/prefer-snippets-over-slots` — Snippets over slots in new Svelte 5 code; they are typed and can take parameters.
|
|
44
|
+
- `svelte/prefer-derived-by` — `$derived.by` for multi-statement derivations instead of an effect plus a `$state`.
|
|
45
|
+
- `svelte/oversized-component` — Component beyond ~300 lines — suggest extracting a child or a `.svelte.js` module.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Vue Review Rules
|
|
2
|
+
|
|
3
|
+
**Scope:** Vue 3 single-file components, composables, and Nuxt applications. Options-API
|
|
4
|
+
files are reviewed against the same rules — the reactivity model is shared even where the
|
|
5
|
+
syntax is not.
|
|
6
|
+
|
|
7
|
+
Losing reactivity is the failure mode this file exists for: nothing throws, nothing logs,
|
|
8
|
+
the screen simply stops matching the data.
|
|
9
|
+
|
|
10
|
+
## BLOCKER — request changes
|
|
11
|
+
|
|
12
|
+
- `vue/v-html-sink` — **`v-html` bound to anything not sanitised on the server.** Vue does not sanitise it. A comment, a CMS field or a translation string rendered this way is script execution.
|
|
13
|
+
- `vue/dynamic-component-from-input` — **`<component :is>` resolved from a user-supplied string.** It selects which component runs; an attacker choosing that is an injection sink.
|
|
14
|
+
- `vue/ssr-module-scope-state` — **Mutable state declared at module scope in an SSR app.** On the server the module is shared by every request, so one user's data renders in another user's response. State belongs in `setup()`, a store factory, or `useState()` in Nuxt.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// WRONG — one `cart` for the whole server process
|
|
18
|
+
const cart = reactive({ items: [] });
|
|
19
|
+
export const useCart = () => cart;
|
|
20
|
+
// RIGHT
|
|
21
|
+
export const useCart = () => useState('cart', () => ({ items: [] }));
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `vue/public-env-secret` — **A secret read through `import.meta.env.VITE_*` or Nuxt `runtimeConfig.public`.** Both are inlined into the client bundle by design. Server-only values go in `runtimeConfig` without `public`.
|
|
25
|
+
- `vue/reactivity-lost-on-destructure` — **A `reactive()` object or `props` destructured into plain variables.** The binding is a one-time copy: the value freezes at its first read and the view silently stops updating. Use `toRefs()`, or keep the object.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// WRONG
|
|
29
|
+
const { count } = reactive({ count: 0 });
|
|
30
|
+
// RIGHT
|
|
31
|
+
const { count } = toRefs(state);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `vue/prop-mutation` — **A child writing to a prop, or mutating an object it received as one.** The parent owns that value; the write is lost on the next parent render and the two trees disagree until then.
|
|
35
|
+
|
|
36
|
+
## HIGH
|
|
37
|
+
|
|
38
|
+
- `vue/lifecycle-after-await` — `onMounted`/`onUnmounted` registered after an `await` in `setup()`. Registration is synchronous — past the first await there is no active instance, so the hook never runs and its cleanup never runs either.
|
|
39
|
+
- `vue/watcher-missing-cleanup` — A `watch`/`watchEffect` that starts a timer, a listener or a request without `onWatcherCleanup` / `onScopeDispose` — the previous run keeps going and its late response overwrites the current one.
|
|
40
|
+
- `vue/watch-instead-of-computed` — A `watch` whose only job is assigning to another ref. That is derived state: `computed()` recomputes correctly, cannot go stale, and needs no cleanup.
|
|
41
|
+
- `vue/reactive-reassignment` — Reassigning a whole `reactive()` array or object (`state.items = next`) where callers captured the original reference — they keep the old proxy. Mutate in place, or use `ref()` and assign `.value`.
|
|
42
|
+
- `vue/deep-watch-large-object` — `{ deep: true }` over a large structure or an entire store. Every mutation anywhere inside re-runs the handler; watch the specific getter instead.
|
|
43
|
+
- `vue/v-for-index-key` — `:key="index"` on a list that reorders, inserts or deletes — Vue reuses the wrong node and component state follows the index, not the item.
|
|
44
|
+
- `vue/v-if-with-v-for` — `v-if` and `v-for` on the same element. The precedence is not what it reads like and the filter re-runs per item; filter in a `computed`.
|
|
45
|
+
- `vue/async-setup-without-suspense` — `async setup()` in a component not wrapped in `<Suspense>` — it renders nothing, with no error, until the promise settles.
|
|
46
|
+
- `vue/composable-called-conditionally` — A composable called inside a condition, a loop or a callback. Like hooks, they bind to the active instance at call time.
|
|
47
|
+
- `vue/unvalidated-route-param` — `route.params` / `route.query` consumed as a typed value with no validation — external input, whatever the router's types claim.
|
|
48
|
+
|
|
49
|
+
## SUGGESTION
|
|
50
|
+
|
|
51
|
+
- `vue/prefer-script-setup` — `<script setup>` over the options object in new components — less ceremony and better type inference.
|
|
52
|
+
- `vue/prefer-typed-defineprops` — Type-based `defineProps<T>()` over the runtime object form where the project is TypeScript.
|
|
53
|
+
- `vue/prefer-shallowref` — `shallowRef` for large immutable payloads that are replaced rather than edited — deep proxying them costs on every access.
|
|
54
|
+
- `vue/oversized-sfc` — Single-file component beyond ~300 lines — suggest extracting a composable.
|
package/templates/redline.yml
CHANGED
|
@@ -29,3 +29,7 @@ jobs:
|
|
|
29
29
|
# block-blocker and block-high stop a merge. The security floor — dependency
|
|
30
30
|
# review and the secret scan — is not on this ladder and blocks at every rung.
|
|
31
31
|
rung: observe
|
|
32
|
+
# Gate jobs another tool in this repository already covers, written by
|
|
33
|
+
# `redline init` from the tools you declared. A stood-down job is skipped
|
|
34
|
+
# and the aggregate reads a skip as a pass. Empty runs every job.
|
|
35
|
+
stand-down: ''
|
|
@@ -39,6 +39,16 @@ on:
|
|
|
39
39
|
which refuses a promotion the repository's recorded evidence does not support.
|
|
40
40
|
type: string
|
|
41
41
|
default: observe
|
|
42
|
+
stand-down:
|
|
43
|
+
description: >-
|
|
44
|
+
Comma-separated gate jobs another tool in the calling repository already covers:
|
|
45
|
+
`policy`, `dependencies`, `secrets`. A stood-down job is skipped and the
|
|
46
|
+
aggregate below reads a skip as a pass. Written by `redline init` from the tools
|
|
47
|
+
the repository DECLARED, never from detection alone — `dependencies` and
|
|
48
|
+
`secrets` are the two jobs no label can waive, so a wrong guess here is the one
|
|
49
|
+
that removes a security check nobody asked to remove. Empty runs every job.
|
|
50
|
+
type: string
|
|
51
|
+
default: ''
|
|
42
52
|
exemption-enforcement:
|
|
43
53
|
description: >-
|
|
44
54
|
What a soft-fail label without a valid "## Redline exemption" block does.
|
|
@@ -111,6 +121,7 @@ jobs:
|
|
|
111
121
|
fi
|
|
112
122
|
|
|
113
123
|
policy:
|
|
124
|
+
if: ${{ !contains(format(',{0},', inputs.stand-down), ',policy,') }}
|
|
114
125
|
# The deterministic tier: the rules a checker can decide, evaluated with no
|
|
115
126
|
# model call. They cost nothing to run and they cannot hallucinate, which is
|
|
116
127
|
# the point — an author cannot argue with a model about whether the word TODO
|
|
@@ -151,6 +162,7 @@ jobs:
|
|
|
151
162
|
--diff-file /tmp/redline.diff --fail-on "$fail_on"
|
|
152
163
|
|
|
153
164
|
dependency-review:
|
|
165
|
+
if: ${{ !contains(format(',{0},', inputs.stand-down), ',dependencies,') }}
|
|
154
166
|
name: Dependency review
|
|
155
167
|
runs-on: ubuntu-latest
|
|
156
168
|
steps:
|
|
@@ -161,6 +173,7 @@ jobs:
|
|
|
161
173
|
comment-summary-in-pr: on-failure
|
|
162
174
|
|
|
163
175
|
secrets:
|
|
176
|
+
if: ${{ !contains(format(',{0},', inputs.stand-down), ',secrets,') }}
|
|
164
177
|
name: Secret scan (diff)
|
|
165
178
|
runs-on: ubuntu-latest
|
|
166
179
|
steps:
|
|
@@ -215,10 +228,21 @@ jobs:
|
|
|
215
228
|
# Security checks can never be downgraded by a label.
|
|
216
229
|
HARD_JOBS='["dependency-review","secrets"]'
|
|
217
230
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
231
|
+
# `skipped` is a pass. None of these jobs has `needs`, so the only thing
|
|
232
|
+
# that skips one is its own stand-down guard above — a deliberate
|
|
233
|
+
# instruction from the calling repository, not a job that fell over.
|
|
234
|
+
# Without this the aggregate failed every repository that narrowed the
|
|
235
|
+
# gate, which is the opposite of what narrowing it asks for.
|
|
236
|
+
PASSED='["success","skipped"]'
|
|
237
|
+
|
|
238
|
+
# `.value.result as $r` first: inside `$passed | index(...)` the input
|
|
239
|
+
# is $passed, so referring to `.value.result` there indexes the array
|
|
240
|
+
# with a string and jq exits 5 — which `set -e` turns into a gate that
|
|
241
|
+
# fails every pull request.
|
|
242
|
+
hard_failed=$(jq -r --argjson hard "$HARD_JOBS" --argjson passed "$PASSED" \
|
|
243
|
+
'to_entries[] | .value.result as $r | select($passed | index($r) | not) | select(.key as $k | $hard | index($k)) | .key' <<<"$RESULTS")
|
|
244
|
+
soft_failed=$(jq -r --argjson hard "$HARD_JOBS" --argjson passed "$PASSED" \
|
|
245
|
+
'to_entries[] | .value.result as $r | select($passed | index($r) | not) | select(.key as $k | $hard | index($k) | not) | .key' <<<"$RESULTS")
|
|
222
246
|
|
|
223
247
|
labelled=false
|
|
224
248
|
for label in ${SOFT_FAIL_LABELS//,/ }; do
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Measures what the skills render target actually saves, per profile.
|
|
3
|
-
//
|
|
4
|
-
// The roadmap makes this piece conditional on the measurement: it is a cost
|
|
5
|
-
// optimisation, it is rated the highest-exposure item in the plan, and it dies if
|
|
6
|
-
// the reduction is marginal. So the number is produced by a script anyone can
|
|
7
|
-
// re-run rather than asserted once in a changelog.
|
|
8
|
-
//
|
|
9
|
-
// What is compared:
|
|
10
|
-
// composed — AGENTS.md, which a Claude session loads in full every turn for the
|
|
11
|
-
// life of the session, whatever file is being edited
|
|
12
|
-
// per-stack — the core skill plus the ONE stack whose files are in play, which
|
|
13
|
-
// is what a skills-rendered repository loads instead
|
|
14
|
-
//
|
|
15
|
-
// Env: [ROOT=.], [OUT] (write JSON here as well as printing)
|
|
16
|
-
|
|
17
|
-
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { tmpdir } from 'node:os';
|
|
19
|
-
import { join, resolve } from 'node:path';
|
|
20
|
-
import { render } from '../dist/render/standards.js';
|
|
21
|
-
|
|
22
|
-
const { ROOT = process.cwd(), OUT } = process.env;
|
|
23
|
-
|
|
24
|
-
// This measures the standards package, so it needs one. Running it in a product
|
|
25
|
-
// repository is a reasonable mistake — the command surface does not hide that it
|
|
26
|
-
// exists — and an ENOENT stack trace is the worst possible answer to it.
|
|
27
|
-
if (!existsSync(join(ROOT, 'standards/manifest.json'))) {
|
|
28
|
-
console.error(`No standards/manifest.json under ${resolve(ROOT)}.`);
|
|
29
|
-
console.error('This measures the Redline standards themselves, so it runs in the source repo.');
|
|
30
|
-
console.error('Point it somewhere else with --root <path>.');
|
|
31
|
-
process.exit(2);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// A scratch root whose manifest has skills enabled, so the measurement does not
|
|
35
|
-
// depend on whether the org has switched it on yet.
|
|
36
|
-
const root = mkdtempSync(join(tmpdir(), 'redline-measure-'));
|
|
37
|
-
cpSync(join(ROOT, 'standards'), join(root, 'standards'), { recursive: true });
|
|
38
|
-
const manifestPath = join(root, 'standards/manifest.json');
|
|
39
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
40
|
-
manifest.vendors.skills.enabled = true;
|
|
41
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
42
|
-
|
|
43
|
-
const rows = [];
|
|
44
|
-
for (const profile of Object.keys(manifest.profiles)) {
|
|
45
|
-
const skillsOut = mkdtempSync(join(tmpdir(), 'redline-skills-'));
|
|
46
|
-
render({ root, profile, out: skillsOut, vendors: ['skills'] });
|
|
47
|
-
const dir = join(skillsOut, '.claude/skills');
|
|
48
|
-
const size = (name) => readFileSync(join(dir, name, 'SKILL.md'), 'utf8').length;
|
|
49
|
-
const names = readdirSync(dir);
|
|
50
|
-
const core = size('redline-core');
|
|
51
|
-
const stacks = names.filter((n) => n !== 'redline-core');
|
|
52
|
-
// The mean stack, not the smallest: quoting the best case would be marketing.
|
|
53
|
-
const perStack =
|
|
54
|
-
core + (stacks.length ? Math.round(stacks.reduce((a, n) => a + size(n), 0) / stacks.length) : 0);
|
|
55
|
-
|
|
56
|
-
const composedOut = mkdtempSync(join(tmpdir(), 'redline-composed-'));
|
|
57
|
-
render({ root, profile, out: composedOut, vendors: ['claude', 'agents'] });
|
|
58
|
-
const composed = readFileSync(join(composedOut, 'AGENTS.md'), 'utf8').length;
|
|
59
|
-
|
|
60
|
-
rows.push({
|
|
61
|
-
profile,
|
|
62
|
-
stacks: stacks.length,
|
|
63
|
-
composedBytes: composed,
|
|
64
|
-
perStackBytes: perStack,
|
|
65
|
-
reduction: 1 - perStack / composed,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
rmSync(skillsOut, { recursive: true, force: true });
|
|
69
|
-
rmSync(composedOut, { recursive: true, force: true });
|
|
70
|
-
}
|
|
71
|
-
rmSync(root, { recursive: true, force: true });
|
|
72
|
-
|
|
73
|
-
rows.sort((a, b) => b.reduction - a.reduction);
|
|
74
|
-
|
|
75
|
-
const pct = (n) => `${(n * 100).toFixed(1)}%`;
|
|
76
|
-
console.log('profile stacks composed per-stack reduction');
|
|
77
|
-
for (const row of rows) {
|
|
78
|
-
console.log(
|
|
79
|
-
`${row.profile.padEnd(17)}${String(row.stacks).padStart(6)} ${String(row.composedBytes).padStart(8)} ${String(row.perStackBytes).padStart(9)} ${pct(row.reduction).padStart(9)}`
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// The finding, stated rather than left for a reader to notice: a single-stack
|
|
84
|
-
// profile pays the skill frontmatter and gains nothing, because there is no
|
|
85
|
-
// second stack to avoid loading. Selecting skills there makes the repository
|
|
86
|
-
// worse, and no amount of rollout enthusiasm changes that.
|
|
87
|
-
const helped = rows.filter((r) => r.reduction > 0);
|
|
88
|
-
const hurt = rows.filter((r) => r.reduction <= 0);
|
|
89
|
-
console.log('');
|
|
90
|
-
console.log(`Helps ${helped.length} profile(s): ${pct(Math.min(...helped.map((r) => r.reduction)))} to ${pct(Math.max(...helped.map((r) => r.reduction)))}.`);
|
|
91
|
-
if (hurt.length > 0) {
|
|
92
|
-
console.log(
|
|
93
|
-
`Costs ${hurt.length} profile(s) — all single-stack (${hurt.map((r) => r.profile).join(', ')}): ` +
|
|
94
|
-
'there is no second stack to avoid loading, so the frontmatter is pure overhead. Do not select skills there.'
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (OUT) {
|
|
99
|
-
writeFileSync(OUT, `${JSON.stringify({ generatedAt: new Date().toISOString(), rows }, null, 2)}\n`);
|
|
100
|
-
console.log(`\nWritten to ${OUT}.`);
|
|
101
|
-
}
|