@omnifyjp/omnify 6.2.0 → 6.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "6.2.0",
3
+ "version": "6.3.1",
4
4
  "description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "zod": "^3.24.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@omnifyjp/omnify-darwin-arm64": "6.2.0",
40
- "@omnifyjp/omnify-darwin-x64": "6.2.0",
41
- "@omnifyjp/omnify-linux-x64": "6.2.0",
42
- "@omnifyjp/omnify-linux-arm64": "6.2.0",
43
- "@omnifyjp/omnify-win32-x64": "6.2.0"
39
+ "@omnifyjp/omnify-darwin-arm64": "6.3.1",
40
+ "@omnifyjp/omnify-darwin-x64": "6.3.1",
41
+ "@omnifyjp/omnify-linux-x64": "6.3.1",
42
+ "@omnifyjp/omnify-linux-arm64": "6.3.1",
43
+ "@omnifyjp/omnify-win32-x64": "6.3.1"
44
44
  }
45
45
  }
package/ts-dist/cli.js CHANGED
@@ -29,6 +29,7 @@ import { Command } from 'commander';
29
29
  import { parse as parseYaml } from 'yaml';
30
30
  import { generateTypeScript } from './generator.js';
31
31
  import { generatePhp, derivePhpConfig } from './php/index.js';
32
+ import { stripPhpComments } from './php/types.js';
32
33
  import { pruneOrphanServiceFilesIfEnabled } from './php/orphan-cleanup.js';
33
34
  import { findSiblingWithSameBasename, findAppRootAncestor } from './php/sibling-skip.js';
34
35
  import { runPintFormat } from './php/pint-format.js';
@@ -100,6 +101,11 @@ function resolveFromConfig(configPath) {
100
101
  route: laravelConfig?.route,
101
102
  config: laravelConfig?.config,
102
103
  nestedset: laravelConfig?.nestedset,
104
+ modules: laravelConfig?.modules,
105
+ shared: laravelConfig?.shared,
106
+ enums: laravelConfig?.enums,
107
+ traits: laravelConfig?.traits,
108
+ openapi: laravelConfig?.openapi,
103
109
  } : undefined;
