Skip to content

Commit 9ef6324

Browse files
committed
fix(observability-map): narrow the required check and the report bot's comment lookup
Four findings from Nick's post-merge review of #4455. The report bot found its own comment by marker text with no author predicate, so it silently edited a human's comment that happened to quote the marker. GitHub gates comment editing on write access, not authorship, so this never failed loudly. Matched on .user.login now, the same identity helm-prerelease.yml pins through peter-evans/find-comment. The package's test suite asserted that nobody anywhere in the webapp declares createJWT, signJWT, setImpersonation or updateEnvVars, walking locals and parameters and object keys. Naming a local variable failed a required check with a message that pointed at nothing. Those two negative self-tests move onto a fixture tree, which proves the predicate can fail without holding the webapp's namespace hostage. The positive assertions stay required, because their absence is what rotted the tool before, but each now names the list to edit. The obsmap and internal path filters shared four generic paths, so any lockfile bump ran this suite twice. Dropped from obsmap, where internal already covers them. The test that should have caught it only checked the package's own source path, so it now asserts the filters' set intersection is empty. Editing pr_checks.yml alone no longer fires the obsmap job live, which is the accepted trade-off. The PR comment's footer said nothing here gates the merge. True of the report, misleading now that the suite gates webapp pull requests, so it names both failure directions and where to read the rules.
1 parent ca9a74e commit 9ef6324

9 files changed

Lines changed: 157 additions & 46 deletions

File tree

.github/workflows/observability-map.yml

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ jobs:
8989
found=""
9090
ok=""
9191
for attempt in 1 2 3; do
92+
# Matched by login, not .user.type == "Bot": other bots and apps on the same PR are
93+
# also type Bot, and login is the exact identity this token's own comments carry.
9294
if found=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
93-
--jq '[.[] | select((.body // "") | startswith("<!-- observability-map-report -->"))][0].id // empty'); then
95+
--jq '[.[] | select(.user.login == "github-actions[bot]" and ((.body // "") | startswith("<!-- observability-map-report -->")))][0].id // empty'); then
9496
ok=1
9597
break
9698
fi
@@ -116,23 +118,31 @@ jobs:
116118
} >> "$GITHUB_OUTPUT"
117119
118120
# The tree-scale mutation corpus: every known laundering shape applied to the whole route tree,
119-
# asserting the score does not rise. Roughly four and a half minutes for 45 entries, which is why
120-
# it is gated out of the package's default `pnpm test` and run here instead. Unlike the report
121-
# job below it has no token to lose, so it runs for fork PRs too, and unlike the report job it is
121+
# asserting the score does not rise. Roughly five minutes for 53 entries, which is why it is
122+
# gated out of the package's default `pnpm test` and run here instead. Unlike the report job
123+
# below it has no token to lose, so it runs for fork PRs too, and unlike the report job it is
122124
# allowed to fail the build.
123125
#
124126
# Gated to this package's own paths rather than running on every route pull request. What the
125127
# corpus measures is the TOOL's resistance to laundering, and only an edit to the tool can weaken
126-
# that, so a routes-only change was paying four and a half minutes of a 4x runner for a result
127-
# that could not differ from the last one. It was also the worst kind of job to spend that on: a
128-
# red x that fires on a large share of webapp pull requests, is allowed to fail, and gates
129-
# nothing, which is the shape people learn to scroll past.
128+
# that, so a routes-only change was paying five minutes of a 4x runner for a result that could
129+
# not differ from the last one. It was also the worst kind of job to spend that on: a red x that
130+
# fires on a large share of webapp pull requests, is allowed to fail, and gates nothing, which is
131+
# the shape people learn to scroll past.
130132
#
131133
# What this gives up is real and small. A route landing a shape no corpus entry has seen can make
132134
# a known laundering mutation start paying, and that is now caught by the nightly rather than by
133135
# the pull request that caused it. Tree drift accrues over months, so a day is the right
134136
# granularity for it; the tool's own regressions, which are the ones a single commit can cause,
135137
# still gate per pull request.
138+
#
139+
# Nothing in this repo watches whether the nightly itself succeeds: no Slack webhook and no
140+
# issue-on-failure step here or in e2e-webapp-auth-full.yml, the only other scheduled workflow, so
141+
# there is no house pattern to follow. A broken corpus fails quietly on the 3am cron, red only in
142+
# the Actions tab,
143+
# for as long as nobody checks it. Wiring up a real notification needs infrastructure (a Slack
144+
# webhook secret, at minimum) that does not exist here yet, so this is a known, unfixed gap
145+
# rather than a fixed one.
136146
mutation-corpus:
137147
name: 🧬 Mutation corpus
138148
needs: changes

