@everystack/cli 0.3.3 → 0.3.5
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/package.json +15 -10
- package/src/cli/audit-api.ts +97 -0
- package/src/cli/bundle-audit.ts +225 -0
- package/src/cli/bundle-weight.ts +356 -0
- package/src/cli/bundle.ts +146 -0
- package/src/cli/commands/audit.ts +134 -0
- package/src/cli/commands/bundle.ts +461 -0
- package/src/cli/commands/lighthouse.ts +104 -0
- package/src/cli/commands/security-probe.ts +213 -0
- package/src/cli/commands/security.ts +12 -4
- package/src/cli/commands/ui-audit.ts +76 -0
- package/src/cli/component-audit.ts +487 -0
- package/src/cli/component-model.ts +304 -0
- package/src/cli/index.ts +35 -0
- package/src/cli/lighthouse.ts +155 -0
- package/src/cli/security-probe.ts +243 -0
- package/src/cli/ui-bloat.ts +198 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component model — a faithful, neutral representation of `.tsx` source (Brick 5a-B).
|
|
3
|
+
*
|
|
4
|
+
* This is the shared foundation, NOT a private detail of the auditor. The UI-bloat
|
|
5
|
+
* checks consume it; so will the 5b frontend Module contract (checking screens +
|
|
6
|
+
* components against a contract). Its reusability comes from *fidelity*: it
|
|
7
|
+
* represents what a component actually IS — name, location, JSX tree, hooks, props,
|
|
8
|
+
* imports, inline style literals — not an audit-shaped projection of it. It knows
|
|
9
|
+
* nothing about `AuditFinding`, checks, budgets, or fixes. The dependency points one
|
|
10
|
+
* way: the auditor imports this model; this model imports nothing from the auditor.
|
|
11
|
+
*
|
|
12
|
+
* Syntactic only (no type checker): everything here is read off the AST, so it needs
|
|
13
|
+
* a single `createSourceFile` per file — no `tsconfig` resolution, no `Program`, fast
|
|
14
|
+
* and pure. `parseComponents(file, text)` is the pure core (no filesystem);
|
|
15
|
+
* `discoverComponents(dir)` is the thin filesystem walk over it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import ts from 'typescript';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { globSync } from 'glob';
|
|
21
|
+
|
|
22
|
+
/** A file-level import: `import def, { a, b }, * as ns from 'mod'`. */
|
|
23
|
+
export interface ImportRef {
|
|
24
|
+
from: string;
|
|
25
|
+
names: string[];
|
|
26
|
+
default?: string;
|
|
27
|
+
namespace?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A structural JSX node — tag identity, the element's own attributes and inline
|
|
31
|
+
* style, and its nested element children (text and non-JSX expressions are not
|
|
32
|
+
* structure). Map/conditional-wrapped JSX is descended into, so genuine reuse (one
|
|
33
|
+
* `.map`) stays one node. */
|
|
34
|
+
export interface JsxNode {
|
|
35
|
+
tag: string;
|
|
36
|
+
/** 1-based line of this element. */
|
|
37
|
+
line: number;
|
|
38
|
+
/** Attribute names present on this element (e.g. ['style', 'onPress']). */
|
|
39
|
+
attrs: string[];
|
|
40
|
+
/** Raw inline `style={{…}}` object literal on this element, if any. */
|
|
41
|
+
style?: string;
|
|
42
|
+
children: JsxNode[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An inline `style={{…}}` object literal and the line it sits on. */
|
|
46
|
+
export interface StyleLiteral {
|
|
47
|
+
text: string;
|
|
48
|
+
line: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A faithful model of one component. Source facts only — no audit concepts. */
|
|
52
|
+
export interface ComponentDescriptor {
|
|
53
|
+
name: string;
|
|
54
|
+
file: string;
|
|
55
|
+
/** 1-based line of the component declaration. */
|
|
56
|
+
line: number;
|
|
57
|
+
/** Line span of the component (for the size metric). */
|
|
58
|
+
lines: number;
|
|
59
|
+
jsx: JsxNode[];
|
|
60
|
+
hooks: string[];
|
|
61
|
+
props: string[];
|
|
62
|
+
imports: ImportRef[];
|
|
63
|
+
styles: StyleLiteral[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isJsx = (
|
|
67
|
+
n: ts.Node,
|
|
68
|
+
): n is ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment =>
|
|
69
|
+
ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n);
|
|
70
|
+
|
|
71
|
+
/** True if any JSX appears anywhere in the subtree — the test for "is a component". */
|
|
72
|
+
function containsJsx(node: ts.Node): boolean {
|
|
73
|
+
let found = false;
|
|
74
|
+
const walk = (n: ts.Node): void => {
|
|
75
|
+
if (found) return;
|
|
76
|
+
if (isJsx(n)) {
|
|
77
|
+
found = true;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ts.forEachChild(n, walk);
|
|
81
|
+
};
|
|
82
|
+
ts.forEachChild(node, walk);
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectImports(sf: ts.SourceFile): ImportRef[] {
|
|
87
|
+
const out: ImportRef[] = [];
|
|
88
|
+
for (const stmt of sf.statements) {
|
|
89
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
90
|
+
const ref: ImportRef = { from: stmt.moduleSpecifier.text, names: [] };
|
|
91
|
+
const clause = stmt.importClause;
|
|
92
|
+
if (clause?.name) ref.default = clause.name.text;
|
|
93
|
+
const bindings = clause?.namedBindings;
|
|
94
|
+
if (bindings) {
|
|
95
|
+
if (ts.isNamespaceImport(bindings)) ref.namespace = bindings.name.text;
|
|
96
|
+
else for (const el of bindings.elements) ref.names.push(el.name.text);
|
|
97
|
+
}
|
|
98
|
+
out.push(ref);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tagName(n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment): string {
|
|
104
|
+
if (ts.isJsxFragment(n)) return 'Fragment';
|
|
105
|
+
const tag = ts.isJsxElement(n) ? n.openingElement.tagName : n.tagName;
|
|
106
|
+
return tag.getText();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read an element's attribute names and its inline `style={{…}}` literal. */
|
|
110
|
+
function readAttrs(
|
|
111
|
+
n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment,
|
|
112
|
+
sf: ts.SourceFile,
|
|
113
|
+
): { attrs: string[]; style?: string } {
|
|
114
|
+
if (ts.isJsxFragment(n)) return { attrs: [] };
|
|
115
|
+
const props = (ts.isJsxElement(n) ? n.openingElement : n).attributes.properties;
|
|
116
|
+
const attrs: string[] = [];
|
|
117
|
+
let style: string | undefined;
|
|
118
|
+
for (const p of props) {
|
|
119
|
+
if (!ts.isJsxAttribute(p) || !ts.isIdentifier(p.name)) continue;
|
|
120
|
+
attrs.push(p.name.text);
|
|
121
|
+
if (
|
|
122
|
+
p.name.text === 'style' &&
|
|
123
|
+
p.initializer &&
|
|
124
|
+
ts.isJsxExpression(p.initializer) &&
|
|
125
|
+
p.initializer.expression &&
|
|
126
|
+
ts.isObjectLiteralExpression(p.initializer.expression)
|
|
127
|
+
) {
|
|
128
|
+
style = p.initializer.expression.getText(sf);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { attrs, style };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build a faithful node for one JSX element, with its attrs, style, line, children. */
|
|
135
|
+
function makeNode(
|
|
136
|
+
n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment,
|
|
137
|
+
sf: ts.SourceFile,
|
|
138
|
+
): JsxNode {
|
|
139
|
+
const { attrs, style } = readAttrs(n, sf);
|
|
140
|
+
const node: JsxNode = {
|
|
141
|
+
tag: tagName(n),
|
|
142
|
+
line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1,
|
|
143
|
+
attrs,
|
|
144
|
+
children: jsxChildren(n, sf),
|
|
145
|
+
};
|
|
146
|
+
if (style !== undefined) node.style = style;
|
|
147
|
+
return node;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The element children of a JSX node, descending through expression containers
|
|
151
|
+
* ({cond && <X/>}, {items.map(=> <Y/>)}) so structure is captured but text is not. */
|
|
152
|
+
function jsxChildren(
|
|
153
|
+
n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment,
|
|
154
|
+
sf: ts.SourceFile,
|
|
155
|
+
): JsxNode[] {
|
|
156
|
+
if (ts.isJsxSelfClosingElement(n)) return [];
|
|
157
|
+
const kids: JsxNode[] = [];
|
|
158
|
+
const fromChild = (child: ts.Node): void => {
|
|
159
|
+
if (isJsx(child)) {
|
|
160
|
+
kids.push(makeNode(child, sf));
|
|
161
|
+
return; // don't descend past a JSX element — its own children handle that
|
|
162
|
+
}
|
|
163
|
+
// descend through JsxExpression / parens / map callbacks to find nested JSX
|
|
164
|
+
if (ts.isJsxText(child)) return;
|
|
165
|
+
ts.forEachChild(child, fromChild);
|
|
166
|
+
};
|
|
167
|
+
for (const child of n.children) fromChild(child);
|
|
168
|
+
return kids;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The outermost JSX nodes within a component body — its roots. */
|
|
172
|
+
function collectRoots(fn: ts.Node, sf: ts.SourceFile): JsxNode[] {
|
|
173
|
+
const roots: JsxNode[] = [];
|
|
174
|
+
const walk = (n: ts.Node): void => {
|
|
175
|
+
if (isJsx(n)) {
|
|
176
|
+
roots.push(makeNode(n, sf));
|
|
177
|
+
return; // stop — nested JSX is captured as children, not as another root
|
|
178
|
+
}
|
|
179
|
+
ts.forEachChild(n, walk);
|
|
180
|
+
};
|
|
181
|
+
ts.forEachChild(fn, walk);
|
|
182
|
+
return roots;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function collectHooks(fn: ts.Node): string[] {
|
|
186
|
+
const hooks: string[] = [];
|
|
187
|
+
const walk = (n: ts.Node): void => {
|
|
188
|
+
if (ts.isCallExpression(n)) {
|
|
189
|
+
const expr = n.expression;
|
|
190
|
+
const name = ts.isIdentifier(expr)
|
|
191
|
+
? expr.text
|
|
192
|
+
: ts.isPropertyAccessExpression(expr)
|
|
193
|
+
? expr.name.text
|
|
194
|
+
: '';
|
|
195
|
+
if (/^use[A-Z]/.test(name)) hooks.push(name);
|
|
196
|
+
}
|
|
197
|
+
ts.forEachChild(n, walk);
|
|
198
|
+
};
|
|
199
|
+
ts.forEachChild(fn, walk);
|
|
200
|
+
return hooks;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectProps(fn: ts.FunctionLikeDeclarationBase): string[] {
|
|
204
|
+
const param = fn.parameters[0];
|
|
205
|
+
if (!param) return [];
|
|
206
|
+
if (ts.isObjectBindingPattern(param.name)) {
|
|
207
|
+
return param.name.elements
|
|
208
|
+
.map((el) => (ts.isIdentifier(el.name) ? el.name.text : ''))
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
if (ts.isIdentifier(param.name)) return [param.name.text];
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function collectStyles(fn: ts.Node, sf: ts.SourceFile): StyleLiteral[] {
|
|
216
|
+
const styles: StyleLiteral[] = [];
|
|
217
|
+
const walk = (n: ts.Node): void => {
|
|
218
|
+
if (
|
|
219
|
+
ts.isJsxAttribute(n) &&
|
|
220
|
+
ts.isIdentifier(n.name) &&
|
|
221
|
+
n.name.text === 'style' &&
|
|
222
|
+
n.initializer &&
|
|
223
|
+
ts.isJsxExpression(n.initializer) &&
|
|
224
|
+
n.initializer.expression &&
|
|
225
|
+
ts.isObjectLiteralExpression(n.initializer.expression)
|
|
226
|
+
) {
|
|
227
|
+
const obj = n.initializer.expression;
|
|
228
|
+
styles.push({
|
|
229
|
+
text: obj.getText(sf),
|
|
230
|
+
line: sf.getLineAndCharacterOfPosition(obj.getStart(sf)).line + 1,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
ts.forEachChild(n, walk);
|
|
234
|
+
};
|
|
235
|
+
ts.forEachChild(fn, walk);
|
|
236
|
+
return styles;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Pure core: parse one `.tsx` file's source text into faithful component
|
|
241
|
+
* descriptors. No filesystem, no type checker.
|
|
242
|
+
*/
|
|
243
|
+
export function parseComponents(file: string, text: string): ComponentDescriptor[] {
|
|
244
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
245
|
+
const imports = collectImports(sf);
|
|
246
|
+
const out: ComponentDescriptor[] = [];
|
|
247
|
+
const lineOf = (pos: number) => sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
248
|
+
|
|
249
|
+
const consider = (name: string, declNode: ts.Node, fn: ts.FunctionLikeDeclarationBase): void => {
|
|
250
|
+
if (!containsJsx(fn)) return;
|
|
251
|
+
const start = declNode.getStart(sf);
|
|
252
|
+
out.push({
|
|
253
|
+
name,
|
|
254
|
+
file,
|
|
255
|
+
line: lineOf(start),
|
|
256
|
+
lines: lineOf(declNode.getEnd()) - lineOf(start) + 1,
|
|
257
|
+
jsx: collectRoots(fn, sf),
|
|
258
|
+
hooks: collectHooks(fn),
|
|
259
|
+
props: collectProps(fn),
|
|
260
|
+
imports,
|
|
261
|
+
styles: collectStyles(fn, sf),
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
for (const node of sf.statements) {
|
|
266
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
267
|
+
const name =
|
|
268
|
+
node.name?.text ??
|
|
269
|
+
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ? 'default' : '');
|
|
270
|
+
if (name) consider(name, node, node);
|
|
271
|
+
} else if (ts.isVariableStatement(node)) {
|
|
272
|
+
for (const d of node.declarationList.declarations) {
|
|
273
|
+
if (
|
|
274
|
+
ts.isIdentifier(d.name) &&
|
|
275
|
+
d.initializer &&
|
|
276
|
+
(ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
|
|
277
|
+
) {
|
|
278
|
+
consider(d.name.text, node, d.initializer);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else if (ts.isExportAssignment(node)) {
|
|
282
|
+
const e = node.expression;
|
|
283
|
+
if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) consider('default', node, e);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Walk a directory for `.tsx` files (excluding node_modules) and parse each into
|
|
291
|
+
* component descriptors. The thin filesystem shell over `parseComponents`.
|
|
292
|
+
*/
|
|
293
|
+
export function discoverComponents(dir: string): ComponentDescriptor[] {
|
|
294
|
+
const files = globSync('**/*.tsx', {
|
|
295
|
+
cwd: dir,
|
|
296
|
+
absolute: true,
|
|
297
|
+
ignore: ['**/node_modules/**', '**/.expo/**', '**/dist/**'],
|
|
298
|
+
});
|
|
299
|
+
const out: ComponentDescriptor[] = [];
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
out.push(...parseComponents(file, readFileSync(file, 'utf8')));
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -21,6 +21,11 @@ import { analyzeSSRCommand } from './commands/analyze.js';
|
|
|
21
21
|
import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
|
|
22
22
|
import { secretsCommand } from './commands/secrets.js';
|
|
23
23
|
import { securityAuditCommand } from './commands/security.js';
|
|
24
|
+
import { securityProbeCommand } from './commands/security-probe.js';
|
|
25
|
+
import { bundleAnalyzeCommand, bundleAuditCommand } from './commands/bundle.js';
|
|
26
|
+
import { auditLighthouseCommand } from './commands/lighthouse.js';
|
|
27
|
+
import { auditCommand } from './commands/audit.js';
|
|
28
|
+
import { uiAuditCommand } from './commands/ui-audit.js';
|
|
24
29
|
import { fail } from './output.js';
|
|
25
30
|
|
|
26
31
|
const args = process.argv.slice(2);
|
|
@@ -206,6 +211,30 @@ async function main() {
|
|
|
206
211
|
case 'security:audit':
|
|
207
212
|
await securityAuditCommand(flags);
|
|
208
213
|
break;
|
|
214
|
+
case 'security:probe':
|
|
215
|
+
await securityProbeCommand(flags);
|
|
216
|
+
break;
|
|
217
|
+
case 'bundle:analyze': {
|
|
218
|
+
const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
219
|
+
await bundleAnalyzeCommand(positional, flags);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'audit':
|
|
223
|
+
await auditCommand(flags);
|
|
224
|
+
break;
|
|
225
|
+
case 'audit:lighthouse':
|
|
226
|
+
await auditLighthouseCommand(flags);
|
|
227
|
+
break;
|
|
228
|
+
case 'bundle:audit': {
|
|
229
|
+
const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
230
|
+
await bundleAuditCommand(positional, flags);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 'ui:audit': {
|
|
234
|
+
const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
235
|
+
await uiAuditCommand(positional, flags);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
209
238
|
case 'secrets': {
|
|
210
239
|
// secrets <subcommand> [positional...] [--flags]
|
|
211
240
|
// Extract positional args: everything after subcommand that isn't a flag or flag value
|
|
@@ -262,6 +291,12 @@ Usage:
|
|
|
262
291
|
everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
|
|
263
292
|
everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
|
|
264
293
|
everystack security:audit --stage <name> [--config <file>] Live catalog scan of a deployed stage (post-deploy)
|
|
294
|
+
everystack security:probe --stage <name> [--host <url>] Live black-box probe: introspects RLS/grants, then tests the deployed surface (RLS coverage, column exposure, injection, limit cap, embed depth)
|
|
295
|
+
everystack audit --stage <name> [--host <url>] [--dir <export>] [--lighthouse] Capstone: security:audit + security:probe + bundle:audit (+ optional lighthouse), combined report + exit code (CI gate); --dir adds the weight/size gate over a local export
|
|
296
|
+
everystack bundle:analyze [dir] Client web-bundle size + composition (run after \`expo export -p web --source-maps\`)
|
|
297
|
+
everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs 3] [--path /] [--json out.json] Mobile Lighthouse (perf/a11y/bp/seo), median over N runs
|
|
298
|
+
everystack bundle:audit [url|--dir <export>] [--stage <name>] [--values-from <.env>] [--leak-only] [--max-file-mb N] [--max-total-mb N] [--max-js-mb N] [--max-data-mb N] Audit a bundle for leaked secrets AND weight (oversized assets, data that belongs in the DB, JS bloat) AND UI design-bloat (style repetition + hardcoded colors). A URL/stage weighs the remote bundle (assets sized via HEAD, no download); --dir scans a local export; --leak-only skips the weight gate
|
|
299
|
+
everystack ui:audit [dir] [--ui-repeat N] [--ui-color-repeat N] Source-side UI design-bloat: styles copy-pasted across call sites, hardcoded colors that should be tokens, raw primitives reimplementing a ui component, oversized components, duplicated JSX. Located + fixable; advisory, never gates
|
|
265
300
|
everystack certs:generate [--output ./certs]
|
|
266
301
|
everystack certs:configure [--input ./certs] [--keyid main]
|
|
267
302
|
everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lighthouse audit — pure, Lighthouse-free parsing/aggregation logic for
|
|
3
|
+
* `everystack audit:lighthouse`.
|
|
4
|
+
*
|
|
5
|
+
* Why N runs: the category scores are stable but Performance swings run-to-run
|
|
6
|
+
* because LCP does under throttled-mobile simulation. A single run can read 92
|
|
7
|
+
* or 65. We run each host several times and report the DISTRIBUTION (median +
|
|
8
|
+
* min/max) — the median is the honest number, the spread shows how noisy LCP is.
|
|
9
|
+
*
|
|
10
|
+
* Measurement rule (in the command): warm the CloudFront edge first, then
|
|
11
|
+
* measure WITHOUT a cache-buster, so we score the real-user warm-HTML path, not
|
|
12
|
+
* a pessimistic cold-origin SSR render.
|
|
13
|
+
*
|
|
14
|
+
* Config-free: the target URL comes from the deployed SST outputs (or --host),
|
|
15
|
+
* never a hand-maintained env map.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'] as const;
|
|
19
|
+
export const METRICS = [
|
|
20
|
+
'first-contentful-paint',
|
|
21
|
+
'largest-contentful-paint',
|
|
22
|
+
'total-blocking-time',
|
|
23
|
+
'cumulative-layout-shift',
|
|
24
|
+
'speed-index',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type Category = (typeof CATEGORIES)[number];
|
|
28
|
+
export type Metric = (typeof METRICS)[number];
|
|
29
|
+
|
|
30
|
+
export interface LhAudit {
|
|
31
|
+
title: string;
|
|
32
|
+
score: number | null;
|
|
33
|
+
numericValue?: number;
|
|
34
|
+
scoreDisplayMode?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LhReport {
|
|
38
|
+
categories: Record<string, { score: number; auditRefs: { id: string }[] }>;
|
|
39
|
+
audits: Record<string, LhAudit>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExtractedRun {
|
|
43
|
+
scores: Record<string, number>; // category → 0..100
|
|
44
|
+
metrics: Record<string, number>; // metric → numericValue
|
|
45
|
+
imperfect: { id: string; title: string; cat: string }[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Aggregate {
|
|
49
|
+
scores: Record<string, number[]>;
|
|
50
|
+
metrics: Record<string, number[]>;
|
|
51
|
+
failing: { id: string; title: string; cat: string; count: number }[];
|
|
52
|
+
noindex: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function median(xs: number[]): number {
|
|
56
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
57
|
+
const m = Math.floor(s.length / 2);
|
|
58
|
+
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Pull scores, metrics, and imperfect audits out of one Lighthouse report. */
|
|
62
|
+
export function extractRun(report: LhReport): ExtractedRun {
|
|
63
|
+
const scores: Record<string, number> = {};
|
|
64
|
+
const metrics: Record<string, number> = {};
|
|
65
|
+
const imperfect: { id: string; title: string; cat: string }[] = [];
|
|
66
|
+
|
|
67
|
+
for (const c of CATEGORIES) {
|
|
68
|
+
scores[c] = Math.round((report.categories[c]?.score ?? 0) * 100);
|
|
69
|
+
}
|
|
70
|
+
for (const m of METRICS) {
|
|
71
|
+
metrics[m] = report.audits[m]?.numericValue ?? 0;
|
|
72
|
+
}
|
|
73
|
+
for (const c of CATEGORIES) {
|
|
74
|
+
for (const ref of report.categories[c]?.auditRefs ?? []) {
|
|
75
|
+
const au = report.audits[ref.id];
|
|
76
|
+
if (au && au.score !== null && au.score < 1 && au.scoreDisplayMode !== 'informative') {
|
|
77
|
+
imperfect.push({ id: ref.id, title: au.title, cat: c });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { scores, metrics, imperfect };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Aggregate the per-run extracts into score/metric distributions + flags. */
|
|
85
|
+
export function aggregate(runs: ExtractedRun[]): Aggregate {
|
|
86
|
+
const scores: Record<string, number[]> = Object.fromEntries(CATEGORIES.map((c) => [c, []]));
|
|
87
|
+
const metrics: Record<string, number[]> = Object.fromEntries(METRICS.map((m) => [m, []]));
|
|
88
|
+
const failingMap = new Map<string, { title: string; cat: string; count: number }>();
|
|
89
|
+
let noindex = false;
|
|
90
|
+
|
|
91
|
+
for (const run of runs) {
|
|
92
|
+
for (const c of CATEGORIES) scores[c].push(run.scores[c] ?? 0);
|
|
93
|
+
for (const m of METRICS) metrics[m].push(run.metrics[m] ?? 0);
|
|
94
|
+
for (const imp of run.imperfect) {
|
|
95
|
+
if (imp.id === 'is-crawlable') noindex = true;
|
|
96
|
+
const e = failingMap.get(imp.id) || { title: imp.title, cat: imp.cat, count: 0 };
|
|
97
|
+
e.count++;
|
|
98
|
+
failingMap.set(imp.id, e);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const failing = [...failingMap.entries()]
|
|
103
|
+
.map(([id, e]) => ({ id, ...e }))
|
|
104
|
+
.sort((a, b) => b.count - a.count);
|
|
105
|
+
return { scores, metrics, failing, noindex };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** extractRun + aggregate over a set of raw reports. */
|
|
109
|
+
export function summarize(reports: LhReport[]): Aggregate {
|
|
110
|
+
return aggregate(reports.map(extractRun));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fmtMs(v: number): string {
|
|
114
|
+
return v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${Math.round(v)}ms`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatLighthouseReport(url: string, runs: number, agg: Aggregate): string[] {
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
lines.push('='.repeat(64));
|
|
120
|
+
lines.push(`${url} (${runs} run${runs === 1 ? '' : 's'})`);
|
|
121
|
+
lines.push('='.repeat(64));
|
|
122
|
+
lines.push('Category median runs');
|
|
123
|
+
for (const c of CATEGORIES) {
|
|
124
|
+
const xs = agg.scores[c];
|
|
125
|
+
const spread = xs.length > 1 ? ` [${Math.min(...xs)}–${Math.max(...xs)}]` : '';
|
|
126
|
+
lines.push(` ${c.padEnd(14)} ${String(median(xs)).padStart(3)} ${xs.join(' ')}${spread}`);
|
|
127
|
+
}
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push('Metric median spread');
|
|
130
|
+
for (const m of METRICS) {
|
|
131
|
+
const xs = agg.metrics[m];
|
|
132
|
+
const cls = m === 'cumulative-layout-shift';
|
|
133
|
+
const f = cls ? (v: number) => v.toFixed(3) : fmtMs;
|
|
134
|
+
lines.push(` ${m.padEnd(24)} ${f(median(xs)).padStart(7)} ${f(Math.min(...xs))}–${f(Math.max(...xs))}`);
|
|
135
|
+
}
|
|
136
|
+
if (agg.failing.length) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push('Imperfect audits (count = runs flagged):');
|
|
139
|
+
for (const e of agg.failing) {
|
|
140
|
+
const note =
|
|
141
|
+
e.id === 'is-crawlable'
|
|
142
|
+
? ' ← expected when the stage is noindex (true SEO only on the indexable domain)'
|
|
143
|
+
: '';
|
|
144
|
+
lines.push(` [${e.cat}] ${e.id} (${e.count}/${runs}) — ${e.title}${note}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (agg.noindex) {
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push(
|
|
150
|
+
'NOTE: is-crawlable fails because this stage is noindex (robots Disallow: /).',
|
|
151
|
+
);
|
|
152
|
+
lines.push(' That suppresses SEO ~20pts. Re-measure the indexable production domain for the true score.');
|
|
153
|
+
}
|
|
154
|
+
return lines;
|
|
155
|
+
}
|