@omnifyjp/ts 6.8.1 → 7.0.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/dist/cli.js CHANGED
@@ -30,10 +30,8 @@ import { parse as parseYaml } from 'yaml';
30
30
  import { generateTypeScript } from './generator.js';
31
31
  import { generatePhp, derivePhpConfig } from './php/index.js';
32
32
  import { evaluateShadowingConflicts } from './php/shadowing-conflicts.js';
33
- import { hasEditableSubclassForBase, pruneDisabledScaffoldLayerBases, pruneOrphanServiceFilesIfEnabled } from './php/orphan-cleanup.js';
33
+ import { collectReferencedClasses, hasEditableSubclassForBase, pruneDisabledScaffoldLayerBases, pruneOrphanServiceFilesIfEnabled } from './php/orphan-cleanup.js';
34
34
  import { findSiblingWithSameBasename, findAppRootAncestor } from './php/sibling-skip.js';
35
- import { runPintFormat } from './php/pint-format.js';
36
- import { runPrettierFormat } from './prettier-format.js';
37
35
  import { resolveInput, sniffInputKind } from './input-resolver.js';
38
36
  function resolveFromConfig(configPath) {
39
37
  const raw = readFileSync(configPath, 'utf-8');
@@ -195,7 +193,6 @@ program
195
193
  let tsCreated = 0;
196
194
  let tsOverwritten = 0;
197
195
  let tsSkipped = 0;
198
- const tsWrittenPaths = [];
199
196
  for (const file of files) {
200
197
  let filePath;
201
198
  if (file.category === 'enum' || file.category === 'plugin-enum') {
@@ -210,7 +207,6 @@ program
210
207
  continue;
211
208
  }
212
209
  writeFileSync(filePath, file.content, 'utf-8');
213
- tsWrittenPaths.push(filePath);
214
210
  if (file.overwrite || !existsSync(filePath)) {
215
211
  tsOverwritten++;
216
212
  }
@@ -223,22 +219,6 @@ program
223
219
  console.log(` ${tsCreated} files created (user-editable)`);
224
220
  if (tsSkipped > 0)
225
221
  console.log(` ${tsSkipped} files skipped (already exist)`);
226
- // Issue #211: format via the consumer project's Prettier (same idea as Pint for PHP).
227
- if (tsWrittenPaths.length > 0) {
228
- const prettierResult = runPrettierFormat(tsWrittenPaths);
229
- if (prettierResult.ran) {
230
- if (prettierResult.exitStatus === 0) {
231
- console.log(` Formatted ${prettierResult.formattedCount} TS file(s) via Prettier`);
232
- }
233
- else {
234
- console.warn(` [omnify] Prettier exited with status ${prettierResult.exitStatus}; generated files may not match project formatting preset.`);
235
- if (prettierResult.errorMessage) {
236
- const trimmed = prettierResult.errorMessage.trim().split('\n').slice(0, 5).join('\n');
237
- console.warn(` ${trimmed}`);
238
- }
239
- }
240
- }
241
- }
242
222
  }
243
223
  else {
244
224
  console.log(`\n[TypeScript] Skipped (disabled)`);
@@ -248,9 +228,12 @@ program
248
228
  console.log(`\n[Laravel] Generating PHP files`);
249
229
  // #255: a disabled scaffold layer still regenerates the bases that
250
230
  // hand-written subclasses extend, so they track the schema.
231
+ // Read before anything is written, so the answer reflects the project's
232
+ // own code, not this run's output.
251
233
  const phpConfigForGen = derivePhpConfig(laravelOverrides);
234
+ const referencedClasses = collectReferencedClasses(configDir, phpConfigForGen);
252
235
  const phpFiles = generatePhp(input, laravelOverrides, {
253
- keepDisabledBase: (file) => hasEditableSubclassForBase(configDir, phpConfigForGen, file),
236
+ keepDisabledBase: (file) => hasEditableSubclassForBase(phpConfigForGen, referencedClasses, file),
254
237
  });
255
238
  let phpCreated = 0;
256
239
  let phpOverwritten = 0;
@@ -259,7 +242,6 @@ program
259
242
  let phpShadowedOutOfSync = 0;
260
243
  let phpShadowedAtRisk = 0;
261
244
  const skippedSiblingExamples = [];
262
- const writtenPaths = [];
263
245
  for (const file of phpFiles) {
264
246
  const filePath = resolve(configDir, file.path);
265
247
  mkdirSync(dirname(filePath), { recursive: true });
@@ -323,7 +305,6 @@ program
323
305
  }
324
306
  }
325
307
  writeFileSync(filePath, file.content, 'utf-8');
326
- writtenPaths.push(filePath);
327
308
  if (file.overwrite || !existsSync(filePath)) {
328
309
  phpOverwritten++;
329
310
  }
@@ -355,48 +336,13 @@ program
355
336
  console.log(` ... and ${phpSkippedSibling - skippedSiblingExamples.length} more`);
356
337
  }
357
338
  }
358
- // Issue #100 v5.8.20: auto-format every written PHP file via
359
- // Laravel Pint when it's installed in the project. Same preset
360
- // the project's CI already uses, so codegen output stops failing
361
- // `pint --test` and devs no longer need a manual sweep after
362
- // every `omnify generate`.
363
- //
364
- // NOT a silent no-op when Pint is missing any more. It used to be, and that cost a
365
- // consumer several investigation sessions: their CI regenerated without Pint on PATH,
366
- // wrote 927 files differing from the committed (Pint-formatted) tree, and the only
367
- // evidence was one ABSENT log line. It was read as a Linux/Darwin build divergence and
368
- // chased as far as "pick a canonical platform" before anyone ran the two commands that
369
- // settle it (godx-jp/godx-corebooks#108, reproduced exactly: hide `vendor/bin/pint`,
370
- // regenerate, 927 files; restore it, 0).
371
- //
372
- // The opt-out (`OMNIFY_NO_PINT=1`) stays quiet — that one is a choice, not a surprise.
373
- if (writtenPaths.length > 0) {
374
- const pintResult = runPintFormat(writtenPaths);
375
- if (pintResult.ran) {
376
- if (pintResult.exitStatus === 0) {
377
- console.log(` Formatted ${pintResult.formattedCount} PHP file(s) via Laravel Pint`);
378
- }
379
- else {
380
- console.warn(` [omnify] Laravel Pint exited with status ${pintResult.exitStatus}; generated files may not match project formatting preset.`);
381
- if (pintResult.errorMessage) {
382
- const trimmed = pintResult.errorMessage.trim().split('\n').slice(0, 5).join('\n');
383
- console.warn(` ${trimmed}`);
384
- }
385
- }
386
- }
387
- else if (pintResult.skipped === 'pint-not-found') {
388
- console.warn(` [omnify] Laravel Pint not found — ${writtenPaths.length} PHP file(s) written UNFORMATTED.`);
389
- console.warn(' If this project commits generated PHP, the output will differ from a tree formatted by Pint.');
390
- console.warn(' Run `composer install` before `omnify generate`, or set OMNIFY_NO_PINT=1 to silence this deliberately.');
391
- }
392
- }
393
339
  // Orphan cleanup: when a schema is removed, renamed, flipped from
394
340
  // kind:object → kind:pivot, or opted out via `options.service: false`,
395
341
  // its previously-emitted `*ServiceBase.php` becomes orphan and
396
342
  // pollutes Composer autoload. Prune base files automatically; warn
397
343
  // about editable `*Service.php` (may contain user code).
398
344
  const phpConfig = derivePhpConfig(laravelOverrides);
399
- for (const summary of pruneDisabledScaffoldLayerBases(configDir, phpConfig, phpFiles)) {
345
+ for (const summary of pruneDisabledScaffoldLayerBases(configDir, phpConfig, phpFiles, referencedClasses)) {
400
346
  if (summary.prunedCount > 0) {
401
347
  console.log(` pruned ${summary.prunedCount} stale ${summary.label}(s) — layer disabled`);
402
348
  }
@@ -208,7 +208,7 @@ function generateUserController(name, schema, config) {
208
208
  className: `${modelName}ControllerBase`,
209
209
  fqn: `${resolveModularBaseNamespace(config, name, 'Controllers', config.controllers.baseNamespace)}\\${modelName}ControllerBase`,
210
210
  }
211
- : resolveBaseClass(config.controllers, `${modelName}Controller`, 'Base');
211
+ : legacyGroupedBase(config, `${modelName}Controller`, schema.group);
212
212
  const editable = config.structure === 'modular'
213
213
  ? { className: `${modelName}Controller`, fileName: `${modelName}Controller.php`, namespace: config.controllers.namespace, path: config.controllers.path }
214
214
  : resolveEditableClass(config.controllers, modelName, 'Controller');
@@ -244,3 +244,13 @@ class ${editable.className} extends ${baseAlias}
244
244
  `;
245
245
  return userFile(`${userPath}/${editable.fileName}`, content);
246
246
  }
247
+ /**
248
+ * Legacy layout nests the base controller under its schema group (see
249
+ * `generateBaseController`), so the stub must import it from the same nested
250
+ * namespace — a flat import names a class that does not exist.
251
+ */
252
+ function legacyGroupedBase(config, className, group) {
253
+ const raw = resolveBaseClass(config.controllers, className, 'Base');
254
+ const { namespace } = nestByGroup({ path: '', namespace: raw.namespace }, group);
255
+ return { className: raw.className, fqn: `${namespace}\\${raw.className}` };
256
+ }
@@ -178,7 +178,7 @@ class FileFactory extends Factory
178
178
  public function definition(): array
179
179
  {
180
180
  $extension = fake()->randomElement(['jpg', 'png', 'pdf', 'docx']);
181
- $originalName = fake()->word() . '.' . $extension;
181
+ $originalName = fake()->word().'.'.$extension;
182
182
  $mimeTypes = [
183
183
  'jpg' => 'image/jpeg',
184
184
  'png' => 'image/png',
@@ -189,7 +189,7 @@ class FileFactory extends Factory
189
189
  return [
190
190
  'collection' => 'default',
191
191
  'disk' => 'public',
192
- 'path' => 'files/' . now()->format('Y/m') . '/' . Str::uuid() . '.' . $extension,
192
+ 'path' => 'files/'.now()->format('Y/m').'/'.Str::uuid().'.'.$extension,
193
193
  'original_name' => $originalName,
194
194
  'mime_type' => $mimeTypes[$extension] ?? 'application/octet-stream',
195
195
  'size' => fake()->numberBetween(1024, 10485760),
@@ -57,7 +57,7 @@ export function compoundFaker(propName, expansion, sourceType) {
57
57
  `'${snakeName}_branch_code' => ${jaFaker}->regexify('[0-9]{3}'),`,
58
58
  `'${snakeName}_account_type' => ${jaFaker}->randomElement(['1', '2', '4']),`,
59
59
  `'${snakeName}_account_number' => ${jaFaker}->regexify('[0-9]{7}'),`,
60
- `'${snakeName}_account_holder' => ${jaFaker}->lastKanaName() . ' ' . ${jaFaker}->firstKanaName(),`,
60
+ `'${snakeName}_account_holder' => ${jaFaker}->lastKanaName().' '.${jaFaker}->firstKanaName(),`,
61
61
  ];
62
62
  default:
63
63
  return genericCompoundFaker(snakeName, expansion);
@@ -96,7 +96,7 @@ function stringFaker(propName, snakeName, property) {
96
96
  if (lower.includes('url') || lower.includes('website'))
97
97
  return `'${snakeName}' => fake()->url(),`;
98
98
  if (lower.includes('path') || lower.includes('file'))
99
- return `'${snakeName}' => 'uploads/' . fake()->uuid() . '.jpg',`;
99
+ return `'${snakeName}' => 'uploads/'.fake()->uuid().'.jpg',`;
100
100
  if (propName === 'name' || propName === 'title')
101
101
  return `'${snakeName}' => fake()->sentence(3),`;
102
102
  if (lower.includes('name'))
@@ -183,7 +183,7 @@ function japaneseAddressFaker(snakeName, expansion) {
183
183
  else {
184
184
  lines.push(`'${snakeName}_prefecture' => fake()->randomElement(['hokkaido', 'aomori', 'iwate', 'miyagi', 'akita', 'yamagata', 'fukushima', 'ibaraki', 'tochigi', 'gunma', 'saitama', 'chiba', 'tokyo', 'kanagawa', 'niigata', 'toyama', 'ishikawa', 'fukui', 'yamanashi', 'nagano', 'gifu', 'shizuoka', 'aichi', 'mie', 'shiga', 'kyoto', 'osaka', 'hyogo', 'nara', 'wakayama', 'tottori', 'shimane', 'okayama', 'hiroshima', 'yamaguchi', 'tokushima', 'kagawa', 'ehime', 'kochi', 'fukuoka', 'saga', 'nagasaki', 'kumamoto', 'oita', 'miyazaki', 'kagoshima', 'okinawa']),`);
185
185
  }
186
- lines.push(`'${snakeName}_address1' => ${jaFaker}->city() . ${jaFaker}->ward(),`);
186
+ lines.push(`'${snakeName}_address1' => ${jaFaker}->city().${jaFaker}->ward(),`);
187
187
  lines.push(`'${snakeName}_address2' => ${jaFaker}->streetAddress(),`);
188
188
  lines.push(`'${snakeName}_address3' => ${jaFaker}->optional()->secondaryAddress(),`);
189
189
  return lines;
@@ -68,12 +68,16 @@ class CleanupFiles extends Command
68
68
 
69
69
  if ($phase === null || $phase === '1') {
70
70
  $r = $this->phase1SoftDelete($dryRun);
71
- if ($r !== self::SUCCESS) $result = $r;
71
+ if ($r !== self::SUCCESS) {
72
+ $result = $r;
73
+ }
72
74
  }
73
75
 
74
76
  if ($phase === null || $phase === '2') {
75
77
  $r = $this->phase2Purge($dryRun);
76
- if ($r !== self::SUCCESS) $result = $r;
78
+ if ($r !== self::SUCCESS) {
79
+ $result = $r;
80
+ }
77
81
  }
78
82
 
79
83
  return $result;
@@ -45,13 +45,24 @@ export interface DisabledLayerPruneSummary {
45
45
  * When a scaffold layer is disabled (issue #252), remove stale auto-generated
46
46
  * BASE files that have no matching user-editable subclass on disk.
47
47
  */
48
- export declare function pruneDisabledScaffoldLayerBases(configDir: string, config: PhpConfig, generated: readonly GeneratedFile[]): DisabledLayerPruneSummary[];
48
+ export declare function pruneDisabledScaffoldLayerBases(configDir: string, config: PhpConfig, generated: readonly GeneratedFile[], referenced?: ReadonlySet<string>): DisabledLayerPruneSummary[];
49
49
  /**
50
- * Issue #255: does a hand-written subclass exist on disk for this generated
51
- * base of a disabled scaffold layer? Same lookup the prune above uses to keep
52
- * a base, so a base is regenerated exactly when it would otherwise be kept.
50
+ * Issue #255: is this generated base of a disabled scaffold layer still used by
51
+ * hand-written code? Same question the prune above asks before deleting a base,
52
+ * so a base is regenerated exactly when it would otherwise be kept.
53
53
  */
54
- export declare function hasEditableSubclassForBase(configDir: string, config: PhpConfig, file: GeneratedFile): boolean;
54
+ export declare function hasEditableSubclassForBase(config: PhpConfig, referenced: ReadonlySet<string>, file: GeneratedFile): boolean;
55
+ /**
56
+ * Every class hand-written PHP under the Laravel root names in a `use` import
57
+ * or a fully-qualified `extends` (#260).
58
+ *
59
+ * "Is this base still used?" used to be answered by looking for a file named
60
+ * after it in the layer's configured directories. Real projects keep their
61
+ * subclasses wherever they like — `app/Policies/`, `app/Services/Promotion/` —
62
+ * so a used base looked unused and was deleted under running code. The import
63
+ * is what PHP resolves, so the import is what we check.
64
+ */
65
+ export declare function collectReferencedClasses(configDir: string, config: PhpConfig): Set<string>;
55
66
  /**
56
67
  * Delete orphan `*ServiceBase.php` files; flag orphan editable services
57
68
  * for warning. Pure-ish: takes the generated file list (= source of
@@ -19,7 +19,7 @@
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, relative, sep } from 'node:path';
22
+ import { join, resolve, basename } from 'node:path';
23
23
  /** Matches the header emitted by `generateEditableService` in service-generator.ts */
24
24
  export const OMNIFY_EDITABLE_SERVICE_MARKER = 'SAFE TO EDIT - This file is never overwritten by Omnify.';
25
25
  const EXTENDS_SERVICE_BASE_RE = /extends\s+[\w\\]+ServiceBase\b/;
@@ -65,80 +65,7 @@ function collectModularLayerDirs(configDir, config, category) {
65
65
  }
66
66
  return dirs;
67
67
  }
68
- const BASE_SUBDIRS_SKIP = new Set(['Base', 'OmnifyBase']);
69
- function findEditableSubclassFile(roots, fileName, excludeAbsPath) {
70
- for (const root of roots) {
71
- const found = findFileAbsolutePath(root, fileName);
72
- if (found && found !== excludeAbsPath)
73
- return true;
74
- }
75
- return false;
76
- }
77
- function findFileAbsolutePath(dir, fileName) {
78
- if (!existsSync(dir))
79
- return null;
80
- let info;
81
- try {
82
- info = statSync(dir);
83
- }
84
- catch {
85
- return null;
86
- }
87
- if (!info.isDirectory()) {
88
- return basename(dir) === fileName ? resolve(dir) : null;
89
- }
90
- for (const entry of readdirSync(dir)) {
91
- if (BASE_SUBDIRS_SKIP.has(entry))
92
- continue;
93
- const full = join(dir, entry);
94
- try {
95
- info = statSync(full);
96
- }
97
- catch {
98
- continue;
99
- }
100
- if (info.isDirectory()) {
101
- const nested = findFileAbsolutePath(full, fileName);
102
- if (nested)
103
- return nested;
104
- }
105
- else if (entry === fileName) {
106
- return resolve(full);
107
- }
108
- }
109
- return null;
110
- }
111
- function collectEditableSearchRoots(configDir, config, layer, modularCategory, baseAbsPath) {
112
- const roots = [];
113
- const add = (p) => {
114
- const abs = resolve(configDir, p);
115
- if (!roots.includes(abs))
116
- roots.push(abs);
117
- };
118
- add(layer.userEditablePath);
119
- if (layer.path !== layer.userEditablePath) {
120
- add(layer.path);
121
- }
122
- if (config.structure === 'modular') {
123
- const modulesAbs = resolve(configDir, config.modules.path);
124
- const rel = relative(modulesAbs, baseAbsPath);
125
- if (rel && !rel.startsWith('..') && !rel.startsWith(`..${sep}`)) {
126
- const segments = rel.split(/[/\\]/);
127
- if (segments.length >= 2 && segments[1] === modularCategory) {
128
- const schemaFolder = segments[0];
129
- add(join(layer.userEditablePath, schemaFolder));
130
- add(join(layer.path, schemaFolder));
131
- }
132
- }
133
- }
134
- return roots;
135
- }
136
- function baseToEditableFileName(baseFileName, baseSuffix, editableSuffix) {
137
- if (!baseFileName.endsWith(baseSuffix))
138
- return null;
139
- return `${baseFileName.slice(0, -baseSuffix.length)}${editableSuffix}`;
140
- }
141
- function pruneDisabledLayerBasesInRoots(configDir, config, spec, roots, expectedAbs) {
68
+ function pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced) {
142
69
  let pruned = 0;
143
70
  let kept = 0;
144
71
  for (const root of roots) {
@@ -150,13 +77,17 @@ function pruneDisabledLayerBasesInRoots(configDir, config, spec, roots, expected
150
77
  const fullPath = join(root, entry);
151
78
  if (expectedAbs.has(fullPath))
152
79
  continue;
153
- const editableName = spec.toEditableFileName(entry);
154
- if (editableName) {
155
- const editableRoots = collectEditableSearchRoots(configDir, config, spec.layerConfig, spec.modularCategory, fullPath);
156
- if (findEditableSubclassFile(editableRoots, editableName, fullPath)) {
157
- kept += 1;
158
- continue;
159
- }
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)) {
89
+ kept += 1;
90
+ continue;
160
91
  }
161
92
  try {
162
93
  unlinkSync(fullPath);
@@ -173,7 +104,7 @@ function pruneDisabledLayerBasesInRoots(configDir, config, spec, roots, expected
173
104
  * When a scaffold layer is disabled (issue #252), remove stale auto-generated
174
105
  * BASE files that have no matching user-editable subclass on disk.
175
106
  */
176
- export function pruneDisabledScaffoldLayerBases(configDir, config, generated) {
107
+ export function pruneDisabledScaffoldLayerBases(configDir, config, generated, referenced = collectReferencedClasses(configDir, config)) {
177
108
  const expectedAbs = new Set();
178
109
  for (const f of generated) {
179
110
  expectedAbs.add(resolve(configDir, f.path));
@@ -186,7 +117,7 @@ export function pruneDisabledScaffoldLayerBases(configDir, config, generated) {
186
117
  ...collectModularLayerDirs(configDir, config, spec.modularCategory),
187
118
  resolve(configDir, spec.legacyBasePath),
188
119
  ];
189
- const { pruned, kept } = pruneDisabledLayerBasesInRoots(configDir, config, spec, roots, expectedAbs);
120
+ const { pruned, kept } = pruneDisabledLayerBasesInRoots(spec, roots, expectedAbs, referenced);
190
121
  if (pruned > 0 || kept > 0) {
191
122
  summaries.push({
192
123
  layer: spec.layer,
@@ -199,22 +130,94 @@ export function pruneDisabledScaffoldLayerBases(configDir, config, generated) {
199
130
  return summaries;
200
131
  }
201
132
  /**
202
- * Issue #255: does a hand-written subclass exist on disk for this generated
203
- * base of a disabled scaffold layer? Same lookup the prune above uses to keep
204
- * a base, so a base is regenerated exactly when it would otherwise be kept.
133
+ * Issue #255: is this generated base of a disabled scaffold layer still used by
134
+ * hand-written code? Same question the prune above asks before deleting a base,
135
+ * so a base is regenerated exactly when it would otherwise be kept.
205
136
  */
206
- export function hasEditableSubclassForBase(configDir, config, file) {
137
+ export function hasEditableSubclassForBase(config, referenced, file) {
207
138
  const name = basename(file.path);
208
- for (const spec of scaffoldLayerSpecs(config)) {
209
- if (spec.enabled || !spec.matchesBaseFile(name))
139
+ const isDisabledScaffoldBase = scaffoldLayerSpecs(config).some((spec) => !spec.enabled && spec.matchesBaseFile(name));
140
+ if (!isDisabledScaffoldBase)
141
+ return false;
142
+ const fqcn = declaredClassName(file.content);
143
+ return fqcn !== null && referenced.has(fqcn);
144
+ }
145
+ /**
146
+ * Every class hand-written PHP under the Laravel root names in a `use` import
147
+ * or a fully-qualified `extends` (#260).
148
+ *
149
+ * "Is this base still used?" used to be answered by looking for a file named
150
+ * after it in the layer's configured directories. Real projects keep their
151
+ * subclasses wherever they like — `app/Policies/`, `app/Services/Promotion/` —
152
+ * so a used base looked unused and was deleted under running code. The import
153
+ * is what PHP resolves, so the import is what we check.
154
+ */
155
+ export function collectReferencedClasses(configDir, config) {
156
+ // Paths resolve against configDir, which for a workspace run is a cache
157
+ // directory; the Laravel app itself sits at rootPath.
158
+ const laravelRoot = resolve(configDir, config.rootPath);
159
+ const referenced = new Set();
160
+ for (const dir of REFERENCE_SCAN_DIRS) {
161
+ walkPhpFiles(resolve(laravelRoot, dir), (content) => {
162
+ if (GENERATED_MARKER.test(content))
163
+ return;
164
+ for (const m of content.matchAll(/^use\s+\\?([A-Za-z_][\w\\]*)(?:\s+as\s+\w+)?\s*;/gm)) {
165
+ referenced.add(m[1]);
166
+ }
167
+ for (const m of content.matchAll(/\bextends\s+\\([A-Za-z_][\w\\]*)/g)) {
168
+ referenced.add(m[1]);
169
+ }
170
+ // An unqualified parent with no import resolves in the file's own namespace.
171
+ const ns = content.match(/^namespace\s+([^;\s]+)\s*;/m)?.[1];
172
+ for (const m of content.matchAll(/\bextends\s+([A-Za-z_]\w*)\b/g)) {
173
+ referenced.add(ns ? `${ns}\\${m[1]}` : m[1]);
174
+ }
175
+ });
176
+ }
177
+ return referenced;
178
+ }
179
+ const REFERENCE_SCAN_DIRS = ['app', 'routes', 'tests', 'database', 'config'];
180
+ const GENERATED_MARKER = /auto-generated by Omnify/i;
181
+ const SKIP_DIRS = new Set(['vendor', 'node_modules', 'storage', '.git']);
182
+ function walkPhpFiles(dir, visit) {
183
+ let entries;
184
+ try {
185
+ entries = readdirSync(dir);
186
+ }
187
+ catch {
188
+ return;
189
+ }
190
+ for (const entry of entries) {
191
+ if (SKIP_DIRS.has(entry))
210
192
  continue;
211
- const editableName = spec.toEditableFileName(name);
212
- if (!editableName)
213
- return false;
214
- const roots = collectEditableSearchRoots(configDir, config, spec.layerConfig, spec.modularCategory, resolve(configDir, file.path));
215
- return findEditableSubclassFile(roots, editableName, resolve(configDir, file.path));
193
+ const full = join(dir, entry);
194
+ let info;
195
+ try {
196
+ info = statSync(full);
197
+ }
198
+ catch {
199
+ continue;
200
+ }
201
+ if (info.isDirectory()) {
202
+ walkPhpFiles(full, visit);
203
+ }
204
+ else if (entry.endsWith('.php')) {
205
+ try {
206
+ visit(readFileSync(full, 'utf-8'));
207
+ }
208
+ catch {
209
+ // unreadable file: nothing to learn from it
210
+ }
211
+ }
216
212
  }
217
- return false;
213
+ }
214
+ /** `Namespace\Class` a PHP file declares, or null. */
215
+ function declaredClassName(content) {
216
+ const ns = content.match(/^namespace\s+([^;\s]+)\s*;/m);
217
+ const cls = content.match(/^(?:(?:abstract|final|readonly)\s+)*(?:class|interface|trait|enum)\s+(\w+)/m);
218
+ if (!cls)
219
+ return null;
220
+ return ns ? `${ns[1]}\\${cls[1]}` : cls[1];
218
221
  }
219
222
  const SCAFFOLD_LAYER_BASE_SUFFIX = {
220
223
  request: 'RequestBase.php',
@@ -223,32 +226,17 @@ const SCAFFOLD_LAYER_BASE_SUFFIX = {
223
226
  controller: 'ControllerBase.php',
224
227
  service: 'ServiceBase.php',
225
228
  };
226
- const SCAFFOLD_LAYER_EDITABLE_SUFFIX = {
227
- request: 'Request.php',
228
- resource: 'Resource.php',
229
- policy: 'Policy.php',
230
- controller: 'Controller.php',
231
- service: 'Service.php',
232
- };
233
229
  /**
234
230
  * Legacy `flatBase` puts regenerated bases directly at `layer.basePath` with
235
231
  * the same filename as the user-editable stub (different directory). That
236
232
  * directory is generated-only: resolveBaseClass() always targets basePath and
237
233
  * only per-schema base classes are written there (issue #96).
238
234
  */
239
- function disabledLayerFileMatchers(config, layerConfig, baseSuffix, editableSuffix) {
240
- const modularMatches = (name) => name.endsWith(baseSuffix);
241
- const modularToEditable = (name) => baseToEditableFileName(name, baseSuffix, editableSuffix);
242
- if (config.structure === 'modular') {
243
- return { matchesBaseFile: modularMatches, toEditableFileName: modularToEditable };
244
- }
245
- if (layerConfig.flatBase) {
246
- return {
247
- matchesBaseFile: (name) => name.endsWith('.php'),
248
- toEditableFileName: (name) => (name.endsWith('.php') ? name : null),
249
- };
235
+ function disabledLayerFileMatchers(config, layerConfig, baseSuffix) {
236
+ if (config.structure !== 'modular' && layerConfig.flatBase) {
237
+ return { matchesBaseFile: (name) => name.endsWith('.php') };
250
238
  }
251
- return { matchesBaseFile: modularMatches, toEditableFileName: modularToEditable };
239
+ return { matchesBaseFile: (name) => name.endsWith(baseSuffix) };
252
240
  }
253
241
  function scaffoldLayerSpecs(config) {
254
242
  const layers = [
@@ -295,8 +283,7 @@ function scaffoldLayerSpecs(config) {
295
283
  ];
296
284
  return layers.map((entry) => {
297
285
  const baseSuffix = SCAFFOLD_LAYER_BASE_SUFFIX[entry.layer];
298
- const editableSuffix = SCAFFOLD_LAYER_EDITABLE_SUFFIX[entry.layer];
299
- const matchers = disabledLayerFileMatchers(config, entry.layerConfig, baseSuffix, editableSuffix);
286
+ const matchers = disabledLayerFileMatchers(config, entry.layerConfig, baseSuffix);
300
287
  return { ...entry, ...matchers };
301
288
  });
302
289
  }
@@ -43,7 +43,8 @@ export function buildRelation(propName, property, modelNamespace, context) {
43
43
  const related = relation === 'MorphTo'
44
44
  ? '\\Illuminate\\Database\\Eloquent\\Model'
45
45
  : '\\' + modelNamespace + '\\' + target;
46
- method = method.replace(' */', ` * @return ${type}<${related}, $this>\n */`);
46
+ // Blank line before the tag: Pint's `phpdoc_separation` (laravel preset).
47
+ method = method.replace(' */', ` *\n * @return ${type}<${related}, $this>\n */`);
47
48
  return { method, imports };
48
49
  }
49
50
  /** Generate the table name Laravel's joiningTable() infers from model basenames. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "6.8.1",
3
+ "version": "7.0.1",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",