Skip to content

feat: AppleScript dictionary — scripted read/write of notes - #208

Merged
drawmeanelephant merged 2 commits into
mainfrom
feat/applescript-scripting
Aug 25, 2026
Merged

drawmeanelephant merged 2 commits into
mainfrom
feat/applescript-scripting

Conversation

@drawmeanelephant

Copy link
Copy Markdown
Owner

Third item from the issue pile: an AppleScript dictionary with read + write, per the maintainer's scope call.

Surface

Resources/BANAL.sdef defines six verbs on the app:

tell application "BANAL"
  create note given title:"Risotto Bianco", body:"Toast the rice.", folder:"Recipes", published:true
  list notes                                   -- JSON array of records
  read note "Recipes/Risotto Bianco.md"        -- JSON record incl. body
  update note "Risotto Bianco.md" given body:"Stir, add stock."
  set published "Risotto Bianco.md" to true
  publish site                                 -- "Published 1 note with builtin."
end tell

How it works

  • New BANALScripting library target: NoteScripting (the verbs) + NSScriptCommand subclasses (dispatch, mapped via sdef <cocoa class>). BANALApp links it through a keep-alive bootstrap so release dead-stripping preserves the ObjC classes.
  • Every verb opens a fresh store through IntentVaultResolver — the same route the App Intents take — and flushes on settle. Scripted writes land as plain files (Recipes/Tom Yum.cook keeps >> metadata; titles become plain names) and a running app observes them like Finder edits. No second database.
  • Bundle plumbing: make app copies the sdef into Resources; Info.plist gains NSAppleScriptEnabled + OSAScriptingDefinition; XcodeGen spec mirrors both.

Verified against the live signed .app

Booted dist/BANAL.app against a scratch vault and drove every verb from osascript: create → read → update → set published → publish site, then asserted disk state (edited body on disk, published: true frontmatter, Cooklang >> metadata, sanitized .publish/Scripted Note.html, feed + index).

Three sdef landmines cost most of the debugging, now documented in the commit: event codes must be 8 chars; <cocoa> must be the first child of <command>; result types must be concrete — the app-side parser rejects violations silently with -1708, while reply conversion of native dictionaries throws inside \_populateReplyAppleEventWithResult (hence JSON-text results).

Tests

5 unit tests on the scripting layer (temp vaults): list/read shapes, create writes plain-named files with frontmatter, unknown language rejected, body+published round-trip to disk, publish returns status copy and artifacts. Full suite green. README gains a Scripting section; CHANGELOG updated.

BANAL.app now ships an sdef (Resources/BANAL.sdef) with six verbs:
list notes, read note, create note, update note, set published,
publish site. Handlers live in a new BANALScripting library target;
every verb goes through the same NoteStore route the App Intents use
(fresh store per call, flush on settle), so scripted writes land as
plain files on disk and the running app observes them like Finder
edits. Disk stays the only truth.

Record answers return as JSON text; publish site returns the status
sentence. The app target links BANALScripting via a keep-alive
bootstrap so release dead-stripping preserves the ObjC command
classes, and Supporting/Info.plist enables NSAppleScriptEnabled +
OSAScriptingDefinition (Makefile copies the sdef into Resources).

Verified against the live signed .app: create/read/update/
set-published/publish all round-trip to disk (.cook with >> metadata
in Recipes/, frontmatter flags, sanitized .publish artifacts).

sdef gotchas baked into the final shape: 8-char event codes, cocoa
class element first inside command, concrete result types — the
app-side parser rejects anything else silently (-1708).
@itoqa

itoqa Bot commented Aug 25, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: eda7460: 16 test cases ran, 15 passed ✅, 1 additional finding ⚠️.

Summary

Coverage spans note creation, reading, updating, publishing, vault persistence, and command integration, along with safety checks for invalid vaults, unsafe paths, hostile names, and concurrent edits. It also examines publishing error handling and site output integrity, with the tested application behavior broadly healthy aside from a separate partial-publication risk.

Safe to merge — the only identified issue is a medium-severity publishing integrity bug that is explicitly unrelated to this PR and is not a regression or newly introduced failure. It is a flag for later rather than a merge blocker.

Tests run by Ito

View full run

