@timidan/rite 0.1.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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/assets/favicon.ico +0 -0
- package/dist/assets/index-CUOHpkRl.js +18 -0
- package/dist/assets/index-kPEQ2Fln.css +1 -0
- package/dist/assets/rite-180.png +0 -0
- package/dist/assets/rite-logo-primary-1200.png +0 -0
- package/dist/assets/rite-og-1200x630.png +0 -0
- package/dist/evidence/proof.json +3473 -0
- package/dist/index.html +30 -0
- package/package.json +70 -0
- package/src/cli/rite.js +510 -0
- package/src/core/engine.js +263 -0
- package/src/core/loader.js +72 -0
- package/src/core/sarif.js +126 -0
- package/src/github/app.js +134 -0
- package/src/github/server.js +411 -0
- package/src/graph/walker.js +220 -0
- package/src/instrument/auto-adapter.js +237 -0
- package/src/instrument/effect-schema.js +168 -0
- package/src/mcp/server.js +185 -0
- package/src/watsonx/draft.js +252 -0
- package/templates/rite.yml +43 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/instrument/auto-adapter.js — Auto-adapter generator.
|
|
3
|
+
*
|
|
4
|
+
* Given a JS/TS module that exports named functions, wraps each target entry
|
|
5
|
+
* and sink with lightweight instrumentation — no code modification needed.
|
|
6
|
+
*
|
|
7
|
+
* How it works:
|
|
8
|
+
* 1. Dynamically imports the target module.
|
|
9
|
+
* 2. Wraps named entry functions to capture call arguments.
|
|
10
|
+
* 3. Wraps the named sink function to record every invocation.
|
|
11
|
+
* 4. Exposes runCase(caseSpec) using the same adapter contract as rite.adapter.mjs.
|
|
12
|
+
*
|
|
13
|
+
* Limitations:
|
|
14
|
+
* - Works for ES modules and CJS modules with named exports.
|
|
15
|
+
* - The sink must be exported OR accessible via a state object returned by a factory.
|
|
16
|
+
* - Does not handle async generator patterns or prototype-chain dispatch.
|
|
17
|
+
* - Does not instrument code behind dynamic require() or conditional imports.
|
|
18
|
+
* - For complex services (dependency injection, class hierarchies), a manual
|
|
19
|
+
* adapter is still recommended.
|
|
20
|
+
*
|
|
21
|
+
* Usage (programmatic):
|
|
22
|
+
* import { generateAdapter } from './auto-adapter.js';
|
|
23
|
+
* const adapter = await generateAdapter({
|
|
24
|
+
* modulePath: './src/service.js',
|
|
25
|
+
* factoryFn: 'createService', // optional: if module exports a factory
|
|
26
|
+
* factoryArgs: [{ orders: [...] }],
|
|
27
|
+
* entries: ['customerRefund', 'supportRefund'],
|
|
28
|
+
* sink: 'issueRefund', // name to detect in call stack / exports
|
|
29
|
+
* snapshotFn: 'snapshot', // optional: for before/after state
|
|
30
|
+
* });
|
|
31
|
+
* const report = await verify(config, adapter);
|
|
32
|
+
*
|
|
33
|
+
* Usage (CLI):
|
|
34
|
+
* rite instrument --module ./src/service.js \
|
|
35
|
+
* --factory createService \
|
|
36
|
+
* --entries customerRefund,supportRefund \
|
|
37
|
+
* --sink issueRefund \
|
|
38
|
+
* --snapshot snapshot \
|
|
39
|
+
* --out examples/generated/rite.adapter.mjs
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { pathToFileURL } from 'node:url';
|
|
43
|
+
import { writeFileSync } from 'node:fs';
|
|
44
|
+
|
|
45
|
+
// ------------------------------------------------------------------ //
|
|
46
|
+
// Effect schema registry //
|
|
47
|
+
// ------------------------------------------------------------------ //
|
|
48
|
+
// Registered sink schemas validate that effects match before comparison.
|
|
49
|
+
// See src/instrument/effect-schema.js for the full registry.
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {{
|
|
53
|
+
* modulePath: string,
|
|
54
|
+
* factoryFn?: string,
|
|
55
|
+
* factoryArgs?: unknown[],
|
|
56
|
+
* entries: string[],
|
|
57
|
+
* sink: string,
|
|
58
|
+
* snapshotFn?: string,
|
|
59
|
+
* seedBuilder?: (caseSpec: object) => object[],
|
|
60
|
+
* }} InstrumentOptions
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate a live adapter object (not a file) by wrapping a loaded module.
|
|
65
|
+
* @param {InstrumentOptions} opts
|
|
66
|
+
* @returns {Promise<{ runCase: (caseSpec: object) => Promise<object> }>}
|
|
67
|
+
*/
|
|
68
|
+
export async function generateAdapter(opts) {
|
|
69
|
+
const { modulePath, factoryFn, factoryArgs = [], entries, sink, snapshotFn, seedBuilder } = opts;
|
|
70
|
+
|
|
71
|
+
const mod = await import(pathToFileURL(modulePath).href);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
/**
|
|
75
|
+
* runCase — conforms to the rite adapter contract.
|
|
76
|
+
*/
|
|
77
|
+
async runCase(caseSpec) {
|
|
78
|
+
const { path: entryName, actor, input } = caseSpec;
|
|
79
|
+
|
|
80
|
+
if (!entries.includes(entryName)) {
|
|
81
|
+
throw new Error(`Entry "${entryName}" not in instrumented entries: ${entries.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build fixture args — use seedBuilder if provided, else empty
|
|
85
|
+
const seeds = seedBuilder ? seedBuilder(caseSpec) : [];
|
|
86
|
+
|
|
87
|
+
// Create a fresh service instance per case
|
|
88
|
+
let instance;
|
|
89
|
+
const capturedSinkCalls = [];
|
|
90
|
+
|
|
91
|
+
if (factoryFn) {
|
|
92
|
+
const factory = mod[factoryFn];
|
|
93
|
+
if (typeof factory !== 'function')
|
|
94
|
+
throw new Error(`Factory "${factoryFn}" not found in module`);
|
|
95
|
+
instance = factory(...(seeds.length ? [{ orders: seeds }, ...factoryArgs.slice(1)] : factoryArgs));
|
|
96
|
+
} else {
|
|
97
|
+
// Module-level exports: wrap directly
|
|
98
|
+
instance = mod;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Wrap the sink to capture calls
|
|
102
|
+
const originalSink = instance[sink];
|
|
103
|
+
if (typeof originalSink === 'function') {
|
|
104
|
+
instance[sink] = function (...args) {
|
|
105
|
+
const result = originalSink.apply(this, args);
|
|
106
|
+
capturedSinkCalls.push({ args, result });
|
|
107
|
+
return result;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// Note: if sink is a private closure (most common case), use snapshotFn to infer effects.
|
|
111
|
+
|
|
112
|
+
const entryFn = instance[entryName];
|
|
113
|
+
if (typeof entryFn !== 'function')
|
|
114
|
+
throw new Error(`Entry "${entryName}" not found on instance`);
|
|
115
|
+
|
|
116
|
+
// Snapshot before
|
|
117
|
+
const snapshotBefore = snapshotFn && typeof instance[snapshotFn] === 'function'
|
|
118
|
+
? instance[snapshotFn]()
|
|
119
|
+
: null;
|
|
120
|
+
const eventsBefore = snapshotBefore?.events?.length ?? 0;
|
|
121
|
+
|
|
122
|
+
// Call entry
|
|
123
|
+
const result = entryFn.call(instance, input, { actorId: actor });
|
|
124
|
+
|
|
125
|
+
// Snapshot after
|
|
126
|
+
const snapshotAfter = snapshotFn && typeof instance[snapshotFn] === 'function'
|
|
127
|
+
? instance[snapshotFn]()
|
|
128
|
+
: null;
|
|
129
|
+
const eventsAfter = snapshotAfter?.events?.length ?? 0;
|
|
130
|
+
|
|
131
|
+
// Derive effects from snapshot delta (works for private sinks)
|
|
132
|
+
const newEvents = snapshotAfter?.events
|
|
133
|
+
? snapshotAfter.events.slice(eventsBefore)
|
|
134
|
+
: capturedSinkCalls.map(c => c.args[0] ?? c.args);
|
|
135
|
+
|
|
136
|
+
const effects = newEvents.map(({ refundId: _rid, ...rest }) => rest);
|
|
137
|
+
|
|
138
|
+
const orderId = input?.orderId;
|
|
139
|
+
const stateBefore = snapshotBefore?.orders?.find(o => o.id === orderId) ?? null;
|
|
140
|
+
const stateAfter = snapshotAfter?.orders?.find(o => o.id === orderId) ?? null;
|
|
141
|
+
const stateChanged = JSON.stringify(stateBefore) !== JSON.stringify(stateAfter);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
decision: result?.ok === true ? 'allow' : 'deny',
|
|
145
|
+
effects,
|
|
146
|
+
stateBefore,
|
|
147
|
+
stateAfter,
|
|
148
|
+
stateChanged,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ------------------------------------------------------------------ //
|
|
155
|
+
// File generator — write a static adapter file //
|
|
156
|
+
// ------------------------------------------------------------------ //
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Generate and write a rite.adapter.mjs file for a given module.
|
|
160
|
+
* The generated file is a fully self-contained adapter that can be committed.
|
|
161
|
+
* @param {InstrumentOptions & { outPath: string, relativeModulePath: string }} opts
|
|
162
|
+
*/
|
|
163
|
+
export function writeAdapterFile(opts) {
|
|
164
|
+
const { entries, sink, snapshotFn, factoryFn, outPath, relativeModulePath } = opts;
|
|
165
|
+
|
|
166
|
+
const importLine = factoryFn
|
|
167
|
+
? `import { ${factoryFn} } from '${relativeModulePath}';`
|
|
168
|
+
: `import * as mod from '${relativeModulePath}';`;
|
|
169
|
+
|
|
170
|
+
const instanceExpr = factoryFn
|
|
171
|
+
? `${factoryFn}({ orders: seedsForCase(input.orderId) })`
|
|
172
|
+
: `mod`;
|
|
173
|
+
|
|
174
|
+
const snapshotBefore = snapshotFn
|
|
175
|
+
? `const snapBefore = instance.${snapshotFn}();`
|
|
176
|
+
: `const snapBefore = { events: [], orders: [] };`;
|
|
177
|
+
|
|
178
|
+
const snapshotAfter = snapshotFn
|
|
179
|
+
? `const snapAfter = instance.${snapshotFn}();`
|
|
180
|
+
: `const snapAfter = { events: [], orders: [] };`;
|
|
181
|
+
|
|
182
|
+
const entryDispatch = entries.map(e =>
|
|
183
|
+
` if (entryName === '${e}') result = instance.${e}(input, { actorId: actor });`
|
|
184
|
+
).join('\n');
|
|
185
|
+
|
|
186
|
+
const content = `/**
|
|
187
|
+
* rite.adapter.mjs — Auto-generated by \`rite instrument\`.
|
|
188
|
+
* Edit seed definitions and the SEEDS map to match your fixtures.
|
|
189
|
+
* Generated: ${new Date().toISOString()}
|
|
190
|
+
*/
|
|
191
|
+
|
|
192
|
+
${importLine}
|
|
193
|
+
|
|
194
|
+
// TODO: populate SEEDS with your fixture data.
|
|
195
|
+
const SEEDS = {
|
|
196
|
+
'example-id': {
|
|
197
|
+
id: 'example-id',
|
|
198
|
+
ownerId: 'actor-owner',
|
|
199
|
+
paymentStatus: 'paid',
|
|
200
|
+
amountCents: 1000,
|
|
201
|
+
currency: 'USD',
|
|
202
|
+
refunded: false,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
function seedsForCase(resourceId) {
|
|
207
|
+
const seed = SEEDS[resourceId];
|
|
208
|
+
if (!seed) throw new Error(\`No seed for: \${resourceId}\`);
|
|
209
|
+
return [{ ...seed }];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function runCase(caseSpec) {
|
|
213
|
+
const { path: entryName, actor, input } = caseSpec;
|
|
214
|
+
const instance = ${instanceExpr};
|
|
215
|
+
${snapshotBefore}
|
|
216
|
+
let result;
|
|
217
|
+
${entryDispatch}
|
|
218
|
+
else throw new Error(\`Unknown entry: \${entryName}\`);
|
|
219
|
+
${snapshotAfter}
|
|
220
|
+
const newEvents = snapAfter.events.slice(snapBefore.events.length);
|
|
221
|
+
const effects = newEvents.map(({ refundId: _rid, ...rest }) => rest);
|
|
222
|
+
const orderId = input?.orderId ?? input?.resourceId;
|
|
223
|
+
const stateBefore = snapBefore.orders?.find(o => o.id === orderId) ?? null;
|
|
224
|
+
const stateAfter = snapAfter.orders?.find(o => o.id === orderId) ?? null;
|
|
225
|
+
const stateChanged = JSON.stringify(stateBefore) !== JSON.stringify(stateAfter);
|
|
226
|
+
return {
|
|
227
|
+
decision: result?.ok === true ? 'allow' : 'deny',
|
|
228
|
+
effects,
|
|
229
|
+
stateBefore,
|
|
230
|
+
stateAfter,
|
|
231
|
+
stateChanged,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
`;
|
|
235
|
+
|
|
236
|
+
writeFileSync(outPath, content, 'utf-8');
|
|
237
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/instrument/effect-schema.js — Typed effect registry.
|
|
3
|
+
*
|
|
4
|
+
* Defines named sink event schemas. The engine validates adapter-reported
|
|
5
|
+
* effects against the registered schema before structural comparison.
|
|
6
|
+
* This prevents two adapters describing the same event differently from
|
|
7
|
+
* both passing silently.
|
|
8
|
+
*
|
|
9
|
+
* Schema format:
|
|
10
|
+
* { field: 'type' | ['type', required?] }
|
|
11
|
+
* Types: 'string' | 'number' | 'boolean' | 'object' | 'any'
|
|
12
|
+
*
|
|
13
|
+
* Register your schema in rite.config.json under "effectSchema":
|
|
14
|
+
* "effectSchema": "refund-event"
|
|
15
|
+
*
|
|
16
|
+
* Or inline in config:
|
|
17
|
+
* "effectSchema": {
|
|
18
|
+
* "orderId": "string",
|
|
19
|
+
* "actorId": "string",
|
|
20
|
+
* "amountCents": "number",
|
|
21
|
+
* "currency": "string"
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ------------------------------------------------------------------ //
|
|
26
|
+
// Built-in schemas //
|
|
27
|
+
// ------------------------------------------------------------------ //
|
|
28
|
+
|
|
29
|
+
/** @type {Record<string, Record<string, string | [string, boolean]>>} */
|
|
30
|
+
const BUILT_IN_SCHEMAS = {
|
|
31
|
+
'refund-event': {
|
|
32
|
+
orderId: 'string',
|
|
33
|
+
actorId: 'string',
|
|
34
|
+
amountCents: 'number',
|
|
35
|
+
currency: 'string',
|
|
36
|
+
},
|
|
37
|
+
'transfer-event': {
|
|
38
|
+
fromAccountId: 'string',
|
|
39
|
+
toAccountId: 'string',
|
|
40
|
+
amountCents: 'number',
|
|
41
|
+
currency: 'string',
|
|
42
|
+
},
|
|
43
|
+
'permission-grant': {
|
|
44
|
+
subjectId: 'string',
|
|
45
|
+
resourceId: 'string',
|
|
46
|
+
permission: 'string',
|
|
47
|
+
grantedBy: 'string',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------ //
|
|
52
|
+
// Validator //
|
|
53
|
+
// ------------------------------------------------------------------ //
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a schema from either a registered name or an inline object.
|
|
57
|
+
* @param {string | Record<string,string> | undefined} schemaRef
|
|
58
|
+
* @returns {Record<string,string> | null}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveSchema(schemaRef) {
|
|
61
|
+
if (!schemaRef) return null;
|
|
62
|
+
if (typeof schemaRef === 'string') return BUILT_IN_SCHEMAS[schemaRef] ?? null;
|
|
63
|
+
if (typeof schemaRef === 'object') return schemaRef;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate one effect object against a schema.
|
|
69
|
+
* Returns an array of violation strings (empty = valid).
|
|
70
|
+
* @param {object} effect
|
|
71
|
+
* @param {Record<string,string>} schema
|
|
72
|
+
* @param {string} context — for error messages
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
export function validateEffect(effect, schema, context = 'effect') {
|
|
76
|
+
const violations = [];
|
|
77
|
+
for (const [field, typeSpec] of Object.entries(schema)) {
|
|
78
|
+
const [expectedType, required = true] = Array.isArray(typeSpec) ? typeSpec : [typeSpec, true];
|
|
79
|
+
const value = /** @type {Record<string,unknown>} */ (effect)[field];
|
|
80
|
+
if (value === undefined || value === null) {
|
|
81
|
+
if (required) violations.push(`${context}.${field}: required field missing`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (expectedType === 'any') continue;
|
|
85
|
+
const actualType = typeof value;
|
|
86
|
+
if (actualType !== expectedType)
|
|
87
|
+
violations.push(`${context}.${field}: expected ${expectedType}; got ${actualType} (${JSON.stringify(value)})`);
|
|
88
|
+
}
|
|
89
|
+
// Reject unknown fields — adapters must not smuggle extra data
|
|
90
|
+
for (const key of Object.keys(effect)) {
|
|
91
|
+
if (!(key in schema))
|
|
92
|
+
violations.push(`${context}.${key}: unexpected field not in schema`);
|
|
93
|
+
}
|
|
94
|
+
return violations;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate all effects in a case result against the config schema.
|
|
99
|
+
* Returns all violations across all effects.
|
|
100
|
+
* @param {object[]} effects
|
|
101
|
+
* @param {string | Record<string,string> | undefined} schemaRef
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
export function validateEffects(effects, schemaRef) {
|
|
105
|
+
const schema = resolveSchema(schemaRef);
|
|
106
|
+
if (!schema || !Array.isArray(effects)) return [];
|
|
107
|
+
const violations = [];
|
|
108
|
+
for (let i = 0; i < effects.length; i++) {
|
|
109
|
+
violations.push(...validateEffect(effects[i], schema, `effects[${i}]`));
|
|
110
|
+
}
|
|
111
|
+
return violations;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @returns {string[]} Names of all built-in schemas */
|
|
115
|
+
export function listSchemas() {
|
|
116
|
+
return Object.keys(BUILT_IN_SCHEMAS);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------------ //
|
|
120
|
+
// Unified effect comparison //
|
|
121
|
+
// ------------------------------------------------------------------ //
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compare observed effects against expected effects and an optional schema.
|
|
125
|
+
* This is the single authoritative comparison used by both the engine and
|
|
126
|
+
* the browser runner — neither re-implements this logic.
|
|
127
|
+
*
|
|
128
|
+
* Checks (in order):
|
|
129
|
+
* 1. Count mismatch — a denial that produces effects always FAIL
|
|
130
|
+
* 2. Per-field structural match against expected values
|
|
131
|
+
* 3. Schema validation (if schemaRef provided)
|
|
132
|
+
*
|
|
133
|
+
* @param {object[]} observed
|
|
134
|
+
* @param {object[]} expected
|
|
135
|
+
* @param {string | Record<string,string> | undefined} [schemaRef]
|
|
136
|
+
* @returns {string[]} failure strings (empty = pass)
|
|
137
|
+
*/
|
|
138
|
+
export function compareEffects(observed, expected, schemaRef) {
|
|
139
|
+
const failures = [];
|
|
140
|
+
const obs = Array.isArray(observed) ? observed : [];
|
|
141
|
+
const exp = Array.isArray(expected) ? expected : [];
|
|
142
|
+
|
|
143
|
+
// 1. Count
|
|
144
|
+
if (obs.length !== exp.length) {
|
|
145
|
+
failures.push(
|
|
146
|
+
`effects count: expected ${exp.length}; observed ${obs.length}` +
|
|
147
|
+
(obs.length > 0 ? ` (first: ${JSON.stringify(obs[0])})` : '')
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Per-field structural match
|
|
152
|
+
for (let i = 0; i < exp.length; i++) {
|
|
153
|
+
const eEff = exp[i];
|
|
154
|
+
const oEff = obs[i];
|
|
155
|
+
if (!oEff) break; // already caught by count check
|
|
156
|
+
for (const key of Object.keys(eEff)) {
|
|
157
|
+
if (eEff[key] !== oEff[key])
|
|
158
|
+
failures.push(`effects[${i}].${key}: expected ${JSON.stringify(eEff[key])}; observed ${JSON.stringify(oEff[key])}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 3. Schema validation
|
|
163
|
+
if (schemaRef && obs.length > 0) {
|
|
164
|
+
failures.push(...validateEffects(obs, schemaRef));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return failures;
|
|
168
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/server.js — Local MCP stdio server for Rite.
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* rite_analyze — Read configured rule and path scope from a config file
|
|
6
|
+
* rite_verify — Run verification using the same core engine as the CLI
|
|
7
|
+
* rite_report — Read and render a saved report file
|
|
8
|
+
*
|
|
9
|
+
* Stdout carries MCP protocol messages only. Logs go to stderr.
|
|
10
|
+
* Workspace and file path arguments are validated; no generic shell execution.
|
|
11
|
+
*
|
|
12
|
+
* Start: node src/cli/rite.js mcp (or rite mcp)
|
|
13
|
+
* Test: npx @modelcontextprotocol/inspector node src/cli/rite.js mcp
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { resolve, dirname } from 'node:path';
|
|
18
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
19
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { validateConfig, verify, ENGINE_VERSION } from '../core/engine.js';
|
|
22
|
+
import { loadAdapterFromConfig } from '../core/loader.js';
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------ //
|
|
25
|
+
// Helpers shared with CLI //
|
|
26
|
+
// ------------------------------------------------------------------ //
|
|
27
|
+
|
|
28
|
+
function safeJson(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
return { ok: true, data: JSON.parse(readFileSync(filePath, 'utf-8')) };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return { ok: false, error: e.message };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderReportText(report) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
lines.push(`Rite v${ENGINE_VERSION} — ${report.ruleId}`);
|
|
39
|
+
lines.push(`Rule: ${report.rule}`);
|
|
40
|
+
lines.push(`Status: ${report.status} Generated: ${report.generatedAt}`);
|
|
41
|
+
lines.push('');
|
|
42
|
+
for (const r of report.results) {
|
|
43
|
+
const icon = r.status === 'PASS' ? '✓' : r.status === 'FAIL' ? '✗' : '!';
|
|
44
|
+
lines.push(`${icon} ${r.status.padEnd(5)} [${r.path}] ${r.id}`);
|
|
45
|
+
if (r.failures?.length) {
|
|
46
|
+
for (const f of r.failures) lines.push(` ${f}`);
|
|
47
|
+
}
|
|
48
|
+
if (r.error) lines.push(` ERROR: ${r.error.split('\n')[0]}`);
|
|
49
|
+
}
|
|
50
|
+
const total = report.results.length;
|
|
51
|
+
const passed = report.results.filter(r => r.status === 'PASS').length;
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(`${report.status}: ${passed}/${total} passed`);
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ------------------------------------------------------------------ //
|
|
58
|
+
// Server //
|
|
59
|
+
// ------------------------------------------------------------------ //
|
|
60
|
+
|
|
61
|
+
export async function startMcpServer() {
|
|
62
|
+
const server = new McpServer({
|
|
63
|
+
name: 'rite',
|
|
64
|
+
version: ENGINE_VERSION,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---- rite_analyze ------------------------------------------------ //
|
|
68
|
+
server.tool(
|
|
69
|
+
'rite_analyze',
|
|
70
|
+
'Read a Rite config file and return its rule, configured entry paths, and case summary.',
|
|
71
|
+
{
|
|
72
|
+
config: z.string().describe('Absolute or relative path to rite.config.json'),
|
|
73
|
+
},
|
|
74
|
+
async ({ config: configArg }) => {
|
|
75
|
+
const configPath = resolve(configArg);
|
|
76
|
+
if (!existsSync(configPath)) {
|
|
77
|
+
return { content: [{ type: 'text', text: `Config not found: ${configPath}` }], isError: true };
|
|
78
|
+
}
|
|
79
|
+
const parsed = safeJson(configPath);
|
|
80
|
+
if (!parsed.ok) {
|
|
81
|
+
return { content: [{ type: 'text', text: `Failed to parse config: ${parsed.error}` }], isError: true };
|
|
82
|
+
}
|
|
83
|
+
const errs = validateConfig(parsed.data);
|
|
84
|
+
if (errs.length) {
|
|
85
|
+
return { content: [{ type: 'text', text: `Config invalid:\n${errs.join('\n')}` }], isError: true };
|
|
86
|
+
}
|
|
87
|
+
const cfg = parsed.data;
|
|
88
|
+
const summary = [
|
|
89
|
+
`ruleId: ${cfg.ruleId}`,
|
|
90
|
+
`rule: ${cfg.rule}`,
|
|
91
|
+
'',
|
|
92
|
+
'Configured paths:',
|
|
93
|
+
...cfg.paths.map(p => ` ${p.entry} → ${p.sink} (${p.source})`),
|
|
94
|
+
'',
|
|
95
|
+
`Cases (${cfg.cases.length}):`,
|
|
96
|
+
...cfg.cases.map(c => ` ${c.id} [${c.path}] actor=${c.actor} expected=${c.expected.decision}`),
|
|
97
|
+
].join('\n');
|
|
98
|
+
return { content: [{ type: 'text', text: summary }] };
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// ---- rite_verify ------------------------------------------------- //
|
|
103
|
+
server.tool(
|
|
104
|
+
'rite_verify',
|
|
105
|
+
'Run Rite verification using the same core engine as the CLI. Returns the full report as JSON.',
|
|
106
|
+
{
|
|
107
|
+
config: z.string().describe('Absolute or relative path to rite.config.json'),
|
|
108
|
+
out: z.string().optional().describe('Optional path to write the JSON report'),
|
|
109
|
+
},
|
|
110
|
+
async ({ config: configArg, out: outArg }) => {
|
|
111
|
+
const configPath = resolve(configArg);
|
|
112
|
+
if (!existsSync(configPath)) {
|
|
113
|
+
return { content: [{ type: 'text', text: `Config not found: ${configPath}` }], isError: true };
|
|
114
|
+
}
|
|
115
|
+
const parsed = safeJson(configPath);
|
|
116
|
+
if (!parsed.ok) {
|
|
117
|
+
return { content: [{ type: 'text', text: `Failed to parse config: ${parsed.error}` }], isError: true };
|
|
118
|
+
}
|
|
119
|
+
const errs = validateConfig(parsed.data);
|
|
120
|
+
if (errs.length) {
|
|
121
|
+
return { content: [{ type: 'text', text: `Config invalid:\n${errs.join('\n')}` }], isError: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const cfg = parsed.data;
|
|
125
|
+
const loaded = await loadAdapterFromConfig(cfg, configPath);
|
|
126
|
+
if (!loaded.ok) {
|
|
127
|
+
return { content: [{ type: 'text', text: loaded.error }], isError: true };
|
|
128
|
+
}
|
|
129
|
+
const { adapter } = loaded;
|
|
130
|
+
|
|
131
|
+
let report;
|
|
132
|
+
try {
|
|
133
|
+
report = await verify(cfg, adapter, { configPath });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { content: [{ type: 'text', text: `Engine error: ${e.message}` }], isError: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (outArg) {
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(resolve(outArg), JSON.stringify(report, null, 2), 'utf-8');
|
|
141
|
+
} catch (e) {
|
|
142
|
+
process.stderr.write(`rite-mcp: could not write report: ${e.message}\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const text = renderReportText(report);
|
|
147
|
+
const json = JSON.stringify(report, null, 2);
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{ type: 'text', text },
|
|
151
|
+
{ type: 'text', text: '\n--- JSON ---\n' + json },
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// ---- rite_report ------------------------------------------------- //
|
|
158
|
+
server.tool(
|
|
159
|
+
'rite_report',
|
|
160
|
+
'Read and render a saved Rite report JSON file.',
|
|
161
|
+
{
|
|
162
|
+
file: z.string().describe('Absolute or relative path to a Rite report JSON file'),
|
|
163
|
+
},
|
|
164
|
+
async ({ file }) => {
|
|
165
|
+
const filePath = resolve(file);
|
|
166
|
+
if (!existsSync(filePath)) {
|
|
167
|
+
return { content: [{ type: 'text', text: `Report not found: ${filePath}` }], isError: true };
|
|
168
|
+
}
|
|
169
|
+
const parsed = safeJson(filePath);
|
|
170
|
+
if (!parsed.ok) {
|
|
171
|
+
return { content: [{ type: 'text', text: `Failed to parse report: ${parsed.error}` }], isError: true };
|
|
172
|
+
}
|
|
173
|
+
const report = parsed.data;
|
|
174
|
+
if (!report.results || !report.status) {
|
|
175
|
+
return { content: [{ type: 'text', text: 'File does not look like a Rite report.' }], isError: true };
|
|
176
|
+
}
|
|
177
|
+
return { content: [{ type: 'text', text: renderReportText(report) }] };
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Start transport
|
|
182
|
+
const transport = new StdioServerTransport();
|
|
183
|
+
await server.connect(transport);
|
|
184
|
+
process.stderr.write(`rite-mcp: server started (Rite v${ENGINE_VERSION})\n`);
|
|
185
|
+
}
|