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

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