@shipfox/api-definitions 13.1.0 → 14.0.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.
Files changed (223) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +35 -0
  3. package/dist/core/entities/index.d.ts +1 -1
  4. package/dist/core/entities/index.d.ts.map +1 -1
  5. package/dist/core/entities/index.js +1 -1
  6. package/dist/core/entities/index.js.map +1 -1
  7. package/dist/core/entities/integration-context.d.ts +4 -0
  8. package/dist/core/entities/integration-context.d.ts.map +1 -1
  9. package/dist/core/entities/integration-context.js.map +1 -1
  10. package/dist/core/entities/sync-state.d.ts +10 -4
  11. package/dist/core/entities/sync-state.d.ts.map +1 -1
  12. package/dist/core/entities/sync-state.js +26 -7
  13. package/dist/core/entities/sync-state.js.map +1 -1
  14. package/dist/core/entities/validation-diagnostic.d.ts +8 -0
  15. package/dist/core/entities/validation-diagnostic.d.ts.map +1 -0
  16. package/dist/core/entities/validation-diagnostic.js +3 -0
  17. package/dist/core/entities/validation-diagnostic.js.map +1 -0
  18. package/dist/core/entities/workflow-definition.d.ts +2 -0
  19. package/dist/core/entities/workflow-definition.d.ts.map +1 -1
  20. package/dist/core/entities/workflow-definition.js.map +1 -1
  21. package/dist/core/errors.d.ts +10 -0
  22. package/dist/core/errors.d.ts.map +1 -1
  23. package/dist/core/errors.js +9 -0
  24. package/dist/core/errors.js.map +1 -1
  25. package/dist/core/index.d.ts +2 -1
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +2 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/integrations.d.ts +3 -1
  30. package/dist/core/integrations.d.ts.map +1 -1
  31. package/dist/core/integrations.js +10 -2
  32. package/dist/core/integrations.js.map +1 -1
  33. package/dist/core/parse-definition.d.ts +4 -4
  34. package/dist/core/parse-definition.d.ts.map +1 -1
  35. package/dist/core/parse-definition.js +5 -5
  36. package/dist/core/parse-definition.js.map +1 -1
  37. package/dist/core/resolve-definition-at-ref.d.ts +66 -0
  38. package/dist/core/resolve-definition-at-ref.d.ts.map +1 -0
  39. package/dist/core/resolve-definition-at-ref.js +374 -0
  40. package/dist/core/resolve-definition-at-ref.js.map +1 -0
  41. package/dist/core/sync-definitions.d.ts +2 -2
  42. package/dist/core/sync-definitions.d.ts.map +1 -1
  43. package/dist/core/sync-definitions.js +5 -5
  44. package/dist/core/sync-definitions.js.map +1 -1
  45. package/dist/core/validate-definition.d.ts +3 -3
  46. package/dist/core/validate-definition.d.ts.map +1 -1
  47. package/dist/core/validate-definition.js +17 -12
  48. package/dist/core/validate-definition.js.map +1 -1
  49. package/dist/core/workflow-model/cron-trigger.d.ts +1 -1
  50. package/dist/core/workflow-model/cron-trigger.d.ts.map +1 -1
  51. package/dist/core/workflow-model/cron-trigger.js +9 -5
  52. package/dist/core/workflow-model/cron-trigger.js.map +1 -1
  53. package/dist/core/workflow-model/index.d.ts +1 -1
  54. package/dist/core/workflow-model/index.d.ts.map +1 -1
  55. package/dist/core/workflow-model/index.js.map +1 -1
  56. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +4 -2
  57. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
  58. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
  59. package/dist/core/workflow-model/normalize-job-listening.d.ts.map +1 -1
  60. package/dist/core/workflow-model/normalize-job-listening.js +51 -30
  61. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
  62. package/dist/core/workflow-model/normalize-triggers.d.ts +1 -1
  63. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
  64. package/dist/core/workflow-model/normalize-triggers.js +48 -18
  65. package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
  66. package/dist/core/workflow-model/normalize-workflow-document.d.ts +1 -1
  67. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
  68. package/dist/core/workflow-model/normalize-workflow-document.js +3 -3
  69. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
  70. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
  71. package/dist/core/workflow-model/parse-interpolation-field.js +1 -15
  72. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
  73. package/dist/core/workflow-model/validate-predicate-expression.d.ts +3 -1
  74. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
  75. package/dist/core/workflow-model/validate-predicate-expression.js +3 -0
  76. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
  77. package/dist/core/workflow-model/validation-issue.d.ts +2 -1
  78. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
  79. package/dist/core/workflow-model/validation-issue.js +12 -15
  80. package/dist/core/workflow-model/validation-issue.js.map +1 -1
  81. package/dist/db/db.d.ts +196 -4
  82. package/dist/db/db.d.ts.map +1 -1
  83. package/dist/db/db.js +3 -1
  84. package/dist/db/db.js.map +1 -1
  85. package/dist/db/definition-triggers.js +3 -1
  86. package/dist/db/definition-triggers.js.map +1 -1
  87. package/dist/db/definitions.d.ts +17 -0
  88. package/dist/db/definitions.d.ts.map +1 -1
  89. package/dist/db/definitions.js +190 -23
  90. package/dist/db/definitions.js.map +1 -1
  91. package/dist/db/index.d.ts +2 -1
  92. package/dist/db/index.d.ts.map +1 -1
  93. package/dist/db/index.js +2 -1
  94. package/dist/db/index.js.map +1 -1
  95. package/dist/db/schema/definitions.d.ts +17 -0
  96. package/dist/db/schema/definitions.d.ts.map +1 -1
  97. package/dist/db/schema/definitions.js +8 -0
  98. package/dist/db/schema/definitions.js.map +1 -1
  99. package/dist/db/schema/sync-states.d.ts +7 -3
  100. package/dist/db/schema/sync-states.d.ts.map +1 -1
  101. package/dist/db/schema/sync-states.js +5 -1
  102. package/dist/db/schema/sync-states.js.map +1 -1
  103. package/dist/db/schema/workflows.d.ts +78 -0
  104. package/dist/db/schema/workflows.d.ts.map +1 -0
  105. package/dist/db/schema/workflows.js +17 -0
  106. package/dist/db/schema/workflows.js.map +1 -0
  107. package/dist/db/sync-states.d.ts +2 -2
  108. package/dist/db/sync-states.d.ts.map +1 -1
  109. package/dist/db/sync-states.js +4 -4
  110. package/dist/db/sync-states.js.map +1 -1
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +5 -1
  113. package/dist/index.js.map +1 -1
  114. package/dist/metrics/index.d.ts +2 -0
  115. package/dist/metrics/index.d.ts.map +1 -0
  116. package/dist/metrics/index.js +3 -0
  117. package/dist/metrics/index.js.map +1 -0
  118. package/dist/metrics/instance.d.ts +4 -0
  119. package/dist/metrics/instance.d.ts.map +1 -0
  120. package/dist/metrics/instance.js +19 -0
  121. package/dist/metrics/instance.js.map +1 -0
  122. package/dist/presentation/dto/definition.d.ts.map +1 -1
  123. package/dist/presentation/dto/definition.js +11 -4
  124. package/dist/presentation/dto/definition.js.map +1 -1
  125. package/dist/presentation/inter-module.d.ts +9 -1
  126. package/dist/presentation/inter-module.d.ts.map +1 -1
  127. package/dist/presentation/inter-module.js +66 -2
  128. package/dist/presentation/inter-module.js.map +1 -1
  129. package/dist/presentation/routes/create-definition.d.ts.map +1 -1
  130. package/dist/presentation/routes/create-definition.js +16 -3
  131. package/dist/presentation/routes/create-definition.js.map +1 -1
  132. package/dist/presentation/routes/get-definition.d.ts.map +1 -1
  133. package/dist/presentation/routes/get-definition.js +18 -4
  134. package/dist/presentation/routes/get-definition.js.map +1 -1
  135. package/dist/presentation/routes/project-access.d.ts +1 -0
  136. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  137. package/dist/presentation/routes/validate-definition.js +3 -3
  138. package/dist/presentation/routes/validate-definition.js.map +1 -1
  139. package/dist/temporal/activities/sync-activities.d.ts +3 -3
  140. package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
  141. package/dist/temporal/activities/sync-activities.js +7 -4
  142. package/dist/temporal/activities/sync-activities.js.map +1 -1
  143. package/dist/temporal/workflows/definition-sync.js +1 -1
  144. package/dist/temporal/workflows/definition-sync.js.map +1 -1
  145. package/dist/temporal/workflows/index.bundle.js +50 -22
  146. package/dist/tsconfig.test.tsbuildinfo +1 -1
  147. package/drizzle/0003_nebulous_nemesis.sql +11 -0
  148. package/drizzle/0004_workflow_ref_lookup.sql +1 -0
  149. package/drizzle/meta/0003_snapshot.json +627 -0
  150. package/drizzle/meta/0004_snapshot.json +649 -0
  151. package/drizzle/meta/_journal.json +14 -0
  152. package/drizzle.config.ts +1 -0
  153. package/package.json +7 -7
  154. package/src/core/entities/index.ts +1 -1
  155. package/src/core/entities/integration-context.ts +4 -0
  156. package/src/core/entities/sync-state.test.ts +49 -0
  157. package/src/core/entities/sync-state.ts +35 -12
  158. package/src/core/entities/validation-diagnostic.ts +8 -0
  159. package/src/core/entities/workflow-definition.ts +2 -0
  160. package/src/core/errors.ts +26 -0
  161. package/src/core/index.ts +12 -0
  162. package/src/core/integrations.test.ts +38 -0
  163. package/src/core/integrations.ts +10 -1
  164. package/src/core/parse-definition-diagnostics.test.ts +47 -0
  165. package/src/core/parse-definition.test.ts +29 -5
  166. package/src/core/parse-definition.ts +7 -7
  167. package/src/core/resolve-definition-at-ref.test.ts +792 -0
  168. package/src/core/resolve-definition-at-ref.ts +551 -0
  169. package/src/core/sync-definitions.test.ts +61 -2
  170. package/src/core/sync-definitions.ts +7 -7
  171. package/src/core/validate-definition.test.ts +31 -22
  172. package/src/core/validate-definition.ts +21 -13
  173. package/src/core/workflow-model/cron-trigger.ts +6 -2
  174. package/src/core/workflow-model/index.ts +1 -0
  175. package/src/core/workflow-model/invalid-workflow-model-error.ts +5 -1
  176. package/src/core/workflow-model/normalize-job-listening.ts +55 -23
  177. package/src/core/workflow-model/normalize-triggers.ts +44 -15
  178. package/src/core/workflow-model/normalize-workflow-document.test.ts +444 -36
  179. package/src/core/workflow-model/normalize-workflow-document.ts +8 -4
  180. package/src/core/workflow-model/parse-interpolation-field.ts +1 -23
  181. package/src/core/workflow-model/validate-predicate-expression.ts +6 -0
  182. package/src/core/workflow-model/validation-issue.ts +8 -13
  183. package/src/db/db.ts +7 -1
  184. package/src/db/definition-triggers.test.ts +44 -0
  185. package/src/db/definition-triggers.ts +1 -1
  186. package/src/db/definitions.test.ts +300 -1
  187. package/src/db/definitions.ts +282 -28
  188. package/src/db/index.ts +2 -0
  189. package/src/db/schema/definitions.test.ts +1 -0
  190. package/src/db/schema/definitions.ts +11 -0
  191. package/src/db/schema/sync-states.ts +12 -3
  192. package/src/db/schema/workflows.ts +23 -0
  193. package/src/db/sync-states.test.ts +87 -18
  194. package/src/db/sync-states.ts +6 -6
  195. package/src/db/workflow-lineage-migration.test.ts +158 -0
  196. package/src/index.ts +3 -1
  197. package/src/metrics/index.ts +1 -0
  198. package/src/metrics/instance.test.ts +50 -0
  199. package/src/metrics/instance.ts +23 -0
  200. package/src/presentation/dto/definition.test.ts +89 -1
  201. package/src/presentation/dto/definition.ts +8 -5
  202. package/src/presentation/inter-module.test.ts +177 -0
  203. package/src/presentation/inter-module.ts +77 -4
  204. package/src/presentation/routes/create-definition.test.ts +38 -0
  205. package/src/presentation/routes/create-definition.ts +20 -3
  206. package/src/presentation/routes/get-definition.test.ts +125 -11
  207. package/src/presentation/routes/get-definition.ts +21 -4
  208. package/src/presentation/routes/list-definitions.test.ts +1 -1
  209. package/src/presentation/routes/validate-definition.test.ts +1 -1
  210. package/src/presentation/routes/validate-definition.ts +3 -3
  211. package/src/presentation/subscribers/on-integration-connection-available.test.ts +3 -0
  212. package/src/temporal/activities/sync-activities.test.ts +46 -2
  213. package/src/temporal/activities/sync-activities.ts +9 -7
  214. package/src/temporal/workflows/definition-sync.ts +1 -1
  215. package/test/factories/definition.ts +1 -0
  216. package/test/globalSetup.ts +2 -0
  217. package/tsconfig.build.tsbuildinfo +1 -1
  218. package/dist/core/entities/validation-warning.d.ts +0 -6
  219. package/dist/core/entities/validation-warning.d.ts.map +0 -1
  220. package/dist/core/entities/validation-warning.js +0 -3
  221. package/dist/core/entities/validation-warning.js.map +0 -1
  222. package/src/core/entities/validation-warning.ts +0 -5
  223. package/src/core/parse-definition-warnings.test.ts +0 -33
