Skip to content

Commit 98932b8

Browse files
committed
fix(observability-map): name the suppression directive honestly, and tighten the tests
obs-map-disable-next-line applied to the whole entry point, so a directive on the last line of a file switched a check off for everything above it. Scoping it to a line is not available, since a finding is attached to an entry point and carries no line number to match against, and inventing a proximity rule would silently drop legitimate suppressions. So the name is now obs-map-disable, which is what it does. The old spelling is not honoured and a test says so. The CONTEXT line now says how many of the collapsed entries are sensitive, 18 of 333, so a reader knows to open the JSON rather than trusting the list. Three tests were passing by luck. The FIX FIRST ordering test sliced on a string the report no longer prints, so its assertions ran against the whole tail. The --no-write test never checked that no file was written, so it would have written into the repo root if the flag broke. A suppression test asserted toContain('1') against a report full of digits, and another never asserted the measured flag it was named for. README corrected: the score is 18, auth-boundary applies to 23 entry points rather than 26 and gates on sensitivity before the one-hop limit, and the invariant section now states both directions.
1 parent b5c9a52 commit 98932b8

7 files changed

Lines changed: 118 additions & 37 deletions

File tree

internal-packages/observability-map/README.md

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Scores every webapp entry point on whether it could explain itself during an inc
44
the ones worth fixing. An entry point is a Remix `loader` or `action` under
55
`apps/webapp/app/routes`, 427 of them at the time of writing.
66

7-
The number it prints today is 19 out of 100. That is not a bug, and the rest of this file is mostly
7+
The number it prints today is 18 out of 100. That is not a bug, and the rest of this file is mostly
88
about why you should believe it.
99

1010
## Running it
@@ -20,7 +20,7 @@ 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-
## What 19 means
23+
## What 18 means
2424

2525
It is the mean score of the 412 entry points that had at least one applicable check, where an
2626
entry's score is the share of its applicable checks that passed. It is low because the webapp does
@@ -30,9 +30,18 @@ route and the request id and nothing about whose request it was.
3030

3131
The score was 76 until we stopped crediting routes for the error handling they do not do. Emptying
3232
every catch clause in the tree used to score it 100, which meant the metric paid you for deleting
33-
error handling. Now removing the catches takes 19 down to 8, and removing the logs as well takes it
34-
to 2. If you change this package, keep that property: mutate the tree to remove error handling and
35-
check the score falls.
33+
error handling.
34+
35+
Two invariants hold now, and both are asserted in `test/score.test.ts` rather than measured once:
36+
37+
- **Removing error handling must not raise the score.** Deleting every catch clause takes 18 to 8,
38+
and deleting the logs as well takes it to 2.
39+
- **Adding error handling that does nothing must not raise the score.** Wrapping every body in
40+
`try { ... } catch (e) { throw e }` leaves it at 18, with no entry moving in either direction.
41+
That mutation used to be worth 27 points across the tree, because a rethrow-only clause counted
42+
as a pass while no catch at all was not-applicable, and the two are observationally identical.
43+
44+
If you change this package, check both directions still hold.
3645

3746
So the number is deliberately unflattering, and one platform change would move most of it. Nothing
3847
central attaches a tenant: `logger` pushes `{ requestId, path, host, method }` onto every line
@@ -43,8 +52,9 @@ rather than celebrating.
4352

4453
## The four checks
4554

46-
- **error-classification**: does every catch clause decide what it caught, by rethrowing, by
47-
branching on the error, or by guarding a parse it can answer for.
55+
- **error-classification**: does every catch clause decide what it caught, by branching on the
56+
error or by guarding a parse it can answer for. A clause that only rethrows decides nothing and
57+
is read as though there were no catch, so it neither passes nor fails.
4858
- **auth-boundary**: does a route handling credentials, tokens, billing or impersonation check who
4959
is asking.
5060
- **request-context**: when this entry point's failure is reported, is the tenant named.
@@ -62,6 +72,11 @@ are reported as a figure: the `AUDIT` and `CONTEXT` lines. 333 entry points fail
6272
fails `request-context` *and* something else keeps both findings and stays in the list, so
6373
`/account/tokens` still shows the whole picture.
6474

