@gobing-ai/ts-rule-engine 0.5.21 → 0.5.23

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 CHANGED
@@ -485,6 +485,80 @@ rules:
485
485
  include: ["src/**/*.ts"]
486
486
  ```
487
487
 
488
+ ## Fixture Verification
489
+
490
+ `verifyRuleFixtures` asserts a rule's own fixtures against the **real** evaluators — the same
491
+ `RuleEngine`, the same registered evaluator implementations — so a fixture's expectation is checked
492
+ by the code it documents, not by a second implementation of the same idea. One rule is evaluated
493
+ against one prepared workdir at a time, independently, sequentially, and with fixes disabled.
494
+
495
+ ```ts
496
+ import { verifyRuleFixtures } from '@gobing-ai/ts-rule-engine';
497
+
498
+ const result = await verifyRuleFixtures({
499
+ ruleFile: '.spur/rules/quality/verify-confidence.yaml',
500
+ fixtureDir: '.spur/rules/fixtures/verify-confidence',
501
+ cases: [
502
+ {
503
+ path: 'should-fire/.spur/run/0101-verdict.json',
504
+ workdir: '/tmp/staged/0101',
505
+ ruleIds: ['verify-confidence-level'],
506
+ expect: 'fire',
507
+ },
508
+ ],
509
+ });
510
+ ```
511
+
512
+ `RuleFixtureVerificationOptions`:
513
+
514
+ | Field | Type | Meaning |
515
+ |-------|------|---------|
516
+ | `ruleFile` | `string` | Rule file loaded through `loadRuleFile`; always a physical path, because the loader has no filesystem port. |
517
+ | `fixtureDir` | `string` | Root the case `path`s resolve under. Absent is an explicit skip. |
518
+ | `cases` | `RuleFixtureCase[]` | Prepared cases: `{ path, workdir, ruleIds, expect }`. Each case is staged by the caller. |
519
+ | `fileSystem` | `FileSystem?` | Port for the existence check and evaluation. Defaults to `createNodeFileSystem()`. |
520
+ | `processExecutor` | `ProcessExecutor?` | Executor handed to process-backed evaluators. Defaults to `nodeBunFactory.createProcessExecutor()`. |
521
+
522
+ `RuleFixtureVerificationResult` carries `pass`, `skipped`, and one `RuleFixtureCaseResult` per
523
+ case/rule pair (`path`, `ruleId`, `expect`, `pass`, `findings`, `processes`, optional `error`).
524
+
525
+ What each expectation means:
526
+
527
+ - `expect: 'fire'` requires a genuine violation finding; `expect: 'pass'` requires zero findings.
528
+ - An evaluator error never satisfies an expected violation — it fails the pair either way.
529
+ - An `exit-code` rule is additionally asserted on the recorded process result: a normal exit must
530
+ differ from (fire) or equal (pass) the rule's configured `successCode` (default `0`). A timeout,
531
+ signal, spawn failure or any non-`exit` outcome fails the pair regardless of expectation, and the
532
+ captured stdout/stderr and findings are retained in the failure diagnostic.
533
+
534
+ Failure is never silent:
535
+
536
+ | Situation | Outcome |
537
+ |-----------|---------|
538
+ | Absent optional `fixtureDir` | `pass: true, skipped: true, cases: []` — nothing was asserted. |
539
+ | Existing `fixtureDir` that is not a directory | Throws instead of silently skipping. |
540
+ | Present but empty `cases` | Failure: an empty case set cannot pass vacuously. |
541
+ | Unknown or disabled rule ID | Failure naming the rule ID. |
542
+ | Absent case workdir, or case `path` missing under `fixtureDir` | Failure naming the case. |
543
+ | `ruleFile` declares extension modules | Throws — this verifier supports built-in rules only, and never silently ignores an extension. |
544
+ | Unreadable rule file, or a case selecting no rule IDs | Throws. |
545
+
546
+ ### Repository gate
547
+
548
+ A consuming repository wires this into its own gate with a thin adapter, because enumeration,
549
+ staging and reporting are repository-specific. `bun run verify-rule-fixtures`
550
+ (`scripts/verify-rule-fixtures.ts` in this workspace) enumerates every declared fixture directory,
551
+ stages each case into an OS-temp workdir, evaluates it, prints a per-set and per-expectation
552
+ summary, and writes a machine-readable report to `.spur/run/rule-fixtures-report.json` before
553
+ exiting non-zero on any mismatch. It reads checked-in fixtures only, never evaluates the live
554
+ repository as a case, removes its scratch workdirs in a `finally`, and fails — rather than skips —
555
+ on a deleted declared directory or an unrecognized fixture shape.
556
+
557
+ The adapter also checks SHA-256 content pins in `scripts/rule-fixture-hashes.json`, including
558
+ support files, so empty or replaced fixtures cannot pass solely through a rule's fail-safe branch.
559
+ Update the corresponding pin alongside each intentional fixture edit; the gate reports changed
560
+ paths and still evaluates the real rules.
561
+
488
562
  ## Rules Migration
489
563
 
490
564
  The `rg` evaluator now runs the **real ripgrep CLI** instead of being a JS-`RegExp` alias of `regex`. Ripgrep's Rust regex engine is ReDoS-immune, runs in parallel, and prunes heavy trees (`node_modules`, `dist`, …) during traversal — a meaningful speedup on large workspaces. The trade-off is a stricter dialect: ripgrep has **no lookbehind and no backreferences**.
@@ -939,4 +1013,4 @@ It does **not** own:
939
1013
  - Repository-specific rule catalogs (project rules live in `.spur/rules/`)
940
1014
  - CI integration or publishing workflows
941
1015
 
942
- Downstream tools (e.g. `spur`) consume the library and add their own CLI, config discovery, run-id stamping, and execution policy.
1016
+ Downstream tools (e.g. `spur`) consume the library and add their own CLI, config discovery, run-id stamping, and execution policy.
@@ -44,7 +44,7 @@ export function matchesGlob(path, pattern) {
44
44
  if (pattern.startsWith('**/')) {
45
45
  const suffix = pattern.slice(3);
46
46
  if (suffix.indexOf('*') === -1) {
47
- return normalized.endsWith(suffix) || normalized.endsWith(`/${suffix}`);
47
+ return normalized === suffix || normalized.endsWith(`/${suffix}`);
48
48
  }
49
49
  }
50
50
  if (pattern === normalized)
@@ -5,7 +5,7 @@ import { configString, DEFAULT_EXCLUDES } from './file-utils.js';
5
5
  * Evaluates source files against a regex using the `rg` (ripgrep) CLI.
6
6
  *
7
7
  * This is the real ripgrep-backed engine registered under the `rg` rule type. Unlike
8
- * the {@link import('./regex-evaluator.js').RegexEvaluator} (`regex` type, JS `RegExp`),
8
+ * the {@link import('./regex-evaluator').RegexEvaluator} (`regex` type, JS `RegExp`),
9
9
  * it runs ripgrep's linear-time Rust regex engine: ReDoS-immune, parallel, and pruning
10
10
  * heavy trees during traversal. The dialect differs from JS `RegExp` — no lookbehind or
11
11
  * backreferences — so rule patterns must be ripgrep-compatible (enforced by the
@@ -0,0 +1,68 @@
1
+ import { type FileSystem, type ProcessExecutor, type ProcessResult } from '@gobing-ai/ts-runtime';
2
+ import type { ConstraintFinding } from './types';
3
+ /** One prepared fixture case: a staged workdir plus the rules it must satisfy. */
4
+ export interface RuleFixtureCase {
5
+ /** Fixture-relative source path this case asserts; used for the existence check and diagnostics. */
6
+ path: string;
7
+ /** Prepared working directory the evaluator runs in. */
8
+ workdir: string;
9
+ /** Rule IDs evaluated independently against this workdir. */
10
+ ruleIds: string[];
11
+ /** Required outcome for every selected rule. */
12
+ expect: 'fire' | 'pass';
13
+ }
14
+ /** Options for {@link verifyRuleFixtures}. */
15
+ export interface RuleFixtureVerificationOptions {
16
+ /** Rule file holding the selected rules. Always read from disk: the loader has no filesystem port. */
17
+ ruleFile: string;
18
+ /** Fixture root the case `path`s resolve under. Absent → the whole verification is skipped. */
19
+ fixtureDir: string;
20
+ /** Prepared cases to assert, in order. */
21
+ cases: RuleFixtureCase[];
22
+ /** Filesystem port for fixture checks and evaluation. Defaults to `createNodeFileSystem()`. */
23
+ fileSystem?: FileSystem;
24
+ /** Executor for process-backed evaluators. Defaults to `nodeBunFactory.createProcessExecutor()`. */
25
+ processExecutor?: ProcessExecutor;
26
+ }
27
+ /** Outcome of one case/rule pair. */
28
+ export interface RuleFixtureCaseResult {
29
+ /** Fixture-relative source path from the case declaration. */
30
+ path: string;
31
+ /** Rule ID this result asserts. */
32
+ ruleId: string;
33
+ /** Expected outcome declared by the case. */
34
+ expect: 'fire' | 'pass';
35
+ /** Whether the recorded evaluation matched the expectation. */
36
+ pass: boolean;
37
+ /** Findings the engine recorded for this pair. */
38
+ findings: ConstraintFinding[];
39
+ /** Process results recorded while evaluating this pair, in invocation order. */
40
+ processes: ProcessResult[];
41
+ /** Failure reason; absent when `pass` is true. */
42
+ error?: string;
43
+ }
44
+ /** Aggregate verification result. */
45
+ export interface RuleFixtureVerificationResult {
46
+ /** True when every case/rule pair passed (also true for a skipped run). */
47
+ pass: boolean;
48
+ /** True when the optional fixture directory is absent, so nothing was asserted. */
49
+ skipped: boolean;
50
+ /** Per case/rule results. */
51
+ cases: RuleFixtureCaseResult[];
52
+ }
53
+ /**
54
+ * Verify prepared rule fixtures against the real evaluators.
55
+ *
56
+ * Loads `ruleFile`, then evaluates one selected rule against one case workdir at a
57
+ * time: expected-`fire` cases need a genuine violation, expected-`pass` cases need
58
+ * zero findings, and evaluator errors always fail. Exit-code rules are additionally
59
+ * asserted on the process result the evaluator produced — the recorded normal exit
60
+ * must differ from (fire) or equal (pass) the configured `successCode` (default 0),
61
+ * and timeout / signal / launch failure always fails.
62
+ *
63
+ * An absent `fixtureDir` yields `pass=true, skipped=true, cases=[]`; a present but
64
+ * empty case set fails. Invalid configuration (no selected rule IDs, extension
65
+ * declarations, unreadable rule file) throws.
66
+ */
67
+ export declare function verifyRuleFixtures(options: RuleFixtureVerificationOptions): Promise<RuleFixtureVerificationResult>;
68
+ //# sourceMappingURL=fixture-verifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fixture-verifier.d.ts","sourceRoot":"","sources":["../src/fixture-verifier.ts"],"names":[],"mappings":"AAAA,OAAO,EAEH,KAAK,UAAU,EAGf,KAAK,eAAe,EACpB,KAAK,aAAa,EACrB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,KAAK,EAAE,iBAAiB,EAAkB,MAAM,SAAS,CAAC;AAEjE,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC5B,oGAAoG;IACpG,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gDAAgD;IAChD,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,WAAW,8BAA8B;IAC3C,sGAAsG;IACtG,QAAQ,EAAE,MAAM,CAAC;IACjB,+FAA+F;IAC/F,UAAU,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,+FAA+F;IAC/F,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,oGAAoG;IACpG,eAAe,CAAC,EAAE,eAAe,CAAC;CACrC;AAED,qCAAqC;AACrC,MAAM,WAAW,qBAAqB;IAClC,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,+DAA+D;IAC/D,IAAI,EAAE,OAAO,CAAC;IACd,kDAAkD;IAClD,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,gFAAgF;IAChF,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,qCAAqC;AACrC,MAAM,WAAW,6BAA6B;IAC1C,2EAA2E;IAC3E,IAAI,EAAE,OAAO,CAAC;IACd,mFAAmF;IACnF,OAAO,EAAE,OAAO,CAAC;IACjB,6BAA6B;IAC7B,KAAK,EAAE,qBAAqB,EAAE,CAAC;CAClC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,kBAAkB,CACpC,OAAO,EAAE,8BAA8B,GACxC,OAAO,CAAC,6BAA6B,CAAC,CA0ExC"}
@@ -0,0 +1,151 @@
1
+ import { createNodeFileSystem, joinPath, nodeBunFactory, } from '@gobing-ai/ts-runtime';
2
+ import { loadRuleFile } from './config/loader.js';
3
+ import { RuleEngine } from './engine.js';
4
+ /**
5
+ * Verify prepared rule fixtures against the real evaluators.
6
+ *
7
+ * Loads `ruleFile`, then evaluates one selected rule against one case workdir at a
8
+ * time: expected-`fire` cases need a genuine violation, expected-`pass` cases need
9
+ * zero findings, and evaluator errors always fail. Exit-code rules are additionally
10
+ * asserted on the process result the evaluator produced — the recorded normal exit
11
+ * must differ from (fire) or equal (pass) the configured `successCode` (default 0),
12
+ * and timeout / signal / launch failure always fails.
13
+ *
14
+ * An absent `fixtureDir` yields `pass=true, skipped=true, cases=[]`; a present but
15
+ * empty case set fails. Invalid configuration (no selected rule IDs, extension
16
+ * declarations, unreadable rule file) throws.
17
+ */
18
+ export async function verifyRuleFixtures(options) {
19
+ const { ruleFile, fixtureDir, cases } = options;
20
+ const fileSystem = options.fileSystem ?? createNodeFileSystem();
21
+ const fixtureStat = await fileSystem.stat(fixtureDir);
22
+ if (fixtureStat === null)
23
+ return { pass: true, skipped: true, cases: [] };
24
+ if (!fixtureStat.isDirectory())
25
+ throw new Error(`Fixture root "${fixtureDir}" is not a directory`);
26
+ if (cases.length === 0)
27
+ return { pass: false, skipped: false, cases: [] };
28
+ for (const fixtureCase of cases) {
29
+ if (fixtureCase.ruleIds.length === 0) {
30
+ throw new Error(`Rule fixture case "${fixtureCase.path}" selects no rule IDs`);
31
+ }
32
+ }
33
+ const loaded = await loadRuleFile(ruleFile);
34
+ if (loaded.extensions.length > 0) {
35
+ throw new Error(`Rule file "${ruleFile}" declares extension modules; verifyRuleFixtures supports built-in rules only`);
36
+ }
37
+ const rulesById = new Map(loaded.rules.filter((rule) => rule.enabled !== false).map((rule) => [rule.id, rule]));
38
+ // Captured per pair: the wrapper records the original ProcessResult so exit-code
39
+ // rules can be asserted on the real process outcome without touching evaluators.
40
+ const processes = [];
41
+ const executor = options.processExecutor ?? nodeBunFactory.createProcessExecutor();
42
+ const engine = new RuleEngine({
43
+ fileSystem,
44
+ processExecutor: {
45
+ run: async (runOptions) => {
46
+ const result = await executor.run(runOptions);
47
+ processes.push(result);
48
+ return result;
49
+ },
50
+ runStreaming: (runOptions) => executor.runStreaming(runOptions),
51
+ },
52
+ });
53
+ const results = [];
54
+ for (const fixtureCase of cases) {
55
+ const setupError = await caseSetupError(fileSystem, fixtureDir, fixtureCase);
56
+ for (const ruleId of fixtureCase.ruleIds) {
57
+ const rule = rulesById.get(ruleId);
58
+ if (setupError !== undefined || rule === undefined) {
59
+ results.push({
60
+ path: fixtureCase.path,
61
+ ruleId,
62
+ expect: fixtureCase.expect,
63
+ pass: false,
64
+ findings: [],
65
+ processes: [],
66
+ error: setupError ?? `rule "${ruleId}" is not an enabled rule in "${ruleFile}"`,
67
+ });
68
+ continue;
69
+ }
70
+ processes.length = 0;
71
+ const findings = (await engine.evaluate([rule], fixtureCase.workdir)).findings;
72
+ const recorded = [...processes];
73
+ const verdict = judgeCase(fixtureCase.expect, rule, findings, recorded);
74
+ results.push({
75
+ path: fixtureCase.path,
76
+ ruleId,
77
+ expect: fixtureCase.expect,
78
+ pass: verdict.pass,
79
+ findings,
80
+ processes: recorded,
81
+ ...(verdict.error !== undefined ? { error: verdict.error } : {}),
82
+ });
83
+ }
84
+ }
85
+ return { pass: results.every((result) => result.pass), skipped: false, cases: results };
86
+ }
87
+ /** Fail a case whose declared source or prepared workdir is absent. */
88
+ async function caseSetupError(fileSystem, fixtureDir, fixtureCase) {
89
+ const sourceStat = await fileSystem.stat(joinPath(fixtureDir, fixtureCase.path));
90
+ if (sourceStat === null)
91
+ return `case source "${fixtureCase.path}" is missing under "${fixtureDir}"`;
92
+ const workdirStat = await fileSystem.stat(fixtureCase.workdir);
93
+ if (workdirStat === null || !workdirStat.isDirectory()) {
94
+ return `case workdir "${fixtureCase.workdir}" is missing`;
95
+ }
96
+ return undefined;
97
+ }
98
+ /** Assert findings — and, for exit-code rules, the recorded process result — against the expectation. */
99
+ function judgeCase(expect, rule, findings, processes) {
100
+ const evaluatorErrors = findings.filter((finding) => finding.kind === 'error');
101
+ if (evaluatorErrors.length > 0) {
102
+ return { pass: false, error: `evaluator error: ${evaluatorErrors.map((f) => f.message).join('; ')}` };
103
+ }
104
+ const violations = findings.filter((finding) => finding.kind !== 'error');
105
+ if (rule.evaluator.type === 'exit-code')
106
+ return judgeExitCodeCase(expect, rule, processes);
107
+ if (expect === 'fire') {
108
+ return violations.length > 0
109
+ ? { pass: true }
110
+ : { pass: false, error: 'expected fire but the rule reported no violation' };
111
+ }
112
+ return violations.length === 0
113
+ ? { pass: true }
114
+ : { pass: false, error: `expected pass but the rule reported ${violations.length} violation(s)` };
115
+ }
116
+ /** Judge an exit-code pair on the recorded normal exit versus the rule's configured `successCode`. */
117
+ function judgeExitCodeCase(expect, rule, processes) {
118
+ const configured = rule.evaluator.config?.successCode;
119
+ const successCode = typeof configured === 'number' ? configured : 0;
120
+ const [execution] = processes;
121
+ if (execution === undefined)
122
+ return { pass: false, error: 'exit-code: no process result recorded' };
123
+ const abnormal = execution.exitCode === null || (execution.outcome !== undefined && execution.outcome !== 'exit');
124
+ if (abnormal) {
125
+ const signal = execution.signal !== undefined ? ` (signal ${execution.signal})` : '';
126
+ return {
127
+ pass: false,
128
+ error: `exit-code: process did not complete normally (outcome ${execution.outcome ?? 'unknown'}${signal})${processDiagnostics(execution)}`,
129
+ };
130
+ }
131
+ // The evaluator reports a violation exactly when the exit differs from the
132
+ // configured success code, so the exit comparison is the assertion for both halves.
133
+ if (expect === 'fire') {
134
+ return execution.exitCode === successCode
135
+ ? {
136
+ pass: false,
137
+ error: `expected fire but exit ${execution.exitCode} equals successCode ${successCode}${processDiagnostics(execution)}`,
138
+ }
139
+ : { pass: true };
140
+ }
141
+ return execution.exitCode === successCode
142
+ ? { pass: true }
143
+ : {
144
+ pass: false,
145
+ error: `expected pass but exit ${execution.exitCode} differs from successCode ${successCode}${processDiagnostics(execution)}`,
146
+ };
147
+ }
148
+ /** Append the recorded streams to an exit-code failure diagnostic. */
149
+ function processDiagnostics(execution) {
150
+ return `\nstdout: ${execution.stdout}\nstderr: ${execution.stderr}`;
151
+ }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { isRipgrepCompatiblePattern } from './evaluators/ripgrep-evaluator';
6
6
  export type { RuleEngineEvents } from './events';
7
7
  export * from './fixers/fixers';
8
8
  export * from './fixers/test-stub-fixer';
9
+ export * from './fixture-verifier';
9
10
  export * from './formatters/json';
10
11
  export * from './formatters/text';
11
12
  export * from './host/bundled-rules';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AAClH,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AAGzB,OAAO,EAAE,0BAA0B,EAAE,MAAM,gCAAgC,CAAC;AAC5E,YAAY,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,cAAc,iBAAiB,CAAC;AAChC,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,eAAe,CAAC;AAC9B,cAAc,gCAAgC,CAAC;AAC/C,cAAc,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AAClH,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AAGzB,OAAO,EAAE,0BAA0B,EAAE,MAAM,gCAAgC,CAAC;AAC5E,YAAY,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,cAAc,iBAAiB,CAAC;AAChC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC;AACxC,cAAc,eAAe,CAAC;AAC9B,cAAc,gCAAgC,CAAC;AAC/C,cAAc,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export * from './engine.js';
10
10
  export { isRipgrepCompatiblePattern } from './evaluators/ripgrep-evaluator.js';
11
11
  export * from './fixers/fixers.js';
12
12
  export * from './fixers/test-stub-fixer.js';
13
+ export * from './fixture-verifier.js';
13
14
  export * from './formatters/json.js';
14
15
  export * from './formatters/text.js';
15
16
  export * from './host/bundled-rules.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-rule-engine",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "@gobing-ai/ts-rule-engine — Constraint rule schemas, loading, evaluation, and result formatting.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -51,10 +51,10 @@
51
51
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-rule-engine-v<version> && git push --tags' && exit 1"
52
52
  },
53
53
  "dependencies": {
54
- "@gobing-ai/ts-ai-runner": "^0.5.21",
55
- "@gobing-ai/ts-infra": "^0.5.21",
56
- "@gobing-ai/ts-runtime": "^0.5.21",
57
- "@gobing-ai/ts-db": "^0.5.21",
54
+ "@gobing-ai/ts-ai-runner": "^0.5.23",
55
+ "@gobing-ai/ts-infra": "^0.5.23",
56
+ "@gobing-ai/ts-runtime": "^0.5.23",
57
+ "@gobing-ai/ts-db": "^0.5.23",
58
58
  "yaml": "^2.7.0",
59
59
  "zod": "^4.1.0"
60
60
  },
@@ -44,7 +44,7 @@ export function matchesGlob(path: string, pattern: string): boolean {
44
44
  if (pattern.startsWith('**/')) {
45
45
  const suffix = pattern.slice(3);
46
46
  if (suffix.indexOf('*') === -1) {
47
- return normalized.endsWith(suffix) || normalized.endsWith(`/${suffix}`);
47
+ return normalized === suffix || normalized.endsWith(`/${suffix}`);
48
48
  }
49
49
  }
50
50
  if (pattern === normalized) return true;
@@ -0,0 +1,236 @@
1
+ import {
2
+ createNodeFileSystem,
3
+ type FileSystem,
4
+ joinPath,
5
+ nodeBunFactory,
6
+ type ProcessExecutor,
7
+ type ProcessResult,
8
+ } from '@gobing-ai/ts-runtime';
9
+ import { loadRuleFile } from './config/loader';
10
+ import { RuleEngine } from './engine';
11
+ import type { ConstraintFinding, ConstraintRule } from './types';
12
+
13
+ /** One prepared fixture case: a staged workdir plus the rules it must satisfy. */
14
+ export interface RuleFixtureCase {
15
+ /** Fixture-relative source path this case asserts; used for the existence check and diagnostics. */
16
+ path: string;
17
+ /** Prepared working directory the evaluator runs in. */
18
+ workdir: string;
19
+ /** Rule IDs evaluated independently against this workdir. */
20
+ ruleIds: string[];
21
+ /** Required outcome for every selected rule. */
22
+ expect: 'fire' | 'pass';
23
+ }
24
+
25
+ /** Options for {@link verifyRuleFixtures}. */
26
+ export interface RuleFixtureVerificationOptions {
27
+ /** Rule file holding the selected rules. Always read from disk: the loader has no filesystem port. */
28
+ ruleFile: string;
29
+ /** Fixture root the case `path`s resolve under. Absent → the whole verification is skipped. */
30
+ fixtureDir: string;
31
+ /** Prepared cases to assert, in order. */
32
+ cases: RuleFixtureCase[];
33
+ /** Filesystem port for fixture checks and evaluation. Defaults to `createNodeFileSystem()`. */
34
+ fileSystem?: FileSystem;
35
+ /** Executor for process-backed evaluators. Defaults to `nodeBunFactory.createProcessExecutor()`. */
36
+ processExecutor?: ProcessExecutor;
37
+ }
38
+
39
+ /** Outcome of one case/rule pair. */
40
+ export interface RuleFixtureCaseResult {
41
+ /** Fixture-relative source path from the case declaration. */
42
+ path: string;
43
+ /** Rule ID this result asserts. */
44
+ ruleId: string;
45
+ /** Expected outcome declared by the case. */
46
+ expect: 'fire' | 'pass';
47
+ /** Whether the recorded evaluation matched the expectation. */
48
+ pass: boolean;
49
+ /** Findings the engine recorded for this pair. */
50
+ findings: ConstraintFinding[];
51
+ /** Process results recorded while evaluating this pair, in invocation order. */
52
+ processes: ProcessResult[];
53
+ /** Failure reason; absent when `pass` is true. */
54
+ error?: string;
55
+ }
56
+
57
+ /** Aggregate verification result. */
58
+ export interface RuleFixtureVerificationResult {
59
+ /** True when every case/rule pair passed (also true for a skipped run). */
60
+ pass: boolean;
61
+ /** True when the optional fixture directory is absent, so nothing was asserted. */
62
+ skipped: boolean;
63
+ /** Per case/rule results. */
64
+ cases: RuleFixtureCaseResult[];
65
+ }
66
+
67
+ /**
68
+ * Verify prepared rule fixtures against the real evaluators.
69
+ *
70
+ * Loads `ruleFile`, then evaluates one selected rule against one case workdir at a
71
+ * time: expected-`fire` cases need a genuine violation, expected-`pass` cases need
72
+ * zero findings, and evaluator errors always fail. Exit-code rules are additionally
73
+ * asserted on the process result the evaluator produced — the recorded normal exit
74
+ * must differ from (fire) or equal (pass) the configured `successCode` (default 0),
75
+ * and timeout / signal / launch failure always fails.
76
+ *
77
+ * An absent `fixtureDir` yields `pass=true, skipped=true, cases=[]`; a present but
78
+ * empty case set fails. Invalid configuration (no selected rule IDs, extension
79
+ * declarations, unreadable rule file) throws.
80
+ */
81
+ export async function verifyRuleFixtures(
82
+ options: RuleFixtureVerificationOptions,
83
+ ): Promise<RuleFixtureVerificationResult> {
84
+ const { ruleFile, fixtureDir, cases } = options;
85
+ const fileSystem = options.fileSystem ?? createNodeFileSystem();
86
+ const fixtureStat = await fileSystem.stat(fixtureDir);
87
+ if (fixtureStat === null) return { pass: true, skipped: true, cases: [] };
88
+ if (!fixtureStat.isDirectory()) throw new Error(`Fixture root "${fixtureDir}" is not a directory`);
89
+ if (cases.length === 0) return { pass: false, skipped: false, cases: [] };
90
+ for (const fixtureCase of cases) {
91
+ if (fixtureCase.ruleIds.length === 0) {
92
+ throw new Error(`Rule fixture case "${fixtureCase.path}" selects no rule IDs`);
93
+ }
94
+ }
95
+
96
+ const loaded = await loadRuleFile(ruleFile);
97
+ if (loaded.extensions.length > 0) {
98
+ throw new Error(
99
+ `Rule file "${ruleFile}" declares extension modules; verifyRuleFixtures supports built-in rules only`,
100
+ );
101
+ }
102
+ const rulesById = new Map(
103
+ loaded.rules.filter((rule) => rule.enabled !== false).map((rule) => [rule.id, rule] as const),
104
+ );
105
+
106
+ // Captured per pair: the wrapper records the original ProcessResult so exit-code
107
+ // rules can be asserted on the real process outcome without touching evaluators.
108
+ const processes: ProcessResult[] = [];
109
+ const executor = options.processExecutor ?? nodeBunFactory.createProcessExecutor();
110
+ const engine = new RuleEngine({
111
+ fileSystem,
112
+ processExecutor: {
113
+ run: async (runOptions) => {
114
+ const result = await executor.run(runOptions);
115
+ processes.push(result);
116
+ return result;
117
+ },
118
+ runStreaming: (runOptions) => executor.runStreaming(runOptions),
119
+ },
120
+ });
121
+
122
+ const results: RuleFixtureCaseResult[] = [];
123
+ for (const fixtureCase of cases) {
124
+ const setupError = await caseSetupError(fileSystem, fixtureDir, fixtureCase);
125
+ for (const ruleId of fixtureCase.ruleIds) {
126
+ const rule = rulesById.get(ruleId);
127
+ if (setupError !== undefined || rule === undefined) {
128
+ results.push({
129
+ path: fixtureCase.path,
130
+ ruleId,
131
+ expect: fixtureCase.expect,
132
+ pass: false,
133
+ findings: [],
134
+ processes: [],
135
+ error: setupError ?? `rule "${ruleId}" is not an enabled rule in "${ruleFile}"`,
136
+ });
137
+ continue;
138
+ }
139
+
140
+ processes.length = 0;
141
+ const findings = (await engine.evaluate([rule], fixtureCase.workdir)).findings;
142
+ const recorded = [...processes];
143
+ const verdict = judgeCase(fixtureCase.expect, rule, findings, recorded);
144
+ results.push({
145
+ path: fixtureCase.path,
146
+ ruleId,
147
+ expect: fixtureCase.expect,
148
+ pass: verdict.pass,
149
+ findings,
150
+ processes: recorded,
151
+ ...(verdict.error !== undefined ? { error: verdict.error } : {}),
152
+ });
153
+ }
154
+ }
155
+
156
+ return { pass: results.every((result) => result.pass), skipped: false, cases: results };
157
+ }
158
+
159
+ /** Fail a case whose declared source or prepared workdir is absent. */
160
+ async function caseSetupError(
161
+ fileSystem: FileSystem,
162
+ fixtureDir: string,
163
+ fixtureCase: RuleFixtureCase,
164
+ ): Promise<string | undefined> {
165
+ const sourceStat = await fileSystem.stat(joinPath(fixtureDir, fixtureCase.path));
166
+ if (sourceStat === null) return `case source "${fixtureCase.path}" is missing under "${fixtureDir}"`;
167
+ const workdirStat = await fileSystem.stat(fixtureCase.workdir);
168
+ if (workdirStat === null || !workdirStat.isDirectory()) {
169
+ return `case workdir "${fixtureCase.workdir}" is missing`;
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ /** Assert findings — and, for exit-code rules, the recorded process result — against the expectation. */
175
+ function judgeCase(
176
+ expect: 'fire' | 'pass',
177
+ rule: ConstraintRule,
178
+ findings: ConstraintFinding[],
179
+ processes: ProcessResult[],
180
+ ): { pass: boolean; error?: string } {
181
+ const evaluatorErrors = findings.filter((finding) => finding.kind === 'error');
182
+ if (evaluatorErrors.length > 0) {
183
+ return { pass: false, error: `evaluator error: ${evaluatorErrors.map((f) => f.message).join('; ')}` };
184
+ }
185
+ const violations = findings.filter((finding) => finding.kind !== 'error');
186
+ if (rule.evaluator.type === 'exit-code') return judgeExitCodeCase(expect, rule, processes);
187
+ if (expect === 'fire') {
188
+ return violations.length > 0
189
+ ? { pass: true }
190
+ : { pass: false, error: 'expected fire but the rule reported no violation' };
191
+ }
192
+ return violations.length === 0
193
+ ? { pass: true }
194
+ : { pass: false, error: `expected pass but the rule reported ${violations.length} violation(s)` };
195
+ }
196
+
197
+ /** Judge an exit-code pair on the recorded normal exit versus the rule's configured `successCode`. */
198
+ function judgeExitCodeCase(
199
+ expect: 'fire' | 'pass',
200
+ rule: ConstraintRule,
201
+ processes: ProcessResult[],
202
+ ): { pass: boolean; error?: string } {
203
+ const configured = rule.evaluator.config?.successCode;
204
+ const successCode = typeof configured === 'number' ? configured : 0;
205
+ const [execution] = processes;
206
+ if (execution === undefined) return { pass: false, error: 'exit-code: no process result recorded' };
207
+ const abnormal = execution.exitCode === null || (execution.outcome !== undefined && execution.outcome !== 'exit');
208
+ if (abnormal) {
209
+ const signal = execution.signal !== undefined ? ` (signal ${execution.signal})` : '';
210
+ return {
211
+ pass: false,
212
+ error: `exit-code: process did not complete normally (outcome ${execution.outcome ?? 'unknown'}${signal})${processDiagnostics(execution)}`,
213
+ };
214
+ }
215
+ // The evaluator reports a violation exactly when the exit differs from the
216
+ // configured success code, so the exit comparison is the assertion for both halves.
217
+ if (expect === 'fire') {
218
+ return execution.exitCode === successCode
219
+ ? {
220
+ pass: false,
221
+ error: `expected fire but exit ${execution.exitCode} equals successCode ${successCode}${processDiagnostics(execution)}`,
222
+ }
223
+ : { pass: true };
224
+ }
225
+ return execution.exitCode === successCode
226
+ ? { pass: true }
227
+ : {
228
+ pass: false,
229
+ error: `expected pass but exit ${execution.exitCode} differs from successCode ${successCode}${processDiagnostics(execution)}`,
230
+ };
231
+ }
232
+
233
+ /** Append the recorded streams to an exit-code failure diagnostic. */
234
+ function processDiagnostics(execution: ProcessResult): string {
235
+ return `\nstdout: ${execution.stdout}\nstderr: ${execution.stderr}`;
236
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export { isRipgrepCompatiblePattern } from './evaluators/ripgrep-evaluator';
11
11
  export type { RuleEngineEvents } from './events';
12
12
  export * from './fixers/fixers';
13
13
  export * from './fixers/test-stub-fixer';
14
+ export * from './fixture-verifier';
14
15
  export * from './formatters/json';
15
16
  export * from './formatters/text';
16
17
  export * from './host/bundled-rules';