archsync 1.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.
Files changed (48) hide show
  1. package/bin/cli.js +91 -0
  2. package/package.json +57 -0
  3. package/src/__tests__/e2e-workflow.test.js +66 -0
  4. package/src/__tests__/hashEngine.test.js +109 -0
  5. package/src/__tests__/impact.test.js +137 -0
  6. package/src/__tests__/parsers.test.js +496 -0
  7. package/src/__tests__/scan-pipeline.test.js +332 -0
  8. package/src/__tests__/schemaBuilder.test.js +145 -0
  9. package/src/__tests__/workspace.test.js +178 -0
  10. package/src/commands/backup.js +54 -0
  11. package/src/commands/connect.js +129 -0
  12. package/src/commands/diff.js +228 -0
  13. package/src/commands/export.js +125 -0
  14. package/src/commands/impactReport.js +50 -0
  15. package/src/commands/import.js +126 -0
  16. package/src/commands/init.js +80 -0
  17. package/src/commands/login.js +116 -0
  18. package/src/commands/plugin.js +28 -0
  19. package/src/commands/push.js +194 -0
  20. package/src/commands/register.js +127 -0
  21. package/src/commands/scan.js +498 -0
  22. package/src/commands/serve.js +133 -0
  23. package/src/commands/setup.js +233 -0
  24. package/src/commands/status.js +56 -0
  25. package/src/commands/validate.js +245 -0
  26. package/src/commands/watch.js +70 -0
  27. package/src/core/credentialStore.js +76 -0
  28. package/src/core/hashEngine.js +34 -0
  29. package/src/core/impactEngine.js +192 -0
  30. package/src/core/monorepoDetector.js +41 -0
  31. package/src/core/pluginManager.js +40 -0
  32. package/src/core/relationshipEngine.js +917 -0
  33. package/src/core/requestSigning.js +16 -0
  34. package/src/core/schemaBuilder.js +230 -0
  35. package/src/core/schemaDeduplicator.js +54 -0
  36. package/src/core/supabaseClient.js +68 -0
  37. package/src/core/workspaceDetector.js +113 -0
  38. package/src/parsers/astParser.js +274 -0
  39. package/src/parsers/configParser.js +49 -0
  40. package/src/parsers/dependencyGraph.js +31 -0
  41. package/src/parsers/flutterParser.js +98 -0
  42. package/src/parsers/goParser.js +99 -0
  43. package/src/parsers/index.js +211 -0
  44. package/src/parsers/javaParser.js +89 -0
  45. package/src/parsers/nodeParser.js +429 -0
  46. package/src/parsers/pythonParser.js +109 -0
  47. package/src/parsers/reactParser.js +368 -0
  48. package/src/parsers/smartComment.js +144 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Unit tests for CLI parsers:
