backend-skeleton 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { ADAPTERS, LOAD_ERRORS } from './registry.mjs';
|
|
2
|
+
|
|
3
|
+
// D-scanner-evidence (CATALOG.md's D3 -- NOT the same as this file's own "D3" comment below,
|
|
4
|
+
// which is this repo's internal DECISIONS.md numbering for something unrelated; see the
|
|
5
|
+
// A2/A3 precedent in CATALOG.md for this exact collision class): weight per evidence signal -- the exact 8 values scoreModule() always
|
|
6
|
+
// used, now named and shared between evidence collection and score summation instead of being
|
|
7
|
+
// scattered `score += N` literals.
|
|
8
|
+
const SIGNAL_WEIGHTS = Object.freeze({
|
|
9
|
+
module_name: 10,
|
|
10
|
+
controller_class: 6,
|
|
11
|
+
controller_path: 5,
|
|
12
|
+
endpoint_path: 5,
|
|
13
|
+
endpoint_operation_id: 5,
|
|
14
|
+
entity_table: 8,
|
|
15
|
+
entity_class: 6,
|
|
16
|
+
enum_name: 3,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// D-scanner-evidence: caps how many matches of the SAME signal type contribute to a module's score -- without
|
|
20
|
+
// this, a controller with many endpoints inflates score linearly regardless of real relevance
|
|
21
|
+
// (generic-grep.mjs already fixed exactly this class of bug once, for className-per-route; this
|
|
22
|
+
// closes the same gap for endpoint_path/endpoint_operation_id, which were still uncapped). Every
|
|
23
|
+
// match still appears in the evidence array (`counted:false` beyond the cap) -- capping affects
|
|
24
|
+
// scoring, not transparency. Value chosen empirically, not guessed: validated against
|
|
25
|
+
// Team-IZ-Backend (organization/curriculum modules) to confirm every previously-correct verdict
|
|
26
|
+
// still holds at this value -- see D-scanner-evidence in DECISIONS.md for the sweep.
|
|
27
|
+
const CAP_PER_SIGNAL = 5;
|
|
28
|
+
|
|
29
|
+
// D-scanner-evidence: splits on non-alphanumeric runs AND camelCase boundaries (lower->upper, and an uppercase
|
|
30
|
+
// run's last letter -> Uppercase+lowercase, so "APIController" -> "API","Controller" not
|
|
31
|
+
// "APIController" whole) -- the SAME function handles both identifiers (className, no separators,
|
|
32
|
+
// relies on camelCase splitting) and paths (basePath/endpoint path, no camelCase, relies on
|
|
33
|
+
// separator splitting) correctly, since each just exercises the half of the regex it needs.
|
|
34
|
+
const CAMEL_BOUNDARY_RE = /([a-z0-9])([A-Z])|([A-Z]+)([A-Z][a-z])/g;
|
|
35
|
+
|
|
36
|
+
export function tokenize(s) {
|
|
37
|
+
if (!s) return [];
|
|
38
|
+
const spaced = String(s).replace(CAMEL_BOUNDARY_RE, (_, a, b, c, d) => (a ? `${a} ${b}` : `${c} ${d}`));
|
|
39
|
+
return spaced.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((t) => t.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A1 §7 (unchanged): the exact default string previously inlined below -- kept as a module
|
|
43
|
+
// constant so an adapter can override it via `result.apiSurfaceSource` without this file needing
|
|
44
|
+
// to know which adapters exist. Neither shipped adapter overrides it today, so output is
|
|
45
|
+
// byte-identical to before G1.
|
|
46
|
+
const DEFAULT_API_SURFACE_SOURCE = 'source-annotations only (this scan does not check for a committed ' +
|
|
47
|
+
'OpenAPI document -- if one is generated by the build, pass it via --openapi-file to ' +
|
|
48
|
+
'`contract emit`)';
|
|
49
|
+
|
|
50
|
+
// D3 (see DECISIONS.md): verdict drives a disposition state machine, not an agent question.
|
|
51
|
+
// A module scores >= COLLISION_THRESHOLD only when it's a strong, multi-signal match (module
|
|
52
|
+
// name + controller/table/path/operationId overlap) -- e.g. scanning Team-IZ-Backend for
|
|
53
|
+
// "organization" scores the `organization` module's module-name match (10) alone at the
|
|
54
|
+
// threshold, before even counting its controller/table/path hits.
|
|
55
|
+
const COLLISION_THRESHOLD = 10;
|
|
56
|
+
|
|
57
|
+
// D-scanner-evidence: `termTokens` matches `textTokens` if it appears as a CONTIGUOUS
|
|
58
|
+
// subsequence, comparing token-by-token with a bidirectional prefix check (not exact equality --
|
|
59
|
+
// "organization" must still match the token "organizations", a real plural-form case the old
|
|
60
|
+
// substring matcher supported by accident). This replaces the old whole-string, un-tokenized
|
|
61
|
+
// `t.includes(nt) || nt.includes(t)` -- the catalog's "symmetric substring matching" complaint --
|
|
62
|
+
// which let a short piece of text match anywhere inside an unrelated long term, or a short term
|
|
63
|
+
// match anywhere inside an unrelated identifier, crossing real word boundaries either way (e.g.
|
|
64
|
+
// term "man" matching inside the token "management" is still possible here since it's a real
|
|
65
|
+
// prefix of that ONE token, but term "man" can no longer match by spanning from the tail of one
|
|
66
|
+
// word into the head of the next, the way whole-string substring search allowed).
|
|
67
|
+
function tokensMatch(termTokens, textTokens) {
|
|
68
|
+
if (termTokens.length === 0 || termTokens.length > textTokens.length) return false;
|
|
69
|
+
for (let start = 0; start <= textTokens.length - termTokens.length; start++) {
|
|
70
|
+
let allMatch = true;
|
|
71
|
+
for (let i = 0; i < termTokens.length; i++) {
|
|
72
|
+
const tt = termTokens[i];
|
|
73
|
+
const xt = textTokens[start + i];
|
|
74
|
+
if (!(xt.startsWith(tt) || tt.startsWith(xt))) { allMatch = false; break; }
|
|
75
|
+
}
|
|
76
|
+
if (allMatch) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Returns every term (not just whether ANY matched) that matches `text` -- D-scanner-evidence
|
|
82
|
+
// needs one evidence record per matching term, not just a boolean.
|
|
83
|
+
function matchingTerms(text, terms) {
|
|
84
|
+
const textTokens = tokenize(text);
|
|
85
|
+
if (textTokens.length === 0) return [];
|
|
86
|
+
return terms.filter((term) => tokensMatch(tokenize(term), textTokens));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// D-scanner-evidence: caps PER SIGNAL TYPE at collection time, not by collecting everything and
|
|
90
|
+
// filtering afterward -- found live, not anticipated: collecting every raw match first (a
|
|
91
|
+
// controller with 600 endpoints all matching one term produces 1200 evidence records for that
|
|
92
|
+
// ONE module) pushed a real scan report past Node's default 1MB `execFileSync` buffer, crashing
|
|
93
|
+
// the exact >64KB-report regression test this codebase already had (see D-process-exit-audit's
|
|
94
|
+
// pipe-truncation history in DECISIONS.md -- this is the same failure class, a different cause).
|
|
95
|
+
// Capping during collection keeps report size bounded regardless of repo size, and every
|
|
96
|
+
// recorded entry always counts toward score (no separate `counted` flag needed) -- signals that
|
|
97
|
+
// hit the cap are named in `cappedSignals` (one string per module, not one flag per entry) so
|
|
98
|
+
// `bskel scan explain` can still say "N further widget-path matches exist, not shown" without an
|
|
99
|
+
// unbounded per-entry record for each of them.
|
|
100
|
+
class EvidenceCollector {
|
|
101
|
+
constructor() {
|
|
102
|
+
this.evidence = [];
|
|
103
|
+
this.counts = new Map();
|
|
104
|
+
this.cappedSignals = new Set();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Encounter order is already deterministic here without a separate sort step: callers walk
|
|
108
|
+
// controllers/entities/enums in the adapter's own already-sorted (O6: rg --files sorted) file
|
|
109
|
+
// order, endpoints in document order within one file, and terms in the user's own --terms
|
|
110
|
+
// order -- the same ordering scoreModule() always scored in.
|
|
111
|
+
add(signal, term, value, file, line) {
|
|
112
|
+
const count = (this.counts.get(signal) ?? 0) + 1;
|
|
113
|
+
this.counts.set(signal, count);
|
|
114
|
+
if (count > CAP_PER_SIGNAL) {
|
|
115
|
+
this.cappedSignals.add(signal);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.evidence.push({ signal, term, value, weight: SIGNAL_WEIGHTS[signal], file, line: line ?? null });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// D-scanner-evidence: walks every signal-bearing field exactly once, in the same order
|
|
123
|
+
// scoreModule() always scored them in.
|
|
124
|
+
function collectEvidence(mod, terms) {
|
|
125
|
+
const c = new EvidenceCollector();
|
|
126
|
+
for (const term of matchingTerms(mod.module, terms)) c.add('module_name', term, mod.module, null, null);
|
|
127
|
+
for (const controller of mod.controllers) {
|
|
128
|
+
for (const term of matchingTerms(controller.className, terms)) c.add('controller_class', term, controller.className, controller.file, controller.line);
|
|
129
|
+
for (const term of matchingTerms(controller.basePath, terms)) c.add('controller_path', term, controller.basePath, controller.file, controller.line);
|
|
130
|
+
for (const ep of controller.endpoints) {
|
|
131
|
+
for (const term of matchingTerms(ep.path, terms)) c.add('endpoint_path', term, ep.path, controller.file, ep.line);
|
|
132
|
+
for (const term of matchingTerms(ep.operationId, terms)) c.add('endpoint_operation_id', term, ep.operationId, controller.file, ep.line);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const e of mod.entities) {
|
|
136
|
+
for (const term of matchingTerms(e.table, terms)) c.add('entity_table', term, e.table, e.file, e.line);
|
|
137
|
+
for (const term of matchingTerms(e.className, terms)) c.add('entity_class', term, e.className, e.file, e.line);
|
|
138
|
+
}
|
|
139
|
+
for (const en of mod.enums) {
|
|
140
|
+
for (const term of matchingTerms(en.name, terms)) c.add('enum_name', term, en.name, en.file, en.line);
|
|
141
|
+
}
|
|
142
|
+
return c;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function scoreModule(mod, terms) {
|
|
146
|
+
const c = collectEvidence(mod, terms);
|
|
147
|
+
const score = c.evidence.reduce((sum, e) => sum + e.weight, 0);
|
|
148
|
+
return { score, evidence: c.evidence, cappedSignals: [...c.cappedSignals].sort() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A4 (D-db-schema-plane): case-insensitive comparison against every already-scanned entity's own
|
|
152
|
+
// `.table` field -- tables the live DB has but no source entity declares, and entities whose
|
|
153
|
+
// declared table isn't found live, both become plain-string `unknowns` entries (same shape every
|
|
154
|
+
// other unknown in this report already uses), never a blocking verdict change. This is the
|
|
155
|
+
// concrete "reveal live drift" value CATALOG.md's A4 Why names.
|
|
156
|
+
export function computeDbDrift(liveTables, relatedModules) {
|
|
157
|
+
const liveNames = new Set(liveTables.map((t) => t.name.toLowerCase()));
|
|
158
|
+
const declaredTables = new Map(); // lowercased table name -> {module, entity}
|
|
159
|
+
for (const m of relatedModules) {
|
|
160
|
+
for (const e of m.entities ?? []) {
|
|
161
|
+
if (e.table) declaredTables.set(e.table.toLowerCase(), { module: m.module, entity: e.className });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const findings = [];
|
|
166
|
+
for (const name of liveNames) {
|
|
167
|
+
if (!declaredTables.has(name)) findings.push(`live DB table "${name}" has no matching source entity in any scanned module (Plane C drift)`);
|
|
168
|
+
}
|
|
169
|
+
for (const [table, { module, entity }] of declaredTables) {
|
|
170
|
+
if (!liveNames.has(table)) findings.push(`entity ${entity} (module "${module}") declares table "${table}", but it was not found in the live DB (Plane C drift)`);
|
|
171
|
+
}
|
|
172
|
+
return findings;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// G1: adapter dispatch, replacing the previous hardcoded two-branch if/else. `adapters` is
|
|
176
|
+
// injectable (defaults to the real zero-registration registry) purely as a test seam for
|
|
177
|
+
// arbitration/ambiguity tests -- every real caller gets the default. See D-adapter-registry in
|
|
178
|
+
// DECISIONS.md.
|
|
179
|
+
//
|
|
180
|
+
// A4: `dbSchema` (`{migrations, live}` or null) is computed by the CALLER, not fetched here --
|
|
181
|
+
// env var resolution and the live DB connection itself are CLI-boundary concerns (matching how
|
|
182
|
+
// every other env-var-driven input in this codebase is resolved at the bin/bskel.mjs layer, never
|
|
183
|
+
// inside a "pure" lib/scanner function), and keeps this function synchronous (Plane C's real I/O
|
|
184
|
+
// is `await`ed by the caller before ever calling this).
|
|
185
|
+
export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, adapters = ADAPTERS }) {
|
|
186
|
+
const detections = adapters
|
|
187
|
+
.map((a) => ({ a, d: a.detect(repoRoot) }))
|
|
188
|
+
.filter(({ d }) => d != null)
|
|
189
|
+
// Not relying on the caller to have pre-sorted `adapters` -- specificity is the sole
|
|
190
|
+
// source of truth for arbitration, computed here regardless of input order.
|
|
191
|
+
.sort((x, y) => y.a.specificity - x.a.specificity || x.a.id.localeCompare(y.a.id));
|
|
192
|
+
|
|
193
|
+
if (detections.length === 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`no scanner adapter detected this repo -- tried: ${adapters.map((a) => a.id).join(', ') || '(no adapters loaded)'}` +
|
|
196
|
+
`${LOAD_ERRORS.length > 0 ? `; ${LOAD_ERRORS.length} adapter(s) failed to load, see \`bskel doctor\`` : ''}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const topSpecificity = detections[0].a.specificity;
|
|
200
|
+
const tiedAtTop = detections.filter(({ a }) => a.specificity === topSpecificity);
|
|
201
|
+
if (tiedAtTop.length > 1) {
|
|
202
|
+
// O6-style refusal: when it's genuinely ambiguous, name every candidate and stop, rather
|
|
203
|
+
// than silently picking one (same principle as handles/emit.mjs's detectBasePackage()).
|
|
204
|
+
throw new Error(
|
|
205
|
+
`ambiguous adapter selection -- ${tiedAtTop.map(({ a }) => a.id).join(', ')} all detected this repo at ` +
|
|
206
|
+
`specificity ${topSpecificity}. Give one a higher specificity, or make its detect() more precise.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { a: chosen, d: detection } = detections[0];
|
|
211
|
+
const result = chosen.scan(repoRoot, detection);
|
|
212
|
+
const adapter = chosen.id;
|
|
213
|
+
const confidence = chosen.confidence;
|
|
214
|
+
const modules = result.modules;
|
|
215
|
+
const pathPrefixSignals = result.pathPrefixSignals ?? [];
|
|
216
|
+
const apiSurfaceSource = result.apiSurfaceSource ?? DEFAULT_API_SURFACE_SOURCE;
|
|
217
|
+
// S2 (D-gate-precision, continued): the adapter's own real read-set, persisted so
|
|
218
|
+
// lib/gate-definitions.mjs's `scan` gate can hash it for a precise staleness token instead of
|
|
219
|
+
// a repo-wide head_sha proxy. Optional (`?? []`) so an adapter that doesn't populate it (a
|
|
220
|
+
// third-party adapter written before this existed) degrades to "no source-file inputs" rather
|
|
221
|
+
// than crashing -- the gate token still falls back to hashing the report itself.
|
|
222
|
+
const filesRead = result.filesRead ?? [];
|
|
223
|
+
|
|
224
|
+
// O6: score alone isn't a deterministic sort key -- two modules tying on score fall back to
|
|
225
|
+
// whatever order they were already in, which traces back to non-deterministic rg discovery
|
|
226
|
+
// order in the adapters above (mitigated there too, but a second determinism layer here is
|
|
227
|
+
// cheap and doesn't depend on every adapter getting it right). Module name is a stable,
|
|
228
|
+
// meaningful secondary key.
|
|
229
|
+
const scored = modules
|
|
230
|
+
.map((m) => {
|
|
231
|
+
const { score, evidence, cappedSignals } = scoreModule(m, terms);
|
|
232
|
+
return { ...m, score, evidence, capped_signals: cappedSignals };
|
|
233
|
+
})
|
|
234
|
+
.sort((a, b) => b.score - a.score || a.module.localeCompare(b.module));
|
|
235
|
+
|
|
236
|
+
const relatedModules = scored.filter((m) => m.score > 0);
|
|
237
|
+
const collisions = relatedModules.filter((m) => m.score >= COLLISION_THRESHOLD);
|
|
238
|
+
|
|
239
|
+
let verdict = 'greenfield';
|
|
240
|
+
if (collisions.length > 0) verdict = 'collision';
|
|
241
|
+
else if (relatedModules.length > 0) verdict = 'adjacent';
|
|
242
|
+
|
|
243
|
+
const unknowns = [];
|
|
244
|
+
if (!includeDb) {
|
|
245
|
+
unknowns.push('DB not scanned (Plane C is opt-in via --db --database-url-env <NAME>) -- pass --db to scan migration files, add --database-url-env for live introspection too. See A4 in CATALOG.md.');
|
|
246
|
+
} else if (!dbSchema?.live) {
|
|
247
|
+
unknowns.push('DB not live-introspected (Plane C needs --database-url-env <NAME> in addition to --db) -- migration files only, no live drift check performed.');
|
|
248
|
+
}
|
|
249
|
+
if (dbSchema?.live) {
|
|
250
|
+
unknowns.push(...computeDbDrift(dbSchema.live.tables, relatedModules));
|
|
251
|
+
}
|
|
252
|
+
// A1 §7: this scan can't correct a global path prefix (only --openapi-file's real-document
|
|
253
|
+
// reconciliation can, see D-openapi-reconciliation) -- but it CAN tell a user who doesn't know
|
|
254
|
+
// that flag exists that the defect is likely present, before they ever emit a wrong contract.
|
|
255
|
+
if (pathPrefixSignals.length > 0) {
|
|
256
|
+
const detail = pathPrefixSignals
|
|
257
|
+
.map((s) => `${s.kind} in ${s.file}${s.prefix ? ` (prefix "${s.prefix}")` : s.pattern ? ` (pattern "${s.pattern}")` : ''}`)
|
|
258
|
+
.join('; ');
|
|
259
|
+
unknowns.push(
|
|
260
|
+
`this repo applies a global path prefix outside controller source (${detail}) -- contract ` +
|
|
261
|
+
'paths built from source annotations alone (no --openapi-file) will be missing it. Generate ' +
|
|
262
|
+
'a real OpenAPI document and pass it to `bskel contract emit --openapi-file <path>`. See ' +
|
|
263
|
+
'D-openapi-reconciliation in DECISIONS.md.',
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
schema: 'sbf.scan-report/1',
|
|
269
|
+
terms,
|
|
270
|
+
adapter,
|
|
271
|
+
confidence,
|
|
272
|
+
api_surface_source: apiSurfaceSource,
|
|
273
|
+
verdict,
|
|
274
|
+
path_prefix_signals: pathPrefixSignals,
|
|
275
|
+
related_modules: relatedModules,
|
|
276
|
+
collisions,
|
|
277
|
+
unknowns,
|
|
278
|
+
files_read: filesRead,
|
|
279
|
+
...(dbSchema ? { db_schema: dbSchema } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// G1: zero-registration adapter discovery -- mirrors stack/apply.mjs's listCatalogChoices()/
|
|
2
|
+
// loadCatalogEntry() (D7 in DECISIONS.md), with one unavoidable difference: a stack catalog entry
|
|
3
|
+
// is pure YAML data, but a scanner adapter is CODE -- dropping a file here executes it. That is
|
|
4
|
+
// acceptable because scanners/adapters/ lives inside this skill's own source, at the same trust
|
|
5
|
+
// level as the rest of it.
|
|
6
|
+
//
|
|
7
|
+
// SECURITY: `loadAdapters({adaptersDir})` below is a test seam ONLY. Never wire it to a CLI flag
|
|
8
|
+
// or an environment variable -- doing so would turn "which directory of JS gets executed" into
|
|
9
|
+
// user-controllable input. See D-adapter-registry in DECISIONS.md.
|
|
10
|
+
//
|
|
11
|
+
// SYNC: `runScan()`/`cmdScan()`/`main()` in bin/bskel.mjs are all synchronous, and existing tests
|
|
12
|
+
// (test/scan.test.mjs, test/contract.test.mjs) call `runScan` synchronously. `import()` is
|
|
13
|
+
// inherently async, so adapter loading happens via a top-level await here instead of forcing
|
|
14
|
+
// every caller of `runScan` to become async. `scanners/index.mjs` statically imports the
|
|
15
|
+
// constants exported below -- Node resolves this module's top-level await as part of resolving
|
|
16
|
+
// that static import, before any of index.mjs's own top-level code runs, so `runScan` itself
|
|
17
|
+
// stays fully synchronous. No cycle risk: this module imports nothing from scanners/index.mjs,
|
|
18
|
+
// and no adapter imports anything from this module.
|
|
19
|
+
//
|
|
20
|
+
// BUNDLER CAVEAT: dynamic `import()` over a `readdirSync` result is invisible to bundlers. There
|
|
21
|
+
// is no build step in this project today (bin/bskel.mjs runs straight from source, no `build`
|
|
22
|
+
// script in package.json), so this costs nothing now -- but adding esbuild/ncc/etc. later would
|
|
23
|
+
// silently produce a zero-adapter registry. Worth remembering if a build step is ever added.
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
28
|
+
|
|
29
|
+
const REGISTRY_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const ADAPTERS_DIR = path.join(REGISTRY_DIR, 'adapters');
|
|
31
|
+
const SCHEMAS_ROOT = path.join(REGISTRY_DIR, '..', 'schemas');
|
|
32
|
+
const SUPPORTED_CONTRACT = 'sbf.adapter/1';
|
|
33
|
+
|
|
34
|
+
let _ajv = null;
|
|
35
|
+
function ajv() {
|
|
36
|
+
if (!_ajv) _ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
37
|
+
return _ajv;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadAdapterSchema() {
|
|
41
|
+
return JSON.parse(fs.readFileSync(path.join(SCHEMAS_ROOT, 'adapter.schema.json'), 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// O6-style determinism: readdirSync has no cross-platform ordering guarantee either -- `.sort()`
|
|
45
|
+
// on the filename list, same discipline as scanners/adapters/*.mjs's own `rg --files` fix.
|
|
46
|
+
//
|
|
47
|
+
// Filtered by NAME, not `withFileTypes()` + `isFile()` -- `isFile()` is false for a symlink, and
|
|
48
|
+
// this skill is itself symlinked into `~/.claude/skills` (see D0 in DECISIONS.md), so a
|
|
49
|
+
// symlinked adapter file is a plausible real shape, not a hypothetical. `_`/`.`-prefixed files
|
|
50
|
+
// are the shared-helper convention (mirrors stack/apply.mjs's own catalog dir filtering) and are
|
|
51
|
+
// silently skipped, not treated as malformed adapters.
|
|
52
|
+
function candidateFiles(dir) {
|
|
53
|
+
if (!fs.existsSync(dir)) return [];
|
|
54
|
+
return fs.readdirSync(dir)
|
|
55
|
+
.filter((name) => name.endsWith('.mjs') && !name.startsWith('_') && !name.startsWith('.'))
|
|
56
|
+
.sort()
|
|
57
|
+
.map((name) => path.join(dir, name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadOneAdapter(file, schema) {
|
|
61
|
+
const id = path.basename(file, '.mjs');
|
|
62
|
+
let mod;
|
|
63
|
+
try {
|
|
64
|
+
mod = await import(pathToFileURL(file).href);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return { error: { file, message: `failed to load: ${err.message}` } };
|
|
67
|
+
}
|
|
68
|
+
const descriptor = mod.adapter;
|
|
69
|
+
if (!descriptor || typeof descriptor !== 'object') {
|
|
70
|
+
return { error: { file, message: 'must `export const adapter = {...}` (sbf.adapter/1 shape) -- no such export found' } };
|
|
71
|
+
}
|
|
72
|
+
if (descriptor.contract !== SUPPORTED_CONTRACT) {
|
|
73
|
+
return { error: { file, message: `declares contract "${descriptor.contract}" -- this build only understands "${SUPPORTED_CONTRACT}"` } };
|
|
74
|
+
}
|
|
75
|
+
if (descriptor.id !== id) {
|
|
76
|
+
return { error: { file, message: `adapter.id "${descriptor.id}" must equal its filename "${id}"` } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate only the JSON-shaped fields -- detect/scan/diagnostics/listReadSet are functions,
|
|
80
|
+
// which JSON Schema has no vocabulary for; checked separately below.
|
|
81
|
+
const { detect, scan, diagnostics, listReadSet, ...data } = descriptor;
|
|
82
|
+
const validateFn = ajv().getSchema(schema.$id) ?? ajv().compile(schema);
|
|
83
|
+
if (!validateFn(data)) {
|
|
84
|
+
const details = (validateFn.errors ?? []).map((e) => `${e.instancePath || '(root)'} ${e.message}`).join('; ');
|
|
85
|
+
return { error: { file, message: `does not match schemas/adapter.schema.json: ${details}` } };
|
|
86
|
+
}
|
|
87
|
+
for (const fnName of ['detect', 'scan']) {
|
|
88
|
+
if (typeof descriptor[fnName] !== 'function') {
|
|
89
|
+
return { error: { file, message: `adapter.${fnName} must be a function` } };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (descriptor.diagnostics !== undefined && typeof descriptor.diagnostics !== 'function') {
|
|
93
|
+
return { error: { file, message: 'adapter.diagnostics, if present, must be a function' } };
|
|
94
|
+
}
|
|
95
|
+
// S2 (D-gate-precision, continued): optional -- an adapter that doesn't implement this just
|
|
96
|
+
// means lib/gate-definitions.mjs's `scan` gate falls back to a coarser staleness token for
|
|
97
|
+
// that adapter, never a crash. Every first-party adapter implements it.
|
|
98
|
+
if (descriptor.listReadSet !== undefined && typeof descriptor.listReadSet !== 'function') {
|
|
99
|
+
return { error: { file, message: 'adapter.listReadSet, if present, must be a function' } };
|
|
100
|
+
}
|
|
101
|
+
return { adapter: descriptor };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Exported for the registry's own unit tests and for `bskel doctor`'s LOAD_ERRORS listing. A
|
|
105
|
+
// broken adapter file must not brick every `bskel` command (this module is imported at process
|
|
106
|
+
// start, before `main()` even parses argv) -- so loading is per-file try/catch: the adapters that
|
|
107
|
+
// did load are still usable, and every failure is collected, never silently dropped.
|
|
108
|
+
export async function loadAdapters({ adaptersDir = ADAPTERS_DIR } = {}) {
|
|
109
|
+
const schema = loadAdapterSchema();
|
|
110
|
+
const adapters = [];
|
|
111
|
+
const errors = [];
|
|
112
|
+
for (const file of candidateFiles(adaptersDir)) {
|
|
113
|
+
const result = await loadOneAdapter(file, schema);
|
|
114
|
+
if (result.error) errors.push(result.error);
|
|
115
|
+
else adapters.push(result.adapter);
|
|
116
|
+
}
|
|
117
|
+
// Arbitration order for scanners/index.mjs's dispatch loop: higher specificity first, id as a
|
|
118
|
+
// stable tiebreak. This is a display/iteration convenience only -- genuine ambiguity (two
|
|
119
|
+
// adapters at the SAME specificity both detecting the same repo) is caught at dispatch time in
|
|
120
|
+
// runScan(), not here, since detect() needs a real repoRoot to evaluate.
|
|
121
|
+
adapters.sort((a, b) => b.specificity - a.specificity || a.id.localeCompare(b.id));
|
|
122
|
+
return { adapters, errors };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function adapterById(adapters, id) {
|
|
126
|
+
return adapters.find((a) => a.id === id) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Top-level await: see the SYNC note in the module header.
|
|
130
|
+
export const { adapters: ADAPTERS, errors: LOAD_ERRORS } = await loadAdapters();
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// D-scanner-evidence: fixed presentation order (matches scanners/index.mjs's own SIGNAL_WEIGHTS
|
|
2
|
+
// declaration order) rather than whatever order evidence happens to appear in -- duplicated here
|
|
3
|
+
// as literal strings rather than imported, keeping this file a pure presentation layer over a
|
|
4
|
+
// plain report object (its existing convention; scanners/index.mjs never imports render.mjs
|
|
5
|
+
// either, no dependency direction to preserve either way).
|
|
6
|
+
const SIGNAL_ORDER = [
|
|
7
|
+
'module_name', 'controller_class', 'controller_path',
|
|
8
|
+
'endpoint_path', 'endpoint_operation_id',
|
|
9
|
+
'entity_table', 'entity_class', 'enum_name',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// `bskel scan explain <module>` -- one module's full evidence breakdown, grouped by signal type,
|
|
13
|
+
// with a running weight subtotal per group so the sum visibly reconciles with `mod.score`.
|
|
14
|
+
export function renderScanExplain(mod) {
|
|
15
|
+
const lines = [`# scan explain: \`${mod.module}\` (score: ${mod.score})`, ''];
|
|
16
|
+
const bySignal = new Map();
|
|
17
|
+
for (const e of mod.evidence ?? []) {
|
|
18
|
+
if (!bySignal.has(e.signal)) bySignal.set(e.signal, []);
|
|
19
|
+
bySignal.get(e.signal).push(e);
|
|
20
|
+
}
|
|
21
|
+
if (bySignal.size === 0) {
|
|
22
|
+
lines.push('No evidence recorded -- this module scored 0.');
|
|
23
|
+
lines.push('');
|
|
24
|
+
}
|
|
25
|
+
for (const signal of SIGNAL_ORDER) {
|
|
26
|
+
const entries = bySignal.get(signal);
|
|
27
|
+
if (!entries || entries.length === 0) continue;
|
|
28
|
+
const subtotal = entries.reduce((sum, e) => sum + e.weight, 0);
|
|
29
|
+
lines.push(`## ${signal} (weight ${entries[0].weight} each, subtotal ${subtotal})`);
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
const where = e.file ? ` (${e.file}${e.line ? `:${e.line}` : ''})` : '';
|
|
32
|
+
lines.push(`- term \`${e.term}\` matched \`${e.value}\`${where}`);
|
|
33
|
+
}
|
|
34
|
+
lines.push('');
|
|
35
|
+
}
|
|
36
|
+
if ((mod.capped_signals ?? []).length > 0) {
|
|
37
|
+
lines.push(`**Capped**: ${mod.capped_signals.join(', ')} had more raw matches than shown above -- score still reflects every entry listed, additional matches beyond the cap did not add further weight.`);
|
|
38
|
+
lines.push('');
|
|
39
|
+
}
|
|
40
|
+
return `${lines.join('\n')}\n`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderScanMarkdown(report) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`# Brownfield scan${report.feature_id ? `: ${report.feature_id}` : ''}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push(`**Terms**: ${report.terms.join(', ') || '(none)'}`);
|
|
48
|
+
lines.push(`**Adapter**: ${report.adapter} (confidence: ${report.confidence})`);
|
|
49
|
+
lines.push(`**API surface source**: ${report.api_surface_source}`);
|
|
50
|
+
lines.push(`**Verdict**: \`${report.verdict}\``);
|
|
51
|
+
lines.push('');
|
|
52
|
+
|
|
53
|
+
if (report.related_modules.length === 0) {
|
|
54
|
+
lines.push('No related modules found -- greenfield for these terms.');
|
|
55
|
+
} else {
|
|
56
|
+
lines.push('## Related modules');
|
|
57
|
+
lines.push('');
|
|
58
|
+
for (const mod of report.related_modules) {
|
|
59
|
+
lines.push(`### \`${mod.module}\` (score: ${mod.score} -- run \`bskel scan explain ${mod.module}\` for the evidence breakdown)`);
|
|
60
|
+
for (const c of mod.controllers) {
|
|
61
|
+
lines.push(`- Controller \`${c.className}\` (base path \`${c.basePath}\`), ${c.endpoints.length} endpoint(s):`);
|
|
62
|
+
for (const ep of c.endpoints) {
|
|
63
|
+
lines.push(` - \`${ep.verb} ${ep.path}\` -- operationId \`${ep.operationId ?? '(unmatched)'}\` (\`${ep.method}\`)`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const e of mod.entities) {
|
|
67
|
+
lines.push(`- Entity \`${e.className}\` -> table \`${e.table ?? '(unknown)'}\`, PK field \`${e.idField ?? '(unknown)'}\``);
|
|
68
|
+
}
|
|
69
|
+
for (const en of mod.enums) {
|
|
70
|
+
lines.push(`- Enum \`${en.name}\`: ${en.constants.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
if (mod.dtos.length > 0) {
|
|
73
|
+
lines.push(`- DTOs: ${mod.dtos.join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
lines.push('');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A4 (D-db-schema-plane): only present when --db was passed -- drift findings already land in
|
|
80
|
+
// `unknowns` (rendered above/below depending on source order), this section is just the raw
|
|
81
|
+
// table/column inventory for a human skimming the markdown without --json.
|
|
82
|
+
if (report.db_schema) {
|
|
83
|
+
lines.push('## Database schema (Plane A/C)');
|
|
84
|
+
const { migrations, live } = report.db_schema;
|
|
85
|
+
lines.push(`- Migrations: ${migrations.tool === 'none' ? 'none found' : `${migrations.tool}, ${migrations.files.length} file(s), ${migrations.tables.length} table reference(s)`}`);
|
|
86
|
+
if (live) {
|
|
87
|
+
lines.push(`- Live schema \`${live.schema}\`: ${live.tables.length} table(s), hash \`${live.schema_hash.slice(0, 12)}\``);
|
|
88
|
+
for (const t of live.tables) lines.push(` - \`${t.name}\` (${t.columns.length} column(s))`);
|
|
89
|
+
} else {
|
|
90
|
+
lines.push('- Live schema: not introspected (pass --database-url-env for Plane C)');
|
|
91
|
+
}
|
|
92
|
+
lines.push('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (report.unknowns.length > 0) {
|
|
96
|
+
lines.push('## Unknowns');
|
|
97
|
+
for (const u of report.unknowns) lines.push(`- ${u}`);
|
|
98
|
+
lines.push('');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (report.disposition) {
|
|
102
|
+
lines.push('## Disposition');
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push(`**Mode**: \`${report.disposition.mode}\` (recorded ${report.disposition.at})`);
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push(report.disposition.note || '(no note provided)');
|
|
107
|
+
lines.push('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return `${lines.join('\n')}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function renderPlanConstraints(report) {
|
|
114
|
+
if (!report.disposition) return null;
|
|
115
|
+
const { mode, note } = report.disposition;
|
|
116
|
+
const lines = [`# Plan constraints (from brownfield-scan disposition: ${mode})`, ''];
|
|
117
|
+
|
|
118
|
+
const modeInstructions = {
|
|
119
|
+
reuse: 'Plan MUST NOT create new entities/controllers/endpoints for the modules listed below. Restrict scope to regression tests and documentation corrections only.',
|
|
120
|
+
extend: 'Plan may ADD to the modules listed below, but every new field/endpoint must state why the existing one is insufficient. Do not duplicate existing functionality.',
|
|
121
|
+
replace: 'Plan replaces functionality in the modules listed below. Requires --breaking-approved to have been passed to `scan disposition`. Must include an explicit deprecation section for what is being replaced.',
|
|
122
|
+
parallel: 'Plan introduces a new, separately-named module alongside the ones listed below. Re-run `bskel scan` against the new module name before implementing to confirm no further collision.',
|
|
123
|
+
};
|
|
124
|
+
lines.push(modeInstructions[mode] ?? '');
|
|
125
|
+
lines.push('');
|
|
126
|
+
if (note) {
|
|
127
|
+
lines.push('## Human note');
|
|
128
|
+
lines.push(note);
|
|
129
|
+
lines.push('');
|
|
130
|
+
}
|
|
131
|
+
lines.push('## Existing modules this disposition applies to');
|
|
132
|
+
for (const mod of report.related_modules) {
|
|
133
|
+
lines.push(`- \`${mod.module}\` (score ${mod.score}) -- see brownfield-scan.md for full detail`);
|
|
134
|
+
}
|
|
135
|
+
return `${lines.join('\n')}\n`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// D3 (D-scanner-evidence): extracted from python-fastapi.mjs/generic-grep.mjs, which each had
|
|
2
|
+
// their own copy of this exact function -- java-spring.mjs needs the same thing now that D3
|
|
3
|
+
// requires every adapter to track a line number per evidence-bearing match, not just endpoints.
|
|
4
|
+
export function lineNumberAt(text, index) {
|
|
5
|
+
let line = 1;
|
|
6
|
+
for (let i = 0; i < index; i++) if (text[i] === '\n') line++;
|
|
7
|
+
return line;
|
|
8
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:adapter:1",
|
|
4
|
+
"title": "backend-skeleton scanner adapter descriptor",
|
|
5
|
+
"description": "Validates the JSON-shaped fields of the `adapter` object a scanners/adapters/<id>.mjs module must export (detect/scan/diagnostics/listReadSet are functions and are checked separately -- JSON Schema has no vocabulary for them). Loaded by scanners/registry.mjs at process start. See D-adapter-registry in DECISIONS.md.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["contract", "id", "title", "specificity", "confidence", "capabilities"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"contract": { "const": "sbf.adapter/1" },
|
|
11
|
+
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
|
|
12
|
+
"title": { "type": "string" },
|
|
13
|
+
"specificity": { "type": "number" },
|
|
14
|
+
"confidence": { "enum": ["high", "low"] },
|
|
15
|
+
"capabilities": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"propertyNames": {
|
|
18
|
+
"enum": ["api.operations", "api.request-shape", "resource.fetch", "codegen.handles"]
|
|
19
|
+
},
|
|
20
|
+
"additionalProperties": { "type": "boolean" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:envelope:1",
|
|
4
|
+
"title": "backend-skeleton agent request/response envelope",
|
|
5
|
+
"description": "Every request/response an AI agent produces for a feature must be wrapped in this envelope. feature_id + feature_uid scope it to a specific feature (and reject a stale copy-pasted payload from a renamed feature, since feature_uid is minted once and never reused); operation_id scopes payload to a specific operation's schema, enforced by the per-feature contract in specs/<feature_id>/contracts/<feature_id>.schema.json, not by this schema alone.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["sbf", "feature_id", "feature_uid", "operation_id", "direction", "payload"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"sbf": { "const": "1" },
|
|
11
|
+
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
12
|
+
"feature_uid": { "type": "string", "format": "uuid" },
|
|
13
|
+
"operation_id": { "type": "string", "pattern": "^[a-zA-Z][A-Za-z0-9]*$" },
|
|
14
|
+
"direction": { "enum": ["request", "response", "error"] },
|
|
15
|
+
"handles": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": { "type": "string", "pattern": "^sbf1_[A-Za-z0-9_-]+$" }
|
|
18
|
+
},
|
|
19
|
+
"payload": {}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:contract-resolution:1",
|
|
4
|
+
"title": "backend-skeleton contract waiver resolution",
|
|
5
|
+
"description": "Documentation only, like state.schema.json -- nothing in the codebase loads this file at runtime (see contracts/completeness.mjs's loadResolution, which parses the JSON directly). Validates specs/<feature_id>/contracts/<feature_id>.resolution.json, written by `bskel contract waive`. Deliberately separate from the contract artifact itself (feature-contract.schema.json) -- see D-contract-completeness in DECISIONS.md for why waivers live in their own file.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "feature_id", "waivers"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "sbf.contract-resolution/1" },
|
|
11
|
+
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
12
|
+
"waivers": {
|
|
13
|
+
"description": "Each entry is a specific {code, subject} pair, never a wildcard -- `bskel contract waive --all` expands to one entry per occurrence present at waive time, so a warning that doesn't exist yet is never covered.",
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"required": ["code", "subject", "reason", "at"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"code": { "type": "string", "pattern": "^CONTRACT_[A-Z_]+$" },
|
|
21
|
+
"subject": { "type": ["string", "null"] },
|
|
22
|
+
"reason": { "type": "string" },
|
|
23
|
+
"at": { "type": "string", "format": "date-time" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|