@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.
Files changed (66) hide show
  1. package/README.md +14 -6
  2. package/lib/index.d.ts +2 -3
  3. package/lib/index.js +2 -3
  4. package/lib/internal/canonicalDigest.js +12 -6
  5. package/lib/internal/loadPrismaModels.js +52 -7
  6. package/lib/internal/loadSwaggerOperations.js +253 -59
  7. package/lib/internal/normalizeSwaggerDocument.js +320 -2
  8. package/lib/structures/ITtscEvidenceDocumentedConfig.d.ts +16 -8
  9. package/lib/structures/ITtscEvidenceGraphClaim.d.ts +6 -0
  10. package/lib/structures/ITtscEvidenceGraphClaimBase.d.ts +8 -2
  11. package/lib/structures/ITtscEvidenceGraphConfig.d.ts +6 -0
  12. package/lib/structures/ITtscEvidenceGraphMarkdownClaim.d.ts +7 -1
  13. package/lib/structures/ITtscEvidenceGraphMarkdownReference.d.ts +7 -1
  14. package/lib/structures/ITtscEvidenceGraphPrismaClaim.d.ts +7 -1
  15. package/lib/structures/ITtscEvidenceGraphPrismaReference.d.ts +7 -1
  16. package/lib/structures/ITtscEvidenceGraphReference.d.ts +10 -4
  17. package/lib/structures/ITtscEvidenceGraphReferenceBase.d.ts +6 -0
  18. package/lib/structures/ITtscEvidenceGraphSwaggerReference.d.ts +6 -0
  19. package/lib/structures/ITtscEvidenceGraphTypeScriptClaim.d.ts +7 -1
  20. package/lib/structures/ITtscEvidenceGraphTypeScriptReference.d.ts +11 -3
  21. package/lib/structures/ITtscEvidenceRules.d.ts +93 -0
  22. package/lib/structures/TtscEvidenceGraphMarkdownSymbol.d.ts +39 -0
  23. package/lib/{typings → structures}/TtscEvidenceGraphPrismaSymbol.d.ts +6 -0
  24. package/lib/{typings → structures}/TtscEvidenceGraphTypeScriptSymbol.d.ts +6 -0
  25. package/lib/structures/index.d.ts +4 -0
  26. package/lib/structures/index.js +4 -0
  27. package/native/base.go +25 -23
  28. package/native/bridge_trace.go +348 -0
  29. package/native/comments.go +1 -1
  30. package/native/config.go +6 -3
  31. package/native/declaration.go +17 -43
  32. package/native/documentation_regions.go +214 -0
  33. package/native/documented.go +5 -4
  34. package/native/exports.go +1 -1
  35. package/native/fingerprint.go +2 -8
  36. package/native/glob.go +4 -1
  37. package/native/graph.go +59 -23
  38. package/native/graph_nodes.go +2 -1
  39. package/native/hints.go +22 -18
  40. package/native/imports.go +7 -6
  41. package/native/input_reader.go +83 -0
  42. package/native/inputs.go +13 -12
  43. package/native/loader.go +36 -24
  44. package/native/markdown.go +587 -177
  45. package/native/markdown_capture.go +223 -0
  46. package/native/model.go +2 -1
  47. package/native/prisma.go +110 -25
  48. package/native/prisma_cache.go +12 -8
  49. package/native/prisma_locator.go +70 -33
  50. package/native/review.go +8 -7
  51. package/native/rooted_typescript.go +3 -4
  52. package/native/swagger.go +123 -15
  53. package/native/swagger_cache.go +16 -12
  54. package/native/todo.go +12 -0
  55. package/native/typescript.go +129 -25
  56. package/package.json +9 -7
  57. package/lib/typings/ITtscLintContributorRules.d.ts +0 -57
  58. package/lib/typings/ITtscLintRuleOptionsMap.d.ts +0 -20
  59. package/lib/typings/ITtscLintRuleOptionsMap.js +0 -2
  60. package/lib/typings/TtscEvidenceGraphMarkdownSymbol.d.ts +0 -32
  61. package/lib/typings/index.d.ts +0 -5
  62. package/lib/typings/index.js +0 -21
  63. /package/lib/{typings/ITtscLintContributorRules.js → structures/ITtscEvidenceRules.js} +0 -0
  64. /package/lib/{typings → structures}/TtscEvidenceGraphMarkdownSymbol.js +0 -0
  65. /package/lib/{typings → structures}/TtscEvidenceGraphPrismaSymbol.js +0 -0
  66. /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 { evidence, type ITtscEvidenceGraphConfig } from "@ttsc/evidence";
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` resolves it:
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 #a1b2c3d4e5f6
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 an `@evidence` tag is ever read from. Takes an
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 an `@evidence` tag is ever read from. Takes an
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 source document can be cyclic: a YAML anchor referring to its own parent
44
- * loads, upgrades, and would then recurse until the stack ends. The failure
45
- * that produced was a diagnostic blaming an upgrade that had succeeded, so the
46
- * cycle is answered here with a marker instead. Two documents that differ only
47
- * in where a cycle closes still differ, because the marker is emitted at the
48
- * position the cycle closes at.
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 location = node_path_1.default.resolve(root, source);
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 document = (0, normalizeSwaggerDocument_1.normalizeSwaggerDocument)(input);
66
+ const normalized = (0, normalizeSwaggerDocument_1.normalizeSwaggerDocument)(input, componentAt);
45
67
  return {
46
68
  source,
47
- operations: operationsOf(document),
69
+ operations: operationsOf(normalized),
48
70
  digest,
49
71
  };
50
72
  }
51
73
  catch (error) {
52
74
  return {
53
75
  source,
54
- message: errorMessage(error),
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.startsWith("http://") || source.startsWith("https://"))
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
- if (source.includes("://"))
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 stat = await promises_1.default.stat(location);
76
- if (!stat.isFile())
77
- throw new Error("the local Swagger source is not a file");
78
- if (stat.size > MAX_DOCUMENT_BYTES)
79
- throw new Error(`the Swagger document exceeds the ${MAX_DOCUMENT_BYTES} byte limit`);
80
- // Hashed before decoding, over the bytes as they were read. The native side
81
- // hashes the file's bytes too, so the two agree by construction; hashing the
82
- // decoded string instead would agree only for inputs where the round trip
83
- // happens to be exact.
84
- const content = await promises_1.default.readFile(location);
85
- return {
86
- text: decodeUtf8(content),
87
- digest: (0, node_crypto_1.createHash)("sha256").update(content).digest("hex"),
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 = (document) => {
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, components));
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, components));
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
- return leftTarget.localeCompare(rightTarget);
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, components) => {
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)(withResolvedReferences(operation, components)),
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, so a recursive schema terminates while two operations reaching
180
- * one cycle by different routes still differ. An undeclared reference is left
181
- * as written too: a broken document is not a digest question, and inventing an
182
- * empty schema for it would make two different broken documents agree.
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, components, open = new Set()) => {
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, components, open));
327
+ return value.map((element) => withResolvedReferences(element, normalized, open));
189
328
  const entries = Object.entries(value);
190
329
  const reference = value["$ref"];
191
- if (typeof reference !== "string" || open.has(reference))
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
- withResolvedReferences(element, components, open),
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
- const target = componentAt(components, reference);
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, components, open);
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
- withResolvedReferences(element, components, open),
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(reference);
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 = "#/components/";
225
- /** Reads one `#/components/<section>/<name>` pointer, or nothing. */
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(COMPONENT_REFERENCE_PREFIX))
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 segments = reference
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 segment of segments) {
235
- if (current === null ||
236
- typeof current !== "object" ||
237
- Array.isArray(current))
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 in current))
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;