3
+ * - reactParser (parseReactFile)
4
+ * - nodeParser (parseNodeFile)
5
+ * - smartComment (parseSmartComments)
6
+ *
7
+ * All tests use vi.mock to intercept fs.readFileSync so no real files are
8
+ * needed on disk.
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+ import { parseReactFile } from '../parsers/reactParser.js';
13
+ import { parseNodeFile } from '../parsers/nodeParser.js';
14
+ import { parseSmartComments } from '../parsers/smartComment.js';
15
+
16
+ // ─── Mock fs module ──────────────────────────────────────────
17
+ // The parsers use `import fs from 'fs'` (default import), so the mock must
18
+ // replace readFileSync on the default export as well as the named one.
19
+ vi.mock('fs', async (importOriginal) => {
20
+ const actual = await importOriginal();
21
+ const readFileSync = vi.fn();
22
+ return {
23
+ ...actual,
24
+ readFileSync,
25
+ default: { ...actual.default, readFileSync },
26
+ };
27
+ });
28
+
29
+ import fs from 'fs';
30
+
31
+ function setFileContent(content) {
32
+ fs.readFileSync.mockReturnValue(content);
33
+ }
34
+
35
+ // ─── reactParser ─────────────────────────────────────────────
36
+
37
+ describe('reactParser — parseReactFile', () => {
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ it('extracts a React functional component by name', () => {
43
+ setFileContent(`
44
+ export default function HomePage() {
45
+ return <div>Home</div>;
46
+ }
47
+ `);
48
+ const result = parseReactFile('/web/pages/HomePage.jsx');
49
+ expect(result.entities.length).toBeGreaterThan(0);
50
+ const comp = result.entities.find(e => e.name === 'HomePage');
51
+ expect(comp).toBeDefined();
52
+ expect(comp.entityType).toBe('screen');
53
+ });
54
+
55
+ it('extracts a named export component', () => {
56
+ setFileContent(`
57
+ export function UserCard() {
58
+ return <div />;
59
+ }
60
+ `);
61
+ const result = parseReactFile('/web/components/UserCard.jsx');
62
+ const comp = result.entities.find(e => e.name === 'UserCard');
63
+ expect(comp).toBeDefined();
64
+ });
65
+
66
+ it('classifies "page"-named components as screen entityType', () => {
67
+ setFileContent(`
68
+ export function DashboardPage() {
69
+ return <div />;
70
+ }
71
+ `);
72
+ const result = parseReactFile('/web/pages/DashboardPage.jsx');
73
+ const comp = result.entities.find(e => e.name === 'DashboardPage');
74
+ expect(comp.entityType).toBe('screen');
75
+ });
76
+
77
+ it('extracts a custom hook as a service entity', () => {
78
+ setFileContent(`
79
+ export function useAuthState() {
80
+ const [user, setUser] = useState(null);
81
+ return user;
82
+ }
83
+ `);
84
+ const result = parseReactFile('/web/hooks/useAuthState.js');
85
+ const hook = result.entities.find(e => e.name === 'useAuthState');
86
+ expect(hook).toBeDefined();
87
+ expect(hook.entityType).toBe('service');
88
+ expect(hook.data.type).toBe('hook');
89
+ });
90
+
91
+ it('extracts a fetch() API call as an api entity', () => {
92
+ setFileContent(`
93
+ export function ProfilePage() {
94
+ useEffect(() => {
95
+ fetch('/api/users/me').then(r => r.json());
96
+ }, []);
97
+ return <div />;
98
+ }
99
+ `);
100
+ const result = parseReactFile('/web/pages/ProfilePage.jsx');
101
+ const apiEntity = result.entities.find(e => e.entityType === 'api');
102
+ expect(apiEntity).toBeDefined();
103
+ expect(apiEntity.data.path).toBe('/api/users/me');
104
+ });
105
+
106
+ it('extracts an axios API call as an api entity', () => {
107
+ setFileContent(`
108
+ export function OrdersPage() {
109
+ useEffect(() => {
110
+ axios.post('/api/orders', payload);
111
+ }, []);
112
+ return <div />;
113
+ }
114
+ `);
115
+ const result = parseReactFile('/web/pages/OrdersPage.jsx');
116
+ const apiEntity = result.entities.find(e => e.entityType === 'api' && e.data.method === 'POST');
117
+ expect(apiEntity).toBeDefined();
118
+ expect(apiEntity.data.path).toBe('/api/orders');
119
+ });
120
+
121
+ it('does not extract lowercase utility functions as components', () => {
122
+ setFileContent(`
123
+ function formatDate(d) { return d.toISOString(); }
124
+ export function Header() { return <header />; }
125
+ `);
126
+ const result = parseReactFile('/web/components/Header.jsx');
127
+ const utils = result.entities.filter(e => e.name === 'formatDate');
128
+ expect(utils).toHaveLength(0);
129
+ });
130
+
131
+ it('sets system to "admin" for files inside /admin/ paths', () => {
132
+ setFileContent(`
133
+ export function AdminDashboard() { return <div />; }
134
+ `);
135
+ const result = parseReactFile('/admin/pages/AdminDashboard.jsx');
136
+ const comp = result.entities.find(e => e.name === 'AdminDashboard');
137
+ expect(comp?.system).toBe('admin');
138
+ });
139
+
140
+ it('does not extract built-in hooks (useState, useEffect, useContext)', () => {
141
+ setFileContent(`
142
+ export function Counter() {
143
+ const [n, setN] = useState(0);
144
+ useEffect(() => {}, []);
145
+ return <div />;
146
+ }
147
+ `);
148
+ const result = parseReactFile('/web/Counter.jsx');
149
+ const builtins = result.entities.filter(e =>
150
+ ['useState', 'useEffect', 'useContext'].includes(e.name)
151
+ );
152
+ expect(builtins).toHaveLength(0);
153
+ });
154
+
155
+ it('creates import relations for relative component imports', () => {
156
+ setFileContent(`
157
+ import Button from './Button';
158
+ export function LoginPage() {
159
+ return <Button />;
160
+ }
161
+ `);
162
+ const result = parseReactFile('/web/pages/LoginPage.jsx');
163
+ // LoginPage should exist and have a relation pointing to Button
164
+ expect(result.relations.length).toBeGreaterThan(0);
165
+ expect(result.relations[0].target).toBe('Button');
166
+ });
167
+
168
+ it('returns empty entities for empty file', () => {
169
+ setFileContent('');
170
+ const result = parseReactFile('/web/empty.jsx');
171
+ expect(result.entities).toHaveLength(0);
172
+ expect(result.relations).toHaveLength(0);
173
+ });
174
+
175
+ it('handles syntax that looks like a component but is a variable assignment', () => {
176
+ setFileContent(`
177
+ const config = { theme: 'dark' };
178
+ export default config;
179
+ `);
180
+ const result = parseReactFile('/web/config.js');
181
+ // No PascalCase function component should be found
182
+ const comps = result.entities.filter(e => e.entityType === 'screen');
183
+ expect(comps).toHaveLength(0);
184
+ });
185
+ });
186
+
187
+ // ─── nodeParser ──────────────────────────────────────────────
188
+
189
+ describe('nodeParser — parseNodeFile', () => {
190
+ beforeEach(() => {
191
+ vi.clearAllMocks();
192
+ });
193
+
194
+ it('extracts Express GET routes', () => {
195
+ setFileContent(`
196
+ const router = express.Router();
197
+ router.get('/users', getUsers);
198
+ router.get('/users/:id', getUserById);
199
+ `);
200
+ const result = parseNodeFile('/backend/routes/userRoutes.js');
201
+ const routes = result.entities.filter(e => e.entityType === 'route');
202
+ expect(routes).toHaveLength(2);
203
+ expect(routes[0].data.method).toBe('GET');
204
+ });
205
+
206
+ it('extracts POST, PUT, DELETE routes', () => {
207
+ setFileContent(`
208
+ router.post('/items', createItem);
209
+ router.put('/items/:id', updateItem);
210
+ router.delete('/items/:id', deleteItem);
211
+ `);
212
+ const result = parseNodeFile('/backend/routes/itemRoutes.js');
213
+ const routes = result.entities.filter(e => e.entityType === 'route');
214
+ expect(routes.map(r => r.data.method).sort()).toEqual(['DELETE', 'POST', 'PUT']);
215
+ });
216
+
217
+ it('extracts route names with full path', () => {
218
+ setFileContent(`router.get('/health', healthCheck);`);
219
+ const result = parseNodeFile('/backend/routes/health.js');
220
+ const route = result.entities.find(e => e.entityType === 'route');
221
+ expect(route.name).toBe('GET /health');
222
+ });
223
+
224
+ it('extracts Mongoose models', () => {
225
+ setFileContent(`
226
+ const User = mongoose.model('User', userSchema);
227
+ const Post = mongoose.model('Post', postSchema);
228
+ `);
229
+ const result = parseNodeFile('/backend/models/User.model.js');
230
+ const models = result.entities.filter(e => e.entityType === 'model' && e.data.orm === 'mongoose');
231
+ expect(models).toHaveLength(2);
232
+ expect(models.map(m => m.name)).toContain('User');
233
+ });
234
+
235
+ it('extracts Sequelize models', () => {
236
+ setFileContent(`const Order = sequelize.define('Order', {});`);
237
+ const result = parseNodeFile('/backend/models/Order.js');
238
+ const model = result.entities.find(e => e.data.orm === 'sequelize');
239
+ expect(model).toBeDefined();
240
+ expect(model.name).toBe('Order');
241
+ });
242
+
243
+ it('extracts Firestore collection references', () => {
244
+ setFileContent(`
245
+ db.collection('users').get();
246
+ db.collection('products').where('active', '==', true);
247
+ `);
248
+ const result = parseNodeFile('/backend/services/db.js');
249
+ const dbs = result.entities.filter(e => e.entityType === 'database');
250
+ expect(dbs).toHaveLength(2);
251
+ expect(dbs.map(d => d.data.collection)).toContain('users');
252
+ });
253
+
254
+ it('deduplicates Firestore collections referenced multiple times', () => {
255
+ setFileContent(`
256
+ db.collection('users').get();
257
+ db.collection('users').where('active', '==', true);
258
+ `);
259
+ const result = parseNodeFile('/backend/services/userService.js');
260
+ const dbs = result.entities.filter(e => e.entityType === 'database');
261
+ expect(dbs).toHaveLength(1);
262
+ });
263
+
264
+ it('extracts class-based controllers', () => {
265
+ setFileContent(`
266
+ class UserController {
267
+ async getUser(req, res) {}
268
+ async createUser(req, res) {}
269
+ }
270
+ `);
271
+ const result = parseNodeFile('/backend/controllers/userController.js');
272
+ const ctrl = result.entities.find(e => e.name === 'UserController');
273
+ expect(ctrl).toBeDefined();
274
+ expect(ctrl.entityType).toBe('controller');
275
+ });
276
+
277
+ it('extracts exported arrow functions from controller files', () => {
278
+ setFileContent(`
279
+ export const getUser = async (req, res) => {
280
+ res.json({});
281
+ };
282
+ export const createUser = async (req, res) => {
283
+ res.json({});
284
+ };
285
+ `);
286
+ const result = parseNodeFile('/backend/controllers/userController.js');
287
+ const controllers = result.entities.filter(e => e.entityType === 'controller');
288
+ expect(controllers.length).toBeGreaterThanOrEqual(2);
289
+ expect(controllers.map(c => c.name)).toContain('getUser');
290
+ });
291
+
292
+ it('extracts mount points from app.use()', () => {
293
+ setFileContent(`
294
+ app.use('/api/v1/users', userRoutes);
295
+ app.use('/api/v1/orders', orderRoutes);
296
+ `);
297
+ const result = parseNodeFile('/backend/app.js');
298
+ const mounts = result.entities.filter(e => e.entityType === 'mount');
299
+ expect(mounts).toHaveLength(2);
300
+ expect(mounts[0].data.prefix).toBe('/api/v1/users');
301
+ });
302
+
303
+ it('extracts custom middleware references (non-built-in)', () => {
304
+ setFileContent(`
305
+ app.use(authMiddleware);
306
+ app.use(rateLimiter);
307
+ app.use(cors);
308
+ `);
309
+ const result = parseNodeFile('/backend/app.js');
310
+ const mws = result.entities.filter(e => e.entityType === 'middleware');
311
+ // cors is in the built-in skip list; authMiddleware and rateLimiter should be extracted
312
+ const names = mws.map(m => m.name);
313
+ expect(names).toContain('authMiddleware');
314
+ expect(names).toContain('rateLimiter');
315
+ expect(names).not.toContain('cors');
316
+ });
317
+
318
+ it('returns empty entities for an empty file', () => {
319
+ setFileContent('');
320
+ const result = parseNodeFile('/backend/empty.js');
321
+ expect(result.entities).toHaveLength(0);
322
+ });
323
+
324
+ it('does not throw on unusual but valid JavaScript patterns', () => {
325
+ setFileContent(`
326
+ const obj = { nested: { deep: true } };
327
+ module.exports = obj;
328
+ `);
329
+ expect(() => parseNodeFile('/backend/misc.js')).not.toThrow();
330
+ });
331
+
332
+ it('creates parent-class relations for classes that extend a base', () => {
333
+ setFileContent(`
334
+ class AdminController extends BaseController {
335
+ async list(req, res) {}
336
+ }
337
+ `);
338
+ const result = parseNodeFile('/backend/controllers/adminController.js');
339
+ const rel = result.relations.find(r => r.relation === 'extends');
340
+ expect(rel).toBeDefined();
341
+ expect(rel.source).toBe('AdminController');
342
+ expect(rel.target).toBe('BaseController');
343
+ });
344
+ });
345
+
346
+ // ─── smartComment parser ──────────────────────────────────────
347
+
348
+ describe('smartComment — parseSmartComments', () => {
349
+ beforeEach(() => {
350
+ vi.clearAllMocks();
351
+ });
352
+
353
+ it('extracts an @archsync:entity directive', () => {
354
+ setFileContent(`
355
+ // @archsync:entity service AuthService
356
+ function doSomething() {}
357
+ `);
358
+ const result = parseSmartComments('/backend/auth.js');
359
+ expect(result.entities).toHaveLength(1);
360
+ expect(result.entities[0].name).toBe('AuthService');
361
+ expect(result.entities[0].entityType).toBe('service');
362
+ });
363
+
364
+ it('extracts an @archsync:api directive', () => {
365
+ setFileContent(`
366
+ // @archsync:api POST /api/auth/login
367
+ router.post('/login', loginHandler);
368
+ `);
369
+ const result = parseSmartComments('/backend/routes/auth.js');
370
+ expect(result.entities).toHaveLength(1);
371
+ expect(result.entities[0].entityType).toBe('api');
372
+ expect(result.entities[0].data.method).toBe('POST');
373
+ expect(result.entities[0].data.path).toBe('/api/auth/login');
374
+ });
375
+
376
+ it('extracts a @archsync:model directive with a name', () => {
377
+ setFileContent(`
378
+ // @archsync:model UserProfile
379
+ class UserProfile {}
380
+ `);
381
+ const result = parseSmartComments('/backend/models/userProfile.js');
382
+ expect(result.entities).toHaveLength(1);
383
+ expect(result.entities[0].entityType).toBe('model');
384
+ expect(result.entities[0].name).toBe('UserProfile');
385
+ });
386
+
387
+ it('extracts @archsync:relation directive', () => {
388
+ setFileContent(`
389
+ // @archsync:relation AuthService -> UserModel queries
390
+ `);
391
+ const result = parseSmartComments('/backend/auth.js');
392
+ expect(result.relations).toHaveLength(1);
393
+ expect(result.relations[0].source).toBe('AuthService');
394
+ expect(result.relations[0].target).toBe('UserModel');
395
+ expect(result.relations[0].relation).toBe('queries');
396
+ });
397
+
398
+ it('uses "uses" as default relation when none is specified', () => {
399
+ setFileContent(`
400
+ // @archsync:relation ServiceA -> ServiceB
401
+ `);
402
+ const result = parseSmartComments('/backend/foo.js');
403
+ expect(result.relations[0].relation).toBe('uses');
404
+ });
405
+
406
+ it('handles @archsync:system directive and applies it to subsequent entities', () => {
407
+ setFileContent(`
408
+ // @archsync:system web
409
+ // @archsync:entity screen LandingPage
410
+ `);
411
+ const result = parseSmartComments('/web/landing.js');
412
+ expect(result.entities[0].system).toBe('web');
413
+ });
414
+
415
+ it('attaches @archsync:tag values to the current entity metadata', () => {
416
+ setFileContent(`
417
+ // @archsync:entity service NotificationService
418
+ // @archsync:tag notifications email push
419
+ `);
420
+ const result = parseSmartComments('/backend/notif.js');
421
+ expect(result.entities[0].metadata.tags).toContain('notifications');
422
+ expect(result.entities[0].metadata.tags).toContain('email');
423
+ expect(result.entities[0].metadata.tags).toContain('push');
424
+ });
425
+
426
+ it('attaches @archsync:description to the current entity', () => {
427
+ setFileContent(`
428
+ // @archsync:entity service PaymentService
429
+ // @archsync:description Handles Stripe payments
430
+ `);
431
+ const result = parseSmartComments('/backend/payment.js');
432
+ expect(result.entities[0].description).toBe('Handles Stripe payments');
433
+ });
434
+
435
+ it('returns empty entities and relations for @archsync:ignore', () => {
436
+ setFileContent(`
437
+ // @archsync:ignore
438
+ // @archsync:entity service ShouldNotAppear
439
+ `);
440
+ const result = parseSmartComments('/backend/ignored.js');
441
+ expect(result.entities).toHaveLength(0);
442
+ expect(result.relations).toHaveLength(0);
443
+ });
444
+
445
+ it('returns empty results for an empty file', () => {
446
+ setFileContent('');
447
+ const result = parseSmartComments('/backend/empty.js');
448
+ expect(result.entities).toHaveLength(0);
449
+ expect(result.relations).toHaveLength(0);
450
+ });
451
+
452
+ it('ignores lines that are not @archsync comments', () => {
453
+ setFileContent(`
454
+ // This is a regular comment
455
+ const x = 1;
456
+ /* Another comment */
457
+ `);
458
+ const result = parseSmartComments('/backend/plain.js');
459
+ expect(result.entities).toHaveLength(0);
460
+ });
461
+
462
+ it('extracts multiple entities from a single file', () => {
463
+ setFileContent(`
464
+ // @archsync:entity service UserService
465
+ // @archsync:entity service OrderService
466
+ // @archsync:entity model UserModel
467
+ `);
468
+ const result = parseSmartComments('/backend/multi.js');
469
+ expect(result.entities).toHaveLength(3);
470
+ });
471
+
472
+ it('records the source line number in entity metadata', () => {
473
+ setFileContent(`
474
+ // @archsync:entity service LineTracked
475
+ `);
476
+ const result = parseSmartComments('/backend/lineno.js');
477
+ expect(result.entities[0].metadata.sourceLine).toBe(2);
478
+ });
479
+
480
+ it('extracts @archsync:api with default path "/" when path is missing', () => {
481
+ setFileContent(`
482
+ // @archsync:api GET
483
+ `);
484
+ const result = parseSmartComments('/backend/route.js');
485
+ expect(result.entities[0].data.path).toBe('/');
486
+ });
487
+
488
+ it('sets scope via @archsync:scope directive on current entity', () => {
489
+ setFileContent(`
490
+ // @archsync:entity api PublicEndpoint
491
+ // @archsync:scope public
492
+ `);
493
+ const result = parseSmartComments('/backend/endpoints.js');
494
+ expect(result.entities[0].data.scope).toBe('public');
495
+ });
496
+ });