@@ -60,6 +60,24 @@ interface ExportSummary {
6060const REPO_ROOT = path . resolve ( import . meta. dirname , '..' ) ;
6161const CDK_TYPES_FILE = path . join ( REPO_ROOT , 'cdk/src/handlers/shared/types.ts' ) ;
6262const CLI_TYPES_FILE = path . join ( REPO_ROOT , 'cli/src/types.ts' ) ;
63+ const CONSTANTS_JSON_FILE = path . join ( REPO_ROOT , 'contracts/constants.json' ) ;
64+
65+ /**
66+ * ``contracts/constants.json`` is the single source of truth for the
67+ * shared numeric bounds (``check-constants-sync.ts`` enforces that
68+ * Python and the JSON agree). The CDK imports these directly
69+ * (``import sharedConstants from '.../constants.json'``) because its
70+ * handlers are esbuild-bundled at deploy; the CLI is a plain
71+ * ``tsc --build`` published package whose ``rootDir`` can't reach the
72+ * file, so it mirrors the same numbers as literals. To compare the two
73+ * fairly we resolve any ``<alias>.a.b`` reference to a value loaded from
74+ * the JSON, so ``sharedConstants.approval_timeout_s.min`` and ``30``
75+ * normalize to the same shape — while a genuine literal drift (``60``)
76+ * still trips the diff.
77+ */
78+ const SHARED_CONSTANTS : Record < string , unknown > = JSON . parse (
79+ fs . readFileSync ( CONSTANTS_JSON_FILE , 'utf-8' ) ,
80+ ) ;
6381
6482/**
6583 * Names that are intentionally CDK-only — handler-internal shapes
@@ -146,6 +164,13 @@ function parseFile(filePath: string): Map<string, ExportSummary> {
146164 const sourceFile = ts . createSourceFile ( filePath , source , ts . ScriptTarget . Latest , true ) ;
147165 const exports = new Map < string , ExportSummary > ( ) ;
148166
167+ // Local names bound to a default-import of contracts/constants.json
168+ // (e.g. ``import sharedConstants from '.../constants.json'``). Used to
169+ // resolve ``sharedConstants.approval_timeout_s.min`` to its JSON value
170+ // when summarizing literal constants, so a JSON-sourced reference and a
171+ // mirrored literal compare equal.
172+ const constantsAliases = collectConstantsAliases ( sourceFile ) ;
173+
149174 for ( const node of sourceFile . statements ) {
150175 // Re-export declarations (``export type { Foo } from './bar'`` or
151176 // ``export type { Foo };`` after an import) are top-level
@@ -169,7 +194,7 @@ function parseFile(filePath: string): Map<string, ExportSummary> {
169194 // Constants like `export const APPROVAL_TIMEOUT_S_MIN = 30`.
170195 for ( const decl of node . declarationList . declarations ) {
171196 if ( ts . isIdentifier ( decl . name ) ) {
172- exports . set ( decl . name . text , summarizeLiteralConst ( decl ) ) ;
197+ exports . set ( decl . name . text , summarizeLiteralConst ( decl , constantsAliases ) ) ;
173198 }
174199 }
175200 } else if ( ts . isFunctionDeclaration ( node ) && node . name ) {
@@ -215,10 +240,71 @@ function summarizeTypeAlias(node: ts.TypeAliasDeclaration): ExportSummary {
215240 return { kind : 'type-alias' , shape : sorted } ;
216241}
217242
218- function summarizeLiteralConst ( decl : ts . VariableDeclaration ) : ExportSummary {
243+ /**
244+ * Collect local identifiers bound to a default-import of
245+ * ``contracts/constants.json`` so property accesses through them can be
246+ * resolved to concrete JSON values. Matches the import by specifier
247+ * basename (``constants.json``) regardless of the relative path depth,
248+ * which differs between the CDK (``../../../../``) and any future CLI use.
249+ */
250+ function collectConstantsAliases ( sourceFile : ts . SourceFile ) : Set < string > {
251+ const aliases = new Set < string > ( ) ;
252+ for ( const node of sourceFile . statements ) {
253+ if (
254+ ts . isImportDeclaration ( node ) &&
255+ ts . isStringLiteral ( node . moduleSpecifier ) &&
256+ path . basename ( node . moduleSpecifier . text ) === 'constants.json' &&
257+ node . importClause ?. name
258+ ) {
259+ aliases . add ( node . importClause . name . text ) ;
260+ }
261+ }
262+ return aliases ;
263+ }
264+
265+ /**
266+ * Resolve a ``<alias>.a.b`` property-access chain rooted at a
267+ * constants.json default-import to its JSON value, returning the value's
268+ * textual form (e.g. ``30``). Returns ``undefined`` for any expression
269+ * that isn't such a chain or doesn't resolve to a primitive in the JSON.
270+ */
271+ function resolveConstantsReference (
272+ expr : ts . Expression ,
273+ aliases : Set < string > ,
274+ ) : string | undefined {
275+ if ( aliases . size === 0 || ! ts . isPropertyAccessExpression ( expr ) ) return undefined ;
276+ const segments : string [ ] = [ ] ;
277+ let cursor : ts . Expression = expr ;
278+ while ( ts . isPropertyAccessExpression ( cursor ) ) {
279+ segments . unshift ( cursor . name . text ) ;
280+ cursor = cursor . expression ;
281+ }
282+ if ( ! ts . isIdentifier ( cursor ) || ! aliases . has ( cursor . text ) ) return undefined ;
283+ let value : unknown = SHARED_CONSTANTS ;
284+ for ( const seg of segments ) {
285+ if ( value == null || typeof value !== 'object' ) return undefined ;
286+ value = ( value as Record < string , unknown > ) [ seg ] ;
287+ }
288+ if ( value == null || typeof value === 'object' ) return undefined ;
289+ return String ( value ) ;
290+ }
291+
292+ function summarizeLiteralConst (
293+ decl : ts . VariableDeclaration ,
294+ constantsAliases : Set < string > ,
295+ ) : ExportSummary {
219296 // Capture the textual initializer so a value drift (e.g.
220- // APPROVAL_TIMEOUT_S_MIN = 30 vs 60) gets flagged. Whitespace
221- // normalized to keep formatting churn out of the diff.
297+ // APPROVAL_TIMEOUT_S_MIN = 30 vs 60) gets flagged. A reference into
298+ // contracts/constants.json (e.g. ``sharedConstants.approval_timeout_s.min``)
299+ // is first resolved to its JSON value so it compares equal to the
300+ // mirrored literal on the other side. Whitespace normalized to keep
301+ // formatting churn out of the diff.
302+ if ( decl . initializer ) {
303+ const resolved = resolveConstantsReference ( decl . initializer , constantsAliases ) ;
304+ if ( resolved !== undefined ) {
305+ return { kind : 'literal-const' , shape : resolved } ;
306+ }
307+ }
222308 const init = decl . initializer ? decl . initializer . getText ( ) . replace ( / \s + / g, ' ' ) . trim ( ) : '' ;
223309 return { kind : 'literal-const' , shape : init } ;
224310}
0 commit comments