Skip to content

feat: allow config file to have config globs - #129

Draft
ruocco-l wants to merge 4 commits into
masterfrom
feat/config-globs
Draft

ruocco-l wants to merge 4 commits into
masterfrom
feat/config-globs

Conversation

@ruocco-l

@ruocco-l ruocco-l commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #128

Adds a globs config file mode: actors are declared once, and shared settings are applied to them through glob-matched configs entries.

  • configs needs at least one entry. Each entry is { match, set }:
    • match takes folderGlob and/or actorFullNameGlob (at least one). When both are given, both must match.
    • set takes tokenEnvVar and/or overrideActorContext (at least one).
  • Matching uses minimatch with default options.
  • No overriding: each setting has exactly one owner per actor. Parsing fails if two configs set the same key on an actor, or if a config sets a key the actor already defines, even when the values are equal. Arrays are not merged.
  • Every actor must end up with a tokenEnvVar, either on the actor or from a matching config.
  • All conflicts and missing tokens are reported together.

Known limitations, opinions welcome:

  • Folders are matched exactly as written, without normalization. A trailing slash (actors/foo/) or a root folder ("", .) may not match the glob you'd expect.
  • Dotfolders aren't matched, because minimatch's dot option is off.
  • Configs that match no actor are silently ignored.

@metalwarrior665 metalwarrior665 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks simpler than I thought :) I have just minor remarks, feel free to argue against them. Let's wait for Juan but this seems quite uncontroversial.

