@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 CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@burdenoff/fe-libs",
3
- "version": "2026.725.4",
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
- "sanity": "bun run lint:sanity && bun run format && bun run type:check && bun run build",
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
+ }