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,869 @@
|
|
|
1
|
+
// A1: reconciles a scan-derived module against a real OpenAPI document -- recovers operationIds
|
|
2
|
+
// the scanner's regex heuristics missed, and (its main purpose) corrects `path` for operations
|
|
3
|
+
// the scanner already matched, since a global path-prefix Spring config (e.g. `addPathPrefix` /
|
|
4
|
+
// `springdoc.paths-to-match`) is invisible to source-annotation scanning. See
|
|
5
|
+
// D-openapi-reconciliation in DECISIONS.md for the real Team-IZ-Backend defect this closes
|
|
6
|
+
// (every emitted contract's `path` was missing `/api/v0` -- verified by generating the real
|
|
7
|
+
// document and diffing).
|
|
8
|
+
//
|
|
9
|
+
// This module never looks at waivers (same discipline as contracts/emit.mjs) and never writes
|
|
10
|
+
// anything -- `loadOpenApiDocument` is the only place a file is read, and `reconcileModule` is
|
|
11
|
+
// pure. contracts/emit.mjs imports `selectModule`/`endpointKey` FROM here -- wait, the reverse:
|
|
12
|
+
// THIS module imports them FROM contracts/emit.mjs (never the other way), so "which module" and
|
|
13
|
+
// "which endpoint is which" are defined in exactly one place.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { endpointKey, BARE_UUID_PATTERN } from './emit.mjs';
|
|
18
|
+
|
|
19
|
+
const MAX_DOCUMENT_BYTES = 16 * 1024 * 1024;
|
|
20
|
+
const MAX_PATHS = 5000;
|
|
21
|
+
const MAX_OPERATIONS = 10000;
|
|
22
|
+
const HTTP_METHODS = Object.freeze(new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']));
|
|
23
|
+
|
|
24
|
+
// A2: request-body JSON Schema projection. Real-data-measured against Team-IZ-Backend's actual
|
|
25
|
+
// OpenAPI document (308 components.schemas, 0 cycles, max $ref-chain depth 5, max structural
|
|
26
|
+
// depth reachable from a requestBody 8, max single-operation node count 23, longest real
|
|
27
|
+
// `pattern` 77 chars) -- every cap below carries multiple times that headroom, see
|
|
28
|
+
// D-openapi-request-schema in DECISIONS.md for the full measurement.
|
|
29
|
+
const MAX_COMPONENT_SCHEMAS = 5000;
|
|
30
|
+
const MAX_SCHEMA_DEPTH = 32; // every recursion level (structural AND $ref), not just $ref-chain depth
|
|
31
|
+
const MAX_SCHEMA_NODES = 2000; // shared counter per top-level inlineSchema() call, enum entries count
|
|
32
|
+
const MAX_PATTERN_LENGTH = 300; // real max observed: 77
|
|
33
|
+
const JSON_MEDIA_TYPE = 'application/json';
|
|
34
|
+
const SCHEMA_REF_PREFIX = '#/components/schemas/';
|
|
35
|
+
|
|
36
|
+
// A3: response/error JSON Schema projection. Reuses every inlineSchema() defense above
|
|
37
|
+
// unchanged (keyword/format whitelist, MAX_SCHEMA_DEPTH/NODES/PATTERN_LENGTH) -- measured by
|
|
38
|
+
// running inlineSchema() itself over all 634 real response/error schema roots in Team-IZ-
|
|
39
|
+
// Backend's document at the current caps: 634/634 resolved, zero failures, max depth 14 (2.3x
|
|
40
|
+
// headroom), max single-schema node count 231 (8.7x headroom). The one genuinely new risk A3
|
|
41
|
+
// introduces is fan-out: A2 resolved at most 1 schema per operation (a request body), but a
|
|
42
|
+
// response/error side can have many documented statuses -- MAX_RESPONSES_PER_OPERATION bounds
|
|
43
|
+
// that (real max observed: 9). See D-openapi-response-schema in DECISIONS.md.
|
|
44
|
+
const MAX_RESPONSES_PER_OPERATION = 64;
|
|
45
|
+
// A6 (D-openapi-export): widened from `/^2[0-9]{2}$/` and `/^[45][0-9]{2}$/` to also accept
|
|
46
|
+
// OpenAPI's own RANGE keys. These are ordinary in real hand-written documents and legal per the
|
|
47
|
+
// official 3.1 meta-schema, whose `responses` object accepts exactly `^[1-5](?:[0-9]{2}|XX)$` plus
|
|
48
|
+
// `default` -- confirmed by executing the real 2022-10-07 schema, not by reading prose about it.
|
|
49
|
+
// Before this widening a document written with `2XX`/`4XX` silently lost every response and error
|
|
50
|
+
// schema: `projectResponseSchemas` simply never matched the status key, and "no matching status"
|
|
51
|
+
// is (correctly) not a failure, so the loss produced no warning anywhere. This is a general
|
|
52
|
+
// importer capability gain, not round-trip plumbing -- it is what makes such a document readable
|
|
53
|
+
// by `contract emit --openapi-file` at all.
|
|
54
|
+
const SUCCESS_STATUS_RE = /^2(?:[0-9]{2}|XX)$/;
|
|
55
|
+
const ERROR_STATUS_RE = /^[45](?:[0-9]{2}|XX)$/;
|
|
56
|
+
const DEFAULT_STATUS_KEY = 'default';
|
|
57
|
+
|
|
58
|
+
// Same shape convention as the rest of contracts/ -- operationId becomes an object key
|
|
59
|
+
// downstream (contracts/emit.mjs's `operations[operationId]`), so it's whitelisted before it's
|
|
60
|
+
// trusted anywhere. Deliberately excludes a leading `_` (so `__proto__` fails on the first
|
|
61
|
+
// character alone); `constructor`/`toString` DO match this shape, but every index in this module
|
|
62
|
+
// is a `Map` (never a plain object), so there is no lookup path where that resolves to an
|
|
63
|
+
// inherited property -- see D-security-1 in DECISIONS.md for the equivalent concern this
|
|
64
|
+
// mirrors, and bin/bskel.mjs's cmdContractToolSchema fix (Object.hasOwn) for the one place a
|
|
65
|
+
// contract's operations DO become plain-object keys downstream of this module.
|
|
66
|
+
export const OPERATION_ID_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,199}$/;
|
|
67
|
+
|
|
68
|
+
// A path-prefix candidate must look like one or more clean path segments -- rules out `{}`
|
|
69
|
+
// (template params leaking into a "prefix"), empty segments (`//`), and anything that isn't a
|
|
70
|
+
// plain path string. Used both for prefix inference (a delta must match this to be trusted) and
|
|
71
|
+
// to validate an explicit `--path-prefix` value.
|
|
72
|
+
export const PATH_PREFIX_RE = /^(?:\/[A-Za-z0-9._~%-]+)+$/;
|
|
73
|
+
|
|
74
|
+
// A2: same whitelist-not-denylist reasoning as OPERATION_ID_RE above, applied to two new classes
|
|
75
|
+
// of externally-influenceable string this module now handles: OpenAPI component-schema names
|
|
76
|
+
// (`components.schemas.<name>`, e.g. "CreateOrganizationRequest") and, inside a resolved schema,
|
|
77
|
+
// its `properties` keys / `required[]` entries (e.g. "dataRetentionDays"). Both become object
|
|
78
|
+
// keys downstream -- inlineSchema()'s output `properties` is a plain object built with
|
|
79
|
+
// `out.properties[k] = ...`, so a `k` of "__proto__" from JSON.parse'd input would hit
|
|
80
|
+
// Object.prototype's `__proto__` setter instead of adding a property. The leading-letter
|
|
81
|
+
// requirement kills `__proto__` on the first character alone, same as OPERATION_ID_RE; measured
|
|
82
|
+
// against all 308 real Team-IZ-Backend component-schema names with zero rejections.
|
|
83
|
+
export const COMPONENT_SCHEMA_NAME_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,199}$/;
|
|
84
|
+
export const SCHEMA_PROPERTY_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
|
|
85
|
+
|
|
86
|
+
// inlineSchema()'s keyword policy: RECURSED keywords are walked into; ASSERTION keywords are
|
|
87
|
+
// copied verbatim (their values are scalars/arrays of scalars, not schema nodes -- nothing to
|
|
88
|
+
// recurse); DROPPED keywords carry no validation meaning and are silently discarded (their
|
|
89
|
+
// absence changes nothing about what a schema accepts); anything else fails that schema closed.
|
|
90
|
+
// The FORMAT set is checked separately (see inlineSchema's format handling) since `uuid` gets
|
|
91
|
+
// rewritten rather than either copied or dropped. A missing-and-therefore-fail-closed keyword is
|
|
92
|
+
// deliberate: silently dropping an assertion (e.g. an unrecognized `pattern`-like keyword) would
|
|
93
|
+
// emit a schema WEAKER than the real one, which is worse than emitting no schema at all -- see
|
|
94
|
+
// D-openapi-request-schema in DECISIONS.md.
|
|
95
|
+
const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf']));
|
|
96
|
+
const COPIED_KEYWORDS = Object.freeze(new Set([
|
|
97
|
+
'type', 'enum', 'const', 'required',
|
|
98
|
+
'minLength', 'maxLength',
|
|
99
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
100
|
+
'minItems', 'maxItems', 'uniqueItems',
|
|
101
|
+
'minProperties', 'maxProperties',
|
|
102
|
+
]));
|
|
103
|
+
const DROPPED_KEYWORDS = Object.freeze(new Set(['description', 'title', 'example', 'examples', 'externalDocs', 'xml', 'deprecated']));
|
|
104
|
+
// Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
|
|
105
|
+
// int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
|
|
106
|
+
// to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
|
|
107
|
+
const SAFE_FORMATS = Object.freeze(new Set(['int32', 'int64', 'email', 'date', 'date-time', 'double', 'float', 'binary', 'uri']));
|
|
108
|
+
|
|
109
|
+
// Thrown internally by inlineSchema()'s recursive walk and caught exactly once at the exported
|
|
110
|
+
// boundary -- with 12+ distinct failure points, threading {ok:false} through every return would
|
|
111
|
+
// bury the actual walking logic. The "never throws across the module boundary" invariant
|
|
112
|
+
// (loadOpenApiDocument's own comment) is about the EXPORTED function, which this preserves.
|
|
113
|
+
class InlineFailure extends Error {
|
|
114
|
+
constructor(reason) {
|
|
115
|
+
super(reason);
|
|
116
|
+
this.reason = reason;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A6 (D-openapi-export): the `info` extension `bskel contract export` stamps on every document it
|
|
121
|
+
// writes. Declared HERE, on the reading side, because the guard below is the load-bearing consumer
|
|
122
|
+
// -- contracts/export.mjs imports this constant rather than spelling the key a second time, so the
|
|
123
|
+
// writer and the reader cannot drift apart and silently disarm the guard.
|
|
124
|
+
export const BSKEL_GENERATED_EXTENSION = 'x-bskel-generated';
|
|
125
|
+
|
|
126
|
+
export function hasBskelExportMarker(doc) {
|
|
127
|
+
const info = doc?.info;
|
|
128
|
+
if (typeof info !== 'object' || info === null || Array.isArray(info)) return false;
|
|
129
|
+
return Object.hasOwn(info, BSKEL_GENERATED_EXTENSION);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeRoute(routePath) {
|
|
133
|
+
let normalized = routePath.replace(/\/{2,}/g, '/');
|
|
134
|
+
if (normalized.length > 1 && normalized.endsWith('/')) normalized = normalized.slice(0, -1);
|
|
135
|
+
return normalized;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Reads and parses exactly once. Every failure mode returns {ok:false, error}, never throws --
|
|
139
|
+
// this is the one function in the module that touches the filesystem, so it's the one place that
|
|
140
|
+
// has to be defensive about a file that's huge, unreadable, not JSON, or JSON-but-not-an-object.
|
|
141
|
+
export function loadOpenApiDocument(filePath) {
|
|
142
|
+
let stat;
|
|
143
|
+
try {
|
|
144
|
+
stat = fs.statSync(filePath);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { ok: false, error: `could not read "${filePath}": ${err.message}` };
|
|
147
|
+
}
|
|
148
|
+
if (!stat.isFile()) {
|
|
149
|
+
return { ok: false, error: `"${filePath}" is not a regular file` };
|
|
150
|
+
}
|
|
151
|
+
if (stat.size > MAX_DOCUMENT_BYTES) {
|
|
152
|
+
return { ok: false, error: `"${filePath}" is ${stat.size} bytes, exceeds the ${MAX_DOCUMENT_BYTES}-byte limit for an OpenAPI document` };
|
|
153
|
+
}
|
|
154
|
+
let raw;
|
|
155
|
+
try {
|
|
156
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return { ok: false, error: `could not read "${filePath}": ${err.message}` };
|
|
159
|
+
}
|
|
160
|
+
let doc;
|
|
161
|
+
try {
|
|
162
|
+
doc = JSON.parse(raw);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { ok: false, error: `could not parse "${filePath}" as JSON: ${err.message}` };
|
|
165
|
+
}
|
|
166
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
167
|
+
return { ok: false, error: `"${filePath}" does not contain a JSON object at its root` };
|
|
168
|
+
}
|
|
169
|
+
const hash = createHash('sha256').update(raw).digest('hex');
|
|
170
|
+
return { ok: true, doc, hash, bytes: stat.size };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Builds two Maps (never plain objects -- see OPERATION_ID_RE's comment) from `doc.paths`:
|
|
174
|
+
// byOperationId (one entry per distinct valid operationId, first occurrence wins) and byRoute
|
|
175
|
+
// (keyed "VERB normalizedPath", value is an array -- more than one entry means the normalized
|
|
176
|
+
// route is ambiguous even within the document itself). `$ref` path items are skipped, not
|
|
177
|
+
// resolved (out of scope for this vertical slice -- see DECISIONS.md).
|
|
178
|
+
//
|
|
179
|
+
// A2: also builds `componentSchemas` (Map<name, schemaNode>, from `doc.components.schemas`) and
|
|
180
|
+
// retains each operation's raw `requestBody` node on its `entry` -- both were previously
|
|
181
|
+
// discarded entirely (A1 only needed {verb, path, operationId}). Indexing stays O(top-level
|
|
182
|
+
// count) here; deep walking into a schema's own `properties`/`$ref` chain happens lazily, only
|
|
183
|
+
// for the operations reconcileModule() actually needs a request-body schema for (see
|
|
184
|
+
// inlineSchema below) -- resolution cost doesn't scale with the size of the whole document.
|
|
185
|
+
export function indexOpenApiDocument(doc) {
|
|
186
|
+
const byOperationId = new Map();
|
|
187
|
+
const byRoute = new Map();
|
|
188
|
+
const componentSchemas = new Map();
|
|
189
|
+
const stats = {
|
|
190
|
+
path_count: 0, operation_count: 0, skipped_path_refs: 0, rejected_operation_ids: 0,
|
|
191
|
+
component_schema_count: 0, rejected_component_schemas: 0,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const openapiVersion = typeof doc.openapi === 'string' ? doc.openapi : null;
|
|
195
|
+
const schemaDialectSupported = typeof openapiVersion === 'string' && /^3\.1(?:\.|$)/.test(openapiVersion);
|
|
196
|
+
|
|
197
|
+
const rawComponentSchemas = doc.components && typeof doc.components === 'object' && !Array.isArray(doc.components)
|
|
198
|
+
? doc.components.schemas
|
|
199
|
+
: null;
|
|
200
|
+
if (rawComponentSchemas && typeof rawComponentSchemas === 'object' && !Array.isArray(rawComponentSchemas)) {
|
|
201
|
+
const schemaNames = Object.keys(rawComponentSchemas);
|
|
202
|
+
if (schemaNames.length > MAX_COMPONENT_SCHEMAS) {
|
|
203
|
+
return { ok: false, error: `OpenAPI document has ${schemaNames.length} component schemas, exceeds the ${MAX_COMPONENT_SCHEMAS}-schema limit` };
|
|
204
|
+
}
|
|
205
|
+
for (const name of schemaNames) {
|
|
206
|
+
const value = rawComponentSchemas[name];
|
|
207
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue;
|
|
208
|
+
if (!COMPONENT_SCHEMA_NAME_RE.test(name)) { stats.rejected_component_schemas++; continue; }
|
|
209
|
+
componentSchemas.set(name, value);
|
|
210
|
+
}
|
|
211
|
+
stats.component_schema_count = componentSchemas.size;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const paths = doc.paths;
|
|
215
|
+
if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) {
|
|
216
|
+
return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers: [], openapiVersion, schemaDialectSupported };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const pathKeys = Object.keys(paths);
|
|
220
|
+
if (pathKeys.length > MAX_PATHS) {
|
|
221
|
+
return { ok: false, error: `OpenAPI document has ${pathKeys.length} paths, exceeds the ${MAX_PATHS}-path limit` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const routeKey of pathKeys) {
|
|
225
|
+
if (typeof routeKey !== 'string' || !routeKey.startsWith('/')) continue;
|
|
226
|
+
const pathItem = paths[routeKey];
|
|
227
|
+
if (typeof pathItem !== 'object' || pathItem === null || Array.isArray(pathItem)) continue;
|
|
228
|
+
if (Object.hasOwn(pathItem, '$ref')) {
|
|
229
|
+
stats.skipped_path_refs++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
stats.path_count++;
|
|
233
|
+
const normalizedRoute = normalizeRoute(routeKey);
|
|
234
|
+
|
|
235
|
+
for (const methodKey of Object.keys(pathItem)) {
|
|
236
|
+
const verbLower = methodKey.toLowerCase();
|
|
237
|
+
if (!HTTP_METHODS.has(verbLower)) continue;
|
|
238
|
+
const operation = pathItem[methodKey];
|
|
239
|
+
if (typeof operation !== 'object' || operation === null || Array.isArray(operation)) continue;
|
|
240
|
+
|
|
241
|
+
if (stats.operation_count >= MAX_OPERATIONS) {
|
|
242
|
+
return { ok: false, error: `OpenAPI document has more than ${MAX_OPERATIONS} operations` };
|
|
243
|
+
}
|
|
244
|
+
stats.operation_count++;
|
|
245
|
+
|
|
246
|
+
const verb = verbLower.toUpperCase();
|
|
247
|
+
const rawOperationId = operation.operationId;
|
|
248
|
+
let operationId = null;
|
|
249
|
+
if (typeof rawOperationId === 'string') {
|
|
250
|
+
if (OPERATION_ID_RE.test(rawOperationId)) {
|
|
251
|
+
operationId = rawOperationId;
|
|
252
|
+
} else {
|
|
253
|
+
stats.rejected_operation_ids++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// A2: raw requestBody node retained verbatim (bounded by the document's own
|
|
258
|
+
// MAX_DOCUMENT_BYTES cap -- no new read, no new size limit needed). A `$ref` requestBody
|
|
259
|
+
// (`#/components/requestBodies/*`) is out of scope -- reconcileModule treats it as "no
|
|
260
|
+
// body to project" rather than resolving it, same as a genuinely bodyless operation.
|
|
261
|
+
const requestBody = typeof operation.requestBody === 'object' && operation.requestBody !== null && !Array.isArray(operation.requestBody)
|
|
262
|
+
? operation.requestBody
|
|
263
|
+
: null;
|
|
264
|
+
// A3: raw responses map retained verbatim, same "no new read, no new size cap" reasoning
|
|
265
|
+
// as requestBody above -- bounded by MAX_DOCUMENT_BYTES already.
|
|
266
|
+
const responses = typeof operation.responses === 'object' && operation.responses !== null && !Array.isArray(operation.responses)
|
|
267
|
+
? operation.responses
|
|
268
|
+
: null;
|
|
269
|
+
const entry = { verb, path: routeKey, operationId, requestBody, responses };
|
|
270
|
+
|
|
271
|
+
const routeMatchKey = `${verb} ${normalizedRoute}`;
|
|
272
|
+
const existingRoute = byRoute.get(routeMatchKey);
|
|
273
|
+
if (existingRoute) existingRoute.push(entry); else byRoute.set(routeMatchKey, [entry]);
|
|
274
|
+
|
|
275
|
+
// "First occurrence wins" -- same convention as contracts/emit.mjs's own
|
|
276
|
+
// CONTRACT_DUPLICATE_OPERATION_ID handling for scan-side duplicates.
|
|
277
|
+
if (operationId && !byOperationId.has(operationId)) {
|
|
278
|
+
byOperationId.set(operationId, entry);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const servers = Array.isArray(doc.servers)
|
|
284
|
+
? doc.servers.filter((s) => s && typeof s.url === 'string').map((s) => s.url)
|
|
285
|
+
: [];
|
|
286
|
+
|
|
287
|
+
return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers, openapiVersion, schemaDialectSupported };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// `S` (scan path) always starts with "/" (scanners/adapters/java-spring.mjs's joinPath guarantees
|
|
291
|
+
// this), so `O.endsWith(S)` alone would risk a false match at a non-segment boundary (e.g. "/api/
|
|
292
|
+
// v0/suborganizations".endsWith("/organizations")); requiring the remainder to itself look like a
|
|
293
|
+
// clean prefix (PATH_PREFIX_RE, which requires each segment to start with "/") makes that
|
|
294
|
+
// impossible -- a match can only occur at an actual "/" boundary between candidate segments.
|
|
295
|
+
function computeDelta(scanPath, docPath) {
|
|
296
|
+
if (docPath === scanPath) return '';
|
|
297
|
+
if (docPath.endsWith(scanPath)) {
|
|
298
|
+
const candidate = docPath.slice(0, docPath.length - scanPath.length);
|
|
299
|
+
if (PATH_PREFIX_RE.test(candidate)) return candidate;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// anchorDeltas: an array of delta strings, ONE PER ANCHOR (duplicates expected and meaningful --
|
|
305
|
+
// the tally becomes the snapshot's `path_prefix.deltas` for audit). A single distinct delta
|
|
306
|
+
// confirms the prefix; zero or conflicting deltas both leave path correction of ALREADY-matched
|
|
307
|
+
// operations unaffected (that never needed a prefix -- see reconcileModule) and only disable
|
|
308
|
+
// recovery of unmatched endpoints, which is the less valuable half of this feature.
|
|
309
|
+
export function inferPathPrefix(anchorDeltas) {
|
|
310
|
+
const counts = new Map();
|
|
311
|
+
for (const d of anchorDeltas) counts.set(d, (counts.get(d) ?? 0) + 1);
|
|
312
|
+
const uniqueDeltas = [...counts.keys()];
|
|
313
|
+
const deltas = Object.fromEntries(counts);
|
|
314
|
+
if (uniqueDeltas.length === 0) return { value: null, origin: 'none', deltas, conflicting: [] };
|
|
315
|
+
if (uniqueDeltas.length === 1) return { value: uniqueDeltas[0], origin: 'inferred', deltas, conflicting: [] };
|
|
316
|
+
return { value: null, origin: 'none', deltas, conflicting: uniqueDeltas };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// A2: dereferences `node` (a schema fragment from a requestBody's application/json content) into
|
|
320
|
+
// a single self-contained JSON Schema tree with NO `$ref` anywhere in the output. Pure, and NEVER
|
|
321
|
+
// throws across this exported boundary (InlineFailure is caught here, anything else re-thrown --
|
|
322
|
+
// it would be a real programming bug, not an untrusted-input failure, and must not be swallowed).
|
|
323
|
+
// Full inlining (never registering a component with ajv by $id) for two independent reasons: ajv
|
|
324
|
+
// would otherwise need every one of a document's component schemas registered just to validate
|
|
325
|
+
// ONE operation's body, and bin/bskel.mjs's cmdContractToolSchema promises its `input_schema`
|
|
326
|
+
// output is a JSON Schema subset "directly usable as-is" for Anthropic tool-use -- no $ref/$defs
|
|
327
|
+
// is exactly what that promise requires; this function is what upholds it.
|
|
328
|
+
export function inlineSchema(node, componentSchemas, opts = {}) {
|
|
329
|
+
const limits = {
|
|
330
|
+
maxDepth: opts.maxDepth ?? MAX_SCHEMA_DEPTH,
|
|
331
|
+
maxNodes: opts.maxNodes ?? MAX_SCHEMA_NODES,
|
|
332
|
+
maxPatternLength: opts.maxPatternLength ?? MAX_PATTERN_LENGTH,
|
|
333
|
+
};
|
|
334
|
+
const state = { nodes: 0 };
|
|
335
|
+
try {
|
|
336
|
+
const schema = walkSchemaNode(node, componentSchemas, 0, new Set(), state, limits);
|
|
337
|
+
return { ok: true, schema, nodes: state.nodes };
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (err instanceof InlineFailure) return { ok: false, reason: err.reason };
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function fail(reason) {
|
|
345
|
+
throw new InlineFailure(reason);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits) {
|
|
349
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node)) fail('not-a-schema-object');
|
|
350
|
+
if (depth > limits.maxDepth) fail('max-depth-exceeded');
|
|
351
|
+
state.nodes++;
|
|
352
|
+
if (state.nodes > limits.maxNodes) fail('too-many-nodes');
|
|
353
|
+
|
|
354
|
+
if (Object.hasOwn(node, '$ref')) {
|
|
355
|
+
// 2020-12 permits siblings alongside $ref (unlike OpenAPI 3.0's restriction), but this
|
|
356
|
+
// module doesn't attempt to MERGE $ref with a sibling assertion -- a DROPPED_KEYWORDS
|
|
357
|
+
// sibling (e.g. a documentation-only `description`) is harmless and ignored; anything else
|
|
358
|
+
// would need merge semantics this vertical slice doesn't implement, so it fails closed.
|
|
359
|
+
const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
|
|
360
|
+
if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k))) fail('ref-with-siblings');
|
|
361
|
+
const ref = node['$ref'];
|
|
362
|
+
if (typeof ref !== 'string' || !ref.startsWith(SCHEMA_REF_PREFIX)) fail('unsupported-ref');
|
|
363
|
+
const name = ref.slice(SCHEMA_REF_PREFIX.length);
|
|
364
|
+
// JSON-Pointer escapes (~0/~1) or percent-encoding in the name are never produced by
|
|
365
|
+
// springdoc for a plain component name -- reject rather than decode-and-guess.
|
|
366
|
+
if (name.includes('~') || name.includes('%') || !COMPONENT_SCHEMA_NAME_RE.test(name)) fail('unsupported-ref');
|
|
367
|
+
if (visiting.has(name)) fail('cycle-detected');
|
|
368
|
+
const target = componentSchemas.get(name);
|
|
369
|
+
if (!target) fail('component-not-found');
|
|
370
|
+
visiting.add(name);
|
|
371
|
+
try {
|
|
372
|
+
return walkSchemaNode(target, componentSchemas, depth + 1, visiting, state, limits);
|
|
373
|
+
} finally {
|
|
374
|
+
// Delete-on-exit: a diamond (two sibling properties referencing the SAME component) stays
|
|
375
|
+
// legal and is inlined independently for each -- only a true ancestor-chain cycle fails.
|
|
376
|
+
visiting.delete(name);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const out = {};
|
|
381
|
+
|
|
382
|
+
// `format` is handled before the general loop below because `uuid` is REWRITTEN (D-security-2,
|
|
383
|
+
// reapplied one layer down -- see emit.mjs's BARE_UUID_PATTERN comment), not copied or dropped
|
|
384
|
+
// like every other keyword; this must run before the loop reaches a `pattern` key so the
|
|
385
|
+
// uuid+pattern conflict check below (inside the loop) sees `out.pattern` already set.
|
|
386
|
+
if (Object.hasOwn(node, 'format')) {
|
|
387
|
+
const format = node.format;
|
|
388
|
+
if (format === 'uuid') {
|
|
389
|
+
out.pattern = BARE_UUID_PATTERN;
|
|
390
|
+
} else if (typeof format === 'string' && SAFE_FORMATS.has(format)) {
|
|
391
|
+
out.format = format;
|
|
392
|
+
} else {
|
|
393
|
+
fail(`unsupported-format:${typeof format === 'string' ? format : typeof format}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const key of Object.keys(node)) {
|
|
398
|
+
if (key === '$ref' || key === 'format') continue; // format already handled above
|
|
399
|
+
if (DROPPED_KEYWORDS.has(key)) continue;
|
|
400
|
+
|
|
401
|
+
if (key === 'pattern') {
|
|
402
|
+
// Two patterns can't be expressed without allOf, which this slice doesn't attempt to
|
|
403
|
+
// synthesize -- a node with BOTH format:'uuid' and an explicit pattern fails closed
|
|
404
|
+
// rather than guessing which one wins (out.pattern is already set if format:'uuid' ran).
|
|
405
|
+
if (Object.hasOwn(out, 'pattern')) fail('uuid-format-with-pattern');
|
|
406
|
+
const pattern = node.pattern;
|
|
407
|
+
if (typeof pattern !== 'string' || pattern.length > limits.maxPatternLength) fail('pattern-too-long');
|
|
408
|
+
// Partial ReDoS mitigation only -- bounds input SIZE, not regex STRUCTURE. A real,
|
|
409
|
+
// already-deployed Team-IZ-Backend pattern (CreateOrganizationRequest.emailDomain) has a
|
|
410
|
+
// nested quantifier well within this length cap. See D-openapi-request-schema.
|
|
411
|
+
try { new RegExp(pattern); } catch { fail('invalid-pattern'); }
|
|
412
|
+
out.pattern = pattern;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (key === 'required') {
|
|
417
|
+
const req = node.required;
|
|
418
|
+
if (!Array.isArray(req)) fail('unsupported-keyword:required');
|
|
419
|
+
for (const r of req) {
|
|
420
|
+
if (typeof r !== 'string' || !SCHEMA_PROPERTY_NAME_RE.test(r)) fail('unsupported-property-name');
|
|
421
|
+
}
|
|
422
|
+
out.required = [...req];
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (COPIED_KEYWORDS.has(key)) {
|
|
427
|
+
if (key === 'enum' && Array.isArray(node.enum)) {
|
|
428
|
+
state.nodes += node.enum.length; // enum entries aren't separate schema nodes, but still cost budget
|
|
429
|
+
if (state.nodes > limits.maxNodes) fail('too-many-nodes');
|
|
430
|
+
}
|
|
431
|
+
out[key] = node[key];
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (key === 'properties') {
|
|
436
|
+
const props = node.properties;
|
|
437
|
+
if (typeof props !== 'object' || props === null || Array.isArray(props)) fail('unsupported-keyword:properties');
|
|
438
|
+
const outProps = {};
|
|
439
|
+
for (const propName of Object.keys(props)) {
|
|
440
|
+
// Same prototype-pollution class as OPERATION_ID_RE/COMPONENT_SCHEMA_NAME_RE -- a
|
|
441
|
+
// violating key fails the WHOLE schema closed (not a per-property drop, which would
|
|
442
|
+
// silently emit a schema weaker than the real one).
|
|
443
|
+
if (!SCHEMA_PROPERTY_NAME_RE.test(propName)) fail('unsupported-property-name');
|
|
444
|
+
outProps[propName] = walkSchemaNode(props[propName], componentSchemas, depth + 1, visiting, state, limits);
|
|
445
|
+
}
|
|
446
|
+
out.properties = outProps;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (key === 'items') {
|
|
451
|
+
out.items = walkSchemaNode(node.items, componentSchemas, depth + 1, visiting, state, limits);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (key === 'additionalProperties') {
|
|
456
|
+
const ap = node.additionalProperties;
|
|
457
|
+
out.additionalProperties = typeof ap === 'boolean'
|
|
458
|
+
? ap
|
|
459
|
+
: walkSchemaNode(ap, componentSchemas, depth + 1, visiting, state, limits);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (key === 'oneOf' || key === 'anyOf' || key === 'allOf') {
|
|
464
|
+
const arr = node[key];
|
|
465
|
+
if (!Array.isArray(arr) || arr.length === 0) fail(`unsupported-keyword:${key}`);
|
|
466
|
+
out[key] = arr.map((el) => walkSchemaNode(el, componentSchemas, depth + 1, visiting, state, limits));
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
fail(`unsupported-keyword:${key}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// A2: attaches requestBodySchema/requestBodyRequired (or schemaUnresolvedReason) to a `matched`/
|
|
477
|
+
// `adopted` result, mutating it in place -- called only for those two kinds (see reconcileModule),
|
|
478
|
+
// since a `drift`/`missing`/`ambiguous`/`unresolved` operation hasn't earned trust on path/verb,
|
|
479
|
+
// let alone body shape. `docEntry` is the OpenAPI-side entry (from byOperationId or byRoute) whose
|
|
480
|
+
// `.requestBody` indexOpenApiDocument() retained. Never treats "nothing to project" as a failure --
|
|
481
|
+
// only an actual unresolvable schema increments schema_unresolved / sets schemaUnresolvedReason.
|
|
482
|
+
function applyRequestBodySchema(result, docEntry, componentSchemas, stats) {
|
|
483
|
+
const requestBody = docEntry.requestBody;
|
|
484
|
+
if (!requestBody || Object.hasOwn(requestBody, '$ref')) {
|
|
485
|
+
stats.schema_none++;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const content = requestBody.content;
|
|
489
|
+
if (typeof content !== 'object' || content === null || Array.isArray(content) || !Object.hasOwn(content, JSON_MEDIA_TYPE)) {
|
|
490
|
+
stats.schema_skipped_media_type++;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const mediaEntry = content[JSON_MEDIA_TYPE];
|
|
494
|
+
const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
|
|
495
|
+
if (!schemaNode || typeof schemaNode !== 'object' || Array.isArray(schemaNode)) {
|
|
496
|
+
stats.schema_none++;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const resolved = inlineSchema(schemaNode, componentSchemas);
|
|
500
|
+
if (resolved.ok) {
|
|
501
|
+
result.requestBodySchema = resolved.schema;
|
|
502
|
+
result.requestBodyRequired = requestBody.required === true;
|
|
503
|
+
stats.schema_resolved++;
|
|
504
|
+
} else {
|
|
505
|
+
result.schemaUnresolvedReason = resolved.reason;
|
|
506
|
+
stats.schema_unresolved++;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// A3: recursive key-sorted serialization, used to compare two INLINE-RESOLVED schemas for
|
|
511
|
+
// structural equality (two different raw response-object nodes can resolve to the identical
|
|
512
|
+
// schema -- e.g. the real Team-IZ-Backend `findCurrentProject`, whose 200 and 204 responses are
|
|
513
|
+
// separate objects but both reference the same `ProjectResponse` component). Array order
|
|
514
|
+
// (`required`/`enum`) is significant, which makes this comparison CONSERVATIVE: two schemas that
|
|
515
|
+
// are semantically equal but differ in array order compare as distinct, which only ever produces
|
|
516
|
+
// an extra (still-correct) `anyOf` branch -- never a false "these are the same" collapse.
|
|
517
|
+
function canonicalJson(value) {
|
|
518
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
519
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
520
|
+
const keys = Object.keys(value).sort();
|
|
521
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// A3: pure. Projects every documented response whose status matches `statusRe` (SUCCESS_STATUS_RE
|
|
525
|
+
// or ERROR_STATUS_RE) and has an `application/json` schema, deduplicating on the RAW schema node
|
|
526
|
+
// first (JSON.stringify -- cheap, and collapses the extremely common case of many statuses all
|
|
527
|
+
// pointing at the literal same node, e.g. every error status sharing one $ref to `ErrorResponse`)
|
|
528
|
+
// and then on the RESOLVED schema via canonicalJson (catches distinct $refs that happen to
|
|
529
|
+
// resolve identically). Returns exactly one discriminated outcome:
|
|
530
|
+
// {outcome:'none'} no matching status, or none had a usable schema
|
|
531
|
+
// {outcome:'skipped-media-type'} a matching status had `content` but no application/json
|
|
532
|
+
// {outcome:'unresolved', reason} at least one matching schema failed inlineSchema()
|
|
533
|
+
// {outcome:'resolved', schema, sources} sources = how many distinct resolved shapes were unioned
|
|
534
|
+
// Fails closed on the FIRST unresolvable schema rather than unioning the rest -- a partial anyOf
|
|
535
|
+
// would describe a NARROWER set of shapes than the document actually allows, i.e. it would reject
|
|
536
|
+
// a real response the API produces. That is the same "never emit something that contradicts
|
|
537
|
+
// reality" rule A2 applied in the opposite direction (never emit a schema weaker than the real
|
|
538
|
+
// DTO) -- here the risk runs the other way, so the fix runs the other way too, but the underlying
|
|
539
|
+
// principle (don't guess, don't approximate) is identical.
|
|
540
|
+
//
|
|
541
|
+
// A6 (D-openapi-export): `includeDefault` folds OpenAPI's `default` response into this direction.
|
|
542
|
+
// It is passed ONLY for the error side, and that asymmetry is the whole point: `default` means
|
|
543
|
+
// "every status not otherwise listed", so its body may well be an error shape. Folding it into
|
|
544
|
+
// SUCCESS would let an error shape satisfy success validation -- a real false negative. Folding it
|
|
545
|
+
// into ERROR can only ever WIDEN the error union, which A3's `anyOf` design already tolerates by
|
|
546
|
+
// construction ("matches at least one documented shape"), and never narrows what a real response is
|
|
547
|
+
// permitted to be. A document whose `default` genuinely describes a success body therefore loses
|
|
548
|
+
// nothing it had before (nothing read `default` at all until now); one whose `default` describes an
|
|
549
|
+
// error -- the overwhelmingly common case, and the only case `bskel contract export` itself emits --
|
|
550
|
+
// gains a real error schema it previously dropped silently.
|
|
551
|
+
function projectResponseSchemas(responses, statusRe, componentSchemas, { includeDefault = false } = {}) {
|
|
552
|
+
if (!responses) return { outcome: 'none' };
|
|
553
|
+
const statusKeys = Object.keys(responses);
|
|
554
|
+
if (statusKeys.length > MAX_RESPONSES_PER_OPERATION) {
|
|
555
|
+
return { outcome: 'unresolved', reason: 'too-many-responses' };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const rawNodesByKey = new Map();
|
|
559
|
+
let sawContentWithoutJson = false;
|
|
560
|
+
for (const status of statusKeys) {
|
|
561
|
+
if (!statusRe.test(status) && !(includeDefault && status === DEFAULT_STATUS_KEY)) continue;
|
|
562
|
+
const resp = responses[status];
|
|
563
|
+
if (typeof resp !== 'object' || resp === null || Array.isArray(resp)) continue;
|
|
564
|
+
const content = resp.content;
|
|
565
|
+
if (typeof content !== 'object' || content === null || Array.isArray(content)) continue; // no content at all -- nothing to project for this status, not a failure
|
|
566
|
+
if (!Object.hasOwn(content, JSON_MEDIA_TYPE)) { sawContentWithoutJson = true; continue; }
|
|
567
|
+
const mediaEntry = content[JSON_MEDIA_TYPE];
|
|
568
|
+
const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
|
|
569
|
+
if (!schemaNode || typeof schemaNode !== 'object' || Array.isArray(schemaNode)) continue;
|
|
570
|
+
const rawKey = JSON.stringify(schemaNode);
|
|
571
|
+
if (!rawNodesByKey.has(rawKey)) rawNodesByKey.set(rawKey, schemaNode);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (rawNodesByKey.size === 0) {
|
|
575
|
+
return { outcome: sawContentWithoutJson ? 'skipped-media-type' : 'none' };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const resolvedByCanonical = new Map();
|
|
579
|
+
for (const node of rawNodesByKey.values()) {
|
|
580
|
+
const resolved = inlineSchema(node, componentSchemas);
|
|
581
|
+
if (!resolved.ok) return { outcome: 'unresolved', reason: resolved.reason };
|
|
582
|
+
const canonicalKey = canonicalJson(resolved.schema);
|
|
583
|
+
if (!resolvedByCanonical.has(canonicalKey)) resolvedByCanonical.set(canonicalKey, resolved.schema);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const distinct = [...resolvedByCanonical.values()];
|
|
587
|
+
if (distinct.length === 1) return { outcome: 'resolved', schema: distinct[0], sources: 1 };
|
|
588
|
+
// A3: anyOf, NEVER oneOf. A2 established that projected schemas never carry
|
|
589
|
+
// additionalProperties:false (Team-IZ-Backend has no Jackson customization -- see
|
|
590
|
+
// D-openapi-request-schema), so two documented response shapes routinely overlap (a minimal
|
|
591
|
+
// 202/204 body is often a strict subset of the 200 body's fields). oneOf requires EXACTLY one
|
|
592
|
+
// branch to match and would reject a real, valid response matching more than one branch --
|
|
593
|
+
// verified directly against the installed Ajv2020: a payload matching two overlapping
|
|
594
|
+
// branches is rejected by oneOf and accepted by anyOf. anyOf states precisely what's true
|
|
595
|
+
// given the envelope carries no status code: "matches at least one documented shape."
|
|
596
|
+
return { outcome: 'resolved', schema: { anyOf: distinct }, sources: distinct.length };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// A3: applies both response (2xx) and error (4xx/5xx) projection to a `matched`/`adopted` result,
|
|
600
|
+
// mirroring applyRequestBodySchema's placement/gating exactly (same two call sites, same
|
|
601
|
+
// schemaProjection.enabled guard). Fields are set ONLY when resolved -- omitted, not null/false,
|
|
602
|
+
// so an operation with nothing to project stays byte-identical to pre-A3 output (same discipline
|
|
603
|
+
// as A2's requestBodySchema).
|
|
604
|
+
function applyResponseSchemas(result, docEntry, componentSchemas, stats) {
|
|
605
|
+
applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, SUCCESS_STATUS_RE, componentSchemas), stats, 'response');
|
|
606
|
+
// A6: `default` contributes to the ERROR side only -- see projectResponseSchemas' own comment.
|
|
607
|
+
applyProjectionOutcome(result, projectResponseSchemas(docEntry.responses, ERROR_STATUS_RE, componentSchemas, { includeDefault: true }), stats, 'error');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function applyProjectionOutcome(result, projected, stats, kind) {
|
|
611
|
+
const fieldSchema = kind === 'response' ? 'responseSchema' : 'errorSchema';
|
|
612
|
+
const fieldSources = kind === 'response' ? 'responseSchemaSources' : 'errorSchemaSources';
|
|
613
|
+
const fieldReason = kind === 'response' ? 'responseSchemaUnresolvedReason' : 'errorSchemaUnresolvedReason';
|
|
614
|
+
const counterPrefix = kind === 'response' ? 'response_schema_' : 'error_schema_';
|
|
615
|
+
if (projected.outcome === 'resolved') {
|
|
616
|
+
result[fieldSchema] = projected.schema;
|
|
617
|
+
result[fieldSources] = projected.sources;
|
|
618
|
+
stats[`${counterPrefix}resolved`]++;
|
|
619
|
+
} else if (projected.outcome === 'unresolved') {
|
|
620
|
+
result[fieldReason] = projected.reason;
|
|
621
|
+
stats[`${counterPrefix}unresolved`]++;
|
|
622
|
+
} else if (projected.outcome === 'skipped-media-type') {
|
|
623
|
+
stats[`${counterPrefix}skipped_media_type`]++;
|
|
624
|
+
} else {
|
|
625
|
+
stats[`${counterPrefix}none`]++;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// The core reconciliation, pure (no I/O). `module` is a scanReport related_modules entry (as
|
|
630
|
+
// selected by contracts/emit.mjs's selectModule -- caller's responsibility to pass the SAME
|
|
631
|
+
// selection buildContract() will use, so endpointKey(ci,ei) lines up). `pathPrefix`, if given
|
|
632
|
+
// (from --path-prefix), overrides inference entirely but the anchor pass still runs so its
|
|
633
|
+
// deltas are recorded for audit in the snapshot.
|
|
634
|
+
export function reconcileModule({ index, module, pathPrefix = null }) {
|
|
635
|
+
const anchorDeltas = [];
|
|
636
|
+
for (const controller of module.controllers) {
|
|
637
|
+
for (const ep of controller.endpoints) {
|
|
638
|
+
if (!ep.operationId) continue;
|
|
639
|
+
const docEntry = index.byOperationId.get(ep.operationId);
|
|
640
|
+
if (!docEntry || docEntry.verb !== ep.verb) continue; // verb mismatch => not a safe anchor, surfaces as drift below
|
|
641
|
+
const delta = computeDelta(ep.path, docEntry.path);
|
|
642
|
+
if (delta !== null) anchorDeltas.push(delta);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const inferred = inferPathPrefix(anchorDeltas);
|
|
646
|
+
const prefix = pathPrefix != null
|
|
647
|
+
? { value: pathPrefix, origin: 'flag', deltas: inferred.deltas, conflicting: [] }
|
|
648
|
+
: inferred;
|
|
649
|
+
|
|
650
|
+
const byEndpoint = new Map();
|
|
651
|
+
const stats = {
|
|
652
|
+
matched: 0, adopted: 0, drift: 0, missing: 0, ambiguous: 0, unresolved: 0,
|
|
653
|
+
// A2: initialized here (not left implicit) so they're always present in evidence.openapi /
|
|
654
|
+
// the snapshot, even for a document with no request bodies at all -- a stable shape for
|
|
655
|
+
// downstream consumers that already spread ...stats (bin/bskel.mjs, snapshotFromReconciliation).
|
|
656
|
+
// Bare `schema_*` means request-body specifically (A2) -- kept as-is, not renamed to
|
|
657
|
+
// `request_schema_*`, for evidence-key stability (test/contract-cli.test.mjs and real gate
|
|
658
|
+
// records already assert this exact name).
|
|
659
|
+
schema_resolved: 0, schema_unresolved: 0, schema_none: 0, schema_skipped_media_type: 0,
|
|
660
|
+
// A3: response (2xx) / error (4xx/5xx) counters, same stable-shape reasoning.
|
|
661
|
+
response_schema_resolved: 0, response_schema_unresolved: 0, response_schema_none: 0, response_schema_skipped_media_type: 0,
|
|
662
|
+
error_schema_resolved: 0, error_schema_unresolved: 0, error_schema_none: 0, error_schema_skipped_media_type: 0,
|
|
663
|
+
};
|
|
664
|
+
// A2: an OpenAPI 3.0 document's `exclusiveMinimum`/`nullable` mean something different under
|
|
665
|
+
// JSON Schema 2020-12 (the dialect Ajv2020 speaks) -- rather than silently misinterpreting
|
|
666
|
+
// those, schema projection is disabled for the WHOLE document, once, here -- not per-operation
|
|
667
|
+
// (which would flood every contract with N warnings for one root cause). Path/verb
|
|
668
|
+
// reconciliation above is dialect-independent and stays fully active either way.
|
|
669
|
+
const schemaProjection = index.schemaDialectSupported
|
|
670
|
+
? { enabled: true, reason: null }
|
|
671
|
+
: { enabled: false, reason: 'unsupported-openapi-version' };
|
|
672
|
+
|
|
673
|
+
for (const [ci, controller] of module.controllers.entries()) {
|
|
674
|
+
for (const [ei, ep] of controller.endpoints.entries()) {
|
|
675
|
+
const key = endpointKey(ci, ei);
|
|
676
|
+
let result;
|
|
677
|
+
|
|
678
|
+
if (ep.operationId) {
|
|
679
|
+
const docEntry = index.byOperationId.get(ep.operationId);
|
|
680
|
+
if (!docEntry) {
|
|
681
|
+
result = { kind: 'missing', scanVerb: ep.verb, scanPath: ep.path };
|
|
682
|
+
stats.missing++;
|
|
683
|
+
} else if (docEntry.verb !== ep.verb) {
|
|
684
|
+
result = {
|
|
685
|
+
kind: 'drift', reason: 'verb',
|
|
686
|
+
openapi: { verb: docEntry.verb, path: docEntry.path },
|
|
687
|
+
scanVerb: ep.verb, scanPath: ep.path,
|
|
688
|
+
};
|
|
689
|
+
stats.drift++;
|
|
690
|
+
} else {
|
|
691
|
+
const delta = computeDelta(ep.path, docEntry.path);
|
|
692
|
+
if (docEntry.path === ep.path || delta !== null) {
|
|
693
|
+
result = {
|
|
694
|
+
kind: 'matched', operationId: ep.operationId, verb: docEntry.verb, path: docEntry.path,
|
|
695
|
+
scanVerb: ep.verb, scanPath: ep.path,
|
|
696
|
+
};
|
|
697
|
+
stats.matched++;
|
|
698
|
+
// A2/A3: matched/adopted ONLY -- schema enrichment never applies to drift/missing/
|
|
699
|
+
// ambiguous/unresolved, same "don't guess" rule A1 established for path/verb.
|
|
700
|
+
if (schemaProjection.enabled) {
|
|
701
|
+
applyRequestBodySchema(result, docEntry, index.componentSchemas, stats);
|
|
702
|
+
applyResponseSchemas(result, docEntry, index.componentSchemas, stats);
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
result = {
|
|
706
|
+
kind: 'drift', reason: 'path',
|
|
707
|
+
openapi: { verb: docEntry.verb, path: docEntry.path },
|
|
708
|
+
scanVerb: ep.verb, scanPath: ep.path,
|
|
709
|
+
};
|
|
710
|
+
stats.drift++;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} else if (prefix.value == null) {
|
|
714
|
+
result = { kind: 'unresolved', reason: 'prefix-inconclusive', scanVerb: ep.verb, scanPath: ep.path };
|
|
715
|
+
stats.unresolved++;
|
|
716
|
+
} else {
|
|
717
|
+
const candidates = prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
|
|
718
|
+
const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${normalizeRoute(c)}`) ?? []);
|
|
719
|
+
if (hits.length === 0) {
|
|
720
|
+
result = { kind: 'unresolved', reason: 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
|
|
721
|
+
stats.unresolved++;
|
|
722
|
+
} else if (hits.length === 1 && hits[0].operationId) {
|
|
723
|
+
result = {
|
|
724
|
+
kind: 'adopted', operationId: hits[0].operationId, verb: hits[0].verb, path: hits[0].path,
|
|
725
|
+
scanVerb: ep.verb, scanPath: ep.path,
|
|
726
|
+
};
|
|
727
|
+
stats.adopted++;
|
|
728
|
+
if (schemaProjection.enabled) {
|
|
729
|
+
applyRequestBodySchema(result, hits[0], index.componentSchemas, stats);
|
|
730
|
+
applyResponseSchemas(result, hits[0], index.componentSchemas, stats);
|
|
731
|
+
}
|
|
732
|
+
} else if (hits.length === 1) {
|
|
733
|
+
// A single route match, but the document itself never gave that operation an
|
|
734
|
+
// operationId -- nothing to route by, so this can't become an addressable
|
|
735
|
+
// operation regardless. Distinct reason from "no-candidate" for diagnosability.
|
|
736
|
+
result = { kind: 'unresolved', reason: 'document-missing-operation-id', scanVerb: ep.verb, scanPath: ep.path };
|
|
737
|
+
stats.unresolved++;
|
|
738
|
+
} else {
|
|
739
|
+
result = {
|
|
740
|
+
kind: 'ambiguous',
|
|
741
|
+
candidates: hits.map((h) => ({ verb: h.verb, path: h.path, operationId: h.operationId })),
|
|
742
|
+
scanVerb: ep.verb, scanPath: ep.path,
|
|
743
|
+
};
|
|
744
|
+
stats.ambiguous++;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
byEndpoint.set(key, result);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return { byEndpoint, prefix, stats, schemaProjection };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Convenience entry point: load + index + reconcile in one call, propagating the first failure.
|
|
756
|
+
// This is what bin/bskel.mjs's cmdContractEmit calls.
|
|
757
|
+
export function buildReconciliation({ filePath, module, pathPrefix = null }) {
|
|
758
|
+
if (pathPrefix != null && !PATH_PREFIX_RE.test(pathPrefix)) {
|
|
759
|
+
return { ok: false, error: `--path-prefix "${pathPrefix}" is not a valid path prefix (expected e.g. "/api/v0")` };
|
|
760
|
+
}
|
|
761
|
+
const loaded = loadOpenApiDocument(filePath);
|
|
762
|
+
if (!loaded.ok) return loaded;
|
|
763
|
+
// A6 (D-openapi-export): the structural half of the hazard `bskel contract export` creates.
|
|
764
|
+
// Piping an export straight back into `contract emit --openapi-file` would make the contract
|
|
765
|
+
// "confirm" itself -- stats.matched would read N/N and A1's entire point (an INDEPENDENT
|
|
766
|
+
// oracle) would evaporate silently. Worse than a no-op: a `drift` operation still sits in
|
|
767
|
+
// `contract.operations` at the scan's own uncorrected verb/path, so an export puts it in the
|
|
768
|
+
// document at exactly that verb/path, computeDelta() then agrees, and a recorded
|
|
769
|
+
// CONTRACT_OPENAPI_DRIFT (ERROR) reclassifies as `matched` and disappears. Same for `missing`;
|
|
770
|
+
// an `ambiguous` endpoint is worse still, since it is never in the contract at all and would
|
|
771
|
+
// come back as a plain CONTRACT_UNMATCHED_ENDPOINT -- a DIFFERENT code, which breaks any
|
|
772
|
+
// waiver already recorded against it ({code, subject} is the waiver key, see
|
|
773
|
+
// D-contract-completeness). Refused here rather than defended against downstream, and through
|
|
774
|
+
// the existing BAD_ARGS/exit-14 path cmdContractEmit already uses for a malformed
|
|
775
|
+
// --openapi-file -- no new exit code, no new machinery.
|
|
776
|
+
if (hasBskelExportMarker(loaded.doc)) {
|
|
777
|
+
return { ok: false, error: `"${filePath}" was generated by \`bskel contract export\` -- reconciling a contract against its own export defeats the point of an independent oracle (every operation would confirm itself, and an already-recorded drift/missing ERROR would silently reclassify as matched). Point --openapi-file at a document the application itself produces. If you genuinely mean to reconcile against a hand-edited copy, remove the "${BSKEL_GENERATED_EXTENSION}" extension from its \`info\` object first.` };
|
|
778
|
+
}
|
|
779
|
+
const indexed = indexOpenApiDocument(loaded.doc);
|
|
780
|
+
if (!indexed.ok) return indexed;
|
|
781
|
+
const recon = reconcileModule({ index: indexed, module, pathPrefix });
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
document: {
|
|
785
|
+
hash: loaded.hash,
|
|
786
|
+
bytes: loaded.bytes,
|
|
787
|
+
path_count: indexed.stats.path_count,
|
|
788
|
+
operation_count: indexed.stats.operation_count,
|
|
789
|
+
skipped_path_refs: indexed.stats.skipped_path_refs,
|
|
790
|
+
rejected_operation_ids: indexed.stats.rejected_operation_ids,
|
|
791
|
+
component_schema_count: indexed.stats.component_schema_count,
|
|
792
|
+
rejected_component_schemas: indexed.stats.rejected_component_schemas,
|
|
793
|
+
openapi_version: indexed.openapiVersion,
|
|
794
|
+
servers: indexed.servers,
|
|
795
|
+
},
|
|
796
|
+
byEndpoint: recon.byEndpoint,
|
|
797
|
+
prefix: recon.prefix,
|
|
798
|
+
stats: recon.stats,
|
|
799
|
+
schemaProjection: recon.schemaProjection,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// `sourceFile`: {file, outsideRepo} precomputed by the caller (bin/bskel.mjs knows the repo
|
|
804
|
+
// root; this module deliberately doesn't) -- keeps machine-specific absolute paths out of a
|
|
805
|
+
// committed artifact when the OpenAPI file lives outside the repo.
|
|
806
|
+
export function snapshotFromReconciliation(reconciliation, { featureId, sourceFile }) {
|
|
807
|
+
const operations = {};
|
|
808
|
+
for (const result of reconciliation.byEndpoint.values()) {
|
|
809
|
+
if (result.kind === 'matched' || result.kind === 'adopted') {
|
|
810
|
+
operations[result.operationId] = {
|
|
811
|
+
verb: result.verb,
|
|
812
|
+
path: result.path,
|
|
813
|
+
via: result.kind === 'adopted' ? 'openapi' : 'operationId',
|
|
814
|
+
scan_path: result.scanPath,
|
|
815
|
+
// A2: records the DECISION, not the schema itself -- the schema payload already lives
|
|
816
|
+
// in the contract file, already covered by the contract gate's contract_hash. This is
|
|
817
|
+
// an audit trail of what reconciliation concluded, not a second copy of the data.
|
|
818
|
+
request_body_schema: result.requestBodySchema
|
|
819
|
+
? 'resolved'
|
|
820
|
+
: result.schemaUnresolvedReason
|
|
821
|
+
? `unresolved:${result.schemaUnresolvedReason}`
|
|
822
|
+
: 'none',
|
|
823
|
+
// A3: same decision-only audit trail, for the response/error projections.
|
|
824
|
+
response_schema: result.responseSchema
|
|
825
|
+
? (result.responseSchemaSources > 1 ? `resolved:union:${result.responseSchemaSources}` : 'resolved')
|
|
826
|
+
: result.responseSchemaUnresolvedReason
|
|
827
|
+
? `unresolved:${result.responseSchemaUnresolvedReason}`
|
|
828
|
+
: 'none',
|
|
829
|
+
error_schema: result.errorSchema
|
|
830
|
+
? (result.errorSchemaSources > 1 ? `resolved:union:${result.errorSchemaSources}` : 'resolved')
|
|
831
|
+
: result.errorSchemaUnresolvedReason
|
|
832
|
+
? `unresolved:${result.errorSchemaUnresolvedReason}`
|
|
833
|
+
: 'none',
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
schema: 'sbf.openapi-snapshot/1',
|
|
839
|
+
feature_id: featureId,
|
|
840
|
+
source: {
|
|
841
|
+
file: sourceFile.file,
|
|
842
|
+
outside_repo: sourceFile.outsideRepo,
|
|
843
|
+
sha256: reconciliation.document.hash,
|
|
844
|
+
bytes: reconciliation.document.bytes,
|
|
845
|
+
},
|
|
846
|
+
document: {
|
|
847
|
+
path_count: reconciliation.document.path_count,
|
|
848
|
+
operation_count: reconciliation.document.operation_count,
|
|
849
|
+
skipped_path_refs: reconciliation.document.skipped_path_refs,
|
|
850
|
+
component_schema_count: reconciliation.document.component_schema_count,
|
|
851
|
+
rejected_component_schemas: reconciliation.document.rejected_component_schemas,
|
|
852
|
+
openapi_version: reconciliation.document.openapi_version,
|
|
853
|
+
servers: reconciliation.document.servers,
|
|
854
|
+
},
|
|
855
|
+
path_prefix: reconciliation.prefix,
|
|
856
|
+
schema_projection: reconciliation.schemaProjection,
|
|
857
|
+
operations,
|
|
858
|
+
stats: reconciliation.stats,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Repo-relative-or-basename-only descriptor for snapshotFromReconciliation's sourceFile param --
|
|
863
|
+
// exported so bin/bskel.mjs doesn't need to duplicate the path.relative/outside-repo logic.
|
|
864
|
+
export function describeSourceFile(repoRoot, filePath) {
|
|
865
|
+
const resolved = path.resolve(filePath);
|
|
866
|
+
const rel = path.relative(repoRoot, resolved);
|
|
867
|
+
const outsideRepo = rel.startsWith('..') || path.isAbsolute(rel);
|
|
868
|
+
return { file: outsideRepo ? path.basename(resolved) : rel, outsideRepo };
|
|
869
|
+
}
|