rulebookguard 0.7.0 → 0.9.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,13 @@ interface ForJson {
153
176
  path: string;
154
177
  rules: (RuleSummaryJson & {
155
178
  file: string;
179
+ regions: LineSpanJson[];
156
180
  })[];
157
181
  }
182
+ interface LineSpanJson {
183
+ line: number;
184
+ endLine: number;
185
+ }
158
186
  interface ImpactJson {
159
187
  id: string;
160
188
  files: string[];
@@ -184,4 +212,4 @@ interface LintOptions {
184
212
  export declare function lintCatalog(catalog: Catalog, scan?: TagScan, onGitFallback?: GitFallbackListener): Promise<LintFinding[]>;
185
213
  export declare function pendingImplementation(catalog: Catalog, scan: TagScan, base: string, changed: string[], lines: Map<string, LineSpan[]>): LintFinding[];
186
214
  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 };
215
+ export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, 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 parseRule, B as formatIssue, C as IMPLEMENTS_MARKER_PATTERN, D as findRuleTags, E as findRuleRegions, F as CONFIG_FILE_NAME, I as loadConfig, L as parseConfig, M as loadContext, N as parseContext, O as isRuleId, P as splitFrontmatter, T as RULE_TAG_PATTERN, _ as relativeToRoot, b as listRepoFiles, f as scanImplementations, g as loadCatalog, h as findRulebookRoot, i as pendingImplementation, j as CONTEXT_FILE_NAME, k as loadRule, m as scanTestTags, n as lintCatalog, p as scanTestRegions, t as formatFinding, v as createGlobMatcher, w as RULE_MARKER_PATTERN, x as toRepoPath, y as expandGlobs, z as FormatError } from "./lint-CaJnNLmU.mjs";
2
+ import { t as rulesForPath } from "./anchors-DQwbqb7w.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,7 @@ 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
+ lines: (spans) => `lines ${spans}`,
174
191
  filesMatched: (count) => `Files matched by anchors (${count}):`,
175
192
  testsTagging: (id, count) => `Tests tagging ${id} (${count}):`,
176
193
  none: "(none)",
