gemstack-ai 1.3.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 (71) hide show
  1. package/.agents/rules/03-gemstack-security.md +2 -2
  2. package/.gemstack/state.json +7 -8
  3. package/CHANGELOG.md +87 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +113 -22
  6. package/RELEASE_NOTES.md +80 -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/009-context-capsule/context-capsule.json +4 -4
  12. package/specs/010-agent-swarm-visual-qa/.gemstack.json +5 -0
  13. package/specs/010-agent-swarm-visual-qa/closure.json +59 -0
  14. package/specs/010-agent-swarm-visual-qa/plan.md +759 -0
  15. package/specs/010-agent-swarm-visual-qa/spec.md +842 -0
  16. package/specs/010-agent-swarm-visual-qa/swarm.json +49 -0
  17. package/specs/010-agent-swarm-visual-qa/tasks.md +873 -0
  18. package/specs/010-agent-swarm-visual-qa/visual-qa.json +41 -0
  19. package/specs/011-gemstack-2.0-hardening/.gemstack.json +5 -0
  20. package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
  21. package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
  22. package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
  23. package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
  24. package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
  25. package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
  26. package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
  27. package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
  28. package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
  29. package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
  30. package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
  31. package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
  32. package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
  33. package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
  34. package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
  35. package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
  36. package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
  37. package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
  38. package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
  39. package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
  40. package/src/cli.js +11 -0
  41. package/src/commands/context.js +1 -1
  42. package/src/commands/doctor.js +18 -0
  43. package/src/commands/hooks.js +98 -14
  44. package/src/commands/init.js +1 -1
  45. package/src/commands/install.js +174 -49
  46. package/src/commands/spec.js +105 -0
  47. package/src/commands/swarm.js +111 -0
  48. package/src/commands/update.js +1 -1
  49. package/src/commands/verify.js +48 -0
  50. package/src/commands/visual.js +82 -0
  51. package/src/lib/backup.js +3 -3
  52. package/src/lib/closure-context.js +9 -1
  53. package/src/lib/context-fatigue.js +165 -0
  54. package/src/lib/contract-amendments.js +109 -0
  55. package/src/lib/dependency-audit.js +202 -0
  56. package/src/lib/filesystem-safe.js +85 -15
  57. package/src/lib/memory-audit.js +121 -0
  58. package/src/lib/provider-boundary.js +5 -1
  59. package/src/lib/provider-registry.js +6 -4
  60. package/src/lib/safety-gates.js +176 -8
  61. package/src/lib/sdd-rigor.js +181 -0
  62. package/src/lib/spec-delta.js +194 -0
  63. package/src/lib/spec-merge.js +168 -0
  64. package/src/lib/swarm.js +639 -0
  65. package/src/lib/visual-qa.js +652 -0
  66. package/template/.agents/rules/03-gemstack-security.md +2 -2
  67. package/.github/workflows/main-ci.yml +0 -32
  68. package/.github/workflows/pr-ci.yml +0 -31
  69. package/.github/workflows/publish.yml +0 -52
  70. package/.github/workflows/release-readiness.yml +0 -43
  71. package/gemstack-ai-1.3.0.tgz +0 -0
@@ -87,6 +87,16 @@ module.exports = async (flags) => {
87
87
  } else {
88
88
  logger.ok('Sección inmutable "4. Intentos fallidos" preservada.');
89
89
  }
90
+
91
+ // Cross-Audit con Git Log (Gemstack 2.0 Sprint D)
92
+ const { crossAuditMemoryWithGit } = require('../lib/memory-audit');
93
+ const memAudit = crossAuditMemoryWithGit(targetDir);
94
+ if (!memAudit.valid && memAudit.unrecorded_commits.length > 0) {
95
+ logger.warn(`Detectados commits recientes no registrados en handoff.md: ${memAudit.unrecorded_commits.map(c => c.hash).join(', ')}`);
96
+ totalWarnings++;
97
+ } else {
98
+ logger.ok('Memoria cruzada (handoff.md <-> git log) verificada.');
99
+ }
90
100
  }
