| 1 | #!/usr/bin/env bun |
| 2 | /** |
| 3 | * Spec-Driven Service Audit Script |
| 4 | * |
| 5 | * This script analyzes a distilled AWS service spec and compares it against |
| 6 | * the alchemy implementation to identify gaps in bindings, resources, |
| 7 | * event sources, and helpers. |
| 8 | * |
| 9 | * Usage: |
| 10 | * bun scripts/audit-service.ts dynamodb |
| 11 | * bun scripts/audit-service.ts s3 |
| 12 | * bun scripts/audit-service.ts --json dynamodb |
| 13 | */ |
| 14 | |
| 15 | import * as fs from "node:fs/promises"; |
| 16 | import * as path from "node:path"; |
| 17 | |
| 18 | // ============ Types ============ |
| 19 | |
| 20 | interface Operation { |
| 21 | name: string; |
| 22 | camelCase: string; |
| 23 | pascalCase: string; |
| 24 | category: OperationCategory; |
| 25 | resourceArity: ResourceArity; |
| 26 | impliesResource: boolean; |
| 27 | impliesEventSource: boolean; |
| 28 | implemented: boolean; |
| 29 | registeredInProviders: boolean; |
| 30 | registeredInIndex: boolean; |
| 31 | } |
| 32 | |
| 33 | type OperationCategory = |
| 34 | | "binding" // Data-plane operation that becomes a Binding.Service |
| 35 | | "resource-lifecycle" // create/update/delete operations that imply a Resource |
| 36 | | "event-source" // stream/notification operations |
| 37 | | "helper-candidate" // operations that might become ergonomic helpers |
| 38 | | "internal"; // operations unlikely to be exposed directly |
| 39 | |
| 40 | type ResourceArity = |
| 41 | | 0 // account/service scoped (e.g., ListBuckets, DescribeLimits) |
| 42 | | 1 // single resource scoped (e.g., GetObject(Bucket), GetItem(Table)) |
| 43 | | 2 // fixed multi-resource (e.g., CopyObject(SourceBucket, DestBucket)) |
| 44 | | "n"; // variadic resource set (e.g., ExecuteTransaction(TableA, TableB, ...)) |
| 45 | |
| 46 | interface AuditReport { |
| 47 | service: string; |
| 48 | distilledPath: string; |
| 49 | alchemyPath: string; |
| 50 | bindingTestPath: string; |
| 51 | totalOperations: number; |
| 52 | implementedBindings: Operation[]; |
| 53 | missingBindings: Operation[]; |
| 54 | resourceLifecycleOps: Operation[]; |
| 55 | eventSourceOps: Operation[]; |
| 56 | helperCandidates: Operation[]; |
| 57 | internalOps: Operation[]; |
| 58 | canonicalResources: CanonicalResource[]; |
| 59 | suggestedHelpers: SuggestedHelper[]; |
| 60 | registrationGaps: RegistrationGap[]; |
| 61 | missingBindingTests: string[]; |
| 62 | leastPrivilegeWarnings: LeastPrivilegeWarning[]; |
| 63 | } |
| 64 | |
| 65 | interface CanonicalResource { |
| 66 | name: string; |
| 67 | impliedByOperations: string[]; |
| 68 | hasProvider: boolean; |
| 69 | suggestedBindings: string[]; |
| 70 | } |
| 71 | |
| 72 | interface SuggestedHelper { |
| 73 | name: string; |
| 74 | pattern: string; |
| 75 | basedOn: string[]; |
| 76 | existingExample: string | null; |
| 77 | } |
| 78 | |
| 79 | interface RegistrationGap { |
| 80 | type: "provider" | "index" | "policy"; |
| 81 | file: string; |
| 82 | missing: string[]; |
| 83 | } |
| 84 | |
| 85 | interface LeastPrivilegeWarning { |
| 86 | binding: string; |
| 87 | file: string; |
| 88 | resourceArity: ResourceArity; |
| 89 | message: string; |
| 90 | } |
| 91 | |
| 92 | // ============ Operation Classification Rules ============ |
| 93 | |
| 94 | // Resource lifecycle operations create/update/delete the resource ITSELF (Table, Bucket, Queue) |
| 95 | // NOT operations on items within a resource (deleteItem is a binding, deleteTable is lifecycle) |
| 96 | const RESOURCE_LIFECYCLE_PATTERNS = [ |
| 97 | /^createTable$/, |
| 98 | /^deleteTable$/, |
| 99 | /^updateTable$/, |
| 100 | /^createBucket$/, |
| 101 | /^deleteBucket$/, |
| 102 | /^createQueue$/, |
| 103 | /^deleteQueue$/, |
| 104 | /^createFunction$/, |
| 105 | /^deleteFunction$/, |
| 106 | /^updateFunctionCode$/, |
| 107 | /^updateFunctionConfiguration$/, |
| 108 | /^createStream$/, |
| 109 | /^deleteStream$/, |
| 110 | /^createPipe$/, |
| 111 | /^deletePipe$/, |
| 112 | /^updatePipe$/, |
| 113 | /^createTopic$/, |
| 114 | /^deleteTopic$/, |
| 115 | /^createSchedule$/, |
| 116 | /^deleteSchedule$/, |
| 117 | /^updateSchedule$/, |
| 118 | /^createScheduleGroup$/, |
| 119 | /^deleteScheduleGroup$/, |
| 120 | /^put[A-Z].*(?:Policy|Configuration|Settings)$/, |
| 121 | ]; |
| 122 | |
| 123 | const EVENT_SOURCE_PATTERNS = [ |
| 124 | /stream/i, |
| 125 | /kinesis/i, |
| 126 | /notification/i, |
| 127 | /subscription/i, |
| 128 | /^describe.*Stream/, |
| 129 | /^enable.*Stream/, |
| 130 | /^disable.*Stream/, |
| 131 | /StreamingDestination/i, |
| 132 | ]; |
| 133 | |
| 134 | // Operations that are both bindings AND helper candidates (will be classified as bindings first) |
| 135 | // These are data-plane operations that might benefit from higher-level wrappers |
| 136 | const HELPER_CANDIDATE_PATTERNS = [ |
| 137 | /^batch/i, |
| 138 | /^transact/i, |
| 139 | /^execute.*Statement/, |
| 140 | ]; |
| 141 | |
| 142 | // Core data-plane bindings (these should always be classified as bindings) |
| 143 | const CORE_BINDING_PATTERNS = [ |
| 144 | /^get(?:Item|Object|Message|Record)$/i, |
| 145 | /^put(?:Item|Object|Record)$/i, |
| 146 | /^delete(?:Item|Object|Message)$/i, |
| 147 | /^update(?:Item)$/i, |
| 148 | /^query$/i, |
| 149 | /^scan$/i, |
| 150 | /^send(?:Message|Record)$/i, |
| 151 | /^receive(?:Message)$/i, |
| 152 | /^head(?:Object|Bucket)$/i, |
| 153 | /^list(?:Objects|ObjectsV2)$/i, |
| 154 | /^copy(?:Object)$/i, |
| 155 | /^upload(?:Part)$/i, |
| 156 | /^complete(?:MultipartUpload)$/i, |
| 157 | /^create(?:MultipartUpload)$/i, |
| 158 | /^abort(?:MultipartUpload)$/i, |
| 159 | ]; |
| 160 | |
| 161 | const INTERNAL_PATTERNS = [ |
| 162 | /^describe(?:Endpoints|Limits)$/, |
| 163 | /^list(?:Tags|Backups|Exports|Imports|GlobalTables|ContributorInsights)$/, |
| 164 | /^tag/i, |
| 165 | /^untag/i, |
| 166 | /Backup/i, |
| 167 | /Export/i, |
| 168 | /Import/i, |
| 169 | /GlobalTable(?!s$)/i, |
| 170 | /ReplicaAutoScaling/i, |
| 171 | /ContributorInsights/i, |
| 172 | /ResourcePolicy/i, |
| 173 | /ContinuousBackups/i, |
| 174 | ]; |
| 175 | |
| 176 | const ZERO_ARITY_PATTERNS = [ |
| 177 | /^list(?:Tables|Buckets|Queues|Functions|Streams)$/i, |
| 178 | /^describe(?:Endpoints|Limits|Account)$/i, |
| 179 | ]; |
| 180 | |
| 181 | const FIXED_MULTI_ARITY_PATTERNS = [/^copy/i, /^replicate/i, /^restore.*From/i]; |
| 182 | |
| 183 | const N_ARITY_PATTERNS = [ |
| 184 | /^batch/i, |
| 185 | /^transact/i, |
| 186 | /^execute.*Statement/i, |
| 187 | /^execute.*Transaction/i, |
| 188 | ]; |
| 189 | |
| 190 | // ============ Utilities ============ |
| 191 | |
| 192 | function toPascalCase(str: string): string { |
| 193 | return str.charAt(0).toUpperCase() + str.slice(1); |
| 194 | } |
| 195 | |
| 196 | function toCamelCase(str: string): string { |
| 197 | return str.charAt(0).toLowerCase() + str.slice(1); |
| 198 | } |
| 199 | |
| 200 | function matchesAnyPattern(name: string, patterns: RegExp[]): boolean { |
| 201 | return patterns.some((p) => p.test(name)); |
| 202 | } |
| 203 | |
| 204 | function classifyOperation( |
| 205 | serviceName: string, |
| 206 | name: string, |
| 207 | ): { |
| 208 | category: OperationCategory; |
| 209 | resourceArity: ResourceArity; |
| 210 | impliesResource: boolean; |
| 211 | impliesEventSource: boolean; |
| 212 | } { |
| 213 | if (serviceName === "iam") { |
| 214 | const lifecycleIamOps = new Set([ |
| 215 | "createAccessKey", |
| 216 | "deleteAccessKey", |
| 217 | "updateAccessKey", |
| 218 | "createAccountAlias", |
| 219 | "deleteAccountAlias", |
| 220 | "updateAccountPasswordPolicy", |
| 221 | "deleteAccountPasswordPolicy", |
| 222 | "createGroup", |
| 223 | "deleteGroup", |
| 224 | "updateGroup", |
| 225 | "createInstanceProfile", |
| 226 | "deleteInstanceProfile", |
| 227 | "createLoginProfile", |
| 228 | "deleteLoginProfile", |
| 229 | "updateLoginProfile", |
| 230 | "createOpenIDConnectProvider", |
| 231 | "deleteOpenIDConnectProvider", |
| 232 | "createPolicy", |
| 233 | "deletePolicy", |
| 234 | "createRole", |
| 235 | "deleteRole", |
| 236 | "updateRole", |
| 237 | "createSAMLProvider", |
| 238 | "deleteSAMLProvider", |
| 239 | "updateSAMLProvider", |
| 240 | "deleteServerCertificate", |
| 241 | "updateServerCertificate", |
| 242 | "createServiceSpecificCredential", |
| 243 | "deleteServiceSpecificCredential", |
| 244 | "updateServiceSpecificCredential", |
| 245 | "deleteSigningCertificate", |
| 246 | "updateSigningCertificate", |
| 247 | "deleteSSHPublicKey", |
| 248 | "updateSSHPublicKey", |
| 249 | "createUser", |
| 250 | "deleteUser", |
| 251 | "updateUser", |
| 252 | "createVirtualMFADevice", |
| 253 | "deleteVirtualMFADevice", |
| 254 | ]); |
| 255 | |
| 256 | if (lifecycleIamOps.has(name)) { |
| 257 | return { |
| 258 | category: "resource-lifecycle", |
| 259 | resourceArity: |
| 260 | name === "updateAccountPasswordPolicy" || |
| 261 | name === "deleteAccountPasswordPolicy" |
| 262 | ? 0 |
| 263 | : 1, |
| 264 | impliesResource: true, |
| 265 | impliesEventSource: false, |
| 266 | }; |
| 267 | } |
| 268 | |
| 269 | return { |
| 270 | category: "internal", |
| 271 | resourceArity: matchesAnyPattern(name, ZERO_ARITY_PATTERNS) ? 0 : 1, |
| 272 | impliesResource: false, |
| 273 | impliesEventSource: false, |
| 274 | }; |
| 275 | } |
| 276 | |
| 277 | if (serviceName === "sns") { |
| 278 | if (name === "createTopic" || name === "deleteTopic") { |
| 279 | return { |
| 280 | category: "resource-lifecycle", |
| 281 | resourceArity: 1, |
| 282 | impliesResource: true, |
| 283 | impliesEventSource: false, |
| 284 | }; |
| 285 | } |
| 286 | |
| 287 | if (name === "subscribe" || name === "unsubscribe") { |
| 288 | return { |
| 289 | category: "resource-lifecycle", |
| 290 | resourceArity: 1, |
| 291 | impliesResource: true, |
| 292 | impliesEventSource: true, |
| 293 | }; |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | if (serviceName === "kinesis") { |
| 298 | if ( |
| 299 | name === "registerStreamConsumer" || |
| 300 | name === "deregisterStreamConsumer" |
| 301 | ) { |
| 302 | return { |
| 303 | category: "resource-lifecycle", |
| 304 | resourceArity: 1, |
| 305 | impliesResource: true, |
| 306 | impliesEventSource: false, |
| 307 | }; |
| 308 | } |
| 309 | |
| 310 | if ( |
| 311 | [ |
| 312 | "addTagsToStream", |
| 313 | "decreaseStreamRetentionPeriod", |
| 314 | "deleteResourcePolicy", |
| 315 | "disableEnhancedMonitoring", |
| 316 | "enableEnhancedMonitoring", |
| 317 | "increaseStreamRetentionPeriod", |
| 318 | "mergeShards", |
| 319 | "putResourcePolicy", |
| 320 | "removeTagsFromStream", |
| 321 | "splitShard", |
| 322 | "startStreamEncryption", |
| 323 | "stopStreamEncryption", |
| 324 | "tagResource", |
| 325 | "untagResource", |
| 326 | "updateMaxRecordSize", |
| 327 | "updateShardCount", |
| 328 | "updateStreamMode", |
| 329 | "updateStreamWarmThroughput", |
| 330 | ].includes(name) |
| 331 | ) { |
| 332 | return { |
| 333 | category: "resource-lifecycle", |
| 334 | resourceArity: 1, |
| 335 | impliesResource: true, |
| 336 | impliesEventSource: false, |
| 337 | }; |
| 338 | } |
| 339 | |
| 340 | if ( |
| 341 | [ |
| 342 | "describeAccountSettings", |
| 343 | "describeLimits", |
| 344 | "describeStream", |
| 345 | "describeStreamConsumer", |
| 346 | "describeStreamSummary", |
| 347 | "getRecords", |
| 348 | "getResourcePolicy", |
| 349 | "getShardIterator", |
| 350 | "listShards", |
| 351 | "listStreamConsumers", |
| 352 | "listStreams", |
| 353 | "listTagsForResource", |
| 354 | "putRecord", |
| 355 | "putRecords", |
| 356 | "subscribeToShard", |
| 357 | ].includes(name) |
| 358 | ) { |
| 359 | return { |
| 360 | category: "binding", |
| 361 | resourceArity: matchesAnyPattern(name, ZERO_ARITY_PATTERNS) ? 0 : 1, |
| 362 | impliesResource: false, |
| 363 | impliesEventSource: false, |
| 364 | }; |
| 365 | } |
| 366 | |
| 367 | if (name === "listTagsForStream" || name === "updateAccountSettings") { |
| 368 | return { |
| 369 | category: "internal", |
| 370 | resourceArity: 1, |
| 371 | impliesResource: false, |
| 372 | impliesEventSource: false, |
| 373 | }; |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | if (serviceName === "rds-data") { |
| 378 | return { |
| 379 | category: "binding", |
| 380 | resourceArity: 1, |
| 381 | impliesResource: false, |
| 382 | impliesEventSource: false, |
| 383 | }; |
| 384 | } |
| 385 | |
| 386 | if (serviceName === "secrets-manager") { |
| 387 | if (["createSecret", "deleteSecret", "updateSecret"].includes(name)) { |
| 388 | return { |
| 389 | category: "resource-lifecycle", |
| 390 | resourceArity: 1, |
| 391 | impliesResource: true, |
| 392 | impliesEventSource: false, |
| 393 | }; |
| 394 | } |
| 395 | |
| 396 | if (name === "listSecrets" || name === "getRandomPassword") { |
| 397 | return { |
| 398 | category: "binding", |
| 399 | resourceArity: 0, |
| 400 | impliesResource: false, |
| 401 | impliesEventSource: false, |
| 402 | }; |
| 403 | } |
| 404 | |
| 405 | if ( |
| 406 | [ |
| 407 | "getSecretValue", |
| 408 | "putSecretValue", |
| 409 | "describeSecret", |
| 410 | "listSecretVersionIds", |
| 411 | "getResourcePolicy", |
| 412 | "putResourcePolicy", |
| 413 | "deleteResourcePolicy", |
| 414 | "updateSecretVersionStage", |
| 415 | "validateResourcePolicy", |
| 416 | ].includes(name) |
| 417 | ) { |
| 418 | return { |
| 419 | category: "binding", |
| 420 | resourceArity: 1, |
| 421 | impliesResource: false, |
| 422 | impliesEventSource: false, |
| 423 | }; |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | if (serviceName === "rds") { |
| 428 | if ( |
| 429 | [ |
| 430 | "createDBCluster", |
| 431 | "deleteDBCluster", |
| 432 | "modifyDBCluster", |
| 433 | "enableHttpEndpoint", |
| 434 | "disableHttpEndpoint", |
| 435 | "startDBCluster", |
| 436 | "stopDBCluster", |
| 437 | "rebootDBCluster", |
| 438 | "createDBClusterEndpoint", |
| 439 | "deleteDBClusterEndpoint", |
| 440 | "modifyDBClusterEndpoint", |
| 441 | "createDBClusterParameterGroup", |
| 442 | "deleteDBClusterParameterGroup", |
| 443 | "modifyDBClusterParameterGroup", |
| 444 | "createDBInstance", |
| 445 | "deleteDBInstance", |
| 446 | "modifyDBInstance", |
| 447 | "createDBParameterGroup", |
| 448 | "deleteDBParameterGroup", |
| 449 | "modifyDBParameterGroup", |
| 450 | "createDBProxy", |
| 451 | "deleteDBProxy", |
| 452 | "modifyDBProxy", |
| 453 | "createDBProxyEndpoint", |
| 454 | "deleteDBProxyEndpoint", |
| 455 | "modifyDBProxyEndpoint", |
| 456 | "modifyDBProxyTargetGroup", |
| 457 | "registerDBProxyTargets", |
| 458 | "deregisterDBProxyTargets", |
| 459 | "createDBSubnetGroup", |
| 460 | "deleteDBSubnetGroup", |
| 461 | "modifyDBSubnetGroup", |
| 462 | "createGlobalCluster", |
| 463 | "deleteGlobalCluster", |
| 464 | "modifyGlobalCluster", |
| 465 | ].includes(name) |
| 466 | ) { |
| 467 | return { |
| 468 | category: "resource-lifecycle", |
| 469 | resourceArity: 1, |
| 470 | impliesResource: true, |
| 471 | impliesEventSource: false, |
| 472 | }; |
| 473 | } |
| 474 | |
| 475 | if ( |
| 476 | [ |
| 477 | "describeDBClusters", |
| 478 | "describeDBInstances", |
| 479 | "describeDBSubnetGroups", |
| 480 | "describeDBClusterParameterGroups", |
| 481 | "describeDBParameterGroups", |
| 482 | "describeDBProxies", |
| 483 | "describeDBProxyEndpoints", |
| 484 | "describeDBProxyTargetGroups", |
| 485 | "describeDBProxyTargets", |
| 486 | "listTagsForResource", |
| 487 | ].includes(name) |
| 488 | ) { |
| 489 | return { |
| 490 | category: "binding", |
| 491 | resourceArity: 1, |
| 492 | impliesResource: false, |
| 493 | impliesEventSource: false, |
| 494 | }; |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | if (serviceName === "eventbridge") { |
| 499 | if ( |
| 500 | [ |
| 501 | "createEventBus", |
| 502 | "deleteEventBus", |
| 503 | "updateEventBus", |
| 504 | "putRule", |
| 505 | "deleteRule", |
| 506 | "putPermission", |
| 507 | "removePermission", |
| 508 | ].includes(name) |
| 509 | ) { |
| 510 | return { |
| 511 | category: "resource-lifecycle", |
| 512 | resourceArity: |
| 513 | name === "putPermission" || name === "removePermission" ? 1 : 1, |
| 514 | impliesResource: true, |
| 515 | impliesEventSource: name === "putRule" || name === "deleteRule", |
| 516 | }; |
| 517 | } |
| 518 | |
| 519 | if ( |
| 520 | [ |
| 521 | "describeEventBus", |
| 522 | "listEventBuses", |
| 523 | "describeRule", |
| 524 | "listRules", |
| 525 | "listTargetsByRule", |
| 526 | "listRuleNamesByTarget", |
| 527 | "putEvents", |
| 528 | "testEventPattern", |
| 529 | ].includes(name) |
| 530 | ) { |
| 531 | return { |
| 532 | category: "binding", |
| 533 | resourceArity: |
| 534 | name === "listEventBuses" || name === "testEventPattern" ? 0 : 1, |
| 535 | impliesResource: false, |
| 536 | impliesEventSource: false, |
| 537 | }; |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | if (serviceName === "pipes") { |
| 542 | if ( |
| 543 | [ |
| 544 | "createPipe", |
| 545 | "describePipe", |
| 546 | "updatePipe", |
| 547 | "deletePipe", |
| 548 | "startPipe", |
| 549 | "stopPipe", |
| 550 | ].includes(name) |
| 551 | ) { |
| 552 | return { |
| 553 | category: "resource-lifecycle", |
| 554 | resourceArity: 1, |
| 555 | impliesResource: true, |
| 556 | impliesEventSource: true, |
| 557 | }; |
| 558 | } |
| 559 | |
| 560 | if (["listPipes", "listTagsForResource"].includes(name)) { |
| 561 | return { |
| 562 | category: "binding", |
| 563 | resourceArity: name === "listPipes" ? 0 : 1, |
| 564 | impliesResource: false, |
| 565 | impliesEventSource: false, |
| 566 | }; |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | if (serviceName === "scheduler") { |
| 571 | if ( |
| 572 | [ |
| 573 | "createSchedule", |
| 574 | "getSchedule", |
| 575 | "updateSchedule", |
| 576 | "deleteSchedule", |
| 577 | "createScheduleGroup", |
| 578 | "getScheduleGroup", |
| 579 | "deleteScheduleGroup", |
| 580 | ].includes(name) |
| 581 | ) { |
| 582 | return { |
| 583 | category: "resource-lifecycle", |
| 584 | resourceArity: 1, |
| 585 | impliesResource: true, |
| 586 | impliesEventSource: true, |
| 587 | }; |
| 588 | } |
| 589 | |
| 590 | if ( |
| 591 | ["listSchedules", "listScheduleGroups", "listTagsForResource"].includes( |
| 592 | name, |
| 593 | ) |
| 594 | ) { |
| 595 | return { |
| 596 | category: "binding", |
| 597 | resourceArity: name === "listTagsForResource" ? 1 : 0, |
| 598 | impliesResource: false, |
| 599 | impliesEventSource: false, |
| 600 | }; |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | const impliesEventSource = matchesAnyPattern(name, EVENT_SOURCE_PATTERNS); |
| 605 | const impliesResource = matchesAnyPattern(name, RESOURCE_LIFECYCLE_PATTERNS); |
| 606 | const isCoreBinding = matchesAnyPattern(name, CORE_BINDING_PATTERNS); |
| 607 | |
| 608 | let category: OperationCategory; |
| 609 | if (matchesAnyPattern(name, INTERNAL_PATTERNS)) { |
| 610 | category = "internal"; |
| 611 | } else if (isCoreBinding) { |
| 612 | // Core bindings take priority - these are the main data-plane operations |
| 613 | category = "binding"; |
| 614 | } else if (impliesResource) { |
| 615 | category = "resource-lifecycle"; |
| 616 | } else if (impliesEventSource) { |
| 617 | category = "event-source"; |
| 618 | } else if (matchesAnyPattern(name, HELPER_CANDIDATE_PATTERNS)) { |
| 619 | category = "helper-candidate"; |
| 620 | } else { |
| 621 | category = "binding"; |
| 622 | } |
| 623 | |
| 624 | let resourceArity: ResourceArity; |
| 625 | if (matchesAnyPattern(name, ZERO_ARITY_PATTERNS)) { |
| 626 | resourceArity = 0; |
| 627 | } else if (matchesAnyPattern(name, N_ARITY_PATTERNS)) { |
| 628 | resourceArity = "n"; |
| 629 | } else if (matchesAnyPattern(name, FIXED_MULTI_ARITY_PATTERNS)) { |
| 630 | resourceArity = 2; |
| 631 | } else { |
| 632 | resourceArity = 1; |
| 633 | } |
| 634 | |
| 635 | return { category, resourceArity, impliesResource, impliesEventSource }; |
| 636 | } |
| 637 | |
| 638 | async function inferImplementedResourceArity( |
| 639 | alchemyPath: string, |
| 640 | pascalCase: string, |
| 641 | fallback: ResourceArity, |
| 642 | ): Promise<ResourceArity> { |
| 643 | const file = path.join(alchemyPath, `${pascalCase}.ts`); |
| 644 | |
| 645 | try { |
| 646 | const content = await fs.readFile(file, "utf-8"); |
| 647 | const serviceSignatureMatch = content.match( |
| 648 | /Binding\.Service<[\s\S]*?,\s*(\([\s\S]*?\)\s*=>\s*Effect\.Effect<)/, |
| 649 | ); |
| 650 | |
| 651 | if (!serviceSignatureMatch) { |
| 652 | return fallback; |
| 653 | } |
| 654 | |
| 655 | const signature = serviceSignatureMatch[1]; |
| 656 | |
| 657 | if (/\(\s*\)\s*=>\s*Effect\.Effect</.test(signature)) { |
| 658 | return 0; |
| 659 | } |
| 660 | |
| 661 | if (/\(\s*\.\.\.[^)]+\)\s*=>\s*Effect\.Effect</.test(signature)) { |
| 662 | return "n"; |
| 663 | } |
| 664 | |
| 665 | if (/\([^)]*,[^)]*\)\s*=>\s*Effect\.Effect</.test(signature)) { |
| 666 | return 2; |
| 667 | } |
| 668 | |
| 669 | if (/\([^)]*\)\s*=>\s*Effect\.Effect</.test(signature)) { |
| 670 | return 1; |
| 671 | } |
| 672 | } catch { |
| 673 | // Fall back to heuristic classification. |
| 674 | } |
| 675 | |
| 676 | return fallback; |
| 677 | } |
| 678 | |
| 679 | function formatArity(arity: ResourceArity): string { |
| 680 | return `arity=${arity}`; |
| 681 | } |
| 682 | |
| 683 | // ============ File Parsing ============ |
| 684 | |
| 685 | async function extractDistilledOperations( |
| 686 | distilledPath: string, |
| 687 | moduleSpecifier?: string, |
| 688 | ): Promise<string[]> { |
| 689 | try { |
| 690 | const content = await fs.readFile(distilledPath, "utf-8"); |
| 691 | const operations: string[] = []; |
| 692 | const regex = /^export const ([a-z][a-zA-Z0-9]*): API\.OperationMethod</gm; |
| 693 | |
| 694 | let match; |
| 695 | while ((match = regex.exec(content)) !== null) { |
| 696 | operations.push(match[1]); |
| 697 | } |
| 698 | |
| 699 | return operations; |
| 700 | } catch { |
| 701 | if (!moduleSpecifier) { |
| 702 | throw new Error(`Could not read distilled spec: ${distilledPath}`); |
| 703 | } |
| 704 | |
| 705 | const mod = await import(moduleSpecifier); |
| 706 | return Object.keys(mod) |
| 707 | .filter((key) => /^[a-z]/.test(key)) |
| 708 | .sort(); |
| 709 | } |
| 710 | } |
| 711 | |
| 712 | async function getAlchemyFiles(alchemyPath: string): Promise<Set<string>> { |
| 713 | const files = new Set<string>(); |
| 714 | try { |
| 715 | const entries = await fs.readdir(alchemyPath); |
| 716 | for (const entry of entries) { |
| 717 | if (entry.endsWith(".ts") && entry !== "index.ts") { |
| 718 | files.add(entry.replace(".ts", "")); |
| 719 | } |
| 720 | } |
| 721 | } catch { |
| 722 | // Directory doesn't exist |
| 723 | } |
| 724 | return files; |
| 725 | } |
| 726 | |
| 727 | async function getIndexExports(indexPath: string): Promise<Set<string>> { |
| 728 | const exports = new Set<string>(); |
| 729 | try { |
| 730 | const content = await fs.readFile(indexPath, "utf-8"); |
| 731 | const regex = /export \* from ["']\.\/([^"']+)["']/g; |
| 732 | let match; |
| 733 | while ((match = regex.exec(content)) !== null) { |
| 734 | const name = match[1].replace(".ts", "").replace(".js", ""); |
| 735 | exports.add(name); |
| 736 | } |
| 737 | } catch { |
| 738 | // File doesn't exist |
| 739 | } |
| 740 | return exports; |
| 741 | } |
| 742 | |
| 743 | async function getProvidersRegistrations( |
| 744 | providersPath: string, |
| 745 | service: string, |
| 746 | ): Promise<{ resources: Set<string>; bindings: Set<string> }> { |
| 747 | const resources = new Set<string>(); |
| 748 | const bindings = new Set<string>(); |
| 749 | |
| 750 | try { |
| 751 | const content = await fs.readFile(providersPath, "utf-8"); |
| 752 | |
| 753 | // Match DynamoDB.TableProvider(), S3.BucketProvider(), etc. |
| 754 | const resourceRegex = new RegExp( |
| 755 | `${service}\\.([A-Z][a-zA-Z0-9]+)Provider\\(\\)`, |
| 756 | "g", |
| 757 | ); |
| 758 | let match; |
| 759 | while ((match = resourceRegex.exec(content)) !== null) { |
| 760 | resources.add(match[1]); |
| 761 | } |
| 762 | |
| 763 | // Match DynamoDB.GetItemPolicyLive, S3.GetObjectPolicyLive, etc. |
| 764 | // Note: The binding name is like "GetItem" and the export is "GetItemPolicyLive" |
| 765 | const bindingRegex = new RegExp( |
| 766 | `${service}\\.([A-Z][a-zA-Z0-9]+)PolicyLive`, |
| 767 | "g", |
| 768 | ); |
| 769 | while ((match = bindingRegex.exec(content)) !== null) { |
| 770 | bindings.add(match[1]); |
| 771 | } |
| 772 | } catch { |
| 773 | // File doesn't exist |
| 774 | } |
| 775 | |
| 776 | return { resources, bindings }; |
| 777 | } |
| 778 | |
| 779 | async function getBindingTestDescribes( |
| 780 | bindingTestPath: string, |
| 781 | ): Promise<{ exists: boolean; describes: Set<string> }> { |
| 782 | const describes = new Set<string>(); |
| 783 | |
| 784 | try { |
| 785 | const content = await fs.readFile(bindingTestPath, "utf-8"); |
| 786 | const regex = /describe\(\s*["']([A-Z][A-Za-z0-9]+)["']/g; |
| 787 | |
| 788 | let match; |
| 789 | while ((match = regex.exec(content)) !== null) { |
| 790 | describes.add(match[1]); |
| 791 | } |
| 792 | |
| 793 | return { exists: true, describes }; |
| 794 | } catch { |
| 795 | return { exists: false, describes }; |
| 796 | } |
| 797 | } |
| 798 | |
| 799 | async function getLeastPrivilegeWarnings( |
| 800 | alchemyPath: string, |
| 801 | operations: Operation[], |
| 802 | ): Promise<LeastPrivilegeWarning[]> { |
| 803 | const warnings: LeastPrivilegeWarning[] = []; |
| 804 | const wildcardResourcePattern = /Resource:\s*\[\s*["']\*["']\s*\]/; |
| 805 | |
| 806 | for (const op of operations) { |
| 807 | if (!op.implemented || op.resourceArity === 0) { |
| 808 | continue; |
| 809 | } |
| 810 | |
| 811 | if ( |
| 812 | op.category !== "binding" && |
| 813 | op.category !== "helper-candidate" && |
| 814 | op.category !== "event-source" |
| 815 | ) { |
| 816 | continue; |
| 817 | } |
| 818 | |
| 819 | const file = path.join(alchemyPath, `${op.pascalCase}.ts`); |
| 820 | try { |
| 821 | const content = await fs.readFile(file, "utf-8"); |
| 822 | if (wildcardResourcePattern.test(content)) { |
| 823 | warnings.push({ |
| 824 | binding: op.pascalCase, |
| 825 | file, |
| 826 | resourceArity: op.resourceArity, |
| 827 | message: |
| 828 | 'Resource-bound binding uses `Resource: ["*"]`; bind the canonical resource(s) explicitly so the policy stays least-privilege.', |
| 829 | }); |
| 830 | } |
| 831 | } catch { |
| 832 | // Ignore missing or unreadable files; other audit checks will surface those. |
| 833 | } |
| 834 | } |
| 835 | |
| 836 | return warnings.sort((a, b) => a.binding.localeCompare(b.binding)); |
| 837 | } |
| 838 | |
| 839 | // ============ Resource Inference ============ |
| 840 | |
| 841 | function inferCanonicalResources( |
| 842 | operations: Operation[], |
| 843 | existingFiles: Set<string>, |
| 844 | ): CanonicalResource[] { |
| 845 | const resourceMap = new Map< |
| 846 | string, |
| 847 | { operations: string[]; bindings: string[] } |
| 848 | >(); |
| 849 | |
| 850 | for (const op of operations) { |
| 851 | if (op.category === "resource-lifecycle") { |
| 852 | let resourceName: string | undefined; |
| 853 | |
| 854 | if ( |
| 855 | ["registerStreamConsumer", "deregisterStreamConsumer"].includes( |
| 856 | op.camelCase, |
| 857 | ) |
| 858 | ) { |
| 859 | resourceName = "StreamConsumer"; |
| 860 | } else if (["putRule", "deleteRule"].includes(op.camelCase)) { |
| 861 | resourceName = "Rule"; |
| 862 | } else if (["putPermission", "removePermission"].includes(op.camelCase)) { |
| 863 | resourceName = "Permission"; |
| 864 | } else if ( |
| 865 | [ |
| 866 | "addTagsToStream", |
| 867 | "createStream", |
| 868 | "decreaseStreamRetentionPeriod", |
| 869 | "deleteResourcePolicy", |
| 870 | "deleteStream", |
| 871 | "disableEnhancedMonitoring", |
| 872 | "enableEnhancedMonitoring", |
| 873 | "increaseStreamRetentionPeriod", |
| 874 | "mergeShards", |
| 875 | "putResourcePolicy", |
| 876 | "removeTagsFromStream", |
| 877 | "splitShard", |
| 878 | "startStreamEncryption", |
| 879 | "stopStreamEncryption", |
| 880 | "tagResource", |
| 881 | "untagResource", |
| 882 | "updateMaxRecordSize", |
| 883 | "updateShardCount", |
| 884 | "updateStreamMode", |
| 885 | "updateStreamWarmThroughput", |
| 886 | ].includes(op.camelCase) |
| 887 | ) { |
| 888 | resourceName = "Stream"; |
| 889 | } |
| 890 | |
| 891 | // Extract resource name from operation like createTable -> Table |
| 892 | const match = |
| 893 | resourceName === undefined |
| 894 | ? op.camelCase.match( |
| 895 | /^(create|delete|update|describe)([A-Z][a-zA-Z]+)/, |
| 896 | ) |
| 897 | : undefined; |
| 898 | if (resourceName || match) { |
| 899 | const resolvedResourceName = resourceName ?? match![2]; |
| 900 | if (!resourceMap.has(resolvedResourceName)) { |
| 901 | resourceMap.set(resolvedResourceName, { |
| 902 | operations: [], |
| 903 | bindings: [], |
| 904 | }); |
| 905 | } |
| 906 | resourceMap.get(resolvedResourceName)!.operations.push(op.camelCase); |
| 907 | } |
| 908 | } else if (op.category === "binding" && op.resourceArity === 1) { |
| 909 | // Associate binding with likely resource |
| 910 | // e.g., getItem, putItem, deleteItem -> Table (DynamoDB convention) |
| 911 | // This is heuristic and service-specific |
| 912 | const commonResources = [ |
| 913 | "Table", |
| 914 | "Bucket", |
| 915 | "Queue", |
| 916 | "Stream", |
| 917 | "Function", |
| 918 | ]; |
| 919 | for (const res of commonResources) { |
| 920 | if (!resourceMap.has(res)) { |
| 921 | resourceMap.set(res, { operations: [], bindings: [] }); |
| 922 | } |
| 923 | resourceMap.get(res)!.bindings.push(op.pascalCase); |
| 924 | } |
| 925 | } |
| 926 | } |
| 927 | |
| 928 | const results: CanonicalResource[] = []; |
| 929 | for (const [name, data] of resourceMap) { |
| 930 | if (data.operations.length > 0) { |
| 931 | results.push({ |
| 932 | name, |
| 933 | impliedByOperations: data.operations, |
| 934 | hasProvider: existingFiles.has(name), |
| 935 | suggestedBindings: data.bindings.slice(0, 10), // Limit for readability |
| 936 | }); |
| 937 | } |
| 938 | } |
| 939 | |
| 940 | return results.sort((a, b) => a.name.localeCompare(b.name)); |
| 941 | } |
| 942 | |
| 943 | // ============ Helper Suggestions ============ |
| 944 | |
| 945 | function suggestHelpers( |
| 946 | operations: Operation[], |
| 947 | service: string, |
| 948 | ): SuggestedHelper[] { |
| 949 | const suggestions: SuggestedHelper[] = []; |
| 950 | |
| 951 | // Pattern: Stream-based helpers like notifications(bucket), messages(queue), changes(table) |
| 952 | const streamOps = operations.filter((op) => op.impliesEventSource); |
| 953 | if (streamOps.length > 0) { |
| 954 | const helperName = |
| 955 | service.toLowerCase() === "dynamodb" |
| 956 | ? "changes" |
| 957 | : service.toLowerCase() === "sqs" |
| 958 | ? "messages" |
| 959 | : service.toLowerCase() === "s3" |
| 960 | ? "notifications" |
| 961 | : "events"; |
| 962 | |
| 963 | suggestions.push({ |
| 964 | name: `${helperName}(resource)`, |
| 965 | pattern: "Event stream subscription helper", |
| 966 | basedOn: streamOps.map((op) => op.camelCase), |
| 967 | existingExample: |
| 968 | service.toLowerCase() === "s3" |
| 969 | ? "alchemy/src/AWS/S3/BucketNotifications.ts" |
| 970 | : service.toLowerCase() === "sqs" |
| 971 | ? "alchemy/src/AWS/SQS/QueueEventSource.ts" |
| 972 | : null, |
| 973 | }); |
| 974 | } |
| 975 | |
| 976 | // Pattern: Batch operations -> typed batch helpers |
| 977 | const batchOps = operations.filter( |
| 978 | (op) => |
| 979 | op.camelCase.startsWith("batch") || op.camelCase.startsWith("transact"), |
| 980 | ); |
| 981 | if (batchOps.length > 0) { |
| 982 | suggestions.push({ |
| 983 | name: "batch operations", |
| 984 | pattern: "Typed batch/transaction wrappers", |
| 985 | basedOn: batchOps.map((op) => op.camelCase), |
| 986 | existingExample: null, |
| 987 | }); |
| 988 | } |
| 989 | |
| 990 | return suggestions; |
| 991 | } |
| 992 | |
| 993 | // ============ Main Audit Logic ============ |
| 994 | |
| 995 | async function auditService(serviceName: string): Promise<AuditReport> { |
| 996 | const serviceNameLower = serviceName.toLowerCase(); |
| 997 | const serviceNameUpper = |
| 998 | serviceName.charAt(0).toUpperCase() + serviceName.slice(1); |
| 999 | |
| 1000 | // Map common service names to their distilled paths and alchemy paths |
| 1001 | const serviceConfig: Record<string, { distilled: string; alchemy: string }> = |
| 1002 | { |
| 1003 | dynamodb: { distilled: "dynamodb", alchemy: "DynamoDB" }, |
| 1004 | s3: { distilled: "s3", alchemy: "S3" }, |
| 1005 | sqs: { distilled: "sqs", alchemy: "SQS" }, |
| 1006 | lambda: { distilled: "lambda", alchemy: "Lambda" }, |
| 1007 | kinesis: { distilled: "kinesis", alchemy: "Kinesis" }, |
| 1008 | ec2: { distilled: "ec2", alchemy: "EC2" }, |
| 1009 | ecs: { distilled: "ecs", alchemy: "ECS" }, |
| 1010 | cloudfront: { distilled: "cloudfront", alchemy: "CloudFront" }, |
| 1011 | cloudwatch: { distilled: "cloudwatch", alchemy: "CloudWatch" }, |
| 1012 | eventbridge: { distilled: "eventbridge", alchemy: "EventBridge" }, |
| 1013 | iam: { distilled: "iam", alchemy: "IAM" }, |
| 1014 | pipes: { distilled: "pipes", alchemy: "Pipes" }, |
| 1015 | sns: { distilled: "sns", alchemy: "SNS" }, |
| 1016 | scheduler: { distilled: "scheduler", alchemy: "Scheduler" }, |
| 1017 | rds: { distilled: "rds", alchemy: "RDS" }, |
| 1018 | "rds-data": { distilled: "rds-data", alchemy: "RDSData" }, |
| 1019 | "secrets-manager": { |
| 1020 | distilled: "secrets-manager", |
| 1021 | alchemy: "SecretsManager", |
| 1022 | }, |
| 1023 | apigateway: { distilled: "api-gateway", alchemy: "ApiGateway" }, |
| 1024 | ses: { distilled: "ses", alchemy: "SES" }, |
| 1025 | sesv2: { distilled: "sesv2", alchemy: "SES" }, |
| 1026 | }; |
| 1027 | |
| 1028 | const config = serviceConfig[serviceNameLower] || { |
| 1029 | distilled: serviceNameLower, |
| 1030 | alchemy: serviceNameUpper, |
| 1031 | }; |
| 1032 | |
| 1033 | const preferredDistilledPath = path.resolve( |
| 1034 | `submodules/distilled/packages/aws/src/services/${config.distilled}.ts`, |
| 1035 | ); |
| 1036 | const resolvedDistilledPath = await fs |
| 1037 | .access(preferredDistilledPath) |
| 1038 | .then(() => preferredDistilledPath) |
| 1039 | .catch(() => undefined); |
| 1040 | const distilledPath = |
| 1041 | resolvedDistilledPath ?? `@distilled.cloud/aws/${config.distilled}`; |
| 1042 | const alchemyPath = path.resolve( |
| 1043 | `packages/alchemy/src/AWS/${config.alchemy}`, |
| 1044 | ); |
| 1045 | const bindingTestPath = path.resolve( |
| 1046 | `packages/alchemy/test/AWS/${config.alchemy}/Bindings.test.ts`, |
| 1047 | ); |
| 1048 | const indexPath = path.join(alchemyPath, "index.ts"); |
| 1049 | const providersPath = path.resolve("packages/alchemy/src/AWS/Providers.ts"); |
| 1050 | |
| 1051 | // Extract data |
| 1052 | const distilledOps = await extractDistilledOperations( |
| 1053 | resolvedDistilledPath ?? preferredDistilledPath, |
| 1054 | `@distilled.cloud/aws/${config.distilled}`, |
| 1055 | ); |
| 1056 | const alchemyFiles = await getAlchemyFiles(alchemyPath); |
| 1057 | const indexExports = await getIndexExports(indexPath); |
| 1058 | const providerRegs = await getProvidersRegistrations( |
| 1059 | providersPath, |
| 1060 | config.alchemy, |
| 1061 | ); |
| 1062 | const bindingTestCoverage = await getBindingTestDescribes(bindingTestPath); |
| 1063 | |
| 1064 | // Classify operations |
| 1065 | const operations: Operation[] = await Promise.all( |
| 1066 | distilledOps.map(async (name) => { |
| 1067 | const pascalCase = toPascalCase(name); |
| 1068 | const classification = classifyOperation(serviceNameLower, name); |
| 1069 | const implemented = alchemyFiles.has(pascalCase); |
| 1070 | const resourceArity = implemented |
| 1071 | ? await inferImplementedResourceArity( |
| 1072 | alchemyPath, |
| 1073 | pascalCase, |
| 1074 | classification.resourceArity, |
| 1075 | ) |
| 1076 | : classification.resourceArity; |
| 1077 | |
| 1078 | return { |
| 1079 | name, |
| 1080 | camelCase: name, |
| 1081 | pascalCase, |
| 1082 | ...classification, |
| 1083 | resourceArity, |
| 1084 | implemented, |
| 1085 | registeredInProviders: providerRegs.bindings.has(pascalCase), |
| 1086 | registeredInIndex: indexExports.has(pascalCase), |
| 1087 | }; |
| 1088 | }), |
| 1089 | ); |
| 1090 | |
| 1091 | // Group by category |
| 1092 | const implementedBindings = operations.filter( |
| 1093 | (op) => op.category === "binding" && op.implemented, |
| 1094 | ); |
| 1095 | const missingBindings = operations.filter( |
| 1096 | (op) => op.category === "binding" && !op.implemented, |
| 1097 | ); |
| 1098 | const resourceLifecycleOps = operations.filter( |
| 1099 | (op) => op.category === "resource-lifecycle", |
| 1100 | ); |
| 1101 | const eventSourceOps = operations.filter( |
| 1102 | (op) => op.category === "event-source", |
| 1103 | ); |
| 1104 | const helperCandidates = operations.filter( |
| 1105 | (op) => op.category === "helper-candidate", |
| 1106 | ); |
| 1107 | const internalOps = operations.filter((op) => op.category === "internal"); |
| 1108 | |
| 1109 | // Infer resources and helpers |
| 1110 | const canonicalResources = inferCanonicalResources(operations, alchemyFiles); |
| 1111 | const suggestedHelpers = suggestHelpers(operations, serviceName); |
| 1112 | const leastPrivilegeWarnings = await getLeastPrivilegeWarnings( |
| 1113 | alchemyPath, |
| 1114 | operations, |
| 1115 | ); |
| 1116 | |
| 1117 | // Find registration gaps |
| 1118 | const registrationGaps: RegistrationGap[] = []; |
| 1119 | |
| 1120 | const implementedButNotInIndex = implementedBindings.filter( |
| 1121 | (op) => !op.registeredInIndex, |
| 1122 | ); |
| 1123 | if (implementedButNotInIndex.length > 0) { |
| 1124 | registrationGaps.push({ |
| 1125 | type: "index", |
| 1126 | file: indexPath, |
| 1127 | missing: implementedButNotInIndex.map((op) => op.pascalCase), |
| 1128 | }); |
| 1129 | } |
| 1130 | |
| 1131 | const implementedButNotInProviders = implementedBindings.filter( |
| 1132 | (op) => !op.registeredInProviders, |
| 1133 | ); |
| 1134 | if (implementedButNotInProviders.length > 0) { |
| 1135 | registrationGaps.push({ |
| 1136 | type: "policy", |
| 1137 | file: providersPath, |
| 1138 | missing: implementedButNotInProviders.map( |
| 1139 | (op) => `${op.pascalCase}PolicyLive`, |
| 1140 | ), |
| 1141 | }); |
| 1142 | } |
| 1143 | |
| 1144 | const missingBindingTests = bindingTestCoverage.exists |
| 1145 | ? implementedBindings |
| 1146 | .filter((op) => !bindingTestCoverage.describes.has(op.pascalCase)) |
| 1147 | .map((op) => op.pascalCase) |
| 1148 | : implementedBindings.map((op) => op.pascalCase); |
| 1149 | |
| 1150 | return { |
| 1151 | service: serviceName, |
| 1152 | distilledPath, |
| 1153 | alchemyPath, |
| 1154 | bindingTestPath, |
| 1155 | totalOperations: operations.length, |
| 1156 | implementedBindings, |
| 1157 | missingBindings, |
| 1158 | resourceLifecycleOps, |
| 1159 | eventSourceOps, |
| 1160 | helperCandidates, |
| 1161 | internalOps, |
| 1162 | canonicalResources, |
| 1163 | suggestedHelpers, |
| 1164 | registrationGaps, |
| 1165 | missingBindingTests, |
| 1166 | leastPrivilegeWarnings, |
| 1167 | }; |
| 1168 | } |
| 1169 | |
| 1170 | // ============ Report Formatting ============ |
| 1171 | |
| 1172 | function formatReport(report: AuditReport): string { |
| 1173 | const lines: string[] = []; |
| 1174 | |
| 1175 | lines.push(`\n${"=".repeat(80)}`); |
| 1176 | lines.push(`SERVICE AUDIT: ${report.service.toUpperCase()}`); |
| 1177 | lines.push(`${"=".repeat(80)}\n`); |
| 1178 | |
| 1179 | lines.push(`Distilled spec: ${report.distilledPath}`); |
| 1180 | lines.push(`Alchemy path: ${report.alchemyPath}`); |
| 1181 | lines.push(`Binding tests: ${report.bindingTestPath}`); |
| 1182 | lines.push(`Total operations in distilled: ${report.totalOperations}\n`); |
| 1183 | |
| 1184 | // Summary |
| 1185 | lines.push(`${"─".repeat(80)}`); |
| 1186 | lines.push("SUMMARY"); |
| 1187 | lines.push(`${"─".repeat(80)}`); |
| 1188 | lines.push( |
| 1189 | ` Implemented bindings: ${report.implementedBindings.length}`, |
| 1190 | ); |
| 1191 | lines.push(` Missing bindings: ${report.missingBindings.length}`); |
| 1192 | lines.push( |
| 1193 | ` Resource lifecycle ops: ${report.resourceLifecycleOps.length}`, |
| 1194 | ); |
| 1195 | lines.push(` Event source ops: ${report.eventSourceOps.length}`); |
| 1196 | lines.push(` Helper candidates: ${report.helperCandidates.length}`); |
| 1197 | lines.push(` Internal ops (skip): ${report.internalOps.length}`); |
| 1198 | lines.push( |
| 1199 | ` Missing binding tests: ${report.missingBindingTests.length}`, |
| 1200 | ); |
| 1201 | lines.push( |
| 1202 | ` Least-privilege warnings: ${report.leastPrivilegeWarnings.length}`, |
| 1203 | ); |
| 1204 | lines.push(""); |
| 1205 | |
| 1206 | // Implemented bindings |
| 1207 | if (report.implementedBindings.length > 0) { |
| 1208 | lines.push(`${"─".repeat(80)}`); |
| 1209 | lines.push("IMPLEMENTED BINDINGS"); |
| 1210 | lines.push(`${"─".repeat(80)}`); |
| 1211 | for (const op of report.implementedBindings) { |
| 1212 | const arity = `[${formatArity(op.resourceArity)}]`; |
| 1213 | const regStatus = op.registeredInProviders |
| 1214 | ? "✓ registered" |
| 1215 | : "⚠ NOT in Providers.ts"; |
| 1216 | lines.push(` ✓ ${op.pascalCase}.ts ${arity} ${regStatus}`); |
| 1217 | } |
| 1218 | lines.push(""); |
| 1219 | } |
| 1220 | |
| 1221 | // Missing bindings (priority list) |
| 1222 | if (report.missingBindings.length > 0) { |
| 1223 | lines.push(`${"─".repeat(80)}`); |
| 1224 | lines.push("MISSING BINDINGS (implement these)"); |
| 1225 | lines.push(`${"─".repeat(80)}`); |
| 1226 | |
| 1227 | // Group by arity |
| 1228 | const arity1 = report.missingBindings.filter( |
| 1229 | (op) => op.resourceArity === 1, |
| 1230 | ); |
| 1231 | const arity0 = report.missingBindings.filter( |
| 1232 | (op) => op.resourceArity === 0, |
| 1233 | ); |
| 1234 | const arity2 = report.missingBindings.filter( |
| 1235 | (op) => op.resourceArity === 2, |
| 1236 | ); |
| 1237 | const arityN = report.missingBindings.filter( |
| 1238 | (op) => op.resourceArity === "n", |
| 1239 | ); |
| 1240 | |
| 1241 | if (arity1.length > 0) { |
| 1242 | lines.push(" Single-resource bindings (arity=1):"); |
| 1243 | for (const op of arity1.slice(0, 20)) { |
| 1244 | lines.push(` • ${op.pascalCase} (${op.camelCase})`); |
| 1245 | } |
| 1246 | if (arity1.length > 20) { |
| 1247 | lines.push(` ... and ${arity1.length - 20} more`); |
| 1248 | } |
| 1249 | } |
| 1250 | |
| 1251 | if (arity0.length > 0) { |
| 1252 | lines.push(" Service-scoped bindings (arity=0):"); |
| 1253 | for (const op of arity0) { |
| 1254 | lines.push(` • ${op.pascalCase} (${op.camelCase})`); |
| 1255 | } |
| 1256 | } |
| 1257 | |
| 1258 | if (arity2.length > 0) { |
| 1259 | lines.push(" Fixed multi-resource bindings (arity=2):"); |
| 1260 | for (const op of arity2) { |
| 1261 | lines.push(` • ${op.pascalCase} (${op.camelCase})`); |
| 1262 | } |
| 1263 | } |
| 1264 | |
| 1265 | if (arityN.length > 0) { |
| 1266 | lines.push(" Variadic resource bindings (arity=n):"); |
| 1267 | for (const op of arityN) { |
| 1268 | lines.push(` • ${op.pascalCase} (${op.camelCase})`); |
| 1269 | } |
| 1270 | } |
| 1271 | lines.push(""); |
| 1272 | } |
| 1273 | |
| 1274 | // Canonical resources |
| 1275 | if (report.canonicalResources.length > 0) { |
| 1276 | lines.push(`${"─".repeat(80)}`); |
| 1277 | lines.push("CANONICAL RESOURCES (IaC resources to implement)"); |
| 1278 | lines.push(`${"─".repeat(80)}`); |
| 1279 | for (const res of report.canonicalResources) { |
| 1280 | const status = res.hasProvider ? "✓ has provider" : "⚠ MISSING provider"; |
| 1281 | lines.push(` ${res.name}: ${status}`); |
| 1282 | lines.push(` Implied by: ${res.impliedByOperations.join(", ")}`); |
| 1283 | } |
| 1284 | lines.push(""); |
| 1285 | } |
| 1286 | |
| 1287 | // Event source operations |
| 1288 | if (report.eventSourceOps.length > 0) { |
| 1289 | lines.push(`${"─".repeat(80)}`); |
| 1290 | lines.push("EVENT SOURCE OPERATIONS"); |
| 1291 | lines.push(`${"─".repeat(80)}`); |
| 1292 | for (const op of report.eventSourceOps) { |
| 1293 | const impl = op.implemented ? "✓" : "○"; |
| 1294 | lines.push(` ${impl} ${op.camelCase}`); |
| 1295 | } |
| 1296 | lines.push(""); |
| 1297 | } |
| 1298 | |
| 1299 | // Helper suggestions |
| 1300 | if (report.suggestedHelpers.length > 0) { |
| 1301 | lines.push(`${"─".repeat(80)}`); |
| 1302 | lines.push("SUGGESTED HELPERS"); |
| 1303 | lines.push(`${"─".repeat(80)}`); |
| 1304 | for (const helper of report.suggestedHelpers) { |
| 1305 | lines.push(` ${helper.name}`); |
| 1306 | lines.push(` Pattern: ${helper.pattern}`); |
| 1307 | lines.push(` Based on: ${helper.basedOn.join(", ")}`); |
| 1308 | if (helper.existingExample) { |
| 1309 | lines.push(` Example: ${helper.existingExample}`); |
| 1310 | } |
| 1311 | } |
| 1312 | lines.push(""); |
| 1313 | } |
| 1314 | |
| 1315 | // Registration gaps |
| 1316 | if (report.registrationGaps.length > 0) { |
| 1317 | lines.push(`${"─".repeat(80)}`); |
| 1318 | lines.push("REGISTRATION GAPS (fix these)"); |
| 1319 | lines.push(`${"─".repeat(80)}`); |
| 1320 | for (const gap of report.registrationGaps) { |
| 1321 | lines.push(` ${gap.type.toUpperCase()} (${gap.file}):`); |
| 1322 | for (const item of gap.missing) { |
| 1323 | lines.push(` • ${item}`); |
| 1324 | } |
| 1325 | } |
| 1326 | lines.push(""); |
| 1327 | } |
| 1328 | |
| 1329 | if (report.missingBindingTests.length > 0) { |
| 1330 | lines.push(`${"─".repeat(80)}`); |
| 1331 | lines.push("MISSING BINDING TESTS (add describe blocks)"); |
| 1332 | lines.push(`${"─".repeat(80)}`); |
| 1333 | lines.push(` In: ${report.bindingTestPath}`); |
| 1334 | for (const binding of report.missingBindingTests) { |
| 1335 | lines.push(` • describe("${binding}", ...)`); |
| 1336 | } |
| 1337 | lines.push(""); |
| 1338 | } |
| 1339 | |
| 1340 | if (report.leastPrivilegeWarnings.length > 0) { |
| 1341 | lines.push(`${"─".repeat(80)}`); |
| 1342 | lines.push("LEAST-PRIVILEGE WARNINGS"); |
| 1343 | lines.push(`${"─".repeat(80)}`); |
| 1344 | for (const warning of report.leastPrivilegeWarnings) { |
| 1345 | lines.push( |
| 1346 | ` ⚠ ${warning.binding}.ts [${formatArity(warning.resourceArity)}] (${warning.file})`, |
| 1347 | ); |
| 1348 | lines.push(` ${warning.message}`); |
| 1349 | } |
| 1350 | lines.push(""); |
| 1351 | } |
| 1352 | |
| 1353 | // Helper candidate operations |
| 1354 | if (report.helperCandidates.length > 0) { |
| 1355 | lines.push(`${"─".repeat(80)}`); |
| 1356 | lines.push("HELPER CANDIDATE OPERATIONS (consider wrapping)"); |
| 1357 | lines.push(`${"─".repeat(80)}`); |
| 1358 | for (const op of report.helperCandidates) { |
| 1359 | const impl = op.implemented ? "✓" : "○"; |
| 1360 | lines.push( |
| 1361 | ` ${impl} ${op.camelCase} [${formatArity(op.resourceArity)}]`, |
| 1362 | ); |
| 1363 | } |
| 1364 | lines.push(""); |
| 1365 | } |
| 1366 | |
| 1367 | lines.push(`${"=".repeat(80)}`); |
| 1368 | lines.push("END OF AUDIT"); |
| 1369 | lines.push(`${"=".repeat(80)}\n`); |
| 1370 | |
| 1371 | return lines.join("\n"); |
| 1372 | } |
| 1373 | |
| 1374 | // ============ CLI ============ |
| 1375 | |
| 1376 | async function main() { |
| 1377 | const args = process.argv.slice(2); |
| 1378 | |
| 1379 | if (args.length === 0) { |
| 1380 | console.log("Usage: bun scripts/audit-service.ts [--json] <service>"); |
| 1381 | console.log("Example: bun scripts/audit-service.ts dynamodb"); |
| 1382 | process.exit(1); |
| 1383 | } |
| 1384 | |
| 1385 | const jsonOutput = args.includes("--json"); |
| 1386 | const serviceName = args.filter((a) => !a.startsWith("--"))[0]; |
| 1387 | |
| 1388 | if (!serviceName) { |
| 1389 | console.error("Error: No service name provided"); |
| 1390 | process.exit(1); |
| 1391 | } |
| 1392 | |
| 1393 | try { |
| 1394 | const report = await auditService(serviceName); |
| 1395 | |
| 1396 | if (jsonOutput) { |
| 1397 | console.log(JSON.stringify(report, null, 2)); |
| 1398 | } else { |
| 1399 | console.log(formatReport(report)); |
| 1400 | } |
| 1401 | } catch (error) { |
| 1402 | console.error(`Error auditing service ${serviceName}:`, error); |
| 1403 | process.exit(1); |
| 1404 | } |
| 1405 | } |
| 1406 | |
| 1407 | main(); |