@gobing-ai/ts-rule-engine 0.3.20 → 0.3.22

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.
Files changed (71) hide show
  1. package/dist/config/loader.d.ts +3 -0
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/loader.js +3 -4
  4. package/dist/engine.d.ts +8 -1
  5. package/dist/engine.d.ts.map +1 -1
  6. package/dist/engine.js +6 -3
  7. package/dist/evaluators/config-access.d.ts +38 -0
  8. package/dist/evaluators/config-access.d.ts.map +1 -0
  9. package/dist/evaluators/config-access.js +60 -0
  10. package/dist/evaluators/coverage-gate-evaluator.d.ts +1 -2
  11. package/dist/evaluators/coverage-gate-evaluator.d.ts.map +1 -1
  12. package/dist/evaluators/coverage-gate-evaluator.js +7 -6
  13. package/dist/evaluators/file-discovery.d.ts +72 -0
  14. package/dist/evaluators/file-discovery.d.ts.map +1 -0
  15. package/dist/evaluators/file-discovery.js +63 -0
  16. package/dist/evaluators/file-utils.d.ts +11 -118
  17. package/dist/evaluators/file-utils.d.ts.map +1 -1
  18. package/dist/evaluators/file-utils.js +11 -177
  19. package/dist/evaluators/forbidden-import-evaluator.d.ts +1 -0
  20. package/dist/evaluators/forbidden-import-evaluator.d.ts.map +1 -1
  21. package/dist/evaluators/forbidden-import-evaluator.js +9 -1
  22. package/dist/evaluators/glob-match.d.ts +34 -0
  23. package/dist/evaluators/glob-match.d.ts.map +1 -0
  24. package/dist/evaluators/glob-match.js +78 -0
  25. package/dist/evaluators/import-boundary-evaluator.d.ts.map +1 -1
  26. package/dist/evaluators/import-boundary-evaluator.js +3 -1
  27. package/dist/evaluators/path-evaluator.d.ts +1 -2
  28. package/dist/evaluators/path-evaluator.d.ts.map +1 -1
  29. package/dist/evaluators/path-evaluator.js +8 -6
  30. package/dist/evaluators/regex-evaluator.d.ts.map +1 -1
  31. package/dist/evaluators/regex-evaluator.js +3 -0
  32. package/dist/evaluators/schema-artifact-evaluator.d.ts +1 -2
  33. package/dist/evaluators/schema-artifact-evaluator.d.ts.map +1 -1
  34. package/dist/evaluators/schema-artifact-evaluator.js +8 -7
  35. package/dist/evaluators/secrets-scanner-evaluator.d.ts.map +1 -1
  36. package/dist/evaluators/secrets-scanner-evaluator.js +3 -1
  37. package/dist/evaluators/test-location-evaluator.d.ts.map +1 -1
  38. package/dist/evaluators/test-location-evaluator.js +3 -1
  39. package/dist/evaluators/tsdoc-export-evaluator.d.ts.map +1 -1
  40. package/dist/evaluators/tsdoc-export-evaluator.js +21 -7
  41. package/dist/fixers/fixers.d.ts +2 -4
  42. package/dist/fixers/fixers.d.ts.map +1 -1
  43. package/dist/fixers/fixers.js +9 -11
  44. package/dist/fixers/test-stub-fixer.d.ts +1 -1
  45. package/dist/fixers/test-stub-fixer.d.ts.map +1 -1
  46. package/dist/fixers/test-stub-fixer.js +2 -2
  47. package/dist/host/bundled-rules.d.ts +2 -2
  48. package/dist/host/bundled-rules.d.ts.map +1 -1
  49. package/dist/host/bundled-rules.js +24 -19
  50. package/dist/types.d.ts +8 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +5 -5
  53. package/src/config/loader.ts +14 -6
  54. package/src/engine.ts +12 -4
  55. package/src/evaluators/config-access.ts +76 -0
  56. package/src/evaluators/coverage-gate-evaluator.ts +7 -8
  57. package/src/evaluators/file-discovery.ts +132 -0
  58. package/src/evaluators/file-utils.ts +29 -258
  59. package/src/evaluators/forbidden-import-evaluator.ts +10 -2
  60. package/src/evaluators/glob-match.ts +76 -0
  61. package/src/evaluators/import-boundary-evaluator.ts +3 -1
  62. package/src/evaluators/path-evaluator.ts +8 -7
  63. package/src/evaluators/regex-evaluator.ts +3 -0
  64. package/src/evaluators/schema-artifact-evaluator.ts +9 -8
  65. package/src/evaluators/secrets-scanner-evaluator.ts +3 -1
  66. package/src/evaluators/test-location-evaluator.ts +5 -2
  67. package/src/evaluators/tsdoc-export-evaluator.ts +21 -7
  68. package/src/fixers/fixers.ts +11 -12
  69. package/src/fixers/test-stub-fixer.ts +3 -3
  70. package/src/host/bundled-rules.ts +23 -19
  71. package/src/types.ts +8 -0