@@ -2,7 +2,11 @@ import type {AgentValidationCatalog} from '@shipfox/api-agent-dto/inter-module';
2
2
  import type {WorkflowDocument} from '@shipfox/workflow-document';
3
3
  import {agentValidationCatalog} from '#test/agent-validation-catalog.js';
4
4
  import type {IntegrationValidationContext} from '../entities/integration-context.js';
5
- import {InvalidWorkflowModelError} from './invalid-workflow-model-error.js';
5
+ import type {WorkflowModel} from '../entities/workflow-model.js';
6
+ import {
7
+ InvalidWorkflowModelError,
8
+ type WorkflowModelValidationIssue,
9
+ } from './invalid-workflow-model-error.js';
6
10
  import {DEFAULT_JOB_CHECKOUT} from './normalize-job-checkout.js';
7
11
  import {normalizeWorkflowDocument as normalizeWorkflowDocumentBase} from './normalize-workflow-document.js';
8
12
 
@@ -34,6 +38,16 @@ function expectInvalid(
34
38
  }
35
39
  }
36
40
 
41
+ function normalizeWithDiagnostics(
42
+ document: WorkflowDocument,
43
+ options?: Omit<Parameters<typeof normalizeWorkflowDocumentBase>[1], 'agentValidationCatalog'> &
44
+ Partial<Pick<Parameters<typeof normalizeWorkflowDocumentBase>[1], 'agentValidationCatalog'>>,
45
+ ): {model: WorkflowModel; diagnostics: WorkflowModelValidationIssue[]} {
46
+ const diagnostics: WorkflowModelValidationIssue[] = [];
47
+ const model = normalizeWorkflowDocument(document, {...options, diagnostics});
48
+ return {model, diagnostics};
49
+ }
50
+
37
51
  function interpolation(source: string): string {
38
52
  return '$'.concat('{{ ', source, ' }}');
39
53
  }
@@ -86,6 +100,13 @@ const integrationValidationContext = {
86
100
  ['linear-main', {id: 'conn_3', provider: 'linear', capabilities: ['agent_tools']}],
87
101
  ['jira-main', {id: 'conn_4', provider: 'jira', capabilities: ['agent_tools']}],
88
102
  ]),
103
+ eventCatalogs: new Map([
104
+ ['github', new Set(['push', 'pull_request.opened'])],
105
+ ['sentry', new Set(['issue.created'])],
106
+ ['linear', new Set(['Issue'])],
107
+ ['jira', new Set(['jira:issue_created'])],
108
+ ]),
109
+ fixedEventProviders: new Set(['webhook']),
89
110
  defaultConnectionSlug: 'github-main',
90
111
  } satisfies IntegrationValidationContext;
91
112
 
@@ -779,6 +800,8 @@ describe('normalizeWorkflowDocument', () => {
779
800
  message: 'Provider "github-copilot" is not supported.',
780
801
  path: ['jobs', 'fix', 'steps', 0, 'provider'],
781
802
  details: {provider: 'github-copilot'},
803
+ severity: 'error',
804
+ scope: 'definition',
782
805
  },
783
806
  ]);
784
807
  });
@@ -829,6 +852,8 @@ describe('normalizeWorkflowDocument', () => {
829
852
  'Agent model "not-a-model" is not available for harness "pi" and provider "anthropic".',
830
853
  path: ['jobs', 'fix', 'steps', 0, 'model'],
831
854
  details: {harness: 'pi', provider: 'anthropic', model: 'not-a-model'},
855
+ severity: 'error',
856
+ scope: 'definition',
832
857
  },
833
858
  ]);
834
859
  });
@@ -902,6 +927,8 @@ describe('normalizeWorkflowDocument', () => {
902
927
  'Harness "claude" does not support provider: openai. Supported providers: anthropic.',
903
928
  path: ['jobs', 'fix', 'steps', 0, 'provider'],
904
929
  details: {harness: 'claude', provider: 'openai', supportedProviders: ['anthropic']},
930
+ severity: 'error',
931
+ scope: 'definition',
905
932
  },
906
933
  ]);
907
934
  });
@@ -940,6 +967,8 @@ describe('normalizeWorkflowDocument', () => {
940
967
  'get_search_content',
941
968
  ],
942
969
  },
970
+ severity: 'error',
971
+ scope: 'definition',
943
972
  },
944
973
  ]);
945
974
  });
