@sungen/driver-api 3.2.29 → 3.2.31

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/api-harness.ts +91 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-api",
3
- "version": "3.2.29",
3
+ "version": "3.2.31",
4
4
  "description": "Sungen API capability — the @api annotation, API catalog, OpenAPI import + `sungen api import`, and the specs/api.ts runtime template. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "author": "eqe team (engineer & quality) — Sun Asterisk",
14
14
  "license": "MIT",
15
15
  "dependencies": {
16
- "@sun-asterisk/sungen": "3.2.29",
16
+ "@sun-asterisk/sungen": "3.2.31",
17
17
  "commander": "^14.0.2",
18
18
  "yaml": "^2.8.2"
19
19
  },
@@ -12,8 +12,11 @@
12
12
  * Type-only on core (like the rest of the driver) — the depth threshold + project context arrive via
13
13
  * the gate input, so loading the driver never forces a core runtime resolve.
14
14
  */
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import { parse as parseYaml } from 'yaml';
15
18
  import type { Catalog, GateResult, DepthResult } from '@sun-asterisk/sungen';
16
- import { resolveApi } from './api-catalog';
19
+ import { resolveApi, type ApiEntry } from './api-catalog';
17
20
 
18
21
  /** A scenario as the audit parses it (the subset the API gate reads). */
