-
Notifications
You must be signed in to change notification settings - Fork 0
feat(g2): knowledge-plane-vault renderer + vault/ skeleton #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0b0eef8
704effa
8f68322
af4052f
e97e79a
c36292f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -46,7 +46,13 @@ completo (tres capas, principios invariantes, scope de cada rama G1–G4). | |||||
| 3. **Snapshots deterministas**: el renderer no debe usar `Date.now()` ni rutas del | ||||||
| host. El timestamp se inyecta vía `profile.metadata.generatedAt`. | ||||||
|
|
||||||
| 4. **Opt-in gate**: el renderer sólo emite archivos cuando | ||||||
| 4. **Sin interpolación no escapada en templates vault**: `loadTemplate` usa `noEscape: true` | ||||||
| globalmente. Los templates de vault que añadan `{{answers.X}}` renderizarán sin HTML-escaping. | ||||||
| En G2 `config.md.hbs` no tiene interpolaciones — riesgo cero hoy. Si G3/G4 añade | ||||||
| interpolación de datos del profile, revisar si el campo puede contener input arbitrario | ||||||
| y escapar explícitamente con `{{{{raw}}}}` o sanitizar antes de inyectar. | ||||||
|
|
||||||
| 5. **Opt-in gate**: el renderer sólo emite archivos cuando | ||||||
| `answers["integrations.knowledge_plane.enabled"] === true`. Con `false` (default), | ||||||
|
||||||
| `answers["integrations.knowledge_plane.enabled"] === true`. Con `false` (default), | |
| `answers.integrations.knowledge_plane.enabled === true`. Con `false` (default), |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { render } from "./knowledge-plane-vault.ts"; | ||
| import type { Profile } from "../lib/profile-model.ts"; | ||
|
|
||
| function makeProfileWithAnswers(answers: Record<string, unknown>): Profile { | ||
| return { | ||
| meta: { version: "1", profileName: "test", profileDescription: "test" }, | ||
| answers, | ||
| placeholders: [], | ||
| }; | ||
| } | ||
|
|
||
| function makeProfile(enabled: unknown): Profile { | ||
| return makeProfileWithAnswers( | ||
| enabled === undefined ? {} : { integrations: { knowledge_plane: { enabled } } } | ||
| ); | ||
| } | ||
|
|
||
| const profileEnabled = makeProfile(true); | ||
| const profileDisabled = makeProfile(false); | ||
| const profileAbsent = makeProfile(undefined); | ||
| const profileIntegrationsNoKP = makeProfileWithAnswers({ integrations: {} }); | ||
|
|
||
| describe("renderers/knowledge-plane-vault — opt-in gate", () => { | ||
| it("returns [] when enabled is false", () => { | ||
| expect(render(profileDisabled)).toEqual([]); | ||
| }); | ||
|
|
||
| it("returns [] when enabled is absent (default false)", () => { | ||
| expect(render(profileAbsent)).toEqual([]); | ||
| }); | ||
|
|
||
| it("returns [] when integrations exists but knowledge_plane key is absent", () => { | ||
| expect(render(profileIntegrationsNoKP)).toEqual([]); | ||
| }); | ||
|
|
||
| it("returns exactly 3 FileWrite entries when enabled is true", () => { | ||
| expect(render(profileEnabled)).toHaveLength(3); | ||
| }); | ||
| }); | ||
|
|
||
| describe("renderers/knowledge-plane-vault — emitted paths", () => { | ||
| it("emits vault/config.md", () => { | ||
| const paths = render(profileEnabled).map((f) => f.path); | ||
| expect(paths).toContain("vault/config.md"); | ||
| }); | ||
|
|
||
| it("emits vault/raw/.gitkeep", () => { | ||
| const paths = render(profileEnabled).map((f) => f.path); | ||
| expect(paths).toContain("vault/raw/.gitkeep"); | ||
| }); | ||
|
|
||
| it("emits vault/wiki/.gitkeep", () => { | ||
| const paths = render(profileEnabled).map((f) => f.path); | ||
| expect(paths).toContain("vault/wiki/.gitkeep"); | ||
| }); | ||
|
|
||
| it("does NOT emit vault/schema.md (naming: config.md wins per G1 rule)", () => { | ||
| const paths = render(profileEnabled).map((f) => f.path); | ||
| expect(paths).not.toContain("vault/schema.md"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("renderers/knowledge-plane-vault — .gitkeep content", () => { | ||
| it("vault/raw/.gitkeep has empty content (single newline)", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/raw/.gitkeep")!; | ||
| expect(file.content).toBe("\n"); | ||
| }); | ||
|
|
||
| it("vault/wiki/.gitkeep has empty content (single newline)", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/wiki/.gitkeep")!; | ||
| expect(file.content).toBe("\n"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("renderers/knowledge-plane-vault — vault/config.md content", () => { | ||
| it("contains ## Propósito section", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; | ||
| expect(file.content).toMatch(/^## Propósito/m); | ||
| }); | ||
|
|
||
| it("contains ## Estructura section", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; | ||
| expect(file.content).toMatch(/^## Estructura/m); | ||
| }); | ||
|
|
||
| it("contains ## Convenciones raw section", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; | ||
| expect(file.content).toMatch(/^## Convenciones raw/m); | ||
| }); | ||
|
|
||
| it("contains ## Convenciones wiki section", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; | ||
| expect(file.content).toMatch(/^## Convenciones wiki/m); | ||
| }); | ||
|
|
||
| it("contains ## Ingestor recomendado section", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; | ||
| expect(file.content).toMatch(/^## Ingestor recomendado/m); | ||
| }); | ||
|
|
||
| it("mentions Obsidian Web Clipper as reference ingestor", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; | ||
| expect(file.content).toMatch(/Obsidian Web Clipper/i); | ||
| }); | ||
|
|
||
| it("explicitly states the adapter is a reference, not a base contract", () => { | ||
| const file = render(profileEnabled).find((f) => f.path === "vault/config.md")!; | ||
| expect(file.content).toMatch(/referencia|reference/i); | ||
| }); | ||
| }); | ||
|
|
||
| describe("renderers/knowledge-plane-vault — structural invariants", () => { | ||
| it("every emitted file ends with a trailing newline", () => { | ||
| for (const f of render(profileEnabled)) { | ||
| expect(f.content.endsWith("\n"), `${f.path} must end with \\n`).toBe(true); | ||
| } | ||
| }); | ||
|
|
||
| it("is deterministic: byte-identical output for equivalent profiles", () => { | ||
| expect(JSON.stringify(render(makeProfile(true)))).toBe( | ||
| JSON.stringify(render(makeProfile(true))) | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { loadTemplate } from "../lib/template-loader.ts"; | ||
| import type { FileWrite, Profile, Renderer } from "../lib/render-pipeline.ts"; | ||
|
|
||
| const configTemplate = loadTemplate("vault/config.md.hbs"); | ||
|
|
||
| export const render: Renderer = (profile: Profile): FileWrite[] => { | ||
| const answers = profile.answers as Record<string, unknown>; | ||
| const integrations = (answers.integrations ?? {}) as Record<string, unknown>; | ||
| const kp = (integrations.knowledge_plane ?? {}) as Record<string, unknown>; | ||
|
|
||
| if (kp.enabled !== true) { | ||
| return []; | ||
| } | ||
|
|
||
| return [ | ||
| { path: "vault/config.md", content: configTemplate(profile) }, | ||
| { path: "vault/raw/.gitkeep", content: "\n" }, | ||
| { path: "vault/wiki/.gitkeep", content: "\n" }, | ||
| ]; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The suggestion to “escapar explícitamente con
{{{{raw}}}}” is inaccurate: Handlebars raw blocks prevent evaluation of template code but do not HTML-escape interpolated values, and there is noescapehelper registered today. SinceloadTemplatecompiles withnoEscape: true, the actionable options are to (a) introduce anescape/sanitization helper and use it for any interpolations, or (b) compile these vault templates with escaping enabled (separate loader or option override) if you want safe-by-default{{...}}behavior.