75+
18 of those 333 are sensitive, including `/admin/impersonate`, the API-key regeneration route and
76+
four envvars routes, so the `CONTEXT` line says how many. Read them out of
77+
`observability-map.json`, where every entry keeps its full check results, rather than assuming the
78+
list is the whole story.
79+
6580
`request-context` is still scored, unlike `audit-trail`. The gap it measures is real and the score
6681
is meant to show it. Only the presentation collapses.
6782

@@ -94,11 +109,17 @@ support.
94109
## Suppression
95110

96111
```ts
97-
// obs-map-disable-next-line auth-boundary -- public by design, see ADR 12
112+
// obs-map-disable auth-boundary -- public by design, see ADR 12
98113
```
99114

100115
The reason is mandatory: a suppression without one is ignored. The directive is read from comments
101-
only, line by line, so a string literal quoting it does not switch a check off.
116+
only, so a string literal quoting it does not switch a check off.
117+
118+
It applies to the whole entry point, not to the line under it. It was called
119+
`obs-map-disable-next-line`, which was untrue in a way that mattered: a directive on the last line
120+
of a file switched a check off for everything above it. Genuine line scoping is not available,
121+
because a finding is attached to an entry point and carries no line number to match against, so the
122+
name was corrected instead. The old spelling is not honoured, and there is a test saying so.
102123

103124
A suppression cannot raise a score. The suppressed check leaves the numerator and the denominator,
104125
and the result is capped by what the entry would have scored unsuppressed, so suppressing a failing
@@ -112,11 +133,16 @@ Read these before trusting a specific verdict.
112133

