@bilalimamoglu/sift 0.3.0 → 0.3.2

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.
package/dist/cli.js CHANGED
@@ -136,6 +136,78 @@ var defaultConfig = {
136
136
  }
137
137
  };
138
138
 
139
+ // src/config/native-provider.ts
140
+ function getNativeProviderDefaults(provider) {
141
+ if (provider === "openrouter") {
142
+ return {
143
+ provider,
144
+ model: "openrouter/free",
145
+ baseUrl: "https://openrouter.ai/api/v1"
146
+ };
147
+ }
148
+ return {
149
+ provider,
150
+ model: defaultConfig.provider.model,
151
+ baseUrl: defaultConfig.provider.baseUrl
152
+ };
153
+ }
154
+ function getProfileProviderState(provider, profile) {
155
+ const defaults = getNativeProviderDefaults(provider);
156
+ return {
157
+ provider,
158
+ model: profile?.model ?? defaults.model,
159
+ baseUrl: profile?.baseUrl ?? defaults.baseUrl
160
+ };
161
+ }
162
+ function getStoredProviderProfile(config, provider) {
163
+ const existingProfile = config.providerProfiles?.[provider];
164
+ if (existingProfile) {
165
+ return existingProfile;
166
+ }
167
+ if (config.provider.provider !== provider) {
168
+ return void 0;
169
+ }
170
+ return {
171
+ model: config.provider.model,
172
+ baseUrl: config.provider.baseUrl,
173
+ apiKey: config.provider.apiKey || void 0
174
+ };
175
+ }
176
+ function setStoredProviderProfile(config, provider, profile) {
177
+ const providerProfiles = {
178
+ ...config.providerProfiles ?? {},
179
+ [provider]: profile
180
+ };
181
+ return {
182
+ ...config,
183
+ providerProfiles
184
+ };
185
+ }
186
+ function preserveActiveNativeProviderProfile(config) {
187
+ const provider = config.provider.provider;
188
+ if (provider !== "openai" && provider !== "openrouter") {
189
+ return config;
190
+ }
191
+ if (config.providerProfiles?.[provider]) {
192
+ return config;
193
+ }
194
+ return setStoredProviderProfile(config, provider, {
195
+ model: config.provider.model,
196
+ baseUrl: config.provider.baseUrl,
197
+ apiKey: config.provider.apiKey || void 0
198
+ });
199
+ }
200
+ function applyActiveProvider(config, provider, profile, apiKey) {
201
+ return {
202
+ ...config,
203
+ provider: {
204
+ ...config.provider,
205
+ ...getProfileProviderState(provider, profile),
206
+ apiKey
207
+ }
208
+ };
209
+ }
210
+
139
211
  // src/config/provider-api-key.ts
140
212
  var OPENAI_COMPATIBLE_BASE_URL_ENV = [
141
213
  { prefix: "https://api.openai.com/", envName: "OPENAI_API_KEY" },
@@ -143,13 +215,16 @@ var OPENAI_COMPATIBLE_BASE_URL_ENV = [
143
215
  { prefix: "https://api.together.xyz/", envName: "TOGETHER_API_KEY" },
144
216
  { prefix: "https://api.groq.com/openai/", envName: "GROQ_API_KEY" }
145
217
  ];
218
+ var NATIVE_PROVIDER_API_KEY_ENV = {
219
+ openai: "OPENAI_API_KEY",
220
+ openrouter: "OPENROUTER_API_KEY"
221
+ };
146
222
  var PROVIDER_API_KEY_ENV = {
147
223
  anthropic: "ANTHROPIC_API_KEY",
148
224
  claude: "ANTHROPIC_API_KEY",
149
225
  groq: "GROQ_API_KEY",
150
- openai: "OPENAI_API_KEY",
151
- openrouter: "OPENROUTER_API_KEY",
152
- together: "TOGETHER_API_KEY"
226
+ together: "TOGETHER_API_KEY",
227
+ ...NATIVE_PROVIDER_API_KEY_ENV
153
228
  };
154
229
  function normalizeBaseUrl(baseUrl) {
155
230
  if (!baseUrl) {
@@ -184,6 +259,9 @@ function resolveProviderApiKey(provider, baseUrl, env) {
184
259
  }
185
260
  return env.SIFT_PROVIDER_API_KEY;
186
261
  }
262
+ function getNativeProviderApiKeyEnvName(provider) {
263
+ return NATIVE_PROVIDER_API_KEY_ENV[provider];
264
+ }
187
265
  function getProviderApiKeyEnvNames(provider, baseUrl) {
188
266
  const envNames = ["SIFT_PROVIDER_API_KEY"];
189
267
  if (provider === "openai-compatible") {
@@ -205,7 +283,11 @@ function getProviderApiKeyEnvNames(provider, baseUrl) {
205
283
 
206
284
  // src/config/schema.ts
207
285
  import { z } from "zod";
208
- var providerNameSchema = z.enum(["openai", "openai-compatible"]);
286
+ var providerNameSchema = z.enum([
287
+ "openai",
288
+ "openai-compatible",
289
+ "openrouter"
290
+ ]);
209
291
  var outputFormatSchema = z.enum([
210
292
  "brief",
211
293
  "bullets",
@@ -234,6 +316,15 @@ var providerConfigSchema = z.object({
234
316
  temperature: z.number().min(0).max(2),
235
317
  maxOutputTokens: z.number().int().positive()
236
318
  });
319
+ var providerProfileSchema = z.object({
320
+ model: z.string().min(1).optional(),
321
+ baseUrl: z.string().url().optional(),
322
+ apiKey: z.string().optional()
323
+ });
324
+ var providerProfilesSchema = z.object({
325
+ openai: providerProfileSchema.optional(),
326
+ openrouter: providerProfileSchema.optional()
327
+ }).optional();
237
328
  var inputConfigSchema = z.object({
238
329
  stripAnsi: z.boolean(),
239
330
  redact: z.boolean(),
@@ -258,10 +349,19 @@ var siftConfigSchema = z.object({
258
349
  provider: providerConfigSchema,
259
350
  input: inputConfigSchema,
260
351
  runtime: runtimeConfigSchema,
261
- presets: z.record(presetDefinitionSchema)
352
+ presets: z.record(presetDefinitionSchema),
353
+ providerProfiles: providerProfilesSchema
262
354
  });
263
355
 
264
356
  // src/config/resolve.ts
357
+ var PROVIDER_DEFAULT_OVERRIDES = {
358
+ openrouter: {
359
+ provider: {
360
+ model: "openrouter/free",
361
+ baseUrl: "https://openrouter.ai/api/v1"
362
+ }
363
+ }
364
+ };
265
365
  function isRecord(value) {
266
366
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
267
367
  }
@@ -324,13 +424,32 @@ function buildCredentialEnvOverrides(env, context) {
324
424
  }
325
425
  };
326
426
  }
427
+ function getBaseConfigForProvider(provider) {
428
+ return mergeDefined(defaultConfig, provider ? PROVIDER_DEFAULT_OVERRIDES[provider] : {});
429
+ }
430
+ function resolveProvisionalProvider(args) {
431
+ const provisional = mergeDefined(
432
+ mergeDefined(
433
+ mergeDefined(defaultConfig, args.fileConfig),
434
+ args.nonCredentialEnvConfig
435
+ ),
436
+ stripApiKey(args.cliOverrides) ?? {}
437
+ );
438
+ return provisional.provider.provider;
439
+ }
327
440
  function resolveConfig(options = {}) {
328
441
  const env = options.env ?? process.env;
329
442
  const fileConfig = loadRawConfig(options.configPath);
330
443
  const nonCredentialEnvConfig = buildNonCredentialEnvOverrides(env);
444
+ const provisionalProvider = resolveProvisionalProvider({
445
+ fileConfig,
446
+ nonCredentialEnvConfig,
447
+ cliOverrides: options.cliOverrides
448
+ });
449
+ const baseConfig = getBaseConfigForProvider(provisionalProvider);
331
450
  const contextConfig = mergeDefined(
332
451
  mergeDefined(
333
- mergeDefined(defaultConfig, fileConfig),
452
+ mergeDefined(baseConfig, fileConfig),
334
453
  nonCredentialEnvConfig
335
454
  ),
336
455
  stripApiKey(options.cliOverrides) ?? {}
@@ -342,7 +461,7 @@ function resolveConfig(options = {}) {
342
461
  const merged = mergeDefined(
343
462
  mergeDefined(
344
463
  mergeDefined(
345
- mergeDefined(defaultConfig, fileConfig),
464
+ mergeDefined(baseConfig, fileConfig),
346
465
  nonCredentialEnvConfig
347
466
  ),
348
467
  credentialEnvConfig
@@ -387,6 +506,32 @@ function writeConfigFile(options) {
387
506
  return resolved;
388
507
  }
389
508
 
509
+ // src/config/editable.ts
510
+ import fs3 from "fs";
511
+ import path4 from "path";
512
+ function isRecord2(value) {
513
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
514
+ }
515
+ function resolveEditableConfigPath(explicitPath) {
516
+ if (explicitPath) {
517
+ return path4.resolve(explicitPath);
518
+ }
519
+ return findConfigPath() ?? getDefaultGlobalConfigPath();
520
+ }
521
+ function loadEditableConfig(explicitPath) {
522
+ const resolvedPath = resolveEditableConfigPath(explicitPath);
523
+ const existed = fs3.existsSync(resolvedPath);
524
+ const rawConfig = existed ? loadRawConfig(resolvedPath) : {};
525
+ const config = siftConfigSchema.parse(
526
+ mergeDefined(defaultConfig, isRecord2(rawConfig) ? rawConfig : {})
527
+ );
528
+ return {
529
+ config,
530
+ existed,
531
+ resolvedPath
532
+ };
533
+ }
534
+
390
535
  // src/ui/presentation.ts
391
536
  import pc from "picocolors";
392
537
  function applyColor(enabled, formatter, value) {
@@ -437,8 +582,7 @@ ${tagline}`;
437
582
  }
438
583
 
439
584
  // src/commands/config-setup.ts
440
- import fs3 from "fs";
441
- import path4 from "path";
585
+ import path5 from "path";
442
586
  import { emitKeypressEvents } from "readline";
443
587
  import { createInterface } from "readline/promises";
444
588
  import { stderr as defaultStderr, stdin as defaultStdin2, stdout as defaultStdout } from "process";
@@ -636,59 +780,102 @@ function createTerminalIO() {
636
780
  };
637
781
  }
638
782
  function resolveSetupPath(targetPath) {
639
- return targetPath ? path4.resolve(targetPath) : getDefaultGlobalConfigPath();
640
- }
641
- function buildOpenAISetupConfig(apiKey) {
642
- return {
643
- ...defaultConfig,
644
- provider: {
645
- ...defaultConfig.provider,
646
- provider: "openai",
647
- model: "gpt-5-nano",
648
- baseUrl: "https://api.openai.com/v1",
649
- apiKey
650
- }
651
- };
783
+ return targetPath ? path5.resolve(targetPath) : getDefaultGlobalConfigPath();
652
784
  }
653
785
  function getSetupPresenter(io) {
654
786
  return createPresentation(io.stdoutIsTTY);
655
787
  }
788
+ function getProviderLabel(provider) {
789
+ return provider === "openrouter" ? "OpenRouter" : "OpenAI";
790
+ }
656
791
  async function promptForProvider(io) {
657
792
  if (io.select) {
658
- const choice = await io.select("Select provider for this machine", ["OpenAI"]);
793
+ const choice = await io.select("Select provider for this machine", [
794
+ "OpenAI",
795
+ "OpenRouter"
796
+ ]);
659
797
  if (choice === "OpenAI") {
660
798
  return "openai";
661
799
  }
800
+ if (choice === "OpenRouter") {
801
+ return "openrouter";
802
+ }
662
803
  }
663
804
  while (true) {
664
- const answer = (await io.ask("Provider [OpenAI]: ")).trim().toLowerCase();
805
+ const answer = (await io.ask("Provider [OpenAI/OpenRouter]: ")).trim().toLowerCase();
665
806
  if (answer === "" || answer === "openai") {
666
807
  return "openai";
667
808
  }
668
- io.error("Only OpenAI is supported in guided setup right now.\n");
809
+ if (answer === "openrouter") {
810
+ return "openrouter";
811
+ }
812
+ io.error("Only OpenAI and OpenRouter are supported in guided setup right now.\n");
669
813
  }
670
814
  }
671
- async function promptForApiKey(io) {
815
+ async function promptForApiKey(io, provider) {
816
+ const providerLabel = getProviderLabel(provider);
817
+ const promptText = `Enter your ${providerLabel} API key (input hidden): `;
818
+ const visiblePromptText = `Enter your ${providerLabel} API key: `;
672
819
  while (true) {
673
- const answer = (await (io.secret ? io.secret("Enter your OpenAI API key (input hidden): ") : io.ask("Enter your OpenAI API key: "))).trim();
820
+ const answer = (await (io.secret ? io.secret(promptText) : io.ask(visiblePromptText))).trim();
674
821
  if (answer.length > 0) {
675
822
  return answer;
676
823
  }
677
824
  io.error("API key cannot be empty.\n");
678
825
  }
679
826
  }
680
- async function promptForOverwrite(io, targetPath) {
827
+ async function promptForApiKeyChoice(args) {
828
+ const providerLabel = getProviderLabel(args.provider);
829
+ if (!args.hasSavedKey && !args.hasEnvKey) {
830
+ return "override";
831
+ }
832
+ if (args.hasSavedKey && args.hasEnvKey) {
833
+ if (args.io.select) {
834
+ const choice = await args.io.select(
835
+ `Found both a saved ${providerLabel} API key and ${args.envName} in your environment`,
836
+ ["Use saved key", "Use env key", "Override"]
837
+ );
838
+ if (choice === "Use saved key") {
839
+ return "saved";
840
+ }
841
+ if (choice === "Use env key") {
842
+ return "env";
843
+ }
844
+ }
845
+ while (true) {
846
+ const answer = (await args.io.ask("API key choice [saved/env/override]: ")).trim().toLowerCase();
847
+ if (answer === "" || answer === "saved") {
848
+ return "saved";
849
+ }
850
+ if (answer === "env") {
851
+ return "env";
852
+ }
853
+ if (answer === "override") {
854
+ return "override";
855
+ }
856
+ args.io.error("Please answer saved, env, or override.\n");
857
+ }
858
+ }
859
+ const sourceLabel = args.hasSavedKey ? "saved key" : `${args.envName} from your environment`;
860
+ if (args.io.select) {
861
+ const choice = await args.io.select(
862
+ `Found an existing ${providerLabel} API key via ${sourceLabel}`,
863
+ ["Use existing key", "Override"]
864
+ );
865
+ if (choice === "Override") {
866
+ return "override";
867
+ }
868
+ return args.hasSavedKey ? "saved" : "env";
869
+ }
681
870
  while (true) {
682
- const answer = (await io.ask(
683
- `Config file already exists at ${targetPath}. Overwrite? [y/N]: `
684
- )).trim().toLowerCase();
685
- if (answer === "" || answer === "n" || answer === "no") {
686
- return false;
871
+ const answer = (await args.io.ask("API key choice [existing/override]: ")).trim().toLowerCase();
872
+ if (answer === "" || answer === "existing") {
873
+ return args.hasSavedKey ? "saved" : "env";
687
874
  }
688
- if (answer === "y" || answer === "yes") {
689
- return true;
875
+ if (answer === "override") {
876
+ return "override";
690
877
  }
691
- io.error("Please answer y or n.\n");
878
+ args.io.error("Please answer existing or override.\n");
692
879
  }
693
880
  }
694
881
  function writeSetupSuccess(io, writtenPath) {
@@ -722,10 +909,86 @@ ${ui.section("Try next")}
722
909
  io.write(` ${ui.command("sift exec --preset test-status -- npm test")}
723
910
  `);
724
911
  }
912
+ function writeProviderDefaults(io, provider) {
913
+ const ui = getSetupPresenter(io);
914
+ if (provider === "openrouter") {
915
+ io.write(`${ui.info("Using OpenRouter defaults for your first run.")}
916
+ `);
917
+ io.write(`${ui.labelValue("Default model", "openrouter/free")}
918
+ `);
919
+ io.write(`${ui.labelValue("Default base URL", "https://openrouter.ai/api/v1")}
920
+ `);
921
+ } else {
922
+ io.write(`${ui.info("Using OpenAI defaults for your first run.")}
923
+ `);
924
+ io.write(`${ui.labelValue("Default model", "gpt-5-nano")}
925
+ `);
926
+ io.write(`${ui.labelValue("Default base URL", "https://api.openai.com/v1")}
927
+ `);
928
+ }
929
+ io.write(
930
+ `${ui.note("Want to switch providers later? Run 'sift config use openai' or 'sift config use openrouter'.")}
931
+ `
932
+ );
933
+ io.write(
934
+ `${ui.note("Want to inspect the active values first? Run 'sift config show --show-secrets'.")}
935
+ `
936
+ );
937
+ }
938
+ function materializeProfile(provider, profile, apiKey) {
939
+ return {
940
+ ...getProfileProviderState(provider, profile),
941
+ ...apiKey !== void 0 ? { apiKey } : {}
942
+ };
943
+ }
944
+ function buildSetupConfig(args) {
945
+ const preservedConfig = preserveActiveNativeProviderProfile(args.config);
946
+ const storedProfile = getStoredProviderProfile(preservedConfig, args.provider);
947
+ if (args.apiKeyChoice === "saved") {
948
+ const profile2 = materializeProfile(
949
+ args.provider,
950
+ storedProfile,
951
+ storedProfile?.apiKey ?? ""
952
+ );
953
+ const configWithProfile2 = setStoredProviderProfile(
954
+ preservedConfig,
955
+ args.provider,
956
+ profile2
957
+ );
958
+ return applyActiveProvider(
959
+ configWithProfile2,
960
+ args.provider,
961
+ profile2,
962
+ profile2.apiKey ?? ""
963
+ );
964
+ }
965
+ if (args.apiKeyChoice === "env") {
966
+ const profile2 = storedProfile ? storedProfile : materializeProfile(args.provider, void 0);
967
+ const configWithProfile2 = storedProfile ? preservedConfig : setStoredProviderProfile(preservedConfig, args.provider, profile2);
968
+ return applyActiveProvider(configWithProfile2, args.provider, profile2, "");
969
+ }
970
+ const profile = materializeProfile(
971
+ args.provider,
972
+ storedProfile,
973
+ args.nextApiKey ?? ""
974
+ );
975
+ const configWithProfile = setStoredProviderProfile(
976
+ preservedConfig,
977
+ args.provider,
978
+ profile
979
+ );
980
+ return applyActiveProvider(
981
+ configWithProfile,
982
+ args.provider,
983
+ profile,
984
+ args.nextApiKey ?? ""
985
+ );
986
+ }
725
987
  async function configSetup(options = {}) {
726
988
  void options.global;
727
989
  const io = options.io ?? createTerminalIO();
728
990
  const ui = getSetupPresenter(io);
991
+ const env = options.env ?? process.env;
729
992
  try {
730
993
  if (!io.stdinIsTTY || !io.stdoutIsTTY) {
731
994
  io.error(
@@ -736,39 +999,43 @@ async function configSetup(options = {}) {
736
999
  io.write(`${ui.welcome("Let's keep the expensive model for the interesting bits.")}
737
1000
  `);
738
1001
  const resolvedPath = resolveSetupPath(options.targetPath);
739
- if (fs3.existsSync(resolvedPath)) {
740
- const shouldOverwrite = await promptForOverwrite(io, resolvedPath);
741
- if (!shouldOverwrite) {
742
- io.write(`${ui.note("Aborted.")}
1002
+ const { config: existingConfig, existed } = loadEditableConfig(resolvedPath);
1003
+ if (existed) {
1004
+ io.write(`${ui.info(`Updating existing config at ${resolvedPath}.`)}
743
1005
  `);
744
- return 1;
745
- }
746
1006
  }
747
- await promptForProvider(io);
748
- io.write(`${ui.info("Using OpenAI defaults for your first run.")}
749
- `);
750
- io.write(`${ui.labelValue("Default model", "gpt-5-nano")}
751
- `);
752
- io.write(`${ui.labelValue("Default base URL", "https://api.openai.com/v1")}
753
- `);
754
- io.write(
755
- `${ui.note(`Want to switch providers or tweak defaults later? Edit ${resolvedPath}.`)}
756
- `
757
- );
758
- io.write(
759
- `${ui.note("Want to inspect the active values first? Run 'sift config show --show-secrets'.")}
760
- `
761
- );
762
- const apiKey = await promptForApiKey(io);
763
- const config = buildOpenAISetupConfig(apiKey);
1007
+ const provider = await promptForProvider(io);
1008
+ writeProviderDefaults(io, provider);
1009
+ const storedProfile = getStoredProviderProfile(existingConfig, provider);
1010
+ const envName = getNativeProviderApiKeyEnvName(provider);
1011
+ const apiKeyChoice = await promptForApiKeyChoice({
1012
+ io,
1013
+ provider,
1014
+ envName,
1015
+ hasSavedKey: Boolean(storedProfile?.apiKey),
1016
+ hasEnvKey: Boolean(env[envName])
1017
+ });
1018
+ const nextApiKey = apiKeyChoice === "override" ? await promptForApiKey(io, provider) : void 0;
1019
+ const config = buildSetupConfig({
1020
+ config: existingConfig,
1021
+ provider,
1022
+ apiKeyChoice,
1023
+ nextApiKey
1024
+ });
764
1025
  const writtenPath = writeConfigFile({
765
1026
  targetPath: resolvedPath,
766
1027
  config,
767
- overwrite: true
1028
+ overwrite: existed
768
1029
  });
1030
+ if (apiKeyChoice === "env") {
1031
+ io.write(
1032
+ `${ui.note(`Using ${envName} from the environment. No API key was written to config.`)}
1033
+ `
1034
+ );
1035
+ }
769
1036
  writeSetupSuccess(io, writtenPath);
770
1037
  const activeConfigPath = findConfigPath();
771
- if (activeConfigPath && path4.resolve(activeConfigPath) !== path4.resolve(writtenPath)) {
1038
+ if (activeConfigPath && path5.resolve(activeConfigPath) !== path5.resolve(writtenPath)) {
772
1039
  writeOverrideWarning(io, activeConfigPath);
773
1040
  }
774
1041
  writeNextSteps(io);
@@ -798,18 +1065,18 @@ function maskConfigSecrets(value) {
798
1065
  return output;
799
1066
  }
800
1067
  function configInit(targetPath, global = false) {
801
- const path7 = writeExampleConfig({
1068
+ const path8 = writeExampleConfig({
802
1069
  targetPath,
803
1070
  global
804
1071
  });
805
1072
  if (!process.stdout.isTTY) {
806
- process.stdout.write(`${path7}
1073
+ process.stdout.write(`${path8}
807
1074
  `);
808
1075
  return;
809
1076
  }
810
1077
  const ui = createPresentation(true);
811
1078
  process.stdout.write(
812
- `${ui.success(`${global ? "Machine-wide" : "Template"} config written to ${path7}`)}
1079
+ `${ui.success(`${global ? "Machine-wide" : "Template"} config written to ${path8}`)}
813
1080
  `
814
1081
  );
815
1082
  }
@@ -838,11 +1105,61 @@ function configValidate(configPath) {
838
1105
  process.stdout.write(`${ui.success(message)}
839
1106
  `);
840
1107
  }
1108
+ function isNativeProviderName(value) {
1109
+ return value === "openai" || value === "openrouter";
1110
+ }
1111
+ function configUse(provider, configPath, env = process.env) {
1112
+ if (!isNativeProviderName(provider)) {
1113
+ throw new Error(`Unsupported config provider: ${provider}`);
1114
+ }
1115
+ const { config, existed, resolvedPath } = loadEditableConfig(configPath);
1116
+ const preservedConfig = preserveActiveNativeProviderProfile(config);
1117
+ const storedProfile = getStoredProviderProfile(preservedConfig, provider);
1118
+ const envName = getNativeProviderApiKeyEnvName(provider);
1119
+ const envKey = env[envName];
1120
+ if (!storedProfile?.apiKey && !envKey) {
1121
+ throw new Error(
1122
+ `No saved ${provider} API key or ${envName} found. Run 'sift config setup' first.`
1123
+ );
1124
+ }
1125
+ const nextConfig = applyActiveProvider(
1126
+ preservedConfig,
1127
+ provider,
1128
+ storedProfile,
1129
+ storedProfile?.apiKey ?? ""
1130
+ );
1131
+ writeConfigFile({
1132
+ targetPath: resolvedPath,
1133
+ config: nextConfig,
1134
+ overwrite: existed
1135
+ });
1136
+ const message = `Switched active provider to ${provider} (${resolvedPath}).`;
1137
+ if (!process.stdout.isTTY) {
1138
+ process.stdout.write(`${message}
1139
+ `);
1140
+ if (!storedProfile?.apiKey && envKey) {
1141
+ process.stdout.write(
1142
+ `Using ${envName} from the environment. No API key was written to config.
1143
+ `
1144
+ );
1145
+ }
1146
+ return;
1147
+ }
1148
+ const ui = createPresentation(true);
1149
+ process.stdout.write(`${ui.success(message)}
1150
+ `);
1151
+ if (!storedProfile?.apiKey && envKey) {
1152
+ process.stdout.write(
1153
+ `${ui.note(`Using ${envName} from the environment. No API key was written to config.`)}
1154
+ `
1155
+ );
1156
+ }
1157
+ }
841
1158
 
842
1159
  // src/commands/agent.ts
843
1160
  import fs4 from "fs";
844
1161
  import os2 from "os";
845
- import path5 from "path";
1162
+ import path6 from "path";
846
1163
  import { createInterface as createInterface2 } from "readline/promises";
847
1164
  import { stderr as defaultStderr2, stdin as defaultStdin3, stdout as defaultStdout2 } from "process";
848
1165
  var AGENT_FILENAMES = {
@@ -907,14 +1224,14 @@ function normalizeAgentScope(value) {
907
1224
  }
908
1225
  function resolveAgentTargetPath(args) {
909
1226
  if (args.targetPath) {
910
- return path5.resolve(args.cwd ?? process.cwd(), args.targetPath);
1227
+ return path6.resolve(args.cwd ?? process.cwd(), args.targetPath);
911
1228
  }
912
1229
  const scope = args.scope ?? "repo";
913
1230
  if (scope === "global") {
914
1231
  const homeDir = args.homeDir ?? os2.homedir();
915
1232
  return args.agent === "codex" ? getDefaultCodexGlobalInstructionsPath(homeDir) : getDefaultClaudeGlobalInstructionsPath(homeDir);
916
1233
  }
917
- return path5.resolve(args.cwd ?? process.cwd(), AGENT_FILENAMES[args.agent]);
1234
+ return path6.resolve(args.cwd ?? process.cwd(), AGENT_FILENAMES[args.agent]);
918
1235
  }
919
1236
  function getManagedBlockMarkers(agent) {
920
1237
  return {
@@ -949,6 +1266,7 @@ function renderInstructionBody() {
949
1266
  "- Start with `standard` text. Use diagnose JSON only when automation or machine branching truly needs it.",
950
1267
  "- If `standard` already shows bucket-level root cause, anchor, and fix lines, trust it and report from it directly.",
951
1268
  "- In that case, do not re-verify the same bucket with raw pytest; at most do one targeted source read before you edit.",
1269
+ "- If `standard` still contains an unknown bucket or ends with `Decision: zoom`, do one deeper sift pass before raw traceback.",
952
1270
  "- If you need a machine-readable diagnosis, use `sift exec --preset test-status --goal diagnose --format json -- <test command>` or the same shape with `sift rerun` / `sift watch --preset test-status`.",
953
1271
  "- Diagnose JSON is summary-first by default. Add `--include-test-ids` only when you truly need the raw failing test IDs.",
954
1272
  "- If diagnose JSON returns `read_targets.context_hint.start_line/end_line`, read only that small line range first.",
@@ -1478,7 +1796,7 @@ function readOptionalFile(targetPath) {
1478
1796
  return fs4.readFileSync(targetPath, "utf8");
1479
1797
  }
1480
1798
  function writeTextFileAtomic(targetPath, content) {
1481
- fs4.mkdirSync(path5.dirname(targetPath), { recursive: true });
1799
+ fs4.mkdirSync(path6.dirname(targetPath), { recursive: true });
1482
1800
  const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
1483
1801
  fs4.writeFileSync(tempPath, content, "utf8");
1484
1802
  fs4.renameSync(tempPath, targetPath);
@@ -1512,7 +1830,7 @@ function runDoctor(config, configPath) {
1512
1830
  if (!config.provider.model) {
1513
1831
  problems.push("Missing provider.model");
1514
1832
  }
1515
- if ((config.provider.provider === "openai" || config.provider.provider === "openai-compatible") && !config.provider.apiKey) {
1833
+ if ((config.provider.provider === "openai" || config.provider.provider === "openai-compatible" || config.provider.provider === "openrouter") && !config.provider.apiKey) {
1516
1834
  problems.push("Missing provider.apiKey");
1517
1835
  problems.push(
1518
1836
  `Set one of: ${getProviderApiKeyEnvNames(
@@ -1714,10 +2032,11 @@ async function buildOpenAICompatibleError(response) {
1714
2032
  return new Error(detail);
1715
2033
  }
1716
2034
  var OpenAICompatibleProvider = class {
1717
- name = "openai-compatible";
2035
+ name;
1718
2036
  baseUrl;
1719
2037
  apiKey;
1720
2038
  constructor(options) {
2039
+ this.name = options.name ?? "openai-compatible";
1721
2040
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
1722
2041
  this.apiKey = options.apiKey;
1723
2042
  }
@@ -1793,13 +2112,20 @@ function createProvider(config) {
1793
2112
  apiKey: config.provider.apiKey
1794
2113
  });
1795
2114
  }
2115
+ if (config.provider.provider === "openrouter") {
2116
+ return new OpenAICompatibleProvider({
2117
+ baseUrl: config.provider.baseUrl,
2118
+ apiKey: config.provider.apiKey,
2119
+ name: "openrouter"
2120
+ });
2121
+ }
1796
2122
  throw new Error(`Unsupported provider: ${config.provider.provider}`);
1797
2123
  }
1798
2124
 
1799
2125
  // src/core/testStatusDecision.ts
1800
2126
  import { z as z2 } from "zod";
1801
2127
  var TEST_STATUS_DIAGNOSE_JSON_CONTRACT = '{"status":"ok|insufficient","diagnosis_complete":boolean,"raw_needed":boolean,"additional_source_read_likely_low_value":boolean,"read_raw_only_if":string|null,"decision":"stop|zoom|read_source|read_raw","dominant_blocker_bucket_index":number|null,"provider_used":boolean,"provider_confidence":number|null,"provider_failed":boolean,"raw_slice_used":boolean,"raw_slice_strategy":"none|bucket_evidence|traceback_window|head_tail","resolved_summary":{"count":number,"families":[{"prefix":string,"count":number}]},"remaining_summary":{"count":number,"families":[{"prefix":string,"count":number}]},"remaining_subset_available":boolean,"main_buckets":[{"bucket_index":number,"label":string,"count":number,"root_cause":string,"evidence":string[],"bucket_confidence":number,"root_cause_confidence":number,"dominant":boolean,"secondary_visible_despite_blocker":boolean,"mini_diff":{"added_paths"?:number,"removed_models"?:number,"changed_task_mappings"?:number}|null}],"read_targets":[{"file":string,"line":number|null,"why":string,"bucket_index":number,"context_hint":{"start_line":number|null,"end_line":number|null,"search_hint":string|null}}],"next_best_action":{"code":"fix_dominant_blocker|read_source_for_bucket|read_raw_for_exact_traceback|insufficient_signal","bucket_index":number|null,"note":string},"resolved_tests"?:string[],"remaining_tests"?:string[]}';
1802
- var TEST_STATUS_PROVIDER_SUPPLEMENT_JSON_CONTRACT = '{"diagnosis_complete":boolean,"raw_needed":boolean,"additional_source_read_likely_low_value":boolean,"read_raw_only_if":string|null,"decision":"stop|zoom|read_source|read_raw","provider_confidence":number|null,"next_best_action":{"code":"fix_dominant_blocker|read_source_for_bucket|read_raw_for_exact_traceback|insufficient_signal","bucket_index":number|null,"note":string}}';
2128
+ var TEST_STATUS_PROVIDER_SUPPLEMENT_JSON_CONTRACT = '{"diagnosis_complete":boolean,"raw_needed":boolean,"additional_source_read_likely_low_value":boolean,"read_raw_only_if":string|null,"decision":"stop|zoom|read_source|read_raw","provider_confidence":number|null,"bucket_supplements":[{"label":string,"count":number,"root_cause":string,"anchor":{"file":string|null,"line":number|null,"search_hint":string|null},"fix_hint":string|null,"confidence":number}],"next_best_action":{"code":"fix_dominant_blocker|read_source_for_bucket|read_raw_for_exact_traceback|insufficient_signal","bucket_index":number|null,"note":string}}';
1803
2129
  var nextBestActionSchema = z2.object({
1804
2130
  code: z2.enum([
1805
2131
  "fix_dominant_blocker",
@@ -1817,6 +2143,20 @@ var testStatusProviderSupplementSchema = z2.object({
1817
2143
  read_raw_only_if: z2.string().nullable(),
1818
2144
  decision: z2.enum(["stop", "zoom", "read_source", "read_raw"]),
1819
2145
  provider_confidence: z2.number().min(0).max(1).nullable(),
2146
+ bucket_supplements: z2.array(
2147
+ z2.object({
2148
+ label: z2.string().min(1),
2149
+ count: z2.number().int().positive(),
2150
+ root_cause: z2.string().min(1),
2151
+ anchor: z2.object({
2152
+ file: z2.string().nullable(),
2153
+ line: z2.number().int().nullable(),
2154
+ search_hint: z2.string().nullable()
2155
+ }),
2156
+ fix_hint: z2.string().nullable(),
2157
+ confidence: z2.number().min(0).max(1)
2158
+ })
2159
+ ).max(2),
1820
2160
  next_best_action: nextBestActionSchema
1821
2161
  });
1822
2162
  var testStatusDiagnoseContractSchema = z2.object({
@@ -1889,6 +2229,209 @@ var testStatusPublicDiagnoseContractSchema = testStatusDiagnoseContractSchema.om
1889
2229
  function parseTestStatusProviderSupplement(input) {
1890
2230
  return testStatusProviderSupplementSchema.parse(JSON.parse(input));
1891
2231
  }
2232
+ var extendedBucketSpecs = [
2233
+ {
2234
+ prefix: "snapshot mismatch:",
2235
+ type: "snapshot_mismatch",
2236
+ label: "snapshot mismatch",
2237
+ genericTitle: "Snapshot mismatches",
2238
+ defaultCoverage: "failed",
2239
+ rootCauseConfidence: 0.84,
2240
+ why: "it contains the failing snapshot expectation behind this bucket",
2241
+ fix: "Update the snapshots if these output changes are intentional, then rerun the suite."
2242
+ },
2243
+ {
2244
+ prefix: "timeout:",
2245
+ type: "timeout_failure",
2246
+ label: "timeout",
2247
+ genericTitle: "Timeout failures",
2248
+ defaultCoverage: "mixed",
2249
+ rootCauseConfidence: 0.9,
2250
+ why: "it contains the test or fixture that exceeded the timeout threshold",
2251
+ fix: "Check for deadlocks, slow setup, or increase the timeout threshold before rerunning."
2252
+ },
2253
+ {
2254
+ prefix: "permission:",
2255
+ type: "permission_denied_failure",
2256
+ label: "permission denied",
2257
+ genericTitle: "Permission failures",
2258
+ defaultCoverage: "error",
2259
+ rootCauseConfidence: 0.85,
2260
+ why: "it contains the file, socket, or port access that was denied",
2261
+ fix: "Check file or port permissions in the CI environment before rerunning."
2262
+ },
2263
+ {
2264
+ prefix: "async loop:",
2265
+ type: "async_event_loop_failure",
2266
+ label: "async event loop",
2267
+ genericTitle: "Async event loop failures",
2268
+ defaultCoverage: "mixed",
2269
+ rootCauseConfidence: 0.88,
2270
+ why: "it contains the async setup or coroutine that caused the event loop error",
2271
+ fix: "Check event loop scope and pytest-asyncio configuration before rerunning."
2272
+ },
2273
+ {
2274
+ prefix: "fixture teardown:",
2275
+ type: "fixture_teardown_failure",
2276
+ label: "fixture teardown",
2277
+ genericTitle: "Fixture teardown failures",
2278
+ defaultCoverage: "error",
2279
+ rootCauseConfidence: 0.85,
2280
+ why: "it contains the fixture teardown path that failed after the test body completed",
2281
+ fix: "Inspect the teardown cleanup path and restore idempotent fixture cleanup before rerunning."
2282
+ },
2283
+ {
2284
+ prefix: "db migration:",
2285
+ type: "db_migration_failure",
2286
+ label: "db migration",
2287
+ genericTitle: "DB migration failures",
2288
+ defaultCoverage: "error",
2289
+ rootCauseConfidence: 0.9,
2290
+ why: "it contains the migration or model definition behind the missing table or relation",
2291
+ fix: "Run pending migrations or fix the expected model schema before rerunning."
2292
+ },
2293
+ {
2294
+ prefix: "configuration:",
2295
+ type: "configuration_error",
2296
+ label: "configuration error",
2297
+ genericTitle: "Configuration errors",
2298
+ defaultCoverage: "error",
2299
+ rootCauseConfidence: 0.95,
2300
+ dominantPriority: 4,
2301
+ dominantBlocker: true,
2302
+ why: "it contains the pytest configuration or conftest setup error that blocks the run",
2303
+ fix: "Fix the pytest configuration, CLI usage, or conftest import error before rerunning."
2304
+ },
2305
+ {
2306
+ prefix: "xdist worker crash:",
2307
+ type: "xdist_worker_crash",
2308
+ label: "xdist worker crash",
2309
+ genericTitle: "xdist worker crashes",
2310
+ defaultCoverage: "error",
2311
+ rootCauseConfidence: 0.92,
2312
+ dominantPriority: 3,
2313
+ why: "it contains the worker startup or shared-state path that crashed an xdist worker",
2314
+ fix: "Check shared state, worker startup hooks, or resource contention between workers before rerunning."
2315
+ },
2316
+ {
2317
+ prefix: "type error:",
2318
+ type: "type_error_failure",
2319
+ label: "type error",
2320
+ genericTitle: "Type errors",
2321
+ defaultCoverage: "mixed",
2322
+ rootCauseConfidence: 0.8,
2323
+ why: "it contains the call site or fixture value that triggered the type error",
2324
+ fix: "Inspect the mismatched argument or object shape and rerun the full suite at standard."
2325
+ },
2326
+ {
2327
+ prefix: "resource leak:",
2328
+ type: "resource_leak_warning",
2329
+ label: "resource leak",
2330
+ genericTitle: "Resource leak warnings",
2331
+ defaultCoverage: "mixed",
2332
+ rootCauseConfidence: 0.74,
2333
+ why: "it contains the warning source behind the leaked file, socket, or coroutine",
2334
+ fix: "Close the leaked resource or suppress the warning only if the cleanup is intentional."
2335
+ },
2336
+ {
2337
+ prefix: "django db access:",
2338
+ type: "django_db_access_denied",
2339
+ label: "django db access",
2340
+ genericTitle: "Django DB access failures",
2341
+ defaultCoverage: "error",
2342
+ rootCauseConfidence: 0.95,
2343
+ why: "it needs the @pytest.mark.django_db decorator or fixture permission to access the database",
2344
+ fix: "Add @pytest.mark.django_db to the test or class before rerunning."
2345
+ },
2346
+ {
2347
+ prefix: "network:",
2348
+ type: "network_failure",
2349
+ label: "network failure",
2350
+ genericTitle: "Network failures",
2351
+ defaultCoverage: "error",
2352
+ rootCauseConfidence: 0.88,
2353
+ dominantPriority: 2,
2354
+ why: "it contains the host, URL, or TLS path behind the network failure",
2355
+ fix: "Check DNS, outbound network access, retries, or TLS trust before rerunning."
2356
+ },
2357
+ {
2358
+ prefix: "segfault:",
2359
+ type: "subprocess_crash_segfault",
2360
+ label: "segfault",
2361
+ genericTitle: "Segfault crashes",
2362
+ defaultCoverage: "mixed",
2363
+ rootCauseConfidence: 0.8,
2364
+ why: "it contains the subprocess or native extension path that crashed with SIGSEGV",
2365
+ fix: "Inspect the native extension, subprocess boundary, or incompatible binary before rerunning."
2366
+ },
2367
+ {
2368
+ prefix: "flaky:",
2369
+ type: "flaky_test_detected",
2370
+ label: "flaky test",
2371
+ genericTitle: "Flaky test detections",
2372
+ defaultCoverage: "mixed",
2373
+ rootCauseConfidence: 0.72,
2374
+ why: "it contains the rerun-prone test that behaved inconsistently across attempts",
2375
+ fix: "Stabilize the nondeterministic test or fixture before relying on reruns."
2376
+ },
2377
+ {
2378
+ prefix: "serialization:",
2379
+ type: "serialization_encoding_failure",
2380
+ label: "serialization or encoding",
2381
+ genericTitle: "Serialization or encoding failures",
2382
+ defaultCoverage: "mixed",
2383
+ rootCauseConfidence: 0.78,
2384
+ why: "it contains the serialization or decoding path behind the malformed payload",
2385
+ fix: "Inspect the encoded payload, serializer, or fixture data before rerunning."
2386
+ },
2387
+ {
2388
+ prefix: "file not found:",
2389
+ type: "file_not_found_failure",
2390
+ label: "file not found",
2391
+ genericTitle: "Missing file failures",
2392
+ defaultCoverage: "mixed",
2393
+ rootCauseConfidence: 0.82,
2394
+ why: "it contains the missing file path or fixture artifact required by the test",
2395
+ fix: "Restore the missing file, fixture artifact, or working-directory assumption before rerunning."
2396
+ },
2397
+ {
2398
+ prefix: "memory:",
2399
+ type: "memory_error",
2400
+ label: "memory error",
2401
+ genericTitle: "Memory failures",
2402
+ defaultCoverage: "mixed",
2403
+ rootCauseConfidence: 0.78,
2404
+ why: "it contains the allocation path that exhausted available memory",
2405
+ fix: "Reduce memory pressure or investigate the large allocation before rerunning."
2406
+ },
2407
+ {
2408
+ prefix: "deprecation as error:",
2409
+ type: "deprecation_warning_as_error",
2410
+ label: "deprecation as error",
2411
+ genericTitle: "Deprecation warnings as errors",
2412
+ defaultCoverage: "mixed",
2413
+ rootCauseConfidence: 0.74,
2414
+ why: "it contains the deprecated API or warning filter that is failing the test run",
2415
+ fix: "Update the deprecated call site or relax the warning policy only if that is intentional."
2416
+ },
2417
+ {
2418
+ prefix: "xfail strict:",
2419
+ type: "xfail_strict_unexpected_pass",
2420
+ label: "strict xfail unexpected pass",
2421
+ genericTitle: "Strict xfail unexpected passes",
2422
+ defaultCoverage: "failed",
2423
+ rootCauseConfidence: 0.78,
2424
+ why: "it contains the strict xfail case that unexpectedly passed",
2425
+ fix: "Remove or update the strict xfail expectation if the test is now passing intentionally."
2426
+ }
2427
+ ];
2428
+ function findExtendedBucketSpec(reason) {
2429
+ return extendedBucketSpecs.find((spec) => reason.startsWith(spec.prefix)) ?? null;
2430
+ }
2431
+ function extractReasonDetail(reason, prefix) {
2432
+ const detail = reason.slice(prefix.length).trim();
2433
+ return detail.length > 0 ? detail : null;
2434
+ }
1892
2435
  function formatCount(count, singular, plural = `${singular}s`) {
1893
2436
  return `${count} ${count === 1 ? singular : plural}`;
1894
2437
  }
@@ -1942,6 +2485,10 @@ function formatTargetSummary(summary) {
1942
2485
  return `count=${summary.count}; families=${families}`;
1943
2486
  }
1944
2487
  function classifyGenericBucketType(reason) {
2488
+ const extended = findExtendedBucketSpec(reason);
2489
+ if (extended) {
2490
+ return extended.type;
2491
+ }
1945
2492
  if (reason.startsWith("missing test env:")) {
1946
2493
  return "shared_environment_blocker";
1947
2494
  }
@@ -1968,14 +2515,77 @@ function classifyGenericBucketType(reason) {
1968
2515
  }
1969
2516
  return "unknown_failure";
1970
2517
  }
2518
+ function isUnknownBucket(bucket) {
2519
+ return bucket.source === "unknown" || bucket.reason.startsWith("unknown ");
2520
+ }
2521
+ function classifyVisibleStatusForLabel(args) {
2522
+ const isError = args.errorLabels.has(args.label);
2523
+ const isFailed = args.failedLabels.has(args.label);
2524
+ if (isError && isFailed) {
2525
+ return "mixed";
2526
+ }
2527
+ if (isError) {
2528
+ return "error";
2529
+ }
2530
+ if (isFailed) {
2531
+ return "failed";
2532
+ }
2533
+ return "unknown";
2534
+ }
2535
+ function inferCoverageFromReason(reason) {
2536
+ const extended = findExtendedBucketSpec(reason);
2537
+ if (extended) {
2538
+ return extended.defaultCoverage;
2539
+ }
2540
+ if (reason.startsWith("missing test env:") || reason.startsWith("fixture guard:") || reason.startsWith("service unavailable:") || reason.startsWith("db refused:") || reason.startsWith("auth bypass absent:") || reason.startsWith("missing module:")) {
2541
+ return "error";
2542
+ }
2543
+ if (reason.startsWith("assertion failed:")) {
2544
+ return "failed";
2545
+ }
2546
+ return "mixed";
2547
+ }
2548
+ function buildCoverageCounts(args) {
2549
+ if (args.coverageKind === "error") {
2550
+ return {
2551
+ error: args.count,
2552
+ failed: 0
2553
+ };
2554
+ }
2555
+ if (args.coverageKind === "failed") {
2556
+ return {
2557
+ error: 0,
2558
+ failed: args.count
2559
+ };
2560
+ }
2561
+ return {
2562
+ error: 0,
2563
+ failed: 0
2564
+ };
2565
+ }
1971
2566
  function buildGenericBuckets(analysis) {
1972
2567
  const buckets = [];
1973
2568
  const grouped = /* @__PURE__ */ new Map();
2569
+ const errorLabels = new Set(analysis.visibleErrorLabels);
2570
+ const failedLabels = new Set(analysis.visibleFailedLabels);
1974
2571
  const push = (reason, item) => {
1975
- const key = `${classifyGenericBucketType(reason)}:${reason}`;
2572
+ const coverageKind = (() => {
2573
+ const status = classifyVisibleStatusForLabel({
2574
+ label: item.label,
2575
+ errorLabels,
2576
+ failedLabels
2577
+ });
2578
+ return status === "unknown" ? inferCoverageFromReason(reason) : status;
2579
+ })();
2580
+ const key = `${classifyGenericBucketType(reason)}:${coverageKind}:${reason}`;
1976
2581
  const existing = grouped.get(key);
1977
2582
  if (existing) {
1978
2583
  existing.count += 1;
2584
+ if (coverageKind === "error") {
2585
+ existing.coverage.error += 1;
2586
+ } else if (coverageKind === "failed") {
2587
+ existing.coverage.failed += 1;
2588
+ }
1979
2589
  if (!existing.representativeItems.some((entry) => entry.label === item.label) && existing.representativeItems.length < 6) {
1980
2590
  existing.representativeItems.push(item);
1981
2591
  }
@@ -1987,19 +2597,30 @@ function buildGenericBuckets(analysis) {
1987
2597
  summaryLines: [],
1988
2598
  reason,
1989
2599
  count: 1,
1990
- confidence: reason.startsWith("assertion failed:") || /^[A-Z][A-Za-z]+(?:Error|Exception):/.test(reason) ? 0.74 : 0.62,
2600
+ confidence: (() => {
2601
+ const extended = findExtendedBucketSpec(reason);
2602
+ if (extended) {
2603
+ return Math.max(0.72, Math.min(extended.rootCauseConfidence, 0.82));
2604
+ }
2605
+ return reason.startsWith("assertion failed:") || /^[A-Z][A-Za-z]+(?:Error|Exception):/.test(reason) ? 0.74 : 0.62;
2606
+ })(),
1991
2607
  representativeItems: [item],
1992
2608
  entities: [],
1993
2609
  hint: void 0,
1994
2610
  overflowCount: 0,
1995
- overflowLabel: "failing tests/modules"
2611
+ overflowLabel: "failing tests/modules",
2612
+ coverage: buildCoverageCounts({
2613
+ count: 1,
2614
+ coverageKind
2615
+ }),
2616
+ source: "heuristic"
1996
2617
  });
1997
2618
  };
1998
2619
  for (const item of [...analysis.collectionItems, ...analysis.inlineItems]) {
1999
2620
  push(item.reason, item);
2000
2621
  }
2001
2622
  for (const bucket of grouped.values()) {
2002
- const title = bucket.type === "assertion_failure" ? "Assertion failures" : bucket.type === "import_dependency_failure" ? "Import/dependency failures" : bucket.type === "collection_failure" ? "Collection or fixture failures" : "Runtime failures";
2623
+ const title = findExtendedBucketSpec(bucket.reason)?.genericTitle ?? (bucket.type === "assertion_failure" || bucket.type === "snapshot_mismatch" ? "Assertion failures" : bucket.type === "import_dependency_failure" ? "Import/dependency failures" : bucket.type === "collection_failure" ? "Collection or fixture failures" : "Runtime failures");
2003
2624
  bucket.headline = `${title}: ${formatCount(bucket.count, "visible failure")} share ${bucket.reason}.`;
2004
2625
  bucket.summaryLines = [bucket.headline];
2005
2626
  bucket.overflowCount = Math.max(bucket.count - bucket.representativeItems.length, 0);
@@ -2045,10 +2666,51 @@ function mergeBucketDetails(existing, incoming) {
2045
2666
  incoming.overflowCount,
2046
2667
  count - representativeItems.length
2047
2668
  ),
2048
- overflowLabel: existing.overflowLabel || incoming.overflowLabel
2669
+ overflowLabel: existing.overflowLabel || incoming.overflowLabel,
2670
+ labelOverride: existing.labelOverride ?? incoming.labelOverride,
2671
+ coverage: {
2672
+ error: Math.max(existing.coverage.error, incoming.coverage.error),
2673
+ failed: Math.max(existing.coverage.failed, incoming.coverage.failed)
2674
+ },
2675
+ source: existing.source
2676
+ };
2677
+ }
2678
+ function inferFailureBucketCoverage(bucket, analysis) {
2679
+ const errorLabels = new Set(analysis.visibleErrorLabels);
2680
+ const failedLabels = new Set(analysis.visibleFailedLabels);
2681
+ let error = 0;
2682
+ let failed = 0;
2683
+ for (const item of bucket.representativeItems) {
2684
+ const status = classifyVisibleStatusForLabel({
2685
+ label: item.label,
2686
+ errorLabels,
2687
+ failedLabels
2688
+ });
2689
+ if (status === "error") {
2690
+ error += 1;
2691
+ } else if (status === "failed") {
2692
+ failed += 1;
2693
+ }
2694
+ }
2695
+ const claimed = bucket.countClaimed ?? bucket.countVisible;
2696
+ if (bucket.type === "contract_snapshot_drift" || bucket.type === "assertion_failure" || bucket.type === "snapshot_mismatch") {
2697
+ return {
2698
+ error,
2699
+ failed: Math.max(failed, claimed)
2700
+ };
2701
+ }
2702
+ if (bucket.type === "shared_environment_blocker" || bucket.type === "import_dependency_failure" || bucket.type === "collection_failure" || bucket.type === "fixture_guard_failure" || bucket.type === "permission_denied_failure" || bucket.type === "fixture_teardown_failure" || bucket.type === "db_migration_failure" || bucket.type === "configuration_error" || bucket.type === "xdist_worker_crash" || bucket.type === "django_db_access_denied" || bucket.type === "network_failure" || bucket.type === "service_unavailable" || bucket.type === "db_connection_failure" || bucket.type === "auth_bypass_absent") {
2703
+ return {
2704
+ error: Math.max(error, claimed),
2705
+ failed
2706
+ };
2707
+ }
2708
+ return {
2709
+ error,
2710
+ failed
2049
2711
  };
2050
2712
  }
2051
- function mergeBuckets(analysis) {
2713
+ function mergeBuckets(analysis, extraBuckets = []) {
2052
2714
  const mergedByIdentity = /* @__PURE__ */ new Map();
2053
2715
  const merged = [];
2054
2716
  const pushBucket = (bucket) => {
@@ -2077,7 +2739,9 @@ function mergeBuckets(analysis) {
2077
2739
  entities: [...bucket2.entities],
2078
2740
  hint: bucket2.hint,
2079
2741
  overflowCount: bucket2.overflowCount,
2080
- overflowLabel: bucket2.overflowLabel
2742
+ overflowLabel: bucket2.overflowLabel,
2743
+ coverage: inferFailureBucketCoverage(bucket2, analysis),
2744
+ source: "heuristic"
2081
2745
  }))) {
2082
2746
  pushBucket(bucket);
2083
2747
  }
@@ -2101,12 +2765,19 @@ function mergeBuckets(analysis) {
2101
2765
  coveredLabels.add(item.label);
2102
2766
  }
2103
2767
  }
2768
+ for (const bucket of extraBuckets) {
2769
+ pushBucket(bucket);
2770
+ }
2104
2771
  return merged;
2105
2772
  }
2106
2773
  function dominantBucketPriority(bucket) {
2107
2774
  if (bucket.reason.startsWith("missing test env:")) {
2108
2775
  return 5;
2109
2776
  }
2777
+ const extended = findExtendedBucketSpec(bucket.reason);
2778
+ if (extended?.dominantPriority !== void 0) {
2779
+ return extended.dominantPriority;
2780
+ }
2110
2781
  if (bucket.type === "shared_environment_blocker") {
2111
2782
  return 4;
2112
2783
  }
@@ -2116,6 +2787,9 @@ function dominantBucketPriority(bucket) {
2116
2787
  if (bucket.type === "collection_failure") {
2117
2788
  return 2;
2118
2789
  }
2790
+ if (isUnknownBucket(bucket)) {
2791
+ return 2;
2792
+ }
2119
2793
  if (bucket.type === "contract_snapshot_drift") {
2120
2794
  return 1;
2121
2795
  }
@@ -2137,9 +2811,16 @@ function prioritizeBuckets(buckets) {
2137
2811
  });
2138
2812
  }
2139
2813
  function isDominantBlockerType(type) {
2140
- return type === "shared_environment_blocker" || type === "import_dependency_failure" || type === "collection_failure";
2814
+ return type === "shared_environment_blocker" || type === "configuration_error" || type === "import_dependency_failure" || type === "collection_failure";
2141
2815
  }
2142
2816
  function labelForBucket(bucket) {
2817
+ if (bucket.labelOverride) {
2818
+ return bucket.labelOverride;
2819
+ }
2820
+ const extended = findExtendedBucketSpec(bucket.reason);
2821
+ if (extended) {
2822
+ return extended.label;
2823
+ }
2143
2824
  if (bucket.reason.startsWith("missing test env:")) {
2144
2825
  return "missing test env";
2145
2826
  }
@@ -2173,21 +2854,40 @@ function labelForBucket(bucket) {
2173
2854
  if (bucket.type === "assertion_failure") {
2174
2855
  return "assertion failure";
2175
2856
  }
2857
+ if (bucket.type === "snapshot_mismatch") {
2858
+ return "snapshot mismatch";
2859
+ }
2176
2860
  if (bucket.type === "collection_failure") {
2177
2861
  return "collection failure";
2178
2862
  }
2179
2863
  if (bucket.type === "runtime_failure") {
2180
2864
  return "runtime failure";
2181
2865
  }
2866
+ if (bucket.reason.startsWith("unknown setup blocker:")) {
2867
+ return "unknown setup blocker";
2868
+ }
2869
+ if (bucket.reason.startsWith("unknown failure family:")) {
2870
+ return "unknown failure family";
2871
+ }
2182
2872
  return "unknown failure";
2183
2873
  }
2184
2874
  function rootCauseConfidenceFor(bucket) {
2875
+ if (isUnknownBucket(bucket)) {
2876
+ return 0.52;
2877
+ }
2878
+ const extended = findExtendedBucketSpec(bucket.reason);
2879
+ if (extended) {
2880
+ return extended.rootCauseConfidence;
2881
+ }
2185
2882
  if (bucket.reason.startsWith("missing test env:") || bucket.reason.startsWith("missing module:") || bucket.reason.startsWith("db refused:") || bucket.reason.startsWith("service unavailable:") || bucket.reason.startsWith("auth bypass absent:")) {
2186
2883
  return 0.95;
2187
2884
  }
2188
2885
  if (bucket.type === "contract_snapshot_drift") {
2189
2886
  return bucket.entities.length > 0 ? 0.92 : 0.76;
2190
2887
  }
2888
+ if (bucket.source === "provider") {
2889
+ return Math.max(0.6, Math.min(bucket.confidence, 0.82));
2890
+ }
2191
2891
  return Math.max(0.6, Math.min(bucket.confidence, 0.88));
2192
2892
  }
2193
2893
  function buildBucketEvidence(bucket) {
@@ -2219,6 +2919,10 @@ function buildReadTargetWhy(args) {
2219
2919
  if (envVar) {
2220
2920
  return `it contains the ${envVar} setup guard`;
2221
2921
  }
2922
+ const extended = findExtendedBucketSpec(args.bucket.reason);
2923
+ if (extended) {
2924
+ return extended.why;
2925
+ }
2222
2926
  if (args.bucket.reason.startsWith("fixture guard:")) {
2223
2927
  return "it contains the fixture/setup guard behind this bucket";
2224
2928
  }
@@ -2231,6 +2935,12 @@ function buildReadTargetWhy(args) {
2231
2935
  if (args.bucket.reason.startsWith("auth bypass absent:")) {
2232
2936
  return "it contains the auth bypass setup behind this bucket";
2233
2937
  }
2938
+ if (args.bucket.reason.startsWith("unknown setup blocker:")) {
2939
+ return "it is the first anchored setup failure in this unknown bucket";
2940
+ }
2941
+ if (args.bucket.reason.startsWith("unknown failure family:")) {
2942
+ return "it is the first anchored failing test in this unknown bucket";
2943
+ }
2234
2944
  if (args.bucket.type === "contract_snapshot_drift") {
2235
2945
  if (args.bucketLabel === "route drift") {
2236
2946
  return "it maps to the visible route drift bucket";
@@ -2243,6 +2953,9 @@ function buildReadTargetWhy(args) {
2243
2953
  }
2244
2954
  return "it maps to the visible stale snapshot expectation";
2245
2955
  }
2956
+ if (args.bucket.type === "snapshot_mismatch") {
2957
+ return "it maps to the visible snapshot mismatch bucket";
2958
+ }
2246
2959
  if (args.bucket.type === "import_dependency_failure") {
2247
2960
  return "it is the first visible failing module in this missing dependency bucket";
2248
2961
  }
@@ -2254,11 +2967,54 @@ function buildReadTargetWhy(args) {
2254
2967
  }
2255
2968
  return `it maps to the visible ${args.bucketLabel} bucket`;
2256
2969
  }
2970
+ function buildExtendedBucketSearchHint(bucket, anchor) {
2971
+ const extended = findExtendedBucketSpec(bucket.reason);
2972
+ if (!extended) {
2973
+ return null;
2974
+ }
2975
+ const detail = extractReasonDetail(bucket.reason, extended.prefix);
2976
+ if (!detail) {
2977
+ return anchor.label.split("::")[1]?.trim() ?? anchor.label ?? null;
2978
+ }
2979
+ if (extended.type === "timeout_failure") {
2980
+ const duration = detail.match(/>\s*([0-9]+(?:\.[0-9]+)?s?)/i)?.[1];
2981
+ return duration ?? anchor.label.split("::")[1]?.trim() ?? detail;
2982
+ }
2983
+ if (extended.type === "db_migration_failure") {
2984
+ const relation = detail.match(/\b(?:relation|table)\s+([A-Za-z0-9_.-]+)/i)?.[1];
2985
+ return relation ?? detail;
2986
+ }
2987
+ if (extended.type === "network_failure") {
2988
+ const url = detail.match(/\bhttps?:\/\/[^\s)'"`]+/i)?.[0];
2989
+ const host = detail.match(/\b(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b/)?.[0];
2990
+ return url ?? host ?? detail;
2991
+ }
2992
+ if (extended.type === "xdist_worker_crash") {
2993
+ return detail.match(/\bgw\d+\b/)?.[0] ?? detail;
2994
+ }
2995
+ if (extended.type === "fixture_teardown_failure") {
2996
+ return detail.replace(/^of\s+/i, "") || anchor.label;
2997
+ }
2998
+ if (extended.type === "file_not_found_failure") {
2999
+ const path8 = detail.match(/['"]([^'"]+)['"]/)?.[1];
3000
+ return path8 ?? detail;
3001
+ }
3002
+ if (extended.type === "permission_denied_failure") {
3003
+ const path8 = detail.match(/['"]([^'"]+)['"]/)?.[1];
3004
+ const port = detail.match(/\bport\s+(\d+)\b/i)?.[1];
3005
+ return path8 ?? (port ? `port ${port}` : detail);
3006
+ }
3007
+ return detail;
3008
+ }
2257
3009
  function buildReadTargetSearchHint(bucket, anchor) {
2258
3010
  const envVar = bucket.reason.match(/^missing test env:\s+([A-Z][A-Z0-9_]{2,})$/)?.[1];
2259
3011
  if (envVar) {
2260
3012
  return envVar;
2261
3013
  }
3014
+ const extendedHint = buildExtendedBucketSearchHint(bucket, anchor);
3015
+ if (extendedHint) {
3016
+ return extendedHint;
3017
+ }
2262
3018
  if (bucket.type === "contract_snapshot_drift") {
2263
3019
  return bucket.entities.find((value) => value.startsWith("/api/")) ?? bucket.entities[0] ?? null;
2264
3020
  }
@@ -2280,6 +3036,9 @@ function buildReadTargetSearchHint(bucket, anchor) {
2280
3036
  if (assertionText) {
2281
3037
  return assertionText;
2282
3038
  }
3039
+ if (bucket.reason.startsWith("unknown ")) {
3040
+ return anchor.reason;
3041
+ }
2283
3042
  const fallbackLabel = anchor.label.split("::")[1]?.trim();
2284
3043
  return fallbackLabel || null;
2285
3044
  }
@@ -2339,6 +3098,12 @@ function buildConcreteNextNote(args) {
2339
3098
  if (args.nextBestAction.code === "read_source_for_bucket") {
2340
3099
  return lead;
2341
3100
  }
3101
+ if (args.nextBestAction.code === "insufficient_signal") {
3102
+ if (args.nextBestAction.note.startsWith("Provider follow-up failed")) {
3103
+ return args.nextBestAction.note;
3104
+ }
3105
+ return `${lead} Then take one deeper sift pass before raw traceback.`;
3106
+ }
2342
3107
  return args.nextBestAction.note;
2343
3108
  }
2344
3109
  function extractMiniDiff(input, bucket) {
@@ -2363,6 +3128,156 @@ function extractMiniDiff(input, bucket) {
2363
3128
  ...changedTaskMappings > 0 ? { changed_task_mappings: changedTaskMappings } : {}
2364
3129
  };
2365
3130
  }
3131
+ function inferSupplementCoverageKind(args) {
3132
+ const extended = findExtendedBucketSpec(args.rootCause);
3133
+ if (extended?.defaultCoverage === "error" || extended?.defaultCoverage === "failed") {
3134
+ return extended.defaultCoverage;
3135
+ }
3136
+ const normalized = `${args.label} ${args.rootCause}`.toLowerCase();
3137
+ if (/env|setup|fixture|import|dependency|service|db|database|auth bypass|collection|connection refused/.test(
3138
+ normalized
3139
+ )) {
3140
+ return "error";
3141
+ }
3142
+ if (/snapshot|contract|drift|assertion|expected|actual|golden/.test(normalized)) {
3143
+ return "failed";
3144
+ }
3145
+ if (args.remainingErrors > 0 && args.remainingFailed === 0) {
3146
+ return "error";
3147
+ }
3148
+ return "failed";
3149
+ }
3150
+ function buildProviderSupplementBuckets(args) {
3151
+ let remainingErrors = args.remainingErrors;
3152
+ let remainingFailed = args.remainingFailed;
3153
+ return args.supplements.flatMap((supplement) => {
3154
+ const coverageKind = inferSupplementCoverageKind({
3155
+ label: supplement.label,
3156
+ rootCause: supplement.root_cause,
3157
+ remainingErrors,
3158
+ remainingFailed
3159
+ });
3160
+ const budget = coverageKind === "error" ? remainingErrors : remainingFailed;
3161
+ const count = Math.max(0, Math.min(supplement.count, budget));
3162
+ if (count === 0) {
3163
+ return [];
3164
+ }
3165
+ if (coverageKind === "error") {
3166
+ remainingErrors -= count;
3167
+ } else {
3168
+ remainingFailed -= count;
3169
+ }
3170
+ const representativeLabel = supplement.anchor.file ?? `${supplement.label} supplement`;
3171
+ const representativeItem = {
3172
+ label: representativeLabel,
3173
+ reason: supplement.root_cause,
3174
+ group: supplement.label,
3175
+ file: supplement.anchor.file,
3176
+ line: supplement.anchor.line,
3177
+ anchor_kind: supplement.anchor.file && supplement.anchor.line !== null ? "traceback" : supplement.anchor.file ? "test_label" : supplement.anchor.search_hint ? "entity" : "none",
3178
+ anchor_confidence: Math.max(0.4, Math.min(supplement.confidence, 0.82))
3179
+ };
3180
+ return [
3181
+ {
3182
+ type: classifyGenericBucketType(supplement.root_cause),
3183
+ headline: `${supplement.label}: ${formatCount(count, "visible failure")} share ${supplement.root_cause}.`,
3184
+ summaryLines: [
3185
+ `${supplement.label}: ${formatCount(count, "visible failure")} share ${supplement.root_cause}.`
3186
+ ],
3187
+ reason: supplement.root_cause,
3188
+ count,
3189
+ confidence: Math.max(0.4, Math.min(supplement.confidence, 0.82)),
3190
+ representativeItems: [representativeItem],
3191
+ entities: supplement.anchor.search_hint ? [supplement.anchor.search_hint] : [],
3192
+ hint: supplement.fix_hint ?? void 0,
3193
+ overflowCount: Math.max(count - 1, 0),
3194
+ overflowLabel: "failing tests/modules",
3195
+ labelOverride: supplement.label,
3196
+ coverage: buildCoverageCounts({
3197
+ count,
3198
+ coverageKind
3199
+ }),
3200
+ source: "provider"
3201
+ }
3202
+ ];
3203
+ });
3204
+ }
3205
+ function pickUnknownAnchor(args) {
3206
+ const fromStatusItems = args.kind === "error" ? args.analysis.visibleErrorItems[0] : null;
3207
+ if (fromStatusItems) {
3208
+ return {
3209
+ label: fromStatusItems.label,
3210
+ reason: fromStatusItems.reason,
3211
+ group: fromStatusItems.group,
3212
+ file: fromStatusItems.file,
3213
+ line: fromStatusItems.line,
3214
+ anchor_kind: fromStatusItems.anchor_kind,
3215
+ anchor_confidence: fromStatusItems.anchor_confidence
3216
+ };
3217
+ }
3218
+ const label = args.kind === "error" ? args.analysis.visibleErrorLabels[0] : args.analysis.visibleFailedLabels[0];
3219
+ if (label) {
3220
+ const normalizedLabel = normalizeTestId(label);
3221
+ const fileMatch = normalizedLabel.match(/^([A-Za-z0-9_./-]+\.[A-Za-z0-9]+)\b/);
3222
+ const file = fileMatch?.[1] ?? normalizedLabel.split("::")[0] ?? null;
3223
+ return {
3224
+ label,
3225
+ reason: args.kind === "error" ? "setup failures share a repeated but unclassified pattern" : "failing tests share a repeated but unclassified pattern",
3226
+ group: args.kind === "error" ? "unknown setup blocker" : "unknown failure family",
3227
+ file: file && file !== label ? file : null,
3228
+ line: null,
3229
+ anchor_kind: file && file !== label ? "test_label" : "none",
3230
+ anchor_confidence: file && file !== label ? 0.6 : 0
3231
+ };
3232
+ }
3233
+ return null;
3234
+ }
3235
+ function buildUnknownBucket(args) {
3236
+ if (args.count <= 0) {
3237
+ return null;
3238
+ }
3239
+ const anchor = pickUnknownAnchor(args);
3240
+ const isError = args.kind === "error";
3241
+ const label = isError ? "unknown setup blocker" : "unknown failure family";
3242
+ const reason = isError ? "unknown setup blocker: setup failures share a repeated but unclassified pattern" : "unknown failure family: failing tests share a repeated but unclassified pattern";
3243
+ return {
3244
+ type: "unknown_failure",
3245
+ headline: `${label}: ${formatCount(args.count, "visible failure")} share a repeated but unclassified pattern.`,
3246
+ summaryLines: [
3247
+ `${label}: ${formatCount(args.count, "visible failure")} share a repeated but unclassified pattern.`
3248
+ ],
3249
+ reason,
3250
+ count: args.count,
3251
+ confidence: 0.45,
3252
+ representativeItems: anchor ? [anchor] : [],
3253
+ entities: [],
3254
+ hint: isError ? "Take one deeper sift pass or inspect the first anchored setup failure." : "Take one deeper sift pass or inspect the first anchored failing test.",
3255
+ overflowCount: Math.max(args.count - (anchor ? 1 : 0), 0),
3256
+ overflowLabel: "failing tests/modules",
3257
+ labelOverride: label,
3258
+ coverage: buildCoverageCounts({
3259
+ count: args.count,
3260
+ coverageKind: isError ? "error" : "failed"
3261
+ }),
3262
+ source: "unknown"
3263
+ };
3264
+ }
3265
+ function buildCoverageResiduals(args) {
3266
+ const covered = args.buckets.reduce(
3267
+ (totals, bucket) => ({
3268
+ error: totals.error + bucket.coverage.error,
3269
+ failed: totals.failed + bucket.coverage.failed
3270
+ }),
3271
+ {
3272
+ error: 0,
3273
+ failed: 0
3274
+ }
3275
+ );
3276
+ return {
3277
+ remainingErrors: Math.max(args.analysis.errors - Math.min(args.analysis.errors, covered.error), 0),
3278
+ remainingFailed: Math.max(args.analysis.failed - Math.min(args.analysis.failed, covered.failed), 0)
3279
+ };
3280
+ }
2366
3281
  function buildOutcomeLines(analysis) {
2367
3282
  if (analysis.noTestsCollected) {
2368
3283
  return ["- Tests did not run.", "- Collected 0 items."];
@@ -2461,6 +3376,10 @@ function buildStandardFixText(args) {
2461
3376
  if (args.bucket.hint) {
2462
3377
  return args.bucket.hint;
2463
3378
  }
3379
+ const extended = findExtendedBucketSpec(args.bucket.reason);
3380
+ if (extended) {
3381
+ return extended.fix;
3382
+ }
2464
3383
  const envVar = args.bucket.reason.match(/^missing test env:\s+([A-Z][A-Z0-9_]{2,})$/)?.[1];
2465
3384
  if (envVar) {
2466
3385
  return `Set ${envVar} before rerunning the affected tests.`;
@@ -2481,9 +3400,18 @@ function buildStandardFixText(args) {
2481
3400
  if (args.bucket.reason.startsWith("auth bypass absent:")) {
2482
3401
  return "Restore the test auth bypass setup and rerun the full suite at standard.";
2483
3402
  }
3403
+ if (args.bucket.reason.startsWith("unknown setup blocker:")) {
3404
+ return "Take one deeper sift pass or inspect the first anchored setup failure before rerunning.";
3405
+ }
3406
+ if (args.bucket.reason.startsWith("unknown failure family:")) {
3407
+ return "Take one deeper sift pass or inspect the first anchored failing test before rerunning.";
3408
+ }
2484
3409
  if (args.bucket.type === "contract_snapshot_drift") {
2485
3410
  return "Review the visible drift and regenerate the contract snapshots if the changes are intentional.";
2486
3411
  }
3412
+ if (args.bucket.type === "snapshot_mismatch") {
3413
+ return "Update the snapshots if these output changes are intentional, then rerun the full suite at standard.";
3414
+ }
2487
3415
  if (args.bucket.type === "assertion_failure") {
2488
3416
  return "Inspect the failing assertion and rerun the full suite at standard.";
2489
3417
  }
@@ -2577,7 +3505,35 @@ function renderVerbose(args) {
2577
3505
  return lines.join("\n");
2578
3506
  }
2579
3507
  function buildTestStatusDiagnoseContract(args) {
2580
- const buckets = prioritizeBuckets(mergeBuckets(args.analysis)).slice(0, 3);
3508
+ const heuristicBuckets = mergeBuckets(args.analysis);
3509
+ const preUnknownSimpleCollectionFailure = args.analysis.collectionErrorCount !== void 0 && args.analysis.collectionItems.length === 0 && heuristicBuckets.length === 0 && (args.providerBucketSupplements?.length ?? 0) === 0;
3510
+ const heuristicResiduals = buildCoverageResiduals({
3511
+ analysis: args.analysis,
3512
+ buckets: heuristicBuckets
3513
+ });
3514
+ const providerSupplementBuckets = buildProviderSupplementBuckets({
3515
+ supplements: args.providerBucketSupplements ?? [],
3516
+ remainingErrors: heuristicResiduals.remainingErrors,
3517
+ remainingFailed: heuristicResiduals.remainingFailed
3518
+ });
3519
+ const combinedBuckets = mergeBuckets(args.analysis, providerSupplementBuckets);
3520
+ const residuals = buildCoverageResiduals({
3521
+ analysis: args.analysis,
3522
+ buckets: combinedBuckets
3523
+ });
3524
+ const unknownBuckets = preUnknownSimpleCollectionFailure ? [] : [
3525
+ buildUnknownBucket({
3526
+ analysis: args.analysis,
3527
+ kind: "error",
3528
+ count: residuals.remainingErrors
3529
+ }),
3530
+ buildUnknownBucket({
3531
+ analysis: args.analysis,
3532
+ kind: "failed",
3533
+ count: residuals.remainingFailed
3534
+ })
3535
+ ].filter((bucket) => Boolean(bucket));
3536
+ const buckets = prioritizeBuckets([...combinedBuckets, ...unknownBuckets]).slice(0, 3);
2581
3537
  const simpleCollectionFailure = args.analysis.collectionErrorCount !== void 0 && args.analysis.collectionItems.length === 0 && buckets.length === 0;
2582
3538
  const dominantBucket = buckets.map((bucket, index) => ({
2583
3539
  bucket,
@@ -2588,8 +3544,10 @@ function buildTestStatusDiagnoseContract(args) {
2588
3544
  }
2589
3545
  return right.bucket.confidence - left.bucket.confidence;
2590
3546
  })[0] ?? null;
2591
- const diagnosisComplete = args.analysis.failed === 0 && args.analysis.errors === 0 && args.analysis.passed > 0 || simpleCollectionFailure || buckets.length > 0 && (dominantBucket?.bucket.confidence ?? 0) >= 0.7;
2592
- const rawNeeded = buckets.length > 0 ? buckets.every((bucket) => bucket.confidence < 0.7) : !(args.analysis.failed === 0 && args.analysis.errors === 0 && args.analysis.passed > 0 || simpleCollectionFailure);
3547
+ const hasUnknownBucket = buckets.some((bucket) => isUnknownBucket(bucket));
3548
+ const hasConcreteCoverage = args.analysis.failed === 0 && args.analysis.errors === 0 ? true : residuals.remainingErrors === 0 && residuals.remainingFailed === 0;
3549
+ const diagnosisComplete = args.analysis.failed === 0 && args.analysis.errors === 0 && args.analysis.passed > 0 || simpleCollectionFailure || buckets.length > 0 && hasConcreteCoverage && !hasUnknownBucket && (dominantBucket?.bucket.confidence ?? 0) >= 0.6;
3550
+ const rawNeeded = buckets.length === 0 ? !(args.analysis.failed === 0 && args.analysis.errors === 0 && args.analysis.passed > 0 || simpleCollectionFailure) : !diagnosisComplete && !hasUnknownBucket && buckets.every((bucket) => bucket.confidence < 0.7);
2593
3551
  const dominantBlockerBucketIndex = dominantBucket && isDominantBlockerType(dominantBucket.bucket.type) ? dominantBucket.index + 1 : null;
2594
3552
  const readTargets = buildReadTargets({
2595
3553
  buckets,
@@ -2624,6 +3582,12 @@ function buildTestStatusDiagnoseContract(args) {
2624
3582
  bucket_index: null,
2625
3583
  note: "Inspect the collection traceback or setup code next; the run failed before tests executed."
2626
3584
  };
3585
+ } else if (hasUnknownBucket) {
3586
+ nextBestAction = {
3587
+ code: "insufficient_signal",
3588
+ bucket_index: dominantBucket ? dominantBucket.index + 1 : null,
3589
+ note: "Take one deeper sift pass or inspect the first anchored failure before falling back to raw traceback."
3590
+ };
2627
3591
  } else if (!diagnosisComplete) {
2628
3592
  nextBestAction = {
2629
3593
  code: rawNeeded ? "read_raw_for_exact_traceback" : "insufficient_signal",
@@ -2661,11 +3625,15 @@ function buildTestStatusDiagnoseContract(args) {
2661
3625
  read_targets: readTargets,
2662
3626
  next_best_action: nextBestAction
2663
3627
  };
3628
+ const effectiveDiagnosisComplete = Boolean(args.contractOverrides?.diagnosis_complete ?? diagnosisComplete) && !hasUnknownBucket;
3629
+ const requestedDecision = args.contractOverrides?.decision;
3630
+ const effectiveDecision = hasUnknownBucket && requestedDecision && (requestedDecision === "stop" || requestedDecision === "read_source") ? "zoom" : requestedDecision;
2664
3631
  const effectiveNextBestAction = args.contractOverrides?.next_best_action ?? baseContract.next_best_action;
2665
3632
  const mergedContractWithoutDecision = {
2666
3633
  ...baseContract,
2667
3634
  ...args.contractOverrides,
2668
- status: args.contractOverrides?.diagnosis_complete ?? diagnosisComplete ? "ok" : "insufficient",
3635
+ diagnosis_complete: effectiveDiagnosisComplete,
3636
+ status: effectiveDiagnosisComplete ? "ok" : "insufficient",
2669
3637
  next_best_action: {
2670
3638
  ...effectiveNextBestAction,
2671
3639
  note: buildConcreteNextNote({
@@ -2679,7 +3647,7 @@ function buildTestStatusDiagnoseContract(args) {
2679
3647
  };
2680
3648
  const contract = testStatusDiagnoseContractSchema.parse({
2681
3649
  ...mergedContractWithoutDecision,
2682
- decision: args.contractOverrides?.decision ?? deriveDecision(mergedContractWithoutDecision)
3650
+ decision: effectiveDecision ?? deriveDecision(mergedContractWithoutDecision)
2683
3651
  });
2684
3652
  return {
2685
3653
  contract,
@@ -2916,9 +3884,12 @@ function resolvePromptPolicy(args) {
2916
3884
  "Return only valid JSON.",
2917
3885
  `Use this exact contract: ${args.outputContract ?? TEST_STATUS_DIAGNOSE_JSON_CONTRACT}.`,
2918
3886
  "Treat the heuristic context as extraction guidance, but do not invent hidden failures.",
2919
- "Use the heuristic extract as the bucket truth unless the visible command output clearly disproves it.",
3887
+ "Use the heuristic extract as the base bucket truth unless the visible command output clearly disproves it.",
3888
+ "If some visible failure or error families remain unexplained, add at most 2 bucket_supplements for the residual families only.",
3889
+ "Do not rewrite or delete heuristic buckets; only supplement missing residual coverage.",
3890
+ "Keep bucket_supplement counts within the unexplained residual failures or errors.",
2920
3891
  "Identify the dominant blocker, remaining visible failure buckets, the decision, and the next best action.",
2921
- "Set diagnosis_complete to true only when the visible output is already sufficient to stop and act.",
3892
+ "Set diagnosis_complete to true only when the visible output is already sufficient to stop and act and no unknown residual family remains.",
2922
3893
  "Set raw_needed to true only when exact traceback lines are still required.",
2923
3894
  "Set provider_confidence to a number between 0 and 1, or null only when confidence cannot be estimated."
2924
3895
  ] : [
@@ -3099,6 +4070,63 @@ function getCount(input, label) {
3099
4070
  const lastMatch = matches.at(-1);
3100
4071
  return lastMatch ? Number(lastMatch[1]) : 0;
3101
4072
  }
4073
+ function detectTestRunner(input) {
4074
+ if (/^\s*Test Files?\s+(?:\d+\s+failed\s*\|\s*)?\d+\s+passed/m.test(input) || /^\s*Tests?\s+(?:\d+\s+failed\s*\|\s*)?\d+\s+passed/m.test(input) || /^\s*Snapshots?\s+(?:\d+\s+failed\s*\|\s*)?\d+\s+passed/m.test(input) || /⎯{2,}\s+Failed Tests?\s+\d+\s+⎯{2,}/.test(input)) {
4075
+ return "vitest";
4076
+ }
4077
+ if (/^\s*Test Suites:\s+\d+\s+failed,\s+\d+\s+passed(?:,\s+\d+\s+total)?/m.test(input) || /^\s*Tests:\s+\d+\s+failed,\s+\d+\s+passed(?:,\s+\d+\s+total)?/m.test(input)) {
4078
+ return "jest";
4079
+ }
4080
+ if (/\bpytest\b/i.test(input) || /^\s*=+.*\b\d+\s+failed\b.*=+\s*$/m.test(input) || /\bcollected\s+\d+\s+items\b/i.test(input)) {
4081
+ return "pytest";
4082
+ }
4083
+ return "unknown";
4084
+ }
4085
+ function extractVitestLineCount(input, label, metric) {
4086
+ const matcher = new RegExp(`^\\s*${label}\\s+(.+)$`, "gmi");
4087
+ const lines = [...input.matchAll(matcher)];
4088
+ const line = lines.at(-1)?.[1];
4089
+ if (!line) {
4090
+ return null;
4091
+ }
4092
+ const metricMatch = line.match(new RegExp(`(\\d+)\\s+${metric}`, "i"));
4093
+ return metricMatch ? Number(metricMatch[1]) : null;
4094
+ }
4095
+ function extractJestLineCount(input, label, metric) {
4096
+ const matcher = new RegExp(`^\\s*${label}:\\s+(.+)$`, "gmi");
4097
+ const lines = [...input.matchAll(matcher)];
4098
+ const line = lines.at(-1)?.[1];
4099
+ if (!line) {
4100
+ return null;
4101
+ }
4102
+ const metricMatch = line.match(new RegExp(`(\\d+)\\s+${metric}`, "i"));
4103
+ return metricMatch ? Number(metricMatch[1]) : null;
4104
+ }
4105
+ function extractTestStatusCounts(input, runner) {
4106
+ if (runner === "vitest") {
4107
+ return {
4108
+ passed: extractVitestLineCount(input, "Tests?", "passed") ?? getCount(input, "passed"),
4109
+ failed: extractVitestLineCount(input, "Tests?", "failed") ?? getCount(input, "failed"),
4110
+ errors: extractVitestLineCount(input, "Errors?", "error") ?? extractVitestLineCount(input, "Errors?", "errors") ?? Math.max(getCount(input, "errors"), getCount(input, "error")),
4111
+ skipped: extractVitestLineCount(input, "Tests?", "skipped") ?? getCount(input, "skipped"),
4112
+ snapshotFailures: extractVitestLineCount(input, "Snapshots?", "failed") ?? void 0
4113
+ };
4114
+ }
4115
+ if (runner === "jest") {
4116
+ return {
4117
+ passed: extractJestLineCount(input, "Tests", "passed") ?? getCount(input, "passed"),
4118
+ failed: extractJestLineCount(input, "Tests", "failed") ?? getCount(input, "failed"),
4119
+ errors: Math.max(getCount(input, "errors"), getCount(input, "error")),
4120
+ skipped: extractJestLineCount(input, "Tests", "skipped") ?? getCount(input, "skipped")
4121
+ };
4122
+ }
4123
+ return {
4124
+ passed: getCount(input, "passed"),
4125
+ failed: getCount(input, "failed"),
4126
+ errors: Math.max(getCount(input, "errors"), getCount(input, "error")),
4127
+ skipped: getCount(input, "skipped")
4128
+ };
4129
+ }
3102
4130
  function formatCount2(count, singular, plural = `${singular}s`) {
3103
4131
  return `${count} ${count === 1 ? singular : plural}`;
3104
4132
  }
@@ -3131,7 +4159,8 @@ function normalizeAnchorFile(value) {
3131
4159
  return value.replace(/\\/g, "/").trim();
3132
4160
  }
3133
4161
  function inferFileFromLabel(label) {
3134
- const candidate = cleanFailureLabel(label).split("::")[0]?.trim();
4162
+ const cleaned = cleanFailureLabel(label);
4163
+ const candidate = cleaned.split("::")[0]?.split(" > ")[0]?.trim();
3135
4164
  if (!candidate) {
3136
4165
  return null;
3137
4166
  }
@@ -3186,6 +4215,15 @@ function parseObservedAnchor(line) {
3186
4215
  anchor_confidence: 0.92
3187
4216
  };
3188
4217
  }
4218
+ const vitestTraceback = normalized.match(/^\s*❯\s+([^:\s][^:]*\.[A-Za-z0-9]+):(\d+)(?::\d+)?/);
4219
+ if (vitestTraceback) {
4220
+ return {
4221
+ file: normalizeAnchorFile(vitestTraceback[1]),
4222
+ line: Number(vitestTraceback[2]),
4223
+ anchor_kind: "traceback",
4224
+ anchor_confidence: 1
4225
+ };
4226
+ }
3189
4227
  return null;
3190
4228
  }
3191
4229
  function resolveAnchorForLabel(args) {
@@ -3202,96 +4240,362 @@ function isLowValueInternalReason(normalized) {
3202
4240
  ) || /\bpython\.py:\d+:\s+in\s+importtestmodule\b/i.test(normalized) || /\bpython\.py:\d+:\s+in\s+import_path\b/i.test(normalized);
3203
4241
  }
3204
4242
  function scoreFailureReason(reason) {
4243
+ if (reason.startsWith("configuration:")) {
4244
+ return 6;
4245
+ }
3205
4246
  if (reason.startsWith("missing test env:")) {
3206
4247
  return 6;
3207
4248
  }
3208
- if (reason.startsWith("missing module:")) {
3209
- return 5;
4249
+ if (reason.startsWith("missing module:")) {
4250
+ return 5;
4251
+ }
4252
+ if (reason.startsWith("snapshot mismatch:")) {
4253
+ return 4;
4254
+ }
4255
+ if (reason.startsWith("assertion failed:")) {
4256
+ return 4;
4257
+ }
4258
+ if (reason.startsWith("timeout:") || reason.startsWith("async loop:") || reason.startsWith("django db access:") || reason.startsWith("db migration:")) {
4259
+ return 3;
4260
+ }
4261
+ if (reason.startsWith("permission:") || reason.startsWith("xdist worker crash:") || reason.startsWith("network:") || reason.startsWith("segfault:") || reason.startsWith("memory:") || reason.startsWith("type error:") || reason.startsWith("serialization:") || reason.startsWith("file not found:") || reason.startsWith("deprecation as error:") || reason.startsWith("xfail strict:") || reason.startsWith("resource leak:") || reason.startsWith("flaky:") || reason.startsWith("fixture teardown:")) {
4262
+ return 2;
4263
+ }
4264
+ if (/^[A-Z][A-Za-z]+(?:Error|Exception):/.test(reason)) {
4265
+ return 3;
4266
+ }
4267
+ if (reason === "import error during collection") {
4268
+ return 2;
4269
+ }
4270
+ return 1;
4271
+ }
4272
+ function buildClassifiedReason(prefix, detail) {
4273
+ return `${prefix}: ${detail}`.slice(0, 120);
4274
+ }
4275
+ function buildExcerptDetail(value, fallback) {
4276
+ const trimmed = value.trim().replace(/\s+/g, " ");
4277
+ return trimmed.length > 0 ? trimmed : fallback;
4278
+ }
4279
+ function sharedBlockerThreshold(reason) {
4280
+ return reason.startsWith("configuration:") ? 1 : 3;
4281
+ }
4282
+ function extractEnvBlockerName(normalized) {
4283
+ const directMatch = normalized.match(
4284
+ /\bDB-isolated tests require\s+([A-Z][A-Z0-9_]{2,})\b/
4285
+ );
4286
+ if (directMatch) {
4287
+ return directMatch[1];
4288
+ }
4289
+ const fallbackMatch = normalized.match(
4290
+ /\b([A-Z][A-Z0-9_]{2,})\b(?=[^.\n]*DB-isolated tests)/
4291
+ );
4292
+ if (fallbackMatch) {
4293
+ return fallbackMatch[1];
4294
+ }
4295
+ const leadingEnvMatch = normalized.match(
4296
+ /\b([A-Z][A-Z0-9_]{2,})\b(?=[^.\n]{0,80}\b(?:is\s+)?(?:missing|unset|not set|not configured|required)\b)/
4297
+ );
4298
+ if (leadingEnvMatch) {
4299
+ return leadingEnvMatch[1];
4300
+ }
4301
+ const trailingEnvMatch = normalized.match(
4302
+ /\b(?:missing|unset|not set|not configured|required)\b[^.\n]{0,80}\b([A-Z][A-Z0-9_]{2,})\b/
4303
+ );
4304
+ if (trailingEnvMatch) {
4305
+ return trailingEnvMatch[1];
4306
+ }
4307
+ const validationEnvMatch = normalized.match(
4308
+ /\bValidationError\b[^.\n]{0,120}\b([A-Z][A-Z0-9_]{2,})\b/
4309
+ );
4310
+ return validationEnvMatch?.[1] ?? null;
4311
+ }
4312
+ function classifyFailureReason(line, options) {
4313
+ const normalized = line.trim().replace(/^[A-Z]\s+/, "");
4314
+ if (normalized.length === 0) {
4315
+ return null;
4316
+ }
4317
+ if (isLowValueInternalReason(normalized)) {
4318
+ return null;
4319
+ }
4320
+ if (/^([A-Za-z0-9_./-]+\.[A-Za-z0-9]+):\d+(?::\d+)?:\s+in\b/.test(normalized) || /^([^:\s][^:]*\.[A-Za-z0-9]+):\d+(?::\d+)?:\s+in\b/.test(normalized) || /^File\s+"[^"]+",\s+line\s+\d+/.test(normalized)) {
4321
+ return null;
4322
+ }
4323
+ const envBlocker = extractEnvBlockerName(normalized);
4324
+ if (envBlocker) {
4325
+ return {
4326
+ reason: `missing test env: ${envBlocker}`,
4327
+ group: "DB-backed tests are blocked by missing test environment configuration"
4328
+ };
4329
+ }
4330
+ const missingEnv = normalized.match(
4331
+ /\b(?:environment variable|env(?:ironment)? var(?:iable)?|missing required env(?:ironment)? variable)\s+([A-Z][A-Z0-9_]{2,})\b/
4332
+ );
4333
+ if (missingEnv) {
4334
+ return {
4335
+ reason: `missing test env: ${missingEnv[1]}`,
4336
+ group: "tests are blocked by missing environment configuration"
4337
+ };
4338
+ }
4339
+ const keyErrorEnv = normalized.match(/KeyError:\s*['"]([A-Z][A-Z0-9_]{2,})['"]/);
4340
+ if (keyErrorEnv) {
4341
+ return {
4342
+ reason: `missing test env: ${keyErrorEnv[1]}`,
4343
+ group: "tests are blocked by missing environment configuration"
4344
+ };
4345
+ }
4346
+ const fixtureGuard = normalized.match(
4347
+ /(?:FixtureLookupError|fixture guard|requires fixture)\b[^A-Za-z0-9_'-]*([a-z_][a-z0-9_]*)?/i
4348
+ );
4349
+ if (fixtureGuard) {
4350
+ return {
4351
+ reason: `fixture guard: ${fixtureGuard[1] ?? "required fixture unavailable"}`.trim(),
4352
+ group: "fixture guards or setup gates"
4353
+ };
4354
+ }
4355
+ if (/(ECONNREFUSED|ConnectionRefusedError|connection refused|could not connect to server)/i.test(
4356
+ normalized
4357
+ ) && /(postgres|database|db|5432)/i.test(normalized)) {
4358
+ return {
4359
+ reason: "db refused: database connection was refused",
4360
+ group: "database connectivity failures"
4361
+ };
4362
+ }
4363
+ if (/(ECONNREFUSED|ConnectionRefusedError|connection refused)/i.test(normalized)) {
4364
+ return {
4365
+ reason: "service unavailable: dependency connection was refused",
4366
+ group: "service availability failures"
4367
+ };
4368
+ }
4369
+ if (/(503\b|service unavailable|temporarily unavailable)/i.test(normalized)) {
4370
+ return {
4371
+ reason: "service unavailable: dependency service is unavailable",
4372
+ group: "service availability failures"
4373
+ };
4374
+ }
4375
+ if (/(auth bypass|test auth|bypass token)/i.test(normalized) && /(missing|absent|not configured|not set|unavailable)/i.test(normalized)) {
4376
+ return {
4377
+ reason: "auth bypass absent: test auth bypass is missing",
4378
+ group: "authentication test setup failures"
4379
+ };
4380
+ }
4381
+ const snapshotMismatch = normalized.match(
4382
+ /((?:Error:\s*)?Snapshot\b.+\bmismatched\b[^$]*|Snapshot comparison failed[^$]*)/i
4383
+ );
4384
+ if (snapshotMismatch) {
4385
+ return {
4386
+ reason: buildClassifiedReason(
4387
+ "snapshot mismatch",
4388
+ buildExcerptDetail(snapshotMismatch[1] ?? normalized, "snapshot output changed")
4389
+ ),
4390
+ group: "snapshot mismatches"
4391
+ };
4392
+ }
4393
+ const timeoutFailure = normalized.match(
4394
+ /(Failed:\s*Timeout\s*>[^,;]+|asyncio\.exceptions\.TimeoutError:\s*.+|TimeoutError:\s*.+|(?:Test|Hook)\s+timed out in\s+\d+(?:\.\d+)?m?s[^$]*|(?:\[vitest-(?:worker|pool)\]:\s*)?Timeout[^$]*)$/i
4395
+ );
4396
+ if (timeoutFailure) {
4397
+ return {
4398
+ reason: buildClassifiedReason(
4399
+ "timeout",
4400
+ buildExcerptDetail(timeoutFailure[1] ?? normalized, "test exceeded timeout threshold")
4401
+ ),
4402
+ group: "timeout failures"
4403
+ };
4404
+ }
4405
+ const asyncLoopFailure = normalized.match(
4406
+ /(Event loop is closed|no current event loop|coroutine .* was never awaited|coroutine was never awaited)/i
4407
+ );
4408
+ if (asyncLoopFailure) {
4409
+ return {
4410
+ reason: buildClassifiedReason(
4411
+ "async loop",
4412
+ buildExcerptDetail(asyncLoopFailure[1] ?? normalized, "async event loop failure")
4413
+ ),
4414
+ group: "async event loop failures"
4415
+ };
4416
+ }
4417
+ const permissionFailure = normalized.match(
4418
+ /(PermissionError:\s*\[Errno 13\][^$]*|Address already in use)/i
4419
+ );
4420
+ if (permissionFailure) {
4421
+ return {
4422
+ reason: buildClassifiedReason(
4423
+ "permission",
4424
+ buildExcerptDetail(permissionFailure[1] ?? normalized, "permission denied or resource locked")
4425
+ ),
4426
+ group: "permission or locked resource failures"
4427
+ };
4428
+ }
4429
+ const xdistWorkerCrash = normalized.match(
4430
+ /(worker ['"][^'"]+['"] crashed|node down:\s*[^,;]+|WorkerLost[^,;]*|Worker exited unexpectedly[^,;]*|worker exited unexpectedly[^,;]*)/i
4431
+ );
4432
+ if (xdistWorkerCrash) {
4433
+ return {
4434
+ reason: buildClassifiedReason(
4435
+ "xdist worker crash",
4436
+ buildExcerptDetail(xdistWorkerCrash[1] ?? normalized, "pytest-xdist worker crashed")
4437
+ ),
4438
+ group: "xdist worker crashes"
4439
+ };
4440
+ }
4441
+ if (/Worker terminated due to reaching memory limit/i.test(normalized)) {
4442
+ return {
4443
+ reason: "memory: Worker terminated due to reaching memory limit",
4444
+ group: "memory exhaustion failures"
4445
+ };
4446
+ }
4447
+ if (/Database access not allowed, use the ["']django_db["'] mark/i.test(normalized)) {
4448
+ return {
4449
+ reason: 'django db access: Database access not allowed, use the "django_db" mark',
4450
+ group: "django database marker failures"
4451
+ };
4452
+ }
4453
+ const networkFailure = normalized.match(
4454
+ /(Max retries exceeded[^,;]*|gaierror[^,;]*|SSLCertVerificationError[^,;]*|Network is unreachable)/i
4455
+ );
4456
+ if (networkFailure) {
4457
+ return {
4458
+ reason: buildClassifiedReason(
4459
+ "network",
4460
+ buildExcerptDetail(networkFailure[1] ?? normalized, "network dependency failure")
4461
+ ),
4462
+ group: "network dependency failures"
4463
+ };
3210
4464
  }
3211
- if (reason.startsWith("assertion failed:")) {
3212
- return 4;
4465
+ const relationMigration = normalized.match(/relation ["'`]([^"'`]+)["'`] does not exist/i);
4466
+ if (relationMigration) {
4467
+ return {
4468
+ reason: buildClassifiedReason("db migration", `relation ${relationMigration[1]} does not exist`),
4469
+ group: "database migration or schema failures"
4470
+ };
3213
4471
  }
3214
- if (/^[A-Z][A-Za-z]+(?:Error|Exception):/.test(reason)) {
3215
- return 3;
4472
+ const noSuchTable = normalized.match(/no such table(?::)?\s*([A-Za-z0-9_.-]+)/i);
4473
+ if (noSuchTable) {
4474
+ return {
4475
+ reason: buildClassifiedReason("db migration", `no such table ${noSuchTable[1]}`),
4476
+ group: "database migration or schema failures"
4477
+ };
3216
4478
  }
3217
- if (reason === "import error during collection") {
3218
- return 2;
4479
+ if (/InconsistentMigrationHistory/i.test(normalized)) {
4480
+ return {
4481
+ reason: "db migration: InconsistentMigrationHistory",
4482
+ group: "database migration or schema failures"
4483
+ };
3219
4484
  }
3220
- return 1;
3221
- }
3222
- function extractEnvBlockerName(normalized) {
3223
- const directMatch = normalized.match(
3224
- /\bDB-isolated tests require\s+([A-Z][A-Z0-9_]{2,})\b/
3225
- );
3226
- if (directMatch) {
3227
- return directMatch[1];
4485
+ if (/(Segmentation fault|SIGSEGV|\bexit 139\b)/i.test(normalized)) {
4486
+ return {
4487
+ reason: buildClassifiedReason(
4488
+ "segfault",
4489
+ buildExcerptDetail(normalized, "subprocess crashed with SIGSEGV")
4490
+ ),
4491
+ group: "subprocess crash failures"
4492
+ };
3228
4493
  }
3229
- const fallbackMatch = normalized.match(
3230
- /\b([A-Z][A-Z0-9_]{2,})\b(?=[^.\n]*DB-isolated tests)/
3231
- );
3232
- return fallbackMatch?.[1] ?? null;
3233
- }
3234
- function classifyFailureReason(line, options) {
3235
- const normalized = line.trim().replace(/^[A-Z]\s+/, "");
3236
- if (normalized.length === 0) {
3237
- return null;
4494
+ if (/(MemoryError\b|\bexit 137\b|OOMKilled|OutOfMemory)/i.test(normalized)) {
4495
+ return {
4496
+ reason: buildClassifiedReason(
4497
+ "memory",
4498
+ buildExcerptDetail(normalized, "process exhausted available memory")
4499
+ ),
4500
+ group: "memory exhaustion failures"
4501
+ };
3238
4502
  }
3239
- if (isLowValueInternalReason(normalized)) {
3240
- return null;
4503
+ const typeErrorFailure = normalized.match(/TypeError:\s*(.+)$/i);
4504
+ if (typeErrorFailure) {
4505
+ return {
4506
+ reason: buildClassifiedReason(
4507
+ "type error",
4508
+ buildExcerptDetail(typeErrorFailure[1] ?? normalized, "TypeError")
4509
+ ),
4510
+ group: "type errors"
4511
+ };
3241
4512
  }
3242
- if (/^([A-Za-z0-9_./-]+\.[A-Za-z0-9]+):\d+(?::\d+)?:\s+in\b/.test(normalized) || /^([^:\s][^:]*\.[A-Za-z0-9]+):\d+(?::\d+)?:\s+in\b/.test(normalized) || /^File\s+"[^"]+",\s+line\s+\d+/.test(normalized)) {
3243
- return null;
4513
+ const serializationFailure = normalized.match(
4514
+ /\b(UnicodeDecodeError|JSONDecodeError|PicklingError):\s*(.+)$/i
4515
+ );
4516
+ if (serializationFailure) {
4517
+ return {
4518
+ reason: buildClassifiedReason(
4519
+ "serialization",
4520
+ `${serializationFailure[1]}: ${buildExcerptDetail(serializationFailure[2] ?? "", serializationFailure[1] ?? "serialization failure")}`
4521
+ ),
4522
+ group: "serialization and encoding failures"
4523
+ };
3244
4524
  }
3245
- const envBlocker = extractEnvBlockerName(normalized);
3246
- if (envBlocker) {
4525
+ const fileNotFoundFailure = normalized.match(/FileNotFoundError:\s*(.+)$/i);
4526
+ if (fileNotFoundFailure) {
3247
4527
  return {
3248
- reason: `missing test env: ${envBlocker}`,
3249
- group: "DB-backed tests are blocked by missing test environment configuration"
4528
+ reason: buildClassifiedReason(
4529
+ "file not found",
4530
+ buildExcerptDetail(fileNotFoundFailure[1] ?? normalized, "missing file during test execution")
4531
+ ),
4532
+ group: "missing file failures"
3250
4533
  };
3251
4534
  }
3252
- const missingEnv = normalized.match(
3253
- /\b(?:environment variable|env(?:ironment)? var(?:iable)?|Missing required env(?:ironment)? variable)\s+([A-Z][A-Z0-9_]{2,})\b/i
4535
+ const deprecationFailure = normalized.match(
4536
+ /\b(DeprecationWarning|FutureWarning|PytestRemovedIn9Warning):\s*(.+)$/i
3254
4537
  );
3255
- if (missingEnv) {
4538
+ if (deprecationFailure) {
3256
4539
  return {
3257
- reason: `missing test env: ${missingEnv[1]}`,
3258
- group: "tests are blocked by missing environment configuration"
4540
+ reason: buildClassifiedReason(
4541
+ "deprecation as error",
4542
+ `${deprecationFailure[1]}: ${buildExcerptDetail(deprecationFailure[2] ?? "", deprecationFailure[1] ?? "warning treated as error")}`
4543
+ ),
4544
+ group: "warnings treated as errors"
3259
4545
  };
3260
4546
  }
3261
- const keyErrorEnv = normalized.match(/KeyError:\s*['"]([A-Z][A-Z0-9_]{2,})['"]/);
3262
- if (keyErrorEnv) {
4547
+ const strictXfail = normalized.match(/XPASS\(strict\)\s*:?\s*(.+)?$/i);
4548
+ if (strictXfail) {
3263
4549
  return {
3264
- reason: `missing test env: ${keyErrorEnv[1]}`,
3265
- group: "tests are blocked by missing environment configuration"
4550
+ reason: buildClassifiedReason(
4551
+ "xfail strict",
4552
+ buildExcerptDetail(strictXfail[1] ?? normalized, "strict xfail unexpectedly passed")
4553
+ ),
4554
+ group: "strict xfail expectation failures"
3266
4555
  };
3267
4556
  }
3268
- const fixtureGuard = normalized.match(
3269
- /(?:FixtureLookupError|fixture guard|requires fixture)\b[^A-Za-z0-9_'-]*([a-z_][a-z0-9_]*)?/i
4557
+ const resourceLeak = normalized.match(
4558
+ /(PytestUnraisableExceptionWarning[^,;]*|ResourceWarning:\s*unclosed[^,;]*)/i
3270
4559
  );
3271
- if (fixtureGuard) {
4560
+ if (resourceLeak) {
3272
4561
  return {
3273
- reason: `fixture guard: ${fixtureGuard[1] ?? "required fixture unavailable"}`.trim(),
3274
- group: "fixture guards or setup gates"
4562
+ reason: buildClassifiedReason(
4563
+ "resource leak",
4564
+ buildExcerptDetail(resourceLeak[1] ?? normalized, "resource leak warning promoted to failure")
4565
+ ),
4566
+ group: "resource leak warnings"
3275
4567
  };
3276
4568
  }
3277
- if (/(ECONNREFUSED|ConnectionRefusedError|connection refused|could not connect to server)/i.test(
3278
- normalized
3279
- ) && /(postgres|database|db|5432)/i.test(normalized)) {
4569
+ const flakyFailure = normalized.match(/\b(RERUN|[0-9]+\s+rerun|Flaky test passed)\b[^$]*/i);
4570
+ if (flakyFailure) {
3280
4571
  return {
3281
- reason: "db refused: database connection was refused",
3282
- group: "database connectivity failures"
4572
+ reason: buildClassifiedReason(
4573
+ "flaky",
4574
+ buildExcerptDetail(flakyFailure[0] ?? normalized, "test required reruns before passing")
4575
+ ),
4576
+ group: "flaky test detections"
3283
4577
  };
3284
4578
  }
3285
- if (/(503\b|service unavailable|temporarily unavailable)/i.test(normalized)) {
4579
+ const teardownFailure = normalized.match(/ERROR at teardown of\s+(.+)$/i);
4580
+ if (teardownFailure) {
3286
4581
  return {
3287
- reason: "service unavailable: dependency service is unavailable",
3288
- group: "service availability failures"
4582
+ reason: buildClassifiedReason(
4583
+ "fixture teardown",
4584
+ buildExcerptDetail(teardownFailure[1] ?? normalized, "fixture teardown failed")
4585
+ ),
4586
+ group: "fixture teardown failures"
3289
4587
  };
3290
4588
  }
3291
- if (/(auth bypass|test auth|bypass token)/i.test(normalized) && /(missing|absent|not configured|not set|unavailable)/i.test(normalized)) {
4589
+ const configurationFailure = normalized.match(
4590
+ /(INTERNALERROR>.+|ConftestImportFailure[^,;]*|UsageError:\s*.+|ERROR:\s*usage:\s*.+|pytest:\s*error:\s*.+|Cannot use import statement outside a module[^$]*|Named export.+not found.+CommonJS[^$]*|failed to load config from.+|localStorage is not available[^$]*|No test suite found in file.+|No test found in suite.+)$/i
4591
+ );
4592
+ if (configurationFailure) {
3292
4593
  return {
3293
- reason: "auth bypass absent: test auth bypass is missing",
3294
- group: "authentication test setup failures"
4594
+ reason: buildClassifiedReason(
4595
+ "configuration",
4596
+ buildExcerptDetail(configurationFailure[1] ?? normalized, "test configuration error")
4597
+ ),
4598
+ group: "test configuration failures"
3295
4599
  };
3296
4600
  }
3297
4601
  const pythonMissingModule = normalized.match(
@@ -3310,6 +4614,20 @@ function classifyFailureReason(line, options) {
3310
4614
  group: options.duringCollection ? "import/dependency errors during collection" : "missing dependency/module errors"
3311
4615
  };
3312
4616
  }
4617
+ const importResolutionFailure = normalized.match(/Failed to resolve import ['"]([^'"]+)['"]/i);
4618
+ if (importResolutionFailure) {
4619
+ return {
4620
+ reason: `missing module: ${importResolutionFailure[1]}`,
4621
+ group: options.duringCollection ? "import/dependency errors during collection" : "missing dependency/module errors"
4622
+ };
4623
+ }
4624
+ const esmModuleFailure = normalized.match(/ERR_MODULE_NOT_FOUND[^'"`]*['"`]([^'"`]+)['"`]/i) ?? normalized.match(/Cannot find package ['"`]([^'"`]+)['"`]/i);
4625
+ if (esmModuleFailure) {
4626
+ return {
4627
+ reason: `missing module: ${esmModuleFailure[1]}`,
4628
+ group: options.duringCollection ? "import/dependency errors during collection" : "missing dependency/module errors"
4629
+ };
4630
+ }
3313
4631
  const assertionFailure = normalized.match(/AssertionError:\s*(.+)$/i);
3314
4632
  if (assertionFailure) {
3315
4633
  return {
@@ -3317,6 +4635,16 @@ function classifyFailureReason(line, options) {
3317
4635
  group: "assertion failures"
3318
4636
  };
3319
4637
  }
4638
+ const vitestUnhandled = normalized.match(/Vitest caught\s+\d+\s+unhandled errors?/i);
4639
+ if (vitestUnhandled) {
4640
+ return {
4641
+ reason: `RuntimeError: ${buildExcerptDetail(vitestUnhandled[0] ?? normalized, "Vitest caught unhandled errors")}`.slice(
4642
+ 0,
4643
+ 120
4644
+ ),
4645
+ group: "runtime failures"
4646
+ };
4647
+ }
3320
4648
  const genericError = normalized.match(/\b([A-Z][A-Za-z]+(?:Error|Exception)):\s*(.+)$/);
3321
4649
  if (genericError) {
3322
4650
  const errorType = genericError[1];
@@ -3361,6 +4689,125 @@ function chooseStrongestFailureItems(items) {
3361
4689
  }
3362
4690
  return order.map((label) => strongest.get(label));
3363
4691
  }
4692
+ function extractJsTestFile(value) {
4693
+ const match = value.match(/([A-Za-z0-9_./-]+\.(?:test|spec)\.[cm]?[jt]sx?)/i);
4694
+ return match ? normalizeAnchorFile(match[1]) : null;
4695
+ }
4696
+ function normalizeJsFailureLabel(label) {
4697
+ return cleanFailureLabel(label).replace(/^[❯×]\s*/, "").replace(/\s+\[[^\]]+\]\s*$/, "").replace(/\s+/g, " ").trim();
4698
+ }
4699
+ function classifyFailureLines(args) {
4700
+ let observedAnchor = null;
4701
+ let strongest = null;
4702
+ for (const line of args.lines) {
4703
+ observedAnchor = parseObservedAnchor(line) ?? observedAnchor;
4704
+ const classification = classifyFailureReason(line, {
4705
+ duringCollection: args.duringCollection
4706
+ });
4707
+ if (!classification) {
4708
+ continue;
4709
+ }
4710
+ const score = scoreFailureReason(classification.reason);
4711
+ if (!strongest || score > strongest.score) {
4712
+ strongest = {
4713
+ classification,
4714
+ score,
4715
+ observedAnchor: parseObservedAnchor(line) ?? observedAnchor
4716
+ };
4717
+ }
4718
+ }
4719
+ if (!strongest) {
4720
+ return null;
4721
+ }
4722
+ return {
4723
+ classification: strongest.classification,
4724
+ observedAnchor: strongest.observedAnchor ?? observedAnchor
4725
+ };
4726
+ }
4727
+ function collectJsFailureBlocks(input) {
4728
+ const blocks = [];
4729
+ let current = null;
4730
+ let section = null;
4731
+ let currentFile = null;
4732
+ const flushCurrent = () => {
4733
+ if (!current) {
4734
+ return;
4735
+ }
4736
+ blocks.push(current);
4737
+ current = null;
4738
+ };
4739
+ for (const rawLine of input.split("\n")) {
4740
+ const line = rawLine.trimEnd();
4741
+ const trimmed = line.trim();
4742
+ if (/⎯{2,}\s+Failed Tests?\s+\d+\s+⎯{2,}/.test(line)) {
4743
+ flushCurrent();
4744
+ section = "failed_tests";
4745
+ continue;
4746
+ }
4747
+ if (/⎯{2,}\s+Failed Suites?\s+\d+\s+⎯{2,}/.test(line)) {
4748
+ flushCurrent();
4749
+ section = "failed_suites";
4750
+ continue;
4751
+ }
4752
+ if (section && /^⎯{2,}.+⎯{2,}\s*$/.test(line)) {
4753
+ flushCurrent();
4754
+ section = null;
4755
+ continue;
4756
+ }
4757
+ const progress = line.match(
4758
+ /^(.+?\.(?:test|spec)\.[cm]?[jt]sx?(?:\s+>.+?)?)\s+(FAILED|ERROR)\s+\[[^\]]+\]\s*$/
4759
+ );
4760
+ if (progress) {
4761
+ flushCurrent();
4762
+ const label = normalizeJsFailureLabel(progress[1]);
4763
+ current = {
4764
+ label,
4765
+ status: progress[2] === "ERROR" ? "error" : "failed",
4766
+ detailLines: []
4767
+ };
4768
+ currentFile = extractJsTestFile(label);
4769
+ continue;
4770
+ }
4771
+ const failHeader = line.match(/^\s*FAIL\s+(.+)$/);
4772
+ if (failHeader) {
4773
+ const label = normalizeJsFailureLabel(failHeader[1]);
4774
+ if (extractJsTestFile(label)) {
4775
+ flushCurrent();
4776
+ current = {
4777
+ label,
4778
+ status: section === "failed_suites" || !label.includes(" > ") ? "error" : "failed",
4779
+ detailLines: []
4780
+ };
4781
+ currentFile = extractJsTestFile(label);
4782
+ continue;
4783
+ }
4784
+ }
4785
+ const failedTest = line.match(/^\s*×\s+(.+)$/);
4786
+ if (failedTest && (section === "failed_tests" || extractJsTestFile(failedTest[1]))) {
4787
+ flushCurrent();
4788
+ const candidate = normalizeJsFailureLabel(failedTest[1]);
4789
+ const file = extractJsTestFile(candidate) ?? currentFile;
4790
+ const label = file && !extractJsTestFile(candidate) ? `${file} > ${candidate}` : candidate;
4791
+ current = {
4792
+ label,
4793
+ status: "failed",
4794
+ detailLines: []
4795
+ };
4796
+ currentFile = extractJsTestFile(label) ?? currentFile;
4797
+ continue;
4798
+ }
4799
+ if (/^\s*(?:Tests?|Snapshots?|Test Files?|Test Suites?)\b/.test(line)) {
4800
+ flushCurrent();
4801
+ section = null;
4802
+ continue;
4803
+ }
4804
+ if (current && trimmed.length > 0) {
4805
+ current.detailLines.push(line);
4806
+ }
4807
+ }
4808
+ flushCurrent();
4809
+ return blocks;
4810
+ }
3364
4811
  function collectCollectionFailureItems(input) {
3365
4812
  const items = [];
3366
4813
  const lines = input.split("\n");
@@ -3368,6 +4815,24 @@ function collectCollectionFailureItems(input) {
3368
4815
  let pendingGenericReason = null;
3369
4816
  let currentAnchor = null;
3370
4817
  for (const line of lines) {
4818
+ const standaloneCollectionLabel = line.match(/No test suite found in file\s+(.+)$/i)?.[1] ?? line.match(/No test found in suite\s+(.+)$/i)?.[1] ?? line.match(/failed to load config from\s+(.+)$/i)?.[1];
4819
+ if (standaloneCollectionLabel) {
4820
+ const classification2 = classifyFailureReason(line, {
4821
+ duringCollection: true
4822
+ });
4823
+ if (classification2) {
4824
+ pushFocusedFailureItem(items, {
4825
+ label: cleanFailureLabel(standaloneCollectionLabel),
4826
+ reason: classification2.reason,
4827
+ group: classification2.group,
4828
+ ...resolveAnchorForLabel({
4829
+ label: cleanFailureLabel(standaloneCollectionLabel),
4830
+ observedAnchor: parseObservedAnchor(line)
4831
+ })
4832
+ });
4833
+ }
4834
+ continue;
4835
+ }
3371
4836
  const collecting = line.match(/^_+\s+ERROR collecting\s+(.+?)\s+_+\s*$/);
3372
4837
  if (collecting) {
3373
4838
  if (currentLabel && pendingGenericReason) {
@@ -3456,6 +4921,24 @@ function collectInlineFailureItems(input) {
3456
4921
  })
3457
4922
  });
3458
4923
  }
4924
+ for (const block of collectJsFailureBlocks(input)) {
4925
+ const resolved = classifyFailureLines({
4926
+ lines: block.detailLines,
4927
+ duringCollection: block.status === "error"
4928
+ });
4929
+ if (!resolved) {
4930
+ continue;
4931
+ }
4932
+ pushFocusedFailureItem(items, {
4933
+ label: block.label,
4934
+ reason: resolved.classification.reason,
4935
+ group: resolved.classification.group,
4936
+ ...resolveAnchorForLabel({
4937
+ label: block.label,
4938
+ observedAnchor: resolved.observedAnchor
4939
+ })
4940
+ });
4941
+ }
3459
4942
  return items;
3460
4943
  }
3461
4944
  function collectInlineFailureItemsWithStatus(input) {
@@ -3490,16 +4973,42 @@ function collectInlineFailureItemsWithStatus(input) {
3490
4973
  })
3491
4974
  });
3492
4975
  }
4976
+ for (const block of collectJsFailureBlocks(input)) {
4977
+ const resolved = classifyFailureLines({
4978
+ lines: block.detailLines,
4979
+ duringCollection: block.status === "error"
4980
+ });
4981
+ if (!resolved) {
4982
+ continue;
4983
+ }
4984
+ items.push({
4985
+ label: block.label,
4986
+ reason: resolved.classification.reason,
4987
+ group: resolved.classification.group,
4988
+ status: block.status,
4989
+ ...resolveAnchorForLabel({
4990
+ label: block.label,
4991
+ observedAnchor: resolved.observedAnchor
4992
+ })
4993
+ });
4994
+ }
3493
4995
  return items;
3494
4996
  }
3495
4997
  function collectStandaloneErrorClassifications(input) {
3496
4998
  const classifications = [];
3497
4999
  for (const line of input.split("\n")) {
5000
+ const trimmed = line.trim();
5001
+ if (!trimmed) {
5002
+ continue;
5003
+ }
3498
5004
  const standalone = line.match(/^\s*E\s+(.+)$/);
3499
- if (!standalone) {
5005
+ const candidate = standalone?.[1] ?? (/^(INTERNALERROR>|ConftestImportFailure\b|UsageError:|ERROR:\s*usage:|pytest:\s*error:)/i.test(
5006
+ trimmed
5007
+ ) ? trimmed : null);
5008
+ if (!candidate) {
3500
5009
  continue;
3501
5010
  }
3502
- const classification = classifyFailureReason(standalone[1], {
5011
+ const classification = classifyFailureReason(candidate, {
3503
5012
  duringCollection: false
3504
5013
  });
3505
5014
  if (!classification || classification.reason === "import error during collection") {
@@ -3615,6 +5124,9 @@ function collectFailureLabels(input) {
3615
5124
  pushLabel(summary[2], summary[1] === "FAILED" ? "failed" : "error");
3616
5125
  }
3617
5126
  }
5127
+ for (const block of collectJsFailureBlocks(input)) {
5128
+ pushLabel(block.label, block.status);
5129
+ }
3618
5130
  return labels;
3619
5131
  }
3620
5132
  function classifyBucketTypeFromReason(reason) {
@@ -3624,6 +5136,60 @@ function classifyBucketTypeFromReason(reason) {
3624
5136
  if (reason.startsWith("fixture guard:")) {
3625
5137
  return "fixture_guard_failure";
3626
5138
  }
5139
+ if (reason.startsWith("timeout:")) {
5140
+ return "timeout_failure";
5141
+ }
5142
+ if (reason.startsWith("permission:")) {
5143
+ return "permission_denied_failure";
5144
+ }
5145
+ if (reason.startsWith("async loop:")) {
5146
+ return "async_event_loop_failure";
5147
+ }
5148
+ if (reason.startsWith("fixture teardown:")) {
5149
+ return "fixture_teardown_failure";
5150
+ }
5151
+ if (reason.startsWith("db migration:")) {
5152
+ return "db_migration_failure";
5153
+ }
5154
+ if (reason.startsWith("configuration:")) {
5155
+ return "configuration_error";
5156
+ }
5157
+ if (reason.startsWith("xdist worker crash:")) {
5158
+ return "xdist_worker_crash";
5159
+ }
5160
+ if (reason.startsWith("type error:")) {
5161
+ return "type_error_failure";
5162
+ }
5163
+ if (reason.startsWith("resource leak:")) {
5164
+ return "resource_leak_warning";
5165
+ }
5166
+ if (reason.startsWith("django db access:")) {
5167
+ return "django_db_access_denied";
5168
+ }
5169
+ if (reason.startsWith("network:")) {
5170
+ return "network_failure";
5171
+ }
5172
+ if (reason.startsWith("segfault:")) {
5173
+ return "subprocess_crash_segfault";
5174
+ }
5175
+ if (reason.startsWith("flaky:")) {
5176
+ return "flaky_test_detected";
5177
+ }
5178
+ if (reason.startsWith("serialization:")) {
5179
+ return "serialization_encoding_failure";
5180
+ }
5181
+ if (reason.startsWith("file not found:")) {
5182
+ return "file_not_found_failure";
5183
+ }
5184
+ if (reason.startsWith("memory:")) {
5185
+ return "memory_error";
5186
+ }
5187
+ if (reason.startsWith("deprecation as error:")) {
5188
+ return "deprecation_warning_as_error";
5189
+ }
5190
+ if (reason.startsWith("xfail strict:")) {
5191
+ return "xfail_strict_unexpected_pass";
5192
+ }
3627
5193
  if (reason.startsWith("service unavailable:")) {
3628
5194
  return "service_unavailable";
3629
5195
  }
@@ -3633,6 +5199,9 @@ function classifyBucketTypeFromReason(reason) {
3633
5199
  if (reason.startsWith("auth bypass absent:")) {
3634
5200
  return "auth_bypass_absent";
3635
5201
  }
5202
+ if (reason.startsWith("snapshot mismatch:")) {
5203
+ return "snapshot_mismatch";
5204
+ }
3636
5205
  if (reason.startsWith("missing module:")) {
3637
5206
  return "import_dependency_failure";
3638
5207
  }
@@ -3645,9 +5214,6 @@ function classifyBucketTypeFromReason(reason) {
3645
5214
  return "unknown_failure";
3646
5215
  }
3647
5216
  function synthesizeSharedBlockerBucket(args) {
3648
- if (args.errors === 0) {
3649
- return null;
3650
- }
3651
5217
  const visibleReasonGroups = /* @__PURE__ */ new Map();
3652
5218
  for (const item of args.visibleErrorItems) {
3653
5219
  const entry = visibleReasonGroups.get(item.reason);
@@ -3662,7 +5228,7 @@ function synthesizeSharedBlockerBucket(args) {
3662
5228
  items: [item]
3663
5229
  });
3664
5230
  }
3665
- const top = [...visibleReasonGroups.entries()].filter(([, entry]) => entry.count >= 3).sort((left, right) => right[1].count - left[1].count)[0];
5231
+ const top = [...visibleReasonGroups.entries()].filter(([reason, entry]) => entry.count >= sharedBlockerThreshold(reason)).sort((left, right) => right[1].count - left[1].count)[0];
3666
5232
  const standaloneReasonGroups = /* @__PURE__ */ new Map();
3667
5233
  for (const classification of collectStandaloneErrorClassifications(args.input)) {
3668
5234
  const entry = standaloneReasonGroups.get(classification.reason);
@@ -3675,7 +5241,7 @@ function synthesizeSharedBlockerBucket(args) {
3675
5241
  group: classification.group
3676
5242
  });
3677
5243
  }
3678
- const standaloneTop = [...standaloneReasonGroups.entries()].filter(([, entry]) => entry.count >= 3).sort((left, right) => right[1].count - left[1].count)[0];
5244
+ const standaloneTop = [...standaloneReasonGroups.entries()].filter(([reason, entry]) => entry.count >= sharedBlockerThreshold(reason)).sort((left, right) => right[1].count - left[1].count)[0];
3679
5245
  const visibleTopReason = top?.[0];
3680
5246
  const visibleTopStats = top?.[1];
3681
5247
  const standaloneTopReason = standaloneTop?.[0];
@@ -3714,6 +5280,12 @@ function synthesizeSharedBlockerBucket(args) {
3714
5280
  let hint;
3715
5281
  if (envVar) {
3716
5282
  hint = `Set ${envVar} (or pass --pgtest-dsn) before rerunning DB-isolated tests.`;
5283
+ } else if (effectiveReason.startsWith("configuration:")) {
5284
+ hint = "Fix the pytest configuration or conftest import error before rerunning the suite.";
5285
+ } else if (effectiveReason.startsWith("xdist worker crash:")) {
5286
+ hint = "Check shared state, worker startup, or resource contention between xdist workers before rerunning.";
5287
+ } else if (effectiveReason.startsWith("network:")) {
5288
+ hint = "Restore DNS, TLS, or outbound network access for the affected dependency before rerunning.";
3717
5289
  } else if (effectiveReason.startsWith("fixture guard:")) {
3718
5290
  hint = "Unblock the required fixture or setup guard before rerunning the affected tests.";
3719
5291
  } else if (effectiveReason.startsWith("db refused:")) {
@@ -3728,6 +5300,12 @@ function synthesizeSharedBlockerBucket(args) {
3728
5300
  let headline;
3729
5301
  if (envVar) {
3730
5302
  headline = `Shared blocker: ${atLeastPrefix}${countText} errors require ${envVar} for DB-isolated tests.`;
5303
+ } else if (effectiveReason.startsWith("configuration:")) {
5304
+ headline = `Shared blocker: ${atLeastPrefix}${countText} visible failure${countText === 1 ? "" : "s"} are caused by a pytest configuration error.`;
5305
+ } else if (effectiveReason.startsWith("xdist worker crash:")) {
5306
+ headline = `Shared blocker: ${atLeastPrefix}${countText} errors are caused by xdist worker crashes.`;
5307
+ } else if (effectiveReason.startsWith("network:")) {
5308
+ headline = `Shared blocker: ${atLeastPrefix}${countText} errors are caused by a network dependency failure.`;
3731
5309
  } else if (effectiveReason.startsWith("fixture guard:")) {
3732
5310
  headline = `Shared blocker: ${atLeastPrefix}${countText} errors are gated by the same fixture/setup guard.`;
3733
5311
  } else if (effectiveReason.startsWith("db refused:")) {
@@ -3758,22 +5336,28 @@ function synthesizeSharedBlockerBucket(args) {
3758
5336
  };
3759
5337
  }
3760
5338
  function synthesizeImportDependencyBucket(args) {
3761
- if (args.errors === 0) {
3762
- return null;
3763
- }
3764
- const importItems = args.visibleErrorItems.filter((item) => item.reason.startsWith("missing module:"));
3765
- if (importItems.length < 2) {
5339
+ const visibleImportItems = args.visibleErrorItems.filter(
5340
+ (item) => item.reason.startsWith("missing module:")
5341
+ );
5342
+ const inlineImportItems = chooseStrongestFailureItems(
5343
+ args.inlineItems.filter((item) => item.reason.startsWith("missing module:"))
5344
+ );
5345
+ const importItems = visibleImportItems.length > 0 ? visibleImportItems : inlineImportItems.map((item) => ({
5346
+ ...item,
5347
+ status: "failed"
5348
+ }));
5349
+ if (importItems.length === 0) {
3766
5350
  return null;
3767
5351
  }
3768
5352
  const allVisibleErrorsAreImportRelated = args.visibleErrorItems.length > 0 && args.visibleErrorItems.every((item) => item.reason.startsWith("missing module:"));
3769
- const countClaimed = allVisibleErrorsAreImportRelated && importItems.length >= 3 && args.errors >= importItems.length ? args.errors : void 0;
5353
+ const countClaimed = allVisibleErrorsAreImportRelated && importItems.length >= 2 && args.errors >= importItems.length ? args.errors : void 0;
3770
5354
  const modules = Array.from(
3771
5355
  new Set(
3772
5356
  importItems.map((item) => item.reason.replace("missing module:", "").trim()).filter(Boolean)
3773
5357
  )
3774
5358
  ).slice(0, 6);
3775
5359
  const headlineCount = countClaimed ?? importItems.length;
3776
- const headline = countClaimed ? `Import/dependency blocker: ${headlineCount} errors are caused by missing dependencies during test collection.` : `Import/dependency blocker: at least ${headlineCount} visible errors are caused by missing dependencies during test collection.`;
5360
+ const headline = countClaimed ? `Import/dependency blocker: ${headlineCount} errors are caused by missing dependencies during test collection.` : `Import/dependency blocker: at least ${headlineCount} visible failure${headlineCount === 1 ? "" : "s"} are caused by missing dependencies during test collection.`;
3777
5361
  const summaryLines = [headline];
3778
5362
  if (modules.length > 0) {
3779
5363
  summaryLines.push(`Missing modules include ${modules.join(", ")}.`);
@@ -3783,7 +5367,7 @@ function synthesizeImportDependencyBucket(args) {
3783
5367
  headline,
3784
5368
  countVisible: importItems.length,
3785
5369
  countClaimed,
3786
- reason: "missing dependencies during test collection",
5370
+ reason: modules.length === 1 ? `missing module: ${modules[0]}` : "missing dependencies during test collection",
3787
5371
  representativeItems: importItems.slice(0, 4).map((item) => ({
3788
5372
  label: item.label,
3789
5373
  reason: item.reason,
@@ -3802,7 +5386,7 @@ function synthesizeImportDependencyBucket(args) {
3802
5386
  };
3803
5387
  }
3804
5388
  function isContractDriftLabel(label) {
3805
- return /(freeze|snapshot|contract|manifest|openapi)/i.test(label);
5389
+ return /(freeze|contract|manifest|openapi|golden)/i.test(label);
3806
5390
  }
3807
5391
  function looksLikeTaskKey(value) {
3808
5392
  return /^[a-z]+(?:_[a-z0-9]+)+$/i.test(value) && !value.startsWith("/api/");
@@ -3864,7 +5448,7 @@ function extractContractDriftEntities(input) {
3864
5448
  }
3865
5449
  function buildContractRepresentativeReason(args) {
3866
5450
  if (/openapi/i.test(args.label) && args.entities.apiPaths.length > 0) {
3867
- const nextPath = args.entities.apiPaths.find((path7) => !args.usedPaths.has(path7)) ?? args.entities.apiPaths[0];
5451
+ const nextPath = args.entities.apiPaths.find((path8) => !args.usedPaths.has(path8)) ?? args.entities.apiPaths[0];
3868
5452
  args.usedPaths.add(nextPath);
3869
5453
  return `added path: ${nextPath}`;
3870
5454
  }
@@ -3933,13 +5517,67 @@ function synthesizeContractDriftBucket(args) {
3933
5517
  overflowLabel: "changed entities"
3934
5518
  };
3935
5519
  }
5520
+ function synthesizeSnapshotMismatchBucket(args) {
5521
+ const snapshotItems = chooseStrongestFailureItems(
5522
+ args.inlineItems.filter((item) => item.reason.startsWith("snapshot mismatch:"))
5523
+ );
5524
+ if (snapshotItems.length === 0) {
5525
+ return null;
5526
+ }
5527
+ const countClaimed = args.snapshotFailures && args.snapshotFailures >= snapshotItems.length ? args.snapshotFailures : void 0;
5528
+ const countText = countClaimed ?? snapshotItems.length;
5529
+ const summaryLines = [
5530
+ `Snapshot mismatches: ${formatCount2(countText, "snapshot expectation")} ${countText === 1 ? "is" : "are"} out of date with current output.`
5531
+ ];
5532
+ return {
5533
+ type: "snapshot_mismatch",
5534
+ headline: summaryLines[0],
5535
+ countVisible: snapshotItems.length,
5536
+ countClaimed,
5537
+ reason: "snapshot mismatch: snapshot expectations differ from current output",
5538
+ representativeItems: snapshotItems.slice(0, 4),
5539
+ entities: snapshotItems.map((item) => item.label.split(" > ").slice(1).join(" > ").trim() || item.label).slice(0, 6),
5540
+ hint: "Update the snapshots if these output changes are intentional.",
5541
+ confidence: countClaimed ? 0.92 : 0.8,
5542
+ summaryLines,
5543
+ overflowCount: Math.max((countClaimed ?? snapshotItems.length) - Math.min(snapshotItems.length, 4), 0),
5544
+ overflowLabel: "snapshot failures"
5545
+ };
5546
+ }
5547
+ function synthesizeTimeoutBucket(args) {
5548
+ const timeoutItems = chooseStrongestFailureItems(
5549
+ args.inlineItems.filter((item) => item.reason.startsWith("timeout:"))
5550
+ );
5551
+ if (timeoutItems.length === 0) {
5552
+ return null;
5553
+ }
5554
+ const summaryLines = [
5555
+ `Timeout failures: ${formatCount2(timeoutItems.length, "test")} exceeded the configured timeout threshold.`
5556
+ ];
5557
+ return {
5558
+ type: "timeout_failure",
5559
+ headline: summaryLines[0],
5560
+ countVisible: timeoutItems.length,
5561
+ countClaimed: timeoutItems.length,
5562
+ reason: timeoutItems.length === 1 ? timeoutItems[0].reason : "timeout: tests exceeded the configured timeout threshold",
5563
+ representativeItems: timeoutItems.slice(0, 4),
5564
+ entities: timeoutItems.map((item) => item.label.split(" > ").slice(1).join(" > ").trim() || item.label).slice(0, 6),
5565
+ hint: "Check for deadlocks, slow setup, or increase the timeout threshold before rerunning.",
5566
+ confidence: 0.84,
5567
+ summaryLines,
5568
+ overflowCount: Math.max(timeoutItems.length - Math.min(timeoutItems.length, 4), 0),
5569
+ overflowLabel: "timeout failures"
5570
+ };
5571
+ }
3936
5572
  function analyzeTestStatus(input) {
3937
- const passed = getCount(input, "passed");
3938
- const failed = getCount(input, "failed");
3939
- const errors = Math.max(getCount(input, "errors"), getCount(input, "error"));
3940
- const skipped = getCount(input, "skipped");
5573
+ const runner = detectTestRunner(input);
5574
+ const counts = extractTestStatusCounts(input, runner);
5575
+ const passed = counts.passed;
5576
+ const failed = counts.failed;
5577
+ const errors = counts.errors;
5578
+ const skipped = counts.skipped;
3941
5579
  const collectionErrors = input.match(/(\d+)\s+errors?\s+during collection/i);
3942
- const noTestsCollected = /\bcollected\s+0\s+items\b/i.test(input) || /\bno tests ran\b/i.test(input);
5580
+ const noTestsCollected = /\bcollected\s+0\s+items\b/i.test(input) || /\bno tests ran\b/i.test(input) || /No test suite found in file/i.test(input) || /No test found in suite/i.test(input);
3943
5581
  const interrupted = /\binterrupted\b/i.test(input) || /\bKeyboardInterrupt\b/i.test(input);
3944
5582
  const collectionItems = chooseStrongestFailureItems(collectCollectionFailureItems(input));
3945
5583
  const inlineItems = chooseStrongestFailureItems(collectInlineFailureItems(input));
@@ -3966,7 +5604,8 @@ function analyzeTestStatus(input) {
3966
5604
  if (!sharedBlocker) {
3967
5605
  const importDependencyBucket = synthesizeImportDependencyBucket({
3968
5606
  errors,
3969
- visibleErrorItems
5607
+ visibleErrorItems,
5608
+ inlineItems
3970
5609
  });
3971
5610
  if (importDependencyBucket) {
3972
5611
  buckets.push(importDependencyBucket);
@@ -3979,11 +5618,26 @@ function analyzeTestStatus(input) {
3979
5618
  if (contractDrift) {
3980
5619
  buckets.push(contractDrift);
3981
5620
  }
5621
+ const snapshotMismatch = synthesizeSnapshotMismatchBucket({
5622
+ inlineItems,
5623
+ snapshotFailures: counts.snapshotFailures
5624
+ });
5625
+ if (snapshotMismatch) {
5626
+ buckets.push(snapshotMismatch);
5627
+ }
5628
+ const timeoutBucket = synthesizeTimeoutBucket({
5629
+ inlineItems
5630
+ });
5631
+ if (timeoutBucket) {
5632
+ buckets.push(timeoutBucket);
5633
+ }
3982
5634
  return {
5635
+ runner,
3983
5636
  passed,
3984
5637
  failed,
3985
5638
  errors,
3986
5639
  skipped,
5640
+ snapshotFailures: counts.snapshotFailures,
3987
5641
  noTestsCollected,
3988
5642
  interrupted,
3989
5643
  collectionErrorCount: collectionErrors ? Number(collectionErrors[1]) : void 0,
@@ -4495,6 +6149,7 @@ function buildGenericRawSlice(args) {
4495
6149
 
4496
6150
  // src/core/run.ts
4497
6151
  var RETRY_DELAY_MS = 300;
6152
+ var PROVIDER_PENDING_NOTICE_DELAY_MS = 150;
4498
6153
  function estimateTokenCount(text) {
4499
6154
  return Math.max(1, Math.ceil(text.length / 4));
4500
6155
  }
@@ -4515,6 +6170,8 @@ function logVerboseTestStatusTelemetry(args) {
4515
6170
  `${pc2.dim("sift")} diagnosis_complete_at_layer=${getDiagnosisCompleteAtLayer(args.contract)}`,
4516
6171
  `${pc2.dim("sift")} heuristic_short_circuit=${!args.contract.provider_used && args.contract.diagnosis_complete && !args.contract.raw_needed && !args.contract.provider_failed}`,
4517
6172
  `${pc2.dim("sift")} raw_input_chars=${args.request.stdin.length}`,
6173
+ `${pc2.dim("sift")} heuristic_input_chars=${args.heuristicInputChars}`,
6174
+ `${pc2.dim("sift")} heuristic_input_truncated=${args.heuristicInputTruncated}`,
4518
6175
  `${pc2.dim("sift")} prepared_input_chars=${args.prepared.meta.finalLength}`,
4519
6176
  `${pc2.dim("sift")} raw_slice_chars=${args.rawSliceChars ?? 0}`,
4520
6177
  `${pc2.dim("sift")} provider_input_chars=${args.providerInputChars ?? 0}`,
@@ -4556,6 +6213,7 @@ function buildDryRunOutput(args) {
4556
6213
  responseMode: args.responseMode,
4557
6214
  policy: args.request.policyName ?? null,
4558
6215
  heuristicOutput: args.heuristicOutput ?? null,
6216
+ heuristicInput: args.heuristicInput,
4559
6217
  input: {
4560
6218
  originalLength: args.prepared.meta.originalLength,
4561
6219
  finalLength: args.prepared.meta.finalLength,
@@ -4572,6 +6230,25 @@ function buildDryRunOutput(args) {
4572
6230
  async function delay(ms) {
4573
6231
  await new Promise((resolve) => setTimeout(resolve, ms));
4574
6232
  }
6233
+ function startProviderPendingNotice() {
6234
+ if (!process.stderr.isTTY) {
6235
+ return () => {
6236
+ };
6237
+ }
6238
+ const message = "sift waiting for provider...";
6239
+ let shown = false;
6240
+ const timer = setTimeout(() => {
6241
+ shown = true;
6242
+ process.stderr.write(`${message}\r`);
6243
+ }, PROVIDER_PENDING_NOTICE_DELAY_MS);
6244
+ return () => {
6245
+ clearTimeout(timer);
6246
+ if (!shown) {
6247
+ return;
6248
+ }
6249
+ process.stderr.write(`\r${" ".repeat(message.length)}\r`);
6250
+ };
6251
+ }
4575
6252
  function withInsufficientHint(args) {
4576
6253
  if (!isInsufficientSignalOutput(args.output)) {
4577
6254
  return args.output;
@@ -4592,22 +6269,27 @@ async function generateWithRetry(args) {
4592
6269
  responseMode: args.responseMode,
4593
6270
  jsonResponseFormat: args.request.config.provider.jsonResponseFormat
4594
6271
  });
6272
+ const stopPendingNotice = startProviderPendingNotice();
4595
6273
  try {
4596
- return await generate();
4597
- } catch (error) {
4598
- const reason = error instanceof Error ? error.message : "unknown_error";
4599
- if (!isRetriableReason(reason)) {
4600
- throw error;
4601
- }
4602
- if (args.request.config.runtime.verbose) {
4603
- process.stderr.write(
4604
- `${pc2.dim("sift")} retry=1 reason=${reason} delay_ms=${RETRY_DELAY_MS}
6274
+ try {
6275
+ return await generate();
6276
+ } catch (error) {
6277
+ const reason = error instanceof Error ? error.message : "unknown_error";
6278
+ if (!isRetriableReason(reason)) {
6279
+ throw error;
6280
+ }
6281
+ if (args.request.config.runtime.verbose) {
6282
+ process.stderr.write(
6283
+ `${pc2.dim("sift")} retry=1 reason=${reason} delay_ms=${RETRY_DELAY_MS}
4605
6284
  `
4606
- );
6285
+ );
6286
+ }
6287
+ await delay(RETRY_DELAY_MS);
4607
6288
  }
4608
- await delay(RETRY_DELAY_MS);
6289
+ return await generate();
6290
+ } finally {
6291
+ stopPendingNotice();
4609
6292
  }
4610
- return generate();
4611
6293
  }
4612
6294
  function hasRecognizableTestStatusSignal(input) {
4613
6295
  const analysis = analyzeTestStatus(input);
@@ -4662,11 +6344,22 @@ function buildTestStatusProviderFailureDecision(args) {
4662
6344
  }
4663
6345
  async function runSift(request) {
4664
6346
  const prepared = prepareInput(request.stdin, request.config.input);
6347
+ const heuristicInput = prepared.redacted;
6348
+ const heuristicInputTruncated = false;
6349
+ const heuristicPrepared = {
6350
+ ...prepared,
6351
+ truncated: heuristicInput,
6352
+ meta: {
6353
+ ...prepared.meta,
6354
+ finalLength: heuristicInput.length,
6355
+ truncatedApplied: heuristicInputTruncated
6356
+ }
6357
+ };
4665
6358
  const provider = createProvider(request.config);
4666
- const hasTestStatusSignal = request.policyName === "test-status" && hasRecognizableTestStatusSignal(prepared.truncated);
4667
- const testStatusAnalysis = hasTestStatusSignal ? analyzeTestStatus(prepared.truncated) : null;
6359
+ const hasTestStatusSignal = request.policyName === "test-status" && hasRecognizableTestStatusSignal(heuristicInput);
6360
+ const testStatusAnalysis = hasTestStatusSignal ? analyzeTestStatus(heuristicInput) : null;
4668
6361
  const testStatusDecision = hasTestStatusSignal && testStatusAnalysis ? buildTestStatusDiagnoseContract({
4669
- input: prepared.truncated,
6362
+ input: heuristicInput,
4670
6363
  analysis: testStatusAnalysis,
4671
6364
  resolvedTests: request.testStatusContext?.resolvedTests,
4672
6365
  remainingTests: request.testStatusContext?.remainingTests
@@ -4681,7 +6374,7 @@ async function runSift(request) {
4681
6374
  `
4682
6375
  );
4683
6376
  }
4684
- const heuristicOutput = request.policyName === "test-status" ? testStatusDecision?.contract.diagnosis_complete ? testStatusHeuristicOutput : null : applyHeuristicPolicy(request.policyName, prepared.truncated, request.detail);
6377
+ const heuristicOutput = request.policyName === "test-status" ? testStatusDecision?.contract.diagnosis_complete ? testStatusHeuristicOutput : null : applyHeuristicPolicy(request.policyName, heuristicInput, request.detail);
4685
6378
  if (heuristicOutput) {
4686
6379
  if (request.config.runtime.verbose) {
4687
6380
  process.stderr.write(`${pc2.dim("sift")} heuristic=${request.policyName}
@@ -4691,7 +6384,7 @@ async function runSift(request) {
4691
6384
  question: request.question,
4692
6385
  format: request.format,
4693
6386
  goal: request.goal,
4694
- input: prepared.truncated,
6387
+ input: heuristicInput,
4695
6388
  detail: request.detail,
4696
6389
  policyName: request.policyName,
4697
6390
  outputContract: request.policyName === "test-status" && request.goal === "diagnose" && request.format === "json" ? request.outputContract ?? TEST_STATUS_DIAGNOSE_JSON_CONTRACT : request.outputContract,
@@ -4711,6 +6404,11 @@ async function runSift(request) {
4711
6404
  prompt: heuristicPrompt.prompt,
4712
6405
  responseMode: heuristicPrompt.responseMode,
4713
6406
  prepared,
6407
+ heuristicInput: {
6408
+ length: heuristicInput.length,
6409
+ truncatedApplied: heuristicInputTruncated,
6410
+ strategy: "full-redacted"
6411
+ },
4714
6412
  heuristicOutput,
4715
6413
  strategy: "heuristic"
4716
6414
  });
@@ -4724,6 +6422,8 @@ async function runSift(request) {
4724
6422
  logVerboseTestStatusTelemetry({
4725
6423
  request,
4726
6424
  prepared,
6425
+ heuristicInputChars: heuristicInput.length,
6426
+ heuristicInputTruncated,
4727
6427
  contract: testStatusDecision.contract,
4728
6428
  finalOutput
4729
6429
  });
@@ -4775,6 +6475,11 @@ async function runSift(request) {
4775
6475
  prompt: prompt.prompt,
4776
6476
  responseMode: prompt.responseMode,
4777
6477
  prepared: providerPrepared2,
6478
+ heuristicInput: {
6479
+ length: heuristicInput.length,
6480
+ truncatedApplied: heuristicInputTruncated,
6481
+ strategy: "full-redacted"
6482
+ },
4778
6483
  heuristicOutput: testStatusHeuristicOutput,
4779
6484
  strategy: "hybrid"
4780
6485
  });
@@ -4788,10 +6493,11 @@ async function runSift(request) {
4788
6493
  });
4789
6494
  const supplement = parseTestStatusProviderSupplement(result.text);
4790
6495
  const mergedDecision = buildTestStatusDiagnoseContract({
4791
- input: prepared.truncated,
6496
+ input: heuristicInput,
4792
6497
  analysis: testStatusAnalysis,
4793
6498
  resolvedTests: request.testStatusContext?.resolvedTests,
4794
6499
  remainingTests: request.testStatusContext?.remainingTests,
6500
+ providerBucketSupplements: supplement.bucket_supplements,
4795
6501
  contractOverrides: {
4796
6502
  diagnosis_complete: supplement.diagnosis_complete,
4797
6503
  raw_needed: supplement.raw_needed,
@@ -4813,6 +6519,8 @@ async function runSift(request) {
4813
6519
  logVerboseTestStatusTelemetry({
4814
6520
  request,
4815
6521
  prepared,
6522
+ heuristicInputChars: heuristicInput.length,
6523
+ heuristicInputTruncated,
4816
6524
  contract: mergedDecision.contract,
4817
6525
  finalOutput,
4818
6526
  rawSliceChars: rawSlice.text.length,
@@ -4825,7 +6533,7 @@ async function runSift(request) {
4825
6533
  const failureDecision = buildTestStatusProviderFailureDecision({
4826
6534
  request,
4827
6535
  baseDecision: testStatusDecision,
4828
- input: prepared.truncated,
6536
+ input: heuristicInput,
4829
6537
  analysis: testStatusAnalysis,
4830
6538
  reason,
4831
6539
  rawSliceUsed: rawSlice.used,
@@ -4846,6 +6554,8 @@ async function runSift(request) {
4846
6554
  logVerboseTestStatusTelemetry({
4847
6555
  request,
4848
6556
  prepared,
6557
+ heuristicInputChars: heuristicInput.length,
6558
+ heuristicInputTruncated,
4849
6559
  contract: failureDecision.contract,
4850
6560
  finalOutput,
4851
6561
  rawSliceChars: rawSlice.text.length,
@@ -4884,6 +6594,11 @@ async function runSift(request) {
4884
6594
  prompt: providerPrompt.prompt,
4885
6595
  responseMode: providerPrompt.responseMode,
4886
6596
  prepared: providerPrepared,
6597
+ heuristicInput: {
6598
+ length: heuristicInput.length,
6599
+ truncatedApplied: heuristicInputTruncated,
6600
+ strategy: "full-redacted"
6601
+ },
4887
6602
  heuristicOutput: testStatusDecision ? testStatusHeuristicOutput : null,
4888
6603
  strategy: testStatusDecision ? "hybrid" : "provider"
4889
6604
  });
@@ -4925,16 +6640,35 @@ async function runSift(request) {
4925
6640
 
4926
6641
  // src/core/testStatusState.ts
4927
6642
  import fs5 from "fs";
4928
- import path6 from "path";
6643
+ import path7 from "path";
4929
6644
  import { z as z3 } from "zod";
4930
6645
  var detailSchema = z3.enum(["standard", "focused", "verbose"]);
4931
6646
  var failureBucketTypeSchema = z3.enum([
4932
6647
  "shared_environment_blocker",
4933
6648
  "fixture_guard_failure",
6649
+ "timeout_failure",
6650
+ "permission_denied_failure",
6651
+ "async_event_loop_failure",
6652
+ "fixture_teardown_failure",
6653
+ "db_migration_failure",
6654
+ "configuration_error",
6655
+ "xdist_worker_crash",
6656
+ "type_error_failure",
6657
+ "resource_leak_warning",
6658
+ "django_db_access_denied",
6659
+ "network_failure",
6660
+ "subprocess_crash_segfault",
6661
+ "flaky_test_detected",
6662
+ "serialization_encoding_failure",
6663
+ "file_not_found_failure",
6664
+ "memory_error",
6665
+ "deprecation_warning_as_error",
6666
+ "xfail_strict_unexpected_pass",
4934
6667
  "service_unavailable",
4935
6668
  "db_connection_failure",
4936
6669
  "auth_bypass_absent",
4937
6670
  "contract_snapshot_drift",
6671
+ "snapshot_mismatch",
4938
6672
  "import_dependency_failure",
4939
6673
  "collection_failure",
4940
6674
  "assertion_failure",
@@ -5036,7 +6770,7 @@ function buildBucketSignature(bucket) {
5036
6770
  ]);
5037
6771
  }
5038
6772
  function basenameMatches(value, matcher) {
5039
- return matcher.test(path6.basename(value));
6773
+ return matcher.test(path7.basename(value));
5040
6774
  }
5041
6775
  function isPytestExecutable(value) {
5042
6776
  return basenameMatches(value, /^pytest(?:\.exe)?$/i);
@@ -5230,7 +6964,7 @@ function tryReadCachedTestStatusRun(statePath = getDefaultTestStatusStatePath())
5230
6964
  }
5231
6965
  }
5232
6966
  function writeCachedTestStatusRun(state, statePath = getDefaultTestStatusStatePath()) {
5233
- fs5.mkdirSync(path6.dirname(statePath), {
6967
+ fs5.mkdirSync(path7.dirname(statePath), {
5234
6968
  recursive: true
5235
6969
  });
5236
6970
  fs5.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}
@@ -6039,6 +7773,7 @@ var defaultCliDeps = {
6039
7773
  configInit,
6040
7774
  configSetup,
6041
7775
  configShow,
7776
+ configUse,
6042
7777
  configValidate,
6043
7778
  runDoctor,
6044
7779
  listPresets,
@@ -6143,9 +7878,12 @@ function shouldKeepPresetPolicy(args) {
6143
7878
  return args.requestedFormat === void 0 || args.requestedFormat === args.presetFormat;
6144
7879
  }
6145
7880
  function applySharedOptions(command) {
6146
- return command.option("--provider <provider>", "Provider: openai | openai-compatible").option("--model <model>", "Model name").option("--base-url <url>", "Provider base URL").option(
7881
+ return command.option(
7882
+ "--provider <provider>",
7883
+ "Provider: openai | openai-compatible | openrouter"
7884
+ ).option("--model <model>", "Model name").option("--base-url <url>", "Provider base URL").option(
6147
7885
  "--api-key <key>",
6148
- "Provider API key (or set OPENAI_API_KEY for provider=openai; use SIFT_PROVIDER_API_KEY or endpoint-native envs for openai-compatible)"
7886
+ "Provider API key (or set OPENAI_API_KEY for provider=openai, OPENROUTER_API_KEY for provider=openrouter; use SIFT_PROVIDER_API_KEY or endpoint-native envs for openai-compatible)"
6149
7887
  ).option(
6150
7888
  "--json-response-format <mode>",
6151
7889
  "JSON response format mode: auto | on | off"
@@ -6690,10 +8428,10 @@ function createCliApp(args = {}) {
6690
8428
  }
6691
8429
  throw new Error(`Unknown agent action: ${action}`);
6692
8430
  });
6693
- cli.command("config <action>", "Config commands: setup | init | show | validate").usage("config <setup|init|show|validate> [options]").example("config setup").example("config setup --global").example("config setup --path ~/.config/sift/config.yaml").example("config init").example("config init --global").example("config show").example("config validate --config ./sift.config.yaml").option("--path <path>", "Target config path for init or setup").option(
8431
+ cli.command("config <action> [provider]", "Config commands: setup | init | show | validate | use").usage("config <setup|init|show|validate|use> [provider] [options]").example("config setup").example("config setup --global").example("config setup --path ~/.config/sift/config.yaml").example("config init").example("config init --global").example("config use openrouter").example("config show").example("config validate --config ./sift.config.yaml").option("--path <path>", "Target config path for init or setup").option(
6694
8432
  "--global",
6695
8433
  "Use the machine-wide config path (~/.config/sift/config.yaml) for init or setup"
6696
- ).option("--config <path>", "Path to config file").option("--show-secrets", "Show secret values in config show").action(async (action, options) => {
8434
+ ).option("--config <path>", "Path to config file").option("--show-secrets", "Show secret values in config show").action(async (action, provider, options) => {
6697
8435
  if (action === "setup") {
6698
8436
  process.exitCode = await deps.configSetup({
6699
8437
  targetPath: options.path,
@@ -6716,6 +8454,13 @@ function createCliApp(args = {}) {
6716
8454
  deps.configValidate(options.config);
6717
8455
  return;
6718
8456
  }
8457
+ if (action === "use") {
8458
+ if (!provider) {
8459
+ throw new Error("Missing provider name.");
8460
+ }
8461
+ deps.configUse(provider, options.config, env);
8462
+ return;
8463
+ }
6719
8464
  throw new Error(`Unknown config action: ${action}`);
6720
8465
  });
6721
8466
  cli.command("doctor", "Check which config is active and whether local setup looks complete").usage("doctor [options]").option("--config <path>", "Path to config file").action((options) => {