@wp-typia/project-tools 0.24.9 → 0.24.11

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.
@@ -4,6 +4,8 @@ import { parseScaffoldBlockMetadata } from "@wp-typia/block-runtime/blocks";
4
4
  import { WORDPRESS_BLOCK_API_COMPATIBILITY } from "@wp-typia/block-types/blocks/compatibility";
5
5
  import { createDoctorCheck, resolveWorkspaceBootstrapPath, } from "./cli-doctor-workspace-shared.js";
6
6
  import { readJsonFileSync } from "../shared/json-utils.js";
7
+ import { hasPhpFunctionCallWithAssignedStringPrefixArgument, hasPhpFunctionCallWithStringArgumentPrefix, } from "../shared/php-utils.js";
8
+ import { hasExecutablePattern, hasUncommentedPattern, maskTypeScriptComments, maskTypeScriptCommentsAndLiterals, } from "../shared/ts-source-masking.js";
7
9
  import { compareVersionFloors, pickHigherVersionFloor, } from "../shared/version-floor.js";
8
10
  import { DEFAULT_SCAFFOLD_WORDPRESS_TARGET_VERSION } from "../templates/scaffold-compatibility.js";
9
11
  const WORDPRESS_VERSION_CHECK_CODES = {
@@ -15,9 +17,18 @@ const BLOCK_VARIATION_BLOCK_JSON_KEYS = {
15
17
  registrationBlockJsonMetadata: "variations",
16
18
  registrationMetadataFile: "variations",
17
19
  };
20
+ const CORE_VARIATION_REGISTRY_IMPORT_PATTERN = /^\s*import\s*(?!type\b)\{[\s\S]*?\}\s*from\s*["']\.\/[^"']+\/[^"']+\/[^"']+["']\s*;?\s*$/mu;
21
+ const REGISTER_BLOCK_VARIATION_CALL_PATTERN = /\bregisterBlockVariation\s*\(/u;
22
+ const REGISTER_WORKSPACE_CORE_VARIATIONS_CALL_PATTERN = /^\s*registerWorkspaceCoreVariations\s*\(\s*\)\s*;?\s*$/mu;
23
+ const REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN = /\bregisterBlockBindingsSource\s*\(/gu;
24
+ const GET_FIELDS_LIST_PROPERTY_PATTERN = /(?:async\s+)?\bgetFieldsList\s*\([^)]*\)|(?:async\s+)?["']getFieldsList["']\s*\([^)]*\)|\bgetFieldsList\s*:|["']getFieldsList["']\s*:|\bgetFieldsList\s*(?=,|\})/gu;
25
+ const SUPPORTED_ATTRIBUTES_FILTER_PREFIX = "block_bindings_supported_attributes_";
18
26
  function isEnabledMetadataValue(value) {
19
27
  return value !== undefined && value !== false && value !== null;
20
28
  }
29
+ function assertNeverBlockVariationFeature(feature) {
30
+ throw new Error(`Unhandled block variation metadata feature "${String(feature)}".`);
31
+ }
21
32
  function isEnabledBlockVariationMetadataFeature(feature, value) {
22
33
  if (feature === "registrationBlockJsonMetadata") {
23
34
  return Array.isArray(value) && value.length > 0;
@@ -25,7 +36,7 @@ function isEnabledBlockVariationMetadataFeature(feature, value) {
25
36
  if (feature === "registrationMetadataFile") {
26
37
  return typeof value === "string" && value.trim().length > 0;
27
38
  }
28
- return isEnabledMetadataValue(value);
39
+ return assertNeverBlockVariationFeature(feature);
29
40
  }
30
41
  function getNestedMetadataValue(object, key) {
31
42
  if (!object) {
@@ -80,6 +91,294 @@ function readExistingTextFile(filePath) {
80
91
  }
81
92
  return fs.readFileSync(filePath, "utf8");
82
93
  }
94
+ function isTypeScriptIdentifierPart(character) {
95
+ return /^[A-Za-z0-9_$]$/u.test(character ?? "");
96
+ }
97
+ function skipTypeScriptWhitespace(source, index) {
98
+ let cursor = index;
99
+ while (/\s/u.test(source[cursor] ?? "")) {
100
+ cursor += 1;
101
+ }
102
+ return cursor;
103
+ }
104
+ function findClosingDelimiter(source, openIndex, openDelimiter, closeDelimiter) {
105
+ let depth = 0;
106
+ for (let cursor = openIndex; cursor < source.length; cursor += 1) {
107
+ const character = source[cursor];
108
+ if (character === openDelimiter) {
109
+ depth += 1;
110
+ continue;
111
+ }
112
+ if (character === closeDelimiter) {
113
+ depth -= 1;
114
+ if (depth === 0) {
115
+ return cursor;
116
+ }
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+ function findTopLevelSatisfiesIndex(source) {
122
+ let braceDepth = 0;
123
+ let bracketDepth = 0;
124
+ let parenthesisDepth = 0;
125
+ for (let cursor = 0; cursor < source.length; cursor += 1) {
126
+ const character = source[cursor];
127
+ if (character === "{") {
128
+ braceDepth += 1;
129
+ continue;
130
+ }
131
+ if (character === "}") {
132
+ braceDepth = Math.max(0, braceDepth - 1);
133
+ continue;
134
+ }
135
+ if (character === "[") {
136
+ bracketDepth += 1;
137
+ continue;
138
+ }
139
+ if (character === "]") {
140
+ bracketDepth = Math.max(0, bracketDepth - 1);
141
+ continue;
142
+ }
143
+ if (character === "(") {
144
+ parenthesisDepth += 1;
145
+ continue;
146
+ }
147
+ if (character === ")") {
148
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
149
+ continue;
150
+ }
151
+ if (braceDepth === 0 &&
152
+ bracketDepth === 0 &&
153
+ parenthesisDepth === 0 &&
154
+ source.startsWith("satisfies", cursor) &&
155
+ !isTypeScriptIdentifierPart(source[cursor - 1]) &&
156
+ !isTypeScriptIdentifierPart(source[cursor + "satisfies".length])) {
157
+ return cursor;
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ function isTopLevelObjectPropertyStart(structureMaskedObjectSource, index) {
163
+ let braceDepth = 0;
164
+ let bracketDepth = 0;
165
+ let parenthesisDepth = 0;
166
+ for (let cursor = 0; cursor < index; cursor += 1) {
167
+ const character = structureMaskedObjectSource[cursor];
168
+ if (character === "{") {
169
+ braceDepth += 1;
170
+ continue;
171
+ }
172
+ if (character === "}") {
173
+ braceDepth = Math.max(0, braceDepth - 1);
174
+ continue;
175
+ }
176
+ if (character === "[") {
177
+ bracketDepth += 1;
178
+ continue;
179
+ }
180
+ if (character === "]") {
181
+ bracketDepth = Math.max(0, bracketDepth - 1);
182
+ continue;
183
+ }
184
+ if (character === "(") {
185
+ parenthesisDepth += 1;
186
+ continue;
187
+ }
188
+ if (character === ")") {
189
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
190
+ }
191
+ }
192
+ if (braceDepth !== 1 || bracketDepth !== 0 || parenthesisDepth !== 0) {
193
+ return false;
194
+ }
195
+ let previousCursor = index - 1;
196
+ while (previousCursor >= 0 &&
197
+ /\s/u.test(structureMaskedObjectSource[previousCursor] ?? "")) {
198
+ previousCursor -= 1;
199
+ }
200
+ const previousToken = structureMaskedObjectSource[previousCursor];
201
+ return previousToken === "{" || previousToken === ",";
202
+ }
203
+ function objectLiteralHasGetFieldsListProperty(commentMaskedObjectSource, structureMaskedObjectSource) {
204
+ GET_FIELDS_LIST_PROPERTY_PATTERN.lastIndex = 0;
205
+ let match;
206
+ while ((match = GET_FIELDS_LIST_PROPERTY_PATTERN.exec(commentMaskedObjectSource))) {
207
+ if (isTopLevelObjectPropertyStart(structureMaskedObjectSource, match.index)) {
208
+ GET_FIELDS_LIST_PROPERTY_PATTERN.lastIndex = 0;
209
+ return true;
210
+ }
211
+ }
212
+ GET_FIELDS_LIST_PROPERTY_PATTERN.lastIndex = 0;
213
+ return false;
214
+ }
215
+ function getRuntimeArgumentEnd(maskedCallArgumentsSource) {
216
+ return (findTopLevelSatisfiesIndex(maskedCallArgumentsSource) ??
217
+ maskedCallArgumentsSource.length);
218
+ }
219
+ function getFirstObjectArgumentSpan(structureMaskedRuntimeArgumentsSource, absoluteStart) {
220
+ const objectStartOffset = skipTypeScriptWhitespace(structureMaskedRuntimeArgumentsSource, 0);
221
+ if (structureMaskedRuntimeArgumentsSource[objectStartOffset] !== "{") {
222
+ return undefined;
223
+ }
224
+ const objectEndOffset = findClosingDelimiter(structureMaskedRuntimeArgumentsSource, objectStartOffset, "{", "}");
225
+ if (objectEndOffset === null) {
226
+ return undefined;
227
+ }
228
+ return {
229
+ end: absoluteStart + objectEndOffset + 1,
230
+ start: absoluteStart + objectStartOffset,
231
+ };
232
+ }
233
+ function getSimpleRuntimeArgumentIdentifier(structureMaskedRuntimeArgumentsSource) {
234
+ const trimmed = structureMaskedRuntimeArgumentsSource.trim();
235
+ const match = /^([A-Za-z_$][\w$]*)(?:\s+as\b[\s\S]*)?$/u.exec(trimmed);
236
+ return match?.[1];
237
+ }
238
+ function findObjectInitializerStart(structureMaskedSource, index) {
239
+ let braceDepth = 0;
240
+ let bracketDepth = 0;
241
+ let parenthesisDepth = 0;
242
+ for (let cursor = index; cursor < structureMaskedSource.length; cursor += 1) {
243
+ const character = structureMaskedSource[cursor];
244
+ if (character === "{") {
245
+ braceDepth += 1;
246
+ continue;
247
+ }
248
+ if (character === "}") {
249
+ braceDepth = Math.max(0, braceDepth - 1);
250
+ continue;
251
+ }
252
+ if (character === "[") {
253
+ bracketDepth += 1;
254
+ continue;
255
+ }
256
+ if (character === "]") {
257
+ bracketDepth = Math.max(0, bracketDepth - 1);
258
+ continue;
259
+ }
260
+ if (character === "(") {
261
+ parenthesisDepth += 1;
262
+ continue;
263
+ }
264
+ if (character === ")") {
265
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
266
+ continue;
267
+ }
268
+ if (braceDepth !== 0 || bracketDepth !== 0 || parenthesisDepth !== 0) {
269
+ continue;
270
+ }
271
+ if (character === ";") {
272
+ return null;
273
+ }
274
+ if (character !== "=" || structureMaskedSource[cursor + 1] === ">") {
275
+ continue;
276
+ }
277
+ const valueStart = skipTypeScriptWhitespace(structureMaskedSource, cursor + 1);
278
+ return structureMaskedSource[valueStart] === "{" ? valueStart : null;
279
+ }
280
+ return null;
281
+ }
282
+ function isTopLevelTypeScriptPosition(structureMaskedSource, index) {
283
+ let braceDepth = 0;
284
+ let bracketDepth = 0;
285
+ let parenthesisDepth = 0;
286
+ for (let cursor = 0; cursor < index; cursor += 1) {
287
+ const character = structureMaskedSource[cursor];
288
+ if (character === "{") {
289
+ braceDepth += 1;
290
+ continue;
291
+ }
292
+ if (character === "}") {
293
+ braceDepth = Math.max(0, braceDepth - 1);
294
+ continue;
295
+ }
296
+ if (character === "[") {
297
+ bracketDepth += 1;
298
+ continue;
299
+ }
300
+ if (character === "]") {
301
+ bracketDepth = Math.max(0, bracketDepth - 1);
302
+ continue;
303
+ }
304
+ if (character === "(") {
305
+ parenthesisDepth += 1;
306
+ continue;
307
+ }
308
+ if (character === ")") {
309
+ parenthesisDepth = Math.max(0, parenthesisDepth - 1);
310
+ }
311
+ }
312
+ return braceDepth === 0 && bracketDepth === 0 && parenthesisDepth === 0;
313
+ }
314
+ function variableObjectHasGetFieldsListProperty(identifier, commentMaskedSource, structureMaskedSource, beforeIndex) {
315
+ const variablePattern = /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\b/gu;
316
+ let match;
317
+ let nearestObjectSpan;
318
+ while ((match = variablePattern.exec(structureMaskedSource))) {
319
+ if (match.index >= beforeIndex) {
320
+ break;
321
+ }
322
+ if (match[1] !== identifier) {
323
+ continue;
324
+ }
325
+ if (!isTopLevelTypeScriptPosition(structureMaskedSource, match.index)) {
326
+ continue;
327
+ }
328
+ const objectStart = findObjectInitializerStart(structureMaskedSource, match.index + match[0].length);
329
+ if (objectStart === null || objectStart >= beforeIndex) {
330
+ continue;
331
+ }
332
+ const objectEnd = findClosingDelimiter(structureMaskedSource, objectStart, "{", "}");
333
+ if (objectEnd === null) {
334
+ continue;
335
+ }
336
+ nearestObjectSpan = {
337
+ end: objectEnd + 1,
338
+ start: objectStart,
339
+ };
340
+ }
341
+ return nearestObjectSpan
342
+ ? objectLiteralHasGetFieldsListProperty(commentMaskedSource.slice(nearestObjectSpan.start, nearestObjectSpan.end), structureMaskedSource.slice(nearestObjectSpan.start, nearestObjectSpan.end))
343
+ : false;
344
+ }
345
+ function hasRegisterBlockBindingsSourceGetFieldsList(source) {
346
+ const structureMaskedSource = maskTypeScriptCommentsAndLiterals(source);
347
+ const commentMaskedSource = maskTypeScriptComments(source);
348
+ REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN.lastIndex = 0;
349
+ let match;
350
+ while ((match =
351
+ REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN.exec(structureMaskedSource))) {
352
+ const openIndex = structureMaskedSource.indexOf("(", match.index);
353
+ if (openIndex === -1) {
354
+ continue;
355
+ }
356
+ const closeIndex = findClosingDelimiter(structureMaskedSource, openIndex, "(", ")");
357
+ if (closeIndex === null) {
358
+ continue;
359
+ }
360
+ const maskedCallArgumentsSource = structureMaskedSource.slice(openIndex + 1, closeIndex);
361
+ const runtimeArgumentEnd = getRuntimeArgumentEnd(maskedCallArgumentsSource);
362
+ const runtimeArgumentsStart = openIndex + 1;
363
+ const maskedRuntimeArgumentsSource = structureMaskedSource.slice(runtimeArgumentsStart, runtimeArgumentsStart + runtimeArgumentEnd);
364
+ const objectArgumentSpan = getFirstObjectArgumentSpan(maskedRuntimeArgumentsSource, runtimeArgumentsStart);
365
+ if (objectArgumentSpan &&
366
+ objectLiteralHasGetFieldsListProperty(commentMaskedSource.slice(objectArgumentSpan.start, objectArgumentSpan.end), structureMaskedSource.slice(objectArgumentSpan.start, objectArgumentSpan.end))) {
367
+ return true;
368
+ }
369
+ const runtimeArgumentIdentifier = getSimpleRuntimeArgumentIdentifier(maskedRuntimeArgumentsSource);
370
+ if (runtimeArgumentIdentifier &&
371
+ variableObjectHasGetFieldsListProperty(runtimeArgumentIdentifier, commentMaskedSource, structureMaskedSource, match.index)) {
372
+ return true;
373
+ }
374
+ REGISTER_BLOCK_BINDINGS_SOURCE_CALL_PATTERN.lastIndex = closeIndex + 1;
375
+ }
376
+ return false;
377
+ }
378
+ function hasSupportedAttributesFilterRegistration(source) {
379
+ return (hasPhpFunctionCallWithStringArgumentPrefix(source, "add_filter", SUPPORTED_ATTRIBUTES_FILTER_PREFIX) ||
380
+ hasPhpFunctionCallWithAssignedStringPrefixArgument(source, "add_filter", SUPPORTED_ATTRIBUTES_FILTER_PREFIX));
381
+ }
83
382
  function collectBlockMetadataRequirements(workspace, inventory) {
84
383
  const issues = [];
85
384
  const requirements = [];
@@ -131,23 +430,16 @@ function collectBlockMetadataRequirements(workspace, inventory) {
131
430
  requirements,
132
431
  };
133
432
  }
134
- function hasGeneratedCoreVariationModule(directoryPath, isRootDirectory = true) {
135
- if (!fs.existsSync(directoryPath)) {
433
+ function hasGeneratedCoreVariationRegistry(projectDir) {
434
+ // Convention: generated core variation registries are rooted at this entrypoint.
435
+ const registryPath = path.join(projectDir, "src", "editor-plugins", "core-variations", "index.ts");
436
+ const source = readExistingTextFile(registryPath);
437
+ if (!source) {
136
438
  return false;
137
439
  }
138
- for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) {
139
- const entryPath = path.join(directoryPath, entry.name);
140
- if (entry.isDirectory() && hasGeneratedCoreVariationModule(entryPath, false)) {
141
- return true;
142
- }
143
- if (!isRootDirectory &&
144
- entry.isFile() &&
145
- entry.name.endsWith(".ts") &&
146
- entry.name !== "index.ts") {
147
- return true;
148
- }
149
- }
150
- return false;
440
+ return (hasUncommentedPattern(source, CORE_VARIATION_REGISTRY_IMPORT_PATTERN) &&
441
+ hasExecutablePattern(source, REGISTER_BLOCK_VARIATION_CALL_PATTERN) &&
442
+ hasExecutablePattern(source, REGISTER_WORKSPACE_CORE_VARIATIONS_CALL_PATTERN));
151
443
  }
152
444
  function collectVariationRequirements(workspace, inventory) {
153
445
  const requirements = [];
@@ -155,8 +447,7 @@ function collectVariationRequirements(workspace, inventory) {
155
447
  for (const variation of inventory.variations) {
156
448
  pushBlockApiRequirement(requirements, `Variation ${variation.block}/${variation.slug}`, variationEntries.editorRegistration);
157
449
  }
158
- const coreVariationsDir = path.join(workspace.projectDir, "src", "editor-plugins", "core-variations");
159
- if (hasGeneratedCoreVariationModule(coreVariationsDir)) {
450
+ if (hasGeneratedCoreVariationRegistry(workspace.projectDir)) {
160
451
  pushBlockApiRequirement(requirements, "Core variations editor plugin", variationEntries.editorRegistration);
161
452
  }
162
453
  return {
@@ -196,11 +487,14 @@ function collectBindingSourceRequirements(workspace, inventory) {
196
487
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.serverRegistration);
197
488
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.editorRegistration);
198
489
  const editorFilePath = path.join(workspace.projectDir, bindingSource.editorFile);
199
- if (readExistingTextFile(editorFilePath)?.includes("getFieldsList")) {
490
+ const editorSource = readExistingTextFile(editorFilePath);
491
+ if (editorSource &&
492
+ hasRegisterBlockBindingsSourceGetFieldsList(editorSource)) {
200
493
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.editorFieldsList);
201
494
  }
202
495
  const serverFilePath = path.join(workspace.projectDir, bindingSource.serverFile);
203
- if (readExistingTextFile(serverFilePath)?.includes("block_bindings_supported_attributes_")) {
496
+ const serverSource = readExistingTextFile(serverFilePath);
497
+ if (serverSource && hasSupportedAttributesFilterRegistration(serverSource)) {
204
498
  pushBlockApiRequirement(requirements, `Binding source ${bindingSource.slug}`, bindingEntries.supportedAttributesFilter);
205
499
  }
206
500
  }
@@ -247,6 +541,9 @@ function formatRequirementSummary(requirements, floor) {
247
541
  .map((requirement) => requirement.label);
248
542
  return labels.length > 0 ? labels.join(", ") : "generated workspace features";
249
543
  }
544
+ function formatFeatureMinimumAction(floor) {
545
+ return `Update the plugin bootstrap \`Requires at least\` header to ${floor} or remove the features that require that floor.`;
546
+ }
250
547
  function createFeatureMinimumCheck(workspace, inventory, headers) {
251
548
  const { issues, requirements } = collectWordPressVersionRequirements(workspace, inventory);
252
549
  const highestFloor = pickHighestRequirementFloor(requirements, issues);
@@ -257,11 +554,11 @@ function createFeatureMinimumCheck(workspace, inventory, headers) {
257
554
  return createDoctorCheck("WordPress feature minimum", "pass", "No generated workspace features declare an additional WordPress hard floor.", WORDPRESS_VERSION_CHECK_CODES.featureMinimum);
258
555
  }
259
556
  if (!headers.requiresAtLeast) {
260
- return createDoctorCheck("WordPress feature minimum", "fail", `Plugin bootstrap is missing a Requires at least header but generated features require WordPress ${highestFloor}.`, WORDPRESS_VERSION_CHECK_CODES.featureMinimum);
557
+ return createDoctorCheck("WordPress feature minimum", "fail", `Plugin bootstrap is missing a Requires at least header but generated features require WordPress ${highestFloor}. ${formatFeatureMinimumAction(highestFloor)}`, WORDPRESS_VERSION_CHECK_CODES.featureMinimum);
261
558
  }
262
559
  try {
263
560
  if (compareVersionFloors(headers.requiresAtLeast, highestFloor) < 0) {
264
- return createDoctorCheck("WordPress feature minimum", "fail", `Requires at least ${headers.requiresAtLeast} is below generated feature floor ${highestFloor} (${formatRequirementSummary(requirements, highestFloor)}).`, WORDPRESS_VERSION_CHECK_CODES.featureMinimum);
561
+ return createDoctorCheck("WordPress feature minimum", "fail", `Requires at least ${headers.requiresAtLeast} is below generated feature floor ${highestFloor} (${formatRequirementSummary(requirements, highestFloor)}). ${formatFeatureMinimumAction(highestFloor)}`, WORDPRESS_VERSION_CHECK_CODES.featureMinimum);
265
562
  }
266
563
  }
267
564
  catch {
@@ -275,7 +572,7 @@ function createTestedTargetCheck(headers, targetVersion) {
275
572
  }
276
573
  try {
277
574
  if (compareVersionFloors(headers.testedUpTo, targetVersion) < 0) {
278
- return createDoctorCheck("WordPress tested target", "warn", `Tested up to ${headers.testedUpTo} is below the selected WordPress target ${targetVersion}.`, WORDPRESS_VERSION_CHECK_CODES.testedTarget);
575
+ return createDoctorCheck("WordPress tested target", "warn", `Tested up to ${headers.testedUpTo} is below the selected WordPress target ${targetVersion}. Update the plugin bootstrap \`Tested up to\` header when the workspace is verified against WordPress ${targetVersion}.`, WORDPRESS_VERSION_CHECK_CODES.testedTarget);
279
576
  }
280
577
  }
281
578
  catch {
@@ -20,6 +20,32 @@ export declare function hasPhpFunctionDefinition(source: string, functionName: s
20
20
  * @returns Whether `source` contains a code-mode call to `functionName`.
21
21
  */
22
22
  export declare function hasPhpFunctionCall(source: string, functionName: string): boolean;
23
+ /**
24
+ * Detect a PHP function call whose first argument is a variable previously
25
+ * assigned from a literal string prefix in executable PHP code.
26
+ */
27
+ export declare function hasPhpFunctionCallWithAssignedStringPrefixArgument(source: string, functionName: string, literalPrefix: string): boolean;
28
+ /**
29
+ * Detect a PHP function call whose first argument is a literal string value.
30
+ *
31
+ * This uses the same code-mode scanner as {@link hasPhpFunctionCall}, so
32
+ * comments, quoted strings, heredoc, and nowdoc content cannot create matches.
33
+ */
34
+ export declare function hasPhpFunctionCallWithStringArgument(source: string, functionName: string, literalArgument: string): boolean;
35
+ /**
36
+ * Detect a PHP function call whose first argument starts with a literal string prefix.
37
+ *
38
+ * Concatenated expressions are accepted here so dynamic WordPress hooks such as
39
+ * `'block_bindings_supported_attributes_' . $block_type` remain detectable.
40
+ */
41
+ export declare function hasPhpFunctionCallWithStringArgumentPrefix(source: string, functionName: string, literalPrefix: string): boolean;
42
+ /**
43
+ * Detect a code-mode PHP string literal that starts with a literal prefix.
44
+ *
45
+ * This is useful for dynamic hook names stored in variables before a later
46
+ * global function call consumes the variable.
47
+ */
48
+ export declare function hasPhpCodeStringLiteralPrefix(source: string, literalPrefix: string): boolean;
23
49
  /**
24
50
  * Locate a PHP function body without counting braces in non-code regions.
25
51
  *
@@ -132,6 +132,15 @@ function matchesPhpFunctionCallAt(source, index, functionName) {
132
132
  if (isPhpIdentifierPart(source[index - 1])) {
133
133
  return false;
134
134
  }
135
+ let previousCursor = index - 1;
136
+ while (previousCursor >= 0 && /\s/u.test(source[previousCursor] ?? "")) {
137
+ previousCursor -= 1;
138
+ }
139
+ const previousToken = source[previousCursor];
140
+ if ((previousToken === ">" && source[previousCursor - 1] === "-") ||
141
+ (previousToken === ":" && source[previousCursor - 1] === ":")) {
142
+ return false;
143
+ }
135
144
  const cursor = index + functionName.length;
136
145
  if (isPhpIdentifierPart(source[cursor])) {
137
146
  return false;
@@ -139,6 +148,60 @@ function matchesPhpFunctionCallAt(source, index, functionName) {
139
148
  const callStart = skipPhpCallTrivia(source, cursor);
140
149
  return callStart !== null && source[callStart] === "(";
141
150
  }
151
+ function parsePhpQuotedStringLiteralAt(source, index) {
152
+ const quote = source[index];
153
+ if (quote !== "'" && quote !== '"') {
154
+ return null;
155
+ }
156
+ let cursor = index + 1;
157
+ let value = "";
158
+ while (cursor < source.length) {
159
+ const character = source[cursor];
160
+ if (character === "\\") {
161
+ const escapedCharacter = source[cursor + 1];
162
+ if (escapedCharacter === undefined) {
163
+ return null;
164
+ }
165
+ value += escapedCharacter;
166
+ cursor += 2;
167
+ continue;
168
+ }
169
+ if (character === quote) {
170
+ return {
171
+ end: cursor + 1,
172
+ value,
173
+ };
174
+ }
175
+ value += character;
176
+ cursor += 1;
177
+ }
178
+ return null;
179
+ }
180
+ function parsePhpVariableNameAt(source, index) {
181
+ if (source[index] !== "$") {
182
+ return null;
183
+ }
184
+ const nameStart = index + 1;
185
+ if (!isPhpIdentifierStart(source[nameStart])) {
186
+ return null;
187
+ }
188
+ let cursor = nameStart + 1;
189
+ while (isPhpIdentifierPart(source[cursor])) {
190
+ cursor += 1;
191
+ }
192
+ return {
193
+ end: cursor,
194
+ name: source.slice(nameStart, cursor),
195
+ };
196
+ }
197
+ function getPhpFunctionCallFirstArgumentStart(source, index, functionName) {
198
+ const cursor = index + functionName.length;
199
+ const callStart = skipPhpCallTrivia(source, cursor);
200
+ if (callStart === null || source[callStart] !== "(") {
201
+ return null;
202
+ }
203
+ return skipPhpCallTrivia(source, callStart + 1);
204
+ }
142
205
  function createPhpScannerState() {
143
206
  return {
144
207
  heredocDelimiter: "",
@@ -309,6 +372,211 @@ export function hasPhpFunctionCall(source, functionName) {
309
372
  }
310
373
  return false;
311
374
  }
375
+ function isPhpAssignmentOperatorAt(source, index) {
376
+ if (index === null || source[index] !== "=") {
377
+ return false;
378
+ }
379
+ const previousToken = source[index - 1];
380
+ const nextToken = source[index + 1];
381
+ return (previousToken !== "=" &&
382
+ previousToken !== "!" &&
383
+ previousToken !== "<" &&
384
+ previousToken !== ">" &&
385
+ previousToken !== "." &&
386
+ nextToken !== "=" &&
387
+ nextToken !== ">");
388
+ }
389
+ function isSupportedPhpAssignedStringSuffix(source, index) {
390
+ const nextTokenIndex = skipPhpCallTrivia(source, index);
391
+ const nextToken = nextTokenIndex === null ? undefined : source[nextTokenIndex];
392
+ return nextToken === "." || nextToken === ";" || nextToken === undefined;
393
+ }
394
+ function getPhpFunctionCallFirstVariableArgumentName(source, index, functionName) {
395
+ const argumentStart = getPhpFunctionCallFirstArgumentStart(source, index, functionName);
396
+ if (argumentStart === null) {
397
+ return undefined;
398
+ }
399
+ const variable = parsePhpVariableNameAt(source, argumentStart);
400
+ if (!variable) {
401
+ return undefined;
402
+ }
403
+ const argumentEnd = skipPhpCallTrivia(source, variable.end);
404
+ const nextToken = argumentEnd === null ? undefined : source[argumentEnd];
405
+ return nextToken === "," || nextToken === ")" || nextToken === undefined
406
+ ? variable.name
407
+ : undefined;
408
+ }
409
+ /**
410
+ * Detect a PHP function call whose first argument is a variable previously
411
+ * assigned from a literal string prefix in executable PHP code.
412
+ */
413
+ export function hasPhpFunctionCallWithAssignedStringPrefixArgument(source, functionName, literalPrefix) {
414
+ const variablesWithPrefix = new Set();
415
+ const scanner = createPhpScannerState();
416
+ let index = 0;
417
+ while (index < source.length) {
418
+ const scan = advancePhpScanner(source, index, scanner);
419
+ if (scan.ambiguous) {
420
+ return false;
421
+ }
422
+ if (!scan.inCode) {
423
+ index = scan.index;
424
+ continue;
425
+ }
426
+ const variable = parsePhpVariableNameAt(source, index);
427
+ if (variable) {
428
+ const assignmentStart = skipPhpCallTrivia(source, variable.end);
429
+ if (assignmentStart !== null &&
430
+ isPhpAssignmentOperatorAt(source, assignmentStart)) {
431
+ const literalStart = skipPhpCallTrivia(source, assignmentStart + 1);
432
+ const literal = literalStart === null
433
+ ? null
434
+ : parsePhpQuotedStringLiteralAt(source, literalStart);
435
+ if (literal &&
436
+ literal.value.startsWith(literalPrefix) &&
437
+ isSupportedPhpAssignedStringSuffix(source, literal.end)) {
438
+ variablesWithPrefix.add(variable.name);
439
+ index = literal.end;
440
+ continue;
441
+ }
442
+ variablesWithPrefix.delete(variable.name);
443
+ }
444
+ }
445
+ if (matchesPhpFunctionCallAt(source, index, functionName)) {
446
+ const variableArgumentName = getPhpFunctionCallFirstVariableArgumentName(source, index, functionName);
447
+ if (variableArgumentName &&
448
+ variablesWithPrefix.has(variableArgumentName)) {
449
+ return true;
450
+ }
451
+ index += functionName.length;
452
+ continue;
453
+ }
454
+ index += 1;
455
+ }
456
+ return false;
457
+ }
458
+ /**
459
+ * Detect a PHP function call whose first argument is a literal string value.
460
+ *
461
+ * This uses the same code-mode scanner as {@link hasPhpFunctionCall}, so
462
+ * comments, quoted strings, heredoc, and nowdoc content cannot create matches.
463
+ */
464
+ export function hasPhpFunctionCallWithStringArgument(source, functionName, literalArgument) {
465
+ return hasPhpFunctionCallWithStringArgumentMatching(source, functionName, (value) => value === literalArgument, { allowConcatenatedPrefix: false });
466
+ }
467
+ /**
468
+ * Detect a PHP function call whose first argument starts with a literal string prefix.
469
+ *
470
+ * Concatenated expressions are accepted here so dynamic WordPress hooks such as
471
+ * `'block_bindings_supported_attributes_' . $block_type` remain detectable.
472
+ */
473
+ export function hasPhpFunctionCallWithStringArgumentPrefix(source, functionName, literalPrefix) {
474
+ return hasPhpFunctionCallWithStringArgumentMatching(source, functionName, (value) => value.startsWith(literalPrefix), { allowConcatenatedPrefix: true });
475
+ }
476
+ /**
477
+ * Detect a code-mode PHP string literal that starts with a literal prefix.
478
+ *
479
+ * This is useful for dynamic hook names stored in variables before a later
480
+ * global function call consumes the variable.
481
+ */
482
+ export function hasPhpCodeStringLiteralPrefix(source, literalPrefix) {
483
+ let index = 0;
484
+ while (index < source.length) {
485
+ if (source.startsWith("<?php", index)) {
486
+ index += 5;
487
+ continue;
488
+ }
489
+ if (source.startsWith("<?", index) || source.startsWith("?>", index)) {
490
+ index += 2;
491
+ continue;
492
+ }
493
+ if (source[index] === "/" && source[index + 1] === "*") {
494
+ const commentEnd = source.indexOf("*/", index + 2);
495
+ if (commentEnd === -1) {
496
+ return false;
497
+ }
498
+ index = commentEnd + 2;
499
+ continue;
500
+ }
501
+ const literal = parsePhpQuotedStringLiteralAt(source, index);
502
+ if (literal) {
503
+ if (literal.value.startsWith(literalPrefix)) {
504
+ return true;
505
+ }
506
+ index = literal.end;
507
+ continue;
508
+ }
509
+ if (source[index] === "/" && source[index + 1] === "/") {
510
+ index = findPhpLineBoundary(source, index + 2).nextStart;
511
+ continue;
512
+ }
513
+ if (source[index] === "#" && source[index + 1] !== "[") {
514
+ index = findPhpLineBoundary(source, index + 1).nextStart;
515
+ continue;
516
+ }
517
+ const heredoc = parsePhpHeredocStart(source, index);
518
+ if (heredoc) {
519
+ let cursor = heredoc.contentStart;
520
+ let closingEnd = null;
521
+ while (cursor < source.length) {
522
+ closingEnd = findPhpHeredocClosingEnd(source, cursor, heredoc.delimiter);
523
+ if (closingEnd !== null) {
524
+ break;
525
+ }
526
+ cursor = findPhpLineBoundary(source, cursor).nextStart;
527
+ }
528
+ if (closingEnd === null) {
529
+ return false;
530
+ }
531
+ index = findPhpLineBoundary(source, closingEnd).nextStart;
532
+ continue;
533
+ }
534
+ index += 1;
535
+ }
536
+ return false;
537
+ }
538
+ function hasPhpFunctionCallWithStringArgumentMatching(source, functionName, matchesArgument, options) {
539
+ const scanner = createPhpScannerState();
540
+ let index = 0;
541
+ while (index < source.length) {
542
+ const scan = advancePhpScanner(source, index, scanner);
543
+ if (scan.ambiguous) {
544
+ return false;
545
+ }
546
+ if (!scan.inCode) {
547
+ index = scan.index;
548
+ continue;
549
+ }
550
+ if (!matchesPhpFunctionCallAt(source, index, functionName)) {
551
+ index += 1;
552
+ continue;
553
+ }
554
+ const argumentStart = getPhpFunctionCallFirstArgumentStart(source, index, functionName);
555
+ if (argumentStart === null) {
556
+ index += functionName.length;
557
+ continue;
558
+ }
559
+ const argument = parsePhpQuotedStringLiteralAt(source, argumentStart);
560
+ if (!argument) {
561
+ index += functionName.length;
562
+ continue;
563
+ }
564
+ const argumentEnd = skipPhpCallTrivia(source, argument.end);
565
+ const nextToken = argumentEnd === null ? undefined : source[argumentEnd];
566
+ const isCompleteLiteralArgument = nextToken === "," || nextToken === ")" || nextToken === undefined;
567
+ const isSupportedPrefixExpression = options.allowConcatenatedPrefix && nextToken === ".";
568
+ if (!isCompleteLiteralArgument &&
569
+ !isSupportedPrefixExpression) {
570
+ index += functionName.length;
571
+ continue;
572
+ }
573
+ if (matchesArgument(argument.value)) {
574
+ return true;
575
+ }
576
+ index += functionName.length;
577
+ }
578
+ return false;
579
+ }
312
580
  /**
313
581
  * Locate a PHP function body without counting braces in non-code regions.
314
582
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wp-typia/project-tools",
3
- "version": "0.24.9",
3
+ "version": "0.24.11",
4
4
  "description": "Project orchestration and programmatic tooling for wp-typia",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "type": "module",