113134
- **One hop, same file only.** If a loader delegates to a helper in the same file, that helper's
114135
statements, catches and calls count as the route's. A helper's own helpers do not, and nothing
115-
imported from another module is ever opened. Most of what a route does is behind an import, which
116-
is why `auth-boundary` applies to 26 entry points rather than 427.
136+
imported from another module is ever opened. `auth-boundary` applies to 23 entry points: it gates
137+
on sensitivity first, which is 26 routes, and the one-hop limit accounts for the other 3, which
138+
hand their work to an imported helper and are reported as unverified rather than unguarded.
117139
- **Loggers are matched by spelling.** A call counts as logging when the callee reads `logger.*` or
118140
`log.*`. An aliased logger, one wrapped in a helper, or `console.error` is invisible, so a route
119141
can be reported as recording nothing while it records plenty.
142+
- **A catch that logs and rethrows reads as though it only rethrows.** The clause evidence cannot
143+
say whether a clause does anything besides rethrow, so `error-classification` withholds credit
144+
rather than granting it. Crediting it would reopen the free-points path a single `logger.error`
145+
line wide.
120146
- **Only the first object-literal argument is read** for identifier fields, and only its property
121147
names. `logger.error("failed", ctx)` where `ctx` is a variable contributes nothing, and neither
122148
does a second object.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,14 @@ export function renderTerminal(report: MapReport): string {
6868

6969
const { applicable, naming } = report.contextGap;
7070
if (applicable > 0) {
71-
const collapsed = report.entries.filter(contextOnly).length;
71+
const collapsed = report.entries.filter(contextOnly);
72+
const sensitive = collapsed.filter((e) => e.sensitive).length;
7273
lines.push("");
7374
lines.push(
7475
`CONTEXT ${naming} of ${applicable} entry points name a tenant on a failure path.` +
75-
(collapsed > 0
76-
? ` ${collapsed} appear${collapsed === 1 ? "s" : ""} only here, not in the list below.`
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.`
7779
: "")
7880
);
7981
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22
* The directive, and the reason that must follow it. The reason runs to the end of the line: `.`
33
* does not match a newline, so a suppression on one line cannot pick up a reason from the next.
44
* A trailing block-comment terminator is trimmed off so it does not end up inside the reason.
5+
*
6+
* It was `obs-map-disable-next-line`, which was a lie: a check applies to a whole entry point, so
7+
* the directive did too, and one on the last line of a file switched a check off for everything
8+
* above it. The honest options were to scope it to a line or to rename it, and scoping is not
9+
* available: a `CheckResult` carries no line number, and neither does an `EntryPoint`, so there is
10+
* nothing to match a line against. Scoping it would mean inventing a proximity rule that silently
11+
* drops legitimate suppressions. So the name now says what it does. Real line scoping needs
12+
* positions on the findings, which is scanner work.
513
*/
6-
const PATTERN = /obs-map-disable-next-line\s+([a-z-]+)\s+--\s+(.+)/;
14+
const PATTERN = /obs-map-disable\s+([a-z-]+)\s+--\s+(.+)/;
715

816
/**
917
* The comment part of a line, or null if there is none.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { existsSync, rmSync } from "node:fs";
2+
import { resolve } from "node:path";
13
import { main, type Io } from "../src/cli.js";
24

5+
const REPORT_FILE = resolve(__dirname, "../../../observability-map.json");
6+
37
const capture = () => {
48
const out: string[] = [];
59
const err: string[] = [];
@@ -51,10 +55,17 @@ describe("map <target>", () => {
5155
});
5256

5357
describe("map", () => {
58+
// The flag is the only thing standing between a test run and a file written into the repo root,
59+
// so the test has to check the file, not just the exit code.
5460
it("renders the whole report without writing when asked not to", () => {
61+
const existedBefore = existsSync(REPORT_FILE);
62+
if (existedBefore) rmSync(REPORT_FILE);
63+
5564
const r = run("--no-write");
65+
5666
expect(r.code).toBe(0);
5767
expect(r.out).toContain("COVERAGE");
5868
expect(r.out).toContain("FIX FIRST");
69+
expect(existsSync(REPORT_FILE)).toBe(false);
5970
});
6071
});

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,14 @@ describe("renderTerminal", () => {
3232
it("surfaces suppressions so laundering is visible rather than silent", () => {
3333
const suppressed = scanFile(
3434
"api.v1.d.ts",
35-
`// obs-map-disable-next-line error-classification -- deliberate, see ticket
35+
`// obs-map-disable error-classification -- deliberate, see ticket
3636
import { prisma } from "~/db.server";
3737
export async function loader() {
3838
try { return await prisma.thing.findMany(); } catch (e) { return null; }
3939
}`
4040
)!;
4141
const out = renderTerminal(buildReport([suppressed], []));
42-
expect(out).toMatch(/suppress/i);
43-
expect(out).toContain("1");
42+
expect(out).toMatch(/SUPPRESSED\s+1 check across 1 entry point/);
4443
});
4544

4645
it("does not mention suppressions when there are none", () => {
@@ -118,7 +117,11 @@ describe("renderTerminal", () => {
118117
buildReport([sensitiveThirtyThree, sensitiveZero, notSensitiveZero, sensitiveAuditOnly], [])
119118
);
120119

121-
const fixFirst = out.slice(out.indexOf("FIX FIRST"), out.indexOf("already solid"));
120+
// Slice to the end of the list, not to a string the I5 fix deleted: `indexOf` returned -1 for
121+
// "already solid" and the assertions were quietly running against the whole tail.
122+
const listEnd = out.indexOf("no findings:");
123+
expect(listEnd).toBeGreaterThan(-1);
124+
const fixFirst = out.slice(out.indexOf("FIX FIRST"), listEnd);
122125
const idxZero = fixFirst.indexOf("api.v1.envvars.ts");
123126
const idxThirtyThree = fixFirst.indexOf("api.v1.auth.tokens.ts");
124127
const idxNotSensitive = fixFirst.indexOf("resources.busy.ts");
@@ -238,6 +241,22 @@ describe("collapsing the house-style finding", () => {
238241
expect(out).toMatch(/CONTEXT\s+0 of 1 entry points name a tenant on a failure path/);
239242
});
240243

244+
// NEW-3. 18 of the collapsed entries are sensitive, including /admin/impersonate and the envvars
245+
// routes, so the line has to say a reader should go and look at them.
246+
it("says how many of the collapsed entries are sensitive", () => {
247+
const sensitiveAndSilent = scanFile(
248+
"api.v1.auth.jwt.ts",
249+
`import { requireUserId } from "~/services/session.server";
250+
import { prisma } from "~/db.server";
251+
export async function loader({ request }) {
252+
const userId = await requireUserId(request);
253+
return prisma.token.findMany({ where: { userId } });
254+
}`
255+
)!;
256+
const out = renderTerminal(buildReport([sensitiveAndSilent], []));
257+
expect(out).toMatch(/1 appears? only here[^\n]*1 of them sensitive/i);
258+
});
259+
241260
it("says how many entries the collapse took out of the list", () => {
242261
const out = renderTerminal(buildReport([namesNobody(), namesNobodyAndSwallows()], []));
243262
expect(out).toMatch(/1 appears? only here/i);

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe("scoreEntry", () => {
5454
});
5555

5656
it("counts a suppressed check as not-applicable", () => {
57-
const suppressed = `// obs-map-disable-next-line error-classification -- health probe
57+
const suppressed = `// obs-map-disable error-classification -- health probe
5858
${RAW}`;
5959
const scored = scoreEntry(scanFile("api.v1.b.ts", suppressed)!);
6060
const ec = scored.checks.find((c) => c.id === "error-classification")!;
@@ -65,12 +65,16 @@ ${RAW}`;
6565
// error-classification would fail here; auth-boundary is not-applicable (not sensitive).
6666
// Suppressing the only applicable scored check must not be indistinguishable from an entry
6767
// point nothing applies to: it is still reported, just not scored on that axis.
68-
const suppressed = `// obs-map-disable-next-line error-classification -- health probe
68+
const suppressed = `// obs-map-disable error-classification -- health probe
6969
${BUSY_AND_FAILING}`;
7070
const scored = scoreEntry(scanFile("api.v1.c.ts", suppressed)!);
7171
expect(scored.checks.find((c) => c.id === "error-classification")!.status).toBe(
7272
"not-applicable"
7373
);
74+
// The point of the test, which it did not previously assert: request-context still applies, so
75+
// the entry is still measured and still counted in the mean.
76+
expect(scored.checks.find((c) => c.id === "request-context")!.status).toBe("fail");
77+
expect(scored.measured).toBe(true);
7478
});
7579

7680
// I1. `score = passed / applicable` meant removing a failing check from the denominator raised
@@ -88,7 +92,7 @@ export async function action({ request }) {
8892
const suppressed = scoreEntry(
8993
scanFile(
9094
"api.v1.auth.tokens.ts",
91-
`// obs-map-disable-next-line error-classification -- deliberate, see ticket
95+
`// obs-map-disable error-classification -- deliberate, see ticket
9296
${source}`
9397
)!
9498
);
@@ -104,8 +108,8 @@ ${source}`
104108
const suppressed = scoreEntry(
105109
scanFile(
106110
"api.v1.b.ts",
107-
`// obs-map-disable-next-line error-classification -- health probe
108-
// obs-map-disable-next-line request-context -- nothing to name here
111+
`// obs-map-disable error-classification -- health probe
112+
// obs-map-disable request-context -- nothing to name here
109113
${BUSY_AND_FAILING}`
110114
)!
111115
);
@@ -116,8 +120,8 @@ ${BUSY_AND_FAILING}`
116120
const suppressed = scoreEntry(
117121
scanFile(
118122
"api.v1.b.ts",
119-
`// obs-map-disable-next-line error-classification -- health probe
120-
// obs-map-disable-next-line request-context -- nothing to name here
123+
`// obs-map-disable error-classification -- health probe
124+
// obs-map-disable request-context -- nothing to name here
121125
${BUSY_AND_FAILING}`
122126
)!
123127
);
@@ -167,7 +171,7 @@ describe("buildReport", () => {
167171
[
168172
scanFile(
169173
"api.v1.b.ts",
170-
`// obs-map-disable-next-line error-classification -- health probe
174+
`// obs-map-disable error-classification -- health probe
171175
${BUSY_AND_FAILING}`
172176
)!,
173177
scanFile("api.v1.c.ts", BUSY_AND_FAILING)!,

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,26 @@ import { suppressedChecks } from "../src/suppression.js";
33
describe("suppressedChecks", () => {
44
it("reads a suppression with its reason", () => {
55
const m = suppressedChecks(
6-
`// obs-map-disable-next-line error-classification -- liveness probe, deliberately silent
6+
`// obs-map-disable error-classification -- liveness probe, deliberately silent
77
export async function loader() { return { ok: true }; }`
88
);
99
expect(m.get("error-classification")).toBe("liveness probe, deliberately silent");
1010
});
1111

1212
it("ignores a suppression with no reason", () => {
13-
const m = suppressedChecks(`// obs-map-disable-next-line error-classification`);
13+
const m = suppressedChecks(`// obs-map-disable error-classification`);
1414
expect(m.size).toBe(0);
1515
});
1616

1717
it("ignores a suppression whose reason is only whitespace", () => {
18-
const m = suppressedChecks(`// obs-map-disable-next-line error-classification -- `);
18+
const m = suppressedChecks(`// obs-map-disable error-classification -- `);
1919
expect(m.size).toBe(0);
2020
});
2121

2222
it("reads several suppressions in one file", () => {
2323
const m = suppressedChecks(
24-
`// obs-map-disable-next-line error-classification -- liveness probe
25-
// obs-map-disable-next-line request-context -- no identifiers exist here
24+
`// obs-map-disable error-classification -- liveness probe
25+
// obs-map-disable request-context -- no identifiers exist here
2626
export async function loader() { return { ok: true }; }`
2727
);
2828
expect(m.size).toBe(2);
@@ -36,7 +36,7 @@ describe("suppressedChecks", () => {
3636

3737
it("does not carry a reason across lines", () => {
3838
const m = suppressedChecks(
39-
`// obs-map-disable-next-line error-classification
39+
`// obs-map-disable error-classification
4040
// some other comment -- with a dash
4141
export async function loader() { return 1; }`
4242
);
@@ -47,15 +47,15 @@ describe("suppressedChecks", () => {
4747
// merely quotes it, in a test fixture or an error message, silently suppressed a real check.
4848
it("ignores the directive inside a string literal", () => {
4949
const m = suppressedChecks(
50-
`const example = "obs-map-disable-next-line error-classification -- not a real suppression";
50+
`const example = "obs-map-disable error-classification -- not a real suppression";
5151
export async function loader() { return 1; }`
5252
);
5353
expect(m.size).toBe(0);
5454
});
5555

5656
it("reads the directive from a block comment", () => {
5757
const m = suppressedChecks(
58-
`/* obs-map-disable-next-line auth-boundary -- public by design, see ADR 12 */
58+
`/* obs-map-disable auth-boundary -- public by design, see ADR 12 */
5959
export async function loader() { return 1; }`
6060
);
6161
expect(m.get("auth-boundary")).toBe("public by design, see ADR 12");
@@ -64,7 +64,7 @@ describe("suppressedChecks", () => {
6464
it("reads the directive from a jsdoc line", () => {
6565
const m = suppressedChecks(
6666
`/**
67-
* obs-map-disable-next-line request-context -- nothing tenant-scoped here
67+
* obs-map-disable request-context -- nothing tenant-scoped here
6868
*/
6969
export async function loader() { return 1; }`
7070
);
@@ -73,9 +73,20 @@ describe("suppressedChecks", () => {
7373

7474
it("ignores code that happens to follow a comment on the same line", () => {
7575
const m = suppressedChecks(
76-
`const x = 1; // obs-map-disable-next-line error-classification -- fine
76+
`const x = 1; // obs-map-disable error-classification -- fine
7777
export async function loader() { return x; }`
7878
);
7979
expect(m.get("error-classification")).toBe("fine");
8080
});
81+
82+
// The directive was called `-next-line` while applying to the whole entry point, so a comment on
83+
// the last line of a file switched a check off for everything above it. Renamed rather than
84+
// scoped, because a finding has no line number to scope it to. The old spelling is not honoured.
85+
it("does not honour the old -next-line spelling", () => {
86+
const m = suppressedChecks(
87+
`// obs-map-disable-next-line error-classification -- stale directive
88+
export async function loader() { return 1; }`
89+
);
90+
expect(m.size).toBe(0);
91+
});
8192
});

0 commit comments

Comments
 (0)