@ttsc/evidence 0.30.3 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/lib/index.d.ts +2 -3
- package/lib/index.js +2 -3
- package/lib/internal/canonicalDigest.js +12 -6
- package/lib/internal/loadPrismaModels.js +52 -7
- package/lib/internal/loadSwaggerOperations.js +253 -59
- package/lib/internal/normalizeSwaggerDocument.js +320 -2
- package/lib/structures/ITtscEvidenceDocumentedConfig.d.ts +16 -8
- package/lib/structures/ITtscEvidenceGraphClaim.d.ts +6 -0
- package/lib/structures/ITtscEvidenceGraphClaimBase.d.ts +8 -2
- package/lib/structures/ITtscEvidenceGraphConfig.d.ts +6 -0
- package/lib/structures/ITtscEvidenceGraphMarkdownClaim.d.ts +7 -1
- package/lib/structures/ITtscEvidenceGraphMarkdownReference.d.ts +7 -1
- package/lib/structures/ITtscEvidenceGraphPrismaClaim.d.ts +7 -1
- package/lib/structures/ITtscEvidenceGraphPrismaReference.d.ts +7 -1
- package/lib/structures/ITtscEvidenceGraphReference.d.ts +10 -4
- package/lib/structures/ITtscEvidenceGraphReferenceBase.d.ts +6 -0
- package/lib/structures/ITtscEvidenceGraphSwaggerReference.d.ts +6 -0
- package/lib/structures/ITtscEvidenceGraphTypeScriptClaim.d.ts +7 -1
- package/lib/structures/ITtscEvidenceGraphTypeScriptReference.d.ts +11 -3
- package/lib/structures/ITtscEvidenceRules.d.ts +93 -0
- package/lib/structures/TtscEvidenceGraphMarkdownSymbol.d.ts +39 -0
- package/lib/{typings → structures}/TtscEvidenceGraphPrismaSymbol.d.ts +6 -0
- package/lib/{typings → structures}/TtscEvidenceGraphTypeScriptSymbol.d.ts +6 -0
- package/lib/structures/index.d.ts +4 -0
- package/lib/structures/index.js +4 -0
- package/native/base.go +25 -23
- package/native/bridge_trace.go +348 -0
- package/native/comments.go +1 -1
- package/native/config.go +6 -3
- package/native/declaration.go +17 -43
- package/native/documentation_regions.go +214 -0
- package/native/documented.go +5 -4
- package/native/exports.go +1 -1
- package/native/fingerprint.go +2 -8
- package/native/glob.go +4 -1
- package/native/graph.go +59 -23
- package/native/graph_nodes.go +2 -1
- package/native/hints.go +22 -18
- package/native/imports.go +7 -6
- package/native/input_reader.go +83 -0
- package/native/inputs.go +13 -12
- package/native/loader.go +36 -24
- package/native/markdown.go +587 -177
- package/native/markdown_capture.go +223 -0
- package/native/model.go +2 -1
- package/native/prisma.go +110 -25
- package/native/prisma_cache.go +12 -8
- package/native/prisma_locator.go +70 -33
- package/native/review.go +8 -7
- package/native/rooted_typescript.go +3 -4
- package/native/swagger.go +123 -15
- package/native/swagger_cache.go +16 -12
- package/native/todo.go +12 -0
- package/native/typescript.go +129 -25
- package/package.json +9 -7
- package/lib/typings/ITtscLintContributorRules.d.ts +0 -57
- package/lib/typings/ITtscLintRuleOptionsMap.d.ts +0 -20
- package/lib/typings/ITtscLintRuleOptionsMap.js +0 -2
- package/lib/typings/TtscEvidenceGraphMarkdownSymbol.d.ts +0 -32
- package/lib/typings/index.d.ts +0 -5
- package/lib/typings/index.js +0 -21
- /package/lib/{typings/ITtscLintContributorRules.js → structures/ITtscEvidenceRules.js} +0 -0
- /package/lib/{typings → structures}/TtscEvidenceGraphMarkdownSymbol.js +0 -0
- /package/lib/{typings → structures}/TtscEvidenceGraphPrismaSymbol.js +0 -0
- /package/lib/{typings → structures}/TtscEvidenceGraphTypeScriptSymbol.js +0 -0
package/README.md
CHANGED
|
@@ -78,7 +78,11 @@ This is a rule contributor to [`@ttsc/lint`](https://github.com/samchon/ttsc/tre
|
|
|
78
78
|
|
|
79
79
|
```ts
|
|
80
80
|
// lint.config.ts
|
|
81
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
evidence,
|
|
83
|
+
type ITtscEvidenceGraphConfig,
|
|
84
|
+
type ITtscEvidenceRules,
|
|
85
|
+
} from "@ttsc/evidence";
|
|
82
86
|
import type { ITtscLintConfig } from "@ttsc/lint";
|
|
83
87
|
|
|
84
88
|
const graph: ITtscEvidenceGraphConfig = {
|
|
@@ -102,10 +106,10 @@ export default {
|
|
|
102
106
|
"evidence/graph": ["error", graph],
|
|
103
107
|
"evidence/review": "error",
|
|
104
108
|
},
|
|
105
|
-
} satisfies ITtscLintConfig
|
|
109
|
+
} satisfies ITtscLintConfig<ITtscEvidenceRules>;
|
|
106
110
|
```
|
|
107
111
|
|
|
108
|
-
One claim: the components under `src` implement the docs, so every H2 and H3 under `docs` must be cited by a component. Run `npx ttsc` and the error count is the backlog.
|
|
112
|
+
One claim: the components under `src` implement the docs, so every H2 and H3 under `docs` must be cited by a component. Run `npx ttsc` and the error count is the backlog. Passing `ITtscEvidenceRules` to `ITtscLintConfig` gives every Evidence rule exact option checking.
|
|
109
113
|
|
|
110
114
|
[The configuration reference](https://ttsc.dev/docs/evidence/claims) has every claim option and the four rules beside `evidence/graph`.
|
|
111
115
|
|
|
@@ -113,12 +117,12 @@ One claim: the components under `src` implement the docs, so every H2 and H3 und
|
|
|
113
117
|
|
|
114
118
|
The configuration is written once. The tags are written forever: `@evidence` cites, `@evidenceReview` verifies, and `@evidenceExclude` declines. [The tag reference](https://ttsc.dev/docs/evidence/tags) has the full grammar.
|
|
115
119
|
|
|
116
|
-
A false tag removes the error, not the problem. `@evidenceReview`
|
|
120
|
+
A false tag removes the error, not the problem. `@evidenceReview` asks for a separate statement of what was verified, which a human then judges:
|
|
117
121
|
|
|
118
122
|
```ts
|
|
119
123
|
/**
|
|
120
124
|
* @evidence docs/discount.md#coupon-stacking States the per-issuer limit.
|
|
121
|
-
* @evidenceReview docs/discount.md#coupon-stacking #
|
|
125
|
+
* @evidenceReview docs/discount.md#coupon-stacking #a1b2c3d
|
|
122
126
|
* Verified against policy section 3.
|
|
123
127
|
*/
|
|
124
128
|
```
|
|
@@ -126,6 +130,10 @@ A false tag removes the error, not the problem. `@evidenceReview` resolves it:
|
|
|
126
130
|
- Reviews match the same declaration and target.
|
|
127
131
|
- The fingerprint expires when the cited content changes.
|
|
128
132
|
|
|
133
|
+
Normalized Swagger operation fingerprints decode local component URI fragments before interpreting JSON Pointer tokens, including array indices. Upgrading can expire reviews of operations using encoded pointers, array elements, or malformed references that previously selected a literal property. Re-read those operations and their referenced contracts before replacing a fingerprint.
|
|
134
|
+
|
|
135
|
+
Raw OpenAPI 3.1 and 3.2 schema references retain their original targets through version normalization, including pointers into schema structures the converter relocates. Foreign, missing and malformed schema references do not bind to a local schema with the same final name. Reference-shaped example, default, constant, enum and extension values remain data. Reviews affected by these earlier interpretations also need to be read again before their fingerprints are replaced.
|
|
136
|
+
|
|
129
137
|
The compiler handles omissions. Humans handle falsehoods.
|
|
130
138
|
|
|
131
139
|
## Spec-Driven Development
|
|
@@ -174,7 +182,7 @@ Make them a checklist:
|
|
|
174
182
|
}
|
|
175
183
|
```
|
|
176
184
|
|
|
177
|
-
`checklist` changes the denominator from principles to functions times principles. Every selected function must answer every principle, as `CouponStackingNotice` does above.
|
|
185
|
+
`checklist` changes the denominator from principles to functions times principles. Every selected function must answer every principle, with `@evidence` as `CouponStackingNotice` does for `no-hard-coding` or with `@evidenceExclude` as it does for `fix-root-causes-not-symptoms`; the two principles it leaves unanswered above would fail the build.
|
|
178
186
|
|
|
179
187
|
One missing answer fails the build. Adding a principle creates a new obligation on every selected function, and `requireReview` expires every affected review when that principle changes.
|
|
180
188
|
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export * from "./structures/index";
|
|
2
|
-
export * from "./typings/index";
|
|
3
2
|
/**
|
|
4
3
|
* The `@ttsc/lint` contributor that checks a project's evidence graph.
|
|
5
4
|
*
|
|
@@ -18,8 +17,8 @@ export * from "./typings/index";
|
|
|
18
17
|
* - `"evidence/singular"` — one public identity per TypeScript file, named after
|
|
19
18
|
* the file. Takes no options, so it carries a bare severity.
|
|
20
19
|
* - `"evidence/documented"` — a JSDoc block on every selected export, which is
|
|
21
|
-
* the only place
|
|
22
|
-
* {@link ITtscEvidenceDocumentedConfig}.
|
|
20
|
+
* the only place a TypeScript declaration's `@evidence` tag is ever read
|
|
21
|
+
* from. Takes an {@link ITtscEvidenceDocumentedConfig}.
|
|
23
22
|
* - `"evidence/todo"` — no remaining JSDoc `@todo` tag anywhere in a checked
|
|
24
23
|
* file, exported or not. Each tag is an unrealized contract reported with its
|
|
25
24
|
* own text, so the diagnostics read as the ledger of what remains to realize.
|
package/lib/index.js
CHANGED
|
@@ -21,7 +21,6 @@ exports.evidence = void 0;
|
|
|
21
21
|
const node_path_1 = __importDefault(require("node:path"));
|
|
22
22
|
const package_json_1 = require("../package.json");
|
|
23
23
|
__exportStar(require("./structures/index"), exports);
|
|
24
|
-
__exportStar(require("./typings/index"), exports);
|
|
25
24
|
/**
|
|
26
25
|
* The `@ttsc/lint` contributor that checks a project's evidence graph.
|
|
27
26
|
*
|
|
@@ -40,8 +39,8 @@ __exportStar(require("./typings/index"), exports);
|
|
|
40
39
|
* - `"evidence/singular"` — one public identity per TypeScript file, named after
|
|
41
40
|
* the file. Takes no options, so it carries a bare severity.
|
|
42
41
|
* - `"evidence/documented"` — a JSDoc block on every selected export, which is
|
|
43
|
-
* the only place
|
|
44
|
-
* {@link ITtscEvidenceDocumentedConfig}.
|
|
42
|
+
* the only place a TypeScript declaration's `@evidence` tag is ever read
|
|
43
|
+
* from. Takes an {@link ITtscEvidenceDocumentedConfig}.
|
|
45
44
|
* - `"evidence/todo"` — no remaining JSDoc `@todo` tag anywhere in a checked
|
|
46
45
|
* file, exported or not. Each tag is an unrealized contract reported with its
|
|
47
46
|
* own text, so the diagnostics read as the ledger of what remains to realize.
|
|
@@ -40,12 +40,18 @@ exports.canonicalJson = canonicalJson;
|
|
|
40
40
|
* Renders one value, refusing to descend into a container already on the path
|
|
41
41
|
* above it.
|
|
42
42
|
*
|
|
43
|
-
* A
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
43
|
+
* A YAML anchor can create an object cycle that parsing and conversion retain.
|
|
44
|
+
* This renderer terminates such values with the string marker "[circular]". The
|
|
45
|
+
* marker aliases an authored literal string, and identifies the position where
|
|
46
|
+
* traversal stopped rather than which ancestor was reached. It is not an
|
|
47
|
+
* injective encoding of cyclic object graphs.
|
|
48
|
+
*
|
|
49
|
+
* Swagger currently resolves references before invoking this renderer. A
|
|
50
|
+
* reachable schema object cycle still fails before a declaration digest is
|
|
51
|
+
* emitted, while reference-shaped literal data is copied without dereferencing
|
|
52
|
+
* and an object cycle there is explicitly refused. This marker does not make
|
|
53
|
+
* the loader accept either kind of cycle. Finite recursive $ref strings use its
|
|
54
|
+
* separate reference guard.
|
|
49
55
|
*/
|
|
50
56
|
const render = (value, seen) => {
|
|
51
57
|
if (value === null || typeof value !== "object")
|
|
@@ -35,6 +35,10 @@ const SEPARATOR = "\u0000";
|
|
|
35
35
|
* relation identities with their doc comments and a digest of each
|
|
36
36
|
* declaration's content.
|
|
37
37
|
*
|
|
38
|
+
* Each local file is read from one handle with a 16MiB acquisition limit and
|
|
39
|
+
* one sentinel byte. The limit is per file, not per set or total process
|
|
40
|
+
* memory; the native cache digest and locator retain their own file reads.
|
|
41
|
+
*
|
|
38
42
|
* One **set** is one request. A Prisma schema folder is several files that form
|
|
39
43
|
* a single namespace — a model in one file may point at a model in another — so
|
|
40
44
|
* a file cannot be parsed alone and the reuse key belongs to the ordered set.
|
|
@@ -118,13 +122,7 @@ const readSet = async (root, sources) => {
|
|
|
118
122
|
// population resolves against the root its configuration declares rather
|
|
119
123
|
// than against the project. The caller sends the paths its own walk
|
|
120
124
|
// produced, so this side only resolves them the same way.
|
|
121
|
-
const
|
|
122
|
-
const stat = await promises_1.default.stat(location);
|
|
123
|
-
if (!stat.isFile())
|
|
124
|
-
throw new Error(`the Prisma schema source '${source}' is not a file`);
|
|
125
|
-
if (stat.size > MAX_SCHEMA_BYTES)
|
|
126
|
-
throw new Error(`the Prisma schema '${source}' exceeds the ${MAX_SCHEMA_BYTES} byte limit`);
|
|
127
|
-
const content = await promises_1.default.readFile(location);
|
|
125
|
+
const content = await readSchema(root, source);
|
|
128
126
|
files.push([source, decodeUtf8(content)]);
|
|
129
127
|
composite.update(source, "utf8");
|
|
130
128
|
composite.update(SEPARATOR);
|
|
@@ -133,6 +131,53 @@ const readSet = async (root, sources) => {
|
|
|
133
131
|
}
|
|
134
132
|
return { files, digest: composite.digest("hex") };
|
|
135
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Reads one schema through one handle, including a sentinel beyond the limit.
|
|
136
|
+
*
|
|
137
|
+
* Stat is admission, not a bound on later bytes. Each short read fills its
|
|
138
|
+
* current chunk, so retained backing allocations grow with accepted bytes
|
|
139
|
+
* rather than with the number of reads. Each acquired-handle path awaits a
|
|
140
|
+
* close attempt. A close rejection becomes a set problem and may replace a
|
|
141
|
+
* prior read error; a rejected close does not certify resource release. This is
|
|
142
|
+
* a per-file acquisition bound; decoding, parser input and native
|
|
143
|
+
* digest/location reads have separate costs, not a whole-process memory quota.
|
|
144
|
+
*/
|
|
145
|
+
const readSchema = async (root, source) => {
|
|
146
|
+
const handle = await promises_1.default.open(node_path_1.default.resolve(root, source), "r");
|
|
147
|
+
try {
|
|
148
|
+
const stat = await handle.stat();
|
|
149
|
+
if (!stat.isFile())
|
|
150
|
+
throw new Error(`the Prisma schema source '${source}' is not a file`);
|
|
151
|
+
const sizeError = () => new Error(`the Prisma schema '${source}' exceeds the ${MAX_SCHEMA_BYTES} byte limit`);
|
|
152
|
+
if (stat.size > MAX_SCHEMA_BYTES)
|
|
153
|
+
throw sizeError();
|
|
154
|
+
const chunks = [];
|
|
155
|
+
let length = 0;
|
|
156
|
+
let chunk = Buffer.allocUnsafe(64 * 1024);
|
|
157
|
+
let used = 0;
|
|
158
|
+
while (true) {
|
|
159
|
+
const { bytesRead } = await handle.read(chunk, used, Math.min(chunk.length - used, MAX_SCHEMA_BYTES - length + 1), null);
|
|
160
|
+
if (bytesRead === 0) {
|
|
161
|
+
if (used > 0)
|
|
162
|
+
chunks.push(chunk.subarray(0, used));
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
length += bytesRead;
|
|
166
|
+
if (length > MAX_SCHEMA_BYTES)
|
|
167
|
+
throw sizeError();
|
|
168
|
+
used += bytesRead;
|
|
169
|
+
if (used === chunk.length) {
|
|
170
|
+
chunks.push(chunk);
|
|
171
|
+
chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_SCHEMA_BYTES - length + 1));
|
|
172
|
+
used = 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return Buffer.concat(chunks, length);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await handle.close();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
136
181
|
const decodeUtf8 = (content) => new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
137
182
|
/**
|
|
138
183
|
* Runs the parser over one whole set and classifies what it declares.
|
|
@@ -30,7 +30,29 @@ const METHODS = [
|
|
|
30
30
|
* function is the narrow process boundary between them: it accepts only source
|
|
31
31
|
* locations and returns operation identities, each carrying a digest of the
|
|
32
32
|
* operation's content taken here because this is the only side that sees the
|
|
33
|
-
* document.
|
|
33
|
+
* document, including inherited servers and authentication. Equivalent explicit
|
|
34
|
+
* overrides and security-set ordering keep that contract's digest stable.
|
|
35
|
+
*
|
|
36
|
+
* URL protocols are interpreted by the URL parser, preserving the configured
|
|
37
|
+
* source spelling in every returned inventory or problem. Local paths resolve
|
|
38
|
+
* against the supplied root and carry their raw-byte digest; remote sources
|
|
39
|
+
* carry no content-cache digest and retain normal fetch and TLS failures.
|
|
40
|
+
*
|
|
41
|
+
* Native configuration owns locator validation. The URL parser canonicalizes
|
|
42
|
+
* protocol case without changing document paths or replacing fetch and its TLS
|
|
43
|
+
* validation. Local paths retain path.resolve and raw-byte SHA256 identity;
|
|
44
|
+
* normalization and operation digests remain independent of source spelling.
|
|
45
|
+
*
|
|
46
|
+
* One source reader separates URL transport from local byte reading. The outer
|
|
47
|
+
* per-source boundary preserves original identity and collects every read or
|
|
48
|
+
* normalization problem. Remote content has no local cache digest. The matching
|
|
49
|
+
* configuration guide states scheme case and unsupported file URLs. Local reads
|
|
50
|
+
* use one handle, acquire at most 16MiB of input plus a sentinel byte, and fill
|
|
51
|
+
* bounded chunks across short reads. Every acquired-handle path awaits a close
|
|
52
|
+
* attempt. A rejected close becomes a source problem with no read digest and
|
|
53
|
+
* may replace an earlier read error; it does not certify successful resource
|
|
54
|
+
* release. UTF-8 decoding and parsing have their own costs; this byte limit is
|
|
55
|
+
* not a total process-memory quota.
|
|
34
56
|
*
|
|
35
57
|
* @internal
|
|
36
58
|
*/
|
|
@@ -41,17 +63,19 @@ const loadSwaggerOperations = async (request) => {
|
|
|
41
63
|
const read = await readSource(request.root, source);
|
|
42
64
|
digest = read.digest;
|
|
43
65
|
const input = (0, yaml_1.parse)(read.text);
|
|
44
|
-
const
|
|
66
|
+
const normalized = (0, normalizeSwaggerDocument_1.normalizeSwaggerDocument)(input, componentAt);
|
|
45
67
|
return {
|
|
46
68
|
source,
|
|
47
|
-
operations: operationsOf(
|
|
69
|
+
operations: operationsOf(normalized),
|
|
48
70
|
digest,
|
|
49
71
|
};
|
|
50
72
|
}
|
|
51
73
|
catch (error) {
|
|
52
74
|
return {
|
|
53
75
|
source,
|
|
54
|
-
message:
|
|
76
|
+
message: source.includes("://")
|
|
77
|
+
? redactSwaggerUrls(errorMessage(error), source)
|
|
78
|
+
: errorMessage(error),
|
|
55
79
|
digest,
|
|
56
80
|
};
|
|
57
81
|
}
|
|
@@ -63,30 +87,62 @@ const loadSwaggerOperations = async (request) => {
|
|
|
63
87
|
};
|
|
64
88
|
exports.loadSwaggerOperations = loadSwaggerOperations;
|
|
65
89
|
const readSource = async (root, source) => {
|
|
66
|
-
if (source.
|
|
90
|
+
if (source.includes("://")) {
|
|
91
|
+
const location = new URL(source);
|
|
92
|
+
if (location.protocol !== "http:" && location.protocol !== "https:")
|
|
93
|
+
throw new Error("only http: and https: URLs are supported");
|
|
67
94
|
return { text: await readRemoteSource(source), digest: "" };
|
|
68
|
-
|
|
69
|
-
throw new Error("only http: and https: URLs are supported");
|
|
95
|
+
}
|
|
70
96
|
// A local document may sit anywhere on the filesystem, including above the
|
|
71
97
|
// project or on an absolute path. The native decoder is what validates the
|
|
72
98
|
// spelling; this side only has to resolve it the same way, which
|
|
73
99
|
// `path.resolve` already does for both forms.
|
|
74
100
|
const location = node_path_1.default.resolve(root, source);
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
const handle = await promises_1.default.open(location, "r");
|
|
102
|
+
try {
|
|
103
|
+
const stat = await handle.stat();
|
|
104
|
+
if (!stat.isFile())
|
|
105
|
+
throw new Error("the local Swagger source is not a file");
|
|
106
|
+
if (stat.size > MAX_DOCUMENT_BYTES)
|
|
107
|
+
throw documentSizeError();
|
|
108
|
+
// The file can grow after stat. Read at most the limit plus one sentinel
|
|
109
|
+
// byte from this same handle, rather than allocating its new whole size.
|
|
110
|
+
const chunks = [];
|
|
111
|
+
let length = 0;
|
|
112
|
+
let chunk = Buffer.allocUnsafe(64 * 1024);
|
|
113
|
+
let used = 0;
|
|
114
|
+
while (true) {
|
|
115
|
+
const { bytesRead } = await handle.read(chunk, used, Math.min(chunk.length - used, MAX_DOCUMENT_BYTES - length + 1), null);
|
|
116
|
+
if (bytesRead === 0) {
|
|
117
|
+
if (used > 0)
|
|
118
|
+
chunks.push(chunk.subarray(0, used));
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
length += bytesRead;
|
|
122
|
+
if (length > MAX_DOCUMENT_BYTES)
|
|
123
|
+
throw documentSizeError();
|
|
124
|
+
used += bytesRead;
|
|
125
|
+
// Short reads fill the same buffer. Retaining a new 64KiB backing
|
|
126
|
+
// allocation for every one-byte read would defeat the memory bound.
|
|
127
|
+
if (used === chunk.length) {
|
|
128
|
+
chunks.push(chunk);
|
|
129
|
+
chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_DOCUMENT_BYTES - length + 1));
|
|
130
|
+
used = 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const content = Buffer.concat(chunks, length);
|
|
134
|
+
// Cache identity hashes exactly the accepted raw bytes. Invalid UTF-8 is
|
|
135
|
+
// an unreadable source and returns no read result or content-cache digest.
|
|
136
|
+
return {
|
|
137
|
+
text: decodeUtf8(content),
|
|
138
|
+
digest: (0, node_crypto_1.createHash)("sha256").update(content).digest("hex"),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await handle.close();
|
|
143
|
+
}
|
|
89
144
|
};
|
|
145
|
+
const documentSizeError = () => new Error(`the Swagger document exceeds the ${MAX_DOCUMENT_BYTES} byte limit`);
|
|
90
146
|
const readRemoteSource = async (source) => {
|
|
91
147
|
const response = await fetch(source, {
|
|
92
148
|
signal: AbortSignal.timeout(REMOTE_TIMEOUT_MILLISECONDS),
|
|
@@ -118,23 +174,25 @@ const readRemoteSource = async (source) => {
|
|
|
118
174
|
return decodeUtf8(content);
|
|
119
175
|
};
|
|
120
176
|
const decodeUtf8 = (content) => new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
121
|
-
const operationsOf = (
|
|
177
|
+
const operationsOf = (normalized) => {
|
|
178
|
+
const document = normalized.document;
|
|
122
179
|
const operations = [];
|
|
123
|
-
const components = (document.components ??
|
|
124
|
-
{});
|
|
125
180
|
for (const [operationPath, item] of Object.entries(document.paths ?? {})) {
|
|
126
181
|
for (const method of METHODS) {
|
|
127
182
|
const operation = item[method];
|
|
128
183
|
if (operation !== undefined)
|
|
129
|
-
operations.push(operationOf(method, operationPath, operation,
|
|
184
|
+
operations.push(operationOf(method, operationPath, operation, item, normalized));
|
|
130
185
|
}
|
|
131
186
|
for (const [method, operation] of Object.entries(item.additionalOperations ?? {}))
|
|
132
|
-
operations.push(operationOf(method, operationPath, operation,
|
|
187
|
+
operations.push(operationOf(method, operationPath, operation, item, normalized));
|
|
133
188
|
}
|
|
134
189
|
operations.sort((left, right) => {
|
|
135
190
|
const leftTarget = `${left.method}:${left.path}`;
|
|
136
191
|
const rightTarget = `${right.method}:${right.path}`;
|
|
137
|
-
|
|
192
|
+
// Code-unit order, never locale order: collation treats canonically
|
|
193
|
+
// equivalent spellings as equal, which can leave two identical targets
|
|
194
|
+
// apart and hide the duplicate the next loop looks for.
|
|
195
|
+
return leftTarget < rightTarget ? -1 : leftTarget > rightTarget ? 1 : 0;
|
|
138
196
|
});
|
|
139
197
|
for (let index = 1; index < operations.length; index++) {
|
|
140
198
|
const previous = operations[index - 1];
|
|
@@ -145,17 +203,92 @@ const operationsOf = (document) => {
|
|
|
145
203
|
}
|
|
146
204
|
return operations;
|
|
147
205
|
};
|
|
148
|
-
const operationOf = (method, operationPath, operation,
|
|
206
|
+
const operationOf = (method, operationPath, operation, item, normalized) => {
|
|
149
207
|
if (!operationPath.startsWith("/"))
|
|
150
208
|
throw new Error(`OpenAPI path '${operationPath}' must start with '/' to form an operation target`);
|
|
151
209
|
if (/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(method) === false ||
|
|
152
210
|
method.includes(":"))
|
|
153
211
|
throw new Error(`OpenAPI method '${method}' cannot form a '<METHOD>:<path>' target`);
|
|
212
|
+
const document = normalized.document;
|
|
213
|
+
const servers = operation.servers ?? item.servers ?? document.servers;
|
|
214
|
+
const security = canonicalSecurity(operation.security ?? document.security ?? []);
|
|
215
|
+
const schemes = document.components?.securitySchemes ?? {};
|
|
216
|
+
const usedSchemes = Object.fromEntries([
|
|
217
|
+
...new Set(security.flatMap((requirement) => Object.keys(requirement))),
|
|
218
|
+
].map((name) => [name, schemes[name]]));
|
|
219
|
+
// Resolve on original holders before copying: literal-position provenance is
|
|
220
|
+
// keyed by object identity. Inheritance is represented outside the written
|
|
221
|
+
// operation so explicit equivalent overrides cannot duplicate its contract.
|
|
222
|
+
const resolved = withResolvedReferences(operation, normalized);
|
|
223
|
+
const { servers: _servers, security: _security, ...content } = resolved;
|
|
154
224
|
return {
|
|
155
225
|
method: method.toUpperCase(),
|
|
156
226
|
path: operationPath,
|
|
157
|
-
digest: (0, canonicalDigest_1.canonicalDigest)(
|
|
227
|
+
digest: (0, canonicalDigest_1.canonicalDigest)({
|
|
228
|
+
operation: content,
|
|
229
|
+
servers: literalValue(servers?.length ? servers : [{ url: "/" }]),
|
|
230
|
+
security,
|
|
231
|
+
securitySchemes: withResolvedReferences(usedSchemes, normalized),
|
|
232
|
+
}),
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Canonicalizes only security's unordered OR alternatives and required scopes.
|
|
237
|
+
*
|
|
238
|
+
* Scheme keys within one alternative remain an AND. Empty alternatives remain
|
|
239
|
+
* distinct from removing authentication with an empty list; all other arrays,
|
|
240
|
+
* including server preference and literal payloads, retain their written
|
|
241
|
+
* order.
|
|
242
|
+
*/
|
|
243
|
+
const canonicalSecurity = (security) => {
|
|
244
|
+
const alternatives = new Map();
|
|
245
|
+
for (const requirement of security) {
|
|
246
|
+
const canonical = Object.fromEntries(Object.entries(requirement).map(([name, scopes]) => [
|
|
247
|
+
name,
|
|
248
|
+
[...new Set(scopes)].sort(),
|
|
249
|
+
]));
|
|
250
|
+
alternatives.set((0, canonicalDigest_1.canonicalDigest)(canonical), canonical);
|
|
251
|
+
}
|
|
252
|
+
return [...alternatives.entries()]
|
|
253
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
254
|
+
.map(([, requirement]) => requirement);
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Redacts remote URL presentation even when a URL is not parseable.
|
|
258
|
+
*
|
|
259
|
+
* The complete configured spelling is replaced before scanning reflected URLs.
|
|
260
|
+
* Lexical authority/query/fragment boundaries also protect malformed host and
|
|
261
|
+
* percent escapes that the URL parser would reject. This changes only error
|
|
262
|
+
* text; transport requests and returned source/cache identities stay
|
|
263
|
+
* untouched.
|
|
264
|
+
*/
|
|
265
|
+
const redactSwaggerUrls = (message, source) => {
|
|
266
|
+
const display = (value) => {
|
|
267
|
+
const suffix = value.search(/[?#]/u);
|
|
268
|
+
const base = suffix < 0 ? value : value.slice(0, suffix);
|
|
269
|
+
const scheme = base.indexOf("://");
|
|
270
|
+
if (scheme < 0)
|
|
271
|
+
return "<redacted URL>";
|
|
272
|
+
const start = scheme + 3;
|
|
273
|
+
const slash = base.indexOf("/", start);
|
|
274
|
+
const end = slash < 0 ? base.length : slash;
|
|
275
|
+
const user = base.lastIndexOf("@", end);
|
|
276
|
+
let authority = base.slice(start);
|
|
277
|
+
if (user >= start)
|
|
278
|
+
authority = "***@" + base.slice(user + 1);
|
|
279
|
+
else
|
|
280
|
+
try {
|
|
281
|
+
new URL(base.slice(0, end));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// A malformed authority can be truncated userinfo. It has no safe host.
|
|
285
|
+
authority = "[redacted]" + base.slice(end);
|
|
286
|
+
}
|
|
287
|
+
return base.slice(0, start) + authority + (suffix < 0 ? "" : "?[redacted]");
|
|
158
288
|
};
|
|
289
|
+
return message
|
|
290
|
+
.replaceAll(source, display(source))
|
|
291
|
+
.replace(/https?:\/\/[^\s"'<>]+/giu, display);
|
|
159
292
|
};
|
|
160
293
|
/**
|
|
161
294
|
* Replaces every local `$ref` into `components` with what it names.
|
|
@@ -176,37 +309,47 @@ const operationOf = (method, operationPath, operation, components) => {
|
|
|
176
309
|
*
|
|
177
310
|
* Siblings of a `$ref` are kept and override what it resolves to, which is what
|
|
178
311
|
* OpenAPI 3.1 says they do. A reference already open on the path above is left
|
|
179
|
-
* as written
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
312
|
+
* as written. The guard uses the decoded pointer, so percent-encoded and plain
|
|
313
|
+
* spellings of the same recursive component stop at the same boundary. Two
|
|
314
|
+
* operations reaching one cycle by different routes can still differ. An
|
|
315
|
+
* undeclared or malformed reference is left as written too: a broken document
|
|
316
|
+
* is not a digest question, and inventing an empty schema for it would make two
|
|
317
|
+
* different broken documents agree. Normalization retains the original pointer
|
|
318
|
+
* identity behind private schema aliases, so a version converter cannot change
|
|
319
|
+
* the recursion boundary or bind an unresolved reference to an unrelated
|
|
320
|
+
* component. Literal example, default, const, enum and extension values are
|
|
321
|
+
* hashed as data, not dereferenced.
|
|
183
322
|
*/
|
|
184
|
-
const withResolvedReferences = (value,
|
|
323
|
+
const withResolvedReferences = (value, normalized, open = new Set()) => {
|
|
185
324
|
if (value === null || typeof value !== "object")
|
|
186
325
|
return value;
|
|
187
326
|
if (Array.isArray(value))
|
|
188
|
-
return value.map((element) => withResolvedReferences(element,
|
|
327
|
+
return value.map((element) => withResolvedReferences(element, normalized, open));
|
|
189
328
|
const entries = Object.entries(value);
|
|
190
329
|
const reference = value["$ref"];
|
|
191
|
-
|
|
330
|
+
const preserved = typeof reference === "string"
|
|
331
|
+
? normalized.referenceAt(reference)
|
|
332
|
+
: undefined;
|
|
333
|
+
const target = preserved?.target;
|
|
334
|
+
if (target === undefined || open.has(target.pointer))
|
|
192
335
|
return Object.fromEntries(entries.map(([key, element]) => [
|
|
193
336
|
key,
|
|
194
|
-
|
|
337
|
+
key === "$ref" && preserved !== undefined
|
|
338
|
+
? preserved.reference
|
|
339
|
+
: normalized.isLiteral(value, key) || key.startsWith("x-")
|
|
340
|
+
? literalValue(element)
|
|
341
|
+
: withResolvedReferences(element, normalized, open),
|
|
195
342
|
]));
|
|
196
|
-
|
|
197
|
-
if (target === undefined)
|
|
198
|
-
return Object.fromEntries(entries.map(([key, element]) => [
|
|
199
|
-
key,
|
|
200
|
-
withResolvedReferences(element, components, open),
|
|
201
|
-
]));
|
|
202
|
-
open.add(reference);
|
|
343
|
+
open.add(target.pointer);
|
|
203
344
|
try {
|
|
204
|
-
const resolved = withResolvedReferences(target,
|
|
345
|
+
const resolved = withResolvedReferences(target.value, normalized, open);
|
|
205
346
|
const siblings = entries
|
|
206
347
|
.filter(([key]) => key !== "$ref")
|
|
207
348
|
.map(([key, element]) => [
|
|
208
349
|
key,
|
|
209
|
-
|
|
350
|
+
normalized.isLiteral(value, key) || key.startsWith("x-")
|
|
351
|
+
? literalValue(element)
|
|
352
|
+
: withResolvedReferences(element, normalized, open),
|
|
210
353
|
]);
|
|
211
354
|
if (siblings.length === 0)
|
|
212
355
|
return resolved;
|
|
@@ -218,29 +361,80 @@ const withResolvedReferences = (value, components, open = new Set()) => {
|
|
|
218
361
|
};
|
|
219
362
|
}
|
|
220
363
|
finally {
|
|
221
|
-
open.delete(
|
|
364
|
+
open.delete(target.pointer);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Copies data without interpreting `$ref`, refusing non-JSON object cycles.
|
|
369
|
+
*
|
|
370
|
+
* Each occurrence contributes its JSON value, even when YAML anchors share an
|
|
371
|
+
* acyclic child. The active path is released in finally; the allocated value
|
|
372
|
+
* lasts through hashing. This is not a total memory or depth bound.
|
|
373
|
+
*/
|
|
374
|
+
const literalValue = (value, open = new Set()) => {
|
|
375
|
+
if (value === null || typeof value !== "object")
|
|
376
|
+
return value;
|
|
377
|
+
if (open.has(value))
|
|
378
|
+
throw new Error("a Swagger literal value contains an object cycle");
|
|
379
|
+
open.add(value);
|
|
380
|
+
try {
|
|
381
|
+
return Array.isArray(value)
|
|
382
|
+
? value.map((element) => literalValue(element, open))
|
|
383
|
+
: Object.fromEntries(Object.entries(value).map(([key, element]) => [
|
|
384
|
+
key,
|
|
385
|
+
literalValue(element, open),
|
|
386
|
+
]));
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
open.delete(value);
|
|
222
390
|
}
|
|
223
391
|
};
|
|
224
|
-
const COMPONENT_REFERENCE_PREFIX = "
|
|
225
|
-
/**
|
|
392
|
+
const COMPONENT_REFERENCE_PREFIX = "/components/";
|
|
393
|
+
/**
|
|
394
|
+
* Reads one local component URI fragment, or nothing.
|
|
395
|
+
*
|
|
396
|
+
* RFC 6901 URI decoding precedes tokenization: %2F separates pointer tokens,
|
|
397
|
+
* while ~1 names a slash inside one token. Malformed URI or tilde escapes name
|
|
398
|
+
* nothing; the caller preserves their original reference rather than selecting
|
|
399
|
+
* a literal property with invalid pointer syntax. Tokens are decoded once and
|
|
400
|
+
* remain case-sensitive, without Unicode normalization.
|
|
401
|
+
*
|
|
402
|
+
* Own object members and canonical unsigned array indices can be selected.
|
|
403
|
+
* Inherited properties, leading-zero indices, array append and absent elements
|
|
404
|
+
* cannot. The decoded pointer also identifies the recursion guard independently
|
|
405
|
+
* of percent-escape spelling. Walking costs the fragment length plus its token
|
|
406
|
+
* count and retains only this lookup's tokens and result.
|
|
407
|
+
*/
|
|
226
408
|
const componentAt = (components, reference) => {
|
|
227
|
-
if (!reference.startsWith(
|
|
409
|
+
if (!reference.startsWith("#"))
|
|
410
|
+
return undefined;
|
|
411
|
+
let pointer;
|
|
412
|
+
try {
|
|
413
|
+
pointer = decodeURIComponent(reference.slice(1));
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
if (!pointer.startsWith(COMPONENT_REFERENCE_PREFIX))
|
|
228
419
|
return undefined;
|
|
229
|
-
const
|
|
420
|
+
const tokens = pointer
|
|
230
421
|
.slice(COMPONENT_REFERENCE_PREFIX.length)
|
|
231
|
-
.split("/")
|
|
232
|
-
.map((segment) => decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
422
|
+
.split("/");
|
|
233
423
|
let current = components;
|
|
234
|
-
for (const
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
424
|
+
for (const token of tokens) {
|
|
425
|
+
if (/~(?:[^01]|$)/u.test(token))
|
|
426
|
+
return undefined;
|
|
427
|
+
const segment = token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
428
|
+
if (current === null || typeof current !== "object")
|
|
429
|
+
return undefined;
|
|
430
|
+
if (Array.isArray(current) &&
|
|
431
|
+
/^(?:0|[1-9][0-9]*)$/u.test(segment) === false)
|
|
238
432
|
return undefined;
|
|
239
|
-
if (!(segment
|
|
433
|
+
if (!Object.hasOwn(current, segment))
|
|
240
434
|
return undefined;
|
|
241
435
|
current = current[segment];
|
|
242
436
|
}
|
|
243
|
-
return current;
|
|
437
|
+
return current === undefined ? undefined : { pointer, value: current };
|
|
244
438
|
};
|
|
245
439
|
const isInventory = (value) => "operations" in value;
|
|
246
440
|
const isProblem = (value) => "message" in value;
|