@apifuse/provider-sdk 2.2.0-beta.40 → 2.2.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/bin/apifuse-check.ts +61 -0
  3. package/bin/apifuse-migrate-shape.ts +84 -0
  4. package/bin/apifuse-submit-check.ts +1760 -222
  5. package/dist/cli/commands.d.ts +1 -1
  6. package/dist/cli/commands.js +8 -0
  7. package/dist/cli/create.js +6 -0
  8. package/dist/cli/migrate-provider-shape.d.ts +52 -0
  9. package/dist/cli/migrate-provider-shape.js +515 -0
  10. package/dist/cli/templates/provider/provider.json.tpl +6 -0
  11. package/dist/contract.js +1 -0
  12. package/dist/define.js +22 -1
  13. package/dist/error-observability.d.ts +7 -0
  14. package/dist/error-observability.js +61 -0
  15. package/dist/errors.d.ts +15 -0
  16. package/dist/fixture-sanitization.js +13 -3
  17. package/dist/index.d.ts +1 -1
  18. package/dist/provider.d.ts +1 -1
  19. package/dist/runtime/executor.js +11 -1
  20. package/dist/server/error-observability.d.ts +1 -0
  21. package/dist/server/error-observability.js +1 -0
  22. package/dist/server/index.d.ts +2 -1
  23. package/dist/server/self-test.js +3 -0
  24. package/dist/server/serve-implementation.d.ts +12 -0
  25. package/dist/server/serve-implementation.js +135 -66
  26. package/dist/types.d.ts +18 -10
  27. package/package.json +3 -3
  28. package/src/cli/commands.ts +10 -0
  29. package/src/cli/create.ts +6 -0
  30. package/src/cli/migrate-provider-shape.ts +701 -0
  31. package/src/cli/templates/provider/provider.json.tpl +6 -0
  32. package/src/contract.ts +1 -0
  33. package/src/define.ts +33 -1
  34. package/src/error-observability.ts +64 -0
  35. package/src/errors.ts +16 -0
  36. package/src/fixture-sanitization.ts +19 -3
  37. package/src/index.ts +1 -0
  38. package/src/provider.ts +1 -0
  39. package/src/runtime/executor.ts +13 -1
  40. package/src/server/error-observability.ts +1 -0
  41. package/src/server/index.ts +2 -0
  42. package/src/server/self-test.ts +5 -0
  43. package/src/server/serve-implementation.ts +172 -84
  44. package/src/types.ts +38 -27
@@ -4,10 +4,11 @@ import { type ChildProcess, spawn } from "node:child_process";
4
4
  import { existsSync, readdirSync, readFileSync } from "node:fs";
5
5
  import { writeFile } from "node:fs/promises";
6
6
  import { createServer } from "node:net";
7
- import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
 
10
10
  import * as acorn from "acorn";
11
+ import ts from "typescript";
11
12
  import { z } from "zod";
12
13
 
13
14
  import packageJson from "../package.json";
