azcodr 2.1.0 → 2.3.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.
Files changed (96) hide show
  1. package/.agents/scripts/agent_guard.js +63 -0
  2. package/.agents/skills/lets-build/SKILL.md +1 -1
  3. package/.github/workflows/ci.yml +9 -0
  4. package/AGENTS.md +1 -1
  5. package/CONTRIBUTING.md +79 -0
  6. package/README.md +4 -3
  7. package/SECURITY.md +32 -0
  8. package/bin/azcodr.js +3 -1
  9. package/data/memory.template +36 -0
  10. package/docs/rules/clean_code.md +13 -0
  11. package/docs/tipping-points.md +37 -0
  12. package/lib/agent-guard-command.d.ts +28 -0
  13. package/lib/agent-guard-command.d.ts.map +1 -0
  14. package/lib/agent-guard-command.js +103 -0
  15. package/lib/agent-guard-command.js.map +1 -0
  16. package/lib/agent-guard-file.d.ts +32 -0
  17. package/lib/agent-guard-file.d.ts.map +1 -0
  18. package/lib/agent-guard-file.js +77 -0
  19. package/lib/agent-guard-file.js.map +1 -0
  20. package/lib/agent-guard-tdd.d.ts +34 -0
  21. package/lib/agent-guard-tdd.d.ts.map +1 -0
  22. package/lib/agent-guard-tdd.js +77 -0
  23. package/lib/agent-guard-tdd.js.map +1 -0
  24. package/lib/agent-guard.d.ts +38 -0
  25. package/lib/agent-guard.d.ts.map +1 -0
  26. package/lib/agent-guard.js +80 -0
  27. package/lib/agent-guard.js.map +1 -0
  28. package/lib/boundaries.d.ts +41 -0
  29. package/lib/boundaries.d.ts.map +1 -0
  30. package/lib/boundaries.js +158 -0
  31. package/lib/boundaries.js.map +1 -0
  32. package/lib/cli-parse.d.ts +4 -0
  33. package/lib/cli-parse.d.ts.map +1 -1
  34. package/lib/cli-parse.js +23 -5
  35. package/lib/cli-parse.js.map +1 -1
  36. package/lib/cli.d.ts +14 -0
  37. package/lib/cli.d.ts.map +1 -1
  38. package/lib/cli.js +33 -5
  39. package/lib/cli.js.map +1 -1
  40. package/lib/errors.d.ts +15 -0
  41. package/lib/errors.d.ts.map +1 -1
  42. package/lib/errors.js +9 -0
  43. package/lib/errors.js.map +1 -1
  44. package/lib/git.d.ts +8 -0
  45. package/lib/git.d.ts.map +1 -1
  46. package/lib/git.js +8 -0
  47. package/lib/git.js.map +1 -1
  48. package/lib/guards.d.ts +22 -1
  49. package/lib/guards.d.ts.map +1 -1
  50. package/lib/guards.js +23 -2
  51. package/lib/guards.js.map +1 -1
  52. package/lib/index.d.ts +34 -0
  53. package/lib/index.d.ts.map +1 -1
  54. package/lib/index.js +25 -0
  55. package/lib/index.js.map +1 -1
  56. package/lib/links.d.ts +14 -0
  57. package/lib/links.d.ts.map +1 -1
  58. package/lib/links.js +11 -0
  59. package/lib/links.js.map +1 -1
  60. package/lib/permissions.d.ts +4 -0
  61. package/lib/permissions.d.ts.map +1 -1
  62. package/lib/permissions.js +4 -0
  63. package/lib/permissions.js.map +1 -1
  64. package/lib/repo.d.ts +10 -0
  65. package/lib/repo.d.ts.map +1 -1
  66. package/lib/repo.js +11 -4
  67. package/lib/repo.js.map +1 -1
  68. package/lib/scaffold.d.ts +29 -5
  69. package/lib/scaffold.d.ts.map +1 -1
  70. package/lib/scaffold.js +27 -25
  71. package/lib/scaffold.js.map +1 -1
  72. package/lib/validate.d.ts +33 -0
  73. package/lib/validate.d.ts.map +1 -0
  74. package/lib/validate.js +24 -0
  75. package/lib/validate.js.map +1 -0
  76. package/memory.md +16 -0
  77. package/package.json +7 -6
  78. package/scripts/validate/links.js +0 -1
  79. package/scripts/validate/root.js +1 -1
  80. package/src/agent-guard-command.ts +118 -0
  81. package/src/agent-guard-file.ts +107 -0
  82. package/src/agent-guard-tdd.ts +96 -0
  83. package/src/agent-guard.ts +123 -0
  84. package/src/boundaries.ts +198 -0
  85. package/src/cli-parse.ts +26 -4
  86. package/src/cli.ts +41 -15
  87. package/src/errors.ts +15 -0
  88. package/src/git.ts +8 -0
  89. package/src/guards.ts +27 -2
  90. package/src/index.ts +61 -0
  91. package/src/links.ts +14 -0
  92. package/src/permissions.ts +4 -0
  93. package/src/repo.ts +14 -4
  94. package/src/scaffold.ts +42 -36
  95. package/src/validate.ts +47 -0
  96. package/scripts/test_coverage.js +0 -66
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export interface SessionTddState {
5
+ lastFailingTestRecorded: boolean;
6
+ lastTestRunTime?: number;
7
+ lastTestPath?: string;
8
+ }
9
+
10
+ export interface TddCheckResult {
11
+ blocked: boolean;
12
+ reason?: string;
13
+ isProductionFile: boolean;
14
+ }
15
+
16
+ export function isTestFile(filePath: string): boolean {
17
+ const norm = filePath.replace(/\\/g, '/').toLowerCase();
18
+ if (/(^|\/)(tests?|__tests__)(\/|$)/i.test(norm)) {
19
+ return true;
20
+ }
21
+ return /\.(test|spec)\.[a-z0-9]+$/i.test(norm);
22
+ }
23
+
24
+ export function isNonCodeFile(filePath: string): boolean {
25
+ const ext = path.extname(filePath).toLowerCase();
26
+ const nonCodeExts = new Set(['.md', '.json', '.yml', '.yaml', '.txt', '.toml', '.lock', '.svg', '.png']);
27
+ return nonCodeExts.has(ext);
28
+ }
29
+
30
+ export function isProductionFile(filePath: string): boolean {
31
+ if (isNonCodeFile(filePath)) return false;
32
+ if (isTestFile(filePath)) return false;
33
+ const norm = filePath.replace(/\\/g, '/').toLowerCase();
34
+ return norm.includes('src/') || norm.includes('lib/') || norm.includes('app/');
35
+ }
36
+
37
+ export function readTddState(stateFile: string): SessionTddState {
38
+ if (!fs.existsSync(stateFile)) {
39
+ return { lastFailingTestRecorded: false };
40
+ }
41
+ try {
42
+ const raw = fs.readFileSync(stateFile, 'utf-8');
43
+ return JSON.parse(raw) as SessionTddState;
44
+ } catch {
45
+ return { lastFailingTestRecorded: false };
46
+ }
47
+ }
48
+
49
+ export function writeTddState(stateFile: string, state: SessionTddState): void {
50
+ try {
51
+ const dir = path.dirname(stateFile);
52
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
53
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
54
+ } catch {
55
+ // Fail-safe write: do not crash agent if state cannot be persisted
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Checks whether an edit to a file is allowed under RED-before-GREEN TDD rules.
61
+ *
62
+ * @param filePath - Path to file being edited.
63
+ * @param state - Current session TDD state.
64
+ * @param enforceTestFirst - Whether test-first enforcement is strictly enabled.
65
+ * @returns Decision result indicating whether edit is blocked.
66
+ */
67
+ export function inspectTddRequirement(
68
+ filePath: string,
69
+ state: SessionTddState,
70
+ enforceTestFirst: boolean = false
71
+ ): TddCheckResult {
72
+ const prod = isProductionFile(filePath);
73
+ if (!enforceTestFirst || !prod) {
74
+ return { blocked: false, isProductionFile: prod };
75
+ }
76
+
77
+ if (!state.lastFailingTestRecorded) {
78
+ const baseName = path.basename(filePath);
79
+ return {
80
+ blocked: true,
81
+ isProductionFile: true,
82
+ reason: `Test-First (RED-before-GREEN) requirement: Production file '${baseName}' cannot be edited before a failing test (RED) is recorded. Author a failing unit or acceptance test first, verify failure output, then edit production code.`
83
+ };
84
+ }
85
+
86
+ return { blocked: false, isProductionFile: true };
87
+ }
88
+
89
+ export default {
90
+ isTestFile,
91
+ isNonCodeFile,
92
+ isProductionFile,
93
+ readTddState,
94
+ writeTddState,
95
+ inspectTddRequirement
96
+ };
@@ -0,0 +1,123 @@
1
+ import path from 'node:path';
2
+ import { inspectCommand } from './agent-guard-command.js';
3
+ import type { CommandViolation } from './agent-guard-command.js';
4
+ import { inspectFileWrite } from './agent-guard-file.js';
5
+ import type { FileCheckResult } from './agent-guard-file.js';
6
+ import {
7
+ inspectTddRequirement,
8
+ readTddState,
9
+ writeTddState
10
+ } from './agent-guard-tdd.js';
11
+ import type { SessionTddState } from './agent-guard-tdd.js';
12
+
13
+ export interface GuardDecision {
14
+ allowed: boolean;
15
+ reason?: string;
16
+ source?: 'command' | 'file' | 'tdd';
17
+ }
18
+
19
+ export interface ToolEnvelope {
20
+ tool_name?: string;
21
+ tool_input?: Record<string, unknown>;
22
+ }
23
+
24
+ export interface GuardOptions {
25
+ workspaceRoot?: string;
26
+ enforceTestFirst?: boolean;
27
+ maxFileLines?: number;
28
+ }
29
+
30
+ function parseEnvelope(raw: string): ToolEnvelope | null {
31
+ try {
32
+ const trimmed = raw.trim();
33
+ if (!trimmed.startsWith('{')) return null;
34
+ return JSON.parse(trimmed) as ToolEnvelope;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function extractKey(input: Record<string, unknown>, keys: readonly string[]): string | undefined {
41
+ for (const k of keys) {
42
+ if (typeof input[k] === 'string') return input[k];
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ function extractFilePath(input: Record<string, unknown>): string | undefined {
48
+ return extractKey(input, ['TargetFile', 'file_path', 'path', 'filePath']);
49
+ }
50
+
51
+ function extractCommand(input: Record<string, unknown>): string | undefined {
52
+ return extractKey(input, ['CommandLine', 'command', 'cmd']);
53
+ }
54
+
55
+ function inspectPreCommand(cmd: string): GuardDecision {
56
+ const violation = inspectCommand(cmd);
57
+ if (violation.blocked) {
58
+ return { allowed: false, reason: violation.reason, source: 'command' };
59
+ }
60
+ return { allowed: true };
61
+ }
62
+
63
+ function inspectPreFile(
64
+ filePath: string,
65
+ input: Record<string, unknown>,
66
+ options: GuardOptions
67
+ ): GuardDecision {
68
+ const incoming = extractKey(input, ['CodeContent', 'content']);
69
+ const replacement = extractKey(input, ['ReplacementContent', 'replacement']);
70
+ const target = extractKey(input, ['TargetContent', 'target']);
71
+
72
+ const fileDecision = inspectFileWrite({
73
+ filePath,
74
+ incomingContent: incoming,
75
+ replacementContent: replacement,
76
+ targetContent: target,
77
+ maxLines: options.maxFileLines
78
+ });
79
+ if (fileDecision.blocked) {
80
+ return { allowed: false, reason: fileDecision.reason, source: 'file' };
81
+ }
82
+
83
+ const root = options.workspaceRoot ?? process.cwd();
84
+ const statePath = path.join(root, '.agents', '.session-state.json');
85
+ const state = readTddState(statePath);
86
+ const tddDecision = inspectTddRequirement(filePath, state, options.enforceTestFirst);
87
+ if (tddDecision.blocked) {
88
+ return { allowed: false, reason: tddDecision.reason, source: 'tdd' };
89
+ }
90
+
91
+ return { allowed: true };
92
+ }
93
+
94
+ /**
95
+ * Inspects a tool-use request before execution (PreToolUse hook).
96
+ *
97
+ * @param rawInput - Raw command string, JSON envelope, or argv slice.
98
+ * @param options - Guard configuration options.
99
+ * @returns Guard decision indicating whether tool execution is permitted.
100
+ */
101
+ export function inspectPreTool(rawInput: string, options: GuardOptions = {}): GuardDecision {
102
+ const env = parseEnvelope(rawInput);
103
+
104
+ if (env && env.tool_input) {
105
+ const cmd = extractCommand(env.tool_input);
106
+ if (cmd) return inspectPreCommand(cmd);
107
+
108
+ const targetFile = extractFilePath(env.tool_input);
109
+ if (targetFile) return inspectPreFile(targetFile, env.tool_input, options);
110
+ }
111
+
112
+ return inspectPreCommand(rawInput);
113
+ }
114
+
115
+ export {
116
+ inspectCommand,
117
+ inspectFileWrite,
118
+ inspectTddRequirement,
119
+ readTddState,
120
+ writeTddState
121
+ };
122
+ export type { CommandViolation, FileCheckResult, SessionTddState };
123
+ export default { inspectPreTool, inspectCommand, inspectFileWrite, inspectTddRequirement };
@@ -0,0 +1,198 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export interface BoundaryRule {
5
+ fromLayer: string;
6
+ disallowImportsFrom: readonly string[];
7
+ }
8
+
9
+ export interface BoundaryViolation {
10
+ file: string;
11
+ importedFile: string;
12
+ fromLayer: string;
13
+ toLayer: string;
14
+ }
15
+
16
+ export interface BoundaryReport {
17
+ moduleCount: number;
18
+ cycles: string[][];
19
+ violations: BoundaryViolation[];
20
+ ok: boolean;
21
+ }
22
+
23
+ export const DEFAULT_BOUNDARY_RULES: readonly BoundaryRule[] = [
24
+ {
25
+ fromLayer: 'domain',
26
+ disallowImportsFrom: ['infrastructure', 'infra', 'adapters', 'cli', 'transport', 'controllers']
27
+ },
28
+ {
29
+ fromLayer: 'core',
30
+ disallowImportsFrom: ['infrastructure', 'infra', 'adapters', 'presentation', 'ui']
31
+ }
32
+ ];
33
+
34
+ function isSkippedDir(name: string): boolean {
35
+ return name.startsWith('.') || name === 'node_modules' || name === 'lib';
36
+ }
37
+
38
+ function isEligibleSource(name: string): boolean {
39
+ const isScript = name.endsWith('.ts') || name.endsWith('.js');
40
+ if (!isScript || name.endsWith('.d.ts')) return false;
41
+ return !name.includes('.test.') && !name.includes('.spec.');
42
+ }
43
+
44
+ export function findSourceFiles(dir: string): string[] {
45
+ if (!fs.existsSync(dir)) return [];
46
+ const results: string[] = [];
47
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
48
+
49
+ for (const entry of entries) {
50
+ if (isSkippedDir(entry.name)) continue;
51
+ const full = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ results.push(...findSourceFiles(full));
54
+ } else if (isEligibleSource(entry.name)) {
55
+ results.push(path.resolve(full));
56
+ }
57
+ }
58
+
59
+ return results.sort();
60
+ }
61
+
62
+ function resolveImportTarget(sourceFile: string, specifier: string): string | null {
63
+ const dir = path.dirname(sourceFile);
64
+ const candidate = path.resolve(dir, specifier);
65
+ if (fs.existsSync(candidate)) return candidate;
66
+
67
+ if (specifier.endsWith('.js')) {
68
+ const tsCandidate = path.resolve(dir, specifier.slice(0, -3) + '.ts');
69
+ if (fs.existsSync(tsCandidate)) return tsCandidate;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export function extractLocalImports(filePath: string): string[] {
75
+ if (!fs.existsSync(filePath)) return [];
76
+ const content = fs.readFileSync(filePath, 'utf-8');
77
+ const imports: string[] = [];
78
+ const regex = /(?:import|export)\s+(?:(?:[\s\S]*?from\s+)|)['"](\.[^'"]+)['"]|import\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
79
+
80
+ for (const match of content.matchAll(regex)) {
81
+ const spec = match[1] ?? match[2];
82
+ if (spec) {
83
+ const resolved = resolveImportTarget(filePath, spec);
84
+ if (resolved && !imports.includes(resolved)) {
85
+ imports.push(resolved);
86
+ }
87
+ }
88
+ }
89
+
90
+ return imports.sort();
91
+ }
92
+
93
+ export function buildDependencyGraph(files: readonly string[]): Map<string, string[]> {
94
+ const graph = new Map<string, string[]>();
95
+ for (const file of files) {
96
+ graph.set(file, extractLocalImports(file));
97
+ }
98
+ return graph;
99
+ }
100
+
101
+ export function detectDependencyCycles(graph: Map<string, string[]>): string[][] {
102
+ const cycles: string[][] = [];
103
+ const visited = new Set<string>();
104
+ const inStack = new Set<string>();
105
+ const pathStack: string[] = [];
106
+
107
+ function dfs(node: string): void {
108
+ visited.add(node);
109
+ inStack.add(node);
110
+ pathStack.push(node);
111
+
112
+ for (const neighbor of graph.get(node) ?? []) {
113
+ if (!visited.has(neighbor)) {
114
+ dfs(neighbor);
115
+ } else if (inStack.has(neighbor)) {
116
+ const startIdx = pathStack.indexOf(neighbor);
117
+ if (startIdx !== -1) {
118
+ cycles.push([...pathStack.slice(startIdx), neighbor]);
119
+ }
120
+ }
121
+ }
122
+
123
+ inStack.delete(node);
124
+ pathStack.pop();
125
+ }
126
+
127
+ for (const node of graph.keys()) {
128
+ if (!visited.has(node)) dfs(node);
129
+ }
130
+
131
+ return cycles;
132
+ }
133
+
134
+ function hasLayerSegment(pathStr: string, segment: string): boolean {
135
+ return new RegExp(`(^|[/\\\\])${segment}([/\\\\]|$)`, 'i').test(pathStr);
136
+ }
137
+
138
+ export function detectBoundaryViolations(
139
+ graph: Map<string, string[]>,
140
+ rules: readonly BoundaryRule[] = DEFAULT_BOUNDARY_RULES
141
+ ): BoundaryViolation[] {
142
+ const violations: BoundaryViolation[] = [];
143
+
144
+ for (const [file, imports] of graph.entries()) {
145
+ for (const rule of rules) {
146
+ if (hasLayerSegment(file, rule.fromLayer)) {
147
+ for (const imported of imports) {
148
+ for (const disallowed of rule.disallowImportsFrom) {
149
+ if (hasLayerSegment(imported, disallowed)) {
150
+ violations.push({
151
+ file,
152
+ importedFile: imported,
153
+ fromLayer: rule.fromLayer,
154
+ toLayer: disallowed
155
+ });
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return violations;
164
+ }
165
+
166
+ /**
167
+ * Inspects a source tree for architectural boundary violations and circular dependency cycles.
168
+ *
169
+ * @param sourceDir - Directory containing source code (e.g. src/).
170
+ * @param rules - Architectural boundary rules.
171
+ * @returns Report summarizing cycle and boundary findings.
172
+ */
173
+ export function inspectBoundaries(
174
+ sourceDir: string,
175
+ rules: readonly BoundaryRule[] = DEFAULT_BOUNDARY_RULES
176
+ ): BoundaryReport {
177
+ const files = findSourceFiles(sourceDir);
178
+ const graph = buildDependencyGraph(files);
179
+ const cycles = detectDependencyCycles(graph);
180
+ const violations = detectBoundaryViolations(graph, rules);
181
+
182
+ return {
183
+ moduleCount: files.length,
184
+ cycles,
185
+ violations,
186
+ ok: cycles.length === 0 && violations.length === 0
187
+ };
188
+ }
189
+
190
+ export default {
191
+ DEFAULT_BOUNDARY_RULES,
192
+ findSourceFiles,
193
+ extractLocalImports,
194
+ buildDependencyGraph,
195
+ detectDependencyCycles,
196
+ detectBoundaryViolations,
197
+ inspectBoundaries
198
+ };
package/src/cli-parse.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * raise the complexity of the parser itself.
6
6
  */
7
7
 
8
+ export type CliCommand = 'scaffold' | 'check';
9
+
8
10
  export interface CliParsedOptions {
11
+ command: CliCommand;
9
12
  targetDir: string | null;
10
13
  force: boolean;
11
14
  noGit: boolean;
@@ -18,9 +21,18 @@ export interface CliParsedOptions {
18
21
  export type BooleanFlagKey = 'force' | 'noGit' | 'dryRun' | 'silent';
19
22
 
20
23
  export function emptyOptions(): Omit<CliParsedOptions, 'terminal' | 'message'> {
21
- return { targetDir: null, force: false, noGit: false, dryRun: false, silent: false };
24
+ return {
25
+ command: 'scaffold',
26
+ targetDir: null,
27
+ force: false,
28
+ noGit: false,
29
+ dryRun: false,
30
+ silent: false
31
+ };
22
32
  }
23
33
 
34
+ export const CHECK_COMMANDS = new Set(['check', 'validate', 'audit']);
35
+
24
36
  export const BOOLEAN_FLAGS = new Map<string, BooleanFlagKey>([
25
37
  ['-f', 'force'],
26
38
  ['--force', 'force'],
@@ -36,12 +48,18 @@ export interface ArgOutcome {
36
48
  message?: string;
37
49
  }
38
50
 
51
+ function checkSpecialFlag(arg: string): 'help' | 'version' | null {
52
+ if (arg === '-h' || arg === '--help') return 'help';
53
+ if (arg === '-v' || arg === '--version') return 'version';
54
+ return null;
55
+ }
56
+
39
57
  export function applyArg(
40
58
  state: Omit<CliParsedOptions, 'terminal' | 'message'>,
41
59
  arg: string
42
60
  ): ArgOutcome {
43
- if (arg === '-h' || arg === '--help') return { terminal: 'help' };
44
- if (arg === '-v' || arg === '--version') return { terminal: 'version' };
61
+ const special = checkSpecialFlag(arg);
62
+ if (special !== null) return { terminal: special };
45
63
  const flag = BOOLEAN_FLAGS.get(arg);
46
64
  if (flag !== undefined) {
47
65
  state[flag] = true;
@@ -53,6 +71,10 @@ export function applyArg(
53
71
  message: `Unknown argument '${arg}'. Run 'npx azcodr --help' for available options.`
54
72
  };
55
73
  }
74
+ if (state.targetDir === null && state.command === 'scaffold' && CHECK_COMMANDS.has(arg)) {
75
+ state.command = 'check';
76
+ return { terminal: null };
77
+ }
56
78
  if (!state.targetDir) {
57
79
  state.targetDir = arg;
58
80
  return { terminal: null };
@@ -74,4 +96,4 @@ export function parseArgs(rawArgs: readonly string[]): CliParsedOptions {
74
96
  return { ...state, terminal: null, message: undefined };
75
97
  }
76
98
 
77
- export default { emptyOptions, BOOLEAN_FLAGS, applyArg, parseArgs };
99
+ export default { emptyOptions, BOOLEAN_FLAGS, CHECK_COMMANDS, applyArg, parseArgs };
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import process from 'node:process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { scaffold, getTemplateDir } from './scaffold.js';
6
6
  import type { ScaffoldOptions, ScaffoldResult } from './scaffold.js';
7
+ import { validate, createSilentReporter } from './validate.js';
7
8
  import { parseArgs } from './cli-parse.js';
8
9
  import type { CliParsedOptions } from './cli-parse.js';
9
10
  import { askQuestion, resolveTargetDir, ensureWritableTarget } from './cli-target.js';
@@ -26,9 +27,11 @@ Enterprise Multi-Tenant Architecture & Agentic Engineering Starter Template
26
27
 
27
28
  Usage:
28
29
  npx azcodr [directory] [options]
30
+ npx azcodr check [directory] [options]
29
31
 
30
32
  Commands:
31
- [directory] Scaffold azcodr template into directory (default: current directory)
33
+ [directory] Scaffold azcodr template into directory (default: current directory)
34
+ check [directory] Validate architecture and governance rules (alias: validate, audit)
32
35
 
33
36
  Options:
34
37
  -d, --dry-run Simulate scaffolding without modifying filesystem
@@ -40,8 +43,9 @@ Options:
40
43
 
41
44
  Examples:
42
45
  npx azcodr my-project
46
+ npx azcodr check
47
+ npx azcodr check ./existing-repo
43
48
  npx azcodr . --dry-run
44
- npx azcodr . --force
45
49
  `);
46
50
  }
47
51
 
@@ -58,6 +62,7 @@ export interface CliIo {
58
62
  cwd?: string;
59
63
  templateDir?: string;
60
64
  scaffold?: (options?: ScaffoldOptions) => ScaffoldResult;
65
+ validate?: (workspaceRoot: string, reporter?: any) => { errors: number; warnings: number } | Promise<{ errors: number; warnings: number }>;
61
66
  }
62
67
 
63
68
  export interface NormalizedCliIo {
@@ -69,20 +74,28 @@ export interface NormalizedCliIo {
69
74
  cwd: string;
70
75
  templateDir: string;
71
76
  scaffoldFn: (options?: ScaffoldOptions) => ScaffoldResult;
77
+ validateFn: (workspaceRoot: string, reporter?: any) => { errors: number; warnings: number } | Promise<{ errors: number; warnings: number }>;
72
78
  }
73
79
 
74
- function normalizeIo(io: CliIo = {}): NormalizedCliIo {
75
- const {
76
- out = console.log,
77
- err = console.error,
78
- exit = process.exit,
79
- stdin = process.stdin,
80
- stdout = process.stdout,
81
- cwd = process.cwd(),
82
- templateDir = getTemplateDir(),
83
- scaffold: scaffoldFn = scaffold
84
- } = io;
85
- return { out, err, exit, stdin, stdout, cwd, templateDir, scaffoldFn };
80
+ function defaultStreams(io: CliIo) {
81
+ return {
82
+ out: io.out ?? console.log,
83
+ err: io.err ?? console.error,
84
+ exit: io.exit ?? process.exit,
85
+ stdin: io.stdin ?? process.stdin,
86
+ stdout: io.stdout ?? process.stdout
87
+ };
88
+ }
89
+
90
+ function normalizeIo(io: CliIo): NormalizedCliIo {
91
+ const streams = defaultStreams(io);
92
+ return {
93
+ ...streams,
94
+ cwd: io.cwd ?? process.cwd(),
95
+ templateDir: io.templateDir ?? getTemplateDir(),
96
+ scaffoldFn: io.scaffold ?? scaffold,
97
+ validateFn: io.validate ?? validate
98
+ };
86
99
  }
87
100
 
88
101
  function outBanner(fullIo: NormalizedCliIo): void {
@@ -103,6 +116,16 @@ function handleTerminal(parsed: CliParsedOptions, io: NormalizedCliIo): void | n
103
116
  return exit(1);
104
117
  }
105
118
 
119
+ async function handleCheckCommand(
120
+ parsed: CliParsedOptions,
121
+ fullIo: NormalizedCliIo
122
+ ): Promise<void | number> {
123
+ const checkDir = parsed.targetDir ? path.resolve(fullIo.cwd, parsed.targetDir) : fullIo.cwd;
124
+ const reporter = parsed.silent ? createSilentReporter() : undefined;
125
+ const result = await fullIo.validateFn(checkDir, reporter);
126
+ return fullIo.exit(result.errors === 0 ? 0 : 1);
127
+ }
128
+
106
129
  function reportDryRun(result: ScaffoldResult, out: (msg: string) => void): void {
107
130
  for (const action of result.actions) {
108
131
  out(` [preview] ${action}`);
@@ -199,10 +222,13 @@ export async function runCli(
199
222
  io: CliIo = {}
200
223
  ): Promise<void | number> {
201
224
  const fullIo = normalizeIo(io);
202
-
203
225
  const parsed = parseArgs(rawArgs);
204
226
  if (parsed.terminal !== null) return handleTerminal(parsed, fullIo);
205
227
 
228
+ if (parsed.command === 'check') {
229
+ return handleCheckCommand(parsed, fullIo);
230
+ }
231
+
206
232
  if (!parsed.silent) {
207
233
  outBanner(fullIo);
208
234
  }
package/src/errors.ts CHANGED
@@ -15,17 +15,32 @@ export const ERROR_CODES = {
15
15
  E_PATH_ESCAPE: 'Path escapes allowed root'
16
16
  } as const;
17
17
 
18
+ /**
19
+ * Union of valid machine-readable error codes for scaffolding failures.
20
+ */
18
21
  export type ScaffoldErrorCode = keyof typeof ERROR_CODES;
19
22
 
23
+ /**
24
+ * Structural interface contract for errors thrown by the scaffolder.
25
+ */
20
26
  export interface ScaffoldErrorShape extends Error {
21
27
  name: 'ScaffoldError';
22
28
  code: ScaffoldErrorCode;
23
29
  }
24
30
 
31
+ /**
32
+ * Custom error class thrown by scaffolding operations, carrying a machine-readable code.
33
+ */
25
34
  export class ScaffoldError extends Error implements ScaffoldErrorShape {
26
35
  override readonly name: 'ScaffoldError' = 'ScaffoldError';
27
36
  readonly code: ScaffoldErrorCode;
28
37
 
38
+ /**
39
+ * Constructs a new ScaffoldError instance.
40
+ *
41
+ * @param code - Machine-readable error code.
42
+ * @param message - Human-readable explanation of the error.
43
+ */
29
44
  constructor(code: ScaffoldErrorCode, message: string) {
30
45
  super(message);
31
46
  this.code = code;
package/src/git.ts CHANGED
@@ -15,6 +15,14 @@ export const GIT_ALLOWED_SUBCOMMANDS = new Set([
15
15
  'commit'
16
16
  ]);
17
17
 
18
+ /**
19
+ * Safely executes an allowlisted git subcommand using execFileSync with an argument array and shell disabled.
20
+ *
21
+ * @param args - Subcommand and arguments array to pass to git.
22
+ * @param options - Child process execution options.
23
+ * @returns Standard output from git as a string.
24
+ * @throws {ScaffoldError} When args is empty or the subcommand is not in GIT_ALLOWED_SUBCOMMANDS.
25
+ */
18
26
  export function runGit(args: string[], options: cp.ExecFileSyncOptions = {}): string {
19
27
  if (!Array.isArray(args) || args.length === 0) {
20
28
  throw new ScaffoldError('E_GIT_ARGS_INVALID', ERROR_CODES.E_GIT_ARGS_INVALID);