From c8124185b4827b4b9ef245eb2b555e3f206406a8 Mon Sep 17 00:00:00 2001 From: John Collier Date: Fri, 19 Jun 2026 15:01:24 -0400 Subject: [PATCH 1/2] feat: implement ingestion pipeline and integration tests (tasks 4.5-4.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add transaction method to SkillRepository interface and SqliteSkillRepository - Implement ingestFromClonedPath (inner pipeline: discover→parse→bundle→atomicSwap) - Implement ingestSource (full pipeline with clone + cleanup in try/finally) - Implement atomicSwap using repository transaction for atomic delete+upsert - Implement loadExamplesIfEmpty seeding 3 built-in skills on first boot - Wire loadExamplesIfEmpty into buildServer startup sequence - Add integration tests for all 3 spec scenarios using real local git repos Co-authored-by: Cursor --- .../rhess-enterprise-skills-server/tasks.md | 8 +- src/server/db/SqliteSkillRepository.ts | 4 + src/server/db/types.ts | 1 + src/server/index.ts | 3 + src/server/ingestion/examples.ts | 148 +++++++++++++++ src/server/ingestion/ingest.ts | 128 +++++++++++++ test/server/ingestion/ingest.test.ts | 177 ++++++++++++++++++ 7 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 src/server/ingestion/examples.ts create mode 100644 src/server/ingestion/ingest.ts create mode 100644 test/server/ingestion/ingest.test.ts diff --git a/openspec/changes/rhess-enterprise-skills-server/tasks.md b/openspec/changes/rhess-enterprise-skills-server/tasks.md index bcd6078..341e598 100644 --- a/openspec/changes/rhess-enterprise-skills-server/tasks.md +++ b/openspec/changes/rhess-enterprise-skills-server/tasks.md @@ -31,10 +31,10 @@ - [x] 4.2 Implement `discoverSkills(repoPath: string): SkillCandidate[]` walking all Agent Skills spec discovery paths - [x] 4.3 Implement YAML frontmatter parser: validates `name` and `description` are present; returns structured metadata - [x] 4.4 Implement archive bundler: tar.gz multi-file skills, compute SHA256 digest; single-file skills served as-is with digest -- [ ] 4.5 Implement `ingestSource(sourceId, url): SyncReport` — clone → discover → parse → classify → stage -- [ ] 4.6 Implement atomic swap: single SQLite transaction deletes old source skills and inserts new ones -- [ ] 4.7 Implement bundled example skills loader: seeds catalog on first boot if no sources registered -- [ ] 4.8 Write integration tests: valid repo → skills indexed; malformed frontmatter → skipped + reported; re-sync → atomic replace +- [x] 4.5 Implement `ingestSource(sourceId, url): SyncReport` — clone → discover → parse → classify → stage +- [x] 4.6 Implement atomic swap: single SQLite transaction deletes old source skills and inserts new ones +- [x] 4.7 Implement bundled example skills loader: seeds catalog on first boot if no sources registered +- [x] 4.8 Write integration tests: valid repo → skills indexed; malformed frontmatter → skipped + reported; re-sync → atomic replace ## 5. Skills Catalog REST API diff --git a/src/server/db/SqliteSkillRepository.ts b/src/server/db/SqliteSkillRepository.ts index 46f4b50..e241d82 100644 --- a/src/server/db/SqliteSkillRepository.ts +++ b/src/server/db/SqliteSkillRepository.ts @@ -131,4 +131,8 @@ export class SqliteSkillRepository implements SkillRepository { count(): number { return this.countStmt.get()!.n; } + + transaction(fn: () => T): T { + return this.db.transaction(fn)(); + } } diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 3e6f06c..a4b2524 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -56,6 +56,7 @@ export interface SkillRepository { upsertMany(skills: UpsertSkillInput[]): void; deleteBySource(sourceId: number): void; count(): number; + transaction(fn: () => T): T; } export interface SourceRepository { diff --git a/src/server/index.ts b/src/server/index.ts index 204b0f5..cec7252 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,7 @@ import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { initDatabase } from "./db/init.js"; import type { Repositories } from "./db/init.js"; +import { loadExamplesIfEmpty } from "./ingestion/examples.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -50,6 +51,8 @@ export async function buildServer(repos?: Repositories) { const DB_PATH = process.env["DATABASE_PATH"] ?? "./rhess.db"; const db = repos ?? initDatabase(DB_PATH); + await loadExamplesIfEmpty(db); + const app = Fastify({ logger: true }); await app.register(fastifyCors, { origin: parseCorsOrigin() }); diff --git a/src/server/ingestion/examples.ts b/src/server/ingestion/examples.ts new file mode 100644 index 0000000..030b59f --- /dev/null +++ b/src/server/ingestion/examples.ts @@ -0,0 +1,148 @@ +import crypto from "node:crypto"; +import type { Repositories } from "../db/init.js"; + +interface ExampleSkill { + slug: string; + name: string; + description: string; + content: string; +} + +const EXAMPLE_SKILLS: ExampleSkill[] = [ + { + slug: "git-conventional-commit", + name: "Git Conventional Commit", + description: "Writes a conventional commit message following the Conventional Commits specification.", + content: `--- +name: Git Conventional Commit +description: Writes a conventional commit message following the Conventional Commits specification. +allowed-tools: + - Bash +--- + +## Git Conventional Commit + +Analyse the staged diff and write a well-formed [Conventional Commit](https://www.conventionalcommits.org/) message. + +### Format + +\`\`\` +(): + +[optional body] + +[optional footer(s)] +\`\`\` + +**Types:** \`feat\`, \`fix\`, \`docs\`, \`style\`, \`refactor\`, \`perf\`, \`test\`, \`chore\`, \`ci\`, \`build\`, \`revert\` + +### Instructions + +1. Run \`git diff --staged\` to inspect the changes. +2. Choose the correct type based on what changed. +3. Keep the summary under 72 characters, imperative mood, no period. +4. Add a body if the change needs context that the diff alone cannot convey. +5. Add a \`BREAKING CHANGE:\` footer if the change breaks any public API. +`, + }, + { + slug: "code-review-checklist", + name: "Code Review Checklist", + description: "Reviews a code change against a standard checklist of common issues.", + content: `--- +name: Code Review Checklist +description: Reviews a code change against a standard checklist of common issues. +allowed-tools: + - Read + - Bash +--- + +## Code Review Checklist + +Review the provided code or diff against the following checklist and report findings. + +### Checklist + +**Correctness** +- [ ] Logic is correct and handles edge cases +- [ ] Error paths are handled (exceptions, nulls, empty collections) +- [ ] No off-by-one errors + +**Security** +- [ ] No secrets or credentials in code +- [ ] Inputs are validated and sanitised +- [ ] No SQL injection or command injection vectors + +**Maintainability** +- [ ] Functions/methods are focused and small +- [ ] Names are descriptive and consistent +- [ ] Dead code has been removed + +**Tests** +- [ ] New behaviour is covered by tests +- [ ] Existing tests still pass + +### Output + +For each finding, report: **severity** (critical / major / minor / nit), **location** (file + line), and **recommendation**. +`, + }, + { + slug: "explain-code", + name: "Explain Code", + description: "Explains what a code block or file does in plain language.", + content: `--- +name: Explain Code +description: Explains what a code block or file does in plain language. +allowed-tools: + - Read +--- + +## Explain Code + +Read the target code and explain it clearly for the intended audience. + +### Steps + +1. Identify the language and any key frameworks/libraries in use. +2. Summarise the **purpose** of the code in one sentence. +3. Walk through the **main logic flow** step by step. +4. Call out any **non-obvious design decisions** or trade-offs. +5. List **side effects** (I/O, mutations, external calls) if present. +6. Flag any **potential bugs or issues** you notice while reading. + +### Output format + +- Start with a one-sentence TL;DR. +- Use numbered steps for the logic walk-through. +- Use a short bullet list for side effects and issues. +- Avoid jargon unless the user's context makes it appropriate. +`, + }, +]; + +function sha256(content: string): string { + return crypto.createHash("sha256").update(content, "utf-8").digest("hex"); +} + +export async function loadExamplesIfEmpty(repos: Repositories): Promise { + if (repos.skills.count() !== 0 || repos.sources.findAll().length !== 0) { + return; + } + + const source = repos.sources.create({ slug: "examples", url: "built-in" }); + + repos.skills.upsertMany( + EXAMPLE_SKILLS.map((skill) => ({ + sourceId: source.id, + sourceSlug: "examples", + slug: skill.slug, + name: skill.name, + description: skill.description, + artifactType: "skill-md" as const, + digest: sha256(skill.content), + content: skill.content, + supportingFiles: [], + })) + ); +} diff --git a/src/server/ingestion/ingest.ts b/src/server/ingestion/ingest.ts new file mode 100644 index 0000000..fab1a72 --- /dev/null +++ b/src/server/ingestion/ingest.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { clone, discoverSkills, parseFrontmatter, bundleSkill } from "./index.js"; +import type { Repositories } from "../db/init.js"; +import type { UpsertSkillInput } from "../db/types.js"; + +export interface SkillIndexEntry { + slug: string; + name: string; + description: string; + allowedTools: string[]; + artifactType: "skill-md" | "archive"; + digest: string; + content: string; + supportingFiles: string[]; +} + +export interface SkillFailure { + path: string; + reason: string; +} + +export interface SyncReport { + discovered: number; + indexed: number; + failed: number; + failures: SkillFailure[]; +} + +/** + * Inner pipeline: discover → parse → bundle → atomic swap. + * Exported so integration tests can call it directly without a real clone. + */ +export async function ingestFromClonedPath( + sourceId: number, + sourceSlug: string, + repoPath: string, + repos: Repositories +): Promise { + const candidates = discoverSkills(repoPath); + const indexed: SkillIndexEntry[] = []; + const failures: SkillFailure[] = []; + + for (const candidate of candidates) { + const relativePath = path.relative(repoPath, candidate.skillMdPath); + try { + const content = fs.readFileSync(candidate.skillMdPath, "utf-8"); + const fmResult = parseFrontmatter(content); + if (!fmResult.ok) { + failures.push({ path: relativePath, reason: fmResult.reason }); + continue; + } + const bundleResult = await bundleSkill(candidate); + indexed.push({ + slug: candidate.slug, + name: fmResult.data.name, + description: fmResult.data.description, + allowedTools: fmResult.data.allowedTools, + artifactType: bundleResult.artifactType, + digest: bundleResult.digest, + content: bundleResult.artifact, + supportingFiles: candidate.supportingFiles, + }); + } catch (err) { + failures.push({ + path: relativePath, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + atomicSwap(sourceId, sourceSlug, indexed, repos); + + return { + discovered: candidates.length, + indexed: indexed.length, + failed: failures.length, + failures, + }; +} + +/** + * Atomically replaces all skills for a source in a single SQLite transaction: + * deletes the old set and inserts the new set in one commit. + */ +export function atomicSwap( + sourceId: number, + sourceSlug: string, + skills: SkillIndexEntry[], + repos: Repositories +): void { + const inputs: UpsertSkillInput[] = skills.map((s) => ({ + sourceId, + sourceSlug, + slug: s.slug, + name: s.name, + description: s.description, + artifactType: s.artifactType, + digest: s.digest, + content: s.content, + supportingFiles: s.supportingFiles, + })); + + repos.skills.transaction(() => { + repos.skills.deleteBySource(sourceId); + repos.skills.upsertMany(inputs); + }); +} + +/** + * Full ingestion pipeline: clone → discover → parse → bundle → atomic swap. + * Clone failures propagate up without being caught. + */ +export async function ingestSource( + sourceId: number, + sourceSlug: string, + url: string, + repos: Repositories +): Promise { + const tmpDir = path.join(os.tmpdir(), `rhess-sync-${sourceId}-${Date.now()}`); + await clone(url, tmpDir); + try { + return await ingestFromClonedPath(sourceId, sourceSlug, tmpDir, repos); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} diff --git a/test/server/ingestion/ingest.test.ts b/test/server/ingestion/ingest.test.ts new file mode 100644 index 0000000..0ee882b --- /dev/null +++ b/test/server/ingestion/ingest.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import BetterSqlite3 from "better-sqlite3"; +import { simpleGit } from "simple-git"; +import { runMigrations } from "../../../src/server/db/schema.js"; +import { SqliteSkillRepository } from "../../../src/server/db/SqliteSkillRepository.js"; +import { SqliteSourceRepository } from "../../../src/server/db/SqliteSourceRepository.js"; +import type { Repositories } from "../../../src/server/db/init.js"; +import { ingestFromClonedPath } from "../../../src/server/ingestion/ingest.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRepos(): Repositories { + const db = new BetterSqlite3(":memory:"); + db.pragma("foreign_keys = ON"); + runMigrations(db); + return { + skills: new SqliteSkillRepository(db), + sources: new SqliteSourceRepository(db), + }; +} + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "rhess-test-")); +} + +async function initRepo(dir: string): Promise { + const git = simpleGit(dir); + await git.init(); + await git.addConfig("user.email", "test@example.com"); + await git.addConfig("user.name", "Test"); +} + +async function commitAll(dir: string, message = "add skills"): Promise { + const git = simpleGit(dir); + await git.add("."); + await git.commit(message); +} + +function writeSkill(dir: string, discoveryPath: string, skillName: string, content: string): void { + const skillDir = path.join(dir, discoveryPath, skillName); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), content, "utf-8"); +} + +const VALID_SKILL_A = `--- +name: Alpha Skill +description: Does alpha things. +--- + +# Alpha Skill + +This skill does alpha things. +`; + +const VALID_SKILL_B = `--- +name: Beta Skill +description: Does beta things. +--- + +# Beta Skill + +This skill does beta things. +`; + +const VALID_SKILL_C = `--- +name: Gamma Skill +description: Does gamma things. +--- + +# Gamma Skill + +This skill does gamma things. +`; + +const MALFORMED_SKILL = `# No frontmatter here + +This is a skill without YAML frontmatter. +`; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ingestFromClonedPath", () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("scenario 1: valid repo → skills indexed", async () => { + tmpDir = makeTmpDir(); + await initRepo(tmpDir); + + // Place 2 valid skills in different discovery paths + writeSkill(tmpDir, "skills", "alpha-skill", VALID_SKILL_A); + writeSkill(tmpDir, ".claude/skills", "beta-skill", VALID_SKILL_B); + await commitAll(tmpDir); + + const repos = makeRepos(); + const source = repos.sources.create({ slug: "test-source", url: "file:///test" }); + const report = await ingestFromClonedPath(source.id, source.slug, tmpDir, repos); + + expect(report.discovered).toBe(2); + expect(report.indexed).toBe(2); + expect(report.failed).toBe(0); + expect(report.failures).toHaveLength(0); + + const allSkills = repos.skills.findAll({ perPage: 100 }); + expect(allSkills).toHaveLength(2); + const slugs = allSkills.map((s) => s.slug).sort(); + expect(slugs).toContain("alpha-skill"); + expect(slugs).toContain("beta-skill"); + }); + + it("scenario 2: malformed frontmatter → skipped + reported", async () => { + tmpDir = makeTmpDir(); + await initRepo(tmpDir); + + writeSkill(tmpDir, "skills", "alpha-skill", VALID_SKILL_A); + writeSkill(tmpDir, "skills", "broken-skill", MALFORMED_SKILL); + await commitAll(tmpDir); + + const repos = makeRepos(); + const source = repos.sources.create({ slug: "test-source", url: "file:///test" }); + const report = await ingestFromClonedPath(source.id, source.slug, tmpDir, repos); + + expect(report.discovered).toBe(2); + expect(report.indexed).toBe(1); + expect(report.failed).toBe(1); + expect(report.failures).toHaveLength(1); + expect(report.failures[0]!.reason).toMatch(/frontmatter/i); + + const allSkills = repos.skills.findAll({ perPage: 100 }); + expect(allSkills).toHaveLength(1); + expect(allSkills[0]!.slug).toBe("alpha-skill"); + }); + + it("scenario 3: re-sync → atomic replace", async () => { + tmpDir = makeTmpDir(); + await initRepo(tmpDir); + + // First sync: 2 skills + writeSkill(tmpDir, "skills", "alpha-skill", VALID_SKILL_A); + writeSkill(tmpDir, "skills", "beta-skill", VALID_SKILL_B); + await commitAll(tmpDir, "initial commit"); + + const repos = makeRepos(); + const source = repos.sources.create({ slug: "test-source", url: "file:///test" }); + const report1 = await ingestFromClonedPath(source.id, source.slug, tmpDir, repos); + expect(report1.indexed).toBe(2); + + // Modify repo: remove alpha, add gamma + fs.rmSync(path.join(tmpDir, "skills", "alpha-skill"), { recursive: true, force: true }); + writeSkill(tmpDir, "skills", "gamma-skill", VALID_SKILL_C); + await commitAll(tmpDir, "update skills"); + + // Second sync: should replace catalog atomically + const report2 = await ingestFromClonedPath(source.id, source.slug, tmpDir, repos); + expect(report2.indexed).toBe(2); + expect(report2.failed).toBe(0); + + const allSkills = repos.skills.findAll({ perPage: 100 }); + expect(allSkills).toHaveLength(2); + const slugs = allSkills.map((s) => s.slug).sort(); + expect(slugs).toEqual(["beta-skill", "gamma-skill"]); + // alpha-skill must be gone + expect(slugs).not.toContain("alpha-skill"); + }); +}); From 1948ca1e7df1cd86fd12710203b2be115b41c9d7 Mon Sep 17 00:00:00 2001 From: John Collier Date: Fri, 19 Jun 2026 15:24:24 -0400 Subject: [PATCH 2/2] fix: address Qodo review bugs and CI lint failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ingest: move clone() inside try/finally so partial clones are always cleaned up on failure (temp dir leak) - ingest: bundle first and reuse artifact content for frontmatter parsing on skill-md type, eliminating duplicate SKILL.md reads - examples: wrap source create + skill upsert in transactionSync so a transient failure cannot leave a permanent orphaned source row - db: rename transaction → transactionSync with doc comment making the sync-only contract explicit and preventing async misuse - test: remove unused beforeEach import (CI lint failure) Co-authored-by: Cursor --- src/server/db/SqliteSkillRepository.ts | 2 +- src/server/db/types.ts | 3 ++- src/server/ingestion/examples.ts | 33 ++++++++++++++------------ src/server/ingestion/ingest.ts | 18 ++++++++++---- test/server/ingestion/ingest.test.ts | 2 +- 5 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/server/db/SqliteSkillRepository.ts b/src/server/db/SqliteSkillRepository.ts index e241d82..ea409fe 100644 --- a/src/server/db/SqliteSkillRepository.ts +++ b/src/server/db/SqliteSkillRepository.ts @@ -132,7 +132,7 @@ export class SqliteSkillRepository implements SkillRepository { return this.countStmt.get()!.n; } - transaction(fn: () => T): T { + transactionSync(fn: () => T): T { return this.db.transaction(fn)(); } } diff --git a/src/server/db/types.ts b/src/server/db/types.ts index a4b2524..3a3a1c3 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -56,7 +56,8 @@ export interface SkillRepository { upsertMany(skills: UpsertSkillInput[]): void; deleteBySource(sourceId: number): void; count(): number; - transaction(fn: () => T): T; + /** Runs fn inside a single SQLite transaction. Callback MUST be synchronous — do not await inside. */ + transactionSync(fn: () => T): T; } export interface SourceRepository { diff --git a/src/server/ingestion/examples.ts b/src/server/ingestion/examples.ts index 030b59f..241a146 100644 --- a/src/server/ingestion/examples.ts +++ b/src/server/ingestion/examples.ts @@ -130,19 +130,22 @@ export async function loadExamplesIfEmpty(repos: Repositories): Promise { return; } - const source = repos.sources.create({ slug: "examples", url: "built-in" }); - - repos.skills.upsertMany( - EXAMPLE_SKILLS.map((skill) => ({ - sourceId: source.id, - sourceSlug: "examples", - slug: skill.slug, - name: skill.name, - description: skill.description, - artifactType: "skill-md" as const, - digest: sha256(skill.content), - content: skill.content, - supportingFiles: [], - })) - ); + // Wrap both writes in a single transaction so a transient failure cannot + // leave a sources row with no skills (which would permanently suppress retry). + repos.skills.transactionSync(() => { + const source = repos.sources.create({ slug: "examples", url: "built-in" }); + repos.skills.upsertMany( + EXAMPLE_SKILLS.map((skill) => ({ + sourceId: source.id, + sourceSlug: "examples", + slug: skill.slug, + name: skill.name, + description: skill.description, + artifactType: "skill-md" as const, + digest: sha256(skill.content), + content: skill.content, + supportingFiles: [], + })) + ); + }); } diff --git a/src/server/ingestion/ingest.ts b/src/server/ingestion/ingest.ts index fab1a72..01b2c60 100644 --- a/src/server/ingestion/ingest.ts +++ b/src/server/ingestion/ingest.ts @@ -45,13 +45,21 @@ export async function ingestFromClonedPath( for (const candidate of candidates) { const relativePath = path.relative(repoPath, candidate.skillMdPath); try { - const content = fs.readFileSync(candidate.skillMdPath, "utf-8"); - const fmResult = parseFrontmatter(content); + const bundleResult = await bundleSkill(candidate); + + // For skill-md, the artifact IS the raw SKILL.md content — reuse it to + // avoid a second readFileSync. For archives the artifact is base64 tar.gz, + // so we read the file directly. + const skillMdContent = + bundleResult.artifactType === "skill-md" + ? bundleResult.artifact + : fs.readFileSync(candidate.skillMdPath, "utf-8"); + + const fmResult = parseFrontmatter(skillMdContent); if (!fmResult.ok) { failures.push({ path: relativePath, reason: fmResult.reason }); continue; } - const bundleResult = await bundleSkill(candidate); indexed.push({ slug: candidate.slug, name: fmResult.data.name, @@ -102,7 +110,7 @@ export function atomicSwap( supportingFiles: s.supportingFiles, })); - repos.skills.transaction(() => { + repos.skills.transactionSync(() => { repos.skills.deleteBySource(sourceId); repos.skills.upsertMany(inputs); }); @@ -119,8 +127,8 @@ export async function ingestSource( repos: Repositories ): Promise { const tmpDir = path.join(os.tmpdir(), `rhess-sync-${sourceId}-${Date.now()}`); - await clone(url, tmpDir); try { + await clone(url, tmpDir); return await ingestFromClonedPath(sourceId, sourceSlug, tmpDir, repos); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/test/server/ingestion/ingest.test.ts b/test/server/ingestion/ingest.test.ts index 0ee882b..c7ae391 100644 --- a/test/server/ingestion/ingest.test.ts +++ b/test/server/ingestion/ingest.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path";