wogiflow 2.6.4 → 2.7.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/.claude/settings.json +0 -1
- package/lib/workspace-changelog.js +182 -0
- package/lib/workspace-channel-server.js +75 -2
- package/lib/workspace-contracts.js +151 -1
- package/lib/workspace-events.js +383 -0
- package/lib/workspace-gates.js +740 -0
- package/lib/workspace-integration-tests.js +299 -0
- package/lib/workspace-intelligence.js +486 -1
- package/lib/workspace-locks.js +371 -0
- package/lib/workspace-messages.js +203 -3
- package/lib/workspace-routing.js +144 -0
- package/package.json +1 -1
- package/scripts/flow-done-gates.js +70 -0
- package/.claude/rules/_internal/README.md +0 -64
- package/.claude/rules/_internal/document-structure.md +0 -77
- package/.claude/rules/_internal/dual-repo-management.md +0 -174
- package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
- package/.claude/rules/_internal/github-releases.md +0 -71
- package/.claude/rules/_internal/model-management.md +0 -35
- package/.claude/rules/_internal/self-maintenance.md +0 -87
- package/.claude/rules/architecture/component-reuse.md +0 -38
- package/.claude/rules/code-style/naming-conventions.md +0 -107
- package/.claude/rules/operations/git-workflows.md +0 -92
- package/.claude/rules/operations/scratch-directory.md +0 -54
- package/.claude/rules/security/security-patterns.md +0 -176
- package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
- package/.workflow/specs/architecture.md.template +0 -24
- package/.workflow/specs/stack.md.template +0 -33
- package/.workflow/specs/testing.md.template +0 -36
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Workspace — Conditional Gate Injection & Cross-Repo Awareness
|
|
5
|
+
*
|
|
6
|
+
* The backbone for workspace-aware development. Detects when workspace mode
|
|
7
|
+
* is active and provides:
|
|
8
|
+
* - workspaceActive() — detect if current cwd is inside a workspace
|
|
9
|
+
* - loadWorkspaceContext() — load manifest, config, integration map
|
|
10
|
+
* - analyzeTaskImpact() — check if a task touches cross-repo surfaces
|
|
11
|
+
* - getWorkspaceQualityGates() — return conditional gates for workspace mode
|
|
12
|
+
* - broadcastPostChange() — notify peers after task completion
|
|
13
|
+
* - runWorkspaceGate() — execute individual workspace quality gates
|
|
14
|
+
*
|
|
15
|
+
* All workspace features are conditional: zero overhead for single-repo projects.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const { WORKSPACE_CONFIG_FILE, WORKSPACE_DIR } = require('./workspace');
|
|
24
|
+
const { buildIntegrationMap } = require('./workspace-contracts');
|
|
25
|
+
const { detectContractDrift, getCascadeTargets, buildDependencyGraph } = require('./workspace-intelligence');
|
|
26
|
+
const { createMessage, saveMessage, getUnreadMessages } = require('./workspace-messages');
|
|
27
|
+
|
|
28
|
+
// ============================================================
|
|
29
|
+
// Constants
|
|
30
|
+
// ============================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Workspace quality gates — injected into flow-done when workspace is active.
|
|
34
|
+
* Each gate has: name, description, phase (pre|post), severity (error|warning).
|
|
35
|
+
*/
|
|
36
|
+
const WORKSPACE_GATES = [
|
|
37
|
+
{
|
|
38
|
+
name: 'crossRepoImpactCheck',
|
|
39
|
+
description: 'Verify cross-repo impact was assessed before implementation',
|
|
40
|
+
phase: 'pre',
|
|
41
|
+
severity: 'error'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'contractCompliance',
|
|
45
|
+
description: 'Verify changes comply with declared contracts',
|
|
46
|
+
phase: 'post',
|
|
47
|
+
severity: 'error'
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'peerNotification',
|
|
51
|
+
description: 'Notify affected peers of changes made',
|
|
52
|
+
phase: 'post',
|
|
53
|
+
severity: 'warning'
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'cascadeVerification',
|
|
57
|
+
description: 'Verify library changes notified all consumers',
|
|
58
|
+
phase: 'post',
|
|
59
|
+
severity: 'error'
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'integrationMapFreshness',
|
|
63
|
+
description: 'Verify integration map is up-to-date',
|
|
64
|
+
phase: 'pre',
|
|
65
|
+
severity: 'warning'
|
|
66
|
+
}
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Max age for integration map before staleness warning (24 hours). */
|
|
70
|
+
const MAP_FRESHNESS_MS = 24 * 60 * 60 * 1000;
|
|
71
|
+
|
|
72
|
+
/** Keywords indicating cross-repo surface area. */
|
|
73
|
+
const CROSS_REPO_SURFACE_KEYWORDS = [
|
|
74
|
+
'endpoint', 'api', 'route', 'contract', 'schema', 'type',
|
|
75
|
+
'interface', 'dto', 'model', 'event', 'message', 'webhook',
|
|
76
|
+
'shared', 'common', 'library', 'package'
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// ============================================================
|
|
80
|
+
// Workspace Detection
|
|
81
|
+
// ============================================================
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if workspace mode is active for the given directory.
|
|
85
|
+
* Walks up the directory tree looking for wogi-workspace.json.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} [cwd] — directory to check (default: process.cwd())
|
|
88
|
+
* @returns {{ active: boolean, root: string|null, configPath: string|null }}
|
|
89
|
+
*/
|
|
90
|
+
function workspaceActive(cwd) {
|
|
91
|
+
const startDir = cwd || process.cwd();
|
|
92
|
+
let dir = path.resolve(startDir);
|
|
93
|
+
const root = path.parse(dir).root;
|
|
94
|
+
|
|
95
|
+
// Walk up at most 5 levels to find workspace root
|
|
96
|
+
for (let i = 0; i < 5; i++) {
|
|
97
|
+
const configPath = path.join(dir, WORKSPACE_CONFIG_FILE);
|
|
98
|
+
if (fs.existsSync(configPath)) {
|
|
99
|
+
return { active: true, root: dir, configPath };
|
|
100
|
+
}
|
|
101
|
+
const parent = path.dirname(dir);
|
|
102
|
+
if (parent === dir || parent === root) break;
|
|
103
|
+
dir = parent;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Also check env variable (set by channel server)
|
|
107
|
+
// Validate that env root is an ancestor of cwd to prevent path injection
|
|
108
|
+
const envRoot = process.env.WOGI_WORKSPACE_ROOT;
|
|
109
|
+
if (envRoot) {
|
|
110
|
+
const resolvedEnv = path.resolve(envRoot);
|
|
111
|
+
const resolvedStart = path.resolve(startDir);
|
|
112
|
+
if (resolvedStart.startsWith(resolvedEnv + path.sep) || resolvedStart === resolvedEnv) {
|
|
113
|
+
const envConfig = path.join(resolvedEnv, WORKSPACE_CONFIG_FILE);
|
|
114
|
+
if (fs.existsSync(envConfig)) {
|
|
115
|
+
return { active: true, root: resolvedEnv, configPath: envConfig };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { active: false, root: null, configPath: null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Determine which member repo the current directory belongs to.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} workspaceRoot
|
|
127
|
+
* @param {string} [cwd]
|
|
128
|
+
* @returns {{ name: string, role: string, path: string }|null}
|
|
129
|
+
*/
|
|
130
|
+
function identifyCurrentMember(workspaceRoot, cwd) {
|
|
131
|
+
const currentDir = path.resolve(cwd || process.cwd());
|
|
132
|
+
const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_FILE);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
136
|
+
for (const [name, memberConfig] of Object.entries(config.members || {})) {
|
|
137
|
+
const memberPath = path.resolve(workspaceRoot, memberConfig.path);
|
|
138
|
+
if (currentDir === memberPath || currentDir.startsWith(memberPath + path.sep)) {
|
|
139
|
+
return { name, role: memberConfig.role, path: memberPath };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (_err) {
|
|
143
|
+
// Config read failure
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================================
|
|
150
|
+
// Context Loading
|
|
151
|
+
// ============================================================
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load the full workspace context needed for gate evaluation.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} workspaceRoot
|
|
157
|
+
* @returns {Object} workspace context
|
|
158
|
+
*/
|
|
159
|
+
function loadWorkspaceContext(workspaceRoot) {
|
|
160
|
+
const context = {
|
|
161
|
+
config: null,
|
|
162
|
+
manifest: null,
|
|
163
|
+
integrationMap: null,
|
|
164
|
+
currentMember: null,
|
|
165
|
+
unreadMessages: [],
|
|
166
|
+
health: null
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Load config
|
|
170
|
+
const configPath = path.join(workspaceRoot, WORKSPACE_CONFIG_FILE);
|
|
171
|
+
try {
|
|
172
|
+
context.config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
173
|
+
} catch (_err) {
|
|
174
|
+
return context;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Load manifest
|
|
178
|
+
const manifestPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'workspace-manifest.json');
|
|
179
|
+
try {
|
|
180
|
+
if (fs.existsSync(manifestPath)) {
|
|
181
|
+
context.manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
182
|
+
}
|
|
183
|
+
} catch (_err) {
|
|
184
|
+
// Non-critical
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Build integration map from manifest
|
|
188
|
+
if (context.manifest) {
|
|
189
|
+
try {
|
|
190
|
+
context.integrationMap = buildIntegrationMap(context.manifest);
|
|
191
|
+
} catch (_err) {
|
|
192
|
+
// Non-critical
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Identify current member
|
|
197
|
+
context.currentMember = identifyCurrentMember(workspaceRoot);
|
|
198
|
+
|
|
199
|
+
// Load unread messages for current member
|
|
200
|
+
if (context.currentMember) {
|
|
201
|
+
try {
|
|
202
|
+
context.unreadMessages = getUnreadMessages(workspaceRoot, context.currentMember.name);
|
|
203
|
+
} catch (_err) {
|
|
204
|
+
context.unreadMessages = [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return context;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================
|
|
212
|
+
// Task Impact Analysis
|
|
213
|
+
// ============================================================
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Analyze whether a task description touches cross-repo surfaces.
|
|
217
|
+
* Used by the pre-dev impact gate to determine if peers need to be notified.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} taskDescription — task title + criteria text
|
|
220
|
+
* @param {Object} context — from loadWorkspaceContext()
|
|
221
|
+
* @returns {Object} impact analysis
|
|
222
|
+
*/
|
|
223
|
+
function analyzeTaskImpact(taskDescription, context) {
|
|
224
|
+
const result = {
|
|
225
|
+
hasCrossRepoImpact: false,
|
|
226
|
+
affectedPeers: [],
|
|
227
|
+
affectedEndpoints: [],
|
|
228
|
+
affectedTypes: [],
|
|
229
|
+
surfaceKeywords: [],
|
|
230
|
+
recommendation: 'none' // 'none' | 'heads-up' | 'query-peers' | 'block-until-ack'
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (!context.manifest || !context.currentMember) return result;
|
|
234
|
+
|
|
235
|
+
const descLower = taskDescription.toLowerCase();
|
|
236
|
+
|
|
237
|
+
// 1. Check for cross-repo surface keywords
|
|
238
|
+
for (const kw of CROSS_REPO_SURFACE_KEYWORDS) {
|
|
239
|
+
if (descLower.includes(kw)) {
|
|
240
|
+
result.surfaceKeywords.push(kw);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 2. Check for endpoint mentions against integration map
|
|
245
|
+
if (context.integrationMap) {
|
|
246
|
+
for (const match of context.integrationMap.matched || []) {
|
|
247
|
+
// Check if the task mentions this endpoint
|
|
248
|
+
const epLower = match.endpoint.toLowerCase();
|
|
249
|
+
const pathSegments = epLower.split('/').filter(s => s && s !== 'api' && s !== 'v1');
|
|
250
|
+
for (const seg of pathSegments) {
|
|
251
|
+
if (seg.startsWith(':') || seg.startsWith('{')) continue;
|
|
252
|
+
if (descLower.includes(seg)) {
|
|
253
|
+
result.affectedEndpoints.push(match);
|
|
254
|
+
// Find peers that consume/provide this endpoint
|
|
255
|
+
const peers = [...(match.providers || []), ...(match.consumers || [])]
|
|
256
|
+
.filter(p => p !== context.currentMember.name);
|
|
257
|
+
for (const peer of peers) {
|
|
258
|
+
if (!result.affectedPeers.includes(peer)) {
|
|
259
|
+
result.affectedPeers.push(peer);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3. Check for type/schema mentions
|
|
269
|
+
if (context.manifest.members) {
|
|
270
|
+
const currentSchemas = context.manifest.members[context.currentMember.name]?.schemas || [];
|
|
271
|
+
for (const schema of currentSchemas) {
|
|
272
|
+
const schemaName = (schema.name || schema).toLowerCase();
|
|
273
|
+
if (descLower.includes(schemaName)) {
|
|
274
|
+
result.affectedTypes.push(schema);
|
|
275
|
+
// Types affect all repos that share this type
|
|
276
|
+
for (const [name, member] of Object.entries(context.manifest.members)) {
|
|
277
|
+
if (name === context.currentMember.name) continue;
|
|
278
|
+
const memberSchemas = (member.schemas || []).map(s => (s.name || s).toLowerCase());
|
|
279
|
+
if (memberSchemas.includes(schemaName)) {
|
|
280
|
+
if (!result.affectedPeers.includes(name)) {
|
|
281
|
+
result.affectedPeers.push(name);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 4. Library role = always affects consumers
|
|
290
|
+
if (context.currentMember.role === 'library') {
|
|
291
|
+
const graph = buildDependencyGraph(context.manifest);
|
|
292
|
+
const cascadeTargets = getCascadeTargets(context.currentMember.name, context.manifest, graph);
|
|
293
|
+
for (const target of cascadeTargets) {
|
|
294
|
+
if (!result.affectedPeers.includes(target)) {
|
|
295
|
+
result.affectedPeers.push(target);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 5. Determine impact level
|
|
301
|
+
result.hasCrossRepoImpact = result.affectedPeers.length > 0 || result.surfaceKeywords.length >= 2;
|
|
302
|
+
|
|
303
|
+
if (result.affectedEndpoints.length > 0 || result.affectedTypes.length > 0) {
|
|
304
|
+
result.recommendation = 'query-peers';
|
|
305
|
+
} else if (result.surfaceKeywords.length >= 2) {
|
|
306
|
+
result.recommendation = 'heads-up';
|
|
307
|
+
} else if (result.affectedPeers.length > 0) {
|
|
308
|
+
result.recommendation = 'heads-up';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Broadcast a pre-dev heads-up message to affected peers.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} workspaceRoot
|
|
318
|
+
* @param {string} fromRepo — current repo name
|
|
319
|
+
* @param {Object} impact — from analyzeTaskImpact()
|
|
320
|
+
* @param {string} taskTitle
|
|
321
|
+
* @returns {Array<string>} message IDs created
|
|
322
|
+
*/
|
|
323
|
+
function broadcastHeadsUp(workspaceRoot, fromRepo, impact, taskTitle) {
|
|
324
|
+
const messageIds = [];
|
|
325
|
+
|
|
326
|
+
for (const peer of impact.affectedPeers) {
|
|
327
|
+
const endpointList = impact.affectedEndpoints.map(e => e.endpoint).join(', ');
|
|
328
|
+
const typeList = impact.affectedTypes.map(t => t.name || t).join(', ');
|
|
329
|
+
|
|
330
|
+
let body = `I'm about to work on: "${taskTitle}"\n\n`;
|
|
331
|
+
if (endpointList) body += `Affected endpoints: ${endpointList}\n`;
|
|
332
|
+
if (typeList) body += `Affected types: ${typeList}\n`;
|
|
333
|
+
body += `\nDoes this affect your side? Any concerns or things I should be aware of?`;
|
|
334
|
+
|
|
335
|
+
const msg = createMessage({
|
|
336
|
+
from: fromRepo,
|
|
337
|
+
to: peer,
|
|
338
|
+
type: 'heads-up',
|
|
339
|
+
subject: `Pre-dev notice: ${taskTitle.substring(0, 60)}`,
|
|
340
|
+
body,
|
|
341
|
+
priority: impact.recommendation === 'query-peers' ? 'high' : 'medium',
|
|
342
|
+
actionRequired: impact.recommendation === 'query-peers'
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
saveMessage(workspaceRoot, msg);
|
|
347
|
+
messageIds.push(msg.id);
|
|
348
|
+
} catch (_err) {
|
|
349
|
+
// Best effort
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return messageIds;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ============================================================
|
|
357
|
+
// Post-Change Broadcast
|
|
358
|
+
// ============================================================
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* After task completion, detect changes and notify affected peers.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} workspaceRoot
|
|
364
|
+
* @param {string} fromRepo — repo that completed the task
|
|
365
|
+
* @param {Object} context — from loadWorkspaceContext()
|
|
366
|
+
* @param {Object} [options] — { changedFiles: string[], taskTitle: string }
|
|
367
|
+
* @returns {Object} broadcast result
|
|
368
|
+
*/
|
|
369
|
+
function broadcastPostChange(workspaceRoot, fromRepo, context, options = {}) {
|
|
370
|
+
const result = {
|
|
371
|
+
driftsDetected: [],
|
|
372
|
+
messagesCreated: [],
|
|
373
|
+
verificationTasksCreated: []
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (!context.manifest) return result;
|
|
377
|
+
|
|
378
|
+
// 1. Detect contract drift
|
|
379
|
+
try {
|
|
380
|
+
const drifts = detectContractDrift(workspaceRoot, context.manifest);
|
|
381
|
+
result.driftsDetected = drifts;
|
|
382
|
+
} catch (_err) {
|
|
383
|
+
// Non-critical
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 2. Check which endpoints/types changed by analyzing changed files
|
|
387
|
+
const { changedFiles = [], taskTitle = 'Unknown task' } = options;
|
|
388
|
+
|
|
389
|
+
// 3. Find affected repos via cascade analysis
|
|
390
|
+
const graph = buildDependencyGraph(context.manifest);
|
|
391
|
+
const cascadeTargets = getCascadeTargets(fromRepo, context.manifest, graph);
|
|
392
|
+
|
|
393
|
+
// 4. Send contract-change notifications
|
|
394
|
+
for (const target of cascadeTargets) {
|
|
395
|
+
const msg = createMessage({
|
|
396
|
+
from: fromRepo,
|
|
397
|
+
to: target,
|
|
398
|
+
type: 'contract-change',
|
|
399
|
+
subject: `Post-change notice: ${taskTitle.substring(0, 60)}`,
|
|
400
|
+
body: `Repo "${fromRepo}" completed: "${taskTitle}"\n\n` +
|
|
401
|
+
(changedFiles.length > 0 ? `Changed files:\n${changedFiles.map(f => ` - ${f}`).join('\n')}\n\n` : '') +
|
|
402
|
+
(result.driftsDetected.length > 0 ? `Contract drifts detected: ${result.driftsDetected.length}\n` : '') +
|
|
403
|
+
`Please verify your integrations still work correctly.`,
|
|
404
|
+
priority: result.driftsDetected.length > 0 ? 'critical' : 'high',
|
|
405
|
+
actionRequired: true,
|
|
406
|
+
suggestedTask: {
|
|
407
|
+
title: `Verify integrations after ${fromRepo} changes — ${taskTitle.substring(0, 40)}`,
|
|
408
|
+
type: 'fix',
|
|
409
|
+
priority: result.driftsDetected.length > 0 ? 'P0' : 'P1'
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
saveMessage(workspaceRoot, msg);
|
|
415
|
+
result.messagesCreated.push(msg.id);
|
|
416
|
+
} catch (_err) {
|
|
417
|
+
// Best effort
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ============================================================
|
|
425
|
+
// Quality Gate Runners
|
|
426
|
+
// ============================================================
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Run a specific workspace quality gate.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} gateName — one of WORKSPACE_GATES[].name
|
|
432
|
+
* @param {string} workspaceRoot
|
|
433
|
+
* @param {Object} context — from loadWorkspaceContext()
|
|
434
|
+
* @param {Object} [taskMeta] — { taskId, taskTitle, changedFiles, impactAssessed }
|
|
435
|
+
* @returns {{ passed: boolean, message: string, severity: string }}
|
|
436
|
+
*/
|
|
437
|
+
function runWorkspaceGate(gateName, workspaceRoot, context, taskMeta = {}) {
|
|
438
|
+
switch (gateName) {
|
|
439
|
+
case 'crossRepoImpactCheck':
|
|
440
|
+
return gateCrossRepoImpactCheck(context, taskMeta);
|
|
441
|
+
|
|
442
|
+
case 'contractCompliance':
|
|
443
|
+
return gateContractCompliance(workspaceRoot, context);
|
|
444
|
+
|
|
445
|
+
case 'peerNotification':
|
|
446
|
+
return gatePeerNotification(workspaceRoot, context, taskMeta);
|
|
447
|
+
|
|
448
|
+
case 'cascadeVerification':
|
|
449
|
+
return gateCascadeVerification(workspaceRoot, context, taskMeta);
|
|
450
|
+
|
|
451
|
+
case 'integrationMapFreshness':
|
|
452
|
+
return gateIntegrationMapFreshness(workspaceRoot);
|
|
453
|
+
|
|
454
|
+
default:
|
|
455
|
+
return { passed: true, message: `Unknown gate: ${gateName}`, severity: 'warning' };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Gate: crossRepoImpactCheck
|
|
461
|
+
* Verify that cross-repo impact was assessed before implementation.
|
|
462
|
+
*/
|
|
463
|
+
function gateCrossRepoImpactCheck(context, taskMeta) {
|
|
464
|
+
const gate = WORKSPACE_GATES.find(g => g.name === 'crossRepoImpactCheck');
|
|
465
|
+
|
|
466
|
+
if (taskMeta.impactAssessed) {
|
|
467
|
+
return { passed: true, message: 'Cross-repo impact was assessed', severity: gate.severity };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// If no impact analysis was done, check if the task even needs one
|
|
471
|
+
if (!context.currentMember) {
|
|
472
|
+
return { passed: true, message: 'Not in a workspace member repo', severity: gate.severity };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const impact = analyzeTaskImpact(taskMeta.taskTitle || '', context);
|
|
476
|
+
if (!impact.hasCrossRepoImpact) {
|
|
477
|
+
return { passed: true, message: 'No cross-repo impact detected', severity: gate.severity };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
passed: false,
|
|
482
|
+
message: `Cross-repo impact detected (${impact.affectedPeers.join(', ')}) but not assessed. Run impact analysis before implementation.`,
|
|
483
|
+
severity: gate.severity
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Gate: contractCompliance
|
|
489
|
+
* Verify changes comply with declared contracts.
|
|
490
|
+
*/
|
|
491
|
+
function gateContractCompliance(workspaceRoot, context) {
|
|
492
|
+
const gate = WORKSPACE_GATES.find(g => g.name === 'contractCompliance');
|
|
493
|
+
|
|
494
|
+
if (!context.manifest) {
|
|
495
|
+
return { passed: true, message: 'No manifest available', severity: gate.severity };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const drifts = detectContractDrift(workspaceRoot, context.manifest);
|
|
500
|
+
const highSeverity = drifts.filter(d => d.severity === 'high');
|
|
501
|
+
|
|
502
|
+
if (highSeverity.length > 0) {
|
|
503
|
+
return {
|
|
504
|
+
passed: false,
|
|
505
|
+
message: `${highSeverity.length} contract compliance issue(s): ${highSeverity.map(d => d.endpoint || d.type).join(', ')}`,
|
|
506
|
+
severity: gate.severity
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { passed: true, message: `Contract compliance OK (${drifts.length} info-level items)`, severity: gate.severity };
|
|
511
|
+
} catch (_err) {
|
|
512
|
+
return { passed: true, message: 'Contract check skipped (error reading contracts)', severity: 'warning' };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Gate: peerNotification
|
|
518
|
+
* Verify affected peers were notified of changes.
|
|
519
|
+
*/
|
|
520
|
+
function gatePeerNotification(workspaceRoot, context, taskMeta) {
|
|
521
|
+
const gate = WORKSPACE_GATES.find(g => g.name === 'peerNotification');
|
|
522
|
+
|
|
523
|
+
if (!context.currentMember || !context.manifest) {
|
|
524
|
+
return { passed: true, message: 'Not in workspace context', severity: gate.severity };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Check if the task had cross-repo impact
|
|
528
|
+
const impact = analyzeTaskImpact(taskMeta.taskTitle || '', context);
|
|
529
|
+
if (!impact.hasCrossRepoImpact) {
|
|
530
|
+
return { passed: true, message: 'No peers to notify', severity: gate.severity };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Check if notifications were sent (look for recent messages from this repo)
|
|
534
|
+
try {
|
|
535
|
+
const { readMessages } = require('./workspace-messages');
|
|
536
|
+
const recentMessages = readMessages(workspaceRoot, {
|
|
537
|
+
from: context.currentMember.name,
|
|
538
|
+
type: 'contract-change'
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Check if there's a recent notification (within last hour)
|
|
542
|
+
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
|
543
|
+
const recentNotifications = recentMessages.filter(
|
|
544
|
+
m => new Date(m.timestamp).getTime() > oneHourAgo
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
if (recentNotifications.length > 0) {
|
|
548
|
+
return { passed: true, message: `${recentNotifications.length} peer notification(s) sent`, severity: gate.severity };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
passed: false,
|
|
553
|
+
message: `Affected peers (${impact.affectedPeers.join(', ')}) were not notified of changes`,
|
|
554
|
+
severity: gate.severity
|
|
555
|
+
};
|
|
556
|
+
} catch (_err) {
|
|
557
|
+
return { passed: true, message: 'Notification check skipped', severity: 'warning' };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Gate: cascadeVerification
|
|
563
|
+
* For library repos, verify all consumers were notified.
|
|
564
|
+
*/
|
|
565
|
+
function gateCascadeVerification(workspaceRoot, context, taskMeta) {
|
|
566
|
+
const gate = WORKSPACE_GATES.find(g => g.name === 'cascadeVerification');
|
|
567
|
+
|
|
568
|
+
if (!context.currentMember || context.currentMember.role !== 'library') {
|
|
569
|
+
return { passed: true, message: 'Not a library repo — cascade check skipped', severity: gate.severity };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (!context.manifest) {
|
|
573
|
+
return { passed: true, message: 'No manifest available', severity: gate.severity };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const graph = buildDependencyGraph(context.manifest);
|
|
577
|
+
const consumers = getCascadeTargets(context.currentMember.name, context.manifest, graph);
|
|
578
|
+
|
|
579
|
+
if (consumers.length === 0) {
|
|
580
|
+
return { passed: true, message: 'No consumers to notify', severity: gate.severity };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Check that messages were sent to ALL consumers
|
|
584
|
+
try {
|
|
585
|
+
const { readMessages } = require('./workspace-messages');
|
|
586
|
+
const recentMessages = readMessages(workspaceRoot, {
|
|
587
|
+
from: context.currentMember.name
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
|
591
|
+
const notifiedPeers = new Set(
|
|
592
|
+
recentMessages
|
|
593
|
+
.filter(m => new Date(m.timestamp).getTime() > oneHourAgo)
|
|
594
|
+
.map(m => m.to)
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const unnotified = consumers.filter(c => !notifiedPeers.has(c));
|
|
598
|
+
|
|
599
|
+
if (unnotified.length === 0) {
|
|
600
|
+
return { passed: true, message: `All ${consumers.length} consumer(s) notified`, severity: gate.severity };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
passed: false,
|
|
605
|
+
message: `Library change: ${unnotified.length} consumer(s) not notified: ${unnotified.join(', ')}`,
|
|
606
|
+
severity: gate.severity
|
|
607
|
+
};
|
|
608
|
+
} catch (_err) {
|
|
609
|
+
return { passed: true, message: 'Cascade check skipped (error)', severity: 'warning' };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Gate: integrationMapFreshness
|
|
615
|
+
* Verify the integration map is not stale.
|
|
616
|
+
*/
|
|
617
|
+
function gateIntegrationMapFreshness(workspaceRoot) {
|
|
618
|
+
const gate = WORKSPACE_GATES.find(g => g.name === 'integrationMapFreshness');
|
|
619
|
+
const mapPath = path.join(workspaceRoot, WORKSPACE_DIR, 'state', 'integration-map.md');
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
if (!fs.existsSync(mapPath)) {
|
|
623
|
+
return {
|
|
624
|
+
passed: false,
|
|
625
|
+
message: 'Integration map does not exist. Run `flow workspace sync`.',
|
|
626
|
+
severity: gate.severity
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const stat = fs.statSync(mapPath);
|
|
631
|
+
const age = Date.now() - stat.mtime.getTime();
|
|
632
|
+
|
|
633
|
+
if (age > MAP_FRESHNESS_MS) {
|
|
634
|
+
const hours = Math.round(age / (60 * 60 * 1000));
|
|
635
|
+
return {
|
|
636
|
+
passed: false,
|
|
637
|
+
message: `Integration map is ${hours}h old (threshold: 24h). Run \`flow workspace sync\`.`,
|
|
638
|
+
severity: gate.severity
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return { passed: true, message: 'Integration map is fresh', severity: gate.severity };
|
|
643
|
+
} catch (_err) {
|
|
644
|
+
return { passed: true, message: 'Freshness check skipped', severity: 'warning' };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ============================================================
|
|
649
|
+
// Gate List & Injection
|
|
650
|
+
// ============================================================
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Get the list of workspace quality gates that should be injected
|
|
654
|
+
* for the current task type.
|
|
655
|
+
*
|
|
656
|
+
* @param {string} taskType — 'feature', 'bugfix', 'refactor', etc.
|
|
657
|
+
* @param {Object} context — from loadWorkspaceContext()
|
|
658
|
+
* @returns {Array<Object>} applicable gates
|
|
659
|
+
*/
|
|
660
|
+
function getWorkspaceQualityGates(taskType, context) {
|
|
661
|
+
if (!context.currentMember) return [];
|
|
662
|
+
|
|
663
|
+
// All gates apply to features and refactors
|
|
664
|
+
if (['feature', 'refactor', 'story'].includes(taskType)) {
|
|
665
|
+
return [...WORKSPACE_GATES];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Bugfixes: only check contract compliance and notification
|
|
669
|
+
if (taskType === 'bugfix' || taskType === 'fix') {
|
|
670
|
+
return WORKSPACE_GATES.filter(g =>
|
|
671
|
+
['contractCompliance', 'peerNotification'].includes(g.name)
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Chores/docs: only freshness check
|
|
676
|
+
if (['chore', 'docs'].includes(taskType)) {
|
|
677
|
+
return WORKSPACE_GATES.filter(g => g.name === 'integrationMapFreshness');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Default: all gates
|
|
681
|
+
return [...WORKSPACE_GATES];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Run all applicable workspace gates and return consolidated results.
|
|
686
|
+
*
|
|
687
|
+
* @param {string} workspaceRoot
|
|
688
|
+
* @param {Object} context
|
|
689
|
+
* @param {Object} taskMeta — { taskId, taskTitle, taskType, changedFiles, impactAssessed }
|
|
690
|
+
* @returns {{ passed: boolean, results: Array<Object>, errors: number, warnings: number }}
|
|
691
|
+
*/
|
|
692
|
+
function runAllWorkspaceGates(workspaceRoot, context, taskMeta = {}) {
|
|
693
|
+
const gates = getWorkspaceQualityGates(taskMeta.taskType || 'feature', context);
|
|
694
|
+
const results = [];
|
|
695
|
+
let errors = 0;
|
|
696
|
+
let warnings = 0;
|
|
697
|
+
|
|
698
|
+
for (const gate of gates) {
|
|
699
|
+
const result = runWorkspaceGate(gate.name, workspaceRoot, context, taskMeta);
|
|
700
|
+
results.push({ gate: gate.name, ...result });
|
|
701
|
+
|
|
702
|
+
if (!result.passed) {
|
|
703
|
+
if (result.severity === 'error') errors++;
|
|
704
|
+
else warnings++;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
passed: errors === 0,
|
|
710
|
+
results,
|
|
711
|
+
errors,
|
|
712
|
+
warnings
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ============================================================
|
|
717
|
+
// Exports
|
|
718
|
+
// ============================================================
|
|
719
|
+
|
|
720
|
+
module.exports = {
|
|
721
|
+
// Detection
|
|
722
|
+
workspaceActive,
|
|
723
|
+
identifyCurrentMember,
|
|
724
|
+
|
|
725
|
+
// Context
|
|
726
|
+
loadWorkspaceContext,
|
|
727
|
+
|
|
728
|
+
// Impact analysis
|
|
729
|
+
analyzeTaskImpact,
|
|
730
|
+
broadcastHeadsUp,
|
|
731
|
+
|
|
732
|
+
// Post-change
|
|
733
|
+
broadcastPostChange,
|
|
734
|
+
|
|
735
|
+
// Quality gates
|
|
736
|
+
WORKSPACE_GATES,
|
|
737
|
+
getWorkspaceQualityGates,
|
|
738
|
+
runWorkspaceGate,
|
|
739
|
+
runAllWorkspaceGates
|
|
740
|
+
};
|