@rigour-labs/core 1.0.0 → 1.4.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.
@@ -0,0 +1,150 @@
1
+ import ts from 'typescript';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { globby } from 'globby';
5
+ import { Gate, GateContext } from './base.js';
6
+ import { Failure, Gates } from '../types/index.js';
7
+ import micromatch from 'micromatch';
8
+
9
+ export class ASTGate extends Gate {
10
+ constructor(private config: Gates) {
11
+ super('ast-analysis', 'AST Structural Analysis');
12
+ }
13
+
14
+ async run(context: GateContext): Promise<Failure[]> {
15
+ const failures: Failure[] = [];
16
+ const files = await globby(['**/*.{ts,js,tsx,jsx}'], {
17
+ cwd: context.cwd,
18
+ ignore: ['node_modules/**', 'dist/**', 'build/**', '**/*.test.*', '**/*.spec.*'],
19
+ });
20
+
21
+ for (const file of files) {
22
+ const fullPath = path.join(context.cwd, file);
23
+ const content = await fs.readFile(fullPath, 'utf-8');
24
+ const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
25
+
26
+ this.analyzeSourceFile(sourceFile, file, failures);
27
+ }
28
+
29
+ return failures;
30
+ }
31
+
32
+ private analyzeSourceFile(sourceFile: ts.SourceFile, relativePath: string, failures: Failure[]) {
33
+ const astConfig = this.config.ast || {};
34
+ const maxComplexity = astConfig.complexity || 10;
35
+ const maxMethods = astConfig.max_methods || 10;
36
+ const maxParams = astConfig.max_params || 5;
37
+
38
+ const visit = (node: ts.Node) => {
39
+ // 1. Complexity & Params for functions
40
+ if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isArrowFunction(node)) {
41
+ const name = this.getNodeName(node);
42
+
43
+ // Parameter count
44
+ if (node.parameters.length > maxParams) {
45
+ failures.push(this.createFailure(
46
+ `Function '${name}' has ${node.parameters.length} parameters (max: ${maxParams})`,
47
+ [relativePath],
48
+ `Reduce number of parameters or use an options object.`
49
+ ));
50
+ // Update: Failures in Runner will be mapped to FixPacket
51
+ (failures[failures.length - 1] as any).metrics = { count: node.parameters.length, max: maxParams };
52
+ }
53
+
54
+ // Cyclomatic Complexity (Simplified: nodes that cause branching)
55
+ let complexity = 1;
56
+ const countComplexity = (n: ts.Node) => {
57
+ if (ts.isIfStatement(n) || ts.isCaseClause(n) || ts.isDefaultClause(n) ||
58
+ ts.isForStatement(n) || ts.isForInStatement(n) || ts.isForOfStatement(n) ||
59
+ ts.isWhileStatement(n) || ts.isDoStatement(n) || ts.isConditionalExpression(n)) {
60
+ complexity++;
61
+ }
62
+ if (ts.isBinaryExpression(n)) {
63
+ if (n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
64
+ n.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
65
+ complexity++;
66
+ }
67
+ }
68
+ ts.forEachChild(n, countComplexity);
69
+ };
70
+ ts.forEachChild(node, countComplexity);
71
+
72
+ if (complexity > maxComplexity) {
73
+ failures.push(this.createFailure(
74
+ `Function '${name}' has cyclomatic complexity of ${complexity} (max: ${maxComplexity})`,
75
+ [relativePath],
76
+ `Refactor '${name}' into smaller, more focused functions.`
77
+ ));
78
+ (failures[failures.length - 1] as any).metrics = { complexity, max: maxComplexity };
79
+ }
80
+ }
81
+
82
+ // 2. Class metrics
83
+ if (ts.isClassDeclaration(node)) {
84
+ const name = node.name?.text || 'Anonymous Class';
85
+ const methods = node.members.filter(ts.isMethodDeclaration);
86
+
87
+ if (methods.length > maxMethods) {
88
+ failures.push(this.createFailure(
89
+ `Class '${name}' has ${methods.length} methods (max: ${maxMethods})`,
90
+ [relativePath],
91
+ `Class '${name}' is becoming a 'God Object'. Split it into smaller services.`
92
+ ));
93
+ (failures[failures.length - 1] as any).metrics = { methodCount: methods.length, max: maxMethods };
94
+ }
95
+ }
96
+
97
+ // 3. Import check for Layer Boundaries
98
+ if (ts.isImportDeclaration(node)) {
99
+ const importPath = (node.moduleSpecifier as ts.StringLiteral).text;
100
+ this.checkBoundary(importPath, relativePath, failures);
101
+ }
102
+
103
+ ts.forEachChild(node, visit);
104
+ };
105
+
106
+ ts.forEachChild(sourceFile, visit);
107
+ }
108
+
109
+ private checkBoundary(importPath: string, relativePath: string, failures: Failure[]) {
110
+ const boundaries = (this.config as any).architecture?.boundaries || [];
111
+ if (boundaries.length === 0) return;
112
+
113
+ for (const rule of boundaries) {
114
+ const isFromMatch = micromatch.isMatch(relativePath, rule.from);
115
+ if (isFromMatch) {
116
+ // Approximate resolution (simplified for now)
117
+ // Real implementation would need to handle alias and absolute path resolution
118
+ const resolved = importPath.startsWith('.')
119
+ ? path.join(path.dirname(relativePath), importPath)
120
+ : importPath;
121
+
122
+ const isToMatch = micromatch.isMatch(resolved, rule.to);
123
+
124
+ if (rule.mode === 'deny' && isToMatch) {
125
+ failures.push(this.createFailure(
126
+ `Architectural Violation: '${relativePath}' is forbidden from importing '${importPath}' (denied by boundary rule).`,
127
+ [relativePath],
128
+ `Remove this import to maintain architectural layering.`
129
+ ));
130
+ } else if (rule.mode === 'allow' && !isToMatch && importPath.startsWith('.')) {
131
+ // Complexity: Allow rules are trickier to implement strictly without full resolution
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ private getNodeName(node: ts.Node): string {
138
+ if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) {
139
+ return node.name?.getText() || 'anonymous';
140
+ }
141
+ if (ts.isArrowFunction(node)) {
142
+ const parent = node.parent;
143
+ if (ts.isVariableDeclaration(parent)) {
144
+ return parent.name.getText();
145
+ }
146
+ return 'anonymous arrow';
147
+ }
148
+ return 'unknown';
149
+ }
150
+ }
@@ -0,0 +1,44 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { Failure, Config } from '../types/index.js';
4
+ import { Gate, GateContext } from './base.js';
5
+
6
+ export class DependencyGate extends Gate {
7
+ constructor(private config: Config) {
8
+ super('dependency-guardian', 'Dependency Guardian');
9
+ }
10
+
11
+ async run(context: GateContext): Promise<Failure[]> {
12
+ const failures: Failure[] = [];
13
+ const forbidden = this.config.gates.dependencies?.forbid || [];
14
+
15
+ if (forbidden.length === 0) return [];
16
+
17
+ const { cwd } = context;
18
+
19
+ // 1. Scan Node.js (package.json)
20
+ const pkgPath = path.join(cwd, 'package.json');
21
+ if (await fs.pathExists(pkgPath)) {
22
+ try {
23
+ const pkg = await fs.readJson(pkgPath);
24
+ const allDeps = {
25
+ ...(pkg.dependencies || {}),
26
+ ...(pkg.devDependencies || {}),
27
+ ...(pkg.peerDependencies || {}),
28
+ };
29
+
30
+ for (const dep of forbidden) {
31
+ if (allDeps[dep]) {
32
+ failures.push(this.createFailure(
33
+ `The package '${dep}' is forbidden by project standards.`,
34
+ ['package.json'],
35
+ `Remove '${dep}' from package.json and use approved alternatives.`
36
+ ));
37
+ }
38
+ }
39
+ } catch (e) { }
40
+ }
41
+
42
+ return failures;
43
+ }
44
+ }
@@ -3,6 +3,9 @@ import { Failure, Config, Report, Status } from '../types/index.js';
3
3
  import { FileGate } from './file.js';
4
4
  import { ContentGate } from './content.js';
5
5
  import { StructureGate } from './structure.js';
6
+ import { ASTGate } from './ast.js';
7
+ import { SafetyGate } from './safety.js';
8
+ import { DependencyGate } from './dependency.js';
6
9
  import { execa } from 'execa';
7
10
  import { Logger } from '../utils/logger.js';
8
11
 
@@ -26,6 +29,9 @@ export class GateRunner {
26
29
  if (this.config.gates.required_files) {
27
30
  this.gates.push(new StructureGate({ requiredFiles: this.config.gates.required_files }));
28
31
  }
32
+ this.gates.push(new ASTGate(this.config.gates));
33
+ this.gates.push(new DependencyGate(this.config));
34
+ this.gates.push(new SafetyGate(this.config.gates));
29
35
  }
30
36
 
31
37
  /**
@@ -0,0 +1,49 @@
1
+ import { Gate, GateContext } from './base.js';
2
+ import { Failure, Gates } from '../types/index.js';
3
+ import { execa } from 'execa';
4
+
5
+ export class SafetyGate extends Gate {
6
+ constructor(private config: Gates) {
7
+ super('safety-rail', 'Safety & Protection Rails');
8
+ }
9
+
10
+ async run(context: GateContext): Promise<Failure[]> {
11
+ const failures: Failure[] = [];
12
+ const safety = this.config.safety || {};
13
+ const protectedPaths = safety.protected_paths || [];
14
+
15
+ if (protectedPaths.length === 0) return [];
16
+
17
+ try {
18
+ // Check for modified files in protected paths using git
19
+ // This is a "Safety Rail" - if an agent touched these, we fail.
20
+ const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: context.cwd });
21
+ const modifiedFiles = stdout.split('\n')
22
+ .filter(line => line.trim().length > 0)
23
+ .map(line => line.slice(3));
24
+
25
+ for (const file of modifiedFiles) {
26
+ if (this.isProtected(file, protectedPaths)) {
27
+ failures.push(this.createFailure(
28
+ `Protected file '${file}' was modified.`,
29
+ [file],
30
+ `Agents are forbidden from modifying files in ${protectedPaths.join(', ')}.`
31
+ ));
32
+ }
33
+ }
34
+ } catch (error) {
35
+ // If not a git repo, skip safety for now
36
+ }
37
+
38
+ return failures;
39
+ }
40
+
41
+ private isProtected(file: string, patterns: string[]): boolean {
42
+ return patterns.some(p => {
43
+ const cleanP = p.replace('/**', '').replace('/*', '');
44
+ if (file === cleanP) return true;
45
+ if (cleanP.endsWith('/')) return file.startsWith(cleanP);
46
+ return file.startsWith(cleanP + '/');
47
+ });
48
+ }
49
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from './types/index.js';
2
2
  export * from './gates/runner.js';
3
3
  export * from './discovery.js';
4
+ export * from './services/fix-packet-service.js';
4
5
  export * from './templates/index.js';
6
+ export * from './types/fix-packet.js';
7
+ export { Gate, GateContext } from './gates/base.js';
5
8
  export * from './utils/logger.js';
@@ -0,0 +1,42 @@
1
+ import { Report, Failure, Config } from '../types/index.js';
2
+ import { FixPacketV2, FixPacketV2Schema } from '../types/fix-packet.js';
3
+
4
+ export class FixPacketService {
5
+ generate(report: Report, config: Config): FixPacketV2 {
6
+ const violations = report.failures.map(f => ({
7
+ id: f.id,
8
+ gate: f.id,
9
+ severity: this.inferSeverity(f),
10
+ title: f.title,
11
+ details: f.details,
12
+ files: f.files,
13
+ hint: f.hint,
14
+ instructions: f.hint ? [f.hint] : [], // Use hint as first instruction
15
+ metrics: (f as any).metrics,
16
+ }));
17
+
18
+ const packet: FixPacketV2 = {
19
+ version: 2,
20
+ goal: "Achieve PASS state by resolving all listed engineering violations.",
21
+ violations,
22
+ constraints: {
23
+ paradigm: config.paradigm,
24
+ protected_paths: config.gates.safety?.protected_paths,
25
+ do_not_touch: config.gates.safety?.protected_paths,
26
+ max_files_changed: config.gates.safety?.max_files_changed_per_cycle,
27
+ no_new_deps: true,
28
+ },
29
+ };
30
+
31
+ return FixPacketV2Schema.parse(packet);
32
+ }
33
+
34
+ private inferSeverity(f: Failure): "low" | "medium" | "high" | "critical" {
35
+ // High complexity or God objects are usually High severity
36
+ if (f.id === 'ast-analysis') return 'high';
37
+ // Unit test or Lint failures are Medium
38
+ if (f.id === 'test' || f.id === 'lint') return 'medium';
39
+ // Documentation or small file size issues are Low
40
+ return 'medium';
41
+ }
42
+ }
@@ -1,60 +1,137 @@
1
- import { Config } from '../types/index.js';
1
+ import { Config, Commands, Gates } from '../types/index.js';
2
2
 
3
3
  export interface Template {
4
4
  name: string;
5
5
  markers: string[];
6
- config: Partial<Config>;
6
+ config: {
7
+ preset?: string;
8
+ paradigm?: string;
9
+ commands?: Partial<Commands>;
10
+ gates?: Partial<Gates>;
11
+ planned?: string[];
12
+ };
7
13
  }
8
14
 
9
15
  export const TEMPLATES: Template[] = [
10
16
  {
11
- name: 'Node.js',
12
- markers: ['package.json'],
17
+ name: 'ui',
18
+ markers: [
19
+ 'react',
20
+ 'next',
21
+ 'vue',
22
+ 'svelte',
23
+ 'next.config.js',
24
+ 'vite.config.ts',
25
+ 'tailwind.config.js',
26
+ 'base.css',
27
+ 'index.html',
28
+ ],
13
29
  config: {
14
- commands: {
15
- lint: 'npm run lint',
16
- test: 'npm test',
30
+ preset: 'ui',
31
+ gates: {
32
+ max_file_lines: 300,
33
+ required_files: ['docs/SPEC.md', 'docs/ARCH.md', 'README.md'],
17
34
  },
35
+ planned: [
36
+ 'Layer Boundary: Components cannot import from DB',
37
+ 'Prop-Drilling Detection: Max depth 5',
38
+ ],
39
+ },
40
+ },
41
+ {
42
+ name: 'api',
43
+ markers: [
44
+ 'express',
45
+ 'fastify',
46
+ 'nestjs',
47
+ 'go.mod',
48
+ 'requirements.txt',
49
+ 'pyproject.toml',
50
+ 'app.py',
51
+ 'main.go',
52
+ 'index.js',
53
+ ],
54
+ config: {
55
+ preset: 'api',
18
56
  gates: {
19
- max_file_lines: 500,
20
- forbid_todos: true,
21
- forbid_fixme: true,
22
- forbid_paths: [],
57
+ max_file_lines: 400,
23
58
  required_files: ['docs/SPEC.md', 'docs/ARCH.md', 'README.md'],
24
59
  },
60
+ planned: [
61
+ 'Service Layer Enforcement: Controllers -> Services only',
62
+ 'Repo Pattern: Databases access isolated to repositories/',
63
+ ],
25
64
  },
26
65
  },
27
66
  {
28
- name: 'Python',
29
- markers: ['pyproject.toml', 'requirements.txt', 'setup.py'],
67
+ name: 'infra',
68
+ markers: [
69
+ 'Dockerfile',
70
+ 'docker-compose.yml',
71
+ 'main.tf',
72
+ 'k8s/',
73
+ 'helm/',
74
+ 'ansible/',
75
+ ],
30
76
  config: {
31
- commands: {
32
- lint: 'ruff check .',
33
- test: 'pytest',
77
+ preset: 'infra',
78
+ gates: {
79
+ max_file_lines: 300,
80
+ required_files: ['docs/RUNBOOK.md', 'docs/ARCH.md', 'README.md'],
34
81
  },
82
+ },
83
+ },
84
+ {
85
+ name: 'data',
86
+ markers: [
87
+ 'ipynb',
88
+ 'spark',
89
+ 'pandas',
90
+ 'data/',
91
+ 'dbt_project.yml',
92
+ ],
93
+ config: {
94
+ preset: 'data',
35
95
  gates: {
36
96
  max_file_lines: 500,
37
- forbid_todos: true,
38
- forbid_fixme: true,
39
- forbid_paths: [],
40
- required_files: ['docs/SPEC.md', 'docs/ARCH.md', 'README.md'],
97
+ required_files: ['docs/DATA_DICTIONARY.md', 'docs/PIPELINE.md', 'README.md'],
41
98
  },
99
+ planned: [
100
+ 'Stochastic Determinism: Seed setting enforcement',
101
+ 'Data Leaks: Detecting PII in notebook outputs',
102
+ ],
42
103
  },
43
104
  },
105
+ ];
106
+
107
+ export const PARADIGM_TEMPLATES: Template[] = [
44
108
  {
45
- name: 'Frontend (React/Vite/Next)',
46
- markers: ['next.config.js', 'vite.config.ts', 'tailwind.config.js'],
109
+ name: 'oop',
110
+ markers: [
111
+ 'class ',
112
+ 'interface ',
113
+ 'implements ',
114
+ 'extends ',
115
+ ],
47
116
  config: {
48
- commands: {
49
- lint: 'npm run lint',
50
- test: 'npm test',
117
+ paradigm: 'oop',
118
+ gates: {
119
+ // Future: class-specific gates
51
120
  },
121
+ },
122
+ },
123
+ {
124
+ name: 'functional',
125
+ markers: [
126
+ '=>',
127
+ 'export const',
128
+ 'map(',
129
+ 'filter(',
130
+ ],
131
+ config: {
132
+ paradigm: 'functional',
52
133
  gates: {
53
- max_file_lines: 300, // Frontend files often should be smaller
54
- forbid_todos: true,
55
- forbid_fixme: true,
56
- forbid_paths: [],
57
- required_files: ['docs/SPEC.md', 'docs/ARCH.md', 'README.md'],
134
+ // Future: function-specific gates
58
135
  },
59
136
  },
60
137
  },
@@ -69,8 +146,24 @@ export const UNIVERSAL_CONFIG: Config = {
69
146
  forbid_fixme: true,
70
147
  forbid_paths: [],
71
148
  required_files: ['docs/SPEC.md', 'docs/ARCH.md', 'docs/DECISIONS.md', 'docs/TASKS.md'],
149
+ ast: {
150
+ complexity: 10,
151
+ max_methods: 10,
152
+ max_params: 5,
153
+ },
154
+ dependencies: {
155
+ forbid: [],
156
+ },
157
+ architecture: {
158
+ boundaries: [],
159
+ },
160
+ safety: {
161
+ max_files_changed_per_cycle: 10,
162
+ protected_paths: ['.github/**', 'docs/**', 'rigour.yml'],
163
+ },
72
164
  },
73
165
  output: {
74
166
  report_path: 'rigour-report.json',
75
167
  },
168
+ planned: [],
76
169
  };
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Fix Packet v2 Schema
5
+ * Designed for high-fidelity communication with AI agents during the refinement loop.
6
+ */
7
+ export const FixPacketV2Schema = z.object({
8
+ version: z.literal(2),
9
+ goal: z.string().default('Achieve PASS state for all quality gates'),
10
+ violations: z.array(z.object({
11
+ id: z.string(),
12
+ gate: z.string(),
13
+ severity: z.enum(['low', 'medium', 'high', 'critical']).default('medium'),
14
+ category: z.string().optional(),
15
+ title: z.string(),
16
+ details: z.string(),
17
+ files: z.array(z.string()).optional(),
18
+ hint: z.string().optional(),
19
+ instructions: z.array(z.string()).optional(), // Step-by-step fix instructions
20
+ metrics: z.record(z.any()).optional(), // e.g., { complexity: 15, max: 10 }
21
+ })),
22
+ constraints: z.object({
23
+ protected_paths: z.array(z.string()).optional(),
24
+ do_not_touch: z.array(z.string()).optional(), // Alias for protected_paths
25
+ max_files_changed: z.number().optional(),
26
+ no_new_deps: z.boolean().optional().default(true),
27
+ allowed_dependencies: z.array(z.string()).optional(),
28
+ paradigm: z.string().optional(), // 'oop', 'functional'
29
+ }).optional().default({}),
30
+ });
31
+
32
+ export type FixPacketV2 = z.infer<typeof FixPacketV2Schema>;
@@ -11,6 +11,26 @@ export const GatesSchema = z.object({
11
11
  'docs/DECISIONS.md',
12
12
  'docs/TASKS.md',
13
13
  ]),
14
+ ast: z.object({
15
+ complexity: z.number().optional().default(10),
16
+ max_methods: z.number().optional().default(10),
17
+ max_params: z.number().optional().default(5),
18
+ }).optional().default({}),
19
+ dependencies: z.object({
20
+ forbid: z.array(z.string()).optional().default([]),
21
+ trusted_registry: z.string().optional(),
22
+ }).optional().default({}),
23
+ architecture: z.object({
24
+ boundaries: z.array(z.object({
25
+ from: z.string(),
26
+ to: z.string(),
27
+ mode: z.enum(['allow', 'deny']).default('deny'),
28
+ })).optional().default([]),
29
+ }).optional().default({}),
30
+ safety: z.object({
31
+ max_files_changed_per_cycle: z.number().optional().default(10),
32
+ protected_paths: z.array(z.string()).optional().default(['.github/**', 'docs/**', 'rigour.yml']),
33
+ }).optional().default({}),
14
34
  });
15
35
 
16
36
  export const CommandsSchema = z.object({
@@ -22,11 +42,14 @@ export const CommandsSchema = z.object({
22
42
 
23
43
  export const ConfigSchema = z.object({
24
44
  version: z.number().default(1),
45
+ preset: z.string().optional(),
46
+ paradigm: z.string().optional(),
25
47
  commands: CommandsSchema.optional().default({}),
26
48
  gates: GatesSchema.optional().default({}),
27
49
  output: z.object({
28
50
  report_path: z.string().default('rigour-report.json'),
29
51
  }).optional().default({}),
52
+ planned: z.array(z.string()).optional().default([]),
30
53
  });
31
54
 
32
55
  export type Gates = z.infer<typeof GatesSchema>;