@@ -188,8 +205,35 @@ const MESSAGES = {
188
205
  deprecatedRuleTag: (id) => `tags ${id}, which is deprecated`,
189
206
  unclosedRuleTag: (id) => `@rule ${id} has no matching @endrule ${id}`,
190
207
  unmatchedEndruleTag: (id) => `@endrule ${id} has no open @rule ${id} before it`,
208
+ unknownImplementsTag: (id) => `marks code as implementing ${id}, which does not exist`,
209
+ deprecatedImplementsTag: (id) => `marks code as implementing ${id}, which is deprecated`,
210
+ unclosedImplementsTag: (id) => `@implements ${id} has no matching @endimplements ${id}`,
211
+ unmatchedEndimplementsTag: (id) => `@endimplements ${id} has no open @implements ${id} before it`,
212
+ implementsOutsideAnchors: (id) => `marks code as implementing ${id}, but no anchor of ${id} matches this file`,
191
213
  contextOverBudget: (context, count, budget) => `context "${context}" has ${count} rules, more than the contextBudget of ${budget}`,
192
214
  pendingImplementation: (id, base) => `rule ${id} changed since ${base} but none of the tests tagging it changed`,
215
+ unknownRuleReference: (field, id) => `${field} names ${id}, which does not exist`,
216
+ selfRuleReference: (field) => `${field} names the rule itself`,
217
+ ruleReferenceHint: (field) => `fix the id, or remove it from ${field}`,
218
+ dependencyCycle: (cycle) => `dependsOn forms a cycle: ${cycle.join(" → ")}`,
219
+ dependencyCycleHint: "remove one dependsOn link of the cycle",
220
+ activeRuleConflict: (a, b) => `active rules ${a} and ${b} conflict`,
221
+ activeRuleConflictHint: "deprecate one of them or set it to proposed, or remove the conflictsWith entry",
222
+ noGherkinScenarios: "No rule has Gherkin scenarios.",
223
+ generatedFeature: (file) => `Generated by "rulebookguard export gherkin" from ${file}; edit the rule instead.`,
224
+ featureKeyword: "Feature",
225
+ noActiveRules: "No active rule to export.",
226
+ removedExport: (path) => `removed ${path}`,
227
+ handWrittenExport: (path, format) => `${path} was not generated by "rulebookguard export ${format}"; move it away and run the export again.`,
228
+ generatedRule: (format, file) => `Generated by "rulebookguard export ${format}" from ${file}; edit the rule instead.`,
229
+ generatedAgentsMd: "Generated by \"rulebookguard export agents-md\"; edit the rules instead.",
230
+ agentsMdHeading: "Business rules",
231
+ agentsMdIntro: "Before changing a file, follow the active rules anchored to it:",
232
+ agentsMdSeeFile: (file) => `See \`${file}\`.`,
233
+ agentsMdNoAnchors: "no anchors",
234
+ noAnchoredRules: "No active rule with anchors to export.",
235
+ unanchoredRuleSkipped: (id) => `${id} is active but has no anchors, so it was not exported.`,
236
+ invalidAgentsMdMarkers: (file, start, end) => `${file} must have one "${start}" followed by one "${end}", or neither; fix it and run the export again.`,
193
237
  noIssues: "No issues found.",
194
238
  hintLabel: "hint",
195
239
  catalogHints: {
@@ -207,6 +251,11 @@ const MESSAGES = {
207
251
  deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `tag the rule that replaces ${id}, or remove the tag` : `tag ${successor}, which supersedes ${id}, instead`,
208
252
  unclosedRuleTagHint: (id) => `add "@endrule ${id}" after the last line of the test, or close the inner regions first`,
209
253
  unmatchedEndruleTagHint: (id) => `add "@rule ${id}" before the test, or remove the stray @endrule`,
254
+ unknownImplementsTagHint: "fix the id in the marker, or create the rule it refers to",
255
+ 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`,
256
+ unclosedImplementsTagHint: (id) => `add "@endimplements ${id}" after the last line of the code that enforces the rule, or close the inner regions first`,
257
+ unmatchedEndimplementsTagHint: (id) => `add "@implements ${id}" before the code that enforces the rule, or remove the stray @endimplements`,
258
+ implementsOutsideAnchorsHint: (id) => `add an anchor to ${id} that matches this file, or move the region to a file it anchors`,
210
259
  contextOverBudgetHint: "split the context into smaller ones, or raise contextBudget in rulebookguard.config.yaml",
211
260
  pendingImplementationHint: (id) => `update or add a test inside an "@rule ${id}" … "@endrule ${id}" region that covers the changed rule`,
212
261
  lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
@@ -245,6 +294,7 @@ const MESSAGES = {
245
294
  noContexts: "No se encontraron contextos. Crea uno con \"rulebookguard new <contexto>\".",
246
295
  noRules: "No se encontraron reglas.",
247
296
  noActiveRulesFor: (path) => `No hay reglas activas ancladas a ${path}.`,
297
+ lines: (spans) => `líneas ${spans}`,
248
298
  filesMatched: (count) => `Archivos que cubren los anchors (${count}):`,
249
299
  testsTagging: (id, count) => `Tests que etiquetan ${id} (${count}):`,
250
300
  none: "(ninguno)",
@@ -262,8 +312,35 @@ const MESSAGES = {
262
312
  deprecatedRuleTag: (id) => `etiqueta ${id}, que está deprecada`,
263
313
  unclosedRuleTag: (id) => `@rule ${id} no tiene su @endrule ${id}`,
264
314
  unmatchedEndruleTag: (id) => `@endrule ${id} no tiene un @rule ${id} abierto antes`,
315
+ unknownImplementsTag: (id) => `marca código como implementación de ${id}, que no existe`,
316
+ deprecatedImplementsTag: (id) => `marca código como implementación de ${id}, que está deprecada`,
317
+ unclosedImplementsTag: (id) => `@implements ${id} no tiene su @endimplements ${id}`,
318
+ unmatchedEndimplementsTag: (id) => `@endimplements ${id} no tiene un @implements ${id} abierto antes`,
319
+ implementsOutsideAnchors: (id) => `marca código como implementación de ${id}, pero ningún anchor de ${id} coincide con este archivo`,
265
320
  contextOverBudget: (context, count, budget) => `el contexto "${context}" tiene ${count} reglas, más que el contextBudget de ${budget}`,
266
321
  pendingImplementation: (id, base) => `la regla ${id} cambió desde ${base} pero no cambió ninguno de los tests que la etiquetan`,
322
+ unknownRuleReference: (field, id) => `${field} nombra a ${id}, que no existe`,
323
+ selfRuleReference: (field) => `${field} nombra a la propia regla`,
324
+ ruleReferenceHint: (field) => `corrige el id o quítalo de ${field}`,
325
+ dependencyCycle: (cycle) => `dependsOn forma un ciclo: ${cycle.join(" → ")}`,
326
+ dependencyCycleHint: "quita un vínculo dependsOn del ciclo",
327
+ activeRuleConflict: (a, b) => `las reglas activas ${a} y ${b} están en conflicto`,
328
+ activeRuleConflictHint: "depreca una de ellas o déjala en proposed, o quita la entrada de conflictsWith",
329
+ noGherkinScenarios: "Ninguna regla tiene escenarios Gherkin.",
330
+ generatedFeature: (file) => `Generado por "rulebookguard export gherkin" desde ${file}; edita la regla en su lugar.`,
331
+ featureKeyword: "Característica",
332
+ noActiveRules: "Ninguna regla activa para exportar.",
333
+ removedExport: (path) => `eliminado ${path}`,
334
+ handWrittenExport: (path, format) => `${path} no fue generado por "rulebookguard export ${format}"; muévelo y vuelve a exportar.`,
335
+ generatedRule: (format, file) => `Generado por "rulebookguard export ${format}" desde ${file}; edita la regla en su lugar.`,
336
+ generatedAgentsMd: "Generado por \"rulebookguard export agents-md\"; edita las reglas en su lugar.",
337
+ agentsMdHeading: "Reglas de negocio",
338
+ agentsMdIntro: "Antes de cambiar un archivo, sigue las reglas activas ancladas a él:",
339
+ agentsMdSeeFile: (file) => `Ver \`${file}\`.`,
340
+ agentsMdNoAnchors: "sin anchors",
341
+ noAnchoredRules: "Ninguna regla activa con anchors para exportar.",
342
+ unanchoredRuleSkipped: (id) => `${id} está activa pero no tiene anchors, así que no se exportó.`,
343
+ invalidAgentsMdMarkers: (file, start, end) => `${file} debe tener un "${start}" seguido de un "${end}", o ninguno; corrígelo y vuelve a exportar.`,
267
344
  noIssues: "No se encontraron problemas.",
268
345
  hintLabel: "sugerencia",
269
346
  catalogHints: {
@@ -281,6 +358,11 @@ const MESSAGES = {
281
358
  deprecatedRuleTagHint: (id, successor) => successor === void 0 ? `etiqueta la regla que reemplaza a ${id} o quita la etiqueta` : `etiqueta ${successor}, que reemplaza a ${id}`,
282
359
  unclosedRuleTagHint: (id) => `agrega "@endrule ${id}" después de la última línea del test, o cierra antes las regiones internas`,
283
360
  unmatchedEndruleTagHint: (id) => `agrega "@rule ${id}" antes del test, o quita el @endrule que sobra`,
361
+ unknownImplementsTagHint: "corrige el id del marcador o crea la regla a la que se refiere",
362
+ 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}`,
363
+ 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`,
364
+ unmatchedEndimplementsTagHint: (id) => `agrega "@implements ${id}" antes del código que cumple la regla, o quita el @endimplements que sobra`,
365
+ implementsOutsideAnchorsHint: (id) => `agrega a ${id} un anchor que coincida con este archivo, o mueve la región a un archivo que ancle`,
284
366
  contextOverBudgetHint: "divide el contexto en otros más chicos o sube contextBudget en rulebookguard.config.yaml",
285
367
  pendingImplementationHint: (id) => `actualiza o agrega un test dentro de una región "@rule ${id}" … "@endrule ${id}" que cubra el cambio de la regla`,
286
368
  lintSummary: (errors, warnings) => `${errors} ${errors === 1 ? "error" : "errores"}, ${warnings} ${warnings === 1 ? "aviso" : "avisos"}`,
@@ -429,10 +511,16 @@ async function loadRule(path, options) {
429
511
  }
430
512
  // src/format/tag.ts
431
513
  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;
514
+ const RULE_MARKER_PATTERN = markerPattern("rule");
515
+ const IMPLEMENTS_MARKER_PATTERN = markerPattern("implements");
516
+ function markerPattern(kind) {
517
+ return new RegExp(`(?<![\\w@.-])@(${kind}|end${kind})\\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\\w-])`, "g");
518
+ }
519
+ const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|#|--|;|<!--|%)/;
433
520
  function findRuleTags(source) {
434
521
  const tags = [];
435
522
  source.split(/\r?\n/).forEach((text, index) => {
523
+ if (!COMMENT_LINE.test(text)) return;
436
524
  for (const match of text.matchAll(RULE_TAG_PATTERN)) tags.push({
437
525
  id: match[1],
438
526
  line: index + 1
@@ -440,15 +528,17 @@ function findRuleTags(source) {
440
528
  });
441
529
  return tags;
442
530
  }
443
- function findRuleRegions(source) {
531
+ function findRuleRegions(source, kind = "rule") {
532
+ const pattern = kind === "rule" ? RULE_MARKER_PATTERN : IMPLEMENTS_MARKER_PATTERN;
444
533
  const regions = [];
445
534
  const problems = [];
446
535
  const open = [];
447
536
  source.split(/\r?\n/).forEach((text, index) => {
537
+ if (!COMMENT_LINE.test(text)) return;
448
538
  const line = index + 1;
449
- for (const match of text.matchAll(RULE_MARKER_PATTERN)) {
539
+ for (const match of text.matchAll(pattern)) {
450
540
  const id = match[2];
451
- if (match[1] === "rule") {
541
+ if (match[1] === kind) {
452
542
  open.push({
453
543
  id,
454
544
  line
@@ -458,14 +548,14 @@ function findRuleRegions(source) {
458
548
  const at = open.map((tag) => tag.id).lastIndexOf(id);
459
549
  if (at === -1) {
460
550
  problems.push({
461
- code: "unmatched-endrule-tag",
551
+ code: `unmatched-end${kind}-tag`,
462
552
  id,
463
553
  line
464
554
  });
465
555
  continue;
466
556
  }
467
557
  for (const inner of open.splice(at + 1)) problems.push({
468
- code: "unclosed-rule-tag",
558
+ code: `unclosed-${kind}-tag`,
469
559
  ...inner
470
560
  });
471
561
  const start = open.pop();
@@ -476,7 +566,7 @@ function findRuleRegions(source) {
476
566
  }
477
567
  });
478
568
  for (const tag of open) problems.push({
479
- code: "unclosed-rule-tag",
569
+ code: `unclosed-${kind}-tag`,
480
570
  ...tag
481
571
  });
482
572
  problems.sort((a, b) => a.line - b.line);
@@ -847,6 +937,30 @@ async function scanTestRegions(root, testGlobs, onGitFallback) {
847
937
  problems
848
938
  };
849
939
  }
940
+ async function scanImplementations(root, files) {
941
+ const regions = /* @__PURE__ */ new Map();
942
+ const problems = [];
943
+ const realRoot = await realpath(root);
944
+ for (const file of files) {
945
+ const path = join(root, file);
946
+ if (!await resolvesInside(path, realRoot)) continue;
947
+ if (!(await stat(path)).isFile()) continue;
948
+ const found = findRuleRegions(await readFile(path, "utf8"), "implements");
949
+ for (const { id, line, endLine } of found.regions) push(regions, id, {
950
+ file,
951
+ line,
952
+ endLine
953
+ });
954
+ problems.push(...found.problems.map((problem) => ({
955
+ ...problem,
956
+ file
957
+ })));
958
+ }
959
+ return {
960
+ regions: sortById(regions),
961
+ problems
962
+ };
963
+ }
850
964
  function push(index, id, value) {
851
965
  const values = index.get(id) ?? [];
852
966
  values.push(value);
@@ -885,6 +999,10 @@ function writeJson(io, value) {
885
999
  function writeLines(io, lines) {
886
1000
  io.stdout(lines.map((line) => `${line}\n`).join(""));
887
1001
  }
1002
+ function shownPath(cwd, file) {
1003
+ const fromCwd = relative(cwd, file);
1004
+ return fromCwd.startsWith("..") || isAbsolute(fromCwd) ? file : fromCwd;
1005
+ }
888
1006
  function gitFallbackWarning(io, language) {
889
1007
  const text = messages(language);
890
1008
  let warned = false;
@@ -973,6 +1091,8 @@ async function lintCatalog(catalog, scan, onGitFallback) {
973
1091
  line
974
1092
  });
975
1093
  }
1094
+ findings.push(...await implementations(catalog, files, invalid, successors, text));
1095
+ findings.push(...ruleRelations(catalog, invalid, text));
976
1096
  const budget = catalog.config.contextBudget;
977
1097
  for (const context of catalog.contexts) {
978
1098
  const count = catalog.rules.filter((rule) => rule.context === context.context).length;
@@ -986,6 +1106,76 @@ async function lintCatalog(catalog, scan, onGitFallback) {
986
1106
  }
987
1107
  return findings;
988
1108
  }
1109
+ async function implementations(catalog, files, invalid, successors, text) {
1110
+ const anchored = createGlobMatcher(catalog.rules.flatMap((rule) => rule.anchors));
1111
+ const scan = await scanImplementations(catalog.root, files.filter(anchored));
1112
+ const byId = new Map(catalog.rules.map((rule) => [rule.id, rule]));
1113
+ const findings = scan.problems.map(({ code, id, file, line }) => ({
1114
+ ...code === "unclosed-implements-tag" ? error(code, file, text.unclosedImplementsTag(id), text.unclosedImplementsTagHint(id)) : error(code, file, text.unmatchedEndimplementsTag(id), text.unmatchedEndimplementsTagHint(id)),
1115
+ line
1116
+ }));
1117
+ for (const [id, regions] of scan.regions) {
1118
+ const rule = byId.get(id);
1119
+ if (rule === void 0 && invalid.has(id)) continue;
1120
+ for (const { file, line } of regions) if (rule === void 0) findings.push({
1121
+ ...error("unknown-implements-tag", file, text.unknownImplementsTag(id), text.unknownImplementsTagHint),
1122
+ line
1123
+ });
1124
+ else if (rule.status === "deprecated") findings.push({
1125
+ ...error("deprecated-implements-tag", file, text.deprecatedImplementsTag(id), text.deprecatedImplementsTagHint(id, successors.get(id))),
1126
+ line
1127
+ });
1128
+ else if (!createGlobMatcher(rule.anchors)(file)) findings.push({
1129
+ ...error("implements-outside-anchors", file, text.implementsOutsideAnchors(id), text.implementsOutsideAnchorsHint(id)),
1130
+ line
1131
+ });
1132
+ }
1133
+ return findings;
1134
+ }
1135
+ const RELATION_FIELDS = ["dependsOn", "conflictsWith"];
1136
+ function ruleRelations(catalog, invalid, text) {
1137
+ const byId = new Map(catalog.rules.map((rule) => [rule.id, rule]));
1138
+ const findings = [];
1139
+ 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)));
1140
+ else if (!byId.has(id) && !invalid.has(id)) findings.push(error("invalid-rule-reference", rule.file, text.unknownRuleReference(field, id), text.ruleReferenceHint(field)));
1141
+ for (const cycle of dependencyCycles(byId)) findings.push(error("dependency-cycle", byId.get(cycle[0]).file, text.dependencyCycle([...cycle, cycle[0]]), text.dependencyCycleHint));
1142
+ const pairs = /* @__PURE__ */ new Set();
1143
+ for (const rule of catalog.rules) {
1144
+ if (rule.status !== "active") continue;
1145
+ for (const id of rule.conflictsWith ?? []) {
1146
+ if (id === rule.id || byId.get(id)?.status !== "active") continue;
1147
+ const [first, second] = [rule.id, id].sort(compare);
1148
+ const key = `${first} ${second}`;
1149
+ if (pairs.has(key)) continue;
1150
+ pairs.add(key);
1151
+ findings.push(error("active-rule-conflict", byId.get(first).file, text.activeRuleConflict(first, second), text.activeRuleConflictHint));
1152
+ }
1153
+ }
1154
+ return findings;
1155
+ }
1156
+ function dependencyCycles(byId) {
1157
+ const state = /* @__PURE__ */ new Map();
1158
+ const stack = [];
1159
+ const cycles = /* @__PURE__ */ new Map();
1160
+ const visit = (id) => {
1161
+ state.set(id, "visiting");
1162
+ stack.push(id);
1163
+ for (const next of byId.get(id)?.dependsOn ?? []) {
1164
+ if (next === id || !byId.has(next)) continue;
1165
+ const seen = state.get(next);
1166
+ if (seen === "visiting") {
1167
+ const cycle = stack.slice(stack.indexOf(next));
1168
+ const start = cycle.indexOf([...cycle].sort(compare)[0]);
1169
+ const rotated = [...cycle.slice(start), ...cycle.slice(0, start)];
1170
+ cycles.set(rotated.join(" "), rotated);
1171
+ } else if (seen === void 0) visit(next);
1172
+ }
1173
+ stack.pop();
1174
+ state.set(id, "done");
1175
+ };
1176
+ for (const id of [...byId.keys()].sort(compare)) if (!state.has(id)) visit(id);
1177
+ return [...cycles.values()];
1178
+ }
989
1179
  function pendingImplementation(catalog, scan, base, changed, lines) {
990
1180
  const text = messages(catalog.config.language);
991
1181
  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)));
@@ -1039,4 +1229,4 @@ function sortFindings(findings) {
1039
1229
  function compare(a, b) {
1040
1230
  return a < b ? -1 : a > b ? 1 : 0;
1041
1231
  }
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 };
1232
+ export { parseRule as A, formatIssue as B, IMPLEMENTS_MARKER_PATTERN as C, findRuleTags as D, findRuleRegions as E, CONFIG_FILE_NAME as F, loadConfig as I, parseConfig as L, loadContext as M, parseContext as N, isRuleId as O, splitFrontmatter as P, messages as R, runGit as S, RULE_TAG_PATTERN as T, relativeToRoot as _, CliError as a, listRepoFiles as b, openCatalog as c, writeLines as d, scanImplementations as f, loadCatalog as g, findRulebookRoot as h, pendingImplementation as i, CONTEXT_FILE_NAME as j, loadRule as k, shownPath as l, scanTestTags as m, lintCatalog as n, findRule as o, scanTestRegions as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeJson as u, createGlobMatcher as v, RULE_MARKER_PATTERN as w, toRepoPath as x, expandGlobs as y, FormatError 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 { S as runGit, a as CliError, c as openCatalog, d as writeLines, n as lintCatalog, p as scanTestRegions, s as gitFallbackWarning, y as expandGlobs } from "./lint-CaJnNLmU.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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.7.0",
39
+ "@rulebookguard/site": "0.9.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.7.0"
46
+ "@rulebookguard/site": "^0.9.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
  },