A metaphor always reads better at the moment you pick it

Skill Factory · No. 101

Shipped

This release adds a spec-time check that refuses a name saying nothing about the job, and renames the skill enforcing it from smith to skillfactory. The demo fixture moved too, from tally to repocount, because tally fails the new rule and a scaffolder whose own golden output violates the rule it enforces is not enforcing one.

The check is the transferable part. Naming guidance is usually a paragraph in a style guide that everybody agrees with and nobody runs, and it stays that way because “does this name describe the thing” sounds like a judgment call. It is closer to a string problem than it looks.

What a bad name actually costs

Three tools here shipped as forge, assay and smith. None of those words says anything about generating GitHub Actions workflows, grading a run, or making skills, so every mention needed a gloss. All three were eventually renamed to ghfactory, eval and skillfactory.

A rename is not a find-and-replace, because a name is not one string. Each of these had a directory, a plugin id, a slash command, a CLI binary, an npm package name, a required status check, and a release tag prefix. Every one of those is a separate identity that something else already points at, and the tag prefixes cut before the rename keep the old spelling forever.

For an agent skill the name is also load-bearing at runtime. Anthropic’s skill authoring guidance is explicit that “the ‘name’ and ‘description’ in your Skill’s metadata are particularly critical. Claude uses these when determining whether to trigger the Skill in response to the current task,” and that only that metadata is preloaded at startup. The same page tells you to avoid “vague names: helper, utils, tools.”

Which is correct, and is advice. Advice loses to a name that sounded great on a Tuesday. Phil Karlton’s line, as Fowler records it, is that “there are only two hard things in Computer Science: cache invalidation and naming things.” The move is to stop treating the hard part as a matter of taste and check the part that is mechanical.

State the rule so a machine can check it

Here is the rule that turned out to be checkable:

Every hyphen-separated segment of the name must be spellable out of words that already appear in what the thing says it does, allowing for a role word and for an abbreviation whose expansion is in the text.

Three pieces to that, and each exists because the naive version is wrong.

“Spellable out of”, not “appears in”. A name is rarely a word from the description verbatim. repocount is repo plus count, and the description says “repository” and “count”. Substring matching finds neither.

Role words are exempt. factory, report, sync, eval, stats describe a kind of thing rather than a subject. They carry no domain meaning, so they cannot be the metaphor you are trying to catch, and demanding the description contain the word “factory” would be silly.

Abbreviations need their expansion present. gh is fine when the text says GitHub and meaningless otherwise. Two-letter pieces are the easiest way to accidentally spell anything, so they are admitted only from a table and only when the long form is really there.

Spell the name out of its own description

The implementation is word-break reachability: walk the segment left to right, and mark position j reachable if some earlier reachable position i leaves a piece segment[i..j) the vocabulary earns. Save it as namecheck.mjs:

// Does the name say what the thing does?
//
// A name is the only part a person sees before deciding whether to use it.
// The rule: every hyphen-separated segment must be spellable out of words the
// description already uses, give or take a role word or a known abbreviation.

const STOPWORDS = new Set([
  'the', 'and', 'for', 'that', 'this', 'with', 'from', 'when', 'what', 'your',
  'you', 'into', 'they', 'them', 'use', 'uses', 'used', 'not', 'are', 'was',
]);

// Words that describe a KIND of thing rather than its subject matter. These are
// always allowed: they carry no domain meaning, so they cannot be a metaphor.
const ROLE_WORDS = new Set(['factory', 'report', 'sync', 'eval', 'kit', 'log', 'stats']);

// Two-letter abbreviations, admitted only when the expansion is in the text.
const ABBREVS = { gh: 'github', db: 'database', ci: 'continuous integration' };

const MIN_STEM = 4;

function vocabulary(spec) {
  const text = [spec.summary, spec.description, spec.oneRule]
    .filter(Boolean).join(' ').toLowerCase();
  const words = (text.match(/[a-z]{3,}/g) ?? []).filter((w) => !STOPWORDS.has(w));
  return { text, tokens: new Set(words) };
}

