gemstack-ai 1.4.0 → 2.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 (59) hide show
  1. package/.agents/rules/03-gemstack-security.md +2 -2
  2. package/.gemstack/state.json +10 -11
  3. package/CHANGELOG.md +33 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +47 -13
  6. package/RELEASE_NOTES.md +40 -1
  7. package/handoff.md +33 -19
  8. package/package.json +4 -3
  9. package/scripts/ci/check-package-contents.js +1 -1
  10. package/scripts/ci/check-secrets.js +84 -0
  11. package/specs/011-gemstack-2.0-hardening/.gemstack.json +5 -0
  12. package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
  13. package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
  14. package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
  15. package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
  16. package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
  17. package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
  18. package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
  19. package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
  20. package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
  21. package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
  22. package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
  23. package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
  24. package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
  25. package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
  26. package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
  27. package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
  28. package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
  29. package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
  30. package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
  31. package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
  32. package/src/cli.js +3 -0
  33. package/src/commands/doctor.js +18 -0
  34. package/src/commands/hooks.js +98 -14
  35. package/src/commands/init.js +1 -1
  36. package/src/commands/install.js +174 -49
  37. package/src/commands/spec.js +105 -0
  38. package/src/commands/update.js +1 -1
  39. package/src/commands/verify.js +10 -0
  40. package/src/lib/backup.js +3 -3
  41. package/src/lib/context-fatigue.js +165 -0
  42. package/src/lib/contract-amendments.js +109 -0
  43. package/src/lib/dependency-audit.js +202 -0
  44. package/src/lib/filesystem-safe.js +85 -15
  45. package/src/lib/memory-audit.js +121 -0
  46. package/src/lib/provider-boundary.js +5 -1
  47. package/src/lib/provider-registry.js +6 -4
  48. package/src/lib/safety-gates.js +176 -8
  49. package/src/lib/sdd-rigor.js +181 -0
  50. package/src/lib/spec-delta.js +194 -0
  51. package/src/lib/spec-merge.js +168 -0
  52. package/src/lib/swarm.js +2 -2
  53. package/src/lib/visual-qa.js +162 -9
  54. package/template/.agents/rules/03-gemstack-security.md +2 -2
  55. package/.github/workflows/main-ci.yml +0 -32
  56. package/.github/workflows/pr-ci.yml +0 -31
  57. package/.github/workflows/publish.yml +0 -52
  58. package/.github/workflows/release-readiness.yml +0 -43
  59. package/gemstack-ai-1.4.0.tgz +0 -0
@@ -34,13 +34,17 @@ function normalizeProviderId(rawId) {
34
34
  * @param {object} [tokenPayload={}]
35
35
  * @returns {string} SHA-256 bound token
36
36
  */
37
- function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}) {
37
+ function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}, secret = null) {
38
38
  const payload = JSON.stringify({
39
39
  providerId,
40
40
  capabilityId,
41
41
  actionId,
42
42
  token: tokenPayload
43
43
  });
44
+ const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET;
45
+ if (effectiveSecret) {
46
+ return crypto.createHmac('sha256', effectiveSecret).update(payload, 'utf8').digest('hex');
47
+ }
44
48
  return crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
45
49
  }
46
50
 
