Skip to content

Development Guide

Setup and guidelines for contributing to Graph Core.

Prerequisites

  • Node.js 22+
  • npm
  • Git

Setup

# Clone repository
git clone https://github.com/sorenwacker/graph-core.git
cd graph-core

# Install dependencies
npm install

# Start development
npm run electron:dev

Project Structure

graph-core/
├── src/                 # Vue application source
│   ├── components/      # Vue components
│   ├── composables/     # Vue composition functions
│   ├── stores/          # Pinia state stores
│   ├── commands/        # Command pattern implementations
│   ├── services/        # API services
│   ├── utils/           # Utility functions
│   └── __tests__/       # Vitest tests
├── electron/            # Electron main process
│   ├── database/        # SQLite (sql.js) operations
│   └── ipc/             # IPC handlers
└── docs/                # Documentation (Zensical)

Development Workflow

Running Tests

# Run all tests
npm test

# Run with coverage
npm run test:coverage

# Run specific test file
npm test -- src/__tests__/useUndoRedo.test.js

Linting

npm run lint

Building

# Development build
npm run build

# Production Electron build
npm run electron:build

Installing a local build on macOS

make install-mac builds a DMG and copies the app into /Applications. This is how work is tried out between releases; it is not a release.

Those builds are ad-hoc signed, because build.mac.identity is null in package.json and must stay that way - CI has no signing certificate, and setting one there would break the release workflow. An ad-hoc signature has no certificate behind it, so the app's designated requirement is a bare hash of the binary:

codesign -d -r- "/Applications/Graph Core.app"
# designated => cdhash H"<hash of this exact binary>"

That hash changes with every build. macOS records the designated requirement when you grant a keychain item "Always Allow", so each local install invalidates the grant and the app asks for keychain access again on next start - it reads the database key through safeStorage at boot.

make install-mac-signed avoids that by signing with a certificate whose identity is stable across rebuilds:

SIGN_IDENTITY="Your Name" make install-mac-signed

The identity must be a code-signing certificate in your keychain; a self-signed one is enough, since the requirement anchors to the certificate rather than to the bytes. Create it in Keychain Access under Certificate Assistant > Create a Certificate, with Identity Type "Self Signed Root" and Certificate Type "Code Signing", then check it with security find-identity -v -p codesigning.

The target builds and installs normally, then re-signs the installed app with codesign. It deliberately does not hand the identity to electron-builder, which reads a Team ID out of the certificate and only an Apple-issued Developer ID has one:

Could not automatically determine ElectronTeamID from identity: <name>

electron-builder wraps signing in a retry, so a self-signed certificate does not fail fast - it re-signs the whole bundle repeatedly for many minutes and only then reports that error. Signing afterwards with --timestamp=none takes a second and needs no Apple infrastructure. The committed configuration and CI are untouched either way.

This only affects your own machine. Released builds are unsigned for everyone else, which is what the xattr -cr line in the release notes is for.

Code Style

Vue Components

  • Use Composition API with <script setup>
  • Extract reusable logic to composables
  • Keep components focused and single-purpose
  • Use TypeScript-style JSDoc for complex props
<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  node: { type: Object, required: true }
})

const emit = defineEmits(['select', 'update'])
</script>

Composables

  • Prefix with use (e.g., useNodeOperations)
  • Return reactive refs and functions
  • Accept configuration as object parameter
export function useFeature(options = {}) {
  const state = ref(options.initial)

  function doSomething() {
    // implementation
  }

  return { state, doSomething }
}

Commands

All state mutations use the Command pattern:

import Command from './Command.js'

export default class MyCommand extends Command {
  constructor(store, params) {
    super()
    this.store = store
    this.params = params
  }

  async execute() {
    // Perform action
    // Store state for undo
  }

  async undo() {
    // Revert action
  }
}

Testing Guidelines

Unit Tests

  • Co-locate tests in src/__tests__/
  • Use Vitest for test runner
  • Mock IPC calls with vi.mock
import { describe, it, expect, vi } from 'vitest'

describe('featureName', () => {
  it('should do something', () => {
    // Arrange
    // Act
    // Assert
  })
})

Integration Tests

Database integration tests run against the real database. createTestDatabase() constructs the production Database (electron/database/index.js) on a throwaway temp file — same schema, same migrations, same operations — and adds a close() that also removes the file. Nothing under electron/database/ needs the electron module, so it imports cleanly in Vitest.

import { createTestDatabase, createNodeFactory } from './helpers/testDatabase.js'

describe('database operations', () => {
  let db
  let factory

  beforeEach(async () => {
    db = await createTestDatabase()
    factory = createNodeFactory(db)
  })

  afterEach(() => {
    db.close()
  })

  it('keeps descendant paths correct after a move', () => {
    const { root, children } = factory.tree()
    const other = factory.project({ title: 'Other' })

    db.moveNode(children[0].id, other.id)

    expect(db.getNode(children[0].id).path).toBe(`${other.id}`)
    expect(db.getDescendants(root.id)).not.toContainEqual(expect.objectContaining({ id: children[0].id }))
  })
})

