Skip to content

Commit dd4e55c

Browse files
iclantonclaude
andauthored
[heft-config-file] Improve README documentation (#5740)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 92f3f2a commit dd4e55c

File tree

2 files changed

+344
-1
lines changed

2 files changed

+344
-1
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft-config-file",
5+
"comment": "Improve documentation in README.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-config-file"
10+
}

libraries/heft-config-file/README.md

Lines changed: 334 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# @rushstack/heft-config-file
22

3-
A library for loading config files for use with the [Heft](https://rushstack.io/pages/heft/overview/) build system.
3+
A library for loading JSON configuration files in the [Heft](https://rushstack.io/pages/heft/overview/) build
4+
system. It supports `extends`-based inheritance between config files, configurable property merge strategies,
5+
automatic path resolution, and JSON schema validation.
46

57
## Links
68

@@ -10,3 +12,334 @@ A library for loading config files for use with the [Heft](https://rushstack.io/
1012
- [API Reference](https://api.rushstack.io/pages/heft-config-file/)
1113

1214
Heft is part of the [Rush Stack](https://rushstack.io/) family of projects.
15+
16+
---
17+
18+
## Overview
19+
20+
`@rushstack/heft-config-file` provides a structured way to load JSON config files that:
21+
22+
- **Extend** a parent config file via an `"extends"` field (including across packages)
23+
- **Merge** parent and child properties with configurable inheritance strategies (append, merge, replace, or custom)
24+
- **Resolve paths** in property values relative to the config file, project root, or via Node.js module resolution
25+
- **Validate** the merged result against a JSON schema
26+
- **Support rigs** by falling back to a [rig package](https://rushstack.io/pages/heft/rig_packages/) profile if the project doesn't have its own config file
27+
28+
---
29+
30+
## For config file authors (Heft users)
31+
32+
If you're writing or customizing a config file that uses this system (e.g. `heft.json`, `typescript.json`, or
33+
a plugin's config file), here's what you need to know.
34+
35+
### The `extends` field
36+
37+
Config files can inherit from a parent file using `"extends"`:
38+
39+
```json
40+
{
41+
"$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json",
42+
"extends": "@my-company/build-config/config/heft.json"
43+
}
44+
```
45+
46+
The `"extends"` value is resolved using Node.js module resolution, so it can be:
47+
48+
- A relative path: `"extends": "../shared/base.json"`
49+
- A package reference: `"extends": "@my-company/rig/profiles/default/config/heft.json"`
50+
51+
Circular `extends` chains are detected and will throw an error.
52+
53+
### How property inheritance works
54+
55+
When a child config extends a parent, each top-level property is merged according to its **inheritance type**.
56+
The inheritance type is configured by the package that defines the config file schema (not by the user).
57+
58+
The built-in inheritance types are:
59+
60+
| Type | Applies to | Behavior |
61+
|------|-----------|---------|
62+
| `replace` | any | Child value completely replaces the parent value (default for objects) |
63+
| `append` | arrays only | Child array elements are appended after parent array elements (default for arrays) |
64+
| `merge` | objects only | Shallow merge: child properties override parent properties, parent-only properties are kept |
65+
| `custom` | any | A custom merge function defined by the loader |
66+
67+
**Setting a property to `null`** always removes the parent's value, regardless of inheritance type.
68+
69+
### Per-property inline override: `$propertyName.inheritanceType`
70+
71+
If the schema allows it, you can override the inheritance type for an individual property directly in your
72+
config file using the `"$<propertyName>.inheritanceType"` annotation:
73+
74+
```json
75+
{
76+
"extends": "./base.json",
77+
78+
"$plugins.inheritanceType": "append",
79+
"plugins": [
80+
{ "pluginName": "my-plugin" }
81+
],
82+
83+
"$settings.inheritanceType": "merge",
84+
"settings": {
85+
"strict": true
86+
}
87+
}
88+
```
89+
90+
These annotations work at any nesting level — you can annotate a nested property the same way:
91+
92+
```json
93+
{
94+
"extends": "./base.json",
95+
96+
"$d.inheritanceType": "merge",
97+
"d": {
98+
"$g.inheritanceType": "append",
99+
"g": [{ "h": "B" }],
100+
101+
"$i.inheritanceType": "replace",
102+
"i": [{ "j": "B" }]
103+
}
104+
}
105+
```
106+
107+
The inline annotation takes precedence over any default set by the loader.
108+
109+
**Note:** `$propertyName.inheritanceType` is a loader-level annotation and is stripped from the final config
110+
object; it will not appear in the merged result or be validated by the schema.
111+
112+
### Path resolution
113+
114+
Properties that represent file system paths may be automatically resolved by the loader. The resolution
115+
method is determined by the loader's configuration, not the config file author. The original (unresolved)
116+
value is preserved and can be retrieved via the API.
117+
118+
---
119+
120+
## For API consumers (plugin/loader authors)
121+
122+
If you're writing a Heft plugin that needs to load a config file, use `ProjectConfigurationFile` or
123+
`NonProjectConfigurationFile`.
124+
125+
### `ProjectConfigurationFile`
126+
127+
Use this for config files stored at a known path relative to the project root, with optional rig support.
128+
129+
```typescript
130+
import { ProjectConfigurationFile, InheritanceType, PathResolutionMethod } from '@rushstack/heft-config-file';
131+
132+
interface IMyPluginConfig {
133+
outputFolder: string;
134+
plugins: string[];
135+
settings?: {
136+
strict: boolean;
137+
};
138+
extends?: string;
139+
}
140+
141+
const loader = new ProjectConfigurationFile<IMyPluginConfig>({
142+
// Path relative to the project root
143+
projectRelativeFilePath: 'config/my-plugin.json',
144+
145+
// Provide either jsonSchemaPath or jsonSchemaObject
146+
jsonSchemaPath: require.resolve('./schemas/my-plugin.schema.json'),
147+
148+
// Configure how properties merge when a config file uses "extends"
149+
propertyInheritance: {
150+
plugins: { inheritanceType: InheritanceType.append },
151+
settings: { inheritanceType: InheritanceType.merge }
152+
// Properties not listed here use the default for their type
153+
},
154+
155+
// Optionally override the default inheritance for all arrays or all objects
156+
propertyInheritanceDefaults: {
157+
array: { inheritanceType: InheritanceType.append }, // built-in default
158+
object: { inheritanceType: InheritanceType.replace } // built-in default
159+
},
160+
161+
// Automatically resolve path properties to absolute paths
162+
jsonPathMetadata: {
163+
'$.outputFolder': {
164+
pathResolutionMethod: PathResolutionMethod.resolvePathRelativeToConfigurationFile
165+
}
166+
}
167+
});
168+
169+
// Load config for a project (throws if not found)
170+
const config = loader.loadConfigurationFileForProject(terminal, projectPath);
171+
172+
// Load config with rig fallback
173+
const config = loader.loadConfigurationFileForProject(terminal, projectPath, rigConfig);
174+
175+
// Returns undefined instead of throwing if the file doesn't exist
176+
const config = loader.tryLoadConfigurationFileForProject(terminal, projectPath, rigConfig);
177+
178+
// Async variants are also available:
179+
// loader.loadConfigurationFileForProjectAsync(...)
180+
// loader.tryLoadConfigurationFileForProjectAsync(...)
181+
```
182+
183+
When a `rigConfig` is provided and the project does not have its own config file, the loader falls back to
184+
the same relative path inside the rig's profile folder.
185+
186+
### `NonProjectConfigurationFile`
187+
188+
Use this for config files at arbitrary absolute paths (not bound to a project root):
189+
190+
```typescript
191+
import { NonProjectConfigurationFile } from '@rushstack/heft-config-file';
192+
193+
const loader = new NonProjectConfigurationFile<IMyConfig>({
194+
jsonSchemaPath: '/path/to/schema.json'
195+
});
196+
197+
const config = loader.loadConfigurationFile(terminal, '/absolute/path/to/config.json');
198+
// Also: tryLoadConfigurationFile, loadConfigurationFileAsync, tryLoadConfigurationFileAsync
199+
```
200+
201+
### JSON schema
202+
203+
Supply either a file path or an inline object:
204+
205+
```typescript
206+
// From a file path
207+
{ jsonSchemaPath: require.resolve('./schemas/my-plugin.schema.json') }
208+
209+
// Inline
210+
{ jsonSchemaObject: { type: 'object', properties: { ... } } }
211+
```
212+
213+
Schema validation runs **after** all inheritance merging, so the schema describes the shape of the final
214+
merged result.
215+
216+
### Custom validation
217+
218+
For validation logic that JSON schema cannot express, supply a `customValidationFunction`:
219+
220+
```typescript
221+
const loader = new ProjectConfigurationFile<IMyPluginConfig>({
222+
projectRelativeFilePath: 'config/my-plugin.json',
223+
jsonSchemaPath: require.resolve('./schemas/my-plugin.schema.json'),
224+
225+
customValidationFunction: (configFile, configFilePath, terminal) => {
226+
if (configFile.outputFolder === configFile.inputFolder) {
227+
terminal.writeErrorLine('outputFolder and inputFolder must be different');
228+
return false;
229+
}
230+
return true;
231+
}
232+
});
233+
```
234+
235+
The function is called after schema validation. If it returns anything other than `true`, an error is thrown.
236+
The function may also throw its own error to provide a custom message.
237+
238+
### Path resolution
239+
240+
Use `jsonPathMetadata` to automatically resolve string properties that represent file system paths. Keys are
241+
[JSONPath](https://jsonpath.com/) expressions, so wildcards work for arrays and nested objects:
242+
243+
```typescript
244+
jsonPathMetadata: {
245+
// Resolve a specific property
246+
'$.outputFolder': {
247+
pathResolutionMethod: PathResolutionMethod.resolvePathRelativeToConfigurationFile
248+
},
249+
250+
// Resolve all "path" properties inside an array of objects
251+
'$.plugins.*.path': {
252+
pathResolutionMethod: PathResolutionMethod.resolvePathRelativeToConfigurationFile
253+
},
254+
255+
// Node.js module resolution (like require.resolve)
256+
'$.loaderPackage': {
257+
pathResolutionMethod: PathResolutionMethod.nodeResolve
258+
},
259+
260+
// Custom resolver
261+
'$.specialPath': {
262+
pathResolutionMethod: PathResolutionMethod.custom,
263+
customResolver: ({ propertyValue, configurationFilePath }) => {
264+
return myCustomResolve(propertyValue, configurationFilePath);
265+
}
266+
}
267+
}
268+
```
269+
270+
Available resolution methods:
271+
272+
| Method | Behavior |
273+
|--------|---------|
274+
| `resolvePathRelativeToConfigurationFile` | `path.resolve(configFileDir, value)` |
275+
| `resolvePathRelativeToProjectRoot` | `path.resolve(projectRoot, value)` |
276+
| `nodeResolve` | Node.js `require.resolve`-style resolution |
277+
| `custom` | Call your own resolver function |
278+
279+
### Inspecting source file and original values
280+
281+
After loading, you can query where objects came from and what their pre-resolution values were:
282+
283+
```typescript
284+
const config = loader.loadConfigurationFileForProject(terminal, projectPath);
285+
286+
// Which config file did this object come from?
287+
const sourceFile = loader.getObjectSourceFilePath(config);
288+
// e.g. "/my-project/config/my-plugin.json"
289+
290+
// What was the raw value of a property before path resolution?
291+
const originalValue = loader.getPropertyOriginalValue({
292+
parentObject: config,
293+
propertyName: 'outputFolder'
294+
});
295+
// e.g. "./dist" (before being resolved to an absolute path)
296+
```
297+
298+
These methods work on any object that was loaded as part of the config file (including nested objects).
299+
300+
### Custom inheritance functions
301+
302+
For cases where the built-in merge strategies aren't enough:
303+
304+
```typescript
305+
import { InheritanceType } from '@rushstack/heft-config-file';
306+
307+
const loader = new ProjectConfigurationFile<IMyConfig>({
308+
projectRelativeFilePath: 'config/my-plugin.json',
309+
jsonSchemaPath: require.resolve('./schemas/my-plugin.schema.json'),
310+
propertyInheritance: {
311+
myProperty: {
312+
inheritanceType: InheritanceType.custom,
313+
inheritanceFunction: (childValue, parentValue) => {
314+
// Merge logic here; return the combined result
315+
return { ...parentValue, ...childValue, extra: 'added' };
316+
}
317+
}
318+
}
319+
});
320+
```
321+
322+
The function receives `(childValue, parentValue)` and must return the merged result. It is not called if the
323+
child sets the property to `null` — in that case the property is simply deleted.
324+
325+
### Inheritance precedence
326+
327+
When a child config file is merged with its parent, the inheritance type for each property is resolved in
328+
this order (highest precedence first):
329+
330+
1. **Inline annotation** in the config file: `"$myProp.inheritanceType": "append"`
331+
2. **`propertyInheritance`** option passed to the loader constructor
332+
3. **`propertyInheritanceDefaults`** option (per type: `array` or `object`)
333+
4. **Built-in defaults**: `append` for arrays, `replace` for objects
334+
335+
### Testing
336+
337+
`TestUtilities.stripAnnotations` removes the internal tracking metadata from a loaded config object,
338+
which is useful when writing snapshot tests:
339+
340+
```typescript
341+
import { TestUtilities } from '@rushstack/heft-config-file';
342+
343+
const config = loader.loadConfigurationFileForProject(terminal, projectPath);
344+
expect(TestUtilities.stripAnnotations(config)).toMatchSnapshot();
345+
```

0 commit comments

Comments
 (0)