91
101
 
92
102
  // 3. Consistencia de Estado Local (.gemstack/state.json)
@@ -430,6 +440,44 @@ module.exports = async (flags) => {
430
440
  logger.ok('Sin spec activa configurada para verificación de context capsule.');
431
441
  }
432
442
 
443
+ // 5.3 Verificación de Swarm Manifest (Upgrade E - Read-Only)
444
+ logger.info('--- 5.3 Verificación de Swarm Manifest & Partition Safety (Read-Only) ---');
445
+ if (loadedState && loadedState.active_spec) {
446
+ const { validateSwarmManifest } = require('../lib/swarm');
447
+ const swarmResult = validateSwarmManifest(targetDir, loadedState.active_spec);
448
+ if (swarmResult.valid) {
449
+ logger.ok(`Swarm manifest verificado y sin colisiones (${loadedState.active_spec}/swarm.json).`);
450
+ } else if (swarmResult.state === 'MISSING') {
451
+ logger.info(`[LEGACY] No se detectó swarm.json en "${loadedState.active_spec}" (Modo Legacy Swarm-Free).`);
452
+ } else {
453
+ for (const f of swarmResult.findings) {
454
+ logger.error(`[${f.code}] ${f.details || f.message}`);
455
+ totalErrors++;
456
+ }
457
+ }
458
+ } else {
459
+ logger.ok('Sin spec activa configurada para verificación de swarm manifest.');
460
+ }
461
+
462
+ // 5.4 Verificación de Visual QA Manifest & Baselines (Upgrade E - Read-Only)
463
+ logger.info('--- 5.4 Verificación de Visual QA Manifest & Baselines (Read-Only) ---');
464
+ if (loadedState && loadedState.active_spec) {
465
+ const { validateVisualManifest } = require('../lib/visual-qa');
466
+ const vqaResult = validateVisualManifest(targetDir, loadedState.active_spec);
467
+ if (vqaResult.valid) {
468
+ logger.ok(`Visual QA manifest y baselines íntegros (${loadedState.active_spec}/visual-qa.json).`);
469
+ } else if (vqaResult.state === 'MISSING') {
470
+ logger.info(`[LEGACY] No se detectó visual-qa.json en "${loadedState.active_spec}" (Modo Legacy Visual-Free).`);
471
+ } else {
472
+ for (const f of vqaResult.findings) {
473
+ logger.error(`[${f.code}] ${f.details || f.message}`);
474
+ totalErrors++;
475
+ }
476
+ }
477
+ } else {
478
+ logger.ok('Sin spec activa configurada para verificación de visual QA.');
479
+ }
480
+
433
481
  // 6. Seguridad Local y Anti-Silent Failures en Tests
434
482
  logger.info('--- 6/6 Verificación de Seguridad y Test Runners ---');