@@ -982,6 +1011,8 @@ describe('normalizeWorkflowDocument', () => {
982
1011
  'Agent step tools require an explicit harness because tool names are harness-specific.',
983
1012
  path: ['jobs', 'fix', 'steps', 0, 'tools'],
984
1013
  details: {tools: ['Read']},
1014
+ severity: 'error',
1015
+ scope: 'definition',
985
1016
  },
986
1017
  ]);
987
1018
  });
@@ -1069,6 +1100,8 @@ describe('normalizeWorkflowDocument', () => {
1069
1100
  thinking: 'off',
1070
1101
  supportedLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
1071
1102
  },
1103
+ severity: 'error',
1104
+ scope: 'definition',
1072
1105
  });
1073
1106
  });
1074
1107
 
@@ -1434,6 +1467,78 @@ describe('normalizeWorkflowDocument', () => {
1434
1467
  ]);
1435
1468
  });
1436
1469
 
1470
+ it('reports missing resolution sources when every until matcher is inert', () => {
1471
+ const document: WorkflowDocument = {
1472
+ name: 'listen forever',
1473
+ jobs: {
1474
+ review: {
1475
+ listening: {
1476
+ on: [{source: 'github', event: 'pull_request_review'}],
1477
+ until: [{source: 'github', event: 'pull_request', filter: 'event.action'}],
1478
+ },
1479
+ steps: [{run: 'echo ok'}],
1480
+ },
1481
+ },
1482
+ };
1483
+
1484
+ const diagnostics: WorkflowModelValidationIssue[] = [];
1485
+ let error: InvalidWorkflowModelError | undefined;
1486
+ try {
1487
+ normalizeWorkflowDocument(document, {diagnostics});
1488
+ expect.fail('Expected InvalidWorkflowModelError');
1489
+ } catch (caught) {
1490
+ expect(caught).toBeInstanceOf(InvalidWorkflowModelError);
1491
+ error = caught as InvalidWorkflowModelError;
1492
+ }
1493
+
1494
+ expect(error?.issues).toEqual([
1495
+ expect.objectContaining({
1496
+ code: 'listening-job-missing-resolution-source',
1497
+ path: ['jobs', 'review', 'listening'],
1498
+ }),
1499
+ ]);
1500
+ expect(diagnostics).toEqual([
1501
+ expect.objectContaining({
1502
+ code: 'invalid-listener-filter',
1503
+ path: ['jobs', 'review', 'listening', 'until', 0, 'filter'],
1504
+ severity: 'error',
1505
+ scope: 'trigger',
1506
+ }),
1507
+ ]);
1508
+ });
1509
+
1510
+ it('drops inert until matchers from the model when a timeout resolves the job', () => {
1511
+ const document: WorkflowDocument = {
1512
+ name: 'listen with timeout',
1513
+ jobs: {
1514
+ review: {
1515
+ listening: {
1516
+ on: [{source: 'github', event: 'pull_request_review'}],
1517
+ until: [{source: 'github', event: 'pull_request', filter: 'event.action'}],
1518
+ timeout: '1h',
1519
+ },
1520
+ steps: [{run: 'echo ok'}],
1521
+ },
1522
+ },
1523
+ };
1524
+
1525
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
1526
+
1527
+ expect(diagnostics).toEqual([
1528
+ expect.objectContaining({
1529
+ code: 'invalid-listener-filter',
1530
+ path: ['jobs', 'review', 'listening', 'until', 0, 'filter'],
1531
+ severity: 'error',
1532
+ scope: 'trigger',
1533
+ }),
1534
+ ]);
1535
+ expect(model.jobs[0]?.listening).toMatchObject({
1536
+ on: [{source: 'github', event: 'pull_request_review'}],
1537
+ timeoutMs: 60 * 60 * 1000,
1538
+ });
1539
+ expect(model.jobs[0]?.listening).not.toHaveProperty('until');
1540
+ });
1541
+
1437
1542
  it('reports listening timeouts above the run timeout', () => {
1438
1543
  const document: WorkflowDocument = {
1439
1544
  name: 'too long',
@@ -1503,7 +1608,6 @@ describe('normalizeWorkflowDocument', () => {
1503
1608
  ['executions root', 'executions.size() > 0', 'context-unavailable-at-predicate-site'],
1504
1609
  ['matrix root', 'matrix.os == "linux"', 'context-unavailable-at-predicate-site'],
1505
1610
  ['runner root', 'runner.os == "linux"', 'runner-context-in-server-predicate'],
1506
- ['non-boolean source', 'event.action', 'invalid-listener-filter'],
1507
1611
  ] as const)('reports invalid listener on filters for %s', (_label, filter, code) => {
1508
1612
  const document: WorkflowDocument = {
1509
1613
  name: 'invalid listener filter',
@@ -1532,6 +1636,70 @@ describe('normalizeWorkflowDocument', () => {
1532
1636
  ]);
1533
1637
  });
1534
1638
 
1639
+ it('rejects a listening job whose only on matcher is inert', () => {
1640
+ const document: WorkflowDocument = {
1641
+ name: 'invalid listener filter',
1642
+ jobs: {
1643
+ review: {
1644
+ listening: {
1645
+ on: [{source: 'github', event: 'pull_request_review', filter: 'event.action'}],
1646
+ max_executions: 1,
1647
+ },
1648
+ steps: [{run: 'echo ok'}],
1649
+ },
1650
+ },
1651
+ };
1652
+
1653
+ const error = expectInvalid(document);
1654
+
1655
+ expect(error.issues).toEqual([
1656
+ expect.objectContaining({
1657
+ code: 'listening-job-no-active-matcher',
1658
+ path: ['jobs', 'review', 'listening', 'on'],
1659
+ }),
1660
+ ]);
1661
+ });
1662
+
1663
+ it('makes an invalid listener on matcher inert and keeps the others active', () => {
1664
+ const document: WorkflowDocument = {
1665
+ name: 'invalid listener filter',
1666
+ jobs: {
1667
+ review: {
1668
+ listening: {
1669
+ on: [
1670
+ {source: 'github', event: 'pull_request_review', filter: 'event.action'},
1671
+ {
1672
+ source: 'github',
1673
+ event: 'pull_request_review',
1674
+ filter: 'event.action == "submitted"',
1675
+ },
1676
+ ],
1677
+ max_executions: 1,
1678
+ },
1679
+ steps: [{run: 'echo ok'}],
1680
+ },
1681
+ },
1682
+ };
1683
+
1684
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
1685
+
1686
+ expect(diagnostics).toEqual([
1687
+ expect.objectContaining({
1688
+ code: 'invalid-listener-filter',
1689
+ path: ['jobs', 'review', 'listening', 'on', 0, 'filter'],
1690
+ details: expect.objectContaining({
1691
+ field: 'listener.on',
1692
+ source: 'event.action',
1693
+ }),
1694
+ severity: 'error',
1695
+ scope: 'trigger',
1696
+ }),
1697
+ ]);
1698
+ expect(model.jobs[0]?.listening?.on).toEqual([
1699
+ {source: 'github', event: 'pull_request_review', filter: 'event.action == "submitted"'},
1700
+ ]);
1701
+ });
1702
+
1535
1703
  it('rejects listener filters that reference jobs without a direct needs edge', () => {
1536
1704
  const document: WorkflowDocument = {
1537
1705
  name: 'listener missing needs',
@@ -3175,6 +3343,8 @@ describe('normalizeWorkflowDocument', () => {
3175
3343
  message: 'Duration must be an integer followed by ms, s, m, h, or d.',
3176
3344
  path: ['jobs', 'build', 'execution_timeout'],
3177
3345
  details: {source: 'ten minutes'},
3346
+ severity: 'error',
3347
+ scope: 'definition',
3178
3348
  },
3179
3349
  ]);
3180
3350
  });
@@ -3198,6 +3368,8 @@ describe('normalizeWorkflowDocument', () => {
3198
3368
  message: 'Duration must be between 1s and 24h.',
3199
3369
  path: ['jobs', 'build', 'execution_timeout'],
3200
3370
  details: {source: '999ms', min_ms: 1000, max_ms: 24 * 60 * 60 * 1000},
3371
+ severity: 'error',
3372
+ scope: 'definition',
3201
3373
  },
3202
3374
  ]);
3203
3375
  });
@@ -3221,6 +3393,8 @@ describe('normalizeWorkflowDocument', () => {
3221
3393
  message: 'Duration must be between 1s and 24h.',
3222
3394
  path: ['jobs', 'build', 'execution_timeout'],
3223
3395
  details: {source: '25h', min_ms: 1000, max_ms: 24 * 60 * 60 * 1000},
3396
+ severity: 'error',
3397
+ scope: 'definition',
3224
3398
  },
3225
3399
  ]);