.github/workflows/pr_checks.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,16 @@ jobs:
104104
# `unit-tests-internal.yml` runs `turbo run test --filter "@internal/*"`, which picks up
105105
# @internal/observability-map and runs the same vitest suite. Listing them here as well
106106
# ran the suite twice on every PR touching them, which was this filter's own doing.
107+
#
108+
# Also deliberately NOT here: pr_checks.yml, package.json, pnpm-lock.yaml,
109+
# pnpm-workspace.yaml. `internal` already lists all four, so a PR touching only one of
110+
# them ran this suite twice for the same reason as above. Editing pr_checks.yml no
111+
# longer runs this job live as a result; integration.test.ts still asserts on its text
112+
# via the `internal` job.
107113
obsmap:
108114
- 'apps/webapp/app/**'
109-
- '.github/workflows/pr_checks.yml'
110115
- '.github/workflows/unit-tests-observability-map.yml'
111116
- '.github/workflows/observability-map.yml'
112-
- 'package.json'
113-
- 'pnpm-lock.yaml'
114-
- 'pnpm-workspace.yaml'
115117
cli:
116118
- 'packages/cli-v3/**'
117119
- 'packages/build/**'

internal-packages/observability-map/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,13 @@ A pull request touching `apps/webapp/app/routes` or this package gets a sticky c
3131
against the tip of the base branch, with the score, what changed, and the current fix list. Every
3232
comment names the head commit it was rendered for, as a link to the compare range, because the
3333
comment is edited in place across pushes and otherwise says nothing about which push it reflects. It
34-
is report-only: nothing here fails the build or blocks a merge. See
35-
`.github/workflows/observability-map.yml`.
34+
is report-only: nothing in the comment or its score fails the build or blocks a merge. Separately,
35+
this package's test suite runs as a required check, on pull requests touching `apps/webapp/app` and
36+
on any other package through the internal job, and fails when a symbol the tool references stops
37+
resolving in one of the three trees it reads (`apps/webapp/app`, `packages/plugins/src`,
38+
`internal-packages/rbac/src`), or when the first route with an anticipated sensitive segment lands.
39+
Each failure names the list to update (`src/webappSymbols.test.ts`).
40+
See `.github/workflows/observability-map.yml`.
3641

3742
The workflow runs on every pull request and applies the path list as a gate inside the job rather
3843
than as a `paths:` filter on the trigger. GitHub evaluates one of those per workflow, so a pull
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// webappSymbols.test.ts's self-test tree. One name per declaration form, never two forms of the same
2+
// name: a single `has` assertion cannot say which branch of the walker answered it, so a shared name
3+
// would let the member branch be deleted with the fixture test still green. `signJWT` is read and
4+
// declared nowhere, so a walker that collected references as declarations would find it.
5+
export function helper(kind: string): boolean {
6+
const createJWT = kind === "jwt";
7+
return createJWT;
8+
}
9+
10+
export const api = {
11+
mintSessionToken: false,
12+
};
13+
14+
export function reads(payload: Record<string, unknown>): unknown {
15+
return payload.signJWT;
16+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Only the file name matters: `routeSegments` reads directory entries, never content.
2+
export {};

internal-packages/observability-map/src/integration.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ describe("the report workflow's one source of the comment id", () => {
128128
expect(lookup).toBeDefined();
129129
expect(lookup).toContain("exit }'");
130130
});
131+
132+
it("only reconciles a comment github-actions[bot] posted, not anyone quoting the marker", () => {
133+
const lookup = steps(job("changes")).find((step) =>
134+
step.includes('startswith("<!-- observability-map')
135+
)!;
136+
expect(lookup).toContain('.user.login == "github-actions[bot]"');
137+
});
131138
});
132139

