delimit-cli 1.0.0 → 2.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.
Files changed (95) hide show
  1. package/.github/workflows/api-governance.yml +24 -0
  2. package/README.md +57 -115
  3. package/adapters/codex-skill.js +87 -0
  4. package/adapters/cursor-extension.js +190 -0
  5. package/adapters/gemini-action.js +93 -0
  6. package/adapters/openai-function.js +112 -0
  7. package/adapters/xai-plugin.js +151 -0
  8. package/bin/delimit-cli.js +921 -0
  9. package/bin/delimit.js +237 -1
  10. package/delimit.yml +19 -0
  11. package/hooks/evidence-status.sh +12 -0
  12. package/hooks/git/commit-msg +4 -0
  13. package/hooks/git/pre-commit +4 -0
  14. package/hooks/git/pre-push +4 -0
  15. package/hooks/install-hooks.sh +583 -0
  16. package/hooks/message-auth-hook.js +9 -0
  17. package/hooks/message-governance-hook.js +9 -0
  18. package/hooks/models/claude-post.js +4 -0
  19. package/hooks/models/claude-pre.js +4 -0
  20. package/hooks/models/codex-post.js +4 -0
  21. package/hooks/models/codex-pre.js +4 -0
  22. package/hooks/models/cursor-post.js +4 -0
  23. package/hooks/models/cursor-pre.js +4 -0
  24. package/hooks/models/gemini-post.js +4 -0
  25. package/hooks/models/gemini-pre.js +4 -0
  26. package/hooks/models/openai-post.js +4 -0
  27. package/hooks/models/openai-pre.js +4 -0
  28. package/hooks/models/windsurf-post.js +4 -0
  29. package/hooks/models/windsurf-pre.js +4 -0
  30. package/hooks/models/xai-post.js +4 -0
  31. package/hooks/models/xai-pre.js +4 -0
  32. package/hooks/post-bash-hook.js +13 -0
  33. package/hooks/post-mcp-hook.js +13 -0
  34. package/hooks/post-response-hook.js +4 -0
  35. package/hooks/post-tool-hook.js +126 -0
  36. package/hooks/post-write-hook.js +13 -0
  37. package/hooks/pre-bash-hook.js +30 -0
  38. package/hooks/pre-mcp-hook.js +13 -0
  39. package/hooks/pre-read-hook.js +13 -0
  40. package/hooks/pre-search-hook.js +13 -0
  41. package/hooks/pre-submit-hook.js +4 -0
  42. package/hooks/pre-task-hook.js +13 -0
  43. package/hooks/pre-tool-hook.js +121 -0
  44. package/hooks/pre-web-hook.js +13 -0
  45. package/hooks/pre-write-hook.js +31 -0
  46. package/hooks/test-hooks.sh +12 -0
  47. package/hooks/update-delimit.sh +6 -0
  48. package/lib/agent.js +509 -0
  49. package/lib/api-engine.js +156 -0
  50. package/lib/auth-setup.js +891 -0
  51. package/lib/decision-engine.js +474 -0
  52. package/lib/hooks-installer.js +416 -0
  53. package/lib/platform-adapters.js +353 -0
  54. package/lib/proxy-handler.js +114 -0
  55. package/package.json +38 -30
  56. package/scripts/infect.js +128 -0
  57. package/test-decision-engine.js +181 -0
  58. package/test-hook.js +27 -0
  59. package/dist/commands/validate.d.ts +0 -2
  60. package/dist/commands/validate.d.ts.map +0 -1
  61. package/dist/commands/validate.js +0 -106
  62. package/dist/commands/validate.js.map +0 -1
  63. package/dist/index.d.ts +0 -3
  64. package/dist/index.d.ts.map +0 -1
  65. package/dist/index.js +0 -71
  66. package/dist/index.js.map +0 -1
  67. package/dist/types/index.d.ts +0 -39
  68. package/dist/types/index.d.ts.map +0 -1
  69. package/dist/types/index.js +0 -3
  70. package/dist/types/index.js.map +0 -1
  71. package/dist/utils/api.d.ts +0 -3
  72. package/dist/utils/api.d.ts.map +0 -1
  73. package/dist/utils/api.js +0 -64
  74. package/dist/utils/api.js.map +0 -1
  75. package/dist/utils/file.d.ts +0 -7
  76. package/dist/utils/file.d.ts.map +0 -1
  77. package/dist/utils/file.js +0 -69
  78. package/dist/utils/file.js.map +0 -1
  79. package/dist/utils/logger.d.ts +0 -14
  80. package/dist/utils/logger.d.ts.map +0 -1
  81. package/dist/utils/logger.js +0 -28
  82. package/dist/utils/logger.js.map +0 -1
  83. package/dist/utils/masker.d.ts +0 -14
  84. package/dist/utils/masker.d.ts.map +0 -1
  85. package/dist/utils/masker.js +0 -89
  86. package/dist/utils/masker.js.map +0 -1
  87. package/src/commands/validate.ts +0 -150
  88. package/src/index.ts +0 -80
  89. package/src/types/index.ts +0 -41
  90. package/src/utils/api.ts +0 -68
  91. package/src/utils/file.ts +0 -71
  92. package/src/utils/logger.ts +0 -27
  93. package/src/utils/masker.ts +0 -101
  94. package/test-sensitive.yaml +0 -109
  95. package/tsconfig.json +0 -23