@@ -17,12 +17,14 @@ const REMOTE_URL_PATTERN = /^https?:\/\//i;
17
17
  function resolveEnvironmentTier(options = {}) {
18
18
  const env = options.env || process.env;
19
19
 
20
- if (options.environment && VALID_ENV_TIERS.includes(options.environment)) {
21
- return options.environment;
20
+ const rawEnv = (options.environment || '').toLowerCase().trim();
21
+ if (rawEnv && VALID_ENV_TIERS.includes(rawEnv)) {
22
+ return rawEnv;
22
23
  }
23
24
 
24
- if (env.GEMSTACK_ENV && VALID_ENV_TIERS.includes(env.GEMSTACK_ENV)) {
25
- return env.GEMSTACK_ENV;
25
+ const rawGemstackEnv = (env.GEMSTACK_ENV || '').toLowerCase().trim();
26
+ if (rawGemstackEnv && VALID_ENV_TIERS.includes(rawGemstackEnv)) {
27
+ return rawGemstackEnv;
26
28
  }
27
29
 
28
30
  // Detect CI first
@@ -1,5 +1,71 @@
1
+ const crypto = require('crypto');
1
2
  const { checkEnvironmentCommercialPolicy, resolveEnvironmentTier } = require('./provider-registry');
2
3
 
4
+ const tokenSpendingLedger = new Map();
5
+
6
+ function resetTokenSpendingLedger() {
7
+ tokenSpendingLedger.clear();
8
+ }
9
+
10
+ /**
11
+ * Issues a cryptographically signed spending token from a trusted boundary.
12
+ *
13
+ * @param {object} params
14
+ * @param {string} [params.secret]
15
+ * @param {string} [params.provider_id='*']
16
+ * @param {string} [params.action_id='*']
17
+ * @param {number} [params.max_budget_units=100]
18
+ * @param {number} [params.ttl_seconds=3600]
19
+ * @returns {object} Signed spending token
20
+ */
21
+ function issueSpendingToken({ secret, provider_id = '*', action_id = '*', max_budget_units = 100, ttl_seconds = 3600 } = {}) {
22
+ const tokenId = (crypto.randomUUID && typeof crypto.randomUUID === 'function')
23
+ ? crypto.randomUUID()
24
+ : crypto.randomBytes(16).toString('hex');
25
+ const now = Date.now();
26
+ const issuedAt = new Date(now).toISOString();
27
+ const expiresAt = new Date(now + ttl_seconds * 1000).toISOString();
28
+ const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET || 'gemstack-boundary-internal-signing-secret';
29
+
30
+ const payload = [tokenId, provider_id, action_id, String(max_budget_units), expiresAt].join(':');
31
+ const signature = crypto.createHmac('sha256', effectiveSecret).update(payload).digest('hex');
32
+
33
+ return {
34
+ token_id: tokenId,
35
+ provider_id,
36
+ action_id,
37
+ max_budget_units: Number(max_budget_units),
38
+ issued_at: issuedAt,
39
+ expires_at: expiresAt,
40
+ signature,
41
+ spent_units: 0
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Verifies a spending token signature and expiration.
47
+ *
48
+ * @param {object} token
49
+ * @param {string} [secret]
50
+ * @returns {{ valid: boolean, reason?: string }}
51
+ */
52
+ function verifySpendingToken(token, secret) {
53
+ if (!token || typeof token !== 'object') return { valid: false, reason: 'TOKEN_INVALID_FORMAT' };
54
+ if (!token.token_id || !token.signature || !token.expires_at) return { valid: false, reason: 'TOKEN_INCOMPLETE' };
55
+
56
+ const effectiveSecret = secret || process.env.GEMSTACK_BOUNDARY_SECRET || 'gemstack-boundary-internal-signing-secret';
57
+ const payload = [token.token_id, token.provider_id || '*', token.action_id || '*', String(token.max_budget_units), token.expires_at].join(':');
58
+ const expectedSig = crypto.createHmac('sha256', effectiveSecret).update(payload).digest('hex');
59
+
60
+ const bufA = Buffer.from(token.signature);
61
+ const bufB = Buffer.from(expectedSig);
62
+ if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) {
63
+ return { valid: false, reason: 'TOKEN_SIGNATURE_INVALID' };
64
+ }
65
+
66
+ return { valid: true };
67
+ }
68
+
3
69
  /**
4
70
  * Creates a normalized, structured gate decision object.
5
71
  *
@@ -155,7 +221,17 @@ function evaluateBillableAction(request, ledger, options = {}) {
155
221
  const providerId = (request.provider_id || '').toLowerCase().trim();
156
222
  const capabilityId = request.capability_id;
157
223
  const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
158
- const requestedUnits = typeof request.requested_units === 'number' ? request.requested_units : 1;
224
+ const requestedUnits = request.requested_units !== undefined ? request.requested_units : 1;
225
+
226
+ if (typeof requestedUnits !== 'number' || !Number.isFinite(requestedUnits) || requestedUnits <= 0) {
227
+ return createGateDecision({
228
+ authorized: false,
229
+ decision: 'DENY',
230
+ reasonCode: 'INVALID_REQUESTED_UNITS',
231
+ message: 'Requested units must be a strictly positive finite number.',
232
+ context: { action_id: actionId, provider_id: providerId, requested_units: requestedUnits }
233
+ });
234
+ }
159
235
 
160
236
  // 1. Action Declaration Check (Must be explicitly declared)
161
237
  if (!actionId || typeof actionId !== 'string' || !actionId.trim()) {
@@ -232,9 +308,7 @@ function evaluateBillableAction(request, ledger, options = {}) {
232
308
 
233
309
  // 5. BILLABLE / POTENTIALLY_BILLABLE Actions -> Require Explicit Spending Token
234
310
  const token = request.authorization_token;
235
- const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
236
-
237
- if (!isTokenValid) {
311
+ if (!token || typeof token !== 'object') {
238
312
  return createGateDecision({
239
313
  authorized: false,
240
314
  decision: 'DENY',
@@ -244,20 +318,111 @@ function evaluateBillableAction(request, ledger, options = {}) {
244
318
  });
245
319
  }
246
320
 
321
+ const boundarySecret = options.boundarySecret || process.env.GEMSTACK_BOUNDARY_SECRET;
322
+
323
+ if (boundarySecret) {
324
+ const verified = verifySpendingToken(token, boundarySecret);
325
+ if (!verified.valid) {
326
+ return createGateDecision({
327
+ authorized: false,
328
+ decision: 'DENY',
329
+ reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
330
+ message: 'Authorization token rejected by trusted boundary: ' + verified.reason,
331
+ context: { action_id: actionId, provider_id: providerId, reason: verified.reason }
332
+ });
333
+ }
334
+ } else if (token.signature && token.token_id) {
335
+ const verified = verifySpendingToken(token);
336
+ if (!verified.valid) {
337
+ return createGateDecision({
338
+ authorized: false,
339
+ decision: 'DENY',
340
+ reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
341
+ message: 'Authorization token signature invalid.',
342
+ context: { action_id: actionId, provider_id: providerId }
343
+ });
344
+ }
345
+ } else {
346
+ // Legacy fallback only when no boundarySecret is active
347
+ const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
348
+ if (!isTokenValid) {
349
+ return createGateDecision({
350
+ authorized: false,
351
+ decision: 'DENY',
352
+ reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
353
+ message: 'Action "' + actionId + '" on provider "' + providerId + '" requires explicit spending authorization.',
354
+ context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
355
+ });
356
+ }
357
+ }
358
+
359
+ // Expiration check
360
+ if (token.expires_at) {
361
+ const expMs = Date.parse(token.expires_at);
362
+ if (!Number.isNaN(expMs) && Date.now() > expMs) {
363
+ return createGateDecision({
364
+ authorized: false,
365
+ decision: 'DENY',
366
+ reasonCode: 'TOKEN_EXPIRED',
367
+ message: 'Authorization token expired at ' + token.expires_at + '.',
368
+ context: { action_id: actionId, provider_id: providerId, expires_at: token.expires_at }
369
+ });
370
+ }
371
+ }
372
+
373
+ // Scope check (Provider & Action)
374
+ if (token.provider_id && token.provider_id !== '*' && token.provider_id.toLowerCase() !== providerId.toLowerCase()) {
375
+ return createGateDecision({
376
+ authorized: false,
377
+ decision: 'DENY',
378
+ reasonCode: 'TOKEN_SCOPE_MISMATCH',
379
+ message: 'Token scope provider "' + token.provider_id + '" does not match requested provider "' + providerId + '".',
380
+ context: { action_id: actionId, provider_id: providerId, token_provider: token.provider_id }
381
+ });
382
+ }
383
+
384
+ if (token.action_id && token.action_id !== '*' && token.action_id !== actionId) {
385
+ return createGateDecision({
386
+ authorized: false,
387
+ decision: 'DENY',
388
+ reasonCode: 'TOKEN_SCOPE_MISMATCH',
389
+ message: 'Token scope action "' + token.action_id + '" does not match requested action "' + actionId + '".',
390
+ context: { action_id: actionId, provider_id: providerId, token_action: token.action_id }
391
+ });
392
+ }
393
+
247
394
  // 6. Budget & Unit Threshold Check
248
395
  const estimatedUnitCost = (capabilityEntry && typeof capabilityEntry.estimated_unit_cost === 'number') ? capabilityEntry.estimated_unit_cost : 1;
249
396
  const estimatedTotalCost = requestedUnits * estimatedUnitCost;
250
397
 
251
398
  if (token && typeof token.max_budget_units === 'number') {
252
- if (estimatedTotalCost > token.max_budget_units) {
399
+ const tokenId = token.token_id;
400
+ const currentSpent = tokenId ? (tokenSpendingLedger.get(tokenId) || 0) : 0;
401
+ const projectedTotal = currentSpent + estimatedTotalCost;
402
+
403
+ if (projectedTotal > token.max_budget_units) {
253
404
  return createGateDecision({
254
405
  authorized: false,
255
406
  decision: 'DENY',
256
407
  reasonCode: 'BUDGET_THRESHOLD_EXCEEDED',
257
- message: 'Action "' + actionId + '" estimated cost (' + estimatedTotalCost + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
258
- context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost, max_budget_units: token.max_budget_units }
408
+ message: 'Action "' + actionId + '" estimated cost (' + projectedTotal + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
409
+ context: {
410
+ action_id: actionId,
411
+ provider_id: providerId,
412
+ capability_id: capabilityId,
413
+ cost_state: costState,
414
+ environment: envTier,
415
+ estimated_total_cost: estimatedTotalCost,
416
+ current_spent: currentSpent,
417
+ projected_total: projectedTotal,
418
+ max_budget_units: token.max_budget_units
419
+ }
259
420
  });
260
421
  }
422
+
423
+ if (tokenId) {
424
+ tokenSpendingLedger.set(tokenId, projectedTotal);
425
+ }
261
426
  }
262
427
 
263
428
  // 7. Full Authorization Pass
@@ -273,5 +438,8 @@ function evaluateBillableAction(request, ledger, options = {}) {
273
438
  module.exports = {
274
439
  createGateDecision,
275
440
  evaluateProviderCapability,
276
- evaluateBillableAction
441
+ evaluateBillableAction,
442
+ issueSpendingToken,
443
+ verifySpendingToken,
444
+ resetTokenSpendingLedger
277
445
  };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SDD Rigor Engine (Gemstack 2.0 Sprint C)
5
+ * Defines and enforces 4 levels of SDD rigor: quick, fix, feature, high-risk.
6
+ */
7
+
8
+ const RIGOR_LEVELS = Object.freeze(['quick', 'fix', 'feature', 'high-risk']);
9
+
10
+ /**
11
+ * Detects the declared rigor level from spec content or metadata.
12
+ * @param {string} specContent - Content of spec.md or quick.md
13
+ * @returns {string} Detected rigor level ('quick'|'fix'|'feature'|'high-risk')
14
+ */
15
+ function detectRigorLevel(specContent) {
16
+ if (!specContent || typeof specContent !== 'string') {
17
+ return 'feature';
18
+ }
19
+
20
+ // Check YAML frontmatter: rigor: <level>
21
+ const frontmatterMatch = specContent.match(/^---\s*[\r\n]+([\s\S]*?)[\r\n]+---/);
22
+ if (frontmatterMatch) {
23
+ const rMatch = frontmatterMatch[1].match(/^\s*rigor:\s*([a-zA-Z0-9_-]+)/m);
24
+ if (rMatch) {
25
+ const level = rMatch[1].trim().toLowerCase();
26
+ if (!RIGOR_LEVELS.includes(level)) {
27
+ const err = new Error(`Nivel de rigor inválido "${level}". Niveles válidos: ${RIGOR_LEVELS.join(', ')}`);
28
+ err.code = 'INVALID_RIGOR_LEVEL';
29
+ throw err;
30
+ }
31
+ return level;
32
+ }
33
+ }
34
+
35
+ // Check Markdown annotations: **Rigor Level**: `level` or Rigor: level
36
+ const inlineMatch = specContent.match(/\*\*Rigor(?:\s+Level)?\*\*:\s*`?([a-zA-Z0-9_-]+)`?/i);
37
+ if (inlineMatch) {
38
+ const level = inlineMatch[1].trim().toLowerCase();
39
+ if (!RIGOR_LEVELS.includes(level)) {
40
+ const err = new Error(`Nivel de rigor inválido "${level}". Niveles válidos: ${RIGOR_LEVELS.join(', ')}`);
41
+ err.code = 'INVALID_RIGOR_LEVEL';
42
+ throw err;
43
+ }
44
+ return level;
45
+ }
46
+
47
+ return 'feature';
48
+ }
49
+
50
+ /**
51
+ * Validates requirements for a given rigor level.
52
+ * @param {string} rigorLevel - 'quick'|'fix'|'feature'|'high-risk'
53
+ * @param {object} context - Context containing specContent, planContent, tasksContent, testMatrix, sidecar, etc.
54
+ * @returns {{ valid: boolean, code?: string, error?: string, [key: string]: any }}
55
+ */
56
+ function validateRigorRequirements(rigorLevel, context = {}) {
57
+ const normLevel = (rigorLevel || 'feature').toLowerCase();
58
+ if (!RIGOR_LEVELS.includes(normLevel)) {
59
+ const err = new Error(`Nivel de rigor inválido "${normLevel}".`);
60
+ err.code = 'INVALID_RIGOR_LEVEL';
61
+ throw err;
62
+ }
63
+
64
+ const {
65
+ specContent = '',
66
+ planContent = null,
67
+ tasksContent = null,
68
+ testMatrix = [],
69
+ sidecar = null
70
+ } = context;
71
+
72
+ switch (normLevel) {
73
+ case 'quick': {
74
+ if (!specContent || typeof specContent !== 'string' || specContent.trim().length === 0) {
75
+ return {
76
+ valid: false,
77
+ code: 'QUICK_SPEC_MISSING',
78
+ error: 'Quick rigor requires at least one spec or quick artifact'
79
+ };
80
+ }
81
+ return {
82
+ valid: true,
83
+ rigor: 'quick',
84
+ single_artifact_allowed: true
85
+ };
86
+ }
87
+
88
+ case 'fix': {
89
+ // Must have at least one regression test in the test matrix
90
+ const matrix = Array.isArray(testMatrix) ? testMatrix : [];
91
+ const regressionTest = matrix.find(t => {
92
+ const cat = (t.category || '').toUpperCase();
93
+ const id = (t.id || '').toUpperCase();
94
+ const desc = (t.description || '').toLowerCase();
95
+ return cat === 'REGRESSION' || id.includes('REG') || desc.includes('regression') || desc.includes('reproduce');
96
+ });
97
+
98
+ if (!regressionTest) {
99
+ return {
100
+ valid: false,
101
+ code: 'FIX_MISSING_REGRESSION_TEST',
102
+ error: 'Fix rigor requires at least one linked regression test in the test matrix (category REGRESSION or ID matching *REG*)'
103
+ };
104
+ }
105
+
106
+ return {
107
+ valid: true,
108
+ rigor: 'fix',
109
+ regression_test_id: regressionTest.id
110
+ };
111
+ }
112
+
113
+ case 'feature': {
114
+ if (!specContent) {
115
+ return { valid: false, code: 'FEATURE_SPEC_MISSING', error: 'Feature rigor requires spec.md' };
116
+ }
117
+ if (!planContent) {
118
+ return { valid: false, code: 'FEATURE_PLAN_MISSING', error: 'Feature rigor requires plan.md' };
119
+ }
120
+ if (!tasksContent) {
121
+ return { valid: false, code: 'FEATURE_TASKS_MISSING', error: 'Feature rigor requires tasks.md' };
122
+ }
123
+ return {
124
+ valid: true,
125
+ rigor: 'feature'
126
+ };
127
+ }
128
+
129
+ case 'high-risk': {
130
+ // 1. Threat Model in specContent
131
+ const hasThreatModel = context.hasThreatModel ||
132
+ /##\s+(?:[0-9.]+\s+)?(?:Threat\s+Model|Modelo\s+de\s+Amenazas)/i.test(specContent);
133
+ if (!hasThreatModel) {
134
+ return {
135
+ valid: false,
136
+ code: 'HIGH_RISK_MISSING_THREAT_MODEL',
137
+ error: 'High-risk rigor requires an explicit Threat Model section in spec.md'
138
+ };
139
+ }
140
+
141
+ // 2. Rollback Plan in planContent
142
+ const hasRollbackPlan = context.hasRollbackPlan ||
143
+ (planContent && /##\s+(?:[0-9.]+\s+)?(?:Rollback\s+Plan|Plan\s+de\s+Rollback)/i.test(planContent));
144
+ if (!hasRollbackPlan) {
145
+ return {
146
+ valid: false,
147
+ code: 'HIGH_RISK_MISSING_ROLLBACK_PLAN',
148
+ error: 'High-risk rigor requires an explicit Rollback Plan section in plan.md'
149
+ };
150
+ }
151
+
152
+ // 3. Dual human approvals in sidecar
153
+ const approvals = sidecar && Array.isArray(sidecar.approvals) ? sidecar.approvals : [];
154
+ const validApprovals = approvals.filter(a => a && a.approver && a.signature);
155
+ const uniqueApprovers = new Set(validApprovals.map(a => a.approver));
156
+
157
+ if (uniqueApprovers.size < 2) {
158
+ return {
159
+ valid: false,
160
+ code: 'HIGH_RISK_INSUFFICIENT_APPROVALS',
161
+ error: `High-risk rigor requires at least 2 distinct human approval signatures. Found: ${uniqueApprovers.size}`
162
+ };
163
+ }
164
+
165
+ return {
166
+ valid: true,
167
+ rigor: 'high-risk',
168
+ approvals_count: uniqueApprovers.size
169
+ };
170
+ }
171
+
172
+ default:
173
+ return { valid: false, code: 'UNKNOWN_RIGOR', error: `Unknown rigor: ${normLevel}` };
174
+ }
175
+ }
176
+
177
+ module.exports = {
178
+ RIGOR_LEVELS,
179
+ detectRigorLevel,
180
+ validateRigorRequirements
181
+ };
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Incremental Spec Delta Engine (Gemstack 2.0 Sprint C)
5
+ * Parses and applies structured ADDED, MODIFIED, REMOVED deltas onto base specifications.
6
+ */
7
+
8
+ /**
9
+ * Extracts and parses a spec delta block or JSON structure.
10
+ * @param {string|object} input - Markdown content containing gemstack-spec-delta block or parsed delta object.
11
+ * @returns {object} Normalized delta object { base_spec, added, modified, removed }
12
+ */
13
+ function parseSpecDelta(input) {
14
+ if (typeof input === 'object' && input !== null) {
15
+ return normalizeDelta(input);
16
+ }
17
+
18
+ if (typeof input !== 'string') {
19
+ throw new Error('Delta input must be a string or object');
20
+ }
21
+
22
+ const blockRegex = /```gemstack-spec-delta\s*([\s\S]*?)```/;
23
+ const match = input.match(blockRegex);
24
+ let parsed;
25
+
26
+ if (match) {
27
+ try {
28
+ parsed = JSON.parse(match[1].trim());
29
+ } catch (e) {
30
+ const err = new Error(`Error parseando gemstack-spec-delta: ${e.message}`);
31
+ err.code = 'DELTA_PARSE_ERROR';
32
+ throw err;
33
+ }
34
+ } else {
35
+ // Try parsing raw JSON
36
+ try {
37
+ parsed = JSON.parse(input.trim());
38
+ } catch (e) {
39
+ const err = new Error('No se encontró bloque gemstack-spec-delta ni JSON válido');
40
+ err.code = 'DELTA_BLOCK_MISSING';
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ return normalizeDelta(parsed);
46
+ }
47
+
48
+ function normalizeDelta(parsed) {
49
+ if (!parsed || typeof parsed !== 'object') {
50
+ const err = new Error('Delta debe ser un objeto');
51
+ err.code = 'INVALID_DELTA_SCHEMA';
52
+ throw err;
53
+ }
54
+
55
+ return {
56
+ base_spec: parsed.base_spec || null,
57
+ added: {
58
+ contracts: Array.isArray(parsed.added?.contracts) ? parsed.added.contracts : [],
59
+ requirements: Array.isArray(parsed.added?.requirements) ? parsed.added.requirements : [],
60
+ tests: Array.isArray(parsed.added?.tests) ? parsed.added.tests : []
61
+ },
62
+ modified: {
63
+ contracts: Array.isArray(parsed.modified?.contracts) ? parsed.modified.contracts : [],
64
+ requirements: Array.isArray(parsed.modified?.requirements) ? parsed.modified.requirements : [],
65
+ tests: Array.isArray(parsed.modified?.tests) ? parsed.modified.tests : []
66
+ },
67
+ removed: {
68
+ contracts: Array.isArray(parsed.removed?.contracts) ? parsed.removed.contracts : [],
69
+ requirements: Array.isArray(parsed.removed?.requirements) ? parsed.removed.requirements : [],
70
+ tests: Array.isArray(parsed.removed?.tests) ? parsed.removed.tests : []
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Applies a normalized delta onto a base specification representation.
77
+ * @param {object} baseSpec - { contracts: [], requirements: [], tests: [] }
78
+ * @param {object} delta - Normalized delta from parseSpecDelta
79
+ * @returns {object} Merged specification representation
80
+ */
81
+ function applySpecDelta(baseSpec, delta) {
82
+ if (!baseSpec || typeof baseSpec !== 'object') {
83
+ throw new Error('baseSpec must be an object');
84
+ }
85
+
86
+ const normDelta = normalizeDelta(delta);
87
+
88
+ // Clone collections
89
+ const contracts = Array.isArray(baseSpec.contracts) ? JSON.parse(JSON.stringify(baseSpec.contracts)) : [];
90
+ const requirements = Array.isArray(baseSpec.requirements) ? JSON.parse(JSON.stringify(baseSpec.requirements)) : [];
91
+ const tests = Array.isArray(baseSpec.tests) ? JSON.parse(JSON.stringify(baseSpec.tests)) : [];
92
+
93
+ // 1. Process REMOVED
94
+ for (const item of normDelta.removed.contracts) {
95
+ const id = typeof item === 'string' ? item : item.id;
96
+ const idx = contracts.findIndex(c => c.id === id);
97
+ if (idx === -1) {
98
+ const err = new Error(`No se puede remover el contrato "${id}": no existe en la especificación base.`);
99
+ err.code = 'DELTA_TARGET_NOT_FOUND';
100
+ throw err;
101
+ }
102
+ contracts.splice(idx, 1);
103
+ }
104
+
105
+ for (const item of normDelta.removed.tests) {
106
+ const id = typeof item === 'string' ? item : item.id;
107
+ const idx = tests.findIndex(t => t.id === id);
108
+ if (idx === -1) {
109
+ const err = new Error(`No se puede remover el test canónico "${id}": no existe en la especificación base.`);
110
+ err.code = 'DELTA_TARGET_NOT_FOUND';
111
+ throw err;
112
+ }
113
+ tests.splice(idx, 1);
114
+ }
115
+
116
+ for (const item of normDelta.removed.requirements) {
117
+ const id = typeof item === 'string' ? item : item.id;
118
+ const idx = requirements.findIndex(r => (typeof r === 'string' ? r === id : r.id === id));
119
+ if (idx === -1) {
120
+ const err = new Error(`No se puede remover el requisito "${id}": no existe en la especificación base.`);
121
+ err.code = 'DELTA_TARGET_NOT_FOUND';
122
+ throw err;
123
+ }
124
+ requirements.splice(idx, 1);
125
+ }
126
+
127
+ // 2. Process MODIFIED
128
+ for (const item of normDelta.modified.contracts) {
129
+ const idx = contracts.findIndex(c => c.id === item.id);
130
+ if (idx === -1) {
131
+ const err = new Error(`No se puede modificar el contrato "${item.id}": no existe en la especificación base.`);
132
+ err.code = 'DELTA_TARGET_NOT_FOUND';
133
+ throw err;
134
+ }
135
+ contracts[idx] = { ...contracts[idx], ...item };
136
+ }
137
+
138
+ for (const item of normDelta.modified.tests) {
139
+ const idx = tests.findIndex(t => t.id === item.id);
140
+ if (idx === -1) {
141
+ const err = new Error(`No se puede modificar el test "${item.id}": no existe en la especificación base.`);
142
+ err.code = 'DELTA_TARGET_NOT_FOUND';
143
+ throw err;
144
+ }
145
+ tests[idx] = { ...tests[idx], ...item };
146
+ }
147
+
148
+ for (const item of normDelta.modified.requirements) {
149
+ const id = typeof item === 'string' ? item : item.id;
150
+ const idx = requirements.findIndex(r => (typeof r === 'string' ? r === id : r.id === id));
151
+ if (idx === -1) {
152
+ const err = new Error(`No se puede modificar el requisito "${id}": no existe en la especificación base.`);
153
+ err.code = 'DELTA_TARGET_NOT_FOUND';
154
+ throw err;
155
+ }
156
+ requirements[idx] = typeof item === 'string' ? item : { ...requirements[idx], ...item };
157
+ }
158
+
159
+ // 3. Process ADDED
160
+ for (const item of normDelta.added.contracts) {
161
+ const existing = contracts.find(c => c.id === item.id);
162
+ if (existing) {
163
+ const err = new Error(`Conflicto al agregar contrato "${item.id}": ya existe en la especificación base.`);
164
+ err.code = 'DELTA_ADD_CONFLICT';
165
+ throw err;
166
+ }
167
+ contracts.push(item);
168
+ }
169
+
170
+ for (const item of normDelta.added.tests) {
171
+ const existing = tests.find(t => t.id === item.id);
172
+ if (existing) {
173
+ const err = new Error(`Conflicto al agregar test "${item.id}": ya existe en la especificación base.`);
174
+ err.code = 'DELTA_ADD_CONFLICT';
175
+ throw err;
176
+ }
177
+ tests.push(item);
178
+ }
179
+
180
+ for (const item of normDelta.added.requirements) {
181
+ requirements.push(item);
182
+ }
183
+
184
+ return {
185
+ contracts,
186
+ requirements,
187
+ tests
188
+ };
189
+ }
190
+
191
+ module.exports = {
192
+ parseSpecDelta,
193
+ applySpecDelta
194
+ };