clearctx 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +1006 -0
- package/STRATEGY.md +485 -0
- package/bin/cli.js +1756 -0
- package/bin/continuity-hook.js +118 -0
- package/bin/mcp.js +27 -0
- package/bin/setup.js +929 -0
- package/package.json +56 -0
- package/src/artifact-store.js +710 -0
- package/src/atomic-io.js +99 -0
- package/src/briefing-generator.js +451 -0
- package/src/continuity-hooks.js +253 -0
- package/src/contract-store.js +525 -0
- package/src/decision-journal.js +229 -0
- package/src/delegate.js +348 -0
- package/src/dependency-resolver.js +453 -0
- package/src/diff-engine.js +473 -0
- package/src/file-lock.js +161 -0
- package/src/index.js +61 -0
- package/src/lineage-graph.js +402 -0
- package/src/manager.js +510 -0
- package/src/mcp-server.js +3501 -0
- package/src/pattern-registry.js +221 -0
- package/src/pipeline-engine.js +618 -0
- package/src/prompts.js +1217 -0
- package/src/safety-net.js +170 -0
- package/src/session-snapshot.js +508 -0
- package/src/snapshot-engine.js +490 -0
- package/src/stale-detector.js +169 -0
- package/src/store.js +131 -0
- package/src/stream-session.js +463 -0
- package/src/team-hub.js +615 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// contract-store.js
|
|
2
|
+
// Layer 2: Contract lifecycle management with timeout and retry support
|
|
3
|
+
//
|
|
4
|
+
// A Contract is a formal agreement between sessions specifying:
|
|
5
|
+
// - What work needs to be done (title, description, inputs)
|
|
6
|
+
// - Who does the work (assignee)
|
|
7
|
+
// - What outputs are expected (expectedOutputs)
|
|
8
|
+
// - What must be satisfied first (dependencies)
|
|
9
|
+
// - How we know it's done (acceptanceCriteria)
|
|
10
|
+
// - Retry and timeout policies
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const { atomicWriteJson, readJsonSafe } = require('./atomic-io');
|
|
16
|
+
const { acquireLock, releaseLock } = require('./file-lock');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ContractStore manages the lifecycle of contracts in a team
|
|
20
|
+
*
|
|
21
|
+
* A contract represents a unit of work with formal inputs, outputs,
|
|
22
|
+
* dependencies, and acceptance criteria. Contracts follow a strict
|
|
23
|
+
* lifecycle: pending → ready → in_progress → completed/failed
|
|
24
|
+
*
|
|
25
|
+
* Features:
|
|
26
|
+
* - Dependency tracking with cycle detection
|
|
27
|
+
* - Automatic status transitions when dependencies are satisfied
|
|
28
|
+
* - Retry support for failed/completed contracts
|
|
29
|
+
* - Timeout configuration
|
|
30
|
+
* - Priority levels
|
|
31
|
+
*
|
|
32
|
+
* @class
|
|
33
|
+
*/
|
|
34
|
+
class ContractStore {
|
|
35
|
+
/**
|
|
36
|
+
* Creates a new ContractStore for a team
|
|
37
|
+
*
|
|
38
|
+
* @param {string} [teamName='default'] - The team name (used for directory isolation)
|
|
39
|
+
*/
|
|
40
|
+
constructor(teamName = 'default') {
|
|
41
|
+
// Set up the base directory where all multi-session data lives
|
|
42
|
+
const baseDir = path.join(os.homedir(), '.clearctx');
|
|
43
|
+
|
|
44
|
+
// Each team gets its own directory to keep contracts separate
|
|
45
|
+
this.teamDir = path.join(baseDir, 'team', teamName);
|
|
46
|
+
|
|
47
|
+
// The contracts directory holds the index.json file
|
|
48
|
+
this.contractsDir = path.join(this.teamDir, 'contracts');
|
|
49
|
+
|
|
50
|
+
// Path to the main index file that stores all contracts
|
|
51
|
+
this.indexPath = path.join(this.contractsDir, 'index.json');
|
|
52
|
+
|
|
53
|
+
// Directory for lock files
|
|
54
|
+
this.locksDir = path.join(baseDir, 'locks');
|
|
55
|
+
|
|
56
|
+
// Create the directories if they don't exist yet
|
|
57
|
+
if (!fs.existsSync(this.contractsDir)) {
|
|
58
|
+
fs.mkdirSync(this.contractsDir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
if (!fs.existsSync(this.locksDir)) {
|
|
61
|
+
fs.mkdirSync(this.locksDir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a new contract
|
|
67
|
+
*
|
|
68
|
+
* This method:
|
|
69
|
+
* 1. Validates the contractId is unique
|
|
70
|
+
* 2. Checks for circular dependencies (cycles)
|
|
71
|
+
* 3. Determines initial status (pending if has dependencies, ready if not)
|
|
72
|
+
* 4. Saves the contract to the index
|
|
73
|
+
*
|
|
74
|
+
* @param {string} contractId - Unique identifier for this contract
|
|
75
|
+
* @param {Object} options - Contract configuration
|
|
76
|
+
* @param {string} options.title - Human-readable title
|
|
77
|
+
* @param {string} [options.description=''] - Longer description of the work
|
|
78
|
+
* @param {string} options.assignee - Session name who will do the work
|
|
79
|
+
* @param {string} options.assigner - Session name who created the contract
|
|
80
|
+
* @param {Object} [options.inputs={}] - Input artifacts and context
|
|
81
|
+
* @param {Array} [options.inputs.artifacts=[]] - Artifact IDs to read as inputs
|
|
82
|
+
* @param {string} [options.inputs.context=''] - Free-text instructions
|
|
83
|
+
* @param {Object} [options.inputs.parameters={}] - Key-value parameters
|
|
84
|
+
* @param {Array} [options.expectedOutputs=[]] - What the assignee should produce
|
|
85
|
+
* @param {Array} [options.dependencies=[]] - What must be satisfied before this can start
|
|
86
|
+
* @param {Array} [options.acceptanceCriteria=[]] - How we know the work is done
|
|
87
|
+
* @param {boolean} [options.autoComplete=true] - Auto-complete when criteria met
|
|
88
|
+
* @param {number} [options.timeoutMs=null] - Optional timeout in milliseconds
|
|
89
|
+
* @param {number} [options.maxRetries=3] - Maximum number of reopen attempts
|
|
90
|
+
* @param {string} [options.priority='normal'] - Priority level (low|normal|high|urgent)
|
|
91
|
+
* @returns {Object} The created contract object
|
|
92
|
+
* @throws {Error} If contractId already exists or circular dependency detected
|
|
93
|
+
*/
|
|
94
|
+
create(contractId, {
|
|
95
|
+
title,
|
|
96
|
+
description = '',
|
|
97
|
+
assignee,
|
|
98
|
+
assigner,
|
|
99
|
+
inputs = {},
|
|
100
|
+
expectedOutputs = [],
|
|
101
|
+
dependencies = [],
|
|
102
|
+
acceptanceCriteria = [],
|
|
103
|
+
autoComplete = true,
|
|
104
|
+
timeoutMs = null,
|
|
105
|
+
maxRetries = 3,
|
|
106
|
+
priority = 'normal'
|
|
107
|
+
}) {
|
|
108
|
+
// Acquire exclusive lock on the contracts index
|
|
109
|
+
// This prevents race conditions when multiple sessions create contracts
|
|
110
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Read the current index (or get an empty object if file doesn't exist)
|
|
114
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
115
|
+
|
|
116
|
+
// Check if this contractId is already taken
|
|
117
|
+
if (index[contractId]) {
|
|
118
|
+
throw new Error(`Contract with ID "${contractId}" already exists`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Detect circular dependencies before creating the contract
|
|
122
|
+
// A circular dependency would create a deadlock where contracts wait on each other
|
|
123
|
+
if (this._detectCycle(contractId, dependencies, index)) {
|
|
124
|
+
throw new Error(`Circular dependency detected for contract "${contractId}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Normalize the inputs object with defaults
|
|
128
|
+
const normalizedInputs = {
|
|
129
|
+
artifacts: inputs.artifacts || [],
|
|
130
|
+
context: inputs.context || '',
|
|
131
|
+
parameters: inputs.parameters || {}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Build the complete contract object
|
|
135
|
+
const now = new Date().toISOString();
|
|
136
|
+
const contract = {
|
|
137
|
+
contractId,
|
|
138
|
+
title,
|
|
139
|
+
description,
|
|
140
|
+
assignee,
|
|
141
|
+
assigner,
|
|
142
|
+
// Status starts as "pending" if there are dependencies, "ready" if not
|
|
143
|
+
status: dependencies.length > 0 ? 'pending' : 'ready',
|
|
144
|
+
priority,
|
|
145
|
+
createdAt: now,
|
|
146
|
+
updatedAt: now,
|
|
147
|
+
startedAt: null,
|
|
148
|
+
completedAt: null,
|
|
149
|
+
inputs: normalizedInputs,
|
|
150
|
+
expectedOutputs,
|
|
151
|
+
dependencies,
|
|
152
|
+
acceptanceCriteria,
|
|
153
|
+
autoComplete,
|
|
154
|
+
timeoutMs,
|
|
155
|
+
maxRetries,
|
|
156
|
+
retryCount: 0,
|
|
157
|
+
result: null
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Add the contract to the index
|
|
161
|
+
index[contractId] = contract;
|
|
162
|
+
|
|
163
|
+
// Save atomically (prevents corruption from partial writes)
|
|
164
|
+
atomicWriteJson(this.indexPath, index);
|
|
165
|
+
|
|
166
|
+
// Return a copy of the contract
|
|
167
|
+
return { ...contract };
|
|
168
|
+
} finally {
|
|
169
|
+
// Always release the lock, even if an error occurred
|
|
170
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Starts a contract (transitions from ready → in_progress)
|
|
176
|
+
*
|
|
177
|
+
* This is called by the assignee when they begin work.
|
|
178
|
+
* The contract must be in "ready" status.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} contractId - The contract to start
|
|
181
|
+
* @returns {Object} The updated contract (including inputs for the assignee)
|
|
182
|
+
* @throws {Error} If contract doesn't exist or is not in "ready" status
|
|
183
|
+
*/
|
|
184
|
+
start(contractId) {
|
|
185
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
189
|
+
const contract = index[contractId];
|
|
190
|
+
|
|
191
|
+
// Verify the contract exists
|
|
192
|
+
if (!contract) {
|
|
193
|
+
throw new Error(`Contract "${contractId}" not found`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Verify the contract is ready to start
|
|
197
|
+
if (contract.status !== 'ready') {
|
|
198
|
+
throw new Error(`Contract "${contractId}" is not ready (current status: ${contract.status})`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Transition to in_progress and record when we started
|
|
202
|
+
contract.status = 'in_progress';
|
|
203
|
+
contract.startedAt = new Date().toISOString();
|
|
204
|
+
contract.updatedAt = new Date().toISOString();
|
|
205
|
+
|
|
206
|
+
// Save the updated contract
|
|
207
|
+
atomicWriteJson(this.indexPath, index);
|
|
208
|
+
|
|
209
|
+
return { ...contract };
|
|
210
|
+
} finally {
|
|
211
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Completes a contract (transitions from in_progress → completed)
|
|
217
|
+
*
|
|
218
|
+
* This is called when the work is done successfully.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} contractId - The contract to complete
|
|
221
|
+
* @param {Object} [options={}] - Completion details
|
|
222
|
+
* @param {string} [options.summary=''] - Summary of what was done
|
|
223
|
+
* @param {Array} [options.publishedArtifacts=[]] - Artifact IDs that were published
|
|
224
|
+
* @returns {Object} The updated contract
|
|
225
|
+
* @throws {Error} If contract doesn't exist or is not in "in_progress" status
|
|
226
|
+
*/
|
|
227
|
+
complete(contractId, { summary = '', publishedArtifacts = [] } = {}) {
|
|
228
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
232
|
+
const contract = index[contractId];
|
|
233
|
+
|
|
234
|
+
if (!contract) {
|
|
235
|
+
throw new Error(`Contract "${contractId}" not found`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (contract.status !== 'in_progress') {
|
|
239
|
+
throw new Error(`Contract "${contractId}" is not in progress (current status: ${contract.status})`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Transition to completed and record the result
|
|
243
|
+
const now = new Date().toISOString();
|
|
244
|
+
contract.status = 'completed';
|
|
245
|
+
contract.completedAt = now;
|
|
246
|
+
contract.updatedAt = now;
|
|
247
|
+
contract.result = { summary, publishedArtifacts };
|
|
248
|
+
|
|
249
|
+
atomicWriteJson(this.indexPath, index);
|
|
250
|
+
|
|
251
|
+
return { ...contract };
|
|
252
|
+
} finally {
|
|
253
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Fails a contract (transitions from in_progress → failed)
|
|
259
|
+
*
|
|
260
|
+
* This is called when the work cannot be completed successfully.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} contractId - The contract to fail
|
|
263
|
+
* @param {string} reason - Why the contract failed
|
|
264
|
+
* @returns {Object} The updated contract
|
|
265
|
+
* @throws {Error} If contract doesn't exist or is not in "in_progress" status
|
|
266
|
+
*/
|
|
267
|
+
fail(contractId, reason) {
|
|
268
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
272
|
+
const contract = index[contractId];
|
|
273
|
+
|
|
274
|
+
if (!contract) {
|
|
275
|
+
throw new Error(`Contract "${contractId}" not found`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (contract.status !== 'in_progress') {
|
|
279
|
+
throw new Error(`Contract "${contractId}" is not in progress (current status: ${contract.status})`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Transition to failed and record the reason
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
contract.status = 'failed';
|
|
285
|
+
contract.completedAt = now;
|
|
286
|
+
contract.updatedAt = now;
|
|
287
|
+
contract.result = { reason };
|
|
288
|
+
|
|
289
|
+
atomicWriteJson(this.indexPath, index);
|
|
290
|
+
|
|
291
|
+
return { ...contract };
|
|
292
|
+
} finally {
|
|
293
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Reopens a failed or completed contract for retry or revision
|
|
299
|
+
*
|
|
300
|
+
* For failed contracts: transitions to "ready" for a retry
|
|
301
|
+
* For completed contracts: transitions to "in_progress" for a revision
|
|
302
|
+
*
|
|
303
|
+
* This increments the retryCount and will fail if maxRetries is exceeded.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} contractId - The contract to reopen
|
|
306
|
+
* @param {Object} [options={}] - Reopen configuration
|
|
307
|
+
* @param {string} [options.reason=''] - Why we're reopening the contract
|
|
308
|
+
* @param {Object} [options.newInputs=null] - New inputs to merge in
|
|
309
|
+
* @returns {Object} The updated contract
|
|
310
|
+
* @throws {Error} If contract doesn't exist, wrong status, or max retries exceeded
|
|
311
|
+
*/
|
|
312
|
+
reopen(contractId, { reason = '', newInputs = null } = {}) {
|
|
313
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
317
|
+
const contract = index[contractId];
|
|
318
|
+
|
|
319
|
+
if (!contract) {
|
|
320
|
+
throw new Error(`Contract "${contractId}" not found`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Can only reopen failed or completed contracts
|
|
324
|
+
if (contract.status !== 'failed' && contract.status !== 'completed') {
|
|
325
|
+
throw new Error(`Contract "${contractId}" cannot be reopened (current status: ${contract.status})`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Check if we've exceeded the retry limit
|
|
329
|
+
if (contract.retryCount >= contract.maxRetries) {
|
|
330
|
+
throw new Error(`Contract "${contractId}" has exceeded max retries (${contract.maxRetries})`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Increment the retry counter
|
|
334
|
+
contract.retryCount += 1;
|
|
335
|
+
|
|
336
|
+
// Determine new status based on current status
|
|
337
|
+
if (contract.status === 'failed') {
|
|
338
|
+
// Failed contracts go back to ready for a retry
|
|
339
|
+
contract.status = 'ready';
|
|
340
|
+
} else {
|
|
341
|
+
// Completed contracts go to in_progress for a revision
|
|
342
|
+
contract.status = 'in_progress';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Merge in new inputs if provided
|
|
346
|
+
if (newInputs) {
|
|
347
|
+
contract.inputs = {
|
|
348
|
+
artifacts: newInputs.artifacts || contract.inputs.artifacts,
|
|
349
|
+
context: newInputs.context || contract.inputs.context,
|
|
350
|
+
parameters: { ...contract.inputs.parameters, ...(newInputs.parameters || {}) }
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Reset completion timestamp and update the updated timestamp
|
|
355
|
+
contract.completedAt = null;
|
|
356
|
+
contract.updatedAt = new Date().toISOString();
|
|
357
|
+
|
|
358
|
+
atomicWriteJson(this.indexPath, index);
|
|
359
|
+
|
|
360
|
+
return { ...contract };
|
|
361
|
+
} finally {
|
|
362
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Gets a single contract by ID
|
|
368
|
+
*
|
|
369
|
+
* @param {string} contractId - The contract to retrieve
|
|
370
|
+
* @returns {Object|null} The contract object, or null if not found
|
|
371
|
+
*/
|
|
372
|
+
get(contractId) {
|
|
373
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
374
|
+
return index[contractId] ? { ...index[contractId] } : null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Lists contracts with optional filtering
|
|
379
|
+
*
|
|
380
|
+
* @param {Object} [filters={}] - Filter criteria
|
|
381
|
+
* @param {string} [filters.status] - Filter by status
|
|
382
|
+
* @param {string} [filters.assignee] - Filter by assignee session name
|
|
383
|
+
* @param {string} [filters.assigner] - Filter by assigner session name
|
|
384
|
+
* @returns {Array} Array of contracts matching the filters
|
|
385
|
+
*/
|
|
386
|
+
list({ status, assignee, assigner } = {}) {
|
|
387
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
388
|
+
|
|
389
|
+
// Get all contracts as an array
|
|
390
|
+
let contracts = Object.values(index);
|
|
391
|
+
|
|
392
|
+
// Apply filters if provided
|
|
393
|
+
if (status) {
|
|
394
|
+
contracts = contracts.filter(c => c.status === status);
|
|
395
|
+
}
|
|
396
|
+
if (assignee) {
|
|
397
|
+
contracts = contracts.filter(c => c.assignee === assignee);
|
|
398
|
+
}
|
|
399
|
+
if (assigner) {
|
|
400
|
+
contracts = contracts.filter(c => c.assigner === assigner);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Return copies to prevent mutation
|
|
404
|
+
return contracts.map(c => ({ ...c }));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Reassigns a contract to a different session
|
|
409
|
+
*
|
|
410
|
+
* @param {string} contractId - The contract to reassign
|
|
411
|
+
* @param {string} newAssignee - The new assignee session name
|
|
412
|
+
* @returns {Object} The updated contract
|
|
413
|
+
* @throws {Error} If contract doesn't exist
|
|
414
|
+
*/
|
|
415
|
+
reassign(contractId, newAssignee) {
|
|
416
|
+
acquireLock(this.locksDir, 'contracts-index');
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
420
|
+
const contract = index[contractId];
|
|
421
|
+
|
|
422
|
+
if (!contract) {
|
|
423
|
+
throw new Error(`Contract "${contractId}" not found`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Update the assignee and timestamp
|
|
427
|
+
contract.assignee = newAssignee;
|
|
428
|
+
contract.updatedAt = new Date().toISOString();
|
|
429
|
+
|
|
430
|
+
atomicWriteJson(this.indexPath, index);
|
|
431
|
+
|
|
432
|
+
return { ...contract };
|
|
433
|
+
} finally {
|
|
434
|
+
releaseLock(this.locksDir, 'contracts-index');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Detects circular dependencies in the contract dependency graph
|
|
440
|
+
*
|
|
441
|
+
* Uses depth-first search (DFS) to traverse the dependency chain.
|
|
442
|
+
* If we encounter the starting contractId during traversal, there's a cycle.
|
|
443
|
+
*
|
|
444
|
+
* Example cycle: A depends on B, B depends on C, C depends on A
|
|
445
|
+
*
|
|
446
|
+
* @private
|
|
447
|
+
* @param {string} contractId - The contract we're creating
|
|
448
|
+
* @param {Array} dependencies - The dependencies for this contract
|
|
449
|
+
* @param {Object} allContracts - All existing contracts in the index
|
|
450
|
+
* @returns {boolean} True if a cycle is detected, false otherwise
|
|
451
|
+
*/
|
|
452
|
+
_detectCycle(contractId, dependencies, allContracts) {
|
|
453
|
+
// Set to track which contracts we've visited in this DFS path
|
|
454
|
+
// This helps us detect when we've looped back to a contract we're already processing
|
|
455
|
+
const visited = new Set();
|
|
456
|
+
|
|
457
|
+
// Set to track the current path we're exploring
|
|
458
|
+
// If we see a contract in the current path again, that's a cycle
|
|
459
|
+
const path = new Set();
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* DFS helper function that explores one contract's dependencies
|
|
463
|
+
*
|
|
464
|
+
* @param {string} currentId - The contract we're currently exploring
|
|
465
|
+
* @returns {boolean} True if a cycle is found
|
|
466
|
+
*/
|
|
467
|
+
const dfs = (currentId) => {
|
|
468
|
+
// If we've already fully explored this contract, skip it
|
|
469
|
+
if (visited.has(currentId)) {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// If this contract is in our current path, we found a cycle!
|
|
474
|
+
if (path.has(currentId)) {
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Add to the current path
|
|
479
|
+
path.add(currentId);
|
|
480
|
+
|
|
481
|
+
// Get this contract's dependencies
|
|
482
|
+
const contract = allContracts[currentId];
|
|
483
|
+
if (contract && contract.dependencies) {
|
|
484
|
+
// Check each dependency
|
|
485
|
+
for (const dep of contract.dependencies) {
|
|
486
|
+
// Only follow "contract" type dependencies
|
|
487
|
+
if (dep.type === 'contract' && dep.contractId) {
|
|
488
|
+
// Recursively check this dependency
|
|
489
|
+
if (dfs(dep.contractId)) {
|
|
490
|
+
return true; // Cycle found deeper in the graph
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Remove from current path (backtrack)
|
|
497
|
+
path.delete(currentId);
|
|
498
|
+
|
|
499
|
+
// Mark as fully visited
|
|
500
|
+
visited.add(currentId);
|
|
501
|
+
|
|
502
|
+
return false;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// Check each dependency of the new contract
|
|
506
|
+
for (const dep of dependencies) {
|
|
507
|
+
if (dep.type === 'contract' && dep.contractId) {
|
|
508
|
+
// Start DFS from this dependency
|
|
509
|
+
// If it eventually leads back to contractId, that's a cycle
|
|
510
|
+
if (dep.contractId === contractId) {
|
|
511
|
+
return true; // Direct self-dependency
|
|
512
|
+
}
|
|
513
|
+
if (dfs(dep.contractId)) {
|
|
514
|
+
return true; // Indirect cycle found
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// No cycles detected
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Export the ContractStore class
|
|
525
|
+
module.exports = ContractStore;
|