Repository navigation
feat(workspace)!: ignore and include read gitignore's forms, and refuse any other - #825
Conversation
…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>
There was a problem hiding this comment.
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.
ignoreandincludealready 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.
isIgnoredandcoveredByIncludeeach read patterns their own way, andincludePatternReadablechecked only include patterns. All three now go throughparsePattern, and one check,patternRefusal, serves both lists when the module is built in index.ts. (src/modules/workspace/observe.ts) - Refusing unreadable
ignorepatterns 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 putsignoreunder 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.mdfragment that opens by naming who needs to act ("Hosts and operators whose workspace mounts setignoreorinclude"). 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:
- 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.
- Medium severity, test coverage. Watcher tests now create every directory before the watch starts, so a matched directory made later is never tested.
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 whendirectoryAtreturns 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 underbuild/, so theundefinedcase 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 anincludepattern, which used to be read as itself, now matches any one character, so such a mount covers more files than before. Theglob()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 inignore. 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**//xis refused with the**message instead of the empty-segment one. In parsePattern,anchoredis computed frombody.includes('/')before the leading/is removed. For**//x,anyDepthis true and the body/xis anchored, so the checkanyDepth && anchoredfires 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 helperspatternRefusalanddirectoryAthave no doc comments.patternRefusalanddirectoryAtare 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.directoryAtin particular has a contract callers depend on: undefined when the path can't be read, and links followed only whenfollowSymlinksis 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.
| const passedOver = passedOverBecause(childRel, entry.name, ignore, view.others, entry.isDirectory()) | ||
| ?? (entry.isDirectory() && nestedAtReal(dir.at(entry.name), view.nested) !== undefined ? OTHER_MOUNT : undefined); |
There was a problem hiding this comment.
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 }); |
There was a problem hiding this comment.
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.
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.
… 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>
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>
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>
TL;DR: a workspace mount's
ignoreandincludenow read the gitignore forms a config copied from a.gitignoreuses:node_modules/,/dist,a/b,**/cache,npm-debug.log*. A pattern of any other form, such as!dist, or[Bb]in/inignore, stops the module from being built, with an error naming the mount, the pattern and why, whereignoreused to match nothing without a word.Problem
On
main(0a4a2c6979),ignoreread 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 atmain(MountConfigpatterns over eight paths, throughobserve.ts's owncoveredByIgnoreandincludePatternReadable) showed:node_modules/,dist/,/dist,**/distandsrc/*.tscovered nothing inignore;node_modules/**covered only the mount root'snode_modules, notpkg/node_modules.So a mount configured with
node_modules/, the form.gitignorefiles use, synced and watched every file innode_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.ignorehad no such check, and the same mistake there is silent.Changes
One grammar for both lists (
parsePatterninobserve.ts, read once per pattern string):*matching any run of characters and?any one within it;/: only a directory;/: a path from the mount's root, segment by segment;**/before one name: that name at any depth, and with a trailing/**as well, as the name with 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.ignorecaseoff (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, asmain's did, where git matches only what lies inside it; the files covered are the same. A run ofgit check-ignore(git 2.47.3,core.ignorecaseoff) 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 ofgit check-ignore(git 2.55.0), which matchedbuild/against neither a link to a directory nor a link that leads nowhere.ignorecovers an entry that a pattern matches, and what lies beneath it, as before.dir/**no longer covers a file nameddir:main'sisIgnoredmatched the prefix whatever the entry was.includereads 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, sofixtures/covers every file beneath a directory namedfixtures, 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), wheremainchecked only the include:!or#, a\, a trailing space,**anywhere else, an empty,.or..segment, or a value that isn't a string;ignore, a[. A gitignore class read as itself would ignore less than its author meant, while inincludeit includes less, the safe direction there;ignorethat 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: inignore,?id].tsxcovers[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,passedOverBecauseandcoveredByIgnoretake that as a required argument, so each caller states it:directoryAtreads where it leads;directoryAt:staton a mount that follows links,lstatotherwise), a scope disk doesn't hold counting as a directory, as any history beneath it implies;ignoreBeneath's scope, are directories;directoryAttoo, or, where disk holds nothing, a directory when history holds entries beneath it, as a sync without a path reads such entries;staton a mount that follows links andlstatotherwise (statMethods[wh.statMethod]), asdirectoryAtreads. 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.ignoreand.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 andcoveredByInclude's separate readings andincludePatternReadable.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 changeMountView.ignore's type and every caller's, for the same effect.*and?within a name: one regex per globbed segment, replacing the two matchers'*.extcases and the include's check of where a*may stand. GitHub's Node.gitignoretemplate hasnpm-debug.log*and.env.*, besidenode_modules/, and?gives a name holding[or\a way to be named.The required
directoryargument:build/means directories named build, and not a build script namedbuild. The alternative, readingdir/as the namedir, 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 makesfixtures/and/docs/mean something in a list that covers files; one loop over a path's directories. Refusing them instead would leaveincludereading a smaller set of forms thanignoreunder one parser.directoryAt, the kind of what disk holds at a path: one helper,staton a mount that follows links andlstatotherwise, 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 filebuildunderbuild/doesn't say the list covers it; every other listing reads disk as onmain. A named sync calls it once, for its note.[read as itself inincludeand refused inignore: one condition inpatternRefusal, 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 inignoreis refused, since read as itself it would ignore less than meant.Tests:
workspace-pattern-forms.test.tsbrings its own smallEnv(a mount over a temporary directory with given files),callandsynced(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:
**between names (a/**/b,**/a/b);.gitignorefiles 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
ignoreorinclude. Old beside new:Configs that now ignore more:
dir/;/xanda/b;**/x, and**/x/**;*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:
!or#, a trailing space or a\, each of whichincluderead as part of a name (a\only where/is the separator);ignore, also a[and the other forms listed under Changes, whichincludealready refused apart from the[, and anignorethat isn't a list, such asnull.includestill reads a[as itself.Against git: a config copied from a
.gitignoreon 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 linkbuildthat 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.tsunless named:/inignorepasses 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).main, wherebuild/matches nothing, and pins the kind the pass over reported paths states./anchors a pattern at the mount's root.**/before one name matches it at any depth, with a trailing/only directories, and with a trailing/**as with a trailing/./**passes over a directory from the root and what lies beneath it, and not a file of its name.*and?match within a name, and*.extas before. Inignore, a?stands for a[in a name, which an ignore pattern can't hold./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).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 inignore; 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 byread.ignoreand forinclude.test/workspace-include.test.ts's load checks are turned round: patterns it refused that it now reads, such assub/file.txt,foo*,a/*/**and[id].tsx, load, and\is refused where/is the separator too.ignoremay 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), at2a37675708, this branch withmain139e8a9cmerged 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 at0a4a2c6979: 13 of the first commit's 16 tests inworkspace-pattern-forms.test.tsfail, and so do its 4 watcher tests. Three pass there:mainbuild/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 onmain's sources, run on its own: therete?t.txtcovers onlyte?t.txt.Removals, each run on this head's sources (
workspace-pattern-forms.test.ts), each failing only the tests named:ignore's refusal of a[removed: the refusal test;[removed from the characters a glob escapes: the[include test, as[id]*.tsxcompiles to an unterminated class;?read as itself: the three?tests, inignore(requirement 8's two) and ininclude;**check put back before the empty-segment check: the refusal test, on**//x;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 neitherout/, made before the watch started, nordeep/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, madeout/with a file before the watch, thendeep/out/and a controllate/after it was ready, and a file in each:.,deepandlate, and the one change reported wascreated late/z.txt;.,deep,deep/out,lateandout, and the one change reported was againcreated 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
mainas here: chokidar 4 hands itsignoredfunction 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.RecipeWorkspaceMount.includedoc (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.tsinindex.ts, and the walk's call ofpassedOverBecause, where #624 renamesnestedAtRealand changes the condition beside it, and this change adds the entry's kind and the read of a followed link after it. The merge ofmain139e8a9ctakes both sides of each. #624 adds no call of the functions this change gives a new argument.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-seruaccount.