diff --git a/scripts/api-diff.luau b/scripts/api-diff.luau new file mode 100644 index 0000000..5234492 --- /dev/null +++ b/scripts/api-diff.luau @@ -0,0 +1,242 @@ +local fs = require("@lute/fs") +local process = require("@lute/process") + +type APIExport = { + name: string, + kind: "function" | "type" | "enum" | "value", + signature: string?, +} + +type APISurface = { + exports: { [string]: APIExport }, + types: { [string]: APIExport }, +} + +type ChangeKind = "Added" | "Removed" | "Modified" + +local ChangeKind = { + Added = "Added" :: "Added", + Removed = "Removed" :: "Removed", + Modified = "Modified" :: "Modified", +} + +type APIChange = { + kind: ChangeKind, + name: string, + before: APIExport?, + after: APIExport?, +} + +type ChangeReport = { + breaking: { APIChange }, + additions: { APIChange }, + modifications: { APIChange }, +} + +local function parseInitLuau(content: string): APISurface + local api: APISurface = { + exports = {}, + types = {}, + } + + for name in content:gmatch("export%s+type%s+([%w_]+)%s*=") do + api.types[name] = { + name = name, + kind = "type", + } + end + + local returnBlock = content:match("return%s*{(.-)}") + if returnBlock then + for key in returnBlock:gmatch("([%w_]+)%s*=") do + api.exports[key] = { + name = key, + kind = "function", + } + end + end + + return api +end + +local function getFileAtRef(ref: string, filePath: string): string? + -- Handle working directory (uncommitted changes) + if ref == "WORKING" or ref == "." then + if not fs.exists(filePath) then + return nil + end + local handle = fs.open(filePath, "r") + local content = fs.read(handle) + fs.close(handle) + return content + end + + -- Try git first + local result = process.run({ + "git", + "show", + `{ref}:{filePath}`, + }) + + if not result.ok then + return nil + end + + return result.stdout +end + +local function getAPISurface(ref: string): APISurface + print(`šŸ“– Reading API surface for {ref}...`) + + local content = getFileAtRef(ref, "src/init.luau") + + if not content then + error(`Could not find src/init.luau for ref {ref}`) + end + + return parseInitLuau(content) +end + +local function compareAPIs(baseline: APISurface, current: APISurface): ChangeReport + local report: ChangeReport = { + breaking = {}, + additions = {}, + modifications = {}, + } + + for name, export in baseline.exports do + if not current.exports[name] then + local change: APIChange = { + kind = ChangeKind.Removed, + name = name, + before = export, + } + + table.insert(report.breaking, change) + end + end + + for name, export in baseline.types do + if not current.types[name] then + table.insert( + report.breaking, + { + kind = ChangeKind.Removed, + name = name, + before = export, + } :: APIChange + ) + end + end + + for name, export in current.exports do + if not baseline.exports[name] then + local change: APIChange = { + kind = ChangeKind.Added, + name = name, + after = export, + } + table.insert(report.additions, change) + end + end + + for name, export in current.types do + if not baseline.types[name] then + local change: APIChange = { + kind = ChangeKind.Added, + name = name, + after = export, + } + table.insert(report.additions, change) + end + end + + return report +end + +local function getVersionBump(report: ChangeReport): "major" | "minor" | "patch" | "none" + if #report.breaking > 0 then + return "major" + elseif #report.additions > 0 then + return "minor" + elseif #report.modifications > 0 then + return "patch" + end + return "none" +end + +local function printReport(baseline: string, current: string, report: ChangeReport) + print("\n" .. string.rep("=", 60)) + print(`API Diff: {baseline} → {current}`) + print(string.rep("=", 60)) + + if #report.breaking > 0 then + print("\nšŸ”“ BREAKING CHANGES:") + for _, change in report.breaking do + print(` - Removed {if change.before then change.before.kind else "export"}: Storyteller.{change.name}`) + end + end + + if #report.additions > 0 then + print("\nāœ… ADDITIONS:") + for _, change in report.additions do + print(` + Added {if change.after then change.after.kind else "export"}: Storyteller.{change.name}`) + end + end + + if #report.modifications > 0 then + print("\nāš ļø MODIFICATIONS:") + for _, change in report.modifications do + print(` ~ Modified {change.name}`) + end + end + + if #report.breaking == 0 and #report.additions == 0 and #report.modifications == 0 then + print("\nNo API changes detected.") + end + + local bump = getVersionBump(report) + print("\nšŸ“Š Recommendation:") + if bump == "major" then + print(` Version bump: MAJOR (breaking changes)`) + elseif bump == "minor" then + print(` Version bump: MINOR (new features)`) + elseif bump == "patch" then + print(` Version bump: PATCH (modifications)`) + else + print(` Version bump: NONE (no API changes)`) + end + + print("\n" .. string.rep("=", 60) .. "\n") +end + +local function main(args: { any }) + if #args < 3 then + print("Usage: lute scripts/tasks/api-diff.luau ") + print("Examples:") + print(" lute scripts/api-diff.luau v1.0.0 HEAD") + print(" lute scripts/api-diff.luau HEAD WORKING # uncommitted changes") + process.exit(1) + end + + local _script = args[1] + local baseline = args[2] + local current = args[3] + + local gitCheck = process.run({ "git", "rev-parse", "--git-dir" }) + if not gitCheck.ok then + error("Not in a git repository") + end + + local baselineAPI = getAPISurface(baseline) + local currentAPI = getAPISurface(current) + + local report = compareAPIs(baselineAPI, currentAPI) + printReport(baseline, current, report) + + if #report.breaking > 0 then + process.exit(1) + end +end + +main({ ... }) diff --git a/scripts/api-snapshot.luau b/scripts/api-snapshot.luau new file mode 100644 index 0000000..e34a145 --- /dev/null +++ b/scripts/api-snapshot.luau @@ -0,0 +1,277 @@ +--!strict +-- API Snapshot Tool +-- +-- Snapshots the public API surface of a Luau module (value exports from the +-- return table and `export type` declarations) into a deterministic golden +-- file that can be checked into version control and verified in CI. +-- +-- Update mode (default): +-- lute run scripts/api-snapshot.luau -- ./src/init.luau +-- +-- Check mode: +-- lute run scripts/api-snapshot.luau -- --check ./src/init.luau + +local luau = require("@lute/luau") +local fs = require("@lute/fs") +local process = require("@lute/process") +local printer = require("@std/syntax/printer") + +local SNAPSHOT_PATH = "api-snapshot.txt" + +-- ──────────────────────────────────────────────────────────────────────────── +-- Argument parsing +-- ──────────────────────────────────────────────────────────────────────────── + +local rawArgs: { string } = { ... } +local checkMode = false +local modulePath: string? = nil + +-- rawArgs[1] is always the script path; skip it and any bare "--" separator +for i, arg in rawArgs do + if i == 1 then + continue + end + if arg == "--" then + continue + end + if arg == "--check" then + checkMode = true + elseif modulePath == nil then + modulePath = arg + end +end + +if modulePath == nil then + print("Usage: lute run scripts/api-snapshot.luau -- [--check] ") + print(" Path to the Luau module to snapshot (e.g. ./src/init.luau)") + print(" --check Compare against golden file instead of writing") + process.exit(1) +end + +-- ──────────────────────────────────────────────────────────────────────────── +-- Read & parse the module +-- ──────────────────────────────────────────────────────────────────────────── + +local function readFile(path: string): string + local handle = fs.open(path, "r") + local contents = fs.read(handle) + fs.close(handle) + return contents +end + +local source = readFile(modulePath :: string) +local parseResult = luau.parse(source) + +-- ──────────────────────────────────────────────────────────────────────────── +-- AST helpers +-- ──────────────────────────────────────────────────────────────────────────── + +local function nodeText(node: luau.AstNode): string + local raw = printer.printnode(node) + local trimmed = raw:match("^%s*(.-)%s*$") + return trimmed or raw +end + +-- Render the LHS of a type alias, including generic parameters. +-- e.g. "Story" or "BooleanControl" +local function typeAliasLhs(stmt: luau.AstStatTypeAlias): string + local name = stmt.name.text + + local hasGenerics = false + if stmt.generics ~= nil then + for _ in stmt.generics do + hasGenerics = true + break + end + end + if not hasGenerics and stmt.genericpacks ~= nil then + for _ in stmt.genericpacks do + hasGenerics = true + break + end + end + + if not hasGenerics then + return name + end + + local open = stmt.opengenerics and "<" or "" + local close = stmt.closegenerics and ">" or "" + + local genericItems: { string } = {} + if stmt.generics then + for _, pair in stmt.generics do + local g = pair.node + local gStr = g.name.text + if g.default then + gStr = gStr .. " = " .. nodeText(g.default) + end + table.insert(genericItems, gStr) + end + end + if stmt.genericpacks then + for _, pair in stmt.genericpacks do + local g = pair.node + local gStr = g.name.text .. "..." + if g.default then + gStr = gStr .. " = " .. nodeText(g.default) + end + table.insert(genericItems, gStr) + end + end + + return name .. open .. table.concat(genericItems, ", ") .. close +end + +-- ──────────────────────────────────────────────────────────────────────────── +-- Collect exports from the AST +-- ──────────────────────────────────────────────────────────────────────────── + +type TypeExport = { name: string, lhs: string, rhs: string } + +local typeExports: { TypeExport } = {} +local valueExports: { string } = {} + +for _, stmt in parseResult.root.statements do + if stmt.kind == "stat" and stmt.tag == "typealias" then + if stmt.export ~= nil then + local lhs = typeAliasLhs(stmt) + local rhs = nodeText(stmt["type"]) + table.insert(typeExports, { + name = stmt.name.text, + lhs = lhs, + rhs = rhs, + }) + end + elseif stmt.kind == "stat" and stmt.tag == "return" then + for _, pair in stmt.expressions do + local expr = pair.node + if expr.kind == "expr" and expr.tag == "table" then + for _, entry in expr.entries do + if entry.kind == "record" then + table.insert(valueExports, entry.key.text) + end + end + end + end + end +end + +table.sort(typeExports, function(a: TypeExport, b: TypeExport) + return a.name:lower() < b.name:lower() +end) +table.sort(valueExports, function(a: string, b: string) + return a:lower() < b:lower() +end) + +-- ──────────────────────────────────────────────────────────────────────────── +-- Serialize to the golden-file format +-- ──────────────────────────────────────────────────────────────────────────── + +local function generateSnapshot(path: string): string + local lines: { string } = { + `-- API Snapshot for {path}`, + "-- Generated by scripts/api-snapshot.luau", + "-- Do not edit manually.", + `-- Update: lute run scripts/api-snapshot.luau -- {path}`, + "", + "[values]", + } + + for _, name in valueExports do + table.insert(lines, name) + end + + table.insert(lines, "") + table.insert(lines, "[types]") + + for _, t in typeExports do + table.insert(lines, `{t.lhs} = {t.rhs}`) + end + + table.insert(lines, "") + + return table.concat(lines, "\n") +end + +-- ──────────────────────────────────────────────────────────────────────────── +-- Diff helper +-- ──────────────────────────────────────────────────────────────────────────── + +local function splitLines(s: string): { string } + local result: { string } = {} + for line in s:gmatch("[^\n]*") do + table.insert(result, line) + end + if #result > 0 and result[#result] == "" then + table.remove(result) + end + return result +end + +local function printDiff(golden: string, current: string): boolean + local goldenLines = splitLines(golden) + local currentLines = splitLines(current) + + local goldenSet: { [string]: boolean } = {} + local currentSet: { [string]: boolean } = {} + + for _, line in goldenLines do + goldenSet[line] = true + end + for _, line in currentLines do + currentSet[line] = true + end + + local hasChanges = false + + for _, line in goldenLines do + if not currentSet[line] and not line:match("^%-%-") and line ~= "" then + print(` - {line}`) + hasChanges = true + end + end + + for _, line in currentLines do + if not goldenSet[line] and not line:match("^%-%-") and line ~= "" then + print(` + {line}`) + hasChanges = true + end + end + + return hasChanges +end + +-- ──────────────────────────────────────────────────────────────────────────── +-- Main +-- ──────────────────────────────────────────────────────────────────────────── + +local snapshot = generateSnapshot(modulePath :: string) + +if checkMode then + if not fs.exists(SNAPSHOT_PATH) then + print(`[api-snapshot] Snapshot file not found: {SNAPSHOT_PATH}`) + print("[api-snapshot] Run without --check to generate the initial snapshot.") + process.exit(1) + end + + local golden = readFile(SNAPSHOT_PATH) + + if golden == snapshot then + print("[api-snapshot] API surface is unchanged.") + else + print("[api-snapshot] API surface has changed!") + print("") + local hasChanges = printDiff(golden, snapshot) + if hasChanges then + print("") + print(`[api-snapshot] Run 'lute run scripts/api-snapshot.luau -- {modulePath}' to update the snapshot.`) + end + process.exit(1) + end +else + local handle = fs.open(SNAPSHOT_PATH, "w+") + fs.write(handle, snapshot) + fs.close(handle) + print(`[api-snapshot] Snapshot written to {SNAPSHOT_PATH}`) +end diff --git a/src/types.luau b/src/types.luau index 881d92f..61fbcf6 100644 --- a/src/types.luau +++ b/src/types.luau @@ -1,8 +1,7 @@ +local ControlTypes = require("@root/controls/ControlTypes") local ModuleLoader = require("@pkg/ModuleLoader") local t = require("@pkg/t") -local ControlTypes = require("@root/controls/ControlTypes") - type ModuleLoader = ModuleLoader.ModuleLoader export type StoryControlsSchema = ControlTypes.StoryControlsSchema @@ -79,7 +78,8 @@ export type UnavailableStorybook = { export type Story = { story: T | (props: StoryProps) -> T, - controls: StoryControlsSchema?, + -- breaking change: controls are now required + controls: StoryControlsSchema, name: string?, packages: StoryPackages?, summary: string?,