@opensip-tools/contracts 1.0.4

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 (86) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/LICENSE +21 -0
  4. package/dist/__tests__/dashboard.test.d.ts +2 -0
  5. package/dist/__tests__/dashboard.test.d.ts.map +1 -0
  6. package/dist/__tests__/dashboard.test.js +85 -0
  7. package/dist/__tests__/dashboard.test.js.map +1 -0
  8. package/dist/__tests__/exit-codes.test.d.ts +2 -0
  9. package/dist/__tests__/exit-codes.test.d.ts.map +1 -0
  10. package/dist/__tests__/exit-codes.test.js +73 -0
  11. package/dist/__tests__/exit-codes.test.js.map +1 -0
  12. package/dist/__tests__/store.test.d.ts +2 -0
  13. package/dist/__tests__/store.test.d.ts.map +1 -0
  14. package/dist/__tests__/store.test.js +169 -0
  15. package/dist/__tests__/store.test.js.map +1 -0
  16. package/dist/exit-codes.d.ts +14 -0
  17. package/dist/exit-codes.d.ts.map +1 -0
  18. package/dist/exit-codes.js +61 -0
  19. package/dist/exit-codes.js.map +1 -0
  20. package/dist/index.d.ts +21 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +20 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/persistence/dashboard/checks.d.ts +7 -0
  25. package/dist/persistence/dashboard/checks.d.ts.map +1 -0
  26. package/dist/persistence/dashboard/checks.js +279 -0
  27. package/dist/persistence/dashboard/checks.js.map +1 -0
  28. package/dist/persistence/dashboard/css.d.ts +6 -0
  29. package/dist/persistence/dashboard/css.d.ts.map +1 -0
  30. package/dist/persistence/dashboard/css.js +141 -0
  31. package/dist/persistence/dashboard/css.js.map +1 -0
  32. package/dist/persistence/dashboard/generator.d.ts +9 -0
  33. package/dist/persistence/dashboard/generator.d.ts.map +1 -0
  34. package/dist/persistence/dashboard/generator.js +79 -0
  35. package/dist/persistence/dashboard/generator.js.map +1 -0
  36. package/dist/persistence/dashboard/index.d.ts +5 -0
  37. package/dist/persistence/dashboard/index.d.ts.map +1 -0
  38. package/dist/persistence/dashboard/index.js +5 -0
  39. package/dist/persistence/dashboard/index.js.map +1 -0
  40. package/dist/persistence/dashboard/overview.d.ts +6 -0
  41. package/dist/persistence/dashboard/overview.d.ts.map +1 -0
  42. package/dist/persistence/dashboard/overview.js +65 -0
  43. package/dist/persistence/dashboard/overview.js.map +1 -0
  44. package/dist/persistence/dashboard/recipes.d.ts +6 -0
  45. package/dist/persistence/dashboard/recipes.d.ts.map +1 -0
  46. package/dist/persistence/dashboard/recipes.js +68 -0
  47. package/dist/persistence/dashboard/recipes.js.map +1 -0
  48. package/dist/persistence/dashboard/sessions.d.ts +6 -0
  49. package/dist/persistence/dashboard/sessions.d.ts.map +1 -0
  50. package/dist/persistence/dashboard/sessions.js +205 -0
  51. package/dist/persistence/dashboard/sessions.js.map +1 -0
  52. package/dist/persistence/dashboard/shared.d.ts +6 -0
  53. package/dist/persistence/dashboard/shared.d.ts.map +1 -0
  54. package/dist/persistence/dashboard/shared.js +211 -0
  55. package/dist/persistence/dashboard/shared.js.map +1 -0
  56. package/dist/persistence/dashboard/tool-tabs.d.ts +6 -0
  57. package/dist/persistence/dashboard/tool-tabs.d.ts.map +1 -0
  58. package/dist/persistence/dashboard/tool-tabs.js +102 -0
  59. package/dist/persistence/dashboard/tool-tabs.js.map +1 -0
  60. package/dist/persistence/store.d.ts +103 -0
  61. package/dist/persistence/store.d.ts.map +1 -0
  62. package/dist/persistence/store.js +156 -0
  63. package/dist/persistence/store.js.map +1 -0
  64. package/dist/types.d.ts +279 -0
  65. package/dist/types.d.ts.map +1 -0
  66. package/dist/types.js +2 -0
  67. package/dist/types.js.map +1 -0
  68. package/package.json +35 -0
  69. package/src/__tests__/dashboard.test.ts +102 -0
  70. package/src/__tests__/exit-codes.test.ts +87 -0
  71. package/src/__tests__/store.test.ts +213 -0
  72. package/src/exit-codes.ts +74 -0
  73. package/src/index.ts +71 -0
  74. package/src/persistence/dashboard/checks.ts +279 -0
  75. package/src/persistence/dashboard/css.ts +141 -0
  76. package/src/persistence/dashboard/generator.ts +89 -0
  77. package/src/persistence/dashboard/index.ts +5 -0
  78. package/src/persistence/dashboard/overview.ts +65 -0
  79. package/src/persistence/dashboard/recipes.ts +68 -0
  80. package/src/persistence/dashboard/sessions.ts +205 -0
  81. package/src/persistence/dashboard/shared.ts +211 -0
  82. package/src/persistence/dashboard/tool-tabs.ts +102 -0
  83. package/src/persistence/store.ts +233 -0
  84. package/src/types.ts +306 -0
  85. package/tsconfig.json +8 -0
  86. package/vitest.config.ts +16 -0
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { generateDashboardHtml } from '../persistence/dashboard/generator.js';
4
+
5
+ import type { CheckCatalogEntry, RecipeCatalogEntry, StoredSession } from '../persistence/store.js';
6
+
7
+ function makeSession(overrides: Partial<StoredSession> = {}): StoredSession {
8
+ return {
9
+ id: 'sess-1',
10
+ tool: 'fit',
11
+ timestamp: new Date().toISOString(),
12
+ cwd: '/proj',
13
+ score: 92,
14
+ passed: true,
15
+ summary: { total: 10, passed: 9, failed: 1, errors: 0, warnings: 0 },
16
+ checks: [],
17
+ durationMs: 100,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ const checkCatalog: CheckCatalogEntry[] = [
23
+ {
24
+ slug: 'no-console-log',
25
+ name: 'No console.log',
26
+ icon: '🚫',
27
+ description: 'Forbids console.log in production',
28
+ tags: ['quality'],
29
+ confidence: 'high',
30
+ source: 'built-in',
31
+ },
32
+ ];
33
+
34
+ const recipeCatalog: RecipeCatalogEntry[] = [
35
+ {
36
+ name: 'default',
37
+ displayName: 'Default',
38
+ description: 'All checks',
39
+ tags: [],
40
+ selectorType: 'all',
41
+ mode: 'parallel',
42
+ timeout: 30_000,
43
+ },
44
+ ];
45
+
46
+ describe('generateDashboardHtml', () => {
47
+ it('produces a complete HTML5 document', () => {
48
+ const html = generateDashboardHtml([makeSession()]);
49
+ expect(html).toContain('<!DOCTYPE html>');
50
+ expect(html).toContain('</html>');
51
+ });
52
+
53
+ it('inlines the latest session score in the document title', () => {
54
+ const html = generateDashboardHtml([makeSession({ score: 85 })]);
55
+ expect(html).toContain('Pass Rate: 85%');
56
+ });
57
+
58
+ it('omits the score from the title when there are no sessions', () => {
59
+ const html = generateDashboardHtml([]);
60
+ expect(html).toMatch(/<title>OpenSIP Tools<\/title>/);
61
+ });
62
+
63
+ it('inlines session, check catalog, and recipe catalog as JS data', () => {
64
+ const html = generateDashboardHtml(
65
+ [makeSession({ id: 'special-session-id' })],
66
+ checkCatalog,
67
+ recipeCatalog,
68
+ );
69
+ expect(html).toContain('special-session-id');
70
+ expect(html).toContain('no-console-log');
71
+ expect(html).toContain('default');
72
+ });
73
+
74
+ it('escapes < and > in inlined JSON to prevent script injection', () => {
75
+ // A session whose cwd contains a fake </script> tag must not break out
76
+ const evil = makeSession({ cwd: '</script><script>alert(1)</script>' });
77
+ const html = generateDashboardHtml([evil]);
78
+ expect(html).not.toMatch(/<\/script>\s*<script>alert\(1\)/);
79
+ expect(html).toContain(String.raw`</script>`);
80
+ });
81
+
82
+ it('renders all three tab panels (overview, fitness, simulation)', () => {
83
+ const html = generateDashboardHtml([]);
84
+ expect(html).toContain('id="panel-overview"');
85
+ expect(html).toContain('id="panel-fitness"');
86
+ expect(html).toContain('id="panel-simulation"');
87
+ });
88
+
89
+ it('includes inline CSS', () => {
90
+ const html = generateDashboardHtml([]);
91
+ expect(html).toMatch(/<style>[\s\S]+?<\/style>/);
92
+ });
93
+
94
+ it('partitions sessions into fit vs sim arrays in the page script', () => {
95
+ const html = generateDashboardHtml([
96
+ makeSession({ id: 'fit-1', tool: 'fit' }),
97
+ makeSession({ id: 'sim-1', tool: 'sim' }),
98
+ ]);
99
+ expect(html).toContain("sessions.filter(s => s.tool === 'fit')");
100
+ expect(html).toContain("sessions.filter(s => s.tool === 'sim')");
101
+ });
102
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { EXIT_CODES, getErrorSuggestion } from '../exit-codes.js';
4
+
5
+ describe('EXIT_CODES', () => {
6
+ it('exposes the documented set', () => {
7
+ expect(EXIT_CODES).toEqual({
8
+ SUCCESS: 0,
9
+ RUNTIME_ERROR: 1,
10
+ CONFIGURATION_ERROR: 2,
11
+ CHECK_NOT_FOUND: 3,
12
+ REPORT_FAILED: 4,
13
+ });
14
+ });
15
+ });
16
+
17
+ describe('getErrorSuggestion', () => {
18
+ it('returns null when no pattern matches', () => {
19
+ expect(getErrorSuggestion(new Error('something else entirely'))).toBeNull();
20
+ });
21
+
22
+ it('handles non-Error inputs', () => {
23
+ expect(getErrorSuggestion('just a string')).toBeNull();
24
+ expect(getErrorSuggestion(undefined)).toBeNull();
25
+ });
26
+
27
+ it('classifies "Check not found: <slug>" with the slug surfaced', () => {
28
+ const out = getErrorSuggestion(new Error('Check not found: foo-check'));
29
+ expect(out?.exitCode).toBe(EXIT_CODES.CHECK_NOT_FOUND);
30
+ expect(out?.message).toContain('foo-check');
31
+ });
32
+
33
+ it('classifies "not found: <slug>" without the "Check" prefix', () => {
34
+ const out = getErrorSuggestion(new Error('Recipe not found: my-recipe'));
35
+ expect(out?.exitCode).toBe(EXIT_CODES.CHECK_NOT_FOUND);
36
+ expect(out?.message).toContain('my-recipe');
37
+ });
38
+
39
+ it('falls back to "unknown" when slug cannot be extracted', () => {
40
+ const out = getErrorSuggestion(new Error('not found'));
41
+ expect(out?.message).toContain('unknown');
42
+ });
43
+
44
+ it('classifies "Unknown recipe ..." as configuration error', () => {
45
+ const out = getErrorSuggestion(new Error('Unknown recipe foo'));
46
+ expect(out?.exitCode).toBe(EXIT_CODES.CONFIGURATION_ERROR);
47
+ expect(out?.action).toContain('--recipes');
48
+ });
49
+
50
+ it('classifies opensip-tools.config.yml errors', () => {
51
+ const out = getErrorSuggestion(new Error('Failed to parse opensip-tools.config.yml'));
52
+ expect(out?.exitCode).toBe(EXIT_CODES.CONFIGURATION_ERROR);
53
+ });
54
+
55
+ it('classifies YAML errors as configuration', () => {
56
+ const out = getErrorSuggestion(new Error('Bad YAML at line 3'));
57
+ expect(out?.exitCode).toBe(EXIT_CODES.CONFIGURATION_ERROR);
58
+ });
59
+
60
+ it('classifies generic config errors', () => {
61
+ const out = getErrorSuggestion(new Error('config invalid'));
62
+ expect(out?.exitCode).toBe(EXIT_CODES.CONFIGURATION_ERROR);
63
+ });
64
+
65
+ it('classifies EACCES as a runtime permission error', () => {
66
+ const out = getErrorSuggestion(new Error('EACCES: permission denied reading /etc'));
67
+ expect(out?.exitCode).toBe(EXIT_CODES.RUNTIME_ERROR);
68
+ expect(out?.action).toContain('permissions');
69
+ });
70
+
71
+ it('classifies "No checks registered" as runtime error with check-pack guidance', () => {
72
+ const out = getErrorSuggestion(new Error('No checks registered'));
73
+ expect(out?.exitCode).toBe(EXIT_CODES.RUNTIME_ERROR);
74
+ expect(out?.action).toContain('checks-*');
75
+ });
76
+
77
+ it('classifies "No checks to run" similarly', () => {
78
+ const out = getErrorSuggestion(new Error('No checks to run'));
79
+ expect(out?.exitCode).toBe(EXIT_CODES.RUNTIME_ERROR);
80
+ });
81
+
82
+ it('classifies fetch/network errors as REPORT_FAILED', () => {
83
+ expect(getErrorSuggestion(new Error('fetch failed'))?.exitCode).toBe(EXIT_CODES.REPORT_FAILED);
84
+ expect(getErrorSuggestion(new Error('ECONNREFUSED'))?.exitCode).toBe(EXIT_CODES.REPORT_FAILED);
85
+ expect(getErrorSuggestion(new Error('network unreachable'))?.exitCode).toBe(EXIT_CODES.REPORT_FAILED);
86
+ });
87
+ });
@@ -0,0 +1,213 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+
7
+ import {
8
+ clearAllSessions,
9
+ clearSessionsOlderThan,
10
+ configurePersistencePaths,
11
+ countSessions,
12
+ generateSessionId,
13
+ getReportsDir,
14
+ getStoreDir,
15
+ loadLatestSession,
16
+ loadSessions,
17
+ sanitizeForFilename,
18
+ saveSession,
19
+ type StoredSession,
20
+ } from '../persistence/store.js';
21
+
22
+ let testDir: string;
23
+ let sessionsDir: string;
24
+ let reportsDir: string;
25
+
26
+ function makeSession(overrides: Partial<StoredSession> = {}): StoredSession {
27
+ return {
28
+ id: 'sess-1',
29
+ tool: 'fit',
30
+ timestamp: new Date().toISOString(),
31
+ cwd: '/proj',
32
+ score: 100,
33
+ passed: true,
34
+ summary: { total: 1, passed: 1, failed: 0, errors: 0, warnings: 0 },
35
+ checks: [],
36
+ durationMs: 0,
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ beforeEach(() => {
42
+
43
+ testDir = mkdtempSync(join(tmpdir(), 'contracts-store-'));
44
+ sessionsDir = join(testDir, 'sessions');
45
+ reportsDir = join(testDir, 'reports');
46
+ configurePersistencePaths({ sessionsDir, reportsDir });
47
+ });
48
+
49
+ afterEach(() => {
50
+ rmSync(testDir, { recursive: true, force: true });
51
+ });
52
+
53
+ describe('configurePersistencePaths', () => {
54
+ it('redirects getStoreDir / getReportsDir to the configured paths', () => {
55
+ expect(getStoreDir()).toBe(sessionsDir);
56
+ expect(getReportsDir()).toBe(reportsDir);
57
+ });
58
+ });
59
+
60
+ describe('sanitizeForFilename', () => {
61
+ it('strips path separators and special chars', () => {
62
+ expect(sanitizeForFilename(String.raw`a/b\c:d*e?f"g<h>i|j.k`)).not.toMatch(/[/\\:*?"<>|.]/);
63
+ });
64
+
65
+ it('collapses parent traversal', () => {
66
+ expect(sanitizeForFilename('../../etc/passwd')).not.toContain('..');
67
+ });
68
+ });
69
+
70
+ describe('saveSession', () => {
71
+ it('writes a json file under the sessions dir and returns its path', () => {
72
+ const path = saveSession(makeSession());
73
+ expect(path.startsWith(sessionsDir)).toBe(true);
74
+ expect(readdirSync(sessionsDir).length).toBe(1);
75
+ });
76
+
77
+ it('encodes the recipe in the filename, sanitized', () => {
78
+ const path = saveSession(makeSession({ recipe: 'foo/bar' }));
79
+ expect(path).toContain('-foo-bar');
80
+ });
81
+
82
+ it('does not include a recipe segment when recipe is absent', () => {
83
+ const path = saveSession(makeSession());
84
+ expect(path).not.toContain('-undefined');
85
+ });
86
+ });
87
+
88
+ describe('loadSessions / loadLatestSession', () => {
89
+ it('returns sessions in newest-first order', () => {
90
+ saveSession(makeSession({ id: 'first', timestamp: '2024-01-01T00:00:00.000Z' }));
91
+ saveSession(makeSession({ id: 'second', timestamp: '2024-06-01T00:00:00.000Z' }));
92
+ saveSession(makeSession({ id: 'third', timestamp: '2024-12-01T00:00:00.000Z' }));
93
+ const out = loadSessions();
94
+ expect(out.map((s) => s.id)).toEqual(['third', 'second', 'first']);
95
+ });
96
+
97
+ it('honors the limit parameter', () => {
98
+ saveSession(makeSession({ id: 'a', timestamp: '2024-01-01T00:00:00.000Z' }));
99
+ saveSession(makeSession({ id: 'b', timestamp: '2024-02-01T00:00:00.000Z' }));
100
+ saveSession(makeSession({ id: 'c', timestamp: '2024-03-01T00:00:00.000Z' }));
101
+ expect(loadSessions(2)).toHaveLength(2);
102
+ });
103
+
104
+ it('skips corrupted JSON files without crashing', () => {
105
+ saveSession(makeSession({ id: 'good' }));
106
+ mkdirSync(sessionsDir, { recursive: true });
107
+ writeFileSync(join(sessionsDir, '2024-99-99-fit.json'), '{not-json');
108
+ const out = loadSessions();
109
+ expect(out.map((s) => s.id)).toContain('good');
110
+ });
111
+
112
+ it('loadLatestSession returns null when no sessions exist', () => {
113
+ expect(loadLatestSession()).toBeNull();
114
+ });
115
+
116
+ it('loadLatestSession returns the newest session', () => {
117
+ saveSession(makeSession({ id: 'old', timestamp: '2024-01-01T00:00:00.000Z' }));
118
+ saveSession(makeSession({ id: 'new', timestamp: '2024-12-31T00:00:00.000Z' }));
119
+ expect(loadLatestSession()?.id).toBe('new');
120
+ });
121
+ });
122
+
123
+ describe('countSessions', () => {
124
+ it('returns 0 when the store is empty', () => {
125
+ expect(countSessions()).toBe(0);
126
+ });
127
+
128
+ it('counts only .json files', () => {
129
+ saveSession(makeSession());
130
+ writeFileSync(join(sessionsDir, 'note.txt'), 'ignored');
131
+ expect(countSessions()).toBe(1);
132
+ });
133
+ });
134
+
135
+ describe('clearAllSessions', () => {
136
+ it('removes every json file and returns the count', () => {
137
+ saveSession(makeSession({ id: 'a', timestamp: '2024-01-01T00:00:00.000Z' }));
138
+ saveSession(makeSession({ id: 'b', timestamp: '2024-02-01T00:00:00.000Z' }));
139
+ expect(clearAllSessions()).toBe(2);
140
+ expect(countSessions()).toBe(0);
141
+ });
142
+
143
+ it('returns 0 when the store is already empty', () => {
144
+ expect(clearAllSessions()).toBe(0);
145
+ });
146
+ });
147
+
148
+ describe('clearSessionsOlderThan', () => {
149
+ it('deletes sessions with timestamps older than the cutoff', () => {
150
+ const oldTs = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
151
+ const newTs = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
152
+ saveSession(makeSession({ id: 'old', timestamp: oldTs }));
153
+ saveSession(makeSession({ id: 'new', timestamp: newTs }));
154
+
155
+ expect(clearSessionsOlderThan(7)).toBe(1);
156
+ expect(countSessions()).toBe(1);
157
+ expect(loadLatestSession()?.id).toBe('new');
158
+ });
159
+
160
+ it('skips files with unparseable timestamps', () => {
161
+ mkdirSync(sessionsDir, { recursive: true });
162
+ writeFileSync(join(sessionsDir, 'bad.json'), JSON.stringify({ timestamp: 'not-a-date' }));
163
+ saveSession(makeSession({ id: 'ok', timestamp: new Date().toISOString() }));
164
+
165
+ // bad.json has unparseable timestamp; 'ok' is recent. None deleted.
166
+ expect(clearSessionsOlderThan(7)).toBe(0);
167
+ });
168
+
169
+ it('skips files with no timestamp field', () => {
170
+ mkdirSync(sessionsDir, { recursive: true });
171
+ writeFileSync(join(sessionsDir, 'no-ts.json'), JSON.stringify({}));
172
+ expect(clearSessionsOlderThan(7)).toBe(0);
173
+ });
174
+
175
+ it('skips unparseable JSON', () => {
176
+ mkdirSync(sessionsDir, { recursive: true });
177
+ writeFileSync(join(sessionsDir, 'broken.json'), '{');
178
+ expect(clearSessionsOlderThan(7)).toBe(0);
179
+ });
180
+ });
181
+
182
+ describe('saveSession pruning', () => {
183
+ it('keeps at most 100 sessions, pruning oldest', () => {
184
+ // Create 105 sessions with monotonically increasing timestamps
185
+ for (let i = 0; i < 105; i++) {
186
+ const day = String(i + 1).padStart(2, '0');
187
+ saveSession(makeSession({ id: `s-${i}`, timestamp: `2024-01-${day}T00:00:00.000Z` }));
188
+ }
189
+ expect(countSessions()).toBeLessThanOrEqual(100);
190
+ });
191
+ });
192
+
193
+ describe('reports dir', () => {
194
+ it('getReportsDir creates the directory if missing', () => {
195
+ rmSync(reportsDir, { recursive: true, force: true });
196
+ expect(getReportsDir()).toBe(reportsDir);
197
+ // The act of calling should have ensured the dir
198
+ expect(readFileSync.length).toBeGreaterThan(0); // sanity: fs is available
199
+ });
200
+ });
201
+
202
+ describe('generateSessionId', () => {
203
+ it('returns a UUID-shaped string', () => {
204
+ const id = generateSessionId();
205
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
206
+ });
207
+
208
+ it('generates unique IDs', () => {
209
+ const a = generateSessionId();
210
+ const b = generateSessionId();
211
+ expect(a).not.toBe(b);
212
+ });
213
+ });
@@ -0,0 +1,74 @@
1
+ export const EXIT_CODES = {
2
+ SUCCESS: 0,
3
+ RUNTIME_ERROR: 1,
4
+ CONFIGURATION_ERROR: 2,
5
+ CHECK_NOT_FOUND: 3,
6
+ REPORT_FAILED: 4,
7
+ } as const;
8
+
9
+ export interface ErrorSuggestion {
10
+ message: string;
11
+ action?: string;
12
+ exitCode: number;
13
+ }
14
+
15
+ export function getErrorSuggestion(err: unknown): ErrorSuggestion | null {
16
+ const message = err instanceof Error ? err.message : String(err);
17
+
18
+ // Check not found
19
+ if (message.includes('Check not found:') || message.includes('not found')) {
20
+ const slug = (/Check not found: (.+)/.exec(message))?.[1] ?? (/not found: (.+)/.exec(message))?.[1];
21
+ return {
22
+ message: `Check '${slug ?? 'unknown'}' not found.`,
23
+ action: 'Run opensip-tools fit --list to see available checks.',
24
+ exitCode: EXIT_CODES.CHECK_NOT_FOUND,
25
+ };
26
+ }
27
+
28
+ // Recipe not found
29
+ if (message.includes('Unknown recipe')) {
30
+ return {
31
+ message: message,
32
+ action: 'Run opensip-tools fit --recipes to see available recipes.',
33
+ exitCode: EXIT_CODES.CONFIGURATION_ERROR,
34
+ };
35
+ }
36
+
37
+ // Config file error
38
+ if (message.includes('opensip-tools.config.yml') || message.includes('YAML') || message.includes('config')) {
39
+ return {
40
+ message: 'Configuration error.',
41
+ action: 'Check opensip-tools.config.yml for syntax errors.',
42
+ exitCode: EXIT_CODES.CONFIGURATION_ERROR,
43
+ };
44
+ }
45
+
46
+ // Permission denied
47
+ if (message.includes('EACCES') || message.includes('permission denied')) {
48
+ return {
49
+ message: 'Permission denied reading files.',
50
+ action: 'Check file permissions in the target directory.',
51
+ exitCode: EXIT_CODES.RUNTIME_ERROR,
52
+ };
53
+ }
54
+
55
+ // No files found
56
+ if (message.includes('No checks registered') || message.includes('No checks to run')) {
57
+ return {
58
+ message: 'No checks available to run.',
59
+ action: 'Install at least one @opensip-tools/checks-* package, or declare plugins.checkPackages in opensip-tools.config.yml.',
60
+ exitCode: EXIT_CODES.RUNTIME_ERROR,
61
+ };
62
+ }
63
+
64
+ // Network error (report-to)
65
+ if (message.includes('fetch') || message.includes('ECONNREFUSED') || message.includes('network')) {
66
+ return {
67
+ message: 'Network error sending report.',
68
+ action: 'Check the --report-to URL and your network connection.',
69
+ exitCode: EXIT_CODES.REPORT_FAILED,
70
+ };
71
+ }
72
+
73
+ return null;
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @opensip-tools/contracts — shared contract types.
3
+ *
4
+ * Tool packages (fitness, simulation) and the CLI entry-point both
5
+ * depend on this package for:
6
+ * - CLI option / output / result types
7
+ * - Exit code constants and error suggestions
8
+ * - Session persistence (saveSession, loadSessions, dashboard generator)
9
+ *
10
+ * contracts depends only on @opensip-tools/core. Tools depend on
11
+ * contracts. The CLI entry-point depends on contracts and on every
12
+ * tool package — the dependency graph stays acyclic.
13
+ */
14
+
15
+ // CLI option / argument types
16
+ export type {
17
+ CliArgs,
18
+ FitOptions,
19
+ InitOptions,
20
+ ToolOptions,
21
+ } from './types.js';
22
+
23
+ // Output and result types
24
+ export type {
25
+ CliOutput,
26
+ CheckOutput,
27
+ FindingOutput,
28
+ TableRow,
29
+ SummaryOptions,
30
+ CommandResult,
31
+ ClearDoneResult,
32
+ FitDoneResult,
33
+ SimDoneResult,
34
+ ListChecksResult,
35
+ ListRecipesResult,
36
+ HistoryResult,
37
+ DashboardResult,
38
+ InitResult,
39
+ ExperimentalResult,
40
+ PluginResult,
41
+ HelpResult,
42
+ ErrorResult,
43
+ } from './types.js';
44
+
45
+ // Exit codes + error suggestion helper
46
+ export { EXIT_CODES, getErrorSuggestion } from './exit-codes.js';
47
+ export type { ErrorSuggestion } from './exit-codes.js';
48
+
49
+ // Session persistence
50
+ export {
51
+ TOOLS_HOME,
52
+ configurePersistencePaths,
53
+ saveSession,
54
+ loadSessions,
55
+ loadLatestSession,
56
+ countSessions,
57
+ clearAllSessions,
58
+ clearSessionsOlderThan,
59
+ getStoreDir,
60
+ getReportsDir,
61
+ generateSessionId,
62
+ sanitizeForFilename,
63
+ } from './persistence/store.js';
64
+ export type {
65
+ StoredSession,
66
+ CheckCatalogEntry,
67
+ RecipeCatalogEntry,
68
+ } from './persistence/store.js';
69
+
70
+ // Dashboard HTML generator
71
+ export { generateDashboardHtml } from './persistence/dashboard/index.js';