@ductape/cli 0.3.35 → 0.3.36
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.
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export interface FeaturesSyncOpts {
|
|
2
2
|
filter?: string;
|
|
3
3
|
}
|
|
4
|
+
export interface FeaturesValidateOpts {
|
|
5
|
+
json?: boolean;
|
|
6
|
+
}
|
|
4
7
|
export declare const DUCTAPE_CATALOGUE_SYNC_ENV = "DUCTAPE_SYNC_MODE";
|
|
5
8
|
export declare const DUCTAPE_FEATURE_FILTER_ENV = "DUCTAPE_FEATURE_FILTER";
|
|
6
9
|
export declare function buildFeaturesSyncEnvironment(filter?: string, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -8,8 +11,10 @@ export declare function buildFeaturesSyncEnvironment(filter?: string, base?: Nod
|
|
|
8
11
|
* Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
|
|
9
12
|
* executes declarative migration files directly), a Feature's handler is real application code
|
|
10
13
|
* that typically depends on the app's own services (repositories, DB connections, DI). Ductape
|
|
11
|
-
* cannot safely execute that code in isolation — so
|
|
14
|
+
* cannot safely execute that code in isolation — so, after a fail-closed AST preflight, this
|
|
15
|
+
* command delegates to a "features:sync"
|
|
12
16
|
* npm script that the project itself owns and controls, giving a single consistent entrypoint
|
|
13
17
|
* across every Ductape project regardless of framework.
|
|
14
18
|
*/
|
|
15
19
|
export declare function runFeaturesSync(opts: FeaturesSyncOpts): Promise<void>;
|
|
20
|
+
export declare function runFeaturesValidate(opts?: FeaturesValidateOpts): Promise<void>;
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { findProjectConfig } from '../lib/config.js';
|
|
5
5
|
import { fail } from '../lib/output.js';
|
|
6
|
+
import { formatFeatureValidation, validateFeatureProject } from '../lib/feature-control-flow.js';
|
|
6
7
|
export const DUCTAPE_CATALOGUE_SYNC_ENV = 'DUCTAPE_SYNC_MODE';
|
|
7
8
|
export const DUCTAPE_FEATURE_FILTER_ENV = 'DUCTAPE_FEATURE_FILTER';
|
|
8
9
|
export function buildFeaturesSyncEnvironment(filter, base = process.env) {
|
|
@@ -21,7 +22,8 @@ Ductape convention for code-first Features:
|
|
|
21
22
|
(ductape.sdk.functions.register(...)) — no network call, safe on every boot.
|
|
22
23
|
3. Add a "features:sync" script to package.json that boots just enough of your app to
|
|
23
24
|
construct real dependencies (no HTTP listener) and calls every registerXFeature(...) once.
|
|
24
|
-
4. Run
|
|
25
|
+
4. Run \`ductape features validate\`, fix every diagnostic, then run
|
|
26
|
+
\`ductape features sync\` explicitly — never automatically on app boot.
|
|
25
27
|
|
|
26
28
|
The CLI sets DUCTAPE_SYNC_MODE=1 for the child process. Ductape framework integrations use this
|
|
27
29
|
official catalogue-only signal to skip serving-time consumers and provider readiness. Project
|
|
@@ -36,7 +38,8 @@ from starting.
|
|
|
36
38
|
* Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
|
|
37
39
|
* executes declarative migration files directly), a Feature's handler is real application code
|
|
38
40
|
* that typically depends on the app's own services (repositories, DB connections, DI). Ductape
|
|
39
|
-
* cannot safely execute that code in isolation — so
|
|
41
|
+
* cannot safely execute that code in isolation — so, after a fail-closed AST preflight, this
|
|
42
|
+
* command delegates to a "features:sync"
|
|
40
43
|
* npm script that the project itself owns and controls, giving a single consistent entrypoint
|
|
41
44
|
* across every Ductape project regardless of framework.
|
|
42
45
|
*/
|
|
@@ -58,6 +61,10 @@ export async function runFeaturesSync(opts) {
|
|
|
58
61
|
console.warn(`Warning: ductape/features/ does not exist at ${dir}. Proceeding anyway — your ` +
|
|
59
62
|
'"features:sync" script may look elsewhere, but the convention is ductape/features/.');
|
|
60
63
|
}
|
|
64
|
+
const validation = validateFeatureProject(dir);
|
|
65
|
+
if (!validation.ok) {
|
|
66
|
+
fail(`${formatFeatureValidation(validation)}\nNo Feature registration process was started.`);
|
|
67
|
+
}
|
|
61
68
|
const npmArgs = ['run', 'features:sync'];
|
|
62
69
|
if (opts.filter)
|
|
63
70
|
npmArgs.push('--', opts.filter);
|
|
@@ -71,3 +78,15 @@ export async function runFeaturesSync(opts) {
|
|
|
71
78
|
fail(`features:sync exited with code ${result.status ?? 1}.`);
|
|
72
79
|
}
|
|
73
80
|
}
|
|
81
|
+
export async function runFeaturesValidate(opts = {}) {
|
|
82
|
+
const found = findProjectConfig();
|
|
83
|
+
if (!found)
|
|
84
|
+
fail('No linked project. Run `ductape link` from your project directory (or `ductape init`).');
|
|
85
|
+
const result = validateFeatureProject(found.dir);
|
|
86
|
+
if (opts.json)
|
|
87
|
+
console.log(JSON.stringify(result, null, 2));
|
|
88
|
+
else
|
|
89
|
+
console.log(formatFeatureValidation(result));
|
|
90
|
+
if (!result.ok)
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { runCloud, runCloudPreflight } from './commands/cloud.js';
|
|
|
18
18
|
import { runDb, runDbActions, runDbContext } from './commands/db.js';
|
|
19
19
|
import { runComponentActions } from './commands/component-actions.js';
|
|
20
20
|
import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
|
|
21
|
-
import { runFeaturesSync } from './commands/features-sync.js';
|
|
21
|
+
import { runFeaturesSync, runFeaturesValidate } from './commands/features-sync.js';
|
|
22
22
|
import { runDbSchemaGenerate, runDbSchemaPush } from './commands/db-schema.js';
|
|
23
23
|
import { runGraph } from './commands/graph.js';
|
|
24
24
|
import { runSecretImportEnv, runSecrets } from './commands/secrets.js';
|
|
@@ -895,10 +895,16 @@ db
|
|
|
895
895
|
return runDb(verb, { file: opts.file, json: opts.json });
|
|
896
896
|
}));
|
|
897
897
|
const features = program.command('features').description('Code-first Ductape Features (ductape/features/)');
|
|
898
|
+
features
|
|
899
|
+
.command('validate')
|
|
900
|
+
.description('Read-only AST validation of code-first Feature control flow; performs no remote mutation')
|
|
901
|
+
.option('--json', 'JSON output')
|
|
902
|
+
.action(wrap((opts) => runFeaturesValidate({ json: Boolean(opts.json) })));
|
|
898
903
|
features
|
|
899
904
|
.command('sync')
|
|
900
905
|
.description('Persist Features from ductape/features/ to the live product by running the project\'s own ' +
|
|
901
|
-
'"features:sync" npm script
|
|
906
|
+
'"features:sync" npm script after a fail-closed AST validation preflight ' +
|
|
907
|
+
'(see `ductape features sync --help` for the required convention). ' +
|
|
902
908
|
'Run explicitly, like `ductape db migrate` — never automatically on app boot.')
|
|
903
909
|
.argument('[filter]', 'Optional substring passed through to the project\'s features:sync script')
|
|
904
910
|
.action(wrap((filter) => runFeaturesSync({ filter })));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type FeatureControlFlowDiagnostic = {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
feature?: string;
|
|
6
|
+
code: string;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
export type FeatureProjectValidation = {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
featuresDir: string;
|
|
13
|
+
filesScanned: number;
|
|
14
|
+
featuresFound: number;
|
|
15
|
+
diagnostics: FeatureControlFlowDiagnostic[];
|
|
16
|
+
};
|
|
17
|
+
export declare function validateFeatureProject(projectRoot: string): FeatureProjectValidation;
|
|
18
|
+
export declare function formatFeatureValidation(result: FeatureProjectValidation): string;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from '@babel/parser';
|
|
4
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']);
|
|
5
|
+
const NATIVE_NODES = new Map([
|
|
6
|
+
['IfStatement', ['FEATURE_NATIVE_IF', 'Replace native if/else with ctx.branch(ctx.when.*, { then, else }).']],
|
|
7
|
+
['ConditionalExpression', ['FEATURE_NATIVE_TERNARY', 'Replace the native ternary with ctx.branch().']],
|
|
8
|
+
['SwitchStatement', ['FEATURE_NATIVE_SWITCH', 'Replace the native switch with explicit ctx.branch() paths.']],
|
|
9
|
+
['ForStatement', ['FEATURE_NATIVE_LOOP', 'Move runtime-sized iteration into ctx.functions.']],
|
|
10
|
+
['ForInStatement', ['FEATURE_NATIVE_LOOP', 'Move runtime-sized iteration into ctx.functions.']],
|
|
11
|
+
['ForOfStatement', ['FEATURE_NATIVE_LOOP', 'Move runtime-sized iteration into ctx.functions.']],
|
|
12
|
+
['WhileStatement', ['FEATURE_NATIVE_LOOP', 'Move runtime-sized iteration into ctx.functions.']],
|
|
13
|
+
['DoWhileStatement', ['FEATURE_NATIVE_LOOP', 'Move runtime-sized iteration into ctx.functions.']],
|
|
14
|
+
]);
|
|
15
|
+
const COLLECTION_METHODS = new Set(['map', 'filter', 'find', 'findIndex', 'some', 'every', 'reduce', 'reduceRight', 'forEach']);
|
|
16
|
+
function propertyName(node) {
|
|
17
|
+
if (!node)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!node.computed && node.key?.type === 'Identifier')
|
|
20
|
+
return node.key.name;
|
|
21
|
+
if (node.key?.type === 'StringLiteral')
|
|
22
|
+
return node.key.value;
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function memberName(node) {
|
|
26
|
+
if (!node || (node.type !== 'MemberExpression' && node.type !== 'OptionalMemberExpression'))
|
|
27
|
+
return undefined;
|
|
28
|
+
if (!node.computed && node.property?.type === 'Identifier')
|
|
29
|
+
return node.property.name;
|
|
30
|
+
if (node.computed && node.property?.type === 'StringLiteral')
|
|
31
|
+
return node.property.value;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function memberPath(node) {
|
|
35
|
+
if (node?.type === 'Identifier')
|
|
36
|
+
return [node.name];
|
|
37
|
+
if (node?.type !== 'MemberExpression' && node?.type !== 'OptionalMemberExpression')
|
|
38
|
+
return [];
|
|
39
|
+
const property = memberName(node);
|
|
40
|
+
return [...memberPath(node.object), ...(property ? [property] : [])];
|
|
41
|
+
}
|
|
42
|
+
function isFeatureDefineCall(node) {
|
|
43
|
+
if (node?.type !== 'CallExpression' && node?.type !== 'OptionalCallExpression')
|
|
44
|
+
return false;
|
|
45
|
+
const parts = memberPath(node.callee);
|
|
46
|
+
if (parts.at(-1) !== 'define')
|
|
47
|
+
return false;
|
|
48
|
+
return parts.some((part) => ['feature', 'features', 'workflow', 'workflows'].includes(part));
|
|
49
|
+
}
|
|
50
|
+
function literalString(node) {
|
|
51
|
+
return node?.type === 'StringLiteral' ? node.value : undefined;
|
|
52
|
+
}
|
|
53
|
+
function isMemberPath(node, objectName, propertyName) {
|
|
54
|
+
return (node?.type === 'MemberExpression' || node?.type === 'OptionalMemberExpression') &&
|
|
55
|
+
node.object?.type === 'Identifier' && node.object.name === objectName && memberName(node) === propertyName;
|
|
56
|
+
}
|
|
57
|
+
function sourceFiles(dir) {
|
|
58
|
+
if (!fs.existsSync(dir))
|
|
59
|
+
return [];
|
|
60
|
+
const output = [];
|
|
61
|
+
const visit = (current) => {
|
|
62
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
63
|
+
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build')
|
|
64
|
+
continue;
|
|
65
|
+
const full = path.join(current, entry.name);
|
|
66
|
+
if (entry.isDirectory())
|
|
67
|
+
visit(full);
|
|
68
|
+
else if (SOURCE_EXTENSIONS.has(path.extname(entry.name)))
|
|
69
|
+
output.push(full);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
visit(dir);
|
|
73
|
+
return output.sort();
|
|
74
|
+
}
|
|
75
|
+
export function validateFeatureProject(projectRoot) {
|
|
76
|
+
const root = path.resolve(projectRoot);
|
|
77
|
+
const featuresDir = path.join(root, 'ductape', 'features');
|
|
78
|
+
const files = sourceFiles(featuresDir);
|
|
79
|
+
const diagnostics = [];
|
|
80
|
+
let featuresFound = 0;
|
|
81
|
+
const add = (file, node, feature, code, message) => {
|
|
82
|
+
diagnostics.push({
|
|
83
|
+
file: path.relative(root, file),
|
|
84
|
+
line: node.loc?.start.line ?? 1,
|
|
85
|
+
column: (node.loc?.start.column ?? 0) + 1,
|
|
86
|
+
feature,
|
|
87
|
+
code,
|
|
88
|
+
message,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
let ast;
|
|
93
|
+
try {
|
|
94
|
+
ast = parse(fs.readFileSync(file, 'utf8'), {
|
|
95
|
+
sourceType: 'unambiguous',
|
|
96
|
+
plugins: ['typescript', 'jsx', ['decorators', { decoratorsBeforeExport: true }]],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
diagnostics.push({
|
|
101
|
+
file: path.relative(root, file), line: 1, column: 1,
|
|
102
|
+
code: 'FEATURE_SOURCE_PARSE_ERROR',
|
|
103
|
+
message: error instanceof Error ? error.message : String(error),
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const visit = (node, active) => {
|
|
108
|
+
if (!node || typeof node !== 'object')
|
|
109
|
+
return;
|
|
110
|
+
if (!active && isFeatureDefineCall(node) && node.arguments?.[0]?.type !== 'ObjectExpression') {
|
|
111
|
+
add(file, node, undefined, 'FEATURE_DYNAMIC_DEFINITION_UNINSPECTED', 'The validator cannot inspect a dynamically supplied Feature definition. Inline the definition object before syncing.');
|
|
112
|
+
}
|
|
113
|
+
if (node.type === 'ObjectExpression') {
|
|
114
|
+
const props = new Map();
|
|
115
|
+
for (const property of node.properties ?? []) {
|
|
116
|
+
if (property.type === 'ObjectProperty' || property.type === 'ObjectMethod') {
|
|
117
|
+
const name = propertyName(property);
|
|
118
|
+
if (name)
|
|
119
|
+
props.set(name, property);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const handlerProp = props.get('handler');
|
|
123
|
+
if (handlerProp && props.has('tag')) {
|
|
124
|
+
featuresFound += 1;
|
|
125
|
+
const tag = literalString(props.get('tag')?.value);
|
|
126
|
+
const mode = literalString(props.get('controlFlowMode')?.value) === 'legacy' ? 'legacy' : 'portable';
|
|
127
|
+
if (mode !== 'legacy') {
|
|
128
|
+
for (const legacyKey of ['branchOverrides', 'recordScenarios']) {
|
|
129
|
+
const legacy = props.get(legacyKey);
|
|
130
|
+
if (legacy)
|
|
131
|
+
add(file, legacy, tag, 'FEATURE_LEGACY_RECORDING_OPTION', `${legacyKey} requires controlFlowMode: 'legacy'.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const handler = handlerProp.type === 'ObjectMethod' ? handlerProp : handlerProp.value;
|
|
135
|
+
if (!['ObjectMethod', 'ArrowFunctionExpression', 'FunctionExpression'].includes(handler?.type)) {
|
|
136
|
+
add(file, handlerProp, tag, 'FEATURE_EXTERNAL_HANDLER_UNINSPECTED', 'The validator cannot inspect a referenced handler. Inline the handler in the Feature definition before syncing.');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
visit(handler, { tag, mode });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (active) {
|
|
144
|
+
if (active.mode === 'portable') {
|
|
145
|
+
const native = NATIVE_NODES.get(node.type);
|
|
146
|
+
if (native)
|
|
147
|
+
add(file, node, active.tag, native[0], native[1]);
|
|
148
|
+
if (node.type === 'LogicalExpression') {
|
|
149
|
+
add(file, node, active.tag, 'FEATURE_NATIVE_LOGICAL_BRANCH', `Native "${node.operator}" short-circuiting runs during recording; use ctx.branch(), ctx.when, or ctx.default().`);
|
|
150
|
+
}
|
|
151
|
+
if ((node.type === 'CallExpression' || node.type === 'OptionalCallExpression') &&
|
|
152
|
+
COLLECTION_METHODS.has(memberName(node.callee) ?? '')) {
|
|
153
|
+
add(file, node, active.tag, 'FEATURE_NATIVE_COLLECTION_CONTROL_FLOW', `Native .${memberName(node.callee)}() is not portable runtime collection logic; use ctx.functions.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') {
|
|
157
|
+
if (isMemberPath(node.callee, 'Date', 'now'))
|
|
158
|
+
add(file, node, active.tag, 'FEATURE_HOST_TIME', 'Use ctx.transform.now().');
|
|
159
|
+
if (isMemberPath(node.callee, 'Math', 'random'))
|
|
160
|
+
add(file, node, active.tag, 'FEATURE_HOST_RANDOM', 'Use a portable function or reusable action.');
|
|
161
|
+
if (memberName(node.callee) === 'randomUUID')
|
|
162
|
+
add(file, node, active.tag, 'FEATURE_HOST_UUID', 'Use ctx.transform.uuid().');
|
|
163
|
+
}
|
|
164
|
+
if ((node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') &&
|
|
165
|
+
isMemberPath(node.object, 'process', 'env')) {
|
|
166
|
+
add(file, node, active.tag, 'FEATURE_HOST_ENVIRONMENT', 'Use Ductape runtime configuration instead of process.env.');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const [key, value] of Object.entries(node)) {
|
|
170
|
+
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra')
|
|
171
|
+
continue;
|
|
172
|
+
if (Array.isArray(value))
|
|
173
|
+
value.forEach((child) => visit(child, active));
|
|
174
|
+
else if (value && typeof value === 'object')
|
|
175
|
+
visit(value, active);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
visit(ast);
|
|
179
|
+
}
|
|
180
|
+
diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
|
|
181
|
+
return { ok: diagnostics.length === 0, projectRoot: root, featuresDir, filesScanned: files.length, featuresFound, diagnostics };
|
|
182
|
+
}
|
|
183
|
+
export function formatFeatureValidation(result) {
|
|
184
|
+
if (result.ok) {
|
|
185
|
+
return `Feature control-flow validation passed (${result.featuresFound} features in ${result.filesScanned} files).`;
|
|
186
|
+
}
|
|
187
|
+
const lines = result.diagnostics.map((item) => `${item.file}:${item.line}:${item.column} ${item.code}${item.feature ? ` [${item.feature}]` : ''}: ${item.message}`);
|
|
188
|
+
return `Feature control-flow validation failed with ${result.diagnostics.length} error(s):\n${lines.join('\n')}`;
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ductape/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.36",
|
|
4
4
|
"description": "Ductape CLI — local platform, login, link projects, and manage resources via the proxy (Workbench-compatible)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"license": "UNLICENSED",
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"@babel/parser": "7.28.5",
|
|
29
30
|
"@inquirer/input": "^5.0.13",
|
|
30
31
|
"@inquirer/password": "^5.0.13",
|
|
31
32
|
"@inquirer/select": "^5.1.5",
|