Result Severity Type Description
General The check could not run because the native app and AppleScript tools need macOS, but this Linux test environment cannot provide them. Source review found the app starts a folder monitor and reloads changed note files from disk.
General The six note commands have matching dictionary entries, handlers, and release bundle metadata. Direct macOS command testing was unavailable in the Linux environment, but source and resource checks found no registration mismatch.
General Verified acceptable by independent adversarial review: the scenario cannot be reached through any real application path. Review notes: The lower-level store would overwrite a stale full-note snapshot if two independent stores actually reached NoteIO.write concurrently, but the finding does not establish a reachable way for the new AppleScript entry point to create that state. Apple events enter a synchronous NSScriptCommand handler, and the complete load-mutate-write operation is main-actor isolated with no suspension point, so t…
General Verified acceptable by independent adversarial review: the scenario cannot be reached through any real application path. Review notes: The finding correctly identifies that NoteStore writes whole-note values, but its required interleaving is not reachable through the cited AppleScript entry points. Both commands execute synchronously on the main actor without a suspension point, so two incoming scripts are serialized: the first command flushes before the second command can load its fresh store, allowing the second to observe the …
Bundle The release bundle contract is complete: all six commands are listed, connected to command handlers, and wired into the app metadata. The live build and AppleScript call could not run because the test environment is Linux and does not provide macOS tools, so this case was reclassified from blocked to passed based on the source checks.
Create Creating a published markdown note places a file named Tom Kha Gai.md in the Recipes folder with the supplied body and published setting. Source inspection and the focused test assertions match this expected behavior; the native AppleScript command could not run in the Linux test environment.
List The note listing and reading behavior is implemented and covered by focused tests. The live AppleScript check could not run because this Linux environment has no macOS scripting tool or BANAL app bundle.
Publish The publish command is wired to the local publishing pipeline and returns its completion message. Source and unit-test coverage confirm that a published Markdown note produces index.html and feed.xml with the builtin compiler; the recorded container could not run the macOS AppleScript command.
Publish Trying to publish when no notes are marked for publishing reports that there is nothing to publish. No site files are created before this check.
Published An existing note can be marked as published and the change is saved. The check could not use the native macOS command in the Linux test environment, but the source path and platform-independent regression test confirm the published state survives a fresh read.
Rev Hostile note titles are turned into safe, visible file names, and folder checks keep created notes inside the selected vault. Native execution was unavailable in the Linux test environment, so this pass is based on the matching source review.
Update Updating an existing note replaces its body, keeps the same note, and saves the change to disk.
Vault Fresh scripted operations are designed to share the vault files, and the source and unit tests cover create, update, publish, and read-back behavior. The live AppleScript check needs macOS and could not run in the Linux test environment.
Vault When no usable vault is available, the scripting operation is designed to stop with a clear vault-not-found error instead of writing notes somewhere else.
Vault The path-safety check could not run in this Linux environment because the native macOS app and AppleScript runtime were unavailable. Source review shows that traversal folders are rejected before a note path is created, so no application defect was confirmed.
⚠️ Medium severity General A note that cannot be rendered is skipped while other notes continue to publish, and later write failures can leave a partial site behind. The command does not fail before exposing that mixed output as the publish result.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟡 Publishing can leave an incomplete site
  • Severity: Medium Medium severity
  • Description: A note that cannot be rendered is skipped while other notes continue to publish, and later write failures can leave a partial site behind. The command does not fail before exposing that mixed output as the publish result.
  • Impact: A publish can appear successful while leaving out a note or exposing an incomplete site to visitors. The site may stay incomplete until the user fixes the failing content or write and publishes again.
  • Steps to Reproduce:
    1. Prepare one published Markdown note and one published non-Markdown note whose renderer will fail.
    2. Run the publish-site command and record the returned status.
    3. Inspect the generated index, feed, and artifact directory for the failed note and for the files from the successful note.
    4. Cause a later page, feed, or configuration write to fail, then inspect the artifact directory before publishing again.
    5. Fix the failing note or write and publish again, then compare the new artifact set with the previous partial set.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: BANALPublisher.publish in Sources/BANALPublisher/BANALPublisher.swift:40-50 iterates over every published note. For non-Markdown notes, renderMarkup returns nil when Oliver is missing, throws, or returns empty HTML (lines 110-115); the caller catches that condition by appending PublishSkip and continues instead of throwing. If at least one Markdown note was compiled, the pipeline proceeds at lines 53-68, so the operation can return a PublishResult even though a published note was not included. PublishResult.statusCopy in Sources/BANALPublisher/PublishModels.swift:77-86 surfaces this as a successful 'Published ... Skipped ...' message rather than a failed publication. The artifact transaction is also incomplete: BuiltinSiteCompiler.compile in Sources/BANALPublisher/SiteCompiler.swift:34-43 removes the existing artifact directory, recreates it, and writes Markdown pages one at a time. It then copies assets at lines 46-49. BANALPublisher.publish writes any Oliver-generated extra pages at lines 70-78, writes feed.xml at lines 80-87, and writes wrangler.toml at lines 89-95, all after compilation and without rollback. An exception at any of those later writes leaves whatever pages were already written in the artifact directory. The smallest practical fix is to treat any required published-note render failure as a thrown publish error and build the complete output in a temporary sibling directory, then replace the destination only after every page, asset, feed, and wrangler file succeeds; on error, retain the prior artifact rather than deleting it.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

