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,16 @@
1
+ // Request Signing for CLI - Task 77
2
+ const crypto = require('crypto');
3
+
4
+ function signRequest(body, apiKey) {
5
+ const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
6
+ return crypto.createHmac('sha256', apiKey).update(bodyStr).digest('hex');
7
+ }
8
+
9
+ function verifySignature(body, signature, apiKey) {
10
+ const expected = signRequest(body, apiKey);
11
+ try {
12
+ return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
13
+ } catch { return false; }
14
+ }
15
+
16
+ module.exports = { signRequest, verifySignature };
@@ -0,0 +1,230 @@
1
+ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
2
+
3
+ // Stable namespace UUID for ArchSync CLI node identity
4
+ const ARCHSYNC_NS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // UUID namespace (URL)
5
+
6
+ // Generate a deterministic UUID from an entity's unique identity key.
7
+ // Same entityType+name+system always → same UUID, across all scans.
8
+ function stableId(entityType, name, system) {
9
+ return uuidv5(`${entityType}:${name}:${system}`, ARCHSYNC_NS);
10
+ }
11
+
12
+ function stableEdgeId(sourceId, targetId, relation) {
13
+ return uuidv5(`${sourceId}→${targetId}:${relation}`, ARCHSYNC_NS);
14
+ }
15
+
16
+ // Build a unified schema from parsed file results.
17
+ // `extraRelations` carries cross-file relations from the relationship engine
18
+ // (route→controller, frontend→API, …) that must be resolved against the same
19
+ // node identity map as the per-file parser relations.
20
+ // Entity types whose identity is their NAME across the whole system —
21
+ // "GET /api/users" referenced from five frontend files must stay one node.
22
+ // Everything else is file-local: two files can each define `main` or `Button`
23
+ // and they must NOT merge into a single node.
24
+ const MERGE_BY_NAME_TYPES = new Set(['api', 'route', 'mount', 'database']);
25
+
26
+ export function buildSchema(parsedFiles, config = {}, extraRelations = []) {
27
+ const nodes = [];
28
+ const edges = [];
29
+ const nodeMap = new Map(); // identity key → node
30
+ const nameIndex = new Map(); // "type:name" → node (system-agnostic fallback)
31
+ const nameOnlyIndex = new Map();// "name" → node (last-resort fallback — a
32
+ // relation's guessed entityType may be wrong,
33
+ // e.g. an import of a widget typed as 'screen')
34
+ const fileIndex = new Map(); // "type:name:file" → node (exact resolution)
35
+
36
+ const identityOf = (entityType, name, system, sourceFile) =>
37
+ MERGE_BY_NAME_TYPES.has(entityType)
38
+ ? stableId(entityType, name, system)
39
+ : stableId(entityType, `${name}@${sourceFile || ''}`, system);
40
+
41
+ for (const fileResult of parsedFiles) {
42
+ for (const entity of fileResult.entities || []) {
43
+ const entitySystem = entity.system || config.defaultSystem || 'backend';
44
+ const entityName = entity.name || entity.text || `Unnamed ${entity.entityType}`;
45
+ const key = identityOf(entity.entityType, entityName, entitySystem, fileResult.filePath);
46
+
47
+ if (nodeMap.has(key)) {
48
+ // Merge data into existing node
49
+ const existing = nodeMap.get(key);
50
+ existing.data = { ...existing.data, ...entity.data };
51
+ if (entity.metadata) {
52
+ existing.metadata = { ...existing.metadata, ...entity.metadata };
53
+ if (entity.metadata.tags) {
54
+ existing.metadata.tags = [...new Set([...(existing.metadata.tags || []), ...entity.metadata.tags])];
55
+ }
56
+ }
57
+ continue;
58
+ }
59
+
60
+ const node = {
61
+ id: key,
62
+ entityType: entity.entityType,
63
+ system: entitySystem,
64
+ text: entityName,
65
+ x: 100 + nodes.length * 180,
66
+ y: 100 + (nodes.length % 5) * 120,
67
+ w: entity.w || 160,
68
+ h: entity.h || 60,
69
+ style: { shape: 'rounded', bgId: 'default', textId: 'default' },
70
+ data: entity.data || {},
71
+ metadata: {
72
+ description: entity.description || '',
73
+ tags: entity.tags || [],
74
+ sourceFile: fileResult.filePath,
75
+ parsedAt: new Date().toISOString(),
76
+ ...entity.metadata,
77
+ },
78
+ isManual: false,
79
+ };
80
+
81
+ nodes.push(node);
82
+ nodeMap.set(key, node);
83
+ const nameKey = `${entity.entityType}:${entityName}`;
84
+ if (!nameIndex.has(nameKey)) nameIndex.set(nameKey, node);
85
+ if (!nameOnlyIndex.has(entityName)) nameOnlyIndex.set(entityName, node);
86
+ fileIndex.set(`${entity.entityType}:${entityName}:${fileResult.filePath}`, node);
87
+ }
88
+ }
89
+
90
+ // ─── Resolve relations → edges ─────────────────────────────
91
+ // Relations reference entities by (type, name, system). The system on a
92
+ // relation is best-effort (parsers/engine may not know it), so fall back
93
+ // to a name-only lookup before giving up.
94
+ const resolveNode = (entityType, name, system, sourceFile) => {
95
+ if (!name) return null;
96
+ // Exact: (type, name, file) — relations from the import-graph pass
97
+ // carry source files, which disambiguates same-named entities
98
+ // (two `main` modules, two `index` files, …).
99
+ if (entityType && sourceFile) {
100
+ const scoped = fileIndex.get(`${entityType}:${name}:${sourceFile}`);
101
+ if (scoped) return scoped;
102
+ }
103
+ if (entityType) {
104
+ const exact = nodeMap.get(identityOf(entityType, name, system || config.defaultSystem || 'backend', sourceFile))
105
+ || nameIndex.get(`${entityType}:${name}`);
106
+ if (exact) return exact;
107
+ }
108
+ // Last resort: match by name alone. Relations often carry a guessed
109
+ // entityType (imports default to 'screen') — without this fallback the
110
+ // edge is silently dropped and the canvas shows no arrow.
111
+ return nameOnlyIndex.get(name) || null;
112
+ };
113
+
114
+ const allRelations = [
115
+ ...parsedFiles.flatMap(f => f.relations || []),
116
+ ...extraRelations,
117
+ ];
118
+
119
+ const seenEdges = new Set();
120
+ for (const rel of allRelations) {
121
+ const sourceNode = resolveNode(rel.sourceType, rel.source, rel.sourceSystem, rel.sourceFile);
122
+ let targetNode = resolveNode(rel.targetType, rel.target, rel.targetSystem, rel.targetFile);
123
+
124
+ // Database targets ("Database", "collection:users", model names) are
125
+ // often referenced by service code without a matching parsed entity —
126
+ // materialise them so the canvas shows the storage layer.
127
+ if (sourceNode && !targetNode && ['database', 'model'].includes(rel.targetType)) {
128
+ const dbSystem = rel.targetSystem || sourceNode.system;
129
+ const id = stableId(rel.targetType, rel.target, dbSystem);
130
+ targetNode = {
131
+ id,
132
+ entityType: rel.targetType,
133
+ system: dbSystem,
134
+ text: rel.target.replace(/^collection:/, ''),
135
+ x: 100 + nodes.length * 180,
136
+ y: 100 + (nodes.length % 5) * 120,
137
+ w: 160,
138
+ h: 60,
139
+ style: { shape: 'cylinder', bgId: 'default', textId: 'default' },
140
+ data: { synthesised: true },
141
+ metadata: { description: '', tags: [], parsedAt: new Date().toISOString() },
142
+ isManual: false,
143
+ };
144
+ nodes.push(targetNode);
145
+ nodeMap.set(id, targetNode);
146
+ nameIndex.set(`${rel.targetType}:${rel.target}`, targetNode);
147
+ }
148
+
149
+ if (!sourceNode || !targetNode || sourceNode.id === targetNode.id) continue;
150
+
151
+ const relation = rel.relation || 'uses';
152
+ const edgeId = stableEdgeId(sourceNode.id, targetNode.id, relation);
153
+ if (seenEdges.has(edgeId)) continue;
154
+ seenEdges.add(edgeId);
155
+ edges.push({
156
+ id: edgeId,
157
+ source: sourceNode.id,
158
+ target: targetNode.id,
159
+ relation,
160
+ isCrossSystem: sourceNode.system !== targetNode.system,
161
+ });
162
+ }
163
+
164
+ const systems = [...new Set(nodes.map(n => n.system))];
165
+
166
+ return {
167
+ // File-level `system` is authoritative for the cross-system merger.
168
+ // We use the config's defaultSystem (set by `init --framework` or
169
+ // hand-edited in `.archsync.json`) so the canvas's merger can tag
170
+ // every node with the correct origin (backend / mobile / web /
171
+ // admin) without the user having to hand-edit the output.
172
+ system: config.defaultSystem || 'backend',
173
+ // Workspace scans contain several systems in one file; the canvas
174
+ // merger honours per-node `system` when this flag is set.
175
+ multiSystem: systems.length > 1,
176
+ systems,
177
+ nodes,
178
+ edges,
179
+ meta: {
180
+ generatedAt: new Date().toISOString(),
181
+ source: 'archsync-cli',
182
+ framework: config.framework || null,
183
+ fileCount: parsedFiles.length,
184
+ nodeCount: nodes.length,
185
+ edgeCount: edges.length,
186
+ },
187
+ };
188
+ }
189
+
190
+ // Compute simple diff between two schemas
191
+ export function diffSchemas(base, incoming) {
192
+ const diff = { added: [], modified: [], deleted: [], unchanged: [] };
193
+
194
+ if (!base && !incoming) return diff;
195
+ if (!base) {
196
+ diff.added = (incoming.nodes || []).map(n => ({ ...n, status: 'added' }));
197
+ return diff;
198
+ }
199
+ if (!incoming) {
200
+ diff.deleted = (base.nodes || []).map(n => ({ ...n, status: 'deleted' }));
201
+ return diff;
202
+ }
203
+
204
+ const baseMap = new Map((base.nodes || []).map(n => [n.id, n]));
205
+ const incomingMap = new Map((incoming.nodes || []).map(n => [n.id, n]));
206
+
207
+ for (const [id, node] of incomingMap) {
208
+ if (!baseMap.has(id)) {
209
+ diff.added.push({ ...node, status: 'added' });
210
+ } else {
211
+ const baseNode = baseMap.get(id);
212
+ const changed = JSON.stringify(baseNode.data) !== JSON.stringify(node.data) ||
213
+ baseNode.text !== node.text ||
214
+ baseNode.entityType !== node.entityType;
215
+ if (changed) {
216
+ diff.modified.push({ ...node, status: 'modified', previous: baseNode });
217
+ } else {
218
+ diff.unchanged.push({ ...node, status: 'unchanged' });
219
+ }
220
+ }
221
+ }
222
+
223
+ for (const [id, node] of baseMap) {
224
+ if (!incomingMap.has(id)) {
225
+ diff.deleted.push({ ...node, status: 'deleted' });
226
+ }
227
+ }
228
+
229
+ return diff;
230
+ }
@@ -0,0 +1,54 @@
1
+ // Schema Normalization & Deduplication - Task 121
2
+ 'use strict';
3
+
4
+ function normalizeEntityName(name) {
5
+ return name
6
+ .replace(/\.(jsx?|tsx?|py|go|java|rb|php)$/i, '') // strip extensions
7
+ .replace(/^.*[/\\]/, '') // strip path prefixes
8
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // PascalCase → words
9
+ .trim();
10
+ }
11
+
12
+ function deduplicateNodes(nodes) {
13
+ const seen = new Map(); // normalizedName → first node
14
+ const idMap = new Map(); // old id → canonical id
15
+
16
+ for (const node of nodes) {
17
+ const normalized = normalizeEntityName(node.name || node.id);
18
+ if (seen.has(normalized)) {
19
+ idMap.set(node.id, seen.get(normalized).id);
20
+ } else {
21
+ seen.set(normalized, node);
22
+ idMap.set(node.id, node.id);
23
+ }
24
+ }
25
+
26
+ return {
27
+ nodes: [...seen.values()],
28
+ idMap,
29
+ };
30
+ }
31
+
32
+ function remapEdges(edges, idMap) {
33
+ const seen = new Set();
34
+ return edges.reduce((acc, edge) => {
35
+ const source = idMap.get(edge.source) || edge.source;
36
+ const target = idMap.get(edge.target) || edge.target;
37
+ if (source === target) return acc; // skip self-loops from merging
38
+ const key = `${source}→${target}`;
39
+ if (seen.has(key)) return acc;
40
+ seen.add(key);
41
+ acc.push({ ...edge, source, target });
42
+ return acc;
43
+ }, []);
44
+ }
45
+
46
+ function generateDedupReport(originalNodes, deduplicatedNodes) {
47
+ return {
48
+ original: originalNodes.length,
49
+ merged: originalNodes.length - deduplicatedNodes.length,
50
+ final: deduplicatedNodes.length,
51
+ };
52
+ }
53
+
54
+ module.exports = { normalizeEntityName, deduplicateNodes, remapEdges, generateDedupReport };
@@ -0,0 +1,68 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createClient } from '@supabase/supabase-js';
4
+
5
+ const CONFIG_FILE = '.archsync.json';
6
+
7
+ // Read local config
8
+ export function readConfig(dir = '.') {
9
+ const configPath = path.resolve(dir, CONFIG_FILE);
10
+ if (!fs.existsSync(configPath)) return null;
11
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
12
+ }
13
+
14
+ // Write local config
15
+ export function writeConfig(config, dir = '.') {
16
+ const configPath = path.resolve(dir, CONFIG_FILE);
17
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
18
+ }
19
+
20
+ // Get authenticated Supabase client from config
21
+ export function getSupabaseClient(config) {
22
+ if (!config?.supabase?.url || !config?.supabase?.anonKey) {
23
+ throw new Error('Supabase not configured. Run `archsync init` first.');
24
+ }
25
+ return createClient(config.supabase.url, config.supabase.anonKey);
26
+ }
27
+
28
+ // Push schema commit
29
+ export async function pushSchemaCommit(client, projectId, branch, schemaJson, message, source = 'cli') {
30
+ const { data, error } = await client.from('schema_commits').insert({
31
+ project_id: projectId,
32
+ branch,
33
+ schema_json: schemaJson,
34
+ commit_message: message || `CLI push at ${new Date().toISOString()}`,
35
+ source,
36
+ }).select().single();
37
+
38
+ if (error) throw error;
39
+ return data;
40
+ }
41
+
42
+ // Fetch latest schema for branch
43
+ export async function fetchLatestSchema(client, projectId, branch = 'main') {
44
+ const { data, error } = await client
45
+ .from('schema_commits')
46
+ .select('*')
47
+ .eq('project_id', projectId)
48
+ .eq('branch', branch)
49
+ .order('created_at', { ascending: false })
50
+ .limit(1)
51
+ .single();
52
+
53
+ if (error && error.code !== 'PGRST116') throw error;
54
+ return data?.schema_json || null;
55
+ }
56
+
57
+ // Push diff record
58
+ export async function pushSchemaDiff(client, projectId, baseCommitId, incomingCommitId, diffJson) {
59
+ const { data, error } = await client.from('schema_diffs').insert({
60
+ project_id: projectId,
61
+ base_commit_id: baseCommitId,
62
+ incoming_commit_id: incomingCommitId,
63
+ diff_json: diffJson,
64
+ }).select().single();
65
+
66
+ if (error) throw error;
67
+ return data;
68
+ }
@@ -0,0 +1,113 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Workspace Detector — finds independent projects (Node backend, Flutter
6
+ * app, React admin, …) living side-by-side under one parent directory so a
7
+ * single `archsync scan --workspace` can scan and cross-link all of them.
8
+ */
9
+
10
+ const SKIP_DIRS = new Set([
11
+ 'node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.turbo',
12
+ '.cache', '.dart_tool', '.idea', '.vscode', 'ios', 'android', 'windows',
13
+ 'linux', 'macos', '.archsync',
14
+ ]);
15
+
16
+ const BACKEND_FRAMEWORKS = new Set(['node', 'express', 'nestjs']);
17
+
18
+ /** Detect the framework of a single project directory, or null if it isn't one. */
19
+ export function detectProjectFramework(dir) {
20
+ if (fs.existsSync(path.join(dir, 'pubspec.yaml'))) return 'flutter';
21
+ if (fs.existsSync(path.join(dir, 'next.config.js')) || fs.existsSync(path.join(dir, 'next.config.mjs'))) return 'nextjs';
22
+
23
+ const pkgPath = path.join(dir, 'package.json');
24
+ if (!fs.existsSync(pkgPath)) return null;
25
+ try {
26
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
27
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
28
+ if (deps['react'] || deps['react-dom']) return 'react';
29
+ if (deps['@nestjs/core']) return 'nestjs';
30
+ if (deps['express'] || deps['fastify']) return 'express';
31
+ return 'node';
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Sanitise a directory name into a canvas-friendly system label. */
38
+ function toSystemLabel(dirName) {
39
+ return dirName.toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'system';
40
+ }
41
+
42
+ /**
43
+ * Scan the immediate children of `rootDir` (plus the root itself) for
44
+ * projects. Backend projects get the canonical 'backend' system label so the
45
+ * canvas's cross-system inference recognises them; frontends keep their
46
+ * directory name (e.g. mobile_app, admin) — anything not named
47
+ * backend/server/api is treated as a frontend by the merger.
48
+ *
49
+ * @returns {Array<{ name, path, framework, system }>}
50
+ */
51
+ export function detectWorkspaceProjects(rootDir) {
52
+ const candidates = [];
53
+
54
+ let entries = [];
55
+ try {
56
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
57
+ } catch {
58
+ return [];
59
+ }
60
+
61
+ for (const entry of entries) {
62
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
63
+ const full = path.join(rootDir, entry.name);
64
+ const framework = detectProjectFramework(full);
65
+ if (framework) candidates.push({ name: entry.name, path: full, framework });
66
+ }
67
+
68
+ // The root itself can be a project (e.g. backend at root, app/ and admin/
69
+ // nested inside) — but a bare workspace manifest doesn't count.
70
+ const rootFramework = detectProjectFramework(rootDir);
71
+ if (rootFramework) {
72
+ let isWorkspaceManifest = false;
73
+ try {
74
+ const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'));
75
+ isWorkspaceManifest = !!pkg.workspaces;
76
+ } catch { /* pubspec or unreadable — treat as a real project */ }
77
+ if (!isWorkspaceManifest) {
78
+ candidates.unshift({ name: path.basename(rootDir), path: rootDir, framework: rootFramework });
79
+ }
80
+ }
81
+
82
+ // Assign system labels: first backend project is 'backend'; everything
83
+ // else (and additional backends) uses its directory name.
84
+ const used = new Set();
85
+ return candidates.map(c => {
86
+ let system = BACKEND_FRAMEWORKS.has(c.framework) ? 'backend' : toSystemLabel(c.name);
87
+ if (used.has(system)) system = toSystemLabel(c.name);
88
+ let suffix = 2;
89
+ const base = system;
90
+ while (used.has(system)) system = `${base}_${suffix++}`;
91
+ used.add(system);
92
+ return { ...c, system };
93
+ });
94
+ }
95
+
96
+ /** Per-framework glob patterns so a Flutter project doesn't drag in build JS, etc. */
97
+ export function includePatternsFor(framework) {
98
+ switch (framework) {
99
+ case 'flutter': return ['lib/**/*.dart'];
100
+ case 'react':
101
+ case 'nextjs': return ['**/*.{js,jsx,ts,tsx}'];
102
+ // Backend projects come in every language — scan them all; the
103
+ // parser registry routes each extension to the right parser.
104
+ default: return ['**/*.{js,ts,py,go,java,kt}'];
105
+ }
106
+ }
107
+
108
+ export const WORKSPACE_EXCLUDES = [
109
+ '**/node_modules/**', '**/build/**', '**/dist/**', '**/.dart_tool/**',
110
+ '**/ios/**', '**/android/**', '**/windows/**', '**/linux/**', '**/macos/**',
111
+ '**/.next/**', '**/coverage/**', '**/*.test.*', '**/*.spec.*',
112
+ '**/test/**', '**/tests/**', '**/__tests__/**',
113
+ ];