@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 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
+ }
@@ -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
- return /["']installable["']\s*:\s*(?:True|true)\b/u.test(content);
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
- try {
38
- installable = isInstallableManifest(await readFile(join(modulePath, '__manifest__.py'), 'utf8'));
112
+ const manifestContent = await readOptionalFile(join(modulePath, '__manifest__.py'));
113
+ if (!manifestContent) {
114
+ issues.push(moduleIssue(moduleName, relativePath, 'missing __manifest__.py'));
39
115
  }
40
- catch {
41
- installable = false;
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 (!installable) {
44
- issues.push({
45
- moduleName,
46
- path: relativePath,
47
- issue: 'missing installable=True in __manifest__.py',
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
- summary = addModuleQualityResult(summary, await analyzeModuleDirectory(current, basename(current), relative(target, current)));
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
- return summary;
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 /["']installable["']\\s*:\\s*(?:True|true)\\b/.test(content);
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: 'missing installable=True in __manifest__.py',
870
+ issue: 'installable is false in __manifest__.py',
871
871
  });
872
872
  }
873
873
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.23",
3
+ "version": "0.9.24",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {