From b813fa37e9eadcbf97ad71021b6a46b37c7a9ea7 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 9 Oct 2023 17:18:59 +0200 Subject: [PATCH 1/5] feat: add a feature to diff --- .projenrc.ts | 4 +- .../service-spec-build/.projen/deps.json | 8 +-- .../@aws-cdk/service-spec-build/package.json | 2 +- .../service-spec-build/src/cli/diff-db.ts | 16 +++++ .../@aws-cdk/service-spec-build/src/diff.ts | 15 +++++ .../@aws-cdk/service-spec-build/src/index.ts | 1 + .../service-spec-build/tsconfig.dev.json | 4 +- .../@aws-cdk/service-spec-build/tsconfig.json | 4 +- .../@aws-cdk/service-spec-types/src/index.ts | 1 + .../service-spec-types/src/types/diff.ts | 67 +++++++++++++++++++ 10 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts create mode 100644 packages/@aws-cdk/service-spec-build/src/diff.ts create mode 100644 packages/@aws-cdk/service-spec-types/src/types/diff.ts diff --git a/.projenrc.ts b/.projenrc.ts index 4705ab316..b7dc06858 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -113,8 +113,8 @@ const serviceSpecBuild = new TypeScriptWorkspace({ parent: repo, name: '@aws-cdk/service-spec-build', description: 'Build the service spec from service-spec-sources to service-spec', - deps: [tsKb, serviceSpecSources, serviceSpecTypes], - devDeps: ['source-map-support'], + deps: [tsKb, serviceSpecTypes], + devDeps: ['source-map-support', serviceSpecSources], private: true, }); const buildDb = serviceSpecBuild.tasks.addTask('build:db', { diff --git a/packages/@aws-cdk/service-spec-build/.projen/deps.json b/packages/@aws-cdk/service-spec-build/.projen/deps.json index df45f1629..6d5fdbc1f 100644 --- a/packages/@aws-cdk/service-spec-build/.projen/deps.json +++ b/packages/@aws-cdk/service-spec-build/.projen/deps.json @@ -1,5 +1,9 @@ { "dependencies": [ + { + "name": "@aws-cdk/service-spec-sources", + "type": "build" + }, { "name": "@types/jest", "type": "build" @@ -78,10 +82,6 @@ "name": "typescript", "type": "build" }, - { - "name": "@aws-cdk/service-spec-sources", - "type": "runtime" - }, { "name": "@aws-cdk/service-spec-types", "type": "runtime" diff --git a/packages/@aws-cdk/service-spec-build/package.json b/packages/@aws-cdk/service-spec-build/package.json index 7f4beb29d..80d212b25 100644 --- a/packages/@aws-cdk/service-spec-build/package.json +++ b/packages/@aws-cdk/service-spec-build/package.json @@ -22,6 +22,7 @@ "projen": "npx projen" }, "devDependencies": { + "@aws-cdk/service-spec-sources": "^0.0.0", "@types/jest": "^29.5.5", "@types/node": "^16", "@typescript-eslint/eslint-plugin": "^6", @@ -42,7 +43,6 @@ "typescript": "^4.9.5" }, "dependencies": { - "@aws-cdk/service-spec-sources": "^0.0.0", "@aws-cdk/service-spec-types": "^0.0.0", "@cdklabs/tskb": "^0.0.0" }, diff --git a/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts b/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts new file mode 100644 index 000000000..d1c88dcdf --- /dev/null +++ b/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts @@ -0,0 +1,16 @@ +import { Property, RichPropertyType, RichSpecDatabase, SpecDatabase, loadDatabase } from '@aws-cdk/service-spec-types'; + +async function main(args: string[]) { + if (args.length < 2) { + throw new Error('Usage: diff-db '); + } + const db1 = await loadDatabase(args[0]); + const db2 = await loadDatabase(args[1]); + + +} + +main(process.argv.slice(2)).catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/packages/@aws-cdk/service-spec-build/src/diff.ts b/packages/@aws-cdk/service-spec-build/src/diff.ts new file mode 100644 index 000000000..999ae7f4c --- /dev/null +++ b/packages/@aws-cdk/service-spec-build/src/diff.ts @@ -0,0 +1,15 @@ +import { SpecDatabase, SpecDatabaseDiff } from '@aws-cdk/service-spec-types'; + +export class DbDiff { + private result: SpecDatabaseDiff = { + resources: { added: [], removed: [], updated: [] }, + services: { added: [], removed: [], updated: [] }, + typeDefinitions: { added: [], removed: [], updated: [] } + }; + + constructor(private readonly db1: SpecDatabase, private readonly db2: SpecDatabase) {} + + public diff() { + for(cont + } +} diff --git a/packages/@aws-cdk/service-spec-build/src/index.ts b/packages/@aws-cdk/service-spec-build/src/index.ts index a1b082de7..10ab7ec54 100644 --- a/packages/@aws-cdk/service-spec-build/src/index.ts +++ b/packages/@aws-cdk/service-spec-build/src/index.ts @@ -1 +1,2 @@ export * from './build-database'; +export * from './diff'; diff --git a/packages/@aws-cdk/service-spec-build/tsconfig.dev.json b/packages/@aws-cdk/service-spec-build/tsconfig.dev.json index 82d895d12..bb5b2a70f 100644 --- a/packages/@aws-cdk/service-spec-build/tsconfig.dev.json +++ b/packages/@aws-cdk/service-spec-build/tsconfig.dev.json @@ -40,10 +40,10 @@ "path": "../../@cdklabs/tskb" }, { - "path": "../service-spec-sources" + "path": "../service-spec-types" }, { - "path": "../service-spec-types" + "path": "../service-spec-sources" } ] } diff --git a/packages/@aws-cdk/service-spec-build/tsconfig.json b/packages/@aws-cdk/service-spec-build/tsconfig.json index 6d9d0016f..965ce942d 100644 --- a/packages/@aws-cdk/service-spec-build/tsconfig.json +++ b/packages/@aws-cdk/service-spec-build/tsconfig.json @@ -37,10 +37,10 @@ "path": "../../@cdklabs/tskb" }, { - "path": "../service-spec-sources" + "path": "../service-spec-types" }, { - "path": "../service-spec-types" + "path": "../service-spec-sources" } ] } diff --git a/packages/@aws-cdk/service-spec-types/src/index.ts b/packages/@aws-cdk/service-spec-types/src/index.ts index 60ae3e390..567d20206 100644 --- a/packages/@aws-cdk/service-spec-types/src/index.ts +++ b/packages/@aws-cdk/service-spec-types/src/index.ts @@ -2,3 +2,4 @@ export * from './types/database'; export * from './types/resource'; export * from './types/augmentations'; export * from './types/metrics'; +export * from './types/diff'; diff --git a/packages/@aws-cdk/service-spec-types/src/types/diff.ts b/packages/@aws-cdk/service-spec-types/src/types/diff.ts new file mode 100644 index 000000000..298f299ae --- /dev/null +++ b/packages/@aws-cdk/service-spec-types/src/types/diff.ts @@ -0,0 +1,67 @@ +import { Attribute, Property, PropertyType, Resource, Service, TypeDefinition } from './resource'; + +export interface SpecDatabaseDiff { + readonly services: ListDiff; + readonly resources: ListDiff; + readonly typeDefinitions: ListDiff; +} + +export interface ListDiff { + readonly added: E[]; + readonly removed: E[]; + readonly updated: ED[]; +} + +export interface MapDiff { + readonly added: Record; + readonly removed: Record; + readonly updated: Record; +} + +interface UpdatedService { + readonly name?: ScalarDiff; + readonly shortName?: ScalarDiff; + readonly capitalized?: ScalarDiff; + readonly cloudFormationNamespace?: ScalarDiff; +} + +interface UpdatedResource { + readonly name?: ScalarDiff; + readonly cloudFormationType?: ScalarDiff; + readonly cloudFormationTransform?: ScalarDiff; + readonly documentation?: ScalarDiff; + readonly properties?: MapDiff; + readonly attributes?: MapDiff; + readonly identifier?: ScalarDiff; + readonly isStateful?: ScalarDiff; + readonly tagInformation?: ScalarDiff; + readonly scrutinizable?: ScalarDiff; +} + +interface UpdatedProperty { + readonly documentation?: ScalarDiff; + readonly required?: ScalarDiff; + readonly type?: ScalarDiff; + readonly previousTypes?: ListDiff; + readonly defaultValue?: ScalarDiff; + readonly deprecated?: ScalarDiff; + readonly scrutinizable?: ScalarDiff; +} + +interface UpdatedAttribute { + readonly documentation?: string; + readonly type?: ScalarDiff; + readonly previousTypes?: ListDiff; +} + +interface UpdatedTypeDefinition { + readonly name?: ScalarDiff; + readonly documentation?: ScalarDiff; + readonly properties?: MapDiff; + readonly mustRenderForBwCompat?: ScalarDiff; +} + +interface ScalarDiff { + readonly old?: A; + readonly new?: A; +} From 83ef530726bfcd8887a7173b713ac7a4d95cf180 Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Wed, 11 Oct 2023 09:38:10 -0400 Subject: [PATCH 2/5] get everything to build --- packages/@aws-cdk/service-spec-build/package.json | 2 +- packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts | 5 +++-- packages/@aws-cdk/service-spec-build/src/diff.ts | 8 +++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/@aws-cdk/service-spec-build/package.json b/packages/@aws-cdk/service-spec-build/package.json index 80d212b25..769b76a6e 100644 --- a/packages/@aws-cdk/service-spec-build/package.json +++ b/packages/@aws-cdk/service-spec-build/package.json @@ -22,7 +22,6 @@ "projen": "npx projen" }, "devDependencies": { - "@aws-cdk/service-spec-sources": "^0.0.0", "@types/jest": "^29.5.5", "@types/node": "^16", "@typescript-eslint/eslint-plugin": "^6", @@ -44,6 +43,7 @@ }, "dependencies": { "@aws-cdk/service-spec-types": "^0.0.0", + "@aws-cdk/service-spec-sources": "^0.0.0", "@cdklabs/tskb": "^0.0.0" }, "main": "lib/index.js", diff --git a/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts b/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts index d1c88dcdf..1548bee07 100644 --- a/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts +++ b/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts @@ -1,4 +1,5 @@ -import { Property, RichPropertyType, RichSpecDatabase, SpecDatabase, loadDatabase } from '@aws-cdk/service-spec-types'; +import { loadDatabase } from '@aws-cdk/service-spec-types'; +import { DbDiff } from '../diff'; async function main(args: string[]) { if (args.length < 2) { @@ -7,7 +8,7 @@ async function main(args: string[]) { const db1 = await loadDatabase(args[0]); const db2 = await loadDatabase(args[1]); - + new DbDiff(db1, db2).diff(); } main(process.argv.slice(2)).catch((e) => { diff --git a/packages/@aws-cdk/service-spec-build/src/diff.ts b/packages/@aws-cdk/service-spec-build/src/diff.ts index 999ae7f4c..0ce674be6 100644 --- a/packages/@aws-cdk/service-spec-build/src/diff.ts +++ b/packages/@aws-cdk/service-spec-build/src/diff.ts @@ -4,12 +4,14 @@ export class DbDiff { private result: SpecDatabaseDiff = { resources: { added: [], removed: [], updated: [] }, services: { added: [], removed: [], updated: [] }, - typeDefinitions: { added: [], removed: [], updated: [] } + typeDefinitions: { added: [], removed: [], updated: [] }, }; - constructor(private readonly db1: SpecDatabase, private readonly db2: SpecDatabase) {} + constructor(private readonly db1: SpecDatabase, private readonly db2: SpecDatabase) { + console.log(this.db1.id, this.db2.id); + } public diff() { - for(cont + console.log(JSON.stringify(this.result)); } } From 7f37ad38514f85851db53ecb52db5f6468fddbdd Mon Sep 17 00:00:00 2001 From: Kaizen Conroy Date: Fri, 13 Oct 2023 18:49:50 -0400 Subject: [PATCH 3/5] rudimentary skeleton for diff --- .projenrc.ts | 4 +++ .../service-spec-build/.projen/tasks.json | 9 +++++ .../@aws-cdk/service-spec-build/package.json | 3 +- .../@aws-cdk/service-spec-build/src/diff.ts | 35 +++++++++++++++++-- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.projenrc.ts b/.projenrc.ts index b7dc06858..a1a011596 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -125,6 +125,10 @@ serviceSpecBuild.tasks.addTask('analyze:db', { exec: 'ts-node src/cli/analyze-db', receiveArgs: true, }); +serviceSpecBuild.tasks.addTask('diff:db', { + exec: 'ts-node src/cli/diff-db', + receiveArgs: true, +}); serviceSpecBuild.gitignore.addPatterns('db.json'); serviceSpecBuild.gitignore.addPatterns('build-report'); diff --git a/packages/@aws-cdk/service-spec-build/.projen/tasks.json b/packages/@aws-cdk/service-spec-build/.projen/tasks.json index ee5a0d6d5..aee90b5b5 100644 --- a/packages/@aws-cdk/service-spec-build/.projen/tasks.json +++ b/packages/@aws-cdk/service-spec-build/.projen/tasks.json @@ -77,6 +77,15 @@ } ] }, + "diff:db": { + "name": "diff:db", + "steps": [ + { + "exec": "ts-node src/cli/diff-db", + "receiveArgs": true + } + ] + }, "eslint": { "name": "eslint", "description": "Runs eslint against the codebase", diff --git a/packages/@aws-cdk/service-spec-build/package.json b/packages/@aws-cdk/service-spec-build/package.json index 769b76a6e..52dfe2861 100644 --- a/packages/@aws-cdk/service-spec-build/package.json +++ b/packages/@aws-cdk/service-spec-build/package.json @@ -9,6 +9,7 @@ "check-for-updates": "npx projen check-for-updates", "compile": "npx projen compile", "default": "npx projen default", + "diff:db": "npx projen diff:db", "eslint": "npx projen eslint", "gather-versions": "npx projen gather-versions", "nx": "npx projen nx", @@ -22,6 +23,7 @@ "projen": "npx projen" }, "devDependencies": { + "@aws-cdk/service-spec-sources": "^0.0.0", "@types/jest": "^29.5.5", "@types/node": "^16", "@typescript-eslint/eslint-plugin": "^6", @@ -43,7 +45,6 @@ }, "dependencies": { "@aws-cdk/service-spec-types": "^0.0.0", - "@aws-cdk/service-spec-sources": "^0.0.0", "@cdklabs/tskb": "^0.0.0" }, "main": "lib/index.js", diff --git a/packages/@aws-cdk/service-spec-build/src/diff.ts b/packages/@aws-cdk/service-spec-build/src/diff.ts index 0ce674be6..90e7a0818 100644 --- a/packages/@aws-cdk/service-spec-build/src/diff.ts +++ b/packages/@aws-cdk/service-spec-build/src/diff.ts @@ -1,4 +1,4 @@ -import { SpecDatabase, SpecDatabaseDiff } from '@aws-cdk/service-spec-types'; +import { Resource, Service, SpecDatabase, SpecDatabaseDiff } from '@aws-cdk/service-spec-types'; export class DbDiff { private result: SpecDatabaseDiff = { @@ -8,7 +8,38 @@ export class DbDiff { }; constructor(private readonly db1: SpecDatabase, private readonly db2: SpecDatabase) { - console.log(this.db1.id, this.db2.id); + const db1Stats = this.distillDatabase(this.db1); + const db2Stats = this.distillDatabase(this.db2); + for (const id of Object.keys(db1Stats.resources)) { + // Case doesn't exist in db2 + if (!db2Stats.resources[id]) { + this.result.resources.removed.push(db1Stats.resources[id]); + } + // Case found the same ids + // TODO: removed case + } + } + + private distillDatabase(db: SpecDatabase) { + // const richDb = new RichSpecDatabase(db); + const services: Record = db.all('service').reduce((obj, item) => { + return { + ...obj, + [item.$id]: item, + }; + }, {}); + const resources: Record = db.all('resource').reduce((obj, item) => { + return { + ...obj, + [item.$id]: item, + }; + }, {}); + + return { + services, + resources, + // typeDefinitions: resources.flatMap((r) => richDb.resourceTypeDefs(r.cloudFormationType)), + }; } public diff() { From c66d79448ea419130063b4528c9cf34e9e3b1f6e Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 18 Oct 2023 15:24:38 +0200 Subject: [PATCH 4/5] Finish diff tool --- .projenrc.ts | 4 +- .../service-spec-build/.projen/deps.json | 17 +- .../service-spec-build/.projen/tasks.json | 2 +- .../@aws-cdk/service-spec-build/package.json | 6 +- .../service-spec-build/src/cli/diff-db.ts | 39 +- .../service-spec-build/src/db-diff.ts | 150 +++++++ .../service-spec-build/src/diff-fmt.ts | 373 ++++++++++++++++++ .../service-spec-build/src/diff-helpers.ts | 150 +++++++ .../@aws-cdk/service-spec-build/src/diff.ts | 48 --- .../@aws-cdk/service-spec-build/src/index.ts | 2 +- .../service-spec-types/src/types/database.ts | 5 +- .../service-spec-types/src/types/diff.ts | 46 +-- .../service-spec-types/src/types/resource.ts | 10 +- yarn.lock | 7 +- 14 files changed, 761 insertions(+), 98 deletions(-) create mode 100644 packages/@aws-cdk/service-spec-build/src/db-diff.ts create mode 100644 packages/@aws-cdk/service-spec-build/src/diff-fmt.ts create mode 100644 packages/@aws-cdk/service-spec-build/src/diff-helpers.ts delete mode 100644 packages/@aws-cdk/service-spec-build/src/diff.ts diff --git a/.projenrc.ts b/.projenrc.ts index a1a011596..bee2c1578 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -113,8 +113,8 @@ const serviceSpecBuild = new TypeScriptWorkspace({ parent: repo, name: '@aws-cdk/service-spec-build', description: 'Build the service spec from service-spec-sources to service-spec', - deps: [tsKb, serviceSpecTypes], - devDeps: ['source-map-support', serviceSpecSources], + deps: [tsKb, serviceSpecTypes, 'commander', 'chalk@^4', serviceSpecSources], + devDeps: ['source-map-support'], private: true, }); const buildDb = serviceSpecBuild.tasks.addTask('build:db', { diff --git a/packages/@aws-cdk/service-spec-build/.projen/deps.json b/packages/@aws-cdk/service-spec-build/.projen/deps.json index 6d5fdbc1f..5b282e303 100644 --- a/packages/@aws-cdk/service-spec-build/.projen/deps.json +++ b/packages/@aws-cdk/service-spec-build/.projen/deps.json @@ -1,9 +1,5 @@ { "dependencies": [ - { - "name": "@aws-cdk/service-spec-sources", - "type": "build" - }, { "name": "@types/jest", "type": "build" @@ -82,6 +78,10 @@ "name": "typescript", "type": "build" }, + { + "name": "@aws-cdk/service-spec-sources", + "type": "runtime" + }, { "name": "@aws-cdk/service-spec-types", "type": "runtime" @@ -89,6 +89,15 @@ { "name": "@cdklabs/tskb", "type": "runtime" + }, + { + "name": "chalk", + "version": "^4", + "type": "runtime" + }, + { + "name": "commander", + "type": "runtime" } ], "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." diff --git a/packages/@aws-cdk/service-spec-build/.projen/tasks.json b/packages/@aws-cdk/service-spec-build/.projen/tasks.json index aee90b5b5..1b59a6fa5 100644 --- a/packages/@aws-cdk/service-spec-build/.projen/tasks.json +++ b/packages/@aws-cdk/service-spec-build/.projen/tasks.json @@ -54,7 +54,7 @@ }, "steps": [ { - "exec": "npm-check-updates --upgrade --target=minor --peer --dep=dev,peer,prod,optional --filter=@types/jest,@types/node,@typescript-eslint/eslint-plugin,@typescript-eslint/parser,eslint-config-prettier,eslint-import-resolver-node,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-prettier,eslint,jest,jest-junit,npm-check-updates,prettier,projen,source-map-support,ts-jest,typescript" + "exec": "npm-check-updates --upgrade --target=minor --peer --dep=dev,peer,prod,optional --filter=@types/jest,@types/node,@typescript-eslint/eslint-plugin,@typescript-eslint/parser,eslint-config-prettier,eslint-import-resolver-node,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-prettier,eslint,jest,jest-junit,npm-check-updates,prettier,projen,source-map-support,ts-jest,typescript,chalk,commander" } ] }, diff --git a/packages/@aws-cdk/service-spec-build/package.json b/packages/@aws-cdk/service-spec-build/package.json index 52dfe2861..0a6558cd2 100644 --- a/packages/@aws-cdk/service-spec-build/package.json +++ b/packages/@aws-cdk/service-spec-build/package.json @@ -23,7 +23,6 @@ "projen": "npx projen" }, "devDependencies": { - "@aws-cdk/service-spec-sources": "^0.0.0", "@types/jest": "^29.5.5", "@types/node": "^16", "@typescript-eslint/eslint-plugin": "^6", @@ -44,8 +43,11 @@ "typescript": "^4.9.5" }, "dependencies": { + "@aws-cdk/service-spec-sources": "^0.0.0", "@aws-cdk/service-spec-types": "^0.0.0", - "@cdklabs/tskb": "^0.0.0" + "@cdklabs/tskb": "^0.0.0", + "chalk": "^4", + "commander": "^11.1.0" }, "main": "lib/index.js", "license": "Apache-2.0", diff --git a/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts b/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts index 1548bee07..bae844674 100644 --- a/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts +++ b/packages/@aws-cdk/service-spec-build/src/cli/diff-db.ts @@ -1,17 +1,42 @@ import { loadDatabase } from '@aws-cdk/service-spec-types'; -import { DbDiff } from '../diff'; +import { Command } from 'commander'; +import { DbDiff } from '../db-diff'; +import { DiffFormatter } from '../diff-fmt'; + +async function main() { + const program = new Command(); + + program + .name('diff-db') + .description('Calculate differences between two databases') + .argument('', 'First database file') + .argument('', 'Second database file') + .option('-j, --json', 'Output json', false) + .parse(); + const options = program.opts(); + const args = program.args; -async function main(args: string[]) { - if (args.length < 2) { - throw new Error('Usage: diff-db '); - } const db1 = await loadDatabase(args[0]); const db2 = await loadDatabase(args[1]); - new DbDiff(db1, db2).diff(); + const result = new DbDiff(db1, db2).diff(); + + const hasChanges = + Object.keys(result.services.added ?? {}).length + + Object.keys(result.services.removed ?? {}).length + + Object.keys(result.services.updated ?? {}).length > + 0; + + if (options.json) { + console.log(JSON.stringify(result, undefined, 2)); + } else { + console.log(new DiffFormatter(db1, db2).format(result)); + } + + process.exitCode = hasChanges ? 1 : 0; } -main(process.argv.slice(2)).catch((e) => { +main().catch((e) => { console.error(e); process.exitCode = 1; }); diff --git a/packages/@aws-cdk/service-spec-build/src/db-diff.ts b/packages/@aws-cdk/service-spec-build/src/db-diff.ts new file mode 100644 index 000000000..78a6cce79 --- /dev/null +++ b/packages/@aws-cdk/service-spec-build/src/db-diff.ts @@ -0,0 +1,150 @@ +import { + Attribute, + Property, + PropertyType, + Resource, + RichPropertyType, + Service, + SpecDatabase, + SpecDatabaseDiff, + TypeDefinition, + UpdatedAttribute, + UpdatedProperty, + UpdatedResource, + UpdatedService, + UpdatedTypeDefinition, +} from '@aws-cdk/service-spec-types'; +import { + diffByKey, + collapseUndefined, + diffScalar, + collapseEmptyDiff, + jsonEq, + diffMap, + diffList, + diffField, +} from './diff-helpers'; + +export class DbDiff { + constructor(private readonly db1: SpecDatabase, private readonly db2: SpecDatabase) {} + + public diff(): SpecDatabaseDiff { + return { + services: diffByKey( + this.db1.all('service'), + this.db2.all('service'), + (service) => service.name, + (a, b) => this.diffService(a, b), + ), + }; + } + + private diffService(a: Service, b: Service): UpdatedService | undefined { + return collapseUndefined({ + capitalized: diffScalar(a, b, 'capitalized'), + cloudFormationNamespace: diffScalar(a, b, 'cloudFormationNamespace'), + name: diffScalar(a, b, 'name'), + shortName: diffScalar(a, b, 'shortName'), + resourceDiff: this.diffServiceResources(a, b), + }); + } + + private diffServiceResources(a: Service, b: Service): UpdatedService['resourceDiff'] { + const aRes = this.db1.follow('hasResource', a).map((r) => r.entity); + const bRes = this.db2.follow('hasResource', b).map((r) => r.entity); + + return collapseEmptyDiff( + diffByKey( + aRes, + bRes, + (resource) => resource.cloudFormationType, + (x, y) => this.diffResource(x, y), + ), + ); + } + + private diffResource(a: Resource, b: Resource): UpdatedResource | undefined { + return collapseUndefined({ + cloudFormationTransform: diffScalar(a, b, 'cloudFormationTransform'), + documentation: diffScalar(a, b, 'documentation'), + cloudFormationType: diffScalar(a, b, 'cloudFormationType'), + isStateful: diffScalar(a, b, 'isStateful'), + identifier: diffField(a, b, 'identifier', jsonEq), + name: diffScalar(a, b, 'name'), + scrutinizable: diffScalar(a, b, 'scrutinizable'), + tagInformation: diffField(a, b, 'tagInformation', jsonEq), + attributes: collapseEmptyDiff(diffMap(a.attributes, b.attributes, (x, y) => this.diffAttribute(x, y))), + properties: collapseEmptyDiff(diffMap(a.properties, b.properties, (x, y) => this.diffProperty(x, y))), + typeDefinitionDiff: this.diffResourceTypeDefinitions(a, b), + }); + } + + private diffAttribute(a: Attribute, b: Attribute): UpdatedAttribute | undefined { + const eqType = this.eqType.bind(this); + + const anyDiffs = collapseUndefined({ + documentation: diffScalar(a, b, 'documentation'), + previousTypes: collapseEmptyDiff(diffList(a.previousTypes ?? [], b.previousTypes ?? [], eqType)), + type: diffField(a, b, 'type', eqType), + }); + + if (anyDiffs) { + return { old: a, new: b }; + } + return undefined; + } + + private diffProperty(a: Property, b: Property): UpdatedProperty | undefined { + const eqType = this.eqType.bind(this); + + const anyDiffs = collapseUndefined({ + documentation: diffScalar(a, b, 'documentation'), + defaultValue: diffScalar(a, b, 'defaultValue'), + deprecated: diffScalar(a, b, 'deprecated'), + required: diffScalar(a, b, 'required'), + scrutinizable: diffScalar(a, b, 'scrutinizable'), + previousTypes: collapseEmptyDiff(diffList(a.previousTypes ?? [], b.previousTypes ?? [], eqType)), + type: diffField(a, b, 'type', eqType), + }); + + if (anyDiffs) { + return { old: a, new: b }; + } + return undefined; + } + + private diffResourceTypeDefinitions(a: Resource, b: Resource): UpdatedResource['typeDefinitionDiff'] { + const aTypes = this.db1.follow('usesType', a).map((r) => r.entity); + const bTypes = this.db2.follow('usesType', b).map((r) => r.entity); + + return collapseEmptyDiff( + diffByKey( + aTypes, + bTypes, + (type) => type.name, + (x, y) => this.diffTypeDefinition(x, y), + ), + ); + } + + private diffTypeDefinition(a: TypeDefinition, b: TypeDefinition): UpdatedTypeDefinition | undefined { + return collapseUndefined({ + documentation: diffScalar(a, b, 'documentation'), + name: diffScalar(a, b, 'name'), + mustRenderForBwCompat: diffScalar(a, b, 'mustRenderForBwCompat'), + properties: collapseEmptyDiff(diffMap(a.properties, b.properties, (x, y) => this.diffProperty(x, y))), + }); + } + + /** + * Tricky -- we have to deep-compare all the type references which will have different ids in + * different databases. + * + * Solve it by doing a string-render and comparing those (for now). + */ + private eqType(a: PropertyType, b: PropertyType): boolean { + const s1 = new RichPropertyType(a).stringify(this.db1, false); + const s2 = new RichPropertyType(b).stringify(this.db2, false); + return s1 === s2; + } +} diff --git a/packages/@aws-cdk/service-spec-build/src/diff-fmt.ts b/packages/@aws-cdk/service-spec-build/src/diff-fmt.ts new file mode 100644 index 000000000..67f2fc206 --- /dev/null +++ b/packages/@aws-cdk/service-spec-build/src/diff-fmt.ts @@ -0,0 +1,373 @@ +import { + Attribute, + Deprecation, + MapDiff, + Property, + Resource, + RichPropertyType, + ScalarDiff, + Service, + SpecDatabase, + SpecDatabaseDiff, + TypeDefinition, + UpdatedAttribute, + UpdatedProperty, + UpdatedResource, + UpdatedService, + UpdatedTypeDefinition, +} from '@aws-cdk/service-spec-types'; +import chalk from 'chalk'; + +const ADDITION = '[+]'; +const UPDATE = '[~]'; +const REMOVAL = '[-]'; +const META_INDENT = ' '; + +type Colorizer = (x: string) => string; + +const [OLD_DB, NEW_DB] = [0, 1]; + +function ident(x: string) { + return x; +} + +export class DiffFormatter { + private readonly buffer = new Array(); + private readonly prefix = new Array(); + private readonly colors: Colorizer[] = [ident]; + private readonly dbs: SpecDatabase[]; + + constructor(db1: SpecDatabase, db2: SpecDatabase) { + this.dbs = [db1, db2]; + } + + public format(diff: SpecDatabaseDiff): string { + this.buffer.splice(0, this.buffer.length); + + this.renderMapDiff( + 'service', + diff.services, + (s, db) => this.renderService(s, db), + (u) => this.renderUpdatedService(u), + ); + + return this.buffer.join(''); + } + + private renderService(s: Service, db: number) { + this.plainStringBlock( + META_INDENT, + listFromProps(s, ['capitalized', 'cloudFormationNamespace', 'name', 'shortName']), + ); + + this.emitList( + this.dbs[db].follow('hasResource', s).map((x) => x.entity), + (resource, last) => + this.withBullet(last, () => { + this.renderResource(resource, db); + }), + ); + } + + private renderUpdatedService(s: UpdatedService) { + const d = pick(s, ['capitalized', 'cloudFormationNamespace', 'name', 'shortName']); + this.plainStringBlock(META_INDENT, listFromDiffs(d)); + + this.renderMapDiff( + 'resource', + s.resourceDiff, + (r, db) => this.renderResource(r, db), + (u) => this.renderUpdatedResource(u), + ); + } + + private renderResource(r: Resource, db: number) { + this.plainStringBlock( + META_INDENT, + listFromProps(r, [ + 'name', + 'identifier', + 'cloudFormationType', + 'cloudFormationTransform', + 'documentation', + 'identifier', + 'isStateful', + 'scrutinizable', + 'tagInformation', + ]), + ); + + // FIXME: props, attributes + + this.emitList( + this.dbs[db].follow('usesType', r).map((x) => x.entity), + (typeDef, last) => + this.withBullet(last, () => { + this.renderTypeDefinition(typeDef, db); + }), + ); + } + + private renderUpdatedResource(s: UpdatedResource) { + const d = pick(s, [ + 'name', + 'identifier', + 'cloudFormationType', + 'cloudFormationTransform', + 'documentation', + 'identifier', + 'isStateful', + 'scrutinizable', + 'tagInformation', + ]); + this.plainStringBlock(META_INDENT, listFromDiffs(d)); + + this.withPrefix(META_INDENT, () => { + if (s.properties) { + this.emit('properties\n'); + this.renderMapDiff( + 'prop', + s.properties, + (p, db) => this.renderProperty(p, db), + (u) => this.renderUpdatedProperty(u), + ); + } + + if (s.attributes) { + this.emit('attributes\n'); + this.renderMapDiff( + 'attr', + s.attributes, + (p, db) => this.renderAttribute(p, db), + (u) => this.renderUpdatedAttribute(u), + ); + } + }); + + this.renderMapDiff( + 'type', + s.typeDefinitionDiff, + (p, db) => this.renderTypeDefinition(p, db), + (u) => this.renderUpdatedTypeDefinition(u), + ); + } + + private renderTypeDefinition(r: TypeDefinition, db: number) { + this.plainStringBlock(META_INDENT, listFromProps(r, ['documentation', 'mustRenderForBwCompat', 'name'])); + + // Properties + this.emitList(Object.entries(r.properties), ([name, p], last) => { + this.withBullet(last, () => { + this.emit(`${name}: `); + this.renderProperty(p, db); + }); + }); + } + + private renderUpdatedTypeDefinition(t: UpdatedTypeDefinition) { + const d = pick(t, ['documentation', 'mustRenderForBwCompat', 'name']); + this.plainStringBlock(META_INDENT, listFromDiffs(d)); + + this.withPrefix(' ', () => { + this.renderMapDiff( + 'prop', + t.properties, + (p, db) => this.renderProperty(p, db), + (u) => this.renderUpdatedProperty(u), + ); + }); + } + + private renderProperty(p: Property, db: number) { + const types = [p.type, ...(p.previousTypes ?? []).reverse()]; + this.emit(types.map((type) => new RichPropertyType(type).stringify(this.dbs[db], false)).join(' ⇐ ')); + + const attributes = []; + if (p.defaultValue) { + attributes.push(`default=${render(p.defaultValue)}`); + } + if (p.deprecated && p.deprecated !== Deprecation.NONE) { + attributes.push(`deprecated=${p.deprecated}`); + } + // FIXME: Documentation? + + if (attributes.length) { + this.emit(` (${attributes.join(', ')})`); + } + } + + private renderUpdatedProperty(t: UpdatedProperty) { + this.withColor(chalk.red, () => { + this.renderProperty(t.old, OLD_DB); + }); + this.emit('\n'); + this.withColor(chalk.green, () => { + this.renderProperty(t.new, NEW_DB); + }); + } + + private renderAttribute(a: Attribute, db: number) { + const types = [a.type, ...(a.previousTypes ?? []).reverse()]; + this.emit(types.map((type) => new RichPropertyType(type).stringify(this.dbs[db], false)).join(' ⇐ ')); + } + + private renderUpdatedAttribute(t: UpdatedAttribute) { + this.withColor(chalk.red, () => { + this.renderAttribute(t.old, OLD_DB); + }); + this.emit('\n'); + this.withColor(chalk.green, () => { + this.renderAttribute(t.new, NEW_DB); + }); + } + + private renderMapDiff( + type: string, + diff: MapDiff | undefined, + renderEl: (x: E, dbI: number) => void, + renderUpdated: (x: U) => void, + ) { + if (!diff) { + return; + } + + // Turn the lists into maps + const keys = Array.from( + new Set([ + ...Object.keys(diff.added ?? {}), + ...Object.keys(diff.removed ?? {}), + ...Object.keys(diff.updated ?? {}), + ]), + ); + keys.sort((a, b) => a.localeCompare(b)); + + this.emitList(keys, (key, last) => { + if (diff.added?.[key]) { + this.withColor(chalk.green, () => + this.withBullet(last, () => { + this.emit(ADDITION); + this.emit(` ${type} ${key}\n`); + renderEl(diff.added?.[key]!, NEW_DB); + }), + ); + } else if (diff.removed?.[key]) { + this.withColor(chalk.green, () => + this.withBullet(last, () => { + this.emit(REMOVAL); + this.emit(` ${type} ${key}\n`); + renderEl(diff.removed?.[key]!, OLD_DB); + }), + ); + } else if (diff.updated?.[key]) { + this.withBullet(last, () => { + this.emit(chalk.yellow(UPDATE)); + this.emit(` ${type} ${key}\n`); + renderUpdated(diff.updated?.[key]!); + }); + } + }); + } + + private emitList(as: A[], block: (a: A, last: boolean) => void) { + for (let i = 0; i < as.length; i++) { + if (i > 0) { + this.emit('\n'); + } + const last = i === as.length - 1; + block(as[i], last); + } + } + + private withBullet(last: boolean, block: () => void): void; + private withBullet(last: boolean, additionalIndent: string, block: () => void): void; + private withBullet(last: boolean, blockOrIndent: string | (() => void), block?: () => void) { + const header = last ? '└' : '├'; + const indent = (last ? ' ' : '│') + (typeof blockOrIndent === 'string' ? blockOrIndent : ' '); + const theBlock = typeof blockOrIndent === 'function' ? blockOrIndent : block; + + this.emit(header); + this.withPrefix(indent, theBlock!); + } + + private withHeader(header: string, indent: string, block: () => void) { + this.emit(header); + this.withPrefix(indent, block); + } + + private plainStringBlock(indent: string, as: string[]) { + if (as.length === 0) { + return; + } + + this.withHeader(indent, indent, () => { + this.emitList(as, (a) => { + this.emit(a); + }); + }); + } + + private emit(x: string) { + if (this.buffer.length && this.buffer[this.buffer.length - 1].endsWith('\n')) { + this.buffer.push(this.currentPrefix); + } + + // Replace newlines with the prefix, except if the string ends in one + const parts = x.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) { + this.buffer.push('\n'); + if (i < parts.length || parts[i] !== '') { + this.buffer.push(this.currentPrefix); + } + } + this.buffer.push(this.currentColor(parts[i])); + } + } + + private get currentPrefix() { + return this.prefix.join(''); + } + + private get currentColor() { + return this.colors[this.colors.length - 1]; + } + + private withPrefix(x: string, block: () => void) { + this.prefix.push(this.currentColor(x)); + try { + block(); + } finally { + this.prefix.pop(); + } + } + + private withColor(col: Colorizer, block: () => void) { + this.colors.push(col); + try { + block(); + } finally { + this.colors.pop(); + } + } +} + +function listFromProps(a: A, ks: K[]) { + return listFromObj(pick(a, ks)); +} + +function listFromObj(xs: Record): string[] { + return Object.entries(xs).map(([k, v]) => `${String(k)}: ${render(v)}`); +} + +function pick(a: A, ks: K[]): Pick { + const pairs = ks.flatMap((k) => (a[k] !== undefined ? [[k, a[k]] as const] : [])); + return Object.fromEntries(pairs) as any; +} + +function listFromDiffs(xs: Record | undefined>): string[] { + return Object.entries(xs).map(([key, diff]) => `${String(key)}: ${render(diff?.old)} → ${render(diff?.new)}`); +} + +function render(x: unknown) { + return typeof x === 'object' ? JSON.stringify(x) : `${x}`; +} diff --git a/packages/@aws-cdk/service-spec-build/src/diff-helpers.ts b/packages/@aws-cdk/service-spec-build/src/diff-helpers.ts new file mode 100644 index 000000000..3100fb81a --- /dev/null +++ b/packages/@aws-cdk/service-spec-build/src/diff-helpers.ts @@ -0,0 +1,150 @@ +import { MapDiff, ListDiff, ScalarDiff } from '@aws-cdk/service-spec-types'; + +export function diffByKey(a: A[], b: A[], keyFn: (x: A) => string, updatedDiff: (a: A, b: A) => B | undefined) { + return diffMap( + Object.fromEntries(a.map((x) => [keyFn(x), x])), + Object.fromEntries(b.map((x) => [keyFn(x), x])), + updatedDiff, + ); +} + +export function diffMap( + a: Record, + b: Record, + updatedDiff: (a: A, b: A) => B | undefined, +): MapDiff { + const added = new Set(Object.keys(b)); + + const ret: Required> = { + added: {}, + removed: {}, + updated: {}, + }; + + for (const [key, value] of Object.entries(a)) { + if (key in b) { + const deep = updatedDiff(value, b[key]); + if (deep) { + ret.updated[key] = deep; + } + added.delete(key); + } else { + ret.removed[key] = value; + } + } + for (const key of added) { + ret.added[key] = b[key]; + } + + return { + ...(Object.keys(ret.added).length > 0 ? { added: ret.added } : {}), + ...(Object.keys(ret.removed).length > 0 ? { removed: ret.removed } : {}), + ...(Object.keys(ret.updated).length > 0 ? { updated: ret.updated } : {}), + }; +} + +/** + * Diff a list by quadratically comparing all elements + */ +export function diffList(as: A[], bs: A[], eq: Eq, updatedDiff: (a: A, b: A) => B | undefined): ListDiff; +export function diffList(as: A[], bs: A[], eq: Eq): ListDiff; +export function diffList( + as: A[], + bs: A[], + eq: Eq, + updatedDiff?: (a: A, b: A) => B | undefined, +): ListDiff { + const added: Array = [...bs]; + + const ret: Required> = { + added: [], + removed: [], + updated: [], + }; + + for (const a of as) { + let found = false; + for (let i = 0; i < added.length; i++) { + const b = added[i]; + if (b === undefined) { + continue; + } + + if (eq(a, b)) { + found = true; + const deep = updatedDiff?.(a, b); + if (deep) { + ret.updated.push(deep); + } + // Mark off + added[i] = undefined; + break; + } + } + + if (!found) { + ret.removed.push(a); + } + } + + for (const b of added) { + if (b !== undefined) { + ret.added.push(b); + } + } + + return { + ...(ret.added.length > 0 ? { added: ret.added } : {}), + ...(ret.removed.length > 0 ? { removed: ret.removed } : {}), + ...(ret.updated.length > 0 ? { updated: ret.updated } : {}), + }; +} + +export function tripleEq(a: A, b: A): boolean { + return a === b; +} + +export function jsonEq(a: A, b: A): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function diffScalar( + a: A, + b: A, + k: K, +): A[K] extends string | number | boolean | undefined ? ScalarDiff> | undefined : void { + // Complex return type makes it so that the === comparison only works on scalars, and for other types + // the user must use diffField with a custom equality function + return diffField(a, b, k, tripleEq) as any; +} + +export function diffField( + a: A, + b: A, + k: K, + eq: Eq, +): ScalarDiff> | undefined { + if (eq(a[k], b[k])) { + return undefined; + } + return { + old: a[k]!, + new: b[k]!, + }; +} + +/** + * Return the object if it has any defined fields, otherwise undefined + */ +export function collapseUndefined(x: A): A | undefined { + return Object.keys(x).some((key) => (x as any)[key] !== undefined) ? x : undefined; +} + +export function collapseEmptyDiff | MapDiff>(x: A): A | undefined { + return Object.keys(x.added ?? {}).length + Object.keys(x.removed ?? {}).length + Object.keys(x.updated ?? {}).length > + 0 + ? x + : undefined; +} + +export type Eq = (x: A, y: A) => boolean; diff --git a/packages/@aws-cdk/service-spec-build/src/diff.ts b/packages/@aws-cdk/service-spec-build/src/diff.ts deleted file mode 100644 index 90e7a0818..000000000 --- a/packages/@aws-cdk/service-spec-build/src/diff.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Resource, Service, SpecDatabase, SpecDatabaseDiff } from '@aws-cdk/service-spec-types'; - -export class DbDiff { - private result: SpecDatabaseDiff = { - resources: { added: [], removed: [], updated: [] }, - services: { added: [], removed: [], updated: [] }, - typeDefinitions: { added: [], removed: [], updated: [] }, - }; - - constructor(private readonly db1: SpecDatabase, private readonly db2: SpecDatabase) { - const db1Stats = this.distillDatabase(this.db1); - const db2Stats = this.distillDatabase(this.db2); - for (const id of Object.keys(db1Stats.resources)) { - // Case doesn't exist in db2 - if (!db2Stats.resources[id]) { - this.result.resources.removed.push(db1Stats.resources[id]); - } - // Case found the same ids - // TODO: removed case - } - } - - private distillDatabase(db: SpecDatabase) { - // const richDb = new RichSpecDatabase(db); - const services: Record = db.all('service').reduce((obj, item) => { - return { - ...obj, - [item.$id]: item, - }; - }, {}); - const resources: Record = db.all('resource').reduce((obj, item) => { - return { - ...obj, - [item.$id]: item, - }; - }, {}); - - return { - services, - resources, - // typeDefinitions: resources.flatMap((r) => richDb.resourceTypeDefs(r.cloudFormationType)), - }; - } - - public diff() { - console.log(JSON.stringify(this.result)); - } -} diff --git a/packages/@aws-cdk/service-spec-build/src/index.ts b/packages/@aws-cdk/service-spec-build/src/index.ts index 10ab7ec54..a1a2007c4 100644 --- a/packages/@aws-cdk/service-spec-build/src/index.ts +++ b/packages/@aws-cdk/service-spec-build/src/index.ts @@ -1,2 +1,2 @@ export * from './build-database'; -export * from './diff'; +export * from './db-diff'; diff --git a/packages/@aws-cdk/service-spec-types/src/types/database.ts b/packages/@aws-cdk/service-spec-types/src/types/database.ts index 2167bf9a1..fabceb55d 100644 --- a/packages/@aws-cdk/service-spec-types/src/types/database.ts +++ b/packages/@aws-cdk/service-spec-types/src/types/database.ts @@ -1,4 +1,5 @@ import { promises as fs } from 'fs'; +import { gunzipSync } from 'zlib'; import { Database, entityCollection, fieldIndex, stringCmp } from '@cdklabs/tskb'; import { IsAugmentedResource, ResourceAugmentation } from './augmentations'; import { @@ -61,7 +62,9 @@ export function emptyDatabase() { export async function loadDatabase(pathToDb: string) { const db = emptyDatabase(); - db.load(JSON.parse(await fs.readFile(pathToDb, { encoding: 'utf-8' }))); + const contents = await fs.readFile(pathToDb); + const json = pathToDb.endsWith('.gz') ? gunzipSync(contents).toString('utf-8') : contents.toString('utf-8'); + db.load(JSON.parse(json)); return db; } diff --git a/packages/@aws-cdk/service-spec-types/src/types/diff.ts b/packages/@aws-cdk/service-spec-types/src/types/diff.ts index 298f299ae..bd5b6bdc9 100644 --- a/packages/@aws-cdk/service-spec-types/src/types/diff.ts +++ b/packages/@aws-cdk/service-spec-types/src/types/diff.ts @@ -1,31 +1,30 @@ -import { Attribute, Property, PropertyType, Resource, Service, TypeDefinition } from './resource'; +import { Attribute, Property, Resource, Service, TypeDefinition } from './resource'; export interface SpecDatabaseDiff { - readonly services: ListDiff; - readonly resources: ListDiff; - readonly typeDefinitions: ListDiff; + services: MapDiff; } export interface ListDiff { - readonly added: E[]; - readonly removed: E[]; - readonly updated: ED[]; + readonly added?: E[]; + readonly removed?: E[]; + readonly updated?: ED[]; } export interface MapDiff { - readonly added: Record; - readonly removed: Record; - readonly updated: Record; + readonly added?: Record; + readonly removed?: Record; + readonly updated?: Record; } -interface UpdatedService { +export interface UpdatedService { readonly name?: ScalarDiff; readonly shortName?: ScalarDiff; readonly capitalized?: ScalarDiff; readonly cloudFormationNamespace?: ScalarDiff; + readonly resourceDiff?: MapDiff; } -interface UpdatedResource { +export interface UpdatedResource { readonly name?: ScalarDiff; readonly cloudFormationType?: ScalarDiff; readonly cloudFormationTransform?: ScalarDiff; @@ -36,32 +35,27 @@ interface UpdatedResource { readonly isStateful?: ScalarDiff; readonly tagInformation?: ScalarDiff; readonly scrutinizable?: ScalarDiff; + readonly typeDefinitionDiff?: MapDiff; } -interface UpdatedProperty { - readonly documentation?: ScalarDiff; - readonly required?: ScalarDiff; - readonly type?: ScalarDiff; - readonly previousTypes?: ListDiff; - readonly defaultValue?: ScalarDiff; - readonly deprecated?: ScalarDiff; - readonly scrutinizable?: ScalarDiff; +export interface UpdatedProperty { + readonly old: Property; + readonly new: Property; } -interface UpdatedAttribute { - readonly documentation?: string; - readonly type?: ScalarDiff; - readonly previousTypes?: ListDiff; +export interface UpdatedAttribute { + readonly old: Attribute; + readonly new: Attribute; } -interface UpdatedTypeDefinition { +export interface UpdatedTypeDefinition { readonly name?: ScalarDiff; readonly documentation?: ScalarDiff; readonly properties?: MapDiff; readonly mustRenderForBwCompat?: ScalarDiff; } -interface ScalarDiff { +export interface ScalarDiff { readonly old?: A; readonly new?: A; } diff --git a/packages/@aws-cdk/service-spec-types/src/types/resource.ts b/packages/@aws-cdk/service-spec-types/src/types/resource.ts index b1eeb7db4..2702a57b3 100644 --- a/packages/@aws-cdk/service-spec-types/src/types/resource.ts +++ b/packages/@aws-cdk/service-spec-types/src/types/resource.ts @@ -458,7 +458,7 @@ export class RichPropertyType { } } - public stringify(db: SpecDatabase): string { + public stringify(db: SpecDatabase, withId = true): string { switch (this.type.type) { case 'integer': case 'boolean': @@ -470,14 +470,14 @@ export class RichPropertyType { case 'tag': return this.type.type; case 'array': - return `Array<${new RichPropertyType(this.type.element).stringify(db)}>`; + return `Array<${new RichPropertyType(this.type.element).stringify(db, withId)}>`; case 'map': - return `Map`; + return `Map`; case 'ref': const type = db.get('typeDefinition', this.type.reference); - return `${type.name}(${this.type.reference.$ref})`; + return withId ? `${type.name}(${this.type.reference.$ref})` : type.name; case 'union': - return this.type.types.map((t) => new RichPropertyType(t).stringify(db)).join(' | '); + return this.type.types.map((t) => new RichPropertyType(t).stringify(db, withId)).join(' | '); } } diff --git a/yarn.lock b/yarn.lock index 2be25dea5..f28358718 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1682,7 +1682,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: +chalk@^4, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -1820,6 +1820,11 @@ commander@^11.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== +commander@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906" + integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== + comment-json@4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.2.2.tgz#5fae70a94e0c8f84a077bd31df5aa5269252f293" From 0809362912fbbd368cdbe75f94d2bf141733db33 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 18 Oct 2023 17:52:13 +0200 Subject: [PATCH 5/5] Refactor tree drawing code to tree emitter --- .../service-spec-build/src/diff-fmt.ts | 173 +++++------------- .../service-spec-build/src/tree-emitter.ts | 104 +++++++++++ 2 files changed, 146 insertions(+), 131 deletions(-) create mode 100644 packages/@aws-cdk/service-spec-build/src/tree-emitter.ts diff --git a/packages/@aws-cdk/service-spec-build/src/diff-fmt.ts b/packages/@aws-cdk/service-spec-build/src/diff-fmt.ts index 67f2fc206..549521b1b 100644 --- a/packages/@aws-cdk/service-spec-build/src/diff-fmt.ts +++ b/packages/@aws-cdk/service-spec-build/src/diff-fmt.ts @@ -17,24 +17,17 @@ import { UpdatedTypeDefinition, } from '@aws-cdk/service-spec-types'; import chalk from 'chalk'; +import { TreeEmitter } from './tree-emitter'; const ADDITION = '[+]'; const UPDATE = '[~]'; const REMOVAL = '[-]'; const META_INDENT = ' '; -type Colorizer = (x: string) => string; - const [OLD_DB, NEW_DB] = [0, 1]; -function ident(x: string) { - return x; -} - export class DiffFormatter { - private readonly buffer = new Array(); - private readonly prefix = new Array(); - private readonly colors: Colorizer[] = [ident]; + private readonly tree = new TreeEmitter(); private readonly dbs: SpecDatabase[]; constructor(db1: SpecDatabase, db2: SpecDatabase) { @@ -42,7 +35,7 @@ export class DiffFormatter { } public format(diff: SpecDatabaseDiff): string { - this.buffer.splice(0, this.buffer.length); + this.tree.clear(); this.renderMapDiff( 'service', @@ -51,19 +44,19 @@ export class DiffFormatter { (u) => this.renderUpdatedService(u), ); - return this.buffer.join(''); + return this.tree.toString(); } private renderService(s: Service, db: number) { - this.plainStringBlock( + this.tree.plainStringBlock( META_INDENT, listFromProps(s, ['capitalized', 'cloudFormationNamespace', 'name', 'shortName']), ); - this.emitList( + this.tree.emitList( this.dbs[db].follow('hasResource', s).map((x) => x.entity), (resource, last) => - this.withBullet(last, () => { + this.tree.withBullet(last, () => { this.renderResource(resource, db); }), ); @@ -71,7 +64,7 @@ export class DiffFormatter { private renderUpdatedService(s: UpdatedService) { const d = pick(s, ['capitalized', 'cloudFormationNamespace', 'name', 'shortName']); - this.plainStringBlock(META_INDENT, listFromDiffs(d)); + this.tree.plainStringBlock(META_INDENT, listFromDiffs(d)); this.renderMapDiff( 'resource', @@ -82,7 +75,7 @@ export class DiffFormatter { } private renderResource(r: Resource, db: number) { - this.plainStringBlock( + this.tree.plainStringBlock( META_INDENT, listFromProps(r, [ 'name', @@ -99,10 +92,10 @@ export class DiffFormatter { // FIXME: props, attributes - this.emitList( + this.tree.emitList( this.dbs[db].follow('usesType', r).map((x) => x.entity), (typeDef, last) => - this.withBullet(last, () => { + this.tree.withBullet(last, () => { this.renderTypeDefinition(typeDef, db); }), ); @@ -120,11 +113,11 @@ export class DiffFormatter { 'scrutinizable', 'tagInformation', ]); - this.plainStringBlock(META_INDENT, listFromDiffs(d)); + this.tree.plainStringBlock(META_INDENT, listFromDiffs(d)); - this.withPrefix(META_INDENT, () => { + this.tree.withPrefix(META_INDENT, () => { if (s.properties) { - this.emit('properties\n'); + this.tree.emit('properties\n'); this.renderMapDiff( 'prop', s.properties, @@ -134,7 +127,7 @@ export class DiffFormatter { } if (s.attributes) { - this.emit('attributes\n'); + this.tree.emit('attributes\n'); this.renderMapDiff( 'attr', s.attributes, @@ -153,12 +146,12 @@ export class DiffFormatter { } private renderTypeDefinition(r: TypeDefinition, db: number) { - this.plainStringBlock(META_INDENT, listFromProps(r, ['documentation', 'mustRenderForBwCompat', 'name'])); + this.tree.plainStringBlock(META_INDENT, listFromProps(r, ['documentation', 'mustRenderForBwCompat', 'name'])); // Properties - this.emitList(Object.entries(r.properties), ([name, p], last) => { - this.withBullet(last, () => { - this.emit(`${name}: `); + this.tree.emitList(Object.entries(r.properties), ([name, p], last) => { + this.tree.withBullet(last, () => { + this.tree.emit(`${name}: `); this.renderProperty(p, db); }); }); @@ -166,9 +159,9 @@ export class DiffFormatter { private renderUpdatedTypeDefinition(t: UpdatedTypeDefinition) { const d = pick(t, ['documentation', 'mustRenderForBwCompat', 'name']); - this.plainStringBlock(META_INDENT, listFromDiffs(d)); + this.tree.plainStringBlock(META_INDENT, listFromDiffs(d)); - this.withPrefix(' ', () => { + this.tree.withPrefix(' ', () => { this.renderMapDiff( 'prop', t.properties, @@ -180,7 +173,7 @@ export class DiffFormatter { private renderProperty(p: Property, db: number) { const types = [p.type, ...(p.previousTypes ?? []).reverse()]; - this.emit(types.map((type) => new RichPropertyType(type).stringify(this.dbs[db], false)).join(' ⇐ ')); + this.tree.emit(types.map((type) => new RichPropertyType(type).stringify(this.dbs[db], false)).join(' ⇐ ')); const attributes = []; if (p.defaultValue) { @@ -192,31 +185,31 @@ export class DiffFormatter { // FIXME: Documentation? if (attributes.length) { - this.emit(` (${attributes.join(', ')})`); + this.tree.emit(` (${attributes.join(', ')})`); } } private renderUpdatedProperty(t: UpdatedProperty) { - this.withColor(chalk.red, () => { + this.tree.withColor(chalk.red, () => { this.renderProperty(t.old, OLD_DB); }); - this.emit('\n'); - this.withColor(chalk.green, () => { + this.tree.emit('\n'); + this.tree.withColor(chalk.green, () => { this.renderProperty(t.new, NEW_DB); }); } private renderAttribute(a: Attribute, db: number) { const types = [a.type, ...(a.previousTypes ?? []).reverse()]; - this.emit(types.map((type) => new RichPropertyType(type).stringify(this.dbs[db], false)).join(' ⇐ ')); + this.tree.emit(types.map((type) => new RichPropertyType(type).stringify(this.dbs[db], false)).join(' ⇐ ')); } private renderUpdatedAttribute(t: UpdatedAttribute) { - this.withColor(chalk.red, () => { + this.tree.withColor(chalk.red, () => { this.renderAttribute(t.old, OLD_DB); }); - this.emit('\n'); - this.withColor(chalk.green, () => { + this.tree.emit('\n'); + this.tree.withColor(chalk.green, () => { this.renderAttribute(t.new, NEW_DB); }); } @@ -241,114 +234,32 @@ export class DiffFormatter { ); keys.sort((a, b) => a.localeCompare(b)); - this.emitList(keys, (key, last) => { + this.tree.emitList(keys, (key, last) => { if (diff.added?.[key]) { - this.withColor(chalk.green, () => - this.withBullet(last, () => { - this.emit(ADDITION); - this.emit(` ${type} ${key}\n`); + this.tree.withColor(chalk.green, () => + this.tree.withBullet(last, () => { + this.tree.emit(ADDITION); + this.tree.emit(` ${type} ${key}\n`); renderEl(diff.added?.[key]!, NEW_DB); }), ); } else if (diff.removed?.[key]) { - this.withColor(chalk.green, () => - this.withBullet(last, () => { - this.emit(REMOVAL); - this.emit(` ${type} ${key}\n`); + this.tree.withColor(chalk.green, () => + this.tree.withBullet(last, () => { + this.tree.emit(REMOVAL); + this.tree.emit(` ${type} ${key}\n`); renderEl(diff.removed?.[key]!, OLD_DB); }), ); } else if (diff.updated?.[key]) { - this.withBullet(last, () => { - this.emit(chalk.yellow(UPDATE)); - this.emit(` ${type} ${key}\n`); + this.tree.withBullet(last, () => { + this.tree.emit(chalk.yellow(UPDATE)); + this.tree.emit(` ${type} ${key}\n`); renderUpdated(diff.updated?.[key]!); }); } }); } - - private emitList(as: A[], block: (a: A, last: boolean) => void) { - for (let i = 0; i < as.length; i++) { - if (i > 0) { - this.emit('\n'); - } - const last = i === as.length - 1; - block(as[i], last); - } - } - - private withBullet(last: boolean, block: () => void): void; - private withBullet(last: boolean, additionalIndent: string, block: () => void): void; - private withBullet(last: boolean, blockOrIndent: string | (() => void), block?: () => void) { - const header = last ? '└' : '├'; - const indent = (last ? ' ' : '│') + (typeof blockOrIndent === 'string' ? blockOrIndent : ' '); - const theBlock = typeof blockOrIndent === 'function' ? blockOrIndent : block; - - this.emit(header); - this.withPrefix(indent, theBlock!); - } - - private withHeader(header: string, indent: string, block: () => void) { - this.emit(header); - this.withPrefix(indent, block); - } - - private plainStringBlock(indent: string, as: string[]) { - if (as.length === 0) { - return; - } - - this.withHeader(indent, indent, () => { - this.emitList(as, (a) => { - this.emit(a); - }); - }); - } - - private emit(x: string) { - if (this.buffer.length && this.buffer[this.buffer.length - 1].endsWith('\n')) { - this.buffer.push(this.currentPrefix); - } - - // Replace newlines with the prefix, except if the string ends in one - const parts = x.split('\n'); - for (let i = 0; i < parts.length; i++) { - if (i > 0) { - this.buffer.push('\n'); - if (i < parts.length || parts[i] !== '') { - this.buffer.push(this.currentPrefix); - } - } - this.buffer.push(this.currentColor(parts[i])); - } - } - - private get currentPrefix() { - return this.prefix.join(''); - } - - private get currentColor() { - return this.colors[this.colors.length - 1]; - } - - private withPrefix(x: string, block: () => void) { - this.prefix.push(this.currentColor(x)); - try { - block(); - } finally { - this.prefix.pop(); - } - } - - private withColor(col: Colorizer, block: () => void) { - this.colors.push(col); - try { - block(); - } finally { - this.colors.pop(); - } - } } function listFromProps(a: A, ks: K[]) { diff --git a/packages/@aws-cdk/service-spec-build/src/tree-emitter.ts b/packages/@aws-cdk/service-spec-build/src/tree-emitter.ts new file mode 100644 index 000000000..5da9f8d9d --- /dev/null +++ b/packages/@aws-cdk/service-spec-build/src/tree-emitter.ts @@ -0,0 +1,104 @@ +type Colorizer = (x: string) => string; + +/** + * A class to emit ASCII art tree structures + */ +export class TreeEmitter { + private readonly buffer = new Array(); + private readonly prefix = new Array(); + private readonly colors: Colorizer[] = [ident]; + + public clear() { + this.buffer.splice(0, this.buffer.length); + } + + public toString() { + return this.buffer.join(''); + } + + public emitList(as: A[], block: (a: A, last: boolean) => void) { + for (let i = 0; i < as.length; i++) { + if (i > 0) { + this.emit('\n'); + } + const last = i === as.length - 1; + block(as[i], last); + } + } + + public withBullet(last: boolean, block: () => void): void; + public withBullet(last: boolean, additionalIndent: string, block: () => void): void; + public withBullet(last: boolean, blockOrIndent: string | (() => void), block?: () => void) { + const header = last ? '└' : '├'; + const indent = (last ? ' ' : '│') + (typeof blockOrIndent === 'string' ? blockOrIndent : ' '); + const theBlock = typeof blockOrIndent === 'function' ? blockOrIndent : block; + + this.emit(header); + this.withPrefix(indent, theBlock!); + } + + public withHeader(header: string, indent: string, block: () => void) { + this.emit(header); + this.withPrefix(indent, block); + } + + public plainStringBlock(indent: string, as: string[]) { + if (as.length === 0) { + return; + } + + this.withHeader(indent, indent, () => { + this.emitList(as, (a) => { + this.emit(a); + }); + }); + } + + public emit(x: string) { + if (this.buffer.length && this.buffer[this.buffer.length - 1].endsWith('\n')) { + this.buffer.push(this.currentPrefix); + } + + // Replace newlines with the prefix, except if the string ends in one + const parts = x.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) { + this.buffer.push('\n'); + if (i < parts.length || parts[i] !== '') { + this.buffer.push(this.currentPrefix); + } + } + this.buffer.push(this.currentColor(parts[i])); + } + } + + private get currentPrefix() { + return this.prefix.join(''); + } + + private get currentColor() { + return this.colors[this.colors.length - 1]; + } + + public withPrefix(x: string, block: () => void) { + this.prefix.push(this.currentColor(x)); + try { + block(); + } finally { + this.prefix.pop(); + } + } + + public withColor(col: Colorizer, block: () => void) { + this.colors.push(col); + try { + block(); + } finally { + this.colors.pop(); + } + } +} + +function ident(x: string) { + return x; +}