// A piece is earned if it prefixes a described word ("repo" of "repository"),
// or extends one ("logger" from "log"). Only a stem of MIN_STEM or more may be
// extended, or every three-letter fragment starts spelling names.
function earned(piece, tokens) {
  for (const t of tokens) {
    if (t.startsWith(piece)) return true;
    if (t.length >= MIN_STEM && piece.startsWith(t)) return true;
  }
  return false;
}

// Can this segment be spelled entirely out of pieces the text earns?
// Word-break reachability: reachable[i] means "segment[0..i) is spellable".
function covers(segment, text, tokens) {
  const n = segment.length;
  const reachable = new Array(n + 1).fill(false);
  reachable[0] = true;
  for (let i = 0; i < n; i++) {
    if (!reachable[i]) continue;
    for (let j = i + 2; j <= n; j++) {
      const piece = segment.slice(i, j);
      if (piece.length === 2) {
        const expansion = ABBREVS[piece];
        if (!expansion || !text.includes(expansion)) continue;
      } else if (!(ROLE_WORDS.has(piece) || earned(piece, tokens))) {
        continue;
      }
      reachable[j] = true;
    }
  }
  return reachable[n];
}

export function nameCoverage(name, spec) {
  const { text, tokens } = vocabulary(spec);
  const uncovered = String(name ?? '')
    .split('-')
    .filter(Boolean)
    .filter((segment) => !covers(segment, text, tokens));
  return { ok: uncovered.length === 0, uncovered };
}

export function validate(spec) {
  const problems = [];
  if (!/^[a-z][a-z0-9-]{1,30}$/.test(spec.name ?? '')) {
    problems.push({ field: 'name', why: 'must be lowercase kebab, 2-31 chars' });
  } else {
    const { ok, uncovered } = nameCoverage(spec.name, spec);
    if (!ok) {
      problems.push({
        field: 'name',
        why: `"${uncovered.join('", "')}" appears nowhere in what this says it does`,
      });
    }
  }
  if (!spec.summary || spec.summary.length < 20) {
    problems.push({ field: 'summary', why: 'needs a one-line summary' });
  }
  return { ok: problems.length === 0, problems };
}

nameCoverage returns the uncovered segments rather than a boolean, which is what lets the error say which part of the name is unearned. “smith appears nowhere in what this says it does” tells an author what to change; “invalid name” does not.

Pin it with a trap that can only fail one way

Now the part that decides whether this check is still working in a year.

The obvious test is “a metaphor name is rejected”. That test passes for the wrong reason the moment any unrelated rule tightens, because a spec that fails for a missing summary also fails. This is the same blind spot mutation testing exists to expose: coverage tells you a line ran, not that anything checked it, and a surviving mutant “might indicate your tests do not sufficiently cover the code.”

Two properties fix it. Hold every field constant except the name, so a difference can only be about the name. And assert which field the failure landed on. Save this as naming.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { nameCoverage, validate } from './namecheck.mjs';

// One spec. The ONLY thing that varies between cases is the name, so a failure
// can only be about the name.
const SPEC = {
  summary: 'Counts what a repository owes you: open pull requests, stale branches, unreleased commits.',
  description:
    'Use when someone asks "what is unreleased", "how many open PRs", or wants a repository backlog count.',
  oneRule: 'Never report a count it did not read from the repository itself.',
};

const spec = (name) => ({ ...SPEC, name });

test('a name spelled out of the description passes', () => {
  assert.deepEqual(nameCoverage('repocount', spec('repocount')), { ok: true, uncovered: [] });
});

test('a role word needs no support from the text', () => {
  assert.ok(nameCoverage('repo-report', spec('repo-report')).ok);
});

test('an abbreviation passes only when its expansion is in the text', () => {
  const withGithub = { ...SPEC, description: SPEC.description + ' Reads GitHub.' };
  assert.ok(nameCoverage('gh-count', { ...withGithub, name: 'gh-count' }).ok);
  assert.deepEqual(nameCoverage('gh-count', spec('gh-count')).uncovered, ['gh']);
});

