codex-chatgpt-control 0.1.0-alpha.1 → 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +55 -0
  3. package/contracts/v1/fixtures/backend-capabilities.json +7 -0
  4. package/contracts/v1/fixtures/blocker-explanation-profiles.json +114 -0
  5. package/contracts/v1/fixtures/command-descriptors.json +192 -15
  6. package/contracts/v1/fixtures/doctor-bridge-upload.json +8 -0
  7. package/contracts/v1/fixtures/doctor-scenario-preflight.json +108 -0
  8. package/contracts/v1/fixtures/existing-tab-diagnostics-blocker.json +57 -0
  9. package/contracts/v1/fixtures/files-preflight-success.json +32 -0
  10. package/contracts/v1/fixtures/help-root.json +1 -1
  11. package/contracts/v1/fixtures/output-json-parse-success.json +17 -2
  12. package/contracts/v1/fixtures/project-sources-plan-add.json +71 -0
  13. package/contracts/v1/fixtures/read-latest-clipped.json +31 -0
  14. package/contracts/v1/fixtures/reports-create-redacted.json +15 -0
  15. package/contracts/v1/fixtures/responses-basic-success.json +11 -0
  16. package/contracts/v1/fixtures/run-basic-success.json +15 -0
  17. package/contracts/v1/fixtures/run-browser-bridge-blocker.json +2 -2
  18. package/contracts/v1/fixtures/run-timeout-partial.json +2 -2
  19. package/contracts/v1/fixtures/runner-budget-blocker.json +1 -1
  20. package/contracts/v1/fixtures/stream-blocked.ndjson +1 -1
  21. package/contracts/v1/fixtures/stream-submitted-completed.ndjson +1 -1
  22. package/contracts/v1/manifest.json +30 -0
  23. package/contracts/v1/parity-suite.json +72 -6
  24. package/contracts/v1/schemas/backend-request.schema.json +3 -0
  25. package/contracts/v1/schemas/command-result.schema.json +48 -0
  26. package/contracts/v1/schemas/response.schema.json +28 -0
  27. package/contracts/v1/schemas/run-result.schema.json +84 -0
  28. package/contracts/v1/surface-drift-policy.json +210 -0
  29. package/dist/codex-chatgpt-control-backend.mjs +5137 -488
  30. package/dist/codex-chatgpt-control.bundle.mjs +5001 -286
  31. package/dist/src/backend/client.d.ts +6 -1
  32. package/dist/src/backend/client.js +5 -0
  33. package/dist/src/backend/protocol.d.ts +1 -1
  34. package/dist/src/backend/protocol.js +7 -0
  35. package/dist/src/backend/session.js +14 -0
  36. package/dist/src/browser/attach.js +128 -20
  37. package/dist/src/browser/downloads.js +2 -1
  38. package/dist/src/browser/page-state.js +6 -3
  39. package/dist/src/client.d.ts +16 -1
  40. package/dist/src/client.js +86 -7
  41. package/dist/src/commands/artifacts.d.ts +5 -0
  42. package/dist/src/commands/artifacts.js +432 -0
  43. package/dist/src/commands/context.js +5 -2
  44. package/dist/src/commands/doctor.d.ts +10 -2
  45. package/dist/src/commands/doctor.js +242 -14
  46. package/dist/src/commands/files.d.ts +2 -1
  47. package/dist/src/commands/files.js +352 -18
  48. package/dist/src/commands/messages.d.ts +2 -1
  49. package/dist/src/commands/messages.js +232 -40
  50. package/dist/src/commands/modes.js +202 -20
  51. package/dist/src/commands/project-sources.d.ts +10 -0
  52. package/dist/src/commands/project-sources.js +643 -0
  53. package/dist/src/commands/registry.js +76 -14
  54. package/dist/src/commands/reports.d.ts +9 -0
  55. package/dist/src/commands/reports.js +83 -6
  56. package/dist/src/commands/response-actions.js +33 -0
  57. package/dist/src/commands/sequence.js +14 -0
  58. package/dist/src/commands/threads.js +2 -1
  59. package/dist/src/commands/timeouts.d.ts +2 -0
  60. package/dist/src/commands/timeouts.js +18 -0
  61. package/dist/src/diagnostics/blockers.d.ts +54 -0
  62. package/dist/src/diagnostics/blockers.js +299 -0
  63. package/dist/src/dom/artifacts.d.ts +7 -0
  64. package/dist/src/dom/artifacts.js +198 -0
  65. package/dist/src/dom/generation-state.d.ts +8 -0
  66. package/dist/src/dom/generation-state.js +103 -0
  67. package/dist/src/dom/locale/am.d.ts +28 -0
  68. package/dist/src/dom/locale/am.js +28 -0
  69. package/dist/src/dom/locale/ar.d.ts +28 -0
  70. package/dist/src/dom/locale/ar.js +28 -0
  71. package/dist/src/dom/locale/bg.d.ts +28 -0
  72. package/dist/src/dom/locale/bg.js +28 -0
  73. package/dist/src/dom/locale/bn.d.ts +28 -0
  74. package/dist/src/dom/locale/bn.js +28 -0
  75. package/dist/src/dom/locale/bs.d.ts +28 -0
  76. package/dist/src/dom/locale/bs.js +28 -0
  77. package/dist/src/dom/locale/ca.d.ts +28 -0
  78. package/dist/src/dom/locale/ca.js +28 -0
  79. package/dist/src/dom/locale/cs.d.ts +28 -0
  80. package/dist/src/dom/locale/cs.js +28 -0
  81. package/dist/src/dom/locale/da.d.ts +28 -0
  82. package/dist/src/dom/locale/da.js +28 -0
  83. package/dist/src/dom/locale/de.d.ts +30 -0
  84. package/dist/src/dom/locale/de.js +30 -0
  85. package/dist/src/dom/locale/el.d.ts +28 -0
  86. package/dist/src/dom/locale/el.js +28 -0
  87. package/dist/src/dom/locale/en.d.ts +50 -0
  88. package/dist/src/dom/locale/en.js +56 -0
  89. package/dist/src/dom/locale/es-419.d.ts +28 -0
  90. package/dist/src/dom/locale/es-419.js +28 -0
  91. package/dist/src/dom/locale/es-ES.d.ts +28 -0
  92. package/dist/src/dom/locale/es-ES.js +28 -0
  93. package/dist/src/dom/locale/et.d.ts +28 -0
  94. package/dist/src/dom/locale/et.js +28 -0
  95. package/dist/src/dom/locale/fa.d.ts +28 -0
  96. package/dist/src/dom/locale/fa.js +28 -0
  97. package/dist/src/dom/locale/fi.d.ts +28 -0
  98. package/dist/src/dom/locale/fi.js +28 -0
  99. package/dist/src/dom/locale/fr-CA.d.ts +29 -0
  100. package/dist/src/dom/locale/fr-CA.js +29 -0
  101. package/dist/src/dom/locale/fr-FR.d.ts +30 -0
  102. package/dist/src/dom/locale/fr-FR.js +30 -0
  103. package/dist/src/dom/locale/gu.d.ts +28 -0
  104. package/dist/src/dom/locale/gu.js +28 -0
  105. package/dist/src/dom/locale/hi.d.ts +28 -0
  106. package/dist/src/dom/locale/hi.js +28 -0
  107. package/dist/src/dom/locale/hr.d.ts +28 -0
  108. package/dist/src/dom/locale/hr.js +28 -0
  109. package/dist/src/dom/locale/hu.d.ts +28 -0
  110. package/dist/src/dom/locale/hu.js +28 -0
  111. package/dist/src/dom/locale/hy.d.ts +28 -0
  112. package/dist/src/dom/locale/hy.js +28 -0
  113. package/dist/src/dom/locale/id.d.ts +28 -0
  114. package/dist/src/dom/locale/id.js +28 -0
  115. package/dist/src/dom/locale/index.d.ts +79 -0
  116. package/dist/src/dom/locale/index.js +202 -0
  117. package/dist/src/dom/locale/is.d.ts +28 -0
  118. package/dist/src/dom/locale/is.js +28 -0
  119. package/dist/src/dom/locale/it.d.ts +27 -0
  120. package/dist/src/dom/locale/it.js +27 -0
  121. package/dist/src/dom/locale/ja.d.ts +27 -0
  122. package/dist/src/dom/locale/ja.js +27 -0
  123. package/dist/src/dom/locale/ka.d.ts +28 -0
  124. package/dist/src/dom/locale/ka.js +28 -0
  125. package/dist/src/dom/locale/kk.d.ts +28 -0
  126. package/dist/src/dom/locale/kk.js +28 -0
  127. package/dist/src/dom/locale/kn.d.ts +28 -0
  128. package/dist/src/dom/locale/kn.js +28 -0
  129. package/dist/src/dom/locale/ko.d.ts +28 -0
  130. package/dist/src/dom/locale/ko.js +28 -0
  131. package/dist/src/dom/locale/lt.d.ts +28 -0
  132. package/dist/src/dom/locale/lt.js +28 -0
  133. package/dist/src/dom/locale/lv.d.ts +28 -0
  134. package/dist/src/dom/locale/lv.js +28 -0
  135. package/dist/src/dom/locale/mk.d.ts +28 -0
  136. package/dist/src/dom/locale/mk.js +28 -0
  137. package/dist/src/dom/locale/ml.d.ts +28 -0
  138. package/dist/src/dom/locale/ml.js +28 -0
  139. package/dist/src/dom/locale/mn.d.ts +28 -0
  140. package/dist/src/dom/locale/mn.js +28 -0
  141. package/dist/src/dom/locale/mr.d.ts +28 -0
  142. package/dist/src/dom/locale/mr.js +28 -0
  143. package/dist/src/dom/locale/ms.d.ts +28 -0
  144. package/dist/src/dom/locale/ms.js +28 -0
  145. package/dist/src/dom/locale/my.d.ts +28 -0
  146. package/dist/src/dom/locale/my.js +28 -0
  147. package/dist/src/dom/locale/nb.d.ts +28 -0
  148. package/dist/src/dom/locale/nb.js +28 -0
  149. package/dist/src/dom/locale/nl.d.ts +28 -0
  150. package/dist/src/dom/locale/nl.js +28 -0
  151. package/dist/src/dom/locale/pa.d.ts +28 -0
  152. package/dist/src/dom/locale/pa.js +28 -0
  153. package/dist/src/dom/locale/pl.d.ts +28 -0
  154. package/dist/src/dom/locale/pl.js +28 -0
  155. package/dist/src/dom/locale/pt-BR.d.ts +28 -0
  156. package/dist/src/dom/locale/pt-BR.js +28 -0
  157. package/dist/src/dom/locale/pt-PT.d.ts +28 -0
  158. package/dist/src/dom/locale/pt-PT.js +28 -0
  159. package/dist/src/dom/locale/ro.d.ts +28 -0
  160. package/dist/src/dom/locale/ro.js +28 -0
  161. package/dist/src/dom/locale/ru.d.ts +28 -0
  162. package/dist/src/dom/locale/ru.js +28 -0
  163. package/dist/src/dom/locale/sk.d.ts +28 -0
  164. package/dist/src/dom/locale/sk.js +28 -0
  165. package/dist/src/dom/locale/sl.d.ts +28 -0
  166. package/dist/src/dom/locale/sl.js +28 -0
  167. package/dist/src/dom/locale/so.d.ts +28 -0
  168. package/dist/src/dom/locale/so.js +28 -0
  169. package/dist/src/dom/locale/sq.d.ts +28 -0
  170. package/dist/src/dom/locale/sq.js +28 -0
  171. package/dist/src/dom/locale/sr.d.ts +28 -0
  172. package/dist/src/dom/locale/sr.js +28 -0
  173. package/dist/src/dom/locale/sv.d.ts +28 -0
  174. package/dist/src/dom/locale/sv.js +28 -0
  175. package/dist/src/dom/locale/sw.d.ts +28 -0
  176. package/dist/src/dom/locale/sw.js +28 -0
  177. package/dist/src/dom/locale/ta.d.ts +28 -0
  178. package/dist/src/dom/locale/ta.js +28 -0
  179. package/dist/src/dom/locale/te.d.ts +28 -0
  180. package/dist/src/dom/locale/te.js +28 -0
  181. package/dist/src/dom/locale/th.d.ts +28 -0
  182. package/dist/src/dom/locale/th.js +28 -0
  183. package/dist/src/dom/locale/tl.d.ts +28 -0
  184. package/dist/src/dom/locale/tl.js +28 -0
  185. package/dist/src/dom/locale/tr.d.ts +28 -0
  186. package/dist/src/dom/locale/tr.js +28 -0
  187. package/dist/src/dom/locale/types.d.ts +45 -0
  188. package/dist/src/dom/locale/types.js +1 -0
  189. package/dist/src/dom/locale/uk.d.ts +28 -0
  190. package/dist/src/dom/locale/uk.js +28 -0
  191. package/dist/src/dom/locale/ur.d.ts +28 -0
  192. package/dist/src/dom/locale/ur.js +28 -0
  193. package/dist/src/dom/locale/vi.d.ts +28 -0
  194. package/dist/src/dom/locale/vi.js +28 -0
  195. package/dist/src/dom/locale/zh-HK.d.ts +28 -0
  196. package/dist/src/dom/locale/zh-HK.js +28 -0
  197. package/dist/src/dom/locale/zh-Hans.d.ts +28 -0
  198. package/dist/src/dom/locale/zh-Hans.js +28 -0
  199. package/dist/src/dom/locale/zh-TW.d.ts +29 -0
  200. package/dist/src/dom/locale/zh-TW.js +29 -0
  201. package/dist/src/dom/locale-labels.d.ts +26 -0
  202. package/dist/src/dom/locale-labels.js +26 -0
  203. package/dist/src/dom/menus.d.ts +6 -0
  204. package/dist/src/dom/menus.js +34 -8
  205. package/dist/src/dom/message-format.d.ts +2 -1
  206. package/dist/src/dom/message-format.js +67 -10
  207. package/dist/src/dom/messages.d.ts +2 -1
  208. package/dist/src/dom/messages.js +2 -4
  209. package/dist/src/dom/selectors.d.ts +1 -0
  210. package/dist/src/dom/selectors.js +42 -15
  211. package/dist/src/index.d.ts +5 -0
  212. package/dist/src/index.js +5 -0
  213. package/dist/src/platform/local-paths.d.ts +4 -0
  214. package/dist/src/platform/local-paths.js +20 -0
  215. package/dist/src/runner/interruptions.js +15 -4
  216. package/dist/src/runner/responses.js +18 -1
  217. package/dist/src/runner/result.js +15 -0
  218. package/dist/src/runner/resume.js +1 -0
  219. package/dist/src/runner/types.d.ts +3 -0
  220. package/dist/src/safety/risk.d.ts +7 -0
  221. package/dist/src/safety/risk.js +7 -0
  222. package/dist/src/safety/untrusted-output.d.ts +73 -0
  223. package/dist/src/safety/untrusted-output.js +199 -0
  224. package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +1 -0
  225. package/dist/src/scripts/apply-intelligence-locale-captures.js +184 -0
  226. package/dist/src/scripts/capture-intelligence-locales.d.ts +25 -0
  227. package/dist/src/scripts/capture-intelligence-locales.js +669 -0
  228. package/dist/src/scripts/check-surface-drift.d.ts +1 -0
  229. package/dist/src/scripts/check-surface-drift.js +11 -0
  230. package/dist/src/scripts/live-smoke/scenarios.js +84 -0
  231. package/dist/src/scripts/locale-capture/language-coverage.d.ts +10 -0
  232. package/dist/src/scripts/locale-capture/language-coverage.js +39 -0
  233. package/dist/src/testing/surface-drift.d.ts +66 -0
  234. package/dist/src/testing/surface-drift.js +363 -0
  235. package/dist/src/types.d.ts +201 -2
  236. package/package.json +2 -1
  237. package/references/backend-protocol.md +93 -1
  238. package/references/language-coverage.md +142 -0
  239. package/references/localization.md +247 -0
  240. package/references/python-parity.md +63 -1
  241. package/references/safety.md +2 -0
  242. package/references/troubleshooting.md +58 -0
@@ -2,6 +2,7 @@ import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { attachAskRead, attachFiles, askMessage, bootstrap, composeMessage, createChatGPT, copyResponse, downloadLatestAttachment, newThread, openThread, readLatest, runSequence, searchThreads, selectTool, setMode, submitMessage, twoTurnExchange, waitAndRead, waitForMessage } from "../../index.js";
5
+ import { readAssistantGenerationState } from "../../dom/generation-state.js";
5
6
  import { contextEnvFlag, contextEnvText } from "./harness.js";
6
7
  export const requiredScenarios = [
7
8
  scenario("bootstrap-new-tab", true, () => true, async (context, meta) => {
@@ -447,6 +448,54 @@ export const requiredScenarios = [
447
448
  })
448
449
  ];
449
450
  export const optionalScenarios = [
451
+ scenario("long-response-partial-short-timeout", false, context => contextEnvFlag(context, "CHATGPT_E2E_LONG_PARTIAL"), async (context, meta) => {
452
+ const env = await bootNewThread(context, meta);
453
+ if ("status" in env)
454
+ return env;
455
+ const result = await askMessage(env, {
456
+ text: [
457
+ "Live capture stress test. Write exactly 180 numbered items.",
458
+ "Each item should be a complete sentence. Continue until item 180."
459
+ ].join("\n"),
460
+ wait: { timeoutMs: 30000, stableMs: 2000, pollMs: 750 },
461
+ read: { format: "markdown" }
462
+ });
463
+ const output = result.output_text ?? "";
464
+ const details = partialCaptureDetails(result, output);
465
+ return !result.ok && result.status === "partial" && result.data?.complete === false
466
+ ? pass(meta, result, details)
467
+ : fail(meta, result, details);
468
+ }),
469
+ scenario("stop-control-detection", false, context => contextEnvFlag(context, "CHATGPT_E2E_STOP_CONTROL"), async (context, meta) => {
470
+ const env = await bootNewThread(context, meta);
471
+ if ("status" in env)
472
+ return env;
473
+ const asked = await askMessage(env, {
474
+ text: [
475
+ "Live stop-control stress test. Write exactly 400 numbered items.",
476
+ "Each item should be a complete sentence. Continue until item 400."
477
+ ].join("\n"),
478
+ wait: false,
479
+ read: false
480
+ });
481
+ if (!asked.ok)
482
+ return fail(meta, asked);
483
+ const generation = await waitForGenerationSignal(env, 30000);
484
+ const waited = await waitForMessage(env, { timeoutMs: 1000, stableMs: 0, pollMs: 250 });
485
+ await stopGenerationIfVisible(env);
486
+ const output = waited.output_text ?? "";
487
+ const details = {
488
+ generationActive: generation.active,
489
+ generationStopped: generation.stopped,
490
+ generationSignals: generation.signals,
491
+ waitStatus: waited.status,
492
+ outputChars: output.length,
493
+ outputHash: hashPreview(output)
494
+ };
495
+ return generation.active && !(waited.ok && waited.data?.complete === true)
496
+ ? pass(meta, waited, details)
497
+ : fail(meta, waited, details);
498
+ }),
450
499
  scenario("download-generated-file", false, context => contextEnvFlag(context, "CHATGPT_E2E_DOWNLOAD"), async (context, meta) => {
451
500
  const env = await bootNewThread(context, meta);
452
501
  if ("status" in env)
@@ -640,6 +689,41 @@ function responseCommand(response) {
640
689
  context: { timestamp: new Date(response.created_at * 1000).toISOString() }
641
690
  };
642
691
  }
692
+ function partialCaptureDetails(result, output) {
693
+ return {
694
+ resultStatus: result.status,
695
+ complete: result.data?.complete,
696
+ outputChars: output.length,
697
+ outputHash: hashPreview(output)
698
+ };
699
+ }
700
+ async function waitForGenerationSignal(env, timeoutMs) {
701
+ const started = Date.now();
702
+ while (Date.now() - started < timeoutMs) {
703
+ if (env.page !== undefined) {
704
+ const state = await readAssistantGenerationState(env.page).catch(() => ({ active: false, stopped: false, signals: [] }));
705
+ if (state.active || state.stopped) {
706
+ return state;
707
+ }
708
+ }
709
+ await env.page?.waitForTimeout?.(500);
710
+ }
711
+ return { active: false, stopped: false, signals: [] };
712
+ }
713
+ async function stopGenerationIfVisible(env) {
714
+ const stop = env.page?.getByRole?.("button", { name: /Stop answering|Stop generating|Stop streaming|Cancel/i });
715
+ const clicked = stop?.click?.({ timeoutMs: 5000 });
716
+ if (clicked !== undefined) {
717
+ await clicked.catch(() => undefined);
718
+ }
719
+ }
720
+ function hashPreview(text) {
721
+ let hash = 0;
722
+ for (const char of text) {
723
+ hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0;
724
+ }
725
+ return Math.abs(hash).toString(16);
726
+ }
643
727
  async function tempFile(name, body) {
644
728
  const dir = await mkdtemp(join(tmpdir(), "chatgpt-live-smoke-"));
645
729
  const file = join(dir, name);
@@ -0,0 +1,10 @@
1
+ export type CoverageLanguage = {
2
+ language: string;
3
+ nativeName: string;
4
+ bcp47: string;
5
+ speakers: string;
6
+ status: string;
7
+ };
8
+ export declare function parseLanguageCoverageMarkdown(markdown: string): CoverageLanguage[];
9
+ export declare function readLanguageCoverage(path: string): Promise<CoverageLanguage[]>;
10
+ export declare function nonEnglishLanguages(languages: readonly CoverageLanguage[]): CoverageLanguage[];
@@ -0,0 +1,39 @@
1
+ import { readFile } from "node:fs/promises";
2
+ const TABLE_ROW_PATTERNS = [
3
+ /^\|\s*[^|]+?\s*\|\s*(?<language>[^|]+?)\s*\|\s*(?<native>[^|]+?)\s*\|\s*`(?<bcp47>[^`]+)`\s*\|\s*(?<speakers>[^|]+?)\s*\|\s*(?<status>[^|]+?)\s*\|/,
4
+ /^\|\s*(?<language>[^|]+?)\s*\|\s*(?<native>[^|]+?)\s*\|\s*`(?<bcp47>[^`]+)`\s*\|\s*(?<speakers>[^|]+?)\s*\|\s*(?<status>[^|]+?)\s*\|/,
5
+ ];
6
+ export function parseLanguageCoverageMarkdown(markdown) {
7
+ const seen = new Set();
8
+ const languages = [];
9
+ for (const line of markdown.split(/\r?\n/)) {
10
+ const match = TABLE_ROW_PATTERNS.map(pattern => pattern.exec(line)).find(Boolean);
11
+ const groups = match?.groups;
12
+ if (groups === undefined)
13
+ continue;
14
+ const bcp47 = groups.bcp47?.trim();
15
+ if (bcp47 === undefined || bcp47.length === 0 || seen.has(bcp47))
16
+ continue;
17
+ const language = groups.language?.trim();
18
+ const nativeName = groups.native?.trim();
19
+ const speakers = groups.speakers?.trim();
20
+ const status = groups.status?.trim();
21
+ if (language === undefined
22
+ || nativeName === undefined
23
+ || speakers === undefined
24
+ || status === undefined
25
+ || language.length === 0
26
+ || nativeName.length === 0) {
27
+ continue;
28
+ }
29
+ seen.add(bcp47);
30
+ languages.push({ language, nativeName, bcp47, speakers, status });
31
+ }
32
+ return languages;
33
+ }
34
+ export async function readLanguageCoverage(path) {
35
+ return parseLanguageCoverageMarkdown(await readFile(path, "utf8"));
36
+ }
37
+ export function nonEnglishLanguages(languages) {
38
+ return languages.filter(language => !/^en(?:-|$)/i.test(language.bcp47));
39
+ }
@@ -0,0 +1,66 @@
1
+ import { type CommandDescriptor } from "../commands/registry.js";
2
+ type DatedExemption = {
3
+ date: string;
4
+ reason: string;
5
+ };
6
+ type CommandExemption = DatedExemption & {
7
+ command: string;
8
+ };
9
+ type DescriptorExemption = DatedExemption & {
10
+ descriptor: string;
11
+ };
12
+ type DocAnchor = {
13
+ id: string;
14
+ paths: string[];
15
+ terms: string[];
16
+ };
17
+ type SurfaceDriftPolicy = {
18
+ schemaVersion: string;
19
+ backendCommandDescriptorExemptions: CommandExemption[];
20
+ descriptorBackendCommandExemptions: DescriptorExemption[];
21
+ backendDispatchExemptions: CommandExemption[];
22
+ pythonFacadeExemptions: CommandExemption[];
23
+ generatedBlockerDocs: string[];
24
+ docAnchors: DocAnchor[];
25
+ };
26
+ export type SurfaceCommandDescriptor = Pick<CommandDescriptor, "name" | "blockers">;
27
+ export type SurfaceBlockerExplanation = {
28
+ kind: string;
29
+ title: string;
30
+ category: string;
31
+ severity: string;
32
+ userActionRequired: boolean;
33
+ nextCommands: string[];
34
+ };
35
+ export type SurfaceDriftModel = {
36
+ backendCommands: string[];
37
+ backendCapabilityCommands: string[];
38
+ backendDispatchCommands: string[];
39
+ commandDescriptors: SurfaceCommandDescriptor[];
40
+ commandDescriptorFixtureNames: string[];
41
+ paritySuiteCommands: string[];
42
+ pythonCommands: string[];
43
+ blockerKinds: string[];
44
+ blockerExplanationFixtureKinds: string[];
45
+ blockerExplanations: SurfaceBlockerExplanation[];
46
+ docs: Record<string, string>;
47
+ policy: SurfaceDriftPolicy;
48
+ };
49
+ export type SurfaceDriftReport = {
50
+ commandCount: number;
51
+ descriptorCount: number;
52
+ blockerKindCount: number;
53
+ pythonCommandCount: number;
54
+ generatedDocsChecked: number;
55
+ docAnchorsChecked: number;
56
+ };
57
+ export type SurfaceDriftValidationResult = {
58
+ ok: boolean;
59
+ errors: string[];
60
+ report: SurfaceDriftReport;
61
+ };
62
+ export declare function validateSurfaceDrift(packageRootInput?: URL | string): SurfaceDriftReport;
63
+ export declare function collectSurfaceDriftModel(packageRootInput?: URL | string): SurfaceDriftModel;
64
+ export declare function validateSurfaceDriftModel(model: SurfaceDriftModel): SurfaceDriftValidationResult;
65
+ export declare function generatedBlockerCoverageSection(explanations: SurfaceBlockerExplanation[]): string;
66
+ export {};
@@ -0,0 +1,363 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { backendCommands } from "../backend/protocol.js";
5
+ import { commandDescriptors } from "../commands/registry.js";
6
+ import { explainCommandBlocker } from "../diagnostics/blockers.js";
7
+ const POLICY_SCHEMA_VERSION = "chatgpt.browser_control.surface_drift_policy.v1";
8
+ const GENERATED_BLOCKERS_START = "<!-- surface-drift:blocker-kind-coverage:start -->";
9
+ const GENERATED_BLOCKERS_END = "<!-- surface-drift:blocker-kind-coverage:end -->";
10
+ export function validateSurfaceDrift(packageRootInput = defaultPackageRoot()) {
11
+ const result = validateSurfaceDriftModel(collectSurfaceDriftModel(packageRootInput));
12
+ if (!result.ok) {
13
+ throw new Error(`Surface drift validation failed:\n- ${result.errors.join("\n- ")}`);
14
+ }
15
+ return result.report;
16
+ }
17
+ export function collectSurfaceDriftModel(packageRootInput = defaultPackageRoot()) {
18
+ const packageRoot = toPath(packageRootInput);
19
+ const contractRoot = join(packageRoot, "contracts", "v1");
20
+ const policy = readJson(join(contractRoot, "surface-drift-policy.json"));
21
+ const repoRoot = resolve(packageRoot, "..", "..");
22
+ const pythonRoot = resolvePythonRoot(packageRoot);
23
+ const blockerKinds = readBlockerKinds(join(packageRoot, "src", "types.ts"));
24
+ const descriptorFixture = readJson(join(contractRoot, "fixtures", "command-descriptors.json"));
25
+ const capabilitiesFixture = readJson(join(contractRoot, "fixtures", "backend-capabilities.json"));
26
+ const blockerProfilesFixture = readJson(join(contractRoot, "fixtures", "blocker-explanation-profiles.json"));
27
+ const paritySuite = readJson(join(contractRoot, "parity-suite.json"));
28
+ const docs = collectDocs(packageRoot, policy, pythonRoot);
29
+ return {
30
+ backendCommands: [...backendCommands],
31
+ backendCapabilityCommands: capabilitiesFixture.commands ?? capabilitiesFixture.result?.commands ?? [],
32
+ backendDispatchCommands: readBackendDispatchCommands(join(packageRoot, "src", "backend", "session.ts")),
33
+ commandDescriptors: commandDescriptors().map(descriptor => ({ name: descriptor.name, blockers: [...descriptor.blockers] })),
34
+ commandDescriptorFixtureNames: (descriptorFixture.result ?? []).map(descriptor => descriptor.name),
35
+ paritySuiteCommands: Object.keys(paritySuite.backendCommands ?? {}),
36
+ pythonCommands: pythonRoot === undefined ? [] : readPythonCommands(pythonRoot, backendCommands),
37
+ blockerKinds,
38
+ blockerExplanationFixtureKinds: (blockerProfilesFixture.result?.profiles ?? []).map(profile => profile.kind),
39
+ blockerExplanations: blockerKinds.map(kind => {
40
+ const explanation = explainCommandBlocker({ kind: kind, message: `Synthetic ${kind} blocker for surface drift validation.` });
41
+ return {
42
+ kind,
43
+ title: explanation.title,
44
+ category: explanation.category,
45
+ severity: explanation.severity,
46
+ userActionRequired: explanation.userActionRequired,
47
+ nextCommands: [...explanation.nextCommands]
48
+ };
49
+ }),
50
+ docs,
51
+ policy: normalizePolicyForLayout(policy, repoRoot)
52
+ };
53
+ }
54
+ export function validateSurfaceDriftModel(model) {
55
+ const errors = [];
56
+ const backendCommandsSet = new Set(model.backendCommands);
57
+ const descriptorNames = sortedUnique(model.commandDescriptors.map(descriptor => descriptor.name));
58
+ const descriptorNameSet = new Set(descriptorNames);
59
+ const blockerKindSet = new Set(model.blockerKinds);
60
+ const backendDescriptorExemptions = new Set(model.policy.backendCommandDescriptorExemptions.map(exemption => exemption.command));
61
+ const descriptorBackendExemptions = new Set(model.policy.descriptorBackendCommandExemptions.map(exemption => exemption.descriptor));
62
+ const backendDispatchExemptions = new Set(model.policy.backendDispatchExemptions.map(exemption => exemption.command));
63
+ const pythonFacadeExemptions = new Set(model.policy.pythonFacadeExemptions.map(exemption => exemption.command));
64
+ validatePolicy(model, errors);
65
+ compareSets("backend capabilities fixture commands", model.backendCapabilityCommands, model.backendCommands, errors);
66
+ compareSets("command descriptor fixture names", model.commandDescriptorFixtureNames, descriptorNames, errors);
67
+ compareSets("parity-suite backend command coverage", model.paritySuiteCommands, model.backendCommands, errors);
68
+ compareSets("blocker explanation profile fixture kinds", model.blockerExplanationFixtureKinds, model.blockerKinds, errors);
69
+ for (const command of model.backendCommands) {
70
+ if (!descriptorNameSet.has(command) && !backendDescriptorExemptions.has(command)) {
71
+ errors.push(`backend command ${command} is missing from command descriptors and has no exemption`);
72
+ }
73
+ if (!model.backendDispatchCommands.includes(command) && !backendDispatchExemptions.has(command)) {
74
+ errors.push(`backend command ${command} is missing from backend dispatch and has no exemption`);
75
+ }
76
+ if (!model.pythonCommands.includes(command) && !pythonFacadeExemptions.has(command)) {
77
+ errors.push(`backend command ${command} is missing from Python facade coverage and has no exemption`);
78
+ }
79
+ }
80
+ for (const descriptor of model.commandDescriptors) {
81
+ if (!backendCommandsSet.has(descriptor.name) && !descriptorBackendExemptions.has(descriptor.name)) {
82
+ errors.push(`command descriptor ${descriptor.name} is not a backend command and has no exemption`);
83
+ }
84
+ for (const blocker of descriptor.blockers) {
85
+ if (!blockerKindSet.has(blocker)) {
86
+ errors.push(`command descriptor ${descriptor.name} references unknown blocker ${blocker}`);
87
+ }
88
+ }
89
+ }
90
+ for (const explanation of model.blockerExplanations) {
91
+ if (!blockerKindSet.has(explanation.kind)) {
92
+ errors.push(`blocker explanation references unknown kind ${explanation.kind}`);
93
+ }
94
+ if (explanation.kind !== "unknown" && explanation.title === "Unknown blocker") {
95
+ errors.push(`blocker kind ${explanation.kind} falls back to the unknown blocker explanation`);
96
+ }
97
+ for (const nextCommand of explanation.nextCommands) {
98
+ if (!backendCommandsSet.has(nextCommand) && !descriptorNameSet.has(nextCommand)) {
99
+ errors.push(`blocker kind ${explanation.kind} references unknown next command ${nextCommand}`);
100
+ }
101
+ }
102
+ }
103
+ const expectedBlockerSection = generatedBlockerCoverageSection(model.blockerExplanations);
104
+ let generatedDocsChecked = 0;
105
+ for (const docPath of model.policy.generatedBlockerDocs) {
106
+ const text = model.docs[docPath];
107
+ if (text === undefined)
108
+ continue;
109
+ generatedDocsChecked += 1;
110
+ if (normalizeLineEndings(extractGeneratedBlockerCoverage(text)) !== expectedBlockerSection) {
111
+ errors.push(`generated blocker coverage section is stale in ${docPath}`);
112
+ }
113
+ }
114
+ if (generatedDocsChecked === 0) {
115
+ errors.push("no generated blocker coverage docs were found");
116
+ }
117
+ let docAnchorsChecked = 0;
118
+ for (const anchor of model.policy.docAnchors) {
119
+ const existingPaths = anchor.paths.filter(path => model.docs[path] !== undefined);
120
+ if (existingPaths.length === 0) {
121
+ errors.push(`doc anchor ${anchor.id} has no existing checked paths`);
122
+ continue;
123
+ }
124
+ for (const docPath of existingPaths) {
125
+ const text = model.docs[docPath];
126
+ if (text === undefined)
127
+ continue;
128
+ docAnchorsChecked += anchor.terms.length;
129
+ for (const term of anchor.terms) {
130
+ if (!text.includes(term)) {
131
+ errors.push(`doc anchor ${anchor.id} missing ${term} in ${docPath}`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return {
137
+ ok: errors.length === 0,
138
+ errors,
139
+ report: {
140
+ commandCount: model.backendCommands.length,
141
+ descriptorCount: descriptorNames.length,
142
+ blockerKindCount: model.blockerKinds.length,
143
+ pythonCommandCount: model.pythonCommands.length,
144
+ generatedDocsChecked,
145
+ docAnchorsChecked
146
+ }
147
+ };
148
+ }
149
+ export function generatedBlockerCoverageSection(explanations) {
150
+ return [
151
+ GENERATED_BLOCKERS_START,
152
+ "## Blocker Kind Coverage",
153
+ "",
154
+ "This section is checked by `npm run docs:drift`. Keep it aligned with `BlockerKind`, `explainCommandBlocker(...)`, command descriptors, and public troubleshooting coverage.",
155
+ "",
156
+ ...explanations.map(explanation => `- \`${explanation.kind}\`: ${explanation.title} (category: \`${explanation.category}\`, severity: \`${explanation.severity}\`, user action: ${explanation.userActionRequired ? "yes" : "no"})`),
157
+ GENERATED_BLOCKERS_END
158
+ ].join("\n");
159
+ }
160
+ function collectDocs(packageRoot, policy, pythonRoot) {
161
+ const paths = new Set([
162
+ ...policy.generatedBlockerDocs,
163
+ ...policy.docAnchors.flatMap(anchor => anchor.paths)
164
+ ]);
165
+ const docs = {};
166
+ for (const docPath of paths) {
167
+ const absolutePath = resolveDocPath(packageRoot, docPath, pythonRoot);
168
+ if (absolutePath === undefined)
169
+ continue;
170
+ if (existsSync(absolutePath) && statSync(absolutePath).isFile()) {
171
+ docs[docPath] = readFileSync(absolutePath, "utf8");
172
+ }
173
+ }
174
+ return docs;
175
+ }
176
+ function resolveDocPath(packageRoot, docPath, pythonRoot) {
177
+ const pythonPrefix = "$pythonRoot/";
178
+ if (docPath.startsWith(pythonPrefix)) {
179
+ if (pythonRoot === undefined)
180
+ return undefined;
181
+ return join(pythonRoot, docPath.slice(pythonPrefix.length));
182
+ }
183
+ return resolve(packageRoot, docPath);
184
+ }
185
+ function extractGeneratedBlockerCoverage(text) {
186
+ const start = text.indexOf(GENERATED_BLOCKERS_START);
187
+ const end = text.indexOf(GENERATED_BLOCKERS_END);
188
+ if (start < 0 || end < start)
189
+ return undefined;
190
+ return text.slice(start, end + GENERATED_BLOCKERS_END.length);
191
+ }
192
+ function normalizeLineEndings(text) {
193
+ return text?.replace(/\r\n?/g, "\n");
194
+ }
195
+ function validatePolicy(model, errors) {
196
+ const policy = model.policy;
197
+ if (policy.schemaVersion !== POLICY_SCHEMA_VERSION) {
198
+ errors.push(`surface-drift-policy.json has unsupported schemaVersion ${String(policy.schemaVersion)}`);
199
+ }
200
+ const backendCommandsSet = new Set(model.backendCommands);
201
+ const descriptorNames = new Set(model.commandDescriptors.map(descriptor => descriptor.name));
202
+ const commandExemptionGroups = [
203
+ ["backendCommandDescriptorExemptions", policy.backendCommandDescriptorExemptions, backendCommandsSet],
204
+ ["backendDispatchExemptions", policy.backendDispatchExemptions, backendCommandsSet],
205
+ ["pythonFacadeExemptions", policy.pythonFacadeExemptions, backendCommandsSet]
206
+ ];
207
+ for (const [group, exemptions, allowedCommands] of commandExemptionGroups) {
208
+ validateUniqueExemptions(group, exemptions.map(exemption => exemption.command), errors);
209
+ for (const exemption of exemptions) {
210
+ validateDatedExemption(`${group}.${exemption.command}`, exemption, errors);
211
+ if (!allowedCommands.has(exemption.command)) {
212
+ errors.push(`${group}.${exemption.command} does not reference a backend command`);
213
+ }
214
+ }
215
+ }
216
+ validateUniqueExemptions("descriptorBackendCommandExemptions", policy.descriptorBackendCommandExemptions.map(exemption => exemption.descriptor), errors);
217
+ for (const exemption of policy.descriptorBackendCommandExemptions) {
218
+ validateDatedExemption(`descriptorBackendCommandExemptions.${exemption.descriptor}`, exemption, errors);
219
+ if (!descriptorNames.has(exemption.descriptor)) {
220
+ errors.push(`descriptorBackendCommandExemptions.${exemption.descriptor} does not reference a command descriptor`);
221
+ }
222
+ }
223
+ const docAnchorIds = policy.docAnchors.map(anchor => anchor.id);
224
+ validateUniqueExemptions("docAnchors", docAnchorIds, errors);
225
+ for (const anchor of policy.docAnchors) {
226
+ if (anchor.paths.length === 0)
227
+ errors.push(`doc anchor ${anchor.id} must include at least one path`);
228
+ if (anchor.terms.length === 0)
229
+ errors.push(`doc anchor ${anchor.id} must include at least one term`);
230
+ }
231
+ }
232
+ function validateDatedExemption(label, exemption, errors) {
233
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(exemption.date)) {
234
+ errors.push(`${label} must include a YYYY-MM-DD date`);
235
+ }
236
+ if (exemption.reason.trim().length < 12) {
237
+ errors.push(`${label} must include a concrete reason`);
238
+ }
239
+ }
240
+ function validateUniqueExemptions(label, values, errors) {
241
+ const seen = new Set();
242
+ for (const value of values) {
243
+ if (seen.has(value))
244
+ errors.push(`${label} contains duplicate entry ${value}`);
245
+ seen.add(value);
246
+ }
247
+ }
248
+ function compareSets(label, actual, expected, errors) {
249
+ const actualSorted = sortedUnique(actual);
250
+ const expectedSorted = sortedUnique(expected);
251
+ const actualSet = new Set(actualSorted);
252
+ const expectedSet = new Set(expectedSorted);
253
+ const missing = expectedSorted.filter(item => !actualSet.has(item));
254
+ const extra = actualSorted.filter(item => !expectedSet.has(item));
255
+ if (missing.length > 0)
256
+ errors.push(`${label} missing: ${missing.join(", ")}`);
257
+ if (extra.length > 0)
258
+ errors.push(`${label} extra: ${extra.join(", ")}`);
259
+ }
260
+ function normalizePolicyForLayout(policy, repoRoot) {
261
+ if (existsSync(join(repoRoot, "packages", "node")) && !existsSync(join(repoRoot, "tools", "public-export"))) {
262
+ return {
263
+ ...policy,
264
+ generatedBlockerDocs: policy.generatedBlockerDocs.filter(path => !path.includes("tools/public-export")),
265
+ docAnchors: policy.docAnchors.map(anchor => ({
266
+ ...anchor,
267
+ paths: anchor.paths.filter(path => !path.includes("tools/public-export"))
268
+ }))
269
+ };
270
+ }
271
+ return policy;
272
+ }
273
+ function readBlockerKinds(typesPath) {
274
+ const text = readFileSync(typesPath, "utf8");
275
+ const match = text.match(/export type BlockerKind =([\s\S]*?);/);
276
+ if (match === null || match[1] === undefined) {
277
+ throw new Error("Unable to find BlockerKind union in src/types.ts.");
278
+ }
279
+ return [...match[1].matchAll(/"([^"]+)"/g)].map(item => {
280
+ const kind = item[1];
281
+ if (kind === undefined)
282
+ throw new Error("Unable to parse BlockerKind literal.");
283
+ return kind;
284
+ });
285
+ }
286
+ function readBackendDispatchCommands(sessionPath) {
287
+ const text = readFileSync(sessionPath, "utf8");
288
+ const match = text.match(/switch \(request\.command\) \{([\s\S]*?)\n \}/);
289
+ if (match === null || match[1] === undefined) {
290
+ throw new Error("Unable to find backend command dispatch switch.");
291
+ }
292
+ return sortedUnique([...match[1].matchAll(/case "([^"]+)":/g)].map(item => {
293
+ const command = item[1];
294
+ if (command === undefined)
295
+ throw new Error("Unable to parse backend dispatch command.");
296
+ return command;
297
+ }));
298
+ }
299
+ function readPythonCommands(pythonRoot, knownBackendCommands) {
300
+ const known = new Set(knownBackendCommands);
301
+ const commands = new Set();
302
+ for (const path of pythonFiles(pythonRoot)) {
303
+ const text = readFileSync(path, "utf8");
304
+ for (const pattern of [
305
+ /command_result\([^,\n]+,\s*"([^"]+)"/g,
306
+ /request_backend\([^,\n]+,\s*"([^"]+)"/g,
307
+ /async_request_backend\([^,\n]+,\s*"([^"]+)"/g,
308
+ /self\.request\("([^"]+)"/g,
309
+ /self\.stream\("([^"]+)"/g,
310
+ /"([^"]+)"\s*:\s*"([^"]+)"/g
311
+ ]) {
312
+ for (const match of text.matchAll(pattern)) {
313
+ const command = match[2] ?? match[1];
314
+ if (command !== undefined && known.has(command)) {
315
+ commands.add(command);
316
+ }
317
+ }
318
+ }
319
+ }
320
+ return sortedUnique([...commands]);
321
+ }
322
+ function pythonFiles(root) {
323
+ const result = [];
324
+ const visit = (path) => {
325
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
326
+ const entryPath = join(path, entry.name);
327
+ if (entry.isDirectory()) {
328
+ if (entry.name === "__pycache__" || entry.name.endsWith(".egg-info"))
329
+ continue;
330
+ visit(entryPath);
331
+ }
332
+ else if (entry.isFile() && entry.name.endsWith(".py")) {
333
+ result.push(entryPath);
334
+ }
335
+ }
336
+ };
337
+ visit(root);
338
+ return result;
339
+ }
340
+ function resolvePythonRoot(packageRoot) {
341
+ const repoRoot = resolve(packageRoot, "..", "..");
342
+ const candidates = [
343
+ join(repoRoot, "packages", "python"),
344
+ resolve(packageRoot, "..", "python"),
345
+ join(repoRoot, "packages", "python"),
346
+ resolve(packageRoot, "..", "python")
347
+ ];
348
+ return candidates.find(path => existsSync(path) && statSync(path).isDirectory());
349
+ }
350
+ function sortedUnique(values) {
351
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
352
+ }
353
+ function readJson(path) {
354
+ return JSON.parse(readFileSync(path, "utf8"));
355
+ }
356
+ function toPath(input) {
357
+ if (input instanceof URL)
358
+ return fileURLToPath(input);
359
+ return input;
360
+ }
361
+ function defaultPackageRoot() {
362
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
363
+ }