@dependabit/manifest 0.1.1

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.
@@ -0,0 +1,400 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import {
5
+ readManifest,
6
+ writeManifest,
7
+ updateDependency,
8
+ addDependency,
9
+ removeDependency,
10
+ mergeManifests,
11
+ createEmptyManifest
12
+ } from '../src/manifest.js';
13
+ import type { DependencyEntry } from '../src/schema.js';
14
+
15
+ const TEST_DIR = '/tmp/dependabit-manifest-tests';
16
+
17
+ describe('Manifest Operations Tests', () => {
18
+ beforeEach(async () => {
19
+ await mkdir(TEST_DIR, { recursive: true });
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(TEST_DIR, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('readManifest and writeManifest', () => {
27
+ it('should write and read a manifest', async () => {
28
+ const manifest = createEmptyManifest({
29
+ owner: 'test',
30
+ name: 'repo',
31
+ branch: 'main',
32
+ commit: 'abc123'
33
+ });
34
+
35
+ const path = join(TEST_DIR, 'manifest.json');
36
+ await writeManifest(path, manifest);
37
+
38
+ const read = await readManifest(path);
39
+ expect(read.repository.owner).toBe('test');
40
+ expect(read.repository.name).toBe('repo');
41
+ });
42
+
43
+ it('should create directory if not exists', async () => {
44
+ const manifest = createEmptyManifest({
45
+ owner: 'test',
46
+ name: 'repo',
47
+ branch: 'main',
48
+ commit: 'abc123'
49
+ });
50
+
51
+ const path = join(TEST_DIR, 'nested', 'dir', 'manifest.json');
52
+ await writeManifest(path, manifest);
53
+
54
+ const read = await readManifest(path);
55
+ expect(read).toBeDefined();
56
+ });
57
+
58
+ it('should validate manifest before writing', async () => {
59
+ const invalid = { version: '2.0.0' } as any;
60
+ const path = join(TEST_DIR, 'manifest.json');
61
+
62
+ await expect(writeManifest(path, invalid)).rejects.toThrow();
63
+ });
64
+ });
65
+
66
+ describe('addDependency', () => {
67
+ it('should add a dependency to manifest', async () => {
68
+ const manifest = createEmptyManifest({
69
+ owner: 'test',
70
+ name: 'repo',
71
+ branch: 'main',
72
+ commit: 'abc123'
73
+ });
74
+
75
+ const path = join(TEST_DIR, 'manifest.json');
76
+ await writeManifest(path, manifest);
77
+
78
+ const dependency: DependencyEntry = {
79
+ id: '550e8400-e29b-41d4-a716-446655440000',
80
+ url: 'https://github.com/microsoft/TypeScript',
81
+ type: 'reference-implementation',
82
+ accessMethod: 'github-api',
83
+ name: 'TypeScript',
84
+ currentStateHash: 'sha256:abc123',
85
+ detectionMethod: 'manual',
86
+ detectionConfidence: 1.0,
87
+ detectedAt: new Date().toISOString(),
88
+ lastChecked: new Date().toISOString(),
89
+ auth: undefined,
90
+ referencedIn: []
91
+ };
92
+
93
+ const updated = await addDependency(path, dependency);
94
+ expect(updated.dependencies).toHaveLength(1);
95
+ expect(updated.dependencies[0].name).toBe('TypeScript');
96
+ expect(updated.statistics.totalDependencies).toBe(1);
97
+ });
98
+
99
+ it('should reject duplicate IDs', async () => {
100
+ const manifest = createEmptyManifest({
101
+ owner: 'test',
102
+ name: 'repo',
103
+ branch: 'main',
104
+ commit: 'abc123'
105
+ });
106
+
107
+ const path = join(TEST_DIR, 'manifest.json');
108
+ await writeManifest(path, manifest);
109
+
110
+ const dependency: DependencyEntry = {
111
+ id: '550e8400-e29b-41d4-a716-446655440000',
112
+ url: 'https://github.com/microsoft/TypeScript',
113
+ type: 'reference-implementation',
114
+ accessMethod: 'github-api',
115
+ name: 'TypeScript',
116
+ currentStateHash: 'sha256:abc123',
117
+ detectionMethod: 'manual',
118
+ detectionConfidence: 1.0,
119
+ detectedAt: new Date().toISOString(),
120
+ lastChecked: new Date().toISOString(),
121
+ auth: undefined,
122
+ referencedIn: []
123
+ };
124
+
125
+ await addDependency(path, dependency);
126
+ await expect(addDependency(path, dependency)).rejects.toThrow(/already exists/);
127
+ });
128
+
129
+ it('should reject duplicate URLs', async () => {
130
+ const manifest = createEmptyManifest({
131
+ owner: 'test',
132
+ name: 'repo',
133
+ branch: 'main',
134
+ commit: 'abc123'
135
+ });
136
+
137
+ const path = join(TEST_DIR, 'manifest.json');
138
+ await writeManifest(path, manifest);
139
+
140
+ const dependency1: DependencyEntry = {
141
+ id: '550e8400-e29b-41d4-a716-446655440000',
142
+ url: 'https://github.com/microsoft/TypeScript',
143
+ type: 'reference-implementation',
144
+ accessMethod: 'github-api',
145
+ name: 'TypeScript',
146
+ currentStateHash: 'sha256:abc123',
147
+ detectionMethod: 'manual',
148
+ detectionConfidence: 1.0,
149
+ detectedAt: new Date().toISOString(),
150
+ lastChecked: new Date().toISOString(),
151
+ auth: undefined,
152
+ referencedIn: []
153
+ };
154
+
155
+ const dependency2: DependencyEntry = {
156
+ ...dependency1,
157
+ id: '660e8400-e29b-41d4-a716-446655440001'
158
+ };
159
+
160
+ await addDependency(path, dependency1);
161
+ await expect(addDependency(path, dependency2)).rejects.toThrow(/already exists/);
162
+ });
163
+ });
164
+
165
+ describe('updateDependency', () => {
166
+ it('should update a dependency', async () => {
167
+ const manifest = createEmptyManifest({
168
+ owner: 'test',
169
+ name: 'repo',
170
+ branch: 'main',
171
+ commit: 'abc123'
172
+ });
173
+
174
+ const path = join(TEST_DIR, 'manifest.json');
175
+ await writeManifest(path, manifest);
176
+
177
+ const dependency: DependencyEntry = {
178
+ id: '550e8400-e29b-41d4-a716-446655440000',
179
+ url: 'https://github.com/microsoft/TypeScript',
180
+ type: 'reference-implementation',
181
+ accessMethod: 'github-api',
182
+ name: 'TypeScript',
183
+ currentStateHash: 'sha256:abc123',
184
+ detectionMethod: 'manual',
185
+ detectionConfidence: 1.0,
186
+ detectedAt: new Date().toISOString(),
187
+ lastChecked: new Date().toISOString(),
188
+ auth: undefined,
189
+ referencedIn: []
190
+ };
191
+
192
+ await addDependency(path, dependency);
193
+
194
+ const updated = await updateDependency(path, dependency.id, {
195
+ currentVersion: '5.9.3',
196
+ currentStateHash: 'sha256:newHash'
197
+ });
198
+
199
+ expect(updated.dependencies[0].currentVersion).toBe('5.9.3');
200
+ expect(updated.dependencies[0].currentStateHash).toBe('sha256:newHash');
201
+ });
202
+
203
+ it('should throw error for non-existent dependency', async () => {
204
+ const manifest = createEmptyManifest({
205
+ owner: 'test',
206
+ name: 'repo',
207
+ branch: 'main',
208
+ commit: 'abc123'
209
+ });
210
+
211
+ const path = join(TEST_DIR, 'manifest.json');
212
+ await writeManifest(path, manifest);
213
+
214
+ await expect(
215
+ updateDependency(path, '550e8400-e29b-41d4-a716-446655440000', {
216
+ currentVersion: '5.9.3'
217
+ })
218
+ ).rejects.toThrow(/not found/);
219
+ });
220
+ });
221
+
222
+ describe('removeDependency', () => {
223
+ it('should remove a dependency', async () => {
224
+ const manifest = createEmptyManifest({
225
+ owner: 'test',
226
+ name: 'repo',
227
+ branch: 'main',
228
+ commit: 'abc123'
229
+ });
230
+
231
+ const path = join(TEST_DIR, 'manifest.json');
232
+ await writeManifest(path, manifest);
233
+
234
+ const dependency: DependencyEntry = {
235
+ id: '550e8400-e29b-41d4-a716-446655440000',
236
+ url: 'https://github.com/microsoft/TypeScript',
237
+ type: 'reference-implementation',
238
+ accessMethod: 'github-api',
239
+ name: 'TypeScript',
240
+ currentStateHash: 'sha256:abc123',
241
+ detectionMethod: 'manual',
242
+ detectionConfidence: 1.0,
243
+ detectedAt: new Date().toISOString(),
244
+ lastChecked: new Date().toISOString(),
245
+ auth: undefined,
246
+ referencedIn: []
247
+ };
248
+
249
+ await addDependency(path, dependency);
250
+ const updated = await removeDependency(path, dependency.id);
251
+
252
+ expect(updated.dependencies).toHaveLength(0);
253
+ expect(updated.statistics.totalDependencies).toBe(0);
254
+ });
255
+ });
256
+
257
+ describe('mergeManifests', () => {
258
+ it('should merge two manifests preserving manual entries', () => {
259
+ const existing = createEmptyManifest({
260
+ owner: 'test',
261
+ name: 'repo',
262
+ branch: 'main',
263
+ commit: 'abc123'
264
+ });
265
+
266
+ const manualDep: DependencyEntry = {
267
+ id: '550e8400-e29b-41d4-a716-446655440000',
268
+ url: 'https://github.com/manual/dep',
269
+ type: 'documentation',
270
+ accessMethod: 'http',
271
+ name: 'Manual Dependency',
272
+ currentStateHash: 'sha256:manual',
273
+ detectionMethod: 'manual',
274
+ detectionConfidence: 1.0,
275
+ detectedAt: new Date().toISOString(),
276
+ lastChecked: new Date().toISOString(),
277
+ auth: undefined,
278
+ referencedIn: []
279
+ };
280
+
281
+ existing.dependencies.push(manualDep);
282
+
283
+ const updated = createEmptyManifest({
284
+ owner: 'test',
285
+ name: 'repo',
286
+ branch: 'main',
287
+ commit: 'def456'
288
+ });
289
+
290
+ const autoDep: DependencyEntry = {
291
+ id: '660e8400-e29b-41d4-a716-446655440001',
292
+ url: 'https://github.com/auto/dep',
293
+ type: 'documentation',
294
+ accessMethod: 'http',
295
+ name: 'Auto Dependency',
296
+ currentStateHash: 'sha256:auto',
297
+ detectionMethod: 'llm-analysis',
298
+ detectionConfidence: 0.9,
299
+ detectedAt: new Date().toISOString(),
300
+ lastChecked: new Date().toISOString(),
301
+ auth: undefined,
302
+ referencedIn: []
303
+ };
304
+
305
+ updated.dependencies.push(autoDep);
306
+
307
+ const merged = mergeManifests(existing, updated);
308
+
309
+ expect(merged.dependencies).toHaveLength(2);
310
+ expect(merged.dependencies.some((d) => d.name === 'Manual Dependency')).toBe(true);
311
+ expect(merged.dependencies.some((d) => d.name === 'Auto Dependency')).toBe(true);
312
+ });
313
+
314
+ it('should preserve change history', () => {
315
+ const existing = createEmptyManifest({
316
+ owner: 'test',
317
+ name: 'repo',
318
+ branch: 'main',
319
+ commit: 'abc123'
320
+ });
321
+
322
+ const depWithHistory: DependencyEntry = {
323
+ id: '550e8400-e29b-41d4-a716-446655440000',
324
+ url: 'https://github.com/test/dep',
325
+ type: 'documentation',
326
+ accessMethod: 'http',
327
+ name: 'Test Dependency',
328
+ currentStateHash: 'sha256:old',
329
+ detectionMethod: 'llm-analysis',
330
+ detectionConfidence: 1.0,
331
+ detectedAt: new Date().toISOString(),
332
+ lastChecked: new Date().toISOString(),
333
+ auth: undefined,
334
+ referencedIn: [],
335
+ changeHistory: [
336
+ {
337
+ detectedAt: new Date().toISOString(),
338
+ severity: 'minor',
339
+ falsePositive: false
340
+ }
341
+ ]
342
+ };
343
+
344
+ existing.dependencies.push(depWithHistory);
345
+
346
+ const updated = createEmptyManifest({
347
+ owner: 'test',
348
+ name: 'repo',
349
+ branch: 'main',
350
+ commit: 'def456'
351
+ });
352
+
353
+ const updatedDep: DependencyEntry = {
354
+ ...depWithHistory,
355
+ currentStateHash: 'sha256:new',
356
+ changeHistory: []
357
+ };
358
+
359
+ updated.dependencies.push(updatedDep);
360
+
361
+ const merged = mergeManifests(existing, updated);
362
+
363
+ expect(merged.dependencies[0].changeHistory).toHaveLength(1);
364
+ });
365
+ });
366
+
367
+ describe('createEmptyManifest', () => {
368
+ it('should create a valid empty manifest', () => {
369
+ const manifest = createEmptyManifest({
370
+ owner: 'test',
371
+ name: 'repo',
372
+ branch: 'main',
373
+ commit: 'abc123'
374
+ });
375
+
376
+ expect(manifest.version).toBe('1.0.0');
377
+ expect(manifest.dependencies).toHaveLength(0);
378
+ expect(manifest.statistics.totalDependencies).toBe(0);
379
+ expect(manifest.repository.owner).toBe('test');
380
+ });
381
+
382
+ it('should accept optional parameters', () => {
383
+ const manifest = createEmptyManifest({
384
+ owner: 'test',
385
+ name: 'repo',
386
+ branch: 'main',
387
+ commit: 'abc123',
388
+ action: 'custom-action',
389
+ version: '2.0.0',
390
+ llmProvider: 'claude',
391
+ llmModel: 'claude-3'
392
+ });
393
+
394
+ expect(manifest.generatedBy.action).toBe('custom-action');
395
+ expect(manifest.generatedBy.version).toBe('2.0.0');
396
+ expect(manifest.generatedBy.llmProvider).toBe('claude');
397
+ expect(manifest.generatedBy.llmModel).toBe('claude-3');
398
+ });
399
+ });
400
+ });
@@ -0,0 +1,261 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { type DependencyManifest, type DependencyEntry } from './schema.js';
4
+ import { validateManifest, validateDependencyEntry } from './validator.js';
5
+
6
+ /**
7
+ * Read and parse a manifest file
8
+ */
9
+ export async function readManifest(path: string): Promise<DependencyManifest> {
10
+ const content = await readFile(path, 'utf-8');
11
+ const data = JSON.parse(content);
12
+ return validateManifest(data);
13
+ }
14
+
15
+ /**
16
+ * Write a manifest to file
17
+ */
18
+ export async function writeManifest(path: string, manifest: DependencyManifest): Promise<void> {
19
+ // Validate before writing
20
+ validateManifest(manifest);
21
+
22
+ // Ensure directory exists
23
+ await mkdir(dirname(path), { recursive: true });
24
+
25
+ // Write formatted JSON
26
+ const content = JSON.stringify(manifest, null, 2);
27
+ await writeFile(path, content, 'utf-8');
28
+ }
29
+
30
+ /**
31
+ * Update a dependency entry in the manifest
32
+ */
33
+ export async function updateDependency(
34
+ path: string,
35
+ dependencyId: string,
36
+ updates: Partial<DependencyEntry>
37
+ ): Promise<DependencyManifest> {
38
+ const manifest = await readManifest(path);
39
+
40
+ const dep = manifest.dependencies.find((d) => d.id === dependencyId);
41
+ if (!dep) {
42
+ throw new Error(`Dependency with id ${dependencyId} not found`);
43
+ }
44
+
45
+ // Update the dependency in place
46
+ Object.assign(dep, updates);
47
+
48
+ // Validate the merged dependency
49
+ validateDependencyEntry(dep);
50
+
51
+ // Update statistics
52
+ manifest.statistics = calculateStatistics(manifest.dependencies);
53
+
54
+ // Write back
55
+ await writeManifest(path, manifest);
56
+
57
+ return manifest;
58
+ }
59
+
60
+ /**
61
+ * Add a new dependency to the manifest
62
+ */
63
+ export async function addDependency(
64
+ path: string,
65
+ dependency: DependencyEntry
66
+ ): Promise<DependencyManifest> {
67
+ const manifest = await readManifest(path);
68
+
69
+ // Check for duplicates by ID or URL
70
+ const existingById = manifest.dependencies.find((dep) => dep.id === dependency.id);
71
+ const existingByUrl = manifest.dependencies.find((dep) => dep.url === dependency.url);
72
+
73
+ if (existingById) {
74
+ throw new Error(`Dependency with id ${dependency.id} already exists`);
75
+ }
76
+
77
+ if (existingByUrl) {
78
+ throw new Error(`Dependency with url ${dependency.url} already exists`);
79
+ }
80
+
81
+ // Add dependency
82
+ manifest.dependencies.push(dependency);
83
+
84
+ // Update statistics
85
+ manifest.statistics = calculateStatistics(manifest.dependencies);
86
+
87
+ // Write back
88
+ await writeManifest(path, manifest);
89
+
90
+ return manifest;
91
+ }
92
+
93
+ /**
94
+ * Remove a dependency from the manifest
95
+ */
96
+ export async function removeDependency(
97
+ path: string,
98
+ dependencyId: string
99
+ ): Promise<DependencyManifest> {
100
+ const manifest = await readManifest(path);
101
+
102
+ const index = manifest.dependencies.findIndex((dep) => dep.id === dependencyId);
103
+ if (index === -1) {
104
+ throw new Error(`Dependency with id ${dependencyId} not found`);
105
+ }
106
+
107
+ // Remove dependency
108
+ manifest.dependencies.splice(index, 1);
109
+
110
+ // Update statistics
111
+ manifest.statistics = calculateStatistics(manifest.dependencies);
112
+
113
+ // Write back
114
+ await writeManifest(path, manifest);
115
+
116
+ return manifest;
117
+ }
118
+
119
+ /**
120
+ * Merge two manifests, preserving manual entries
121
+ * Manual entries are those with detectionMethod === 'manual'
122
+ */
123
+ export function mergeManifests(
124
+ existing: DependencyManifest,
125
+ updated: DependencyManifest,
126
+ options: {
127
+ preserveManual?: boolean;
128
+ preserveHistory?: boolean;
129
+ } = {}
130
+ ): DependencyManifest {
131
+ const { preserveManual = true, preserveHistory = true } = options;
132
+
133
+ // Create a deep copy of the updated manifest to avoid mutations
134
+ const merged: DependencyManifest = {
135
+ ...updated,
136
+ dependencies: updated.dependencies.map((dep) => ({
137
+ ...dep,
138
+ changeHistory: dep.changeHistory ? [...dep.changeHistory] : [],
139
+ referencedIn: dep.referencedIn ? [...dep.referencedIn] : []
140
+ }))
141
+ };
142
+
143
+ if (preserveManual) {
144
+ // Find manual entries in existing manifest
145
+ const manualEntries = existing.dependencies.filter((dep) => dep.detectionMethod === 'manual');
146
+
147
+ // Add manual entries that aren't in the updated manifest
148
+ for (const manualEntry of manualEntries) {
149
+ const existsInUpdated = merged.dependencies.some(
150
+ (dep) => dep.id === manualEntry.id || dep.url === manualEntry.url
151
+ );
152
+
153
+ if (!existsInUpdated) {
154
+ merged.dependencies.push({
155
+ ...manualEntry,
156
+ changeHistory: manualEntry.changeHistory ? [...manualEntry.changeHistory] : [],
157
+ referencedIn: manualEntry.referencedIn ? [...manualEntry.referencedIn] : []
158
+ });
159
+ }
160
+ }
161
+ }
162
+
163
+ if (preserveHistory) {
164
+ // Preserve change history for matching dependencies
165
+ merged.dependencies = merged.dependencies.map((dep) => {
166
+ const existingDep = existing.dependencies.find((d) => d.id === dep.id || d.url === dep.url);
167
+
168
+ if (existingDep && existingDep.changeHistory && existingDep.changeHistory.length > 0) {
169
+ return {
170
+ ...dep,
171
+ changeHistory: [...existingDep.changeHistory, ...(dep.changeHistory || [])]
172
+ };
173
+ }
174
+
175
+ return dep;
176
+ });
177
+ }
178
+
179
+ // Recalculate statistics
180
+ merged.statistics = calculateStatistics(merged.dependencies);
181
+
182
+ return merged;
183
+ }
184
+
185
+ /**
186
+ * Calculate statistics for a list of dependencies
187
+ */
188
+ function calculateStatistics(dependencies: DependencyEntry[]): DependencyManifest['statistics'] {
189
+ const byType: Record<string, number> = {};
190
+ const byAccessMethod: Record<string, number> = {};
191
+ const byDetectionMethod: Record<string, number> = {};
192
+ let totalConfidence = 0;
193
+ let falsePositiveCount = 0;
194
+ let totalChangeCount = 0;
195
+
196
+ for (const dep of dependencies) {
197
+ byType[dep.type] = (byType[dep.type] || 0) + 1;
198
+ byAccessMethod[dep.accessMethod] = (byAccessMethod[dep.accessMethod] || 0) + 1;
199
+ byDetectionMethod[dep.detectionMethod] = (byDetectionMethod[dep.detectionMethod] || 0) + 1;
200
+ totalConfidence += dep.detectionConfidence;
201
+
202
+ // Count false positives in change history
203
+ const changeHistory = dep.changeHistory || [];
204
+ const fpCount = changeHistory.filter((change) => change.falsePositive).length;
205
+ falsePositiveCount += fpCount;
206
+ totalChangeCount += changeHistory.length;
207
+ }
208
+
209
+ const averageConfidence = dependencies.length > 0 ? totalConfidence / dependencies.length : 0;
210
+
211
+ const falsePositiveRate =
212
+ totalChangeCount > 0 ? falsePositiveCount / totalChangeCount : undefined;
213
+
214
+ return {
215
+ totalDependencies: dependencies.length,
216
+ byType,
217
+ byAccessMethod,
218
+ byDetectionMethod,
219
+ averageConfidence,
220
+ falsePositiveRate
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Create an empty manifest template
226
+ */
227
+ export function createEmptyManifest(options: {
228
+ owner: string;
229
+ name: string;
230
+ branch: string;
231
+ commit: string;
232
+ action?: string;
233
+ version?: string;
234
+ llmProvider?: string;
235
+ llmModel?: string;
236
+ }): DependencyManifest {
237
+ return {
238
+ version: '1.0.0',
239
+ generatedAt: new Date().toISOString(),
240
+ generatedBy: {
241
+ action: options.action || 'dependabit',
242
+ version: options.version || '0.1.0',
243
+ llmProvider: options.llmProvider || 'github-copilot',
244
+ llmModel: options.llmModel
245
+ },
246
+ repository: {
247
+ owner: options.owner,
248
+ name: options.name,
249
+ branch: options.branch,
250
+ commit: options.commit
251
+ },
252
+ dependencies: [],
253
+ statistics: {
254
+ totalDependencies: 0,
255
+ byType: {},
256
+ byAccessMethod: {},
257
+ byDetectionMethod: {},
258
+ averageConfidence: 0
259
+ }
260
+ };
261
+ }