At the end of the eslint() function in build/eslint.ts — the script npm run eslint actually executes in the microsoft/vscode repository — sit the three lines that decide whether a pull request can merge clean:
if (warningCount > 0 || errorCount > 0) { throw new Error(`eslint failed with ${warningCount + errorCount} warnings and/or errors`);}
It sums warnings and errors into one number and throws if that number is anything but zero. The distinction between the two severities — the one every ESLint config in the industry spends review cycles arguing about — has no effect on this gate whatsoever.
That is the detail that makes the VS Code layering rules real. The rules saying that platform may not import editor, that a file under a common/ folder may not touch the DOM, that electron-main may not reach into browser code, are all configured at severity 'warn'. In this repository, that word is a display choice governing the colour of a squiggle in your editor. The gate does not read it.
Most large codebases keep their architecture in a wiki page and then violate it quietly for years, because nothing standing between the page and the merge button ever reads the page. VS Code’s version is a data structure, checked by two lint rules that ship inside the repo, in a pass whose exit status is the merge condition. Every path, line and quotation below was read from the raw files on main on 4 September 2026, at repository version 1.138.0.
Two Axes Intersect at Every File Path
src/vs currently holds seven directories: base, code, editor, platform, server, sessions, workbench. Those are the layers. The Source Code Organization wiki page defines them in a line each — base provides “general utilities and user interface building blocks that can be used in any other layer”; platform “should not include editor or workbench specific services or code”; code is “the entry point to the desktop app that stitches everything together.”

