roadmapsmith 0.4.0 → 0.5.1
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/package.json +40 -41
- package/src/config.js +212 -208
- package/src/generator/index.js +45 -103
- package/src/index.js +10 -7
- package/src/io.js +263 -227
- package/src/match.js +85 -85
- package/src/model.js +32 -33
- package/src/parser/index.js +106 -108
- package/src/renderer/professional.js +11 -2
- package/src/utils.js +141 -142
- package/src/validator/index.js +22 -3
package/src/io.js
CHANGED
|
@@ -1,228 +1,264 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { ensureTrailingNewline, toPosix } = require('./utils');
|
|
6
|
-
|
|
7
|
-
const DEFAULT_IGNORED_DIRS = new Set([
|
|
8
|
-
'.git',
|
|
9
|
-
'.idea',
|
|
10
|
-
'.vscode',
|
|
11
|
-
'.next',
|
|
12
|
-
'.nuxt',
|
|
13
|
-
'.turbo',
|
|
14
|
-
'.cache',
|
|
15
|
-
'dist',
|
|
16
|
-
'build',
|
|
17
|
-
'coverage',
|
|
18
|
-
'target',
|
|
19
|
-
'node_modules',
|
|
20
|
-
'.venv',
|
|
21
|
-
'venv',
|
|
22
|
-
'__pycache__',
|
|
23
|
-
'.pytest_cache',
|
|
24
|
-
'.mypy_cache',
|
|
25
|
-
'.ruff_cache'
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
function readTextIfExists(filePath) {
|
|
29
|
-
try {
|
|
30
|
-
return fs.readFileSync(filePath, 'utf8');
|
|
31
|
-
} catch {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function ensureDirForFile(filePath) {
|
|
37
|
-
const dir = path.dirname(filePath);
|
|
38
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function writeText(filePath, content, options = {}) {
|
|
42
|
-
const next = ensureTrailingNewline(content);
|
|
43
|
-
const before = readTextIfExists(filePath);
|
|
44
|
-
const changed = before == null || before !== next;
|
|
45
|
-
|
|
46
|
-
if (!changed) {
|
|
47
|
-
return {
|
|
48
|
-
changed: false,
|
|
49
|
-
before,
|
|
50
|
-
after: next,
|
|
51
|
-
path: filePath
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (!options.dryRun) {
|
|
56
|
-
ensureDirForFile(filePath);
|
|
57
|
-
fs.writeFileSync(filePath, next, 'utf8');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
changed: true,
|
|
62
|
-
before,
|
|
63
|
-
after: next,
|
|
64
|
-
path: filePath
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function walkFiles(rootPath, options = {}) {
|
|
69
|
-
const ignoredDirs = options.ignoredDirs || DEFAULT_IGNORED_DIRS;
|
|
70
|
-
const result = [];
|
|
71
|
-
|
|
72
|
-
function walk(current) {
|
|
73
|
-
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
74
|
-
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
75
|
-
|
|
76
|
-
for (const entry of entries) {
|
|
77
|
-
const absolutePath = path.join(current, entry.name);
|
|
78
|
-
const relativePath = toPosix(path.relative(rootPath, absolutePath));
|
|
79
|
-
if (entry.isDirectory()) {
|
|
80
|
-
if (ignoredDirs.has(entry.name)) {
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
walk(absolutePath);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
result.push(relativePath);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
walk(rootPath);
|
|
91
|
-
return result;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function parseJsonIfExists(filePath) {
|
|
95
|
-
const content = readTextIfExists(filePath);
|
|
96
|
-
if (!content) {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
try {
|
|
100
|
-
return JSON.parse(content);
|
|
101
|
-
} catch {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function detectLanguages(files) {
|
|
107
|
-
const languageByExtension = {
|
|
108
|
-
'.js': 'JavaScript',
|
|
109
|
-
'.cjs': 'JavaScript',
|
|
110
|
-
'.mjs': 'JavaScript',
|
|
111
|
-
'.ts': 'TypeScript',
|
|
112
|
-
'.tsx': 'TypeScript',
|
|
113
|
-
'.jsx': 'JavaScript',
|
|
114
|
-
'.py': 'Python',
|
|
115
|
-
'.go': 'Go',
|
|
116
|
-
'.rs': 'Rust',
|
|
117
|
-
'.java': 'Java',
|
|
118
|
-
'.kt': 'Kotlin',
|
|
119
|
-
'.swift': 'Swift',
|
|
120
|
-
'.rb': 'Ruby',
|
|
121
|
-
'.php': 'PHP',
|
|
122
|
-
'.cs': 'C#',
|
|
123
|
-
'.cpp': 'C++',
|
|
124
|
-
'.c': 'C',
|
|
125
|
-
'.h': 'C',
|
|
126
|
-
'.sh': 'Shell'
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const languages = new Set();
|
|
130
|
-
for (const file of files) {
|
|
131
|
-
const ext = path.extname(file).toLowerCase();
|
|
132
|
-
if (languageByExtension[ext]) {
|
|
133
|
-
languages.add(languageByExtension[ext]);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return Array.from(languages).sort((left, right) => left.localeCompare(right));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function detectTestFrameworks(projectRoot, files) {
|
|
140
|
-
const frameworks = new Set();
|
|
141
|
-
|
|
142
|
-
const packageJson = parseJsonIfExists(path.join(projectRoot, 'package.json'));
|
|
143
|
-
if (packageJson) {
|
|
144
|
-
const scripts = packageJson.scripts || {};
|
|
145
|
-
if (scripts.test) {
|
|
146
|
-
frameworks.add('node-test-script');
|
|
147
|
-
}
|
|
148
|
-
const deps = {
|
|
149
|
-
...(packageJson.dependencies || {}),
|
|
150
|
-
...(packageJson.devDependencies || {})
|
|
151
|
-
};
|
|
152
|
-
if (deps.jest) frameworks.add('jest');
|
|
153
|
-
if (deps.vitest) frameworks.add('vitest');
|
|
154
|
-
if (deps.mocha) frameworks.add('mocha');
|
|
155
|
-
if (deps.ava) frameworks.add('ava');
|
|
156
|
-
if (deps.tap) frameworks.add('tap');
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (files.some((file) => file.endsWith('pyproject.toml')) || files.some((file) => file.endsWith('pytest.ini'))) {
|
|
160
|
-
frameworks.add('pytest');
|
|
161
|
-
}
|
|
162
|
-
if (files.some((file) => /(^|\/)test_.*\.py$/.test(file)) || files.some((file) => /(^|\/)tests\//.test(file))) {
|
|
163
|
-
frameworks.add('python-tests');
|
|
164
|
-
}
|
|
165
|
-
if (files.some((file) => file.endsWith('go.mod'))) {
|
|
166
|
-
frameworks.add('go');
|
|
167
|
-
}
|
|
168
|
-
if (files.some((file) => file.endsWith('_test.go'))) {
|
|
169
|
-
frameworks.add('go-test');
|
|
170
|
-
}
|
|
171
|
-
if (files.some((file) => file.endsWith('Cargo.toml'))) {
|
|
172
|
-
frameworks.add('rust');
|
|
173
|
-
}
|
|
174
|
-
if (files.some((file) => /(^|\/)tests\//.test(file) && file.endsWith('.rs'))) {
|
|
175
|
-
frameworks.add('cargo-test');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (files.some((file) => /(^|\/)(__tests__|tests)\//.test(file) || /\.test\.|\.spec\./.test(file))) {
|
|
179
|
-
frameworks.add('generic-tests');
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return Array.from(frameworks).sort((left, right) => left.localeCompare(right));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
for (
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { ensureTrailingNewline, toPosix } = require('./utils');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_IGNORED_DIRS = new Set([
|
|
8
|
+
'.git',
|
|
9
|
+
'.idea',
|
|
10
|
+
'.vscode',
|
|
11
|
+
'.next',
|
|
12
|
+
'.nuxt',
|
|
13
|
+
'.turbo',
|
|
14
|
+
'.cache',
|
|
15
|
+
'dist',
|
|
16
|
+
'build',
|
|
17
|
+
'coverage',
|
|
18
|
+
'target',
|
|
19
|
+
'node_modules',
|
|
20
|
+
'.venv',
|
|
21
|
+
'venv',
|
|
22
|
+
'__pycache__',
|
|
23
|
+
'.pytest_cache',
|
|
24
|
+
'.mypy_cache',
|
|
25
|
+
'.ruff_cache'
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function readTextIfExists(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureDirForFile(filePath) {
|
|
37
|
+
const dir = path.dirname(filePath);
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeText(filePath, content, options = {}) {
|
|
42
|
+
const next = ensureTrailingNewline(content);
|
|
43
|
+
const before = readTextIfExists(filePath);
|
|
44
|
+
const changed = before == null || before !== next;
|
|
45
|
+
|
|
46
|
+
if (!changed) {
|
|
47
|
+
return {
|
|
48
|
+
changed: false,
|
|
49
|
+
before,
|
|
50
|
+
after: next,
|
|
51
|
+
path: filePath
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!options.dryRun) {
|
|
56
|
+
ensureDirForFile(filePath);
|
|
57
|
+
fs.writeFileSync(filePath, next, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
changed: true,
|
|
62
|
+
before,
|
|
63
|
+
after: next,
|
|
64
|
+
path: filePath
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function walkFiles(rootPath, options = {}) {
|
|
69
|
+
const ignoredDirs = options.ignoredDirs || DEFAULT_IGNORED_DIRS;
|
|
70
|
+
const result = [];
|
|
71
|
+
|
|
72
|
+
function walk(current) {
|
|
73
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
74
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
75
|
+
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const absolutePath = path.join(current, entry.name);
|
|
78
|
+
const relativePath = toPosix(path.relative(rootPath, absolutePath));
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (ignoredDirs.has(entry.name)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
walk(absolutePath);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
result.push(relativePath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
walk(rootPath);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseJsonIfExists(filePath) {
|
|
95
|
+
const content = readTextIfExists(filePath);
|
|
96
|
+
if (!content) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(content);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function detectLanguages(files) {
|
|
107
|
+
const languageByExtension = {
|
|
108
|
+
'.js': 'JavaScript',
|
|
109
|
+
'.cjs': 'JavaScript',
|
|
110
|
+
'.mjs': 'JavaScript',
|
|
111
|
+
'.ts': 'TypeScript',
|
|
112
|
+
'.tsx': 'TypeScript',
|
|
113
|
+
'.jsx': 'JavaScript',
|
|
114
|
+
'.py': 'Python',
|
|
115
|
+
'.go': 'Go',
|
|
116
|
+
'.rs': 'Rust',
|
|
117
|
+
'.java': 'Java',
|
|
118
|
+
'.kt': 'Kotlin',
|
|
119
|
+
'.swift': 'Swift',
|
|
120
|
+
'.rb': 'Ruby',
|
|
121
|
+
'.php': 'PHP',
|
|
122
|
+
'.cs': 'C#',
|
|
123
|
+
'.cpp': 'C++',
|
|
124
|
+
'.c': 'C',
|
|
125
|
+
'.h': 'C',
|
|
126
|
+
'.sh': 'Shell'
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const languages = new Set();
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const ext = path.extname(file).toLowerCase();
|
|
132
|
+
if (languageByExtension[ext]) {
|
|
133
|
+
languages.add(languageByExtension[ext]);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Array.from(languages).sort((left, right) => left.localeCompare(right));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function detectTestFrameworks(projectRoot, files) {
|
|
140
|
+
const frameworks = new Set();
|
|
141
|
+
|
|
142
|
+
const packageJson = parseJsonIfExists(path.join(projectRoot, 'package.json'));
|
|
143
|
+
if (packageJson) {
|
|
144
|
+
const scripts = packageJson.scripts || {};
|
|
145
|
+
if (scripts.test) {
|
|
146
|
+
frameworks.add('node-test-script');
|
|
147
|
+
}
|
|
148
|
+
const deps = {
|
|
149
|
+
...(packageJson.dependencies || {}),
|
|
150
|
+
...(packageJson.devDependencies || {})
|
|
151
|
+
};
|
|
152
|
+
if (deps.jest) frameworks.add('jest');
|
|
153
|
+
if (deps.vitest) frameworks.add('vitest');
|
|
154
|
+
if (deps.mocha) frameworks.add('mocha');
|
|
155
|
+
if (deps.ava) frameworks.add('ava');
|
|
156
|
+
if (deps.tap) frameworks.add('tap');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (files.some((file) => file.endsWith('pyproject.toml')) || files.some((file) => file.endsWith('pytest.ini'))) {
|
|
160
|
+
frameworks.add('pytest');
|
|
161
|
+
}
|
|
162
|
+
if (files.some((file) => /(^|\/)test_.*\.py$/.test(file)) || files.some((file) => /(^|\/)tests\//.test(file))) {
|
|
163
|
+
frameworks.add('python-tests');
|
|
164
|
+
}
|
|
165
|
+
if (files.some((file) => file.endsWith('go.mod'))) {
|
|
166
|
+
frameworks.add('go');
|
|
167
|
+
}
|
|
168
|
+
if (files.some((file) => file.endsWith('_test.go'))) {
|
|
169
|
+
frameworks.add('go-test');
|
|
170
|
+
}
|
|
171
|
+
if (files.some((file) => file.endsWith('Cargo.toml'))) {
|
|
172
|
+
frameworks.add('rust');
|
|
173
|
+
}
|
|
174
|
+
if (files.some((file) => /(^|\/)tests\//.test(file) && file.endsWith('.rs'))) {
|
|
175
|
+
frameworks.add('cargo-test');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (files.some((file) => /(^|\/)(__tests__|tests)\//.test(file) || /\.test\.|\.spec\./.test(file))) {
|
|
179
|
+
frameworks.add('generic-tests');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return Array.from(frameworks).sort((left, right) => left.localeCompare(right));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Detects workspace package roots (one level deep) from package.json workspaces field
|
|
186
|
+
// and physical scan of packages/, apps/, tools/. Supports prefix/* and prefix/** globs.
|
|
187
|
+
function detectWorkspaces(projectRoot, files) {
|
|
188
|
+
const packages = new Set();
|
|
189
|
+
const workspacePrefixes = ['packages/', 'apps/', 'tools/'];
|
|
190
|
+
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
if (!file.endsWith('/package.json')) continue;
|
|
193
|
+
for (const prefix of workspacePrefixes) {
|
|
194
|
+
if (!file.startsWith(prefix)) continue;
|
|
195
|
+
const rest = file.slice(prefix.length);
|
|
196
|
+
const segments = rest.split('/');
|
|
197
|
+
if (segments.length === 2 && segments[1] === 'package.json') {
|
|
198
|
+
packages.add(prefix + segments[0]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const pkg = parseJsonIfExists(path.join(projectRoot, 'package.json'));
|
|
204
|
+
const globs = Array.isArray(pkg && pkg.workspaces) ? pkg.workspaces
|
|
205
|
+
: (pkg && pkg.workspaces && Array.isArray(pkg.workspaces.packages)) ? pkg.workspaces.packages
|
|
206
|
+
: [];
|
|
207
|
+
for (const glob of globs) {
|
|
208
|
+
const match = glob.match(/^([A-Za-z0-9_.-]+)\/\*{1,2}$/);
|
|
209
|
+
if (!match) continue;
|
|
210
|
+
const prefix = match[1] + '/';
|
|
211
|
+
for (const file of files) {
|
|
212
|
+
if (!file.startsWith(prefix) || !file.endsWith('/package.json')) continue;
|
|
213
|
+
const rest = file.slice(prefix.length);
|
|
214
|
+
const segments = rest.split('/');
|
|
215
|
+
if (segments.length === 2) packages.add(prefix + segments[0]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return Array.from(packages).sort((a, b) => a.localeCompare(b));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function lineDiff(before, after) {
|
|
223
|
+
const left = (before || '').split(/\r?\n/);
|
|
224
|
+
const right = (after || '').split(/\r?\n/);
|
|
225
|
+
const max = Math.max(left.length, right.length);
|
|
226
|
+
const changes = [];
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < max; i += 1) {
|
|
229
|
+
const oldLine = left[i] == null ? '' : left[i];
|
|
230
|
+
const newLine = right[i] == null ? '' : right[i];
|
|
231
|
+
if (oldLine !== newLine) {
|
|
232
|
+
changes.push({ index: i + 1, oldLine, newLine });
|
|
233
|
+
}
|
|
234
|
+
if (changes.length >= 20) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return changes;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function printDryRunDiff(filePath, before, after) {
|
|
243
|
+
const changes = lineDiff(before, after);
|
|
244
|
+
console.log(`Dry run: ${filePath}`);
|
|
245
|
+
if (changes.length === 0) {
|
|
246
|
+
console.log('- no line changes');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const change of changes) {
|
|
251
|
+
console.log(`L${change.index} - ${change.oldLine}`);
|
|
252
|
+
console.log(`L${change.index} + ${change.newLine}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
detectLanguages,
|
|
258
|
+
detectTestFrameworks,
|
|
259
|
+
detectWorkspaces,
|
|
260
|
+
printDryRunDiff,
|
|
261
|
+
readTextIfExists,
|
|
262
|
+
walkFiles,
|
|
263
|
+
writeText
|
|
228
264
|
};
|
package/src/match.js
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { similarityScore, slugify, tokenize, uniqueBy } = require('./utils');
|
|
4
|
-
const { PHASE_ORDER } = require('./model');
|
|
5
|
-
|
|
6
|
-
function canonicalSignature(text) {
|
|
7
|
-
const tokens = uniqueBy(tokenize(text), (token) => token).slice(0, 8);
|
|
8
|
-
return tokens.join('-') || slugify(text);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function inferPriorityWeight(priority) {
|
|
12
|
-
const normalized = String(priority || '').toUpperCase();
|
|
13
|
-
if (normalized === 'P0') return 0;
|
|
14
|
-
if (normalized === 'P1') return 1;
|
|
15
|
-
if (normalized === 'P2') return 2;
|
|
16
|
-
return 3;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
dedupeTasks,
|
|
85
|
-
findBestTaskMatch
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { similarityScore, slugify, tokenize, uniqueBy } = require('./utils');
|
|
4
|
+
const { PHASE_ORDER } = require('./model');
|
|
5
|
+
|
|
6
|
+
function canonicalSignature(text) {
|
|
7
|
+
const tokens = uniqueBy(tokenize(text), (token) => token).slice(0, 8);
|
|
8
|
+
return tokens.join('-') || slugify(text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function inferPriorityWeight(priority) {
|
|
12
|
+
const normalized = String(priority || '').toUpperCase();
|
|
13
|
+
if (normalized === 'P0') return 0;
|
|
14
|
+
if (normalized === 'P1') return 1;
|
|
15
|
+
if (normalized === 'P2') return 2;
|
|
16
|
+
if (normalized === 'P3') return 3;
|
|
17
|
+
return 4;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findBestTaskMatch(candidate, existingTasks, minScore = 0.55) {
|
|
21
|
+
const direct = existingTasks.find((task) => task.id === candidate.id);
|
|
22
|
+
if (direct) {
|
|
23
|
+
return { task: direct, score: 1 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let best = null;
|
|
27
|
+
for (const task of existingTasks) {
|
|
28
|
+
const score = similarityScore(candidate.text, task.text);
|
|
29
|
+
if (score < minScore) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!best || score > best.score) {
|
|
33
|
+
best = { task, score };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return best;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dedupeTasks(tasks) {
|
|
41
|
+
const grouped = new Map();
|
|
42
|
+
|
|
43
|
+
for (const task of tasks) {
|
|
44
|
+
const signature = canonicalSignature(task.text);
|
|
45
|
+
if (!grouped.has(signature)) {
|
|
46
|
+
grouped.set(signature, task);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = grouped.get(signature);
|
|
51
|
+
const candidate = task;
|
|
52
|
+
|
|
53
|
+
if (current.checked !== candidate.checked) {
|
|
54
|
+
grouped.set(signature, candidate.checked ? candidate : current);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const currentWeight = inferPriorityWeight(current.priority);
|
|
59
|
+
const candidateWeight = inferPriorityWeight(candidate.priority);
|
|
60
|
+
if (candidateWeight < currentWeight) {
|
|
61
|
+
grouped.set(signature, candidate);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (candidateWeight === currentWeight && candidate.text.length < current.text.length) {
|
|
66
|
+
grouped.set(signature, candidate);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return Array.from(grouped.values()).sort((left, right) => {
|
|
71
|
+
const leftPhase = PHASE_ORDER.indexOf(left.phase);
|
|
72
|
+
const rightPhase = PHASE_ORDER.indexOf(right.phase);
|
|
73
|
+
if (leftPhase !== rightPhase) {
|
|
74
|
+
return leftPhase - rightPhase;
|
|
75
|
+
}
|
|
76
|
+
if (left.priority !== right.priority) {
|
|
77
|
+
return inferPriorityWeight(left.priority) - inferPriorityWeight(right.priority);
|
|
78
|
+
}
|
|
79
|
+
return left.text.localeCompare(right.text);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
dedupeTasks,
|
|
85
|
+
findBestTaskMatch
|
|
86
86
|
};
|