@burdenoff/fe-libs 2026.725.4 → 2026.726.1
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 +8 -2
- package/scripts/graphql-schema-drift/README.md +137 -0
- package/scripts/graphql-schema-drift/cli.ts +83 -0
- package/scripts/graphql-schema-drift/extract.ts +95 -0
- package/scripts/graphql-schema-drift/fragments.ts +92 -0
- package/scripts/graphql-schema-drift/fs-util.ts +22 -0
- package/scripts/graphql-schema-drift/index.ts +11 -0
- package/scripts/graphql-schema-drift/schema.ts +42 -0
- package/scripts/graphql-schema-drift/types.ts +73 -0
- package/scripts/graphql-schema-drift/validate.ts +159 -0
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/fe-libs",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.726.1",
|
|
4
4
|
"description": "Burdenoff frontend primitives and domain libraries",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"graphql-schema-drift": "./scripts/graphql-schema-drift/cli.ts"
|
|
8
|
+
},
|
|
6
9
|
"imports": {
|
|
7
10
|
"#lib/*": "./src/lib/*",
|
|
8
11
|
"#ui/*": "./src/ui/*",
|
|
@@ -265,6 +268,8 @@
|
|
|
265
268
|
"src/shared/styles/**/*",
|
|
266
269
|
"src/shared/shims/**/*",
|
|
267
270
|
"src/vite/**/*",
|
|
271
|
+
"scripts/graphql-schema-drift/*.ts",
|
|
272
|
+
"scripts/graphql-schema-drift/README.md",
|
|
268
273
|
"README.md",
|
|
269
274
|
"LICENSE"
|
|
270
275
|
],
|
|
@@ -275,7 +280,8 @@
|
|
|
275
280
|
"lint:sanity": "if [ \"${CI:-}\" = \"true\" ]; then bun run lint; else bun run lint -- --cache --cache-location .eslintcache; fi",
|
|
276
281
|
"format": "prettier --config shared/config/prettier.config.mjs --write \"src/**/*.{ts,tsx}\" \"!src/config/*.config.ts\"",
|
|
277
282
|
"type:check": "tsc --noEmit",
|
|
278
|
-
"
|
|
283
|
+
"test": "bun test scripts/graphql-schema-drift/__tests__",
|
|
284
|
+
"sanity": "bun run lint:sanity && bun run format && bun run type:check && bun run test && bun run build",
|
|
279
285
|
"switch:status": "bun scripts/deps-mode.ts status",
|
|
280
286
|
"switch:remote": "bun scripts/deps-mode.ts remote",
|
|
281
287
|
"switch:local": "bun scripts/deps-mode.ts local",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# graphql-schema-drift
|
|
2
|
+
|
|
3
|
+
Shared PR-time guard that validates an MFE's **inline** GraphQL query /
|
|
4
|
+
mutation / subscription documents (hand-rolled template literals, `gql`-tagged
|
|
5
|
+
or not) against a configured schema snapshot, and fails the build on any
|
|
6
|
+
mismatch.
|
|
7
|
+
|
|
8
|
+
## Why this exists (BOFF-3922)
|
|
9
|
+
|
|
10
|
+
MFEs that write GraphQL as `.graphql` files under `src/operations/**` already
|
|
11
|
+
get this check for free — `graphql-codegen`'s `documents:` glob parses and
|
|
12
|
+
validates those against `schema:` as part of generating types. But several
|
|
13
|
+
MFEs (and more will follow) hand-roll queries as plain template literals
|
|
14
|
+
inside `.ts`/`.tsx` hooks — those never touch `documents:`, so `codegen`
|
|
15
|
+
silently no-ops on them (`ignoreNoDocuments: true`), and nothing else in CI
|
|
16
|
+
looks at them. A backend rename or a flat-list→Connection pagination change
|
|
17
|
+
then breaks the MFE in prod with zero CI signal. This happened three times
|
|
18
|
+
independently before this tool existed:
|
|
19
|
+
|
|
20
|
+
- **BOFF-3915** (labsofscience) — root incident that opened BOFF-3922.
|
|
21
|
+
- **BOFF-4703** (manufacturedops) — `useDowntimeEvents`/`useInspectionPlans`.
|
|
22
|
+
- **BOFF-3529** (planmagnet) — 44 pages' inline `gql` blocks post field-rename.
|
|
23
|
+
- **movethewheels** `useOrders.ts` (2026-07-25) — hand-rolled queries against
|
|
24
|
+
the pre-rename `Order`/`orderStats` schema, weeks after the backend renamed
|
|
25
|
+
the domain to `DeliveryJob`.
|
|
26
|
+
|
|
27
|
+
manufacturedops and planmagnet each independently wrote a bespoke
|
|
28
|
+
`scripts/validate-schema-drift.ts` to guard against a repeat. This package
|
|
29
|
+
generalizes both into one configurable tool so future repos don't reinvent it
|
|
30
|
+
a fourth, fifth, sixth time.
|
|
31
|
+
|
|
32
|
+
## What it does NOT (yet) do
|
|
33
|
+
|
|
34
|
+
- **Doesn't check `.graphql`-file-based MFEs.** Those already get schema
|
|
35
|
+
validation from `bun run codegen` today. What they're missing is a
|
|
36
|
+
guarantee that the _committed schema snapshot itself_ is fresh — codegen
|
|
37
|
+
will happily validate a correct-looking query against a stale local
|
|
38
|
+
`graphql/wspace.graphql` and pass. That's a different problem (snapshot
|
|
39
|
+
freshness, not missing validation) and needs a different fix — see
|
|
40
|
+
"Fleet-wide follow-up" below.
|
|
41
|
+
- **Doesn't fetch schema from Hive CDN.** Every `schemaGroups[].files` entry
|
|
42
|
+
is a file already committed to the consuming repo. This avoids needing new
|
|
43
|
+
CI secrets to land the tool in its first 3 repos, but it means the schema
|
|
44
|
+
source can itself go stale (exactly the manufacturedops/planmagnet root
|
|
45
|
+
cause) unless someone keeps it refreshed. See "Fleet-wide follow-up".
|
|
46
|
+
- **Doesn't check the version an app-shell actually pins.** BOFF-3922 also
|
|
47
|
+
flags that a shell can pin an MFE version older than the deployed schema.
|
|
48
|
+
Out of scope here — this only checks the repo's own HEAD.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
1. Add `@burdenoff/fe-libs` as a dependency (every MFE already has it).
|
|
53
|
+
2. Create `schema-drift.config.json` at the repo root:
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
{
|
|
57
|
+
"sourceRoots": [{ "dir": "src/myproduct" }],
|
|
58
|
+
"fragmentDirs": ["src/operations/wspace/myproduct/fragments"],
|
|
59
|
+
"schemaGroups": [
|
|
60
|
+
{ "name": "subgraph", "files": ["graphql/myproduct.graphql"] },
|
|
61
|
+
{ "name": "gateway-fallback", "files": ["graphql/wspace.graphql"] },
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
3. Add a script and wire it into `sanity`:
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
"scripts": {
|
|
70
|
+
"validate:schema-drift": "graphql-schema-drift",
|
|
71
|
+
"sanity": "bun run codegen && bun run validate:schema-drift && bun run lint:sanity && bun run format && bun run type:check && bun run build"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
4. Keep the referenced schema file(s) fresh — same discipline as
|
|
76
|
+
`graphql/wspace.graphql` / `graphql/global.graphql` already require. E.g.:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cp ../../wspace/myproduct/wspace-myproduct-svc/.hive-schema.graphql graphql/myproduct.graphql
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Config reference
|
|
83
|
+
|
|
84
|
+
See `types.ts` for the full, documented shape (`SchemaDriftConfig`). Summary:
|
|
85
|
+
|
|
86
|
+
| Field | Purpose |
|
|
87
|
+
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
88
|
+
| `sourceRoots[]` | Dirs scanned recursively for inline `query`/`mutation`/`subscription`/`fragment` template literals. |
|
|
89
|
+
| `fragmentDirs[]` | Optional dirs of standalone `.graphql`/`.gql` fragment files, resolved for `...Spread`s (in addition to inline `fragment` blocks, which are always collected automatically). |
|
|
90
|
+
| `schemaGroups[]` | ≥1 named schema targets. A document is clean if it validates against **any one** group — this covers both "try my subgraph, fall back to the gateway snapshot for foreign root fields" (multiple groups) and "concatenate a base SDL + an `extend type Query` roots file into one schema" (`mergeExtends` within one group). |
|
|
91
|
+
| `treatUnresolvedInterpolationAsError` | Default `true`. An operation with a `${...}` this tool can't resolve against a same-file `const` fails loudly rather than silently validating a truncated document. |
|
|
92
|
+
|
|
93
|
+
## Extraction rules
|
|
94
|
+
|
|
95
|
+
- Matches backtick blocks whose content starts with `query`, `mutation`,
|
|
96
|
+
`subscription`, or `fragment` — with or without a preceding `gql` tag.
|
|
97
|
+
- `${NAME}` interpolations are expanded against same-file top-level
|
|
98
|
+
`const NAME = \`...\`;` constants (transitively, up to 10 passes).
|
|
99
|
+
- `fragment ... on ... { }` blocks (inline or from `fragmentDirs`) are pooled
|
|
100
|
+
and spliced in wherever `...FragmentName` is spread, transitively.
|
|
101
|
+
|
|
102
|
+
## Fleet-wide follow-up (not done in this pass)
|
|
103
|
+
|
|
104
|
+
BOFF-3922 asks for a fleet-wide rollout across all 63 `microfe-*` repos. That
|
|
105
|
+
is deliberately **not** attempted here — this PR proves the tool on 3 real
|
|
106
|
+
repos (2 migrations off bespoke scripts + 1 fresh installation covering a
|
|
107
|
+
guard gap that a real incident had already exposed) and leaves the rest as
|
|
108
|
+
scoped follow-up:
|
|
109
|
+
|
|
110
|
+
1. **Wire into the remaining MFEs with inline/hand-rolled documents.** Audit
|
|
111
|
+
the fleet for `.ts`/`.tsx` files containing bare `` `query `` / `` `mutation ``
|
|
112
|
+
backtick blocks outside any `documents:` glob (grep
|
|
113
|
+
``grep -rlE '`\s*(query|mutation)\s' src --include=*.ts --include=*.tsx``
|
|
114
|
+
per repo) and add a `schema-drift.config.json` + wire into `sanity` for
|
|
115
|
+
each hit.
|
|
116
|
+
2. **Solve schema-source freshness**, either by:
|
|
117
|
+
- a scheduled job per repo that re-runs `schema:pull`-equivalent and opens
|
|
118
|
+
a PR when the snapshot drifts from the live subgraph, or
|
|
119
|
+
- a `schemaGroups[].source: 'hive-cdn'` mode that fetches
|
|
120
|
+
`https://cdn.graphql-hive.com/artifacts/v1/{target}/sdl` with
|
|
121
|
+
`X-Hive-CDN-Key` at validation time instead of reading a committed file
|
|
122
|
+
(precedent: `microfe-groups/.graphqlrc.yml`'s `schema-wspace` codegen
|
|
123
|
+
project already pulls schema this way, just not wired to this tool).
|
|
124
|
+
This needs a CDN token provisioned per repo/CI — real effort, not a
|
|
125
|
+
one-line change.
|
|
126
|
+
3. **Extend coverage to `.graphql`-file-based MFEs** (the majority of the
|
|
127
|
+
fleet) once (2) is solved — at that point the value-add over what codegen
|
|
128
|
+
already does is real (freshness), not just redundant re-validation of the
|
|
129
|
+
same local file codegen already checked.
|
|
130
|
+
4. **Pin-aware validation** — validate the _published_ MFE version an
|
|
131
|
+
app-shell pins, not just the repo's own HEAD, per the original ticket's
|
|
132
|
+
"worth considering" list.
|
|
133
|
+
|
|
134
|
+
## Reference incidents this tool is proof against
|
|
135
|
+
|
|
136
|
+
Run `bun test scripts/graphql-schema-drift/__tests__` in this repo for the
|
|
137
|
+
unit-test coverage of the extraction/fragment/schema-group logic itself.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* graphql-schema-drift — shared CI guard that validates an MFE's inline
|
|
4
|
+
* GraphQL query/mutation/subscription documents against its own configured
|
|
5
|
+
* schema snapshot(s), catching the class of bug where a backend rename or
|
|
6
|
+
* pagination change silently breaks a hand-rolled template-literal query that
|
|
7
|
+
* `graphql-codegen` never sees (BOFF-3922; incidents BOFF-3915, BOFF-4703,
|
|
8
|
+
* BOFF-3529, and the movethewheels useOrders.ts rewrite of 2026-07-25).
|
|
9
|
+
*
|
|
10
|
+
* Usage (from a consuming MFE repo root):
|
|
11
|
+
* bun run node_modules/@burdenoff/fe-libs/scripts/graphql-schema-drift/cli.ts [configPath]
|
|
12
|
+
* or, since fe-libs declares this as a package `bin`:
|
|
13
|
+
* graphql-schema-drift [configPath]
|
|
14
|
+
*
|
|
15
|
+
* `configPath` defaults to `./schema-drift.config.json`. See README.md in
|
|
16
|
+
* this directory for the config shape and worked examples.
|
|
17
|
+
*
|
|
18
|
+
* Exit code: 0 if every extracted document validates cleanly, 1 otherwise.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
import { runValidation } from './validate';
|
|
24
|
+
import type { SchemaDriftConfig } from './types';
|
|
25
|
+
|
|
26
|
+
function main(): void {
|
|
27
|
+
const configPathArg = process.argv[2] ?? 'schema-drift.config.json';
|
|
28
|
+
const repoRoot = process.cwd();
|
|
29
|
+
const configPath = resolve(repoRoot, configPathArg);
|
|
30
|
+
|
|
31
|
+
if (!existsSync(configPath)) {
|
|
32
|
+
console.error(`[graphql-schema-drift] Config not found: ${configPath}`);
|
|
33
|
+
console.error(
|
|
34
|
+
'Create a schema-drift.config.json at your repo root (or pass a path as the first argument). ' +
|
|
35
|
+
'See @burdenoff/fe-libs/scripts/graphql-schema-drift/README.md for the config shape.'
|
|
36
|
+
);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let config: SchemaDriftConfig;
|
|
41
|
+
try {
|
|
42
|
+
config = JSON.parse(readFileSync(configPath, 'utf8')) as SchemaDriftConfig;
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error(`[graphql-schema-drift] Failed to parse ${configPath}: ${(e as Error).message}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let report: ReturnType<typeof runValidation>;
|
|
50
|
+
try {
|
|
51
|
+
report = runValidation(config, repoRoot);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error(`[graphql-schema-drift] ${(e as Error).message}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (report.errors.length > 0) {
|
|
59
|
+
const fileCount = new Set(report.errors.map((e) => e.file)).size;
|
|
60
|
+
console.error(
|
|
61
|
+
`\n[graphql-schema-drift] FAILED — ${report.errors.length} operation(s) with schema drift across ${fileCount} file(s):\n`
|
|
62
|
+
);
|
|
63
|
+
for (const err of report.errors) {
|
|
64
|
+
const label = err.operationName ?? `block #${err.index}`;
|
|
65
|
+
console.error(` ${err.file} :: ${label}`);
|
|
66
|
+
for (const msg of err.messages) {
|
|
67
|
+
console.error(` - ${msg}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
console.error(
|
|
71
|
+
'\nEach of these operations must validate against at least one schema group configured in ' +
|
|
72
|
+
'schema-drift.config.json. Either fix the query/mutation to match the current schema, or refresh ' +
|
|
73
|
+
'the schema snapshot file(s) referenced there if the schema itself changed.\n'
|
|
74
|
+
);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(
|
|
79
|
+
`[graphql-schema-drift] OK — validated ${report.documentCount} operation(s) across ${report.fileCount} source file(s). 0 errors.`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extraction of inline GraphQL documents from `.ts`/`.tsx` source.
|
|
3
|
+
*
|
|
4
|
+
* Generalizes two patterns proven in production incidents (BOFF-4703
|
|
5
|
+
* manufacturedops, BOFF-3529 planmagnet):
|
|
6
|
+
* - bare backtick template literals: `const LIST = \`query ListX { ... }\`;`
|
|
7
|
+
* - `gql`-tagged template literals: `` gql`query ListX { ... }` ``
|
|
8
|
+
*
|
|
9
|
+
* Both are matched by the same regex — the extractor only cares that the
|
|
10
|
+
* backtick content starts with `query` / `mutation` / `subscription` /
|
|
11
|
+
* `fragment`; whether it's preceded by a `gql` tag is irrelevant to whether
|
|
12
|
+
* the document is real GraphQL that must validate against the schema.
|
|
13
|
+
*
|
|
14
|
+
* Same-file top-level `const NAME = \`...\`;` string constants are collected
|
|
15
|
+
* and used to expand `${NAME}` interpolations (the manufacturedops
|
|
16
|
+
* `*_FIELDS` selection-set fragment pattern) before parsing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Max passes when expanding `${NAME}` interpolations; guards against cyclic consts. */
|
|
20
|
+
const MAX_INTERPOLATION_DEPTH = 10;
|
|
21
|
+
|
|
22
|
+
const DOC_BLOCK_RE = /`(\s*(?:query|mutation|subscription|fragment)\s[\s\S]*?)`/g;
|
|
23
|
+
const CONST_RE = /const\s+([A-Za-z_$][\w$]*)\s*=\s*`([^`]*)`/g;
|
|
24
|
+
const INTERPOLATION_RE = /\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Collect `const NAME = `...`;` single-backtick string constants from a source
|
|
28
|
+
* file — the selection-set fragments this codebase interpolates into
|
|
29
|
+
* operations. Constants containing a nested backtick are intentionally not
|
|
30
|
+
* matched (same limitation as the original per-repo scripts this generalizes).
|
|
31
|
+
*/
|
|
32
|
+
export function collectStringConstants(source: string): Map<string, string> {
|
|
33
|
+
const consts = new Map<string, string>();
|
|
34
|
+
let match: RegExpExecArray | null;
|
|
35
|
+
CONST_RE.lastIndex = 0;
|
|
36
|
+
while ((match = CONST_RE.exec(source)) !== null) {
|
|
37
|
+
consts.set(match[1], match[2]);
|
|
38
|
+
}
|
|
39
|
+
return consts;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Replace `${NAME}` with the corresponding constant, transitively. */
|
|
43
|
+
export function expandInterpolations(body: string, consts: ReadonlyMap<string, string>): string {
|
|
44
|
+
let current = body;
|
|
45
|
+
for (let pass = 0; pass < MAX_INTERPOLATION_DEPTH; pass++) {
|
|
46
|
+
if (!current.includes('${')) return current;
|
|
47
|
+
const next = current.replace(INTERPOLATION_RE, (whole, name: string) =>
|
|
48
|
+
consts.has(name) ? (consts.get(name) as string) : whole
|
|
49
|
+
);
|
|
50
|
+
if (next === current) return current;
|
|
51
|
+
current = next;
|
|
52
|
+
}
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Names of any `${...}` interpolations remaining after expansion. */
|
|
57
|
+
export function collectUnresolvedInterpolations(body: string): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
let match: RegExpExecArray | null;
|
|
60
|
+
const re = new RegExp(INTERPOLATION_RE.source, 'g');
|
|
61
|
+
while ((match = re.exec(body)) !== null) {
|
|
62
|
+
out.push(match[1]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ExtractedBlock {
|
|
68
|
+
file: string;
|
|
69
|
+
index: number;
|
|
70
|
+
/** `false` for a `fragment ... on ...` block — those are collected as fragments, not validated standalone. */
|
|
71
|
+
isFragment: boolean;
|
|
72
|
+
/** Fully `${NAME}`-expanded source text. */
|
|
73
|
+
source: string;
|
|
74
|
+
unresolved: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract every backtick block that looks like a GraphQL document (operation
|
|
79
|
+
* or fragment) from a single source file's text, expanding same-file
|
|
80
|
+
* `${NAME}` constant interpolations along the way.
|
|
81
|
+
*/
|
|
82
|
+
export function extractBlocks(source: string, file: string): ExtractedBlock[] {
|
|
83
|
+
const consts = collectStringConstants(source);
|
|
84
|
+
const blocks: ExtractedBlock[] = [];
|
|
85
|
+
let match: RegExpExecArray | null;
|
|
86
|
+
const re = new RegExp(DOC_BLOCK_RE.source, 'g');
|
|
87
|
+
let index = 0;
|
|
88
|
+
while ((match = re.exec(source)) !== null) {
|
|
89
|
+
const expanded = expandInterpolations(match[1], consts);
|
|
90
|
+
const unresolved = [...new Set(collectUnresolvedInterpolations(expanded))];
|
|
91
|
+
const isFragment = /^\s*fragment\s/.test(expanded);
|
|
92
|
+
blocks.push({ file, index: index++, isFragment, source: expanded, unresolved });
|
|
93
|
+
}
|
|
94
|
+
return blocks;
|
|
95
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
Kind,
|
|
5
|
+
parse,
|
|
6
|
+
type DefinitionNode,
|
|
7
|
+
type DocumentNode,
|
|
8
|
+
type FragmentDefinitionNode,
|
|
9
|
+
} from 'graphql';
|
|
10
|
+
import { safeListFilesRecursive } from './fs-util';
|
|
11
|
+
|
|
12
|
+
/** Load every `fragment X on Y { ... }` definition from standalone `.graphql`/`.gql` files under `dirs`. */
|
|
13
|
+
export function loadFragmentDefinitionsFromDirs(
|
|
14
|
+
dirs: readonly string[],
|
|
15
|
+
repoRoot: string
|
|
16
|
+
): Map<string, FragmentDefinitionNode> {
|
|
17
|
+
const map = new Map<string, FragmentDefinitionNode>();
|
|
18
|
+
for (const dir of dirs) {
|
|
19
|
+
const absDir = join(repoRoot, dir);
|
|
20
|
+
for (const file of safeListFilesRecursive(absDir, ['.graphql', '.gql'])) {
|
|
21
|
+
const doc = parse(readFileSync(file, 'utf8'));
|
|
22
|
+
for (const def of doc.definitions) {
|
|
23
|
+
if (def.kind === Kind.FRAGMENT_DEFINITION) {
|
|
24
|
+
map.set(def.name.value, def);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Collect the transitive set of fragment names spread anywhere within a set of definitions. */
|
|
33
|
+
export function collectSpreadNames(
|
|
34
|
+
definitions: readonly DefinitionNode[],
|
|
35
|
+
found: Set<string> = new Set()
|
|
36
|
+
): Set<string> {
|
|
37
|
+
const visit = (node: unknown): void => {
|
|
38
|
+
if (!node || typeof node !== 'object') return;
|
|
39
|
+
if (Array.isArray(node)) {
|
|
40
|
+
for (const item of node) visit(item);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const obj = node as { kind?: string; name?: { value?: string } };
|
|
44
|
+
if (obj.kind === Kind.FRAGMENT_SPREAD && obj.name?.value) {
|
|
45
|
+
found.add(obj.name.value);
|
|
46
|
+
}
|
|
47
|
+
for (const key of Object.keys(obj)) {
|
|
48
|
+
if (key === 'loc') continue;
|
|
49
|
+
visit((obj as Record<string, unknown>)[key]);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
for (const def of definitions) visit(def);
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Given a parsed document and a pool of known fragment definitions (from
|
|
58
|
+
* inline `fragment` blocks and/or standalone fragment files), return a new
|
|
59
|
+
* document with every transitively-spread fragment's definition appended.
|
|
60
|
+
* A spread whose name isn't found in `fragmentPool` is left unresolved —
|
|
61
|
+
* `validate()` will correctly report it as an "Unknown fragment" error.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveFragments(
|
|
64
|
+
doc: DocumentNode,
|
|
65
|
+
fragmentPool: ReadonlyMap<string, FragmentDefinitionNode>
|
|
66
|
+
): DocumentNode {
|
|
67
|
+
const needed = new Set<string>();
|
|
68
|
+
let frontier = collectSpreadNames(doc.definitions);
|
|
69
|
+
while (frontier.size > 0) {
|
|
70
|
+
const next = new Set<string>();
|
|
71
|
+
for (const name of frontier) {
|
|
72
|
+
if (needed.has(name)) continue;
|
|
73
|
+
needed.add(name);
|
|
74
|
+
const fragDef = fragmentPool.get(name);
|
|
75
|
+
if (fragDef) {
|
|
76
|
+
for (const nested of collectSpreadNames([fragDef])) {
|
|
77
|
+
if (!needed.has(nested)) next.add(nested);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
frontier = next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const extraDefs: DefinitionNode[] = [];
|
|
85
|
+
for (const name of needed) {
|
|
86
|
+
const fragDef = fragmentPool.get(name);
|
|
87
|
+
if (fragDef) extraDefs.push(fragDef);
|
|
88
|
+
}
|
|
89
|
+
if (extraDefs.length === 0) return doc;
|
|
90
|
+
|
|
91
|
+
return { ...doc, definitions: [...doc.definitions, ...extraDefs] };
|
|
92
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Recursively list files under `dir` whose name ends with one of `suffixes`. */
|
|
5
|
+
export function listFilesRecursive(dir: string, suffixes: readonly string[]): string[] {
|
|
6
|
+
const out: string[] = [];
|
|
7
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
8
|
+
const full = join(dir, entry.name);
|
|
9
|
+
if (entry.isDirectory()) {
|
|
10
|
+
out.push(...listFilesRecursive(full, suffixes));
|
|
11
|
+
} else if (suffixes.some((s) => entry.name.endsWith(s))) {
|
|
12
|
+
out.push(full);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Same as `listFilesRecursive`, but returns `[]` instead of throwing when `dir` doesn't exist. */
|
|
19
|
+
export function safeListFilesRecursive(dir: string, suffixes: readonly string[]): string[] {
|
|
20
|
+
if (!existsSync(dir)) return [];
|
|
21
|
+
return listFilesRecursive(dir, suffixes);
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Programmatic entry point — primarily for tests. CLI usage is via cli.ts. */
|
|
2
|
+
export {
|
|
3
|
+
collectStringConstants,
|
|
4
|
+
expandInterpolations,
|
|
5
|
+
extractBlocks,
|
|
6
|
+
type ExtractedBlock,
|
|
7
|
+
} from './extract';
|
|
8
|
+
export { collectSpreadNames, loadFragmentDefinitionsFromDirs, resolveFragments } from './fragments';
|
|
9
|
+
export { loadSchemaGroup, loadSchemaGroups, type LoadedSchemaGroup } from './schema';
|
|
10
|
+
export { runValidation, type DriftError, type DriftReport } from './validate';
|
|
11
|
+
export type { SchemaDriftConfig, SchemaGroupConfig, SourceRootConfig } from './types';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildSchema, type GraphQLSchema } from 'graphql';
|
|
4
|
+
import type { SchemaGroupConfig } from './types';
|
|
5
|
+
|
|
6
|
+
const EXTEND_RE = /\bextend\s+(type|interface)\b/g;
|
|
7
|
+
|
|
8
|
+
export interface LoadedSchemaGroup {
|
|
9
|
+
name: string;
|
|
10
|
+
schema: GraphQLSchema;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build one `GraphQLSchema` from a group's SDL file(s). Files are concatenated
|
|
15
|
+
* in the order given. When `mergeExtends` is set, `extend type X` / `extend
|
|
16
|
+
* interface X` in every file is rewritten to `type X` / `interface X` first —
|
|
17
|
+
* this lets a file that declares `extend type Query` against root fields only
|
|
18
|
+
* present in the composed supergraph (not in the group's own base SDL) build
|
|
19
|
+
* as a single self-contained schema instead of requiring the extend target to
|
|
20
|
+
* pre-exist.
|
|
21
|
+
*/
|
|
22
|
+
export function loadSchemaGroup(group: SchemaGroupConfig, repoRoot: string): LoadedSchemaGroup {
|
|
23
|
+
const parts = group.files.map((file) => {
|
|
24
|
+
const text = readFileSync(join(repoRoot, file), 'utf8');
|
|
25
|
+
return group.mergeExtends ? text.replace(EXTEND_RE, '$1') : text;
|
|
26
|
+
});
|
|
27
|
+
const sdl = parts.join('\n\n');
|
|
28
|
+
return {
|
|
29
|
+
name: group.name,
|
|
30
|
+
schema: buildSchema(sdl, { assumeValidSDL: true, assumeValid: true }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadSchemaGroups(
|
|
35
|
+
groups: readonly SchemaGroupConfig[],
|
|
36
|
+
repoRoot: string
|
|
37
|
+
): LoadedSchemaGroup[] {
|
|
38
|
+
if (groups.length === 0) {
|
|
39
|
+
throw new Error('schema-drift.config.json must declare at least one entry in schemaGroups.');
|
|
40
|
+
}
|
|
41
|
+
return groups.map((g) => loadSchemaGroup(g, repoRoot));
|
|
42
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config shape for the shared MFE GraphQL schema-drift validator (BOFF-3922).
|
|
3
|
+
*
|
|
4
|
+
* One `schema-drift.config.json` lives at the root of a consuming MFE repo.
|
|
5
|
+
* See README.md in this directory for the full guide and worked examples.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A named set of schema SDL file(s) that form one validation target.
|
|
10
|
+
*
|
|
11
|
+
* A document is considered clean if it validates against **at least one**
|
|
12
|
+
* configured group (fallback semantics — this is how a repo whose components
|
|
13
|
+
* legitimately call root fields owned by a different subgraph, e.g. a shared
|
|
14
|
+
* TagPicker calling wspace-tags-svc fields, can validate against its own
|
|
15
|
+
* subgraph first and the composed gateway snapshot second).
|
|
16
|
+
*
|
|
17
|
+
* Multiple `files` within the SAME group are concatenated into one schema
|
|
18
|
+
* (this is how a subgraph's base types + a separately-published gateway-roots
|
|
19
|
+
* extension file are validated as a single schema — see `mergeExtends`).
|
|
20
|
+
*/
|
|
21
|
+
export interface SchemaGroupConfig {
|
|
22
|
+
/** Human-readable label used only in error output. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** SDL file paths, relative to the repo root (the config file's directory). */
|
|
25
|
+
files: string[];
|
|
26
|
+
/**
|
|
27
|
+
* When true, `extend type X` / `extend interface X` in every file of this
|
|
28
|
+
* group is rewritten to a plain `type X` / `interface X` before the files
|
|
29
|
+
* are concatenated. Use this when one file declares `extend type Query`
|
|
30
|
+
* against root fields that only exist in the composed supergraph (not in
|
|
31
|
+
* the base SDL file itself) — without the rewrite, `buildSchema` would
|
|
32
|
+
* reject the `extend` because it has nothing to extend.
|
|
33
|
+
*/
|
|
34
|
+
mergeExtends?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A directory of `.ts`/`.tsx` source files to scan for inline GraphQL documents. */
|
|
38
|
+
export interface SourceRootConfig {
|
|
39
|
+
/** Directory path, relative to the repo root. Scanned recursively. */
|
|
40
|
+
dir: string;
|
|
41
|
+
/** File extensions to scan. Defaults to `['.ts', '.tsx']`. */
|
|
42
|
+
extensions?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SchemaDriftConfig {
|
|
46
|
+
/**
|
|
47
|
+
* Directories to scan for inline GraphQL operations — template literals
|
|
48
|
+
* (optionally `gql`-tagged) whose content starts with `query`, `mutation`,
|
|
49
|
+
* or `subscription`. This is the primary extraction mode: it is what
|
|
50
|
+
* catches drift in hand-rolled documents that `graphql-codegen` never sees
|
|
51
|
+
* because they aren't `.graphql` files on its `documents:` glob.
|
|
52
|
+
*/
|
|
53
|
+
sourceRoots: SourceRootConfig[];
|
|
54
|
+
/**
|
|
55
|
+
* Extra directories of standalone `.graphql`/`.gql` fragment files to
|
|
56
|
+
* resolve `...FragmentName` spreads against, in addition to any `fragment`
|
|
57
|
+
* blocks found inline within `sourceRoots` (those are always collected
|
|
58
|
+
* automatically). Optional — omit if the repo has no separate fragment
|
|
59
|
+
* files.
|
|
60
|
+
*/
|
|
61
|
+
fragmentDirs?: string[];
|
|
62
|
+
/** One or more schema groups. A document must validate against at least one. */
|
|
63
|
+
schemaGroups: SchemaGroupConfig[];
|
|
64
|
+
/**
|
|
65
|
+
* When an inline operation contains an unresolved `${...}` interpolation
|
|
66
|
+
* (i.e. not a same-file top-level `const NAME = \`...\`` this tool can
|
|
67
|
+
* expand), fail loudly instead of silently stripping it — a stripped
|
|
68
|
+
* interpolation can hide the exact selection set that would actually
|
|
69
|
+
* reveal drift. Defaults to `true`. Only set `false` if a repo has a
|
|
70
|
+
* verified reason every such interpolation is validation-irrelevant.
|
|
71
|
+
*/
|
|
72
|
+
treatUnresolvedInterpolationAsError?: boolean;
|
|
73
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Kind, parse, validate, type DocumentNode, type FragmentDefinitionNode } from 'graphql';
|
|
4
|
+
import { extractBlocks } from './extract';
|
|
5
|
+
import { safeListFilesRecursive } from './fs-util';
|
|
6
|
+
import { loadFragmentDefinitionsFromDirs, resolveFragments } from './fragments';
|
|
7
|
+
import { loadSchemaGroups } from './schema';
|
|
8
|
+
import type { SchemaDriftConfig } from './types';
|
|
9
|
+
|
|
10
|
+
export interface DriftError {
|
|
11
|
+
file: string;
|
|
12
|
+
index: number;
|
|
13
|
+
operationName: string | null;
|
|
14
|
+
messages: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DriftReport {
|
|
18
|
+
errors: DriftError[];
|
|
19
|
+
/** Count of operation documents actually validated (fragment-only blocks are not counted). */
|
|
20
|
+
documentCount: number;
|
|
21
|
+
fileCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getOperationName(doc: DocumentNode): string | null {
|
|
25
|
+
for (const def of doc.definitions) {
|
|
26
|
+
if (def.kind === Kind.OPERATION_DEFINITION || def.kind === Kind.FRAGMENT_DEFINITION) {
|
|
27
|
+
return def.name?.value ?? null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run the configured extraction + validation pipeline against a repo.
|
|
35
|
+
*
|
|
36
|
+
* `repoRoot` is the directory `schema-drift.config.json` lives in — all
|
|
37
|
+
* paths in the config (`sourceRoots[].dir`, `fragmentDirs`,
|
|
38
|
+
* `schemaGroups[].files`) are resolved relative to it.
|
|
39
|
+
*/
|
|
40
|
+
export function runValidation(config: SchemaDriftConfig, repoRoot: string): DriftReport {
|
|
41
|
+
const schemaGroups = loadSchemaGroups(config.schemaGroups, repoRoot);
|
|
42
|
+
const treatUnresolvedAsError = config.treatUnresolvedInterpolationAsError ?? true;
|
|
43
|
+
|
|
44
|
+
// Pass 1: scan every configured source file once, extracting both operation
|
|
45
|
+
// blocks and inline `fragment` blocks. Inline fragments are pooled globally
|
|
46
|
+
// (a fragment defined in one file is commonly spread from a hook/component
|
|
47
|
+
// in another within the same MFE).
|
|
48
|
+
const sourceFiles: string[] = [];
|
|
49
|
+
for (const root of config.sourceRoots) {
|
|
50
|
+
const extensions = root.extensions ?? ['.ts', '.tsx'];
|
|
51
|
+
sourceFiles.push(...safeListFilesRecursive(join(repoRoot, root.dir), extensions));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const inlineFragmentPool = new Map<string, FragmentDefinitionNode>();
|
|
55
|
+
const pendingOperations: Array<{
|
|
56
|
+
relFile: string;
|
|
57
|
+
index: number;
|
|
58
|
+
source: string;
|
|
59
|
+
unresolved: string[];
|
|
60
|
+
}> = [];
|
|
61
|
+
|
|
62
|
+
for (const file of sourceFiles) {
|
|
63
|
+
const relFile = file.slice(repoRoot.length + 1);
|
|
64
|
+
const text = readFileSync(file, 'utf8');
|
|
65
|
+
for (const block of extractBlocks(text, relFile)) {
|
|
66
|
+
if (block.isFragment) {
|
|
67
|
+
if (block.unresolved.length > 0) continue; // can't parse a fragment with unresolved interpolation; surfaces via any spread that needs it
|
|
68
|
+
try {
|
|
69
|
+
const fragDoc = parse(block.source);
|
|
70
|
+
for (const def of fragDoc.definitions) {
|
|
71
|
+
if (def.kind === Kind.FRAGMENT_DEFINITION) inlineFragmentPool.set(def.name.value, def);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Malformed inline fragment: fall through silently here — if it's
|
|
75
|
+
// actually spread anywhere, the consuming operation will fail to
|
|
76
|
+
// resolve the fragment name and be reported as an error there.
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
pendingOperations.push({
|
|
81
|
+
relFile,
|
|
82
|
+
index: block.index,
|
|
83
|
+
source: block.source,
|
|
84
|
+
unresolved: block.unresolved,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fileFragmentPool = config.fragmentDirs
|
|
90
|
+
? loadFragmentDefinitionsFromDirs(config.fragmentDirs, repoRoot)
|
|
91
|
+
: new Map<string, FragmentDefinitionNode>();
|
|
92
|
+
const fragmentPool = new Map<string, FragmentDefinitionNode>([
|
|
93
|
+
...fileFragmentPool,
|
|
94
|
+
...inlineFragmentPool,
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
// Pass 2: parse + resolve fragments + validate every operation block against
|
|
98
|
+
// every schema group, in order. A document is clean if ANY group accepts it.
|
|
99
|
+
const errors: DriftError[] = [];
|
|
100
|
+
let documentCount = 0;
|
|
101
|
+
|
|
102
|
+
for (const op of pendingOperations) {
|
|
103
|
+
documentCount++;
|
|
104
|
+
|
|
105
|
+
if (op.unresolved.length > 0 && treatUnresolvedAsError) {
|
|
106
|
+
errors.push({
|
|
107
|
+
file: op.relFile,
|
|
108
|
+
index: op.index,
|
|
109
|
+
operationName: null,
|
|
110
|
+
messages: [
|
|
111
|
+
`Unresolved interpolation(s): ${[...new Set(op.unresolved)].join(', ')}. Define them as ` +
|
|
112
|
+
`top-level backtick string constants in the same file so this operation can be validated, ` +
|
|
113
|
+
`or set "treatUnresolvedInterpolationAsError": false in schema-drift.config.json if this is ` +
|
|
114
|
+
`verified to be validation-irrelevant.`,
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let doc: DocumentNode;
|
|
121
|
+
try {
|
|
122
|
+
doc = parse(op.source);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
errors.push({
|
|
125
|
+
file: op.relFile,
|
|
126
|
+
index: op.index,
|
|
127
|
+
operationName: null,
|
|
128
|
+
messages: [`Parse error: ${(e as Error).message}`],
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const resolvedDoc = resolveFragments(doc, fragmentPool);
|
|
134
|
+
|
|
135
|
+
let bestErrors: string[] | null = null;
|
|
136
|
+
let passed = false;
|
|
137
|
+
for (const group of schemaGroups) {
|
|
138
|
+
const groupErrors = validate(group.schema, resolvedDoc);
|
|
139
|
+
if (groupErrors.length === 0) {
|
|
140
|
+
passed = true;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
// Report the FIRST group's errors as the actionable ones if nothing passes —
|
|
144
|
+
// it's the group most likely to be this document's "home" schema.
|
|
145
|
+
if (bestErrors === null) bestErrors = groupErrors.map((e) => e.message);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!passed) {
|
|
149
|
+
errors.push({
|
|
150
|
+
file: op.relFile,
|
|
151
|
+
index: op.index,
|
|
152
|
+
operationName: getOperationName(doc),
|
|
153
|
+
messages: bestErrors ?? ['No schema group accepted this document.'],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { errors, documentCount, fileCount: sourceFiles.length };
|
|
159
|
+
}
|