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
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ jobs:
| node mcp.mjs)
echo "$OUT"
echo "$OUT" | grep -q '"name":"audit"' # tools/list projects the audit verb
echo "$OUT" | grep -q '"name":"extract"' # ...and the extract verb
echo "$OUT" | grep -q '"name":"extract"' # ...and extract
echo "$OUT" | grep -q '"name":"scan"' # ...and scan
echo "$OUT" | grep -q 'coverage' # tools/call runs extract → structured output
- name: Registry drift spike smoke-test
run: node spikes/registry-drift.mjs
Expand All @@ -59,10 +60,10 @@ jobs:
run: npm install --include=optional --no-audit --no-fund
- name: Concept drift spike — stemmed matching (optional stemmer now installed)
run: node spikes/concept-drift.mjs samples/page.html | grep -q "token overlap (stemmed)"
- name: Static-string scan spike — AST extract + Zod-typed keepers, emit round-trips
- name: Scan verb — AST extract + Zod-typed keepers, emit round-trips
run: |
node spikes/scan.mjs | grep -q "acorn AST" # optional acorn now installed
node spikes/scan.mjs --emit > /tmp/scan.json
node scan.mjs | grep -q "acorn AST" # optional acorn now installed
node scan.mjs --emit > /tmp/scan.json
node -e 'const o=require("/tmp/scan.json");const n=Object.keys(o).filter(k=>!k.startsWith("$")).length;if(!n){process.exit(1)}console.log(n+" keepers")'
CATALOG=/tmp/scan.json node audit.mjs > /dev/null # the emitted keepers audit clean
- name: Room — mount the socket store + audit through it
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@ human CLI view, and MCP / agents consume `output` directly:
every string on a surface to a DTCG token (covered ones reuse their catalog symbol,
uncovered get a proposed `surface.*` key) — **bootstrap a catalog from a page**, then
refine. Round-trips: the emitted file is a valid `CATALOG=` input.
- **Scan** — `node scan.mjs [dir] [--emit]` (the `string-audit-scan` bin) extracts **every
hardcoded static string from source** (i18n-style, AST via optional `acorn`), splits
*keepers* (copy that should be a typed symbol) from incidental, and **Zod-validates** each
keeper against its type's contract ([`types.mjs`](types.mjs) — the same contracts `audit`
uses). All static strings surfaced; Zod + symbols for the ones that matter.
- **MCP** — `node mcp.mjs` (the `string-audit-mcp` bin) is a stdio MCP server exposing
`audit` + `extract` as tools: `tools/list` is the projected toolset, `tools/call`
validates arguments against the verb's Zod input and runs it. So an agent can audit copy
or extract a surface as a tool call.
`audit` + `extract` + `scan` as tools: `tools/list` is the projected toolset, `tools/call`
validates arguments against the verb's Zod input and runs it. So an agent can audit copy,
extract a surface, or scan a tree as a tool call.
- **Anthropic** — the `report` tool (above) is the same projection (`toAnthropicTool`).

## Copy hygiene — deterministic prose checks
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
"type": "module",
"bin": {
"string-audit": "audit.mjs",
"string-audit-mcp": "mcp.mjs"
"string-audit-mcp": "mcp.mjs",
"string-audit-scan": "scan.mjs"
},
"scripts": {
"audit": "node audit.mjs",
"extract": "node extract.mjs",
"scan": "node scan.mjs",
"mcp": "node mcp.mjs",
"test": "node test.mjs"
},
Expand Down
27 changes: 27 additions & 0 deletions scan.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// `scan` — extract every hardcoded static string from source (i18n-style), then type +
// Zod-validate the keepers (the copy that should be symbols). The `string-audit-scan` bin
// / `scan` verb (verbs.mjs) projected to a CLI via verbspec. AST extraction via optional
// `acorn`; the per-type Zod contracts are shared with `audit` (types.mjs).
// node scan.mjs [dir] # report
// node scan.mjs [dir] --emit > content/strings.json # DTCG catalog of the keepers
// node scan.mjs --help
import { parseArgs, toHelp } from "@bounded-systems/verbspec";
import { scanVerb } from "./verbs.mjs";

