Skip to content

Commit 82fac3e

Browse files
committed
feat(observability-map): add --routes flag and a PR-comment renderer for CI
1 parent b709cc2 commit 82fac3e

8 files changed

Lines changed: 489 additions & 28 deletions

File tree

internal-packages/observability-map/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ suppresses. The single-route mode takes either the route path the report prints
2020
the file name (`api.v1.token.ts`). An exact match wins over the routes it is a prefix of, and an
2121
ambiguous prefix warns and names the alternatives rather than silently picking one.
2222

23+
## CI
24+
25+
A PR that touches `apps/webapp/app/routes` or this package gets a sticky comment scanning head
26+
against the PR's merge base, with the score, what changed, and the current fix list. It is
27+
report-only: nothing here fails the build or blocks a merge, and the gate stays deferred until a
28+
later phase decides to add one. See `.github/workflows/observability-map.yml`.
29+
2330
## What 17 means
2431

2532
It is the mean score of the 412 entry points that had at least one applicable check, where an

internal-packages/observability-map/src/cli.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, writeFileSync } from "node:fs";
1+
import { existsSync, statSync, writeFileSync } from "node:fs";
22
import { dirname, resolve } from "node:path";
33
import { fileURLToPath } from "node:url";
44
import type { EntryPoint } from "./types.js";
@@ -63,7 +63,25 @@ export function main(argv: string[], io: Io = processIo): number {
6363
const target = args.find((a) => !a.startsWith("--"));
6464

6565
const repoRoot = findRepoRoot(dirname(fileURLToPath(import.meta.url)));
66-
const routesDir = resolve(repoRoot, DEFAULT_ROUTES);
66+
const routesFlag = args.find((a) => a.startsWith("--routes="));
67+
68+
let routesDir: string;
69+
if (routesFlag) {
70+
routesDir = resolve(process.cwd(), routesFlag.slice("--routes=".length));
71+
let isDir = false;
72+
try {
73+
isDir = statSync(routesDir).isDirectory();
74+
} catch {
75+
isDir = false;
76+
}
77+
if (!isDir) {
78+
io.err(`--routes: not a readable directory: ${routesDir}\n`);
79+
return 1;
80+
}
81+
} else {
82+
routesDir = resolve(repoRoot, DEFAULT_ROUTES);
83+
}
84+
6785
const { entryPoints, parseFailures } = scanDirectory(routesDir);
6886

6987
if (target) {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import type { MapReport, ScoredEntry } from "../score.js";
2+
import { auditLine, contextLine, contextOnly, scoredFailures } from "./terminal.js";
3+
4+
/** First line of every comment this job posts, so the upsert step can find its own comment again. */
5+
export const MARKER = "<!-- observability-map-report -->";
6+
7+
const MAX_CHANGED_ROWS = 15;
8+
9+
const failingIds = (e: ScoredEntry) => e.checks.filter((c) => c.status === "fail").map((c) => c.id);
10+
11+
function scoreLine(head: MapReport, base: MapReport | null): string {
12+
const headline =
13+
head.global === null
14+
? `not measured over ${head.measured} measured of ${head.entries.length} entry points`
15+
: `**${head.global}/100** over ${head.measured} measured of ${head.entries.length} entry points`;
16+
17+
if (!base) return headline;
18+
if (base.global === null || head.global === null) return `${headline} (base not measured)`;
19+
20+
const diff = head.global - base.global;
21+
const comparison =
22+
diff === 0
23+
? `(base ${base.global}, no change)`
24+
: diff > 0
25+
? `(base ${base.global}, up ${diff})`
26+
: `(base ${base.global}, down ${-diff})`;
27+
return `${headline} ${comparison}`;
28+
}
29+
30+
type ChangedRow = {
31+
routePath: string;
32+
sensitive: boolean;
33+
baseScore: number | "new";
34+
headScore: number;
35+
nowFailing: string[];
36+
/**
37+
* How much the entry got worse, used to sort the table. A new entry has no base score to
38+
* subtract from, so it is scored against a perfect 100: a new entry landing at 60 sorts the
39+
* same as an existing one that dropped 40 points, which is the ordering "what needs fixing
40+
* first" implies.
41+
*/
42+
drop: number;
43+
};
44+
45+
function changedRows(head: MapReport, base: MapReport): { rows: ChangedRow[]; removed: number } {
46+
const baseByFile = new Map(base.entries.map((e) => [e.fileName, e]));
47+
const headFiles = new Set(head.entries.map((e) => e.fileName));
48+
49+
const rows: ChangedRow[] = [];
50+
for (const h of head.entries) {
51+
const b = baseByFile.get(h.fileName);
52+
if (!b) {
53+
rows.push({
54+
routePath: h.routePath,
55+
sensitive: h.sensitive,
56+
baseScore: "new",
57+
headScore: h.score,
58+
nowFailing: failingIds(h),
59+
drop: 100 - h.score,
60+
});
61+
continue;
62+
}
63+
if (b.score === h.score) continue;
64+
const baseFailing = new Set(failingIds(b));
65+
rows.push({
66+
routePath: h.routePath,
67+
sensitive: h.sensitive,
68+
baseScore: b.score,
69+
headScore: h.score,
70+
nowFailing: failingIds(h).filter((id) => !baseFailing.has(id)),
71+
drop: b.score - h.score,
72+
});
73+
}
74+
75+
rows.sort(
76+
(a, b) =>
77+
Number(b.sensitive) - Number(a.sensitive) ||
78+
b.drop - a.drop ||
79+
a.routePath.localeCompare(b.routePath)
80+
);
81+
82+
const removed = base.entries.filter((e) => !headFiles.has(e.fileName)).length;
83+
return { rows, removed };
84+
}
85+
86+
function whatChangedSection(head: MapReport, base: MapReport | null): string[] {
87+
const lines = ["**What this PR changed**"];
88+
89+
if (!base) {
90+
lines.push("Base comparison unavailable.");
91+
return lines;
92+
}
93+
94+
const { rows, removed } = changedRows(head, base);
95+
96+
if (rows.length === 0 && removed === 0) {
97+
lines.push("No entry point this PR touches changed its score.");
98+
return lines;
99+
}
100+
101+
if (rows.length > 0) {
102+
lines.push("");
103+
lines.push("| route | base | head | now failing |");
104+
lines.push("| --- | --- | --- | --- |");
105+
for (const row of rows.slice(0, MAX_CHANGED_ROWS)) {
106+
lines.push(
107+
`| ${row.routePath} | ${row.baseScore} | ${row.headScore} | ${row.nowFailing.join(", ")} |`
108+
);
109+
}
110+
if (rows.length > MAX_CHANGED_ROWS) {
111+
lines.push("");
112+
lines.push(`and ${rows.length - MAX_CHANGED_ROWS} more`);
113+
}
114+
}
115+
116+
if (removed > 0) {
117+
lines.push("");
118+
lines.push(`${removed} entries removed`);
119+
}
120+
121+
return lines;
122+
}
123+
124+
function fixFirstSection(head: MapReport): string[] {
125+
const lines = ["FIX FIRST"];
126+
const worst = head.entries
127+
.filter((e) => scoredFailures(e).length > 0 && !contextOnly(e))
128+
.sort(
129+
(a, b) =>
130+
Number(b.sensitive) - Number(a.sensitive) ||
131+
a.score - b.score ||
132+
a.fileName.localeCompare(b.fileName)
133+
);
134+
135+
for (const e of worst.slice(0, 3)) {
136+
const marks = e.sensitive ? " (sensitive)" : "";
137+
lines.push(
138+
`- ${e.routePath}${marks} - ${scoredFailures(e)
139+
.map((c) => c.id)
140+
.join(", ")}`
141+
);
142+
}
143+
return lines;
144+
}
145+
146+
/**
147+
* Pure function, no I/O: `head` and `base` are already-built reports. Matches entries across the
148+
* two by `fileName`, the same identifier `renderJson` carries.
149+
*/
150+
export function renderPrComment(head: MapReport, base: MapReport | null): string {
151+
const lines = [MARKER, "", "## Observability map", "", scoreLine(head, base), ""];
152+
153+
lines.push(...whatChangedSection(head, base), "");
154+
lines.push(...fixFirstSection(head), "");
155+
156+
const audit = auditLine(head);
157+
if (audit) lines.push(audit);
158+
const context = contextLine(head);
159+
if (context) lines.push(context);
160+
if (audit || context) lines.push("");
161+
162+
lines.push(
163+
"Report only, nothing here gates the merge. The rules and their reasons: " +
164+
"internal-packages/observability-map/README.md."
165+
);
166+
167+
const headFailures = head.parseFailures.length;
168+
const baseFailures = base?.parseFailures.length ?? 0;
169+
if (headFailures > 0 || baseFailures > 0) {
170+
const parts: string[] = [];
171+
if (headFailures > 0) parts.push(`${headFailures} at head`);
172+
if (baseFailures > 0) parts.push(`${baseFailures} at base`);
173+
lines.push(
174+
`Warning: parse failures (${parts.join(", ")}) are excluded from the score, shrinking the denominator.`
175+
);
176+
}
177+
178+
return lines.join("\n");
179+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { readFileSync } from "node:fs";
2+
import { resolve } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import type { MapReport } from "../score.js";
5+
import { renderPrComment } from "./prComment.js";
6+
7+
/** Where output goes. Injectable so tests can read it without spawning a process. */
8+
export type Io = { out: (s: string) => void; err: (s: string) => void };
9+
10+
const processIo: Io = {
11+
out: (s) => process.stdout.write(s),
12+
err: (s) => process.stderr.write(s),
13+
};
14+
15+
/** `-` or a missing second arg means no base: the CI job falls back to this when the base scan
16+
* itself failed, so the comment still renders rather than the job going red. */
17+
export function main(argv: string[], io: Io = processIo): number {
18+
const args = argv.slice(2);
19+
const headPath = args[0];
20+
const basePath = args[1];
21+
22+
if (!headPath) {
23+
io.err("usage: prCommentCli.ts <head.json> [base.json|-]\n");
24+
return 1;
25+
}
26+
27+
const head = JSON.parse(readFileSync(headPath, "utf8")) as MapReport;
28+
const base: MapReport | null =
29+
!basePath || basePath === "-" ? null : JSON.parse(readFileSync(basePath, "utf8"));
30+
31+
io.out(renderPrComment(head, base));
32+
io.out("\n");
33+
return 0;
34+
}
35+
36+
// Only when run as a program. Importing the module, which the tests do, must not read a file.
37+
const invokedDirectly =
38+
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
39+
if (invokedDirectly) process.exitCode = main(process.argv);

internal-packages/observability-map/src/report/terminal.ts

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const gauge = (score: number | null) => {
1717
* of the fixable, route-specific gaps the list exists to surface. That gap is reported once, as
1818
* `AUDIT`, below.
1919
*/
20-
const scoredFailures = (e: ScoredEntry) =>
20+
export const scoredFailures = (e: ScoredEntry) =>
2121
e.checks.filter((c) => SCORED_CHECK_IDS.includes(c.id) && c.status === "fail");
2222

2323
/**
@@ -27,11 +27,40 @@ const scoredFailures = (e: ScoredEntry) =>
2727
* An entry that fails something else as well stays in the list with all of its findings, so a
2828
* route like `/account/tokens` still shows the request-context gap alongside the rest.
2929
*/
30-
const contextOnly = (e: ScoredEntry) => {
30+
export const contextOnly = (e: ScoredEntry) => {
3131
const failures = scoredFailures(e);
3232
return failures.length === 1 && failures[0]!.id === "request-context";
3333
};
3434

35+
/** The AUDIT figure, shared with `prComment.ts` so both renderers say the same thing. Null when
36+
* there is nothing to report, i.e. no sensitive mutation exists. */
37+
export function auditLine(report: MapReport): string | null {
38+
const { sensitiveMutations, withAudit } = report.auditGap;
39+
if (sensitiveMutations === 0) return null;
40+
// The closing sentence is a claim about the codebase, so it is only made when the figure in
41+
// front of it supports it. It was printed unconditionally, including next to a non-zero count.
42+
const gap =
43+
withAudit === 0
44+
? " No audit helper exists in the webapp."
45+
: ` ${sensitiveMutations - withAudit} without one.`;
46+
return `AUDIT ${withAudit} of ${sensitiveMutations} sensitive mutations record an actor.${gap}`;
47+
}
48+
49+
/** The CONTEXT figure, shared with `prComment.ts`. Null when nothing is applicable. */
50+
export function contextLine(report: MapReport): string | null {
51+
const { applicable, naming } = report.contextGap;
52+
if (applicable === 0) return null;
53+
const collapsed = report.entries.filter(contextOnly);
54+
const sensitive = collapsed.filter((e) => e.sensitive).length;
55+
return (
56+
`CONTEXT ${naming} of ${applicable} entry points name a tenant on a failure path.` +
57+
(collapsed.length > 0
58+
? ` ${collapsed.length} appear${collapsed.length === 1 ? "s" : ""} only here, ` +
59+
`${sensitive} of them sensitive, in the JSON rather than the list below.`
60+
: "")
61+
);
62+
}
63+
3564
export function renderTerminal(report: MapReport): string {
3665
const lines: string[] = [];
3766

@@ -52,32 +81,16 @@ export function renderTerminal(report: MapReport): string {
5281
}/${report.sensitiveCohort.n} entry points`
5382
);
5483

55-
const { sensitiveMutations, withAudit } = report.auditGap;
56-
if (sensitiveMutations > 0) {
84+
const audit = auditLine(report);
85+
if (audit) {
5786
lines.push("");
58-
// The closing sentence is a claim about the codebase, so it is only made when the figure in
59-
// front of it supports it. It was printed unconditionally, including next to a non-zero count.
60-
const gap =
61-
withAudit === 0
62-
? " No audit helper exists in the webapp."
63-
: ` ${sensitiveMutations - withAudit} without one.`;
64-
lines.push(
65-
`AUDIT ${withAudit} of ${sensitiveMutations} sensitive mutations record an actor.${gap}`
66-
);
87+
lines.push(audit);
6788
}
6889

69-
const { applicable, naming } = report.contextGap;
70-
if (applicable > 0) {
71-
const collapsed = report.entries.filter(contextOnly);
72-
const sensitive = collapsed.filter((e) => e.sensitive).length;
90+
const context = contextLine(report);
91+
if (context) {
7392
lines.push("");
74-
lines.push(
75-
`CONTEXT ${naming} of ${applicable} entry points name a tenant on a failure path.` +
76-
(collapsed.length > 0
77-
? ` ${collapsed.length} appear${collapsed.length === 1 ? "s" : ""} only here, ` +
78-
`${sensitive} of them sensitive, in the JSON rather than the list below.`
79-
: "")
80-
);
93+
lines.push(context);
8194
}
8295

8396
if (report.suppressions.checks > 0) {

internal-packages/observability-map/test/cli.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { existsSync, rmSync } from "node:fs";
2-
import { resolve } from "node:path";
1+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join, resolve } from "node:path";
34
import { main, type Io } from "../src/cli.js";
45

56
const REPORT_FILE = resolve(__dirname, "../../../observability-map.json");
@@ -69,3 +70,31 @@ describe("map", () => {
6970
expect(existsSync(REPORT_FILE)).toBe(false);
7071
});
7172
});
73+
74+
describe("map --routes=<dir>", () => {
75+
it("scans the directory it names instead of the repo's routes tree", () => {
76+
const dir = mkdtempSync(join(tmpdir(), "obs-map-routes-"));
77+
writeFileSync(
78+
join(dir, "resources.only.ts"),
79+
`export const loader = () => new Response("ok");`
80+
);
81+
82+
const r = run("--routes=" + dir, "--json", "--no-write");
83+
84+
expect(r.code).toBe(0);
85+
const parsed = JSON.parse(r.out);
86+
expect(parsed.entries).toHaveLength(1);
87+
expect(parsed.entries[0].fileName).toBe("resources.only.ts");
88+
89+
rmSync(dir, { recursive: true });
90+
});
91+
92+
it("exits 1 with a message when the directory does not exist", () => {
93+
const dir = join(tmpdir(), "obs-map-routes-does-not-exist");
94+
const r = run("--routes=" + dir);
95+
96+
expect(r.code).toBe(1);
97+
expect(r.err).toContain("not a readable directory");
98+
expect(r.out).toBe("");
99+
});
100+
});

0 commit comments

Comments
 (0)