main picked up the agent CLI (#207) and the identity-contract fix
(#206) while this branch carried the AppleScript dictionary. Both
sides appended a section after Publish — Command line and Scripting
now coexist — and the STATUS verification-tooling exception appears
once, with both surfaces listed.
@drawmeanelephant
drawmeanelephant merged commit 4369a5b into main Aug 25, 2026
3 checks passed
@drawmeanelephant
drawmeanelephant deleted the feat/applescript-scripting branch August 25, 2026 18:47
@itoqa

itoqa Bot commented Aug 25, 2026

Copy link
Copy Markdown

Ito QA test results
Ito Diff Reporteda74606baa7be: 18 test cases ran, 1 new failure ❌, 15 passing ✅, 2 additional findings ⚠️.

Diff Summary

The run covered normal vault and publishing behavior, stable external identities, diagnostics, empty and failed operations, and boundary cases involving unsafe paths and file access. It also exercised adversarial security and overlapping-publish scenarios, with the broader expected behavior generally holding aside from a serious path-isolation issue.

Not safe to merge yet — this PR introduces a high-severity security failure in vault path enforcement that can expose files outside the selected vault. The publish concurrency findings are unrelated to this PR and are caveats rather than merge drivers.

Tests run by Ito

View full run

Result State Severity Type Description
❌ New Failure High severity Rev The command should reject a note link that leaves the vault. Its path check can accept the in-vault link, and the command then prints the outside file. The CLI could not run in the Linux container because the project requires Apple platform Swift modules, but source inspection confirms the unsafe path.
Passing General The boundary check could not run because the native command is unavailable in the Linux test environment. Source inspection shows that the application checks the 255-byte limit and creates unique collision names without changing the local note files.
Passing General The doctor command keeps the Boris and Oliver checks in its report and returns a failure code when any check fails, even if another check only warns. Runtime execution was blocked because this macOS-only Swift package cannot compile in the Linux test environment.
Passing General Each published note uses one shared external identity for its staged file, page link, and feed entry, so those outputs stay tied to the original local note.
Passing General Verified acceptable by independent adversarial review: the observed behavior is intended and documented in this codebase. Review notes: The finding accurately traces the control flow but treats the fallback as a defect contrary to affirmative product documentation and UI semantics: Boris is optional, and publishing uses it only when available, otherwise using the builtin compiler. Doctor's stricter stale-path diagnostic does not turn Boris into a required publish engine. The PR-causation claim is supported because the PR adds anot…
Passing General Verified acceptable by independent adversarial review: the reported expectation does not match what the code actually promises. Review notes: The assigned scenario is a configured Boris process failing before artifact replacement. The code deliberately builds Boris output in staging and leaves the documented last generated site artifact intact unless compilation succeeds, while the CLI returns an explicit error. Direct writes in the builtin/post-compile path could separately leave partial output after an I/O failure, but that is a diffe…
Passing General The publish comparison could not run in the Linux test environment, but the source assigns collision ids in a fixed order based on note type and filename.
Passing Doctor The healthy configuration check could not run because the macOS-only Swift package was tested in a Linux container. Source review shows that the command reports the vault, Boris, Oliver, and identity checks and returns success when all checks are healthy.
Passing Doctor The doctor check uses a warning for an optional engine that is not set up and a failure for an engine configured at a missing or unusable path. The command could not run in the Linux test environment, but the source code supports both expected outcomes.
Passing Identity The publish check could not run because the available workbench is Linux-only while this app requires macOS. Source review shows that publishing keeps local filenames unchanged and creates safe, stable identities and paths for the published notes.
Passing Identity Publishing rewrites unsafe name parts for external site paths while keeping the original files unchanged. Markdown keeps the plain name, and a matching Cook note gets a unique numeric suffix.
Passing Publish The empty-vault check could not reach the application because the Linux Swift toolchain cannot compile the macOS-only package. Source review confirms the publish command has a failure path for an empty published-note set, so this run does not show a product defect.
Passing Publish The publish command could not start because the local Linux target cannot compile the macOS app. No publish result or artifact was produced, so the feature itself was not shown to fail.
Passing Publish The structured publish check could not run because the Linux test environment cannot build this macOS-only app. The source includes the JSON response path, but no response or artifact paths were produced in this environment.
Passing Summary The local workbench and fixture vault were ready, but the summary commands could not run in the Linux container because this macOS app needs an unavailable Apple framework. The fixture note names and contents stayed unchanged.
Passing Summary The selected note vault was not changed, and no content outside it was displayed. The command could not run on the Linux test machine because this macOS app could not compile there, but the source checks cover both escaping paths and missing notes.
⏸️ Skipped General The check could not run because the native app and AppleScript tools need macOS, but this Linux test environment cannot provide them. Source review found the app starts a folder monitor and reloads changed note files from disk.
⏸️ Skipped General The six note commands have matching dictionary entries, handlers, and release bundle metadata. Direct macOS command testing was unavailable in the Linux environment, but source and resource checks found no registration mismatch.
⏸️ Skipped General Verified acceptable by independent adversarial review: the scenario cannot be reached through any real application path. Review notes: The lower-level store would overwrite a stale full-note snapshot if two independent stores actually reached NoteIO.write concurrently, but the finding does not establish a reachable way for the new AppleScript entry point to create that state. Apple events enter a synchronous NSScriptCommand handler, and the complete load-mutate-write operation is main-actor isolated with no suspension point, so t…
⏸️ Skipped General Verified acceptable by independent adversarial review: the scenario cannot be reached through any real application path. Review notes: The finding correctly identifies that NoteStore writes whole-note values, but its required interleaving is not reachable through the cited AppleScript entry points. Both commands execute synchronously on the main actor without a suspension point, so two incoming scripts are serialized: the first command flushes before the second command can load its fresh store, allowing the second to observe the …
⏸️ Skipped Bundle The release bundle contract is complete: all six commands are listed, connected to command handlers, and wired into the app metadata. The live build and AppleScript call could not run because the test environment is Linux and does not provide macOS tools, so this case was reclassified from blocked to passed based on the source checks.
⏸️ Skipped Create Creating a published markdown note places a file named Tom Kha Gai.md in the Recipes folder with the supplied body and published setting. Source inspection and the focused test assertions match this expected behavior; the native AppleScript command could not run in the Linux test environment.
⏸️ Skipped List The note listing and reading behavior is implemented and covered by focused tests. The live AppleScript check could not run because this Linux environment has no macOS scripting tool or BANAL app bundle.
⏸️ Skipped Publish The publish command is wired to the local publishing pipeline and returns its completion message. Source and unit-test coverage confirm that a published Markdown note produces index.html and feed.xml with the builtin compiler; the recorded container could not run the macOS AppleScript command.
⏸️ Skipped Publish Trying to publish when no notes are marked for publishing reports that there is nothing to publish. No site files are created before this check.
⏸️ Skipped Published An existing note can be marked as published and the change is saved. The check could not use the native macOS command in the Linux test environment, but the source path and platform-independent regression test confirm the published state survives a fresh read.
⏸️ Skipped Rev Hostile note titles are turned into safe, visible file names, and folder checks keep created notes inside the selected vault. Native execution was unavailable in the Linux test environment, so this pass is based on the matching source review.
⏸️ Skipped Update Updating an existing note replaces its body, keeps the same note, and saves the change to disk.
⏸️ Skipped Vault Fresh scripted operations are designed to share the vault files, and the source and unit tests cover create, update, publish, and read-back behavior. The live AppleScript check needs macOS and could not run in the Linux test environment.
⏸️ Skipped Vault When no usable vault is available, the scripting operation is designed to stop with a clear vault-not-found error instead of writing notes somewhere else.
⏸️ Skipped Vault The path-safety check could not run in this Linux environment because the native macOS app and AppleScript runtime were unavailable. Source review shows that traversal folders are rejected before a note path is created, so no application defect was confirmed.
⚠️ Additional Finding Medium severity General Starting two publishes together does not keep their temporary files separate. The recorded commands could not reach this check because both stopped at the Linux Swift compilation error, but the source code still shows that concurrent executions share and rewrite the same directories.
⚠️ Additional Finding Medium severity General Each publish is expected to use an independent, internally consistent set of temporary and generated files. Instead, repeated runs use the same staging and artifact directories, so a later run can remove staging files while an earlier run is still reading them and can replace the generated site while the earlier run is writing it.
Tests that are no longer relevant

Below are tests that previously ran and are no longer relevant:

Type Test Description
General Publishing can leave an incomplete site Dropped because No corresponding current-commit claim or scoped candidate surface remains.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟡 Concurrent publishes can corrupt results
  • Severity: Medium Medium severity
  • Description: Starting two publishes together does not keep their temporary files separate. The recorded commands could not reach this check because both stopped at the Linux Swift compilation error, but the source code still shows that concurrent executions share and rewrite the same directories.
  • Impact: When two publishes for the same vault run at once, one result can be incomplete or contain notes from the other publish. Running the publishes one at a time avoids the problem, but the affected publish must be run again.
  • Steps to Reproduce:
    1. Prepare a vault with at least one published note.
    2. Start two identical publish commands for that vault at the same time.
    3. After both commands finish, compare each reported result with the files in the staging and publish directories.
    4. Check whether either result is missing notes or contains files from the other invocation.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: PublishConfiguration.default(for:) in Sources/BANALPublisher/PublishModels.swift:34-42 assigns configuration.stagingDirectory to vault.metadataURL/.banal/stage and configuration.artifactDirectory to vault.publishURL, so every invocation for one vault receives the same two paths. BANALPublisher.publish in Sources/BANALPublisher/BANALPublisher.swift:57-68 stages notes and then compiles using those shared paths. BorisAdapter.stage in Sources/BANALPublisher/BorisAdapter.swift:140-147 removes the existing staging directory before recreating it and writing content at lines 144-164. With two invocations, one process can remove the directory while the other is still writing or compiling. The builtin compiler in Sources/BANALPublisher/SiteCompiler.swift:27-49 likewise removes and recreates the shared artifact directory before writing pages; the Boris compiler removes and copies the same shared artifact directory at lines 148-163. These unsynchronized delete/write/copy operations allow partial trees, missing pages, or content from the other invocation. The PR diff shows changes to BorisAdapter's identity assignment, but not to its stage method, PublishModels.swift, or SiteCompiler.swift, so the changed PR lines are not the direct cause. The smallest practical fix is to give each publish invocation unique staging and artifact paths, then report those paths, or otherwise serialize access to the shared paths; per-invocation directories are the safer targeted fix because it preserves concurrent publishing.
Evidence Package
🟡 Repeated publishes can overwrite each other
  • Severity: Medium Medium severity
  • Description: Each publish is expected to use an independent, internally consistent set of temporary and generated files. Instead, repeated runs use the same staging and artifact directories, so a later run can remove staging files while an earlier run is still reading them and can replace the generated site while the earlier run is writing it.
  • Impact: When publishes overlap, users may receive a mixed or incorrect website because one publish can replace another publish's files. The site can stay wrong until a clean publish is run.
  • Steps to Reproduce:
    1. Create a vault with at least one published note.
    2. Start two publish commands for the same vault one after the other before the first one finishes.
    3. Compare the generated pages and temporary files with the source notes from both runs.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: PublishConfiguration.default(for:) in Sources/BANALPublisher/PublishModels.swift:34-42 derives stagingDirectory as vault.metadataURL/.banal/stage and artifactDirectory as vault.publishURL/.publish. These values contain no per-run identifier. BANALPublisher.publish in Sources/BANALPublisher/BANALPublisher.swift:57-68 passes both shared paths into staging and compilation. BorisAdapter.stage in Sources/BANALPublisher/BorisAdapter.swift:140-147 unconditionally removes the existing staging directory before recreating it, so a second invocation can delete the first invocation's inputs. The compiler paths are also shared: BuiltinSiteCompiler in Sources/BANALPublisher/SiteCompiler.swift:34-37 removes and recreates .publish, while BorisCLICompiler in lines 154-157 replaces the same artifact directory after compiling. The PR context lists only CLI, identity, documentation, and test changes; it does not change these path definitions or cleanup operations. The smallest practical fix is to allocate a unique staging directory per publish and compile into a unique temporary artifact directory, then publish that completed tree to the stable .publish location with a coordinated replacement that cannot overlap an in-progress run.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant