rulebookguard 0.8.0 → 0.10.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/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  type RuleStatus = "proposed" | "active" | "deprecated";
2
2
  type RuleLanguage = "en" | "es";
3
+ type RuleOrigin = "authored" | "extracted";
3
4
  interface ParseOptions {
4
5
  /** Language of the issue messages; `en` when unset. */
5
6
  language?: RuleLanguage | undefined;
@@ -27,6 +28,14 @@ interface RuleFrontmatter {
27
28
  entities: string[];
28
29
  status: RuleStatus;
29
30
  supersedes?: string;
31
+ /** Rules this rule needs in force; `lint` rejects unknown ids and cycles. */
32
+ dependsOn?: string[];
33
+ /** Rules that cannot be active at the same time as this one. */
34
+ conflictsWith?: string[];
35
+ /** `extracted` when the rule was lifted from existing code rather than written from intent. */
36
+ origin?: RuleOrigin;
37
+ /** Where the rule shows in the code or tests, for example `src/x.ts:42`. */
38
+ evidence?: string[];
30
39
  anchors: string[];
31
40
  summary: string;
32
41
  }
@@ -65,18 +74,22 @@ interface RuleTag {
65
74
  interface RuleRegion extends RuleTag {
66
75
  endLine: number;
67
76
  }
77
+ type RegionKind = "rule" | "implements";
68
78
  type RuleTagProblemCode = "unclosed-rule-tag" | "unmatched-endrule-tag";
69
- interface RuleTagProblem extends RuleTag {
70
- code: RuleTagProblemCode;
79
+ type ImplementsTagProblemCode = "unclosed-implements-tag" | "unmatched-endimplements-tag";
80
+ interface RuleTagProblem<Code extends string = RuleTagProblemCode> extends RuleTag {
81
+ code: Code;
71
82
  }
72
- interface RuleRegions {
83
+ interface RuleRegions<Code extends string = RuleTagProblemCode> {
73
84
  regions: RuleRegion[];
74
- problems: RuleTagProblem[];
85
+ problems: RuleTagProblem<Code>[];
75
86
  }
76
87
  export declare const RULE_TAG_PATTERN: RegExp;
77
88
  export declare const RULE_MARKER_PATTERN: RegExp;
89
+ export declare const IMPLEMENTS_MARKER_PATTERN: RegExp;
78
90
  export declare function findRuleTags(source: string): RuleTag[];
79
- export declare function findRuleRegions(source: string): RuleRegions;
91
+ export declare function findRuleRegions(source: string, kind?: "rule"): RuleRegions;
92
+ export declare function findRuleRegions(source: string, kind: "implements"): RuleRegions<ImplementsTagProblemCode>;
80
93
  export declare function rulesForPath(rules: RuleDoc[], path: string): RuleDoc[];
81
94
  export declare function toRepoPath(path: string): string;
82
95
  export declare function createGlobMatcher(globs: string[]): (path: string) => boolean;
@@ -109,8 +122,8 @@ interface TagLocation {
109
122
  interface TagRegion extends TagLocation {
110
123
  endLine: number;
111
124
  }
112
- interface TagProblem extends TagLocation {
113
- code: RuleTagProblemCode;
125
+ interface TagProblem<Code extends string = RuleTagProblemCode> extends TagLocation {
126
+ code: Code;
114
127
  id: string;
115
128
  }
116
129
  type TagIndex = Map<string, TagLocation[]>;
@@ -122,6 +135,11 @@ interface TagScan {
122
135
  }
123
136
  export declare function scanTestTags(root: string, testGlobs: string[], onGitFallback?: GitFallbackListener): Promise<TagIndex>;
124
137
  export declare function scanTestRegions(root: string, testGlobs: string[], onGitFallback?: GitFallbackListener): Promise<TagScan>;
138
+ interface ImplementationScan {
139
+ regions: RegionIndex;
140
+ problems: TagProblem<ImplementsTagProblemCode>[];
141
+ }
142
+ export declare function scanImplementations(root: string, files: string[]): Promise<ImplementationScan>;
125
143
  interface ContextsJson {
126
144
  contexts: {
127
145
  context: string;
@@ -145,6 +163,11 @@ interface ShowJson {
145
163
  entities: string[];
146
164
  anchors: string[];
147
165
  supersedes: string | null;
166
+ dependsOn: string[];
167
+ conflictsWith: string[];
168
+ origin: RuleOrigin | null;
169
+ evidence: string[];
170
+ implementations: TagRegion[];
148
171
  file: string;
149
172
  body: string;
150
173
  };
@@ -153,8 +176,21 @@ interface ForJson {
153
176
  path: string;
154
177
  rules: (RuleSummaryJson & {
155
178
  file: string;
179
+ regions: LineSpanJson[];
180
+ })[];
181
+ }
182
+ interface ForChangedJson {
183
+ base: string;
184
+ files: string[];
185
+ rules: (RuleSummaryJson & {
186
+ file: string;
187
+ files: string[];
156
188
  })[];
157
189
  }
190
+ interface LineSpanJson {
191
+ line: number;
192
+ endLine: number;
193
+ }
158
194
  interface ImpactJson {
159
195
  id: string;
160
196
  files: string[];
@@ -184,4 +220,4 @@ interface LintOptions {
184
220
  export declare function lintCatalog(catalog: Catalog, scan?: TagScan, onGitFallback?: GitFallbackListener): Promise<LintFinding[]>;
185
221
  export declare function pendingImplementation(catalog: Catalog, scan: TagScan, base: string, changed: string[], lines: Map<string, LineSpan[]>): LintFinding[];
186
222
  export declare function formatFinding(finding: LintFinding): string;
187
- export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, ForJson, FormatIssue, Frontmatter, GitFallbackListener, ImpactJson, LintFinding, LintJson, LintOptions, LintSeverity, ListJson, ParseOptions, RegionIndex, RuleDoc, RuleFrontmatter, RuleLanguage, RuleRegion, RuleRegions, RuleStatus, RuleSummaryJson, RuleTag, RuleTagProblem, RuleTagProblemCode, RulebookConfig, ShowJson, TagIndex, TagLocation, TagProblem, TagRegion, TagScan };
223
+ export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, ForChangedJson, ForJson, FormatIssue, Frontmatter, GitFallbackListener, ImpactJson, ImplementationScan, ImplementsTagProblemCode, LintFinding, LintJson, LintOptions, LintSeverity, ListJson, ParseOptions, RegionIndex, RegionKind, RuleDoc, RuleFrontmatter, RuleLanguage, RuleRegion, RuleRegions, RuleStatus, RuleSummaryJson, RuleTag, RuleTagProblem, RuleTagProblemCode, RulebookConfig, ShowJson, TagIndex, TagLocation, TagProblem, TagRegion, TagScan };
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { A as parseContext, C as findRuleRegions, D as parseRule, E as loadRule, I as FormatError, L as formatIssue, M as CONFIG_FILE_NAME, N as loadConfig, O as CONTEXT_FILE_NAME, P as parseConfig, S as RULE_TAG_PATTERN, T as isRuleId, _ as expandGlobs, d as scanTestRegions, f as scanTestTags, g as createGlobMatcher, h as relativeToRoot, i as pendingImplementation, j as splitFrontmatter, k as loadContext, m as loadCatalog, n as lintCatalog, p as findRulebookRoot, t as formatFinding, v as listRepoFiles, w as findRuleTags, x as RULE_MARKER_PATTERN, y as toRepoPath } from "./lint-DfibQDW3.mjs";
2
- import { t as rulesForPath } from "./anchors-C2j9c5MI.mjs";
3
- export { CONFIG_FILE_NAME, CONTEXT_FILE_NAME, FormatError, RULE_MARKER_PATTERN, RULE_TAG_PATTERN, createGlobMatcher, expandGlobs, findRuleRegions, findRuleTags, findRulebookRoot, formatFinding, formatIssue, isRuleId, lintCatalog, listRepoFiles, loadCatalog, loadConfig, loadContext, loadRule, parseConfig, parseContext, parseRule, pendingImplementation, relativeToRoot, rulesForPath, scanTestRegions, scanTestTags, splitFrontmatter, toRepoPath };
1
+ import { A as loadRule, B as FormatError, D as findRuleRegions, E as RULE_TAG_PATTERN, F as splitFrontmatter, I as CONFIG_FILE_NAME, L as loadConfig, M as CONTEXT_FILE_NAME, N as loadContext, O as findRuleTags, P as parseContext, R as parseConfig, S as toRepoPath, T as RULE_MARKER_PATTERN, V as formatIssue, _ as loadCatalog, a as pendingImplementation, b as expandGlobs, g as findRulebookRoot, h as scanTestTags, j as parseRule, k as isRuleId, m as scanTestRegions, n as formatFinding, p as scanImplementations, r as lintCatalog, v as relativeToRoot, w as IMPLEMENTS_MARKER_PATTERN, x as listRepoFiles, y as createGlobMatcher } from "./lint-BPdk6XxH.mjs";
2
+ import { t as rulesForPath } from "./anchors-jf2bKfyg.mjs";
3
+ export { CONFIG_FILE_NAME, CONTEXT_FILE_NAME, FormatError, IMPLEMENTS_MARKER_PATTERN, RULE_MARKER_PATTERN, RULE_TAG_PATTERN, createGlobMatcher, expandGlobs, findRuleRegions, findRuleTags, findRulebookRoot, formatFinding, formatIssue, isRuleId, lintCatalog, listRepoFiles, loadCatalog, loadConfig, loadContext, loadRule, parseConfig, parseContext, parseRule, pendingImplementation, relativeToRoot, rulesForPath, scanImplementations, scanTestRegions, scanTestTags, splitFrontmatter, toRepoPath };
@@ -1,4 +1,4 @@
1
- import { access, lstat, readFile, readdir, realpath } from "node:fs/promises";
1
+ import { access, lstat, readFile, readdir, realpath, stat } from "node:fs/promises";
2
2
  import { Ajv2020 } from "ajv/dist/2020.js";
3
3
  import { YAMLParseError, parse } from "yaml";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -115,6 +115,22 @@ var rule_schema_default = {
115
115
  "deprecated"
116
116
  ] },
117
117
  "supersedes": { "$ref": "#/$defs/ruleId" },
118
+ "dependsOn": {
119
+ "type": "array",
120
+ "items": { "$ref": "#/$defs/ruleId" }
121
+ },
122
+ "conflictsWith": {
123
+ "type": "array",
124
+ "items": { "$ref": "#/$defs/ruleId" }
125
+ },
126
+ "origin": { "enum": ["authored", "extracted"] },
127
+ "evidence": {
128
+ "type": "array",
129
+ "items": {
130
+ "type": "string",
131
+ "minLength": 1
132
+ }
133
+ },
118
134
  "anchors": {
119
135
  "type": "array",
120
136
  "items": {
@@ -171,6 +187,8 @@ const MESSAGES = {
171
187
  noContexts: "No contexts found. Create one with \"rulebookguard new <context>\".",
172
188
  noRules: "No rules found.",
173
189
  noActiveRulesFor: (path) => `No active rules anchored to ${path}.`,
190
+ noActiveRulesForChanged: (ref) => `No active rules anchored to the files changed since ${ref}.`,
191
+ lines: (spans) => `lines ${spans}`,
174
192
  filesMatched: (count) => `Files matched by anchors (${count}):`,
175
193
  testsTagging: (id, count) => `Tests tagging ${id} (${count}):`,
176
194
  none: "(none)",
@@ -188,8 +206,35 @@ const MESSAGES = {
188
206
  deprecatedRuleTag: (id) => `tags ${id}, which is deprecated`,
189
207
  unclosedRuleTag: (id) => `@rule ${id} has no matching @endrule ${id}`,
190
208
  unmatchedEndruleTag: (id) => `@endrule ${id} has no open @rule ${id} before it`,
209
+ unknownImplementsTag: (id) => `marks code as implementing ${id}, which does not exist`,
210
+ deprecatedImplementsTag: (id) => `marks code as implementing ${id}, which is deprecated`,
211
+ unclosedImplementsTag: (id) => `@implements ${id} has no matching @endimplements ${id}`,
212
+ unmatchedEndimplementsTag: (id) => `@endimplements ${id} has no open @implements ${id} before it`,
213
+ implementsOutsideAnchors: (id) => `marks code as implementing ${id}, but no anchor of ${id} matches this file`,
191
214
  contextOverBudget: (context, count, budget) => `context "${context}" has ${count} rules, more than the contextBudget of ${budget}`,
192
215
  pendingImplementation: (id, base) => `rule ${id} changed since ${base} but none of the tests tagging it changed`,
216
+ unknownRuleReference: (field, id) => `${field} names ${id}, which does not exist`,
217
+ selfRuleReference: (field) => `${field} names the rule itself`,
218
+ ruleReferenceHint: (field) => `fix the id, or remove it from ${field}`,
219
+ dependencyCycle: (cycle) => `dependsOn forms a cycle: ${cycle.join(" → ")}`,
220
+ dependencyCycleHint: "remove one dependsOn link of the cycle",
221
+ activeRuleConflict: (a, b) => `active rules ${a} and ${b} conflict`,
222
+ activeRuleConflictHint: "deprecate one of them or set it to proposed, or remove the conflictsWith entry",
223
+ noGherkinScenarios: "No rule has Gherkin scenarios.",
224
+ generatedFeature: (file) => `Generated by "rulebookguard export gherkin" from ${file}; edit the rule instead.`,
225
+ featureKeyword: "Feature",
226
+ noActiveRules: "No active rule to export.",
227
+ removedExport: (path) => `removed ${path}`,
228
+ handWrittenExport: (path, format) => `${path} was not generated by "rulebookguard export ${format}"; move it away and run the export again.`,
229
+ generatedRule: (format, file) => `Generated by "rulebookguard export ${format}" from ${file}; edit the rule instead.`,
230
+ generatedAgentsMd: "Generated by \"rulebookguard export agents-md\"; edit the rules instead.",
231
+ agentsMdHeading: "Business rules",
232
+ agentsMdIntro: "Before changing a file, follow the active rules anchored to it:",
233
+ agentsMdSeeFile: (file) => `See \`${file}\`.`,
234
+ agentsMdNoAnchors: "no anchors",
235
+ noAnchoredRules: "No active rule with anchors to export.",
236
+ unanchoredRuleSkipped: (id) => `${id} is active but has no anchors, so it was not exported.`,
237
+ invalidAgentsMdMarkers: (file, start, end) => `${file} must have one "${start}" followed by one "${end}", or neither; fix it and run the export again.`,
193
238
  noIssues: "No issues found.",
194
239
  hintLabel: "hint",
195
240
  catalogHints: {
@@ -207,10 +252,15 @@ const MESSAGES = {
207
252
  deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `tag the rule that replaces ${id}, or remove the tag` : `tag ${successor}, which supersedes ${id}, instead`,
208
253
  unclosedRuleTagHint: (id) => `add "@endrule ${id}" after the last line of the test, or close the inner regions first`,
209
254
  unmatchedEndruleTagHint: (id) => `add "@rule ${id}" before the test, or remove the stray @endrule`,
255
+ unknownImplementsTagHint: "fix the id in the marker, or create the rule it refers to",
256
+ deprecatedImplementsTagHint: (id, successor) => successor === void 0 ? `mark the code with the rule that replaces ${id}, or remove the region` : `mark the code with ${successor}, which supersedes ${id}, instead`,
257
+ unclosedImplementsTagHint: (id) => `add "@endimplements ${id}" after the last line of the code that enforces the rule, or close the inner regions first`,
258
+ unmatchedEndimplementsTagHint: (id) => `add "@implements ${id}" before the code that enforces the rule, or remove the stray @endimplements`,
259
+ implementsOutsideAnchorsHint: (id) => `add an anchor to ${id} that matches this file, or move the region to a file it anchors`,
210
260
  contextOverBudgetHint: "split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml",
211
261
  pendingImplementationHint: (id) => `update or add a test inside an "@rule ${id}" … "@endrule ${id}" region that covers the changed rule`,
212
262
  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.`,
263
+ needsWorkTree: (command, root) => `${command} needs a git work tree, but ${root} is not inside one.`,
214
264
  unknownRef: (ref) => `Unknown git ref "${ref}".`,
215
265
  cannotListChanged: (ref, reason) => `Cannot list the files changed since "${ref}": ${reason}`
216
266
  },
@@ -245,6 +295,8 @@ const MESSAGES = {
245
295
  noContexts: "No se encontraron contextos. Crea uno con \"rulebookguard new <contexto>\".",
246
296
  noRules: "No se encontraron reglas.",
247
297
  noActiveRulesFor: (path) => `No hay reglas activas ancladas a ${path}.`,
298
+ noActiveRulesForChanged: (ref) => `No hay reglas activas ancladas a los archivos cambiados desde ${ref}.`,
299
+ lines: (spans) => `líneas ${spans}`,
248
300
  filesMatched: (count) => `Archivos que cubren los anchors (${count}):`,
249
301
  testsTagging: (id, count) => `Tests que etiquetan ${id} (${count}):`,
250
302
  none: "(ninguno)",
@@ -262,8 +314,35 @@ const MESSAGES = {
262
314
  deprecatedRuleTag: (id) => `etiqueta ${id}, que está deprecada`,
263
315
  unclosedRuleTag: (id) => `@rule ${id} no tiene su @endrule ${id}`,
264
316
  unmatchedEndruleTag: (id) => `@endrule ${id} no tiene un @rule ${id} abierto antes`,
317
+ unknownImplementsTag: (id) => `marca código como implementación de ${id}, que no existe`,
318
+ deprecatedImplementsTag: (id) => `marca código como implementación de ${id}, que está deprecada`,
319
+ unclosedImplementsTag: (id) => `@implements ${id} no tiene su @endimplements ${id}`,
320
+ unmatchedEndimplementsTag: (id) => `@endimplements ${id} no tiene un @implements ${id} abierto antes`,
321
+ implementsOutsideAnchors: (id) => `marca código como implementación de ${id}, pero ningún anchor de ${id} coincide con este archivo`,
265
322
  contextOverBudget: (context, count, budget) => `el contexto "${context}" tiene ${count} reglas, más que el contextBudget de ${budget}`,
266
323
  pendingImplementation: (id, base) => `la regla ${id} cambió desde ${base} pero no cambió ninguno de los tests que la etiquetan`,
324
+ unknownRuleReference: (field, id) => `${field} nombra a ${id}, que no existe`,
325
+ selfRuleReference: (field) => `${field} nombra a la propia regla`,
326
+ ruleReferenceHint: (field) => `corrige el id o quítalo de ${field}`,
327
+ dependencyCycle: (cycle) => `dependsOn forma un ciclo: ${cycle.join(" → ")}`,
328
+ dependencyCycleHint: "quita un vínculo dependsOn del ciclo",
329
+ activeRuleConflict: (a, b) => `las reglas activas ${a} y ${b} están en conflicto`,
330
+ activeRuleConflictHint: "depreca una de ellas o déjala en proposed, o quita la entrada de conflictsWith",
331
+ noGherkinScenarios: "Ninguna regla tiene escenarios Gherkin.",
332
+ generatedFeature: (file) => `Generado por "rulebookguard export gherkin" desde ${file}; edita la regla en su lugar.`,
333
+ featureKeyword: "Característica",
334
+ noActiveRules: "Ninguna regla activa para exportar.",
335
+ removedExport: (path) => `eliminado ${path}`,
336
+ handWrittenExport: (path, format) => `${path} no fue generado por "rulebookguard export ${format}"; muévelo y vuelve a exportar.`,
337
+ generatedRule: (format, file) => `Generado por "rulebookguard export ${format}" desde ${file}; edita la regla en su lugar.`,
338
+ generatedAgentsMd: "Generado por \"rulebookguard export agents-md\"; edita las reglas en su lugar.",
339
+ agentsMdHeading: "Reglas de negocio",
340
+ agentsMdIntro: "Antes de cambiar un archivo, sigue las reglas activas ancladas a él:",
341
+ agentsMdSeeFile: (file) => `Ver \`${file}\`.`,
342
+ agentsMdNoAnchors: "sin anchors",
343
+ noAnchoredRules: "Ninguna regla activa con anchors para exportar.",
344
+ unanchoredRuleSkipped: (id) => `${id} está activa pero no tiene anchors, así que no se exportó.`,
345
+ invalidAgentsMdMarkers: (file, start, end) => `${file} debe tener un "${start}" seguido de un "${end}", o ninguno; corrígelo y vuelve a exportar.`,
267
346
  noIssues: "No se encontraron problemas.",
268
347
  hintLabel: "sugerencia",
269
348
  catalogHints: {
@@ -281,10 +360,15 @@ const MESSAGES = {
281
360
  deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `etiqueta la regla que reemplaza a ${id} o quita la etiqueta` : `etiqueta ${successor}, que reemplaza a ${id}`,
282
361
  unclosedRuleTagHint: (id) => `agrega "@endrule ${id}" después de la última línea del test, o cierra antes las regiones internas`,
283
362
  unmatchedEndruleTagHint: (id) => `agrega "@rule ${id}" antes del test, o quita el @endrule que sobra`,
363
+ unknownImplementsTagHint: "corrige el id del marcador o crea la regla a la que se refiere",
364
+ deprecatedImplementsTagHint: (id, successor) => successor === void 0 ? `marca el código con la regla que reemplaza a ${id} o quita la región` : `marca el código con ${successor}, que reemplaza a ${id}`,
365
+ unclosedImplementsTagHint: (id) => `agrega "@endimplements ${id}" después de la última línea del código que cumple la regla, o cierra antes las regiones internas`,
366
+ unmatchedEndimplementsTagHint: (id) => `agrega "@implements ${id}" antes del código que cumple la regla, o quita el @endimplements que sobra`,
367
+ implementsOutsideAnchorsHint: (id) => `agrega a ${id} un anchor que coincida con este archivo, o mueve la región a un archivo que ancle`,
284
368
  contextOverBudgetHint: "divide el contexto en otros más chicos o sube contextBudget en rulebookguard.config.yaml",
285
369
  pendingImplementationHint: (id) => `actualiza o agrega un test dentro de una región "@rule ${id}" … "@endrule ${id}" que cubra el cambio de la regla`,
286
370
  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.`,
371
+ needsWorkTree: (command, root) => `${command} necesita un work tree de git, pero ${root} no está en uno.`,
288
372
  unknownRef: (ref) => `La referencia de git "${ref}" no existe.`,
289
373
  cannotListChanged: (ref, reason) => `No se pudieron listar los archivos cambiados desde "${ref}": ${reason}`
290
374
  }
@@ -429,10 +513,16 @@ async function loadRule(path, options) {
429
513
  }
430
514
  // src/format/tag.ts
431
515
  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;
516
+ const RULE_MARKER_PATTERN = markerPattern("rule");
517
+ const IMPLEMENTS_MARKER_PATTERN = markerPattern("implements");
518
+ function markerPattern(kind) {
519
+ return new RegExp(`(?<![\\w@.-])@(${kind}|end${kind})\\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\\w-])`, "g");
520
+ }
521
+ const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|#|--|;|<!--|%)/;
433
522
  function findRuleTags(source) {
434
523
  const tags = [];
435
524
  source.split(/\r?\n/).forEach((text, index) => {
525
+ if (!COMMENT_LINE.test(text)) return;
436
526
  for (const match of text.matchAll(RULE_TAG_PATTERN)) tags.push({
437
527
  id: match[1],
438
528
  line: index + 1
@@ -440,15 +530,17 @@ function findRuleTags(source) {
440
530
  });
441
531
  return tags;
442
532
  }
443
- function findRuleRegions(source) {
533
+ function findRuleRegions(source, kind = "rule") {
534
+ const pattern = kind === "rule" ? RULE_MARKER_PATTERN : IMPLEMENTS_MARKER_PATTERN;
444
535
  const regions = [];
445
536
  const problems = [];
446
537
  const open = [];
447
538
  source.split(/\r?\n/).forEach((text, index) => {
539
+ if (!COMMENT_LINE.test(text)) return;
448
540
  const line = index + 1;
449
- for (const match of text.matchAll(RULE_MARKER_PATTERN)) {
541
+ for (const match of text.matchAll(pattern)) {
450
542
  const id = match[2];
451
- if (match[1] === "rule") {
543
+ if (match[1] === kind) {
452
544
  open.push({
453
545
  id,
454
546
  line
@@ -458,14 +550,14 @@ function findRuleRegions(source) {
458
550
  const at = open.map((tag) => tag.id).lastIndexOf(id);
459
551
  if (at === -1) {
460
552
  problems.push({
461
- code: "unmatched-endrule-tag",
553
+ code: `unmatched-end${kind}-tag`,
462
554
  id,
463
555
  line
464
556
  });
465
557
  continue;
466
558
  }
467
559
  for (const inner of open.splice(at + 1)) problems.push({
468
- code: "unclosed-rule-tag",
560
+ code: `unclosed-${kind}-tag`,
469
561
  ...inner
470
562
  });
471
563
  const start = open.pop();
@@ -476,7 +568,7 @@ function findRuleRegions(source) {
476
568
  }
477
569
  });
478
570
  for (const tag of open) problems.push({
479
- code: "unclosed-rule-tag",
571
+ code: `unclosed-${kind}-tag`,
480
572
  ...tag
481
573
  });
482
574
  problems.sort((a, b) => a.line - b.line);
@@ -523,10 +615,11 @@ async function isCommitRef(cwd, ref) {
523
615
  return false;
524
616
  }
525
617
  }
526
- async function changedSince(cwd, ref) {
618
+ async function changedSince(cwd, ref, { bothRenamePaths = false } = {}) {
527
619
  const changed = await gitPaths(cwd, "diff", [
528
620
  "--name-only",
529
621
  "--relative",
622
+ ...bothRenamePaths ? ["--no-renames"] : [],
530
623
  ref,
531
624
  "--"
532
625
  ]);
@@ -847,6 +940,30 @@ async function scanTestRegions(root, testGlobs, onGitFallback) {
847
940
  problems
848
941
  };
849
942
  }
943
+ async function scanImplementations(root, files) {
944
+ const regions = /* @__PURE__ */ new Map();
945
+ const problems = [];
946
+ const realRoot = await realpath(root);
947
+ for (const file of files) {
948
+ const path = join(root, file);
949
+ if (!await resolvesInside(path, realRoot)) continue;
950
+ if (!(await stat(path)).isFile()) continue;
951
+ const found = findRuleRegions(await readFile(path, "utf8"), "implements");
952
+ for (const { id, line, endLine } of found.regions) push(regions, id, {
953
+ file,
954
+ line,
955
+ endLine
956
+ });
957
+ problems.push(...found.problems.map((problem) => ({
958
+ ...problem,
959
+ file
960
+ })));
961
+ }
962
+ return {
963
+ regions: sortById(regions),
964
+ problems
965
+ };
966
+ }
850
967
  function push(index, id, value) {
851
968
  const values = index.get(id) ?? [];
852
969
  values.push(value);
@@ -885,6 +1002,10 @@ function writeJson(io, value) {
885
1002
  function writeLines(io, lines) {
886
1003
  io.stdout(lines.map((line) => `${line}\n`).join(""));
887
1004
  }
1005
+ function shownPath(cwd, file) {
1006
+ const fromCwd = relative(cwd, file);
1007
+ return fromCwd.startsWith("..") || isAbsolute(fromCwd) ? file : toRepoPath(fromCwd);
1008
+ }
888
1009
  function gitFallbackWarning(io, language) {
889
1010
  const text = messages(language);
890
1011
  let warned = false;
@@ -917,7 +1038,7 @@ async function lintCommand(io, options) {
917
1038
  }, messages("en"))), options.json, messages("en"));
918
1039
  }
919
1040
  const text = messages(catalog.config.language);
920
- const changed = options.base === void 0 ? void 0 : await changedFiles(catalog.root, options.base, text);
1041
+ const changed = options.base === void 0 ? void 0 : await changedFiles(catalog.root, options.base, text, "lint --base");
921
1042
  const warn = gitFallbackWarning(io, catalog.config.language);
922
1043
  const scan = await scanTestRegions(catalog.root, catalog.config.testGlobs, warn);
923
1044
  const findings = await lintCatalog(catalog, scan, warn);
@@ -973,6 +1094,8 @@ async function lintCatalog(catalog, scan, onGitFallback) {
973
1094
  line
974
1095
  });
975
1096
  }
1097
+ findings.push(...await implementations(catalog, files, invalid, successors, text));
1098
+ findings.push(...ruleRelations(catalog, invalid, text));
976
1099
  const budget = catalog.config.contextBudget;
977
1100
  for (const context of catalog.contexts) {
978
1101
  const count = catalog.rules.filter((rule) => rule.context === context.context).length;
@@ -986,6 +1109,76 @@ async function lintCatalog(catalog, scan, onGitFallback) {
986
1109
  }
987
1110
  return findings;
988
1111
  }
1112
+ async function implementations(catalog, files, invalid, successors, text) {
1113
+ const anchored = createGlobMatcher(catalog.rules.flatMap((rule) => rule.anchors));
1114
+ const scan = await scanImplementations(catalog.root, files.filter(anchored));
1115
+ const byId = new Map(catalog.rules.map((rule) => [rule.id, rule]));
1116
+ const findings = scan.problems.map(({ code, id, file, line }) => ({
1117
+ ...code === "unclosed-implements-tag" ? error(code, file, text.unclosedImplementsTag(id), text.unclosedImplementsTagHint(id)) : error(code, file, text.unmatchedEndimplementsTag(id), text.unmatchedEndimplementsTagHint(id)),
1118
+ line
1119
+ }));
1120
+ for (const [id, regions] of scan.regions) {
1121
+ const rule = byId.get(id);
1122
+ if (rule === void 0 && invalid.has(id)) continue;
1123
+ for (const { file, line } of regions) if (rule === void 0) findings.push({
1124
+ ...error("unknown-implements-tag", file, text.unknownImplementsTag(id), text.unknownImplementsTagHint),
1125
+ line
1126
+ });
1127
+ else if (rule.status === "deprecated") findings.push({
1128
+ ...error("deprecated-implements-tag", file, text.deprecatedImplementsTag(id), text.deprecatedImplementsTagHint(id, successors.get(id))),
1129
+ line
1130
+ });
1131
+ else if (!createGlobMatcher(rule.anchors)(file)) findings.push({
1132
+ ...error("implements-outside-anchors", file, text.implementsOutsideAnchors(id), text.implementsOutsideAnchorsHint(id)),
1133
+ line
1134
+ });
1135
+ }
1136
+ return findings;
1137
+ }
1138
+ const RELATION_FIELDS = ["dependsOn", "conflictsWith"];
1139
+ function ruleRelations(catalog, invalid, text) {
1140
+ const byId = new Map(catalog.rules.map((rule) => [rule.id, rule]));
1141
+ const findings = [];
1142
+ for (const rule of catalog.rules) for (const field of RELATION_FIELDS) for (const id of rule[field] ?? []) if (id === rule.id) findings.push(error("invalid-rule-reference", rule.file, text.selfRuleReference(field), text.ruleReferenceHint(field)));
1143
+ else if (!byId.has(id) && !invalid.has(id)) findings.push(error("invalid-rule-reference", rule.file, text.unknownRuleReference(field, id), text.ruleReferenceHint(field)));
1144
+ for (const cycle of dependencyCycles(byId)) findings.push(error("dependency-cycle", byId.get(cycle[0]).file, text.dependencyCycle([...cycle, cycle[0]]), text.dependencyCycleHint));
1145
+ const pairs = /* @__PURE__ */ new Set();
1146
+ for (const rule of catalog.rules) {
1147
+ if (rule.status !== "active") continue;
1148
+ for (const id of rule.conflictsWith ?? []) {
1149
+ if (id === rule.id || byId.get(id)?.status !== "active") continue;
1150
+ const [first, second] = [rule.id, id].sort(compare);
1151
+ const key = `${first} ${second}`;
1152
+ if (pairs.has(key)) continue;
1153
+ pairs.add(key);
1154
+ findings.push(error("active-rule-conflict", byId.get(first).file, text.activeRuleConflict(first, second), text.activeRuleConflictHint));
1155
+ }
1156
+ }
1157
+ return findings;
1158
+ }
1159
+ function dependencyCycles(byId) {
1160
+ const state = /* @__PURE__ */ new Map();
1161
+ const stack = [];
1162
+ const cycles = /* @__PURE__ */ new Map();
1163
+ const visit = (id) => {
1164
+ state.set(id, "visiting");
1165
+ stack.push(id);
1166
+ for (const next of byId.get(id)?.dependsOn ?? []) {
1167
+ if (next === id || !byId.has(next)) continue;
1168
+ const seen = state.get(next);
1169
+ if (seen === "visiting") {
1170
+ const cycle = stack.slice(stack.indexOf(next));
1171
+ const start = cycle.indexOf([...cycle].sort(compare)[0]);
1172
+ const rotated = [...cycle.slice(start), ...cycle.slice(0, start)];
1173
+ cycles.set(rotated.join(" "), rotated);
1174
+ } else if (seen === void 0) visit(next);
1175
+ }
1176
+ stack.pop();
1177
+ state.set(id, "done");
1178
+ };
1179
+ for (const id of [...byId.keys()].sort(compare)) if (!state.has(id)) visit(id);
1180
+ return [...cycles.values()];
1181
+ }
989
1182
  function pendingImplementation(catalog, scan, base, changed, lines) {
990
1183
  const text = messages(catalog.config.language);
991
1184
  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)));
@@ -1011,11 +1204,11 @@ async function testLineChanges(catalog, scan, base, changed, text) {
1011
1204
  }
1012
1205
  return lines;
1013
1206
  }
1014
- async function changedFiles(root, base, text) {
1015
- if (!await isInsideWorkTree(root)) throw new CliError(text.needsWorkTree(root));
1207
+ async function changedFiles(root, base, text, command, options) {
1208
+ if (!await isInsideWorkTree(root)) throw new CliError(text.needsWorkTree(command, root));
1016
1209
  if (!await isCommitRef(root, base)) throw new CliError(text.unknownRef(base));
1017
1210
  try {
1018
- return await changedSince(root, base);
1211
+ return await changedSince(root, base, options);
1019
1212
  } catch (error) {
1020
1213
  throw new CliError(text.cannotListChanged(base, gitFailureReason(error)));
1021
1214
  }
@@ -1039,4 +1232,4 @@ function sortFindings(findings) {
1039
1232
  function compare(a, b) {
1040
1233
  return a < b ? -1 : a > b ? 1 : 0;
1041
1234
  }
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 };
1235
+ export { loadRule as A, FormatError as B, runGit as C, findRuleRegions as D, RULE_TAG_PATTERN as E, splitFrontmatter as F, CONFIG_FILE_NAME as I, loadConfig as L, CONTEXT_FILE_NAME as M, loadContext as N, findRuleTags as O, parseContext as P, parseConfig as R, toRepoPath as S, RULE_MARKER_PATTERN as T, formatIssue as V, loadCatalog as _, pendingImplementation as a, expandGlobs as b, gitFallbackWarning as c, writeJson as d, writeLines as f, findRulebookRoot as g, scanTestTags as h, lintCommand as i, parseRule as j, isRuleId as k, openCatalog as l, scanTestRegions as m, formatFinding as n, CliError as o, scanImplementations as p, lintCatalog as r, findRule as s, changedFiles as t, shownPath as u, relativeToRoot as v, IMPLEMENTS_MARKER_PATTERN as w, listRepoFiles as x, createGlobMatcher as y, messages as z };
@@ -1,4 +1,4 @@
1
- import { _ as expandGlobs, a as CliError, b as runGit, c as openCatalog, d as scanTestRegions, n as lintCatalog, s as gitFallbackWarning, u as writeLines } from "./lint-DfibQDW3.mjs";
1
+ import { C as runGit, S as toRepoPath, b as expandGlobs, c as gitFallbackWarning, f as writeLines, l as openCatalog, m as scanTestRegions, o as CliError, r as lintCatalog } from "./lint-BPdk6XxH.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
4
4
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -84,6 +84,9 @@ const TEXT = {
84
84
  entities: "Entities",
85
85
  supersedes: "Supersedes",
86
86
  supersededBy: "Superseded by",
87
+ origin: "Origin",
88
+ notValidated: "not validated by the business yet",
89
+ evidence: "Evidence",
87
90
  ruleFile: "Rule file",
88
91
  ruleText: "Rule",
89
92
  anchors: "Anchors",
@@ -123,6 +126,9 @@ const TEXT = {
123
126
  entities: "Entidades",
124
127
  supersedes: "Reemplaza a",
125
128
  supersededBy: "Reemplazada por",
129
+ origin: "Origen",
130
+ notValidated: "sin validar todavía por el negocio",
131
+ evidence: "Evidencia",
126
132
  ruleFile: "Archivo de la regla",
127
133
  ruleText: "Regla",
128
134
  anchors: "Anclas",
@@ -231,6 +237,8 @@ function rulePage({ rule, files, tests, history, supersedes, supersededBy }, tex
231
237
  ];
232
238
  if (supersedes.length > 0) rows.push([text.supersedes, supersedes.map(ruleLink).join(" → ")]);
233
239
  if (supersededBy !== void 0) rows.push([text.supersededBy, ruleLink(supersededBy)]);
240
+ if (rule.origin !== void 0) rows.push([text.origin, rule.origin === "extracted" && rule.status === "proposed" ? `${rule.origin}, ${text.notValidated}` : rule.origin]);
241
+ const evidence = rule.evidence ?? [];
234
242
  return page(rule.id, rule.summary, [
235
243
  `**${cell(rule.summary)}**`,
236
244
  "",
@@ -242,6 +250,12 @@ function rulePage({ rule, files, tests, history, supersedes, supersededBy }, tex
242
250
  "",
243
251
  demote(rule.body.trim()),
244
252
  "",
253
+ ...evidence.length === 0 ? [] : [
254
+ `## ${text.evidence}`,
255
+ "",
256
+ ...list(evidence.map((item) => `\`${item}\``), text.none),
257
+ ""
258
+ ],
245
259
  `## ${text.anchors}`,
246
260
  "",
247
261
  ...list(rule.anchors.map((anchor) => `\`${anchor}\``), text.none),
@@ -403,7 +417,7 @@ async function siteCommand(io, options, until = untilInterrupted()) {
403
417
  const catalog = await openCatalog(io);
404
418
  const out = resolve(io.cwd, options.out ?? "site");
405
419
  const fromCwd = relative(io.cwd, out);
406
- const shown = fromCwd === "" ? "." : fromCwd.startsWith("..") || isAbsolute(fromCwd) ? out : fromCwd;
420
+ const shown = fromCwd === "" ? "." : fromCwd.startsWith("..") || isAbsolute(fromCwd) ? out : toRepoPath(fromCwd);
407
421
  await assertReplaceable(out, shown);
408
422
  const site = await importSitePackage([catalog.root, dirname(fileURLToPath(import.meta.url))]);
409
423
  const language = catalog.config.language ?? "en";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Keep business rules as the single source of truth, bound to code and tests.",
5
5
  "homepage": "https://github.com/JuanCJR/rulebookguard#readme",
6
6
  "bugs": {
@@ -36,14 +36,14 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@modelcontextprotocol/client": "2.3.1",
39
- "@rulebookguard/site": "0.8.0",
39
+ "@rulebookguard/site": "0.10.0",
40
40
  "@types/node": "^20.19.43",
41
41
  "@types/picomatch": "^4.0.3",
42
42
  "typescript": "^7.0.2",
43
43
  "vite-plus": "^1.4.0"
44
44
  },
45
45
  "peerDependencies": {
46
- "@rulebookguard/site": "^0.8.0"
46
+ "@rulebookguard/site": "^0.10.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@rulebookguard/site": {
@@ -12,6 +12,10 @@
12
12
  "entities": { "type": "array", "items": { "type": "string", "minLength": 1 } },
13
13
  "status": { "enum": ["proposed", "active", "deprecated"] },
14
14
  "supersedes": { "$ref": "#/$defs/ruleId" },
15
+ "dependsOn": { "type": "array", "items": { "$ref": "#/$defs/ruleId" } },
16
+ "conflictsWith": { "type": "array", "items": { "$ref": "#/$defs/ruleId" } },
17
+ "origin": { "enum": ["authored", "extracted"] },
18
+ "evidence": { "type": "array", "items": { "type": "string", "minLength": 1 } },
15
19
  "anchors": { "type": "array", "items": { "type": "string", "minLength": 1 } },
16
20
  "summary": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]*$" }
17
21
  },