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.
- package/.agents/rules/03-gemstack-security.md +2 -2
- package/.gemstack/state.json +10 -11
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +47 -13
- package/RELEASE_NOTES.md +40 -1
- package/handoff.md +33 -19
- package/package.json +4 -3
- package/scripts/ci/check-package-contents.js +1 -1
- package/scripts/ci/check-secrets.js +84 -0
- package/specs/011-gemstack-2.0-hardening/.gemstack.json +5 -0
- package/specs/011-gemstack-2.0-hardening/closure.json +58 -0
- package/specs/011-gemstack-2.0-hardening/plan.md +210 -0
- package/specs/011-gemstack-2.0-hardening/spec.md +277 -0
- package/specs/011-gemstack-2.0-hardening/tasks.md +59 -0
- package/specs/012-gemstack-2.0-honest-evidence/.gemstack.json +5 -0
- package/specs/012-gemstack-2.0-honest-evidence/closure.json +58 -0
- package/specs/012-gemstack-2.0-honest-evidence/plan.md +202 -0
- package/specs/012-gemstack-2.0-honest-evidence/spec.md +222 -0
- package/specs/012-gemstack-2.0-honest-evidence/tasks.md +99 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/.gemstack.json +9 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/closure.json +58 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/context-capsule.json +227 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/plan.md +179 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/spec.md +212 -0
- package/specs/013-gemstack-2.0-adaptable-sdd/tasks.md +90 -0
- package/specs/014-gemstack-2.0-context-memory/.gemstack.json +9 -0
- package/specs/014-gemstack-2.0-context-memory/closure.json +58 -0
- package/specs/014-gemstack-2.0-context-memory/plan.md +161 -0
- package/specs/014-gemstack-2.0-context-memory/spec.md +163 -0
- package/specs/014-gemstack-2.0-context-memory/tasks.md +79 -0
- package/src/cli.js +3 -0
- package/src/commands/doctor.js +18 -0
- package/src/commands/hooks.js +98 -14
- package/src/commands/init.js +1 -1
- package/src/commands/install.js +174 -49
- package/src/commands/spec.js +105 -0
- package/src/commands/update.js +1 -1
- package/src/commands/verify.js +10 -0
- package/src/lib/backup.js +3 -3
- package/src/lib/context-fatigue.js +165 -0
- package/src/lib/contract-amendments.js +109 -0
- package/src/lib/dependency-audit.js +202 -0
- package/src/lib/filesystem-safe.js +85 -15
- package/src/lib/memory-audit.js +121 -0
- package/src/lib/provider-boundary.js +5 -1
- package/src/lib/provider-registry.js +6 -4
- package/src/lib/safety-gates.js +176 -8
- package/src/lib/sdd-rigor.js +181 -0
- package/src/lib/spec-delta.js +194 -0
- package/src/lib/spec-merge.js +168 -0
- package/src/lib/swarm.js +2 -2
- package/src/lib/visual-qa.js +162 -9
- package/template/.agents/rules/03-gemstack-security.md +2 -2
- package/.github/workflows/main-ci.yml +0 -32
- package/.github/workflows/pr-ci.yml +0 -31
- package/.github/workflows/publish.yml +0 -52
- package/.github/workflows/release-readiness.yml +0 -43
- package/gemstack-ai-1.4.0.tgz +0 -0
|
@@ -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
|
+
};
|
|
@@ -1,22 +1,92 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
function normalizePlatformPath(p) {
|
|
6
|
+
const resolved = path.resolve(p);
|
|
7
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolveSafe(targetDir, relativePath) {
|
|
11
|
+
const target = path.resolve(targetDir);
|
|
12
|
+
const candidate = path.resolve(targetDir, relativePath);
|
|
13
|
+
const rel = path.relative(target, candidate);
|
|
14
|
+
|
|
15
|
+
const isInside = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
16
|
+
|
|
17
|
+
if (!isInside) {
|
|
18
|
+
const err = new Error(`[PATH_TRAVERSAL_DETECTED] Path Traversal blocked: ${relativePath}`);
|
|
19
|
+
err.code = 'PATH_TRAVERSAL_DETECTED';
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveSafeStrict(targetDir, relativePath) {
|
|
26
|
+
const candidate = resolveSafe(targetDir, relativePath);
|
|
27
|
+
const target = path.resolve(targetDir);
|
|
28
|
+
|
|
29
|
+
const realRoot = fs.existsSync(target) ? fs.realpathSync(target) : target;
|
|
30
|
+
const normRoot = normalizePlatformPath(realRoot);
|
|
31
|
+
|
|
32
|
+
// Check intermediate components and candidate for symlink escapes
|
|
33
|
+
const rel = path.relative(target, candidate);
|
|
34
|
+
const parts = rel.split(/[\\/]/).filter(Boolean);
|
|
35
|
+
|
|
36
|
+
let current = target;
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
current = path.join(current, part);
|
|
39
|
+
if (fs.existsSync(current)) {
|
|
40
|
+
const realCurrent = fs.realpathSync(current);
|
|
41
|
+
const normCurrent = normalizePlatformPath(realCurrent);
|
|
42
|
+
if (!normCurrent.startsWith(normRoot) || (normCurrent !== normRoot && normCurrent[normRoot.length] !== path.sep && normCurrent[normRoot.length] !== '/' && normCurrent[normRoot.length] !== '\\')) {
|
|
43
|
+
const err = new Error(`Symlink escape detected: path component "${part}" resolves to "${realCurrent}" outside project root "${realRoot}"`);
|
|
44
|
+
err.code = 'SYMLINK_ESCAPE_DETECTED';
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (fs.existsSync(candidate)) {
|
|
51
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
52
|
+
const normCandidate = normalizePlatformPath(realCandidate);
|
|
53
|
+
if (!normCandidate.startsWith(normRoot)) {
|
|
54
|
+
const err = new Error(`Symlink escape detected: destination resolves to "${realCandidate}" outside project root "${realRoot}"`);
|
|
55
|
+
err.code = 'SYMLINK_ESCAPE_DETECTED';
|
|
56
|
+
throw err;
|
|
14
57
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ensureDir(dirPath) {
|
|
64
|
+
if (!fs.existsSync(dirPath)) {
|
|
65
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function withConfinedAtomicWrite(rootDir, relativePath, writeFn) {
|
|
70
|
+
const finalPath = resolveSafeStrict(rootDir, relativePath);
|
|
71
|
+
const tmpDir = path.join(path.resolve(rootDir), '.gemstack', 'tmp');
|
|
72
|
+
ensureDir(tmpDir);
|
|
73
|
+
|
|
74
|
+
const tmpFile = path.join(tmpDir, `atomic-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
75
|
+
try {
|
|
76
|
+
writeFn(tmpFile);
|
|
77
|
+
ensureDir(path.dirname(finalPath));
|
|
78
|
+
fs.renameSync(tmpFile, finalPath);
|
|
79
|
+
return finalPath;
|
|
80
|
+
} finally {
|
|
81
|
+
if (fs.existsSync(tmpFile)) {
|
|
82
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
20
83
|
}
|
|
21
84
|
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
resolveSafe,
|
|
89
|
+
resolveSafeStrict,
|
|
90
|
+
ensureDir,
|
|
91
|
+
withConfinedAtomicWrite
|
|
22
92
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Memory Cross-Audit Engine (Gemstack 2.0 Sprint D)
|
|
5
|
+
* Reconciles git commit log with handoff.md to detect unrecorded work and memory drift.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const fssafe = require('./filesystem-safe');
|
|
12
|
+
|
|
13
|
+
const MANDATORY_SECTIONS = [
|
|
14
|
+
'1. Objetivo',
|
|
15
|
+
'2. Estado actual',
|
|
16
|
+
'3. Archivos y cambios',
|
|
17
|
+
'4. Intentos fallidos',
|
|
18
|
+
'5. Próximos pasos'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extracts recent git commits offline from the repository log.
|
|
23
|
+
* @param {string} targetDir
|
|
24
|
+
* @param {number} limit
|
|
25
|
+
* @returns {Array<{ hash: string, message: string }>}
|
|
26
|
+
*/
|
|
27
|
+
function getRecentGitCommits(targetDir, limit = 5) {
|
|
28
|
+
try {
|
|
29
|
+
const gitRes = spawnSync('git', ['log', `-n${limit}`, '--oneline'], {
|
|
30
|
+
cwd: targetDir,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
shell: false
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (gitRes.status !== 0 || !gitRes.stdout) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return gitRes.stdout
|
|
40
|
+
.trim()
|
|
41
|
+
.split('\n')
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map(line => {
|
|
44
|
+
const parts = line.trim().split(' ');
|
|
45
|
+
const hash = parts[0];
|
|
46
|
+
const message = parts.slice(1).join(' ');
|
|
47
|
+
return { hash, message };
|
|
48
|
+
});
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validates handoff integrity and cross-checks recent git commits against handoff records.
|
|
56
|
+
* @param {string} targetDir - Directory containing handoff.md
|
|
57
|
+
* @param {object} options - { commits?: Array<{ hash: string, message: string }>, limit?: number }
|
|
58
|
+
* @returns {{ valid: boolean, handoff_intact: boolean, unrecorded_commits: Array<object>, error?: string }}
|
|
59
|
+
*/
|
|
60
|
+
function crossAuditMemoryWithGit(targetDir, options = {}) {
|
|
61
|
+
const handoffPath = fssafe.resolveSafe(targetDir, 'handoff.md');
|
|
62
|
+
if (!fs.existsSync(handoffPath)) {
|
|
63
|
+
return {
|
|
64
|
+
valid: false,
|
|
65
|
+
handoff_intact: false,
|
|
66
|
+
unrecorded_commits: [],
|
|
67
|
+
error: 'handoff.md no encontrado en el directorio raíz'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handoffContent = fs.readFileSync(handoffPath, 'utf8');
|
|
72
|
+
|
|
73
|
+
// 1. Verify mandatory sections
|
|
74
|
+
for (const sec of MANDATORY_SECTIONS) {
|
|
75
|
+
if (!handoffContent.includes(sec)) {
|
|
76
|
+
return {
|
|
77
|
+
valid: false,
|
|
78
|
+
handoff_intact: false,
|
|
79
|
+
missing_section: sec,
|
|
80
|
+
unrecorded_commits: [],
|
|
81
|
+
error: `handoff.md carece de la sección obligatoria: "${sec}".`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Obtain commits to check
|
|
87
|
+
const commits = options.commits || getRecentGitCommits(targetDir, options.limit || 5);
|
|
88
|
+
const unrecordedCommits = [];
|
|
89
|
+
|
|
90
|
+
for (const c of commits) {
|
|
91
|
+
// Check if commit hash or key terms of message are mentioned in handoff
|
|
92
|
+
const hashFound = c.hash && handoffContent.toLowerCase().includes(c.hash.toLowerCase());
|
|
93
|
+
|
|
94
|
+
// Extract meaningful words (length >= 5) from commit message
|
|
95
|
+
const words = c.message
|
|
96
|
+
? c.message
|
|
97
|
+
.replace(/[^\w\s-]/g, '')
|
|
98
|
+
.split(/\s+/)
|
|
99
|
+
.filter(w => w.length >= 5)
|
|
100
|
+
: [];
|
|
101
|
+
|
|
102
|
+
const wordsFound = words.length > 0 && words.some(w => handoffContent.toLowerCase().includes(w.toLowerCase()));
|
|
103
|
+
|
|
104
|
+
if (!hashFound && !wordsFound) {
|
|
105
|
+
unrecordedCommits.push(c);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
valid: unrecordedCommits.length === 0,
|
|
111
|
+
handoff_intact: true,
|
|
112
|
+
unrecorded_commits: unrecordedCommits,
|
|
113
|
+
recent_commits_checked: commits.length
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
MANDATORY_SECTIONS,
|
|
119
|
+
getRecentGitCommits,
|
|
120
|
+
crossAuditMemoryWithGit
|
|
121
|
+
};
|