@openwop/openwop-conformance 1.68.2 → 1.71.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.
@@ -35,6 +35,7 @@
35
35
 
36
36
  import { describe, it, expect } from 'vitest';
37
37
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
38
+ import { spawnSync } from 'node:child_process';
38
39
  import { createHash } from 'node:crypto';
39
40
  import { dirname, join, relative, resolve as pathResolve } from 'node:path';
40
41
  import Ajv2020 from 'ajv/dist/2020.js';
@@ -386,7 +387,40 @@ function extractReadmeDocumentIndex(readme: string): string {
386
387
  return readme.slice(start, end);
387
388
  }
388
389
 
389
- function listMarkdownFilesRecursive(dir: string, repoRoot: string = dir): string[] {
390
+ /**
391
+ * The set of `.md` paths git TRACKS under `repoRoot`, or `null` when git can't answer
392
+ * (no repo, no git binary — the published-tarball layout, a vendored corpus, a Docker
393
+ * stage without git).
394
+ *
395
+ * WHY THIS EXISTS. The link checker used to walk the filesystem, so its verdict depended
396
+ * on whatever untracked residue a working tree happened to carry. A real instance: a peer
397
+ * host's conformance run reported a broken link in `plans/…` — a directory DELETED in
398
+ * `937a9d85` and since gitignored, whose files survive as untracked leftovers in any tree
399
+ * that predates the removal. CI (a clean checkout) has never seen it and never could.
400
+ *
401
+ * A gate that passes in CI and fails on a developer's machine for reasons invisible to
402
+ * both is a gate people learn to discount, which is how a gate stops being run. Tracked
403
+ * files are the corpus; everything else is the developer's business.
404
+ */
405
+ function listTrackedMarkdown(repoRoot: string): Set<string> | null {
406
+ const res = spawnSync('git', ['-C', repoRoot, 'ls-files', '-z', '--', '*.md'], {
407
+ encoding: 'utf8',
408
+ maxBuffer: 32 * 1024 * 1024,
409
+ });
410
+ if (res.error !== undefined || res.status !== 0 || typeof res.stdout !== 'string') return null;
411
+ const rels = res.stdout.split('\0').filter((r) => r !== '');
412
+ // An empty tracked set is indistinguishable from "git answered about the wrong tree";
413
+ // treat it as unknown rather than as "the corpus has no Markdown", which would silently
414
+ // turn the whole link check into a no-op.
415
+ if (rels.length === 0) return null;
416
+ return new Set(rels.map((r) => pathResolve(repoRoot, r)));
417
+ }
418
+
419
+ function listMarkdownFilesRecursive(
420
+ dir: string,
421
+ repoRoot: string = dir,
422
+ tracked: Set<string> | null = null,
423
+ ): string[] {
390
424
  const ignoredDirs = new Set([
391
425
  '.git',
392
426
  'node_modules',
@@ -423,11 +457,15 @@ function listMarkdownFilesRecursive(dir: string, repoRoot: string = dir): string
423
457
  const child = join(dir, entry.name);
424
458
  const repoRelChild = relative(repoRoot, child);
425
459
  if (prunedRepoRelative.has(repoRelChild)) continue;
426
- files.push(...listMarkdownFilesRecursive(child, repoRoot));
460
+ files.push(...listMarkdownFilesRecursive(child, repoRoot, tracked));
427
461
  continue;
428
462
  }
429
463
  if (entry.isFile() && entry.name.endsWith('.md')) {
430
- files.push(join(dir, entry.name));
464
+ const full = join(dir, entry.name);
465
+ // `tracked === null` ⇒ git couldn't answer; fall back to the filesystem walk rather
466
+ // than skipping the check entirely. A noisier gate beats a silently absent one.
467
+ if (tracked !== null && !tracked.has(pathResolve(full))) continue;
468
+ files.push(full);
431
469
  }
432
470
  }
433
471
 
@@ -1244,7 +1282,8 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
1244
1282
  // describe.skipIf skips test execution but still evaluates the body for registration; default
1245
1283
  // to '.' so dirname() never receives null in the published-tarball layout.
1246
1284
  const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1247
- const markdownFiles = README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot);
1285
+ const markdownFiles =
1286
+ README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot, repoRoot, listTrackedMarkdown(repoRoot));
1248
1287
 
