@gobing-ai/ts-rule-engine 0.3.14 → 0.3.17

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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * In-memory persistence adapter for unit tests.
3
+ * Stores rows in Maps so tests can assert on persisted state.
4
+ */
5
+ export class MemoryRulePersistenceAdapter {
6
+ constructor() {
7
+ // Explicit constructor for V8 function coverage — class implements an interface, no super().
8
+ }
9
+ runs = new Map();
10
+ evals = new Map();
11
+ async insertRun(input) {
12
+ const now = new Date().toISOString();
13
+ this.runs.set(input.id, {
14
+ ...input,
15
+ status: 'running',
16
+ findingCount: 0,
17
+ fixCount: 0,
18
+ appliedFixCount: 0,
19
+ durationMs: null,
20
+ completedAt: null,
21
+ createdAt: now,
22
+ updatedAt: now,
23
+ });
24
+ }
25
+ async updateRunStatus(runId, status, findingCount, fixCount, appliedFixCount, durationMs) {
26
+ const row = this.runs.get(runId);
27
+ if (!row)
28
+ throw new Error(`Run not found: ${runId}`);
29
+ row.status = status;
30
+ row.findingCount = findingCount;
31
+ row.fixCount = fixCount;
32
+ row.appliedFixCount = appliedFixCount;
33
+ row.durationMs = durationMs;
34
+ row.completedAt = new Date().toISOString();
35
+ row.updatedAt = new Date().toISOString();
36
+ }
37
+ async insertEvalRun(input) {
38
+ const now = new Date().toISOString();
39
+ const key = `${input.runId}:${input.ruleId}`;
40
+ this.evals.set(key, {
41
+ ...input,
42
+ status: 'running',
43
+ findingCount: 0,
44
+ fixCount: 0,
45
+ durationMs: null,
46
+ error: null,
47
+ findingsJson: null,
48
+ fixesJson: null,
49
+ completedAt: null,
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ });
53
+ }
54
+ async updateEvalRun(input) {
55
+ const key = `${input.runId}:${input.ruleId}`;
56
+ const row = this.evals.get(key);
57
+ if (!row)
58
+ throw new Error(`Eval run not found: ${key}`);
59
+ row.status = input.status;
60
+ row.findingCount = input.findingCount;
61
+ row.fixCount = input.fixCount;
62
+ row.durationMs = input.durationMs;
63
+ row.error = input.error ?? null;
64
+ row.findingsJson = input.findingsJson ?? null;
65
+ row.fixesJson = input.fixesJson ?? null;
66
+ row.completedAt = new Date().toISOString();
67
+ row.updatedAt = new Date().toISOString();
68
+ }
69
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Rule-engine persistence schema.
3
+ *
4
+ * Mirrors the workflow-engine persistence pattern: the engine owns the DDL and
5
+ * direct writes; consumers query the resulting tables through their own DAOs.
6
+ * `rule_runs` is the list/query unit; `rule_eval_runs` is the per-rule detail
7
+ * unit. Timestamps are TEXT (ISO 8601) for portability across SQLite drivers
8
+ * including D1.
9
+ */
10
+ export declare const RULE_ENGINE_SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS rule_runs (\n id TEXT PRIMARY KEY,\n preset TEXT,\n source_kind TEXT NOT NULL,\n source_value TEXT,\n status TEXT NOT NULL DEFAULT 'running',\n rule_count INTEGER NOT NULL DEFAULT 0,\n finding_count INTEGER NOT NULL DEFAULT 0,\n fix_count INTEGER NOT NULL DEFAULT 0,\n applied_fix_count INTEGER NOT NULL DEFAULT 0,\n fail_on TEXT,\n stop_on_first TEXT,\n fix_mode TEXT NOT NULL DEFAULT 'none',\n dry_run INTEGER NOT NULL DEFAULT 0,\n started_at TEXT NOT NULL,\n completed_at TEXT,\n duration_ms INTEGER,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS rule_eval_runs (\n id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL REFERENCES rule_runs(id),\n rule_id TEXT NOT NULL,\n severity TEXT NOT NULL,\n evaluator TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'running',\n finding_count INTEGER NOT NULL DEFAULT 0,\n fix_count INTEGER NOT NULL DEFAULT 0,\n duration_ms INTEGER,\n error TEXT,\n findings_json TEXT,\n fixes_json TEXT,\n started_at TEXT NOT NULL,\n completed_at TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_rule_eval_runs_run_id ON rule_eval_runs (run_id);\n";
11
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/persistence/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,sBAAsB,4zCA2ClC,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Rule-engine persistence schema.
3
+ *
4
+ * Mirrors the workflow-engine persistence pattern: the engine owns the DDL and
5
+ * direct writes; consumers query the resulting tables through their own DAOs.
6
+ * `rule_runs` is the list/query unit; `rule_eval_runs` is the per-rule detail
7
+ * unit. Timestamps are TEXT (ISO 8601) for portability across SQLite drivers
8
+ * including D1.
9
+ */
10
+ export const RULE_ENGINE_SCHEMA_SQL = `
11
+ CREATE TABLE IF NOT EXISTS rule_runs (
12
+ id TEXT PRIMARY KEY,
13
+ preset TEXT,
14
+ source_kind TEXT NOT NULL,
15
+ source_value TEXT,
16
+ status TEXT NOT NULL DEFAULT 'running',
17
+ rule_count INTEGER NOT NULL DEFAULT 0,
18
+ finding_count INTEGER NOT NULL DEFAULT 0,
19
+ fix_count INTEGER NOT NULL DEFAULT 0,
20
+ applied_fix_count INTEGER NOT NULL DEFAULT 0,
21
+ fail_on TEXT,
22
+ stop_on_first TEXT,
23
+ fix_mode TEXT NOT NULL DEFAULT 'none',
24
+ dry_run INTEGER NOT NULL DEFAULT 0,
25
+ started_at TEXT NOT NULL,
26
+ completed_at TEXT,
27
+ duration_ms INTEGER,
28
+ metadata_json TEXT NOT NULL DEFAULT '{}',
29
+ created_at TEXT NOT NULL,
30
+ updated_at TEXT NOT NULL
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS rule_eval_runs (
34
+ id TEXT PRIMARY KEY,
35
+ run_id TEXT NOT NULL REFERENCES rule_runs(id),
36
+ rule_id TEXT NOT NULL,
37
+ severity TEXT NOT NULL,
38
+ evaluator TEXT NOT NULL,
39
+ status TEXT NOT NULL DEFAULT 'running',
40
+ finding_count INTEGER NOT NULL DEFAULT 0,
41
+ fix_count INTEGER NOT NULL DEFAULT 0,
42
+ duration_ms INTEGER,
43
+ error TEXT,
44
+ findings_json TEXT,
45
+ fixes_json TEXT,
46
+ started_at TEXT NOT NULL,
47
+ completed_at TEXT,
48
+ created_at TEXT NOT NULL,
49
+ updated_at TEXT NOT NULL
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_rule_eval_runs_run_id ON rule_eval_runs (run_id);
53
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-rule-engine",
3
- "version": "0.3.14",
3
+ "version": "0.3.17",
4
4
  "description": "@gobing-ai/ts-rule-engine — Constraint rule schemas, loading, evaluation, and result formatting.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -49,9 +49,10 @@
49
49
  "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"
50
50
  },
51
51
  "dependencies": {
52
- "@gobing-ai/ts-ai-runner": "^0.3.14",
53
- "@gobing-ai/ts-infra": "^0.3.14",
54
- "@gobing-ai/ts-runtime": "^0.3.14",
52
+ "@gobing-ai/ts-ai-runner": "^0.3.17",
53
+ "@gobing-ai/ts-infra": "^0.3.17",
54
+ "@gobing-ai/ts-runtime": "^0.3.17",
55
+ "@gobing-ai/ts-db": "^0.3.17",
55
56
  "yaml": "^2.7.0",
56
57
  "zod": "^4.1.0"
57
58
  },
package/src/engine.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from './fixers/fixers';
12
12
  import { registerBuiltins } from './host/builtins';
13
13
  import { RuleEngineHost } from './host/rule-engine-host';
14
+ import type { RulePersistenceAdapter } from './persistence/adapter';
14
15
  import type { ConstraintFinding, ConstraintRule, Fix, FixMode, RuleEngineResult, RuleEvaluator } from './types';
15
16
  import { createFinding, SEVERITY_RANK } from './types';
16
17
 
@@ -24,6 +25,12 @@ export interface RuleEngineOptions {
24
25
  events?: EventBus<RuleEngineEvents>;
25
26
  /** Optional logger; defaults to the shared `rule-engine` category logger. */
26
27
  logger?: Logger;
28
+ /** Optional persistence adapter for durable run/eval history. */
29
+ persistence?: RulePersistenceAdapter;
30
+ /** Caller-provided run ID; engine generates one if absent. */
31
+ runId?: string;
32
+ /** Caller metadata for the run row (preset, failOn, source info, etc.). */
33
+ runMeta?: Record<string, unknown>;
27
34
  }
28
35
 
29
36
  /** Orchestrates enabled constraint rules through a typed evaluator host. */
@@ -33,6 +40,9 @@ export class RuleEngine {
33
40
 
34
41
  private readonly events: EventBus<RuleEngineEvents> | undefined;
35
42
  private readonly logger: Logger;
43
+ private readonly persistence: RulePersistenceAdapter | undefined;
44
+ private readonly runId: string;
45
+ private readonly runMeta: Record<string, unknown> | undefined;
36
46
 
37
47
  constructor(options: RuleEngineOptions = {}) {
38
48
  this.host = options.host ?? new RuleEngineHost();
@@ -40,6 +50,9 @@ export class RuleEngine {
40
50
  registerBuiltinFixers(this.host, options.processExecutor);
41
51
  this.events = options.events;
42
52
  this.logger = options.logger ?? getLogger('rule-engine');
53
+ this.persistence = options.persistence;
54
+ this.runId = options.runId ?? crypto.randomUUID();
55
+ this.runMeta = options.runMeta;
43
56
  }
44
57
 
45
58
  /** Register or replace an evaluator. */
@@ -70,6 +83,10 @@ export class RuleEngine {
70
83
  * provider by evaluator type and calls `createFixes`. The effective fix mode
71
84
  * is the minimum of the rule's configured mode and `maxFixMode`.
72
85
  *
86
+ * When `options.persistence` is set, this method writes a run row before
87
+ * evaluation and per-rule eval rows during the loop. A `done`/`failed`
88
+ * status is written after the loop completes.
89
+ *
73
90
  * @param rules - Normalized rule definitions to evaluate.
74
91
  * @param workdir - Working directory to scan.
75
92
  * @param maxFixMode - Highest fix authority requested by the caller.
@@ -87,6 +104,21 @@ export class RuleEngine {
87
104
  const enabledRules = rules.filter((r) => r.enabled !== false);
88
105
  const runStartMs = Date.now();
89
106
 
107
+ // Insert the run row before any evaluation.
108
+ const runMeta = this.runMeta ?? {};
109
+ await this.persistence?.insertRun({
110
+ id: this.runId,
111
+ sourceKind: (runMeta.sourceKind as 'preset' | 'file') ?? 'preset',
112
+ sourceValue: runMeta.sourceValue as string | undefined,
113
+ preset: runMeta.preset as string | undefined,
114
+ ruleCount: enabledRules.length,
115
+ failOn: runMeta.failOn as string | undefined,
116
+ stopOnFirst: runMeta.stopOnFirst as string | undefined,
117
+ fixMode: maxFixMode,
118
+ dryRun: (runMeta.dryRun as boolean) ?? false,
119
+ metadataJson: runMeta.metadataJson as string | undefined,
120
+ });
121
+
90
122
  return await traceAsync(
91
123
  'rule.run',
92
124
  async () => {
@@ -103,6 +135,15 @@ export class RuleEngine {
103
135
  if (rule.enabled === false) continue;
104
136
  index++;
105
137
 
138
+ // Persist eval start.
139
+ await this.persistence?.insertEvalRun({
140
+ id: `${this.runId}:${rule.id}`,
141
+ runId: this.runId,
142
+ ruleId: rule.id,
143
+ severity: rule.severity,
144
+ evaluator: rule.evaluator.type,
145
+ });
146
+
106
147
  const evalStartMs = Date.now();
107
148
  this.logger.debug('eval start', { ruleId: rule.id, index, total: enabledRules.length });
108
149
  addSpanEvent('rule.eval.start', { ruleId: rule.id, index, total: enabledRules.length });
@@ -110,6 +151,7 @@ export class RuleEngine {
110
151
 
111
152
  let ruleFindings: ConstraintFinding[] = [];
112
153
  let ruleEvalFixes: Fix[] = [];
154
+ let evalError: string | undefined;
113
155
  try {
114
156
  const result = await this.host.evaluators
115
157
  .get(rule.evaluator.type)
@@ -117,15 +159,15 @@ export class RuleEngine {
117
159
  ruleFindings = result.findings;
118
160
  ruleEvalFixes = result.fixes;
119
161
  } catch (error) {
120
- const message = error instanceof Error ? error.message : String(error);
162
+ evalError = error instanceof Error ? error.message : String(error);
121
163
  ruleFindings = [
122
- createFinding(rule, message, null, {
164
+ createFinding(rule, evalError, null, {
123
165
  code: `evaluator:${rule.evaluator.type}`,
124
166
  kind: 'error',
125
167
  }),
126
168
  ];
127
- addSpanEvent('rule.eval.error', { ruleId: rule.id, error: message });
128
- void this.events?.emit('rule.eval.error', { ruleId: rule.id, error: message });
169
+ addSpanEvent('rule.eval.error', { ruleId: rule.id, error: evalError });
170
+ void this.events?.emit('rule.eval.error', { ruleId: rule.id, error: evalError });
129
171
  }
130
172
 
131
173
  const durationMs = Date.now() - evalStartMs;
@@ -140,6 +182,17 @@ export class RuleEngine {
140
182
  durationMs,
141
183
  });
142
184
 
185
+ // Persist eval completion.
186
+ await this.persistence?.updateEvalRun({
187
+ runId: this.runId,
188
+ ruleId: rule.id,
189
+ status: evalError ? 'failed' : 'done',
190
+ findingCount: ruleFindings.length,
191
+ fixCount: ruleEvalFixes.length,
192
+ durationMs,
193
+ ...(evalError ? { error: evalError } : {}),
194
+ });
195
+
143
196
  findings.push(...ruleFindings);
144
197
  fixes.push(...ruleEvalFixes);
145
198
 
@@ -193,6 +246,16 @@ export class RuleEngine {
193
246
  stoppedEarly: stoppedEarlyLocal,
194
247
  });
195
248
 
249
+ // Finalize the run row.
250
+ await this.persistence?.updateRunStatus(
251
+ this.runId,
252
+ 'done',
253
+ findings.length,
254
+ fixes.length,
255
+ 0, // appliedFixCount — set by the caller after applying fixes
256
+ runDurationMs,
257
+ );
258
+
196
259
  return { findings, fixes };
197
260
  },
198
261
  { attributes: { 'rule.count': enabledRules.length } },
@@ -124,12 +124,28 @@ export function relativeParent(path: string): string {
124
124
  return parent === '.' ? '' : parent;
125
125
  }
126
126
 
127
- /** Return true when a path matches any supplied fragment or suffix. */
127
+ /**
128
+ * Return true when a path matches any supplied pattern.
129
+ *
130
+ * Each pattern is matched in two passes, most-specific first:
131
+ * 1. If it contains a glob metachar (`*`), try anchored {@link matchesGlob} —
132
+ * so `packages/**\/*.ts` resolves segment-by-segment instead of collapsing
133
+ * to a bare fragment (the latter silently matched nothing, leaving every
134
+ * glob-scoped loose-mode rule scanning zero files).
135
+ * 2. Otherwise (or if the glob did not match), fall back to the legacy
136
+ * fragment/suffix substring test, where stars are stripped — preserving
137
+ * bare fragments like `.ts`, `src/`, `/tests/` that loose-mode evaluators
138
+ * have always accepted, and keeping unanchored patterns like `*.ts`
139
+ * (→ suffix `.ts`) matching nested paths.
140
+ */
128
141
  export function matchesAny(path: string, patterns: string[] | undefined): boolean {
129
142
  if (patterns === undefined || patterns.length === 0) return true;
143
+ const normalizedPath = path.replaceAll('\\', '/');
130
144
  return patterns.some((pattern) => {
131
- const clean = pattern.replaceAll('\\', '/').replaceAll('**/', '').replaceAll('*', '');
132
- return clean.length === 0 || path.includes(clean) || path.endsWith(clean);
145
+ const normalized = pattern.replaceAll('\\', '/');
146
+ if (normalized.includes('*') && matchesGlob(normalizedPath, normalized)) return true;
147
+ const clean = normalized.replaceAll('**/', '').replaceAll('*', '');
148
+ return clean.length === 0 || normalizedPath.includes(clean) || normalizedPath.endsWith(clean);
133
149
  });
134
150
  }
135
151
 
package/src/index.ts CHANGED
@@ -15,5 +15,6 @@ export * from './formatters/json';
15
15
  export * from './formatters/text';
16
16
  export * from './host/bundled-rules';
17
17
  export * from './host/rule-engine-host';
18
+ export * from './persistence';
18
19
  export * from './resolvers/test-path-resolver';
19
20
  export * from './types';
@@ -0,0 +1,66 @@
1
+ import type { FixMode } from '../types';
2
+
3
+ /** Input for inserting a rule run row (engine caller provides run metadata). */
4
+ export interface RuleRunInput {
5
+ id: string;
6
+ preset?: string;
7
+ sourceKind: 'preset' | 'file';
8
+ sourceValue?: string;
9
+ ruleCount: number;
10
+ failOn?: string;
11
+ stopOnFirst?: string;
12
+ fixMode: FixMode;
13
+ dryRun: boolean;
14
+ metadataJson?: string;
15
+ }
16
+
17
+ /** Input for inserting a per-rule eval row. */
18
+ export interface RuleEvalRunInput {
19
+ id: string;
20
+ runId: string;
21
+ ruleId: string;
22
+ severity: string;
23
+ evaluator: string;
24
+ }
25
+
26
+ /** Input for updating a per-rule eval row after evaluation completes. */
27
+ export interface RuleEvalRunUpdate {
28
+ runId: string;
29
+ ruleId: string;
30
+ status: 'done' | 'failed' | 'skipped';
31
+ findingCount: number;
32
+ fixCount: number;
33
+ durationMs: number;
34
+ /** Evaluator error message, if status is 'failed'. */
35
+ error?: string;
36
+ /** Bounded JSON for findings. */
37
+ findingsJson?: string;
38
+ /** Bounded JSON for fixes. */
39
+ fixesJson?: string;
40
+ }
41
+
42
+ /**
43
+ * Persistence adapter contract for the rule engine.
44
+ * Implementations write directly to a durable store; the engine does NOT
45
+ * use EventBus subscribers for durability.
46
+ */
47
+ export interface RulePersistenceAdapter {
48
+ /** Insert a run row as 'running'. Must be idempotent. */
49
+ insertRun(input: RuleRunInput): Promise<void>;
50
+
51
+ /** Finalize a run row after evaluation completes. */
52
+ updateRunStatus(
53
+ runId: string,
54
+ status: 'done' | 'failed',
55
+ findingCount: number,
56
+ fixCount: number,
57
+ appliedFixCount: number,
58
+ durationMs: number,
59
+ ): Promise<void>;
60
+
61
+ /** Insert a per-rule eval row as 'running'. Must be idempotent. */
62
+ insertEvalRun(input: RuleEvalRunInput): Promise<void>;
63
+
64
+ /** Finalize a per-rule eval row after evaluation completes. */
65
+ updateEvalRun(input: RuleEvalRunUpdate): Promise<void>;
66
+ }
@@ -0,0 +1,93 @@
1
+ import type { DbAdapter } from '@gobing-ai/ts-db';
2
+
3
+ import type { RuleEvalRunInput, RuleEvalRunUpdate, RulePersistenceAdapter, RuleRunInput } from './adapter';
4
+
5
+ /** Persist engine run/eval rows to SQLite via a ts-db DbAdapter. */
6
+ export class DbRulePersistenceAdapter implements RulePersistenceAdapter {
7
+ constructor(private readonly db: DbAdapter) {}
8
+
9
+ async insertRun(input: RuleRunInput): Promise<void> {
10
+ const now = new Date().toISOString();
11
+ await this.db.run(
12
+ `INSERT INTO rule_runs (id, preset, source_kind, source_value, status, rule_count,
13
+ fail_on, stop_on_first, fix_mode, dry_run, metadata_json, started_at, created_at, updated_at)
14
+ VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
15
+ input.id,
16
+ input.preset ?? null,
17
+ input.sourceKind,
18
+ input.sourceValue ?? null,
19
+ input.ruleCount,
20
+ input.failOn ?? null,
21
+ input.stopOnFirst ?? null,
22
+ input.fixMode,
23
+ input.dryRun ? 1 : 0,
24
+ input.metadataJson ?? '{}',
25
+ now,
26
+ now,
27
+ now,
28
+ );
29
+ }
30
+
31
+ async updateRunStatus(
32
+ runId: string,
33
+ status: 'done' | 'failed',
34
+ findingCount: number,
35
+ fixCount: number,
36
+ appliedFixCount: number,
37
+ durationMs: number,
38
+ ): Promise<void> {
39
+ const now = new Date().toISOString();
40
+ await this.db.run(
41
+ `UPDATE rule_runs
42
+ SET status = ?, finding_count = ?, fix_count = ?, applied_fix_count = ?,
43
+ duration_ms = ?, completed_at = ?, updated_at = ?
44
+ WHERE id = ?`,
45
+ status,
46
+ findingCount,
47
+ fixCount,
48
+ appliedFixCount,
49
+ durationMs,
50
+ now,
51
+ now,
52
+ runId,
53
+ );
54
+ }
55
+
56
+ async insertEvalRun(input: RuleEvalRunInput): Promise<void> {
57
+ const now = new Date().toISOString();
58
+ await this.db.run(
59
+ `INSERT INTO rule_eval_runs (id, run_id, rule_id, severity, evaluator, status,
60
+ started_at, created_at, updated_at)
61
+ VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?)`,
62
+ input.id,
63
+ input.runId,
64
+ input.ruleId,
65
+ input.severity,
66
+ input.evaluator,
67
+ now,
68
+ now,
69
+ now,
70
+ );
71
+ }
72
+
73
+ async updateEvalRun(input: RuleEvalRunUpdate): Promise<void> {
74
+ const now = new Date().toISOString();
75
+ await this.db.run(
76
+ `UPDATE rule_eval_runs
77
+ SET status = ?, finding_count = ?, fix_count = ?, duration_ms = ?,
78
+ error = ?, findings_json = ?, fixes_json = ?, completed_at = ?, updated_at = ?
79
+ WHERE run_id = ? AND rule_id = ?`,
80
+ input.status,
81
+ input.findingCount,
82
+ input.fixCount,
83
+ input.durationMs,
84
+ input.error ?? null,
85
+ input.findingsJson ?? null,
86
+ input.fixesJson ?? null,
87
+ now,
88
+ now,
89
+ input.runId,
90
+ input.ruleId,
91
+ );
92
+ }
93
+ }
@@ -0,0 +1,4 @@
1
+ export type { RuleEvalRunInput, RuleEvalRunUpdate, RulePersistenceAdapter, RuleRunInput } from './adapter';
2
+ export { DbRulePersistenceAdapter } from './db-adapter';
3
+ export { MemoryRulePersistenceAdapter } from './memory-adapter';
4
+ export { RULE_ENGINE_SCHEMA_SQL } from './schema';
@@ -0,0 +1,106 @@
1
+ import type { RuleEvalRunInput, RuleEvalRunUpdate, RulePersistenceAdapter, RuleRunInput } from './adapter';
2
+
3
+ /** Runtime row shape for the memory adapter. */
4
+ interface RuleRunRow extends RuleRunInput {
5
+ status: 'running' | 'done' | 'failed';
6
+ findingCount: number;
7
+ fixCount: number;
8
+ appliedFixCount: number;
9
+ durationMs: number | null;
10
+ completedAt: string | null;
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ }
14
+
15
+ interface RuleEvalRow extends RuleEvalRunInput {
16
+ status: 'running' | 'done' | 'failed' | 'skipped';
17
+ findingCount: number;
18
+ fixCount: number;
19
+ durationMs: number | null;
20
+ error: string | null;
21
+ findingsJson: string | null;
22
+ fixesJson: string | null;
23
+ completedAt: string | null;
24
+ createdAt: string;
25
+ updatedAt: string;
26
+ }
27
+
28
+ /**
29
+ * In-memory persistence adapter for unit tests.
30
+ * Stores rows in Maps so tests can assert on persisted state.
31
+ */
32
+ export class MemoryRulePersistenceAdapter implements RulePersistenceAdapter {
33
+ constructor() {
34
+ // Explicit constructor for V8 function coverage — class implements an interface, no super().
35
+ }
36
+
37
+ readonly runs = new Map<string, RuleRunRow>();
38
+ readonly evals = new Map<string, RuleEvalRow>();
39
+
40
+ async insertRun(input: RuleRunInput): Promise<void> {
41
+ const now = new Date().toISOString();
42
+ this.runs.set(input.id, {
43
+ ...input,
44
+ status: 'running',
45
+ findingCount: 0,
46
+ fixCount: 0,
47
+ appliedFixCount: 0,
48
+ durationMs: null,
49
+ completedAt: null,
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ });
53
+ }
54
+
55
+ async updateRunStatus(
56
+ runId: string,
57
+ status: 'done' | 'failed',
58
+ findingCount: number,
59
+ fixCount: number,
60
+ appliedFixCount: number,
61
+ durationMs: number,
62
+ ): Promise<void> {
63
+ const row = this.runs.get(runId);
64
+ if (!row) throw new Error(`Run not found: ${runId}`);
65
+ row.status = status;
66
+ row.findingCount = findingCount;
67
+ row.fixCount = fixCount;
68
+ row.appliedFixCount = appliedFixCount;
69
+ row.durationMs = durationMs;
70
+ row.completedAt = new Date().toISOString();
71
+ row.updatedAt = new Date().toISOString();
72
+ }
73
+
74
+ async insertEvalRun(input: RuleEvalRunInput): Promise<void> {
75
+ const now = new Date().toISOString();
76
+ const key = `${input.runId}:${input.ruleId}`;
77
+ this.evals.set(key, {
78
+ ...input,
79
+ status: 'running',
80
+ findingCount: 0,
81
+ fixCount: 0,
82
+ durationMs: null,
83
+ error: null,
84
+ findingsJson: null,
85
+ fixesJson: null,
86
+ completedAt: null,
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ });
90
+ }
91
+
92
+ async updateEvalRun(input: RuleEvalRunUpdate): Promise<void> {
93
+ const key = `${input.runId}:${input.ruleId}`;
94
+ const row = this.evals.get(key);
95
+ if (!row) throw new Error(`Eval run not found: ${key}`);
96
+ row.status = input.status;
97
+ row.findingCount = input.findingCount;
98
+ row.fixCount = input.fixCount;
99
+ row.durationMs = input.durationMs;
100
+ row.error = input.error ?? null;
101
+ row.findingsJson = input.findingsJson ?? null;
102
+ row.fixesJson = input.fixesJson ?? null;
103
+ row.completedAt = new Date().toISOString();
104
+ row.updatedAt = new Date().toISOString();
105
+ }
106
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Rule-engine persistence schema.
3
+ *
4
+ * Mirrors the workflow-engine persistence pattern: the engine owns the DDL and
5
+ * direct writes; consumers query the resulting tables through their own DAOs.
6
+ * `rule_runs` is the list/query unit; `rule_eval_runs` is the per-rule detail
7
+ * unit. Timestamps are TEXT (ISO 8601) for portability across SQLite drivers
8
+ * including D1.
9
+ */
10
+
11
+ export const RULE_ENGINE_SCHEMA_SQL = `
12
+ CREATE TABLE IF NOT EXISTS rule_runs (
13
+ id TEXT PRIMARY KEY,
14
+ preset TEXT,
15
+ source_kind TEXT NOT NULL,
16
+ source_value TEXT,
17
+ status TEXT NOT NULL DEFAULT 'running',
18
+ rule_count INTEGER NOT NULL DEFAULT 0,
19
+ finding_count INTEGER NOT NULL DEFAULT 0,
20
+ fix_count INTEGER NOT NULL DEFAULT 0,
21
+ applied_fix_count INTEGER NOT NULL DEFAULT 0,
22
+ fail_on TEXT,
23
+ stop_on_first TEXT,
24
+ fix_mode TEXT NOT NULL DEFAULT 'none',
25
+ dry_run INTEGER NOT NULL DEFAULT 0,
26
+ started_at TEXT NOT NULL,
27
+ completed_at TEXT,
28
+ duration_ms INTEGER,
29
+ metadata_json TEXT NOT NULL DEFAULT '{}',
30
+ created_at TEXT NOT NULL,
31
+ updated_at TEXT NOT NULL
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS rule_eval_runs (
35
+ id TEXT PRIMARY KEY,
36
+ run_id TEXT NOT NULL REFERENCES rule_runs(id),
37
+ rule_id TEXT NOT NULL,
38
+ severity TEXT NOT NULL,
39
+ evaluator TEXT NOT NULL,
40
+ status TEXT NOT NULL DEFAULT 'running',
41
+ finding_count INTEGER NOT NULL DEFAULT 0,
42
+ fix_count INTEGER NOT NULL DEFAULT 0,
43
+ duration_ms INTEGER,
44
+ error TEXT,
45
+ findings_json TEXT,
46
+ fixes_json TEXT,
47
+ started_at TEXT NOT NULL,
48
+ completed_at TEXT,
49
+ created_at TEXT NOT NULL,
50
+ updated_at TEXT NOT NULL
51
+ );
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_rule_eval_runs_run_id ON rule_eval_runs (run_id);
54
+ `;