gemstack-ai 1.0.1 → 1.2.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/01-gemstack-core.md +15 -0
- package/.agents/rules/02-gemstack-constitution.md +12 -1
- package/.agents/skills/gemstack-handoff/SKILL.md +2 -1
- package/.agents/skills/gemstack-plan/SKILL.md +3 -1
- package/.agents/skills/gemstack-qa/SKILL.md +3 -0
- package/.agents/skills/gemstack-review/SKILL.md +7 -5
- package/.agents/skills/gemstack-ship/SKILL.md +10 -1
- package/.agents/skills/gemstack-spec/SKILL.md +4 -2
- package/.agents/skills/gemstack-tasks/SKILL.md +5 -3
- package/.gemstack/state.json +18 -2
- package/CHANGELOG.md +84 -0
- package/MANUAL.md +4 -4
- package/README.md +49 -8
- package/RELEASE_NOTES.md +129 -0
- package/assets/logo.jpg +0 -0
- package/docs/architecture-consistency.md +156 -0
- package/docs/spec-driven-development.md +26 -0
- package/gemstack-ai-1.2.0.tgz +0 -0
- package/handoff.md +40 -40
- package/package.json +3 -2
- package/scripts/ci/smoke-cli.js +1 -0
- package/specs/006-architecture-consistency-engine/.gemstack.json +9 -0
- package/specs/006-architecture-consistency-engine/plan.md +319 -0
- package/specs/006-architecture-consistency-engine/spec.md +179 -0
- package/specs/006-architecture-consistency-engine/tasks.md +532 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/.gemstack.json +5 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/closure.json +59 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/plan.md +484 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/spec.md +597 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/tasks.md +536 -0
- package/specs/templates/plan.md +41 -0
- package/specs/templates/spec.md +35 -0
- package/specs/templates/tasks.md +10 -0
- package/src/cli.js +15 -4
- package/src/commands/collect.js +340 -0
- package/src/commands/ship.js +79 -0
- package/src/commands/verify.js +433 -0
- package/src/lib/closure-context.js +444 -0
- package/src/lib/contracts.js +388 -0
- package/src/lib/findings.js +227 -0
- package/src/lib/hasher.js +103 -0
- package/src/lib/runner-adapters.js +347 -0
- package/src/lib/state.js +143 -0
- package/src/lib/test-matrix.js +187 -0
- package/src/mcp-server.js +1 -1
- package/template/.agents/rules/01-gemstack-core.md +15 -0
- package/template/.agents/rules/02-gemstack-constitution.md +12 -1
- package/template/.agents/skills/gemstack-handoff/SKILL.md +2 -1
- package/template/.agents/skills/gemstack-plan/SKILL.md +2 -1
- package/template/.agents/skills/gemstack-review/SKILL.md +7 -5
- package/template/.agents/skills/gemstack-ship/SKILL.md +5 -0
- package/template/.agents/skills/gemstack-spec/SKILL.md +3 -2
- package/template/.agents/skills/gemstack-tasks/SKILL.md +4 -3
- package/template/docs/architecture-consistency.md +144 -0
- package/template/specs/templates/plan.md +11 -0
- package/template/specs/templates/spec.md +17 -0
- package/template/specs/templates/tasks.md +1 -0
- package/gemstack-ai-1.0.1.tgz +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes content for canonical hashing:
|
|
7
|
+
* - Checks and rejects UTF-8 BOM by throwing CONTRACT_PARSE_ERROR
|
|
8
|
+
* - Normalizes CRLF and lone CR to standard LF
|
|
9
|
+
* Does NOT trim or alter whitespace/markdown/json formatting.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} content
|
|
12
|
+
* @returns {string} normalized string
|
|
13
|
+
*/
|
|
14
|
+
function normalizeContent(content) {
|
|
15
|
+
if (typeof content !== 'string') {
|
|
16
|
+
throw new TypeError('Content must be a string');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Reject UTF-8 BOM
|
|
20
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
21
|
+
const err = new Error('UTF-8 BOM is forbidden in phase artifacts.');
|
|
22
|
+
err.code = 'CONTRACT_PARSE_ERROR';
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Normalize newlines: CRLF -> LF, then lone CR -> LF
|
|
27
|
+
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Computes canonical 64-character lowercase hexadecimal SHA-256 hash of normalized content.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} content
|
|
34
|
+
* @returns {string} 64-char lowercase hex SHA-256
|
|
35
|
+
*/
|
|
36
|
+
function hashContent(content) {
|
|
37
|
+
const normalized = normalizeContent(content);
|
|
38
|
+
return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads a file as UTF-8 and computes its canonical hash.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} filePath
|
|
45
|
+
* @returns {string} 64-char lowercase hex SHA-256
|
|
46
|
+
*/
|
|
47
|
+
function hashFile(filePath) {
|
|
48
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
49
|
+
return hashContent(raw);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
|
|
53
|
+
const WINDOWS_UNC_RE = /^\\\\[^\\/]+[\\/][^\\/]+/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Checks if a path string has Windows structure (drive letter, UNC, or backslashes).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} p
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isWindowsPath(p) {
|
|
62
|
+
if (typeof p !== 'string') return false;
|
|
63
|
+
return WINDOWS_DRIVE_RE.test(p) || WINDOWS_UNC_RE.test(p) || p.includes('\\');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Normalizes file paths to repository-relative POSIX format (using forward slashes '/').
|
|
68
|
+
* Uses explicit path.win32 or path.posix based on input path flavor rather than host OS.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} filePath
|
|
71
|
+
* @param {string} [rootPath] - optional repository root
|
|
72
|
+
* @returns {string} normalized POSIX relative path
|
|
73
|
+
*/
|
|
74
|
+
function normalizePath(filePath, rootPath) {
|
|
75
|
+
if (!filePath) return '';
|
|
76
|
+
if (!rootPath) {
|
|
77
|
+
return filePath.replace(/\\/g, '/');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fileIsWindows = isWindowsPath(filePath);
|
|
81
|
+
const rootIsWindows = isWindowsPath(rootPath);
|
|
82
|
+
|
|
83
|
+
const isPosixAbs = (p) => typeof p === 'string' && p.startsWith('/') && !WINDOWS_DRIVE_RE.test(p);
|
|
84
|
+
if ((fileIsWindows && isPosixAbs(rootPath)) || (isPosixAbs(filePath) && rootIsWindows)) {
|
|
85
|
+
throw new Error(`Incompatible mixed path flavors: filePath="${filePath}", rootPath="${rootPath}"`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const impl = (fileIsWindows || rootIsWindows) ? path.win32 : path.posix;
|
|
89
|
+
const rel = impl.relative(rootPath, filePath);
|
|
90
|
+
|
|
91
|
+
if (rel.startsWith('..\\') || rel.startsWith('../') || rel === '..') {
|
|
92
|
+
throw new Error(`Path "${filePath}" is outside root directory "${rootPath}"`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return rel.replace(/\\/g, '/');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
normalizeContent,
|
|
100
|
+
hashContent,
|
|
101
|
+
hashFile,
|
|
102
|
+
normalizePath
|
|
103
|
+
};
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const { writeJsonAtomic } = require('./state');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spawns node:test runner safely with explicit argv and shell: false.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} rootPath
|
|
10
|
+
* @param {Array<string>} testFiles
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string, durationMs: number }>}
|
|
13
|
+
*/
|
|
14
|
+
function executeNodeTestRunner(rootPath, testFiles, options = {}) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
const args = ['--test', '--test-reporter=tap', ...(testFiles || [])];
|
|
18
|
+
|
|
19
|
+
const cleanEnv = { ...process.env };
|
|
20
|
+
delete cleanEnv.NODE_TEST_CONTEXT;
|
|
21
|
+
delete cleanEnv.NODE_TEST_WORKER_ID;
|
|
22
|
+
|
|
23
|
+
const child = spawn(process.execPath, args, {
|
|
24
|
+
cwd: rootPath,
|
|
25
|
+
shell: false,
|
|
26
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
27
|
+
env: cleanEnv
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
let stdout = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
|
|
33
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
34
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
35
|
+
|
|
36
|
+
child.on('error', (err) => {
|
|
37
|
+
resolve({
|
|
38
|
+
exitCode: 1,
|
|
39
|
+
stdout,
|
|
40
|
+
stderr: err.message,
|
|
41
|
+
durationMs: Date.now() - startTime
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
child.on('close', (exitCode) => {
|
|
46
|
+
resolve({
|
|
47
|
+
exitCode: exitCode === null ? 1 : exitCode,
|
|
48
|
+
stdout,
|
|
49
|
+
stderr,
|
|
50
|
+
durationMs: Date.now() - startTime
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parses node:test TAP output to extract physical counts and test events.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} tapOutput
|
|
60
|
+
* @returns {{ physicalTotal: number, passed: number, failed: number, skipped: number, todo: number, cancelled: number, tests: Array<object> }}
|
|
61
|
+
*/
|
|
62
|
+
function parseNodeTestTap(tapOutput) {
|
|
63
|
+
const lines = (tapOutput || '').split('\n');
|
|
64
|
+
const tests = [];
|
|
65
|
+
const suites = [];
|
|
66
|
+
let passed = 0;
|
|
67
|
+
let failed = 0;
|
|
68
|
+
let skipped = 0;
|
|
69
|
+
let todo = 0;
|
|
70
|
+
let cancelled = 0;
|
|
71
|
+
|
|
72
|
+
const idRegex = /\b(TEST-[A-Z0-9]+-[A-Z0-9]+)\b/;
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
const trimmed = line.trim();
|
|
77
|
+
|
|
78
|
+
// TAP test line format: ok 1 - title # duration_ms
|
|
79
|
+
// or: not ok 2 - title # duration_ms
|
|
80
|
+
if (trimmed.startsWith('ok ') || trimmed.startsWith('not ok ')) {
|
|
81
|
+
const isNotOk = trimmed.startsWith('not ok ');
|
|
82
|
+
const rest = isNotOk ? trimmed.slice(7) : trimmed.slice(3);
|
|
83
|
+
|
|
84
|
+
// Remove test number: "1 - title..."
|
|
85
|
+
const numMatch = rest.match(/^\d+\s*-\s*(.*)/);
|
|
86
|
+
const titleAndDirectives = numMatch ? numMatch[1] : rest;
|
|
87
|
+
|
|
88
|
+
// Lookahead in YAML diagnostic block for "type: 'suite'" vs "type: 'test'"
|
|
89
|
+
let eventType = null;
|
|
90
|
+
let durationMs = 0;
|
|
91
|
+
|
|
92
|
+
for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) {
|
|
93
|
+
const nextTrimmed = lines[j].trim();
|
|
94
|
+
if (nextTrimmed === '...') break;
|
|
95
|
+
if (nextTrimmed.startsWith('ok ') || nextTrimmed.startsWith('not ok ')) break;
|
|
96
|
+
|
|
97
|
+
const typeMatch = nextTrimmed.match(/^type:\s*['"]?([a-zA-Z0-9_-]+)['"]?/i);
|
|
98
|
+
if (typeMatch) {
|
|
99
|
+
eventType = typeMatch[1].toLowerCase();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const durMatch = nextTrimmed.match(/^duration_ms:\s*([\d.]+)/i);
|
|
103
|
+
if (durMatch) {
|
|
104
|
+
durationMs = parseFloat(durMatch[1]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check inline duration if not in YAML
|
|
109
|
+
if (!durationMs) {
|
|
110
|
+
const durMatchInline = titleAndDirectives.match(/duration_ms\s*[:=]\s*([\d.]+)/i) ||
|
|
111
|
+
titleAndDirectives.match(/time=([\d.]+)ms/i);
|
|
112
|
+
if (durMatchInline) durationMs = parseFloat(durMatchInline[1]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// If explicit suite container event in TAP, classify as SUITE and exclude from physical tests
|
|
116
|
+
if (eventType === 'suite') {
|
|
117
|
+
suites.push({
|
|
118
|
+
kind: 'SUITE',
|
|
119
|
+
title: titleAndDirectives.split('#')[0].trim(),
|
|
120
|
+
durationMs
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let rawOutcome = isNotOk ? 'FAIL' : 'PASS';
|
|
126
|
+
|
|
127
|
+
if (/#\s*SKIP\b/i.test(titleAndDirectives)) {
|
|
128
|
+
rawOutcome = 'SKIP';
|
|
129
|
+
} else if (/#\s*TODO\b/i.test(titleAndDirectives)) {
|
|
130
|
+
rawOutcome = 'TODO';
|
|
131
|
+
} else if (/#\s*CANCELLED\b/i.test(titleAndDirectives)) {
|
|
132
|
+
rawOutcome = 'CANCELLED';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const idMatch = titleAndDirectives.match(idRegex);
|
|
136
|
+
const canonicalId = idMatch ? idMatch[1] : null;
|
|
137
|
+
|
|
138
|
+
if (rawOutcome === 'PASS') passed++;
|
|
139
|
+
else if (rawOutcome === 'FAIL') failed++;
|
|
140
|
+
else if (rawOutcome === 'SKIP') skipped++;
|
|
141
|
+
else if (rawOutcome === 'TODO') todo++;
|
|
142
|
+
else if (rawOutcome === 'CANCELLED') cancelled++;
|
|
143
|
+
|
|
144
|
+
tests.push({
|
|
145
|
+
kind: 'TEST',
|
|
146
|
+
id: canonicalId,
|
|
147
|
+
title: titleAndDirectives.split('#')[0].trim(),
|
|
148
|
+
rawOutcome,
|
|
149
|
+
durationMs,
|
|
150
|
+
isSupporting: canonicalId === null
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const physicalTotal = passed + failed + skipped + todo + cancelled;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
physicalTotal,
|
|
159
|
+
passed,
|
|
160
|
+
failed,
|
|
161
|
+
skipped,
|
|
162
|
+
todo,
|
|
163
|
+
cancelled,
|
|
164
|
+
tests,
|
|
165
|
+
suites
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reconciles canonical acceptance requirements with runner execution traces.
|
|
171
|
+
*
|
|
172
|
+
* @param {Array<object>} canonicalMatrix
|
|
173
|
+
* @param {Array<object>} planBindings
|
|
174
|
+
* @param {Array<object>} executedEvents
|
|
175
|
+
* @returns {object}
|
|
176
|
+
*/
|
|
177
|
+
function reconcileTestRun(canonicalMatrix, planBindings, executedEvents) {
|
|
178
|
+
const boundCanonicalIds = new Set((planBindings || []).map(b => b.test_id));
|
|
179
|
+
const expectedRequired = (canonicalMatrix || []).filter(m => m.gate === 'REQUIRED');
|
|
180
|
+
const validCanonicalIds = new Set((canonicalMatrix || []).map(m => m.id));
|
|
181
|
+
|
|
182
|
+
const executedCanonical = [];
|
|
183
|
+
const executedSupporting = [];
|
|
184
|
+
const executedIdCounts = {};
|
|
185
|
+
|
|
186
|
+
for (const ev of (executedEvents || [])) {
|
|
187
|
+
if (ev.id) {
|
|
188
|
+
executedCanonical.push(ev);
|
|
189
|
+
executedIdCounts[ev.id] = (executedIdCounts[ev.id] || 0) + 1;
|
|
190
|
+
} else {
|
|
191
|
+
executedSupporting.push(ev);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 1. Check duplicate physical execution identities
|
|
196
|
+
const duplicates = Object.keys(executedIdCounts).filter(id => executedIdCounts[id] > 1);
|
|
197
|
+
|
|
198
|
+
// 2. Check orphans (physical test claimed canonical ID absent from SPEC)
|
|
199
|
+
const orphans = Object.keys(executedIdCounts).filter(id => !validCanonicalIds.has(id));
|
|
200
|
+
|
|
201
|
+
// 3. Check missing vs not executed
|
|
202
|
+
const executedIdSet = new Set(Object.keys(executedIdCounts));
|
|
203
|
+
const missing = [];
|
|
204
|
+
const notExecuted = [];
|
|
205
|
+
|
|
206
|
+
for (const req of expectedRequired) {
|
|
207
|
+
if (!boundCanonicalIds.has(req.id)) {
|
|
208
|
+
missing.push(req.id);
|
|
209
|
+
} else if (!executedIdSet.has(req.id)) {
|
|
210
|
+
notExecuted.push(req.id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 4. Check phantoms (phantom test: claimed executed/pass but absent from runner events)
|
|
215
|
+
const phantoms = [];
|
|
216
|
+
|
|
217
|
+
// 5. Arithmetic equation validation
|
|
218
|
+
const canonicalCount = executedCanonical.length;
|
|
219
|
+
const supportingCount = executedSupporting.length;
|
|
220
|
+
const totalPhysical = executedEvents.length;
|
|
221
|
+
|
|
222
|
+
const mathValid = (canonicalCount + supportingCount === totalPhysical);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
mathValid,
|
|
226
|
+
totalPhysical,
|
|
227
|
+
canonicalCount,
|
|
228
|
+
supportingCount,
|
|
229
|
+
duplicates,
|
|
230
|
+
orphans,
|
|
231
|
+
missing,
|
|
232
|
+
notExecuted,
|
|
233
|
+
phantoms
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Executes a PACKAGE_SCRIPT gate safely without shell: true.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} rootPath
|
|
241
|
+
* @param {object} gateConfig
|
|
242
|
+
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
|
|
243
|
+
*/
|
|
244
|
+
function executePackageScriptGate(rootPath, gateConfig) {
|
|
245
|
+
return new Promise((resolve) => {
|
|
246
|
+
const pkgPath = path.join(rootPath, 'package.json');
|
|
247
|
+
if (!fs.existsSync(pkgPath)) {
|
|
248
|
+
const err = new Error('package.json not found for PACKAGE_SCRIPT gate');
|
|
249
|
+
err.code = 'REQUIRED_GATE_MISSING';
|
|
250
|
+
return resolve({ exitCode: 1, stdout: '', stderr: err.message, error: err });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let pkg;
|
|
254
|
+
try {
|
|
255
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
256
|
+
} catch (e) {
|
|
257
|
+
const err = new Error('Failed to parse package.json: ' + e.message);
|
|
258
|
+
err.code = 'REQUIRED_GATE_MISSING';
|
|
259
|
+
return resolve({ exitCode: 1, stdout: '', stderr: err.message, error: err });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!pkg.scripts || !pkg.scripts[gateConfig.script]) {
|
|
263
|
+
const err = new Error('Script "' + gateConfig.script + '" not found in package.json');
|
|
264
|
+
err.code = 'REQUIRED_GATE_MISSING';
|
|
265
|
+
return resolve({ exitCode: 1, stdout: '', stderr: err.message, error: err });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const cleanEnv = { ...process.env };
|
|
269
|
+
delete cleanEnv.NODE_TEST_CONTEXT;
|
|
270
|
+
delete cleanEnv.NODE_TEST_WORKER_ID;
|
|
271
|
+
|
|
272
|
+
let execBinary = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
273
|
+
let execArgs = ['run', gateConfig.script];
|
|
274
|
+
|
|
275
|
+
// On Windows, node.js spawn('npm.cmd', ..., { shell: false }) triggers EINVAL in node 22+ unless .cmd is spawned via cmd /c or direct npm-cli.js
|
|
276
|
+
if (process.platform === 'win32') {
|
|
277
|
+
const npmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
278
|
+
if (fs.existsSync(npmCli)) {
|
|
279
|
+
execBinary = process.execPath;
|
|
280
|
+
execArgs = [npmCli, 'run', gateConfig.script];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const child = spawn(execBinary, execArgs, {
|
|
285
|
+
cwd: rootPath,
|
|
286
|
+
shell: false,
|
|
287
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
288
|
+
env: cleanEnv
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
let stdout = '';
|
|
292
|
+
let stderr = '';
|
|
293
|
+
|
|
294
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
295
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
296
|
+
|
|
297
|
+
child.on('error', (err) => {
|
|
298
|
+
resolve({ exitCode: 1, stdout, stderr: err.message, error: err });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
child.on('close', (exitCode) => {
|
|
302
|
+
resolve({ exitCode: exitCode === null ? 1 : exitCode, stdout, stderr });
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Assembles and atomically writes the feature-local closure.json manifest.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} targetDir
|
|
311
|
+
* @param {object} manifestData
|
|
312
|
+
* @returns {object} Written manifest
|
|
313
|
+
*/
|
|
314
|
+
function generateClosureManifest(targetDir, manifestData) {
|
|
315
|
+
const closurePath = path.join(targetDir, 'closure.json');
|
|
316
|
+
|
|
317
|
+
const manifest = {
|
|
318
|
+
schema: 'gemstack-closure',
|
|
319
|
+
version: 1,
|
|
320
|
+
feature: manifestData.feature,
|
|
321
|
+
generated_at: manifestData.generated_at || new Date().toISOString(),
|
|
322
|
+
status: manifestData.status,
|
|
323
|
+
closure_context: manifestData.closure_context,
|
|
324
|
+
acceptance_signature: manifestData.acceptance_signature,
|
|
325
|
+
canonical_summary: manifestData.canonical_summary,
|
|
326
|
+
physical_summary: manifestData.physical_summary,
|
|
327
|
+
reconciliation: manifestData.reconciliation,
|
|
328
|
+
task_traceability_summary: manifestData.task_traceability_summary,
|
|
329
|
+
required_gates: manifestData.required_gates || {},
|
|
330
|
+
supplemental_gates: manifestData.supplemental_gates || {},
|
|
331
|
+
exceptions: manifestData.exceptions || [],
|
|
332
|
+
evidence_sources: manifestData.evidence_sources || [],
|
|
333
|
+
blockers: manifestData.blockers || [],
|
|
334
|
+
warnings: manifestData.warnings || []
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
writeJsonAtomic(closurePath, manifest);
|
|
338
|
+
return manifest;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
executeNodeTestRunner,
|
|
343
|
+
parseNodeTestTap,
|
|
344
|
+
reconcileTestRun,
|
|
345
|
+
executePackageScriptGate,
|
|
346
|
+
generateClosureManifest
|
|
347
|
+
};
|
package/src/lib/state.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads .gemstack/state.json with legacy fallback defaults.
|
|
6
|
+
* Preserves all unknown existing fields.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} rootPath - Workspace root path
|
|
9
|
+
* @returns {object} State object
|
|
10
|
+
*/
|
|
11
|
+
function readState(rootPath) {
|
|
12
|
+
const statePath = path.join(rootPath, '.gemstack', 'state.json');
|
|
13
|
+
if (!fs.existsSync(statePath)) {
|
|
14
|
+
return {
|
|
15
|
+
version: '0.1',
|
|
16
|
+
current_phase: null,
|
|
17
|
+
status: null,
|
|
18
|
+
stop_reason: null,
|
|
19
|
+
active_spec: null,
|
|
20
|
+
completed_phases: [],
|
|
21
|
+
phase_hashes: null,
|
|
22
|
+
consistency: null,
|
|
23
|
+
guard_mode: { careful: false, freeze: false, allowed_paths: [] }
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(statePath, 'utf8');
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
const { findings, accepted_exceptions, ...cleanParsed } = parsed;
|
|
31
|
+
return {
|
|
32
|
+
version: cleanParsed.version || '0.1',
|
|
33
|
+
current_phase: cleanParsed.current_phase ?? null,
|
|
34
|
+
status: cleanParsed.status ?? null,
|
|
35
|
+
stop_reason: cleanParsed.stop_reason ?? null,
|
|
36
|
+
active_spec: cleanParsed.active_spec ?? null,
|
|
37
|
+
completed_phases: Array.isArray(cleanParsed.completed_phases) ? cleanParsed.completed_phases : [],
|
|
38
|
+
phase_hashes: cleanParsed.phase_hashes ?? null,
|
|
39
|
+
consistency: cleanParsed.consistency ?? null,
|
|
40
|
+
guard_mode: cleanParsed.guard_mode || { careful: false, freeze: false, allowed_paths: [] },
|
|
41
|
+
...cleanParsed // preserve any extra operational fields (excluding findings / accepted_exceptions)
|
|
42
|
+
};
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new Error(`Failed to parse .gemstack/state.json: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Writes an object atomically to disk by writing to a temporary file in the same directory
|
|
50
|
+
* and renaming it over the destination. Bounded retry for Windows locks.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} filePath - Absolute path to destination file
|
|
53
|
+
* @param {object} data - Object to serialize
|
|
54
|
+
*/
|
|
55
|
+
function writeJsonAtomic(filePath, data) {
|
|
56
|
+
const dir = path.dirname(filePath);
|
|
57
|
+
if (!fs.existsSync(dir)) {
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
62
|
+
const serialized = JSON.stringify(data, null, 2) + '\n';
|
|
63
|
+
fs.writeFileSync(tmpPath, serialized, 'utf8');
|
|
64
|
+
|
|
65
|
+
// Bounded retry for Windows transient file locks
|
|
66
|
+
let attempts = 0;
|
|
67
|
+
const maxAttempts = 5;
|
|
68
|
+
while (attempts < maxAttempts) {
|
|
69
|
+
try {
|
|
70
|
+
fs.renameSync(tmpPath, filePath);
|
|
71
|
+
return;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
attempts++;
|
|
74
|
+
if (attempts >= maxAttempts) {
|
|
75
|
+
// cleanup tmp
|
|
76
|
+
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
// Busy wait short sleep
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
while (Date.now() - start < 20) {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Writes .gemstack/state.json atomically.
|
|
88
|
+
* Strips historical findings and accepted_exceptions to enforce persistence boundary.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} rootPath - Workspace root path
|
|
91
|
+
* @param {object} stateObj - State data
|
|
92
|
+
*/
|
|
93
|
+
function writeStateAtomic(rootPath, stateObj) {
|
|
94
|
+
const statePath = path.join(rootPath, '.gemstack', 'state.json');
|
|
95
|
+
const { findings, accepted_exceptions, ...operationalState } = stateObj;
|
|
96
|
+
writeJsonAtomic(statePath, operationalState);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reads feature sidecar .gemstack.json with safe defaults.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} featureDir - Path to specs/<feature>
|
|
103
|
+
* @returns {object} Sidecar data
|
|
104
|
+
*/
|
|
105
|
+
function readSidecar(featureDir) {
|
|
106
|
+
const sidecarPath = path.join(featureDir, '.gemstack.json');
|
|
107
|
+
if (!fs.existsSync(sidecarPath)) {
|
|
108
|
+
return {
|
|
109
|
+
phase_hashes: {},
|
|
110
|
+
historical_findings: [],
|
|
111
|
+
accepted_exceptions: []
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const raw = fs.readFileSync(sidecarPath, 'utf8');
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return {
|
|
119
|
+
phase_hashes: {},
|
|
120
|
+
historical_findings: [],
|
|
121
|
+
accepted_exceptions: []
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Writes feature sidecar .gemstack.json atomically.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} featureDir - Path to specs/<feature>
|
|
130
|
+
* @param {object} sidecarObj - Data to write
|
|
131
|
+
*/
|
|
132
|
+
function writeSidecarAtomic(featureDir, sidecarObj) {
|
|
133
|
+
const sidecarPath = path.join(featureDir, '.gemstack.json');
|
|
134
|
+
writeJsonAtomic(sidecarPath, sidecarObj);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
readState,
|
|
139
|
+
writeStateAtomic,
|
|
140
|
+
readSidecar,
|
|
141
|
+
writeSidecarAtomic,
|
|
142
|
+
writeJsonAtomic
|
|
143
|
+
};
|