feat(schema-pack): add slug_filter to retype mapping rules (#2655)

retype mapping_rules' existing path_filter matches pages.source_path,
which is only populated for pages synced from a git repo. Pages ingested
via the put_page MCP tool (or any write path that doesn't go through
sync) have source_path = NULL, so path_filter can never disambiguate
them — a same-from_type retype rule targeting a slug prefix has no way
to address this class of page at all.

slug_filter adds an independent, orthogonal LIKE filter on pages.slug
(the field that's always populated), combinable with path_filter via AND
when both are given. Wired through the full call path: schema validation
(manifest-v1.ts), the RetypeRule interface + probeRule/applyRetypeRule
(retype.ts), and the pack-manifest → RetypeRule conversion in the
unify-types job handler (unify-types-handler.ts) — the last one matters
because a field only present in the zod schema but not carried through
that conversion would validate fine yet silently no-op at execution time.

- manifest-v1.ts: add optional slug_filter to RetypeMappingRuleSchema
- retype.ts: slug_filter on RetypeRule; probeRule + applyRetypeRule both
  apply `AND slug LIKE $N` when present, independent of path_filter
- unify-types-handler.ts: carry rule.slug_filter through the
  pack-mapping-rule → RetypeRule conversion
- tests: skips-outside-slug_filter, matches-despite-NULL-source_path
  (the motivating case), path_filter+slug_filter combined via AND

320/320 existing schema-pack tests pass; 3 new tests added; tsc --noEmit
clean.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
This commit is contained in:
Yicon
2026-08-02 08:12:46 +08:00
committed by GitHub
co-authored by Claude Sonnet 5 Time Attakc
parent 6d1232d5a6
commit a68379050e
4 changed files with 76 additions and 0 deletions
+1
View File
@@ -258,6 +258,7 @@ const RetypeMappingRuleSchema = z.object({
subtype: z.string().optional(),
subtype_field: z.enum(ALLOWED_SUBTYPE_FIELDS).default('subtype'),
path_filter: z.string().optional(),
slug_filter: z.string().optional(),
}).strict();
const ResolverSchema = z.union([
+16
View File
@@ -50,6 +50,12 @@ export interface RetypeRule {
subtype_field?: AllowedSubtypeField;
/** Optional source_path LIKE filter for disambiguation. */
path_filter?: string;
/** Optional slug LIKE filter for disambiguation. Independent of
* path_filter (both may be given; combined with AND). Useful when
* pages were ingested without a populated source_path (e.g. written
* via the put_page MCP tool rather than synced from a git repo), where
* path_filter can never match. */
slug_filter?: string;
}
export interface RetypeOpts {
@@ -114,6 +120,7 @@ async function probeRule(
engine: BrainEngine,
fromType: string,
pathFilter: string | undefined,
slugFilter: string | undefined,
sourceId: string | undefined,
): Promise<{ count: number; sample: string[] }> {
// The catch-all sentinel uses a special "not in pack types" probe; for now
@@ -129,6 +136,10 @@ async function probeRule(
where += ` AND source_path LIKE $${params.length + 1}`;
params.push(pathFilter);
}
if (slugFilter) {
where += ` AND slug LIKE $${params.length + 1}`;
params.push(slugFilter);
}
if (sourceId) {
where += ` AND source_id = $${params.length + 1}`;
params.push(sourceId);
@@ -178,6 +189,10 @@ async function applyRetypeRule(
winWhereParts.push(`source_path LIKE $${winParams.length + 1}`);
winParams.push(rule.path_filter);
}
if (rule.slug_filter) {
winWhereParts.push(`slug LIKE $${winParams.length + 1}`);
winParams.push(rule.slug_filter);
}
if (sourceId) {
winWhereParts.push(`source_id = $${winParams.length + 1}`);
winParams.push(sourceId);
@@ -313,6 +328,7 @@ export async function runRetypeCore(
ctx.engine,
rule.from_type,
rule.path_filter,
rule.slug_filter,
sourceId,
);
let applied = 0;
@@ -152,6 +152,7 @@ export async function runUnifyTypes(
subtype: rule.subtype,
subtype_field: rule.subtype_field,
path_filter: rule.path_filter,
slug_filter: rule.slug_filter,
});
}
} else if (rule.kind === 'page_to_link') {
+58
View File
@@ -188,6 +188,64 @@ describe('runRetypeCore', () => {
);
expect(rows[0].type).toBe('tweet-single');
});
it('skips pages outside the slug_filter', async () => {
await seed('tweets/a', 'tweet-single');
await seed('other/b', 'tweet-single');
const result = await runRetypeCore(ctxOf(), {
rules: [{ from_type: 'tweet-single', to_type: 'tweet', slug_filter: 'tweets/%' }],
apply: true,
});
expect(result.total_applied).toBe(1);
const rows = await engine.executeRaw<{ slug: string; type: string }>(
`SELECT slug, type FROM pages WHERE slug LIKE '%/%' ORDER BY slug`,
);
expect(rows.find((r) => r.slug === 'tweets/a')?.type).toBe('tweet');
expect(rows.find((r) => r.slug === 'other/b')?.type).toBe('tweet-single');
});
it('matches slug_filter even when source_path is NULL (put_page-ingested pages)', async () => {
// Pages written via the put_page MCP tool (vs. synced from a git repo)
// never get a source_path — this is the exact gap slug_filter closes.
await engine.putPage('tweets/a', {
title: 'tweets/a',
type: 'tweet-single' as never,
compiled_truth: 'body that exceeds minimum length to pass any backstop guards we may have around content here',
timeline: '',
frontmatter: {},
source_path: null as never,
});
const dryRun = await runRetypeCore(ctxOf(), {
rules: [{ from_type: 'tweet-single', to_type: 'tweet', path_filter: 'tweets/%' }],
apply: false,
});
expect(dryRun.per_rule[0].would_apply).toBe(0); // path_filter can't match: source_path is NULL
const result = await runRetypeCore(ctxOf(), {
rules: [{ from_type: 'tweet-single', to_type: 'tweet', slug_filter: 'tweets/%' }],
apply: true,
});
expect(result.total_applied).toBe(1); // slug_filter matches regardless of source_path
});
it('combines path_filter AND slug_filter when both given', async () => {
await seed('tweets/a', 'tweet-single', { sourcePath: 'tweets/a.md' });
await seed('tweets/b', 'tweet-single', { sourcePath: 'archive/tweets-b.md' });
const result = await runRetypeCore(ctxOf(), {
rules: [{
from_type: 'tweet-single',
to_type: 'tweet',
path_filter: 'tweets/%',
slug_filter: 'tweets/%',
}],
apply: true,
});
// Only tweets/a matches BOTH filters (tweets/b's source_path is under archive/).
expect(result.total_applied).toBe(1);
const rows = await engine.executeRaw<{ type: string }>(
`SELECT type FROM pages WHERE slug = 'tweets/b'`,
);
expect(rows[0].type).toBe('tweet-single');
});
});
describe('subtype_field allowlist (D9)', () => {