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,277 @@
|
|
|
1
|
+
const { checkEnvironmentCommercialPolicy, resolveEnvironmentTier } = require('./provider-registry');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a normalized, structured gate decision object.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} params
|
|
7
|
+
* @param {boolean} params.authorized
|
|
8
|
+
* @param {string} params.decision - 'ALLOW' | 'DENY'
|
|
9
|
+
* @param {string} params.reasonCode
|
|
10
|
+
* @param {string} params.message
|
|
11
|
+
* @param {object} [params.context={}]
|
|
12
|
+
* @returns {object} Structured gate decision
|
|
13
|
+
*/
|
|
14
|
+
function createGateDecision({ authorized, decision, reasonCode, message, context = {} }) {
|
|
15
|
+
// Strip any accidental credential fields from context
|
|
16
|
+
const cleanContext = {};
|
|
17
|
+
for (const [k, v] of Object.entries(context)) {
|
|
18
|
+
const lk = k.toLowerCase();
|
|
19
|
+
if (!lk.includes('key') && !lk.includes('secret') && !lk.includes('token') && !lk.includes('pass')) {
|
|
20
|
+
cleanContext[k] = v;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
authorized: Boolean(authorized),
|
|
26
|
+
decision: decision || (authorized ? 'ALLOW' : 'DENY'),
|
|
27
|
+
reasonCode,
|
|
28
|
+
reason_code: reasonCode, // backward/spec compatibility
|
|
29
|
+
message,
|
|
30
|
+
context: cleanContext
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ProviderCapabilityGate: Validates provider registration, environment allowance,
|
|
36
|
+
* capability declaration, and adapter execution support.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} request
|
|
39
|
+
* @param {string} request.provider_id
|
|
40
|
+
* @param {string} request.capability_id
|
|
41
|
+
* @param {string} [request.environment]
|
|
42
|
+
* @param {object} [request.adapter]
|
|
43
|
+
* @param {object} registry - ProviderRegistry instance
|
|
44
|
+
* @param {object} [options={}]
|
|
45
|
+
* @returns {object} Structured gate decision
|
|
46
|
+
*/
|
|
47
|
+
function evaluateProviderCapability(request, registry, options = {}) {
|
|
48
|
+
if (!request || typeof request !== 'object') {
|
|
49
|
+
return createGateDecision({
|
|
50
|
+
authorized: false,
|
|
51
|
+
decision: 'DENY',
|
|
52
|
+
reasonCode: 'UNKNOWN_PROVIDER_IDENTITY',
|
|
53
|
+
message: 'Capability evaluation request must be an object.',
|
|
54
|
+
context: {}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const providerId = (request.provider_id || '').toLowerCase().trim();
|
|
59
|
+
const capabilityId = request.capability_id;
|
|
60
|
+
const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
|
|
61
|
+
|
|
62
|
+
// 1. Is Provider Registered?
|
|
63
|
+
if (!providerId || !registry || !registry.hasProvider(providerId)) {
|
|
64
|
+
return createGateDecision({
|
|
65
|
+
authorized: false,
|
|
66
|
+
decision: 'DENY',
|
|
67
|
+
reasonCode: 'UNKNOWN_PROVIDER_IDENTITY',
|
|
68
|
+
message: 'Provider "' + (request.provider_id || '') + '" is not registered in provider registry.',
|
|
69
|
+
context: { provider_id: request.provider_id, capability_id: capabilityId, environment: envTier }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const provider = registry.getProvider(providerId);
|
|
74
|
+
|
|
75
|
+
// 2. Is Provider Permitted in Active Environment?
|
|
76
|
+
if (provider.type === 'COMMERCIAL') {
|
|
77
|
+
const envPolicy = checkEnvironmentCommercialPolicy(envTier, options);
|
|
78
|
+
if (!envPolicy.allowed) {
|
|
79
|
+
return createGateDecision({
|
|
80
|
+
authorized: false,
|
|
81
|
+
decision: 'DENY',
|
|
82
|
+
reasonCode: envPolicy.reasonCode,
|
|
83
|
+
message: envPolicy.message,
|
|
84
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. Is Capability Declared for this Provider?
|
|
90
|
+
if (!capabilityId || !registry.hasCapability(providerId, capabilityId)) {
|
|
91
|
+
return createGateDecision({
|
|
92
|
+
authorized: false,
|
|
93
|
+
decision: 'DENY',
|
|
94
|
+
reasonCode: 'PROVIDER_CAPABILITY_UNDECLARED',
|
|
95
|
+
message: 'Capability "' + (capabilityId || '') + '" is not declared for provider "' + providerId + '".',
|
|
96
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 4. Does Adapter Support Capability?
|
|
101
|
+
if (request.adapter) {
|
|
102
|
+
const hasAdapterSupport = typeof request.adapter.supportsCapability === 'function'
|
|
103
|
+
? request.adapter.supportsCapability(capabilityId)
|
|
104
|
+
: (Array.isArray(request.adapter.supportedCapabilities) && request.adapter.supportedCapabilities.includes(capabilityId));
|
|
105
|
+
|
|
106
|
+
if (!hasAdapterSupport) {
|
|
107
|
+
return createGateDecision({
|
|
108
|
+
authorized: false,
|
|
109
|
+
decision: 'DENY',
|
|
110
|
+
reasonCode: 'PROVIDER_CAPABILITY_UNSUPPORTED',
|
|
111
|
+
message: 'Adapter for provider "' + providerId + '" does not implement capability "' + capabilityId + '".',
|
|
112
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 5. Success
|
|
118
|
+
return createGateDecision({
|
|
119
|
+
authorized: true,
|
|
120
|
+
decision: 'ALLOW',
|
|
121
|
+
reasonCode: 'CAPABILITY_VERIFIED',
|
|
122
|
+
message: 'Provider "' + providerId + '" capability "' + capabilityId + '" verified successfully.',
|
|
123
|
+
context: { provider_id: providerId, capability_id: capabilityId, environment: envTier }
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* BillableActionGate: Evaluates spending authorization, cost state classification,
|
|
129
|
+
* budget limits, and fail-closed commercial defaults.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} request
|
|
132
|
+
* @param {string} request.action_id
|
|
133
|
+
* @param {string} request.provider_id
|
|
134
|
+
* @param {string} request.capability_id
|
|
135
|
+
* @param {string} [request.environment]
|
|
136
|
+
* @param {number} [request.requested_units=1]
|
|
137
|
+
* @param {object} [request.authorization_token]
|
|
138
|
+
* @param {object} ledger - Loaded cost-ledger object or ProviderRegistry
|
|
139
|
+
* @param {object} [options={}]
|
|
140
|
+
* @param {string[]} [options.declaredActions] - Optional whitelist of declared actions in spec.md
|
|
141
|
+
* @returns {object} Structured gate decision
|
|
142
|
+
*/
|
|
143
|
+
function evaluateBillableAction(request, ledger, options = {}) {
|
|
144
|
+
if (!request || typeof request !== 'object') {
|
|
145
|
+
return createGateDecision({
|
|
146
|
+
authorized: false,
|
|
147
|
+
decision: 'DENY',
|
|
148
|
+
reasonCode: 'UNDECLARED_BILLABLE_ACTION',
|
|
149
|
+
message: 'Billable action request must be an object.',
|
|
150
|
+
context: {}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const actionId = request.action_id;
|
|
155
|
+
const providerId = (request.provider_id || '').toLowerCase().trim();
|
|
156
|
+
const capabilityId = request.capability_id;
|
|
157
|
+
const envTier = resolveEnvironmentTier({ environment: request.environment, ...options });
|
|
158
|
+
const requestedUnits = typeof request.requested_units === 'number' ? request.requested_units : 1;
|
|
159
|
+
|
|
160
|
+
// 1. Action Declaration Check (Must be explicitly declared)
|
|
161
|
+
if (!actionId || typeof actionId !== 'string' || !actionId.trim()) {
|
|
162
|
+
return createGateDecision({
|
|
163
|
+
authorized: false,
|
|
164
|
+
decision: 'DENY',
|
|
165
|
+
reasonCode: 'UNDECLARED_BILLABLE_ACTION',
|
|
166
|
+
message: 'Action ID is missing or invalid.',
|
|
167
|
+
context: { action_id: actionId, provider_id: providerId, environment: envTier }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.declaredActions && Array.isArray(options.declaredActions)) {
|
|
172
|
+
if (!options.declaredActions.includes(actionId)) {
|
|
173
|
+
return createGateDecision({
|
|
174
|
+
authorized: false,
|
|
175
|
+
decision: 'DENY',
|
|
176
|
+
reasonCode: 'UNDECLARED_BILLABLE_ACTION',
|
|
177
|
+
message: 'Billable action "' + actionId + '" is not declared in specification or active plan.',
|
|
178
|
+
context: { action_id: actionId, provider_id: providerId, environment: envTier }
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Resolve Provider & Capability from Ledger
|
|
184
|
+
let providerEntry = null;
|
|
185
|
+
let capabilityEntry = null;
|
|
186
|
+
|
|
187
|
+
if (ledger && ledger.providers && ledger.providers[providerId]) {
|
|
188
|
+
providerEntry = ledger.providers[providerId];
|
|
189
|
+
if (providerEntry.capabilities && providerEntry.capabilities[capabilityId]) {
|
|
190
|
+
capabilityEntry = providerEntry.capabilities[capabilityId];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Cost State Resolution (Strict Fail-Closed)
|
|
195
|
+
const costState = capabilityEntry ? capabilityEntry.cost_state : (request.cost_state || 'UNKNOWN');
|
|
196
|
+
|
|
197
|
+
if (costState === 'UNKNOWN' || !['FREE', 'BILLABLE', 'POTENTIALLY_BILLABLE'].includes(costState)) {
|
|
198
|
+
return createGateDecision({
|
|
199
|
+
authorized: false,
|
|
200
|
+
decision: 'DENY',
|
|
201
|
+
reasonCode: 'UNKNOWN_COST_CLASSIFICATION',
|
|
202
|
+
message: 'Action "' + actionId + '" has UNKNOWN cost classification. Fail-closed: execution denied.',
|
|
203
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: 'UNKNOWN', environment: envTier }
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 3. Environment Check for Commercial / Billable
|
|
208
|
+
const isCommercial = providerEntry ? providerEntry.type === 'COMMERCIAL' : (costState !== 'FREE');
|
|
209
|
+
if (isCommercial) {
|
|
210
|
+
const envPolicy = checkEnvironmentCommercialPolicy(envTier, options);
|
|
211
|
+
if (!envPolicy.allowed) {
|
|
212
|
+
return createGateDecision({
|
|
213
|
+
authorized: false,
|
|
214
|
+
decision: 'DENY',
|
|
215
|
+
reasonCode: envPolicy.reasonCode,
|
|
216
|
+
message: envPolicy.message,
|
|
217
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 4. FREE Actions (Permitted for Local / MOCK / Zero Cost)
|
|
223
|
+
if (costState === 'FREE') {
|
|
224
|
+
return createGateDecision({
|
|
225
|
+
authorized: true,
|
|
226
|
+
decision: 'ALLOW',
|
|
227
|
+
reasonCode: 'ACTION_AUTHORIZED_FREE',
|
|
228
|
+
message: 'Action "' + actionId + '" is FREE and authorized for execution.',
|
|
229
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: 'FREE', environment: envTier }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 5. BILLABLE / POTENTIALLY_BILLABLE Actions -> Require Explicit Spending Token
|
|
234
|
+
const token = request.authorization_token;
|
|
235
|
+
const isTokenValid = token && (token.granted === true || token.granted_by || token.source === 'CLI_FLAG' || token.max_budget_units !== undefined);
|
|
236
|
+
|
|
237
|
+
if (!isTokenValid) {
|
|
238
|
+
return createGateDecision({
|
|
239
|
+
authorized: false,
|
|
240
|
+
decision: 'DENY',
|
|
241
|
+
reasonCode: 'BILLABLE_ACTION_UNAUTHORIZED',
|
|
242
|
+
message: 'Action "' + actionId + '" on provider "' + providerId + '" requires explicit spending authorization.',
|
|
243
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier }
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 6. Budget & Unit Threshold Check
|
|
248
|
+
const estimatedUnitCost = (capabilityEntry && typeof capabilityEntry.estimated_unit_cost === 'number') ? capabilityEntry.estimated_unit_cost : 1;
|
|
249
|
+
const estimatedTotalCost = requestedUnits * estimatedUnitCost;
|
|
250
|
+
|
|
251
|
+
if (token && typeof token.max_budget_units === 'number') {
|
|
252
|
+
if (estimatedTotalCost > token.max_budget_units) {
|
|
253
|
+
return createGateDecision({
|
|
254
|
+
authorized: false,
|
|
255
|
+
decision: 'DENY',
|
|
256
|
+
reasonCode: 'BUDGET_THRESHOLD_EXCEEDED',
|
|
257
|
+
message: 'Action "' + actionId + '" estimated cost (' + estimatedTotalCost + ') exceeds authorized budget limit (' + token.max_budget_units + ').',
|
|
258
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost, max_budget_units: token.max_budget_units }
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 7. Full Authorization Pass
|
|
264
|
+
return createGateDecision({
|
|
265
|
+
authorized: true,
|
|
266
|
+
decision: 'ALLOW',
|
|
267
|
+
reasonCode: 'ACTION_AUTHORIZED',
|
|
268
|
+
message: 'Action "' + actionId + '" authorized for execution within budget.',
|
|
269
|
+
context: { action_id: actionId, provider_id: providerId, capability_id: capabilityId, cost_state: costState, environment: envTier, estimated_total_cost: estimatedTotalCost }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createGateDecision,
|
|
275
|
+
evaluateProviderCapability,
|
|
276
|
+
evaluateBillableAction
|
|
277
|
+
};
|
|
@@ -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
|
+
};
|