chati-dev 1.4.0 → 2.0.2
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 +40 -24
- package/framework/agents/build/dev.md +343 -0
- package/framework/agents/clarity/architect.md +112 -0
- package/framework/agents/clarity/brief.md +182 -0
- package/framework/agents/clarity/brownfield-wu.md +181 -0
- package/framework/agents/clarity/detail.md +110 -0
- package/framework/agents/clarity/greenfield-wu.md +153 -0
- package/framework/agents/clarity/ux.md +112 -0
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +31 -1
- package/framework/context/governance.md +37 -0
- package/framework/context/protocols.md +34 -0
- package/framework/context/quality.md +27 -0
- package/framework/context/root.md +24 -0
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/architect.yaml +51 -0
- package/framework/domains/agents/brief.yaml +47 -0
- package/framework/domains/agents/brownfield-wu.yaml +49 -0
- package/framework/domains/agents/detail.yaml +47 -0
- package/framework/domains/agents/dev.yaml +49 -0
- package/framework/domains/agents/devops.yaml +43 -0
- package/framework/domains/agents/greenfield-wu.yaml +47 -0
- package/framework/domains/agents/orchestrator.yaml +49 -0
- package/framework/domains/agents/phases.yaml +47 -0
- package/framework/domains/agents/qa-implementation.yaml +43 -0
- package/framework/domains/agents/qa-planning.yaml +44 -0
- package/framework/domains/agents/tasks.yaml +48 -0
- package/framework/domains/agents/ux.yaml +50 -0
- package/framework/domains/constitution.yaml +77 -0
- package/framework/domains/global.yaml +64 -0
- package/framework/domains/workflows/brownfield-discovery.yaml +16 -0
- package/framework/domains/workflows/brownfield-fullstack.yaml +26 -0
- package/framework/domains/workflows/brownfield-service.yaml +22 -0
- package/framework/domains/workflows/brownfield-ui.yaml +22 -0
- package/framework/domains/workflows/greenfield-fullstack.yaml +26 -0
- package/framework/hooks/constitution-guard.js +101 -0
- package/framework/hooks/mode-governance.js +92 -0
- package/framework/hooks/model-governance.js +76 -0
- package/framework/hooks/prism-engine.js +89 -0
- package/framework/hooks/session-digest.js +60 -0
- package/framework/hooks/settings.json +44 -0
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/migrations/v1.4-to-v2.0.yaml +167 -0
- package/framework/migrations/v2.0-to-v2.0.1.yaml +132 -0
- package/framework/orchestrator/chati.md +284 -6
- package/framework/tasks/architect-api-design.md +63 -0
- package/framework/tasks/architect-consolidate.md +47 -0
- package/framework/tasks/architect-db-design.md +73 -0
- package/framework/tasks/architect-design.md +95 -0
- package/framework/tasks/architect-security-review.md +62 -0
- package/framework/tasks/architect-stack-selection.md +53 -0
- package/framework/tasks/brief-consolidate.md +249 -0
- package/framework/tasks/brief-constraint-identify.md +277 -0
- package/framework/tasks/brief-extract-requirements.md +339 -0
- package/framework/tasks/brief-stakeholder-map.md +176 -0
- package/framework/tasks/brief-validate-completeness.md +121 -0
- package/framework/tasks/brownfield-wu-architecture-map.md +394 -0
- package/framework/tasks/brownfield-wu-deep-discovery.md +312 -0
- package/framework/tasks/brownfield-wu-dependency-scan.md +359 -0
- package/framework/tasks/brownfield-wu-migration-plan.md +483 -0
- package/framework/tasks/brownfield-wu-report.md +325 -0
- package/framework/tasks/brownfield-wu-risk-assess.md +424 -0
- package/framework/tasks/detail-acceptance-criteria.md +372 -0
- package/framework/tasks/detail-consolidate.md +138 -0
- package/framework/tasks/detail-edge-case-analysis.md +300 -0
- package/framework/tasks/detail-expand-prd.md +389 -0
- package/framework/tasks/detail-nfr-extraction.md +223 -0
- package/framework/tasks/dev-code-review.md +404 -0
- package/framework/tasks/dev-consolidate.md +543 -0
- package/framework/tasks/dev-debug.md +322 -0
- package/framework/tasks/dev-implement.md +252 -0
- package/framework/tasks/dev-iterate.md +411 -0
- package/framework/tasks/dev-pr-prepare.md +497 -0
- package/framework/tasks/dev-refactor.md +342 -0
- package/framework/tasks/dev-test-write.md +306 -0
- package/framework/tasks/devops-ci-setup.md +412 -0
- package/framework/tasks/devops-consolidate.md +712 -0
- package/framework/tasks/devops-deploy-config.md +598 -0
- package/framework/tasks/devops-monitoring-setup.md +658 -0
- package/framework/tasks/devops-release-prepare.md +673 -0
- package/framework/tasks/greenfield-wu-analyze-empty.md +169 -0
- package/framework/tasks/greenfield-wu-report.md +266 -0
- package/framework/tasks/greenfield-wu-scaffold-detection.md +203 -0
- package/framework/tasks/greenfield-wu-tech-stack-assess.md +255 -0
- package/framework/tasks/orchestrator-deviation.md +260 -0
- package/framework/tasks/orchestrator-escalate.md +276 -0
- package/framework/tasks/orchestrator-handoff.md +243 -0
- package/framework/tasks/orchestrator-health.md +372 -0
- package/framework/tasks/orchestrator-mode-switch.md +262 -0
- package/framework/tasks/orchestrator-resume.md +189 -0
- package/framework/tasks/orchestrator-route.md +169 -0
- package/framework/tasks/orchestrator-spawn-terminal.md +358 -0
- package/framework/tasks/orchestrator-status.md +260 -0
- package/framework/tasks/orchestrator-suggest-mode.md +372 -0
- package/framework/tasks/phases-breakdown.md +91 -0
- package/framework/tasks/phases-dependency-mapping.md +67 -0
- package/framework/tasks/phases-mvp-scoping.md +94 -0
- package/framework/tasks/qa-impl-consolidate.md +522 -0
- package/framework/tasks/qa-impl-performance-test.md +487 -0
- package/framework/tasks/qa-impl-regression-check.md +413 -0
- package/framework/tasks/qa-impl-sast-scan.md +402 -0
- package/framework/tasks/qa-impl-test-execute.md +344 -0
- package/framework/tasks/qa-impl-verdict.md +339 -0
- package/framework/tasks/qa-planning-consolidate.md +309 -0
- package/framework/tasks/qa-planning-coverage-plan.md +338 -0
- package/framework/tasks/qa-planning-gate-define.md +339 -0
- package/framework/tasks/qa-planning-risk-matrix.md +631 -0
- package/framework/tasks/qa-planning-test-strategy.md +217 -0
- package/framework/tasks/tasks-acceptance-write.md +75 -0
- package/framework/tasks/tasks-consolidate.md +57 -0
- package/framework/tasks/tasks-decompose.md +80 -0
- package/framework/tasks/tasks-estimate.md +66 -0
- package/framework/tasks/ux-a11y-check.md +49 -0
- package/framework/tasks/ux-component-map.md +55 -0
- package/framework/tasks/ux-consolidate.md +46 -0
- package/framework/tasks/ux-user-flow.md +46 -0
- package/framework/tasks/ux-wireframe.md +76 -0
- package/package.json +2 -2
- package/scripts/bundle-framework.js +2 -0
- package/scripts/changelog-generator.js +222 -0
- package/scripts/codebase-mapper.js +728 -0
- package/scripts/commit-message-generator.js +167 -0
- package/scripts/coverage-analyzer.js +260 -0
- package/scripts/dependency-analyzer.js +280 -0
- package/scripts/framework-analyzer.js +308 -0
- package/scripts/generate-constitution-domain.js +253 -0
- package/scripts/health-check.js +481 -0
- package/scripts/ide-sync.js +327 -0
- package/scripts/performance-analyzer.js +325 -0
- package/scripts/plan-tracker.js +278 -0
- package/scripts/populate-entity-registry.js +481 -0
- package/scripts/pr-review.js +317 -0
- package/scripts/rollback-manager.js +310 -0
- package/scripts/stuck-detector.js +343 -0
- package/scripts/test-quality-assessment.js +257 -0
- package/scripts/validate-agents.js +367 -0
- package/scripts/validate-tasks.js +465 -0
- package/src/autonomy/autonomous-gate.js +293 -0
- package/src/autonomy/index.js +51 -0
- package/src/autonomy/mode-manager.js +225 -0
- package/src/autonomy/mode-suggester.js +283 -0
- package/src/autonomy/progress-reporter.js +268 -0
- package/src/autonomy/safety-net.js +320 -0
- package/src/context/bracket-tracker.js +79 -0
- package/src/context/domain-loader.js +107 -0
- package/src/context/engine.js +144 -0
- package/src/context/formatter.js +184 -0
- package/src/context/index.js +4 -0
- package/src/context/layers/l0-constitution.js +28 -0
- package/src/context/layers/l1-global.js +37 -0
- package/src/context/layers/l2-agent.js +39 -0
- package/src/context/layers/l3-workflow.js +42 -0
- package/src/context/layers/l4-task.js +24 -0
- package/src/decision/analyzer.js +167 -0
- package/src/decision/engine.js +270 -0
- package/src/decision/index.js +38 -0
- package/src/decision/registry-healer.js +450 -0
- package/src/decision/registry-updater.js +330 -0
- package/src/gates/circuit-breaker.js +119 -0
- package/src/gates/g1-planning-complete.js +153 -0
- package/src/gates/g2-qa-planning.js +153 -0
- package/src/gates/g3-implementation.js +188 -0
- package/src/gates/g4-qa-implementation.js +207 -0
- package/src/gates/g5-deploy-ready.js +180 -0
- package/src/gates/gate-base.js +144 -0
- package/src/gates/index.js +46 -0
- package/src/installer/brownfield-upgrader.js +249 -0
- package/src/installer/core.js +82 -11
- package/src/installer/file-hasher.js +51 -0
- package/src/installer/manifest.js +117 -0
- package/src/installer/templates.js +17 -15
- package/src/installer/transaction.js +229 -0
- package/src/installer/validator.js +18 -1
- package/src/intelligence/registry-manager.js +2 -2
- package/src/memory/agent-memory.js +255 -0
- package/src/memory/gotchas-injector.js +72 -0
- package/src/memory/gotchas.js +361 -0
- package/src/memory/index.js +35 -0
- package/src/memory/search.js +233 -0
- package/src/memory/session-digest.js +239 -0
- package/src/merger/env-merger.js +112 -0
- package/src/merger/index.js +56 -0
- package/src/merger/replace-merger.js +51 -0
- package/src/merger/yaml-merger.js +127 -0
- package/src/orchestrator/agent-selector.js +285 -0
- package/src/orchestrator/deviation-handler.js +350 -0
- package/src/orchestrator/handoff-engine.js +271 -0
- package/src/orchestrator/index.js +67 -0
- package/src/orchestrator/intent-classifier.js +264 -0
- package/src/orchestrator/pipeline-manager.js +492 -0
- package/src/orchestrator/pipeline-state.js +223 -0
- package/src/orchestrator/session-manager.js +409 -0
- package/src/tasks/executor.js +195 -0
- package/src/tasks/handoff.js +226 -0
- package/src/tasks/index.js +4 -0
- package/src/tasks/loader.js +210 -0
- package/src/tasks/router.js +182 -0
- package/src/terminal/collector.js +216 -0
- package/src/terminal/index.js +30 -0
- package/src/terminal/isolation.js +129 -0
- package/src/terminal/monitor.js +277 -0
- package/src/terminal/spawner.js +269 -0
- package/src/upgrade/checker.js +1 -1
- package/src/wizard/i18n.js +3 -3
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit Message Generator — Produces conventional commit messages from staged changes.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes changed file paths to determine type and scope, then builds
|
|
5
|
+
* a properly formatted conventional commit string.
|
|
6
|
+
*
|
|
7
|
+
* @module scripts/commit-message-generator
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { dirname, basename, extname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Path-based rules for detecting commit type.
|
|
14
|
+
* Order matters: first match wins.
|
|
15
|
+
*
|
|
16
|
+
* @type {Array<{ pattern: RegExp, type: string }>}
|
|
17
|
+
*/
|
|
18
|
+
const TYPE_RULES = [
|
|
19
|
+
{ pattern: /\.github\/|\.gitlab-ci|Jenkinsfile|\.circleci/i, type: 'ci' },
|
|
20
|
+
{ pattern: /\.test\.|\.spec\.|__tests__|test\/|tests\//i, type: 'test' },
|
|
21
|
+
{ pattern: /docs\/|\.md$/i, type: 'docs' },
|
|
22
|
+
{ pattern: /package\.json$|package-lock\.json$|yarn\.lock$|pnpm-lock/i, type: 'chore' },
|
|
23
|
+
{ pattern: /\.eslint|\.prettier|tsconfig|\.editorconfig|\.gitignore/i, type: 'chore' },
|
|
24
|
+
{ pattern: /Dockerfile|docker-compose|\.dockerignore/i, type: 'ci' },
|
|
25
|
+
{ pattern: /webpack|rollup|vite\.config|esbuild|babel\.config/i, type: 'build' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detects the conventional commit type from a list of changed files.
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]} files — array of file paths (relative)
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function detectCommitType(files) {
|
|
35
|
+
if (!files || files.length === 0) return 'chore';
|
|
36
|
+
|
|
37
|
+
const typeCounts = {};
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
let detected = null;
|
|
41
|
+
for (const rule of TYPE_RULES) {
|
|
42
|
+
if (rule.pattern.test(file)) {
|
|
43
|
+
detected = rule.type;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Default to 'feat' for unmatched source files
|
|
48
|
+
if (!detected) {
|
|
49
|
+
detected = 'feat';
|
|
50
|
+
}
|
|
51
|
+
typeCounts[detected] = (typeCounts[detected] || 0) + 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Pick the type that covers most files
|
|
55
|
+
let maxType = 'chore';
|
|
56
|
+
let maxCount = 0;
|
|
57
|
+
for (const [type, count] of Object.entries(typeCounts)) {
|
|
58
|
+
if (count > maxCount) {
|
|
59
|
+
maxCount = count;
|
|
60
|
+
maxType = type;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return maxType;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Detects the scope from file paths.
|
|
69
|
+
* Uses the common parent directory or module name.
|
|
70
|
+
*
|
|
71
|
+
* @param {string[]} files — array of file paths (relative)
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function detectScope(files) {
|
|
75
|
+
if (!files || files.length === 0) return '';
|
|
76
|
+
|
|
77
|
+
// Normalize paths and extract directory parts
|
|
78
|
+
const dirParts = files.map((f) => {
|
|
79
|
+
const dir = dirname(f);
|
|
80
|
+
return dir.split(/[/\\]/).filter(Boolean);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (dirParts.length === 0) return '';
|
|
84
|
+
|
|
85
|
+
// Single file: use its immediate parent directory
|
|
86
|
+
if (files.length === 1) {
|
|
87
|
+
const parts = dirParts[0];
|
|
88
|
+
if (parts.length === 0 || (parts.length === 1 && parts[0] === '.')) return '';
|
|
89
|
+
return parts[parts.length - 1];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Multiple files: find common prefix
|
|
93
|
+
const shortest = Math.min(...dirParts.map((p) => p.length));
|
|
94
|
+
let commonDepth = 0;
|
|
95
|
+
for (let i = 0; i < shortest; i++) {
|
|
96
|
+
const segment = dirParts[0][i];
|
|
97
|
+
if (dirParts.every((p) => p[i] === segment)) {
|
|
98
|
+
commonDepth = i + 1;
|
|
99
|
+
} else {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (commonDepth === 0) return '';
|
|
105
|
+
|
|
106
|
+
const commonParts = dirParts[0].slice(0, commonDepth);
|
|
107
|
+
// Skip generic top-level directories for the scope name
|
|
108
|
+
const skipDirs = new Set(['src', 'lib', 'packages', 'apps', 'modules']);
|
|
109
|
+
const meaningful = commonParts.filter((p) => !skipDirs.has(p));
|
|
110
|
+
|
|
111
|
+
if (meaningful.length === 0 && commonParts.length > 0) {
|
|
112
|
+
// If all parts are generic, use the deepest one
|
|
113
|
+
return commonParts[commonParts.length - 1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return meaningful.length > 0 ? meaningful[meaningful.length - 1] : '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Formats a conventional commit message.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} type
|
|
123
|
+
* @param {string} scope
|
|
124
|
+
* @param {string} description
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function formatCommitMessage(type, scope, description) {
|
|
128
|
+
const scopePart = scope ? `(${scope})` : '';
|
|
129
|
+
return `${type}${scopePart}: ${description}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generates a conventional commit message from options.
|
|
134
|
+
*
|
|
135
|
+
* @param {Object} options
|
|
136
|
+
* @param {string[]} options.files — list of changed file paths
|
|
137
|
+
* @param {string} [options.diff] — git diff string (used for description hints)
|
|
138
|
+
* @param {string} [options.type] — override commit type
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function generateCommitMessage(options = {}) {
|
|
142
|
+
const { files = [], type: overrideType } = options;
|
|
143
|
+
|
|
144
|
+
if (files.length === 0) {
|
|
145
|
+
return 'chore: update files';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const type = overrideType || detectCommitType(files);
|
|
149
|
+
const scope = detectScope(files);
|
|
150
|
+
|
|
151
|
+
// Generate description from file names
|
|
152
|
+
let description;
|
|
153
|
+
if (files.length === 1) {
|
|
154
|
+
const name = basename(files[0], extname(files[0]));
|
|
155
|
+
const verb = type === 'feat' ? 'add' : type === 'fix' ? 'fix' : 'update';
|
|
156
|
+
description = `${verb} ${name}`;
|
|
157
|
+
} else if (files.length <= 3) {
|
|
158
|
+
const names = files.map((f) => basename(f, extname(f)));
|
|
159
|
+
const verb = type === 'feat' ? 'add' : type === 'fix' ? 'fix' : 'update';
|
|
160
|
+
description = `${verb} ${names.join(', ')}`;
|
|
161
|
+
} else {
|
|
162
|
+
const verb = type === 'feat' ? 'add' : type === 'fix' ? 'fix' : 'update';
|
|
163
|
+
description = `${verb} ${files.length} files`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return formatCommitMessage(type, scope, description);
|
|
167
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage Analyzer — Identifies code coverage gaps by mapping source to test files.
|
|
3
|
+
*
|
|
4
|
+
* Uses naming conventions to correlate source files with their test counterparts,
|
|
5
|
+
* detects uncovered exports, and generates human-readable reports.
|
|
6
|
+
*
|
|
7
|
+
* @module scripts/coverage-analyzer
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
11
|
+
import { join, relative, extname } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} CoverageReport
|
|
15
|
+
* @property {string[]} coveredFiles — source files with corresponding tests
|
|
16
|
+
* @property {string[]} uncoveredFiles — source files without tests
|
|
17
|
+
* @property {string[]} orphanedTests — test files without corresponding source
|
|
18
|
+
* @property {number} coverageRatio
|
|
19
|
+
* @property {number} totalSourceFiles
|
|
20
|
+
* @property {number} totalTestFiles
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursively collects JS files from a directory, excluding node_modules and .git.
|
|
25
|
+
* @param {string} dir
|
|
26
|
+
* @param {string} [ext='.js']
|
|
27
|
+
* @returns {string[]}
|
|
28
|
+
*/
|
|
29
|
+
function collectFiles(dir, ext = '.js') {
|
|
30
|
+
const results = [];
|
|
31
|
+
|
|
32
|
+
function walk(currentDir) {
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = readdirSync(currentDir);
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry === 'node_modules' || entry === '.git') continue;
|
|
41
|
+
const fullPath = join(currentDir, entry);
|
|
42
|
+
let stat;
|
|
43
|
+
try {
|
|
44
|
+
stat = statSync(fullPath);
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (stat.isDirectory()) {
|
|
49
|
+
walk(fullPath);
|
|
50
|
+
} else if (stat.isFile() && fullPath.endsWith(ext)) {
|
|
51
|
+
results.push(fullPath);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
walk(dir);
|
|
57
|
+
return results.sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Derives the expected test file path for a source file.
|
|
62
|
+
* Convention: src/foo/bar.js -> test/foo/bar.test.js
|
|
63
|
+
*
|
|
64
|
+
* @param {string} srcFile — absolute path to source file
|
|
65
|
+
* @param {string} srcDir — root source directory
|
|
66
|
+
* @param {string} testDir — root test directory
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function expectedTestPath(srcFile, srcDir, testDir) {
|
|
70
|
+
const rel = relative(srcDir, srcFile);
|
|
71
|
+
const ext = extname(rel);
|
|
72
|
+
const withoutExt = rel.slice(0, -ext.length);
|
|
73
|
+
return join(testDir, `${withoutExt}.test${ext}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Derives the expected source file path for a test file.
|
|
78
|
+
* Convention: test/foo/bar.test.js -> src/foo/bar.js
|
|
79
|
+
*
|
|
80
|
+
* @param {string} testFile
|
|
81
|
+
* @param {string} srcDir
|
|
82
|
+
* @param {string} testDir
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function expectedSourcePath(testFile, srcDir, testDir) {
|
|
86
|
+
const rel = relative(testDir, testFile);
|
|
87
|
+
const noTest = rel.replace(/\.test\./, '.').replace(/\.spec\./, '.');
|
|
88
|
+
return join(srcDir, noTest);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Analyzes coverage by mapping source files to test files.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} srcDir
|
|
95
|
+
* @param {string} testDir
|
|
96
|
+
* @returns {CoverageReport}
|
|
97
|
+
*/
|
|
98
|
+
export function analyzeCoverage(srcDir, testDir) {
|
|
99
|
+
const sourceFiles = collectFiles(srcDir, '.js').filter(
|
|
100
|
+
(f) => !f.endsWith('.test.js') && !f.endsWith('.spec.js')
|
|
101
|
+
);
|
|
102
|
+
const testFiles = collectFiles(testDir, '.js').filter(
|
|
103
|
+
(f) => f.endsWith('.test.js') || f.endsWith('.spec.js')
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const coveredFiles = [];
|
|
107
|
+
const uncoveredFiles = [];
|
|
108
|
+
|
|
109
|
+
for (const src of sourceFiles) {
|
|
110
|
+
const expected = expectedTestPath(src, srcDir, testDir);
|
|
111
|
+
if (existsSync(expected)) {
|
|
112
|
+
coveredFiles.push(relative(srcDir, src));
|
|
113
|
+
} else {
|
|
114
|
+
uncoveredFiles.push(relative(srcDir, src));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const orphanedTests = [];
|
|
119
|
+
for (const test of testFiles) {
|
|
120
|
+
const expected = expectedSourcePath(test, srcDir, testDir);
|
|
121
|
+
if (!existsSync(expected)) {
|
|
122
|
+
orphanedTests.push(relative(testDir, test));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const totalSourceFiles = sourceFiles.length;
|
|
127
|
+
const coverageRatio =
|
|
128
|
+
totalSourceFiles > 0
|
|
129
|
+
? Math.round((coveredFiles.length / totalSourceFiles) * 10000) / 10000
|
|
130
|
+
: 0;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
coveredFiles,
|
|
134
|
+
uncoveredFiles,
|
|
135
|
+
orphanedTests,
|
|
136
|
+
coverageRatio,
|
|
137
|
+
totalSourceFiles,
|
|
138
|
+
totalTestFiles: testFiles.length,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parses exported function names from a source file via regex heuristics.
|
|
144
|
+
* Handles: export function, export const/let, export default, module.exports.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} srcFilePath
|
|
147
|
+
* @returns {string[]}
|
|
148
|
+
*/
|
|
149
|
+
export function getUncoveredFunctions(srcFilePath, testFilePath) {
|
|
150
|
+
let srcContent;
|
|
151
|
+
try {
|
|
152
|
+
srcContent = readFileSync(srcFilePath, 'utf-8');
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Extract exported function/const names
|
|
158
|
+
const exportedNames = new Set();
|
|
159
|
+
|
|
160
|
+
// ESM: export function foo(
|
|
161
|
+
const exportFuncRe = /export\s+(?:async\s+)?function\s+(\w+)/g;
|
|
162
|
+
let m;
|
|
163
|
+
while ((m = exportFuncRe.exec(srcContent)) !== null) {
|
|
164
|
+
exportedNames.add(m[1]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ESM: export const foo = / export let foo =
|
|
168
|
+
const exportConstRe = /export\s+(?:const|let|var)\s+(\w+)\s*=/g;
|
|
169
|
+
while ((m = exportConstRe.exec(srcContent)) !== null) {
|
|
170
|
+
exportedNames.add(m[1]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ESM: export class Foo
|
|
174
|
+
const exportClassRe = /export\s+class\s+(\w+)/g;
|
|
175
|
+
while ((m = exportClassRe.exec(srcContent)) !== null) {
|
|
176
|
+
exportedNames.add(m[1]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ESM: export { foo, bar }
|
|
180
|
+
const exportBracketRe = /export\s*\{([^}]+)\}/g;
|
|
181
|
+
while ((m = exportBracketRe.exec(srcContent)) !== null) {
|
|
182
|
+
const names = m[1].split(',').map((n) => n.trim().split(/\s+as\s+/)[0].trim());
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
if (name) exportedNames.add(name);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (exportedNames.size === 0) return [];
|
|
189
|
+
|
|
190
|
+
// If no test file, all exported names are uncovered
|
|
191
|
+
if (!testFilePath) {
|
|
192
|
+
return [...exportedNames];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let testContent;
|
|
196
|
+
try {
|
|
197
|
+
testContent = readFileSync(testFilePath, 'utf-8');
|
|
198
|
+
} catch {
|
|
199
|
+
return [...exportedNames];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check which names appear in the test file
|
|
203
|
+
const uncovered = [];
|
|
204
|
+
for (const name of exportedNames) {
|
|
205
|
+
// Look for the name being referenced in tests (import, call, etc.)
|
|
206
|
+
const nameRegex = new RegExp(`\\b${name}\\b`);
|
|
207
|
+
if (!nameRegex.test(testContent)) {
|
|
208
|
+
uncovered.push(name);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return uncovered;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Generates a formatted coverage report string.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} srcDir
|
|
219
|
+
* @param {string} testDir
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
export function generateCoverageReport(srcDir, testDir) {
|
|
223
|
+
const report = analyzeCoverage(srcDir, testDir);
|
|
224
|
+
const lines = [];
|
|
225
|
+
|
|
226
|
+
lines.push('=== Coverage Analysis Report ===');
|
|
227
|
+
lines.push('');
|
|
228
|
+
lines.push(`Source directory : ${srcDir}`);
|
|
229
|
+
lines.push(`Test directory : ${testDir}`);
|
|
230
|
+
lines.push(`Source files : ${report.totalSourceFiles}`);
|
|
231
|
+
lines.push(`Test files : ${report.totalTestFiles}`);
|
|
232
|
+
lines.push(`Coverage ratio : ${(report.coverageRatio * 100).toFixed(1)}%`);
|
|
233
|
+
lines.push('');
|
|
234
|
+
|
|
235
|
+
if (report.coveredFiles.length > 0) {
|
|
236
|
+
lines.push(`--- Covered (${report.coveredFiles.length}) ---`);
|
|
237
|
+
for (const f of report.coveredFiles) {
|
|
238
|
+
lines.push(` [OK] ${f}`);
|
|
239
|
+
}
|
|
240
|
+
lines.push('');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (report.uncoveredFiles.length > 0) {
|
|
244
|
+
lines.push(`--- Uncovered (${report.uncoveredFiles.length}) ---`);
|
|
245
|
+
for (const f of report.uncoveredFiles) {
|
|
246
|
+
lines.push(` [!!] ${f}`);
|
|
247
|
+
}
|
|
248
|
+
lines.push('');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (report.orphanedTests.length > 0) {
|
|
252
|
+
lines.push(`--- Orphaned Tests (${report.orphanedTests.length}) ---`);
|
|
253
|
+
for (const f of report.orphanedTests) {
|
|
254
|
+
lines.push(` [??] ${f}`);
|
|
255
|
+
}
|
|
256
|
+
lines.push('');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return lines.join('\n');
|
|
260
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Analyzer — Inspects project dependencies for issues and metrics.
|
|
3
|
+
*
|
|
4
|
+
* Reads package.json and optional lock files to produce a comprehensive
|
|
5
|
+
* dependency report including counts, version patterns, and potential problems.
|
|
6
|
+
*
|
|
7
|
+
* @module scripts/dependency-analyzer
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} Issue
|
|
15
|
+
* @property {string} severity — 'error' | 'warning' | 'info'
|
|
16
|
+
* @property {string} name — dependency name
|
|
17
|
+
* @property {string} message
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} DependencyStats
|
|
22
|
+
* @property {number} total
|
|
23
|
+
* @property {number} prod
|
|
24
|
+
* @property {number} dev
|
|
25
|
+
* @property {number} peer
|
|
26
|
+
* @property {number} optional
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} DependencyReport
|
|
31
|
+
* @property {DependencyStats} stats
|
|
32
|
+
* @property {Issue[]} issues
|
|
33
|
+
* @property {string[]} prodDeps
|
|
34
|
+
* @property {string[]} devDeps
|
|
35
|
+
* @property {string[]} peerDeps
|
|
36
|
+
* @property {string[]} optionalDeps
|
|
37
|
+
* @property {string[]} duplicatesAcrossGroups
|
|
38
|
+
* @property {boolean} hasLockFile
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Safely reads and parses a JSON file.
|
|
43
|
+
* @param {string} filePath
|
|
44
|
+
* @returns {Object|null}
|
|
45
|
+
*/
|
|
46
|
+
function readJSON(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Gets basic dependency counts from a target directory.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} targetDir
|
|
59
|
+
* @returns {DependencyStats}
|
|
60
|
+
*/
|
|
61
|
+
export function getDependencyStats(targetDir) {
|
|
62
|
+
const pkg = readJSON(join(targetDir, 'package.json'));
|
|
63
|
+
if (!pkg) {
|
|
64
|
+
return { total: 0, prod: 0, dev: 0, peer: 0, optional: 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const prod = Object.keys(pkg.dependencies || {}).length;
|
|
68
|
+
const dev = Object.keys(pkg.devDependencies || {}).length;
|
|
69
|
+
const peer = Object.keys(pkg.peerDependencies || {}).length;
|
|
70
|
+
const optional = Object.keys(pkg.optionalDependencies || {}).length;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
total: prod + dev + peer + optional,
|
|
74
|
+
prod,
|
|
75
|
+
dev,
|
|
76
|
+
peer,
|
|
77
|
+
optional,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Checks dependencies for common issues.
|
|
83
|
+
*
|
|
84
|
+
* @param {Record<string, string>} deps — name-to-version map
|
|
85
|
+
* @param {string} group — group label ('prod', 'dev', etc.)
|
|
86
|
+
* @returns {Issue[]}
|
|
87
|
+
*/
|
|
88
|
+
export function checkForIssues(deps, group = 'prod') {
|
|
89
|
+
const issues = [];
|
|
90
|
+
|
|
91
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
92
|
+
// Wildcard version
|
|
93
|
+
if (version === '*' || version === 'latest') {
|
|
94
|
+
issues.push({
|
|
95
|
+
severity: 'error',
|
|
96
|
+
name,
|
|
97
|
+
message: `${group} dependency "${name}" uses wildcard version "${version}" — pin to a specific range`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// URL-based version (git, http)
|
|
102
|
+
if (/^(git|http|https|ssh|file):/.test(version) || version.includes('github.com')) {
|
|
103
|
+
issues.push({
|
|
104
|
+
severity: 'warning',
|
|
105
|
+
name,
|
|
106
|
+
message: `${group} dependency "${name}" uses a URL-based version — may cause reproducibility issues`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Very broad range (>= without upper bound)
|
|
111
|
+
if (/^>=/.test(version) && !version.includes('<') && !version.includes(' ')) {
|
|
112
|
+
issues.push({
|
|
113
|
+
severity: 'warning',
|
|
114
|
+
name,
|
|
115
|
+
message: `${group} dependency "${name}" has an unbounded lower version "${version}"`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Version 0.x (potentially unstable)
|
|
120
|
+
if (/^[\^~]?0\./.test(version)) {
|
|
121
|
+
issues.push({
|
|
122
|
+
severity: 'info',
|
|
123
|
+
name,
|
|
124
|
+
message: `${group} dependency "${name}" is pre-1.0 (${version}) — API may be unstable`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Empty version
|
|
129
|
+
if (!version || version.trim() === '') {
|
|
130
|
+
issues.push({
|
|
131
|
+
severity: 'error',
|
|
132
|
+
name,
|
|
133
|
+
message: `${group} dependency "${name}" has an empty version`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return issues;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Full dependency analysis for a target directory.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} targetDir
|
|
145
|
+
* @returns {DependencyReport}
|
|
146
|
+
*/
|
|
147
|
+
export function analyzeDependencies(targetDir) {
|
|
148
|
+
const pkg = readJSON(join(targetDir, 'package.json'));
|
|
149
|
+
if (!pkg) {
|
|
150
|
+
return {
|
|
151
|
+
stats: { total: 0, prod: 0, dev: 0, peer: 0, optional: 0 },
|
|
152
|
+
issues: [{ severity: 'error', name: '-', message: 'No package.json found' }],
|
|
153
|
+
prodDeps: [],
|
|
154
|
+
devDeps: [],
|
|
155
|
+
peerDeps: [],
|
|
156
|
+
optionalDeps: [],
|
|
157
|
+
duplicatesAcrossGroups: [],
|
|
158
|
+
hasLockFile: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const prodDeps = pkg.dependencies || {};
|
|
163
|
+
const devDeps = pkg.devDependencies || {};
|
|
164
|
+
const peerDeps = pkg.peerDependencies || {};
|
|
165
|
+
const optionalDeps = pkg.optionalDependencies || {};
|
|
166
|
+
|
|
167
|
+
const stats = getDependencyStats(targetDir);
|
|
168
|
+
|
|
169
|
+
// Collect issues from all groups
|
|
170
|
+
const issues = [
|
|
171
|
+
...checkForIssues(prodDeps, 'prod'),
|
|
172
|
+
...checkForIssues(devDeps, 'dev'),
|
|
173
|
+
...checkForIssues(peerDeps, 'peer'),
|
|
174
|
+
...checkForIssues(optionalDeps, 'optional'),
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
// Find duplicates across prod and dev
|
|
178
|
+
const prodNames = new Set(Object.keys(prodDeps));
|
|
179
|
+
const devNames = new Set(Object.keys(devDeps));
|
|
180
|
+
const duplicatesAcrossGroups = [...prodNames].filter((n) => devNames.has(n));
|
|
181
|
+
|
|
182
|
+
if (duplicatesAcrossGroups.length > 0) {
|
|
183
|
+
for (const dup of duplicatesAcrossGroups) {
|
|
184
|
+
issues.push({
|
|
185
|
+
severity: 'warning',
|
|
186
|
+
name: dup,
|
|
187
|
+
message: `"${dup}" appears in both dependencies and devDependencies`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check for lock file
|
|
193
|
+
const hasLockFile =
|
|
194
|
+
existsSync(join(targetDir, 'package-lock.json')) ||
|
|
195
|
+
existsSync(join(targetDir, 'yarn.lock')) ||
|
|
196
|
+
existsSync(join(targetDir, 'pnpm-lock.yaml')) ||
|
|
197
|
+
existsSync(join(targetDir, 'bun.lockb'));
|
|
198
|
+
|
|
199
|
+
if (!hasLockFile) {
|
|
200
|
+
issues.push({
|
|
201
|
+
severity: 'warning',
|
|
202
|
+
name: '-',
|
|
203
|
+
message: 'No lock file found — builds may not be reproducible',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
stats,
|
|
209
|
+
issues,
|
|
210
|
+
prodDeps: Object.keys(prodDeps).sort(),
|
|
211
|
+
devDeps: Object.keys(devDeps).sort(),
|
|
212
|
+
peerDeps: Object.keys(peerDeps).sort(),
|
|
213
|
+
optionalDeps: Object.keys(optionalDeps).sort(),
|
|
214
|
+
duplicatesAcrossGroups,
|
|
215
|
+
hasLockFile,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Formats a dependency report as a human-readable string.
|
|
221
|
+
*
|
|
222
|
+
* @param {DependencyReport} report
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
export function formatDependencyReport(report) {
|
|
226
|
+
const lines = [];
|
|
227
|
+
|
|
228
|
+
lines.push('=== Dependency Analysis Report ===');
|
|
229
|
+
lines.push('');
|
|
230
|
+
lines.push(`Total dependencies : ${report.stats.total}`);
|
|
231
|
+
lines.push(` Production : ${report.stats.prod}`);
|
|
232
|
+
lines.push(` Development : ${report.stats.dev}`);
|
|
233
|
+
lines.push(` Peer : ${report.stats.peer}`);
|
|
234
|
+
lines.push(` Optional : ${report.stats.optional}`);
|
|
235
|
+
lines.push(`Lock file : ${report.hasLockFile ? 'Yes' : 'No'}`);
|
|
236
|
+
lines.push('');
|
|
237
|
+
|
|
238
|
+
if (report.duplicatesAcrossGroups.length > 0) {
|
|
239
|
+
lines.push(`--- Duplicates (prod & dev) ---`);
|
|
240
|
+
for (const dup of report.duplicatesAcrossGroups) {
|
|
241
|
+
lines.push(` [DUP] ${dup}`);
|
|
242
|
+
}
|
|
243
|
+
lines.push('');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const errors = report.issues.filter((i) => i.severity === 'error');
|
|
247
|
+
const warnings = report.issues.filter((i) => i.severity === 'warning');
|
|
248
|
+
const infos = report.issues.filter((i) => i.severity === 'info');
|
|
249
|
+
|
|
250
|
+
if (errors.length > 0) {
|
|
251
|
+
lines.push(`--- Errors (${errors.length}) ---`);
|
|
252
|
+
for (const issue of errors) {
|
|
253
|
+
lines.push(` [ERR] ${issue.message}`);
|
|
254
|
+
}
|
|
255
|
+
lines.push('');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (warnings.length > 0) {
|
|
259
|
+
lines.push(`--- Warnings (${warnings.length}) ---`);
|
|
260
|
+
for (const issue of warnings) {
|
|
261
|
+
lines.push(` [WRN] ${issue.message}`);
|
|
262
|
+
}
|
|
263
|
+
lines.push('');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (infos.length > 0) {
|
|
267
|
+
lines.push(`--- Info (${infos.length}) ---`);
|
|
268
|
+
for (const issue of infos) {
|
|
269
|
+
lines.push(` [INF] ${issue.message}`);
|
|
270
|
+
}
|
|
271
|
+
lines.push('');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (report.issues.length === 0) {
|
|
275
|
+
lines.push('No issues found.');
|
|
276
|
+
lines.push('');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return lines.join('\n');
|
|
280
|
+
}
|