gemstack-ai 1.1.2 → 1.3.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/skills/gemstack-plan/SKILL.md +2 -1
- package/.agents/skills/gemstack-qa/SKILL.md +3 -0
- package/.agents/skills/gemstack-ship/SKILL.md +5 -1
- package/.agents/skills/gemstack-spec/SKILL.md +3 -2
- package/.agents/skills/gemstack-tasks/SKILL.md +4 -3
- package/.gemstack/state.json +7 -8
- package/CHANGELOG.md +75 -0
- package/README.md +36 -0
- package/RELEASE_NOTES.md +61 -0
- package/docs/architecture-consistency.md +14 -2
- package/docs/spec-driven-development.md +26 -0
- package/{gemstack-ai-1.1.2.tgz → gemstack-ai-1.3.0.tgz} +0 -0
- package/handoff.md +30 -15
- package/package.json +2 -2
- 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/008-cost-provider-safety-gates/.gemstack.json +5 -0
- package/specs/008-cost-provider-safety-gates/closure.json +59 -0
- package/specs/008-cost-provider-safety-gates/plan.md +456 -0
- package/specs/008-cost-provider-safety-gates/spec.md +633 -0
- package/specs/008-cost-provider-safety-gates/tasks.md +635 -0
- package/specs/009-context-capsule/closure.json +59 -0
- package/specs/009-context-capsule/context-capsule.json +428 -0
- package/specs/009-context-capsule/plan.md +663 -0
- package/specs/009-context-capsule/spec.md +913 -0
- package/specs/009-context-capsule/tasks.md +720 -0
- package/specs/templates/plan.md +30 -0
- package/specs/templates/spec.md +18 -0
- package/specs/templates/tasks.md +9 -0
- package/src/cli.js +8 -0
- package/src/commands/collect.js +340 -0
- package/src/commands/context.js +95 -0
- package/src/commands/ship.js +79 -0
- package/src/commands/verify.js +182 -6
- package/src/lib/closure-context.js +453 -0
- package/src/lib/context-capsule.js +594 -0
- package/src/lib/cost-ledger.js +355 -0
- package/src/lib/provider-boundary.js +186 -0
- package/src/lib/provider-registry.js +265 -0
- package/src/lib/runner-adapters.js +347 -0
- package/src/lib/safety-gates.js +277 -0
- package/src/lib/test-matrix.js +187 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { createFinding } = require('./findings');
|
|
4
|
+
|
|
5
|
+
const VALID_ENV_TIERS = ['test', 'ci', 'development', 'staging', 'production'];
|
|
6
|
+
const PROVIDER_ID_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
7
|
+
const REMOTE_URL_PATTERN = /^https?:\/\//i;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the deterministic environment tier.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} [options={}]
|
|
13
|
+
* @param {string} [options.environment]
|
|
14
|
+
* @param {object} [options.env=process.env]
|
|
15
|
+
* @returns {string} One of: 'test', 'ci', 'development', 'staging', 'production'
|
|
16
|
+
*/
|
|
17
|
+
function resolveEnvironmentTier(options = {}) {
|
|
18
|
+
const env = options.env || process.env;
|
|
19
|
+
|
|
20
|
+
if (options.environment && VALID_ENV_TIERS.includes(options.environment)) {
|
|
21
|
+
return options.environment;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (env.GEMSTACK_ENV && VALID_ENV_TIERS.includes(env.GEMSTACK_ENV)) {
|
|
25
|
+
return env.GEMSTACK_ENV;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Detect CI first
|
|
29
|
+
if (env.CI === 'true' || env.CI === '1' || env.CONTINUOUS_INTEGRATION === 'true') {
|
|
30
|
+
return 'ci';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Detect test runner
|
|
34
|
+
if (env.NODE_ENV === 'test' || typeof global.it === 'function' || typeof global.test === 'function' || process.argv.includes('--test')) {
|
|
35
|
+
return 'test';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return 'development';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates whether commercial providers are permitted in a given environment tier.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} envTier
|
|
45
|
+
* @param {object} [options={}]
|
|
46
|
+
* @param {boolean} [options.allowBillable=false]
|
|
47
|
+
* @param {object} [options.ciWaiver=null]
|
|
48
|
+
* @returns {{ allowed: boolean, reasonCode: string, message: string }}
|
|
49
|
+
*/
|
|
50
|
+
function checkEnvironmentCommercialPolicy(envTier, options = {}) {
|
|
51
|
+
if (envTier === 'test') {
|
|
52
|
+
return {
|
|
53
|
+
allowed: false,
|
|
54
|
+
reasonCode: 'ENV_COMMERCIAL_DENIED',
|
|
55
|
+
message: 'Commercial provider invocation is strictly denied in test environment.'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (envTier === 'ci') {
|
|
60
|
+
if (options.ciWaiver && options.ciWaiver.signed === true && options.ciWaiver.expires_at > Date.now()) {
|
|
61
|
+
return {
|
|
62
|
+
allowed: true,
|
|
63
|
+
reasonCode: 'CI_WAIVER_ACCEPTED',
|
|
64
|
+
message: 'Commercial invocation permitted in CI via explicit signed waiver.'
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
allowed: false,
|
|
69
|
+
reasonCode: 'ENV_COMMERCIAL_DENIED',
|
|
70
|
+
message: 'Commercial provider invocation is denied in CI environment without explicit signed waiver.'
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (envTier === 'development') {
|
|
75
|
+
if (!options.allowBillable) {
|
|
76
|
+
return {
|
|
77
|
+
allowed: false,
|
|
78
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
79
|
+
message: 'Commercial provider actions in development require explicit authorization (--allow-billable).'
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
allowed: true,
|
|
84
|
+
reasonCode: 'DEV_AUTHORIZED',
|
|
85
|
+
message: 'Commercial execution authorized for development session.'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// staging / production
|
|
90
|
+
return {
|
|
91
|
+
allowed: true,
|
|
92
|
+
reasonCode: 'PRODUCTION_ALLOWED',
|
|
93
|
+
message: 'Commercial execution permitted under managed environment.'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validates integrity of a MOCK provider configuration to prevent network escape.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} providerConfig
|
|
101
|
+
* @returns {{ valid: boolean, reasonCode: string|null, message: string|null }}
|
|
102
|
+
*/
|
|
103
|
+
function validateMockIntegrity(providerConfig) {
|
|
104
|
+
if (!providerConfig || typeof providerConfig !== 'object') {
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
108
|
+
message: 'Mock provider configuration must be a valid object.'
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (providerConfig.type !== 'MOCK') {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
116
|
+
message: 'Provider is not declared with type "MOCK".'
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Scan for remote endpoints, urls, or external host configurations
|
|
121
|
+
const forbiddenFields = ['url', 'endpoint', 'baseUrl', 'base_url', 'host', 'remoteUrl', 'remote_url'];
|
|
122
|
+
for (const field of forbiddenFields) {
|
|
123
|
+
if (typeof providerConfig[field] === 'string' && providerConfig[field].trim()) {
|
|
124
|
+
const val = providerConfig[field].trim();
|
|
125
|
+
if (REMOTE_URL_PATTERN.test(val) || val.includes('://') || (!val.includes('localhost') && !val.includes('127.0.0.1') && val.includes('.'))) {
|
|
126
|
+
return {
|
|
127
|
+
valid: false,
|
|
128
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
129
|
+
message: 'Mock provider declared forbidden remote endpoint at field "' + field + '": ' + val
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Scan nested capabilities for remote endpoint overrides
|
|
136
|
+
if (providerConfig.capabilities && typeof providerConfig.capabilities === 'object') {
|
|
137
|
+
for (const [capId, cap] of Object.entries(providerConfig.capabilities)) {
|
|
138
|
+
if (cap && typeof cap === 'object') {
|
|
139
|
+
for (const field of forbiddenFields) {
|
|
140
|
+
if (typeof cap[field] === 'string' && cap[field].trim()) {
|
|
141
|
+
return {
|
|
142
|
+
valid: false,
|
|
143
|
+
reasonCode: 'MOCK_PROVIDER_ESCAPE_VIOLATION',
|
|
144
|
+
message: 'Mock capability "' + capId + '" declared forbidden remote endpoint at "' + field + '".'
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
valid: true,
|
|
154
|
+
reasonCode: null,
|
|
155
|
+
message: null
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Creates an in-memory ProviderRegistry instance.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} providersMap
|
|
163
|
+
* @returns {object} ProviderRegistry
|
|
164
|
+
*/
|
|
165
|
+
function createProviderRegistry(providersMap = {}) {
|
|
166
|
+
const normalizedProviders = new Map();
|
|
167
|
+
|
|
168
|
+
for (const [rawId, config] of Object.entries(providersMap)) {
|
|
169
|
+
const id = rawId.toLowerCase().trim();
|
|
170
|
+
if (!PROVIDER_ID_REGEX.test(id)) {
|
|
171
|
+
throw new Error('Invalid provider ID format: "' + rawId + '". Must match slug pattern: ' + PROVIDER_ID_REGEX.toString());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const type = (config.type || 'COMMERCIAL').toUpperCase();
|
|
175
|
+
const capabilities = new Map();
|
|
176
|
+
|
|
177
|
+
if (config.capabilities) {
|
|
178
|
+
if (Array.isArray(config.capabilities)) {
|
|
179
|
+
for (const cap of config.capabilities) {
|
|
180
|
+
capabilities.set(cap, { id: cap });
|
|
181
|
+
}
|
|
182
|
+
} else if (typeof config.capabilities === 'object') {
|
|
183
|
+
for (const [capId, capVal] of Object.entries(config.capabilities)) {
|
|
184
|
+
capabilities.set(capId, capVal);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
normalizedProviders.set(id, {
|
|
190
|
+
...config,
|
|
191
|
+
id,
|
|
192
|
+
type,
|
|
193
|
+
capabilities
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
hasProvider(providerId) {
|
|
199
|
+
if (typeof providerId !== 'string') return false;
|
|
200
|
+
return normalizedProviders.has(providerId.toLowerCase().trim());
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
getProvider(providerId) {
|
|
204
|
+
if (typeof providerId !== 'string') return null;
|
|
205
|
+
return normalizedProviders.get(providerId.toLowerCase().trim()) || null;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
hasCapability(providerId, capabilityId) {
|
|
209
|
+
const p = this.getProvider(providerId);
|
|
210
|
+
if (!p) return false;
|
|
211
|
+
return p.capabilities.has(capabilityId);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
getCapability(providerId, capabilityId) {
|
|
215
|
+
const p = this.getProvider(providerId);
|
|
216
|
+
if (!p) return null;
|
|
217
|
+
return p.capabilities.get(capabilityId) || null;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
listProviders() {
|
|
221
|
+
return Array.from(normalizedProviders.values());
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Loads provider registry from repository artifacts or ledger.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} rootPath
|
|
230
|
+
* @param {string} [featureDir='']
|
|
231
|
+
* @returns {object} ProviderRegistry
|
|
232
|
+
*/
|
|
233
|
+
function loadProviderRegistry(rootPath, featureDir = '') {
|
|
234
|
+
const candidatePaths = [
|
|
235
|
+
path.join(rootPath, featureDir, 'cost-ledger.json'),
|
|
236
|
+
path.join(rootPath, 'cost-ledger.json'),
|
|
237
|
+
path.join(rootPath, '.gemstack/cost-ledger.json'),
|
|
238
|
+
path.join(rootPath, '.gemstack/providers.json')
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const p of candidatePaths) {
|
|
242
|
+
if (fs.existsSync(p)) {
|
|
243
|
+
try {
|
|
244
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
245
|
+
if (raw.providers && typeof raw.providers === 'object') {
|
|
246
|
+
return createProviderRegistry(raw.providers);
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
// Fall through to next candidate
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return createProviderRegistry({});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
VALID_ENV_TIERS,
|
|
259
|
+
PROVIDER_ID_REGEX,
|
|
260
|
+
resolveEnvironmentTier,
|
|
261
|
+
checkEnvironmentCommercialPolicy,
|
|
262
|
+
validateMockIntegrity,
|
|
263
|
+
createProviderRegistry,
|
|
264
|
+
loadProviderRegistry
|
|
265
|
+
};
|
|
@@ -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
|
+
};
|