@ontrails/warden 1.0.0-beta.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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +21 -0
  5. package/README.md +132 -0
  6. package/dist/cli.d.ts +46 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +221 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/drift.d.ts +26 -0
  11. package/dist/drift.d.ts.map +1 -0
  12. package/dist/drift.js +27 -0
  13. package/dist/drift.js.map +1 -0
  14. package/dist/formatters.d.ts +29 -0
  15. package/dist/formatters.d.ts.map +1 -0
  16. package/dist/formatters.js +87 -0
  17. package/dist/formatters.js.map +1 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +26 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/rules/ast.d.ts +41 -0
  23. package/dist/rules/ast.d.ts.map +1 -0
  24. package/dist/rules/ast.js +163 -0
  25. package/dist/rules/ast.js.map +1 -0
  26. package/dist/rules/context-no-surface-types.d.ts +12 -0
  27. package/dist/rules/context-no-surface-types.d.ts.map +1 -0
  28. package/dist/rules/context-no-surface-types.js +96 -0
  29. package/dist/rules/context-no-surface-types.js.map +1 -0
  30. package/dist/rules/implementation-returns-result.d.ts +13 -0
  31. package/dist/rules/implementation-returns-result.d.ts.map +1 -0
  32. package/dist/rules/implementation-returns-result.js +231 -0
  33. package/dist/rules/implementation-returns-result.js.map +1 -0
  34. package/dist/rules/index.d.ts +22 -0
  35. package/dist/rules/index.d.ts.map +1 -0
  36. package/dist/rules/index.js +41 -0
  37. package/dist/rules/index.js.map +1 -0
  38. package/dist/rules/no-direct-impl-in-route.d.ts +12 -0
  39. package/dist/rules/no-direct-impl-in-route.d.ts.map +1 -0
  40. package/dist/rules/no-direct-impl-in-route.js +46 -0
  41. package/dist/rules/no-direct-impl-in-route.js.map +1 -0
  42. package/dist/rules/no-direct-implementation-call.d.ts +12 -0
  43. package/dist/rules/no-direct-implementation-call.d.ts.map +1 -0
  44. package/dist/rules/no-direct-implementation-call.js +39 -0
  45. package/dist/rules/no-direct-implementation-call.js.map +1 -0
  46. package/dist/rules/no-sync-result-assumption.d.ts +6 -0
  47. package/dist/rules/no-sync-result-assumption.d.ts.map +1 -0
  48. package/dist/rules/no-sync-result-assumption.js +98 -0
  49. package/dist/rules/no-sync-result-assumption.js.map +1 -0
  50. package/dist/rules/no-throw-in-detour-target.d.ts +12 -0
  51. package/dist/rules/no-throw-in-detour-target.d.ts.map +1 -0
  52. package/dist/rules/no-throw-in-detour-target.js +87 -0
  53. package/dist/rules/no-throw-in-detour-target.js.map +1 -0
  54. package/dist/rules/no-throw-in-implementation.d.ts +9 -0
  55. package/dist/rules/no-throw-in-implementation.d.ts.map +1 -0
  56. package/dist/rules/no-throw-in-implementation.js +34 -0
  57. package/dist/rules/no-throw-in-implementation.js.map +1 -0
  58. package/dist/rules/prefer-schema-inference.d.ts +7 -0
  59. package/dist/rules/prefer-schema-inference.d.ts.map +1 -0
  60. package/dist/rules/prefer-schema-inference.js +86 -0
  61. package/dist/rules/prefer-schema-inference.js.map +1 -0
  62. package/dist/rules/scan.d.ts +8 -0
  63. package/dist/rules/scan.d.ts.map +1 -0
  64. package/dist/rules/scan.js +32 -0
  65. package/dist/rules/scan.js.map +1 -0
  66. package/dist/rules/specs.d.ts +29 -0
  67. package/dist/rules/specs.d.ts.map +1 -0
  68. package/dist/rules/specs.js +192 -0
  69. package/dist/rules/specs.js.map +1 -0
  70. package/dist/rules/structure.d.ts +13 -0
  71. package/dist/rules/structure.d.ts.map +1 -0
  72. package/dist/rules/structure.js +142 -0
  73. package/dist/rules/structure.js.map +1 -0
  74. package/dist/rules/types.d.ts +52 -0
  75. package/dist/rules/types.d.ts.map +1 -0
  76. package/dist/rules/types.js +2 -0
  77. package/dist/rules/types.js.map +1 -0
  78. package/dist/rules/valid-describe-refs.d.ts +7 -0
  79. package/dist/rules/valid-describe-refs.d.ts.map +1 -0
  80. package/dist/rules/valid-describe-refs.js +51 -0
  81. package/dist/rules/valid-describe-refs.js.map +1 -0
  82. package/dist/rules/valid-detour-refs.d.ts +6 -0
  83. package/dist/rules/valid-detour-refs.d.ts.map +1 -0
  84. package/dist/rules/valid-detour-refs.js +116 -0
  85. package/dist/rules/valid-detour-refs.js.map +1 -0
  86. package/package.json +25 -0
  87. package/src/__tests__/cli.test.ts +198 -0
  88. package/src/__tests__/drift.test.ts +74 -0
  89. package/src/__tests__/formatters.test.ts +157 -0
  90. package/src/__tests__/implementation-returns-result.test.ts +75 -0
  91. package/src/__tests__/no-direct-implementation-call.test.ts +83 -0
  92. package/src/__tests__/no-sync-result-assumption.test.ts +85 -0
  93. package/src/__tests__/no-throw-in-detour-target.test.ts +78 -0
  94. package/src/__tests__/prefer-schema-inference.test.ts +84 -0
  95. package/src/__tests__/rules.test.ts +188 -0
  96. package/src/__tests__/valid-describe-refs.test.ts +60 -0
  97. package/src/cli.ts +343 -0
  98. package/src/drift.ts +50 -0
  99. package/src/formatters.ts +113 -0
  100. package/src/index.ts +47 -0
  101. package/src/rules/ast.ts +217 -0
  102. package/src/rules/context-no-surface-types.ts +150 -0
  103. package/src/rules/implementation-returns-result.ts +343 -0
  104. package/src/rules/index.ts +54 -0
  105. package/src/rules/no-direct-impl-in-route.ts +77 -0
  106. package/src/rules/no-direct-implementation-call.ts +47 -0
  107. package/src/rules/no-sync-result-assumption.ts +156 -0
  108. package/src/rules/no-throw-in-detour-target.ts +150 -0
  109. package/src/rules/no-throw-in-implementation.ts +41 -0
  110. package/src/rules/prefer-schema-inference.ts +141 -0
  111. package/src/rules/scan.ts +46 -0
  112. package/src/rules/specs.ts +384 -0
  113. package/src/rules/structure.ts +234 -0
  114. package/src/rules/types.ts +62 -0
  115. package/src/rules/valid-describe-refs.ts +94 -0
  116. package/src/rules/valid-detour-refs.ts +187 -0
  117. package/tsconfig.json +9 -0
  118. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,116 @@
