diff --git a/.github/workflows/generated-types-model-overlap.yml b/.github/workflows/generated-types-model-overlap.yml new file mode 100644 index 0000000..5da62f7 --- /dev/null +++ b/.github/workflows/generated-types-model-overlap.yml @@ -0,0 +1,172 @@ +name: Generated types model overlap + +on: + workflow_call: + inputs: + project_root: + description: Directory containing @fylein/types in node_modules + required: false + type: string + default: "." + tsconfig_path: + description: TypeScript configuration used to resolve local model types + required: false + type: string + default: tsconfig.json + install_filter: + description: Optional pnpm workspace package filter + required: false + type: string + default: "" + +permissions: + contents: read + packages: read + pull-requests: write + +jobs: + model_changes: + name: Detect changed model files + runs-on: ubuntu-latest + outputs: + any_changed: ${{ steps.changed_files.outputs.any_changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fetch merge queue base + if: github.event_name == 'merge_group' + run: git fetch --depth=1 origin "${{ github.event.merge_group.base_sha }}" + + - name: Check for model changes + id: changed_files + uses: tj-actions/changed-files@v47 + with: + base_sha: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }} + files: "**/*.model.ts" + + analyze: + needs: model_changes + if: needs.model_changes.outputs.any_changed == 'true' + name: Check generated type overlap + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve workflows repository ref + id: workflows_ref + env: + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + ref="${WORKFLOW_REF##*@}" + if [[ -z "$ref" || "$ref" == refs/pull/* || "$ref" == refs/heads/gh-readonly-queue/* ]]; then + ref=master + elif [[ "$ref" == refs/heads/* ]]; then + ref="${ref#refs/heads/}" + elif [[ "$ref" == refs/tags/* ]]; then + ref="${ref#refs/tags/}" + fi + echo "ref=$ref" >> "$GITHUB_OUTPUT" + + - name: Checkout workflow tools + uses: actions/checkout@v6 + with: + repository: fylein/workflows + ref: ${{ steps.workflows_ref.outputs.ref }} + path: .generated-types-workflow + + - uses: pnpm/action-setup@v6 + with: + cache: true + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Install analysis dependencies + env: + NODE_AUTH_TOKEN: ${{ github.token }} + INSTALL_FILTER: ${{ inputs.install_filter }} + run: | + args=(--frozen-lockfile --ignore-scripts) + if [[ -n "$INSTALL_FILTER" ]]; then + args+=(--filter "$INSTALL_FILTER") + fi + pnpm install "${args[@]}" + + - name: Compare changed models with generated types + env: + BASE_SHA: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event.pull_request.base.sha }} + PROJECT_ROOT: ${{ inputs.project_root }} + TSCONFIG_PATH: ${{ inputs.tsconfig_path }} + REPORT_PATH: ${{ runner.temp }}/generated-type-overlap.json + COMMENT_PATH: ${{ runner.temp }}/generated-type-overlap.md + run: | + node .generated-types-workflow/scripts/check-generated-type-overlap.mjs \ + --base "$BASE_SHA" \ + --threshold 60 \ + --min-keys 3 \ + --types-directory "$PROJECT_ROOT/node_modules/@fylein/types/dist" \ + --tsconfig "$TSCONFIG_PATH" \ + --json "$REPORT_PATH" \ + --markdown "$COMMENT_PATH" + + - name: Publish approval requirement + id: result + env: + REPORT_PATH: ${{ runner.temp }}/generated-type-overlap.json + run: echo "requires-review=$(jq -r '.requiresReview' "$REPORT_PATH")" >> "$GITHUB_OUTPUT" + + - name: Create or update PR comment + if: github.event_name != 'merge_group' + uses: actions/github-script@v8 + env: + COMMENT_PATH: ${{ runner.temp }}/generated-type-overlap.md + REPORT_PATH: ${{ runner.temp }}/generated-type-overlap.json + with: + script: | + const { pathToFileURL } = require('node:url'); + const scriptUrl = pathToFileURL( + `${process.env.GITHUB_WORKSPACE}/.generated-types-workflow/scripts/generated-types-model-overlap-workflow.mjs`, + ).href; + const { publishGeneratedTypeOverlap } = await import(scriptUrl); + await publishGeneratedTypeOverlap({ + github, + context, + commentPath: process.env.COMMENT_PATH, + reportPath: process.env.REPORT_PATH, + }); + + - name: Verify generated-types team approval + if: github.event_name != 'merge_group' && steps.result.outputs.requires-review == 'true' + uses: actions/github-script@v8 + with: + script: | + const { pathToFileURL } = require('node:url'); + const scriptUrl = pathToFileURL( + `${process.env.GITHUB_WORKSPACE}/.generated-types-workflow/scripts/generated-types-model-overlap-workflow.mjs`, + ).href; + const { verifyGeneratedTypesApproval } = await import(scriptUrl); + await verifyGeneratedTypesApproval({ github, context, core }); + + status: + name: Generated types model overlap + needs: [model_changes, analyze] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify workflow result + env: + DETECTION_RESULT: ${{ needs.model_changes.result }} + ANALYSIS_RESULT: ${{ needs.analyze.result }} + run: | + if [[ "$DETECTION_RESULT" == "failure" || "$DETECTION_RESULT" == "cancelled" || \ + "$ANALYSIS_RESULT" == "failure" || "$ANALYSIS_RESULT" == "cancelled" ]]; then + echo "Generated type overlap checks did not pass." + exit 1 + fi + + echo "Generated type overlap checks passed." diff --git a/README.md b/README.md index 93cdf80..0e40219 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,26 @@ python3 -m pip install --disable-pip-version-check -q PyYAML==6.0.2 python3 -m unittest discover -s tests -p 'test_*.py' ``` +## Generated types model overlap + +This workflow checks changed `*.model.ts` files against the OpenAPI models installed from `@fylein/types`. It reports the closest generated type, comments on matching files, and requires approval from the API-model reviewers when the model meets the configured structural-overlap threshold and minimum field count. + +Add the reusable workflow as a job in each repository where this check is required: + +```yaml +jobs: + generated_type_overlap: + permissions: + contents: read + packages: read + pull-requests: write + uses: fylein/workflows/.github/workflows/generated-types-model-overlap.yml@master + with: + project_root: app-v2 + tsconfig_path: app-v2/tsconfig.base.json + install_filter: app-v2 +``` + ## JSCPD Duplication Check ### What It Does @@ -185,4 +205,4 @@ If you are assigned as a reviewer on a pull request where the EOL/Outdated JS Sc ## Configuration -Both workflows are designed to be called from other workflows with specific inputs. Refer to the workflow YAML files for detailed configuration options. \ No newline at end of file +These workflows are designed to be called from other workflows with specific inputs. Refer to the workflow YAML files for detailed configuration options. diff --git a/scripts/check-generated-type-overlap.mjs b/scripts/check-generated-type-overlap.mjs new file mode 100644 index 0000000..a7e93fa --- /dev/null +++ b/scripts/check-generated-type-overlap.mjs @@ -0,0 +1,609 @@ +import { execFileSync } from "node:child_process"; +import { + appendFileSync, + existsSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = process.cwd(); +const requireFromRepository = createRequire( + join(repositoryRoot, "package.json"), +); +const ts = requireFromRepository("typescript"); +const defaultTypesDirectory = join( + repositoryRoot, + "node_modules/@fylein/types/dist", +); + +const parseArguments = (argv) => { + const options = { + base: "master", + head: "HEAD", + files: [], + threshold: 80, + minimumKeys: 3, + typesDirectory: defaultTypesDirectory, + tsconfigPath: join(repositoryRoot, "tsconfig.json"), + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument.startsWith("--")) { + throw new Error(`Unexpected argument: ${argument}`); + } + + const [name, inlineValue] = argument.slice(2).split("=", 2); + const value = inlineValue ?? argv[index + 1]; + if (inlineValue === undefined) { + index += 1; + } + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for --${name}`); + } + + switch (name) { + case "base": + options.base = value; + break; + case "head": + options.head = value; + break; + case "files": + options.files.push(...value.split(",").filter(Boolean)); + break; + case "threshold": + options.threshold = Number(value); + break; + case "min-keys": + options.minimumKeys = Number(value); + break; + case "types-directory": + options.typesDirectory = resolve(value); + break; + case "tsconfig": + options.tsconfigPath = resolve(value); + break; + case "json": + options.jsonPath = resolve(value); + break; + case "markdown": + options.markdownPath = resolve(value); + break; + default: + throw new Error(`Unknown option: --${name}`); + } + } + + if ( + !Number.isFinite(options.threshold) || + options.threshold < 0 || + options.threshold > 100 + ) { + throw new Error("--threshold must be a number between 0 and 100."); + } + if (!Number.isInteger(options.minimumKeys) || options.minimumKeys < 1) { + throw new Error("--min-keys must be a positive integer."); + } + + return options; +}; + +const walkGeneratedDeclarations = (directory) => { + const declarations = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + declarations.push(...walkGeneratedDeclarations(path)); + } else if (entry.isFile() && entry.name === "types.gen.d.ts") { + declarations.push(path); + } + } + return declarations; +}; + +const getChangedModelFiles = ({ base, head }) => { + const output = execFileSync( + "git", + [ + "diff", + "--diff-filter=AMR", + "--name-only", + `${base}...${head}`, + "--", + "*.model.ts", + ], + { cwd: repositoryRoot, encoding: "utf8" }, + ); + return output + .trim() + .split("\n") + .filter(Boolean) + .map((path) => resolve(repositoryRoot, path)); +}; + +const isExported = (declaration) => + declaration.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ) ?? false; + +const isExportedTypeDeclaration = (declaration) => + ts.isTypeAliasDeclaration(declaration) && isExported(declaration); + +const getGeneratedImports = (sourceFile) => { + const importedNames = new Set(); + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) + ) { + continue; + } + if (!statement.moduleSpecifier.text.startsWith("@fylein/types")) { + continue; + } + + const importClause = statement.importClause; + if (importClause?.name) { + importedNames.add(importClause.name.text); + } + const bindings = importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + importedNames.add(element.name.text); + } + } else if (bindings && ts.isNamespaceImport(bindings)) { + importedNames.add(bindings.name.text); + } + } + return importedNames; +}; + +const getTypeReferenceRootName = (typeName) => { + let current = typeName; + while (ts.isQualifiedName(current)) { + current = current.left; + } + return ts.isIdentifier(current) ? current.text : null; +}; + +const referencesGeneratedTypeThroughWrappers = (node, generatedImports) => { + if (ts.isParenthesizedTypeNode(node)) { + return referencesGeneratedTypeThroughWrappers(node.type, generatedImports); + } + if (!ts.isTypeReferenceNode(node)) { + return false; + } + if (generatedImports.has(getTypeReferenceRootName(node.typeName))) { + return true; + } + return ( + node.typeArguments?.some((typeArgument) => + referencesGeneratedTypeThroughWrappers(typeArgument, generatedImports), + ) ?? false + ); +}; + +const isGeneratedDerived = (declaration, generatedImports) => + ts.isIntersectionTypeNode(declaration.type) && + declaration.type.types.some((type) => + referencesGeneratedTypeThroughWrappers(type, generatedImports), + ); + +const getTypeDeclarations = (type) => + [type.aliasSymbol, type.getSymbol()].flatMap( + (symbol) => symbol?.declarations ?? [], + ); + +const isExternalType = (type) => + getTypeDeclarations(type).some((declaration) => { + const sourceFile = declaration.getSourceFile(); + const fileName = sourceFile.fileName.replaceAll("\\", "/"); + return ( + sourceFile.hasNoDefaultLib || + (fileName.includes("/node_modules/") && + !fileName.includes("/@fylein/types/")) + ); + }); + +const getComparableTypes = (type) => + (type.isIntersection() ? type.types : [type]).filter( + (member) => + !(member.flags & leafTypeFlags) && !isExternalType(member), + ); + +const getPropertyNames = (checker, declaration) => { + const type = checker.getTypeAtLocation(declaration.name); + return [ + ...new Set( + getComparableTypes(type).flatMap((member) => + checker + .getPropertiesOfType(member) + .map((property) => property.getName()), + ), + ), + ] + .filter((name) => name !== "__proto__") + .sort(); +}; + +const leafTypeFlags = + ts.TypeFlags.Intrinsic | + ts.TypeFlags.Primitive | + ts.TypeFlags.TypeParameter; + +const getStructuralSignatures = (checker, declaration) => { + const signatures = new Set(); + const rootType = checker.getTypeAtLocation(declaration.name); + + const visit = (type, path, ancestors, depth) => { + if (type.flags & leafTypeFlags) { + signatures.add(`${path}:value`); + return; + } + + if (depth >= 10 || ancestors.has(type)) { + signatures.add(`${path}:object`); + return; + } + + if (type.isUnion()) { + const nextAncestors = new Set(ancestors); + nextAncestors.add(type); + for (const member of type.types) { + if ( + member.flags & + (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void) + ) { + continue; + } + visit(member, path, nextAncestors, depth + 1); + } + return; + } + + if (type.isIntersection()) { + const nextAncestors = new Set(ancestors); + nextAncestors.add(type); + for (const member of getComparableTypes(type)) { + visit(member, path, nextAncestors, depth + 1); + } + return; + } + + if (checker.isArrayType(type) || checker.isTupleType(type)) { + signatures.add(`${path}:array`); + const nextAncestors = new Set(ancestors); + nextAncestors.add(type); + const typeArguments = checker.getTypeArguments(type); + for (const elementType of typeArguments) { + visit(elementType, `${path}[]`, nextAncestors, depth + 1); + } + return; + } + + if (isExternalType(type)) { + signatures.add(`${path}:value`); + return; + } + + if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) { + signatures.add(`${path}:value`); + return; + } + + const properties = checker.getPropertiesOfType(type); + if (path) { + signatures.add(`${path}:object`); + } + + const nextAncestors = new Set(ancestors); + nextAncestors.add(type); + for (const property of properties) { + const propertyDeclaration = + property.valueDeclaration ?? property.declarations?.[0] ?? declaration; + const propertyType = checker.getTypeOfSymbolAtLocation( + property, + propertyDeclaration, + ); + const propertyPath = path + ? `${path}.${property.getName()}` + : property.getName(); + visit(propertyType, propertyPath, nextAncestors, depth + 1); + } + }; + + visit(rootType, "", new Set(), 0); + return [...signatures].filter(Boolean).sort(); +}; + +export const compareKeySets = (localKeys, generatedKeys) => { + const generatedKeySet = new Set(generatedKeys); + const localKeySet = new Set(localKeys); + const matchedKeys = localKeys.filter((key) => generatedKeySet.has(key)); + const missingLocalKeys = localKeys.filter((key) => !generatedKeySet.has(key)); + const extraGeneratedKeys = generatedKeys.filter( + (key) => !localKeySet.has(key), + ); + const coverage = + localKeys.length === 0 ? 0 : (matchedKeys.length / localKeys.length) * 100; + const unionSize = new Set([...localKeys, ...generatedKeys]).size; + const jaccard = unionSize === 0 ? 0 : matchedKeys.length / unionSize; + + return { + coverage, + jaccard, + matchedKeys, + missingLocalKeys, + extraGeneratedKeys, + }; +}; + +const getGeneratedCandidates = (program, checker, generatedFiles) => { + const candidates = []; + for (const generatedFile of generatedFiles) { + const sourceFile = program.getSourceFile(generatedFile); + if (!sourceFile) { + continue; + } + const role = basename(dirname(generatedFile)); + for (const declaration of sourceFile.statements) { + if (!isExportedTypeDeclaration(declaration)) { + continue; + } + const keys = getPropertyNames(checker, declaration); + if (keys.length > 0) { + candidates.push({ + name: declaration.name.text, + role, + keys, + structuralSignatures: getStructuralSignatures(checker, declaration), + }); + } + } + } + return candidates; +}; + +const findClosestCandidate = ( + localKeys, + localStructuralSignatures, + candidates, +) => { + let closest = null; + for (const candidate of candidates) { + const topLevelComparison = compareKeySets(localKeys, candidate.keys); + const structuralComparison = compareKeySets( + localStructuralSignatures, + candidate.structuralSignatures, + ); + if ( + !closest || + structuralComparison.coverage > closest.structuralCoverage || + (structuralComparison.coverage === closest.structuralCoverage && + topLevelComparison.coverage > closest.coverage) || + (structuralComparison.coverage === closest.structuralCoverage && + topLevelComparison.coverage === closest.coverage && + structuralComparison.jaccard > closest.structuralJaccard) || + (structuralComparison.coverage === closest.structuralCoverage && + topLevelComparison.coverage === closest.coverage && + structuralComparison.jaccard === closest.structuralJaccard && + candidate.keys.length < closest.generatedKeyCount) + ) { + closest = { + name: candidate.name, + role: candidate.role, + generatedKeyCount: candidate.keys.length, + ...topLevelComparison, + structuralCoverage: structuralComparison.coverage, + structuralJaccard: structuralComparison.jaccard, + matchedStructuralSignatures: structuralComparison.matchedKeys, + missingStructuralSignatures: structuralComparison.missingLocalKeys, + }; + } + } + return closest?.coverage === 0 && closest.structuralCoverage === 0 + ? null + : closest; +}; + +const readCompilerOptions = (tsconfigPath) => { + const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (config.error) { + throw new Error( + ts.flattenDiagnosticMessageText(config.error.messageText, "\n"), + ); + } + return ts.parseJsonConfigFileContent( + config.config, + ts.sys, + dirname(tsconfigPath), + ).options; +}; + +export const analyzeModelFiles = ({ + files, + minimumKeys, + threshold, + typesDirectory, + tsconfigPath, +}) => { + if (!existsSync(typesDirectory)) { + throw new Error( + `Generated types directory not found: ${typesDirectory}. Run pnpm install first.`, + ); + } + + const modelFiles = files + .map((file) => resolve(file)) + .filter((file) => existsSync(file) && statSync(file).isFile()); + const generatedFiles = walkGeneratedDeclarations(typesDirectory); + const program = ts.createProgram({ + rootNames: [...modelFiles, ...generatedFiles], + options: { + ...readCompilerOptions(tsconfigPath), + noEmit: true, + skipLibCheck: true, + }, + }); + const checker = program.getTypeChecker(); + const candidates = getGeneratedCandidates(program, checker, generatedFiles); + const results = []; + + for (const file of modelFiles) { + const sourceFile = program.getSourceFile(file); + if (!sourceFile) { + continue; + } + const generatedImports = getGeneratedImports(sourceFile); + for (const declaration of sourceFile.statements) { + if (!isExportedTypeDeclaration(declaration)) { + continue; + } + const localKeys = getPropertyNames(checker, declaration); + if (localKeys.length === 0) { + continue; + } + const localStructuralSignatures = getStructuralSignatures( + checker, + declaration, + ); + const generatedDerived = isGeneratedDerived( + declaration, + generatedImports, + ); + const closest = findClosestCandidate( + localKeys, + localStructuralSignatures, + candidates, + ); + const requiresReview = Boolean( + closest && + !generatedDerived && + localStructuralSignatures.length >= minimumKeys && + closest.structuralCoverage >= threshold, + ); + results.push({ + file: relative(repositoryRoot, file), + exportName: declaration.name.text, + localKeyCount: localKeys.length, + localStructuralSignatureCount: localStructuralSignatures.length, + generatedDerived, + requiresReview, + closest, + }); + } + } + + return { + requiresReview: results.some((result) => result.requiresReview), + results, + candidateCount: candidates.length, + }; +}; + +const formatPercentage = (value) => `${value.toFixed(1)}%`; + +const formatConsoleReport = (report, options, durationMs) => { + const lines = [ + `Generated type overlap check: ${report.results.length} exported object model(s), ${report.candidateCount} generated candidate(s), ${durationMs.toFixed(0)} ms`, + `Threshold: ${options.threshold}% of local structure; minimum structural members: ${options.minimumKeys}`, + ]; + if (report.results.length === 0) { + lines.push("No models to check."); + return lines.join("\n"); + } + + for (const result of report.results) { + const closest = result.closest; + lines.push("", `${result.file} — ${result.exportName}`); + if (!closest) { + lines.push(" Closest generated type available in @fylein/types: none"); + continue; + } + lines.push( + ` Closest generated type available in @fylein/types: @fylein/types/${closest.role} ${closest.name}`, + ` Matching top-level keys: ${closest.matchedKeys.length}/${result.localKeyCount} (${formatPercentage(closest.coverage)})`, + ` Matching nested structure: ${closest.matchedStructuralSignatures.length}/${result.localStructuralSignatureCount} (${formatPercentage(closest.structuralCoverage)})`, + ` Extends generated type: ${result.generatedDerived ? "yes" : "no"}`, + ` Result: ${result.requiresReview ? "❌ ACTION REQUIRED - REPLACE OR REQUEST APPROVAL" : "✅ PASS - NO ACTION REQUIRED"}`, + ); + } + return lines.join("\n"); +}; + +const formatMarkdownReport = (report, options, durationMs) => { + const rows = report.results.map((result) => { + const closest = result.closest; + return [ + `\`${result.file}\``, + `\`${result.exportName}\``, + closest + ? `\`@fylein/types/${closest.role}\` \`${closest.name}\`` + : "None", + closest + ? `${closest.matchedStructuralSignatures.length}/${result.localStructuralSignatureCount} fields (${formatPercentage(closest.structuralCoverage)})` + : "—", + result.requiresReview + ? "❌ Action required" + : "✅ Pass - no action required", + ].join(" | "); + }); + return [ + "## Generated type overlap", + "", + `Threshold: **${options.threshold}%**, minimum structural members: **${options.minimumKeys}**, runtime: **${durationMs.toFixed(0)} ms**.`, + "", + "| File | Current model/type | Closest generated type available in `@fylein/types` | Structural overlap | Result |", + "| --- | --- | --- | --- | --- |", + ...(rows.length > 0 ? rows : ["| — | — | — | — | No models to check |"]), + "", + ].join("\n"); +}; + +const main = () => { + const options = parseArguments(process.argv.slice(2)); + const files = + options.files.length > 0 + ? options.files.map((file) => resolve(file)) + : getChangedModelFiles(options); + const startedAt = performance.now(); + const report = analyzeModelFiles({ + files, + minimumKeys: options.minimumKeys, + threshold: options.threshold, + typesDirectory: options.typesDirectory, + tsconfigPath: options.tsconfigPath, + }); + const durationMs = performance.now() - startedAt; + const output = { + ...report, + durationMs, + threshold: options.threshold, + minimumKeys: options.minimumKeys, + checkedFiles: files.map((file) => relative(repositoryRoot, resolve(file))), + }; + + process.stdout.write(`${formatConsoleReport(report, options, durationMs)}\n`); + if (options.jsonPath) { + writeFileSync(options.jsonPath, `${JSON.stringify(output, null, 2)}\n`); + } + const markdownReport = formatMarkdownReport(report, options, durationMs); + if (options.markdownPath) { + writeFileSync(options.markdownPath, markdownReport); + } + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdownReport); + } +}; + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/scripts/generated-types-model-overlap-workflow.mjs b/scripts/generated-types-model-overlap-workflow.mjs new file mode 100644 index 0000000..47858b5 --- /dev/null +++ b/scripts/generated-types-model-overlap-workflow.mjs @@ -0,0 +1,204 @@ +import { readFileSync } from "node:fs"; + +const summaryMarker = ""; +const inlineMarkerPrefix = "`; + const imports = [ + ...Map.groupBy(findings, (finding) => finding.closest.role), + ] + .map(([role, roleFindings]) => { + const names = [ + ...new Set(roleFindings.map((finding) => finding.closest.name)), + ].sort(); + return `import { ${names.join(", ")} } from '@fylein/types/${role}';`; + }) + .join("\n"); + const matches = findings + .map( + (finding) => + `- \`${finding.exportName}\` → \`${finding.closest.name}\` (${finding.closest.structuralCoverage.toFixed(1)}% structural overlap)`, + ) + .join("\n"); + const inlineBody = `${marker} +This model overlaps generated OpenAPI types above the configured threshold: + +${matches} + +**Action:** Replace the handwritten model with the generated type where compatible, update its consumers, and remove the local model/export when it is no longer used. + +\`\`\`ts +${imports} +\`\`\` + +Verify that the suggested role matches the endpoint. For a dynamic multi-role endpoint, use the matching \`@fylein/types/cross-role\` export when available. If the generated contract is not compatible, retain the model and request approval from a member of [@fylein/${reviewerTeam}](${reviewerTeamUrl}).`; + const existingReviewComment = botReviewComments.find((comment) => + comment.body?.includes(marker), + ); + const response = existingReviewComment + ? await github.rest.pulls.updateReviewComment({ + owner, + repo, + comment_id: existingReviewComment.id, + body: inlineBody, + }) + : await github.rest.pulls.createReviewComment({ + ...pullRequest, + commit_id: headSha, + path, + subject_type: "file", + body: inlineBody, + }); + inlineCommentUrls.set(path, response.data.html_url); + } + + for (const comment of botReviewComments) { + if (!findingsByFile.has(comment.path)) { + await github.rest.pulls.deleteReviewComment({ + owner, + repo, + comment_id: comment.id, + }); + } + } + + for (const result of report.results) { + const encodedFile = result.file + .split("/") + .map(encodeURIComponent) + .join("/"); + const fileUrl = `https://github.com/${owner}/${repo}/blob/${headSha}/${encodedFile}`; + const tableCells = `\`${result.file}\` | \`${result.exportName}\``; + const linkedTableCells = `[${result.file}](${fileUrl}) | \`${result.exportName}\``; + markdown = markdown + .split("\n") + .map((line) => { + if (!line.startsWith(tableCells)) { + return line; + } + const linkedLine = line.replace(tableCells, linkedTableCells); + return result.requiresReview + ? `${linkedLine} - [View comment](${inlineCommentUrls.get(result.file)})` + : linkedLine; + }) + .join("\n"); + } + + const body = `${summaryMarker}\n${markdown}`; + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } +}; + +export const verifyGeneratedTypesApproval = async ({ + github, + context, + core, +}) => { + const { owner, repo } = context.repo; + const pull_number = context.issue.number; + const headSha = context.payload.pull_request.head.sha; + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number, + per_page: 100, + }); + const latestDecisions = new Map( + reviews + .filter( + (review) => + review.user && + ["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(review.state), + ) + .map((review) => [review.user.login, review]), + ); + const approval = [...latestDecisions.values()].find( + (review) => + review.state === "APPROVED" && + review.commit_id === headSha && + approvedReviewerLogins.has(review.user.login.toLowerCase()), + ); + + if (approval) { + core.info( + `Approved by @${approval.user.login} from @fylein/${reviewerTeam}.`, + ); + return; + } + + const pullRequest = { owner, repo, pull_number }; + const { data: requestedReviewers } = + await github.rest.pulls.listRequestedReviewers(pullRequest); + const teamIsRequested = requestedReviewers.teams.some( + (team) => team.slug === reviewerTeam, + ); + if (!teamIsRequested) { + await github.rest.pulls.requestReviewers({ + ...pullRequest, + team_reviewers: [reviewerTeam], + }); + } + + core.setFailed(`Approval required from a member of @fylein/${reviewerTeam}.`); +}; diff --git a/tests/check-generated-type-overlap.test.mjs b/tests/check-generated-type-overlap.test.mjs new file mode 100644 index 0000000..faaf666 --- /dev/null +++ b/tests/check-generated-type-overlap.test.mjs @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + analyzeModelFiles, + compareKeySets, +} from "../scripts/check-generated-type-overlap.mjs"; + +const writeTsconfig = (directory) => { + const tsconfigPath = join(directory, "tsconfig.json"); + writeFileSync( + tsconfigPath, + `${JSON.stringify({ compilerOptions: { moduleResolution: "bundler", module: "es2020", strict: true } })}\n`, + ); + return tsconfigPath; +}; + +test("calculates local-key coverage and diagnostic key lists", () => { + const comparison = compareKeySets( + ["id", "name", "state", "label"], + ["id", "name", "state", "created_at"], + ); + + assert.equal(comparison.coverage, 75); + assert.deepEqual(comparison.matchedKeys, ["id", "name", "state"]); + assert.deepEqual(comparison.missingLocalKeys, ["label"]); + assert.deepEqual(comparison.extraGeneratedKeys, ["created_at"]); +}); + +test("does not report overlap for an empty local key set", () => { + assert.equal(compareKeySets([], ["id"]).coverage, 0); +}); + +test("finds and flags the closest generated model", () => { + const temporaryDirectory = mkdtempSync( + join(tmpdir(), "generated-type-overlap-"), + ); + const generatedDirectory = join(temporaryDirectory, "admin"); + const modelPath = join(temporaryDirectory, "legacy-expense-field.model.ts"); + mkdirSync(generatedDirectory, { recursive: true }); + const expenseFieldShape = `{ + id: number; + org_id: string; + created_at: string; + updated_at: string; + seq: number; + field_name: string; + column_name: string; + type: string; + options: string[]; + default_value: string | null; + placeholder: string | null; + category_ids: number[]; + is_enabled: boolean; + is_custom: boolean; + is_mandatory: boolean; + }`; + writeFileSync( + join(generatedDirectory, "types.gen.d.ts"), + `export type ExpenseFieldOut = ${expenseFieldShape};\n`, + ); + writeFileSync( + modelPath, + `export type LegacyExpenseField = ${expenseFieldShape};\n`, + ); + + try { + const report = analyzeModelFiles({ + files: [modelPath], + minimumKeys: 3, + threshold: 80, + typesDirectory: temporaryDirectory, + tsconfigPath: writeTsconfig(temporaryDirectory), + }); + + assert.equal(report.requiresReview, true); + assert.equal(report.results[0].closest.name, "ExpenseFieldOut"); + assert.equal(report.results[0].closest.coverage, 100); + assert.equal(report.results[0].closest.structuralCoverage, 100); + } finally { + rmSync(temporaryDirectory, { recursive: true }); + } +}); + +test("does not treat matching parent keys as matching nested objects or array elements", () => { + const temporaryDirectory = mkdtempSync( + join(tmpdir(), "generated-type-overlap-nested-"), + ); + const generatedDirectory = join(temporaryDirectory, "admin"); + const modelPath = join(temporaryDirectory, "nested-local.model.ts"); + const generatedPath = join(generatedDirectory, "types.gen.d.ts"); + + mkdirSync(generatedDirectory, { recursive: true }); + writeFileSync( + generatedPath, + `export type NestedGenerated = { + employee: { id: string; name: string }; + files: Array<{ id: string; url: string }>; + };\n`, + ); + writeFileSync( + modelPath, + `export type NestedLocal = { + employee: { email: string; active: boolean }; + files: Array<{ size: number; state: string }>; + }; + + export type UnrelatedLocal = { + benchmark_panel_heading: string; + benchmark_panel_is_expanded: boolean; + benchmark_panel_sort_index: number; + };\n`, + ); + + try { + const report = analyzeModelFiles({ + files: [modelPath], + minimumKeys: 2, + threshold: 80, + typesDirectory: temporaryDirectory, + tsconfigPath: writeTsconfig(temporaryDirectory), + }); + + const nestedResult = report.results.find( + (result) => result.exportName === "NestedLocal", + ); + const unrelatedResult = report.results.find( + (result) => result.exportName === "UnrelatedLocal", + ); + + assert.equal(report.results.length, 2); + assert.equal(nestedResult.closest.name, "NestedGenerated"); + assert.equal(nestedResult.closest.coverage, 100); + assert.ok(nestedResult.closest.structuralCoverage < 80); + assert.equal(nestedResult.requiresReview, false); + assert.equal(unrelatedResult.closest, null); + } finally { + rmSync(temporaryDirectory, { recursive: true }); + } +}); + +test("traverses generated types but treats external types as opaque", () => { + const temporaryDirectory = mkdtempSync( + join(tmpdir(), "generated-type-overlap-derived-"), + ); + const generatedDirectory = join(temporaryDirectory, "admin"); + const packageDirectory = join( + temporaryDirectory, + "node_modules/@fylein/types", + ); + const externalPackageDirectory = join( + temporaryDirectory, + "node_modules/external-package", + ); + const modelPath = join(temporaryDirectory, "generated-usage.model.ts"); + mkdirSync(generatedDirectory, { recursive: true }); + mkdirSync(packageDirectory, { recursive: true }); + mkdirSync(externalPackageDirectory, { recursive: true }); + const generatedType = `export type ExpenseFieldOut = { + id: string; + name: string; + state: string; + };\n`; + writeFileSync(join(generatedDirectory, "types.gen.d.ts"), generatedType); + writeFileSync(join(packageDirectory, "cross-role.d.ts"), generatedType); + writeFileSync( + join(packageDirectory, "package.json"), + `${JSON.stringify({ exports: { "./cross-role": "./cross-role.d.ts" } })}\n`, + ); + writeFileSync( + join(externalPackageDirectory, "index.d.ts"), + "export type ExternalState = { deeply: { nested: string } };\n", + ); + writeFileSync( + join(externalPackageDirectory, "package.json"), + `${JSON.stringify({ types: "./index.d.ts" })}\n`, + ); + writeFileSync( + modelPath, + `import { ExpenseFieldOut } from '@fylein/types/cross-role'; + import { ExternalState } from 'external-package'; + + type Wrapper = Readonly; + + export type UIExpenseFieldExtension = Wrapper & { label: string }; + + export type ExpenseFieldContainer = { + expenseField: ExpenseFieldOut; + label: string; + selected: boolean; + }; + + export type WindowWithPerformanceObserver = Window & { + PerformanceObserver?: typeof PerformanceObserver; + }; + + export type ExternalStateContainer = { + label: string; + state: ExternalState; + };\n`, + ); + + try { + const report = analyzeModelFiles({ + files: [modelPath], + minimumKeys: 3, + threshold: 80, + typesDirectory: temporaryDirectory, + tsconfigPath: writeTsconfig(temporaryDirectory), + }); + const extension = report.results.find( + (result) => result.exportName === "UIExpenseFieldExtension", + ); + const container = report.results.find( + (result) => result.exportName === "ExpenseFieldContainer", + ); + const windowExtension = report.results.find( + (result) => result.exportName === "WindowWithPerformanceObserver", + ); + const externalContainer = report.results.find( + (result) => result.exportName === "ExternalStateContainer", + ); + + assert.equal(extension.generatedDerived, true); + assert.equal(container.generatedDerived, false); + assert.ok(container.localStructuralSignatureCount > 3); + assert.equal(windowExtension.localStructuralSignatureCount, 1); + assert.equal(externalContainer.localStructuralSignatureCount, 2); + } finally { + rmSync(temporaryDirectory, { recursive: true }); + } +});