@effected/schemastore 0.1.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.
@@ -0,0 +1,137 @@
1
+ import { KeywordFamilies } from "./KeywordFamilies.js";
2
+ import { Effect, Result, Schema } from "effect";
3
+
4
+ //#region src/AnnotationCarriers.ts
5
+ /**
6
+ * Indicates that the carrier re-graft walk nested past the package's
7
+ * hardening cap (256 levels), which also intercepts cyclic inputs before
8
+ * they can recurse forever.
9
+ *
10
+ * Raised by {@link AnnotationCarriers.carry}.
11
+ *
12
+ * @public
13
+ */
14
+ var CarrierDepthExceededError = class extends Schema.TaggedErrorClass()("CarrierDepthExceededError", {
15
+ /** JSON pointer (in the lowered document's coordinates) where the cap was hit. */
16
+ path: Schema.String,
17
+ /** The nesting cap that was exceeded. */
18
+ maxDepth: Schema.Number
19
+ }) {
20
+ get message() {
21
+ return `Carrier re-graft nesting exceeds ${this.maxDepth} levels at "${this.path}"`;
22
+ }
23
+ };
24
+ var CarryFailure = class {
25
+ error;
26
+ constructor(error) {
27
+ this.error = error;
28
+ }
29
+ };
30
+ const escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
31
+ const isSchemaObject = (node) => typeof node === "object" && node !== null && !Array.isArray(node);
32
+ const graftMap = (source, target, path, depth) => {
33
+ if (!isSchemaObject(source) || !isSchemaObject(target)) return target;
34
+ const out = { ...target };
35
+ for (const [name, subschema] of Object.entries(source)) if (Object.hasOwn(target, name)) out[name] = graft(subschema, target[name], `${path}/${escapePointerSegment(name)}`, depth + 1);
36
+ return out;
37
+ };
38
+ const graftArray = (source, target, path, depth) => {
39
+ if (!Array.isArray(source) || !Array.isArray(target)) return target;
40
+ return target.map((element, index) => index < source.length ? graft(source[index], element, `${path}/${index}`, depth + 1) : element);
41
+ };
42
+ const graft = (source, target, path, depth) => {
43
+ if (depth >= 256) throw new CarryFailure(CarrierDepthExceededError.make({
44
+ path,
45
+ maxDepth: 256
46
+ }));
47
+ if (!isSchemaObject(source) || !isSchemaObject(target)) return target;
48
+ const out = { ...target };
49
+ for (const [key, value] of Object.entries(source)) {
50
+ if (KeywordFamilies.isDeclared(key)) {
51
+ out[key] = value;
52
+ continue;
53
+ }
54
+ const keyPath = `${path}/${escapePointerSegment(key)}`;
55
+ switch (key) {
56
+ case "properties":
57
+ case "patternProperties":
58
+ if (Object.hasOwn(out, key)) out[key] = graftMap(value, out[key], keyPath, depth);
59
+ break;
60
+ case "additionalProperties":
61
+ case "propertyNames":
62
+ if (Object.hasOwn(out, key)) out[key] = graft(value, out[key], keyPath, depth + 1);
63
+ break;
64
+ case "allOf":
65
+ case "anyOf":
66
+ case "oneOf":
67
+ if (Object.hasOwn(out, key)) out[key] = graftArray(value, out[key], keyPath, depth);
68
+ break;
69
+ case "prefixItems":
70
+ if (Object.hasOwn(out, "items")) out.items = Array.isArray(value) ? graftArray(value, out.items, `${path}/items`, depth) : graft(value, out.items, `${path}/items`, depth + 1);
71
+ break;
72
+ case "items": if (Object.hasOwn(source, "prefixItems")) {
73
+ if (Object.hasOwn(out, "additionalItems")) out.additionalItems = graft(value, out.additionalItems, `${path}/additionalItems`, depth + 1);
74
+ } else if (Object.hasOwn(out, "items")) out.items = graft(value, out.items, keyPath, depth + 1);
75
+ }
76
+ }
77
+ return out;
78
+ };
79
+ /**
80
+ * Re-grafts the declared non-standard keyword families
81
+ * ({@link KeywordFamilies}) from a Draft 2020-12 schema node onto its
82
+ * lowered Draft-07 counterpart.
83
+ *
84
+ * Why this exists: annotation keys admitted into the Draft 2020-12 document
85
+ * (core's `includeAnnotationKey`) are **dropped by core's Draft-07 lowering**,
86
+ * whose keyword walk copies a fixed subset — verified against the installed
87
+ * beta. Carrying `x-taplo`, `x-tombi-*`, `x-intellij-*` or the vscode set
88
+ * into an emitted SchemaStore document therefore requires this post-lowering
89
+ * step; it cannot ride `ToJsonSchemaOptions` alone.
90
+ *
91
+ * The walk mirrors the lowering's own structural rules, so every carrier
92
+ * lands on the node the annotation was attached to — including the one
93
+ * coordinate move the lowering makes (2020-12 `prefixItems[i]` → Draft-07
94
+ * `items[i]`, trailing `items` → `additionalItems`). Only declared-family
95
+ * keys are copied; nothing else about the target changes.
96
+ *
97
+ * `StoreDocument.fromSchema` applies this automatically to the root schema
98
+ * and every `$defs` pool entry — annotate a schema node
99
+ * (`Schema.String.annotate({ "x-taplo": { hidden: true } })`) and the key
100
+ * appears in the built document. Call this directly only when driving core's
101
+ * pipeline yourself.
102
+ *
103
+ * Know the boundary (core behavior, probed at the installed beta): an
104
+ * annotation must sit on the schema **definition** node. Annotating a
105
+ * hoisted (identifier-carrying) schema at its *usage* site — e.g.
106
+ * `Person.annotate({...})` inside a struct field — reaches neither the
107
+ * `$ref` node nor the pool entry, even in the 2020-12 document, so there is
108
+ * nothing to carry.
109
+ *
110
+ * @public
111
+ */
112
+ var AnnotationCarriers = class AnnotationCarriers {
113
+ constructor() {}
114
+ /**
115
+ * Grafts declared-family keys from `source` (a Draft 2020-12 schema
116
+ * node) onto `target` (its lowered Draft-07 counterpart), returning a
117
+ * new node. Pure and synchronous — the primitive form;
118
+ * {@link AnnotationCarriers.carry} is the same walk behind a span.
119
+ */
120
+ static carryResult(source, target) {
121
+ try {
122
+ return Result.succeed(graft(source, target, "", 0));
123
+ } catch (cause) {
124
+ if (cause instanceof CarryFailure) return Result.fail(cause.error);
125
+ throw cause;
126
+ }
127
+ }
128
+ /**
129
+ * Effect form of {@link AnnotationCarriers.carryResult}, adding only the
130
+ * `AnnotationCarriers.carry` span. Defined in terms of the `Result`
131
+ * primitive — synchronous callers can use that variant directly.
132
+ */
133
+ static carry = Effect.fn("AnnotationCarriers.carry")((source, target) => Effect.fromResult(AnnotationCarriers.carryResult(source, target)));
134
+ };
135
+
136
+ //#endregion
137
+ export { AnnotationCarriers, CarrierDepthExceededError };
@@ -0,0 +1,135 @@
1
+ import { Effect, Result, Schema } from "effect";
2
+
3
+ //#region src/CanonicalJson.ts
4
+ /**
5
+ * Indicates that a value reachable from the serialization input is not a
6
+ * JSON value: `undefined`, a function, a symbol, a `bigint`, a non-finite
7
+ * number, or an object that is neither an array nor a plain object.
8
+ *
9
+ * Raised by {@link CanonicalJson.serialize}. Unlike `JSON.stringify` — which
10
+ * silently drops `undefined` members and rewrites `NaN`/`Infinity` to
11
+ * `null` — canonical serialization refuses to alter the document, so every
12
+ * non-JSON value is a typed failure carrying the path to fix.
13
+ *
14
+ * @public
15
+ */
16
+ var NonJsonValueError = class extends Schema.TaggedErrorClass()("NonJsonValueError", {
17
+ /** JSON pointer to the offending value (`""` is the document root). */
18
+ path: Schema.String,
19
+ /** The `typeof`/structural description of the rejected value. */
20
+ found: Schema.String
21
+ }) {
22
+ get message() {
23
+ return `Non-JSON value (${this.found}) at "${this.path}"`;
24
+ }
25
+ };
26
+ /**
27
+ * Indicates that the serialization input nests deeper than the package's
28
+ * hardening cap (256 levels), which also intercepts cyclic values before
29
+ * they can recurse forever.
30
+ *
31
+ * Raised by {@link CanonicalJson.serialize}.
32
+ *
33
+ * @public
34
+ */
35
+ var JsonDepthExceededError = class extends Schema.TaggedErrorClass()("JsonDepthExceededError", {
36
+ /** JSON pointer to the node where the cap was hit. */
37
+ path: Schema.String,
38
+ /** The nesting cap that was exceeded. */
39
+ maxDepth: Schema.Number
40
+ }) {
41
+ get message() {
42
+ return `JSON nesting exceeds ${this.maxDepth} levels at "${this.path}"`;
43
+ }
44
+ };
45
+ var SerializeFailure = class {
46
+ error;
47
+ constructor(error) {
48
+ this.error = error;
49
+ }
50
+ };
51
+ const escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
52
+ /**
53
+ * Deterministic, canonical JSON text: the package's owned serializer, so a
54
+ * consumer never shells out to an external formatter to produce a stable
55
+ * committed schema file.
56
+ *
57
+ * The canonical form is fully specified: object keys in insertion order
58
+ * (document assembly owns meaningful ordering — keys are never sorted),
59
+ * every array element and object member on its own line, the configured
60
+ * indent (tab by default), `"` string escaping exactly as `JSON.stringify`
61
+ * produces it, LF line endings and a single trailing newline. Equal inputs
62
+ * serialize to equal bytes.
63
+ *
64
+ * Values that are not JSON fail typed rather than being silently rewritten
65
+ * (see {@link NonJsonValueError}); nesting past the hardening cap — which
66
+ * includes cyclic values — fails with {@link JsonDepthExceededError}.
67
+ *
68
+ * @public
69
+ */
70
+ var CanonicalJson = class CanonicalJson {
71
+ constructor() {}
72
+ /**
73
+ * Serializes `value` to canonical JSON text. Pure and synchronous — the
74
+ * primitive form; {@link CanonicalJson.serialize} is the same engine
75
+ * behind a span.
76
+ */
77
+ static serializeResult(value, options) {
78
+ const unit = options?.indent === void 0 || options.indent === "tab" ? " " : indentUnit(options.indent);
79
+ try {
80
+ return Result.succeed(`${emit(value, "", 0, unit)}\n`);
81
+ } catch (cause) {
82
+ if (cause instanceof SerializeFailure) return Result.fail(cause.error);
83
+ throw cause;
84
+ }
85
+ }
86
+ /**
87
+ * Effect form of {@link CanonicalJson.serializeResult}, adding only the
88
+ * `CanonicalJson.serialize` span. Defined in terms of the `Result`
89
+ * primitive — synchronous callers can use that variant directly.
90
+ */
91
+ static serialize = Effect.fn("CanonicalJson.serialize")((value, options) => Effect.fromResult(CanonicalJson.serializeResult(value, options)));
92
+ };
93
+ const indentUnit = (indent) => {
94
+ if (!Number.isInteger(indent) || indent < 0) throw new Error(`indent must be "tab" or a non-negative integer space count, got ${indent}`);
95
+ return " ".repeat(indent);
96
+ };
97
+ const emit = (value, path, depth, unit) => {
98
+ if (value === null) return "null";
99
+ switch (typeof value) {
100
+ case "boolean": return value ? "true" : "false";
101
+ case "number":
102
+ if (!Number.isFinite(value)) throw new SerializeFailure(NonJsonValueError.make({
103
+ path,
104
+ found: String(value)
105
+ }));
106
+ return JSON.stringify(value);
107
+ case "string": return JSON.stringify(value);
108
+ case "object": break;
109
+ default: throw new SerializeFailure(NonJsonValueError.make({
110
+ path,
111
+ found: typeof value
112
+ }));
113
+ }
114
+ if (depth >= 256) throw new SerializeFailure(JsonDepthExceededError.make({
115
+ path,
116
+ maxDepth: 256
117
+ }));
118
+ const indent = unit.repeat(depth + 1);
119
+ const closing = unit.repeat(depth);
120
+ if (Array.isArray(value)) {
121
+ if (value.length === 0) return "[]";
122
+ return `[\n${value.map((item, index) => `${indent}${emit(item, `${path}/${index}`, depth + 1, unit)}`).join(",\n")}\n${closing}]`;
123
+ }
124
+ const prototype = Object.getPrototypeOf(value);
125
+ if (prototype !== Object.prototype && prototype !== null) throw new SerializeFailure(NonJsonValueError.make({
126
+ path,
127
+ found: "non-plain object"
128
+ }));
129
+ const entries = Object.entries(value);
130
+ if (entries.length === 0) return "{}";
131
+ return `{\n${entries.map(([key, member]) => `${indent}${JSON.stringify(key)}: ${emit(member, `${path}/${escapePointerSegment(key)}`, depth + 1, unit)}`).join(",\n")}\n${closing}}`;
132
+ };
133
+
134
+ //#endregion
135
+ export { CanonicalJson, JsonDepthExceededError, NonJsonValueError };
@@ -0,0 +1,127 @@
1
+ import { SchemaVersioning } from "./SchemaVersioning.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/CatalogEntry.ts
5
+ /**
6
+ * A fileMatch hygiene finding: a value in a lint report, not an error —
7
+ * SchemaStore reviewers reject entries over these, so surfacing them
8
+ * locally is the point, but a warned entry is still a valid entry.
9
+ *
10
+ * @public
11
+ */
12
+ var CatalogLintFinding = class extends Schema.Class("CatalogLintFinding")({
13
+ /** Which hygiene check fired. */
14
+ check: Schema.Literals(["GenericFileMatch", "ComplexFileMatch"]),
15
+ /** The `fileMatch` pattern the finding is about. */
16
+ pattern: Schema.String,
17
+ /** Human-readable explanation with the SchemaStore rationale. */
18
+ message: Schema.String
19
+ }) {};
20
+ const CONFIG_EXTENSIONS = /* @__PURE__ */ new Set([
21
+ "cfg",
22
+ "conf",
23
+ "env",
24
+ "ini",
25
+ "json",
26
+ "json5",
27
+ "jsonc",
28
+ "properties",
29
+ "toml",
30
+ "xml",
31
+ "yaml",
32
+ "yml"
33
+ ]);
34
+ const GENERIC_BASENAMES = /* @__PURE__ */ new Set([
35
+ "conf",
36
+ "config",
37
+ "configuration",
38
+ "options",
39
+ "settings"
40
+ ]);
41
+ const COMPLEX_GLOB = /[{}[\]]|[?*+@!]\(|^!/;
42
+ const lintPattern = (pattern) => {
43
+ const findings = [];
44
+ const basename = pattern.slice(pattern.lastIndexOf("/") + 1);
45
+ const dot = basename.lastIndexOf(".");
46
+ const stem = dot === -1 ? basename : basename.slice(0, dot);
47
+ const extension = dot === -1 ? "" : basename.slice(dot + 1).toLowerCase();
48
+ if (stem === "*" && CONFIG_EXTENSIONS.has(extension)) findings.push(CatalogLintFinding.make({
49
+ check: "GenericFileMatch",
50
+ pattern,
51
+ message: `"${pattern}" claims every .${extension} file; SchemaStore rejects generic patterns — match the specific file name(s) the schema owns`
52
+ }));
53
+ else if (GENERIC_BASENAMES.has(stem.toLowerCase()) && CONFIG_EXTENSIONS.has(extension)) findings.push(CatalogLintFinding.make({
54
+ check: "GenericFileMatch",
55
+ pattern,
56
+ message: `"${pattern}" matches the generic name "${basename}", which other tools also use; SchemaStore rejects generic patterns — qualify it with a tool-specific name or directory`
57
+ }));
58
+ if (COMPLEX_GLOB.test(pattern)) findings.push(CatalogLintFinding.make({
59
+ check: "ComplexFileMatch",
60
+ pattern,
61
+ message: `"${pattern}" uses complex glob constructs; SchemaStore asks for simple patterns — expand alternations into multiple entries`
62
+ }));
63
+ return findings;
64
+ };
65
+ /**
66
+ * A SchemaStore `catalog.json` entry: the class is the schema, so decoding
67
+ * an existing entry and encoding one for submission are the same artifact.
68
+ * `versions` is present only for versioned catalogs
69
+ * ({@link SchemaVersioning.catalogUrls} assembles both modes).
70
+ *
71
+ * @public
72
+ */
73
+ var CatalogEntry = class CatalogEntry extends Schema.Class("CatalogEntry")({
74
+ /** The schema's display name in the catalog. */
75
+ name: Schema.String,
76
+ /** The catalog description. */
77
+ description: Schema.String,
78
+ /** Glob patterns editors match files against. */
79
+ fileMatch: Schema.Array(Schema.String),
80
+ /** The schema URL — the unversioned file, or the latest version. */
81
+ url: Schema.String,
82
+ /**
83
+ * Versioned mode only: label → schema URL. Inserted ascending, but key
84
+ * order is not a contract — bare-major labels enumerate first (see
85
+ * `SchemaVersioning.catalogUrls`); derive ordering from the labels.
86
+ */
87
+ versions: Schema.optionalKey(Schema.Record(Schema.String, Schema.String))
88
+ }) {
89
+ /**
90
+ * Assembles an entry from a catalog identity plus
91
+ * {@link SchemaVersioning.catalogUrls}' inputs: pass `versions` for the
92
+ * versioned mode (the `versions` map and latest-pointing `url` are
93
+ * derived), omit it for the unversioned mode.
94
+ */
95
+ static assemble(options) {
96
+ const urls = SchemaVersioning.catalogUrls({
97
+ baseUrl: options.baseUrl,
98
+ name: options.fileBaseName ?? options.name,
99
+ ...options.versions !== void 0 ? { versions: options.versions } : {}
100
+ });
101
+ return CatalogEntry.make({
102
+ name: options.name,
103
+ description: options.description,
104
+ fileMatch: options.fileMatch,
105
+ url: urls.url,
106
+ ...urls.versions !== void 0 ? { versions: urls.versions } : {}
107
+ });
108
+ }
109
+ /**
110
+ * The fileMatch hygiene lint over this entry's patterns — pure shape
111
+ * analysis (no glob engine): generic patterns SchemaStore rejects and
112
+ * complex constructs it asks contributors to expand.
113
+ */
114
+ lint() {
115
+ return CatalogEntry.lintFileMatch(this.fileMatch);
116
+ }
117
+ /**
118
+ * {@link CatalogEntry.lint} over a bare pattern list, for callers
119
+ * checking patterns before an entry exists.
120
+ */
121
+ static lintFileMatch(patterns) {
122
+ return patterns.flatMap(lintPattern);
123
+ }
124
+ };
125
+
126
+ //#endregion
127
+ export { CatalogEntry, CatalogLintFinding };
@@ -0,0 +1,201 @@
1
+ import { KeywordFamilies } from "./KeywordFamilies.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/DocumentLint.ts
5
+ /**
6
+ * A structural lint finding over an assembled document: a value in a
7
+ * report, never an error channel — a document with findings is still a
8
+ * document, and the consumer decides what a finding gates.
9
+ *
10
+ * @public
11
+ */
12
+ var DocumentLintFinding = class extends Schema.Class("DocumentLintFinding")({
13
+ /** Which check fired. */
14
+ check: Schema.Literals([
15
+ "UnresolvedRef",
16
+ "UnknownKeyword",
17
+ "DescriptionWithoutUrl",
18
+ "DepthExceeded"
19
+ ]),
20
+ /** `"warning"` for structural defects, `"advisory"` for best practices. */
21
+ severity: Schema.Literals(["warning", "advisory"]),
22
+ /** JSON pointer into the flat document (`""` is the root schema). */
23
+ path: Schema.String,
24
+ /** Human-readable explanation. */
25
+ message: Schema.String
26
+ }) {};
27
+ const DRAFT_07_KEYWORDS = /* @__PURE__ */ new Set([
28
+ "$comment",
29
+ "$defs",
30
+ "$id",
31
+ "$ref",
32
+ "$schema",
33
+ "additionalItems",
34
+ "additionalProperties",
35
+ "allOf",
36
+ "anyOf",
37
+ "const",
38
+ "contains",
39
+ "contentEncoding",
40
+ "contentMediaType",
41
+ "default",
42
+ "definitions",
43
+ "dependencies",
44
+ "description",
45
+ "else",
46
+ "enum",
47
+ "examples",
48
+ "exclusiveMaximum",
49
+ "exclusiveMinimum",
50
+ "format",
51
+ "if",
52
+ "items",
53
+ "maxItems",
54
+ "maxLength",
55
+ "maxProperties",
56
+ "maximum",
57
+ "minItems",
58
+ "minLength",
59
+ "minProperties",
60
+ "minimum",
61
+ "multipleOf",
62
+ "not",
63
+ "oneOf",
64
+ "pattern",
65
+ "patternProperties",
66
+ "properties",
67
+ "propertyNames",
68
+ "readOnly",
69
+ "required",
70
+ "then",
71
+ "title",
72
+ "type",
73
+ "uniqueItems",
74
+ "writeOnly"
75
+ ]);
76
+ const URL_LINE = /^https?:\/\/\S+$/;
77
+ const escapePointerSegment = (segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1");
78
+ const unescapePointerSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
79
+ const isSchemaObject = (node) => typeof node === "object" && node !== null && !Array.isArray(node);
80
+ const checkRef = (value, path, context) => {
81
+ if (typeof value !== "string") return;
82
+ if (value === "#") return;
83
+ const match = /^#\/\$defs\/([^/]+)/.exec(value);
84
+ if (match !== null && Object.hasOwn(context.defs, unescapePointerSegment(match[1]))) return;
85
+ context.findings.push(DocumentLintFinding.make({
86
+ check: "UnresolvedRef",
87
+ severity: "warning",
88
+ path,
89
+ message: `$ref "${value}" does not resolve against the document's $defs pool`
90
+ }));
91
+ };
92
+ const lintSchema = (node, path, depth, context) => {
93
+ if (!isSchemaObject(node)) return;
94
+ if (depth >= 256) {
95
+ context.findings.push(DocumentLintFinding.make({
96
+ check: "DepthExceeded",
97
+ severity: "warning",
98
+ path,
99
+ message: `schema nests deeper than ${256} levels; lint did not descend further`
100
+ }));
101
+ return;
102
+ }
103
+ for (const [key, value] of Object.entries(node)) {
104
+ const keyPath = `${path}/${escapePointerSegment(key)}`;
105
+ if (!DRAFT_07_KEYWORDS.has(key) && !KeywordFamilies.isDeclared(key)) {
106
+ context.findings.push(DocumentLintFinding.make({
107
+ check: "UnknownKeyword",
108
+ severity: "warning",
109
+ path: keyPath,
110
+ message: `"${key}" is not a Draft-07 keyword or a declared non-standard family (x-taplo*, x-tombi-*, x-intellij-*, vscode); ajv strict mode rejects it`
111
+ }));
112
+ continue;
113
+ }
114
+ switch (key) {
115
+ case "$ref":
116
+ checkRef(value, keyPath, context);
117
+ break;
118
+ case "properties":
119
+ case "patternProperties":
120
+ case "$defs":
121
+ case "definitions":
122
+ if (isSchemaObject(value)) for (const [name, subschema] of Object.entries(value)) lintSchema(subschema, `${keyPath}/${escapePointerSegment(name)}`, depth + 1, context);
123
+ break;
124
+ case "dependencies":
125
+ if (isSchemaObject(value)) {
126
+ for (const [name, dependency] of Object.entries(value)) if (!Array.isArray(dependency)) lintSchema(dependency, `${keyPath}/${escapePointerSegment(name)}`, depth + 1, context);
127
+ }
128
+ break;
129
+ case "items":
130
+ if (Array.isArray(value)) value.forEach((subschema, index) => {
131
+ lintSchema(subschema, `${keyPath}/${index}`, depth + 1, context);
132
+ });
133
+ else lintSchema(value, keyPath, depth + 1, context);
134
+ break;
135
+ case "additionalItems":
136
+ case "additionalProperties":
137
+ case "propertyNames":
138
+ case "contains":
139
+ case "if":
140
+ case "then":
141
+ case "else":
142
+ case "not":
143
+ lintSchema(value, keyPath, depth + 1, context);
144
+ break;
145
+ case "allOf":
146
+ case "anyOf":
147
+ case "oneOf": if (Array.isArray(value)) value.forEach((subschema, index) => {
148
+ lintSchema(subschema, `${keyPath}/${index}`, depth + 1, context);
149
+ });
150
+ }
151
+ }
152
+ };
153
+ /**
154
+ * Owned structural checks over an assembled {@link StoreDocument} — the
155
+ * always-available half of the validation story (a real-engine gate like
156
+ * ajv strict mode stays at the consumer's edge):
157
+ *
158
+ * - `UnresolvedRef` — every `$ref` resolves against the `$defs` pool
159
+ * (`#` self-refs allowed; anything else, including a surviving
160
+ * `#/definitions/...` pointer, is a warning).
161
+ * - `UnknownKeyword` — no keyword outside Draft-07 plus the declared
162
+ * non-standard families ({@link KeywordFamilies}: `x-taplo*`, `x-tombi-*`,
163
+ * `x-intellij-*` and the vscode set), which ajv strict mode would reject.
164
+ * - `DescriptionWithoutUrl` — advisory: SchemaStore's description
165
+ * convention ends the root description with a docs URL line.
166
+ *
167
+ * Tractable because the input is bounded `toJsonSchemaDocument` output;
168
+ * this is not a general JSON Schema validator.
169
+ *
170
+ * @public
171
+ */
172
+ var DocumentLint = class {
173
+ constructor() {}
174
+ /**
175
+ * Runs every check; total — hostile nesting degrades to a
176
+ * `DepthExceeded` finding rather than an error.
177
+ */
178
+ static lint(document) {
179
+ const context = {
180
+ defs: document.defs,
181
+ findings: []
182
+ };
183
+ lintSchema(document.root, "", 0, context);
184
+ for (const [name, definition] of Object.entries(document.defs)) lintSchema(definition, `/$defs/${escapePointerSegment(name)}`, 1, context);
185
+ const description = document.root.description;
186
+ if (typeof description === "string") {
187
+ const lines = description.split("\n");
188
+ const last = lines[lines.length - 1] ?? "";
189
+ if (!URL_LINE.test(last.trim())) context.findings.push(DocumentLintFinding.make({
190
+ check: "DescriptionWithoutUrl",
191
+ severity: "advisory",
192
+ path: "/description",
193
+ message: "SchemaStore's description convention ends with a documentation URL on its own line (<description>\\n<docs-url>)"
194
+ }));
195
+ }
196
+ return context.findings;
197
+ }
198
+ };
199
+
200
+ //#endregion
201
+ export { DocumentLint, DocumentLintFinding };
@@ -0,0 +1,51 @@
1
+ //#region src/KeywordFamilies.ts
2
+ /**
3
+ * The one owner of the declared non-standard keyword families — the
4
+ * language-server keyword sets SchemaStore's CONTRIBUTING enumerates as
5
+ * legitimately consumed by editor toolchains, which ajv strict mode would
6
+ * otherwise reject:
7
+ *
8
+ * - **vscode-json-languageservice** (exact names): `allowTrailingCommas`,
9
+ * `defaultSnippets`, `enumDescriptions`, `markdownDescription`,
10
+ * `markdownEnumDescriptions`.
11
+ * - **taplo**: the `x-taplo` prefix (`x-taplo`, `x-taplo-info`, ...).
12
+ * - **tombi**: the `x-tombi-` prefix (`x-tombi-toml-version`,
13
+ * `x-tombi-array-values-order`, `x-tombi-array-values-order-by`,
14
+ * `x-tombi-table-keys-order`, `x-tombi-string-formats`,
15
+ * `x-tombi-additional-key-label`).
16
+ * - **IntelliJ**: the `x-intellij-` prefix (`x-intellij-language-injection`,
17
+ * `x-intellij-html-description`, `x-intellij-enum-metadata`).
18
+ *
19
+ * Both consumers of the registry route through {@link KeywordFamilies.isDeclared}:
20
+ * `DocumentLint`'s `UnknownKeyword` check (a declared key is not flagged) and
21
+ * `AnnotationCarriers` (only declared keys are re-grafted after the Draft-07
22
+ * lowering). One predicate, so the lint and the carriers cannot drift.
23
+ */
24
+ const VSCODE_KEYWORDS = /* @__PURE__ */ new Set([
25
+ "allowTrailingCommas",
26
+ "defaultSnippets",
27
+ "enumDescriptions",
28
+ "markdownDescription",
29
+ "markdownEnumDescriptions"
30
+ ]);
31
+ /**
32
+ * The declared non-standard keyword families as one predicate: the
33
+ * vscode-json-languageservice set by exact name, plus the `x-taplo`,
34
+ * `x-tombi-` and `x-intellij-` prefixes.
35
+ *
36
+ * @public
37
+ */
38
+ var KeywordFamilies = class {
39
+ constructor() {}
40
+ /**
41
+ * Whether `key` belongs to a declared non-standard keyword family.
42
+ * Draft-07's own keywords are a separate vocabulary — this predicate
43
+ * answers only for the language-server extension families.
44
+ */
45
+ static isDeclared(key) {
46
+ return VSCODE_KEYWORDS.has(key) || key.startsWith("x-taplo") || key.startsWith("x-tombi-") || key.startsWith("x-intellij-");
47
+ }
48
+ };
49
+
50
+ //#endregion
51
+ export { KeywordFamilies };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.