3226
3400
  });
@@ -3244,6 +3418,8 @@ describe('normalizeWorkflowDocument', () => {
3244
3418
  message: 'Duration must be between 1s and 24h.',
3245
3419
  path: ['jobs', 'build', 'execution_timeout'],
3246
3420
  details: {source: '30d', min_ms: 1000, max_ms: 24 * 60 * 60 * 1000},
3421
+ severity: 'error',
3422
+ scope: 'definition',
3247
3423
  },
3248
3424
  ]);
3249
3425
  });
@@ -3336,6 +3512,8 @@ describe('normalizeWorkflowDocument', () => {
3336
3512
  message: 'Step "build-review" must restart from an earlier keyed step; found "producer".',
3337
3513
  path: ['jobs', 'build', 'steps', 0, 'gate', 'on_failure'],
3338
3514
  details: {stepId: 'build-review', restartFrom: 'producer'},
3515
+ severity: 'error',
3516
+ scope: 'definition',
3339
3517
  },
3340
3518
  ]);
3341
3519
  });
@@ -3364,6 +3542,8 @@ describe('normalizeWorkflowDocument', () => {
3364
3542
  message: 'Step "build-review" must restart from an earlier keyed step; found "review".',
3365
3543
  path: ['jobs', 'build', 'steps', 0, 'gate', 'on_failure'],
3366
3544
  details: {stepId: 'build-review', restartFrom: 'review'},
3545
+ severity: 'error',
3546
+ scope: 'definition',
3367
3547
  },
3368
3548
  ]);
3369
3549
  });
@@ -3393,6 +3573,8 @@ describe('normalizeWorkflowDocument', () => {
3393
3573
  message: 'Step "build-review" must restart from an earlier keyed step; found "producer".',
3394
3574
  path: ['jobs', 'build', 'steps', 0, 'gate', 'on_failure'],
3395
3575
  details: {stepId: 'build-review', restartFrom: 'producer'},
3576
+ severity: 'error',
3577
+ scope: 'definition',
3396
3578
  },
3397
3579
  ]);
3398
3580
  });
@@ -3416,6 +3598,8 @@ describe('normalizeWorkflowDocument', () => {
3416
3598
  message: 'Job "test" depends on unknown job "build".',
3417
3599
  path: ['jobs', 'test', 'needs'],
3418
3600
  details: {job: 'test', dependency: 'build'},
3601
+ severity: 'error',
3602
+ scope: 'definition',
3419
3603
  },
3420
3604
  ]);
3421
3605
  });
@@ -3439,6 +3623,8 @@ describe('normalizeWorkflowDocument', () => {
3439
3623
  message: 'Job "test" depends on itself.',
3440
3624
  path: ['jobs', 'test', 'needs'],
3441
3625
  details: {job: 'test'},
3626
+ severity: 'error',
3627
+ scope: 'definition',
3442
3628
  },
3443
3629
  ]);
3444
3630
  });
@@ -3466,6 +3652,8 @@ describe('normalizeWorkflowDocument', () => {
3466
3652
  message: 'Circular dependency detected among jobs: build, test.',
3467
3653
  path: ['jobs'],
3468
3654
  details: {cycleSourceNames: ['build', 'test'], cycleJobIds: ['build', 'test']},
3655
+ severity: 'error',
3656
+ scope: 'definition',
3469
3657
  },
3470
3658
  ]);
3471
3659
  });