@@ -112,11 +113,14 @@ export type SmokeResult = {
112
113
  type SourceFinding = {
113
114
  file: string;
114
115
  line: number;
116
+ detail?: string;
115
117
  };
116
118
 
117
119
  const SDK_NATIVE_CATEGORY = "sdk-native";
118
120
  const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
119
121
  const MAX_SOURCE_FINDING_EVIDENCE = 5;
122
+ const MAX_LOAD_ERROR_EVIDENCE = 5;
123
+ const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
120
124
 
121
125
  const CATEGORY_MAX_POINTS = {
122
126
  definition: 15,
@@ -271,7 +275,20 @@ export async function buildSubmitCheckReport(
271
275
  ): Promise<SubmitCheckReport> {
272
276
  const checks: SubmitCheck[] = [];
273
277
  const baseChecks = await safeRunChecks(providerRoot);
274
- const provider = await safeLoadProvider(providerRoot);
278
+ const loadResult = await safeLoadProvider(providerRoot);
279
+ const provider = loadResult.ok ? loadResult.provider : undefined;
280
+ let indexParseError: string | undefined;
281
+ if (!provider) {
282
+ const indexPath = resolve(providerRoot, "index.ts");
283
+ if (existsSync(indexPath)) {
284
+ const relPath = toRelativeProviderPath(providerRoot, indexPath);
285
+ try {
286
+ maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
287
+ } catch (error) {
288
+ indexParseError = error instanceof Error ? error.message : String(error);
289
+ }
290
+ }
291
+ }
275
292
 
276
293
  // Prompt-asset freshness is reported by its own dedicated zero-point
277
294
  // blocker below; filter the base-check duplicate so it is not double
@@ -282,17 +299,20 @@ export async function buildSubmitCheckReport(
282
299
  ),
283
300
  );
284
301
  checks.push(scorePromptAssetFreshness(providerRoot));
285
- checks.push(scoreProviderIdSlug(providerRoot, provider));
286
- checks.push(scoreNoVendorShim(providerRoot));
287
- checks.push(scoreNoVendorImport(providerRoot));
288
- checks.push(scoreDescribeKey(providerRoot));
289
- checks.push(scoreNoRawFetch(providerRoot));
290
- checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
291
- checks.push(scoreManagedBrowserRuntime(providerRoot));
292
- checks.push(scoreAsAssertionCount(providerRoot));
293
- checks.push(scoreUnsafeInputPassthrough(providerRoot));
294
- checks.push(scoreUnjustifiedLooseSchema(providerRoot));
295
- checks.push(scoreFlatOperationComposition(providerRoot));
302
+ if (!indexParseError) {
303
+ checks.push(scoreProviderIdSlug(providerRoot, provider));
304
+ checks.push(scoreNoVendorShim(providerRoot));
305
+ checks.push(scoreNoVendorImport(providerRoot));
306
+ checks.push(scoreDescribeKey(providerRoot));
307
+ checks.push(scoreNoRawFetch(providerRoot));
308
+ checks.push(scoreNoDynamicCode(providerRoot));
309
+ checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
310
+ checks.push(scoreManagedBrowserRuntime(providerRoot));
311
+ checks.push(scoreAsAssertionCount(providerRoot));
312
+ checks.push(scoreUnsafeInputPassthrough(providerRoot));
313
+ checks.push(scoreUnjustifiedLooseSchema(providerRoot));
314
+ checks.push(scoreFlatOperationComposition(providerRoot));
315
+ }
296
316
 
297
317
  if (provider) {
298
318
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
@@ -311,15 +331,38 @@ export async function buildSubmitCheckReport(
311
331
  checks.push(scoreRepositoryDx(providerRoot));
312
332
  checks.push(scoreSecrets(providerRoot, provider));
313
333
  } else {
334
+ const loadEvidence = indexParseError
335
+ ? undefined
336
+ : loadResult.ok
337
+ ? undefined
338
+ : formatLoadErrorEvidence(loadResult.error, providerRoot);
339
+ const loadRemediation = indexParseError
340
+ ? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
341
+ : !loadResult.ok
342
+ ? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
343
+ : "Fix index.ts so it default-exports defineProvider(...).";
314
344
  checks.push(
315
345
  blocker(
316
346
  "provider-load",
317
347
  "definition",
318
348
  "Provider could not be loaded.",
319
- "Fix index.ts so it default-exports defineProvider(...).",
349
+ loadRemediation,
320
350
  CATEGORY_MAX_POINTS.definition,
351
+ loadEvidence,
321
352
  ),
322
353
  );
354
+ if (indexParseError) {
355
+ checks.push(
356
+ blocker(
357
+ "provider-load-parse",
358
+ "definition",
359
+ "Provider index.ts could not be parsed safely.",
360
+ "Fix the syntax error in index.ts before submitting.",
361
+ 0,
362
+ [indexParseError],
363
+ ),
364
+ );
365
+ }
323
366
  }
324
367
 
325
368
  const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
@@ -447,7 +490,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
447
490
  }
448
491
 
449
492
  function scoreNoRawFetch(providerRoot: string): SubmitCheck {
450
- const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
493
+ const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
451
494
  if (findings.length > 0) {
452
495
  const evidence = formatSourceFindings(findings);
453
496
  return blocker(
@@ -463,6 +506,435 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
463
506
  return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
464
507
  }
465
508
 
509
+ const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
510
+ const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
511
+ const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
512
+ "globalThis",
513
+ "window",
514
+ "self",
515
+ "global",
516
+ ]);
517
+
518
+ function unwrapLocalExpression(expression: ts.Expression): ts.Expression {
519
+ let current = expression;
520
+ while (
521
+ ts.isParenthesizedExpression(current) ||
522
+ ts.isAsExpression(current) ||
523
+ ts.isSatisfiesExpression(current) ||
524
+ ts.isNonNullExpression(current)
525
+ ) {
526
+ current = current.expression;
527
+ }
528
+ if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
529
+ return unwrapLocalExpression(current.right);
530
+ }
531
+ return current;
532
+ }
533
+
534
+ type LocalBinding = {
535
+ declaration: ts.Identifier;
536
+ initializer?: ts.Expression;
537
+ destructuredProperty?: string;
538
+ mutable: boolean;
539
+ reassigned: boolean;
540
+ };
541
+
542
+ type LocalBindings = {
543
+ scopes: Map<ts.Node, Map<string, LocalBinding[]>>;
544
+ };
545
+
546
+ function isLexicalScope(node: ts.Node): boolean {
547
+ return (
548
+ ts.isSourceFile(node) ||
549
+ ts.isFunctionLike(node) ||
550
+ ts.isBlock(node) ||
551
+ ts.isModuleBlock(node) ||
552
+ ts.isCaseBlock(node) ||
553
+ ts.isCatchClause(node) ||
554
+ ts.isForStatement(node) ||
555
+ ts.isForInStatement(node) ||
556
+ ts.isForOfStatement(node)
557
+ );
558
+ }
559
+
560
+ function enclosingScope(node: ts.Node, functionScoped: boolean): ts.Node {
561
+ let current: ts.Node | undefined = node.parent;
562
+ while (current !== undefined) {
563
+ if (
564
+ ts.isSourceFile(current) ||
565
+ (functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
566
+ ) {
567
+ return current;
568
+ }
569
+ current = current.parent;
570
+ }
571
+ return node.getSourceFile();
572
+ }
573
+
574
+ function collectLocalBindings(sourceFile: ts.SourceFile): LocalBindings {
575
+ const bindings: LocalBindings = { scopes: new Map() };
576
+ const addBinding = (
577
+ scope: ts.Node,
578
+ name: ts.Identifier,
579
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
580
+ ): void => {
581
+ let scopeBindings = bindings.scopes.get(scope);
582
+ if (scopeBindings === undefined) {
583
+ scopeBindings = new Map();
584
+ bindings.scopes.set(scope, scopeBindings);
585
+ }
586
+ const existing = scopeBindings.get(name.text);
587
+ const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
588
+ if (existing === undefined) {
589
+ scopeBindings.set(name.text, [localBinding]);
590
+ } else {
591
+ existing.push(localBinding);
592
+ }
593
+ };
594
+ const addBindingName = (
595
+ bindingName: ts.BindingName,
596
+ scope: ts.Node,
597
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
598
+ ): void => {
599
+ if (ts.isIdentifier(bindingName)) {
600
+ addBinding(scope, bindingName, binding);
601
+ return;
602
+ }
603
+ for (const element of bindingName.elements) {
604
+ if (ts.isOmittedExpression(element)) {
605
+ continue;
606
+ }
607
+ if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
608
+ const property = element.propertyName ?? element.name;
609
+ const propertyName = propertyNameText(property);
610
+ if (propertyName !== undefined && ts.isIdentifier(element.name)) {
611
+ addBinding(scope, element.name, {
612
+ initializer: binding.initializer,
613
+ destructuredProperty: propertyName,
614
+ mutable: binding.mutable,
615
+ });
616
+ continue;
617
+ }
618
+ }
619
+ addBindingName(element.name, scope, { mutable: binding.mutable });
620
+ }
621
+ };
622
+ const visit = (node: ts.Node): void => {
623
+ if (ts.isVariableDeclaration(node)) {
624
+ const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
625
+ const isConstBinding =
626
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
627
+ const isBlockScoped =
628
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
629
+ addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
630
+ initializer: node.initializer,
631
+ mutable: !isConstBinding,
632
+ });
633
+ } else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
634
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
635
+ } else if (ts.isFunctionExpression(node) && node.name !== undefined) {
636
+ addBinding(node, node.name, { mutable: false });
637
+ } else if (ts.isClassDeclaration(node) && node.name !== undefined) {
638
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
639
+ } else if (ts.isClassExpression(node) && node.name !== undefined) {
640
+ addBinding(node, node.name, { mutable: false });
641
+ } else if (ts.isParameter(node)) {
642
+ addBindingName(node.name, enclosingScope(node, true), { mutable: true });
643
+ } else if (ts.isImportClause(node) && node.name !== undefined) {
644
+ addBinding(sourceFile, node.name, { mutable: false });
645
+ } else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
646
+ addBinding(sourceFile, node.name, { mutable: false });
647
+ }
648
+ ts.forEachChild(node, visit);
649
+ };
650
+ visit(sourceFile);
651
+
652
+ const markReassigned = (identifier: ts.Identifier): void => {
653
+ for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
654
+ binding.reassigned = true;
655
+ }
656
+ };
657
+ const markAssignmentTarget = (node: ts.Node): void => {
658
+ const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
659
+ if (ts.isIdentifier(target)) {
660
+ markReassigned(target);
661
+ return;
662
+ }
663
+ if (ts.isObjectLiteralExpression(target)) {
664
+ for (const element of target.properties) {
665
+ if (ts.isSpreadAssignment(element)) {
666
+ markAssignmentTarget(element.expression);
667
+ } else if (ts.isPropertyAssignment(element)) {
668
+ markAssignmentTarget(element.initializer);
669
+ } else if (ts.isShorthandPropertyAssignment(element)) {
670
+ markAssignmentTarget(element.name);
671
+ }
672
+ }
673
+ } else if (ts.isArrayLiteralExpression(target)) {
674
+ for (const element of target.elements) {
675
+ if (!ts.isOmittedExpression(element)) {
676
+ markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
677
+ }
678
+ }
679
+ }
680
+ };
681
+ const visitAssignments = (node: ts.Node): void => {
682
+ if (
683
+ ts.isBinaryExpression(node) &&
684
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
685
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
686
+ ) {
687
+ markAssignmentTarget(node.left);
688
+ } else if (
689
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
690
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
691
+ node.operator === ts.SyntaxKind.MinusMinusToken)
692
+ ) {
693
+ markAssignmentTarget(node.operand);
694
+ } else if (
695
+ (ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
696
+ !ts.isVariableDeclarationList(node.initializer)
697
+ ) {
698
+ markAssignmentTarget(node.initializer);
699
+ }
700
+ ts.forEachChild(node, visitAssignments);
701
+ };
702
+ visitAssignments(sourceFile);
703
+ return bindings;
704
+ }
705
+
706
+ function lookupLocalBindings(
707
+ reference: ts.Identifier,
708
+ bindings: LocalBindings,
709
+ ): readonly LocalBinding[] | undefined {
710
+ let current: ts.Node | undefined = reference;
711
+ while (current !== undefined) {
712
+ const localBindings = bindings.scopes.get(current)?.get(reference.text);
713
+ if (localBindings !== undefined && localBindings.length > 0) {
714
+ return localBindings;
715
+ }
716
+ current = current.parent;
717
+ }
718
+ return undefined;
719
+ }
720
+
721
+ function propertyNameText(name: ts.PropertyName | ts.BindingName): string | undefined {
722
+ if (
723
+ ts.isIdentifier(name) ||
724
+ ts.isStringLiteral(name) ||
725
+ ts.isNumericLiteral(name) ||
726
+ ts.isNoSubstitutionTemplateLiteral(name)
727
+ ) {
728
+ return name.text;
729
+ }
730
+ if (ts.isComputedPropertyName(name)) {
731
+ const expression = unwrapLocalExpression(name.expression);
732
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
733
+ return expression.text;
734
+ }
735
+ }
736
+ return undefined;
737
+ }
738
+
739
+ function isUnshadowedGlobalObject(reference: ts.Expression, bindings: LocalBindings): boolean {
740
+ const receiver = unwrapLocalExpression(reference);
741
+ return (
742
+ ts.isIdentifier(receiver) &&
743
+ DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
744
+ lookupLocalBindings(receiver, bindings) === undefined
745
+ );
746
+ }
747
+
748
+ function isGlobalMemberSink(
749
+ expression: ts.Expression,
750
+ sinkNames: ReadonlySet<string>,
751
+ bindings: LocalBindings,
752
+ ): boolean {
753
+ const callee = unwrapLocalExpression(expression);
754
+ let sinkName: string | undefined;
755
+ let receiver: ts.Expression | undefined;
756
+ if (ts.isPropertyAccessExpression(callee)) {
757
+ sinkName = callee.name.text;
758
+ receiver = callee.expression;
759
+ } else if (ts.isElementAccessExpression(callee)) {
760
+ const argument = callee.argumentExpression;
761
+ if (
762
+ argument !== undefined &&
763
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
764
+ ) {
765
+ sinkName = argument.text;
766
+ receiver = callee.expression;
767
+ }
768
+ }
769
+ if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
770
+ return false;
771
+ }
772
+ return isUnshadowedGlobalObject(receiver, bindings);
773
+ }
774
+
775
+ function isGlobalSinkReference(
776
+ expression: ts.Expression,
777
+ sinkNames: ReadonlySet<string>,
778
+ bindings: LocalBindings,
779
+ resolving: ReadonlySet<LocalBinding>,
780
+ ): boolean {
781
+ const reference = unwrapLocalExpression(expression);
782
+ if (isGlobalMemberSink(reference, sinkNames, bindings)) {
783
+ return true;
784
+ }
785
+ if (!ts.isIdentifier(reference)) {
786
+ return false;
787
+ }
788
+ return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
789
+ }
790
+
791
+ function isGlobalSinkIdentifier(
792
+ reference: ts.Identifier,
793
+ sinkNames: ReadonlySet<string>,
794
+ bindings: LocalBindings,
795
+ resolving: ReadonlySet<LocalBinding>,
796
+ ): boolean {
797
+ const localBindings = lookupLocalBindings(reference, bindings);
798
+ if (localBindings === undefined || localBindings.length === 0) {
799
+ return sinkNames.has(reference.text);
800
+ }
801
+ return localBindings.every((binding) => {
802
+ if (resolving.has(binding) || binding.initializer === undefined) {
803
+ return false;
804
+ }
805
+ // A mutable alias is safe to resolve only when no assignment targeting
806
+ // this lexical binding exists in its scope (including nested closures).
807
+ // Reassigned let/var aliases remain unresolved to avoid false positives.
808
+ if (binding.mutable && binding.reassigned) {
809
+ return false;
810
+ }
811
+ const nextResolving = new Set(resolving);
812
+ nextResolving.add(binding);
813
+ if (binding.destructuredProperty !== undefined) {
814
+ return (
815
+ sinkNames.has(binding.destructuredProperty) &&
816
+ isUnshadowedGlobalObject(binding.initializer, bindings)
817
+ );
818
+ }
819
+ return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
820
+ });
821
+ }
822
+
823
+ function isGlobalSinkCallee(
824
+ expression: ts.Expression,
825
+ sinkNames: ReadonlySet<string>,
826
+ bindings: LocalBindings,
827
+ ): boolean {
828
+ const callee = unwrapLocalExpression(expression);
829
+ if (isGlobalMemberSink(callee, sinkNames, bindings)) {
830
+ return true;
831
+ }
832
+ if (!ts.isIdentifier(callee)) {
833
+ return false;
834
+ }
835
+ return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
836
+ }
837
+
838
+ function resolveConstStringIdentifier(
839
+ reference: ts.Identifier,
840
+ bindings: LocalBindings,
841
+ resolving: ReadonlySet<LocalBinding> = new Set(),
842
+ ): string | undefined {
843
+ const localBindings = lookupLocalBindings(reference, bindings);
844
+ if (localBindings === undefined || localBindings.length === 0) {
845
+ return undefined;
846
+ }
847
+ let resolved: string | undefined;
848
+ for (const binding of localBindings) {
849
+ if (
850
+ resolving.has(binding) ||
851
+ binding.mutable ||
852
+ binding.initializer === undefined ||
853
+ binding.destructuredProperty !== undefined
854
+ ) {
855
+ return undefined;
856
+ }
857
+ const nextResolving = new Set(resolving);
858
+ nextResolving.add(binding);
859
+ const initializer = unwrapLocalExpression(binding.initializer);
860
+ let value: string | undefined;
861
+ if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
862
+ value = initializer.text;
863
+ } else if (ts.isIdentifier(initializer)) {
864
+ value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
865
+ }
866
+ if (value === undefined || (resolved !== undefined && resolved !== value)) {
867
+ return undefined;
868
+ }
869
+ resolved = value;
870
+ }
871
+ return resolved;
872
+ }
873
+
874
+ function findGlobalSinkCalls(
875
+ providerRoot: string,
876
+ sinkNames: ReadonlySet<string>,
877
+ includeConstructors = false,
878
+ ): SourceFinding[] {
879
+ const findings: SourceFinding[] = [];
880
+ const seen = new Set<string>();
881
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
882
+ const source = readFileSync(filePath, "utf8");
883
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
884
+ const sourceFile = ts.createSourceFile(
885
+ relPath,
886
+ source,
887
+ ts.ScriptTarget.Latest,
888
+ true,
889
+ ts.ScriptKind.TS,
890
+ );
891
+ const bindings = collectLocalBindings(sourceFile);
892
+ const visit = (node: ts.Node): void => {
893
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
894
+ return;
895
+ }
896
+ if (
897
+ (ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
898
+ isGlobalSinkCallee(node.expression, sinkNames, bindings)
899
+ ) {
900
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
901
+ const key = `${relPath}:${line}`;
902
+ if (!seen.has(key)) {
903
+ seen.add(key);
904
+ findings.push({ file: relPath, line });
905
+ }
906
+ }
907
+ ts.forEachChild(node, visit);
908
+ };
909
+ visit(sourceFile);
910
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
911
+ break;
912
+ }
913
+ }
914
+ return findings;
915
+ }
916
+
917
+ function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
918
+ const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
919
+ if (findings.length > 0) {
920
+ return blocker(
921
+ "no-dynamic-code",
922
+ SDK_NATIVE_CATEGORY,
923
+ "Dynamic code evaluation is not permitted in provider source.",
924
+ "Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
925
+ 0,
926
+ formatSourceFindings(findings),
927
+ );
928
+ }
929
+
930
+ return pass(
931
+ "no-dynamic-code",
932
+ SDK_NATIVE_CATEGORY,
933
+ "Provider source avoids dynamic code evaluation.",
934
+ 0,
935
+ );
936
+ }
937
+
466
938
  const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
467
939
  /\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
468
940
  ];
