archsync 1.0.0 → 1.0.2
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/README.md +67 -0
- package/dist/archsync.cjs +2 -0
- package/package.json +11 -7
- package/bin/cli.js +0 -91
- package/src/__tests__/e2e-workflow.test.js +0 -66
- package/src/__tests__/hashEngine.test.js +0 -109
- package/src/__tests__/impact.test.js +0 -137
- package/src/__tests__/parsers.test.js +0 -496
- package/src/__tests__/scan-pipeline.test.js +0 -332
- package/src/__tests__/schemaBuilder.test.js +0 -145
- package/src/__tests__/workspace.test.js +0 -178
- package/src/commands/backup.js +0 -54
- package/src/commands/connect.js +0 -129
- package/src/commands/diff.js +0 -228
- package/src/commands/export.js +0 -125
- package/src/commands/impactReport.js +0 -50
- package/src/commands/import.js +0 -126
- package/src/commands/init.js +0 -80
- package/src/commands/login.js +0 -116
- package/src/commands/plugin.js +0 -28
- package/src/commands/push.js +0 -194
- package/src/commands/register.js +0 -127
- package/src/commands/scan.js +0 -498
- package/src/commands/serve.js +0 -133
- package/src/commands/setup.js +0 -233
- package/src/commands/status.js +0 -56
- package/src/commands/validate.js +0 -245
- package/src/commands/watch.js +0 -70
- package/src/core/credentialStore.js +0 -76
- package/src/core/hashEngine.js +0 -34
- package/src/core/impactEngine.js +0 -192
- package/src/core/monorepoDetector.js +0 -41
- package/src/core/pluginManager.js +0 -40
- package/src/core/relationshipEngine.js +0 -917
- package/src/core/requestSigning.js +0 -16
- package/src/core/schemaBuilder.js +0 -230
- package/src/core/schemaDeduplicator.js +0 -54
- package/src/core/supabaseClient.js +0 -68
- package/src/core/workspaceDetector.js +0 -113
- package/src/parsers/astParser.js +0 -274
- package/src/parsers/configParser.js +0 -49
- package/src/parsers/dependencyGraph.js +0 -31
- package/src/parsers/flutterParser.js +0 -98
- package/src/parsers/goParser.js +0 -99
- package/src/parsers/index.js +0 -211
- package/src/parsers/javaParser.js +0 -89
- package/src/parsers/nodeParser.js +0 -429
- package/src/parsers/pythonParser.js +0 -109
- package/src/parsers/reactParser.js +0 -368
- package/src/parsers/smartComment.js +0 -144
|
@@ -1,16 +0,0 @@
|
|
|
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 };
|
|
@@ -1,230 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
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 };
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
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
|
-
];
|