@omnifyjp/ts 7.0.1 → 7.0.3

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 CHANGED
File without changes
@@ -18,8 +18,8 @@ export function toFaker(propName, property, schemas = {}) {
18
18
  case 'Int':
19
19
  case 'BigInt': return intFaker(snakeName, property);
20
20
  case 'TinyInt': return `'${snakeName}' => fake()->numberBetween(0, 127),`;
21
- case 'Float':
22
- case 'Decimal': return `'${snakeName}' => fake()->randomFloat(2, 1, 10000),`;
21
+ case 'Float': return `'${snakeName}' => fake()->randomFloat(2, 1, 10000),`;
22
+ case 'Decimal': return decimalFaker(snakeName, property);
23
23
  case 'Boolean': return `'${snakeName}' => fake()->boolean(),`;
24
24
  case 'Text': return `'${snakeName}' => fake()->paragraphs(3, true),`;
25
25
  case 'MediumText': return `'${snakeName}' => fake()->paragraphs(3, true),`;
@@ -82,43 +82,81 @@ export function associationFaker(propName, property, modelNamespace, currentSche
82
82
  return { fake, import: importLine };
83
83
  }
84
84
  function stringFaker(propName, snakeName, property) {
85
+ const expr = stringFakerExpr(propName, snakeName, property);
86
+ const length = property['length'];
87
+ // Clamp to the column's actual length regardless of which branch above
88
+ // picked the generator: a wrong/over-eager name heuristic (or a caller
89
+ // constraining `length` below what the heuristic normally produces) must
90
+ // stay inside the column instead of overflowing it — an SQL 1406 on
91
+ // MySQL/MariaDB weeks after the schema shipped (sqlite enforces no such
92
+ // limit, so this was invisible locally). Applied uniformly to every
93
+ // generator here rather than cherry-picked per branch (#264).
94
+ const value = length != null ? `mb_substr(${expr}, 0, ${length})` : expr;
95
+ return `'${snakeName}' => ${value},`;
96
+ }
97
+ function stringFakerExpr(propName, snakeName, property) {
85
98
  const lower = propName.toLowerCase();
99
+ // Name-heuristic tokens, split on the already-normalized snake_case column
100
+ // name so a single word boundary decides a match — `shipAddress` must not
101
+ // trip the bare `ip` check meant for `ipAddress` (#264: more-specific
102
+ // patterns must win, deterministically, not by accidental substring order).
103
+ const tokens = snakeName.split('_');
86
104
  if (propName === 'slug')
87
- return `'${snakeName}' => fake()->unique()->slug(2),`;
105
+ return `fake()->unique()->slug(2)`;
88
106
  if (propName === 'uuid' || propName === 'uid')
89
- return `'${snakeName}' => (string) \\Illuminate\\Support\\Str::uuid(),`;
107
+ return `(string) \\Illuminate\\Support\\Str::uuid()`;
108
+ if (tokens.includes('ip'))
109
+ return `fake()->ipv4()`;
90
110
  if (lower.includes('email'))
91
- return `'${snakeName}' => fake()->unique()->safeEmail(),`;
111
+ return `fake()->unique()->safeEmail()`;
92
112
  if (lower.includes('phone'))
93
- return `'${snakeName}' => fake()->phoneNumber(),`;
113
+ return `fake()->phoneNumber()`;
94
114
  if (lower.includes('image') || lower.includes('photo') || lower.includes('avatar'))
95
- return `'${snakeName}' => fake()->imageUrl(),`;
115
+ return `fake()->imageUrl()`;
96
116
  if (lower.includes('url') || lower.includes('website'))
97
- return `'${snakeName}' => fake()->url(),`;
117
+ return `fake()->url()`;
98
118
  if (lower.includes('path') || lower.includes('file'))
99
- return `'${snakeName}' => 'uploads/'.fake()->uuid().'.jpg',`;
119
+ return `'uploads/'.fake()->uuid().'.jpg'`;
100
120
  if (propName === 'name' || propName === 'title')
101
- return `'${snakeName}' => fake()->sentence(3),`;
121
+ return `fake()->sentence(3)`;
102
122
  if (lower.includes('name'))
103
- return `'${snakeName}' => fake()->name(),`;
123
+ return `fake()->name()`;
104
124
  if (lower.includes('address'))
105
- return `'${snakeName}' => fake()->address(),`;
125
+ return `fake()->address()`;
106
126
  if (lower.includes('city'))
107
- return `'${snakeName}' => fake()->city(),`;
127
+ return `fake()->city()`;
108
128
  if (lower.includes('country'))
109
- return `'${snakeName}' => fake()->country(),`;
129
+ return `fake()->country()`;
110
130
  if (lower.includes('zip') || lower.includes('postal'))
111
- return `'${snakeName}' => fake()->postcode(),`;
131
+ return `fake()->postcode()`;
112
132
  if (lower.includes('color'))
113
- return `'${snakeName}' => fake()->hexColor(),`;
133
+ return `fake()->hexColor()`;
114
134
  if (lower.includes('token') || lower.includes('secret') || lower.includes('key'))
115
- return `'${snakeName}' => \\Illuminate\\Support\\Str::random(32),`;
135
+ return `\\Illuminate\\Support\\Str::random(32)`;
116
136
  if (lower.includes('code'))
117
- return `'${snakeName}' => fake()->unique()->regexify('[A-Z0-9]{8}'),`;
137
+ return `fake()->unique()->regexify('[A-Z0-9]{8}')`;
118
138
  const length = property['length'];
119
139
  if (length != null && length <= 50)
120
- return `'${snakeName}' => fake()->words(3, true),`;
121
- return `'${snakeName}' => fake()->sentence(),`;
140
+ return `fake()->words(3, true)`;
141
+ return `fake()->sentence()`;
142
+ }
143
+ /** Largest value a `Decimal(precision, scale)` column accepts (all-9s at that precision/scale). */
144
+ function decimalMax(precision, scale) {
145
+ const digits = '9'.repeat(Math.max(precision, 1));
146
+ if (scale <= 0)
147
+ return digits;
148
+ const intPart = digits.slice(0, digits.length - scale) || '0';
149
+ const fracPart = digits.slice(digits.length - scale);
150
+ return `${intPart}.${fracPart}`;
151
+ }
152
+ function decimalFaker(snakeName, property) {
153
+ // Laravel's default Decimal migration is `decimal(8, 2)` (blueprint.go),
154
+ // matched here so an unspecified precision/scale keeps generating the same
155
+ // range as before.
156
+ const precision = property['precision'] ?? 8;
157
+ const scale = property['scale'] ?? 2;
158
+ const max = decimalMax(precision, scale);
159
+ return `'${snakeName}' => fake()->randomFloat(${scale}, 0, ${max}),`;
122
160
  }
123
161
  function intFaker(snakeName, property) {
124
162
  const isUnique = property['unique'] ?? false;
@@ -19,7 +19,8 @@
19
19
  * warning is printed so the user can decide whether to clean up by hand.
20
20
  */
21
21
  import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
22
- import { join, resolve, basename } from 'node:path';
22
+ import { join, relative, resolve, basename } from 'node:path';
23
+ import { resolveModularBaseNamespace } from './types.js';
23
24
  /** Matches the header emitted by `generateEditableService` in service-generator.ts */
24
25
  export const OMNIFY_EDITABLE_SERVICE_MARKER = 'SAFE TO EDIT - This file is never overwritten by Omnify.';
25
26
  const EXTENDS_SERVICE_BASE_RE = /extends\s+[\w\\]+ServiceBase\b/;
@@ -60,34 +61,83 @@ function collectModularLayerDirs(configDir, config, category) {
60
61
  for (const entry of readdirSync(modulesAbs)) {
61
62
  const layerDir = join(modulesAbs, entry, category);
62
63
  if (existsSync(layerDir) && statSync(layerDir).isDirectory()) {
63
- dirs.push(layerDir);
64
+ dirs.push({
65
+ dir: layerDir,
66
+ namespace: resolveModularBaseNamespace(config, entry, category, ''),
67
+ });
64
68
  }
65
69
  }
66
70
  return dirs;
67
71
  }
72
+ /**
73
+ * FQCN a base file claims from its LOCATION: the layer root's namespace,
74
+ * one segment per group subdirectory, then the class name from the file
75
+ * name (`Base/Crm/FooRequestBase.php` under `App\...\Base` →
76
+ * `App\...\Base\Crm\FooRequestBase`). Derived relative to the layer
77
+ * root, never from the bare basename — a grouped file's group segment
78
+ * is part of its identity, and it is also what hand-written `use`
79
+ * imports name (#263).
80
+ */
81
+ function fqcnFromLayerRelativePath(rootNamespace, relPath) {
82
+ const segments = relPath.split(/[\\/]/).filter((s) => s.length > 0);
83
+ const fileName = segments.pop();
84
+ if (!fileName)
85
+ return rootNamespace;
86
+ return [rootNamespace, ...segments, fileName.replace(/\.php$/i, '')]
87
+ .filter((s) => s.length > 0)
88
+ .join('\\');
89
+ }
90
+ /**
91
+ * Recursively visit every file under `root`. Directories are entered
92
+ * but never removed — the surrounding cleanup (pruneOrphanServiceFiles,
93
+ * walkPhpFiles) deletes files only and leaves emptied folders behind,
94
+ * and this prune matches that behaviour.
95
+ */
96
+ function walkLayerFiles(root, visit) {
97
+ if (!existsSync(root))
98
+ return;
99
+ let entries;
100
+ try {
101
+ entries = readdirSync(root);
102
+ }
103
+ catch {
104
+ return;
105
+ }
106
+ for (const entry of entries) {
107
+ const full = join(root, entry);
108
+ let info;
109
+ try {
110
+ info = statSync(full);
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ if (info.isDirectory()) {
116
+ walkLayerFiles(full, visit);
117
+ }
118
+ else {
119
+ visit(full);
120
+ }
121
+ }
122
+ }
68
123
  function pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced) {
69
124
  let pruned = 0;
70
125
  let kept = 0;
126
+ // Grouped schemas nest their bases one directory per group
127
+ // (`app/Omnify/Requests/OmnifyBase/Crm/FooRequestBase.php`), so the
128
+ // walk must recurse — a one-level `readdirSync` leaves every grouped
129
+ // stale base on disk (#263).
71
130
  for (const root of roots) {
72
- if (!existsSync(root))
73
- continue;
74
- for (const entry of readdirSync(root)) {
75
- if (!spec.matchesBaseFile(entry))
76
- continue;
77
- const fullPath = join(root, entry);
131
+ walkLayerFiles(root.dir, (fullPath) => {
132
+ const name = basename(fullPath);
133
+ if (!spec.matchesBaseFile(name))
134
+ return;
78
135
  if (expectedAbs.has(fullPath))
79
- continue;
80
- let content = '';
81
- try {
82
- content = readFileSync(fullPath, 'utf-8');
83
- }
84
- catch {
85
- continue;
86
- }
87
- const fqcn = declaredClassName(content);
88
- if (fqcn && referenced.has(fqcn)) {
136
+ return;
137
+ const fqcn = fqcnFromLayerRelativePath(root.namespace, relative(root.dir, fullPath));
138
+ if (referenced.has(fqcn)) {
89
139
  kept += 1;
90
- continue;
140
+ return;
91
141
  }
92
142
  try {
93
143
  unlinkSync(fullPath);
@@ -96,7 +146,7 @@ function pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced) {
96
146
  catch {
97
147
  // skip uncleanable files
98
148
  }
99
- }
149
+ });
100
150
  }
101
151
  return { pruned, kept };
102
152
  }
@@ -115,7 +165,7 @@ export function pruneDisabledScaffoldLayerBases(configDir, config, generated, re
115
165
  continue;
116
166
  const roots = [
117
167
  ...collectModularLayerDirs(configDir, config, spec.modularCategory),
118
- resolve(configDir, spec.legacyBasePath),
168
+ { dir: resolve(configDir, spec.legacyBasePath), namespace: spec.layerConfig.baseNamespace },
119
169
  ];
120
170
  const { pruned, kept } = pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced);
121
171
  if (pruned > 0 || kept > 0) {
@@ -3,7 +3,32 @@
3
3
  */
4
4
  import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName } from './naming-helper.js';
5
5
  import { toStoreRules, toUpdateRules, formatRules, resolveFkRuleType } from './type-mapper.js';
6
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
6
+ import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, resolveGlobalEnumNamespace } from './types.js';
7
+ import { enumClassName } from './enum-generator.js';
8
+ /**
9
+ * Format rules for an EnumRef property, replacing the generic `'string'` rule
10
+ * `toStoreRules`/`toUpdateRules` emit for EnumRef with `Rule::enum(<Class>::class)`
11
+ * — bare `'string'` lets any value through validation while the model cast
12
+ * and migration column both enforce the enum's set (#265). Duplicates just
13
+ * enough of `formatRules`' string/rule_unique_ignore branches, since the
14
+ * `Rule::enum(...)` case can't be added to type-mapper.ts's `Rule` union from
15
+ * here without editing a file this job does not own.
16
+ */
17
+ function formatEnumRefRules(rules, enumFqcn) {
18
+ const parts = [];
19
+ for (const rule of rules) {
20
+ if (rule === 'string') {
21
+ parts.push(`Rule::enum(${enumFqcn}::class)`);
22
+ }
23
+ else if (typeof rule === 'string') {
24
+ parts.push(`'${rule}'`);
25
+ }
26
+ else if (Array.isArray(rule) && rule[0] === 'rule_unique_ignore') {
27
+ parts.push(`Rule::unique('${rule[1]}')->ignore($this->route('${rule[2]}'))`);
28
+ }
29
+ }
30
+ return `[${parts.join(', ')}]`;
31
+ }
7
32
  /** Generate Store/Update request classes for all project-owned visible object schemas. */
8
33
  export function generateRequests(reader, config) {
9
34
  if (!config.requests.enabled)
@@ -142,7 +167,21 @@ function generateBaseRequest(name, schema, reader, config, action) {
142
167
  const rules = isUpdate
143
168
  ? toUpdateRules(prop, tableName, modelRouteParam)
144
169
  : toStoreRules(prop, tableName);
145
- rulesLines.push(` '${snakeName}' => ${formatRules(rules)},`);
170
+ // EnumRef: validate against the enum's actual value set via Rule::enum()
171
+ // instead of the bare 'string' toStoreRules/toUpdateRules emit for it —
172
+ // otherwise an out-of-set string passes validation, hits the DB, and only
173
+ // explodes later when the model cast reads it back (#265). Use the exact
174
+ // FQCN the model cast/doc-block already use (model-generator.ts's
175
+ // buildCasts), so validation and the cast never disagree. Falls back to
176
+ // the plain rules when the referenced enum schema doesn't exist, matching
177
+ // toStoreRules' own fallback for that case.
178
+ const enumName = type === 'EnumRef' ? prop['enum'] : undefined;
179
+ const enumSchema = typeof enumName === 'string' ? reader.getSchema(enumName) : undefined;
180
+ const globalEnumNs = type === 'EnumRef' ? resolveGlobalEnumNamespace(config, config.models.namespace) : '';
181
+ const formattedRules = typeof enumName === 'string' && enumSchema?.kind === 'enum' && globalEnumNs
182
+ ? formatEnumRefRules(rules, `\\${globalEnumNs}\\${enumClassName(enumName)}`)
183
+ : formatRules(rules);
184
+ rulesLines.push(` '${snakeName}' => ${formattedRules},`);
146
185
  attributeKeys.push(snakeName);
147
186
  // Translatable: add locale-nested rules (Astrotomic expects {locale}.{field})
148
187
  const isTranslatable = prop['translatable'] ?? false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "7.0.1",
3
+ "version": "7.0.3",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,7 +9,7 @@
9
9
  "omnify-ts": "dist/cli.js"
10
10
  },
11
11
  "scripts": {
12
- "build": "tsc",
12
+ "build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
13
13
  "dev": "tsc --watch",
14
14
  "generate-l12": "node dist/cli.js --config ../../examples/l12/omnify.yaml",
15
15
  "test": "vitest run"
@@ -1,57 +0,0 @@
1
- /**
2
- * Auto-format generated PHP files via Laravel Pint. Issue #100 v5.8.20.
3
- *
4
- * Reporter scenario (godx-jp/godx-kintai 2026-04-26 audit): every
5
- * `omnify generate --force` produces 150+ PHP files that fail Laravel
6
- * Pint's standard preset (`vendor/bin/pint --test`):
7
- *
8
- * - ordered_imports — `use` statements not alphabetised
9
- * - fully_qualified_strict_types — exception types referenced via FQN
10
- * - class_attributes_separation — missing blank lines between members
11
- * - function_declaration — function-keyword spacing
12
- * - unary_operator_spaces — `! \$x` vs `!\$x`
13
- * - no_unused_imports — codegen leaves the occasional dangling import
14
- * - single_line_empty_body — empty class bodies on multiple lines
15
- * - self_static_accessor — `self::` vs `static::` on enums
16
- * - blank_line_before_statement — enum `case` separators
17
- *
18
- * Auto-detect: when `<rootPath>/vendor/bin/pint` exists, run it on the
19
- * files we just wrote. Same Pint preset the project's CI already uses,
20
- * so no risk of formatter drift between codegen and CI checks.
21
- *
22
- * Opt-out: future config field; for now `chmod -x vendor/bin/pint` or
23
- * deleting it disables the auto-run.
24
- */
25
- /**
26
- * Resolve the `vendor/bin/pint` path for the project. Walks up from
27
- * the first written file until we find a sibling `vendor/bin/pint`,
28
- * stopping at filesystem root or after 12 levels (defensive against
29
- * pathological inputs). Returns the absolute path or null.
30
- */
31
- export declare function findPintBinary(anyWrittenPhpPath: string): string | null;
32
- export interface PintFormatResult {
33
- ran: boolean;
34
- pintPath: string | null;
35
- /** Files passed to pint. */
36
- formattedCount: number;
37
- /** Pint's exit status if it ran. 0 = success. */
38
- exitStatus: number | null;
39
- /** Captured stderr when pint failed (status != 0). */
40
- errorMessage?: string;
41
- /**
42
- * Why the run did not happen, when `ran` is false. The caller needs the three apart: only
43
- * `pint-not-found` is worth telling a human about, and it is the one that silently changes what
44
- * gets written to disk (godx-jp/godx-corebooks#108 — 927 files, reproduced).
45
- */
46
- skipped?: 'no-php-files' | 'opted-out' | 'pint-not-found';
47
- }
48
- /**
49
- * Run Laravel Pint on the given PHP files (absolute paths). No-op if
50
- * Pint isn't installed in the project. Pint is invoked with `--quiet`
51
- * so it stays out of the way unless something breaks.
52
- *
53
- * Pint takes a list of files (or directories) and formats them in
54
- * place. It uses the project's `pint.json` config if present, else the
55
- * Laravel preset.
56
- */
57
- export declare function runPintFormat(writtenPhpPaths: string[]): PintFormatResult;
@@ -1,102 +0,0 @@
1
- /**
2
- * Auto-format generated PHP files via Laravel Pint. Issue #100 v5.8.20.
3
- *
4
- * Reporter scenario (godx-jp/godx-kintai 2026-04-26 audit): every
5
- * `omnify generate --force` produces 150+ PHP files that fail Laravel
6
- * Pint's standard preset (`vendor/bin/pint --test`):
7
- *
8
- * - ordered_imports — `use` statements not alphabetised
9
- * - fully_qualified_strict_types — exception types referenced via FQN
10
- * - class_attributes_separation — missing blank lines between members
11
- * - function_declaration — function-keyword spacing
12
- * - unary_operator_spaces — `! \$x` vs `!\$x`
13
- * - no_unused_imports — codegen leaves the occasional dangling import
14
- * - single_line_empty_body — empty class bodies on multiple lines
15
- * - self_static_accessor — `self::` vs `static::` on enums
16
- * - blank_line_before_statement — enum `case` separators
17
- *
18
- * Auto-detect: when `<rootPath>/vendor/bin/pint` exists, run it on the
19
- * files we just wrote. Same Pint preset the project's CI already uses,
20
- * so no risk of formatter drift between codegen and CI checks.
21
- *
22
- * Opt-out: future config field; for now `chmod -x vendor/bin/pint` or
23
- * deleting it disables the auto-run.
24
- */
25
- import { existsSync, statSync } from 'node:fs';
26
- import { spawnSync } from 'node:child_process';
27
- import { dirname, join } from 'node:path';
28
- /**
29
- * Resolve the `vendor/bin/pint` path for the project. Walks up from
30
- * the first written file until we find a sibling `vendor/bin/pint`,
31
- * stopping at filesystem root or after 12 levels (defensive against
32
- * pathological inputs). Returns the absolute path or null.
33
- */
34
- export function findPintBinary(anyWrittenPhpPath) {
35
- let cur = dirname(anyWrittenPhpPath);
36
- for (let i = 0; i < 12; i++) {
37
- const candidate = join(cur, 'vendor', 'bin', 'pint');
38
- if (existsSync(candidate)) {
39
- try {
40
- const st = statSync(candidate);
41
- if (st.isFile())
42
- return candidate;
43
- }
44
- catch {
45
- // ignore stat errors and keep climbing
46
- }
47
- }
48
- const parent = dirname(cur);
49
- if (parent === cur)
50
- break;
51
- cur = parent;
52
- }
53
- return null;
54
- }
55
- /**
56
- * Run Laravel Pint on the given PHP files (absolute paths). No-op if
57
- * Pint isn't installed in the project. Pint is invoked with `--quiet`
58
- * so it stays out of the way unless something breaks.
59
- *
60
- * Pint takes a list of files (or directories) and formats them in
61
- * place. It uses the project's `pint.json` config if present, else the
62
- * Laravel preset.
63
- */
64
- export function runPintFormat(writtenPhpPaths) {
65
- if (writtenPhpPaths.length === 0) {
66
- return { ran: false, pintPath: null, formattedCount: 0, exitStatus: null, skipped: 'no-php-files' };
67
- }
68
- // Opt-out (#174). The auto-run hides what the generators actually emit: a
69
- // template that Pint rewrites looks fine here and produces churn in every
70
- // project where Pint is absent or does not run. `OMNIFY_NO_PINT=1` is how
71
- // the repo's own fixture check measures the raw output.
72
- if (process.env.OMNIFY_NO_PINT === '1') {
73
- return { ran: false, pintPath: null, formattedCount: 0, exitStatus: null, skipped: 'opted-out' };
74
- }
75
- // Resolution walks up from a written file. Trying only the first one makes
76
- // it depend on emission order — a single file outside the Laravel root and
77
- // the whole run goes unformatted, silently. Try each until one resolves.
78
- const pintPath = writtenPhpPaths.reduce((found, path) => found ?? findPintBinary(path), null);
79
- if (!pintPath) {
80
- return { ran: false, pintPath: null, formattedCount: 0, exitStatus: null, skipped: 'pint-not-found' };
81
- }
82
- // Pint's working dir should be the Laravel project root (where
83
- // composer.json + pint.json live). The pint binary is at
84
- // `<projectRoot>/vendor/bin/pint`, so projectRoot is two parents up.
85
- const projectRoot = dirname(dirname(dirname(pintPath)));
86
- // Pass each file as its own arg. Pint accepts multiple file/dir
87
- // arguments; capping the batch is unnecessary up to a few hundred
88
- // files (well under the OS arg limit).
89
- const result = spawnSync(pintPath, ['--quiet', ...writtenPhpPaths], {
90
- cwd: projectRoot,
91
- stdio: ['ignore', 'pipe', 'pipe'],
92
- encoding: 'utf-8',
93
- });
94
- const exitStatus = result.status ?? null;
95
- return {
96
- ran: true,
97
- pintPath,
98
- formattedCount: writtenPhpPaths.length,
99
- exitStatus,
100
- errorMessage: exitStatus !== 0 ? (result.stderr || result.stdout || undefined) : undefined,
101
- };
102
- }
@@ -1,23 +0,0 @@
1
- /**
2
- * Auto-format generated TypeScript files via the consuming project's Prettier.
3
- * Issue omnify-jp/omnify-go#211 (mirrors php/pint-format.ts).
4
- */
5
- /**
6
- * Resolve `node_modules/.bin/prettier` by walking up from a written `.ts` file.
7
- */
8
- export declare function findPrettierBinary(anyWrittenTsPath: string): string | null;
9
- /** Directory containing the `node_modules` that owns this Prettier binary. */
10
- export declare function prettierProjectRoot(prettierPath: string): string;
11
- export interface PrettierFormatResult {
12
- ran: boolean;
13
- prettierPath: string | null;
14
- formattedCount: number;
15
- exitStatus: number | null;
16
- errorMessage?: string;
17
- skipped?: 'no-ts-files' | 'opted-out' | 'prettier-not-found';
18
- }
19
- /**
20
- * Run the project's Prettier on the given TypeScript files (absolute paths).
21
- * Silent no-op when Prettier is not installed; never throws.
22
- */
23
- export declare function runPrettierFormat(writtenTsPaths: string[]): PrettierFormatResult;
@@ -1,102 +0,0 @@
1
- /**
2
- * Auto-format generated TypeScript files via the consuming project's Prettier.
3
- * Issue omnify-jp/omnify-go#211 (mirrors php/pint-format.ts).
4
- */
5
- import { existsSync, statSync } from 'node:fs';
6
- import { spawnSync } from 'node:child_process';
7
- import { dirname, join } from 'node:path';
8
- /** Max file paths per Prettier invocation (stay under typical ARG_MAX). */
9
- const PRETTIER_BATCH_SIZE = 100;
10
- /**
11
- * Resolve `node_modules/.bin/prettier` by walking up from a written `.ts` file.
12
- */
13
- export function findPrettierBinary(anyWrittenTsPath) {
14
- let cur = dirname(anyWrittenTsPath);
15
- for (let i = 0; i < 12; i++) {
16
- const candidate = join(cur, 'node_modules', '.bin', 'prettier');
17
- if (existsSync(candidate)) {
18
- try {
19
- const st = statSync(candidate);
20
- if (st.isFile())
21
- return candidate;
22
- }
23
- catch {
24
- // ignore stat errors and keep climbing
25
- }
26
- }
27
- const parent = dirname(cur);
28
- if (parent === cur)
29
- break;
30
- cur = parent;
31
- }
32
- return null;
33
- }
34
- /** Directory containing the `node_modules` that owns this Prettier binary. */
35
- export function prettierProjectRoot(prettierPath) {
36
- return dirname(dirname(dirname(prettierPath)));
37
- }
38
- function runPrettierBatch(prettierPath, projectRoot, files) {
39
- const result = spawnSync(prettierPath, ['--write', ...files], {
40
- cwd: projectRoot,
41
- stdio: ['ignore', 'pipe', 'pipe'],
42
- encoding: 'utf-8',
43
- });
44
- const exitStatus = result.status ?? null;
45
- return {
46
- exitStatus,
47
- errorMessage: exitStatus !== 0 ? (result.stderr || result.stdout || undefined) : undefined,
48
- };
49
- }
50
- /**
51
- * Run the project's Prettier on the given TypeScript files (absolute paths).
52
- * Silent no-op when Prettier is not installed; never throws.
53
- */
54
- export function runPrettierFormat(writtenTsPaths) {
55
- if (writtenTsPaths.length === 0) {
56
- return {
57
- ran: false,
58
- prettierPath: null,
59
- formattedCount: 0,
60
- exitStatus: null,
61
- skipped: 'no-ts-files',
62
- };
63
- }
64
- if (process.env.OMNIFY_NO_PRETTIER === '1') {
65
- return {
66
- ran: false,
67
- prettierPath: null,
68
- formattedCount: 0,
69
- exitStatus: null,
70
- skipped: 'opted-out',
71
- };
72
- }
73
- const prettierPath = writtenTsPaths.reduce((found, path) => found ?? findPrettierBinary(path), null);
74
- if (!prettierPath) {
75
- return {
76
- ran: false,
77
- prettierPath: null,
78
- formattedCount: 0,
79
- exitStatus: null,
80
- skipped: 'prettier-not-found',
81
- };
82
- }
83
- const projectRoot = prettierProjectRoot(prettierPath);
84
- let exitStatus = 0;
85
- let errorMessage;
86
- for (let i = 0; i < writtenTsPaths.length; i += PRETTIER_BATCH_SIZE) {
87
- const batch = writtenTsPaths.slice(i, i + PRETTIER_BATCH_SIZE);
88
- const batchResult = runPrettierBatch(prettierPath, projectRoot, batch);
89
- if (batchResult.exitStatus !== 0) {
90
- exitStatus = batchResult.exitStatus;
91
- errorMessage = batchResult.errorMessage;
92
- break;
93
- }
94
- }
95
- return {
96
- ran: true,
97
- prettierPath,
98
- formattedCount: writtenTsPaths.length,
99
- exitStatus,
100
- errorMessage,
101
- };
102
- }