Comment thread bin/utils.ts Outdated
for (const [index, configEntry] of configs.entries()) {
const { folder, actorFullName } = configEntry as ActorGlobConfigEntry;

// TODO: Allow for combined filtering?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would allow this, don't think it is that confusing or dangerous. You simply filter once per folder and then per name. There might be legit use-cases for this

Comment thread bin/utils.ts Outdated
import { selectActors } from './actor-filtering.js';
import { isPathWithinScope } from './path-utils.js';
import type { ActorConfig, ActorConfigFile } from './types.js';
import type { ActorConfig, ActorConfigFile, ActorConfigFileEntry, ActorGlobConfigEntry } from './types.js';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These types don't match very well, one says "File", other doesn't.

btw we already have ActorConfig type which is basically the same thing, we should either unify them or derive one from the other. Are there cases where these will differ? If we are simply merging them then they should not differ. No need to solve that in this PR but sooner rather than later.

Comment thread bin/utils.ts Outdated

const validateGlobConfigEntries = (configs: unknown[]): ActorGlobConfigEntry[] => {
for (const [index, configEntry] of configs.entries()) {
const { folder, actorFullName } = configEntry as ActorGlobConfigEntry;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be zod parse I guess but I don't know if you can get such a nice errors from it, don't have experience

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't have much experience with zod but when I looked into it I remember that there was a lot of overhead to make it work. I don't think it's necessary, as long as the inputs here are somewhat limited in number

Comment thread bin/utils.ts Outdated

let overlay: Partial<ActorConfigFileEntry> = {};
for (const configEntry of [...matchingFolderConfigs, ...matchingActorFullNameConfigs]) {
const { folder: matchedFolder, actorFullName: matchedActorFullName, ...rest } = configEntry;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's think a bit if we shouldn't separate the matching fields folder, actorFullName vs the configs, it is a bit weird they are on the same level

Comment thread bin/utils.ts Outdated
);

let overlay: Partial<ActorConfigFileEntry> = {};
for (const configEntry of [...matchingFolderConfigs, ...matchingActorFullNameConfigs]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to support having both folder and name with AND logic, this would need to change

Comment thread bin/utils.ts Outdated
const matchingActorFullNameConfigs = configs.filter(
(configEntry) =>
configEntry.actorFullName !== undefined &&
typeof actorEntry.actorFullName === 'string' &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already validate this eariler and the type should be string | undefined now, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

332a437
We didn't, but now we do

@ruocco-l

Copy link
Copy Markdown
Contributor Author

In this PR I postponed the problem that would pose adding the notifier object. As of now, an object would be completely rewritten and it does not have a partial merging logic (e.g. every notifier would get the same token but each actor needs to get its own slack channel for test report). I gave it some thought and even though it's not necessary as of this PR would be merged, we want to add it soon so it makes sense to do it here, so that the diff is tidy.

@metalwarrior665 I'll do your requested changes first and then I'll try to add the deep object merging logic.
@JuanGalilea I'll make this PR a draft until it's ready with the changes I mentioned above, I'll ping you when it's ready to review again.

@ruocco-l
ruocco-l marked this pull request as draft September 13, 2026 14:17
@ruocco-l
ruocco-l marked this pull request as ready for review September 14, 2026 12:35
Comment thread README.md Outdated
Comment on lines +69 to +71
1. entries matching on `folder` alone
2. entries matching on `actorFullName` alone
3. entries matching on both `folder` and `actorFullName` together

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be more complicated than just order based

Comment thread bin/utils.ts Outdated
const validateGlobConfigEntries = (configs: unknown[]): ActorGlobConfigEntry[] => {
for (const [index, configEntry] of configs.entries()) {
const { folder, actorFullName } = configEntry as ActorGlobConfigEntry;
const { match, set } = configEntry as ActorGlobConfigEntry;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would give this 2nd thought; maybe it is unnecessarily nested now just for the 2 matcher fields.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I quite like the assertiveness of match and set

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let's see what @JuanGalilea thinks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i was thinking of something like Record<glob, config>.
ohhhh your matcher can do both folders and names, makes sense actually.

Yeah, match and set do it for me.

Comment thread README.md Outdated
3. entries matching on both `folder` and `actorFullName` together
4. the actor's own literal entry in `actors[]`

Within a single tier, if more than one entry matches, only the _last_ one (array order) applies — earlier same-tier matches are dropped entirely, not merged in. Across tiers, results are deep-merged from lowest to highest precedence: a higher tier wins on any field it sets, but a field it doesn't set is inherited from a lower tier rather than being lost. Object-valued fields merge key by key; array-valued fields (e.g. `overrideActorContext`) keep the higher tier's array intact and append only the lower tier's entries that aren't already present, preserving the higher tier's order.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too complex. I would drop the notion of tiers completely. 1-3 can just be taken in order (any use-case for the current way?). And the point 4 can stay undocumented (for backward compat) and we just append it at the end of the glob array so it will override any conflicting fields before.

only the last one (array order) applies — earlier same-tier matches are dropped entirely, not merged in

I don't get this. I thought the whole goal is to merge here. E.g. one glob sets token on Actor X, another glob sets slack on Actor X etc., these are merged in the config object. Theoretically, we could only merge the top level config fields but I'm not sure about this

array-valued fields (e.g. overrideActorContext) keep the higher tier's array intact

I think we should not merge arrays. They shouldn't include more nested objects so there won't be need for any deep merging. Users can just retype the whole array in the later glob.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I will just do it by order, it's much easier to understand, you are right, I was just hooked on the "more specific" idea, especially with the filter combo.

I would concede the arrays overwrite (I don't see a particular use for now, other than avoid typing a specific folder that goes into multiple overrideActorContext) but I would keep the deep merge and not to just top level. You could have:

"configs": [
    { "match": { "folder": "**" }, "set": { "notifiers": { "slack": { "tokenEnvVar": "SLACK_TOKEN" } } } },
    { "match": { "folder": "actors/*" }, "set": { "notifiers": { "slack": { "targets": { "release-report": { "dev": "#releases" } } } } } },
    { "match": { "actorFullName": "myteam/web-scraper" }, "set": { "notifiers": { "slack": { "targets": { "test-report": "#ws-tests" } } } } }
]

and this will allow to avoid resetting the token every time and still give flexibility on the other options.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree

@metalwarrior665 metalwarrior665 mentioned this pull request Sep 14, 2026
3 tasks
Comment thread bin/utils.ts Outdated
return undefined;
};

const validateGlobConfigEntries = (configs: unknown[]): ActorGlobConfigEntry[] => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole function is just one zod schema 👀
It would probably take wayy less mental overhead and probably be parsed better 😅

Comment thread bin/utils.ts Outdated
// matching combined (folder AND actorFullName) entries, the actor's own literal entry. The tier
// winners deep-merge in that ascending order, so a field one tier doesn't set is inherited from
// a lower tier instead of being clobbered.
export const mergeGlobConfigs = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fuck this is complicated, are the tiers even necessary?

Also just had a long chat with some guys from the team and now I'm pretty sure this config scheme is not the way to go 😭.

I'll think a bit more about this today and get back to you

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We agreed to remove the tiers here. But happy to hear the arguments from the outside.

@ruocco-l
ruocco-l changed the base branch from master to feat/mode-grouped September 23, 2026 11:31

@JuanGalilea JuanGalilea left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code is too inlined for my taste

Also i have some comments on functionalities.

Seems like the groundwork for modes payed off, since it seems we can add these pretty easily

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add one test here parsing one with globs, so as to test that the whole thing is doing what its supposed to.
Its slightly redundant but still valuable imo.

I did 1 with grouped somewhere here.

z.object({
folder: z.string(),
actorFullName: z.string().regex(ACTOR_FULL_NAME_REGEX),
tokenEnvVar: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THEY CAN HAVE STUFF HERE?
whats the precedence?

Also, nice for legacy compat, but slightly worrying

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no precedence, if the configs try to set something that it is already there, it errors. It's why there is the owner that check who set the key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, saw it in the implementation. I think its good.

folderGlob: z.string().optional(),
actorFullNameGlob: z.string().optional(),
})
.refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one glob is required.'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i hate that this is the zod standard for this kind of thing.

const errors: string[] = [];

const resolved = body.actors.flatMap((actor): ResolvedActorConfig[] => {
const result = { ...actor };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

structuredClone might be safer if we ever get some nesting on the actor fields

export const GLOBS_PARSER = defineStrategy(CONFIG_FILE_STRATEGY.GLOBS, schema, (body: GlobsConfig) => {
const errors: string[] = [];

const resolved = body.actors.flatMap((actor): ResolvedActorConfig[] => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

holy function nesting batman!
inline function that inlines a function into flatmap? 👀
The flatmapped one might benefit of some testing without all the other fluff man

);
} else {
errors.push(
`Actor "${actor.actorFullName}": "${key}" is set by both configs[${owner}] and configs[${index}].`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having configs be an array of rules make these errors very painful to write 😭. Maybe a shortened "selector" view could be nice.

errors.push(
`Actor "${actor.actorFullName}" has no tokenEnvVar: set it on the actor or through a matching config.`,
);
return [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some Either return could be beneficial here, with the added bonus of no mutation of error (not very functional-pilled my man 😂 )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tracking useless globs (they are not owner for no key) might be beneficial to steer people out of dumb configurations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

splitting the matching function into a pure function taking actor + globConfigs[] might be good for testability

tokenEnvVar: z.string().optional(),
overrideActorContext: z.array(z.string()).optional(),
})
.refine((data) => Object.keys(data).length > 0, 'Invalid input: At least one setting is required.'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should do a non-empty object refining function as a zod utility.
This call is strike 3 on the duplication side (i did it once and you have 2 more)

@ruocco-l
ruocco-l marked this pull request as draft September 23, 2026 13:49
Base automatically changed from feat/mode-grouped to master September 30, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create globs for config file

4 participants