Skip to content

feat(workspace)!: ignore and include read gitignore's forms, and refuse any other - #825

Merged
nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:phoebe/ignore-gitignore-forms
Oct 26, 2026
Merged

nissa-seru merged 7 commits into
anima-research:mainfrom
nissa-seru:phoebe/ignore-gitignore-forms

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: a workspace mount's ignore and include now read the gitignore forms a config copied from a .gitignore uses: node_modules/, /dist, a/b, **/cache, npm-debug.log*. A pattern of any other form, such as !dist, or [Bb]in/ in ignore, stops the module from being built, with an error naming the mount, the pattern and why, where ignore used to match nothing without a word.

Problem

On main (0a4a2c6979), ignore read three forms: a name, dir/** and *.ext (isIgnored, src/modules/workspace/observe.ts). Every other pattern matched only an entry of exactly that name, or nothing, and loaded without a word. A probe at main (MountConfig patterns over eight paths, through observe.ts's own coveredByIgnore and includePatternReadable) showed:

  • node_modules/, dist/, /dist, **/dist and src/*.ts covered nothing in ignore;
  • node_modules/** covered only the mount root's node_modules, not pkg/node_modules.

So a mount configured with node_modules/, the form .gitignore files use, synced and watched every file in node_modules, and nothing said its pattern wasn't read. The include, since #536, refuses a pattern its matcher can't read, because one that matches nothing takes every file out of the mount unsaid. ignore had no such check, and the same mistake there is silent.

Changes

  • One grammar for both lists (parsePattern in observe.ts, read once per pattern string):

    • a name, matched at any depth, with * matching any run of characters and ? any one within it;
    • a trailing /: only a directory;
    • a leading or inner /: a path from the mount's root, segment by segment;
    • a leading **/ before one name: that name at any depth, and with a trailing /** as well, as the name with a trailing /;
    • a trailing /**: a directory from the root and what lies beneath it.

    Each form covers the files git's reading of it covers, with three differences: names compare exactly, as git's do with core.ignorecase off (its default folds case on macOS and Windows); a ? matches one character where git's matches one byte, so the two differ on names outside ASCII; and on a mount that follows links, a link to a directory counts as a directory, where git reads every link as a file. A trailing /** also covers the directory itself, as main's did, where git matches only what lies inside it; the files covered are the same. A run of git check-ignore (git 2.47.3, core.ignorecase off) against this matcher, over 25 patterns and 57 paths, none of them a link, differs only there: where a ? falls on é, and on the directories a trailing /** names. The difference for links comes from Crispin-2406's run of git check-ignore (git 2.55.0), which matched build/ against neither a link to a directory nor a link that leads nowhere.

  • ignore covers an entry that a pattern matches, and what lies beneath it, as before. dir/** no longer covers a file named dir: main's isIgnored matched the prefix whatever the entry was.

  • include reads the same forms for files, and a [ as itself, as 0.27.0's doc promised ([id].tsx). A pattern that matches only directories covers what lies beneath a directory it matches, so fixtures/ covers every file beneath a directory named fixtures, at any depth. Any other pattern covers a file by its own name or path, and nothing beneath a directory it matches, as feat(workspace): a mount's include names the files it holds, and is its boundary #536 decided.

  • Refused when the module is built, for both lists, by one check (patternRefusal), where main checked only the include:

    • a leading ! or #, a \, a trailing space, ** anywhere else, an empty, . or .. segment, or a value that isn't a string;
    • in ignore, a [. A gitignore class read as itself would ignore less than its author meant, while in include it includes less, the safe direction there;
    • an ignore that isn't a list. [] still loads, and is how a direct mount opts out of its default list.

    The error names the mount, the pattern and why, then the forms the list reads. Since the lists have no escapes, the [ and \ refusals say that a ?, which matches any one character, can stand for one in a name: in ignore, ?id].tsx covers [id].tsx, and any other name that differs from it only there.

  • The entry's kind. A directory-only pattern has to know whether the last segment is a directory; every segment above it is one. passedOverAt, passedOverBecause and coveredByIgnore take that as a required argument, so each caller states it:

    • the walk passes its dirent's, and for a link on a mount that follows links, which a directory-only pattern names, what directoryAt reads where it leads;
    • a listing's scope is taken as a directory, and where only a directory-only pattern names the scope itself, as what disk holds there (directoryAt: stat on a mount that follows links, lstat otherwise), a scope disk doesn't hold counting as a directory, as any history beneath it implies;
    • a search's start, and ignoreBeneath's scope, are directories;
    • a pass over paths the watcher reported decides files and links, since the watcher reports only those;
    • a sync that names a path, for its "this mount's ignore list covers it" note, passes what disk holds at the path, read by directoryAt too, or, where disk holds nothing, a directory when history holds entries beneath it, as a sync without a path reads such entries;
    • the watcher passes chokidar's stats. By reading chokidar 4.0.3: before it watches a path, its stats come from stat on a mount that follows links and lstat otherwise (statMethods[wh.statMethod]), as directoryAt reads. It asks without stats before it stats a path, asks again with them before watching it, and asks without them when a path it watches is removed. Where it gives none, no directory-only pattern matches. A directory such a pattern matches never passes the check with its stats, so it's never watched, and its removal is never reported.
  • MountConfig.ignore and .include's docs say the forms, the three differences from git, and the refusals.

What each part is for

What the change is for: a list read the way its author wrote it, or refused with a reason. Each part, what it buys and what it costs:

  • One parser for both lists: both matchers and both load checks read the same forms the same way. It replaces isIgnored's and coveredByInclude's separate readings and includePatternReadable.

  • The cache of parsed patterns (readings, a map keyed by the pattern string): a walk tests every entry against every pattern, and parsing compiles a regex for a glob, so each pattern is parsed once. Keys are configured patterns. Parsing them where a mount's view is built instead would change MountView.ignore's type and every caller's, for the same effect.

  • * and ? within a name: one regex per globbed segment, replacing the two matchers' *.ext cases and the include's check of where a * may stand. GitHub's Node .gitignore template has npm-debug.log* and .env.*, beside node_modules/, and ? gives a name holding [ or \ a way to be named.

  • The required directory argument: build/ means directories named build, and not a build script named build. The alternative, reading dir/ as the name dir, takes no argument, and silently passes over a file that the slash says to keep.

  • A reason for each refusal: the parser has to tell the forms apart to refuse them at all, so the reason costs only its words. For ! and #, a list of the forms read wouldn't tell an author what to do.

  • include's directory-only patterns cover what lies beneath: this is what makes fixtures/ and /docs/ mean something in a list that covers files; one loop over a path's directories. Refusing them instead would leave include reading a smaller set of forms than ignore under one parser.

  • directoryAt, the kind of what disk holds at a path: one helper, stat on a mount that follows links and lstat otherwise, as chokidar's stats are, reading a link it can't follow, such as one that leads nowhere, as no directory, so the walk, a listing, a named sync's note and the watcher read a link alike. The walk calls it only for such a link, and a listing only where a directory-only pattern alone names its scope, so a listing naming a file build under build/ doesn't say the list covers it; every other listing reads disk as on main. A named sync calls it once, for its note.

  • [ read as itself in include and refused in ignore: one condition in patternRefusal, and a [ among the characters a glob escapes. It keeps 0.27.0's promise for route files such as [id].tsx, while a gitignore class in ignore is refused, since read as itself it would ignore less than meant.

  • Tests: workspace-pattern-forms.test.ts brings its own small Env (a mount over a temporary directory with given files), call and synced (what a sync without a path takes in, with given lists), since each test needs only those. The watcher tests' helper now makes each written file's directory before the watcher starts, so a test can write beneath a directory a pattern names; the existing tests' directories were already made.

  • Left out:

    • negation;
    • character classes;
    • ** between names (a/**/b, **/a/b);
    • reading .gitignore files themselves.

    Each would need a fuller matcher, and neither connectome-host's recipes nor agent-framework's defaults use one.

