@wpmoo/toolkit 0.9.22 → 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 +49 -19
- package/dist/cockpit/command-registry.js +23 -24
- package/dist/cockpit/menu.js +16 -4
- package/dist/module-manifest.js +298 -0
- package/dist/module-quality.js +213 -19
- package/dist/module-target-resolver.js +91 -0
- package/dist/prompts/index.js +4 -1
- package/dist/status.js +16 -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';
|
|
@@ -38,7 +39,7 @@ import { confirmPrompt, introPrompt, isPromptCancel, notePrompt, outroPrompt, se
|
|
|
38
39
|
import { renderBanner } from './templates.js';
|
|
39
40
|
import { checkForUpdate, isUpdateCheckSkipped, restartCli } from './update-check.js';
|
|
40
41
|
import { packageName, packageVersion, renderVersion, renderVersionTag } from './version.js';
|
|
41
|
-
import { environmentStatusJson, getEnvironmentStatus,
|
|
42
|
+
import { environmentStatusJson, getEnvironmentStatus, environmentBannerSummaryLine, renderEnvironmentStatusForTarget, } from './status.js';
|
|
42
43
|
import { getGitHubAccounts, getGitHubRepositoryStatus, githubRepositoryUrl, realGitHub, createGitHubRepository, } from './github.js';
|
|
43
44
|
import { environmentGitHubOwner } from './environment-context.js';
|
|
44
45
|
import { handlePromptCancel, handleUnavailableMenuChoice, installPromptCancelKeyTracker, isMenuBackSignal, MenuBackSignal, menuIntroTitle, menuPromptMessage, } from './menu-navigation.js';
|
|
@@ -184,27 +185,16 @@ function validateRepoName(value) {
|
|
|
184
185
|
function startupVersionLine(latestVersion) {
|
|
185
186
|
return `v${packageVersion()}${latestVersion ? ` -> v${latestVersion} available` : ''}`;
|
|
186
187
|
}
|
|
187
|
-
function pluralize(count, singular, plural) {
|
|
188
|
-
return `${count} ${count === 1 ? singular : plural}`;
|
|
189
|
-
}
|
|
190
|
-
function renderStartupEnvironmentLine(status) {
|
|
191
|
-
if (status.kind !== 'environment') {
|
|
192
|
-
return `Environment: ${renderEnvironmentStatusSummary(status)}`;
|
|
193
|
-
}
|
|
194
|
-
const issueCount = status.composeErrors.length + status.invalidSourceRepoPaths.length + status.missingCoreFiles.length;
|
|
195
|
-
const issueSuffix = issueCount > 0 ? ` · ${pluralize(issueCount, 'issue', 'issues')}` : '';
|
|
196
|
-
return [
|
|
197
|
-
`Environment: Odoo ${status.odooVersion}`,
|
|
198
|
-
pluralize(status.sourceRepoCount, 'repo', 'repos'),
|
|
199
|
-
pluralize(status.moduleCandidateCount, 'module', 'modules'),
|
|
200
|
-
].join(' · ') + issueSuffix;
|
|
201
|
-
}
|
|
202
188
|
function renderStartupBanner(details, latestVersion) {
|
|
203
189
|
const versionLine = startupVersionLine(latestVersion);
|
|
204
190
|
return renderBanner(details?.(versionLine), details ? { version: versionLine } : undefined);
|
|
205
191
|
}
|
|
206
192
|
function renderCockpitStatusLines(status, serviceStatus, lastStatus) {
|
|
207
|
-
return [
|
|
193
|
+
return [
|
|
194
|
+
environmentBannerSummaryLine(status),
|
|
195
|
+
renderServiceRuntimeStatusLine(serviceStatus),
|
|
196
|
+
lastStatus,
|
|
197
|
+
];
|
|
208
198
|
}
|
|
209
199
|
function renderLastCommandStatus(command) {
|
|
210
200
|
return `Last: ${command.label} ✓ completed`;
|
|
@@ -1120,6 +1110,46 @@ function dailyActionSelectedLabel(command, argv) {
|
|
|
1120
1110
|
}
|
|
1121
1111
|
return undefined;
|
|
1122
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
|
+
}
|
|
1123
1153
|
async function selectDatabaseArg(cwd, message, fallback, options = {}) {
|
|
1124
1154
|
const databaseResult = normalizeDatabaseListResult(await listEnvironmentDatabases(cwd, options));
|
|
1125
1155
|
const databases = databaseResult.databases;
|
|
@@ -1596,7 +1626,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
|
|
|
1596
1626
|
}
|
|
1597
1627
|
if (isDailyActionCommand(route.command)) {
|
|
1598
1628
|
console.log(renderBanner());
|
|
1599
|
-
await runDailyAction(route.command, route.argv, cwd);
|
|
1629
|
+
await runDailyAction(route.command, await resolveDailyActionModuleTargets(route.command, route.argv, cwd), cwd);
|
|
1600
1630
|
return;
|
|
1601
1631
|
}
|
|
1602
1632
|
const options = optionsFromArgs(route.argv);
|
|
@@ -29,41 +29,40 @@ function internalCommand(id, category, label, description, aliases = []) {
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
export const cockpitCommands = [
|
|
32
|
-
dailyCommand('start', 'services', 'Start services', 'Start
|
|
33
|
-
dailyCommand('stop', 'services', 'Stop services', 'Stop
|
|
34
|
-
dailyCommand('restart', 'services', 'Restart services', 'Restart
|
|
35
|
-
dailyCommand('logs', 'services', 'View logs', '
|
|
36
|
-
dailyCommand('shell', 'services', 'Open shell', 'Open a
|
|
32
|
+
dailyCommand('start', 'services', 'Start services', 'Start Odoo services.', ['up', 'compose up']),
|
|
33
|
+
dailyCommand('stop', 'services', 'Stop services', 'Stop Odoo services.', ['down', 'compose down']),
|
|
34
|
+
dailyCommand('restart', 'services', 'Restart services', 'Restart Odoo services.', ['reload']),
|
|
35
|
+
dailyCommand('logs', 'services', 'View logs', 'Tail service logs.', ['log', 'tail']),
|
|
36
|
+
dailyCommand('shell', 'services', 'Open shell', 'Open a service shell.', ['bash', 'terminal']),
|
|
37
37
|
internalCommand('list-modules', 'modules', 'List modules', 'Browse detected Odoo modules by source category.', [
|
|
38
38
|
'modules list',
|
|
39
39
|
'browse modules',
|
|
40
|
+
'/module',
|
|
41
|
+
'module',
|
|
40
42
|
]),
|
|
41
|
-
dailyCommand('install', 'modules', 'Install module', 'Install
|
|
42
|
-
dailyCommand('update', 'modules', 'Update module', 'Update
|
|
43
|
-
dailyCommand('test', 'modules', 'Run tests', 'Run
|
|
44
|
-
dailyCommand('lint', 'modules', 'Run environment lint', 'Run
|
|
45
|
-
dailyCommand('pot', 'modules', 'Generate POT', 'Generate translation
|
|
46
|
-
dailyCommand('psql', 'database', 'Open psql', 'Open
|
|
47
|
-
dailyCommand('snapshot', 'database', 'Create snapshot', 'Create a database snapshot.', ['backup', 'dump']),
|
|
48
|
-
dailyCommand('restore-snapshot', 'database', 'Restore snapshot', 'Restore a
|
|
49
|
-
dailyCommand('resetdb', 'database', 'Reset database', 'Reset
|
|
43
|
+
dailyCommand('install', 'modules', 'Install module', 'Install modules in the database.', ['install module', 'module']),
|
|
44
|
+
dailyCommand('update', 'modules', 'Update module', 'Update modules in the database.', ['upgrade', 'module']),
|
|
45
|
+
dailyCommand('test', 'modules', 'Run tests', 'Run tests for selected modules.', ['tests', 'pytest', 'module']),
|
|
46
|
+
dailyCommand('lint', 'modules', 'Run environment lint', 'Run environment lint checks.', ['check', 'quality']),
|
|
47
|
+
dailyCommand('pot', 'modules', 'Generate POT', 'Generate module translation templates.', ['translation', 'i18n']),
|
|
48
|
+
dailyCommand('psql', 'database', 'Open psql', 'Open PostgreSQL prompt.', ['postgres', 'sql', '/db']),
|
|
49
|
+
dailyCommand('snapshot', 'database', 'Create snapshot', 'Create a database snapshot.', ['backup', 'dump', '/snapshot']),
|
|
50
|
+
dailyCommand('restore-snapshot', 'database', 'Restore snapshot', 'Restore a named snapshot.', ['restore', 'snapshot restore']),
|
|
51
|
+
dailyCommand('resetdb', 'database', 'Reset database', 'Reset the environment database.', ['reset db', 'database reset']),
|
|
50
52
|
internalCommand('status', 'diagnostics', 'Environment status', 'Show a summary of the current environment state.', [
|
|
51
53
|
'state',
|
|
52
54
|
'summary',
|
|
53
55
|
]),
|
|
54
|
-
internalCommand('doctor', 'diagnostics', 'Run doctor', 'Run environment diagnostics
|
|
55
|
-
|
|
56
|
-
'health',
|
|
57
|
-
]),
|
|
58
|
-
internalCommand('add-repo', 'repositories', 'Add source repo', 'Add a source repository as an environment submodule.', [
|
|
56
|
+
internalCommand('doctor', 'diagnostics', 'Run doctor', 'Run environment diagnostics.', ['diagnose', 'health']),
|
|
57
|
+
internalCommand('add-repo', 'repositories', 'Add source repo', 'Add a source repository.', [
|
|
59
58
|
'repository add',
|
|
60
59
|
'source add',
|
|
61
60
|
]),
|
|
62
|
-
internalCommand('remove-repo', 'repositories', 'Remove source repo', 'Remove a source repository
|
|
63
|
-
internalCommand('add-module', 'modules', 'Add module', 'Add a module
|
|
64
|
-
internalCommand('remove-module', 'modules', 'Remove module', 'Remove a module
|
|
65
|
-
internalCommand('safe-reset', 'maintenance', 'Safe reset environment', 'Refresh generated
|
|
66
|
-
internalCommand('exit', 'maintenance', 'Exit', '
|
|
61
|
+
internalCommand('remove-repo', 'repositories', 'Remove source repo', 'Remove a source repository.', ['repository remove', 'source remove']),
|
|
62
|
+
internalCommand('add-module', 'modules', 'Add module', 'Add a module to a source repository.', ['module add']),
|
|
63
|
+
internalCommand('remove-module', 'modules', 'Remove module', 'Remove a module from a source repository.', ['module remove']),
|
|
64
|
+
internalCommand('safe-reset', 'maintenance', 'Safe reset environment', 'Refresh generated files only.', ['reset', 'refresh', '/safe']),
|
|
65
|
+
internalCommand('exit', 'maintenance', 'Exit', 'Close the command palette.', ['quit', 'back']),
|
|
67
66
|
];
|
|
68
67
|
const defaultCommandIds = new Set(['start', 'logs', 'test', 'status', 'doctor', 'exit']);
|
|
69
68
|
export function normalizeCockpitSearchTerm(term) {
|
package/dist/cockpit/menu.js
CHANGED
|
@@ -33,6 +33,21 @@ function categoryHeading(category) {
|
|
|
33
33
|
function commandName(command) {
|
|
34
34
|
return `${rgb(226, 184, 96, ` ${command.label.padEnd(topLevelCommandLabelWidth)}`)}${dim(` ${command.description}`)}`;
|
|
35
35
|
}
|
|
36
|
+
const disabledReasonNextStep = {
|
|
37
|
+
'No modules found.': 'Next: choose "Add module" first.',
|
|
38
|
+
'Services stopped.': 'Next: choose "Start services" first.',
|
|
39
|
+
'Already running.': 'Next: choose "Stop services" or "Restart services".',
|
|
40
|
+
'Docker not running.': 'Next: start Docker, then choose "Start services".',
|
|
41
|
+
'No source repos found.': 'Next: choose "Add source repo" first.',
|
|
42
|
+
};
|
|
43
|
+
function disabledError(reason) {
|
|
44
|
+
const base = 'This option is disabled and cannot be selected.';
|
|
45
|
+
if (!reason) {
|
|
46
|
+
return base;
|
|
47
|
+
}
|
|
48
|
+
const nextStep = disabledReasonNextStep[reason];
|
|
49
|
+
return nextStep ? `${base}\nReason: ${reason}\n${nextStep}` : `${base}\nReason: ${reason}`;
|
|
50
|
+
}
|
|
36
51
|
function serviceDisabledReason(command, serviceStatus) {
|
|
37
52
|
if (command.category !== 'services' || !serviceStatus)
|
|
38
53
|
return undefined;
|
|
@@ -56,9 +71,6 @@ function disabledReason(command, serviceStatus, moduleCount, sourceRepoCount) {
|
|
|
56
71
|
moduleDisabledReason(command, moduleCount) ??
|
|
57
72
|
sourceRepoDisabledReason(command, sourceRepoCount));
|
|
58
73
|
}
|
|
59
|
-
function disabledError() {
|
|
60
|
-
return 'This option is disabled and cannot be selected.';
|
|
61
|
-
}
|
|
62
74
|
function commandDisabledValue(reason) {
|
|
63
75
|
if (!reason) {
|
|
64
76
|
return undefined;
|
|
@@ -131,7 +143,7 @@ export async function selectCockpitTopLevelMenu(options = {}) {
|
|
|
131
143
|
pageSize: topLevelPageSize(choices.length),
|
|
132
144
|
loop: false,
|
|
133
145
|
hideMessage: true,
|
|
134
|
-
disabledError
|
|
146
|
+
disabledError,
|
|
135
147
|
navigationWarning: options.navigationWarning,
|
|
136
148
|
escapeBehavior: 'ignore',
|
|
137
149
|
});
|
|
@@ -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/prompts/index.js
CHANGED
|
@@ -171,10 +171,13 @@ function hiddenSelectTheme(disabledError, navigationHelp = 'exit', navigationWar
|
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
173
|
function disabledErrorI18n(disabledError, activeReason) {
|
|
174
|
-
const i18n = { disabledError };
|
|
174
|
+
const i18n = typeof disabledError === 'string' ? { disabledError } : { disabledError: disabledError(undefined) };
|
|
175
175
|
Object.defineProperty(i18n, 'disabledError', {
|
|
176
176
|
get: () => {
|
|
177
177
|
const reason = activeReason();
|
|
178
|
+
if (typeof disabledError === 'function') {
|
|
179
|
+
return disabledError(reason);
|
|
180
|
+
}
|
|
178
181
|
return reason ? `${disabledError}\nReason: ${reason}` : disabledError;
|
|
179
182
|
},
|
|
180
183
|
});
|
package/dist/status.js
CHANGED
|
@@ -5,6 +5,10 @@ import { defaultOdooVersion, markerPath } from './environment.js';
|
|
|
5
5
|
import { emptyModuleQualitySummary, mergeModuleQualitySummaries, scanModuleQuality, } from './module-quality.js';
|
|
6
6
|
import { isValidPathSegment, validateRepoPath } from './path-validation.js';
|
|
7
7
|
const validSourceTypes = ['private', 'oca', 'external'];
|
|
8
|
+
const summarySeparator = ' \u00B7 ';
|
|
9
|
+
function pluralize(count, singular, plural) {
|
|
10
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
11
|
+
}
|
|
8
12
|
function normalizeSourceType(sourceType) {
|
|
9
13
|
if (typeof sourceType === 'string' && validSourceTypes.includes(sourceType)) {
|
|
10
14
|
return sourceType;
|
|
@@ -85,6 +89,18 @@ async function missingCoreFiles(target, odooVersion) {
|
|
|
85
89
|
missing.push(...composeLayout.missingFiles);
|
|
86
90
|
return { missing, composeFiles: composeLayout.files, composeErrors: composeLayout.errors };
|
|
87
91
|
}
|
|
92
|
+
export function environmentBannerSummaryLine(status) {
|
|
93
|
+
if (status.kind !== 'environment') {
|
|
94
|
+
return `Environment: ${summaryText(status)}`;
|
|
95
|
+
}
|
|
96
|
+
const issueCount = status.composeErrors.length + status.invalidSourceRepoPaths.length + status.missingCoreFiles.length;
|
|
97
|
+
const issueSuffix = issueCount > 0 ? `${summarySeparator}${pluralize(issueCount, 'issue', 'issues')}` : '';
|
|
98
|
+
return [
|
|
99
|
+
`Environment: Odoo ${status.odooVersion}`,
|
|
100
|
+
pluralize(status.sourceRepoCount, 'repo', 'repos'),
|
|
101
|
+
pluralize(status.moduleCandidateCount, 'module', 'modules'),
|
|
102
|
+
].join(summarySeparator) + issueSuffix;
|
|
103
|
+
}
|
|
88
104
|
function summaryText(status) {
|
|
89
105
|
if (status.kind === 'no_environment')
|
|
90
106
|
return 'No WPMoo environment detected.';
|
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
|
|