@glubean/cli 0.5.0 → 0.7.0

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.
@@ -7,6 +7,7 @@ import { loadProjectEnv } from "@glubean/runner";
7
7
  import { resolveEnvFileName } from "../lib/active_env.js";
8
8
  import { shouldSkipTest } from "../lib/skip.js";
9
9
  import { CLI_VERSION } from "../version.js";
10
+ import { redactMetadataForUpload } from "../lib/redact-metadata.js";
10
11
  import { extractContractCases, extractFromSource } from "@glubean/scanner/static";
11
12
  import { extractContractFromFile, findTemplateMatch, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
12
13
  import { applyEnvTemplating } from "@glubean/runner";
@@ -49,29 +50,6 @@ async function findProjectConfig(startDir) {
49
50
  // No glubean project found — use the starting directory (scratch mode)
50
51
  return { rootDir: startDir };
51
52
  }
52
- /**
53
- * True if a flow's extracted step tree contains a branch (condition / switch) or
54
- * a poll (bounded poll-until). Recurses into branch cases/default so a poll
55
- * nested inside a branch body is caught too.
56
- *
57
- * Used to gate `--upload`: Glubean Cloud cannot render `kind:"branch"` or
58
- * `kind:"poll"` flows yet, and uploading would silently drop them (local run view
59
- * ≠ Cloud view), so we refuse rather than mislead. See contract-flow-condition.md
60
- * §12 / contract-flow-poll.md §8.
61
- */
62
- function flowStepsHaveBranchOrPoll(steps) {
63
- if (!steps)
64
- return false;
65
- for (const s of steps) {
66
- if (s.kind === "branch" || s.kind === "poll")
67
- return true;
68
- if (s.cases && s.cases.some((c) => flowStepsHaveBranchOrPoll(c.steps)))
69
- return true;
70
- if (s.default && flowStepsHaveBranchOrPoll(s.default))
71
- return true;
72
- }
73
- return false;
74
- }
75
53
  // Config consolidation (docs/06): the package.json `glubean` field is no
76
54
  // longer a config source. Warn (don't error) when one lingers so users
77
55
  // migrate it into glubean.yaml instead of wondering why it stopped working.
@@ -102,12 +80,33 @@ function isGlob(target) {
102
80
  const TEST_FILE_SUFFIXES = [
103
81
  ".test.ts",
104
82
  ".contract.ts",
83
+ ".workflow.ts",
105
84
  ".flow.ts",
106
85
  ".bootstrap.ts",
107
86
  ];
108
87
  function isGlubeanTestFile(name) {
109
88
  return TEST_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
110
89
  }
90
+ // Files that carry contract/workflow DECLARATIONS and are safe to
91
+ // runtime-import for extraction. Matched by SUFFIX, not substring: a normal
92
+ // test like `checkout.workflow.test.ts` ends in `.test.ts` and must stay on
93
+ // the static test path — a `.workflow.` substring would mis-route it into
94
+ // contract extraction (which ignores `test()` exports), silently dropping its
95
+ // tests (codex 0.6 P2).
96
+ const RUNTIME_ARTIFACT_SUFFIXES = [
97
+ ".contract.ts",
98
+ ".contract.js",
99
+ ".contract.mjs",
100
+ ".workflow.ts",
101
+ ".workflow.js",
102
+ ".workflow.mjs",
103
+ ".flow.ts",
104
+ ".flow.js",
105
+ ".flow.mjs",
106
+ ];
107
+ function isRuntimeExtractableArtifact(name) {
108
+ return RUNTIME_ARTIFACT_SUFFIXES.some((suffix) => name.endsWith(suffix));
109
+ }
111
110
  function isBootstrapOnlyFile(name) {
112
111
  return name.endsWith(".bootstrap.ts");
113
112
  }
@@ -130,7 +129,7 @@ export function classifyGlubeanFile(filePath) {
130
129
  return "test";
131
130
  if (filePath.endsWith(".contract.ts"))
132
131
  return "contract";
133
- if (filePath.endsWith(".flow.ts"))
132
+ if (filePath.endsWith(".workflow.ts") || filePath.endsWith(".flow.ts"))
134
133
  return "flow";
135
134
  if (filePath.endsWith(".bootstrap.ts"))
136
135
  return "bootstrap";
@@ -230,6 +229,14 @@ export async function resolveTestFilesForSuite(target, kinds) {
230
229
  // `.flow.ts` file (recommended canonical layout) or declare the
231
230
  // suite as `kinds: [contract, flow]` so both candidate file types
232
231
  // are scanned and the runnable-level filter sorts them out.
232
+ //
233
+ // The same applies to a vNext workflow authored in a `.test.ts`
234
+ // (workflows ride the "flow" RUNNABLE kind, codex S2.6 R10/R11): a
235
+ // strict `kinds: [flow]` suite won't see the file. Move the workflow
236
+ // to a `.flow.ts` (recommended — it also gains the metadata
237
+ // projection, which `.test.ts` files never get) or declare
238
+ // `kinds: [test, flow]`; the runnable-level filter then keeps the
239
+ // workflow and drops the plain test() exports.
233
240
  return files.filter((f) => {
234
241
  const k = classifyGlubeanFile(f);
235
242
  if (k === undefined)
@@ -251,16 +258,21 @@ export async function discoverTests(filePath) {
251
258
  return [];
252
259
  }
253
260
  const content = await readFile(filePath, "utf-8");
254
- if (filePath.includes(".contract.") || filePath.includes(".flow.")) {
261
+ if (isRuntimeExtractableArtifact(filePath)) {
255
262
  // Runtime extraction via shared function (supports .with() syntax).
256
- // Returns BOTH contracts and flows; v0.2+ flow files often export only
257
- // flows, so we must emit one DiscoveredTest per flow in addition to
258
- // per contract case.
263
+ // Returns BOTH contracts and workflows; `.workflow.ts` / `.flow.ts` files
264
+ // often export only workflows, so we must emit one DiscoveredTest per
265
+ // workflow in addition to per contract case.
259
266
  const result = await extractContractFromFile(filePath);
260
267
  const results = [];
261
268
  for (const ec of result.contracts) {
262
269
  const contractTags = ec.tags ?? [];
263
270
  for (const c of ec.cases) {
271
+ // Non-runnable cases (direction: "inbound", design §9.5): the SDK
272
+ // registered no Test — advertising them here would schedule an id
273
+ // that can never resolve (codex I2 R1 P1).
274
+ if (c.runnable === false)
275
+ continue;
264
276
  // Mirror SDK dispatchContract: finalTags = contract + case + runtime
265
277
  // synthetic. Without this, pre-spawn excludeTags / --tag filtering
266
278
  // skips contract cases entirely (Phase 1 filter reads meta.tags).
@@ -292,22 +304,28 @@ export async function discoverTests(filePath) {
292
304
  });
293
305
  }
294
306
  }
295
- // Each flow has a single orchestrator Test (setup steps → teardown).
296
- // Discover it as one runnable entry with the flow id. Post-Phase 2f
297
- // flows live as `kind: "flow"` entries inside `result.attachments`.
298
- // SDK maps FlowMeta.skip TestMeta.deferred (string reason); mirror
299
- // that here so the runner's deferred-skip path applies uniformly.
300
- for (const att of result.attachments) {
301
- if (att.kind !== "flow")
302
- continue;
307
+ // vNext workflows (S2.6): each BuiltWorkflow wraps the graph in ONE
308
+ // simple test discover it like a flow orchestrator, so a file whose
309
+ // projection scan/upload advertises is also runnable (codex S2.6 R2 P2).
310
+ // During the migration window workflows ride the "flow" runnable kind:
311
+ // a workflow IS the vNext orchestrator replacing contract.flow(), and
312
+ // suites declaring `kinds: [flow]` mean "run the graph orchestrators in
313
+ // these files" — no new user-facing kinds enum until flow is deleted.
314
+ // WorkflowMeta.skip → deferred mirrors the SDK's own Test wrapping.
315
+ for (const wf of result.workflows) {
303
316
  results.push({
304
- exportName: att.exportName,
317
+ exportName: wf.exportName,
305
318
  meta: {
306
- id: att.flow.id,
307
- description: att.flow.description,
308
- tags: att.flow.tags,
309
- only: att.flow.only,
310
- deferred: att.flow.skip,
319
+ id: wf.id,
320
+ name: wf.name,
321
+ description: wf.description,
322
+ tags: wf.tags,
323
+ only: wf.only,
324
+ deferred: wf.skip,
325
+ // data-driven members: grouping + concurrency ride the projection
326
+ // (codex S2.12 R1 P2 — the registry alone never reaches the CLI).
327
+ ...(wf.groupId ? { groupId: wf.groupId } : {}),
328
+ ...(wf.parallel ? { parallel: true } : {}),
311
329
  kind: "flow",
312
330
  },
313
331
  });
@@ -318,17 +336,26 @@ export async function discoverTests(filePath) {
318
336
  // contain ONLY contract.http(...). Stricter than MCP's gate: CLI
319
337
  // emits flows as runnable tests via discoverTests, so silently
320
338
  // dropping `contract.flow(...)` would hide an actual test. Any
321
- // non-HTTP usage (including flow) fail closed and surface the
322
- // import error so the user knows discovery is degraded.
339
+ // non-HTTP usage (including flow, and a vNext workflow(...) codex
340
+ // S2.6 R6 P2) → fail closed and surface the import error so the user
341
+ // knows discovery is degraded.
323
342
  if (result.errors.length > 0) {
324
343
  // Allow whitespace/newlines between `contract` and `.method` so the
325
344
  // common fluent style `contract\n .flow(...)` still trips the gate.
326
345
  const hasHttp = /contract\s*\.\s*http\b/i.test(content);
327
346
  const hasNonHttp = /contract\s*\.\s*(?!http\b)\w+\s*[.(]/i.test(content);
328
- const contracts = hasHttp && !hasNonHttp ? extractContractCases(content) : [];
347
+ // Import-clause check catches aliased workflow imports too
348
+ // (`import { workflow as wf }` — codex S2.6 R8 P2).
349
+ const hasWorkflow = /\bworkflow\s*\(/.test(content) ||
350
+ /import\s[^;]*?\{[^}]*\bworkflow\b[^}]*\}/.test(content);
351
+ const contracts = hasHttp && !hasNonHttp && !hasWorkflow ? extractContractCases(content) : [];
329
352
  if (contracts.length > 0) {
330
353
  for (const c of contracts) {
331
354
  for (const caseItem of c.cases) {
355
+ // Static-fallback mirror of the runnable filter above (the AST
356
+ // extractor marks inboundCase()/direction literals).
357
+ if (caseItem.direction === "inbound")
358
+ continue;
332
359
  const requires = caseItem.requires ?? "headless";
333
360
  const defaultRun = caseItem.defaultRun ??
334
361
  (requires !== "headless" ? "opt-in" : "always");
@@ -397,7 +424,16 @@ export async function discoverTests(filePath) {
397
424
  parallel: m.parallel,
398
425
  requires: m.requires,
399
426
  defaultRun: m.defaultRun,
400
- kind: "test",
427
+ // A vNext workflow is a graph orchestrator — it rides the "flow"
428
+ // RUNNABLE kind even when authored in a .test.ts, so the
429
+ // runnable-level suite filter and the --upload gate treat it like a
430
+ // flow (codex S2.6 R10 P2). NOTE: the FILE-level suite filter still
431
+ // maps .test.ts ↔ "test" (see resolveTestFilesForSuite's KNOWN
432
+ // LIMITATION) — a strict kinds:[flow] suite needs the workflow in a
433
+ // .flow.ts, or kinds:[test, flow].
434
+ kind: m.workflow ? "flow" : "test",
435
+ // WorkflowMeta.skip reason → deferred.
436
+ ...(m.deferred !== undefined ? { deferred: m.deferred } : {}),
401
437
  },
402
438
  };
403
439
  });
@@ -414,7 +450,6 @@ function matchesFilter(testItem, filter) {
414
450
  export const __testing = {
415
451
  matchesTags: (...args) => matchesTags(...args),
416
452
  matchesExcludeTags: (...args) => matchesExcludeTags(...args),
417
- flowStepsHaveBranchOrPoll: (...args) => flowStepsHaveBranchOrPoll(...args),
418
453
  };
419
454
  function matchesTags(testItem, tags, mode = "or") {
420
455
  if (!testItem.meta.tags?.length)
@@ -508,7 +543,7 @@ export async function runCommand(target, options = {}) {
508
543
  const targetDisplay = Array.isArray(target) ? target.join(", ") : target;
509
544
  if (testFiles.length === 0) {
510
545
  console.error(`\n${colors.red}❌ No test files found for target: ${Array.isArray(target) ? target.join(", ") : target}${colors.reset}`);
511
- console.error(`${colors.dim}Glubean looks for files matching *.test.ts, *.contract.ts, or *.flow.ts in the target directory.${colors.reset}`);
546
+ console.error(`${colors.dim}Glubean looks for files matching *.test.ts, *.contract.ts, *.workflow.ts, or *.flow.ts in the target directory.${colors.reset}`);
512
547
  console.error(`${colors.dim}Run "glubean run tests/" or "glubean run path/to/file.test.ts".${colors.reset}\n`);
513
548
  await writeEmptyResult(target, runStartLocal);
514
549
  process.exit(1);
@@ -761,48 +796,13 @@ export async function runCommand(target, options = {}) {
761
796
  }
762
797
  console.log(`${colors.dim}${parts.join(" + ")} (${testsToRun.length}/${totalDiscovered} tests)${colors.reset}`);
763
798
  }
764
- // ── Gate: Cloud cannot yet render branch (condition/switch) flows ──────
765
- // Operate on the POST-FILTER selected runnables (`testsToRun`) so a branch
766
- // flow that was filtered out (--filter / tags / .only / suite kinds) does not
767
- // block an otherwise-branchless upload. Uploading a branch flow would
768
- // silently drop its branches server-side (Cloud run view local), so refuse
769
- // before running and name the offending flows. Bootstrap already ran, so
770
- // plugin-backed files re-extract from Node's cache cleanly.
771
- // (contract-flow-condition.md §12 / Spike 6.)
772
- if (options.upload) {
773
- // Exclude deferred (FlowMeta.skip → meta.deferred) flows: they don't
774
- // execute — only a skipped row is uploaded — so their branches never reach
775
- // Cloud and they must not block the upload.
776
- const selectedFlows = testsToRun.filter((ft) => ft.test.meta.kind === "flow" && !ft.test.meta.deferred);
777
- if (selectedFlows.length > 0) {
778
- // Map each selected flow's source file → the set of its branch/poll flow ids.
779
- const branchIdsByFile = new Map();
780
- for (const filePath of new Set(selectedFlows.map((ft) => ft.filePath))) {
781
- try {
782
- const extracted = await extractContractFromFile(filePath);
783
- const ids = new Set();
784
- for (const att of extracted.attachments ?? []) {
785
- if (att.kind === "flow" && flowStepsHaveBranchOrPoll(att.flow.steps))
786
- ids.add(att.flow.id);
787
- }
788
- branchIdsByFile.set(filePath, ids);
789
- }
790
- catch {
791
- // Real import/extraction errors are surfaced by discovery above.
792
- }
793
- }
794
- const branchFlows = selectedFlows.filter((ft) => branchIdsByFile.get(ft.filePath)?.has(ft.test.meta.id));
795
- if (branchFlows.length > 0) {
796
- console.error(`${colors.red}Error: --upload does not yet support branch (condition/switch) or poll flows.${colors.reset}`);
797
- console.error(`${colors.dim}Glubean Cloud can't render these flows yet, and uploading would silently drop their branches/polls:${colors.reset}`);
798
- for (const ft of branchFlows) {
799
- console.error(`${colors.dim} - ${ft.test.meta.id} (${ft.exportName}) [${relative(process.cwd(), ft.filePath)}]${colors.reset}`);
800
- }
801
- console.error(`${colors.dim}Run without --upload, or remove condition/switchOn/switchCond/poll from these flows, until Cloud support lands.${colors.reset}`);
802
- process.exit(1);
803
- }
804
- }
805
- }
799
+ // NOTE (0.6, owner): the run-view upload is UNCONDITIONAL — branch/poll
800
+ // workflows upload like any other. The earlier OPTION D gate refused them
801
+ // because Cloud "couldn't render" branch/poll run views, but refusing the
802
+ // upload is the wrong trade: uploading is always correct, and Cloud degrades
803
+ // gracefully (render raw + the projected `note`/`message` on opaque nodes)
804
+ // until a richer run view lands. The projection already uploads whole
805
+ // (branch/poll included), so the run view simply joins it.
806
806
  console.log(`\n${colors.bold}Running ${testsToRun.length} test(s)...${colors.reset}\n`);
807
807
  // ── Spike 3: runner input channels (attachment-model §8) ────────────────
808
808
  // `--input-json` / `--bootstrap-json` / `--force-standalone` apply to a
@@ -1949,6 +1949,12 @@ export async function runCommand(target, options = {}) {
1949
1949
  const built = await buildMetadata(scanResult, {
1950
1950
  generatedBy: `@glubean/cli@${CLI_VERSION}`,
1951
1951
  projectId,
1952
+ // Upload path only: carry the lossless full CONTRACT projection for
1953
+ // the Cloud c/f metadata snapshot. Deep-redacted below before upload;
1954
+ // never written to the on-disk metadata.json (that path omits it).
1955
+ // `workflows` is always present (Design Y) and redacted in the same
1956
+ // pass below.
1957
+ includeProjection: true,
1952
1958
  });
1953
1959
  metadata = built;
1954
1960
  }
@@ -1976,6 +1982,20 @@ export async function runCommand(target, options = {}) {
1976
1982
  }
1977
1983
  metadata = { ...metadata, runPlan };
1978
1984
  }
1985
+ // Deep-redact the FULL contract + workflow projection before upload. Test
1986
+ // events are redacted below via scope-based `redactEvent`, but the
1987
+ // projection is a free-form tree that can carry secrets anywhere
1988
+ // (examples, default headers, gRPC metadata, `extensions`/`meta`, literal
1989
+ // compare/switch values, assertion messages). `redactMetadataForUpload`
1990
+ // redacts ONLY the projection buckets (contractsProjection + workflows) —
1991
+ // never `files`/`rootHash` — so the server's test registry/dedup keeps
1992
+ // its verbatim sha256 hashes. The projection is uploaded WHOLE (branch/
1993
+ // poll included): it is the lossless source for the server snapshot, not
1994
+ // a run view (see the buildMetadata R14 note); the branch/poll run-view
1995
+ // gate is a separate layer, untouched here.
1996
+ if (metadata) {
1997
+ metadata = await redactMetadataForUpload(metadata, effectiveRedaction);
1998
+ }
1979
1999
  const redactedPayload = {
1980
2000
  ...resultPayload,
1981
2001
  metadata,