@sungen/driver-api 3.1.2-beta.116 → 3.1.2-beta.118
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/package.json +2 -2
- package/src/api-context.ts +52 -0
- package/src/api-harness.ts +135 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-api",
|
|
3
|
-
"version": "3.1.2-beta.
|
|
3
|
+
"version": "3.1.2-beta.118",
|
|
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.1.2-beta.
|
|
16
|
+
"@sun-asterisk/sungen": "3.1.2-beta.118",
|
|
17
17
|
"commander": "^14.0.2",
|
|
18
18
|
"yaml": "^2.8.2"
|
|
19
19
|
},
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API discovery + context-mapping (AO-3) — the `api` capability's Discover → Contextualize hooks.
|
|
3
|
+
*
|
|
4
|
+
* Discovery reads the reviewed catalog (apis.yaml) + notes the spec source; context-mapping turns
|
|
5
|
+
* the endpoints into generation units by method-profile: every endpoint → a `matrix` unit (contract
|
|
6
|
+
* + error), each mutating endpoint → also an `async` unit (idempotency), and an api flow → a `flow`
|
|
7
|
+
* unit (the ordered journey). The orchestration loop (`sungen context` → `/sungen:design`) consumes
|
|
8
|
+
* these to know WHAT to author before writing scenarios. Type-only on core.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context, DiscoveryProvider, ContextMapper, GenerationUnit } from '@sun-asterisk/sungen';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { lintApiCatalog } from './api-catalog';
|
|
14
|
+
|
|
15
|
+
interface EndpointFact { name: string; method: string; path: string; datasource?: string; expectStatus?: number | string }
|
|
16
|
+
|
|
17
|
+
const MUTATING = /^(POST|PUT|PATCH|DELETE)$/;
|
|
18
|
+
|
|
19
|
+
export const apiDiscovery: DiscoveryProvider = {
|
|
20
|
+
appliesTo: (t) => t.kind === 'api',
|
|
21
|
+
discover: async (target, scope) => {
|
|
22
|
+
const cwd = (scope as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
23
|
+
const unitId = target.id; // `api/<area>` | `api/flows/<flow>`
|
|
24
|
+
let endpoints: EndpointFact[] = [];
|
|
25
|
+
try {
|
|
26
|
+
endpoints = lintApiCatalog(unitId, null, cwd).entries.map((e) => ({
|
|
27
|
+
name: e.name, method: e.method, path: e.path, datasource: e.datasource, expectStatus: e.expect?.status,
|
|
28
|
+
}));
|
|
29
|
+
} catch { /* no catalog → the verification gate reports it */ }
|
|
30
|
+
|
|
31
|
+
const sources: Record<string, unknown> = {};
|
|
32
|
+
const specPath = path.join(cwd, 'qa', unitId, 'requirements', 'spec.md');
|
|
33
|
+
if (fs.existsSync(specPath)) sources.spec = path.relative(cwd, specPath);
|
|
34
|
+
|
|
35
|
+
return { target: { ...target, capability: 'api' }, sources, facts: { endpoints } };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const apiContextMapper: ContextMapper = {
|
|
40
|
+
decompose: (ctx: Context): GenerationUnit[] => {
|
|
41
|
+
const endpoints = ((ctx.facts ?? {}) as { endpoints?: EndpointFact[] }).endpoints ?? [];
|
|
42
|
+
const units: GenerationUnit[] = [];
|
|
43
|
+
const isFlow = typeof ctx.target.id === 'string' && ctx.target.id.includes('/flows/');
|
|
44
|
+
if (isFlow) units.push({ mode: 'flow', targetSlice: ctx.target }); // the ordered journey is one unit
|
|
45
|
+
for (const e of endpoints) {
|
|
46
|
+
units.push({ mode: 'matrix', targetSlice: e }); // contract + error matrix (70%)
|
|
47
|
+
if (MUTATING.test(e.method)) units.push({ mode: 'async', targetSlice: e }); // idempotency/concurrency (10%)
|
|
48
|
+
}
|
|
49
|
+
if (!units.length) units.push({ mode: 'matrix', targetSlice: ctx.target });
|
|
50
|
+
return units;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API harness — the `api` capability's score-bearing gate + viewpoint catalog (AO-2).
|
|
3
|
+
*
|
|
4
|
+
* The audit engine is capability-routed (AO-1): for an `api/<area>` unit it calls THIS capability's
|
|
5
|
+
* `viewpoints()` + `gateProvider()` instead of the UI ones, then assembles the score with the same
|
|
6
|
+
* `0.4·coverage + 0.3·businessDepth + 0.15·balance + 0.15·trace` formula. We compute API-native
|
|
7
|
+
* coverage + depth here (not the UI keyword model), so an API area is scored on what matters for an
|
|
8
|
+
* API: contract + failure coverage, and — the headline — `businessDepth`: do the business-critical
|
|
9
|
+
* (mutating) scenarios PROVE THE EFFECT (assert body / DB side-effect / concurrency invariant), or
|
|
10
|
+
* just the status?
|
|
11
|
+
*
|
|
12
|
+
* Type-only on core (like the rest of the driver) — the depth threshold + project context arrive via
|
|
13
|
+
* the gate input, so loading the driver never forces a core runtime resolve.
|
|
14
|
+
*/
|
|
15
|
+
import type { Catalog, GateResult, DepthResult } from '@sun-asterisk/sungen';
|
|
16
|
+
import { resolveApi } from './api-catalog';
|
|
17
|
+
|
|
18
|
+
/** A scenario as the audit parses it (the subset the API gate reads). */
|
|
19
|
+
interface ApiScenario {
|
|
20
|
+
name: string;
|
|
21
|
+
apiRefs?: string[];
|
|
22
|
+
queryRefs?: string[];
|
|
23
|
+
casesDataset?: string;
|
|
24
|
+
stepsText?: string; // lowercase steps text
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ApiGateInput {
|
|
28
|
+
scenarios: ApiScenario[];
|
|
29
|
+
focus: string;
|
|
30
|
+
cwd: string;
|
|
31
|
+
screenName: string; // catalog-resolution id, e.g. `api/<area>`
|
|
32
|
+
threshold: number; // required businessDepth for the focus (from core)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MUTATING = /^(POST|PUT|PATCH|DELETE)$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The API viewpoint catalog (display + the project-override seam). The gate computes coverage from
|
|
39
|
+
* the live `apis.yaml`, so this is intentionally light — one universal theme per viewpoint category.
|
|
40
|
+
*/
|
|
41
|
+
export function apiViewpoints(): Catalog {
|
|
42
|
+
const theme = (t: string, keywords: string[]) => ({ theme: t, keywords });
|
|
43
|
+
return {
|
|
44
|
+
page_types: {
|
|
45
|
+
// method-profiles (the API analog of UI page-types) — documented; the gate enforces them.
|
|
46
|
+
read: { detect_keywords: ['get', 'list', 'fetch'], must_cover: [theme('contract', ['status', 'body'])] },
|
|
47
|
+
mutating: {
|
|
48
|
+
detect_keywords: ['create', 'update', 'delete', 'post', 'put', 'patch'],
|
|
49
|
+
must_cover: [theme('contract', ['status', 'body']), theme('error', ['4', '5', 'invalid', 'cases'])],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
universal: [theme('contract', ['status']), theme('error', ['4', '5'])],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Score-bearing API gate: coverage (must-cover viewpoints per endpoint, by method-profile) + depth
|
|
58
|
+
* (mutating scenarios that prove the effect). Returns the same `{gate, depth}` shape the audit
|
|
59
|
+
* assembles into the score.
|
|
60
|
+
*/
|
|
61
|
+
export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth: DepthResult } {
|
|
62
|
+
const { scenarios, focus, cwd, screenName, threshold } = input;
|
|
63
|
+
const apiScenarios = scenarios.filter((s) => (s.apiRefs ?? []).length);
|
|
64
|
+
|
|
65
|
+
// Resolve each referenced endpoint's HTTP method (best-effort; unresolved → the verification
|
|
66
|
+
// sensor already fails the gate, so we just skip it here).
|
|
67
|
+
const methodOf = new Map<string, string>();
|
|
68
|
+
for (const s of apiScenarios) {
|
|
69
|
+
for (const name of s.apiRefs ?? []) {
|
|
70
|
+
if (methodOf.has(name)) continue;
|
|
71
|
+
try { methodOf.set(name, resolveApi(name, screenName, cwd).method); } catch { /* unresolved */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const isMutating = (name: string) => MUTATING.test(methodOf.get(name) ?? '');
|
|
75
|
+
|
|
76
|
+
// ── Coverage: must-cover viewpoints per resolved endpoint ──────────────────────────────────
|
|
77
|
+
// read (GET/HEAD): {contract}; mutating: {contract, error}. (auth/idempotency/side-effect are
|
|
78
|
+
// advisory via the api-viewpoints sensor + rewarded through businessDepth, not coverage-gating.)
|
|
79
|
+
const asserts = (s: ApiScenario, endpoint: string, vp: 'contract' | 'error'): boolean => {
|
|
80
|
+
if (!(s.apiRefs ?? []).includes(endpoint)) return false;
|
|
81
|
+
const t = s.stepsText ?? '';
|
|
82
|
+
if (vp === 'contract') return new RegExp(`\\b${endpoint.toLowerCase()}\\.(status|ok|body|headers|ok_count|status_counts)\\b`).test(t);
|
|
83
|
+
return !!s.casesDataset || /\b[45]\d\d\b/.test(t); // error: a @cases matrix or an asserted 4xx/5xx
|
|
84
|
+
};
|
|
85
|
+
const gaps: GateResult['gaps'] = [];
|
|
86
|
+
let covered = 0;
|
|
87
|
+
let themesTotal = 0;
|
|
88
|
+
for (const endpoint of methodOf.keys()) {
|
|
89
|
+
const vps: ('contract' | 'error')[] = isMutating(endpoint) ? ['contract', 'error'] : ['contract'];
|
|
90
|
+
for (const vp of vps) {
|
|
91
|
+
themesTotal++;
|
|
92
|
+
if (apiScenarios.some((s) => asserts(s, endpoint, vp))) covered++;
|
|
93
|
+
else gaps.push({ theme: `${endpoint}:${vp}`, keywords: [], status: 'missing' });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const coverageRatio = themesTotal ? covered / themesTotal : 0;
|
|
97
|
+
const area = screenName.replace(/^api\//, '');
|
|
98
|
+
const gate: GateResult = {
|
|
99
|
+
pageType: `api:${area}`,
|
|
100
|
+
themesTotal,
|
|
101
|
+
themesCovered: covered,
|
|
102
|
+
coverageRatio,
|
|
103
|
+
gaps,
|
|
104
|
+
universalGaps: [],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ── businessDepth: the SUCCESS path of a mutation must PROVE THE EFFECT, not just assert status ──
|
|
108
|
+
// An error scenario (a @cases matrix, or one asserting a 4xx/5xx) proves the failure CONTRACT —
|
|
109
|
+
// the status IS its correct assertion (the API analog of UI NAV), so it is not depth-required.
|
|
110
|
+
const errorLike = (s: ApiScenario) => !!s.casesDataset || /\bis\s+["']?[45]\d\d\b/.test(s.stepsText ?? '');
|
|
111
|
+
const depthRequired = apiScenarios.filter((s) => (s.apiRefs ?? []).some(isMutating) && !errorLike(s));
|
|
112
|
+
const isDeep = (s: ApiScenario): boolean => {
|
|
113
|
+
const t = s.stepsText ?? '';
|
|
114
|
+
return /\b[a-z_][a-z0-9_]*\.body\b/.test(t) // asserts a response body field
|
|
115
|
+
|| /\b[a-z_][a-z0-9_]*\.(ok_count|status_counts)\b/.test(t) // a concurrency invariant
|
|
116
|
+
|| (s.queryRefs ?? []).length > 0; // a @query DB side-effect cross-check
|
|
117
|
+
};
|
|
118
|
+
const reqShallow = depthRequired.filter((s) => !isDeep(s));
|
|
119
|
+
const bcDepthRatio = depthRequired.length ? (depthRequired.length - reqShallow.length) / depthRequired.length : 1;
|
|
120
|
+
const verdict: DepthResult['verdict'] = bcDepthRatio < threshold ? (focus === 'smoke' ? 'warn' : 'fail') : 'pass';
|
|
121
|
+
const depth: DepthResult = {
|
|
122
|
+
total: apiScenarios.length,
|
|
123
|
+
shallowTotal: reqShallow.length,
|
|
124
|
+
shallowRatio: apiScenarios.length ? reqShallow.length / apiScenarios.length : 0,
|
|
125
|
+
businessCriticalTotal: depthRequired.length,
|
|
126
|
+
businessCriticalShallow: reqShallow.length,
|
|
127
|
+
bcDepthRatio,
|
|
128
|
+
shallowBusinessCritical: reqShallow.map((s) => ({ name: s.name, category: 'API' })),
|
|
129
|
+
focus,
|
|
130
|
+
threshold,
|
|
131
|
+
verdict,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return { gate, depth };
|
|
135
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import type { CapabilityRegistry, Sensor, GateInput } from '@sun-asterisk/sungen';
|
|
20
20
|
import { resolveApi, apiParamNames, lintApiCatalog } from './api-catalog';
|
|
21
21
|
import { registerApiCommand } from './cli-import';
|
|
22
|
+
import { apiViewpoints, apiGateProvider } from './api-harness';
|
|
23
|
+
import { apiDiscovery, apiContextMapper } from './api-context';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Parse `name(a={{x}},b="lit",c=3)` annotation overrides → JS expressions. Inlined from core
|
|
@@ -184,6 +186,10 @@ export function register(registry: CapabilityRegistry): void {
|
|
|
184
186
|
runtimeHelpers: [{ file: 'api.ts', template: 'specs-api.ts' }], // template resolved from core's templates dir
|
|
185
187
|
preconditionCodegen: apiPreconditionCodegen, // @api:<name> → bind {{name}}
|
|
186
188
|
sensors: [apiVerificationSensor, apiViewpointSensor],
|
|
189
|
+
viewpoints: apiViewpoints as () => unknown, // AO-2: API viewpoint catalog
|
|
190
|
+
gateProvider: apiGateProvider as (i: unknown) => unknown, // AO-2: API coverage + businessDepth
|
|
191
|
+
discovery: apiDiscovery, // AO-3: catalog/spec → Context slice
|
|
192
|
+
contextMapper: apiContextMapper, // AO-3: endpoints → matrix/async/flow generation units
|
|
187
193
|
cliCommands: [registerApiCommand as (program: unknown) => void], // `sungen api import <openapi>`
|
|
188
194
|
});
|
|
189
195
|
}
|