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
1214Heft 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