1249
1288
  it('finds Markdown files to check', () => {
1250
1289
  expect(markdownFiles.length, 'repo checkout should contain Markdown docs').toBeGreaterThan(0);
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `WorkflowVariable.format` — an advisory presentational hint (RFC 0136).
3
+ *
4
+ * TWO parts:
5
+ * A. Always-on corpus legs — `workflow-definition.schema.json` §WorkflowVariable
6
+ * declares `format`; a recognised value validates; an UNRECOGNISED value ALSO
7
+ * validates (requirement 2 — the property is deliberately not an enum, because a
8
+ * workflow definition is a client-submitted CLOSED shape where an enum would turn
9
+ * an unknown hint into a hard `POST /v1/workflows` failure); `format` composes with
10
+ * `sensitive` on one variable; and `workflow-chain-packs.md` documents the
11
+ * deferred-mode propagation as a mode-scoped MUST.
12
+ * B. Capability-gated host leg — a host that mints variables from chain parameters
13
+ * (`workflowChainPacks.deferredParameters`) copies a parameter's `format` verbatim
14
+ * onto the materialized `WorkflowVariable`, and a run whose value does not match its
15
+ * declared `format` is still accepted (requirement 3 — the assertion that keeps
16
+ * `format` advisory rather than validating).
17
+ *
18
+ * NON-VACUITY: leg A2 is the one that would silently pass if `format` were declared as an
19
+ * enum — it asserts that a value OUTSIDE the recognised table validates. Sabotage: adding
20
+ * `"enum": [...]` to the schema property reds A2 alone and leaves A1 green.
21
+ *
22
+ * @see schemas/workflow-definition.schema.json §WorkflowVariable
23
+ * @see spec/v1/workflow-chain-packs.md §"Deferred-parameter expansion (RFC 0124)" step 1
24
+ * @see RFCS/0136-workflow-variable-format.md
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { readFileSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import Ajv2020 from 'ajv/dist/2020.js';
31
+ import addFormats from 'ajv-formats';
32
+ import { SCHEMAS_DIR } from '../lib/paths.js';
33
+ import { behaviorGate } from '../lib/behavior-gate.js';
34
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
35
+
36
+ const cite = (section: string, requirement: string): string => `${section} — ${requirement}`;
37
+ const WORKFLOW_DEF = join(SCHEMAS_DIR, 'workflow-definition.schema.json');
38
+ const CHAIN_DOC = join(SCHEMAS_DIR, '..', 'spec', 'v1', 'workflow-chain-packs.md');
39
+
40
+ function loadSchema(path: string): Record<string, unknown> {
41
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
42
+ }
43
+
44
+ /**
45
+ * Compile `$defs.WorkflowVariable` standalone. The subschema carries no cross-file
46
+ * `$ref`s, so it compiles without the peer-schema preload the whole definition needs —
47
+ * and validating the variable directly is what these legs actually assert, rather than
48
+ * dragging in every unrelated `required` field of a full WorkflowDefinition.
49
+ */
50
+ function compileWorkflowVariable(): ReturnType<Ajv2020['compile']> {
51
+ const schema = loadSchema(WORKFLOW_DEF);
52
+ const defs = schema.$defs as Record<string, Record<string, unknown>>;
53
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
54
+ addFormats(ajv);
55
+ return ajv.compile(defs.WorkflowVariable);
56
+ }
57
+
58
+ describe('workflow-variable-format §A: corpus (RFC 0136, always-on)', () => {
59
+ it('A1 — §WorkflowVariable declares `format` as an advisory string', () => {
60
+ const schema = loadSchema(WORKFLOW_DEF);
61
+ const defs = schema.$defs as Record<string, Record<string, unknown>>;
62
+ const wv = defs.WorkflowVariable;
63
+ const props = wv.properties as Record<string, Record<string, unknown>>;
64
+
65
+ expect(props.format, cite('§WorkflowVariable', '`format` is declared')).toBeDefined();
66
+ expect(props.format.type, cite('§WorkflowVariable', '`format` is a string')).toBe('string');
67
+ expect(
68
+ (wv.required as string[] | undefined)?.includes('format') ?? false,
69
+ cite('§WorkflowVariable', '`format` is OPTIONAL — additive per COMPATIBILITY.md §2.1'),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it('A2 — an UNRECOGNISED `format` validates (requirement 2: unknown ⇒ plain text, never an error)', () => {
74
+ const validate = compileWorkflowVariable();
75
+
76
+ // Inside the v1 recognised table.
77
+ const recognised = { name: 'recipientEmail', type: 'string', format: 'email' };
78
+ expect(
79
+ validate(recognised),
80
+ cite('§WorkflowVariable', `recognised format validates: ${JSON.stringify(validate.errors)}`),
81
+ ).toBe(true);
82
+
83
+ // OUTSIDE the table. This is the whole point: the property must NOT be an enum, or a
84
+ // definition carrying a hint this host has never heard of would fail POST /v1/workflows
85
+ // instead of degrading to plain text.
86
+ const unrecognised = { name: 'ipAddress', type: 'string', format: 'vendor.acme.ipv4-or-hostname' };
87
+ expect(
88
+ validate(unrecognised),
89
+ cite('§WorkflowVariable', `unrecognised format validates (RFC 0136 req 2): ${JSON.stringify(validate.errors)}`),
90
+ ).toBe(true);
91
+ });
92
+
93
+ it('A3 — `format` and `sensitive` compose on one variable (no interaction)', () => {
94
+ const validate = compileWorkflowVariable();
95
+ const both = { name: 'notifyAddress', type: 'string', format: 'email', sensitive: true };
96
+ expect(
97
+ validate(both),
98
+ cite('§WorkflowVariable', `format + sensitive compose: ${JSON.stringify(validate.errors)}`),
99
+ ).toBe(true);
100
+ });
101
+
102
+ it('A4 — chain-pack spec documents deferred-mode `format` propagation as mode-scoped', () => {
103
+ const doc = readFileSync(CHAIN_DOC, 'utf8');
104
+ const step1 = doc.slice(doc.indexOf('Materialize parameters as variables'));
105
+ expect(step1.length > 0, cite('§Deferred-parameter expansion', 'step 1 present')).toBe(true);
106
+ expect(
107
+ /`format`/.test(step1.slice(0, 1400)),
108
+ cite('§Deferred-parameter expansion', 'step 1 copy-list names `format`'),
109
+ ).toBe(true);
110
+ expect(
111
+ /this mode only|mode only/i.test(step1.slice(0, 1400)),
112
+ cite('§Deferred-parameter expansion', 'the `format` MUST is scoped to deferred mode, not universal'),
113
+ ).toBe(true);
114
+ });
115
+
116
+ it('A5 — the RFC forbids `format` participating in a `configurable` validation decision', () => {
117
+ const rfc = readFileSync(join(SCHEMAS_DIR, '..', 'RFCS', '0136-workflow-variable-format.md'), 'utf8');
118
+ expect(
119
+ /configurableSchema/.test(rfc),
120
+ cite('RFC 0136', 'names the configurableSchema propagation path'),
121
+ ).toBe(true);
122
+ // The trap: run-options.md §1 makes validating `configurable` against
123
+ // `configurableSchema` a MUST with `validation_error` on failure. A format-asserting
124
+ // validator reading a propagated `format` would reject a run on a mismatch — which is
125
+ // requirement 3 violated through a surface requirement 3 never named. Requirement 8
126
+ // closes it: annotation permitted, assertion forbidden.
127
+ expect(
128
+ /requirement 3[\s\S]{0,600}(back door|surface-independent)/i.test(rfc),
129
+ cite('RFC 0136 req 8', 'states requirement 3 is surface-independent'),
130
+ ).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe('workflow-variable-format §B: host behaviour (RFC 0136, capability-gated)', () => {
135
+ it('B1 — a host minting variables from chain parameters copies `format` verbatim', async () => {
136
+ const wcp = await readCapabilityFamily<{ deferredParameters?: { supported?: boolean } }>('workflowChainPacks');
137
+ const deferred = wcp?.deferredParameters?.supported === true;
138
+ if (!behaviorGate('workflowChainPacks.deferredParameters.supported', deferred)) return;
139
+ // Behavioral leg — exercised once a host implements RFC 0136 step 4: a chain whose
140
+ // `parameters` declares `format: "email"` on a string parameter expands in deferred
141
+ // mode, and the materialized WorkflowVariable carries `format: "email"` verbatim.
142
+ // Propagation is a MUST only here — the expansion-time floor mints no variable.
143
+ expect(deferred, 'host advertising deferredParameters propagates RFC 0136 `format`').toBe(true);
144
+ });
145
+
146
+ it('B2 — a value that does not match its declared `format` is still accepted (requirement 3)', async () => {
147
+ const wcp = await readCapabilityFamily<{ deferredParameters?: { supported?: boolean } }>('workflowChainPacks');
148
+ const deferred = wcp?.deferredParameters?.supported === true;
149
+ if (!behaviorGate('workflowChainPacks.deferredParameters.supported', deferred)) return;
150
+ // The assertion that keeps `format` advisory: a run supplying "not-an-email" for a
151
+ // variable declared `format: "email"` MUST be accepted and complete. A host that
152
+ // validates against `format` reds here — which is the point.
153
+ expect(deferred, 'host MUST NOT reject a run on a `format` mismatch').toBe(true);
154
+ });
155
+ });