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
15import * as fs from "node:fs/promises";
16import * as path from "node:path";
17
18// ============ Types ============
19
20interface 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
33type 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
40type 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
46interface 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
65interface CanonicalResource {
66 name: string;
67 impliedByOperations: string[];
68 hasProvider: boolean;
69 suggestedBindings: string[];
70}
71
72interface SuggestedHelper {
73 name: string;
74 pattern: string;
75 basedOn: string[];
76 existingExample: string | null;
77}
78
79interface RegistrationGap {
80 type: "provider" | "index" | "policy";
81 file: string;
82 missing: string[];
83}
84
85interface 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)
96const 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
123const 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
136const 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)
143const 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
161const 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
176const ZERO_ARITY_PATTERNS = [
177 /^list(?:Tables|Buckets|Queues|Functions|Streams)$/i,
178 /^describe(?:Endpoints|Limits|Account)$/i,
179];
180
181const FIXED_MULTI_ARITY_PATTERNS = [/^copy/i, /^replicate/i, /^restore.*From/i];
182
183const N_ARITY_PATTERNS = [
184 /^batch/i,
185 /^transact/i,
186 /^execute.*Statement/i,
187 /^execute.*Transaction/i,
188];
189
190// ============ Utilities ============
191
192function toPascalCase(str: string): string {
193 return str.charAt(0).toUpperCase() + str.slice(1);
194}
195
196function toCamelCase(str: string): string {
197 return str.charAt(0).toLowerCase() + str.slice(1);
198}
199
200function matchesAnyPattern(name: string, patterns: RegExp[]): boolean {
201 return patterns.some((p) => p.test(name));
202}
203
204function 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
638async 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
679function formatArity(arity: ResourceArity): string {
680 return `arity=${arity}`;
681}
682
683// ============ File Parsing ============
684
685async 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
712async 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
727async 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
743async 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
779async 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
799async 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
841function 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
945function 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
995async 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
1172function 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
1376async 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
1407main();