@lightdash/cli 1.84.1 → 1.85.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.
@@ -0,0 +1,49 @@
1
+ import { type DataAppDataReferences, type DataAppExploreIndex, type DataReferenceLocation } from '@lightdash/common';
2
+ export type AppsValidateFormat = 'human' | 'json';
3
+ export type AppsValidateOptions = {
4
+ format: AppsValidateFormat;
5
+ live: boolean;
6
+ verbose: boolean;
7
+ };
8
+ export type AppsValidationIssueCode = 'bundle' | 'dependencies' | 'external_connection' | 'manifest' | 'semantic_layer' | 'semantic_reference' | 'source_parse';
9
+ export type AppsValidationIssue = {
10
+ code: AppsValidationIssueCode;
11
+ message: string;
12
+ location: DataReferenceLocation | null;
13
+ };
14
+ export type AppsValidationCoverage = DataAppDataReferences['stats'] & {
15
+ unanalyzed: number;
16
+ };
17
+ export type AppValidationResult = {
18
+ path: string;
19
+ name: string | null;
20
+ projectUuid: string | null;
21
+ valid: boolean;
22
+ errors: AppsValidationIssue[];
23
+ warnings: AppsValidationIssue[];
24
+ coverage: AppsValidationCoverage;
25
+ };
26
+ export type AppsValidationReport = {
27
+ valid: boolean;
28
+ mode: 'live' | 'offline';
29
+ summary: {
30
+ apps: number;
31
+ errors: number;
32
+ warnings: number;
33
+ callSites: number;
34
+ unanalyzedCallSites: number;
35
+ };
36
+ apps: AppValidationResult[];
37
+ };
38
+ type ValidateAppOptions = {
39
+ live: boolean;
40
+ liveProjectUuid?: string;
41
+ loadLiveIndex: (projectUuid: string) => Promise<DataAppExploreIndex>;
42
+ };
43
+ export declare const fetchLiveDataAppExploreIndex: (projectUuid: string) => Promise<DataAppExploreIndex>;
44
+ export declare const validateLocalDataApp: (appPath: string, options: ValidateAppOptions) => Promise<AppValidationResult>;
45
+ export declare const buildAppsValidationReport: (apps: AppValidationResult[], live: boolean) => AppsValidationReport;
46
+ export declare const renderAppsValidationHuman: (report: AppsValidationReport) => string;
47
+ export declare const appsValidateHandler: (pathArgs: string[] | undefined, options: AppsValidateOptions) => Promise<void>;
48
+ export {};
49
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/validate.ts"],"names":[],"mappings":"AAAA,OAAO,EAcH,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EAGxB,KAAK,qBAAqB,EAC7B,MAAM,mBAAmB,CAAC;AAgB3B,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,MAAM,CAAC;AAElD,MAAM,MAAM,mBAAmB,GAAG;IAC9B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAC7B,QAAQ,GACR,cAAc,GACd,qBAAqB,GACrB,UAAU,GACV,gBAAgB,GAChB,oBAAoB,GACpB,cAAc,CAAC;AAErB,MAAM,MAAM,mBAAmB,GAAG;IAC9B,IAAI,EAAE,uBAAuB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,qBAAqB,GAAG,IAAI,CAAC;CAC1C,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG,qBAAqB,CAAC,OAAO,CAAC,GAAG;IAClE,UAAU,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,mBAAmB,EAAE,CAAC;IAC9B,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,QAAQ,EAAE,sBAAsB,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,OAAO,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,mBAAmB,EAAE,MAAM,CAAC;KAC/B,CAAC;IACF,IAAI,EAAE,mBAAmB,EAAE,CAAC;CAC/B,CAAC;AAEF,KAAK,kBAAkB,GAAG;IACtB,IAAI,EAAE,OAAO,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACxE,CAAC;AA2LF,eAAO,MAAM,4BAA4B,gBACxB,MAAM,KACpB,OAAO,CAAC,mBAAmB,CAqB7B,CAAC;AAEF,eAAO,MAAM,oBAAoB,YACpB,MAAM,WACN,kBAAkB,KAC5B,OAAO,CAAC,mBAAmB,CA4H7B,CAAC;AAEF,eAAO,MAAM,yBAAyB,SAC5B,mBAAmB,EAAE,QACrB,OAAO,KACd,oBAwBF,CAAC;AAmBF,eAAO,MAAM,yBAAyB,WAC1B,oBAAoB,KAC7B,MA4CF,CAAC;AAEF,eAAO,MAAM,mBAAmB,aAClB,MAAM,EAAE,GAAG,SAAS,WACrB,mBAAmB,KAC7B,OAAO,CAAC,IAAI,CA2Cd,CAAC"}
@@ -0,0 +1,332 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.appsValidateHandler = exports.renderAppsValidationHuman = exports.buildAppsValidationReport = exports.validateLocalDataApp = exports.fetchLiveDataAppExploreIndex = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@lightdash/common");
6
+ const fs_1 = require("fs");
7
+ const p_limit_1 = tslib_1.__importDefault(require("p-limit"));
8
+ const path = tslib_1.__importStar(require("path"));
9
+ const config_1 = require("../../config");
10
+ const env_1 = require("../../env");
11
+ const globalState_1 = tslib_1.__importDefault(require("../../globalState"));
12
+ const styles = tslib_1.__importStar(require("../../styles"));
13
+ const apiClient_1 = require("../dbt/apiClient");
14
+ const appCodeFiles_1 = require("./appCodeFiles");
15
+ const scaffolding_1 = require("./scaffolding");
16
+ const emptyCoverage = () => ({
17
+ callSites: 0,
18
+ fullyResolved: 0,
19
+ partiallyResolved: 0,
20
+ unresolved: 0,
21
+ unanalyzed: 0,
22
+ });
23
+ const toCoverage = (stats) => ({
24
+ ...stats,
25
+ unanalyzed: stats.partiallyResolved + stats.unresolved,
26
+ });
27
+ const issue = (code, message, location = null) => ({ code, message, location });
28
+ const readSemanticLayerFiles = async (appDir) => {
29
+ const contextDir = path.join(appDir, '.lightdash', 'context');
30
+ const candidates = [path.join(contextDir, 'semantic-layer.yml')];
31
+ const modelsDir = path.join(contextDir, 'models');
32
+ const modelEntries = await fs_1.promises
33
+ .readdir(modelsDir, { withFileTypes: true })
34
+ .catch((error) => {
35
+ if (error.code === 'ENOENT')
36
+ return [];
37
+ throw error;
38
+ });
39
+ for (const entry of modelEntries) {
40
+ if (entry.isFile())
41
+ candidates.push(path.join(modelsDir, entry.name));
42
+ }
43
+ const files = await Promise.all(candidates.map(async (filePath) => {
44
+ try {
45
+ const content = await fs_1.promises.readFile(filePath, 'utf-8');
46
+ return {
47
+ path: path
48
+ .relative(appDir, filePath)
49
+ .split(path.sep)
50
+ .join('/'),
51
+ content,
52
+ };
53
+ }
54
+ catch (error) {
55
+ if (error.code === 'ENOENT') {
56
+ return null;
57
+ }
58
+ throw error;
59
+ }
60
+ }));
61
+ return files.filter((file) => file !== null);
62
+ };
63
+ const validateManifest = (manifest) => {
64
+ const errors = [];
65
+ if (manifest.slug !== undefined && !(0, common_1.isValidDataAppSlug)(manifest.slug)) {
66
+ errors.push(issue('manifest', `Invalid slug "${manifest.slug}" in lightdash-app.yml. Slugs must start with a lowercase letter or digit and contain only lowercase letters, digits, and hyphens, up to 255 characters.`));
67
+ }
68
+ if (manifest.vizSchema !== undefined) {
69
+ const parsed = common_1.dataAppVizSchema.safeParse(manifest.vizSchema);
70
+ if (!parsed.success) {
71
+ const details = parsed.error.issues
72
+ .map((entry) => `${entry.path.join('.')}: ${entry.message}`)
73
+ .join('; ');
74
+ errors.push(issue('manifest', `Invalid vizSchema in lightdash-app.yml (${details}).`));
75
+ }
76
+ }
77
+ const aliases = new Set();
78
+ const links = manifest.externalConnections;
79
+ if (links === undefined)
80
+ return { errors, aliases };
81
+ if (!Array.isArray(links)) {
82
+ errors.push(issue('manifest', 'Invalid externalConnections in lightdash-app.yml: expected a list.'));
83
+ return { errors, aliases };
84
+ }
85
+ for (const link of links) {
86
+ if (!link ||
87
+ typeof link !== 'object' ||
88
+ typeof link.alias !== 'string' ||
89
+ typeof link.connectionSlug !== 'string') {
90
+ errors.push(issue('manifest', 'Invalid externalConnections entry in lightdash-app.yml: expected alias and connectionSlug strings.'));
91
+ }
92
+ else {
93
+ const duplicate = aliases.has(link.alias);
94
+ aliases.add(link.alias);
95
+ if (!/^[a-z0-9_-]+$/i.test(link.alias) || link.alias.length > 64) {
96
+ errors.push(issue('manifest', `Invalid external connection alias "${link.alias}" in lightdash-app.yml: aliases must contain only letters, numbers, hyphens, and underscores (max 64 characters).`));
97
+ }
98
+ else if (duplicate) {
99
+ errors.push(issue('manifest', `Duplicate external connection alias "${link.alias}" in lightdash-app.yml.`));
100
+ }
101
+ }
102
+ }
103
+ return { errors, aliases };
104
+ };
105
+ const validateDependencies = async (appDir) => {
106
+ try {
107
+ const deps = await (0, appCodeFiles_1.readDependenciesFromDir)(appDir);
108
+ if (deps === null)
109
+ return [];
110
+ const templateDependencies = (0, appCodeFiles_1.applySdkMirrorToTemplateDeps)((0, scaffolding_1.loadTemplateDependencies)(env_1.CLI_VERSION), deps.packageJson);
111
+ if (deps.lockfile === null) {
112
+ const customDeps = (0, common_1.computeCustomDependencies)(deps.packageJson, templateDependencies);
113
+ if (Object.keys(customDeps).length > 0) {
114
+ const hint = deps.hasNpmLockfile
115
+ ? 'package-lock.json is not used by the server; run `pnpm install` to generate pnpm-lock.yaml.'
116
+ : 'Run `pnpm install` to generate pnpm-lock.yaml.';
117
+ return [
118
+ issue('dependencies', `Custom dependencies require pnpm-lock.yaml. ${hint}`),
119
+ ];
120
+ }
121
+ return [];
122
+ }
123
+ (0, common_1.validateDataAppDependencies)({ packageJson: deps.packageJson, lockfile: deps.lockfile }, { templateDependencies });
124
+ return [];
125
+ }
126
+ catch (error) {
127
+ return [issue('dependencies', (0, common_1.getErrorMessage)(error))];
128
+ }
129
+ };
130
+ const sourceFilesFromBundle = (files) => files.map((file) => ({
131
+ path: file.path,
132
+ content: Buffer.from(file.contentBase64, 'base64').toString('utf-8'),
133
+ }));
134
+ const fetchLiveDataAppExploreIndex = async (projectUuid) => {
135
+ const summaries = await (0, apiClient_1.lightdashApi)({
136
+ method: 'GET',
137
+ url: `/api/v1/projects/${projectUuid}/explores`,
138
+ body: undefined,
139
+ });
140
+ const limit = (0, p_limit_1.default)(8);
141
+ const explores = await Promise.all(summaries
142
+ .filter((summary) => !(0, common_1.isSummaryExploreError)(summary))
143
+ .map((summary) => limit(() => (0, apiClient_1.lightdashApi)({
144
+ method: 'GET',
145
+ url: `/api/v1/projects/${projectUuid}/explores/${encodeURIComponent(summary.name)}`,
146
+ body: undefined,
147
+ }))));
148
+ return (0, common_1.buildDataAppExploreIndexFromExplores)(explores);
149
+ };
150
+ exports.fetchLiveDataAppExploreIndex = fetchLiveDataAppExploreIndex;
151
+ const validateLocalDataApp = async (appPath, options) => {
152
+ const appDir = path.resolve(appPath);
153
+ const errors = [];
154
+ const warnings = [];
155
+ let bundle;
156
+ try {
157
+ bundle = await (0, appCodeFiles_1.readBundleFromDir)(appDir);
158
+ }
159
+ catch (error) {
160
+ return {
161
+ path: appDir,
162
+ name: null,
163
+ projectUuid: null,
164
+ valid: false,
165
+ errors: [issue('bundle', (0, common_1.getErrorMessage)(error))],
166
+ warnings,
167
+ coverage: emptyCoverage(),
168
+ };
169
+ }
170
+ const { manifest } = bundle;
171
+ const manifestResult = validateManifest(manifest);
172
+ errors.push(...manifestResult.errors);
173
+ errors.push(...(await validateDependencies(appDir)));
174
+ const sourceFiles = sourceFilesFromBundle(bundle.files);
175
+ if (sourceFiles.length === 0) {
176
+ errors.push(issue('bundle', 'App bundle has no files under src/ to validate.'));
177
+ }
178
+ const extracted = (0, common_1.extractDataAppDataReferences)(sourceFiles);
179
+ const coverage = toCoverage(extracted.stats);
180
+ warnings.push(...extracted.parseErrors.map((parseError) => issue('source_parse', `Could not statically analyze ${parseError.path}: ${parseError.message}`)));
181
+ for (const ref of extracted.references) {
182
+ if (ref.kind === 'externalFetch' &&
183
+ ref.alias !== null &&
184
+ !manifestResult.aliases.has(ref.alias)) {
185
+ errors.push(issue('external_connection', `externalFetch alias "${ref.alias}" is not declared in lightdash-app.yml externalConnections.`, ref.location));
186
+ }
187
+ }
188
+ const hasSemanticReferences = extracted.references.some((ref) => ref.kind !== 'externalFetch');
189
+ if (hasSemanticReferences) {
190
+ try {
191
+ let exploreIndex;
192
+ if (options.live) {
193
+ if (!options.liveProjectUuid) {
194
+ throw new Error("No project selected. Run 'lightdash config set-project' before using --live validation.");
195
+ }
196
+ exploreIndex = await options.loadLiveIndex(options.liveProjectUuid);
197
+ }
198
+ else {
199
+ const semanticFiles = await readSemanticLayerFiles(appDir);
200
+ if (semanticFiles.length === 0) {
201
+ throw new Error('No local semantic layer snapshot found.');
202
+ }
203
+ exploreIndex =
204
+ (0, common_1.buildDataAppExploreIndexFromModelFiles)(semanticFiles);
205
+ }
206
+ errors.push(...(0, common_1.checkDataAppDataReferences)(extracted.references, exploreIndex).map((referenceError) => issue('semantic_reference', referenceError.error, referenceError.location)));
207
+ }
208
+ catch (error) {
209
+ const message = (0, common_1.getErrorMessage)(error);
210
+ let semanticLayerMessage;
211
+ if (options.live) {
212
+ semanticLayerMessage = `Could not load live semantic layer: ${message}`;
213
+ }
214
+ else if (message.toLowerCase().includes('re-download')) {
215
+ semanticLayerMessage = message;
216
+ }
217
+ else {
218
+ semanticLayerMessage = `${message} Re-download the app to refresh its local semantic layer snapshot.`;
219
+ }
220
+ errors.push(issue('semantic_layer', semanticLayerMessage));
221
+ }
222
+ }
223
+ let projectUuid = null;
224
+ if (options.live) {
225
+ projectUuid = options.liveProjectUuid ?? null;
226
+ }
227
+ else if (typeof manifest.projectUuid === 'string') {
228
+ projectUuid = manifest.projectUuid;
229
+ }
230
+ return {
231
+ path: appDir,
232
+ name: typeof manifest.name === 'string' ? manifest.name : null,
233
+ projectUuid,
234
+ valid: errors.length === 0,
235
+ errors,
236
+ warnings,
237
+ coverage,
238
+ };
239
+ };
240
+ exports.validateLocalDataApp = validateLocalDataApp;
241
+ const buildAppsValidationReport = (apps, live) => {
242
+ const errors = apps.reduce((count, app) => count + app.errors.length, 0);
243
+ const warnings = apps.reduce((count, app) => count + app.warnings.length, 0);
244
+ return {
245
+ valid: errors === 0,
246
+ mode: live ? 'live' : 'offline',
247
+ summary: {
248
+ apps: apps.length,
249
+ errors,
250
+ warnings,
251
+ callSites: apps.reduce((count, app) => count + app.coverage.callSites, 0),
252
+ unanalyzedCallSites: apps.reduce((count, app) => count + app.coverage.unanalyzed, 0),
253
+ },
254
+ apps,
255
+ };
256
+ };
257
+ exports.buildAppsValidationReport = buildAppsValidationReport;
258
+ const formatCoverage = (coverage) => {
259
+ if (coverage.callSites === 0) {
260
+ return 'Coverage: no data-reference call sites found.';
261
+ }
262
+ if (coverage.unanalyzed === 0) {
263
+ return `Coverage: all ${coverage.callSites} data-reference call site(s) were statically analyzed.`;
264
+ }
265
+ return `Coverage: ${coverage.unanalyzed} of ${coverage.callSites} data-reference call site(s) couldn't be fully analyzed; unresolved values were skipped and are not errors.`;
266
+ };
267
+ const formatIssue = (entry) => {
268
+ const prefix = entry.location
269
+ ? `${entry.location.path}:${entry.location.line}:${entry.location.column} — `
270
+ : '';
271
+ return `${prefix}${entry.message}`;
272
+ };
273
+ const renderAppsValidationHuman = (report) => {
274
+ const lines = [
275
+ `Validating ${report.summary.apps} data app(s) using ${report.mode === 'live'
276
+ ? 'the live project semantic layer'
277
+ : 'local semantic layer snapshots'}.`,
278
+ ];
279
+ if (report.mode === 'offline') {
280
+ lines.push(styles.secondary('Offline snapshots may be stale. Use --live for upload-time semantic validation parity.'));
281
+ }
282
+ for (const app of report.apps) {
283
+ lines.push('');
284
+ const label = app.name ? `${app.name} (${app.path})` : app.path;
285
+ lines.push(`${app.valid ? styles.success('✓') : styles.error('✗')} ${label}`);
286
+ lines.push(` ${formatCoverage(app.coverage)}`);
287
+ for (const warning of app.warnings) {
288
+ lines.push(` ${styles.warning('warning')} ${formatIssue(warning)}`);
289
+ }
290
+ for (const error of app.errors) {
291
+ lines.push(` ${styles.error('error')} ${formatIssue(error)}`);
292
+ }
293
+ }
294
+ lines.push('');
295
+ lines.push(report.valid
296
+ ? styles.success(`Validation passed for ${report.summary.apps} data app(s) with ${report.summary.warnings} warning(s).`)
297
+ : styles.error(`Validation failed with ${report.summary.errors} error(s) across ${report.summary.apps} data app(s).`));
298
+ return `${lines.join('\n')}\n`;
299
+ };
300
+ exports.renderAppsValidationHuman = renderAppsValidationHuman;
301
+ const appsValidateHandler = async (pathArgs, options) => {
302
+ globalState_1.default.setVerbose(options.verbose);
303
+ const paths = pathArgs && pathArgs.length > 0 ? pathArgs : ['.'];
304
+ const resolvedPaths = [
305
+ ...new Set(paths.map((appPath) => path.resolve(process.cwd(), appPath))),
306
+ ];
307
+ const liveProjectUuid = options.live
308
+ ? (await (0, config_1.getConfig)()).context?.project
309
+ : undefined;
310
+ const liveIndexes = new Map();
311
+ const loadLiveIndex = (projectUuid) => {
312
+ const cached = liveIndexes.get(projectUuid);
313
+ if (cached)
314
+ return cached;
315
+ const pending = (0, exports.fetchLiveDataAppExploreIndex)(projectUuid);
316
+ liveIndexes.set(projectUuid, pending);
317
+ return pending;
318
+ };
319
+ const apps = await Promise.all(resolvedPaths.map((appPath) => (0, exports.validateLocalDataApp)(appPath, {
320
+ live: options.live,
321
+ liveProjectUuid,
322
+ loadLiveIndex,
323
+ })));
324
+ const report = (0, exports.buildAppsValidationReport)(apps, options.live);
325
+ process.stdout.write(options.format === 'json'
326
+ ? `${JSON.stringify(report, null, 2)}\n`
327
+ : (0, exports.renderAppsValidationHuman)(report));
328
+ if (!report.valid) {
329
+ throw new common_1.ParameterError(`Data app validation failed with ${report.summary.errors} error(s).`);
330
+ }
331
+ };
332
+ exports.appsValidateHandler = appsValidateHandler;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=validate.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.test.d.ts","sourceRoot":"","sources":["../../../src/handlers/apps/validate.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,270 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const common_1 = require("@lightdash/common");
5
+ const fs_1 = require("fs");
6
+ const os = tslib_1.__importStar(require("os"));
7
+ const path = tslib_1.__importStar(require("path"));
8
+ const appCodeFiles_1 = require("./appCodeFiles");
9
+ const validate_1 = require("./validate");
10
+ const semanticLayer = [
11
+ 'models:',
12
+ ' - name: orders',
13
+ ' meta:',
14
+ ' metrics:',
15
+ ' total_revenue:',
16
+ ' type: sum',
17
+ ' columns:',
18
+ ' - name: status',
19
+ ].join('\n');
20
+ const defaultManifest = {
21
+ codeVersion: 1,
22
+ projectUuid: 'project-uuid',
23
+ slug: 'orders-app',
24
+ version: 1,
25
+ name: 'Orders app',
26
+ description: '',
27
+ template: null,
28
+ downloadedAt: '2026-08-01T00:00:00.000Z',
29
+ externalConnections: [{ alias: 'stripe', connectionSlug: 'stripe-api' }],
30
+ };
31
+ const validSource = `
32
+ import { query } from '@lightdash/query-sdk';
33
+ query('orders').dimensions(['status']).metrics(['total_revenue']);
34
+ lightdash.externalFetch('stripe', { path: '/charges' });
35
+ `;
36
+ const makeApp = async (args) => {
37
+ const dir = await fs_1.promises.mkdtemp(path.join(os.tmpdir(), 'ld-app-validate-'));
38
+ const code = {
39
+ manifest: args?.manifest ?? defaultManifest,
40
+ files: [
41
+ {
42
+ path: 'src/App.tsx',
43
+ contentBase64: Buffer.from(args?.source ?? validSource).toString('base64'),
44
+ },
45
+ ],
46
+ };
47
+ await (0, appCodeFiles_1.writeBundleToDir)(dir, code);
48
+ const layout = args?.semanticLayout ?? 'sharded';
49
+ if (layout === 'legacy') {
50
+ const contextDir = path.join(dir, '.lightdash', 'context');
51
+ await fs_1.promises.mkdir(contextDir, { recursive: true });
52
+ await fs_1.promises.writeFile(path.join(contextDir, 'semantic-layer.yml'), semanticLayer);
53
+ }
54
+ else if (layout === 'sharded') {
55
+ const modelsDir = path.join(dir, '.lightdash', 'context', 'models');
56
+ await fs_1.promises.mkdir(modelsDir, { recursive: true });
57
+ await Promise.all([
58
+ fs_1.promises.writeFile(path.join(dir, '.lightdash', 'context', 'semantic-layer.yml'), '# See models/'),
59
+ fs_1.promises.writeFile(path.join(modelsDir, 'orders.yml'), semanticLayer),
60
+ ]);
61
+ }
62
+ if (args?.packageJson !== undefined) {
63
+ await fs_1.promises.writeFile(path.join(dir, 'package.json'), args.packageJson);
64
+ }
65
+ if (args?.lockfile !== undefined) {
66
+ await fs_1.promises.writeFile(path.join(dir, 'pnpm-lock.yaml'), args.lockfile);
67
+ }
68
+ return dir;
69
+ };
70
+ const unusedLiveLoader = () => {
71
+ throw new Error('live semantic layer should not be loaded');
72
+ };
73
+ const liveIndex = (0, common_1.buildDataAppExploreIndexFromModelFiles)([
74
+ { path: 'orders.yml', content: semanticLayer },
75
+ ]);
76
+ describe('validateLocalDataApp', () => {
77
+ it.each(['sharded', 'legacy'])('validates a correct app against a %s offline semantic layer', async (semanticLayout) => {
78
+ const dir = await makeApp({ semanticLayout });
79
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
80
+ live: false,
81
+ loadLiveIndex: unusedLiveLoader,
82
+ });
83
+ expect(result.valid).toBe(true);
84
+ expect(result.errors).toEqual([]);
85
+ expect(result.coverage).toMatchObject({
86
+ callSites: 2,
87
+ fullyResolved: 2,
88
+ unanalyzed: 0,
89
+ });
90
+ });
91
+ it('reports semantic errors and undeclared externalFetch aliases with source locations', async () => {
92
+ const dir = await makeApp({
93
+ source: `
94
+ import { query } from '@lightdash/query-sdk';
95
+ query('ordrs').dimensions(['missing_field']);
96
+ client.externalFetch('salesforce', { path: '/accounts' });
97
+ `,
98
+ });
99
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
100
+ live: false,
101
+ loadLiveIndex: unusedLiveLoader,
102
+ });
103
+ expect(result.valid).toBe(false);
104
+ expect(result.errors).toEqual(expect.arrayContaining([
105
+ expect.objectContaining({
106
+ code: 'semantic_reference',
107
+ message: "Explore 'ordrs' does not exist — did you mean 'orders'?",
108
+ location: expect.objectContaining({ path: 'src/App.tsx' }),
109
+ }),
110
+ expect.objectContaining({
111
+ code: 'external_connection',
112
+ message: expect.stringContaining('salesforce'),
113
+ location: expect.objectContaining({ path: 'src/App.tsx' }),
114
+ }),
115
+ ]));
116
+ });
117
+ it('counts dynamic query call sites as unanalyzed without failing', async () => {
118
+ const dir = await makeApp({
119
+ source: `
120
+ import { query } from '@lightdash/query-sdk';
121
+ function build(explore) {
122
+ return query(explore).dimensions(['status']);
123
+ }
124
+ `,
125
+ });
126
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
127
+ live: false,
128
+ loadLiveIndex: unusedLiveLoader,
129
+ });
130
+ expect(result.valid).toBe(true);
131
+ expect(result.coverage).toMatchObject({
132
+ callSites: 1,
133
+ unresolved: 1,
134
+ unanalyzed: 1,
135
+ });
136
+ });
137
+ it('does not treat a dynamic externalFetch alias as an error', async () => {
138
+ const dir = await makeApp({
139
+ source: `
140
+ function load(client, alias) {
141
+ return client.externalFetch(alias, { path: '/resource' });
142
+ }
143
+ `,
144
+ semanticLayout: 'none',
145
+ });
146
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
147
+ live: false,
148
+ loadLiveIndex: unusedLiveLoader,
149
+ });
150
+ expect(result.valid).toBe(true);
151
+ expect(result.errors).toEqual([]);
152
+ expect(result.coverage).toMatchObject({
153
+ callSites: 1,
154
+ unresolved: 1,
155
+ unanalyzed: 1,
156
+ });
157
+ });
158
+ it('uses the live semantic layer and does not require a local snapshot', async () => {
159
+ const dir = await makeApp({ semanticLayout: 'none' });
160
+ const loadLiveIndex = vi.fn().mockResolvedValue(liveIndex);
161
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
162
+ live: true,
163
+ liveProjectUuid: 'configured-project-uuid',
164
+ loadLiveIndex,
165
+ });
166
+ expect(result.valid).toBe(true);
167
+ expect(result.projectUuid).toBe('configured-project-uuid');
168
+ expect(loadLiveIndex).toHaveBeenCalledWith('configured-project-uuid');
169
+ });
170
+ it('explains how to configure a project for live validation', async () => {
171
+ const dir = await makeApp({ semanticLayout: 'none' });
172
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
173
+ live: true,
174
+ loadLiveIndex: unusedLiveLoader,
175
+ });
176
+ expect(result.valid).toBe(false);
177
+ expect(result.projectUuid).toBeNull();
178
+ expect(result.errors).toContainEqual(expect.objectContaining({
179
+ code: 'semantic_layer',
180
+ message: expect.stringContaining('lightdash config set-project'),
181
+ }));
182
+ });
183
+ it('gives an upgrade path instead of crashing on a missing offline snapshot', async () => {
184
+ const dir = await makeApp({ semanticLayout: 'none' });
185
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
186
+ live: false,
187
+ loadLiveIndex: unusedLiveLoader,
188
+ });
189
+ expect(result.valid).toBe(false);
190
+ expect(result.errors).toContainEqual(expect.objectContaining({
191
+ code: 'semantic_layer',
192
+ message: expect.stringContaining('Re-download the app'),
193
+ }));
194
+ });
195
+ it('validates slug and vizSchema from the manifest', async () => {
196
+ const manifest = {
197
+ ...defaultManifest,
198
+ slug: '../invalid',
199
+ vizSchema: { fields: 'not-an-array' },
200
+ };
201
+ const dir = await makeApp({ manifest });
202
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
203
+ live: false,
204
+ loadLiveIndex: unusedLiveLoader,
205
+ });
206
+ expect(result.errors).toEqual(expect.arrayContaining([
207
+ expect.objectContaining({
208
+ code: 'manifest',
209
+ message: expect.stringContaining('Invalid slug'),
210
+ }),
211
+ expect.objectContaining({
212
+ code: 'manifest',
213
+ message: expect.stringContaining('Invalid vizSchema'),
214
+ }),
215
+ ]));
216
+ });
217
+ it('requires a pnpm lockfile for custom dependencies', async () => {
218
+ const dir = await makeApp({
219
+ packageJson: JSON.stringify({
220
+ dependencies: { 'left-pad': '1.3.0' },
221
+ }),
222
+ });
223
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
224
+ live: false,
225
+ loadLiveIndex: unusedLiveLoader,
226
+ });
227
+ expect(result.errors).toContainEqual(expect.objectContaining({
228
+ code: 'dependencies',
229
+ message: expect.stringContaining('pnpm-lock.yaml'),
230
+ }));
231
+ });
232
+ it('surfaces parser failures as warnings rather than errors', async () => {
233
+ const dir = await makeApp({
234
+ source: 'const broken = ;',
235
+ semanticLayout: 'none',
236
+ });
237
+ const result = await (0, validate_1.validateLocalDataApp)(dir, {
238
+ live: false,
239
+ loadLiveIndex: unusedLiveLoader,
240
+ });
241
+ expect(result.valid).toBe(true);
242
+ expect(result.warnings).toContainEqual(expect.objectContaining({ code: 'source_parse' }));
243
+ });
244
+ });
245
+ describe('renderAppsValidationHuman', () => {
246
+ it('makes partial green coverage explicit', () => {
247
+ const report = (0, validate_1.buildAppsValidationReport)([
248
+ {
249
+ path: '/tmp/orders-app',
250
+ name: 'Orders app',
251
+ projectUuid: 'project-uuid',
252
+ valid: true,
253
+ errors: [],
254
+ warnings: [],
255
+ coverage: {
256
+ callSites: 14,
257
+ fullyResolved: 11,
258
+ partiallyResolved: 2,
259
+ unresolved: 1,
260
+ unanalyzed: 3,
261
+ },
262
+ },
263
+ ], false);
264
+ const output = (0, validate_1.renderAppsValidationHuman)(report);
265
+ expect(output).toContain("3 of 14 data-reference call site(s) couldn't be fully analyzed");
266
+ expect(output).toContain('unresolved values were skipped');
267
+ expect(output).toContain('Validation passed');
268
+ expect(output).toContain('Offline snapshots may be stale');
269
+ });
270
+ });