@@ -1,3 +1,4 @@
1
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
1
2
  import {
2
3
  type ConstraintRule,
3
4
  createFinding,
@@ -61,7 +62,8 @@ export class SecretsScannerEvaluator implements RuleEvaluator {
61
62
  const scope = config.scope as { include?: unknown; exclude?: unknown } | undefined;
62
63
  const include = stringArray(scope?.include) ?? rule.include;
63
64
  const exclude = stringArray(scope?.exclude) ?? rule.exclude;
64
- const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'loose' });
65
+ const fs = context.fileSystem ?? createNodeFileSystem();
66
+ const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'loose', fs });
65
67
 
66
68
  const findings = [];
67
69
  for (const { file, content } of files) {
@@ -1,6 +1,8 @@
1
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
1
2
  import type { CapabilityRegistry } from '@gobing-ai/ts-runtime/extension';
2
3
  import { type TestPathResolver, TypeScriptTestPathResolver } from '../resolvers/test-path-resolver';
3
4
  import {
5
+ type ConstraintFinding,
4
6
  type ConstraintRule,
5
7
  createFinding,
6
8
  type RuleContext,
@@ -45,8 +47,9 @@ export class TestLocationEvaluator implements RuleEvaluator {
45
47
  const forbid = config.forbid ?? [];
46
48
  const include = rule.include ?? ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'];
47
49
  const exclude = rule.exclude ?? [];
48
- const allFiles = await discoverFiles({ workdir: context.workdir });
49
- const findings = [];
50
+ const findings: ConstraintFinding[] = [];
51
+ const fs = context.fileSystem ?? createNodeFileSystem();
52
+ const allFiles = await discoverFiles({ workdir: context.workdir, fs });
50
53
 
51
54
  const testPatterns = config.requireCorrespondingTest ? [expected] : include;
52
55
  const testFiles = allFiles.filter((file) => testPatterns.some((pattern) => matchesGlob(file, pattern)));
@@ -1,3 +1,4 @@
1
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
1
2
  import {
2
3
  type ConstraintRule,
3
4
  createFinding,
@@ -54,7 +55,8 @@ export class TsdocExportEvaluator implements RuleEvaluator {
54
55
  const exclude = rule.exclude ?? [];
55
56
  // Single, strict glob scoping — collapses the previous double-scoping (loose
56
57
  // discoverFiles prefilter + per-file matchesGlob) into one pass.
57
- const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'glob' });
58
+ const fs = context.fileSystem ?? createNodeFileSystem();
59
+ const files = await scanFiles({ workdir: context.workdir, include, exclude, matchMode: 'glob', fs });
58
60
 
59
61
  const findings = [];
60
62
  for (const { file, content } of files) {
@@ -94,19 +96,31 @@ function findExports(lines: string[], requested: ReadonlySet<ExportKind>): Expor
94
96
  /**
95
97
  * True when a JSDoc block precedes a declaration.
96
98
  *
97
- * Skips decorator lines (`@Component(...)`) so a documented but decorated class
98
- * is not falsely flagged, then checks whether the nearest preceding non-decorator
99
- * line closes (or is) a JSDoc comment.
99
+ * Walks backward from the line before the declaration, skipping blank lines,
100
+ * JSDoc body lines (`* …`), and decorator / JSDoc-tag lines (`@…`), then
101
+ * checks whether the nearest meaningful line closes (or is) a JSDoc comment.
100
102
  */
101
103
  function precededByJsdoc(lines: string[], declarationLine: number): boolean {
102
104
  let cursor = declarationLine - 2; // zero-based line above the declaration
103
105
  while (cursor >= 0) {
104
106
  const prev = lines[cursor]?.trim() ?? '';
105
- if (prev.startsWith('@')) {
106
- cursor -= 1; // decorator — keep walking up
107
+ // Blank line between JSDoc closing fence and declaration — keep walking.
108
+ if (prev === '') {
109
+ cursor -= 1;
107
110
  continue;
108
111
  }
109
- return prev.endsWith('*/') || prev.startsWith('/**');
112
+ // JSDoc closing or single-line fence — found it.
113
+ if (prev.endsWith('*/') || prev.startsWith('/**')) {
114
+ return true;
115
+ }
116
+ // JSDoc body line (* …) or decorator / tag (@…). Check after the
117
+ // fence so `*/` is never misidentified as a body line.
118
+ if (prev.startsWith('*') || prev.startsWith('@')) {
119
+ cursor -= 1;
120
+ continue;
121
+ }
122
+ // Non-JSDoc, non-decorator line — no JSDoc precedes this declaration.
123
+ return false;
110
124
  }
111
125
  return false;
112
126
  }
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import {
9
+ createNodeFileSystem,
10
+ type FileSystem,
9
11
  isAbsolutePath,
10
12
  joinPath,
11
- NodeFileSystem,
12
13
  type ProcessExecutor,
13
14
  relativePath,
14
15
  resolvePath,
@@ -66,7 +67,7 @@ export async function applyFixes(
66
67
  workdir: string,
67
68
  fixes: readonly Fix[],
68
69
  dryRun = false,
69
- fs: NodeFileSystem = new NodeFileSystem(),
70
+ fs: FileSystem = createNodeFileSystem(),
70
71
  ): Promise<FixApplicationResult> {
71
72
  const byFile = new Map<string, Fix[]>();
72
73
  for (const fix of fixes) {
@@ -117,7 +118,7 @@ export async function applyFixes(
117
118
  const isFullDeletion =
118
119
  next.length === 0 && selected.applied.some((fix) => fix.start === 0 && fix.end === original.length);
119
120
  if (isFullDeletion) {
120
- await fs.unlink(absPath);
121
+ await fs.deleteFile(absPath);
121
122
  } else {
122
123
  await fs.writeFile(absPath, next);
123
124
  }
@@ -173,10 +174,10 @@ function isValidRange(start: number, end: number, contentLength: number): boolea
173
174
  * replacement string (supports `$1` / `$&` back-references).
174
175
  */
175
176
  export class RegexFixerProvider implements RuleFixerProvider {
176
- private readonly fs = new NodeFileSystem();
177
-
178
177
  /** Produce fixes for regex-evaluator findings. */
179
178
  async createFixes({ rule, context, findings, fix }: RuleFixerInput): Promise<Fix[]> {
179
+ const fs = context.fileSystem ?? createNodeFileSystem();
180
+
180
181
  if (fix.mode === 'none' || fix.replacement === undefined) return [];
181
182
  // After the guard above, mode is guaranteed to not be 'none'.
182
183
  const fixMode = fix.mode as Exclude<FixMode, 'none'>;
@@ -191,8 +192,8 @@ export class RegexFixerProvider implements RuleFixerProvider {
191
192
  if (!finding.filePath) continue;
192
193
  const absPath = resolveWorkdirPath(context.workdir, finding.filePath);
193
194
  if (!isInsideWorkdir(context.workdir, absPath) || finding.line == null) continue;
194
- if (!(await this.fs.exists(absPath))) continue;
195
- const source = await this.fs.readFile(absPath);
195
+ if (!(await fs.exists(absPath))) continue;
196
+ const source = await fs.readFile(absPath);
196
197
  const line = getLineRange(source, finding.line);
197
198
  if (!line) continue;
198
199
  for (const match of source.slice(line.start, line.end).matchAll(regex)) {
@@ -220,18 +221,16 @@ export class RegexFixerProvider implements RuleFixerProvider {
220
221
  * `applyFixes` interprets a zero-length result spanning the full file as a deletion.
221
222
  */
222
223
  export class PathFixerProvider implements RuleFixerProvider {
223
- private readonly fs = new NodeFileSystem();
224
-
225
224
  /** Produce deletion fixes for path-evaluator findings. */
226
225
  async createFixes({ rule, context, findings, fix }: RuleFixerInput): Promise<Fix[]> {
226
+ const fs = context.fileSystem ?? createNodeFileSystem();
227
227
  if (fix.mode !== 'auto' || rule.evaluator.config?.must !== 'absent') return [];
228
228
  const fixes: Fix[] = [];
229
229
  for (const finding of findings) {
230
230
  if (!finding.filePath) continue;
231
231
  const absPath = resolveWorkdirPath(context.workdir, finding.filePath);
232
- if (!isInsideWorkdir(context.workdir, absPath)) continue;
233
- if (!(await this.fs.exists(absPath))) continue;
234
- const content = await this.fs.readFile(absPath);
232
+ if (!(await fs.exists(absPath))) continue;
233
+ const content = await fs.readFile(absPath);
235
234
  fixes.push({
236
235
  ruleId: rule.id,
237
236
  filePath: finding.filePath,
@@ -11,7 +11,7 @@
11
11
  * @module rule-engine/fixers/test-stub-fixer
12
12
  */
13
13
 
14
- import { isAbsolutePath, joinPath, NodeFileSystem, type ProcessExecutor } from '@gobing-ai/ts-runtime';
14
+ import { createNodeFileSystem, isAbsolutePath, joinPath, type ProcessExecutor } from '@gobing-ai/ts-runtime';
15
15
  import type { CapabilityRegistry } from '@gobing-ai/ts-runtime/extension';
16
16
  import type { TestPathResolver } from '../resolvers/test-path-resolver';
17
17
  import type { Fix } from '../types';
@@ -20,11 +20,11 @@ import type { RuleFixerInput, RuleFixerProvider } from './fixers';
20
20
  /** File-system seam for dependency injection in tests. */
21
21
  export interface TestStubFileSystem {
22
22
  /** Resolve to true if the path exists on disk. */
23
- exists(path: string): Promise<boolean>;
23
+ exists(path: string): boolean | Promise<boolean>;
24
24
  }
25
25
 
26
26
  /** Real file-system implementation backed by the runtime FileSystem seam. */
27
- export const realFileSystem: TestStubFileSystem = new NodeFileSystem();
27
+ export const realFileSystem: TestStubFileSystem = createNodeFileSystem();
28
28
 
29
29
  /** Dependencies injected into {@link TestStubFixer}. */
30
30
  export interface TestStubFixerDeps {
@@ -1,4 +1,4 @@
1
- import { dirnamePath, joinPath, NodeSyncFileSystem, type SyncFileSystem } from '@gobing-ai/ts-runtime';
1
+ import { createNodeFileSystem, dirnamePath, type FileSystem, joinPath } from '@gobing-ai/ts-runtime';
2
2
 
3
3
  /**
4
4
  * Directory name holding the rule presets and category folders bundled with this
@@ -9,7 +9,8 @@ const BUNDLED_RULES_DIR = 'rules';
9
9
 
10
10
  /** Memoized result so the upward filesystem walk runs at most once per process. */
11
11
  let cachedRoot: string | null | undefined;
12
- const defaultFs = new NodeSyncFileSystem();
12
+ /** In-flight resolution to prevent racing concurrent calls. */
13
+ let resolving: Promise<string | null> | undefined;
13
14
 
14
15
  /**
15
16
  * Resolve the absolute path to the rule presets bundled with
@@ -26,26 +27,28 @@ const defaultFs = new NodeSyncFileSystem();
26
27
  * which makes it robust to the build's `src`→`dist` layout shift. Returns `null`
27
28
  * if the directory is absent (e.g. a partial install that excluded the assets).
28
29
  */
29
- export function bundledRulesRoot(): string | null {
30
- return bundledRulesRootWithFs(defaultFs);
30
+ export async function bundledRulesRoot(): Promise<string | null> {
31
+ if (cachedRoot !== undefined) return cachedRoot;
32
+ if (resolving !== undefined) return resolving;
33
+ resolving = bundledRulesRootWithFs(createNodeFileSystem());
34
+ cachedRoot = await resolving;
35
+ resolving = undefined;
36
+ return cachedRoot;
31
37
  }
32
38
 
33
- function bundledRulesRootWithFs(fs: SyncFileSystem): string | null {
34
- if (cachedRoot !== undefined) return cachedRoot;
39
+ async function bundledRulesRootWithFs(fs: FileSystem): Promise<string | null> {
35
40
  let dir = import.meta.dirname;
36
- // Walk to filesystem root at most; the package root is only a few levels up.
37
41
  while (true) {
38
42
  const candidate = joinPath(dir, BUNDLED_RULES_DIR);
39
- if (fs.stat(candidate)?.isDirectory() === true) {
40
- cachedRoot = candidate;
41
- return cachedRoot;
43
+ const st = await fs.stat(candidate);
44
+ if (st?.isDirectory() === true) {
45
+ return candidate;
42
46
  }
43
47
  const parent = dirnamePath(dir);
44
48
  if (parent === dir) break;
45
49
  dir = parent;
46
50
  }
47
- cachedRoot = null;
48
- return cachedRoot;
51
+ return null;
49
52
  }
50
53
 
51
54
  /**
@@ -56,20 +59,21 @@ function bundledRulesRootWithFs(fs: SyncFileSystem): string | null {
56
59
  * (e.g. a per-user global rules directory) on first run. Returns an empty array
57
60
  * when no bundled directory is present.
58
61
  */
59
- export function listBundledRuleFiles(): string[] {
60
- const root = bundledRulesRoot();
62
+ export async function listBundledRuleFiles(): Promise<string[]> {
63
+ const root = await bundledRulesRoot();
61
64
  if (root === null) return [];
62
- return walk(defaultFs, root, '').sort();
65
+ return (await walk(createNodeFileSystem(), root, '')).sort();
63
66
  }
64
67
 
65
68
  /** Recursively collect YAML/JSON files under `dir`, returning paths relative to the walk origin. */
66
- function walk(fs: SyncFileSystem, dir: string, relPrefix: string): string[] {
69
+ async function walk(fs: FileSystem, dir: string, relPrefix: string): Promise<string[]> {
67
70
  const acc: string[] = [];
68
- for (const entry of fs.readDir(dir)) {
71
+ for (const entry of await fs.readDir(dir)) {
69
72
  const abs = joinPath(dir, entry);
70
73
  const rel = relPrefix.length > 0 ? `${relPrefix}/${entry}` : entry;
71
- if (fs.stat(abs)?.isDirectory() === true) {
72
- acc.push(...walk(fs, abs, rel));
74
+ const st = await fs.stat(abs);
75
+ if (st?.isDirectory() === true) {
76
+ acc.push(...(await walk(fs, abs, rel)));
73
77
  } else if (/\.(ya?ml|json)$/i.test(entry)) {
74
78
  acc.push(rel);
75
79
  }
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { FileSystem } from '@gobing-ai/ts-runtime';
1
2
  import { assertRelativeExtensionPath } from '@gobing-ai/ts-runtime/extension';
2
3
  import { z } from 'zod';
3
4
 
@@ -156,6 +157,13 @@ export interface RuleContext {
156
157
  workdir: string;
157
158
  /** Rule being evaluated. */
158
159
  rule: ConstraintRule;
160
+ /**
161
+ * Filesystem port (ADR-011 union-return {@link FileSystem}); defaults to
162
+ * `createNodeFileSystem()` when constructed by `RuleEngine`. Evaluators and
163
+ * fixer providers MUST read files through this port rather than constructing
164
+ * their own filesystem instance, so a host can inject a virtual fs in tests.
165
+ */
166
+ fileSystem?: FileSystem;
159
167
  }
160
168
 
161
169
  /** Evaluator implementation contract. */