Cutting across those is a second axis: the target environment, meaning which runtime APIs a file may assume. common is plain JavaScript that runs anywhere. browser assumes a DOM. node assumes Node.js. electron-browser assumes a DOM plus a narrow bridge to the Electron main process. electron-utility and electron-main assume Electron’s utility- and main-process APIs respectively.
One correction worth making loudly, because stale documentation and search summaries are thick with it: the current name is electron-browser, not electron-sandbox. Directory listings of src/vs/workbench, src/vs/code and src/vs/base/parts/sandbox taken on 4 September 2026 show electron-browser and no electron-sandbox anywhere, and the wiki’s target-environment list does not mention it either. electron-sandbox was a real name for a period around the 2022 sandboxing work. It is stale now.
The elegant part is that a file never declares its position on either axis. Its path already has. src/vs/platform/files/common/files.ts is platform layer, common environment, with no annotation, header or manifest entry saying so. code-layering.ts walks the file’s directory components from the right and stops at the first one that appears as a key in the rule’s configuration. The folder name is the declaration — which means the rule cannot drift out of sync with the file tree, and moving a file relocates its permissions.
What the Adjacency List Actually Forbids
local/code-layering‘s entire configuration in eslint.config.js is an adjacency list, reproduced here exactly:
'local/code-layering': [ 'warn', { 'common': [], 'node': ['common'], 'browser': ['common'], 'electron-browser': ['common', 'browser'], 'electron-utility': ['common', 'node'], 'electron-main': ['common', 'node', 'electron-utility'] }]
| Target environment | May import from | The refusal that matters |
|---|---|---|
common | nothing beyond itself | No DOM, no Node — it must run identically in a tab, a Node process and Electron |
browser | common | Cannot reach Node’s filesystem |
node | common | Cannot assume a document exists |
electron-browser | common, browser | Renderer side; no direct Node APIs |
electron-utility | common, node | No DOM |
electron-main | common, node, electron-utility | Not browser or electron-browser — the main process has no DOM to borrow |
When it fires, the message is specific enough to act on without opening documentation: Bad layering. You are not allowed to access {{from}} from here, allowed layers are: [{{allowed}}].
The layer axis is governed by a second rule, local/code-import-patterns, keyed by target glob. Immediately above its configuration block sits a comment that is the whole argument in ASCII:
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// !!! Do not relax these rules !!!// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!The comment directly beneath explains the /~ suffix: a path ending in /~ “indicates a template position which will be substituted with one or more layers,” and “when /~ is used in the target, the rule will be expanded to 14 distinct rules” — the target environments plus worker, doubled for their test/ counterparts. One written line becomes fourteen checks.
| Layer target | Allowed VS Code imports |
|---|---|
src/vs/base/~ | vs/base/~ only |
src/vs/platform/*/~ | vs/base/~, vs/base/parts/*/~, vs/platform/*/~ |
src/vs/editor/~ | the above, plus vs/editor/~ |
src/vs/workbench/~ | the above, plus vs/editor/contrib/*/~, vs/workbench/~, vs/workbench/services/*/~ |
src/vs/code/~ | base, platform, editor, vs/code/~ — and vs/workbench/~ only when: 'hasBrowser' |
src/vs/server/~ | base, platform, vs/workbench/~ with its api, services and contrib trees, vs/server/~ |
Read the platform row for what is missing: neither vs/editor nor vs/workbench appears, which is the mechanical form of the wiki’s prose sentence. Then read the code row for the conditional. The desktop entry point may import the workbench framework, but the rule engine only applies a when: 'hasBrowser' restriction to files whose target environment actually is a browser one. A file under src/vs/code/electron-main/ therefore cannot import the UI framework at all — not by convention, by arithmetic.
The Rule Denies by Default
Here is the part that does the most work and attracts the least attention. code-import-patterns.ts loops over its configured targets looking for one whose glob matches the file being linted. If none matches, it does not shrug and move on:
context.report({ loc: { line: 1, column: 0 }, messageId: 'badFilename'});
That message reads: “Missing definition in code-import-patterns for this file. Define rules at https://github.com/microsoft/vscode/blob/main/eslint.config.js”.
So the failure mode of an unconfigured file is denial, not exemption. You cannot add a directory to src/vs and have it quietly inherit permission to import anything; the first file you put there fails the lint pass at line 1 until somebody writes down what that directory may depend on. An architecture rule that only catches known-bad imports decays as the tree grows. This one makes growth itself the trigger.
The rule’s other three messages are worth listing, because two of them are load-bearing for a different migration entirely: badImport (“Imports violates ‘{{restrictions}}’ restrictions”), badAbsolute (“Imports have to be relative to support ESM”), and badExtension (“Imports have to end with .js or .css to support ESM”). The multi-year move off AMD is its own story and has its own article; the point here is that its constraints were executed by adding two more messages to a rule that already ran on every file in the repository.
Why 103 Rules Say warn and Only Seven Say error

Counting the severity strings in eslint.config.js on 4 September 2026 gives 103 occurrences of 'warn' against 7 of 'error'. That ratio looks like a codebase that gave up. It is the opposite. Once the CI wrapper treats any nonzero total as failure, the severity field stops meaning “how much do we mean this” and starts meaning only “which colour should the squiggle be while I am mid-refactor.”
That separation is genuinely useful. A layering violation mid-edit — you have moved a file and not yet moved its imports — is information, not an emergency, and a blocking red marker for a transient state is noise. VS Code keeps the gentler in-editor signal and an absolute gate, because it decoupled the label from the enforcement. If your team argues about warn versus error, the argument is usually misdirected: the question that decides anything is what your CI wrapper does with the totals.
The rules themselves live in .eslint-plugin-local/, which currently holds 49 custom rule files — 39 named code-*.ts, and 10 vscode-dts-*.ts policing the extension API’s own type definitions. Layering is not an exceptional investment in this repository. It is one of forty-nine.
“There Should Exist No Reason to Disable”
Every lint rule has an escape hatch, and in most codebases the escape hatch is where the architecture goes to die. // eslint-disable-next-line comments accumulate faster than anyone reviews them, and each one is a private, undocumented exception to a rule nobody can now trust.

On 6 September 2024, VS Code maintainer Johannes Rieken opened issue #227752, titled “Do not disable local/code-import-patterns ESLint rule.” The body, verbatim:
“There should exist no reason to disable the
local/code-import-patterns. You either need to move things into the right layer or update the configuration of the rule”
Underneath sat a checklist of 18 files carrying 21 suppression comments, each assigned to the owner of the code — terminal capabilities, the tree-sitter tokenization service, an editor-tokens inspector, a colour-registry release test. The framing is the load-bearing part. An inline disable is treated as a bug in the rule’s configuration, to be fixed centrally, rather than as a local judgment call. Either the file is in the wrong layer, or the allow-list is wrong. There is no third option in which the file is fine, the rule is fine, and everybody agrees to look away.
The issue closed on 24 March 2025 against the March 2025 milestone with every box ticked. Spot-checking three of the named files on main on 4 September 2026 — terminalInstance.ts, inspectEditorTokens.ts and commandDetectionCapability.ts — finds zero code-import-patterns suppressions remaining in any of them; a fourth, standaloneTreeSitterService.ts, no longer exists. The cleanup held for eighteen months.
The Boundary Ships as a Separate npm Package
A rule can be enforced and still be pointless. The evidence that this particular boundary carries real weight is that a subset of exactly this source tree is compiled, tree-shaken and published as an independent product.

build/lib/standalone.ts defines extractEditor(). build/gulpfile.editor.ts invokes it under a task named extract-editor-src, with three entry points — vs/editor/editor.main.ts, vs/editor/editor.worker.start.ts and vs/editor/common/services/editorWebWorkerMain.ts — at shakeLevel: 2, which is class-member granularity, writing into out-editor-src and then out-monaco-editor-core/esm/vs. The package it produces is named monaco-editor-core. The wider monaco-editor package comes from a separate repository that, in the wiki’s words, “adds some programming language features on top of the Monaco Editor Core and publishes the combination as an npm package.”
The extraction’s real boundary specification is src/tsconfig.monaco.json, whose include list is five globs:
"vs/editor/*","vs/base/common/*","vs/base/browser/*","vs/platform/*/common/*","vs/platform/*/browser/*"vs/workbench does not appear. It cannot appear, because local/code-import-patterns has never permitted an editor-layer file to import one. A widespread retelling has it that editor imports nothing from platform either — that is wrong, and the config says so plainly: src/vs/editor/~ may import vs/platform/*/~. What actually leaves the repository is editor, plus base, plus a filtered slice of platform narrow enough to survive outside its home, with tsconfig.monaco.json‘s exclude list dropping vs/platform/telemetry/*, vs/platform/terminal/*, vs/platform/assignment/* and vs/platform/externalTerminal/*.
The boundary is guarded from the other side too. A dedicated rule, code-no-standalone-editor.ts, exempts vs/editor and then reports every import of vs/editor/standalone/, vs/editor/editor.api, vs/editor/editor.main or vs/editor/editor.worker.start from anywhere else, with the message “Not allowed to import standalone editor modules.” The workbench cannot reach into the standalone shims; the editor cannot reach up into the workbench. That symmetry is why extractEditor() runs at all.
This is what an enforced boundary buys that a documented one cannot. Nobody has to remember it. Nobody has to review for it. It is checked on every file on every pass — and the proof it held is that a second shipping product falls out of the tree on demand.
Where the Config Admits Its Own Exceptions
Honesty about exceptions is part of why the rule survives. The src/vs/workbench/~ restriction list carries a conditional entry allowing vs/workbench/contrib/*/~ in test files, tagged // TODO@layers. The platform allow-list names specific npm packages permitted even inside common/ — tas-client, @microsoft/1ds-core-js, @xterm/headless. src/vs/platform/agentHost/~ carries a longer list still, including @anthropic-ai/sdk and @modelcontextprotocol/sdk/**/*, which is quiet evidence that the newest agent features went in through the existing machinery rather than around it. And the sessions layer, which postdates the wiki’s layer list, ships its own LAYERS.md.
Each of those is a written, reviewable, centrally-located exception with a name attached. That is a different object from an inline disable in a file nobody opens again. The rule has not stayed pure. It has stayed auditable, which is the property that actually matters.
Layering also does not do everything, and the repo does not pretend otherwise. Import cycles within a layer are a separate problem, so build/lib/checkCyclicDependencies.ts remains a separate build gate walking the compiled dependency graph. Two differently-shaped tools, because one rule does not cover both.
Adopting This Without Copying the Scale
The transferable mechanism is two pieces, and both are cheap.
- Write the allow-list as data. A map from directory name to the set of directories it may import from. If your system has genuinely different runtime environments — server versus client, main versus worker, trusted versus untrusted — that map already exists in somebody’s head. Move it into a config file a linter reads.
- Make the CI wrapper, not the severity label, the gate. Whatever your lint runner is, have it fail on a nonzero total. Then you can set the rule to
warn, keep editors calm during refactors, and still have an absolute boundary.
Two refinements are worth stealing outright. Deny by default — if a file matches no rule, report it, so new directories cannot inherit unlimited permissions through omission. And treat suppressions as configuration bugs: when someone needs an inline disable, the answer is to move the file or amend the allow-list, and both of those are visible in one place at review time.
Where this is overkill is easy to state, and worth stating plainly. If your codebase has one runtime environment and a few dozen modules, your adjacency list has two rows and you have built a machine to enforce something you could hold in your head. The cost is never the initial rule — it is config drift, exception review, and the day a legitimate refactor is blocked by a rule nobody feels authorised to change. VS Code’s 49 custom lint rules, seven layers and 14-way template expansion are what a boundary-first architecture looks like after a decade of growth under real constraints, not a starting configuration. The transferable idea is enforce your boundaries mechanically, early. It is not have this many boundaries.
The one part worth doing on day one at any size is the CI wrapper. It costs three lines, and it is what converts every rule you add afterwards from advice into a boundary. That is the whole trick behind the VS Code layering rules: not the adjacency list, which any team could write in an afternoon, but the fact that nothing in the repository can merge past it.
❓ Frequently Asked Questions
If the layering rules are set to 'warn', can a violation actually merge? No. build/eslint.ts sums warningCount and errorCount across every linted file and throws when the total exceeds zero. A single layering warning fails the lint pass, and therefore the build. The severity string only changes how the problem is rendered in an editor.
What happens if I add a file to a directory nobody has written a rule for? It fails. code-import-patterns iterates its configured targets and, when no glob matches the file, reports badFilename at line 1, column 0 — “Missing definition in code-import-patterns for this file.” A new directory must declare its import budget before its first file can compile clean.
Can a file just suppress the rule with an inline comment? Mechanically yes; as policy, no. Issue #227752 (opened 6 September 2024, closed 24 March 2025) catalogued 18 files carrying such suppressions and required each to be resolved by moving the code or amending the rule’s configuration, on the stated principle that “there should exist no reason to disable the local/code-import-patterns.”
Do the layering rules prevent circular imports? Not on their own. They govern which layer and which target environment a file may import from, which leaves cycles inside a single layer entirely possible. build/lib/checkCyclicDependencies.ts is a separate build step that walks the compiled dependency graph and fails on any cycle it finds.
Is electron-sandbox still one of the target environments? No. As of 4 September 2026 both the source tree and the Source Code Organization wiki use electron-browser, and electron-sandbox appears nowhere under src/vs. The name was real around the 2022 process-sandboxing work, and a lot of secondary documentation still carries it.
Does the editor layer really import nothing from platform? That claim is repeated widely and it is wrong. eslint.config.js grants src/vs/editor/~ access to vs/base/~, vs/base/parts/*/~, vs/platform/*/~ and vs/editor/~. The layer it may never import is workbench — which is exactly the constraint extractEditor() depends on, and src/tsconfig.monaco.json reflects it by including vs/platform/*/common/* and vs/platform/*/browser/* while omitting vs/workbench entirely.
📦 Recommended Resources
Some links below are Amazon affiliate links. If you buy through them, this site earns a small commission at no extra cost to you.
Start with the free primary material, because on this specific subject it is better than any book. eslint.config.js, .eslint-plugin-local/code-layering.ts, .eslint-plugin-local/code-import-patterns.ts and build/eslint.ts in the microsoft/vscode repository are about forty minutes of reading and they contain the entire mechanism. The Source Code Organization wiki page gives you the vocabulary first. Read those before you buy anything. The three books below are for the layer underneath — deciding which boundaries are worth enforcing, and what to do when your codebase does not have any yet.
- Building Evolutionary Architectures: Automated Software Governance by Neal Ford, Rebecca Parsons and Patrick Kua (O’Reilly, 2nd edition, 2022) — the subtitle is this article’s argument. The book’s central idea is the fitness function: an automated, objective check that an architectural characteristic still holds, run continuously rather than reviewed occasionally.
local/code-import-patternsbehind a CI wrapper that fails on nonzero is precisely one of these, and the book gives you the framework for deciding which of your own characteristics deserve the same treatment. The most directly applicable book on this list.
- A Philosophy of Software Design, 2nd Edition by John Ousterhout (Yaknyam Press, 2021) — short, opinionated, and useful for the question the layering rule cannot answer: whether the boundary you are about to enforce is in the right place. Ousterhout’s “deep modules” argument — narrow interface, substantial implementation — explains why
commonbeing allowed to import nothing is a feature rather than an inconvenience. Read it as the design counterpart to the enforcement mechanics.
- Working Effectively with Legacy Code by Michael Feathers (Prentice Hall, 2004) — for the far more common situation, which is that you want an enforced boundary and your codebase currently has nothing you could draw one around. Feathers is about finding seams and breaking dependencies incrementally, on a system that has to keep shipping the whole time. Dated in its tooling and entirely undated in its method. VS Code’s own two-year AMD-to-ESM migration was this kind of work at scale.
✅ Pro tip: before buying anything, spend ten minutes running your own lint pass and reading what your CI wrapper does with the result. If a nonzero warning count does not fail your build, every “enforced” rule you have is advisory — and that is a three-line fix, not a book-length one.