@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.
- package/AnnotationCarriers.js +137 -0
- package/CanonicalJson.js +135 -0
- package/CatalogEntry.js +127 -0
- package/DocumentLint.js +201 -0
- package/KeywordFamilies.js +51 -0
- package/LICENSE +21 -0
- package/SchemaFile.js +119 -0
- package/SchemaTarget.js +30 -0
- package/SchemaValidator.js +91 -0
- package/SchemaVersioning.js +153 -0
- package/StoreDocument.js +142 -0
- package/index.d.ts +839 -0
- package/index.js +12 -0
- package/package.json +49 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -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 };
|
package/CanonicalJson.js
ADDED
|
@@ -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 };
|
package/CatalogEntry.js
ADDED
|
@@ -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 };
|
package/DocumentLint.js
ADDED
|
@@ -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.
|