const argv = process.argv.slice(2);
if (argv.includes("--help") || argv.includes("-h")) {
// single-verb bin: drop verbspec's `<bin> <verb>` prefix (no `scan` subcommand here).
console.log(toHelp(scanVerb, "node scan.mjs").replace("node scan.mjs scan", "node scan.mjs"));
process.exit(0);
}
const input = parseArgs(scanVerb, argv);
const output = await scanVerb.run(input);
if (input.emit) {
const slug = (v) => v.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").split("-").slice(0, 5).join("-");
const doc = { "$description": `Keeper strings scanned from ${output.dir} (${output.keepers.length} of ${output.literals} literals, ${output.parser}). Typed; review + adopt as content tokens.` };
for (const k of output.keepers) doc[`scan.${k.type}.${slug(k.value)}`] = { "$value": k.value, "$type": k.type, "$description": `from ${k.file}${k.valid ? "" : ` (⚠ ${k.error})`}` };
process.stdout.write(JSON.stringify(doc, null, 2) + "\n");
} else {
process.stdout.write(scanVerb.render(output));
}
1 change: 0 additions & 1 deletion spikes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ zero-dep and runnable on its own.
| `vale-rules.mjs` | Data-driven scan over the (now root) `ai-tells.json` returning **structured `{ level, msg }`** findings — the shape `prose.mjs` adopted. (Production reads the same file; this is just a standalone demo.) | `node spikes/vale-rules.mjs` |
| `registry-drift.mjs` | **[#22] schema-aware drift check** — flags copy referencing a `--flag` or enum value that isn't in the verbspec registry (`verbs.mjs`): renamed/removed/typo'd surface = a correctness `error`. Pure + zero-dep (takes a `vocab`); `vocabFromRegistry(registry)` is the wire-up adapter. The differentiated check no off-the-shelf linter does. | `node spikes/registry-drift.mjs` |
| `concept-drift.mjs` | **[#28] concept drift (string-level)** — treats every string as a unit, like i18n/translation (whole messages, not single words): does each canonical brand *message* have a semantically-matching surface string, and which surface strings are *off-message*? A *signal*, not a gate. **Three match tiers**, best-available + graceful fallback: **embeddings** (semantic — paraphrases count; opt-in `EMBED_API_KEY`, OpenAI-compatible — OpenAI/Voyage/ollama) → **token overlap, stemmed** (optional `stemmer`) → **token overlap, exact** (zero-dep). Pairs with `extract --emit`. | `node spikes/concept-drift.mjs` |
| `scan.mjs` | **static-string extraction over SOURCE** — i18n/translation-style: walk the `.mjs/.js/.ts` tree, pull **every** string literal, then split **keepers** (hardcoded copy that should be a typed symbol) from incidental (identifiers/paths/code). Each keeper gets an inferred type **validated by a Zod schema** (headline ≤65, cta = action-verb, meta ≤160 …) — "all static strings" surfaced, "Zod + symbols" for the ones that matter. AST extraction via optional `acorn` (regex fallback). `--emit` writes a DTCG `content/strings.json` of the keepers (round-trips into `audit`). | `node spikes/scan.mjs [dir] [--emit]` |

## The direction these point at

Expand Down
124 changes: 0 additions & 124 deletions spikes/scan.mjs

This file was deleted.

20 changes: 17 additions & 3 deletions test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { buildRequest, parseResponse } from "./anthropic.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";
import { auditVerb, extractVerb, scanVerb, registry } from "./verbs.mjs";
import { typeFindings, claimFindings } from "./types.mjs";
import { toMcpToolset, toMcpTool, parseArgs } from "@bounded-systems/verbspec";

// request shape
Expand Down Expand Up @@ -112,7 +113,7 @@ console.log("✓ prose checks verified — { level, msg } + ai-isms + overclaims

// ── verbspec surfaces: audit + extract as VerbSpecs → CLI + MCP (verbs.mjs) ──────
const toolset = toMcpToolset(registry);
assert.deepEqual(toolset.map((t) => t.name).sort(), ["audit", "extract"], "registry projects to the audit + extract MCP toolset");
assert.deepEqual(toolset.map((t) => t.name).sort(), ["audit", "extract", "scan"], "registry projects audit + extract + scan to the MCP toolset");
assert.ok(toMcpTool(extractVerb).inputSchema.required.includes("file"), "extract MCP tool requires the file argument");
assert.ok(!toMcpTool(auditVerb).inputSchema.required, "audit MCP tool has no required args (all env-defaulted flags)");

Expand All @@ -135,4 +136,17 @@ assert.ok(Object.values(ex.tokens).every((t) => t["$value"] && t["$type"] && t["
assert.ok(Object.keys(ex.tokens).some((k) => k.startsWith("surface.")), "uncovered strings get a proposed surface.* key");
assert.ok("tagline" in ex.tokens, "a covered string reuses its catalog symbol (tagline), not a proposed key");

console.log("✓ verbspec surfaces verified — audit/extract VerbSpecs → CLI parse + MCP toolset + structured output + extractor");
// scan: lift hardcoded static strings from source → Zod-typed keepers (shared types.mjs)
const sc = await scanVerb.run({ dir: "." });
assert.ok(sc.files > 0 && sc.literals > 0, "scan walks source + finds string literals");
assert.ok(sc.keepers.length > 0 && sc.keepers.length < sc.literals, "keepers are a subset (copy, not every literal)");
assert.ok(sc.keepers.every((k) => k.type && typeof k.valid === "boolean"), "each keeper is typed + Zod-validated");

// types.mjs: the per-type Zod contracts — the single source `audit` and `scan` both use
assert.deepEqual(typeFindings("headline", "The boundary an agent acts through"), [], "a clean headline passes its contract");
assert.ok(typeFindings("headline", "x".repeat(80)).includes("too long for a headline (>65)"), "an over-long headline fails its contract");
assert.ok(typeFindings("cta", "Learn more about everything here").includes("doesn't open with an action verb"), "a non-action-verb cta fails its contract");
assert.equal(claimFindings("Rated 4.8 stars by 12,000 customers.", []).length, 1, "an ungrounded stat is flagged");
assert.equal(claimFindings("Rated 4.8 stars.", ["4.8 stars"]).length, 0, "a grounded stat passes");

console.log("✓ verbspec surfaces verified — audit/extract/scan VerbSpecs → CLI + MCP + structured output + shared Zod type contracts");
Loading