133140
/**
@@ -257,6 +264,32 @@ describe("the package's tests are wired into the gate", () => {
257264
).toContain('--filter "@internal/*"');
258265
});
259266

267+
// The test above only checks the package's own source path, a different overlap that was already
268+
// fixed. It has no way to catch a shared *generic* path (package.json, a lockfile, this workflow
269+
// file itself) added to both filters, which is its own way to run the suite twice. Asserted as the
270+
// actual set intersection, not another hardcoded path, so any future shared path fails this too.
271+
it("shares no path with the internal filter, so the suite runs once", () => {
272+
const text = read(PR_CHECKS);
273+
// Comment lines are dropped before matching: an apostrophe in prose ("this filter's own doing")
274+
// otherwise pairs with a real path's quote and swallows it, which would be a silent false pass.
275+
const pathsOf = (name: string, next: string) =>
276+
new Set(
277+
[
278+
...text
279+
.split(` ${name}:`)[1]!
280+
.split(` ${next}:`)[0]!
281+
.split("\n")
282+
.filter((line) => !line.trim().startsWith("#"))
283+
.join("\n")
284+
.matchAll(/'([^']+)'/g),
285+
].map((m) => m[1])
286+
);
287+
const internal = pathsOf("internal", "obsmap");
288+
const obsmap = pathsOf("obsmap", "cli");
289+
const shared = [...obsmap].filter((p) => internal.has(p));
290+
expect(shared).toEqual([]);
291+
});
292+
260293
it("is in the all-checks needs list, or it gates nothing", () => {
261294
const needs = read(PR_CHECKS).split(" needs:").pop()!.split(" if: always()")[0]!;
262295
expect(needs).toContain("- obsmap");

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,10 +432,15 @@ describe("renderPrComment", () => {
432432
expect(renderPrComment(head, null)).not.toContain("Warning: parse failures");
433433
});
434434

435-
it("footer names the report-only rule and the readme", () => {
435+
it("footer names the report-only rule, the required suite that is not it, and the readme", () => {
436436
const head = buildReport([scanFile("api.v1.a.ts", cleanSource)!], []);
437437
const out = renderPrComment(head, null);
438-
expect(out).toContain("Report only, nothing here gates the merge.");
438+
expect(out).toContain("report-only and never gate the merge");
439+
expect(out).toContain("a required test suite");
440+
// Both directions. A footer naming only the rename case sends the author who added the first
441+
// `secrets` route looking for a rename they never made.
442+
expect(out).toContain("renames or removes a symbol");
443+
expect(out).toContain("adds the first route with a segment");
439444
expect(out).toContain("internal-packages/observability-map/README.md");
440445
});
441446
});

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ export type CommitContext = {
2525

2626
const SHORT_SHA_LENGTH = 7;
2727

28+
/**
29+
* Closes every comment this job posts. The second sentence is there because the package's own test
30+
* suite does gate webapp pull requests, and a blocked author's first stop is this footer, which used
31+
* to tell them nothing gates anything.
32+
*/
33+
const FOOTER =
34+
"The score and findings here are report-only and never gate the merge. Separately, a required " +
35+
"test suite keeps this tool's symbol and route lists in sync with the code they name, and can " +
36+
"fail a pull request that renames or removes a symbol they reference, or that adds the first " +
37+
"route with a segment they anticipate. Each failure names the list to edit. The rules and their " +
38+
"reasons: internal-packages/observability-map/README.md.";
39+
2840
/** Directly under the heading, because the comment is edited in place across pushes and the first
2941
* question about it is which push it reflects. */
3042
function commitLines(commit: CommitContext | undefined): string[] {
@@ -255,8 +267,7 @@ export function renderResolvedComment(commit?: CommitContext): string {
255267
"Nothing in this pull request moves the report any more. The findings an earlier push " +
256268
"reported are gone.",
257269
"",
258-
"Report only, nothing here gates the merge. The rules and their reasons: " +
259-
"internal-packages/observability-map/README.md.",
270+
FOOTER,
260271
].join("\n");
261272
}
262273

@@ -275,8 +286,7 @@ export function renderScanFailedComment(commit?: CommitContext): string {
275286
"The scan failed for this run, so there is no report. Anything above is from an earlier push " +
276287
"and is stale. The workflow log has the error.",
277288
"",
278-
"Report only, nothing here gates the merge. The rules and their reasons: " +
279-
"internal-packages/observability-map/README.md.",
289+
FOOTER,
280290
].join("\n");
281291
}
282292

