backend-skeleton 1.3.0 → 1.5.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/bin/bskel.mjs +308 -0
- package/contracts/emit.mjs +22 -6
- package/handles/providers/java-spring/plan.mjs +24 -3
- package/handles/providers/java-spring/rules.mjs +143 -0
- package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
- package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
- package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
- package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
- package/handles/providers/python-fastapi/rules.mjs +133 -0
- package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
- package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
- package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
- package/handles/providers/typescript-express/plan.mjs +15 -2
- package/handles/providers/typescript-express/rules.mjs +129 -0
- package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
- package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
- package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
- package/lib/cli.mjs +37 -0
- package/lib/gate-definitions.mjs +27 -1
- package/lib/workflow.mjs +9 -0
- package/package.json +2 -1
- package/rules/compile.mjs +433 -0
- package/rules/derived.mjs +87 -0
- package/rules/diagnostics.mjs +147 -0
- package/rules/store.mjs +141 -0
- package/rules/vocabulary.mjs +172 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +49 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/feature-rules.schema.json +139 -0
package/rules/store.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// D-business-rules (R1/R2): the I/O boundary for business rules. Paths, reading the hand-authored
|
|
2
|
+
// YAML source, and reading/writing the compiled JSON artifact with schema validation on BOTH
|
|
3
|
+
// sides -- the exact shape lib/cross-feature-collisions.mjs's
|
|
4
|
+
// loadCrossFeatureResolution/saveCrossFeatureResolution and contracts/completeness.mjs's
|
|
5
|
+
// loadResolution/saveResolution already hold ("refusing to write an invalid ...").
|
|
6
|
+
//
|
|
7
|
+
// rules/compile.mjs stays pure and filesystem-free; everything that touches disk lives here.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { parse as parseYaml } from 'yaml';
|
|
11
|
+
import { specPath } from '../lib/paths.mjs';
|
|
12
|
+
import { readJsonIfExists, writeFileAtomic } from '../lib/fsutil.mjs';
|
|
13
|
+
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
14
|
+
import { RULES_SOURCE_SCHEMA } from './compile.mjs';
|
|
15
|
+
|
|
16
|
+
// The hand-authored file. Sits directly in the feature's spec dir alongside dependencies.json --
|
|
17
|
+
// both are human-authored declarations about this feature's own fields, so they are siblings.
|
|
18
|
+
export function rulesSourcePath(root, featureId) {
|
|
19
|
+
return specPath(root, featureId, 'rules.yaml');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The compiled artifact. In its own `rules/` subdirectory, matching how `handles/migration.sql`
|
|
23
|
+
// already nests a generated artifact under the feature's spec dir.
|
|
24
|
+
export function rulesArtifactPath(root, featureId) {
|
|
25
|
+
return specPath(root, featureId, 'rules', `${featureId}.rules.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads and parses specs/<featureId>/rules.yaml. Returns null when the file does not exist at all
|
|
30
|
+
* -- a legal, common state meaning "this feature declares no hand-authored rules", which still
|
|
31
|
+
* compiles to a real artifact from contract-projected rules alone (R4).
|
|
32
|
+
*
|
|
33
|
+
* Throws on malformed YAML or a wrong/missing `schema` key. Deliberately NOT schema-validated
|
|
34
|
+
* beyond that: rules/compile.mjs resolves every rule against the contract itself and produces
|
|
35
|
+
* strictly better refusals (naming real known fields, real declared types, real enum states) than
|
|
36
|
+
* a structural schema could -- see schemas/feature-rules.schema.json's own description.
|
|
37
|
+
*/
|
|
38
|
+
export function loadRulesSource(root, featureId) {
|
|
39
|
+
const file = rulesSourcePath(root, featureId);
|
|
40
|
+
if (!fs.existsSync(file)) return null;
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = parseYaml(fs.readFileSync(file, 'utf8'));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`${file}: not valid YAML -- ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
if (parsed === null || parsed === undefined) return { schema: RULES_SOURCE_SCHEMA, rules: [] };
|
|
48
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
49
|
+
throw new Error(`${file}: must be a YAML mapping with a "rules:" list, got ${Array.isArray(parsed) ? 'a list' : typeof parsed}`);
|
|
50
|
+
}
|
|
51
|
+
if (parsed.schema !== RULES_SOURCE_SCHEMA) {
|
|
52
|
+
throw new Error(`${file}: expected \`schema: ${RULES_SOURCE_SCHEMA}\` at the top of the file, got ${parsed.schema === undefined ? '(nothing)' : JSON.stringify(parsed.schema)}`);
|
|
53
|
+
}
|
|
54
|
+
if (parsed.rules !== undefined && !Array.isArray(parsed.rules)) {
|
|
55
|
+
throw new Error(`${file}: "rules" must be a list, got ${typeof parsed.rules}`);
|
|
56
|
+
}
|
|
57
|
+
return { schema: parsed.schema, rules: parsed.rules ?? [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadRulesArtifact(root, featureId) {
|
|
61
|
+
const file = rulesArtifactPath(root, featureId);
|
|
62
|
+
const parsed = readJsonIfExists(file);
|
|
63
|
+
if (parsed === null) return null;
|
|
64
|
+
const { ok, errors } = validateAgainstSchema('feature-rules.schema.json', parsed);
|
|
65
|
+
if (!ok) {
|
|
66
|
+
throw new Error(`${file}: does not match schemas/feature-rules.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function saveRulesArtifact(root, featureId, artifact) {
|
|
72
|
+
const { ok, errors } = validateAgainstSchema('feature-rules.schema.json', artifact);
|
|
73
|
+
if (!ok) {
|
|
74
|
+
throw new Error(`refusing to write an invalid rules artifact for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
75
|
+
}
|
|
76
|
+
const file = rulesArtifactPath(root, featureId);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
// Tab-indented + trailing newline, matching every other generated JSON artifact in this repo
|
|
79
|
+
// (observe's own <feature>.observed-schema.json uses exactly this). Byte-stable across runs so
|
|
80
|
+
// a no-op recompile leaves the file untouched and the `rules` gate stays green.
|
|
81
|
+
writeFileAtomic(file, `${JSON.stringify(artifact, null, '\t')}\n`);
|
|
82
|
+
return artifact;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A starter rules.yaml, written only when the user explicitly asks for one and only when no file
|
|
86
|
+
// exists. Deliberately contains ZERO real rules -- every example is commented out. Writing a live
|
|
87
|
+
// rule here would be `bskel` inventing a constraint about the user's domain, exactly the line
|
|
88
|
+
// D-greenfield-parameters draws ("does the generated file encode a claim about the user's domain
|
|
89
|
+
// that the user did not state?").
|
|
90
|
+
export function starterRulesSource(featureId) {
|
|
91
|
+
return `# Business rules for ${featureId}.
|
|
92
|
+
#
|
|
93
|
+
# Compiled by \`bskel rules check --feature ${featureId}\` into
|
|
94
|
+
# specs/${featureId}/rules/${featureId}.rules.json, which is what the generated
|
|
95
|
+
# runtime checkers actually execute. Nothing here is enforced until you run that command.
|
|
96
|
+
#
|
|
97
|
+
# Constraints your OpenAPI document already states (minLength, maximum, enum, ...) are picked up
|
|
98
|
+
# AUTOMATICALLY from the contract -- you do not need to repeat them here. This file is only for
|
|
99
|
+
# the rules no schema keyword can express.
|
|
100
|
+
schema: ${RULES_SOURCE_SCHEMA}
|
|
101
|
+
rules: []
|
|
102
|
+
#
|
|
103
|
+
# Uncomment and adapt any of these -- then run \`bskel rules check --feature ${featureId}\`, which
|
|
104
|
+
# verifies every pointer, type, and enum state against your own contract before compiling.
|
|
105
|
+
#
|
|
106
|
+
# - id: end-after-start
|
|
107
|
+
# kind: cross
|
|
108
|
+
# operation: createBooking
|
|
109
|
+
# pointers: [/startDate, /endDate]
|
|
110
|
+
# assert: lt
|
|
111
|
+
# reason: "a booking cannot end before it starts"
|
|
112
|
+
#
|
|
113
|
+
# - id: publish-flow
|
|
114
|
+
# kind: transition
|
|
115
|
+
# operation: updateArticle
|
|
116
|
+
# pointer: /status
|
|
117
|
+
# from: [draft]
|
|
118
|
+
# to: [published, archived]
|
|
119
|
+
# reason: "an article may only be published or archived out of draft"
|
|
120
|
+
#
|
|
121
|
+
# - id: discount-cap
|
|
122
|
+
# kind: field
|
|
123
|
+
# operation: createOrder
|
|
124
|
+
# pointer: /discountPercent
|
|
125
|
+
# assert: maximum
|
|
126
|
+
# value: 50
|
|
127
|
+
# reason: "policy cap, not expressible in the public API schema"
|
|
128
|
+
#
|
|
129
|
+
# - id: order-total
|
|
130
|
+
# kind: derived
|
|
131
|
+
# resource: Order
|
|
132
|
+
# field: total
|
|
133
|
+
# expr:
|
|
134
|
+
# op: sub
|
|
135
|
+
# args:
|
|
136
|
+
# - op: mul
|
|
137
|
+
# args: [{ ref: price }, { ref: quantity }]
|
|
138
|
+
# - { ref: discount }
|
|
139
|
+
# reason: "total = price * quantity - discount; compiles to a real Order Rules pure function"
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// D-business-rules (R3): the closed vocabulary of business-rule assertions this project compiles
|
|
2
|
+
// into target-language code. Frozen, small, and every member mechanically executable in Java,
|
|
3
|
+
// Python, and TypeScript without interpretation -- that constraint is the whole design, not a
|
|
4
|
+
// convenience. See DECISIONS.md's D-business-rules, R3.
|
|
5
|
+
//
|
|
6
|
+
// This module is PURE DATA plus pure predicates over it. It imports nothing, so it can be read by
|
|
7
|
+
// the compiler, the CLI, and every provider emitter without any risk of the import cycle
|
|
8
|
+
// D-zero-config-scan hit (adapter -> lib/doctor.mjs -> lib/verify.mjs -> scanners/registry.mjs).
|
|
9
|
+
//
|
|
10
|
+
// DELIBERATE NON-OVERLAP with `handles/observe-schema-projection.mjs`. That module already
|
|
11
|
+
// projects `required`/`type`/`pattern` out of a contract's own requestBodySchema, and every
|
|
12
|
+
// provider's generated checker already enforces those three. This vocabulary covers exactly the
|
|
13
|
+
// constraints that projection DROPS -- so a single violation is never reported twice by two
|
|
14
|
+
// different checkers, and neither module has to know what the other kept. `required` as an
|
|
15
|
+
// unconditional assertion is therefore deliberately absent here: it is the contract's/OpenAPI
|
|
16
|
+
// document's own statement, not a business rule layered on top. Its genuinely-new sibling,
|
|
17
|
+
// `requiredIf` (conditional on another field), IS here, because no schema keyword expresses it.
|
|
18
|
+
|
|
19
|
+
// ---- field assertions: one scalar JSON Pointer, one bounded comparison ----------------------
|
|
20
|
+
//
|
|
21
|
+
// `valueType` is what the rule's own `value` must be (validated at compile time, so a generated
|
|
22
|
+
// runtime never has to defend against a malformed rule). `appliesTo` is the set of projected
|
|
23
|
+
// scalar types the assertion is meaningful against -- checked against the contract's own
|
|
24
|
+
// requestBodySchema at compile time (R6), which is how a rule that says "minLength on an integer"
|
|
25
|
+
// is refused with the real type named instead of silently never firing.
|
|
26
|
+
export const FIELD_ASSERTS = Object.freeze({
|
|
27
|
+
minLength: Object.freeze({ valueType: 'integer', appliesTo: Object.freeze(['string']), summary: 'string length >= value' }),
|
|
28
|
+
maxLength: Object.freeze({ valueType: 'integer', appliesTo: Object.freeze(['string']), summary: 'string length <= value' }),
|
|
29
|
+
minimum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number >= value' }),
|
|
30
|
+
maximum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number <= value' }),
|
|
31
|
+
exclusiveMinimum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number > value' }),
|
|
32
|
+
exclusiveMaximum: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number < value' }),
|
|
33
|
+
multipleOf: Object.freeze({ valueType: 'number', appliesTo: Object.freeze(['number', 'integer']), summary: 'number is an exact multiple of value' }),
|
|
34
|
+
enum: Object.freeze({ valueType: 'array', appliesTo: Object.freeze(['string', 'number', 'integer', 'boolean']), summary: 'value is one of a fixed list' }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---- cross-field assertions: two or more pointers, compared to each other -------------------
|
|
38
|
+
//
|
|
39
|
+
// `arity: 2` means exactly two pointers; `arity: 'n'` means two or more. `comparison: true` marks
|
|
40
|
+
// the members whose operands must be mutually comparable (both numeric, or both string) -- a
|
|
41
|
+
// `lt` between a string and an integer is refused at compile time rather than given some
|
|
42
|
+
// language-specific coercion behavior that would differ across the three runtimes.
|
|
43
|
+
export const CROSS_ASSERTS = Object.freeze({
|
|
44
|
+
lt: Object.freeze({ arity: 2, comparison: true, summary: 'first < second' }),
|
|
45
|
+
lte: Object.freeze({ arity: 2, comparison: true, summary: 'first <= second' }),
|
|
46
|
+
gt: Object.freeze({ arity: 2, comparison: true, summary: 'first > second' }),
|
|
47
|
+
gte: Object.freeze({ arity: 2, comparison: true, summary: 'first >= second' }),
|
|
48
|
+
eq: Object.freeze({ arity: 2, comparison: true, summary: 'first == second' }),
|
|
49
|
+
neq: Object.freeze({ arity: 2, comparison: true, summary: 'first != second' }),
|
|
50
|
+
requiredIf: Object.freeze({ arity: 2, comparison: false, summary: 'if the first is present, the second must be too' }),
|
|
51
|
+
mutuallyExclusive: Object.freeze({ arity: 'n', comparison: false, summary: 'at most one of these may be present' }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---- the three predicate rule kinds -----------------------------------------------------------
|
|
55
|
+
//
|
|
56
|
+
// `derived` is deliberately NOT here: it produces a value rather than answering true/false about
|
|
57
|
+
// one, so it cannot be executed by a predicate checker at all and takes a different compilation
|
|
58
|
+
// path entirely (R5). Keeping the predicate kinds in their own frozen list is what lets the
|
|
59
|
+
// compiler and every checker iterate them generically.
|
|
60
|
+
export const PREDICATE_KINDS = Object.freeze(['field', 'cross', 'transition']);
|
|
61
|
+
|
|
62
|
+
// Every kind an authored rule may declare -- PREDICATE_KINDS plus `derived`. Used only at the
|
|
63
|
+
// "is this kind even real" dispatch point in rules/compile.mjs; every other consumer (the
|
|
64
|
+
// checkers, `rules explain`'s search) still branches on PREDICATE_KINDS vs. `derived` separately,
|
|
65
|
+
// because the two are executed through genuinely different mechanisms (R5).
|
|
66
|
+
export const ALL_RULE_KINDS = Object.freeze([...PREDICATE_KINDS, 'derived']);
|
|
67
|
+
|
|
68
|
+
// R5/Phase 3: the closed arithmetic vocabulary a `derived` rule's expr tree may use. Deliberately
|
|
69
|
+
// tiny and binary-only (exactly 2 args per op) -- the explicit "no arbitrary arithmetic beyond the
|
|
70
|
+
// frozen operator set" scope boundary this item's own plan named. A third operand is expressed by
|
|
71
|
+
// nesting (`mul(mul(a,b),c)`), never by widening arity.
|
|
72
|
+
export const DERIVED_OPS = Object.freeze(['add', 'sub', 'mul', 'div']);
|
|
73
|
+
|
|
74
|
+
// PascalCase for Java/TypeScript method names (`computeTotal`), snake_case for Python
|
|
75
|
+
// (`compute_total`) -- pure string transforms, reused by all three provider emitters so a
|
|
76
|
+
// resource/field name is capitalized identically everywhere rather than three subtly different
|
|
77
|
+
// regexes drifting apart.
|
|
78
|
+
export function pascalCase(name) {
|
|
79
|
+
return String(name).replace(/(^\w|[-_]\w)/g, (m) => m.replace(/[-_]/, '').toUpperCase());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function snakeCase(name) {
|
|
83
|
+
return String(name)
|
|
84
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
85
|
+
.replace(/[-\s]+/g, '_')
|
|
86
|
+
.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Where a compiled rule came from. `contract` = projected from the feature's own
|
|
90
|
+
// requestBodySchema, i.e. a fact the user's OpenAPI document already asserts, transported (R4 --
|
|
91
|
+
// the same "copying is not synthesizing" posture D-openapi-passthrough established). `declared` =
|
|
92
|
+
// a human wrote it in rules.yaml. Kept distinct in the compiled artifact so an auditor can always
|
|
93
|
+
// tell which constraints the document licensed and which a human added on top.
|
|
94
|
+
export const RULE_ORIGINS = Object.freeze(['contract', 'declared']);
|
|
95
|
+
|
|
96
|
+
// The scalar types a rule can address, matching observe-schema-projection.mjs's own SCALAR_TYPES
|
|
97
|
+
// exactly -- a rule can only constrain something that module already projects as a scalar leaf.
|
|
98
|
+
export const RULE_SCALAR_TYPES = Object.freeze(['string', 'number', 'integer', 'boolean']);
|
|
99
|
+
|
|
100
|
+
export const FIELD_ASSERT_NAMES = Object.freeze(Object.keys(FIELD_ASSERTS).sort());
|
|
101
|
+
export const CROSS_ASSERT_NAMES = Object.freeze(Object.keys(CROSS_ASSERTS).sort());
|
|
102
|
+
|
|
103
|
+
export function getFieldAssert(name) {
|
|
104
|
+
return Object.hasOwn(FIELD_ASSERTS, name) ? FIELD_ASSERTS[name] : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getCrossAssert(name) {
|
|
108
|
+
return Object.hasOwn(CROSS_ASSERTS, name) ? CROSS_ASSERTS[name] : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The typo-defense points, same shape as lib/gate-definitions.mjs's requireGateDefinition() and
|
|
112
|
+
// contracts/completeness.mjs's requireWarningCode(): name the known values rather than letting a
|
|
113
|
+
// misspelled assertion silently compile to a rule that can never fire.
|
|
114
|
+
export function requireFieldAssert(name) {
|
|
115
|
+
const spec = getFieldAssert(name);
|
|
116
|
+
if (!spec) throw new Error(`unknown field assertion "${name}" -- known field assertions: ${FIELD_ASSERT_NAMES.join(', ')}`);
|
|
117
|
+
return spec;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function requireCrossAssert(name) {
|
|
121
|
+
const spec = getCrossAssert(name);
|
|
122
|
+
if (!spec) throw new Error(`unknown cross-field assertion "${name}" -- known cross-field assertions: ${CROSS_ASSERT_NAMES.join(', ')}`);
|
|
123
|
+
return spec;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Is `value` the right shape for this assertion's declared valueType? Deliberately strict:
|
|
127
|
+
// `integer` rejects 1.5 AND rejects a numeric string, because a rule whose operand needs coercing
|
|
128
|
+
// is a rule whose behavior would differ between Java, Python, and JS.
|
|
129
|
+
export function valueMatchesType(valueType, value) {
|
|
130
|
+
switch (valueType) {
|
|
131
|
+
case 'integer': return typeof value === 'number' && Number.isInteger(value);
|
|
132
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
133
|
+
case 'array': return Array.isArray(value) && value.length > 0;
|
|
134
|
+
case 'none': return value === undefined;
|
|
135
|
+
default: return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Two projected scalar types are comparable if they are both numeric or both string. Booleans are
|
|
140
|
+
// deliberately never comparable with `lt`/`gt` (ordering booleans is a language-specific accident,
|
|
141
|
+
// not a business rule); `eq`/`neq` between two booleans is still refused here for the same reason
|
|
142
|
+
// consistency matters more than convenience -- use a field `enum` assertion instead.
|
|
143
|
+
export function typesAreComparable(a, b) {
|
|
144
|
+
const numeric = new Set(['number', 'integer']);
|
|
145
|
+
if (numeric.has(a) && numeric.has(b)) return true;
|
|
146
|
+
return a === 'string' && b === 'string';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Renders one compiled rule as a plain-English sentence, for `bskel rules explain`. Pure and
|
|
150
|
+
// exported (rather than built inline in the CLI) so it has a direct unit test, and so the same
|
|
151
|
+
// wording can be reused by any future renderer -- the same call lib/workflow.mjs made when it
|
|
152
|
+
// exported isMutatingCommand() instead of inlining it.
|
|
153
|
+
export function explainRule({ kind, rule }) {
|
|
154
|
+
if (kind === 'field') {
|
|
155
|
+
const spec = getFieldAssert(rule.assert);
|
|
156
|
+
const what = spec ? spec.summary.replace('value', JSON.stringify(rule.value)) : `${rule.assert} ${JSON.stringify(rule.value)}`;
|
|
157
|
+
return `${rule.pointer} must satisfy: ${what}`;
|
|
158
|
+
}
|
|
159
|
+
if (kind === 'cross') {
|
|
160
|
+
const spec = getCrossAssert(rule.assert);
|
|
161
|
+
if (rule.assert === 'requiredIf') return `if ${rule.pointers[0]} is present, ${rule.pointers[1]} must be present too`;
|
|
162
|
+
if (rule.assert === 'mutuallyExclusive') return `at most one of ${rule.pointers.join(', ')} may be present`;
|
|
163
|
+
return `${rule.pointers[0]} must be ${spec ? spec.summary.replace('first ', '').replace(' second', '') : rule.assert} ${rule.pointers[1]}`;
|
|
164
|
+
}
|
|
165
|
+
if (kind === 'transition') {
|
|
166
|
+
return `${rule.pointer} may only change from {${rule.from.join(', ')}} to {${rule.to.join(', ')}}`;
|
|
167
|
+
}
|
|
168
|
+
if (kind === 'derived') {
|
|
169
|
+
return `${rule.resource}.${rule.field} is computed from (${rule.params.join(', ')}) -- see \`rules/derived.mjs\`'s renderExprInfix() for the exact formula, or the generated <Resource>Rules class itself`;
|
|
170
|
+
}
|
|
171
|
+
return `(no explanation available for kind "${kind}")`;
|
|
172
|
+
}
|
|
@@ -72,6 +72,55 @@ export function findClassOrRecordDeclaration(maskedText) {
|
|
|
72
72
|
return { keyword: m[1], name: m[2], index: m.index };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// D-spring-data-rest-adapter: interface-shaped counterpart to findClassOrRecordDeclaration()
|
|
76
|
+
// above -- CLASS_OR_RECORD_RE is hard-gated to `class`/`record`, so a Spring Data repository
|
|
77
|
+
// interface (`interface X extends JpaRepository<Widget, UUID>`) is never recognized by it. Only
|
|
78
|
+
// the single common shape "interface Name extends Super<A, B>" is matched -- a second
|
|
79
|
+
// extends-interface in a multi-interface list (e.g. `extends QuerydslPredicateExecutor<Widget>,
|
|
80
|
+
// JpaRepository<Widget, UUID>`) is not found; the caller is expected to validate `superName`
|
|
81
|
+
// against a known allow-list and fail closed rather than guess which listed interface is the real
|
|
82
|
+
// Spring Data supertype. Operates on masked text.
|
|
83
|
+
const INTERFACE_EXTENDS_RE = /(?:public\s+)?\binterface\s+(\w+)\s+extends\s+(\w+)/;
|
|
84
|
+
|
|
85
|
+
export function findInterfaceExtendsDeclaration(maskedText) {
|
|
86
|
+
const m = maskedText.match(INTERFACE_EXTENDS_RE);
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const afterSuper = m.index + m[0].length;
|
|
89
|
+
let typeArgsStart = null;
|
|
90
|
+
let typeArgsEnd = null;
|
|
91
|
+
if (maskedText[afterSuper] === '<') {
|
|
92
|
+
const close = matchBalanced(maskedText, afterSuper, '<', '>');
|
|
93
|
+
if (close !== -1) {
|
|
94
|
+
typeArgsStart = afterSuper + 1;
|
|
95
|
+
typeArgsEnd = close;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { name: m[1], superName: m[2], index: m.index, typeArgsStart, typeArgsEnd };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// D-spring-data-rest-adapter: depth-tracked top-level comma split on '<'/'>' only -- same
|
|
102
|
+
// algorithm patch-strategy.mjs's own splitTopLevelParams() uses on '('/')' for a DTO constructor's
|
|
103
|
+
// argument list. Reimplemented here rather than imported: patch-strategy.mjs lives under
|
|
104
|
+
// handles/providers/java-spring/, a DOWNSTREAM consumer of this file -- importing from it here
|
|
105
|
+
// would invert the dependency direction this codebase's module layout otherwise keeps one-way.
|
|
106
|
+
export function splitTopLevelTypeArgs(argsText) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let start = 0;
|
|
110
|
+
for (let i = 0; i < argsText.length; i++) {
|
|
111
|
+
const ch = argsText[i];
|
|
112
|
+
if (ch === '<') depth++;
|
|
113
|
+
else if (ch === '>') depth--;
|
|
114
|
+
else if (ch === ',' && depth === 0) {
|
|
115
|
+
parts.push(argsText.slice(start, i).trim());
|
|
116
|
+
start = i + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const last = argsText.slice(start).trim();
|
|
120
|
+
if (last !== '' || parts.length > 0) parts.push(last);
|
|
121
|
+
return parts.filter((p) => p !== '');
|
|
122
|
+
}
|
|
123
|
+
|
|
75
124
|
const WHITESPACE_RE = /^\s*/;
|
|
76
125
|
// A2 Phase 2 (D-java-ast-helper): `[\w.]+`, not `\w+` -- found live while building the real
|
|
77
126
|
// JavaParser/Symbol-Solver AST cross-check. A fully-qualified annotation
|
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { lineNumberAt, listRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
|
|
9
|
-
import { maskNonCode, findClassOrRecordDeclaration, findClassLevelMappingArgs, findMappingAnnotations } from './_java-spring-analyzer.mjs';
|
|
9
|
+
import { maskNonCode, findClassOrRecordDeclaration, findClassLevelMappingArgs, findMappingAnnotations, matchBalanced, findInterfaceExtendsDeclaration, splitTopLevelTypeArgs } from './_java-spring-analyzer.mjs';
|
|
10
10
|
|
|
11
11
|
const JAVA_BUILD_FILE_GLOBS = ['build.gradle', 'build.gradle.kts', 'pom.xml'];
|
|
12
12
|
|
|
@@ -36,6 +36,23 @@ export function detectJavaSpringRoot(repoRoot) {
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// D-spring-data-rest-adapter: literal substring grep, the same shape as
|
|
40
|
+
// handles/providers/java-spring/emit.mjs's hasSpringAopDependency() -- but here, at SCAN time, for
|
|
41
|
+
// a different reason: @Entity/@RestController pair with spring-boot-starter-data-jpa/-web,
|
|
42
|
+
// dependencies virtually every real Spring Boot app already has, so this adapter has never needed
|
|
43
|
+
// to cross-check a dependency before trusting an annotation. @RepositoryRestResource is unusually
|
|
44
|
+
// easy to add decoratively (copied from a tutorial) without the actual
|
|
45
|
+
// spring-boot-starter-data-rest starter, and without it ALL 6 synthesized routes would be
|
|
46
|
+
// fictional, not just one field -- severe enough to warrant a check no other annotation in this
|
|
47
|
+
// adapter needs. Same version-drift fragility hasSpringAopDependency's own history found for a
|
|
48
|
+
// different artifact name (D-handles-pilot-cohort) is inherited here, not re-solved.
|
|
49
|
+
export function hasSpringDataRestDependency(repoRoot) {
|
|
50
|
+
for (const buildFile of listRgFiles(repoRoot, JAVA_BUILD_FILE_GLOBS)) {
|
|
51
|
+
if (fs.readFileSync(buildFile, 'utf8').includes('spring-boot-starter-data-rest')) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
// O6: `rg --files` (no `--sort`) is explicitly unordered/parallel by ripgrep's own docs -- two
|
|
40
57
|
// runs against an unchanged repo can return files in a different order, which without this sort
|
|
41
58
|
// would propagate into non-deterministic controller/entity/module array order in every scan
|
|
@@ -165,6 +182,128 @@ function extractController(text, filePath) {
|
|
|
165
182
|
return { className, basePath, operationIds, endpoints, file: filePath, line: classLine };
|
|
166
183
|
}
|
|
167
184
|
|
|
185
|
+
const SPRING_DATA_REPOSITORY_SUPERTYPES = new Set(['JpaRepository', 'CrudRepository', 'PagingAndSortingRepository']);
|
|
186
|
+
|
|
187
|
+
// D-spring-data-rest-adapter: Spring Data REST auto-generates a full CRUD REST API from a
|
|
188
|
+
// repository interface annotated @RepositoryRestResource -- a real, common pattern extractController()
|
|
189
|
+
// above is blind to (it only ever recognizes @RestController classes, and interface declarations
|
|
190
|
+
// aren't recognized anywhere else in this file either). Mirrors extractController()'s shape/style,
|
|
191
|
+
// but gated on a completely different annotation and declaration keyword. Populates
|
|
192
|
+
// controller.declarations[]/endpoint.declarationIndex (see D-route-expansion-provenance) -- this
|
|
193
|
+
// is the first adapter to do so. Every skip below is a deliberate "don't guess" refusal, not a
|
|
194
|
+
// missing feature -- see this item's own DECISIONS.md entry for the full rationale per case.
|
|
195
|
+
function extractRepositoryResource(text, filePath, hasDataRestDependency) {
|
|
196
|
+
if (!/@RepositoryRestResource\b/.test(text)) return { controller: null, note: null };
|
|
197
|
+
|
|
198
|
+
const masked = maskNonCode(text);
|
|
199
|
+
const decl = findInterfaceExtendsDeclaration(masked);
|
|
200
|
+
if (!decl || !SPRING_DATA_REPOSITORY_SUPERTYPES.has(decl.superName)) {
|
|
201
|
+
return { controller: null, note: null };
|
|
202
|
+
}
|
|
203
|
+
const declLine = lineNumberAt(text, decl.index);
|
|
204
|
+
|
|
205
|
+
if (!hasDataRestDependency) {
|
|
206
|
+
return {
|
|
207
|
+
controller: null,
|
|
208
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource found on ${decl.name}, but no spring-boot-starter-data-rest dependency was found in this repo's build.gradle/build.gradle.kts/pom.xml -- Spring Data REST would not actually be active, so no routes are synthesized for this repository.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (decl.typeArgsStart == null || decl.typeArgsEnd == null) {
|
|
213
|
+
return {
|
|
214
|
+
controller: null,
|
|
215
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource found on ${decl.name}, but its "extends ${decl.superName}<...>" generic type arguments could not be parsed -- the entity type is unknown, so no routes are synthesized.`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const typeArgs = splitTopLevelTypeArgs(masked.slice(decl.typeArgsStart, decl.typeArgsEnd));
|
|
219
|
+
if (typeArgs.length < 2) {
|
|
220
|
+
return {
|
|
221
|
+
controller: null,
|
|
222
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource found on ${decl.name}, but "extends ${decl.superName}<...>" does not declare both an entity and id type -- no routes are synthesized.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const entityName = typeArgs[0];
|
|
226
|
+
|
|
227
|
+
// The annotation's own argument text -- scoped so a positional bare-quote grab (like
|
|
228
|
+
// extractQuotedOrValue() elsewhere in this file) can't accidentally pick a DIFFERENT string
|
|
229
|
+
// attribute (@RepositoryRestResource also has e.g. collectionResourceRel) instead of `path`.
|
|
230
|
+
const annotationMatch = masked.match(/@RepositoryRestResource\s*\(/);
|
|
231
|
+
let explicitPath = null;
|
|
232
|
+
if (annotationMatch) {
|
|
233
|
+
const openParen = annotationMatch.index + annotationMatch[0].length - 1;
|
|
234
|
+
const closeParen = matchBalanced(masked, openParen, '(', ')');
|
|
235
|
+
if (closeParen !== -1) {
|
|
236
|
+
const argsText = text.slice(openParen + 1, closeParen);
|
|
237
|
+
const pathMatch = argsText.match(/path\s*=\s*"([^"]*)"/);
|
|
238
|
+
if (pathMatch) explicitPath = pathMatch[1];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!explicitPath) {
|
|
242
|
+
return {
|
|
243
|
+
controller: null,
|
|
244
|
+
note: `${filePath}:${declLine}: @RepositoryRestResource on ${decl.name} has no explicit path="..." attribute -- Spring's default (an English-pluralized entity name) is not guessed here. Add path="..." and re-scan.`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Any interface-body override of an inherited CRUD action (e.g. `@Override @RestResource(exported
|
|
249
|
+
// = false) void deleteById(...)`) changes which of the 6 standard routes are actually exposed --
|
|
250
|
+
// safely determining WHICH action(s) that affects is out of scope, so the whole repository is
|
|
251
|
+
// refused rather than risk a partially-wrong route set (a resolver pointing at something that
|
|
252
|
+
// 404s/405s in the real app is worse than no resolver at all).
|
|
253
|
+
const bodyOpenBrace = masked.indexOf('{', decl.index);
|
|
254
|
+
const bodyCloseBrace = bodyOpenBrace !== -1 ? matchBalanced(masked, bodyOpenBrace, '{', '}') : -1;
|
|
255
|
+
const bodyText = bodyOpenBrace !== -1 && bodyCloseBrace !== -1 ? masked.slice(bodyOpenBrace, bodyCloseBrace) : '';
|
|
256
|
+
if (/@RestResource\b/.test(bodyText)) {
|
|
257
|
+
return {
|
|
258
|
+
controller: null,
|
|
259
|
+
note: `${filePath}:${declLine}: ${decl.name} overrides at least one CRUD method with @RestResource(...) -- which specific action(s) that suppresses or renames can't be safely determined by static scanning, so no routes are synthesized for this repository at all (a partially-correct route set is worse than none).`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const basePath = `/${explicitPath.replace(/^\/+|\/+$/g, '')}`;
|
|
264
|
+
const declarations = [{
|
|
265
|
+
rule: 'java-spring:repository-rest-resource-crud',
|
|
266
|
+
line: declLine,
|
|
267
|
+
label: `@RepositoryRestResource(path="${explicitPath}") on ${decl.name}`,
|
|
268
|
+
}];
|
|
269
|
+
// D-spring-data-rest-adapter (SR2): operationId is bskel's OWN synthesized, self-describing
|
|
270
|
+
// label -- not a verified claim about what a real springdoc-generated document would call the
|
|
271
|
+
// same operation (that was never measured). A real --openapi-file document using a different
|
|
272
|
+
// name for the same route degrades to the existing CONTRACT_OPENAPI_MISSING_OPERATION warning
|
|
273
|
+
// every other operationId mismatch already produces, not a new failure mode. The synthesized,
|
|
274
|
+
// truthy operationId is what lets handles/providers/java-spring/plan.mjs's findFetchOperation()
|
|
275
|
+
// find these routes at all -- that function needed zero code changes (see SR3).
|
|
276
|
+
const ROUTES = [
|
|
277
|
+
{ verb: 'GET', path: basePath, suffix: 'CollectionResource' },
|
|
278
|
+
{ verb: 'POST', path: basePath, suffix: 'CollectionResource' },
|
|
279
|
+
{ verb: 'GET', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
280
|
+
{ verb: 'PUT', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
281
|
+
{ verb: 'PATCH', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
282
|
+
{ verb: 'DELETE', path: `${basePath}/{id}`, suffix: 'ItemResource' },
|
|
283
|
+
];
|
|
284
|
+
const endpoints = ROUTES.map((r) => ({
|
|
285
|
+
verb: r.verb,
|
|
286
|
+
path: r.path,
|
|
287
|
+
operationId: `${r.verb.toLowerCase()}${entityName}${r.suffix}`,
|
|
288
|
+
method: null,
|
|
289
|
+
line: declLine,
|
|
290
|
+
declarationIndex: 0,
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
controller: {
|
|
295
|
+
className: decl.name,
|
|
296
|
+
basePath,
|
|
297
|
+
operationIds: endpoints.map((e) => e.operationId),
|
|
298
|
+
endpoints,
|
|
299
|
+
declarations,
|
|
300
|
+
file: filePath,
|
|
301
|
+
line: declLine,
|
|
302
|
+
},
|
|
303
|
+
note: null,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
168
307
|
// D-entity-id-field-inheritance: found live against a real corpus check (spring-projects/
|
|
169
308
|
// spring-petclinic) -- `Owner extends Person extends BaseEntity`, and `@Id` lives on `BaseEntity`
|
|
170
309
|
// (a `@MappedSuperclass`), the standard, textbook JPA pattern for sharing an id/audit-field base
|
|
@@ -344,6 +483,13 @@ export function scanJavaSpring(repoRoot) {
|
|
|
344
483
|
return modules.get(key);
|
|
345
484
|
};
|
|
346
485
|
|
|
486
|
+
// D-spring-data-rest-adapter (SR5): computed once per scan, not per file -- a repo-wide,
|
|
487
|
+
// multi-module-aware check (same discovery detectJavaSpringRoot() already uses), not the
|
|
488
|
+
// single-root-file-only shape handles/providers/java-spring/emit.mjs's own
|
|
489
|
+
// hasSpringAopDependency() has.
|
|
490
|
+
const hasDataRestDependency = hasSpringDataRestDependency(repoRoot);
|
|
491
|
+
const repositoryResourceNotes = [];
|
|
492
|
+
|
|
347
493
|
for (const file of files) {
|
|
348
494
|
const text = fileTexts.get(file);
|
|
349
495
|
const mod = moduleOf(file, srcRoot, basePackage);
|
|
@@ -356,6 +502,11 @@ export function scanJavaSpring(repoRoot) {
|
|
|
356
502
|
const entity = extractEntity(text, file, classIndex);
|
|
357
503
|
if (entity) moduleEntry(mod).entities.push(entity);
|
|
358
504
|
}
|
|
505
|
+
if (/@RepositoryRestResource\b/.test(text)) {
|
|
506
|
+
const { controller, note } = extractRepositoryResource(text, file, hasDataRestDependency);
|
|
507
|
+
if (controller) moduleEntry(mod).controllers.push(controller);
|
|
508
|
+
if (note) repositoryResourceNotes.push(note);
|
|
509
|
+
}
|
|
359
510
|
if (mod && file.includes(`${path.sep}domain${path.sep}`) && /public\s+enum\s+\w+/.test(text)) {
|
|
360
511
|
const en = extractDomainEnum(text, file);
|
|
361
512
|
if (en) moduleEntry(mod).enums.push(en);
|
|
@@ -370,7 +521,7 @@ export function scanJavaSpring(repoRoot) {
|
|
|
370
521
|
// drift, and every other manifest-shaped gate input in this codebase (stack's `applied_file:`)
|
|
371
522
|
// is repo-relative too.
|
|
372
523
|
const filesRead = files.map((f) => path.relative(repoRoot, f));
|
|
373
|
-
return { srcRoot, modules: [...modules.values()], pathPrefixSignals: detectGlobalPathPrefixSignals(repoRoot), filesRead };
|
|
524
|
+
return { srcRoot, modules: [...modules.values()], pathPrefixSignals: detectGlobalPathPrefixSignals(repoRoot), filesRead, repositoryResourceNotes };
|
|
374
525
|
}
|
|
375
526
|
|
|
376
527
|
// G1: adapter descriptor consumed by scanners/registry.mjs -- see D-adapter-registry in
|
|
@@ -397,7 +548,7 @@ export const adapter = {
|
|
|
397
548
|
detect: detectJavaSpringRoot,
|
|
398
549
|
scan(repoRoot, _detection) {
|
|
399
550
|
const result = scanJavaSpring(repoRoot);
|
|
400
|
-
return { modules: result.modules, pathPrefixSignals: result.pathPrefixSignals, filesRead: result.filesRead };
|
|
551
|
+
return { modules: result.modules, pathPrefixSignals: result.pathPrefixSignals, filesRead: result.filesRead, repositoryResourceNotes: result.repositoryResourceNotes };
|
|
401
552
|
},
|
|
402
553
|
// S2 (D-gate-precision, continued): reuses the EXACT same listJavaFiles() call scan() itself
|
|
403
554
|
// makes -- no separate file-walking logic -- so the `scan` gate's staleness token can re-derive
|
package/scanners/index.mjs
CHANGED
|
@@ -218,6 +218,10 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
|
|
|
218
218
|
const confidence = chosen.confidence;
|
|
219
219
|
const modules = result.modules;
|
|
220
220
|
const pathPrefixSignals = result.pathPrefixSignals ?? [];
|
|
221
|
+
// D-spring-data-rest-adapter (SR4): the first per-adapter diagnostic-message passthrough into
|
|
222
|
+
// unknowns[] -- optional (`?? []`) so an adapter that doesn't populate it degrades to "nothing
|
|
223
|
+
// to report" rather than throwing, the same discipline apiSurfaceSource/filesRead already use.
|
|
224
|
+
const repositoryResourceNotes = result.repositoryResourceNotes ?? [];
|
|
221
225
|
const apiSurfaceSource = result.apiSurfaceSource ?? DEFAULT_API_SURFACE_SOURCE;
|
|
222
226
|
// S2 (D-gate-precision, continued): the adapter's own real read-set, persisted so
|
|
223
227
|
// lib/gate-definitions.mjs's `scan` gate can hash it for a precise staleness token instead of
|
|
@@ -301,6 +305,12 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
|
|
|
301
305
|
if (dbSchema?.live) {
|
|
302
306
|
unknowns.push(...computeDbDrift(dbSchema.live.tables, relatedModules));
|
|
303
307
|
}
|
|
308
|
+
// D-spring-data-rest-adapter (SR4): each entry names a repository the adapter recognized
|
|
309
|
+
// (found @RepositoryRestResource) but refused to synthesize routes for, and why -- a
|
|
310
|
+
// deliberate "don't guess" boundary, never silent.
|
|
311
|
+
if (repositoryResourceNotes.length > 0) {
|
|
312
|
+
unknowns.push(...repositoryResourceNotes);
|
|
313
|
+
}
|
|
304
314
|
// A1 §7: this scan can't correct a global path prefix (only --openapi-file's real-document
|
|
305
315
|
// reconciliation can, see D-openapi-reconciliation) -- but it CAN tell a user who doesn't know
|
|
306
316
|
// that flag exists that the defect is likely present, before they ever emit a wrong contract.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": ["sbf_contract", "feature_id", "feature_uid", "source", "operations", "warnings", "completeness"],
|
|
9
9
|
"properties": {
|
|
10
|
-
"sbf_contract": { "const": "
|
|
10
|
+
"sbf_contract": { "const": "9" },
|
|
11
11
|
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
12
12
|
"feature_uid": { "type": "string", "format": "uuid" },
|
|
13
13
|
"source": {
|
|
@@ -104,6 +104,17 @@
|
|
|
104
104
|
"type": "array",
|
|
105
105
|
"items": { "type": "string" }
|
|
106
106
|
},
|
|
107
|
+
"expansion": {
|
|
108
|
+
"description": "X2 (D-route-expansion-provenance): present only when this operation's scan endpoint was expanded from a multi-route declaration recognized by the scan adapter (e.g. a future Rails/Laravel-style resource declaration, or a Spring Data REST @RepositoryRestResource) -- omitted for every ordinary 1:1 declared endpoint. `rule` is a namespaced <adapter-id>:<rule-slug> naming the expansion pattern; `declarationLine` is the single source line of the declaration itself (not this operation's own route); `label` is an optional human-readable description, null when not worth composing one.",
|
|
109
|
+
"type": "object",
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"required": ["rule", "declarationLine"],
|
|
112
|
+
"properties": {
|
|
113
|
+
"rule": { "type": "string", "pattern": "^[a-z0-9-]+:[a-z0-9-]+$" },
|
|
114
|
+
"declarationLine": { "type": "integer", "minimum": 1 },
|
|
115
|
+
"label": { "type": ["string", "null"] }
|
|
116
|
+
}
|
|
117
|
+
},
|
|
107
118
|
"sourceDescription": {
|
|
108
119
|
"description": "A10: the operation's `description`, copied verbatim from a real source document. Present only when `contract emit --descriptions` (opt-in, unlike every other source-backed field in this schema) was passed AND the source document declared one for this exact operation AND it did not exceed the length cap.",
|
|
109
120
|
"type": "string"
|