createNodeFactory(db) provides task(), project(), note(), person(), tree() and linked() builders.

Because the helper is the production class, a regression in electron/database/* fails these tests. Do not add schema or path logic to the helper — that turns it back into a mirror that can silently drift from the code it is supposed to protect.

End-to-end smoke tests

The unit suite never boots the real app: AG Grid is mocked, Cytoscape never renders, and nothing exercises the Electron boot path. The smoke pack in e2e/ closes that gap. It launches the packaged renderer (dist/) in real Electron with an isolated --user-data-dir, so your own database is never touched, and walks the core flows: startup, node creation, view switching, table cell editing, delete and undo, and persistence across a relaunch.

To run the pack locally:

make e2e

The target builds the renderer, bundles the preload, and runs Playwright against the result. The pack is a release gate: the release workflow runs it before building artifacts, and it stays out of the per-push CI job to keep CI minutes for the checks that change most often.

When you add a feature that changes startup, navigation, or data persistence, extend the smoke pack in the same change. A flow that only unit tests cover is a flow the packaged app can break silently - that is how the v1.11.1 artifacts shipped broken.

Documentation

Documentation is built with Zensical, the successor to Material for MkDocs.

Serving Docs Locally

make docs

The target runs uvx zensical serve, so uv resolves the tool into a cached environment - there is no virtualenv to create or keep in sync, and no Python setup beyond installing uv itself. This is a Node project with no pyproject.toml, so the docs tool is deliberately not declared as a Python dependency. To check for broken links and nav problems before pushing:

make docs-build

Writing Docs

  • Place guides in docs/guides/
  • Place reference docs in docs/reference/
  • Add the page to nav in zensical.toml - every file under docs/ is published, so a page missing from the nav is live but unreachable
  • Keep internal engineering documents outside docs/ entirely. Zensical ignores MkDocs' exclude_docs key without warning, so an exclusion list is not a reliable way to hold a page back; CODEBASE-REVIEW.md sits at the repository root for this reason
  • Mermaid diagrams use ```mermaid fences (rendered by pymdownx.superfences, no extra plugin)
  • Use admonitions sparingly
  • Include code examples

Commit Guidelines

Commit messages are checked by commitlint (@commitlint/config-conventional) in a Husky hook:

  • type(scope): description with a type from feat, fix, refactor, docs, test, ci, chore, perf, build, style, revert
  • Header at most 72 characters, imperative mood, no emojis
  • Reference issues when applicable
feat(timeline): add Ctrl+scroll zoom

See Standards.

Pull Request Process

  1. Create feature branch from main
  2. Write tests for new functionality
  3. Update documentation if needed
  4. Ensure all tests pass
  5. Submit PR with clear description

Continuous integration

.github/workflows/ci.yml defines a single test job that runs on every push to main and every pull request against it. In order, it checks formatting (format:check), lints (lint), type-checks (type-check), audits dependencies for high-severity advisories, runs the unit suite (test:run), and builds the renderer.

Type-checking is part of the job because neither the unit suite nor eslint runs the TypeScript compiler, so a dependency that breaks type-checking passes both. The TypeScript 7 bump (PR #102) is the case that proved it: TypeScript 7 removed the ./lib/tsc subpath from its package exports, vue-tsc resolves exactly that path, and npm run type-check failed outright while CI stayed green. Dependabot auto-merge polls this same test check by name, so a gap here is a gap in the merge gate.

src/__tests__/ciWorkflow.test.js gates the job: it fails if any of those checks is dropped.

Dependencies

Dependabot opens PRs weekly, with minor and patch updates grouped into one PR. Merging is automated only for the safe subset:

  • .github/workflows/dependabot-automerge.yml merges Dependabot PRs whose update type is semver-patch or semver-minor. The workflow polls the CI test check by name and refuses to merge unless it succeeded, so red CI blocks the merge even if main carries no required-checks protection; it polls that one named check rather than watching all checks because the workflow is itself a check on the PR and would otherwise wait on itself. The merge uses GitHub auto-merge (squash), so if required-checks protection exists as well, GitHub enforces it a second time.
  • Major updates are never merged automatically. CI here is weak evidence for majors: the AG Grid tests mock the grid and CI does not package the Electron app, so a green check on an Electron or AG Grid major proves little. They wait for a person.
  • Branch protection requiring the test check is recommended on main as a second, platform-enforced layer (no reviews, admins exempt so direct pushes keep working), but the workflow does not depend on it.
  • The auto-merge is performed with the workflow's GITHUB_TOKEN, and pushes made with that token do not trigger other workflows: the resulting merge commit on main gets no CI run of its own. The PR itself was gated on the same test check, which is why this is acceptable.

src/__tests__/dependabotAutomerge.test.js gates the workflow: it fails if the merge step stops requiring the patch/minor guard, drops --auto, or loses the Dependabot actor check.

Held-back majors

A major that cannot work yet is held back in .github/dependabot.yml, not in package.json. A version range does not stop Dependabot: typescript was already declared ^6.0.3, which excludes 7, and the 7.0.2 PR was opened regardless, because a major update rewrites the range rather than respecting it. Only an ignore entry stops the same PR returning every week.

Currently held back:

  • typescript major. TypeScript 7 is the Go rewrite and dropped the ./lib/tsc subpath from its package exports; vue-tsc resolves exactly that path, so npm run type-check fails with ERR_PACKAGE_PATH_NOT_EXPORTED before it checks anything. Neither package can be fixed here. Lift the hold once vue-tsc supports TypeScript 7 - its peer range is typescript: >=5.0.0, which already claims a compatibility it does not have, so the peer range is not evidence. Check that npm run type-check passes rather than trusting the install.

Minor and patch updates continue to flow for a held-back package; only the major is ignored. src/__tests__/dependabotIgnores.test.js gates this: it fails if the hold is dropped, if it widens to catch minors and patches, or if a held-back package is not explained here.

Keyboard input ownership

Global keyboard shortcuts live in useKeyboardShortcuts. They must stand down when a focused surface owns keyboard input, or a shortcut fires while the user is typing. That decision lives in one place, utils/inputOwnership.js, not in the shortcut handler:

  • ownsTextInput(target) is true for form fields, contenteditable, and CodeMirror. Text-sensitive shortcuts skip these.
  • ownsAllKeys(target) is true inside an element marked data-owns-keys. A surface that binds even plain navigation keys - the node spreadsheet is the current example - marks its root with that attribute, and every global shortcut skips it.

When you add an input surface, do not add a case to the shortcut handler. Use standard form elements (recognised automatically) or, for a surface that owns every key, put data-owns-keys on its root. src/__tests__/inputOwnership.test.js gates the model.

Releases

Releases are tag-driven: pushing a semver tag runs .github/workflows/release.yml, pushing to main does not. A full release requires an existing pre-release for the same base version, so the order is v1.12.0-rc.1 first, then v1.12.0.

The workflow creates the GitHub release as a draft, builds artifacts on each platform and uploads them to that draft, then flips it to published only once the artifacts are in place.

Cadence: one full release per month

A full release goes out at most once per calendar month. Everything between those is a release candidate. Building and installing locally (make install-mac) is not a release and is not restricted - that is how work is tried out between releases.

Two exceptions bypass the monthly limit: a critical bugfix and a security patch. Claim one in the annotated tag's message, on its own line:

RELEASE-EXCEPTION: security

The accepted reasons are critical and security, and the claim must be the whole line - RELEASE-EXCEPTION: security patch claims nothing. A malformed claim is reported as such rather than silently ignored, so a real security patch is never blocked by a typo with a message about monthly cadence.

Only an annotated tag can claim an exception, because only an annotated tag has a message of its own. This has to be read deliberately: git tag -l --format=%(contents) follows a lightweight tag through to its commit and returns the commit message, so a commit whose message happened to contain the marker would claim the exception without anyone tagging deliberately. The wrapper checks the ref is a tag object before reading its message.

The month of a previous release is the date GitHub published it (publishedAt), not createdAt, which is the tagged commit's date and can fall in an earlier month than the release itself: v1.18.0 and v1.18.0-rc.1 share a createdAt because they share a commit. Drafts are excluded, since an abandoned draft in the current month would otherwise block a legitimate release.

The release-policy job enforces this before anything is built or published: it reads the pushed tag, the tag's message, and the publication dates of previous full releases, and fails the workflow when a second full release is attempted in a month without an exception. A rejected tag is deleted by the existing cleanup-invalid job, which deletes the ref through the API: the job has no checkout, so the git push --delete it used before ran in an empty workspace and failed silently, leaving rejected tags on the remote where a re-push of the same tag would not retrigger the workflow.

scripts/releasePolicy.mjs holds the decision, scripts/check-release-policy.mjs is the thin wrapper the workflow runs, and src/__tests__/releasePolicy.test.js covers the rules.

Release creation is idempotent

The step that creates the release reuses an existing release for the tag rather than creating a new one. This matters because a draft release is not bound to its tag: gh release create will happily create a second draft for a tag that already has a release, so a re-run or a retried job silently produces duplicates. Four such duplicate pairs accumulated on the repository before this was enforced (v1.10.1, v1.10.1-rc.1, v1.10.3, v1.10.3-beta.1), each an empty draft shadowing the real published release.

src/__tests__/releaseWorkflow.test.js executes the step's script against a stubbed gh and fails if a second release is created when one already exists.

Release notes

Notes combine a fixed Installation section with GitHub's --generate-notes output. The generated "What's Changed" list is derived from merged pull requests, so work committed directly to main produces an empty changelog. When a release covers direct commits, write its notes from CHANGELOG.md instead.

The generated "Full Changelog" link compares against the previous tag. Deleting a tag after release, as happened with the pulled v1.11.1, leaves that link pointing at a tag that no longer exists and it 404s; repoint it at the last surviving tag.

See Also