@ductape/cli 0.3.35 → 0.4.0

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 this command delegates to a "features:sync"
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 it explicitly with \`ductape features sync\` never automatically on app boot.
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 this command delegates to a "features:sync"
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
+ }
@@ -0,0 +1,3 @@
1
+ export declare function runSessionsValidate(opts?: {
2
+ json?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import { findProjectConfig } from '../lib/config.js';
2
+ import { fail } from '../lib/output.js';
3
+ import { formatSessionConformance, validateSessionConformance } from '../lib/session-conformance.js';
4
+ export async function runSessionsValidate(opts = {}) {
5
+ const found = findProjectConfig();
6
+ if (!found)
7
+ fail('No linked project. Run `ductape link` from your project directory (or `ductape init`).');
8
+ const result = validateSessionConformance(found.dir);
9
+ console.log(opts.json ? JSON.stringify(result, null, 2) : formatSessionConformance(result));
10
+ if (!result.ok)
11
+ process.exitCode = 1;
12
+ }
package/dist/index.js CHANGED
@@ -18,7 +18,8 @@ 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
+ import { runSessionsValidate } from './commands/sessions-validate.js';
22
23
  import { runDbSchemaGenerate, runDbSchemaPush } from './commands/db-schema.js';
23
24
  import { runGraph } from './commands/graph.js';
24
25
  import { runSecretImportEnv, runSecrets } from './commands/secrets.js';
@@ -895,10 +896,22 @@ db
895
896
  return runDb(verb, { file: opts.file, json: opts.json });
896
897
  }));
897
898
  const features = program.command('features').description('Code-first Ductape Features (ductape/features/)');
899
+ const sessions = program.command('sessions').description('Session lifecycle and propagation checks');
900
+ sessions
901
+ .command('validate')
902
+ .description('Read-only project-wide audit of Ductape SDK session propagation')
903
+ .option('--json', 'JSON output')
904
+ .action(wrap((opts) => runSessionsValidate({ json: Boolean(opts.json) })));
905
+ features
906
+ .command('validate')
907
+ .description('Read-only AST validation of code-first Feature control flow; performs no remote mutation')
908
+ .option('--json', 'JSON output')
909
+ .action(wrap((opts) => runFeaturesValidate({ json: Boolean(opts.json) })));
898
910
  features
899
911
  .command('sync')
900
912
  .description('Persist Features from ductape/features/ to the live product by running the project\'s own ' +
901
- '"features:sync" npm script (see `ductape features sync --help` for the required convention). ' +
913
+ '"features:sync" npm script after a fail-closed AST validation preflight ' +
914
+ '(see `ductape features sync --help` for the required convention). ' +
902
915
  'Run explicitly, like `ductape db migrate` — never automatically on app boot.')
903
916
  .argument('[filter]', 'Optional substring passed through to the project\'s features:sync script')
904
917
  .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
+ }
@@ -0,0 +1,16 @@
1
+ export type SessionClassification = 'explicit' | 'inherited' | 'delegated' | 'system' | 'missing';
2
+ export interface SessionFinding {
3
+ file: string;
4
+ line: number;
5
+ call: string;
6
+ classification: SessionClassification;
7
+ reason?: string;
8
+ }
9
+ export interface SessionValidationResult {
10
+ ok: boolean;
11
+ root: string;
12
+ summary: Record<SessionClassification | 'calls', number>;
13
+ findings: SessionFinding[];
14
+ }
15
+ export declare function validateSessionConformance(rootDir: string): SessionValidationResult;
16
+ export declare function formatSessionConformance(result: SessionValidationResult): string;
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
4
+ const IGNORED = new Set(['.git', 'dist', 'build', 'coverage', 'node_modules']);
5
+ const CALL = /\b(?:ductape\.)?(api|feature|database|databases|graph|vector|storage|events|notifications|quota|fallback)\.([A-Za-z_$][\w$]*)\s*\(/g;
6
+ function walk(root, files) {
7
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
8
+ if (entry.isSymbolicLink() || IGNORED.has(entry.name))
9
+ continue;
10
+ const target = path.join(root, entry.name);
11
+ if (entry.isDirectory())
12
+ walk(target, files);
13
+ else if (EXTENSIONS.has(path.extname(entry.name)))
14
+ files.push(target);
15
+ }
16
+ }
17
+ function invocation(source, open) {
18
+ let depth = 0;
19
+ let quote = '';
20
+ let escaped = false;
21
+ for (let i = open; i < source.length; i += 1) {
22
+ const char = source[i];
23
+ if (quote) {
24
+ if (escaped)
25
+ escaped = false;
26
+ else if (char === '\\')
27
+ escaped = true;
28
+ else if (char === quote)
29
+ quote = '';
30
+ continue;
31
+ }
32
+ if (char === '"' || char === "'" || char === '`') {
33
+ quote = char;
34
+ continue;
35
+ }
36
+ if (char === '(')
37
+ depth += 1;
38
+ if (char === ')' && --depth === 0)
39
+ return source.slice(open + 1, i);
40
+ }
41
+ return source.slice(open + 1);
42
+ }
43
+ export function validateSessionConformance(rootDir) {
44
+ const root = path.resolve(rootDir);
45
+ const files = [];
46
+ walk(root, files);
47
+ const findings = [];
48
+ for (const file of files) {
49
+ const source = fs.readFileSync(file, 'utf8');
50
+ let match;
51
+ while ((match = CALL.exec(source))) {
52
+ const call = `${match[1]}.${match[2]}`;
53
+ const line = source.slice(0, match.index).split('\n').length;
54
+ const args = invocation(source, source.indexOf('(', match.index));
55
+ const nearby = source.slice(Math.max(0, match.index - 300), match.index);
56
+ let classification = 'missing';
57
+ let reason;
58
+ if (/\bsession\s*:/.test(args))
59
+ classification = 'explicit';
60
+ else if (/\bctx\.$/.test(nearby))
61
+ classification = 'inherited';
62
+ else {
63
+ const marker = nearby.match(/ductape-session:\s*(delegated|system)\s+([^\n]+)/i);
64
+ if (marker) {
65
+ classification = marker[1].toLowerCase();
66
+ reason = marker[2].trim();
67
+ }
68
+ }
69
+ findings.push({ file: path.relative(root, file).split(path.sep).join('/'), line, call, classification, reason });
70
+ }
71
+ }
72
+ const summary = { calls: findings.length, explicit: 0, inherited: 0, delegated: 0, system: 0, missing: 0 };
73
+ for (const finding of findings)
74
+ summary[finding.classification] += 1;
75
+ return { ok: summary.missing === 0, root, summary, findings };
76
+ }
77
+ export function formatSessionConformance(result) {
78
+ const lines = [`Session propagation: ${result.ok ? 'PASS' : 'FAIL'} (${result.summary.calls} calls)`];
79
+ for (const finding of result.findings.filter((item) => item.classification === 'missing'))
80
+ lines.push(` ${finding.file}:${finding.line} ${finding.call} has no explicit, inherited, delegated, or system session classification`);
81
+ return lines.join('\n');
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.3.35",
3
+ "version": "0.4.0",
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",