Skip to content

Commit 707dae1

Browse files
authored
fix(tooling): docs-drift stops flagging docs for tests-only changes (#4091)
`affected-docs.mjs` derived changed-package roots from every file under `packages/`, tests included. A test observes behaviour rather than defining it, so it cannot make an implementation-accuracy doc stale — yet every tests-only PR lit up its packages' whole doc set. Three in a row did it (#4064, #4078 and one before), each flagging 6 packages/services docs for a diff with no production code. This is the one place the mapper's deliberate over-inclusion actively hurt: a category that is ALWAYS false teaches the reader to skip the comment, and then it fails on the PR where it is right. - Test files are dropped before the roots are derived: *.test.* / *.spec.* at any depth (covering .integration.test.ts and .conformance.test.ts) plus __tests__ / __mocks__ / __fixtures__. - The narrowing is NOT silent — the excluded count rides the summary line and `testFilesSkipped` in --json. - `--self-test` pins the matcher (the check-error-code-casing / check-route-envelope convention), run by the workflow before it computes the mapping. - The workflow also triggers on the mapper's own paths, so editing it runs its own guard. CI surfaced that gap: on the first push the drift job never ran at all, which meant the self-test had been added but was structurally unreachable by the change most likely to break it. Verified against real history: 46e86ba (tests only) → 0 docs, was 6; 4965bfa (engine.ts + formula) → the same 11 docs as before; --all unchanged at 178. Swapping the anchored match for a naive path.includes('test') fails 7 self-test cases, including control-flow.zod.ts and latest.ts being swallowed as tests.
1 parent ea24593 commit 707dae1

4 files changed

Lines changed: 141 additions & 4 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
---
3+
4+
Tooling only — no package changes, nothing to release.
5+
6+
The docs-drift mapper (`scripts/docs-audit/affected-docs.mjs`) no longer treats a
7+
test-file change as a reason to flag docs. A test observes behaviour rather than
8+
defining it, so it cannot make an implementation-accuracy doc stale — yet every
9+
tests-only PR lit up its packages' whole doc set. Three in a row (#4064, #4078, and one
10+
before) each flagged 6 `packages/services` docs for a diff containing no production
11+
code.
12+
13+
That is the one place the mapper's deliberate over-inclusion actively hurt: a comment a
14+
reader learns to skip stops doing its job on the PR where it is right.
15+
16+
- Test files are dropped before the changed-package roots are derived: `*.test.*` /
17+
`*.spec.*` at any depth, and anything under `__tests__` / `__mocks__` / `__fixtures__`.
18+
- The narrowing is **not silent** — the excluded count appears in the summary line and
19+
as `testFilesSkipped` in `--json`.
20+
- A `--self-test` flag pins the matcher, following the convention of
21+
`check-error-code-casing` / `check-route-envelope`, and the drift workflow now runs it
22+
before computing the mapping. Verified to catch the dangerous direction: replacing the
23+
anchored match with a naive substring check fails 7 cases, including
24+
`control-flow.zod.ts` and `latest.ts` being swallowed as "tests".
25+
26+
Verified against real history: the tests-only commit `46e86bad` (#4078) now maps to 0
27+
docs where it previously reported 6, while the implementation commit `4965bfac` (#4059)
28+
still maps to the same 11 docs it did before.

.github/workflows/docs-drift-check.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ on:
1111
types: [opened, synchronize, reopened]
1212
paths:
1313
- 'packages/**'
14+
# The mapper and this workflow, so a change to the guard runs the guard. Without
15+
# these, editing `affected-docs.mjs` was the one change its own self-test could
16+
# never see — a `packages/**`-only trigger means the tool is unguarded exactly
17+
# when it is being modified. A PR that touches only these gets the benign
18+
# "0 changed package(s) ✅" comment, which is the correct answer.
19+
- 'scripts/docs-audit/**'
20+
- '.github/workflows/docs-drift-check.yml'
1421

1522
permissions:
1623
contents: read
@@ -34,6 +41,13 @@ jobs:
3441
- name: Fetch base branch
3542
run: git fetch --no-tags origin "${{ github.base_ref }}"
3643

44+
# The mapper excludes test files (a test cannot make an implementation doc
45+
# stale). Self-test first, so a regression that widened the exclusion into
46+
# dropping real implementation changes fails loudly here instead of turning
47+
# this whole comment quietly empty.
48+
- name: Self-test the change → docs mapper
49+
run: node scripts/docs-audit/affected-docs.mjs --self-test
50+
3751
- name: Compute affected docs
3852
id: affected
3953
run: |

scripts/docs-audit/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,26 @@ node scripts/docs-audit/affected-docs.mjs --json origin/main
2121

2222
# every hand-written doc (full audit scope)
2323
node scripts/docs-audit/affected-docs.mjs --all
24+
25+
# check the test-file matcher (needs no repo state; CI runs this before the mapping)
26+
node scripts/docs-audit/affected-docs.mjs --self-test
2427
```
2528

2629
Heuristic: a doc is *affected* by a changed package `P` if it mentions `P`'s npm
2730
name (`@objectstack/<x>`) or repo path (`packages/<x>`). Over-inclusion is preferred
2831
over misses; the periodic **full** audit (part 4) is the backstop for docs that
2932
describe a package without naming it.
3033

34+
**One exclusion:** changes to **test files** are dropped before the changed-package
35+
roots are derived. A test observes behaviour rather than defining it, so it cannot make
36+
an implementation-accuracy doc stale — yet counting them made every tests-only PR light
37+
up its packages' whole doc set, a class of finding that is always false. That is the one
38+
place over-inclusion actively hurt: a comment a reader learns to skip stops working on
39+
the PR where it is right. The count of excluded files is reported in the summary and as
40+
`testFilesSkipped` in `--json`, so the narrowing is never silent, and `--self-test`
41+
pins the matcher against paths that must and must not match (`commands/test.ts` is
42+
implementation; `foo.conformance.test.ts` is not).
43+
3144
## 2. CI gate — `.github/workflows/docs-drift-check.yml`
3245

3346
On any PR that touches `packages/**`, runs `affected-docs.mjs` against the base branch

scripts/docs-audit/affected-docs.mjs

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// node scripts/docs-audit/affected-docs.mjs [sinceRef] # docs affected by changes since <sinceRef> (default origin/main)
88
// node scripts/docs-audit/affected-docs.mjs --all # every hand-written doc (full audit)
99
// node scripts/docs-audit/affected-docs.mjs --json [...] # emit JSON {docs, changedPackages, ...} instead of a path list
10+
// node scripts/docs-audit/affected-docs.mjs --self-test # check the test-file matcher (no repo state needed)
1011
//
1112
// Scope: hand-written docs only = content/docs/**/*.mdx MINUS content/docs/references/**
1213
// (references are generated from packages/spec and handled by a separate regenerate pass).
@@ -15,6 +16,14 @@
1516
// npm name (`@objectstack/<x>`) or its repo path (`packages/<x>`). Over-inclusion is
1617
// intentionally preferred over misses; the periodic FULL audit is the backstop for
1718
// docs that describe a package without naming it.
19+
//
20+
// One exclusion, though: a change to a TEST file cannot make an implementation-accuracy
21+
// doc stale, because tests do not define behaviour — they observe it. Counting them made
22+
// every tests-only PR light up its packages' whole doc set (three in a row on #4064 /
23+
// #4078 / one before), which is a class of finding that is always false. A reader who
24+
// learns the comment is usually noise stops reading it, and then it fails to do its job
25+
// on the PR where it is right. So test files are dropped before deriving the changed
26+
// package roots; everything else stays deliberately over-inclusive.
1827

1928
import { execSync } from 'node:child_process';
2029
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
@@ -26,6 +35,12 @@ const asJson = args.includes('--json');
2635
const all = args.includes('--all');
2736
const sinceRef = args.find((a) => !a.startsWith('--')) || 'origin/main';
2837

38+
// Short-circuit before any git work — the self-test needs no repo state.
39+
if (args.includes('--self-test')) {
40+
selfTest();
41+
process.exit(0);
42+
}
43+
2944
function sh(cmd) {
3045
return execSync(cmd, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
3146
}
@@ -61,9 +76,63 @@ try {
6176
changedFiles = sh(`git diff --name-only ${sinceRef} -- packages/`).split('\n').filter(Boolean);
6277
}
6378

79+
/**
80+
* A test file — it observes behaviour rather than defining it, so changing one cannot
81+
* make an implementation-accuracy doc stale. Covers the repo's conventions: `*.test.*`
82+
* / `*.spec.*` at any depth (including `.integration.test.ts` and `.conformance.test.ts`)
83+
* plus anything under a `__tests__` / `__mocks__` / `__fixtures__` directory.
84+
*
85+
* Verify with `--self-test`.
86+
*/
87+
function isTestFile(path) {
88+
return /(^|\/)__(tests|mocks|fixtures)__\//.test(path)
89+
|| /(^|\/)[^/]+\.(test|spec)\.[^/]+$/.test(path);
90+
}
91+
92+
/**
93+
* Check the test-file matcher against known-good and known-bad paths, so the
94+
* exclusion cannot silently widen into dropping real implementation changes — the
95+
* one way this optimisation could turn into a miss.
96+
*/
97+
function selfTest() {
98+
const cases = [
99+
// [path, isTest, label]
100+
['packages/services/service-automation/src/builtin/config-schemas.test.ts', true, 'plain .test.ts'],
101+
['packages/rest/src/package-envelope.conformance.test.ts', true, 'compound .conformance.test.ts'],
102+
['packages/services/service-automation/src/runas-grant-resolution.integration.test.ts', true, '.integration.test.ts'],
103+
['packages/spec/src/data/object.spec.ts', true, '.spec.ts'],
104+
['packages/foo/src/__tests__/helper.ts', true, 'helper inside __tests__'],
105+
['packages/foo/src/__mocks__/driver.ts', true, '__mocks__'],
106+
['packages/foo/src/__fixtures__/stack.json', true, '__fixtures__'],
107+
108+
['packages/services/service-automation/src/engine.ts', false, 'implementation'],
109+
['packages/spec/src/automation/control-flow.zod.ts', false, 'a zod schema'],
110+
['packages/formula/src/validate.ts', false, 'implementation with a test-ish name'],
111+
['packages/cli/src/commands/test.ts', false, 'a command NAMED test is not a test file'],
112+
['packages/qa/src/testing.ts', false, 'testing.ts is implementation'],
113+
['packages/spec/src/latest.ts', false, 'no false positive on a bare name'],
114+
['packages/foo/src/tests-helper.ts', false, 'tests-helper is not __tests__'],
115+
];
116+
let failed = 0;
117+
for (const [path, want, label] of cases) {
118+
const got = isTestFile(path);
119+
if (got !== want) {
120+
console.error(` ✗ self-test "${label}": ${path} → expected isTestFile=${want}, got ${got}`);
121+
failed++;
122+
}
123+
}
124+
if (failed) {
125+
console.error(`\n✗ affected-docs self-test failed (${failed} case(s)).`);
126+
process.exit(1);
127+
}
128+
console.log(`✓ affected-docs self-test: ${cases.length} cases pass.`);
129+
}
130+
131+
64132
// collect package roots: packages/<x> and packages/plugins/<x>
65133
const pkgRoots = new Set();
66-
for (const f of changedFiles) {
134+
const implementationChanges = changedFiles.filter((f) => !isTestFile(f));
135+
for (const f of implementationChanges) {
67136
let m = f.match(/^(packages\/plugins\/[^/]+)\//) || f.match(/^(packages\/[^/]+)\//);
68137
if (m) pkgRoots.add(m[1]);
69138
}
@@ -91,11 +160,24 @@ for (const doc of handwritten) {
91160
if (hits.length) affected.push({ doc, via: [...new Set(hits)] });
92161
}
93162

94-
emit(affected.map((a) => a.doc), changedPackages, `${affected.length} docs affected by ${changedPackages.length} changed package(s) since ${sinceRef}`, affected);
163+
// Report what was excluded rather than dropping it silently — a tool that quietly
164+
// narrows its own scope reads as "nothing to see here" when it means "I did not look".
165+
const testFilesSkipped = changedFiles.length - implementationChanges.length;
166+
const skipNote = testFilesSkipped > 0
167+
? ` (${testFilesSkipped} test file(s) excluded — tests cannot make an implementation doc stale)`
168+
: '';
169+
170+
emit(
171+
affected.map((a) => a.doc),
172+
changedPackages,
173+
`${affected.length} docs affected by ${changedPackages.length} changed package(s) since ${sinceRef}${skipNote}`,
174+
affected,
175+
testFilesSkipped,
176+
);
95177

96-
function emit(docList, changedPackages, summary, detail) {
178+
function emit(docList, changedPackages, summary, detail, testFilesSkipped = 0) {
97179
if (asJson) {
98-
process.stdout.write(JSON.stringify({ summary, sinceRef: all ? null : sinceRef, changedPackages, docs: docList, detail: detail || null }, null, 2) + '\n');
180+
process.stdout.write(JSON.stringify({ summary, sinceRef: all ? null : sinceRef, changedPackages, docs: docList, detail: detail || null, testFilesSkipped }, null, 2) + '\n');
99181
} else {
100182
process.stderr.write(`# ${summary}\n`);
101183
process.stdout.write(docList.join('\n') + (docList.length ? '\n' : ''));

0 commit comments

Comments
 (0)