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
11 changes: 6 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,19 @@ jobs:
echo "$OUT"
echo "$OUT" | grep -q '"name":"audit"' # tools/list projects the audit verb
echo "$OUT" | grep -q '"name":"extract"' # ...and extract
echo "$OUT" | grep -q '"name":"scan"' # ...and scan
echo "$OUT" | grep -q '"name":"scan"' # ...and scan
echo "$OUT" | grep -q '"name":"concept-drift"' # ...and concept-drift
echo "$OUT" | grep -q 'coverage' # tools/call runs extract → structured output
- name: Registry drift spike smoke-test
run: node spikes/registry-drift.mjs
- name: Concept drift spike smoke-test
run: node spikes/concept-drift.mjs
- name: Concept-drift verb smoke-test (core — exact-overlap fallback, zero-dep)
run: node concept-drift.mjs
- name: Install optional CAS backends + textlint for the socket store step
run: npm install --include=optional --no-audit --no-fund
- name: Typecheck — tsc --checkJs (no build; full deps so optional import()s resolve)
run: npm run typecheck
- 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: Concept-drift verb — stemmed matching (optional stemmer now installed)
run: node concept-drift.mjs samples/page.html | grep -q "token overlap (stemmed)"
- name: Scan verb — AST extract + Zod-typed keepers, emit round-trips
run: |
node scan.mjs | grep -q "acorn AST" # optional acorn now installed
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,15 @@ human CLI view, and MCP / agents consume `output` directly:
*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.
- **Concept-drift** — `node concept-drift.mjs [target]` (the `string-audit-concept-drift`
bin) asks, for each canonical brand *message*, whether a surface string still *means* it —
string-level, like i18n/translation. **Tiered matching**, best-available + graceful
fallback: **embeddings** (semantic, opt-in `EMBED_API_KEY`, OpenAI-compatible) → **token
overlap, stemmed** (optional `stemmer`) → **exact** (zero-dep). A *signal*, not a gate.
- **MCP** — `node mcp.mjs` (the `string-audit-mcp` bin) is a stdio MCP server exposing
`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.
`audit` + `extract` + `scan` + `concept-drift` 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, scan a tree, or check drift as a tool call.
- **Anthropic** — the `report` tool (above) is the same projection (`toAnthropicTool`).

## Types — no build step
Expand Down
21 changes: 21 additions & 0 deletions concept-drift.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// `concept-drift` — has a surface drifted from the canonical brand messages? Per-message
// coverage vs the registry core (the `concept-drift` verb / `string-audit-concept-drift`
// bin, verbs.mjs, projected to a CLI via verbspec). Tiered matching, best-available:
// embeddings (opt-in EMBED_API_KEY, OpenAI-compatible) → token overlap (stemmed) → exact.
// node concept-drift.mjs [target.json|target.html]
// node extract.mjs page.html --emit > t.json && node concept-drift.mjs t.json
// EMBED_API_KEY=… node concept-drift.mjs # tier 1 (semantic)
// node concept-drift.mjs --help
import { parseArgs, toHelp } from "@bounded-systems/verbspec";
import { conceptDriftVerb } 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.
console.log(toHelp(conceptDriftVerb, "node concept-drift.mjs").replace("node concept-drift.mjs concept-drift", "node concept-drift.mjs"));
process.exit(0);
}
const input = parseArgs(conceptDriftVerb, argv);
const output = await conceptDriftVerb.run(input);
process.stdout.write(conceptDriftVerb.render(output, input));
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
"bin": {
"string-audit": "audit.mjs",
"string-audit-mcp": "mcp.mjs",
"string-audit-scan": "scan.mjs"
"string-audit-scan": "scan.mjs",
"string-audit-concept-drift": "concept-drift.mjs"
},
"scripts": {
"audit": "node audit.mjs",
"extract": "node extract.mjs",
"scan": "node scan.mjs",
"concept-drift": "node concept-drift.mjs",
"mcp": "node mcp.mjs",
"typecheck": "tsc --noEmit",
"test": "node test.mjs"
Expand Down
1 change: 0 additions & 1 deletion spikes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,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` |

## The direction these point at

Expand Down
99 changes: 0 additions & 99 deletions spikes/concept-drift.mjs

This file was deleted.

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

// request shape
const req = buildRequest({ type: "headline", value: "Hello world", grounding: ["fact-a", "fact-b"] });
assert.equal(req.tool_choice.name, "report", "forces the report tool (structured output)");
assert.ok(req.tools[0].input_schema.required.includes("score"), "schema requires score");
assert.ok(req.tools[0].input_schema.required.includes("findings"), "schema requires findings");
assert.ok(/** @type {any} */ (req.tools[0].input_schema).required.includes("score"), "schema requires score");
assert.ok(/** @type {any} */ (req.tools[0].input_schema).required.includes("findings"), "schema requires findings");
// The tool is single-sourced from a VerbSpec (anthropic.mjs, projected via toAnthropicTool);
// pin the exact projected schema so a verbspec/zod bump can't silently drift the contract
// (no $schema pointer; integer 0..10; string[]; additionalProperties:false).
Expand Down Expand Up @@ -113,8 +113,8 @@ 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", "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.deepEqual(toolset.map((t) => t.name).sort(), ["audit", "concept-drift", "extract", "scan"], "registry projects audit + extract + scan + concept-drift to the MCP toolset");
assert.ok(/** @type {any} */ (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)");

// CLI projection: parseArgs maps the file positional + flags, validated by the Zod input.
Expand All @@ -124,7 +124,7 @@ assert.equal(exInput.catalog, "vendor/brand/content/strings.json", "--catalog fl

// extract.run is a pure read → structured `output` (the shape MCP/agents consume); the CLI
// view is just render(output).
const ex = extractVerb.run({ file: "samples/page.html" });
const ex = await extractVerb.run({ file: "samples/page.html" }); // run may be sync or async per the VerbSpec type
assert.equal(ex.file, "page.html");
assert.ok(ex.coverage >= 0 && ex.coverage <= 100, "coverage is a percent");
assert.ok(ex.uncovered.every((u) => u.symbol.startsWith("surface.")), "each uncovered string carries a proposed symbol");
Expand All @@ -149,4 +149,12 @@ assert.ok(typeFindings("cta", "Learn more about everything here").includes("does
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");
// concept-drift: per-message coverage vs the brand canon (string-level, tiered matching)
const cd = await conceptDriftVerb.run({ target: "samples/page.html" });
assert.ok(cd.canon > 0 && cd.surface > 0, "concept-drift reads canon + surface strings");
assert.ok(cd.coverage >= 0 && cd.coverage <= 100, "coverage is a percent of canon messages represented");
assert.equal(cd.messages.length, cd.canon, "one entry per canon message");
assert.ok(cd.messages.every((m) => typeof m.represented === "boolean" && typeof m.score === "number"), "each canon message reports a best match + represented flag");
assert.ok(/token overlap|embeddings/.test(cd.mode), "reports the active match tier (graceful fallback)");

console.log("✓ verbspec surfaces verified — audit/extract/scan/concept-drift VerbSpecs → CLI + MCP + structured output + shared Zod type contracts");
3 changes: 1 addition & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,5 @@
"noImplicitAny": false,
"strict": false
},
"include": ["*.mjs"],
"exclude": ["test.mjs"]
"include": ["*.mjs"]
}
Loading