@ductape/cli 0.3.36 → 0.4.1

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,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
@@ -19,6 +19,7 @@ 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
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,6 +896,12 @@ 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) })));
898
905
  features
899
906
  .command('validate')
900
907
  .description('Read-only AST validation of code-first Feature control flow; performs no remote mutation')
@@ -45,7 +45,19 @@ export interface ICreateIndexOp {
45
45
  order?: 'asc' | 'desc';
46
46
  }>;
47
47
  unique?: boolean;
48
+ sparse?: boolean;
48
49
  ifNotExists?: boolean;
50
+ sqlOptions?: {
51
+ method?: string;
52
+ where?: string;
53
+ include?: string[];
54
+ concurrent?: boolean;
55
+ };
56
+ mongoOptions?: {
57
+ expireAfterSeconds?: number;
58
+ partialFilterExpression?: Record<string, unknown>;
59
+ collation?: Record<string, unknown>;
60
+ };
49
61
  }
50
62
  export interface IDropIndexOp {
51
63
  type: 'dropIndex';
@@ -1,4 +1,5 @@
1
1
  import type { IMigration } from './db-types.js';
2
+ export declare function validateEquivalentMigrationIndexes(migrations: IMigration[]): void;
2
3
  export declare function getMigrationsDir(projectDir: string, dbTag: string): string;
3
4
  export declare function loadMigrationFiles(projectDir: string, dbTag: string): IMigration[];
4
5
  export declare function writeMigrationFile(projectDir: string, dbTag: string, migration: IMigration, counter: number): string;
@@ -1,6 +1,27 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { PROJECT_CONFIG_DIR } from './config.js';
4
+ function stable(value) {
5
+ if (Array.isArray(value))
6
+ return `[${value.map(stable).join(',')}]`;
7
+ if (value && typeof value === 'object')
8
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, nested]) => `${JSON.stringify(key)}:${stable(nested)}`).join(',')}}`;
9
+ return JSON.stringify(value);
10
+ }
11
+ export function validateEquivalentMigrationIndexes(migrations) {
12
+ const seen = new Map();
13
+ for (const migration of migrations)
14
+ migration.up.forEach((operation, operationIndex) => {
15
+ if (operation.type !== 'createIndex')
16
+ return;
17
+ const fingerprint = stable({ collection: operation.collection, fields: operation.fields.map((field) => ({ name: field.name, order: field.order ?? 'asc' })), unique: Boolean(operation.unique), sparse: Boolean(operation.sparse), sql: operation.sqlOptions ?? {}, mongo: operation.mongoOptions ?? {} });
18
+ const previous = seen.get(fingerprint);
19
+ if (previous && previous.name !== operation.name) {
20
+ throw new Error(`Equivalent index definitions use different names: migration "${previous.migration}" operation ${previous.operation + 1} creates "${previous.name}", while migration "${migration.tag}" operation ${operationIndex + 1} creates "${operation.name}".`);
21
+ }
22
+ seen.set(fingerprint, { migration: migration.tag, operation: operationIndex, name: operation.name });
23
+ });
24
+ }
4
25
  export function getMigrationsDir(projectDir, dbTag) {
5
26
  return path.join(projectDir, PROJECT_CONFIG_DIR, 'database', 'migrations', dbTag);
6
27
  }
@@ -12,10 +33,12 @@ export function loadMigrationFiles(projectDir, dbTag) {
12
33
  .readdirSync(dir)
13
34
  .filter((f) => f.endsWith('.json'))
14
35
  .sort();
15
- return files.map((file) => {
36
+ const migrations = files.map((file) => {
16
37
  const raw = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
17
38
  return raw;
18
39
  });
40
+ validateEquivalentMigrationIndexes(migrations);
41
+ return migrations;
19
42
  }
20
43
  function makeTimestamp(counter) {
21
44
  const now = new Date();
@@ -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.36",
3
+ "version": "0.4.1",
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",