diff --git a/.github/scripts/check-web-archive.mjs b/.github/scripts/check-web-archive.mjs new file mode 100644 index 0000000..463058b --- /dev/null +++ b/.github/scripts/check-web-archive.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node + +/** + * Check broken links against the Wayback Machine (web.archive.org) + * + * This script reads the lychee link checker output (markdown format), + * extracts broken URLs, and checks each one against the Wayback Machine API. + * It then outputs a report with: + * - Links that have a web archive version (with suggestion to replace) + * - Links that have no web archive version (clearly marked as unrecoverable) + * + * Usage: + * node .github/scripts/check-web-archive.mjs + * + * Environment variables: + * - LYCHEE_OUTPUT: Path to lychee markdown output file (default: lychee/out.md) + * + * GitHub Actions outputs: + * - all_archived: 'true' if all broken links have a web archive version + * + * Exit codes: + * - 0: All broken links have web archive versions (or no broken links) + * - 1: Some broken links have no web archive version + */ + +import { readFileSync, appendFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const WAYBACK_API = 'https://archive.org/wayback/available?url='; + +/** + * Write output to GitHub Actions output file + * @param {string} name - Output name + * @param {string} value - Output value + */ +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${name}=${value}\n`); + } + console.log(`${name}=${value}`); +} + +/** + * Extract the "Errors per input" section of a lychee markdown report. + * + * Everything after it - most importantly "## Redirects per input" - describes + * links that resolved successfully. Scanning the whole report made every + * redirected link look broken and failed the workflow for healthy URLs + * (issue #96). + * @param {string} content - The markdown content from lychee + * @returns {string} The errors section, or an empty string when there is none + */ +function extractErrorsSection(content) { + const lines = content.split('\n'); + const start = lines.findIndex((line) => /^#+\s+Errors per input\s*$/.test(line)); + if (start === -1) { + return ''; + } + const heading = /^(#+)\s/.exec(lines[start])[1].length; + const section = []; + for (const line of lines.slice(start + 1)) { + const next = /^(#+)\s/.exec(line); + if (next && next[1].length <= heading) { + break; // a sibling or parent heading ends the errors section + } + section.push(line); + } + return section.join('\n'); +} + +/** + * Extract broken links from lychee markdown output. + * Lychee markdown format includes lines like: + * * [404] https://example.com/broken-link + * * [ERROR] | File not found + * @param {string} content - The markdown content from lychee + * @returns {{urls: string[], others: string[]}} Broken http(s) URLs, and broken + * links that cannot be looked up in the Wayback Machine (local files, + * unresolvable root-relative links, ...) + */ +function extractBrokenLinks(content) { + const section = extractErrorsSection(content); + const urls = []; + const others = []; + + // One bullet per broken link; the status marker is always present. + const entryPattern = + /^\s*(?:\*|-)\s+\[(?:4\d\d|5\d\d|ERROR|TIMEOUT|UNKNOWN)\]\s+|)]+)>?/gim; + let match; + while ((match = entryPattern.exec(section)) !== null) { + const link = match[1].trim().replace(/[.,;!?]+$/, ''); + if (!link) continue; + if (/^https?:\/\//i.test(link)) { + if (!urls.includes(link)) urls.push(link); + } else if (!others.includes(link)) { + others.push(link); + } + } + + return { urls, others }; +} + +/** + * Check if a URL has an archived version in the Wayback Machine + * Uses the Wayback Machine Availability API: + * https://archive.org/help/wayback_api.php + * @param {string} url - The URL to check + * @returns {Promise<{available: boolean, archiveUrl: string|null, timestamp: string|null}>} + */ +async function checkWaybackMachine(url) { + const apiUrl = `${WAYBACK_API}${encodeURIComponent(url)}`; + + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(apiUrl, { + headers: { + 'User-Agent': 'broken-link-checker/1.0 (GitHub Actions CI)', + }, + signal: controller.signal, + }); + + if (!response.ok) { + console.warn(` Wayback API returned ${response.status} for ${url}`); + return { available: false, archiveUrl: null, timestamp: null }; + } + + const data = await response.json(); + + if (data.archived_snapshots?.closest?.available === true) { + const snapshot = data.archived_snapshots.closest; + const archiveUrl = snapshot.url.replace(/^http:\/\//, 'https://'); + return { + available: true, + archiveUrl, + timestamp: snapshot.timestamp, + }; + } + + return { available: false, archiveUrl: null, timestamp: null }; + } catch (error) { + console.warn( + ` Failed to check Wayback Machine for ${url}: ${error.message}` + ); + return { available: false, archiveUrl: null, timestamp: null }; + } finally { + globalThis.clearTimeout(timeoutId); + } +} + +/** + * Format a timestamp from Wayback Machine (YYYYMMDDHHmmss) to readable date + * @param {string} timestamp - e.g. "20231015143022" + * @returns {string} - e.g. "2023-10-15" + */ +function formatTimestamp(timestamp) { + if (!timestamp || timestamp.length < 8) { + return timestamp; + } + const year = timestamp.slice(0, 4); + const month = timestamp.slice(4, 6); + const day = timestamp.slice(6, 8); + return `${year}-${month}-${day}`; +} + +/** + * Main function + */ +async function main() { + const lycheeOutput = process.env.LYCHEE_OUTPUT || 'lychee/out.md'; + + console.log('=== Web Archive Fallback Check ===\n'); + console.log(`Reading lychee output from: ${lycheeOutput}\n`); + + if (!existsSync(lycheeOutput)) { + console.log('No lychee output file found. Skipping web archive check.'); + setOutput('all_archived', 'true'); + process.exit(0); + } + + const content = readFileSync(lycheeOutput, 'utf-8'); + const { urls: brokenUrls, others: unarchivableLinks } = + extractBrokenLinks(content); + + if (unarchivableLinks.length > 0) { + // Local files and unresolvable root-relative links have no Wayback + // equivalent. Reporting `all_archived=true` for them turned a real lychee + // failure into a green run (issue #96). + console.log( + `✗ ${unarchivableLinks.length} broken link(s) cannot be checked against the Web Archive:` + ); + for (const link of unarchivableLinks) { + console.log(` ${link}`); + console.log( + '::error title=Broken link - not recoverable from the Web Archive::' + + `Broken link detected: ${link}\n` + + 'It is not an http(s) URL (missing file, unresolvable relative link, ...),\n' + + 'so the Wayback Machine cannot provide a fallback.\n' + + 'How to fix: correct the path, restore the missing file, or pass --root-dir\n' + + 'to lychee so root-relative links resolve.' + ); + } + console.log(''); + } + + if (brokenUrls.length === 0) { + console.log('No broken URLs found in lychee output.'); + setOutput('all_archived', unarchivableLinks.length === 0 ? 'true' : 'false'); + process.exit(unarchivableLinks.length === 0 ? 0 : 1); + } + + console.log( + `Found ${brokenUrls.length} broken URL(s). Checking Web Archive...\n` + ); + + const withArchive = []; + const withoutArchive = []; + + for (const url of brokenUrls) { + console.log(`Checking: ${url}`); + const result = await checkWaybackMachine(url); + + if (result.available) { + const date = formatTimestamp(result.timestamp); + console.log(` ✓ Archived on ${date}: ${result.archiveUrl}`); + withArchive.push({ url, archiveUrl: result.archiveUrl, date }); + } else { + console.log(' ✗ Not found in Web Archive'); + withoutArchive.push(url); + } + + // Small delay to avoid rate-limiting the Wayback API + await new Promise((resolve) => globalThis.setTimeout(resolve, 500)); + } + + console.log('\n=== Web Archive Check Summary ===\n'); + + if (withArchive.length > 0) { + console.log( + `✓ ${withArchive.length} broken link(s) have Web Archive versions - consider replacing:` + ); + for (const { url, archiveUrl, date } of withArchive) { + console.log(` Original: ${url}`); + console.log(` Archive (${date}): ${archiveUrl}`); + console.log(''); + } + + // Print GitHub Actions annotations as suggestions (one per link) + for (const { url, archiveUrl, date } of withArchive) { + console.log( + `::notice title=Broken link - Web Archive available (${date})::` + + `Broken link detected: ${url}\n` + + `A Web Archive snapshot from ${date} is available.\n` + + `Suggested fix: replace the broken link with the archived version:\n` + + ` ${archiveUrl}` + ); + } + } + + if (withoutArchive.length > 0) { + console.log( + `✗ ${withoutArchive.length} broken link(s) have NO Web Archive version:` + ); + for (const url of withoutArchive) { + console.log(` ${url}`); + } + console.log(''); + + // Print GitHub Actions annotations as errors (one per link) + for (const url of withoutArchive) { + console.log( + `::error title=Broken link - No Web Archive fallback::` + + `Broken link detected: ${url}\n` + + `No archived version was found in the Wayback Machine.\n` + + `How to fix:\n` + + ` 1. Find an updated URL for the same or equivalent content and replace the link.\n` + + ` 2. Remove the link if the content is no longer relevant.\n` + + ` 3. Add the URL to .lycheeignore if it is a known false positive (e.g. localhost, example.com).` + ); + } + } + + const allArchived = + withoutArchive.length === 0 && unarchivableLinks.length === 0; + setOutput('all_archived', allArchived ? 'true' : 'false'); + + if (!allArchived) { + console.log( + '\nAction required: Fix or remove the broken links listed above.' + ); + console.log( + 'For links with Web Archive versions, you can replace them with the suggested archive.org URLs.' + ); + process.exit(1); + } else { + console.log( + '\nAll broken links have Web Archive versions. Consider replacing them with the suggested archive.org URLs.' + ); + process.exit(0); + } +} + +export { extractErrorsSection, extractBrokenLinks }; + +// Only run when executed directly, so the unit tests can import the parsers. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error('Unexpected error:', error); + process.exit(1); + }); +} diff --git a/.github/scripts/check-web-archive.test.mjs b/.github/scripts/check-web-archive.test.mjs new file mode 100644 index 0000000..884227f --- /dev/null +++ b/.github/scripts/check-web-archive.test.mjs @@ -0,0 +1,69 @@ +// Regression tests for the lychee report parser used by the Broken Link +// Checker workflow. Both cases below were live CI defects found in issue #96: +// run 32145481148 escalated 9 "broken" links while lychee itself reported 4 +// errors (false positives from the redirects section), and the four real +// errors included two that the script could never verify (false negative). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + extractErrorsSection, + extractBrokenLinks, +} from './check-web-archive.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const report = readFileSync(join(here, 'fixtures', 'lychee-report.md'), 'utf-8'); + +test('the errors section stops at the next top-level heading', () => { + const section = extractErrorsSection(report); + assert.ok(section.includes('Errors in README.md')); + assert.ok( + !section.includes('Redirects per input'), + 'the redirects section must not leak into the errors section' + ); +}); + +test('a report without an errors section yields nothing', () => { + assert.equal(extractErrorsSection('# Report\n\n## Redirects per input\n\n* https://example.com --[301]--> https://example.org\n'), ''); +}); + +test('redirected links are not reported as broken', () => { + const { urls } = extractBrokenLinks(report); + for (const redirected of [ + 'https://docs.rs/link-cli', + 'https://github.com/linksplatform/Protocols.Lino', + 'https://habr.com/ru/articles/804617', + ]) { + assert.ok( + !urls.some((url) => url.startsWith(redirected)), + `${redirected} redirects successfully and must not be treated as broken` + ); + } +}); + +test('every http error is extracted exactly once', () => { + const { urls } = extractBrokenLinks(report); + assert.deepEqual(urls, [ + 'https://link-foundation.github.io/link-cli/csharp/', + 'https://link-foundation.github.io/link-cli/rust/link_cli/', + ]); +}); + +test('errors that the Wayback Machine cannot answer are still reported', () => { + const { others } = extractBrokenLinks(report); + assert.equal( + others.length, + 2, + 'the missing DocFX file and the unresolvable root-relative link must not be silently dropped' + ); + assert.ok(others.some((link) => link.endsWith('Foundation.Data.Doublets.Cli.yml'))); +}); + +test('the parsed error count matches the count lychee reports', () => { + const { urls, others } = extractBrokenLinks(report); + const reported = Number(/🚫 Errors\s*\|\s*(\d+)/.exec(report)[1]); + assert.equal(urls.length + others.length, reported); +}); diff --git a/.github/scripts/fixtures/lychee-report.md b/.github/scripts/fixtures/lychee-report.md new file mode 100644 index 0000000..c5b370c --- /dev/null +++ b/.github/scripts/fixtures/lychee-report.md @@ -0,0 +1,39 @@ +# Link Checker Report + +| Status | Count | +| -------------- | ----- | +| 🔍 Total | 120 | +| ✅ Successful | 108 | +| ⏳ Timeouts | 0 | +| 🔀 Redirected | 6 | +| 👻 Excluded | 2 | +| ❓ Unknown | 0 | +| 🚫 Errors | 4 | +| ⛔ Unsupported | 0 | + +## Errors per input + +### Errors in csharp/docs/index.md + +* [ERROR] (at 15:12) | File not found. Check if file exists and path is correct + +### Errors in js/index.html + +* [ERROR] (at 10:49) | Cannot resolve root-relative link '/favicon.svg': To resolve root-relative links in local files, provide a root dir + +### Errors in README.md + +* [404] (at 48:130) | Rejected status code: 404 Not Found +* [404] (at 49:62) | Rejected status code: 404 Not Found + +## Redirects per input + +### Redirects in README.md + +* https://docs.rs/link-cli --[302]--> https://docs.rs/link-cli/latest/link_cli/ +* https://github.com/linksplatform/Protocols.Lino --[301]--> https://github.com/link-foundation/links-notation +* https://habr.com/ru/articles/804617 --[301]--> https://habr.com/ru/articles/804617/ --[302]--> https://habr.com/ru/companies/deepfoundation/articles/804617/ + +### Redirects in rust/README.md + +* https://docs.rs/link-cli --[302]--> https://docs.rs/link-cli/latest/link_cli/ diff --git a/.github/scripts/simulate-fresh-merge.sh b/.github/scripts/simulate-fresh-merge.sh new file mode 100755 index 0000000..c6df2e6 --- /dev/null +++ b/.github/scripts/simulate-fresh-merge.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Simulate merging the pull request head into a fresh copy of the base branch and +# run the fast checks against that merge result. +# +# A pull request can be green in isolation and still break the base branch when a +# semantic (non-textual) conflict is introduced: both sides merge cleanly, but the +# combined tree no longer compiles or passes tests. GitHub only tests the PR head +# (or a merge commit computed at PR creation time), so this script recreates the +# merge locally against the current tip of the base branch. +# +# Environment: +# GITHUB_BASE_REF - base branch name (set automatically for pull_request events) +# FRESH_MERGE_CHECKS - optional newline-separated override of the commands to run +# +# Ported from link-foundation/rust-ai-driven-development-pipeline-template for +# issue #96; the check list is parameterised because this repository builds +# three stacks (Rust, C#, JavaScript/WASM) from one tree. +set -euo pipefail + +BASE_REF="${GITHUB_BASE_REF:-main}" + +if [ -z "${GITHUB_BASE_REF:-}" ]; then + echo "GITHUB_BASE_REF is not set; assuming base branch '${BASE_REF}'" +fi + +echo "Fetching origin/${BASE_REF}..." +git fetch --no-tags origin "${BASE_REF}" + +BASE_SHA="$(git rev-parse "origin/${BASE_REF}")" +HEAD_SHA="$(git rev-parse HEAD)" +echo "Base: ${BASE_REF} (${BASE_SHA})" +echo "Head: ${HEAD_SHA}" + +if git merge-base --is-ancestor "${HEAD_SHA}" "${BASE_SHA}"; then + echo "Head is already contained in origin/${BASE_REF}; nothing to simulate." + exit 0 +fi + +# Merge into a detached checkout of the base tip so the working branch is untouched. +git config user.name "${GIT_AUTHOR_NAME:-github-actions[bot]}" +git config user.email "${GIT_AUTHOR_EMAIL:-github-actions[bot]@users.noreply.github.com}" +git checkout --detach "${BASE_SHA}" + +if ! git merge --no-edit "${HEAD_SHA}"; then + echo "::error::Textual merge conflict with origin/${BASE_REF}. Merge the base branch into this pull request and resolve the conflicts." + git merge --abort || true + git checkout --force - + exit 1 +fi + +echo "Merge succeeded. Running checks on the merged tree..." + +status=0 +if [ -n "${FRESH_MERGE_CHECKS:-}" ]; then + # One command per line, so commands may contain spaces. + while IFS= read -r check; do + [ -n "${check}" ] || continue + echo "::group::${check}" + eval "${check}" || status=1 + echo "::endgroup::" + done <<< "${FRESH_MERGE_CHECKS}" +else + echo "::group::cargo fmt --all -- --check" + cargo fmt --all -- --check || status=1 + echo "::endgroup::" + + echo "::group::cargo clippy --all-targets --all-features" + cargo clippy --all-targets --all-features || status=1 + echo "::endgroup::" + + echo "::group::cargo test --all-features" + cargo test --all-features || status=1 + echo "::endgroup::" +fi + +if [ "${status}" -ne 0 ]; then + echo "::error::Checks failed on the simulated merge with origin/${BASE_REF} even though they pass on the pull request head. This is a semantic merge conflict." +fi + +git checkout --force - >/dev/null 2>&1 || true +exit "${status}" diff --git a/.github/scripts/workflow-policy.test.mjs b/.github/scripts/workflow-policy.test.mjs new file mode 100644 index 0000000..fc5f737 --- /dev/null +++ b/.github/scripts/workflow-policy.test.mjs @@ -0,0 +1,256 @@ +// Regression tests for the CI/CD invariants restored in issue #96. +// +// These run without any third-party dependency (the lint jobs call them with a +// bare `node --test`), so the workflows are inspected with a small line-based +// scanner instead of a YAML parser. Every rule below encodes a defect that was +// actually present in this repository at some point. + +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const workflowsDir = join(repoRoot, '.github', 'workflows'); + +const workflows = readdirSync(workflowsDir) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .map((name) => ({ + name, + path: join(workflowsDir, name), + text: readFileSync(join(workflowsDir, name), 'utf8'), + })); + +test('there is at least one workflow to inspect', () => { + assert.ok(workflows.length > 0, `no workflows found in ${workflowsDir}`); +}); + +/** Splits a workflow into `{ name, body }` entries, one per job. */ +function readJobs(text) { + const lines = text.split('\n'); + const jobsStart = lines.findIndex((line) => line === 'jobs:'); + if (jobsStart === -1) { + return []; + } + const jobs = []; + let current = null; + for (const line of lines.slice(jobsStart + 1)) { + if (/^[A-Za-z]/.test(line)) { + break; // back to a top-level key, the jobs mapping ended + } + const header = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line); + if (header) { + current = { name: header[1], body: [] }; + jobs.push(current); + continue; + } + if (current) { + current.body.push(line); + } + } + return jobs.map((job) => ({ name: job.name, body: job.body.join('\n') })); +} + +/** Reads the `options:` of a `workflow_dispatch` choice input. */ +function readChoiceOptions(text, inputName) { + const lines = text.split('\n'); + const start = lines.findIndex((line) => line.trim() === `${inputName}:`); + if (start === -1) { + return []; + } + const indent = lines[start].length - lines[start].trimStart().length; + const options = []; + let inOptions = false; + for (const line of lines.slice(start + 1)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const lineIndent = line.length - line.trimStart().length; + if (lineIndent <= indent) break; // left the input definition + if (trimmed === 'options:') { + inOptions = true; + continue; + } + if (inOptions) { + const item = /^-\s+(.+?)\s*$/.exec(trimmed); + if (!item) break; + options.push(item[1].replace(/^['"]|['"]$/g, '')); + } + } + return options; +} + +// A workflow that lists PR branches under `push:` while also reacting to +// `pull_request:` runs twice for every push to such a branch, doubling CI cost +// and producing duplicate status checks (issue #96). +test('no workflow reacts to both pull_request and non-main push branches', () => { + for (const workflow of workflows) { + if (!/^ {2}pull_request:$/m.test(workflow.text)) continue; + const lines = workflow.text.split('\n'); + const pushIndex = lines.findIndex((line) => line === ' push:'); + if (pushIndex === -1) continue; + const branches = []; + let inBranches = false; + for (const line of lines.slice(pushIndex + 1)) { + if (line.trim() === '') continue; + const indent = line.length - line.trimStart().length; + if (indent <= 2) break; // left the push trigger + if (indent === 4) { + inBranches = line.trim() === 'branches:'; + continue; + } + if (inBranches) { + const item = /^-\s+(.+?)\s*$/.exec(line.trim()); + if (item) branches.push(item[1].replace(/^['"]|['"]$/g, '')); + } + } + const extraneous = branches.filter((branch) => branch !== 'main'); + assert.deepEqual( + extraneous, + [], + `${workflow.name} triggers on both pull_request and push to ${extraneous.join(', ')}, so PR branches run twice`, + ); + } +}); + +// Tests that never run in CI are the purest false negative: js/test held a +// failing repository-layout test for months because no workflow invoked it. +test('the JavaScript unit tests are executed by a workflow', () => { + const jsTests = readdirSync(join(repoRoot, 'js', 'test')).filter((name) => name.endsWith('.test.mjs')); + assert.ok(jsTests.length > 0, 'expected JavaScript tests under js/test'); + assert.ok( + workflows.some((workflow) => /npm run test:js/.test(workflow.text)), + 'no workflow runs "npm run test:js", so js/test/*.test.mjs never executes in CI', + ); +}); + +for (const workflow of workflows) { + // A job without a timeout can hang for the runner's six-hour default and + // burn the whole CI budget before anyone notices. + test(`${workflow.name}: every job declares timeout-minutes`, () => { + for (const job of readJobs(workflow.text)) { + assert.match( + job.body, + /^ {4}timeout-minutes: \d+$/m, + `job "${job.name}" in ${workflow.name} has no timeout-minutes`, + ); + } + }); + + // Least privilege: the default token must be read-only unless a job opts in. + test(`${workflow.name}: declares a top-level permissions block`, () => { + assert.match( + workflow.text, + /^permissions:$/m, + `${workflow.name} does not declare top-level permissions`, + ); + }); + + // Every job must be covered by a concurrency group, either its own (writers) + // or the workflow-level one (readers), so superseded runs do not pile up. + test(`${workflow.name}: every job is covered by a concurrency group`, () => { + const hasWorkflowLevel = /^concurrency:$/m.test(workflow.text); + for (const job of readJobs(workflow.text)) { + assert.ok( + hasWorkflowLevel || /^ {4}concurrency:$/m.test(job.body), + `job "${job.name}" in ${workflow.name} has no concurrency group`, + ); + } + }); + + // `continue-on-error` turns a real failure into a green run - the exact + // false negative that hid the Windows test failures reported in issue #96. + test(`${workflow.name}: does not mask failures with continue-on-error`, () => { + assert.ok( + !/continue-on-error/.test(workflow.text), + `${workflow.name} uses continue-on-error, which masks real failures`, + ); + }); + + // In YAML a leading `!` starts a tag, so `if: !cancelled() && ...` is a + // parse error. The expression has to be wrapped in ${{ }} on a single line. + test(`${workflow.name}: single-line if expressions do not start with !`, () => { + const offenders = workflow.text + .split('\n') + .map((line, index) => ({ line, number: index + 1 })) + .filter(({ line }) => /^\s*if:\s*!/.test(line)); + assert.deepEqual( + offenders.map(({ number }) => number), + [], + `${workflow.name} has unwrapped "if: !..." expressions (wrap them in \${{ }})`, + ); + }); + + // Every advertised release mode must be handled by a job. Before issue #96 + // the C# pipeline offered a "changeset-pr" mode that no job implemented, so + // selecting it produced a successful run that did nothing at all. + test(`${workflow.name}: every release_mode option is handled by a job`, () => { + const modes = readChoiceOptions(workflow.text, 'release_mode'); + for (const mode of modes) { + assert.ok( + workflow.text.includes(`release_mode == '${mode}'`), + `${workflow.name} offers release_mode "${mode}" but no job guards on it`, + ); + } + }); +} + +// GitHub Pages serves a single site per repository, so two workflows that both +// upload a Pages artifact silently overwrite each other: whichever deploys last +// wins and the other one's URLs answer 404. Until issue #96 both docs.yml and +// wasm.yml deployed, which is why the API reference links in README.md were +// broken. docs.yml now assembles everything into one artifact. +test('exactly one workflow publishes GitHub Pages', () => { + const publishers = workflows + .filter((workflow) => /uses:\s*actions\/deploy-pages@/.test(workflow.text)) + .map((workflow) => workflow.name); + assert.deepEqual( + publishers, + ['docs.yml'], + `expected docs.yml to be the only Pages publisher, found: ${publishers.join(', ')}`, + ); +}); + +test('only the Pages publisher uploads a Pages artifact', () => { + const uploaders = workflows + .filter((workflow) => /uses:\s*actions\/upload-pages-artifact@/.test(workflow.text)) + .map((workflow) => workflow.name); + assert.deepEqual( + uploaders, + ['docs.yml'], + `a second Pages artifact replaces the published site, found: ${uploaders.join(', ')}`, + ); +}); + +// `cargo clippy` without `-D warnings` prints its findings and exits 0, so lint +// regressions land silently. Both Rust workspaces must be gated (issue #96). +test('every clippy invocation denies warnings', () => { + for (const workflow of workflows) { + const invocations = workflow.text + .split('\n') + .map((line, index) => ({ line: line.trim(), number: index + 1 })) + .filter(({ line }) => /^(run:\s*)?cargo clippy\b/.test(line)); + for (const { line, number } of invocations) { + assert.match( + line, + /--\s+-D\s+warnings/, + `${workflow.name}:${number}: "${line}" does not fail on clippy warnings`, + ); + } + } +}); + +// A pull request that is green on its own head can still break main: a clean +// textual merge is not necessarily a compiling one. Each stack that has a +// pull-request pipeline re-runs its checks on the simulated merge result. +test('the Rust and C# pipelines simulate a fresh merge on pull requests', () => { + for (const name of ['rust.yml', 'csharp.yml']) { + const workflow = workflows.find((candidate) => candidate.name === name); + assert.ok(workflow, `${name} is missing`); + assert.match( + workflow.text, + /simulate-fresh-merge\.sh/, + `${name} does not run .github/scripts/simulate-fresh-merge.sh`, + ); + } +}); diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index f90ae2d..ca384da 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -33,9 +33,16 @@ on: required: false type: string +# Reader runs (PR validation) are cancelled when superseded; runs on main are +# writers - they publish to NuGet and push version commits, so cancelling one +# mid-flight would leave a half-published release behind (issue #96). concurrency: group: csharp-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# Least privilege by default; the publishing jobs opt into more below. +permissions: + contents: read env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true @@ -51,6 +58,7 @@ jobs: detect-changes: name: Detect Changes runs-on: ubuntu-latest + timeout-minutes: 10 if: github.event_name != 'workflow_dispatch' outputs: cs-changed: ${{ steps.changes.outputs.cs-changed }} @@ -85,6 +93,7 @@ jobs: changeset-check: name: Changeset Validation runs-on: ubuntu-latest + timeout-minutes: 10 needs: [detect-changes] if: github.event_name == 'pull_request' && needs.detect-changes.outputs.csharp-code-changed == 'true' steps: @@ -122,9 +131,10 @@ jobs: lint: name: Lint and Format Check runs-on: ubuntu-latest + timeout-minutes: 20 needs: [detect-changes] if: | - always() && !cancelled() && ( + !cancelled() && ( github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.cs-changed == 'true' || @@ -152,18 +162,30 @@ jobs: working-directory: . run: node --test csharp/scripts/*.test.mjs + - name: Run workflow policy tests + working-directory: . + run: node --test .github/scripts/*.test.mjs + - name: Restore dependencies run: dotnet restore + - name: Verify formatting + run: dotnet format --verify-no-changes --verbosity diagnostic + - name: Build run: dotnet build --no-restore --configuration Release + - name: Check file sizes + working-directory: . + run: node csharp/scripts/check-file-size.mjs + # === TEST ON MULTIPLE OS === test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 30 needs: [detect-changes, changeset-check] - if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') + if: ${{ !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -183,22 +205,53 @@ jobs: run: dotnet build --no-restore --configuration Release - name: Run tests - # Windows has pre-existing file locking issues with some tests - continue-on-error: ${{ matrix.os == 'windows-latest' }} run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" - name: Upload coverage to Codecov if: matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: fail_ci_if_error: false + # === SEMANTIC MERGE CONFLICT CHECK === + # The jobs above test the pull request head. Two pull requests that each pass + # can still break `main` once merged, because a clean textual merge does not + # imply a compiling one (issue #96). This job merges the head into the current + # tip of the base branch and rebuilds and retests that tree. + fresh-merge: + name: Simulate Fresh Merge + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'pull_request' + needs: [detect-changes] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + # The script rewrites the working tree (detached checkout + merge), so it + # has to run from the repository root, not from the csharp/ default. + - name: Simulate fresh merge with base branch + working-directory: . + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + FRESH_MERGE_CHECKS: | + dotnet build csharp --configuration Release + dotnet test csharp --no-build --configuration Release --verbosity normal + run: bash .github/scripts/simulate-fresh-merge.sh + # === BUILD PACKAGE === build: name: Build Package runs-on: ubuntu-latest + timeout-minutes: 20 needs: [lint, test] - if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} steps: - uses: actions/checkout@v6 @@ -226,8 +279,14 @@ jobs: release: name: Release needs: [lint, test, build] - if: always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 45 + # Writer group: never cancel a release that may already have pushed a + # package or a version commit. + concurrency: + group: csharp-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write packages: write @@ -408,8 +467,14 @@ jobs: instant-release: name: Instant Release needs: [lint, test, build] - if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 45 + # Writer group: never cancel a release that may already have pushed a + # package or a version commit. + concurrency: + group: csharp-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write packages: write @@ -517,3 +582,70 @@ jobs: --package-id "${{ steps.package.outputs.library_id }}" \ --changelog-path "csharp/CHANGELOG.md" \ --assets-glob "csharp/artifacts/*.nupkg" + + # === MANUAL CHANGESET PR === + # The workflow_dispatch input above advertises a 'changeset-pr' release mode, + # but no job used to handle it: selecting it silently did nothing while the + # run still reported success (issue #96). This job implements the advertised + # mode - it opens a pull request carrying the changeset instead of releasing + # straight away. The branch name matches the 'changeset-manual-release-*' + # prefix that changeset-check already skips. + changeset-pr: + name: Create Changeset PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + # Writer group: never cancel a run that may already have pushed a branch. + concurrency: + group: csharp-writer-${{ github.repository }} + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create changeset file + working-directory: . + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + CHANGESET_FILE="csharp/.changeset/manual-release-${RUN_ID}.md" + DESCRIPTION_TEXT="${DESCRIPTION:-Manual ${BUMP_TYPE} release}" + { + echo '---' + echo "'Foundation.Data.Doublets.Cli': ${BUMP_TYPE}" + echo '---' + echo + echo "${DESCRIPTION_TEXT}" + } > "$CHANGESET_FILE" + echo "Created changeset: $CHANGESET_FILE" + cat "$CHANGESET_FILE" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will version, publish, and create a GitHub release diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index de17de0..5ffb51c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,12 +3,15 @@ name: Docs # Build and deploy a unified API reference site that hosts both the C# # (`Foundation.Data.Doublets.Cli`) and Rust (`link-cli`) library docs. # -# GitHub Pages allows only one deployment per repository, so this workflow -# combines DocFX-generated C# docs (under `/csharp/`) and `cargo doc`-generated -# Rust docs (under `/rust/`) into a single site that also includes a small -# landing page linking out to both. The deploy job runs on pushes to `main` -# and on manual dispatch, mirroring the AI driven development pipeline -# templates' approach (see docs/case-studies/issue-92/templates). +# GitHub Pages serves a single site per repository, so this workflow is the +# only publisher: it combines the WebAssembly workbench (site root), the +# DocFX-generated C# docs (under `/csharp/`), the `cargo doc`-generated Rust +# docs (under `/rust/`), and an API landing page (under `/docs/`) into one +# artifact. Until issue #96 wasm.yml deployed the workbench separately, so +# whichever workflow finished last replaced the other one's files and the +# documentation URLs advertised in README.md answered 404. The deploy job runs +# on pushes to `main` and on manual dispatch, mirroring the AI driven +# development pipeline templates' approach (see docs/case-studies/issue-92/templates). on: push: @@ -19,6 +22,8 @@ on: - 'csharp/docfx.json' - 'rust/src/**' - 'rust/Cargo.toml' + - 'rust/wasm/**' + - 'js/**' - '.github/workflows/docs.yml' pull_request: branches: [main] @@ -28,6 +33,8 @@ on: - 'csharp/docfx.json' - 'rust/src/**' - 'rust/Cargo.toml' + - 'rust/wasm/**' + - 'js/**' - '.github/workflows/docs.yml' workflow_dispatch: @@ -61,6 +68,18 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: npm + cache-dependency-path: js/package-lock.json - name: Cache cargo registry uses: actions/cache@v5 @@ -69,7 +88,8 @@ jobs: ~/.cargo/registry ~/.cargo/git rust/target - key: ${{ runner.os }}-cargo-docs-${{ hashFiles('rust/Cargo.lock') }} + rust/wasm/target + key: ${{ runner.os }}-cargo-docs-${{ hashFiles('rust/Cargo.lock', 'rust/wasm/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-docs- @@ -87,14 +107,24 @@ jobs: - name: Build Rust documentation run: cargo doc --manifest-path rust/Cargo.toml --no-deps --all-features + - name: Install npm dependencies + working-directory: js + run: npm ci + + - name: Build WebAssembly workbench + working-directory: js + run: npm run build:pages + - name: Assemble unified site run: | set -euo pipefail - mkdir -p _site/csharp _site/rust + mkdir -p _site/csharp _site/rust _site/docs + # The workbench owns the site root; the docs live in sub-folders. + cp -R dist/. _site/ cp -R csharp/_site/. _site/csharp/ cp -R rust/target/doc/. _site/rust/ - # Landing page that links into both sub-sites. - cat > _site/index.html <<'HTML' + # Landing page that links into both documentation sub-sites. + cat > _site/docs/index.html <<'HTML' @@ -116,8 +146,9 @@ jobs:

Generated reference for the C# and Rust library packages that ship alongside the clink CLI.

Source: github.com/link-foundation/link-cli

@@ -142,6 +173,10 @@ jobs: deploy: name: Deploy to GitHub Pages + # Single writer for GitHub Pages: never cancel a deployment mid-flight. + concurrency: + group: github-pages-${{ github.repository }} + cancel-in-progress: false if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' needs: build runs-on: ubuntu-latest diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000..e2d868e --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,100 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path dev/log + --root-dir ${{ github.workspace }}/js + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node .github/scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links found and no web archive fallback + if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true' + run: | + echo "::error::Broken links were detected with no Web Archive fallback available." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " The Web Archive (Wayback Machine) check found no archived versions for some of them." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 26d24bf..4f1f876 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -12,6 +12,14 @@ on: - '.github/workflows/rust.yml' workflow_dispatch: inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changelog-pr bump_type: description: 'Version bump type' required: true @@ -25,9 +33,16 @@ on: required: false type: string +# Reader runs (PR validation) are cancelled when superseded; runs on main are +# writers - they publish to crates.io and push version commits, so cancelling +# one mid-flight would leave a half-published release behind (issue #96). concurrency: group: rust-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# Least privilege by default; the publishing jobs opt into more below. +permissions: + contents: read env: CARGO_TERM_COLOR: always @@ -40,6 +55,7 @@ jobs: detect-changes: name: Detect Changes runs-on: ubuntu-latest + timeout-minutes: 15 if: github.event_name != 'workflow_dispatch' outputs: rs-changed: ${{ steps.changes.outputs.rs-changed }} @@ -72,6 +88,7 @@ jobs: changelog: name: Changelog Fragment Check runs-on: ubuntu-latest + timeout-minutes: 15 needs: [detect-changes] if: github.event_name == 'pull_request' && needs.detect-changes.outputs.rust-code-changed == 'true' steps: @@ -95,9 +112,10 @@ jobs: lint: name: Lint and Format Check runs-on: ubuntu-latest + timeout-minutes: 30 needs: [detect-changes] if: | - always() && !cancelled() && ( + !cancelled() && ( github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rs-changed == 'true' || @@ -129,20 +147,47 @@ jobs: ${{ runner.os }}-cargo- - name: Check formatting - run: cargo fmt --manifest-path rust/Cargo.toml --all -- --check + run: | + cargo fmt --manifest-path rust/Cargo.toml --all -- --check + cargo fmt --manifest-path rust/wasm/Cargo.toml --all -- --check + # `-D warnings` is what makes this a gate: without it clippy prints its + # findings and still exits 0, so lint warnings accumulated invisibly + # (issue #96). The wasm crate is a separate workspace and was not linted + # at all until now. - name: Run Clippy - run: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features + run: | + cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings + cargo clippy --manifest-path rust/wasm/Cargo.toml --all-targets --all-features -- -D warnings - name: Check file size limit run: rust-script rust/scripts/check-file-size.rs + # A dependency bump in Cargo.toml without the matching Cargo.lock update + # otherwise passes CI while every job silently re-resolves the graph + # (issue #96). --locked turns that drift into a failure. + - name: Verify Cargo.lock is up to date + run: | + cargo metadata --locked --format-version 1 --manifest-path rust/Cargo.toml > /dev/null + cargo metadata --locked --format-version 1 --manifest-path rust/wasm/Cargo.toml > /dev/null + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + # Guards the workflow invariants restored in issue #96 (timeouts, least + # privilege, no masked failures, no dead release modes). + - name: Run workflow policy tests + run: node --test .github/scripts/*.test.mjs + # === TEST === test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 45 needs: [detect-changes, changelog] - if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') + if: ${{ !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -170,12 +215,56 @@ jobs: - name: Run doc tests run: cargo test --manifest-path rust/Cargo.toml --doc --verbose + # === SEMANTIC MERGE CONFLICT CHECK === + # Every other job tests the pull request head. A pull request can be green in + # isolation and still break `main`: two changes that merge without a textual + # conflict can still contradict each other semantically, and the breakage only + # appears after the merge lands (issue #96). This job merges the head into the + # current tip of the base branch and re-runs the fast checks on that tree. + # Ported from the Rust pipeline template's "Simulate fresh merge" step. + fresh-merge: + name: Simulate Fresh Merge + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'pull_request' + needs: [detect-changes] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-fresh-merge-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-fresh-merge- + + - name: Simulate fresh merge with base branch + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + FRESH_MERGE_CHECKS: | + cargo fmt --manifest-path rust/Cargo.toml --all -- --check + cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings + cargo test --manifest-path rust/Cargo.toml --all-features + run: bash .github/scripts/simulate-fresh-merge.sh + # === BUILD === build: name: Build Package runs-on: ubuntu-latest + timeout-minutes: 30 needs: [lint, test] - if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} steps: - uses: actions/checkout@v6 @@ -203,8 +292,14 @@ jobs: auto-release: name: Auto Release needs: [lint, test, build] - if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 60 + # Writer group: never cancel a release that may already have published a + # crate or pushed a version commit. + concurrency: + group: rust-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write steps: @@ -296,8 +391,14 @@ jobs: manual-release: name: Manual Release needs: [lint, test, build] - if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 60 + # Writer group: never cancel a release that may already have published a + # crate or pushed a version commit. + concurrency: + group: rust-writer-${{ github.repository }} + cancel-in-progress: false permissions: contents: write steps: @@ -354,3 +455,63 @@ jobs: --repository "${{ github.repository }}" \ --tag-prefix "rust-v" \ --language "Rust" + + # === MANUAL CHANGELOG PR === + # Mirrors the C# 'changeset-pr' mode and the Rust pipeline template: instead of + # releasing straight away, open a pull request carrying the changelog fragment + # so the release goes through the normal review path (issue #96). + changelog-pr: + name: Create Changelog PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr' + runs-on: ubuntu-latest + timeout-minutes: 15 + # Writer group: never cancel a run that may already have pushed a branch. + concurrency: + group: rust-writer-${{ github.repository }} + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Create changelog fragment + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: | + rust-script rust/scripts/create-changelog-fragment.rs \ + --rust-root rust \ + --bump-type "$BUMP_TYPE" \ + --description "$DESCRIPTION" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release' + branch: changelog-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changelog fragment in this PR + 2. Merge this PR to main + 3. The automated release workflow will publish to crates.io and create a GitHub release diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..6d95cd5 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,151 @@ +name: Security + +# Mirrors the security workflow shared by the C#, Rust and JS +# ai-driven-development-pipeline templates, merged into a single file because +# this repository ships all three languages (issue #96). + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + # build-mode has to be declared per language. Leaving it unset made every + # run log "Cannot build an overlay database because build-mode is set to + # 'undefined'" and fall back to a full database, and drove the standalone + # autobuild step, whose inputs the action now reports as DEPRECATED + # (issue #96). Only C# needs a compiler; the other three are analysed from + # source. + matrix: + include: + - language: csharp + build-mode: autobuild + - language: rust + build-mode: none + - language: javascript-typescript + build-mode: none + - language: actions + build-mode: none + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + if: matrix.language == 'csharp' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + # With build-mode set here the action builds the database itself, so the + # deprecated github/codeql-action/autobuild step is no longer needed. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Analyze + uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + cargo-audit: + name: Cargo audit (${{ matrix.manifest }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-cargo-audit-${{ matrix.manifest }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + manifest: [rust, rust/wasm] + steps: + - uses: actions/checkout@v6 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit@0.22.2 + + - name: Audit committed Cargo.lock + run: cargo audit --file ${{ matrix.manifest }}/Cargo.lock + + npm-audit: + name: Audit npm lock + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-npm-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Audit current lock + working-directory: js + run: npm audit --package-lock-only --audit-level=high + + # === COMMITTED SECRETS === + # CodeQL and the audit jobs look at code and dependencies; nothing looked at + # the literal contents of the tree. This repository commits CI evidence under + # dev/log, which is exactly where a token pasted from a workflow log would end + # up unnoticed (issue #96). Ported from the pipeline templates' secrets-scan + # job. .gitignore doubles as the ignore file, so node_modules and build output + # are skipped. + secrets-scan: + name: Scan for committed secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-secrets-scan + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Run secretlint + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint --secretlintignore .gitignore "**/*" diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 663685b..0c602f7 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -1,10 +1,12 @@ name: WebAssembly CI on: + # Only main on push: PR branches are covered by the pull_request trigger, and + # listing them here made every push to an issue-* branch run this workflow + # twice (issue #96). push: branches: - main - - issue-* paths: - '.github/workflows/wasm.yml' - 'js/**' @@ -72,6 +74,12 @@ jobs: working-directory: js run: npm run test:wasm + # js/test/*.test.mjs existed but no workflow ever ran it, so repository + # layout regressions passed CI unnoticed (issue #96). + - name: Test JavaScript + working-directory: js + run: npm run test:js + - name: Build React WebAssembly app working-directory: js run: npm run build @@ -82,79 +90,7 @@ jobs: name: link-cli-web path: dist/ - build-pages: - name: Build GitHub Pages app - if: | - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - github.event_name == 'workflow_dispatch' - needs: test - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v6 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - - name: Install wasm-pack - run: cargo install wasm-pack --version 0.14.0 --locked - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20.x' - cache: npm - cache-dependency-path: js/package-lock.json - - - name: Cache cargo registry - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - rust/target - rust/wasm/target - key: ${{ runner.os }}-wasm-pages-cargo-${{ hashFiles('rust/wasm/Cargo.lock', 'rust/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-wasm-pages-cargo- - ${{ runner.os }}-wasm-cargo- - - - name: Install npm dependencies - working-directory: js - run: npm ci - - - name: Configure Pages - uses: actions/configure-pages@v6 - - - name: Build GitHub Pages app - working-directory: js - run: npm run build:pages - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: dist/ - - deploy-pages: - name: Deploy GitHub Pages - if: | - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - github.event_name == 'workflow_dispatch' - needs: build-pages - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Deploy Pages artifact - id: deployment - uses: actions/deploy-pages@v5 + # GitHub Pages is published by docs.yml alone: it assembles this workbench + # together with the C# and Rust API references into a single artifact. This + # workflow used to deploy the workbench on its own, which replaced the docs + # already published at /csharp/ and /rust/ (issue #96). diff --git a/.gitignore b/.gitignore index eb388a0..5824e25 100644 --- a/.gitignore +++ b/.gitignore @@ -424,3 +424,7 @@ csharp/_site/ csharp/docs/api/*.yml csharp/docs/api/*.manifest _site/ + +# Issue/PR investigation logs are deliberately tracked so the evidence behind a +# change ships with the pull request (issue #96). +!dev/log/ diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000..3f1ec02 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,14 @@ +# Lychee ignore patterns - regex patterns for URLs to skip during link checking + +# npmjs.com returns 403 to link checkers because of bot protection. +https://www\.npmjs\.com + +# DocFX generates csharp/docs/api/*.yml into the build output; the files are +# deliberately not committed, so the relative link from csharp/docs/index.md +# cannot be resolved from a source checkout. +csharp/docs/api/ + +# Sub-sites of the unified GitHub Pages site, published by +# .github/workflows/docs.yml on merge to main. A pull request that documents +# them would otherwise fail on URLs it is about to create. +https://link-foundation\.github\.io/link-cli/(csharp|rust|docs)/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..aa74954 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +# Local mirror of the cheap CI gates, so the same defects fail on `git commit` +# instead of ten minutes into a workflow run. Ported from the AI driven +# development pipeline templates for issue #96 and adapted to this monorepo: +# every hook has to point at the stack-specific manifest, because the three +# projects live under csharp/, rust/ and js/. +# +# Install once with: +# pip install pre-commit && pre-commit install +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-xml + - id: check-merge-conflict + - id: check-added-large-files + + - repo: local + hooks: + # Mirrors the "Lint and Format Check" job of rust.yml. + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --manifest-path rust/Cargo.toml --all -- + language: system + types: [rust] + pass_filenames: false + + - id: cargo-clippy + name: cargo clippy + entry: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings + language: system + types: [rust] + pass_filenames: false + + # Mirrors the "Lint and Format Check" job of csharp.yml. The solution sets + # TreatWarningsAsErrors in csharp/Directory.Build.props, so a plain build + # is already the warning gate. + - id: dotnet-format + name: dotnet format + entry: dotnet format csharp --verify-no-changes + language: system + types: [c#] + pass_filenames: false + + # Mirrors the workflow policy and repository layout guards. These are pure + # Node with no dependencies, so they are fast enough for every commit. + - id: workflow-policy + name: workflow policy and repository layout tests + entry: node --test .github/scripts/*.test.mjs js/test/*.test.mjs csharp/scripts/*.test.mjs + language: system + files: ^(\.github/|js/|csharp/|rust/|\.lycheeignore|README\.md) + pass_filenames: false diff --git a/.secretlintrc.json b/.secretlintrc.json new file mode 100644 index 0000000..7a1a5df --- /dev/null +++ b/.secretlintrc.json @@ -0,0 +1,7 @@ +{ + "rules": [ + { + "id": "@secretlint/secretlint-rule-preset-recommend" + } + ] +} diff --git a/README.md b/README.md index 6ad5978..1abe944 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ package built from `doublets-rs`. - C# library: package page at and the DocFX-generated reference on [GitHub Pages](https://link-foundation.github.io/link-cli/csharp/). - Rust library: (also mirrored on [GitHub Pages](https://link-foundation.github.io/link-cli/rust/link_cli/)). -- Combined landing page: . +- API landing page: (the site root hosts the WebAssembly workbench). ## Installation diff --git a/csharp/.changeset/issue-96-cicd-hardening.md b/csharp/.changeset/issue-96-cicd-hardening.md new file mode 100644 index 0000000..e2a92bf --- /dev/null +++ b/csharp/.changeset/issue-96-cicd-hardening.md @@ -0,0 +1,16 @@ +--- +'Foundation.Data.Doublets.Cli': patch +--- + +Hardened the C# build and the pipelines around it (issue #96). +`Directory.Build.props` now turns warnings into errors and enables the +.NET analyzers, and `TransactionsDecorator` / `VersionControlDecorator` +implement `IDisposable` so the memory-mapped databases they own are +released deterministically — the leak that made the Windows test job +fail while the pipeline still reported success. The C# workflow no +longer masks those Windows failures with `continue-on-error`, verifies +formatting and file sizes, and finally implements the `changeset-pr` +release mode it had been advertising without handling. +Pull requests also re-run the build and tests on a simulated merge with +the tip of `main`, and the coverage upload moved to +`codecov/codecov-action@v7` to stop the Node.js 20 deprecation warning. diff --git a/csharp/.editorconfig b/csharp/.editorconfig new file mode 100644 index 0000000..3a87ac2 --- /dev/null +++ b/csharp/.editorconfig @@ -0,0 +1,20 @@ +# C# analyzer configuration for link-cli. +# +# The repository builds with the default (recommended) .NET analyzer set and +# TreatWarningsAsErrors, so no warning can reach a green CI run again (issue #96). +# +# On top of that, the resource-lifetime rules are promoted to errors. Issue #96 was +# caused precisely by a leaked memory-mapped file handle, which is invisible on POSIX +# and fatal on Windows, so these rules are worth failing the build over. + +root = true + +[*.cs] +# CA1001: types that own disposable fields must be disposable. +dotnet_diagnostic.CA1001.severity = error +# CA1063: implement IDisposable correctly. +dotnet_diagnostic.CA1063.severity = error +# CA1816: Dispose should call GC.SuppressFinalize. +dotnet_diagnostic.CA1816.severity = error +# CA2000: dispose objects before losing scope. +dotnet_diagnostic.CA2000.severity = error diff --git a/csharp/Directory.Build.props b/csharp/Directory.Build.props new file mode 100644 index 0000000..6c2c516 --- /dev/null +++ b/csharp/Directory.Build.props @@ -0,0 +1,18 @@ + + + + + net8 + enable + enable + latest + true + true + true + + + diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Matching.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Matching.cs new file mode 100644 index 0000000..22bb248 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Matching.cs @@ -0,0 +1,424 @@ +// Part of the partial AdvancedMixedQueryProcessor class. +// Pattern matching: turning restriction patterns into concrete variable solutions. +using System; +using Platform.Delegates; +using Platform.Data; +using Platform.Data.Doublets; +using Link.Foundation.Links.Notation; +using LinoLink = Link.Foundation.Links.Notation.Link; +using DoubletLink = Platform.Data.Doublets.Link; +namespace Foundation.Data.Doublets.Cli +{ + public static partial class AdvancedMixedQueryProcessor + { + private static List> FindAllSolutions(INamedTypesLinks links, List patterns) + { + var partialSolutions = new List> { new Dictionary() }; + + for (int i = 0; i < patterns.Count; i++) + { + var pattern = patterns[i]; + var newSolutions = new List>(); + foreach (var solution in partialSolutions) + { + var matches = MatchPattern(links, pattern, solution).ToList(); + foreach (var match in matches) + { + if (AreSolutionsCompatible(solution, match)) + { + var combinedSolution = new Dictionary(solution); + foreach (var assignment in match) + { + combinedSolution[assignment.Key] = assignment.Value; + } + newSolutions.Add(combinedSolution); + } + } + } + partialSolutions = newSolutions; + if (partialSolutions.Count == 0) break; + } + + return partialSolutions; + } + + private static bool AreSolutionsCompatible( + Dictionary existingSolution, + Dictionary newAssignments) + { + foreach (var assignment in newAssignments) + { + if (existingSolution.TryGetValue(assignment.Key, out var existingValue)) + { + if (existingValue != assignment.Value) + { + return false; + } + } + } + return true; + } + + private static IEnumerable> MatchPattern( + INamedTypesLinks links, + Pattern pattern, + Dictionary currentSolution) + { + var anyConstant = links.Constants.Any; + if (pattern.IsLeaf) + { + uint leafIndex = ResolveId(links, pattern.Index, currentSolution); + var candidates = links.All(new DoubletLink(leafIndex, anyConstant, anyConstant)); + foreach (var link in candidates) + { + var candidateLink = new DoubletLink(link); + var assignments = new Dictionary(); + AssignVariableIfNeeded(pattern.Index, candidateLink.Index, assignments); + yield return assignments; + } + yield break; + } + + bool indexIsVariable = IsVariable(pattern.Index); + bool indexIsAny = pattern.Index == "*"; + uint resolvedIndex = ResolveId(links, pattern.Index, currentSolution); + + // If idxResolved is a known link => skip enumerating everything + if (!indexIsVariable && !indexIsAny && resolvedIndex != anyConstant && resolvedIndex != 0 && links.Exists(resolvedIndex)) + { + var link = new DoubletLink(links.GetLink(resolvedIndex)); + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, resolvedIndex, combined); + yield return combined; + } + } + } + else + { + // Otherwise we iterate over all links + var allLinks = links.All(new DoubletLink(anyConstant, anyConstant, anyConstant)); + foreach (var raw in allLinks) + { + var candidateLink = new DoubletLink(raw); + if (!CheckIdMatch(links, pattern.Index, candidateLink.Index, currentSolution)) + continue; + + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, candidateLink.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, candidateLink.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, candidateLink.Index, combined); + yield return combined; + } + } + } + } + } + + private static IEnumerable> RecursiveMatchSubPattern( + INamedTypesLinks links, + Pattern? pattern, + uint linkId, + Dictionary currentSolution) + { + if (pattern == null) + { + yield return currentSolution; + yield break; + } + + if (pattern.IsLeaf) + { + if (CheckIdMatch(links, pattern.Index, linkId, currentSolution)) + { + var newSolution = new Dictionary(currentSolution); + AssignVariableIfNeeded(pattern.Index, linkId, newSolution); + yield return newSolution; + } + yield break; + } + + if (!links.Exists(linkId)) yield break; + + var link = new DoubletLink(links.GetLink(linkId)); + if (!CheckIdMatch(links, pattern.Index, link.Index, currentSolution)) + { + yield break; + } + + var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); + foreach (var sourceSolution in sourceMatches) + { + var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); + foreach (var targetSolution in targetMatches) + { + var combined = new Dictionary(targetSolution); + AssignVariableIfNeeded(pattern.Index, link.Index, combined); + yield return combined; + } + } + } + + private static bool CheckIdMatch( + INamedTypesLinks links, + string patternId, + uint candidateId, + Dictionary currentSolution) + { + if (string.IsNullOrEmpty(patternId)) return true; + if (patternId == "*") return true; + + if (IsVariable(patternId)) + { + if (currentSolution.TryGetValue(patternId, out var existingVal)) + { + return existingVal == candidateId; + } + return true; + } + + uint parsed = links.Constants.Any; + if (TryParseLinkId(patternId, links, ref parsed)) + { + if (parsed == links.Constants.Any) return true; + return parsed == candidateId; + } + return true; + } + + private static void AssignVariableIfNeeded(string id, uint value, Dictionary assignments) + { + if (IsVariable(id)) + { + assignments[id] = value; + } + } + + private static bool IsVariable(string identifier) + { + return !string.IsNullOrEmpty(identifier) && identifier.StartsWith("$"); + } + + private static uint ResolveId( + INamedTypesLinks links, + string identifier, + Dictionary currentSolution) + { + var anyConstant = links.Constants.Any; + if (string.IsNullOrEmpty(identifier)) return anyConstant; + if (currentSolution.TryGetValue(identifier, out var value)) + { + return value; + } + if (IsVariable(identifier)) + { + return anyConstant; + } + uint parsedValue = anyConstant; + if (TryParseLinkId(identifier, links, ref parsedValue)) + { + return parsedValue; + } + return anyConstant; + } + + private static bool DetermineIfSolutionIsNoOperation( + Dictionary solution, + List restrictions, + List substitutions, + INamedTypesLinks links) + { + var substitutedRestrictions = restrictions + .Select(r => ApplySolutionToPattern(links, solution, r, isSubstitution: false)) + .Where(link => link != null) + .Select(link => new DoubletLink(link!)) + .ToList(); + + var substitutedSubstitutions = ApplySolutionToPatterns(links, solution, substitutions, isSubstitution: true); + + substitutedRestrictions.Sort((a, b) => a.Index.CompareTo(b.Index)); + substitutedSubstitutions.Sort((a, b) => a.Index.CompareTo(b.Index)); + + if (substitutedRestrictions.Count != substitutedSubstitutions.Count) return false; + for (int i = 0; i < substitutedRestrictions.Count; i++) + { + if (!substitutedRestrictions[i].Equals(substitutedSubstitutions[i])) + { + return false; + } + } + return true; + } + + private static List ExtractMatchedLinks( + INamedTypesLinks links, + Dictionary solution, + List patterns) + { + var matchedLinks = new List(); + foreach (var pattern in patterns) + { + var applied = ApplySolutionToPattern(links, solution, pattern); + if (applied != null) + { + var matches = links.All(applied); + foreach (var match in matches) + { + matchedLinks.Add(new DoubletLink(match)); + } + } + } + return matchedLinks.Distinct().ToList(); + } + + private static DoubletLink? ApplySolutionToPattern( + INamedTypesLinks links, + Dictionary solution, + Pattern? pattern, + bool isSubstitution = false, + HashSet? visitedIndexes = null) + { + if (pattern == null) return null; + visitedIndexes ??= new HashSet(); + + // Retrieve the ANY constant once for both leaf and composite cases + var anyConstant = links.Constants.Any; + + if (pattern.IsLeaf) + { + uint resolvedIndex = ResolveId(links, pattern.Index, solution); + return new DoubletLink(resolvedIndex, anyConstant, anyConstant); + } + else + { + uint resolvedIndex = ResolvePatternIndex(links, pattern.Index, solution, isSubstitution); + var sourceLink = ApplySolutionToPattern(links, solution, pattern.Source, isSubstitution, visitedIndexes); + var targetLink = ApplySolutionToPattern(links, solution, pattern.Target, isSubstitution, visitedIndexes); + + uint resolvedSource = sourceLink?.Index ?? anyConstant; + uint resolvedTarget = targetLink?.Index ?? anyConstant; + + PreserveExistingSubstitutionParts(links, solution, pattern, resolvedIndex, ref resolvedSource, ref resolvedTarget, isSubstitution, visitedIndexes); + + if (resolvedSource == 0) resolvedSource = anyConstant; + if (resolvedTarget == 0) resolvedTarget = anyConstant; + + return new DoubletLink(resolvedIndex, resolvedSource, resolvedTarget); + } + } + + private static uint ResolvePatternIndex( + INamedTypesLinks links, + string identifier, + Dictionary solution, + bool isSubstitution) + { + if (isSubstitution && string.IsNullOrEmpty(identifier)) + { + return links.Constants.Null; + } + + if (isSubstitution && IsVariable(identifier) && !solution.ContainsKey(identifier)) + { + return links.Constants.Null; + } + + return ResolveId(links, identifier, solution); + } + + private static List ApplySolutionToPatterns( + INamedTypesLinks links, + Dictionary solution, + List patterns, + bool isSubstitution) + { + var workingSolution = isSubstitution ? new Dictionary(solution) : solution; + return patterns + .Select(pattern => ApplySolutionToPattern(links, workingSolution, pattern, isSubstitution)) + .Where(link => link != null) + .Select(link => new DoubletLink(link!)) + .ToList(); + } + + private static void PreserveExistingSubstitutionParts( + INamedTypesLinks links, + Dictionary solution, + Pattern pattern, + uint resolvedIndex, + ref uint resolvedSource, + ref uint resolvedTarget, + bool isSubstitution, + HashSet visitedIndexes) + { + if (!isSubstitution || resolvedIndex == links.Constants.Null || resolvedIndex == links.Constants.Any || !links.Exists(resolvedIndex)) + { + return; + } + + if (!visitedIndexes.Add(resolvedIndex)) + { + return; + } + + try + { + var existingLink = new DoubletLink(links.GetLink(resolvedIndex)); + + if (ShouldPreserveExistingPart(pattern.Source, solution) && CanPreserveExistingPart(existingLink, existingLink.Source, visitedIndexes)) + { + resolvedSource = existingLink.Source; + AssignVariableIfNeeded(pattern.Source!.Index, resolvedSource, solution); + } + else if (TryResolveVariablePart(pattern.Source, solution, out var boundSource)) + { + resolvedSource = boundSource; + } + + if (ShouldPreserveExistingPart(pattern.Target, solution) && CanPreserveExistingPart(existingLink, existingLink.Target, visitedIndexes)) + { + resolvedTarget = existingLink.Target; + AssignVariableIfNeeded(pattern.Target!.Index, resolvedTarget, solution); + } + else if (TryResolveVariablePart(pattern.Target, solution, out var boundTarget)) + { + resolvedTarget = boundTarget; + } + } + finally + { + visitedIndexes.Remove(resolvedIndex); + } + } + + private static bool ShouldPreserveExistingPart(Pattern? partPattern, Dictionary solution) + { + return partPattern?.IsLeaf == true + && IsVariable(partPattern.Index) + && !solution.ContainsKey(partPattern.Index); + } + + private static bool TryResolveVariablePart(Pattern? partPattern, Dictionary solution, out uint value) + { + value = default; + return partPattern?.IsLeaf == true + && IsVariable(partPattern.Index) + && solution.TryGetValue(partPattern.Index, out value); + } + + private static bool CanPreserveExistingPart(DoubletLink existingLink, uint part, HashSet visitedIndexes) + { + return existingLink.IsFullPoint() + || existingLink.IsPartialPoint() + || !visitedIndexes.Contains(part); + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Mutations.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Mutations.cs new file mode 100644 index 0000000..48719a0 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.Mutations.cs @@ -0,0 +1,482 @@ +// Part of the partial AdvancedMixedQueryProcessor class. +// Applying a solution to the store: creating, updating and removing doublets. +using System; +using Platform.Delegates; +using Platform.Data; +using Platform.Data.Doublets; +using Link.Foundation.Links.Notation; +using LinoLink = Link.Foundation.Links.Notation.Link; +using DoubletLink = Platform.Data.Doublets.Link; +namespace Foundation.Data.Doublets.Cli +{ + public static partial class AdvancedMixedQueryProcessor + { + private static void CreateOrUpdateLink(INamedTypesLinks links, DoubletLink linkDefinition, Options options) + { + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + // Wildcard substitution rename: delegate to nested creation with proper naming + if (linkDefinition.Index == anyConstant) + { + TraceIfEnabled(options, "[CreateOrUpdateLink] Detected wildcard substitution => nested create & name."); + var parsed = new Parser().Parse(options.Query ?? string.Empty); + if (parsed.Count > 0) + { + var outer = parsed[0]; + if (outer.Values != null && outer.Values.Count > 1) + { + var substitutionLinoLink = outer.Values[1]; + if (substitutionLinoLink.Values != null) + { + foreach (var composite in substitutionLinoLink.Values) + { + EnsureNestedLinkCreatedRecursively(links, composite, options); + } + } + } + } + return; + } + + if (linkDefinition.Index != nullConstant) + { + // update existing link + if (!links.Exists(linkDefinition.Index)) + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} doesn't exist => ensuring creation."); + LinksExtensions.EnsureCreated(links, linkDefinition.Index); + } + var existingLinkRecord = links.GetLink(linkDefinition.Index); + var existingDoublet = new DoubletLink(existingLinkRecord); + + if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) + { + TraceIfEnabled(options, + $"[CreateOrUpdateLink] Updating link #{linkDefinition.Index}: {existingDoublet.Source}->{linkDefinition.Source}, {existingDoublet.Target}->{linkDefinition.Target}."); + LinksExtensions.EnsureCreated(links, linkDefinition.Index); + links.Update( + new DoubletLink(linkDefinition.Index, anyConstant, anyConstant), + linkDefinition, + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + else + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} is already S={linkDefinition.Source}, T={linkDefinition.Target} => no change."); + options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); + } + } + else + { + // create new link + var existingLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); + if (existingLinkIndex == default) + { + uint newLinkIndex = 0; + TraceIfEnabled(options, + $"[CreateOrUpdateLink] Creating new link => (S={linkDefinition.Source},T={linkDefinition.Target})."); + links.CreateAndUpdate(linkDefinition.Source, linkDefinition.Target, (beforeState, afterState) => + { + var afterLinkRecord = new DoubletLink(afterState); + if (newLinkIndex == 0 && afterLinkRecord.Index != 0 && afterLinkRecord.Index != anyConstant) + { + newLinkIndex = afterLinkRecord.Index; + TraceIfEnabled(options, $"[CreateOrUpdateLink] => assigned new ID={newLinkIndex}"); + } + return options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue; + }); + + if (newLinkIndex == 0 || newLinkIndex == anyConstant) + { + newLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); + } + } + else + { + TraceIfEnabled(options, $"[CreateOrUpdateLink] Link already found => ID={existingLinkIndex}, no changes."); + var existingLink = new DoubletLink(existingLinkIndex, linkDefinition.Source, linkDefinition.Target); + options.ChangesHandler?.Invoke(existingLink, existingLink); + } + } + } + + private static void RemoveLinks( + INamedTypesLinks links, + DoubletLink restriction, + Options options) + { + var linksToRemove = links.All(restriction) + .Where(l => l != null) + .Select(l => new DoubletLink(l)) + .ToList(); + + TraceIfEnabled(options, + $"[RemoveLinks] Found {linksToRemove.Count} link(s) matching (ID={restriction.Index}, S={restriction.Source}, T={restriction.Target})."); + + foreach (var link in linksToRemove) + { + if (links.Exists(link.Index)) + { + // Remove the name before deleting + links.RemoveName(link.Index); + TraceIfEnabled(options, $"[RemoveLinks] Deleting link => ID={link.Index}, S={link.Source}, T={link.Target}"); + links.Delete(link, (before, after) => + options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + } + } + } + + private static DoubletLink ConvertToDoubletLink(INamedTypesLinks links, LinoLink linoLink, uint defaultValue) + { + uint index = defaultValue; + uint source = defaultValue; + uint target = defaultValue; + TryParseLinkId(linoLink.Id, links, ref index); + if (linoLink.Values?.Count == 2) + { + var sourceLink = linoLink.Values[0]; + TryParseLinkId(sourceLink.Id, links, ref source); + var targetLink = linoLink.Values[1]; + TryParseLinkId(targetLink.Id, links, ref target); + } + return new DoubletLink(index, source, target); + } + + private static bool TryParseLinkId(string? id, INamedTypesLinks links, ref uint parsedValue) + { + if (string.IsNullOrEmpty(id)) return false; + if (id == "*") + { + parsedValue = links.Constants.Any; + return true; + } + else if (id.EndsWith(":")) + { + var trimmed = id.TrimEnd(':'); + if (uint.TryParse(trimmed, out uint linkId)) + { + parsedValue = linkId; + return true; + } + // Try to resolve as string alias + var aliasId = links.GetByName(trimmed); + if (aliasId != links.Constants.Null) + { + parsedValue = aliasId; + return true; + } + } + else if (uint.TryParse(id, out uint linkVal)) + { + parsedValue = linkVal; + return true; + } + else + { + // Try to resolve as string alias + var aliasId = links.GetByName(id); + if (aliasId != links.Constants.Null) + { + parsedValue = aliasId; + return true; + } + } + return false; + } + + public class Pattern + { + public string Index; + public Pattern? Source; + public Pattern? Target; + + public Pattern(string index, Pattern? source = null, Pattern? target = null) + { + Index = index ?? ""; + Source = source; + Target = target; + } + + public bool IsLeaf => Source == null && Target == null; + } + + private static Pattern CreatePatternFromLino(LinoLink linkNode) + { + if (linkNode.Values == null || linkNode.Values.Count == 0) + { + return new Pattern(linkNode.Id ?? ""); + } + + if (linkNode.Values.Count == 2) + { + var sourcePattern = CreatePatternFromLino(linkNode.Values[0]); + var targetPattern = CreatePatternFromLino(linkNode.Values[1]); + return new Pattern(linkNode.Id ?? "", sourcePattern, targetPattern); + } + + // If more than 2 => treat similarly to leaf with ID + return new Pattern(linkNode.Id ?? ""); + } + + private static uint EnsureLinkCreated(INamedTypesLinks links, DoubletLink link, Options options) + { + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + if (link.Index == nullConstant) + { + // If no index => search or create + var existingIndex = links.SearchOrDefault(link.Source, link.Target); + if (existingIndex == default) + { + uint createdIndex = 0; + TraceIfEnabled(options, $"[EnsureLinkCreated] Creating link for (S={link.Source}, T={link.Target})."); + links.CreateAndUpdate(link.Source, link.Target, (before, after) => + { + var afterLink = new DoubletLink(after); + if (createdIndex == 0 && afterLink.Index != 0 && afterLink.Index != anyConstant) + { + createdIndex = afterLink.Index; + TraceIfEnabled(options, $"[EnsureLinkCreated] => assigned new ID={createdIndex}"); + } + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + + if (createdIndex == 0 || createdIndex == anyConstant) + { + createdIndex = links.SearchOrDefault(link.Source, link.Target); + } + return createdIndex; + } + else + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link already found => ID={existingIndex} => no-op."); + var existing = new DoubletLink(existingIndex, link.Source, link.Target); + options.ChangesHandler?.Invoke(existing, existing); + return existingIndex; + } + } + else + { + // We have an index => ensure created or updated + if (!links.Exists(link.Index)) + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} doesn't exist => ensuring creation."); + LinksExtensions.EnsureCreated(links, link.Index); + } + var stored = links.GetLink(link.Index); + var storedD = new DoubletLink(stored); + if (storedD.Source != link.Source || storedD.Target != link.Target) + { + TraceIfEnabled(options, + $"[EnsureLinkCreated] Updating link #{link.Index} => {storedD.Source}->{link.Source}, {storedD.Target}->{link.Target}."); + uint finalIndex = link.Index; + links.Update(new DoubletLink(link.Index, anyConstant, anyConstant), link, (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); + return finalIndex; + } + else + { + TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); + options.ChangesHandler?.Invoke(storedD, storedD); + return link.Index; + } + } + } + + // Helper for link naming logic + private static bool IsNumericOrStar(string? id) + { + if (string.IsNullOrEmpty(id)) return false; + if (id == "*") return true; + uint dummy; + return uint.TryParse(id, out dummy); + } + + private static void TraceIfEnabled(Options options, string message) + { + if (options.Trace) + { + Console.WriteLine(message); + } + } + + // Consolidates getting or creating a named link (leaf) without setting its relationships + private static uint EnsureNamedLeafLink(INamedTypesLinks links, string name, Options options) + { + var existing = links.GetByName(name); + if (existing != links.Constants.Null) return existing; + var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created named leaf '{name}' => ID={newId}"); + links.SetName(newId, name); + return newId; + } + + // Applies a single structural update to an existing link: sets its source and target + private static void ApplyCompositeUpdate(INamedTypesLinks links, uint id, uint source, uint target, Options options) + { + var restriction = new DoubletLink(id, links.Constants.Null, links.Constants.Null); + var substitution = new DoubletLink(id, source, target); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link ID={id} => Source={source}, Target={target}"); + links.Update(restriction, substitution, (before, after) => + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={before}, after={after}"); + return links.Constants.Continue; + }); + } + + /// + /// Detects a two-child composite pattern where at least one child matches the composite identifier. + /// + private static bool TryGetTwoChildCompositePattern( + LinoLink pattern, + out string compositeIdentifier, + out LinoLink leftPattern, + out LinoLink rightPattern) + { + compositeIdentifier = pattern.Id ?? string.Empty; + leftPattern = default!; + rightPattern = default!; + if (!string.IsNullOrEmpty(compositeIdentifier) + && pattern.Values != null + && pattern.Values.Count == 2) + { + leftPattern = pattern.Values[0]; + rightPattern = pattern.Values[1]; + // Only detect composites when one or both children share the identifier + if (leftPattern.Id == compositeIdentifier || rightPattern.Id == compositeIdentifier) + { + return true; + } + } + return false; + } + + private enum CompositeCase { Self, LeftMix, RightMix } + + private static CompositeCase ClassifyCompositeCase(string name, LinoLink left, LinoLink right) + { + if (left.Id == name && right.Id == name) return CompositeCase.Self; + if (left.Id == name && right.Id != name) return CompositeCase.LeftMix; + if (left.Id != name && right.Id == name) return CompositeCase.RightMix; + throw new InvalidOperationException($"Invalid composite pattern for name '{name}'"); + } + + private static uint HandleStringComposite(string name, LinoLink left, LinoLink right, INamedTypesLinks links, Options options) + { + var id = EnsureNamedLeafLink(links, name, options); + var caseType = ClassifyCompositeCase(name, left, right); + switch (caseType) + { + case CompositeCase.Self: + ApplyCompositeUpdate(links, id, id, id, options); + return id; + case CompositeCase.LeftMix: + { + var otherId = EnsureNestedLinkCreatedRecursively(links, right, options); + ApplyCompositeUpdate(links, id, id, otherId, options); + return id; + } + case CompositeCase.RightMix: + { + var otherId = EnsureNestedLinkCreatedRecursively(links, left, options); + ApplyCompositeUpdate(links, id, otherId, id, options); + return id; + } + default: + throw new InvalidOperationException($"Unhandled composite case {caseType}"); + } + } + + /// + /// Resolves a single leaf pattern into its numeric or named link ID. + /// + private static uint ResolveLeaf(LinoLink pattern, INamedTypesLinks links, Options options) + { + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; + + if (string.IsNullOrEmpty(pattern.Id)) + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with empty ID => returning ANY."); + return anyConstant; + } + if (pattern.Id == "*") + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with '*' => returning ANY."); + return anyConstant; + } + if (pattern.Id.StartsWith("$")) + { + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Variable leaf => returning ANY."); + return anyConstant; + } + if (uint.TryParse(pattern.Id, out uint parsedNumber)) + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Leaf parse => returning {parsedNumber}."); + return parsedNumber; + } + var existingId = links.GetByName(pattern.Id); + if (existingId != links.Constants.Null) + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Found existing named leaf '{pattern.Id}' => ID={existingId}"); + return existingId; + } + var newLeafId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] SetName({newLeafId}, '{pattern.Id}')"); + links.SetName(newLeafId, pattern.Id); + var restriction = new DoubletLink(newLeafId, links.Constants.Null, links.Constants.Null); + var substitution = new DoubletLink(newLeafId, newLeafId, newLeafId); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link {newLeafId} to be self-referential"); + links.Update(restriction, substitution, (beforeState, afterState) => + { + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={beforeState}, after={afterState}"); + return links.Constants.Continue; + }); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created new self-referential named leaf '{pattern.Id}' => ID={newLeafId}"); + return newLeafId; + } + + /// + /// Ensures a composite link exists with the given index or named identifier and child IDs. + /// + private static uint CreateCompositeLink( + string? literalIdentifier, + uint sourceLinkId, + uint targetLinkId, + INamedTypesLinks links, + Options options) + { + // Determine the numeric index for the composite: default 0, wildcard, or parsed from identifier + uint compositeIndex = 0; + var wildcardIndex = links.Constants.Any; + if (!string.IsNullOrEmpty(literalIdentifier)) + { + if (literalIdentifier == "*") + { + compositeIndex = wildcardIndex; + } + else + { + var identifierClean = literalIdentifier.Replace(":", string.Empty); + if (uint.TryParse(identifierClean, out var parsedIndex)) + { + compositeIndex = parsedIndex; + } + } + } + // Build the composite link structure and ensure it exists + var compositeLinkDefinition = new DoubletLink(compositeIndex, sourceLinkId, targetLinkId); + var compositeLinkId = EnsureLinkCreated(links, compositeLinkDefinition, options); + TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created or ensured composite link => Index={compositeIndex}, Source={sourceLinkId}, Target={targetLinkId} => Actual ID={compositeLinkId}"); + // Assign the name for non-numeric identifiers + if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier) && !literalIdentifier.StartsWith("$")) + { + links.SetName(compositeLinkId, literalIdentifier); + } + return compositeLinkId; + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.References.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.References.cs new file mode 100644 index 0000000..7b5a408 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.References.cs @@ -0,0 +1,329 @@ +// Part of the partial AdvancedMixedQueryProcessor class. +// Reference validation and auto-creation of links a substitution refers to. +using System; +using Platform.Delegates; +using Platform.Data; +using Platform.Data.Doublets; +using Link.Foundation.Links.Notation; +using LinoLink = Link.Foundation.Links.Notation.Link; +using DoubletLink = Platform.Data.Doublets.Link; +namespace Foundation.Data.Doublets.Cli +{ + public static partial class AdvancedMixedQueryProcessor + { + private static void ValidateLinksExistOrWillBeCreated( + INamedTypesLinks links, + IList restrictionPatterns, + IList substitutionPatterns, + Options options) + { + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Starting validation"); + + var plan = BuildLinkReferencePlan(links, substitutionPatterns); + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {string.Join(", ", plan.NumericIdsToBeCreated.OrderBy(id => id))}"); + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Named links to be created: {string.Join(", ", plan.NamesToBeCreated.OrderBy(name => name, StringComparer.Ordinal))}"); + + CollectMissingReferences(restrictionPatterns, links, plan, false, "restriction", options); + CollectMissingReferences(substitutionPatterns, links, plan, true, "substitution", options); + + if (plan.MissingReferences.Count > 0) + { + if (!options.AutoCreateMissingReferences) + { + var missing = plan.MissingReferences[0]; + throw new InvalidOperationException( + $"Invalid reference to non-existent link '{missing.Identifier}' in {missing.PatternType} pattern. " + + $"Link '{missing.Identifier}' does not exist and will not be created by this operation. " + + "Use --auto-create-missing-references to create missing references as point links." + ); + } + + AutoCreateMissingReferences(links, plan, options); + } + + TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); + } + + private sealed class LinkReferencePlan + { + public HashSet NumericIdsToBeCreated { get; } = new(); + public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); + public HashSet<(uint Source, uint Target)> CompositePairsToBeCreated { get; } = new(); + public List MissingReferences { get; } = new(); + private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); + + public void AddMissingReference(MissingLinkReference reference) + { + if (_missingReferenceKeys.Add(reference.Key)) + { + MissingReferences.Add(reference); + } + } + } + + private sealed class MissingLinkReference + { + public required string Identifier { get; init; } + public required string PatternType { get; init; } + public required uint? NumericId { get; init; } + public string Key => NumericId.HasValue ? $"id:{NumericId.Value}" : $"name:{Identifier}"; + } + + private static LinkReferencePlan BuildLinkReferencePlan(INamedTypesLinks links, IList substitutionPatterns) + { + var plan = new LinkReferencePlan(); + var reservedNumericIds = new HashSet(); + + foreach (var pattern in substitutionPatterns) + { + CollectExplicitDefinitions(pattern, plan, reservedNumericIds); + } + + foreach (var pattern in substitutionPatterns) + { + CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); + } + + foreach (var pattern in substitutionPatterns) + { + CollectCompositePairs(pattern, plan); + } + + return plan; + } + + private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePlan plan, HashSet reservedNumericIds) + { + if (IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + if (uint.TryParse(identifier, out var linkId)) + { + plan.NumericIdsToBeCreated.Add(linkId); + reservedNumericIds.Add(linkId); + } + else + { + plan.NamesToBeCreated.Add(identifier); + } + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectExplicitDefinitions(subPattern, plan, reservedNumericIds); + } + } + } + + private static void CollectCompositePairs(LinoLink pattern, LinkReferencePlan plan) + { + if (IsComposite(pattern) && + TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier) && + pattern.Values != null && + TryGetConcreteNumericIdentifier(pattern.Values[0].Id, out var source) && + TryGetConcreteNumericIdentifier(pattern.Values[1].Id, out var target)) + { + plan.CompositePairsToBeCreated.Add((source, target)); + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectCompositePairs(subPattern, plan); + } + } + } + + private static void CollectImplicitDefinitions( + LinoLink pattern, + INamedTypesLinks links, + LinkReferencePlan plan, + HashSet reservedNumericIds) + { + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectImplicitDefinitions(subPattern, links, plan, reservedNumericIds); + } + } + + if (IsComposite(pattern) && !TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier)) + { + var nextId = GetNextAvailableLinkId(links, reservedNumericIds); + reservedNumericIds.Add(nextId); + plan.NumericIdsToBeCreated.Add(nextId); + } + } + + private static uint GetNextAvailableLinkId(INamedTypesLinks links, HashSet reservedNumericIds) + { + uint nextId = 1; + while (links.Exists(nextId) || reservedNumericIds.Contains(nextId)) + { + nextId++; + } + return nextId; + } + + private static void CollectMissingReferences( + IList patterns, + INamedTypesLinks links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, + Options options) + { + foreach (var pattern in patterns) + { + CollectMissingReferences(pattern, links, plan, isSubstitution, patternType, options); + } + } + + private static void CollectMissingReferences( + LinoLink pattern, + INamedTypesLinks links, + LinkReferencePlan plan, + bool isSubstitution, + string patternType, + Options options) + { + var patternIdIsDefinition = isSubstitution && IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier); + + if (!patternIdIsDefinition && TryGetConcreteIdentifier(pattern.Id, out var identifier)) + { + ValidateReferenceIdentifier(identifier, links, plan, patternType, options); + } + + if (pattern.Values != null) + { + foreach (var subPattern in pattern.Values) + { + CollectMissingReferences(subPattern, links, plan, isSubstitution, patternType, options); + } + } + } + + private static void ValidateReferenceIdentifier( + string identifier, + INamedTypesLinks links, + LinkReferencePlan plan, + string patternType, + Options options) + { + if (uint.TryParse(identifier, out var linkId)) + { + if (!links.Exists(linkId) && !plan.NumericIdsToBeCreated.Contains(linkId)) + { + plan.AddMissingReference(new MissingLinkReference + { + Identifier = identifier, + PatternType = patternType, + NumericId = linkId + }); + return; + } + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); + return; + } + + if (links.GetByName(identifier) == links.Constants.Null && !plan.NamesToBeCreated.Contains(identifier)) + { + plan.AddMissingReference(new MissingLinkReference + { + Identifier = identifier, + PatternType = patternType, + NumericId = null + }); + return; + } + + TraceIfEnabled(options, $"[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {patternType} pattern"); + } + + private static void AutoCreateMissingReferences( + INamedTypesLinks links, + LinkReferencePlan plan, + Options options) + { + foreach (var missing in plan.MissingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) + { + var linkId = missing.NumericId!.Value; + if (links.Exists(linkId)) + { + continue; + } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId}."); + LinksExtensions.EnsureCreated(links, linkId); + if (plan.CompositePairsToBeCreated.Contains((linkId, linkId))) + { + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Link {linkId} exists as a placeholder because ({linkId}, {linkId}) is defined by the substitution."); + continue; + } + links.Update( + new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), + new DoubletLink(linkId, linkId, linkId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + + foreach (var missing in plan.MissingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) + { + if (links.GetByName(missing.Identifier) != links.Constants.Null) + { + continue; + } + + TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{missing.Identifier}' as point link."); + EnsureNamedPointLink(links, missing.Identifier, options); + } + } + + private static void EnsureNamedPointLink(INamedTypesLinks links, string name, Options options) + { + if (links.GetByName(name) != links.Constants.Null) + { + return; + } + + var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(newId, name); + links.Update( + new DoubletLink(newId, links.Constants.Null, links.Constants.Null), + new DoubletLink(newId, newId, newId), + (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue + ); + } + + private static bool IsComposite(LinoLink pattern) => pattern.Values?.Count == 2; + + private static bool TryGetConcreteIdentifier(string? id, out string identifier) + { + identifier = string.Empty; + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } + + identifier = id.TrimEnd(':'); + if (identifier.Length == 0 || identifier == "*" || identifier.StartsWith("$")) + { + return false; + } + + return true; + } + + private static bool TryGetConcreteNumericIdentifier(string? id, out uint linkId) + { + linkId = 0; + return TryGetConcreteIdentifier(id, out var identifier) && uint.TryParse(identifier, out linkId); + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs index 21c5c80..27105fc 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/AdvancedMixedQueryProcessor.cs @@ -5,1673 +5,479 @@ using Link.Foundation.Links.Notation; using LinoLink = Link.Foundation.Links.Notation.Link; using DoubletLink = Platform.Data.Doublets.Link; - namespace Foundation.Data.Doublets.Cli { - public static class AdvancedMixedQueryProcessor - { - public class Options - { - public string? Query { get; set; } - public WriteHandler? ChangesHandler { get; set; } - - /// - /// Enables extra console tracing of internal steps if true. - /// - public bool Trace { get; set; } = false; - - /// - /// Creates missing numeric and named references as self-referential point links instead of failing validation. - /// - public bool AutoCreateMissingReferences { get; set; } = false; - - public static implicit operator Options(string query) => new Options { Query = query }; - } - - public static void ProcessQuery(INamedTypesLinks links, Options options) + public static partial class AdvancedMixedQueryProcessor { - ArgumentNullException.ThrowIfNull(links); - ArgumentNullException.ThrowIfNull(options); - - var query = options.Query; - TraceIfEnabled(options, $"[ProcessQuery] Query: \"{query}\""); - - if (string.IsNullOrEmpty(query)) - { - TraceIfEnabled(options, "[ProcessQuery] Query is empty, returning."); - return; - } - - var parser = new Parser(); - var parsedLinks = parser.Parse(query); - - TraceIfEnabled(options, $"[ProcessQuery] Parser returned {parsedLinks.Count} top-level link(s)."); - if (parsedLinks.Count == 0) - { - TraceIfEnabled(options, "[ProcessQuery] No top-level parsed links found, returning."); - return; - } - - // We expect something like (( restriction ) ( substitution )) - var outerLink = parsedLinks[0]; - var outerLinkValues = outerLink.Values; - if (outerLinkValues == null || outerLinkValues.Count < 2) - { - TraceIfEnabled(options, "[ProcessQuery] Outer link has fewer than 2 sub-links, returning."); - return; - } - - var restrictionLink = outerLinkValues[0]; - var substitutionLink = outerLinkValues[1]; - - TraceIfEnabled(options, $"[ProcessQuery] Restriction link => Id=\"{restrictionLink.Id}\" Values.Count={restrictionLink.Values?.Count ?? 0}"); - TraceIfEnabled(options, $"[ProcessQuery] Substitution link => Id=\"{substitutionLink.Id}\" Values.Count={substitutionLink.Values?.Count ?? 0}"); - - // If both restriction and substitution are empty, do nothing - if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) - { - TraceIfEnabled(options, "[ProcessQuery] Restriction & substitution both empty => no operation, returning."); - return; - } - - // Creation scenario: no restriction, only substitution - if (restrictionLink.Values?.Count == 0 && (substitutionLink.Values?.Count ?? 0) > 0) - { - TraceIfEnabled(options, "[ProcessQuery] No restriction, but substitution is non-empty => creation scenario."); - - // VALIDATION: Validate that all references in creation scenario are valid - try - { - var emptyRestrictionPatterns = new List(); - ValidateLinksExistOrWillBeCreated(links, emptyRestrictionPatterns, substitutionLink.Values ?? new List(), options); - } - catch (Exception ex) - { - TraceIfEnabled(options, $"[ProcessQuery] Creation validation failed: {ex.Message}"); - throw; - } - - foreach (var linkToCreate in substitutionLink.Values ?? new List()) - { - var createdId = EnsureNestedLinkCreatedRecursively(links, linkToCreate, options); - TraceIfEnabled(options, $"[ProcessQuery] Created link ID #{createdId} from substitution pattern."); - } - return; - } - - // Build pattern lists from the sub-links - var restrictionPatterns = restrictionLink.Values ?? new List(); - var substitutionPatterns = substitutionLink.Values ?? new List(); - - TraceIfEnabled(options, $"[ProcessQuery] Restriction patterns to parse: {restrictionPatterns.Count}"); - TraceIfEnabled(options, $"[ProcessQuery] Substitution patterns to parse: {substitutionPatterns.Count}"); - - // VALIDATION: Check that all referenced links exist or will be created - try - { - ValidateLinksExistOrWillBeCreated(links, restrictionPatterns, substitutionPatterns, options); - } - catch (Exception ex) - { - TraceIfEnabled(options, $"[ProcessQuery] Validation failed: {ex.Message}"); - throw; - } - - var restrictionInternalPatterns = restrictionPatterns - .Select(l => CreatePatternFromLino(l)) - .ToList(); - - var substitutionInternalPatterns = substitutionPatterns - .Select(l => CreatePatternFromLino(l)) - .ToList(); - - // ---------------------------------------------------------------- - // FIX: If we see restrictionLink with exactly 1 sub-link => that sub-link has 2 sub-values => interpret as a single composite pattern - // This handles patterns like ((() (1 2))) where the outer restriction has a single composite child - if ( - string.IsNullOrEmpty(restrictionLink.Id) && - restrictionLink.Values?.Count == 1 - ) - { - var single = restrictionLink.Values[0]; - // Check if this is a composite (has 2 sub-values) and doesn't have a numeric/wildcard ID - if ( - single.Values?.Count == 2 && - (string.IsNullOrEmpty(single.Id) || !IsNumericOrStar(single.Id)) - ) + public class Options { - // Create a single composite pattern from ((1 *) (* 2)) - var topLevelPattern = CreatePatternFromLino(single); - - // If it doesn't have an explicit index or if it's "*", force a variable ID, so we don't unify with #1/#2 - if (string.IsNullOrEmpty(topLevelPattern.Index) || topLevelPattern.Index == "*") - { - topLevelPattern.Index = "$top_" + Guid.NewGuid().ToString("N"); - TraceIfEnabled(options, $"[ProcessQuery] Assigned a variable index => {topLevelPattern.Index}"); - } - - // Clear out the multiple sub-pattern expansions and replace with our single composite pattern - restrictionInternalPatterns.Clear(); - restrictionInternalPatterns.Add(topLevelPattern); - - TraceIfEnabled(options, - "[ProcessQuery] Detected single sub-link with 2 sub-values => replaced with one composite restriction pattern."); - } - } - // ---------------------------------------------------------------- - - // If restrictionLink.Id is not empty => treat it as an extra pattern - if (!string.IsNullOrEmpty(restrictionLink.Id)) - { - TraceIfEnabled(options, "[ProcessQuery] Restriction link has non-empty Id => adding extra pattern for it."); - var extraRestrictionPattern = CreatePatternFromLino(restrictionLink); - restrictionInternalPatterns.Insert(0, extraRestrictionPattern); - } - - // If substitutionLink.Id is not empty => treat it as an extra pattern - if (!string.IsNullOrEmpty(substitutionLink.Id)) - { - TraceIfEnabled(options, "[ProcessQuery] Substitution link has non-empty Id => adding extra pattern for it."); - var extraSubstitutionPattern = CreatePatternFromLino(substitutionLink); - substitutionInternalPatterns.Insert(0, extraSubstitutionPattern); - } + public string? Query { get; set; } + public WriteHandler? ChangesHandler { get; set; } - TraceIfEnabled(options, "[ProcessQuery] Converting restriction patterns => done."); - TraceIfEnabled(options, "[ProcessQuery] Converting substitution patterns => done."); + /// + /// Enables extra console tracing of internal steps if true. + /// + public bool Trace { get; set; } = false; - TraceIfEnabled(options, "[ProcessQuery] Finding solutions for restriction patterns..."); - var solutions = FindAllSolutions(links, restrictionInternalPatterns); + /// + /// Creates missing numeric and named references as self-referential point links instead of failing validation. + /// + public bool AutoCreateMissingReferences { get; set; } = false; - TraceIfEnabled(options, $"[ProcessQuery] Found {solutions.Count} total solution(s) matching restriction patterns."); - if (solutions.Count == 0) - { - TraceIfEnabled(options, "[ProcessQuery] No solutions found => returning."); - return; - } - - // Decide if all solutions would lead to a no-op - bool allSolutionsNoOperation = solutions.All(solution => - DetermineIfSolutionIsNoOperation(solution, restrictionInternalPatterns, substitutionInternalPatterns, links)); - - TraceIfEnabled(options, "[ProcessQuery] allSolutionsNoOperation=" + allSolutionsNoOperation); - - var allPlannedOperations = new List<(DoubletLink before, DoubletLink after)>(); - if (allSolutionsNoOperation) - { - TraceIfEnabled(options, "[ProcessQuery] All solutions produce no differences => we'll track them as no-op changes."); - foreach (var solution in solutions) - { - var matchedLinks = ExtractMatchedLinks(links, solution, restrictionInternalPatterns); - TraceIfEnabled(options, $"[ProcessQuery] One solution => matched {matchedLinks.Count} link(s)."); - foreach (var link in matchedLinks) - { - allPlannedOperations.Add((link, link)); - } - } - } - else - { - TraceIfEnabled(options, "[ProcessQuery] Some solutions lead to actual changes => building operations."); - foreach (var solution in solutions) - { - var substitutionLinks = ApplySolutionToPatterns(links, solution, substitutionInternalPatterns, isSubstitution: true); - var restrictionLinks = ApplySolutionToPatterns(links, solution, restrictionInternalPatterns, isSubstitution: false); - - TraceIfEnabled(options, - "[ProcessQuery] For a solution => " + - $"substitution links count={substitutionLinks.Count}, restriction links count={restrictionLinks.Count}."); - - var operations = DetermineOperationsFromPatterns(restrictionLinks, substitutionLinks, links); - TraceIfEnabled(options, $"[ProcessQuery] => {operations.Count} operation(s) derived from these patterns."); - allPlannedOperations.AddRange(operations); + public static implicit operator Options(string query) => new Options { Query = query }; } - } - - TraceIfEnabled(options, "[ProcessQuery] All planned operations => " + allPlannedOperations.Count); - if (allSolutionsNoOperation) - { - TraceIfEnabled(options, "[ProcessQuery] Since they're all no-ops, just calling ChangesHandler with (before, before)."); - foreach (var (before, after) in allPlannedOperations) + public static void ProcessQuery(INamedTypesLinks links, Options options) { - options.ChangesHandler?.Invoke(before, after); - } - } - else - { - var intendedFinalStates = new Dictionary(); - foreach (var (before, after) in allPlannedOperations) - { - if (after.Index != 0) - { - intendedFinalStates[after.Index] = after; - } - else if (before.Index != 0 && after.Index == 0) - { - intendedFinalStates[before.Index] = default(DoubletLink); - } - } + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(options); - var unexpectedDeletions = new List(); - var originalHandler = options.ChangesHandler; + var query = options.Query; + TraceIfEnabled(options, $"[ProcessQuery] Query: \"{query}\""); - try - { - options.ChangesHandler = (before, after) => - { - var beforeLink = new DoubletLink(before); - var afterLink = new DoubletLink(after); - if (beforeLink.Index != 0 && afterLink.Index == 0) + if (string.IsNullOrEmpty(query)) { - bool isExpected = allPlannedOperations.Any(op => op.before.Index == beforeLink.Index && op.after.Index == 0); - if (!isExpected) - { - unexpectedDeletions.Add(new DoubletLink(beforeLink)); - TraceIfEnabled(options, $"[ProcessQuery] Detected unexpected deletion of link #{beforeLink.Index} => will restore later."); - } + TraceIfEnabled(options, "[ProcessQuery] Query is empty, returning."); + return; } - return originalHandler?.Invoke(before, after) ?? links.Constants.Continue; - }; - TraceIfEnabled(options, "[ProcessQuery] Applying all planned operations..."); - ApplyAllPlannedOperations(links, allPlannedOperations, options); - } - finally - { - options.ChangesHandler = originalHandler; - } + var parser = new Parser(); + var parsedLinks = parser.Parse(query); - TraceIfEnabled(options, "[ProcessQuery] Restoring unexpected deletions if any..."); - RestoreUnexpectedLinkDeletions(links, unexpectedDeletions, intendedFinalStates, options); - } + TraceIfEnabled(options, $"[ProcessQuery] Parser returned {parsedLinks.Count} top-level link(s)."); + if (parsedLinks.Count == 0) + { + TraceIfEnabled(options, "[ProcessQuery] No top-level parsed links found, returning."); + return; + } - TraceIfEnabled(options, "[ProcessQuery] Finished processing query."); - } + // We expect something like (( restriction ) ( substitution )) + var outerLink = parsedLinks[0]; + var outerLinkValues = outerLink.Values; + if (outerLinkValues == null || outerLinkValues.Count < 2) + { + TraceIfEnabled(options, "[ProcessQuery] Outer link has fewer than 2 sub-links, returning."); + return; + } - /// - /// Recursively ensures that a LinoLink (potentially nested) is created. - /// Returns the numeric ID or ANY if leaf/unparseable. - /// - private static uint EnsureNestedLinkCreatedRecursively(INamedTypesLinks links, LinoLink pattern, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; + var restrictionLink = outerLinkValues[0]; + var substitutionLink = outerLinkValues[1]; - // Handle string-based two-child composites - if (TryGetTwoChildCompositePattern(pattern, out var name, out var left, out var right) && !IsNumericOrStar(name)) - { - return HandleStringComposite(name, left, right, links, options); - } + TraceIfEnabled(options, $"[ProcessQuery] Restriction link => Id=\"{restrictionLink.Id}\" Values.Count={restrictionLink.Values?.Count ?? 0}"); + TraceIfEnabled(options, $"[ProcessQuery] Substitution link => Id=\"{substitutionLink.Id}\" Values.Count={substitutionLink.Values?.Count ?? 0}"); - if (pattern.Values == null || pattern.Values.Count == 0) - { - return ResolveLeaf(pattern, links, options); - } + // If both restriction and substitution are empty, do nothing + if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) + { + TraceIfEnabled(options, "[ProcessQuery] Restriction & substitution both empty => no operation, returning."); + return; + } - // If 2 Values => interpret as a composite link - if (pattern.Values.Count == 2) - { - var sourceId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[0], options); - var targetId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[1], options); + // Creation scenario: no restriction, only substitution + if (restrictionLink.Values?.Count == 0 && (substitutionLink.Values?.Count ?? 0) > 0) + { + TraceIfEnabled(options, "[ProcessQuery] No restriction, but substitution is non-empty => creation scenario."); + + // VALIDATION: Validate that all references in creation scenario are valid + try + { + var emptyRestrictionPatterns = new List(); + ValidateLinksExistOrWillBeCreated(links, emptyRestrictionPatterns, substitutionLink.Values ?? new List(), options); + } + catch (Exception ex) + { + TraceIfEnabled(options, $"[ProcessQuery] Creation validation failed: {ex.Message}"); + throw; + } + + foreach (var linkToCreate in substitutionLink.Values ?? new List()) + { + var createdId = EnsureNestedLinkCreatedRecursively(links, linkToCreate, options); + TraceIfEnabled(options, $"[ProcessQuery] Created link ID #{createdId} from substitution pattern."); + } + return; + } - // Generic composite creation for numeric or non-matching patterns - return CreateCompositeLink(pattern.Id, sourceId, targetId, links, options); - } + // Build pattern lists from the sub-links + var restrictionPatterns = restrictionLink.Values ?? new List(); + var substitutionPatterns = substitutionLink.Values ?? new List(); - // If more than 2 => do nothing special => ANY - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] More than 2 sub-values => returning ANY."); - return anyConstant; - } + TraceIfEnabled(options, $"[ProcessQuery] Restriction patterns to parse: {restrictionPatterns.Count}"); + TraceIfEnabled(options, $"[ProcessQuery] Substitution patterns to parse: {substitutionPatterns.Count}"); - private static void RestoreUnexpectedLinkDeletions( - INamedTypesLinks links, - List unexpectedDeletions, - Dictionary finalIntendedStates, - Options options) - { - if (unexpectedDeletions.Count > 0) - { - TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] We have {unexpectedDeletions.Count} unexpected deletion(s)."); - foreach (var deletedLink in unexpectedDeletions) - { - if (finalIntendedStates.TryGetValue(deletedLink.Index, out var intendedLink)) - { - if (intendedLink.Index == 0) + // VALIDATION: Check that all referenced links exist or will be created + try { - TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Link #{deletedLink.Index} was intended-deletion => skip restore."); - continue; + ValidateLinksExistOrWillBeCreated(links, restrictionPatterns, substitutionPatterns, options); } - if (!links.Exists(intendedLink.Index)) + catch (Exception ex) { - TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Recreating link #{deletedLink.Index} => was unexpected deletion."); - CreateOrUpdateLink(links, intendedLink, options); + TraceIfEnabled(options, $"[ProcessQuery] Validation failed: {ex.Message}"); + throw; } - } - } - } - else - { - TraceIfEnabled(options, "[RestoreUnexpectedLinkDeletions] No unexpected deletions found."); - } - } - - private static List<(DoubletLink before, DoubletLink after)> DetermineOperationsFromPatterns( - List restrictions, - List substitutions, - INamedTypesLinks links) - { - var anyOrZero = new HashSet { 0, links.Constants.Any }; - var normalRestrictions = restrictions.Where(r => !anyOrZero.Contains(r.Index)).ToList(); - var wildcardRestrictions = restrictions.Where(r => anyOrZero.Contains(r.Index)).ToList(); - - var normalSubstitutions = substitutions.Where(s => !anyOrZero.Contains(s.Index)).ToList(); - var wildcardSubstitutions = substitutions.Where(s => anyOrZero.Contains(s.Index)).ToList(); + var restrictionInternalPatterns = restrictionPatterns + .Select(l => CreatePatternFromLino(l)) + .ToList(); + + var substitutionInternalPatterns = substitutionPatterns + .Select(l => CreatePatternFromLino(l)) + .ToList(); + + // ---------------------------------------------------------------- + // FIX: If we see restrictionLink with exactly 1 sub-link => that sub-link has 2 sub-values => interpret as a single composite pattern + // This handles patterns like ((() (1 2))) where the outer restriction has a single composite child + if ( + string.IsNullOrEmpty(restrictionLink.Id) && + restrictionLink.Values?.Count == 1 + ) + { + var single = restrictionLink.Values[0]; + // Check if this is a composite (has 2 sub-values) and doesn't have a numeric/wildcard ID + if ( + single.Values?.Count == 2 && + (string.IsNullOrEmpty(single.Id) || !IsNumericOrStar(single.Id)) + ) + { + // Create a single composite pattern from ((1 *) (* 2)) + var topLevelPattern = CreatePatternFromLino(single); + + // If it doesn't have an explicit index or if it's "*", force a variable ID, so we don't unify with #1/#2 + if (string.IsNullOrEmpty(topLevelPattern.Index) || topLevelPattern.Index == "*") + { + topLevelPattern.Index = "$top_" + Guid.NewGuid().ToString("N"); + TraceIfEnabled(options, $"[ProcessQuery] Assigned a variable index => {topLevelPattern.Index}"); + } + + // Clear out the multiple sub-pattern expansions and replace with our single composite pattern + restrictionInternalPatterns.Clear(); + restrictionInternalPatterns.Add(topLevelPattern); + + TraceIfEnabled(options, + "[ProcessQuery] Detected single sub-link with 2 sub-values => replaced with one composite restriction pattern."); + } + } + // ---------------------------------------------------------------- - var restrictionByIndex = normalRestrictions.ToDictionary(r => r.Index, r => r); - var substitutionByIndex = normalSubstitutions.ToDictionary(s => s.Index, s => s); + // If restrictionLink.Id is not empty => treat it as an extra pattern + if (!string.IsNullOrEmpty(restrictionLink.Id)) + { + TraceIfEnabled(options, "[ProcessQuery] Restriction link has non-empty Id => adding extra pattern for it."); + var extraRestrictionPattern = CreatePatternFromLino(restrictionLink); + restrictionInternalPatterns.Insert(0, extraRestrictionPattern); + } - var operations = new List<(DoubletLink before, DoubletLink after)>(); - var allIndices = restrictionByIndex.Keys.Union(substitutionByIndex.Keys).ToList(); + // If substitutionLink.Id is not empty => treat it as an extra pattern + if (!string.IsNullOrEmpty(substitutionLink.Id)) + { + TraceIfEnabled(options, "[ProcessQuery] Substitution link has non-empty Id => adding extra pattern for it."); + var extraSubstitutionPattern = CreatePatternFromLino(substitutionLink); + substitutionInternalPatterns.Insert(0, extraSubstitutionPattern); + } - // Step 1) For each distinct index in normal restrictions & substitutions - foreach (var linkIndex in allIndices) - { - bool hasRestriction = restrictionByIndex.TryGetValue(linkIndex, out var restrictionLink); - bool hasSubstitution = substitutionByIndex.TryGetValue(linkIndex, out var substitutionLink); + TraceIfEnabled(options, "[ProcessQuery] Converting restriction patterns => done."); + TraceIfEnabled(options, "[ProcessQuery] Converting substitution patterns => done."); - if (hasRestriction && hasSubstitution) - { - if (restrictionLink.Source != substitutionLink.Source || restrictionLink.Target != substitutionLink.Target) - { - operations.Add((restrictionLink, substitutionLink)); - } - else - { - operations.Add((restrictionLink, restrictionLink)); - } - } - else if (hasRestriction && !hasSubstitution) - { - // Deletion - operations.Add((restrictionLink, default(DoubletLink))); - } - else if (!hasRestriction && hasSubstitution) - { - // Creation - operations.Add((default(DoubletLink), substitutionLink)); - } - } + TraceIfEnabled(options, "[ProcessQuery] Finding solutions for restriction patterns..."); + var solutions = FindAllSolutions(links, restrictionInternalPatterns); - // Step 2) Wildcard restrictions => each is a separate "delete" - foreach (var restrictionLink in wildcardRestrictions) - { - operations.Add((restrictionLink, default(DoubletLink))); - } + TraceIfEnabled(options, $"[ProcessQuery] Found {solutions.Count} total solution(s) matching restriction patterns."); + if (solutions.Count == 0) + { + TraceIfEnabled(options, "[ProcessQuery] No solutions found => returning."); + return; + } - // Step 3) Wildcard substitutions => each is a separate "create" - foreach (var substitutionLink in wildcardSubstitutions) - { - operations.Add((default(DoubletLink), substitutionLink)); - } + // Decide if all solutions would lead to a no-op + bool allSolutionsNoOperation = solutions.All(solution => + DetermineIfSolutionIsNoOperation(solution, restrictionInternalPatterns, substitutionInternalPatterns, links)); - return operations; - } + TraceIfEnabled(options, "[ProcessQuery] allSolutionsNoOperation=" + allSolutionsNoOperation); - private static void ApplyAllPlannedOperations( - INamedTypesLinks links, - List<(DoubletLink before, DoubletLink after)> operations, - Options options) - { - foreach (var (before, after) in operations) - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Operation: before=({before.Index}:{before.Source}->{before.Target}), after=({after.Index}:{after.Source}->{after.Target})"); - if (before.Index != 0) - { - var beforeName = links.GetName(before.Index); - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for before.Index {before.Index} = '{beforeName}'"); - } - if (after.Index != 0) - { - var afterNamePre = links.GetName(after.Index); - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePre}' (pre-op)"); - } - if (before.Index != 0 && after.Index == 0) - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Deleting link => ID={before.Index}, S={before.Source}, T={before.Target}"); - RemoveLinks(links, before, options); - } - else if (before.Index == 0 && (after.Index != 0 || after.Source != 0 || after.Target != 0)) - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Creating link => ID={after.Index}, S={after.Source}, T={after.Target}"); - CreateOrUpdateLink(links, after, options); - } - else if (before.Index != 0 && after.Index != 0) - { - if (before.Source != after.Source || before.Target != after.Target) - { - if (before.Index == after.Index) + var allPlannedOperations = new List<(DoubletLink before, DoubletLink after)>(); + if (allSolutionsNoOperation) { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Updating link in-place => ID={before.Index}"); - if (!links.Exists(after.Index)) - { - LinksExtensions.EnsureCreated(links, after.Index); - } - links.Update(before, after, (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); + TraceIfEnabled(options, "[ProcessQuery] All solutions produce no differences => we'll track them as no-op changes."); + foreach (var solution in solutions) + { + var matchedLinks = ExtractMatchedLinks(links, solution, restrictionInternalPatterns); + TraceIfEnabled(options, $"[ProcessQuery] One solution => matched {matchedLinks.Count} link(s)."); + foreach (var link in matchedLinks) + { + allPlannedOperations.Add((link, link)); + } + } } else { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Removing old link => ID={before.Index} then creating new => ID={after.Index}."); - RemoveLinks(links, before, options); - CreateOrUpdateLink(links, after, options); + TraceIfEnabled(options, "[ProcessQuery] Some solutions lead to actual changes => building operations."); + foreach (var solution in solutions) + { + var substitutionLinks = ApplySolutionToPatterns(links, solution, substitutionInternalPatterns, isSubstitution: true); + var restrictionLinks = ApplySolutionToPatterns(links, solution, restrictionInternalPatterns, isSubstitution: false); + + TraceIfEnabled(options, + "[ProcessQuery] For a solution => " + + $"substitution links count={substitutionLinks.Count}, restriction links count={restrictionLinks.Count}."); + + var operations = DetermineOperationsFromPatterns(restrictionLinks, substitutionLinks, links); + TraceIfEnabled(options, $"[ProcessQuery] => {operations.Count} operation(s) derived from these patterns."); + allPlannedOperations.AddRange(operations); + } } - } - else - { - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] No changes for link => ID={before.Index} => no-op."); - options.ChangesHandler?.Invoke(before, before); - } - } - if (after.Index != 0) - { - var afterNamePost = links.GetName(after.Index); - TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePost}' (post-op)"); - } - } - } - private static List> FindAllSolutions(INamedTypesLinks links, List patterns) - { - var partialSolutions = new List> { new Dictionary() }; + TraceIfEnabled(options, "[ProcessQuery] All planned operations => " + allPlannedOperations.Count); - for (int i = 0; i < patterns.Count; i++) - { - var pattern = patterns[i]; - var newSolutions = new List>(); - foreach (var solution in partialSolutions) - { - var matches = MatchPattern(links, pattern, solution).ToList(); - foreach (var match in matches) - { - if (AreSolutionsCompatible(solution, match)) + if (allSolutionsNoOperation) { - var combinedSolution = new Dictionary(solution); - foreach (var assignment in match) - { - combinedSolution[assignment.Key] = assignment.Value; - } - newSolutions.Add(combinedSolution); + TraceIfEnabled(options, "[ProcessQuery] Since they're all no-ops, just calling ChangesHandler with (before, before)."); + foreach (var (before, after) in allPlannedOperations) + { + options.ChangesHandler?.Invoke(before, after); + } } - } - } - partialSolutions = newSolutions; - if (partialSolutions.Count == 0) break; - } - - return partialSolutions; - } - - private static bool AreSolutionsCompatible( - Dictionary existingSolution, - Dictionary newAssignments) - { - foreach (var assignment in newAssignments) - { - if (existingSolution.TryGetValue(assignment.Key, out var existingValue)) - { - if (existingValue != assignment.Value) - { - return false; - } - } - } - return true; - } - - private static IEnumerable> MatchPattern( - INamedTypesLinks links, - Pattern pattern, - Dictionary currentSolution) - { - var anyConstant = links.Constants.Any; - if (pattern.IsLeaf) - { - uint leafIndex = ResolveId(links, pattern.Index, currentSolution); - var candidates = links.All(new DoubletLink(leafIndex, anyConstant, anyConstant)); - foreach (var link in candidates) - { - var candidateLink = new DoubletLink(link); - var assignments = new Dictionary(); - AssignVariableIfNeeded(pattern.Index, candidateLink.Index, assignments); - yield return assignments; - } - yield break; - } - - bool indexIsVariable = IsVariable(pattern.Index); - bool indexIsAny = pattern.Index == "*"; - uint resolvedIndex = ResolveId(links, pattern.Index, currentSolution); - - // If idxResolved is a known link => skip enumerating everything - if (!indexIsVariable && !indexIsAny && resolvedIndex != anyConstant && resolvedIndex != 0 && links.Exists(resolvedIndex)) - { - var link = new DoubletLink(links.GetLink(resolvedIndex)); - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); - foreach (var targetSolution in targetMatches) - { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, resolvedIndex, combined); - yield return combined; - } - } - } - else - { - // Otherwise we iterate over all links - var allLinks = links.All(new DoubletLink(anyConstant, anyConstant, anyConstant)); - foreach (var raw in allLinks) - { - var candidateLink = new DoubletLink(raw); - if (!CheckIdMatch(links, pattern.Index, candidateLink.Index, currentSolution)) - continue; - - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, candidateLink.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, candidateLink.Target, sourceSolution); - foreach (var targetSolution in targetMatches) + else { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, candidateLink.Index, combined); - yield return combined; + var intendedFinalStates = new Dictionary(); + foreach (var (before, after) in allPlannedOperations) + { + if (after.Index != 0) + { + intendedFinalStates[after.Index] = after; + } + else if (before.Index != 0 && after.Index == 0) + { + intendedFinalStates[before.Index] = default(DoubletLink); + } + } + + var unexpectedDeletions = new List(); + var originalHandler = options.ChangesHandler; + + try + { + options.ChangesHandler = (before, after) => + { + var beforeLink = new DoubletLink(before); + var afterLink = new DoubletLink(after); + if (beforeLink.Index != 0 && afterLink.Index == 0) + { + bool isExpected = allPlannedOperations.Any(op => op.before.Index == beforeLink.Index && op.after.Index == 0); + if (!isExpected) + { + unexpectedDeletions.Add(new DoubletLink(beforeLink)); + TraceIfEnabled(options, $"[ProcessQuery] Detected unexpected deletion of link #{beforeLink.Index} => will restore later."); + } + } + return originalHandler?.Invoke(before, after) ?? links.Constants.Continue; + }; + + TraceIfEnabled(options, "[ProcessQuery] Applying all planned operations..."); + ApplyAllPlannedOperations(links, allPlannedOperations, options); + } + finally + { + options.ChangesHandler = originalHandler; + } + + TraceIfEnabled(options, "[ProcessQuery] Restoring unexpected deletions if any..."); + RestoreUnexpectedLinkDeletions(links, unexpectedDeletions, intendedFinalStates, options); } - } - } - } - } - - private static IEnumerable> RecursiveMatchSubPattern( - INamedTypesLinks links, - Pattern? pattern, - uint linkId, - Dictionary currentSolution) - { - if (pattern == null) - { - yield return currentSolution; - yield break; - } - - if (pattern.IsLeaf) - { - if (CheckIdMatch(links, pattern.Index, linkId, currentSolution)) - { - var newSolution = new Dictionary(currentSolution); - AssignVariableIfNeeded(pattern.Index, linkId, newSolution); - yield return newSolution; - } - yield break; - } - - if (!links.Exists(linkId)) yield break; - - var link = new DoubletLink(links.GetLink(linkId)); - if (!CheckIdMatch(links, pattern.Index, link.Index, currentSolution)) - { - yield break; - } - var sourceMatches = RecursiveMatchSubPattern(links, pattern.Source, link.Source, currentSolution); - foreach (var sourceSolution in sourceMatches) - { - var targetMatches = RecursiveMatchSubPattern(links, pattern.Target, link.Target, sourceSolution); - foreach (var targetSolution in targetMatches) - { - var combined = new Dictionary(targetSolution); - AssignVariableIfNeeded(pattern.Index, link.Index, combined); - yield return combined; + TraceIfEnabled(options, "[ProcessQuery] Finished processing query."); } - } - } - - private static bool CheckIdMatch( - INamedTypesLinks links, - string patternId, - uint candidateId, - Dictionary currentSolution) - { - if (string.IsNullOrEmpty(patternId)) return true; - if (patternId == "*") return true; - if (IsVariable(patternId)) - { - if (currentSolution.TryGetValue(patternId, out var existingVal)) + /// + /// Recursively ensures that a LinoLink (potentially nested) is created. + /// Returns the numeric ID or ANY if leaf/unparseable. + /// + private static uint EnsureNestedLinkCreatedRecursively(INamedTypesLinks links, LinoLink pattern, Options options) { - return existingVal == candidateId; - } - return true; - } - - uint parsed = links.Constants.Any; - if (TryParseLinkId(patternId, links, ref parsed)) - { - if (parsed == links.Constants.Any) return true; - return parsed == candidateId; - } - return true; - } - - private static void AssignVariableIfNeeded(string id, uint value, Dictionary assignments) - { - if (IsVariable(id)) - { - assignments[id] = value; - } - } - - private static bool IsVariable(string identifier) - { - return !string.IsNullOrEmpty(identifier) && identifier.StartsWith("$"); - } + var nullConstant = links.Constants.Null; + var anyConstant = links.Constants.Any; - private static uint ResolveId( - INamedTypesLinks links, - string identifier, - Dictionary currentSolution) - { - var anyConstant = links.Constants.Any; - if (string.IsNullOrEmpty(identifier)) return anyConstant; - if (currentSolution.TryGetValue(identifier, out var value)) - { - return value; - } - if (IsVariable(identifier)) - { - return anyConstant; - } - uint parsedValue = anyConstant; - if (TryParseLinkId(identifier, links, ref parsedValue)) - { - return parsedValue; - } - return anyConstant; - } - - private static bool DetermineIfSolutionIsNoOperation( - Dictionary solution, - List restrictions, - List substitutions, - INamedTypesLinks links) - { - var substitutedRestrictions = restrictions - .Select(r => ApplySolutionToPattern(links, solution, r, isSubstitution: false)) - .Where(link => link != null) - .Select(link => new DoubletLink(link!)) - .ToList(); - - var substitutedSubstitutions = ApplySolutionToPatterns(links, solution, substitutions, isSubstitution: true); - - substitutedRestrictions.Sort((a, b) => a.Index.CompareTo(b.Index)); - substitutedSubstitutions.Sort((a, b) => a.Index.CompareTo(b.Index)); - - if (substitutedRestrictions.Count != substitutedSubstitutions.Count) return false; - for (int i = 0; i < substitutedRestrictions.Count; i++) - { - if (!substitutedRestrictions[i].Equals(substitutedSubstitutions[i])) - { - return false; - } - } - return true; - } - - private static List ExtractMatchedLinks( - INamedTypesLinks links, - Dictionary solution, - List patterns) - { - var matchedLinks = new List(); - foreach (var pattern in patterns) - { - var applied = ApplySolutionToPattern(links, solution, pattern); - if (applied != null) - { - var matches = links.All(applied); - foreach (var match in matches) - { - matchedLinks.Add(new DoubletLink(match)); - } - } - } - return matchedLinks.Distinct().ToList(); - } - - private static DoubletLink? ApplySolutionToPattern( - INamedTypesLinks links, - Dictionary solution, - Pattern? pattern, - bool isSubstitution = false, - HashSet? visitedIndexes = null) - { - if (pattern == null) return null; - visitedIndexes ??= new HashSet(); - - // Retrieve the ANY constant once for both leaf and composite cases - var anyConstant = links.Constants.Any; - - if (pattern.IsLeaf) - { - uint resolvedIndex = ResolveId(links, pattern.Index, solution); - return new DoubletLink(resolvedIndex, anyConstant, anyConstant); - } - else - { - uint resolvedIndex = ResolvePatternIndex(links, pattern.Index, solution, isSubstitution); - var sourceLink = ApplySolutionToPattern(links, solution, pattern.Source, isSubstitution, visitedIndexes); - var targetLink = ApplySolutionToPattern(links, solution, pattern.Target, isSubstitution, visitedIndexes); - - uint resolvedSource = sourceLink?.Index ?? anyConstant; - uint resolvedTarget = targetLink?.Index ?? anyConstant; - - PreserveExistingSubstitutionParts(links, solution, pattern, resolvedIndex, ref resolvedSource, ref resolvedTarget, isSubstitution, visitedIndexes); - - if (resolvedSource == 0) resolvedSource = anyConstant; - if (resolvedTarget == 0) resolvedTarget = anyConstant; - - return new DoubletLink(resolvedIndex, resolvedSource, resolvedTarget); - } - } - - private static uint ResolvePatternIndex( - INamedTypesLinks links, - string identifier, - Dictionary solution, - bool isSubstitution) - { - if (isSubstitution && string.IsNullOrEmpty(identifier)) - { - return links.Constants.Null; - } - - if (isSubstitution && IsVariable(identifier) && !solution.ContainsKey(identifier)) - { - return links.Constants.Null; - } - - return ResolveId(links, identifier, solution); - } - - private static List ApplySolutionToPatterns( - INamedTypesLinks links, - Dictionary solution, - List patterns, - bool isSubstitution) - { - var workingSolution = isSubstitution ? new Dictionary(solution) : solution; - return patterns - .Select(pattern => ApplySolutionToPattern(links, workingSolution, pattern, isSubstitution)) - .Where(link => link != null) - .Select(link => new DoubletLink(link!)) - .ToList(); - } - - private static void PreserveExistingSubstitutionParts( - INamedTypesLinks links, - Dictionary solution, - Pattern pattern, - uint resolvedIndex, - ref uint resolvedSource, - ref uint resolvedTarget, - bool isSubstitution, - HashSet visitedIndexes) - { - if (!isSubstitution || resolvedIndex == links.Constants.Null || resolvedIndex == links.Constants.Any || !links.Exists(resolvedIndex)) - { - return; - } - - if (!visitedIndexes.Add(resolvedIndex)) - { - return; - } - - try - { - var existingLink = new DoubletLink(links.GetLink(resolvedIndex)); - - if (ShouldPreserveExistingPart(pattern.Source, solution) && CanPreserveExistingPart(existingLink, existingLink.Source, visitedIndexes)) - { - resolvedSource = existingLink.Source; - AssignVariableIfNeeded(pattern.Source!.Index, resolvedSource, solution); - } - else if (TryResolveVariablePart(pattern.Source, solution, out var boundSource)) - { - resolvedSource = boundSource; - } - - if (ShouldPreserveExistingPart(pattern.Target, solution) && CanPreserveExistingPart(existingLink, existingLink.Target, visitedIndexes)) - { - resolvedTarget = existingLink.Target; - AssignVariableIfNeeded(pattern.Target!.Index, resolvedTarget, solution); - } - else if (TryResolveVariablePart(pattern.Target, solution, out var boundTarget)) - { - resolvedTarget = boundTarget; - } - } - finally - { - visitedIndexes.Remove(resolvedIndex); - } - } - - private static bool ShouldPreserveExistingPart(Pattern? partPattern, Dictionary solution) - { - return partPattern?.IsLeaf == true - && IsVariable(partPattern.Index) - && !solution.ContainsKey(partPattern.Index); - } - - private static bool TryResolveVariablePart(Pattern? partPattern, Dictionary solution, out uint value) - { - value = default; - return partPattern?.IsLeaf == true - && IsVariable(partPattern.Index) - && solution.TryGetValue(partPattern.Index, out value); - } - - private static bool CanPreserveExistingPart(DoubletLink existingLink, uint part, HashSet visitedIndexes) - { - return existingLink.IsFullPoint() - || existingLink.IsPartialPoint() - || !visitedIndexes.Contains(part); - } - - private static void CreateOrUpdateLink(INamedTypesLinks links, DoubletLink linkDefinition, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - // Wildcard substitution rename: delegate to nested creation with proper naming - if (linkDefinition.Index == anyConstant) - { - TraceIfEnabled(options, "[CreateOrUpdateLink] Detected wildcard substitution => nested create & name."); - var parsed = new Parser().Parse(options.Query ?? string.Empty); - if (parsed.Count > 0) - { - var outer = parsed[0]; - if (outer.Values != null && outer.Values.Count > 1) - { - var substitutionLinoLink = outer.Values[1]; - if (substitutionLinoLink.Values != null) + // Handle string-based two-child composites + if (TryGetTwoChildCompositePattern(pattern, out var name, out var left, out var right) && !IsNumericOrStar(name)) { - foreach (var composite in substitutionLinoLink.Values) - { - EnsureNestedLinkCreatedRecursively(links, composite, options); - } + return HandleStringComposite(name, left, right, links, options); } - } - } - return; - } - if (linkDefinition.Index != nullConstant) - { - // update existing link - if (!links.Exists(linkDefinition.Index)) - { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} doesn't exist => ensuring creation."); - LinksExtensions.EnsureCreated(links, linkDefinition.Index); - } - var existingLinkRecord = links.GetLink(linkDefinition.Index); - var existingDoublet = new DoubletLink(existingLinkRecord); - - if (existingDoublet.Source != linkDefinition.Source || existingDoublet.Target != linkDefinition.Target) - { - TraceIfEnabled(options, - $"[CreateOrUpdateLink] Updating link #{linkDefinition.Index}: {existingDoublet.Source}->{linkDefinition.Source}, {existingDoublet.Target}->{linkDefinition.Target}."); - LinksExtensions.EnsureCreated(links, linkDefinition.Index); - links.Update( - new DoubletLink(linkDefinition.Index, anyConstant, anyConstant), - linkDefinition, - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } - else - { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link #{linkDefinition.Index} is already S={linkDefinition.Source}, T={linkDefinition.Target} => no change."); - options.ChangesHandler?.Invoke(existingDoublet, existingDoublet); - } - } - else - { - // create new link - var existingLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); - if (existingLinkIndex == default) - { - uint newLinkIndex = 0; - TraceIfEnabled(options, - $"[CreateOrUpdateLink] Creating new link => (S={linkDefinition.Source},T={linkDefinition.Target})."); - links.CreateAndUpdate(linkDefinition.Source, linkDefinition.Target, (beforeState, afterState) => - { - var afterLinkRecord = new DoubletLink(afterState); - if (newLinkIndex == 0 && afterLinkRecord.Index != 0 && afterLinkRecord.Index != anyConstant) + if (pattern.Values == null || pattern.Values.Count == 0) { - newLinkIndex = afterLinkRecord.Index; - TraceIfEnabled(options, $"[CreateOrUpdateLink] => assigned new ID={newLinkIndex}"); + return ResolveLeaf(pattern, links, options); } - return options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue; - }); - - if (newLinkIndex == 0 || newLinkIndex == anyConstant) - { - newLinkIndex = links.SearchOrDefault(linkDefinition.Source, linkDefinition.Target); - } - } - else - { - TraceIfEnabled(options, $"[CreateOrUpdateLink] Link already found => ID={existingLinkIndex}, no changes."); - var existingLink = new DoubletLink(existingLinkIndex, linkDefinition.Source, linkDefinition.Target); - options.ChangesHandler?.Invoke(existingLink, existingLink); - } - } - } - private static void RemoveLinks( - INamedTypesLinks links, - DoubletLink restriction, - Options options) - { - var linksToRemove = links.All(restriction) - .Where(l => l != null) - .Select(l => new DoubletLink(l)) - .ToList(); + // If 2 Values => interpret as a composite link + if (pattern.Values.Count == 2) + { + var sourceId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[0], options); + var targetId = EnsureNestedLinkCreatedRecursively(links, pattern.Values[1], options); - TraceIfEnabled(options, - $"[RemoveLinks] Found {linksToRemove.Count} link(s) matching (ID={restriction.Index}, S={restriction.Source}, T={restriction.Target})."); + // Generic composite creation for numeric or non-matching patterns + return CreateCompositeLink(pattern.Id, sourceId, targetId, links, options); + } - foreach (var link in linksToRemove) - { - if (links.Exists(link.Index)) - { - // Remove the name before deleting - links.RemoveName(link.Index); - TraceIfEnabled(options, $"[RemoveLinks] Deleting link => ID={link.Index}, S={link.Source}, T={link.Target}"); - links.Delete(link, (before, after) => - options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue); + // If more than 2 => do nothing special => ANY + TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] More than 2 sub-values => returning ANY."); + return anyConstant; } - } - } - private static DoubletLink ConvertToDoubletLink(INamedTypesLinks links, LinoLink linoLink, uint defaultValue) - { - uint index = defaultValue; - uint source = defaultValue; - uint target = defaultValue; - TryParseLinkId(linoLink.Id, links, ref index); - if (linoLink.Values?.Count == 2) - { - var sourceLink = linoLink.Values[0]; - TryParseLinkId(sourceLink.Id, links, ref source); - var targetLink = linoLink.Values[1]; - TryParseLinkId(targetLink.Id, links, ref target); - } - return new DoubletLink(index, source, target); - } - - private static bool TryParseLinkId(string? id, INamedTypesLinks links, ref uint parsedValue) - { - if (string.IsNullOrEmpty(id)) return false; - if (id == "*") - { - parsedValue = links.Constants.Any; - return true; - } - else if (id.EndsWith(":")) - { - var trimmed = id.TrimEnd(':'); - if (uint.TryParse(trimmed, out uint linkId)) - { - parsedValue = linkId; - return true; - } - // Try to resolve as string alias - var aliasId = links.GetByName(trimmed); - if (aliasId != links.Constants.Null) + private static void RestoreUnexpectedLinkDeletions( + INamedTypesLinks links, + List unexpectedDeletions, + Dictionary finalIntendedStates, + Options options) { - parsedValue = aliasId; - return true; - } - } - else if (uint.TryParse(id, out uint linkVal)) - { - parsedValue = linkVal; - return true; - } - else - { - // Try to resolve as string alias - var aliasId = links.GetByName(id); - if (aliasId != links.Constants.Null) - { - parsedValue = aliasId; - return true; - } - } - return false; - } - - public class Pattern - { - public string Index; - public Pattern? Source; - public Pattern? Target; - - public Pattern(string index, Pattern? source = null, Pattern? target = null) - { - Index = index ?? ""; - Source = source; - Target = target; - } - - public bool IsLeaf => Source == null && Target == null; - } - - private static Pattern CreatePatternFromLino(LinoLink linkNode) - { - if (linkNode.Values == null || linkNode.Values.Count == 0) - { - return new Pattern(linkNode.Id ?? ""); - } - - if (linkNode.Values.Count == 2) - { - var sourcePattern = CreatePatternFromLino(linkNode.Values[0]); - var targetPattern = CreatePatternFromLino(linkNode.Values[1]); - return new Pattern(linkNode.Id ?? "", sourcePattern, targetPattern); - } - - // If more than 2 => treat similarly to leaf with ID - return new Pattern(linkNode.Id ?? ""); - } - - private static uint EnsureLinkCreated(INamedTypesLinks links, DoubletLink link, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - if (link.Index == nullConstant) - { - // If no index => search or create - var existingIndex = links.SearchOrDefault(link.Source, link.Target); - if (existingIndex == default) - { - uint createdIndex = 0; - TraceIfEnabled(options, $"[EnsureLinkCreated] Creating link for (S={link.Source}, T={link.Target})."); - links.CreateAndUpdate(link.Source, link.Target, (before, after) => - { - var afterLink = new DoubletLink(after); - if (createdIndex == 0 && afterLink.Index != 0 && afterLink.Index != anyConstant) + if (unexpectedDeletions.Count > 0) { - createdIndex = afterLink.Index; - TraceIfEnabled(options, $"[EnsureLinkCreated] => assigned new ID={createdIndex}"); + TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] We have {unexpectedDeletions.Count} unexpected deletion(s)."); + foreach (var deletedLink in unexpectedDeletions) + { + if (finalIntendedStates.TryGetValue(deletedLink.Index, out var intendedLink)) + { + if (intendedLink.Index == 0) + { + TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Link #{deletedLink.Index} was intended-deletion => skip restore."); + continue; + } + if (!links.Exists(intendedLink.Index)) + { + TraceIfEnabled(options, $"[RestoreUnexpectedLinkDeletions] Recreating link #{deletedLink.Index} => was unexpected deletion."); + CreateOrUpdateLink(links, intendedLink, options); + } + } + } + } + else + { + TraceIfEnabled(options, "[RestoreUnexpectedLinkDeletions] No unexpected deletions found."); } - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - - if (createdIndex == 0 || createdIndex == anyConstant) - { - createdIndex = links.SearchOrDefault(link.Source, link.Target); - } - return createdIndex; - } - else - { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link already found => ID={existingIndex} => no-op."); - var existing = new DoubletLink(existingIndex, link.Source, link.Target); - options.ChangesHandler?.Invoke(existing, existing); - return existingIndex; - } - } - else - { - // We have an index => ensure created or updated - if (!links.Exists(link.Index)) - { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} doesn't exist => ensuring creation."); - LinksExtensions.EnsureCreated(links, link.Index); - } - var stored = links.GetLink(link.Index); - var storedD = new DoubletLink(stored); - if (storedD.Source != link.Source || storedD.Target != link.Target) - { - TraceIfEnabled(options, - $"[EnsureLinkCreated] Updating link #{link.Index} => {storedD.Source}->{link.Source}, {storedD.Target}->{link.Target}."); - uint finalIndex = link.Index; - links.Update(new DoubletLink(link.Index, anyConstant, anyConstant), link, (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); - return finalIndex; - } - else - { - TraceIfEnabled(options, $"[EnsureLinkCreated] Link #{link.Index} is already correct => no-op."); - options.ChangesHandler?.Invoke(storedD, storedD); - return link.Index; - } - } - } - - // Helper for link naming logic - private static bool IsNumericOrStar(string? id) - { - if (string.IsNullOrEmpty(id)) return false; - if (id == "*") return true; - uint dummy; - return uint.TryParse(id, out dummy); - } - - private static void TraceIfEnabled(Options options, string message) - { - if (options.Trace) - { - Console.WriteLine(message); - } - } - - // Consolidates getting or creating a named link (leaf) without setting its relationships - private static uint EnsureNamedLeafLink(INamedTypesLinks links, string name, Options options) - { - var existing = links.GetByName(name); - if (existing != links.Constants.Null) return existing; - var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created named leaf '{name}' => ID={newId}"); - links.SetName(newId, name); - return newId; - } - - // Applies a single structural update to an existing link: sets its source and target - private static void ApplyCompositeUpdate(INamedTypesLinks links, uint id, uint source, uint target, Options options) - { - var restriction = new DoubletLink(id, links.Constants.Null, links.Constants.Null); - var substitution = new DoubletLink(id, source, target); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link ID={id} => Source={source}, Target={target}"); - links.Update(restriction, substitution, (before, after) => - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={before}, after={after}"); - return links.Constants.Continue; - }); - } - - /// - /// Detects a two-child composite pattern where at least one child matches the composite identifier. - /// - private static bool TryGetTwoChildCompositePattern( - LinoLink pattern, - out string compositeIdentifier, - out LinoLink leftPattern, - out LinoLink rightPattern) - { - compositeIdentifier = pattern.Id ?? string.Empty; - leftPattern = default!; - rightPattern = default!; - if (!string.IsNullOrEmpty(compositeIdentifier) - && pattern.Values != null - && pattern.Values.Count == 2) - { - leftPattern = pattern.Values[0]; - rightPattern = pattern.Values[1]; - // Only detect composites when one or both children share the identifier - if (leftPattern.Id == compositeIdentifier || rightPattern.Id == compositeIdentifier) - { - return true; - } - } - return false; - } - - private enum CompositeCase { Self, LeftMix, RightMix } - - private static CompositeCase ClassifyCompositeCase(string name, LinoLink left, LinoLink right) - { - if (left.Id == name && right.Id == name) return CompositeCase.Self; - if (left.Id == name && right.Id != name) return CompositeCase.LeftMix; - if (left.Id != name && right.Id == name) return CompositeCase.RightMix; - throw new InvalidOperationException($"Invalid composite pattern for name '{name}'"); - } - - private static uint HandleStringComposite(string name, LinoLink left, LinoLink right, INamedTypesLinks links, Options options) - { - var id = EnsureNamedLeafLink(links, name, options); - var caseType = ClassifyCompositeCase(name, left, right); - switch (caseType) - { - case CompositeCase.Self: - ApplyCompositeUpdate(links, id, id, id, options); - return id; - case CompositeCase.LeftMix: - { - var otherId = EnsureNestedLinkCreatedRecursively(links, right, options); - ApplyCompositeUpdate(links, id, id, otherId, options); - return id; - } - case CompositeCase.RightMix: - { - var otherId = EnsureNestedLinkCreatedRecursively(links, left, options); - ApplyCompositeUpdate(links, id, otherId, id, options); - return id; - } - default: - throw new InvalidOperationException($"Unhandled composite case {caseType}"); - } - } - - /// - /// Resolves a single leaf pattern into its numeric or named link ID. - /// - private static uint ResolveLeaf(LinoLink pattern, INamedTypesLinks links, Options options) - { - var nullConstant = links.Constants.Null; - var anyConstant = links.Constants.Any; - - if (string.IsNullOrEmpty(pattern.Id)) - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with empty ID => returning ANY."); - return anyConstant; - } - if (pattern.Id == "*") - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Leaf with '*' => returning ANY."); - return anyConstant; - } - if (pattern.Id.StartsWith("$")) - { - TraceIfEnabled(options, "[EnsureNestedLinkCreatedRecursively] Variable leaf => returning ANY."); - return anyConstant; - } - if (uint.TryParse(pattern.Id, out uint parsedNumber)) - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Leaf parse => returning {parsedNumber}."); - return parsedNumber; - } - var existingId = links.GetByName(pattern.Id); - if (existingId != links.Constants.Null) - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Found existing named leaf '{pattern.Id}' => ID={existingId}"); - return existingId; - } - var newLeafId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] SetName({newLeafId}, '{pattern.Id}')"); - links.SetName(newLeafId, pattern.Id); - var restriction = new DoubletLink(newLeafId, links.Constants.Null, links.Constants.Null); - var substitution = new DoubletLink(newLeafId, newLeafId, newLeafId); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Updating link {newLeafId} to be self-referential"); - links.Update(restriction, substitution, (beforeState, afterState) => - { - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Update handler: before={beforeState}, after={afterState}"); - return links.Constants.Continue; - }); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created new self-referential named leaf '{pattern.Id}' => ID={newLeafId}"); - return newLeafId; - } - - /// - /// Ensures a composite link exists with the given index or named identifier and child IDs. - /// - private static uint CreateCompositeLink( - string? literalIdentifier, - uint sourceLinkId, - uint targetLinkId, - INamedTypesLinks links, - Options options) - { - // Determine the numeric index for the composite: default 0, wildcard, or parsed from identifier - uint compositeIndex = 0; - var wildcardIndex = links.Constants.Any; - if (!string.IsNullOrEmpty(literalIdentifier)) - { - if (literalIdentifier == "*") - { - compositeIndex = wildcardIndex; - } - else - { - var identifierClean = literalIdentifier.Replace(":", string.Empty); - if (uint.TryParse(identifierClean, out var parsedIndex)) - { - compositeIndex = parsedIndex; - } - } - } - // Build the composite link structure and ensure it exists - var compositeLinkDefinition = new DoubletLink(compositeIndex, sourceLinkId, targetLinkId); - var compositeLinkId = EnsureLinkCreated(links, compositeLinkDefinition, options); - TraceIfEnabled(options, $"[EnsureNestedLinkCreatedRecursively] Created or ensured composite link => Index={compositeIndex}, Source={sourceLinkId}, Target={targetLinkId} => Actual ID={compositeLinkId}"); - // Assign the name for non-numeric identifiers - if (!string.IsNullOrEmpty(literalIdentifier) && !IsNumericOrStar(literalIdentifier) && !literalIdentifier.StartsWith("$")) - { - links.SetName(compositeLinkId, literalIdentifier); - } - return compositeLinkId; - } - - private static void ValidateLinksExistOrWillBeCreated( - INamedTypesLinks links, - IList restrictionPatterns, - IList substitutionPatterns, - Options options) - { - TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Starting validation"); - - var plan = BuildLinkReferencePlan(links, substitutionPatterns); - - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Numeric links to be created: {string.Join(", ", plan.NumericIdsToBeCreated.OrderBy(id => id))}"); - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Named links to be created: {string.Join(", ", plan.NamesToBeCreated.OrderBy(name => name, StringComparer.Ordinal))}"); - - CollectMissingReferences(restrictionPatterns, links, plan, false, "restriction", options); - CollectMissingReferences(substitutionPatterns, links, plan, true, "substitution", options); - - if (plan.MissingReferences.Count > 0) - { - if (!options.AutoCreateMissingReferences) - { - var missing = plan.MissingReferences[0]; - throw new InvalidOperationException( - $"Invalid reference to non-existent link '{missing.Identifier}' in {missing.PatternType} pattern. " + - $"Link '{missing.Identifier}' does not exist and will not be created by this operation. " + - "Use --auto-create-missing-references to create missing references as point links." - ); - } - - AutoCreateMissingReferences(links, plan, options); - } - - TraceIfEnabled(options, "[ValidateLinksExistOrWillBeCreated] Validation completed"); - } - - private sealed class LinkReferencePlan - { - public HashSet NumericIdsToBeCreated { get; } = new(); - public HashSet NamesToBeCreated { get; } = new(StringComparer.Ordinal); - public HashSet<(uint Source, uint Target)> CompositePairsToBeCreated { get; } = new(); - public List MissingReferences { get; } = new(); - private readonly HashSet _missingReferenceKeys = new(StringComparer.Ordinal); - - public void AddMissingReference(MissingLinkReference reference) - { - if (_missingReferenceKeys.Add(reference.Key)) - { - MissingReferences.Add(reference); - } - } - } - - private sealed class MissingLinkReference - { - public required string Identifier { get; init; } - public required string PatternType { get; init; } - public required uint? NumericId { get; init; } - public string Key => NumericId.HasValue ? $"id:{NumericId.Value}" : $"name:{Identifier}"; - } - - private static LinkReferencePlan BuildLinkReferencePlan(INamedTypesLinks links, IList substitutionPatterns) - { - var plan = new LinkReferencePlan(); - var reservedNumericIds = new HashSet(); - - foreach (var pattern in substitutionPatterns) - { - CollectExplicitDefinitions(pattern, plan, reservedNumericIds); - } - - foreach (var pattern in substitutionPatterns) - { - CollectImplicitDefinitions(pattern, links, plan, reservedNumericIds); - } - - foreach (var pattern in substitutionPatterns) - { - CollectCompositePairs(pattern, plan); - } - - return plan; - } - - private static void CollectExplicitDefinitions(LinoLink pattern, LinkReferencePlan plan, HashSet reservedNumericIds) - { - if (IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var identifier)) - { - if (uint.TryParse(identifier, out var linkId)) - { - plan.NumericIdsToBeCreated.Add(linkId); - reservedNumericIds.Add(linkId); - } - else - { - plan.NamesToBeCreated.Add(identifier); - } - } - - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectExplicitDefinitions(subPattern, plan, reservedNumericIds); - } - } - } - - private static void CollectCompositePairs(LinoLink pattern, LinkReferencePlan plan) - { - if (IsComposite(pattern) && - TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier) && - pattern.Values != null && - TryGetConcreteNumericIdentifier(pattern.Values[0].Id, out var source) && - TryGetConcreteNumericIdentifier(pattern.Values[1].Id, out var target)) - { - plan.CompositePairsToBeCreated.Add((source, target)); - } - - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectCompositePairs(subPattern, plan); } - } - } - private static void CollectImplicitDefinitions( - LinoLink pattern, - INamedTypesLinks links, - LinkReferencePlan plan, - HashSet reservedNumericIds) - { - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) + private static List<(DoubletLink before, DoubletLink after)> DetermineOperationsFromPatterns( + List restrictions, + List substitutions, + INamedTypesLinks links) { - CollectImplicitDefinitions(subPattern, links, plan, reservedNumericIds); - } - } - - if (IsComposite(pattern) && !TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier)) - { - var nextId = GetNextAvailableLinkId(links, reservedNumericIds); - reservedNumericIds.Add(nextId); - plan.NumericIdsToBeCreated.Add(nextId); - } - } - - private static uint GetNextAvailableLinkId(INamedTypesLinks links, HashSet reservedNumericIds) - { - uint nextId = 1; - while (links.Exists(nextId) || reservedNumericIds.Contains(nextId)) - { - nextId++; - } - return nextId; - } - - private static void CollectMissingReferences( - IList patterns, - INamedTypesLinks links, - LinkReferencePlan plan, - bool isSubstitution, - string patternType, - Options options) - { - foreach (var pattern in patterns) - { - CollectMissingReferences(pattern, links, plan, isSubstitution, patternType, options); - } - } + var anyOrZero = new HashSet { 0, links.Constants.Any }; - private static void CollectMissingReferences( - LinoLink pattern, - INamedTypesLinks links, - LinkReferencePlan plan, - bool isSubstitution, - string patternType, - Options options) - { - var patternIdIsDefinition = isSubstitution && IsComposite(pattern) && TryGetConcreteIdentifier(pattern.Id, out var _ignoredIdentifier); + var normalRestrictions = restrictions.Where(r => !anyOrZero.Contains(r.Index)).ToList(); + var wildcardRestrictions = restrictions.Where(r => anyOrZero.Contains(r.Index)).ToList(); - if (!patternIdIsDefinition && TryGetConcreteIdentifier(pattern.Id, out var identifier)) - { - ValidateReferenceIdentifier(identifier, links, plan, patternType, options); - } + var normalSubstitutions = substitutions.Where(s => !anyOrZero.Contains(s.Index)).ToList(); + var wildcardSubstitutions = substitutions.Where(s => anyOrZero.Contains(s.Index)).ToList(); - if (pattern.Values != null) - { - foreach (var subPattern in pattern.Values) - { - CollectMissingReferences(subPattern, links, plan, isSubstitution, patternType, options); - } - } - } + var restrictionByIndex = normalRestrictions.ToDictionary(r => r.Index, r => r); + var substitutionByIndex = normalSubstitutions.ToDictionary(s => s.Index, s => s); - private static void ValidateReferenceIdentifier( - string identifier, - INamedTypesLinks links, - LinkReferencePlan plan, - string patternType, - Options options) - { - if (uint.TryParse(identifier, out var linkId)) - { - if (!links.Exists(linkId) && !plan.NumericIdsToBeCreated.Contains(linkId)) - { - plan.AddMissingReference(new MissingLinkReference - { - Identifier = identifier, - PatternType = patternType, - NumericId = linkId - }); - return; - } - TraceIfEnabled(options, $"[ValidateReferencesInPattern] Link {linkId} reference validated in {patternType} pattern"); - return; - } + var operations = new List<(DoubletLink before, DoubletLink after)>(); + var allIndices = restrictionByIndex.Keys.Union(substitutionByIndex.Keys).ToList(); - if (links.GetByName(identifier) == links.Constants.Null && !plan.NamesToBeCreated.Contains(identifier)) - { - plan.AddMissingReference(new MissingLinkReference - { - Identifier = identifier, - PatternType = patternType, - NumericId = null - }); - return; - } + // Step 1) For each distinct index in normal restrictions & substitutions + foreach (var linkIndex in allIndices) + { + bool hasRestriction = restrictionByIndex.TryGetValue(linkIndex, out var restrictionLink); + bool hasSubstitution = substitutionByIndex.TryGetValue(linkIndex, out var substitutionLink); + + if (hasRestriction && hasSubstitution) + { + if (restrictionLink.Source != substitutionLink.Source || restrictionLink.Target != substitutionLink.Target) + { + operations.Add((restrictionLink, substitutionLink)); + } + else + { + operations.Add((restrictionLink, restrictionLink)); + } + } + else if (hasRestriction && !hasSubstitution) + { + // Deletion + operations.Add((restrictionLink, default(DoubletLink))); + } + else if (!hasRestriction && hasSubstitution) + { + // Creation + operations.Add((default(DoubletLink), substitutionLink)); + } + } - TraceIfEnabled(options, $"[ValidateReferencesInPattern] Named link '{identifier}' reference validated in {patternType} pattern"); - } + // Step 2) Wildcard restrictions => each is a separate "delete" + foreach (var restrictionLink in wildcardRestrictions) + { + operations.Add((restrictionLink, default(DoubletLink))); + } - private static void AutoCreateMissingReferences( - INamedTypesLinks links, - LinkReferencePlan plan, - Options options) - { - foreach (var missing in plan.MissingReferences.Where(reference => reference.NumericId.HasValue).OrderBy(reference => reference.NumericId!.Value)) - { - var linkId = missing.NumericId!.Value; - if (links.Exists(linkId)) - { - continue; - } + // Step 3) Wildcard substitutions => each is a separate "create" + foreach (var substitutionLink in wildcardSubstitutions) + { + operations.Add((default(DoubletLink), substitutionLink)); + } - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing numeric reference {linkId}."); - LinksExtensions.EnsureCreated(links, linkId); - if (plan.CompositePairsToBeCreated.Contains((linkId, linkId))) - { - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Link {linkId} exists as a placeholder because ({linkId}, {linkId}) is defined by the substitution."); - continue; + return operations; } - links.Update( - new DoubletLink(linkId, links.Constants.Null, links.Constants.Null), - new DoubletLink(linkId, linkId, linkId), - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } - foreach (var missing in plan.MissingReferences.Where(reference => !reference.NumericId.HasValue).OrderBy(reference => reference.Identifier, StringComparer.Ordinal)) - { - if (links.GetByName(missing.Identifier) != links.Constants.Null) + private static void ApplyAllPlannedOperations( + INamedTypesLinks links, + List<(DoubletLink before, DoubletLink after)> operations, + Options options) { - continue; + foreach (var (before, after) in operations) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Operation: before=({before.Index}:{before.Source}->{before.Target}), after=({after.Index}:{after.Source}->{after.Target})"); + if (before.Index != 0) + { + var beforeName = links.GetName(before.Index); + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for before.Index {before.Index} = '{beforeName}'"); + } + if (after.Index != 0) + { + var afterNamePre = links.GetName(after.Index); + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePre}' (pre-op)"); + } + if (before.Index != 0 && after.Index == 0) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Deleting link => ID={before.Index}, S={before.Source}, T={before.Target}"); + RemoveLinks(links, before, options); + } + else if (before.Index == 0 && (after.Index != 0 || after.Source != 0 || after.Target != 0)) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Creating link => ID={after.Index}, S={after.Source}, T={after.Target}"); + CreateOrUpdateLink(links, after, options); + } + else if (before.Index != 0 && after.Index != 0) + { + if (before.Source != after.Source || before.Target != after.Target) + { + if (before.Index == after.Index) + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Updating link in-place => ID={before.Index}"); + if (!links.Exists(after.Index)) + { + LinksExtensions.EnsureCreated(links, after.Index); + } + links.Update(before, after, (beforeState, afterState) => + options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue); + } + else + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Removing old link => ID={before.Index} then creating new => ID={after.Index}."); + RemoveLinks(links, before, options); + CreateOrUpdateLink(links, after, options); + } + } + else + { + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] No changes for link => ID={before.Index} => no-op."); + options.ChangesHandler?.Invoke(before, before); + } + } + if (after.Index != 0) + { + var afterNamePost = links.GetName(after.Index); + TraceIfEnabled(options, $"[ApplyAllPlannedOperations] Name for after.Index {after.Index} = '{afterNamePost}' (post-op)"); + } + } } - - TraceIfEnabled(options, $"[ValidateLinksExistOrWillBeCreated] Auto-creating missing named reference '{missing.Identifier}' as point link."); - EnsureNamedPointLink(links, missing.Identifier, options); - } - } - - private static void EnsureNamedPointLink(INamedTypesLinks links, string name, Options options) - { - if (links.GetByName(name) != links.Constants.Null) - { - return; - } - - var newId = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - links.SetName(newId, name); - links.Update( - new DoubletLink(newId, links.Constants.Null, links.Constants.Null), - new DoubletLink(newId, newId, newId), - (beforeState, afterState) => - options.ChangesHandler?.Invoke(beforeState, afterState) ?? links.Constants.Continue - ); - } - - private static bool IsComposite(LinoLink pattern) => pattern.Values?.Count == 2; - - private static bool TryGetConcreteIdentifier(string? id, out string identifier) - { - identifier = string.Empty; - if (string.IsNullOrWhiteSpace(id)) - { - return false; - } - - identifier = id.TrimEnd(':'); - if (identifier.Length == 0 || identifier == "*" || identifier.StartsWith("$")) - { - return false; - } - - return true; - } - - private static bool TryGetConcreteNumericIdentifier(string? id, out uint linkId) - { - linkId = 0; - return TryGetConcreteIdentifier(id, out var identifier) && uint.TryParse(identifier, out linkId); } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs index 4bab672..d4c464a 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/BasicQueryProcessor.cs @@ -6,114 +6,114 @@ namespace Foundation.Data.Doublets.Cli { - // Query Processor class with single static method to process queries - public static class BasicQueryProcessor - { - // ProcessQuery method to process queries - public static void ProcessQuery(ILinks links, string query) + // Query Processor class with single static method to process queries + public static class BasicQueryProcessor { - var parser = new Parser(); - var parsedLinks = parser.Parse(query); + // ProcessQuery method to process queries + public static void ProcessQuery(ILinks links, string query) + { + var parser = new Parser(); + var parsedLinks = parser.Parse(query); - if (parsedLinks.Count == 0) - { - return; - } + if (parsedLinks.Count == 0) + { + return; + } - var outerLink = parsedLinks[0]; - var outerLinkValues = outerLink.Values; + var outerLink = parsedLinks[0]; + var outerLinkValues = outerLink.Values; - if (outerLinkValues?.Count < 2) - { - return; - } + if (outerLinkValues?.Count < 2) + { + return; + } - var @null = links.Constants.Null; - var any = links.Constants.Any; + var @null = links.Constants.Null; + var any = links.Constants.Any; - if (outerLinkValues == null) - { - return; - } + if (outerLinkValues == null) + { + return; + } - var restrictionLink = outerLinkValues[0]; - var substitutionLink = outerLinkValues[1]; + var restrictionLink = outerLinkValues[0]; + var substitutionLink = outerLinkValues[1]; - if ((restrictionLink.Values?.Count == 0) && - (substitutionLink.Values?.Count == 0)) - { - return; - } - else if ((restrictionLink.Values?.Count > 0) && - (substitutionLink.Values?.Count > 0)) - { - // Update operation for multiple links - if (restrictionLink.Values.Count != substitutionLink.Values.Count) - { - Console.WriteLine("The number of restriction links and substitution links must be the same."); - return; - } + if ((restrictionLink.Values?.Count == 0) && + (substitutionLink.Values?.Count == 0)) + { + return; + } + else if ((restrictionLink.Values?.Count > 0) && + (substitutionLink.Values?.Count > 0)) + { + // Update operation for multiple links + if (restrictionLink.Values.Count != substitutionLink.Values.Count) + { + Console.WriteLine("The number of restriction links and substitution links must be the same."); + return; + } - for (int i = 0; i < restrictionLink.Values.Count; i++) - { - var restrictionLinoLink = restrictionLink.Values[i]; - var substitutionLinoLink = substitutionLink.Values[i]; + for (int i = 0; i < restrictionLink.Values.Count; i++) + { + var restrictionLinoLink = restrictionLink.Values[i]; + var substitutionLinoLink = substitutionLink.Values[i]; - var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); - var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); + var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); + var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); - links.Update(restrictionDoublet, substitutionDoublet, (before, after) => - { - return links.Constants.Continue; - }); - } + links.Update(restrictionDoublet, substitutionDoublet, (before, after) => + { + return links.Constants.Continue; + }); + } - return; - } - else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation - { - foreach (var linkToDelete in restrictionLink.Values ?? []) - { - var queryLink = ToDoubletLink(links, linkToDelete, any); - links.DeleteByQuery(queryLink); + return; + } + else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation + { + foreach (var linkToDelete in restrictionLink.Values ?? []) + { + var queryLink = ToDoubletLink(links, linkToDelete, any); + links.DeleteByQuery(queryLink); + } + return; + } + else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation + { + foreach (var linkToCreate in substitutionLink.Values ?? []) + { + var doubletLink = ToDoubletLink(links, linkToCreate, @null); + links.GetOrCreate(doubletLink.Source, doubletLink.Target); + } + return; + } } - return; - } - else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation - { - foreach (var linkToCreate in substitutionLink.Values ?? []) - { - var doubletLink = ToDoubletLink(links, linkToCreate, @null); - links.GetOrCreate(doubletLink.Source, doubletLink.Target); - } - return; - } - } - static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) - { - uint index = defaultValue; - uint source = defaultValue; - uint target = defaultValue; - if (!string.IsNullOrEmpty(linoLink.Id) && uint.TryParse(linoLink.Id, out uint linkId)) - { - index = linkId; - } - if (linoLink.Values?.Count == 2) - { - var sourceLink = linoLink.Values[0]; - var targetLink = linoLink.Values[1]; - if (!string.IsNullOrEmpty(sourceLink.Id) && uint.TryParse(sourceLink.Id, out uint sourceId)) - { - source = sourceId; - } - if (!string.IsNullOrEmpty(targetLink.Id) && uint.TryParse(targetLink.Id, out uint targetId)) + static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) { - target = targetId; + uint index = defaultValue; + uint source = defaultValue; + uint target = defaultValue; + if (!string.IsNullOrEmpty(linoLink.Id) && uint.TryParse(linoLink.Id, out uint linkId)) + { + index = linkId; + } + if (linoLink.Values?.Count == 2) + { + var sourceLink = linoLink.Values[0]; + var targetLink = linoLink.Values[1]; + if (!string.IsNullOrEmpty(sourceLink.Id) && uint.TryParse(sourceLink.Id, out uint sourceId)) + { + source = sourceId; + } + if (!string.IsNullOrEmpty(targetLink.Id) && uint.TryParse(targetLink.Id, out uint targetId)) + { + target = targetId; + } + } + return new DoubletLink(index, source, target); } - } - return new DoubletLink(index, source, target); } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs b/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs index 9786183..9a26127 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/ChangesSimplifier.cs @@ -2,201 +2,201 @@ namespace Foundation.Data.Doublets.Cli { - public static class ChangesSimplifier - { - /// - /// Simplifies a list of changes by identifying chains of transformations. - /// If multiple final states are reachable from the same initial state, returns multiple simplified changes. - /// If a scenario arises where no initial or final states can be identified (no-ops), returns the original transitions as-is. - /// - /// List of tuples representing changes (before, after). - /// - /// Simplified list of changes from initial states to final states, - /// or original transitions if no change is detected. - /// - public static IEnumerable<(Link Before, Link After)> SimplifyChanges( - IEnumerable<(Link Before, Link After)> changes - ) + public static class ChangesSimplifier { - if (changes == null) throw new ArgumentNullException(nameof(changes)); - - var changesList = changes.ToList(); - if (changesList.Count == 0) - { - // No changes at all, return empty - return Enumerable.Empty<(Link, Link)>(); - } - - // **FIX for Issue #26**: Remove duplicate before states by keeping the last occurrence - // This handles cases where the same link is reported with multiple different transformations - changesList = RemoveDuplicateBeforeStates(changesList); - - // First, handle unchanged states directly - var unchangedStates = new List<(Link Before, Link After)>(); - var changedStates = new List<(Link Before, Link After)>(); - - foreach (var change in changesList) - { - if (LinkEqualityComparer.Instance.Equals(change.Before, change.After)) + /// + /// Simplifies a list of changes by identifying chains of transformations. + /// If multiple final states are reachable from the same initial state, returns multiple simplified changes. + /// If a scenario arises where no initial or final states can be identified (no-ops), returns the original transitions as-is. + /// + /// List of tuples representing changes (before, after). + /// + /// Simplified list of changes from initial states to final states, + /// or original transitions if no change is detected. + /// + public static IEnumerable<(Link Before, Link After)> SimplifyChanges( + IEnumerable<(Link Before, Link After)> changes + ) { - unchangedStates.Add(change); - } - else - { - changedStates.Add(change); - } - } - - // Gather all 'Before' links and all 'After' links from changed states - var beforeLinks = new HashSet>(changedStates.Select(c => c.Before), LinkEqualityComparer.Instance); - var afterLinks = new HashSet>(changedStates.Select(c => c.After), LinkEqualityComparer.Instance); + if (changes == null) throw new ArgumentNullException(nameof(changes)); - // Identify initial states: appear as Before but never as After - var initialStates = beforeLinks.Where(b => !afterLinks.Contains(b)).ToList(); + var changesList = changes.ToList(); + if (changesList.Count == 0) + { + // No changes at all, return empty + return Enumerable.Empty<(Link, Link)>(); + } - // Identify final states: appear as After but never as Before - var finalStates = afterLinks.Where(a => !beforeLinks.Contains(a)) - .ToHashSet(LinkEqualityComparer.Instance); + // **FIX for Issue #26**: Remove duplicate before states by keeping the last occurrence + // This handles cases where the same link is reported with multiple different transformations + changesList = RemoveDuplicateBeforeStates(changesList); - // Build adjacency (Before -> possible list of After links) - var adjacency = new Dictionary, List>>(LinkEqualityComparer.Instance); - foreach (var (before, after) in changedStates) - { - if (!adjacency.TryGetValue(before, out var list)) - { - list = new List>(); - adjacency[before] = list; - } - list.Add(after); - } + // First, handle unchanged states directly + var unchangedStates = new List<(Link Before, Link After)>(); + var changedStates = new List<(Link Before, Link After)>(); - // If we have no identified initial states, treat it as a no-op scenario: - // just return original transitions. - if (initialStates.Count == 0) - { - return changesList; - } + foreach (var change in changesList) + { + if (LinkEqualityComparer.Instance.Equals(change.Before, change.After)) + { + unchangedStates.Add(change); + } + else + { + changedStates.Add(change); + } + } - var results = new List<(Link Before, Link After)>(); + // Gather all 'Before' links and all 'After' links from changed states + var beforeLinks = new HashSet>(changedStates.Select(c => c.Before), LinkEqualityComparer.Instance); + var afterLinks = new HashSet>(changedStates.Select(c => c.After), LinkEqualityComparer.Instance); - // Add unchanged states first - results.AddRange(unchangedStates); + // Identify initial states: appear as Before but never as After + var initialStates = beforeLinks.Where(b => !afterLinks.Contains(b)).ToList(); - // Traverse each initial state with DFS - foreach (var initial in initialStates.Distinct(LinkEqualityComparer.Instance)) - { - var stack = new Stack>(); - stack.Push(initial); + // Identify final states: appear as After but never as Before + var finalStates = afterLinks.Where(a => !beforeLinks.Contains(a)) + .ToHashSet(LinkEqualityComparer.Instance); - var visited = new HashSet>(LinkEqualityComparer.Instance); + // Build adjacency (Before -> possible list of After links) + var adjacency = new Dictionary, List>>(LinkEqualityComparer.Instance); + foreach (var (before, after) in changedStates) + { + if (!adjacency.TryGetValue(before, out var list)) + { + list = new List>(); + adjacency[before] = list; + } + list.Add(after); + } - while (stack.Count > 0) - { - var current = stack.Pop(); - // Skip if already visited - if (!visited.Add(current)) - { - continue; - } - - bool hasNext = adjacency.TryGetValue(current, out var nextLinks); - bool isFinalOrDeadEnd = finalStates.Contains(current) || !hasNext || nextLinks!.Count == 0; - - // If final or no further transitions, record (initial -> current) - if (isFinalOrDeadEnd) - { - results.Add((initial, current)); - } - - // Otherwise push neighbors - if (hasNext) - { - foreach (var next in nextLinks!) + // If we have no identified initial states, treat it as a no-op scenario: + // just return original transitions. + if (initialStates.Count == 0) { - stack.Push(next); + return changesList; } - } - } - } - - // ***** IMPORTANT: Sort the final results so that - // items appear in ascending order by their After link. - // This ensures tests that expect a specific order pass reliably. - return results - .OrderBy(r => r.After.Index) - .ThenBy(r => r.After.Source) - .ThenBy(r => r.After.Target); - } - /// - /// Removes problematic duplicate before states that lead to simplification issues. - /// This fixes Issue #26 where multiple transformations from the same before state - /// to conflicting after states (including null states) would cause the simplifier to fail. - /// - /// The key insight: If we have multiple transitions from the same before state, - /// and one of them is to a "null" state (0: 0 0), we should prefer the non-null transition - /// as it represents the actual final transformation. - /// - /// The list of changes that may contain problematic duplicate before states - /// A list with problematic duplicates resolved - private static List<(Link Before, Link After)> RemoveDuplicateBeforeStates( - List<(Link Before, Link After)> changes) - { - // Group changes by their before state - var groupedChanges = changes.GroupBy(c => c.Before, LinkEqualityComparer.Instance); + var results = new List<(Link Before, Link After)>(); - var result = new List<(Link Before, Link After)>(); + // Add unchanged states first + results.AddRange(unchangedStates); - foreach (var group in groupedChanges) - { - var changesForThisBefore = group.ToList(); + // Traverse each initial state with DFS + foreach (var initial in initialStates.Distinct(LinkEqualityComparer.Instance)) + { + var stack = new Stack>(); + stack.Push(initial); + + var visited = new HashSet>(LinkEqualityComparer.Instance); + + while (stack.Count > 0) + { + var current = stack.Pop(); + // Skip if already visited + if (!visited.Add(current)) + { + continue; + } + + bool hasNext = adjacency.TryGetValue(current, out var nextLinks); + bool isFinalOrDeadEnd = finalStates.Contains(current) || !hasNext || nextLinks!.Count == 0; + + // If final or no further transitions, record (initial -> current) + if (isFinalOrDeadEnd) + { + results.Add((initial, current)); + } + + // Otherwise push neighbors + if (hasNext) + { + foreach (var next in nextLinks!) + { + stack.Push(next); + } + } + } + } - if (changesForThisBefore.Count == 1) - { - // No duplicates, keep as is - result.AddRange(changesForThisBefore); + // ***** IMPORTANT: Sort the final results so that + // items appear in ascending order by their After link. + // This ensures tests that expect a specific order pass reliably. + return results + .OrderBy(r => r.After.Index) + .ThenBy(r => r.After.Source) + .ThenBy(r => r.After.Target); } - else + + /// + /// Removes problematic duplicate before states that lead to simplification issues. + /// This fixes Issue #26 where multiple transformations from the same before state + /// to conflicting after states (including null states) would cause the simplifier to fail. + /// + /// The key insight: If we have multiple transitions from the same before state, + /// and one of them is to a "null" state (0: 0 0), we should prefer the non-null transition + /// as it represents the actual final transformation. + /// + /// The list of changes that may contain problematic duplicate before states + /// A list with problematic duplicates resolved + private static List<(Link Before, Link After)> RemoveDuplicateBeforeStates( + List<(Link Before, Link After)> changes) { - // Multiple changes from the same before state - // Check if any of them is to a null state (0: 0 0) - var nullTransition = changesForThisBefore.FirstOrDefault(c => - c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0); - var nonNullTransitions = changesForThisBefore.Where(c => - !(c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0)).ToList(); - - if (nullTransition != default && nonNullTransitions.Count > 0) - { - // Issue #26 scenario: We have both null and non-null transitions - // Prefer the non-null transitions as they represent the actual final states - result.AddRange(nonNullTransitions); - } - else - { - // No null transitions involved, this is a legitimate multiple-branch scenario - // Keep all transitions - result.AddRange(changesForThisBefore); - } - } - } + // Group changes by their before state + var groupedChanges = changes.GroupBy(c => c.Before, LinkEqualityComparer.Instance); - return result; - } + var result = new List<(Link Before, Link After)>(); - /// - /// An equality comparer for Link that checks Index/Source/Target. - /// - private class LinkEqualityComparer : IEqualityComparer> - { - public static readonly LinkEqualityComparer Instance = new LinkEqualityComparer(); + foreach (var group in groupedChanges) + { + var changesForThisBefore = group.ToList(); + + if (changesForThisBefore.Count == 1) + { + // No duplicates, keep as is + result.AddRange(changesForThisBefore); + } + else + { + // Multiple changes from the same before state + // Check if any of them is to a null state (0: 0 0) + var nullTransition = changesForThisBefore.FirstOrDefault(c => + c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0); + var nonNullTransitions = changesForThisBefore.Where(c => + !(c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0)).ToList(); + + if (nullTransition != default && nonNullTransitions.Count > 0) + { + // Issue #26 scenario: We have both null and non-null transitions + // Prefer the non-null transitions as they represent the actual final states + result.AddRange(nonNullTransitions); + } + else + { + // No null transitions involved, this is a legitimate multiple-branch scenario + // Keep all transitions + result.AddRange(changesForThisBefore); + } + } + } - public bool Equals(Link x, Link y) - => x.Index == y.Index && x.Source == y.Source && x.Target == y.Target; + return result; + } - public int GetHashCode(Link obj) - => HashCode.Combine(obj.Index, obj.Source, obj.Target); + /// + /// An equality comparer for Link{uint} that checks Index/Source/Target. + /// + private class LinkEqualityComparer : IEqualityComparer> + { + public static readonly LinkEqualityComparer Instance = new LinkEqualityComparer(); + + public bool Equals(Link x, Link y) + => x.Index == y.Index && x.Source == y.Source && x.Target == y.Target; + + public int GetHashCode(Link obj) + => HashCode.Combine(obj.Index, obj.Source, obj.Target); + } } - } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs b/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs index 1af9d0d..0622e64 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/LinksExtensions.cs @@ -4,64 +4,64 @@ namespace Foundation.Data.Doublets.Cli { - public static class LinksExtensions - { - public static void EnsureCreated(this ILinks links, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber { links.EnsureCreated(links.Create, addresses); } - - public static void EnsureCreated(this ILinks links, Func creator, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber + public static class LinksExtensions { - var nonExistentAddresses = new HashSet(); - foreach (var address in addresses) - { - EnsureSupportedInternalReference(links, address); - if (!links.Exists(address)) + public static void EnsureCreated(this ILinks links, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber { links.EnsureCreated(links.Create, addresses); } + + public static void EnsureCreated(this ILinks links, Func creator, params TLinkAddress[] addresses) where TLinkAddress : IUnsignedNumber { - nonExistentAddresses.Add(address); - } - } + var nonExistentAddresses = new HashSet(); + foreach (var address in addresses) + { + EnsureSupportedInternalReference(links, address); + if (!links.Exists(address)) + { + nonExistentAddresses.Add(address); + } + } - if (nonExistentAddresses.Count > 0) - { - var max = nonExistentAddresses.Max()!; - var createdLinks = new List(); - var seenCreatedLinks = new HashSet(); - TLinkAddress createdLink; + if (nonExistentAddresses.Count > 0) + { + var max = nonExistentAddresses.Max()!; + var createdLinks = new List(); + var seenCreatedLinks = new HashSet(); + TLinkAddress createdLink; - do - { - createdLink = creator(); - EnsureSupportedInternalReference(links, createdLink); + do + { + createdLink = creator(); + EnsureSupportedInternalReference(links, createdLink); - if (!seenCreatedLinks.Add(createdLink)) - { - throw new InvalidOperationException($"Link creation returned address {createdLink} more than once before reaching target {max}."); - } + if (!seenCreatedLinks.Add(createdLink)) + { + throw new InvalidOperationException($"Link creation returned address {createdLink} more than once before reaching target {max}."); + } - if (Comparer.Default.Compare(createdLink, max) > 0) - { - throw new InvalidOperationException($"Link creation produced address {createdLink} beyond requested target {max}."); - } + if (Comparer.Default.Compare(createdLink, max) > 0) + { + throw new InvalidOperationException($"Link creation produced address {createdLink} beyond requested target {max}."); + } - createdLinks.Add(createdLink); + createdLinks.Add(createdLink); + } + while (createdLink != max); + + for (var i = 0; i < createdLinks.Count; i++) + { + if (!nonExistentAddresses.Contains(createdLinks[i]) && links.Exists(createdLinks[i])) + { + links.Delete(createdLinks[i]); + } + } + } } - while (createdLink != max); - for (var i = 0; i < createdLinks.Count; i++) + private static void EnsureSupportedInternalReference(ILinks links, TLinkAddress address) where TLinkAddress : IUnsignedNumber { - if (!nonExistentAddresses.Contains(createdLinks[i]) && links.Exists(createdLinks[i])) - { - links.Delete(createdLinks[i]); - } + if (!links.Constants.IsInternalReference(address)) + { + throw new InvalidOperationException($"Cannot ensure unsupported link address {address}. Only non-zero internal references in the supported range can be created."); + } } - } - } - - private static void EnsureSupportedInternalReference(ILinks links, TLinkAddress address) where TLinkAddress : IUnsignedNumber - { - if (!links.Constants.IsInternalReference(address)) - { - throw new InvalidOperationException($"Cannot ensure unsupported link address {address}. Only non-zero internal references in the supported range can be created."); - } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/LinksFacadeDisposer.cs b/csharp/Foundation.Data.Doublets.Cli.Library/LinksFacadeDisposer.cs new file mode 100644 index 0000000..dd6203b --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Library/LinksFacadeDisposer.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli +{ + /// + /// Releases every links facade reachable from a decorator chain. + /// + /// + /// Links decorators wrap each other and ultimately own memory-mapped file handles. On POSIX a + /// mapped file can still be unlinked, so leaking those handles goes unnoticed; Windows uses + /// mandatory locking and fails the delete with . Disposing the + /// whole chain keeps behaviour identical on every platform. + /// + public static class LinksFacadeDisposer + { + /// + /// Disposes and every inner links facade it references, innermost first. + /// + /// The outermost facade, or . + public static void Dispose(object? facade) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + Dispose(facade, visited); + } + + private static void Dispose(object? facade, HashSet visited) + { + if (facade is null || !visited.Add(facade)) + { + return; + } + + foreach (var inner in EnumerateInnerLinks(facade)) + { + Dispose(inner, visited); + } + + if (facade is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private static IEnumerable EnumerateInnerLinks(object facade) + { + for (var type = facade.GetType(); type is not null; type = type.BaseType) + { + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (IsLinksFacade(field.FieldType)) + { + yield return field.GetValue(facade); + } + } + } + } + + private static bool IsLinksFacade(Type type) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ILinks<>)) + { + return true; + } + foreach (var @interface in type.GetInterfaces()) + { + if (@interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(ILinks<>)) + { + return true; + } + } + return false; + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs index 34b3435..e40c3e0 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/MixedQueryProcessor.cs @@ -8,343 +8,343 @@ namespace Foundation.Data.Doublets.Cli { - public static class MixedQueryProcessor - { - public class Options + public static class MixedQueryProcessor { - public string? Query { get; set; } - public WriteHandler? ChangesHandler { get; set; } + public class Options + { + public string? Query { get; set; } + public WriteHandler? ChangesHandler { get; set; } - public static implicit operator Options(string query) => new Options { Query = query }; - } + public static implicit operator Options(string query) => new Options { Query = query }; + } - public static void ProcessQuery(ILinks links, Options options) - { - ArgumentNullException.ThrowIfNull(links); - ArgumentNullException.ThrowIfNull(options); + public static void ProcessQuery(ILinks links, Options options) + { + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(options); - var query = options.Query; - var @null = links.Constants.Null; - var any = links.Constants.Any; - if (string.IsNullOrEmpty(query)) - { - return; - } + var query = options.Query; + var @null = links.Constants.Null; + var any = links.Constants.Any; + if (string.IsNullOrEmpty(query)) + { + return; + } - var parser = new Parser(); - var parsedLinks = parser.Parse(query); + var parser = new Parser(); + var parsedLinks = parser.Parse(query); - if (parsedLinks.Count == 0) - { - return; - } + if (parsedLinks.Count == 0) + { + return; + } - var outerLink = parsedLinks[0]; - var outerLinkValues = outerLink.Values; - if (outerLinkValues?.Count < 2) - { - return; - } + var outerLink = parsedLinks[0]; + var outerLinkValues = outerLink.Values; + if (outerLinkValues?.Count < 2) + { + return; + } - var restrictionLink = outerLinkValues![0]; - var substitutionLink = outerLinkValues![1]; + var restrictionLink = outerLinkValues![0]; + var substitutionLink = outerLinkValues![1]; - if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) - { - return; - } - else if ((restrictionLink.Values?.Count > 0) && (substitutionLink.Values?.Count > 0)) - { - // Build dictionaries - var restrictionLinksById = (restrictionLink.Values ?? new List()) - .Where(l => !string.IsNullOrEmpty(l.Id)) - .ToDictionary(l => l.Id!); - if (!string.IsNullOrEmpty(restrictionLink.Id)) - { - restrictionLinksById[restrictionLink.Id!] = restrictionLink; - } + if ((restrictionLink.Values?.Count == 0) && (substitutionLink.Values?.Count == 0)) + { + return; + } + else if ((restrictionLink.Values?.Count > 0) && (substitutionLink.Values?.Count > 0)) + { + // Build dictionaries + var restrictionLinksById = (restrictionLink.Values ?? new List()) + .Where(l => !string.IsNullOrEmpty(l.Id)) + .ToDictionary(l => l.Id!); + if (!string.IsNullOrEmpty(restrictionLink.Id)) + { + restrictionLinksById[restrictionLink.Id!] = restrictionLink; + } - var substitutionLinksById = (substitutionLink.Values ?? new List()) - .Where(l => !string.IsNullOrEmpty(l.Id)) - .ToDictionary(l => l.Id!); - if (!string.IsNullOrEmpty(substitutionLink.Id)) - { - substitutionLinksById[substitutionLink.Id!] = substitutionLink; - } + var substitutionLinksById = (substitutionLink.Values ?? new List()) + .Where(l => !string.IsNullOrEmpty(l.Id)) + .ToDictionary(l => l.Id!); + if (!string.IsNullOrEmpty(substitutionLink.Id)) + { + substitutionLinksById[substitutionLink.Id!] = substitutionLink; + } - var allIds = restrictionLinksById.Keys.Union(substitutionLinksById.Keys).ToList(); + var allIds = restrictionLinksById.Keys.Union(substitutionLinksById.Keys).ToList(); - // Collect variable assignments from restriction links - var variableAssignments = new Dictionary(); - foreach (var kv in restrictionLinksById) - { - var lino = kv.Value; - if (lino.Values?.Count == 2 && lino.Id != null) - { - // This means we have something like (2: $var1 $var2) or (2: 1 $var) - // Get the actual DB link to resolve variables - var dbl = ToDoubletLink(links, lino, any); - if (dbl.Index != any && dbl.Index != @null) + // Collect variable assignments from restriction links + var variableAssignments = new Dictionary(); + foreach (var kv in restrictionLinksById) + { + var lino = kv.Value; + if (lino.Values?.Count == 2 && lino.Id != null) + { + // This means we have something like (2: $var1 $var2) or (2: 1 $var) + // Get the actual DB link to resolve variables + var dbl = ToDoubletLink(links, lino, any); + if (dbl.Index != any && dbl.Index != @null) + { + var actual = new DoubletLink(links.GetLink(dbl.Index)); + // actual.Source and actual.Target contain the real numbers + // lino.Values[0].Id and lino.Values[1].Id may contain variables + AssignVariableFromLink(lino.Values[0].Id, actual.Source, variableAssignments, any, @null); + AssignVariableFromLink(lino.Values[1].Id, actual.Target, variableAssignments, any, @null); + } + } + else if (lino.Values?.Count == 2 && lino.Id?.StartsWith("$") != true) + { + // Similar logic for a link without explicit index but with variables + var dbl = ToDoubletLink(links, lino, any); + // If dbl.Index is unknown, we can't directly read from DB by index, but we can still assign known numeric parts + // If source or target is numeric or '*', no assignment needed unless it's variable + AssignVariableFromLink(lino.Values[0].Id, dbl.Source, variableAssignments, any, @null); + AssignVariableFromLink(lino.Values[1].Id, dbl.Target, variableAssignments, any, @null); + } + } + + // Before comparing variables for no-op, let's apply variable substitution to substitution links + // Replace variables in substitutionLinksById with their assigned values if any + foreach (var kv in substitutionLinksById.ToList()) + { + var lino = kv.Value; + if (lino.Values?.Count == 2) + { + var newSourceId = ReplaceVariable(lino.Values[0].Id, variableAssignments); + var newTargetId = ReplaceVariable(lino.Values[1].Id, variableAssignments); + + if (newSourceId != lino.Values[0].Id || newTargetId != lino.Values[1].Id) + { + if (lino.Id != null) + { + lino = new LinoLink(lino.Id, new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); + } + else + { + lino = new LinoLink(new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); + } + substitutionLinksById[kv.Key] = lino; + } + } + } + + // Basic variable no-op check + var variableIds = allIds.Where(id => id.StartsWith("$")).ToArray(); + foreach (var varId in variableIds) + { + if (restrictionLinksById.TryGetValue(varId, out var varRestrictionLink) + && substitutionLinksById.TryGetValue(varId, out var varSubstitutionLink)) + { + if (AreLinksEquivalent(varRestrictionLink, varSubstitutionLink)) + { + // Remove this variable from difference tracking + allIds = allIds.Except([varId]).ToList(); + } + } + } + + // After handling variables, if allIds is empty, it means no changes. + // If we have variables, let's treat this scenario as a read operation. + if (!allIds.Any() && variableIds.Any()) + { + // Perform read operation for each restriction pattern link + foreach (var kv in restrictionLinksById) + { + var restrictionPattern = ToDoubletLink(links, kv.Value, links.Constants.Any); + ReadAll(links, restrictionPattern, options); + } + return; + } + + // If we still have differences, proceed with sets/unsets/updates + foreach (var id in allIds) + { + bool hasRestriction = restrictionLinksById.TryGetValue(id, out var restrictionLinoLink); + bool hasSubstitution = substitutionLinksById.TryGetValue(id, out var substitutionLinoLink); + + if (hasRestriction && hasSubstitution) + { + // Update operation + var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); + var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); + + links.Update(restrictionDoublet, substitutionDoublet, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } + else if (hasRestriction && !hasSubstitution) + { + var queryLink = ToDoubletLink(links, restrictionLinoLink, any); + Unset(links, queryLink, options); + } + else if (!hasRestriction && hasSubstitution) + { + var doubletLink = ToDoubletLink(links, substitutionLinoLink, @null); + Set(links, doubletLink, options); + } + } + + return; + } + else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation + { + foreach (var linkToDelete in restrictionLink.Values ?? []) + { + var queryLink = ToDoubletLink(links, linkToDelete, any); + Unset(links, queryLink, options); + } + return; + } + else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation { - var actual = new DoubletLink(links.GetLink(dbl.Index)); - // actual.Source and actual.Target contain the real numbers - // lino.Values[0].Id and lino.Values[1].Id may contain variables - AssignVariableFromLink(lino.Values[0].Id, actual.Source, variableAssignments, any, @null); - AssignVariableFromLink(lino.Values[1].Id, actual.Target, variableAssignments, any, @null); + foreach (var linkToCreate in substitutionLink.Values ?? []) + { + var doubletLink = ToDoubletLink(links, linkToCreate, @null); + Set(links, doubletLink, options); + } + return; } - } - else if (lino.Values?.Count == 2 && lino.Id?.StartsWith("$") != true) - { - // Similar logic for a link without explicit index but with variables - var dbl = ToDoubletLink(links, lino, any); - // If dbl.Index is unknown, we can't directly read from DB by index, but we can still assign known numeric parts - // If source or target is numeric or '*', no assignment needed unless it's variable - AssignVariableFromLink(lino.Values[0].Id, dbl.Source, variableAssignments, any, @null); - AssignVariableFromLink(lino.Values[1].Id, dbl.Target, variableAssignments, any, @null); - } } - // Before comparing variables for no-op, let's apply variable substitution to substitution links - // Replace variables in substitutionLinksById with their assigned values if any - foreach (var kv in substitutionLinksById.ToList()) + static string ReplaceVariable(string? id, Dictionary variableAssignments) { - var lino = kv.Value; - if (lino.Values?.Count == 2) - { - var newSourceId = ReplaceVariable(lino.Values[0].Id, variableAssignments); - var newTargetId = ReplaceVariable(lino.Values[1].Id, variableAssignments); - - if (newSourceId != lino.Values[0].Id || newTargetId != lino.Values[1].Id) + if (string.IsNullOrEmpty(id)) return id ?? ""; + if (variableAssignments.TryGetValue(id, out var val)) { - if (lino.Id != null) - { - lino = new LinoLink(lino.Id, new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); - } - else - { - lino = new LinoLink(new List { new LinoLink(newSourceId), new LinoLink(newTargetId) }); - } - substitutionLinksById[kv.Key] = lino; + return val.ToString(); } - } + return id; } - // Basic variable no-op check - var variableIds = allIds.Where(id => id.StartsWith("$")).ToArray(); - foreach (var varId in variableIds) + static void AssignVariableFromLink(string? varId, uint val, Dictionary variableAssignments, uint any, uint @null) { - if (restrictionLinksById.TryGetValue(varId, out var varRestrictionLink) - && substitutionLinksById.TryGetValue(varId, out var varSubstitutionLink)) - { - if (AreLinksEquivalent(varRestrictionLink, varSubstitutionLink)) + if (!string.IsNullOrEmpty(varId) && varId.StartsWith("$") && val != any && val != @null) { - // Remove this variable from difference tracking - allIds = allIds.Except([varId]).ToList(); + variableAssignments[varId] = val; } - } } - // After handling variables, if allIds is empty, it means no changes. - // If we have variables, let's treat this scenario as a read operation. - if (!allIds.Any() && variableIds.Any()) + static bool AreLinksEquivalent(LinoLink a, LinoLink b) { - // Perform read operation for each restriction pattern link - foreach (var kv in restrictionLinksById) - { - var restrictionPattern = ToDoubletLink(links, kv.Value, links.Constants.Any); - ReadAll(links, restrictionPattern, options); - } - return; + if (a.Id != b.Id) return false; + if (a.Values?.Count != b.Values?.Count) return false; + if (a.Values == null || b.Values == null) return a.Values == b.Values; + for (int i = 0; i < a.Values.Count; i++) + { + var av = a.Values[i]; + var bv = b.Values[i]; + if (av.Id != bv.Id) return false; + } + return true; } - // If we still have differences, proceed with sets/unsets/updates - foreach (var id in allIds) + static void Set(this ILinks links, DoubletLink substitutionLink, Options options) { - bool hasRestriction = restrictionLinksById.TryGetValue(id, out var restrictionLinoLink); - bool hasSubstitution = substitutionLinksById.TryGetValue(id, out var substitutionLinoLink); + var @null = links.Constants.Null; + var any = links.Constants.Any; + if (substitutionLink.Source == any) + { + throw new ArgumentException($"The source of the link {substitutionLink} cannot be any."); + } + if (substitutionLink.Target == any) + { + throw new ArgumentException($"The target of the link {substitutionLink} cannot be any."); + } + if (substitutionLink.Index != @null) + { + // links.EnsureCreated(doubletLink.Index); + LinksExtensions.EnsureCreated(links, substitutionLink.Index); // contain fix + var restrictionDoublet = new DoubletLink(substitutionLink.Index, any, any); + options.ChangesHandler?.Invoke(null, restrictionDoublet); + links.Update(restrictionDoublet, substitutionLink, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } + else + { + // Get or create + var linkIndex = links.SearchOrDefault(substitutionLink.Source, substitutionLink.Target); - if (hasRestriction && hasSubstitution) - { - // Update operation - var restrictionDoublet = ToDoubletLink(links, restrictionLinoLink, any); - var substitutionDoublet = ToDoubletLink(links, substitutionLinoLink, @null); + if (linkIndex == default) + { + linkIndex = links.CreateAndUpdate(substitutionLink.Source, substitutionLink.Target, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } + else + { + var existingLink = new DoubletLink(linkIndex, substitutionLink.Source, substitutionLink.Target); + options.ChangesHandler?.Invoke(existingLink, existingLink); + } + } + } - links.Update(restrictionDoublet, substitutionDoublet, (before, after) => + static void ReadAll(this ILinks links, DoubletLink restrictionLink, Options options) + { + links.Each(restrictionLink, link => { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + return options.ChangesHandler?.Invoke(link, link) ?? links.Constants.Continue; }); - } - else if (hasRestriction && !hasSubstitution) - { - var queryLink = ToDoubletLink(links, restrictionLinoLink, any); - Unset(links, queryLink, options); - } - else if (!hasRestriction && hasSubstitution) - { - var doubletLink = ToDoubletLink(links, substitutionLinoLink, @null); - Set(links, doubletLink, options); - } } - return; - } - else if (substitutionLink.Values?.Count == 0) // If substitution is empty, perform delete operation - { - foreach (var linkToDelete in restrictionLink.Values ?? []) - { - var queryLink = ToDoubletLink(links, linkToDelete, any); - Unset(links, queryLink, options); - } - return; - } - else if (restrictionLink.Values?.Count == 0) // If restriction is empty, perform create operation - { - foreach (var linkToCreate in substitutionLink.Values ?? []) + static void Unset(this ILinks links, DoubletLink restrictionLink, Options options) { - var doubletLink = ToDoubletLink(links, linkToCreate, @null); - Set(links, doubletLink, options); + var linksToDelete = links.All(restrictionLink); + foreach (var link in linksToDelete) + { + links.Delete(link, (before, after) => + { + return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; + }); + } } - return; - } - } - static string ReplaceVariable(string? id, Dictionary variableAssignments) - { - if (string.IsNullOrEmpty(id)) return id ?? ""; - if (variableAssignments.TryGetValue(id, out var val)) - { - return val.ToString(); - } - return id; - } - - static void AssignVariableFromLink(string? varId, uint val, Dictionary variableAssignments, uint any, uint @null) - { - if (!string.IsNullOrEmpty(varId) && varId.StartsWith("$") && val != any && val != @null) - { - variableAssignments[varId] = val; - } - } - - static bool AreLinksEquivalent(LinoLink a, LinoLink b) - { - if (a.Id != b.Id) return false; - if (a.Values?.Count != b.Values?.Count) return false; - if (a.Values == null || b.Values == null) return a.Values == b.Values; - for (int i = 0; i < a.Values.Count; i++) - { - var av = a.Values[i]; - var bv = b.Values[i]; - if (av.Id != bv.Id) return false; - } - return true; - } - - static void Set(this ILinks links, DoubletLink substitutionLink, Options options) - { - var @null = links.Constants.Null; - var any = links.Constants.Any; - if (substitutionLink.Source == any) - { - throw new ArgumentException($"The source of the link {substitutionLink} cannot be any."); - } - if (substitutionLink.Target == any) - { - throw new ArgumentException($"The target of the link {substitutionLink} cannot be any."); - } - if (substitutionLink.Index != @null) - { - // links.EnsureCreated(doubletLink.Index); - LinksExtensions.EnsureCreated(links, substitutionLink.Index); // contain fix - var restrictionDoublet = new DoubletLink(substitutionLink.Index, any, any); - options.ChangesHandler?.Invoke(null, restrictionDoublet); - links.Update(restrictionDoublet, substitutionLink, (before, after) => + static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - } - else - { - // Get or create - var linkIndex = links.SearchOrDefault(substitutionLink.Source, substitutionLink.Target); - - if (linkIndex == default) - { - linkIndex = links.CreateAndUpdate(substitutionLink.Source, substitutionLink.Target, (before, after) => - { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - } - else - { - var existingLink = new DoubletLink(linkIndex, substitutionLink.Source, substitutionLink.Target); - options.ChangesHandler?.Invoke(existingLink, existingLink); + uint index = defaultValue; + uint source = defaultValue; + uint target = defaultValue; + TryParseLinkId(linoLink.Id, links.Constants, ref index); + if (linoLink.Values?.Count == 2) + { + var sourceLink = linoLink.Values[0]; + TryParseLinkId(sourceLink.Id, links.Constants, ref source); + var targetLink = linoLink.Values[1]; + TryParseLinkId(targetLink.Id, links.Constants, ref target); + } + return new DoubletLink(index, source, target); } - } - } - - static void ReadAll(this ILinks links, DoubletLink restrictionLink, Options options) - { - links.Each(restrictionLink, link => - { - return options.ChangesHandler?.Invoke(link, link) ?? links.Constants.Continue; - }); - } - static void Unset(this ILinks links, DoubletLink restrictionLink, Options options) - { - var linksToDelete = links.All(restrictionLink); - foreach (var link in linksToDelete) - { - links.Delete(link, (before, after) => + static bool TryParseLinkId(string? id, LinksConstants constants, ref uint parsedValue) { - return options.ChangesHandler?.Invoke(before, after) ?? links.Constants.Continue; - }); - } - } - - static DoubletLink ToDoubletLink(ILinks links, LinoLink linoLink, uint defaultValue) - { - uint index = defaultValue; - uint source = defaultValue; - uint target = defaultValue; - TryParseLinkId(linoLink.Id, links.Constants, ref index); - if (linoLink.Values?.Count == 2) - { - var sourceLink = linoLink.Values[0]; - TryParseLinkId(sourceLink.Id, links.Constants, ref source); - var targetLink = linoLink.Values[1]; - TryParseLinkId(targetLink.Id, links.Constants, ref target); - } - return new DoubletLink(index, source, target); - } - - static bool TryParseLinkId(string? id, LinksConstants constants, ref uint parsedValue) - { - if (string.IsNullOrEmpty(id)) - { - return false; - } - if (id == "*") - { - parsedValue = constants.Any; - return true; - } - else if (id.EndsWith(":")) - { - var trimmed = id.TrimEnd(':'); - if (uint.TryParse(trimmed, out uint linkId)) - { - parsedValue = linkId; - return true; + if (string.IsNullOrEmpty(id)) + { + return false; + } + if (id == "*") + { + parsedValue = constants.Any; + return true; + } + else if (id.EndsWith(":")) + { + var trimmed = id.TrimEnd(':'); + if (uint.TryParse(trimmed, out uint linkId)) + { + parsedValue = linkId; + return true; + } + } + else if (uint.TryParse(id, out uint linkId)) + { + parsedValue = linkId; + return true; + } + return false; } - } - else if (uint.TryParse(id, out uint linkId)) - { - parsedValue = linkId; - return true; - } - return false; } - } } \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs index 3eed47e..c7e7e17 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedLinksDecorator.cs @@ -10,9 +10,11 @@ using System.Linq; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + namespace Foundation.Data.Doublets.Cli { - public class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks + public sealed class NamedLinksDecorator : LinksDecoratorBase, INamedTypesLinks, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -24,7 +26,11 @@ public class NamedLinksDecorator : LinksDecoratorBase NamedLinks; public readonly string NamedLinksDatabaseFileName; + private readonly ILinks _namedLinksFacade; + private bool _disposed; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the links facade is transferred to the caller, which releases it through " + nameof(Dispose) + ".")] public static ILinks MakeLinks(string databaseFilename) { var links = new UnitedMemoryLinks(databaseFilename); @@ -39,6 +45,8 @@ public static string MakeNamesDatabaseFilename(string databaseFilename) return namesDatabaseFilename; } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The names database memory and links are owned by this instance and released by " + nameof(Dispose) + ".")] public NamedLinksDecorator(ILinks links, string namesDatabaseFilename, bool tracingEnabled = false) : base(links) { _tracingEnabled = tracingEnabled; @@ -47,6 +55,7 @@ public NamedLinksDecorator(ILinks links, string namesDatabaseFilen var namesMemory = new FileMappedResizableDirectMemory(namesDatabaseFilename, UnitedMemoryLinks.DefaultLinksSizeStep); var namesLinks = new UnitedMemoryLinks(namesMemory, UnitedMemoryLinks.DefaultLinksSizeStep, namesConstants, IndexTreeType.Default); var decoratedNamesLinks = namesLinks.DecorateWithAutomaticUniquenessAndUsagesResolution(); + _namedLinksFacade = decoratedNamesLinks; NamedLinks = new UnicodeStringStorage(decoratedNamesLinks).NamedLinks; NamedLinksDatabaseFileName = namesDatabaseFilename; } @@ -56,6 +65,17 @@ public NamedLinksDecorator(string databaseFilename, bool tracingEnabled = false) { } + /// + /// Releases the memory-mapped file handles of both the data and the names databases. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + LinksFacadeDisposer.Dispose(_namedLinksFacade); + LinksFacadeDisposer.Dispose(_links); + } + /// /// Gets the name associated with the specified link address. /// diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs index 663d864..8619b8a 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/NamedTypesDecorator.cs @@ -11,9 +11,11 @@ using Platform.Data.Doublets.Memory; using Platform.Data.Doublets.Memory.United.Generic; +using System.Diagnostics.CodeAnalysis; + namespace Foundation.Data.Doublets.Cli { - public class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes, IDisposable + public sealed class NamedTypesDecorator : LinksDecoratorBase, INamedTypesLinks, IPinnedTypes, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -28,6 +30,8 @@ public class NamedTypesDecorator : LinksDecoratorBase _namedLinksFacade; private bool _disposed; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the links facade is transferred to the caller, which releases it through " + nameof(Dispose) + ".")] public static ILinks MakeLinks(string databaseFilename) { var links = new UnitedMemoryLinks(databaseFilename); @@ -47,6 +51,8 @@ public NamedTypesDecorator(ILinks links, string namesDatabaseFilen { } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The names database memory and links are owned by this instance and released by " + nameof(Dispose) + ".")] public NamedTypesDecorator(PinnedTypesDecorator pinnedTypesDecorator, string namesDatabaseFilename, bool tracingEnabled = false) : base(pinnedTypesDecorator) { _tracingEnabled = tracingEnabled; @@ -70,46 +76,8 @@ public void Dispose() { if (_disposed) return; _disposed = true; - DisposeLinksFacade(_namedLinksFacade); - DisposeLinksFacade(PinnedTypesDecorator); - } - - private static void DisposeLinksFacade(object? facade) - { - var visited = new HashSet(ReferenceEqualityComparer.Instance); - DisposeLinksFacade(facade, visited); - } - - private static void DisposeLinksFacade(object? facade, HashSet visited) - { - if (facade is null || !visited.Add(facade)) - { - return; - } - - foreach (var inner in EnumerateInnerLinks(facade)) - { - DisposeLinksFacade(inner, visited); - } - - if (facade is IDisposable disposable) - { - disposable.Dispose(); - } - } - - private static IEnumerable EnumerateInnerLinks(object facade) - { - for (var type = facade.GetType(); type is not null; type = type.BaseType) - { - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) - { - if (typeof(ILinks).IsAssignableFrom(field.FieldType)) - { - yield return field.GetValue(facade); - } - } - } + LinksFacadeDisposer.Dispose(_namedLinksFacade); + LinksFacadeDisposer.Dispose(PinnedTypesDecorator); } public IEnumerator GetEnumerator() diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs index a868af5..f5a947d 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/PersistentTransformationDecorator.cs @@ -12,8 +12,8 @@ namespace Foundation.Data.Doublets.Cli; public enum PersistentTransformationKind { - Once, - Always + Once, + Always } public sealed record PersistentTransformation( @@ -22,409 +22,409 @@ public sealed record PersistentTransformation( string Condition, string Substitution) { - public string Query => $"({Condition} {Substitution})"; + public string Query => $"({Condition} {Substitution})"; } public sealed class PersistentTransformationDecorator : LinksDecoratorBase, INamedTypesLinks { - private const string InternalNamePrefix = "__persistent_transformation:"; - - private readonly INamedTypesLinks _namedLinks; - private readonly INamedTypesLinks _triggerLinks; - private readonly bool _trace; - private bool _applyingTriggers; - private bool _suppressTriggers; - - public bool AutoCreateMissingReferences { get; set; } - - public PersistentTransformationDecorator( - INamedTypesLinks links, - INamedTypesLinks triggerLinks, - bool trace = false) - : base(links) - { - _namedLinks = links; - _triggerLinks = triggerLinks; - _trace = trace; - } - - public static string MakeTriggersDatabaseFilename(string databaseFilename) - { - var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); - var directory = Path.GetDirectoryName(databaseFilename); - return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.triggers.links"); - } - - public uint StoreTrigger(PersistentTransformationKind kind, string query) - { - var parsed = PersistentTransformationQuery.Parse(query); - return WithoutTriggerApplication(() => - { - var schema = EnsureSchema(); - var conditionText = EnsureNamedPoint(_triggerLinks, ConditionTextName(parsed.Condition)); - var substitutionText = EnsureNamedPoint(_triggerLinks, SubstitutionTextName(parsed.Substitution)); - var conditionRecord = _triggerLinks.GetOrCreate(schema.Condition, conditionText); - var substitutionRecord = _triggerLinks.GetOrCreate(schema.Substitution, substitutionText); - var payload = _triggerLinks.GetOrCreate(conditionRecord, substitutionRecord); - var triggerType = kind == PersistentTransformationKind.Always ? schema.Always : schema.Once; - var root = _triggerLinks.GetOrCreate(triggerType, payload); - Trace($"Stored {kind} trigger #{root}: {parsed.Query}"); - return root; - }); - } - - public int RemoveTriggers(string query) - { - var parsed = PersistentTransformationQuery.Parse(query); - return WithoutTriggerApplication(() => - { - var matchingTriggers = GetTriggers() - .Where(trigger => trigger.Condition == parsed.Condition && trigger.Substitution == parsed.Substitution) - .ToList(); - - foreach (var trigger in matchingTriggers) - { - DeleteTriggerRoot(trigger.Root); - } - - return matchingTriggers.Count; - }); - } - - public IReadOnlyList GetTriggers() - { - if (!TryGetSchema(out var schema)) - { - return []; - } + private const string InternalNamePrefix = "__persistent_transformation:"; + + private readonly INamedTypesLinks _namedLinks; + private readonly INamedTypesLinks _triggerLinks; + private readonly bool _trace; + private bool _applyingTriggers; + private bool _suppressTriggers; - var linksByIndex = AllLinks(_triggerLinks).ToDictionary(link => link.Index); - var triggers = new List(); + public bool AutoCreateMissingReferences { get; set; } - foreach (var link in linksByIndex.Values.OrderBy(link => link.Index)) + public PersistentTransformationDecorator( + INamedTypesLinks links, + INamedTypesLinks triggerLinks, + bool trace = false) + : base(links) { - var kind = link.Source == schema.Always - ? PersistentTransformationKind.Always - : link.Source == schema.Once - ? PersistentTransformationKind.Once - : (PersistentTransformationKind?)null; - - if (kind is null || !linksByIndex.TryGetValue(link.Target, out var payload)) - { - continue; - } - - if (!linksByIndex.TryGetValue(payload.Source, out var conditionRecord) - || !linksByIndex.TryGetValue(payload.Target, out var substitutionRecord) - || conditionRecord.Source != schema.Condition - || substitutionRecord.Source != schema.Substitution) - { - continue; - } - - var condition = DecodeTextName(_triggerLinks.GetName(conditionRecord.Target), "condition"); - var substitution = DecodeTextName(_triggerLinks.GetName(substitutionRecord.Target), "substitution"); - if (condition is null || substitution is null) - { - continue; - } - - triggers.Add(new PersistentTransformation(link.Index, kind.Value, condition, substitution)); + _namedLinks = links; + _triggerLinks = triggerLinks; + _trace = trace; } - return triggers; - } - - public override uint Create(IList? substitution, WriteHandler? handler) - { - return RunWriteOperation(() => _links.Create(substitution, handler)); - } - - public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) - { - return RunWriteOperation(() => _links.Update(restriction, substitution, handler)); - } - - public override uint Delete(IList? restriction, WriteHandler? handler) - { - return RunWriteOperation(() => _links.Delete(restriction, handler)); - } - - public override uint Each(IList? restriction, ReadHandler? handler) - { - return _links.Each(restriction, handler); - } - - public string? GetName(uint link) - { - return _namedLinks.GetName(link); - } - - public uint SetName(uint link, string name) - { - return _namedLinks.SetName(link, name); - } - - public uint GetByName(string name) - { - return _namedLinks.GetByName(name); - } - - public void RemoveName(uint link) - { - _namedLinks.RemoveName(link); - } - - private uint RunWriteOperation(Func operation) - { - var result = operation(); - ApplyTriggersAfterOperation(); - return result; - } - - private void ApplyTriggersAfterOperation() - { - if (_suppressTriggers || _applyingTriggers) + public static string MakeTriggersDatabaseFilename(string databaseFilename) { - return; + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.triggers.links"); } - var triggers = GetTriggers(); - if (triggers.Count == 0) + public uint StoreTrigger(PersistentTransformationKind kind, string query) { - return; + var parsed = PersistentTransformationQuery.Parse(query); + return WithoutTriggerApplication(() => + { + var schema = EnsureSchema(); + var conditionText = EnsureNamedPoint(_triggerLinks, ConditionTextName(parsed.Condition)); + var substitutionText = EnsureNamedPoint(_triggerLinks, SubstitutionTextName(parsed.Substitution)); + var conditionRecord = _triggerLinks.GetOrCreate(schema.Condition, conditionText); + var substitutionRecord = _triggerLinks.GetOrCreate(schema.Substitution, substitutionText); + var payload = _triggerLinks.GetOrCreate(conditionRecord, substitutionRecord); + var triggerType = kind == PersistentTransformationKind.Always ? schema.Always : schema.Once; + var root = _triggerLinks.GetOrCreate(triggerType, payload); + Trace($"Stored {kind} trigger #{root}: {parsed.Query}"); + return root; + }); } - _applyingTriggers = true; - try + public int RemoveTriggers(string query) { - foreach (var trigger in triggers) - { - var changes = new List<(DoubletLink Before, DoubletLink After)>(); - QueryProcessor.ProcessQuery(this, new QueryProcessor.Options + var parsed = PersistentTransformationQuery.Parse(query); + return WithoutTriggerApplication(() => { - Query = trigger.Query, - Trace = _trace, - AutoCreateMissingReferences = AutoCreateMissingReferences, - ChangesHandler = (before, after) => - { - changes.Add((new DoubletLink(before), new DoubletLink(after))); - return Constants.Continue; - } + var matchingTriggers = GetTriggers() + .Where(trigger => trigger.Condition == parsed.Condition && trigger.Substitution == parsed.Substitution) + .ToList(); + + foreach (var trigger in matchingTriggers) + { + DeleteTriggerRoot(trigger.Root); + } + + return matchingTriggers.Count; }); + } - if (changes.Count > 0 && trigger.Kind == PersistentTransformationKind.Once) + public IReadOnlyList GetTriggers() + { + if (!TryGetSchema(out var schema)) + { + return []; + } + + var linksByIndex = AllLinks(_triggerLinks).ToDictionary(link => link.Index); + var triggers = new List(); + + foreach (var link in linksByIndex.Values.OrderBy(link => link.Index)) { - DeleteTriggerRoot(trigger.Root); + var kind = link.Source == schema.Always + ? PersistentTransformationKind.Always + : link.Source == schema.Once + ? PersistentTransformationKind.Once + : (PersistentTransformationKind?)null; + + if (kind is null || !linksByIndex.TryGetValue(link.Target, out var payload)) + { + continue; + } + + if (!linksByIndex.TryGetValue(payload.Source, out var conditionRecord) + || !linksByIndex.TryGetValue(payload.Target, out var substitutionRecord) + || conditionRecord.Source != schema.Condition + || substitutionRecord.Source != schema.Substitution) + { + continue; + } + + var condition = DecodeTextName(_triggerLinks.GetName(conditionRecord.Target), "condition"); + var substitution = DecodeTextName(_triggerLinks.GetName(substitutionRecord.Target), "substitution"); + if (condition is null || substitution is null) + { + continue; + } + + triggers.Add(new PersistentTransformation(link.Index, kind.Value, condition, substitution)); } - } + + return triggers; } - finally + + public override uint Create(IList? substitution, WriteHandler? handler) { - _applyingTriggers = false; + return RunWriteOperation(() => _links.Create(substitution, handler)); } - } - private T WithoutTriggerApplication(Func action) - { - var previousSuppressTriggers = _suppressTriggers; - _suppressTriggers = true; - try + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) { - return action(); + return RunWriteOperation(() => _links.Update(restriction, substitution, handler)); } - finally + + public override uint Delete(IList? restriction, WriteHandler? handler) { - _suppressTriggers = previousSuppressTriggers; + return RunWriteOperation(() => _links.Delete(restriction, handler)); } - } - - private TriggerSchema EnsureSchema() - { - var type = EnsureNamedPoint(_triggerLinks, "Type"); - var trigger = EnsureNamedPoint(_triggerLinks, "Trigger"); - var once = EnsureNamedPoint(_triggerLinks, "Once"); - var always = EnsureNamedPoint(_triggerLinks, "Always"); - var condition = EnsureNamedPoint(_triggerLinks, "Condition"); - var substitution = EnsureNamedPoint(_triggerLinks, "Substitution"); - - _triggerLinks.GetOrCreate(type, trigger); - _triggerLinks.GetOrCreate(trigger, once); - _triggerLinks.GetOrCreate(trigger, always); - _triggerLinks.GetOrCreate(type, condition); - _triggerLinks.GetOrCreate(type, substitution); - - return new TriggerSchema(type, trigger, once, always, condition, substitution); - } - - private bool TryGetSchema(out TriggerSchema schema) - { - var type = _triggerLinks.GetByName("Type"); - var trigger = _triggerLinks.GetByName("Trigger"); - var once = _triggerLinks.GetByName("Once"); - var always = _triggerLinks.GetByName("Always"); - var condition = _triggerLinks.GetByName("Condition"); - var substitution = _triggerLinks.GetByName("Substitution"); - var @null = _triggerLinks.Constants.Null; - - if (type == @null || trigger == @null || once == @null || always == @null || condition == @null || substitution == @null) + + public override uint Each(IList? restriction, ReadHandler? handler) { - schema = default; - return false; + return _links.Each(restriction, handler); } - schema = new TriggerSchema(type, trigger, once, always, condition, substitution); - return true; - } + public string? GetName(uint link) + { + return _namedLinks.GetName(link); + } - private void DeleteTriggerRoot(uint root) - { - if (!_triggerLinks.Exists(root)) + public uint SetName(uint link, string name) { - return; + return _namedLinks.SetName(link, name); } - var rootLink = new DoubletLink(_triggerLinks.GetLink(root)); - _triggerLinks.Delete(rootLink, null); - Trace($"Deleted trigger #{root}"); - } + public uint GetByName(string name) + { + return _namedLinks.GetByName(name); + } - private static uint EnsureNamedPoint(INamedTypesLinks links, string name) - { - var existing = links.GetByName(name); - if (existing != links.Constants.Null) + public void RemoveName(uint link) { - return existing; + _namedLinks.RemoveName(link); } - var id = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); - links.SetName(id, name); - links.Update( - new DoubletLink(id, links.Constants.Null, links.Constants.Null), - new DoubletLink(id, id, id), - null); - return id; - } - - private static List AllLinks(INamedTypesLinks links) - { - var any = links.Constants.Any; - return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); - } - - private static string ConditionTextName(string condition) - { - return $"{InternalNamePrefix}condition:{condition}"; - } - - private static string SubstitutionTextName(string substitution) - { - return $"{InternalNamePrefix}substitution:{substitution}"; - } - - private static string? DecodeTextName(string? name, string part) - { - var prefix = $"{InternalNamePrefix}{part}:"; - return name is not null && name.StartsWith(prefix, StringComparison.Ordinal) - ? name[prefix.Length..] - : null; - } - - private void Trace(string message) - { - if (_trace) + private uint RunWriteOperation(Func operation) { - Console.WriteLine($"[PersistentTransformation] {message}"); + var result = operation(); + ApplyTriggersAfterOperation(); + return result; } - } - private readonly record struct TriggerSchema(uint Type, uint Trigger, uint Once, uint Always, uint Condition, uint Substitution); + private void ApplyTriggersAfterOperation() + { + if (_suppressTriggers || _applyingTriggers) + { + return; + } - private sealed record PersistentTransformationQuery(string Condition, string Substitution) - { - public string Query => $"({Condition} {Substitution})"; + var triggers = GetTriggers(); + if (triggers.Count == 0) + { + return; + } - public static PersistentTransformationQuery Parse(string query) + _applyingTriggers = true; + try + { + foreach (var trigger in triggers) + { + var changes = new List<(DoubletLink Before, DoubletLink After)>(); + QueryProcessor.ProcessQuery(this, new QueryProcessor.Options + { + Query = trigger.Query, + Trace = _trace, + AutoCreateMissingReferences = AutoCreateMissingReferences, + ChangesHandler = (before, after) => + { + changes.Add((new DoubletLink(before), new DoubletLink(after))); + return Constants.Continue; + } + }); + + if (changes.Count > 0 && trigger.Kind == PersistentTransformationKind.Once) + { + DeleteTriggerRoot(trigger.Root); + } + } + } + finally + { + _applyingTriggers = false; + } + } + + private T WithoutTriggerApplication(Func action) { - var parser = new Parser(); - var parsedLinks = parser.Parse(query); - if (parsedLinks.Count == 0) - { - throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); - } - - LinoLink condition; - LinoLink substitution; - var outerLink = parsedLinks[0]; - if (outerLink.Values is { Count: >= 2 } outerValues) - { - condition = outerValues[0]; - substitution = outerValues[1]; - } - else if (parsedLinks.Count >= 2) - { - condition = parsedLinks[0]; - substitution = parsedLinks[1]; - } - else - { - throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); - } - - return new PersistentTransformationQuery(Format(condition), Format(substitution)); + var previousSuppressTriggers = _suppressTriggers; + _suppressTriggers = true; + try + { + return action(); + } + finally + { + _suppressTriggers = previousSuppressTriggers; + } } - private static string Format(LinoLink link) + private TriggerSchema EnsureSchema() { - if (link.Values is null || link.Values.Count == 0) - { - return string.IsNullOrEmpty(link.Id) ? "()" : EscapeReference(link.Id); - } - - var values = string.Join(" ", link.Values.Select(Format)); - if (string.IsNullOrEmpty(link.Id)) - { - return $"({values})"; - } - - return $"({EscapeReference(link.Id)}: {values})"; + var type = EnsureNamedPoint(_triggerLinks, "Type"); + var trigger = EnsureNamedPoint(_triggerLinks, "Trigger"); + var once = EnsureNamedPoint(_triggerLinks, "Once"); + var always = EnsureNamedPoint(_triggerLinks, "Always"); + var condition = EnsureNamedPoint(_triggerLinks, "Condition"); + var substitution = EnsureNamedPoint(_triggerLinks, "Substitution"); + + _triggerLinks.GetOrCreate(type, trigger); + _triggerLinks.GetOrCreate(trigger, once); + _triggerLinks.GetOrCreate(trigger, always); + _triggerLinks.GetOrCreate(type, condition); + _triggerLinks.GetOrCreate(type, substitution); + + return new TriggerSchema(type, trigger, once, always, condition, substitution); } - private static string EscapeReference(string reference) + private bool TryGetSchema(out TriggerSchema schema) { - if (string.IsNullOrWhiteSpace(reference)) - { - return string.Empty; - } - - var hasSingleQuote = reference.Contains('\''); - var hasDoubleQuote = reference.Contains('"'); - var needsQuoting = reference.Contains(':') - || reference.Contains('(') - || reference.Contains(')') - || reference.Contains(' ') - || reference.Contains('\t') - || reference.Contains('\n') - || reference.Contains('\r') - || hasSingleQuote - || hasDoubleQuote; - - if (hasSingleQuote && hasDoubleQuote) - { - return $"'{reference.Replace("'", "\\'")}'"; - } - - if (hasDoubleQuote) - { - return $"'{reference}'"; - } - - if (hasSingleQuote) - { - return $"\"{reference}\""; - } - - return needsQuoting ? $"'{reference}'" : reference; + var type = _triggerLinks.GetByName("Type"); + var trigger = _triggerLinks.GetByName("Trigger"); + var once = _triggerLinks.GetByName("Once"); + var always = _triggerLinks.GetByName("Always"); + var condition = _triggerLinks.GetByName("Condition"); + var substitution = _triggerLinks.GetByName("Substitution"); + var @null = _triggerLinks.Constants.Null; + + if (type == @null || trigger == @null || once == @null || always == @null || condition == @null || substitution == @null) + { + schema = default; + return false; + } + + schema = new TriggerSchema(type, trigger, once, always, condition, substitution); + return true; + } + + private void DeleteTriggerRoot(uint root) + { + if (!_triggerLinks.Exists(root)) + { + return; + } + + var rootLink = new DoubletLink(_triggerLinks.GetLink(root)); + _triggerLinks.Delete(rootLink, null); + Trace($"Deleted trigger #{root}"); + } + + private static uint EnsureNamedPoint(INamedTypesLinks links, string name) + { + var existing = links.GetByName(name); + if (existing != links.Constants.Null) + { + return existing; + } + + var id = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(id, name); + links.Update( + new DoubletLink(id, links.Constants.Null, links.Constants.Null), + new DoubletLink(id, id, id), + null); + return id; + } + + private static List AllLinks(INamedTypesLinks links) + { + var any = links.Constants.Any; + return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); + } + + private static string ConditionTextName(string condition) + { + return $"{InternalNamePrefix}condition:{condition}"; + } + + private static string SubstitutionTextName(string substitution) + { + return $"{InternalNamePrefix}substitution:{substitution}"; + } + + private static string? DecodeTextName(string? name, string part) + { + var prefix = $"{InternalNamePrefix}{part}:"; + return name is not null && name.StartsWith(prefix, StringComparison.Ordinal) + ? name[prefix.Length..] + : null; + } + + private void Trace(string message) + { + if (_trace) + { + Console.WriteLine($"[PersistentTransformation] {message}"); + } + } + + private readonly record struct TriggerSchema(uint Type, uint Trigger, uint Once, uint Always, uint Condition, uint Substitution); + + private sealed record PersistentTransformationQuery(string Condition, string Substitution) + { + public string Query => $"({Condition} {Substitution})"; + + public static PersistentTransformationQuery Parse(string query) + { + var parser = new Parser(); + var parsedLinks = parser.Parse(query); + if (parsedLinks.Count == 0) + { + throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); + } + + LinoLink condition; + LinoLink substitution; + var outerLink = parsedLinks[0]; + if (outerLink.Values is { Count: >= 2 } outerValues) + { + condition = outerValues[0]; + substitution = outerValues[1]; + } + else if (parsedLinks.Count >= 2) + { + condition = parsedLinks[0]; + substitution = parsedLinks[1]; + } + else + { + throw new ArgumentException("Persistent transformation query must contain a condition and a substitution.", nameof(query)); + } + + return new PersistentTransformationQuery(Format(condition), Format(substitution)); + } + + private static string Format(LinoLink link) + { + if (link.Values is null || link.Values.Count == 0) + { + return string.IsNullOrEmpty(link.Id) ? "()" : EscapeReference(link.Id); + } + + var values = string.Join(" ", link.Values.Select(Format)); + if (string.IsNullOrEmpty(link.Id)) + { + return $"({values})"; + } + + return $"({EscapeReference(link.Id)}: {values})"; + } + + private static string EscapeReference(string reference) + { + if (string.IsNullOrWhiteSpace(reference)) + { + return string.Empty; + } + + var hasSingleQuote = reference.Contains('\''); + var hasDoubleQuote = reference.Contains('"'); + var needsQuoting = reference.Contains(':') + || reference.Contains('(') + || reference.Contains(')') + || reference.Contains(' ') + || reference.Contains('\t') + || reference.Contains('\n') + || reference.Contains('\r') + || hasSingleQuote + || hasDoubleQuote; + + if (hasSingleQuote && hasDoubleQuote) + { + return $"'{reference.Replace("'", "\\'")}'"; + } + + if (hasDoubleQuote) + { + return $"'{reference}'"; + } + + if (hasSingleQuote) + { + return $"\"{reference}\""; + } + + return needsQuoting ? $"'{reference}'" : reference; + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs index fd7a427..acd4cc3 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/SimpleLinksDecorator.cs @@ -8,9 +8,11 @@ using Platform.Memory; using Platform.Data.Doublets.Memory; +using System.Diagnostics.CodeAnalysis; + namespace Foundation.Data.Doublets.Cli { - public class SimpleLinksDecorator : LinksDecoratorBase + public sealed class SimpleLinksDecorator : LinksDecoratorBase, IDisposable where TLinkAddress : struct, IUnsignedNumber, IComparisonOperators, @@ -22,7 +24,11 @@ public class SimpleLinksDecorator : LinksDecoratorBase NamedLinks; public readonly string NamedLinksDatabaseFileName; + private readonly ILinks _namedLinksFacade; + private bool _disposed; + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the links facade is transferred to the caller, which releases it through " + nameof(Dispose) + ".")] public static ILinks MakeLinks(string databaseFilename) { var links = new UnitedMemoryLinks(databaseFilename); @@ -37,6 +43,8 @@ public static string MakeNamesDatabaseFilename(string databaseFilename) return namesDatabaseFilename; } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The names database memory and links are owned by this instance and released by " + nameof(Dispose) + ".")] public SimpleLinksDecorator(ILinks links, string namesDatabaseFilename, bool tracingEnabled = false) : base(links) { _tracingEnabled = tracingEnabled; @@ -45,6 +53,7 @@ public SimpleLinksDecorator(ILinks links, string namesDatabaseFile var namesMemory = new FileMappedResizableDirectMemory(namesDatabaseFilename, UnitedMemoryLinks.DefaultLinksSizeStep); var namesLinks = new UnitedMemoryLinks(namesMemory, UnitedMemoryLinks.DefaultLinksSizeStep, namesConstants, IndexTreeType.Default); var decoratedNamesLinks = namesLinks.DecorateWithAutomaticUniquenessAndUsagesResolution(); + _namedLinksFacade = decoratedNamesLinks; NamedLinks = new UnicodeStringStorage(decoratedNamesLinks).NamedLinks; NamedLinksDatabaseFileName = namesDatabaseFilename; } @@ -54,15 +63,28 @@ public SimpleLinksDecorator(string databaseFilename, bool tracingEnabled = false { } + /// + /// Releases the memory-mapped file handles of both the data and the names databases. + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + LinksFacadeDisposer.Dispose(_namedLinksFacade); + LinksFacadeDisposer.Dispose(_links); + } + public override TLinkAddress Delete(IList? restriction, WriteHandler? handler) { var constants = _links.Constants; - return _links.Delete(restriction, (before, after) => { - if (handler == null) { + return _links.Delete(restriction, (before, after) => + { + if (handler == null) + { return constants.Continue; } return handler(before, after); }); } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs index 8f2b46c..8f8bb08 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs @@ -12,9 +12,9 @@ namespace Foundation.Data.Doublets.Cli; /// The kind of write operation recorded by a transition. public enum TransitionKind { - Create, - Update, - Delete + Create, + Update, + Delete } /// @@ -25,8 +25,8 @@ public enum TransitionKind /// public enum CommitMode { - Sync, - Async + Sync, + Async } /// @@ -43,55 +43,55 @@ public enum CommitMode /// public abstract record LogRetentionPolicy { - public sealed record Infinite() : LogRetentionPolicy; - public sealed record Chunked(long ChunkSize, string ArchiveDirectory) : LogRetentionPolicy; - public sealed record Sized(long MaxTransitions) : LogRetentionPolicy; - - public static LogRetentionPolicy Default { get; } = new Infinite(); - - /// - /// Parses a CLI spec: infinite, sized:<n>, or - /// chunked:<n>:<dir>. - /// - public static LogRetentionPolicy Parse(string spec) - { - ArgumentNullException.ThrowIfNull(spec); - var trimmed = spec.Trim(); - if (trimmed.Length == 0 || trimmed.Equals("infinite", StringComparison.OrdinalIgnoreCase)) - { - return new Infinite(); - } - - var lowered = trimmed.ToLowerInvariant(); - if (lowered.StartsWith("sized:", StringComparison.Ordinal)) - { - var rest = trimmed.Substring("sized:".Length); - if (!long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var max) || max < 0) - { - throw new ArgumentException($"Invalid sized retention spec '{spec}'.", nameof(spec)); - } - return new Sized(max); - } - - if (lowered.StartsWith("chunked:", StringComparison.Ordinal)) - { - var rest = trimmed.Substring("chunked:".Length); - var colon = rest.IndexOf(':'); - if (colon <= 0 || colon == rest.Length - 1) - { - throw new ArgumentException($"Invalid chunked retention spec '{spec}'.", nameof(spec)); - } - var sizeText = rest.Substring(0, colon); - var dir = rest.Substring(colon + 1); - if (!long.TryParse(sizeText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chunkSize) || chunkSize <= 0) - { - throw new ArgumentException($"Invalid chunked size in '{spec}'.", nameof(spec)); - } - return new Chunked(chunkSize, dir); - } - - throw new ArgumentException($"Unknown retention spec '{spec}'.", nameof(spec)); - } + public sealed record Infinite() : LogRetentionPolicy; + public sealed record Chunked(long ChunkSize, string ArchiveDirectory) : LogRetentionPolicy; + public sealed record Sized(long MaxTransitions) : LogRetentionPolicy; + + public static LogRetentionPolicy Default { get; } = new Infinite(); + + /// + /// Parses a CLI spec: infinite, sized:<n>, or + /// chunked:<n>:<dir>. + /// + public static LogRetentionPolicy Parse(string spec) + { + ArgumentNullException.ThrowIfNull(spec); + var trimmed = spec.Trim(); + if (trimmed.Length == 0 || trimmed.Equals("infinite", StringComparison.OrdinalIgnoreCase)) + { + return new Infinite(); + } + + var lowered = trimmed.ToLowerInvariant(); + if (lowered.StartsWith("sized:", StringComparison.Ordinal)) + { + var rest = trimmed.Substring("sized:".Length); + if (!long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var max) || max < 0) + { + throw new ArgumentException($"Invalid sized retention spec '{spec}'.", nameof(spec)); + } + return new Sized(max); + } + + if (lowered.StartsWith("chunked:", StringComparison.Ordinal)) + { + var rest = trimmed.Substring("chunked:".Length); + var colon = rest.IndexOf(':'); + if (colon <= 0 || colon == rest.Length - 1) + { + throw new ArgumentException($"Invalid chunked retention spec '{spec}'.", nameof(spec)); + } + var sizeText = rest.Substring(0, colon); + var dir = rest.Substring(colon + 1); + if (!long.TryParse(sizeText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chunkSize) || chunkSize <= 0) + { + throw new ArgumentException($"Invalid chunked size in '{spec}'.", nameof(spec)); + } + return new Chunked(chunkSize, dir); + } + + throw new ArgumentException($"Unknown retention spec '{spec}'.", nameof(spec)); + } } /// @@ -108,83 +108,83 @@ public readonly record struct Transition( DoubletLink Before, DoubletLink After) { - internal const string SchemaVersion = "v1"; - - /// Encodes the transition as a single line stored as the - /// name of one link in the log doublets store. - public string Serialize() - { - return string.Join('|', - SchemaVersion, - TransactionId.ToString("N"), - Sequence.ToString(CultureInfo.InvariantCulture), - Timestamp.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), - ((int)Kind).ToString(CultureInfo.InvariantCulture), - $"{Before.Index},{Before.Source},{Before.Target}", - $"{After.Index},{After.Source},{After.Target}"); - } - - public static bool TryParse(string text, out Transition transition) - { - transition = default; - if (string.IsNullOrWhiteSpace(text)) return false; - var parts = text.Split('|'); - if (parts.Length < 7 || parts[0] != SchemaVersion) return false; - if (!Guid.TryParseExact(parts[1], "N", out var txId)) return false; - if (!long.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) return false; - if (!long.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms)) return false; - if (!int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var kindValue)) return false; - if (!TryParseLink(parts[5], out var before)) return false; - if (!TryParseLink(parts[6], out var after)) return false; - transition = new Transition( - txId, - seq, - DateTimeOffset.FromUnixTimeMilliseconds(ms), - (TransitionKind)kindValue, - before, - after); - return true; - } - - private static bool TryParseLink(string text, out DoubletLink link) - { - link = default; - var parts = text.Split(','); - if (parts.Length != 3) return false; - if (!uint.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) return false; - if (!uint.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var source)) return false; - if (!uint.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var target)) return false; - link = new DoubletLink(index, source, target); - return true; - } + internal const string SchemaVersion = "v1"; + + /// Encodes the transition as a single line stored as the + /// name of one link in the log doublets store. + public string Serialize() + { + return string.Join('|', + SchemaVersion, + TransactionId.ToString("N"), + Sequence.ToString(CultureInfo.InvariantCulture), + Timestamp.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + ((int)Kind).ToString(CultureInfo.InvariantCulture), + $"{Before.Index},{Before.Source},{Before.Target}", + $"{After.Index},{After.Source},{After.Target}"); + } + + public static bool TryParse(string text, out Transition transition) + { + transition = default; + if (string.IsNullOrWhiteSpace(text)) return false; + var parts = text.Split('|'); + if (parts.Length < 7 || parts[0] != SchemaVersion) return false; + if (!Guid.TryParseExact(parts[1], "N", out var txId)) return false; + if (!long.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) return false; + if (!long.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms)) return false; + if (!int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var kindValue)) return false; + if (!TryParseLink(parts[5], out var before)) return false; + if (!TryParseLink(parts[6], out var after)) return false; + transition = new Transition( + txId, + seq, + DateTimeOffset.FromUnixTimeMilliseconds(ms), + (TransitionKind)kindValue, + before, + after); + return true; + } + + private static bool TryParseLink(string text, out DoubletLink link) + { + link = default; + var parts = text.Split(','); + if (parts.Length != 3) return false; + if (!uint.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) return false; + if (!uint.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var source)) return false; + if (!uint.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var target)) return false; + link = new DoubletLink(index, source, target); + return true; + } } /// A live transaction handle. Disposal without commit rolls /// back automatically (R10). public interface ITransaction : IDisposable { - Guid Id { get; } - DateTimeOffset StartedAt { get; } - bool IsCommitted { get; } - bool IsRolledBack { get; } - IReadOnlyList Transitions { get; } - void Commit(); - void Rollback(); - Task CommitAsync(CancellationToken cancellationToken = default); + Guid Id { get; } + DateTimeOffset StartedAt { get; } + bool IsCommitted { get; } + bool IsRolledBack { get; } + IReadOnlyList Transitions { get; } + void Commit(); + void Rollback(); + Task CommitAsync(CancellationToken cancellationToken = default); } /// A links store with transactional semantics layered on top /// of the underlying . public interface ITransactionsLinks : INamedTypesLinks { - ITransaction BeginTransaction(); - Task BeginTransactionAsync(CancellationToken cancellationToken = default); - IReadOnlyList Log { get; } - LogRetentionPolicy RetentionPolicy { get; set; } - CommitMode CommitMode { get; set; } - void Recover(); - long AppliedSequence { get; } - long LastLoggedSequence { get; } + ITransaction BeginTransaction(); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + IReadOnlyList Log { get; } + LogRetentionPolicy RetentionPolicy { get; set; } + CommitMode CommitMode { get; set; } + void Recover(); + long AppliedSequence { get; } + long LastLoggedSequence { get; } } /// @@ -194,728 +194,734 @@ public interface ITransactionsLinks : INamedTypesLinks /// retention policies, and crash recovery. Optional — no behavioural /// change if not opted in (R8). /// -public sealed class TransactionsDecorator : LinksDecoratorBase, ITransactionsLinks +public sealed class TransactionsDecorator : LinksDecoratorBase, ITransactionsLinks, IDisposable { - internal const string CommitMarkerPrefix = "__transactions:commit:"; - internal const string RollbackMarkerPrefix = "__transactions:rollback:"; - internal const string AppliedMarkerPrefix = "__transactions:applied:"; - internal const string TransitionNamePrefix = "__transactions:transition:"; - - private readonly INamedTypesLinks _inner; - private readonly INamedTypesLinks _logStore; - private readonly bool _trace; - private readonly object _lock = new(); - private readonly List _log = new(); - private readonly HashSet _committed = new(); - private readonly HashSet _rolledBack = new(); - private readonly HashSet _applied = new(); - private readonly BlockingCollection> _asyncQueue = new(); - private readonly CancellationTokenSource _backgroundCts = new(); - private readonly Task _backgroundWorker; - private Transaction? _current; - private long _sequenceCounter; - private long _appliedSequence; - private bool _disposed; - private bool _replaying; - private LogRetentionPolicy _retentionPolicy; - private CommitMode _commitMode; - - public TransactionsDecorator( - INamedTypesLinks inner, - INamedTypesLinks logStore, - LogRetentionPolicy? retentionPolicy = null, - CommitMode commitMode = CommitMode.Sync, - bool trace = false) - : base(inner) - { - _inner = inner; - _logStore = logStore; - _retentionPolicy = retentionPolicy ?? LogRetentionPolicy.Default; - _commitMode = commitMode; - _trace = trace; - _backgroundWorker = Task.Run(RunBackgroundWorker); - Recover(); - } - - public CommitMode CommitMode - { - get { lock (_lock) return _commitMode; } - set { lock (_lock) _commitMode = value; } - } - - public LogRetentionPolicy RetentionPolicy - { - get { lock (_lock) return _retentionPolicy; } - set { lock (_lock) _retentionPolicy = value ?? LogRetentionPolicy.Default; } - } - - public IReadOnlyList Log - { - get { lock (_lock) return _log.ToArray(); } - } - - public long AppliedSequence { get { lock (_lock) return _appliedSequence; } } - public long LastLoggedSequence { get { lock (_lock) return _sequenceCounter; } } - - public ITransaction BeginTransaction() - { - lock (_lock) - { - if (_current is not null) - { - throw new InvalidOperationException("Nested transactions are not supported."); - } - _current = new Transaction(this, autoCommit: false); - Trace($"Began transaction {_current.Id:N}."); - return _current; - } - } - - public Task BeginTransactionAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(BeginTransaction()); - } - - // Write API (wraps the user's handler so we observe before/after) ------- - - public override uint Create(IList? substitution, WriteHandler? handler) - { - return RunWrite(TransitionKind.Create, h => _inner.Create(substitution, h), handler); - } - - public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) - { - return RunWrite(TransitionKind.Update, h => _inner.Update(restriction, substitution, h), handler); - } - - public override uint Delete(IList? restriction, WriteHandler? handler) - { - return RunWrite(TransitionKind.Delete, h => _inner.Delete(restriction, h), handler); - } - - private uint RunWrite( - TransitionKind kind, - Func, uint> innerCall, - WriteHandler? userHandler) - { - if (_replaying) - { - return innerCall(userHandler ?? NullHandler); - } - - Transaction transaction; - bool ownsTransaction; - lock (_lock) - { - if (_current is null) - { - _current = new Transaction(this, autoCommit: true); - ownsTransaction = true; - } - else - { - ownsTransaction = false; - } - transaction = _current; - } - - var @continue = _inner.Constants.Continue; - var observed = new Dictionary(); - var observedOrder = new List(); - - WriteHandler wrapped = (before, after) => - { - var beforeLink = before is null ? default(DoubletLink?) : new DoubletLink(before); - var afterLink = after is null ? default(DoubletLink?) : new DoubletLink(after); - var key = beforeLink?.Index ?? afterLink?.Index ?? 0; - if (key != 0) - { - if (!observed.TryGetValue(key, out var state)) - { - observedOrder.Add(key); - state = (beforeLink, afterLink); - } - else - { - state = (state.Before ?? beforeLink, afterLink); - } - observed[key] = state; - } - return userHandler is null ? @continue : userHandler(before, after); - }; + internal const string CommitMarkerPrefix = "__transactions:commit:"; + internal const string RollbackMarkerPrefix = "__transactions:rollback:"; + internal const string AppliedMarkerPrefix = "__transactions:applied:"; + internal const string TransitionNamePrefix = "__transactions:transition:"; - uint result; - try + private readonly INamedTypesLinks _inner; + private readonly INamedTypesLinks _logStore; + private readonly bool _trace; + private readonly object _lock = new(); + private readonly List _log = new(); + private readonly HashSet _committed = new(); + private readonly HashSet _rolledBack = new(); + private readonly HashSet _applied = new(); + private readonly BlockingCollection> _asyncQueue = new(); + private readonly CancellationTokenSource _backgroundCts = new(); + private readonly Task _backgroundWorker; + private Transaction? _current; + private long _sequenceCounter; + private long _appliedSequence; + private bool _disposed; + private bool _replaying; + private LogRetentionPolicy _retentionPolicy; + private CommitMode _commitMode; + + public TransactionsDecorator( + INamedTypesLinks inner, + INamedTypesLinks logStore, + LogRetentionPolicy? retentionPolicy = null, + CommitMode commitMode = CommitMode.Sync, + bool trace = false) + : base(inner) { - result = innerCall(wrapped); + _inner = inner; + _logStore = logStore; + _retentionPolicy = retentionPolicy ?? LogRetentionPolicy.Default; + _commitMode = commitMode; + _trace = trace; + _backgroundWorker = Task.Run(RunBackgroundWorker); + Recover(); } - catch + + public CommitMode CommitMode { - // best-effort: record nothing if the inner store threw before any - // before/after callback fired, and discard the auto transaction. - if (ownsTransaction) - { - lock (_lock) - { - if (_current == transaction) _current = null; - } - } - throw; + get { lock (_lock) return _commitMode; } + set { lock (_lock) _commitMode = value; } } - foreach (var key in observedOrder) + public LogRetentionPolicy RetentionPolicy { - var state = observed[key]; - var before = state.Before ?? default; - var after = state.After ?? default; - RecordTransition(transaction, kind, before, after); + get { lock (_lock) return _retentionPolicy; } + set { lock (_lock) _retentionPolicy = value ?? LogRetentionPolicy.Default; } } - if (ownsTransaction) + public IReadOnlyList Log { - transaction.Commit(); + get { lock (_lock) return _log.ToArray(); } } - return result; - } + public long AppliedSequence { get { lock (_lock) return _appliedSequence; } } + public long LastLoggedSequence { get { lock (_lock) return _sequenceCounter; } } - private static DoubletLink LinkOrEmpty(IList? raw) - { - return raw is null ? default : new DoubletLink(raw); - } - - private static uint NullHandler(IList? before, IList? after) => default; + public ITransaction BeginTransaction() + { + lock (_lock) + { + if (_current is not null) + { + throw new InvalidOperationException("Nested transactions are not supported."); + } + _current = new Transaction(this, autoCommit: false); + Trace($"Began transaction {_current.Id:N}."); + return _current; + } + } - private void RecordTransition(Transaction transaction, TransitionKind kind, DoubletLink before, DoubletLink after) - { - Transition transition; - lock (_lock) + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) { - var sequence = ++_sequenceCounter; - transition = new Transition( - transaction.Id, - sequence, - DateTimeOffset.UtcNow, - kind, - before, - after); - transaction.AddTransition(transition); - _log.Add(transition); - WriteTransitionToLog(transition); - Trace($"Recorded {kind} seq={sequence} tx={transaction.Id:N}: ({before.Index},{before.Source},{before.Target}) -> ({after.Index},{after.Source},{after.Target})."); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(BeginTransaction()); } - } - // INamedTypes forwarding ------------------------------------------------ + // Write API (wraps the user's handler so we observe before/after) ------- - public string? GetName(uint link) => _inner.GetName(link); - public uint SetName(uint link, string name) => _inner.SetName(link, name); - public uint GetByName(string name) => _inner.GetByName(name); - public void RemoveName(uint link) => _inner.RemoveName(link); + public override uint Create(IList? substitution, WriteHandler? handler) + { + return RunWrite(TransitionKind.Create, h => _inner.Create(substitution, h), handler); + } - // Recovery -------------------------------------------------------------- + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) + { + return RunWrite(TransitionKind.Update, h => _inner.Update(restriction, substitution, h), handler); + } - public void Recover() - { - lock (_lock) + public override uint Delete(IList? restriction, WriteHandler? handler) { - _log.Clear(); - _committed.Clear(); - _rolledBack.Clear(); - _applied.Clear(); - _sequenceCounter = 0; - _appliedSequence = 0; + return RunWrite(TransitionKind.Delete, h => _inner.Delete(restriction, h), handler); + } - var any = _logStore.Constants.Any; - var anyLink = new DoubletLink(any, any, any); - foreach (var raw in _logStore.All(anyLink)) - { - var link = new DoubletLink(raw); - var name = _logStore.GetName(link.Index); - if (string.IsNullOrEmpty(name)) continue; + private uint RunWrite( + TransitionKind kind, + Func, uint> innerCall, + WriteHandler? userHandler) + { + if (_replaying) + { + return innerCall(userHandler ?? NullHandler); + } - if (name.StartsWith(TransitionNamePrefix, StringComparison.Ordinal)) + Transaction transaction; + bool ownsTransaction; + lock (_lock) { - var payload = name.Substring(TransitionNamePrefix.Length); - if (Transition.TryParse(payload, out var transition)) - { - InsertOrdered(_log, transition); - if (transition.Sequence > _sequenceCounter) + if (_current is null) { - _sequenceCounter = transition.Sequence; + _current = new Transaction(this, autoCommit: true); + ownsTransaction = true; } - } + else + { + ownsTransaction = false; + } + transaction = _current; } - else if (name.StartsWith(CommitMarkerPrefix, StringComparison.Ordinal)) + + var @continue = _inner.Constants.Continue; + var observed = new Dictionary(); + var observedOrder = new List(); + + WriteHandler wrapped = (before, after) => { - if (Guid.TryParseExact(name.Substring(CommitMarkerPrefix.Length), "N", out var txId)) - { - _committed.Add(txId); - } - } - else if (name.StartsWith(RollbackMarkerPrefix, StringComparison.Ordinal)) + var beforeLink = before is null ? default(DoubletLink?) : new DoubletLink(before); + var afterLink = after is null ? default(DoubletLink?) : new DoubletLink(after); + var key = beforeLink?.Index ?? afterLink?.Index ?? 0; + if (key != 0) + { + if (!observed.TryGetValue(key, out var state)) + { + observedOrder.Add(key); + state = (beforeLink, afterLink); + } + else + { + state = (state.Before ?? beforeLink, afterLink); + } + observed[key] = state; + } + return userHandler is null ? @continue : userHandler(before, after); + }; + + uint result; + try { - if (Guid.TryParseExact(name.Substring(RollbackMarkerPrefix.Length), "N", out var txId)) - { - _rolledBack.Add(txId); - } + result = innerCall(wrapped); } - else if (name.StartsWith(AppliedMarkerPrefix, StringComparison.Ordinal)) + catch { - var rest = name.Substring(AppliedMarkerPrefix.Length); - if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) - { - _applied.Add(seq); - if (seq > _appliedSequence) _appliedSequence = seq; - } + // best-effort: record nothing if the inner store threw before any + // before/after callback fired, and discard the auto transaction. + if (ownsTransaction) + { + lock (_lock) + { + if (_current == transaction) _current = null; + } + } + throw; } - } - _replaying = true; - try - { - // Re-apply committed transitions whose side-effects were lost - // (e.g. async crash before checkpoint). Only those not yet - // recorded as applied are touched. - foreach (var transition in _log) + foreach (var key in observedOrder) { - if (!_committed.Contains(transition.TransactionId)) continue; - if (_applied.Contains(transition.Sequence)) continue; - TryApplyTransition(transition, recordApplied: true); + var state = observed[key]; + var before = state.Before ?? default; + var after = state.After ?? default; + RecordTransition(transaction, kind, before, after); } - // Auto-rollback transitions written but never committed and never - // rolled back: this is the crash-mid-transaction case (R10). - foreach (var transition in _log.OrderByDescending(t => t.Sequence)) + if (ownsTransaction) { - if (_committed.Contains(transition.TransactionId)) continue; - if (_rolledBack.Contains(transition.TransactionId)) continue; - TryRevertTransition(transition); + transaction.Commit(); } - // Mark recovered-but-incomplete transactions as rolled back so - // we don't try to revert them on the next open. - var pendingTxIds = _log - .Where(t => !_committed.Contains(t.TransactionId) && !_rolledBack.Contains(t.TransactionId)) - .Select(t => t.TransactionId) - .Distinct() - .ToList(); - foreach (var txId in pendingTxIds) + return result; + } + + private static DoubletLink LinkOrEmpty(IList? raw) + { + return raw is null ? default : new DoubletLink(raw); + } + + private static uint NullHandler(IList? before, IList? after) => default; + + private void RecordTransition(Transaction transaction, TransitionKind kind, DoubletLink before, DoubletLink after) + { + Transition transition; + lock (_lock) { - _rolledBack.Add(txId); - WriteMarker(RollbackMarkerPrefix + txId.ToString("N")); + var sequence = ++_sequenceCounter; + transition = new Transition( + transaction.Id, + sequence, + DateTimeOffset.UtcNow, + kind, + before, + after); + transaction.AddTransition(transition); + _log.Add(transition); + WriteTransitionToLog(transition); + Trace($"Recorded {kind} seq={sequence} tx={transaction.Id:N}: ({before.Index},{before.Source},{before.Target}) -> ({after.Index},{after.Source},{after.Target})."); } - } - finally - { - _replaying = false; - } } - } - // Disposal -------------------------------------------------------------- + // INamedTypes forwarding ------------------------------------------------ - /// - /// Stops the background worker. The wrapped data store and log store - /// are not disposed here; callers are expected to own those. - /// - public void Shutdown() - { - if (_disposed) return; - _disposed = true; - try + public string? GetName(uint link) => _inner.GetName(link); + public uint SetName(uint link, string name) => _inner.SetName(link, name); + public uint GetByName(string name) => _inner.GetByName(name); + public void RemoveName(uint link) => _inner.RemoveName(link); + + // Recovery -------------------------------------------------------------- + + public void Recover() { - _asyncQueue.CompleteAdding(); - _backgroundCts.Cancel(); - _backgroundWorker.Wait(TimeSpan.FromSeconds(5)); + lock (_lock) + { + _log.Clear(); + _committed.Clear(); + _rolledBack.Clear(); + _applied.Clear(); + _sequenceCounter = 0; + _appliedSequence = 0; + + var any = _logStore.Constants.Any; + var anyLink = new DoubletLink(any, any, any); + foreach (var raw in _logStore.All(anyLink)) + { + var link = new DoubletLink(raw); + var name = _logStore.GetName(link.Index); + if (string.IsNullOrEmpty(name)) continue; + + if (name.StartsWith(TransitionNamePrefix, StringComparison.Ordinal)) + { + var payload = name.Substring(TransitionNamePrefix.Length); + if (Transition.TryParse(payload, out var transition)) + { + InsertOrdered(_log, transition); + if (transition.Sequence > _sequenceCounter) + { + _sequenceCounter = transition.Sequence; + } + } + } + else if (name.StartsWith(CommitMarkerPrefix, StringComparison.Ordinal)) + { + if (Guid.TryParseExact(name.Substring(CommitMarkerPrefix.Length), "N", out var txId)) + { + _committed.Add(txId); + } + } + else if (name.StartsWith(RollbackMarkerPrefix, StringComparison.Ordinal)) + { + if (Guid.TryParseExact(name.Substring(RollbackMarkerPrefix.Length), "N", out var txId)) + { + _rolledBack.Add(txId); + } + } + else if (name.StartsWith(AppliedMarkerPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(AppliedMarkerPrefix.Length); + if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + _applied.Add(seq); + if (seq > _appliedSequence) _appliedSequence = seq; + } + } + } + + _replaying = true; + try + { + // Re-apply committed transitions whose side-effects were lost + // (e.g. async crash before checkpoint). Only those not yet + // recorded as applied are touched. + foreach (var transition in _log) + { + if (!_committed.Contains(transition.TransactionId)) continue; + if (_applied.Contains(transition.Sequence)) continue; + TryApplyTransition(transition, recordApplied: true); + } + + // Auto-rollback transitions written but never committed and never + // rolled back: this is the crash-mid-transaction case (R10). + foreach (var transition in _log.OrderByDescending(t => t.Sequence)) + { + if (_committed.Contains(transition.TransactionId)) continue; + if (_rolledBack.Contains(transition.TransactionId)) continue; + TryRevertTransition(transition); + } + + // Mark recovered-but-incomplete transactions as rolled back so + // we don't try to revert them on the next open. + var pendingTxIds = _log + .Where(t => !_committed.Contains(t.TransactionId) && !_rolledBack.Contains(t.TransactionId)) + .Select(t => t.TransactionId) + .Distinct() + .ToList(); + foreach (var txId in pendingTxIds) + { + _rolledBack.Add(txId); + WriteMarker(RollbackMarkerPrefix + txId.ToString("N")); + } + } + finally + { + _replaying = false; + } + } } - catch + + // Disposal -------------------------------------------------------------- + + /// + /// Stops the background worker. The wrapped data store and log store + /// are not disposed here; callers are expected to own those. + /// + public void Dispose() => Shutdown(); + + /// + /// Stops the background worker. Kept as a named method for backwards + /// compatibility; delegates to it. + /// + public void Shutdown() { - // best-effort shutdown + if (_disposed) return; + _disposed = true; + try + { + _asyncQueue.CompleteAdding(); + _backgroundCts.Cancel(); + _backgroundWorker.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // best-effort shutdown + } + _asyncQueue.Dispose(); + _backgroundCts.Dispose(); } - _asyncQueue.Dispose(); - _backgroundCts.Dispose(); - } - // Commit / rollback paths ----------------------------------------------- + // Commit / rollback paths ----------------------------------------------- - internal void OnCommit(Transaction transaction, bool forceAsync) - { - bool runAsync; - Transition[] transitions; - lock (_lock) + internal void OnCommit(Transaction transaction, bool forceAsync) { - if (transaction.IsCommitted || transaction.IsRolledBack) return; - _committed.Add(transaction.Id); - transaction.MarkCommitted(); - WriteMarker(CommitMarkerPrefix + transaction.Id.ToString("N")); - if (_current == transaction) _current = null; - runAsync = forceAsync || _commitMode == CommitMode.Async; - transitions = transaction.Transitions.ToArray(); - Trace($"Committed tx {transaction.Id:N} (mode={(runAsync ? "async" : "sync")}, transitions={transitions.Length})."); + bool runAsync; + Transition[] transitions; + lock (_lock) + { + if (transaction.IsCommitted || transaction.IsRolledBack) return; + _committed.Add(transaction.Id); + transaction.MarkCommitted(); + WriteMarker(CommitMarkerPrefix + transaction.Id.ToString("N")); + if (_current == transaction) _current = null; + runAsync = forceAsync || _commitMode == CommitMode.Async; + transitions = transaction.Transitions.ToArray(); + Trace($"Committed tx {transaction.Id:N} (mode={(runAsync ? "async" : "sync")}, transitions={transitions.Length})."); + } + + if (runAsync) + { + _asyncQueue.Add(() => Task.Run(() => ApplyTransitionsAsync(transitions))); + } + else + { + lock (_lock) + { + foreach (var transition in transitions) + { + MarkApplied(transition); + } + EnforceRetentionLocked(); + } + } } - if (runAsync) + internal void OnRollback(Transaction transaction) { - _asyncQueue.Add(() => Task.Run(() => ApplyTransitionsAsync(transitions))); + lock (_lock) + { + if (transaction.IsCommitted || transaction.IsRolledBack) return; + transaction.MarkRolledBack(); + _rolledBack.Add(transaction.Id); + _replaying = true; + try + { + foreach (var transition in transaction.Transitions.AsEnumerable().Reverse()) + { + TryRevertTransition(transition); + } + } + finally + { + _replaying = false; + } + WriteMarker(RollbackMarkerPrefix + transaction.Id.ToString("N")); + if (_current == transaction) _current = null; + Trace($"Rolled back tx {transaction.Id:N} ({transaction.Transitions.Count} transitions)."); + EnforceRetentionLocked(); + } } - else + + private void TryRevertTransition(Transition transition) { - lock (_lock) - { - foreach (var transition in transitions) + try + { + if (transition.Before.Index == 0) + { + DeleteIfExists(transition.After.Index); + } + else + { + RestoreLink(transition.Before); + } + } + catch (Exception ex) { - MarkApplied(transition); - } - EnforceRetentionLocked(); - } - } - } - - internal void OnRollback(Transaction transaction) - { - lock (_lock) - { - if (transaction.IsCommitted || transaction.IsRolledBack) return; - transaction.MarkRolledBack(); - _rolledBack.Add(transaction.Id); - _replaying = true; - try - { - foreach (var transition in transaction.Transitions.AsEnumerable().Reverse()) - { - TryRevertTransition(transition); - } - } - finally - { - _replaying = false; - } - WriteMarker(RollbackMarkerPrefix + transaction.Id.ToString("N")); - if (_current == transaction) _current = null; - Trace($"Rolled back tx {transaction.Id:N} ({transaction.Transitions.Count} transitions)."); - EnforceRetentionLocked(); - } - } - - private void TryRevertTransition(Transition transition) - { - try - { - if (transition.Before.Index == 0) - { - DeleteIfExists(transition.After.Index); - } - else - { - RestoreLink(transition.Before); - } - } - catch (Exception ex) - { - Trace($"Failed to revert transition seq={transition.Sequence}: {ex.Message}"); - } - } - - /// - /// Revert a single transition's side-effect against the data store - /// without writing a new log entry. Intended for use by higher-level - /// decorators (e.g. version control) that need to drive replay/rewind - /// without producing additional transitions. - /// - public void RevertTransition(Transition transition) - { - lock (_lock) - { - _replaying = true; - try - { - TryRevertTransition(transition); - } - finally - { - _replaying = false; - } - } - } - - /// - /// Apply a single transition's side-effect against the data store - /// without writing a new log entry. Intended for use by higher-level - /// decorators (e.g. version control) that need to drive replay/rewind - /// without producing additional transitions. - /// - public void ApplyTransition(Transition transition) - { - lock (_lock) - { - _replaying = true; - try - { - TryApplyTransition(transition, recordApplied: false); - } - finally - { - _replaying = false; - } - } - } - - private void TryApplyTransition(Transition transition, bool recordApplied) - { - try - { - if (transition.After.Index == 0) - { - DeleteIfExists(transition.Before.Index); - } - else - { - RestoreLink(transition.After); - } - - if (recordApplied) - { - MarkApplied(transition); - } - } - catch (Exception ex) - { - Trace($"Failed to apply transition seq={transition.Sequence}: {ex.Message}"); - } - } - - private void MarkApplied(Transition transition) - { - if (_applied.Add(transition.Sequence)) - { - WriteMarker(AppliedMarkerPrefix + transition.Sequence.ToString(CultureInfo.InvariantCulture)); - if (transition.Sequence > _appliedSequence) _appliedSequence = transition.Sequence; - } - } - - private void RestoreLink(DoubletLink link) - { - if (link.Index == 0) return; - if (!_inner.Exists(link.Index)) - { - _inner.EnsureCreated(link.Index); - } - _inner.Update( - new DoubletLink(link.Index, _inner.Constants.Any, _inner.Constants.Any), - new DoubletLink(link.Index, link.Source, link.Target), - null); - } - - private void DeleteIfExists(uint index) - { - if (index != 0 && _inner.Exists(index)) - { - _inner.Delete(new DoubletLink(index, _inner.Constants.Any, _inner.Constants.Any), null); - } - } - - internal void WriteTransitionToLog(Transition transition) - { - var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); - var name = TransitionNamePrefix + transition.Serialize(); - _logStore.SetName(link, name); - } - - internal void WriteMarker(string name) - { - var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); - _logStore.SetName(link, name); - } - - private static void InsertOrdered(List list, Transition transition) - { - var lo = 0; - var hi = list.Count; - while (lo < hi) - { - var mid = (lo + hi) >> 1; - if (list[mid].Sequence < transition.Sequence) lo = mid + 1; else hi = mid; - } - list.Insert(lo, transition); - } - - private void EnforceRetentionLocked() - { - switch (_retentionPolicy) - { - case LogRetentionPolicy.Infinite: - return; - case LogRetentionPolicy.Sized sized: - EnforceSizedLocked(sized.MaxTransitions); - break; - case LogRetentionPolicy.Chunked chunked: - EnforceChunkedLocked(chunked); - break; - } - } - - private void EnforceSizedLocked(long maxTransitions) - { - if (maxTransitions <= 0) return; - while (_log.Count > maxTransitions) + Trace($"Failed to revert transition seq={transition.Sequence}: {ex.Message}"); + } + } + + /// + /// Revert a single transition's side-effect against the data store + /// without writing a new log entry. Intended for use by higher-level + /// decorators (e.g. version control) that need to drive replay/rewind + /// without producing additional transitions. + /// + public void RevertTransition(Transition transition) { - var head = _log[0]; - if (!_applied.Contains(head.Sequence)) - { - // R7: never drop an un-applied transition. - TryApplyTransition(head, recordApplied: true); - if (!_applied.Contains(head.Sequence)) break; - } - _log.RemoveAt(0); - Trace($"Dropped applied transition seq={head.Sequence} per sized retention."); + lock (_lock) + { + _replaying = true; + try + { + TryRevertTransition(transition); + } + finally + { + _replaying = false; + } + } } - } - private void EnforceChunkedLocked(LogRetentionPolicy.Chunked chunked) - { - if (chunked.ChunkSize <= 0) return; - if (_log.Count < chunked.ChunkSize) return; + /// + /// Apply a single transition's side-effect against the data store + /// without writing a new log entry. Intended for use by higher-level + /// decorators (e.g. version control) that need to drive replay/rewind + /// without producing additional transitions. + /// + public void ApplyTransition(Transition transition) + { + lock (_lock) + { + _replaying = true; + try + { + TryApplyTransition(transition, recordApplied: false); + } + finally + { + _replaying = false; + } + } + } - var chunk = _log.Take((int)chunked.ChunkSize).ToList(); - foreach (var transition in chunk) + private void TryApplyTransition(Transition transition, bool recordApplied) { - if (!_applied.Contains(transition.Sequence)) - { - TryApplyTransition(transition, recordApplied: true); - if (!_applied.Contains(transition.Sequence)) return; // never drop unapplied - } - } - - try + try + { + if (transition.After.Index == 0) + { + DeleteIfExists(transition.Before.Index); + } + else + { + RestoreLink(transition.After); + } + + if (recordApplied) + { + MarkApplied(transition); + } + } + catch (Exception ex) + { + Trace($"Failed to apply transition seq={transition.Sequence}: {ex.Message}"); + } + } + + private void MarkApplied(Transition transition) { - Directory.CreateDirectory(chunked.ArchiveDirectory); - var fileName = Path.Combine( - chunked.ArchiveDirectory, - $"transitions-chunk-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{Guid.NewGuid():N}.log"); - using (var writer = new StreamWriter(fileName, append: false)) - { - foreach (var t in chunk) writer.WriteLine(t.Serialize()); - } - Trace($"Archived {chunk.Count} transitions to {fileName}."); - } - catch (Exception ex) - { - Trace($"Chunk archive failed: {ex.Message}"); - return; - } - - _log.RemoveRange(0, chunk.Count); - } - - private async Task ApplyTransitionsAsync(IReadOnlyList transitions) - { - foreach (var transition in transitions) - { - try - { - lock (_lock) + if (_applied.Add(transition.Sequence)) { - // Side-effects normally already applied (inner store ran - // them inline). Re-apply only if needed and mark applied. - MarkApplied(transition); + WriteMarker(AppliedMarkerPrefix + transition.Sequence.ToString(CultureInfo.InvariantCulture)); + if (transition.Sequence > _appliedSequence) _appliedSequence = transition.Sequence; } - } - catch - { - // Recovery on next open will resume. - } } - lock (_lock) + private void RestoreLink(DoubletLink link) { - EnforceRetentionLocked(); + if (link.Index == 0) return; + if (!_inner.Exists(link.Index)) + { + _inner.EnsureCreated(link.Index); + } + _inner.Update( + new DoubletLink(link.Index, _inner.Constants.Any, _inner.Constants.Any), + new DoubletLink(link.Index, link.Source, link.Target), + null); } - await Task.CompletedTask; - } - private void RunBackgroundWorker() - { - try + private void DeleteIfExists(uint index) { - foreach (var work in _asyncQueue.GetConsumingEnumerable(_backgroundCts.Token)) - { - try { work().GetAwaiter().GetResult(); } catch { /* ignored */ } - } + if (index != 0 && _inner.Exists(index)) + { + _inner.Delete(new DoubletLink(index, _inner.Constants.Any, _inner.Constants.Any), null); + } } - catch (OperationCanceledException) { /* expected */ } - catch { /* background should never blow up */ } - } - private void Trace(string message) - { - if (_trace) Console.WriteLine($"[Transactions] {message}"); - } + internal void WriteTransitionToLog(Transition transition) + { + var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); + var name = TransitionNamePrefix + transition.Serialize(); + _logStore.SetName(link, name); + } - /// Conventional sidecar filename for the transitions log. - public static string MakeTransitionsDatabaseFilename(string databaseFilename) - { - ArgumentNullException.ThrowIfNull(databaseFilename); - var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); - var directory = Path.GetDirectoryName(databaseFilename); - return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.transitions.links"); - } + internal void WriteMarker(string name) + { + var link = _logStore.CreateAndUpdate(_logStore.Constants.Null, _logStore.Constants.Null); + _logStore.SetName(link, name); + } - // Transaction handle ---------------------------------------------------- + private static void InsertOrdered(List list, Transition transition) + { + var lo = 0; + var hi = list.Count; + while (lo < hi) + { + var mid = (lo + hi) >> 1; + if (list[mid].Sequence < transition.Sequence) lo = mid + 1; else hi = mid; + } + list.Insert(lo, transition); + } - internal sealed class Transaction : ITransaction - { - private readonly TransactionsDecorator _owner; - private readonly List _transitions = new(); - private readonly bool _autoCommit; - private int _state; // 0 = open, 1 = committed, 2 = rolled back + private void EnforceRetentionLocked() + { + switch (_retentionPolicy) + { + case LogRetentionPolicy.Infinite: + return; + case LogRetentionPolicy.Sized sized: + EnforceSizedLocked(sized.MaxTransitions); + break; + case LogRetentionPolicy.Chunked chunked: + EnforceChunkedLocked(chunked); + break; + } + } - public Transaction(TransactionsDecorator owner, bool autoCommit) + private void EnforceSizedLocked(long maxTransitions) { - _owner = owner; - _autoCommit = autoCommit; - Id = Guid.NewGuid(); - StartedAt = DateTimeOffset.UtcNow; + if (maxTransitions <= 0) return; + while (_log.Count > maxTransitions) + { + var head = _log[0]; + if (!_applied.Contains(head.Sequence)) + { + // R7: never drop an un-applied transition. + TryApplyTransition(head, recordApplied: true); + if (!_applied.Contains(head.Sequence)) break; + } + _log.RemoveAt(0); + Trace($"Dropped applied transition seq={head.Sequence} per sized retention."); + } + } + + private void EnforceChunkedLocked(LogRetentionPolicy.Chunked chunked) + { + if (chunked.ChunkSize <= 0) return; + if (_log.Count < chunked.ChunkSize) return; + + var chunk = _log.Take((int)chunked.ChunkSize).ToList(); + foreach (var transition in chunk) + { + if (!_applied.Contains(transition.Sequence)) + { + TryApplyTransition(transition, recordApplied: true); + if (!_applied.Contains(transition.Sequence)) return; // never drop unapplied + } + } + + try + { + Directory.CreateDirectory(chunked.ArchiveDirectory); + var fileName = Path.Combine( + chunked.ArchiveDirectory, + $"transitions-chunk-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{Guid.NewGuid():N}.log"); + using (var writer = new StreamWriter(fileName, append: false)) + { + foreach (var t in chunk) writer.WriteLine(t.Serialize()); + } + Trace($"Archived {chunk.Count} transitions to {fileName}."); + } + catch (Exception ex) + { + Trace($"Chunk archive failed: {ex.Message}"); + return; + } + + _log.RemoveRange(0, chunk.Count); } - public Guid Id { get; } - public DateTimeOffset StartedAt { get; } - public bool IsCommitted => _state == 1; - public bool IsRolledBack => _state == 2; - public IReadOnlyList Transitions => _transitions; + private async Task ApplyTransitionsAsync(IReadOnlyList transitions) + { + foreach (var transition in transitions) + { + try + { + lock (_lock) + { + // Side-effects normally already applied (inner store ran + // them inline). Re-apply only if needed and mark applied. + MarkApplied(transition); + } + } + catch + { + // Recovery on next open will resume. + } + } + + lock (_lock) + { + EnforceRetentionLocked(); + } + await Task.CompletedTask; + } - internal void AddTransition(Transition transition) => _transitions.Add(transition); - internal void MarkCommitted() => _state = 1; - internal void MarkRolledBack() => _state = 2; + private void RunBackgroundWorker() + { + try + { + foreach (var work in _asyncQueue.GetConsumingEnumerable(_backgroundCts.Token)) + { + try { work().GetAwaiter().GetResult(); } catch { /* ignored */ } + } + } + catch (OperationCanceledException) { /* expected */ } + catch { /* background should never blow up */ } + } - public void Commit() => _owner.OnCommit(this, forceAsync: false); + private void Trace(string message) + { + if (_trace) Console.WriteLine($"[Transactions] {message}"); + } - public Task CommitAsync(CancellationToken cancellationToken = default) + /// Conventional sidecar filename for the transitions log. + public static string MakeTransitionsDatabaseFilename(string databaseFilename) { - cancellationToken.ThrowIfCancellationRequested(); - _owner.OnCommit(this, forceAsync: true); - return Task.CompletedTask; + ArgumentNullException.ThrowIfNull(databaseFilename); + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.transitions.links"); } - public void Rollback() => _owner.OnRollback(this); + // Transaction handle ---------------------------------------------------- - public void Dispose() + internal sealed class Transaction : ITransaction { - if (_state == 0) - { - // Per-write auto transactions should not auto-rollback if the - // caller forgot to commit (Commit happens automatically in - // RunWrite); for explicit user transactions, dispose = rollback. - if (_autoCommit) + private readonly TransactionsDecorator _owner; + private readonly List _transitions = new(); + private readonly bool _autoCommit; + private int _state; // 0 = open, 1 = committed, 2 = rolled back + + public Transaction(TransactionsDecorator owner, bool autoCommit) { - _owner.OnCommit(this, forceAsync: false); + _owner = owner; + _autoCommit = autoCommit; + Id = Guid.NewGuid(); + StartedAt = DateTimeOffset.UtcNow; } - else + + public Guid Id { get; } + public DateTimeOffset StartedAt { get; } + public bool IsCommitted => _state == 1; + public bool IsRolledBack => _state == 2; + public IReadOnlyList Transitions => _transitions; + + internal void AddTransition(Transition transition) => _transitions.Add(transition); + internal void MarkCommitted() => _state = 1; + internal void MarkRolledBack() => _state = 2; + + public void Commit() => _owner.OnCommit(this, forceAsync: false); + + public Task CommitAsync(CancellationToken cancellationToken = default) { - _owner.OnRollback(this); + cancellationToken.ThrowIfCancellationRequested(); + _owner.OnCommit(this, forceAsync: true); + return Task.CompletedTask; + } + + public void Rollback() => _owner.OnRollback(this); + + public void Dispose() + { + if (_state == 0) + { + // Per-write auto transactions should not auto-rollback if the + // caller forgot to commit (Commit happens automatically in + // RunWrite); for explicit user transactions, dispose = rollback. + if (_autoCommit) + { + _owner.OnCommit(this, forceAsync: false); + } + else + { + _owner.OnRollback(this); + } + } } - } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs index bf9bba9..147204c 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Library/VersionControlDecorator.cs @@ -18,17 +18,17 @@ public sealed record BranchInfo(string Name, string? Parent, long ForkSeq, long /// public interface IVersionControlLinks : INamedTypesLinks { - string CurrentBranch { get; } - long CurrentSequence { get; } - ITransaction BeginTransaction(); - Task BeginTransactionAsync(CancellationToken cancellationToken = default); - IReadOnlyList ListBranches(); - IReadOnlyDictionary ListTags(); - void Branch(string name, long? from = null); - void SwitchBranch(string name); - void Checkout(long sequence); - void Tag(string name, long? sequence = null); - bool TryGetTag(string name, out long sequence); + string CurrentBranch { get; } + long CurrentSequence { get; } + ITransaction BeginTransaction(); + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + IReadOnlyList ListBranches(); + IReadOnlyDictionary ListTags(); + void Branch(string name, long? from = null); + void SwitchBranch(string name); + void Checkout(long sequence); + void Tag(string name, long? sequence = null); + bool TryGetTag(string name, out long sequence); } /// @@ -38,594 +38,610 @@ public interface IVersionControlLinks : INamedTypesLinks /// () over the transitions log. Optional — when not /// instantiated the underlying transactions decorator behaves identically. /// -public sealed class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks +public sealed class VersionControlDecorator : LinksDecoratorBase, IVersionControlLinks, IDisposable { - /// Default name of the initial branch (analogous to git's main). - public const string DefaultBranchName = "main"; - - internal const string BranchPrefix = "__vc:branch:"; - internal const string TagPrefix = "__vc:tag:"; - internal const string CurrentPrefix = "__vc:current="; - internal const string AppliedPrefix = "__vc:applied="; - internal const string TransitionPrefix = "__vc:trans:"; - - private readonly TransactionsDecorator _transactions; - private readonly INamedTypesLinks _branchesStore; - private readonly object _lock = new(); - private readonly Dictionary _branches = new(StringComparer.Ordinal); - private readonly Dictionary _tags = new(StringComparer.Ordinal); - private readonly Dictionary _transitionBranches = new(); - private readonly Dictionary _branchLinks = new(StringComparer.Ordinal); - private readonly Dictionary _tagLinks = new(StringComparer.Ordinal); - private uint _currentBranchLink; - private uint _appliedLink; - private string _currentBranch = DefaultBranchName; - private long _currentApplied; - private VersionControlTransaction? _activeTransaction; - private readonly bool _trace; - - public VersionControlDecorator( - TransactionsDecorator transactions, - INamedTypesLinks branchesStore, - bool trace = false) - : base(transactions) - { - _transactions = transactions ?? throw new ArgumentNullException(nameof(transactions)); - _branchesStore = branchesStore ?? throw new ArgumentNullException(nameof(branchesStore)); - _trace = trace; - Recover(); - EnsureDefaultBranch(); - } - - public string CurrentBranch { get { lock (_lock) return _currentBranch; } } - public long CurrentSequence { get { lock (_lock) return _currentApplied; } } - - public IReadOnlyList ListBranches() - { - lock (_lock) return _branches.Values.OrderBy(b => b.Name, StringComparer.Ordinal).ToArray(); - } - - public IReadOnlyDictionary ListTags() - { - lock (_lock) return new Dictionary(_tags, StringComparer.Ordinal); - } - - public bool TryGetTag(string name, out long sequence) - { - lock (_lock) return _tags.TryGetValue(name, out sequence); - } - - public ITransaction BeginTransaction() - { - lock (_lock) - { - if (_activeTransaction is not null) - { - throw new InvalidOperationException("Nested version-control transactions are not supported."); - } - - var beforeSequence = _transactions.LastLoggedSequence; - var branchName = _currentBranch; - var inner = _transactions.BeginTransaction(); - _activeTransaction = new VersionControlTransaction(this, inner, branchName, beforeSequence); - return _activeTransaction; - } - } - - public Task BeginTransactionAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(BeginTransaction()); - } - - // -- Write overrides (attribute new transitions to the current branch) -- - - public override uint Create(IList? substitution, WriteHandler? handler) - { - return RunVcWrite(() => _transactions.Create(substitution, handler)); - } - - public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) - { - return RunVcWrite(() => _transactions.Update(restriction, substitution, handler)); - } - - public override uint Delete(IList? restriction, WriteHandler? handler) - { - return RunVcWrite(() => _transactions.Delete(restriction, handler)); - } - - private uint RunVcWrite(Func innerWrite) - { - lock (_lock) - { - var beforeSeq = _transactions.LastLoggedSequence; - var result = innerWrite(); - if (_activeTransaction is null) - { - AttributeNewTransitionsLocked(beforeSeq, _currentBranch); - } - return result; - } - } - - private void AttributeNewTransitionsLocked(long beforeSeq, string branchName) - { - var afterSeq = _transactions.LastLoggedSequence; - if (afterSeq <= beforeSeq) return; - - for (var s = beforeSeq + 1; s <= afterSeq; s++) - { - _transitionBranches[s] = branchName; - WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={branchName}"); - } - if (_branches.TryGetValue(branchName, out var info)) - { - var updated = info with { Head = afterSeq }; - _branches[branchName] = updated; - UpdateBranchLinkLocked(updated); - } - if (string.Equals(_currentBranch, branchName, StringComparison.Ordinal)) - { - _currentApplied = afterSeq; - SetAppliedLocked(afterSeq); - } - } - - // -- Branching --------------------------------------------------------- - - public void Branch(string name, long? from = null) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Branch name must not be empty.", nameof(name)); - } - lock (_lock) - { - EnsureNoOpenTransactionLocked(nameof(Branch)); - if (_branches.ContainsKey(name)) - { - throw new InvalidOperationException($"Branch '{name}' already exists."); - } - var parent = _currentBranch; - var forkSeq = from ?? _currentApplied; - if (forkSeq < 0) - { - throw new ArgumentOutOfRangeException(nameof(from), forkSeq, "Fork point cannot be negative."); - } - if (forkSeq > 0) - { - var path = BuildBranchSeqsLocked(parent); - if (!path.Contains(forkSeq)) + /// Default name of the initial branch (analogous to git's main). + public const string DefaultBranchName = "main"; + + internal const string BranchPrefix = "__vc:branch:"; + internal const string TagPrefix = "__vc:tag:"; + internal const string CurrentPrefix = "__vc:current="; + internal const string AppliedPrefix = "__vc:applied="; + internal const string TransitionPrefix = "__vc:trans:"; + + private readonly TransactionsDecorator _transactions; + private readonly INamedTypesLinks _branchesStore; + private readonly object _lock = new(); + private readonly Dictionary _branches = new(StringComparer.Ordinal); + private readonly Dictionary _tags = new(StringComparer.Ordinal); + private readonly Dictionary _transitionBranches = new(); + private readonly Dictionary _branchLinks = new(StringComparer.Ordinal); + private readonly Dictionary _tagLinks = new(StringComparer.Ordinal); + private uint _currentBranchLink; + private uint _appliedLink; + private string _currentBranch = DefaultBranchName; + private long _currentApplied; + private VersionControlTransaction? _activeTransaction; + private readonly bool _trace; + + /// + /// Rolls back and releases the transaction that is still open, if any. + /// The wrapped transactions decorator and branches store are owned by + /// the caller and are deliberately left untouched. + /// + public void Dispose() + { + VersionControlTransaction? active; + lock (_lock) { - throw new InvalidOperationException($"Fork point {forkSeq} is not reachable on branch '{parent}'."); + active = _activeTransaction; + _activeTransaction = null; } - } - CreateBranchLocked(name, parent, forkSeq, head: forkSeq); - Trace($"Created branch '{name}' from '{parent}' at seq {forkSeq}."); + active?.Dispose(); } - } - public void SwitchBranch(string name) - { - lock (_lock) + public VersionControlDecorator( + TransactionsDecorator transactions, + INamedTypesLinks branchesStore, + bool trace = false) + : base(transactions) { - EnsureNoOpenTransactionLocked(nameof(SwitchBranch)); - if (!_branches.TryGetValue(name, out var target)) - { - throw new InvalidOperationException($"Unknown branch '{name}'."); - } - var targetPath = BuildBranchSeqsLocked(name); - ApplyDiffToLocked(targetPath, newBranch: name); - Trace($"Switched to branch '{name}' at seq {_currentApplied}."); + _transactions = transactions ?? throw new ArgumentNullException(nameof(transactions)); + _branchesStore = branchesStore ?? throw new ArgumentNullException(nameof(branchesStore)); + _trace = trace; + Recover(); + EnsureDefaultBranch(); } - } - public void Checkout(long sequence) - { - lock (_lock) + public string CurrentBranch { get { lock (_lock) return _currentBranch; } } + public long CurrentSequence { get { lock (_lock) return _currentApplied; } } + + public IReadOnlyList ListBranches() { - EnsureNoOpenTransactionLocked(nameof(Checkout)); - if (sequence < 0) - { - throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must be non-negative."); - } - var path = BuildBranchSeqsLocked(_currentBranch); - if (sequence > 0 && !path.Contains(sequence)) - { - throw new InvalidOperationException($"Sequence {sequence} is not reachable on branch '{_currentBranch}'."); - } - ApplyDiffToLocked(path.Where(s => s <= sequence).ToList(), newBranch: _currentBranch); - Trace($"Checked out seq {sequence} on branch '{_currentBranch}'."); - } - } - - public void Tag(string name, long? sequence = null) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Tag name must not be empty.", nameof(name)); - } - lock (_lock) - { - EnsureNoOpenTransactionLocked(nameof(Tag)); - var seq = sequence ?? _currentApplied; - if (seq < 0) - { - throw new ArgumentOutOfRangeException(nameof(sequence), seq, "Tag sequence must be non-negative."); - } - _tags[name] = seq; - UpdateTagLinkLocked(name, seq); - Trace($"Created tag '{name}' at seq {seq}."); - } - } - - // -- Path / diff helpers ---------------------------------------------- - - private void ApplyDiffToLocked(List targetPath, string newBranch) - { - var currentPath = BuildBranchSeqsLocked(_currentBranch) - .Where(s => s <= _currentApplied) - .ToList(); - - var common = 0; - var max = Math.Min(currentPath.Count, targetPath.Count); - while (common < max && currentPath[common] == targetPath[common]) common++; - - for (var i = currentPath.Count - 1; i >= common; i--) - { - var transition = FindTransition(currentPath[i]); - if (transition is not null) - { - _transactions.RevertTransition(transition.Value); - } - } - for (var i = common; i < targetPath.Count; i++) - { - var transition = FindTransition(targetPath[i]); - if (transition is not null) - { - _transactions.ApplyTransition(transition.Value); - } - } - - if (!ReferenceEquals(newBranch, _currentBranch)) - { - _currentBranch = newBranch; - SetCurrentBranchLocked(newBranch); - } - _currentApplied = targetPath.Count == 0 ? 0 : targetPath[^1]; - SetAppliedLocked(_currentApplied); - } - - private void EnsureNoOpenTransactionLocked(string operation) - { - if (_activeTransaction is not null) - { - throw new InvalidOperationException($"{operation} is not allowed while a version-control transaction is open."); + lock (_lock) return _branches.Values.OrderBy(b => b.Name, StringComparer.Ordinal).ToArray(); } - } - - private void CommitVersionTransaction(VersionControlTransaction transaction) - { - lock (_lock) - { - transaction.Inner.Commit(); - if (ReferenceEquals(_activeTransaction, transaction)) - { - _activeTransaction = null; - AttributeNewTransitionsLocked(transaction.BeforeSequence, transaction.BranchName); - } + + public IReadOnlyDictionary ListTags() + { + lock (_lock) return new Dictionary(_tags, StringComparer.Ordinal); } - } - private void RollbackVersionTransaction(VersionControlTransaction transaction) - { - lock (_lock) - { - try - { - transaction.Inner.Rollback(); - } - finally - { - if (ReferenceEquals(_activeTransaction, transaction)) + public bool TryGetTag(string name, out long sequence) + { + lock (_lock) return _tags.TryGetValue(name, out sequence); + } + + public ITransaction BeginTransaction() + { + lock (_lock) { - _activeTransaction = null; + if (_activeTransaction is not null) + { + throw new InvalidOperationException("Nested version-control transactions are not supported."); + } + + var beforeSequence = _transactions.LastLoggedSequence; + var branchName = _currentBranch; + var inner = _transactions.BeginTransaction(); + _activeTransaction = new VersionControlTransaction(this, inner, branchName, beforeSequence); + return _activeTransaction; } - } } - } - private List BuildBranchSeqsLocked(string branchName) - { - return BuildBranchSeqsLocked(branchName, new HashSet(StringComparer.Ordinal)); - } + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(BeginTransaction()); + } + + // -- Write overrides (attribute new transitions to the current branch) -- + + public override uint Create(IList? substitution, WriteHandler? handler) + { + return RunVcWrite(() => _transactions.Create(substitution, handler)); + } - private List BuildBranchSeqsLocked(string branchName, HashSet visited) - { - if (!_branches.TryGetValue(branchName, out var info)) return new List(); - if (!visited.Add(branchName)) return new List(); - var seqs = new List(); - if (info.Parent is not null && _branches.ContainsKey(info.Parent)) + public override uint Update(IList? restriction, IList? substitution, WriteHandler? handler) { - seqs.AddRange(BuildBranchSeqsLocked(info.Parent, visited).Where(s => s <= info.ForkSeq)); + return RunVcWrite(() => _transactions.Update(restriction, substitution, handler)); } - var own = _transitionBranches - .Where(p => p.Value == branchName && p.Key <= info.Head) - .Select(p => p.Key) - .OrderBy(s => s); - seqs.AddRange(own); - return seqs; - } - private Transition? FindTransition(long sequence) - { - foreach (var t in _transactions.Log) + public override uint Delete(IList? restriction, WriteHandler? handler) { - if (t.Sequence == sequence) return t; + return RunVcWrite(() => _transactions.Delete(restriction, handler)); } - return null; - } - // -- Persistence helpers ---------------------------------------------- + private uint RunVcWrite(Func innerWrite) + { + lock (_lock) + { + var beforeSeq = _transactions.LastLoggedSequence; + var result = innerWrite(); + if (_activeTransaction is null) + { + AttributeNewTransitionsLocked(beforeSeq, _currentBranch); + } + return result; + } + } - private void EnsureDefaultBranch() - { - lock (_lock) + private void AttributeNewTransitionsLocked(long beforeSeq, string branchName) { - var existing = _transactions.LastLoggedSequence; - if (!_branches.ContainsKey(DefaultBranchName)) - { - // Pre-existing transitions are attributed to the default branch. - for (var s = 1L; s <= existing; s++) + var afterSeq = _transactions.LastLoggedSequence; + if (afterSeq <= beforeSeq) return; + + for (var s = beforeSeq + 1; s <= afterSeq; s++) { - if (!_transitionBranches.ContainsKey(s)) - { - _transitionBranches[s] = DefaultBranchName; - WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={DefaultBranchName}"); - } + _transitionBranches[s] = branchName; + WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={branchName}"); } - CreateBranchLocked(DefaultBranchName, parent: null, forkSeq: 0, head: existing); - _currentBranch = DefaultBranchName; - _currentApplied = existing; - SetCurrentBranchLocked(DefaultBranchName); - SetAppliedLocked(existing); - } - else if (_currentBranchLink == 0) - { - SetCurrentBranchLocked(_currentBranch); - } - } - } - - private void CreateBranchLocked(string name, string? parent, long forkSeq, long head) - { - var info = new BranchInfo(name, parent, forkSeq, head); - _branches[name] = info; - UpdateBranchLinkLocked(info); - } - - private void UpdateBranchLinkLocked(BranchInfo info) - { - var nameMarker = EncodeBranchMarker(info); - if (!_branchLinks.TryGetValue(info.Name, out var link)) - { - link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - _branchLinks[info.Name] = link; - } - _branchesStore.SetName(link, nameMarker); - } - - private void UpdateTagLinkLocked(string name, long seq) - { - var nameMarker = $"{TagPrefix}{name}={seq.ToString(CultureInfo.InvariantCulture)}"; - if (!_tagLinks.TryGetValue(name, out var link)) - { - link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - _tagLinks[name] = link; - } - _branchesStore.SetName(link, nameMarker); - } - - private void SetCurrentBranchLocked(string name) - { - _currentBranch = name; - if (_currentBranchLink == 0) - { - _currentBranchLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - } - _branchesStore.SetName(_currentBranchLink, $"{CurrentPrefix}{name}"); - } - - private void SetAppliedLocked(long seq) - { - if (_appliedLink == 0) - { - _appliedLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - } - _branchesStore.SetName(_appliedLink, $"{AppliedPrefix}{seq.ToString(CultureInfo.InvariantCulture)}"); - } - - private void WriteImmutableMarker(string name) - { - var link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); - _branchesStore.SetName(link, name); - } - - private static string EncodeBranchMarker(BranchInfo info) - { - var parent = info.Parent ?? string.Empty; - return string.Concat( - BranchPrefix, - info.Name, - ":parent=", parent, - ":fork=", info.ForkSeq.ToString(CultureInfo.InvariantCulture), - ":head=", info.Head.ToString(CultureInfo.InvariantCulture)); - } - - private static bool TryDecodeBranchMarker(string text, out BranchInfo info) - { - info = default!; - if (!text.StartsWith(BranchPrefix, StringComparison.Ordinal)) return false; - var rest = text.Substring(BranchPrefix.Length); - var parentIdx = rest.IndexOf(":parent=", StringComparison.Ordinal); - if (parentIdx < 0) return false; - var name = rest.Substring(0, parentIdx); - rest = rest.Substring(parentIdx + ":parent=".Length); - var forkIdx = rest.IndexOf(":fork=", StringComparison.Ordinal); - if (forkIdx < 0) return false; - var parentText = rest.Substring(0, forkIdx); - rest = rest.Substring(forkIdx + ":fork=".Length); - var headIdx = rest.IndexOf(":head=", StringComparison.Ordinal); - if (headIdx < 0) return false; - var forkText = rest.Substring(0, headIdx); - var headText = rest.Substring(headIdx + ":head=".Length); - if (!long.TryParse(forkText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fork)) return false; - if (!long.TryParse(headText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var head)) return false; - info = new BranchInfo(name, parentText.Length == 0 ? null : parentText, fork, head); - return true; - } - - public void Recover() - { - lock (_lock) - { - _branches.Clear(); - _tags.Clear(); - _transitionBranches.Clear(); - _branchLinks.Clear(); - _tagLinks.Clear(); - _currentBranch = DefaultBranchName; - _currentBranchLink = 0; - _appliedLink = 0; - _currentApplied = 0; - - var any = _branchesStore.Constants.Any; - var anyLink = new DoubletLink(any, any, any); - foreach (var raw in _branchesStore.All(anyLink)) - { - var link = new DoubletLink(raw); - var name = _branchesStore.GetName(link.Index); - if (string.IsNullOrEmpty(name)) continue; - - if (name.StartsWith(BranchPrefix, StringComparison.Ordinal)) + if (_branches.TryGetValue(branchName, out var info)) { - if (TryDecodeBranchMarker(name, out var info)) - { - _branches[info.Name] = info; - _branchLinks[info.Name] = link.Index; - } + var updated = info with { Head = afterSeq }; + _branches[branchName] = updated; + UpdateBranchLinkLocked(updated); } - else if (name.StartsWith(CurrentPrefix, StringComparison.Ordinal)) + if (string.Equals(_currentBranch, branchName, StringComparison.Ordinal)) { - _currentBranch = name.Substring(CurrentPrefix.Length); - _currentBranchLink = link.Index; + _currentApplied = afterSeq; + SetAppliedLocked(afterSeq); } - else if (name.StartsWith(AppliedPrefix, StringComparison.Ordinal)) + } + + // -- Branching --------------------------------------------------------- + + public void Branch(string name, long? from = null) + { + if (string.IsNullOrWhiteSpace(name)) { - var rest = name.Substring(AppliedPrefix.Length); - if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) - { - _currentApplied = seq; - _appliedLink = link.Index; - } + throw new ArgumentException("Branch name must not be empty.", nameof(name)); } - else if (name.StartsWith(TagPrefix, StringComparison.Ordinal)) + lock (_lock) { - var rest = name.Substring(TagPrefix.Length); - var eq = rest.IndexOf('='); - if (eq > 0) - { - var tagName = rest.Substring(0, eq); - if (long.TryParse(rest.Substring(eq + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var tagSeq)) + EnsureNoOpenTransactionLocked(nameof(Branch)); + if (_branches.ContainsKey(name)) { - _tags[tagName] = tagSeq; - _tagLinks[tagName] = link.Index; + throw new InvalidOperationException($"Branch '{name}' already exists."); } - } + var parent = _currentBranch; + var forkSeq = from ?? _currentApplied; + if (forkSeq < 0) + { + throw new ArgumentOutOfRangeException(nameof(from), forkSeq, "Fork point cannot be negative."); + } + if (forkSeq > 0) + { + var path = BuildBranchSeqsLocked(parent); + if (!path.Contains(forkSeq)) + { + throw new InvalidOperationException($"Fork point {forkSeq} is not reachable on branch '{parent}'."); + } + } + CreateBranchLocked(name, parent, forkSeq, head: forkSeq); + Trace($"Created branch '{name}' from '{parent}' at seq {forkSeq}."); } - else if (name.StartsWith(TransitionPrefix, StringComparison.Ordinal)) + } + + public void SwitchBranch(string name) + { + lock (_lock) { - var rest = name.Substring(TransitionPrefix.Length); - var colon = rest.IndexOf(":branch=", StringComparison.Ordinal); - if (colon > 0 && - long.TryParse(rest.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) - { - var branchName = rest.Substring(colon + ":branch=".Length); - _transitionBranches[seq] = branchName; - } + EnsureNoOpenTransactionLocked(nameof(SwitchBranch)); + if (!_branches.TryGetValue(name, out var target)) + { + throw new InvalidOperationException($"Unknown branch '{name}'."); + } + var targetPath = BuildBranchSeqsLocked(name); + ApplyDiffToLocked(targetPath, newBranch: name); + Trace($"Switched to branch '{name}' at seq {_currentApplied}."); } - } } - } - // -- INamedTypes forwarding ------------------------------------------- + public void Checkout(long sequence) + { + lock (_lock) + { + EnsureNoOpenTransactionLocked(nameof(Checkout)); + if (sequence < 0) + { + throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must be non-negative."); + } + var path = BuildBranchSeqsLocked(_currentBranch); + if (sequence > 0 && !path.Contains(sequence)) + { + throw new InvalidOperationException($"Sequence {sequence} is not reachable on branch '{_currentBranch}'."); + } + ApplyDiffToLocked(path.Where(s => s <= sequence).ToList(), newBranch: _currentBranch); + Trace($"Checked out seq {sequence} on branch '{_currentBranch}'."); + } + } - public string? GetName(uint link) => _transactions.GetName(link); - public uint SetName(uint link, string name) => _transactions.SetName(link, name); - public uint GetByName(string name) => _transactions.GetByName(name); - public void RemoveName(uint link) => _transactions.RemoveName(link); + public void Tag(string name, long? sequence = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Tag name must not be empty.", nameof(name)); + } + lock (_lock) + { + EnsureNoOpenTransactionLocked(nameof(Tag)); + var seq = sequence ?? _currentApplied; + if (seq < 0) + { + throw new ArgumentOutOfRangeException(nameof(sequence), seq, "Tag sequence must be non-negative."); + } + _tags[name] = seq; + UpdateTagLinkLocked(name, seq); + Trace($"Created tag '{name}' at seq {seq}."); + } + } - // -- Convenience ------------------------------------------------------ + // -- Path / diff helpers ---------------------------------------------- - /// Conventional sidecar filename for the version-control store. - public static string MakeVersionControlDatabaseFilename(string databaseFilename) - { - ArgumentNullException.ThrowIfNull(databaseFilename); - var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); - var directory = Path.GetDirectoryName(databaseFilename); - return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.versioncontrol.links"); - } + private void ApplyDiffToLocked(List targetPath, string newBranch) + { + var currentPath = BuildBranchSeqsLocked(_currentBranch) + .Where(s => s <= _currentApplied) + .ToList(); - private void Trace(string message) - { - if (_trace) Console.WriteLine($"[VersionControl] {message}"); - } + var common = 0; + var max = Math.Min(currentPath.Count, targetPath.Count); + while (common < max && currentPath[common] == targetPath[common]) common++; - private sealed class VersionControlTransaction : ITransaction - { - private readonly VersionControlDecorator _owner; + for (var i = currentPath.Count - 1; i >= common; i--) + { + var transition = FindTransition(currentPath[i]); + if (transition is not null) + { + _transactions.RevertTransition(transition.Value); + } + } + for (var i = common; i < targetPath.Count; i++) + { + var transition = FindTransition(targetPath[i]); + if (transition is not null) + { + _transactions.ApplyTransition(transition.Value); + } + } - internal VersionControlTransaction( - VersionControlDecorator owner, - ITransaction inner, - string branchName, - long beforeSequence) + if (!ReferenceEquals(newBranch, _currentBranch)) + { + _currentBranch = newBranch; + SetCurrentBranchLocked(newBranch); + } + _currentApplied = targetPath.Count == 0 ? 0 : targetPath[^1]; + SetAppliedLocked(_currentApplied); + } + + private void EnsureNoOpenTransactionLocked(string operation) { - _owner = owner; - Inner = inner; - BranchName = branchName; - BeforeSequence = beforeSequence; + if (_activeTransaction is not null) + { + throw new InvalidOperationException($"{operation} is not allowed while a version-control transaction is open."); + } } - internal ITransaction Inner { get; } - internal string BranchName { get; } - internal long BeforeSequence { get; } + private void CommitVersionTransaction(VersionControlTransaction transaction) + { + lock (_lock) + { + transaction.Inner.Commit(); + if (ReferenceEquals(_activeTransaction, transaction)) + { + _activeTransaction = null; + AttributeNewTransitionsLocked(transaction.BeforeSequence, transaction.BranchName); + } + } + } - public Guid Id => Inner.Id; - public DateTimeOffset StartedAt => Inner.StartedAt; - public bool IsCommitted => Inner.IsCommitted; - public bool IsRolledBack => Inner.IsRolledBack; - public IReadOnlyList Transitions => Inner.Transitions; + private void RollbackVersionTransaction(VersionControlTransaction transaction) + { + lock (_lock) + { + try + { + transaction.Inner.Rollback(); + } + finally + { + if (ReferenceEquals(_activeTransaction, transaction)) + { + _activeTransaction = null; + } + } + } + } - public void Commit() => _owner.CommitVersionTransaction(this); + private List BuildBranchSeqsLocked(string branchName) + { + return BuildBranchSeqsLocked(branchName, new HashSet(StringComparer.Ordinal)); + } - public Task CommitAsync(CancellationToken cancellationToken = default) + private List BuildBranchSeqsLocked(string branchName, HashSet visited) { - cancellationToken.ThrowIfCancellationRequested(); - _owner.CommitVersionTransaction(this); - return Task.CompletedTask; + if (!_branches.TryGetValue(branchName, out var info)) return new List(); + if (!visited.Add(branchName)) return new List(); + var seqs = new List(); + if (info.Parent is not null && _branches.ContainsKey(info.Parent)) + { + seqs.AddRange(BuildBranchSeqsLocked(info.Parent, visited).Where(s => s <= info.ForkSeq)); + } + var own = _transitionBranches + .Where(p => p.Value == branchName && p.Key <= info.Head) + .Select(p => p.Key) + .OrderBy(s => s); + seqs.AddRange(own); + return seqs; } - public void Rollback() => _owner.RollbackVersionTransaction(this); + private Transition? FindTransition(long sequence) + { + foreach (var t in _transactions.Log) + { + if (t.Sequence == sequence) return t; + } + return null; + } - public void Dispose() + // -- Persistence helpers ---------------------------------------------- + + private void EnsureDefaultBranch() + { + lock (_lock) + { + var existing = _transactions.LastLoggedSequence; + if (!_branches.ContainsKey(DefaultBranchName)) + { + // Pre-existing transitions are attributed to the default branch. + for (var s = 1L; s <= existing; s++) + { + if (!_transitionBranches.ContainsKey(s)) + { + _transitionBranches[s] = DefaultBranchName; + WriteImmutableMarker($"{TransitionPrefix}{s.ToString(CultureInfo.InvariantCulture)}:branch={DefaultBranchName}"); + } + } + CreateBranchLocked(DefaultBranchName, parent: null, forkSeq: 0, head: existing); + _currentBranch = DefaultBranchName; + _currentApplied = existing; + SetCurrentBranchLocked(DefaultBranchName); + SetAppliedLocked(existing); + } + else if (_currentBranchLink == 0) + { + SetCurrentBranchLocked(_currentBranch); + } + } + } + + private void CreateBranchLocked(string name, string? parent, long forkSeq, long head) + { + var info = new BranchInfo(name, parent, forkSeq, head); + _branches[name] = info; + UpdateBranchLinkLocked(info); + } + + private void UpdateBranchLinkLocked(BranchInfo info) + { + var nameMarker = EncodeBranchMarker(info); + if (!_branchLinks.TryGetValue(info.Name, out var link)) + { + link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _branchLinks[info.Name] = link; + } + _branchesStore.SetName(link, nameMarker); + } + + private void UpdateTagLinkLocked(string name, long seq) + { + var nameMarker = $"{TagPrefix}{name}={seq.ToString(CultureInfo.InvariantCulture)}"; + if (!_tagLinks.TryGetValue(name, out var link)) + { + link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _tagLinks[name] = link; + } + _branchesStore.SetName(link, nameMarker); + } + + private void SetCurrentBranchLocked(string name) + { + _currentBranch = name; + if (_currentBranchLink == 0) + { + _currentBranchLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + } + _branchesStore.SetName(_currentBranchLink, $"{CurrentPrefix}{name}"); + } + + private void SetAppliedLocked(long seq) + { + if (_appliedLink == 0) + { + _appliedLink = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + } + _branchesStore.SetName(_appliedLink, $"{AppliedPrefix}{seq.ToString(CultureInfo.InvariantCulture)}"); + } + + private void WriteImmutableMarker(string name) + { + var link = _branchesStore.CreateAndUpdate(_branchesStore.Constants.Null, _branchesStore.Constants.Null); + _branchesStore.SetName(link, name); + } + + private static string EncodeBranchMarker(BranchInfo info) { - if (!Inner.IsCommitted && !Inner.IsRolledBack) - { - _owner.RollbackVersionTransaction(this); - } + var parent = info.Parent ?? string.Empty; + return string.Concat( + BranchPrefix, + info.Name, + ":parent=", parent, + ":fork=", info.ForkSeq.ToString(CultureInfo.InvariantCulture), + ":head=", info.Head.ToString(CultureInfo.InvariantCulture)); + } + + private static bool TryDecodeBranchMarker(string text, out BranchInfo info) + { + info = default!; + if (!text.StartsWith(BranchPrefix, StringComparison.Ordinal)) return false; + var rest = text.Substring(BranchPrefix.Length); + var parentIdx = rest.IndexOf(":parent=", StringComparison.Ordinal); + if (parentIdx < 0) return false; + var name = rest.Substring(0, parentIdx); + rest = rest.Substring(parentIdx + ":parent=".Length); + var forkIdx = rest.IndexOf(":fork=", StringComparison.Ordinal); + if (forkIdx < 0) return false; + var parentText = rest.Substring(0, forkIdx); + rest = rest.Substring(forkIdx + ":fork=".Length); + var headIdx = rest.IndexOf(":head=", StringComparison.Ordinal); + if (headIdx < 0) return false; + var forkText = rest.Substring(0, headIdx); + var headText = rest.Substring(headIdx + ":head=".Length); + if (!long.TryParse(forkText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fork)) return false; + if (!long.TryParse(headText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var head)) return false; + info = new BranchInfo(name, parentText.Length == 0 ? null : parentText, fork, head); + return true; + } + + public void Recover() + { + lock (_lock) + { + _branches.Clear(); + _tags.Clear(); + _transitionBranches.Clear(); + _branchLinks.Clear(); + _tagLinks.Clear(); + _currentBranch = DefaultBranchName; + _currentBranchLink = 0; + _appliedLink = 0; + _currentApplied = 0; + + var any = _branchesStore.Constants.Any; + var anyLink = new DoubletLink(any, any, any); + foreach (var raw in _branchesStore.All(anyLink)) + { + var link = new DoubletLink(raw); + var name = _branchesStore.GetName(link.Index); + if (string.IsNullOrEmpty(name)) continue; + + if (name.StartsWith(BranchPrefix, StringComparison.Ordinal)) + { + if (TryDecodeBranchMarker(name, out var info)) + { + _branches[info.Name] = info; + _branchLinks[info.Name] = link.Index; + } + } + else if (name.StartsWith(CurrentPrefix, StringComparison.Ordinal)) + { + _currentBranch = name.Substring(CurrentPrefix.Length); + _currentBranchLink = link.Index; + } + else if (name.StartsWith(AppliedPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(AppliedPrefix.Length); + if (long.TryParse(rest, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + _currentApplied = seq; + _appliedLink = link.Index; + } + } + else if (name.StartsWith(TagPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(TagPrefix.Length); + var eq = rest.IndexOf('='); + if (eq > 0) + { + var tagName = rest.Substring(0, eq); + if (long.TryParse(rest.Substring(eq + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var tagSeq)) + { + _tags[tagName] = tagSeq; + _tagLinks[tagName] = link.Index; + } + } + } + else if (name.StartsWith(TransitionPrefix, StringComparison.Ordinal)) + { + var rest = name.Substring(TransitionPrefix.Length); + var colon = rest.IndexOf(":branch=", StringComparison.Ordinal); + if (colon > 0 && + long.TryParse(rest.Substring(0, colon), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seq)) + { + var branchName = rest.Substring(colon + ":branch=".Length); + _transitionBranches[seq] = branchName; + } + } + } + } + } + + // -- INamedTypes forwarding ------------------------------------------- + + public string? GetName(uint link) => _transactions.GetName(link); + public uint SetName(uint link, string name) => _transactions.SetName(link, name); + public uint GetByName(string name) => _transactions.GetByName(name); + public void RemoveName(uint link) => _transactions.RemoveName(link); + + // -- Convenience ------------------------------------------------------ + + /// Conventional sidecar filename for the version-control store. + public static string MakeVersionControlDatabaseFilename(string databaseFilename) + { + ArgumentNullException.ThrowIfNull(databaseFilename); + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(databaseFilename); + var directory = Path.GetDirectoryName(databaseFilename); + return Path.Combine(directory ?? string.Empty, $"{filenameWithoutExtension}.versioncontrol.links"); + } + + private void Trace(string message) + { + if (_trace) Console.WriteLine($"[VersionControl] {message}"); + } + + private sealed class VersionControlTransaction : ITransaction + { + private readonly VersionControlDecorator _owner; + + internal VersionControlTransaction( + VersionControlDecorator owner, + ITransaction inner, + string branchName, + long beforeSequence) + { + _owner = owner; + Inner = inner; + BranchName = branchName; + BeforeSequence = beforeSequence; + } + + internal ITransaction Inner { get; } + internal string BranchName { get; } + internal long BeforeSequence { get; } + + public Guid Id => Inner.Id; + public DateTimeOffset StartedAt => Inner.StartedAt; + public bool IsCommitted => Inner.IsCommitted; + public bool IsRolledBack => Inner.IsRolledBack; + public IReadOnlyList Transitions => Inner.Transitions; + + public void Commit() => _owner.CommitVersionTransaction(this); + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _owner.CommitVersionTransaction(this); + return Task.CompletedTask; + } + + public void Rollback() => _owner.RollbackVersionTransaction(this); + + public void Dispose() + { + if (!Inner.IsCommitted && !Inner.IsRolledBack) + { + _owner.RollbackVersionTransaction(this); + } + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.Helpers.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.Helpers.cs new file mode 100644 index 0000000..440e901 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.Helpers.cs @@ -0,0 +1,276 @@ +// Shared fixtures and assertions for the AdvancedMixedQueryProcessor tests. +using System.Globalization; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory.United.Generic; + +using DoubletLink = Platform.Data.Doublets.Link; + +using static Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + public partial class AdvancedMixedQueryProcessor + { + /// + private static TimeSpan TestTimeout + { + get + { + const int defaultTimeoutSeconds = 60; + var configured = Environment.GetEnvironmentVariable("LINK_CLI_TEST_TIMEOUT_SECONDS"); + if (!string.IsNullOrWhiteSpace(configured) + && int.TryParse(configured, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) + && seconds > 0) + { + return TimeSpan.FromSeconds(seconds); + } + return TimeSpan.FromSeconds(defaultTimeoutSeconds); + } + } + + private static void RunTestWithLinks(Action> testAction, bool enableTracing = false) + { + string tempDbFile = Path.GetTempFileName(); + var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); + try + { + // Disposed at the end of the try block, before the finally deletes the backing files. The + // decorator owns memory-mapped handles for both databases; POSIX tolerates unlinking a file + // that is still mapped, Windows fails the delete with IOException. + using var decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); + + var timeout = TestTimeout; + using var cts = new CancellationTokenSource(timeout); + var task = Task.Run(() => + { + testAction(decoratedLinks); + }, cts.Token); + + try + { + task.Wait(cts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine($"[Test] Test was cancelled after {timeout.TotalSeconds} seconds timeout"); + throw new TimeoutException($"Test exceeded {timeout.TotalSeconds} seconds timeout"); + } + } + finally + { + if (File.Exists(namesDbFile)) + { + File.Delete(namesDbFile); + } + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + } + } + + private static List GetAllLinks(NamedTypesDecorator links) + { + var any = links.Constants.Any; + var query = new DoubletLink(index: any, source: any, target: any); + var allLinks = links.All(query).Select(doublet => new DoubletLink(doublet)).ToList(); + Console.WriteLine($"[Test] All links: {string.Join(" ", allLinks)}"); + return allLinks; + } + + private static void ProcessQuery(NamedTypesDecorator links, string query) + { + ProcessQuery(links, new Options { Query = query }); + } + + private static void ProcessQuery(NamedTypesDecorator links, Options options) + { + options.AutoCreateMissingReferences = true; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); + } + + private static void ProcessQueryStrict(NamedTypesDecorator links, string query) + { + ProcessQueryStrict(links, new Options { Query = query }); + } + + private static void ProcessQueryStrict(NamedTypesDecorator links, Options options) + { + options.AutoCreateMissingReferences = false; + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); + } + + private static void AssertLinkExists(List allLinks, uint index, uint source, uint target) + { + var link = new DoubletLink(index, source, target); + Assert.True(allLinks.Contains(link), $"Link {link} not found in the list of all links ({string.Join(" ", allLinks)})"); + } + + private static void AssertChangeExists(List<(DoubletLink, DoubletLink)> changes, DoubletLink linkBefore, DoubletLink linkAfter) + { + Assert.Contains(changes, change => change.Item1 == linkBefore && change.Item2 == linkAfter); + } + + // New tests for link reference validation + + [Fact] + public void CreateLinkWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Act & Assert - should throw exception for referencing non-existent link 10 + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(() ((1: 10 20)))"); + }); + + Assert.Contains("Invalid reference to non-existent link '10'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); + }); + } + + [Fact] + public void CreateLinkWithValidSelfReference_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because link 1 references itself + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } + + [Fact] + public void CreateMultipleLinksWithCrossReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because both links are created in the same operation + ProcessQueryStrict(links, "(() ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void CreateLinkReferencingExistingLink_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Arrange - create first link + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Act - should succeed because link 1 exists + ProcessQueryStrict(links, "(() ((2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void UpdateWithNonExistentReference_ShouldThrowException() + { + RunTestWithLinks(links => + { + // Arrange - create initial link + ProcessQueryStrict(links, "(() ((1: 1 1)))"); + + // Act & Assert - should throw exception for referencing non-existent link 99 + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(((1: 1 1)) ((1: 1 99)))"); + }); + + Assert.Contains("Invalid reference to non-existent link '99'", exception.Message); + }); + } + + [Fact] + public void CreateNamedLinkWithMissingNamedReferences_ShouldThrowException() + { + RunTestWithLinks(links => + { + var exception = Assert.Throws(() => + { + ProcessQueryStrict(links, "(() ((child: father mother)))"); + }); + + Assert.Contains("Invalid reference to non-existent link 'father'", exception.Message); + Assert.Contains("--auto-create-missing-references", exception.Message); + }); + } + + [Fact] + public void CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((20: 10 20)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 10, 10, 10); + AssertLinkExists(allLinks, 20, 10, 20); + }); + } + + [Fact] + public void CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, fatherId, fatherId, fatherId); + AssertLinkExists(allLinks, motherId, motherId, motherId); + AssertLinkExists(allLinks, childId, fatherId, motherId); + }); + } + + [Fact] + public void CreateLinkWithVariableReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because variables are not validated + ProcessQueryStrict(links, "(() (($link: $source $target)))"); + + // Assert - one link should be created with variables resolved + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } + + [Fact] + public void CreateLinkWithWildcardReferences_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act - should succeed because wildcards are not validated + ProcessQueryStrict(links, "(() ((1: * *)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + }); + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.More.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.More.cs new file mode 100644 index 0000000..5d12fde --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.More.cs @@ -0,0 +1,896 @@ +// Continuation of the AdvancedMixedQueryProcessor test suite. +using System.Globalization; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory.United.Generic; + +using DoubletLink = Platform.Data.Doublets.Link; + +using static Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; +namespace Foundation.Data.Doublets.Cli.Tests.Tests +{ + public partial class AdvancedMixedQueryProcessor + { + [Fact] + public void DeleteMultipleLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((1 2) (2 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteLinksByAnyTargetTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((1 *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } + + [Fact] + public void DeleteLinksByAnySourceTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (1 2)))"); + + // Act + ProcessQuery(links, "(((* 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } + + [Fact] + public void DeleteAllLinksBySourceAndTargetTest1() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((* *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void NestedDeleteAllLinksBySourceAndTargetTest1() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "((((* *) (* *))) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteAllLinksBySourceAndTargetTest2() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 1)))"); + + // Act + ProcessQuery(links, "(((* *)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void DeleteAllLinksByIndexTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 2)))"); + + // Act + ProcessQuery(links, "(((*:)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } + + [Fact] + public void CreateNamedFamilyLinksTest() + { + RunTestWithLinks(links => + { + // Prepare query: create (child: father mother) + var query = "(() ((child: father mother)))"; + var options = new Options + { + Query = query, + }; + ProcessQuery(links, options); + + // Assert: links for 'father', 'mother', and 'child' exist and are named + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, fatherId); + Assert.NotEqual(links.Constants.Null, motherId); + Assert.NotEqual(links.Constants.Null, childId); + Assert.Equal("father", links.GetName(fatherId)); + Assert.Equal("mother", links.GetName(motherId)); + Assert.Equal("child", links.GetName(childId)); + + // The child link should have father as source and mother as target + var allLinks = GetAllLinks(links); + var childLink = allLinks.First(l => l.Index == childId); + Assert.Equal(fatherId, childLink.Source); + Assert.Equal(motherId, childLink.Target); + }); + } + + [Fact] + public void CreateTwoNamedLinksTest() + { + RunTestWithLinks(links => + { + Console.WriteLine("[Test] Starting UpdateNamedLinkNameTest"); + + // Create initial link: (child: father mother) + Console.WriteLine("[Test] Step 1: Creating initial link"); + var createOptions = new Options { Query = "(() ((child: father mother)))", Trace = true }; + ProcessQuery(links, createOptions); + Console.WriteLine("[Test] Initial link creation completed"); + + // Verify initial state + Console.WriteLine("[Test] Step 2: Verifying initial state"); + var initialChildId = links.GetByName("child"); + Console.WriteLine($"[Test] Initial child ID: {initialChildId}"); + var initialFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Initial father ID: {initialFatherId}"); + var initialMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Initial mother ID: {initialMotherId}"); + + Assert.NotEqual(links.Constants.Null, initialChildId); + Assert.NotEqual(links.Constants.Null, initialFatherId); + Assert.NotEqual(links.Constants.Null, initialMotherId); + + var initialLinks = GetAllLinks(links); + Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); + var initialChildLink = initialLinks.First(l => l.Index == initialChildId); + Assert.Equal(initialFatherId, initialChildLink.Source); + Assert.Equal(initialMotherId, initialChildLink.Target); + Console.WriteLine("[Test] Initial state verification completed"); + + // Update child link to be named "son" instead + Console.WriteLine("[Test] Step 3: Updating link name"); + // First, let's try to remove the old name + Console.WriteLine("[Test] Removing old name 'child'"); + links.RemoveName(initialChildId); + Console.WriteLine("[Test] Old name removed"); + + // Then create the new link with the new name + Console.WriteLine("[Test] Creating new link with name 'son'"); + var updateOptions = new Options { Query = "(() ((son: father mother)))", Trace = true }; + ProcessQuery(links, updateOptions); + Console.WriteLine("[Test] New link creation completed"); + + // Verify final state + Console.WriteLine("[Test] Step 4: Verifying final state"); + Assert.Equal(links.Constants.Null, links.GetByName("child")); + var finalSonId = links.GetByName("son"); + Console.WriteLine($"[Test] Final son ID: {finalSonId}"); + var finalFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); + var finalMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); + + Assert.NotEqual(links.Constants.Null, finalSonId); + Assert.NotEqual(links.Constants.Null, finalFatherId); + Assert.NotEqual(links.Constants.Null, finalMotherId); + + var finalLinks = GetAllLinks(links); + Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); + var finalSonLink = Assert.Single(finalLinks, l => l.Index == finalSonId); + Assert.Equal(finalFatherId, finalSonLink.Source); + Assert.Equal(finalMotherId, finalSonLink.Target); + Console.WriteLine("[Test] Final state verification completed"); + Console.WriteLine("[Test] UpdateNamedLinkNameTest completed successfully"); + }, enableTracing: true); + } + + [Fact] + public void UpdateNamedLinkNameTest() + { + Console.WriteLine("[Test] ===== Starting UpdateNamedLinkNameTest ====="); + RunTestWithLinks(links => + { + try + { + Console.WriteLine($"[Test] Constants: Null={links.Constants.Null}, Any={links.Constants.Any}, Continue={links.Constants.Continue}"); + // Step 1: Creating initial link + Console.WriteLine("[Test] Step 1: Creating initial link"); + var createQuery = "(() ((child: father mother)))"; + Console.WriteLine($"[Test] Query: {createQuery}"); + + var createOptions = new Options + { + Query = createQuery, + Trace = true + }; + ProcessQuery(links, createOptions); + Console.WriteLine("[Test] Initial link creation completed"); + + // Step 2: Verify initial state + Console.WriteLine("[Test] Step 2: Verifying initial state"); + var childId = links.GetByName("child"); + Console.WriteLine($"[Test] Initial child ID: {childId}"); + var fatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Initial father ID: {fatherId}"); + var motherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Initial mother ID: {motherId}"); + + var initialLinks = links.All().ToList(); + Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); + foreach (var link in initialLinks) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Initial link: Index={link}, Source={source}, Target={target}"); + } + Console.WriteLine("[Test] Initial state verification completed"); + + // Step 3: Update link name + Console.WriteLine("[Test] Step 3: Updating link name from 'child' to 'son'"); + var updateQuery = "(((child: father mother)) ((son: father mother)))"; + Console.WriteLine($"[Test] Query: {updateQuery}"); + + // Log state before update + Console.WriteLine("[Test] Current state before update:"); + Console.WriteLine($"[Test] - child name exists: {links.GetByName("child") != 0}"); + Console.WriteLine($"[Test] - son name exists: {links.GetByName("son") != 0}"); + Console.WriteLine($"[Test] - father name exists: {links.GetByName("father") != 0}"); + Console.WriteLine($"[Test] - mother name exists: {links.GetByName("mother") != 0}"); + + Console.WriteLine("[Test] Starting ProcessQuery for update..."); + Console.WriteLine("[Test] Current links before update:"); + foreach (var link in links.All()) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); + } + + // Add detailed tracing for the update operation + var updateOptions = new Options + { + Query = updateQuery, + Trace = true, + ChangesHandler = (before, after) => + { + Console.WriteLine($"[Test] Update ChangesHandler called:"); + Console.WriteLine($"[Test] - Before state: {before}"); + Console.WriteLine($"[Test] - After state: {after}"); + + // Log name states during change + Console.WriteLine($"[Test] - child name during change: {links.GetByName("child")}"); + Console.WriteLine($"[Test] - son name during change: {links.GetByName("son")}"); + Console.WriteLine($"[Test] - father name during change: {links.GetByName("father")}"); + Console.WriteLine($"[Test] - mother name during change: {links.GetByName("mother")}"); + + // Log all links during change + Console.WriteLine("[Test] - All links during change:"); + foreach (var link in links.All()) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); + } + + // Add detailed tracing for link creation + if (after != null && before == null) + { + var afterLink = new DoubletLink(after); + var source = links.GetSource(after); + var target = links.GetTarget(after); + Console.WriteLine($"[Test] Creating new link: Index={afterLink.Index}, Source={source}, Target={target}"); + Console.WriteLine($"[Test] Checking if link exists: {links.Exists>(afterLink.Index)}"); + Console.WriteLine($"[Test] Checking if source exists: {links.Exists>(source)}"); + Console.WriteLine($"[Test] Checking if target exists: {links.Exists>(target)}"); + + // Log all names before creation + Console.WriteLine("[Test] Names before creation:"); + foreach (var name in new[] { "child", "son", "father", "mother" }) + { + var id = links.GetByName(name); + Console.WriteLine($"[Test] - {name}: {id}"); + } + } + + return links.Constants.Continue; + } + }; + + ProcessQuery(links, updateOptions); + Console.WriteLine("[Test] Update operation completed"); + + // Step 4: Verify final state + Console.WriteLine("[Test] Step 4: Verifying final state"); + var finalChildId = links.GetByName("child"); + Console.WriteLine($"[Test] Final child ID: {finalChildId}"); + var finalSonId = links.GetByName("son"); + Console.WriteLine($"[Test] Final son ID: {finalSonId}"); + var finalFatherId = links.GetByName("father"); + Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); + var finalMotherId = links.GetByName("mother"); + Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); + + var finalLinks = links.All().ToList(); + Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); + foreach (var link in finalLinks) + { + var source = links.GetSource(link); + var target = links.GetTarget(link); + Console.WriteLine($"[Test] Final link: Index={link}, Source={source}, Target={target}"); + } + + // Verify the update was successful + Assert.Equal(0, finalChildId); // Old name should be gone + Assert.NotEqual(0, finalSonId); // New name should exist + Assert.Equal(finalFatherId, links.GetSource(finalSonId)); // Source should be father + Assert.Equal(finalMotherId, links.GetTarget(finalSonId)); // Target should be mother + + Console.WriteLine("[Test] ===== UpdateNamedLinkNameTest completed successfully ====="); + } + catch (Exception ex) + { + Console.WriteLine($"[Test] Error in UpdateNamedLinkNameTest: {ex}"); + Console.WriteLine($"[Test] Stack trace: {ex.StackTrace}"); + throw; + } + }, enableTracing: true); + } + + [Fact] + public void DeleteNamedFamilyLinksRemovesNamesTest() + { + RunTestWithLinks(links => + { + // Prepare query: create (child: father mother) + var query = "(() ((child: father mother)))"; + var options = new Options + { + Query = query, + }; + ProcessQuery(links, options); + + // Delete the 'child' link + var childId = links.GetByName("child"); + links.Delete(childId); + + // Assert: 'child' name is removed, 'father' and 'mother' remain + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.NotEqual(links.Constants.Null, links.GetByName("father")); + Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void DeleteNamedLinkTest() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + + ProcessQuery(links, "(((*:)) ())"); + + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.Equal(links.Constants.Null, links.GetByName("father")); + Assert.Equal(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void DeleteByNamesTest() + { + RunTestWithLinks(links => + { + // Create link by name + ProcessQuery(links, "(() ((child: father mother)))"); + + // Delete link by name + ProcessQuery(links, "(((child: father mother)) ())"); + + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.NotEqual(links.Constants.Null, links.GetByName("father")); + Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); + }); + } + + [Fact] + public void NameLookupConsistencyTest() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((x: 1 2)))"); + ProcessQuery(links, "(((x: 1 2)) ((y: 1 2)))"); + ProcessQuery(links, "(((y: 1 2)) ((z: 1 2)))"); + links.Delete(links.GetByName("z")); + Assert.Equal(links.Constants.Null, links.GetByName("x")); + Assert.Equal(links.Constants.Null, links.GetByName("y")); + Assert.Equal(links.Constants.Null, links.GetByName("z")); + }); + } + + [Fact] + public void CreateNamedLinkWithStringId_ShouldCreateSingleLink() + { + RunTestWithLinks(links => + { + var options = new Options { Query = "(() ((link: link link)))", Trace = true }; + ProcessQuery(links, options); + var allLinks = GetAllLinks(links); + // This should only create a single named link with string id 'link' + Assert.Single(allLinks); + var linkId = links.GetByName("link"); + Assert.NotEqual(links.Constants.Null, linkId); + var link = allLinks.First(); + Assert.Equal(linkId, link.Index); + Assert.Equal(linkId, link.Source); + Assert.Equal(linkId, link.Target); + Assert.Equal("link", links.GetName(linkId)); + }, enableTracing: true); + } + + [Fact] + public void CreateLinkWithIntegerId_ShouldCreateSingleLink() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1)))"); + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + var link = allLinks.First(); + Assert.Equal(1u, link.Index); + Assert.Equal(1u, link.Source); + Assert.Equal(1u, link.Target); + }); + } + + [Fact] + public void CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQuery(links, "(() ((2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } + + [Fact] + public void CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + ProcessQuery(links, "(() ((2: 1 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 1, 2); + }); + } + + [Fact] + public void CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((type: type type)))"); + ProcessQuery(links, "(() ((link: link type)))"); + + // Assert + var allLinks = GetAllLinks(links); + // Expect only two links, but extra self-referential named link for 'link' is created indicating a bug. + Assert.Equal(2, allLinks.Count); + var typeId = links.GetByName("type"); + var linkId = links.GetByName("link"); + AssertLinkExists(allLinks, typeId, typeId, typeId); + AssertLinkExists(allLinks, linkId, linkId, typeId); + }); + } + + [Fact] + public void CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((type: type type)))"); + ProcessQuery(links, "(() ((link: type link)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + var typeId = links.GetByName("type"); + var linkId = links.GetByName("link"); + AssertLinkExists(allLinks, typeId, typeId, typeId); + AssertLinkExists(allLinks, linkId, typeId, linkId); + }); + } + + // ============================================ + // Link Deduplication Tests + // ============================================ + + [Fact] + public void DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink() + { + // Issue #65: Test deduplication of (m a) (m a) pattern + // Query: () (((m a) (m a))) + // Expected: m, a (named self-refs), link 3 = (m a), link 4 = (3 3) + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((m a) (m a))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(4, allLinks.Count); + + // Get the named link IDs + var mId = links.GetByName("m"); + var aId = links.GetByName("a"); + + Assert.NotEqual(links.Constants.Null, mId); + Assert.NotEqual(links.Constants.Null, aId); + + // m and a should be self-referencing + AssertLinkExists(allLinks, mId, mId, mId); + AssertLinkExists(allLinks, aId, aId, aId); + + // Find the (m a) link + var maLink = allLinks.FirstOrDefault(l => l.Source == mId && l.Target == aId); + Assert.NotEqual(default, maLink); + + // Find the outer link ((m a) (m a)) which should be (maLink.Index maLink.Index) + var outerLink = allLinks.FirstOrDefault(l => l.Source == maLink.Index && l.Target == maLink.Index); + Assert.NotEqual(default, outerLink); + + // Verify deduplication: the outer link's source and target should be the same + Assert.Equal(outerLink.Source, outerLink.Target); + }); + } + + [Fact] + public void DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink() + { + // Issue #65: Test deduplication with numeric IDs + // Query: () (((1 2) (1 2))) + // When using numeric IDs directly, they are treated as references (not creating self-refs) + // So (1 2) creates link with source=1, target=2 + // The deduplication still works: ((1 2) (1 2)) creates only one (1 2) link + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 2) (1 2))))"); + + // Assert + var allLinks = GetAllLinks(links); + + // Should have 2 links: (1 2) and ((1 2) (1 2)) + Assert.Equal(2, allLinks.Count); + + // Link 1 should be (1 2) - the deduplicated sub-link + AssertLinkExists(allLinks, 1, 1, 2); + + // Link 2 should be (1 1) - referencing the same sub-link twice + AssertLinkExists(allLinks, 2, 1, 1); + }); + } + + [Fact] + public void DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink() + { + // Test with three identical pairs using named links: (((a b) ((a b) (a b)))) + // The (a b) should only be created once + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((a b) ((a b) (a b)))))"); + + // Assert + var allLinks = GetAllLinks(links); + + var aId = links.GetByName("a"); + var bId = links.GetByName("b"); + + // a and b should be self-referencing + AssertLinkExists(allLinks, aId, aId, aId); + AssertLinkExists(allLinks, bId, bId, bId); + + // Find (a b) link - the deduplicated sub-link + var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); + Assert.NotEqual(default, abLink); + + // Find ((a b) (a b)) link - should reference abLink twice + var innerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == abLink.Index); + Assert.NotEqual(default, innerLink); + + // Find outer link ((a b) ((a b) (a b))) + var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == innerLink.Index); + Assert.NotEqual(default, outerLink); + + Assert.Equal(5, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks() + { + // Test that named links are reused across queries + RunTestWithLinks(links => + { + // First query creates (m a) + ProcessQuery(links, "(() ((m a)))"); + + var mId = links.GetByName("m"); + var aId = links.GetByName("a"); + + // Second query should reuse existing m and a links + ProcessQuery(links, "(() (((m a) (m a))))"); + + // Assert + var allLinks = GetAllLinks(links); + + // m and a should still have the same IDs + Assert.Equal(mId, links.GetByName("m")); + Assert.Equal(aId, links.GetByName("a")); + + // Should have 4 links total: m, a, (m a), ((m a) (m a)) + Assert.Equal(4, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks() + { + // Test that different pairs are NOT deduplicated + // Query: () (((a b) (b a))) - using named links + // (a b) and (b a) are different and should both be created + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((a b) (b a))))"); + + // Assert + var allLinks = GetAllLinks(links); + + var aId = links.GetByName("a"); + var bId = links.GetByName("b"); + + // a and b should be self-referencing + AssertLinkExists(allLinks, aId, aId, aId); + AssertLinkExists(allLinks, bId, bId, bId); + + // Find (a b) link + var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); + Assert.NotEqual(default, abLink); + + // Find (b a) link + var baLink = allLinks.FirstOrDefault(l => l.Source == bId && l.Target == aId); + Assert.NotEqual(default, baLink); + + // Find outer link ((a b) (b a)) - should have different source and target + var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == baLink.Index); + Assert.NotEqual(default, outerLink); + Assert.NotEqual(outerLink.Source, outerLink.Target); + + Assert.Equal(5, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels() + { + // Test deeply nested deduplication using named links + // Query: () ((((x y) (x y)) ((x y) (x y)))) + // (x y) is duplicated at multiple levels + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((((x y) (x y)) ((x y) (x y)))))"); + + // Assert + var allLinks = GetAllLinks(links); + + var xId = links.GetByName("x"); + var yId = links.GetByName("y"); + + // x and y should be self-referencing + AssertLinkExists(allLinks, xId, xId, xId); + AssertLinkExists(allLinks, yId, yId, yId); + + // Find (x y) - the base link + var xyLink = allLinks.FirstOrDefault(l => l.Source == xId && l.Target == yId); + Assert.NotEqual(default, xyLink); + + // Find ((x y) (x y)) - references (x y) twice (deduplicated) + var level1Link = allLinks.FirstOrDefault(l => l.Source == xyLink.Index && l.Target == xyLink.Index); + Assert.NotEqual(default, level1Link); + + // Find (((x y) (x y)) ((x y) (x y))) - references level1Link twice (deduplicated) + var level2Link = allLinks.FirstOrDefault(l => l.Source == level1Link.Index && l.Target == level1Link.Index); + Assert.NotEqual(default, level2Link); + + // Total: x, y, (x y), ((x y) (x y)), (((x y) (x y)) ((x y) (x y))) + Assert.Equal(5, allLinks.Count); + }); + } + + [Fact] + public void DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds() + { + // Issue #65: Verify that named links maintain consistent IDs across operations + RunTestWithLinks(links => + { + // First create named links + ProcessQuery(links, "(() ((p: p p)))"); + ProcessQuery(links, "(() ((a: a a)))"); + + var pId = links.GetByName("p"); + var aId = links.GetByName("a"); + + // Now create ((p a) (p a)) - should reuse existing p and a + ProcessQuery(links, "(() (((p a) (p a))))"); + + // Assert + var allLinks = GetAllLinks(links); + + // p and a should still have the same IDs + Assert.Equal(pId, links.GetByName("p")); + Assert.Equal(aId, links.GetByName("a")); + + // Verify the structure + AssertLinkExists(allLinks, pId, pId, pId); + AssertLinkExists(allLinks, aId, aId, aId); + + // Find (p a) link + var paLink = allLinks.FirstOrDefault(l => l.Source == pId && l.Target == aId); + Assert.NotEqual(default, paLink); + + // Find ((p a) (p a)) link - should reference paLink twice + var outerLink = allLinks.FirstOrDefault(l => l.Source == paLink.Index && l.Target == paLink.Index); + Assert.NotEqual(default, outerLink); + }); + } + + [Fact] + public void StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((father: father father)))"); + ProcessQuery(links, "(() ((mother: mother mother)))"); + ProcessQuery(links, "(() ((child: father mother)))"); + + var fatherId = links.GetByName("father"); + var motherId = links.GetByName("mother"); + var childId = links.GetByName("child"); + + ProcessQuery(links, "((($id: father mother)) (($id: mother father)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, fatherId, fatherId, fatherId); + AssertLinkExists(allLinks, motherId, motherId, motherId); + AssertLinkExists(allLinks, childId, motherId, fatherId); + }); + } + + [Fact] + public void Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1) (18: 1 21) (19: 1 20) (20: 20 20) (21: 21 21)))"); + + ProcessQuery(links, "((($i: 1 21)) (($i: $s $t) ($i 20)))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(6, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 18, 1, 21); + AssertLinkExists(allLinks, 19, 1, 20); + AssertLinkExists(allLinks, 20, 20, 20); + AssertLinkExists(allLinks, 21, 21, 21); + + var outgoingLink = Assert.Single(allLinks, link => link.Source == 18 && link.Target == 20); + Assert.NotEqual(links.Constants.Null, outgoingLink.Index); + Assert.NotEqual(links.Constants.Any, outgoingLink.Index); + Assert.DoesNotContain(allLinks, link => link.Index == links.Constants.Any || link.Source == links.Constants.Any || link.Target == links.Constants.Any); + }); + } + + [Fact] + public void Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((21: 21 21)))"); + + ProcessQuery(links, "(((21: 21 21)) ((21: $s $t)))"); + + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 21, 21, 21); + Assert.DoesNotContain(allLinks, link => link.Source == links.Constants.Any || link.Target == links.Constants.Any); + }); + } + + [Fact] + public void EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException() + { + RunTestWithLinks(links => + { + var exception = Assert.Throws(() => LinksExtensions.EnsureCreated(links, links.Constants.Any)); + + Assert.Contains("unsupported link address", exception.Message); + }); + } + + // Helper methods + + /// + /// Wall-clock budget for a single test body. Every test here is expected to finish in + /// milliseconds, so this is a deadlock guard, not a performance assertion: the previous value of + /// one second was tight enough that a loaded CI runner failed tests that were perfectly correct. + /// Override with the LINK_CLI_TEST_TIMEOUT_SECONDS environment variable. + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs index b16440b..961a4c5 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/AdvancedMixedQueryProcessor.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Platform.Data; using Platform.Data.Doublets; using Platform.Data.Doublets.Memory.United.Generic; @@ -5,1812 +6,698 @@ using DoubletLink = Platform.Data.Doublets.Link; using static Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; - namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class AdvancedMixedQueryProcessor - { - [Fact] - public void CreateSingleLinkTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } - - [Fact] - public void CreateSingleLinkWithIndexTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } - - [Fact] - public void CreateSingleLinkWithIndexAfterGapTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((2: 2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void CreateSingleLinkWithIndexAfterDoubleGapTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((3: 3 3)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 3, 3, 3); - }); - } - - [Fact] - public void CreateLinkWithSource2Target2Test() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void CreateMultipleLinksTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void Create2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 1, 2); - }); - } - - [Fact] - public void Create3LevelNestedLinksTest() + public partial class AdvancedMixedQueryProcessor { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 1) ((2 2) (3 3)))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(5, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 2, 3); - AssertLinkExists(allLinks, 5, 1, 4); - }); - } - - [Fact] - public void Create4LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) (4 4))))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(7, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 3, 4); - AssertLinkExists(allLinks, 6, 2, 5); - AssertLinkExists(allLinks, 7, 1, 6); - }); - } - - [Fact] - public void Create5LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Structure visualization: - // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5) - // ((4 4) (5 5)) => #6: 4->5 - // ((3 3) ((4 4) (5 5))) => #7: 3->6 - // ((2 2) ((3 3) ((4 4) (5 5)))) => #8: 2->7 - // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9: 1->8 - // - // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))" - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(9, allLinks.Count); - - // Leaf links - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 5, 5); - - // ((4 4) (5 5)) => #6:4->5 - AssertLinkExists(allLinks, 6, 4, 5); - - // ((3 3) ((4 4) (5 5))) => #7:3->6 - AssertLinkExists(allLinks, 7, 3, 6); - - // ((2 2) ((3 3) ((4 4) (5 5)))) => #8:2->7 - AssertLinkExists(allLinks, 8, 2, 7); - - // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9:1->8 - AssertLinkExists(allLinks, 9, 1, 8); - }); - } - - [Fact] - public void Create6LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Structure: - // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6) - // ((5 5) (6 6)) => #7:5->6 - // ((4 4) ((5 5) (6 6))) => #8:4->7 - // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 - // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 - // - // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))" - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(11, allLinks.Count); - - // Leaf links - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 5, 5); - AssertLinkExists(allLinks, 6, 6, 6); - - // ((5 5) (6 6)) => #7:5->6 - AssertLinkExists(allLinks, 7, 5, 6); - - // ((4 4) ((5 5) (6 6))) => #8:4->7 - AssertLinkExists(allLinks, 8, 4, 7); - - // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 - AssertLinkExists(allLinks, 9, 3, 8); - - // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 - AssertLinkExists(allLinks, 10, 2, 9); - - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 - AssertLinkExists(allLinks, 11, 1, 10); - }); - } - - [Fact] - public void Create7LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6), (7 7) - // ((6 6) (7 7)) => #8:6->7 - // ((5 5) ((6 6) (7 7))) => #9:5->8 - // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 - // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 - // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 - // - // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))" - ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(13, allLinks.Count); - - // Leaf links - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - AssertLinkExists(allLinks, 3, 3, 3); - AssertLinkExists(allLinks, 4, 4, 4); - AssertLinkExists(allLinks, 5, 5, 5); - AssertLinkExists(allLinks, 6, 6, 6); - AssertLinkExists(allLinks, 7, 7, 7); - - // ((6 6) (7 7)) => #8:6->7 - AssertLinkExists(allLinks, 8, 6, 7); - - // ((5 5) ((6 6) (7 7))) => #9:5->8 - AssertLinkExists(allLinks, 9, 5, 8); - - // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 - AssertLinkExists(allLinks, 10, 4, 9); - - // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 - AssertLinkExists(allLinks, 11, 3, 10); - - // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 - AssertLinkExists(allLinks, 12, 2, 11); - - // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 - AssertLinkExists(allLinks, 13, 1, 12); - }); - } - - [Fact] - public void UpdateSingleLinkTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((2 2)))"); - - // Act - ProcessQuery(links, "(((1: 1 1)) ((1: 1 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void ExactMatchAndDelete2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Act - ProcessQuery(links, "(((3: (1: 1 1) (2: 2 2))) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void MatchWithExactIndexAndDelete2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Act - ProcessQuery(links, "(( (3: (1 *) (* 2)) ) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void MatchAndDelete2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() (((1 1) (2 2))))"); - - // Act - ProcessQuery(links, "(( ((1 *) (* 2)) ) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void NoExactMatch2LevelNestedLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQueryStrict(links, "() ((1: 1 1) (2: 2 2))"); - - // Act - ProcessQueryStrict(links, "((1: (1: 1 1) (1: 2 1))) ()"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void NoUpdateUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((2 2)))"); - - Options options = new Options(); - - var changes = new List<(DoubletLink, DoubletLink)>(); - options.Query = "((($index: $source $target)) (($index: $source $target)))"; - options.ChangesHandler = (before, after) => - { - changes.Add((new DoubletLink(before), new DoubletLink(after))); - return links.Constants.Continue; - }; - - // Act - ProcessQuery(links, options); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - Assert.Equal(2, changes.Count); - AssertChangeExists(changes, new DoubletLink(1, 1, 1), new DoubletLink(1, 1, 1)); - AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); - }); - } - - [Fact] - public void SwapSourceAndTargetForSingleLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - ProcessQuery(links, "(() ((1 2)))"); - - // Act - ProcessQuery(links, "(((2: $source $target)) ((2: $target $source)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } - - [Fact] - public void SwapSourceAndTargetForAllLinksUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange: create initial links (1: 1 2) and (2: 2 1) - ProcessQuery(links, "(() ((1 2) (2 1)))"); - - // Act: swap source and target for all links - ProcessQuery(links, "((($index: $source $target)) (($index: $target $source)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 2, 1); - AssertLinkExists(allLinks, 2, 1, 2); - }); - } - - [Fact] - public void SwapEqualSourceAndTargetUsingVariablesHasAllChangesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "() ((1 1) (2 2))"); - ProcessQuery(links, "((1: 1 1)) ((1: 1 2))"); - - Options options = new Options(); - - var changes = new List<(DoubletLink, DoubletLink)>(); - options.Query = "((($index: $source $target)) (($index: $target $source)))"; - options.ChangesHandler = (before, after) => - { - changes.Add((new DoubletLink(before), new DoubletLink(after))); - return links.Constants.Continue; - }; - - // Act - ProcessQuery(links, options); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 2, 1); - AssertLinkExists(allLinks, 2, 2, 2); - Assert.Equal(2, changes.Count); - AssertChangeExists(changes, new DoubletLink(1, 1, 2), new DoubletLink(1, 2, 1)); - AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); - }); - } - - [Fact] - public void MakeAllLinksToGoOutOfFirstLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((2 2) (2 1)))"); - - // Act: make all links to go out of the first link - ProcessQuery(links, "((($index: $source $target)) (($index: 1 $target)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 1, 1); - }); - } - - [Fact] - public void MakeAllLinksToGoIntoFirstLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((2 2) (1 2)))"); - - // Act: make all links to go into the first link - ProcessQuery(links, "((($index: $source $target)) (($index: $source 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 2, 1); - AssertLinkExists(allLinks, 2, 1, 1); - }); - } - - [Fact] - public void MakeAllLinksSelfReferencingUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 1)))"); - - // Act: make all links self-referencing - ProcessQuery(links, "((($index: $source $target)) (($index: $index $index)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void MatchSelfReferencingAndMakeThemGoOutFromFirstLinkUsingVariablesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2) (3 1) (4 4)))"); - - // Act: match self-referencing links and make them go out from the first link - ProcessQuery(links, "((($index: $index $index)) (($index: 1 $index)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(4, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 1, 2); - AssertLinkExists(allLinks, 3, 3, 1); - AssertLinkExists(allLinks, 4, 1, 4); - }); - } - - [Fact] - public void MultipleUpdatesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Act - ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } - - [Fact] - public void MixedMultipleUpdatesTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Act - ProcessQuery(links, "(((2: 2 2) (1: 1 1)) ((1: 1 2) (2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } - - [Fact] - public void CreationDuringUpdateTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - - // Act: Add new link with ID '2' by including it only in substitution - ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (2: 2 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void CreationWithEmptySlotDuringUpdateTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1)))"); - - // Act: Add new link with ID '2' by including it only in substitution - ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (3: 3 3)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 3, 3, 3); - }); - } - - [Fact] - public void DeletionDuringUpdateTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (2 2)))"); - - // Act: Remove link with ID '2' by omitting it in substitution - ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } - - [Fact] - public void DeleteSingleLinkTest_Source1Target2() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2)))"); - ProcessQuery(links, "(() ((2 2)))"); - - // Act - ProcessQuery(links, "(((1 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void DeleteSingleLinkTest_Source2Target2() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((2 2)))"); - - // Act - ProcessQuery(links, "(((2 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void DeleteMultipleLinksTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((1 2) (2 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void DeleteLinksByAnyTargetTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((1 *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 2, 2, 2); - }); - } - - [Fact] - public void DeleteLinksByAnySourceTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 1) (1 2)))"); - - // Act - ProcessQuery(links, "(((* 2)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } - - [Fact] - public void DeleteAllLinksBySourceAndTargetTest1() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((* *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void NestedDeleteAllLinksBySourceAndTargetTest1() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "((((* *) (* *))) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void DeleteAllLinksBySourceAndTargetTest2() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 1)))"); - - // Act - ProcessQuery(links, "(((* *)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void DeleteAllLinksByIndexTest() - { - RunTestWithLinks(links => - { - // Arrange - ProcessQuery(links, "(() ((1 2) (2 2)))"); - - // Act - ProcessQuery(links, "(((*:)) ())"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Empty(allLinks); - }); - } - - [Fact] - public void CreateNamedFamilyLinksTest() - { - RunTestWithLinks(links => - { - // Prepare query: create (child: father mother) - var query = "(() ((child: father mother)))"; - var options = new Options + [Fact] + public void CreateSingleLinkTest() { - Query = query, - }; - ProcessQuery(links, options); - - // Assert: links for 'father', 'mother', and 'child' exist and are named - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - Assert.NotEqual(links.Constants.Null, fatherId); - Assert.NotEqual(links.Constants.Null, motherId); - Assert.NotEqual(links.Constants.Null, childId); - Assert.Equal("father", links.GetName(fatherId)); - Assert.Equal("mother", links.GetName(motherId)); - Assert.Equal("child", links.GetName(childId)); - - // The child link should have father as source and mother as target - var allLinks = GetAllLinks(links); - var childLink = allLinks.First(l => l.Index == childId); - Assert.Equal(fatherId, childLink.Source); - Assert.Equal(motherId, childLink.Target); - }); - } - - [Fact] - public void CreateTwoNamedLinksTest() - { - RunTestWithLinks(links => - { - Console.WriteLine("[Test] Starting UpdateNamedLinkNameTest"); - - // Create initial link: (child: father mother) - Console.WriteLine("[Test] Step 1: Creating initial link"); - var createOptions = new Options { Query = "(() ((child: father mother)))", Trace = true }; - ProcessQuery(links, createOptions); - Console.WriteLine("[Test] Initial link creation completed"); - - // Verify initial state - Console.WriteLine("[Test] Step 2: Verifying initial state"); - var initialChildId = links.GetByName("child"); - Console.WriteLine($"[Test] Initial child ID: {initialChildId}"); - var initialFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Initial father ID: {initialFatherId}"); - var initialMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Initial mother ID: {initialMotherId}"); - - Assert.NotEqual(links.Constants.Null, initialChildId); - Assert.NotEqual(links.Constants.Null, initialFatherId); - Assert.NotEqual(links.Constants.Null, initialMotherId); - - var initialLinks = GetAllLinks(links); - Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); - var initialChildLink = initialLinks.First(l => l.Index == initialChildId); - Assert.Equal(initialFatherId, initialChildLink.Source); - Assert.Equal(initialMotherId, initialChildLink.Target); - Console.WriteLine("[Test] Initial state verification completed"); - - // Update child link to be named "son" instead - Console.WriteLine("[Test] Step 3: Updating link name"); - // First, let's try to remove the old name - Console.WriteLine("[Test] Removing old name 'child'"); - links.RemoveName(initialChildId); - Console.WriteLine("[Test] Old name removed"); - - // Then create the new link with the new name - Console.WriteLine("[Test] Creating new link with name 'son'"); - var updateOptions = new Options { Query = "(() ((son: father mother)))", Trace = true }; - ProcessQuery(links, updateOptions); - Console.WriteLine("[Test] New link creation completed"); - - // Verify final state - Console.WriteLine("[Test] Step 4: Verifying final state"); - Assert.Equal(links.Constants.Null, links.GetByName("child")); - var finalSonId = links.GetByName("son"); - Console.WriteLine($"[Test] Final son ID: {finalSonId}"); - var finalFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); - var finalMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); - - Assert.NotEqual(links.Constants.Null, finalSonId); - Assert.NotEqual(links.Constants.Null, finalFatherId); - Assert.NotEqual(links.Constants.Null, finalMotherId); - - var finalLinks = GetAllLinks(links); - Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); - var finalSonLink = Assert.Single(finalLinks, l => l.Index == finalSonId); - Assert.Equal(finalFatherId, finalSonLink.Source); - Assert.Equal(finalMotherId, finalSonLink.Target); - Console.WriteLine("[Test] Final state verification completed"); - Console.WriteLine("[Test] UpdateNamedLinkNameTest completed successfully"); - }, enableTracing: true); - } + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } - [Fact] - public void UpdateNamedLinkNameTest() - { - Console.WriteLine("[Test] ===== Starting UpdateNamedLinkNameTest ====="); - RunTestWithLinks(links => - { - try + [Fact] + public void CreateSingleLinkWithIndexTest() { - Console.WriteLine($"[Test] Constants: Null={links.Constants.Null}, Any={links.Constants.Any}, Continue={links.Constants.Continue}"); - // Step 1: Creating initial link - Console.WriteLine("[Test] Step 1: Creating initial link"); - var createQuery = "(() ((child: father mother)))"; - Console.WriteLine($"[Test] Query: {createQuery}"); - - var createOptions = new Options - { - Query = createQuery, - Trace = true - }; - ProcessQuery(links, createOptions); - Console.WriteLine("[Test] Initial link creation completed"); - - // Step 2: Verify initial state - Console.WriteLine("[Test] Step 2: Verifying initial state"); - var childId = links.GetByName("child"); - Console.WriteLine($"[Test] Initial child ID: {childId}"); - var fatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Initial father ID: {fatherId}"); - var motherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Initial mother ID: {motherId}"); - - var initialLinks = links.All().ToList(); - Console.WriteLine($"[Test] Initial links count: {initialLinks.Count}"); - foreach (var link in initialLinks) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Initial link: Index={link}, Source={source}, Target={target}"); - } - Console.WriteLine("[Test] Initial state verification completed"); - - // Step 3: Update link name - Console.WriteLine("[Test] Step 3: Updating link name from 'child' to 'son'"); - var updateQuery = "(((child: father mother)) ((son: father mother)))"; - Console.WriteLine($"[Test] Query: {updateQuery}"); - - // Log state before update - Console.WriteLine("[Test] Current state before update:"); - Console.WriteLine($"[Test] - child name exists: {links.GetByName("child") != 0}"); - Console.WriteLine($"[Test] - son name exists: {links.GetByName("son") != 0}"); - Console.WriteLine($"[Test] - father name exists: {links.GetByName("father") != 0}"); - Console.WriteLine($"[Test] - mother name exists: {links.GetByName("mother") != 0}"); - - Console.WriteLine("[Test] Starting ProcessQuery for update..."); - Console.WriteLine("[Test] Current links before update:"); - foreach (var link in links.All()) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); - } - - // Add detailed tracing for the update operation - var updateOptions = new Options - { - Query = updateQuery, - Trace = true, - ChangesHandler = (before, after) => + RunTestWithLinks(links => { - Console.WriteLine($"[Test] Update ChangesHandler called:"); - Console.WriteLine($"[Test] - Before state: {before}"); - Console.WriteLine($"[Test] - After state: {after}"); - - // Log name states during change - Console.WriteLine($"[Test] - child name during change: {links.GetByName("child")}"); - Console.WriteLine($"[Test] - son name during change: {links.GetByName("son")}"); - Console.WriteLine($"[Test] - father name during change: {links.GetByName("father")}"); - Console.WriteLine($"[Test] - mother name during change: {links.GetByName("mother")}"); - - // Log all links during change - Console.WriteLine("[Test] - All links during change:"); - foreach (var link in links.All()) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Link: Index={link}, Source={source}, Target={target}"); - } - - // Add detailed tracing for link creation - if (after != null && before == null) - { - var afterLink = new DoubletLink(after); - var source = links.GetSource(after); - var target = links.GetTarget(after); - Console.WriteLine($"[Test] Creating new link: Index={afterLink.Index}, Source={source}, Target={target}"); - Console.WriteLine($"[Test] Checking if link exists: {links.Exists>(afterLink.Index)}"); - Console.WriteLine($"[Test] Checking if source exists: {links.Exists>(source)}"); - Console.WriteLine($"[Test] Checking if target exists: {links.Exists>(target)}"); - - // Log all names before creation - Console.WriteLine("[Test] Names before creation:"); - foreach (var name in new[] { "child", "son", "father", "mother" }) - { - var id = links.GetByName(name); - Console.WriteLine($"[Test] - {name}: {id}"); - } - } - - return links.Constants.Continue; - } - }; - - ProcessQuery(links, updateOptions); - Console.WriteLine("[Test] Update operation completed"); - - // Step 4: Verify final state - Console.WriteLine("[Test] Step 4: Verifying final state"); - var finalChildId = links.GetByName("child"); - Console.WriteLine($"[Test] Final child ID: {finalChildId}"); - var finalSonId = links.GetByName("son"); - Console.WriteLine($"[Test] Final son ID: {finalSonId}"); - var finalFatherId = links.GetByName("father"); - Console.WriteLine($"[Test] Final father ID: {finalFatherId}"); - var finalMotherId = links.GetByName("mother"); - Console.WriteLine($"[Test] Final mother ID: {finalMotherId}"); - - var finalLinks = links.All().ToList(); - Console.WriteLine($"[Test] Final links count: {finalLinks.Count}"); - foreach (var link in finalLinks) - { - var source = links.GetSource(link); - var target = links.GetTarget(link); - Console.WriteLine($"[Test] Final link: Index={link}, Source={source}, Target={target}"); - } - - // Verify the update was successful - Assert.Equal(0, finalChildId); // Old name should be gone - Assert.NotEqual(0, finalSonId); // New name should exist - Assert.Equal(finalFatherId, links.GetSource(finalSonId)); // Source should be father - Assert.Equal(finalMotherId, links.GetTarget(finalSonId)); // Target should be mother - - Console.WriteLine("[Test] ===== UpdateNamedLinkNameTest completed successfully ====="); + // Act + ProcessQuery(links, "(() ((1: 1 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); } - catch (Exception ex) + + [Fact] + public void CreateSingleLinkWithIndexAfterGapTest() { - Console.WriteLine($"[Test] Error in UpdateNamedLinkNameTest: {ex}"); - Console.WriteLine($"[Test] Stack trace: {ex.StackTrace}"); - throw; + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((2: 2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 2, 2, 2); + }); } - }, enableTracing: true); - } - [Fact] - public void DeleteNamedFamilyLinksRemovesNamesTest() - { - RunTestWithLinks(links => - { - // Prepare query: create (child: father mother) - var query = "(() ((child: father mother)))"; - var options = new Options + [Fact] + public void CreateSingleLinkWithIndexAfterDoubleGapTest() { - Query = query, - }; - ProcessQuery(links, options); - - // Delete the 'child' link - var childId = links.GetByName("child"); - links.Delete(childId); - - // Assert: 'child' name is removed, 'father' and 'mother' remain - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.NotEqual(links.Constants.Null, links.GetByName("father")); - Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); - }); - } - - [Fact] - public void DeleteNamedLinkTest() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); - - ProcessQuery(links, "(((*:)) ())"); - - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.Equal(links.Constants.Null, links.GetByName("father")); - Assert.Equal(links.Constants.Null, links.GetByName("mother")); - }); - } - - [Fact] - public void DeleteByNamesTest() - { - RunTestWithLinks(links => - { - // Create link by name - ProcessQuery(links, "(() ((child: father mother)))"); - - // Delete link by name - ProcessQuery(links, "(((child: father mother)) ())"); - - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.NotEqual(links.Constants.Null, links.GetByName("father")); - Assert.NotEqual(links.Constants.Null, links.GetByName("mother")); - }); - } - - [Fact] - public void NameLookupConsistencyTest() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((x: 1 2)))"); - ProcessQuery(links, "(((x: 1 2)) ((y: 1 2)))"); - ProcessQuery(links, "(((y: 1 2)) ((z: 1 2)))"); - links.Delete(links.GetByName("z")); - Assert.Equal(links.Constants.Null, links.GetByName("x")); - Assert.Equal(links.Constants.Null, links.GetByName("y")); - Assert.Equal(links.Constants.Null, links.GetByName("z")); - }); - } - - [Fact] - public void CreateNamedLinkWithStringId_ShouldCreateSingleLink() - { - RunTestWithLinks(links => - { - var options = new Options { Query = "(() ((link: link link)))", Trace = true }; - ProcessQuery(links, options); - var allLinks = GetAllLinks(links); - // This should only create a single named link with string id 'link' - Assert.Single(allLinks); - var linkId = links.GetByName("link"); - Assert.NotEqual(links.Constants.Null, linkId); - var link = allLinks.First(); - Assert.Equal(linkId, link.Index); - Assert.Equal(linkId, link.Source); - Assert.Equal(linkId, link.Target); - Assert.Equal("link", links.GetName(linkId)); - }, enableTracing: true); - } - - [Fact] - public void CreateLinkWithIntegerId_ShouldCreateSingleLink() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1)))"); - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - var link = allLinks.First(); - Assert.Equal(1u, link.Index); - Assert.Equal(1u, link.Source); - Assert.Equal(1u, link.Target); - }); - } - - [Fact] - public void CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - ProcessQuery(links, "(() ((2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((3: 3 3)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 3, 3, 3); + }); + } [Fact] - public void CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((1: 1 1)))"); - ProcessQuery(links, "(() ((2: 1 2)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 1, 2); - }); - } - - [Fact] - public void CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((type: type type)))"); - ProcessQuery(links, "(() ((link: link type)))"); - - // Assert - var allLinks = GetAllLinks(links); - // Expect only two links, but extra self-referential named link for 'link' is created indicating a bug. - Assert.Equal(2, allLinks.Count); - var typeId = links.GetByName("type"); - var linkId = links.GetByName("link"); - AssertLinkExists(allLinks, typeId, typeId, typeId); - AssertLinkExists(allLinks, linkId, linkId, typeId); - }); - } - - [Fact] - public void CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((type: type type)))"); - ProcessQuery(links, "(() ((link: type link)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - var typeId = links.GetByName("type"); - var linkId = links.GetByName("link"); - AssertLinkExists(allLinks, typeId, typeId, typeId); - AssertLinkExists(allLinks, linkId, typeId, linkId); - }); - } - - // ============================================ - // Link Deduplication Tests - // ============================================ - - [Fact] - public void DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink() - { - // Issue #65: Test deduplication of (m a) (m a) pattern - // Query: () (((m a) (m a))) - // Expected: m, a (named self-refs), link 3 = (m a), link 4 = (3 3) - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((m a) (m a))))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(4, allLinks.Count); - - // Get the named link IDs - var mId = links.GetByName("m"); - var aId = links.GetByName("a"); - - Assert.NotEqual(links.Constants.Null, mId); - Assert.NotEqual(links.Constants.Null, aId); - - // m and a should be self-referencing - AssertLinkExists(allLinks, mId, mId, mId); - AssertLinkExists(allLinks, aId, aId, aId); - - // Find the (m a) link - var maLink = allLinks.FirstOrDefault(l => l.Source == mId && l.Target == aId); - Assert.NotEqual(default, maLink); - - // Find the outer link ((m a) (m a)) which should be (maLink.Index maLink.Index) - var outerLink = allLinks.FirstOrDefault(l => l.Source == maLink.Index && l.Target == maLink.Index); - Assert.NotEqual(default, outerLink); - - // Verify deduplication: the outer link's source and target should be the same - Assert.Equal(outerLink.Source, outerLink.Target); - }); - } - - [Fact] - public void DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink() - { - // Issue #65: Test deduplication with numeric IDs - // Query: () (((1 2) (1 2))) - // When using numeric IDs directly, they are treated as references (not creating self-refs) - // So (1 2) creates link with source=1, target=2 - // The deduplication still works: ((1 2) (1 2)) creates only one (1 2) link - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((1 2) (1 2))))"); - - // Assert - var allLinks = GetAllLinks(links); - - // Should have 2 links: (1 2) and ((1 2) (1 2)) - Assert.Equal(2, allLinks.Count); - - // Link 1 should be (1 2) - the deduplicated sub-link - AssertLinkExists(allLinks, 1, 1, 2); - - // Link 2 should be (1 1) - referencing the same sub-link twice - AssertLinkExists(allLinks, 2, 1, 1); - }); - } - - [Fact] - public void DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink() - { - // Test with three identical pairs using named links: (((a b) ((a b) (a b)))) - // The (a b) should only be created once - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((a b) ((a b) (a b)))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var aId = links.GetByName("a"); - var bId = links.GetByName("b"); - - // a and b should be self-referencing - AssertLinkExists(allLinks, aId, aId, aId); - AssertLinkExists(allLinks, bId, bId, bId); + public void CreateLinkWithSource2Target2Test() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - // Find (a b) link - the deduplicated sub-link - var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); - Assert.NotEqual(default, abLink); + [Fact] + public void CreateMultipleLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() ((1 1) (2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - // Find ((a b) (a b)) link - should reference abLink twice - var innerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == abLink.Index); - Assert.NotEqual(default, innerLink); + [Fact] + public void Create2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(3, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 1, 2); + }); + } - // Find outer link ((a b) ((a b) (a b))) - var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == innerLink.Index); - Assert.NotEqual(default, outerLink); + [Fact] + public void Create3LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 1) ((2 2) (3 3)))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(5, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 2, 3); + AssertLinkExists(allLinks, 5, 1, 4); + }); + } - Assert.Equal(5, allLinks.Count); - }); - } + [Fact] + public void Create4LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Act + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) (4 4))))))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(7, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 3, 4); + AssertLinkExists(allLinks, 6, 2, 5); + AssertLinkExists(allLinks, 7, 1, 6); + }); + } - [Fact] - public void DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks() - { - // Test that named links are reused across queries - RunTestWithLinks(links => - { - // First query creates (m a) - ProcessQuery(links, "(() ((m a)))"); + [Fact] + public void Create5LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Structure visualization: + // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5) + // ((4 4) (5 5)) => #6: 4->5 + // ((3 3) ((4 4) (5 5))) => #7: 3->6 + // ((2 2) ((3 3) ((4 4) (5 5)))) => #8: 2->7 + // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9: 1->8 + // + // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))" + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) (5 5)))))))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(9, allLinks.Count); + + // Leaf links + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 5, 5); + + // ((4 4) (5 5)) => #6:4->5 + AssertLinkExists(allLinks, 6, 4, 5); + + // ((3 3) ((4 4) (5 5))) => #7:3->6 + AssertLinkExists(allLinks, 7, 3, 6); + + // ((2 2) ((3 3) ((4 4) (5 5)))) => #8:2->7 + AssertLinkExists(allLinks, 8, 2, 7); + + // ((1 1) ((2 2) ((3 3) ((4 4) (5 5))))) => #9:1->8 + AssertLinkExists(allLinks, 9, 1, 8); + }); + } - var mId = links.GetByName("m"); - var aId = links.GetByName("a"); + [Fact] + public void Create6LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Structure: + // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6) + // ((5 5) (6 6)) => #7:5->6 + // ((4 4) ((5 5) (6 6))) => #8:4->7 + // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 + // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 + // + // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))" + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))))))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(11, allLinks.Count); + + // Leaf links + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 5, 5); + AssertLinkExists(allLinks, 6, 6, 6); + + // ((5 5) (6 6)) => #7:5->6 + AssertLinkExists(allLinks, 7, 5, 6); + + // ((4 4) ((5 5) (6 6))) => #8:4->7 + AssertLinkExists(allLinks, 8, 4, 7); + + // ((3 3) ((4 4) ((5 5) (6 6)))) => #9:3->8 + AssertLinkExists(allLinks, 9, 3, 8); + + // ((2 2) ((3 3) ((4 4) ((5 5) (6 6))))) => #10:2->9 + AssertLinkExists(allLinks, 10, 2, 9); + + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) (6 6)))))) => #11:1->10 + AssertLinkExists(allLinks, 11, 1, 10); + }); + } - // Second query should reuse existing m and a links - ProcessQuery(links, "(() (((m a) (m a))))"); + [Fact] + public void Create7LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Leaves: (1 1), (2 2), (3 3), (4 4), (5 5), (6 6), (7 7) + // ((6 6) (7 7)) => #8:6->7 + // ((5 5) ((6 6) (7 7))) => #9:5->8 + // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 + // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 + // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 + // + // Query: "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))" + ProcessQuery(links, "(() (((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))))))"); + + var allLinks = GetAllLinks(links); + Assert.Equal(13, allLinks.Count); + + // Leaf links + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + AssertLinkExists(allLinks, 3, 3, 3); + AssertLinkExists(allLinks, 4, 4, 4); + AssertLinkExists(allLinks, 5, 5, 5); + AssertLinkExists(allLinks, 6, 6, 6); + AssertLinkExists(allLinks, 7, 7, 7); + + // ((6 6) (7 7)) => #8:6->7 + AssertLinkExists(allLinks, 8, 6, 7); + + // ((5 5) ((6 6) (7 7))) => #9:5->8 + AssertLinkExists(allLinks, 9, 5, 8); + + // ((4 4) ((5 5) ((6 6) (7 7)))) => #10:4->9 + AssertLinkExists(allLinks, 10, 4, 9); + + // ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))) => #11:3->10 + AssertLinkExists(allLinks, 11, 3, 10); + + // ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7)))))) => #12:2->11 + AssertLinkExists(allLinks, 12, 2, 11); + + // ((1 1) ((2 2) ((3 3) ((4 4) ((5 5) ((6 6) (7 7))))))) => #13:1->12 + AssertLinkExists(allLinks, 13, 1, 12); + }); + } - // Assert - var allLinks = GetAllLinks(links); + [Fact] + public void UpdateSingleLinkTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((2 2)))"); + + // Act + ProcessQuery(links, "(((1: 1 1)) ((1: 1 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - // m and a should still have the same IDs - Assert.Equal(mId, links.GetByName("m")); - Assert.Equal(aId, links.GetByName("a")); + [Fact] + public void ExactMatchAndDelete2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Act + ProcessQuery(links, "(((3: (1: 1 1) (2: 2 2))) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - // Should have 4 links total: m, a, (m a), ((m a) (m a)) - Assert.Equal(4, allLinks.Count); - }); - } + [Fact] + public void MatchWithExactIndexAndDelete2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Act + ProcessQuery(links, "(( (3: (1 *) (* 2)) ) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks() - { - // Test that different pairs are NOT deduplicated - // Query: () (((a b) (b a))) - using named links - // (a b) and (b a) are different and should both be created - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() (((a b) (b a))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var aId = links.GetByName("a"); - var bId = links.GetByName("b"); - - // a and b should be self-referencing - AssertLinkExists(allLinks, aId, aId, aId); - AssertLinkExists(allLinks, bId, bId, bId); - - // Find (a b) link - var abLink = allLinks.FirstOrDefault(l => l.Source == aId && l.Target == bId); - Assert.NotEqual(default, abLink); - - // Find (b a) link - var baLink = allLinks.FirstOrDefault(l => l.Source == bId && l.Target == aId); - Assert.NotEqual(default, baLink); - - // Find outer link ((a b) (b a)) - should have different source and target - var outerLink = allLinks.FirstOrDefault(l => l.Source == abLink.Index && l.Target == baLink.Index); - Assert.NotEqual(default, outerLink); - Assert.NotEqual(outerLink.Source, outerLink.Target); - - Assert.Equal(5, allLinks.Count); - }); - } + [Fact] + public void MatchAndDelete2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() (((1 1) (2 2))))"); + + // Act + ProcessQuery(links, "(( ((1 *) (* 2)) ) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels() - { - // Test deeply nested deduplication using named links - // Query: () ((((x y) (x y)) ((x y) (x y)))) - // (x y) is duplicated at multiple levels - RunTestWithLinks(links => - { - // Act - ProcessQuery(links, "(() ((((x y) (x y)) ((x y) (x y)))))"); - - // Assert - var allLinks = GetAllLinks(links); - - var xId = links.GetByName("x"); - var yId = links.GetByName("y"); - - // x and y should be self-referencing - AssertLinkExists(allLinks, xId, xId, xId); - AssertLinkExists(allLinks, yId, yId, yId); - - // Find (x y) - the base link - var xyLink = allLinks.FirstOrDefault(l => l.Source == xId && l.Target == yId); - Assert.NotEqual(default, xyLink); - - // Find ((x y) (x y)) - references (x y) twice (deduplicated) - var level1Link = allLinks.FirstOrDefault(l => l.Source == xyLink.Index && l.Target == xyLink.Index); - Assert.NotEqual(default, level1Link); - - // Find (((x y) (x y)) ((x y) (x y))) - references level1Link twice (deduplicated) - var level2Link = allLinks.FirstOrDefault(l => l.Source == level1Link.Index && l.Target == level1Link.Index); - Assert.NotEqual(default, level2Link); - - // Total: x, y, (x y), ((x y) (x y)), (((x y) (x y)) ((x y) (x y))) - Assert.Equal(5, allLinks.Count); - }); - } + [Fact] + public void NoExactMatch2LevelNestedLinksTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQueryStrict(links, "() ((1: 1 1) (2: 2 2))"); + + // Act + ProcessQueryStrict(links, "((1: (1: 1 1) (1: 2 1))) ()"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds() - { - // Issue #65: Verify that named links maintain consistent IDs across operations - RunTestWithLinks(links => - { - // First create named links - ProcessQuery(links, "(() ((p: p p)))"); - ProcessQuery(links, "(() ((a: a a)))"); - - var pId = links.GetByName("p"); - var aId = links.GetByName("a"); - - // Now create ((p a) (p a)) - should reuse existing p and a - ProcessQuery(links, "(() (((p a) (p a))))"); - - // Assert - var allLinks = GetAllLinks(links); - - // p and a should still have the same IDs - Assert.Equal(pId, links.GetByName("p")); - Assert.Equal(aId, links.GetByName("a")); - - // Verify the structure - AssertLinkExists(allLinks, pId, pId, pId); - AssertLinkExists(allLinks, aId, aId, aId); - - // Find (p a) link - var paLink = allLinks.FirstOrDefault(l => l.Source == pId && l.Target == aId); - Assert.NotEqual(default, paLink); - - // Find ((p a) (p a)) link - should reference paLink twice - var outerLink = allLinks.FirstOrDefault(l => l.Source == paLink.Index && l.Target == paLink.Index); - Assert.NotEqual(default, outerLink); - }); - } + [Fact] + public void NoUpdateUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((2 2)))"); - [Fact] - public void StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((father: father father)))"); - ProcessQuery(links, "(() ((mother: mother mother)))"); - ProcessQuery(links, "(() ((child: father mother)))"); - - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - - ProcessQuery(links, "((($id: father mother)) (($id: mother father)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, fatherId, fatherId, fatherId); - AssertLinkExists(allLinks, motherId, motherId, motherId); - AssertLinkExists(allLinks, childId, motherId, fatherId); - }); - } + Options options = new Options(); - [Fact] - public void Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1) (18: 1 21) (19: 1 20) (20: 20 20) (21: 21 21)))"); - - ProcessQuery(links, "((($i: 1 21)) (($i: $s $t) ($i 20)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(6, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 18, 1, 21); - AssertLinkExists(allLinks, 19, 1, 20); - AssertLinkExists(allLinks, 20, 20, 20); - AssertLinkExists(allLinks, 21, 21, 21); - - var outgoingLink = Assert.Single(allLinks, link => link.Source == 18 && link.Target == 20); - Assert.NotEqual(links.Constants.Null, outgoingLink.Index); - Assert.NotEqual(links.Constants.Any, outgoingLink.Index); - Assert.DoesNotContain(allLinks, link => link.Index == links.Constants.Any || link.Source == links.Constants.Any || link.Target == links.Constants.Any); - }); - } + var changes = new List<(DoubletLink, DoubletLink)>(); + options.Query = "((($index: $source $target)) (($index: $source $target)))"; + options.ChangesHandler = (before, after) => + { + changes.Add((new DoubletLink(before), new DoubletLink(after))); + return links.Constants.Continue; + }; + + // Act + ProcessQuery(links, options); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + Assert.Equal(2, changes.Count); + AssertChangeExists(changes, new DoubletLink(1, 1, 1), new DoubletLink(1, 1, 1)); + AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); + }); + } - [Fact] - public void Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((21: 21 21)))"); + [Fact] + public void SwapSourceAndTargetForSingleLinkUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + ProcessQuery(links, "(() ((1 2)))"); + + // Act + ProcessQuery(links, "(((2: $source $target)) ((2: $target $source)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } - ProcessQuery(links, "(((21: 21 21)) ((21: $s $t)))"); + [Fact] + public void SwapSourceAndTargetForAllLinksUsingVariablesTest() + { + RunTestWithLinks(links => + { + // Arrange: create initial links (1: 1 2) and (2: 2 1) + ProcessQuery(links, "(() ((1 2) (2 1)))"); + + // Act: swap source and target for all links + ProcessQuery(links, "((($index: $source $target)) (($index: $target $source)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 2, 1); + AssertLinkExists(allLinks, 2, 1, 2); + }); + } - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 21, 21, 21); - Assert.DoesNotContain(allLinks, link => link.Source == links.Constants.Any || link.Target == links.Constants.Any); - }); - } + [Fact] + public void SwapEqualSourceAndTargetUsingVariablesHasAllChangesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "() ((1 1) (2 2))"); + ProcessQuery(links, "((1: 1 1)) ((1: 1 2))"); - [Fact] - public void EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException() - { - RunTestWithLinks(links => - { - var exception = Assert.Throws(() => LinksExtensions.EnsureCreated(links, links.Constants.Any)); + Options options = new Options(); - Assert.Contains("unsupported link address", exception.Message); - }); - } + var changes = new List<(DoubletLink, DoubletLink)>(); + options.Query = "((($index: $source $target)) (($index: $target $source)))"; + options.ChangesHandler = (before, after) => + { + changes.Add((new DoubletLink(before), new DoubletLink(after))); + return links.Constants.Continue; + }; + + // Act + ProcessQuery(links, options); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 2, 1); + AssertLinkExists(allLinks, 2, 2, 2); + Assert.Equal(2, changes.Count); + AssertChangeExists(changes, new DoubletLink(1, 1, 2), new DoubletLink(1, 2, 1)); + AssertChangeExists(changes, new DoubletLink(2, 2, 2), new DoubletLink(2, 2, 2)); + }); + } - // Helper methods - private static void RunTestWithLinks(Action> testAction, bool enableTracing = false) - { - string tempDbFile = Path.GetTempFileName(); - NamedTypesDecorator? decoratedLinks = null; - try - { - decoratedLinks = new NamedTypesDecorator(tempDbFile, tracingEnabled: enableTracing); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); - var task = Task.Run(() => + [Fact] + public void MakeAllLinksToGoOutOfFirstLinkUsingVariablesTest() { - testAction(decoratedLinks); - }, cts.Token); + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((2 2) (2 1)))"); + + // Act: make all links to go out of the first link + ProcessQuery(links, "((($index: $source $target)) (($index: 1 $target)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 1, 1); + }); + } - try + [Fact] + public void MakeAllLinksToGoIntoFirstLinkUsingVariablesTest() { - task.Wait(cts.Token); + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((2 2) (1 2)))"); + + // Act: make all links to go into the first link + ProcessQuery(links, "((($index: $source $target)) (($index: $source 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 2, 1); + AssertLinkExists(allLinks, 2, 1, 1); + }); } - catch (OperationCanceledException) + + [Fact] + public void MakeAllLinksSelfReferencingUsingVariablesTest() { - Console.WriteLine("[Test] Test was cancelled after 1 seconds timeout"); - throw new TimeoutException("Test exceeded 1 seconds timeout"); + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2) (2 1)))"); + + // Act: make all links self-referencing + ProcessQuery(links, "((($index: $source $target)) (($index: $index $index)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); } - } - finally - { - if (decoratedLinks != null && File.Exists(decoratedLinks.NamedLinksDatabaseFileName)) + + [Fact] + public void MatchSelfReferencingAndMakeThemGoOutFromFirstLinkUsingVariablesTest() { - File.Delete(decoratedLinks.NamedLinksDatabaseFileName); + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2) (3 1) (4 4)))"); + + // Act: match self-referencing links and make them go out from the first link + ProcessQuery(links, "((($index: $index $index)) (($index: 1 $index)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(4, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 1, 2); + AssertLinkExists(allLinks, 3, 3, 1); + AssertLinkExists(allLinks, 4, 1, 4); + }); } - File.Delete(tempDbFile); - } - } - private static List GetAllLinks(NamedTypesDecorator links) - { - var any = links.Constants.Any; - var query = new DoubletLink(index: any, source: any, target: any); - var allLinks = links.All(query).Select(doublet => new DoubletLink(doublet)).ToList(); - Console.WriteLine($"[Test] All links: {string.Join(" ", allLinks)}"); - return allLinks; - } - - private static void ProcessQuery(NamedTypesDecorator links, string query) - { - ProcessQuery(links, new Options { Query = query }); - } - - private static void ProcessQuery(NamedTypesDecorator links, Options options) - { - options.AutoCreateMissingReferences = true; - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); - } - - private static void ProcessQueryStrict(NamedTypesDecorator links, string query) - { - ProcessQueryStrict(links, new Options { Query = query }); - } - - private static void ProcessQueryStrict(NamedTypesDecorator links, Options options) - { - options.AutoCreateMissingReferences = false; - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, options); - } - - private static void AssertLinkExists(List allLinks, uint index, uint source, uint target) - { - var link = new DoubletLink(index, source, target); - Assert.True(allLinks.Contains(link), $"Link {link} not found in the list of all links ({string.Join(" ", allLinks)})"); - } - - private static void AssertChangeExists(List<(DoubletLink, DoubletLink)> changes, DoubletLink linkBefore, DoubletLink linkAfter) - { - Assert.Contains(changes, change => change.Item1 == linkBefore && change.Item2 == linkAfter); - } - - // New tests for link reference validation + [Fact] + public void MultipleUpdatesTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2)))"); + + // Act + ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } - [Fact] - public void CreateLinkWithNonExistentReference_ShouldThrowException() - { - RunTestWithLinks(links => - { - // Act & Assert - should throw exception for referencing non-existent link 10 - var exception = Assert.Throws(() => + [Fact] + public void MixedMultipleUpdatesTest() { - ProcessQueryStrict(links, "(() ((1: 10 20)))"); - }); + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2)))"); + + // Act + ProcessQuery(links, "(((2: 2 2) (1: 1 1)) ((1: 1 2) (2: 2 1)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 2); + AssertLinkExists(allLinks, 2, 2, 1); + }); + } - Assert.Contains("Invalid reference to non-existent link '10'", exception.Message); - Assert.Contains("--auto-create-missing-references", exception.Message); - }); - } + [Fact] + public void CreationDuringUpdateTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + + // Act: Add new link with ID '2' by including it only in substitution + ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (2: 2 2)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void CreateLinkWithValidSelfReference_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because link 1 references itself - ProcessQueryStrict(links, "(() ((1: 1 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - AssertLinkExists(allLinks, 1, 1, 1); - }); - } + [Fact] + public void CreationWithEmptySlotDuringUpdateTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1)))"); + + // Act: Add new link with ID '2' by including it only in substitution + ProcessQuery(links, "(((1: 1 1)) ((1: 1 1) (3: 3 3)))"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Equal(2, allLinks.Count); + AssertLinkExists(allLinks, 1, 1, 1); + AssertLinkExists(allLinks, 3, 3, 3); + }); + } - [Fact] - public void CreateMultipleLinksWithCrossReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because both links are created in the same operation - ProcessQueryStrict(links, "(() ((1: 1 2) (2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 2); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + [Fact] + public void DeletionDuringUpdateTest() + { + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 1) (2 2)))"); - [Fact] - public void CreateLinkReferencingExistingLink_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Arrange - create first link - ProcessQueryStrict(links, "(() ((1: 1 1)))"); - - // Act - should succeed because link 1 exists - ProcessQueryStrict(links, "(() ((2: 2 1)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 1, 1, 1); - AssertLinkExists(allLinks, 2, 2, 1); - }); - } + // Act: Remove link with ID '2' by omitting it in substitution + ProcessQuery(links, "(((1: 1 1) (2: 2 2)) ((1: 1 1)))"); - [Fact] - public void UpdateWithNonExistentReference_ShouldThrowException() - { - RunTestWithLinks(links => - { - // Arrange - create initial link - ProcessQueryStrict(links, "(() ((1: 1 1)))"); + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 1, 1, 1); + }); + } - // Act & Assert - should throw exception for referencing non-existent link 99 - var exception = Assert.Throws(() => + [Fact] + public void DeleteSingleLinkTest_Source1Target2() { - ProcessQueryStrict(links, "(((1: 1 1)) ((1: 1 99)))"); - }); - - Assert.Contains("Invalid reference to non-existent link '99'", exception.Message); - }); - } + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((1 2)))"); + ProcessQuery(links, "(() ((2 2)))"); + + // Act + ProcessQuery(links, "(((1 2)) ())"); + + // Assert + var allLinks = GetAllLinks(links); + Assert.Single(allLinks); + AssertLinkExists(allLinks, 2, 2, 2); + }); + } - [Fact] - public void CreateNamedLinkWithMissingNamedReferences_ShouldThrowException() - { - RunTestWithLinks(links => - { - var exception = Assert.Throws(() => + [Fact] + public void DeleteSingleLinkTest_Source2Target2() { - ProcessQueryStrict(links, "(() ((child: father mother)))"); - }); - - Assert.Contains("Invalid reference to non-existent link 'father'", exception.Message); - Assert.Contains("--auto-create-missing-references", exception.Message); - }); - } - - [Fact] - public void CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((20: 10 20)))"); - - var allLinks = GetAllLinks(links); - Assert.Equal(2, allLinks.Count); - AssertLinkExists(allLinks, 10, 10, 10); - AssertLinkExists(allLinks, 20, 10, 20); - }); - } - - [Fact] - public void CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); - - var fatherId = links.GetByName("father"); - var motherId = links.GetByName("mother"); - var childId = links.GetByName("child"); - - var allLinks = GetAllLinks(links); - Assert.Equal(3, allLinks.Count); - AssertLinkExists(allLinks, fatherId, fatherId, fatherId); - AssertLinkExists(allLinks, motherId, motherId, motherId); - AssertLinkExists(allLinks, childId, fatherId, motherId); - }); - } + RunTestWithLinks(links => + { + // Arrange + ProcessQuery(links, "(() ((2 2)))"); - [Fact] - public void CreateLinkWithVariableReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because variables are not validated - ProcessQueryStrict(links, "(() (($link: $source $target)))"); - - // Assert - one link should be created with variables resolved - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - }); - } + // Act + ProcessQuery(links, "(((2 2)) ())"); - [Fact] - public void CreateLinkWithWildcardReferences_ShouldSucceed() - { - RunTestWithLinks(links => - { - // Act - should succeed because wildcards are not validated - ProcessQueryStrict(links, "(() ((1: * *)))"); - - // Assert - var allLinks = GetAllLinks(links); - Assert.Single(allLinks); - }); + // Assert + var allLinks = GetAllLinks(links); + Assert.Empty(allLinks); + }); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs index a0bcfb1..9dec3a6 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs @@ -4,13 +4,13 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class ChangesSimplifierTests - { - [Fact] - public void SimplifyChanges_SpecificExample_RemovesIntermediateStates() + public class ChangesSimplifierTests { - // Arrange - var changes = new List<(Link Before, Link After)> + [Fact] + public void SimplifyChanges_SpecificExample_RemovesIntermediateStates() + { + // Arrange + var changes = new List<(Link Before, Link After)> { // (1: 2 1) ↦ (1: 0 0) (new Link(index: 1, source: 2, target: 1), new Link(index: 1, source: 0, target: 0)), @@ -25,39 +25,39 @@ public void SimplifyChanges_SpecificExample_RemovesIntermediateStates() (new Link(index: 1, source: 0, target: 0), new Link(index: 0, source: 0, target: 0)) }; - // Expected simplified changes: - // (1: 2 1) ↦ (0: 0 0) - // (2: 1 2) ↦ (0: 0 0) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes: + // (1: 2 1) ↦ (0: 0 0) + // (2: 1 2) ↦ (0: 0 0) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 2, target: 1), new Link(index: 0, source: 0, target: 0)), (new Link(index: 2, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_MultipleChainsFromSameBefore_RemovesIntermediateStates() - { - // Arrange - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_MultipleChainsFromSameBefore_RemovesIntermediateStates() + { + // Arrange + var changes = new List<(Link Before, Link After)> { // (0: 0 0) ↦ (1: 0 0) (new Link(index: 0, source: 0, target: 0), new Link(index: 1, source: 0, target: 0)), @@ -72,92 +72,92 @@ public void SimplifyChanges_MultipleChainsFromSameBefore_RemovesIntermediateStat (new Link(index: 2, source: 0, target: 0), new Link(index: 2, source: 2, target: 1)) }; - // Expected simplified changes: - // (0: 0 0) ↦ (1: 1 2) - // (0: 0 0) ↦ (2: 2 1) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes: + // (0: 0 0) ↦ (1: 1 2) + // (0: 0 0) ↦ (2: 2 1) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 0, source: 0, target: 0), new Link(index: 1, source: 1, target: 2)), (new Link(index: 0, source: 0, target: 0), new Link(index: 2, source: 2, target: 1)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_NoChange_StillKeepsFirstAndLastState() - { - // Arrange - // These changes represent a "read" operation: the link is read but not changed. - // (1: 2 1) -> (1: 2 1) - // (2: 1 2) -> (2: 1 2) - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_NoChange_StillKeepsFirstAndLastState() + { + // Arrange + // These changes represent a "read" operation: the link is read but not changed. + // (1: 2 1) -> (1: 2 1) + // (2: 1 2) -> (2: 1 2) + var changes = new List<(Link Before, Link After)> { (new Link(index: 1, source: 2, target: 1), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 1, target: 2), new Link(index: 2, source: 1, target: 2)) }; - // Expected simplified changes: - // They are the same because no actual changes occurred, but we still keep them. - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes: + // They are the same because no actual changes occurred, but we still keep them. + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 2, target: 1), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 1, target: 2), new Link(index: 2, source: 1, target: 2)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates() - { - // Arrange - // These transitions demonstrate several branches starting from (0: 0 0), - // each ultimately reaching (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). - // The intermediate states (1: 0 0), (2: 0 0), (3: 0 0), (4: 0 0) are - // transitions on the way to the final states. - // - // Original transitions (Before -> After): - // (0: 0 0) -> (1: 0 0) - // (1: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 0 0) - // (2: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 0 0) - // (3: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 0 0) - // (4: 0 0) -> (4: 4 4) - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates() + { + // Arrange + // These transitions demonstrate several branches starting from (0: 0 0), + // each ultimately reaching (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). + // The intermediate states (1: 0 0), (2: 0 0), (3: 0 0), (4: 0 0) are + // transitions on the way to the final states. + // + // Original transitions (Before -> After): + // (0: 0 0) -> (1: 0 0) + // (1: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 0 0) + // (2: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 0 0) + // (3: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 0 0) + // (4: 0 0) -> (4: 4 4) + var changes = new List<(Link Before, Link After)> { (new Link(0, 0, 0), new Link(1, 0, 0)), (new Link(1, 0, 0), new Link(1, 1, 1)), @@ -169,12 +169,12 @@ public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinal (new Link(4, 0, 0), new Link(4, 4, 4)) }; - // Expected final transitions (After simplification): - // (0: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 4 4) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected final transitions (After simplification): + // (0: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 4 4) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(0, 0, 0), new Link(1, 1, 1)), (new Link(0, 0, 0), new Link(2, 2, 2)), @@ -182,42 +182,42 @@ public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinal (new Link(0, 0, 0), new Link(4, 4, 4)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - // We expect exactly four final states from (0: 0 0), each linking directly - // to one of the final links (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - - // Check that each expected (Before, After) pair is present in the result. - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains(simplifiedChanges, actual => - actual.Before.Index == expected.Before.Index && - actual.Before.Source == expected.Before.Source && - actual.Before.Target == expected.Before.Target && - actual.After.Index == expected.After.Index && - actual.After.Source == expected.After.Source && - actual.After.Target == expected.After.Target - ); - } - } - - [Fact] - public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates_InCorrectOrder() - { - // Arrange - // Original transitions (Before -> After): - // (0: 0 0) -> (1: 0 0) - // (1: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 0 0) - // (2: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 0 0) - // (3: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 0 0) - // (4: 0 0) -> (4: 4 4) - var changes = new List<(Link Before, Link After)> + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + // We expect exactly four final states from (0: 0 0), each linking directly + // to one of the final links (1: 1 1), (2: 2 2), (3: 3 3), or (4: 4 4). + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + + // Check that each expected (Before, After) pair is present in the result. + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains(simplifiedChanges, actual => + actual.Before.Index == expected.Before.Index && + actual.Before.Source == expected.Before.Source && + actual.Before.Target == expected.Before.Target && + actual.After.Index == expected.After.Index && + actual.After.Source == expected.After.Source && + actual.After.Target == expected.After.Target + ); + } + } + + [Fact] + public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinalStates_InCorrectOrder() + { + // Arrange + // Original transitions (Before -> After): + // (0: 0 0) -> (1: 0 0) + // (1: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 0 0) + // (2: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 0 0) + // (3: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 0 0) + // (4: 0 0) -> (4: 4 4) + var changes = new List<(Link Before, Link After)> { (new Link(0, 0, 0), new Link(1, 0, 0)), (new Link(1, 0, 0), new Link(1, 1, 1)), @@ -229,80 +229,80 @@ public void SimplifyChanges_MultipleBranchesFromSameInitial_ProducesCorrectFinal (new Link(4, 0, 0), new Link(4, 4, 4)) }; - // Expected final transitions (After simplification): - // (0: 0 0) -> (1: 1 1) - // (0: 0 0) -> (2: 2 2) - // (0: 0 0) -> (3: 3 3) - // (0: 0 0) -> (4: 4 4) - - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Assert - // 1) Ensure we got exactly 4 transitions after simplification. - Assert.Equal(4, simplifiedChanges.Count); - - // 2) Check that each final pair is present (as before), - // AND that they appear in ascending order: - // first => After.Index == 1, - // second => After.Index == 2, - // third => After.Index == 3, - // fourth => After.Index == 4. - - Assert.Collection( - simplifiedChanges, - // 1st final link: (0:0 0) -> (1:1 1) - first => - { - Assert.Equal(0u, first.Before.Index); - Assert.Equal(0u, first.Before.Source); - Assert.Equal(0u, first.Before.Target); - - Assert.Equal(1u, first.After.Index); - Assert.Equal(1u, first.After.Source); - Assert.Equal(1u, first.After.Target); - }, - // 2nd final link: (0:0 0) -> (2:2 2) - second => - { - Assert.Equal(0u, second.Before.Index); - Assert.Equal(0u, second.Before.Source); - Assert.Equal(0u, second.Before.Target); - - Assert.Equal(2u, second.After.Index); - Assert.Equal(2u, second.After.Source); - Assert.Equal(2u, second.After.Target); - }, - // 3rd final link: (0:0 0) -> (3:3 3) - third => - { - Assert.Equal(0u, third.Before.Index); - Assert.Equal(0u, third.Before.Source); - Assert.Equal(0u, third.Before.Target); - - Assert.Equal(3u, third.After.Index); - Assert.Equal(3u, third.After.Source); - Assert.Equal(3u, third.After.Target); - }, - // 4th final link: (0:0 0) -> (4:4 4) - fourth => - { - Assert.Equal(0u, fourth.Before.Index); - Assert.Equal(0u, fourth.Before.Source); - Assert.Equal(0u, fourth.Before.Target); - - Assert.Equal(4u, fourth.After.Index); - Assert.Equal(4u, fourth.After.Source); - Assert.Equal(4u, fourth.After.Target); - } - ); - } - - [Fact] - public void SimplifyChanges_SpecificExample_KeepsUnchangedStates() - { - // Arrange - var changes = new List<(Link Before, Link After)> + // Expected final transitions (After simplification): + // (0: 0 0) -> (1: 1 1) + // (0: 0 0) -> (2: 2 2) + // (0: 0 0) -> (3: 3 3) + // (0: 0 0) -> (4: 4 4) + + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Assert + // 1) Ensure we got exactly 4 transitions after simplification. + Assert.Equal(4, simplifiedChanges.Count); + + // 2) Check that each final pair is present (as before), + // AND that they appear in ascending order: + // first => After.Index == 1, + // second => After.Index == 2, + // third => After.Index == 3, + // fourth => After.Index == 4. + + Assert.Collection( + simplifiedChanges, + // 1st final link: (0:0 0) -> (1:1 1) + first => + { + Assert.Equal(0u, first.Before.Index); + Assert.Equal(0u, first.Before.Source); + Assert.Equal(0u, first.Before.Target); + + Assert.Equal(1u, first.After.Index); + Assert.Equal(1u, first.After.Source); + Assert.Equal(1u, first.After.Target); + }, + // 2nd final link: (0:0 0) -> (2:2 2) + second => + { + Assert.Equal(0u, second.Before.Index); + Assert.Equal(0u, second.Before.Source); + Assert.Equal(0u, second.Before.Target); + + Assert.Equal(2u, second.After.Index); + Assert.Equal(2u, second.After.Source); + Assert.Equal(2u, second.After.Target); + }, + // 3rd final link: (0:0 0) -> (3:3 3) + third => + { + Assert.Equal(0u, third.Before.Index); + Assert.Equal(0u, third.Before.Source); + Assert.Equal(0u, third.Before.Target); + + Assert.Equal(3u, third.After.Index); + Assert.Equal(3u, third.After.Source); + Assert.Equal(3u, third.After.Target); + }, + // 4th final link: (0:0 0) -> (4:4 4) + fourth => + { + Assert.Equal(0u, fourth.Before.Index); + Assert.Equal(0u, fourth.Before.Source); + Assert.Equal(0u, fourth.Before.Target); + + Assert.Equal(4u, fourth.After.Index); + Assert.Equal(4u, fourth.After.Source); + Assert.Equal(4u, fourth.After.Target); + } + ); + } + + [Fact] + public void SimplifyChanges_SpecificExample_KeepsUnchangedStates() + { + // Arrange + var changes = new List<(Link Before, Link After)> { // (1: 1 2) ↦ (1: 2 1) (new Link(index: 1, source: 1, target: 2), new Link(index: 1, source: 2, target: 1)), @@ -311,29 +311,29 @@ public void SimplifyChanges_SpecificExample_KeepsUnchangedStates() (new Link(index: 2, source: 2, target: 2), new Link(index: 2, source: 2, target: 2)) }; - // Expected simplified changes still have (2: 2 2): - // (1: 1 2) ↦ (1: 2 1) - // (2: 2 2) ↦ (2: 2 2) - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected simplified changes still have (2: 2 2): + // (1: 1 2) ↦ (1: 2 1) + // (2: 2 2) ↦ (2: 2 2) + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 1, target: 2), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 2, target: 2), new Link(index: 2, source: 2, target: 2)) }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); - // Assert - AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); - } + // Assert + AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); + } - [Fact] - public void SimplifyChanges_Issue26_UpdateOperationSimplification() - { - // Arrange - This represents the scenario described in GitHub issue #26 - // Where links (1: 1 2) and (2: 2 1) are being updated to swap source and target - // The issue was that intermediate steps were being shown instead of the final transformation - var changes = new List<(Link Before, Link After)> + [Fact] + public void SimplifyChanges_Issue26_UpdateOperationSimplification() + { + // Arrange - This represents the scenario described in GitHub issue #26 + // Where links (1: 1 2) and (2: 2 1) are being updated to swap source and target + // The issue was that intermediate steps were being shown instead of the final transformation + var changes = new List<(Link Before, Link After)> { // Step 1: Link (1: 1 2) is first deleted (becomes null/empty) (new Link(index: 1, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)), @@ -345,27 +345,27 @@ public void SimplifyChanges_Issue26_UpdateOperationSimplification() (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), }; - // Expected - The simplification should show only the initial-to-final transformations - var expectedSimplifiedChanges = new List<(Link Before, Link After)> + // Expected - The simplification should show only the initial-to-final transformations + var expectedSimplifiedChanges = new List<(Link Before, Link After)> { (new Link(index: 1, source: 1, target: 2), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); - // Assert - AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); - } + // Assert + AssertChangeSetEqual(expectedSimplifiedChanges, simplifiedChanges); + } - [Fact] - public void SimplifyChanges_Issue26_AlternativeScenario_NoSimplificationOccurs() - { - // Arrange - This tests a different scenario that might represent the actual issue - // Maybe the problem is that the changes are NOT being chained correctly - // Let's simulate what might happen if the simplifier doesn't work correctly - var changes = new List<(Link Before, Link After)> + [Fact] + public void SimplifyChanges_Issue26_AlternativeScenario_NoSimplificationOccurs() + { + // Arrange - This tests a different scenario that might represent the actual issue + // Maybe the problem is that the changes are NOT being chained correctly + // Let's simulate what might happen if the simplifier doesn't work correctly + var changes = new List<(Link Before, Link After)> { // Let's say we get these individual changes that don't form a proper chain (new Link(index: 1, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)), // delete @@ -373,260 +373,260 @@ public void SimplifyChanges_Issue26_AlternativeScenario_NoSimplificationOccurs() (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), // direct update }; - // Act - var simplifiedChanges = SimplifyChanges(changes).ToList(); - - // Debug output - Console.WriteLine("=== Debug: Alternative Scenario ==="); - Console.WriteLine("Input changes:"); - for (int i = 0; i < changes.Count; i++) - { - var (b, a) = changes[i]; - Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); - } - - Console.WriteLine("Actual simplified changes:"); - for (int i = 0; i < simplifiedChanges.Count; i++) - { - var (b, a) = simplifiedChanges[i]; - Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); - } - Console.WriteLine($"Count: {simplifiedChanges.Count}"); - Console.WriteLine("=== End Debug ==="); - - // The issue might be that we get 3 changes instead of 2 - // If the simplifier doesn't work, we'd see all 3 changes + // Act + var simplifiedChanges = SimplifyChanges(changes).ToList(); + + // Debug output + Console.WriteLine("=== Debug: Alternative Scenario ==="); + Console.WriteLine("Input changes:"); + for (int i = 0; i < changes.Count; i++) + { + var (b, a) = changes[i]; + Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); + } + + Console.WriteLine("Actual simplified changes:"); + for (int i = 0; i < simplifiedChanges.Count; i++) + { + var (b, a) = simplifiedChanges[i]; + Console.WriteLine($" {i + 1}. ({b.Index}: {b.Source} {b.Target}) -> ({a.Index}: {a.Source} {a.Target})"); + } + Console.WriteLine($"Count: {simplifiedChanges.Count}"); + Console.WriteLine("=== End Debug ==="); + + // The issue might be that we get 3 changes instead of 2 + // If the simplifier doesn't work, we'd see all 3 changes + } + + private static void AssertChangeSetEqual( + List<(Link Before, Link After)> expectedSimplifiedChanges, + List<(Link Before, Link After)> simplifiedChanges + ) + { + Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); + foreach (var expected in expectedSimplifiedChanges) + { + Assert.Contains( + simplifiedChanges, + actual => + actual.Before.Index == expected.Before.Index + && actual.Before.Source == expected.Before.Source + && actual.Before.Target == expected.Before.Target + && actual.After.Index == expected.After.Index + && actual.After.Source == expected.After.Source + && actual.After.Target == expected.After.Target + ); + } + } + + // [Fact] + // public void SimplifyChanges_NoChanges_ReturnsEmpty() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)>(); + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes); + + // // Assert + // Assert.Empty(simplified); + // } + + // [Fact] + // public void SimplifyChanges_SingleChange_ReturnsSameChange() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Single(simplified); + // Assert.Equal(1u, simplified[0].Before.Index); + // Assert.Equal(0u, simplified[0].Before.Source); // Assuming default values for other fields + // Assert.Equal(0u, simplified[0].Before.Target); + // Assert.Equal(new Link(1, 4, 5), simplified[0].After); + // } + + // [Fact] + // public void SimplifyChanges_MultipleNonOverlappingChanges_ReturnsAllChanges() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(2, 3, 4), new Link(2, 5, 6)), + // (new Link(3, 4, 5), new Link(3, 6, 7)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(3, simplified.Count); + + // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); + // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); + // Assert.Contains(simplified, c => c.Before.Index == 3 && c.After.Index == 3 && c.After.Source == 6 && c.After.Target == 7); + // } + + // [Fact] + // public void SimplifyChanges_MultipleOverlappingChanges_ReturnsInitialToFinal() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 4, 5), new Link(1, 6, 7)), + // (new Link(2, 3, 4), new Link(2, 5, 6)), + // (new Link(2, 5, 6), new Link(2, 0, 0)), + // (new Link(3, 4, 5), new Link(3, 6, 7)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(3, simplified.Count); + + // // Link 1: from (1,2,3) to (1,6,7) + // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 6, 7), link1.After); + + // // Link 2: from (2,3,4) to (2,0,0) + // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); + // Assert.Equal(new Link(2, 3, 4), link2.Before); + // Assert.Equal(new Link(2, 0, 0), link2.After); + + // // Link 3: from (3,4,5) to (3,6,7) + // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); + // Assert.Equal(new Link(3, 4, 5), link3.Before); + // Assert.Equal(new Link(3, 6, 7), link3.After); + // } + + // [Fact] + // public void SimplifyChanges_ComplexScenario_MixedChanges() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // // Link 1: Multiple changes + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 4, 5), new Link(1, 6, 7)), + // (new Link(1, 6, 7), new Link(1, 8, 9)), + + // // Link 2: Single change + // (new Link(2, 3, 4), new Link(2, 5, 6)), + + // // Link 3: Multiple changes + // (new Link(3, 4, 5), new Link(3, 6, 7)), + // (new Link(3, 6, 7), new Link(3, 0, 0)), + + // // Link 4: No changes + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(3, simplified.Count); + + // // Link 1: from (1,2,3) to (1,8,9) + // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 8, 9), link1.After); + + // // Link 2: from (2,3,4) to (2,5,6) + // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); + // Assert.Equal(new Link(2, 3, 4), link2.Before); + // Assert.Equal(new Link(2, 5, 6), link2.After); + + // // Link 3: from (3,4,5) to (3,0,0) + // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); + // Assert.Equal(new Link(3, 4, 5), link3.Before); + // Assert.Equal(new Link(3, 0, 0), link3.After); + // } + + // [Fact] + // public void SimplifyChanges_SameAfterMultipleChanges_ReturnsLastChange() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 4, 5), new Link(1, 6, 7)), + // (new Link(1, 6, 7), new Link(1, 4, 5)) // Reverting back + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Single(simplified); + + // // Link 1: from (1,2,3) to (1,4,5) + // var link1 = simplified.First(); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 4, 5), link1.After); + // } + + // [Fact] + // public void SimplifyChanges_DuplicateChanges_IgnoresDuplicates() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 2, 3), new Link(1, 4, 5)), // Duplicate + // (new Link(2, 3, 4), new Link(2, 5, 6)), + // (new Link(2, 3, 4), new Link(2, 5, 6)) // Duplicate + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Equal(2, simplified.Count); + + // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); + // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); + // } + + // [Fact] + // public void SimplifyChanges_NullChanges_ThrowsException() + // { + // // Arrange + // List<(Link Before, Link After)> changes = null; + + // // Act & Assert + // Assert.Throws(() => ChangesSimplifier.SimplifyChanges(changes).ToList()); + // } + + // [Fact] + // public void SimplifyChanges_ChangesWithSameBeforeDifferentAfter_LastAfterIsRetained() + // { + // // Arrange + // var changes = new List<(Link Before, Link After)> + // { + // (new Link(1, 2, 3), new Link(1, 4, 5)), + // (new Link(1, 2, 3), new Link(1, 6, 7)), + // (new Link(1, 2, 3), new Link(1, 8, 9)) + // }; + + // // Act + // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); + + // // Assert + // Assert.Single(simplified); + + // var link1 = simplified.First(); + // Assert.Equal(new Link(1, 2, 3), link1.Before); + // Assert.Equal(new Link(1, 8, 9), link1.After); // Last change is retained + // } } - - private static void AssertChangeSetEqual( - List<(Link Before, Link After)> expectedSimplifiedChanges, - List<(Link Before, Link After)> simplifiedChanges - ) - { - Assert.Equal(expectedSimplifiedChanges.Count, simplifiedChanges.Count); - foreach (var expected in expectedSimplifiedChanges) - { - Assert.Contains( - simplifiedChanges, - actual => - actual.Before.Index == expected.Before.Index - && actual.Before.Source == expected.Before.Source - && actual.Before.Target == expected.Before.Target - && actual.After.Index == expected.After.Index - && actual.After.Source == expected.After.Source - && actual.After.Target == expected.After.Target - ); - } - } - - // [Fact] - // public void SimplifyChanges_NoChanges_ReturnsEmpty() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)>(); - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes); - - // // Assert - // Assert.Empty(simplified); - // } - - // [Fact] - // public void SimplifyChanges_SingleChange_ReturnsSameChange() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Single(simplified); - // Assert.Equal(1u, simplified[0].Before.Index); - // Assert.Equal(0u, simplified[0].Before.Source); // Assuming default values for other fields - // Assert.Equal(0u, simplified[0].Before.Target); - // Assert.Equal(new Link(1, 4, 5), simplified[0].After); - // } - - // [Fact] - // public void SimplifyChanges_MultipleNonOverlappingChanges_ReturnsAllChanges() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(2, 3, 4), new Link(2, 5, 6)), - // (new Link(3, 4, 5), new Link(3, 6, 7)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(3, simplified.Count); - - // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); - // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); - // Assert.Contains(simplified, c => c.Before.Index == 3 && c.After.Index == 3 && c.After.Source == 6 && c.After.Target == 7); - // } - - // [Fact] - // public void SimplifyChanges_MultipleOverlappingChanges_ReturnsInitialToFinal() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 4, 5), new Link(1, 6, 7)), - // (new Link(2, 3, 4), new Link(2, 5, 6)), - // (new Link(2, 5, 6), new Link(2, 0, 0)), - // (new Link(3, 4, 5), new Link(3, 6, 7)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(3, simplified.Count); - - // // Link 1: from (1,2,3) to (1,6,7) - // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 6, 7), link1.After); - - // // Link 2: from (2,3,4) to (2,0,0) - // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); - // Assert.Equal(new Link(2, 3, 4), link2.Before); - // Assert.Equal(new Link(2, 0, 0), link2.After); - - // // Link 3: from (3,4,5) to (3,6,7) - // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); - // Assert.Equal(new Link(3, 4, 5), link3.Before); - // Assert.Equal(new Link(3, 6, 7), link3.After); - // } - - // [Fact] - // public void SimplifyChanges_ComplexScenario_MixedChanges() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // // Link 1: Multiple changes - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 4, 5), new Link(1, 6, 7)), - // (new Link(1, 6, 7), new Link(1, 8, 9)), - - // // Link 2: Single change - // (new Link(2, 3, 4), new Link(2, 5, 6)), - - // // Link 3: Multiple changes - // (new Link(3, 4, 5), new Link(3, 6, 7)), - // (new Link(3, 6, 7), new Link(3, 0, 0)), - - // // Link 4: No changes - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(3, simplified.Count); - - // // Link 1: from (1,2,3) to (1,8,9) - // var link1 = simplified.FirstOrDefault(c => c.Before.Index == 1); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 8, 9), link1.After); - - // // Link 2: from (2,3,4) to (2,5,6) - // var link2 = simplified.FirstOrDefault(c => c.Before.Index == 2); - // Assert.Equal(new Link(2, 3, 4), link2.Before); - // Assert.Equal(new Link(2, 5, 6), link2.After); - - // // Link 3: from (3,4,5) to (3,0,0) - // var link3 = simplified.FirstOrDefault(c => c.Before.Index == 3); - // Assert.Equal(new Link(3, 4, 5), link3.Before); - // Assert.Equal(new Link(3, 0, 0), link3.After); - // } - - // [Fact] - // public void SimplifyChanges_SameAfterMultipleChanges_ReturnsLastChange() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 4, 5), new Link(1, 6, 7)), - // (new Link(1, 6, 7), new Link(1, 4, 5)) // Reverting back - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Single(simplified); - - // // Link 1: from (1,2,3) to (1,4,5) - // var link1 = simplified.First(); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 4, 5), link1.After); - // } - - // [Fact] - // public void SimplifyChanges_DuplicateChanges_IgnoresDuplicates() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 2, 3), new Link(1, 4, 5)), // Duplicate - // (new Link(2, 3, 4), new Link(2, 5, 6)), - // (new Link(2, 3, 4), new Link(2, 5, 6)) // Duplicate - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Equal(2, simplified.Count); - - // Assert.Contains(simplified, c => c.Before.Index == 1 && c.After.Index == 1 && c.After.Source == 4 && c.After.Target == 5); - // Assert.Contains(simplified, c => c.Before.Index == 2 && c.After.Index == 2 && c.After.Source == 5 && c.After.Target == 6); - // } - - // [Fact] - // public void SimplifyChanges_NullChanges_ThrowsException() - // { - // // Arrange - // List<(Link Before, Link After)> changes = null; - - // // Act & Assert - // Assert.Throws(() => ChangesSimplifier.SimplifyChanges(changes).ToList()); - // } - - // [Fact] - // public void SimplifyChanges_ChangesWithSameBeforeDifferentAfter_LastAfterIsRetained() - // { - // // Arrange - // var changes = new List<(Link Before, Link After)> - // { - // (new Link(1, 2, 3), new Link(1, 4, 5)), - // (new Link(1, 2, 3), new Link(1, 6, 7)), - // (new Link(1, 2, 3), new Link(1, 8, 9)) - // }; - - // // Act - // var simplified = ChangesSimplifier.SimplifyChanges(changes).ToList(); - - // // Assert - // Assert.Single(simplified); - - // var link1 = simplified.First(); - // Assert.Equal(new Link(1, 2, 3), link1.Before); - // Assert.Equal(new Link(1, 8, 9), link1.After); // Last change is retained - // } - } } \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/DecoratorDisposalTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/DecoratorDisposalTests.cs new file mode 100644 index 0000000..c3617cc --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/DecoratorDisposalTests.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using Xunit; +using Foundation.Data.Doublets.Cli; +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli.Tests +{ + /// + /// Regression tests for the Windows CI failures tracked by issue #96. + /// + /// + /// Every decorator opens memory-mapped files for the data database and for the names database. + /// POSIX lets a still-mapped file be unlinked, so a leaked handle is invisible on Linux and macOS; + /// Windows uses mandatory locking and fails the delete with . That is what + /// produced 226 IOExceptions across 114 failing tests on windows-latest, which the CI workflow then + /// hid behind `continue-on-error`. These tests pin the contract that makes the delete safe + /// everywhere: the decorators are disposable, and disposing them releases both databases. + /// + public class DecoratorDisposalTests + { + [Fact] + public void NamedLinksDecorator_IsDisposable() => Assert.True(typeof(IDisposable).IsAssignableFrom(typeof(NamedLinksDecorator))); + + [Fact] + public void NamedTypesDecorator_IsDisposable() => Assert.True(typeof(IDisposable).IsAssignableFrom(typeof(NamedTypesDecorator))); + + [Fact] + public void SimpleLinksDecorator_IsDisposable() => Assert.True(typeof(IDisposable).IsAssignableFrom(typeof(SimpleLinksDecorator))); + + [Fact] + public void NamedLinksDecorator_DatabasesCanBeDeletedAfterDispose() + => AssertDatabasesCanBeDeletedAfterDispose( + NamedLinksDecorator.MakeNamesDatabaseFilename, + databaseFilename => new NamedLinksDecorator(databaseFilename)); + + [Fact] + public void NamedTypesDecorator_DatabasesCanBeDeletedAfterDispose() + => AssertDatabasesCanBeDeletedAfterDispose( + NamedTypesDecorator.MakeNamesDatabaseFilename, + databaseFilename => new NamedTypesDecorator(databaseFilename)); + + [Fact] + public void SimpleLinksDecorator_DatabasesCanBeDeletedAfterDispose() + => AssertDatabasesCanBeDeletedAfterDispose( + SimpleLinksDecorator.MakeNamesDatabaseFilename, + databaseFilename => new SimpleLinksDecorator(databaseFilename)); + + [Fact] + public void Dispose_IsIdempotent() + { + var databaseFilename = Path.GetTempFileName(); + var namesDatabaseFilename = NamedTypesDecorator.MakeNamesDatabaseFilename(databaseFilename); + try + { + var decorator = new NamedTypesDecorator(databaseFilename); + decorator.GetOrCreate(1u, 1u); + + decorator.Dispose(); + decorator.Dispose(); + } + finally + { + Delete(databaseFilename); + Delete(namesDatabaseFilename); + } + } + + private static void AssertDatabasesCanBeDeletedAfterDispose( + Func makeNamesDatabaseFilename, + Func> createDecorator) + { + var databaseFilename = Path.GetTempFileName(); + var namesDatabaseFilename = makeNamesDatabaseFilename(databaseFilename); + try + { + var decorator = createDecorator(databaseFilename); + // Force both databases to be materialised before the handles are released. + decorator.GetOrCreate(1u, 1u); + ((IDisposable)decorator).Dispose(); + + // Before the fix this threw IOException on Windows because the handles were still open. + File.Delete(databaseFilename); + File.Delete(namesDatabaseFilename); + + Assert.False(File.Exists(databaseFilename)); + Assert.False(File.Exists(namesDatabaseFilename)); + } + finally + { + Delete(databaseFilename); + Delete(namesDatabaseFilename); + } + } + + private static void Delete(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs index e4087fe..8a0a91d 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/Issue62ReviewCoverageTests.cs @@ -7,88 +7,90 @@ namespace Foundation.Data.Doublets.Cli.Tests { - public class Issue62ReviewCoverageTests - { - [Fact] - public void ExplicitNumericIdUpdate_CanBeReversedWithAnotherUpdate() + public class Issue62ReviewCoverageTests { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((1: 1 1)))"); - AssertLink(links, 1, 1, 1); + [Fact] + public void ExplicitNumericIdUpdate_CanBeReversedWithAnotherUpdate() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((1: 1 1)))"); + AssertLink(links, 1, 1, 1); - ProcessQuery(links, "(((1: 1 1)) ((1: 2 2)))"); - AssertLink(links, 1, 2, 2); + ProcessQuery(links, "(((1: 1 1)) ((1: 2 2)))"); + AssertLink(links, 1, 2, 2); - ProcessQuery(links, "(((1: 2 2)) ((1: 1 1)))"); - AssertLink(links, 1, 1, 1); - }); - } + ProcessQuery(links, "(((1: 2 2)) ((1: 1 1)))"); + AssertLink(links, 1, 1, 1); + }); + } - [Fact] - public void NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping() - { - RunTestWithLinks(links => - { - ProcessQuery(links, "(() ((child: father mother)))"); - var firstChild = links.GetByName("child"); - Assert.NotEqual(links.Constants.Null, firstChild); + [Fact] + public void NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping() + { + RunTestWithLinks(links => + { + ProcessQuery(links, "(() ((child: father mother)))"); + var firstChild = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, firstChild); - ProcessQuery(links, "((child: father mother)) ()"); - Assert.Equal(links.Constants.Null, links.GetByName("child")); - Assert.Null(links.GetName(firstChild)); + ProcessQuery(links, "((child: father mother)) ()"); + Assert.Equal(links.Constants.Null, links.GetByName("child")); + Assert.Null(links.GetName(firstChild)); - ProcessQuery(links, "(() ((child: father mother)))"); - var recreatedChild = links.GetByName("child"); - Assert.NotEqual(links.Constants.Null, recreatedChild); - Assert.Equal("child", links.GetName(recreatedChild)); - }); - } + ProcessQuery(links, "(() ((child: father mother)))"); + var recreatedChild = links.GetByName("child"); + Assert.NotEqual(links.Constants.Null, recreatedChild); + Assert.Equal("child", links.GetName(recreatedChild)); + }); + } - private static void RunTestWithLinks(Action> testAction) - { - var tempDbFile = Path.GetTempFileName(); - NamedTypesDecorator? links = null; - try - { - links = new NamedTypesDecorator(tempDbFile); - testAction(links); - } - finally - { - if (links != null && File.Exists(links.NamedLinksDatabaseFileName)) + private static void RunTestWithLinks(Action> testAction) { - File.Delete(links.NamedLinksDatabaseFileName); + var tempDbFile = Path.GetTempFileName(); + var namesDbFile = NamedTypesDecorator.MakeNamesDatabaseFilename(tempDbFile); + try + { + // Disposed at the end of the try block, before the finally deletes the backing files: + // Windows refuses to delete a file that is still memory-mapped. + using var links = new NamedTypesDecorator(tempDbFile); + testAction(links); + } + finally + { + if (File.Exists(namesDbFile)) + { + File.Delete(namesDbFile); + } + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + } } - if (File.Exists(tempDbFile)) + + private static void ProcessQuery(NamedTypesDecorator links, string query) { - File.Delete(tempDbFile); + AdvancedMixedQueryProcessor.ProcessQuery( + links, + new AdvancedMixedQueryProcessor.Options + { + Query = query, + AutoCreateMissingReferences = true + }); } - } - } - private static void ProcessQuery(NamedTypesDecorator links, string query) - { - AdvancedMixedQueryProcessor.ProcessQuery( - links, - new AdvancedMixedQueryProcessor.Options + private static void AssertLink(NamedTypesDecorator links, uint index, uint source, uint target) { - Query = query, - AutoCreateMissingReferences = true - }); - } + var any = links.Constants.Any; + var allLinks = links.All(new DoubletLink(any, any, any)) + .Select(link => new DoubletLink(link)) + .ToList(); - private static void AssertLink(NamedTypesDecorator links, uint index, uint source, uint target) - { - var any = links.Constants.Any; - var allLinks = links.All(new DoubletLink(any, any, any)) - .Select(link => new DoubletLink(link)) - .ToList(); - - var formattedLinks = string.Join(" ", allLinks.Select(link => $"({link.Index}: {link.Source}->{link.Target})")); - Assert.True( - allLinks.Any(link => link.Index == index && link.Source == source && link.Target == target), - $"Expected link ({index}: {source}->{target}) but found: {formattedLinks}"); + var formattedLinks = string.Join(" ", allLinks.Select(link => $"({link.Index}: {link.Source}->{link.Target})")); + Assert.True( + allLinks.Any(link => link.Index == index && link.Source == source && link.Target == target), + $"Expected link ({index}: {source}->{target}) but found: {formattedLinks}"); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs index ec4132c..8da96b9 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs @@ -98,7 +98,7 @@ private static void WithNamedLinks(Action> test) try { - var links = new NamedTypesDecorator(dbPath, false); + using var links = new NamedTypesDecorator(dbPath, false); test(links); } finally diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs index 9e06bec..c7b39e9 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs @@ -152,7 +152,7 @@ private static void WithNamedLinks(Action> test) try { - var links = new NamedTypesDecorator(dbPath, false); + using var links = new NamedTypesDecorator(dbPath, false); test(links); } finally diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs index 5390cda..04e3bd0 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedLinksDecoratorTests.cs @@ -16,32 +16,57 @@ public void CanConstructNamedLinksDecorator() // Arrange var tempDbFile = Path.GetTempFileName(); - // Act - var decorator = new NamedLinksDecorator(tempDbFile, true); var namesDatabaseFilename = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); - - // Assert - Assert.NotNull(decorator); - - // Clean up - if (File.Exists(tempDbFile)) + try { - File.Delete(tempDbFile); + // Act + using var decorator = new NamedLinksDecorator(tempDbFile, true); + + // Assert + Assert.NotNull(decorator); } - if (File.Exists(namesDatabaseFilename)) + finally { - File.Delete(namesDatabaseFilename); + // Clean up: the decorator is disposed by the `using` above, so the memory-mapped + // files are unlocked and can be deleted on Windows as well. + if (File.Exists(tempDbFile)) + { + File.Delete(tempDbFile); + } + if (File.Exists(namesDatabaseFilename)) + { + File.Delete(namesDatabaseFilename); + } } } + // Asserted as "directory of the input" + "expected file name" rather than as one hard-coded + // string: the implementation builds the result with Path.Combine, which emits '\\' on Windows, + // so an expectation such as "/tmp/test.names.links" passes on Linux and macOS but fails on + // Windows for a purely cosmetic reason. [Theory] - [InlineData("/tmp/test.db", "/tmp/test.names.links")] + [InlineData("/tmp/test.db", "test.names.links")] [InlineData("test.db", "test.names.links")] [InlineData("a.b.c", "a.b.names.links")] - public void MakeNamesDatabaseFilename_CorrectlyGeneratesFilename(string dbFilename, string expected) + public void MakeNamesDatabaseFilename_CorrectlyGeneratesFilename(string dbFilename, string expectedFileName) { var result = NamedLinksDecorator.MakeNamesDatabaseFilename(dbFilename); - Assert.Equal(expected, result); + + Assert.Equal(expectedFileName, Path.GetFileName(result)); + Assert.Equal(Path.GetDirectoryName(dbFilename), Path.GetDirectoryName(result)); + } + + // All three decorators duplicate MakeNamesDatabaseFilename; they must agree on every platform. + [Theory] + [InlineData("/tmp/test.db")] + [InlineData("test.db")] + [InlineData("a.b.c")] + public void MakeNamesDatabaseFilename_IsConsistentAcrossDecorators(string dbFilename) + { + var expected = NamedLinksDecorator.MakeNamesDatabaseFilename(dbFilename); + + Assert.Equal(expected, NamedTypesDecorator.MakeNamesDatabaseFilename(dbFilename)); + Assert.Equal(expected, SimpleLinksDecorator.MakeNamesDatabaseFilename(dbFilename)); } [Fact] @@ -51,7 +76,7 @@ public void SetNameAndGetName_ShouldReturnSameName() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(10u, 20u); string name = "testName"; decorator.SetName(link, name); @@ -72,7 +97,7 @@ public void SetName_OverwriteOldName() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(1u, 2u); string firstName = "first"; string secondName = "second"; @@ -95,7 +120,7 @@ public void RemoveName_ShouldReturnNullAfterRemoval() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(5u, 6u); string name = "name"; decorator.SetName(link, name); @@ -117,7 +142,7 @@ public void RemoveName_NonExistent_DoesNotThrow() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(7u, 8u); decorator.RemoveName(link); Assert.Null(decorator.GetName(link)); @@ -136,7 +161,7 @@ public void AfterCreation_SetNameAndGetName_ShouldWork() var expectedNamesDb = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new NamedLinksDecorator(tempDbFile, false); + using var decorator = new NamedLinksDecorator(tempDbFile, false); var link = decorator.GetOrCreate(10u, 20u); string name = "myLinkName"; decorator.SetName(link, name); @@ -153,9 +178,10 @@ public void AfterCreation_SetNameAndGetName_ShouldWork() public void DeleteLink_RemovesNameAutomatically() { var tempDbFile = Path.GetTempFileName(); - var decorator = new NamedLinksDecorator(tempDbFile, false); + var namesDatabaseFilename = NamedLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { + using var decorator = new NamedLinksDecorator(tempDbFile, false); var source = 1u; var target = 1u; var link = decorator.GetOrCreate(source, target); @@ -169,7 +195,7 @@ public void DeleteLink_RemovesNameAutomatically() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); - if (File.Exists(decorator.NamedLinksDatabaseFileName)) File.Delete(decorator.NamedLinksDatabaseFileName); + if (File.Exists(namesDatabaseFilename)) File.Delete(namesDatabaseFilename); } } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs index a4ca5a2..ec167ef 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/NamedTypesDecoratorTests.cs @@ -11,7 +11,7 @@ namespace Foundation.Data.Doublets.Cli.Tests { - public class NamedTypesDecoratorTests : IDisposable + public sealed class NamedTypesDecoratorTests : IDisposable { private readonly string _tempDbPath; private readonly string _tempNamesDbPath; @@ -26,6 +26,7 @@ public void Dispose() { if (File.Exists(_tempDbPath)) File.Delete(_tempDbPath); if (File.Exists(_tempNamesDbPath)) File.Delete(_tempNamesDbPath); + GC.SuppressFinalize(this); } private static void RunTestWithLinks(Action> testAction) @@ -49,7 +50,7 @@ public void NamedTypesDecorator_ImplementsILinks() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); Assert.True(decorator is ILinks); }); @@ -60,7 +61,7 @@ public void NamedTypesDecorator_ImplementsINamedTypes() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); Assert.True(decorator is INamedTypes); }); @@ -71,7 +72,7 @@ public void NamedTypesDecorator_ImplementsIPinnedTypes() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); Assert.True(decorator is IPinnedTypes); }); @@ -83,7 +84,7 @@ public void NamedTypesDecorator_UsesProvidedPinnedTypesDecorator() RunTestWithLinks(links => { var pinnedTypesDecorator = new PinnedTypesDecorator(links); - var decorator = new NamedTypesDecorator(pinnedTypesDecorator, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(pinnedTypesDecorator, _tempNamesDbPath); Assert.Same(pinnedTypesDecorator, decorator.PinnedTypesDecorator); Assert.True(decorator is ILinks); @@ -97,7 +98,7 @@ public void NamedTypesDecorator_CanEnumeratePinnedTypes() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var types = decorator.Take(3).ToArray(); var (type1, type2, type3) = decorator; @@ -114,7 +115,7 @@ public void NamedTypesDecorator_CanSetAndGetNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link1 = decorator.GetOrCreate(10u, 20u); var link2 = decorator.GetOrCreate(30u, 40u); @@ -138,7 +139,7 @@ public void NamedTypesDecorator_CanGetLinkByName() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(50u, 60u); decorator.SetName(link, "UniqueTestName"); @@ -154,7 +155,7 @@ public void NamedTypesDecorator_CanRemoveNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(70u, 80u); decorator.SetName(link, "TemporaryName"); @@ -177,7 +178,7 @@ public void NamedTypesDecorator_CanOverwriteNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(90u, 100u); decorator.SetName(link, "FirstName"); @@ -203,7 +204,7 @@ public void NamedTypesDecorator_ReassigningExistingNameMovesNameToNewLink() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var firstLink = decorator.GetOrCreate(10u, 11u); var secondLink = decorator.GetOrCreate(20u, 21u); @@ -222,7 +223,7 @@ public void NamedTypesDecorator_DeleteRemovesAssociatedNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var link = decorator.GetOrCreate(110u, 120u); decorator.SetName(link, "LinkToDelete"); @@ -245,7 +246,7 @@ public void NamedTypesDecorator_CanConstructFromDatabaseFilename() try { - var decorator = new NamedTypesDecorator(tempDbFile); + using var decorator = new NamedTypesDecorator(tempDbFile); var link = decorator.GetOrCreate(1u, 2u); decorator.SetName(link, "FromFile"); @@ -265,7 +266,7 @@ public void NamedTypesDecorator_HandlesNonexistentNames() { RunTestWithLinks(links => { - var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); + using var decorator = new NamedTypesDecorator(links, _tempNamesDbPath); var linkByNonexistentName = decorator.GetByName("NonexistentName"); Assert.Equal(links.Constants.Null, linkByNonexistentName); diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs index 10319dc..4ac9f16 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/PersistentTransformationDecoratorTests.cs @@ -5,113 +5,109 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class PersistentTransformationDecoratorTests - { - [Fact] - public void AlwaysTriggerIsStoredInLinksAndAppliedAfterWrite() + public class PersistentTransformationDecoratorTests { - RunWithPersistentLinks((links, triggerLinks) => - { - links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); - - var allTriggerLinks = AllLinks(triggerLinks); - var alwaysId = triggerLinks.GetByName("Always"); - Assert.NotEqual(triggerLinks.Constants.Null, alwaysId); - Assert.Contains(allTriggerLinks, link => link.Source == alwaysId && link.Target != alwaysId); - - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + [Fact] + public void AlwaysTriggerIsStoredInLinksAndAppliedAfterWrite() { - Query = "(() ((1: 1 1)))", - AutoCreateMissingReferences = true - }); - - Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 2); - }); - } - - [Fact] - public void OnceTriggerDeletesItselfAfterFirstMatch() - { - RunWithPersistentLinks((links, triggerLinks) => - { - links.StoreTrigger(PersistentTransformationKind.Once, "(((1: 1 1)) ((1: 1 2)))"); + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); + + var allTriggerLinks = AllLinks(triggerLinks); + var alwaysId = triggerLinks.GetByName("Always"); + Assert.NotEqual(triggerLinks.Constants.Null, alwaysId); + Assert.Contains(allTriggerLinks, link => link.Source == alwaysId && link.Target != alwaysId); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(() ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 2); + }); + } - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + [Fact] + public void OnceTriggerDeletesItselfAfterFirstMatch() { - Query = "(() ((1: 1 1)))", - AutoCreateMissingReferences = true - }); - - Assert.DoesNotContain(links.GetTriggers(), trigger => trigger.Kind == PersistentTransformationKind.Once); + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Once, "(((1: 1 1)) ((1: 1 2)))"); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(() ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.DoesNotContain(links.GetTriggers(), trigger => trigger.Kind == PersistentTransformationKind.Once); + + Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + { + Query = "(((1: 1 2)) ((1: 1 1)))", + AutoCreateMissingReferences = true + }); + + Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 1); + }); + } - Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.ProcessQuery(links, new Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor.Options + [Fact] + public void NeverRemovesMatchingStoredTrigger() { - Query = "(((1: 1 2)) ((1: 1 1)))", - AutoCreateMissingReferences = true - }); - - Assert.Contains(AllLinks(links), link => link.Index == 1 && link.Source == 1 && link.Target == 1); - }); - } - - [Fact] - public void NeverRemovesMatchingStoredTrigger() - { - RunWithPersistentLinks((links, triggerLinks) => - { - links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); + RunWithPersistentLinks((links, triggerLinks) => + { + links.StoreTrigger(PersistentTransformationKind.Always, "(((1: 1 1)) ((1: 1 2)))"); - var removed = links.RemoveTriggers("(((1: 1 1)) ((1: 1 2)))"); + var removed = links.RemoveTriggers("(((1: 1 1)) ((1: 1 2)))"); - Assert.Equal(1, removed); - Assert.Empty(links.GetTriggers()); - }); - } + Assert.Equal(1, removed); + Assert.Empty(links.GetTriggers()); + }); + } - private static void RunWithPersistentLinks(Action> action) - { - var dataFile = Path.GetTempFileName(); - var triggerFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - NamedTypesDecorator? triggerLinks = null; - try - { - dataLinks = new NamedTypesDecorator(dataFile); - triggerLinks = new NamedTypesDecorator(triggerFile); - var links = new PersistentTransformationDecorator(dataLinks, triggerLinks) + private static void RunWithPersistentLinks(Action> action) { - AutoCreateMissingReferences = true - }; - - action(links, triggerLinks); - } - finally - { - DeleteIfExists(dataFile); - DeleteIfExists(triggerFile); - if (dataLinks is not null) - { - DeleteIfExists(dataLinks.NamedLinksDatabaseFileName); + var dataFile = Path.GetTempFileName(); + var triggerFile = Path.GetTempFileName(); + var dataNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile); + var triggerNamesFile = NamedTypesDecorator.MakeNamesDatabaseFilename(triggerFile); + try + { + // Both decorators are disposed at the end of the try block, before the finally deletes the + // backing files: Windows refuses to delete a file that is still memory-mapped. + using var dataLinks = new NamedTypesDecorator(dataFile); + using var triggerLinks = new NamedTypesDecorator(triggerFile); + var links = new PersistentTransformationDecorator(dataLinks, triggerLinks) + { + AutoCreateMissingReferences = true + }; + + action(links, triggerLinks); + } + finally + { + DeleteIfExists(dataFile); + DeleteIfExists(triggerFile); + DeleteIfExists(dataNamesFile); + DeleteIfExists(triggerNamesFile); + } } - if (triggerLinks is not null) + + private static List AllLinks(INamedTypesLinks links) { - DeleteIfExists(triggerLinks.NamedLinksDatabaseFileName); + var any = links.Constants.Any; + return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); } - } - } - private static List AllLinks(INamedTypesLinks links) - { - var any = links.Constants.Any; - return links.All(new DoubletLink(any, any, any)).Select(link => new DoubletLink(link)).ToList(); - } - - private static void DeleteIfExists(string path) - { - if (File.Exists(path)) - { - File.Delete(path); - } + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs index bcd2fad..ea0d394 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/PinnedTypesTests.cs @@ -84,7 +84,7 @@ public void Should_Validate_Existing_Links() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act var result = new List(); foreach (var type in pinnedTypes.Take(numberOfTypes)) @@ -123,7 +123,7 @@ public void Should_Throw_Exception_For_Invalid_Link_Structure() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act & Assert var exception = Assert.Throws(() => { @@ -155,7 +155,7 @@ public void Should_Reset_Enumerator() var enumerator = pinnedTypes.GetEnumerator(); var allLinks = links.All(); - + // Act enumerator.MoveNext(); var first = enumerator.Current; @@ -192,7 +192,7 @@ public void Should_Validate_Existing_Links_With_Ulong() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act var result = new List(); foreach (var type in pinnedTypes.Take(numberOfTypes)) @@ -223,7 +223,7 @@ public void Should_Create_And_Iterate_Over_Types_With_RealDataStore() var pinnedTypes = new PinnedTypes(links); var allLinks = links.All(); - + // Act var result = new List(); foreach (var type in pinnedTypes.Take(numberOfTypes)) diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs index 9067138..54f4463 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/SimpleLinksDecoratorTests.cs @@ -13,14 +13,16 @@ public class SimpleLinksDecoratorTests public void CanConstructSimpleLinksDecorator() { var tempDbFile = Path.GetTempFileName(); + var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new SimpleLinksDecorator(tempDbFile); + using var decorator = new SimpleLinksDecorator(tempDbFile); Assert.NotNull(decorator); } finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } @@ -28,9 +30,10 @@ public void CanConstructSimpleLinksDecorator() public void Delete_WithRestriction_DoesNotThrow() { var tempDbFile = Path.GetTempFileName(); + var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new SimpleLinksDecorator(tempDbFile); + using var decorator = new SimpleLinksDecorator(tempDbFile); var source = 1u; var target = 2u; // create a link so there is something to delete @@ -46,6 +49,7 @@ public void Delete_WithRestriction_DoesNotThrow() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } @@ -53,9 +57,10 @@ public void Delete_WithRestriction_DoesNotThrow() public void DeleteAfterGetOrCreate_DoesNotThrow() { var tempDbFile = Path.GetTempFileName(); + var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); try { - var decorator = new SimpleLinksDecorator(tempDbFile); + using var decorator = new SimpleLinksDecorator(tempDbFile); var source = 1u; var target = 1u; var link = decorator.GetOrCreate(source, target); @@ -65,6 +70,7 @@ public void DeleteAfterGetOrCreate_DoesNotThrow() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } @@ -72,8 +78,10 @@ public void DeleteAfterGetOrCreate_DoesNotThrow() public void DeleteAfterGetOrCreate_DoesNotThrow_WithTracing() { var tempDbFile = Path.GetTempFileName(); - var decorator = new SimpleLinksDecorator(tempDbFile, true); - try { + var namesDbFile = SimpleLinksDecorator.MakeNamesDatabaseFilename(tempDbFile); + try + { + using var decorator = new SimpleLinksDecorator(tempDbFile, true); var source = 1u; var target = 1u; var link = decorator.GetOrCreate(source, target); @@ -83,8 +91,8 @@ public void DeleteAfterGetOrCreate_DoesNotThrow_WithTracing() finally { if (File.Exists(tempDbFile)) File.Delete(tempDbFile); - if (File.Exists(decorator.NamedLinksDatabaseFileName)) File.Delete(decorator.NamedLinksDatabaseFileName); + if (File.Exists(namesDbFile)) File.Delete(namesDbFile); } } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs index ed05558..b7612d8 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/TransactionsDecoratorTests.cs @@ -5,268 +5,268 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class TransactionsDecoratorTests - { - [Fact] - public void AutoTransactionRecordsCreateAndUpdate() + public class TransactionsDecoratorTests { - // CreateAndUpdate is an extension that calls Create then Update on - // the doublets store. Each emits a transition. - RunWithTransactions((tx, _) => - { - var created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - Assert.NotEqual(tx.Constants.Null, created); + [Fact] + public void AutoTransactionRecordsCreateAndUpdate() + { + // CreateAndUpdate is an extension that calls Create then Update on + // the doublets store. Each emits a transition. + RunWithTransactions((tx, _) => + { + var created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + Assert.NotEqual(tx.Constants.Null, created); - var log = tx.Log; - Assert.Equal(2, log.Count); - Assert.Equal(TransitionKind.Create, log[0].Kind); - Assert.Equal(TransitionKind.Update, log[1].Kind); - Assert.Equal(created, log[0].After.Index); - }); - } + var log = tx.Log; + Assert.Equal(2, log.Count); + Assert.Equal(TransitionKind.Create, log[0].Kind); + Assert.Equal(TransitionKind.Update, log[1].Kind); + Assert.Equal(created, log[0].After.Index); + }); + } - [Fact] - public void RollbackUndoesCreate() - { - RunWithTransactions((tx, _) => - { - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void RollbackUndoesCreate() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - Assert.True(tx.Exists(created)); - transaction.Rollback(); - } + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + Assert.True(tx.Exists(created)); + transaction.Rollback(); + } - Assert.False(tx.Exists(created), "Rolled-back create must remove the link."); - }); - } + Assert.False(tx.Exists(created), "Rolled-back create must remove the link."); + }); + } - [Fact] - public void DisposeWithoutCommitRollsBack() - { - RunWithTransactions((tx, _) => - { - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void DisposeWithoutCommitRollsBack() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - } + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } - Assert.False(tx.Exists(created), "Disposing an open transaction must rollback (R10)."); - }); - } + Assert.False(tx.Exists(created), "Disposing an open transaction must rollback (R10)."); + }); + } - [Fact] - public void CommitPersistsCreate() - { - RunWithTransactions((tx, _) => - { - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void CommitPersistsCreate() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - transaction.Commit(); + RunWithTransactions((tx, _) => + { + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + transaction.Commit(); + } + + Assert.True(tx.Exists(created)); + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + }); } - Assert.True(tx.Exists(created)); - Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); - }); - } + [Fact] + public void RollbackUndoesUpdate() + { + RunWithTransactions((tx, _) => + { + var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - [Fact] - public void RollbackUndoesUpdate() - { - RunWithTransactions((tx, _) => - { - var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + using (var transaction = tx.BeginTransaction()) + { + tx.Update( + new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), + new DoubletLink(c, a, b), + null); + var updated = new DoubletLink(tx.GetLink(c)); + Assert.Equal(a, updated.Source); + Assert.Equal(b, updated.Target); + transaction.Rollback(); + } - using (var transaction = tx.BeginTransaction()) + var afterRollback = new DoubletLink(tx.GetLink(c)); + Assert.Equal(c, afterRollback.Index); + Assert.Equal(tx.Constants.Null, afterRollback.Source); + Assert.Equal(tx.Constants.Null, afterRollback.Target); + }); + } + + [Fact] + public void RollbackUndoesDelete() { - tx.Update( + RunWithTransactions((tx, _) => + { + var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + tx.Update( new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), new DoubletLink(c, a, b), null); - var updated = new DoubletLink(tx.GetLink(c)); - Assert.Equal(a, updated.Source); - Assert.Equal(b, updated.Target); - transaction.Rollback(); - } - var afterRollback = new DoubletLink(tx.GetLink(c)); - Assert.Equal(c, afterRollback.Index); - Assert.Equal(tx.Constants.Null, afterRollback.Source); - Assert.Equal(tx.Constants.Null, afterRollback.Target); - }); - } + using (var transaction = tx.BeginTransaction()) + { + tx.Delete(new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), null); + Assert.False(tx.Exists(c)); + transaction.Rollback(); + } - [Fact] - public void RollbackUndoesDelete() - { - RunWithTransactions((tx, _) => - { - var a = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var b = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - var c = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - tx.Update( - new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), - new DoubletLink(c, a, b), - null); + Assert.True(tx.Exists(c), "Delete must be restored by rollback."); + var restored = new DoubletLink(tx.GetLink(c)); + Assert.Equal(a, restored.Source); + Assert.Equal(b, restored.Target); + }); + } - using (var transaction = tx.BeginTransaction()) + [Fact] + public void SizedRetentionDropsOldestAfterApplied() { - tx.Delete(new DoubletLink(c, tx.Constants.Any, tx.Constants.Any), null); - Assert.False(tx.Exists(c)); - transaction.Rollback(); - } + RunWithTransactions((tx, _) => + { + tx.RetentionPolicy = new LogRetentionPolicy.Sized(MaxTransitions: 3); + for (var i = 0; i < 5; i++) + { + tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } - Assert.True(tx.Exists(c), "Delete must be restored by rollback."); - var restored = new DoubletLink(tx.GetLink(c)); - Assert.Equal(a, restored.Source); - Assert.Equal(b, restored.Target); - }); - } + Assert.True(tx.Log.Count <= 3, $"Sized retention must cap log length; got {tx.Log.Count}."); + }); + } - [Fact] - public void SizedRetentionDropsOldestAfterApplied() - { - RunWithTransactions((tx, _) => - { - tx.RetentionPolicy = new LogRetentionPolicy.Sized(MaxTransitions: 3); - for (var i = 0; i < 5; i++) + [Fact] + public void ChunkedRetentionArchivesOldest() { - tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - } + var archiveDir = Path.Combine(Path.GetTempPath(), $"tx-archive-{Guid.NewGuid():N}"); + try + { + RunWithTransactions((tx, _) => + { + tx.RetentionPolicy = new LogRetentionPolicy.Chunked(ChunkSize: 2, ArchiveDirectory: archiveDir); + for (var i = 0; i < 4; i++) + { + tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + } - Assert.True(tx.Log.Count <= 3, $"Sized retention must cap log length; got {tx.Log.Count}."); - }); - } + Assert.True(Directory.Exists(archiveDir)); + var files = Directory.EnumerateFiles(archiveDir, "transitions-chunk-*.log").ToList(); + Assert.NotEmpty(files); + }); + } + finally + { + if (Directory.Exists(archiveDir)) Directory.Delete(archiveDir, recursive: true); + } + } - [Fact] - public void ChunkedRetentionArchivesOldest() - { - var archiveDir = Path.Combine(Path.GetTempPath(), $"tx-archive-{Guid.NewGuid():N}"); - try - { - RunWithTransactions((tx, _) => + [Fact] + public void RetentionPolicyParsesSpecs() { - tx.RetentionPolicy = new LogRetentionPolicy.Chunked(ChunkSize: 2, ArchiveDirectory: archiveDir); - for (var i = 0; i < 4; i++) - { - tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - } + Assert.IsType(LogRetentionPolicy.Parse("infinite")); + Assert.IsType(LogRetentionPolicy.Parse("sized:1000")); + Assert.IsType(LogRetentionPolicy.Parse("chunked:500:/tmp/x")); + Assert.Throws(() => LogRetentionPolicy.Parse("garbage")); + } - Assert.True(Directory.Exists(archiveDir)); - var files = Directory.EnumerateFiles(archiveDir, "transitions-chunk-*.log").ToList(); - Assert.NotEmpty(files); - }); - } - finally - { - if (Directory.Exists(archiveDir)) Directory.Delete(archiveDir, recursive: true); - } - } + [Fact] + public void TransitionRoundTripsThroughSerialize() + { + var t = new Transition( + Guid.NewGuid(), + Sequence: 42, + Timestamp: DateTimeOffset.FromUnixTimeMilliseconds(1234567890000), + Kind: TransitionKind.Update, + Before: new DoubletLink(1, 2, 3), + After: new DoubletLink(1, 4, 5)); - [Fact] - public void RetentionPolicyParsesSpecs() - { - Assert.IsType(LogRetentionPolicy.Parse("infinite")); - Assert.IsType(LogRetentionPolicy.Parse("sized:1000")); - Assert.IsType(LogRetentionPolicy.Parse("chunked:500:/tmp/x")); - Assert.Throws(() => LogRetentionPolicy.Parse("garbage")); - } + Assert.True(Transition.TryParse(t.Serialize(), out var parsed)); + Assert.Equal(t, parsed); + } - [Fact] - public void TransitionRoundTripsThroughSerialize() - { - var t = new Transition( - Guid.NewGuid(), - Sequence: 42, - Timestamp: DateTimeOffset.FromUnixTimeMilliseconds(1234567890000), - Kind: TransitionKind.Update, - Before: new DoubletLink(1, 2, 3), - After: new DoubletLink(1, 4, 5)); + [Fact] + public void AsyncCommitEventuallyMarksApplied() + { + RunWithTransactions((tx, _) => + { + tx.CommitMode = CommitMode.Async; + uint created; + using (var transaction = tx.BeginTransaction()) + { + created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); + transaction.CommitAsync().GetAwaiter().GetResult(); + } - Assert.True(Transition.TryParse(t.Serialize(), out var parsed)); - Assert.Equal(t, parsed); - } + // Allow background worker time to drain. + var deadline = DateTime.UtcNow.AddSeconds(5); + while (tx.AppliedSequence < tx.LastLoggedSequence && DateTime.UtcNow < deadline) + { + Thread.Sleep(50); + } + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + Assert.True(tx.Exists(created)); + }); + } - [Fact] - public void AsyncCommitEventuallyMarksApplied() - { - RunWithTransactions((tx, _) => - { - tx.CommitMode = CommitMode.Async; - uint created; - using (var transaction = tx.BeginTransaction()) + [Fact] + public void NoBehaviourChangeWhenNotOptedIn() { - created = tx.CreateAndUpdate(tx.Constants.Null, tx.Constants.Null); - transaction.CommitAsync().GetAwaiter().GetResult(); + // Acceptance for R8: bare NamedTypesDecorator behaves identically + // whether or not TransactionsDecorator is wrapped above it. + var dataFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + var created = dataLinks.CreateAndUpdate(dataLinks.Constants.Null, dataLinks.Constants.Null); + Assert.True(dataLinks.Exists(created)); + } + finally + { + dataLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + } } - // Allow background worker time to drain. - var deadline = DateTime.UtcNow.AddSeconds(5); - while (tx.AppliedSequence < tx.LastLoggedSequence && DateTime.UtcNow < deadline) + private static void RunWithTransactions(Action> action) { - Thread.Sleep(50); + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + TransactionsDecorator? tx = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + action(tx, dataLinks); + } + finally + { + tx?.Dispose(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + } } - Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); - Assert.True(tx.Exists(created)); - }); - } - - [Fact] - public void NoBehaviourChangeWhenNotOptedIn() - { - // Acceptance for R8: bare NamedTypesDecorator behaves identically - // whether or not TransactionsDecorator is wrapped above it. - var dataFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - try - { - dataLinks = new NamedTypesDecorator(dataFile); - var created = dataLinks.CreateAndUpdate(dataLinks.Constants.Null, dataLinks.Constants.Null); - Assert.True(dataLinks.Exists(created)); - } - finally - { - dataLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - } - } - private static void RunWithTransactions(Action> action) - { - var dataFile = Path.GetTempFileName(); - var logFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - NamedTypesDecorator? logLinks = null; - TransactionsDecorator? tx = null; - try - { - dataLinks = new NamedTypesDecorator(dataFile); - logLinks = new NamedTypesDecorator(logFile); - tx = new TransactionsDecorator(dataLinks, logLinks); - action(tx, dataLinks); - } - finally - { - tx?.Shutdown(); - dataLinks?.Dispose(); - logLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(logFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); - } - } - - private static void Cleanup(string path) - { - if (File.Exists(path)) File.Delete(path); + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs index 254f499..b2793ff 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/UnicodeStringStorageTests.cs @@ -245,7 +245,7 @@ private static void RunTestWithLinks(Action> testAction) try { var constants = new LinksConstants(enableExternalReferencesSupport: true); - var memory = new FileMappedResizableDirectMemory(tempDbFile, UnitedMemoryLinks.DefaultLinksSizeStep); + using var memory = new FileMappedResizableDirectMemory(tempDbFile, UnitedMemoryLinks.DefaultLinksSizeStep); using var links = new UnitedMemoryLinks(memory, UnitedMemoryLinks.DefaultLinksSizeStep, constants, Platform.Data.Doublets.Memory.IndexTreeType.Default); var decoratedLinks = links.DecorateWithAutomaticUniquenessAndUsagesResolution(); testAction(decoratedLinks); diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs index 7d4ed34..b4f6eed 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/VersionControlDecoratorTests.cs @@ -5,375 +5,375 @@ namespace Foundation.Data.Doublets.Cli.Tests.Tests { - public class VersionControlDecoratorTests - { - [Fact] - public void DefaultBranchExistsOnFirstOpen() + public class VersionControlDecoratorTests { - RunWithVc((vc, _, _) => - { - Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); - var branches = vc.ListBranches(); - Assert.Single(branches); - Assert.Equal(VersionControlDecorator.DefaultBranchName, branches[0].Name); - }); - } - - [Fact] - public void NewTransitionsAreAttributedToCurrentBranch() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var head = tx.LastLoggedSequence; - Assert.True(head >= 2, $"CreateAndUpdate must produce at least two transitions (got {head})."); - Assert.Equal(head, vc.CurrentSequence); - }); - } - - [Fact] - public void CheckoutToZeroRewindsEverything() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b)); - - vc.Checkout(0); - - Assert.False(vc.Exists(a), "All links must be rewound after checkout 0."); - Assert.False(vc.Exists(b)); - Assert.Equal(0, vc.CurrentSequence); - }); - } - - [Fact] - public void CheckoutAndForwardReplayRestoresState() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var afterFirst = tx.LastLoggedSequence; - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var afterSecond = tx.LastLoggedSequence; - - vc.Checkout(afterFirst); - Assert.True(vc.Exists(a), "First link must remain after partial rewind."); - Assert.False(vc.Exists(b), "Second link must disappear after partial rewind."); - - vc.Checkout(afterSecond); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b), "Second link must reappear after forward checkout."); - }); - } + [Fact] + public void DefaultBranchExistsOnFirstOpen() + { + RunWithVc((vc, _, _) => + { + Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); + var branches = vc.ListBranches(); + Assert.Single(branches); + Assert.Equal(VersionControlDecorator.DefaultBranchName, branches[0].Name); + }); + } - [Fact] - public void BranchForksFromCurrentHead() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var headBeforeBranch = vc.CurrentSequence; - - vc.Branch("feature"); - Assert.Contains(vc.ListBranches(), b => b.Name == "feature"); - }); - } + [Fact] + public void NewTransitionsAreAttributedToCurrentBranch() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var head = tx.LastLoggedSequence; + Assert.True(head >= 2, $"CreateAndUpdate must produce at least two transitions (got {head})."); + Assert.Equal(head, vc.CurrentSequence); + }); + } - [Fact] - public void SwitchBranchAppliesAndRewindsTransitions() - { - RunWithVc((vc, tx, _) => - { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var headBeforeBranch = vc.CurrentSequence; - - vc.Branch("feature"); - vc.SwitchBranch("feature"); - Assert.Equal("feature", vc.CurrentBranch); - - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - Assert.True(vc.Exists(b)); - var featureHead = vc.CurrentSequence; - - vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); - Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); - Assert.True(vc.Exists(a), "Main-branch link must remain after switching back."); - Assert.False(vc.Exists(b), "Feature-branch link must disappear after switching back to main."); - Assert.Equal(headBeforeBranch, vc.CurrentSequence); - - vc.SwitchBranch("feature"); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b), "Feature-branch link must reappear after switching back to feature."); - Assert.Equal(featureHead, vc.CurrentSequence); - }); - } + [Fact] + public void CheckoutToZeroRewindsEverything() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + + vc.Checkout(0); + + Assert.False(vc.Exists(a), "All links must be rewound after checkout 0."); + Assert.False(vc.Exists(b)); + Assert.Equal(0, vc.CurrentSequence); + }); + } - [Fact] - public void TagPointsToCurrentHead() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Tag("v1"); - Assert.True(vc.TryGetTag("v1", out var seq)); - Assert.Equal(vc.CurrentSequence, seq); - Assert.Contains("v1", vc.ListTags().Keys); - }); - } + [Fact] + public void CheckoutAndForwardReplayRestoresState() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var afterFirst = tx.LastLoggedSequence; + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var afterSecond = tx.LastLoggedSequence; + + vc.Checkout(afterFirst); + Assert.True(vc.Exists(a), "First link must remain after partial rewind."); + Assert.False(vc.Exists(b), "Second link must disappear after partial rewind."); + + vc.Checkout(afterSecond); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b), "Second link must reappear after forward checkout."); + }); + } - [Fact] - public void BranchFromExplicitSeqUsesGivenPoint() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var firstHead = vc.CurrentSequence; - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - - vc.Branch("backport", from: firstHead); - var branchInfo = vc.ListBranches().Single(b => b.Name == "backport"); - Assert.Equal(firstHead, branchInfo.ForkSeq); - }); - } + [Fact] + public void BranchForksFromCurrentHead() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var headBeforeBranch = vc.CurrentSequence; + + vc.Branch("feature"); + Assert.Contains(vc.ListBranches(), b => b.Name == "feature"); + }); + } - [Fact] - public void RecoverRebuildsStateFromBranchesStore() - { - // Recovery is exercised here by attaching a *second* VC decorator - // to the same live branches store, which is equivalent in behaviour - // to reopening the underlying file (the file-mapped store is shared). - RunWithVc((vc, _, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Tag("checkpoint"); - vc.Branch("feature"); - - // Force a fresh decorator over the same in-process VC store. - var branchesStore = GetBranchesStore(vc); - var transactions = GetTransactions(vc); - var reopened = new VersionControlDecorator(transactions, branchesStore); - Assert.Contains(reopened.ListBranches(), b => b.Name == "feature"); - Assert.True(reopened.TryGetTag("checkpoint", out _)); - }); - } + [Fact] + public void SwitchBranchAppliesAndRewindsTransitions() + { + RunWithVc((vc, tx, _) => + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var headBeforeBranch = vc.CurrentSequence; + + vc.Branch("feature"); + vc.SwitchBranch("feature"); + Assert.Equal("feature", vc.CurrentBranch); + + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.True(vc.Exists(b)); + var featureHead = vc.CurrentSequence; + + vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); + Assert.Equal(VersionControlDecorator.DefaultBranchName, vc.CurrentBranch); + Assert.True(vc.Exists(a), "Main-branch link must remain after switching back."); + Assert.False(vc.Exists(b), "Feature-branch link must disappear after switching back to main."); + Assert.Equal(headBeforeBranch, vc.CurrentSequence); + + vc.SwitchBranch("feature"); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b), "Feature-branch link must reappear after switching back to feature."); + Assert.Equal(featureHead, vc.CurrentSequence); + }); + } - private static INamedTypesLinks GetBranchesStore(VersionControlDecorator vc) - { - return (INamedTypesLinks)typeof(VersionControlDecorator) - .GetField("_branchesStore", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! - .GetValue(vc)!; - } + [Fact] + public void TagPointsToCurrentHead() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Tag("v1"); + Assert.True(vc.TryGetTag("v1", out var seq)); + Assert.Equal(vc.CurrentSequence, seq); + Assert.Contains("v1", vc.ListTags().Keys); + }); + } - private static TransactionsDecorator GetTransactions(VersionControlDecorator vc) - { - return (TransactionsDecorator)typeof(VersionControlDecorator) - .GetField("_transactions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! - .GetValue(vc)!; - } + [Fact] + public void BranchFromExplicitSeqUsesGivenPoint() + { + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var firstHead = vc.CurrentSequence; + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + + vc.Branch("backport", from: firstHead); + var branchInfo = vc.ListBranches().Single(b => b.Name == "backport"); + Assert.Equal(firstHead, branchInfo.ForkSeq); + }); + } - [Fact] - public void CheckoutOutOfRangeThrows() - { - RunWithVc((vc, tx, _) => - { - vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - Assert.Throws(() => vc.Checkout(999)); - }); - } + [Fact] + public void RecoverRebuildsStateFromBranchesStore() + { + // Recovery is exercised here by attaching a *second* VC decorator + // to the same live branches store, which is equivalent in behaviour + // to reopening the underlying file (the file-mapped store is shared). + RunWithVc((vc, _, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Tag("checkpoint"); + vc.Branch("feature"); + + // Force a fresh decorator over the same in-process VC store. + var branchesStore = GetBranchesStore(vc); + var transactions = GetTransactions(vc); + var reopened = new VersionControlDecorator(transactions, branchesStore); + Assert.Contains(reopened.ListBranches(), b => b.Name == "feature"); + Assert.True(reopened.TryGetTag("checkpoint", out _)); + }); + } - [Fact] - public void DuplicateBranchThrows() - { - RunWithVc((vc, _, _) => - { - vc.Branch("feature"); - Assert.Throws(() => vc.Branch("feature")); - }); - } + private static INamedTypesLinks GetBranchesStore(VersionControlDecorator vc) + { + return (INamedTypesLinks)typeof(VersionControlDecorator) + .GetField("_branchesStore", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(vc)!; + } - [Fact] - public void FullStackAcidRollbackIsAtomicAndIsolated() - { - RunWithVc((vc, _, _) => - { - var baseline = Snapshot(vc); - var initialSequence = vc.CurrentSequence; + private static TransactionsDecorator GetTransactions(VersionControlDecorator vc) + { + return (TransactionsDecorator)typeof(VersionControlDecorator) + .GetField("_transactions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(vc)!; + } - using (var transaction = vc.BeginTransaction()) + [Fact] + public void CheckoutOutOfRangeThrows() { - var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Update( - new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), - new DoubletLink(a, b, b), - null); - - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b)); - Assert.Throws(() => vc.BeginTransaction()); - Assert.Throws(() => vc.Branch("blocked")); - - transaction.Rollback(); + RunWithVc((vc, tx, _) => + { + vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + Assert.Throws(() => vc.Checkout(999)); + }); } - Assert.Equal(initialSequence, vc.CurrentSequence); - Assert.Equal(initialSequence, vc.ListBranches().Single(b => b.Name == VersionControlDecorator.DefaultBranchName).Head); - Assert.Equal(baseline, Snapshot(vc)); - }); - } + [Fact] + public void DuplicateBranchThrows() + { + RunWithVc((vc, _, _) => + { + vc.Branch("feature"); + Assert.Throws(() => vc.Branch("feature")); + }); + } - [Fact] - public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() - { - var dataFile = Path.GetTempFileName(); - var logFile = Path.GetTempFileName(); - var vcFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - NamedTypesDecorator? logLinks = null; - NamedTypesDecorator? vcLinks = null; - TransactionsDecorator? tx = null; - NamedTypesDecorator? reopenedDataLinks = null; - NamedTypesDecorator? reopenedLogLinks = null; - NamedTypesDecorator? reopenedVcLinks = null; - TransactionsDecorator? reopenedTx = null; - - try - { - uint a; - uint b; - long committedSequence; - - dataLinks = new NamedTypesDecorator(dataFile); - logLinks = new NamedTypesDecorator(logFile); - vcLinks = new NamedTypesDecorator(vcFile); - tx = new TransactionsDecorator(dataLinks, logLinks); - var vc = new VersionControlDecorator(tx, vcLinks); - - using (var transaction = vc.BeginTransaction()) + [Fact] + public void FullStackAcidRollbackIsAtomicAndIsolated() { - a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); - vc.Update( - new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), - new DoubletLink(a, b, b), - null); - transaction.Commit(); + RunWithVc((vc, _, _) => + { + var baseline = Snapshot(vc); + var initialSequence = vc.CurrentSequence; + + using (var transaction = vc.BeginTransaction()) + { + var a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + var b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Update( + new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), + new DoubletLink(a, b, b), + null); + + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + Assert.Throws(() => vc.BeginTransaction()); + Assert.Throws(() => vc.Branch("blocked")); + + transaction.Rollback(); + } + + Assert.Equal(initialSequence, vc.CurrentSequence); + Assert.Equal(initialSequence, vc.ListBranches().Single(b => b.Name == VersionControlDecorator.DefaultBranchName).Head); + Assert.Equal(baseline, Snapshot(vc)); + }); } - committedSequence = vc.CurrentSequence; - Assert.True(committedSequence >= 5); - Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); - Assert.Equal(committedSequence, vc.ListBranches().Single(branch => branch.Name == VersionControlDecorator.DefaultBranchName).Head); - - vc.Tag("acid-commit"); - vc.Branch("audit"); - vc.SwitchBranch("audit"); - vc.Delete(new DoubletLink(b, vc.Constants.Any, vc.Constants.Any), null); - Assert.False(vc.Exists(b)); - - vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); - Assert.True(vc.Exists(a)); - Assert.True(vc.Exists(b)); - var restored = new DoubletLink(vc.GetLink(a)); - Assert.Equal(b, restored.Source); - Assert.Equal(b, restored.Target); - - tx.Shutdown(); - tx = null; - dataLinks.Dispose(); - dataLinks = null; - logLinks.Dispose(); - logLinks = null; - vcLinks.Dispose(); - vcLinks = null; - - reopenedDataLinks = new NamedTypesDecorator(dataFile); - reopenedLogLinks = new NamedTypesDecorator(logFile); - reopenedVcLinks = new NamedTypesDecorator(vcFile); - reopenedTx = new TransactionsDecorator(reopenedDataLinks, reopenedLogLinks); - var reopened = new VersionControlDecorator(reopenedTx, reopenedVcLinks); - - Assert.True(reopened.TryGetTag("acid-commit", out var tagSequence)); - Assert.Equal(committedSequence, tagSequence); - Assert.Contains(reopened.ListBranches(), branch => branch.Name == "audit"); - Assert.Equal(VersionControlDecorator.DefaultBranchName, reopened.CurrentBranch); - Assert.True(reopened.Exists(a)); - Assert.True(reopened.Exists(b)); - restored = new DoubletLink(reopened.GetLink(a)); - Assert.Equal(b, restored.Source); - Assert.Equal(b, restored.Target); - Assert.Equal(reopenedTx.LastLoggedSequence, reopenedTx.AppliedSequence); - } - finally - { - tx?.Shutdown(); - reopenedTx?.Shutdown(); - dataLinks?.Dispose(); - logLinks?.Dispose(); - vcLinks?.Dispose(); - reopenedDataLinks?.Dispose(); - reopenedLogLinks?.Dispose(); - reopenedVcLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(logFile); - Cleanup(vcFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); - } - } + [Fact] + public void FullStackAcidCommitIsConsistentAndDurableAcrossReopen() + { + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + var vcFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + NamedTypesDecorator? vcLinks = null; + TransactionsDecorator? tx = null; + NamedTypesDecorator? reopenedDataLinks = null; + NamedTypesDecorator? reopenedLogLinks = null; + NamedTypesDecorator? reopenedVcLinks = null; + TransactionsDecorator? reopenedTx = null; + + try + { + uint a; + uint b; + long committedSequence; + + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + vcLinks = new NamedTypesDecorator(vcFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + using var vc = new VersionControlDecorator(tx, vcLinks); + + using (var transaction = vc.BeginTransaction()) + { + a = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + b = vc.CreateAndUpdate(vc.Constants.Null, vc.Constants.Null); + vc.Update( + new DoubletLink(a, vc.Constants.Any, vc.Constants.Any), + new DoubletLink(a, b, b), + null); + transaction.Commit(); + } + + committedSequence = vc.CurrentSequence; + Assert.True(committedSequence >= 5); + Assert.Equal(tx.LastLoggedSequence, tx.AppliedSequence); + Assert.Equal(committedSequence, vc.ListBranches().Single(branch => branch.Name == VersionControlDecorator.DefaultBranchName).Head); + + vc.Tag("acid-commit"); + vc.Branch("audit"); + vc.SwitchBranch("audit"); + vc.Delete(new DoubletLink(b, vc.Constants.Any, vc.Constants.Any), null); + Assert.False(vc.Exists(b)); + + vc.SwitchBranch(VersionControlDecorator.DefaultBranchName); + Assert.True(vc.Exists(a)); + Assert.True(vc.Exists(b)); + var restored = new DoubletLink(vc.GetLink(a)); + Assert.Equal(b, restored.Source); + Assert.Equal(b, restored.Target); + + tx.Dispose(); + tx = null; + dataLinks.Dispose(); + dataLinks = null; + logLinks.Dispose(); + logLinks = null; + vcLinks.Dispose(); + vcLinks = null; + + reopenedDataLinks = new NamedTypesDecorator(dataFile); + reopenedLogLinks = new NamedTypesDecorator(logFile); + reopenedVcLinks = new NamedTypesDecorator(vcFile); + reopenedTx = new TransactionsDecorator(reopenedDataLinks, reopenedLogLinks); + using var reopened = new VersionControlDecorator(reopenedTx, reopenedVcLinks); + + Assert.True(reopened.TryGetTag("acid-commit", out var tagSequence)); + Assert.Equal(committedSequence, tagSequence); + Assert.Contains(reopened.ListBranches(), branch => branch.Name == "audit"); + Assert.Equal(VersionControlDecorator.DefaultBranchName, reopened.CurrentBranch); + Assert.True(reopened.Exists(a)); + Assert.True(reopened.Exists(b)); + restored = new DoubletLink(reopened.GetLink(a)); + Assert.Equal(b, restored.Source); + Assert.Equal(b, restored.Target); + Assert.Equal(reopenedTx.LastLoggedSequence, reopenedTx.AppliedSequence); + } + finally + { + tx?.Dispose(); + reopenedTx?.Dispose(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + vcLinks?.Dispose(); + reopenedDataLinks?.Dispose(); + reopenedLogLinks?.Dispose(); + reopenedVcLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(vcFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); + } + } - private static void RunWithVc(Action> action) - { - var dataFile = Path.GetTempFileName(); - var logFile = Path.GetTempFileName(); - var vcFile = Path.GetTempFileName(); - NamedTypesDecorator? dataLinks = null; - NamedTypesDecorator? logLinks = null; - NamedTypesDecorator? vcLinks = null; - TransactionsDecorator? tx = null; - try - { - dataLinks = new NamedTypesDecorator(dataFile); - logLinks = new NamedTypesDecorator(logFile); - vcLinks = new NamedTypesDecorator(vcFile); - tx = new TransactionsDecorator(dataLinks, logLinks); - var vc = new VersionControlDecorator(tx, vcLinks); - action(vc, tx, dataLinks); - } - finally - { - tx?.Shutdown(); - dataLinks?.Dispose(); - logLinks?.Dispose(); - vcLinks?.Dispose(); - Cleanup(dataFile); - Cleanup(logFile); - Cleanup(vcFile); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); - Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); - } - } + private static void RunWithVc(Action> action) + { + var dataFile = Path.GetTempFileName(); + var logFile = Path.GetTempFileName(); + var vcFile = Path.GetTempFileName(); + NamedTypesDecorator? dataLinks = null; + NamedTypesDecorator? logLinks = null; + NamedTypesDecorator? vcLinks = null; + TransactionsDecorator? tx = null; + try + { + dataLinks = new NamedTypesDecorator(dataFile); + logLinks = new NamedTypesDecorator(logFile); + vcLinks = new NamedTypesDecorator(vcFile); + tx = new TransactionsDecorator(dataLinks, logLinks); + using var vc = new VersionControlDecorator(tx, vcLinks); + action(vc, tx, dataLinks); + } + finally + { + tx?.Dispose(); + dataLinks?.Dispose(); + logLinks?.Dispose(); + vcLinks?.Dispose(); + Cleanup(dataFile); + Cleanup(logFile); + Cleanup(vcFile); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(dataFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(logFile)); + Cleanup(NamedTypesDecorator.MakeNamesDatabaseFilename(vcFile)); + } + } - private static void Cleanup(string path) - { - if (File.Exists(path)) File.Delete(path); - } + private static void Cleanup(string path) + { + if (File.Exists(path)) File.Delete(path); + } - private static IReadOnlyList Snapshot(ILinks links) - { - var any = links.Constants.Any; - var query = new DoubletLink(any, any, any); - return links.All(query) - .Select(link => new DoubletLink(link)) - .OrderBy(link => link.Index) - .ThenBy(link => link.Source) - .ThenBy(link => link.Target) - .ToArray(); + private static IReadOnlyList Snapshot(ILinks links) + { + var any = links.Constants.Any; + var query = new DoubletLink(any, any, any); + return links.All(query) + .Select(link => new DoubletLink(link)) + .OrderBy(link => link.Index) + .ThenBy(link => link.Source) + .ThenBy(link => link.Target) + .ToArray(); + } } - } } diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index a65b64b..04bf1ed 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -11,169 +11,169 @@ var dbOption = new Option("--db", "--data-source", "--data", "-d") { - Description = "Path to the links database file", - DefaultValueFactory = _ => defaultDatabaseFilename + Description = "Path to the links database file", + DefaultValueFactory = _ => defaultDatabaseFilename }; var queryOption = new Option("--query", "--apply", "--do", "-q") { - Description = "LiNo query for CRUD operation" + Description = "LiNo query for CRUD operation" }; var queryArgument = new Argument("query") { - Description = "LiNo query for CRUD operation", - Arity = ArgumentArity.ZeroOrOne + Description = "LiNo query for CRUD operation", + Arity = ArgumentArity.ZeroOrOne }; var traceOption = new Option("--trace", "-t") { - Description = "Enable trace (verbose output)", - DefaultValueFactory = _ => false + Description = "Enable trace (verbose output)", + DefaultValueFactory = _ => false }; var autoCreateMissingReferencesOption = new Option("--auto-create-missing-references") { - Description = "Create missing numeric and named references as self-referential point links", - DefaultValueFactory = _ => false + Description = "Create missing numeric and named references as self-referential point links", + DefaultValueFactory = _ => false }; var structureOption = new Option("--structure", "-s") { - Description = "ID of the link to format its structure" + Description = "ID of the link to format its structure" }; var beforeOption = new Option("--before", "-b") { - Description = "Print the state of the database before applying changes", - DefaultValueFactory = _ => false + Description = "Print the state of the database before applying changes", + DefaultValueFactory = _ => false }; var changesOption = new Option("--changes", "-c") { - Description = "Print the changes applied by the query", - DefaultValueFactory = _ => false + Description = "Print the changes applied by the query", + DefaultValueFactory = _ => false }; var afterOption = new Option("--after", "--links", "-a") { - Description = "Print the state of the database after applying changes", - DefaultValueFactory = _ => false + Description = "Print the state of the database after applying changes", + DefaultValueFactory = _ => false }; var outputOption = new Option("--out", "--lino-output", "--export") { - Description = "Path to write the complete database as a LiNo file" + Description = "Path to write the complete database as a LiNo file" }; var alwaysOption = new Option("--always") { - Description = "Store the query as an always-on persistent transformation trigger", - DefaultValueFactory = _ => false + Description = "Store the query as an always-on persistent transformation trigger", + DefaultValueFactory = _ => false }; var onceOption = new Option("--once") { - Description = "Store the query as a persistent transformation trigger that deletes itself after it fires", - DefaultValueFactory = _ => false + Description = "Store the query as a persistent transformation trigger that deletes itself after it fires", + DefaultValueFactory = _ => false }; var neverOption = new Option("--never") { - Description = "Remove stored persistent transformation triggers matching the query", - DefaultValueFactory = _ => false + Description = "Remove stored persistent transformation triggers matching the query", + DefaultValueFactory = _ => false }; var triggersOption = new Option("--triggers") { - Description = "Enable persistent transformation triggers for this command", - DefaultValueFactory = _ => false + Description = "Enable persistent transformation triggers for this command", + DefaultValueFactory = _ => false }; var triggersFileOption = new Option("--triggers-file") { - Description = "Path to the persistent transformation trigger links database" + Description = "Path to the persistent transformation trigger links database" }; var embedTriggersOption = new Option("--embed-triggers") { - Description = "Store persistent transformation triggers directly in the main links database", - DefaultValueFactory = _ => false + Description = "Store persistent transformation triggers directly in the main links database", + DefaultValueFactory = _ => false }; var inputOption = new Option("--in", "--lino-input", "--import") { - Description = "Path to read and import a LiNo file into the database" + Description = "Path to read and import a LiNo file into the database" }; var transactionsOption = new Option("--transactions") { - Description = "Enable the transactions layer (default log path: .transitions.links)", - DefaultValueFactory = _ => false + Description = "Enable the transactions layer (default log path: .transitions.links)", + DefaultValueFactory = _ => false }; var transactionsFileOption = new Option("--transactions-file") { - Description = "Path to the transitions log store (default: .transitions.links). Implies --transactions." + Description = "Path to the transitions log store (default: .transitions.links). Implies --transactions." }; var commitModeOption = new Option("--commit-mode") { - Description = "Choose 'sync' or 'async' commits (default: sync). Implies --transactions." + Description = "Choose 'sync' or 'async' commits (default: sync). Implies --transactions." }; var retentionOption = new Option("--retention") { - Description = "Log retention policy: 'infinite', 'sized:', or 'chunked::'. Implies --transactions." + Description = "Log retention policy: 'infinite', 'sized:', or 'chunked::'. Implies --transactions." }; var vcOption = new Option("--vc") { - Description = "Enable the version-control decorator (implies --transactions)", - DefaultValueFactory = _ => false + Description = "Enable the version-control decorator (implies --transactions)", + DefaultValueFactory = _ => false }; var vcFileOption = new Option("--vc-file") { - Description = "Path to the version-control branches store (default: .versioncontrol.links)" + Description = "Path to the version-control branches store (default: .versioncontrol.links)" }; var branchOption = new Option("--branch") { - Description = "Switch to a branch (creating it if --branch-from is also passed). Implies --vc." + Description = "Switch to a branch (creating it if --branch-from is also passed). Implies --vc." }; var branchFromOption = new Option("--branch-from") { - Description = "When creating a branch with --branch, fork from this sequence point." + Description = "When creating a branch with --branch, fork from this sequence point." }; var checkoutOption = new Option("--checkout") { - Description = "Time-travel to a specific transition sequence or named tag. Implies --vc." + Description = "Time-travel to a specific transition sequence or named tag. Implies --vc." }; var tagOption = new Option("--tag") { - Description = "Create a tag in the form 'name' (at current head) or 'name='. Implies --vc." + Description = "Create a tag in the form 'name' (at current head) or 'name='. Implies --vc." }; var listBranchesOption = new Option("--list-branches") { - Description = "List version-control branches and exit.", - DefaultValueFactory = _ => false + Description = "List version-control branches and exit.", + DefaultValueFactory = _ => false }; var listTagsOption = new Option("--list-tags") { - Description = "List version-control tags and exit.", - DefaultValueFactory = _ => false + Description = "List version-control tags and exit.", + DefaultValueFactory = _ => false }; var logOption = new Option("--log") { - Description = "Print the transitions log and exit. Implies --transactions.", - DefaultValueFactory = _ => false + Description = "Print the transitions log and exit. Implies --transactions.", + DefaultValueFactory = _ => false }; var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store"); @@ -211,404 +211,404 @@ rootCommand.SetAction( parseResult => { - var db = parseResult.GetValue(dbOption)!; - var queryOptionValue = parseResult.GetValue(queryOption) ?? ""; - var queryArgumentValue = parseResult.GetValue(queryArgument) ?? ""; - var trace = parseResult.GetValue(traceOption); - var autoCreateMissingReferences = parseResult.GetValue(autoCreateMissingReferencesOption); - var structure = parseResult.GetValue(structureOption); - var before = parseResult.GetValue(beforeOption); - var changes = parseResult.GetValue(changesOption); - var after = parseResult.GetValue(afterOption); - var always = parseResult.GetValue(alwaysOption); - var once = parseResult.GetValue(onceOption); - var never = parseResult.GetValue(neverOption); - var triggers = parseResult.GetValue(triggersOption); - var triggersFile = parseResult.GetValue(triggersFileOption); - var embedTriggers = parseResult.GetValue(embedTriggersOption); - var inputPath = parseResult.GetValue(inputOption); - var outputPath = parseResult.GetValue(outputOption); - var transactionsFlag = parseResult.GetValue(transactionsOption); - var transactionsPathRaw = parseResult.GetValue(transactionsFileOption); - var commitModeRaw = parseResult.GetValue(commitModeOption); - var retentionRaw = parseResult.GetValue(retentionOption); - var vc = parseResult.GetValue(vcOption); - var vcFile = parseResult.GetValue(vcFileOption); - var branchName = parseResult.GetValue(branchOption); - var branchFrom = parseResult.GetValue(branchFromOption); - var checkoutPoint = parseResult.GetValue(checkoutOption); - var tagSpec = parseResult.GetValue(tagOption); - var listBranches = parseResult.GetValue(listBranchesOption); - var listTags = parseResult.GetValue(listTagsOption); - var showLog = parseResult.GetValue(logOption); - - var triggerCommandCount = new[] { always, once, never }.Count(value => value); - if (triggerCommandCount > 1) - { - Console.Error.WriteLine("Only one of --always, --once, or --never can be used at a time."); - return 1; - } - - var vcRequested = vc - || !string.IsNullOrWhiteSpace(vcFile) - || !string.IsNullOrWhiteSpace(branchName) - || branchFrom.HasValue - || !string.IsNullOrWhiteSpace(checkoutPoint) - || !string.IsNullOrWhiteSpace(tagSpec) - || listBranches - || listTags; - - var transactionsRequested = transactionsFlag - || !string.IsNullOrWhiteSpace(transactionsPathRaw) - || !string.IsNullOrWhiteSpace(commitModeRaw) - || !string.IsNullOrWhiteSpace(retentionRaw) - || showLog - || vcRequested; - - CommitMode commitMode = CommitMode.Sync; - if (!string.IsNullOrWhiteSpace(commitModeRaw)) - { - if (commitModeRaw.Equals("sync", StringComparison.OrdinalIgnoreCase)) - { - commitMode = CommitMode.Sync; - } - else if (commitModeRaw.Equals("async", StringComparison.OrdinalIgnoreCase)) - { - commitMode = CommitMode.Async; - } - else - { - Console.Error.WriteLine($"Invalid --commit-mode value '{commitModeRaw}'. Use 'sync' or 'async'."); - return 1; - } - } - - LogRetentionPolicy? retentionPolicy = null; - if (!string.IsNullOrWhiteSpace(retentionRaw)) - { - try - { - retentionPolicy = LogRetentionPolicy.Parse(retentionRaw); - } - catch (ArgumentException ex) - { - Console.Error.WriteLine($"Invalid --retention value: {ex.Message}"); - return 1; - } - } - - var baseLinks = new NamedTypesDecorator(db, trace); - INamedTypesLinks decoratedLinks = baseLinks; - NamedTypesDecorator? transitionsStore = null; - NamedTypesDecorator? vcBranchesStore = null; - TransactionsDecorator? transactionsLinks = null; - VersionControlDecorator? vcLinks = null; - - if (transactionsRequested) - { - var effectiveTransactionsFile = !string.IsNullOrWhiteSpace(transactionsPathRaw) - ? transactionsPathRaw - : TransactionsDecorator.MakeTransitionsDatabaseFilename(db); - transitionsStore = new NamedTypesDecorator(effectiveTransactionsFile, trace); - transactionsLinks = new TransactionsDecorator( - baseLinks, - transitionsStore, - retentionPolicy, - commitMode, - trace); - decoratedLinks = transactionsLinks; - } - - if (vcRequested) - { - if (transactionsLinks is null) + var db = parseResult.GetValue(dbOption)!; + var queryOptionValue = parseResult.GetValue(queryOption) ?? ""; + var queryArgumentValue = parseResult.GetValue(queryArgument) ?? ""; + var trace = parseResult.GetValue(traceOption); + var autoCreateMissingReferences = parseResult.GetValue(autoCreateMissingReferencesOption); + var structure = parseResult.GetValue(structureOption); + var before = parseResult.GetValue(beforeOption); + var changes = parseResult.GetValue(changesOption); + var after = parseResult.GetValue(afterOption); + var always = parseResult.GetValue(alwaysOption); + var once = parseResult.GetValue(onceOption); + var never = parseResult.GetValue(neverOption); + var triggers = parseResult.GetValue(triggersOption); + var triggersFile = parseResult.GetValue(triggersFileOption); + var embedTriggers = parseResult.GetValue(embedTriggersOption); + var inputPath = parseResult.GetValue(inputOption); + var outputPath = parseResult.GetValue(outputOption); + var transactionsFlag = parseResult.GetValue(transactionsOption); + var transactionsPathRaw = parseResult.GetValue(transactionsFileOption); + var commitModeRaw = parseResult.GetValue(commitModeOption); + var retentionRaw = parseResult.GetValue(retentionOption); + var vc = parseResult.GetValue(vcOption); + var vcFile = parseResult.GetValue(vcFileOption); + var branchName = parseResult.GetValue(branchOption); + var branchFrom = parseResult.GetValue(branchFromOption); + var checkoutPoint = parseResult.GetValue(checkoutOption); + var tagSpec = parseResult.GetValue(tagOption); + var listBranches = parseResult.GetValue(listBranchesOption); + var listTags = parseResult.GetValue(listTagsOption); + var showLog = parseResult.GetValue(logOption); + + var triggerCommandCount = new[] { always, once, never }.Count(value => value); + if (triggerCommandCount > 1) { - Console.Error.WriteLine("--vc requires the transactions layer (this should have been auto-enabled)."); - return 1; + Console.Error.WriteLine("Only one of --always, --once, or --never can be used at a time."); + return 1; } - var effectiveVcFile = !string.IsNullOrWhiteSpace(vcFile) - ? vcFile - : VersionControlDecorator.MakeVersionControlDatabaseFilename(db); - vcBranchesStore = new NamedTypesDecorator(effectiveVcFile, trace); - vcLinks = new VersionControlDecorator(transactionsLinks, vcBranchesStore, trace); - decoratedLinks = vcLinks; - } - PersistentTransformationDecorator? persistentLinks = null; - var defaultTriggersFile = PersistentTransformationDecorator.MakeTriggersDatabaseFilename(db); - var effectiveTriggersFile = string.IsNullOrWhiteSpace(triggersFile) ? defaultTriggersFile : triggersFile; - var persistentTransformationsEnabled = always - || once - || never - || triggers - || embedTriggers - || !string.IsNullOrWhiteSpace(triggersFile) - || File.Exists(effectiveTriggersFile); - - if (persistentTransformationsEnabled) - { - var triggerLinks = embedTriggers - ? (INamedTypesLinks)baseLinks - : new NamedTypesDecorator(effectiveTriggersFile, trace); - persistentLinks = new PersistentTransformationDecorator(decoratedLinks, triggerLinks, trace) + var vcRequested = vc + || !string.IsNullOrWhiteSpace(vcFile) + || !string.IsNullOrWhiteSpace(branchName) + || branchFrom.HasValue + || !string.IsNullOrWhiteSpace(checkoutPoint) + || !string.IsNullOrWhiteSpace(tagSpec) + || listBranches + || listTags; + + var transactionsRequested = transactionsFlag + || !string.IsNullOrWhiteSpace(transactionsPathRaw) + || !string.IsNullOrWhiteSpace(commitModeRaw) + || !string.IsNullOrWhiteSpace(retentionRaw) + || showLog + || vcRequested; + + CommitMode commitMode = CommitMode.Sync; + if (!string.IsNullOrWhiteSpace(commitModeRaw)) { - AutoCreateMissingReferences = autoCreateMissingReferences - }; - decoratedLinks = persistentLinks; - } - - try - { - return RunCli(); - } - finally - { - transactionsLinks?.Shutdown(); - } - - int RunCli() - { - if (vcLinks is not null) - { - if (!string.IsNullOrWhiteSpace(checkoutPoint)) - { - if (!TryResolveSequence(vcLinks, checkoutPoint, out var seq)) + if (commitModeRaw.Equals("sync", StringComparison.OrdinalIgnoreCase)) { - Console.Error.WriteLine($"Unknown checkout point '{checkoutPoint}'."); - return 1; + commitMode = CommitMode.Sync; } - try + else if (commitModeRaw.Equals("async", StringComparison.OrdinalIgnoreCase)) { - vcLinks.Checkout(seq); - if (trace) Console.WriteLine($"Checked out seq {seq} on branch '{vcLinks.CurrentBranch}'."); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error during --checkout: {ex.Message}"); - return 1; - } - } - - if (!string.IsNullOrWhiteSpace(branchName)) - { - var existing = vcLinks.ListBranches().Any(b => b.Name == branchName); - if (!existing) - { - try - { - vcLinks.Branch(branchName, branchFrom); - if (trace) Console.WriteLine($"Created branch '{branchName}'."); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error creating branch '{branchName}': {ex.Message}"); - return 1; - } - } - try - { - vcLinks.SwitchBranch(branchName); - if (trace) Console.WriteLine($"Switched to branch '{branchName}'."); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error switching to branch '{branchName}': {ex.Message}"); - return 1; - } - } - - if (!string.IsNullOrWhiteSpace(tagSpec)) - { - var eq = tagSpec.IndexOf('='); - string tagName; - long? tagSeq = null; - if (eq < 0) - { - tagName = tagSpec; + commitMode = CommitMode.Async; } else { - tagName = tagSpec.Substring(0, eq); - var point = tagSpec.Substring(eq + 1); - if (!TryResolveSequence(vcLinks, point, out var resolved)) - { - Console.Error.WriteLine($"Unknown tag point '{point}'."); + Console.Error.WriteLine($"Invalid --commit-mode value '{commitModeRaw}'. Use 'sync' or 'async'."); return 1; - } - tagSeq = resolved; } + } + + LogRetentionPolicy? retentionPolicy = null; + if (!string.IsNullOrWhiteSpace(retentionRaw)) + { try { - vcLinks.Tag(tagName, tagSeq); - if (trace) Console.WriteLine($"Tagged '{tagName}' at seq {tagSeq ?? vcLinks.CurrentSequence}."); + retentionPolicy = LogRetentionPolicy.Parse(retentionRaw); } - catch (Exception ex) + catch (ArgumentException ex) { - Console.Error.WriteLine($"Error creating tag '{tagName}': {ex.Message}"); - return 1; + Console.Error.WriteLine($"Invalid --retention value: {ex.Message}"); + return 1; } - } + } - if (listBranches) - { - foreach (var info in vcLinks.ListBranches()) - { - var marker = info.Name == vcLinks.CurrentBranch ? "*" : " "; - var parent = info.Parent ?? "-"; - Console.WriteLine($"{marker} {info.Name}\tparent={parent}\tfork={info.ForkSeq}\thead={info.Head}"); - } - return 0; - } + var baseLinks = new NamedTypesDecorator(db, trace); + INamedTypesLinks decoratedLinks = baseLinks; + NamedTypesDecorator? transitionsStore = null; + NamedTypesDecorator? vcBranchesStore = null; + TransactionsDecorator? transactionsLinks = null; + VersionControlDecorator? vcLinks = null; - if (listTags) - { - foreach (var tag in vcLinks.ListTags().OrderBy(t => t.Key, StringComparer.Ordinal)) + if (transactionsRequested) + { + var effectiveTransactionsFile = !string.IsNullOrWhiteSpace(transactionsPathRaw) + ? transactionsPathRaw + : TransactionsDecorator.MakeTransitionsDatabaseFilename(db); + transitionsStore = new NamedTypesDecorator(effectiveTransactionsFile, trace); + transactionsLinks = new TransactionsDecorator( + baseLinks, + transitionsStore, + retentionPolicy, + commitMode, + trace); + decoratedLinks = transactionsLinks; + } + + if (vcRequested) + { + if (transactionsLinks is null) { - Console.WriteLine($"{tag.Key}\t{tag.Value}"); + Console.Error.WriteLine("--vc requires the transactions layer (this should have been auto-enabled)."); + return 1; } - return 0; - } + var effectiveVcFile = !string.IsNullOrWhiteSpace(vcFile) + ? vcFile + : VersionControlDecorator.MakeVersionControlDatabaseFilename(db); + vcBranchesStore = new NamedTypesDecorator(effectiveVcFile, trace); + vcLinks = new VersionControlDecorator(transactionsLinks, vcBranchesStore, trace); + decoratedLinks = vcLinks; } - if (showLog) + PersistentTransformationDecorator? persistentLinks = null; + var defaultTriggersFile = PersistentTransformationDecorator.MakeTriggersDatabaseFilename(db); + var effectiveTriggersFile = string.IsNullOrWhiteSpace(triggersFile) ? defaultTriggersFile : triggersFile; + var persistentTransformationsEnabled = always + || once + || never + || triggers + || embedTriggers + || !string.IsNullOrWhiteSpace(triggersFile) + || File.Exists(effectiveTriggersFile); + + if (persistentTransformationsEnabled) { - if (transactionsLinks is null) - { - Console.Error.WriteLine("--log requires the transactions layer."); - return 1; - } - foreach (var transition in transactionsLinks.Log) - { - Console.WriteLine($"{transition.Sequence}\t{transition.Timestamp:O}\t{transition.Kind}\t{transition.TransactionId:N}\t({transition.Before.Index},{transition.Before.Source},{transition.Before.Target}) -> ({transition.After.Index},{transition.After.Source},{transition.After.Target})"); - } - return 0; + var triggerLinks = embedTriggers + ? (INamedTypesLinks)baseLinks + : new NamedTypesDecorator(effectiveTriggersFile, trace); + persistentLinks = new PersistentTransformationDecorator(decoratedLinks, triggerLinks, trace) + { + AutoCreateMissingReferences = autoCreateMissingReferences + }; + decoratedLinks = persistentLinks; } - return RunQueryPipeline(); - } - - bool TryResolveSequence(VersionControlDecorator vc, string point, out long sequence) - { - sequence = 0; - if (string.IsNullOrWhiteSpace(point)) return false; - if (long.TryParse(point, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var direct)) + try { - sequence = direct; - return true; + return RunCli(); } - if (vc.TryGetTag(point, out var tagSeq)) + finally { - sequence = tagSeq; - return true; + transactionsLinks?.Shutdown(); } - return false; - } - int RunQueryPipeline() - { - - if (before) - { - PrintAllLinks(decoratedLinks); - } + int RunCli() + { + if (vcLinks is not null) + { + if (!string.IsNullOrWhiteSpace(checkoutPoint)) + { + if (!TryResolveSequence(vcLinks, checkoutPoint, out var seq)) + { + Console.Error.WriteLine($"Unknown checkout point '{checkoutPoint}'."); + return 1; + } + try + { + vcLinks.Checkout(seq); + if (trace) Console.WriteLine($"Checked out seq {seq} on branch '{vcLinks.CurrentBranch}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error during --checkout: {ex.Message}"); + return 1; + } + } + + if (!string.IsNullOrWhiteSpace(branchName)) + { + var existing = vcLinks.ListBranches().Any(b => b.Name == branchName); + if (!existing) + { + try + { + vcLinks.Branch(branchName, branchFrom); + if (trace) Console.WriteLine($"Created branch '{branchName}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error creating branch '{branchName}': {ex.Message}"); + return 1; + } + } + try + { + vcLinks.SwitchBranch(branchName); + if (trace) Console.WriteLine($"Switched to branch '{branchName}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error switching to branch '{branchName}': {ex.Message}"); + return 1; + } + } + + if (!string.IsNullOrWhiteSpace(tagSpec)) + { + var eq = tagSpec.IndexOf('='); + string tagName; + long? tagSeq = null; + if (eq < 0) + { + tagName = tagSpec; + } + else + { + tagName = tagSpec.Substring(0, eq); + var point = tagSpec.Substring(eq + 1); + if (!TryResolveSequence(vcLinks, point, out var resolved)) + { + Console.Error.WriteLine($"Unknown tag point '{point}'."); + return 1; + } + tagSeq = resolved; + } + try + { + vcLinks.Tag(tagName, tagSeq); + if (trace) Console.WriteLine($"Tagged '{tagName}' at seq {tagSeq ?? vcLinks.CurrentSequence}."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error creating tag '{tagName}': {ex.Message}"); + return 1; + } + } + + if (listBranches) + { + foreach (var info in vcLinks.ListBranches()) + { + var marker = info.Name == vcLinks.CurrentBranch ? "*" : " "; + var parent = info.Parent ?? "-"; + Console.WriteLine($"{marker} {info.Name}\tparent={parent}\tfork={info.ForkSeq}\thead={info.Head}"); + } + return 0; + } + + if (listTags) + { + foreach (var tag in vcLinks.ListTags().OrderBy(t => t.Key, StringComparer.Ordinal)) + { + Console.WriteLine($"{tag.Key}\t{tag.Value}"); + } + return 0; + } + } - if (!TryReadLinoInput(decoratedLinks, inputPath)) - { - return 1; - } + if (showLog) + { + if (transactionsLinks is null) + { + Console.Error.WriteLine("--log requires the transactions layer."); + return 1; + } + foreach (var transition in transactionsLinks.Log) + { + Console.WriteLine($"{transition.Sequence}\t{transition.Timestamp:O}\t{transition.Kind}\t{transition.TransactionId:N}\t({transition.Before.Index},{transition.Before.Source},{transition.Before.Target}) -> ({transition.After.Index},{transition.After.Source},{transition.After.Target})"); + } + return 0; + } - if (structure.HasValue) - { - var linkId = structure.Value; - try - { - var structureFormatted = LinoDatabaseOutput.FormatStructure(decoratedLinks, linkId); - Console.WriteLine(structureFormatted); + return RunQueryPipeline(); } - catch (Exception ex) + + bool TryResolveSequence(VersionControlDecorator vc, string point, out long sequence) { - Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}"); - return 1; + sequence = 0; + if (string.IsNullOrWhiteSpace(point)) return false; + if (long.TryParse(point, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var direct)) + { + sequence = direct; + return true; + } + if (vc.TryGetTag(point, out var tagSeq)) + { + sequence = tagSeq; + return true; + } + return false; } - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + int RunQueryPipeline() + { - var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; + if (before) + { + PrintAllLinks(decoratedLinks); + } - if ((always || once || never) && string.IsNullOrWhiteSpace(effectiveQuery)) - { - Console.Error.WriteLine("--always, --once, and --never require a query."); - return 1; - } + if (!TryReadLinoInput(decoratedLinks, inputPath)) + { + return 1; + } - if (persistentLinks is not null && (always || once)) - { - var kind = always ? PersistentTransformationKind.Always : PersistentTransformationKind.Once; - var trigger = persistentLinks.StoreTrigger(kind, effectiveQuery); - Console.WriteLine($"{kind} persistent transformation trigger stored: {trigger}"); - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + if (structure.HasValue) + { + var linkId = structure.Value; + try + { + var structureFormatted = LinoDatabaseOutput.FormatStructure(decoratedLinks, linkId); + Console.WriteLine(structureFormatted); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}"); + return 1; + } + + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } - if (persistentLinks is not null && never) - { - var removed = persistentLinks.RemoveTriggers(effectiveQuery); - Console.WriteLine($"Persistent transformation triggers removed: {removed}"); - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; - var changesList = new List<(DoubletLink Before, DoubletLink After)>(); + if ((always || once || never) && string.IsNullOrWhiteSpace(effectiveQuery)) + { + Console.Error.WriteLine("--always, --once, and --never require a query."); + return 1; + } - if (!string.IsNullOrWhiteSpace(effectiveQuery)) - { - var options = new QueryProcessor.Options - { - Query = effectiveQuery, - Trace = trace, - AutoCreateMissingReferences = autoCreateMissingReferences, - ChangesHandler = (beforeLink, afterLink) => - { - changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); - return decoratedLinks.Constants.Continue; - } - }; - - QueryProcessor.ProcessQuery(decoratedLinks, options); - } + if (persistentLinks is not null && (always || once)) + { + var kind = always ? PersistentTransformationKind.Always : PersistentTransformationKind.Once; + var trigger = persistentLinks.StoreTrigger(kind, effectiveQuery); + Console.WriteLine($"{kind} persistent transformation trigger stored: {trigger}"); + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } - if (changes && changesList.Any()) - { - if (trace) - { - Console.WriteLine("[DEBUG] Raw changes before simplification:"); - for (int i = 0; i < changesList.Count; i++) - { - var (beforeLink, afterLink) = changesList[i]; - Console.WriteLine($"[DEBUG] {i + 1}. ({beforeLink.Index}: {beforeLink.Source} {beforeLink.Target}) -> ({afterLink.Index}: {afterLink.Source} {afterLink.Target})"); - } - Console.WriteLine($"[DEBUG] Total raw changes: {changesList.Count}"); - } + if (persistentLinks is not null && never) + { + var removed = persistentLinks.RemoveTriggers(effectiveQuery); + Console.WriteLine($"Persistent transformation triggers removed: {removed}"); + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } - var simplifiedChanges = SimplifyChanges(changesList); + var changesList = new List<(DoubletLink Before, DoubletLink After)>(); - if (trace) - { - Console.WriteLine($"[DEBUG] Simplified changes count: {simplifiedChanges.Count()}"); - } + if (!string.IsNullOrWhiteSpace(effectiveQuery)) + { + var options = new QueryProcessor.Options + { + Query = effectiveQuery, + Trace = trace, + AutoCreateMissingReferences = autoCreateMissingReferences, + ChangesHandler = (beforeLink, afterLink) => + { + changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); + return decoratedLinks.Constants.Continue; + } + }; + + QueryProcessor.ProcessQuery(decoratedLinks, options); + } - foreach (var (linkBefore, linkAfter) in simplifiedChanges) - { - PrintChange(decoratedLinks, linkBefore, linkAfter); - } - } + if (changes && changesList.Any()) + { + if (trace) + { + Console.WriteLine("[DEBUG] Raw changes before simplification:"); + for (int i = 0; i < changesList.Count; i++) + { + var (beforeLink, afterLink) = changesList[i]; + Console.WriteLine($"[DEBUG] {i + 1}. ({beforeLink.Index}: {beforeLink.Source} {beforeLink.Target}) -> ({afterLink.Index}: {afterLink.Source} {afterLink.Target})"); + } + Console.WriteLine($"[DEBUG] Total raw changes: {changesList.Count}"); + } + + var simplifiedChanges = SimplifyChanges(changesList); + + if (trace) + { + Console.WriteLine($"[DEBUG] Simplified changes count: {simplifiedChanges.Count()}"); + } + + foreach (var (linkBefore, linkAfter) in simplifiedChanges) + { + PrintChange(decoratedLinks, linkBefore, linkAfter); + } + } - if (after) - { - PrintAllLinks(decoratedLinks); - } + if (after) + { + PrintAllLinks(decoratedLinks); + } - return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; - } + return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; + } } ); @@ -616,48 +616,48 @@ int RunQueryPipeline() static void PrintAllLinks(INamedTypesLinks links) { - LinoDatabaseOutput.WriteDatabase(links, Console.Out); + LinoDatabaseOutput.WriteDatabase(links, Console.Out); } static void PrintChange(INamedTypesLinks links, DoubletLink linkBefore, DoubletLink linkAfter) { - Console.WriteLine(LinoDatabaseOutput.FormatChange(links, linkBefore, linkAfter)); + Console.WriteLine(LinoDatabaseOutput.FormatChange(links, linkBefore, linkAfter)); } static bool TryWriteLinoOutput(INamedTypesLinks links, string? outputPath) { - if (string.IsNullOrWhiteSpace(outputPath)) - { - return true; - } + if (string.IsNullOrWhiteSpace(outputPath)) + { + return true; + } - try - { - LinoDatabaseOutput.WriteToFile(links, outputPath); - return true; - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException) - { - Console.Error.WriteLine($"Error writing LiNo output file '{outputPath}': {ex.Message}"); - return false; - } + try + { + LinoDatabaseOutput.WriteToFile(links, outputPath); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException) + { + Console.Error.WriteLine($"Error writing LiNo output file '{outputPath}': {ex.Message}"); + return false; + } } static bool TryReadLinoInput(INamedTypesLinks links, string? inputPath) { - if (string.IsNullOrWhiteSpace(inputPath)) - { - return true; - } + if (string.IsNullOrWhiteSpace(inputPath)) + { + return true; + } - try - { - LinoDatabaseInput.ReadFromFile(links, inputPath); - return true; - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException || ex is FormatException) - { - Console.Error.WriteLine($"Error reading LiNo input file '{inputPath}': {ex.Message}"); - return false; - } + try + { + LinoDatabaseInput.ReadFromFile(links, inputPath); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException || ex is FormatException) + { + Console.Error.WriteLine($"Error reading LiNo input file '{inputPath}': {ex.Message}"); + return false; + } } diff --git a/dev/log/issues/96/pulls/97/analysis/README.md b/dev/log/issues/96/pulls/97/analysis/README.md new file mode 100644 index 0000000..0acb75d --- /dev/null +++ b/dev/log/issues/96/pulls/97/analysis/README.md @@ -0,0 +1,134 @@ +# Issue #96 — Deep analysis + +> "Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all." +> — + +This folder contains the raw evidence (`../ci-logs/`, `../templates/`, `../workflows/`, +`../best-practices/`) and the analysis below. + +## 1. Requirements extracted from the issue + +| ID | Requirement (verbatim intent) | +|----|-------------------------------| +| R1 | Find and fix all **false positives** in CI/CD | +| R2 | Find and fix all **false negatives** in CI/CD | +| R3 | Find and fix all **warnings** in CI/CD | +| R4 | Find and fix all **errors** in CI/CD | +| R5 | Compare **the full file tree** of CI/CD scripts against the three AI-driven-development pipeline templates (`rust-`, `csharp-`, `js-ai-driven-development-pipeline-template`) and reuse all best practices | +| R6 | If the same defect exists in a template, **report an issue in that template repo** too | +| R7 | Follow the CI/CD best practices document from `link-assistant/hive-mind` (`docs/CI-CD-BEST-PRACTICES.md`) | +| R8 | Plan and execute **everything in this single pull request** until every requirement is fully addressed | + +## 2. Timeline of events + +| When | What | Evidence | +|------|------|----------| +| earlier | Windows C# tests started failing with `System.IO.IOException` on temp-file deletion | run 25760911270 (logs expired, HTTP 410) | +| — | Instead of fixing the tests, `continue-on-error: ${{ matrix.os == 'windows-latest' }}` was added to the `Run tests` step of `csharp.yml` with the comment *"Windows has pre-existing file locking issues with some tests"* | `.github/workflows/csharp.yml:186-187` | +| 2026-05-20 | Run 26176444325: the Windows job **reports success** while its log contains `Test Run Failed. Total tests: 212, Passed: 98, Failed: 114` and `Build FAILED` | `../ci-logs/run-26176444325.log` | +| same run | macOS job flakes on `SwapSourceAndTargetForAllLinksUsingVariablesTest` with `System.TimeoutException : Test exceeded 1 seconds timeout` | same log | +| same run | 2 distinct `CS1570` warnings emitted 8× across the matrix; a Node.js-20 deprecation warning is emitted by `actions/github-script` pulled in transitively by `codecov/codecov-action@v5` | same log | +| 2026-08 | Issue #96 filed asking for all of the above to be found and fixed | `../issue/issue-96.json` | + +## 3. Findings, root causes and fixes + +### R2 — false negatives (a real failure reported as success) + +**F1. `continue-on-error` masks 114 failing Windows tests.** +`.github/workflows/csharp.yml:187` marks the whole `dotnet test` step as non-fatal on +`windows-latest`. GitHub Actions then reports the job green. This is the single most +severe defect: for months the Windows leg of the matrix has been decorative. +*Root cause:* a symptom was suppressed rather than diagnosed. +*Fix:* remove the suppression — but only after F2/F3 below are actually fixed, +otherwise CI just turns red. + +**F17. `csharp/scripts/check-file-size.mjs` is dead code.** The script exists and +enforces a 1000-line limit, but no workflow ever invokes it, so the limit is not +enforced for C# at all (the Rust equivalent *is* wired up at `rust.yml:138`). +*Fix:* wire it into the C# lint job. + +**F8/F9. C# warnings can never fail CI.** There is no `csharp/Directory.Build.props` +(all three templates ship one with `TreatWarningsAsErrors`, `EnableNETAnalyzers`, +`AnalysisLevel=latest-all`), and the lint job never runs +`dotnet format --verify-no-changes` (present at `csharp/release.yml:199` in the template). +*Fix:* add both. + +### R4 — errors + +**F2. 226 `System.IO.IOException` → 73 failing Windows tests.** +Every failure is `System.IO.FileSystem.DeleteFile`. The test helpers construct +`NamedTypesDecorator` / `NamedLinksDecorator` / `SimpleLinksDecorator`, +which own `FileMappedResizableDirectMemory` handles, and then `File.Delete` the backing +files **without disposing the decorator first**. +*Root cause:* POSIX allows unlinking a file that is still open; Windows uses mandatory +locking and refuses. The tests are therefore not a Windows bug — they are a resource-leak +bug that only Windows is strict enough to surface. +*Fix:* dispose every `IDisposable` decorator before deleting its files, in **all** +affected helpers. + +**F3. 2 `Assert.Equal` failures on Windows.** +`NamedLinksDecoratorTests.MakeNamesDatabaseFilename_CorrectlyGeneratesFilename` +hard-codes `/`-separated expectations (`"/tmp/test.names.links"`), while the production +code builds the path with `Path.Combine`, which emits `\` on Windows. +*Root cause:* platform-dependent expectation in a platform-independent test. +*Fix:* build the expectation with the same platform-neutral primitives. + +**F4. macOS flake: `Test exceeded 1 seconds timeout`.** +`RunTestWithLinks` wraps every test body in a `CancellationTokenSource(TimeSpan.FromSeconds(1))`. +*Root cause:* a hard-coded 1-second wall-clock budget is far too tight for a shared, +loaded CI runner; it measures runner load, not correctness. +*Fix:* raise the budget and make it overridable via an environment variable. + +### R3 — warnings + +**F5. `CS1570` ×2** at `ChangesSimplifier.cs:187` — `Link` written literally inside an +XML doc comment, so `` is parsed as a (never-closed) XML tag. +*Fix:* use the documentation-comment escape `Link{uint}`; sweep the whole codebase for +the same pattern. + +**F6. Node.js 20 deprecation** for `actions/github-script@60a0d83…`. Not referenced by any +workflow in this repo — it comes transitively from `codecov/codecov-action@v5`. Nothing to +fix locally; reportable upstream (R6). + +**F7. File-size warning:** `rust/src/query_processor.rs` is 994 lines, over the 900-line +warn threshold of `rust/scripts/check-file-size.rs`. + +### R1 — false positives + +A "false positive" here is CI failing (or warning) for something that is not a real defect +in the change under test. The 1-second test timeout (F4) is exactly that: a red build +caused by runner load. Fixing F4 removes it. `fail_ci_if_error: false` on the Codecov step +is retained deliberately — a Codecov outage must not fail a code change. + +### R5/R7 — template + best-practice gaps + +| ID | Gap | Templates that have it | +|----|-----|------------------------| +| F10 | no `security.yml` (CodeQL + `dependency-review-action`) | rust, csharp, js | +| F11 | no `links.yml` (lychee broken-link check) | rust, csharp, js | +| F12 | zero `timeout-minutes` in `csharp.yml` and `rust.yml` | all | +| F13 | no workflow-level least-privilege `permissions:` in `csharp.yml`/`rust.yml` | all | +| F14 | workflow-level `cancel-in-progress: true` on workflows that contain **release/write** jobs — an in-flight NuGet/crates.io publish or tag push can be cancelled | all (reader/writer split) | +| F15 | `always()` in job `if:` instead of `!cancelled()` — keeps running after the user cancels | best practice #12 | +| F16 | no "simulate fresh merge with the base branch" validation | best practice #7 | +| F18 | JS is ~11% of the repo but has no lint job | js template | + +## 4. Existing components / libraries surveyed + +* **`dotnet format`** (ships with the SDK) — whitespace/style verification; no third-party + linter needed for C#. +* **Roslyn analyzers** via `EnableNETAnalyzers` + `AnalysisLevel=latest-all` — built in, + preferred over adding StyleCop/SonarAnalyzer packages. +* **`github/codeql-action`** — first-party SAST, already the templates' choice. +* **`actions/dependency-review-action`** — first-party dependency-diff scanning. +* **`lycheeverse/lychee-action`** — the de-facto broken-link checker; used by the templates. +* **`Microsoft.NET.Test.Sdk` / xUnit `IAsyncLifetime`** — the idiomatic way to scope + test resources; used here via `IDisposable` fixtures instead of ad-hoc `try/finally`. +* No third-party library solves the Windows file-locking problem: the correct fix is to + dispose the handle, which is a plain resource-lifetime bug. + +## 5. Verbose / debug mode + +`RunTestWithLinks` already accepts `enableTracing` (default `false`). The timeout is now +also controllable without a code change via `LINK_CLI_TEST_TIMEOUT_SECONDS`, so a future +iteration can widen or narrow the budget from CI alone. Both default to off/generous. diff --git a/dev/log/issues/96/pulls/97/analysis/temp-file-leak-evidence.md b/dev/log/issues/96/pulls/97/analysis/temp-file-leak-evidence.md new file mode 100644 index 0000000..7830921 --- /dev/null +++ b/dev/log/issues/96/pulls/97/analysis/temp-file-leak-evidence.md @@ -0,0 +1,25 @@ +# Evidence: temp-file leak fixed (measurable on Linux) + +The Windows `IOException`s are the visible symptom of a leak that also existed on Linux — +it was simply invisible there, because POSIX allows unlinking a still-mapped file, and +because several test helpers never deleted the `.names.links` companion database at all. + +Reproduction, before the fix (`dotnet test --no-build -c Release`, Linux): + +``` +$ ls /tmp/*.names.links | wc -l +104 +``` + +After the fix (temp directory cleared, full suite re-run): + +``` +$ rm -f /tmp/*.names.links +$ dotnet test --no-build -c Release +Passed! - Failed: 0, Passed: 222, Skipped: 0, Total: 222 +$ ls /tmp/*.names.links 2>/dev/null | wc -l +0 +``` + +Every names database is now released and deleted. On Windows the same change is what makes +the `File.Delete` calls succeed instead of throwing. diff --git a/dev/log/issues/96/pulls/97/analysis/windows-failed-tests.txt b/dev/log/issues/96/pulls/97/analysis/windows-failed-tests.txt new file mode 100644 index 0000000..fd6e0e4 --- /dev/null +++ b/dev/log/issues/96/pulls/97/analysis/windows-failed-tests.txt @@ -0,0 +1,114 @@ +Foundation.Data.Doublets.Cli.Tests.Issue62ReviewCoverageTests.ExplicitNumericIdUpdate_CanBeReversedWithAnotherUpdate +Foundation.Data.Doublets.Cli.Tests.Issue62ReviewCoverageTests.NamedLink_CreateDeleteRecreate_DoesNotLeaveStaleNameMapping +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_CreatesNamedReferencesAsPointLinks +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_ReproducesNumberedLinksAtTheirExplicitIndexes +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_TreatsOutOfRangeNumbersAsNames +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseInputTests.ImportText_UnquotesNamesWrittenByExporter +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_EscapesNamesThatNeedQuoting +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_SelectsQuoteStyleForNamesContainingQuotes +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_UsesNamesForIndexesSourcesAndTargets_WhenNamesExist +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatDatabase_UsesNumberedReferences_WhenLinksHaveNoNames +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatStructure_RendersLeftBranchWithLinkIndexes +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.FormatStructure_RendersRepeatedSourceAndTargetAsReferenceOnRight +Foundation.Data.Doublets.Cli.Tests.LinoDatabaseOutputTests.WriteToFile_WritesCompleteDatabaseAsLinoLines +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.AfterCreation_SetNameAndGetName_ShouldWork +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.CanConstructNamedLinksDecorator +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.DeleteLink_RemovesNameAutomatically +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.MakeNamesDatabaseFilename_CorrectlyGeneratesFilename +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.RemoveName_NonExistent_DoesNotThrow +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.RemoveName_ShouldReturnNullAfterRemoval +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.SetNameAndGetName_ShouldReturnSameName +Foundation.Data.Doublets.Cli.Tests.NamedLinksDecoratorTests.SetName_OverwriteOldName +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanConstructFromDatabaseFilename +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanEnumeratePinnedTypes +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanGetLinkByName +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanOverwriteNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanRemoveNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_CanSetAndGetNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_DeleteRemovesAssociatedNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_HandlesNonexistentNames +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ImplementsILinks +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ImplementsINamedTypes +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ImplementsIPinnedTypes +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_ReassigningExistingNameMovesNameToNewLink +Foundation.Data.Doublets.Cli.Tests.NamedTypesDecoratorTests.NamedTypesDecorator_UsesProvidedPinnedTypesDecorator +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.CanConstructSimpleLinksDecorator +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.DeleteAfterGetOrCreate_DoesNotThrow +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.DeleteAfterGetOrCreate_DoesNotThrow_WithTracing +Foundation.Data.Doublets.Cli.Tests.SimpleLinksDecoratorTests.Delete_WithRestriction_DoesNotThrow +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create3LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create4LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create5LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create6LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Create7LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLeftCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLeftCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkReferencingExistingLink_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithAutoCreateMissingNumericReferences_ShouldCreatePointLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithIntegerId_ShouldCreateSingleLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithNonExistentReference_ShouldThrowException +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithSource2Target2Test +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithValidSelfReference_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithVariableReferences_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateLinkWithWildcardReferences_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateMultipleLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateMultipleLinksWithCrossReferences_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedFamilyLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedLinkWithAutoCreateMissingNamedReferences_ShouldCreatePointLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedLinkWithMissingNamedReferences_ShouldThrowException +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateNamedLinkWithStringId_ShouldCreateSingleLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateRightCompositeIntegerChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateRightCompositeStringChildrenWithoutExtraLeaf_ShouldSucceed +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkWithIndexAfterDoubleGapTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkWithIndexAfterGapTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateSingleLinkWithIndexTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreateTwoNamedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreationDuringUpdateTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.CreationWithEmptySlotDuringUpdateTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateDuplicatePairWithNamedLinks_ShouldCreateOnlyOneSubLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateDuplicatePairWithNumericLinks_ShouldCreateOnlyOneSubLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateMixedNamedAndNumericLinks_ShouldReuseExistingLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateNamedLinks_MultipleQueries_ShouldReuseSameIds +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateNestedDuplicates_ShouldDeduplicateAtAllLevels +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateTripleDuplicatePair_ShouldCreateOnlyOneSubLink +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeduplicateWithDifferentPairs_ShouldNotDeduplicateDifferentLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteAllLinksByIndexTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteAllLinksBySourceAndTargetTest1 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteAllLinksBySourceAndTargetTest2 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteByNamesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteLinksByAnySourceTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteLinksByAnyTargetTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteMultipleLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteNamedFamilyLinksRemovesNamesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteNamedLinkTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteSingleLinkTest_Source1Target2 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeleteSingleLinkTest_Source2Target2 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.DeletionDuringUpdateTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.EnsureCreated_WithSpecialAnyReference_ShouldThrowControlledException +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.ExactMatchAndDelete2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Issue20_SubstituteFullPointWithUnboundParts_ShouldKeepFullPoint +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.Issue20_SubstituteMatchedLinkAndOutgoingLink_ShouldPreserveExistingParts +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MakeAllLinksSelfReferencingUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MakeAllLinksToGoIntoFirstLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MakeAllLinksToGoOutOfFirstLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MatchAndDelete2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MatchSelfReferencingAndMakeThemGoOutFromFirstLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MatchWithExactIndexAndDelete2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MixedMultipleUpdatesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.MultipleUpdatesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NameLookupConsistencyTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NestedDeleteAllLinksBySourceAndTargetTest1 +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NoExactMatch2LevelNestedLinksTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.NoUpdateUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.StringAliasesInVariableRestriction_ShouldConstrainMatchesToNamedLinks +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.SwapEqualSourceAndTargetUsingVariablesHasAllChangesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.SwapSourceAndTargetForAllLinksUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.SwapSourceAndTargetForSingleLinkUsingVariablesTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.UpdateNamedLinkNameTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.UpdateSingleLinkTest +Foundation.Data.Doublets.Cli.Tests.Tests.AdvancedMixedQueryProcessor.UpdateWithNonExistentReference_ShouldThrowException +Foundation.Data.Doublets.Cli.Tests.Tests.PersistentTransformationDecoratorTests.AlwaysTriggerIsStoredInLinksAndAppliedAfterWrite +Foundation.Data.Doublets.Cli.Tests.Tests.PersistentTransformationDecoratorTests.NeverRemovesMatchingStoredTrigger +Foundation.Data.Doublets.Cli.Tests.Tests.PersistentTransformationDecoratorTests.OnceTriggerDeletesItselfAfterFirstMatch diff --git a/dev/log/issues/96/pulls/97/best-practices/CI-CD-BEST-PRACTICES.md b/dev/log/issues/96/pulls/97/best-practices/CI-CD-BEST-PRACTICES.md new file mode 100644 index 0000000..d0bedd0 --- /dev/null +++ b/dev/log/issues/96/pulls/97/best-practices/CI-CD-BEST-PRACTICES.md @@ -0,0 +1,437 @@ +# CI/CD Best Practices for AI-Driven Development (languages: en • [zh](CI-CD-BEST-PRACTICES.zh.md) • [hi](CI-CD-BEST-PRACTICES.hi.md) • [ru](CI-CD-BEST-PRACTICES.ru.md)) + +This document describes CI/CD best practices that significantly improve the quality and reliability of AI-driven development workflows. When properly configured, Hive Mind AI solvers are forced to iterate with CI/CD checks until all tests pass, ensuring code quality meets the highest standards. + +## Why CI/CD Matters for AI Development + +Hive Mind's AI issue solver is instructed to pay attention to CI/CD checks in each pull request. This creates a powerful feedback loop: + +1. **AI creates a solution** - The solver generates code based on issue requirements +2. **CI/CD validates the solution** - Automated checks verify code quality +3. **AI iterates until passing** - The solver fixes issues until all checks pass +4. **Quality is guaranteed** - No code merges without passing all gates + +This approach ensures consistent quality regardless of whether the team consists of humans, AIs, or both. + +## Recommended CI/CD Templates + +We provide ready-to-use templates for multiple languages with all best practices pre-configured: + +| Language | Template Repository | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| JavaScript/TypeScript | [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) | +| Rust | [rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) | +| Python | [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) | +| Go | [go-ai-driven-development-pipeline-template](https://github.com/link-foundation/go-ai-driven-development-pipeline-template) | +| C# | [csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) | +| Java | [java-ai-driven-development-pipeline-template](https://github.com/link-foundation/java-ai-driven-development-pipeline-template) | +| PHP | [php-ai-driven-development-pipeline-template](https://github.com/link-foundation/php-ai-driven-development-pipeline-template) | + +> **Tip:** You don't have to pick a template by hand. Run `fix --ci-cd` (see [Automatic CI/CD Remediation](#automatic-cicd-remediation)) and Hive Mind detects the repository's languages and selects the matching templates for you. + +## Key CI/CD Principles + +### 1. Run Checks Only on Relevant File Changes + +**Only trigger checks when relevant files change.** This dramatically reduces CI costs and run times. + +Use a `detect-changes` job at the start of your workflow to determine which file categories changed: + +```yaml +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + code-changed: ${{ steps.changes.outputs.code }} + docs-changed: ${{ steps.changes.outputs.docs }} + docker-changed: ${{ steps.changes.outputs.docker }} + workflow-changed: ${{ steps.changes.outputs.workflow }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Detect changes + id: changes + run: node scripts/detect-code-changes.mjs +``` + +Then gate each job on the relevant output: + +```yaml +test-suites: + needs: [detect-changes] + if: needs.detect-changes.outputs.code-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... + +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + # ... + +docker-pr-check: + needs: [detect-changes] + if: needs.detect-changes.outputs.docker-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... +``` + +**What to exclude from "code changes" detection:** + +- Markdown files (`*.md`) — documentation-only changes don't need changeset files +- `.changeset/` folder — changeset metadata isn't code +- `data/` and `experiments/` folders — non-production content +- `.gitkeep` files — placeholder files with no functional impact + +**What always triggers checks when changed:** + +- Source code files (`.mjs`, `.ts`, `.py`, `.rs`, `.go`, etc.) +- `package.json` / dependency manifests +- CI/CD workflow files (`.github/workflows/*.yml`) +- `Dockerfile` and related infrastructure files + +### 2. File Size Limits + +**Enforce a maximum of 1000-1500 lines per code file.** + +This constraint benefits both AI and human developers: + +- AI models can read and understand entire files within context windows +- Humans can navigate and comprehend files without cognitive overload +- Forces modular, well-organized code architecture + +Example enforcement in CI (bash): + +```bash +find src/ -name "*.mjs" -type f | while read -r file; do + line_count=$(wc -l < "$file") + if [ "$line_count" -gt 1500 ]; then + echo "ERROR: $file has $line_count lines (limit: 1500)" + echo "::error file=$file::File has $line_count lines (limit: 1500)" + exit 1 + fi +done +``` + +**Synchronize the file-size ESLint rule with the CI check** to catch violations locally before CI: + +```js +// eslint.config.mjs +{ + rules: { + 'max-lines': ['error', { max: 1500 }] + } +} +``` + +### 3. Automated Code Formatting + +Consistent formatting eliminates style debates and reduces diff noise: + +| Language | Tool | +| --------------------- | ----------------------------- | +| JavaScript/TypeScript | ESLint + Prettier | +| Rust | rustfmt | +| Python | Ruff | +| Go | gofmt | +| C# | dotnet format | +| Java | Spotless (Google Java Format) | +| PHP | PHP CS Fixer | + +All templates include pre-commit hooks that run formatters automatically before each commit. + +### 4. Static Analysis & Linting + +Catch bugs and enforce patterns before code reaches review: + +| Language | Tools | +| --------------------- | ----------------------------------- | +| JavaScript/TypeScript | ESLint with strict rules | +| Rust | Clippy (pedantic + nursery) | +| Python | Ruff + mypy | +| Go | go vet + staticcheck | +| C# | .NET analyzers (warnings as errors) | +| Java | SpotBugs (maximum effort) | +| PHP | PHPStan (max level) | + +### 5. Fast-Fail Job Ordering + +**Run fast checks before slow checks** to give the fastest possible feedback: + +``` +Fast checks (~7-30s each): Slow checks (~1-10 min each): +├── test-compilation ├── test-suites (unit tests) +├── lint (format + ESLint) ├── test-execution (integration) +└── check-file-line-limits ├── docker-pr-check + └── helm-pr-check +``` + +Gate slow checks on fast checks: + +```yaml +test-suites: + needs: [test-compilation, lint, check-file-line-limits] + if: | + always() && + !cancelled() && + !contains(needs.*.result, 'failure') && + needs.test-compilation.result == 'success' && + needs.lint.result == 'success' && + needs.check-file-line-limits.result == 'success' +``` + +### 6. Changeset-Based Versioning + +All templates use a changeset system that: + +- **Eliminates merge conflicts** - Each PR creates an independent changeset file +- **Automates version bumps** - Highest bump type wins when merging +- **Generates changelogs** - Release notes are compiled automatically +- **Supports semantic versioning** - patch/minor/major bumps are explicit + +| Language | Tool | +| --------------------- | ---------------------------- | +| JavaScript/TypeScript | @changesets/cli | +| Rust | changelog.d + custom scripts | +| Python | Scriv | +| PHP | changelog.d + custom scripts | +| Go, C#, Java | Custom changeset workflows | + +**Exempt docs-only PRs from changeset requirements:** + +```yaml +changeset-check: + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' +``` + +Documentation-only changes (updating `.md` files) should not require a version bump. + +### 7. Validate the Actual Merge Result + +**CI must test what will actually be merged, not a stale PR snapshot.** + +When a PR is opened against a base branch that later receives new commits, the GitHub merge preview can become stale. Simulate a fresh merge before running checks: + +```yaml +- name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git fetch origin "$BASE_REF" + BEHIND_COUNT=$(git rev-list --count HEAD..origin/$BASE_REF) + if [ "$BEHIND_COUNT" -gt 0 ]; then + git merge origin/$BASE_REF --no-edit || \ + (echo "::error::Merge conflict! PR must be rebased before merging." && exit 1) + fi +``` + +This ensures lint, file-size, and other checks validate the final merged state. + +### 8. Pre-commit Hooks + +Local quality gates prevent broken commits from reaching CI: + +1. Format check and auto-fix +2. Lint and static analysis +3. Type checking (where applicable) +4. File size validation +5. Secrets detection + +This "shift left" approach catches issues immediately rather than waiting for CI. + +### 9. Release Automation + +Automated release workflows ensure: + +- **No manual version management** - Versions update automatically +- **OIDC trusted publishing** - No API tokens needed in CI (npm, PyPI, crates.io) +- **Validated releases only** - All checks must pass before publishing +- **Dual trigger modes** - Both automatic (on merge) and manual (workflow dispatch) + +**Prohibit manual version changes** in PRs — all version bumps should be managed by the CI release workflow: + +```yaml +version-check: + if: github.event_name == 'pull_request' + steps: + - name: Check for version changes in package.json + run: node scripts/check-version.mjs +``` + +### 10. Concurrency Control + +**Separate cancellable read-only checks from non-cancellable write jobs.** Configure concurrency at the job level when a workflow contains both kinds of work: + +```yaml +jobs: + lint: + # Include the job identity (and matrix values, when present) so unrelated + # checks remain parallel while a newer run replaces only the stale check. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true + # ... + + deploy: + needs: [lint] + if: ${{ !cancelled() && needs.lint.result == 'success' }} + # Every job that writes to main or an external deployment target uses this + # repository-wide group, even when the jobs live in different workflows. + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # ... +``` + +- **Read-only jobs:** Cancel superseded checks on both pull requests and `main` to reduce runner load. Give each job a distinct suffix; include relevant matrix values so different matrix entries can still run in parallel. +- **Dependent writers:** Use `needs` and require successful prerequisites. A cancelled prerequisite must make its write job not start. +- **Active writers:** Give every release, deploy, tag, generated-content push, and other write job the same repository-scoped group with `cancel-in-progress: false`. An already started writer finishes while the next writer waits in the queue, including writers from another workflow file. +- **Workflow scope:** Do not put cancellable concurrency at workflow level when the workflow has write jobs. Cancelling the workflow would also interrupt a writer that has already started. + +By default, a concurrency group keeps at most one running and one pending job; a newer pending writer replaces the older pending writer. If every queued write must run, add `queue: max` to the writer's concurrency block (up to 100 jobs can wait). `queue: max` cannot be combined with `cancel-in-progress: true`, and execution order follows when jobs start waiting rather than workflow dispatch order, so write jobs should remain idempotent. See [GitHub's concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) for the current queue limits and semantics. + +Use `!cancelled()` instead of `always()` in job conditions so cancellation propagates correctly through the job graph. A bare `always()` can keep downstream work running after cancellation. + +### 11. Secrets Detection + +Prevent accidental credential leaks in CI: + +- Include a secrets scan step using tools like `secretlint` or `truffleHog` +- Fail CI immediately if secrets are detected +- Never log environment variables or token values + +### 12. Documentation Validation + +**Validate documentation files in CI just like code:** + +- Check file size limits (e.g., max 2500 lines for docs) +- Verify required sections exist in key documents +- Check for broken links using tools like `lychee` + +```yaml +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + steps: + - run: node tests/docs-validation.mjs +``` + +### 13. Container Images: Native Runners per Architecture + +**Build each architecture on its own native runner.** GitHub provides free arm64 Linux runners for public repositories (`ubuntu-24.04-arm`). Emulating arm64 with QEMU on an x86 runner is much slower for compiled languages, and building two architectures inside one job makes them sequential instead of parallel. + +```yaml +build-image: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: docker/build-push-action@v7 + with: + platforms: ${{ matrix.platform }} + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + +merge-manifest: + needs: [build-image] + steps: + - run: docker buildx imagetools create -t $IMAGE:$VERSION $DIGESTS +``` + +- **No `setup-qemu-action`.** Its presence means an architecture is being emulated; use a native runner instead. +- **Publish images for every architecture your users run.** A single-architecture image silently excludes Apple Silicon, Graviton, and arm CI runners. +- **Always cache.** Set `cache-from: type=gha` and `cache-to: type=gha,mode=max` on every build step; otherwise every architecture rebuilds the full dependency tree for every release. +- **Never gate the release on the image push.** Publish the GitHub Release and language-registry package first, then attach images as they finish. Release notes contain no data derived from image bytes, so a slow or failed registry push must not hide an otherwise completed release. +- **Assert what you shipped.** Verify that the published manifest lists every intended platform and that each default-branch tag has a corresponding GitHub Release; a missing release is otherwise easy to overlook. + +Reference implementations: [`link-foundation/box`](https://github.com/link-foundation/box) and [`link-assistant/hive-mind`](https://github.com/link-assistant/hive-mind). + +## Quality Enforcement Strategy + +The templates implement a defense-in-depth approach: + +``` +Developer Machine → CI/CD Pipeline → Release +├── Pre-commit hooks ├── detect-changes ├── All checks pass +├── Local tests ├── version-check ├── Version bump +└── IDE integration ├── changeset-check ├── Changelog update + ├── test-compilation └── Publish package + ├── lint (format+ESLint) + ├── check-file-line-limits + ├── test-suites + ├── test-execution + ├── validate-docs + └── docker-pr-check +``` + +Each layer catches different issues, ensuring no problematic code reaches production. + +## Getting Started + +1. **Choose a template** from the table above matching your language +2. **Use it as a GitHub template** to create your new repository +3. **Configure secrets** if needed for publishing (OIDC preferred) +4. **Start developing** with all best practices pre-configured + +The AI solvers will automatically respect and iterate with all configured checks, producing higher quality output than repositories without CI/CD enforcement. + +## Automatic CI/CD Remediation + +For an existing repository, you don't need to apply these practices by hand. The `fix` command automates the whole flow: + +```bash +fix https://github.com/owner/repo --ci-cd +``` + +This command: + +1. **Detects the repository's languages** using the GitHub Linguist API (`GET /repos/{owner}/{repo}/languages`), ordered by the number of bytes per language. +2. **Selects the matching CI/CD templates** from the table above, sorted so the template for the most-used language comes first. +3. **Inspects the latest default-branch commit** and collects its CI/CD runs (falling back to the most recent runs on the default branch when the latest commit has none). +4. **Creates a remediation issue** that lists the failing runs, the detected languages, the recommended templates, and a link back to this document. The issue is created as a **Bug** (with a `bug` label) and its title and text are taken from the [standard remediation template](https://github.com/link-assistant/web-capture/issues/139). +5. **Hands the issue off to `/solve --development-log --deep-analysis --auto-merge`**, which iterates until the fixes are merged. Every option `fix` does not consume itself (for example `--tool`, `--model`, `--think`) is forwarded to `/solve`. + +### Why the issue is a Bug, and what it leaves out + +`--development-log` replaces the template's retired case-study-folder instruction and collects artifacts under `./dev/log/issues/{issue-id}/pulls/{pull-id}`. `/fix` never emits the retired paragraph, including with `--no-solve` or partial option sets. `--deep-analysis` supplies the timeline, root-cause, debug-output, and upstream-reporting guidance, so `fix` conditionally omits the matching paragraphs instead of delivering them twice. + +That omission is only lossless because `/solve` emits the root-cause wording **only for bug-typed issues** — which is why `fix` creates the issue as a Bug. Issue types are configured per organization and labels per repository, so if the target repository accepts neither, the issue is still created without them. + +The retired paragraph cannot be restored by an option combination; `--development-log` is the only supported collection workflow. The remaining conditional omissions are controlled by `--deep-analysis`. + +### Language → Template Mapping + +The command maps detected languages to templates as follows (JavaScript and TypeScript share a single template): + +| Detected Language(s) | Template | +| --------------------- | ---------------------------------------------------------------- | +| JavaScript/TypeScript | `link-foundation/js-ai-driven-development-pipeline-template` | +| Rust | `link-foundation/rust-ai-driven-development-pipeline-template` | +| Python | `link-foundation/python-ai-driven-development-pipeline-template` | +| Go | `link-foundation/go-ai-driven-development-pipeline-template` | +| C# | `link-foundation/csharp-ai-driven-development-pipeline-template` | +| Java | `link-foundation/java-ai-driven-development-pipeline-template` | +| PHP | `link-foundation/php-ai-driven-development-pipeline-template` | + +Languages without a dedicated template (for example Shell or Dockerfile) are listed in the issue for awareness, and the closest matching template is recommended. + +Use `--dry-run` to preview the issue without creating it, and `--no-solve` to create the issue without starting `/solve`: + +```bash +fix owner/repo --ci-cd --dry-run +fix owner/repo --ci-cd --no-solve +``` + +## References + +- [Code Architecture Principles](https://github.com/link-foundation/code-architecture-principles) +- [Contributing Guidelines](./CONTRIBUTING.md) +- [Best Practices](./BEST-PRACTICES.md) diff --git a/dev/log/issues/96/pulls/97/issue/issue-96-comments.json b/dev/log/issues/96/pulls/97/issue/issue-96-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/issue-96-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/issue-96.json b/dev/log/issues/96/pulls/97/issue/issue-96.json new file mode 100644 index 0000000..433f21c --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/issue-96.json @@ -0,0 +1 @@ +{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"### Latest default-branch CI/CD runs\n\n| Workflow | Status | Conclusion | Run |\n| --- | --- | --- | --- |\n| Docs | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444416) |\n| C# CI/CD Pipeline | completed | failure | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444325) |\n| WebAssembly CI | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444415) |\n| Rust CI/CD Pipeline | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444323) |\n\nUse all the best practices from CI/CD templates (check full file tree to compare for all GitHub workflow and CI/CD scripts file), if the same issue is found in template report issue also in templates:\n\n- https://github.com/link-foundation/rust-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/js-ai-driven-development-pipeline-template\n\nWe should compare all files, so we don't have more CI/CD errors in the future and reuse all the best practices from these templates.\n\nFollow the CI/CD best practices collected in [https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md).\n\nPlease plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.\n\n---\n\n
\nContext collected by /fix --ci-cd\n\n- **Repository:** [link-foundation/link-cli](https://github.com/link-foundation/link-cli)\n- **Default branch:** `main`\n- **Latest commit:** `ab2ce8b` ([commit](https://github.com/link-foundation/link-cli/commit/ab2ce8be8e671c91e011d4f02eea10a19deea809)) — Merge pull request #95 from link-foundation/issue-94-c873317dc78c\n- **CI/CD runs found:** 4 (1 not passing)\n\n**Detected languages**\n\n- **Rust** — 45.6%\n- **C#** — 42.2%\n- **JavaScript** — 11.3%\n- **CSS** — 0.8%\n- **HTML** — 0.1%\n\n**Recommended CI/CD templates**\n\nApply the best practices from these templates, in priority order (most-used language first):\n\n1. **Rust** — [link-foundation/rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) _(detected: Rust)_\n2. **C#** — [link-foundation/csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) _(detected: C#)_\n3. **JavaScript / TypeScript** — [link-foundation/js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) _(detected: JavaScript)_\n\nOther detected languages without a dedicated template: CSS, HTML.\n\n
","createdAt":"2026-08-18T12:54:10Z","labels":[{"id":"LA_kwDONXCAbs8AAAAB0ixENw","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":96,"state":"OPEN","title":"Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all"} diff --git a/dev/log/issues/96/pulls/97/issue/issue-96.txt b/dev/log/issues/96/pulls/97/issue/issue-96.txt new file mode 100644 index 0000000..4b63683 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/issue-96.txt @@ -0,0 +1,66 @@ +title: Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all +state: OPEN +author: konard (Konstantin Diachenko) +labels: bug +comments: 0 +assignees: +projects: +milestone: +issue-type: Bug +parent: +sub-issues: +sub-issues-completed: +blocked-by: +blocking: +number: 96 +-- +### Latest default-branch CI/CD runs + +| Workflow | Status | Conclusion | Run | +| --- | --- | --- | --- | +| Docs | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444416) | +| C# CI/CD Pipeline | completed | failure | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444325) | +| WebAssembly CI | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444415) | +| Rust CI/CD Pipeline | completed | success | [run](https://github.com/link-foundation/link-cli/actions/runs/26176444323) | + +Use all the best practices from CI/CD templates (check full file tree to compare for all GitHub workflow and CI/CD scripts file), if the same issue is found in template report issue also in templates: + +- https://github.com/link-foundation/rust-ai-driven-development-pipeline-template +- https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template +- https://github.com/link-foundation/js-ai-driven-development-pipeline-template + +We should compare all files, so we don't have more CI/CD errors in the future and reuse all the best practices from these templates. + +Follow the CI/CD best practices collected in [https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md). + +Please plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done. + +--- + +
+Context collected by /fix --ci-cd + +- **Repository:** [link-foundation/link-cli](https://github.com/link-foundation/link-cli) +- **Default branch:** `main` +- **Latest commit:** `ab2ce8b` ([commit](https://github.com/link-foundation/link-cli/commit/ab2ce8be8e671c91e011d4f02eea10a19deea809)) — Merge pull request #95 from link-foundation/issue-94-c873317dc78c +- **CI/CD runs found:** 4 (1 not passing) + +**Detected languages** + +- **Rust** — 45.6% +- **C#** — 42.2% +- **JavaScript** — 11.3% +- **CSS** — 0.8% +- **HTML** — 0.1% + +**Recommended CI/CD templates** + +Apply the best practices from these templates, in priority order (most-used language first): + +1. **Rust** — [link-foundation/rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) _(detected: Rust)_ +2. **C#** — [link-foundation/csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) _(detected: C#)_ +3. **JavaScript / TypeScript** — [link-foundation/js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) _(detected: JavaScript)_ + +Other detected languages without a dedicated template: CSS, HTML. + +
diff --git a/dev/log/issues/96/pulls/97/issue/pr-97-conversation-comments.json b/dev/log/issues/96/pulls/97/issue/pr-97-conversation-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97-conversation-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/pr-97-review-comments.json b/dev/log/issues/96/pulls/97/issue/pr-97-review-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97-review-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/pr-97-reviews.json b/dev/log/issues/96/pulls/97/issue/pr-97-reviews.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97-reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/96/pulls/97/issue/pr-97.json b/dev/log/issues/96/pulls/97/issue/pr-97.json new file mode 100644 index 0000000..4b24bec --- /dev/null +++ b/dev/log/issues/96/pulls/97/issue/pr-97.json @@ -0,0 +1 @@ +{"body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #96.\n\n### 📋 Issue Reference\nFixes #96\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","createdAt":"2026-08-18T12:54:50Z","headRefName":"issue-96-df5aa0703ffa","number":97,"state":"OPEN","title":"[WIP] Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all"} diff --git a/dev/log/issues/96/pulls/97/templates/csharp-workflows.txt b/dev/log/issues/96/pulls/97/templates/csharp-workflows.txt new file mode 100644 index 0000000..5d02517 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp-workflows.txt @@ -0,0 +1,4 @@ +docs.yml +links.yml +release.yml +security.yml diff --git a/dev/log/issues/96/pulls/97/templates/csharp/docs.yml b/dev/log/issues/96/pulls/97/templates/csharp/docs.yml new file mode 100644 index 0000000..642b36a --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/docs.yml @@ -0,0 +1,110 @@ +name: docs + +# Build and deploy DocFX API documentation to GitHub Pages. +# +# Build runs on every push to main and on PRs that touch docs, sources, or +# this workflow. Publishing is gated on `push` to `main` (and manual dispatch) +# plus an explicit DEPLOY_GITHUB_PAGES=true repository variable, so fresh +# repositories keep docs build validation without failing before Pages is +# enabled and configured. See issue #15 for the failure mode that gating on +# releases produces. + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'docfx.json' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'docfx.json' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Install DocFX + run: dotnet tool update -g docfx + + - name: Restore dependencies + run: dotnet restore + + - name: Build documentation site + run: docfx docfx.json -o _site + + - name: List built site (debug) + run: | + echo "::group::_site tree" + find _site -maxdepth 3 -print + echo "::endgroup::" + + - name: Skip GitHub Pages deployment + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES != 'true' + run: | + echo "::notice::GitHub Pages deployment is disabled. Configure Pages, set repository variable DEPLOY_GITHUB_PAGES=true, then rerun this workflow to publish docs." + + - name: Configure GitHub Pages + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + uses: actions/upload-pages-artifact@v5 + with: + path: _site + + deploy: + name: Deploy to GitHub Pages + if: | + ((github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch') && + vars.DEPLOY_GITHUB_PAGES == 'true' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + - name: Print resolved deployment URL (debug) + run: | + echo "Pages deployed to: ${{ steps.deployment.outputs.page_url }}" diff --git a/dev/log/issues/96/pulls/97/templates/csharp/links.yml b/dev/log/issues/96/pulls/97/templates/csharp/links.yml new file mode 100644 index 0000000..a465793 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/links.yml @@ -0,0 +1,98 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links found and no web archive fallback + if: steps.lychee.outputs.exit_code != 0 && steps.webarchive.outputs.all_archived != 'true' + run: | + echo "::error::Broken links were detected with no Web Archive fallback available." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " The Web Archive (Wayback Machine) check found no archived versions for some of them." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/96/pulls/97/templates/csharp/release.yml b/dev/log/issues/96/pulls/97/templates/csharp/release.yml new file mode 100644 index 0000000..1aaddc0 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/release.yml @@ -0,0 +1,793 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name != 'workflow_dispatch' + outputs: + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: bun run "${{ steps.csharp_layout.outputs.root }}/scripts/detect-code-changes.mjs" + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets + changeset-check: + name: Changeset Validation + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Validate changeset + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: | + # Skip changeset check for automated release PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-manual-release-"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Run changeset validation script + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/validate-changeset.mjs" + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-foundation/js-ai-driven-development-pipeline-template/pull/18 for why this dependency was removed + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [detect-changes] + # detect-changes is intentionally skipped for workflow_dispatch. Without a + # status-check function, GitHub adds an implicit success() on detect-changes + # and skips lint (and everything that needs it, including instant-release). + # always() && !cancelled() lets the OR conditions decide instead. + if: | + always() && !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Check formatting + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet format --verify-no-changes --verbosity diagnostic + + - name: Build with warnings as errors + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release /warnaserror + + - name: Run script tests + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: bun test scripts/*.test.mjs + + - name: Check file size limit + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: bun run scripts/check-file-size.mjs + + # === TEST ON MULTIPLE OS === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + needs: [detect-changes] + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # Run tests only for events or file changes that affect runtime behavior. + if: | + always() && !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Build + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release + + - name: Run tests + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" + + - name: Skip Codecov upload when token is unavailable + if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN == '' + run: echo "::notice::CODECOV_TOKEN is not configured; skipping Codecov upload." + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v7 + with: + token: ${{ env.CODECOV_TOKEN }} + fail_ci_if_error: true + + # === BUILD PACKAGE === + # Only runs if lint and test pass + build: + name: Build Package + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [lint, test] + if: always() && needs.lint.result == 'success' && needs.test.result == 'success' + steps: + - uses: actions/checkout@v6 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet restore + + - name: Build Release + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet build --no-restore --configuration Release + + - name: Pack NuGet package + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: ${{ steps.csharp_layout.outputs.root }}/artifacts/*.nupkg + + # === AUTOMATIC RELEASE === + # Runs on push to main using changesets + release: + name: Release + needs: [lint, test, build] + if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Check for changesets + id: check_changesets + run: | + # Count changeset files (excluding README.md and config.json) + CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset" + CHANGESET_COUNT=$(find "$CHANGESET_DIR" -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT + + - name: Check if release is needed + # Self-healing gate: even when a changeset is absent, resume publishing + # if the csproj is missing on NuGet or its GitHub release does + # not exist. See issue #11 and the JS template's check-release-needed.mjs + # for the same pattern. + id: check_release + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + CSHARP_ROOT: ${{ steps.csharp_layout.outputs.root }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/check-release-needed.mjs" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + echo "Multiple changesets detected, merging..." + bun run scripts/merge-changesets.mjs + + - name: Version and commit + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \ + --mode changeset \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Resolve release version + # Picks the version that downstream steps should publish. Prefers the + # one just committed; falls back to the csproj reported by + # check-release-needed for self-healing re-runs. + id: release_version + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + run: | + if [ -n "${{ steps.version.outputs.new_version }}" ]; then + VERSION="${{ steps.version.outputs.new_version }}" + else + VERSION="${{ steps.check_release.outputs.current_version }}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing version: $VERSION" + + - name: Build release package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package id + id: package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r') + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r') + fi + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID="MyPackage" + fi + echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + # Upfront validation surfaces an expired/invalid NUGET_API_KEY before + # we attempt a push that would otherwise return HTTP 403 mid-flight. + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for NuGet indexing + # NuGet's flat-container API can take up to 15 minutes to reflect a + # newly pushed package (see issue #13). Poll it via the tested helper + # before creating the GitHub release so users can `dotnet add package` + # the version mentioned in the release notes. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true')) + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Smoke-test published NuGet package + # Indexing proves the version exists; this installs it in a clean + # throwaway project and runs the advertised library entry point before + # release notes are published. The helper captures command output + # before previewing it, avoiding SIGPIPE-prone live-output pagination. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true')) + run: | + bun run scripts/smoke-test-nuget-package.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && + steps.check_release.outputs.skip_bump == 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \ + --release-version "${{ steps.release_version.outputs.version }}" \ + --repository "${{ github.repository }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --assets-glob "./artifacts/*.nupkg" + + # === MANUAL INSTANT RELEASE === + # Triggered via workflow_dispatch with instant mode + instant-release: + name: Instant Release + needs: [lint, test, build] + # Mirror the automatic release job: a status-check function plus explicit + # needs.*.result checks so the dispatch run is evaluated even though + # detect-changes (an upstream skip) propagated through the needs graph. + if: | + always() && !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.lint.result == 'success' && + needs.test.result == 'success' && + needs.build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Version and commit + id: version + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/version-and-commit.mjs" \ + --mode instant \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" + + - name: Build package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package id + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + id: package + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:PackageId | tail -n 1 | tr -d '\r') + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID=$(dotnet msbuild src/MyPackage/MyPackage.csproj -getProperty:AssemblyName | tail -n 1 | tr -d '\r') + fi + if [ -z "$PACKAGE_ID" ]; then + PACKAGE_ID="MyPackage" + fi + echo "id=$PACKAGE_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + working-directory: ${{ steps.csharp_layout.outputs.root }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Wait for NuGet indexing + # NuGet's flat-container API can take up to 15 minutes to reflect a + # newly pushed package (see issue #13). Poll it via the tested helper + # before creating the GitHub release so users can `dotnet add package` + # the version mentioned in the release notes. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/wait-for-nuget.mjs" \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Smoke-test published NuGet package + # Indexing proves the version exists; this installs it in a clean + # throwaway project and runs the advertised library entry point before + # release notes are published. The helper captures command output + # before previewing it, avoiding SIGPIPE-prone live-output pagination. + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + bun run scripts/smoke-test-nuget-package.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bun run "${{ steps.csharp_layout.outputs.root }}/scripts/create-github-release.mjs" \ + --release-version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --csharp-root "${{ steps.csharp_layout.outputs.root }}" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --assets-glob "./artifacts/*.nupkg" + + # === MANUAL CHANGESET PR === + # Creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect C# layout + id: csharp_layout + shell: bash + run: | + set -euo pipefail + if compgen -G "*.csproj" > /dev/null || compgen -G "*.sln" > /dev/null || find src -maxdepth 5 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="." + MULTI_LANGUAGE="false" + elif find csharp -maxdepth 6 -name "*.csproj" -print -quit 2>/dev/null | grep -q .; then + CSHARP_ROOT="csharp" + MULTI_LANGUAGE="true" + else + echo "::error::Could not find a C# project at the repository root or under csharp/" + exit 1 + fi + echo "root=$CSHARP_ROOT" >> "$GITHUB_OUTPUT" + echo "multi-language=$MULTI_LANGUAGE" >> "$GITHUB_OUTPUT" + echo "Detected C# layout: root=$CSHARP_ROOT, multi-language=$MULTI_LANGUAGE" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Create changeset file + run: | + CHANGESET_ID=$(date +%s) + CHANGESET_DIR="${{ steps.csharp_layout.outputs.root }}/.changeset" + mkdir -p "$CHANGESET_DIR" + CHANGESET_FILE="$CHANGESET_DIR/manual-release-${CHANGESET_ID}.md" + + cat > "$CHANGESET_FILE" << 'EOF' + --- + 'MyPackage': ${{ github.event.inputs.bump_type }} + --- + + ${{ github.event.inputs.description || 'Manual release' }} + EOF + + echo "Created changeset: $CHANGESET_FILE" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will version, publish, and create a GitHub release diff --git a/dev/log/issues/96/pulls/97/templates/csharp/security.yml b/dev/log/issues/96/pulls/97/templates/csharp/security.yml new file mode 100644 index 0000000..80b2b93 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/csharp/security.yml @@ -0,0 +1,49 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [csharp, actions] + steps: + - uses: actions/checkout@v6 + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v4 + - uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + - uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/dev/log/issues/96/pulls/97/templates/js-workflows.txt b/dev/log/issues/96/pulls/97/templates/js-workflows.txt new file mode 100644 index 0000000..021b09c --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js-workflows.txt @@ -0,0 +1,4 @@ +example-app.yml +links.yml +release.yml +security.yml diff --git a/dev/log/issues/96/pulls/97/templates/js/example-app.yml b/dev/log/issues/96/pulls/97/templates/js/example-app.yml new file mode 100644 index 0000000..01f7f71 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/example-app.yml @@ -0,0 +1,312 @@ +name: Example app + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'examples/universal-app/**' + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/update-preview-images.mjs' + - '.github/workflows/example-app.yml' + push: + branches: + - main + paths: + - 'examples/universal-app/**' + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'scripts/update-preview-images.mjs' + - '.github/workflows/example-app.yml' + workflow_dispatch: + +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + web-build: + name: Build web app + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-web-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/configure-pages@v6 + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Build app + run: npm run example:web:build + env: + GITHUB_PAGES: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + VITE_REPOSITORY_URL: https://github.com/${{ github.repository }} + + - name: Upload web build artifact + uses: actions/upload-artifact@v7 + with: + name: universal-example-web + path: examples/universal-app/dist + if-no-files-found: error + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v5 + with: + path: examples/universal-app/dist + + # Requires Settings → Pages → Source = GitHub Actions to be set once + # in the repository before this job can succeed. See README → "Deploying + # the example app". The Pages source cannot be configured from a workflow. + pages-deploy: + name: Deploy GitHub Pages + needs: [web-build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v5 + + desktop-package: + name: Package desktop app (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-desktop-package-${{ matrix.os }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Package Electron app + shell: bash + run: | + npm run example:desktop:package + for attempt in {1..30}; do + if [[ -d examples/universal-app/out ]] && + [[ -n "$(find examples/universal-app/out -mindepth 1 -print -quit)" ]]; then + find examples/universal-app/out -maxdepth 2 -mindepth 1 -print + exit 0 + fi + sleep 1 + done + echo "::error::Desktop package output was not created at examples/universal-app/out" + exit 1 + env: + VITE_REPOSITORY_URL: https://github.com/${{ github.repository }} + + - name: Upload desktop package + uses: actions/upload-artifact@v7 + with: + name: universal-example-desktop-${{ matrix.os }} + path: examples/universal-app/out + if-no-files-found: error + + android-build: + name: Build Android app + if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_ANDROID_BUILD == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-android-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Add Android project + run: npm --prefix examples/universal-app run mobile:android:add + + - name: Build Android project + run: npm --prefix examples/universal-app run mobile:android:build + + - name: Upload Android output + uses: actions/upload-artifact@v7 + with: + name: universal-example-android + path: | + examples/universal-app/android/app/build/outputs/**/*.apk + examples/universal-app/android/app/build/outputs/**/*.aab + if-no-files-found: warn + + ios-build: + name: Build iOS app + if: github.event_name == 'workflow_dispatch' && vars.EXAMPLE_APP_ENABLE_IOS_BUILD == 'true' + runs-on: macos-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-ios-build + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: npm + cache-dependency-path: examples/universal-app/package-lock.json + + - name: Install app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Add iOS project + run: npm --prefix examples/universal-app run mobile:ios:add + + - name: Build iOS project + run: npm --prefix examples/universal-app run mobile:ios:build + + # Regenerate example-app preview screenshots (docs/screenshots/example-app/*) + # using browser-commander + Playwright so README/site images always reflect + # the current UI. Issue: #62. Implementation: scripts/update-preview-images.mjs. + preview-regen: + name: Regenerate Preview Images + runs-on: ubuntu-latest + container: + # Keep this tag in sync with the playwright package version below. + image: mcr.microsoft.com/playwright:v1.59.1-noble + timeout-minutes: 20 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' + permissions: + contents: write + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + steps: + - uses: actions/checkout@v6 + with: + # Regenerate against main HEAD so the bot commit lands on a + # fast-forward parent regardless of which trigger started the job. + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Install example app dependencies + run: npm ci --prefix examples/universal-app --no-audit --no-fund + + - name: Install browser automation dependencies + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + run: npm install --no-save --package-lock=false --no-audit --no-fund browser-commander@0.8.1 playwright@1.59.1 + + - name: Regenerate preview images + run: node scripts/update-preview-images.mjs + + - name: Detect drift + id: drift + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "drift=true" >> "$GITHUB_OUTPUT" + echo "Preview images drifted:" + git status --porcelain + else + echo "drift=false" >> "$GITHUB_OUTPUT" + echo "Preview images already current." + fi + + - name: Commit drift back to main + if: steps.drift.outputs.drift == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Stage only generated artifacts so unrelated changes can't leak in. + git add docs/screenshots/example-app/*.png || true + if git diff --cached --quiet; then + echo "::notice::No tracked preview-image changes to commit (drift was outside expected paths)." + git status --porcelain + exit 0 + fi + # [skip ci] prevents an infinite re-run loop; the next push to main + # will pick up these fresh images for the regular pages-build. + git commit -m "chore(preview): regenerate example-app preview images [skip ci]" + bash scripts/push-main-with-rebase-retry.sh + + - name: Upload screenshot failure artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: preview-regen-failure-${{ github.run_id }} + path: | + docs/screenshots/ + web/test-results/ + web/playwright-report/ + retention-days: 7 + if-no-files-found: ignore + + - name: Summarize regeneration result + if: always() + run: | + if [[ "${{ steps.drift.outputs.drift }}" == "true" ]]; then + echo "::notice::Preview images regenerated and (if applicable) committed to main." + else + echo "::notice::Preview images are already up to date." + fi diff --git a/dev/log/issues/96/pulls/97/templates/js/links.yml b/dev/log/issues/96/pulls/97/templates/js/links.yml new file mode 100644 index 0000000..3cc3b91 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/links.yml @@ -0,0 +1,101 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + # Exclude the Vite source HTML because its root-relative app asset + # URLs are only valid when served by Vite. + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path examples/universal-app/index.html + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links were found + if: always() && steps.lychee.outputs.exit_code != 0 + run: | + echo "::error::Broken live links were detected." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " An archive is a suggested replacement; it does not make the live link valid." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. For links marked with a '::notice::' annotation above, a Web Archive replacement exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/96/pulls/97/templates/js/release.yml b/dev/log/issues/96/pulls/97/templates/js/release.yml new file mode 100644 index 0000000..ec8edd7 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/release.yml @@ -0,0 +1,862 @@ +name: Checks and release + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + # Manual release support - consolidated here to work with npm trusted publishing + # npm only allows ONE workflow file as trusted publisher, so all publishing + # must go through this workflow (release.yml) + workflow_dispatch: + inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Manual release type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Manual release description (optional)' + required: false + type: string + +# Least-privilege default for the highest-value token in the repository. +# Every job starts read-only; the publishing jobs escalate individually. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + # Typical run: ~6s. Cap at 5min so a hung detection step + # surfaces quickly instead of stalling the whole pipeline. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name != 'workflow_dispatch' + outputs: + js-changed: ${{ steps.changes.outputs.js-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect changes + id: changes + run: node scripts/detect-code-changes.mjs + + # === FAST CHECKS - run before slow tests for fastest feedback === + # See: hive-mind CI/CD best practices principle #5 (fast-fail job ordering) + + # Syntax check all .mjs files with node --check (~7s) + test-compilation: + name: Test Compilation + runs-on: ubuntu-latest + # Typical run: <10s. Tight cap fails fast on syntax-check hangs. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-compilation + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.js-changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Check .mjs syntax + run: bash scripts/check-mjs-syntax.sh + + # Enforce 1500-line limit on .mjs files and release.yml + check-file-line-limits: + name: Check File Line Limits + runs-on: ubuntu-latest + # Typical run: <10s. This job only walks tracked files and counts lines. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-check-file-line-limits + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + # Enforces the 1500-line limit on JavaScript (.js/.mjs/.cjs) and + # Markdown (.md) files plus release.yml. This is the single source + # of truth for the line limit; validate-docs no longer re-checks it. + - name: Check file line limits + run: bash scripts/check-file-line-limits.sh + + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in package.json - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + # Typical run: ~6s. Read-only package.json diff inspection. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for version changes in package.json + env: + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: node scripts/check-version.mjs + + # === CHANGESET CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changesets + changeset-check: + name: Check for Changesets + runs-on: ubuntu-latest + # Typical run: <30s including npm install. 10min covers cold runners. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-changeset-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Check for changesets + env: + # Pass PR context to the validation script + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Skip changeset check for automated version PRs + if [[ "$GITHUB_HEAD_REF" == "changeset-release/"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Run changeset validation script + # This validates that exactly ONE changeset was ADDED by this PR + # Pre-existing changesets from other merged PRs are ignored + node scripts/validate-changeset.mjs + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changeset-check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + # IMPORTANT: ESLint includes max-lines rule (1500 lines) to ensure files stay maintainable + # See docs/case-studies/issue-23 for why fresh merge simulation is critical + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + # Typical run: <1min including install, ESLint, Prettier, jscpd, + # and secretlint. 10min protects against a hung lint plugin. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + !cancelled() && + ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + with: + # For PRs, fetch enough history to merge with base branch + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Check code duplication + run: npm run check:duplication + + - name: Check for secrets + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*" + + # Test matrix: 3 runtimes (Node.js, Bun, Deno) x 3 OS (Ubuntu, macOS, Windows) + # IMPORTANT: Tests must validate the ACTUAL merge result, not a stale merge preview. + # See docs/case-studies/issue-23 for why this is critical. + # Fast-fail: slow test matrix only runs after fast checks pass (hive-mind principle #5) + test: + name: Test (${{ matrix.runtime }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # Typical run: <1min per runtime/OS on warm runners, with Windows + # sometimes slower on cold starts. 10min fails hung tests well + # before GitHub Actions' 6h default. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.runtime }}-${{ matrix.os }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: + [ + detect-changes, + changeset-check, + test-compilation, + lint, + check-file-line-limits, + ] + # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278) + # Run for relevant code/package/workflow changes, or for an instant manual + # release. Skipped fast checks count as non-failures only after one of + # those positive gates passes. + if: | + !cancelled() && + ( + needs.detect-changes.outputs.any-code-changed == 'true' || + ( + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' + ) + ) && + (needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') && + (needs.test-compilation.result == 'success' || needs.test-compilation.result == 'skipped') && + (needs.lint.result == 'success' || needs.lint.result == 'skipped') && + (needs.check-file-line-limits.result == 'success' || needs.check-file-line-limits.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runtime: [node, bun, deno] + steps: + - uses: actions/checkout@v6 + with: + # For PRs, fetch enough history to merge with base branch + fetch-depth: 0 + + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + shell: bash + run: bash scripts/simulate-fresh-merge.sh + + - name: Setup Node.js + if: matrix.runtime == 'node' + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies (Node.js) + if: matrix.runtime == 'node' + run: npm install + + - name: Run tests (Node.js) + if: matrix.runtime == 'node' + run: npm test + + - name: Setup Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies (Bun) + if: matrix.runtime == 'bun' + run: bun install + + - name: Run tests (Bun) + if: matrix.runtime == 'bun' + # --timeout caps an individual test at 30s, matching Node's + # --test-timeout budget while leaving headroom for cold runners. + run: bun test --timeout 30000 + + - name: Setup Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Run tests (Deno) + if: matrix.runtime == 'deno' + run: deno test --allow-read + + # === DOCKER IMAGE BUILD CHECK (pull requests) === + # Builds the Dockerfile without pushing so a broken image fails the pull + # request instead of surfacing only after publish (issue #106). + # push: false + load: true keeps this working for fork pull requests, + # which have no registry credentials. Skipped when no Dockerfile exists. + docker-build: + name: Docker Image Build Check + runs-on: ubuntu-latest + # Typical run: a few minutes with a warm GHA layer cache. 30min covers + # a cold, uncached build without allowing a 6h hang. + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-docker-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + permissions: + contents: read + env: + DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }} + DOCKERFILE: ${{ vars.DOCKERFILE }} + steps: + - uses: actions/checkout@v6 + + - name: Check Docker build configuration + id: docker_config + run: node scripts/check-docker-build.mjs + + - name: Set up Docker Buildx (resilient) + if: steps.docker_config.outputs.enabled == 'true' + uses: ./.github/actions/setup-buildx-resilient + + - name: Build Docker image (no push) + if: steps.docker_config.outputs.enabled == 'true' + uses: docker/build-push-action@v7 + with: + context: ${{ steps.docker_config.outputs.context }} + file: ${{ steps.docker_config.outputs.dockerfile }} + push: false + load: true + tags: pr-check:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # === DOCUMENTATION VALIDATION === + # Validate documentation files when docs change (hive-mind principle #12) + validate-docs: + name: Validate Documentation + runs-on: ubuntu-latest + # Typical run: <10s. Pure shell checks over documentation files. + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-validate-docs + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + needs.detect-changes.outputs.docs-changed == 'true' + steps: + - uses: actions/checkout@v6 + + # Documentation line limits (1500 lines, matching the architecture + # limit) are enforced by the check-file-line-limits job, which scans + # every .md file. This job only validates required-file presence. + - name: Check required documentation files exist + run: | + REQUIRED_FILES=( + "docs/BEST-PRACTICES.md" + "docs/CONTRIBUTING.md" + "README.md" + "CHANGELOG.md" + ) + + MISSING=() + for file in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$file" ]; then + echo "ERROR: Required documentation file missing: $file" + MISSING+=("$file") + else + echo "Found: $file" + fi + done + + if [ "${#MISSING[@]}" -gt 0 ]; then + echo "" + echo "Missing required documentation files:" + printf ' %s\n' "${MISSING[@]}" + exit 1 + else + echo "All required documentation files present." + fi + + # Release - only runs on main after tests pass (for push events) + release: + name: Release + needs: [lint, test] + # Typical run is well under 10min. 30min gives npm and GitHub + # release APIs room for retries without allowing a 6h hang. + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # Use !cancelled() instead of always() so cancellation propagates correctly (hive-mind issue #1278) + # This is needed because lint/test jobs have a transitive dependency on changeset-check + if: | + !cancelled() && + github.ref == 'refs/heads/main' && + github.event_name == 'push' && + needs.lint.result == 'success' && + needs.test.result == 'success' + runs-on: ubuntu-latest + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + outputs: + published: ${{ steps.publish.outputs.published }} + published_version: ${{ steps.publish.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - name: Remove deprecated npm auth config + run: node scripts/sanitize-npm-userconfig.mjs + + - name: Install dependencies + run: npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Check for changesets + id: check_changesets + run: node scripts/check-changesets.mjs + + - name: Check if release is needed + id: check_release + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + run: node scripts/check-release-needed.mjs + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + run: | + echo "Multiple changesets detected, merging..." + node scripts/merge-changesets.mjs + + - name: Version packages and commit to main + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + run: node scripts/version-and-commit.mjs --mode changeset + + - name: Publish to npm + # Run if version was committed, if a previous attempt already committed (for re-runs), + # or if check-release-needed detected an unpublished version (self-healing, issue #36) + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + id: publish + # Optional NPM_TOKEN bootstrap fallback. OIDC trusted publishing is the + # steady-state mechanism, but it cannot create a brand-new package (the + # first publish returns E404 because a trusted publisher can only be + # configured for a package that already exists). When NPM_TOKEN is set, + # the first publish succeeds; once the package exists and a trusted + # publisher is configured, OIDC takes over and the token can be removed. + # See issue #77. + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/publish-to-npm.mjs --should-pull + + - name: Smoke-test published npm package + if: steps.publish.outputs.published == 'true' + run: node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}" + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Manual Instant Release - triggered via workflow_dispatch with instant mode + # This job is in release.yml because npm trusted publishing + # only allows one workflow file to be registered as a trusted publisher + instant-release: + name: Instant Release + # Publishing must wait for both quality gates and require explicit success. + needs: [lint, test] + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.lint.result == 'success' && + needs.test.result == 'success' + runs-on: ubuntu-latest + # Same publish envelope as the automated release path. + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + outputs: + published: ${{ steps.publish.outputs.published }} + published_version: ${{ steps.publish.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - name: Remove deprecated npm auth config + run: node scripts/sanitize-npm-userconfig.mjs + + - name: Install dependencies + run: npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Version packages and commit to main + id: version + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + RELEASE_DESCRIPTION: ${{ github.event.inputs.description }} + run: node scripts/version-and-commit.mjs --mode instant --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION" + + - name: Publish to npm + # Run if version was committed OR if a previous attempt already committed (for re-runs) + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish + # Optional NPM_TOKEN bootstrap fallback; OIDC trusted publishing is used + # when the secret is unset. See the release job above for details (#77). + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/publish-to-npm.mjs + + - name: Smoke-test published npm package + if: steps.publish.outputs.published == 'true' + run: node scripts/smoke-test-package.mjs --package-version "${{ steps.publish.outputs.published_version }}" + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Optional Docker Hub publishing for packages that also ship Docker images. + # Set vars.DOCKERHUB_IMAGE to enable this path, then configure + # vars.DOCKERHUB_USERNAME and secrets.DOCKERHUB_TOKEN. + docker-publish-config: + name: Configure Docker Hub Publish + needs: [release, instant-release] + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + if: | + !cancelled() && + ( + (needs.release.result == 'success' && needs.release.outputs.published == 'true') || + (needs.instant-release.result == 'success' && needs.instant-release.outputs.published == 'true') + ) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + context: ${{ steps.docker_config.outputs.context }} + dockerfile: ${{ steps.docker_config.outputs.dockerfile }} + enabled: ${{ steps.docker_config.outputs.enabled }} + image: ${{ steps.docker_config.outputs.image }} + version: ${{ steps.release_version.outputs.version }} + env: + DOCKER_CONTEXT: ${{ vars.DOCKER_CONTEXT }} + DOCKERFILE: ${{ vars.DOCKERFILE }} + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} + RELEASE_VERSION: ${{ needs.release.outputs.published_version || needs.instant-release.outputs.published_version }} + steps: + - uses: actions/checkout@v6 + + - name: Check Docker publish configuration + id: docker_config + run: node scripts/check-docker-publish.mjs + + - name: Export release version + id: release_version + env: + VERSION: ${{ env.RELEASE_VERSION }} + run: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Wait for npm package availability before Docker publish + if: steps.docker_config.outputs.enabled == 'true' + run: node scripts/wait-for-npm.mjs --release-version "${{ env.RELEASE_VERSION }}" + + # Build each architecture on a native runner. Each build is pushed by digest; + # docker-publish combines those immutable digests into the release tags. + docker-publish-build: + name: Build Docker Image (${{ matrix.platform }}) + needs: [docker-publish-config] + if: needs.docker-publish-config.outputs.enabled == 'true' + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Build and push platform image by digest + id: build + uses: ./.github/actions/publish-dockerhub + with: + context: ${{ needs.docker-publish-config.outputs.context }} + file: ${{ needs.docker-publish-config.outputs.dockerfile }} + image: ${{ needs.docker-publish-config.outputs.image }} + platform: ${{ matrix.platform }} + token: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ vars.DOCKERHUB_USERNAME }} + version: ${{ needs.docker-publish-config.outputs.version }} + + - name: Export image digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload image digest + uses: actions/upload-artifact@v7 + with: + name: docker-digest-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-publish: + name: Optional Docker Hub Publish + needs: [docker-publish-config, docker-publish-build] + if: needs.docker-publish-build.result == 'success' + timeout-minutes: 30 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx (resilient) + uses: ./.github/actions/setup-buildx-resilient + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Download image digests + uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - name: Create multi-architecture manifest + working-directory: /tmp/digests + env: + IMAGE: ${{ needs.docker-publish-config.outputs.image }} + VERSION: ${{ needs.docker-publish-config.outputs.version }} + run: | + docker buildx imagetools create \ + --tag "${IMAGE}:latest" \ + --tag "${IMAGE}:${VERSION}" \ + $(printf "${IMAGE}@sha256:%s " *) + + # Manual Changeset PR - creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + # PR creation does not publish, but it must still pass the fast lint gate. + needs: [lint] + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'changeset-pr' && + needs.lint.result == 'success' + runs-on: ubuntu-latest + # PR creation only: install, create a changeset, format, and open a PR. + timeout-minutes: 10 + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Install dependencies + run: npm install + + - name: Create changeset file + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + RELEASE_DESCRIPTION: ${{ github.event.inputs.description }} + run: node scripts/create-manual-changeset.mjs --bump-type "$BUMP_TYPE" --description "$RELEASE_DESCRIPTION" + + - name: Format changeset with Prettier + run: | + # Run Prettier on the changeset file to ensure it matches project style + npx prettier --write ".changeset/*.md" || true + + echo "Formatted changeset files" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will create a version PR + 4. Merge the version PR to publish to npm and create a GitHub release + + # GitHub reports jobs killed by timeout-minutes as cancelled rather than + # failed. Observe every job so those cancellations become visible failures + # on main, where concurrency never supersedes an in-progress run. + pipeline-status: + name: Pipeline Status + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-pipeline-status + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: always() + needs: + - detect-changes + - test-compilation + - check-file-line-limits + - version-check + - changeset-check + - lint + - test + - docker-build + - docker-publish-config + - docker-publish-build + - validate-docs + - release + - instant-release + - docker-publish + - changeset-pr + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - name: Fail the run when a required job was cancelled or failed + env: + NEEDS_JSON: ${{ toJSON(needs) }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + run: bash scripts/check-pipeline-status.sh diff --git a/dev/log/issues/96/pulls/97/templates/js/security.yml b/dev/log/issues/96/pulls/97/templates/js/security.yml new file mode 100644 index 0000000..6c0e421 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/js/security.yml @@ -0,0 +1,93 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [javascript-typescript, actions] + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Analyze + uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + npm-audit: + name: Audit npm lock (${{ matrix.directory }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-npm-audit-${{ matrix.directory }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + directory: ['.', examples/universal-app] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Audit current lock + working-directory: ${{ matrix.directory }} + run: npm audit --package-lock-only --audit-level=high diff --git a/dev/log/issues/96/pulls/97/templates/rust-workflows.txt b/dev/log/issues/96/pulls/97/templates/rust-workflows.txt new file mode 100644 index 0000000..806c40a --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust-workflows.txt @@ -0,0 +1,4 @@ +desktop-release.yml +links.yml +release.yml +security.yml diff --git a/dev/log/issues/96/pulls/97/templates/rust/desktop-release.yml b/dev/log/issues/96/pulls/97/templates/rust/desktop-release.yml new file mode 100644 index 0000000..f888ceb --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/desktop-release.yml @@ -0,0 +1,171 @@ +name: Desktop Release + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_run: + workflows: [CI/CD Pipeline] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Release tag (defaults to the latest published release) + required: false + type: string + +permissions: + contents: read + +concurrency: + group: desktop-release-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + resolve: + name: Resolve release + if: ${{ vars.DESKTOP_RELEASE_ENABLED == 'true' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + tag: ${{ steps.release.outputs.tag }} + should_build: ${{ steps.release.outputs.should_build }} + steps: + - uses: actions/checkout@v6 + - name: Resolve release tag + id: release + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + REPO: ${{ github.repository }} + WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: bash scripts/desktop-release-resolve.sh + + build: + name: Build ${{ matrix.label }} + needs: resolve + if: ${{ !cancelled() && (github.event_name == 'pull_request' || needs.resolve.outputs.should_build == 'true') }} + strategy: + fail-fast: false + matrix: + include: + - { label: linux-x64, runner: ubuntu-latest } + - { label: linux-arm64, runner: ubuntu-24.04-arm } + - { label: macos-x64, runner: macos-15-intel } + - { label: macos-arm64, runner: macos-14 } + - { label: windows-x64, runner: windows-latest } + - { label: windows-arm64, runner: windows-11-arm } + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name != 'pull_request' && needs.resolve.outputs.tag || '' }} + - uses: dtolnay/rust-toolchain@stable + - name: Package desktop application + env: + DESKTOP_RELEASE_TAG: ${{ github.event_name == 'pull_request' && 'dry-run' || needs.resolve.outputs.tag }} + # electron-builder 26 requires both values for certificate-free PR builds: + CSC_FOR_PULL_REQUEST: 'true' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: bash scripts/package-desktop.sh "${{ matrix.label }}" desktop-dist + - name: Smoke test release assets + env: + OUTPUT_DIR: desktop-dist + run: | + set -euo pipefail + first_asset="$(find "$OUTPUT_DIR" -type f -size +0c -print -quit)" + [ -n "$first_asset" ] || { echo "No non-empty desktop assets were produced" >&2; exit 1; } + find "$OUTPUT_DIR" -type f -size +0c -print | sed 's/^/Verified /' + # Electron apps must retain a signature envelope. Use + # electron-builder --mac ... -c.mac.identity=- and set + # CSC_FOR_PULL_REQUEST=true on the ad-hoc path. + while IFS= read -r app; do + [ -f "$app/Contents/_CodeSignature/CodeResources" ] || { + echo "Missing macOS CodeResources signature envelope: $app" >&2; exit 1; + } + done < <(find "$OUTPUT_DIR" -type d -name '*.app' -print) + - name: Create checksum fragment + run: | + set -euo pipefail + cd desktop-dist + if command -v sha256sum >/dev/null; then + find . -type f -print0 | sort -z | xargs -0 sha256sum + else + find . -type f -exec shasum -a 256 {} + + fi | sed 's# \./# #' > "SHA256SUMS-${{ matrix.label }}.partial" + - name: Upload packaged assets + uses: actions/upload-artifact@v7 + with: + name: desktop-assets-${{ matrix.label }} + path: desktop-dist + retention-days: 7 + - name: Upload checksum fragment + uses: actions/upload-artifact@v7 + with: + name: desktop-checksums-${{ matrix.label }} + path: desktop-dist/SHA256SUMS-${{ matrix.label }}.partial + retention-days: 7 + + finalize: + name: Publish checksums and provenance + needs: [resolve, build] + if: ${{ !cancelled() && github.event_name != 'pull_request' && needs.build.result == 'success' && needs.resolve.outputs.should_build == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: write + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + pattern: desktop-assets-* + path: release-assets + - uses: actions/download-artifact@v8 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + pattern: desktop-checksums-* + path: checksums + merge-multiple: true + - name: Consolidate release metadata + env: + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + cat checksums/*.partial | sort -k2 > SHA256SUMS.txt + { + echo 'Desktop build provenance' + echo '========================' + echo 'Status : complete' + echo 'Repository : ${{ github.repository }}' + echo "Release tag: $TAG" + echo 'Targets : linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64 windows-arm64' + echo 'Attestation: verify each asset with gh attestation verify --repo ${{ github.repository }}' + echo 'Run : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + } > BUILD-PROVENANCE.txt + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: | + release-assets/**/* + SHA256SUMS.txt + - name: Upload release metadata + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + mapfile -d '' assets < <(find release-assets -type f ! -name '*.partial' -print0) + [ ${#assets[@]} -gt 0 ] || { echo 'No release assets were downloaded' >&2; exit 1; } + gh release upload "$TAG" "${assets[@]}" SHA256SUMS.txt BUILD-PROVENANCE.txt \ + --repo "${{ github.repository }}" --clobber diff --git a/dev/log/issues/96/pulls/97/templates/rust/links.yml b/dev/log/issues/96/pulls/97/templates/rust/links.yml new file mode 100644 index 0000000..4607278 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/links.yml @@ -0,0 +1,108 @@ +name: Broken Link Checker + +on: + push: + branches: + - main + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + - '.lycheeignore' + - 'scripts/check-web-archive.mjs' + - 'scripts/check-web-archive.test.mjs' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '**.html' + - '.github/workflows/links.yml' + - '.lycheeignore' + - 'scripts/check-web-archive.mjs' + - 'scripts/check-web-archive.test.mjs' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +# Provide Git config to actions/checkout itself; checkout runs git init before +# any workflow step can configure Git. +env: + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run: <1min with lychee cache. 10min prevents slow + # external hosts or Wayback Machine probes from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Test Web Archive report parser + run: node --test scripts/check-web-archive.test.mjs + + - name: Check links with lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + # Check all Markdown and HTML files + # Exclude case-studies directory - these are research documents from + # external repos with references to files and issues that don't exist + # in this repository (similar exclusion pattern as eslint.config.js) + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + './**/*.md' + './**/*.html' + # Don't fail the workflow immediately - we want to check web archive first + fail: false + # Output file for broken links report (used by check-web-archive.mjs) + output: lychee/out.md + # Write a job summary + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check broken links against Web Archive + if: steps.lychee.outputs.exit_code != 0 + id: webarchive + run: node scripts/check-web-archive.mjs + env: + LYCHEE_OUTPUT: lychee/out.md + + - name: Fail if broken links were found + if: always() && steps.lychee.outputs.exit_code != 0 + run: | + echo "::error::Broken live links were detected." + echo "" + echo "What happened:" + echo " lychee found one or more broken links in the *.md and *.html files of this repository." + echo " A Web Archive snapshot is a suggested replacement; it does not fix the broken source link." + echo "" + echo "How to fix:" + echo " 1. Review the 'Check links with lychee' step above for a full list of broken links." + echo " 2. Review the Web Archive step when it ran. For links marked with a '::notice::' annotation," + echo " a Web Archive version exists." + echo " Replace those broken links with the suggested archive.org URL." + echo " 3. For links with no archive version, either:" + echo " a. Find an updated URL that points to the same or equivalent content." + echo " b. Remove the link if the content is no longer relevant." + echo " c. Add the URL to .lycheeignore if it is a known false positive." + echo "" + echo "Report location: lychee/out.md (available as a workflow artifact if configured)." + exit 1 diff --git a/dev/log/issues/96/pulls/97/templates/rust/release.yml b/dev/log/issues/96/pulls/97/templates/rust/release.yml new file mode 100644 index 0000000..6c70feb --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/release.yml @@ -0,0 +1,1072 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changelog-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +# Least-privilege default for every job; jobs that need more escalate explicitly. +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + # Provide Git's initial branch before actions/checkout runs git init so + # Git 2.54+ does not emit the upcoming Git 3.0 default-branch hint. + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + # Harden Cargo registry index and crate downloads against transient network + # flakes on GitHub-hosted runners, including curl HTTP/2 framing failures. + CARGO_NET_RETRY: '10' + CARGO_HTTP_MULTIPLEXING: 'false' + # Support both CARGO_REGISTRY_TOKEN (cargo's native env var) and CARGO_TOKEN (for backwards compatibility) + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + # Optional: set repository variable DOCKERHUB_IMAGE to namespace/image to publish Docker Hub releases. + DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} + +# Concurrency is intentionally job-scoped. Each read-only job cancels only its +# superseded counterpart for the same non-main ref. Main checks are not +# cancelled, so a cancelled main job is always exceptional and the terminal +# status gate can safely report it as a failure. Jobs that can +# mutate repositories, registries, releases, or Pages all use one shared +# concurrency group instead. A write that has started is never cancelled, and +# GitHub retains at most one pending run for the group. +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name != 'workflow_dispatch' + outputs: + rs-changed: ${{ steps.changes.outputs.rs-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: rust-script scripts/detect-code-changes.rs + + # === CHANGELOG CHECK - only runs on PRs with code changes === + # Docs-only PRs (./docs folder, markdown files) don't require changelog fragments + changelog: + name: Changelog Fragment Check + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-changelog + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-changelog-fragment.rs + + # === VERSION CHECK - prevents manual version modification in PRs === + # This ensures versions are only modified by the automated release pipeline + version-check: + name: Version Modification Check + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check for manual version changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_BASE_REF: ${{ github.base_ref }} + run: rust-script scripts/check-version-modification.rs + + # === SECRETS SCAN === + # Language-agnostic credential scan; runs via npx so no Node dependency is committed. + secrets-scan: + name: Secrets Scan + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-secrets-scan + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + steps: + - uses: actions/checkout@v6 + + - name: Run secretlint + run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint --secretlintignore .gitignore "**/*" + + # === FRESH MERGE SIMULATION === + # Catches semantic merge conflicts: a pull request that is green in isolation but + # breaks the base branch once merged into its current tip. + fresh-merge: + name: Fresh Merge Simulation + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-fresh-merge + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-fresh-merge-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-fresh-merge- + + - name: Simulate fresh merge with base branch + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: bash scripts/simulate-fresh-merge.sh + + # === DOCKER IMAGE BUILD CHECK === + # Builds the image at pull-request stage without pushing, so a Dockerfile + # regression fails the pull request instead of producing a half-finished release + # (crate published, image missing). push: false also works for fork pull requests + # that have no registry credentials. + docker-build: + name: Docker Image Build Check + runs-on: ubuntu-latest + # The image compiles the crate from scratch; 30 minutes is not enough cold. + timeout-minutes: 60 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-docker-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + + - name: Check for Dockerfile + id: dockerfile + run: | + if [ -f Dockerfile ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "No Dockerfile at repository root; skipping image build check." + fi + + - name: Set up Docker Buildx + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/setup-buildx-action@v4 + + - name: Build image (no push) + if: steps.dockerfile.outputs.exists == 'true' + uses: docker/build-push-action@v7 + with: + context: . + push: false + load: true + tags: app:pr-check + cache-from: type=gha + cache-to: type=gha,mode=max + + # === CARGO.LOCK GUARD === + # Binary crates must commit Cargo.lock so CI and release jobs resolve the same + # dependency graph every run. This also prevents cache keys based on + # hashFiles('**/Cargo.lock') from silently degrading to the empty hash. + cargo-lock: + name: Cargo.lock Guard + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-cargo-lock + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes] + if: | + !cancelled() && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Check committed Cargo.lock for binary crates + run: rust-script scripts/check-cargo-lock.rs + + # === LINT AND FORMAT CHECK === + # Lint runs independently of changelog check - it's a fast check that should always run + # See: https://github.com/link-assistant/hive-mind/pull/1024 for why this dependency was removed + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + # Note: !cancelled() is required because detect-changes is skipped on workflow_dispatch, + # and without it, this job would also be skipped even though its condition includes workflow_dispatch. + # See: https://github.com/actions/runner/issues/491 + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' || + needs.detect-changes.outputs.any-code-changed == 'true' + ) + ) + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-lint- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run Clippy + run: cargo clippy --all-targets --all-features + + # Warning-band annotations are limited to files this pull request changed, + # so unchanged files stop repeating the same warning on every run. + # The 1000-line hard limit still applies to the whole repository. + - name: Collect changed files + id: changed-files + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + { + echo 'files</dev/null; then + git diff --name-only "$BASE_SHA" HEAD + else + # No usable base (e.g. workflow_dispatch): fall back to annotating every warning. + git ls-files + fi + echo 'CHANGED_FILES_EOF' + } >> "$GITHUB_OUTPUT" + + - name: Build documentation + # Fail-closed rustdoc gate: rustdoc-only lints (for example + # rustdoc::private_intra_doc_links) are not reported by clippy or + # `cargo test --doc`, so they must be caught here, before release, + # instead of in the post-release deploy-docs job. + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --all-features + + - name: Check file size limit + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.files }} + run: rust-script scripts/check-file-size.rs + + # === TEST === + # Test runs independently of changelog check and only for code-affecting changes + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.os }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + # Note: !cancelled() is required because detect-changes is skipped on workflow_dispatch, + # and without it, this job would also be skipped even though its condition includes workflow_dispatch. + # See: https://github.com/actions/runner/issues/491 + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.any-code-changed == 'true' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + ) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry on Windows + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-test-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test-registry- + + - name: Cache cargo registry and target on Unix + if: runner.os != 'Windows' + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test- + + - name: Run tests + run: cargo test --all-features --verbose + + - name: Run doc tests + run: cargo test --doc --verbose + + # === CODE COVERAGE === + # Generate and upload code coverage using cargo-llvm-cov + coverage: + name: Code Coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-coverage + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [detect-changes, cargo-lock] + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + if: | + !cancelled() && ( + needs.cargo-lock.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' + ) + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-coverage- + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate code coverage + run: cargo llvm-cov --all-features --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + if: env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v7 + with: + token: ${{ env.CODECOV_TOKEN }} + files: lcov.info + disable_search: true + fail_ci_if_error: true + + - name: Report skipped Codecov upload + if: env.CODECOV_TOKEN == '' + run: echo "::notice::Skipping Codecov upload because CODECOV_TOKEN is not configured" + + # === BUILD === + # Build package - only runs if lint and test pass + build: + name: Build Package + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-build + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + needs: [lint, test] + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Build release + run: cargo build --release --verbose + + - name: Check package + run: cargo package --list --allow-dirty + + - name: Check crate package size + run: rust-script scripts/check-crate-size.rs + + # === AUTO RELEASE === + # Automatic release on push to main using changelog fragments + # This job automatically bumps version based on fragments in changelog.d/ + auto-release: + name: Auto Release + needs: [lint, test, build] + outputs: + docker_enabled: ${{ steps.dockerhub.outputs.enabled }} + release_version: ${{ steps.release-metadata.outputs.version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + # Note: !cancelled() ensures consistent behavior with other jobs that depend on skipped jobs. + if: | + !cancelled() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.build.result == 'success' + runs-on: ubuntu-latest + # Covers the crate build, publish, smoke test, and a cold Docker build. + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Configure git + run: rust-script scripts/git-config.rs + + - name: Determine bump type from changelog fragments + id: bump_type + run: rust-script scripts/get-bump-type.rs + + - name: Check if version already released or no fragments + id: check + env: + HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: rust-script scripts/check-release-needed.rs + + - name: Collect changelog and bump version + id: version + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true' + run: | + rust-script scripts/version-and-commit.rs \ + --bump-type "${{ steps.bump_type.outputs.bump_type }}" + + - name: Get current version + id: current_version + if: steps.check.outputs.should_release == 'true' + run: rust-script scripts/get-version.rs + + - name: Build release + if: steps.check.outputs.should_release == 'true' + run: cargo build --release + + - name: Check crate package size + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + run: rust-script scripts/check-crate-size.rs + + - name: Publish to Crates.io + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Smoke-test published crate + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: rust-script scripts/smoke-test-published-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Configure Docker Hub publishing + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + id: dockerhub + run: | + disable_dockerhub() { + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "$1" + } + + if [ -z "$DOCKERHUB_IMAGE" ]; then + disable_dockerhub "Docker Hub publishing disabled: DOCKERHUB_IMAGE repository variable is not set" + exit 0 + fi + + if [ ! -f Dockerfile ]; then + disable_dockerhub "Docker Hub publishing disabled: Dockerfile was not found at repository root" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "docker_hub_url=https://hub.docker.com/r/${DOCKERHUB_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Resolve release metadata + id: release-metadata + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + run: | + release_version="${{ steps.version.outputs.new_version }}" + if [ -z "$release_version" ]; then + release_version="${{ steps.current_version.outputs.version }}" + fi + echo "version=$release_version" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: | + steps.check.outputs.should_release == 'true' && ( + steps.check.outputs.crate_published == 'true' || + steps.publish-crate.outputs.publish_result == 'success' + ) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_HUB_URL: ${{ steps.dockerhub.outputs.docker_hub_url }} + run: | + release_args=( + --release-version "${{ steps.release-metadata.outputs.version }}" + --repository "${{ github.repository }}" + ) + if [ -n "$DOCKER_HUB_URL" ]; then + release_args+=(--docker-hub-url "$DOCKER_HUB_URL") + fi + rust-script scripts/create-github-release.rs "${release_args[@]}" + + # === MANUAL INSTANT RELEASE === + # Manual release via workflow_dispatch - only after CI passes + manual-release: + name: Instant Release + needs: [lint, test, build] + outputs: + docker_enabled: ${{ steps.dockerhub.outputs.enabled }} + release_version: ${{ steps.version.outputs.new_version }} + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + # Note: !cancelled() is required to evaluate the condition when dependencies may be skipped. + # The build job ensures lint and test passed before this job runs. + if: | + !cancelled() && + github.event_name == 'workflow_dispatch' && + github.event.inputs.release_mode == 'instant' && + needs.build.result == 'success' + runs-on: ubuntu-latest + # Covers the crate build, publish, smoke test, and a cold Docker build. + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Configure git + run: rust-script scripts/git-config.rs + + - name: Collect changelog fragments + run: rust-script scripts/collect-changelog.rs + + - name: Version and commit + id: version + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/version-and-commit.rs --bump-type "$BUMP_TYPE" --description "$DESCRIPTION" + + - name: Build release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: cargo build --release + + - name: Check crate package size + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: rust-script scripts/check-crate-size.rs + + - name: Publish to Crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish-crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + run: rust-script scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + run: rust-script scripts/wait-for-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Smoke-test published crate + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + run: rust-script scripts/smoke-test-published-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Configure Docker Hub publishing + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + id: dockerhub + run: | + disable_dockerhub() { + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "$1" + } + + if [ -z "$DOCKERHUB_IMAGE" ]; then + disable_dockerhub "Docker Hub publishing disabled: DOCKERHUB_IMAGE repository variable is not set" + exit 0 + fi + + if [ ! -f Dockerfile ]; then + disable_dockerhub "Docker Hub publishing disabled: Dockerfile was not found at repository root" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "docker_hub_url=https://hub.docker.com/r/${DOCKERHUB_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: | + (steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') && + steps.publish-crate.outputs.publish_result == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_HUB_URL: ${{ steps.dockerhub.outputs.docker_hub_url }} + run: | + release_args=( + --release-version "${{ steps.version.outputs.new_version }}" + --repository "${{ github.repository }}" + ) + if [ -n "$DOCKER_HUB_URL" ]; then + release_args+=(--docker-hub-url "$DOCKER_HUB_URL") + fi + rust-script scripts/create-github-release.rs "${release_args[@]}" + + # Build each architecture on a native runner. Publishing by digest lets both + # builds run in parallel without racing to update a shared image tag. + docker-publish: + name: Publish Docker / ${{ matrix.platform }} + needs: [auto-release, manual-release] + if: | + !cancelled() && + (needs.auto-release.result == 'success' || needs.manual-release.result == 'success') && + (needs.auto-release.outputs.docker_enabled == 'true' || needs.manual-release.outputs.docker_enabled == 'true') + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.auto-release.outputs.release_version || needs.manual-release.outputs.release_version }} + steps: + - uses: actions/checkout@v6 + with: + ref: v${{ env.RELEASE_VERSION }} + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: ./.github/actions/setup-buildx-resilient + + - name: Build and push architecture digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + labels: org.opencontainers.image.version=${{ env.RELEASE_VERSION }} + outputs: type=image,name=${{ env.DOCKERHUB_IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v6 + with: + name: docker-digest-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + docker-merge-manifest: + name: Publish Docker manifest + needs: [auto-release, manual-release, docker-publish] + if: | + !cancelled() && + needs.docker-publish.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME || secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.auto-release.outputs.release_version || needs.manual-release.outputs.release_version }} + steps: + - name: Download digests + uses: actions/download-artifact@v7 + with: + path: /tmp/digests + pattern: docker-digest-* + merge-multiple: true + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Create multi-architecture manifest + working-directory: /tmp/digests + run: | + mapfile -t digests < <(printf '${DOCKERHUB_IMAGE}@sha256:%s\n' *) + docker buildx imagetools create \ + --tag "${DOCKERHUB_IMAGE}:latest" \ + --tag "${DOCKERHUB_IMAGE}:${RELEASE_VERSION}" \ + "${digests[@]}" + + - name: Verify manifest platforms + run: | + manifest="$(docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${RELEASE_VERSION}")" + grep -F 'linux/amd64' <<< "$manifest" + grep -F 'linux/arm64' <<< "$manifest" + + # === MANUAL CHANGELOG PR === + changelog-pr: + name: Create Changelog PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: ./scripts/install-rust-script.sh + + - name: Create changelog fragment + env: + BUMP_TYPE: ${{ github.event.inputs.bump_type }} + DESCRIPTION: ${{ github.event.inputs.description }} + run: rust-script scripts/create-changelog-fragment.rs --bump-type "$BUMP_TYPE" --description "$DESCRIPTION" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release' + branch: changelog-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changelog fragment in this PR + 2. Merge this PR to main + 3. The automated release workflow will publish to crates.io and create a GitHub release + + # === DEPLOY DOCUMENTATION === + # Deploy Rust API documentation to GitHub Pages after a successful package build. + # Keep this independent from package/GitHub release publication so the website + # still updates when the release path fails. Use the official Pages artifact + # deployment path so repositories configured with "GitHub Actions" as their + # Pages source fail this job if Pages cannot deploy. + # + # One-time setup: in the repository's Settings -> Pages, set Source to + # "GitHub Actions". Without this, the first run fails on actions/deploy-pages + # with "Get Pages site failed" / "Failed to create deployment". This cannot be + # configured from a workflow. See README.md "Deploying API documentation". + deploy-docs: + name: Deploy Rust Documentation + needs: [build] + concurrency: + group: ${{ github.workflow }}-main-write + cancel-in-progress: false + if: | + !cancelled() && + needs.build.result == 'success' && ( + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant') + ) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v6 + with: + ref: main + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build documentation + # Defense in depth: same fail-closed flags as the lint job's gate. + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --all-features + + - name: Generate Pages root index + run: | + # rustdoc emits target/doc//index.html but no root index.html, + # which makes the GitHub Pages root URL return 404. Redirect the root + # to the crate docs directory that rustdoc generated. + crate=$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["name"].replace("-","_"))') + printf '\n' "$crate" \ + > target/doc/index.html + touch target/doc/.nojekyll + + - name: Add desktop download page + run: | + mkdir -p target/doc/download + sed 's#__REPOSITORY__#${{ github.repository }}#g' \ + docs/download/index.html > target/doc/download/index.html + + - name: Configure GitHub Pages + uses: actions/configure-pages@v6 + + - name: Verify site tree + run: find target/doc -maxdepth 2 -print + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: target/doc + include-hidden-files: true + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + # === PIPELINE STATUS === + # GitHub reports jobs killed by timeout-minutes as cancelled. Observe every + # job so a timeout on main becomes a visible failure instead of a grey run. + pipeline-status: + name: Pipeline Status + needs: + - detect-changes + - changelog + - version-check + - secrets-scan + - fresh-merge + - docker-build + - cargo-lock + - lint + - test + - coverage + - build + - auto-release + - manual-release + - docker-publish + - docker-merge-manifest + - changelog-pr + - deploy-docs + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - name: Check aggregate pipeline status + env: + NEEDS_JSON: ${{ toJSON(needs) }} + IS_MAIN: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + run: bash scripts/check-pipeline-status.sh diff --git a/dev/log/issues/96/pulls/97/templates/rust/security.yml b/dev/log/issues/96/pulls/97/templates/rust/security.yml new file mode 100644 index 0000000..ab7cd08 --- /dev/null +++ b/dev/log/issues/96/pulls/97/templates/rust/security.yml @@ -0,0 +1,74 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + +# Keep the workflow read-only unless a job explicitly needs an additional scope. +permissions: + contents: read + +jobs: + cargo-audit: + name: Cargo audit + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-cargo-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v6 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit@0.22.2 + + - name: Audit committed Cargo.lock + run: cargo audit --file Cargo.lock + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [rust, actions] + steps: + - uses: actions/checkout@v6 + + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - uses: github/codeql-action/autobuild@v4 + + - uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/dev/log/issues/96/pulls/97/workflows/repo/csharp.yml b/dev/log/issues/96/pulls/97/workflows/repo/csharp.yml new file mode 100644 index 0000000..f90ae2d --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/csharp.yml @@ -0,0 +1,519 @@ +name: C# CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'csharp/**' + - 'csharp/scripts/**' + - '.github/workflows/csharp.yml' + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: csharp-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +defaults: + run: + working-directory: csharp + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + cs-changed: ${{ steps.changes.outputs.cs-changed }} + csproj-changed: ${{ steps.changes.outputs.csproj-changed }} + sln-changed: ${{ steps.changes.outputs.sln-changed }} + props-changed: ${{ steps.changes.outputs.props-changed }} + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + csharp-code-changed: ${{ steps.changes.outputs.csharp-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Detect changes + id: changes + working-directory: . + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node csharp/scripts/detect-code-changes.mjs + + # === CHANGESET CHECK - only runs on PRs with code changes === + changeset-check: + name: Changeset Validation + runs-on: ubuntu-latest + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.csharp-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Validate changeset + working-directory: . + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Skip changeset check for automated release PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-manual-release-"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Check if changeset exists in csharp/.changeset + CHANGESET_COUNT=$(find csharp/.changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + if [ "$CHANGESET_COUNT" -eq 0 ]; then + echo "::warning::No changeset found in csharp/.changeset/. Please add a changeset for C# code changes." + else + echo "Found $CHANGESET_COUNT changeset file(s)" + fi + + # === LINT AND FORMAT CHECK === + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.cs-changed == 'true' || + needs.detect-changes.outputs.csproj-changed == 'true' || + needs.detect-changes.outputs.sln-changed == 'true' || + needs.detect-changes.outputs.props-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Run release script tests + working-directory: . + run: node --test csharp/scripts/*.test.mjs + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + # === TEST ON MULTIPLE OS === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [detect-changes, changeset-check] + if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Run tests + # Windows has pre-existing file locking issues with some tests + continue-on-error: ${{ matrix.os == 'windows-latest' }} + run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: false + + # === BUILD PACKAGE === + build: + name: Build Package + runs-on: ubuntu-latest + needs: [lint, test] + if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build Release + run: dotnet build --no-restore --configuration Release + + - name: Pack NuGet package + run: dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: csharp/artifacts/*.nupkg + + # === AUTOMATIC RELEASE === + release: + name: Release + needs: [lint, test, build] + if: always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Check for changesets + id: check_changesets + working-directory: . + run: | + # Count changeset files (excluding README.md and config.json) + CHANGESET_COUNT=$(find csharp/.changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "changeset_count=$CHANGESET_COUNT" >> $GITHUB_OUTPUT + + - name: Check if release is needed + # Self-healing gate: even when a changeset is absent, resume publishing + # if the csproj is missing on NuGet or its GitHub release does + # not exist. See docs/case-studies/issue-84/README.md and the JS + # template's check-release-needed.mjs for the same pattern. + id: check_release + working-directory: . + env: + HAS_CHANGESETS: ${{ steps.check_changesets.outputs.has_changesets }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node csharp/scripts/check-release-needed.mjs + + - name: Merge multiple changesets + if: steps.check_changesets.outputs.has_changesets == 'true' && steps.check_changesets.outputs.changeset_count > 1 + working-directory: . + run: | + echo "Multiple changesets detected, merging..." + node csharp/scripts/merge-changesets.mjs \ + --dir csharp/.changeset \ + --package-name Foundation.Data.Doublets.Cli + + - name: Version and commit + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + working-directory: . + run: node csharp/scripts/version-and-commit.mjs --mode changeset + + - name: Resolve release version + # Picks the version that downstream steps should publish. Prefers the + # one just committed; falls back to the csproj reported by + # check-release-needed for self-healing re-runs. + id: release_version + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + run: | + if [ -n "${{ steps.version.outputs.new_version }}" ]; then + VERSION="${{ steps.version.outputs.new_version }}" + else + VERSION="${{ steps.check_release.outputs.current_version }}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing version: $VERSION" + + - name: Build release package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package ids + id: package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + run: | + CLI_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj | head -n 1) + if [ -z "$CLI_ID" ]; then + CLI_ID=clink + fi + LIB_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli.Library/Foundation.Data.Doublets.Cli.Library.csproj | head -n 1) + if [ -z "$LIB_ID" ]; then + LIB_ID=Foundation.Data.Doublets.Cli + fi + echo "id=$CLI_ID" >> "$GITHUB_OUTPUT" + echo "library_id=$LIB_ID" >> "$GITHUB_OUTPUT" + + - name: Validate NuGet API key + # Upfront validation surfaces an expired/invalid NUGET_API_KEY before + # we attempt a push that would otherwise return HTTP 403 mid-flight. + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::warning::NUGET_API_KEY is not configured — NuGet publish will be skipped." + exit 0 + fi + echo "NUGET_API_KEY length: ${#NUGET_API_KEY}" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify CLI package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true')) + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Verify library package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true')) + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --release-version "${{ steps.release_version.outputs.version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' || + (steps.check_release.outputs.should_release == 'true' && steps.check_release.outputs.skip_bump == 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: . + run: | + node csharp/scripts/create-github-release.mjs \ + --release-version "${{ steps.release_version.outputs.version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "csharp-v" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --changelog-path "csharp/CHANGELOG.md" \ + --assets-glob "csharp/artifacts/*.nupkg" + + # === MANUAL INSTANT RELEASE === + instant-release: + name: Instant Release + needs: [lint, test, build] + if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + + - name: Version and commit + id: version + working-directory: . + run: | + node csharp/scripts/version-and-commit.mjs \ + --mode instant \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" + + - name: Build package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + run: | + dotnet restore + dotnet build --configuration Release + dotnet pack --no-build --configuration Release --output ./artifacts + + - name: Resolve NuGet package ids + id: package + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + run: | + CLI_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj | head -n 1) + if [ -z "$CLI_ID" ]; then + CLI_ID=clink + fi + LIB_ID=$(sed -n 's:.*\(.*\).*:\1:p' Foundation.Data.Doublets.Cli.Library/Foundation.Data.Doublets.Cli.Library.csproj | head -n 1) + if [ -z "$LIB_ID" ]; then + LIB_ID=Foundation.Data.Doublets.Cli + fi + echo "id=$CLI_ID" >> "$GITHUB_OUTPUT" + echo "library_id=$LIB_ID" >> "$GITHUB_OUTPUT" + + - name: Publish to NuGet + id: nuget_publish + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -n "$NUGET_API_KEY" ]; then + dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "NUGET_API_KEY not set, skipping NuGet publish" + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify CLI package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Verify library package on NuGet + if: >- + steps.nuget_publish.outputs.published == 'true' && ( + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true') + run: | + node scripts/wait-for-nuget.mjs \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --release-version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + if: >- + steps.version.outputs.version_committed == 'true' || + steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: . + run: | + node csharp/scripts/create-github-release.mjs \ + --release-version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "csharp-v" \ + --language "C#" \ + --package-id "${{ steps.package.outputs.id }}" \ + --package-id "${{ steps.package.outputs.library_id }}" \ + --changelog-path "csharp/CHANGELOG.md" \ + --assets-glob "csharp/artifacts/*.nupkg" diff --git a/dev/log/issues/96/pulls/97/workflows/repo/docs.yml b/dev/log/issues/96/pulls/97/workflows/repo/docs.yml new file mode 100644 index 0000000..de17de0 --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/docs.yml @@ -0,0 +1,159 @@ +name: Docs + +# Build and deploy a unified API reference site that hosts both the C# +# (`Foundation.Data.Doublets.Cli`) and Rust (`link-cli`) library docs. +# +# GitHub Pages allows only one deployment per repository, so this workflow +# combines DocFX-generated C# docs (under `/csharp/`) and `cargo doc`-generated +# Rust docs (under `/rust/`) into a single site that also includes a small +# landing page linking out to both. The deploy job runs on pushes to `main` +# and on manual dispatch, mirroring the AI driven development pipeline +# templates' approach (see docs/case-studies/issue-92/templates). + +on: + push: + branches: [main] + paths: + - 'csharp/Foundation.Data.Doublets.Cli.Library/**' + - 'csharp/docs/**' + - 'csharp/docfx.json' + - 'rust/src/**' + - 'rust/Cargo.toml' + - '.github/workflows/docs.yml' + pull_request: + branches: [main] + paths: + - 'csharp/Foundation.Data.Doublets.Cli.Library/**' + - 'csharp/docs/**' + - 'csharp/docfx.json' + - 'rust/src/**' + - 'rust/Cargo.toml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.0.x' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-docs-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-docs- + + - name: Install DocFX + run: dotnet tool update -g docfx + + - name: Restore C# dependencies + working-directory: csharp + run: dotnet restore + + - name: Build C# documentation + working-directory: csharp + run: docfx docfx.json -o _site + + - name: Build Rust documentation + run: cargo doc --manifest-path rust/Cargo.toml --no-deps --all-features + + - name: Assemble unified site + run: | + set -euo pipefail + mkdir -p _site/csharp _site/rust + cp -R csharp/_site/. _site/csharp/ + cp -R rust/target/doc/. _site/rust/ + # Landing page that links into both sub-sites. + cat > _site/index.html <<'HTML' + + + + + link-cli API documentation + + + + +

link-cli API documentation

+

Generated reference for the C# and Rust library packages that + ship alongside the clink CLI.

+ +

Source: github.com/link-foundation/link-cli

+ + + HTML + + - name: List unified site (debug) + run: | + echo "::group::_site tree" + find _site -maxdepth 3 -print + echo "::endgroup::" + + - name: Configure GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + uses: actions/upload-pages-artifact@v5 + with: + path: _site + + deploy: + name: Deploy to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + - name: Print resolved deployment URL (debug) + run: | + echo "Pages deployed to: ${{ steps.deployment.outputs.page_url }}" diff --git a/dev/log/issues/96/pulls/97/workflows/repo/rust.yml b/dev/log/issues/96/pulls/97/workflows/repo/rust.yml new file mode 100644 index 0000000..26d24bf --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/rust.yml @@ -0,0 +1,356 @@ +name: Rust CI/CD Pipeline + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'rust/**' + - 'rust/scripts/**' + - '.github/workflows/rust.yml' + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +concurrency: + group: rust-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + +jobs: + # === DETECT CHANGES - determines which jobs should run === + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + rs-changed: ${{ steps.changes.outputs.rs-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + rust-code-changed: ${{ steps.changes.outputs.rust-code-changed }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Detect changes + id: changes + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: rust-script rust/scripts/detect-code-changes.rs + + # === CHANGELOG CHECK - only runs on PRs with code changes === + changelog: + name: Changelog Fragment Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.rust-code-changed == 'true' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Check for changelog fragments + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + RUST_ROOT: rust + run: rust-script rust/scripts/check-changelog-fragment.rs + + # === LINT AND FORMAT CHECK === + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + needs: [detect-changes] + if: | + always() && !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install rust-script + run: cargo install rust-script + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --manifest-path rust/Cargo.toml --all -- --check + + - name: Run Clippy + run: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features + + - name: Check file size limit + run: rust-script rust/scripts/check-file-size.rs + + # === TEST === + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [detect-changes, changelog] + if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run tests + run: cargo test --manifest-path rust/Cargo.toml --all-features --verbose + + - name: Run doc tests + run: cargo test --manifest-path rust/Cargo.toml --doc --verbose + + # === BUILD === + build: + name: Build Package + runs-on: ubuntu-latest + needs: [lint, test] + if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Build release + run: cargo build --manifest-path rust/Cargo.toml --release --verbose + + - name: Check package + run: cargo package --manifest-path rust/Cargo.toml --list --allow-dirty + + # === AUTO RELEASE === + auto-release: + name: Auto Release + needs: [lint, test, build] + if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Configure git + run: rust-script rust/scripts/git-config.rs + + - name: Determine bump type from changelog fragments + id: bump_type + env: + RUST_ROOT: rust + run: rust-script rust/scripts/get-bump-type.rs + + - name: Check if version already released or no fragments + id: check + env: + HAS_FRAGMENTS: ${{ steps.bump_type.outputs.has_fragments }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUST_ROOT: rust + run: | + rust-script rust/scripts/check-release-needed.rs \ + --tag-prefix rust-v \ + --repository "${{ github.repository }}" + + - name: Collect changelog and bump version + id: version + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.skip_bump != 'true' + working-directory: . + run: | + rust-script rust/scripts/version-and-commit.rs \ + --rust-root rust \ + --tag-prefix rust-v \ + --bump-type "${{ steps.bump_type.outputs.bump_type }}" + + - name: Get current version + id: current_version + if: steps.check.outputs.should_release == 'true' + env: + RUST_ROOT: rust + run: rust-script rust/scripts/get-version.rs + + - name: Build release + if: steps.check.outputs.should_release == 'true' + run: cargo build --manifest-path rust/Cargo.toml --release + + - name: Publish to Crates.io + id: publish_crate + if: steps.check.outputs.should_release == 'true' && steps.check.outputs.crate_published != 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + RUST_ROOT: rust + run: rust-script rust/scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: steps.check.outputs.should_release == 'true' + env: + RUST_ROOT: rust + run: rust-script rust/scripts/wait-for-crate.rs --release-version "${{ steps.current_version.outputs.version }}" + + - name: Create GitHub Release + if: steps.check.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RELEASE_VERSION="${{ steps.version.outputs.new_version }}" + if [ -z "$RELEASE_VERSION" ]; then + RELEASE_VERSION="${{ steps.current_version.outputs.version }}" + fi + + rust-script rust/scripts/create-github-release.rs \ + --rust-root rust \ + --release-version "$RELEASE_VERSION" \ + --repository "${{ github.repository }}" \ + --tag-prefix "rust-v" \ + --language "Rust" + + # === MANUAL RELEASE === + manual-release: + name: Manual Release + needs: [lint, test, build] + if: always() && !cancelled() && github.event_name == 'workflow_dispatch' && needs.lint.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install rust-script + run: cargo install rust-script + + - name: Configure git + run: rust-script rust/scripts/git-config.rs + + - name: Version and commit + id: version + run: | + rust-script rust/scripts/version-and-commit.rs \ + --rust-root rust \ + --tag-prefix rust-v \ + --bump-type "${{ github.event.inputs.bump_type }}" \ + --description "${{ github.event.inputs.description }}" + + - name: Build release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + run: cargo build --manifest-path rust/Cargo.toml --release + + - name: Publish to Crates.io + id: publish_crate + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }} + CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} + RUST_ROOT: rust + run: rust-script rust/scripts/publish-crate.rs + + - name: Wait for Crate availability on Crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + RUST_ROOT: rust + run: rust-script rust/scripts/wait-for-crate.rs --release-version "${{ steps.version.outputs.new_version }}" + + - name: Create GitHub Release + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + rust-script rust/scripts/create-github-release.rs \ + --rust-root rust \ + --release-version "${{ steps.version.outputs.new_version }}" \ + --repository "${{ github.repository }}" \ + --tag-prefix "rust-v" \ + --language "Rust" diff --git a/dev/log/issues/96/pulls/97/workflows/repo/wasm.yml b/dev/log/issues/96/pulls/97/workflows/repo/wasm.yml new file mode 100644 index 0000000..663685b --- /dev/null +++ b/dev/log/issues/96/pulls/97/workflows/repo/wasm.yml @@ -0,0 +1,160 @@ +name: WebAssembly CI + +on: + push: + branches: + - main + - issue-* + paths: + - '.github/workflows/wasm.yml' + - 'js/**' + - 'rust/**' + pull_request: + branches: + - main + paths: + - '.github/workflows/wasm.yml' + - 'js/**' + - 'rust/**' + workflow_dispatch: + +concurrency: + group: wasm-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: npm + cache-dependency-path: js/package-lock.json + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + rust/wasm/target + key: ${{ runner.os }}-wasm-cargo-${{ hashFiles('rust/wasm/Cargo.lock', 'rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-wasm-cargo- + + - name: Install npm dependencies + working-directory: js + run: npm ci + + - name: Test Rust CLI core + run: cargo test --manifest-path rust/Cargo.toml --all-features + + - name: Test WebAssembly wrapper + working-directory: js + run: npm run test:wasm + + - name: Build React WebAssembly app + working-directory: js + run: npm run build + + - name: Upload built app + uses: actions/upload-artifact@v7 + with: + name: link-cli-web + path: dist/ + + build-pages: + name: Build GitHub Pages app + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: npm + cache-dependency-path: js/package-lock.json + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + rust/wasm/target + key: ${{ runner.os }}-wasm-pages-cargo-${{ hashFiles('rust/wasm/Cargo.lock', 'rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-wasm-pages-cargo- + ${{ runner.os }}-wasm-cargo- + + - name: Install npm dependencies + working-directory: js + run: npm ci + + - name: Configure Pages + uses: actions/configure-pages@v6 + + - name: Build GitHub Pages app + working-directory: js + run: npm run build:pages + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: dist/ + + deploy-pages: + name: Deploy GitHub Pages + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' + needs: build-pages + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v5 diff --git a/js/package-lock.json b/js/package-lock.json index e40a476..45df73b 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -19,63 +19,10 @@ "vite-plugin-wasm": "^3.6.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -83,9 +30,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -100,9 +47,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -117,9 +64,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -134,9 +81,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -151,9 +98,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -168,9 +115,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], @@ -188,9 +135,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], @@ -208,9 +155,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], @@ -228,9 +175,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -248,9 +195,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -268,9 +215,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -288,9 +235,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -304,29 +251,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -341,9 +269,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -364,17 +292,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -451,9 +368,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -467,23 +384,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -502,9 +419,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -523,9 +440,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -544,9 +461,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -565,9 +482,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -586,9 +503,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -610,9 +527,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -634,9 +551,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -658,9 +575,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -682,9 +599,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -703,9 +620,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -733,9 +650,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -759,9 +676,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -772,9 +689,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -792,7 +709,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -822,14 +739,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -838,27 +755,26 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -879,9 +795,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -895,26 +811,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -930,7 +838,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/js/package.json b/js/package.json index 381edd9..e2c8439 100644 --- a/js/package.json +++ b/js/package.json @@ -12,7 +12,7 @@ "dev": "npm run build:wasm && vite --config vite.config.js --host 0.0.0.0", "preview": "vite preview --config vite.config.js --host 0.0.0.0", "test": "npm run test:wasm && npm run test:js && npm run build", - "test:js": "node --test test/*.test.mjs ../csharp/scripts/*.test.mjs", + "test:js": "node --test test/*.test.mjs ../csharp/scripts/*.test.mjs ../.github/scripts/*.test.mjs", "test:wasm": "wasm-pack test --node ../rust/wasm", "clean": "rm -rf ../dist pkg ../pkg ../pkg-node ../pkg-bundler ../target ../rust/wasm/target" }, diff --git a/js/test/repositoryLayout.test.mjs b/js/test/repositoryLayout.test.mjs index 41ce9eb..853a922 100644 --- a/js/test/repositoryLayout.test.mjs +++ b/js/test/repositoryLayout.test.mjs @@ -96,8 +96,14 @@ test('WebAssembly workflow uses the JavaScript package lockfile from js', () => assert.doesNotMatch(workflow, /(^|\s)- 'web\/\*\*'/); }); -test('WebAssembly workflow deploys GitHub Pages automatically on push to main', () => { - const workflow = readFileSync(join(repoRoot, '.github/workflows/wasm.yml'), 'utf8'); +test('the documentation workflow deploys GitHub Pages automatically on push to main', () => { + // GitHub Pages serves a single site per repository. wasm.yml used to deploy + // the workbench on its own while docs.yml deployed the API references, so + // whichever ran last replaced the other one's files and the documentation + // URLs in README.md answered 404. docs.yml is now the only publisher and + // assembles the workbench and both API references into one artifact + // (issue #96). + const workflow = readFileSync(join(repoRoot, '.github/workflows/docs.yml'), 'utf8'); assert.match(workflow, /name: Deploy GitHub Pages/); assert.match(workflow, /uses: actions\/deploy-pages@/); @@ -109,7 +115,19 @@ test('WebAssembly workflow deploys GitHub Pages automatically on push to main', workflow, /github\.event_name == 'push'[\s\S]*?github\.ref == 'refs\/heads\/main'/ ); - assert.doesNotMatch(workflow, /inputs\.deploy_pages/); + assert.match( + workflow, + /npm run build:pages/, + 'docs.yml must build the WebAssembly workbench it publishes at the site root' + ); + + const wasmWorkflow = readFileSync(join(repoRoot, '.github/workflows/wasm.yml'), 'utf8'); + assert.doesNotMatch( + wasmWorkflow, + /uses: actions\/(deploy-pages|upload-pages-artifact)@/, + 'a second Pages artifact replaces the published site' + ); + assert.doesNotMatch(wasmWorkflow, /inputs\.deploy_pages/); }); test('CSharp release workflow attaches NuGet packages to GitHub Releases', () => { diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4009841..3a8e8c2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -60,9 +60,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "autocfg" @@ -358,9 +358,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] diff --git a/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md b/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md new file mode 100644 index 0000000..a4f2578 --- /dev/null +++ b/rust/changelog.d/20260818_120000_issue_96_cicd_hardening.md @@ -0,0 +1,17 @@ +--- +bump: patch +--- + +Hardened the Rust pipeline (issue #96). Every job now declares a +timeout and a least-privilege token, release jobs run in a +non-cancellable writer concurrency group, the advertised +`changelog-pr` release mode is implemented, and the lint job fails on +`Cargo.lock` drift. `anyhow` and `memmap2` were refreshed in both +lockfiles to clear the outstanding RUSTSEC advisories. +`cargo clippy` now runs with `-D warnings` and also covers the `rust/wasm` +workspace, which had never been formatted or linted, and pull requests +re-run the fast checks on a simulated merge with the tip of `main` so a +semantic merge conflict fails the pull request instead of `main`. The +pattern-matching helpers moved from `query_processor.rs` into +`query_processor/matching.rs` to clear the file-size warning; no public API +changed. diff --git a/rust/src/query_processor.rs b/rust/src/query_processor.rs index 1e46706..0b62b88 100644 --- a/rust/src/query_processor.rs +++ b/rust/src/query_processor.rs @@ -15,6 +15,9 @@ use crate::named_type_links::NamedTypeLinks; use crate::parser::Parser; use crate::query_types::{Pattern, ResolvedLink}; +// Pattern matching lives in a submodule; see query_processor/matching.rs. +mod matching; + /// QueryProcessor handles LiNo query parsing and execution /// Corresponds to AdvancedMixedQueryProcessor in C# pub struct QueryProcessor { @@ -310,209 +313,6 @@ impl QueryProcessor { .all(|(key, value)| existing.get(key).is_none_or(|existing| existing == value)) } - fn match_pattern( - &self, - storage: &mut impl NamedTypeLinks, - pattern: &Pattern, - current_solution: &HashMap, - ) -> Result>> { - if pattern.is_leaf() { - let resolved_index = - self.resolve_match_id(storage, &pattern.index, current_solution)?; - return Ok(storage - .all_links() - .into_iter() - .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) - .map(|link| { - let mut assignments = HashMap::new(); - Self::assign_variable(&pattern.index, link.index, &mut assignments); - assignments - }) - .collect()); - } - - let resolved_index = self.resolve_match_id(storage, &pattern.index, current_solution)?; - - if !Self::is_variable(&pattern.index) - && !Self::is_any(resolved_index) - && resolved_index != 0 - && storage.exists(resolved_index) - { - let link = storage.get_link(resolved_index).unwrap(); - return self.match_link_against_pattern(storage, pattern, link, current_solution); - } - - let mut results = Vec::new(); - for link in storage.all_links() { - results.extend(self.match_link_against_pattern( - storage, - pattern, - link, - current_solution, - )?); - } - Ok(results) - } - - fn match_link_against_pattern( - &self, - storage: &mut impl NamedTypeLinks, - pattern: &Pattern, - link: Link, - current_solution: &HashMap, - ) -> Result>> { - if !self.check_id_match(storage, &pattern.index, link.index, current_solution)? { - return Ok(Vec::new()); - } - - let mut results = Vec::new(); - let source_matches = self.recursive_match_subpattern( - storage, - pattern.source.as_deref(), - link.source, - current_solution, - )?; - - for source_solution in source_matches { - let target_matches = self.recursive_match_subpattern( - storage, - pattern.target.as_deref(), - link.target, - &source_solution, - )?; - for mut target_solution in target_matches { - Self::assign_variable(&pattern.index, link.index, &mut target_solution); - results.push(target_solution); - } - } - - Ok(results) - } - - fn recursive_match_subpattern( - &self, - storage: &mut impl NamedTypeLinks, - pattern: Option<&Pattern>, - link_id: u32, - current_solution: &HashMap, - ) -> Result>> { - let Some(pattern) = pattern else { - return Ok(vec![current_solution.clone()]); - }; - - if pattern.is_leaf() { - if self.check_id_match(storage, &pattern.index, link_id, current_solution)? { - let mut solution = current_solution.clone(); - Self::assign_variable(&pattern.index, link_id, &mut solution); - return Ok(vec![solution]); - } - return Ok(Vec::new()); - } - - let Some(link) = storage.get_link(link_id) else { - return Ok(Vec::new()); - }; - - self.match_link_against_pattern(storage, pattern, link, current_solution) - } - - fn check_id_match( - &self, - storage: &mut impl NamedTypeLinks, - pattern_id: &str, - candidate_id: u32, - current_solution: &HashMap, - ) -> Result { - if pattern_id.is_empty() || pattern_id == "*" { - return Ok(true); - } - - if Self::is_variable(pattern_id) { - return Ok(current_solution - .get(pattern_id) - .is_none_or(|existing| *existing == candidate_id)); - } - - if let Ok(parsed) = pattern_id.parse::() { - return Ok(parsed == candidate_id); - } - - Ok(storage - .get_by_name(pattern_id)? - .is_some_and(|named_id| named_id == candidate_id)) - } - - fn resolve_match_id( - &self, - storage: &mut impl NamedTypeLinks, - identifier: &str, - current_solution: &HashMap, - ) -> Result { - if identifier.is_empty() || identifier == "*" { - return Ok(u32::MAX); - } - if let Some(value) = current_solution.get(identifier) { - return Ok(*value); - } - if Self::is_variable(identifier) { - return Ok(u32::MAX); - } - if let Ok(parsed) = identifier.parse::() { - return Ok(parsed); - } - Ok(storage.get_by_name(identifier)?.unwrap_or(0)) - } - - fn matched_links( - &self, - storage: &mut impl NamedTypeLinks, - pattern: &Pattern, - solution: &HashMap, - ) -> Result> { - if pattern.is_leaf() { - let resolved_index = self.resolve_match_id(storage, &pattern.index, solution)?; - return Ok(storage - .all_links() - .into_iter() - .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) - .collect()); - } - - let mut links = Vec::new(); - for matched_solution in self.match_pattern(storage, pattern, solution)? { - if let Some(definition) = - self.resolve_pattern_readonly(storage, pattern, &matched_solution, false)? - { - links.extend(self.links_matching_definition(storage, &definition)?); - } - } - Ok(links) - } - - fn solution_is_no_operation( - &self, - storage: &mut impl NamedTypeLinks, - solution: &HashMap, - restrictions: &[Pattern], - substitutions: &[Pattern], - ) -> Result { - let mut restriction_links = self - .resolve_patterns_readonly(storage, restrictions, solution, false)? - .into_iter() - .map(|definition| definition.to_link()) - .collect::>(); - let mut substitution_links = self - .resolve_patterns_readonly(storage, substitutions, solution, true)? - .into_iter() - .map(|definition| definition.to_link()) - .collect::>(); - - restriction_links.sort_by_key(|link| link.index); - substitution_links.sort_by_key(|link| link.index); - - Ok(restriction_links == substitution_links) - } - fn resolve_patterns_readonly( &self, storage: &mut impl NamedTypeLinks, diff --git a/rust/src/query_processor/matching.rs b/rust/src/query_processor/matching.rs new file mode 100644 index 0000000..f6d41f2 --- /dev/null +++ b/rust/src/query_processor/matching.rs @@ -0,0 +1,221 @@ +//! Pattern matching for [`QueryProcessor`]. +//! +//! Extracted from `query_processor.rs` for issue #96: the file had grown to 994 +//! lines and CI warned that it was approaching the 1000-line limit enforced by +//! `rust/scripts/check-file-size.rs`. These are the read-only helpers that +//! decide whether a stored link satisfies a pattern; they carry no state beyond +//! `QueryProcessor`'s tracing flag. + +use anyhow::Result; +use std::collections::HashMap; + +use crate::link::Link; +use crate::named_type_links::NamedTypeLinks; +use crate::query_types::Pattern; + +use super::QueryProcessor; + +impl QueryProcessor { + pub(super) fn match_pattern( + &self, + storage: &mut impl NamedTypeLinks, + pattern: &Pattern, + current_solution: &HashMap, + ) -> Result>> { + if pattern.is_leaf() { + let resolved_index = + self.resolve_match_id(storage, &pattern.index, current_solution)?; + return Ok(storage + .all_links() + .into_iter() + .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) + .map(|link| { + let mut assignments = HashMap::new(); + Self::assign_variable(&pattern.index, link.index, &mut assignments); + assignments + }) + .collect()); + } + + let resolved_index = self.resolve_match_id(storage, &pattern.index, current_solution)?; + + if !Self::is_variable(&pattern.index) + && !Self::is_any(resolved_index) + && resolved_index != 0 + && storage.exists(resolved_index) + { + let link = storage.get_link(resolved_index).unwrap(); + return self.match_link_against_pattern(storage, pattern, link, current_solution); + } + + let mut results = Vec::new(); + for link in storage.all_links() { + results.extend(self.match_link_against_pattern( + storage, + pattern, + link, + current_solution, + )?); + } + Ok(results) + } + + pub(super) fn match_link_against_pattern( + &self, + storage: &mut impl NamedTypeLinks, + pattern: &Pattern, + link: Link, + current_solution: &HashMap, + ) -> Result>> { + if !self.check_id_match(storage, &pattern.index, link.index, current_solution)? { + return Ok(Vec::new()); + } + + let mut results = Vec::new(); + let source_matches = self.recursive_match_subpattern( + storage, + pattern.source.as_deref(), + link.source, + current_solution, + )?; + + for source_solution in source_matches { + let target_matches = self.recursive_match_subpattern( + storage, + pattern.target.as_deref(), + link.target, + &source_solution, + )?; + for mut target_solution in target_matches { + Self::assign_variable(&pattern.index, link.index, &mut target_solution); + results.push(target_solution); + } + } + + Ok(results) + } + + pub(super) fn recursive_match_subpattern( + &self, + storage: &mut impl NamedTypeLinks, + pattern: Option<&Pattern>, + link_id: u32, + current_solution: &HashMap, + ) -> Result>> { + let Some(pattern) = pattern else { + return Ok(vec![current_solution.clone()]); + }; + + if pattern.is_leaf() { + if self.check_id_match(storage, &pattern.index, link_id, current_solution)? { + let mut solution = current_solution.clone(); + Self::assign_variable(&pattern.index, link_id, &mut solution); + return Ok(vec![solution]); + } + return Ok(Vec::new()); + } + + let Some(link) = storage.get_link(link_id) else { + return Ok(Vec::new()); + }; + + self.match_link_against_pattern(storage, pattern, link, current_solution) + } + + pub(super) fn check_id_match( + &self, + storage: &mut impl NamedTypeLinks, + pattern_id: &str, + candidate_id: u32, + current_solution: &HashMap, + ) -> Result { + if pattern_id.is_empty() || pattern_id == "*" { + return Ok(true); + } + + if Self::is_variable(pattern_id) { + return Ok(current_solution + .get(pattern_id) + .is_none_or(|existing| *existing == candidate_id)); + } + + if let Ok(parsed) = pattern_id.parse::() { + return Ok(parsed == candidate_id); + } + + Ok(storage + .get_by_name(pattern_id)? + .is_some_and(|named_id| named_id == candidate_id)) + } + + pub(super) fn resolve_match_id( + &self, + storage: &mut impl NamedTypeLinks, + identifier: &str, + current_solution: &HashMap, + ) -> Result { + if identifier.is_empty() || identifier == "*" { + return Ok(u32::MAX); + } + if let Some(value) = current_solution.get(identifier) { + return Ok(*value); + } + if Self::is_variable(identifier) { + return Ok(u32::MAX); + } + if let Ok(parsed) = identifier.parse::() { + return Ok(parsed); + } + Ok(storage.get_by_name(identifier)?.unwrap_or(0)) + } + + pub(super) fn matched_links( + &self, + storage: &mut impl NamedTypeLinks, + pattern: &Pattern, + solution: &HashMap, + ) -> Result> { + if pattern.is_leaf() { + let resolved_index = self.resolve_match_id(storage, &pattern.index, solution)?; + return Ok(storage + .all_links() + .into_iter() + .filter(|link| Self::is_any(resolved_index) || link.index == resolved_index) + .collect()); + } + + let mut links = Vec::new(); + for matched_solution in self.match_pattern(storage, pattern, solution)? { + if let Some(definition) = + self.resolve_pattern_readonly(storage, pattern, &matched_solution, false)? + { + links.extend(self.links_matching_definition(storage, &definition)?); + } + } + Ok(links) + } + + pub(super) fn solution_is_no_operation( + &self, + storage: &mut impl NamedTypeLinks, + solution: &HashMap, + restrictions: &[Pattern], + substitutions: &[Pattern], + ) -> Result { + let mut restriction_links = self + .resolve_patterns_readonly(storage, restrictions, solution, false)? + .into_iter() + .map(|definition| definition.to_link()) + .collect::>(); + let mut substitution_links = self + .resolve_patterns_readonly(storage, substitutions, solution, true)? + .into_iter() + .map(|definition| definition.to_link()) + .collect::>(); + + restriction_links.sort_by_key(|link| link.index); + substitution_links.sort_by_key(|link| link.index); + + Ok(restriction_links == substitution_links) + } +} diff --git a/rust/wasm/Cargo.lock b/rust/wasm/Cargo.lock index a793a2e..63e12f9 100644 --- a/rust/wasm/Cargo.lock +++ b/rust/wasm/Cargo.lock @@ -60,9 +60,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-trait" @@ -462,9 +462,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ]