diff --git a/AGENTS.md b/AGENTS.md index 76695f0..e15a09f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,31 +98,38 @@ the function's shape without opening it. **Effectful** — touches the world (filesystem, streams, processes, registries): -| Prefix | Contract | Example | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -| `apply` | perform previously planned changes | `applyEdits` | -| `create` | bring a resource into existence (file, directory, process) | `createWorkDir` | -| `claim` | atomically take exclusive ownership of a work item or resource; ownership ends at commit or an explicit release | `claimNextChain` | -| `read` | pull raw content from filesystem or network into memory | `readSource` | -| `load` | read **and** parse into a ready structure | `loadConfig` | -| `write` | persist to the filesystem | `writeOutput` | -| `remove` | delete a resource | `removeStaleDist` | -| `update` | mutate existing state or resource in place | `updateIndex` | -| `upsert` | single-statement insert-or-update keyed by a natural or composite key, refreshing the conflicting row's columns in place | `upsertUser` | -| `set` | assign a store's named state slice wholesale — the store-setter idiom; partial mutation is `update` | `setSelectedNode` | -| `print` | write to stdout/stderr | `printHelp` | -| `run` | execute a subprocess, task, or whole pipeline | `runCLI` | -| `check` | evaluate and report findings; effects allowed per mode | `checkFile` | -| `try` | X with failures captured as a value instead of a throw | `tryCheckFile` | -| `register` | add to a registry the caller doesn't own | `registerMatcher` | -| `assert` | throw when an invariant doesn't hold | `assertSpan` | -| `require` | throw unless a runtime condition holds — a guard real input can trip (`assert` covers invariants) | `requireAuth` | -| `emit` | dispatch an event or notification | `emitProgress` | -| `send` | transmit a payload to a remote receiver (fire-and-forget or RPC — no resource semantics; REST mutations are `create`/`update`/`remove`) | `sendWebhook` | -| `wait` | block until an event or condition resolves; may return the awaited value | `waitForMessage` | -| `start` | put a long-running resource into service (server, worker, poll loop); `stop` reverses it | `startQueues` | -| `stop` | take a long-running resource out of service, releasing what `start` acquired | `stopWorker` | -| `drain` | consume a pending backlog until empty | `drainJobs` | +| Prefix | Contract | Example | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `apply` | perform previously planned changes | `applyEdits` | +| `create` | bring a resource into existence (file, directory, process) | `createWorkDir` | +| `claim` | atomically take exclusive ownership of a work item or resource; ownership ends at commit or an explicit release | `claimNextChain` | +| `read` | pull raw content from filesystem or network into memory | `readSource` | +| `load` | read **and** parse into a ready structure | `loadConfig` | +| `write` | persist to the filesystem | `writeOutput` | +| `remove` | delete a resource | `removeStaleDist` | +| `update` | mutate existing state or resource in place | `updateIndex` | +| `upsert` | single-statement insert-or-update keyed by a natural or composite key, refreshing the conflicting row's columns in place | `upsertUser` | +| `set` | assign a store's named state slice wholesale — the store-setter idiom; partial mutation is `update` | `setSelectedNode` | +| `toggle` | invert a boolean state slice | `toggleDevCamera` | +| `reset` | return state to its initial value | `resetCombatState` | +| `print` | write to stdout/stderr | `printHelp` | +| `run` | execute a subprocess, task, or whole pipeline | `runCLI` | +| `check` | evaluate and report findings; effects allowed per mode | `checkFile` | +| `try` | X with failures captured as a value instead of a throw | `tryCheckFile` | +| `register` | add to a registry the caller doesn't own | `registerMatcher` | +| `subscribe` | attach a listener to an event source, returning or enabling detachment | `subscribeToTicks` | +| `unsubscribe` | detach what `subscribe` attached | `unsubscribe` | +| `assert` | throw when an invariant doesn't hold | `assertSpan` | +| `require` | throw unless a runtime condition holds — a guard real input can trip (`assert` covers invariants) | `requireAuth` | +| `verify` | test a claim or credential against evidence, rejecting on mismatch | `verifySession` | +| `emit` | dispatch an event or notification | `emitProgress` | +| `send` | transmit a payload to a remote receiver (fire-and-forget or RPC — no resource semantics; REST mutations are `create`/`update`/`remove`) | `sendWebhook` | +| `wait` | block until an event or condition resolves; may return the awaited value | `waitForMessage` | +| `setup` | prepare the environment or fixture the following code assumes; `teardown` reverses it | `setupTest` | +| `teardown` | release what `setup` prepared | `teardownTest` | +| `start` | put a long-running resource into service (server, worker, poll loop); `stop` reverses it | `startQueues` | +| `stop` | take a long-running resource out of service, releasing what `start` acquired | `stopWorker` | +| `drain` | consume a pending backlog until empty | `drainJobs` | **Wrappers and factories** — the result is behaviour, not data: diff --git a/agents/shared.md b/agents/shared.md index 589c6e1..909b715 100644 --- a/agents/shared.md +++ b/agents/shared.md @@ -96,31 +96,38 @@ the function's shape without opening it. **Effectful** — touches the world (filesystem, streams, processes, registries): -| Prefix | Contract | Example | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -| `apply` | perform previously planned changes | `applyEdits` | -| `create` | bring a resource into existence (file, directory, process) | `createWorkDir` | -| `claim` | atomically take exclusive ownership of a work item or resource; ownership ends at commit or an explicit release | `claimNextChain` | -| `read` | pull raw content from filesystem or network into memory | `readSource` | -| `load` | read **and** parse into a ready structure | `loadConfig` | -| `write` | persist to the filesystem | `writeOutput` | -| `remove` | delete a resource | `removeStaleDist` | -| `update` | mutate existing state or resource in place | `updateIndex` | -| `upsert` | single-statement insert-or-update keyed by a natural or composite key, refreshing the conflicting row's columns in place | `upsertUser` | -| `set` | assign a store's named state slice wholesale — the store-setter idiom; partial mutation is `update` | `setSelectedNode` | -| `print` | write to stdout/stderr | `printHelp` | -| `run` | execute a subprocess, task, or whole pipeline | `runCLI` | -| `check` | evaluate and report findings; effects allowed per mode | `checkFile` | -| `try` | X with failures captured as a value instead of a throw | `tryCheckFile` | -| `register` | add to a registry the caller doesn't own | `registerMatcher` | -| `assert` | throw when an invariant doesn't hold | `assertSpan` | -| `require` | throw unless a runtime condition holds — a guard real input can trip (`assert` covers invariants) | `requireAuth` | -| `emit` | dispatch an event or notification | `emitProgress` | -| `send` | transmit a payload to a remote receiver (fire-and-forget or RPC — no resource semantics; REST mutations are `create`/`update`/`remove`) | `sendWebhook` | -| `wait` | block until an event or condition resolves; may return the awaited value | `waitForMessage` | -| `start` | put a long-running resource into service (server, worker, poll loop); `stop` reverses it | `startQueues` | -| `stop` | take a long-running resource out of service, releasing what `start` acquired | `stopWorker` | -| `drain` | consume a pending backlog until empty | `drainJobs` | +| Prefix | Contract | Example | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `apply` | perform previously planned changes | `applyEdits` | +| `create` | bring a resource into existence (file, directory, process) | `createWorkDir` | +| `claim` | atomically take exclusive ownership of a work item or resource; ownership ends at commit or an explicit release | `claimNextChain` | +| `read` | pull raw content from filesystem or network into memory | `readSource` | +| `load` | read **and** parse into a ready structure | `loadConfig` | +| `write` | persist to the filesystem | `writeOutput` | +| `remove` | delete a resource | `removeStaleDist` | +| `update` | mutate existing state or resource in place | `updateIndex` | +| `upsert` | single-statement insert-or-update keyed by a natural or composite key, refreshing the conflicting row's columns in place | `upsertUser` | +| `set` | assign a store's named state slice wholesale — the store-setter idiom; partial mutation is `update` | `setSelectedNode` | +| `toggle` | invert a boolean state slice | `toggleDevCamera` | +| `reset` | return state to its initial value | `resetCombatState` | +| `print` | write to stdout/stderr | `printHelp` | +| `run` | execute a subprocess, task, or whole pipeline | `runCLI` | +| `check` | evaluate and report findings; effects allowed per mode | `checkFile` | +| `try` | X with failures captured as a value instead of a throw | `tryCheckFile` | +| `register` | add to a registry the caller doesn't own | `registerMatcher` | +| `subscribe` | attach a listener to an event source, returning or enabling detachment | `subscribeToTicks` | +| `unsubscribe` | detach what `subscribe` attached | `unsubscribe` | +| `assert` | throw when an invariant doesn't hold | `assertSpan` | +| `require` | throw unless a runtime condition holds — a guard real input can trip (`assert` covers invariants) | `requireAuth` | +| `verify` | test a claim or credential against evidence, rejecting on mismatch | `verifySession` | +| `emit` | dispatch an event or notification | `emitProgress` | +| `send` | transmit a payload to a remote receiver (fire-and-forget or RPC — no resource semantics; REST mutations are `create`/`update`/`remove`) | `sendWebhook` | +| `wait` | block until an event or condition resolves; may return the awaited value | `waitForMessage` | +| `setup` | prepare the environment or fixture the following code assumes; `teardown` reverses it | `setupTest` | +| `teardown` | release what `setup` prepared | `teardownTest` | +| `start` | put a long-running resource into service (server, worker, poll loop); `stop` reverses it | `startQueues` | +| `stop` | take a long-running resource out of service, releasing what `start` acquired | `stopWorker` | +| `drain` | consume a pending backlog until empty | `drainJobs` | **Wrappers and factories** — the result is behaviour, not data: diff --git a/packages/format-codemod/src/cli/build-unified-diff.ts b/packages/format-codemod/src/cli/build-unified-diff.ts index 250898d..98e7c62 100644 --- a/packages/format-codemod/src/cli/build-unified-diff.ts +++ b/packages/format-codemod/src/cli/build-unified-diff.ts @@ -55,7 +55,7 @@ class MyersDiff { } buildOps(): DiffOp[] { - this.computeTrace(); + this.updateTrace(); return this.backtrack(); } @@ -64,7 +64,7 @@ class MyersDiff { * One snapshot of v per edit-distance round; the round that reaches the end * stops the search and the snapshots drive the backtrack. */ - private computeTrace(): void { + private updateTrace(): void { for (let d = 0; d <= this.a.length + this.b.length; d++) { this.trace.push(new Map(this.v)); @@ -74,6 +74,7 @@ class MyersDiff { } } + // oxlint-disable-next-line zgeoff/function-verb -- Myers-native vocabulary private stepRound(d: number): boolean { for (let k = -d; k <= d; k += 2) { const x = this.slideDiagonal(this.pickX(k, d), k); @@ -94,6 +95,7 @@ class MyersDiff { return moveDown ? (this.v.get(k + 1) ?? 0) : (this.v.get(k - 1) ?? 0) + 1; } + // oxlint-disable-next-line zgeoff/function-verb -- Myers-native vocabulary private slideDiagonal(x: number, k: number): number { let nx = x; @@ -104,6 +106,7 @@ class MyersDiff { return nx; } + // oxlint-disable-next-line zgeoff/function-verb -- Myers-native vocabulary private backtrack(): DiffOp[] { const ops: DiffOp[] = []; let pos: Position = { x: this.a.length, y: this.b.length }; @@ -119,6 +122,7 @@ class MyersDiff { return ops.toReversed(); } + // oxlint-disable-next-line zgeoff/function-verb -- Myers-native vocabulary private unwindRound(pos: Position, d: number): { ops: DiffOp[]; pos: Position } { const previous = this.findPrevious(pos, d); const prev = previous.prev; diff --git a/packages/format-codemod/src/transform/build-edits-from-ast.ts b/packages/format-codemod/src/transform/build-edits-from-ast.ts index df4b321..92de073 100644 --- a/packages/format-codemod/src/transform/build-edits-from-ast.ts +++ b/packages/format-codemod/src/transform/build-edits-from-ast.ts @@ -12,6 +12,7 @@ export function buildEditsFromAST(src: string, parsed: ParsedSource): Edit[] { return walk(file, parsed.program).toSorted((a, b) => b.start - a.start); } +// oxlint-disable-next-line zgeoff/function-verb -- algorithm-native traversal vocabulary function walk(file: SourceFile, node: ASTNode): Edit[] { const edits: Edit[] = []; diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index fbdf1d4..b495bb2 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -6,6 +6,7 @@ restriction-rule cherry-picks, comment-style enforcement via `@stylistic`, and a function declarations, bare named exports, destructured and inline-typed parameters, ternary and await arguments, awaits hidden inside a control-flow condition or a `&&`/`||`/`??` chain, and single-line `/** … */` blocks (auto-fixed to multi-line; inline `@type`/`@lends` casts exempt). +`zgeoff/function-verb` enforces the function-naming taxonomy from the shared agent guidelines. ## Usage @@ -34,6 +35,25 @@ import config from '@zgeoff/oxlint-config'; The plugin is addressable on its own at `@zgeoff/oxlint-config/plugin` for configs that want the `zgeoff/*` rules without the rest. +## `zgeoff/function-verb` + +Every function declaration, function-valued variable, and class method must start with a verb from +the function-naming taxonomy. Banned verbs report their taxonomy replacement (`fetchUser` → use +`read`). Object-literal properties are exempt — they overwhelmingly implement externally-defined +shapes whose names the author doesn't choose — as are names not starting with a lowercase letter. +Exempt an algorithm-native name (`walk`, `backtrack`) with a disable comment in the module +implementing that algorithm. + +Options extend the shipped set per repo: + +```jsonc +{ + "rules": { + "zgeoff/function-verb": ["error", { "verbs": ["walk"], "exemptNames": ["main"] }], + }, +} +``` + ## Notes - The config enables the `typescript`, `unicorn`, `oxc`, `import`, and `promise` plugins and loads diff --git a/packages/oxlint-config/function-verb.js b/packages/oxlint-config/function-verb.js new file mode 100644 index 0000000..3244904 --- /dev/null +++ b/packages/oxlint-config/function-verb.js @@ -0,0 +1,218 @@ +// the closed verb list from the shared function-naming taxonomy, one entry +// per table row; extending it here without the matching taxonomy edit fails +// the drift-guard test +const taxonomyVerbs = [ + 'is', + 'has', + 'can', + 'should', + 'needs', + 'build', + 'define', + 'parse', + 'encode', + 'decode', + 'derive', + 'plan', + 'pick', + 'find', + 'get', + 'collect', + 'count', + 'split', + 'merge', + 'sort', + 'format', + 'render', + 'normalize', + 'resolve', + 'expand', + 'compress', + 'decompress', + 'to', + 'transform', + 'apply', + 'create', + 'claim', + 'read', + 'load', + 'write', + 'remove', + 'update', + 'upsert', + 'set', + 'toggle', + 'reset', + 'print', + 'run', + 'check', + 'try', + 'register', + 'subscribe', + 'unsubscribe', + 'assert', + 'require', + 'verify', + 'emit', + 'send', + 'wait', + 'setup', + 'teardown', + 'start', + 'stop', + 'drain', + 'with', + 'make', + 'use', + 'on', + 'handle', +]; + +// the templated taxonomy entries (`with`, `handle`, …): the verb is +// only valid with a suffix, so the bare word is not a function name +const suffixRequiredVerbs = new Set([ + 'build', + 'define', + 'to', + 'toggle', + 'try', + 'with', + 'make', + 'use', + 'on', + 'handle', +]); + +// banned verb → the taxonomy verb to use instead; null means the verb is too +// vague to map and the name should say what the function does +const bannedVerbs = new Map([ + ['process', null], + ['manage', null], + ['do', null], + ['perform', null], + ['execute', 'run'], + ['compute', 'build'], + ['fetch', 'read'], + ['save', 'write'], + ['store', 'write'], + ['delete', 'remove'], + ['search', 'find'], + ['lookup', 'find'], +]); + +function isVerbMatch(name, verb, bareAllowed) { + if (name === verb) { + return bareAllowed; + } + + return name.startsWith(verb) && /[A-Z]/u.test(name.charAt(verb.length)); +} + +function findAllowedVerb(name, verbs) { + return verbs.find((verb) => isVerbMatch(name, verb, !suffixRequiredVerbs.has(verb))) ?? null; +} + +function findBannedVerb(name) { + for (const [verb, replacement] of bannedVerbs) { + if (isVerbMatch(name, verb, true)) { + return { verb, replacement }; + } + } + + return null; +} + +function planReport(name, verbs, exemptNames) { + if (exemptNames.has(name) || !/^[a-z]/u.test(name)) { + return null; + } + + if (findAllowedVerb(name, verbs) !== null) { + return null; + } + + const banned = findBannedVerb(name); + + if (banned === null) { + return { messageId: 'unknownVerb', data: { name } }; + } + + if (banned.replacement === null) { + return { messageId: 'vagueVerb', data: { name, verb: banned.verb } }; + } + + return { + messageId: 'bannedVerb', + data: { name, verb: banned.verb, replacement: banned.replacement }, + }; +} + +/** + * Enforces the function-naming taxonomy on function declarations, + * function-valued variables, and class methods. Object-literal properties are + * exempt — they overwhelmingly implement externally-defined shapes (rule + * visitors, route tables) whose names the author doesn't choose — as are + * names not starting with a lowercase letter (components, classes). + * Options: `verbs` appends repo-local verbs to the shipped taxonomy; + * `exemptNames` skips exact names. + */ +const functionVerb = { + meta: { + type: 'suggestion', + messages: { + unknownVerb: + "'{{name}}' does not start with a taxonomy verb — pick one, or extend the taxonomy and the `verbs` option in the same PR.", + bannedVerb: "'{{name}}' starts with the banned verb '{{verb}}' — use `{{replacement}}`.", + vagueVerb: "'{{name}}' starts with the banned verb '{{verb}}' — name what the function does.", + }, + schema: [ + { + type: 'object', + properties: { + verbs: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + exemptNames: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = context.options[0] ?? {}; + const verbs = + options.verbs === undefined ? taxonomyVerbs : [...taxonomyVerbs, ...options.verbs]; + const exemptNames = new Set(options.exemptNames); + + const checkName = (id) => { + const report = planReport(id.name, verbs, exemptNames); + + if (report !== null) { + context.report({ node: id, messageId: report.messageId, data: report.data }); + } + }; + + return { + FunctionDeclaration(node) { + if (node.id !== null) { + checkName(node.id); + } + }, + VariableDeclarator(node) { + const isFunctionInit = + node.init !== null && + node.init !== undefined && + (node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression'); + + if (isFunctionInit && node.id.type === 'Identifier') { + checkName(node.id); + } + }, + MethodDefinition(node) { + if (node.kind === 'method' && !node.computed && node.key.type === 'Identifier') { + checkName(node.key); + } + }, + }; + }, +}; + +export default functionVerb; diff --git a/packages/oxlint-config/oxlintrc.json b/packages/oxlint-config/oxlintrc.json index 0c26b17..372b316 100644 --- a/packages/oxlint-config/oxlintrc.json +++ b/packages/oxlint-config/oxlintrc.json @@ -26,6 +26,7 @@ "zgeoff/no-await-in-condition": "error", "zgeoff/no-await-in-logical": "error", "zgeoff/no-single-line-jsdoc": "error", + "zgeoff/function-verb": "error", // enforced comment style — line comments unless JSDoc or exclamation "@stylistic/multiline-comment-style": [ diff --git a/packages/oxlint-config/package.json b/packages/oxlint-config/package.json index 2d83725..87892e2 100644 --- a/packages/oxlint-config/package.json +++ b/packages/oxlint-config/package.json @@ -11,6 +11,7 @@ "files": [ "oxlintrc.json", "plugin.js", + "function-verb.js", "index.js" ], "type": "module", diff --git a/packages/oxlint-config/plugin.js b/packages/oxlint-config/plugin.js index 123636c..7b01f9b 100644 --- a/packages/oxlint-config/plugin.js +++ b/packages/oxlint-config/plugin.js @@ -1,9 +1,11 @@ +import functionVerb from './function-verb.js'; + // Custom lint rules, loaded through oxlint's jsPlugins (ESLint v9 rule API). // Each rule bans a shape no native oxlint rule can express — esquery selectors // for AST shapes, a source-comment scan where the target isn't // esquery-selectable. -function banSelectors(type, message, selectors) { +function buildBanRule(type, message, selectors) { return { meta: { type, messages: { banned: message }, schema: [] }, create(context) { @@ -96,7 +98,7 @@ const noSingleLineJSDoc = { const plugin = { meta: { name: 'zgeoff' }, rules: { - 'no-top-level-arrow': banSelectors( + 'no-top-level-arrow': buildBanRule( 'problem', 'Declare top-level functions with the `function` keyword instead of assigning an arrow function.', [ @@ -106,19 +108,19 @@ const plugin = { "Program > ExpressionStatement > AssignmentExpression[right.type='ArrowFunctionExpression']", ], ), - 'no-nested-function-declaration': banSelectors( + 'no-nested-function-declaration': buildBanRule( 'problem', 'Use an arrow function here; reserve the `function` keyword for top-level declarations.', [ 'FunctionDeclaration:not(Program > FunctionDeclaration):not(Program > ExportNamedDeclaration > FunctionDeclaration):not(Program > ExportDefaultDeclaration > FunctionDeclaration)', ], ), - 'no-bare-named-exports': banSelectors( + 'no-bare-named-exports': buildBanRule( 'problem', 'Export declarations inline instead of listing names in `export { ... }`.', ['ExportNamedDeclaration[specifiers.length>0][declaration=null][source=null]'], ), - 'no-inline-param-object-types': banSelectors( + 'no-inline-param-object-types': buildBanRule( 'problem', 'Give this parameter a named interface or type alias instead of an inline object-literal type.', [ @@ -126,7 +128,7 @@ const plugin = { 'TSMethodSignature > Identifier.params > TSTypeAnnotation > TSTypeLiteral', ], ), - 'no-destructured-params': banSelectors( + 'no-destructured-params': buildBanRule( 'suggestion', 'Accept the parameter whole instead of destructuring it in the signature.', [ @@ -134,12 +136,12 @@ const plugin = { ':matches(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression) > AssignmentPattern.params > ObjectPattern', ], ), - 'no-pick-destructuring': banSelectors( + 'no-pick-destructuring': buildBanRule( 'suggestion', 'Use direct member access; destructure an object only with a rest element to omit properties.', ['VariableDeclarator > ObjectPattern:not(:has(> RestElement))'], ), - 'no-ternary-args': banSelectors( + 'no-ternary-args': buildBanRule( 'suggestion', 'Extract this ternary to a named const instead of passing it as an argument.', [ @@ -147,12 +149,12 @@ const plugin = { 'NewExpression > ConditionalExpression.arguments', ], ), - 'no-await-args': banSelectors( + 'no-await-args': buildBanRule( 'suggestion', 'Await into a named const instead of awaiting inside an argument list.', ['CallExpression > AwaitExpression.arguments', 'NewExpression > AwaitExpression.arguments'], ), - 'no-await-in-condition': banSelectors( + 'no-await-in-condition': buildBanRule( 'suggestion', 'Await into a named const before the statement instead of inside a control-flow condition.', [ @@ -160,12 +162,13 @@ const plugin = { 'SwitchStatement > AwaitExpression.discriminant', ], ), - 'no-await-in-logical': banSelectors( + 'no-await-in-logical': buildBanRule( 'suggestion', 'Await into a named const instead of chaining it after a && / || / ?? operator.', ['LogicalExpression > AwaitExpression'], ), 'no-single-line-jsdoc': noSingleLineJSDoc, + 'function-verb': functionVerb, }, }; diff --git a/packages/oxlint-config/plugin.test.ts b/packages/oxlint-config/plugin.test.ts index c5eeec9..e34892d 100644 --- a/packages/oxlint-config/plugin.test.ts +++ b/packages/oxlint-config/plugin.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; const oxlintBin = join(import.meta.dir, '..', '..', 'node_modules', '.bin', 'oxlint'); const pluginPath = join(import.meta.dir, 'plugin.js'); +const sharedTaxonomyPath = join(import.meta.dir, '..', '..', 'agents', 'shared.md'); interface LintResult { exitCode: number; @@ -12,13 +13,12 @@ interface LintResult { output: string; } -async function createLintTree(source: string): Promise { +type RuleSettings = Readonly>; + +async function createLintTree(source: string, rules: RuleSettings): Promise { const dir = await mkdtemp(join(tmpdir(), 'oxlint-config-test-')); - const config = { - jsPlugins: [pluginPath], - rules: { 'zgeoff/no-single-line-jsdoc': 'error' }, - }; + const config = { jsPlugins: [pluginPath], rules }; await writeFile(join(dir, '.oxlintrc.json'), JSON.stringify(config)); await writeFile(join(dir, 'sample.ts'), source); @@ -26,8 +26,8 @@ async function createLintTree(source: string): Promise { return dir; } -async function runLint(source: string, fix: boolean): Promise { - const dir = await createLintTree(source); +async function runLint(source: string, fix: boolean, rules?: RuleSettings): Promise { + const dir = await createLintTree(source, rules ?? { 'zgeoff/no-single-line-jsdoc': 'error' }); const fixArgs = fix ? ['--fix'] : []; const args = [oxlintBin, '-c', '.oxlintrc.json', ...fixArgs, 'sample.ts']; @@ -39,6 +39,50 @@ async function runLint(source: string, fix: boolean): Promise { return { exitCode, stdout, output }; } +/** + * Collects the allowed verbs from the taxonomy tables in the shared agents + * partial: every table row between the section heading and the banned + * paragraph, with `` templates reduced to their leading verb. + */ +function collectTaxonomyVerbs(markdown: string): string[] { + const start = markdown.indexOf('### Function naming'); + const end = markdown.indexOf('**Banned**'); + const section = markdown.slice(start, end); + const verbs = section.match(/(?<=^\| `)[a-z]+/gmu) ?? []; + + return [...new Set(verbs)]; +} + +/** + * Collects the banned verbs from the shared agents partial: the backticked + * words in the banned paragraph, excluding parenthesized asides (replacement + * pointers and the framework-convention carve-out). + */ +function collectBannedVerbs(markdown: string): string[] { + const start = markdown.indexOf('**Banned**'); + const end = markdown.indexOf('Algorithm-native'); + const paragraph = markdown.slice(start, end); + const banned: string[] = []; + + for (const match of paragraph.matchAll(/`(?[a-z]+)`/gu)) { + const preceding = paragraph.slice(0, match.index); + const verb = match.groups?.['verb']; + + if ( + verb !== undefined && + countOccurrences(preceding, '(') === countOccurrences(preceding, ')') + ) { + banned.push(verb); + } + } + + return banned; +} + +function countOccurrences(text: string, part: string): number { + return text.split(part).length - 1; +} + test('it flags a single-line JSDoc block', async () => { const result = await runLint('/** Documents the export. */\nexport const answer = 42;\n', false); @@ -99,3 +143,154 @@ test('it reports but does not fix a block sharing its line with code', async () expect(result.exitCode).toBe(1); expect(result.output).toBe(source); }); + +test('it flags a function whose name lacks a taxonomy verb', async () => { + const source = 'export function grabConfig(): number {\n return 1;\n}\n'; + + const result = await runLint(source, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toInclude('function-verb'); + expect(result.stdout).toInclude('grabConfig'); +}); + +test('it points a banned verb at its replacement', async () => { + const source = 'export function fetchUser(): number {\n return 1;\n}\n'; + + const result = await runLint(source, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toInclude("banned verb 'fetch'"); + expect(result.stdout).toInclude('`read`'); +}); + +test('it tells a vague banned verb to name what the function does', async () => { + const source = 'export function processInput(): number {\n return 1;\n}\n'; + + const result = await runLint(source, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toInclude('name what the function does'); +}); + +test('it accepts taxonomy verbs on declarations, const functions, and class methods', async () => { + const source = [ + 'export function buildThing(): number {', + ' return 1;', + '}', + '', + 'export function withScope(run: () => void): void {', + ' const applyAll = () => run();', + '', + ' applyAll();', + '}', + '', + 'export class Box {', + ' updateValue(): void {}', + '}', + '', + ].join('\n'); + + const result = await runLint(source, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(0); +}); + +test('it requires a suffix on templated verbs', async () => { + const bare = 'export const handle = (): void => {};\n'; + const suffixed = 'export function handleRowClick(): void {}\n'; + + const bareResult = await runLint(bare, false, { 'zgeoff/function-verb': 'error' }); + const suffixedResult = await runLint(suffixed, false, { 'zgeoff/function-verb': 'error' }); + + expect(bareResult.exitCode).toBe(1); + expect(suffixedResult.exitCode).toBe(0); +}); + +test('it matches a verb only at a camelCase boundary', async () => { + const source = 'export function tokenize(): number {\n return 1;\n}\n'; + + const result = await runLint(source, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(1); +}); + +test('it skips PascalCase names, object-literal properties, getters, and setters', async () => { + const source = [ + 'export function Component(): null {', + ' return null;', + '}', + '', + 'export const visitor = {', + ' enter(): void {},', + '};', + '', + 'export class Box {', + ' get value(): number {', + ' return 1;', + ' }', + '', + ' set value(next: number) {}', + '}', + '', + ].join('\n'); + + const result = await runLint(source, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(0); +}); + +test('it accepts repo-local verbs added through the verbs option', async () => { + const source = 'export function walkTree(): void {}\n'; + + const bareRule = await runLint(source, false, { 'zgeoff/function-verb': 'error' }); + + const extended = await runLint(source, false, { + 'zgeoff/function-verb': ['error', { verbs: ['walk'] }], + }); + + expect(bareRule.exitCode).toBe(1); + expect(extended.exitCode).toBe(0); +}); + +test('it skips names listed in the exemptNames option', async () => { + const source = 'export function main(): void {}\n'; + + const result = await runLint(source, false, { + 'zgeoff/function-verb': ['error', { exemptNames: ['main'] }], + }); + + expect(result.exitCode).toBe(0); +}); + +test('it accepts every verb in the shared taxonomy', async () => { + const markdown = await readFile(sharedTaxonomyPath, 'utf8'); + + const verbs = collectTaxonomyVerbs(markdown); + + expect(verbs.length).toBeGreaterThan(40); + + const lines = verbs.map((verb) => `export function ${verb}Thing(): void {}`); + + const result = await runLint(`${lines.join('\n')}\n`, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(0); +}); + +test('it rejects every verb the shared taxonomy bans', async () => { + const markdown = await readFile(sharedTaxonomyPath, 'utf8'); + + const banned = collectBannedVerbs(markdown).filter((verb) => verb !== 'handle'); + + expect(banned.length).toBeGreaterThan(10); + + const lines = banned.map((verb) => `export function ${verb}Thing(): void {}`); + + const result = await runLint(`${lines.join('\n')}\n`, false, { 'zgeoff/function-verb': 'error' }); + + expect(result.exitCode).toBe(1); + + for (const verb of banned) { + expect(result.stdout).toInclude(`${verb}Thing`); + } +});