1
+ import { collectTrailIds } from './specs.js';
2
+ const trackBraces = (line, state) => {
3
+ for (const ch of line) {
4
+ if (ch === '{') {
5
+ state.depth += 1;
6
+ state.found = true;
7
+ }
8
+ if (ch === '}') {
9
+ state.depth -= 1;
10
+ }
11
+ }
12
+ };
13
+ const collectArrayText = (lines, start) => {
14
+ let text = '';
15
+ for (let k = start; k < lines.length && k < start + 20; k += 1) {
16
+ const line = lines[k];
17
+ if (!line) {
18
+ continue;
19
+ }
20
+ text += `${line}\n`;
21
+ if (text.includes(']')) {
22
+ break;
23
+ }
24
+ }
25
+ return text;
26
+ };
27
+ const findMissingDetourTargets = (text, knownIds) => {
28
+ const missing = [];
29
+ for (const m of text.matchAll(/target\s*:\s*["'`]([^"'`]+)["'`]/g)) {
30
+ const [, id] = m;
31
+ if (id && !knownIds.has(id)) {
32
+ missing.push(id);
33
+ }
34
+ }
35
+ return missing;
36
+ };
37
+ const findMissingPlainDetours = (text, knownIds) => {
38
+ const missing = [];
39
+ const cleaned = text.replaceAll(/target\s*:\s*["'`][^"'`]+["'`]/g, '');
40
+ for (const m of cleaned.matchAll(/["'`]([^"'`]+)["'`]/g)) {
41
+ const [, id] = m;
42
+ if (id && id.includes('.') && !knownIds.has(id)) {
43
+ missing.push(id);
44
+ }
45
+ }
46
+ return missing;
47
+ };
48
+ const findAllMissingDetours = (text, knownIds) => [
49
+ ...findMissingDetourTargets(text, knownIds),
50
+ ...findMissingPlainDetours(text, knownIds),
51
+ ];
52
+ const addMissingDetourDiagnostics = (specLine, j, lines, trailId, lineNum, filePath, knownIds, diagnostics) => {
53
+ if (!/\bdetours\s*:/.test(specLine)) {
54
+ return;
55
+ }
56
+ for (const targetId of findAllMissingDetours(collectArrayText(lines, j), knownIds)) {
57
+ diagnostics.push({
58
+ filePath,
59
+ line: lineNum,
60
+ message: `Trail "${trailId}" has detour targeting "${targetId}" which is not defined.`,
61
+ rule: 'valid-detour-refs',
62
+ severity: 'error',
63
+ });
64
+ }
65
+ };
66
+ const scanTrailDetours = (lines, startIndex, trailId, filePath, knownIds, diagnostics) => {
67
+ const braceState = { depth: 0, found: false };
68
+ for (let j = startIndex; j < lines.length && j < startIndex + 200; j += 1) {
69
+ const specLine = lines[j];
70
+ if (!specLine) {
71
+ continue;
72
+ }
73
+ trackBraces(specLine, braceState);
74
+ addMissingDetourDiagnostics(specLine, j, lines, trailId, startIndex + 1, filePath, knownIds, diagnostics);
75
+ if (braceState.found && braceState.depth <= 0) {
76
+ break;
77
+ }
78
+ }
79
+ };
80
+ const processLine = (line, i, lines, filePath, knownIds, diagnostics) => {
81
+ const trailMatch = line.match(/\btrail\s*\(\s*["'`]([^"'`]+)["'`]/);
82
+ if (!trailMatch) {
83
+ return;
84
+ }
85
+ const [, trailId] = trailMatch;
86
+ if (!trailId) {
87
+ return;
88
+ }
89
+ scanTrailDetours(lines, i, trailId, filePath, knownIds, diagnostics);
90
+ };
91
+ const checkDetourRefs = (sourceCode, filePath, knownIds) => {
92
+ const diagnostics = [];
93
+ const lines = sourceCode.split('\n');
94
+ for (let i = 0; i < lines.length; i += 1) {
95
+ const line = lines[i];
96
+ if (line) {
97
+ processLine(line, i, lines, filePath, knownIds, diagnostics);
98
+ }
99
+ }
100
+ return diagnostics;
101
+ };
102
+ /**
103
+ * Checks that all trail IDs referenced in `detours` declarations exist.
104
+ */
105
+ export const validDetourRefs = {
106
+ check(sourceCode, filePath) {
107
+ return checkDetourRefs(sourceCode, filePath, collectTrailIds(sourceCode));
108
+ },
109
+ checkWithContext(sourceCode, filePath, context) {
110
+ return checkDetourRefs(sourceCode, filePath, context.knownTrailIds);
111
+ },
112
+ description: 'Ensure all detour target trail IDs reference defined trails.',
113
+ name: 'valid-detour-refs',
114
+ severity: 'error',
115
+ };
116
+ //# sourceMappingURL=valid-detour-refs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"valid-detour-refs.js","sourceRoot":"","sources":["../../src/rules/valid-detour-refs.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@ontrails/warden",
3
+ "version": "1.0.0-beta.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./package.json": "./package.json"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -b",
11
+ "test": "bun test",
12
+ "typecheck": "tsc --noEmit",
13
+ "lint": "oxlint ./src",
14
+ "clean": "rm -rf dist *.tsbuildinfo"
15
+ },
16
+ "devDependencies": {
17
+ "@oxc-project/types": "^0.122.0",
18
+ "oxc-parser": "^0.121.0",
19
+ "zod": "catalog:"
20
+ },
21
+ "peerDependencies": {
22
+ "@ontrails/core": "workspace:*",
23
+ "@ontrails/schema": "workspace:*"
24
+ }
25
+ }
@@ -0,0 +1,198 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { formatWardenReport, runWarden } from '../cli.js';
7
+
8
+ const makeTempDir = (): string => {
9
+ const dir = join(
10
+ tmpdir(),
11
+ `warden-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
12
+ );
13
+ mkdirSync(dir, { recursive: true });
14
+ return dir;
15
+ };
16
+
17
+ describe('runWarden', () => {
18
+ test('produces a report with diagnostics for bad code', async () => {
19
+ const dir = makeTempDir();
20
+ try {
21
+ writeFileSync(
22
+ join(dir, 'bad.ts'),
23
+ `trail("entity.show", {
24
+ implementation: async (input, ctx) => {
25
+ throw new Error("boom");
26
+ }
27
+ })`
28
+ );
29
+
30
+ const report = await runWarden({ rootDir: dir });
31
+ expect(report.errorCount).toBeGreaterThan(0);
32
+ expect(report.passed).toBe(false);
33
+ } finally {
34
+ rmSync(dir, { force: true, recursive: true });
35
+ }
36
+ });
37
+
38
+ test('passes for clean code', async () => {
39
+ const dir = makeTempDir();
40
+ try {
41
+ writeFileSync(
42
+ join(dir, 'good.ts'),
43
+ `trail("entity.show", {
44
+ implementation: async (input, ctx) => {
45
+ return Result.ok(data);
46
+ }
47
+ })`
48
+ );
49
+
50
+ const report = await runWarden({ rootDir: dir });
51
+ expect(report.errorCount).toBe(0);
52
+ expect(report.passed).toBe(true);
53
+ } finally {
54
+ rmSync(dir, { force: true, recursive: true });
55
+ }
56
+ });
57
+
58
+ test('lintOnly skips drift check', async () => {
59
+ const dir = makeTempDir();
60
+ try {
61
+ writeFileSync(join(dir, 'empty.ts'), 'export {}');
62
+ const report = await runWarden({ lintOnly: true, rootDir: dir });
63
+ expect(report.drift).toBeNull();
64
+ } finally {
65
+ rmSync(dir, { force: true, recursive: true });
66
+ }
67
+ });
68
+
69
+ test('driftOnly skips lint', async () => {
70
+ const dir = makeTempDir();
71
+ try {
72
+ // Even with bad code, driftOnly should produce 0 diagnostics
73
+ writeFileSync(
74
+ join(dir, 'bad.ts'),
75
+ `trail("x", { implementation: async () => { throw new Error("x"); } })`
76
+ );
77
+ const report = await runWarden({ driftOnly: true, rootDir: dir });
78
+ expect(report.diagnostics.length).toBe(0);
79
+ expect(report.drift).not.toBeNull();
80
+ } finally {
81
+ rmSync(dir, { force: true, recursive: true });
82
+ }
83
+ });
84
+
85
+ test('uses project context for detour references across files', async () => {
86
+ const dir = makeTempDir();
87
+ try {
88
+ writeFileSync(
89
+ join(dir, 'show.ts'),
90
+ `trail("entity.show", {
91
+ detours: { NotFoundError: ["entity.search"] },
92
+ implementation: async (input, ctx) => {
93
+ return Result.ok(data);
94
+ }
95
+ })`
96
+ );
97
+ writeFileSync(
98
+ join(dir, 'search.ts'),
99
+ `trail("entity.search", {
100
+ implementation: async (input, ctx) => {
101
+ return Result.ok(data);
102
+ }
103
+ })`
104
+ );
105
+
106
+ const report = await runWarden({ rootDir: dir });
107
+ const detourRefErrors = report.diagnostics.filter(
108
+ (diagnostic) => diagnostic.rule === 'valid-detour-refs'
109
+ );
110
+
111
+ expect(detourRefErrors).toHaveLength(0);
112
+ } finally {
113
+ rmSync(dir, { force: true, recursive: true });
114
+ }
115
+ });
116
+
117
+ test('flags throws in detour targets declared in another file', async () => {
118
+ const dir = makeTempDir();
119
+ try {
120
+ writeFileSync(
121
+ join(dir, 'show.ts'),
122
+ `trail("entity.show", {
123
+ detours: { NotFoundError: ["entity.search"] },
124
+ implementation: async (input, ctx) => {
125
+ return Result.ok(data);
126
+ }
127
+ })`
128
+ );
129
+ writeFileSync(
130
+ join(dir, 'search.ts'),
131
+ `trail("entity.search", {
132
+ implementation: async (input, ctx) => {
133
+ throw new Error("boom");
134
+ }
135
+ })`
136
+ );
137
+
138
+ const report = await runWarden({ rootDir: dir });
139
+ const detourThrowRules = report.diagnostics.filter(
140
+ (diagnostic) => diagnostic.rule === 'no-throw-in-detour-target'
141
+ );
142
+
143
+ expect(detourThrowRules).toHaveLength(1);
144
+ expect(detourThrowRules[0]?.message).toContain('entity.search');
145
+ } finally {
146
+ rmSync(dir, { force: true, recursive: true });
147
+ }
148
+ });
149
+ });
150
+
151
+ describe('formatWardenReport', () => {
152
+ test('formats a clean report', () => {
153
+ const output = formatWardenReport({
154
+ diagnostics: [],
155
+ drift: { committedHash: null, currentHash: 'stub', stale: false },
156
+ errorCount: 0,
157
+ passed: true,
158
+ warnCount: 0,
159
+ });
160
+ expect(output).toContain('Warden Report');
161
+ expect(output).toContain('Lint: clean');
162
+ expect(output).toContain('Drift: clean');
163
+ expect(output).toContain('Result: PASS');
164
+ });
165
+
166
+ test('formats a report with errors', () => {
167
+ const output = formatWardenReport({
168
+ diagnostics: [
169
+ {
170
+ filePath: 'src/trails/entity.ts',
171
+ line: 3,
172
+ message: 'Do not throw inside implementation.',
173
+ rule: 'no-throw-in-implementation',
174
+ severity: 'error',
175
+ },
176
+ ],
177
+ drift: { committedHash: null, currentHash: 'stub', stale: false },
178
+ errorCount: 1,
179
+ passed: false,
180
+ warnCount: 0,
181
+ });
182
+ expect(output).toContain('1 errors');
183
+ expect(output).toContain('Result: FAIL');
184
+ expect(output).toContain('entity.ts:3');
185
+ });
186
+
187
+ test('formats a report with stale drift', () => {
188
+ const output = formatWardenReport({
189
+ diagnostics: [],
190
+ drift: { committedHash: 'abc', currentHash: 'def', stale: true },
191
+ errorCount: 0,
192
+ passed: false,
193
+ warnCount: 0,
194
+ });
195
+ expect(output).toContain('surface.lock is stale');
196
+ expect(output).toContain('Result: FAIL');
197
+ });
198
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ import { trail, topo, Result } from '@ontrails/core';
7
+ import { hashSurfaceMap, generateSurfaceMap } from '@ontrails/schema';
8
+ import { z } from 'zod';
9
+
10
+ import { checkDrift } from '../drift.js';
11
+
12
+ const makeTopo = () => {
13
+ const t = trail('test.hello', {
14
+ implementation: () => Result.ok({ greeting: 'hi' }),
15
+ input: z.object({ name: z.string() }),
16
+ output: z.object({ greeting: z.string() }),
17
+ });
18
+ return topo('test-app', { t });
19
+ };
20
+
21
+ const createTempDir = (): string => {
22
+ const dir = join(tmpdir(), `drift-test-${Date.now()}`);
23
+ mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ };
26
+
27
+ describe('checkDrift', () => {
28
+ test('returns stale: false when no topo is provided', async () => {
29
+ const result = await checkDrift('/tmp');
30
+ expect(result.stale).toBe(false);
31
+ expect(result.currentHash).toBe('unknown');
32
+ });
33
+
34
+ test('returns stale: false when no lock file exists', async () => {
35
+ const dir = createTempDir();
36
+ try {
37
+ const result = await checkDrift(dir, makeTopo());
38
+ expect(result.stale).toBe(false);
39
+ expect(result.committedHash).toBeNull();
40
+ expect(result.currentHash.length).toBeGreaterThan(0);
41
+ } finally {
42
+ rmSync(dir, { force: true, recursive: true });
43
+ }
44
+ });
45
+
46
+ test('returns stale: false when lock matches current hash', async () => {
47
+ const dir = createTempDir();
48
+ try {
49
+ const tp = makeTopo();
50
+ const hash = hashSurfaceMap(generateSurfaceMap(tp));
51
+ writeFileSync(join(dir, 'surface.lock'), `${hash}\n`);
52
+
53
+ const result = await checkDrift(dir, tp);
54
+ expect(result.stale).toBe(false);
55
+ expect(result.committedHash).toBe(hash);
56
+ expect(result.currentHash).toBe(hash);
57
+ } finally {
58
+ rmSync(dir, { force: true, recursive: true });
59
+ }
60
+ });
61
+
62
+ test('returns stale: true when lock does not match', async () => {
63
+ const dir = createTempDir();
64
+ try {
65
+ writeFileSync(join(dir, 'surface.lock'), 'outdated-hash\n');
66
+
67
+ const result = await checkDrift(dir, makeTopo());
68
+ expect(result.stale).toBe(true);
69
+ expect(result.committedHash).toBe('outdated-hash');
70
+ } finally {
71
+ rmSync(dir, { force: true, recursive: true });
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { WardenReport } from '../cli.js';
4
+ import {
5
+ formatGitHubAnnotations,
6
+ formatJson,
7
+ formatSummary,
8
+ } from '../formatters.js';
9
+
10
+ const cleanReport: WardenReport = {
11
+ diagnostics: [],
12
+ drift: { committedHash: 'abc', currentHash: 'abc', stale: false },
13
+ errorCount: 0,
14
+ passed: true,
15
+ warnCount: 0,
16
+ };
17
+
18
+ const reportWithDiagnostics: WardenReport = {
19
+ diagnostics: [
20
+ {
21
+ filePath: 'packages/core/src/result.ts',
22
+ line: 42,
23
+ message: 'Throw statement found in trail implementation',
24
+ rule: 'no-throw-in-implementation',
25
+ severity: 'error',
26
+ },
27
+ {
28
+ filePath: 'packages/core/src/trails.ts',
29
+ line: 15,
30
+ message: 'Trail "entity.show" has no output schema',
31
+ rule: 'require-output-schema',
32
+ severity: 'warn',
33
+ },
34
+ ],
35
+ drift: null,
36
+ errorCount: 1,
37
+ passed: false,
38
+ warnCount: 1,
39
+ };
40
+
41
+ const reportWithDrift: WardenReport = {
42
+ diagnostics: [],
43
+ drift: { committedHash: 'abc', currentHash: 'def', stale: true },
44
+ errorCount: 0,
45
+ passed: false,
46
+ warnCount: 0,
47
+ };
48
+
49
+ describe('formatGitHubAnnotations', () => {
50
+ test('produces empty string for clean report', () => {
51
+ expect(formatGitHubAnnotations(cleanReport)).toBe('');
52
+ });
53
+
54
+ test('maps error severity to ::error', () => {
55
+ const output = formatGitHubAnnotations(reportWithDiagnostics);
56
+ expect(output).toContain(
57
+ '::error file=packages/core/src/result.ts,line=42::no-throw-in-implementation:'
58
+ );
59
+ });
60
+
61
+ test('maps warn severity to ::warning', () => {
62
+ const output = formatGitHubAnnotations(reportWithDiagnostics);
63
+ expect(output).toContain(
64
+ '::warning file=packages/core/src/trails.ts,line=15::require-output-schema:'
65
+ );
66
+ });
67
+
68
+ test('emits drift as a single ::error annotation', () => {
69
+ const output = formatGitHubAnnotations(reportWithDrift);
70
+ expect(output).toContain('::error::drift: surface.lock is stale');
71
+ });
72
+
73
+ test('produces one line per diagnostic', () => {
74
+ const lines = formatGitHubAnnotations(reportWithDiagnostics)
75
+ .split('\n')
76
+ .filter(Boolean);
77
+ expect(lines).toHaveLength(2);
78
+ });
79
+ });
80
+
81
+ describe('formatJson', () => {
82
+ test('produces valid JSON', () => {
83
+ const parsed = JSON.parse(formatJson(cleanReport));
84
+ expect(parsed).toBeDefined();
85
+ });
86
+
87
+ test('includes passed status', () => {
88
+ const parsed = JSON.parse(formatJson(cleanReport));
89
+ expect(parsed.passed).toBe(true);
90
+ });
91
+
92
+ test('includes summary counts', () => {
93
+ const parsed = JSON.parse(formatJson(reportWithDiagnostics));
94
+ expect(parsed.summary).toEqual({
95
+ errors: 1,
96
+ suggestions: 0,
97
+ warnings: 1,
98
+ });
99
+ });
100
+
101
+ test('includes diagnostics array', () => {
102
+ const parsed = JSON.parse(formatJson(reportWithDiagnostics));
103
+ expect(parsed.diagnostics).toHaveLength(2);
104
+ expect(parsed.diagnostics[0].rule).toBe('no-throw-in-implementation');
105
+ });
106
+
107
+ test('includes null drift when absent', () => {
108
+ const parsed = JSON.parse(formatJson(reportWithDiagnostics));
109
+ expect(parsed.drift).toBeNull();
110
+ });
111
+
112
+ test('includes drift result when present', () => {
113
+ const parsed = JSON.parse(formatJson(reportWithDrift));
114
+ expect(parsed.drift.stale).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe('formatSummary', () => {
119
+ test('includes markdown heading', () => {
120
+ expect(formatSummary(cleanReport)).toContain('## Warden Report');
121
+ });
122
+
123
+ test('shows PASS for clean report', () => {
124
+ expect(formatSummary(cleanReport)).toContain('**Result: PASS**');
125
+ });
126
+
127
+ test('shows FAIL for failing report', () => {
128
+ expect(formatSummary(reportWithDiagnostics)).toContain('**Result: FAIL**');
129
+ });
130
+
131
+ test('groups errors under ### Errors heading', () => {
132
+ const output = formatSummary(reportWithDiagnostics);
133
+ expect(output).toContain('### Errors');
134
+ expect(output).toContain('no-throw-in-implementation');
135
+ });
136
+
137
+ test('groups warnings under ### Warnings heading', () => {
138
+ const output = formatSummary(reportWithDiagnostics);
139
+ expect(output).toContain('### Warnings');
140
+ expect(output).toContain('require-output-schema');
141
+ });
142
+
143
+ test('includes file:line in backticks', () => {
144
+ const output = formatSummary(reportWithDiagnostics);
145
+ expect(output).toContain('`packages/core/src/result.ts:42`');
146
+ });
147
+
148
+ test('includes drift section when stale', () => {
149
+ const output = formatSummary(reportWithDrift);
150
+ expect(output).toContain('### Drift');
151
+ expect(output).toContain('surface.lock is stale');
152
+ });
153
+
154
+ test('omits drift section when clean', () => {
155
+ expect(formatSummary(cleanReport)).not.toContain('### Drift');
156
+ });
157
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { implementationReturnsResult } from '../rules/implementation-returns-result.js';
4
+
5
+ const TEST_FILE = 'test.ts';
6
+
7
+ describe('implementation-returns-result', () => {
8
+ test('flags raw object return in trail implementation', () => {
9
+ const code = `
10
+ trail("entity.show", {
11
+ implementation: async (input, ctx) => {
12
+ return { name: "foo" };
13
+ }
14
+ })`;
15
+
16
+ const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
17
+
18
+ expect(diagnostics.length).toBe(1);
19
+ expect(diagnostics[0]?.rule).toBe('implementation-returns-result');
20
+ expect(diagnostics[0]?.severity).toBe('error');
21
+ });
22
+
23
+ test('allows Result.ok() and returning ctx.follow() results', () => {
24
+ const code = `
25
+ hike("entity.onboard", {
26
+ implementation: async (input, ctx) => {
27
+ const result = await ctx.follow("entity.create", input);
28
+ return result;
29
+ }
30
+ })
31
+
32
+ trail("entity.create", {
33
+ implementation: async (input, ctx) => Result.ok({ id: "123" })
34
+ })`;
35
+
36
+ const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
37
+
38
+ expect(diagnostics.length).toBe(0);
39
+ });
40
+
41
+ test('flags concise raw implementation bodies', () => {
42
+ const code = `
43
+ trail("entity.create", {
44
+ implementation: async (input, ctx) => ({ id: "123" })
45
+ })`;
46
+
47
+ const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
48
+
49
+ expect(diagnostics.length).toBe(1);
50
+ expect(diagnostics[0]?.message).toContain('entity.create');
51
+ });
52
+
53
+ test('allows returning explicitly Result-typed local helpers', () => {
54
+ const code = `
55
+ const buildDetail = (trailId: string): Result<object, Error> =>
56
+ Result.ok({ trailId });
57
+
58
+ const buildDiff = async (): Promise<Result<object, Error>> =>
59
+ Result.ok({ breaking: [] });
60
+
61
+ trail("survey", {
62
+ implementation: async (input, ctx) => {
63
+ if (input.diff) {
64
+ return await buildDiff();
65
+ }
66
+
67
+ return buildDetail(input.trailId);
68
+ }
69
+ })`;
70
+
71
+ const diagnostics = implementationReturnsResult.check(code, TEST_FILE);
72
+
73
+ expect(diagnostics.length).toBe(0);
74
+ });
75
+ });