diff --git a/.agents/skills/qa-engineer-manual/scenarios/has-stories/stories/blog-index_3.json b/.agents/skills/qa-engineer-manual/scenarios/has-stories/stories/blog-index_3.json index 3087ad6c2..270eaaeea 100644 --- a/.agents/skills/qa-engineer-manual/scenarios/has-stories/stories/blog-index_3.json +++ b/.agents/skills/qa-engineer-manual/scenarios/has-stories/stories/blog-index_3.json @@ -4,16 +4,5 @@ "name": "Blog", "slug": "blog-index", "full_slug": "blog-index", - "is_folder": true, - "content": { - "component": "page", - "headline": "Blog", - "hero_image": { - "id": 1, - "fieldtype": "asset", - "filename": "https://placeholder.example.com/hero.png", - "alt": "Blog hero" - }, - "seo_description": "Our blog" - } + "is_folder": true } diff --git a/packages/cli/src/commands/schema/affected/README.md b/packages/cli/src/commands/schema/affected/README.md new file mode 100644 index 000000000..000d897ec --- /dev/null +++ b/packages/cli/src/commands/schema/affected/README.md @@ -0,0 +1,53 @@ +# Schema Affected Command + +The `schema affected` command reports which stories a pending schema change affects and which would +break. It is a read-only dry run of `schema push`: it diffs your local code-first schema against the +remote space, then checks every story that uses a changed component for breakage. + +## Basic Usage + +Point the command at your schema entry file and target space: + +```bash +storyblok schema affected ./schema/index.ts --space YOUR_SPACE_ID +``` + +The summary lists each impacted component with the number of stories affected, how many would break, +and the field-level change that causes it: + +``` +hero (changed): 3 stories affected, 3 would break + - badge (required_added): present in 0 stories, 3 would break + +Totals: 3 stories affected across 1 component; 3 would break. +``` + +Gate a CI pipeline by failing the run when any story would break: + +```bash +storyblok schema affected ./schema/index.ts --space YOUR_SPACE_ID --fail-on-break +``` + +## Options + +| Option | Description | Default | +| --------------------- | ------------------------------------------------------------------------------------------------ | ------------ | +| `-s, --space ` | (Required) The ID of the space to diff against | - | +| `-p, --path ` | Base path where local stories are read from with `--local` (stories live under `/stories`) | `.storyblok` | +| `--local` | Analyze locally pulled stories instead of fetching from the space | `false` | +| `--include-deleted` | Treat remote-only components as deleted, mirroring `schema push --delete` | `false` | +| `--fail-on-break` | Exit with a non-zero code when any story would break, for CI gating | `false` | + +## Notes + +- The analysis covers field-structural changes (field add, remove, type change, newly required + fields) and component deletion (`--include-deleted`). It does not flag nested allow-list or + reference constraint changes, because Storyblok tolerates orphaned nested bloks, so existing + content is not broken by those changes. +- Breakage is diffed against both the old and new schema, so only errors the change introduces are + counted. Pre-existing invalid content is not misattributed. +- By default the command fetches only the stories that use an impacted component (as a nested blok + or as their root content type) directly from the space. Use `--local` to analyze already-pulled + story JSON instead. Run `storyblok stories pull --space YOUR_SPACE_ID` first. +- The full per-story and per-field detail is written to the standard command report file when + reporting is enabled (`--report-enabled`). diff --git a/packages/cli/src/commands/schema/affected/actions.test.ts b/packages/cli/src/commands/schema/affected/actions.test.ts new file mode 100644 index 000000000..9afd095ec --- /dev/null +++ b/packages/cli/src/commands/schema/affected/actions.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vitest"; + +import type { Component, Story } from "../../../types"; +import type { DiffResult } from "../types"; +import type { ComponentBreakingChanges } from "../migrations/types"; +import { toSchemaLike } from "../to-schema-like"; +import { + aggregate, + analyzeStory, + computeImpactedComponents, + createAnalyzeContext, + type ImpactedMap, +} from "./actions"; + +function makeComponent(name: string, schema: Record>): Component { + return { + id: 1, + name, + created_at: "", + updated_at: "", + is_root: false, + is_nestable: true, + schema, + } as unknown as Component; +} + +function makeStory(overrides: Partial & { content: unknown }): Story { + return { id: 1, uuid: "u", name: "Story", full_slug: "story", ...overrides } as unknown as Story; +} + +describe("computeImpactedComponents", () => { + const emptyDiff: DiffResult = { diffs: [], creates: 0, updates: 0, unchanged: 0, stale: 0 }; + + it("should mark components with breaking changes as update", () => { + const breaking: ComponentBreakingChanges[] = [ + { componentName: "hero", changes: [{ kind: "removed", field: "subtitle" }] }, + ]; + const impacted = computeImpactedComponents(emptyDiff, breaking); + + expect(impacted.get("hero")).toMatchObject({ action: "update" }); + expect(impacted.get("hero")?.fields[0]).toMatchObject({ + field: "subtitle", + contentKey: "subtitle", + kind: "removed", + }); + }); + + it("should mark stale components as removed only with withDelete", () => { + const diff: DiffResult = { + diffs: [ + { + type: "component", + name: "teaser", + action: "stale", + diff: null, + local: null, + remote: null, + }, + ], + creates: 0, + updates: 0, + unchanged: 0, + stale: 1, + }; + + expect(computeImpactedComponents(diff, []).has("teaser")).toBe(false); + expect(computeImpactedComponents(diff, [], { withDelete: true }).get("teaser")).toMatchObject({ + action: "removed", + }); + }); + + it("should map a rename content key to the old field name", () => { + const breaking: ComponentBreakingChanges[] = [ + { + componentName: "hero", + changes: [{ kind: "rename", field: "headline", oldField: "title" }], + }, + ]; + const impacted = computeImpactedComponents(emptyDiff, breaking); + + expect(impacted.get("hero")?.fields[0]).toMatchObject({ + field: "headline", + contentKey: "title", + kind: "rename", + }); + }); +}); + +describe("analyzeStory", () => { + it("should flag a removed field as a warning, not broken", () => { + const oldSchema = toSchemaLike([ + makeComponent("hero", { title: { type: "text" }, subtitle: { type: "text" } }), + ]); + const newSchema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]); + const impacted: ImpactedMap = new Map([ + [ + "hero", + { + component: "hero", + action: "update", + fields: [{ field: "subtitle", contentKey: "subtitle", kind: "removed" }], + }, + ], + ]); + const story = makeStory({ + content: { _uid: "r", component: "hero", title: "Hi", subtitle: "gone" }, + }); + + const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema)); + + expect(result?.components).toEqual(["hero"]); + expect(result?.broken).toBe(false); + expect(result?.usedFields).toEqual([{ component: "hero", field: "subtitle" }]); + expect(result?.issues.some((i) => i.code === "unknown_field" && i.severity === "warning")).toBe( + true, + ); + }); + + it("should flag a newly required field as broken", () => { + const oldSchema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]); + const newSchema = toSchemaLike([ + makeComponent("hero", { title: { type: "text", required: true } }), + ]); + const impacted: ImpactedMap = new Map([ + [ + "hero", + { + component: "hero", + action: "update", + fields: [{ field: "title", contentKey: "title", kind: "required_added" }], + }, + ], + ]); + const story = makeStory({ content: { _uid: "r", component: "hero" } }); + + const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema)); + + expect(result?.broken).toBe(true); + expect( + result?.issues.some( + (i) => i.component === "hero" && i.field === "title" && i.severity === "error", + ), + ).toBe(true); + }); + + it("should not count pre-existing errors as change-induced breakage", () => { + // `title` is required in both schemas; the story already violates it, so the + // analyzed change (adding required `subtitle`) must not blame `title`. + const oldSchema = toSchemaLike([ + makeComponent("hero", { title: { type: "text", required: true } }), + ]); + const newSchema = toSchemaLike([ + makeComponent("hero", { + title: { type: "text", required: true }, + subtitle: { type: "text" }, + }), + ]); + const impacted: ImpactedMap = new Map([ + [ + "hero", + { + component: "hero", + action: "update", + fields: [{ field: "subtitle", contentKey: "subtitle", kind: "required_added" }], + }, + ], + ]); + const story = makeStory({ content: { _uid: "r", component: "hero" } }); + + const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema)); + + expect(result?.broken).toBe(false); + expect(result?.issues).toEqual([]); + }); + + it("should attribute a rename error to the renamed field", () => { + const oldSchema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]); + const newSchema = toSchemaLike([ + makeComponent("hero", { headline: { type: "text", required: true } }), + ]); + const impacted: ImpactedMap = new Map([ + [ + "hero", + { + component: "hero", + action: "update", + fields: [{ field: "headline", contentKey: "title", kind: "rename" }], + }, + ], + ]); + const story = makeStory({ content: { _uid: "r", component: "hero", title: "Hi" } }); + + const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema)); + + expect(result?.broken).toBe(true); + expect(result?.usedFields).toEqual([{ component: "hero", field: "headline" }]); + expect( + result?.issues.some((i) => i.field === "headline" && i.code === "missing_required_field"), + ).toBe(true); + }); + + it("should flag stories using a removed component as broken", () => { + const oldSchema = toSchemaLike([ + makeComponent("page", { body: { type: "bloks" } }), + makeComponent("teaser", {}), + ]); + const newSchema = toSchemaLike([makeComponent("page", { body: { type: "bloks" } })]); + const impacted: ImpactedMap = new Map([ + ["teaser", { component: "teaser", action: "removed", fields: [] }], + ]); + const story = makeStory({ + content: { _uid: "r", component: "page", body: [{ _uid: "a", component: "teaser" }] }, + }); + + const result = analyzeStory(story, createAnalyzeContext(impacted, oldSchema, newSchema)); + + expect(result?.broken).toBe(true); + expect( + result?.issues.some((i) => i.component === "teaser" && i.code === "component_removed"), + ).toBe(true); + }); + + it("should return null for stories that do not use any impacted component", () => { + const schema = toSchemaLike([makeComponent("hero", { title: { type: "text" } })]); + const impacted: ImpactedMap = new Map([ + [ + "hero", + { + component: "hero", + action: "update", + fields: [{ field: "title", contentKey: "title", kind: "type_changed" }], + }, + ], + ]); + const story = makeStory({ content: { _uid: "r", component: "other" } }); + + expect(analyzeStory(story, createAnalyzeContext(impacted, schema, schema))).toBeNull(); + }); +}); + +describe("aggregate", () => { + it("should total used and broken stories per component and field", () => { + const impacted: ImpactedMap = new Map([ + [ + "hero", + { + component: "hero", + action: "update", + fields: [{ field: "title", contentKey: "title", kind: "required_added" }], + }, + ], + ]); + const stories = [ + { + id: 1, + uuid: "a", + name: "A", + full_slug: "a", + components: ["hero"], + usedFields: [{ component: "hero", field: "title" }], + broken: true, + issues: [ + { + component: "hero", + field: "title", + severity: "error" as const, + code: "missing_required_field", + message: "", + }, + ], + }, + { + id: 2, + uuid: "b", + name: "B", + full_slug: "b", + components: ["hero"], + usedFields: [], + broken: false, + issues: [], + }, + ]; + + const report = aggregate("12345", impacted, stories); + + expect(report.totals).toEqual({ usedStories: 2, brokenStories: 1, brokenComponents: 1 }); + const hero = report.components[0]; + expect(hero).toMatchObject({ component: "hero", usedStories: 2, brokenStories: 1 }); + expect(hero.fields[0]).toMatchObject({ field: "title", used: 1, broken: 1 }); + }); +}); diff --git a/packages/cli/src/commands/schema/affected/actions.ts b/packages/cli/src/commands/schema/affected/actions.ts new file mode 100644 index 000000000..1ed32be34 --- /dev/null +++ b/packages/cli/src/commands/schema/affected/actions.ts @@ -0,0 +1,420 @@ +import { Readable, Writable } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { validateStory } from "@storyblok/schema"; +import type { ValidationIssue } from "@storyblok/schema"; + +import type { Story } from "../../../types"; +import type { DiffResult } from "../types"; +import type { BreakingChange, ComponentBreakingChanges } from "../migrations/types"; +import type { AdaptedSchema } from "../to-schema-like"; +import { + fetchStoriesStream, + fetchStoryStream, + readLocalStoriesStream, +} from "../../stories/streams"; +import { collectComponentUsage } from "./content-usage"; + +/** How an impacted component changed. `removed` = deleted from the local schema. */ +export type ImpactAction = "update" | "removed"; + +/** A field-level change that affects existing content. */ +export interface ImpactedField { + /** Field name to report (the new name for renames). */ + field: string; + /** Key present in existing (remote) content — the old name for renames. */ + contentKey: string; + kind: BreakingChange["kind"]; +} + +/** An impacted component plus the field-level changes driving the impact. */ +export interface ImpactedComponent { + component: string; + action: ImpactAction; + fields: ImpactedField[]; +} + +export type ImpactedMap = Map; + +/** A validation issue attributed to an impacted component (and optionally a field). */ +export interface AttributedIssue { + component: string; + field?: string; + severity: ValidationIssue["severity"]; + code: string; + message: string; +} + +/** An impacted field a story actually contains (drives per-field usage counts). */ +export interface UsedField { + component: string; + field: string; +} + +/** Per-story impact result. */ +export interface AffectedStory { + id: number; + uuid: string; + name: string; + full_slug: string; + /** Impacted components the story uses. */ + components: string[]; + /** Impacted fields the story actually contains. */ + usedFields: UsedField[]; + /** `true` when the change would newly produce a validation error in this story. */ + broken: boolean; + issues: AttributedIssue[]; +} + +/** Per-field aggregate. */ +export interface FieldImpact { + field: string; + kind: BreakingChange["kind"]; + used: number; + broken: number; +} + +/** Per-component aggregate. */ +export interface ComponentImpact { + component: string; + action: ImpactAction; + usedStories: number; + brokenStories: number; + fields: FieldImpact[]; +} + +/** The full impact report. */ +export interface AffectedReport { + space: string; + components: ComponentImpact[]; + stories: AffectedStory[]; + totals: { usedStories: number; brokenStories: number; brokenComponents: number }; +} + +/** The content key a breaking change touches in existing content, and the reported field name. */ +function toImpactedField(change: BreakingChange): ImpactedField { + if (change.kind === "rename") { + return { field: change.field, contentKey: change.oldField, kind: change.kind }; + } + return { field: change.field, contentKey: change.field, kind: change.kind }; +} + +/** + * Derives the set of impacted components from the schema diff and breaking-change + * analysis. Updated components with breaking field changes are always impacted. + * Removed (stale) components are impacted only when `withDelete` is set, because a + * plain `schema push` leaves stale components in place — it only deletes them with + * `--delete` — so their content would not break otherwise. + */ +export function computeImpactedComponents( + diffResult: DiffResult, + breakingChanges: ComponentBreakingChanges[], + options: { withDelete?: boolean } = {}, +): ImpactedMap { + const impacted: ImpactedMap = new Map(); + + for (const comp of breakingChanges) { + impacted.set(comp.componentName, { + component: comp.componentName, + action: "update", + fields: comp.changes.map(toImpactedField), + }); + } + + if (options.withDelete) { + for (const diff of diffResult.diffs) { + if (diff.type === "component" && diff.action === "stale") { + impacted.set(diff.name, { component: diff.name, action: "removed", fields: [] }); + } + } + } + + return impacted; +} + +/** Maps each impacted component to the content keys its impacted fields occupy. */ +function toContentKeyMap(impacted: ImpactedMap): Map> { + const map = new Map>(); + for (const [component, entry] of impacted) { + map.set(component, new Set(entry.fields.map((field) => field.contentKey))); + } + return map; +} + +/** Everything `analyzeStory` needs, computed once per run rather than per story. */ +export interface AnalyzeContext { + impacted: ImpactedMap; + contentKeyMap: Map>; + oldSchema: AdaptedSchema; + newSchema: AdaptedSchema; +} + +/** Builds the shared analysis context (hoists the per-component content-key map). */ +export function createAnalyzeContext( + impacted: ImpactedMap, + oldSchema: AdaptedSchema, + newSchema: AdaptedSchema, +): AnalyzeContext { + return { impacted, contentKeyMap: toContentKeyMap(impacted), oldSchema, newSchema }; +} + +/** Stable identity for a validation issue, used to diff old vs. new schema results. */ +function issueKey(issue: ValidationIssue): string { + return `${issue.entity}|${issue.code}|${issue.path.join(".")}`; +} + +/** Finds the impacted field an issue path points at, matching by old or new field name. */ +function matchField(entry: ImpactedComponent, issuePath: (string | number)[]): string | undefined { + const pathKeys = new Set( + issuePath.filter((segment): segment is string => typeof segment === "string"), + ); + for (const field of entry.fields) { + // Renames validate at the new name but occupy the old content key, so match either. + if (pathKeys.has(field.contentKey) || pathKeys.has(field.field)) { + return field.field; + } + } + return undefined; +} + +/** + * Analyzes one story: which impacted components/fields it uses and whether the + * change would newly break it. Breakage is diffed against the old (remote) schema + * so pre-existing invalid content is not misattributed to the analyzed change. + */ +export function analyzeStory(story: Story, ctx: AnalyzeContext): AffectedStory | null { + const usage = collectComponentUsage(story.content, ctx.contentKeyMap); + if (usage.size === 0) { + return null; + } + + const issues: AttributedIssue[] = []; + const usedFields: UsedField[] = []; + let broken = false; + + // Per-field usage: an impacted field is "used" when its content key is present. + for (const [component, entry] of ctx.impacted) { + const componentUsage = usage.get(component); + if (!componentUsage) { + continue; + } + for (const field of entry.fields) { + if (componentUsage.fields.has(field.contentKey)) { + usedFields.push({ component, field: field.field }); + } + } + } + + // Removed components are absent from the new schema, so every story using one + // breaks once the deletion is pushed. `validateStory` reports these against the + // `story` entity (not `block:`), so surface them explicitly here. + for (const component of usage.keys()) { + const entry = ctx.impacted.get(component); + if (entry?.action === "removed") { + broken = true; + issues.push({ + component, + severity: "error", + code: "component_removed", + message: `Component "${component}" is removed from the schema; its content becomes out-of-schema.`, + }); + } + } + + // Only errors the change introduces count: subtract issues already present when + // validating the story against the old schema. + const preExisting = new Set(validateStory(story, ctx.oldSchema).issues.map(issueKey)); + const { issues: validationIssues } = validateStory(story, ctx.newSchema); + for (const issue of validationIssues) { + if (!issue.entity.startsWith("block:") || preExisting.has(issueKey(issue))) { + continue; + } + const component = issue.entity.slice("block:".length); + const entry = ctx.impacted.get(component); + if (!entry || !usage.has(component)) { + continue; + } + if (issue.severity === "error") { + broken = true; + } + issues.push({ + component, + field: matchField(entry, issue.path), + severity: issue.severity, + code: issue.code, + message: issue.message, + }); + } + + return { + id: story.id, + uuid: story.uuid ?? "", + name: story.name ?? "", + full_slug: story.full_slug ?? "", + components: [...usage.keys()], + usedFields, + broken, + issues, + }; +} + +/** Aggregates per-story results into per-component and per-field impact totals. */ +export function aggregate( + space: string, + impacted: ImpactedMap, + stories: AffectedStory[], +): AffectedReport { + const components: ComponentImpact[] = []; + + for (const [component, entry] of impacted) { + const used = stories.filter((story) => story.components.includes(component)); + const broken = used.filter((story) => + story.issues.some((issue) => issue.component === component && issue.severity === "error"), + ); + + const fields: FieldImpact[] = entry.fields.map((field) => { + const usedCount = used.filter((story) => + story.usedFields.some( + (usedField) => usedField.component === component && usedField.field === field.field, + ), + ).length; + const brokenCount = broken.filter((story) => + story.issues.some( + (issue) => + issue.component === component && + issue.field === field.field && + issue.severity === "error", + ), + ).length; + return { field: field.field, kind: field.kind, used: usedCount, broken: brokenCount }; + }); + + components.push({ + component, + action: entry.action, + usedStories: used.length, + brokenStories: broken.length, + fields, + }); + } + + return { + space, + components, + stories, + totals: { + usedStories: stories.length, + brokenStories: stories.filter((story) => story.broken).length, + brokenComponents: components.filter((component) => component.brokenStories > 0).length, + }, + }; +} + +/** Progress and error callbacks shared by the remote and local analyzers. */ +export interface AnalyzeHooks { + onTotal?: (total: number) => void; + onStory?: () => void; + onStoryError?: (error: Error, storyRef?: string) => void; +} + +/** A pipeline sink that analyzes each story and collects any impact into `results`. */ +function createAnalyzeCollector(ctx: AnalyzeContext, results: AffectedStory[]): Writable { + return new Writable({ + objectMode: true, + write(story: Story, _encoding, callback) { + const result = analyzeStory(story, ctx); + if (result) { + results.push(result); + } + callback(); + }, + }); +} + +/** + * Fetches remote stories that use any impacted component, fetches full content + * per story, and analyzes each. + * + * Two MAPI filters are combined to cover every usage: + * - `contain_component` matches a component used as a *nested* blok. It has AND + * (superset) semantics for a comma-separated list, so we issue one request per + * impacted component to get their union. + * - `filter_query[component][in]` matches a component used as a story's *root* + * content type. `contain_component` alone misses a root-only story that has no + * nested bloks (its root component is never indexed as "contained"), so those + * stories would be silently omitted — a false all-clear. One `in`-list request + * covers all impacted names (OR semantics). + * + * Refs de-duplicate by story id, so a story matched by several filters is fetched + * once; over-fetching is harmless because `analyzeStory` recomputes usage from + * the story's own content. + */ +export async function analyzeRemoteStories( + spaceId: string, + impacted: ImpactedMap, + oldSchema: AdaptedSchema, + newSchema: AdaptedSchema, + hooks: AnalyzeHooks = {}, +): Promise { + const ctx = createAnalyzeContext(impacted, oldSchema, newSchema); + const results: AffectedStory[] = []; + + // Gather and de-duplicate list refs. One `contain_component` request per + // impacted component (nested usage) plus one `filter_query` request for all + // impacted names (root content-type usage). + const refs = new Map(); + for (const component of impacted.keys()) { + const listStream = fetchStoriesStream({ + spaceId, + params: { contain_component: component }, + onPageError: (error, page) => + hooks.onStoryError?.(error, `component "${component}" (page ${page})`), + }); + for await (const story of listStream) { + refs.set(story.id, story); + } + } + + const rootStream = fetchStoriesStream({ + spaceId, + params: { filter_query: { component: { in: [...impacted.keys()].join(",") } } }, + onPageError: (error, page) => hooks.onStoryError?.(error, `root content type (page ${page})`), + }); + for await (const story of rootStream) { + refs.set(story.id, story); + } + + hooks.onTotal?.(refs.size); + + const fetchStream = fetchStoryStream({ + spaceId, + // Increment per attempted story (success or failure) so progress reaches 100%. + onIncrement: hooks.onStory, + onStoryError: (error, story) => hooks.onStoryError?.(error, String(story.id)), + }); + + await pipeline(Readable.from(refs.values()), fetchStream, createAnalyzeCollector(ctx, results)); + return results; +} + +/** Reads local story JSON files and analyzes each against the impacted set. */ +export async function analyzeLocalStories( + directoryPath: string, + impacted: ImpactedMap, + oldSchema: AdaptedSchema, + newSchema: AdaptedSchema, + hooks: AnalyzeHooks = {}, +): Promise { + const ctx = createAnalyzeContext(impacted, oldSchema, newSchema); + const results: AffectedStory[] = []; + + const localStream = readLocalStoriesStream({ + directoryPath, + setTotalStories: hooks.onTotal, + onIncrement: hooks.onStory, + onStoryError: (error, filename) => hooks.onStoryError?.(error, filename), + }); + + await pipeline(localStream, createAnalyzeCollector(ctx, results)); + return results; +} diff --git a/packages/cli/src/commands/schema/affected/constants.ts b/packages/cli/src/commands/schema/affected/constants.ts new file mode 100644 index 000000000..402321f42 --- /dev/null +++ b/packages/cli/src/commands/schema/affected/constants.ts @@ -0,0 +1,10 @@ +export interface SchemaAffectedOptions { + space?: string; + path?: string; + /** Analyze locally pulled story JSON files (default stories directory) instead of fetching remote. */ + local?: boolean; + /** Treat remote-only components as deleted, mirroring `schema push --delete`. */ + includeDeleted?: boolean; + /** Exit with a non-zero code when any story would break (for CI gating). */ + failOnBreak?: boolean; +} diff --git a/packages/cli/src/commands/schema/affected/content-usage.test.ts b/packages/cli/src/commands/schema/affected/content-usage.test.ts new file mode 100644 index 000000000..f4c5581d2 --- /dev/null +++ b/packages/cli/src/commands/schema/affected/content-usage.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { collectComponentUsage } from "./content-usage"; + +describe("collectComponentUsage", () => { + it("should count top-level and nested blok usage of impacted components", () => { + const content = { + _uid: "root", + component: "page", + body: [ + { _uid: "a", component: "hero", title: "One" }, + { _uid: "b", component: "hero", title: "Two" }, + { _uid: "c", component: "teaser" }, + ], + }; + + const usage = collectComponentUsage(content, new Map([["hero", new Set()]])); + + expect(usage.get("hero")?.count).toBe(2); + expect(usage.has("teaser")).toBe(false); + expect(usage.has("page")).toBe(false); + }); + + it("should count impacted-field presence per component", () => { + const content = { + component: "hero", + body: [ + { _uid: "a", component: "card", subtitle: "x" }, + { _uid: "b", component: "card" }, + ], + }; + + const usage = collectComponentUsage(content, new Map([["card", new Set(["subtitle"])]])); + + expect(usage.get("card")?.count).toBe(2); + expect(usage.get("card")?.fields.get("subtitle")).toBe(1); + }); + + it("should find bloks embedded in richtext field values", () => { + const content = { + component: "page", + text: { + type: "doc", + content: [ + { + type: "blok", + attrs: { body: [{ _uid: "a", component: "hero", title: "Hi" }] }, + }, + ], + }, + }; + + const usage = collectComponentUsage(content, new Map([["hero", new Set(["title"])]])); + + expect(usage.get("hero")?.count).toBe(1); + expect(usage.get("hero")?.fields.get("title")).toBe(1); + }); + + it("should return an empty map when nothing impacted is used", () => { + const content = { component: "page", body: [{ component: "teaser" }] }; + + const usage = collectComponentUsage(content, new Map([["hero", new Set()]])); + + expect(usage.size).toBe(0); + }); +}); diff --git a/packages/cli/src/commands/schema/affected/content-usage.ts b/packages/cli/src/commands/schema/affected/content-usage.ts new file mode 100644 index 000000000..f8d29ca62 --- /dev/null +++ b/packages/cli/src/commands/schema/affected/content-usage.ts @@ -0,0 +1,73 @@ +import { isRecord } from "../utils"; + +/** Usage of a single component within one story's content. */ +export interface ComponentUsage { + /** Number of blok instances of this component. */ + count: number; + /** Impacted field name → number of instances where that field is present. */ + fields: Map; +} + +function recordBlok( + blok: Record, + impacted: Map>, + usage: Map, +): void { + const component = blok.component; + if (typeof component !== "string") { + return; + } + const impactedFields = impacted.get(component); + if (!impactedFields) { + return; + } + + let entry = usage.get(component); + if (!entry) { + entry = { count: 0, fields: new Map() }; + usage.set(component, entry); + } + entry.count += 1; + + for (const field of impactedFields) { + if (field in blok) { + entry.fields.set(field, (entry.fields.get(field) ?? 0) + 1); + } + } +} + +/** + * Walks a story's `content` and counts how often each impacted component (and + * its impacted fields) appears, recursing through nested `bloks` fields and + * richtext-embedded bloks. Any object carrying a string `component` key is + * treated as a blok instance; the traversal is otherwise structure-agnostic so + * it also reaches bloks nested inside richtext `attrs.body` arrays. + * + * @param content - The story's `content` object. + * @param impacted - Map of impacted component name → impacted field names. + * @returns Usage keyed by component name; components not in `impacted` are omitted. + */ +export function collectComponentUsage( + content: unknown, + impacted: Map>, +): Map { + const usage = new Map(); + + const walk = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) { + walk(item); + } + return; + } + if (isRecord(value)) { + recordBlok(value, impacted, usage); + for (const nested of Object.values(value)) { + walk(nested); + } + } + }; + + walk(content); + return usage; +} diff --git a/packages/cli/src/commands/schema/affected/format.test.ts b/packages/cli/src/commands/schema/affected/format.test.ts new file mode 100644 index 000000000..23dcae233 --- /dev/null +++ b/packages/cli/src/commands/schema/affected/format.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import type { AffectedReport } from "./actions"; +import { formatSummary, pluralize } from "./format"; + +describe("pluralize", () => { + it("should use the singular noun for a count of one", () => { + expect(pluralize(1, "story", "stories")).toBe("1 story"); + expect(pluralize(1, "component")).toBe("1 component"); + }); + + it("should use the plural noun for counts other than one", () => { + expect(pluralize(0, "story", "stories")).toBe("0 stories"); + expect(pluralize(3, "story", "stories")).toBe("3 stories"); + expect(pluralize(2, "component")).toBe("2 components"); + }); +}); + +describe("formatSummary", () => { + it("should pluralize story and component counts based on their value", () => { + const report: AffectedReport = { + space: "123", + components: [ + { + component: "hero", + action: "update", + usedStories: 1, + brokenStories: 1, + fields: [{ field: "badge", kind: "required_added", used: 1, broken: 1 }], + }, + { + component: "cta", + action: "removed", + usedStories: 3, + brokenStories: 3, + fields: [], + }, + ], + stories: [], + totals: { usedStories: 4, brokenStories: 4, brokenComponents: 2 }, + }; + + const lines = formatSummary(report); + + expect(lines).toContain("hero (changed): 1 story affected, 1 would break"); + expect(lines).toContain(" - badge (required_added): present in 1 story, 1 would break"); + expect(lines).toContain("cta (removed): 3 stories affected, 3 would break"); + expect(lines).toContain("Totals: 4 stories affected across 2 components; 4 would break."); + }); +}); diff --git a/packages/cli/src/commands/schema/affected/format.ts b/packages/cli/src/commands/schema/affected/format.ts new file mode 100644 index 000000000..25a942cca --- /dev/null +++ b/packages/cli/src/commands/schema/affected/format.ts @@ -0,0 +1,35 @@ +import type { AffectedReport, ComponentImpact } from "./actions"; + +/** Prefixes `count` with the singular or plural noun (`1 story`, `3 stories`). */ +export function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function componentLine(component: ComponentImpact): string { + const label = component.action === "removed" ? "removed" : "changed"; + return `${component.component} (${label}): ${pluralize(component.usedStories, "story", "stories")} affected, ${component.brokenStories} would break`; +} + +/** Builds the human-readable summary lines (per component, per field, totals). */ +export function formatSummary(report: AffectedReport): string[] { + const lines: string[] = []; + + for (const component of report.components) { + lines.push(componentLine(component)); + for (const field of component.fields) { + // `used` counts stories whose content contains the field; for `required_added` + // it is expected to be 0 (the field is absent), so phrase it as presence to + // avoid reading as a contradiction against the break count. + lines.push( + ` - ${field.field} (${field.kind}): present in ${pluralize(field.used, "story", "stories")}, ${field.broken} would break`, + ); + } + } + + lines.push(""); + lines.push( + `Totals: ${pluralize(report.totals.usedStories, "story", "stories")} affected across ${pluralize(report.components.length, "component")}; ${report.totals.brokenStories} would break.`, + ); + + return lines; +} diff --git a/packages/cli/src/commands/schema/affected/index.test.ts b/packages/cli/src/commands/schema/affected/index.test.ts new file mode 100644 index 000000000..f1dcda541 --- /dev/null +++ b/packages/cli/src/commands/schema/affected/index.test.ts @@ -0,0 +1,467 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { vol } from "memfs"; + +import "../index"; +import { schemaCommand } from "../command"; +import type { SchemaData } from "../types"; +import type { Component } from "../../../types"; +import { DEFAULT_SPACE, getID } from "../../__tests__/helpers"; +import { getReporter, resetReporter } from "../../../lib/reporter/reporter"; +import { getUI } from "../../../lib/ui"; + +import { loadSchema } from "../load-schema"; + +// loadSchema uses jiti to dynamically import TypeScript entry files at runtime, +// which cannot be resolved in the test environment, so we mock it and provide +// the local schema directly. +vi.mock("../load-schema", () => ({ + loadSchema: vi.fn(), +})); + +function makeComponent(name: string, schema: Record>): Component { + return { + id: getID(), + name, + created_at: "2024-01-01", + updated_at: "2024-01-01", + is_root: false, + is_nestable: true, + schema, + } as unknown as Component; +} + +interface StoryFixture { + id: number; + uuid: string; + name: string; + full_slug: string; + content: Record; +} + +const server = setupServer(); + +let storiesListCalls = 0; + +const preconditions = { + hasLocalSchema(components: Component[]) { + vi.mocked(loadSchema).mockResolvedValue({ + components, + datasources: [], + folders: [], + } satisfies SchemaData); + }, + hasRemote(components: Component[]) { + server.use( + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/components`, () => + HttpResponse.json({ components }), + ), + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/component_groups`, () => + HttpResponse.json({ component_groups: [] }), + ), + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/datasources`, () => + HttpResponse.json({ datasources: [] }), + ), + ); + }, + hasStories(stories: StoryFixture[]) { + server.use( + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/stories`, () => { + storiesListCalls += 1; + return HttpResponse.json( + { + stories: stories.map(({ id, uuid, name, full_slug }) => ({ + id, + uuid, + name, + full_slug, + })), + }, + { headers: { Total: String(stories.length), "Per-Page": "100" } }, + ); + }), + http.get( + `https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/stories/:id`, + ({ params }) => { + const story = stories.find((s) => String(s.id) === params.id); + return HttpResponse.json({ story }); + }, + ), + ); + }, +}; + +// The detailed impact report is attached to the standard report file via +// `reporter.addMeta('schemaAffected', ...)`, so enable the reporter and read it back. +function readOutputReport() { + const entry = Object.entries(vol.toJSON()).find(([filename]) => filename.endsWith("report.json")); + return entry ? JSON.parse(entry[1] as string).meta?.schemaAffected : undefined; +} + +async function runAffected(extraArgs: string[] = []) { + resetReporter(); + getReporter({ enabled: true, filePath: "report.json" }); + await schemaCommand.parseAsync([ + "node", + "test", + "affected", + "schema.ts", + "--space", + DEFAULT_SPACE, + ...extraArgs, + ]); +} + +describe("schema affected command", () => { + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + + afterEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + vol.reset(); + server.resetHandlers(); + resetReporter(); + storiesListCalls = 0; + process.exitCode = undefined; + }); + + afterAll(() => server.close()); + + it("should flag stories missing a newly required field as broken", async () => { + preconditions.hasLocalSchema([ + makeComponent("hero", { + title: { type: "text" }, + subtitle: { type: "text", required: true }, + }), + ]); + preconditions.hasRemote([makeComponent("hero", { title: { type: "text" } })]); + preconditions.hasStories([ + { + id: 100, + uuid: "u-home", + name: "Home", + full_slug: "home", + content: { _uid: "r", component: "hero", title: "Hi" }, + }, + ]); + + await runAffected(); + + const report = readOutputReport(); + expect(report.totals).toMatchObject({ usedStories: 1, brokenStories: 1 }); + const hero = report.components.find((c: { component: string }) => c.component === "hero"); + expect(hero).toMatchObject({ usedStories: 1, brokenStories: 1 }); + expect(hero.fields.find((f: { field: string }) => f.field === "subtitle")).toMatchObject({ + kind: "required_added", + broken: 1, + }); + }); + + it("should treat a removed field as affected but not broken", async () => { + preconditions.hasLocalSchema([makeComponent("hero", { title: { type: "text" } })]); + preconditions.hasRemote([ + makeComponent("hero", { title: { type: "text" }, subtitle: { type: "text" } }), + ]); + preconditions.hasStories([ + { + id: 101, + uuid: "u-a", + name: "A", + full_slug: "a", + content: { _uid: "r", component: "hero", title: "Hi", subtitle: "orphan" }, + }, + ]); + + await runAffected(); + + const report = readOutputReport(); + expect(report.totals).toMatchObject({ usedStories: 1, brokenStories: 0 }); + expect( + report.stories[0].issues.some( + (i: { code: string; severity: string }) => + i.code === "unknown_field" && i.severity === "warning", + ), + ).toBe(true); + }); + + it("should flag stories using a removed component as broken with --include-deleted", async () => { + preconditions.hasLocalSchema([makeComponent("page", { body: { type: "bloks" } })]); + preconditions.hasRemote([ + makeComponent("page", { body: { type: "bloks" } }), + makeComponent("teaser", { headline: { type: "text" } }), + ]); + preconditions.hasStories([ + { + id: 102, + uuid: "u-b", + name: "B", + full_slug: "b", + content: { + _uid: "r", + component: "page", + body: [{ _uid: "x", component: "teaser", headline: "Hey" }], + }, + }, + ]); + + await runAffected(["--include-deleted"]); + + const report = readOutputReport(); + const teaser = report.components.find((c: { component: string }) => c.component === "teaser"); + expect(teaser).toMatchObject({ action: "removed", brokenStories: 1 }); + expect(report.totals.brokenStories).toBe(1); + }); + + it("should not treat a removed component as affected without --include-deleted", async () => { + preconditions.hasLocalSchema([makeComponent("page", { body: { type: "bloks" } })]); + preconditions.hasRemote([ + makeComponent("page", { body: { type: "bloks" } }), + makeComponent("teaser", { headline: { type: "text" } }), + ]); + preconditions.hasStories([]); + + await runAffected(); + + expect(storiesListCalls).toBe(0); + expect(readOutputReport()).toBeUndefined(); + }); + + it("should union stories across multiple impacted components (MAPI contain_component is AND)", async () => { + preconditions.hasLocalSchema([ + makeComponent("hero", { title: { type: "text", required: true } }), + makeComponent("teaser", { headline: { type: "text", required: true } }), + ]); + preconditions.hasRemote([ + makeComponent("hero", { title: { type: "text" } }), + makeComponent("teaser", { headline: { type: "text" } }), + ]); + + // No single story uses both components, so a single AND-filtered request for + // `hero,teaser` would return nothing. Only one request per component unions them. + const stories: StoryFixture[] = [ + { + id: 200, + uuid: "u-h", + name: "H", + full_slug: "h", + content: { _uid: "r", component: "hero" }, + }, + { + id: 201, + uuid: "u-t", + name: "T", + full_slug: "t", + content: { _uid: "r", component: "teaser" }, + }, + ]; + const componentsOf = (content: unknown): Set => { + const found = new Set(); + const walk = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + if (value && typeof value === "object") { + const component = (value as Record).component; + if (typeof component === "string") { + found.add(component); + } + Object.values(value).forEach(walk); + } + }; + walk(content); + return found; + }; + server.use( + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/stories`, ({ request }) => { + storiesListCalls += 1; + const contain = new URL(request.url).searchParams.get("contain_component"); + const required = contain ? contain.split(",") : []; + // Mirror MAPI: match stories whose component set is a superset of all requested names. + const matched = stories.filter((story) => + required.every((name) => componentsOf(story.content).has(name)), + ); + return HttpResponse.json( + { + stories: matched.map(({ id, uuid, name, full_slug }) => ({ + id, + uuid, + name, + full_slug, + })), + }, + { headers: { Total: String(matched.length), "Per-Page": "100" } }, + ); + }), + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/stories/:id`, ({ params }) => + HttpResponse.json({ story: stories.find((story) => String(story.id) === params.id) }), + ), + ); + + await runAffected(); + + const report = readOutputReport(); + expect(report.totals).toMatchObject({ usedStories: 2, brokenStories: 2 }); + expect(report.components.map((c: { component: string }) => c.component).sort()).toEqual([ + "hero", + "teaser", + ]); + }); + + it("should include a story that uses an impacted component only as its root content type", async () => { + // `contain_component=page` never matches a story that uses `page` only as its + // root type with no nested bloks — that story must be caught via the + // `filter_query[component][in]` root-content-type request, or breakage is + // silently under-reported (a false all-clear under --fail-on-break). + preconditions.hasLocalSchema([ + makeComponent("page", { + title: { type: "text" }, + subtitle: { type: "text", required: true }, + }), + ]); + preconditions.hasRemote([makeComponent("page", { title: { type: "text" } })]); + + const story: StoryFixture = { + id: 300, + uuid: "u-p", + name: "P", + full_slug: "p", + content: { _uid: "r", component: "page", title: "Hi" }, + }; + server.use( + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/stories`, ({ request }) => { + storiesListCalls += 1; + const url = new URL(request.url); + // Only the root-content-type filter returns the flat story; contain_component does not. + const rootIn = url.searchParams.get("filter_query[component][in]"); + const matched = rootIn?.split(",").includes("page") ? [story] : []; + return HttpResponse.json( + { + stories: matched.map(({ id, uuid, name, full_slug }) => ({ + id, + uuid, + name, + full_slug, + })), + }, + { headers: { Total: String(matched.length), "Per-Page": "100" } }, + ); + }), + http.get(`https://mapi.storyblok.com/v1/spaces/${DEFAULT_SPACE}/stories/:id`, ({ params }) => + HttpResponse.json({ story: String(story.id) === params.id ? story : undefined }), + ), + ); + + await runAffected(); + + const report = readOutputReport(); + expect(report.totals).toMatchObject({ usedStories: 1, brokenStories: 1 }); + expect(report.components.map((c: { component: string }) => c.component)).toEqual(["page"]); + }); + + it("should exit non-zero with --fail-on-break when a story would break", async () => { + preconditions.hasLocalSchema([ + makeComponent("hero", { + title: { type: "text" }, + subtitle: { type: "text", required: true }, + }), + ]); + preconditions.hasRemote([makeComponent("hero", { title: { type: "text" } })]); + preconditions.hasStories([ + { + id: 100, + uuid: "u-home", + name: "Home", + full_slug: "home", + content: { _uid: "r", component: "hero", title: "Hi" }, + }, + ]); + + await runAffected(["--fail-on-break"]); + + expect(process.exitCode).toBe(1); + }); + + it("should not set a non-zero exit code for breakage without --fail-on-break", async () => { + preconditions.hasLocalSchema([ + makeComponent("hero", { + title: { type: "text" }, + subtitle: { type: "text", required: true }, + }), + ]); + preconditions.hasRemote([makeComponent("hero", { title: { type: "text" } })]); + preconditions.hasStories([ + { + id: 100, + uuid: "u-home", + name: "Home", + full_slug: "home", + content: { _uid: "r", component: "hero", title: "Hi" }, + }, + ]); + + await runAffected(); + + expect(process.exitCode).toBeUndefined(); + }); + + it("should fail with actionable guidance when --local finds no pulled stories", async () => { + const errorSpy = vi.spyOn(getUI(), "error").mockImplementation(() => {}); + preconditions.hasLocalSchema([makeComponent("hero", { title: { type: "text" } })]); + preconditions.hasRemote([ + makeComponent("hero", { title: { type: "text" }, subtitle: { type: "text" } }), + ]); + + await runAffected(["--local"]); + + expect( + errorSpy.mock.calls.some( + ([message]) => typeof message === "string" && message.includes("stories pull"), + ), + ).toBe(true); + // The guard returns before any analysis, so no impact report is attached. + expect(readOutputReport()).toBeUndefined(); + // An operational failure must not be a green run, so CI gating can trust it. + expect(process.exitCode).toBe(1); + }); + + it("should warn and exit non-zero when the entry file resolves to an empty schema", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + preconditions.hasLocalSchema([]); + + await runAffected(); + + expect( + warnSpy.mock.calls.some( + ([message]) => + typeof message === "string" && message.includes("No components or datasources"), + ), + ).toBe(true); + expect(process.exitCode).toBe(1); + // A misconfigured entry-file must never diff nothing and report a false all-clear. + expect(readOutputReport()).toBeUndefined(); + }); + + it("should exit non-zero when the required --space is missing", async () => { + vi.spyOn(getUI(), "error").mockImplementation(() => {}); + resetReporter(); + getReporter({ enabled: true, filePath: "report.json" }); + + await schemaCommand.parseAsync(["node", "test", "affected", "schema.ts"]); + + expect(process.exitCode).toBe(1); + }); + + it("should not fetch stories when there are no content-affecting changes", async () => { + const shared = { title: { type: "text" } }; + preconditions.hasLocalSchema([makeComponent("hero", shared)]); + preconditions.hasRemote([makeComponent("hero", shared)]); + preconditions.hasStories([]); + + await runAffected(); + + expect(storiesListCalls).toBe(0); + expect(readOutputReport()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/schema/affected/index.ts b/packages/cli/src/commands/schema/affected/index.ts new file mode 100644 index 000000000..761738bbf --- /dev/null +++ b/packages/cli/src/commands/schema/affected/index.ts @@ -0,0 +1,208 @@ +import { colorPalette, commands, directories } from "../../../constants"; +import { CommandError, handleError, requireAuthentication, toError } from "../../../utils"; +import { getLogger } from "../../../lib/logger/logger"; +import { getReporter } from "../../../lib/reporter/reporter"; +import { getUI } from "../../../lib/ui"; +import { session } from "../../../session"; +import { fileExists, resolveCommandPath } from "../../../utils/filesystem"; +import { schemaCommand } from "../command"; +import type { SchemaData } from "../types"; +import { loadSchema } from "../load-schema"; +import { diffSchema } from "../diff-schema"; +import { fetchRemoteSchema } from "../actions"; +import { analyzeBreakingChanges } from "../migrations/analyze"; +import { toSchemaLike } from "../to-schema-like"; +import type { SchemaAffectedOptions } from "./constants"; +import { + aggregate, + analyzeLocalStories, + analyzeRemoteStories, + computeImpactedComponents, +} from "./actions"; +import { formatSummary, pluralize } from "./format"; + +schemaCommand + .command("affected ") + .description("Report which stories a schema change affects and which would break") + .option("-s, --space ", "space ID") + .option("--local", "analyze locally pulled stories instead of fetching from the space", false) + .option( + "--include-deleted", + "treat remote-only components as deleted (mirrors `schema push --delete`)", + false, + ) + .option( + "--fail-on-break", + "exit with a non-zero code when any story would break (for CI gating)", + false, + ) + .addHelpText( + "after", + ` +Scope: + Analyzes field-structural changes (field add/remove, type change, newly + required fields) and component deletion (--include-deleted). It does not + flag nested allow-list or reference constraint changes (e.g. narrowing a + bloks field's allowed components): Storyblok tolerates orphaned nested + bloks, so existing content is not broken by those changes.`, + ) + .action(async (entryFile: string, options: SchemaAffectedOptions, command) => { + const ui = getUI(); + const logger = getLogger(); + const reporter = getReporter(); + const { space, path: basePath, verbose } = command.optsWithGlobals(); + const { state } = session(); + + ui.title(commands.SCHEMA, colorPalette.SCHEMA, "Analyzing schema impact..."); + logger.info("Schema affected started", { entryFile, space }); + + if (!requireAuthentication(state, verbose)) { + return; + } + + if (!space) { + handleError( + new CommandError("Please provide the space as argument --space SPACE_ID."), + verbose, + ); + process.exitCode = 1; + return; + } + + try { + // 1. Load local schema + const loadSpinner = ui.createSpinner("Resolving schema..."); + let local: SchemaData; + try { + local = await loadSchema(entryFile); + } catch (maybeError) { + loadSpinner.failed("Failed to resolve schema"); + handleError(toError(maybeError), verbose); + process.exitCode = 1; + return; + } + loadSpinner.succeed( + `Found: ${local.components.length} components, ${local.datasources.length} datasources`, + ); + + // An empty resolved schema is almost always a misconfigured entry-file + // (typo, wrong export). Fail loudly rather than diffing nothing and + // reporting a false all-clear — critical under `--fail-on-break` in CI. + if (local.components.length + local.datasources.length === 0) { + ui.warn( + "No components or datasources found in the entry file. Verify the file exports schema definitions.", + ); + process.exitCode = 1; + return; + } + + // 2. Fetch remote state + const remoteSpinner = ui.createSpinner(`Fetching remote state from space ${space}...`); + let remoteResult: Awaited>; + try { + remoteResult = await fetchRemoteSchema(space); + } catch (maybeError) { + remoteSpinner.failed("Failed to fetch remote schema"); + handleError(toError(maybeError), verbose); + process.exitCode = 1; + return; + } + const { remote, rawComponents } = remoteResult; + remoteSpinner.succeed( + `Remote: ${remote.components.size} components, ${remote.datasources.size} datasources`, + ); + + // 3. Diff + breaking-change analysis + const diffResult = diffSchema(local, remote); + const breakingChanges = analyzeBreakingChanges(diffResult, local, remote); + + // 4. Determine impacted components + const impacted = computeImpactedComponents(diffResult, breakingChanges, { + withDelete: options.includeDeleted, + }); + + if (impacted.size === 0) { + ui.br(); + ui.ok("No content-affecting schema changes detected."); + reporter.addSummary("schemaAffectedResults", { total: 0, succeeded: 0, failed: 0 }); + return; + } + + // 5. Analyze stories that use impacted components. Breakage is validated + // against both schemas so only errors the change introduces are counted. + const oldSchema = toSchemaLike(rawComponents); + const newSchema = toSchemaLike(local.components); + + // `--local` reads pulled stories from the default directory; fail early with + // actionable guidance when it is missing rather than surfacing a raw fs error. + const storiesPath = options.local + ? resolveCommandPath(directories.stories, space, basePath) + : undefined; + if (storiesPath && !(await fileExists(storiesPath))) { + handleError( + new CommandError( + `No local stories found at ${storiesPath}. Run \`storyblok stories pull --space ${space}\` first.`, + ), + verbose, + ); + process.exitCode = 1; + return; + } + + const progress = ui.createProgressBar({ title: "Analyzing stories" }); + let fetchErrors = 0; + const hooks = { + onTotal: (total: number) => progress.setTotal(total), + onStory: () => progress.increment(), + onStoryError: (error: Error, storyRef?: string) => { + fetchErrors += 1; + logger.warn("Failed to fetch story for impact analysis", { + story: storyRef, + error: error.message, + }); + }, + }; + + const stories = storiesPath + ? await analyzeLocalStories(storiesPath, impacted, oldSchema, newSchema, hooks) + : await analyzeRemoteStories(space, impacted, oldSchema, newSchema, hooks); + // Tear down the whole MultiBar (not just this bar) so its async renderer + // stops before we log the summary; a bare `progress.stop()` leaves the + // render loop alive and its final frame flushes after the output. + ui.stopAllProgressBars(); + + if (fetchErrors > 0) { + ui.warn( + `${pluralize(fetchErrors, "story", "stories")} could not be fetched and were skipped. Re-run with --verbose for details.`, + ); + } + + // 6. Aggregate + report + const report = aggregate(space, impacted, stories); + + ui.br(); + for (const line of formatSummary(report)) { + ui.log(line); + } + + // The full per-story/per-field detail rides along in the standard report + // file (`--report-enabled`, on by default); there is no bespoke JSON flag. + reporter.addMeta("schemaAffected", report); + reporter.addSummary("schemaAffectedResults", { + total: report.totals.usedStories, + succeeded: report.totals.usedStories - report.totals.brokenStories, + failed: report.totals.brokenStories, + }); + logger.info("Schema affected finished", { totals: report.totals }); + + // Opt-in CI gate: surface breaking impact as a non-zero exit code. + if (options.failOnBreak && report.totals.brokenStories > 0) { + process.exitCode = 1; + } + } catch (maybeError) { + handleError(toError(maybeError), verbose); + process.exitCode = 1; + } finally { + reporter.finalize(); + } + }); diff --git a/packages/cli/src/commands/schema/push/diff-schema.test.ts b/packages/cli/src/commands/schema/diff-schema.test.ts similarity index 99% rename from packages/cli/src/commands/schema/push/diff-schema.test.ts rename to packages/cli/src/commands/schema/diff-schema.test.ts index 3499237c1..848e055b7 100644 --- a/packages/cli/src/commands/schema/push/diff-schema.test.ts +++ b/packages/cli/src/commands/schema/diff-schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { RemoteSchemaData, SchemaData } from "../types"; +import type { RemoteSchemaData, SchemaData } from "./types"; import { diffSchema } from "./diff-schema"; function makeComponent(name: string, schema: Record) { diff --git a/packages/cli/src/commands/schema/push/diff-schema.ts b/packages/cli/src/commands/schema/diff-schema.ts similarity index 97% rename from packages/cli/src/commands/schema/push/diff-schema.ts rename to packages/cli/src/commands/schema/diff-schema.ts index 45c958aad..8f17ad102 100644 --- a/packages/cli/src/commands/schema/push/diff-schema.ts +++ b/packages/cli/src/commands/schema/diff-schema.ts @@ -1,9 +1,9 @@ import { createTwoFilesPatch } from "diff"; -import type { DiffResult, EntityDiff, RemoteSchemaData, SchemaData } from "../types"; -import { applyDefaults, COMPONENT_DEFAULTS, DATASOURCE_DEFAULTS, isRecord } from "../utils"; -import { serializeComponent, serializeDatasource } from "../serialize"; -import { buildGroupPathByUuid } from "../folders"; +import type { DiffResult, EntityDiff, RemoteSchemaData, SchemaData } from "./types"; +import { applyDefaults, COMPONENT_DEFAULTS, DATASOURCE_DEFAULTS, isRecord } from "./utils"; +import { serializeComponent, serializeDatasource } from "./serialize"; +import { buildGroupPathByUuid } from "./folders"; type EntityType = "component" | "datasource"; diff --git a/packages/cli/src/commands/schema/index.ts b/packages/cli/src/commands/schema/index.ts index 9875da616..4364ae95f 100644 --- a/packages/cli/src/commands/schema/index.ts +++ b/packages/cli/src/commands/schema/index.ts @@ -3,3 +3,4 @@ import "./push"; import "./init"; import "./rollback"; import "./validate"; +import "./affected"; diff --git a/packages/cli/src/commands/schema/push/load-schema.test.ts b/packages/cli/src/commands/schema/load-schema.test.ts similarity index 96% rename from packages/cli/src/commands/schema/push/load-schema.test.ts rename to packages/cli/src/commands/schema/load-schema.test.ts index 2d18816ad..a2e1fd134 100644 --- a/packages/cli/src/commands/schema/push/load-schema.test.ts +++ b/packages/cli/src/commands/schema/load-schema.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { classifyExports } from "./load-schema"; +import { classifyExports, loadSchema } from "./load-schema"; + +describe("loadSchema", () => { + it("should throw a clear error when the entry file does not exist", async () => { + await expect(loadSchema("/definitely/missing/schema.ts")).rejects.toThrow( + /Schema entry file not found/, + ); + }); +}); // The DSL export predicates (`isComponent` / `isDatasource` / `isSchemaObject`) // are unit-tested in `src/utils/schema/classify-exports.test.ts`. This suite diff --git a/packages/cli/src/commands/schema/push/load-schema.ts b/packages/cli/src/commands/schema/load-schema.ts similarity index 95% rename from packages/cli/src/commands/schema/push/load-schema.ts rename to packages/cli/src/commands/schema/load-schema.ts index 48a125a80..bbed8078c 100644 --- a/packages/cli/src/commands/schema/push/load-schema.ts +++ b/packages/cli/src/commands/schema/load-schema.ts @@ -1,8 +1,8 @@ -import type { LocalFolder, SchemaData } from "../types"; -import { CommandError, isRecord } from "../../../utils"; -import { collectSchemaExports, loadSchemaModule } from "../../../utils/schema/classify-exports"; -import { expandFolderPath } from "../folders"; -import { mapBlockToWire, mapDatasourceToWire } from "../map-to-wire"; +import type { LocalFolder, SchemaData } from "./types"; +import { CommandError, isRecord } from "../../utils"; +import { collectSchemaExports, loadSchemaModule } from "../../utils/schema/classify-exports"; +import { expandFolderPath } from "./folders"; +import { mapBlockToWire, mapDatasourceToWire } from "./map-to-wire"; /** * Builds the deduped, parent-first {@link LocalFolder} list from harvested diff --git a/packages/cli/src/commands/schema/push/migrations/analyze.test.ts b/packages/cli/src/commands/schema/migrations/analyze.test.ts similarity index 99% rename from packages/cli/src/commands/schema/push/migrations/analyze.test.ts rename to packages/cli/src/commands/schema/migrations/analyze.test.ts index ca67f907d..a96c6b758 100644 --- a/packages/cli/src/commands/schema/push/migrations/analyze.test.ts +++ b/packages/cli/src/commands/schema/migrations/analyze.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { DiffResult, RemoteSchemaData, SchemaData } from "../../types"; +import type { DiffResult, RemoteSchemaData, SchemaData } from "../types"; import { analyzeBreakingChanges, classifyFieldChanges, detectRenames } from "./analyze"; diff --git a/packages/cli/src/commands/schema/push/migrations/analyze.ts b/packages/cli/src/commands/schema/migrations/analyze.ts similarity index 99% rename from packages/cli/src/commands/schema/push/migrations/analyze.ts rename to packages/cli/src/commands/schema/migrations/analyze.ts index fb225ff49..7848dcc96 100644 --- a/packages/cli/src/commands/schema/push/migrations/analyze.ts +++ b/packages/cli/src/commands/schema/migrations/analyze.ts @@ -1,4 +1,4 @@ -import type { DiffResult, RemoteSchemaData, SchemaData } from "../../types"; +import type { DiffResult, RemoteSchemaData, SchemaData } from "../types"; import type { BreakingChange, ComponentBreakingChanges, RenameMatch } from "./types"; /** Fields treated as internal Storyblok sentinels — never part of user-defined schema. */ diff --git a/packages/cli/src/commands/schema/push/migrations/generate.test.ts b/packages/cli/src/commands/schema/migrations/generate.test.ts similarity index 100% rename from packages/cli/src/commands/schema/push/migrations/generate.test.ts rename to packages/cli/src/commands/schema/migrations/generate.test.ts diff --git a/packages/cli/src/commands/schema/push/migrations/generate.ts b/packages/cli/src/commands/schema/migrations/generate.ts similarity index 98% rename from packages/cli/src/commands/schema/push/migrations/generate.ts rename to packages/cli/src/commands/schema/migrations/generate.ts index bae116ed7..ea4b23d3b 100644 --- a/packages/cli/src/commands/schema/push/migrations/generate.ts +++ b/packages/cli/src/commands/schema/migrations/generate.ts @@ -1,8 +1,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "pathe"; -import { resolvePath } from "../../../../utils/filesystem"; -import { fileTimestamp } from "../../utils"; +import { resolvePath } from "../../../utils/filesystem"; +import { fileTimestamp } from "../utils"; import type { BreakingChange } from "./types"; /** Compatible type pairs that don't need a content migration. */ diff --git a/packages/cli/src/commands/schema/push/migrations/types.ts b/packages/cli/src/commands/schema/migrations/types.ts similarity index 100% rename from packages/cli/src/commands/schema/push/migrations/types.ts rename to packages/cli/src/commands/schema/migrations/types.ts diff --git a/packages/cli/src/commands/schema/push/index.test.ts b/packages/cli/src/commands/schema/push/index.test.ts index ac3526764..b246b17a3 100644 --- a/packages/cli/src/commands/schema/push/index.test.ts +++ b/packages/cli/src/commands/schema/push/index.test.ts @@ -9,12 +9,12 @@ import { schemaCommand } from "../command"; import type { SchemaData } from "../types"; import { DEFAULT_SPACE, getID } from "../../__tests__/helpers"; -import { loadSchema } from "./load-schema"; +import { loadSchema } from "../load-schema"; // loadSchema uses jiti to dynamically import TypeScript entry files at runtime. // jiti cannot resolve @storyblok/schema imports in the test environment, so we // mock this module and provide schema data directly via preconditions. -vi.mock("./load-schema", () => ({ +vi.mock("../load-schema", () => ({ loadSchema: vi.fn(), })); @@ -172,7 +172,7 @@ const preconditions = { // Runs the real classifier so the aborting error is the production one; it // never touches jiti, only the already-loaded module exports below. const { classifyExports } = - await vi.importActual("./load-schema"); + await vi.importActual("../load-schema"); vi.mocked(loadSchema).mockImplementation(async () => classifyExports({ a: { name: "hero", fields: [{ name: "headline", type: "text" }] }, diff --git a/packages/cli/src/commands/schema/push/index.ts b/packages/cli/src/commands/schema/push/index.ts index 0e7fe6b2c..21fc1f4e5 100644 --- a/packages/cli/src/commands/schema/push/index.ts +++ b/packages/cli/src/commands/schema/push/index.ts @@ -12,21 +12,20 @@ import { schemaCommand } from "../command"; import { displayPath } from "../utils"; import type { SchemaPushOptions } from "./constants"; import type { SchemaData } from "../types"; -import { loadSchema } from "./load-schema"; -import { diffSchema } from "./diff-schema"; +import { loadSchema } from "../load-schema"; +import { diffSchema } from "../diff-schema"; import { fetchRemoteSchema } from "../actions"; import { buildGroupPathByUuid } from "../folders"; import { buildChangesetEntries, executePush, formatDiffOutput } from "./actions"; import { saveChangeset } from "../changeset"; -import { analyzeBreakingChanges } from "./migrations/analyze"; -import { renderMigrationCode, writeMigrationFile } from "./migrations/generate"; +import { analyzeBreakingChanges } from "../migrations/analyze"; +import { renderMigrationCode, writeMigrationFile } from "../migrations/generate"; import { writeLocalComponents } from "./write-local-components"; schemaCommand .command("push ") .description("Push local TypeScript schema and datasource definitions to a Storyblok space") .option("-s, --space ", "space ID") - .option("-p, --path ", "path for file storage") .option("--dry-run", "Show diffs without applying changes", false) .option("--delete", "Delete remote entities not present in local schema", false) .option("--migrations", "Generate scaffold migration files for breaking changes", true) diff --git a/packages/cli/src/commands/schema/rollback/index.ts b/packages/cli/src/commands/schema/rollback/index.ts index 2434353fe..a5e3e1057 100644 --- a/packages/cli/src/commands/schema/rollback/index.ts +++ b/packages/cli/src/commands/schema/rollback/index.ts @@ -25,7 +25,6 @@ schemaCommand .command("rollback [changeset-file]") .description("Roll back a Storyblok space to the state captured in a changeset") .option("-s, --space ", "space ID") - .option("-p, --path ", "path for file storage") .option("--dry-run", "Show what would be undone without applying changes", false) .option("--yes", "Skip confirmation prompt", false) .option("--latest", "Automatically select the most recent changeset", false) diff --git a/packages/cli/src/commands/schema/to-schema-like.test.ts b/packages/cli/src/commands/schema/to-schema-like.test.ts new file mode 100644 index 000000000..5c2c465fb --- /dev/null +++ b/packages/cli/src/commands/schema/to-schema-like.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import type { Component } from "../../types"; +import { toSchemaLike } from "./to-schema-like"; + +function makeComponent(name: string, schema: Record>): Component { + return { + id: 1, + name, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + is_root: false, + is_nestable: true, + schema, + } as unknown as Component; +} + +describe("toSchemaLike", () => { + it("should convert a component schema record into a block with a fields array", () => { + const result = toSchemaLike([ + makeComponent("hero", { + title: { type: "text", required: true, max_length: 50 }, + body: { type: "bloks" }, + }), + ]); + + expect(result.blocks).toEqual([ + { + name: "hero", + fields: [ + { name: "title", type: "text", required: true, max_length: 50 }, + { name: "body", type: "bloks" }, + ], + }, + ]); + }); + + it("should drop sentinel keys (_uid, component)", () => { + const [block] = toSchemaLike([ + makeComponent("hero", { + _uid: { type: "text" }, + component: { type: "text" }, + title: { type: "text" }, + }), + ]).blocks; + + expect(block.fields.map((f) => f.name)).toEqual(["title"]); + }); + + it("should map MAPI component_whitelist to allow", () => { + const [block] = toSchemaLike([ + makeComponent("page", { + body: { type: "bloks", component_whitelist: ["hero", "teaser"] }, + }), + ]).blocks; + + expect(block.fields[0].allow).toEqual(["hero", "teaser"]); + }); + + it("should drop fields whose type is not a known content field type", () => { + const [block] = toSchemaLike([ + makeComponent("hero", { + title: { type: "text" }, + legacy: { type: "commerce" }, + icon: { type: "image" }, + }), + ]).blocks; + + expect(block.fields.map((f) => f.name)).toEqual(["title"]); + }); +}); diff --git a/packages/cli/src/commands/schema/to-schema-like.ts b/packages/cli/src/commands/schema/to-schema-like.ts new file mode 100644 index 000000000..49e706653 --- /dev/null +++ b/packages/cli/src/commands/schema/to-schema-like.ts @@ -0,0 +1,88 @@ +import type { FieldType } from "@storyblok/schema"; + +import type { Component } from "../../types"; +import { isRecord } from "./utils"; + +/** Field-schema keys that are internal Storyblok sentinels, never user-defined fields. */ +const SENTINEL_FIELDS = new Set(["_uid", "component"]); + +// Content field types the `@storyblok/schema` validators understand. Declaring +// this as `Record` makes it a compile-time exhaustiveness check: +// adding or removing a `FieldType` in the schema package fails to type-check here +// until this map is updated in lockstep. +const KNOWN_FIELD_TYPES = { + text: true, + textarea: true, + richtext: true, + markdown: true, + number: true, + datetime: true, + boolean: true, + option: true, + options: true, + asset: true, + multiasset: true, + multilink: true, + bloks: true, + table: true, + section: true, + tab: true, + custom: true, +} satisfies Record; + +function isKnownFieldType(value: unknown): value is FieldType { + return ( + typeof value === "string" && Object.prototype.hasOwnProperty.call(KNOWN_FIELD_TYPES, value) + ); +} + +/** A single field in the adapted schema, structurally compatible with the validators' `SchemaFieldLike`. */ +export interface AdaptedField { + name: string; + type: FieldType; + allow?: string[]; + [key: string]: unknown; +} + +/** A block plus its fields, structurally compatible with the validators' `SchemaLike.blocks`. */ +export interface AdaptedSchema { + blocks: { name: string; fields: AdaptedField[] }[]; +} + +function toField(name: string, def: Record): AdaptedField | null { + // Field types the validators cannot check (e.g. commerce, image, link) are + // dropped: `validateStory` has no rule for them, so they never affect breakage. + if (!isKnownFieldType(def.type)) { + return null; + } + + const field: AdaptedField = { ...def, name, type: def.type }; + + // MAPI stores the allowed-blocks list for `bloks` fields as `component_whitelist`; + // the validators expect it under `allow`. + if (Array.isArray(def.component_whitelist)) { + field.allow = def.component_whitelist.filter( + (entry): entry is string => typeof entry === "string", + ); + } + + return field; +} + +/** + * Adapts MAPI components (a `schema` record keyed by field name) into the block + * + fields-array shape accepted by the `@storyblok/schema` validators. The + * result is passed to `validateStory` to detect content that a schema change + * would break. + */ +export function toSchemaLike(components: Component[]): AdaptedSchema { + return { + blocks: components.map((component) => ({ + name: component.name, + fields: Object.entries(component.schema ?? {}) + .filter(([key]) => !SENTINEL_FIELDS.has(key)) + .map(([name, def]) => (isRecord(def) ? toField(name, def) : null)) + .filter((field): field is AdaptedField => field !== null), + })), + }; +}