19
22
  interface ApiScenario {
@@ -67,16 +70,29 @@ export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth:
67
70
  const criticalMethods = (input.businessCriticalMethods?.length ? input.businessCriticalMethods : DEFAULT_CRITICAL).map((m) => m.toUpperCase());
68
71
  const apiScenarios = scenarios.filter((s) => (s.apiRefs ?? []).length);
69
72
 
70
- // Resolve each referenced endpoint's HTTP method (best-effort; unresolved → the verification
71
- // sensor already fails the gate, so we just skip it here).
72
- const methodOf = new Map<string, string>();
73
+ // Resolve each referenced entry (best-effort; unresolved → the verification sensor already fails
74
+ // the gate, so we just skip it here — it contributes no theme).
75
+ const entryOf = new Map<string, ApiEntry>();
73
76
  for (const s of apiScenarios) {
74
77
  for (const name of s.apiRefs ?? []) {
75
- if (methodOf.has(name)) continue;
76
- try { methodOf.set(name, resolveApi(name, screenName, cwd).method); } catch { /* unresolved */ }
78
+ if (entryOf.has(name)) continue;
79
+ try { entryOf.set(name, resolveApi(name, screenName, cwd)); } catch { /* unresolved */ }
77
80
  }
78
81
  }
79
- const isMutating = (name: string) => criticalMethods.includes((methodOf.get(name) ?? '').toUpperCase());
82
+ const isMutating = (name: string) => criticalMethods.includes((entryOf.get(name)?.method ?? '').toUpperCase());
83
+
84
+ // Alias groups: two catalog entries that are the same request under two names — the `<x>_control`
85
+ // clone an author adds so one scenario can bind two responses of one endpoint — are ONE endpoint.
86
+ // Counting the clone separately demanded its own error scenario, a gap nothing but a duplicate
87
+ // scenario could close. Entries on the same route that differ in any request/response detail
88
+ // (encoding, body, headers, expect, …) stay distinct contracts.
89
+ const defaultDatasource = defaultDatasourceName(cwd);
90
+ const aliasGroups = new Map<string, string[]>(); // fingerprint → alias names, first referenced = label
91
+ for (const [name, entry] of entryOf) {
92
+ const key = endpointFingerprint(entry, defaultDatasource);
93
+ const group = aliasGroups.get(key);
94
+ if (group) group.push(name); else aliasGroups.set(key, [name]);
95
+ }
80
96
 
81
97
  // ── Coverage: must-cover viewpoints per resolved endpoint ──────────────────────────────────
82
98
  // read (GET/HEAD): {contract}; mutating: {contract, error}. (auth/idempotency/side-effect are
@@ -90,12 +106,14 @@ export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth:
90
106
  const gaps: GateResult['gaps'] = [];
91
107
  let covered = 0;
92
108
  let themesTotal = 0;
93
- for (const endpoint of methodOf.keys()) {
94
- const vps: ('contract' | 'error')[] = isMutating(endpoint) ? ['contract', 'error'] : ['contract'];
109
+ for (const aliases of aliasGroups.values()) {
110
+ const label = aliases[0];
111
+ const vps: ('contract' | 'error')[] = isMutating(label) ? ['contract', 'error'] : ['contract'];
95
112
  for (const vp of vps) {
96
113
  themesTotal++;
97
- if (apiScenarios.some((s) => asserts(s, endpoint, vp))) covered++;
98
- else gaps.push({ theme: `${endpoint}:${vp}`, keywords: [], status: 'missing' });
114
+ // A theme is covered when ANY alias of the endpoint asserts it.
115
+ if (apiScenarios.some((s) => aliases.some((alias) => asserts(s, alias, vp)))) covered++;
116
+ else gaps.push({ theme: `${label}:${vp}`, keywords: [], status: 'missing' });
99
117
  }
100
118
  }
101
119
  const coverageRatio = themesTotal ? covered / themesTotal : 0;
@@ -142,3 +160,65 @@ export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth:
142
160
 
143
161
  return { gate, depth };
144
162
  }
163
+
164
+ /**
165
+ * The datasource a request without an explicit `datasource` runs against — the same rule as the
166
+ * runtime client: the first `kind: api` entry of datasources.yaml (project root, then qa/), else the
167
+ * first entry. `undefined` when the project has no datasources.yaml (then only entries that both
168
+ * omit the field can match each other).
169
+ */
170
+ function defaultDatasourceName(cwd: string): string | undefined {
171
+ const file = [path.join(cwd, 'datasources.yaml'), path.join(cwd, 'qa', 'datasources.yaml')].find((f) => fs.existsSync(f));
172
+ if (!file) return undefined;
173
+ try {
174
+ const doc = parseYaml(fs.readFileSync(file, 'utf8')) as { datasources?: Record<string, { kind?: string }> } | null;
175
+ const all = doc?.datasources ?? {};
176
+ const keys = Object.keys(all);
177
+ return keys.find((k) => (all[k]?.kind || 'api') === 'api') ?? keys[0];
178
+ } catch {
179
+ return undefined;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Identity of the request/response contract an entry describes — every field except how the entry
185
+ * is named (`name`, `description`) and where it lives (`scope`, `file`), with the effective
186
+ * datasource filled in. Canonical (sorted keys), so YAML key order never splits an alias.
187
+ */
188
+ function endpointFingerprint(entry: ApiEntry, defaultDatasource: string | undefined): string {
189
+ const { name: _name, description: _description, scope: _scope, file: _file, ...contract } = entry;
190
+ const sorted = (xs: string[] | undefined) => (xs ? [...xs].sort() : undefined);
191
+ return canonicalJson({
192
+ ...contract,
193
+ datasource: entry.datasource ?? defaultDatasource,
194
+ // Sets, not sequences: `params` is validated against the `:refs` in both directions with no order,
195
+ // `arrayPaths` is a membership list — a retyped clone must not split on order.
196
+ params: sorted(entry.params),
197
+ arrayPaths: sorted(entry.arrayPaths),
198
+ // The runtime treats header names case-insensitively — so does the identity.
199
+ headers: entry.headers ? Object.fromEntries(Object.entries(entry.headers).map(([k, v]) => [k.toLowerCase(), v])) : undefined,
200
+ // `status: 201` and `status: "201"` are the same expectation (every consumer reads it via String());
201
+ // an `expect:` block without a status is no expectation at all.
202
+ expect: entry.expect?.status != null ? { status: String(entry.expect.status) } : undefined,
203
+ });
204
+ }
205
+
206
+ /** Deterministic JSON: sorted object keys, `undefined` members dropped, cycles and non-finite numbers named. */
207
+ function canonicalJson(value: unknown, seen: WeakSet<object> = new WeakSet()): string {
208
+ if (typeof value === 'number' && !Number.isFinite(value)) return String(value); // NaN/Infinity ≠ null
209
+ if (Array.isArray(value)) {
210
+ if (seen.has(value)) return '"[cycle]"';
211
+ seen.add(value);
212
+ return `[${value.map((v) => canonicalJson(v, seen)).join(',')}]`;
213
+ }
214
+ if (value && typeof value === 'object') {
215
+ if (seen.has(value)) return '"[cycle]"'; // a self-referencing YAML anchor must not crash the audit
216
+ seen.add(value);
217
+ const entries = Object.entries(value as Record<string, unknown>)
218
+ .filter(([, v]) => v !== undefined)
219
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
220
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v, seen)}`);
221
+ return `{${entries.join(',')}}`;
222
+ }
223
+ return JSON.stringify(value) ?? 'null';
224
+ }