Before you start contributing, let's learn a bit about the structure of the Bilt codebase.
First off, Bilt is a monorepo itself, and it is built using Bilt! (dogfooding is great!) So the first thing we need to understand, is that while the Bilt CLI is one package, it actually uses about 10 packages overall for its functionality.
All bilt code is in the packages directory, in a flat hierarchy. Let's go over what those packages are. We'll go top down.
But before that, there is the site itself. You can find information about contributing to it in the contributing doc there.
The main package is (cli
)[../packages/cli]. It includes the CLI that is used to run Bilt and all
the "UI" that is needed to determine what to build, to run the build, and to show the result. The
actual logic of determining which packages to build, and determining their build order is in
three packages:
-
packages-to-build
is the package with the algorithm that uses the dependency graph to determine which packages need building. It exports one functioncalculatePackagesToBuild
which receives all the package information, including the dependencies, and returns an array of packages that need building. In order to do taht, it also gets information about the last build time of each package to determine whether it (and its dependents) need to be built. -
build
: once we have that information (of which packages to build), we need to build them in a specific order, according to the dependencies between them. This is what this package does. It has a main functionbuild
that executes a JS function for each package that needs to be built. It also returns success/failure information while it is executing about each package that was built. This is done because it is an "async generator" that can return information to the caller while it is executing. -
[
build-with-configuration
]: this package determines what to do on each step of the build. It can read the configuration (.biltrc
) determine the steps to invoke before the build of the packages, the steps for each package, and what to do after the package is built. It is the package that is responsible for running the build of a package.
To truly understand the algorithms in these packages, see How Bilt Works. But, frankly, most of the code their is done, and the features and bugs are elsewhere.
These packages are lower-level still and support CLI in its quest to give the correct information that the "build order" packages need. That information is around i) dependencies, and ii) last build time. So... NPM information and Git information. Which brings me to the packages:
npm-packages
: the main function in this package isfindNpmPackageInfos
, which, given a set of package directories, finds the name, version, and dependencies of each package.git-packages
: the main function in this package isfindLatestPackageChanges
that returns the list of packages, and their last build time, according to information in Git (see How Bilt Works to understand how it does that).
Another package is npm-next-version
:
this package is used by Bilt in the default build steps it has,
and its purpose is to determine what the "next" version of a package should be when it is published.
This package includes TypeScript types that are used by all of the above packages. I usually dislike packages that have stuff for all packages, but, hey, we're all human, right? I've tried distributing the types more as time goes by, and not adding any more types here.
There's a lot of testing being done in the codebase, and to support that, we have the "testkit" packages, which are packages that have functionality needed for testing.
npm-testkit
: used to test functionality around npm install and publish. Exports thestartNpmRegistry
function that starts an NPM registry (Verdaccio, that can be used by whatevernpm install/publish
code you have to publish to it. This is used because publishing to the public npm registry just to check functionality just doesn't make sense.git-testkit
: used to create git repositories and manipulate them. Many small utilities around git.
scripting-commons
: a package including code that executes processes, creates temporary directories, and reads and write files, all in a nice and easy to use way.
While you could use any IDE/Editor, I recommend Visual Studio Code as their is specific stuff in the monorepo for this editor. The explanations in this page assume VSCode.
To code, open VSCode, and open the workspace file in the root of the repo. This will open a workspace with all the packages as projects in the workspace. I would heartily recommend installing the ESLint extension because all packages in the monorepo use ESLint and it's nice to see the warnings and errors as you code. I would also recommend installing the Prettier extension for the same reason.
Note that ESlint won't work until you npm install
the package with the code, because the ESlint
extension uses the local ESLint of the package.
Once you've opened the workspace, it's time to also install Bilt itself, because Bilt is used to build Bilt (say it fast 10 times!). That's easy. Just:
npm install -g @bilt/cli
To see whether it works, just do bilt --version
.
When using Bilt, you are constantly publishing packages in this monorepo, so that other packages
can use them. When developing in the main
branch, that makes sense, but it does not make sense
when developing in a branch, i.e. in a Pull Request. To deal with that, the usual method is
to run an NPM registry locally, and to ensure that all Bilt packages are published to it. For this,
there is a special directory, dev
that has scripts to enable this. So before developing,
do the following:
# from the root of the repository
cd dev
npm install
npm run develop-in-branch
This will run Verdaccio (an NPM registry), and ensure that all publishes of bilt
packages
will publish to it and not to the public NPM registry. Now you can develop to your hearts content.
It is important not to close the window that ran this registry, so that it will always run while
you're developing.
To kill Verdaccio and restore everything to what it was, do:
# from the root of the repository
cd dev
npm run develop-in-main
Now let's go over the structure of a package to see how to develop in one package:
Now that we've gotten past the responsibility of each package, let's discuss each package. All packages have the exact same structure, and the exact same lifecycle. Let's talk about the lifecycle, meaning how do I install, build, test, and publish them:
When you first want to start working with a package, you need to install all dependencies. The usual will work:
npm install
Now you're ready to code. Once you've npm install
-ed it, you can start
coding and running the tests. Bilt was developed with a very simple methodology in mind: you never
run Bilt to see that it works. Rather, you write tests to ensure that the code you wrote works.
So the methodology is simple:
- Create a branch and a Pull Request, in regular OSS manner.
- Write code.
- Write tests that check the code.
- Run the tests and fix the code until they pass (see below on how to run the tests).
- Done? Run
bilt . -m "<commit-message>"
on the package.- Remember running
npm run develop-in-branch
? That command makes sure that when bilt builds the package, it will publish it only to the local registry and not to the global public NPM registry.
- Remember running
- This will also commit and push your changes, and the CI will build all of Bilt based on your change.
There are two methods to run the test: a "full" one, and a "quick" one. The full one is to run
npm test
This will concurrently run ESLint, TypeScript (why TypeScript? See below),
and the Mocha tests (that are also run in parallel). This is great for CI, and for ensuring that everything passes, but bad for day to day development.
To run only the Mocha tests, and serially, use npm run mocha -- -b
(I have an alias called
qt
for that). The -b
tells Mocha to bail and stop on the first test failure.
Also, usually, I am working on a single test, and it's nice to run just that test instead
of all of them. To do that, goto that test in the code, and add .only
to the it
:
it.only("should do something great", () => {});
This tells Mocha to run only that test. You can also attach a .only
to the describe
to run
a group of tests.
Since we're running our code via tests, I created a launch configuration that runs Mocha on that package. To run it, goto the Debug pane in VSCode, and choose the configuration "Mocha Tests (<name-of-package>)". Now put breakpoints wherever you want, and launch the debugger, either by clicking on the "run" icon or by pressing <F5>.
The debugger will run all the tests (you will probably have a .only
somewhere to run only
one test), and stop on your breakpoint. Now start debugging with ease!
Of course, console.log
-ing your way also works... 😎. Just don't forget to remove them
before pushing. Tip: a good way of not forgetting is to add a comment to the console.log
in the form of // @@@<name>
.
The @@@
in the comment will cause ESLint to fail, so it won't let you push the change.
While theoretically you can git commit
and git push
yourself, the best and safest way
to do it would be to use bilt
itself:
bilt . -m "<commit message>"
The .
in the command tells Bilt to build only the current package. What it will do is:
npm install
npm update
to update all dependencies in the code (it does only semver-minor updates, which are 99.99% of the time safe)- Update the version of the package using
npm-next-version
npm run build
to run whatever build steps are necessary. All the packages in Bilt only use this step to generate.d.ts
files for TypeScript use (see below section on TypeScript to understand what this means). If you have a type error that TypeScript catches, it will catch it here.npm test
to run all the tests. This runs ESLint, TypeScript, and Mocha in parallel (to save time)npm publish
to publish the package. Remember, we ran a local registry so that this publish won't go to the global NPM registry, but rather to the local one running on your machine.git add . && git commit
to commit the changes in this directorygit push
to push the changes
This is, in essence, a full build of only that package, and it's best to use Bilt to ensure that a full build passes. Don't worry: it usually takes less than a minute.
Once your code is pushed, Bilt in CI will wake up and build all the dependencies of that package,
assuming there are any. For example, if you changed the build-with-configuration
and pushed it,
CI will also build the cli
package, because the cli
package
depends on build-with-configuration
.
To see the build you triggered in action, and to ensure it doesn't fail, goto the "Actions" tab in the Github monorepo: https://github.com/giltayar/bilt/actions.
What if a bug or a feature spans two packages? This definitely does happen, although not as commonly as you would think.
There are two ways to do that. Let's take an example. Let's say you want to add
functionality to the cli
package, but for that you want to add a function to scripting-commons
.
In other words, you want to develop the cli
and scripting-commons
together.
The recommended way is to first add the functionality to scripting-commons
, write the tests,
make them run, and then Bilt it using bilt . -m "<commit message>"
. This will publish the
package to the local NPM registry you installed previously. Now that you've finished doing that,
you can goto the cli
package, and run npm update
to update the scripting-commons
dependency
to the newer version, and continue with the feature there.
The alternative, but less recommended way, is to use
npm link
.
This command links two packages together.
The only thing you need to do here is execute the following:
cd packages/cli
npm link ../scripting-commons
This will link cli
to scripting-commons
: any changes you do to scripting-commons
will
immediately be seen by cli
.
Note: any
npm install
you run incli
will erase the link and you will go back to using the latestscripting-commons
in the registry. Also note: when you change the type signature of something inscripting-commons
, you should runnpm run build
there so that the.d.ts
files will be regenerated, so thatcli
can see the new type signature (see "JSDoc typing" below). Also note: don't try to do it on more than two packages, because Node and NPM get confused if there are too many links.
Let's look at the source code of Bilt packages. The structure of them all is the same, so
we'll take cli
as an example.
The two most important directories there are src
and test
. src
will include all
source code of the package, and test
will include the tests for the package.
The third most important file is package.json
, which you all know, and which
points to the main file of the package (the "entry point" to the package, which is what
we run when we import
the package), and which includes all dependencies and dev dependencies.
There are also lots of configuration files in the root directory, most of them "dotfiles" (i.e. starting with "dot"). You can usually ignore them.
The main file here is cli.js
. If you look at the package.json
"main", you will see that this
file is the entry point to the package and is what you import when you import
the package.
All the other source files in src
are usually import
-ed directly or indirectly by cli.js
.
The bilt
package is a bit different in that it includes another "entry point": run-bilt.js
.
This is what is executed when you run bilt
in the command line (you can see that the
package.json
has "bin"
that points to it). But run-bilt.js
is a small wrapper that
just imports and calls the main function in bilt.js
, so you can usually safely ignore it.
To understand the bilt
CLI and how it works, goto the design of CLI in
design.md
All source code is written in JavaScript, but has JSDocs that give full type information that
is typechecked by TypeScript. See "JSDoc Typing" below. It also doesn't use
CommonJS (require(...)
) to import modules and packages, but rather the newer ESM that
uses import
to do that. See "ESM" for more information.
The test directory includes subdirectories for the three main types of tests. Not all packages include all kinds of tests. The three types are:
unit
: simple tests that test one function or one simple module. Easiest to understand and easiest to add to, but give the least confidence.integ
: "integration" tests that tests part or all of a package, using internal interfaces. For example, incli
, it will test the command line through the function incli.js
and not by executing therun-cli.js
process, as a user would.e2e
: tests the whole package, as a user would. In thecli
case, it runs therun-cli.js
as a process and checks the output. We try to minimize the number of e2e tests to a mininum and have most tests beinteg
orunit
.
Bilt uses Mocha and Chai for all its testing.
All source code in all Bilt packages uses JSDoc typing, to fully typecheck the code with TypeScript, but without the need to transpile. To understand how to use JSDoc Typing, read this.
But don't worry: you can incrementally learn about it, and it should be usually pretty
straightforward, especially if you've used TypeScript in the past. One thing you should NOT
do is use //@ts-ignore-error
to ignore typechecking errors, unless you know that this is fine.
I would suggest DM-ing one of the maintainers of the project if you feel a //@ts-ignore-error
is warranted. In 99% of the cases, it isn't.
Bilt also uses the new ES Modules support in Node.js (which is why it won't work in Node.js versions less than 12). If you want to learn more about it, read about it here.
Two rules to remember:
-
When you import another file, you must include the extension of the file:
import './another-file' // ERROR: won't work! import './another-file.js` // Yay!!!
-
You can import packages that are not ESM, but if you're using named imports e.g.
import {namedImport} from 'some-package
, then for some packages you may get an error saying you can't import them using named imports. In that case, do the following:import somePackage from "some-package"; const { namedImport } = somePackage;
-
Function and variable names can (and should be) as long as needed to explain what's in them. Don't hesitate to go overboard with long names. Definitely no shortcuts. The one exception are parameter names for small anonymous functions that you pass to
filter
andmap
, because in that case, the parameter is obvious from usage. -
The code should "breath": separate functionality in a function with an empty line. Separate the variable declarations from the code with an empty line. Let the code "breath" by not having it cramped together.
That's it for the guide! If you want to dive into the codebase, I would recommend
understanding the topmost package: cli
, by reading its design document
here.