@@ -3497,6 +3685,8 @@ describe('normalizeWorkflowDocument', () => {
3497
3685
  message: 'Circular dependency detected among jobs: build, test.',
3498
3686
  path: ['jobs'],
3499
3687
  details: {cycleSourceNames: ['build', 'test'], cycleJobIds: ['build', 'test']},
3688
+ severity: 'error',
3689
+ scope: 'definition',
3500
3690
  },
3501
3691
  ]);
3502
3692
  });
@@ -3522,6 +3712,8 @@ describe('normalizeWorkflowDocument', () => {
3522
3712
  message: 'Job keys "build app" and "build-app" resolve to the same stable id "build-app".',
3523
3713
  path: ['jobs', 'build-app'],
3524
3714
  details: {id: 'build-app', sourceKeys: ['build app', 'build-app']},
3715
+ severity: 'error',
3716
+ scope: 'definition',
3525
3717
  },
3526
3718
  ]);
3527
3719
  });
@@ -3547,7 +3739,7 @@ describe('normalizeWorkflowDocument', () => {
3547
3739
  ]);
3548
3740
  });
3549
3741
 
3550
- it('reports stable trigger id collisions', () => {
3742
+ it('reports stable trigger id collisions with the later trigger inert', () => {
3551
3743
  const document: WorkflowDocument = {
3552
3744
  name: 'trigger collision',
3553
3745
  triggers: {
@@ -3561,17 +3753,20 @@ describe('normalizeWorkflowDocument', () => {
3561
3753
  },
3562
3754
  };
3563
3755
 
3564
- const error = expectInvalid(document);
3756
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
3565
3757
 
3566
- expect(error.issues).toEqual([
3758
+ expect(diagnostics).toEqual([
3567
3759
  {
3568
3760
  code: 'duplicate-trigger-id',
3569
3761
  message:
3570
3762
  'Trigger keys "main_push" and "main push" resolve to the same stable id "main-push".',
3571
3763
  path: ['triggers', 'main push'],
3572
3764
  details: {id: 'main-push', sourceKeys: ['main_push', 'main push']},
3765
+ severity: 'error',
3766
+ scope: 'trigger',
3573
3767
  },
3574
3768
  ]);
3769
+ expect(model.triggers.map((trigger) => trigger.key)).toEqual(['main_push']);
3575
3770
  });
3576
3771
 
3577
3772
  it('reports stable step id collisions inside a job', () => {
@@ -3592,6 +3787,8 @@ describe('normalizeWorkflowDocument', () => {
3592
3787
  message: 'Steps 0 and 1 in job "build" resolve to the same stable id "build-step-1".',
3593
3788
  path: ['jobs', 'build', 'steps', 1],
3594
3789
  details: {id: 'build-step-1', indexes: [0, 1]},
3790
+ severity: 'error',
3791
+ scope: 'definition',
3595
3792
  },
3596
3793
  ]);
3597
3794
  });
@@ -3675,17 +3872,6 @@ describe('normalizeWorkflowDocument', () => {
3675
3872
  site: 'ingest',
3676
3873
  },
3677
3874
  ],
3678
- [
3679
- 'non-boolean shape',
3680
- 'event.ref',
3681
- 'invalid-trigger-filter',
3682
- {
3683
- field: 'trigger.filter',
3684
- source: 'event.ref',
3685
- contextRoots: ['event'],
3686
- reason: 'Predicate source must be boolean-shaped.',
3687
- },
3688
- ],
3689
3875
  ] as const)('rejects trigger filters with %s', (_label, filter, code, details) => {
3690
3876
  const document: WorkflowDocument = {
3691
3877
  name: 'invalid trigger filter',
@@ -3711,11 +3897,50 @@ describe('normalizeWorkflowDocument', () => {
3711
3897
  message: expect.any(String),
3712
3898
  path: ['triggers', 'main', 'filter'],
3713
3899
  details,
3900
+ severity: 'error',
3901
+ scope: 'definition',
3714
3902
  },
3715
3903
  ]);
3716
3904
  });
3717
3905
 
3718
- it.each(['manual', 'cron'] as const)('rejects filters on %s triggers', (source) => {
3906
+ it('makes a trigger with a non-boolean filter inert', () => {
3907
+ const document: WorkflowDocument = {
3908
+ name: 'invalid trigger filter',
3909
+ triggers: {
3910
+ main: {
3911
+ source: 'github',
3912
+ event: 'push',
3913
+ filter: 'event.ref',
3914
+ },
3915
+ },
3916
+ jobs: {
3917
+ build: {
3918
+ steps: [{run: 'npm run build'}],
3919
+ },
3920
+ },
3921
+ };
3922
+
3923
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
3924
+
3925
+ expect(diagnostics).toEqual([
3926
+ {
3927
+ code: 'invalid-trigger-filter',
3928
+ message: expect.any(String),
3929
+ path: ['triggers', 'main', 'filter'],
3930
+ details: {
3931
+ field: 'trigger.filter',
3932
+ source: 'event.ref',
3933
+ contextRoots: ['event'],
3934
+ reason: 'Predicate source must be boolean-shaped.',
3935
+ },
3936
+ severity: 'error',
3937
+ scope: 'trigger',
3938
+ },
3939
+ ]);
3940
+ expect(model.triggers).toEqual([]);
3941
+ });
3942
+
3943
+ it.each(['manual', 'cron'] as const)('makes a %s trigger with a filter inert', (source) => {
3719
3944
  const document: WorkflowDocument = {
3720
3945
  name: 'unsupported trigger filter',
3721
3946
  triggers: {
@@ -3733,16 +3958,19 @@ describe('normalizeWorkflowDocument', () => {
3733
3958
  },
3734
3959
  };
3735
3960
 
3736
- const error = expectInvalid(document);
3961
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
3737
3962
 
3738
- expect(error.issues).toEqual([
3963
+ expect(diagnostics).toEqual([
3739
3964
  {
3740
3965
  code: 'invalid-trigger-filter',
3741
3966
  message: `A ${source} trigger cannot define a filter because it does not receive an event payload.`,
3742
3967
  path: ['triggers', 'main', 'filter'],
3743
3968
  details: {source: 'event.ref == "refs/heads/main"', triggerSource: source},
3969
+ severity: 'error',
3970
+ scope: 'trigger',
3744
3971
  },
3745
3972
  ]);
3973
+ expect(model.triggers).toEqual([]);
3746
3974
  });
3747
3975
 
3748
3976
  it('maps trigger with values to model inputs', () => {
@@ -3844,7 +4072,64 @@ describe('normalizeWorkflowDocument', () => {
3844
4072
  ]);
3845
4073
  });
3846
4074
 
3847
- it('reports a cron trigger with a non-tick event', () => {
4075
+ it('materializes fire for a manual trigger with the event omitted', () => {
4076
+ const document: WorkflowDocument = {
4077
+ name: 'manual trigger',
4078
+ triggers: {
4079
+ manual: {source: 'manual'},
4080
+ },
4081
+ jobs: {
4082
+ build: {
4083
+ steps: [{run: 'npm run build'}],
4084
+ },
4085
+ },
4086
+ };
4087
+
4088
+ const model = normalizeWorkflowDocument(document);
4089
+
4090
+ expect(model.triggers).toEqual([
4091
+ {
4092
+ id: 'manual',
4093
+ key: 'manual',
4094
+ source: 'manual',
4095
+ event: 'fire',
4096
+ },
4097
+ ]);
4098
+ });
4099
+
4100
+ it('materializes tick for a cron trigger with the event omitted', () => {
4101
+ const document: WorkflowDocument = {
4102
+ name: 'nightly trigger',
4103
+ triggers: {
4104
+ nightly: {
4105
+ source: 'cron',
4106
+ config: {schedule: '0 2 * * *'},
4107
+ },
4108
+ },
4109
+ jobs: {
4110
+ build: {
4111
+ steps: [{run: 'npm run build'}],
4112
+ },
4113
+ },
4114
+ };
4115
+
4116
+ const model = normalizeWorkflowDocument(document);
4117
+
4118
+ expect(model.triggers).toEqual([
4119
+ {
4120
+ id: 'nightly',
4121
+ key: 'nightly',
4122
+ source: 'cron',
4123
+ event: 'tick',
4124
+ config: {
4125
+ schedule: '0 2 * * *',
4126
+ timezone: 'UTC',
4127
+ },
4128
+ },
4129
+ ]);
4130
+ });
4131
+
4132
+ it('makes a cron trigger with a non-tick event inert', () => {
3848
4133
  const document: WorkflowDocument = {
3849
4134
  name: 'nightly trigger',
3850
4135
  triggers: {
@@ -3861,19 +4146,58 @@ describe('normalizeWorkflowDocument', () => {
3861
4146
  },
3862
4147
  };
3863
4148
 
3864
- const error = expectInvalid(document);
4149
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
3865
4150
 
3866
- expect(error.issues).toEqual([
4151
+ expect(diagnostics).toEqual([
3867
4152
  {
3868
4153
  code: 'invalid-cron-event',
3869
4154
  message: 'A cron trigger must use event "tick"; found "push".',
3870
4155
  path: ['triggers', 'nightly', 'event'],
3871
4156
  details: {event: 'push'},
4157
+ severity: 'error',
4158
+ scope: 'trigger',
3872
4159
  },
3873
4160
  ]);
4161
+ expect(model.triggers).toEqual([]);
3874
4162
  });
3875
4163
 
3876
- it('reports a cron trigger without a schedule', () => {
4164
+ it('keeps the event absent for integration triggers with the event omitted', () => {
4165
+ const document: WorkflowDocument = {
4166
+ name: 'source subscriptions',
4167
+ triggers: {
4168
+ on_any_deploy: {
4169
+ source: 'deploy_hook',
4170
+ },
4171
+ on_any_github_event: {
4172
+ source: 'github_acme',
4173
+ },
4174
+ },
4175
+ jobs: {
4176
+ build: {
4177
+ steps: [{run: 'npm run build'}],
4178
+ },
4179
+ },
4180
+ };
4181
+
4182
+ const model = normalizeWorkflowDocument(document);
4183
+
4184
+ expect(model.triggers).toEqual([
4185
+ {
4186
+ id: 'on-any-deploy',
4187
+ key: 'on_any_deploy',
4188
+ source: 'deploy_hook',
4189
+ },
4190
+ {
4191
+ id: 'on-any-github-event',
4192
+ key: 'on_any_github_event',
4193
+ source: 'github_acme',
4194
+ },
4195
+ ]);
4196
+ expect(model.triggers[0]).not.toHaveProperty('event');
4197
+ expect(model.triggers[1]).not.toHaveProperty('event');
4198
+ });
4199
+
4200
+ it('makes a cron trigger without a schedule inert', () => {
3877
4201
  const document: WorkflowDocument = {
3878
4202
  name: 'nightly trigger',
3879
4203
  triggers: {
@@ -3889,22 +4213,25 @@ describe('normalizeWorkflowDocument', () => {
3889
4213
  },
3890
4214
  };
3891
4215
 
3892
- const error = expectInvalid(document);
4216
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
3893
4217
 
3894
- expect(error.issues).toEqual([
4218
+ expect(diagnostics).toEqual([
3895
4219
  {
3896
4220
  code: 'missing-cron-schedule',
3897
4221
  message: 'A cron trigger requires a schedule.',
3898
4222
  path: ['triggers', 'nightly', 'config', 'schedule'],
4223
+ severity: 'error',
4224
+ scope: 'trigger',
3899
4225
  },
3900
4226
  ]);
4227
+ expect(model.triggers).toEqual([]);
3901
4228
  });
3902
4229
 
3903
4230
  it.each([
3904
4231
  ['malformed', 'not a cron'],
3905
4232
  ['6-field', '0 0 2 * * *'],
3906
4233
  ['preset', '@daily'],
3907
- ])('reports an invalid %s cron schedule', (_label, schedule) => {
4234
+ ])('makes a cron trigger with an invalid %s schedule inert', (_label, schedule) => {
3908
4235
  const document: WorkflowDocument = {
3909
4236
  name: 'nightly trigger',
3910
4237
  triggers: {
@@ -3921,19 +4248,22 @@ describe('normalizeWorkflowDocument', () => {
3921
4248
  },
3922
4249
  };
3923
4250
 
3924
- const error = expectInvalid(document);
4251
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
3925
4252
 
3926
- expect(error.issues).toEqual([
4253
+ expect(diagnostics).toEqual([
3927
4254
  {
3928
4255
  code: 'invalid-cron-schedule',
3929
4256
  message: 'Cron trigger schedule must be a valid 5-field cron expression.',
3930
4257
  path: ['triggers', 'nightly', 'config', 'schedule'],
3931
4258
  details: {schedule},
4259
+ severity: 'error',
4260
+ scope: 'trigger',
3932
4261
  },
3933
4262
  ]);
4263
+ expect(model.triggers).toEqual([]);
3934
4264
  });
3935
4265
 
3936
- it('reports an invalid cron timezone', () => {
4266
+ it('makes a cron trigger with an invalid timezone inert', () => {
3937
4267
  const document: WorkflowDocument = {
3938
4268
  name: 'nightly trigger',
3939
4269
  triggers: {
@@ -3953,16 +4283,19 @@ describe('normalizeWorkflowDocument', () => {
3953
4283
  },
3954
4284
  };
3955
4285
 
3956
- const error = expectInvalid(document);
4286
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
3957
4287
 
3958
- expect(error.issues).toEqual([
4288
+ expect(diagnostics).toEqual([
3959
4289
  {
3960
4290
  code: 'invalid-cron-timezone',
3961
4291
  message: 'Cron trigger timezone must be a valid IANA time zone.',
3962
4292
  path: ['triggers', 'nightly', 'config', 'timezone'],
3963
4293
  details: {timezone: 'Not/A/Zone'},
4294
+ severity: 'error',
4295
+ scope: 'trigger',
3964
4296
  },
3965
4297
  ]);
4298
+ expect(model.triggers).toEqual([]);
3966
4299
  });
3967
4300
 
3968
4301
  it('allows multiple cron triggers', () => {
@@ -4010,7 +4343,47 @@ describe('normalizeWorkflowDocument', () => {
4010
4343
  expect(model.triggers).toMatchObject([{id: 'manual', source: 'manual', event: 'fire'}]);
4011
4344
  });
4012
4345
 
4013
- it('reports multiple manual triggers as a semantic rule', () => {
4346
+ it('keeps other triggers active when a cron trigger is inert', () => {
4347
+ const document: WorkflowDocument = {
4348
+ name: 'partially broken triggers',
4349
+ triggers: {
4350
+ nightly: {
4351
+ source: 'cron',
4352
+ event: 'tick',
4353
+ config: {schedule: 'not a cron'},
4354
+ },
4355
+ on_demand: {source: 'manual', event: 'fire'},
4356
+ },
4357
+ jobs: {
4358
+ build: {
4359
+ steps: [{run: 'npm run build'}],
4360
+ },
4361
+ },
4362
+ };
4363
+
4364
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
4365
+
4366
+ expect(diagnostics).toEqual([
4367
+ {
4368
+ code: 'invalid-cron-schedule',
4369
+ message: 'Cron trigger schedule must be a valid 5-field cron expression.',
4370
+ path: ['triggers', 'nightly', 'config', 'schedule'],
4371
+ details: {schedule: 'not a cron'},
4372
+ severity: 'error',
4373
+ scope: 'trigger',
4374
+ },
4375
+ ]);
4376
+ expect(model.triggers).toEqual([
4377
+ {
4378
+ id: 'on-demand',
4379
+ key: 'on_demand',
4380
+ source: 'manual',
4381
+ event: 'fire',
4382
+ },
4383
+ ]);
4384
+ });
4385
+
4386
+ it('keeps the first manual trigger active and makes later ones inert', () => {
4014
4387
  const document: WorkflowDocument = {
4015
4388
  name: 'manual triggers',
4016
4389
  triggers: {
@@ -4024,16 +4397,51 @@ describe('normalizeWorkflowDocument', () => {
4024
4397
  },
4025
4398
  };
4026
4399
 
4027
- const error = expectInvalid(document);
4400
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
4028
4401
 
4029
- expect(error.issues).toEqual([
4402
+ expect(diagnostics).toEqual([
4030
4403
  {
4031
4404
  code: 'multiple-manual-triggers',
4032
- message: 'A workflow may declare at most one manual trigger; found 2: one, two.',
4033
- path: ['triggers'],
4405
+ message:
4406
+ 'A workflow may declare at most one manual trigger; found 2: one, two. This trigger is inert because it is not the first manual trigger in document order.',
4407
+ path: ['triggers', 'two'],
4034
4408
  details: {manualTriggerKeys: ['one', 'two']},
4409
+ severity: 'error',
4410
+ scope: 'trigger',
4035
4411
  },
4036
4412
  ]);
4413
+ expect(model.triggers.map((trigger) => trigger.key)).toEqual(['one']);
4414
+ });
4415
+
4416
+ it('promotes the first valid manual trigger when an earlier one is inert', () => {
4417
+ const document: WorkflowDocument = {
4418
+ name: 'manual triggers with an inert first trigger',
4419
+ triggers: {
4420
+ broken: {
4421
+ source: 'manual',
4422
+ event: 'fire',
4423
+ filter: 'event.ref == "refs/heads/main"',
4424
+ },
4425
+ working: {source: 'manual', event: 'fire'},
4426
+ },
4427
+ jobs: {
4428
+ build: {
4429
+ steps: [{run: 'npm run build'}],
4430
+ },
4431
+ },
4432
+ };
4433
+
4434
+ const {model, diagnostics} = normalizeWithDiagnostics(document);
4435
+
4436
+ expect(diagnostics).toEqual([
4437
+ expect.objectContaining({
4438
+ code: 'invalid-trigger-filter',
4439
+ path: ['triggers', 'broken', 'filter'],
4440
+ severity: 'error',
4441
+ scope: 'trigger',
4442
+ }),
4443
+ ]);
4444
+ expect(model.triggers.map((trigger) => trigger.key)).toEqual(['working']);
4037
4445
  });
4038
4446
 
4039
4447
  it('accumulates independent semantic issues in one pass', () => {