gemstack-ai 1.1.2 → 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.
@@ -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
+ };
@@ -0,0 +1,187 @@
1
+ const crypto = require('node:crypto');
2
+ const { normalizeContent } = require('./hasher');
3
+
4
+ const CANONICAL_LAYERS = ['UNIT', 'INTEGRATION', 'E2E', 'CLI'];
5
+ const CANONICAL_GATES = ['REQUIRED', 'SUPPLEMENTAL'];
6
+ const CANONICAL_ID_REGEX = /^TEST-[A-Z0-9]+-[A-Z0-9]+$/;
7
+ const REQUIRED_FIELDS = ['id', 'category', 'layer', 'description', 'pass_criteria', 'gate'];
8
+
9
+ /**
10
+ * Extracts the single column-0 gemstack-test-matrix block from markdown.
11
+ *
12
+ * @param {string} markdownContent
13
+ * @returns {{ matrix: Array<object>|null, isLegacy: boolean }}
14
+ */
15
+ function extractTestMatrixBlock(markdownContent) {
16
+ const normalized = normalizeContent(markdownContent);
17
+ const lines = normalized.split('\n');
18
+ const fence = '```';
19
+ const header = '```gemstack-test-matrix';
20
+
21
+ const blocks = [];
22
+ let inBlock = false;
23
+ let blockLines = [];
24
+
25
+ for (const line of lines) {
26
+ if (!inBlock) {
27
+ if (line.trimEnd() === header) {
28
+ inBlock = true;
29
+ blockLines = [];
30
+ }
31
+ } else {
32
+ if (line.trimEnd() === fence) {
33
+ inBlock = false;
34
+ blocks.push(blockLines.join('\n'));
35
+ } else {
36
+ blockLines.push(line);
37
+ }
38
+ }
39
+ }
40
+
41
+ if (blocks.length === 0) {
42
+ return { matrix: null, isLegacy: true };
43
+ }
44
+
45
+ if (blocks.length > 1) {
46
+ const err = new Error(`Multiple gemstack-test-matrix blocks detected (${blocks.length}). Exactly one is permitted.`);
47
+ err.code = 'TEST_MATRIX_PARSE_ERROR';
48
+ throw err;
49
+ }
50
+
51
+ const jsonRaw = blocks[0].trim();
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(jsonRaw);
55
+ } catch (parseErr) {
56
+ const err = new Error(`Failed to parse gemstack-test-matrix JSON: ${parseErr.message}`);
57
+ err.code = 'TEST_MATRIX_PARSE_ERROR';
58
+ throw err;
59
+ }
60
+
61
+ return { matrix: parsed, isLegacy: false };
62
+ }
63
+
64
+ /**
65
+ * Validates a canonical test matrix array.
66
+ *
67
+ * @param {any} matrix
68
+ * @returns {Array<object>} Sanitized array of canonical test objects
69
+ */
70
+ function validateTestMatrix(matrix) {
71
+ if (!Array.isArray(matrix)) {
72
+ const err = new Error('gemstack-test-matrix must be a JSON array of test objects');
73
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
74
+ throw err;
75
+ }
76
+
77
+ const seenIds = new Set();
78
+ const validated = [];
79
+
80
+ for (let i = 0; i < matrix.length; i++) {
81
+ const item = matrix[i];
82
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
83
+ const err = new Error(`Item at index ${i} in gemstack-test-matrix must be a non-null object`);
84
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
85
+ throw err;
86
+ }
87
+
88
+ // Check unknown fields
89
+ const keys = Object.keys(item);
90
+ for (const k of keys) {
91
+ if (!REQUIRED_FIELDS.includes(k)) {
92
+ const err = new Error(`Item at index ${i} contains unknown field: "${k}"`);
93
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
94
+ throw err;
95
+ }
96
+ }
97
+
98
+ // Check required fields and empty values
99
+ for (const rf of REQUIRED_FIELDS) {
100
+ if (!(rf in item)) {
101
+ const err = new Error(`Item at index ${i} missing required field: "${rf}"`);
102
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
103
+ throw err;
104
+ }
105
+ if (typeof item[rf] !== 'string' || item[rf].trim().length === 0) {
106
+ const err = new Error(`Item at index ${i} field "${rf}" must be a non-empty string`);
107
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
108
+ throw err;
109
+ }
110
+ }
111
+
112
+ // Validate ID regex
113
+ if (!CANONICAL_ID_REGEX.test(item.id)) {
114
+ const err = new Error(`Item at index ${i} has invalid ID "${item.id}". Must match ^TEST-[A-Z0-9]+-[A-Z0-9]+$`);
115
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
116
+ throw err;
117
+ }
118
+
119
+ // Validate duplicate ID
120
+ if (seenIds.has(item.id)) {
121
+ const err = new Error(`Duplicate test ID detected in gemstack-test-matrix: "${item.id}"`);
122
+ err.code = 'TEST_MATRIX_DUPLICATE_ID';
123
+ throw err;
124
+ }
125
+ seenIds.add(item.id);
126
+
127
+ // Validate enum fields
128
+ if (!CANONICAL_LAYERS.includes(item.layer)) {
129
+ const err = new Error(`Item "${item.id}" has invalid layer "${item.layer}". Must be one of: ${CANONICAL_LAYERS.join(', ')}`);
130
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
131
+ throw err;
132
+ }
133
+
134
+ if (!CANONICAL_GATES.includes(item.gate)) {
135
+ const err = new Error(`Item "${item.id}" has invalid gate "${item.gate}". Must be one of: ${CANONICAL_GATES.join(', ')}`);
136
+ err.code = 'TEST_MATRIX_INVALID_SHAPE';
137
+ throw err;
138
+ }
139
+
140
+ validated.push({
141
+ id: item.id,
142
+ category: item.category,
143
+ layer: item.layer,
144
+ description: item.description,
145
+ pass_criteria: item.pass_criteria,
146
+ gate: item.gate
147
+ });
148
+ }
149
+
150
+ return validated;
151
+ }
152
+
153
+ /**
154
+ * Computes deterministic SHA-256 acceptanceSignature digest over canonical test matrix.
155
+ *
156
+ * @param {Array<object>} matrix
157
+ * @returns {string} 64-character lowercase hexadecimal digest
158
+ */
159
+ function computeAcceptanceSignature(matrix) {
160
+ const validated = validateTestMatrix(matrix);
161
+
162
+ // Sort canonical records by id using deterministic code-unit ordering
163
+ const sorted = [...validated].sort((a, b) => (a.id < b.id ? -1 : (a.id > b.id ? 1 : 0)));
164
+
165
+ // Normalize each record with ASCII-sorted keys: category, description, gate, id, layer, pass_criteria
166
+ const normalizedRecords = sorted.map(rec => ({
167
+ category: rec.category,
168
+ description: rec.description,
169
+ gate: rec.gate,
170
+ id: rec.id,
171
+ layer: rec.layer,
172
+ pass_criteria: rec.pass_criteria
173
+ }));
174
+
175
+ const canonicalJson = JSON.stringify(normalizedRecords);
176
+ return crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex');
177
+ }
178
+
179
+ module.exports = {
180
+ CANONICAL_LAYERS,
181
+ CANONICAL_GATES,
182
+ CANONICAL_ID_REGEX,
183
+ REQUIRED_FIELDS,
184
+ extractTestMatrixBlock,
185
+ validateTestMatrix,
186
+ computeAcceptanceSignature
187
+ };