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,444 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const crypto = require('node:crypto');
4
+ const { execFileSync } = require('node:child_process');
5
+ const { hashFile, normalizePath, normalizeContent } = require('./hasher');
6
+
7
+ function parsePlanBindings(planContent, canonicalMatrix) {
8
+ const normalized = normalizeContent(planContent);
9
+ const lines = normalized.split('\n');
10
+ const fence = String.fromCharCode(96, 96, 96);
11
+ const header = fence + 'gemstack-test-bindings';
12
+
13
+ const blocks = [];
14
+ let inBlock = false;
15
+ let blockLines = [];
16
+
17
+ for (const line of lines) {
18
+ if (!inBlock) {
19
+ if (line.trimEnd() === header) {
20
+ inBlock = true;
21
+ blockLines = [];
22
+ }
23
+ } else {
24
+ if (line.trimEnd() === fence) {
25
+ inBlock = false;
26
+ blocks.push(blockLines.join('\n'));
27
+ } else {
28
+ blockLines.push(line);
29
+ }
30
+ }
31
+ }
32
+
33
+ if (blocks.length === 0) {
34
+ return [];
35
+ }
36
+
37
+ if (blocks.length > 1) {
38
+ const err = new Error('Multiple gemstack-test-bindings blocks detected. Exactly one is permitted.');
39
+ err.code = 'BINDINGS_PARSE_ERROR';
40
+ throw err;
41
+ }
42
+
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(blocks[0].trim());
46
+ } catch (e) {
47
+ const err = new Error('Failed to parse gemstack-test-bindings JSON: ' + e.message);
48
+ err.code = 'BINDINGS_PARSE_ERROR';
49
+ throw err;
50
+ }
51
+
52
+ if (!Array.isArray(parsed)) {
53
+ const err = new Error('gemstack-test-bindings must be a JSON array');
54
+ err.code = 'BINDINGS_PARSE_ERROR';
55
+ throw err;
56
+ }
57
+
58
+ const validCanonicalIds = new Set(canonicalMatrix ? canonicalMatrix.map(m => m.id) : []);
59
+ const seenTestIds = new Set();
60
+ const validated = [];
61
+
62
+ for (let i = 0; i < parsed.length; i++) {
63
+ const item = parsed[i];
64
+ if (!item || typeof item !== 'object') {
65
+ const err = new Error('Binding at index ' + i + ' must be an object');
66
+ err.code = 'BINDINGS_PARSE_ERROR';
67
+ throw err;
68
+ }
69
+
70
+ const testId = item.test_id || item.id;
71
+ if (!testId || typeof testId !== 'string') {
72
+ const err = new Error('Binding at index ' + i + ' missing test_id');
73
+ err.code = 'BINDINGS_PARSE_ERROR';
74
+ throw err;
75
+ }
76
+
77
+ if (validCanonicalIds.size > 0 && !validCanonicalIds.has(testId)) {
78
+ const err = new Error('Binding references unknown canonical ID: "' + testId + '"');
79
+ err.code = 'BINDINGS_PARSE_ERROR';
80
+ throw err;
81
+ }
82
+
83
+ if (seenTestIds.has(testId)) {
84
+ const err = new Error('Duplicate test_id binding detected: "' + testId + '"');
85
+ err.code = 'DUPLICATE_TEST_BINDING';
86
+ throw err;
87
+ }
88
+ seenTestIds.add(testId);
89
+
90
+ if (!item.file || typeof item.file !== 'string') {
91
+ const err = new Error('Binding for "' + testId + '" missing file');
92
+ err.code = 'BINDINGS_PARSE_ERROR';
93
+ throw err;
94
+ }
95
+
96
+ const normFile = item.file.replace(/\\/g, '/');
97
+ if (path.isAbsolute(normFile) || normFile.startsWith('../')) {
98
+ const err = new Error('Binding file must be repository-relative POSIX path: "' + item.file + '"');
99
+ err.code = 'BINDINGS_PARSE_ERROR';
100
+ throw err;
101
+ }
102
+
103
+ validated.push({
104
+ test_id: testId,
105
+ runner: item.runner || 'node:test',
106
+ file: normFile
107
+ });
108
+ }
109
+
110
+ return validated;
111
+ }
112
+
113
+ function parsePlanGates(planContent) {
114
+ const normalized = normalizeContent(planContent);
115
+ const lines = normalized.split('\n');
116
+ const fence = String.fromCharCode(96, 96, 96);
117
+ const header = fence + 'gemstack-closure-gates';
118
+
119
+ const blocks = [];
120
+ let inBlock = false;
121
+ let blockLines = [];
122
+
123
+ for (const line of lines) {
124
+ if (!inBlock) {
125
+ if (line.trimEnd() === header) {
126
+ inBlock = true;
127
+ blockLines = [];
128
+ }
129
+ } else {
130
+ if (line.trimEnd() === fence) {
131
+ inBlock = false;
132
+ blocks.push(blockLines.join('\n'));
133
+ } else {
134
+ blockLines.push(line);
135
+ }
136
+ }
137
+ }
138
+
139
+ if (blocks.length === 0) {
140
+ return [];
141
+ }
142
+
143
+ if (blocks.length > 1) {
144
+ const err = new Error('Multiple gemstack-closure-gates blocks detected. Exactly one is permitted.');
145
+ err.code = 'GATES_PARSE_ERROR';
146
+ throw err;
147
+ }
148
+
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(blocks[0].trim());
152
+ } catch (e) {
153
+ const err = new Error('Failed to parse gemstack-closure-gates JSON: ' + e.message);
154
+ err.code = 'GATES_PARSE_ERROR';
155
+ throw err;
156
+ }
157
+
158
+ if (!Array.isArray(parsed)) {
159
+ const err = new Error('gemstack-closure-gates must be a JSON array');
160
+ err.code = 'GATES_PARSE_ERROR';
161
+ throw err;
162
+ }
163
+
164
+ const seenGateIds = new Set();
165
+ const validated = [];
166
+
167
+ for (let i = 0; i < parsed.length; i++) {
168
+ const item = parsed[i];
169
+ if (!item.id || typeof item.id !== 'string') {
170
+ const err = new Error('Gate at index ' + i + ' missing id');
171
+ err.code = 'GATES_PARSE_ERROR';
172
+ throw err;
173
+ }
174
+
175
+ if (seenGateIds.has(item.id)) {
176
+ const err = new Error('Duplicate gate id detected: "' + item.id + '"');
177
+ err.code = 'GATES_PARSE_ERROR';
178
+ throw err;
179
+ }
180
+ seenGateIds.add(item.id);
181
+
182
+ if (item.type !== 'PACKAGE_SCRIPT') {
183
+ const err = new Error('Unsupported gate type: "' + item.type + '". Only PACKAGE_SCRIPT is supported in MVP.');
184
+ err.code = 'GATES_PARSE_ERROR';
185
+ throw err;
186
+ }
187
+
188
+ if (!item.script || typeof item.script !== 'string') {
189
+ const err = new Error('PACKAGE_SCRIPT gate "' + item.id + '" missing script name');
190
+ err.code = 'GATES_PARSE_ERROR';
191
+ throw err;
192
+ }
193
+
194
+ if (!['REQUIRED', 'SUPPLEMENTAL'].includes(item.requirement)) {
195
+ const err = new Error('Gate "' + item.id + '" has invalid requirement: "' + item.requirement + '"');
196
+ err.code = 'GATES_PARSE_ERROR';
197
+ throw err;
198
+ }
199
+
200
+ if (item.requirement === 'REQUIRED' && typeof item.waivable !== 'boolean') {
201
+ const err = new Error('REQUIRED gate "' + item.id + '" requires explicit boolean waivable property');
202
+ err.code = 'GATES_PARSE_ERROR';
203
+ throw err;
204
+ }
205
+
206
+ validated.push({
207
+ id: item.id,
208
+ type: item.type,
209
+ script: item.script,
210
+ requirement: item.requirement,
211
+ waivable: typeof item.waivable === 'boolean' ? item.waivable : false
212
+ });
213
+ }
214
+
215
+ return validated;
216
+ }
217
+
218
+ function parseTaskMetadata(tasksContent) {
219
+ const normalized = normalizeContent(tasksContent);
220
+ const lines = normalized.split('\n');
221
+ const tasks = [];
222
+
223
+ const taskHeaderRegex = /^[*-]\s+\[[ xX]\]\s+\*\*(T\d+):\s*(.*?)\*\*/;
224
+ let currentTask = null;
225
+
226
+ for (const line of lines) {
227
+ const match = line.match(taskHeaderRegex);
228
+ if (match) {
229
+ if (currentTask) {
230
+ tasks.push(currentTask);
231
+ }
232
+ currentTask = {
233
+ id: match[1],
234
+ title: match[2].trim(),
235
+ validation_required: null,
236
+ tests: [],
237
+ files: [],
238
+ depends: []
239
+ };
240
+ continue;
241
+ }
242
+
243
+ if (currentTask) {
244
+ const vMatch = line.match(/<!--\s*gemstack:validation_required=(true|false)\s*-->/);
245
+ if (vMatch) {
246
+ currentTask.validation_required = vMatch[1] === 'true';
247
+ continue;
248
+ }
249
+ const tMatch = line.match(/<!--\s*gemstack:tests=(.*?)\s*-->/);
250
+ if (tMatch) {
251
+ currentTask.tests = tMatch[1] ? tMatch[1].split(',').map(s => s.trim()).filter(Boolean) : [];
252
+ continue;
253
+ }
254
+ const fMatch = line.match(/<!--\s*gemstack:files=(.*?)\s*-->/);
255
+ if (fMatch) {
256
+ currentTask.files = fMatch[1] ? fMatch[1].split(',').map(s => s.trim().replace(/\\/g, '/')).filter(Boolean) : [];
257
+ continue;
258
+ }
259
+ const dMatch = line.match(/<!--\s*gemstack:depends=(.*?)\s*-->/);
260
+ if (dMatch) {
261
+ currentTask.depends = dMatch[1] ? dMatch[1].split(',').map(s => s.trim()).filter(Boolean) : [];
262
+ continue;
263
+ }
264
+ }
265
+ }
266
+
267
+ if (currentTask) {
268
+ tasks.push(currentTask);
269
+ }
270
+
271
+ for (const t of tasks) {
272
+ if (t.validation_required === null) {
273
+ const err = new Error('Task ' + t.id + ' must explicitly declare validation_required');
274
+ err.code = 'TASK_VALIDATION_MISSING';
275
+ throw err;
276
+ }
277
+ if (t.validation_required && t.tests.length === 0) {
278
+ const err = new Error('Task ' + t.id + ' has validation_required=true but specifies no tests');
279
+ err.code = 'TASK_VALIDATION_MISSING';
280
+ throw err;
281
+ }
282
+ }
283
+
284
+ return tasks;
285
+ }
286
+
287
+ function reconcileTaskTraceability(canonicalMatrix, taskList) {
288
+ const reverseMap = {};
289
+ const tasksWithValidation = [];
290
+ const tasksDocOnly = [];
291
+
292
+ for (const t of taskList) {
293
+ if (t.validation_required) {
294
+ tasksWithValidation.push(t.id);
295
+ for (const testId of t.tests) {
296
+ if (!reverseMap[testId]) {
297
+ reverseMap[testId] = [];
298
+ }
299
+ reverseMap[testId].push(t.id);
300
+ }
301
+ } else {
302
+ tasksDocOnly.push(t.id);
303
+ }
304
+ }
305
+
306
+ const unmappedCanonical = [];
307
+ const requiredCanonical = canonicalMatrix.filter(m => m.gate === 'REQUIRED');
308
+
309
+ for (const c of requiredCanonical) {
310
+ if (!reverseMap[c.id] || reverseMap[c.id].length === 0) {
311
+ unmappedCanonical.push(c.id);
312
+ }
313
+ }
314
+
315
+ const summary = {
316
+ tasks_total: taskList.length,
317
+ tasks_with_validation: tasksWithValidation.length,
318
+ tasks_documentation_only: tasksDocOnly.length,
319
+ unmapped_canonical_tests: unmappedCanonical
320
+ };
321
+
322
+ return { summary, unmappedCanonical, reverseMap };
323
+ }
324
+
325
+ function resolveRelevantFiles(rootPath, featureDir, planBindings, taskList, planGates) {
326
+ const relFeature = normalizePath(featureDir, rootPath);
327
+ const filesSet = new Set();
328
+
329
+ const specPath = (relFeature + '/spec.md').replace(/^\.\//, '');
330
+ const planPath = (relFeature + '/plan.md').replace(/^\.\//, '');
331
+ const tasksPath = (relFeature + '/tasks.md').replace(/^\.\//, '');
332
+
333
+ if (fs.existsSync(path.join(rootPath, specPath))) filesSet.add(specPath);
334
+ if (fs.existsSync(path.join(rootPath, planPath))) filesSet.add(planPath);
335
+ if (fs.existsSync(path.join(rootPath, tasksPath))) filesSet.add(tasksPath);
336
+
337
+ for (const b of (planBindings || [])) {
338
+ if (b.file && fs.existsSync(path.join(rootPath, b.file))) {
339
+ filesSet.add(b.file);
340
+ }
341
+ }
342
+
343
+ for (const t of (taskList || [])) {
344
+ for (const f of (t.files || [])) {
345
+ if (fs.existsSync(path.join(rootPath, f))) {
346
+ filesSet.add(f);
347
+ }
348
+ }
349
+ }
350
+
351
+ const hasPkgScript = (planGates || []).some(g => g.type === 'PACKAGE_SCRIPT');
352
+ if (hasPkgScript && fs.existsSync(path.join(rootPath, 'package.json'))) {
353
+ filesSet.add('package.json');
354
+ }
355
+
356
+ return Array.from(filesSet).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
357
+ }
358
+
359
+ function computeContentAggregateHash(rootPath, filePaths) {
360
+ const sortedPaths = [...filePaths].sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
361
+ const entries = [];
362
+
363
+ for (const p of sortedPaths) {
364
+ const abs = path.join(rootPath, p);
365
+ if (fs.existsSync(abs)) {
366
+ entries.push({
367
+ path: p.replace(/\\/g, '/'),
368
+ hash: hashFile(abs)
369
+ });
370
+ }
371
+ }
372
+
373
+ const canonicalJson = JSON.stringify(entries);
374
+ return crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex');
375
+ }
376
+
377
+ function resolveRepositoryContext(rootPath) {
378
+ const gitDir = path.join(rootPath, '.git');
379
+ if (!fs.existsSync(gitDir)) {
380
+ return {
381
+ type: 'non-git',
382
+ commit: null,
383
+ working_tree_clean: null
384
+ };
385
+ }
386
+
387
+ try {
388
+ const headCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
389
+ cwd: rootPath,
390
+ stdio: ['ignore', 'pipe', 'ignore'],
391
+ encoding: 'utf8'
392
+ }).trim();
393
+
394
+ const statusOutput = execFileSync('git', ['status', '--porcelain'], {
395
+ cwd: rootPath,
396
+ stdio: ['ignore', 'pipe', 'ignore'],
397
+ encoding: 'utf8'
398
+ }).trim();
399
+
400
+ return {
401
+ type: 'git',
402
+ commit: headCommit,
403
+ working_tree_clean: statusOutput.length === 0
404
+ };
405
+ } catch (e) {
406
+ return {
407
+ type: 'git',
408
+ commit: null,
409
+ working_tree_clean: null
410
+ };
411
+ }
412
+ }
413
+
414
+ function computeClosureContextHash(contextObj) {
415
+ const sortedKeys = Object.keys(contextObj).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
416
+ const normalized = {};
417
+
418
+ for (const k of sortedKeys) {
419
+ const val = contextObj[k];
420
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
421
+ const subKeys = Object.keys(val).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
422
+ normalized[k] = {};
423
+ for (const sk of subKeys) {
424
+ normalized[k][sk] = val[sk];
425
+ }
426
+ } else {
427
+ normalized[k] = val;
428
+ }
429
+ }
430
+
431
+ const canonicalJson = JSON.stringify(normalized);
432
+ return crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex');
433
+ }
434
+
435
+ module.exports = {
436
+ parsePlanBindings,
437
+ parsePlanGates,
438
+ parseTaskMetadata,
439
+ reconcileTaskTraceability,
440
+ resolveRelevantFiles,
441
+ computeContentAggregateHash,
442
+ resolveRepositoryContext,
443
+ computeClosureContextHash
444
+ };