Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,17 @@ Same `get/put/has` port, three backings:
home for the socket "door"; the CAS blobs/refs/lineage live under `<room>/cas`.

## Status
v0.4.0 — runnable. Deterministic + caching + grounding verified; the Anthropic path is
v0.5.1 — runnable. Deterministic + caching + grounding verified; the Anthropic path is
implemented (live-verify with a key). `audit`/`extract` are authored once as
[`verbspec`](https://github.com/bounded-systems/verbspec) `VerbSpec`s and projected to CLI
+ MCP (the `string-audit-mcp` bin); the `report` tool is the same projection (#18, #19).
Copy-hygiene suite (ai-isms, overclaims, proofread, readability) with data-driven
[`ai-tells.json`](ai-tells.json) rules + first-class severity. The optional Vale provider
ships, gated on `AUDIT_VALE` (#6, #12); em-dash voice tells (antithesis, cadence) are
`suggestion`, not `warn`, so intentional voice doesn't gate downstream.
Copy-hygiene suite (ai-isms, overclaims, proofread, readability, **registry-drift**) with
data-driven [`ai-tells.json`](ai-tells.json) rules + first-class severity. **registry-drift**
(#22) checks copy against the live verbspec registry — a `--flag`/enum the surface no longer
has is an `error`; its vocab is built from the projected MCP schema, not Zod internals (#27).
Optional Vale + textlint providers, gated on `AUDIT_VALE` / `AUDIT_TEXTLINT` (#6, #12, #22);
em-dash voice tells (antithesis, cadence) are `suggestion`, not `warn`, so intentional voice
doesn't gate downstream.
`cas`/`anchored-chain` are optional deps (the `STORE=cas`/socket backings); the default
run needs neither. The default catalog is the **real semantic-key registry** —
[`brand`](https://github.com/bounded-systems/brand)'s canonical content tokens, vendored
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bounded-systems/string-audit",
"version": "0.5.0",
"version": "0.5.1",
"description": "Cost-aware, grounded content auditor — typed string symbols, type-scoped audits, CAS-memoized LLM calls.",
"type": "module",
"bin": {
Expand Down
43 changes: 24 additions & 19 deletions prose.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -177,37 +177,42 @@ export function findOverlaps(catalog) {
// retext: 35-dep chain, AST, autofix — overkill for flag/token matching.
// In-house: zero deps, the registry is already the typed source of truth.
//
// prose.mjs must not import verbs.mjs (circular). Callers pass a `vocab` built by
// vocabFromRegistry() before calling into the prose pipeline.
// prose.mjs must not import verbspec/verbs.mjs (circular + keeps the consumer's prose-only
// gate JSR-free). Callers project the registry and pass a `vocab` built by vocabFromToolset().

const FLAG_RE = /(?<![\w-])--([a-z][a-z0-9-]*)/gi;
const enumRef = (name) => new RegExp(`\\b(?:--${name}[ =]|${name.toUpperCase()}=)([a-z][a-z0-9-]*)`, "gi");
const BACKTICK_RE = /`([^`]+)`/g;
const BIN_VERB_RE = /^([\w][\w-]*)\s+([\w][\w-]*)$/;

// Adapter: build the vocab from a verbspec registry (verbs.mjs format).
// Call this once per audit run and pass the result to registryDrift().
export function vocabFromRegistry(reg) {
if (!reg) return null;
const verbIds = new Set(Object.keys(reg));
// Adapter: build the vocab from verbspec's PUBLIC projection — `toMcpToolset(registry)`,
// i.e. [{ name, inputSchema }] where inputSchema is JSON Schema. `name` → verb id;
// inputSchema.properties keys → flags; properties[x].enum → enum values. This deliberately
// avoids reaching into Zod internals (`verb.input._def.shape()` / `field._def.entries`): a
// private surface a zod/verbspec bump could change, silently degrading the vocab — and
// since an unknown `--flag` is an error, a degraded vocab false-positives valid copy and
// breaks the gate. The MCP projection is the stable contract test.mjs already pins.
// prose.mjs stays verbspec-free; verbs.mjs (which has verbspec) projects and passes this.
// Build once per audit run and hand the result to registryDrift().
export function vocabFromToolset(toolset, bins = ["string-audit", "string-audit-mcp"]) {
if (!Array.isArray(toolset)) return null;
const verbIds = new Set();
const flags = new Set(["help", "version"]);
const enums = {};
const bins = new Set(["string-audit", "string-audit-mcp"]);
for (const verb of Object.values(reg)) {
try {
const shape = verb.input?.shape ?? verb.input?._def?.shape?.() ?? {};
for (const [name, field] of Object.entries(shape)) {
flags.add(name);
const opts = field?.options ?? field?._def?.entries ?? field?._def?.values;
if (opts) enums[name] = new Set(Array.isArray(opts) ? opts : Object.values(opts));
}
} catch { /* verbspec version differences */ }
for (const t of toolset) {
if (t?.name) verbIds.add(t.name);
for (const [name, prop] of Object.entries(t?.inputSchema?.properties ?? {})) {
flags.add(name.toLowerCase());
if (Array.isArray(prop?.enum)) enums[name.toLowerCase()] = new Set(prop.enum.map((x) => String(x).toLowerCase()));
}
}
return { verbIds, flags, enums, bins };
return { verbIds, flags, enums, bins: new Set(bins) };
}

export function registryDrift(value, type, vocab) {
if (!vocab || !["body", "headline", "subhead", "title"].includes(type)) return [];
// Fail-safe: a missing/degraded vocab (only the globals → projection failed) must NOT
// turn valid --flags into false-positive errors. No-op instead of flagging.
if (!vocab || vocab.flags.size <= 2 || !["body", "headline", "subhead", "title"].includes(type)) return [];
const { verbIds, flags, enums, bins } = vocab;
const out = [];
const seen = new Set();
Expand Down
15 changes: 12 additions & 3 deletions test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// shape + response parsing (the live call itself is a keyed run, not tested here).
import assert from "node:assert/strict";
import { buildRequest, parseResponse } from "./anthropic.mjs";
import { aiIsms, overclaims, spellCheck, proofread, readability, registryDrift, vocabFromRegistry } from "./prose.mjs";
import { aiIsms, overclaims, spellCheck, proofread, readability, registryDrift, vocabFromToolset } from "./prose.mjs";
import { valeLint, valeEnabled } from "./vale.mjs";
import { textlintEnabled, textlintLint } from "./textlint.mjs";
import { auditVerb, extractVerb, registry } from "./verbs.mjs";
Expand Down Expand Up @@ -87,8 +87,9 @@ assert.equal(textlintEnabled(), false, "textlint provider is off unless AUDIT_TE
assert.deepEqual(await textlintLint("anything"), [], "textlint provider is a no-op when off");

// ── registry-aware drift check (issue #22, Direction 2) ─────────────────────────
const minReg = { audit: { input: { shape: { catalog: {}, store: { options: ["fs", "cas", "socket"] } } } } };
const minVocab = vocabFromRegistry(minReg);
// Vocab is built from the projected MCP tool schema (#27) — { name, inputSchema } — not Zod internals.
const minToolset = [{ name: "audit", inputSchema: { properties: { catalog: {}, store: { enum: ["fs", "cas", "socket"] } } } }];
const minVocab = vocabFromToolset(minToolset);
assert.equal(registryDrift("Clean headline copy.", "headline", minVocab).length, 0, "no flag refs → no drift findings");
assert.equal(registryDrift("Run `string-audit audit` to check.", "body", minVocab).length, 0, "known bin+verb pair → no drift");
assert.ok(registryDrift("Run `string-audit analyze` to scan.", "body", minVocab).some((f) => f.level === "error" && /analyze/.test(f.msg)), "unknown verb → error");
Expand All @@ -99,6 +100,14 @@ assert.ok(registryDrift("Use STORE=socket for the backend.", "body", minVocab).l
assert.equal(registryDrift("Run `string-audit audit` now.", "cta", minVocab).length, 0, "cta type is skipped (not tool-doc copy)");
assert.equal(registryDrift(null, "body", null).length, 0, "no vocab → graceful no-op");

// #27 — vocab from the REAL projected registry (not Zod internals): a verbspec/zod bump
// that breaks the MCP projection fails HERE, instead of silently false-positiving valid flags.
const realVocab = vocabFromToolset(Object.values(registry).map((vb) => toMcpTool(vb)));
assert.ok(realVocab.flags.has("catalog") && realVocab.flags.has("store"), "real registry projects its flags");
assert.ok(realVocab.verbIds.has("audit") && realVocab.verbIds.has("extract"), "real registry projects its verb ids");
assert.equal(registryDrift("Pass --catalog and --store to audit.", "body", realVocab).length, 0, "valid registry flags never false-positive");
assert.equal(registryDrift("Pass --catalog now.", "body", vocabFromToolset([])).length, 0, "degraded vocab (empty projection) no-ops — no false errors");

console.log("✓ prose checks verified — { level, msg } + ai-isms + overclaims + proofread + readability + vale gate + registry-drift");

// ── verbspec surfaces: audit + extract as VerbSpecs → CLI + MCP (verbs.mjs) ──────
Expand Down
9 changes: 5 additions & 4 deletions verbs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { createHash } from "node:crypto";
import { dirname, join, basename } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { defineVerb } from "@bounded-systems/verbspec";
import { defineVerb, toMcpTool } from "@bounded-systems/verbspec";
import { auditWithAnthropic } from "./anthropic.mjs";
import { spellCheck, grammarCheck, aiIsms, overclaims, proofread, readability, findOverlaps, registryDrift, vocabFromRegistry } from "./prose.mjs";
import { spellCheck, grammarCheck, aiIsms, overclaims, proofread, readability, findOverlaps, registryDrift, vocabFromToolset } from "./prose.mjs";
import { valeLint } from "./vale.mjs";
import { textlintLint } from "./textlint.mjs";
import { loadCatalog } from "./catalog.mjs";
Expand Down Expand Up @@ -126,8 +126,9 @@ export const auditVerb = defineVerb({
writeFileSync(lastFile, JSON.stringify(Object.fromEntries(Object.entries(results).map(([s, r]) => [s, r.score]))));

const typeLevel = (m) => /UNGROUNDED|grounded/i.test(m) ? "error" : "suggestion";
// Build the registry vocab once (not per-symbol) so registryDrift stays pure/no-import.
const vocab = vocabFromRegistry(registry);
// Build the registry vocab once (not per-symbol) from verbspec's public MCP projection,
// so registryDrift stays pure/verbspec-free and a zod bump can't silently degrade it.
const vocab = vocabFromToolset(Object.values(registry).map((vb) => toMcpTool(vb)));
// textlintLint is async (dynamic import); all others are sync. Run async prose in parallel,
// sync prose inline. The map returns Promises, which we settle via Promise.all.
const symbols = await Promise.all(Object.entries(results).map(async ([s, r]) => {
Expand Down
Loading