// The trap. Same spec, metaphor name.
test('a metaphor is rejected, and the failure names the segment', () => {
  const { ok, uncovered } = nameCoverage('smith', spec('smith'));
  assert.equal(ok, false);
  assert.deepEqual(uncovered, ['smith']);
});

// This is the assertion that keeps the trap honest. Without it, the test still
// passes the day an unrelated rule starts rejecting this spec for some other
// reason, and it stops proving anything about names at all.
test('the metaphor spec fails on the name field specifically', () => {
  const { ok, problems } = validate(spec('smith'));
  assert.equal(ok, false);
  assert.deepEqual(problems.map((p) => p.field), ['name']);
});

test('the same spec with a descriptive name has no problems at all', () => {
  assert.deepEqual(validate(spec('repocount')), { ok: true, problems: [] });
});
node --test
✔ a name spelled out of the description passes (0.584625ms)
✔ a role word needs no support from the text (0.093083ms)
✔ an abbreviation passes only when its expansion is in the text (0.057375ms)
✔ a metaphor is rejected, and the failure names the segment (0.2705ms)
✔ the metaphor spec fails on the name field specifically (0.089916ms)
✔ the same spec with a descriptive name has no problems at all (0.071042ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0

Run it on real candidates

A short driver makes the check usable while you are still choosing. Save it as check.mjs, importing validate and looping over candidate names against one spec:

import { validate } from './namecheck.mjs';

const SPEC = {
  summary: 'Counts what a repository owes you: open pull requests, stale branches, unreleased commits.',
  description: 'Use when someone asks "what is unreleased", "how many open PRs", or wants a repository backlog count.',
  oneRule: 'Never report a count it did not read from the repository itself.',
};

for (const name of ['smith', 'tally', 'repocount', 'repo-report']) {
  const { ok, problems } = validate({ ...SPEC, name });
  console.log(`${ok ? 'ok  ' : 'FAIL'} ${name.padEnd(12)} ${problems.map((p) => `${p.field}: ${p.why}`).join('; ')}`);
}
FAIL smith        name: "smith" appears nowhere in what this says it does
FAIL tally        name: "tally" appears nowhere in what this says it does
ok   repocount    
ok   repo-report

Both rejected names are real: smith is what this skill shipped as, and tally is what its own demo fixture was called until this release.

Gotchas

A trap that merely fails somewhere stops proving anything. The metaphor fixture is identical to the passing one in every field except the name, and the test asserts the failing field is name. Without that assertion the case still goes green after an unrelated rule starts rejecting the fixture for a different reason, and the naming check could be deleted entirely without anything turning red. Escape: when you pin a rejection, assert the rejection’s location, not just its existence.

Test files that a generator owns get regenerated. These cases live in naming.test.mjs rather than the scaffolded baseline.test.mjs, because the freeze command rewrites that file from a template and would have silently taken the naming tests with it. Escape: before adding a case to a test file, check whether any tool writes that file; if one does, put the case in a sibling the generator does not own.

A checker whose rule is too generous passes everything. Allowing any fragment to extend any word means a three-letter stem in the prose can spell almost any name, and the check goes quietly vacuous while still returning ok. The minimum stem length exists only to stop that. Escape: whenever you loosen a matching rule to admit a legitimate case, re-run the known-bad fixture; a rule that no longer rejects it is not a rule.

Enforcing a new rule on existing work breaks the work, not the rule. This grades at spec time only. The skills that predate it are grandfathered, and the verify path does not re-grade a shipped name, because a name already spent on a directory, a tag prefix and a status check cannot be fixed by a linter telling you about it now. Escape: date the rule to when it landed and apply it going forward, or the first thing your new check does is fail your whole repository.

Sources

Changelog

  • refactor: name three skills after the job they do, and enforce it (#156) (0907422)