What callers will notice

Hosts and operators whose mount configurations set ignore or include. Old beside new:

  • Configs that now ignore more:

    • dir/;
    • /x and a/b;
    • **/x, and **/x/**;
    • a * anywhere but a leading *., or a ?.

    Each matched nothing, or only an entry named exactly as written. In include, a pattern holding a ?, which it read as itself, now covers more files too.

  • Configs that now ignore less: a file named like a dir/** pattern's directory is no longer passed over. 0.27.0's doc described the form as "anything under node_modules".

  • Configs that loaded and are now refused at load:

    • in either list, a leading ! or #, a trailing space or a \, each of which include read as part of a name (a \ only where / is the separator);
    • in ignore, also a [ and the other forms listed under Changes, which include already refused apart from the [, and an ignore that isn't a list, such as null. include still reads a [ as itself.
  • Against git: a config copied from a .gitignore on macOS or Windows, where git folds case by default, can cover less here, where names compare exactly; where a ? falls on a character outside ASCII, it matches the whole character here, and one byte of its encoding in git; and on a mount that follows links, build/ covers a link build that leads to a directory, which git reads as a file and leaves alone.

  • connectome-host's recipes and agent-framework's defaults: none is in any of these groups. connectome-host's recipes set neither list, and agent-framework's direct-mount default is eight names, each read as before.

Tests

Requirements, and the tests that pin them, in test/workspace-pattern-forms.test.ts unless named:

  1. A trailing / in ignore passes over a directory of that name at any depth, and not a file of that name. A sync naming the directory says the list covers it, and one naming the file doesn't; the named directory's sync takes in what lies beneath it, a nested directory of the same name included (workspace: bare mount-wide sync reports totalSynced: 0 while per-path sync on the same file works — bulk and per-path disagree about reality in both directions #276).
  2. A sync naming a path where disk has a file, and history has entries beneath it, doesn't say a directory-only pattern covers the file.
  3. A listing naming a file that a directory-only pattern names doesn't say the list covers it. One naming such a directory that disk no longer holds, with history beneath it, does say so, with its advice, as a sync without a path reads that history as beneath a directory.
  4. A file the watcher reports, named like a directory-only pattern, is taken in. A control: it passes on main, where build/ matches nothing, and pins the kind the pass over reported paths states.
  5. A leading or inner / anchors a pattern at the mount's root.
  6. A leading **/ before one name matches it at any depth, with a trailing / only directories, and with a trailing /** as with a trailing /.
  7. A trailing /** passes over a directory from the root and what lies beneath it, and not a file of its name.
  8. * and ? match within a name, and *.ext as before. In ignore, a ? stands for a [ in a name, which an ignore pattern can't hold.
  9. On a mount that follows links, a link counts as what it leads to: a directory-only pattern covers a link to a directory, in a listing, in a named sync's note and in a sync without a path, and not a link to a file or one that leads nowhere.
  10. The watcher passes over a directory a trailing / names and reports a file of that name, and reports nothing when that directory is removed (test/workspace-watcher.test.ts, on chokidar's native and polling backends).
  11. In include, a trailing / covers what lies beneath a directory of that name at any depth, and not a file of that name; a leading or inner / covers a file by its path from the root, with * within a name; **/ before one name and / at both ends read as in ignore; a ? matches any one character, where 0.27.0 read it as itself; and a [ is read as itself, with * and ? beside it too. A file outside is refused by read.
  12. A pattern of another form is refused when the module is built, and the error names the mount, the pattern and why, then the forms read, for ignore and for include. test/workspace-include.test.ts's load checks are turned round: patterns it refused that it now reads, such as sub/file.txt, foo*, a/*/** and [id].tsx, load, and \ is refused where / is the separator too.
  13. ignore may be empty, and must be a list.

Runs, on Linux with Node v24.22.1:

  • The whole suite (npm run clean, npm run build, npx tsc --noEmit, npm test), at 2a37675708, this branch with main 139e8a9c merged in: 8,038 tests, 7,976 pass, 0 fail, 0 cancelled, 62 skipped. The build and the typecheck are clean. The change adds 22 tests: 18 in the new file, and 2 watcher tests, each run on both backends.

  • Red-first, against main's sources at 0a4a2c6979: 13 of the first commit's 16 tests in workspace-pattern-forms.test.ts fail, and so do its 4 watcher tests. Three pass there:

    • requirement 4, a control;
    • requirements 2 and 3, since on main build/ matches nothing at all. Each fails against the first commit, whose readings it's about.

    Each later commit's new tests were also run against the commit before it. Those for what it fixes fail there, and those it adds only as cover pass. The ? include test, added later, also fails on main's sources, run on its own: there te?t.txt covers only te?t.txt.

  • Removals, each run on this head's sources (workspace-pattern-forms.test.ts), each failing only the tests named:

    • with ignore's refusal of a [ removed: the refusal test;
    • with [ removed from the characters a glob escapes: the [ include test, as [id]*.tsx compiles to an unterminated class;
    • with the walk's reading of a followed link removed: requirement 9's test;
    • with a listing counting a scope disk doesn't hold as a file: requirement 3's new test;
    • with ? read as itself: the three ? tests, in ignore (requirement 8's two) and in include;
    • with the ** check put back before the empty-segment check: the refusal test, on **//x;
    • with a link that leads nowhere read as nothing there: requirement 9's test, on its listing.

Not verified

  • A link to a directory, on a mount that follows links, counts as a directory for the walk, a listing, a named sync's note, the watcher and a path through it, and requirement 9 pins the first three. The watcher's case is by reading chokidar 4.0.3; no test runs a link through it.

  • That the watcher doesn't watch a directory a directory-only pattern names is by a probe run at this head, not a test. On both backends, chokidar's getWatched() held neither out/, made before the watch started, nor deep/out/, made after it was ready, and held both once the watcher's check was given no directory kind. The probe, a run outside the suite, made out/ with a file before the watch, then deep/out/ and a control late/ after it was ready, and a file in each:

    • as built: the watched directories beneath the root were ., deep and late, and the one change reported was created late/z.txt;
    • with the check given no directory kind: they were ., deep, deep/out, late and out, and the one change reported was again created late/z.txt.

    What the watcher reports is the same either way, since each path beneath such a directory is passed over through it, so the tests, which check what's reported, can't tell the two apart.

  • Windows wasn't run: CI runs Linux and macOS. Paths there are read with \ as a separator, as before, and a pattern holding one is refused on every platform now.

What this leaves

  • The watcher on Windows passes over nothing, on main as here: chokidar 4 hands its ignored function a path spelled with / (matchPatterns → normalizePath), and the watcher compares that with its root spelled with \. So chokidar watches what the list covers, and each change there takes a pass, which passes it over by design. That's older than this change, and is workspace: on Windows, the watcher watches what a mount passes over by design #823.
  • connectome-host's RecipeWorkspaceMount.include doc (src/recipe.ts) still names the old forms. The host's own check passes any list of strings on to this module's, so a recipe holding a refused pattern stops loading with this module's error.

Overlaps

#624, now merged, conflicted in two places: the import list from observe.ts in index.ts, and the walk's call of passedOverBecause, where #624 renames nestedAtReal and changes the condition beside it, and this change adds the entry's kind and the read of a followed link after it. The merge of main 139e8a9c takes both sides of each. #624 adds no call of the functions this change gives a new argument.


  • Changelog fragment added: changelog.d/ignore-gitignore-forms.breaking.md.

Written by Phoebe-2419 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made its design calls, and Crispin-2406 (Claude Opus 5.5) read every commit.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Phoebe-2419 and others added 3 commits October 26, 2026 02:40
…se any other

Both lists now read one grammar, parsed once per pattern string in
observe.ts (parsePattern, cached in `readings`):
- a name, matched at any depth, with `*` and `?` within it (a regex per
  segment that holds either, in place of the `*.ext` special case in both
  matchers and the include's check);
- a trailing `/`: only a directory;
- a leading or inner `/`: a path from the mount's root, segment by segment;
- a leading `**/` before one name: that name at any depth;
- a trailing `/**`: anchored, and directory only, so `dir/**` no longer
  covers a file named `dir` (main's isIgnored matched the prefix whatever
  the entry was).
Anything else is refused when WorkspaceModule is built, for `ignore` as
for the include (anima-research#536's rule): a leading `!` or `#`, `[`, `\` (on every
platform, a tightening of the include on POSIX), a trailing space, `**`
anywhere else, an empty, `.` or `..` segment, a non-string. `ignore` may be
empty (`[]` is how a direct mount opts out of its default) but must be a
list. The refusal names the mount, the pattern, the parser's reason and the
forms read.

Refused rather than accepted with a warning: an ignore pattern the list
can't read ignores less than its author meant, so files meant to stay out
are tracked and synced. The list fails closed at load, as the include does.

The entry's kind: a directory-only pattern needs to know whether the last
segment is a directory; every segment above it is one. isIgnored,
coveredByIgnore, passedOverBecause and passedOverAt take a required
`directory` argument, so each caller states it:
- the walk: the dirent (a link is no directory there; the walk enters none);
- walkScope's scope, walkStart's start, ignoreBeneath's scope and
  passedOverBetween's segments: directories;
- reconcile's `own` in an unnamed paths pass: files or links, which is all
  the watcher reports;
- the sync's note for a named path: the kind the sync found there;
- the watcher: chokidar's stats. chokidar 4.0.3 calls `ignored` without
  stats in _addToNodeFs (before it stats; it asks again with stats before
  watching) and in _remove; readdirp's filters carry lstat stats. With no
  stats nothing directory-only matches; an ignored directory is never
  tracked, so _remove never reports it.
isIgnored now tests only the entry itself; what lies beneath is covered
through the callers' tests of each directory above a path, or by the walk
not descending.

coveredByInclude: a directory-only pattern covers files beneath a directory
it matches (`fixtures/`; `/docs/` is `docs/**`); any other covers a file by
its own name or path, and nothing beneath a directory it matches (anima-research#536).

Docs: MountConfig.ignore and .include rewritten. The changelog fragment is
breaking: a configuration that loads on 0.27.0 can be refused.

Tests: test/workspace-pattern-forms.test.ts (new), two watcher tests on
each backend, the include's load-check and `\` tests turned round, and a
workspace-three-way comment on `ignore: ['a/b']` reworded (it now covers
the nested mount's directory too; the walk still counts it as that
mount's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k, and read `**/x/**`

- A sync naming a path took, for its "this mount's ignore list covers it"
  note, the pass scope's isDir, which counts history beneath the path.
  Where disk holds a file there, that told the agent a directory-only
  pattern covered the file, though a sync without a path maintains it. The
  note now reads what disk holds at the path, and history's directory only
  where disk holds nothing, as a sync without a path reads stored entries
  beneath it (reconcile's `above`).
- A listing of a regular file that a directory-only pattern names said
  "ignored by the mount". walkScope now reads the scope's kind from disk,
  but only when a directory-only pattern alone names the scope itself
  (passedOverAt with directory=false finds nothing), so every other listing
  reads disk as before. Reading every listing's scope took over the point
  at which workspace-three-way's "a file standing where a directory was
  proves nothing once a directory is back" swaps the scope on its first
  lstat, and failed it.
- `**/x/**` combines the two `**` forms the grammar reads, and gitignore
  reads it as `x/`; parsePattern refused it with a message naming exactly
  those forms. A trailing `/**` now anchors only without a leading `**/`.
- walkScope's comment said a path rule (`cache/**`) matches what lies
  beneath, which isIgnored no longer does; the clause is cut.
- Texts: the changelog lists a leading `!` or `#`, `[`, a trailing space
  and `\` as refused in either list, with which of them the include read
  before, and says an include pattern holding `?` now covers more; the
  ignore doc's opening says each form covers the files gitignore's reading
  covers.

Tests: the named sync's note where history lies beneath a file; a listing
of such a file; `**/cache/**`; include `/docs/` and `**/cache/`; anima-research#276's
set-aside at any depth beneath a named directory; a batch the watcher hands
over naming `sub/build` (a control); one include refusal by exact text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ath's kind through followed links

- `include` reads a `[` as itself, keeping 0.27.0's documented promise that
  `[id].tsx` names that file, and reading it literally includes less than
  a gitignore class would, the safe direction for an include. `ignore`
  still refuses a `[`: read as itself, a class would ignore less than its
  author meant. So the `[` refusal moves from parsePattern to
  patternRefusal(pattern, list), and `[` joins the characters a glob
  segment escapes (without it, `[id]*.tsx` compiled to an unterminated
  class and threw from the constructor).
- directoryAt(path, followSymlinks): `stat` on a mount that follows links,
  `lstat` otherwise, as chokidar's stats are before it watches a path.
  walkScope (when a directory-only pattern alone names the scope) and the
  named sync's note read through it, so a listing, the note and the watcher
  agree about a link: a followed link to a directory counts as a directory,
  and a followed link to a file as a file. The sync's pass-scope isDir is
  main's again.
- Texts: the ignore doc and the changelog name where this matcher and git
  differ: names compare exactly, as git's do with core.ignorecase off
  (git's default folds case on macOS and Windows), and `?` matches one
  character where git's matches one byte. The changelog says each form
  covers the files git's reading covers. The include doc says `[` is read
  as itself, and the changelog lists `[` as refused in `ignore` only.

Tests: include `[id].tsx` and `[id]*.tsx` read literally; `ignore`'s
`?id].tsx` covers `[id].tsx` (the refusal's advice); on a mount that follows
links, a link to a directory is covered by `build/` in a listing and in the
named sync's note, and a link to a file named by `bin/` is not; the include
load check accepts `[id].tsx`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 2 medium severity findings can be fixed after the merge.

Reviewed commit d9cd000 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This fits. It extends the workspace mount's existing ignore and include options inside the module that already owns them.

5 reasons with sources

It gives both lists one parser, and it gives ignore the same check at build time that include already had. It comes with tests and a breaking changelog fragment written the way the project asks.

  • It changes behavior the workspace module already has, and adds no new feature area. ignore and include already exist in MountConfig and are matched in observe.ts. The change widens the forms they read and makes them refuse patterns they can't read. It adds no dependency, service or new public option. (src/modules/workspace/types.ts (MountConfig.ignore / include))
  • It replaces two separate pattern readers with one, so the matching is no longer done two ways. isIgnored and coveredByInclude each read patterns their own way, and includePatternReadable checked only include patterns. All three now go through parsePattern, and one check, patternRefusal, serves both lists when the module is built in index.ts. (src/modules/workspace/observe.ts)
  • Refusing unreadable ignore patterns follows what the include already does. On main, the include is already checked when the module is built and refuses a pattern its matcher can't read, because such a pattern would silently take files out of the mount. This change puts ignore under the same rule. (src/modules/workspace/index.ts (constructor mount checks), types.ts include doc)
  • The breaking change is declared the way the contributing guide asks. It adds a .breaking.md fragment that opens by naming who needs to act ("Hosts and operators whose workspace mounts set ignore or include"). The fragment says which configs now fail to load and what each list covers now that it didn't before. (changelog.d/README.md; CONTRIBUTING.md (Changelog))
  • It is one coherent change, and the tests that come with it cover the new forms. Every caller that now passes whether the entry is a directory (walk, listing, sync note, watcher, reconcile) exists to support the directory-only patterns. The tests are a new pattern-forms suite plus updates to the include and watcher tests. The separate code review notes gaps in the tests, which it will handle. (CONTRIBUTING.md (What a PR should contain))

Findings

2 findings of medium or high severity, see the inline comments:

4 more findings, less severe
  • Low severity, test coverage, src/modules/workspace/observe.ts:1949-1953. No test for a listing scope that a directory-only pattern names and that disk doesn't hold. walkScope's new branch keeps the ignored verdict when directoryAt returns undefined, i.e. nothing is on disk at the path. Per the description, that is because history beneath the path implies a directory. The new tests only cover the case where disk holds a file, so the listing is not called ignored. Nothing tests a scope that is absent from disk but present in history under build/, so the undefined case and its reasoning can regress unnoticed.
  • Low severity, test coverage, src/modules/workspace/observe.ts:1725-1729. No test that ? in include now matches any one character. The changelog warns that a ? in an include pattern, which used to be read as itself, now matches any one character, so such a mount covers more files than before. The glob() conversion handles ? the same way for both lists. The include tests in workspace-pattern-forms.test.ts only use * with [ ([id]*.tsx); ? is only tested in ignore. The behavior change the changelog calls out for include has no test.
  • Low severity, documentation, src/modules/workspace/observe.ts:1715-1723. A pattern like **//x is refused with the ** message instead of the empty-segment one. In parsePattern, anchored is computed from body.includes('/') before the leading / is removed. For **//x, anyDepth is true and the body /x is anchored, so the check anyDepth && anchored fires first. The error says '"" is read only as a leading "/" before one name...', but the real fault is the empty segment after **/. An operator following that message will look for a misplaced ** rather than the double slash.
  • Low severity, documentation, src/modules/workspace/observe.ts:1741-1753. New exported helpers patternRefusal and directoryAt have no doc comments. patternRefusal and directoryAt are new exports used from index.ts and the tests. The exported functions around them in observe.ts (e.g. coveredByIgnore, insideMount, passedOverAt) each have a doc comment on their contract. directoryAt in particular has a contract callers depend on: undefined when the path can't be read, and links followed only when followSymlinks is true. Callers such as walkScope and the sync note treat undefined differently, and nothing at the definition says what it means.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/modules/workspace/observe.ts Outdated
Comment on lines 2087 to 2088
const passedOver = passedOverBecause(childRel, entry.name, ignore, view.others, entry.isDirectory())
?? (entry.isDirectory() && nestedAtReal(dir.at(entry.name), view.nested) !== undefined ? OTHER_MOUNT : undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. On a mount that follows links, the walk judges a link by its dirent while the listing, sync note and watcher follow it.

The PR's rule is that on a mount with followSymlinks: true, a link counts as what it points to. directoryAt uses stat there, and the watcher gets chokidar's stat-based stats. The walk does not follow that rule: it calls passedOverBecause(..., entry.isDirectory()) with the readdir Dirent, and for a symlink that is always false. So with ignore: ['build/'] and a link build -> real/, ls work/build and sync {path:'work/build'} say the ignore list covers it, and the watcher passes over it. Yet a sync without a path, which walks the parent, does not report the link as ignored: it records it as a symlink leaf or LINK_NOT_WALKED. Each code path now gives a different answer for the same entry. The new test only checks the listing and the named sync, so it does not catch this.

mkdirSync(join(root, 'cache'), { recursive: true });
mkdirSync(join(root, 'drafts~'), { recursive: true });
for (const other of others) mkdirSync(join(root, other), { recursive: true });
for (const f of files) mkdirSync(join(root, dirname(f)), { recursive: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity, test coverage. Watcher tests now create every directory before the watch starts, so a matched directory made later is never tested.

reported() now makes dirname(f) for every file before it opens the watcher. In the new out/ test, out and deep/out therefore exist when chokidar does its first scan. The risky case is different: a directory named out created after the watch has started. There chokidar first calls ignored without stats, and the new code returns false (stats?.isDirectory() ?? false). Whether the directory is then left unwatched depends entirely on chokidar asking again with stats, which the PR argues from reading chokidar's source and does not test. Add a case that creates out/ with files after ready and checks that nothing beneath it is reported, on both backends.

Phoebe-2419 and others added 4 commits October 26, 2026 10:47
One conflict, the import line from ./observe.js in
src/modules/workspace/index.ts: main's line, with directoryAt and
patternRefusal added and includePatternReadable dropped, as this branch
changes them. main's side calls none of the functions whose signatures
this branch changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… "**//x" is refused for its empty segment

- On a mount that follows links, the walk judged a link by its dirent, so
  a link to a directory that a directory-only pattern names was listed as
  a symlink it doesn't walk, while a listing naming it, a sync naming it
  and the watcher read it as a directory and passed it over as the ignore
  list covering it. The walk now reads such a link with directoryAt, only
  where a directory-only pattern names it, and passes a link to a
  directory over as one. Every other entry is read as before.
- parsePattern checks for an empty, "." or ".." segment before the "**"
  forms, and keeps a "/" after a leading "**/" as an empty segment, so
  "**//x" is refused for that segment rather than with the "**" reason.

Tests: a sync without a path on a mount that follows links passes over
the link to a directory as ignored by the mount; a listing naming a
directory that a directory-only pattern names, which disk no longer holds,
with history beneath it, gives the ignored region and its advice; a "?" in
include matches any one character; "**//x"'s refusal text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g; links named as a difference from git

- directoryAt, on a mount that follows links, reads a link whose stat
  fails as no directory (an lstat finds the link), and keeps undefined for
  nothing there. A listing naming a link that leads nowhere, under a
  directory-only pattern, said the list covered it, while the walk, which
  passes over a link only when it leads to a directory, didn't. Now both,
  and a sync naming it, read it as no directory.
- MountConfig.ignore's doc and the changelog fragment name a third
  difference from git: on a mount that follows links, a link to a
  directory counts as a directory, where git reads every link as a file.

Test: requirement 9's test checks a listing of a link that leads nowhere,
with its name under a directory-only pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two conflicts, each resolved by keeping both sides:
- workspace/index.ts, the ./observe.js import: main's names, with this
  branch's directoryAt and patternRefusal added and includePatternReadable
  (which this branch removes) left out.
- workspace/observe.ts, walkScope's passed-over check: main's
  `!entry.isSymbolicLink() && nestedRootAtReal(...)` for another mount's
  directory, with this branch's entry-kind argument to passedOverBecause
  and its read of a followed link that a directory-only pattern names.
nissa-seru pushed a commit to nissa-seru/connectome-host that referenced this pull request Oct 26, 2026
… refuse

The include refusal test used `src/*.ts`, a form agent-framework's
workspace include reads once it takes gitignore's forms
(anima-research/agent-framework#825), so against that change nothing
throws. The test means to pin that a pattern the framework refuses stops
the recipe loading, in the framework's own words. It now uses
`src/../x`, which 0.27.0 and that change both refuse for its ".."
segment, and matches either version's sentence about it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 26, 2026
@nissa-seru
nissa-seru merged commit 8e0780e into anima-research:main Oct 26, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 26, 2026
main brought anima-research#826, anima-research#819, anima-research#824, anima-research#821, anima-research#825 (ignore and include read
gitignore's forms, and refuse any other), anima-research#815, anima-research#820 and anima-research#818 (answers that
write say where the change stands on disk). Where they met this change, in
src/modules/workspace/index.ts:

- the imports from observe.ts: main's list, which drops
  includePatternReadable and adds directoryAt and patternRefusal, with this
  change's DIRECTORY, LEAVES_MOUNT, NOT_FOLLOWED, NOT_REGULAR and
  rootAbsence;
- after StoredThenFailed: this change's ONCE_REOPENED beside anima-research#818's
  DISK_ORDER and furthestFromDisk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 27, 2026
268 commits from main, 28 pull requests: #353 (every agent on the store
re-initialised after a rollback, suppression, switch, undo or redo),
#817, #835, #818, #820, #815, #825, #821, #824 and the rest, with main's
manager-release machinery (an ephemeral run's auxiliary membrane, a
tracked manager released with its agent) and writeToFork (a fork's
channel message held behind a live surgery). Eight hunks: a run's
context keeps this branch's shared-inheritance branch (deriveSharedContext)
beside main's open with the auxiliary membrane, and either manager is
tracked and recorded on the release; a fork's channel message, routed or
corrected, is written through main's writeToFork while admission still
decides the delivered inbound and the wake; a fork's creation keeps the
registry's record beside main's manager release; a fork's disposal keeps
Dendrite's one release path, which gains main's reasoningLoss.forget and
releaseManager (and the name-only branch its forget). .gitignore and the
diagnostics test take main's words. #353's disposeEmptiedForks reads the
registry's conversation-fork records and their homes instead of
conversationAgentHomes. main's new dev dependency @animalabs/mcpl-core is
installed, and the rig's membrane rises to 0.7.2 (connectome-host main
asks for it).

Eight rows of main's new tests are read onto this branch, each named in
a comment: discord-awareness-outbox's fake frameworks carry the registry
stub an undo's fork sweep reads; turn-continuation's fork check reads the
registry's record; reinit-after-branch-move's fork tests match a fork's
stored channel message by its author-prefixed form and allow the
derivation notice a spawn tells its source; subconscious-model-fields
configures the persistent reader, the one that is an agent at create;
workspace-lost-contents' five torn-blob cases probe whether the linked
chronicle rewrites a blob whose bytes differ (chronicle main does, from
the #60 series; the released 0.4.0 keeps the file) and assert each
behaviour. Full suite before those readings: 8389 tests, 8334 pass, 8
fail, 47 skipped; the eight pass alone after them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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