@sungen/driver-data-factory 3.2.10-beta.6 → 3.2.12-beta.10
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/common/experience/auth-account-states.yaml +19 -0
- package/common/field-types/currency.yaml +10 -10
- package/common/field-types/email.yaml +19 -16
- package/common/field-types/jp/katakana.yaml +12 -12
- package/common/field-types/number-float.yaml +10 -10
- package/common/field-types/number-integer.yaml +12 -12
- package/common/field-types/password.yaml +22 -22
- package/common/field-types/phone.yaml +14 -14
- package/common/field-types/postcode.yaml +8 -8
- package/common/field-types/string.yaml +11 -11
- package/common/security/broken-auth.yaml +6 -6
- package/common/security/idor.yaml +5 -5
- package/common/security/input-validation.yaml +5 -5
- package/common/security/sqli.yaml +7 -7
- package/common/security/upload.yaml +7 -7
- package/common/security/xss.yaml +9 -9
- package/common/techniques/boundary.yaml +4 -4
- package/common/techniques/decision-table.yaml +4 -4
- package/common/techniques/equivalence.yaml +4 -4
- package/common/techniques/state-transition.yaml +4 -4
- package/package.json +3 -3
- package/src/applicability.ts +80 -0
- package/src/boundary.ts +319 -0
- package/src/catalog-loader.ts +104 -18
- package/src/cli.ts +233 -14
- package/src/contract.ts +93 -0
- package/src/crosscheck.ts +149 -0
- package/src/detect.ts +122 -0
- package/src/fixture.ts +111 -0
- package/src/generator.ts +231 -98
- package/src/index.ts +17 -2
- package/src/lint.ts +31 -16
- package/src/readme.ts +52 -0
- package/src/sensor.ts +32 -4
- package/src/state.ts +225 -0
- package/src/types.ts +82 -1
- package/src/validate.ts +77 -0
package/src/index.ts
CHANGED
|
@@ -18,15 +18,30 @@ import { registerDataFactoryCommand } from './cli';
|
|
|
18
18
|
import { dataFactoryCoverageSensor } from './sensor';
|
|
19
19
|
import { dfPreconditionCodegen } from './precondition';
|
|
20
20
|
|
|
21
|
-
export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir } from './catalog-loader';
|
|
21
|
+
export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir, describeProvenance } from './catalog-loader';
|
|
22
22
|
export { expandField, expandFieldMap, renderTestData } from './generator';
|
|
23
23
|
export type { FieldDef, FieldMap, ExpandedField, ExpandedPoint } from './generator';
|
|
24
24
|
export { lintFieldMap, summarize } from './lint';
|
|
25
25
|
export type { LintIssue } from './lint';
|
|
26
|
+
export { validateGenerated, validatePoints } from './validate';
|
|
27
|
+
export type { ValidationFinding } from './validate';
|
|
28
|
+
export { expandBoundaryHint, satisfiesConstraints, ensureBvaComplete, emailOfTotalLength } from './boundary';
|
|
29
|
+
export type { Validity, BoundaryPoint, BvaExtra } from './boundary';
|
|
30
|
+
export { evaluateWhen, inProfile, fieldFacts } from './applicability';
|
|
31
|
+
export { generate, buildRequest, validateRequest, fingerprint, GENERATOR_VERSION, SCHEMA_VERSION } from './contract';
|
|
32
|
+
export type { DataFactoryRequest, DataFactoryResult } from './contract';
|
|
33
|
+
export { loadStateRecipes, validateStateRecipe, buildStatePlan, renderPreconditionBlock, topoSort, hasDatasource, scaffoldRecipe, renderRecipeYaml, guessProvenance } from './state';
|
|
34
|
+
export type { StateRecipe, StateResource, StatePlan, StateFinding, Provenance, Lifecycle } from './state';
|
|
35
|
+
export { crosscheckUnit, crosscheckShared, crosscheckAll, referencedVars } from './crosscheck';
|
|
36
|
+
export type { CrossFinding } from './crosscheck';
|
|
37
|
+
export { detectUnit, hasInputFields, hasFieldMap, detectPreconditionSignals, hasStateDeclaration } from './detect';
|
|
38
|
+
export type { DetectFinding } from './detect';
|
|
26
39
|
export { registerDataFactoryCommand } from './cli';
|
|
40
|
+
export { generateFixture } from './fixture';
|
|
41
|
+
export type { FixtureResult, FixtureExpected, ExpectedRow } from './fixture';
|
|
27
42
|
export { dataFactoryCoverageSensor } from './sensor';
|
|
28
43
|
export { dfPreconditionCodegen } from './precondition';
|
|
29
|
-
export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning } from './types';
|
|
44
|
+
export type { Catalog, FieldType, SecurityBank, Technique, Experience, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance, SelectionContext, Profile } from './types';
|
|
30
45
|
|
|
31
46
|
/** Register the Data Factory capability. */
|
|
32
47
|
export function register(registry: CapabilityRegistry): void {
|
package/src/lint.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Data Factory lint — the deterministic coverage oracle from phuong-phap-tao-test-data.md §6.
|
|
3
3
|
*
|
|
4
|
-
* For each field it confirms the "
|
|
4
|
+
* For each field it confirms the "is it enough" checklist: >=1 valid, boundary present when the field has a
|
|
5
5
|
* length/number bound, ≥1 invalid per rule, no unresolved values, error codes mapped (not left as
|
|
6
6
|
* placeholders). It also surfaces blind spots (type not in catalog) and — via the catalog loader —
|
|
7
7
|
* stale `.overwrite` warnings. Pure functions; no side effects.
|
|
8
8
|
*/
|
|
9
|
-
import type { Catalog } from './types';
|
|
10
|
-
import type { FieldMap, ExpandedField } from './generator';
|
|
9
|
+
import type { Catalog, SelectionContext } from './types';
|
|
10
|
+
import type { FieldMap, ExpandedField, FieldDef } from './generator';
|
|
11
11
|
import { expandFieldMap } from './generator';
|
|
12
12
|
|
|
13
13
|
export interface LintIssue {
|
|
@@ -19,14 +19,25 @@ export interface LintIssue {
|
|
|
19
19
|
|
|
20
20
|
const BOUNDED = new Set(['email', 'string', 'password', 'phone', 'postcode', 'katakana', 'number-integer', 'number-float', 'currency']);
|
|
21
21
|
|
|
22
|
-
export function lintFieldMap(map: FieldMap, catalog: Catalog): { issues: LintIssue[]; expanded: ExpandedField[] } {
|
|
23
|
-
const expanded = expandFieldMap(map, catalog);
|
|
22
|
+
export function lintFieldMap(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): { issues: LintIssue[]; expanded: ExpandedField[] } {
|
|
23
|
+
const expanded = expandFieldMap(map, catalog, ctx);
|
|
24
24
|
const issues: LintIssue[] = [];
|
|
25
|
-
const byName = new Map(map.fields.map((f) => [f.name, f]));
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
// Flatten nested groups to leaf fields (dotted path), pairing each with its FieldDef (for `required`).
|
|
27
|
+
const leaves: Array<{ f: ExpandedField; def?: FieldDef; path: string }> = [];
|
|
28
|
+
const walk = (efs: ExpandedField[], defs: FieldDef[], prefix: string) => {
|
|
29
|
+
for (const ef of efs) {
|
|
30
|
+
const def = defs.find((d) => d.name === ef.name);
|
|
31
|
+
const path = prefix ? `${prefix}.${ef.name}` : ef.name;
|
|
32
|
+
if (ef.children) walk(ef.children, def?.fields || [], path);
|
|
33
|
+
else leaves.push({ f: ef, def, path });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
walk(expanded, map.fields, '');
|
|
37
|
+
|
|
38
|
+
for (const { f, def, path } of leaves) {
|
|
28
39
|
if (f.blindSpot) {
|
|
29
|
-
issues.push({ field:
|
|
40
|
+
issues.push({ field: path, severity: 'error', code: 'BLIND_SPOT',
|
|
30
41
|
message: `type "${f.type}" is not in the Data Factory catalog — supply qa/data-factory/project/${f.name}.yaml (valid set, invalid/nonexistent values, error code, dependencies)` });
|
|
31
42
|
continue;
|
|
32
43
|
}
|
|
@@ -34,26 +45,30 @@ export function lintFieldMap(map: FieldMap, catalog: Catalog): { issues: LintIss
|
|
|
34
45
|
const boundary = f.points.filter((p) => p.group === 'boundary');
|
|
35
46
|
const invalid = f.points.filter((p) => p.group === 'invalid');
|
|
36
47
|
|
|
37
|
-
if (valid.length === 0) issues.push({ field:
|
|
38
|
-
if (invalid.length === 0) issues.push({ field:
|
|
39
|
-
if (BOUNDED.has(f.type) && boundary.length === 0) {
|
|
40
|
-
issues.push({ field:
|
|
48
|
+
if (valid.length === 0) issues.push({ field: path, severity: 'error', code: 'NO_VALID', message: 'no valid representative value' });
|
|
49
|
+
if (invalid.length === 0) issues.push({ field: path, severity: 'error', code: 'NO_INVALID', message: 'no invalid value — every rule needs at least one' });
|
|
50
|
+
if (f.type && BOUNDED.has(f.type) && boundary.length === 0) {
|
|
51
|
+
issues.push({ field: path, severity: 'warn', code: 'NO_BOUNDARY', message: `type "${f.type}" usually has min/max — add boundary values or state why not (phuong-phap §6)` });
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
// Unresolved (todo) values — a boundary/gen the constraints could not synthesize.
|
|
44
55
|
for (const p of f.points) {
|
|
45
|
-
if (p.todo) issues.push({ field:
|
|
56
|
+
if (p.todo) issues.push({ field: path, severity: 'warn', code: 'UNRESOLVED',
|
|
46
57
|
message: `${p.id} (${p.group}) needs input: ${p.todo}${p.note ? ` — ${p.note}` : ''}` });
|
|
47
58
|
}
|
|
48
59
|
// Placeholder error codes still unmapped.
|
|
49
60
|
const openErrs = invalid.filter((p) => p.open);
|
|
50
61
|
if (openErrs.length) {
|
|
51
|
-
issues.push({ field:
|
|
62
|
+
issues.push({ field: path, severity: 'info', code: 'OPEN_ERRORREF',
|
|
52
63
|
message: `${openErrs.length} invalid case(s) use placeholder error codes (${[...new Set(openErrs.map((p) => p.expectedError))].join(', ')}) — map to real codes via errorMap in the field-map, or confirm with BA/Dev` });
|
|
53
64
|
}
|
|
54
|
-
const def = byName.get(f.name);
|
|
55
65
|
if (def?.required && valid.length === 0) {
|
|
56
|
-
issues.push({ field:
|
|
66
|
+
issues.push({ field: path, severity: 'error', code: 'REQUIRED_NO_VALID', message: 'required field with no valid value' });
|
|
67
|
+
}
|
|
68
|
+
// Applicability (M2b): cases NOT materialized for this field/context — surfaced, never silent.
|
|
69
|
+
if (f.excluded?.length) {
|
|
70
|
+
issues.push({ field: path, severity: 'info', code: 'NOT_APPLICABLE',
|
|
71
|
+
message: `${f.excluded.length} case(s) not applicable here: ${f.excluded.map((e) => `${e.id} (${e.reason})`).join('; ')}` });
|
|
57
72
|
}
|
|
58
73
|
}
|
|
59
74
|
return { issues, expanded };
|
package/src/readme.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drops a `qa/data-factory/README.md` into the user's project so the folder explains itself.
|
|
3
|
+
* Write-if-absent (never clobbers a user-edited README), like a config file.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
const README = `# \`qa/data-factory/\` — Data Factory (your project's test-data knowledge)
|
|
9
|
+
|
|
10
|
+
This folder is your project's **test-data standard**: machine-readable data rules that
|
|
11
|
+
\`sungen data gen\` / \`/sungen:create-data-test\` (and \`/sungen:create-test\` step 5.3) expand into
|
|
12
|
+
standardized test-data — **valid / boundary / invalid** values, each carrying a \`CHK-*\` trace id back
|
|
13
|
+
to the spec. It is a reusable asset that **survives \`sungen update\`** (only the driver's shipped
|
|
14
|
+
catalog is refreshed; nothing here is touched).
|
|
15
|
+
|
|
16
|
+
It is **not** a copy of your spec — it is the structured, reusable data-rules layer the deterministic
|
|
17
|
+
generator needs, complementary to \`requirements/spec.md\` (which is prose spec per screen/flow).
|
|
18
|
+
|
|
19
|
+
## What lives here
|
|
20
|
+
|
|
21
|
+
| File | What it is |
|
|
22
|
+
|---|---|
|
|
23
|
+
| \`<name>.fields.yaml\` | **Field-map** for a screen/flow — one entry per input field: its catalog \`type\`, the project's real \`constraints\`, an \`errorMap\` (catalog placeholder code → your real message code), and optionally \`unique: true\`, nested \`fields:\`, \`dependsOn:\`. Authored from \`requirements/spec.md\`; expanded deterministically. A unit with **no input fields** (navigation/list) has no field-map — that's correct. |
|
|
24
|
+
| \`common.overwrite.yaml\` | **Project-wide overrides** to the shipped catalog — your real length limits, real error codes, banned values, extra invalid cases. Deep-merged over the common (later-wins, keyed by \`type\` + data-point \`id\`). Genuinely new project knowledge the shipped catalog can't have. |
|
|
25
|
+
| \`project/<entity>.yaml\` | **Blind-spot answers** — business enums, cross-entity rules, data models the catalog can't know. You fill these when Data Factory hits a field \`type\` it doesn't have (instead of it guessing). |
|
|
26
|
+
| \`recipes.yaml\` | Seed recipes for the \`@dataFactory:<recipe>\` annotation — named field sets bound into \`{{recipe.*}}\` at runtime. |
|
|
27
|
+
|
|
28
|
+
## Good to know
|
|
29
|
+
|
|
30
|
+
- **\`sungen data gen\` merges** into \`test-data/<name>.yaml\` — it replaces only the field keys it owns
|
|
31
|
+
and preserves everything else (create-test data, flow-namespaced keys, \`@cases\`, overrides).
|
|
32
|
+
- **\`unique: true\`** makes a field's valid value fresh per run (\`{{$timestamp}}\`) so a register
|
|
33
|
+
email/username never collides — no cross-run cache.
|
|
34
|
+
- \`sungen data lint\` checks coverage; \`sungen data fixture\` builds a CSV import fixture under
|
|
35
|
+
\`qa/fixtures/\`.
|
|
36
|
+
- Edit the field-map / \`.overwrite\` and re-run — never hand-edit the generated \`test-data\` values.
|
|
37
|
+
|
|
38
|
+
Full guide: https://sungen.sun-asterisk.vn/docs/guides/data-factory
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
/** Ensure `qa/data-factory/README.md` exists (write-if-absent). */
|
|
42
|
+
export function ensureDataFactoryReadme(cwd: string): void {
|
|
43
|
+
const dir = join(cwd, 'qa', 'data-factory');
|
|
44
|
+
const path = join(dir, 'README.md');
|
|
45
|
+
if (existsSync(path)) return;
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
writeFileSync(path, README, 'utf8');
|
|
49
|
+
} catch {
|
|
50
|
+
/* best-effort — a missing README must never fail a data command */
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/sensor.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* coverage lint as `sungen data lint` and surfaces the gaps in the generate output — so weak
|
|
6
6
|
* test-data coverage (missing boundary/invalid, unresolved values, unmapped error codes, blind
|
|
7
7
|
* spots) is visible in the standard harness flow, not only when you remember to run `data lint`.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* Auto-detection (Layer A): even with NO field-map, it NOTICES what's missing — a unit with input
|
|
10
|
+
* fields but no standardized data, or a spec that describes a stateful precondition the test never
|
|
11
|
+
* declares — so Sungen nudges QA instead of staying silent (was: "no field-map → nothing to say").
|
|
9
12
|
*
|
|
10
13
|
* Advisory, not gate: the ContextRouter scopes gate sensors by the annotation tags a feature carries,
|
|
11
14
|
* and test-data generation is a design-time concern with no `.feature` tag — so gating here would
|
|
@@ -17,6 +20,8 @@ import { parse as parseYaml } from 'yaml';
|
|
|
17
20
|
import type { Sensor } from '@sun-asterisk/sungen';
|
|
18
21
|
import { loadEffectiveCatalog } from './catalog-loader';
|
|
19
22
|
import { lintFieldMap, summarize } from './lint';
|
|
23
|
+
import { validateGenerated } from './validate';
|
|
24
|
+
import { detectUnit } from './detect';
|
|
20
25
|
import type { FieldMap } from './generator';
|
|
21
26
|
|
|
22
27
|
export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
@@ -25,11 +30,21 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
|
25
30
|
kind: 'advisory',
|
|
26
31
|
run: ({ dir, cwd }) => {
|
|
27
32
|
const name = basename(dir);
|
|
33
|
+
// Layer A auto-detection — runs regardless of whether a field-map exists, so a unit that needs
|
|
34
|
+
// standardization or a declared precondition is NOTICED, not silently skipped.
|
|
35
|
+
const detected = detectUnit(cwd, dir, name).map((d) => ({
|
|
36
|
+
sensorId: 'data-factory-coverage',
|
|
37
|
+
capability: 'data-factory',
|
|
38
|
+
scenario: d.scenario,
|
|
39
|
+
message: d.message,
|
|
40
|
+
severity: d.severity,
|
|
41
|
+
}));
|
|
42
|
+
|
|
28
43
|
const fieldMapPath = join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`);
|
|
29
|
-
if (!existsSync(fieldMapPath)) return
|
|
44
|
+
if (!existsSync(fieldMapPath)) return detected; // no field-map: report only what detection noticed
|
|
30
45
|
let map: FieldMap;
|
|
31
|
-
try { map = parseYaml(readFileSync(fieldMapPath, 'utf8')) as FieldMap; } catch { return
|
|
32
|
-
if (!map || !Array.isArray(map.fields)) return
|
|
46
|
+
try { map = parseYaml(readFileSync(fieldMapPath, 'utf8')) as FieldMap; } catch { return detected; }
|
|
47
|
+
if (!map || !Array.isArray(map.fields)) return detected;
|
|
33
48
|
|
|
34
49
|
const { catalog, warnings } = loadEffectiveCatalog(cwd);
|
|
35
50
|
const { issues } = lintFieldMap(map, catalog);
|
|
@@ -49,6 +64,17 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
|
49
64
|
severity: 'warn',
|
|
50
65
|
});
|
|
51
66
|
}
|
|
67
|
+
// Semantic validity (M1): a value labeled valid that violates the constraints (or a synthesized
|
|
68
|
+
// boundary that lands in-range) — the "structurally right but semantically wrong" class.
|
|
69
|
+
for (const v of validateGenerated(map, catalog)) {
|
|
70
|
+
findings.push({
|
|
71
|
+
sensorId: 'data-factory-coverage',
|
|
72
|
+
capability: 'data-factory',
|
|
73
|
+
scenario: v.field,
|
|
74
|
+
message: `DATA-FACTORY-${v.code}: ${v.message}`,
|
|
75
|
+
severity: v.severity,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
52
78
|
if (findings.length) {
|
|
53
79
|
const s = summarize(issues);
|
|
54
80
|
findings.unshift({
|
|
@@ -59,6 +85,8 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
|
59
85
|
severity: s.errors ? 'warn' : 'info',
|
|
60
86
|
});
|
|
61
87
|
}
|
|
88
|
+
// Detection findings (e.g. an undeclared stateful precondition) are still relevant with a field-map.
|
|
89
|
+
findings.push(...detected);
|
|
62
90
|
return findings;
|
|
63
91
|
},
|
|
64
92
|
};
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — domain/stateful data, Layer 0: the DECLARATION (M3-A).
|
|
3
|
+
*
|
|
4
|
+
* Grounded in a real spec-only project (`examples/nextjp`, web-only, no datasource): domain/stateful
|
|
5
|
+
* preconditions are everywhere in spec + viewpoint, and QA already writes them by hand in prose + tags
|
|
6
|
+
* (e.g. "requires a valid, unused reset token ≤24h; seeded via fixture / prior flow; where it cannot be
|
|
7
|
+
* seeded, treat as @manual"). This module standardizes that: a `state-recipe` (entity graph + relations
|
|
8
|
+
* + desired state + bindings + lifecycle + PROVENANCE) becomes a reviewable, traceable **manual
|
|
9
|
+
* precondition** rendered into the testcase — working with only spec + viewpoint, no DB/API, no Core.
|
|
10
|
+
*
|
|
11
|
+
* Layer 1 (opt-in runtime provisioning) reuses the SAME declaration where an api/db datasource exists;
|
|
12
|
+
* this file does not execute anything — it validates + renders the declaration deterministically.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, existsSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { parse as parseYaml } from 'yaml';
|
|
17
|
+
|
|
18
|
+
/** WHERE a resource's state comes from — NOT only api/db (nextjp: prior-flow / fixture / manual dominate). */
|
|
19
|
+
export type Provenance = 'prior-flow' | 'fixture' | 'client-storage' | 'server-context' | 'api' | 'db' | 'manual';
|
|
20
|
+
export type Lifecycle = 'reusable' | 'unique-per-run' | 'consumable' | 'leased' | 'expiring' | 'mutable-state';
|
|
21
|
+
|
|
22
|
+
const PROVENANCES: Provenance[] = ['prior-flow', 'fixture', 'client-storage', 'server-context', 'api', 'db', 'manual'];
|
|
23
|
+
const LIFECYCLES: Lifecycle[] = ['reusable', 'unique-per-run', 'consumable', 'leased', 'expiring', 'mutable-state'];
|
|
24
|
+
/** Provenances that need a runtime datasource — Layer 1. The rest resolve at design time (Layer 0). */
|
|
25
|
+
const RUNTIME_PROVENANCE = new Set<Provenance>(['api', 'db']);
|
|
26
|
+
|
|
27
|
+
export interface StateResource {
|
|
28
|
+
ref: string;
|
|
29
|
+
entity: string;
|
|
30
|
+
provenance?: Provenance; // default 'manual'
|
|
31
|
+
provider?: string; // @api:x / @query:x — required only for api/db provenance (Layer 1)
|
|
32
|
+
transition?: string; // endpoint that moves the entity to desiredState (Layer 1)
|
|
33
|
+
dependsOn?: string[];
|
|
34
|
+
with?: Record<string, unknown>;
|
|
35
|
+
desiredState?: string;
|
|
36
|
+
lifecycle?: Lifecycle;
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StateRecipe {
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
resources?: StateResource[];
|
|
44
|
+
bindings?: Record<string, string>;
|
|
45
|
+
cleanup?: string | { keep: true; reason: string };
|
|
46
|
+
/** 1b — cross-screen flow-shared dataset: `entity`'s data is shared verbatim across `screens`. */
|
|
47
|
+
shared?: { entity: string; screens: string[] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StateFinding {
|
|
51
|
+
recipe: string;
|
|
52
|
+
severity: 'error' | 'warn' | 'info';
|
|
53
|
+
code: string;
|
|
54
|
+
message: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StatePlan {
|
|
58
|
+
recipe: string;
|
|
59
|
+
/** Topologically ordered resources (dependencies first). */
|
|
60
|
+
order: string[];
|
|
61
|
+
resources: StateResource[];
|
|
62
|
+
bindings: Record<string, string>;
|
|
63
|
+
/** True when NO resource can be auto-provisioned here (no api/db provenance, or missing datasource). */
|
|
64
|
+
manual: boolean;
|
|
65
|
+
/** The identity pairs a shared dataset implies (for the Cross-artifact Gate). */
|
|
66
|
+
sharedIdentity?: { entity: string; screens: string[] };
|
|
67
|
+
/** Human-readable precondition lines (Gherkin Given/And) for weaving into a Background. */
|
|
68
|
+
preconditionLines: string[];
|
|
69
|
+
cleanup?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Load state-recipes (recipes with a `resources` graph) from qa/data-factory/recipes.yaml. */
|
|
73
|
+
export function loadStateRecipes(cwd: string): StateRecipe[] {
|
|
74
|
+
const p = join(cwd, 'qa', 'data-factory', 'recipes.yaml');
|
|
75
|
+
if (!existsSync(p)) return [];
|
|
76
|
+
const doc = parseYaml(readFileSync(p, 'utf8')) || {};
|
|
77
|
+
const recipes = doc.recipes || {};
|
|
78
|
+
const out: StateRecipe[] = [];
|
|
79
|
+
for (const [name, r] of Object.entries<any>(recipes)) {
|
|
80
|
+
if (r && (Array.isArray(r.resources) || r.shared)) out.push({ name, ...r });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Topologically sort resources by dependsOn. Returns { order, cycle } — cycle non-empty ⇒ error. */
|
|
86
|
+
export function topoSort(resources: StateResource[]): { order: string[]; cycle: string[] } {
|
|
87
|
+
const byRef = new Map(resources.map((r) => [r.ref, r]));
|
|
88
|
+
const state = new Map<string, 0 | 1 | 2>(); // 0=unseen,1=in-stack,2=done
|
|
89
|
+
const order: string[] = [];
|
|
90
|
+
let cycle: string[] = [];
|
|
91
|
+
const visit = (ref: string, stack: string[]): boolean => {
|
|
92
|
+
const s = state.get(ref) || 0;
|
|
93
|
+
if (s === 2) return true;
|
|
94
|
+
if (s === 1) { cycle = [...stack.slice(stack.indexOf(ref)), ref]; return false; }
|
|
95
|
+
state.set(ref, 1);
|
|
96
|
+
for (const dep of byRef.get(ref)?.dependsOn || []) {
|
|
97
|
+
if (!byRef.has(dep)) continue; // dangling dep reported separately
|
|
98
|
+
if (!visit(dep, [...stack, ref])) return false;
|
|
99
|
+
}
|
|
100
|
+
state.set(ref, 2);
|
|
101
|
+
order.push(ref);
|
|
102
|
+
return true;
|
|
103
|
+
};
|
|
104
|
+
for (const r of resources) if (!visit(r.ref, [])) break;
|
|
105
|
+
return { order, cycle };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Validate a state-recipe (structure only — no runtime). */
|
|
109
|
+
export function validateStateRecipe(recipe: StateRecipe): StateFinding[] {
|
|
110
|
+
const out: StateFinding[] = [];
|
|
111
|
+
const F = (severity: StateFinding['severity'], code: string, message: string) => out.push({ recipe: recipe.name, severity, code, message });
|
|
112
|
+
const resources = recipe.resources || [];
|
|
113
|
+
const refs = new Set(resources.map((r) => r.ref));
|
|
114
|
+
if (!resources.length && !recipe.shared) F('warn', 'EMPTY', 'recipe has neither resources nor a shared dataset');
|
|
115
|
+
for (const r of resources) {
|
|
116
|
+
if (!r.ref) F('error', 'NO_REF', 'a resource has no `ref`');
|
|
117
|
+
if (!r.entity) F('error', 'NO_ENTITY', `resource "${r.ref}" has no \`entity\``);
|
|
118
|
+
if (r.provenance && !PROVENANCES.includes(r.provenance)) F('error', 'BAD_PROVENANCE', `resource "${r.ref}" provenance "${r.provenance}" ∉ ${PROVENANCES.join('|')}`);
|
|
119
|
+
if (r.lifecycle && !LIFECYCLES.includes(r.lifecycle)) F('error', 'BAD_LIFECYCLE', `resource "${r.ref}" lifecycle "${r.lifecycle}" ∉ ${LIFECYCLES.join('|')}`);
|
|
120
|
+
for (const dep of r.dependsOn || []) if (!refs.has(dep)) F('error', 'DANGLING_DEP', `resource "${r.ref}" dependsOn "${dep}" which is not a resource`);
|
|
121
|
+
// api/db provenance needs a provider endpoint (Layer 1); others must NOT pretend to auto-provision.
|
|
122
|
+
if (RUNTIME_PROVENANCE.has(r.provenance as Provenance) && !r.provider) F('error', 'NO_PROVIDER', `resource "${r.ref}" provenance "${r.provenance}" needs a \`provider\` endpoint (@api:/@query:)`);
|
|
123
|
+
if (r.desiredState && !r.transition && RUNTIME_PROVENANCE.has(r.provenance as Provenance)) F('warn', 'NO_TRANSITION', `resource "${r.ref}" desiredState "${r.desiredState}" has no \`transition\` endpoint — Layer 1 cannot move it automatically`);
|
|
124
|
+
}
|
|
125
|
+
// Bindings must reference a real ${ref.field}.
|
|
126
|
+
for (const [k, v] of Object.entries(recipe.bindings || {})) {
|
|
127
|
+
const m = /^\$\{([^.}]+)\.[^}]+\}$/.exec(String(v));
|
|
128
|
+
if (!m) F('warn', 'BINDING_SHAPE', `binding "${k}: ${v}" is not of the form \${ref.field}`);
|
|
129
|
+
else if (!refs.has(m[1])) F('error', 'BINDING_REF', `binding "${k}" references unknown resource "${m[1]}"`);
|
|
130
|
+
}
|
|
131
|
+
const { cycle } = topoSort(resources);
|
|
132
|
+
if (cycle.length) F('error', 'CYCLE', `dependsOn cycle: ${cycle.join(' → ')}`);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const article = (w: string) => (/^[aeiou]/i.test(w) ? 'an' : 'a');
|
|
137
|
+
|
|
138
|
+
/** Build the deterministic plan + the human-readable precondition block for a state-recipe. */
|
|
139
|
+
export function buildStatePlan(recipe: StateRecipe, opts: { datasource?: boolean } = {}): StatePlan {
|
|
140
|
+
const resources = (recipe.resources || []).map((r) => ({ provenance: 'manual' as Provenance, ...r }));
|
|
141
|
+
const { order } = topoSort(resources);
|
|
142
|
+
const ordered = order.length ? order.map((ref) => resources.find((r) => r.ref === ref)!) : resources;
|
|
143
|
+
// A resource is auto-provisionable only when its provenance is api/db AND a datasource is configured.
|
|
144
|
+
const autoable = (r: StateResource) => RUNTIME_PROVENANCE.has(r.provenance as Provenance) && !!opts.datasource;
|
|
145
|
+
const manual = ordered.length > 0 && !ordered.every(autoable);
|
|
146
|
+
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
for (const r of ordered) {
|
|
149
|
+
const withStr = r.with && Object.keys(r.with).length ? ' ' + Object.entries(r.with).map(([k, v]) => `${k}=${v}`).join(', ') : '';
|
|
150
|
+
const owner = (r.dependsOn || []).length ? ` owned by ${r.dependsOn!.join('/')}` : '';
|
|
151
|
+
const stateStr = r.desiredState ? ` in state "${r.desiredState}"` : '';
|
|
152
|
+
const meta = `[provenance: ${r.provenance}${r.lifecycle ? `; lifecycle: ${r.lifecycle}` : ''}${autoable(r) ? `; auto: ${r.provider}` : ''}]`;
|
|
153
|
+
lines.push(`Given ${article(r.entity)} ${r.entity} "${r.ref}"${owner} exists${stateStr} ${meta}${withStr}`);
|
|
154
|
+
}
|
|
155
|
+
for (const [k, v] of Object.entries(recipe.bindings || {})) lines.push(`# binding: ${k} ← ${v}`);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
recipe: recipe.name,
|
|
159
|
+
order: ordered.map((r) => r.ref),
|
|
160
|
+
resources: ordered,
|
|
161
|
+
bindings: recipe.bindings || {},
|
|
162
|
+
manual,
|
|
163
|
+
...(recipe.shared ? { sharedIdentity: recipe.shared } : {}),
|
|
164
|
+
preconditionLines: lines,
|
|
165
|
+
cleanup: typeof recipe.cleanup === 'string' ? recipe.cleanup : recipe.cleanup ? `keep — ${recipe.cleanup.reason}` : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether the project has a datasource (⇒ Layer 1 possible). */
|
|
170
|
+
export function hasDatasource(cwd: string): boolean {
|
|
171
|
+
return existsSync(join(cwd, 'datasources.yaml')) || existsSync(join(cwd, 'qa', 'datasources.yaml'));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Guess a provenance from a precondition sentence — best-effort; QA confirms. */
|
|
175
|
+
export function guessProvenance(signal: string): Provenance {
|
|
176
|
+
const s = signal.toLowerCase();
|
|
177
|
+
if (/session-storage|session storage|local ?storage|cookie|buffer/.test(s)) return 'client-storage';
|
|
178
|
+
if (/token|reset|verification|otp|invitation|seeded/.test(s)) return 'fixture';
|
|
179
|
+
if (/logged in|authenticated|signed in|auth token|session/.test(s)) return 'server-context';
|
|
180
|
+
if (/already|existing|registered|must exist|previously|prior/.test(s)) return 'prior-flow';
|
|
181
|
+
return 'manual';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Auto-scaffold a state-recipe SKELETON from a spec's precondition sentences (Layer-C auto-draft).
|
|
186
|
+
* Deterministic: one resource per detected signal, provenance guessed, entity left as a TODO for the
|
|
187
|
+
* AI/QA to fill (never fabricates a domain entity). Returns null when no precondition signal is found.
|
|
188
|
+
*/
|
|
189
|
+
export function scaffoldRecipe(unit: string, signals: string[]): StateRecipe | null {
|
|
190
|
+
if (!signals.length) return null;
|
|
191
|
+
const resources: StateResource[] = signals.slice(0, 4).map((sig, i) => ({
|
|
192
|
+
ref: `r${i + 1}`,
|
|
193
|
+
entity: 'TODO', // ← name the real entity (from the signal); do not invent domain values
|
|
194
|
+
provenance: guessProvenance(sig),
|
|
195
|
+
lifecycle: 'reusable',
|
|
196
|
+
note: sig,
|
|
197
|
+
}));
|
|
198
|
+
return {
|
|
199
|
+
name: `${unit}_precondition`,
|
|
200
|
+
description: `stateful precondition for ${unit} — AUTO-SCAFFOLDED from spec; REVIEW: name each entity, set desiredState/bindings, confirm with QA`,
|
|
201
|
+
resources,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Serialize a scaffolded recipe as a YAML block to splice under `recipes:` (2-space indented). */
|
|
206
|
+
export function renderRecipeYaml(recipe: StateRecipe): string {
|
|
207
|
+
const lines: string[] = [` ${recipe.name}:`];
|
|
208
|
+
if (recipe.description) lines.push(` description: "${recipe.description}"`);
|
|
209
|
+
lines.push(' resources:');
|
|
210
|
+
for (const r of recipe.resources || []) {
|
|
211
|
+
lines.push(` - ref: ${r.ref}`);
|
|
212
|
+
lines.push(` entity: ${r.entity} # ← name the real entity`);
|
|
213
|
+
lines.push(` provenance: ${r.provenance} # guessed — confirm (prior-flow|fixture|client-storage|server-context|manual|api|db)`);
|
|
214
|
+
if (r.lifecycle) lines.push(` lifecycle: ${r.lifecycle}`);
|
|
215
|
+
if (r.note) lines.push(` note: ${JSON.stringify(r.note)}`);
|
|
216
|
+
}
|
|
217
|
+
lines.push(' # bindings: { someId: "${r1.id}" } # ← wire ids the test needs');
|
|
218
|
+
return lines.join('\n') + '\n';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Render the precondition block for weaving into a Gherkin Background (with the @manual tag if needed). */
|
|
222
|
+
export function renderPreconditionBlock(plan: StatePlan): string {
|
|
223
|
+
const tag = plan.manual ? '# @manual:data-setup — required state (auto-provisions when an api/db datasource is added)' : '# required state (Layer 1: auto-provisioned via the referenced endpoints)';
|
|
224
|
+
return [`${tag}`, `# recipe: ${plan.recipe}`, ...plan.preconditionLines].join('\n');
|
|
225
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface DataPoint {
|
|
|
9
9
|
id: string;
|
|
10
10
|
/** Literal value (present for concrete cases). */
|
|
11
11
|
value?: string;
|
|
12
|
-
/** Human instruction to synthesize the value when it depends on project constraints (e.g. "local
|
|
12
|
+
/** Human instruction to synthesize the value when it depends on project constraints (e.g. "local part = maxLocal"). */
|
|
13
13
|
gen?: string;
|
|
14
14
|
/** Why this value is invalid (invalid group only). */
|
|
15
15
|
reason?: string;
|
|
@@ -18,6 +18,33 @@ export interface DataPoint {
|
|
|
18
18
|
/** Pull values from a security bank (e.g. `security/sqli`) into this group. */
|
|
19
19
|
from?: string;
|
|
20
20
|
note?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional explicit validity for this point (`valid` | `invalid`). Normally the generator's oracle
|
|
23
|
+
* computes it from the constraints; set this only to pin an intent the constraints can't express
|
|
24
|
+
* (e.g. a format-invalid value whose length happens to sit inside the range).
|
|
25
|
+
*/
|
|
26
|
+
validity?: 'valid' | 'invalid';
|
|
27
|
+
/**
|
|
28
|
+
* Where this point ended up coming from after the `.overwrite` merge — `shipped` (company common) or
|
|
29
|
+
* `overwrite` (the project replaced/added it). Set by the loader; carried through to the generated
|
|
30
|
+
* value so a reader can answer "why is this value here?". Not authored by hand.
|
|
31
|
+
*/
|
|
32
|
+
source?: 'shipped' | 'overwrite';
|
|
33
|
+
/**
|
|
34
|
+
* Applicability conditions (M2b). The point is materialized only when ALL conditions hold for the
|
|
35
|
+
* field + selection context. Field-derived keys are always enforced; environment keys only filter
|
|
36
|
+
* when the context supplies them (a missing context value = "no filter", so the point is kept):
|
|
37
|
+
* - `required: true` — applies only to a REQUIRED field (dropped when the field is explicitly optional).
|
|
38
|
+
* - `negativesAllowed: true` — applies only when the numeric field allows negatives (min < 0 / no min).
|
|
39
|
+
* - `locale: <prefix>` — applies only when ctx.locale starts with the prefix (e.g. `ja`).
|
|
40
|
+
* - `channel: api|ui` / `sink: html|text|…` — applies only when ctx.channel/ctx.sink matches.
|
|
41
|
+
*/
|
|
42
|
+
when?: Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Bucket for output + profile selection. `adversarial` = a security/injection payload (kept out of
|
|
45
|
+
* the plain `invalid` bucket so a QA sees attack cases separately). Default (unset) = functional.
|
|
46
|
+
*/
|
|
47
|
+
category?: 'adversarial';
|
|
21
48
|
}
|
|
22
49
|
|
|
23
50
|
export interface FieldType {
|
|
@@ -48,10 +75,40 @@ export interface Technique {
|
|
|
48
75
|
[k: string]: unknown;
|
|
49
76
|
}
|
|
50
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A domain-EXPERIENCE checklist — cases that come from real-world testing experience, not from a
|
|
80
|
+
* field's format (e.g. the credential/account states a login screen must cover: soft-deleted, locked,
|
|
81
|
+
* unverified…). Each case is a SCENARIO that usually needs a domain-state precondition, so it is
|
|
82
|
+
* knowledge for the login/auth viewpoint + a state-recipe — not a field-level value.
|
|
83
|
+
*/
|
|
84
|
+
export interface Experience {
|
|
85
|
+
experience: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
applies_when?: string;
|
|
88
|
+
cases: Array<Record<string, unknown> & { id: string; expect?: string }>;
|
|
89
|
+
oracle?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
export interface Catalog {
|
|
52
93
|
fieldTypes: Record<string, FieldType>;
|
|
53
94
|
security: Record<string, SecurityBank>;
|
|
54
95
|
techniques: Record<string, Technique>;
|
|
96
|
+
experience: Record<string, Experience>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Named case-selection profiles (which groups/categories a run wants). */
|
|
100
|
+
export type Profile = 'regression' | 'functional' | 'smoke' | 'security-min';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The selection context Core (or a CLI flag) passes to the generator to pick applicable cases.
|
|
104
|
+
* All fields optional — an absent field means "no filter on that axis" (permissive default).
|
|
105
|
+
*/
|
|
106
|
+
export interface SelectionContext {
|
|
107
|
+
locale?: string;
|
|
108
|
+
channel?: 'api' | 'ui';
|
|
109
|
+
sink?: string;
|
|
110
|
+
/** `regression` (default) = every case; see Profile for the reduced sets. */
|
|
111
|
+
profile?: Profile;
|
|
55
112
|
}
|
|
56
113
|
|
|
57
114
|
/** A warning that a project's common.overwrite.yaml entry looks stale against the shipped common. */
|
|
@@ -60,3 +117,27 @@ export interface OverwriteWarning {
|
|
|
60
117
|
detail: string;
|
|
61
118
|
suggestion: string;
|
|
62
119
|
}
|
|
120
|
+
|
|
121
|
+
/** What the `.overwrite` layer changed for one field-type (provenance — "no silent overwrite"). */
|
|
122
|
+
export interface FieldTypeProvenance {
|
|
123
|
+
type: string;
|
|
124
|
+
/** Ids removed via `disable:` (an id that was not in the shipped common is reported as a warning). */
|
|
125
|
+
disabled: string[];
|
|
126
|
+
/** Ids that existed in shipped AND the overlay — the overlay value won. */
|
|
127
|
+
replaced: string[];
|
|
128
|
+
/** Ids new from the overlay (a project-specific case). */
|
|
129
|
+
added: string[];
|
|
130
|
+
/** Constraint keys the overlay changed. */
|
|
131
|
+
constraintsOverridden: string[];
|
|
132
|
+
/** True when the overlay replaced `uses_security`. */
|
|
133
|
+
securityChanged: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Catalog-wide provenance of the shipped-common → project-overwrite merge. */
|
|
137
|
+
export interface CatalogProvenance {
|
|
138
|
+
fieldTypes: Record<string, FieldTypeProvenance>;
|
|
139
|
+
/** Wholly new field-types added by the project (not in shipped common). */
|
|
140
|
+
newFieldTypes: string[];
|
|
141
|
+
security: { added: string[]; replaced: string[] };
|
|
142
|
+
techniques: { added: string[]; replaced: string[] };
|
|
143
|
+
}
|