From 94f75aff0191750fcc4cd4c27106d156a1361950 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Thu, 3 Sep 2026 18:22:12 +0900 Subject: [PATCH 1/7] Add actor-preferred-username-required rule (#895) @fedify/lint's existing "required property" rules all pair a property with a Context getter method (e.g. `id` <-> getActorUri()), so their generated error message can point at the right method to call. `preferredUsername` has no such getter -- it's a plain literal set directly on the actor -- so PropertyConfig.getter is now optional, and actorPropertyRequired() falls back to a simpler message when it's absent. This required generalizing test-templates.ts, which assumed every property has a getter in a couple of places: property-assignment code generation (used by the "good" test fixtures) and the mismatch tests' "wrong getter" picker. Both are fixed to skip getter-less properties instead of producing broken code or failing to compile. Assisted-by: Claude Code:claude-sonnet-5 --- CHANGES.md | 6 + .../lint/actor-preferred-username-rule.md | 3 + packages/lint/src/index.ts | 4 + packages/lint/src/lib/const.ts | 7 + packages/lint/src/lib/messages.ts | 30 ++-- packages/lint/src/lib/mismatch.ts | 6 +- packages/lint/src/lib/test-templates.ts | 145 +++++++++++++++++- packages/lint/src/lib/types.ts | 2 +- packages/lint/src/mod.ts | 4 + packages/lint/src/oxlint.ts | 4 + .../actor-preferred-username-required.ts | 8 + .../actor-preferred-username-required.test.ts | 13 ++ packages/lint/src/tests/integration.test.ts | 11 ++ 13 files changed, 219 insertions(+), 24 deletions(-) create mode 100644 changes.d/lint/actor-preferred-username-rule.md create mode 100644 packages/lint/src/rules/actor-preferred-username-required.ts create mode 100644 packages/lint/src/tests/actor-preferred-username-required.test.ts diff --git a/CHANGES.md b/CHANGES.md index 92cd12d48..4a3f0173a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -233,6 +233,12 @@ To be released. `endpoints.uploadMedia` is not built with `ctx.getMediaUploaderUri(identifier)`. + - Added the `actor-preferred-username-required` lint rule, which warns + when an actor dispatcher's return value does not include a + `preferredUsername` property. [[#895]] + +[#895]: https://github.com/fedify-dev/fedify/issues/895 + ### @fedify/mysql - Fixed the CommonJS MySQL adapter build so it no longer requires diff --git a/changes.d/lint/actor-preferred-username-rule.md b/changes.d/lint/actor-preferred-username-rule.md new file mode 100644 index 000000000..1a539a5d6 --- /dev/null +++ b/changes.d/lint/actor-preferred-username-rule.md @@ -0,0 +1,3 @@ + - Added the `actor-preferred-username-required` lint rule, which warns + when an actor dispatcher's return value does not include a + `preferredUsername` property. [[#895]] diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 703f18c17..e7a4a1b49 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -54,6 +54,9 @@ import { import { eslint as actorOutboxPropertyRequired, } from "./rules/actor-outbox-property-required.ts"; +import { + eslint as actorPreferredUsernameRequired, +} from "./rules/actor-preferred-username-required.ts"; import { eslint as actorPublicKeyRequired, } from "./rules/actor-public-key-required.ts"; @@ -110,6 +113,7 @@ const rules: Record< [RULE_IDS.actorUploadMediaPropertyMismatch]: actorUploadMediaPropertyMismatch, [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, + [RULE_IDS.actorPreferredUsernameRequired]: actorPreferredUsernameRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, [RULE_IDS.outboxListenerDeliveryRequired]: outboxListenerDeliveryRequired, [RULE_IDS.mediaUploaderObjectUriRequired]: mediaUploaderObjectUriRequired, diff --git a/packages/lint/src/lib/const.ts b/packages/lint/src/lib/const.ts index a659b52a1..4720e69a1 100644 --- a/packages/lint/src/lib/const.ts +++ b/packages/lint/src/lib/const.ts @@ -114,6 +114,12 @@ export const properties = { requiresIdentifier: true, isKeyProperty: true, }, + preferredUsername: { + name: "preferredUsername", + path: ["preferredUsername"], + setter: "setActorDispatcher", + requiresIdentifier: false, + }, } as const satisfies Record; /** @@ -133,6 +139,7 @@ export const RULE_IDS = { actorUploadMediaPropertyRequired: "actor-upload-media-property-required", actorPublicKeyRequired: "actor-public-key-required", actorAssertionMethodRequired: "actor-assertion-method-required", + actorPreferredUsernameRequired: "actor-preferred-username-required", // Mismatch rules actorIdMismatch: "actor-id-mismatch", diff --git a/packages/lint/src/lib/messages.ts b/packages/lint/src/lib/messages.ts index af160860d..de5a7fb17 100644 --- a/packages/lint/src/lib/messages.ts +++ b/packages/lint/src/lib/messages.ts @@ -11,18 +11,24 @@ export const actorPropertyRequired = ({ path, getter, requiresIdentifier = true, -}: PropertyConfig): string => - `When \`${setter}\` is configured, the \`${ - path.join(".") - }\` property is recommended. Use \`${ - getExpectedCall({ - ctxName: "Context", - methodName: getter, - idName: "identifier", - path: path.join("."), - requiresIdentifier, - }) - }\` for the \`${path.join(".")}\` property URI.`; +}: PropertyConfig): string => { + const propertyPath = path.join("."); + + const recommendation = getter == null + ? `Set the \`${propertyPath}\` property directly on the actor object.` + : `Use \`${ + getExpectedCall({ + ctxName: "Context", + methodName: getter, + idName: "identifier", + path: path.join("."), + requiresIdentifier, + }) + }\` for the \`${propertyPath}\` property URI.`; + + return `When \`${setter}\` is configured, the \`${propertyPath}\` ` + + `property is recommended. ${recommendation}`; +}; /** * Generates error message for *-mismatch rules. diff --git a/packages/lint/src/lib/mismatch.ts b/packages/lint/src/lib/mismatch.ts index 0833dbc74..ce4d3dd67 100644 --- a/packages/lint/src/lib/mismatch.ts +++ b/packages/lint/src/lib/mismatch.ts @@ -78,7 +78,7 @@ const getNameIfIdentifier = (node: Parameter): string | null => node?.type === "Identifier" ? node.name : null; function createMismatchRule( - config: PropertyConfig, + config: PropertyConfig & { getter: string }, describe: ( methodCallContext: MethodCallContext, ) => Context extends Deno.lint.RuleContext ? { @@ -152,7 +152,7 @@ function createMismatchRule( * @returns A Deno lint rule */ export const createMismatchRuleDeno = ( - config: PropertyConfig, + config: PropertyConfig & { getter: string }, ): Deno.lint.Rule => ({ create: createMismatchRule( config, @@ -163,7 +163,7 @@ export const createMismatchRuleDeno = ( }); export const createMismatchRuleEslint = ( - config: PropertyConfig, + config: PropertyConfig & { getter: string }, ): Rule.RuleModule => ({ meta: { type: "problem", diff --git a/packages/lint/src/lib/test-templates.ts b/packages/lint/src/lib/test-templates.ts index 2a1539b59..e0d23a20a 100644 --- a/packages/lint/src/lib/test-templates.ts +++ b/packages/lint/src/lib/test-templates.ts @@ -8,6 +8,20 @@ import type { MethodCallContext, PropertyConfig } from "./types.ts"; type PropertyKey = keyof typeof properties; +/** + * Property keys whose config has a `getter` — the subset mismatch rules + * apply to (a property with no Context getter, like `preferredUsername`, + * cannot be "mismatched" against one). + */ +type PropertyKeyWithGetter = { + [K in PropertyKey]: (typeof properties)[K] extends { getter: string } ? K + : never; +}[PropertyKey]; + +/** Safely reads a property config's `getter`, if it has one. */ +const getterOf = (p: PropertyConfig): string | undefined => + "getter" in p ? p.getter : undefined; + interface TestConfig { rule: { deno: Deno.lint.Rule; @@ -109,7 +123,7 @@ const createPropertyAssignment = ( const idName = options.idName ?? "identifier"; const requiresIdentifier = prop.requiresIdentifier ?? true; - const methodCall = createMethodCall( + const methodCall = getter == null ? `"example-value"` : createMethodCall( getter, requiresIdentifier, ctxName, @@ -134,7 +148,7 @@ const createMethodCallContext = ( path: prop.path.join("."), ctxName, idName, - methodName: prop.getter, + methodName: prop.getter!, requiresIdentifier: prop.requiresIdentifier ?? true, }); @@ -408,6 +422,117 @@ export function createIdRequiredRuleTests(config: TestConfig): TestSuite { }; } +/** + * Creates required rule tests for `preferredUsername`, which — like `id` — + * uses `setActorDispatcher` as its own setter, so the generic + * `createRequiredDispatcherRuleTests` (built for properties with a separate + * setter such as `setInboxListeners`) does not apply. Unlike `id`, it has no + * getter, so its "good" cases use a plain literal instead of a context call. + */ +export function createPreferredUsernameRequiredRuleTests( + config: TestConfig, +): TestSuite { + const { rule, ruleName } = config; + const expectedError = actorPropertyRequired(properties.preferredUsername); + + return { + // ✅ Good - non-Federation object + "non-federation object": [ + lintTest({ + code: createActorDispatcherCode( + `return new Person({ name: "John Doe" });`, + ), + rule, + ruleName, + federationSetup: ` + const federation = { setActorDispatcher: () => {} }; + `, + }), + true, + ], + + // ✅ Good - with preferredUsername property + "with preferredUsername property": [ + lintTest({ + code: createActorDispatcherCode(`return new Person({ + preferredUsername: identifier, + name: "John Doe", + });`), + rule, + ruleName, + }), + true, + ], + + // ✅ Good - BlockStatement with preferredUsername + "block statement with preferredUsername": [ + lintTest({ + code: createActorDispatcherCode(`const name = "John Doe"; + return new Person({ + preferredUsername: identifier, + name, + });`), + rule, + ruleName, + }), + true, + ], + + // ❌ Bad - without preferredUsername property + "without preferredUsername property": [ + lintTest({ + code: createActorDispatcherCode( + `return new Person({ name: "John Doe" });`, + ), + rule, + ruleName, + expectedError, + }), + false, + ], + + // ❌ Bad - returning empty object + "returning empty object": [ + lintTest({ + code: createActorDispatcherCode(`return new Person({});`), + rule, + ruleName, + expectedError, + }), + false, + ], + + // ✅ Good - multiple properties including preferredUsername + "multiple properties including preferredUsername": [ + lintTest({ + code: createActorDispatcherCode(`return new Person({ + preferredUsername: identifier, + name: "John Doe", + inbox: ctx.getInboxUri(identifier), + outbox: ctx.getOutboxUri(identifier), + });`), + rule, + ruleName, + }), + true, + ], + + // ❌ Bad - variable assignment without preferredUsername + "variable assignment without preferredUsername": [ + lintTest({ + code: createActorDispatcherCode( + `const actor = new Person({ name: "John Doe" }); + return actor;`, + ), + rule, + ruleName, + expectedError, + }), + false, + ], + }; +} + // ============================================================================= // Mismatch Rule Tests // ============================================================================= @@ -416,7 +541,7 @@ export function createIdRequiredRuleTests(config: TestConfig): TestSuite { * Creates mismatch rule tests for standard properties */ export function createMismatchRuleTests( - propertyKey: PropertyKey, + propertyKey: PropertyKeyWithGetter, config: TestConfig, ): TestSuite { const { rule, ruleName } = config; @@ -427,8 +552,10 @@ export function createMismatchRuleTests( // Find a wrong getter for testing const wrongGetters = Object.values(properties) - .filter((p) => p.getter !== prop.getter) - .map((p) => p.getter); + .map(getterOf) + .filter((getter): getter is string => + getter != null && getter !== prop.getter + ); const wrongGetter = wrongGetters[0] || "getWrongUri"; const wrongSetter = Object.values(properties) .filter((p) => p.setter !== prop.setter) @@ -940,7 +1067,7 @@ return new Person({ ${ID_PROP} name: "C" });`, * Creates common edge case tests for mismatch rules */ export function createMismatchEdgeCaseTests( - propertyKey: PropertyKey, + propertyKey: PropertyKeyWithGetter, config: TestConfig, ): TestSuite { const { rule, ruleName } = config; @@ -951,8 +1078,10 @@ export function createMismatchEdgeCaseTests( // Find a wrong getter for testing const wrongGetters = Object.values(properties) - .filter((p) => p.getter !== prop.getter) - .map((p) => p.getter); + .map(getterOf) + .filter((getter): getter is string => + getter != null && getter !== prop.getter + ); const wrongGetter = wrongGetters[0] || "getWrongUri"; const createLocalPropertyCode = (getter: string) => diff --git a/packages/lint/src/lib/types.ts b/packages/lint/src/lib/types.ts index 1806f9241..313e432b5 100644 --- a/packages/lint/src/lib/types.ts +++ b/packages/lint/src/lib/types.ts @@ -87,7 +87,7 @@ export interface PropertyConfig { */ path: readonly string[]; /** Context method name to get the URI (e.g., "getActorUri", "getInboxUri") */ - getter: string; + getter?: string; /** * Dispatcher/Listener method name * (e.g., "setActorDispatcher", "setInboxListeners") diff --git a/packages/lint/src/mod.ts b/packages/lint/src/mod.ts index 8e4218017..5ff51f6e5 100644 --- a/packages/lint/src/mod.ts +++ b/packages/lint/src/mod.ts @@ -46,6 +46,9 @@ import { import { deno as actorOutboxPropertyRequired, } from "./rules/actor-outbox-property-required.ts"; +import { + deno as actorPreferredUsernameRequired, +} from "./rules/actor-preferred-username-required.ts"; import { deno as actorPublicKeyRequired, } from "./rules/actor-public-key-required.ts"; @@ -105,6 +108,7 @@ const plugin: Deno.lint.Plugin = { actorUploadMediaPropertyMismatch, [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, + [RULE_IDS.actorPreferredUsernameRequired]: actorPreferredUsernameRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, [RULE_IDS.outboxListenerDeliveryRequired]: outboxListenerDeliveryRequired, [RULE_IDS.mediaUploaderObjectUriRequired]: mediaUploaderObjectUriRequired, diff --git a/packages/lint/src/oxlint.ts b/packages/lint/src/oxlint.ts index 36909176c..9f32152c9 100644 --- a/packages/lint/src/oxlint.ts +++ b/packages/lint/src/oxlint.ts @@ -65,6 +65,9 @@ import { import { eslint as actorOutboxPropertyRequired, } from "./rules/actor-outbox-property-required.ts"; +import { + eslint as actorPreferredUsernameRequired, +} from "./rules/actor-preferred-username-required.ts"; import { eslint as actorPublicKeyRequired, } from "./rules/actor-public-key-required.ts"; @@ -121,6 +124,7 @@ const rules: Record< [RULE_IDS.actorUploadMediaPropertyMismatch]: actorUploadMediaPropertyMismatch, [RULE_IDS.actorPublicKeyRequired]: actorPublicKeyRequired, [RULE_IDS.actorAssertionMethodRequired]: actorAssertionMethodRequired, + [RULE_IDS.actorPreferredUsernameRequired]: actorPreferredUsernameRequired, [RULE_IDS.collectionFilteringNotImplemented]: collectionFiltering, [RULE_IDS.outboxListenerDeliveryRequired]: outboxListenerDeliveryRequired, [RULE_IDS.mediaUploaderObjectUriRequired]: mediaUploaderObjectUriRequired, diff --git a/packages/lint/src/rules/actor-preferred-username-required.ts b/packages/lint/src/rules/actor-preferred-username-required.ts new file mode 100644 index 000000000..8e8d879de --- /dev/null +++ b/packages/lint/src/rules/actor-preferred-username-required.ts @@ -0,0 +1,8 @@ +import { properties } from "../lib/const.ts"; +import { + createRequiredRuleDeno, + createRequiredRuleEslint, +} from "../lib/required.ts"; + +export const deno = createRequiredRuleDeno(properties.preferredUsername); +export const eslint = createRequiredRuleEslint(properties.preferredUsername); diff --git a/packages/lint/src/tests/actor-preferred-username-required.test.ts b/packages/lint/src/tests/actor-preferred-username-required.test.ts new file mode 100644 index 000000000..b6a6bc8f7 --- /dev/null +++ b/packages/lint/src/tests/actor-preferred-username-required.test.ts @@ -0,0 +1,13 @@ +import { RULE_IDS } from "../lib/const.ts"; +import { + createPreferredUsernameRequiredRuleTests, + createRequiredEdgeCaseTests, + runTests, +} from "../lib/test-templates.ts"; +import * as rule from "../rules/actor-preferred-username-required.ts"; + +const ruleName = RULE_IDS.actorPreferredUsernameRequired; +const config = { rule, ruleName }; + +runTests(ruleName, createPreferredUsernameRequiredRuleTests(config)); +runTests(ruleName, createRequiredEdgeCaseTests("preferredUsername", config)); diff --git a/packages/lint/src/tests/integration.test.ts b/packages/lint/src/tests/integration.test.ts index 9684548a5..4b99de3e6 100644 --- a/packages/lint/src/tests/integration.test.ts +++ b/packages/lint/src/tests/integration.test.ts @@ -146,6 +146,7 @@ federation return new Person({ id: ctx.getActorUri(identifier), name: "John Doe", + preferredUsername: identifier, summary: "A test actor for comprehensive lint rule validation", inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ @@ -374,6 +375,16 @@ test("Integration: ❌ actor-id-required - missing id property", () => assertHasError("actor-id-required"), )); +test("Integration: ❌ actor-preferred-username-required - missing preferred-username property", () => + pipe( + COMPLETE_VALID_CODE, + replace( + "preferredUsername: identifier,", + "// preferredUsername: identifier, // REMOVED", + ), + assertHasError("actor-preferred-username-required"), + )); + test( "Integration: ❌ actor-inbox-property-required - missing inbox property", () => From c2d13966dddf4cd62460e68c4a4308775d7e7977 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Thu, 3 Sep 2026 18:23:07 +0900 Subject: [PATCH 2/7] Document actor-preferred-username-required rule Add the actor-preferred-username-required reference section to docs/manual/lint.md, matching the format used by the other required-property rules. Assisted-by: Claude Code:claude-sonnet-5 --- docs/manual/lint.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/manual/lint.md b/docs/manual/lint.md index c17b1e7a5..e8a77084c 100644 --- a/docs/manual/lint.md +++ b/docs/manual/lint.md @@ -557,6 +557,43 @@ federation [Object Integrity Proofs]: ./send.md#object-integrity-proofs +### `actor-preferred-username-required` + +Ensures actors have a `preferredUsername` property. + +**When this rule applies:** +The actor dispatcher is configured with `setActorDispatcher()`, but the actor +object doesn't include a `preferredUsername` property. + +**Why it matters:** +Most fediverse software expects actors to expose a stable +`preferredUsername`. Omitting it tends to make remote display, search, and +profile rendering worse, even though Fedify itself doesn't require it. + +~~~~ typescript twoslash +// @noErrors: 2345 +import { createFederation } from "@fedify/fedify"; +import { Person } from "@fedify/vocab"; +const federation = createFederation({ kv: null as any }); +// ---cut-before--- +// ❌ Bad: Missing preferredUsername property +federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => { + return new Person({ + id: ctx.getActorUri(identifier), + name: "John Doe", // No preferredUsername! + }); +}); + +// ✅ Good: Include preferredUsername property +federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => { + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: "John Doe", + }); +}); +~~~~ + ### `actor-inbox-property-required` Ensures `inbox` is defined when `setInboxListeners()` is configured. From cbfbfced22c2b4d22164fb322f925b9598763103 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 4 Sep 2026 09:04:15 +0900 Subject: [PATCH 3/7] Accept plural preferredUsernames initializer `preferredUsername` uses vocab's singular/plural accessor sugar: a Person constructed with `preferredUsernames: [identifier]` behaves identically to one with `preferredUsername: identifier`. The actor-preferred-username-required rule only recognized the singular key, so it reported a false positive when only the plural initializer was used. PropertyConfig gains an optional `pluralName` field, and createRequiredRule now accepts either the configured path or its pluralized last segment when both are present. Only `preferredUsername` sets `pluralName`, so no other rule's behavior changes. Add a regression test covering the plural initializer. https://github.com/fedify-dev/fedify/pull/1022#discussion_r3923979598 Assisted-by: Claude Code:claude-sonnet-5 --- packages/lint/src/lib/const.ts | 1 + packages/lint/src/lib/required.ts | 12 +++++++++++- packages/lint/src/lib/test-templates.ts | 13 +++++++++++++ packages/lint/src/lib/types.ts | 7 +++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/lib/const.ts b/packages/lint/src/lib/const.ts index 4720e69a1..7752b0f71 100644 --- a/packages/lint/src/lib/const.ts +++ b/packages/lint/src/lib/const.ts @@ -119,6 +119,7 @@ export const properties = { path: ["preferredUsername"], setter: "setActorDispatcher", requiresIdentifier: false, + pluralName: "preferredUsernames", }, } as const satisfies Record; diff --git a/packages/lint/src/lib/required.ts b/packages/lint/src/lib/required.ts index 8d928de87..8ea9b04cb 100644 --- a/packages/lint/src/lib/required.ts +++ b/packages/lint/src/lib/required.ts @@ -77,7 +77,17 @@ function createRequiredRule( const propertyChecker = createPropertyChecker(Boolean)( config.path, ); - const propertySearcher = createPropertySearcher(propertyChecker); + const pluralPropertyChecker = config.pluralName == null ? null : ( + createPropertyChecker(Boolean)([ + ...config.path.slice(0, -1), + config.pluralName, + ]) + ); + const propertySearcher = createPropertySearcher( + pluralPropertyChecker == null + ? propertyChecker + : (node) => propertyChecker(node) || pluralPropertyChecker(node), + ); return { VariableDeclarator: federationTracker.VariableDeclarator, diff --git a/packages/lint/src/lib/test-templates.ts b/packages/lint/src/lib/test-templates.ts index e0d23a20a..741c84901 100644 --- a/packages/lint/src/lib/test-templates.ts +++ b/packages/lint/src/lib/test-templates.ts @@ -464,6 +464,19 @@ export function createPreferredUsernameRequiredRuleTests( true, ], + // ✅ Good - with plural preferredUsernames property + "with preferredUsernames (plural) property": [ + lintTest({ + code: createActorDispatcherCode(`return new Person({ + preferredUsernames: [identifier], + name: "John Doe", + });`), + rule, + ruleName, + }), + true, + ], + // ✅ Good - BlockStatement with preferredUsername "block statement with preferredUsername": [ lintTest({ diff --git a/packages/lint/src/lib/types.ts b/packages/lint/src/lib/types.ts index 313e432b5..26a40a02c 100644 --- a/packages/lint/src/lib/types.ts +++ b/packages/lint/src/lib/types.ts @@ -95,6 +95,13 @@ export interface PropertyConfig { setter: string; /** Whether the getter requires an identifier parameter (default: true) */ requiresIdentifier: boolean; + /** + * Plural form of the property name that also satisfies this check + * (e.g., "preferredUsernames" for "preferredUsername"), for vocabulary + * properties whose constructors accept a plural initializer as sugar for + * the singular one. + */ + pluralName?: string; /** Nested property configuration, if this property is nested inside another */ nested?: NestedPropertyConfig; /** Whether this is a key-related property (uses getActorKeyPairs) */ From f0da813d6c96449cfc8c59baac2c87369c179233 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 4 Sep 2026 09:18:54 +0900 Subject: [PATCH 4/7] Add PR credit and version note for lint rule Add the pull request number and contributor credit to the actor-preferred-username-required changelog fragment, and pin its reference links with `sacho resolve-links`. Note in the rule's documentation that it was introduced in Fedify 2.4.0, matching the version in changes.d/next.txt. https://github.com/fedify-dev/fedify/pull/1022#discussion_r3923670343 https://github.com/fedify-dev/fedify/pull/1022#discussion_r3923683177 Assisted-by: Claude Code:claude-sonnet-5 --- CHANGES.md | 3 ++- changes.d/lint/actor-preferred-username-rule.md | 7 ++++++- docs/manual/lint.md | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 4a3f0173a..011924bf4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -235,9 +235,10 @@ To be released. - Added the `actor-preferred-username-required` lint rule, which warns when an actor dispatcher's return value does not include a - `preferredUsername` property. [[#895]] + `preferredUsername` property. [[#895], [#1022] by Jae-Hyuk-Jang\] [#895]: https://github.com/fedify-dev/fedify/issues/895 +[#1022]: https://github.com/fedify-dev/fedify/pull/1022 ### @fedify/mysql diff --git a/changes.d/lint/actor-preferred-username-rule.md b/changes.d/lint/actor-preferred-username-rule.md index 1a539a5d6..89b122b71 100644 --- a/changes.d/lint/actor-preferred-username-rule.md +++ b/changes.d/lint/actor-preferred-username-rule.md @@ -1,3 +1,8 @@ +--- +links: + '#1022': https://github.com/fedify-dev/fedify/pull/1022 + '#895': https://github.com/fedify-dev/fedify/issues/895 +--- - Added the `actor-preferred-username-required` lint rule, which warns when an actor dispatcher's return value does not include a - `preferredUsername` property. [[#895]] + `preferredUsername` property. [[#895], [#1022] by Jae-Hyuk-Jang] diff --git a/docs/manual/lint.md b/docs/manual/lint.md index e8a77084c..eac45894f 100644 --- a/docs/manual/lint.md +++ b/docs/manual/lint.md @@ -561,6 +561,8 @@ federation Ensures actors have a `preferredUsername` property. +*This rule is introduced in Fedify 2.4.0.* + **When this rule applies:** The actor dispatcher is configured with `setActorDispatcher()`, but the actor object doesn't include a `preferredUsername` property. From 60da25e0e3680da047c87d12629d8ce4dc67e328 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 4 Sep 2026 09:44:01 +0900 Subject: [PATCH 5/7] Skip Tombstone returns in required rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActorDispatcher can return a Tombstone for a deleted actor, which Fedify treats like null for required-property purposes—there's no actor object to check. property-checker.ts's checkBranchWith() only special-cased null, so a dispatcher returning only a Tombstone, or a conditional dispatcher whose other branch returns an actor, both triggered a false positive. checkBranchWith() is shared by every required-property rule and already recurses into ternary and if/else branches, so treating `new Tombstone(...)` like null there fixes direct and mixed returns across all of them without touching the branching logic itself. Add regression tests to the shared required-rule edge case factory in test-templates.ts, covering a Tombstone-only return and a ternary mixing Tombstone with a valid actor. actor-id-required is excluded: a real Tombstone always carries an `id`, so a test built the same way would pass regardless of this fix and wouldn't verify anything. https://github.com/fedify-dev/fedify/pull/1022#discussion_r3923814080 Assisted-by: Claude Code:claude-sonnet-5 --- packages/lint/src/lib/property-checker.ts | 13 +++++++++-- packages/lint/src/lib/test-templates.ts | 28 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/lint/src/lib/property-checker.ts b/packages/lint/src/lib/property-checker.ts index b294fdefc..3d129ead1 100644 --- a/packages/lint/src/lib/property-checker.ts +++ b/packages/lint/src/lib/property-checker.ts @@ -126,14 +126,23 @@ const unwrapTypeScriptExpression = (node: Expression): Expression => { const isNullLiteral = (node: Expression): boolean => node.type === "Literal" && node.value === null; +const isTombstoneExpression = (node: Expression): boolean => + node.type === "NewExpression" && + node.callee.type === "Identifier" && + node.callee.name === "Tombstone"; + // Check if both branches have the property const checkBranchWith = (propertyChecker: PropertyChecker) => (branch: Expression): boolean => { const expression = unwrapTypeScriptExpression(branch); // A null return means that no actor was found, so there is no actor object - // whose properties need to be checked. - if (isNullLiteral(expression)) return true; + // whose properties need to be checked. A Tombstone return means the actor + // was deleted, which ActorDispatcher explicitly permits, so it likewise + // has no actor properties to check. + if (isNullLiteral(expression) || isTombstoneExpression(expression)) { + return true; + } return pipe( expression, diff --git a/packages/lint/src/lib/test-templates.ts b/packages/lint/src/lib/test-templates.ts index 741c84901..01bbf1080 100644 --- a/packages/lint/src/lib/test-templates.ts +++ b/packages/lint/src/lib/test-templates.ts @@ -816,6 +816,34 @@ return new Person({ ${ID_PROP} name: "A" });`, true, ], + // ✅ Dispatcher returns only a Tombstone (e.g. a deleted actor) + "returns only a Tombstone": [ + lintTest({ + code: createDispatcherCode( + `return new Tombstone({ id: ctx.getActorUri(identifier) });`, + setter, + ), + rule, + ruleName, + }), + true, + ], + + // ✅ Ternary with Tombstone and property in actor branch + "ternary with Tombstone and property in actor branch": [ + lintTest({ + code: createDispatcherCode( + `return condition + ? new Tombstone({ id: ctx.getActorUri(identifier) }) + : new Person({ ${ID_PROP} ${propCode} name: "A" });`, + setter, + ), + rule, + ruleName, + }), + true, + ], + // ✅ Ternary with property in both branches "ternary with property in both branches": [ lintTest({ From da2acd9b6a08e8f0ba3f6c708e5bc541d2283ac4 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 4 Sep 2026 10:29:37 +0900 Subject: [PATCH 6/7] Skip Tombstone in concise-body dispatchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's Tombstone check lived in checkBranchWith(), which only runs for return statements and ternary/if-else branches. An arrow function with a concise body—`(ctx, identifier) => new Tombstone(...)`, with no `return` keyword—reaches createPropertySearcher()'s "NewExpression" case directly instead, bypassing that check and still reporting a false positive. Apply the same isTombstoneExpression() guard there, and add a regression test covering a concise-body actor dispatcher that returns only a Tombstone. https://github.com/fedify-dev/fedify/pull/1022#discussion_r3929974121 Assisted-by: Claude Code:claude-sonnet-5 --- packages/lint/src/lib/property-checker.ts | 1 + packages/lint/src/lib/test-templates.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/lint/src/lib/property-checker.ts b/packages/lint/src/lib/property-checker.ts index 3d129ead1..4b0e83563 100644 --- a/packages/lint/src/lib/property-checker.ts +++ b/packages/lint/src/lib/property-checker.ts @@ -224,6 +224,7 @@ export const createPropertySearcher = (propertyChecker: PropertyChecker) => { return checkAllReturnPaths(propertyChecker)(node); case "NewExpression": + if (isTombstoneExpression(node)) return true; return pipe( node, extractFirstObjectExpression, diff --git a/packages/lint/src/lib/test-templates.ts b/packages/lint/src/lib/test-templates.ts index 01bbf1080..419c9e37a 100644 --- a/packages/lint/src/lib/test-templates.ts +++ b/packages/lint/src/lib/test-templates.ts @@ -543,6 +543,21 @@ export function createPreferredUsernameRequiredRuleTests( }), false, ], + + // ✅ Good - concise-body dispatcher returning only a Tombstone + "concise-body dispatcher returning only a Tombstone": [ + lintTest({ + code: ` +federation.setActorDispatcher( + "/users/{identifier}", + (ctx, identifier) => new Tombstone({ id: ctx.getActorUri(identifier) }), +); +`, + rule, + ruleName, + }), + true, + ], }; } From dd37ff59a9573a4263310e2e52bd39bc505325a3 Mon Sep 17 00:00:00 2001 From: Jae-Hyuk-Jang Date: Fri, 4 Sep 2026 19:46:33 +0900 Subject: [PATCH 7/7] Move version note to the top of lint rule section Move the "introduced in Fedify 2.4.0" sentence to the very beginning of the actor-preferred-username-required section, ahead of the one-line description, as suggested. https://github.com/fedify-dev/fedify/pull/1022#discussion_r3933057234 Assisted-by: Claude Code:claude-sonnet-5 --- docs/manual/lint.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manual/lint.md b/docs/manual/lint.md index eac45894f..4f9184c11 100644 --- a/docs/manual/lint.md +++ b/docs/manual/lint.md @@ -559,10 +559,10 @@ federation ### `actor-preferred-username-required` -Ensures actors have a `preferredUsername` property. - *This rule is introduced in Fedify 2.4.0.* +Ensures actors have a `preferredUsername` property. + **When this rule applies:** The actor dispatcher is configured with `setActorDispatcher()`, but the actor object doesn't include a `preferredUsername` property.