435
483
  const envPath = fssafe.resolveSafe(targetDir, '.env');
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Gemstack Visual QA Command Handler (Upgrade E)
3
+ *
4
+ * Implements "gemstack vqa validate" and "gemstack vqa promote" subcommands.
5
+ * Read-only validation and explicit baseline promotion.
6
+ */
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const logger = require('../lib/logger');
11
+ const fssafe = require('../lib/filesystem-safe');
12
+ const { readState } = require('../lib/state');
13
+ const { validateVisualManifest, promoteVisualBaseline } = require('../lib/visual-qa');
14
+
15
+ async function visualCommand(args = [], flags = {}) {
16
+ const subcommand = args[0] || 'validate';
17
+ const targetDir = flags.target ? path.resolve(flags.target) : process.cwd();
18
+
19
+ let state;
20
+ try {
21
+ state = readState(targetDir);
22
+ } catch (e) {
23
+ logger.error(`Error loading .gemstack/state.json: ${e.message}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ const activeSpec = state.active_spec;
28
+ if (!activeSpec) {
29
+ if (flags.json) {
30
+ console.log(JSON.stringify({ status: 'NO_ACTIVE_SPEC', message: 'No active feature spec set.' }));
31
+ } else {
32
+ logger.info('Sin spec activa pendiente. Opera en modo legacy.');
33
+ }
34
+ return;
35
+ }
36
+
37
+ if (subcommand === 'validate') {
38
+ const res = validateVisualManifest(targetDir, activeSpec);
39
+ if (flags.json) {
40
+ console.log(JSON.stringify(res, null, 2));
41
+ return;
42
+ }
43
+
44
+ if (res.state === 'MISSING') {
45
+ logger.info(`[LEGACY] No se detectó visual-qa.json en "${activeSpec}".`);
46
+ return;
47
+ }
48
+
49
+ if (res.valid) {
50
+ logger.ok(`Visual QA manifest validado con éxito: ${activeSpec}/visual-qa.json.`);
51
+ } else {
52
+ for (const f of res.findings) {
53
+ logger.error(`[${f.code}] ${f.details}`);
54
+ }
55
+ process.exit(1);
56
+ }
57
+ } else if (subcommand === 'promote') {
58
+ const scenarioId = args[1];
59
+ const liveImagePath = args[2];
60
+
61
+ if (!scenarioId || !liveImagePath) {
62
+ logger.error('Usage: gemstack vqa promote <scenario-id> <live-image-path>');
63
+ process.exit(1);
64
+ }
65
+
66
+ try {
67
+ const res = promoteVisualBaseline(targetDir, activeSpec, scenarioId, liveImagePath);
68
+ logger.ok(`Baseline promoted for scenario "${scenarioId}". Updated ${activeSpec}/visual-qa.json.`);
69
+ if (flags.json) {
70
+ console.log(JSON.stringify(res, null, 2));
71
+ }
72
+ } catch (err) {
73
+ logger.error(`Failed to promote baseline: ${err.message}`);
74
+ process.exit(1);
75
+ }
76
+ } else {
77
+ logger.error(`Unknown visual subcommand: "${subcommand}". Use "validate" or "promote".`);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ module.exports = visualCommand;
package/src/lib/backup.js CHANGED
@@ -5,12 +5,12 @@ const logger = require('./logger');
5
5
 
6
6
  module.exports = {
7
7
  backupFile: (targetDir, relativeFilePath, dryRun, sessionTimestamp) => {
8
- const fullPath = fssafe.resolveSafe(targetDir, relativeFilePath);
8
+ const fullPath = fssafe.resolveSafeStrict(targetDir, relativeFilePath);
9
9
  if (!fs.existsSync(fullPath)) return;
10
10
 
11
11
  const timestamp = sessionTimestamp || new Date().toISOString().replace(/[:.]/g, '-');
12
- const backupDir = fssafe.resolveSafe(targetDir, `.gemstack/backups/${timestamp}`);
13
- const backupDest = fssafe.resolveSafe(backupDir, relativeFilePath);
12
+ const backupDir = fssafe.resolveSafeStrict(targetDir, `.gemstack/backups/${timestamp}`);
13
+ const backupDest = fssafe.resolveSafeStrict(backupDir, relativeFilePath);
14
14
 
15
15
  logger.info(`Backup: ${relativeFilePath}`);
16
16
  if (!dryRun) {
@@ -220,7 +220,7 @@ function parseTaskMetadata(tasksContent) {
220
220
  const lines = normalized.split('\n');
221
221
  const tasks = [];
222
222
 
223
- const taskHeaderRegex = /^[*-]\s+\[[ xX]\]\s+\*\*(T\d+):\s*(.*?)\*\*/;
223
+ const taskHeaderRegex = /^[*-]\s+\[[ xX]\]\s+\*\*([A-Za-z0-9_-]+):\s*(.*?)\*\*/;
224
224
  let currentTask = null;
225
225
 
226
226
  for (const line of lines) {
@@ -343,6 +343,14 @@ function resolveRelevantFiles(rootPath, featureDir, planBindings, taskList, plan
343
343
  if (fs.existsSync(path.join(rootPath, featureCapsule))) filesSet.add(featureCapsule);
344
344
  if (fs.existsSync(path.join(rootPath, '.gemstack/context-capsule.json'))) filesSet.add('.gemstack/context-capsule.json');
345
345
 
346
+ const featureSwarm = (relFeature + '/swarm.json').replace(/^\.\//, '');
347
+ if (fs.existsSync(path.join(rootPath, featureSwarm))) filesSet.add(featureSwarm);
348
+ if (fs.existsSync(path.join(rootPath, '.gemstack/swarm.json'))) filesSet.add('.gemstack/swarm.json');
349
+
350
+ const featureVqa = (relFeature + '/visual-qa.json').replace(/^\.\//, '');
351
+ if (fs.existsSync(path.join(rootPath, featureVqa))) filesSet.add(featureVqa);
352
+ if (fs.existsSync(path.join(rootPath, '.gemstack/visual-qa.json'))) filesSet.add('.gemstack/visual-qa.json');
353
+
346
354
  for (const b of (planBindings || [])) {
347
355
  if (b.file && fs.existsSync(path.join(rootPath, b.file))) {
348
356
  filesSet.add(b.file);
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Context Fatigue & Noise Pruning Engine (Gemstack 2.0 Sprint D)
5
+ * Monitors accumulated token load, detects redundancy, and prunes ephemeral noise.
6
+ */
7
+
8
+ const DEFAULT_TOKEN_THRESHOLD = 16000;
9
+ const DEFAULT_REDUNDANCY_THRESHOLD = 0.4;
10
+
11
+ /**
12
+ * Fast conservative token estimation (~4 characters per token).
13
+ * @param {string} text
14
+ * @returns {number}
15
+ */
16
+ function estimateTokens(text) {
17
+ if (!text || typeof text !== 'string') return 0;
18
+ return Math.ceil(text.length / 4);
19
+ }
20
+
21
+ /**
22
+ * Calculates redundancy ratio based on repeated content blocks and duplicate messages.
23
+ * @param {Array<string|object>} messages
24
+ * @returns {number} Float between 0.0 and 1.0
25
+ */
26
+ function computeRedundancyRatio(messages) {
27
+ if (!Array.isArray(messages) || messages.length <= 1) return 0;
28
+
29
+ const texts = messages.map(m => (typeof m === 'string' ? m : (m.content || m.text || JSON.stringify(m))));
30
+ const totalLength = texts.reduce((acc, t) => acc + t.length, 0);
31
+ if (totalLength === 0) return 0;
32
+
33
+ const seen = new Set();
34
+ let duplicateLength = 0;
35
+
36
+ for (const t of texts) {
37
+ const trimmed = t.trim();
38
+ if (seen.has(trimmed)) {
39
+ duplicateLength += trimmed.length;
40
+ } else {
41
+ seen.add(trimmed);
42
+ }
43
+ }
44
+
45
+ return Number((duplicateLength / totalLength).toFixed(4));
46
+ }
47
+
48
+ /**
49
+ * Detects whether the current context window suffers from token fatigue or high redundancy.
50
+ * @param {Array<string|object>} messages
51
+ * @param {object} options - { tokenThreshold?: number, redundancyThreshold?: number }
52
+ * @returns {{ fatigue: boolean, total_tokens: number, token_limit: number, redundancy_ratio: number, reason?: string }}
53
+ */
54
+ function detectContextFatigue(messages, options = {}) {
55
+ const tokenThreshold = options.tokenThreshold || DEFAULT_TOKEN_THRESHOLD;
56
+ const redundancyThreshold = options.redundancyThreshold || DEFAULT_REDUNDANCY_THRESHOLD;
57
+
58
+ if (!Array.isArray(messages)) {
59
+ return {
60
+ fatigue: false,
61
+ total_tokens: 0,
62
+ token_limit: tokenThreshold,
63
+ redundancy_ratio: 0
64
+ };
65
+ }
66
+
67
+ const texts = messages.map(m => (typeof m === 'string' ? m : (m.content || m.text || JSON.stringify(m))));
68
+ const totalTokens = texts.reduce((acc, t) => acc + estimateTokens(t), 0);
69
+ const redundancyRatio = computeRedundancyRatio(messages);
70
+
71
+ let fatigue = false;
72
+ const reasons = [];
73
+
74
+ if (totalTokens > tokenThreshold) {
75
+ fatigue = true;
76
+ reasons.push(`Token count (${totalTokens}) exceeds threshold (${tokenThreshold})`);
77
+ }
78
+
79
+ if (redundancyRatio > redundancyThreshold) {
80
+ fatigue = true;
81
+ reasons.push(`Redundancy ratio (${(redundancyRatio * 100).toFixed(1)}%) exceeds limit (${(redundancyThreshold * 100).toFixed(1)}%)`);
82
+ }
83
+
84
+ return {
85
+ fatigue,
86
+ total_tokens: totalTokens,
87
+ token_limit: tokenThreshold,
88
+ redundancy_ratio: redundancyRatio,
89
+ reason: reasons.length > 0 ? reasons.join('; ') : undefined
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Deterministically prunes ephemeral noise, duplicated tool outputs, and redundant dialogue.
95
+ * Guarantees preservation of architectural contracts, state definitions, and critical decisions.
96
+ * @param {Array<string|object>} messages
97
+ * @param {object} options - { retainTail?: number }
98
+ * @returns {Array<string|object>} Pruned message array
99
+ */
100
+ function pruneContextNoise(messages, options = {}) {
101
+ if (!Array.isArray(messages) || messages.length === 0) return [];
102
+ const retainTail = options.retainTail || 3;
103
+
104
+ const isContractOrState = (text) => {
105
+ return (
106
+ text.includes('gemstack-contracts') ||
107
+ text.includes('gemstack-inherited-contracts') ||
108
+ text.includes('gemstack-test-matrix') ||
109
+ text.includes('phase_hashes') ||
110
+ text.includes('handoff.md') ||
111
+ text.includes('### Intentos fallidos') ||
112
+ text.includes('4. Intentos fallidos')
113
+ );
114
+ };
115
+
116
+ const isEphemeralNoise = (text) => {
117
+ const trimmed = text.trim();
118
+ if (trimmed.length < 5) return true;
119
+ if (/^(ok|done|entendido|continuando|esperando|running)(?:\.{1,3})?$/i.test(trimmed)) return true;
120
+ return false;
121
+ };
122
+
123
+ const seenHashes = new Set();
124
+ const pruned = [];
125
+
126
+ for (let i = 0; i < messages.length; i++) {
127
+ const m = messages[i];
128
+ const text = typeof m === 'string' ? m : (m.content || m.text || JSON.stringify(m));
129
+
130
+ // Always preserve contracts, invariants and state
131
+ if (isContractOrState(text)) {
132
+ pruned.push(m);
133
+ continue;
134
+ }
135
+
136
+ // Always preserve recent tail messages
137
+ if (i >= messages.length - retainTail) {
138
+ pruned.push(m);
139
+ continue;
140
+ }
141
+
142
+ // Skip pure ephemeral chit-chat
143
+ if (isEphemeralNoise(text)) {
144
+ continue;
145
+ }
146
+
147
+ // De-duplicate identical intermediate messages
148
+ const trimmed = text.trim();
149
+ if (seenHashes.has(trimmed)) {
150
+ continue;
151
+ }
152
+ seenHashes.add(trimmed);
153
+
154
+ pruned.push(m);
155
+ }
156
+
157
+ return pruned;
158
+ }
159
+
160
+ module.exports = {
161
+ estimateTokens,
162
+ computeRedundancyRatio,
163
+ detectContextFatigue,
164
+ pruneContextNoise
165
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Formal Contract Amendment Engine (Gemstack 2.0 Sprint C)
5
+ * Replaces silent contract mutations with signed, auditable amendment records.
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+
10
+ const REQUIRED_AMENDMENT_FIELDS = ['amendment_id', 'contract_id', 'version', 'reason', 'approved_by', 'signature'];
11
+
12
+ /**
13
+ * Computes deterministic signature for an amendment.
14
+ * @param {object} amendment - { amendment_id, contract_id, version, reason, approved_by }
15
+ * @param {string|null} secret - Optional HMAC secret
16
+ * @returns {string} Hex-encoded SHA-256 or HMAC-SHA256 digest
17
+ */
18
+ function computeAmendmentSignature(amendment, secret = null) {
19
+ if (!amendment || typeof amendment !== 'object') {
20
+ throw new Error('Amendment must be an object');
21
+ }
22
+
23
+ const payload = [
24
+ String(amendment.amendment_id || ''),
25
+ String(amendment.contract_id || ''),
26
+ String(amendment.version || ''),
27
+ String(amendment.reason || '').trim(),
28
+ String(amendment.approved_by || '').trim()
29
+ ].join('|');
30
+
31
+ if (secret && typeof secret === 'string' && secret.length > 0) {
32
+ return crypto.createHmac('sha256', secret).update(payload).digest('hex');
33
+ }
34
+
35
+ return crypto.createHash('sha256').update(payload).digest('hex');
36
+ }
37
+
38
+ /**
39
+ * Validates that any changes from upstreamContracts to currentContracts are justified by signed amendments.
40
+ * @param {Array<object>} upstreamContracts
41
+ * @param {Array<object>} currentContracts
42
+ * @param {Array<object>} amendments
43
+ * @param {object} options - { secret?: string }
44
+ * @returns {{ valid: boolean, code?: string, error?: string, verified_amendments?: number }}
45
+ */
46
+ function validateContractAmendments(upstreamContracts = [], currentContracts = [], amendments = [], options = {}) {
47
+ const upstreamMap = new Map((upstreamContracts || []).map(c => [c.id, c]));
48
+ const currentMap = new Map((currentContracts || []).map(c => [c.id, c]));
49
+ const amendmentList = Array.isArray(amendments) ? amendments : [];
50
+ const amendmentMap = new Map(amendmentList.map(a => [a.contract_id, a]));
51
+
52
+ // 1. Detect modified or removed contracts
53
+ const changedContractIds = [];
54
+
55
+ for (const [id, upstream] of upstreamMap.entries()) {
56
+ if (!currentMap.has(id)) {
57
+ changedContractIds.push({ id, type: 'REMOVED', upstream });
58
+ } else {
59
+ const current = currentMap.get(id);
60
+ if (JSON.stringify(upstream) !== JSON.stringify(current)) {
61
+ changedContractIds.push({ id, type: 'MODIFIED', upstream, current });
62
+ }
63
+ }
64
+ }
65
+
66
+ // 2. Ensure each changed contract has a valid, signed amendment
67
+ for (const changed of changedContractIds) {
68
+ if (!amendmentMap.has(changed.id)) {
69
+ return {
70
+ valid: false,
71
+ code: 'UNAUTHORIZED_CONTRACT_MUTATION',
72
+ error: `El contrato congelado "${changed.id}" fue ${changed.type === 'REMOVED' ? 'eliminado' : 'modificado'} sin un registro formal de enmienda.`
73
+ };
74
+ }
75
+ }
76
+
77
+ // 3. Verify each declared amendment
78
+ for (const a of amendmentList) {
79
+ for (const field of REQUIRED_AMENDMENT_FIELDS) {
80
+ if (!a[field] && a[field] !== 0) {
81
+ return {
82
+ valid: false,
83
+ code: 'AMENDMENT_MALFORMED',
84
+ error: `Enmienda "${a.amendment_id || 'UNKNOWN'}" carece del campo obligatorio "${field}".`
85
+ };
86
+ }
87
+ }
88
+
89
+ const expectedSig = computeAmendmentSignature(a, options.secret);
90
+ if (a.signature !== expectedSig) {
91
+ return {
92
+ valid: false,
93
+ code: 'AMENDMENT_SIGNATURE_INVALID',
94
+ error: `Firma criptográfica inválida para la enmienda "${a.amendment_id}" del contrato "${a.contract_id}".`
95
+ };
96
+ }
97
+ }
98
+
99
+ return {
100
+ valid: true,
101
+ verified_amendments: amendmentList.length
102
+ };
103
+ }
104
+
105
+ module.exports = {
106
+ REQUIRED_AMENDMENT_FIELDS,
107
+ computeAmendmentSignature,
108
+ validateContractAmendments
109
+ };
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Offline Dependency Auditor (Gemstack 2.0 Sprint D)
5
+ * Detects orphan dependencies, undeclared imports, and circular local import cycles offline.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const fssafe = require('./filesystem-safe');
11
+
12
+ const NODE_BUILTINS = new Set([
13
+ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
14
+ 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
15
+ 'events', 'fs', 'fs/promises', 'http', 'http2', 'https', 'inspector',
16
+ 'module', 'net', 'os', 'path', 'path/posix', 'path/win32', 'perf_hooks',
17
+ 'process', 'punycode', 'querystring', 'readline', 'repl', 'stream',
18
+ 'stream/promises', 'stream/consumers', 'stream/web', 'string_decoder',
19
+ 'test', 'timers', 'timers/promises', 'tls', 'trace_events', 'tty',
20
+ 'url', 'util', 'util/types', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib'
21
+ ]);
22
+
23
+ function isNodeBuiltin(moduleName) {
24
+ if (moduleName.startsWith('node:')) return true;
25
+ return NODE_BUILTINS.has(moduleName);
26
+ }
27
+
28
+ /**
29
+ * Extracts import/require targets from file content.
30
+ * @param {string} content
31
+ * @returns {Array<string>} List of required/imported module specifiers
32
+ */
33
+ function extractImportsFromContent(content) {
34
+ const imports = [];
35
+ // Match require('...')
36
+ const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
37
+ let match;
38
+ while ((match = requireRegex.exec(content)) !== null) {
39
+ imports.push(match[1]);
40
+ }
41
+
42
+ // Match import ... from '...' or import('...')
43
+ const importRegex = /(?:import\s+(?:[\s\S]*?from\s+)?|import\s*\()\s*['"]([^'"]+)['"]/g;
44
+ while ((match = importRegex.exec(content)) !== null) {
45
+ imports.push(match[1]);
46
+ }
47
+
48
+ return imports;
49
+ }
50
+
51
+ /**
52
+ * Resolves package name from import specifier.
53
+ * E.g., 'express' -> 'express', '@scope/pkg/sub' -> '@scope/pkg'
54
+ */
55
+ function getPackageName(specifier) {
56
+ if (specifier.startsWith('@')) {
57
+ const parts = specifier.split('/');
58
+ return parts.slice(0, 2).join('/');
59
+ }
60
+ return specifier.split('/')[0];
61
+ }
62
+
63
+ /**
64
+ * Scans directory recursively for JavaScript/TypeScript files.
65
+ */
66
+ function collectSourceFiles(dir, files = []) {
67
+ if (!fs.existsSync(dir)) return files;
68
+
69
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ const fullPath = path.join(dir, entry.name);
72
+ if (entry.isDirectory()) {
73
+ if (entry.name !== 'node_modules' && entry.name !== '.git') {
74
+ collectSourceFiles(fullPath, files);
75
+ }
76
+ } else if (entry.isFile() && /\.(js|mjs|cjs|ts)$/.test(entry.name)) {
77
+ files.push(fullPath);
78
+ }
79
+ }
80
+ return files;
81
+ }
82
+
83
+ /**
84
+ * Detects circular dependency cycles using depth-first search.
85
+ */
86
+ function findCircularCycles(dependencyGraph) {
87
+ const cycles = [];
88
+ const visited = new Set();
89
+ const recursionStack = [];
90
+
91
+ function dfs(node) {
92
+ visited.add(node);
93
+ recursionStack.push(node);
94
+
95
+ const neighbors = dependencyGraph.get(node) || [];
96
+ for (const neighbor of neighbors) {
97
+ if (!visited.has(neighbor)) {
98
+ dfs(neighbor);
99
+ } else {
100
+ const cycleStartIndex = recursionStack.indexOf(neighbor);
101
+ if (cycleStartIndex !== -1) {
102
+ const cyclePath = [...recursionStack.slice(cycleStartIndex), neighbor];
103
+ cycles.push(cyclePath);
104
+ }
105
+ }
106
+ }
107
+
108
+ recursionStack.pop();
109
+ }
110
+
111
+ for (const node of dependencyGraph.keys()) {
112
+ if (!visited.has(node)) {
113
+ dfs(node);
114
+ }
115
+ }
116
+
117
+ return cycles;
118
+ }
119
+
120
+ /**
121
+ * Audits project dependencies and local module cycles completely offline.
122
+ * @param {string} targetDir - Directory containing package.json and src/
123
+ * @returns {{ orphans: string[], undeclared: string[], circularCycles: string[][], is_clean: boolean }}
124
+ */
125
+ function auditDependencies(targetDir) {
126
+ const pkgPath = fssafe.resolveSafe(targetDir, 'package.json');
127
+ let dependencies = {};
128
+ let devDependencies = {};
129
+
130
+ if (fs.existsSync(pkgPath)) {
131
+ try {
132
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
133
+ dependencies = pkg.dependencies || {};
134
+ devDependencies = pkg.devDependencies || {};
135
+ } catch (_) {}
136
+ }
137
+
138
+ const srcDir = fssafe.resolveSafe(targetDir, 'src');
139
+ const sourceFiles = collectSourceFiles(srcDir);
140
+
141
+ const usedPackages = new Set();
142
+ const dependencyGraph = new Map();
143
+
144
+ for (const filePath of sourceFiles) {
145
+ const normFile = filePath.replace(/\\/g, '/');
146
+ dependencyGraph.set(normFile, []);
147
+
148
+ let content = '';
149
+ try {
150
+ content = fs.readFileSync(filePath, 'utf8');
151
+ } catch (_) {
152
+ continue;
153
+ }
154
+
155
+ const imports = extractImportsFromContent(content);
156
+ for (const imp of imports) {
157
+ if (imp.startsWith('.')) {
158
+ // Local relative import
159
+ const resolvedPath = path.resolve(path.dirname(filePath), imp);
160
+ const candidates = [
161
+ resolvedPath,
162
+ resolvedPath + '.js',
163
+ resolvedPath + '.mjs',
164
+ path.join(resolvedPath, 'index.js')
165
+ ];
166
+ const match = candidates.find(c => fs.existsSync(c) && fs.statSync(c).isFile());
167
+ if (match) {
168
+ const normNeighbor = match.replace(/\\/g, '/');
169
+ dependencyGraph.get(normFile).push(normNeighbor);
170
+ }
171
+ } else if (!isNodeBuiltin(imp)) {
172
+ // External package
173
+ const pkgName = getPackageName(imp);
174
+ usedPackages.add(pkgName);
175
+ }
176
+ }
177
+ }
178
+
179
+ // 1. Detect orphans (in dependencies but not used in src)
180
+ const orphans = Object.keys(dependencies).filter(dep => !usedPackages.has(dep));
181
+
182
+ // 2. Detect undeclared (used in src but missing from dependencies & devDependencies)
183
+ const allDeclared = new Set([...Object.keys(dependencies), ...Object.keys(devDependencies)]);
184
+ const undeclared = Array.from(usedPackages).filter(dep => !allDeclared.has(dep));
185
+
186
+ // 3. Detect circular cycles
187
+ const circularCycles = findCircularCycles(dependencyGraph);
188
+
189
+ return {
190
+ orphans,
191
+ undeclared,
192
+ circularCycles,
193
+ is_clean: orphans.length === 0 && undeclared.length === 0 && circularCycles.length === 0
194
+ };
195
+ }
196
+
197
+ module.exports = {
198
+ isNodeBuiltin,
199
+ extractImportsFromContent,
200
+ auditDependencies,
201
+ findCircularCycles
202
+ };