@wpmoo/toolkit 0.9.23 → 0.9.24
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/dist/cli.js +43 -2
- package/dist/module-manifest.js +298 -0
- package/dist/module-quality.js +213 -19
- package/dist/module-target-resolver.js +91 -0
- package/dist/templates.js +2 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,8 @@ import { getDoctorReport, runDoctor } from './doctor.js';
|
|
|
20
20
|
import { getOriginUrl, realGit } from './git.js';
|
|
21
21
|
import { renderHelp } from './help.js';
|
|
22
22
|
import { runLocalCockpit } from './local-cockpit.js';
|
|
23
|
-
import { addModuleToSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
|
|
23
|
+
import { addModuleToSourceRepo, listModulesInEnvironment, removeModuleFromSourceRepo, } from './module-actions.js';
|
|
24
|
+
import { resolveModuleTarget } from './module-target-resolver.js';
|
|
24
25
|
import { supportedOdooVersions } from './odoo-versions.js';
|
|
25
26
|
import { renderRepositorySetupNote } from './prompt-copy.js';
|
|
26
27
|
import { promptRepositoryUrl } from './prompt-repositories.js';
|
|
@@ -1109,6 +1110,46 @@ function dailyActionSelectedLabel(command, argv) {
|
|
|
1109
1110
|
}
|
|
1110
1111
|
return undefined;
|
|
1111
1112
|
}
|
|
1113
|
+
function dailyActionModuleArgIndex(command) {
|
|
1114
|
+
return ['install', 'update', 'test', 'pot'].includes(command) ? 0 : undefined;
|
|
1115
|
+
}
|
|
1116
|
+
function moduleTargetLabel(module) {
|
|
1117
|
+
return `${module.moduleName} (${module.sourceType}/${module.repoPath})`;
|
|
1118
|
+
}
|
|
1119
|
+
function moduleTargetResolutionError(resolution) {
|
|
1120
|
+
const candidates = resolution.candidates.map(moduleTargetLabel).join(', ');
|
|
1121
|
+
if (resolution.kind === 'ambiguous') {
|
|
1122
|
+
return new Error(`Ambiguous module target "${resolution.query}": ${candidates}.`);
|
|
1123
|
+
}
|
|
1124
|
+
return new Error(candidates
|
|
1125
|
+
? `No module matches "${resolution.query}". Did you mean: ${candidates}?`
|
|
1126
|
+
: `No module matches "${resolution.query}".`);
|
|
1127
|
+
}
|
|
1128
|
+
async function resolveDailyActionModuleTargets(command, argv, cwd) {
|
|
1129
|
+
const moduleArgIndex = dailyActionModuleArgIndex(command);
|
|
1130
|
+
if (moduleArgIndex === undefined) {
|
|
1131
|
+
return [...argv];
|
|
1132
|
+
}
|
|
1133
|
+
const moduleArg = argv[moduleArgIndex];
|
|
1134
|
+
if (!moduleArg || moduleArg.startsWith('-')) {
|
|
1135
|
+
return [...argv];
|
|
1136
|
+
}
|
|
1137
|
+
const modules = await listModulesInEnvironment(cwd);
|
|
1138
|
+
if (modules.length === 0) {
|
|
1139
|
+
return [...argv];
|
|
1140
|
+
}
|
|
1141
|
+
const resolvedModuleNames = moduleArg.split(',').map((query) => {
|
|
1142
|
+
const trimmedQuery = query.trim();
|
|
1143
|
+
const resolution = resolveModuleTarget(trimmedQuery, modules);
|
|
1144
|
+
if (resolution.kind !== 'exact') {
|
|
1145
|
+
throw moduleTargetResolutionError(resolution);
|
|
1146
|
+
}
|
|
1147
|
+
return resolution.module.moduleName;
|
|
1148
|
+
});
|
|
1149
|
+
const resolvedArgv = [...argv];
|
|
1150
|
+
resolvedArgv[moduleArgIndex] = resolvedModuleNames.join(',');
|
|
1151
|
+
return resolvedArgv;
|
|
1152
|
+
}
|
|
1112
1153
|
async function selectDatabaseArg(cwd, message, fallback, options = {}) {
|
|
1113
1154
|
const databaseResult = normalizeDatabaseListResult(await listEnvironmentDatabases(cwd, options));
|
|
1114
1155
|
const databases = databaseResult.databases;
|
|
@@ -1585,7 +1626,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1585
1626
|
}
|
|
1586
1627
|
if (isDailyActionCommand(route.command)) {
|
|
1587
1628
|
console.log(renderBanner());
|
|
1588
|
-
await runDailyAction(route.command, route.argv, cwd);
|
|
1629
|
+
await runDailyAction(route.command, await resolveDailyActionModuleTargets(route.command, route.argv, cwd), cwd);
|
|
1589
1630
|
return;
|
|
1590
1631
|
}
|
|
1591
1632
|
const options = optionsFromArgs(route.argv);
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
function createParserState(content) {
|
|
2
|
+
return {
|
|
3
|
+
content,
|
|
4
|
+
parser: {
|
|
5
|
+
index: 0,
|
|
6
|
+
line: 1,
|
|
7
|
+
column: 1,
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function makeError(state, message) {
|
|
12
|
+
return { message, index: state.index, line: state.line, column: state.column };
|
|
13
|
+
}
|
|
14
|
+
function throwParseError(state, message) {
|
|
15
|
+
const { line, column } = makeError(state, message);
|
|
16
|
+
throw new Error(`Parse error at ${line}:${column}: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
function isWhitespace(char) {
|
|
19
|
+
return /\s/u.test(char);
|
|
20
|
+
}
|
|
21
|
+
function peek(state, content, offset = 0) {
|
|
22
|
+
return content[state.index + offset] ?? '';
|
|
23
|
+
}
|
|
24
|
+
function consumeChar(state, content) {
|
|
25
|
+
const char = peek(state, content);
|
|
26
|
+
if (char === '') {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
state.index += 1;
|
|
30
|
+
if (char === '\n') {
|
|
31
|
+
state.line += 1;
|
|
32
|
+
state.column = 1;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
state.column += 1;
|
|
36
|
+
}
|
|
37
|
+
return char;
|
|
38
|
+
}
|
|
39
|
+
function skipWhitespaceAndComments(state, content) {
|
|
40
|
+
while (state.index < content.length) {
|
|
41
|
+
const char = peek(state, content);
|
|
42
|
+
if (isWhitespace(char)) {
|
|
43
|
+
consumeChar(state, content);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (char === '#') {
|
|
47
|
+
while (state.index < content.length && peek(state, content) !== '\n') {
|
|
48
|
+
consumeChar(state, content);
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function parseString(state, content) {
|
|
56
|
+
const quote = consumeChar(state, content);
|
|
57
|
+
const chars = [];
|
|
58
|
+
while (state.index < content.length) {
|
|
59
|
+
const char = consumeChar(state, content);
|
|
60
|
+
if (char === '\\') {
|
|
61
|
+
const escaped = consumeChar(state, content);
|
|
62
|
+
if (!escaped) {
|
|
63
|
+
throwParseError(state, 'unterminated string escape');
|
|
64
|
+
}
|
|
65
|
+
if (escaped === 'n') {
|
|
66
|
+
chars.push('\n');
|
|
67
|
+
}
|
|
68
|
+
else if (escaped === 'r') {
|
|
69
|
+
chars.push('\r');
|
|
70
|
+
}
|
|
71
|
+
else if (escaped === 't') {
|
|
72
|
+
chars.push('\t');
|
|
73
|
+
}
|
|
74
|
+
else if (escaped === quote) {
|
|
75
|
+
chars.push(quote);
|
|
76
|
+
}
|
|
77
|
+
else if (escaped === '\\') {
|
|
78
|
+
chars.push('\\');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
chars.push(escaped);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === quote) {
|
|
86
|
+
return chars.join('');
|
|
87
|
+
}
|
|
88
|
+
if (char === '\n' || char === '\r') {
|
|
89
|
+
throwParseError(state, 'unterminated string literal');
|
|
90
|
+
}
|
|
91
|
+
chars.push(char);
|
|
92
|
+
}
|
|
93
|
+
throwParseError(state, 'unterminated string literal');
|
|
94
|
+
}
|
|
95
|
+
function parseIdentifier(state, content) {
|
|
96
|
+
const chars = [];
|
|
97
|
+
const start = state.index;
|
|
98
|
+
const first = peek(state, content);
|
|
99
|
+
if (!/[A-Za-z_]/u.test(first)) {
|
|
100
|
+
throwParseError(state, 'expected identifier');
|
|
101
|
+
}
|
|
102
|
+
chars.push(consumeChar(state, content));
|
|
103
|
+
while (state.index < content.length) {
|
|
104
|
+
const char = peek(state, content);
|
|
105
|
+
if (/[A-Za-z0-9_]/u.test(char)) {
|
|
106
|
+
chars.push(consumeChar(state, content));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (chars.length === 0) {
|
|
112
|
+
throwParseError(state, 'expected identifier at ' + start);
|
|
113
|
+
}
|
|
114
|
+
return chars.join('');
|
|
115
|
+
}
|
|
116
|
+
function parseNumber(state, content) {
|
|
117
|
+
const chars = [];
|
|
118
|
+
if (peek(state, content) === '-') {
|
|
119
|
+
chars.push(consumeChar(state, content));
|
|
120
|
+
}
|
|
121
|
+
while (/[0-9]/u.test(peek(state, content))) {
|
|
122
|
+
chars.push(consumeChar(state, content));
|
|
123
|
+
}
|
|
124
|
+
if (peek(state, content) === '.') {
|
|
125
|
+
chars.push(consumeChar(state, content));
|
|
126
|
+
if (!/[0-9]/u.test(peek(state, content))) {
|
|
127
|
+
throwParseError(state, 'invalid numeric literal');
|
|
128
|
+
}
|
|
129
|
+
while (/[0-9]/u.test(peek(state, content))) {
|
|
130
|
+
chars.push(consumeChar(state, content));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const value = Number(chars.join(''));
|
|
134
|
+
if (!Number.isFinite(value)) {
|
|
135
|
+
throwParseError(state, 'invalid numeric literal');
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
function parseValue(state, content) {
|
|
140
|
+
skipWhitespaceAndComments(state, content);
|
|
141
|
+
const char = peek(state, content);
|
|
142
|
+
if (char === '{') {
|
|
143
|
+
return parseObject(state, content);
|
|
144
|
+
}
|
|
145
|
+
if (char === '[') {
|
|
146
|
+
return parseList(state, content);
|
|
147
|
+
}
|
|
148
|
+
if (char === '"' || char === "'") {
|
|
149
|
+
return parseString(state, content);
|
|
150
|
+
}
|
|
151
|
+
if (char === '-' || /[0-9]/u.test(char)) {
|
|
152
|
+
return parseNumber(state, content);
|
|
153
|
+
}
|
|
154
|
+
if (/[A-Za-z_]/u.test(char)) {
|
|
155
|
+
const identifier = parseIdentifier(state, content);
|
|
156
|
+
if (identifier === 'True')
|
|
157
|
+
return true;
|
|
158
|
+
if (identifier === 'False')
|
|
159
|
+
return false;
|
|
160
|
+
if (identifier === 'None')
|
|
161
|
+
return undefined;
|
|
162
|
+
throwParseError(state, `unsupported identifier '${identifier}'`);
|
|
163
|
+
}
|
|
164
|
+
throwParseError(state, `unexpected character '${char || 'EOF'}'`);
|
|
165
|
+
}
|
|
166
|
+
function expectChar(state, content, expected) {
|
|
167
|
+
skipWhitespaceAndComments(state, content);
|
|
168
|
+
const char = consumeChar(state, content);
|
|
169
|
+
if (char !== expected) {
|
|
170
|
+
throwParseError(state, `expected '${expected}' but found '${char || 'EOF'}'`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function parseList(state, content) {
|
|
174
|
+
expectChar(state, content, '[');
|
|
175
|
+
const values = [];
|
|
176
|
+
skipWhitespaceAndComments(state, content);
|
|
177
|
+
if (peek(state, content) === ']') {
|
|
178
|
+
consumeChar(state, content);
|
|
179
|
+
return values;
|
|
180
|
+
}
|
|
181
|
+
while (state.index < content.length) {
|
|
182
|
+
const value = parseValue(state, content);
|
|
183
|
+
values.push(value);
|
|
184
|
+
skipWhitespaceAndComments(state, content);
|
|
185
|
+
if (peek(state, content) === ',') {
|
|
186
|
+
consumeChar(state, content);
|
|
187
|
+
skipWhitespaceAndComments(state, content);
|
|
188
|
+
if (peek(state, content) === ']') {
|
|
189
|
+
consumeChar(state, content);
|
|
190
|
+
return values;
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (peek(state, content) === ']') {
|
|
195
|
+
consumeChar(state, content);
|
|
196
|
+
return values;
|
|
197
|
+
}
|
|
198
|
+
throwParseError(state, "expected ',' or ']'");
|
|
199
|
+
}
|
|
200
|
+
throwParseError(state, 'unterminated list literal');
|
|
201
|
+
}
|
|
202
|
+
function parseObject(state, content) {
|
|
203
|
+
expectChar(state, content, '{');
|
|
204
|
+
const manifest = {};
|
|
205
|
+
skipWhitespaceAndComments(state, content);
|
|
206
|
+
if (peek(state, content) === '}') {
|
|
207
|
+
consumeChar(state, content);
|
|
208
|
+
return manifest;
|
|
209
|
+
}
|
|
210
|
+
while (state.index < content.length) {
|
|
211
|
+
skipWhitespaceAndComments(state, content);
|
|
212
|
+
const key = parseManifestKey(state, content);
|
|
213
|
+
skipWhitespaceAndComments(state, content);
|
|
214
|
+
expectChar(state, content, ':');
|
|
215
|
+
const value = parseValue(state, content);
|
|
216
|
+
manifest[key] = value;
|
|
217
|
+
skipWhitespaceAndComments(state, content);
|
|
218
|
+
if (peek(state, content) === ',') {
|
|
219
|
+
consumeChar(state, content);
|
|
220
|
+
skipWhitespaceAndComments(state, content);
|
|
221
|
+
if (peek(state, content) === '}') {
|
|
222
|
+
consumeChar(state, content);
|
|
223
|
+
return manifest;
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (peek(state, content) === '}') {
|
|
228
|
+
consumeChar(state, content);
|
|
229
|
+
return manifest;
|
|
230
|
+
}
|
|
231
|
+
throwParseError(state, "expected ',' or '}'");
|
|
232
|
+
}
|
|
233
|
+
throwParseError(state, 'unterminated object literal');
|
|
234
|
+
}
|
|
235
|
+
function parseManifestKey(state, content) {
|
|
236
|
+
skipWhitespaceAndComments(state, content);
|
|
237
|
+
const char = peek(state, content);
|
|
238
|
+
if (char !== '"' && char !== "'") {
|
|
239
|
+
throwParseError(state, 'manifest keys must be quoted');
|
|
240
|
+
}
|
|
241
|
+
return parseString(state, content);
|
|
242
|
+
}
|
|
243
|
+
function validateManifest(manifest) {
|
|
244
|
+
if (manifest.name !== undefined && typeof manifest.name !== 'string') {
|
|
245
|
+
throw new Error('invalid manifest: name must be a string');
|
|
246
|
+
}
|
|
247
|
+
if (manifest.version !== undefined && typeof manifest.version !== 'string') {
|
|
248
|
+
throw new Error('invalid manifest: version must be a string');
|
|
249
|
+
}
|
|
250
|
+
if (manifest.license !== undefined && typeof manifest.license !== 'string') {
|
|
251
|
+
throw new Error('invalid manifest: license must be a string');
|
|
252
|
+
}
|
|
253
|
+
if (manifest.application !== undefined && typeof manifest.application !== 'boolean') {
|
|
254
|
+
throw new Error('invalid manifest: application must be a boolean');
|
|
255
|
+
}
|
|
256
|
+
if (manifest.installable !== undefined && typeof manifest.installable !== 'boolean') {
|
|
257
|
+
throw new Error('invalid manifest: installable must be a boolean');
|
|
258
|
+
}
|
|
259
|
+
if (manifest.depends !== undefined && !Array.isArray(manifest.depends)) {
|
|
260
|
+
throw new Error('invalid manifest: depends must be a list of strings');
|
|
261
|
+
}
|
|
262
|
+
if (manifest.data !== undefined && !Array.isArray(manifest.data)) {
|
|
263
|
+
throw new Error('invalid manifest: data must be a list of strings');
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(manifest.depends) && !manifest.depends.every((entry) => typeof entry === 'string')) {
|
|
266
|
+
throw new Error('invalid manifest: depends must be a list of strings');
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(manifest.data) && !manifest.data.every((entry) => typeof entry === 'string')) {
|
|
269
|
+
throw new Error('invalid manifest: data must be a list of strings');
|
|
270
|
+
}
|
|
271
|
+
return manifest;
|
|
272
|
+
}
|
|
273
|
+
export function parseOdooManifest(content) {
|
|
274
|
+
try {
|
|
275
|
+
const { content: sourceContent, parser } = createParserState(content);
|
|
276
|
+
const manifest = parseValue(parser, sourceContent);
|
|
277
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
278
|
+
return { ok: false, error: 'Invalid manifest: top-level value must be a dictionary literal' };
|
|
279
|
+
}
|
|
280
|
+
skipWhitespaceAndComments(parser, sourceContent);
|
|
281
|
+
if (parser.index < sourceContent.length) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
error: `Invalid manifest: trailing content after top-level object at line ${parser.line}, column ${parser.column}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
manifest: validateManifest(manifest),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error instanceof Error) {
|
|
294
|
+
return { ok: false, error: error.message };
|
|
295
|
+
}
|
|
296
|
+
return { ok: false, error: 'Invalid manifest content' };
|
|
297
|
+
}
|
|
298
|
+
}
|
package/dist/module-quality.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { parseOdooManifest } from './module-manifest.js';
|
|
3
4
|
export function emptyModuleQualitySummary() {
|
|
4
5
|
return {
|
|
5
6
|
totalModules: 0,
|
|
@@ -11,7 +12,8 @@ export function emptyModuleQualitySummary() {
|
|
|
11
12
|
};
|
|
12
13
|
}
|
|
13
14
|
export function isInstallableManifest(content) {
|
|
14
|
-
|
|
15
|
+
const parsed = parseOdooManifest(content);
|
|
16
|
+
return parsed.ok && parsed.manifest.installable !== false;
|
|
15
17
|
}
|
|
16
18
|
export function hasActionableMenuXml(content, moduleName) {
|
|
17
19
|
const actionId = `action_${moduleName}`;
|
|
@@ -31,32 +33,144 @@ async function readMenusXml(modulePath) {
|
|
|
31
33
|
return [];
|
|
32
34
|
}
|
|
33
35
|
}
|
|
36
|
+
async function readPythonModelFiles(modulePath) {
|
|
37
|
+
try {
|
|
38
|
+
const entries = await readdir(join(modulePath, 'models'), { withFileTypes: true });
|
|
39
|
+
return entries
|
|
40
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.py') && entry.name !== '__init__.py')
|
|
41
|
+
.map((entry) => entry.name);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function readViewXmlFiles(modulePath) {
|
|
48
|
+
try {
|
|
49
|
+
const entries = await readdir(join(modulePath, 'views'), { withFileTypes: true });
|
|
50
|
+
return entries
|
|
51
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.xml') && !entry.name.endsWith('_menus.xml'))
|
|
52
|
+
.map((entry) => entry.name);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function directoryExists(path) {
|
|
59
|
+
try {
|
|
60
|
+
return (await stat(path)).isDirectory();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function fileExists(path) {
|
|
67
|
+
try {
|
|
68
|
+
return (await stat(path)).isFile();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function readOptionalFile(path) {
|
|
75
|
+
try {
|
|
76
|
+
return await readFile(path, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function moduleIssue(moduleName, path, issue) {
|
|
83
|
+
return { moduleName, path, issue };
|
|
84
|
+
}
|
|
85
|
+
function manifestData(manifest) {
|
|
86
|
+
return Array.isArray(manifest?.data) ? manifest.data : [];
|
|
87
|
+
}
|
|
88
|
+
function manifestDepends(manifest) {
|
|
89
|
+
return Array.isArray(manifest?.depends) ? manifest.depends : [];
|
|
90
|
+
}
|
|
91
|
+
function dataIncludesAccessCsv(data) {
|
|
92
|
+
return data.includes('security/ir.model.access.csv');
|
|
93
|
+
}
|
|
94
|
+
function dataIncludesViewXml(data) {
|
|
95
|
+
return data.some((entry) => entry.startsWith('views/') && entry.endsWith('.xml') && !entry.endsWith('_menus.xml'));
|
|
96
|
+
}
|
|
97
|
+
function moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data) {
|
|
98
|
+
return (modelFiles.length > 0 ||
|
|
99
|
+
viewFiles.length > 0 ||
|
|
100
|
+
menuXml.length > 0 ||
|
|
101
|
+
data.some((entry) => entry.startsWith('security/') || entry.startsWith('views/')));
|
|
102
|
+
}
|
|
103
|
+
function pythonImportPresent(content, importName) {
|
|
104
|
+
if (!content)
|
|
105
|
+
return false;
|
|
106
|
+
return new RegExp(`^\\s*from\\s+\\.\\s+import\\s+.*\\b${importName}\\b`, 'mu').test(content);
|
|
107
|
+
}
|
|
34
108
|
export async function analyzeModuleDirectory(modulePath, moduleName = basename(modulePath), relativePath = modulePath) {
|
|
35
109
|
const issues = [];
|
|
110
|
+
let manifest;
|
|
36
111
|
let installable = false;
|
|
37
|
-
|
|
38
|
-
|
|
112
|
+
const manifestContent = await readOptionalFile(join(modulePath, '__manifest__.py'));
|
|
113
|
+
if (!manifestContent) {
|
|
114
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing __manifest__.py'));
|
|
39
115
|
}
|
|
40
|
-
|
|
41
|
-
|
|
116
|
+
else {
|
|
117
|
+
const parsedManifest = parseOdooManifest(manifestContent);
|
|
118
|
+
if (parsedManifest.ok) {
|
|
119
|
+
manifest = parsedManifest.manifest;
|
|
120
|
+
installable = manifest.installable !== false;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
issues.push(moduleIssue(moduleName, relativePath, `invalid manifest syntax: ${parsedManifest.error}`));
|
|
124
|
+
}
|
|
42
125
|
}
|
|
43
|
-
if (
|
|
44
|
-
issues.push(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
126
|
+
if (manifest?.installable === false) {
|
|
127
|
+
issues.push(moduleIssue(moduleName, relativePath, 'installable is false in __manifest__.py'));
|
|
128
|
+
}
|
|
129
|
+
if (manifest && !manifest.license) {
|
|
130
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing license in __manifest__.py'));
|
|
131
|
+
}
|
|
132
|
+
if (manifest && manifest.depends === undefined) {
|
|
133
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing depends in __manifest__.py'));
|
|
49
134
|
}
|
|
50
135
|
const menuXml = await readMenusXml(modulePath);
|
|
136
|
+
const modelFiles = await readPythonModelFiles(modulePath);
|
|
137
|
+
const viewFiles = await readViewXmlFiles(modulePath);
|
|
138
|
+
const depends = manifestDepends(manifest);
|
|
139
|
+
const data = manifestData(manifest);
|
|
140
|
+
const hasOdooStructures = moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data);
|
|
141
|
+
if (hasOdooStructures && !depends.includes('base')) {
|
|
142
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing base dependency for model-based module'));
|
|
143
|
+
}
|
|
144
|
+
if (modelFiles.length > 0) {
|
|
145
|
+
const rootInit = await readOptionalFile(join(modulePath, '__init__.py'));
|
|
146
|
+
if (!pythonImportPresent(rootInit, 'models')) {
|
|
147
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing __init__.py models import'));
|
|
148
|
+
}
|
|
149
|
+
const modelsInit = await readOptionalFile(join(modulePath, 'models/__init__.py'));
|
|
150
|
+
const missingModelImport = modelFiles
|
|
151
|
+
.map((fileName) => fileName.replace(/\.py$/u, ''))
|
|
152
|
+
.some((modelImport) => !pythonImportPresent(modelsInit, modelImport));
|
|
153
|
+
if (missingModelImport) {
|
|
154
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing models/__init__.py model import'));
|
|
155
|
+
}
|
|
156
|
+
if (!(await fileExists(join(modulePath, 'security/ir.model.access.csv')))) {
|
|
157
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
|
|
161
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
|
|
162
|
+
}
|
|
163
|
+
if (hasOdooStructures && viewFiles.length === 0 && !dataIncludesViewXml(data)) {
|
|
164
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing views XML under views/'));
|
|
165
|
+
}
|
|
166
|
+
if (!(await directoryExists(join(modulePath, 'tests')))) {
|
|
167
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
|
|
168
|
+
}
|
|
51
169
|
const hasMenuAction = menuXml.some((content) => hasActionableMenuXml(content, moduleName));
|
|
52
170
|
if (!hasMenuAction) {
|
|
53
|
-
issues.push(
|
|
54
|
-
moduleName,
|
|
55
|
-
path: relativePath,
|
|
56
|
-
issue: 'missing actionable menu XML',
|
|
57
|
-
});
|
|
171
|
+
issues.push(moduleIssue(moduleName, relativePath, 'missing actionable menu XML'));
|
|
58
172
|
}
|
|
59
|
-
return { moduleName, relativePath, installable, hasMenuAction, issues };
|
|
173
|
+
return { moduleName, relativePath, installable, hasMenuAction, depends, ...(manifest ? { manifest } : {}), issues };
|
|
60
174
|
}
|
|
61
175
|
export function addModuleQualityResult(summary, result) {
|
|
62
176
|
return {
|
|
@@ -69,15 +183,86 @@ export function addModuleQualityResult(summary, result) {
|
|
|
69
183
|
};
|
|
70
184
|
}
|
|
71
185
|
export function mergeModuleQualitySummaries(left, right) {
|
|
186
|
+
const missingDependencies = [
|
|
187
|
+
...(left.dependencyGraph?.missingDependencies ?? []),
|
|
188
|
+
...(right.dependencyGraph?.missingDependencies ?? []),
|
|
189
|
+
];
|
|
190
|
+
const dependencies = [...(left.dependencyGraph?.dependencies ?? []), ...(right.dependencyGraph?.dependencies ?? [])];
|
|
191
|
+
const cycles = [...(left.dependencyGraph?.cycles ?? []), ...(right.dependencyGraph?.cycles ?? [])];
|
|
192
|
+
const dependencyGraph = dependencies.length > 0 || missingDependencies.length > 0 || cycles.length > 0
|
|
193
|
+
? { dependencies, missingDependencies, cycles }
|
|
194
|
+
: undefined;
|
|
72
195
|
return {
|
|
73
196
|
totalModules: left.totalModules + right.totalModules,
|
|
74
197
|
installableModules: left.installableModules + right.installableModules,
|
|
75
198
|
nonInstallableModules: left.nonInstallableModules + right.nonInstallableModules,
|
|
76
199
|
modulesWithMenuActions: left.modulesWithMenuActions + right.modulesWithMenuActions,
|
|
77
200
|
modulesMissingMenuActions: left.modulesMissingMenuActions + right.modulesMissingMenuActions,
|
|
201
|
+
...(dependencyGraph ? { dependencyGraph } : {}),
|
|
78
202
|
issues: [...left.issues, ...right.issues],
|
|
79
203
|
};
|
|
80
204
|
}
|
|
205
|
+
function firstToken(value) {
|
|
206
|
+
return value.split(/[_-]+/u, 1)[0] ?? value;
|
|
207
|
+
}
|
|
208
|
+
function looksLikeMissingLocalDependency(moduleName, dependency) {
|
|
209
|
+
if (dependency === 'base')
|
|
210
|
+
return false;
|
|
211
|
+
const namespace = firstToken(moduleName);
|
|
212
|
+
const generatedOrProjectNamespaces = new Set(['custom', 'demo', 'module', 'odoo', 'wpmoo']);
|
|
213
|
+
return Boolean(namespace) && generatedOrProjectNamespaces.has(namespace) && dependency.startsWith(`${namespace}_`);
|
|
214
|
+
}
|
|
215
|
+
function findCycleFrom(start, current, graph, path = [start]) {
|
|
216
|
+
for (const dependency of graph.get(current) ?? []) {
|
|
217
|
+
if (dependency === start) {
|
|
218
|
+
return [...path, start];
|
|
219
|
+
}
|
|
220
|
+
if (path.includes(dependency)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const found = findCycleFrom(start, dependency, graph, [...path, dependency]);
|
|
224
|
+
if (found)
|
|
225
|
+
return found;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
function moduleDependencyGraph(results) {
|
|
230
|
+
const byName = new Map(results.map((result) => [result.moduleName, result]));
|
|
231
|
+
const localModuleNames = new Set(byName.keys());
|
|
232
|
+
const dependencyEdges = new Map();
|
|
233
|
+
const missingDependencies = [];
|
|
234
|
+
const dependencies = [];
|
|
235
|
+
const issues = [];
|
|
236
|
+
for (const result of results) {
|
|
237
|
+
const localDependencies = result.depends.filter((dependency) => localModuleNames.has(dependency));
|
|
238
|
+
dependencyEdges.set(result.moduleName, localDependencies);
|
|
239
|
+
for (const dependency of result.depends) {
|
|
240
|
+
if (localModuleNames.has(dependency)) {
|
|
241
|
+
dependencies.push({ moduleName: result.moduleName, dependency, kind: 'local' });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (looksLikeMissingLocalDependency(result.moduleName, dependency)) {
|
|
245
|
+
dependencies.push({ moduleName: result.moduleName, dependency, kind: 'unresolved' });
|
|
246
|
+
missingDependencies.push({ moduleName: result.moduleName, dependency });
|
|
247
|
+
issues.push(moduleIssue(result.moduleName, result.relativePath, `missing local dependency ${dependency}`));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
dependencies.push({ moduleName: result.moduleName, dependency, kind: 'external' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const cycles = [];
|
|
254
|
+
for (const moduleName of localModuleNames) {
|
|
255
|
+
const cycle = findCycleFrom(moduleName, moduleName, dependencyEdges);
|
|
256
|
+
if (cycle) {
|
|
257
|
+
cycles.push(cycle);
|
|
258
|
+
const result = byName.get(moduleName);
|
|
259
|
+
if (result) {
|
|
260
|
+
issues.push(moduleIssue(moduleName, result.relativePath, `dependency cycle detected: ${cycle.join(' -> ')}`));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { graph: { dependencies, missingDependencies, cycles }, issues };
|
|
265
|
+
}
|
|
81
266
|
export async function scanModuleQuality(root, target) {
|
|
82
267
|
try {
|
|
83
268
|
const rootStat = await stat(root);
|
|
@@ -89,6 +274,7 @@ export async function scanModuleQuality(root, target) {
|
|
|
89
274
|
}
|
|
90
275
|
let summary = emptyModuleQualitySummary();
|
|
91
276
|
const stack = [root];
|
|
277
|
+
const results = [];
|
|
92
278
|
while (stack.length > 0) {
|
|
93
279
|
const current = stack.pop();
|
|
94
280
|
if (!current)
|
|
@@ -104,8 +290,16 @@ export async function scanModuleQuality(root, target) {
|
|
|
104
290
|
}
|
|
105
291
|
}
|
|
106
292
|
if (hasManifest) {
|
|
107
|
-
|
|
293
|
+
const result = await analyzeModuleDirectory(current, basename(current), relative(target, current));
|
|
294
|
+
results.push(result);
|
|
295
|
+
summary = addModuleQualityResult(summary, result);
|
|
108
296
|
}
|
|
109
297
|
}
|
|
110
|
-
|
|
298
|
+
const dependencyGraph = moduleDependencyGraph(results);
|
|
299
|
+
const hasDependencyGraphIssues = dependencyGraph.graph.missingDependencies.length > 0 || dependencyGraph.graph.cycles.length > 0;
|
|
300
|
+
return {
|
|
301
|
+
...summary,
|
|
302
|
+
...(hasDependencyGraphIssues ? { dependencyGraph: dependencyGraph.graph } : {}),
|
|
303
|
+
issues: [...summary.issues, ...dependencyGraph.issues],
|
|
304
|
+
};
|
|
111
305
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
function normalizeQuery(query) {
|
|
2
|
+
return query.trim().toLowerCase();
|
|
3
|
+
}
|
|
4
|
+
function moduleMatchesExact(query, module) {
|
|
5
|
+
return module.moduleName.toLowerCase() === query;
|
|
6
|
+
}
|
|
7
|
+
function moduleMatchesPartial(query, module) {
|
|
8
|
+
return module.moduleName.toLowerCase().includes(query);
|
|
9
|
+
}
|
|
10
|
+
function tokenizeModuleName(moduleName) {
|
|
11
|
+
return moduleName.toLowerCase().split(/[_-]+/g).filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
function levenshteinDistance(a, b) {
|
|
14
|
+
if (a === b) {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
if (!a) {
|
|
18
|
+
return b.length;
|
|
19
|
+
}
|
|
20
|
+
if (!b) {
|
|
21
|
+
return a.length;
|
|
22
|
+
}
|
|
23
|
+
const rows = a.length + 1;
|
|
24
|
+
const cols = b.length + 1;
|
|
25
|
+
const matrix = Array.from({ length: rows }, (_, rowIndex) => {
|
|
26
|
+
const row = new Array(cols);
|
|
27
|
+
if (rowIndex === 0) {
|
|
28
|
+
for (let col = 0; col < cols; col += 1) {
|
|
29
|
+
row[col] = col;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
row[0] = rowIndex;
|
|
34
|
+
}
|
|
35
|
+
return row;
|
|
36
|
+
});
|
|
37
|
+
for (let row = 1; row < rows; row += 1) {
|
|
38
|
+
for (let col = 1; col < cols; col += 1) {
|
|
39
|
+
const cost = a[row - 1] === b[col - 1] ? 0 : 1;
|
|
40
|
+
const substitutions = matrix[row - 1][col - 1] + cost;
|
|
41
|
+
const insertions = matrix[row][col - 1] + 1;
|
|
42
|
+
const deletions = matrix[row - 1][col] + 1;
|
|
43
|
+
matrix[row][col] = Math.min(substitutions, insertions, deletions);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return matrix[a.length][b.length];
|
|
47
|
+
}
|
|
48
|
+
function nearestCandidates(query, modules, maxItems = 3) {
|
|
49
|
+
const queryNormalized = query;
|
|
50
|
+
const scoredModules = modules
|
|
51
|
+
.map((module) => {
|
|
52
|
+
const fullMatchDistance = levenshteinDistance(module.moduleName.toLowerCase(), queryNormalized);
|
|
53
|
+
const tokenMatchDistance = tokenizeModuleName(module.moduleName).reduce((best, token) => Math.min(best, levenshteinDistance(token, queryNormalized)), Number.POSITIVE_INFINITY);
|
|
54
|
+
const distance = Math.min(fullMatchDistance, tokenMatchDistance);
|
|
55
|
+
return { module, distance };
|
|
56
|
+
})
|
|
57
|
+
.filter((entry) => entry.distance <= 4);
|
|
58
|
+
const scoredWithIndex = scoredModules.map((entry, index) => ({ ...entry, index }));
|
|
59
|
+
scoredWithIndex.sort((left, right) => {
|
|
60
|
+
if (left.distance !== right.distance) {
|
|
61
|
+
return left.distance - right.distance;
|
|
62
|
+
}
|
|
63
|
+
return left.index - right.index;
|
|
64
|
+
});
|
|
65
|
+
const topDistance = scoredWithIndex[0]?.distance ?? Number.POSITIVE_INFINITY;
|
|
66
|
+
return scoredWithIndex
|
|
67
|
+
.filter((entry) => entry.distance <= topDistance + 1)
|
|
68
|
+
.slice(0, maxItems)
|
|
69
|
+
.map((entry) => entry.module);
|
|
70
|
+
}
|
|
71
|
+
export function resolveModuleTarget(query, modules) {
|
|
72
|
+
const normalizedQuery = normalizeQuery(query);
|
|
73
|
+
if (!normalizedQuery) {
|
|
74
|
+
return { kind: 'no-match', query, candidates: [] };
|
|
75
|
+
}
|
|
76
|
+
const exactMatches = modules.filter((module) => moduleMatchesExact(normalizedQuery, module));
|
|
77
|
+
if (exactMatches.length === 1) {
|
|
78
|
+
return { kind: 'exact', query, module: exactMatches[0] };
|
|
79
|
+
}
|
|
80
|
+
if (exactMatches.length > 1) {
|
|
81
|
+
return { kind: 'ambiguous', query, candidates: exactMatches };
|
|
82
|
+
}
|
|
83
|
+
const partialMatches = normalizedQuery.length >= 3 ? modules.filter((module) => moduleMatchesPartial(normalizedQuery, module)) : [];
|
|
84
|
+
if (partialMatches.length === 1) {
|
|
85
|
+
return { kind: 'exact', query, module: partialMatches[0] };
|
|
86
|
+
}
|
|
87
|
+
if (partialMatches.length > 1) {
|
|
88
|
+
return { kind: 'ambiguous', query, candidates: partialMatches };
|
|
89
|
+
}
|
|
90
|
+
return { kind: 'no-match', query, candidates: nearestCandidates(normalizedQuery, modules) };
|
|
91
|
+
}
|
package/dist/templates.js
CHANGED
|
@@ -828,7 +828,7 @@ function emptyModuleQuality() {
|
|
|
828
828
|
}
|
|
829
829
|
|
|
830
830
|
function manifestIsInstallable(content) {
|
|
831
|
-
return
|
|
831
|
+
return !/["']installable["']\\s*:\\s*(?:False|false)\\b/.test(content);
|
|
832
832
|
}
|
|
833
833
|
|
|
834
834
|
function menuXmlHasAction(content, moduleName) {
|
|
@@ -867,7 +867,7 @@ async function analyzeModule(modulePath) {
|
|
|
867
867
|
issues.push({
|
|
868
868
|
moduleName,
|
|
869
869
|
path: moduleRelativePath,
|
|
870
|
-
issue: '
|
|
870
|
+
issue: 'installable is false in __manifest__.py',
|
|
871
871
|
});
|
|
872
872
|
}
|
|
873
873
|
|