@@ -518,7 +990,11 @@ function countAsAssertions(providerRoot: string): {
518
990
 
519
991
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
520
992
  const content = readFileSync(filePath, "utf8");
521
- const lines = content.split(/\r?\n/);
993
+ const lines = maskCommentsAndStrings(
994
+ content,
995
+ toRelativeProviderPath(providerRoot, filePath),
996
+ { blankPropertyKeys: true },
997
+ ).split(/\r?\n/);
522
998
  for (let index = 0; index < lines.length; index += 1) {
523
999
  const line = lines[index];
524
1000
  if (
@@ -709,18 +1185,44 @@ function offsetToLine(source: string, offset: number): number {
709
1185
  // between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
710
1186
  const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
711
1187
 
1188
+ type ExpressionSlice = {
1189
+ raw: string;
1190
+ masked: string;
1191
+ };
1192
+
1193
+ function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
1194
+ const start = expr.raw.length - expr.raw.trimStart().length;
1195
+ const end = expr.raw.trimEnd().length;
1196
+ return {
1197
+ raw: expr.raw.slice(start, end),
1198
+ masked: expr.masked.slice(start, end),
1199
+ };
1200
+ }
1201
+
1202
+ function balancedExpressionSlice(
1203
+ source: string,
1204
+ valueStart: number,
1205
+ fileName?: string,
1206
+ ): ExpressionSlice {
1207
+ const raw = balancedValueExpression(source, valueStart, fileName);
1208
+ return trimExpressionSlice({
1209
+ raw,
1210
+ masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
1211
+ });
1212
+ }
1213
+
712
1214
  // Strips redundant wrapping parentheses from an expression so that a value like
713
1215
  // `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
714
1216
  // Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
715
1217
  // the whole expression is parenthesized), preserving call expressions such as
716
1218
  // `makeOperations()` whose first `(` is not a wrapper.
717
- function unwrapParens(expr: string): string {
718
- let value = expr.trim();
719
- while (value.startsWith("(")) {
1219
+ function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
1220
+ let value = trimExpressionSlice(expr);
1221
+ while (value.raw.startsWith("(")) {
720
1222
  let depth = 0;
721
1223
  let matchIndex = -1;
722
- for (let i = 0; i < value.length; i += 1) {
723
- const ch = value[i];
1224
+ for (let i = 0; i < value.raw.length; i += 1) {
1225
+ const ch = value.masked[i];
724
1226
  if (ch === "(") {
725
1227
  depth += 1;
726
1228
  } else if (ch === ")") {
@@ -733,8 +1235,11 @@ function unwrapParens(expr: string): string {
733
1235
  }
734
1236
  // Only a true wrapper spans the entire expression (closing paren is the
735
1237
  // last char). Otherwise the leading `(` belongs to a sub-expression.
736
- if (matchIndex === value.length - 1) {
737
- value = value.slice(1, -1).trim();
1238
+ if (matchIndex === value.raw.length - 1) {
1239
+ value = trimExpressionSlice({
1240
+ raw: value.raw.slice(1, -1),
1241
+ masked: value.masked.slice(1, -1),
1242
+ });
738
1243
  } else {
739
1244
  break;
740
1245
  }
@@ -746,8 +1251,8 @@ function unwrapParens(expr: string): string {
746
1251
  // across (){}[] and stopping at the first top-level `,`/`;` or unmatched
747
1252
  // closing bracket. This lets a property value be read across newlines, so a
748
1253
  // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
749
- function balancedValueExpression(source: string, valueStart: number): string {
750
- const masked = maskCommentsAndStrings(source);
1254
+ function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
1255
+ const masked = maskCommentsAndStrings(source, fileName);
751
1256
  let depth = 0;
752
1257
  let index = valueStart;
753
1258
  for (; index < source.length; index += 1) {
@@ -771,14 +1276,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
771
1276
  // nested deeper than the outer object (inside handler bodies, nested objects,
772
1277
  // or arrays) are ignored, so only a factory composition of the object itself
773
1278
  // is detected. Input is expected to start at the outer `{`.
774
- function hasTopLevelFactorySpread(expr: string): boolean {
775
- const open = expr.indexOf("{");
1279
+ function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
1280
+ const open = expr.masked.indexOf("{");
776
1281
  if (open === -1) {
777
1282
  return false;
778
1283
  }
779
1284
  let depth = 0;
780
- for (let i = open; i < expr.length; i += 1) {
781
- const ch = expr[i];
1285
+ for (let i = open; i < expr.raw.length; i += 1) {
1286
+ const ch = expr.masked[i];
782
1287
  if (ch === "{" || ch === "(" || ch === "[") {
783
1288
  depth += 1;
784
1289
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -786,11 +1291,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
786
1291
  if (depth === 0) {
787
1292
  break;
788
1293
  }
789
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
1294
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
790
1295
  // A spread at the object's own level. Check whether the spread
791
1296
  // argument is a call expression (factory) rather than a plain
792
1297
  // identifier/member spread of an already-built object.
793
- const rest = expr.slice(i + 3);
1298
+ const rest = expr.raw.slice(i + 3);
794
1299
  if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
795
1300
  return true;
796
1301
  }
@@ -805,15 +1310,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
805
1310
  // hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
806
1311
  // resolved to their declarations: `const hidden = makeOperations()` spread as
807
1312
  // `{ ...hidden }` is still a factory-composed map and must block.
808
- function topLevelSpreadIdentifiers(expr: string): string[] {
809
- const open = expr.indexOf("{");
1313
+ function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
1314
+ const open = expr.masked.indexOf("{");
810
1315
  if (open === -1) {
811
1316
  return [];
812
1317
  }
813
1318
  const names: string[] = [];
814
1319
  let depth = 0;
815
- for (let i = open; i < expr.length; i += 1) {
816
- const ch = expr[i];
1320
+ for (let i = open; i < expr.raw.length; i += 1) {
1321
+ const ch = expr.masked[i];
817
1322
  if (ch === "{" || ch === "(" || ch === "[") {
818
1323
  depth += 1;
819
1324
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -821,8 +1326,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
821
1326
  if (depth === 0) {
822
1327
  break;
823
1328
  }
824
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
825
- const rest = expr.slice(i + 3);
1329
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
1330
+ const rest = expr.raw.slice(i + 3);
826
1331
  // Bare identifier spread (no call parens) -> needs declaration
827
1332
  // resolution. `...obj.prop` member spreads are treated as already
828
1333
  // built and ignored (the leading identifier is captured).
@@ -855,8 +1360,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
855
1360
  // `makeOperations()` — stays classified as factory composition.
856
1361
  const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
857
1362
  const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
858
- function isTransparentObjectReshape(expr: string): boolean {
859
- const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
1363
+ function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
1364
+ const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
860
1365
  if (!head) {
861
1366
  return false;
862
1367
  }
@@ -864,7 +1369,7 @@ function isTransparentObjectReshape(expr: string): boolean {
864
1369
  // transparent only when that argument's root callee is `Object.entries(`
865
1370
  // (optionally chained: `Object.entries(obj).filter(...)`), so the source
866
1371
  // object is enumerable from source rather than produced by an opaque call.
867
- const firstArg = expr.slice(head[0].length).trimStart();
1372
+ const firstArg = expr.masked.slice(head[0].length).trimStart();
868
1373
  return OBJECT_ENTRIES_HEAD.test(firstArg);
869
1374
  }
870
1375
 
@@ -998,7 +1503,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
998
1503
  continue;
999
1504
  }
1000
1505
  const valueStart = match.index + match[0].length;
1001
- const value = balancedValueExpression(source, valueStart);
1506
+ const value = balancedValueExpression(source, valueStart, relPath);
1002
1507
  if (PASSTHROUGH_CALL.test(value)) {
1003
1508
  const site: ConstSite = {
1004
1509
  file: relPath,
@@ -1027,8 +1532,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1027
1532
  // that is itself a passthrough expression, or that references a passthrough
1028
1533
  // const by name (resolved against the provider-wide map), is a violation.
1029
1534
  for (const filePath of files) {
1030
- const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1031
1535
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1536
+ const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1032
1537
 
1033
1538
  const inputProp = /\binput\s*:\s*/g;
1034
1539
  for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
@@ -1039,7 +1544,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1039
1544
  continue;
1040
1545
  }
1041
1546
  const valueStart = match.index + match[0].length;
1042
- const value = balancedValueExpression(source, valueStart);
1547
+ const value = balancedValueExpression(source, valueStart, relPath);
1043
1548
  if (PASSTHROUGH_CALL.test(value)) {
1044
1549
  push({ file: relPath, line: offsetToLine(source, valueStart) });
1045
1550
  continue;
@@ -1150,13 +1655,19 @@ function spreadIdentifierResolvesToFactory(
1150
1655
  if (!existsSync(filePath)) {
1151
1656
  continue;
1152
1657
  }
1658
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
1153
1659
  const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1660
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
1661
+ blankPropertyKeys: true,
1662
+ });
1154
1663
  const re = new RegExp(declRe.source, "g");
1155
- for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1664
+ for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
1156
1665
  sawDeclaration = true;
1157
- const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1666
+ const expr = unwrapParens(
1667
+ balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
1668
+ );
1158
1669
  const isFactory =
1159
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1670
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1160
1671
  !isTransparentObjectReshape(expr);
1161
1672
  if (isFactory) {
1162
1673
  return true;
@@ -1165,12 +1676,232 @@ function spreadIdentifierResolvesToFactory(
1165
1676
  }
1166
1677
  // No local declaration anywhere but imported into index.ts => constructed
1167
1678
  // out of view; treat as factory (conservative, false-negative-safe).
1168
- if (!sawDeclaration && fileImportsBinding(indexSource, name)) {
1679
+ if (
1680
+ !sawDeclaration &&
1681
+ fileImportsBinding(
1682
+ maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
1683
+ name,
1684
+ )
1685
+ ) {
1169
1686
  return true;
1170
1687
  }
1171
1688
  return false;
1172
1689
  }
1173
1690
 
1691
+ function isDefineProviderCall(node: ts.CallExpression): boolean {
1692
+ return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
1693
+ }
1694
+
1695
+ function resolveDefineProviderImplementationCall(
1696
+ candidate: ts.CallExpression,
1697
+ ): ts.CallExpression | undefined {
1698
+ let root = candidate;
1699
+ while (ts.isCallExpression(root.expression)) {
1700
+ root = root.expression;
1701
+ }
1702
+ return isDefineProviderCall(root) ? candidate : undefined;
1703
+ }
1704
+
1705
+ function findProviderImplementationCall(sourceFile: ts.SourceFile): ts.CallExpression | undefined {
1706
+ const defaultExports = sourceFile.statements.filter(
1707
+ (statement): statement is ts.ExportAssignment =>
1708
+ ts.isExportAssignment(statement) && !statement.isExportEquals,
1709
+ );
1710
+
1711
+ // Phase-separated providers export a builder invocation directly. Preserve
1712
+ // the previous locator's preference for that call over every defineProvider
1713
+ // declaration that may also appear in the file.
1714
+ for (const assignment of defaultExports) {
1715
+ const expression = unwrapImplementationExpression(assignment.expression);
1716
+ if (!ts.isCallExpression(expression)) {
1717
+ continue;
1718
+ }
1719
+ if (resolveDefineProviderImplementationCall(expression) !== undefined) {
1720
+ continue;
1721
+ }
1722
+ if (ts.isIdentifier(expression.expression)) {
1723
+ return expression;
1724
+ }
1725
+ }
1726
+
1727
+ // For `defineProvider(metadata)(implementation)`, select the outer call whose
1728
+ // argument is the implementation. A plain `defineProvider(implementation)`
1729
+ // selects the single call itself.
1730
+ for (const assignment of defaultExports) {
1731
+ const defaultExpression = unwrapImplementationExpression(assignment.expression);
1732
+ if (!ts.isCallExpression(defaultExpression)) {
1733
+ continue;
1734
+ }
1735
+ const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
1736
+ if (implementationCall !== undefined) {
1737
+ return implementationCall;
1738
+ }
1739
+ }
1740
+
1741
+ // Resolve `export default provider` to a top-level variable initialized by
1742
+ // defineProvider, including its curried implementation call when present.
1743
+ for (const assignment of defaultExports) {
1744
+ if (!ts.isIdentifier(assignment.expression)) {
1745
+ continue;
1746
+ }
1747
+ const exportedName = assignment.expression.text;
1748
+ for (const statement of sourceFile.statements) {
1749
+ if (!ts.isVariableStatement(statement)) {
1750
+ continue;
1751
+ }
1752
+ for (const declaration of statement.declarationList.declarations) {
1753
+ if (
1754
+ !ts.isIdentifier(declaration.name) ||
1755
+ declaration.name.text !== exportedName ||
1756
+ declaration.initializer === undefined
1757
+ ) {
1758
+ continue;
1759
+ }
1760
+ if (!ts.isCallExpression(declaration.initializer)) {
1761
+ continue;
1762
+ }
1763
+ const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
1764
+ if (implementationCall !== undefined) {
1765
+ return implementationCall;
1766
+ }
1767
+ }
1768
+ }
1769
+ }
1770
+
1771
+ // Structural fixtures without a recognizable default export retain the
1772
+ // previous fallback to the first defineProvider call in source order.
1773
+ let firstCall: ts.CallExpression | undefined;
1774
+ const visit = (node: ts.Node): void => {
1775
+ if (firstCall !== undefined) {
1776
+ return;
1777
+ }
1778
+ if (ts.isCallExpression(node)) {
1779
+ const implementationCall = resolveDefineProviderImplementationCall(node);
1780
+ if (implementationCall !== undefined) {
1781
+ firstCall = implementationCall;
1782
+ return;
1783
+ }
1784
+ }
1785
+ ts.forEachChild(node, visit);
1786
+ };
1787
+ visit(sourceFile);
1788
+ return firstCall;
1789
+ }
1790
+
1791
+ function unwrapImplementationExpression(expression: ts.Expression): ts.Expression {
1792
+ let current = expression;
1793
+ while (
1794
+ ts.isSatisfiesExpression(current) ||
1795
+ ts.isAsExpression(current) ||
1796
+ ts.isParenthesizedExpression(current) ||
1797
+ ts.isNonNullExpression(current)
1798
+ ) {
1799
+ current = current.expression;
1800
+ }
1801
+ return current;
1802
+ }
1803
+
1804
+ function isOperationsProperty(
1805
+ property: ts.ObjectLiteralElementLike,
1806
+ ): property is ts.PropertyAssignment | ts.ShorthandPropertyAssignment {
1807
+ if (ts.isShorthandPropertyAssignment(property)) {
1808
+ return property.name.text === "operations";
1809
+ }
1810
+ if (!ts.isPropertyAssignment(property)) {
1811
+ return false;
1812
+ }
1813
+ return (
1814
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1815
+ property.name.text === "operations"
1816
+ );
1817
+ }
1818
+
1819
+ function isOperationsAccessorOrMethod(
1820
+ property: ts.ObjectLiteralElementLike,
1821
+ ): property is ts.GetAccessorDeclaration | ts.SetAccessorDeclaration | ts.MethodDeclaration {
1822
+ if (
1823
+ !ts.isGetAccessorDeclaration(property) &&
1824
+ !ts.isSetAccessorDeclaration(property) &&
1825
+ !ts.isMethodDeclaration(property)
1826
+ ) {
1827
+ return false;
1828
+ }
1829
+ return (
1830
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1831
+ property.name.text === "operations"
1832
+ );
1833
+ }
1834
+
1835
+ function isSourceEnumerableStaticObjectLiteral(expression: ts.Expression): boolean {
1836
+ const unwrapped = unwrapImplementationExpression(expression);
1837
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
1838
+ return false;
1839
+ }
1840
+ return unwrapped.properties.every((property) => {
1841
+ if (ts.isSpreadAssignment(property)) {
1842
+ return isSourceEnumerableStaticObjectLiteral(property.expression);
1843
+ }
1844
+ if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
1845
+ return property.name === undefined;
1846
+ }
1847
+ return property.name.text !== "operations" || isOperationsProperty(property);
1848
+ });
1849
+ }
1850
+
1851
+ function findComputedImplementationProperty(
1852
+ objectLiteral: ts.ObjectLiteralExpression,
1853
+ ): ts.ObjectLiteralElementLike | undefined {
1854
+ for (const property of objectLiteral.properties) {
1855
+ if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
1856
+ return property;
1857
+ }
1858
+ if (ts.isSpreadAssignment(property)) {
1859
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1860
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1861
+ const nested = findComputedImplementationProperty(spreadExpression);
1862
+ if (nested !== undefined) {
1863
+ return nested;
1864
+ }
1865
+ }
1866
+ }
1867
+ }
1868
+ return undefined;
1869
+ }
1870
+
1871
+ function findEffectiveOperationsProperty(
1872
+ objectLiteral: ts.ObjectLiteralExpression,
1873
+ ):
1874
+ | ts.PropertyAssignment
1875
+ | ts.ShorthandPropertyAssignment
1876
+ | ts.GetAccessorDeclaration
1877
+ | ts.SetAccessorDeclaration
1878
+ | ts.MethodDeclaration
1879
+ | undefined {
1880
+ let effective:
1881
+ | ts.PropertyAssignment
1882
+ | ts.ShorthandPropertyAssignment
1883
+ | ts.GetAccessorDeclaration
1884
+ | ts.SetAccessorDeclaration
1885
+ | ts.MethodDeclaration
1886
+ | undefined;
1887
+ for (const property of objectLiteral.properties) {
1888
+ if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
1889
+ effective = property;
1890
+ continue;
1891
+ }
1892
+ if (ts.isSpreadAssignment(property)) {
1893
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1894
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1895
+ const nested = findEffectiveOperationsProperty(spreadExpression);
1896
+ if (nested !== undefined) {
1897
+ effective = nested;
1898
+ }
1899
+ }
1900
+ }
1901
+ }
1902
+ return effective;
1903
+ }
1904
+
1174
1905
  function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1175
1906
  const indexPath = resolve(providerRoot, "index.ts");
1176
1907
  const ruleId = "flat-operation-composition";
@@ -1184,10 +1915,23 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1184
1915
  }
1185
1916
 
1186
1917
  const source = readFileSync(indexPath, "utf8");
1187
- // Use the same whitespace-tolerant detection as the resolver below, so a
1188
- // `defineProvider (` / `defineProvider\n(` formatting cannot pass the early
1189
- // exit before the real classification runs.
1190
- if (!/\bdefineProvider\s*\(/.test(source)) {
1918
+ const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
1919
+ // The report preflight already rejects unparseable index.ts sources before
1920
+ // this rule runs, so this local AST parse only replaces property location.
1921
+ const sourceFile = ts.createSourceFile(
1922
+ indexRelPath,
1923
+ source,
1924
+ ts.ScriptTarget.Latest,
1925
+ true,
1926
+ ts.ScriptKind.TS,
1927
+ );
1928
+ const implementationCall = findProviderImplementationCall(sourceFile);
1929
+ const rawImplementationArg = implementationCall?.arguments[0];
1930
+ const implementationArg =
1931
+ rawImplementationArg === undefined
1932
+ ? undefined
1933
+ : unwrapImplementationExpression(rawImplementationArg);
1934
+ if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
1191
1935
  return pass(
1192
1936
  ruleId,
1193
1937
  SDK_NATIVE_CATEGORY,
@@ -1196,82 +1940,78 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1196
1940
  );
1197
1941
  }
1198
1942
 
1199
- // Scope the scan to the exported provider builder's implementation argument,
1200
- // or to the legacy-looking declaration call used by structural test fixtures.
1201
- // A provider can contain helper/non-exported defineProvider calls before the
1202
- // real default export (e.g. test scaffolds), so resolve the default export
1203
- // rather than blindly taking the first regex match. Resolution order:
1204
- // 1. `export default <builder>(` — phase-separated provider
1205
- // 2. `export default defineProvider(` structural fixture
1206
- // 3. `export default <ident>` then `const <ident> = defineProvider(`
1207
- // 4. fallback: first `defineProvider(` in the file
1208
- let defineParenIndex = -1;
1209
- const builderDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*\(/.exec(source);
1210
- if (builderDefault?.[1] !== undefined && builderDefault[1] !== "defineProvider") {
1211
- defineParenIndex = builderDefault.index + builderDefault[0].length - 1;
1212
- }
1213
- const inlineDefault = /\bexport\s+default\s+defineProvider\s*\(/.exec(source);
1214
- if (defineParenIndex === -1 && inlineDefault) {
1215
- const declarationParen = inlineDefault.index + inlineDefault[0].length - 1;
1216
- const declarationStart = declarationParen + 1;
1217
- const declaration = balancedValueExpression(source, declarationStart);
1218
- let cursor = declarationStart + declaration.length;
1219
- while (/\s/.test(source[cursor] ?? "")) cursor++;
1220
- if (source[cursor] === ")") cursor++;
1221
- while (/\s/.test(source[cursor] ?? "")) cursor++;
1222
- defineParenIndex = source[cursor] === "(" ? cursor : declarationParen;
1223
- } else if (defineParenIndex === -1) {
1224
- const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1225
- const exportedName = namedDefault?.[1];
1226
- if (exportedName !== undefined) {
1227
- const namedDecl = new RegExp(
1228
- `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
1229
- ).exec(source);
1230
- if (namedDecl) {
1231
- defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
1232
- }
1233
- }
1234
- if (defineParenIndex === -1) {
1235
- const firstCall = /\bdefineProvider\s*\(/.exec(source);
1236
- if (firstCall) {
1237
- defineParenIndex = firstCall.index + firstCall[0].length - 1;
1238
- }
1239
- }
1943
+ // Resolve the top-level operations property from the AST, then feed its raw
1944
+ // initializer offset into the existing expression classifier and alias
1945
+ // resolver unchanged.
1946
+ const opsProp = findEffectiveOperationsProperty(implementationArg);
1947
+ let opsValue: ExpressionSlice | undefined;
1948
+ let opsLine = 1;
1949
+ if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
1950
+ const valueStart = opsProp.initializer.getStart(sourceFile);
1951
+ opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
1952
+ opsLine = offsetToLine(source, valueStart);
1240
1953
  }
1241
- if (defineParenIndex === -1) {
1242
- return pass(
1954
+
1955
+ // Property shorthand: `buildProvider({ operations })` — resolve the
1956
+ // local `operations` const initializer.
1957
+ let aliasName: string | undefined;
1958
+ if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
1959
+ aliasName = "operations";
1960
+ } else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
1961
+ // `operations: ops` — a bare identifier alias to resolve.
1962
+ aliasName = opsValue.raw;
1963
+ }
1964
+
1965
+ // A computed property might resolve to `operations`, but its composition is
1966
+ // not source-enumerable here. Fail closed, like an unresolved imported alias.
1967
+ const computed = findComputedImplementationProperty(implementationArg);
1968
+ if (computed !== undefined) {
1969
+ const computedLine = offsetToLine(source, computed.getStart(sourceFile));
1970
+ return blocker(
1243
1971
  ruleId,
1244
1972
  SDK_NATIVE_CATEGORY,
1245
- "No defineProvider call to evaluate for operation composition.",
1973
+ "The defineProvider operations property uses a computed property name that cannot be statically resolved.",
1974
+ "Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
1246
1975
  0,
1976
+ [`${indexRelPath}:${computedLine} (computed property name)`],
1247
1977
  );
1248
1978
  }
1249
- const argStart = defineParenIndex + 1;
1250
- const argText = balancedValueExpression(source, argStart);
1251
1979
 
1252
- // Resolve the value passed as `operations:` inside the implementation call,
1253
- // following one alias hop. The value is classified as a static object
1254
- // literal (pass) or a factory/call expression (block). The regex index is
1255
- // offset back into the full source so line numbers stay accurate.
1256
- const opsProp = /\boperations\s*:\s*/.exec(argText);
1257
- let opsValue: string | undefined;
1258
- let opsLine = 1;
1259
- if (opsProp) {
1260
- const valueStart = argStart + opsProp.index + opsProp[0].length;
1261
- opsValue = unwrapParens(balancedValueExpression(source, valueStart).trim());
1262
- opsLine = offsetToLine(source, valueStart);
1980
+ if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
1981
+ const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
1982
+ return blocker(
1983
+ ruleId,
1984
+ SDK_NATIVE_CATEGORY,
1985
+ "The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
1986
+ "Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
1987
+ 0,
1988
+ [`${indexRelPath}:${memberLine} (operations accessor/method)`],
1989
+ );
1263
1990
  }
1264
1991
 
1265
- // Property shorthand: `buildProvider({ operations })` resolve the
1266
- // local `operations` const initializer.
1267
- let aliasName: string | undefined;
1268
- if (opsValue === undefined) {
1269
- if (/\boperations\s*[,}]/.test(argText)) {
1270
- aliasName = "operations";
1271
- }
1272
- } else if (/^[A-Za-z_$][\w$]*$/.test(opsValue)) {
1273
- // `operations: ops` — a bare identifier alias to resolve.
1274
- aliasName = opsValue;
1992
+ // A spread can override a preceding literal `operations` property. Only an
1993
+ // inline static object literal (including recursively inline static spreads)
1994
+ // exposes all of its property names to this source-only gate. Identifiers,
1995
+ // calls, and other expressions are opaque and therefore fail closed.
1996
+ const opaqueSpread = implementationArg.properties.find(
1997
+ (property): property is ts.SpreadAssignment =>
1998
+ ts.isSpreadAssignment(property) &&
1999
+ !isSourceEnumerableStaticObjectLiteral(property.expression),
2000
+ );
2001
+ if (opaqueSpread !== undefined) {
2002
+ const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
2003
+ return escapeHatchResult(
2004
+ providerRoot,
2005
+ ruleId,
2006
+ [{ file: indexRelPath, line: spreadLine }],
2007
+ {
2008
+ blockerMessage:
2009
+ "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2010
+ remediation:
2011
+ "Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
2012
+ passMessage: "defineProvider declares operations as a static object literal.",
2013
+ },
2014
+ );
1275
2015
  }
1276
2016
 
1277
2017
  // Determine the effective initializer expression to classify. The alias may
@@ -1305,7 +2045,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1305
2045
  // do not resolve the exact import target path; "any same-named factory
1306
2046
  // blocks" is the conservative, false-negative-avoiding choice for a gate.)
1307
2047
  type Candidate = {
1308
- expr: string;
2048
+ expr: ExpressionSlice;
1309
2049
  line: number;
1310
2050
  file: string;
1311
2051
  isFactory: boolean;
@@ -1315,15 +2055,22 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1315
2055
  if (!existsSync(filePath)) {
1316
2056
  continue;
1317
2057
  }
1318
- const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1319
2058
  const relPath = toRelativeProviderPath(providerRoot, filePath);
2059
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
2060
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
2061
+ blankPropertyKeys: true,
2062
+ });
1320
2063
 
1321
2064
  const declRe = new RegExp(aliasDecl.source, "g");
1322
- for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
2065
+ for (
2066
+ let m = declRe.exec(maskedFileSource);
2067
+ m !== null;
2068
+ m = declRe.exec(maskedFileSource)
2069
+ ) {
1323
2070
  const valueStart = m.index + m[0].length;
1324
- const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
2071
+ const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
1325
2072
  const isFactory =
1326
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
2073
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1327
2074
  !isTransparentObjectReshape(expr);
1328
2075
  candidates.push({
1329
2076
  expr,
@@ -1333,9 +2080,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1333
2080
  });
1334
2081
  }
1335
2082
  const destructRe = new RegExp(destructured.source, "g");
1336
- for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
2083
+ for (
2084
+ let m = destructRe.exec(maskedFileSource);
2085
+ m !== null;
2086
+ m = destructRe.exec(maskedFileSource)
2087
+ ) {
2088
+ const raw = `${m[1]}(`;
1337
2089
  candidates.push({
1338
- expr: `${m[1]}(`,
2090
+ expr: { raw, masked: raw },
1339
2091
  line: offsetToLine(fileSource, m.index),
1340
2092
  file: relPath,
1341
2093
  isFactory: true,
@@ -1361,11 +2113,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1361
2113
  // the unresolved import as a factory-composed (non-static) shape rather
1362
2114
  // than silently passing.
1363
2115
  if (!resolved) {
1364
- const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1365
- source,
1366
- );
2116
+ const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
2117
+ blankPropertyKeys: true,
2118
+ });
2119
+ const importMatch = new RegExp(
2120
+ `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
2121
+ ).exec(maskedIndexSource);
1367
2122
  if (importMatch) {
1368
- effective = `${aliasName}(`;
2123
+ const raw = `${aliasName}(`;
2124
+ effective = { raw, masked: raw };
1369
2125
  effectiveLine = offsetToLine(source, importMatch.index);
1370
2126
  effectiveFile = "index.ts";
1371
2127
  }
@@ -1389,14 +2145,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1389
2145
  spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1390
2146
  );
1391
2147
  const isStaticLiteral =
1392
- effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
2148
+ effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1393
2149
  // A call expression `ident(...)` (factory) or a factory-spread literal is
1394
2150
  // the rejected, non-static shape — UNLESS it is the stdlib
1395
2151
  // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1396
2152
  // whose op set is still enumerable from source (verified golden pattern).
1397
2153
  const isFactoryCall =
1398
2154
  effective !== undefined &&
1399
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
2155
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
2156
+ hasFactorySpread ||
2157
+ hasFactorySpreadIdentifier) &&
1400
2158
  !isTransparentObjectReshape(effective);
1401
2159
 
1402
2160
  if (isFactoryCall && !isStaticLiteral) {
@@ -1422,7 +2180,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1422
2180
  }
1423
2181
 
1424
2182
  function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1425
- const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
2183
+ const credentialReferences = findCredentialReferences(providerRoot);
1426
2184
  const authMode = provider.auth?.mode ?? "none";
1427
2185
  const credentialKeys = provider.credential?.keys ?? [];
1428
2186
  const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
@@ -1440,16 +2198,117 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1440
2198
  "Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
1441
2199
  };
1442
2200
  }
1443
-
1444
- return pass(
1445
- "credential-usage",
1446
- SDK_NATIVE_CATEGORY,
1447
- authMode === "none" && credentialKeys.length === 0
1448
- ? "Provider does not declare reusable credentials."
1449
- : "Credential-backed provider references ctx.credential.",
1450
- 0,
1451
- credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
1452
- );
2201
+
2202
+ return pass(
2203
+ "credential-usage",
2204
+ SDK_NATIVE_CATEGORY,
2205
+ authMode === "none" && credentialKeys.length === 0
2206
+ ? "Provider does not declare reusable credentials."
2207
+ : "Credential-backed provider references ctx.credential.",
2208
+ 0,
2209
+ credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
2210
+ );
2211
+ }
2212
+
2213
+ function isDirectCredentialReference(expression: ts.Expression): boolean {
2214
+ const reference = unwrapLocalExpression(expression);
2215
+ let receiver: ts.Expression | undefined;
2216
+ let memberName: string | undefined;
2217
+ if (ts.isPropertyAccessExpression(reference)) {
2218
+ receiver = reference.expression;
2219
+ memberName = reference.name.text;
2220
+ } else if (ts.isElementAccessExpression(reference)) {
2221
+ receiver = reference.expression;
2222
+ const argument = reference.argumentExpression;
2223
+ if (
2224
+ argument !== undefined &&
2225
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
2226
+ ) {
2227
+ memberName = argument.text;
2228
+ }
2229
+ }
2230
+ const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
2231
+ return (
2232
+ memberName === "credential" &&
2233
+ unwrappedReceiver !== undefined &&
2234
+ ts.isIdentifier(unwrappedReceiver) &&
2235
+ unwrappedReceiver.text === "ctx"
2236
+ );
2237
+ }
2238
+
2239
+ function isCredentialReference(
2240
+ expression: ts.Expression,
2241
+ bindings: LocalBindings,
2242
+ resolving: ReadonlySet<LocalBinding> = new Set(),
2243
+ ): boolean {
2244
+ const reference = unwrapLocalExpression(expression);
2245
+ if (isDirectCredentialReference(reference)) {
2246
+ return true;
2247
+ }
2248
+ if (!ts.isIdentifier(reference)) {
2249
+ return false;
2250
+ }
2251
+ const localBindings = lookupLocalBindings(reference, bindings);
2252
+ if (localBindings === undefined || localBindings.length === 0) {
2253
+ return false;
2254
+ }
2255
+ return localBindings.every((binding) => {
2256
+ if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
2257
+ return false;
2258
+ }
2259
+ const nextResolving = new Set(resolving);
2260
+ nextResolving.add(binding);
2261
+ if (binding.destructuredProperty !== undefined) {
2262
+ const source = unwrapLocalExpression(binding.initializer);
2263
+ return (
2264
+ binding.destructuredProperty === "credential" &&
2265
+ ts.isIdentifier(source) &&
2266
+ source.text === "ctx"
2267
+ );
2268
+ }
2269
+ return isCredentialReference(binding.initializer, bindings, nextResolving);
2270
+ });
2271
+ }
2272
+
2273
+ function findCredentialReferences(providerRoot: string): SourceFinding[] {
2274
+ const findings: SourceFinding[] = [];
2275
+ const seen = new Set<string>();
2276
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2277
+ const source = readFileSync(filePath, "utf8");
2278
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2279
+ const sourceFile = ts.createSourceFile(
2280
+ relPath,
2281
+ source,
2282
+ ts.ScriptTarget.Latest,
2283
+ true,
2284
+ ts.ScriptKind.TS,
2285
+ );
2286
+ const bindings = collectLocalBindings(sourceFile);
2287
+ const visit = (node: ts.Node): void => {
2288
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2289
+ return;
2290
+ }
2291
+ if (
2292
+ ts.isExpression(node) &&
2293
+ (isDirectCredentialReference(node) ||
2294
+ ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
2295
+ isCredentialReference(node.expression, bindings)))
2296
+ ) {
2297
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
2298
+ const key = `${relPath}:${line}`;
2299
+ if (!seen.has(key)) {
2300
+ seen.add(key);
2301
+ findings.push({ file: relPath, line });
2302
+ }
2303
+ }
2304
+ ts.forEachChild(node, visit);
2305
+ };
2306
+ visit(sourceFile);
2307
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2308
+ break;
2309
+ }
2310
+ }
2311
+ return findings;
1453
2312
  }
1454
2313
 
1455
2314
  // ---------------------------------------------------------------------------
@@ -1608,23 +2467,34 @@ function scoreSdkOwnedSecretPresence(
1608
2467
  function findSourceLineMatches(
1609
2468
  providerRoot: string,
1610
2469
  pattern: RegExp | ((line: string) => boolean),
2470
+ useMaskedSource = false,
1611
2471
  ): SourceFinding[] {
1612
- return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
2472
+ return findSourceFindings(
2473
+ providerRoot,
2474
+ (line) => matchesLinePattern(line, pattern),
2475
+ useMaskedSource,
2476
+ );
1613
2477
  }
1614
2478
 
1615
2479
  function findSourceFindings(
1616
2480
  providerRoot: string,
1617
2481
  matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
2482
+ useMaskedSource = false,
1618
2483
  ): SourceFinding[] {
1619
2484
  const findings: SourceFinding[] = [];
1620
2485
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1621
2486
  const content = readFileSync(filePath, "utf8");
1622
- const lines = content.split(/\r?\n/);
2487
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2488
+ const lines = (
2489
+ useMaskedSource
2490
+ ? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
2491
+ : content
2492
+ ).split(/\r?\n/);
1623
2493
  for (let index = 0; index < lines.length; index += 1) {
1624
2494
  const line = lines[index];
1625
2495
  if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
1626
2496
  findings.push({
1627
- file: toRelativeProviderPath(providerRoot, filePath),
2497
+ file: relPath,
1628
2498
  line: index + 1,
1629
2499
  });
1630
2500
  if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
@@ -1740,7 +2610,10 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
1740
2610
  }
1741
2611
 
1742
2612
  function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1743
- return findings.map((finding) => `${finding.file}:${finding.line}`);
2613
+ return findings.map(
2614
+ (finding) =>
2615
+ `${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
2616
+ );
1744
2617
  }
1745
2618
 
1746
2619
  function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
@@ -1836,7 +2709,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
1836
2709
  {
1837
2710
  message: "Base provider checks can run",
1838
2711
  passed: false,
1839
- details: [error instanceof Error ? error.message : String(error)],
2712
+ details: [formatThrownValue(error)],
1840
2713
  },
1841
2714
  ];
1842
2715
  }
@@ -2392,19 +3265,37 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
2392
3265
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2393
3266
  const source = readFileSync(filePath, "utf8");
2394
3267
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3268
+ maskCommentsAndStrings(source, relPath);
3269
+ const sourceFile = ts.createSourceFile(
3270
+ relPath,
3271
+ source,
3272
+ ts.ScriptTarget.Latest,
3273
+ true,
3274
+ ts.ScriptKind.TS,
3275
+ );
3276
+ const bindings = collectLocalBindings(sourceFile);
2395
3277
  const upstreamRanges = findUpstreamMarkedConstRanges(source);
2396
3278
  for (const zObject of findZObjectLiterals(source)) {
2397
3279
  if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
2398
3280
  continue;
2399
3281
  }
2400
- if (!zObjectAppearsPublicOutput(source, zObject)) {
3282
+ if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
2401
3283
  continue;
2402
3284
  }
2403
- for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
3285
+ for (const keyFinding of vendorKeyFindingsForObject(
3286
+ source,
3287
+ sourceFile,
3288
+ zObject,
3289
+ bindings,
3290
+ )) {
2404
3291
  const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
2405
3292
  if (!seen.has(key)) {
2406
3293
  seen.add(key);
2407
- findings.push({ file: relPath, line: keyFinding.line });
3294
+ findings.push({
3295
+ file: relPath,
3296
+ line: keyFinding.line,
3297
+ detail: keyFinding.key,
3298
+ });
2408
3299
  if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2409
3300
  return findings;
2410
3301
  }
@@ -2440,20 +3331,260 @@ function findZObjectLiterals(source: string): ZObjectLiteral[] {
2440
3331
  return literals;
2441
3332
  }
2442
3333
 
2443
- function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
2444
- const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
2445
- if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
3334
+ function zObjectAppearsPublicOutput(
3335
+ sourceFile: ts.SourceFile,
3336
+ zObject: ZObjectLiteral,
3337
+ relPath: string,
3338
+ ): boolean {
3339
+ let zObjectCall: ts.CallExpression | undefined;
3340
+ const findCall = (node: ts.Node): void => {
3341
+ if (
3342
+ zObjectCall === undefined &&
3343
+ ts.isCallExpression(node) &&
3344
+ node.getStart(sourceFile) === zObject.callStart
3345
+ ) {
3346
+ zObjectCall = node;
3347
+ return;
3348
+ }
3349
+ ts.forEachChild(node, findCall);
3350
+ };
3351
+ findCall(sourceFile);
3352
+ if (zObjectCall === undefined) {
3353
+ return true;
3354
+ }
3355
+
3356
+ let expression: ts.Expression = zObjectCall;
3357
+ while (
3358
+ ts.isParenthesizedExpression(expression.parent) ||
3359
+ ts.isAsExpression(expression.parent) ||
3360
+ ts.isSatisfiesExpression(expression.parent) ||
3361
+ ts.isNonNullExpression(expression.parent)
3362
+ ) {
3363
+ expression = expression.parent;
3364
+ }
3365
+ if (
3366
+ ts.isPropertyAssignment(expression.parent) &&
3367
+ isPublicOutputPropertyName(expression.parent.name)
3368
+ ) {
3369
+ return true;
3370
+ }
3371
+
3372
+ const declaration = findEnclosingVariableDeclaration(expression);
3373
+ if (
3374
+ declaration === undefined ||
3375
+ !ts.isIdentifier(declaration.name) ||
3376
+ declaration.initializer === undefined ||
3377
+ !ts.isVariableDeclarationList(declaration.parent) ||
3378
+ (declaration.parent.flags & ts.NodeFlags.Const) === 0
3379
+ ) {
3380
+ return true;
3381
+ }
3382
+
3383
+ const reachability = localSchemaBindingReachability(
3384
+ sourceFile,
3385
+ declaration.name.text,
3386
+ declaration,
3387
+ new Set(),
3388
+ );
3389
+ if (reachability === "public") {
3390
+ return true;
3391
+ }
3392
+ if (reachability === "unknown") {
3393
+ return true;
3394
+ }
3395
+ // Sibling modules are scanned independently and therefore remain
3396
+ // fail-closed. An exported binding in index.ts is likewise consumable by
3397
+ // another module, so local inertness does not prove it is private.
3398
+ if (relPath !== "index.ts") {
3399
+ return true;
3400
+ }
3401
+ return isExportedVariableBinding(sourceFile, declaration);
3402
+ }
3403
+
3404
+ type SchemaReachability = "public" | "internal" | "unknown";
3405
+
3406
+ const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
3407
+ "parse",
3408
+ "safeParse",
3409
+ "parseAsync",
3410
+ "safeParseAsync",
3411
+ ]);
3412
+
3413
+ function isExportedVariableBinding(
3414
+ sourceFile: ts.SourceFile,
3415
+ declaration: ts.VariableDeclaration,
3416
+ ): boolean {
3417
+ const declarationStatement = declaration.parent.parent;
3418
+ if (
3419
+ ts.isVariableStatement(declarationStatement) &&
3420
+ declarationStatement.modifiers?.some(
3421
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
3422
+ ) === true
3423
+ ) {
2446
3424
  return true;
2447
3425
  }
2448
- const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
2449
- return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
3426
+ if (!ts.isIdentifier(declaration.name)) {
3427
+ return false;
3428
+ }
3429
+ const name = declaration.name.text;
3430
+ let exported = false;
3431
+ const visit = (node: ts.Node): void => {
3432
+ if (exported) {
3433
+ return;
3434
+ }
3435
+ if (ts.isExportSpecifier(node)) {
3436
+ const localName = node.propertyName ?? node.name;
3437
+ if (ts.isIdentifier(localName) && localName.text === name) {
3438
+ exported = true;
3439
+ return;
3440
+ }
3441
+ }
3442
+ if (ts.isExportAssignment(node)) {
3443
+ const expression = unwrapLocalExpression(node.expression);
3444
+ if (ts.isIdentifier(expression) && expression.text === name) {
3445
+ exported = true;
3446
+ return;
3447
+ }
3448
+ }
3449
+ ts.forEachChild(node, visit);
3450
+ };
3451
+ for (const statement of sourceFile.statements) {
3452
+ visit(statement);
3453
+ if (exported) {
3454
+ break;
3455
+ }
3456
+ }
3457
+ return exported;
3458
+ }
3459
+
3460
+ function isPublicOutputPropertyName(name: ts.PropertyName): boolean {
3461
+ const text = propertyNameText(name);
3462
+ return text === "output" || text === "response";
3463
+ }
3464
+
3465
+ function findEnclosingVariableDeclaration(node: ts.Node): ts.VariableDeclaration | undefined {
3466
+ let current: ts.Node | undefined = node;
3467
+ while (current !== undefined && !ts.isSourceFile(current)) {
3468
+ if (ts.isVariableDeclaration(current)) {
3469
+ return current;
3470
+ }
3471
+ current = current.parent;
3472
+ }
3473
+ return undefined;
3474
+ }
3475
+
3476
+ function localSchemaBindingReachability(
3477
+ sourceFile: ts.SourceFile,
3478
+ name: string,
3479
+ declaration: ts.VariableDeclaration,
3480
+ resolving: ReadonlySet<string>,
3481
+ ): SchemaReachability {
3482
+ if (resolving.has(name)) {
3483
+ return "unknown";
3484
+ }
3485
+ const nextResolving = new Set(resolving);
3486
+ nextResolving.add(name);
3487
+ let result: SchemaReachability = "internal";
3488
+ const visit = (node: ts.Node): void => {
3489
+ if (result === "public") {
3490
+ return;
3491
+ }
3492
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
3493
+ const parent = node.parent;
3494
+ if (isParseLikeSchemaReceiver(node)) {
3495
+ return;
3496
+ }
3497
+ if (ts.isExportSpecifier(parent)) {
3498
+ const localName = parent.propertyName ?? parent.name;
3499
+ if (localName === node) {
3500
+ result = "public";
3501
+ }
3502
+ return;
3503
+ }
3504
+ if (
3505
+ ts.isPropertyAssignment(parent) &&
3506
+ parent.initializer === node &&
3507
+ isPublicOutputPropertyName(parent.name)
3508
+ ) {
3509
+ result = "public";
3510
+ return;
3511
+ }
3512
+ if (ts.isShorthandPropertyAssignment(parent)) {
3513
+ result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
3514
+ return;
3515
+ }
3516
+ if (
3517
+ ts.isVariableDeclaration(parent) &&
3518
+ parent.initializer === node &&
3519
+ ts.isIdentifier(parent.name) &&
3520
+ ts.isVariableDeclarationList(parent.parent) &&
3521
+ (parent.parent.flags & ts.NodeFlags.Const) !== 0
3522
+ ) {
3523
+ const aliasReachability = localSchemaBindingReachability(
3524
+ sourceFile,
3525
+ parent.name.text,
3526
+ parent,
3527
+ nextResolving,
3528
+ );
3529
+ if (aliasReachability === "public" || result === "internal") {
3530
+ result = aliasReachability;
3531
+ }
3532
+ return;
3533
+ }
3534
+ if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
3535
+ return;
3536
+ }
3537
+ if (
3538
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
3539
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
3540
+ ts.isBindingElement(parent) ||
3541
+ ts.isImportSpecifier(parent)
3542
+ ) {
3543
+ return;
3544
+ }
3545
+ result = "unknown";
3546
+ }
3547
+ ts.forEachChild(node, visit);
3548
+ };
3549
+ visit(sourceFile);
3550
+ return result;
3551
+ }
3552
+
3553
+ function isParseLikeSchemaReceiver(identifier: ts.Identifier): boolean {
3554
+ const access = identifier.parent;
3555
+ let methodName: string | undefined;
3556
+ if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
3557
+ methodName = access.name.text;
3558
+ } else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
3559
+ const argument = access.argumentExpression;
3560
+ if (
3561
+ argument !== undefined &&
3562
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
3563
+ ) {
3564
+ methodName = argument.text;
3565
+ }
3566
+ }
3567
+ return (
3568
+ methodName !== undefined &&
3569
+ PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
3570
+ ts.isCallExpression(access.parent) &&
3571
+ access.parent.expression === access
3572
+ );
2450
3573
  }
2451
3574
 
2452
3575
  function vendorKeyFindingsForObject(
2453
3576
  source: string,
3577
+ sourceFile: ts.SourceFile,
2454
3578
  zObject: ZObjectLiteral,
3579
+ bindings: LocalBindings,
2455
3580
  ): Array<{ key: string; line: number }> {
2456
- const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
3581
+ const keys = collectTopLevelObjectKeys(
3582
+ source,
3583
+ sourceFile,
3584
+ zObject.objectStart,
3585
+ zObject.objectEnd,
3586
+ bindings,
3587
+ );
2457
3588
  const digitFamilies = new Map<string, Set<string>>();
2458
3589
  for (const key of keys) {
2459
3590
  const member = numberedFamilyMember(key.name);
@@ -2509,8 +3640,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
2509
3640
 
2510
3641
  function collectTopLevelObjectKeys(
2511
3642
  source: string,
3643
+ sourceFile: ts.SourceFile,
2512
3644
  objectStart: number,
2513
3645
  objectEnd: number,
3646
+ bindings: LocalBindings,
2514
3647
  ): Array<{ name: string; offset: number }> {
2515
3648
  const keys: Array<{ name: string; offset: number }> = [];
2516
3649
  const masked = maskCommentsAndStrings(source);
@@ -2532,17 +3665,16 @@ function collectTopLevelObjectKeys(
2532
3665
  index = endQuote + 1;
2533
3666
  } else if (masked[index] === "[") {
2534
3667
  const computedEnd = findMatchingBracket(masked, index);
2535
- const literalStart = findNextNonWhitespace(masked, index + 1);
2536
- if (computedEnd === -1 || literalStart === -1) {
3668
+ if (computedEnd === -1) {
2537
3669
  break;
2538
3670
  }
2539
- const computedQuote = source[literalStart];
2540
- if (computedQuote === '"' || computedQuote === "'") {
2541
- const literalEnd = findStringEnd(source, literalStart);
2542
- const afterLiteral =
2543
- literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2544
- if (literalEnd !== -1 && afterLiteral === computedEnd) {
2545
- key = source.slice(literalStart + 1, literalEnd);
3671
+ const expression = computedPropertyExpressionAtOffset(sourceFile, index);
3672
+ if (expression !== undefined) {
3673
+ const unwrapped = unwrapLocalExpression(expression);
3674
+ if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
3675
+ key = unwrapped.text;
3676
+ } else if (ts.isIdentifier(unwrapped)) {
3677
+ key = resolveConstStringIdentifier(unwrapped, bindings);
2546
3678
  }
2547
3679
  }
2548
3680
  index = computedEnd + 1;
@@ -2569,6 +3701,25 @@ function collectTopLevelObjectKeys(
2569
3701
  return keys;
2570
3702
  }
2571
3703
 
3704
+ function computedPropertyExpressionAtOffset(
3705
+ sourceFile: ts.SourceFile,
3706
+ offset: number,
3707
+ ): ts.Expression | undefined {
3708
+ let expression: ts.Expression | undefined;
3709
+ const visit = (node: ts.Node): void => {
3710
+ if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
3711
+ return;
3712
+ }
3713
+ if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
3714
+ expression = node.expression;
3715
+ return;
3716
+ }
3717
+ ts.forEachChild(node, visit);
3718
+ };
3719
+ visit(sourceFile);
3720
+ return expression;
3721
+ }
3722
+
2572
3723
  function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
2573
3724
  const findings: SourceFinding[] = [];
2574
3725
  const seen = new Set<string>();
@@ -2576,6 +3727,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2576
3727
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2577
3728
  const source = readFileSync(filePath, "utf8");
2578
3729
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3730
+ maskCommentsAndStrings(source, relPath);
3731
+ const sourceFile = ts.createSourceFile(
3732
+ relPath,
3733
+ source,
3734
+ ts.ScriptTarget.Latest,
3735
+ true,
3736
+ ts.ScriptKind.TS,
3737
+ );
3738
+ const bindings = collectLocalBindings(sourceFile);
2579
3739
  const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
2580
3740
  start: zObject.callStart,
2581
3741
  end: zObject.objectEnd,
@@ -2588,7 +3748,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2588
3748
  ].filter((range) => rangeContainedInRanges(fixtureRanges, range));
2589
3749
 
2590
3750
  for (const range of fixtureResponseRanges) {
2591
- for (const literal of findStringLiteralsInRange(source, range)) {
3751
+ for (const literal of findStringLiteralsInRange(source, range, relPath)) {
2592
3752
  if (
2593
3753
  rangeContainsOffset(zObjectRanges, literal.offset) ||
2594
3754
  rangeContainsOffset(upstreamRanges, literal.offset) ||
@@ -2609,12 +3769,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2609
3769
  }
2610
3770
  }
2611
3771
  }
3772
+
3773
+ const visitAliases = (node: ts.Node): void => {
3774
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3775
+ return;
3776
+ }
3777
+ const offset = node.getStart(sourceFile);
3778
+ if (
3779
+ ts.isIdentifier(node) &&
3780
+ isIdentifierValueUse(node) &&
3781
+ offset >= range.start &&
3782
+ offset <= range.end &&
3783
+ !rangeContainsOffset(zObjectRanges, offset) &&
3784
+ !rangeContainsOffset(upstreamRanges, offset)
3785
+ ) {
3786
+ const value = resolveConstStringIdentifier(node, bindings);
3787
+ if (
3788
+ value !== undefined &&
3789
+ isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
3790
+ ) {
3791
+ const line = offsetToLine(source, offset);
3792
+ const key = `${relPath}:${line}:${value}`;
3793
+ if (!seen.has(key)) {
3794
+ seen.add(key);
3795
+ findings.push({ file: relPath, line });
3796
+ }
3797
+ }
3798
+ }
3799
+ ts.forEachChild(node, visitAliases);
3800
+ };
3801
+ visitAliases(sourceFile);
3802
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3803
+ return findings;
3804
+ }
2612
3805
  }
2613
3806
  }
2614
3807
 
2615
3808
  return findings;
2616
3809
  }
2617
3810
 
3811
+ function isIdentifierValueUse(identifier: ts.Identifier): boolean {
3812
+ const parent = identifier.parent;
3813
+ return !(
3814
+ (ts.isPropertyAssignment(parent) && parent.name === identifier) ||
3815
+ (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
3816
+ ts.isBindingElement(parent) ||
3817
+ ts.isVariableDeclaration(parent) ||
3818
+ ts.isParameter(parent) ||
3819
+ ts.isImportClause(parent) ||
3820
+ ts.isImportSpecifier(parent) ||
3821
+ ts.isNamespaceImport(parent) ||
3822
+ ts.isFunctionDeclaration(parent) ||
3823
+ ts.isFunctionExpression(parent) ||
3824
+ ts.isClassDeclaration(parent) ||
3825
+ ts.isClassExpression(parent) ||
3826
+ ts.isPropertyDeclaration(parent) ||
3827
+ ts.isPropertySignature(parent) ||
3828
+ ts.isMethodDeclaration(parent) ||
3829
+ ts.isMethodSignature(parent)
3830
+ );
3831
+ }
3832
+
3833
+ function propertyNameForValueExpression(expression: ts.Expression): string | undefined {
3834
+ let current: ts.Expression = expression;
3835
+ while (
3836
+ ts.isParenthesizedExpression(current.parent) ||
3837
+ ts.isAsExpression(current.parent) ||
3838
+ ts.isSatisfiesExpression(current.parent) ||
3839
+ ts.isNonNullExpression(current.parent)
3840
+ ) {
3841
+ current = current.parent;
3842
+ }
3843
+ if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
3844
+ return propertyNameText(current.parent.name);
3845
+ }
3846
+ if (ts.isShorthandPropertyAssignment(current.parent)) {
3847
+ return current.parent.name.text;
3848
+ }
3849
+ return undefined;
3850
+ }
3851
+
2618
3852
  function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
2619
3853
  const ranges: ObjectRange[] = [];
2620
3854
  const masked = maskCommentsAndStrings(source);
@@ -2658,23 +3892,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2658
3892
  return ranges;
2659
3893
  }
2660
3894
 
2661
- function findConstValueRangeContaining(
2662
- source: string,
2663
- offset: number,
2664
- ): NamedObjectRange | undefined {
2665
- return findNamedConstValueRanges(source).find(
2666
- (range) => offset >= range.start && offset <= range.end,
2667
- );
2668
- }
2669
-
2670
3895
  function findStringLiteralsInRange(
2671
3896
  source: string,
2672
3897
  range: ObjectRange,
3898
+ fileName: string,
2673
3899
  ): Array<{ value: string; offset: number }> {
2674
3900
  const literals: Array<{ value: string; offset: number }> = [];
3901
+ const masked = maskCommentsAndStrings(source, fileName);
2675
3902
  let index = range.start;
2676
3903
  while (index <= range.end) {
2677
- const quote = source[index];
3904
+ const quote = masked[index];
2678
3905
  if (quote !== '"' && quote !== "'" && quote !== "`") {
2679
3906
  index += 1;
2680
3907
  continue;
@@ -2784,57 +4011,199 @@ function findStringEnd(source: string, start: number): number {
2784
4011
  return -1;
2785
4012
  }
2786
4013
 
2787
- function maskCommentsAndStrings(source: string): string {
2788
- const chars = source.split("");
2789
- for (let index = 0; index < source.length; index += 1) {
2790
- if (source.startsWith("//", index)) {
2791
- const bodyStart = index + 2;
2792
- const newline = source.indexOf("\n", bodyStart);
2793
- const end = newline === -1 ? source.length : newline;
2794
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2795
- chars[bodyIndex] = " ";
2796
- }
2797
- index = end;
2798
- continue;
4014
+ // Masking now parses with TypeScript, which is orders of magnitude more
4015
+ // expensive than the character walk it replaced, and six scanners call this
4016
+ // helper once per candidate match the same file is masked thousands of
4017
+ // times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
4018
+ // memoization that run regresses from ~8s to minutes and can exceed the CI
4019
+ // validation timeout. Successful results are cached by exact source text and
4020
+ // property-key mode; the cache stays small because a run only ever reads a
4021
+ // handful of files.
4022
+ // Parse failures are NOT cached: they throw and abort the check (fail-closed).
4023
+ const MASK_CACHE_LIMIT = 64;
4024
+ const maskCache = new Map<string, string>();
4025
+
4026
+ // Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
4027
+ // because scanners treat these as depth, string/regex delimiters, or top-level stops.
4028
+ const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
4029
+ "{",
4030
+ "}",
4031
+ "(",
4032
+ ")",
4033
+ "[",
4034
+ "]",
4035
+ "`",
4036
+ '"',
4037
+ "'",
4038
+ "/",
4039
+ "\\",
4040
+ ",",
4041
+ ";",
4042
+ ]);
4043
+
4044
+ type MaskCommentsAndStringsOptions = {
4045
+ blankPropertyKeys?: boolean;
4046
+ };
4047
+
4048
+ export function maskCommentsAndStrings(
4049
+ source: string,
4050
+ fileName = "provider.ts",
4051
+ options: MaskCommentsAndStringsOptions = {},
4052
+ ): string {
4053
+ const blankPropertyKeys = options.blankPropertyKeys === true;
4054
+ const cacheKey = `${blankPropertyKeys ? "blank-keys" : "preserve-keys"}\0${source}`;
4055
+ const cached = maskCache.get(cacheKey);
4056
+ if (cached !== undefined) {
4057
+ return cached;
4058
+ }
4059
+ const masked = computeMaskedSource(source, fileName, blankPropertyKeys);
4060
+ if (maskCache.size >= MASK_CACHE_LIMIT) {
4061
+ const oldest = maskCache.keys().next();
4062
+ if (!oldest.done) {
4063
+ maskCache.delete(oldest.value);
2799
4064
  }
2800
- if (source.startsWith("/*", index)) {
2801
- const bodyStart = index + 2;
2802
- const close = source.indexOf("*/", bodyStart);
2803
- const end = close === -1 ? source.length : close;
2804
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2805
- if (chars[bodyIndex] !== "\n") {
2806
- chars[bodyIndex] = " ";
2807
- }
4065
+ }
4066
+ maskCache.set(cacheKey, masked);
4067
+ return masked;
4068
+ }
4069
+
4070
+ function computeMaskedSource(
4071
+ source: string,
4072
+ fileName: string,
4073
+ blankPropertyKeys: boolean,
4074
+ ): string {
4075
+ const transpiled = ts.transpileModule(source, {
4076
+ // Declaration files trigger an internal TypeScript Debug Failure when
4077
+ // passed to transpileModule. Parsing is all we need here, so always use a
4078
+ // synthetic implementation filename while retaining the real filename
4079
+ // for source mapping and sanitized diagnostics below.
4080
+ fileName: "provider.ts",
4081
+ reportDiagnostics: true,
4082
+ compilerOptions: { target: ts.ScriptTarget.Latest },
4083
+ });
4084
+ const sourceFile = ts.createSourceFile(
4085
+ fileName,
4086
+ source,
4087
+ ts.ScriptTarget.Latest,
4088
+ true,
4089
+ ts.ScriptKind.TS,
4090
+ );
4091
+ const parseDiagnostic = transpiled.diagnostics?.[0];
4092
+ if (parseDiagnostic) {
4093
+ const position = parseDiagnostic.start ?? 0;
4094
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
4095
+ throw new Error(
4096
+ `Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
4097
+ );
4098
+ }
4099
+
4100
+ const chars = source.split("");
4101
+ const maskRange = (start: number, end: number): void => {
4102
+ for (let index = start; index < end; index += 1) {
4103
+ // Preserve the backslash of a string line continuation (\ followed
4104
+ // by LF, CRLF, or lone CR) and every line terminator, so masked
4105
+ // string bodies remain syntactically valid and the mask output can
4106
+ // be re-parsed (mask(mask(x)) === mask(x)).
4107
+ if (
4108
+ source[index] === "\\" &&
4109
+ (source[index + 1] === "\n" || source[index + 1] === "\r")
4110
+ ) {
4111
+ continue;
2808
4112
  }
2809
- index = close === -1 ? source.length : close + 1;
2810
- continue;
2811
- }
2812
- const quote = source[index];
2813
- if (quote !== '"' && quote !== "'" && quote !== "`") {
2814
- continue;
2815
- }
2816
- const end = findStringEnd(source, index);
2817
- if (end === -1) {
2818
- break;
4113
+ if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
2819
4114
  }
2820
- // Preserve quoted property keys ("response": ...) so range/key scanners
2821
- // can still match them; only string VALUES are blanked.
2822
- let probe = end + 1;
2823
- while (probe < source.length && /\s/.test(source[probe] ?? "")) {
2824
- probe += 1;
4115
+ };
4116
+ const commentRanges = new Map<string, ts.CommentRange>();
4117
+ const addCommentRanges = (ranges: readonly ts.CommentRange[] | undefined): void => {
4118
+ for (const range of ranges ?? []) {
4119
+ commentRanges.set(`${range.pos}:${range.end}`, range);
2825
4120
  }
2826
- if (source[probe] !== ":") {
2827
- for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
2828
- if (chars[bodyIndex] !== "\n") {
2829
- chars[bodyIndex] = " ";
4121
+ };
4122
+
4123
+ const visit = (node: ts.Node): void => {
4124
+ addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
4125
+ addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
4126
+
4127
+ const start = node.getStart(sourceFile);
4128
+ if (ts.isStringLiteral(node)) {
4129
+ // Preserve quoted property keys ("response": ...) by default so range/key
4130
+ // scanners can still match them. Line scanners opt into blanking key bodies
4131
+ // as well, preventing key text from looking like executable source.
4132
+ const isQuotedPropertyKey =
4133
+ ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4134
+ if (blankPropertyKeys || !isQuotedPropertyKey) {
4135
+ maskRange(start + 1, node.end - 1);
4136
+ } else {
4137
+ for (let index = start + 1; index < node.end - 1; index += 1) {
4138
+ if (
4139
+ source[index] === "\\" &&
4140
+ (source[index + 1] === "\n" || source[index + 1] === "\r")
4141
+ ) {
4142
+ continue;
4143
+ }
4144
+ if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
4145
+ chars[index] = " ";
4146
+ }
2830
4147
  }
2831
4148
  }
2832
- }
2833
- index = end;
4149
+ } else if (ts.isRegularExpressionLiteral(node)) {
4150
+ let closeDelimiter = node.end - 1;
4151
+ while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
4152
+ closeDelimiter -= 1;
4153
+ }
4154
+ maskRange(start + 1, closeDelimiter);
4155
+ } else if (ts.isNoSubstitutionTemplateLiteral(node)) {
4156
+ maskRange(start + 1, node.end - 1);
4157
+ } else if (node.kind === ts.SyntaxKind.TemplateHead) {
4158
+ maskRange(start + 1, node.end - 2);
4159
+ } else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
4160
+ maskRange(start + 1, node.end - 2);
4161
+ } else if (node.kind === ts.SyntaxKind.TemplateTail) {
4162
+ maskRange(start + 1, node.end - 1);
4163
+ }
4164
+
4165
+ for (const child of node.getChildren(sourceFile)) visit(child);
4166
+ };
4167
+
4168
+ visit(sourceFile);
4169
+ for (const comment of commentRanges.values()) {
4170
+ const markerLength = 2;
4171
+ const bodyEnd =
4172
+ comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
4173
+ ? comment.end - markerLength
4174
+ : comment.end;
4175
+ maskRange(comment.pos + markerLength, bodyEnd);
2834
4176
  }
2835
4177
  return chars.join("");
2836
4178
  }
2837
4179
 
4180
+ // Escapes every non-printable or formatting character before a submitter-
4181
+ // controlled filename is interpolated into a diagnostic that the CLI prints.
4182
+ // Category-based on purpose: enumerated code-point lists kept missing members
4183
+ // of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
4184
+ // individually in review), so this escapes the whole Unicode categories —
4185
+ // Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
4186
+ // (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
4187
+ // filenames pass through unchanged.
4188
+ const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
4189
+
4190
+ function sanitizeDiagnosticFileName(fileName: string): string {
4191
+ let sanitized = "";
4192
+ for (const character of fileName) {
4193
+ const codePoint = character.codePointAt(0);
4194
+ if (codePoint === undefined) continue;
4195
+ if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
4196
+ sanitized +=
4197
+ codePoint <= 0xff
4198
+ ? `\\x${codePoint.toString(16).padStart(2, "0")}`
4199
+ : `\\u{${codePoint.toString(16)}}`;
4200
+ } else {
4201
+ sanitized += character;
4202
+ }
4203
+ }
4204
+ return sanitized;
4205
+ }
4206
+
2838
4207
  function skipWhitespaceAndComments(source: string, start: number, end: number): number {
2839
4208
  let index = start;
2840
4209
  while (index < end) {
@@ -4229,8 +5598,9 @@ function shannonEntropy(value: string): number {
4229
5598
 
4230
5599
  function guessSecretName(line: string): string {
4231
5600
  const match =
4232
- /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
4233
- /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
5601
+ /\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
5602
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
5603
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
4234
5604
  const raw = match?.[1] ?? "SECRET";
4235
5605
  return raw
4236
5606
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
@@ -4245,6 +5615,35 @@ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/
4245
5615
  // the three stay coherent.
4246
5616
  const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
4247
5617
 
5618
+ // A secret-ish name is only supporting evidence. Shorter values must also look
5619
+ // credential-like, and known config tokens/numeric values are never candidates.
5620
+ const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
5621
+ const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
5622
+ const NON_SECRET_ENV_VALUE_TOKENS = new Set([
5623
+ "true",
5624
+ "false",
5625
+ "yes",
5626
+ "no",
5627
+ "on",
5628
+ "off",
5629
+ "none",
5630
+ "null",
5631
+ "nil",
5632
+ "auto",
5633
+ "default",
5634
+ "local",
5635
+ "debug",
5636
+ "info",
5637
+ "warn",
5638
+ "error",
5639
+ "always",
5640
+ "never",
5641
+ "enabled",
5642
+ "disabled",
5643
+ "0",
5644
+ "1",
5645
+ ]);
5646
+
4248
5647
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
4249
5648
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
4250
5649
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
@@ -4256,12 +5655,151 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
4256
5655
  ],
4257
5656
  ];
4258
5657
 
4259
- async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
5658
+ type SafeLoadProviderResult =
5659
+ | { ok: true; provider: ProviderDefinition | undefined }
5660
+ | { ok: false; error: unknown };
5661
+
5662
+ async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
5663
+ try {
5664
+ return { ok: true, provider: await loadProvider(providerRoot) };
5665
+ } catch (error) {
5666
+ return { ok: false, error };
5667
+ }
5668
+ }
5669
+
5670
+ const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
5671
+
5672
+ function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
5673
+ const evidence: string[] = [];
5674
+ const seen = new Set<object>();
5675
+ let current: unknown = error;
5676
+ let frame = 0;
5677
+ while (frame < MAX_LOAD_ERROR_EVIDENCE) {
5678
+ if (typeof current === "object" || typeof current === "function") {
5679
+ if (current !== null && seen.has(current)) {
5680
+ break;
5681
+ }
5682
+ if (current !== null) seen.add(current);
5683
+ }
5684
+ evidence.push(
5685
+ `${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
5686
+ formatThrownValue(current),
5687
+ providerRoot,
5688
+ )}`,
5689
+ );
5690
+ frame += 1;
5691
+ const cause = readThrownValueCause(current);
5692
+ if (!cause.found || cause.value === undefined || cause.value === null) {
5693
+ break;
5694
+ }
5695
+ current = cause.value;
5696
+ }
5697
+ return evidence;
5698
+ }
5699
+
5700
+ function formatThrownValue(value: unknown): string {
4260
5701
  try {
4261
- return await loadProvider(providerRoot);
5702
+ if (value instanceof Error) {
5703
+ return String(value.message);
5704
+ }
5705
+ if (
5706
+ value !== null &&
5707
+ (typeof value === "object" || typeof value === "function") &&
5708
+ "message" in value
5709
+ ) {
5710
+ return String((value as { message?: unknown }).message);
5711
+ }
5712
+ return `<non-Error value thrown: ${String(value)}>`;
4262
5713
  } catch {
4263
- return undefined;
5714
+ return UNRENDERABLE_LOAD_ERROR;
5715
+ }
5716
+ }
5717
+
5718
+ function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
5719
+ try {
5720
+ if (
5721
+ value === null ||
5722
+ (typeof value !== "object" && typeof value !== "function") ||
5723
+ !("cause" in value)
5724
+ ) {
5725
+ return { found: false };
5726
+ }
5727
+ return { found: true, value: (value as { cause?: unknown }).cause };
5728
+ } catch {
5729
+ return { found: false };
5730
+ }
5731
+ }
5732
+
5733
+ function isPlausibleSecretNamedEnvValue(value: string): boolean {
5734
+ const normalized = value.trim();
5735
+ if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
5736
+ if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
5737
+ if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
5738
+ if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
5739
+
5740
+ const hasLetter = /[A-Za-z]/.test(normalized);
5741
+ const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
5742
+ return hasLetter && hasDigitOrSymbol;
5743
+ }
5744
+
5745
+ function replaceEnvValue(
5746
+ input: string,
5747
+ value: string,
5748
+ requireIdentifierBoundaries: boolean,
5749
+ ): string {
5750
+ if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
5751
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5752
+ return input.replace(
5753
+ new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
5754
+ "[REDACTED]",
5755
+ );
5756
+ }
5757
+
5758
+ function sanitizeLoadErrorText(value: string, providerRoot: string): string {
5759
+ let output = value.replace(
5760
+ /(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
5761
+ (_match, prefix: string, rawPath: string) => {
5762
+ const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
5763
+ const relativePath = relative(providerRoot, resolve(pathCandidate));
5764
+ const insideProvider =
5765
+ relativePath === "" ||
5766
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
5767
+ return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
5768
+ },
5769
+ );
5770
+ output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
5771
+ output = output.replace(
5772
+ /((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
5773
+ "$1[REDACTED]",
5774
+ );
5775
+ const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
5776
+ for (const [name, item] of Object.entries(process.env)) {
5777
+ if (typeof item !== "string") continue;
5778
+ const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
5779
+ let shouldRedact = secretNamed;
5780
+ if (secretNamed) {
5781
+ shouldRedact = isPlausibleSecretNamedEnvValue(item);
5782
+ } else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
5783
+ const charset = classifyEntropyCharset(item);
5784
+ const threshold = charset === "hex" ? 3.0 : 4.5;
5785
+ shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
5786
+ }
5787
+ if (shouldRedact) {
5788
+ envValues.push({
5789
+ value: item,
5790
+ requireIdentifierBoundaries:
5791
+ secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
5792
+ });
5793
+ }
5794
+ }
5795
+ envValues.sort((a, b) => b.value.length - a.value.length);
5796
+ for (const envValue of envValues) {
5797
+ output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
4264
5798
  }
5799
+ output = redact(output).replace(/\s+/g, " ");
5800
+ return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
5801
+ ? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
5802
+ : output;
4265
5803
  }
4266
5804
 
4267
5805
  async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {