rulebookguard 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -81
- package/dist/{anchors-BFJCm1NU.mjs → anchors-C2j9c5MI.mjs} +1 -1
- package/dist/cli.mjs +99 -36
- package/dist/index.d.mts +43 -8
- package/dist/index.mjs +3 -3
- package/dist/lint-DfibQDW3.mjs +1042 -0
- package/dist/{site-BiiOPVEe.mjs → site-DmY5YVte.mjs} +5 -5
- package/package.json +3 -3
- package/dist/lint-C1yUeLOq.mjs +0 -755
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
import { access, lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
|
+
import { YAMLParseError, parse } from "yaml";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import picomatch from "picomatch";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
// src/format/errors.ts
|
|
9
|
+
function formatIssue(issue) {
|
|
10
|
+
return issue.field === void 0 ? `${issue.file}: ${issue.message}` : `${issue.file}: field "${issue.field}" ${issue.message}`;
|
|
11
|
+
}
|
|
12
|
+
var FormatError = class extends Error {
|
|
13
|
+
issues;
|
|
14
|
+
constructor(issues) {
|
|
15
|
+
super(issues.map(formatIssue).join("\n"));
|
|
16
|
+
this.name = "FormatError";
|
|
17
|
+
this.issues = issues;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
// src/format/read.ts
|
|
21
|
+
async function readSource(path) {
|
|
22
|
+
try {
|
|
23
|
+
return await readFile(path, "utf8");
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new FormatError([{
|
|
26
|
+
file: path,
|
|
27
|
+
message: `cannot read file (${error.code ?? "unknown error"})`
|
|
28
|
+
}]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// schemas/v1/config.schema.json
|
|
32
|
+
var config_schema_default = {
|
|
33
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
34
|
+
$id: "urn:rulebookguard:schema:v1:config",
|
|
35
|
+
title: "rulebookguard.config.yaml",
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
"schema": { "const": 1 },
|
|
39
|
+
"rulesDir": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"minLength": 1,
|
|
42
|
+
"default": "docs/rules"
|
|
43
|
+
},
|
|
44
|
+
"testGlobs": {
|
|
45
|
+
"type": "array",
|
|
46
|
+
"items": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"minLength": 1
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"contextBudget": {
|
|
52
|
+
"type": "integer",
|
|
53
|
+
"minimum": 1,
|
|
54
|
+
"default": 30
|
|
55
|
+
},
|
|
56
|
+
"language": { "enum": ["en", "es"] }
|
|
57
|
+
},
|
|
58
|
+
required: ["schema", "testGlobs"],
|
|
59
|
+
additionalProperties: false
|
|
60
|
+
};
|
|
61
|
+
// schemas/v1/context.schema.json
|
|
62
|
+
var context_schema_default = {
|
|
63
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
64
|
+
$id: "urn:rulebookguard:schema:v1:context",
|
|
65
|
+
title: "_context.md frontmatter",
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
"context": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"minLength": 1
|
|
71
|
+
},
|
|
72
|
+
"prefix": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"pattern": "^[A-Z][A-Z0-9]*$"
|
|
75
|
+
},
|
|
76
|
+
"summary": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"minLength": 1,
|
|
79
|
+
"pattern": "^[^\\r\\n]*$"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
required: [
|
|
83
|
+
"context",
|
|
84
|
+
"prefix",
|
|
85
|
+
"summary"
|
|
86
|
+
],
|
|
87
|
+
additionalProperties: false
|
|
88
|
+
};
|
|
89
|
+
// schemas/v1/rule.schema.json
|
|
90
|
+
var rule_schema_default = {
|
|
91
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
92
|
+
$id: "urn:rulebookguard:schema:v1:rule",
|
|
93
|
+
title: "Rule file frontmatter",
|
|
94
|
+
type: "object",
|
|
95
|
+
$defs: { "ruleId": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"pattern": "^R-[A-Z][A-Z0-9]*-[0-9]{3}$"
|
|
98
|
+
} },
|
|
99
|
+
properties: {
|
|
100
|
+
"id": { "$ref": "#/$defs/ruleId" },
|
|
101
|
+
"context": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"minLength": 1
|
|
104
|
+
},
|
|
105
|
+
"entities": {
|
|
106
|
+
"type": "array",
|
|
107
|
+
"items": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"minLength": 1
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"status": { "enum": [
|
|
113
|
+
"proposed",
|
|
114
|
+
"active",
|
|
115
|
+
"deprecated"
|
|
116
|
+
] },
|
|
117
|
+
"supersedes": { "$ref": "#/$defs/ruleId" },
|
|
118
|
+
"anchors": {
|
|
119
|
+
"type": "array",
|
|
120
|
+
"items": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"minLength": 1
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"summary": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"minLength": 1,
|
|
128
|
+
"pattern": "^[^\\r\\n]*$"
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
required: [
|
|
132
|
+
"id",
|
|
133
|
+
"context",
|
|
134
|
+
"entities",
|
|
135
|
+
"status",
|
|
136
|
+
"anchors",
|
|
137
|
+
"summary"
|
|
138
|
+
],
|
|
139
|
+
additionalProperties: false
|
|
140
|
+
};
|
|
141
|
+
// src/messages.ts
|
|
142
|
+
const MESSAGES = {
|
|
143
|
+
en: {
|
|
144
|
+
field: (name) => `field "${name}"`,
|
|
145
|
+
missingFrontmatter: "missing YAML frontmatter delimited by \"---\" lines",
|
|
146
|
+
unclosedFrontmatter: "frontmatter is not closed by a \"---\" line",
|
|
147
|
+
invalidYaml: (detail) => `invalid YAML: ${detail}`,
|
|
148
|
+
required: "is required",
|
|
149
|
+
notAllowed: "is not allowed",
|
|
150
|
+
oneOf: (values) => `must be one of: ${values}`,
|
|
151
|
+
equalTo: (value) => `must be equal to ${value}`,
|
|
152
|
+
singleLine: "must be a single line",
|
|
153
|
+
ofType: () => void 0,
|
|
154
|
+
minLength: () => void 0,
|
|
155
|
+
minimum: () => void 0,
|
|
156
|
+
pattern: () => void 0,
|
|
157
|
+
missingRulesDir: "directory does not exist",
|
|
158
|
+
missingContextFile: "has no _context.md",
|
|
159
|
+
contextMismatch: (name) => `must match its directory name "${name}"`,
|
|
160
|
+
fileNameMismatch: (baseName) => `must match the file name "${baseName}"`,
|
|
161
|
+
idPrefixMismatch: (prefix) => `must use the context prefix "${prefix}"`,
|
|
162
|
+
duplicateId: (first) => `duplicates the id of ${first}`,
|
|
163
|
+
catalogWarning: (count) => `warning: the catalog has ${count} format ${count === 1 ? "issue" : "issues"}; run "rulebookguard lint" to see them and to check anchors and test tags.`,
|
|
164
|
+
unknownRule: (id) => `Unknown rule id "${id}". Run "rulebookguard list" to list them.`,
|
|
165
|
+
unknownContext: (name) => `Unknown context "${name}". Run "rulebookguard contexts" to list them.`,
|
|
166
|
+
invalidStatus: (status, allowed) => `Invalid status "${status}": expected one of ${allowed}.`,
|
|
167
|
+
outsideRoot: (input, root) => `Path "${input}" is outside the rulebook root ${root}.`,
|
|
168
|
+
gitFallback: (reason) => `warning: git could not list files (${reason}); walking the directory instead.`,
|
|
169
|
+
submoduleSkipped: (submodule, reason) => `warning: could not list files of submodule "${submodule}" (${reason}); skipping it.`,
|
|
170
|
+
ruleCount: (count) => `${count} ${count === 1 ? "rule" : "rules"}`,
|
|
171
|
+
noContexts: "No contexts found. Create one with \"rulebookguard new <context>\".",
|
|
172
|
+
noRules: "No rules found.",
|
|
173
|
+
noActiveRulesFor: (path) => `No active rules anchored to ${path}.`,
|
|
174
|
+
filesMatched: (count) => `Files matched by anchors (${count}):`,
|
|
175
|
+
testsTagging: (id, count) => `Tests tagging ${id} (${count}):`,
|
|
176
|
+
none: "(none)",
|
|
177
|
+
noFreeId: (context, lastId) => `No free rule id left in context "${context}" (${lastId} is taken).`,
|
|
178
|
+
alreadyExists: (file) => `${file} already exists; nothing was written.`,
|
|
179
|
+
invalidContextName: (name) => `Invalid context name "${name}": use lowercase letters, digits, "-" or "_", starting with a letter or digit.`,
|
|
180
|
+
invalidPrefix: (prefix) => `Invalid prefix "${prefix}": use uppercase letters and digits, starting with a letter. Pass one with --prefix.`,
|
|
181
|
+
prefixTaken: (prefix, context) => `Prefix "${prefix}" is already used by context "${context}". Pass another one with --prefix.`,
|
|
182
|
+
prefixMismatch: (context, prefix, given) => `Context "${context}" already exists with prefix "${prefix}", not "${given}".`,
|
|
183
|
+
contextTemplateSummary: "TODO: one-line summary of the context.",
|
|
184
|
+
contextTemplateBody: "TODO: describe what this context covers and what it leaves out.",
|
|
185
|
+
untestedRule: (id) => `active rule ${id} has no test tagging it`,
|
|
186
|
+
unmatchedAnchor: (anchor) => `anchor "${anchor}" matches no file`,
|
|
187
|
+
unknownRuleTag: (id) => `tags ${id}, which does not exist`,
|
|
188
|
+
deprecatedRuleTag: (id) => `tags ${id}, which is deprecated`,
|
|
189
|
+
unclosedRuleTag: (id) => `@rule ${id} has no matching @endrule ${id}`,
|
|
190
|
+
unmatchedEndruleTag: (id) => `@endrule ${id} has no open @rule ${id} before it`,
|
|
191
|
+
contextOverBudget: (context, count, budget) => `context "${context}" has ${count} rules, more than the contextBudget of ${budget}`,
|
|
192
|
+
pendingImplementation: (id, base) => `rule ${id} changed since ${base} but none of the tests tagging it changed`,
|
|
193
|
+
noIssues: "No issues found.",
|
|
194
|
+
hintLabel: "hint",
|
|
195
|
+
catalogHints: {
|
|
196
|
+
schema: "fix the file as the message says; the JSON Schemas ship under rulebookguard/schemas/v1/",
|
|
197
|
+
"missing-rules-dir": "create the directory, or set rulesDir in rulebookguard.config.yaml",
|
|
198
|
+
"missing-context-file": "add a _context.md with context, prefix and summary",
|
|
199
|
+
"context-mismatch": "make the context field equal to the directory name, or move the file to the matching directory",
|
|
200
|
+
"file-name-mismatch": "rename the file after the id, or change the id",
|
|
201
|
+
"id-prefix-mismatch": "change the id to use the context prefix, or move the rule to the context that owns its prefix",
|
|
202
|
+
"duplicate-id": "give one of the rules another id; \"rulebookguard new <context>\" picks the next free one"
|
|
203
|
+
},
|
|
204
|
+
untestedRuleHint: (id) => `wrap a test in a file matched by testGlobs with "@rule ${id}" and "@endrule ${id}", or set the rule's status to proposed`,
|
|
205
|
+
unmatchedAnchorHint: "fix the glob so it matches the files that implement the rule, or remove it",
|
|
206
|
+
unknownRuleTagHint: "fix the id in the tag, or create the rule it refers to",
|
|
207
|
+
deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `tag the rule that replaces ${id}, or remove the tag` : `tag ${successor}, which supersedes ${id}, instead`,
|
|
208
|
+
unclosedRuleTagHint: (id) => `add "@endrule ${id}" after the last line of the test, or close the inner regions first`,
|
|
209
|
+
unmatchedEndruleTagHint: (id) => `add "@rule ${id}" before the test, or remove the stray @endrule`,
|
|
210
|
+
contextOverBudgetHint: "split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml",
|
|
211
|
+
pendingImplementationHint: (id) => `update or add a test inside an "@rule ${id}" … "@endrule ${id}" region that covers the changed rule`,
|
|
212
|
+
lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
|
|
213
|
+
needsWorkTree: (root) => `lint --base needs a git work tree, but ${root} is not inside one.`,
|
|
214
|
+
unknownRef: (ref) => `Unknown git ref "${ref}".`,
|
|
215
|
+
cannotListChanged: (ref, reason) => `Cannot list the files changed since "${ref}": ${reason}`
|
|
216
|
+
},
|
|
217
|
+
es: {
|
|
218
|
+
field: (name) => `el campo "${name}"`,
|
|
219
|
+
missingFrontmatter: "falta el frontmatter YAML delimitado por líneas \"---\"",
|
|
220
|
+
unclosedFrontmatter: "el frontmatter no se cierra con una línea \"---\"",
|
|
221
|
+
invalidYaml: (detail) => `YAML inválido: ${detail}`,
|
|
222
|
+
required: "es obligatorio",
|
|
223
|
+
notAllowed: "no está permitido",
|
|
224
|
+
oneOf: (values) => `debe ser uno de: ${values}`,
|
|
225
|
+
equalTo: (value) => `debe ser igual a ${value}`,
|
|
226
|
+
singleLine: "debe ocupar una sola línea",
|
|
227
|
+
ofType: (type) => `debe ser de tipo ${type}`,
|
|
228
|
+
minLength: (limit) => limit === 1 ? "no puede estar vacío" : `debe tener al menos ${limit} caracteres`,
|
|
229
|
+
minimum: (limit) => `debe ser mayor o igual que ${limit}`,
|
|
230
|
+
pattern: (pattern) => `debe cumplir el patrón "${pattern}"`,
|
|
231
|
+
missingRulesDir: "el directorio no existe",
|
|
232
|
+
missingContextFile: "no tiene _context.md",
|
|
233
|
+
contextMismatch: (name) => `debe coincidir con el nombre de su directorio "${name}"`,
|
|
234
|
+
fileNameMismatch: (baseName) => `debe coincidir con el nombre del archivo "${baseName}"`,
|
|
235
|
+
idPrefixMismatch: (prefix) => `debe usar el prefijo del contexto "${prefix}"`,
|
|
236
|
+
duplicateId: (first) => `repite el id de ${first}`,
|
|
237
|
+
catalogWarning: (count) => `aviso: el catálogo tiene ${count} ${count === 1 ? "problema" : "problemas"} de formato; ejecuta "rulebookguard lint" para verlos y para revisar anchors y etiquetas de test.`,
|
|
238
|
+
unknownRule: (id) => `No existe la regla "${id}". Ejecuta "rulebookguard list" para ver las reglas.`,
|
|
239
|
+
unknownContext: (name) => `No existe el contexto "${name}". Ejecuta "rulebookguard contexts" para ver los contextos.`,
|
|
240
|
+
invalidStatus: (status, allowed) => `Estado "${status}" inválido: debe ser uno de ${allowed}.`,
|
|
241
|
+
outsideRoot: (input, root) => `La ruta "${input}" está fuera de la raíz del rulebook ${root}.`,
|
|
242
|
+
gitFallback: (reason) => `aviso: git no pudo listar los archivos (${reason}); se recorre el directorio en su lugar.`,
|
|
243
|
+
submoduleSkipped: (submodule, reason) => `aviso: no se pudieron listar los archivos del submódulo "${submodule}" (${reason}); se omite.`,
|
|
244
|
+
ruleCount: (count) => `${count} ${count === 1 ? "regla" : "reglas"}`,
|
|
245
|
+
noContexts: "No se encontraron contextos. Crea uno con \"rulebookguard new <contexto>\".",
|
|
246
|
+
noRules: "No se encontraron reglas.",
|
|
247
|
+
noActiveRulesFor: (path) => `No hay reglas activas ancladas a ${path}.`,
|
|
248
|
+
filesMatched: (count) => `Archivos que cubren los anchors (${count}):`,
|
|
249
|
+
testsTagging: (id, count) => `Tests que etiquetan ${id} (${count}):`,
|
|
250
|
+
none: "(ninguno)",
|
|
251
|
+
noFreeId: (context, lastId) => `No quedan ids libres en el contexto "${context}" (${lastId} ya existe).`,
|
|
252
|
+
alreadyExists: (file) => `${file} ya existe; no se escribió nada.`,
|
|
253
|
+
invalidContextName: (name) => `Nombre de contexto "${name}" inválido: usa minúsculas, dígitos, "-" o "_", empezando por una letra o un dígito.`,
|
|
254
|
+
invalidPrefix: (prefix) => `Prefijo "${prefix}" inválido: usa mayúsculas y dígitos, empezando por una letra. Indica uno con --prefix.`,
|
|
255
|
+
prefixTaken: (prefix, context) => `El prefijo "${prefix}" ya lo usa el contexto "${context}". Indica otro con --prefix.`,
|
|
256
|
+
prefixMismatch: (context, prefix, given) => `El contexto "${context}" ya existe con el prefijo "${prefix}", no "${given}".`,
|
|
257
|
+
contextTemplateSummary: "TODO: resumen del contexto en una línea.",
|
|
258
|
+
contextTemplateBody: "TODO: describe qué cubre este contexto y qué queda fuera.",
|
|
259
|
+
untestedRule: (id) => `la regla activa ${id} no tiene ningún test que la etiquete`,
|
|
260
|
+
unmatchedAnchor: (anchor) => `el anchor "${anchor}" no coincide con ningún archivo`,
|
|
261
|
+
unknownRuleTag: (id) => `etiqueta ${id}, que no existe`,
|
|
262
|
+
deprecatedRuleTag: (id) => `etiqueta ${id}, que está deprecada`,
|
|
263
|
+
unclosedRuleTag: (id) => `@rule ${id} no tiene su @endrule ${id}`,
|
|
264
|
+
unmatchedEndruleTag: (id) => `@endrule ${id} no tiene un @rule ${id} abierto antes`,
|
|
265
|
+
contextOverBudget: (context, count, budget) => `el contexto "${context}" tiene ${count} reglas, más que el contextBudget de ${budget}`,
|
|
266
|
+
pendingImplementation: (id, base) => `la regla ${id} cambió desde ${base} pero no cambió ninguno de los tests que la etiquetan`,
|
|
267
|
+
noIssues: "No se encontraron problemas.",
|
|
268
|
+
hintLabel: "sugerencia",
|
|
269
|
+
catalogHints: {
|
|
270
|
+
schema: "corrige el archivo según el mensaje; los JSON Schemas vienen en rulebookguard/schemas/v1/",
|
|
271
|
+
"missing-rules-dir": "crea el directorio o cambia rulesDir en rulebookguard.config.yaml",
|
|
272
|
+
"missing-context-file": "agrega un _context.md con context, prefix y summary",
|
|
273
|
+
"context-mismatch": "iguala el campo context al nombre del directorio o mueve el archivo al directorio que corresponde",
|
|
274
|
+
"file-name-mismatch": "renombra el archivo según el id o cambia el id",
|
|
275
|
+
"id-prefix-mismatch": "cambia el id para usar el prefijo del contexto o mueve la regla al contexto dueño de ese prefijo",
|
|
276
|
+
"duplicate-id": "dale otro id a una de las reglas; \"rulebookguard new <contexto>\" elige el siguiente libre"
|
|
277
|
+
},
|
|
278
|
+
untestedRuleHint: (id) => `envuelve un test de un archivo que coincida con testGlobs con "@rule ${id}" y "@endrule ${id}", o cambia el estado de la regla a proposed`,
|
|
279
|
+
unmatchedAnchorHint: "corrige el glob para que coincida con los archivos que implementan la regla, o quítalo",
|
|
280
|
+
unknownRuleTagHint: "corrige el id de la etiqueta o crea la regla a la que se refiere",
|
|
281
|
+
deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `etiqueta la regla que reemplaza a ${id} o quita la etiqueta` : `etiqueta ${successor}, que reemplaza a ${id}`,
|
|
282
|
+
unclosedRuleTagHint: (id) => `agrega "@endrule ${id}" después de la última línea del test, o cierra antes las regiones internas`,
|
|
283
|
+
unmatchedEndruleTagHint: (id) => `agrega "@rule ${id}" antes del test, o quita el @endrule que sobra`,
|
|
284
|
+
contextOverBudgetHint: "divide el contexto en otros más chicos o sube contextBudget en rulebookguard.config.yaml",
|
|
285
|
+
pendingImplementationHint: (id) => `actualiza o agrega un test dentro de una región "@rule ${id}" … "@endrule ${id}" que cubra el cambio de la regla`,
|
|
286
|
+
lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errores"}, ${warnings} ${warnings === 1 ? "aviso" : "avisos"}`,
|
|
287
|
+
needsWorkTree: (root) => `lint --base necesita un work tree de git, pero ${root} no está en uno.`,
|
|
288
|
+
unknownRef: (ref) => `La referencia de git "${ref}" no existe.`,
|
|
289
|
+
cannotListChanged: (ref, reason) => `No se pudieron listar los archivos cambiados desde "${ref}": ${reason}`
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
function messages(language) {
|
|
293
|
+
return MESSAGES[language ?? "en"];
|
|
294
|
+
}
|
|
295
|
+
// src/format/validate.ts
|
|
296
|
+
const ajv = new Ajv2020({
|
|
297
|
+
allErrors: true,
|
|
298
|
+
useDefaults: true
|
|
299
|
+
});
|
|
300
|
+
const validators = {
|
|
301
|
+
config: ajv.compile(config_schema_default),
|
|
302
|
+
context: ajv.compile(context_schema_default),
|
|
303
|
+
rule: ajv.compile(rule_schema_default)
|
|
304
|
+
};
|
|
305
|
+
function validate(validator, data, file, { language } = {}) {
|
|
306
|
+
if (validator(data)) return data;
|
|
307
|
+
const text = messages(language);
|
|
308
|
+
throw new FormatError((validator.errors ?? []).map((error) => toIssue(error, file, text)));
|
|
309
|
+
}
|
|
310
|
+
function toIssue(error, file, text) {
|
|
311
|
+
const base = toFieldPath(error.instancePath);
|
|
312
|
+
const params = error.params;
|
|
313
|
+
const fallback = error.message ?? "is invalid";
|
|
314
|
+
switch (error.keyword) {
|
|
315
|
+
case "required": return {
|
|
316
|
+
file,
|
|
317
|
+
field: join$1(base, String(params.missingProperty)),
|
|
318
|
+
message: text.required
|
|
319
|
+
};
|
|
320
|
+
case "additionalProperties": return {
|
|
321
|
+
file,
|
|
322
|
+
field: join$1(base, String(params.additionalProperty)),
|
|
323
|
+
message: text.notAllowed
|
|
324
|
+
};
|
|
325
|
+
case "enum": return withField(file, base, text.oneOf(params.allowedValues.join(", ")));
|
|
326
|
+
case "const": return withField(file, base, text.equalTo(JSON.stringify(params.allowedValue)));
|
|
327
|
+
case "pattern":
|
|
328
|
+
if (params.pattern === "^[^\\r\\n]*$") return withField(file, base, text.singleLine);
|
|
329
|
+
return withField(file, base, text.pattern(String(params.pattern)) ?? fallback);
|
|
330
|
+
case "type": return withField(file, base, text.ofType(String(params.type)) ?? fallback);
|
|
331
|
+
case "minLength": return withField(file, base, text.minLength(Number(params.limit)) ?? fallback);
|
|
332
|
+
case "minimum": return withField(file, base, text.minimum(Number(params.limit)) ?? fallback);
|
|
333
|
+
default: return withField(file, base, fallback);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function withField(file, field, message) {
|
|
337
|
+
return field === "" ? {
|
|
338
|
+
file,
|
|
339
|
+
message
|
|
340
|
+
} : {
|
|
341
|
+
file,
|
|
342
|
+
field,
|
|
343
|
+
message
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function join$1(base, key) {
|
|
347
|
+
return base === "" ? key : `${base}.${key}`;
|
|
348
|
+
}
|
|
349
|
+
function toFieldPath(instancePath) {
|
|
350
|
+
return instancePath.split("/").slice(1).map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")).reduce((path, segment) => /^\d+$/.test(segment) ? `${path}[${segment}]` : join$1(path, segment), "");
|
|
351
|
+
}
|
|
352
|
+
// src/format/yaml.ts
|
|
353
|
+
function parseYaml(source, file, { language } = {}) {
|
|
354
|
+
try {
|
|
355
|
+
return parse(source);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error instanceof YAMLParseError) throw new FormatError([{
|
|
358
|
+
file,
|
|
359
|
+
message: messages(language).invalidYaml(singleLine(error))
|
|
360
|
+
}]);
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function singleLine(error) {
|
|
365
|
+
const first = (error.message.split("\n")[0] ?? "").replace(/:\s*$/, "");
|
|
366
|
+
const position = error.linePos?.[0];
|
|
367
|
+
if (position === void 0 || / at line \d+, column \d+$/.test(first)) return first;
|
|
368
|
+
return `${first} at line ${position.line}, column ${position.col}`;
|
|
369
|
+
}
|
|
370
|
+
// src/format/config.ts
|
|
371
|
+
const CONFIG_FILE_NAME = "rulebookguard.config.yaml";
|
|
372
|
+
function parseConfig(source, file) {
|
|
373
|
+
return validate(validators.config, parseYaml(source, file), file);
|
|
374
|
+
}
|
|
375
|
+
async function loadConfig(path) {
|
|
376
|
+
return parseConfig(await readSource(path), path);
|
|
377
|
+
}
|
|
378
|
+
// src/format/frontmatter.ts
|
|
379
|
+
const OPENING = /^---\r?\n/;
|
|
380
|
+
const CLOSING = /^---[ \t]*(?:\r?\n|$)/m;
|
|
381
|
+
function splitFrontmatter(source, file, { language } = {}) {
|
|
382
|
+
const text = messages(language);
|
|
383
|
+
const opening = OPENING.exec(source);
|
|
384
|
+
if (opening === null) throw new FormatError([{
|
|
385
|
+
file,
|
|
386
|
+
message: text.missingFrontmatter
|
|
387
|
+
}]);
|
|
388
|
+
const rest = source.slice(opening[0].length);
|
|
389
|
+
const closing = CLOSING.exec(rest);
|
|
390
|
+
if (closing === null) throw new FormatError([{
|
|
391
|
+
file,
|
|
392
|
+
message: text.unclosedFrontmatter
|
|
393
|
+
}]);
|
|
394
|
+
const yaml = rest.slice(0, closing.index);
|
|
395
|
+
const body = rest.slice(closing.index + closing[0].length).replace(/^(?:\r?\n)+/, "");
|
|
396
|
+
return {
|
|
397
|
+
data: parseYaml(yaml, file, { language }),
|
|
398
|
+
body
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
// src/format/context.ts
|
|
402
|
+
const CONTEXT_FILE_NAME = "_context.md";
|
|
403
|
+
function parseContext(source, file, { language } = {}) {
|
|
404
|
+
const { data, body } = splitFrontmatter(source, file, { language });
|
|
405
|
+
return {
|
|
406
|
+
file,
|
|
407
|
+
...validate(validators.context, data, file, { language }),
|
|
408
|
+
body
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async function loadContext(path, options) {
|
|
412
|
+
return parseContext(await readSource(path), path, options);
|
|
413
|
+
}
|
|
414
|
+
// src/format/rule.ts
|
|
415
|
+
const RULE_ID = /^R-[A-Z][A-Z0-9]*-[0-9]{3}$/;
|
|
416
|
+
function isRuleId(value) {
|
|
417
|
+
return RULE_ID.test(value);
|
|
418
|
+
}
|
|
419
|
+
function parseRule(source, file, { language } = {}) {
|
|
420
|
+
const { data, body } = splitFrontmatter(source, file, { language });
|
|
421
|
+
return {
|
|
422
|
+
file,
|
|
423
|
+
...validate(validators.rule, data, file, { language }),
|
|
424
|
+
body
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async function loadRule(path, options) {
|
|
428
|
+
return parseRule(await readSource(path), path, options);
|
|
429
|
+
}
|
|
430
|
+
// src/format/tag.ts
|
|
431
|
+
const RULE_TAG_PATTERN = /(?<![\w@.-])@rule\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\w-])/g;
|
|
432
|
+
const RULE_MARKER_PATTERN = /(?<![\w@.-])@(rule|endrule)\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\w-])/g;
|
|
433
|
+
function findRuleTags(source) {
|
|
434
|
+
const tags = [];
|
|
435
|
+
source.split(/\r?\n/).forEach((text, index) => {
|
|
436
|
+
for (const match of text.matchAll(RULE_TAG_PATTERN)) tags.push({
|
|
437
|
+
id: match[1],
|
|
438
|
+
line: index + 1
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
return tags;
|
|
442
|
+
}
|
|
443
|
+
function findRuleRegions(source) {
|
|
444
|
+
const regions = [];
|
|
445
|
+
const problems = [];
|
|
446
|
+
const open = [];
|
|
447
|
+
source.split(/\r?\n/).forEach((text, index) => {
|
|
448
|
+
const line = index + 1;
|
|
449
|
+
for (const match of text.matchAll(RULE_MARKER_PATTERN)) {
|
|
450
|
+
const id = match[2];
|
|
451
|
+
if (match[1] === "rule") {
|
|
452
|
+
open.push({
|
|
453
|
+
id,
|
|
454
|
+
line
|
|
455
|
+
});
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const at = open.map((tag) => tag.id).lastIndexOf(id);
|
|
459
|
+
if (at === -1) {
|
|
460
|
+
problems.push({
|
|
461
|
+
code: "unmatched-endrule-tag",
|
|
462
|
+
id,
|
|
463
|
+
line
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
for (const inner of open.splice(at + 1)) problems.push({
|
|
468
|
+
code: "unclosed-rule-tag",
|
|
469
|
+
...inner
|
|
470
|
+
});
|
|
471
|
+
const start = open.pop();
|
|
472
|
+
regions.push({
|
|
473
|
+
...start,
|
|
474
|
+
endLine: line
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
for (const tag of open) problems.push({
|
|
479
|
+
code: "unclosed-rule-tag",
|
|
480
|
+
...tag
|
|
481
|
+
});
|
|
482
|
+
problems.sort((a, b) => a.line - b.line);
|
|
483
|
+
return {
|
|
484
|
+
regions,
|
|
485
|
+
problems
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// src/catalog/git.ts
|
|
489
|
+
const execFileAsync = promisify(execFile);
|
|
490
|
+
const SAFE_CONFIG = ["-c", "core.fsmonitor=false"];
|
|
491
|
+
async function runGit(cwd, args) {
|
|
492
|
+
const { stdout } = await execFileAsync("git", [...SAFE_CONFIG, ...args], {
|
|
493
|
+
cwd,
|
|
494
|
+
maxBuffer: 268435456
|
|
495
|
+
});
|
|
496
|
+
return stdout;
|
|
497
|
+
}
|
|
498
|
+
async function gitPaths(cwd, command, args) {
|
|
499
|
+
return (await runGit(cwd, [
|
|
500
|
+
command,
|
|
501
|
+
"-z",
|
|
502
|
+
...args
|
|
503
|
+
])).split("\0").filter((path) => path !== "");
|
|
504
|
+
}
|
|
505
|
+
async function isInsideWorkTree(cwd) {
|
|
506
|
+
try {
|
|
507
|
+
return (await runGit(cwd, ["rev-parse", "--is-inside-work-tree"])).trim() === "true";
|
|
508
|
+
} catch {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function isCommitRef(cwd, ref) {
|
|
513
|
+
if (ref.startsWith("-")) return false;
|
|
514
|
+
try {
|
|
515
|
+
await runGit(cwd, [
|
|
516
|
+
"rev-parse",
|
|
517
|
+
"--verify",
|
|
518
|
+
"--quiet",
|
|
519
|
+
`${ref}^{commit}`
|
|
520
|
+
]);
|
|
521
|
+
return true;
|
|
522
|
+
} catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function changedSince(cwd, ref) {
|
|
527
|
+
const changed = await gitPaths(cwd, "diff", [
|
|
528
|
+
"--name-only",
|
|
529
|
+
"--relative",
|
|
530
|
+
ref,
|
|
531
|
+
"--"
|
|
532
|
+
]);
|
|
533
|
+
const untracked = await gitPaths(cwd, "ls-files", ["--others", "--exclude-standard"]);
|
|
534
|
+
return {
|
|
535
|
+
files: [.../* @__PURE__ */ new Set([...changed, ...untracked])].sort(),
|
|
536
|
+
untracked: new Set(untracked)
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function changedLines(cwd, ref, file) {
|
|
540
|
+
const diff = await runGit(cwd, [
|
|
541
|
+
"--literal-pathspecs",
|
|
542
|
+
"diff",
|
|
543
|
+
"-U0",
|
|
544
|
+
"--no-color",
|
|
545
|
+
"--no-ext-diff",
|
|
546
|
+
"--no-textconv",
|
|
547
|
+
"--relative",
|
|
548
|
+
ref,
|
|
549
|
+
"--",
|
|
550
|
+
file
|
|
551
|
+
]);
|
|
552
|
+
const spans = [];
|
|
553
|
+
for (const match of diff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
554
|
+
const start = Number(match[1]);
|
|
555
|
+
const count = match[2] === void 0 ? 1 : Number(match[2]);
|
|
556
|
+
spans.push(count === 0 ? [start + .5, start + .5] : [start, start + count - 1]);
|
|
557
|
+
}
|
|
558
|
+
return spans;
|
|
559
|
+
}
|
|
560
|
+
function gitFailureReason(error) {
|
|
561
|
+
const { code, stderr, message } = error;
|
|
562
|
+
if (code === "ENOENT") return "git was not found";
|
|
563
|
+
return `${typeof stderr === "string" ? stderr : ""}\n${String(message ?? error)}`.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("warning:"))[0] ?? "git failed";
|
|
564
|
+
}
|
|
565
|
+
function isNotARepository(error) {
|
|
566
|
+
const { stderr } = error;
|
|
567
|
+
return typeof stderr === "string" && stderr.includes("not a git repository");
|
|
568
|
+
}
|
|
569
|
+
// src/catalog/files.ts
|
|
570
|
+
const SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
571
|
+
const GITLINK_MODE = "160000";
|
|
572
|
+
function toRepoPath(path) {
|
|
573
|
+
return path.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
|
|
574
|
+
}
|
|
575
|
+
function createGlobMatcher(globs) {
|
|
576
|
+
if (globs.length === 0) return () => false;
|
|
577
|
+
const isMatch = picomatch(globs);
|
|
578
|
+
return (path) => isMatch(toRepoPath(path));
|
|
579
|
+
}
|
|
580
|
+
async function listRepoFiles(root, onGitFallback) {
|
|
581
|
+
return (await listGitFiles(root, {
|
|
582
|
+
realRoot: await realpath(root),
|
|
583
|
+
prefix: "",
|
|
584
|
+
onGitFallback
|
|
585
|
+
}) ?? await walkFiles(root)).sort();
|
|
586
|
+
}
|
|
587
|
+
async function expandGlobs(root, globs, onGitFallback) {
|
|
588
|
+
const isMatch = createGlobMatcher(globs);
|
|
589
|
+
return (await listRepoFiles(root, onGitFallback)).filter(isMatch);
|
|
590
|
+
}
|
|
591
|
+
async function listGitFiles(root, scope) {
|
|
592
|
+
let listing;
|
|
593
|
+
try {
|
|
594
|
+
listing = await listGitIndex(root);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (isNotARepository(error)) return;
|
|
597
|
+
if (scope.prefix !== "") throw error;
|
|
598
|
+
scope.onGitFallback?.(gitFailureReason(error));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (listing === void 0) return;
|
|
602
|
+
const { files, gitlinks } = listing;
|
|
603
|
+
for (const gitlink of gitlinks) {
|
|
604
|
+
const nested = await listSubmoduleFiles(root, gitlink, scope);
|
|
605
|
+
files.push(...nested.map((file) => `${gitlink}/${file}`));
|
|
606
|
+
}
|
|
607
|
+
return files;
|
|
608
|
+
}
|
|
609
|
+
async function listGitIndex(root) {
|
|
610
|
+
const staged = (await gitPaths(root, "ls-files", ["--stage"])).map(parseStageEntry);
|
|
611
|
+
const others = await gitPaths(root, "ls-files", ["--others", "--exclude-standard"]);
|
|
612
|
+
const deleted = new Set(await gitPaths(root, "ls-files", ["--deleted"]));
|
|
613
|
+
const gitlinks = new Set(staged.filter(({ mode }) => mode === GITLINK_MODE).map(({ path }) => path));
|
|
614
|
+
const files = [.../* @__PURE__ */ new Set([...staged.map(({ path }) => path), ...others])].filter((file) => !deleted.has(file) && !gitlinks.has(file));
|
|
615
|
+
if (files.length === 0 && gitlinks.size === 0) return;
|
|
616
|
+
return {
|
|
617
|
+
files,
|
|
618
|
+
gitlinks: [...gitlinks]
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
async function listSubmoduleFiles(root, gitlink, scope) {
|
|
622
|
+
const dir = join(root, gitlink);
|
|
623
|
+
const path = `${scope.prefix}${gitlink}`;
|
|
624
|
+
try {
|
|
625
|
+
if (!await isContainedEntry(dir, scope.realRoot)) return [];
|
|
626
|
+
if (!await exists$1(join(dir, ".git"))) return await walkFiles(dir);
|
|
627
|
+
return await listGitFiles(dir, {
|
|
628
|
+
...scope,
|
|
629
|
+
prefix: `${path}/`
|
|
630
|
+
}) ?? await walkFiles(dir);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
scope.onGitFallback?.(gitFailureReason(error), path);
|
|
633
|
+
return [];
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function isContainedEntry(path, realRoot) {
|
|
637
|
+
try {
|
|
638
|
+
if ((await lstat(path)).isSymbolicLink()) return false;
|
|
639
|
+
} catch (error) {
|
|
640
|
+
if (error.code === "ENOENT") return false;
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
return resolvesInside(path, realRoot);
|
|
644
|
+
}
|
|
645
|
+
async function resolvesInside(path, realRoot) {
|
|
646
|
+
let target;
|
|
647
|
+
try {
|
|
648
|
+
target = await realpath(path);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (error.code === "ENOENT") return false;
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
const fromRoot = relative(realRoot, target);
|
|
654
|
+
return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
|
|
655
|
+
}
|
|
656
|
+
function parseStageEntry(entry) {
|
|
657
|
+
const tab = entry.indexOf(" ");
|
|
658
|
+
return {
|
|
659
|
+
mode: entry.slice(0, entry.indexOf(" ")),
|
|
660
|
+
path: entry.slice(tab + 1)
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
async function exists$1(path) {
|
|
664
|
+
try {
|
|
665
|
+
await access(path);
|
|
666
|
+
return true;
|
|
667
|
+
} catch {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
async function walkFiles(root) {
|
|
672
|
+
const files = [];
|
|
673
|
+
await walk(root, "", files);
|
|
674
|
+
return files;
|
|
675
|
+
}
|
|
676
|
+
async function walk(root, prefix, files) {
|
|
677
|
+
const entries = await readdir(join(root, prefix), { withFileTypes: true });
|
|
678
|
+
for (const entry of entries) {
|
|
679
|
+
const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
680
|
+
if (entry.isDirectory()) {
|
|
681
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name)) await walk(root, path, files);
|
|
682
|
+
} else if (entry.isFile()) files.push(path);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// src/catalog/load.ts
|
|
686
|
+
async function findRulebookRoot(start) {
|
|
687
|
+
let directory = resolve(start);
|
|
688
|
+
for (;;) {
|
|
689
|
+
if (await exists(join(directory, "rulebookguard.config.yaml"))) return directory;
|
|
690
|
+
const parent = dirname(directory);
|
|
691
|
+
if (parent === directory) return;
|
|
692
|
+
directory = parent;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
async function loadCatalog(root) {
|
|
696
|
+
const config = await loadConfig(join(root, CONFIG_FILE_NAME));
|
|
697
|
+
const catalog = {
|
|
698
|
+
root,
|
|
699
|
+
config,
|
|
700
|
+
contexts: [],
|
|
701
|
+
rules: [],
|
|
702
|
+
invalidRuleIds: [],
|
|
703
|
+
issues: []
|
|
704
|
+
};
|
|
705
|
+
const rulesDir = toRepoPath(config.rulesDir).replace(/\/+$/, "");
|
|
706
|
+
const directories = await listEntries(join(root, rulesDir));
|
|
707
|
+
if (directories === void 0) {
|
|
708
|
+
catalog.issues.push({
|
|
709
|
+
code: "missing-rules-dir",
|
|
710
|
+
file: rulesDir,
|
|
711
|
+
message: messages(config.language).missingRulesDir
|
|
712
|
+
});
|
|
713
|
+
return catalog;
|
|
714
|
+
}
|
|
715
|
+
for (const entry of directories.filter((item) => item.isDirectory())) await loadContextDirectory(catalog, `${rulesDir}/${entry.name}`, entry.name);
|
|
716
|
+
catalog.issues.push(...findDuplicateIds(catalog.rules, config.language));
|
|
717
|
+
return catalog;
|
|
718
|
+
}
|
|
719
|
+
async function loadContextDirectory(catalog, dir, name) {
|
|
720
|
+
const files = (await listEntries(join(catalog.root, dir)) ?? []).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name);
|
|
721
|
+
let context;
|
|
722
|
+
if (files.includes("_context.md")) {
|
|
723
|
+
context = await load(catalog, `${dir}/${CONTEXT_FILE_NAME}`, loadContext);
|
|
724
|
+
if (context !== void 0) {
|
|
725
|
+
catalog.contexts.push(context);
|
|
726
|
+
checkContextName(catalog, context, name);
|
|
727
|
+
}
|
|
728
|
+
} else catalog.issues.push({
|
|
729
|
+
code: "missing-context-file",
|
|
730
|
+
file: dir,
|
|
731
|
+
message: messages(catalog.config.language).missingContextFile
|
|
732
|
+
});
|
|
733
|
+
for (const file of files.filter((item) => item !== CONTEXT_FILE_NAME)) {
|
|
734
|
+
const rule = await load(catalog, `${dir}/${file}`, loadRule);
|
|
735
|
+
const baseName = file.slice(0, -3);
|
|
736
|
+
if (rule === void 0 && isRuleId(baseName)) catalog.invalidRuleIds.push(baseName);
|
|
737
|
+
if (rule !== void 0) {
|
|
738
|
+
catalog.rules.push(rule);
|
|
739
|
+
checkRule(catalog, rule, name, baseName, context);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
async function load(catalog, file, loader) {
|
|
744
|
+
try {
|
|
745
|
+
return {
|
|
746
|
+
...await loader(join(catalog.root, file), { language: catalog.config.language }),
|
|
747
|
+
file
|
|
748
|
+
};
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if (!(error instanceof FormatError)) throw error;
|
|
751
|
+
for (const issue of error.issues) catalog.issues.push({
|
|
752
|
+
code: "schema",
|
|
753
|
+
...issue,
|
|
754
|
+
file
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function checkContextName(catalog, doc, name) {
|
|
760
|
+
if (doc.context !== name) catalog.issues.push({
|
|
761
|
+
code: "context-mismatch",
|
|
762
|
+
file: doc.file,
|
|
763
|
+
field: "context",
|
|
764
|
+
message: messages(catalog.config.language).contextMismatch(name)
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
function checkRule(catalog, rule, directory, baseName, context) {
|
|
768
|
+
if (rule.id !== baseName) catalog.issues.push({
|
|
769
|
+
code: "file-name-mismatch",
|
|
770
|
+
file: rule.file,
|
|
771
|
+
field: "id",
|
|
772
|
+
message: messages(catalog.config.language).fileNameMismatch(baseName)
|
|
773
|
+
});
|
|
774
|
+
if (context !== void 0 && !rule.id.startsWith(`R-${context.prefix}-`)) catalog.issues.push({
|
|
775
|
+
code: "id-prefix-mismatch",
|
|
776
|
+
file: rule.file,
|
|
777
|
+
field: "id",
|
|
778
|
+
message: messages(catalog.config.language).idPrefixMismatch(context.prefix)
|
|
779
|
+
});
|
|
780
|
+
checkContextName(catalog, rule, directory);
|
|
781
|
+
}
|
|
782
|
+
function findDuplicateIds(rules, language) {
|
|
783
|
+
const firstFile = /* @__PURE__ */ new Map();
|
|
784
|
+
const issues = [];
|
|
785
|
+
for (const rule of rules) {
|
|
786
|
+
const first = firstFile.get(rule.id);
|
|
787
|
+
if (first === void 0) firstFile.set(rule.id, rule.file);
|
|
788
|
+
else issues.push({
|
|
789
|
+
code: "duplicate-id",
|
|
790
|
+
file: rule.file,
|
|
791
|
+
field: "id",
|
|
792
|
+
message: messages(language).duplicateId(first)
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
return issues;
|
|
796
|
+
}
|
|
797
|
+
async function listEntries(path) {
|
|
798
|
+
try {
|
|
799
|
+
return (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
if (error.code === "ENOENT") return;
|
|
802
|
+
throw error;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
async function exists(path) {
|
|
806
|
+
try {
|
|
807
|
+
await access(path);
|
|
808
|
+
return true;
|
|
809
|
+
} catch {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function relativeToRoot(root, path) {
|
|
814
|
+
return toRepoPath(relative(root, path));
|
|
815
|
+
}
|
|
816
|
+
// src/catalog/tags.ts
|
|
817
|
+
async function scanTestTags(root, testGlobs, onGitFallback) {
|
|
818
|
+
return (await scanTestRegions(root, testGlobs, onGitFallback)).tags;
|
|
819
|
+
}
|
|
820
|
+
async function scanTestRegions(root, testGlobs, onGitFallback) {
|
|
821
|
+
const tags = /* @__PURE__ */ new Map();
|
|
822
|
+
const regions = /* @__PURE__ */ new Map();
|
|
823
|
+
const problems = [];
|
|
824
|
+
const realRoot = await realpath(root);
|
|
825
|
+
for (const file of await expandGlobs(root, testGlobs, onGitFallback)) {
|
|
826
|
+
const path = join(root, file);
|
|
827
|
+
if (!await resolvesInside(path, realRoot)) continue;
|
|
828
|
+
const source = await readFile(path, "utf8");
|
|
829
|
+
for (const { id, line } of findRuleTags(source)) push(tags, id, {
|
|
830
|
+
file,
|
|
831
|
+
line
|
|
832
|
+
});
|
|
833
|
+
const found = findRuleRegions(source);
|
|
834
|
+
for (const { id, line, endLine } of found.regions) push(regions, id, {
|
|
835
|
+
file,
|
|
836
|
+
line,
|
|
837
|
+
endLine
|
|
838
|
+
});
|
|
839
|
+
problems.push(...found.problems.map((problem) => ({
|
|
840
|
+
...problem,
|
|
841
|
+
file
|
|
842
|
+
})));
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
tags: sortById(tags),
|
|
846
|
+
regions: sortById(regions),
|
|
847
|
+
problems
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function push(index, id, value) {
|
|
851
|
+
const values = index.get(id) ?? [];
|
|
852
|
+
values.push(value);
|
|
853
|
+
index.set(id, values);
|
|
854
|
+
}
|
|
855
|
+
function sortById(index) {
|
|
856
|
+
return new Map([...index].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
857
|
+
}
|
|
858
|
+
var CliError = class extends Error {
|
|
859
|
+
exitCode;
|
|
860
|
+
constructor(message, exitCode = 1) {
|
|
861
|
+
super(message);
|
|
862
|
+
this.name = "CliError";
|
|
863
|
+
this.exitCode = exitCode;
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
async function requireRulebookRoot(io) {
|
|
867
|
+
const root = await findRulebookRoot(io.cwd);
|
|
868
|
+
if (root === void 0) throw new CliError(`${CONFIG_FILE_NAME} not found in ${io.cwd} or any parent directory.`);
|
|
869
|
+
return root;
|
|
870
|
+
}
|
|
871
|
+
async function openCatalog(io) {
|
|
872
|
+
const catalog = await loadCatalog(await requireRulebookRoot(io));
|
|
873
|
+
const count = catalog.issues.length;
|
|
874
|
+
if (count > 0) io.stderr(`${messages(catalog.config.language).catalogWarning(count)}\n`);
|
|
875
|
+
return catalog;
|
|
876
|
+
}
|
|
877
|
+
function findRule(catalog, id) {
|
|
878
|
+
const rule = catalog.rules.find((item) => item.id === id);
|
|
879
|
+
if (rule === void 0) throw new CliError(messages(catalog.config.language).unknownRule(id));
|
|
880
|
+
return rule;
|
|
881
|
+
}
|
|
882
|
+
function writeJson(io, value) {
|
|
883
|
+
io.stdout(`${JSON.stringify(value, null, 2)}\n`);
|
|
884
|
+
}
|
|
885
|
+
function writeLines(io, lines) {
|
|
886
|
+
io.stdout(lines.map((line) => `${line}\n`).join(""));
|
|
887
|
+
}
|
|
888
|
+
function gitFallbackWarning(io, language) {
|
|
889
|
+
const text = messages(language);
|
|
890
|
+
let warned = false;
|
|
891
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
892
|
+
return (reason, submodule) => {
|
|
893
|
+
if (submodule !== void 0) {
|
|
894
|
+
if (!skipped.has(submodule)) {
|
|
895
|
+
skipped.add(submodule);
|
|
896
|
+
io.stderr(`${text.submoduleSkipped(submodule, reason)}\n`);
|
|
897
|
+
}
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (!warned) {
|
|
901
|
+
warned = true;
|
|
902
|
+
io.stderr(`${text.gitFallback(reason)}\n`);
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
// src/commands/lint.ts
|
|
907
|
+
async function lintCommand(io, options) {
|
|
908
|
+
const root = await requireRulebookRoot(io);
|
|
909
|
+
let catalog;
|
|
910
|
+
try {
|
|
911
|
+
catalog = await loadCatalog(root);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
if (!(error instanceof FormatError)) throw error;
|
|
914
|
+
return report(io, error.issues.map((issue) => issueFinding("schema", {
|
|
915
|
+
...issue,
|
|
916
|
+
file: relativeToRoot(root, issue.file)
|
|
917
|
+
}, messages("en"))), options.json, messages("en"));
|
|
918
|
+
}
|
|
919
|
+
const text = messages(catalog.config.language);
|
|
920
|
+
const changed = options.base === void 0 ? void 0 : await changedFiles(catalog.root, options.base, text);
|
|
921
|
+
const warn = gitFallbackWarning(io, catalog.config.language);
|
|
922
|
+
const scan = await scanTestRegions(catalog.root, catalog.config.testGlobs, warn);
|
|
923
|
+
const findings = await lintCatalog(catalog, scan, warn);
|
|
924
|
+
if (options.base !== void 0 && changed !== void 0) {
|
|
925
|
+
const lines = await testLineChanges(catalog, scan, options.base, changed, text);
|
|
926
|
+
findings.push(...pendingImplementation(catalog, scan, options.base, changed.files, lines));
|
|
927
|
+
}
|
|
928
|
+
return report(io, findings, options.json, text);
|
|
929
|
+
}
|
|
930
|
+
function report(io, findings, json, text) {
|
|
931
|
+
const sorted = sortFindings(findings);
|
|
932
|
+
const errors = sorted.filter((finding) => finding.severity === "error").length;
|
|
933
|
+
const warnings = sorted.length - errors;
|
|
934
|
+
if (json) writeJson(io, {
|
|
935
|
+
findings: sorted,
|
|
936
|
+
errors,
|
|
937
|
+
warnings
|
|
938
|
+
});
|
|
939
|
+
else if (sorted.length === 0) writeLines(io, [text.noIssues]);
|
|
940
|
+
else writeLines(io, [...sorted.flatMap((finding) => [formatFinding(finding), ` ${text.hintLabel}: ${finding.hint}`]), text.lintSummary(errors, warnings)]);
|
|
941
|
+
return errors > 0 ? 1 : 0;
|
|
942
|
+
}
|
|
943
|
+
function issueFinding(code, { file, field, message }, text) {
|
|
944
|
+
return error(code, file, field === void 0 ? message : `${text.field(field)} ${message}`, text.catalogHints[code]);
|
|
945
|
+
}
|
|
946
|
+
async function lintCatalog(catalog, scan, onGitFallback) {
|
|
947
|
+
const { tags: tagIndex, problems } = scan ?? await scanTestRegions(catalog.root, catalog.config.testGlobs, onGitFallback);
|
|
948
|
+
const text = messages(catalog.config.language);
|
|
949
|
+
const findings = catalog.issues.map((issue) => issueFinding(issue.code, issue, text));
|
|
950
|
+
for (const { code, id, file, line } of problems) findings.push(code === "unclosed-rule-tag" ? {
|
|
951
|
+
...error(code, file, text.unclosedRuleTag(id), text.unclosedRuleTagHint(id)),
|
|
952
|
+
line
|
|
953
|
+
} : {
|
|
954
|
+
...error(code, file, text.unmatchedEndruleTag(id), text.unmatchedEndruleTagHint(id)),
|
|
955
|
+
line
|
|
956
|
+
});
|
|
957
|
+
const files = await listRepoFiles(catalog.root, onGitFallback);
|
|
958
|
+
const status = new Map(catalog.rules.map((rule) => [rule.id, rule.status]));
|
|
959
|
+
const invalid = new Set(catalog.invalidRuleIds);
|
|
960
|
+
const successors = new Map(catalog.rules.filter((rule) => rule.supersedes !== void 0 && rule.status !== "deprecated").map((rule) => [rule.supersedes, rule.id]));
|
|
961
|
+
for (const rule of catalog.rules) {
|
|
962
|
+
if (rule.status === "active" && !tagIndex.has(rule.id)) findings.push(error("untested-rule", rule.file, text.untestedRule(rule.id), text.untestedRuleHint(rule.id)));
|
|
963
|
+
for (const anchor of rule.anchors) if (!files.some(createGlobMatcher([anchor]))) findings.push(error("unmatched-anchor", rule.file, text.unmatchedAnchor(anchor), text.unmatchedAnchorHint));
|
|
964
|
+
}
|
|
965
|
+
for (const [id, locations] of tagIndex) {
|
|
966
|
+
const ruleStatus = status.get(id);
|
|
967
|
+
if (ruleStatus !== void 0 && ruleStatus !== "deprecated" || invalid.has(id)) continue;
|
|
968
|
+
for (const { file, line } of locations) findings.push(ruleStatus === void 0 ? {
|
|
969
|
+
...error("unknown-rule-tag", file, text.unknownRuleTag(id), text.unknownRuleTagHint),
|
|
970
|
+
line
|
|
971
|
+
} : {
|
|
972
|
+
...error("deprecated-rule-tag", file, text.deprecatedRuleTag(id), text.deprecatedRuleTagHint(id, successors.get(id))),
|
|
973
|
+
line
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
const budget = catalog.config.contextBudget;
|
|
977
|
+
for (const context of catalog.contexts) {
|
|
978
|
+
const count = catalog.rules.filter((rule) => rule.context === context.context).length;
|
|
979
|
+
if (count > budget) findings.push({
|
|
980
|
+
severity: "warning",
|
|
981
|
+
code: "context-over-budget",
|
|
982
|
+
file: context.file,
|
|
983
|
+
message: text.contextOverBudget(context.context, count, budget),
|
|
984
|
+
hint: text.contextOverBudgetHint
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
return findings;
|
|
988
|
+
}
|
|
989
|
+
function pendingImplementation(catalog, scan, base, changed, lines) {
|
|
990
|
+
const text = messages(catalog.config.language);
|
|
991
|
+
return changedActiveRules(catalog, scan, changed).filter((rule) => !(scan.regions.get(rule.id) ?? []).some((region) => (lines.get(region.file) ?? []).some(([start, end]) => start <= region.endLine && end >= region.line))).map((rule) => error("pending-implementation", rule.file, text.pendingImplementation(rule.id, base), text.pendingImplementationHint(rule.id)));
|
|
992
|
+
}
|
|
993
|
+
function changedActiveRules(catalog, scan, changed) {
|
|
994
|
+
const changedSet = new Set(changed);
|
|
995
|
+
return catalog.rules.filter((rule) => rule.status === "active" && changedSet.has(rule.file) && (scan.tags.get(rule.id) ?? []).length > 0);
|
|
996
|
+
}
|
|
997
|
+
async function testLineChanges(catalog, scan, base, changed, text) {
|
|
998
|
+
const changedSet = new Set(changed.files);
|
|
999
|
+
const files = new Set(changedActiveRules(catalog, scan, changed.files).flatMap((rule) => scan.regions.get(rule.id) ?? []).map((region) => region.file).filter((file) => changedSet.has(file)));
|
|
1000
|
+
const lines = /* @__PURE__ */ new Map();
|
|
1001
|
+
for (const file of files) {
|
|
1002
|
+
if (changed.untracked.has(file)) {
|
|
1003
|
+
lines.set(file, [[1, Number.POSITIVE_INFINITY]]);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
try {
|
|
1007
|
+
lines.set(file, await changedLines(catalog.root, base, file));
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
throw new CliError(text.cannotListChanged(base, gitFailureReason(error)));
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return lines;
|
|
1013
|
+
}
|
|
1014
|
+
async function changedFiles(root, base, text) {
|
|
1015
|
+
if (!await isInsideWorkTree(root)) throw new CliError(text.needsWorkTree(root));
|
|
1016
|
+
if (!await isCommitRef(root, base)) throw new CliError(text.unknownRef(base));
|
|
1017
|
+
try {
|
|
1018
|
+
return await changedSince(root, base);
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
throw new CliError(text.cannotListChanged(base, gitFailureReason(error)));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
function formatFinding(finding) {
|
|
1024
|
+
const location = finding.line === void 0 ? finding.file : `${finding.file}:${finding.line}`;
|
|
1025
|
+
return `${finding.severity} ${finding.code} ${location} ${finding.message}`;
|
|
1026
|
+
}
|
|
1027
|
+
function error(code, file, message, hint) {
|
|
1028
|
+
return {
|
|
1029
|
+
severity: "error",
|
|
1030
|
+
code,
|
|
1031
|
+
file,
|
|
1032
|
+
message,
|
|
1033
|
+
hint
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
function sortFindings(findings) {
|
|
1037
|
+
return [...findings].sort((a, b) => compare(a.file, b.file) || (a.line ?? 0) - (b.line ?? 0) || compare(a.code, b.code));
|
|
1038
|
+
}
|
|
1039
|
+
function compare(a, b) {
|
|
1040
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1041
|
+
}
|
|
1042
|
+
export { parseContext as A, findRuleRegions as C, parseRule as D, loadRule as E, messages as F, FormatError as I, formatIssue as L, CONFIG_FILE_NAME as M, loadConfig as N, CONTEXT_FILE_NAME as O, parseConfig as P, RULE_TAG_PATTERN as S, isRuleId as T, expandGlobs as _, CliError as a, runGit as b, openCatalog as c, scanTestRegions as d, scanTestTags as f, createGlobMatcher as g, relativeToRoot as h, pendingImplementation as i, splitFrontmatter as j, loadContext as k, writeJson as l, loadCatalog as m, lintCatalog as n, findRule as o, findRulebookRoot as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, listRepoFiles as v, findRuleTags as w, RULE_MARKER_PATTERN as x, toRepoPath as y };
|