@@ -319,10 +329,7 @@ export function renderPrComment(
319329
lines.push("```", "", "</details>", "");
320330
}
321331

322-
lines.push(
323-
"Report only, nothing here gates the merge. The rules and their reasons: " +
324-
"internal-packages/observability-map/README.md."
325-
);
332+
lines.push(FOOTER);
326333

327334
const headFailures = head.parseFailures.length;
328335
const baseFailures = base?.parseFailures.length ?? 0;

internal-packages/observability-map/src/webappSymbols.test.ts

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const MAX_EXTERNAL_GUARDS = 2;
5353

5454
const ROUTES = resolve(REPO, "apps/webapp/app/routes");
5555

56+
/** A tree this package owns, for proving the walkers can answer no. Proving that on the live tree
57+
* meant asserting nobody in the webapp ever declares certain names, even as a local variable. */
58+
const FIXTURES = resolve(__dirname, "../fixtures/webappSymbols");
59+
5660
function walkFiles(dir: string, out: string[] = []): string[] {
5761
for (const entry of readdirSync(dir, { withFileTypes: true })) {
5862
const path = join(dir, entry.name);
@@ -62,7 +66,7 @@ function walkFiles(dir: string, out: string[] = []): string[] {
6266
return out;
6367
}
6468

65-
function declaredNames(): Set<string> {
69+
function declaredNames(roots: string[]): Set<string> {
6670
const names = new Set<string>();
6771
const addBinding = (name: ts.BindingName) => {
6872
if (ts.isIdentifier(name)) {
@@ -74,7 +78,7 @@ function declaredNames(): Set<string> {
7478
}
7579
};
7680

77-
const files = ROOTS.flatMap((root) => walkFiles(root));
81+
const files = roots.flatMap((root) => walkFiles(root));
7882
for (const file of files) {
7983
const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, false);
8084
const visit = (node: ts.Node) => {
@@ -108,9 +112,9 @@ function declaredNames(): Set<string> {
108112
}
109113

110114
/** Every dot-separated piece of every route name, flat file or directory, e.g. `billing-limits`. */
111-
function routeSegments(): Set<string> {
115+
function routeSegments(dir: string): Set<string> {
112116
const segments = new Set<string>();
113-
for (const entry of readdirSync(ROUTES, { withFileTypes: true })) {
117+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
114118
for (const part of entry.name.replace(/\.tsx?$/, "").split(".")) {
115119
// `sensitivity.ts`'s own normalizer. This validates the vocabulary that file matches on, so
116120
// a segment has to be trimmed here exactly as it is trimmed there; the local `/_+$/` was
@@ -122,29 +126,41 @@ function routeSegments(): Set<string> {
122126
}
123127

124128
describe("the names the tool matches on exist in the webapp", () => {
125-
const declared = declaredNames();
126-
const segments = routeSegments();
129+
const declared = declaredNames(ROOTS);
130+
const segments = routeSegments(ROUTES);
127131

128132
it("found a codebase to check against", () => {
129133
expect(declared.size).toBeGreaterThan(5000);
130134
expect(segments.size).toBeGreaterThan(100);
131135
});
132136

133137
it("every sensitive symbol is declared somewhere", () => {
134-
expect(SENSITIVE_SYMBOLS.filter((s) => !declared.has(s))).toEqual([]);
138+
expect(
139+
SENSITIVE_SYMBOLS.filter((s) => !declared.has(s)),
140+
"renamed or removed? update SENSITIVE_SYMBOLS in " +
141+
"internal-packages/observability-map/src/sensitivity.ts in the same PR"
142+
).toEqual([]);
135143
});
136144

137145
// The list this test did not cover, and it had rotted completely: all three of `auditLog`,
138146
// `recordAudit` and `writeAuditEvent` were exported nowhere, so `audit-trail`'s pass branch could
139147
// not fire and the report said "No audit helper exists in the webapp" while
140148
// `models/admin.server.ts` was writing `impersonationAuditLog` rows on two paths.
141149
it("every audit symbol is declared somewhere", () => {
142-
expect(AUDIT_SYMBOLS.filter((s) => !declared.has(s))).toEqual([]);
150+
expect(
151+
AUDIT_SYMBOLS.filter((s) => !declared.has(s)),
152+
"renamed or removed? update AUDIT_SYMBOLS in " +
153+
"internal-packages/observability-map/src/checks/auditTrail.ts in the same PR"
154+
).toEqual([]);
143155
});
144156

145157
it("every auth guard is declared somewhere, or is a listed dependency method", () => {
146158
const names = [...GUARDS, ...SOFT_GUARDS];
147-
expect(names.filter((g) => !declared.has(g) && !EXTERNAL_GUARDS.has(g))).toEqual([]);
159+
expect(
160+
names.filter((g) => !declared.has(g) && !EXTERNAL_GUARDS.has(g)),
161+
"renamed or removed? update GUARDS or SOFT_GUARDS in " +
162+
"internal-packages/observability-map/src/checks/authBoundary.ts in the same PR"
163+
).toEqual([]);
148164
});
149165

150166
// The escape hatch is only worth having while it stays small.
@@ -155,25 +171,40 @@ describe("the names the tool matches on exist in the webapp", () => {
155171

156172
it("every sensitive path segment names a real route segment", () => {
157173
const live = SENSITIVE_SEGMENTS.filter((s) => !ANTICIPATED_SEGMENTS.includes(s));
158-
expect(live.filter((s) => !segments.has(s))).toEqual([]);
174+
expect(
175+
live.filter((s) => !segments.has(s)),
176+
"renamed or removed the last route with this segment? update SENSITIVE_SEGMENTS in " +
177+
"internal-packages/observability-map/src/sensitivity.ts in the same PR"
178+
).toEqual([]);
159179
});
160180

161-
// The escape hatch is only worth having while it is small and honest about itself.
181+
// The escape hatch is only worth having while it is small and honest about itself. Kept required
182+
// rather than moved to the nightly on purpose: the PR adding the first such route is the one whose
183+
// author knows the route exists, and the segment is scored sensitive either way.
162184
it("every anticipated segment really does name nothing yet", () => {
163-
expect(ANTICIPATED_SEGMENTS.filter((s) => segments.has(s))).toEqual([]);
185+
expect(
186+
ANTICIPATED_SEGMENTS.filter((s) => segments.has(s)),
187+
"added the first route with this segment? move it from ANTICIPATED_SEGMENTS into the live " +
188+
"list in internal-packages/observability-map/src/sensitivity.ts in the same PR"
189+
).toEqual([]);
164190
});
165191

166-
// The checker has to be able to fail. These run the same predicates over the names the last round
167-
// shipped, which is what the test exists to have caught.
168-
it("would reject the symbols that named nothing", () => {
169-
for (const dead of ["setImpersonation", "createJWT", "signJWT", "updateEnvVars"]) {
170-
expect(declared.has(dead)).toBe(false);
171-
}
192+
// The checker has to be able to fail. Proven on the fixture tree rather than the live one, which
193+
// is where the earlier version of these tests asserted that no webapp file declares `createJWT`
194+
// even as a local variable, and failed this suite on any pull request that did.
195+
it("finds a fixture name however it is declared, and rejects one that is only read", () => {
196+
const names = declaredNames([join(FIXTURES, "app")]);
197+
// A name per declaration form. Sharing one across both would pass on either branch alone, and
198+
// the member branch is the one with no other cover: several guards are reached through an
199+
// object, so deleting it fails only the live-tree assertions this fixture exists to replace.
200+
expect(names.has("createJWT")).toBe(true);
201+
expect(names.has("mintSessionToken")).toBe(true);
202+
expect(names.has("signJWT")).toBe(false);
172203
});
173204

174-
it("would reject a guard name and a path segment that name nothing", () => {
175-
expect(declared.has("requireNothingAtAll")).toBe(false);
176-
expect(EXTERNAL_GUARDS.has("requireNothingAtAll")).toBe(false);
177-
expect(segments.has("no-such-route-segment")).toBe(false);
205+
it("finds a fixture route segment, and rejects the segment's own substring", () => {
206+
const fixtureSegments = routeSegments(join(FIXTURES, "routes"));
207+
expect(fixtureSegments.has("secrets")).toBe(true);
208+
expect(fixtureSegments.has("secret")).toBe(false);
178209
});
179210
});

0 commit comments

Comments
 (0)