package/lib/agent.js ADDED
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env node
2
+
3
+ const express = require('express');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const yaml = require('js-yaml');
7
+ const minimatch = require('minimatch');
8
+ const { execSync } = require('child_process');
9
+ const DecisionEngine = require('./decision-engine');
10
+
11
+ class DelimitAgent {
12
+ constructor() {
13
+ this.app = express();
14
+ this.app.use(express.json());
15
+
16
+ // State
17
+ this.sessionMode = 'auto';
18
+ this.globalPolicies = {}; // Cached global/user/org policies
19
+ this.projectPolicyCache = new Map(); // Cache for project-specific policies
20
+ this.auditLog = [];
21
+ this.port = process.env.DELIMIT_AGENT_PORT || 7823;
22
+ this.decisionEngine = new DecisionEngine();
23
+ this.startTime = Date.now();
24
+ this.cacheExpiry = 5 * 60 * 1000; // 5 minute cache for project policies
25
+
26
+ // Setup routes
27
+ this.setupRoutes();
28
+
29
+ // Load global policies only
30
+ this.loadGlobalPolicies();
31
+ }
32
+
33
+ setupRoutes() {
34
+ // Evaluate governance for an action
35
+ this.app.post('/evaluate', (req, res) => {
36
+ try {
37
+ const context = req.body;
38
+ const decision = this.evaluateGovernance(context);
39
+ this.logDecision(decision, context);
40
+ res.json(decision);
41
+ } catch (error) {
42
+ console.error('Error in /evaluate:', error);
43
+ res.status(500).json({ error: 'Internal server error', action: 'allow' });
44
+ }
45
+ });
46
+
47
+ // Set session mode
48
+ this.app.post('/mode', (req, res) => {
49
+ this.sessionMode = req.body.mode;
50
+ res.json({ success: true, mode: this.sessionMode });
51
+ });
52
+
53
+ // Get status
54
+ this.app.get('/status', (req, res) => {
55
+ try {
56
+ // Create dummy context for status check
57
+ const dummyContext = { pwd: process.cwd() };
58
+ const resolvedPolicies = this.resolvePoliciesForContext(dummyContext);
59
+ const mergedPolicy = this.mergePoliciesWithSources(resolvedPolicies);
60
+
61
+ const recentDecisions = this.auditLog.slice(-5).map(d => ({
62
+ timestamp: new Date(d.timestamp).toLocaleTimeString(),
63
+ mode: d.mode,
64
+ action: d.action,
65
+ rule: d.rule
66
+ }));
67
+
68
+ res.json({
69
+ sessionMode: this.sessionMode,
70
+ defaultMode: mergedPolicy.defaultMode || 'advisory',
71
+ effectiveMode: this.decisionEngine.lastDecision?.effectiveMode,
72
+ policiesLoaded: Object.keys(this.globalPolicies),
73
+ totalRules: mergedPolicy.rules?.length || 0,
74
+ auditLogSize: this.auditLog.length,
75
+ lastDecision: this.decisionEngine.lastDecision ? {
76
+ timestamp: this.decisionEngine.lastDecision.timestamp,
77
+ action: this.decisionEngine.lastDecision.action
78
+ } : null,
79
+ recentDecisions: recentDecisions,
80
+ uptime: process.uptime()
81
+ });
82
+ } catch (error) {
83
+ console.error('Error in /status:', error);
84
+ res.status(500).json({ error: 'Internal server error' });
85
+ }
86
+ });
87
+
88
+ // Get recent audit log
89
+ this.app.get('/audit', (req, res) => {
90
+ res.json(this.auditLog.slice(-100));
91
+ });
92
+
93
+ // Explain a decision
94
+ this.app.get('/explain/:id', (req, res) => {
95
+ const id = req.params.id;
96
+ let explanation;
97
+
98
+ if (id === 'last') {
99
+ explanation = this.decisionEngine.explainDecision();
100
+ } else {
101
+ explanation = this.decisionEngine.explainDecision(id);
102
+ }
103
+
104
+ if (explanation === 'No decision found') {
105
+ res.status(404).json({ error: 'Decision not found' });
106
+ } else {
107
+ res.json({ explanation });
108
+ }
109
+ });
110
+ }
111
+
112
+ loadGlobalPolicies() {
113
+ this.globalPolicies = {};
114
+
115
+ // Load organization policy from environment or config
116
+ const orgPolicyUrl = process.env.DELIMIT_ORG_POLICY_URL;
117
+ if (orgPolicyUrl) {
118
+ // TODO: Implement remote org policy fetching
119
+ console.log('[Agent] Org policy URL configured:', orgPolicyUrl);
120
+ }
121
+
122
+ // Load user policy from standard location
123
+ const userPolicyPath = path.join(process.env.HOME, '.config', 'delimit', 'delimit.yml');
124
+ if (fs.existsSync(userPolicyPath)) {
125
+ try {
126
+ this.globalPolicies.user = {
127
+ policy: yaml.load(fs.readFileSync(userPolicyPath, 'utf8')),
128
+ path: userPolicyPath,
129
+ loadedAt: Date.now()
130
+ };
131
+ console.log('[Agent] Loaded user policy from', userPolicyPath);
132
+ } catch (e) {
133
+ console.error('[Agent] Failed to load user policy:', e.message);
134
+ }
135
+ }
136
+
137
+ // Load system-wide default policy if exists
138
+ const systemPolicyPath = '/etc/delimit/delimit.yml';
139
+ if (fs.existsSync(systemPolicyPath)) {
140
+ try {
141
+ this.globalPolicies.system = {
142
+ policy: yaml.load(fs.readFileSync(systemPolicyPath, 'utf8')),
143
+ path: systemPolicyPath,
144
+ loadedAt: Date.now()
145
+ };
146
+ console.log('[Agent] Loaded system policy from', systemPolicyPath);
147
+ } catch (e) {
148
+ console.error('[Agent] Failed to load system policy:', e.message);
149
+ }
150
+ }
151
+ }
152
+
153
+ evaluateGovernance(context) {
154
+ // Resolve policies for this specific context
155
+ const resolvedPolicies = this.resolvePoliciesForContext(context);
156
+
157
+ // Check for malformed project policy BEFORE merging
158
+ if (resolvedPolicies.project?.error) {
159
+ // Project policy is malformed - this is a critical error
160
+ // Fail closed: block the action and report the error
161
+ console.error('[Agent] CRITICAL: Project policy is malformed, failing closed');
162
+ return {
163
+ timestamp: new Date().toISOString(),
164
+ mode: 'enforce',
165
+ rule: 'MALFORMED_POLICY',
166
+ action: 'block',
167
+ message: `🛑 GOVERNANCE ERROR: Project policy at ${resolvedPolicies.project.path} is malformed\n` +
168
+ `Error: ${resolvedPolicies.project.error}\n` +
169
+ `Action blocked until policy is fixed.\n` +
170
+ `Fix the policy file or remove it to proceed.`,
171
+ requiresOverride: true,
172
+ error: true,
173
+ policyError: resolvedPolicies.project.error
174
+ };
175
+ }
176
+
177
+ // Merge policies in correct precedence order
178
+ const mergedPolicy = this.mergePoliciesWithSources(resolvedPolicies);
179
+
180
+ // The merged policy now contains accurate source information
181
+ // No need to re-add it since mergePoliciesWithSources handles it correctly
182
+
183
+ // Use the DecisionEngine for evaluation
184
+ const decision = this.decisionEngine.makeDecision(context, mergedPolicy, this.sessionMode);
185
+
186
+ // Convert to legacy format for backward compatibility
187
+ return {
188
+ timestamp: decision.timestamp,
189
+ mode: decision.effectiveMode,
190
+ rule: decision.matchedRules.length > 0 ? decision.matchedRules[0].name : null,
191
+ action: decision.action,
192
+ message: decision.message,
193
+ requiresOverride: decision.action === 'block' &&
194
+ !mergedPolicy.overrides?.allowEnforceOverride,
195
+ policySource: decision.explanation?.source || mergedPolicy.defaultModeSource,
196
+ precedenceOrder: mergedPolicy._precedenceOrder
197
+ };
198
+ }
199
+
200
+ ruleMatches(rule, context) {
201
+ if (!rule.triggers) return false;
202
+
203
+ for (const trigger of rule.triggers) {
204
+ let matches = true;
205
+
206
+ // Check path patterns
207
+ if (trigger.path && context.files) {
208
+ const pathMatches = context.files.some(file =>
209
+ minimatch(file, trigger.path)
210
+ );
211
+ matches = matches && pathMatches;
212
+ }
213
+
214
+ // Check content patterns
215
+ if (trigger.content && context.diff) {
216
+ const contentMatches = trigger.content.some(pattern =>
217
+ context.diff.includes(pattern)
218
+ );
219
+ matches = matches && contentMatches;
220
+ }
221
+
222
+ // Check command patterns
223
+ if (trigger.command && context.command) {
224
+ matches = matches && minimatch(context.command, trigger.command);
225
+ }
226
+
227
+ // Check git branch
228
+ if (trigger.gitBranch && context.gitBranch) {
229
+ matches = matches && trigger.gitBranch.includes(context.gitBranch);
230
+ }
231
+
232
+ // Check commit message
233
+ if (trigger.commitMessage && context.commitMessage) {
234
+ const regex = new RegExp(trigger.commitMessage);
235
+ matches = matches && regex.test(context.commitMessage);
236
+ }
237
+
238
+ if (matches) return true;
239
+ }
240
+
241
+ return false;
242
+ }
243
+
244
+ getStrongerMode(mode1, mode2) {
245
+ const strength = { advisory: 1, guarded: 2, enforce: 3 };
246
+ return strength[mode2] > strength[mode1] ? mode2 : mode1;
247
+ }
248
+
249
+ makeDecision(mode, rule, context, policy) {
250
+ const decision = {
251
+ timestamp: new Date().toISOString(),
252
+ mode: mode,
253
+ rule: rule ? rule.name : null,
254
+ action: 'allow',
255
+ message: '',
256
+ requiresOverride: false
257
+ };
258
+
259
+ switch (mode) {
260
+ case 'advisory':
261
+ decision.action = 'allow';
262
+ decision.message = rule ?
263
+ `[Advisory] ${rule.name} policy triggered` :
264
+ '[Advisory] Proceeding with standard checks';
265
+ break;
266
+
267
+ case 'guarded':
268
+ decision.action = 'prompt';
269
+ decision.message = rule ?
270
+ `[Guarded] ${rule.name} policy requires confirmation` :
271
+ '[Guarded] Action requires confirmation';
272
+ decision.requiresOverride = true;
273
+ break;
274
+
275
+ case 'enforce':
276
+ decision.action = 'block';
277
+ decision.message = rule ?
278
+ `[Enforce] Blocked by ${rule.name} policy` :
279
+ '[Enforce] Action blocked by governance';
280
+ decision.requiresOverride = !policy.overrides?.allowEnforceOverride;
281
+ break;
282
+ }
283
+
284
+ return decision;
285
+ }
286
+
287
+ resolvePoliciesForContext(context) {
288
+ const policies = {
289
+ org: this.globalPolicies.org || null,
290
+ system: this.globalPolicies.system || null,
291
+ user: this.globalPolicies.user || null,
292
+ project: null
293
+ };
294
+
295
+ // Resolve project policy for this specific context
296
+ if (context.pwd) {
297
+ policies.project = this.getProjectPolicy(context.pwd);
298
+ }
299
+
300
+ return policies;
301
+ }
302
+
303
+ getProjectPolicy(projectPath) {
304
+ // Check cache first
305
+ const cacheKey = projectPath;
306
+ const cached = this.projectPolicyCache.get(cacheKey);
307
+
308
+ if (cached && (Date.now() - cached.loadedAt) < this.cacheExpiry) {
309
+ return cached;
310
+ }
311
+
312
+ // Load project policy from the actual project directory
313
+ const policyPath = path.join(projectPath, 'delimit.yml');
314
+ let projectPolicy = null;
315
+
316
+ if (fs.existsSync(policyPath)) {
317
+ try {
318
+ const stat = fs.statSync(policyPath);
319
+ const policyContent = fs.readFileSync(policyPath, 'utf8');
320
+ const parsedPolicy = yaml.load(policyContent);
321
+
322
+ // Validate the parsed policy
323
+ if (!parsedPolicy || typeof parsedPolicy !== 'object') {
324
+ // Malformed policy - DO NOT fall back silently
325
+ projectPolicy = {
326
+ policy: null,
327
+ path: policyPath,
328
+ loadedAt: Date.now(),
329
+ modifiedAt: stat.mtime.getTime(),
330
+ error: 'Invalid YAML or empty policy file',
331
+ source: null
332
+ };
333
+ console.error(`[Agent] Project policy at ${policyPath} is malformed or empty`);
334
+ } else {
335
+ projectPolicy = {
336
+ policy: parsedPolicy,
337
+ path: policyPath,
338
+ loadedAt: Date.now(),
339
+ modifiedAt: stat.mtime.getTime(),
340
+ source: 'project policy'
341
+ };
342
+ }
343
+
344
+ // Cache the result
345
+ this.projectPolicyCache.set(cacheKey, projectPolicy);
346
+
347
+ } catch (e) {
348
+ console.error(`[Agent] Failed to load project policy from ${policyPath}:`, e.message);
349
+ // Store the error state - DO NOT silently fall back
350
+ projectPolicy = {
351
+ policy: null,
352
+ path: policyPath,
353
+ loadedAt: Date.now(),
354
+ error: e.message,
355
+ source: null
356
+ };
357
+ // Cache the failure to avoid repeated file system hits
358
+ this.projectPolicyCache.set(cacheKey, projectPolicy);
359
+ }
360
+ } else {
361
+ // No project policy file - this is OK, not an error
362
+ projectPolicy = {
363
+ policy: null,
364
+ path: policyPath,
365
+ loadedAt: Date.now(),
366
+ source: null
367
+ };
368
+ // Cache the absence of project policy
369
+ this.projectPolicyCache.set(cacheKey, projectPolicy);
370
+ }
371
+
372
+ return projectPolicy;
373
+ }
374
+
375
+ mergePoliciesWithSources(resolvedPolicies) {
376
+ // Build merged policy with CORRECT precedence: project > user > system > org
377
+ // Rules from higher-precedence policies completely override rules with the same name from lower-precedence policies
378
+ const sources = [];
379
+ const rules = [];
380
+ const seenRuleNames = new Set();
381
+ let defaultMode = 'advisory';
382
+ let defaultModeSource = 'defaults';
383
+
384
+ // Process policies in order of precedence (HIGHEST FIRST)
385
+ const policiesInOrder = [
386
+ { type: 'project', data: resolvedPolicies.project },
387
+ { type: 'user', data: resolvedPolicies.user },
388
+ { type: 'system', data: resolvedPolicies.system },
389
+ { type: 'org', data: resolvedPolicies.org }
390
+ ];
391
+
392
+ // First pass: determine the effective defaultMode (highest precedence wins)
393
+ for (const { type, data } of policiesInOrder) {
394
+ if (data?.policy?.defaultMode) {
395
+ if (defaultModeSource === 'defaults') {
396
+ defaultMode = data.policy.defaultMode;
397
+ defaultModeSource = `${type} policy`;
398
+ break; // Stop at first policy that defines defaultMode
399
+ }
400
+ }
401
+ }
402
+
403
+ // Second pass: merge rules with proper precedence
404
+ for (const { type, data } of policiesInOrder) {
405
+ if (!data) continue;
406
+
407
+ // Handle malformed policies explicitly
408
+ if (data.error) {
409
+ console.error(`[Agent] ${type} policy is malformed: ${data.error}`);
410
+ // Do NOT silently fall back - log the error and continue
411
+ sources.push({ type, path: data.path, error: data.error });
412
+ continue;
413
+ }
414
+
415
+ if (!data.policy) {
416
+ // Policy file doesn't exist or is empty
417
+ continue;
418
+ }
419
+
420
+ // Validate policy structure
421
+ if (typeof data.policy !== 'object') {
422
+ console.error(`[Agent] ${type} policy is not a valid object`);
423
+ sources.push({ type, path: data.path, error: 'Invalid policy format' });
424
+ continue;
425
+ }
426
+
427
+ sources.push({ type, path: data.path });
428
+
429
+ // Process rules if they exist and are valid
430
+ if (Array.isArray(data.policy.rules)) {
431
+ for (const rule of data.policy.rules) {
432
+ // Rules must have names to be overridable
433
+ if (!rule.name) {
434
+ console.warn(`[Agent] Rule from ${type} policy missing 'name' property:`, rule);
435
+ continue;
436
+ }
437
+
438
+ // Only add rule if we haven't seen this name from a higher-precedence source
439
+ if (!seenRuleNames.has(rule.name)) {
440
+ rules.push({
441
+ ...rule,
442
+ source: `${type} policy`,
443
+ _sourcePath: data.path
444
+ });
445
+ seenRuleNames.add(rule.name);
446
+ } else {
447
+ // Log when a rule is overridden for debugging
448
+ console.debug(`[Agent] Rule '${rule.name}' from ${type} policy overridden by higher precedence`);
449
+ }
450
+ }
451
+ } else if (data.policy.rules !== undefined) {
452
+ console.warn(`[Agent] ${type} policy has invalid 'rules' field (not an array)`);
453
+ }
454
+ }
455
+
456
+ return {
457
+ defaultMode,
458
+ defaultModeSource,
459
+ rules,
460
+ _sources: sources,
461
+ _resolvedAt: Date.now(),
462
+ _context: resolvedPolicies,
463
+ _precedenceOrder: 'project > user > system > org'
464
+ };
465
+ }
466
+
467
+ logDecision(decision, context) {
468
+ const logEntry = {
469
+ ...decision,
470
+ context: {
471
+ command: context.command,
472
+ pwd: context.pwd,
473
+ user: process.env.USER,
474
+ gitBranch: context.gitBranch
475
+ }
476
+ };
477
+
478
+ this.auditLog.push(logEntry);
479
+
480
+ // Also write to file
481
+ const logDir = path.join(process.env.HOME, '.delimit', 'audit');
482
+ fs.mkdirSync(logDir, { recursive: true });
483
+ const logFile = path.join(logDir, `${new Date().toISOString().split('T')[0]}.jsonl`);
484
+ fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
485
+ }
486
+
487
+ start() {
488
+ this.app.listen(this.port, '127.0.0.1', () => {
489
+ console.log(`[Delimit Agent] Running on port ${this.port}`);
490
+ console.log(`[Delimit Agent] Session mode: ${this.sessionMode}`);
491
+ console.log(`[Delimit Agent] Global policies loaded: ${Object.keys(this.globalPolicies).join(', ')}`);
492
+ });
493
+
494
+ // Reload global policies periodically and clear project cache
495
+ setInterval(() => {
496
+ this.loadGlobalPolicies();
497
+ // Clear project policy cache to pick up changes
498
+ this.projectPolicyCache.clear();
499
+ }, 60000);
500
+ }
501
+ }
502
+
503
+ // Start the agent
504
+ if (require.main === module) {
505
+ const agent = new DelimitAgent();
506
+ agent.start();
507
+ }
508
+
509
+ module.exports = DelimitAgent;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * API Engine — Bridge from npm CLI to the Python gateway core.
3
+ *
4
+ * Invokes the delimit-gateway Python engine for:
5
+ * - lint (diff + policy)
6
+ * - diff (pure diff)
7
+ * - explain (human-readable templates)
8
+ * - semver (version classification)
9
+ *
10
+ * The gateway is the single implementation authority.
11
+ * This module is a pure translation layer.
12
+ */
13
+
14
+ const { execSync } = require('child_process');
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+
19
+ // Gateway root — the Python engine lives here
20
+ const GATEWAY_ROOT = process.env.DELIMIT_GATEWAY_ROOT || '/home/delimit/delimit-gateway';
21
+
22
+ // Python executable — prefer venv if available
23
+ const PYTHON = (() => {
24
+ const venvPy = '/home/delimit/.delimit_suite/venv/bin/python';
25
+ if (fs.existsSync(venvPy)) return venvPy;
26
+ return 'python3';
27
+ })();
28
+
29
+ /**
30
+ * Run a Python script against the gateway core.
31
+ * Writes to a temp file to avoid shell escaping issues.
32
+ * Returns parsed JSON or throws.
33
+ */
34
+ function runGateway(pythonCode, timeoutMs = 30000) {
35
+ const tmpFile = path.join(os.tmpdir(), `delimit_${process.pid}_${Date.now()}.py`);
36
+ try {
37
+ fs.writeFileSync(tmpFile, pythonCode);
38
+ const result = execSync(
39
+ `${PYTHON} "${tmpFile}"`,
40
+ {
41
+ cwd: GATEWAY_ROOT,
42
+ timeout: timeoutMs,
43
+ encoding: 'utf-8',
44
+ env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' },
45
+ }
46
+ );
47
+ return JSON.parse(result.trim());
48
+ } catch (err) {
49
+ if (err.stdout) {
50
+ try { return JSON.parse(err.stdout.trim()); } catch (_) {}
51
+ }
52
+ throw new Error(err.stderr || err.message || 'Gateway execution failed');
53
+ } finally {
54
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Escape a string for safe embedding in Python source.
60
+ */
61
+ function pyStr(s) {
62
+ if (s == null) return 'None';
63
+ return JSON.stringify(s); // JSON strings are valid Python strings
64
+ }
65
+
66
+ /**
67
+ * delimit lint — diff + policy evaluation (primary command)
68
+ */
69
+ function lint(oldSpec, newSpec, opts = {}) {
70
+ const lines = [
71
+ 'import json, yaml, sys',
72
+ 'sys.path.insert(0, ".")',
73
+ 'from core.policy_engine import evaluate_with_policy',
74
+ `with open(${pyStr(oldSpec)}) as f: old = yaml.safe_load(f)`,
75
+ `with open(${pyStr(newSpec)}) as f: new = yaml.safe_load(f)`,
76
+ `r = evaluate_with_policy(old, new`,
77
+ ];
78
+ const args = ['include_semver=True'];
79
+ if (opts.policy) args.push(`policy_file=${pyStr(opts.policy)}`);
80
+ if (opts.version) args.push(`current_version=${pyStr(opts.version)}`);
81
+ if (opts.name) args.push(`api_name=${pyStr(opts.name)}`);
82
+ // Close the function call
83
+ lines[lines.length - 1] = `r = evaluate_with_policy(old, new, ${args.join(', ')})`;
84
+ lines.push('print(json.dumps(r))');
85
+ return runGateway(lines.join('\n'));
86
+ }
87
+
88
+ /**
89
+ * delimit diff — pure diff, no policy
90
+ */
91
+ function diff(oldSpec, newSpec) {
92
+ return runGateway([
93
+ 'import json, yaml, sys',
94
+ 'sys.path.insert(0, ".")',
95
+ 'from core.diff_engine_v2 import OpenAPIDiffEngine',
96
+ `with open(${pyStr(oldSpec)}) as f: old = yaml.safe_load(f)`,
97
+ `with open(${pyStr(newSpec)}) as f: new = yaml.safe_load(f)`,
98
+ 'engine = OpenAPIDiffEngine()',
99
+ 'changes = engine.compare(old, new)',
100
+ 'breaking = [c for c in changes if c.is_breaking]',
101
+ 'r = {"total_changes": len(changes), "breaking_changes": len(breaking), "changes": [{"type": c.type.value, "path": c.path, "message": c.message, "is_breaking": c.is_breaking} for c in changes]}',
102
+ 'print(json.dumps(r))',
103
+ ].join('\n'));
104
+ }
105
+
106
+ /**
107
+ * delimit explain — human-readable explanation
108
+ */
109
+ function explain(oldSpec, newSpec, opts = {}) {
110
+ const template = opts.template || 'developer';
111
+ const args = [`template=${pyStr(template)}`];
112
+ if (opts.oldVersion) args.push(`old_version=${pyStr(opts.oldVersion)}`);
113
+ if (opts.newVersion) args.push(`new_version=${pyStr(opts.newVersion)}`);
114
+ if (opts.name) args.push(`api_name=${pyStr(opts.name)}`);
115
+
116
+ return runGateway([
117
+ 'import json, yaml, sys',
118
+ 'sys.path.insert(0, ".")',
119
+ 'from core.diff_engine_v2 import OpenAPIDiffEngine',
120
+ 'from core.explainer import explain, TEMPLATES',
121
+ `with open(${pyStr(oldSpec)}) as f: old = yaml.safe_load(f)`,
122
+ `with open(${pyStr(newSpec)}) as f: new = yaml.safe_load(f)`,
123
+ 'engine = OpenAPIDiffEngine()',
124
+ 'changes = engine.compare(old, new)',
125
+ `out = explain(changes, ${args.join(', ')})`,
126
+ `print(json.dumps({"template": ${pyStr(template)}, "available_templates": TEMPLATES, "output": out}))`,
127
+ ].join('\n'));
128
+ }
129
+
130
+ /**
131
+ * delimit semver — classify version bump
132
+ */
133
+ function semver(oldSpec, newSpec, currentVersion) {
134
+ const extraLines = currentVersion
135
+ ? [
136
+ `r["current_version"] = ${pyStr(currentVersion)}`,
137
+ `r["next_version"] = bump_version(${pyStr(currentVersion)}, classify(changes))`,
138
+ ]
139
+ : [];
140
+
141
+ return runGateway([
142
+ 'import json, yaml, sys',
143
+ 'sys.path.insert(0, ".")',
144
+ 'from core.diff_engine_v2 import OpenAPIDiffEngine',
145
+ 'from core.semver_classifier import classify_detailed, bump_version, classify',
146
+ `with open(${pyStr(oldSpec)}) as f: old = yaml.safe_load(f)`,
147
+ `with open(${pyStr(newSpec)}) as f: new = yaml.safe_load(f)`,
148
+ 'engine = OpenAPIDiffEngine()',
149
+ 'changes = engine.compare(old, new)',
150
+ 'r = classify_detailed(changes)',
151
+ ...extraLines,
152
+ 'print(json.dumps(r))',
153
+ ].join('\n'));
154
+ }
155
+
156
+ module.exports = { lint, diff, explain, semver, GATEWAY_ROOT };