104
110
  return {
105
111
  inputSpec,
@@ -226,6 +232,7 @@ program
226
232
  let phpOverwritten = 0;
227
233
  let phpSkipped = 0;
228
234
  let phpSkippedSibling = 0;
235
+ let phpShadowed = 0;
229
236
  const skippedSiblingExamples = [];
230
237
  const writtenPaths = [];
231
238
  for (const file of phpFiles) {
@@ -233,11 +240,24 @@ program
233
240
  mkdirSync(dirname(filePath), { recursive: true });
234
241
  if (!file.overwrite && existsSync(filePath)) {
235
242
  phpSkipped++;
243
+ const existing = file.expects || (file.conflicts && file.conflicts.length > 0)
244
+ ? readFileSync(filePath, 'utf-8')
245
+ : '';
236
246
  // The file predates something the template now needs. Say so once,
237
247
  // naming the file and the line, rather than rewriting user code.
238
- if (file.expects && !readFileSync(filePath, 'utf-8').includes(file.expects.needle)) {
248
+ if (file.expects && !existing.includes(file.expects.needle)) {
239
249
  console.warn(`[omnify-ts] ${relative(configDir, filePath)}: ${file.expects.hint}`);
240
250
  }
251
+ // The mirror: the file contains an edit that silently disables what
252
+ // the base generates. #171 — a redeclared $fillable shadows the
253
+ // parent's, so every property added afterwards is stripped on mass
254
+ // assignment with no error anywhere.
255
+ for (const conflict of file.conflicts ?? []) {
256
+ if (new RegExp(conflict.pattern).test(stripPhpComments(existing))) {
257
+ phpShadowed++;
258
+ console.warn(`[omnify-ts] ${relative(configDir, filePath)}: ${conflict.hint}`);
259
+ }
260
+ }
241
261
  continue;
242
262
  }
243
263
  // Issue #98 v5.8.18 / #99 v5.8.19: skip user-editable stub
@@ -285,6 +305,10 @@ program
285
305
  console.log(` ${phpCreated} files created (user-editable)`);
286
306
  if (phpSkipped > 0)
287
307
  console.log(` ${phpSkipped} files skipped (already exist)`);
308
+ if (phpShadowed > 0) {
309
+ console.warn(` ${phpShadowed} editable file(s) shadow a generated property — ` +
310
+ `schema changes will NOT reach them until that is fixed (see warnings above).`);
311
+ }
288
312
  if (phpSkippedSibling > 0) {
289
313
  console.log(` ${phpSkippedSibling} user-editable stub(s) skipped (project sibling with same name found in subfolder)`);
290
314
  for (const ex of skippedSiblingExamples) {
@@ -72,9 +72,7 @@ class WriteAuditLog implements ShouldQueue
72
72
  * ip_address, user_agent, tags,
73
73
  * created_at).
74
74
  */
75
- public function __construct(public array $payload)
76
- {
77
- }
75
+ public function __construct(public array $payload) {}
78
76
 
79
77
  /**
80
78
  * Default retry behaviour: try 3 times with 5-second backoff between
@@ -197,7 +195,7 @@ class Audit extends Model
197
195
  */
198
196
  public function prunable(): Builder
199
197
  {
200
- $retention = (string) config('omnify.audit.logRetention', ${JSON.stringify(retention)});
198
+ $retention = (string) config('omnify.audit.logRetention', '${retention.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}');
201
199
  if ($retention === '') {
202
200
  return static::query()->whereRaw('1 = 0');
203
201
  }
@@ -156,6 +156,7 @@ trait HasOmnifyAuditLog
156
156
  * restores the previous state, even on exception.
157
157
  *
158
158
  * @template T
159
+ *
159
160
  * @param callable(): T $callback
160
161
  * @return T
161
162
  */
@@ -59,7 +59,7 @@ ${labelsConst}
59
59
  public function label(?string $locale = null): string
60
60
  {
61
61
  $locale = $locale ?? app()->getLocale();
62
- $labels = static::LABELS[$this->value] ?? [];
62
+ $labels = self::LABELS[$this->value] ?? [];
63
63
 
64
64
  return $labels[$locale]
65
65
  ?? $labels[config('app.fallback_locale', 'en')]
@@ -138,9 +138,9 @@ use ${traitsNamespace}\\HasLocalizedDisplayName;${sharedModelsNamespace !== base
138
138
  */
139
139
  class FileBaseModel extends BaseModel
140
140
  {
141
+ use HasLocalizedDisplayName;
141
142
  use HasUuids;
142
143
  use SoftDeletes;
143
- use HasLocalizedDisplayName;
144
144
 
145
145
  /**
146
146
  * The table associated with the model.
@@ -55,11 +55,13 @@ trait HasFiles
55
55
  /**
56
56
  * Attach files by IDs to a collection, making them permanent.
57
57
  *
58
- * @param array<string> $fileIds
58
+ * @param array<string> $fileIds
59
59
  */
60
60
  public function attachFiles(array $fileIds, string $collection = 'default', int $startOrder = 0): void
61
61
  {
62
- if (empty($fileIds)) return;
62
+ if (empty($fileIds)) {
63
+ return;
64
+ }
63
65
 
64
66
  File::whereIn('id', $fileIds)->update([
65
67
  'fileable_type' => $this->getMorphClass(),
@@ -67,14 +69,14 @@ trait HasFiles
67
69
  'collection' => $collection,
68
70
  'status' => FileStatusEnum::Permanent,
69
71
  'expires_at' => null,
70
- 'sort_order' => \\Illuminate\\Support\\Facades\\DB::raw('sort_order + ' . $startOrder),
72
+ 'sort_order' => \\Illuminate\\Support\\Facades\\DB::raw('sort_order + '.$startOrder),
71
73
  ]);
72
74
  }
73
75
 
74
76
  /**
75
77
  * Sync files for a collection (detach old, attach new).
76
78
  *
77
- * @param array<string> $fileIds
79
+ * @param array<string> $fileIds
78
80
  */
79
81
  public function syncFiles(array $fileIds, string $collection = 'default'): void
80
82
  {
@@ -35,6 +35,7 @@ import { generateControllers } from './controller-generator.js';
35
35
  import { generateServices } from './service-generator.js';
36
36
  import { generateRoutes } from './route-generator.js';
37
37
  import { generateEnums } from './enum-generator.js';
38
+ import { normalizeNotOperator, normalizePhpBlankLines, normalizePhpStatements, normalizeTopLevelUses } from './php-style.js';
38
39
  import { generateOpenApi } from './openapi-generator.js';
39
40
  import { generateArchitectureDoc } from './architecture-doc-generator.js';
40
41
  export { derivePhpConfig } from './types.js';
@@ -111,5 +112,10 @@ export function generatePhp(data, overrides) {
111
112
  // no behavior change for existing consumers, and the doc updates
112
113
  // automatically as schemas / config change so it never drifts.
113
114
  files.push(...generateArchitectureDoc(reader, config));
114
- return files;
115
+ // #174: every generated PHP file leaves here in Pint's import shape —
116
+ // unused imports dropped, the rest ordered. Doing it once, centrally, is why
117
+ // no template has to remember: the two rules fired on almost every layer.
118
+ return files.map((file) => file.path.endsWith('.php')
119
+ ? { ...file, content: normalizePhpBlankLines(normalizePhpStatements(normalizeNotOperator(normalizeTopLevelUses(file.content)))) }
120
+ : file);
115
121
  }
@@ -5,7 +5,7 @@ import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName, pluralize } fro
5
5
  import { resolveTimestamps } from './timestamps.js';
6
6
  import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
7
7
  import { buildRelation } from './relation-builder.js';
8
- import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, } from './types.js';
8
+ import { baseFile, userFile, shadowingPropertyConflicts, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup, } from './types.js';
9
9
  import { enumClassName } from './enum-generator.js';
10
10
  /** Generate base model and user model for all project-owned object schemas,
11
11
  * plus user models for package schemas (extending the package model). */
@@ -474,7 +474,11 @@ ${traits.join('\n')}
474
474
  //
475
475
  }
476
476
  `;
477
- return userFile(`${editable.path}/${editable.fileName}`, content);
477
+ // #171: this subclass is created once and then owned by the project. The
478
+ // most common edit to it — redeclaring $fillable — silently disables every
479
+ // property omnify adds to the base afterwards. We cannot prevent the edit,
480
+ // but we can stop it from being invisible.
481
+ return userFile(`${editable.path}/${editable.fileName}`, content, undefined, shadowingPropertyConflicts(baseAlias));
478
482
  }
479
483
  function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset', hasFiles = false, modelNamespace = '', localesNamespace = '', traitsNamespace = '', sharedModelsNamespace = '', config, hasAuditLog = false) {
480
484
  const lines = [];
@@ -608,25 +612,26 @@ function buildDocProperties(properties, expandedProperties, propertyOrder, reade
608
612
  return lines.length === 0 ? '' : lines.join('\n') + '\n';
609
613
  }
610
614
  function buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, hasFiles = false, hasAuditLog = false) {
611
- const lines = [];
612
- lines.push(' use HasLocalizedDisplayName;');
615
+ const names = ['HasLocalizedDisplayName'];
613
616
  if (hasFiles)
614
- lines.push(' use HasFiles;');
617
+ names.push('HasFiles');
615
618
  if (hasAuditLog)
616
- lines.push(' use HasOmnifyAuditLog;');
619
+ names.push('HasOmnifyAuditLog');
617
620
  if (needsUuidTrait)
618
- lines.push(' use HasUuids;');
621
+ names.push('HasUuids');
619
622
  if (needsUlidTrait)
620
- lines.push(' use HasUlids;');
623
+ names.push('HasUlids');
621
624
  if (isAuthenticatable)
622
- lines.push(' use Notifiable;');
625
+ names.push('Notifiable');
623
626
  if (hasNestedSet)
624
- lines.push(' use NodeTrait;');
627
+ names.push('NodeTrait');
625
628
  if (hasSoftDelete)
626
- lines.push(' use SoftDeletes;');
629
+ names.push('SoftDeletes');
627
630
  if (hasTranslatable)
628
- lines.push(' use Translatable;');
629
- return lines.join('\n') + '\n';
631
+ names.push('Translatable');
632
+ // #174: Pint's `ordered_traits` sorts them, so emit them sorted.
633
+ names.sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0));
634
+ return names.map((n) => ` use ${n};`).join('\n') + '\n';
630
635
  }
631
636
  function buildFillable(properties, expandedProperties, propertyOrder) {
632
637
  const fields = [];
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Formatting helpers that keep emitted PHP byte-stable under Laravel Pint.
3
+ *
4
+ * Issue #174: `omnify generate` wrote PHP that Pint immediately rewrote, so a
5
+ * version bump with no schema change produced hundreds of modified files that
6
+ * a formatter pass then reverted. Consumers running Pint paid review noise;
7
+ * consumers without it kept the churn forever. Codegen a formatter rewrites
8
+ * can never be verified byte-for-byte, so the generators emit Pint's own shape
9
+ * instead of relying on the post-generate Pint pass to fix it up.
10
+ */
11
+ /**
12
+ * Collects `use` imports for the classes a Resource references, so the emitted
13
+ * file matches what Laravel Pint's `laravel` preset produces.
14
+ *
15
+ * Issue #174: the generator emitted inline `\App\Http\Resources\FooResource`,
16
+ * which Pint rewrites into an import plus the short name. Every consumer with
17
+ * Pint therefore saw the whole Resource layer change on `generate` and change
18
+ * back on `pint` — 315 files of churn per run on the reporting project — and
19
+ * every consumer WITHOUT Pint kept the churn permanently. Codegen a formatter
20
+ * immediately rewrites can never be verified byte-for-byte.
21
+ *
22
+ * Two cases keep the FQCN, because an import would be wrong rather than noisy:
23
+ * - the short name is already taken by a different FQN (a genuine collision);
24
+ * - the class lives in this file's own namespace, where PHP resolves the
25
+ * short name without an import and Pint adds none.
26
+ */
27
+ export declare function createClassImporter(currentNamespace: string, reserved?: string[]): {
28
+ /** Reference `fqn` the way Pint would: short name + import when possible. */
29
+ ref(fqn: string): string;
30
+ /** Every FQN to import, minus the reserved ones the template writes itself. */
31
+ list(): string[];
32
+ };
33
+ export type ClassImporter = ReturnType<typeof createClassImporter>;
34
+ /**
35
+ * Render a `use` block in Pint's `ordered_imports` order. PHP-CS-Fixer's alpha
36
+ * algorithm compares with `\` replaced by a space, so a shorter namespace
37
+ * sorts before a longer one sharing its prefix.
38
+ */
39
+ export declare function renderUseBlock(fqns: string[]): string;
40
+ /**
41
+ * Rewrite a file's top-level `use` block the way Pint's `ordered_imports` and
42
+ * `no_unused_imports` would: drop imports nothing in the file references, then
43
+ * sort what is left.
44
+ *
45
+ * Applied to every generated PHP file rather than to each template, because
46
+ * every template that grew an import grew the same two violations (#174). Only
47
+ * `use` at column 0 is touched — a trait `use` inside a class body is indented,
48
+ * and grouped or function/const imports are left exactly as written.
49
+ */
50
+ export declare function normalizeTopLevelUses(content: string): string;
51
+ /**
52
+ * The blank-line half of Pint's `laravel` preset, applied to every generated
53
+ * PHP file (#174):
54
+ *
55
+ * - `no_extra_blank_lines` — never two blank lines in a row, and none left
56
+ * hanging before a closing brace;
57
+ * - `class_attributes_separation` — one blank line between a class's trait
58
+ * `use` group and the member that follows it.
59
+ *
60
+ * Templates composed from optional sections (a `$incrementing` block that is
61
+ * usually absent, a trait list that is sometimes empty) produce these by
62
+ * construction, which is why this is central rather than per template.
63
+ */
64
+ export declare function normalizePhpBlankLines(content: string): string;
65
+ /**
66
+ * The remaining whole-file rules from Pint's `laravel` preset that templates
67
+ * kept getting wrong (#174):
68
+ *
69
+ * - `function_declaration` — an arrow function is `fn () =>`, not `fn() =>`;
70
+ * - `blank_line_before_statement` — a `return` that follows a statement gets
71
+ * a blank line before it.
72
+ */
73
+ export declare function normalizePhpStatements(content: string): string;
74
+ /**
75
+ * `not_operator_with_successor_space` — Laravel's preset writes `! $x`, not
76
+ * `!$x` (#174).
77
+ *
78
+ * Done with a scanner rather than a regex because the same character is
79
+ * ordinary text inside a string literal, and generated services embed SQL and
80
+ * message strings. Only code outside quotes is touched, and `!=` / `!==` are
81
+ * comparisons, not negations.
82
+ */
83
+ export declare function normalizeNotOperator(content: string): string;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Formatting helpers that keep emitted PHP byte-stable under Laravel Pint.
3
+ *
4
+ * Issue #174: `omnify generate` wrote PHP that Pint immediately rewrote, so a
5
+ * version bump with no schema change produced hundreds of modified files that
6
+ * a formatter pass then reverted. Consumers running Pint paid review noise;
7
+ * consumers without it kept the churn forever. Codegen a formatter rewrites
8
+ * can never be verified byte-for-byte, so the generators emit Pint's own shape
9
+ * instead of relying on the post-generate Pint pass to fix it up.
10
+ */
11
+ /**
12
+ * Collects `use` imports for the classes a Resource references, so the emitted
13
+ * file matches what Laravel Pint's `laravel` preset produces.
14
+ *
15
+ * Issue #174: the generator emitted inline `\App\Http\Resources\FooResource`,
16
+ * which Pint rewrites into an import plus the short name. Every consumer with
17
+ * Pint therefore saw the whole Resource layer change on `generate` and change
18
+ * back on `pint` — 315 files of churn per run on the reporting project — and
19
+ * every consumer WITHOUT Pint kept the churn permanently. Codegen a formatter
20
+ * immediately rewrites can never be verified byte-for-byte.
21
+ *
22
+ * Two cases keep the FQCN, because an import would be wrong rather than noisy:
23
+ * - the short name is already taken by a different FQN (a genuine collision);
24
+ * - the class lives in this file's own namespace, where PHP resolves the
25
+ * short name without an import and Pint adds none.
26
+ */
27
+ export function createClassImporter(currentNamespace, reserved = []) {
28
+ // short name → FQN it is bound to. Seeded with the names the template always
29
+ // imports, so a schema called e.g. `Request` cannot shadow them.
30
+ const bound = new Map();
31
+ for (const fqn of reserved) {
32
+ bound.set(fqn.slice(fqn.lastIndexOf('\\') + 1), fqn);
33
+ }
34
+ const imported = new Set();
35
+ return {
36
+ /** Reference `fqn` the way Pint would: short name + import when possible. */
37
+ ref(fqn) {
38
+ const clean = fqn.replace(/^\\+/, '');
39
+ const shortName = clean.slice(clean.lastIndexOf('\\') + 1);
40
+ const ns = clean.slice(0, clean.lastIndexOf('\\'));
41
+ if (ns === currentNamespace)
42
+ return shortName;
43
+ const existing = bound.get(shortName);
44
+ if (existing && existing !== clean)
45
+ return `\\${clean}`;
46
+ if (!existing)
47
+ bound.set(shortName, clean);
48
+ imported.add(clean);
49
+ return shortName;
50
+ },
51
+ /** Every FQN to import, minus the reserved ones the template writes itself. */
52
+ list() {
53
+ return [...imported];
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * Render a `use` block in Pint's `ordered_imports` order. PHP-CS-Fixer's alpha
59
+ * algorithm compares with `\` replaced by a space, so a shorter namespace
60
+ * sorts before a longer one sharing its prefix.
61
+ */
62
+ export function renderUseBlock(fqns) {
63
+ const sortKey = (fqn) => fqn.split(' as ')[0].replace(/\\/g, ' ').toLowerCase();
64
+ return [...new Set(fqns)]
65
+ .sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : sortKey(a) > sortKey(b) ? 1 : 0))
66
+ .map((fqn) => `use ${fqn};`)
67
+ .join('\n');
68
+ }
69
+ /**
70
+ * Rewrite a file's top-level `use` block the way Pint's `ordered_imports` and
71
+ * `no_unused_imports` would: drop imports nothing in the file references, then
72
+ * sort what is left.
73
+ *
74
+ * Applied to every generated PHP file rather than to each template, because
75
+ * every template that grew an import grew the same two violations (#174). Only
76
+ * `use` at column 0 is touched — a trait `use` inside a class body is indented,
77
+ * and grouped or function/const imports are left exactly as written.
78
+ */
79
+ export function normalizeTopLevelUses(content) {
80
+ const lines = content.split('\n');
81
+ const useIdx = [];
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ if (/^(class|trait|interface|enum|abstract|final|return)\b/.test(line))
85
+ break;
86
+ if (/^use\s+[A-Za-z_\\][^;]*;\s*$/.test(line) && !line.includes('{'))
87
+ useIdx.push(i);
88
+ }
89
+ if (useIdx.length === 0)
90
+ return content;
91
+ const first = useIdx[0];
92
+ const last = useIdx[useIdx.length - 1];
93
+ // Anything other than blank lines between the imports means this is not a
94
+ // single block (a comment splitting it, say); leave the file alone.
95
+ for (let i = first; i <= last; i++) {
96
+ if (!useIdx.includes(i) && lines[i].trim() !== '')
97
+ return content;
98
+ }
99
+ const body = [...lines.slice(0, first), ...lines.slice(last + 1)].join('\n');
100
+ const kept = useIdx
101
+ .map((i) => lines[i])
102
+ .filter((line) => {
103
+ const m = line.match(/^use\s+(?:function\s+|const\s+)?([^;]+);$/);
104
+ if (!m)
105
+ return true;
106
+ const spec = m[1].trim();
107
+ const alias = spec.includes(' as ')
108
+ ? spec.slice(spec.lastIndexOf(' as ') + 4).trim()
109
+ : spec.slice(spec.lastIndexOf('\\') + 1);
110
+ // A name used anywhere in the body — code, docblock or attribute — keeps
111
+ // its import, which is the same call `no_unused_imports` makes.
112
+ return new RegExp(`(?<![\\w\\\\])${alias}(?![\\w])`).test(body);
113
+ });
114
+ const sorted = renderUseBlock(kept.map((line) => line.replace(/^use\s+/, '').replace(/;$/, ''))).split('\n').filter((l) => l !== '');
115
+ return [...lines.slice(0, first), ...sorted, ...lines.slice(last + 1)].join('\n');
116
+ }
117
+ /**
118
+ * The blank-line half of Pint's `laravel` preset, applied to every generated
119
+ * PHP file (#174):
120
+ *
121
+ * - `no_extra_blank_lines` — never two blank lines in a row, and none left
122
+ * hanging before a closing brace;
123
+ * - `class_attributes_separation` — one blank line between a class's trait
124
+ * `use` group and the member that follows it.
125
+ *
126
+ * Templates composed from optional sections (a `$incrementing` block that is
127
+ * usually absent, a trait list that is sometimes empty) produce these by
128
+ * construction, which is why this is central rather than per template.
129
+ */
130
+ export function normalizePhpBlankLines(content) {
131
+ const lines = content.split('\n');
132
+ const out = [];
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const line = lines[i];
135
+ const isBlank = line.trim() === '';
136
+ if (isBlank) {
137
+ // Collapse a run of blank lines, and drop it entirely when the next
138
+ // real line closes a block.
139
+ if (out.length > 0 && out[out.length - 1].trim() === '')
140
+ continue;
141
+ let j = i;
142
+ while (j < lines.length && lines[j].trim() === '')
143
+ j++;
144
+ if (j < lines.length && lines[j].trim().startsWith('}'))
145
+ continue;
146
+ }
147
+ const prev = out[out.length - 1];
148
+ // A method's closing brace must be separated from the next member.
149
+ if (prev !== undefined
150
+ && /^ {4}\}$/.test(prev)
151
+ && !isBlank
152
+ && !line.trim().startsWith('}')) {
153
+ out.push('');
154
+ }
155
+ // A class's trait `use` group must be separated from what follows it.
156
+ const prevUse = out[out.length - 1];
157
+ if (prevUse !== undefined
158
+ && /^\s+use\s+[A-Za-z_\\][^;]*;\s*$/.test(prevUse)
159
+ && !isBlank
160
+ && !/^\s+use\s+[A-Za-z_\\][^;]*;\s*$/.test(line)
161
+ && !line.trim().startsWith('}')) {
162
+ out.push('');
163
+ }
164
+ out.push(line);
165
+ }
166
+ return out.join('\n');
167
+ }
168
+ /**
169
+ * The remaining whole-file rules from Pint's `laravel` preset that templates
170
+ * kept getting wrong (#174):
171
+ *
172
+ * - `function_declaration` — an arrow function is `fn () =>`, not `fn() =>`;
173
+ * - `blank_line_before_statement` — a `return` that follows a statement gets
174
+ * a blank line before it.
175
+ */
176
+ export function normalizePhpStatements(content) {
177
+ const spaced = content.replace(/(?<![\w$>\\])fn\(/g, 'fn (');
178
+ const lines = spaced.split('\n');
179
+ const out = [];
180
+ for (const line of lines) {
181
+ const prev = out[out.length - 1];
182
+ if (prev !== undefined
183
+ && /^\s*return\b/.test(line)
184
+ && prev.trim() !== ''
185
+ && !prev.trimEnd().endsWith('{')
186
+ && !/^\s*(\/\/|\/\*|\*)/.test(prev)) {
187
+ out.push('');
188
+ }
189
+ out.push(line);
190
+ }
191
+ return out.join('\n');
192
+ }
193
+ /**
194
+ * `not_operator_with_successor_space` — Laravel's preset writes `! $x`, not
195
+ * `!$x` (#174).
196
+ *
197
+ * Done with a scanner rather than a regex because the same character is
198
+ * ordinary text inside a string literal, and generated services embed SQL and
199
+ * message strings. Only code outside quotes is touched, and `!=` / `!==` are
200
+ * comparisons, not negations.
201
+ */
202
+ export function normalizeNotOperator(content) {
203
+ let out = '';
204
+ let i = 0;
205
+ while (i < content.length) {
206
+ const ch = content[i];
207
+ const next = content[i + 1];
208
+ // Comments are prose: an apostrophe in "Laravel's" must not be read as a
209
+ // string opening, or everything after it goes untouched.
210
+ if (ch === '/' && next === '/') {
211
+ const end = content.indexOf('\n', i);
212
+ const stop = end === -1 ? content.length : end;
213
+ out += content.slice(i, stop);
214
+ i = stop;
215
+ continue;
216
+ }
217
+ if (ch === '/' && next === '*') {
218
+ const end = content.indexOf('*/', i + 2);
219
+ const stop = end === -1 ? content.length : end + 2;
220
+ out += content.slice(i, stop);
221
+ i = stop;
222
+ continue;
223
+ }
224
+ if (ch === "'" || ch === '"') {
225
+ const quote = ch;
226
+ let j = i + 1;
227
+ while (j < content.length) {
228
+ if (content[j] === '\\') {
229
+ j += 2;
230
+ continue;
231
+ }
232
+ if (content[j] === quote) {
233
+ j++;
234
+ break;
235
+ }
236
+ j++;
237
+ }
238
+ out += content.slice(i, j);
239
+ i = j;
240
+ continue;
241
+ }
242
+ if (ch === '!' && next !== '=' && next !== ' ') {
243
+ out += '! ';
244
+ i++;
245
+ continue;
246
+ }
247
+ out += ch;
248
+ i++;
249
+ }
250
+ return out;
251
+ }
@@ -65,7 +65,17 @@ export function runPintFormat(writtenPhpPaths) {
65
65
  if (writtenPhpPaths.length === 0) {
66
66
  return { ran: false, pintPath: null, formattedCount: 0, exitStatus: null };
67
67
  }
68
- const pintPath = findPintBinary(writtenPhpPaths[0]);
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 };
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);
69
79
  if (!pintPath) {
70
80
  return { ran: false, pintPath: null, formattedCount: 0, exitStatus: null };
71
81
  }
@@ -5,6 +5,7 @@ import { toPascalCase, toSnakeCase, toCamelCase, toFkColumnName } from './naming
5
5
  import { resolveTimestamps } from './timestamps.js';
6
6
  import { isHiddenByDefault, toResourceExpression } from './type-mapper.js';
7
7
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveBaseClass, resolveEditableClass, nestByGroup, nestEditableByGroup } from './types.js';
8
+ import { createClassImporter, renderUseBlock } from './php-style.js';
8
9
  /** Generate Resource classes for all project-owned visible object schemas.
9
10
  *
10
11
  * Issue #101 v5.8.23 emitted ONLY the base — but child resources embed a
@@ -72,6 +73,12 @@ function generateBaseResource(name, schema, reader, config) {
72
73
  const hasSoftDelete = options.softDelete ?? false;
73
74
  const hasId = options.id ?? true;
74
75
  const fields = [];
76
+ // #174: reference other Resources by short name + `use` import, the way Pint
77
+ // would rewrite them, so `generate` and the formatter stop disagreeing.
78
+ const importer = createClassImporter(baseNamespace, [
79
+ 'Illuminate\\Http\\Request',
80
+ 'Illuminate\\Http\\Resources\\Json\\JsonResource',
81
+ ]);
75
82
  if (hasId) {
76
83
  fields.push(" 'id' => $this->id,");
77
84
  }
@@ -84,7 +91,7 @@ function generateBaseResource(name, schema, reader, config) {
84
91
  if (isHiddenByDefault(type) || hidden)
85
92
  continue;
86
93
  if (type === 'Association') {
87
- addAssociationFields(propName, prop, fields, resourceNamespace, modelNamespace, reader, config);
94
+ addAssociationFields(propName, prop, fields, resourceNamespace, modelNamespace, reader, config, importer);
88
95
  continue;
89
96
  }
90
97
  if (expandedProperties[propName]) {
@@ -129,6 +136,11 @@ function generateBaseResource(name, schema, reader, config) {
129
136
  fields.push(" 'deleted_at' => $this->deleted_at?->toISOString(),");
130
137
  }
131
138
  const fieldsContent = fields.join('\n');
139
+ const useBlock = renderUseBlock([
140
+ 'Illuminate\\Http\\Request',
141
+ 'Illuminate\\Http\\Resources\\Json\\JsonResource',
142
+ ...importer.list(),
143
+ ]);
132
144
  const content = `<?php
133
145
 
134
146
  namespace ${baseNamespace};
@@ -140,8 +152,7 @@ namespace ${baseNamespace};
140
152
  * @generated by omnify
141
153
  */
142
154
 
143
- use Illuminate\\Http\\Request;
144
- use Illuminate\\Http\\Resources\\Json\\JsonResource;
155
+ ${useBlock}
145
156
 
146
157
  class ${modelName}ResourceBase extends JsonResource
147
158
  {
@@ -236,7 +247,7 @@ class ${editable.className} extends ${baseAlias}
236
247
  `;
237
248
  return userFile(`${editable.path}/${editable.fileName}`, content);
238
249
  }
239
- function addAssociationFields(propName, prop, fields, resourceNamespace, modelNamespace, reader, config) {
250
+ function addAssociationFields(propName, prop, fields, resourceNamespace, modelNamespace, reader, config, importer) {
240
251
  const relation = prop['relation'] ?? '';
241
252
  const target = prop['target'] ?? '';
242
253
  const methodName = toCamelCase(propName);
@@ -299,17 +310,17 @@ function addAssociationFields(propName, prop, fields, resourceNamespace, modelNa
299
310
  case 'ManyToOne': {
300
311
  const snakeName = toFkColumnName(propName);
301
312
  fields.push(` '${snakeName}' => $this->${snakeName},`);
302
- fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn() => new \\${targetResNs}\\${targetResource}($this->${methodName})),`);
313
+ fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => new ${importer.ref(`${targetResNs}\\${targetResource}`)}($this->${methodName})),`);
303
314
  break;
304
315
  }
305
316
  case 'OneToMany':
306
317
  case 'ManyToMany':
307
318
  case 'MorphMany':
308
- fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn() => \\${targetResNs}\\${targetResource}::collection($this->${methodName})),`);
319
+ fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => ${importer.ref(`${targetResNs}\\${targetResource}`)}::collection($this->${methodName})),`);
309
320
  break;
310
321
  case 'OneToOne':
311
322
  case 'MorphOne':
312
- fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn() => new \\${targetResNs}\\${targetResource}($this->${methodName})),`);
323
+ fields.push(` '${methodName}' => $this->whenLoaded('${methodName}', fn () => new ${importer.ref(`${targetResNs}\\${targetResource}`)}($this->${methodName})),`);
313
324
  break;
314
325
  case 'MorphTo':
315
326
  fields.push(` '${methodName}' => $this->whenLoaded('${methodName}'),`);
@@ -1257,14 +1257,14 @@ function buildSortSection(_sortableFields, defaultSort, allowedSortColumns, tx)
1257
1257
  $query
1258
1258
  ->leftJoin('${trTable} as tr_sort', function ($j) use ($locale) {
1259
1259
  $j->on('tr_sort.${fk}', '=', '${mainTable}.id')
1260
- ->where('tr_sort.locale', $locale);
1260
+ ->where('tr_sort.locale', $locale);
1261
1261
  })
1262
1262
  ->leftJoin('${trTable} as tr_sort_fb', function ($j) use ($fallback) {
1263
1263
  $j->on('tr_sort_fb.${fk}', '=', '${mainTable}.id')
1264
- ->where('tr_sort_fb.locale', $fallback);
1264
+ ->where('tr_sort_fb.locale', $fallback);
1265
1265
  })
1266
1266
  ->select('${mainTable}.*')
1267
- ->orderByRaw("COALESCE(tr_sort.\`{$column}\`, tr_sort_fb.\`{$column}\`) " . strtoupper($direction));
1267
+ ->orderByRaw("COALESCE(tr_sort.\`{$column}\`, tr_sort_fb.\`{$column}\`) ".strtoupper($direction));
1268
1268
  } else {
1269
1269
  $query->orderBy($column, $direction);
1270
1270
  }`;
@@ -1314,7 +1314,6 @@ function buildFindByIdMethod(modelName, eagerLoad, eagerCount, hasSoftDelete) {
1314
1314
  *
1315
1315
  * @example Fetch an active ${modelName}
1316
1316
  * $model = $service->findById('01h000000000000000000000000');
1317
- *
1318
1317
  * @example Resolve a trashed ${modelName} for restore() / forceDelete()
1319
1318
  * $trashed = $service->findById('01h000000000000000000000000', withTrashed: true);
1320
1319
  * $service->restore($trashed);
@@ -1713,11 +1712,11 @@ ${lookupExample}
1713
1712
  ])
1714
1713
  ->leftJoin('${trTable} as tr_current', function ($j) use ($locale) {
1715
1714
  $j->on('tr_current.${fk}', '=', '${mainTable}.id')
1716
- ->where('tr_current.locale', $locale);
1715
+ ->where('tr_current.locale', $locale);
1717
1716
  })
1718
1717
  ->leftJoin('${trTable} as tr_fallback', function ($j) use ($fallback) {
1719
1718
  $j->on('tr_fallback.${fk}', '=', '${mainTable}.id')
1720
- ->where('tr_fallback.locale', $fallback);
1719
+ ->where('tr_fallback.locale', $fallback);
1721
1720
  })
1722
1721
  ${orderByLine};
1723
1722
 
@@ -26,8 +26,7 @@ trait HasLocalizedDisplayName
26
26
  /**
27
27
  * Get the localized display name for this model.
28
28
  *
29
- * @param string|null $locale Locale code (defaults to app locale)
30
- * @return string
29
+ * @param string|null $locale Locale code (defaults to app locale)
31
30
  */
32
31
  public static function displayName(?string $locale = null): string
33
32
  {
@@ -53,9 +52,8 @@ trait HasLocalizedDisplayName
53
52
  /**
54
53
  * Get the localized display name for a property.
55
54
  *
56
- * @param string $property Property name
57
- * @param string|null $locale Locale code (defaults to app locale)
58
- * @return string
55
+ * @param string $property Property name
56
+ * @param string|null $locale Locale code (defaults to app locale)
59
57
  */
60
58
  public static function propertyDisplayName(string $property, ?string $locale = null): string
61
59
  {
@@ -71,7 +69,7 @@ trait HasLocalizedDisplayName
71
69
  /**
72
70
  * Get all localized display names for a property.
73
71
  *
74
- * @param string $property Property name
72
+ * @param string $property Property name
75
73
  * @return array<string, string>
76
74
  */
77
75
  public static function allPropertyDisplayNames(string $property): array
@@ -82,7 +80,7 @@ trait HasLocalizedDisplayName
82
80
  /**
83
81
  * Get all property display names for a given locale.
84
82
  *
85
- * @param string|null $locale Locale code (defaults to app locale)
83
+ * @param string|null $locale Locale code (defaults to app locale)
86
84
  * @return array<string, string>
87
85
  */
88
86
  public static function allPropertyDisplayNamesForLocale(?string $locale = null): array
@@ -21,6 +21,23 @@ export interface GeneratedFile {
21
21
  readonly needle: string;
22
22
  readonly hint: string;
23
23
  };
24
+ /**
25
+ * For a user file only: things the file must NOT contain, and why.
26
+ *
27
+ * The mirror of `expects`. Some edits to a user-editable subclass silently
28
+ * disable what the base class generates — the worst being a redeclared
29
+ * `protected $fillable`, which in PHP REPLACES the parent's rather than
30
+ * merging, so every property added to the schema afterwards is stripped by
31
+ * Eloquent on mass assignment. Nothing throws; `save()` returns true and the
32
+ * column stays NULL (issue #171).
33
+ *
34
+ * Each entry is matched against the content of the file we skipped, and
35
+ * `hint` is printed when it matches.
36
+ */
37
+ readonly conflicts?: readonly {
38
+ readonly pattern: string;
39
+ readonly hint: string;
40
+ }[];
24
41
  }
25
42
  /** Create a base file (always overwritten). */
26
43
  export declare function baseFile(path: string, content: string): GeneratedFile;
@@ -28,7 +45,28 @@ export declare function baseFile(path: string, content: string): GeneratedFile;
28
45
  export declare function userFile(path: string, content: string, expects?: {
29
46
  needle: string;
30
47
  hint: string;
31
- }): GeneratedFile;
48
+ }, conflicts?: readonly {
49
+ pattern: string;
50
+ hint: string;
51
+ }[]): GeneratedFile;
52
+ /**
53
+ * Eloquent array properties that REPLACE the parent's when redeclared in a
54
+ * subclass, rather than merging. Redeclaring any of these in a user-editable
55
+ * model shadows whatever the generated base puts there, and the failure is
56
+ * completely silent. Issue #171.
57
+ */
58
+ export declare const SHADOWING_ELOQUENT_PROPERTIES: readonly ["fillable", "guarded", "casts", "hidden", "visible", "appends", "dates", "touches", "with"];
59
+ /**
60
+ * Build the `conflicts` entries for a generated model's editable subclass.
61
+ *
62
+ * `baseClass` is named in the hint because the whole point is that the base is
63
+ * still correct — the generator did emit the property — and the reader needs to
64
+ * know which file they are shadowing.
65
+ */
66
+ export declare function shadowingPropertyConflicts(baseClass: string): {
67
+ pattern: string;
68
+ hint: string;
69
+ }[];
32
70
  /** Category of base file for modular path resolution. */
33
71
  export type BaseCategory = 'Models' | 'Controllers' | 'Requests' | 'Resources' | 'Services' | 'Enums' | 'Traits' | 'Locales' | 'Policies';
34
72
  /**
@@ -418,3 +456,15 @@ export declare function nestEditableByGroup(layer: BaseEditableLayer, loc: {
418
456
  * All paths and namespaces fall back to sensible defaults.
419
457
  */
420
458
  export declare function derivePhpConfig(overrides?: LaravelCodegenOverrides): PhpConfig;
459
+ /**
460
+ * Strip PHP comments so a commented-out declaration does not read as a real
461
+ * one. Without this, the very common
462
+ *
463
+ * // protected $fillable = ['name']; // left from a refactor
464
+ *
465
+ * would be reported as shadowing the base — and a warning that fires on code
466
+ * that is not running is worse than no warning, because people learn to ignore
467
+ * it. Naive on purpose: it can strip a `//` inside a string literal, which only
468
+ * ever costs us a warning we would otherwise have printed, never a false one.
469
+ */
470
+ export declare function stripPhpComments(source: string): string;
@@ -6,8 +6,53 @@ export function baseFile(path, content) {
6
6
  return { path, content, overwrite: true, type: 'base' };
7
7
  }
8
8
  /** Create a user file (created once, skip if exists). */
9
- export function userFile(path, content, expects) {
10
- return { path, content, overwrite: false, type: 'user', ...(expects ? { expects } : {}) };
9
+ export function userFile(path, content, expects, conflicts) {
10
+ return {
11
+ path,
12
+ content,
13
+ overwrite: false,
14
+ type: 'user',
15
+ ...(expects ? { expects } : {}),
16
+ ...(conflicts && conflicts.length > 0 ? { conflicts } : {}),
17
+ };
18
+ }
19
+ /**
20
+ * Eloquent array properties that REPLACE the parent's when redeclared in a
21
+ * subclass, rather than merging. Redeclaring any of these in a user-editable
22
+ * model shadows whatever the generated base puts there, and the failure is
23
+ * completely silent. Issue #171.
24
+ */
25
+ export const SHADOWING_ELOQUENT_PROPERTIES = [
26
+ 'fillable',
27
+ 'guarded',
28
+ 'casts',
29
+ 'hidden',
30
+ 'visible',
31
+ 'appends',
32
+ 'dates',
33
+ 'touches',
34
+ 'with',
35
+ ];
36
+ /**
37
+ * Build the `conflicts` entries for a generated model's editable subclass.
38
+ *
39
+ * `baseClass` is named in the hint because the whole point is that the base is
40
+ * still correct — the generator did emit the property — and the reader needs to
41
+ * know which file they are shadowing.
42
+ */
43
+ export function shadowingPropertyConflicts(baseClass) {
44
+ return SHADOWING_ELOQUENT_PROPERTIES.map((prop) => ({
45
+ // Matches `protected $fillable`, `public array $casts`, `protected static
46
+ // $with` — any redeclaration, whatever the visibility or type hint.
47
+ pattern: String.raw `(?:public|protected|private)\s+(?:static\s+)?(?:array\s+)?\$` + prop + String.raw `\s*=`,
48
+ hint: `redeclares $${prop}, which REPLACES ` +
49
+ `${baseClass}::$${prop} rather than merging with it (PHP does not merge ` +
50
+ `redeclared properties). Anything omnify adds to $${prop} from now on is ` +
51
+ `silently dropped: mass assignment strips it, save() still returns true, ` +
52
+ `and the column stays NULL. Fix: delete the redeclaration and let the base ` +
53
+ `provide it, or build it from the parent, e.g. ` +
54
+ `__construct() { $this->${prop} = array_merge(parent::$${prop} ?? [], [...]); }`,
55
+ }));
11
56
  }
12
57
  /**
13
58
  * Resolve base file path for a schema based on structure.
@@ -382,3 +427,20 @@ export function derivePhpConfig(overrides) {
382
427
  },
383
428
  };
384
429
  }
430
+ /**
431
+ * Strip PHP comments so a commented-out declaration does not read as a real
432
+ * one. Without this, the very common
433
+ *
434
+ * // protected $fillable = ['name']; // left from a refactor
435
+ *
436
+ * would be reported as shadowing the base — and a warning that fires on code
437
+ * that is not running is worse than no warning, because people learn to ignore
438
+ * it. Naive on purpose: it can strip a `//` inside a string literal, which only
439
+ * ever costs us a warning we would otherwise have printed, never a false one.
440
+ */
441
+ export function stripPhpComments(source) {
442
+ return source
443
+ .replace(/\/\*[\s\S]*?\*\//g, '')
444
+ .replace(/(^|\s)\/\/[^\n]*/g, '$1')
445
+ .replace(/(^|\s)#[^\n]*/g, '$1');
446
+ }